adding mind map

This commit is contained in:
rulingcom 2025-05-23 11:41:14 +08:00
parent b4879bd7ae
commit ed5c0a4223
34 changed files with 5389 additions and 4 deletions

View File

@ -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
}
}

View File

@ -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 {
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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: '<map version="1.0.1"><node ID="root" TEXT="jsMind freemind example"/></map>',
},
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('<map version="1.0.1">')
df._build_map(mind.root, xml_lines)
xml_lines.push('</map>')
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('<node')
xml_lines.push(' ID="' + node.id + '"')
if (!!pos) {
xml_lines.push(' POSITION="' + pos + '"')
}
if (!node.expanded) {
xml_lines.push(' FOLDED="true"')
}
xml_lines.push(' TEXT="' + df._escape(node.topic) + '">')
// for attributes
var node_data = node.data
if (node_data != null) {
for (var k in node_data) {
xml_lines.push('<attribute NAME="' + k + '" VALUE="' + node_data[k] + '"/>')
}
}
// for children
var children = node.children
for (var i = 0; i < children.length; i++) {
df._build_map(children[i], xml_lines)
}
xml_lines.push('</node>')
},
_escape: function (text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;')
},
},
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)
}
}
},
},
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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()
}
}
}

View File

@ -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
},
},
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
})
}

View File

@ -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
? `<a href="${node.link}" target="_blank">${node.text}</a>`
: 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')
}

View File

@ -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)
}

View File

@ -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) =>
`<div class="${SUGGESTION_ITEM_CLASS}" data-link="${item.link}" data-text="${item.text}">${item.text}</div>`
)
.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'
}
}

View File

@ -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'
}
}

View File

@ -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,
}
}

View File

@ -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;
}

View File

@ -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

10
app/models/mind_map.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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
end

View File

@ -0,0 +1,39 @@
<fieldset class="utable-heading-wrap">
<div class="utable-heading-header">
<h4><%= t("universal_table.table_name") %> - <%= @mind_map.u_table.title %></h4>
</div>
<div class="control-group">
<div class="controls">
<div class="input-append">
<div class="tab-content">
<% @site_in_use_locales.each do |locale| %>
<% active = (locale == @site_in_use_locales.first ? "active in" : "") %>
<div class="tab-pane fade <%= active %>" id="mind_map_<%= locale.to_s %>">
<%= f.fields_for :title_translations do |f| %>
<%= f.text_field locale, :placeholder => "Title", :value => @mind_map.title_translations[locale] %>
<% end %>
</div>
<% end %>
</div>
<div class="btn-group" data-toggle="buttons-radio">
<% @site_in_use_locales.each do |locale| %>
<% active = (locale == @site_in_use_locales.first ? "active" : "") %>
<%= link_to t(locale).to_s,"#mind_map_#{locale.to_s}",:class=>"btn #{active}",:data=>{:toggle=>"tab"}%>
<% end %>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset class="utable-heading-wrap">
<div class="utable-heading-header">
<h4><%= t("universal_table.mind_map") %></h4>
</div>
<div class="control-group">
<div class="controls">
<button id="toggle_editable">Disable Editing</button>
<button id="save_mind_map">Save Mind Map</button>
</div>
<div id="jsmind_container"></div>
</div>
</fieldset>

View File

@ -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 %>
<script type="module">
import '/assets/mind_map/utils/custom.overrides.js'
import '/assets/mind_map/jsmind/plugins/jsmind.draggable-node.js'
import { initJsmind, getJsmindData } from '/assets/mind_map/utils/custom.main.js'
import { INITIAL_MIND } from '/assets/mind_map/utils/custom.config.js'
// 操控心智圖是否可編輯
// Control whether the mind map is editable
let isEditable = true
// 心智圖實例
// Mind map instance
let jm
// 心智圖初始數據
// Initial mind map data
let mind = INITIAL_MIND
// 心智圖自訂選項(可參考 jsmind 官方文檔)
// Custom options for the mind map (refer to the jsmind official documentation)
const options = {
container: 'jsmind_container',
editable: isEditable,
theme: 'primary',
mode: 'full',
view: {
engine: 'svg',
draggable: true,
node_overflow: 'wrap',
},
shortcut: {
mapping: {
// 避免與 Toolbar 按下 Enter 事件衝突
// Avoid conflicts with the Enter key event in the Toolbar
addbrother: 2048 + 13,
},
},
}
// 初始化心智圖並掛載實例
// Initialize the mind map and attach the instance
jm = initJsmind(mind, options, isEditable)
// 儲存當前數據
// Save the current data
document.getElementById('save_mind_map').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
let data = getJsmindData(jm)
console.log(data)
})
// 調整可編輯狀態
// Toggle the editable state
document.getElementById('toggle_editable').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
isEditable = !isEditable
e.target.innerHTML = isEditable ? 'Disable Editing' : 'Enable Editing'
mind = getJsmindData(jm)
jm = initJsmind(mind, options, isEditable)
return false;
})
</script>

View File

@ -16,6 +16,7 @@
<ul class="nav nav-pills">
<% if can_edit %>
<li><a href="<%= edit_admin_universal_table_path(table) %>"><%= t(:edit) %></a></li>
<li><a href="<%= "/admin/universal_table/#{table.id.to_s}/mind_map" %>"><%= t("universal_table.mind_map") %></a></li>
<% if table.ordered_with_sort_number %>
<li><a href="<%= admin_universal_table_edit_sort_path(table) %>"><%= t('universal_table.edit_sort') %></a></li>
<% end %>

View File

@ -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
downloaded_times: Downloaded Times
mind_map: Mind Map

View File

@ -20,4 +20,5 @@ zh_tw:
manual_update_sort: 手動更新排序
drag_file_to_here: 拖移檔案到此
show_lang: 呈現語系
downloaded_times: 下載次數
downloaded_times: 下載次數
mind_map: Mind Map

View File

@ -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"