diff --git a/teammapper-frontend/mmp/src/index.ts b/teammapper-frontend/mmp/src/index.ts index a195edd8..f972734e 100644 --- a/teammapper-frontend/mmp/src/index.ts +++ b/teammapper-frontend/mmp/src/index.ts @@ -1,13 +1,12 @@ import {OptionParameters} from './map/options' import MmpMap from './map/map' import { PropertyMapping } from './map/handlers/nodes' -import DOMPurify from 'dompurify' /** * Return a mmp object with all mmp functions. */ -export function create (id: string, options?: OptionParameters) { - return new MmpMap(id, options) +export function create (id: string, ref: HTMLElement, options?: OptionParameters) { + return new MmpMap(id, ref, options) } export const NodePropertyMapping = PropertyMapping diff --git a/teammapper-frontend/mmp/src/map/handlers/draw.ts b/teammapper-frontend/mmp/src/map/handlers/draw.ts index d1e7acb0..5215a68a 100644 --- a/teammapper-frontend/mmp/src/map/handlers/draw.ts +++ b/teammapper-frontend/mmp/src/map/handlers/draw.ts @@ -13,20 +13,22 @@ export default class Draw { private map: Map private base64regex: RegExp = /[^a-zA-Z0-9+\/;:,=]/i private editing: boolean = false + private mapRef: HTMLElement /** * Get the associated map instance. * @param {Map} map */ - constructor(map: Map) { + constructor(map: Map, ref: HTMLElement) { this.map = map + this.mapRef = ref } /** * Create svg and main css map properties. */ public create() { - this.map.dom.container = d3.select('#' + this.map.id) + this.map.dom.container = d3.select(this.mapRef) .style('position', 'relative') this.map.dom.svg = this.map.dom.container.append('svg') @@ -76,7 +78,6 @@ export default class Draw { this.enableNodeNameEditing(node) }).on('touchstart', (event: TouchEvent, node: Node) => { if (!this.map.options.edit) return false - // 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) { @@ -317,6 +318,7 @@ export default class Draw { public enableNodeNameEditing(node: Node) { this.editing = true const name = node.getNameDOM() + name.setAttribute('contenteditable', 'true') name.innerHTML = DOMPurify.sanitize(node.name) Utils.focusWithCaretAtEnd(name) @@ -389,6 +391,7 @@ export default class Draw { name.ondblclick = name.onmousedown = name.onblur = name.onkeydown = name.oninput = name.onpaste = null + name.setAttribute('contenteditable', 'false') name.style.setProperty('cursor', 'pointer') name.blur() @@ -452,8 +455,6 @@ export default class Draw { // fix against cursor jumping out of nodes on firefox if empty div.style.setProperty('min-width', '20px') - div.setAttribute('contenteditable', 'true') - div.innerHTML = DOMPurify.sanitize(node.name) return div.outerHTML diff --git a/teammapper-frontend/mmp/src/map/handlers/events.ts b/teammapper-frontend/mmp/src/map/handlers/events.ts index 3af58189..02823f23 100644 --- a/teammapper-frontend/mmp/src/map/handlers/events.ts +++ b/teammapper-frontend/mmp/src/map/handlers/events.ts @@ -45,6 +45,14 @@ export default class Events { this.dispatcher.on(Event[event], callback as any) } + /** + * Removes / resets all callbacks + */ + public unsubscribeAll = () => { + Object.values(Event).forEach((event: string) => { + this.dispatcher.on(event, null) + }) + } } export enum Event { diff --git a/teammapper-frontend/mmp/src/map/map.ts b/teammapper-frontend/mmp/src/map/map.ts index 149d55c0..4aad2440 100644 --- a/teammapper-frontend/mmp/src/map/map.ts +++ b/teammapper-frontend/mmp/src/map/map.ts @@ -36,7 +36,7 @@ export default class MmpMap { * @param {OptionParameters} options * @returns {MmpInstance} mmpInstance */ - constructor(id: string, options?: OptionParameters) { + constructor(id: string, ref: HTMLElement, options?: OptionParameters) { this.id = id this.dom = {} @@ -45,7 +45,7 @@ export default class MmpMap { this.zoom = new Zoom(this) this.history = new History(this) this.drag = new Drag(this) - this.draw = new Draw(this) + this.draw = new Draw(this, ref) this.nodes = new Nodes(this) this.export = new Export(this) this.copyPaste = new CopyPaste(this) @@ -109,6 +109,7 @@ export default class MmpMap { removeNode: this.nodes.removeNode, selectNode: this.nodes.selectNode, undo: this.history.undo, + unsubscribeAll: this.events.unsubscribeAll, updateNode: this.nodes.updateNode, updateOptions: this.options.update, zoomIn: this.zoom.zoomIn, @@ -128,7 +129,7 @@ export interface MmpInstance { existNode: Function exportAsImage: Function exportAsJSON: Function - exportNodeProperties: Function, + exportNodeProperties: Function exportRootProperties: Function exportSelectedNode: Function highlightNode: Function @@ -142,8 +143,9 @@ export interface MmpInstance { removeNode: Function selectNode: Function undo: Function + unsubscribeAll: Function updateNode: Function - updateOptions: Function, + updateOptions: Function zoomIn: Function zoomOut: Function } diff --git a/teammapper-frontend/mmp/src/map/types.ts b/teammapper-frontend/mmp/src/map/types.ts index b5bbf161..f84011c4 100644 --- a/teammapper-frontend/mmp/src/map/types.ts +++ b/teammapper-frontend/mmp/src/map/types.ts @@ -15,7 +15,7 @@ interface MapProperties { } interface NodeUpdateEvent { - nodeProperties: NodeProperties, + nodeProperties: ExportNodeProperties, previousValue: any, changedProperty: string } diff --git a/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts index 08f9618d..c97ade94 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts @@ -1,10 +1,10 @@ -import { Injectable } from '@angular/core' +import { Injectable, OnDestroy } from '@angular/core' import { MmpService } from '../mmp/mmp.service' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, Observable } from 'rxjs' import { CachedMap, CachedMapEntry, CachedMapOptions } from '../../../shared/models/cached-map.model' import { io, Socket } from 'socket.io-client' import { NodePropertyMapping } from '@mmp/index' -import { ExportNodeProperties, MapProperties, MapSnapshot, NodeUpdateEvent } from '@mmp/map/types' +import { ExportNodeProperties, MapCreateEvent, MapProperties, MapSnapshot, NodeUpdateEvent } from '@mmp/map/types' import { PrivateServerMap, ResponseMapOptionsUpdated, ResponseMapUpdated, ResponseNodeAdded, ResponseNodeRemoved, ResponseNodeUpdated, ResponseSelectionUpdated, ServerMap } from './server-types' import { API_URL, HttpService } from '../../http/http.service' import { DialogService } from '../../../shared/services/dialog/dialog.service' @@ -32,10 +32,13 @@ interface ServerClientList { @Injectable({ providedIn: 'root' }) -export class MapSyncService { - // Observable of behavior subject with the attached map key. - public clientListChanged: BehaviorSubject +export class MapSyncService implements OnDestroy { + // needed in color panel to show all clients + private readonly clientListSubject: BehaviorSubject + // needed in map component to initialize when map is rendered and data present private readonly attachedMapSubject: BehaviorSubject + // needed in the application component for UI related tasks + private readonly attachedNodeSubject: BehaviorSubject private socket: Socket private colorMapping: ClientColorMapping private availableColors: string[] @@ -51,51 +54,86 @@ export class MapSyncService { ) { // Initialization of the behavior subjects. this.attachedMapSubject = new BehaviorSubject(null) + this.attachedNodeSubject = new BehaviorSubject(null) - this.colorMapping = {} - this.clientListChanged = new BehaviorSubject([]) + this.clientListSubject = new BehaviorSubject([]) this.availableColors = COLORS this.clientColor = this.availableColors[Math.floor(Math.random() * this.availableColors.length)] this.modificationSecret = '' + this.colorMapping = {} + this.socket = io() } - public async initNewMap (): Promise { + ngOnDestroy () { + this.reset() + } + + public async prepareNewMap (): Promise { const privateServerMap: PrivateServerMap = await this.postMapToServer() const serverMap = privateServerMap.map // store private map data locally this.storageService.set(serverMap.uuid, { adminId: privateServerMap.adminId, modificationSecret: privateServerMap.modificationSecret, ttl: serverMap.deletedAt }) - this.initMap(serverMap) + this.prepareMap(serverMap) this.settingsService.setEditMode(true) this.modificationSecret = privateServerMap.modificationSecret return privateServerMap } - public async initExistingMap (id: string, modificationSecret: string): Promise { + public async prepareExistingMap (id: string, modificationSecret: string): Promise { this.modificationSecret = modificationSecret const serverMap = await this.fetchMapFromServer(id) if (!serverMap) { return } - this.initMap(serverMap) - this.checkModificationSecret() + this.prepareMap(serverMap) return serverMap } - /** - * Attach a map. - */ + // In case the component is destroyed or will be reinitialized it is important to reset state + // that might cause problems or performance issues, e.g. removing listeners, cleanup state. + // The current map is used inside the settings component and should stay therefore as it was. + public reset () { + if (this.socket) { + this.socket.removeAllListeners() + this.leaveMap() + } + this.colorMapping = {} + } + + public initMap() { + this.mmpService.new(this.getAttachedMap().cachedMap.data) + this.attachedNodeSubject.next(this.mmpService.selectNode(this.mmpService.getRootNode().id)) + + this.createMapListeners() + this.listenServerEvents(this.getAttachedMap().cachedMap.uuid) + } + public attachMap (cachedMapEntry: CachedMapEntry): void { this.attachedMapSubject.next(cachedMapEntry) } - /** - * Update the attached map. - */ + public getAttachedMapObservable (): Observable { + return this.attachedMapSubject.asObservable() + } + + public getClientListObservable (): Observable { + return this.clientListSubject.asObservable() + } + + public getAttachedNodeObservable (): Observable { + return this.attachedNodeSubject.asObservable() + } + + public getAttachedMap (): CachedMapEntry { + return this.attachedMapSubject.getValue() + } + + // update the attached map from outside control flow public async updateAttachedMap (): Promise { const cachedMapEntry: CachedMapEntry = this.getAttachedMap() @@ -111,26 +149,6 @@ export class MapSyncService { this.attachMap({ key: cachedMapEntry.key, cachedMap }) } - public getAttachedMap (): CachedMapEntry { - return this.attachedMapSubject.getValue() - } - - public async fetchMapFromServer (id: string): Promise { - const response = await this.httpService.get(API_URL.ROOT, '/maps/' + id) - if (!response.ok) return null - - const json: ServerMap = await response.json() - return json - } - - public async postMapToServer(): Promise { - const response = await this.httpService.post( - API_URL.ROOT, '/maps/', - JSON.stringify({ rootNode: this.settingsService.getCachedSettings().mapOptions.rootNode }) - ) - return response.json() - } - public async joinMap (mmpUuid: string, color: string): Promise { return await new Promise((resolve: (reason: any) => void, reject: (reason: any) => void) => { this.socket.emit('join', { mapId: mmpUuid, color }, (serverMap: MapProperties) => { @@ -232,6 +250,22 @@ export class MapSyncService { ) } + private async fetchMapFromServer (id: string): Promise { + const response = await this.httpService.get(API_URL.ROOT, '/maps/' + id) + if (!response.ok) return null + + const json: ServerMap = await response.json() + return json + } + + private async postMapToServer(): Promise { + const response = await this.httpService.post( + API_URL.ROOT, '/maps/', + JSON.stringify({ rootNode: this.settingsService.getCachedSettings().mapOptions.rootNode }) + ) + return response.json() + } + /** * Return the key of the map in the storage */ @@ -246,15 +280,14 @@ export class MapSyncService { return Object.assign({}, serverMap, { lastModified: Date.parse(serverMap.lastModified), deletedAt: Date.parse(serverMap.deletedAt) }) } - private listenServerEvents (uuid: string): void { - this.socket = io() + private listenServerEvents (uuid: string): Promise { + this.checkModificationSecret() this.socket.io.on('reconnect', async () => { const serverMap: MapProperties = await this.joinMap(uuid, this.clientColor) this.dialogService.closeDisconnectDialog() this.mmpService.new(serverMap.data, false) - this.updateAttachedMap() }) this.socket.on('nodeAdded', (result: ResponseNodeAdded) => { @@ -279,7 +312,6 @@ export class MapSyncService { if (result.clientId === this.socket.id) return this.mmpService.new(result.map.data, false) - this.updateAttachedMap() }) this.socket.on('mapOptionsUpdated', (result: ResponseMapOptionsUpdated) => { @@ -339,16 +371,7 @@ export class MapSyncService { window.location.reload() }) - this.joinMap(uuid, this.clientColor) - } - - private initColorMapping (): void { - if (!this.socket?.id) return - - this.colorMapping = { - [this.socket.id]: { nodeId: this.mmpService.exportSelectedNode().id, color: DEFAULT_SELF_COLOR } - } - this.extractClientListForSubscriber() + return this.joinMap(uuid, this.clientColor) } private colorForNode (nodeId: string): string { @@ -363,22 +386,66 @@ export class MapSyncService { } private extractClientListForSubscriber (): void { - this.clientListChanged.next(Object.values(this.colorMapping).map((e: ClientColorMappingValue) => e?.color)) + this.clientListSubject.next(Object.values(this.colorMapping).map((e: ClientColorMappingValue) => e?.color)) } - private initMap(serverMap: ServerMap) { + private prepareMap(serverMap: ServerMap) { const mapKey = this.createKey(serverMap.uuid) const mapProps = this.convertServerMapToMmp(serverMap) this.attachMap({ key: mapKey, cachedMap: { ...mapProps, ...{ options: serverMap.options } } }) + this.mmpService.updateAdditionalMapOptions(serverMap.options) + } - this.mmpService.new(mapProps.data) + private createMapListeners () { + // create is NOT called by the mmp lib for initial map load / and call, but for _imported_ maps + this.mmpService.on('create').subscribe((result: MapCreateEvent) => { + this.attachedNodeSubject.next(this.mmpService.selectNode()) - // init data and other components from exisitng data - this.listenServerEvents(serverMap.uuid) - this.initColorMapping() - this.mmpService.updateAdditionalMapOptions(serverMap.options) + this.updateAttachedMap() + this.updateMap(result.previousMapData) + }) + + this.mmpService.on('nodeSelect').subscribe((nodeProps: ExportNodeProperties) => { + this.updateNodeSelection(nodeProps.id, true) + this.attachedNodeSubject.next(nodeProps) + }) + + this.mmpService.on('nodeDeselect').subscribe((nodeProps: ExportNodeProperties) => { + this.updateNodeSelection(nodeProps.id, false) + this.attachedNodeSubject.next(nodeProps) + }) + + this.mmpService.on('nodeUpdate').subscribe((result: NodeUpdateEvent) => { + this.attachedNodeSubject.next(result.nodeProperties) + this.updateNode(result) + this.updateAttachedMap() + }) + + this.mmpService.on('undo').subscribe(() => { + this.attachedNodeSubject.next(this.mmpService.selectNode()) + this.updateAttachedMap() + this.updateMap() + }) + + this.mmpService.on('redo').subscribe(() => { + this.attachedNodeSubject.next(this.mmpService.selectNode()) + this.updateAttachedMap() + this.updateMap() + }) + + this.mmpService.on('nodeCreate').subscribe((newNode: ExportNodeProperties) => { + this.addNode(newNode) + this.updateAttachedMap() + this.mmpService.selectNode(newNode.id) + this.mmpService.editNode() + }) + + this.mmpService.on('nodeRemove').subscribe((removedNode: ExportNodeProperties) => { + this.removeNode(removedNode) + this.updateAttachedMap() + }) } } \ No newline at end of file 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 d40d4c6a..55450a9c 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@angular/core' -import { Observable } from 'rxjs' +import { Injectable, OnDestroy } from '@angular/core' +import { Observable, Subscription } from 'rxjs' import { SettingsService } from '../settings/settings.service' import { UtilsService } from '../utils/utils.service' import { jsPDF } from 'jspdf' +import { first } from 'rxjs/operators'; import * as mmp from '@mmp/index' import MmpMap from '@mmp/map/map' import { ExportHistory, ExportNodeProperties, MapSnapshot, OptionParameters, UserNodeProperties } from '@mmp/map/types' @@ -15,31 +16,39 @@ import { CachedMapOptions } from 'src/app/shared/models/cached-map.model' @Injectable({ providedIn: 'root' }) -export class MmpService { +export class MmpService implements OnDestroy { private currentMap: MmpMap private readonly branchColors: Array // additional options that are not handled within mmp, like fontMaxSize etc. private additionalOptions: CachedMapOptions; + private settingsSubscription: Subscription; constructor (public settingsService: SettingsService) { this.additionalOptions = null this.branchColors = COLORS - settingsService.getEditModeSubject().subscribe((result: boolean) => { - if(!this.currentMap) return + this.settingsSubscription = settingsService.getEditModeObservable() + .pipe(first((val: boolean | null) => val !== null)) + .subscribe((result: boolean | null) => { + if(!this.currentMap) return - this.currentMap.options.update('drag', result) - this.currentMap.options.update('edit', result) - }) + this.currentMap.options.update('drag', result) + this.currentMap.options.update('edit', result) + } + ) + } + + ngOnDestroy () { + this.settingsSubscription.unsubscribe() } /** * Create a mindmap using mmp and save the instance with corresponding id. * All function below require the mmp id. */ - public async create (id: string, options?: OptionParameters) { - const map: MmpMap = mmp.create(id, options) + public async create (id: string, ref: HTMLElement, options?: OptionParameters) { + const map: MmpMap = mmp.create(id, ref, options) // additional options do not include the standard mmp map options this.additionalOptions = await this.defaultAdditionalOptions() @@ -51,7 +60,11 @@ export class MmpService { * Remove the mind mmp. */ public remove () { + if(!this.currentMap) return + + this.currentMap.instance.unsubscribeAll() this.currentMap.instance.remove() + this.currentMap = undefined } /** diff --git a/teammapper-frontend/src/app/core/services/settings/settings.service.ts b/teammapper-frontend/src/app/core/services/settings/settings.service.ts index 8282b0a6..2a5064d1 100644 --- a/teammapper-frontend/src/app/core/services/settings/settings.service.ts +++ b/teammapper-frontend/src/app/core/services/settings/settings.service.ts @@ -54,8 +54,8 @@ export class SettingsService { return this.settingsSubject.getValue() } - public getEditModeSubject (): BehaviorSubject { - return this.editModeSubject + public getEditModeObservable (): Observable { + return this.editModeSubject.asObservable() } public setEditMode (value: boolean) { diff --git a/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts b/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts index d2f7f46d..31eddd76 100644 --- a/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts +++ b/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts @@ -1,15 +1,17 @@ -import { Injectable } from '@angular/core' +import { Injectable, OnDestroy } from '@angular/core' import { MmpService } from '../mmp/mmp.service' import { Router } from '@angular/router' import { Hotkey, HotkeysService } from 'angular2-hotkeys' +import { first, Subscription } from 'rxjs'; import { SettingsService } from '../settings/settings.service' @Injectable({ providedIn: 'root' }) -export class ShortcutsService { +export class ShortcutsService implements OnDestroy { private hotKeys: Hotkey[] private editMode: boolean + private settingsSubscription: Subscription constructor (private mmpService: MmpService, private hotkeysService: HotkeysService, @@ -21,10 +23,17 @@ export class ShortcutsService { * Add all global hot keys of the application. */ public init () { - this.settingsService.getEditModeSubject().subscribe((result: boolean) => { - this.editMode = result - this.registerHotKeys() - }) + this.settingsSubscription = this.settingsService.getEditModeObservable() + .pipe(first((val: boolean | null) => val !== null)) + .subscribe((result: boolean | null) => { + this.editMode = result + this.registerHotKeys() + } + ) + } + + ngOnDestroy () { + this.settingsSubscription.unsubscribe() } public registerHotKeys() { diff --git a/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.html b/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.html index 96e3ee68..46e3dd5f 100644 --- a/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.html +++ b/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.html @@ -2,7 +2,7 @@
- + account_circle
diff --git a/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.ts b/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.ts index dcdba145..76bc9be4 100644 --- a/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/client-color-panels/client-color-panels.component.ts @@ -1,5 +1,6 @@ import { Component, ElementRef, ViewChild } from '@angular/core' import { MapSyncService } from 'src/app/core/services/map-sync/map-sync.service' +import { Observable } from 'rxjs'; @Component({ selector: 'teammapper-client-colors-panel', @@ -9,11 +10,9 @@ import { MapSyncService } from 'src/app/core/services/map-sync/map-sync.service' export class ClientColorPanelsComponent { @ViewChild('background') public background: ElementRef - public clientColors: string[] + public clientColors: Observable constructor (public mapSyncService: MapSyncService) { - mapSyncService.clientListChanged.subscribe((clients: string[]) => { - this.clientColors = clients - }) + this.clientColors = mapSyncService.getClientListObservable() } } diff --git a/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.html b/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.html index 47a89980..446c6381 100644 --- a/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.html +++ b/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts b/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts index b266cc55..f4e0b707 100644 --- a/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts @@ -1,4 +1,5 @@ import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core' +import { ExportNodeProperties } from '@mmp/map/types' import { MmpService } from '../../../../core/services/mmp/mmp.service' @Component({ @@ -7,7 +8,7 @@ import { MmpService } from '../../../../core/services/mmp/mmp.service' styleUrls: ['./color-panels.component.scss'] }) export class ColorPanelsComponent implements OnInit { - @Input() public node: any + @Input() public node: ExportNodeProperties @Input() public editDisabled: boolean @ViewChild('background') public background: ElementRef diff --git a/teammapper-frontend/src/app/modules/application/components/map/map.component.html b/teammapper-frontend/src/app/modules/application/components/map/map.component.html index cdd6d9f5..83189991 100644 --- a/teammapper-frontend/src/app/modules/application/components/map/map.component.html +++ b/teammapper-frontend/src/app/modules/application/components/map/map.component.html @@ -1 +1 @@ -
+
diff --git a/teammapper-frontend/src/app/modules/application/components/map/map.component.ts b/teammapper-frontend/src/app/modules/application/components/map/map.component.ts index c6b68c9d..f231fef6 100644 --- a/teammapper-frontend/src/app/modules/application/components/map/map.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/map/map.component.ts @@ -1,11 +1,42 @@ -import { Component } from '@angular/core' +import { Component, ElementRef, ViewChild, OnDestroy, AfterViewInit } from '@angular/core' +import { MapSyncService } from 'src/app/core/services/map-sync/map-sync.service'; +import { MmpService } from 'src/app/core/services/mmp/mmp.service'; +import { SettingsService } from 'src/app/core/services/settings/settings.service'; +import { CachedMapEntry } from 'src/app/shared/models/cached-map.model'; + +import { first, Subscription } from 'rxjs'; @Component({ selector: 'teammapper-map', templateUrl: './map.component.html', styleUrls: ['./map.component.scss'] }) -export class MapComponent { - constructor () { +export class MapComponent implements AfterViewInit, OnDestroy { + @ViewChild('map') mapWrapper: ElementRef; + + private mapSyncServiceSubscription: Subscription; + + constructor ( + private settingsService: SettingsService, + private mmpService: MmpService, + private mapSyncService: MapSyncService + ) {} + + public async ngAfterViewInit() { + const settings = this.settingsService.getCachedSettings() + + this.mapSyncServiceSubscription = this.mapSyncService.getAttachedMapObservable() + .pipe(first((val: CachedMapEntry | null) => val !== null)) + .subscribe(async (_result: CachedMapEntry | null) => { + await this.mmpService.create('map_1', this.mapWrapper.nativeElement, settings.mapOptions) + this.mapSyncService.initMap() + } + ) + } + + ngOnDestroy() { + this.mapSyncService.reset() + this.mmpService.remove() + this.mapSyncServiceSubscription.unsubscribe() } } diff --git a/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.html b/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.html index 38cff22c..53efc80e 100644 --- a/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.html +++ b/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.html @@ -1,6 +1,6 @@
- + - - + + - +
diff --git a/teammapper-frontend/src/app/modules/application/pages/application/application.component.ts b/teammapper-frontend/src/app/modules/application/pages/application/application.component.ts index c131666e..bb50c76d 100644 --- a/teammapper-frontend/src/app/modules/application/pages/application/application.component.ts +++ b/teammapper-frontend/src/app/modules/application/pages/application/application.component.ts @@ -1,21 +1,29 @@ -import { Component, OnInit } from '@angular/core' +import { Component, OnInit, OnDestroy } from '@angular/core' +import { Subscription, Observable } from 'rxjs'; import { MapSyncService } from '../../../../core/services/map-sync/map-sync.service' import { MmpService } from '../../../../core/services/mmp/mmp.service' import { SettingsService } from '../../../../core/services/settings/settings.service' import { UtilsService } from '../../../../core/services/utils/utils.service' -import { ActivatedRoute, Router, NavigationStart, RouterEvent } from '@angular/router' -import { ExportNodeProperties, MapCreateEvent, NodeUpdateEvent, OptionParameters } from '@mmp/map/types' +import { ActivatedRoute, Router } from '@angular/router' +import { ExportNodeProperties } from '@mmp/map/types' import { StorageService } from 'src/app/core/services/storage/storage.service' import { ServerMap } from 'src/app/core/services/map-sync/server-types' +// Initialization process of a map: +// 1) Render the wrapper element inside the map angular html component +// 2) Wait for data fetching completion (triggered within application component) +// 3) Init mmp library and fill map with data when available +// 4) Register to server events @Component({ selector: 'teammapper-application', templateUrl: './application.component.html', styleUrls: ['./application.component.scss'] }) -export class ApplicationComponent implements OnInit { - public node: any - public editDisabled: boolean +export class ApplicationComponent implements OnInit, OnDestroy { + public node: Observable + public editMode: Observable + + private imageDropSubscription: Subscription; constructor (private mmpService: MmpService, private settingsService: SettingsService, @@ -23,44 +31,31 @@ export class ApplicationComponent implements OnInit { private storageService: StorageService, private route: ActivatedRoute, private router: Router) { - this.node = {} } - public async ngOnInit () { - const settings = this.settingsService.getCachedSettings() + async ngOnInit () { this.storageService.cleanExpired() - // If the map was already initialized before, reload the page as otherwise mmp library gets into a broken state - if(this.mmpService.getCurrentMap()) window.location.reload() - - // Create the mind map. - this.initMap({ ...settings.mapOptions}) + this.initMap() this.handleImageDropObservable() - this.router.events.subscribe((event: RouterEvent) => { - if (event instanceof NavigationStart) { - this.mapSyncService.leaveMap() - } - }) + this.node = this.mapSyncService.getAttachedNodeObservable() + this.editMode = this.settingsService.getEditModeObservable() + } - this.settingsService.getEditModeSubject().subscribe((result: boolean) => this.editDisabled = !result) + ngOnDestroy () { + this.imageDropSubscription.unsubscribe() } public handleImageDropObservable () { - UtilsService.observableDroppedImages().subscribe((image: string) => { + this.imageDropSubscription = UtilsService.observableDroppedImages().subscribe((image: string) => { this.mmpService.updateNode('imageSrc', image) }) } // Initializes the map by either loading an existing one or creating a new one - // Right now creation would be triggered with the /map route and forward to /map/ABC. - public async initMap (options: OptionParameters) { - // Initialize the mmpService component - // This does not mean that any data is loaded just yet. Its more like initializing a mindmapp tab - await this.mmpService.create('map_1', options) - - // Try to either load the given id from the server, or initialize a new map with empty data + private async initMap () { const givenId: string = this.route.snapshot.paramMap.get('id') const modificationSecret: string = this.route.snapshot.fragment const map: ServerMap = await this.loadAndPrepareWithMap(givenId, modificationSecret); @@ -70,69 +65,16 @@ export class ApplicationComponent implements OnInit { this.router.navigate(['']) return } - - this.node = this.mmpService.selectNode(this.mmpService.getRootNode().id) - - // Initialize all listeners - this.createMapListeners() - } - - public createMapListeners () { - // create is NOT called by the mmp lib for initial map load / and call, but for _imported_ maps - this.mmpService.on('create').subscribe((result: MapCreateEvent) => { - Object.assign(this.node, this.mmpService.selectNode()) - - this.mapSyncService.updateAttachedMap() - this.mapSyncService.updateMap(result.previousMapData) - }) - - this.mmpService.on('nodeSelect').subscribe((nodeProps: ExportNodeProperties) => { - this.mapSyncService.updateNodeSelection(nodeProps.id, true) - Object.assign(this.node, nodeProps) - }) - - this.mmpService.on('nodeDeselect').subscribe((nodeProps: ExportNodeProperties) => { - this.mapSyncService.updateNodeSelection(nodeProps.id, false) - Object.assign(this.node, this.mmpService.selectNode()) - }) - - this.mmpService.on('nodeUpdate').subscribe((result: NodeUpdateEvent) => { - Object.assign(this.node, result.nodeProperties) - this.mapSyncService.updateNode(result) - this.mapSyncService.updateAttachedMap() - }) - - this.mmpService.on('undo').subscribe(() => { - Object.assign(this.node, this.mmpService.selectNode()) - this.mapSyncService.updateAttachedMap() - this.mapSyncService.updateMap() - }) - - this.mmpService.on('redo').subscribe(() => { - Object.assign(this.node, this.mmpService.selectNode()) - this.mapSyncService.updateAttachedMap() - this.mapSyncService.updateMap() - }) - - this.mmpService.on('nodeCreate').subscribe((newNode: ExportNodeProperties) => { - this.mapSyncService.addNode(newNode) - this.mapSyncService.updateAttachedMap() - this.mmpService.selectNode(newNode.id) - this.mmpService.editNode() - }) - - this.mmpService.on('nodeRemove').subscribe((removedNode: ExportNodeProperties) => { - this.mapSyncService.removeNode(removedNode) - this.mapSyncService.updateAttachedMap() - }) } private async loadAndPrepareWithMap(mapId: string, modificationSecret: string): Promise { if(mapId) { - return await this.mapSyncService.initExistingMap(mapId, modificationSecret) + return await this.mapSyncService.prepareExistingMap(mapId, modificationSecret) } else { - const privateServerMap = await this.mapSyncService.initNewMap() - history.replaceState({}, '', `/map/${privateServerMap.map.uuid}#${privateServerMap.modificationSecret}`) + const privateServerMap = await this.mapSyncService.prepareNewMap() + const newUrl = this.router.createUrlTree([`/map/${privateServerMap.map.uuid}`], {fragment: privateServerMap.modificationSecret}).toString() + // router navigate would work as well, but it will trigger a component rerender which is not required + history.replaceState({}, '', newUrl) return privateServerMap.map } } diff --git a/teammapper-frontend/src/app/modules/application/pages/settings/settings.component.html b/teammapper-frontend/src/app/modules/application/pages/settings/settings.component.html index f31ac507..e2a37907 100644 --- a/teammapper-frontend/src/app/modules/application/pages/settings/settings.component.html +++ b/teammapper-frontend/src/app/modules/application/pages/settings/settings.component.html @@ -34,7 +34,7 @@

{{ "PAGES.SETTINGS.TITLE" | translate }}

- +
@@ -60,6 +60,7 @@

{{ "PAGES.SETTINGS.TITLE" | translate }}

{{ "PAGES.SETTINGS.TITLE" | translate }} {{ "PAGES.SETTINGS.TITLE" | translate }} {{ "PAGES.SETTINGS.TITLE" | translate }}
- - constructor ( private settingsService: SettingsService, @@ -26,21 +28,15 @@ export class SettingsComponent { this.languages = SettingsService.LANGUAGES this.settings = this.settingsService.getCachedSettings() this.mapOptions = this.mmpService.getAdditionalMapOptions() + this.editMode = this.settingsService.getEditModeObservable() } public async updateGeneralMapOptions() { await this.settingsService.updateCachedSettings(this.settings) - - this.mmpService.updateOptions('rootNode', this.settings.mapOptions.rootNode) - this.mmpService.updateOptions('defaultNode', this.settings.mapOptions.defaultNode) - this.mmpService.updateOptions('centerOnResize', this.settings.mapOptions.centerOnResize) } public async updateMapOptions () { await this.validateMapOptionsInput() - // Update locally - this.mmpService.updateAdditionalMapOptions(this.mapOptions) - // Sync to other users this.mapSyncService.updateMapOptions(this.mapOptions) } diff --git a/teammapper-frontend/src/app/shared/pipes/inverse-bool.pipe.ts b/teammapper-frontend/src/app/shared/pipes/inverse-bool.pipe.ts new file mode 100644 index 00000000..9b006f58 --- /dev/null +++ b/teammapper-frontend/src/app/shared/pipes/inverse-bool.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'inverseBool', + pure: false, +}) +export class InverseBoolPipe implements PipeTransform { + transform(value: boolean | null): boolean { + return !value + } +} \ No newline at end of file diff --git a/teammapper-frontend/src/app/shared/shared.module.ts b/teammapper-frontend/src/app/shared/shared.module.ts index 0a32867d..aed381bc 100644 --- a/teammapper-frontend/src/app/shared/shared.module.ts +++ b/teammapper-frontend/src/app/shared/shared.module.ts @@ -22,9 +22,11 @@ import { MatDialogModule } from '@angular/material/dialog' import { DialogService } from './services/dialog/dialog.service' import { ShareDialogComponent } from './components/share-dialog/share-dialog.component' import { AboutDialogComponent } from './components/about-modal/about-dialog.component' +import { InverseBoolPipe } from './pipes/inverse-bool.pipe' const PIPES = [ - StripTags + StripTags, + InverseBoolPipe ] @NgModule({