diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.common.js b/app/assets/javascripts/mind_map/jsmind/jsmind.common.js
new file mode 100644
index 0000000..0a13da6
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.common.js
@@ -0,0 +1,78 @@
+export const __version__ = '1.0.0'
+export const __author__ = 'author'
+
+if (typeof String.prototype.startsWith != 'function') {
+ String.prototype.startsWith = function (p) {
+ return this.slice(0, p.length) === p
+ }
+}
+
+export const Direction = {
+ left: -1,
+ center: 0,
+ right: 1,
+ of: function (dir) {
+ if (!dir || dir === -1 || dir === 0 || dir === 1) {
+ return dir
+ }
+ if (dir === '-1' || dir === '0' || dir === '1') {
+ return parseInt(dir)
+ }
+ if (dir.toLowerCase() === 'left') {
+ return this.left
+ }
+ if (dir.toLowerCase() === 'right') {
+ return this.right
+ }
+ if (dir.toLowerCase() === 'center') {
+ return this.center
+ }
+ },
+}
+export const EventType = { show: 1, resize: 2, edit: 3, select: 4 }
+export const Key = { meta: 1 << 13, ctrl: 1 << 12, alt: 1 << 11, shift: 1 << 10 }
+export const LogLevel = { debug: 1, info: 2, warn: 3, error: 4, disable: 9 }
+
+// an noop function define
+var _noop = function () {}
+export let logger =
+ typeof console === 'undefined'
+ ? {
+ level: _noop,
+ log: _noop,
+ debug: _noop,
+ info: _noop,
+ warn: _noop,
+ error: _noop,
+ }
+ : {
+ level: setup_logger_level,
+ log: console.log,
+ debug: console.debug,
+ info: console.info,
+ warn: console.warn,
+ error: console.error,
+ }
+
+function setup_logger_level(log_level) {
+ if (log_level > LogLevel.debug) {
+ logger.debug = _noop
+ } else {
+ logger.debug = console.debug
+ }
+ if (log_level > LogLevel.info) {
+ logger.info = _noop
+ } else {
+ logger.info = console.info
+ }
+ if (log_level > LogLevel.warn) {
+ logger.warn = _noop
+ } else {
+ logger.warn = console.warn
+ }
+ if (log_level > LogLevel.error) {
+ logger.error = _noop
+ } else {
+ logger.error = console.error
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.css b/app/assets/javascripts/mind_map/jsmind/jsmind.css
new file mode 100644
index 0000000..427fedf
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.css
@@ -0,0 +1,400 @@
+/* important section */
+.jsmind-inner {
+ position: relative;
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+ outline: none;
+} /*box-shadow:0 0 2px #000;*/
+.jsmind-inner {
+ moz-user-select: -moz-none;
+ -moz-user-select: none;
+ -o-user-select: none;
+ -khtml-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.jsmind-inner canvas {
+ position: absolute;
+}
+
+/* z-index:1 */
+svg.jsmind {
+ position: absolute;
+ z-index: 1;
+}
+canvas.jsmind {
+ position: absolute;
+ z-index: 1;
+}
+
+/* z-index:2 */
+jmnodes {
+ position: absolute;
+ z-index: 2;
+ background-color: rgba(0, 0, 0, 0);
+} /*background color is necessary*/
+jmnode {
+ position: absolute;
+ cursor: default;
+ max-width: 400px;
+}
+jmexpander {
+ position: absolute;
+ width: 11px;
+ height: 11px;
+ display: block;
+ overflow: hidden;
+ line-height: 12px;
+ font-size: 10px;
+ text-align: center;
+ border-radius: 6px;
+ border-width: 1px;
+ border-style: solid;
+ cursor: pointer;
+}
+
+.jmnode-overflow-wrap jmnodes {
+ min-width: 420px;
+}
+
+.jmnode-overflow-hidden jmnode {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+/* default theme */
+jmnode {
+ padding: 10px;
+ background-color: #fff;
+ color: #333;
+ border-radius: 5px;
+ box-shadow: 1px 1px 1px #666;
+ font: 16px/1.125 Verdana, Arial, Helvetica, sans-serif;
+}
+jmnode:hover {
+ box-shadow: 2px 2px 8px #000;
+ background-color: #ebebeb;
+ color: #333;
+}
+jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+ box-shadow: 2px 2px 8px #000;
+}
+jmnode.root {
+ font-size: 24px;
+}
+jmexpander {
+ border-color: gray;
+}
+jmexpander:hover {
+ border-color: #000;
+}
+
+@media screen and (max-device-width: 1024px) {
+ jmnode {
+ padding: 5px;
+ border-radius: 3px;
+ font-size: 14px;
+ }
+ jmnode.root {
+ font-size: 21px;
+ }
+}
+/* primary theme */
+jmnodes.theme-primary jmnode {
+ background-color: #428bca;
+ color: #fff;
+ border-color: #357ebd;
+}
+jmnodes.theme-primary jmnode:hover {
+ background-color: #3276b1;
+ border-color: #285e8e;
+}
+jmnodes.theme-primary jmnode.selected {
+ background-color: #f1c40f;
+ color: #fff;
+}
+jmnodes.theme-primary jmnode.root {
+}
+jmnodes.theme-primary jmexpander {
+}
+jmnodes.theme-primary jmexpander:hover {
+}
+
+/* warning theme */
+jmnodes.theme-warning jmnode {
+ background-color: #f0ad4e;
+ border-color: #eea236;
+ color: #fff;
+}
+jmnodes.theme-warning jmnode:hover {
+ background-color: #ed9c28;
+ border-color: #d58512;
+}
+jmnodes.theme-warning jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-warning jmnode.root {
+}
+jmnodes.theme-warning jmexpander {
+}
+jmnodes.theme-warning jmexpander:hover {
+}
+
+/* danger theme */
+jmnodes.theme-danger jmnode {
+ background-color: #d9534f;
+ border-color: #d43f3a;
+ color: #fff;
+}
+jmnodes.theme-danger jmnode:hover {
+ background-color: #d2322d;
+ border-color: #ac2925;
+}
+jmnodes.theme-danger jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-danger jmnode.root {
+}
+jmnodes.theme-danger jmexpander {
+}
+jmnodes.theme-danger jmexpander:hover {
+}
+
+/* success theme */
+jmnodes.theme-success jmnode {
+ background-color: #5cb85c;
+ border-color: #4cae4c;
+ color: #fff;
+}
+jmnodes.theme-success jmnode:hover {
+ background-color: #47a447;
+ border-color: #398439;
+}
+jmnodes.theme-success jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-success jmnode.root {
+}
+jmnodes.theme-success jmexpander {
+}
+jmnodes.theme-success jmexpander:hover {
+}
+
+/* info theme */
+jmnodes.theme-info jmnode {
+ background-color: #5dc0de;
+ border-color: #46b8da;
+ color: #fff;
+}
+jmnodes.theme-info jmnode:hover {
+ background-color: #39b3d7;
+ border-color: #269abc;
+}
+jmnodes.theme-info jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-info jmnode.root {
+}
+jmnodes.theme-info jmexpander {
+}
+jmnodes.theme-info jmexpander:hover {
+}
+
+/* greensea theme */
+jmnodes.theme-greensea jmnode {
+ background-color: #1abc9c;
+ color: #fff;
+}
+jmnodes.theme-greensea jmnode:hover {
+ background-color: #16a085;
+}
+jmnodes.theme-greensea jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-greensea jmnode.root {
+}
+jmnodes.theme-greensea jmexpander {
+}
+jmnodes.theme-greensea jmexpander:hover {
+}
+
+/* nephrite theme */
+jmnodes.theme-nephrite jmnode {
+ background-color: #2ecc71;
+ color: #fff;
+}
+jmnodes.theme-nephrite jmnode:hover {
+ background-color: #27ae60;
+}
+jmnodes.theme-nephrite jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-nephrite jmnode.root {
+}
+jmnodes.theme-nephrite jmexpander {
+}
+jmnodes.theme-nephrite jmexpander:hover {
+}
+
+/* belizehole theme */
+jmnodes.theme-belizehole jmnode {
+ background-color: #3498db;
+ color: #fff;
+}
+jmnodes.theme-belizehole jmnode:hover {
+ background-color: #2980b9;
+}
+jmnodes.theme-belizehole jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-belizehole jmnode.root {
+}
+jmnodes.theme-belizehole jmexpander {
+}
+jmnodes.theme-belizehole jmexpander:hover {
+}
+
+/* wisteria theme */
+jmnodes.theme-wisteria jmnode {
+ background-color: #9b59b6;
+ color: #fff;
+}
+jmnodes.theme-wisteria jmnode:hover {
+ background-color: #8e44ad;
+}
+jmnodes.theme-wisteria jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-wisteria jmnode.root {
+}
+jmnodes.theme-wisteria jmexpander {
+}
+jmnodes.theme-wisteria jmexpander:hover {
+}
+
+/* asphalt theme */
+jmnodes.theme-asphalt jmnode {
+ background-color: #34495e;
+ color: #fff;
+}
+jmnodes.theme-asphalt jmnode:hover {
+ background-color: #2c3e50;
+}
+jmnodes.theme-asphalt jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-asphalt jmnode.root {
+}
+jmnodes.theme-asphalt jmexpander {
+}
+jmnodes.theme-asphalt jmexpander:hover {
+}
+
+/* orange theme */
+jmnodes.theme-orange jmnode {
+ background-color: #f1c40f;
+ color: #fff;
+}
+jmnodes.theme-orange jmnode:hover {
+ background-color: #f39c12;
+}
+jmnodes.theme-orange jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-orange jmnode.root {
+}
+jmnodes.theme-orange jmexpander {
+}
+jmnodes.theme-orange jmexpander:hover {
+}
+
+/* pumpkin theme */
+jmnodes.theme-pumpkin jmnode {
+ background-color: #e67e22;
+ color: #fff;
+}
+jmnodes.theme-pumpkin jmnode:hover {
+ background-color: #d35400;
+}
+jmnodes.theme-pumpkin jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-pumpkin jmnode.root {
+}
+jmnodes.theme-pumpkin jmexpander {
+}
+jmnodes.theme-pumpkin jmexpander:hover {
+}
+
+/* pomegranate theme */
+jmnodes.theme-pomegranate jmnode {
+ background-color: #e74c3c;
+ color: #fff;
+}
+jmnodes.theme-pomegranate jmnode:hover {
+ background-color: #c0392b;
+}
+jmnodes.theme-pomegranate jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-pomegranate jmnode.root {
+}
+jmnodes.theme-pomegranate jmexpander {
+}
+jmnodes.theme-pomegranate jmexpander:hover {
+}
+
+/* clouds theme */
+jmnodes.theme-clouds jmnode {
+ background-color: #ecf0f1;
+ color: #333;
+}
+jmnodes.theme-clouds jmnode:hover {
+ background-color: #bdc3c7;
+}
+jmnodes.theme-clouds jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-clouds jmnode.root {
+}
+jmnodes.theme-clouds jmexpander {
+}
+jmnodes.theme-clouds jmexpander:hover {
+}
+
+/* asbestos theme */
+jmnodes.theme-asbestos jmnode {
+ background-color: #95a5a6;
+ color: #fff;
+}
+jmnodes.theme-asbestos jmnode:hover {
+ background-color: #7f8c8d;
+}
+jmnodes.theme-asbestos jmnode.selected {
+ background-color: #11f;
+ color: #fff;
+}
+jmnodes.theme-asbestos jmnode.root {
+}
+jmnodes.theme-asbestos jmexpander {
+}
+jmnodes.theme-asbestos jmexpander:hover {
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.data_provider.js b/app/assets/javascripts/mind_map/jsmind/jsmind.data_provider.js
new file mode 100644
index 0000000..ae9da1b
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.data_provider.js
@@ -0,0 +1,56 @@
+import { logger } from './jsmind.common.js'
+import { format } from './jsmind.format.js'
+
+export class DataProvider {
+ constructor(jm) {
+ this.jm = jm
+ }
+
+ init() {
+ logger.debug('data.init')
+ }
+ reset() {
+ logger.debug('data.reset')
+ }
+ load(mind_data) {
+ var df = null
+ var mind = null
+ if (typeof mind_data === 'object') {
+ if (!!mind_data.format) {
+ df = mind_data.format
+ } else {
+ df = 'node_tree'
+ }
+ } else {
+ df = 'freemind'
+ }
+
+ if (df == 'node_array') {
+ mind = format.node_array.get_mind(mind_data)
+ } else if (df == 'node_tree') {
+ mind = format.node_tree.get_mind(mind_data)
+ } else if (df == 'freemind') {
+ mind = format.freemind.get_mind(mind_data)
+ } else if (df == 'text') {
+ mind = format.text.get_mind(mind_data)
+ } else {
+ logger.warn('unsupported format')
+ }
+ return mind
+ }
+ get_data(data_format) {
+ var data = null
+ if (data_format == 'node_array') {
+ data = format.node_array.get_data(this.jm.mind)
+ } else if (data_format == 'node_tree') {
+ data = format.node_tree.get_data(this.jm.mind)
+ } else if (data_format == 'freemind') {
+ data = format.freemind.get_data(this.jm.mind)
+ } else if (data_format == 'text') {
+ data = format.text.get_data(this.jm.mind)
+ } else {
+ logger.error('unsupported ' + data_format + ' format')
+ }
+ return data
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.dom.js b/app/assets/javascripts/mind_map/jsmind/jsmind.dom.js
new file mode 100644
index 0000000..6a748c1
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.dom.js
@@ -0,0 +1,49 @@
+class Dom {
+ constructor(w) {
+ this.w = w
+ this.d = w.document
+ this.g = function (id) {
+ return this.d.getElementById(id)
+ }
+ this.c = function (tag) {
+ return this.d.createElement(tag)
+ }
+ this.t = function (n, t) {
+ if (n.hasChildNodes()) {
+ n.firstChild.nodeValue = t
+ } else {
+ n.appendChild(this.d.createTextNode(t))
+ }
+ }
+
+ this.h = function (n, t) {
+ if (t instanceof HTMLElement) {
+ n.innerHTML = ''
+ n.appendChild(t)
+ } else {
+ n.innerHTML = t
+ }
+ }
+ // detect isElement
+ this.i = function (el) {
+ return (
+ !!el &&
+ typeof el === 'object' &&
+ el.nodeType === 1 &&
+ typeof el.style === 'object' &&
+ typeof el.ownerDocument === 'object'
+ )
+ }
+
+ //target,eventType,handler
+ this.on = function (t, e, h) {
+ if (!!t.addEventListener) {
+ t.addEventListener(e, h, false)
+ } else {
+ t.attachEvent('on' + e, h)
+ }
+ }
+ }
+}
+
+export const $ = new Dom(window)
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.format.js b/app/assets/javascripts/mind_map/jsmind/jsmind.format.js
new file mode 100644
index 0000000..dbdb750
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.format.js
@@ -0,0 +1,533 @@
+import { __author__, __version__, logger, Direction } from './jsmind.common.js'
+import { Mind } from './jsmind.mind.js'
+import { Node } from './jsmind.node.js'
+import { util } from './jsmind.util.js'
+
+const DEFAULT_META = { name: 'jsMind', author: __author__, version: __version__ }
+
+export const format = {
+ node_tree: {
+ example: {
+ meta: DEFAULT_META,
+ format: 'node_tree',
+ data: { id: 'root', topic: 'jsMind node_tree example' },
+ },
+ get_mind: function (source) {
+ var df = format.node_tree
+ var mind = new Mind()
+ mind.name = source.meta.name
+ mind.author = source.meta.author
+ mind.version = source.meta.version
+ df._parse(mind, source.data)
+ return mind
+ },
+ get_data: function (mind) {
+ var df = format.node_tree
+ var json = {}
+ json.meta = {
+ name: mind.name,
+ author: mind.author,
+ version: mind.version,
+ }
+ json.format = 'node_tree'
+ json.data = df._build_node(mind.root)
+ return json
+ },
+
+ _parse: function (mind, node_root) {
+ var df = format.node_tree
+ var data = df._extract_data(node_root)
+ mind.set_root(node_root.id, node_root.topic, data)
+ if ('children' in node_root) {
+ var children = node_root.children
+ for (var i = 0; i < children.length; i++) {
+ df._extract_subnode(mind, mind.root, children[i])
+ }
+ }
+ },
+
+ _extract_data: function (node_json) {
+ var data = {}
+ for (var k in node_json) {
+ if (
+ k == 'id' ||
+ k == 'topic' ||
+ k == 'children' ||
+ k == 'direction' ||
+ k == 'expanded'
+ ) {
+ continue
+ }
+ data[k] = node_json[k]
+ }
+ return data
+ },
+
+ _extract_subnode: function (mind, node_parent, node_json) {
+ var df = format.node_tree
+ var data = df._extract_data(node_json)
+ var d = null
+ if (node_parent.isroot) {
+ d = node_json.direction == 'left' ? Direction.left : Direction.right
+ }
+ var node = mind.add_node(
+ node_parent,
+ node_json.id,
+ node_json.topic,
+ data,
+ d,
+ node_json.expanded
+ )
+ if (!!node_json['children']) {
+ var children = node_json.children
+ for (var i = 0; i < children.length; i++) {
+ df._extract_subnode(mind, node, children[i])
+ }
+ }
+ },
+
+ _build_node: function (node) {
+ var df = format.node_tree
+ if (!(node instanceof Node)) {
+ return
+ }
+ var o = {
+ id: node.id,
+ topic: node.topic,
+ expanded: node.expanded,
+ }
+ if (!!node.parent && node.parent.isroot) {
+ o.direction = node.direction == Direction.left ? 'left' : 'right'
+ }
+ if (node.data != null) {
+ var node_data = node.data
+ for (var k in node_data) {
+ o[k] = node_data[k]
+ }
+ }
+ var children = node.children
+ if (children.length > 0) {
+ o.children = []
+ for (var i = 0; i < children.length; i++) {
+ o.children.push(df._build_node(children[i]))
+ }
+ }
+ return o
+ },
+ },
+
+ node_array: {
+ example: {
+ meta: DEFAULT_META,
+ format: 'node_array',
+ data: [{ id: 'root', topic: 'jsMind node_array example', isroot: true }],
+ },
+
+ get_mind: function (source) {
+ var df = format.node_array
+ var mind = new Mind()
+ mind.name = source.meta.name
+ mind.author = source.meta.author
+ mind.version = source.meta.version
+ df._parse(mind, source.data)
+ return mind
+ },
+
+ get_data: function (mind) {
+ var df = format.node_array
+ var json = {}
+ json.meta = {
+ name: mind.name,
+ author: mind.author,
+ version: mind.version,
+ }
+ json.format = 'node_array'
+ json.data = []
+ df._array(mind, json.data)
+ return json
+ },
+
+ _parse: function (mind, node_array) {
+ var df = format.node_array
+ var nodes = node_array.slice(0)
+ // reverse array for improving looping performance
+ nodes.reverse()
+ var root_node = df._extract_root(mind, nodes)
+ if (!!root_node) {
+ df._extract_subnode(mind, root_node, nodes)
+ } else {
+ logger.error('root node can not be found')
+ }
+ },
+
+ _extract_root: function (mind, node_array) {
+ var df = format.node_array
+ var i = node_array.length
+ while (i--) {
+ if ('isroot' in node_array[i] && node_array[i].isroot) {
+ var root_json = node_array[i]
+ var data = df._extract_data(root_json)
+ var node = mind.set_root(root_json.id, root_json.topic, data)
+ node_array.splice(i, 1)
+ return node
+ }
+ }
+ return null
+ },
+
+ _extract_subnode: function (mind, parent_node, node_array) {
+ var df = format.node_array
+ var i = node_array.length
+ var node_json = null
+ var data = null
+ var extract_count = 0
+ while (i--) {
+ node_json = node_array[i]
+ if (node_json.parentid == parent_node.id) {
+ data = df._extract_data(node_json)
+ var d = null
+ var node_direction = node_json.direction
+ if (!!node_direction) {
+ d = node_direction == 'left' ? Direction.left : Direction.right
+ }
+ var node = mind.add_node(
+ parent_node,
+ node_json.id,
+ node_json.topic,
+ data,
+ d,
+ node_json.expanded
+ )
+ node_array.splice(i, 1)
+ extract_count++
+ var sub_extract_count = df._extract_subnode(mind, node, node_array)
+ if (sub_extract_count > 0) {
+ // reset loop index after extract subordinate node
+ i = node_array.length
+ extract_count += sub_extract_count
+ }
+ }
+ }
+ return extract_count
+ },
+
+ _extract_data: function (node_json) {
+ var data = {}
+ for (var k in node_json) {
+ if (
+ k == 'id' ||
+ k == 'topic' ||
+ k == 'parentid' ||
+ k == 'isroot' ||
+ k == 'direction' ||
+ k == 'expanded'
+ ) {
+ continue
+ }
+ data[k] = node_json[k]
+ }
+ return data
+ },
+
+ _array: function (mind, node_array) {
+ var df = format.node_array
+ df._array_node(mind.root, node_array)
+ },
+
+ _array_node: function (node, node_array) {
+ var df = format.node_array
+ if (!(node instanceof Node)) {
+ return
+ }
+ var o = {
+ id: node.id,
+ topic: node.topic,
+ expanded: node.expanded,
+ }
+ if (!!node.parent) {
+ o.parentid = node.parent.id
+ }
+ if (node.isroot) {
+ o.isroot = true
+ }
+ if (!!node.parent && node.parent.isroot) {
+ o.direction = node.direction == Direction.left ? 'left' : 'right'
+ }
+ if (node.data != null) {
+ var node_data = node.data
+ for (var k in node_data) {
+ o[k] = node_data[k]
+ }
+ }
+ node_array.push(o)
+ var ci = node.children.length
+ for (var i = 0; i < ci; i++) {
+ df._array_node(node.children[i], node_array)
+ }
+ },
+ },
+
+ freemind: {
+ example: {
+ meta: DEFAULT_META,
+ format: 'freemind',
+ data: ' ',
+ },
+ get_mind: function (source) {
+ var df = format.freemind
+ var mind = new Mind()
+ mind.name = source.meta.name
+ mind.author = source.meta.author
+ mind.version = source.meta.version
+ var xml = source.data
+ var xml_doc = df._parse_xml(xml)
+ var xml_root = df._find_root(xml_doc)
+ df._load_node(mind, null, xml_root)
+ return mind
+ },
+
+ get_data: function (mind) {
+ var df = format.freemind
+ var json = {}
+ json.meta = {
+ name: mind.name,
+ author: mind.author,
+ version: mind.version,
+ }
+ json.format = 'freemind'
+ var xml_lines = []
+ xml_lines.push('')
+ df._build_map(mind.root, xml_lines)
+ xml_lines.push(' ')
+ json.data = xml_lines.join('')
+ return json
+ },
+
+ _parse_xml: function (xml) {
+ var xml_doc = null
+ if (window.DOMParser) {
+ var parser = new DOMParser()
+ xml_doc = parser.parseFromString(xml, 'text/xml')
+ } else {
+ // Internet Explorer
+ xml_doc = new ActiveXObject('Microsoft.XMLDOM')
+ xml_doc.async = false
+ xml_doc.loadXML(xml)
+ }
+ return xml_doc
+ },
+
+ _find_root: function (xml_doc) {
+ var nodes = xml_doc.childNodes
+ var node = null
+ var root = null
+ var n = null
+ for (var i = 0; i < nodes.length; i++) {
+ n = nodes[i]
+ if (n.nodeType == 1 && n.tagName == 'map') {
+ node = n
+ break
+ }
+ }
+ if (!!node) {
+ var ns = node.childNodes
+ node = null
+ for (var i = 0; i < ns.length; i++) {
+ n = ns[i]
+ if (n.nodeType == 1 && n.tagName == 'node') {
+ node = n
+ break
+ }
+ }
+ }
+ return node
+ },
+
+ _load_node: function (mind, parent_node, xml_node) {
+ var df = format.freemind
+ var node_id = xml_node.getAttribute('ID')
+ var node_topic = xml_node.getAttribute('TEXT')
+ var node_folded = xml_node.getAttribute('FOLDED')
+ // look for richcontent
+ if (node_topic == null) {
+ var topic_children = xml_node.childNodes
+ var topic_child = null
+ for (var i = 0; i < topic_children.length; i++) {
+ topic_child = topic_children[i]
+ if (topic_child.nodeType == 1 && topic_child.tagName === 'richcontent') {
+ node_topic = topic_child.textContent
+ break
+ }
+ }
+ }
+ var node_data = df._load_attributes(xml_node)
+ var node_expanded =
+ 'expanded' in node_data ? node_data.expanded == 'true' : node_folded != 'true'
+ delete node_data.expanded
+
+ var node_position = xml_node.getAttribute('POSITION')
+ var node_direction = null
+ if (!!node_position) {
+ node_direction = node_position == 'left' ? Direction.left : Direction.right
+ }
+ var node = null
+ if (!!parent_node) {
+ node = mind.add_node(
+ parent_node,
+ node_id,
+ node_topic,
+ node_data,
+ node_direction,
+ node_expanded
+ )
+ } else {
+ node = mind.set_root(node_id, node_topic, node_data)
+ }
+ var children = xml_node.childNodes
+ var child = null
+ for (var i = 0; i < children.length; i++) {
+ child = children[i]
+ if (child.nodeType == 1 && child.tagName == 'node') {
+ df._load_node(mind, node, child)
+ }
+ }
+ },
+
+ _load_attributes: function (xml_node) {
+ var children = xml_node.childNodes
+ var attr = null
+ var attr_data = {}
+ for (var i = 0; i < children.length; i++) {
+ attr = children[i]
+ if (attr.nodeType == 1 && attr.tagName === 'attribute') {
+ attr_data[attr.getAttribute('NAME')] = attr.getAttribute('VALUE')
+ }
+ }
+ return attr_data
+ },
+
+ _build_map: function (node, xml_lines) {
+ var df = format.freemind
+ var pos = null
+ if (!!node.parent && node.parent.isroot) {
+ pos = node.direction === Direction.left ? 'left' : 'right'
+ }
+ xml_lines.push('')
+
+ // for attributes
+ var node_data = node.data
+ if (node_data != null) {
+ for (var k in node_data) {
+ xml_lines.push(' ')
+ }
+ }
+
+ // for children
+ var children = node.children
+ for (var i = 0; i < children.length; i++) {
+ df._build_map(children[i], xml_lines)
+ }
+
+ xml_lines.push(' ')
+ },
+
+ _escape: function (text) {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''')
+ .replace(/"/g, '"')
+ },
+ },
+ text: {
+ example: {
+ meta: DEFAULT_META,
+ format: 'text',
+ data: 'jsMind text example\n node1\n node1-sub\n node1-sub\n node2',
+ },
+ _line_regex: /\s*/,
+ get_mind: function (source) {
+ var df = format.text
+ var mind = new Mind()
+ mind.name = source.meta.name
+ mind.author = source.meta.author
+ mind.version = source.meta.version
+ var lines = source.data.split(/\n|\r/)
+ df._fill_nodes(mind, lines, 0, 0)
+ return mind
+ },
+
+ _fill_nodes: function (mind, lines) {
+ let node_path = []
+ let i = 0
+ while (i < lines.length) {
+ let line = lines[i]
+ let level = line.match(/\s*/)[0].length
+ let topic = line.substr(level)
+
+ if (level == 0 && node_path.length > 0) {
+ log.error('more than 1 root node was found: ' + topic)
+ return
+ }
+ if (level > node_path.length) {
+ log.error('a suspended node was found: ' + topic)
+ return
+ }
+ let diff = node_path.length - level
+ while (diff--) {
+ node_path.pop()
+ }
+
+ if (level == 0 && node_path.length == 0) {
+ let node = mind.set_root(util.uuid.newid(), topic)
+ node_path.push(node)
+ } else {
+ let node = mind.add_node(
+ node_path[level - 1],
+ util.uuid.newid(),
+ topic,
+ {},
+ null
+ )
+ node_path.push(node)
+ }
+ i++
+ }
+ node_path.length = 0
+ },
+
+ get_data: function (mind) {
+ var df = format.text
+ var json = {}
+ json.meta = {
+ name: mind.name,
+ author: mind.author,
+ version: mind.version,
+ }
+ json.format = 'text'
+ let lines = []
+ df._build_lines(lines, [mind.root], 0)
+ json.data = lines.join('\n')
+ return json
+ },
+
+ _build_lines: function (lines, nodes, level) {
+ let prefix = new Array(level + 1).join(' ')
+ for (let node of nodes) {
+ lines.push(prefix + node.topic)
+ if (!!node.children) {
+ format.text._build_lines(lines, node.children, level + 1)
+ }
+ }
+ },
+ },
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.graph.js b/app/assets/javascripts/mind_map/jsmind/jsmind.graph.js
new file mode 100644
index 0000000..47313c2
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.graph.js
@@ -0,0 +1,179 @@
+import { $ } from './jsmind.dom.js'
+import { logger } from './jsmind.common.js'
+
+class SvgGraph {
+ constructor(view) {
+ this.view = view
+ this.opts = view.opts
+ this.e_svg = SvgGraph.c('svg')
+ this.e_svg.setAttribute('class', 'jsmind')
+ this.size = { w: 0, h: 0 }
+ this.lines = []
+ this.line_drawing = {
+ straight: this._line_to,
+ curved: this._bezier_to,
+ }
+ this.init_line_render()
+ }
+ static c(tag) {
+ return $.d.createElementNS('http://www.w3.org/2000/svg', tag)
+ }
+ init_line_render() {
+ if (typeof this.opts.custom_line_render === 'function') {
+ this.drawing = (path, x1, y1, x2, y2) => {
+ try {
+ this.opts.custom_line_render.call(this, {
+ ctx: path,
+ start_point: { x: x1, y: y1 },
+ end_point: { x: x2, y: y2 },
+ })
+ } catch (e) {
+ logger.error('custom line renderer error: ', e)
+ }
+ }
+ } else {
+ this.drawing = this.line_drawing[this.opts.line_style] || this.line_drawing.curved
+ }
+ }
+ element() {
+ return this.e_svg
+ }
+ set_size(w, h) {
+ this.size.w = w
+ this.size.h = h
+ this.e_svg.setAttribute('width', w)
+ this.e_svg.setAttribute('height', h)
+ }
+ clear() {
+ var len = this.lines.length
+ while (len--) {
+ this.e_svg.removeChild(this.lines[len])
+ }
+ this.lines.length = 0
+ }
+ draw_line(pout, pin, offset, color) {
+ var line = SvgGraph.c('path')
+ line.setAttribute('stroke', color || this.opts.line_color)
+ line.setAttribute('stroke-width', this.opts.line_width)
+ line.setAttribute('fill', 'transparent')
+ this.lines.push(line)
+ this.e_svg.appendChild(line)
+ this.drawing(line, pin.x + offset.x, pin.y + offset.y, pout.x + offset.x, pout.y + offset.y)
+ }
+
+ copy_to(dest_canvas_ctx, callback) {
+ var img = new Image()
+ img.onload = function () {
+ dest_canvas_ctx.drawImage(img, 0, 0)
+ !!callback && callback()
+ }
+ img.src =
+ 'data:image/svg+xml;base64,' + btoa(new XMLSerializer().serializeToString(this.e_svg))
+ }
+ _bezier_to(path, x1, y1, x2, y2) {
+ path.setAttribute(
+ 'd',
+ 'M ' +
+ x1 +
+ ' ' +
+ y1 +
+ ' C ' +
+ (x1 + ((x2 - x1) * 2) / 3) +
+ ' ' +
+ y1 +
+ ', ' +
+ x1 +
+ ' ' +
+ y2 +
+ ', ' +
+ x2 +
+ ' ' +
+ y2
+ )
+ }
+ _line_to(path, x1, y1, x2, y2) {
+ path.setAttribute('d', 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2)
+ }
+}
+
+class CanvasGraph {
+ constructor(view) {
+ this.opts = view.opts
+ this.e_canvas = $.c('canvas')
+ this.e_canvas.className = 'jsmind'
+ this.canvas_ctx = this.e_canvas.getContext('2d')
+ this.size = { w: 0, h: 0 }
+ this.line_drawing = {
+ straight: this._line_to,
+ curved: this._bezier_to,
+ }
+ this.dpr = view.device_pixel_ratio
+ this.init_line_render()
+ }
+ init_line_render() {
+ if (typeof this.opts.custom_line_render === 'function') {
+ this.drawing = (ctx, x1, y1, x2, y2) => {
+ try {
+ this.opts.custom_line_render.call(this, {
+ ctx,
+ start_point: { x: x1, y: y1 },
+ end_point: { x: x2, y: y2 },
+ })
+ } catch (e) {
+ logger.error('custom line render error: ', e)
+ }
+ }
+ } else {
+ this.drawing = this.line_drawing[this.opts.line_style] || this.line_drawing.curved
+ }
+ }
+ element() {
+ return this.e_canvas
+ }
+ set_size(w, h) {
+ this.size.w = w
+ this.size.h = h
+ if (this.e_canvas.width && this.e_canvas.height && this.canvas_ctx.scale) {
+ this.e_canvas.width = w * this.dpr
+ this.e_canvas.height = h * this.dpr
+
+ this.e_canvas.style.width = w + 'px'
+ this.e_canvas.style.height = h + 'px'
+ this.canvas_ctx.scale(this.dpr, this.dpr)
+ } else {
+ this.e_canvas.width = w
+ this.e_canvas.height = h
+ }
+ }
+
+ clear() {
+ this.canvas_ctx.clearRect(0, 0, this.size.w, this.size.h)
+ }
+ draw_line(pout, pin, offset, color) {
+ var ctx = this.canvas_ctx
+ ctx.strokeStyle = color || this.opts.line_color
+ ctx.lineWidth = this.opts.line_width
+ ctx.lineCap = 'round'
+ this.drawing(ctx, pin.x + offset.x, pin.y + offset.y, pout.x + offset.x, pout.y + offset.y)
+ }
+ copy_to(dest_canvas_ctx, callback) {
+ dest_canvas_ctx.drawImage(this.e_canvas, 0, 0, this.size.w, this.size.h)
+ !!callback && callback()
+ }
+ _bezier_to(ctx, x1, y1, x2, y2) {
+ ctx.beginPath()
+ ctx.moveTo(x1, y1)
+ ctx.bezierCurveTo(x1 + ((x2 - x1) * 2) / 3, y1, x1, y2, x2, y2)
+ ctx.stroke()
+ }
+ _line_to(ctx, x1, y1, x2, y2) {
+ ctx.beginPath()
+ ctx.moveTo(x1, y1)
+ ctx.lineTo(x2, y2)
+ ctx.stroke()
+ }
+}
+
+export function init_graph(view, engine) {
+ return engine.toLowerCase() === 'svg' ? new SvgGraph(view) : new CanvasGraph(view)
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.js b/app/assets/javascripts/mind_map/jsmind/jsmind.js
new file mode 100644
index 0000000..5f5799d
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.js
@@ -0,0 +1,704 @@
+import { __version__, logger, EventType, Direction, LogLevel } from './jsmind.common.js'
+import { merge_option } from './jsmind.option.js'
+import { Mind } from './jsmind.mind.js'
+import { Node } from './jsmind.node.js'
+import { DataProvider } from './jsmind.data_provider.js'
+import { LayoutProvider } from './jsmind.layout_provider.js'
+import { ViewProvider } from './jsmind.view_provider.js'
+import { ShortcutProvider } from './jsmind.shortcut_provider.js'
+import { Plugin, register as _register_plugin, apply as apply_plugins } from './jsmind.plugin.js'
+import { format } from './jsmind.format.js'
+import { $ } from './jsmind.dom.js'
+import { util as _util } from './jsmind.util.js'
+
+export default class jsMind {
+ static mind = Mind
+ static node = Node
+ static direction = Direction
+ static event_type = EventType
+ static $ = $
+ static plugin = Plugin
+ static register_plugin = _register_plugin
+ static util = _util
+
+ constructor(options) {
+ jsMind.current = this
+ this.options = merge_option(options)
+ logger.level(LogLevel[this.options.log_level])
+ this.version = __version__
+ this.initialized = false
+ this.mind = null
+ this.event_handles = []
+ this.init()
+ }
+
+ init() {
+ if (!!this.initialized) {
+ return
+ }
+ this.initialized = true
+ var opts_layout = {
+ mode: this.options.mode,
+ hspace: this.options.layout.hspace,
+ vspace: this.options.layout.vspace,
+ pspace: this.options.layout.pspace,
+ cousin_space: this.options.layout.cousin_space,
+ }
+ var opts_view = {
+ container: this.options.container,
+ support_html: this.options.support_html,
+ engine: this.options.view.engine,
+ enable_device_pixel_ratio: this.options.view.enable_device_pixel_ratio,
+ hmargin: this.options.view.hmargin,
+ vmargin: this.options.view.vmargin,
+ line_width: this.options.view.line_width,
+ line_color: this.options.view.line_color,
+ line_style: this.options.view.line_style,
+ custom_line_render: this.options.view.custom_line_render,
+ draggable: this.options.view.draggable,
+ hide_scrollbars_when_draggable: this.options.view.hide_scrollbars_when_draggable,
+ node_overflow: this.options.view.node_overflow,
+ zoom: this.options.view.zoom,
+ custom_node_render: this.options.view.custom_node_render,
+ expander_style: this.options.view.expander_style,
+ }
+ // create instance of function provider
+ this.data = new DataProvider(this)
+ this.layout = new LayoutProvider(this, opts_layout)
+ this.view = new ViewProvider(this, opts_view)
+ this.shortcut = new ShortcutProvider(this, this.options.shortcut)
+
+ this.data.init()
+ this.layout.init()
+ this.view.init()
+ this.shortcut.init()
+
+ this._event_bind()
+
+ apply_plugins(this, this.options.plugin)
+ }
+ get_editable() {
+ return this.options.editable
+ }
+ enable_edit() {
+ this.options.editable = true
+ }
+ disable_edit() {
+ this.options.editable = false
+ }
+ get_view_draggable() {
+ return this.options.view.draggable
+ }
+ enable_view_draggable() {
+ this.options.view.draggable = true
+ this.view.setup_canvas_draggable(true)
+ }
+ disable_view_draggable() {
+ this.options.view.draggable = false
+ this.view.setup_canvas_draggable(false)
+ }
+ // options are 'mousedown', 'click', 'dblclick', 'mousewheel'
+ enable_event_handle(event_handle) {
+ this.options.default_event_handle['enable_' + event_handle + '_handle'] = true
+ }
+ // options are 'mousedown', 'click', 'dblclick', 'mousewheel'
+ disable_event_handle(event_handle) {
+ this.options.default_event_handle['enable_' + event_handle + '_handle'] = false
+ }
+ set_theme(theme) {
+ var theme_old = this.options.theme
+ this.options.theme = !!theme ? theme : null
+ if (theme_old != this.options.theme) {
+ this.view.reset_theme()
+ this.view.reset_custom_style()
+ }
+ }
+ _event_bind() {
+ this.view.add_event(this, 'mousedown', this.mousedown_handle)
+ this.view.add_event(this, 'click', this.click_handle)
+ this.view.add_event(this, 'dblclick', this.dblclick_handle)
+ this.view.add_event(this, 'mousewheel', this.mousewheel_handle, true)
+ }
+ mousedown_handle(e) {
+ if (!this.options.default_event_handle['enable_mousedown_handle']) {
+ return
+ }
+ var element = e.target || event.srcElement
+ var node_id = this.view.get_binded_nodeid(element)
+ if (!!node_id) {
+ if (this.view.is_node(element)) {
+ this.select_node(node_id)
+ }
+ } else {
+ this.select_clear()
+ }
+ }
+ click_handle(e) {
+ if (!this.options.default_event_handle['enable_click_handle']) {
+ return
+ }
+ var element = e.target || event.srcElement
+ var is_expander = this.view.is_expander(element)
+ if (is_expander) {
+ var node_id = this.view.get_binded_nodeid(element)
+ if (!!node_id) {
+ this.toggle_node(node_id)
+ }
+ }
+ }
+ dblclick_handle(e) {
+ if (!this.options.default_event_handle['enable_dblclick_handle']) {
+ return
+ }
+ if (this.get_editable()) {
+ var element = e.target || event.srcElement
+ var is_node = this.view.is_node(element)
+ if (is_node) {
+ var node_id = this.view.get_binded_nodeid(element)
+ if (!!node_id) {
+ this.begin_edit(node_id)
+ }
+ }
+ }
+ }
+ // Use [Ctrl] + Mousewheel, to zoom in/out.
+ mousewheel_handle(e) {
+ // Test if mousewheel option is enabled and Ctrl key is pressed.
+ if (!this.options.default_event_handle['enable_mousewheel_handle'] || !e.ctrlKey) {
+ return
+ }
+ var evt = e || event
+ // Avoid default page scrolling behavior.
+ evt.preventDefault()
+
+ if (evt.deltaY < 0) {
+ this.view.zoom_in(evt) // wheel down
+ } else {
+ this.view.zoom_out(evt)
+ }
+ }
+ begin_edit(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return false
+ } else {
+ return this.begin_edit(the_node)
+ }
+ }
+ if (this.get_editable()) {
+ this.view.edit_node_begin(node)
+ } else {
+ logger.error('fail, this mind map is not editable.')
+ return
+ }
+ }
+ end_edit() {
+ this.view.edit_node_end()
+ }
+ toggle_node(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return
+ } else {
+ return this.toggle_node(the_node)
+ }
+ }
+ if (node.isroot) {
+ return
+ }
+ this.view.save_location(node)
+ this.layout.toggle_node(node)
+ this.view.relayout()
+ this.view.restore_location(node)
+ }
+ expand_node(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return
+ } else {
+ return this.expand_node(the_node)
+ }
+ }
+ if (node.isroot) {
+ return
+ }
+ this.view.save_location(node)
+ this.layout.expand_node(node)
+ this.view.relayout()
+ this.view.restore_location(node)
+ }
+ collapse_node(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return
+ } else {
+ return this.collapse_node(the_node)
+ }
+ }
+ if (node.isroot) {
+ return
+ }
+ this.view.save_location(node)
+ this.layout.collapse_node(node)
+ this.view.relayout()
+ this.view.restore_location(node)
+ }
+ expand_all() {
+ this.layout.expand_all()
+ this.view.relayout()
+ }
+ collapse_all() {
+ this.layout.collapse_all()
+ this.view.relayout()
+ }
+ expand_to_depth(depth) {
+ this.layout.expand_to_depth(depth)
+ this.view.relayout()
+ }
+ _reset() {
+ this.view.reset()
+ this.layout.reset()
+ this.data.reset()
+ }
+ _show(mind, skip_centering) {
+ var m = mind || format.node_array.example
+ this.mind = this.data.load(m)
+ if (!this.mind) {
+ logger.error('data.load error')
+ return
+ } else {
+ logger.debug('data.load ok')
+ }
+
+ this.view.load()
+ logger.debug('view.load ok')
+
+ this.layout.layout()
+ logger.debug('layout.layout ok')
+
+ this.view.show(!skip_centering)
+ logger.debug('view.show ok')
+
+ this.invoke_event_handle(EventType.show, { data: [mind] })
+ }
+ show(mind, skip_centering) {
+ this._reset()
+ this._show(mind, skip_centering)
+ }
+ get_meta() {
+ return {
+ name: this.mind.name,
+ author: this.mind.author,
+ version: this.mind.version,
+ }
+ }
+ get_data(data_format) {
+ var df = data_format || 'node_tree'
+ return this.data.get_data(df)
+ }
+ get_root() {
+ return this.mind.root
+ }
+ get_node(node) {
+ if (Node.is_node(node)) {
+ return node
+ }
+ return this.mind.get_node(node)
+ }
+ add_node(parent_node, node_id, topic, data, direction) {
+ if (this.get_editable()) {
+ var the_parent_node = this.get_node(parent_node)
+ var dir = Direction.of(direction)
+ if (dir === undefined) {
+ dir = this.layout.calculate_next_child_direction(the_parent_node)
+ }
+ var node = this.mind.add_node(the_parent_node, node_id, topic, data, dir)
+ if (!!node) {
+ this.view.add_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ this.view.reset_node_custom_style(node)
+ this.expand_node(the_parent_node)
+ this.invoke_event_handle(EventType.edit, {
+ evt: 'add_node',
+ data: [the_parent_node.id, node_id, topic, data, dir],
+ node: node_id,
+ })
+ }
+ return node
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ insert_node_before(node_before, node_id, topic, data, direction) {
+ if (this.get_editable()) {
+ var the_node_before = this.get_node(node_before)
+ var dir = Direction.of(direction)
+ if (dir === undefined) {
+ dir = this.layout.calculate_next_child_direction(the_node_before.parent)
+ }
+ var node = this.mind.insert_node_before(the_node_before, node_id, topic, data, dir)
+ if (!!node) {
+ this.view.add_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ this.invoke_event_handle(EventType.edit, {
+ evt: 'insert_node_before',
+ data: [the_node_before.id, node_id, topic, data, dir],
+ node: node_id,
+ })
+ }
+ return node
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ insert_node_after(node_after, node_id, topic, data, direction) {
+ if (this.get_editable()) {
+ var the_node_after = this.get_node(node_after)
+ var dir = Direction.of(direction)
+ if (dir === undefined) {
+ dir = this.layout.calculate_next_child_direction(the_node_after.parent)
+ }
+ var node = this.mind.insert_node_after(the_node_after, node_id, topic, data, dir)
+ if (!!node) {
+ this.view.add_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ this.invoke_event_handle(EventType.edit, {
+ evt: 'insert_node_after',
+ data: [the_node_after.id, node_id, topic, data, dir],
+ node: node_id,
+ })
+ }
+ return node
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ remove_node(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return false
+ } else {
+ return this.remove_node(the_node)
+ }
+ }
+ if (this.get_editable()) {
+ if (node.isroot) {
+ logger.error('fail, can not remove root node')
+ return false
+ }
+ var node_id = node.id
+ var parent_id = node.parent.id
+ var parent_node = this.get_node(parent_id)
+ this.view.save_location(parent_node)
+ this.view.remove_node(node)
+ this.mind.remove_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ this.view.restore_location(parent_node)
+ this.invoke_event_handle(EventType.edit, {
+ evt: 'remove_node',
+ data: [node_id],
+ node: parent_id,
+ })
+ return true
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return false
+ }
+ }
+ update_node(node_id, topic) {
+ if (this.get_editable()) {
+ if (_util.text.is_empty(topic)) {
+ logger.warn('fail, topic can not be empty')
+ return
+ }
+ var node = this.get_node(node_id)
+ if (!!node) {
+ if (node.topic === topic) {
+ logger.info('nothing changed')
+ this.view.update_node(node)
+ return
+ }
+ node.topic = topic
+ this.view.update_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ this.invoke_event_handle(EventType.edit, {
+ evt: 'update_node',
+ data: [node_id, topic],
+ node: node_id,
+ })
+ }
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return
+ }
+ }
+ move_node(node_id, before_id, parent_id, direction) {
+ if (this.get_editable()) {
+ var node = this.get_node(node_id)
+ var updated_node = this.mind.move_node(node, before_id, parent_id, direction)
+ if (!!updated_node) {
+ this.view.update_node(updated_node)
+ this.layout.layout()
+ this.view.show(false)
+ this.invoke_event_handle(EventType.edit, {
+ evt: 'move_node',
+ data: [node_id, before_id, parent_id, direction],
+ node: node_id,
+ })
+ }
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return
+ }
+ }
+ select_node(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return
+ } else {
+ return this.select_node(the_node)
+ }
+ }
+ if (!this.layout.is_visible(node)) {
+ return
+ }
+ this.mind.selected = node
+ this.view.select_node(node)
+ this.invoke_event_handle(EventType.select, { evt: 'select_node', data: [], node: node.id })
+ }
+ get_selected_node() {
+ if (!!this.mind) {
+ return this.mind.selected
+ } else {
+ return null
+ }
+ }
+ select_clear() {
+ if (!!this.mind) {
+ this.mind.selected = null
+ this.view.select_clear()
+ }
+ }
+ is_node_visible(node) {
+ return this.layout.is_visible(node)
+ }
+ scroll_node_to_center(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ } else {
+ this.scroll_node_to_center(the_node)
+ }
+ return
+ }
+ this.view.center_node(node)
+ }
+ find_node_before(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return
+ } else {
+ return this.find_node_before(the_node)
+ }
+ }
+ if (node.isroot) {
+ return null
+ }
+ var n = null
+ if (node.parent.isroot) {
+ var c = node.parent.children
+ var prev = null
+ var ni = null
+ for (var i = 0; i < c.length; i++) {
+ ni = c[i]
+ if (node.direction === ni.direction) {
+ if (node.id === ni.id) {
+ n = prev
+ }
+ prev = ni
+ }
+ }
+ } else {
+ n = this.mind.get_node_before(node)
+ }
+ return n
+ }
+ find_node_after(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return
+ } else {
+ return this.find_node_after(the_node)
+ }
+ }
+ if (node.isroot) {
+ return null
+ }
+ var n = null
+ if (node.parent.isroot) {
+ var c = node.parent.children
+ var found = false
+ var ni = null
+ for (var i = 0; i < c.length; i++) {
+ ni = c[i]
+ if (node.direction === ni.direction) {
+ if (found) {
+ n = ni
+ break
+ }
+ if (node.id === ni.id) {
+ found = true
+ }
+ }
+ }
+ } else {
+ n = this.mind.get_node_after(node)
+ }
+ return n
+ }
+ set_node_color(node_id, bg_color, fg_color) {
+ if (this.get_editable()) {
+ var node = this.mind.get_node(node_id)
+ if (!!node) {
+ if (!!bg_color) {
+ node.data['background-color'] = bg_color
+ }
+ if (!!fg_color) {
+ node.data['foreground-color'] = fg_color
+ }
+ this.view.reset_node_custom_style(node)
+ }
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ set_node_font_style(node_id, size, weight, style) {
+ if (this.get_editable()) {
+ var node = this.mind.get_node(node_id)
+ if (!!node) {
+ if (!!size) {
+ node.data['font-size'] = size
+ }
+ if (!!weight) {
+ node.data['font-weight'] = weight
+ }
+ if (!!style) {
+ node.data['font-style'] = style
+ }
+ this.view.reset_node_custom_style(node)
+ this.view.update_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ }
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ set_node_background_image(node_id, image, width, height, rotation) {
+ if (this.get_editable()) {
+ var node = this.mind.get_node(node_id)
+ if (!!node) {
+ if (!!image) {
+ node.data['background-image'] = image
+ }
+ if (!!width) {
+ node.data['width'] = width
+ }
+ if (!!height) {
+ node.data['height'] = height
+ }
+ if (!!rotation) {
+ node.data['background-rotation'] = rotation
+ }
+ this.view.reset_node_custom_style(node)
+ this.view.update_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ }
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ set_node_background_rotation(node_id, rotation) {
+ if (this.get_editable()) {
+ var node = this.mind.get_node(node_id)
+ if (!!node) {
+ if (!node.data['background-image']) {
+ logger.error(
+ 'fail, only can change rotation angle of node with background image'
+ )
+ return null
+ }
+ node.data['background-rotation'] = rotation
+ this.view.reset_node_custom_style(node)
+ this.view.update_node(node)
+ this.layout.layout()
+ this.view.show(false)
+ }
+ } else {
+ logger.error('fail, this mind map is not editable')
+ return null
+ }
+ }
+ resize() {
+ this.view.resize()
+ }
+ // callback(type ,data)
+ add_event_listener(callback) {
+ if (typeof callback === 'function') {
+ this.event_handles.push(callback)
+ }
+ }
+ clear_event_listener() {
+ this.event_handles = []
+ }
+ invoke_event_handle(type, data) {
+ var j = this
+ $.w.setTimeout(function () {
+ j._invoke_event_handle(type, data)
+ }, 0)
+ }
+ _invoke_event_handle(type, data) {
+ var l = this.event_handles.length
+ for (var i = 0; i < l; i++) {
+ this.event_handles[i](type, data)
+ }
+ }
+
+ static show(options, mind) {
+ logger.warn(
+ '`jsMind.show(options, mind)` is deprecated, please use `jm = new jsMind(options); jm.show(mind);` instead'
+ )
+ var _jm = new jsMind(options)
+ _jm.show(mind)
+ return _jm
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.layout_provider.js b/app/assets/javascripts/mind_map/jsmind/jsmind.layout_provider.js
new file mode 100644
index 0000000..bc93a06
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.layout_provider.js
@@ -0,0 +1,445 @@
+import { logger, Direction, EventType } from './jsmind.common.js'
+
+export class LayoutProvider {
+ constructor(jm, options) {
+ this.opts = options
+ this.jm = jm
+ this.isside = this.opts.mode == 'side'
+ this.bounds = null
+
+ this.cache_valid = false
+ }
+ init() {
+ logger.debug('layout.init')
+ }
+ reset() {
+ logger.debug('layout.reset')
+ this.bounds = { n: 0, s: 0, w: 0, e: 0 }
+ }
+ calculate_next_child_direction(node) {
+ if (this.isside) {
+ return Direction.right
+ }
+ var children = node.children || []
+ var children_len = children.length
+ var r = 0
+ for (var i = 0; i < children_len; i++) {
+ if (children[i].direction === Direction.left) {
+ r--
+ } else {
+ r++
+ }
+ }
+ return children_len > 1 && r > 0 ? Direction.left : Direction.right
+ }
+ layout() {
+ logger.debug('layout.layout')
+ this.layout_direction()
+ this.layout_offset()
+ }
+ layout_direction() {
+ this._layout_direction_root()
+ }
+ _layout_direction_root() {
+ var node = this.jm.mind.root
+ var layout_data = null
+ if ('layout' in node._data) {
+ layout_data = node._data.layout
+ } else {
+ layout_data = {}
+ node._data.layout = layout_data
+ }
+ var children = node.children
+ var children_count = children.length
+ layout_data.direction = Direction.center
+ layout_data.side_index = 0
+ if (this.isside) {
+ var i = children_count
+ while (i--) {
+ this._layout_direction_side(children[i], Direction.right, i)
+ }
+ } else {
+ var i = children_count
+ var subnode = null
+ while (i--) {
+ subnode = children[i]
+ if (subnode.direction == Direction.left) {
+ this._layout_direction_side(subnode, Direction.left, i)
+ } else {
+ this._layout_direction_side(subnode, Direction.right, i)
+ }
+ }
+ }
+ }
+ _layout_direction_side(node, direction, side_index) {
+ var layout_data = null
+ if ('layout' in node._data) {
+ layout_data = node._data.layout
+ } else {
+ layout_data = {}
+ node._data.layout = layout_data
+ }
+ var children = node.children
+ var children_count = children.length
+
+ layout_data.direction = direction
+ layout_data.side_index = side_index
+ var i = children_count
+ while (i--) {
+ this._layout_direction_side(children[i], direction, i)
+ }
+ }
+ layout_offset() {
+ var node = this.jm.mind.root
+ var layout_data = node._data.layout
+ layout_data.offset_x = 0
+ layout_data.offset_y = 0
+ layout_data.outer_height = 0
+ var children = node.children
+ var i = children.length
+ var left_nodes = []
+ var right_nodes = []
+ var subnode = null
+ while (i--) {
+ subnode = children[i]
+ if (subnode._data.layout.direction == Direction.right) {
+ right_nodes.unshift(subnode)
+ } else {
+ left_nodes.unshift(subnode)
+ }
+ }
+ layout_data.left_nodes = left_nodes
+ layout_data.right_nodes = right_nodes
+ layout_data.outer_height_left = this._layout_offset_subnodes(left_nodes)
+ layout_data.outer_height_right = this._layout_offset_subnodes(right_nodes)
+ this.bounds.e = node._data.view.width / 2
+ this.bounds.w = 0 - this.bounds.e
+ this.bounds.n = 0
+ this.bounds.s = Math.max(layout_data.outer_height_left, layout_data.outer_height_right)
+ }
+ // layout both the x and y axis
+ _layout_offset_subnodes(nodes) {
+ var total_height = 0
+ var nodes_count = nodes.length
+ var i = nodes_count
+ var node = null
+ var node_outer_height = 0
+ var layout_data = null
+ var base_y = 0
+ var pd = null // parent._data
+ while (i--) {
+ node = nodes[i]
+ layout_data = node._data.layout
+ if (pd == null) {
+ pd = node.parent._data
+ }
+
+ node_outer_height = this._layout_offset_subnodes(node.children)
+ if (!node.expanded) {
+ node_outer_height = 0
+ this.set_visible(node.children, false)
+ }
+ node_outer_height = Math.max(node._data.view.height, node_outer_height)
+ if (node.children.length > 1) {
+ node_outer_height += this.opts.cousin_space
+ }
+
+ layout_data.outer_height = node_outer_height
+ layout_data.offset_y = base_y - node_outer_height / 2
+ layout_data.offset_x =
+ this.opts.hspace * layout_data.direction +
+ (pd.view.width * (pd.layout.direction + layout_data.direction)) / 2
+ if (!node.parent.isroot) {
+ layout_data.offset_x += this.opts.pspace * layout_data.direction
+ }
+
+ base_y = base_y - node_outer_height - this.opts.vspace
+ total_height += node_outer_height
+ }
+ if (nodes_count > 1) {
+ total_height += this.opts.vspace * (nodes_count - 1)
+ }
+ i = nodes_count
+ var middle_height = total_height / 2
+ while (i--) {
+ node = nodes[i]
+ node._data.layout.offset_y += middle_height
+ }
+ return total_height
+ }
+ // layout the y axis only, for collapse/expand a node
+ _layout_offset_subnodes_height(nodes) {
+ var total_height = 0
+ var nodes_count = nodes.length
+ var i = nodes_count
+ var node = null
+ var node_outer_height = 0
+ var layout_data = null
+ var base_y = 0
+ var pd = null // parent._data
+ while (i--) {
+ node = nodes[i]
+ layout_data = node._data.layout
+ if (pd == null) {
+ pd = node.parent._data
+ }
+
+ node_outer_height = this._layout_offset_subnodes_height(node.children)
+ if (!node.expanded) {
+ node_outer_height = 0
+ }
+ node_outer_height = Math.max(node._data.view.height, node_outer_height)
+ if (node.children.length > 1) {
+ node_outer_height += this.opts.cousin_space
+ }
+
+ layout_data.outer_height = node_outer_height
+ layout_data.offset_y = base_y - node_outer_height / 2
+ base_y = base_y - node_outer_height - this.opts.vspace
+ total_height += node_outer_height
+ }
+ if (nodes_count > 1) {
+ total_height += this.opts.vspace * (nodes_count - 1)
+ }
+ i = nodes_count
+ var middle_height = total_height / 2
+ while (i--) {
+ node = nodes[i]
+ node._data.layout.offset_y += middle_height
+ }
+ return total_height
+ }
+ get_node_offset(node) {
+ var layout_data = node._data.layout
+ var offset_cache = null
+ if ('_offset_' in layout_data && this.cache_valid) {
+ offset_cache = layout_data._offset_
+ } else {
+ offset_cache = { x: -1, y: -1 }
+ layout_data._offset_ = offset_cache
+ }
+ if (offset_cache.x == -1 || offset_cache.y == -1) {
+ var x = layout_data.offset_x
+ var y = layout_data.offset_y
+ if (!node.isroot) {
+ var offset_p = this.get_node_offset(node.parent)
+ x += offset_p.x
+ y += offset_p.y
+ }
+ offset_cache.x = x
+ offset_cache.y = y
+ }
+ return offset_cache
+ }
+ get_node_point(node) {
+ var view_data = node._data.view
+ var offset_p = this.get_node_offset(node)
+ var p = {}
+ p.x = offset_p.x + (view_data.width * (node._data.layout.direction - 1)) / 2
+ p.y = offset_p.y - view_data.height / 2
+ return p
+ }
+ get_node_point_in(node) {
+ var p = this.get_node_offset(node)
+ return p
+ }
+ get_node_point_out(node) {
+ var layout_data = node._data.layout
+ var pout_cache = null
+ if ('_pout_' in layout_data && this.cache_valid) {
+ pout_cache = layout_data._pout_
+ } else {
+ pout_cache = { x: -1, y: -1 }
+ layout_data._pout_ = pout_cache
+ }
+ if (pout_cache.x == -1 || pout_cache.y == -1) {
+ if (node.isroot) {
+ pout_cache.x = 0
+ pout_cache.y = 0
+ } else {
+ var view_data = node._data.view
+ var offset_p = this.get_node_offset(node)
+ pout_cache.x =
+ offset_p.x + (view_data.width + this.opts.pspace) * node._data.layout.direction
+ pout_cache.y = offset_p.y
+ }
+ }
+ return pout_cache
+ }
+ get_expander_point(node) {
+ var p = this.get_node_point_out(node)
+ var ex_p = {}
+ if (node._data.layout.direction == Direction.right) {
+ ex_p.x = p.x - this.opts.pspace
+ } else {
+ ex_p.x = p.x
+ }
+ ex_p.y = p.y - Math.ceil(this.opts.pspace / 2)
+ return ex_p
+ }
+ get_min_size() {
+ var nodes = this.jm.mind.nodes
+ var node = null
+ var pout = null
+ for (var node_id in nodes) {
+ node = nodes[node_id]
+ pout = this.get_node_point_out(node)
+ if (pout.x > this.bounds.e) {
+ this.bounds.e = pout.x
+ }
+ if (pout.x < this.bounds.w) {
+ this.bounds.w = pout.x
+ }
+ }
+ return {
+ w: this.bounds.e - this.bounds.w,
+ h: this.bounds.s - this.bounds.n,
+ }
+ }
+ toggle_node(node) {
+ if (node.isroot) {
+ return
+ }
+ if (node.expanded) {
+ this.collapse_node(node)
+ } else {
+ this.expand_node(node)
+ }
+ }
+ expand_node(node) {
+ node.expanded = true
+ this.part_layout(node)
+ this.set_visible(node.children, true)
+ this.jm.invoke_event_handle(EventType.show, {
+ evt: 'expand_node',
+ data: [],
+ node: node.id,
+ })
+ }
+ collapse_node(node) {
+ node.expanded = false
+ this.part_layout(node)
+ this.set_visible(node.children, false)
+ this.jm.invoke_event_handle(EventType.show, {
+ evt: 'collapse_node',
+ data: [],
+ node: node.id,
+ })
+ }
+ expand_all() {
+ var nodes = this.jm.mind.nodes
+ var c = 0
+ var node
+ for (var node_id in nodes) {
+ node = nodes[node_id]
+ if (!node.expanded) {
+ node.expanded = true
+ c++
+ }
+ }
+ if (c > 0) {
+ var root = this.jm.mind.root
+ this.part_layout(root)
+ this.set_visible(root.children, true)
+ }
+ }
+ collapse_all() {
+ var nodes = this.jm.mind.nodes
+ var c = 0
+ var node
+ for (var node_id in nodes) {
+ node = nodes[node_id]
+ if (node.expanded && !node.isroot) {
+ node.expanded = false
+ c++
+ }
+ }
+ if (c > 0) {
+ var root = this.jm.mind.root
+ this.part_layout(root)
+ this.set_visible(root.children, true)
+ }
+ }
+ expand_to_depth(target_depth, curr_nodes, curr_depth) {
+ if (target_depth < 1) {
+ return
+ }
+ var nodes = curr_nodes || this.jm.mind.root.children
+ var depth = curr_depth || 1
+ var i = nodes.length
+ var node = null
+ while (i--) {
+ node = nodes[i]
+ if (depth < target_depth) {
+ if (!node.expanded) {
+ this.expand_node(node)
+ }
+ this.expand_to_depth(target_depth, node.children, depth + 1)
+ }
+ if (depth == target_depth) {
+ if (node.expanded) {
+ this.collapse_node(node)
+ }
+ }
+ }
+ }
+ part_layout(node) {
+ var root = this.jm.mind.root
+ if (!!root) {
+ var root_layout_data = root._data.layout
+ if (node.isroot) {
+ root_layout_data.outer_height_right = this._layout_offset_subnodes_height(
+ root_layout_data.right_nodes
+ )
+ root_layout_data.outer_height_left = this._layout_offset_subnodes_height(
+ root_layout_data.left_nodes
+ )
+ } else {
+ if (node._data.layout.direction == Direction.right) {
+ root_layout_data.outer_height_right = this._layout_offset_subnodes_height(
+ root_layout_data.right_nodes
+ )
+ } else {
+ root_layout_data.outer_height_left = this._layout_offset_subnodes_height(
+ root_layout_data.left_nodes
+ )
+ }
+ }
+ this.bounds.s = Math.max(
+ root_layout_data.outer_height_left,
+ root_layout_data.outer_height_right
+ )
+ this.cache_valid = false
+ } else {
+ logger.warn('can not found root node')
+ }
+ }
+ set_visible(nodes, visible) {
+ var i = nodes.length
+ var node = null
+ var layout_data = null
+ while (i--) {
+ node = nodes[i]
+ layout_data = node._data.layout
+ if (node.expanded) {
+ this.set_visible(node.children, visible)
+ } else {
+ this.set_visible(node.children, false)
+ }
+ if (!node.isroot) {
+ node._data.layout.visible = visible
+ }
+ }
+ }
+ is_expand(node) {
+ return node.expanded
+ }
+ is_visible(node) {
+ var layout_data = node._data.layout
+ if ('visible' in layout_data && !layout_data.visible) {
+ return false
+ } else {
+ return true
+ }
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.mind.js b/app/assets/javascripts/mind_map/jsmind/jsmind.mind.js
new file mode 100644
index 0000000..3129cea
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.mind.js
@@ -0,0 +1,254 @@
+import { Node } from './jsmind.node.js'
+import { logger, Direction } from './jsmind.common.js'
+
+export class Mind {
+ constructor() {
+ this.name = null
+ this.author = null
+ this.version = null
+ this.root = null
+ this.selected = null
+ this.nodes = {}
+ }
+ get_node(node_id) {
+ if (node_id in this.nodes) {
+ return this.nodes[node_id]
+ } else {
+ logger.warn('the node[id=' + node_id + '] can not be found')
+ return null
+ }
+ }
+ set_root(node_id, topic, data) {
+ if (this.root == null) {
+ this.root = new Node(node_id, 0, topic, data, true)
+ this._put_node(this.root)
+ return this.root
+ } else {
+ logger.error('root node is already exist')
+ return null
+ }
+ }
+ add_node(parent_node, node_id, topic, data, direction, expanded, idx) {
+ if (!Node.is_node(parent_node)) {
+ logger.error('the parent_node ' + parent_node + ' is not a node.')
+ return null
+ }
+ var node_index = idx || -1
+ var node = new Node(
+ node_id,
+ node_index,
+ topic,
+ data,
+ false,
+ parent_node,
+ parent_node.direction,
+ expanded
+ )
+ if (parent_node.isroot) {
+ node.direction = direction || Direction.right
+ }
+ if (this._put_node(node)) {
+ parent_node.children.push(node)
+ this._update_index(parent_node)
+ } else {
+ logger.error("fail, the node id '" + node.id + "' has been already exist.")
+ node = null
+ }
+ return node
+ }
+ insert_node_before(node_before, node_id, topic, data, direction) {
+ if (!Node.is_node(node_before)) {
+ logger.error('the node_before ' + node_before + ' is not a node.')
+ return null
+ }
+ var node_index = node_before.index - 0.5
+ return this.add_node(node_before.parent, node_id, topic, data, direction, true, node_index)
+ }
+ get_node_before(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return null
+ } else {
+ return this.get_node_before(the_node)
+ }
+ }
+ if (node.isroot) {
+ return null
+ }
+ var idx = node.index - 2
+ if (idx >= 0) {
+ return node.parent.children[idx]
+ } else {
+ return null
+ }
+ }
+ insert_node_after(node_after, node_id, topic, data, direction) {
+ if (!Node.is_node(node_after)) {
+ logger.error('the node_after ' + node_after + ' is not a node.')
+ return null
+ }
+ var node_index = node_after.index + 0.5
+ return this.add_node(node_after.parent, node_id, topic, data, direction, true, node_index)
+ }
+ get_node_after(node) {
+ if (!Node.is_node(node)) {
+ var the_node = this.get_node(node)
+ if (!the_node) {
+ logger.error('the node[id=' + node + '] can not be found.')
+ return null
+ } else {
+ return this.get_node_after(the_node)
+ }
+ }
+ if (node.isroot) {
+ return null
+ }
+ var idx = node.index
+ var brothers = node.parent.children
+ if (brothers.length > idx) {
+ return node.parent.children[idx]
+ } else {
+ return null
+ }
+ }
+ move_node(node, before_id, parent_id, direction) {
+ if (!Node.is_node(node)) {
+ logger.error('the parameter node ' + node + ' is not a node.')
+ return null
+ }
+ if (!parent_id) {
+ parent_id = node.parent.id
+ }
+ return this._move_node(node, before_id, parent_id, direction)
+ }
+ _flow_node_direction(node, direction) {
+ if (typeof direction === 'undefined') {
+ direction = node.direction
+ } else {
+ node.direction = direction
+ }
+ var len = node.children.length
+ while (len--) {
+ this._flow_node_direction(node.children[len], direction)
+ }
+ }
+ _move_node_internal(node, before_id) {
+ if (!!node && !!before_id) {
+ if (before_id == '_last_') {
+ node.index = -1
+ this._update_index(node.parent)
+ } else if (before_id == '_first_') {
+ node.index = 0
+ this._update_index(node.parent)
+ } else {
+ var node_before = !!before_id ? this.get_node(before_id) : null
+ if (
+ node_before != null &&
+ node_before.parent != null &&
+ node_before.parent.id == node.parent.id
+ ) {
+ node.index = node_before.index - 0.5
+ this._update_index(node.parent)
+ }
+ }
+ }
+ return node
+ }
+ _move_node(node, before_id, parent_id, direction) {
+ if (!!node && !!parent_id) {
+ var parent_node = this.get_node(parent_id)
+ if (Node.inherited(node, parent_node)) {
+ logger.error('can not move a node to its children')
+ return null
+ }
+ if (node.parent.id != parent_id) {
+ // remove from parent's children
+ var sibling = node.parent.children
+ var si = sibling.length
+ while (si--) {
+ if (sibling[si].id == node.id) {
+ sibling.splice(si, 1)
+ break
+ }
+ }
+ let origin_parent = node.parent
+ node.parent = parent_node
+ parent_node.children.push(node)
+ this._update_index(origin_parent)
+ }
+
+ if (node.parent.isroot) {
+ if (direction == Direction.left) {
+ node.direction = direction
+ } else {
+ node.direction = Direction.right
+ }
+ } else {
+ node.direction = node.parent.direction
+ }
+ this._move_node_internal(node, before_id)
+ this._flow_node_direction(node)
+ }
+ return node
+ }
+ remove_node(node) {
+ if (!Node.is_node(node)) {
+ logger.error('the parameter node ' + node + ' is not a node.')
+ return false
+ }
+ if (node.isroot) {
+ logger.error('fail, can not remove root node')
+ return false
+ }
+ if (this.selected != null && this.selected.id == node.id) {
+ this.selected = null
+ }
+ // clean all subordinate nodes
+ var children = node.children
+ var ci = children.length
+ while (ci--) {
+ this.remove_node(children[ci])
+ }
+ // clean all children
+ children.length = 0
+ var node_parent = node.parent
+ // remove from parent's children
+ var sibling = node_parent.children
+ var si = sibling.length
+ while (si--) {
+ if (sibling[si].id == node.id) {
+ sibling.splice(si, 1)
+ break
+ }
+ }
+ // remove from global nodes
+ delete this.nodes[node.id]
+ // clean all properties
+ for (var k in node) {
+ delete node[k]
+ }
+ // remove it's self
+ node = null
+ this._update_index(node_parent)
+ return true
+ }
+ _put_node(node) {
+ if (node.id in this.nodes) {
+ logger.warn("the node_id '" + node.id + "' has been already exist.")
+ return false
+ } else {
+ this.nodes[node.id] = node
+ return true
+ }
+ }
+ _update_index(node) {
+ if (node instanceof Node) {
+ node.children.sort(Node.compare)
+ for (var i = 0; i < node.children.length; i++) {
+ node.children[i].index = i + 1
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.node.js b/app/assets/javascripts/mind_map/jsmind/jsmind.node.js
new file mode 100644
index 0000000..550836d
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.node.js
@@ -0,0 +1,82 @@
+import { logger } from './jsmind.common.js'
+export class Node {
+ constructor(sId, iIndex, sTopic, oData, bIsRoot, oParent, eDirection, bExpanded) {
+ if (!sId) {
+ logger.error('invalid node id')
+ return
+ }
+ if (typeof iIndex != 'number') {
+ logger.error('invalid node index')
+ return
+ }
+ if (typeof bExpanded === 'undefined') {
+ bExpanded = true
+ }
+ this.id = sId
+ this.index = iIndex
+ this.topic = sTopic
+ this.data = oData || {}
+ this.isroot = bIsRoot
+ this.parent = oParent
+ this.direction = eDirection
+ this.expanded = !!bExpanded
+ this.children = []
+ this._data = {}
+ }
+
+ get_location() {
+ var vd = this._data.view
+ return {
+ x: vd.abs_x,
+ y: vd.abs_y,
+ }
+ }
+ get_size() {
+ var vd = this._data.view
+ return {
+ w: vd.width,
+ h: vd.height,
+ }
+ }
+
+ static compare(node1, node2) {
+ // '-1' is always the latest
+ var r = 0
+ var i1 = node1.index
+ var i2 = node2.index
+ if (i1 >= 0 && i2 >= 0) {
+ r = i1 - i2
+ } else if (i1 == -1 && i2 == -1) {
+ r = 0
+ } else if (i1 == -1) {
+ r = 1
+ } else if (i2 == -1) {
+ r = -1
+ } else {
+ r = 0
+ }
+ return r
+ }
+ static inherited(parent_node, node) {
+ if (!!parent_node && !!node) {
+ if (parent_node.id === node.id) {
+ return true
+ }
+ if (parent_node.isroot) {
+ return true
+ }
+ var pid = parent_node.id
+ var p = node
+ while (!p.isroot) {
+ p = p.parent
+ if (p.id === pid) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+ static is_node(n) {
+ return !!n && n instanceof Node
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.option.js b/app/assets/javascripts/mind_map/jsmind/jsmind.option.js
new file mode 100644
index 0000000..86b37e8
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.option.js
@@ -0,0 +1,69 @@
+import { util } from './jsmind.util.js'
+
+const default_options = {
+ container: '', // id of the container
+ editable: false, // you can change it in your options
+ theme: null,
+ mode: 'full', // full or side
+ support_html: true,
+ log_level: 'info',
+
+ view: {
+ engine: 'canvas',
+ enable_device_pixel_ratio: false,
+ hmargin: 100,
+ vmargin: 50,
+ line_width: 2,
+ line_color: '#555',
+ line_style: 'curved', // [straight | curved]
+ draggable: false, // drag the mind map with your mouse, when it's larger that the container
+ hide_scrollbars_when_draggable: false, // hide container scrollbars, when mind map is larger than container and draggable option is true.
+ node_overflow: 'hidden', // [hidden | wrap]
+ zoom: {
+ min: 0.5,
+ max: 2.1,
+ step: 0.1,
+ },
+ custom_node_render: null,
+ expander_style: 'char', // [char | number]
+ },
+ layout: {
+ hspace: 30,
+ vspace: 20,
+ pspace: 13,
+ cousin_space: 0,
+ },
+ default_event_handle: {
+ enable_mousedown_handle: true,
+ enable_click_handle: true,
+ enable_dblclick_handle: true,
+ enable_mousewheel_handle: true,
+ },
+ shortcut: {
+ enable: true,
+ handles: {},
+ mapping: {
+ addchild: [45, 4096 + 13], // Insert, Ctrl+Enter
+ addbrother: 13, // Enter
+ editnode: 113, // F2
+ delnode: 46, // Delete
+ toggle: 32, // Space
+ left: 37, // Left
+ up: 38, // Up
+ right: 39, // Right
+ down: 40, // Down
+ },
+ },
+ plugin: {},
+}
+
+export function merge_option(options) {
+ var opts = {}
+ util.json.merge(opts, default_options)
+ util.json.merge(opts, options)
+
+ if (!opts.container) {
+ throw new Error('the options.container should not be null or empty.')
+ }
+ return opts
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.plugin.js b/app/assets/javascripts/mind_map/jsmind/jsmind.plugin.js
new file mode 100644
index 0000000..59e9b14
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.plugin.js
@@ -0,0 +1,39 @@
+import { $ } from './jsmind.dom.js'
+
+const plugin_data = {
+ plugins: [],
+}
+
+export function register(plugin) {
+ if (!(plugin instanceof Plugin)) {
+ throw new Error('can not register plugin, it is not an instance of Plugin')
+ }
+ if (plugin_data.plugins.map((p) => p.name).includes(plugin.name)) {
+ throw new Error('can not register plugin ' + plugin.name + ': plugin name already exist')
+ }
+ plugin_data.plugins.push(plugin)
+}
+
+export function apply(jm, options) {
+ $.w.setTimeout(function () {
+ _apply(jm, options)
+ }, 0)
+}
+
+function _apply(jm, options) {
+ plugin_data.plugins.forEach((p) => p.fn_init(jm, options[p.name]))
+}
+
+export class Plugin {
+ // function fn_init(jm, options){ }
+ constructor(name, fn_init) {
+ if (!name) {
+ throw new Error('plugin must has a name')
+ }
+ if (!fn_init || typeof fn_init !== 'function') {
+ throw new Error('plugin must has an init function')
+ }
+ this.name = name
+ this.fn_init = fn_init
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.shortcut_provider.js b/app/assets/javascripts/mind_map/jsmind/jsmind.shortcut_provider.js
new file mode 100644
index 0000000..89a8f05
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.shortcut_provider.js
@@ -0,0 +1,188 @@
+import { $ } from './jsmind.dom.js'
+import { util } from './jsmind.util.js'
+import { Direction } from './jsmind.common.js'
+
+export class ShortcutProvider {
+ constructor(jm, options) {
+ this.jm = jm
+ this.opts = options
+ this.mapping = options.mapping
+ this.handles = options.handles
+ this._newid = null
+ this._mapping = {}
+ }
+ init() {
+ $.on(this.jm.view.e_panel, 'keydown', this.handler.bind(this))
+
+ this.handles['addchild'] = this.handle_addchild
+ this.handles['addbrother'] = this.handle_addbrother
+ this.handles['editnode'] = this.handle_editnode
+ this.handles['delnode'] = this.handle_delnode
+ this.handles['toggle'] = this.handle_toggle
+ this.handles['up'] = this.handle_up
+ this.handles['down'] = this.handle_down
+ this.handles['left'] = this.handle_left
+ this.handles['right'] = this.handle_right
+
+ for (var handle in this.mapping) {
+ if (!!this.mapping[handle] && handle in this.handles) {
+ let keys = this.mapping[handle]
+ if (!Array.isArray(keys)) {
+ keys = [keys]
+ }
+ for (let key of keys) {
+ this._mapping[key] = this.handles[handle]
+ }
+ }
+ }
+
+ if (typeof this.opts.id_generator === 'function') {
+ this._newid = this.opts.id_generator
+ } else {
+ this._newid = util.uuid.newid
+ }
+ }
+ enable_shortcut() {
+ this.opts.enable = true
+ }
+ disable_shortcut() {
+ this.opts.enable = false
+ }
+ handler(e) {
+ if (e.which == 9) {
+ e.preventDefault()
+ } //prevent tab to change focus in browser
+ if (this.jm.view.is_editing()) {
+ return
+ }
+ var evt = e || event
+ if (!this.opts.enable) {
+ return true
+ }
+ var kc =
+ evt.keyCode +
+ (evt.metaKey << 13) +
+ (evt.ctrlKey << 12) +
+ (evt.altKey << 11) +
+ (evt.shiftKey << 10)
+ if (kc in this._mapping) {
+ this._mapping[kc].call(this, this.jm, e)
+ }
+ }
+ handle_addchild(_jm, e) {
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node) {
+ var node_id = this._newid()
+ var node = _jm.add_node(selected_node, node_id, 'New Node')
+ if (!!node) {
+ _jm.select_node(node_id)
+ _jm.begin_edit(node_id)
+ }
+ }
+ }
+ handle_addbrother(_jm, e) {
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node && !selected_node.isroot) {
+ var node_id = this._newid()
+ var node = _jm.insert_node_after(selected_node, node_id, 'New Node')
+ if (!!node) {
+ _jm.select_node(node_id)
+ _jm.begin_edit(node_id)
+ }
+ }
+ }
+ handle_editnode(_jm, e) {
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node) {
+ _jm.begin_edit(selected_node)
+ }
+ }
+ handle_delnode(_jm, e) {
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node && !selected_node.isroot) {
+ _jm.select_node(selected_node.parent)
+ _jm.remove_node(selected_node)
+ }
+ }
+ handle_toggle(_jm, e) {
+ var evt = e || event
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node) {
+ _jm.toggle_node(selected_node.id)
+ evt.stopPropagation()
+ evt.preventDefault()
+ }
+ }
+ handle_up(_jm, e) {
+ var evt = e || event
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node) {
+ var up_node = _jm.find_node_before(selected_node)
+ if (!up_node) {
+ var np = _jm.find_node_before(selected_node.parent)
+ if (!!np && np.children.length > 0) {
+ up_node = np.children[np.children.length - 1]
+ }
+ }
+ if (!!up_node) {
+ _jm.select_node(up_node)
+ }
+ evt.stopPropagation()
+ evt.preventDefault()
+ }
+ }
+ handle_down(_jm, e) {
+ var evt = e || event
+ var selected_node = _jm.get_selected_node()
+ if (!!selected_node) {
+ var down_node = _jm.find_node_after(selected_node)
+ if (!down_node) {
+ var np = _jm.find_node_after(selected_node.parent)
+ if (!!np && np.children.length > 0) {
+ down_node = np.children[0]
+ }
+ }
+ if (!!down_node) {
+ _jm.select_node(down_node)
+ }
+ evt.stopPropagation()
+ evt.preventDefault()
+ }
+ }
+ handle_left(_jm, e) {
+ this._handle_direction(_jm, e, Direction.left)
+ }
+ handle_right(_jm, e) {
+ this._handle_direction(_jm, e, Direction.right)
+ }
+ _handle_direction(_jm, e, d) {
+ var evt = e || event
+ var selected_node = _jm.get_selected_node()
+ var node = null
+ if (!!selected_node) {
+ if (selected_node.isroot) {
+ var c = selected_node.children
+ var children = []
+ for (var i = 0; i < c.length; i++) {
+ if (c[i].direction === d) {
+ children.push(i)
+ }
+ }
+ node = c[children[Math.floor((children.length - 1) / 2)]]
+ } else if (selected_node.direction === d) {
+ var children = selected_node.children
+ var children_count = children.length
+ if (children_count > 0) {
+ node = children[Math.floor((children_count - 1) / 2)]
+ }
+ } else {
+ node = selected_node.parent
+ }
+ if (!!node) {
+ _jm.select_node(node)
+ }
+ evt.stopPropagation()
+ evt.preventDefault()
+ }
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.util.js b/app/assets/javascripts/mind_map/jsmind/jsmind.util.js
new file mode 100644
index 0000000..6c98ae3
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.util.js
@@ -0,0 +1,94 @@
+import { $ } from './jsmind.dom.js'
+
+export const util = {
+ file: {
+ read: function (file_data, fn_callback) {
+ var reader = new FileReader()
+ reader.onload = function () {
+ if (typeof fn_callback === 'function') {
+ fn_callback(this.result, file_data.name)
+ }
+ }
+ reader.readAsText(file_data)
+ },
+
+ save: function (file_data, type, name) {
+ var blob
+ if (typeof $.w.Blob === 'function') {
+ blob = new Blob([file_data], { type: type })
+ } else {
+ var BlobBuilder =
+ $.w.BlobBuilder ||
+ $.w.MozBlobBuilder ||
+ $.w.WebKitBlobBuilder ||
+ $.w.MSBlobBuilder
+ var bb = new BlobBuilder()
+ bb.append(file_data)
+ blob = bb.getBlob(type)
+ }
+ if (navigator.msSaveBlob) {
+ navigator.msSaveBlob(blob, name)
+ } else {
+ var URL = $.w.URL || $.w.webkitURL
+ var blob_url = URL.createObjectURL(blob)
+ var anchor = $.c('a')
+ if ('download' in anchor) {
+ anchor.style.visibility = 'hidden'
+ anchor.href = blob_url
+ anchor.download = name
+ $.d.body.appendChild(anchor)
+ var evt = $.d.createEvent('MouseEvents')
+ evt.initEvent('click', true, true)
+ anchor.dispatchEvent(evt)
+ $.d.body.removeChild(anchor)
+ } else {
+ location.href = blob_url
+ }
+ }
+ },
+ },
+
+ json: {
+ json2string: function (json) {
+ return JSON.stringify(json)
+ },
+ string2json: function (json_str) {
+ return JSON.parse(json_str)
+ },
+ merge: function (b, a) {
+ for (var o in a) {
+ if (o in b) {
+ if (
+ typeof b[o] === 'object' &&
+ Object.prototype.toString.call(b[o]).toLowerCase() == '[object object]' &&
+ !b[o].length
+ ) {
+ util.json.merge(b[o], a[o])
+ } else {
+ b[o] = a[o]
+ }
+ } else {
+ b[o] = a[o]
+ }
+ }
+ return b
+ },
+ },
+
+ uuid: {
+ newid: function () {
+ return (
+ new Date().getTime().toString(16) + Math.random().toString(16).substring(2)
+ ).substring(2, 18)
+ },
+ },
+
+ text: {
+ is_empty: function (s) {
+ if (!s) {
+ return true
+ }
+ return s.replace(/\s*/, '').length == 0
+ },
+ },
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/jsmind.view_provider.js b/app/assets/javascripts/mind_map/jsmind/jsmind.view_provider.js
new file mode 100644
index 0000000..1cd824d
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/jsmind.view_provider.js
@@ -0,0 +1,662 @@
+import { logger, EventType } from './jsmind.common.js'
+import { $ } from './jsmind.dom.js'
+import { init_graph } from './jsmind.graph.js'
+import { util } from './jsmind.util.js'
+
+export class ViewProvider {
+ constructor(jm, options) {
+ this.opts = options
+ this.jm = jm
+ this.layout = jm.layout
+
+ this.container = null
+ this.e_panel = null
+ this.e_nodes = null
+
+ this.size = { w: 0, h: 0 }
+
+ this.selected_node = null
+ this.editing_node = null
+
+ this.graph = null
+ this.render_node = !!options.custom_node_render
+ ? this._custom_node_render
+ : this._default_node_render
+ this.zoom_current = 1
+ this.device_pixel_ratio = this.opts.enable_device_pixel_ratio
+ ? $.w.devicePixelRatio || 1
+ : 1
+ this._initialized = false
+ }
+ init() {
+ logger.debug(this.opts)
+ logger.debug('view.init')
+
+ this.container = $.i(this.opts.container) ? this.opts.container : $.g(this.opts.container)
+ if (!this.container) {
+ logger.error('the options.view.container was not be found in dom')
+ return
+ }
+ this.graph = init_graph(this, this.opts.engine)
+
+ this.e_panel = $.c('div')
+ this.e_nodes = $.c('jmnodes')
+ this.e_editor = $.c('input')
+ this.e_panel.className = 'jsmind-inner jmnode-overflow-' + this.opts.node_overflow
+ this.e_panel.tabIndex = 1
+ this.e_panel.appendChild(this.graph.element())
+ this.e_panel.appendChild(this.e_nodes)
+
+ this.e_editor.className = 'jsmind-editor'
+ this.e_editor.type = 'text'
+
+ var v = this
+ $.on(this.e_editor, 'keydown', function (e) {
+ var evt = e || event
+ if (evt.keyCode == 13) {
+ v.edit_node_end()
+ evt.stopPropagation()
+ }
+ })
+ $.on(this.e_editor, 'blur', function (e) {
+ v.edit_node_end()
+ })
+
+ this.container.appendChild(this.e_panel)
+
+ if (!this.container.offsetParent) {
+ new IntersectionObserver((entities, observer) => {
+ if (entities[0].isIntersecting) {
+ observer.unobserve(this.e_panel)
+ this.resize()
+ }
+ }).observe(this.e_panel)
+ }
+ }
+
+ add_event(obj, event_name, event_handle, capture_by_panel) {
+ let target = !!capture_by_panel ? this.e_panel : this.e_nodes
+ $.on(target, event_name, function (e) {
+ var evt = e || event
+ event_handle.call(obj, evt)
+ })
+ }
+ get_binded_nodeid(element) {
+ if (element == null) {
+ return null
+ }
+ var tagName = element.tagName.toLowerCase()
+ if (tagName == 'jmnode' || tagName == 'jmexpander') {
+ return element.getAttribute('nodeid')
+ } else if (tagName == 'jmnodes' || tagName == 'body' || tagName == 'html') {
+ return null
+ } else {
+ return this.get_binded_nodeid(element.parentElement)
+ }
+ }
+ is_node(element) {
+ if (element == null) {
+ return false
+ }
+ var tagName = element.tagName.toLowerCase()
+ if (tagName == 'jmnode') {
+ return true
+ } else if (tagName == 'jmnodes' || tagName == 'body' || tagName == 'html') {
+ return false
+ } else {
+ return this.is_node(element.parentElement)
+ }
+ }
+ is_expander(element) {
+ return element.tagName.toLowerCase() == 'jmexpander'
+ }
+ reset() {
+ logger.debug('view.reset')
+ this.selected_node = null
+ this.clear_lines()
+ this.clear_nodes()
+ this.reset_theme()
+ }
+ reset_theme() {
+ var theme_name = this.jm.options.theme
+ if (!!theme_name) {
+ this.e_nodes.className = 'theme-' + theme_name
+ } else {
+ this.e_nodes.className = ''
+ }
+ }
+ reset_custom_style() {
+ var nodes = this.jm.mind.nodes
+ for (var nodeid in nodes) {
+ this.reset_node_custom_style(nodes[nodeid])
+ }
+ }
+ load() {
+ logger.debug('view.load')
+ this.setup_canvas_draggable(this.opts.draggable)
+ this.init_nodes()
+ this._initialized = true
+ }
+ expand_size() {
+ var min_size = this.layout.get_min_size()
+ var min_width = min_size.w + this.opts.hmargin * 2
+ var min_height = min_size.h + this.opts.vmargin * 2
+ var client_w = this.e_panel.clientWidth
+ var client_h = this.e_panel.clientHeight
+ if (client_w < min_width) {
+ client_w = min_width
+ }
+ if (client_h < min_height) {
+ client_h = min_height
+ }
+ this.size.w = client_w
+ this.size.h = client_h
+ }
+ init_nodes_size(node) {
+ var view_data = node._data.view
+ view_data.width = view_data.element.clientWidth
+ view_data.height = view_data.element.clientHeight
+ }
+ init_nodes() {
+ var nodes = this.jm.mind.nodes
+ var doc_frag = $.d.createDocumentFragment()
+ for (var nodeid in nodes) {
+ this.create_node_element(nodes[nodeid], doc_frag)
+ }
+ this.e_nodes.appendChild(doc_frag)
+
+ this.run_in_c11y_mode_if_needed(() => {
+ for (var nodeid in nodes) {
+ this.init_nodes_size(nodes[nodeid])
+ }
+ })
+ }
+ add_node(node) {
+ this.create_node_element(node, this.e_nodes)
+ this.run_in_c11y_mode_if_needed(() => {
+ this.init_nodes_size(node)
+ })
+ }
+ run_in_c11y_mode_if_needed(func) {
+ if (!!this.container.offsetParent) {
+ func()
+ return
+ }
+ logger.warn(
+ 'init nodes in compatibility mode. because the container or its parent has style {display:none}. '
+ )
+ this.e_panel.style.position = 'absolute'
+ this.e_panel.style.top = '-100000'
+ $.d.body.appendChild(this.e_panel)
+ func()
+ this.container.appendChild(this.e_panel)
+ this.e_panel.style.position = null
+ this.e_panel.style.top = null
+ }
+ create_node_element(node, parent_node) {
+ var view_data = null
+ if ('view' in node._data) {
+ view_data = node._data.view
+ } else {
+ view_data = {}
+ node._data.view = view_data
+ }
+
+ var d = $.c('jmnode')
+ if (node.isroot) {
+ d.className = 'root'
+ } else {
+ var d_e = $.c('jmexpander')
+ $.t(d_e, '-')
+ d_e.setAttribute('nodeid', node.id)
+ d_e.style.visibility = 'hidden'
+ parent_node.appendChild(d_e)
+ view_data.expander = d_e
+ }
+ if (!!node.topic) {
+ this.render_node(d, node)
+ }
+ d.setAttribute('nodeid', node.id)
+ d.style.visibility = 'hidden'
+ this._reset_node_custom_style(d, node.data)
+
+ parent_node.appendChild(d)
+ view_data.element = d
+ }
+ remove_node(node) {
+ if (this.selected_node != null && this.selected_node.id == node.id) {
+ this.selected_node = null
+ }
+ if (this.editing_node != null && this.editing_node.id == node.id) {
+ node._data.view.element.removeChild(this.e_editor)
+ this.editing_node = null
+ }
+ var children = node.children
+ var i = children.length
+ while (i--) {
+ this.remove_node(children[i])
+ }
+ if (node._data.view) {
+ var element = node._data.view.element
+ var expander = node._data.view.expander
+ this.e_nodes.removeChild(element)
+ this.e_nodes.removeChild(expander)
+ node._data.view.element = null
+ node._data.view.expander = null
+ }
+ }
+ update_node(node) {
+ var view_data = node._data.view
+ var element = view_data.element
+ if (!!node.topic) {
+ this.render_node(element, node)
+ }
+ if (this.layout.is_visible(node)) {
+ view_data.width = element.clientWidth
+ view_data.height = element.clientHeight
+ } else {
+ let origin_style = element.getAttribute('style')
+ element.style = 'visibility: visible; left:0; top:0;'
+ view_data.width = element.clientWidth
+ view_data.height = element.clientHeight
+ element.style = origin_style
+ }
+ }
+ select_node(node) {
+ if (!!this.selected_node) {
+ var element = this.selected_node._data.view.element
+ element.className = element.className.replace(/\s*selected\b/i, '')
+ this.restore_selected_node_custom_style(this.selected_node)
+ }
+ if (!!node) {
+ this.selected_node = node
+ node._data.view.element.className += ' selected'
+ this.clear_selected_node_custom_style(node)
+ }
+ }
+ select_clear() {
+ this.select_node(null)
+ }
+ get_editing_node() {
+ return this.editing_node
+ }
+ is_editing() {
+ return !!this.editing_node
+ }
+ edit_node_begin(node) {
+ if (!node.topic) {
+ logger.warn("don't edit image nodes")
+ return
+ }
+ if (this.editing_node != null) {
+ this.edit_node_end()
+ }
+ this.editing_node = node
+ var view_data = node._data.view
+ var element = view_data.element
+ var topic = node.topic
+ var ncs = getComputedStyle(element)
+ this.e_editor.value = topic
+ this.e_editor.style.width =
+ element.clientWidth -
+ parseInt(ncs.getPropertyValue('padding-left')) -
+ parseInt(ncs.getPropertyValue('padding-right')) +
+ 'px'
+ element.innerHTML = ''
+ element.appendChild(this.e_editor)
+ element.style.zIndex = 5
+ this.e_editor.focus()
+ this.e_editor.select()
+ }
+ edit_node_end() {
+ if (this.editing_node != null) {
+ var node = this.editing_node
+ this.editing_node = null
+ var view_data = node._data.view
+ var element = view_data.element
+ var topic = this.e_editor.value
+ element.style.zIndex = 'auto'
+ element.removeChild(this.e_editor)
+ if (util.text.is_empty(topic) || node.topic === topic) {
+ this.render_node(element, node)
+ } else {
+ this.jm.update_node(node.id, topic)
+ }
+ }
+ this.e_panel.focus()
+ }
+ get_view_offset() {
+ var bounds = this.layout.bounds
+ var _x = (this.size.w - bounds.e - bounds.w) / 2
+ var _y = this.size.h / 2
+ return { x: _x, y: _y }
+ }
+ resize() {
+ this.graph.set_size(1, 1)
+ this.e_nodes.style.width = '1px'
+ this.e_nodes.style.height = '1px'
+
+ this.expand_size()
+ this._show()
+ }
+ _show() {
+ this.graph.set_size(this.size.w, this.size.h)
+ this.e_nodes.style.width = this.size.w + 'px'
+ this.e_nodes.style.height = this.size.h + 'px'
+ this.show_nodes()
+ this.show_lines()
+ //this.layout.cache_valid = true;
+ this.jm.invoke_event_handle(EventType.resize, { data: [] })
+ }
+ zoom_in(e) {
+ return this.set_zoom(this.zoom_current + this.opts.zoom.step, e)
+ }
+ zoom_out(e) {
+ return this.set_zoom(this.zoom_current - this.opts.zoom.step, e)
+ }
+ set_zoom(zoom, e) {
+ if (zoom < this.opts.zoom.min || zoom > this.opts.zoom.max) {
+ return false
+ }
+ let e_panel_rect = this.e_panel.getBoundingClientRect()
+ if (
+ zoom < 1 &&
+ zoom < this.zoom_current &&
+ this.size.w * zoom < e_panel_rect.width &&
+ this.size.h * zoom < e_panel_rect.height
+ ) {
+ return false
+ }
+ let zoom_center = !!e
+ ? { x: e.x - e_panel_rect.x, y: e.y - e_panel_rect.y }
+ : { x: e_panel_rect.width / 2, y: e_panel_rect.height / 2 }
+ let panel_scroll_x =
+ ((this.e_panel.scrollLeft + zoom_center.x) * zoom) / this.zoom_current - zoom_center.x
+ let panel_scroll_y =
+ ((this.e_panel.scrollTop + zoom_center.y) * zoom) / this.zoom_current - zoom_center.y
+
+ this.zoom_current = zoom
+ for (var i = 0; i < this.e_panel.children.length; i++) {
+ this.e_panel.children[i].style.zoom = zoom
+ }
+ this._show()
+ this.e_panel.scrollLeft = panel_scroll_x
+ this.e_panel.scrollTop = panel_scroll_y
+ return true
+ }
+ show(keep_center) {
+ logger.debug(`view.show: {keep_center: ${keep_center}}`)
+ this.expand_size()
+ this._show()
+ if (!!keep_center) {
+ this.center_node(this.jm.mind.root)
+ }
+ }
+ relayout() {
+ this.expand_size()
+ this._show()
+ }
+ save_location(node) {
+ var vd = node._data.view
+ vd._saved_location = {
+ x: parseInt(vd.element.style.left) - this.e_panel.scrollLeft,
+ y: parseInt(vd.element.style.top) - this.e_panel.scrollTop,
+ }
+ }
+ restore_location(node) {
+ var vd = node._data.view
+ this.e_panel.scrollLeft = parseInt(vd.element.style.left) - vd._saved_location.x
+ this.e_panel.scrollTop = parseInt(vd.element.style.top) - vd._saved_location.y
+ }
+ clear_nodes() {
+ var mind = this.jm.mind
+ if (mind == null) {
+ return
+ }
+ var nodes = mind.nodes
+ var node = null
+ for (var nodeid in nodes) {
+ node = nodes[nodeid]
+ node._data.view.element = null
+ node._data.view.expander = null
+ }
+ this.e_nodes.innerHTML = ''
+ }
+ show_nodes() {
+ var nodes = this.jm.mind.nodes
+ var node = null
+ var node_element = null
+ var p = null
+ var view_data = null
+ var view_offset = this.get_view_offset()
+ for (var nodeid in nodes) {
+ node = nodes[nodeid]
+ view_data = node._data.view
+ node_element = view_data.element
+ if (!this.layout.is_visible(node)) {
+ node_element.style.display = 'none'
+ view_data.expander.style.display = 'none'
+ continue
+ }
+ this.reset_node_custom_style(node)
+ p = this.layout.get_node_point(node)
+ view_data.abs_x = view_offset.x + p.x
+ view_data.abs_y = view_offset.y + p.y
+ node_element.style.left = view_offset.x + p.x + 'px'
+ node_element.style.top = view_offset.y + p.y + 'px'
+ node_element.style.display = ''
+ node_element.style.visibility = 'visible'
+ this._show_expander(node, view_offset)
+ }
+ }
+ _show_expander(node, view_offset) {
+ if (node.isroot) {
+ return
+ }
+
+ var expander = node._data.view.expander
+ if (node.children.length == 0) {
+ expander.style.display = 'none'
+ expander.style.visibility = 'hidden'
+ return
+ }
+
+ let expander_text = this._get_expander_text(node)
+ $.t(expander, expander_text)
+
+ let p_expander = this.layout.get_expander_point(node)
+ expander.style.left = view_offset.x + p_expander.x + 'px'
+ expander.style.top = view_offset.y + p_expander.y + 'px'
+ expander.style.display = ''
+ expander.style.visibility = 'visible'
+ }
+
+ _get_expander_text(node) {
+ let style = !!this.opts.expander_style ? this.opts.expander_style.toLowerCase() : 'char'
+ if (style === 'number') {
+ return node.children.length > 99 ? '...' : node.children.length
+ }
+ if (style === 'char') {
+ return node.expanded ? '-' : '+'
+ }
+ }
+
+ _default_node_render(ele, node) {
+ if (this.opts.support_html) {
+ $.h(ele, node.topic)
+ } else {
+ $.t(ele, node.topic)
+ }
+ }
+ _custom_node_render(ele, node) {
+ let rendered = this.opts.custom_node_render(this.jm, ele, node)
+ if (!rendered) {
+ this._default_node_render(ele, node)
+ }
+ }
+ reset_node_custom_style(node) {
+ this._reset_node_custom_style(node._data.view.element, node.data)
+ }
+ _reset_node_custom_style(node_element, node_data) {
+ if ('background-color' in node_data) {
+ node_element.style.backgroundColor = node_data['background-color']
+ }
+ if ('foreground-color' in node_data) {
+ node_element.style.color = node_data['foreground-color']
+ }
+ if ('width' in node_data) {
+ node_element.style.width = node_data['width'] + 'px'
+ }
+ if ('height' in node_data) {
+ node_element.style.height = node_data['height'] + 'px'
+ }
+ if ('font-size' in node_data) {
+ node_element.style.fontSize = node_data['font-size'] + 'px'
+ }
+ if ('font-weight' in node_data) {
+ node_element.style.fontWeight = node_data['font-weight']
+ }
+ if ('font-style' in node_data) {
+ node_element.style.fontStyle = node_data['font-style']
+ }
+ if ('background-image' in node_data) {
+ var backgroundImage = node_data['background-image']
+ if (backgroundImage.startsWith('data') && node_data['width'] && node_data['height']) {
+ var img = new Image()
+
+ img.onload = function () {
+ var c = $.c('canvas')
+ c.width = node_element.clientWidth
+ c.height = node_element.clientHeight
+ var img = this
+ if (c.getContext) {
+ var ctx = c.getContext('2d')
+ ctx.drawImage(
+ img,
+ 2,
+ 2,
+ node_element.clientWidth,
+ node_element.clientHeight
+ )
+ var scaledImageData = c.toDataURL()
+ node_element.style.backgroundImage = 'url(' + scaledImageData + ')'
+ }
+ }
+ img.src = backgroundImage
+ } else {
+ node_element.style.backgroundImage = 'url(' + backgroundImage + ')'
+ }
+ node_element.style.backgroundSize = '99%'
+
+ if ('background-rotation' in node_data) {
+ node_element.style.transform = 'rotate(' + node_data['background-rotation'] + 'deg)'
+ }
+ }
+ }
+ restore_selected_node_custom_style(node) {
+ var node_element = node._data.view.element
+ var node_data = node.data
+ if ('background-color' in node_data) {
+ node_element.style.backgroundColor = node_data['background-color']
+ }
+ if ('foreground-color' in node_data) {
+ node_element.style.color = node_data['foreground-color']
+ }
+ }
+ clear_selected_node_custom_style(node) {
+ var node_element = node._data.view.element
+ node_element.style.backgroundColor = ''
+ node_element.style.color = ''
+ }
+ clear_lines() {
+ this.graph.clear()
+ }
+ show_lines() {
+ this.clear_lines()
+ var nodes = this.jm.mind.nodes
+ var node = null
+ var pin = null
+ var pout = null
+ var color = null
+ var _offset = this.get_view_offset()
+ for (var nodeid in nodes) {
+ node = nodes[nodeid]
+ if (!!node.isroot) {
+ continue
+ }
+ if (!this.layout.is_visible(node)) {
+ continue
+ }
+ pin = this.layout.get_node_point_in(node)
+ pout = this.layout.get_node_point_out(node.parent)
+ color = node.data['leading-line-color']
+ this.graph.draw_line(pout, pin, _offset, color)
+ }
+ }
+ // Drag the whole mind map with your mouse, when it's larger that the container
+ setup_canvas_draggable(enabled) {
+ this.opts.draggable = enabled
+ if (!this._initialized) {
+ let dragging = false
+ let x, y
+ if (this.opts.hide_scrollbars_when_draggable) {
+ // Avoid scrollbars when mind map is larger than the container (e_panel = id jsmind-inner)
+ this.e_panel.style = 'overflow: hidden'
+ }
+ // Move the whole mind map with mouse moves, while button is down.
+ $.on(this.container, 'mousedown', (eventDown) => {
+ if (this.opts.draggable) {
+ dragging = true
+ // Record current mouse position.
+ x = eventDown.clientX
+ y = eventDown.clientY
+ }
+ })
+ // Stop moving mind map once mouse button is released.
+ $.on(this.container, 'mouseup', () => {
+ dragging = false
+ })
+ // Follow current mouse position and move mind map accordingly.
+ $.on(this.container, 'mousemove', (eventMove) => {
+ if (this.opts.draggable) {
+ if (dragging) {
+ this.e_panel.scrollBy(x - eventMove.clientX, y - eventMove.clientY)
+ // Record new current position.
+ x = eventMove.clientX
+ y = eventMove.clientY
+ }
+ }
+ })
+ }
+ }
+ center_node(node) {
+ if (!this.layout.is_visible(node)) {
+ logger.warn('can not scroll to the node, because it is invisible')
+ return false
+ }
+ let view_data = node._data.view
+ let e_panel_rect = this.e_panel.getBoundingClientRect()
+ let node_center_point = {
+ x: view_data.abs_x + view_data.width / 2,
+ y: view_data.abs_y + view_data.height / 2,
+ }
+ this.e_panel.scrollTo(
+ node_center_point.x * this.zoom_current - e_panel_rect.width / 2,
+ node_center_point.y * this.zoom_current - e_panel_rect.height / 2
+ )
+ return true
+ }
+
+ zoomIn(e) {
+ logger.warn('please use zoom_in instead')
+ return this.zoom_in(e)
+ }
+ zoomOut(e) {
+ logger.warn('please use zoom_out instead')
+ return this.zoom_out(e)
+ }
+ setZoom(zoom, e) {
+ logger.warn('please use set_zoom instead')
+ return this.set_zoom(zoom, e)
+ }
+}
diff --git a/app/assets/javascripts/mind_map/jsmind/plugins/jsmind.draggable-node.js b/app/assets/javascripts/mind_map/jsmind/plugins/jsmind.draggable-node.js
new file mode 100644
index 0000000..46076ba
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/plugins/jsmind.draggable-node.js
@@ -0,0 +1,466 @@
+import jsMind from '../jsmind.js'
+
+if (!jsMind) {
+ throw new Error('jsMind is not defined')
+}
+
+const $ = jsMind.$
+
+const clear_selection =
+ 'getSelection' in $.w
+ ? function () {
+ $.w.getSelection().removeAllRanges()
+ }
+ : function () {
+ $.d.selection.empty()
+ }
+
+const DEFAULT_OPTIONS = {
+ line_width: 5,
+ line_color: 'rgba(0,0,0,0.3)',
+ line_color_invalid: 'rgba(255,51,51,0.6)',
+ lookup_delay: 200,
+ lookup_interval: 100,
+ scrolling_trigger_width: 20,
+ scrolling_step_length: 10,
+ shadow_node_class_name: 'jsmind-draggable-shadow-node',
+}
+
+class DraggableNode {
+ constructor(jm, options) {
+ var opts = {}
+ jsMind.util.json.merge(opts, DEFAULT_OPTIONS)
+ jsMind.util.json.merge(opts, options)
+
+ this.version = '0.4.0'
+ this.jm = jm
+ this.options = opts
+ this.e_canvas = null
+ this.canvas_ctx = null
+ this.shadow = null
+ this.shadow_p_x = 0
+ this.shadow_p_y = 0
+ this.shadow_w = 0
+ this.shadow_h = 0
+ this.active_node = null
+ this.target_node = null
+ this.target_direct = null
+ this.client_w = 0
+ this.client_h = 0
+ this.offset_x = 0
+ this.offset_y = 0
+ this.hlookup_delay = 0
+ this.hlookup_timer = 0
+ this.capture = false
+ this.moved = false
+ this.canvas_draggable = jm.get_view_draggable()
+ this.view_panel = jm.view.e_panel
+ this.view_panel_rect = null
+ }
+ init() {
+ this.create_canvas()
+ this.create_shadow()
+ this.event_bind()
+ }
+ resize() {
+ this.jm.view.e_nodes.appendChild(this.shadow)
+ this.e_canvas.width = this.jm.view.size.w
+ this.e_canvas.height = this.jm.view.size.h
+ }
+ create_canvas() {
+ var c = $.c('canvas')
+ this.jm.view.e_panel.appendChild(c)
+ var ctx = c.getContext('2d')
+ this.e_canvas = c
+ this.canvas_ctx = ctx
+ }
+ create_shadow() {
+ var s = $.c('jmnode')
+ s.style.visibility = 'hidden'
+ s.style.zIndex = '3'
+ s.style.cursor = 'move'
+ s.style.opacity = '0.7'
+ s.className = this.options.shadow_node_class_name
+ this.shadow = s
+ }
+ reset_shadow(el) {
+ var s = this.shadow.style
+ this.shadow.innerHTML = el.innerHTML
+ s.left = el.style.left
+ s.top = el.style.top
+ s.width = el.style.width
+ s.height = el.style.height
+ s.backgroundImage = el.style.backgroundImage
+ s.backgroundSize = el.style.backgroundSize
+ s.transform = el.style.transform
+ this.shadow_w = this.shadow.clientWidth
+ this.shadow_h = this.shadow.clientHeight
+ }
+ show_shadow() {
+ if (!this.moved) {
+ this.shadow.style.visibility = 'visible'
+ }
+ }
+ hide_shadow() {
+ this.shadow.style.visibility = 'hidden'
+ }
+ magnet_shadow(shadow_p, node_p, invalid) {
+ this.canvas_ctx.lineWidth = this.options.line_width
+ this.canvas_ctx.strokeStyle = invalid
+ ? this.options.line_color_invalid
+ : this.options.line_color
+ this.canvas_ctx.lineCap = 'round'
+ this.clear_lines()
+ this.canvas_lineto(shadow_p.x, shadow_p.y, node_p.x, node_p.y)
+ }
+ clear_lines() {
+ this.canvas_ctx.clearRect(0, 0, this.jm.view.size.w, this.jm.view.size.h)
+ }
+ canvas_lineto(x1, y1, x2, y2) {
+ this.canvas_ctx.beginPath()
+ this.canvas_ctx.moveTo(x1, y1)
+ this.canvas_ctx.lineTo(x2, y2)
+ this.canvas_ctx.stroke()
+ }
+ event_bind() {
+ var jd = this
+ var container = this.jm.view.container
+ $.on(container, 'mousedown', function (e) {
+ if (e.button === 0) {
+ jd.dragstart.call(jd, e)
+ }
+ })
+ $.on(container, 'mousemove', function (e) {
+ if (e.movementX !== 0 || e.movementY !== 0) {
+ jd.drag.call(jd, e)
+ }
+ })
+ $.on(container, 'mouseup', function (e) {
+ jd.dragend.call(jd, e)
+ })
+ $.on(container, 'touchstart', function (e) {
+ jd.dragstart.call(jd, e)
+ })
+ $.on(container, 'touchmove', function (e) {
+ jd.drag.call(jd, e)
+ })
+ $.on(container, 'touchend', function (e) {
+ jd.dragend.call(jd, e)
+ })
+ }
+ dragstart(e) {
+ if (!this.jm.get_editable()) {
+ return
+ }
+ if (this.capture) {
+ return
+ }
+ var jview = this.jm.view
+ if (jview.is_editing()) {
+ return
+ }
+ this.active_node = null
+ this.view_draggable = this.jm.get_view_draggable()
+
+ var el = this.find_node_element(e.target)
+ if (!el) {
+ return
+ }
+ if (this.view_draggable) {
+ this.jm.disable_view_draggable()
+ }
+ var nodeid = jview.get_binded_nodeid(el)
+ if (!!nodeid) {
+ var node = this.jm.get_node(nodeid)
+ if (!node.isroot) {
+ this.reset_shadow(el)
+ this.view_panel_rect = this.view_panel.getBoundingClientRect()
+ this.active_node = node
+ this.offset_x =
+ (e.clientX || e.touches[0].clientX) / jview.zoom_current - el.offsetLeft
+ this.offset_y =
+ (e.clientY || e.touches[0].clientY) / jview.zoom_current - el.offsetTop
+ this.client_hw = Math.floor(el.clientWidth / 2)
+ this.client_hh = Math.floor(el.clientHeight / 2)
+ if (this.hlookup_delay != 0) {
+ $.w.clearTimeout(this.hlookup_delay)
+ }
+ if (this.hlookup_timer != 0) {
+ $.w.clearInterval(this.hlookup_timer)
+ }
+ var jd = this
+ this.hlookup_delay = $.w.setTimeout(function () {
+ jd.hlookup_delay = 0
+ jd.hlookup_timer = $.w.setInterval(function () {
+ jd.lookup_target_node.call(jd)
+ }, jd.options.lookup_interval)
+ }, this.options.lookup_delay)
+ jd.capture = true
+ }
+ }
+ }
+ drag(e) {
+ if (!this.jm.get_editable()) {
+ return
+ }
+ if (this.capture) {
+ e.preventDefault()
+ this.show_shadow()
+ this.moved = true
+ clear_selection()
+ var jview = this.jm.view
+ var px = (e.clientX || e.touches[0].clientX) / jview.zoom_current - this.offset_x
+ var py = (e.clientY || e.touches[0].clientY) / jview.zoom_current - this.offset_y
+ // scrolling container axisY if drag nodes exceeding container
+ if (
+ e.clientY - this.view_panel_rect.top < this.options.scrolling_trigger_width &&
+ this.view_panel.scrollTop > this.options.scrolling_step_length
+ ) {
+ this.view_panel.scrollBy(0, -this.options.scrolling_step_length)
+ this.offset_y += this.options.scrolling_step_length / jview.zoom_current
+ } else if (
+ this.view_panel_rect.bottom - e.clientY < this.options.scrolling_trigger_width &&
+ this.view_panel.scrollTop <
+ this.view_panel.scrollHeight -
+ this.view_panel_rect.height -
+ this.options.scrolling_step_length
+ ) {
+ this.view_panel.scrollBy(0, this.options.scrolling_step_length)
+ this.offset_y -= this.options.scrolling_step_length / jview.zoom_current
+ }
+ // scrolling container axisX if drag nodes exceeding container
+ if (
+ e.clientX - this.view_panel_rect.left < this.options.scrolling_trigger_width &&
+ this.view_panel.scrollLeft > this.options.scrolling_step_length
+ ) {
+ this.view_panel.scrollBy(-this.options.scrolling_step_length, 0)
+ this.offset_x += this.options.scrolling_step_length / jview.zoom_current
+ } else if (
+ this.view_panel_rect.right - e.clientX < this.options.scrolling_trigger_width &&
+ this.view_panel.scrollLeft <
+ this.view_panel.scrollWidth -
+ this.view_panel_rect.width -
+ this.options.scrolling_step_length
+ ) {
+ this.view_panel.scrollBy(this.options.scrolling_step_length, 0)
+ this.offset_x -= this.options.scrolling_step_length / jview.zoom_current
+ }
+ this.shadow.style.left = px + 'px'
+ this.shadow.style.top = py + 'px'
+ clear_selection()
+ }
+ }
+ dragend(e) {
+ if (!this.jm.get_editable()) {
+ return
+ }
+ if (this.view_draggable) {
+ this.jm.enable_view_draggable()
+ }
+ if (this.capture) {
+ if (this.hlookup_delay != 0) {
+ $.w.clearTimeout(this.hlookup_delay)
+ this.hlookup_delay = 0
+ this.clear_lines()
+ }
+ if (this.hlookup_timer != 0) {
+ $.w.clearInterval(this.hlookup_timer)
+ this.hlookup_timer = 0
+ this.clear_lines()
+ }
+ if (this.moved) {
+ var src_node = this.active_node
+ var target_node = this.target_node
+ var target_direct = this.target_direct
+ this.move_node(src_node, target_node, target_direct)
+ }
+ this.hide_shadow()
+ }
+ this.view_panel_rect = null
+ this.moved = false
+ this.capture = false
+ }
+ find_node_element(el) {
+ if (
+ !el ||
+ el === this.jm.view.e_nodes ||
+ el === this.jm.view.e_panel ||
+ el === this.jm.view.container
+ ) {
+ return null
+ }
+ if (el.tagName.toLowerCase() === 'jmnode') {
+ return el
+ }
+ return this.find_node_element(el.parentNode)
+ }
+ lookup_target_node() {
+ let sx = this.shadow.offsetLeft
+ let sy = this.shadow.offsetTop
+ if (sx === this.shadow_p_x && sy === this.shadow_p_y) {
+ return
+ }
+ this.shadow_p_x = sx
+ this.shadow_p_y = sy
+
+ let target_direction =
+ this.shadow_p_x + this.shadow_w / 2 >= this.get_root_x()
+ ? jsMind.direction.right
+ : jsMind.direction.left
+ let overlapping_node = this.lookup_overlapping_node_parent(target_direction)
+ let target_node = overlapping_node || this.lookup_close_node(target_direction)
+ if (!!target_node) {
+ let points = this.calc_point_of_node(target_node, target_direction)
+ let invalid = jsMind.node.inherited(this.active_node, target_node)
+ this.magnet_shadow(points.sp, points.np, invalid)
+ this.target_node = target_node
+ this.target_direct = target_direction
+ }
+ }
+ get_root_x() {
+ let root = this.jm.get_root()
+ let root_location = root.get_location()
+ let root_size = root.get_size()
+ return root_location.x + root_size.w / 2
+ }
+
+ lookup_overlapping_node_parent(direction) {
+ let shadowRect = this.shadow.getBoundingClientRect()
+ let x = shadowRect.x + (shadowRect.width * (1 - direction)) / 2
+ let deltaX = (this.jm.options.layout.hspace + this.jm.options.layout.pspace) * direction
+ let deltaY = shadowRect.height
+ let points = [
+ [x, shadowRect.y],
+ [x, shadowRect.y + deltaY / 2],
+ [x, shadowRect.y + deltaY],
+ [x + deltaX / 2, shadowRect.y],
+ [x + deltaX / 2, shadowRect.y + deltaY / 2],
+ [x + deltaX / 2, shadowRect.y + deltaY],
+ [x + deltaX, shadowRect.y],
+ [x + deltaX, shadowRect.y + deltaY / 2],
+ [x + deltaX, shadowRect.y + deltaY],
+ ]
+ for (const p of points) {
+ let n = this.lookup_node_parent_by_location(p[0], p[1])
+ if (!!n) {
+ return n
+ }
+ }
+ }
+
+ lookup_node_parent_by_location(x, y) {
+ return $.d
+ .elementsFromPoint(x, y)
+ .filter(
+ (x) => x.tagName === 'JMNODE' && x.className !== this.options.shadow_node_class_name
+ )
+ .map((el) => this.jm.view.get_binded_nodeid(el))
+ .map((id) => id && this.jm.mind.nodes[id])
+ .map((n) => n && n.parent)
+ .find((n) => n)
+ }
+
+ lookup_close_node(direction) {
+ return Object.values(this.jm.mind.nodes)
+ .filter((n) => n.direction == direction || n.isroot)
+ .filter((n) => this.jm.layout.is_visible(n))
+ .filter((n) => this.shadow_on_target_side(n, direction))
+ .map((n) => ({ node: n, distance: this.shadow_to_node(n, direction) }))
+ .reduce(
+ (prev, curr) => {
+ return prev.distance < curr.distance ? prev : curr
+ },
+ { node: this.jm.get_root(), distance: Number.MAX_VALUE }
+ ).node
+ }
+
+ shadow_on_target_side(node, dir) {
+ return (
+ (dir == jsMind.direction.right && this.shadow_to_right_of_node(node) > 0) ||
+ (dir == jsMind.direction.left && this.shadow_to_left_of_node(node) > 0)
+ )
+ }
+
+ shadow_to_right_of_node(node) {
+ return this.shadow_p_x - node.get_location().x - node.get_size().w
+ }
+
+ shadow_to_left_of_node(node) {
+ return node.get_location().x - this.shadow_p_x - this.shadow_w
+ }
+
+ shadow_to_base_line_of_node(node) {
+ return this.shadow_p_y + this.shadow_h / 2 - node.get_location().y - node.get_size().h / 2
+ }
+
+ shadow_to_node(node, dir) {
+ let distance_x =
+ dir === jsMind.direction.right
+ ? Math.abs(this.shadow_to_right_of_node(node))
+ : Math.abs(this.shadow_to_left_of_node(node))
+ let distance_y = Math.abs(this.shadow_to_base_line_of_node(node))
+ return distance_x + distance_y
+ }
+
+ calc_point_of_node(node, dir) {
+ let ns = node.get_size()
+ let nl = node.get_location()
+ let node_x = node.isroot
+ ? nl.x + ns.w / 2
+ : nl.x + (ns.w * (1 + dir)) / 2 + this.options.line_width * dir
+ let node_y = nl.y + ns.h / 2
+ let shadow_x =
+ this.shadow_p_x + (this.shadow_w * (1 - dir)) / 2 - this.options.line_width * dir
+ let shadow_y = this.shadow_p_y + this.shadow_h / 2
+ return {
+ sp: { x: shadow_x, y: shadow_y },
+ np: { x: node_x, y: node_y },
+ }
+ }
+
+ move_node(src_node, target_node, target_direct) {
+ var shadow_h = this.shadow.offsetTop
+ if (!!target_node && !!src_node && !jsMind.node.inherited(src_node, target_node)) {
+ // lookup before_node
+ var sibling_nodes = target_node.children
+ var sc = sibling_nodes.length
+ var node = null
+ var delta_y = Number.MAX_VALUE
+ var node_before = null
+ var beforeid = '_last_'
+ while (sc--) {
+ node = sibling_nodes[sc]
+ if (node.direction == target_direct && node.id != src_node.id) {
+ var dy = node.get_location().y - shadow_h
+ if (dy > 0 && dy < delta_y) {
+ delta_y = dy
+ node_before = node
+ beforeid = '_first_'
+ }
+ }
+ }
+ if (!!node_before) {
+ beforeid = node_before.id
+ }
+ this.jm.move_node(src_node.id, beforeid, target_node.id, target_direct)
+ }
+ this.active_node = null
+ this.target_node = null
+ this.target_direct = null
+ }
+ jm_event_handle(type, data) {
+ if (type === jsMind.event_type.resize) {
+ this.resize()
+ }
+ }
+}
+
+var draggable_plugin = new jsMind.plugin('draggable_node', function (jm, options) {
+ var jd = new DraggableNode(jm, options)
+ jd.init()
+ jm.add_event_listener(function (type, data) {
+ jd.jm_event_handle.call(jd, type, data)
+ })
+})
+
+jsMind.register_plugin(draggable_plugin)
diff --git a/app/assets/javascripts/mind_map/jsmind/plugins/jsmind.screenshot.js b/app/assets/javascripts/mind_map/jsmind/plugins/jsmind.screenshot.js
new file mode 100644
index 0000000..340bdda
--- /dev/null
+++ b/app/assets/javascripts/mind_map/jsmind/plugins/jsmind.screenshot.js
@@ -0,0 +1,158 @@
+import jsMind from 'jsmind'
+import domtoimage from 'dom-to-image'
+
+if (!jsMind) {
+ throw new Error('jsMind is not defined')
+}
+
+if (!domtoimage) {
+ throw new Error('dom-to-image is required')
+}
+
+const $ = jsMind.$
+
+const DEFAULT_OPTIONS = {
+ filename: null,
+ watermark: {
+ left: $.w.location,
+ right: 'https://github.com/hizzgdev/jsmind',
+ },
+ background: 'transparent',
+}
+
+class JmScreenshot {
+ constructor(jm, options) {
+ var opts = {}
+ jsMind.util.json.merge(opts, DEFAULT_OPTIONS)
+ jsMind.util.json.merge(opts, options)
+
+ this.version = '0.2.0'
+ this.jm = jm
+ this.options = opts
+ this.dpr = jm.view.device_pixel_ratio
+ }
+
+ shoot() {
+ let c = this.create_canvas()
+ let ctx = c.getContext('2d')
+ ctx.scale(this.dpr, this.dpr)
+ Promise.resolve(ctx)
+ .then(() => this.draw_background(ctx))
+ .then(() => this.draw_lines(ctx))
+ .then(() => this.draw_nodes(ctx))
+ .then(() => this.draw_watermark(c, ctx))
+ .then(() => this.download(c))
+ .then(() => this.clear(c))
+ }
+
+ create_canvas() {
+ let c = $.c('canvas')
+ const w = this.jm.view.size.w
+ const h = this.jm.view.size.h
+ c.width = w * this.dpr
+ c.height = h * this.dpr
+ c.style.width = w + 'px'
+ c.style.height = h + 'px'
+
+ c.style.visibility = 'hidden'
+ this.jm.view.e_panel.appendChild(c)
+ return c
+ }
+
+ clear(c) {
+ c.parentNode.removeChild(c)
+ }
+
+ draw_background(ctx) {
+ return new Promise(
+ function (resolve, _) {
+ const bg = this.options.background
+ if (!!bg && bg !== 'transparent') {
+ ctx.fillStyle = this.options.background
+ ctx.fillRect(0, 0, this.jm.view.size.w, this.jm.view.size.h)
+ }
+ resolve(ctx)
+ }.bind(this)
+ )
+ }
+
+ draw_lines(ctx) {
+ return new Promise(
+ function (resolve, _) {
+ this.jm.view.graph.copy_to(ctx, function () {
+ resolve(ctx)
+ })
+ }.bind(this)
+ )
+ }
+
+ draw_nodes(ctx) {
+ return domtoimage
+ .toSvg(this.jm.view.e_nodes, { style: { zoom: 1 } })
+ .then(this.load_image)
+ .then(function (img) {
+ ctx.drawImage(img, 0, 0)
+ return ctx
+ })
+ }
+
+ draw_watermark(c, ctx) {
+ ctx.textBaseline = 'bottom'
+ ctx.fillStyle = '#000'
+ ctx.font = '11px Verdana,Arial,Helvetica,sans-serif'
+ if (!!this.options.watermark.left) {
+ ctx.textAlign = 'left'
+ ctx.fillText(this.options.watermark.left, 5.5, c.height - 2.5)
+ }
+ if (!!this.options.watermark.right) {
+ ctx.textAlign = 'right'
+ ctx.fillText(this.options.watermark.right, c.width - 5.5, c.height - 2.5)
+ }
+ return ctx
+ }
+
+ load_image(url) {
+ return new Promise(function (resolve, reject) {
+ let img = new Image()
+ img.onload = function () {
+ resolve(img)
+ }
+ img.onerror = reject
+ img.src = url
+ })
+ }
+
+ download(c) {
+ var name = (this.options.filename || this.jm.mind.name) + '.png'
+
+ if (navigator.msSaveBlob && !!c.msToBlob) {
+ var blob = c.msToBlob()
+ navigator.msSaveBlob(blob, name)
+ } else {
+ var blob_url = c.toDataURL()
+ var anchor = $.c('a')
+ if ('download' in anchor) {
+ anchor.style.visibility = 'hidden'
+ anchor.href = blob_url
+ anchor.download = name
+ $.d.body.appendChild(anchor)
+ var evt = $.d.createEvent('MouseEvents')
+ evt.initEvent('click', true, true)
+ anchor.dispatchEvent(evt)
+ $.d.body.removeChild(anchor)
+ } else {
+ location.href = blob_url
+ }
+ }
+ }
+}
+
+let screenshot_plugin = new jsMind.plugin('screenshot', function (jm, options) {
+ var jmss = new JmScreenshot(jm, options)
+ jm.screenshot = jmss
+ jm.shoot = function () {
+ jmss.shoot()
+ }
+})
+
+jsMind.register_plugin(screenshot_plugin)
diff --git a/app/assets/javascripts/mind_map/utils/custom.config.js b/app/assets/javascripts/mind_map/utils/custom.config.js
new file mode 100644
index 0000000..f1ececb
--- /dev/null
+++ b/app/assets/javascripts/mind_map/utils/custom.config.js
@@ -0,0 +1,42 @@
+// 顏色選項集中管理
+// Color options management
+export const PALETTE_COLORS = [
+ '#FF0000',
+ '#00FF00',
+ '#0000FF',
+ '#FFFF00',
+ '#FF00FF',
+ '#00FFFF',
+ '#000000',
+ '#666666',
+ '#999999',
+ '#FFFFFF',
+]
+
+// 心智圖初始數據
+export const INITIAL_MIND = {
+ meta: {},
+ format: 'node_array',
+ data: [
+ {
+ id: 'root',
+ topic: 'FirstNode',
+ expanded: true,
+ isroot: true,
+ },
+ ],
+}
+
+// 模擬打 API
+// Simulate an API call to search based on the query
+export async function mockSearchApi(query) {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve([
+ { text: `${query} 建議1`, link: `https://example.com/${query}1` },
+ { text: `${query} 建議2`, link: `https://example.com/${query}2` },
+ { text: `${query} 建議3`, link: `https://example.com/${query}3` },
+ ])
+ }, 500)
+ })
+}
diff --git a/app/assets/javascripts/mind_map/utils/custom.main.js b/app/assets/javascripts/mind_map/utils/custom.main.js
new file mode 100644
index 0000000..b9f2567
--- /dev/null
+++ b/app/assets/javascripts/mind_map/utils/custom.main.js
@@ -0,0 +1,51 @@
+import jsMind from '../jsmind/jsmind.js'
+import '../jsmind/plugins/jsmind.draggable-node.js'
+import { JsmindSearch } from './custom.search.js'
+import { JsmindToolbar } from './custom.toolbar.js'
+import { mockSearchApi } from './custom.config.js'
+
+/**
+ * 初始化 jsMind 心智圖
+ * Initialize jsMind mind map
+ * @param {Object} mind - 心智圖的資料 (Mind map data)
+ * @param {Object} options - 配置選項 (Configuration options)
+ * @param {boolean} isEditable - 是否可編輯 (Is editable)
+ * @returns {Object} - jsMind 實例 (jsMind instance)
+ */
+export function initJsmind(mind, options, isEditable) {
+ const container = document.getElementById(options.container)
+ container.innerHTML = ''
+
+ options.editable = isEditable
+ const jm = new jsMind(options)
+
+ // 依據是否可編輯調整顯示為連結或文字
+ // Adjust display as a link or text based on editability
+ const formattedData = mind.data.map((node) => {
+ node.topic =
+ !isEditable && node.link
+ ? `${node.text} `
+ : node.text || node.topic
+ return node
+ })
+ jm.show({ meta: mind.meta, format: 'node_array', data: formattedData })
+
+ // 掛載附加模組(遠程搜尋 & 工具列)
+ // Attach additional modules (Remote search & Toolbar)
+ if (isEditable) {
+ new JsmindSearch(jm, mockSearchApi)
+ new JsmindToolbar(jm)
+ }
+
+ return jm
+}
+
+/**
+ * 獲取當前心智圖數據
+ * Get the current mind map data
+ * @param {Object} jm - jsMind 實例 (jsMind instance)
+ * @returns {Object} - 心智圖數據 (Mind map data)
+ */
+export function getJsmindData(jm) {
+ return jm.get_data('node_array')
+}
diff --git a/app/assets/javascripts/mind_map/utils/custom.overrides.js b/app/assets/javascripts/mind_map/utils/custom.overrides.js
new file mode 100644
index 0000000..635848c
--- /dev/null
+++ b/app/assets/javascripts/mind_map/utils/custom.overrides.js
@@ -0,0 +1,67 @@
+import { util } from '../jsmind/jsmind.util.js'
+import { Mind } from '../jsmind/jsmind.mind.js'
+import { ViewProvider } from '../jsmind/jsmind.view_provider.js'
+import jsMind from '../jsmind/jsmind.js'
+
+ViewProvider.prototype.edit_node_end = function () {
+ if (this.editing_node != null) {
+ var node = this.editing_node
+ this.editing_node = null
+ var view_data = node._data.view
+ var element = view_data.element
+
+ // 客製化修改:顯示文字由 node.data 控制
+ // Customization: Display text is controlled by node.data
+ var topic = node.data.text
+ element.style.zIndex = 'auto'
+ element.removeChild(this.e_editor)
+ if (util.text.is_empty(topic) || node.topic === topic) {
+ this.render_node(element, node)
+ } else {
+ this.jm.update_node(node.id, topic)
+ }
+ }
+ this.e_panel.focus()
+}
+
+ViewProvider.prototype.select_node = function (node) {
+ if (!!this.selected_node) {
+ var element = this.selected_node._data.view.element
+ element.className = element.className.replace(/\s*selected\b/i, '')
+ this.restore_selected_node_custom_style(this.selected_node)
+ }
+ if (!!node) {
+ this.selected_node = node
+ node._data.view.element.className += ' selected'
+ // 客製化修改:不清除自定義樣式
+ // Customization: Do not clear custom styles
+ // this.clear_selected_node_custom_style(node)
+ }
+}
+
+const originalAddNode = Mind.prototype.add_node
+Mind.prototype.add_node = function (
+ parent_node,
+ node_id,
+ topic,
+ data = {},
+ direction,
+ expanded,
+ idx
+) {
+ for (let style of ['leading-line-color', 'background-color', 'foreground-color']) {
+ if (parent_node.data?.[style]) {
+ data[style] = parent_node.data[style]
+ }
+ }
+ arguments[3] = data
+
+ return originalAddNode.apply(this, arguments)
+}
+
+const originalMousedownHandle = jsMind.prototype.mousedown_handle
+jsMind.prototype.mousedown_handle = function (e) {
+ if (e.button !== 0) return
+
+ return originalMousedownHandle.apply(this, arguments)
+}
diff --git a/app/assets/javascripts/mind_map/utils/custom.search.js b/app/assets/javascripts/mind_map/utils/custom.search.js
new file mode 100644
index 0000000..0185ebe
--- /dev/null
+++ b/app/assets/javascripts/mind_map/utils/custom.search.js
@@ -0,0 +1,162 @@
+import { getRelativePosition } from './custom.util.js'
+
+const EDITOR_CLASS = 'jsmind-editor' // jsmind class name
+const SUGGESTION_BOX_CLASS = 'jsmind-suggestions'
+const SUGGESTION_ITEM_CLASS = 'suggestion-item'
+
+/**
+ * jsMind 搜尋管理
+ * jsMind Search Manager
+ */
+export class JsmindSearch {
+ /**
+ * 建構搜尋
+ * Constructor for search
+ * @param {Object} jm - jsMind 實例 (jsMind instance)
+ * @param {Function} searchAPI - 遠程搜尋 API 函式 (Remote search API function)
+ */
+ constructor(jm, searchAPI) {
+ this.jm = jm
+ this.searchAPI = searchAPI
+ this.container = document.getElementById(jm.options.container)
+ this.suggestionBox = null
+
+ this.init()
+ }
+
+ /**
+ * 初始化搜尋事件
+ * Initialize search events
+ */
+ init() {
+ // 確保不會重複綁定 dblclick 事件
+ // Ensure double-click event is not bound multiple times
+ this.container.removeEventListener('dblclick', this.onDoubleClick)
+ this.container.addEventListener('dblclick', this.onDoubleClick.bind(this))
+ }
+
+ /**
+ * 處理雙擊事件以觸發搜尋
+ * Handle double-click event to trigger search
+ * @param {Event} e - 事件對象 (Event object)
+ */
+ onDoubleClick(e) {
+ // 非可編輯狀態不執行
+ // Ignore if not editable
+ if (!this.jm.options.editable) return
+
+ const node = this.jm.get_selected_node()
+ if (!node) return
+
+ // 避免影響原生編輯功能,稍後執行
+ // Prevent interfering with native edit mode
+ setTimeout(() => this.handleSearch(node), 100)
+ }
+
+ /**
+ * 開始處理搜尋
+ * Start handling search
+ * @param {Object} node - 當前選中節點 (Selected node)
+ */
+ handleSearch(node) {
+ const inputField = document.querySelector(`.${EDITOR_CLASS}`)
+ if (!inputField) return
+
+ // 確保不會重複綁定 input 事件
+ // Ensure input event is not bound multiple times
+ inputField.removeEventListener('input', this.onInput)
+ inputField.addEventListener('input', this.onInput.bind(this, node))
+ }
+
+ /**
+ * 處理使用者輸入
+ * Handle user input
+ * @param {Object} node - 當前選中節點 (Selected node)
+ * @param {Event} e - 輸入事件 (Input event)
+ */
+ async onInput(node, e) {
+ const query = e.target.value.trim()
+ if (!query) return
+ try {
+ const results = await this.searchAPI(query)
+ this.showSuggestion(node, e.target, results)
+ } catch (error) {
+ // Search API error handling
+ console.error('搜尋 API 錯誤:', error)
+ }
+ }
+
+ /**
+ * 顯示搜尋建議框
+ * Show search suggestion box
+ * @param {Object} node - 當前選中節點 (Selected node)
+ * @param {HTMLElement} inputElement - 輸入框 (Input field)
+ * @param {Array} results - 搜尋結果 (Search results)
+ */
+ showSuggestion(node, inputElement, results) {
+ const container = this.container
+ const nodeElement = inputElement.parentNode
+ if (!nodeElement) return
+
+ const { left, top, height } = getRelativePosition(nodeElement, container)
+ this.suggestionBox = this.suggestionBox || this.createSuggestionBox()
+
+ // 更新建議框內容
+ // Update suggestion box content
+ this.suggestionBox.innerHTML = results
+ .map(
+ (item) =>
+ `
${item.text}
`
+ )
+ .join('')
+
+ this.suggestionBox.style.left = `${left}px`
+ this.suggestionBox.style.top = `${top + height}px`
+ this.suggestionBox.style.display = 'block'
+
+ // 綁定建議點擊事件
+ // Bind suggestion click events
+ document.querySelectorAll(`.${SUGGESTION_ITEM_CLASS}`).forEach((item) => {
+ item.removeEventListener('mousedown', this.onSuggestionClick)
+ item.addEventListener('mousedown', this.onSuggestionClick.bind(this, node))
+ })
+ }
+
+ /**
+ * 建立搜尋建議框
+ * Create search suggestion box
+ * @returns {HTMLElement} - 建議框 DOM (Suggestion box DOM)
+ */
+ createSuggestionBox() {
+ let suggestionBox = document.getElementById(SUGGESTION_BOX_CLASS)
+ if (!suggestionBox) {
+ suggestionBox = document.createElement('div')
+ suggestionBox.classList.add(SUGGESTION_BOX_CLASS)
+ this.container.appendChild(suggestionBox)
+ }
+ return suggestionBox
+ }
+
+ /**
+ * 處理點擊建議
+ * Handle suggestion click
+ * @param {Object} node - 當前選中節點 (Selected node)
+ * @param {Event} e - 點擊事件 (Click event)
+ */
+ onSuggestionClick(node, e) {
+ e.preventDefault()
+
+ const text = e.target.getAttribute('data-text')
+ const link = e.target.getAttribute('data-link')
+
+ node.data.text = text
+ node.data.link = link
+
+ this.jm.end_edit()
+ this.jm.update_node(node.id, text)
+
+ // 選擇後隱藏建議框
+ // Hide suggestions after selection
+ this.suggestionBox.style.display = 'none'
+ }
+}
diff --git a/app/assets/javascripts/mind_map/utils/custom.toolbar.js b/app/assets/javascripts/mind_map/utils/custom.toolbar.js
new file mode 100644
index 0000000..a367f14
--- /dev/null
+++ b/app/assets/javascripts/mind_map/utils/custom.toolbar.js
@@ -0,0 +1,265 @@
+import { util } from '../jsmind/jsmind.util.js'
+import { getRelativePosition } from './custom.util.js'
+import { PALETTE_COLORS } from './custom.config.js'
+
+const TOOLBAR_ID = 'jsmind-toolbar'
+
+/**
+ * jsMind 工具列管理
+ * jsMind Toolbar Manager
+ */
+export class JsmindToolbar {
+ /**
+ * 建構工具列
+ * Constructor for toolbar
+ * @param {Object} jm - jsMind 實例 (jsMind instance)
+ */
+ constructor(jm) {
+ this.jm = jm
+ this.container = document.getElementById(jm.options.container)
+ this.toolbarNodeId = null
+ this.toolbar = null
+ this.bgColorPalette = null
+ this.strokeColorPalette = null
+ this.textColorPalette = null
+
+ this.init()
+ }
+
+ /**
+ * 初始化工具列事件
+ * Initialize toolbar events
+ */
+ init() {
+ // 監聽節點選取事件
+ // Listen for node selection events
+ this.jm.add_event_listener((e, f, g) => {
+ // 忽略非選擇節點事件
+ // Ignore non-selection events
+ if (e !== 4) return
+
+ const node = this.jm.get_selected_node()
+ if (!node || node.id === this.toolbarNodeId) return
+
+ this.toolbarNodeId = node.id
+ if (!this.toolbar) {
+ this.createToolbar()
+ }
+ this.moveToolbar(node)
+ })
+
+ // 確保不會重複綁定點擊事件
+ // Ensure click event is not bound multiple times
+ this.container.removeEventListener('click', this.onClickOutside)
+ this.container.addEventListener('click', this.onClickOutside.bind(this))
+ }
+
+ /**
+ * 處理點擊事件來隱藏工具列
+ * Handle click event to hide toolbar
+ * @param {Event} e - 事件對象 (Event object)
+ */
+ onClickOutside(e) {
+ const clickedNode = e.target.tagName === 'JMNODE'
+ const clickedToolbar = e.target.closest(`#${TOOLBAR_ID}`)
+ if (!clickedNode && !clickedToolbar && this.toolbar) {
+ this.hideToolbar()
+ }
+ }
+
+ /**
+ * 建立工具列 UI
+ * Create toolbar UI
+ */
+ createToolbar() {
+ this.toolbar = document.createElement('div')
+ this.toolbar.id = TOOLBAR_ID
+
+ // 建立工具列按鈕
+ // Create toolbar buttons
+ const buttons = [
+ { id: 'toolbar-add-child-btn', text: '新增', onClick: this.handleAddChild.bind(this) },
+ { id: 'toolbar-delete-btn', text: '刪除', onClick: this.handleDelete.bind(this) },
+ {
+ id: 'toolbar-stroke-color-btn',
+ text: '線條顏色',
+ onClick: this.handleStrokeColor.bind(this),
+ },
+ {
+ id: 'toolbar-bg-color-btn',
+ text: '背景顏色',
+ onClick: this.handleBgColor.bind(this),
+ },
+ {
+ id: 'toolbar-text-color-btn',
+ text: '文字顏色',
+ onClick: this.handleTextColor.bind(this),
+ },
+ ]
+ buttons.forEach((button) => {
+ const btn = document.createElement('button')
+ btn.id = button.id
+ btn.innerText = button.text
+ btn.onclick = button.onClick
+ this.toolbar.appendChild(btn)
+ // 附加顏色選單
+ // Append color palettes to corresponding buttons
+ if (button.id === 'toolbar-bg-color-btn') {
+ this.bgColorPalette = this.createColorPalette(
+ (color) => this.setNodeStyle('background-color', color),
+ btn
+ )
+ }
+ if (button.id === 'toolbar-stroke-color-btn') {
+ this.strokeColorPalette = this.createColorPalette(
+ (color) => this.setNodeStyle('leading-line-color', color),
+ btn
+ )
+ }
+ if (button.id === 'toolbar-text-color-btn') {
+ this.textColorPalette = this.createColorPalette(
+ (color) => this.setNodeStyle('foreground-color', color),
+ btn
+ )
+ }
+ })
+
+ this.container.appendChild(this.toolbar)
+ }
+
+ /**
+ * 移動工具列至選中節點
+ * Move the toolbar to the selected node
+ */
+ moveToolbar(node) {
+ const nodeElement = node._data.view.element
+ if (!nodeElement) return
+
+ const { left, top } = getRelativePosition(nodeElement, this.container)
+ this.toolbar.style.left = `${left}px`
+ this.toolbar.style.top = `${top - 40}px`
+ this.toolbar.style.display = 'block'
+
+ // 根節點則隱藏刪除與線條顏色按鈕
+ // Hide delete & stroke color buttons if the node is root
+ const deleteBtn = this.toolbar.querySelector('#toolbar-delete-btn')
+ const strokeColorBtn = this.toolbar.querySelector('#toolbar-stroke-color-btn')
+
+ if (node.id === 'root') {
+ deleteBtn.style.display = 'none'
+ strokeColorBtn.style.display = 'none'
+ } else {
+ deleteBtn.style.display = 'inline-block'
+ strokeColorBtn.style.display = 'inline-block'
+ }
+ }
+
+ /**
+ * 隱藏工具列
+ * Hide the toolbar
+ */
+ hideToolbar() {
+ this.toolbar.style.display = 'none'
+ this.bgColorPalette.style.display = 'none'
+ this.strokeColorPalette.style.display = 'none'
+ this.textColorPalette.style.display = 'none'
+ this.toolbarNodeId = null
+ }
+
+ /**
+ * 建立顏色選單
+ * Create color palette
+ */
+ createColorPalette(onSelect, button) {
+ const colorPalette = document.createElement('div')
+ colorPalette.classList.add('toolbar-color-palette')
+ colorPalette.style.display = 'none'
+
+ PALETTE_COLORS.forEach((color) => {
+ const colorBox = document.createElement('div')
+ colorBox.classList.add('toolbar-color-palette-box')
+ colorBox.style.backgroundColor = color
+ colorBox.onclick = () => {
+ onSelect(color)
+ colorPalette.style.display = 'none'
+ }
+ colorPalette.appendChild(colorBox)
+ })
+
+ button.appendChild(colorPalette)
+ return colorPalette
+ }
+
+ /**
+ * 設定節點樣式
+ * Set node style
+ */
+ setNodeStyle(style, color) {
+ if (!this.toolbarNodeId) return
+ const node = this.jm.get_node(this.toolbarNodeId)
+ if (!node) return
+ node.data[style] = color
+ if (style === 'leading-line-color') this.jm.view.show_lines()
+ else this.jm.view.restore_selected_node_custom_style(node)
+ }
+
+ /**
+ * 處理新增節點事件
+ * Handle add child node event
+ */
+ handleAddChild(e) {
+ e.preventDefault();
+ e.stopPropagation()
+ if (!this.toolbarNodeId) return
+ const node = this.jm.get_node(this.toolbarNodeId)
+ if (!node) return
+
+ const newNode = this.jm.add_node(node, util.uuid.newid(), 'NewNode')
+ this.jm.select_node(newNode)
+ }
+
+ /**
+ * 處理刪除節點事件
+ * Handle delete node event
+ */
+ handleDelete(e) {
+ e.preventDefault();
+ e.stopPropagation()
+ if (!this.toolbarNodeId) return
+ const node = this.jm.get_node(this.toolbarNodeId)
+ if (!node) return
+ this.jm.remove_node(node)
+ this.hideToolbar()
+ }
+
+ /**
+ * 處理其他樣式設定事件
+ * Handle style setting event
+ */
+ handleStrokeColor(e) {
+ e.preventDefault();
+ e.stopPropagation()
+ this.toggleColorPalette(this.strokeColorPalette)
+ }
+ handleBgColor(e) {
+ e.preventDefault();
+ e.stopPropagation()
+ this.toggleColorPalette(this.bgColorPalette)
+ }
+ handleTextColor(e) {
+ e.preventDefault();
+ e.stopPropagation()
+ this.toggleColorPalette(this.textColorPalette)
+ }
+
+ /**
+ * 顯示或隱藏顏色選單
+ * Toggle color palette display
+ */
+ toggleColorPalette(palette) {
+ ;[this.bgColorPalette, this.strokeColorPalette, this.textColorPalette].forEach((p) => {
+ if (p !== palette) p.style.display = 'none'
+ })
+ palette.style.display = palette.style.display === 'block' ? 'none' : 'block'
+ }
+}
diff --git a/app/assets/javascripts/mind_map/utils/custom.util.js b/app/assets/javascripts/mind_map/utils/custom.util.js
new file mode 100644
index 0000000..a79f840
--- /dev/null
+++ b/app/assets/javascripts/mind_map/utils/custom.util.js
@@ -0,0 +1,17 @@
+/**
+ * 獲取元素相對於指定容器的位置
+ * Get the relative position of an element within a given container
+ * @param {HTMLElement} element - 目標元素 (Target element)
+ * @param {HTMLElement} container - 參考容器 (Reference container)
+ * @returns {Object} - { left, top, height } 位置資訊 (Position details)
+ */
+export function getRelativePosition(element, container) {
+ let nodeRect = element.getBoundingClientRect()
+ let containerRect = container.getBoundingClientRect()
+
+ return {
+ left: nodeRect.left - containerRect.left,
+ top: nodeRect.top - containerRect.top,
+ height: nodeRect.height,
+ }
+}
diff --git a/app/assets/stylesheets/mind_map/mindmap.css b/app/assets/stylesheets/mind_map/mindmap.css
new file mode 100644
index 0000000..001372f
--- /dev/null
+++ b/app/assets/stylesheets/mind_map/mindmap.css
@@ -0,0 +1,171 @@
+/* ============================
+ 基礎布局 (Layout)
+ ============================ */
+.jsmind-inner {
+ position: relative;
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+ outline: none;
+ user-select: none; /* 防止文字選取 */
+}
+
+.jsmind-inner canvas {
+ position: absolute;
+}
+
+#jsmind_container {
+ position: relative;
+ width: 1200px;
+ height: 800px;
+ border: 1px solid #ccc;
+ background: #f4f4f4;
+}
+
+/* ============================
+ 層級管理 (Z-index)
+ ============================ */
+svg.jsmind,
+canvas.jsmind {
+ position: absolute;
+ z-index: 1;
+}
+
+jmnodes {
+ position: absolute;
+ z-index: 2;
+ background-color: rgba(0, 0, 0, 0); /* 透明背景,確保可點擊 */
+}
+
+jmnode {
+ position: absolute;
+ cursor: default;
+ max-width: 400px;
+}
+
+jmexpander {
+ position: absolute;
+ width: 11px;
+ height: 11px;
+ display: block;
+ overflow: hidden;
+ line-height: 12px;
+ font-size: 10px;
+ text-align: center;
+ border-radius: 6px;
+ border-width: 1px;
+ border-style: solid;
+ cursor: pointer;
+}
+
+/* ============================
+ 文字溢出控制 (Overflow)
+ ============================ */
+.jmnode-overflow-wrap jmnodes {
+ min-width: 420px;
+}
+
+.jmnode-overflow-hidden jmnode {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ============================
+ 預設主題 (Default Theme)
+ ============================ */
+jmnode {
+ padding: 10px;
+ background-color: #fff;
+ color: #333;
+ border-radius: 5px;
+ box-shadow: 1px 1px 1px #666;
+ font: 16px/1.125 Verdana, Arial, Helvetica, sans-serif;
+}
+
+jmnode:hover {
+ box-shadow: 2px 2px 8px #000;
+ filter: brightness(95%);
+}
+
+jmnode.selected {
+ box-shadow: 2px 2px 8px #000;
+ filter: brightness(90%);
+}
+
+jmnode.root {
+ font-size: 24px;
+}
+
+/* 展開/收合按鈕 */
+jmexpander {
+ border-color: gray;
+}
+
+jmexpander:hover {
+ border-color: #000;
+}
+
+/* ============================
+ 響應式設計 (Responsive)
+ ============================ */
+@media screen and (max-device-width: 1024px) {
+ jmnode {
+ padding: 5px;
+ border-radius: 3px;
+ font-size: 14px;
+ }
+
+ jmnode.root {
+ font-size: 21px;
+ }
+}
+
+/* ============================
+ 工具列樣式 (Toolbar Styles)
+ ============================ */
+#jsmind-toolbar {
+ position: absolute;
+ z-index: 1000;
+}
+
+/* 顏色選單 */
+.toolbar-color-palette {
+ position: absolute;
+ bottom: 30px;
+ background: #ccc;
+ border: 1px solid #ccc;
+ z-index: 1001;
+ white-space: nowrap;
+}
+
+.toolbar-color-palette-box {
+ width: 20px;
+ height: 20px;
+ margin: 2px;
+ display: inline-block;
+ cursor: pointer;
+}
+
+/* ============================
+ 遠程搜尋下拉選單 (Search Dropdown)
+ ============================ */
+.jsmind-suggestions {
+ position: absolute;
+ height: 200px;
+ width: 200px;
+ background: white;
+ border: 1px solid #ccc;
+ box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+}
+
+.suggestion-item {
+ padding: 8px;
+ cursor: pointer;
+ border-bottom: 1px solid #eee;
+}
+
+.suggestion-item:hover {
+ background: #f0f0f0;
+}
diff --git a/app/controllers/admin/mind_maps_controller.rb b/app/controllers/admin/mind_maps_controller.rb
new file mode 100644
index 0000000..690d29a
--- /dev/null
+++ b/app/controllers/admin/mind_maps_controller.rb
@@ -0,0 +1,12 @@
+class Admin::MindMapsController < OrbitAdminController
+ def landing_page
+ table = UTable.find(params[:id])
+ if table.mind_map.nil?
+ @mind_map = MindMap.new
+ @mind_map.u_table = table
+ @mind_map.save
+ else
+ @mind_map = table.mind_map
+ end
+ end
+end
diff --git a/app/models/mind_map.rb b/app/models/mind_map.rb
new file mode 100644
index 0000000..00d8518
--- /dev/null
+++ b/app/models/mind_map.rb
@@ -0,0 +1,10 @@
+class MindMap
+ include Mongoid::Document
+ include Mongoid::Timestamps
+ include Slug
+
+ field :title, as: :slug_title, localize: true
+
+ belongs_to :u_table
+ has_many :mind_map_nodes, :dependent => :destroy
+end
diff --git a/app/models/mind_map_node.rb b/app/models/mind_map_node.rb
new file mode 100644
index 0000000..b8f4ac9
--- /dev/null
+++ b/app/models/mind_map_node.rb
@@ -0,0 +1,15 @@
+class MindMapNode
+ include Mongoid::Document
+ include Mongoid::Timestamps
+
+ field :node_id
+ field :parent_node_id
+ field :text, type: String
+ field :expanded, type: Boolean
+ field :background_color, type: String
+ field :foreground_color, type: String
+ field :leading_line_color, type: String
+ field :link, type: String
+
+ belongs_to :mind_map
+end
diff --git a/app/models/u_table.rb b/app/models/u_table.rb
index 95c3e9d..3b95ff2 100644
--- a/app/models/u_table.rb
+++ b/app/models/u_table.rb
@@ -5,7 +5,7 @@ class UTable
include Slug
field :title, as: :slug_title, localize: true
-
+
field :ordered_with_sort_number, type: Boolean, default: false
field :sort_number_order_direction, type: String, default: 'desc'
@@ -14,6 +14,7 @@ class UTable
has_many :table_columns, :dependent => :destroy
has_many :table_entries, :dependent => :destroy
+ has_one :mind_map, :dependent => :destroy
accepts_nested_attributes_for :table_columns, :allow_destroy => true
@@ -37,4 +38,4 @@ class UTable
end
[sort_column,direction]
end
-end
\ No newline at end of file
+end
diff --git a/app/views/admin/mind_maps/_form.html.erb b/app/views/admin/mind_maps/_form.html.erb
new file mode 100644
index 0000000..03db7b5
--- /dev/null
+++ b/app/views/admin/mind_maps/_form.html.erb
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+ Disable Editing
+ Save Mind Map
+
+
+
+
\ No newline at end of file
diff --git a/app/views/admin/mind_maps/landing_page.html.erb b/app/views/admin/mind_maps/landing_page.html.erb
new file mode 100644
index 0000000..7244295
--- /dev/null
+++ b/app/views/admin/mind_maps/landing_page.html.erb
@@ -0,0 +1,72 @@
+<% content_for :page_specific_css do %>
+ <%= stylesheet_link_tag "universal_table/universal-table" %>
+ <%= stylesheet_link_tag "mind_map/mindmap" %>
+<% end %>
+<%= form_for @mind_map, url: admin_mind_map_path(@mind_map), html: {class: "form-horizontal main-forms"} do |f| %>
+ <%= render :partial => "form", locals: {f: f} %>
+<% end %>
+
diff --git a/app/views/admin/universal_tables/_index.html.erb b/app/views/admin/universal_tables/_index.html.erb
index b543612..a9aed1e 100644
--- a/app/views/admin/universal_tables/_index.html.erb
+++ b/app/views/admin/universal_tables/_index.html.erb
@@ -16,6 +16,7 @@
<% if can_edit %>
<%= t(:edit) %>
+ "><%= t("universal_table.mind_map") %>
<% if table.ordered_with_sort_number %>
<%= t('universal_table.edit_sort') %>
<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2ac7437..db7fd88 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -20,4 +20,5 @@ en:
manual_update_sort: Manually Update Sorting
drag_file_to_here: Drag file to here
show_lang: Language
- downloaded_times: Downloaded Times
\ No newline at end of file
+ downloaded_times: Downloaded Times
+ mind_map: Mind Map
\ No newline at end of file
diff --git a/config/locales/zh_tw.yml b/config/locales/zh_tw.yml
index aaef0cc..405ed39 100644
--- a/config/locales/zh_tw.yml
+++ b/config/locales/zh_tw.yml
@@ -20,4 +20,5 @@ zh_tw:
manual_update_sort: 手動更新排序
drag_file_to_here: 拖移檔案到此
show_lang: 呈現語系
- downloaded_times: 下載次數
\ No newline at end of file
+ downloaded_times: 下載次數
+ mind_map: Mind Map
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 458d847..020632f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -22,6 +22,7 @@ Rails.application.routes.draw do
patch "/universal_tables/update_entry", to: 'universal_tables#update_entry'
post "/universal_tables/import_data_from_excel", to: 'universal_tables#import_data_from_excel'
get "universal_tables/checkforthread", to: "universal_tables#checkforthread"
+ get "/universal_table/:id/mind_map", to: "mind_maps#landing_page"
resources :universal_tables do
get "new_entry"
delete "delete_entry"
@@ -33,6 +34,7 @@ Rails.application.routes.draw do
get "export_data"
end
end
+ resources :mind_maps
end
get "/xhr/universal_table/export", to: 'universal_tables#export_filtered'
get "/xhr/universal_table/download", to: "universal_tables#download_file"