UNPKG

mind.svg.js

Version:

Display and operate MindMap using SVG in browser

1,515 lines (1,400 loc) 70.7 kB
import { Constants as EVENT, fireEvent, mapEventHandler, namedHandler, hasEventHandler, handledEvent, continueSiblingHandler } from "./mind.svg.event"; import { ENode, isENode } from "./mind.svg.node"; import { unused, ERROR } from "./mind.svg.util"; import * as Extend from "./mind.svg.extend"; // 检查用的定时器节拍 const TIMER_CHECKER_INTERVAL = 100; // 私有成员的存储符号 const PRIVATE_SYMBOL = Symbol("MindSVG.Private"); // ID的前缀 const PREFIX_ID = "MID-"; // 思维导图主题项的标记戳值 const STAMP_TOPIC_TITLE = "topic-item-title"; // 思维导图主题项折叠按钮标记戳 const STAMP_FOLD_ICON = "topic-item-fold-icon"; // 思维导图主题项图片的标记戳 const STAMP_TOPIC_IMAGE = "topic-item-image"; // 思维导图的LABEL更多按钮的标记戳 const STAMP_TOPIC_LABEL_MORE = "topic-labels-more"; // 思维导图的LABEL的标记戳 const STAMP_TOPIC_LABELS = "topic-labels"; // 思维导图的备注的标记戳 const STAMP_TOPIC_NOTES = "topic-notes"; // 思维导图的链接的标记戳 const STAMP_TOPIC_LINK = "topic-link"; // 思维导图的附加标记的标记戳 const STAMP_TOPIC_MARKERS = "topic-markers"; // 加号图标的ID const ID_ICON_PLUS = "icon-plus"; // 减号图标的ID const ID_ICON_MINUS = "icon-minus"; // 更多图标的ID const ID_ICON_MORE = "icon-more"; // 备注图标的ID const ID_ICON_NOTES = "icon-notes"; // 链接图标的ID const ID_ICON_LINK = "icon-link"; // 默认的基础类 const CLASS_NAME = { MAIN_BASIC: "mind-main-basic", MAIN_CONTAINER: "mind-main-container", MAIN_SVG: "mind-svg", TOPIC_GROUP_BASIC: "mind-topic-group-basic", TOPIC_ITEM_G_BASIC: "mind-topic-item-group-basic", TOPIC_IN_FOCUS: "mind-topic-focus", TOPIC_TEXT_BASIC: "mind-text-basic", TOPIC_TEXT: "mind-text", TOPIC_RECT: "mind-topic", TOPIC_LEVEL_PREFIX: "mind-topic-level", TOPIC_CHILD_G_BASIC: "mind-topic-children-group-basic", TOPIC_LINK_LINE: "mind-line", TOPIC_FOLD_ICON: "mind-topic-fold-icon", TOPIC_LABELS: "mind-topic-labels", TOPIC_LABEL_BG: "mind-topic-label-background", TOPIC_LINK: "mind-topic-link", TOPIC_NOTES: "mind-topic-notes", TOPIC_MARKERS: "mind-topic-markers" }; // 默认配置参数 const DEFAULT_CONFIGS = { paddingX: 10, paddingY: 10, topicMarginX: 46, topicMarginY: 17, contentMarginX: 6, contentMarginY: 6, rectRadius: 6, lineBezierCtrlSize: 17, subPaddingX: 6, subPaddingY: 6, maxLabelsPreview: 3, }; // 右侧连接 const CONNECT_DIRECTION_RIGHT = 0; // 左侧连接 const CONNECT_DIRECTION_LEFT = 1; // 主题里的数据名称前缀 const NAME_DATA_PREFIX = "_data$"; // 附件关联的前缀 const ATTACHMENT_LINK_PREFIX = "xap:"; // 元素的附加数据名称 const DATA_EXTEND = "extend"; /** * 生成主题数据属性的名称 * @param {*} _name */ function namedData(_name) { return `${NAME_DATA_PREFIX}${_name}`; } /** * 从格式化的数据属性名称中解析出数据的纯名称 * @param {*} _name */ function dataPureName(_name) { return _name.startsWith(NAME_DATA_PREFIX) ? _name.substr(NAME_DATA_PREFIX.length) : undefined; } /** * 获取对象的私有成员空间 * @param {*} _this 对象实例 * @param {*} _key 私有成员的名称,如果不传入该参数则获取实例的全部私有成员空间 */ function getPrivate(_this, _key) { return _key === undefined ? _this[PRIVATE_SYMBOL] : _this[PRIVATE_SYMBOL][_key]; } /** * 得到符合本程序规范的ID名称 * @param {*} _id 人工或者数据预先指定的id,不传入该参数则自动随机生成一个id */ function mID(_id) { let ret; if (_id) { ret = ((typeof _id === "string") ? _id : String(_id)); if (!ret.startsWith(PREFIX_ID)) { ret = PREFIX_ID + ret; } } else { ret = `${PREFIX_ID}${Date.now()}${Math.random().toString(16).substr(2,3)}`; } return ret; } /** * SVG预定义件类 */ class Defs { constructor(_svg) { if (!isENode(_svg)) { throw ERROR.INVALID_PARAM("_svg"); } const defs = (this.defs = _svg.createSVGChild("defs")); if (defs) { const list = Object.getOwnPropertyNames(Defs); for (let idx in list) { const name = list[idx]; const fn = Defs[name]; if (typeof fn === "function") { const element = fn(this); if (isENode(element)) { element.attr("id", name); } } } } } /** * 创建SVG的元素 * @param {*} _tag 元素标签 */ createChild(_tag) { return this.defs.createSVGChild(_tag); } } /** * 主题项类 */ class TopicItem { constructor (_data, _level, _parent) { if (isENode(_data)) { // 从既有元素封装出一个实例 if ((_data.tagName !== "g") || !_data.hasClass(CLASS_NAME.TOPIC_ITEM_G_BASIC)) { throw ERROR.ILLEGAL_INSTANCE("_data", "<g> with item group class"); } const content = (this.content = _data); this.fireEvent = fireEvent.bind(content); this.titleRect = content.firstDescendant(TopicItem.titleSelector); this.titleText = content.firstDescendant(`text.${CLASS_NAME.TOPIC_TEXT}`); this.foldIcon = content.firstDescendant(`use.${CLASS_NAME.TOPIC_FOLD_ICON}`); } else if (isENode(_parent)) { // 创建一个全新实例 const id = _parent.attr("id"); _data = _data || {}; // 本类只创建主题项自身的数据,主题的下属子主题数据不由本类来实例化 const content = (this.content = _parent.createSVGChild("g")); content.attr({ id: id, class: `${CLASS_NAME.TOPIC_ITEM_G_BASIC} ${CLASS_NAME.TOPIC_LEVEL_PREFIX}${_level}` }); this.fireEvent = fireEvent.bind(content); // 创建基本的主题方框和主题文字 const titleRect = (this.titleRect = content.createSVGChild("rect")); const titleText = (this.titleText = content.createSVGChild("text")); titleText.text = _data.title; titleText.attr({ id: id, class: `${CLASS_NAME.TOPIC_TEXT_BASIC} ${CLASS_NAME.TOPIC_TEXT}`, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_TITLE }); titleRect.attr({ id: id, class: CLASS_NAME.TOPIC_RECT, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_TITLE }); // 其余数据直接通过属性设置以保证其元素设置代码的一致性 for (let property of TopicItem.propertyNames()) { if ((property.name !== "title") && (property.name in _data)) { this[property.fullName] = _data[property.name]; } } // 创建预备使用的主题折叠按钮 if (_level > 0) { (this.foldIcon = content.createSVGChild("use")).attr({ id: id, href: `#${ID_ICON_MINUS}`, style: "display: none;", class: CLASS_NAME.TOPIC_FOLD_ICON, [EVENT.ATTR_EVENT_STAMP]: STAMP_FOLD_ICON }); } } else { throw ERROR.INVALID_PARAM(_parent); } } /** * 转换一个名称为属性数据名称,如果不存在对应的属性数据,则返回undefined * @param {*} _name */ static propertyName(_name) { const dest = namedData(_name); const list = Object.getOwnPropertyNames(TopicItem.prototype); return (list && (list.indexOf(dest) >= 0)) ? dest : undefined; } /** * 获取所有属性数据名称的迭代器 */ static * propertyNames() { const list = Object.getOwnPropertyNames(TopicItem.prototype); for (let itemName of list) { const dataName = dataPureName(itemName); if (dataName) { yield { name: dataName, fullName: itemName }; } } } /** * 获取遍历所有属性数据的迭代器 */ * propertys() { const list = Object.getOwnPropertyNames(TopicItem.prototype); for (let itemName of list) { const dataName = dataPureName(itemName); if (dataName) { yield { name: dataName, fullName: itemName, value: this[itemName] }; } } } /** * 获取/设置主题的层级 * @param {*} _level 要设置的主题层级,如果不传入参数表示获取主题层级 */ level(_level) { const content = this.content; if (content) { const classList = content.classList; let levelIdx = -1; for (let idx in classList) { if (classList[idx].startsWith(CLASS_NAME.TOPIC_LEVEL_PREFIX)) { levelIdx = idx; break; } } if (_level === undefined) { return (levelIdx >= 0) ? parseInt(classList[levelIdx].substr(CLASS_NAME.TOPIC_LEVEL_PREFIX.length)) : 0; } else { if (levelIdx >= 0) { classList[levelIdx] = ""; } classList.push(`${CLASS_NAME.TOPIC_LEVEL_PREFIX}${_level}`); content.attr("class", classList.join(" ")); } } } /** * 获取主题项的大小 */ get size() { const rect = this.content.rect; return {width:rect.width, height:rect.height}; } /** * 获取标题区的坐标 */ get titleZone() { const titleRect = this.titleRect; if (titleRect) { return titleRect.getRelativeRect(titleRect.root); } else { return {x:0, y:0, width:0, height:0}; } } /** * 获取内部项目的全局区域坐标信息 * @param {*} _item */ globalZone(_item) { const selector = TopicItem[`${_item}Selector`]; if (selector) { const node = this.content.firstDescendant(selector); return node ? node.globalRect : undefined; } return undefined; } /** * 获取内部项目相对于其他元素的坐标信息 * @param {*} _item * @param {*} _node 相对的元素,如果不传入该参数,则表示相对于MindSVG的容器元素 */ relativeZone(_item, _node) { let node; if (_item) { const selector = TopicItem[`${_item}Selector`]; if (selector) { node = this.content.firstDescendant(selector); } } else { node = this.content; } if (node) { const relNode = ENode().attach(_node) || node.parent(`div.${CLASS_NAME.MAIN_CONTAINER}`); return relNode ? node.getRelativeRect(relNode) : undefined; } return undefined; } /** * 获取自定义数据 */ get [namedData("customData")]() { return this.content.data("customData"); } /** * 设置自定义数据 */ set [namedData("customData")](_value) { this.content.data("customData", _value); } /** * 获取标题 */ get [namedData("title")]() { return this.titleText ? this.titleText.text : ""; } /** * 设置标题 */ set [namedData("title")](_value) { this.titleText && (this.titleText.text = _value); } /** * 获取图片 */ get [namedData("image")]() { const img = this.content.firstDescendant(TopicItem.imageSelector); if (img) { const data = {}; const attachment = img.data(DATA_EXTEND); data.src = attachment ? attachment : img.attr("href"); const width = img.attr("width"); width && (data.width = JSON.parse(width)); const height = img.attr("height"); height && (data.height = JSON.parse(height)); return data; } return undefined; } /** * 设置图片 */ set [namedData("image")](_value) { if (_value && _value.src) { const img = this.content.firstDescendant(TopicItem.imageSelector) || this.content.createSVGChild(TopicItem.imageSelector); if (img) { const attrs = { [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_IMAGE }; if (_value.width !== undefined) { attrs.width = _value.width; } if (_value.height !== undefined) { attrs.height = _value.height; } let src = String(_value.src); if (src.startsWith(ATTACHMENT_LINK_PREFIX)) { img.data(DATA_EXTEND, src); src = this.fireEvent(EVENT.EVENT_QUERY_ATTACHMENT, src.substr(ATTACHMENT_LINK_PREFIX.length), ""); } else { img.data(DATA_EXTEND, null); } src = src.trim(); if (src) { attrs.href = src; img.attr(attrs); } else { img.remove(); } } } else { const img = this.content.firstDescendant(TopicItem.imageSelector); img && img.remove(); } } /** * 获取标签 */ get [namedData("labels")]() { const labels = this.content.firstDescendant(TopicItem.labelsSelector); if (labels) { const list = []; for (let text of labels.getDescandant("text")) { list.push(text.text); } return list; } return undefined; } /** * 设置标签 */ set [namedData("labels")](_value) { if ((_value instanceof Array) && (_value.length > 0)) { const labels = this.content.firstDescendant(TopicItem.labelsSelector) || this.content.createSVGChild("g"); if (labels) { const attrs = { class: CLASS_NAME.TOPIC_LABELS, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_LABELS }; labels.clearChildren(); labels.attr(attrs); const rect = labels.createSVGChild("rect"); attrs.class = CLASS_NAME.TOPIC_LABEL_BG; rect.attr(attrs); attrs.class = `${CLASS_NAME.TOPIC_TEXT} ${CLASS_NAME.TOPIC_TEXT_BASIC}`; for (let item of _value) { const text = labels.createSVGChild("text"); text.attr(attrs); text.text = item; } } } else { const labels = this.content.firstDescendant(TopicItem.labelsSelector); labels && labels.remove(); } } /** * 获取链接 */ get [namedData("href")]() { const href = this.content.firstDescendant(TopicItem.linkSelector); if (href) { return href.data(DATA_EXTEND); } return undefined; } /** * 设置链接 */ set [namedData("href")](_value) { if (_value && (_value = String(_value).trim())) { const href = this.content.firstDescendant(TopicItem.linkSelector) || this.content.createSVGChild("use"); href.data(DATA_EXTEND, _value); href.attr({ class: CLASS_NAME.TOPIC_LINK, href: `#${ID_ICON_LINK}`, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_LINK }); } else { const href = this.content.firstDescendant(TopicItem.linkSelector); href && href.remove(); } } /** * 获取备注 */ get [namedData("notes")]() { const notes = this.content.firstDescendant(TopicItem.notesSelector); if (notes) { return notes.data(DATA_EXTEND); } return undefined; } /** * 设置备注 */ set [namedData("notes")](_value) { if (_value) { const notes = this.content.firstDescendant(TopicItem.notesSelector) || this.content.createSVGChild("use"); notes.data(DATA_EXTEND, _value); notes.attr({ class: CLASS_NAME.TOPIC_NOTES, href: `#${ID_ICON_NOTES}`, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_NOTES }); } else { const href = this.content.firstDescendant(TopicItem.notesSelector); href && href.remove(); } } /** * 获取附属标志 */ get [namedData("markers")]() { const markerGroup = this.content.firstDescendant(TopicItem.markersSelector); if (markerGroup) { const markerList = {}; for (let item of markerGroup.getDescandant("use")) { const itemHref = item.attr("href"); if (itemHref) { let {1:itemName, 2:itemValue} = itemHref.split(/[#-]/); let fn = Defs[`${itemName}$translate`]; (typeof fn === 'function') && (itemValue = fn(itemValue)); markerList[itemName] = itemValue; } } return markerList; } return undefined; } /** * 设置附属标志 */ set [namedData("markers")](_value) { if (_value) { const markerGroup = this.content.firstDescendant(TopicItem.markersSelector) || this.content.createSVGChild("g"); if (markerGroup) { markerGroup.attr({ class: CLASS_NAME.TOPIC_MARKERS, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_MARKERS }); markerGroup.clearChildren(); let itemCount = 0; for (let itemName in _value) { let itemValue = _value[itemName]; let fn = Defs[`${itemName}$translate`]; (typeof fn === "function") && (itemValue = fn(itemValue, true)); const itemNode = markerGroup.createSVGChild("use"); itemNode.attr({ href: `#${itemName}-${itemValue}`, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_MARKERS }); itemCount++; } (itemCount <= 0) && markerGroup.remove(); } } else { const markerGroup = this.content.firstDescendant(TopicItem.markersSelector); markerGroup && markerGroup.remove(); } } /** * 获取主题项的连接点坐标(相对于主题组自身坐标系) * @param {*} _direction 连接方向,取值为CONNECT_DIRECTION_LEFT和CONNECT_DIRECTION_RIGHT */ connectPointer(_direction) { let x; let y; if (this.titleRect) { const box = this.titleRect.rect; y = (box.height >> 1); x = (_direction == CONNECT_DIRECTION_LEFT) ? 0 : box.width; } else { x = 0; y = 0; } return {x, y}; } /** * 设置折叠 * @param {*} _style 不传入参数表示不现实折叠按钮,true表示已折叠,false表示未折叠 */ fold(_style) { const foldIcon = this.foldIcon; if (foldIcon) { if ((_style === undefined) || (_style === null)) { foldIcon.style("display", "none"); foldIcon.attr({href: `#${ID_ICON_MINUS}`}); } else { foldIcon.attr({href: `#${_style ? ID_ICON_PLUS : ID_ICON_MINUS}`}); foldIcon.style("display", "unset"); } } } /** * 设置主题焦点 * @param {*} _focus 不传入参数或者传入true表示主题拥有焦点,否则清除主题焦点 */ focus(_focus = true) { let ret = undefined; const content = this.content; if (content) { if (_focus) { if (!content.hasClass(CLASS_NAME.TOPIC_IN_FOCUS)) { const root = content.root; if (root) { const lastFocus = root.firstDescendant(TopicItem.focusItemSelector); if (lastFocus) { lastFocus.removeClass(CLASS_NAME.TOPIC_IN_FOCUS); } } content.addClass(CLASS_NAME.TOPIC_IN_FOCUS); } ret = true; } else if (content.hasClass(CLASS_NAME.TOPIC_IN_FOCUS)) { content.removeClass(CLASS_NAME.TOPIC_IN_FOCUS); ret = false; } } return ret; } /** * 检查主题是否有焦点 */ get isInFocus() { return this.content && this.content.hasClass(CLASS_NAME.TOPIC_IN_FOCUS); } /** * 根据MindSVG配置布局主题项内部的元素 * @param {*} _mindSVG 隶属于的MindSVG对象 */ layout(_mindSVG, _direction) { if (!(_mindSVG instanceof MindSVG)) { throw ERROR.INVALID_PARAM("_mindSVG"); } // 布局基本方框和文字 const titleText = this.titleText; const titleRect = this.titleRect; if (titleText && titleRect) { const { paddingX, paddingY, rectRadius, maxLabelsPreview, subPaddingX, subPaddingY } = _mindSVG.config(); // 先获取标题的基本大小 let { width:textWidth, height:textHeight } = titleText.rect; let rectWidth = textWidth + (paddingX << 1); const rectHeight = textHeight + (paddingY << 1); // 布局底部栏的内容物 const underTop = rectHeight + subPaddingY; let underWidth = 0; let underRightObjs = []; // 如果有标签,则布局标签 const labels = this.content.firstDescendant(TopicItem.labelsSelector); let offsetX = subPaddingX; if (labels) { let textCount = 0; let textHeight = 0; for (let text of labels.getDescandant("text")) { text.attr({ transform: `translate(${offsetX}, ${subPaddingY})`, x: null, y: null, style: `display: ${textCount >= maxLabelsPreview ? "none": "unset"}` }); const textRect = text.rect; (0 === textCount) && (textHeight = (textRect.height + subPaddingY + subPaddingY)); if ((++textCount) <= maxLabelsPreview) { offsetX += textRect.width + subPaddingX; } } if (textCount > maxLabelsPreview) { const moreIcon = labels.firstDescendant("use") ||labels.createSVGChild("use"); moreIcon.attr({ href: `#${ID_ICON_MORE}`, [EVENT.ATTR_EVENT_STAMP]: STAMP_TOPIC_LABEL_MORE, transform: `translate(${offsetX}, ${subPaddingY})` }); offsetX += moreIcon.width + subPaddingX; } const labelRect = labels.firstDescendant(`rect.${CLASS_NAME.TOPIC_LABEL_BG}`); labelRect && labelRect.attr({ x: null, y: null, width: offsetX, height: textHeight, rx: rectRadius, ry: rectRadius, }); labels.attr("transform", `translate(0, ${underTop})`); underWidth += offsetX; } // 如果有链接、备注则计算链接备注占用的空间 for (let idx = 0; idx < 2; idx++) { const obj = this.content.firstDescendant(TopicItem.underLineSelectors[idx]); if (obj) { const width = obj.width; underWidth += width + subPaddingX; underRightObjs.push({obj, width}); } } // 如果有markers,则先对markers做内部布局,以计算markers要占用的空间 const markers = this.content.firstDescendant(TopicItem.markersSelector); offsetX = 0; if (markers) { for (let item of markers.getDescandant("use")) { (offsetX !== 0) && (offsetX += subPaddingX); item.attr("transform", `translate(${offsetX}, 0)`); offsetX += item.width; } underWidth += offsetX + subPaddingX; underRightObjs.push({obj:markers, width:offsetX}); } // 根据labels、markers、链接、备注计算主题标题需要的最大长度 (underRightObjs.length > 0) && (underWidth += subPaddingX); (underWidth > rectWidth) && (rectWidth = underWidth); // 如果有图片,则布局图片, 如果图片的大小比当前计算的标题区大小要小,则图片居中布局 const img = this.content.firstDescendant(TopicItem.imageSelector); if (img) { const { width:imgWidth, height:imgHeight } = img.rect; let imgLeft = 0; (imgWidth > rectWidth) ? (rectWidth = imgWidth) : (imgLeft = (rectWidth - imgWidth) / 2); img.attr({ transform: `translate(${imgLeft}, -${imgHeight + subPaddingY})`, x: null, y: null }); } // 现在开始从标题下的右侧开始布局链接、备注、markers offsetX = rectWidth - subPaddingX; for (let idx in underRightObjs) { const {obj, width} = underRightObjs[idx]; if (obj) { offsetX -= width; obj.attr({ transform: `translate(${offsetX}, ${underTop})`, x: null, y: null }); offsetX -= subPaddingX; } } // 最后根据主题的最大长度来布局主题标题文字和画框 const textLeft = (rectWidth - textWidth) / 2; titleText.attr({ transform: `translate(${textLeft}, ${paddingY})`, x: null, y: null }); titleRect.attr({ width: rectWidth, height: rectHeight, rx: rectRadius, ry: rectRadius, x: null, y: null }); // 布局折叠图标 const foldIcon = this.foldIcon; if (foldIcon) { foldIcon.attr("transform", `translate(${_direction === CONNECT_DIRECTION_LEFT ? 0 : rectWidth}, ${rectHeight / 2})`); } } } static imageSelector = "image"; static titleSelector = `rect.${CLASS_NAME.TOPIC_RECT}`; static linkSelector = `use.${CLASS_NAME.TOPIC_LINK}`; static notesSelector = `use.${CLASS_NAME.TOPIC_NOTES}`; static markersSelector = `g.${CLASS_NAME.TOPIC_MARKERS}`; static labelsSelector = `g.${CLASS_NAME.TOPIC_LABELS}`; static focusItemSelector = `.${CLASS_NAME.TOPIC_IN_FOCUS}`; static underLineSelectors = [ TopicItem.linkSelector, TopicItem.notesSelector, TopicItem.markersSelector, TopicItem.labelsSelector ]; } /** * 主题类 */ class Topic { constructor (_data, _level, _parent) { const inner = (this[PRIVATE_SYMBOL] = {}); if (isENode(_data)) { // 从既有元素封装出一个实例 if ((_data.tagName !== "g") || !_data.hasClass(CLASS_NAME.TOPIC_GROUP_BASIC)) { throw ERROR.ILLEGAL_INSTANCE("_data", "<g> with group class"); } const content = (inner.content = _data); this.fireEvent = fireEvent.bind(content); inner.childrenGroup = content.firstDescendant(Topic.childrenGroupSelector); inner.item = new TopicItem(content.firstDescendant(`g.${CLASS_NAME.TOPIC_ITEM_G_BASIC}#${content.attr("id")}`)); } else if (isENode(_parent) && _parent.isSVG) { // 创建一个全新实例 const id = mID(_data.id); // 本类的关键在于创建一个主题组用于管理主题内的全局数据,具体的主题数据由其他类实现 const content = (inner.content = _parent.createSVGChild("g")); content.attr({ id: id, class: CLASS_NAME.TOPIC_GROUP_BASIC }); if (_data && (_data.direction !== undefined)) { content.data("direction", _data.direction); } this.fireEvent = fireEvent.bind(content); // 创建主题项实例, 注意:子主题组要优先于主题项被创建,这样才能保证子主题连接线不会把主题项给覆盖 const childrenGroup = (inner.childrenGroup = content.createSVGChild("g")); childrenGroup.attr({ id: id, class: CLASS_NAME.TOPIC_CHILD_G_BASIC }); const item = (inner.item = new TopicItem(_data, _level, content)); // 挂载事件处理 mapEventHandler(item.content, this); // 创建子主题 const subSource = _data.children; if (subSource instanceof Array) { for (let idx in subSource) { this.addNewChild(subSource[idx]); } } // 注意:此处并不创建连接线,连接线在布局的时候生成 } } // 折叠按钮被点击,进行折叠和展开的处理 [namedHandler("click", STAMP_FOLD_ICON)](_event, _stamp, _element) { unused(_stamp); this.fold(String(_element.getAttribute("href")).indexOf(ID_ICON_MINUS) >= 0); handledEvent(_event); } // 唤起链接 [namedHandler("click", STAMP_TOPIC_LINK)](_event) { this.fireEvent(EVENT.EVENT_INVOKE_LINK, { topic: this, data: this.topicData("href") }); continueSiblingHandler(_event); } // 唤起备注 [namedHandler("click", STAMP_TOPIC_NOTES)](_event) { this.fireEvent(EVENT.EVENT_INVOKE_NOTES, { topic: this, data: this.topicData("notes") }); continueSiblingHandler(_event); } // 唤起图像 [namedHandler("click", STAMP_TOPIC_IMAGE)](_event) { this.fireEvent(EVENT.EVENT_INVOKE_IMAGE, { topic: this, data: this.topicData("image") }); continueSiblingHandler(_event); } // 唤起标签 [namedHandler("click", STAMP_TOPIC_LABELS)](_event) { this.fireEvent(EVENT.EVENT_INVOKE_LABELS, { topic: this, data: this.topicData("labels") }); continueSiblingHandler(_event); } // 唤起标签(更多) [namedHandler("click", STAMP_TOPIC_LABEL_MORE)](_event) { this.fireEvent(EVENT.EVENT_INVOKE_LABELS, { topic: this, data: this.topicData("labels"), more: true }); continueSiblingHandler(_event); } // 唤起图标 [namedHandler("click", STAMP_TOPIC_MARKERS)](_event) { this.fireEvent(EVENT.EVENT_INVOKE_MARKERS, { topic: this, data: this.topicData("markers") }); continueSiblingHandler(_event); } // 点击了主题的标题部分 [namedHandler("click")](_event) { this.focus(); handledEvent(_event); } /** * 获取深度操作该主题组的细节实例 */ get content() { return getPrivate(this).content; } /** * 获取父主题 */ get parent() { if (this.level > 0) { const content = getPrivate(this).content; const parentNode = content.parent(Topic.contentSelector); return parentNode ? new Topic(parentNode) : undefined; } return undefined; } /** * 获取主题项的大小 */ get size() { const content = getPrivate(this).content; let width = 0; let height = 0; if (content) { const box = content.rect; width = box.width; height = box.height; } return {width, height}; } /** * 获取主题组的起始点坐标(相对于主题组自身坐标系) */ get originCorner() { const content = getPrivate(this).content; let x; let y; if (content) { const rect = content.rect; x = rect.x; y = rect.y; } else { x = 0; y = 0; } return {x, y}; } /** * 获取主题的ID */ get id() { const content = getPrivate(this).content; return content ? (content.attr("id") || "") : ""; } /** * 获取/设置主题的布局方向 */ direction(_value) { const content = getPrivate(this).content; if (_value === undefined) { return content.data("direction") || CONNECT_DIRECTION_RIGHT; } else { content.data("direction", _value); } return this; } /** * 获取标题区的坐标 */ get titleZone() { const item = getPrivate(this).item; return item ? item.titleZone : {x:0, y:0, width:0, height:0}; } /** * 获取主题项内容的坐标 */ get itemZone() { const item = getPrivate(this).item; return (item && item.content) ? item.content.globalRect : {x:0, y:0, width:0, height:0}; } /** * 获取内部项目的全局区域坐标信息 * @param {*} _item */ globalZone(_item) { const item = getPrivate(this).item; return item ? item.globalZone(_item) : undefined; } /** * 获取内部项目相对于导图容器的坐标信息 * @param {*} _item */ relativeZone(_item) { const item = getPrivate(this).item; return item ? item.relativeZone(_item) : undefined; } /** * 获取主题的全区域坐标 */ get totalZone() { const content = getPrivate(this).content; return content ? content.globalRect : {x:0, y:0, width:0, height:0}; } /** * 获取子主题的实例集合的迭代器 */ * childrenTopics() { const childrenGroup = getPrivate(this).childrenGroup; if (childrenGroup) { const nodeIterator = childrenGroup.getChildren(Topic.contentSelector); for (let itemNode of nodeIterator) { yield new Topic(itemNode); } } } /** * 检查是否有子主题 */ get hasChild() { const childrenGroup = getPrivate(this).childrenGroup; return childrenGroup && (childrenGroup.firstDescendant(Topic.contentSelector) !== undefined); } /** * 获取主题的等级 */ get level() { const item = getPrivate(this).item; return item.level(); } /** * 获取或设置主题自身的数据 * @param {*} _key 要操作的数据的名称,如果不传入参数,表示获取主题的所有数据;如果传入的是字符串,则表示获取或设置对应名称的数据;如果传入的是对象,则将对象内的成员批量设置为主题的属性 * @param {*} _value */ topicData(_key, _value) { const item = getPrivate(this).item; if (item) { if (_key === undefined) { const data = {}; for (let property of item.propertys()) { (property.value !== undefined) && (property.value !== null) && (data[property.name] = property.value); } if (this.level === 1) { data.direction = this.direction(); } return data; } else if (typeof _key === "string") { const dataName = TopicItem.propertyName(_key); if (dataName) { if (_value === undefined) { return item[dataName]; } else { item[dataName] = _value; this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } } } else { let count = 0; for (let itemName in _key) { const dataName = TopicItem.propertyName(itemName); if (dataName) { const itemValue = _key[itemName]; item[dataName] = itemValue; count ++; } } (count !== 0) && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } } return this; } /** * 获取主题的所有数据 */ get data() { const value = this.topicData(); if (value) { value.id = this.id; const children = (value.children = []); const childrenList = this.childrenTopics(); for (const subTopic of childrenList) { children.push(subTopic.data); } } return value; } /** * 检查是否是相同的主题 * @param {*} _topic */ isSame(_topic) { return (_topic instanceof Topic) && (getPrivate(this).content.isSame(getPrivate(_topic).content)); } /** * 让主题组完成相对定位 * @param {*} _x * @param {*} _y */ translate(_x, _y) { const content = getPrivate(this).content; content && content.attr("transform", `translate(${_x}, ${_y})`); return this; } /** * 设置/获取主题折叠 * @param {*} _isFold 如果不传入参数表示获取折叠的设置 */ fold(_isFold) { const childrenGroup = getPrivate(this).childrenGroup; this.focus(); if (_isFold === undefined) { return childrenGroup && childrenGroup.style("display", "none"); } else { childrenGroup && childrenGroup.style("display", (_isFold ? "none" : "unset")); this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } return this; } /** * 设置主题是否拥有焦点 * @param {*} _focus true或者不传入参数表示主题拥有焦点,否则清除主题焦点 */ focus(_focus = true) { const item = getPrivate(this).item; if (item) { const ret = item.focus(_focus); if (ret !== undefined) { this.fireEvent(EVENT.EVENT_FOCUS_CHANGE, ret ? this : null); } } return this; } /** * 检查主题是否有焦点 */ get isInFocus() { const item = getPrivate(this); return item && item.isInFocus; } /** * 添加一个新的子主题 * @param {*} _data 子主题的数据,如果不传入数据,则使用默认数据 * @param {*} _redraw true或者不传入该参数表示理解重绘画面;false表示不立即重绘画面;该参数用于对思维导图一次做多项变更时减少刷新 */ addNewChild(_data, _redraw = true) { const _value = _data ? (_data.title ? _data : Object.assign({title:Topic.DefaultTitle}, _data)) : {title:Topic.DefaultTitle}; const topic = new Topic(_value, this.level + 1, getPrivate(this).childrenGroup); _redraw && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); return topic; } /** * 将主题从思维导图中移除 * @param {*} _redraw true或者不传入该参数表示理解重绘画面;false表示不立即重绘画面;该参数用于对思维导图一次做多项变更时减少刷新 */ remove(_redraw = true) { if (this.level > 0) { const content = getPrivate(this).content; if (content) { const parentNode = content.parent(Topic.childrenGroupSelector); const path = parentNode.firstDescendant(`path#${this.id}`); if (path) { path.remove(); } content.remove(); _redraw && fireEvent.call(parentNode, EVENT.EVENT_REQUIRE_LAYOUT); } } return this; } /** * 删除所有子主题 * @param {*} _redraw true或者不传入该参数表示理解重绘画面;false表示不立即重绘画面;该参数用于对思维导图一次做多项变更时减少刷新 */ clearChildren(_redraw = true) { for (const item of this.childrenTopics()) { (item instanceof Topic) && item.remove(); } _redraw && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); return this; } /** * 将主题加为其他主题的子主题 * @param {*} _parent 新的父主题 * @param {*} _toHead true表示加为新父主题的第一个子主题;false或不传入参数,表示加为新父主题的最后一个子主题 * @param {*} _redraw true或者不传入该参数表示理解重绘画面;false表示不立即重绘画面;该参数用于对思维导图一次做多项变更时减少刷新 */ insertTo(_parent, _toHead, _redraw = true) { if (_parent instanceof Topic) { const childrenGroup = getPrivate(_parent).childrenGroup; const { content, item } = getPrivate(this); if (childrenGroup && content) { this.remove(); _toHead ? content.insertToTop(childrenGroup) : content.appendTo(childrenGroup); item.level(_parent.level + 1); _redraw && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } } return this; } /** * 添加为一个主题的同级后续主题 * @param {*} _sibling 同级主题 * @param {*} _redraw true或者不传入该参数表示理解重绘画面;false表示不立即重绘画面;该参数用于对思维导图一次做多项变更时减少刷新 */ insertNextTo(_sibling, _redraw = true) { if (_sibling instanceof Topic) { const siblingContent = _sibling.content; const { content, item } = getPrivate(this); this.remove(); content.insertAfter(siblingContent); item.level(_sibling.level); _redraw && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } } /** * 将主题加为一个主题的兄弟主题 * @param {*} _brother 兄弟主题 * @param {*} _before true表示加到_brother主题之前,false或不传入该参数表示加到_brother主题之后 * @param {*} _redraw true或者不传入该参数表示理解重绘画面;false表示不立即重绘画面;该参数用于对思维导图一次做多项变更时减少刷新 */ insertAsBrother(_brother, _before, _redraw = true) { if (_brother instanceof Topic) { const brotherContent = getPrivate(_brother).content; const { content, item } = getPrivate(this); if (brotherContent && content) { this.remove(); _before ? content.insertBefore(brotherContent) : content.insertAfter(brotherContent); item.level(_brother.level); _redraw && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } } return this; } /** * 获取主题的可见性 */ get visible() { return (getPrivate(this).content.style("display") !== "none"); } /** * 设置主题的可见性 */ set visible(_value) { const content = getPrivate(this).content; const oldValue = (content.style("display") !== "none"); _value = _value || false; if (oldValue !== _value) { const display = (_value ? "unset" : "none"); content.style("display", display); const path = content.root.firstDescendant(`path#${content.attr("id")}`); if (path) { path.node.style.display = display; } if ((!_value) && this.isInFocus) { this.focus(false); } _value && this.fireEvent(EVENT.EVENT_REQUIRE_LAYOUT); } } /** * 获取下一个兄弟主题 */ get nextSibling() { let node = this.content; while ((node = ENode().attach(node.node.nextSibling))) { if (node.matches(Topic.contentSelector)) { return new Topic(node); } } return undefined; } /** * 获取上一个兄弟主题 */ get previousSibling() { let node = this.content; while ((node = ENode().attach(node.node.previousSibling))) { if (node.matches(Topic.contentSelector)) { return new Topic(node); } } return undefined; } /** * 获取第一个下级主题 */ get firstDescendant() { const node = this.content.firstDescendant(Topic.contentSelector); return node ? new Topic(node) : undefined; } /** * 根据MindSVG配置布局主题项内部的元素 * @param {*} _mindSVG 隶属于的MindSVG对象 * @param {*} _foldLevel 折叠等级 */ layout(_mindSVG, _foldLevel, _direction) { if (!(_mindSVG instanceof MindSVG)) { throw ERROR.INVALID_PARAM("_mindSVG"); } const { content, item:topicItem, childrenGroup } = getPrivate(this); if (!content || !(topicItem instanceof(TopicItem)) || !childrenGroup) { throw ERROR.ILLEGAL_INSTANCE("Topic"); } // 布局主题项 topicItem.layout(_mindSVG, _direction); // 确定折叠图标的显示 const level = topicItem.level(); const isFold = (_foldLevel === undefined) ? (childrenGroup.node.style.display === "none") : ((_foldLevel > 0) ? (_foldLevel <= level) : false); topicItem.fold((this.hasChild && (level > 0)) ? isFold : null); if (isFold) { // 折叠状态下就隐藏子项组,也不做子项组的布局 childrenGroup.node.style.display = "none"; } else { // 显示子项组,先让子主题内部完成布局,然后让子主题在子元素组中完成布局 childrenGroup.node.style.display = "unset"; // 获取布局用的基本参数 const connectPointer = topicItem.connectPointer(CONNECT_DIRECTION_RIGHT); const configs = _mindSVG.config(); const topicMarginRightX = configs.topicMarginX; const topicMarginLeftX = 0 - connectPointer.x - topicMarginRightX; const topicMarginY = configs.topicMarginY; const subTopicLayoutParams = {}; let offsetRightY = 0; let offsetLeftY = 0; let direction = _direction; // 先计算子主题的基本布局参数 for (const subItem of this.childrenTopics()) { if (subItem.visible) { (_direction === undefined) && (direction = subItem.direction()); subItem.layout(_mindSVG, _foldLevel, direction); const subOrigin = subItem.originCorner; if (direction === CONNECT_DIRECTION_LEFT) { // 布局在左侧的子主题 const translateY = (subOrigin.y < 0) ? offsetLeftY - subOrigin.y : offsetLeftY; const titleZone = subItem.titleZone; const subHeight = subItem.size.height; offsetLeftY += subHeight + topicMarginY; subTopicLayoutParams[subItem.id] = { obj: subItem, x: topicMarginLeftX - titleZone.width, y: translateY, lineY: translateY + (titleZone.height / 2), left: true }; } else { // 布局在右侧的子主题 const translateY = (subOrigin.y < 0) ? offsetRightY - subOrigin.y : offsetRightY; const subHeight = subItem.size.height; offsetRightY += subHeight + topicMarginY; subTopicLayoutParams[subItem.id] = { obj: subItem, x: topicMarginRightX, y: translateY, lineY: translateY + (subItem.titleZone.height / 2), left: false }; } } } // 再计算让左右主题平衡居中布局的修正量 if (offsetRightY > offsetLeftY) { offsetLeftY = (offsetRightY - offsetLeftY) / 2; offsetRightY = 0; } else { offsetRightY = (offsetLeftY - offsetRightY) / 2; offsetLeftY = 0; } // 执行布局,并连接主题项与子主题之间的线 const lineBezierCtrlSize = configs.lineBezierCtrlSize; const startY = (childrenGroup.height >> 1); const pathDataRightPrefix = `M0 ${startY}C${lineBezierCtrlSize} ${startY} ${topicMarginRightX - lineBezierCtrlSize} `; const pathDataLeftPrefix = `M${0 - connectPointer.x} ${startY}C${0 - connectPointer.x - lineBezierCtrlSize} ${startY} ${topicMarginLeftX + lineBezierCtrlSize} `; for (let lineID in subTopicLayoutParams) { let {obj, x, y, lineY:endY, left:isLeft} = subTopicLayoutParams[lineID]; const offsetY = (isLeft ? offsetLeftY : offsetRightY); obj.translate(x, y + offsetY); endY += offsetY; const path = childrenGroup.firstDescendant(`path#${lineID}`) || childrenGroup.createSVGChild("path"); path.attr({ id: lineID, d: isLeft ? `${pathDataLeftPrefix}${endY} ${topicMarginLeftX} ${endY}` : `${pathDataRightPrefix}${endY} ${topicMarginRightX} ${endY}`, class: CLASS_NAME.TOPIC_LINK_LINE }); } // 将子项组布局到与主题项连接点垂直居中的位置上去 childrenGroup.attr("transform", `translate(${connectPointer.x}, ${0 - startY + connectPointer.y})`); } } /** * 文字转换 */ toString() { return JSON.stringify(this.topicData()); } static DefaultTitle = "默认主题"; static contentSelector = `g.${CLASS_NAME.TOPIC_GROUP_BASIC}`; static childrenGroupSelector = `g.${CLASS_NAME.TOPIC_CHILD_G_BASIC}`; } /** * 获取元素所在的主题 * @param {*} _node */ export function topicOfNode(_node) { let node = ENode().attach(_node); if (node) { node = node.parent(Topic.contentSelector); if (node) { return new Topic(node); } } return undefined; } /** * SVG思维导图类 */ export class MindSVG { constructor (_parent) { const inner = (this[PRIVATE_SYMBOL] = { configs: {}, rootTopic: undefined, container: ENode("div") }); const parent = ENode().attach(_parent) || ENode().attach("body"); const container = inner.container; container.attr({ class: `${CLASS_NAME.MAIN_BASIC} ${CLASS_NAME.MAIN_CONTAINER}`, tabindex: "1" }); container.appendTo(parent); this.fireEvent = fireEvent.bind(conta