diff --git a/README.md b/README.md index b86f30c8..c0ff1f34 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ TeamMapper is based on mindmapp (https://github.com/cedoor/mindmapp , discontinu - Host and create your own mindmaps - Set node images, colors and font properties. +- Add links to nodes - Shortcuts - Import and export functionality (JSON, SVG, PDF, PNG...) - Redo / Undo diff --git a/teammapper-backend/src/map/entities/mmpNode.entity.ts b/teammapper-backend/src/map/entities/mmpNode.entity.ts index 53b877a4..caefa049 100644 --- a/teammapper-backend/src/map/entities/mmpNode.entity.ts +++ b/teammapper-backend/src/map/entities/mmpNode.entity.ts @@ -77,6 +77,9 @@ export class MmpNode { @Column({ nullable: true, default: 60 }) imageSize: number; + @Column({ nullable: true }) + linkHref: string; + @Column({ nullable: true }) locked: boolean; diff --git a/teammapper-backend/src/map/types.ts b/teammapper-backend/src/map/types.ts index 1c7b29b4..ea0ab3d0 100644 --- a/teammapper-backend/src/map/types.ts +++ b/teammapper-backend/src/map/types.ts @@ -46,6 +46,7 @@ export interface IMmpClientNode { id: string; image: { src: string; size: number }; k: number; + link: { href: string } locked: boolean; name: string; parent: string; diff --git a/teammapper-backend/src/map/utils/clientServerMapping.ts b/teammapper-backend/src/map/utils/clientServerMapping.ts index 4571d75e..024431df 100644 --- a/teammapper-backend/src/map/utils/clientServerMapping.ts +++ b/teammapper-backend/src/map/utils/clientServerMapping.ts @@ -14,6 +14,9 @@ const mapMmpNodeToClient = (serverNode: MmpNode): IMmpClientNode => ({ size: serverNode.fontSize || 12, weight: serverNode.fontWeight || '', }, + link: { + href: serverNode.linkHref || '' + }, id: serverNode.id, image: { src: serverNode.imageSrc || '', size: serverNode.imageSize || 0 }, k: serverNode.k || 1, @@ -47,6 +50,7 @@ const mapClientNodeToMmpNode = (clientNode: IMmpClientNode, mapId: string): Obje imageSrc: clientNode.image?.src, imageSize: clientNode.image?.size, k: clientNode.k, + linkHref: clientNode.link?.href, locked: clientNode.locked, name: clientNode.name, nodeParentId: clientNode.parent ? clientNode.parent : null, diff --git a/teammapper-backend/src/migrations/1678605712865-AddLinkHrefToNode.ts b/teammapper-backend/src/migrations/1678605712865-AddLinkHrefToNode.ts new file mode 100644 index 00000000..297e3bb3 --- /dev/null +++ b/teammapper-backend/src/migrations/1678605712865-AddLinkHrefToNode.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddLinkHrefToNode1678605712865 implements MigrationInterface { + name = 'AddLinkHrefToNode1678605712865' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "mmp_node" ADD "linkHref" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "mmp_node" DROP COLUMN "linkHref"`); + } + +} diff --git a/teammapper-backend/test/app.e2e-spec.ts b/teammapper-backend/test/app.e2e-spec.ts index effa827e..765930d0 100644 --- a/teammapper-backend/test/app.e2e-spec.ts +++ b/teammapper-backend/test/app.e2e-spec.ts @@ -90,6 +90,7 @@ describe('AppController (e2e)', () => { parent: '', k: -15.361675447001142, id: '51271bf2-81fa-477a-b0bd-10cecf8d6b65', + link: { href: '' }, locked: false, isRoot: true, }, @@ -118,6 +119,7 @@ describe('AppController (e2e)', () => { coordinates: { x: 1, y: 2 }, font: {}, colors: {}, + link: {} }, }); }); @@ -150,6 +152,7 @@ describe('AppController (e2e)', () => { coordinates: { x: 3, y: 4 }, font: {}, colors: {}, + link: {} }, }); }); diff --git a/teammapper-frontend/mmp/src/index.ts b/teammapper-frontend/mmp/src/index.ts index 0b14ae7b..a195edd8 100644 --- a/teammapper-frontend/mmp/src/index.ts +++ b/teammapper-frontend/mmp/src/index.ts @@ -10,6 +10,4 @@ export function create (id: string, options?: OptionParameters) { return new MmpMap(id, options) } -DOMPurify.setConfig({ADD_ATTR: ['contenteditable'], ADD_TAGS: ['mat-icon']}) - export const NodePropertyMapping = PropertyMapping diff --git a/teammapper-frontend/mmp/src/map/handlers/drag.ts b/teammapper-frontend/mmp/src/map/handlers/drag.ts index 290185ed..61dbab71 100644 --- a/teammapper-frontend/mmp/src/map/handlers/drag.ts +++ b/teammapper-frontend/mmp/src/map/handlers/drag.ts @@ -44,8 +44,6 @@ export default class Drag { * @param {Node} node */ private started(event: D3DragEvent, node: Node) { - event.sourceEvent.preventDefault() - this.orientation = this.map.nodes.getOrientation(node) this.descendants = this.map.nodes.getDescendants(node) diff --git a/teammapper-frontend/mmp/src/map/handlers/draw.ts b/teammapper-frontend/mmp/src/map/handlers/draw.ts index c403f0c0..4d5de76b 100644 --- a/teammapper-frontend/mmp/src/map/handlers/draw.ts +++ b/teammapper-frontend/mmp/src/map/handlers/draw.ts @@ -12,6 +12,7 @@ export default class Draw { private map: Map private base64regex: RegExp = /[^a-zA-Z0-9+\/;:,=]/i + private editing: boolean = false /** * Get the associated map instance. @@ -61,6 +62,7 @@ export default class Draw { const outer = dom.nodes.enter().append('g') .style('cursor', 'pointer') + .style('touch-action', 'none') .attr('class', this.map.id + '_node') .attr('id', function (node: Node) { node.dom = this @@ -70,7 +72,14 @@ export default class Draw { .on('dblclick', (event: MouseEvent, node: Node) => { event.stopPropagation() this.enableNodeNameEditing(node) - }).on('touchstart', (_event: TouchEvent, node: Node) => { + }).on('touchstart', (event: TouchEvent, node: Node) => { + // When not clicking a link and not in edit mode, disable all mobile native touch events + // A single tap is supposed to move the node in this application + if(!this.isLinkTarget(event) && !this.editing) { + event.preventDefault() + } + + // a single tap should enter moving node mode - not a selection if (!tapedTwice) { tapedTwice = true @@ -105,9 +114,10 @@ export default class Draw { .style('stroke-width', 3) .attr('d', (node: Node) => this.drawNodeBackground(node)) - // Set image of the node + // Set image and link of the node outer.each((node: Node) => { this.setImage(node) + this.setLink(node) }) @@ -199,6 +209,7 @@ export default class Draw { d3.selectAll('.' + this.map.id + '_branch').attr('d', (node: Node) => this.drawBranch(node) as any) this.updateImagePosition(node) + this.updateLinkPosition(node) this.updateNodeNameContainer(node) } @@ -242,6 +253,35 @@ export default class Draw { } } + /** + * Set main properties of node image and create it if it does not exist. + * @param {Node} node + */ + public setLink(node: Node) { + let domLink = node.getLinkDOM() + + if (!domLink) { + domLink = document.createElementNS('http://www.w3.org/2000/svg', 'a') + const domText = document.createElementNS('http://www.w3.org/2000/svg', 'text') + domText.textContent = 'link' + domText.classList.add('link-text') + domText.classList.add('material-icons') + domText.style.setProperty('fill', DOMPurify.sanitize(node.colors.name)) + domText.setAttribute('y', node.dimensions.height.toString()) + domText.setAttribute('x', '-10') + node.dom.appendChild(domLink) + domLink.appendChild(domText) + } + + if (DOMPurify.sanitize(node.link.href) !== '') { + domLink.setAttribute('href', DOMPurify.sanitize(node.link.href)) + domLink.setAttribute('target', '_self') + + } else { + domLink.remove() + } + } + /** * Update the node image position. * @param {Node} node @@ -254,11 +294,24 @@ export default class Draw { } } + /** + * Update the node link position. + * @param {Node} node + */ + public updateLinkPosition(node: Node) { + if (DOMPurify.sanitize(node.link.href) !== '') { + const link = node.getLinkDOM(), + y = node.dimensions.height + link.setAttribute('y', y.toString()) + } + } + /** * Enable and manage all events for the name editing. * @param {Node} node */ public enableNodeNameEditing(node: Node) { + this.editing = true const name = node.getNameDOM() name.innerHTML = DOMPurify.sanitize(node.name) @@ -321,6 +374,8 @@ export default class Draw { } name.onblur = () => { + this.editing = false + if (name.innerHTML !== node.name) { this.map.nodes.updateNode('name', DOMPurify.sanitize(name.innerHTML)) } @@ -383,6 +438,7 @@ export default class Draw { div.style.setProperty('font-weight', DOMPurify.sanitize(node.font.weight)) div.style.setProperty('text-decoration', DOMPurify.sanitize(node.font.decoration)) + div.style.setProperty('touch-action', 'none') div.style.setProperty('display', 'inline-block') div.style.setProperty('white-space', 'pre') div.style.setProperty('width', 'auto') @@ -399,4 +455,13 @@ export default class Draw { return div.outerHTML } + /** + * Checks if the target of the event is the link below the node + * @param {TouchEvent} event + * @returns {boolean} + */ + private isLinkTarget(event: TouchEvent): boolean { + return event.target['classList'][0] === 'link-text' + } + } diff --git a/teammapper-frontend/mmp/src/map/handlers/history.ts b/teammapper-frontend/mmp/src/map/handlers/history.ts index c9c6193c..94846e4c 100644 --- a/teammapper-frontend/mmp/src/map/handlers/history.ts +++ b/teammapper-frontend/mmp/src/map/handlers/history.ts @@ -1,8 +1,9 @@ import Map from '../map' -import Node, {Colors, Coordinates, ExportNodeProperties, Font, Image, NodeProperties} from '../models/node' +import Node, {Colors, Coordinates, ExportNodeProperties, Font, Image, Link, NodeProperties} from '../models/node' import {Event} from './events' import Log from '../../utils/log' import Utils from '../../utils/utils' +import { DefaultNodeValues } from '../options' /** * Manage map history, for each change save a snapshot. @@ -120,23 +121,26 @@ export default class History { this.map.nodes.clear() snapshot.forEach((property: ExportNodeProperties) => { + // in case the data model changes this makes sure all properties are at least present using defaults + const mergedProperty = { ...DefaultNodeValues, ...property } as ExportNodeProperties const properties: NodeProperties = { - id: property.id, - parent: this.map.nodes.getNode(property.parent), - k: property.k, - name: property.name, - coordinates: Utils.cloneObject(property.coordinates) as Coordinates, - image: Utils.cloneObject(property.image) as Image, - colors: Utils.cloneObject(property.colors) as Colors, - font: Utils.cloneObject(property.font) as Font, - locked: property.locked, - isRoot: property.isRoot + id: mergedProperty.id, + parent: this.map.nodes.getNode(mergedProperty.parent), + k: mergedProperty.k, + name: mergedProperty.name, + coordinates: Utils.cloneObject(mergedProperty.coordinates) as Coordinates, + image: Utils.cloneObject(mergedProperty.image) as Image, + colors: Utils.cloneObject(mergedProperty.colors) as Colors, + font: Utils.cloneObject(mergedProperty.font) as Font, + link: Utils.cloneObject(mergedProperty.link) as Link, + locked: mergedProperty.locked, + isRoot: mergedProperty.isRoot } const node: Node = new Node(properties) this.map.nodes.setNode(node.id, node) - if(property.isRoot) this.map.rootId = property.id + if(mergedProperty.isRoot) this.map.rootId = mergedProperty.id }) this.map.draw.clear() @@ -203,6 +207,8 @@ export default class History { typeof node.k === 'number', typeof node.name === 'string', typeof node.locked === 'boolean', + // older maps do not include the link prop yet + (node.link === undefined || typeof node.link.href === 'string'), node.coordinates && typeof node.coordinates.x === 'number' && typeof node.coordinates.y === 'number', diff --git a/teammapper-frontend/mmp/src/map/handlers/nodes.ts b/teammapper-frontend/mmp/src/map/handlers/nodes.ts index 47c1d386..71592714 100644 --- a/teammapper-frontend/mmp/src/map/handlers/nodes.ts +++ b/teammapper-frontend/mmp/src/map/handlers/nodes.ts @@ -4,6 +4,7 @@ import Node, { ExportNodeProperties, Font, Image, + Link, NodeProperties, UserNodeProperties } from '../models/node' @@ -264,6 +265,9 @@ export default class Nodes { case 'imageSize': updated = this.updateNodeImageSize(node, value, graphic) break + case 'linkHref': + updated = this.updateNodeLinkHref(node, value) + break case 'backgroundColor': updated = this.updateNodeBackgroundColor(node, value, graphic) break @@ -370,6 +374,7 @@ export default class Nodes { image: Utils.cloneObject(node.image) as Image, colors: Utils.cloneObject(node.colors) as Colors, font: Utils.cloneObject(node.font) as Font, + link: Utils.cloneObject(node.link) as Link, locked: node.locked, isRoot: node.isRoot, k: node.k @@ -862,6 +867,26 @@ export default class Nodes { } } + /** + * Update the node link href with a new value. + * @param {Node} node + * @param {string} href + * @returns {boolean} + */ + private updateNodeLinkHref = (node: Node, href: string) => { + if (href && typeof href !== 'string') { + Log.error('The link href must be a string', 'type') + } + + if (node.link.href !== href) { + node.link.href = href + + this.map.draw.setLink(node) + } else { + return false + } + } + /** * Update the node font style. * @param {Node} node @@ -1021,6 +1046,7 @@ export const PropertyMapping = { coordinates: ['coordinates'], imageSrc: ['image', 'src'], imageSize: ['image', 'size'], + linkHref: ['link', 'href'], backgroundColor: ['colors', 'background'], branchColor: ['colors', 'branch'], fontWeight: ['font', 'weight'], diff --git a/teammapper-frontend/mmp/src/map/models/node.ts b/teammapper-frontend/mmp/src/map/models/node.ts index 48ba7218..6e59dda4 100644 --- a/teammapper-frontend/mmp/src/map/models/node.ts +++ b/teammapper-frontend/mmp/src/map/models/node.ts @@ -15,6 +15,7 @@ export default class Node implements NodeProperties { public image: Image public colors: Colors public font: Font + public link: Link public locked: boolean public dom: SVGGElement public isRoot: boolean @@ -31,6 +32,7 @@ export default class Node implements NodeProperties { this.colors = properties.colors this.image = properties.image this.font = properties.font + this.link = properties.link this.locked = properties.locked this.isRoot = properties.isRoot @@ -80,12 +82,23 @@ export default class Node implements NodeProperties { return this.dom.querySelector('image') } + /** + * Return the SVG a of the node link. + * @returns {SVGIAElement} a + */ + public getLinkDOM(): SVGAElement { + // Unfortunately typescript returns an html type as default - in this case its a SVG element + // https://github.com/microsoft/TypeScript/issues/51844 + return this.dom.querySelector('a > text') as any + } + } export interface UserNodeProperties { name?: string coordinates?: Coordinates image?: Image + link?: Link colors?: Colors font?: Font locked?: boolean @@ -119,6 +132,10 @@ export interface Image { size: number } +export interface Link { + href: string +} + export interface Colors { name?: string background?: string diff --git a/teammapper-frontend/mmp/src/map/options.ts b/teammapper-frontend/mmp/src/map/options.ts index b28d0d4f..0b98e650 100644 --- a/teammapper-frontend/mmp/src/map/options.ts +++ b/teammapper-frontend/mmp/src/map/options.ts @@ -1,4 +1,4 @@ -import {Colors, Font, Image, UserNodeProperties} from './models/node' +import {Colors, Font, Image, Link} from './models/node' import Utils from '../utils/utils' import Map from './map' import * as d3 from 'd3' @@ -33,45 +33,18 @@ export default class Options implements OptionParameters { this.zoom = parameters.zoom !== undefined ? parameters.zoom : true // Default node properties - this.defaultNode = Utils.mergeObjects({ - name: '', - image: { - src: '', - size: 60 - }, - colors: { - name: '#787878', - background: '#f9f9f9', - branch: '#577a96' - }, - font: { - size: 16, - style: 'normal', - weight: 'normal' - }, - locked: true, - isRoot: false - }, parameters.defaultNode, true) as DefaultNodeProperties + this.defaultNode = Utils.mergeObjects( + DefaultNodeValues, + parameters.defaultNode, + true + ) as DefaultNodeProperties // Default root node properties - this.rootNode = Utils.mergeObjects({ - name: 'Root node', - image: { - src: '', - size: 70 - }, - colors: { - name: '#787878', - background: '#f0f6f5', - branch: '' - }, - font: { - size: 20, - style: 'normal', - weight: 'normal' - }, - isRoot: true - }, parameters.rootNode, true) as DefaultNodeProperties + this.rootNode = Utils.mergeObjects( + DefaultRootNodeValues, + parameters.rootNode, + true + ) as DefaultNodeProperties } public update = (property: string, value: any) => { @@ -187,12 +160,62 @@ export default class Options implements OptionParameters { } } +export const DefaultNodeValues: DefaultNodeProperties = { + name: '', + link: { + href: '' + }, + image: { + src: '', + size: 60 + }, + colors: { + name: '#787878', + background: '#f9f9f9', + branch: '#577a96' + }, + font: { + size: 16, + style: 'normal', + weight: 'normal', + decoration: '' + }, + locked: true, + isRoot: false +} + +export const DefaultRootNodeValues: DefaultNodeProperties = { + name: 'Root node', + link: { + href: '' + }, + image: { + src: '', + size: 70 + }, + colors: { + name: '#787878', + background: '#f0f6f5', + branch: '' + }, + font: { + size: 20, + style: 'normal', + weight: 'normal', + decoration: '' + }, + locked: true, + isRoot: true +} + export interface DefaultNodeProperties { name: string image: Image + link: Link colors: Colors font: Font locked: boolean + isRoot: boolean } export interface OptionParameters { diff --git a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts index 7272d69a..4c8a598f 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -356,6 +356,20 @@ export class MmpService { this.updateNode('imageSrc', image) } + /** + * Inserts a link in the selected node. + */ + public addNodeLink (href: string) { + this.updateNode('linkHref', href) + } + + /** + * Removes a link in the selected node. + */ + public removeNodeLink () { + this.updateNode('linkHref', '') + } + /** * Removes an image of the selected node. */ diff --git a/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html b/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html index dfb057c1..6ffc65e5 100644 --- a/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html +++ b/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html @@ -107,12 +107,21 @@ color="primary" mat-icon-button> format_italic - + +
+ + + + + + +