538 lines
17 KiB
TypeScript
Raw Normal View History

2021-07-16 11:41:58 -03:00
/*
2021-12-25 14:39:34 -08:00
* Copyright [2021] [wisemapping]
2021-07-16 11:41:58 -03:00
*
* Licensed under WiseMapping Public License, Version 1.0 (the "License").
* It is basically the Apache License, Version 2.0 (the "License") plus the
* "powered by wisemapping" text requirement on every single page;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the license at
*
* http://www.wisemapping.org/license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
2022-02-15 11:36:14 -08:00
import { $assert, $defined, createDocument } from '@wisemapping/core-js';
import { Point } from '@wisemapping/web2d';
import Mindmap from '../model/Mindmap';
import { TopicShape } from '../model/INodeModel';
import ConnectionLine from '../ConnectionLine';
import FeatureModelFactory from '../model/FeatureModelFactory';
import NodeModel from '../model/NodeModel';
import RelationshipModel from '../model/RelationshipModel';
import XMLMindmapSerializer from './XMLMindmapSerializer';
import FeatureType from '../model/FeatureType';
2022-11-01 16:31:33 -07:00
import emojiToIconMap from './iconToEmoji.json';
2021-07-16 11:41:58 -03:00
2022-02-15 11:36:14 -08:00
class XMLSerializerTango implements XMLMindmapSerializer {
private static MAP_ROOT_NODE = 'map';
private _idsMap: Record<number, Element>;
toXML(mindmap: Mindmap): Document {
$assert(mindmap, 'Can not save a null mindmap');
const document = createDocument();
// Store map attributes ...
const mapElem = document.createElement('map');
const name = mindmap.getId();
if ($defined(name)) {
mapElem.setAttribute('name', this._rmXmlInv(name));
}
const version = mindmap.getVersion();
if ($defined(version)) {
mapElem.setAttribute('version', version);
}
document.appendChild(mapElem);
// Create branches ...
const topics = mindmap.getBranches();
topics.forEach((topic) => {
const topicDom = this._topicToXML(document, topic);
mapElem.appendChild(topicDom);
});
// Create Relationships
const relationships = mindmap.getRelationships();
relationships.forEach((relationship) => {
if (
2022-07-13 01:45:36 +00:00
mindmap.findNodeById(relationship.getFromNode()) !== null &&
mindmap.findNodeById(relationship.getToNode()) !== null
2022-02-15 11:36:14 -08:00
) {
// Isolated relationships are not persisted ....
const relationDom = XMLSerializerTango._relationshipToXML(document, relationship);
mapElem.appendChild(relationDom);
}
});
return document;
}
protected _topicToXML(document: Document, topic: NodeModel) {
const parentTopic = document.createElement('topic');
// Set topic attributes...
if (topic.getType() === 'CentralTopic') {
parentTopic.setAttribute('central', 'true');
} else {
const pos = topic.getPosition();
parentTopic.setAttribute('position', `${pos.x},${pos.y}`);
const order = topic.getOrder();
if (typeof order === 'number' && Number.isFinite(order)) {
parentTopic.setAttribute('order', order.toString());
}
}
const text = topic.getText();
2022-11-24 00:07:16 -08:00
if (text) {
2022-02-15 11:36:14 -08:00
this._noteTextToXML(document, parentTopic, text);
}
const shape = topic.getShapeType();
if ($defined(shape)) {
parentTopic.setAttribute('shape', shape);
if (shape === TopicShape.IMAGE) {
const size = topic.getImageSize();
2022-07-13 01:45:36 +00:00
parentTopic.setAttribute('image', `${size.width},${size.height}:${topic.getImageUrl()}`);
2022-02-15 11:36:14 -08:00
}
}
2022-07-13 01:45:36 +00:00
if (
topic.areChildrenShrunken() &&
topic.getChildren().length > 0 &&
topic.getType() !== 'CentralTopic'
) {
2022-02-15 11:36:14 -08:00
parentTopic.setAttribute('shrink', 'true');
}
// Font properties ...
const id = topic.getId();
parentTopic.setAttribute('id', id.toString());
let font = '';
const fontFamily = topic.getFontFamily();
font += `${fontFamily || ''};`;
const fontSize = topic.getFontSize();
font += `${fontSize || ''};`;
const fontColor = topic.getFontColor();
font += `${fontColor || ''};`;
const fontWeight = topic.getFontWeight();
font += `${fontWeight || ''};`;
const fontStyle = topic.getFontStyle();
font += `${fontStyle || ''};`;
if (
2022-07-13 01:45:36 +00:00
$defined(fontFamily) ||
$defined(fontSize) ||
$defined(fontColor) ||
$defined(fontWeight) ||
$defined(fontStyle)
2022-02-15 11:36:14 -08:00
) {
parentTopic.setAttribute('fontStyle', font);
}
const bgColor = topic.getBackgroundColor();
if ($defined(bgColor)) {
parentTopic.setAttribute('bgColor', bgColor);
}
const brColor = topic.getBorderColor();
if ($defined(brColor)) {
parentTopic.setAttribute('brColor', brColor);
}
const metadata = topic.getMetadata();
if ($defined(metadata)) {
parentTopic.setAttribute('metadata', metadata);
}
// Serialize features ...
const features = topic.getFeatures();
features.forEach((feature) => {
const featureType = feature.getType();
const featureDom = document.createElement(featureType);
const attributes = feature.getAttributes();
const attributesKeys = Object.keys(attributes);
for (let attrIndex = 0; attrIndex < attributesKeys.length; attrIndex++) {
const key = attributesKeys[attrIndex];
const value = attributes[key];
if (key === 'text') {
const cdata = document.createCDATASection(this._rmXmlInv(value));
featureDom.appendChild(cdata);
} else {
featureDom.setAttribute(key, value);
2022-02-15 11:36:14 -08:00
}
}
parentTopic.appendChild(featureDom);
});
// CHILDREN TOPICS
const childTopics = topic.getChildren();
childTopics.forEach((childTopic) => {
const childDom = this._topicToXML(document, childTopic);
parentTopic.appendChild(childDom);
});
return parentTopic;
}
protected _noteTextToXML(document: Document, elem: Element, text: string) {
if (text.indexOf('\n') === -1) {
elem.setAttribute('text', this._rmXmlInv(text));
} else {
const textDom = document.createElement('text');
const cdata = document.createCDATASection(this._rmXmlInv(text));
textDom.appendChild(cdata);
elem.appendChild(textDom);
}
}
static _relationshipToXML(document: Document, relationship: RelationshipModel) {
const result = document.createElement('relationship');
result.setAttribute('srcTopicId', relationship.getFromNode().toString());
result.setAttribute('destTopicId', relationship.getToNode().toString());
const lineType = relationship.getLineType();
result.setAttribute('lineType', lineType.toString());
if (lineType === ConnectionLine.CURVED || lineType === ConnectionLine.SIMPLE_CURVED) {
if ($defined(relationship.getSrcCtrlPoint())) {
const srcPoint = relationship.getSrcCtrlPoint();
2022-07-13 01:45:36 +00:00
result.setAttribute('srcCtrlPoint', `${Math.round(srcPoint.x)},${Math.round(srcPoint.y)}`);
2022-02-15 11:36:14 -08:00
}
if ($defined(relationship.getDestCtrlPoint())) {
const destPoint = relationship.getDestCtrlPoint();
result.setAttribute(
'destCtrlPoint',
`${Math.round(destPoint.x)},${Math.round(destPoint.y)}`,
);
}
}
result.setAttribute('endArrow', String(relationship.getEndArrow()));
result.setAttribute('startArrow', String(relationship.getStartArrow()));
return result;
}
loadFromDom(dom: Document, mapId: string) {
$assert(dom, 'dom can not be null');
$assert(mapId, 'mapId can not be null');
const rootElem = dom.documentElement;
// Is a wisemap?.
$assert(
rootElem.tagName === XMLSerializerTango.MAP_ROOT_NODE,
`This seem not to be a map document. Found tag: ${rootElem.tagName}`,
);
this._idsMap = {};
// Start the loading process ...
const version = rootElem.getAttribute('version') || 'pela';
const mindmap = new Mindmap(mapId, version);
// Add all the topics nodes ...
const childNodes = Array.from(rootElem.childNodes);
const topicsNodes = childNodes
2022-07-13 01:45:36 +00:00
.filter((child: ChildNode) => child.nodeType === 1 && (child as Element).tagName === 'topic')
2022-02-15 11:36:14 -08:00
.map((c) => c as Element);
topicsNodes.forEach((child) => {
const topic = this._deserializeNode(child, mindmap);
mindmap.addBranch(topic);
});
// Then all relationshops, they are connected to topics ...
const relationshipsNodes = childNodes
.filter(
(child: ChildNode) => child.nodeType === 1 && (child as Element).tagName === 'relationship',
)
.map((c) => c as Element);
relationshipsNodes.forEach((child) => {
try {
const relationship = XMLSerializerTango._deserializeRelationship(child, mindmap);
mindmap.addRelationship(relationship);
} catch (e) {
console.error(e);
}
});
// Clean up from the recursion ...
this._idsMap = null;
mindmap.setId(mapId);
return mindmap;
}
2022-11-24 00:07:16 -08:00
protected _deserializeNode(domElem: Element, mindmap: Mindmap): NodeModel {
2022-02-15 11:36:14 -08:00
const type = domElem.getAttribute('central') != null ? 'CentralTopic' : 'MainTopic';
// Load attributes...
2022-11-24 00:07:16 -08:00
let id: number | undefined;
const idStr = domElem.getAttribute('id');
if (idStr) {
id = Number.parseInt(idStr, 10);
2022-02-15 11:36:14 -08:00
}
2022-11-24 00:07:16 -08:00
if (id !== undefined && !this._idsMap[id]) {
2022-02-15 11:36:14 -08:00
this._idsMap[id] = domElem;
2022-11-24 00:07:16 -08:00
} else {
id = undefined;
2022-02-15 11:36:14 -08:00
}
2022-11-24 00:07:16 -08:00
// Create element ...
2022-02-15 11:36:14 -08:00
const topic = mindmap.createNode(type, id);
// Set text property is it;s defined...
const text = domElem.getAttribute('text');
if ($defined(text) && text) {
topic.setText(text);
}
const fontStyle = domElem.getAttribute('fontStyle');
if ($defined(fontStyle) && fontStyle) {
const font = fontStyle.split(';');
if (font[0]) {
topic.setFontFamily(font[0]);
}
if (font[1]) {
topic.setFontSize(Number.parseInt(font[1], 10));
}
if (font[2]) {
topic.setFontColor(font[2]);
}
if (font[3]) {
topic.setFontWeight(font[3]);
}
if (font[4]) {
topic.setFontStyle(font[4]);
}
}
2022-10-31 08:32:51 -07:00
let shape = domElem.getAttribute('shape');
2022-11-24 00:07:16 -08:00
if (shape) {
2022-10-31 08:32:51 -07:00
// Fix typo on serialization....
shape = shape.replace('rectagle', 'rectangle');
2022-02-15 11:36:14 -08:00
topic.setShapeType(shape);
2022-11-24 00:07:16 -08:00
// Is an image ?
const image = domElem.getAttribute('image');
if (image && shape === TopicShape.IMAGE) {
2022-02-15 11:36:14 -08:00
const size = image.substring(0, image.indexOf(':'));
const url = image.substring(image.indexOf(':') + 1, image.length);
topic.setImageUrl(url);
const split = size.split(',');
topic.setImageSize(Number.parseInt(split[0], 10), Number.parseInt(split[1], 10));
}
}
const bgColor = domElem.getAttribute('bgColor');
2022-11-24 00:07:16 -08:00
if (bgColor) {
2022-02-15 11:36:14 -08:00
topic.setBackgroundColor(bgColor);
}
const borderColor = domElem.getAttribute('brColor');
2022-11-24 00:07:16 -08:00
if (borderColor) {
2022-02-15 11:36:14 -08:00
topic.setBorderColor(borderColor);
}
const order = domElem.getAttribute('order');
2022-11-24 00:07:16 -08:00
if (order !== null && order !== 'NaN') {
2022-02-15 11:36:14 -08:00
// Hack for broken maps ...
topic.setOrder(parseInt(order, 10));
}
const isShrink = domElem.getAttribute('shrink');
// Hack: Some production maps has been stored with the central topic collapsed. This is a bug.
if ($defined(isShrink) && type !== 'CentralTopic') {
topic.setChildrenShrunken(Boolean(isShrink));
}
const position = domElem.getAttribute('position');
2022-11-24 00:07:16 -08:00
if (position !== null) {
2022-02-15 11:36:14 -08:00
const pos = position.split(',');
topic.setPosition(Number.parseInt(pos[0], 10), Number.parseInt(pos[1], 10));
}
const metadata = domElem.getAttribute('metadata');
2022-11-24 00:07:16 -08:00
if (metadata !== null) {
2022-02-15 11:36:14 -08:00
topic.setMetadata(metadata);
}
// Creating icons and children nodes
const children = Array.from(domElem.childNodes);
children.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
const elem = child as Element;
if (elem.tagName === 'topic') {
const childTopic = this._deserializeNode(elem, mindmap);
childTopic.connectTo(topic);
} else if (FeatureModelFactory.isSupported(elem.tagName)) {
// Load attributes ...
const namedNodeMap = elem.attributes;
const attributes: Record<string, string> = {};
for (let j = 0; j < namedNodeMap.length; j++) {
const attribute = namedNodeMap.item(j);
attributes[attribute.name] = attribute.value;
}
// Has text node ?.
const textAttr = XMLSerializerTango._deserializeTextAttr(elem);
if (textAttr) {
attributes.text = textAttr;
}
// Create a new element ....
const featureType = elem.tagName as FeatureType;
2022-11-01 16:31:33 -07:00
let feature = FeatureModelFactory.createModel(featureType, attributes);
// Migrate icons to emoji ...
if (featureType === 'icon') {
const svgIcon: string = attributes.id;
const emoji = XMLSerializerTango.emojiEquivalent(svgIcon);
if (emoji) {
attributes.id = emoji;
feature = FeatureModelFactory.createModel('eicon' as FeatureType, attributes);
}
}
2022-02-15 11:36:14 -08:00
topic.addFeature(feature);
} else if (elem.tagName === 'text') {
const nodeText = XMLSerializerTango._deserializeNodeText(child);
topic.setText(nodeText);
}
}
});
// Workaround: for some reason, some saved maps have holes in the order.
if (topic.getType() !== 'CentralTopic') {
topic
.getChildren()
2022-03-13 17:04:44 -03:00
.sort((a, b) => a.getOrder() - b.getOrder())
2022-02-15 11:36:14 -08:00
.forEach((child, index) => {
if (child.getOrder() !== index) {
child.setOrder(index);
console.log('Toppic with order sequence hole. Introducing auto recovery sequence fix.');
}
});
}
return topic;
}
static _deserializeTextAttr(domElem: Element): string {
let value = domElem.getAttribute('text');
if (!$defined(value)) {
const children = domElem.childNodes;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.nodeType === Node.CDATA_SECTION_NODE) {
value = child.nodeValue;
}
}
} else {
// Notes must be decoded ...
value = unescape(value);
// Hack for empty nodes ...
if (value === '') {
value = ' ';
}
}
return value;
}
2022-11-01 16:31:33 -07:00
private static emojiEquivalent(icon: string): string | undefined {
return emojiToIconMap[icon];
}
2022-11-24 00:07:16 -08:00
private static _deserializeNodeText(domElem: ChildNode): string | null {
2022-02-15 11:36:14 -08:00
const children = domElem.childNodes;
2022-11-24 00:07:16 -08:00
let value: string | null = null;
2022-02-15 11:36:14 -08:00
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.nodeType === Node.CDATA_SECTION_NODE) {
value = child.nodeValue;
}
}
return value;
}
2022-03-14 00:37:42 -03:00
static _deserializeRelationship(domElement: Element, mindmap: Mindmap): RelationshipModel {
2022-02-15 11:36:14 -08:00
const srcId = Number.parseInt(domElement.getAttribute('srcTopicId'), 10);
const destId = Number.parseInt(domElement.getAttribute('destTopicId'), 10);
const lineType = Number.parseInt(domElement.getAttribute('lineType'), 10);
const srcCtrlPoint = domElement.getAttribute('srcCtrlPoint');
const destCtrlPoint = domElement.getAttribute('destCtrlPoint');
// If for some reason a relationship lines has source and dest nodes the same, don't import it.
if (srcId === destId) {
throw new Error('Invalid relationship, dest and source are equals');
}
2022-03-14 00:37:42 -03:00
2022-02-15 11:36:14 -08:00
// Is the connections points valid ?. If it's not, do not load the relationship ...
if (mindmap.findNodeById(srcId) == null || mindmap.findNodeById(destId) == null) {
throw new Error('Transition could not created, missing node for relationship');
}
const model = mindmap.createRelationship(srcId, destId);
model.setLineType(lineType);
if ($defined(srcCtrlPoint) && srcCtrlPoint !== '') {
model.setSrcCtrlPoint(Point.fromString(srcCtrlPoint));
}
if ($defined(destCtrlPoint) && destCtrlPoint !== '') {
model.setDestCtrlPoint(Point.fromString(destCtrlPoint));
}
2022-03-14 00:37:42 -03:00
model.setEndArrow(false);
model.setStartArrow(true);
2022-02-15 11:36:14 -08:00
return model;
}
/**
2022-07-13 01:45:36 +00:00
* This method ensures that the output String has only
* valid XML unicode characters as specified by the
* XML 1.0 standard. For reference, please see
* <a href="http://www.w3.org/TR/2000/REC-xml-20001006#NT-Char">the
* standard</a>. This method will return an empty
* String if the input is null or empty.
*
* @param in The String whose non-valid characters we want to remove.
* @return The in String, stripped of non-valid characters.
*/
2022-11-24 00:07:16 -08:00
protected _rmXmlInv(str: string): string {
2022-02-15 11:36:14 -08:00
let result = '';
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (
2022-07-13 01:45:36 +00:00
c === 0x9 ||
c === 0xa ||
c === 0xd ||
(c >= 0x20 && c <= 0xd7ff) ||
(c >= 0xe000 && c <= 0xfffd) ||
(c >= 0x10000 && c <= 0x10ffff)
2022-02-15 11:36:14 -08:00
) {
result += str.charAt(i);
}
}
return result;
}
2021-12-19 08:06:42 -08:00
}
2021-07-16 11:41:58 -03:00
2021-12-23 12:00:11 -08:00
export default XMLSerializerTango;