diff --git a/HANDOFF.md b/HANDOFF.md index da34d0a..fcc0a4e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -36,11 +36,11 @@ private/migrations.rkt private/people.rkt scrbl/racket-wiki.scrbl server.rkt -static/cmap/cmap-racket-wiki.js -static/cmap/cmap.css +static/js/cmap/cmap-racket-wiki.js +static/js/cmap/cmap.css static/index.html static/js/wiki.js -static/cmap/model/markdown-exporter.js +static/js/cmap/model/markdown-exporter.js test/cmap-export.test.js translate.rkt ``` @@ -94,7 +94,7 @@ racket setup-vendor.rkt --data ./wiki-data | `translate.rkt` | Engelse/Nederlandse server- en frontendvertalingen | | `static/index.html` | skelet van de ingelogde single-page-interface | | `static/js/wiki.js` | applicatiestatus, API-calls, pagina-editor en CMap-hostintegratie | -| `static/cmap/` | lokaal onderhouden CMap-renderer, editorlaag en styling | +| `static/js/cmap/` | lokaal onderhouden CMap-renderer, editorlaag en styling | | `architecture/` | importeerbare Nederlandse architectuurpagina's en twee CMaps | | `scrbl/racket-wiki.scrbl` | package/API-documentatie in Scribble | @@ -143,10 +143,10 @@ raco make main.rkt server.rkt setup-vendor.rkt \ migrate-cmap-subpages.rkt architecture/import.rkt node --check static/js/wiki.js -node --check static/cmap/model/markdown-exporter.js +node --check static/js/cmap/model/markdown-exporter.js node --check static/js/combobox.js -node --check static/cmap/cmap.js -node --check static/cmap/cmap-racket-wiki.js +node --check static/js/cmap/cmap.js +node --check static/js/cmap/cmap-racket-wiki.js node --test test/cmap-export.test.js ``` diff --git a/README.md b/README.md index 7b2fe5a..7608ff4 100644 --- a/README.md +++ b/README.md @@ -572,7 +572,7 @@ The CMap prototype now reports its complete selection path to the browser consol ### 0.2.45 -The concept-map renderer is now maintained as an application component below `static/cmap/` instead of being downloaded and patched as a vendor dependency. The directory contains `cmap.js`, the wiki interaction layer `cmap-racket-wiki.js`, its separate `cmap.css`, and notes about the local component boundary. +The concept-map renderer is now maintained as an application component below `static/js/cmap/` instead of being downloaded and patched as a vendor dependency. The directory contains `cmap.js`, the wiki interaction layer `cmap-racket-wiki.js`, its separate `cmap.css`, and notes about the local component boundary. Selection now follows cmap's own coordinate hit test. The renderer exposes an `onSelection` callback for the public node or link wrapper it found, and `onRendered` callbacks report when the asynchronously drawn DOM element is actually available. The wiki layer therefore no longer has to infer selection from a DOM pointer target or decorate a node before it exists. Clicking a concept applies the CMapTools-like selection ring and controls; clicking empty map space clears it. diff --git a/architecture/pages/structuur-en-samenhang.md b/architecture/pages/structuur-en-samenhang.md index b018564..fe1b0aa 100644 --- a/architecture/pages/structuur-en-samenhang.md +++ b/architecture/pages/structuur-en-samenhang.md @@ -38,7 +38,7 @@ De map `private/` bevat de backendonderdelen: `translate.rkt` staat bewust aan de publieke rand: zowel backend als frontend gebruiken dezelfde effectieve vertaaltabel. -De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routedispatch, API-aanroepen, editor- en paginatoestand en speciale views. De modules onder `static/js/wiki/` bevatten afzonderlijk de referentiesyntaxis, routeherkenning, Markdowntransformaties, paginakoppen en breadcrumbhistorie. De controllers onder `static/js/wiki/admin/` beheren ieder één administratief formulier of overzicht; routes, permissiecontrole, breadcrumbs en algemene adminnavigatie blijven in `wiki.js`. `static/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak. +De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routedispatch, API-aanroepen, editor- en paginatoestand en speciale views. De modules onder `static/js/wiki/` bevatten afzonderlijk de referentiesyntaxis, routeherkenning, Markdowntransformaties, paginakoppen en breadcrumbhistorie. De controllers onder `static/js/wiki/admin/` beheren ieder één administratief formulier of overzicht; routes, permissiecontrole, breadcrumbs en algemene adminnavigatie blijven in `wiki.js`. `static/js/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak. ## Afhankelijkheidsrichting diff --git a/private/cmap-storage.rkt b/private/cmap-storage.rkt index 12db116..82162bf 100644 --- a/private/cmap-storage.rkt +++ b/private/cmap-storage.rkt @@ -354,6 +354,18 @@ SQL (hash-set result 'document (text->document (vector-ref row 2))) result))) +;;; Read the latest SVG render for one database CMap and version. +(define (cmap-render-by-version db cmap-slug version) + (let ((row (query-maybe-row db + #<concept-map row) - 'document - (λ (document) (hydrate-concept-definitions db document))) + (if row + (hash-set + (hash-update (row->concept-map row) + 'document + (λ (document) (hydrate-concept-definitions db document))) + 'renderedSvg + (or (cmap-render-by-version db (vector-ref row 0) (vector-ref row 3)) "")) #f)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -748,7 +763,7 @@ SQL ; update shared registries, and concept-map-storage-document strips ; duplicate content before insertion. read-concept-map hydrates the result. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (create-concept-map! config slug title document author) +(define (create-concept-map! config slug title document author [rendered-svg ""]) (let* ((clean-slug (string-trim slug)) (clean-title (string-trim title)) (now (current-seconds))) @@ -766,7 +781,7 @@ SQL (concept-map-storage-document canonical-document)))) (sync-concept-definitions! db canonical-document author now) (sync-person-tags! db canonical-document) - (query-value + (let ((cmap-id (query-value db #<23! db) + (query-exec db + #<> 'renderedSvg', updated_at +FROM concept_maps +WHERE jsonb_typeof(document -> 'renderedSvg') = 'string' +ON CONFLICT (concept_map_id, document_version) DO NOTHING +SQL + ) + (query-exec db + #< 'renderedSvg') = 'string' +SQL + ) + (record-schema-version! db 23)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Bring a racket-wiki PostgreSQL database to the current schema. ; pre : db is a writable PostgreSQL connection and config identifies the @@ -1277,6 +1312,7 @@ SQL ((= version 19) (migrate-19->20! db) (loop (database-schema-version db))) ((= version 20) (migrate-20->21! db) (loop (database-schema-version db))) ((= version 21) (migrate-21->22! db) (loop (database-schema-version db))) + ((= version 22) (migrate-22->23! db) (loop (database-schema-version db))) ((> version current-schema-version) (error 'migrate-database! "database schema ~a is newer than this racket-wiki supports (~a)" diff --git a/server.rkt b/server.rkt index a04baa3..c44375f 100644 --- a/server.rkt +++ b/server.rkt @@ -400,6 +400,7 @@ CSS (title->slug title) requested-slug)) (define document (request-concept-map-document body)) + (define rendered-svg (hash-ref body 'renderedSvg "")) (cond ((string=? title "") (json-error 400 "Title is required")) ((string=? slug "") (json-error 400 "The title cannot be converted to a concept map address")) @@ -411,7 +412,8 @@ CSS slug title document - (wiki-user-username (wiki-session-user session))) + (wiki-user-username (wiki-session-user session)) + rendered-svg) #:code 201))))))) (define (concept-map-get-handler config req slug) @@ -459,6 +461,7 @@ CSS (define body (request-json req)) (define title (string-trim (hash-ref body 'title ""))) (define document (request-concept-map-document body)) + (define rendered-svg (hash-ref body 'renderedSvg "")) (define base-version (hash-ref body 'baseVersion "")) (define snapshot? (eq? (hash-ref body 'snapshot #f) #t)) (define supplied-save-kind (hash-ref body 'saveKind #f)) @@ -488,7 +491,8 @@ CSS (wiki-user-username (wiki-session-user session)) base-version summary - action)))))) + action + rendered-svg)))))) (define (concept-map-rename-handler config req slug) (require-write-role diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js deleted file mode 100644 index c1195df..0000000 --- a/static/cmap/cmap-racket-wiki.js +++ /dev/null @@ -1,1628 +0,0 @@ -import { - CmapModel, - ConceptMapConcept, - ConceptMapConnector, - ConceptMapPhrase, - PLACEMENT_FIELDS -} from "./model/concept-map.js"; -import { CONCEPT_FIELDS } from "./model/concept-repository.js"; -import { CmapHistory } from "./controller/cmap-history.js"; -import { CmapSubmapController } from "./controller/cmap-submap-controller.js"; -import { CmapSelectionController } from "./controller/cmap-selection-controller.js"; -import { CmapInteractionController } from "./controller/cmap-interaction-controller.js"; -import { CmapItemDecorator } from "./view/cmap-item-decorator.js"; -import { CmapView } from "./cmap-view.js"; -import { - debug, - debugPrefix, - elementDescription, - selectionStyle, - escapeHtml, - numberOr, - conceptDescriptionReference, - newConceptId, - normalizeConceptTags -} from "./cmap-utils.js"; - -/* - * Racket Wiki editor layer for the bundled racket-wiki CMap component. - * - * cmap.js owns hit testing, dragging and render lifecycle callbacks. This file - * adds the wiki-specific editor model and CMapTools-like controls. - */ -(() => { - "use strict"; - - debug("cmap-racket-wiki.js loaded", { - script: document.currentScript ? document.currentScript.src : null, - cmapAvailable: true, - stylesheets: Array.from(document.styleSheets || []) - .map((sheet) => sheet.href) - .filter((href) => href && href.includes("cmap.css")) - }); - - ////////////////////////////////////////////////////////////////////////////// - // Editor - ////////////////////////////////////////////////////////////////////////////// - - /** - * goal : Add the wiki editor model to the bundled CMap drawing component. - * pre : canvas is a DOM element. - * post : Concepts and linking phrases can be selected and connected by - * direct manipulation. - * result : A CmapEditor instance. - */ - class CmapEditor { - constructor(canvas, options = {}) { - this.canvas = canvas; - this.renderItem = options.renderItem || null; - this.onOpenPage = options.onOpenPage || null; - this.onOpenSubMap = options.onOpenSubMap || null; - this.onPopulateSubMap = options.onPopulateSubMap || null; - this.onSubMapPromoted = options.onSubMapPromoted || null; - this.onMapChange = options.onMapChange || null; - this.onOpenCmap = options.onOpenCmap || null; - this.onOpenParentCmap = options.onOpenParentCmap || null; - this.onOpenExternalUrl = options.onOpenExternalUrl || null; - this.onOpenStoredSubMap = options.onOpenStoredSubMap || null; - this.onOpenBoundaryReference = options.onOpenBoundaryReference || null; - this.onVisibilityChange = options.onVisibilityChange || null; - this.onConfirmDetachFromSubmap = options.onConfirmDetachFromSubmap || null; - this.onEditItem = options.onEditItem || null; - this.onCreateConnectedItem = options.onCreateConnectedItem || null; - this.onSelectionChange = options.onSelectionChange || null; - this.onHistoryChange = options.onHistoryChange || null; - this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null; - this.labels = { - createRelation: options.createRelationLabel || "Create relation", - editConcept: options.editConceptLabel || "Edit concept", - resizeConcept: options.resizeConceptLabel || "Resize concept", - relation: options.relationLabel || "Relation" - }; - this.view = new CmapView( - canvas, - (component, event) => this.interaction.handleMapSelection(component, event), - (component, event) => this.interaction.handleMapActivation(component, event), - options.createDiagramEngine || null); - this.map = this.view.map; - this.items = []; - this.connectors = []; - this.unresolvedConnectors = []; - this.model = CmapModel.fromDocument({}); - this.conceptMaps = this.model.conceptMap.conceptMapsById; - this.documentMetadata = this.model.conceptMap.metadata; - this.activeMapRoot = null; - this.mapHistory = []; - this.selectedItem = null; - this.selectedItems = new Set(); - this.selectedConnector = null; - this.destroyed = false; - this.nextId = 1; - this.nextConnectorId = 1; - this.nextGroupId = 1; - this.dragRelation = null; - this.zoomFactor = 1; - this.marqueeMouseDownHandler = null; - this.activeMarqueeCleanup = null; - this.canvasPanPointerDownHandler = null; - this.activeCanvasPanCleanup = null; - this.history = new CmapHistory({ - snapshot: () => this.historySnapshot(), - restore: (snapshot) => this.restoreHistoryDocument(snapshot), - onChange: (change) => { - if (this.onHistoryChange) this.onHistoryChange(change); - } - }); - this.submaps = new CmapSubmapController(this); - this.selection = new CmapSelectionController(this); - this.interaction = new CmapInteractionController(this); - this.decorator = new CmapItemDecorator(this); - this.boundaryLayer = null; - this.boundaryScrollHandler = () => this.refreshBoundaryReferences(); - this.canvas.addEventListener("scroll", this.boundaryScrollHandler, { passive: true }); - this.interaction.installCanvasPanning(); - this.interaction.installMarqueeSelection(); - debug("editor created", { - canvas: elementDescription(canvas), - drawingEngine: this.map.constructor.name - }); - } - - /** Return the view record that renders one model item id. */ - itemRecord(id) { - return this.items.find((record) => Number(record.id) === Number(id)) || null; - } - - /** Return a copy of the records currently rendered by this editor. */ - itemRecords() { - return [...this.items]; - } - - /** Return rendered records that are visible in the active map context. */ - visibleItemRecords() { - return this.items.filter((record) => this.isEffectiveItemVisible(record)); - } - - containsItemRecord(record) { - return this.items.includes(record); - } - - descendantItemRecords(record) { - return this.items.filter((candidate) => this.isDescendantOf(candidate, record)); - } - - setConceptMapReference(reference) { - if (!reference?.id) throw new TypeError("A concept-map reference id is required"); - this.conceptMaps.set(reference.id, reference); - return reference; - } - - itemCount() { - return this.items.length; - } - - connectorCount() { - return this.connectors.length + this.unresolvedConnectors.length; - } - - conceptRepository() { - return this.model.repository; - } - - conceptMapModel() { - return this.model.conceptMap; - } - - /** - * Register the domain data behind a view record. - * The model receives only serializable concept and placement fields. - */ - registerModelItem(record) { - const existing = this.model.conceptMap.item(record.id); - if (existing) return existing; - const values = { kind: record.kind }; - for (const field of PLACEMENT_FIELDS) { - if (field === "parentSubmapId") { - values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null; - } else if (field === "hiddenContexts") { - values.hiddenContexts = Array.from(record.hiddenContexts || []); - } else if (field === "x" || field === "y" || field === "width" || field === "height") { - values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]); - } else { - values[field] = record[field]; - } - } - let modelItem; - if (record.kind === "phrase") { - modelItem = new ConceptMapPhrase(record.id, { - ...values, - label: record.label, - synopsis: record.synopsis - }); - } else { - const conceptValues = {}; - for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field]; - this.model.repository.ensure(record.conceptId, conceptValues); - modelItem = new ConceptMapConcept(record.id, record.conceptId, values); - } - this.model.conceptMap.addItem(modelItem); - return modelItem; - } - - /** - * Make an existing editor record a view onto its pure model item. - * DOM handles stay on the record; all serializable fields live in the model. - */ - bindRecordToModel(record, modelItem) { - if (record.modelItem === modelItem) return record; - Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true }); - Object.defineProperty(record, "kind", { - configurable: true, - get: () => modelItem.kind, - set: (value) => { modelItem.kind = value; } - }); - Object.defineProperty(record, "conceptId", { - configurable: true, - get: () => modelItem.conceptId || null, - set: (conceptId) => { - if (modelItem instanceof ConceptMapPhrase || !conceptId) return; - const current = this.model.repository.concept(modelItem.conceptId); - const target = this.model.repository.concept(conceptId) || - this.model.repository.ensure(conceptId, current ? current.toDocument() : {}); - modelItem.conceptId = target.id; - } - }); - for (const field of ["label", "synopsis"]) { - Object.defineProperty(record, field, { - configurable: true, - get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] : - this.model.repository.requireConcept(modelItem.conceptId)[field], - set: (value) => { - if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || ""); - else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value }); - } - }); - } - for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) { - Object.defineProperty(record, field, { - configurable: true, - get: () => modelItem instanceof ConceptMapPhrase ? null : - this.model.repository.requireConcept(modelItem.conceptId)[field], - set: (value) => { - if (!(modelItem instanceof ConceptMapPhrase)) { - this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value }); - } - } - }); - } - for (const field of PLACEMENT_FIELDS.filter((name) => - name !== "parentSubmapId" && name !== "x" && name !== "y")) { - Object.defineProperty(record, field, { - configurable: true, - get: () => modelItem[field], - set: (value) => { modelItem[field] = value; } - }); - } - Object.defineProperty(record, "parentSubmap", { - configurable: true, - get: () => modelItem.parentSubmapId === null ? null : - this.itemRecord(modelItem.parentSubmapId), - set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; } - }); - return record; - } - - /** Register and bind a record that was inserted through the editor view. */ - attachRecordToModel(record) { - return this.bindRecordToModel(record, this.registerModelItem(record)); - } - - /** Bind a rendered connector to its map-local connector model. */ - attachConnectorToModel(record) { - let modelConnector = this.model.conceptMap.connector(record.id); - if (!modelConnector) { - modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector( - record.id, record.source.id, record.target.id, record)); - } - Object.defineProperty(record, "modelConnector", { - value: modelConnector, - configurable: true - }); - for (const field of ["hasArrow", "lineColor", "lineWidth"]) { - Object.defineProperty(record, field, { - configurable: true, - get: () => modelConnector[field], - set: (value) => { modelConnector[field] = value; } - }); - } - for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) { - Object.defineProperty(record, field, { - configurable: true, - get: () => this.itemRecord(modelConnector[idField]), - set: (value) => { modelConnector[idField] = Number(value.id); } - }); - } - return record; - } - - /** Keep the pure model synchronized with geometry owned by the drawing engine. */ - synchronizeModel() { - const itemIds = new Set(this.items.map((record) => Number(record.id))); - for (const modelItem of this.model.conceptMap.items()) { - if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id); - } - for (const record of this.items) { - const modelItem = this.registerModelItem(record); - this.bindRecordToModel(record, modelItem); - if (record.node) { - modelItem.x = Number(record.node.attr("x")); - modelItem.y = Number(record.node.attr("y")); - modelItem.width = Number(record.node.attr("width")); - modelItem.height = Number(record.node.attr("height")); - } - } - const connectorIds = new Set(); - for (const connector of this.connectors) { - connectorIds.add(Number(connector.id)); - let modelConnector = this.model.conceptMap.connector(connector.id); - if (!modelConnector) { - modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector( - connector.id, connector.source.id, connector.target.id, connector)); - } - modelConnector.sourceId = Number(connector.source.id); - modelConnector.targetId = Number(connector.target.id); - modelConnector.hasArrow = connector.hasArrow; - modelConnector.lineColor = connector.lineColor; - modelConnector.lineWidth = connector.lineWidth; - } - for (const connector of this.unresolvedConnectors) { - connectorIds.add(Number(connector.id)); - if (!this.model.conceptMap.connector(connector.id)) { - this.model.conceptMap.addConnector(new ConceptMapConnector( - connector.id, connector.sourceId, connector.targetId, connector)); - } - } - for (const connector of this.model.conceptMap.connectors()) { - if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id); - } - this.model.conceptMap.metadata = this.documentMetadata; - this.model.conceptMap.setConceptMapReferences([...this.conceptMaps.values()]); - this.conceptMaps = this.model.conceptMap.conceptMapsById; - return this.model; - } - - historySnapshot() { - return JSON.stringify(this.toDocument()); - } - - resetHistory() { - this.history.reset(); - } - - scheduleHistoryCommit() { - this.history.scheduleCommit(); - } - - refreshHistorySnapshot() { - this.history.refreshSnapshot(); - } - - commitHistory() { - return this.history.commit(); - } - - canUndo() { - return this.history.canUndo(); - } - - canRedo() { - return this.history.canRedo(); - } - - clearDocument() { - this.clearSelection(false); - for (const connector of this.connectors) connector.link.remove(); - for (const record of this.items) { - if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove(); - record.node.remove(); - } - this.items = []; - this.connectors = []; - this.unresolvedConnectors = []; - this.model = CmapModel.fromDocument({}); - this.conceptMaps = this.model.conceptMap.conceptMapsById; - this.documentMetadata = this.model.conceptMap.metadata; - this.activeMapRoot = null; - this.mapHistory = []; - this.nextId = 1; - this.nextConnectorId = 1; - if (this.boundaryLayer) { - this.boundaryLayer.remove(); - this.boundaryLayer = null; - } - this.nextGroupId = 1; - } - - restoreHistoryDocument(snapshot) { - const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null; - const mapHistoryIds = this.mapHistory.map((record) => record.id); - this.clearDocument(); - this.loadDocument(JSON.parse(snapshot)); - this.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null; - this.mapHistory = mapHistoryIds - .map((id) => this.items.find((item) => item.id === id)) - .filter(Boolean); - this.applyCurrentContextLayout(); - const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; - if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); - this.notifySelection(); - } - - undo() { - const restored = this.history.undo(); - if (!restored) return false; - debug("undo applied", { - undoCount: this.history.undoCount, - redoCount: this.history.redoCount - }); - return true; - } - - redo() { - const restored = this.history.redo(); - if (!restored) return false; - debug("redo applied", { - undoCount: this.history.undoCount, - redoCount: this.history.redoCount - }); - return true; - } - - /** - * goal : Add a draggable concept or linking-phrase node. - * pre : options may contain the normal ionstage/cmap node attributes. - * post : The item is drawn and receives selection/relation/resize UI. - * result : The item record used by the editor. - */ - addItem(options = {}) { - // Begin the transaction before map.node can synchronously render and fit - // the item. A render callback may refresh the current layout snapshot; - // while this timer is pending it must not replace the pre-add snapshot. - this.scheduleHistoryCommit(); - const requestedId = Number(options.id); - const id = Number.isInteger(requestedId) && requestedId > 0 ? requestedId : this.nextId; - this.nextId = Math.max(this.nextId, id + 1); - const kind = options.kind || "concept"; - const autoWidth = options.width === undefined || options.width === null; - const autoHeight = options.height === undefined || options.height === null; - const record = { - id, - conceptId: options.conceptId || (kind === "phrase" ? null : newConceptId()), - kind, - label: options.label || "Concept", - synopsis: options.synopsis || "", - aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [], - tags: normalizeConceptTags(options.tags), - descriptionPageSlug: kind === "phrase" ? null : - (options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)), - pageSlug: options.pageSlug || null, - cmapSlug: options.cmapSlug || null, - externalUrl: options.externalUrl || null, - parentCmapLink: Boolean(options.parentCmapLink), - groupId: options.groupId || null, - childMap: options.childMap || null, - parentSubmap: options.parentSubmap || null, - submapDepth: numberOr(options.submapDepth, 0), - expanded: false, - submapInitialized: false, - separateMap: Boolean(options.separateMap), - mapReference: options.mapReference || null, - hiddenContexts: new Set(Array.isArray(options.hiddenContexts) ? - options.hiddenContexts.map(String) : []), - layouts: options.layouts && typeof options.layouts === "object" ? - Object.fromEntries(Object.entries(options.layouts).map(([context, layout]) => [context, { - x: numberOr(Number(layout.x), numberOr(options.x, 0)), - y: numberOr(Number(layout.y), numberOr(options.y, 0)), - width: numberOr(Number(layout.width), numberOr(options.width, 220)), - height: numberOr(Number(layout.height), numberOr(options.height, 70)), - backgroundColor: layout.backgroundColor || options.backgroundColor || "#f3f6f8", - borderColor: layout.borderColor || options.borderColor || "#5d6d7e", - textColor: layout.textColor || options.textColor || "#222222", - fontFamily: layout.fontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", - fontSize: layout.fontSize || options.fontSize || "11pt", - fontWeight: layout.fontWeight || options.fontWeight || "700", - fontStyle: layout.fontStyle || options.fontStyle || "normal", - synopsisTextColor: layout.synopsisTextColor || layout.textColor || - options.synopsisTextColor || options.textColor || "#222222", - synopsisFontFamily: layout.synopsisFontFamily || layout.fontFamily || - options.synopsisFontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", - synopsisFontSize: layout.synopsisFontSize || - options.synopsisFontSize || "0.84em", - synopsisFontWeight: layout.synopsisFontWeight || layout.fontWeight || - options.synopsisFontWeight || options.fontWeight || "700", - synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle || - options.synopsisFontStyle || options.fontStyle || "normal" - }])) : {}, - submapAnchorLineElement: null, - imageSource: options.imageSource || "", - backgroundColor: options.backgroundColor || "#f3f6f8", - borderColor: options.borderColor || "#5d6d7e", - submapBackgroundColor: kind === "submap" ? - (options.submapBackgroundColor || "#edf7e8") : null, - submapBorderColor: kind === "submap" ? - (options.submapBorderColor || "#57834a") : null, - textColor: options.textColor || "#222222", - fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif", - fontSize: options.fontSize || "11pt", - fontWeight: options.fontWeight || "700", - fontStyle: options.fontStyle || "normal", - synopsisTextColor: options.synopsisTextColor || options.textColor || "#222222", - synopsisFontFamily: options.synopsisFontFamily || options.fontFamily || - "Arial, Helvetica, sans-serif", - synopsisFontSize: options.synopsisFontSize || "0.84em", - synopsisFontWeight: options.synopsisFontWeight || options.fontWeight || "700", - synopsisFontStyle: options.synopsisFontStyle || options.fontStyle || "normal", - width: options.width || (kind === "phrase" ? 145 : 220), - height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)), - autoWidth, - autoHeight, - fitContentPending: autoWidth || autoHeight, - node: null - }; - record.usageCount = record.conceptId && kind !== "phrase" ? - this.items.filter((item) => item.conceptId === record.conceptId && - item.kind !== "phrase").length + 1 : null; - - const node = this.view.createNode({ - content: this.itemHtml(record), - contentType: "html", - x: numberOr(options.x, 80 + ((id * 37) % 420)), - y: numberOr(options.y, 80 + ((id * 83) % 360)), - width: record.width, - height: record.height, - backgroundColor: record.backgroundColor, - borderColor: record.borderColor, - borderWidth: kind === "phrase" ? 0 : 2, - textColor: record.textColor - }); - record.node = node; - this.items.push(record); - this.attachRecordToModel(record); - this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []); - this.refreshConceptMapReferences(); - node.onRendered((_renderedNode, element) => this.decorateItem(record, element)); - node.onMove((_movedNode, x, y) => this.handleItemMove(record, x, y)); - node.onMoveEnd(() => this.handleItemMoveEnd(record)); - debug("item registered; waiting for cmap render callback", { - id: record.id, - kind: record.kind, - label: record.label - }); - return record; - } - - refreshConceptUsageIndicators(conceptIds = null) { - const ids = conceptIds ? new Set(conceptIds.filter(Boolean)) : new Set(this.items - .map((item) => item.conceptId).filter(Boolean)); - for (const conceptId of ids) { - const peers = this.items.filter((item) => item.conceptId === conceptId && - item.kind !== "phrase"); - for (const peer of peers) { - peer.usageCount = peers.length; - if (!peer.node) continue; - peer.node.attr({ content: this.itemHtml(peer) }); - peer.node.redraw(); - } - } - } - - addSubmapItem(parentSubmap, options = {}) { - if (!parentSubmap || parentSubmap.kind !== "submap") { - throw TypeError("A submap parent is required"); - } - const index = this.items.filter((item) => item.parentSubmap === parentSubmap).length; - return this.addItem({ - ...options, - parentSubmap, - submapDepth: parentSubmap.submapDepth + 1, - x: options.x === undefined ? Number(parentSubmap.node.attr("x")) + 55 + ((index % 2) * 245) : options.x, - y: options.y === undefined ? Number(parentSubmap.node.attr("y")) + 105 + (Math.floor(index / 2) * 125) : options.y - }); - } - - refreshConceptMapReferences() { - for (const submap of this.items.filter((item) => item.separateMap && item.mapReference)) { - submap.mapReference.itemIds = this.items - .filter((item) => this.isDescendantOf(item, submap)) - .map((item) => item.id); - } - } - - mapContextKey(root = this.activeMapRoot) { - return this.submaps.mapContextKey(root); - } - - itemLayout(record) { - return { - x: Number(record.node.attr("x")), - y: Number(record.node.attr("y")), - width: Number(record.node.attr("width")), - height: Number(record.node.attr("height")), - backgroundColor: record.backgroundColor, - borderColor: record.borderColor, - textColor: record.textColor, - fontFamily: record.fontFamily, - fontSize: record.fontSize, - fontWeight: record.fontWeight, - fontStyle: record.fontStyle, - synopsisTextColor: record.synopsisTextColor, - synopsisFontFamily: record.synopsisFontFamily, - synopsisFontSize: record.synopsisFontSize, - synopsisFontWeight: record.synopsisFontWeight, - synopsisFontStyle: record.synopsisFontStyle - }; - } - - saveCurrentContextLayout() { - const context = this.mapContextKey(); - for (const record of this.items.filter((item) => this.isEffectiveItemVisible(item))) { - record.layouts[context] = this.itemLayout(record); - } - } - - applyCurrentContextLayout() { - const context = this.mapContextKey(); - const visible = this.items.filter((item) => this.isEffectiveItemVisible(item)); - let offset = { x: 0, y: 0 }; - if (this.activeMapRoot && !this.activeMapRoot.layouts[context]) { - const rootLayout = this.itemLayout(this.activeMapRoot); - offset = { x: 80 - rootLayout.x, y: 80 - rootLayout.y }; - } - - for (const record of visible) { - if (!record.layouts[context]) { - const current = this.itemLayout(record); - record.layouts[context] = { - ...current, - x: current.x + offset.x, - y: current.y + offset.y - }; - } - const layout = record.layouts[context]; - record.width = layout.width; - record.height = layout.height; - record.backgroundColor = layout.backgroundColor || record.backgroundColor; - record.borderColor = layout.borderColor || record.borderColor; - record.textColor = layout.textColor || record.textColor; - record.fontFamily = layout.fontFamily || record.fontFamily; - record.fontSize = layout.fontSize || record.fontSize; - record.fontWeight = layout.fontWeight || record.fontWeight; - record.fontStyle = layout.fontStyle || record.fontStyle; - record.synopsisTextColor = layout.synopsisTextColor || layout.textColor || - record.synopsisTextColor; - record.synopsisFontFamily = layout.synopsisFontFamily || layout.fontFamily || - record.synopsisFontFamily; - record.synopsisFontSize = layout.synopsisFontSize || record.synopsisFontSize; - record.synopsisFontWeight = layout.synopsisFontWeight || layout.fontWeight || - record.synopsisFontWeight; - record.synopsisFontStyle = layout.synopsisFontStyle || layout.fontStyle || - record.synopsisFontStyle; - record.node.attr({ - x: layout.x, - y: layout.y, - width: layout.width, - height: layout.height, - content: this.itemHtml(record), - backgroundColor: record.backgroundColor, - borderColor: record.borderColor, - textColor: record.textColor - }); - record.node.redraw(); - } - this.refreshSubmapVisibility(); - this.refreshConnectorGeometry(); - window.requestAnimationFrame(() => { - if (!this.destroyed && this.canvas.isConnected) { - // DrawingSurface creates and attaches its DOM surface asynchronously. - // Reapply context visibility after that first render so descendants - // of collapsed submaps cannot briefly become the rendered baseline. - this.refreshSubmapVisibility(); - this.refreshConnectorGeometry(); - } - }); - } - - refreshConnectorGeometry() { - for (const connector of this.connectors) { - const source = this.connectorEndpoint(connector.source); - const target = this.connectorEndpoint(connector.target); - this.applyConnectorVisualEndpoints(connector, source, target); - if (source && target && source !== target) { - if (connector.link.sourceNode() !== source.node) connector.link.sourceNode(source.node); - if (connector.link.targetNode() !== target.node) connector.link.targetNode(target.node); - connector.visualSource = source; - connector.visualTarget = target; - connector.link.straighten(); - connector.link.redraw(); - } - } - this.refreshBoundaryReferences(); - } - - itemInsideActiveMap(record) { - return this.submaps.itemInsideActiveMap(record); - } - - boundaryConceptFor(record, crossedConnector, inside) { - if (!record || record.kind !== "phrase") return record; - for (const connector of this.connectors) { - if (connector === crossedConnector) continue; - if (connector.source !== record && connector.target !== record) continue; - const neighbour = connector.source === record ? connector.target : connector.source; - if (neighbour.kind === "phrase") continue; - if (this.itemInsideActiveMap(neighbour) === inside) return neighbour; - } - return record; - } - - refreshBoundaryReferences() { - if (this.boundaryLayer) { - this.boundaryLayer.remove(); - this.boundaryLayer = null; - } - if (!this.activeMapRoot) return; - const surface = this.surfaceElement(); - if (!surface) return; - const crossings = []; - for (const connector of this.connectors) { - const sourceInside = this.itemInsideActiveMap(connector.source); - const targetInside = this.itemInsideActiveMap(connector.target); - if (sourceInside === targetInside) continue; - const insideRecord = sourceInside ? connector.source : connector.target; - const outsideRecord = sourceInside ? connector.target : connector.source; - const insideConcept = this.boundaryConceptFor(insideRecord, connector, true); - const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false); - if (!insideConcept || !outsideConcept || !this.isItemVisible(insideConcept)) continue; - crossings.push({ connector, sourceInside, insideConcept, outsideConcept }); - } - if (!crossings.length) return; - - const layer = document.createElement("div"); - layer.className = "rw-cmap-boundary-layer"; - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.classList.add("rw-cmap-boundary-lines"); - const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs"); - const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); - marker.setAttribute("id", "rw-cmap-boundary-arrow"); - marker.setAttribute("viewBox", "0 0 10 10"); - marker.setAttribute("refX", "9"); - marker.setAttribute("refY", "5"); - marker.setAttribute("markerWidth", "7"); - marker.setAttribute("markerHeight", "7"); - marker.setAttribute("orient", "auto-start-reverse"); - const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path"); - arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z"); - arrow.setAttribute("fill", "#4a5560"); - marker.append(arrow); - definitions.append(marker); - svg.append(definitions); - layer.append(svg); - surface.append(layer); - this.boundaryLayer = layer; - - const viewLeft = this.canvas.scrollLeft / this.zoomFactor; - const viewTop = this.canvas.scrollTop / this.zoomFactor; - const viewWidth = this.canvas.clientWidth / this.zoomFactor; - const viewHeight = this.canvas.clientHeight / this.zoomFactor; - const buttonWidth = 190; - const occupied = { left: [], right: [] }; - const reserveY = (side, desired) => { - let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40)); - while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34; - if (y > viewTop + viewHeight - 40) y = viewTop + 12; - occupied[side].push(y); - return y; - }; - - for (const crossing of crossings) { - const insideX = Number(crossing.insideConcept.node.attr("x")) + - (Number(crossing.insideConcept.node.attr("width")) / 2); - const insideY = Number(crossing.insideConcept.node.attr("y")) + - (Number(crossing.insideConcept.node.attr("height")) / 2); - const outsideX = Number(crossing.outsideConcept.node.attr("x")) + - (Number(crossing.outsideConcept.node.attr("width")) / 2); - const side = outsideX < insideX ? "left" : "right"; - const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12; - const y = reserveY(side, insideY - 15); - const button = document.createElement("button"); - button.type = "button"; - button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`; - button.style.transform = `translate(${x}px, ${y}px)`; - button.style.width = `${buttonWidth}px`; - button.textContent = crossing.outsideConcept.label || "External concept"; - button.title = "Open the concept map containing this connection"; - button.addEventListener("click", () => { - if (this.onOpenBoundaryReference) { - this.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector); - } - }); - layer.append(button); - - const boundaryX = side === "left" ? x + buttonWidth : x; - const boundaryY = y + 15; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const startX = crossing.sourceInside ? insideX : boundaryX; - const startY = crossing.sourceInside ? insideY : boundaryY; - const endX = crossing.sourceInside ? boundaryX : insideX; - const endY = crossing.sourceInside ? boundaryY : insideY; - path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", crossing.connector.lineColor || "#4a5560"); - path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2)); - if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)"); - svg.append(path); - } - } - - isDescendantOf(record, submap) { - return this.submaps.isDescendantOf(record, submap); - } - - isItemVisible(record) { - return this.submaps.isItemVisible(record); - } - - hiddenItemsInCurrentContext() { - return this.submaps.hiddenItemsInCurrentContext(); - } - - canHideSelectionInCurrentContext() { - return this.submaps.canHideSelectionInCurrentContext(); - } - - hideSelectionInCurrentContext() { - return this.submaps.hideSelectionInCurrentContext(); - } - - showItemInCurrentContext(record) { - return this.submaps.showItemInCurrentContext(record); - } - - ensureSubmapContents(record) { - return this.submaps.ensureSubmapContents(record); - } - - toggleSubmap(record, expanded = !record.expanded) { - return this.submaps.toggleSubmap(record, expanded); - } - - openSubmapMap(record) { - return this.submaps.openSubmapMap(record); - } - - openRootMap() { - return this.submaps.openRootMap(); - } - - canStepBackWithinMap() { - return this.submaps.canStepBackWithinMap(); - } - - openParentMap() { - return this.submaps.openParentMap(); - } - - promoteSubmap(record, name) { - return this.submaps.promoteSubmap(record, name); - } - - prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) { - return this.submaps.prepareStoredSubmapExtraction(record, targetSlug, childMetadata); - } - - replaceDocument(document) { - if (!document || typeof document !== "object") return false; - this.history.replace(() => { - this.clearDocument(); - this.loadDocument(document); - }); - this.notifySelection(); - return true; - } - - /** Replace the editor contents with a public CMap domain model. */ - replaceModel(model) { - if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required"); - return this.replaceDocument(model.toDocument()); - } - - setZoom(percent) { - const next = Math.max(25, Math.min(300, Number(percent) || 100)); - this.zoomFactor = next / 100; - this.view.setZoom(this.zoomFactor); - this.ensureCanvasExtent(0, 0); - debug("zoom changed", { percent: next, factor: this.zoomFactor }); - return next; - } - - zoomPercentage() { - return Math.round(this.zoomFactor * 100); - } - - /** - * Install a record-level visibility filter on the generic diagram engine. - * - * The callback receives a wiki item or connector record and its public - * diagram handle. It may return a boolean or a `{ visible }` decision. - * The editor's own visibility rules remain the base visibility and are - * combined with the supplied policy by DiagramEngine. - */ - setFilter(filter) { - if (filter !== null && filter !== undefined && typeof filter !== "function") { - throw new TypeError("A CMap filter must be a function"); - } - this.view.map.setFilter(filter ? (handle) => { - const item = this.items.find((record) => record.node === handle) || null; - const connector = this.connectors.find((record) => record.link === handle) || null; - return filter(item || connector, handle); - } : null); - return this; - } - - surfaceElement() { - return this.view.surfaceElement(); - } - - ensureCanvasExtent(x, y, padding = 180) { - const surface = this.surfaceElement(); - if (!surface) return; - const viewportWidth = this.canvas.clientWidth / this.zoomFactor; - const viewportHeight = this.canvas.clientHeight / this.zoomFactor; - const width = Math.max(viewportWidth, Number(x) + padding, - Number.parseFloat(surface.style.minWidth) || 0); - const height = Math.max(viewportHeight, Number(y) + padding, - Number.parseFloat(surface.style.minHeight) || 0); - surface.style.minWidth = `${Math.ceil(width)}px`; - surface.style.minHeight = `${Math.ceil(height)}px`; - } - - /** - * goal : Change presentation/content of an existing item. - * pre : record belongs to this editor. - * post : The ionstage node and interaction handles are redrawn. - */ - updateItem(record, changes = {}) { - // node.redraw may synchronously run fitItemToContent. Mark the mutation - // first, so that automatic sizing cannot turn the edited state into the - // history baseline before it has been committed as its own Undo step. - this.scheduleHistoryCommit(); - if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags); - for (const [key, value] of Object.entries(changes)) { - if (value !== undefined) record[key] = value; - } - - const identityKeys = ["label", "synopsis", "aspects", "tags", - "descriptionPageSlug", - "pageSlug", "cmapSlug", "externalUrl", - "imageSource"]; - if (record.conceptId && record.kind !== "phrase") { - for (const peer of this.items) { - if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue; - for (const key of identityKeys) { - if (changes[key] !== undefined) peer[key] = changes[key]; - } - peer.node.attr({ content: this.itemHtml(peer) }); - peer.node.redraw(); - } - } - - if (record.autoWidth || record.autoHeight) record.fitContentPending = true; - - record.width = numberOr(Number(record.width), record.node.attr("width")); - record.height = numberOr(Number(record.height), record.node.attr("height")); - record.node.attr({ - content: this.itemHtml(record), - width: record.width, - height: record.height, - backgroundColor: record.backgroundColor, - borderColor: record.borderColor, - textColor: record.textColor - }); - record.node.redraw(); - if (record.kind === "submap") this.submaps.updateGroupAppearance(record); - this.redrawConnectorsFor(record); - this.refreshSubmapVisibility(); - } - - /** - * goal : Connect source to target with a separate linking phrase. - * pre : source and target are items in this editor. - * post : source -> phrase -> target is visible; the phrase can branch. - * result : The newly created linking-phrase item. - */ - connectWithPhrase(source, target, label = "?????", editImmediately = true) { - const a = this.itemCenter(source); - const b = this.itemCenter(target); - const parentSubmap = this.commonSubmapParent([source, target]); - const phrase = this.addItem({ - kind: "phrase", - label, - parentSubmap, - submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, - x: ((a.x + b.x) / 2) - 72, - y: ((a.y + b.y) / 2) - 18, - backgroundColor: "#fbfbf8", - borderColor: "transparent" - }); - this.addConnector(source, phrase, false); - this.addConnector(phrase, target, true); - this.reconcilePhraseMembership(phrase); - this.selectItem(phrase); - if (editImmediately) this.editPhraseInline(phrase); - return phrase; - } - - submapChain(record) { - const result = []; - if (record.kind === "submap") result.push(record); - let parent = record.parentSubmap; - while (parent) { - result.push(parent); - parent = parent.parentSubmap; - } - return result; - } - - commonSubmapParent(records) { - if (!records.length) return null; - const chains = records.map((record) => this.submapChain(record)); - return chains[0] - .filter((candidate) => chains.every((chain) => chain.includes(candidate))) - .sort((a, b) => b.submapDepth - a.submapDepth)[0] || null; - } - - reconcilePhraseMembership(phrase = null) { - const phrases = phrase ? [phrase] : this.items.filter((item) => item.kind === "phrase"); - for (const item of phrases) { - const endpoints = this.connectors - .filter((connector) => connector.source === item || connector.target === item) - .map((connector) => connector.source === item ? connector.target : connector.source) - .filter((endpoint) => endpoint.kind !== "phrase"); - if (!endpoints.length) continue; - const parent = this.commonSubmapParent(endpoints); - item.parentSubmap = parent; - item.submapDepth = parent ? parent.submapDepth + 1 : 0; - } - } - - /** - * goal : Add one directed connector between two existing map items. - * pre : source and target are items in this editor. - * post : A selectable ionstage/cmap link joins them. - * result : Connector record. - */ - addConnector(source, target, hasArrow = true, options = {}) { - const sourceCenter = this.itemCenter(source); - const targetCenter = this.itemCenter(target); - const link = this.view.createConnector({ - content: "", - width: 1, - height: 1, - backgroundColor: "transparent", - borderColor: "transparent", - borderWidth: 0, - lineColor: options.lineColor || "#333", - lineWidth: numberOr(Number(options.lineWidth), 2), - hasArrow, - cx: (sourceCenter.x + targetCenter.x) / 2, - cy: (sourceCenter.y + targetCenter.y) / 2, - sourceX: sourceCenter.x, - sourceY: sourceCenter.y, - targetX: targetCenter.x, - targetY: targetCenter.y - }); - link.sourceNode(source.node).targetNode(target.node); - link.straighten(); - link.draggable(true); - - const record = { - id: Number.isInteger(Number(options.id)) && Number(options.id) > 0 ? - Number(options.id) : this.nextConnectorId, - link, - source, - target, - visualSource: source, - visualTarget: target, - hasArrow, - lineColor: options.lineColor || "#333", - lineWidth: numberOr(Number(options.lineWidth), 2) - }; - this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1); - this.attachConnectorToModel(record); - this.connectors.push(record); - link.onRendered((_renderedLink, element) => this.decorateConnector(record, element)); - link.onConnectionChange((_changedLink, type, node) => - this.handleConnectorConnectionChange(record, type, node)); - link.visible(this.isItemVisible(source) && this.isItemVisible(target)); - this.scheduleHistoryCommit(); - return record; - } - - /** - * Keep a dragged ionstage/cmap link endpoint and the persisted wiki - * connector model in lockstep. The drawing library owns the endpoint - * handles; the wiki model owns sourceId/targetId and submap projection. - */ - handleConnectorConnectionChange(connector, type, node) { - if (!connector || (type !== "source" && type !== "target")) return false; - const endpoint = this.items.find((item) => item.node === node) || null; - const otherType = type === "source" ? "target" : "source"; - - // A connector is only a storable relation while both ends are attached - // to different items. Restore the previous logical projection when an - // endpoint is dropped on empty space or on its opposite endpoint. - if (!endpoint || endpoint === connector[otherType]) { - connector[`visual${type === "source" ? "Source" : "Target"}`] = null; - this.applyConnectorVisualEndpoints(connector, - this.connectorEndpoint(connector.source), - this.connectorEndpoint(connector.target)); - return false; - } - - if (connector[type] === endpoint) { - connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint; - return false; - } - - const previous = connector[type]; - connector[type] = endpoint; - connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint; - this.reconcilePhraseMembership(); - this.refreshSubmapVisibility(); - this.refreshConnectorGeometry(); - debug("connector endpoint changed", { - connectorId: connector.id, - type, - previousItemId: previous ? previous.id : null, - itemId: endpoint.id - }); - this.scheduleHistoryCommit(); - return true; - } - - // Selection Delegation - selectItem(record, options) { return this.selection.selectItem(record, options); } - selectConnector(record) { return this.selection.selectConnector(record); } - clearSelection(notify) { return this.selection.clearSelection(notify); } - selected() { return this.selection.selected(); } - selectedAll() { return this.selection.selectedAll(); } - refreshSelectionDecoration() { return this.selection.refreshSelectionDecoration(); } - storeConceptReferences(records) { return this.selection.storeConceptReferences(records); } - copySelectionReferences() { return this.selection.copySelectionReferences(); } - canCutSelectionReferences() { return this.selection.canCutSelectionReferences(); } - cutSelectionReferences() { return this.selection.cutSelectionReferences(); } - canPasteConceptReferences() { return this.selection.canPasteConceptReferences(); } - pasteConceptReferences() { return this.selection.pasteConceptReferences(); } - selectAll() { return this.selection.selectAll(); } - layoutSelectionRecords() { return this.selection.layoutSelectionRecords(); } - canLayoutSelection(command) { return this.selection.canLayoutSelection(command); } - applySelectionLayout(command) { return this.selection.applySelectionLayout(command); } - canGroupSelection() { return this.selection.canGroupSelection(); } - groupSelection(options) { return this.selection.groupSelection(options); } - canUngroupSelection() { return this.selection.canUngroupSelection(); } - ungroupSelection() { return this.selection.ungroupSelection(); } - deleteSelection() { return this.selection.deleteSelection(); } - notifySelection() { return this.selection.notifySelection(); } - - getDocumentMetadata() { - return { - tags: [...this.documentMetadata.tags], - summary: this.documentMetadata.summary, - explanationPageSlug: this.documentMetadata.explanationPageSlug - }; - } - - setDocumentMetadata(metadata = {}) { - this.scheduleHistoryCommit(); - this.documentMetadata = { - tags: Array.isArray(metadata.tags) ? metadata.tags.map(String) - .map((tag) => tag.trim()).filter(Boolean) : [], - summary: String(metadata.summary || "").trim(), - explanationPageSlug: String(metadata.explanationPageSlug || "").trim() - }; - this.model.conceptMap.metadata = this.documentMetadata; - return this.getDocumentMetadata(); - } - - /** Serialize the pure repository and map model at the API boundary. */ - toDocument() { - this.saveCurrentContextLayout(); - this.refreshConceptMapReferences(); - return this.synchronizeModel().toDocument(); - } - - /** Return the synchronized domain model currently edited by this view. */ - currentModel() { - this.saveCurrentContextLayout(); - this.refreshConceptMapReferences(); - return this.synchronizeModel(); - } - - /** Load a public CMap domain model into an empty editor. */ - loadModel(model) { - if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required"); - this.loadDocument(model.toDocument()); - } - - loadDocument(document = {}) { - if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) { - throw new Error("A concept map document can only be loaded into an empty editor"); - } - this.model = CmapModel.fromDocument(document); - this.conceptMaps = this.model.conceptMap.conceptMapsById; - this.documentMetadata = this.model.conceptMap.metadata; - const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument()); - const connectorDocuments = this.model.conceptMap.connectors() - .map((connector) => connector.toDocument()); - const records = new Map(); - - for (const itemDocument of itemDocuments) { - const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {}; - const record = this.addItem({ - ...itemDocument, - ...concept, - id: itemDocument.id, - conceptId: itemDocument.conceptId, - kind: itemDocument.kind || concept.kind || "concept", - parentSubmap: null - }); - record.autoWidth = Boolean(itemDocument.autoWidth); - record.autoHeight = Boolean(itemDocument.autoHeight); - record.fitContentPending = false; - record.expanded = Boolean(itemDocument.expanded); - record.submapInitialized = Boolean(itemDocument.submapInitialized); - records.set(record.id, record); - } - const groupNumbers = itemDocuments - .map((item) => /^group-(\d+)$/.exec(item.groupId || "")) - .filter(Boolean) - .map((match) => Number(match[1])); - this.nextGroupId = groupNumbers.length ? Math.max(...groupNumbers) + 1 : 1; - for (const itemDocument of itemDocuments) { - const record = records.get(Number(itemDocument.id)); - const parent = records.get(Number(itemDocument.parentSubmapId)) || null; - if (record) record.parentSubmap = parent; - } - for (const connectorDocument of connectorDocuments) { - const source = records.get(Number(connectorDocument.sourceId)); - const target = records.get(Number(connectorDocument.targetId)); - if (!source || !target) { - this.unresolvedConnectors.push({ ...connectorDocument }); - const unresolvedId = Number(connectorDocument.id); - if (Number.isInteger(unresolvedId) && unresolvedId > 0) { - this.nextConnectorId = Math.max(this.nextConnectorId, unresolvedId + 1); - } - console.warn(`${debugPrefix} relation retained without rendering`, { - relationId: connectorDocument.id || null, - sourceId: connectorDocument.sourceId, - targetId: connectorDocument.targetId, - missingSource: !source, - missingTarget: !target - }); - continue; - } - this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument); - } - this.reconcilePhraseMembership(); - this.refreshConceptMapReferences(); - this.applyCurrentContextLayout(); - this.clearSelection(); - if (!this.history.isRestoring) this.resetHistory(); - return this; - } - - // Interaction Delegation - startRelationDrag(event, source) { return this.interaction.startRelationDrag(event, source); } - finishRelation(source, target, direct) { return this.interaction.finishRelation(source, target, direct); } - createDraftLine(start) { return this.interaction.createDraftLine(start); } - startResize(event, record) { return this.interaction.startResize(event, record); } - handleItemMove(record, x, y) { return this.interaction.handleItemMove(record, x, y); } - beginItemMove(record, includeDescendants) { return this.interaction.beginItemMove(record, includeDescendants); } - moveSubmapGroup(record, x, y, moveMembership) { return this.interaction.moveSubmapGroup(record, x, y, moveMembership); } - handleItemMoveEnd(record) { return this.interaction.handleItemMoveEnd(record); } - startSubmapFrameDrag(event, record) { return this.interaction.startSubmapFrameDrag(event, record); } - installMarqueeSelection() { return this.interaction.installMarqueeSelection(); } - installCanvasPanning() { return this.interaction.installCanvasPanning(); } - handleMapSelection(component, event) { return this.interaction.handleMapSelection(component, event); } - handleMapActivation(component, event) { return this.interaction.handleMapActivation(component, event); } - - // Decorator Delegation - itemHtml(record) { return this.decorator.itemHtml(record); } - editSelected() { - const record = this.selectedItem; - if (!record) return false; - if (record.kind === "phrase") { - this.editPhraseInline(record); - } else if (this.onEditItem) { - this.onEditItem(record); - } - return true; - } - editPhraseInline(record) { return this.decorator.editPhraseInline(record); } - applyItemTypography(record, element) { return this.decorator.applyItemTypography(record, element); } - decorateItem(record, renderedElement) { return this.decorator.decorateItem(record, renderedElement); } - fitItemToContent(record, element) { return this.decorator.fitItemToContent(record, element); } - decorateConnector(record, renderedElement) { return this.decorator.decorateConnector(record, renderedElement); } - ensureSubmapToggle(record, element) { return this.decorator.ensureSubmapToggle(record, element); } - ensureHandles(record, element) { return this.decorator.ensureHandles(record, element); } - removeHandles(element) { return this.decorator.removeHandles(element); } - refreshBoundaryReferences() { return this.decorator.refreshBoundaryReferences(); } - boundaryConceptFor(record, crossedConnector, inside) { return this.decorator.boundaryConceptFor(record, crossedConnector, inside); } - updateSubmapAnchorLine(record, bounds, surface) { return this.decorator.updateSubmapAnchorLine(record, bounds, surface); } - - updateSubmapDepth(record, depth) { - record.submapDepth = depth; - for (const child of this.items.filter((item) => item.parentSubmap === record)) { - this.updateSubmapDepth(child, depth + 1); - } - } - - pointInBounds(point, bounds) { - return Boolean(bounds && point.x >= bounds.left && point.x <= bounds.right && - point.y >= bounds.top && point.y <= bounds.bottom); - } - - pointNearConnector(point, tolerance = 8 / this.zoomFactor) { - const distanceToSegment = (start, end) => { - const segmentX = end.x - start.x; - const segmentY = end.y - start.y; - const segmentLengthSquared = (segmentX * segmentX) + (segmentY * segmentY); - if (segmentLengthSquared === 0) { - return Math.hypot(point.x - start.x, point.y - start.y); - } - const projection = Math.max(0, Math.min(1, - (((point.x - start.x) * segmentX) + ((point.y - start.y) * segmentY)) / - segmentLengthSquared)); - const nearestX = start.x + (projection * segmentX); - const nearestY = start.y + (projection * segmentY); - return Math.hypot(point.x - nearestX, point.y - nearestY); - }; - - return this.connectors.some((connector) => { - const source = connector.visualSource || this.connectorEndpoint(connector.source); - const target = connector.visualTarget || this.connectorEndpoint(connector.target); - if (!source || !target || source === target) return false; - return distanceToSegment(this.itemCenter(source), this.itemCenter(target)) <= tolerance; - }); - } - - submapAtPoint(point, excludedRecord = null, excludedRecords = []) { - const exclusions = new Set(excludedRecords); - const candidates = this.items - .filter((item) => item.kind === "submap" && item.expanded && - item !== excludedRecord && - !exclusions.has(item) && - (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && - !excludedRecords.some((record) => this.isDescendantOf(item, record)) && - this.isItemVisible(item)) - .sort((a, b) => b.submapDepth - a.submapDepth); - const match = candidates.find((item) => this.pointInBounds(point, this.submapBounds(item, excludedRecord))); - return match || (this.activeMapRoot && this.activeMapRoot !== excludedRecord && - !exclusions.has(this.activeMapRoot) ? this.activeMapRoot : null); - } - - submapBounds(record, excludedRecord = null) { - const visibleItems = this.items.filter((item) => - this.isDescendantOf(item, record) && - item !== excludedRecord && - (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && - this.isItemVisible(item)); - if (visibleItems.length === 0) return null; - return { - left: Math.min(...visibleItems.map((item) => Number(item.node.attr("x")))) - 34, - top: Math.min(...visibleItems.map((item) => Number(item.node.attr("y")))) - 42, - right: Math.max(...visibleItems.map((item) => - Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 34, - bottom: Math.max(...visibleItems.map((item) => - Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 34 - }; - } - - visualEndpointFor(record) { - if (this.isItemVisible(record)) return record; - if (record.kind === "phrase" || this.activeMapRoot) return null; - let parent = record.parentSubmap; - while (parent) { - if (this.isItemVisible(parent)) return parent; - parent = parent.parentSubmap; - } - return null; - } - - isEffectiveItemVisible(record) { - if (!this.isItemVisible(record)) return false; - if (record.kind !== "phrase") return true; - const neighbours = this.connectors - .filter((connector) => connector.source === record || connector.target === record) - .map((connector) => connector.source === record ? connector.target : connector.source) - .filter((item) => item.kind !== "phrase"); - return neighbours.every((item) => Boolean(this.visualEndpointFor(item))); - } - - connectorEndpoint(record) { - if (record.kind === "phrase") { - return this.isEffectiveItemVisible(record) ? record : null; - } - return this.visualEndpointFor(record); - } - - applyConnectorVisualEndpoints(connector, source, target) { - if (!source || !target || source === target) { - connector.link.visible(false); - return; - } - if (connector.visualSource !== source) { - connector.link.sourceNode(source.node); - connector.visualSource = source; - } - if (connector.visualTarget !== target) { - connector.link.targetNode(target.node); - connector.visualTarget = target; - } - connector.link.visible(true); - connector.link.straighten(); - connector.link.redraw(); - } - - refreshSubmapVisibility() { - this.submaps.refreshVisibility(); - } - - updateSubmapFrame(record) { - this.submaps.refreshGroups(); - return this.submaps.diagramGroups.get(record)?.redraw() || null; - } - - redrawConnectorsFor(record) { - for (const connector of this.connectors) { - if (connector.source === record || connector.target === record || - connector.visualSource === record || connector.visualTarget === record) { - connector.link.straighten(); - connector.link.redraw(); - } - } - } - - destroy() { - this.destroyed = true; - this.history.destroy(); - if (this.boundaryScrollHandler) { - this.canvas.removeEventListener("scroll", this.boundaryScrollHandler); - this.boundaryScrollHandler = null; - } - this.interaction.destroy(); - for (const item of this.items) { - if (item.submapAnchorLineElement) { - item.submapAnchorLineElement.remove(); - item.submapAnchorLineElement = null; - } - } - this.view.destroy(); - debug("editor destroyed"); - } - - deleteSelection() { - const records = new Set(this.selectedAll().filter((item) => item !== this.activeMapRoot)); - for (const record of Array.from(records)) { - if (record.kind === "submap") { - for (const item of this.items) { - if (this.isDescendantOf(item, record)) records.add(item); - } - } - } - const connectors = new Set(this.connectors.filter((connector) => - connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target))); - const affectedPhrases = new Set(); - for (const connector of connectors) { - if (connector.source.kind === "phrase" && !records.has(connector.source)) { - affectedPhrases.add(connector.source); - } - if (connector.target.kind === "phrase" && !records.has(connector.target)) { - affectedPhrases.add(connector.target); - } - } - let foundOrphan = true; - while (foundOrphan) { - foundOrphan = false; - const remainingConnectors = this.connectors.filter((connector) => - !connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target)); - for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) { - const hasSource = remainingConnectors.some((connector) => connector.target === phrase); - const hasTarget = remainingConnectors.some((connector) => connector.source === phrase); - if (hasSource && hasTarget) continue; - records.add(phrase); - for (const connector of this.connectors) { - if (connector.source !== phrase && connector.target !== phrase) continue; - connectors.add(connector); - if (connector.source.kind === "phrase" && !records.has(connector.source)) { - affectedPhrases.add(connector.source); - } - if (connector.target.kind === "phrase" && !records.has(connector.target)) { - affectedPhrases.add(connector.target); - } - } - foundOrphan = true; - } - } - if (!records.size && !connectors.size) return false; - - this.clearSelection(false); - for (const connector of connectors) { - connector.link.remove(); - this.model.conceptMap.removeConnector(connector.id); - } - this.connectors = this.connectors.filter((connector) => !connectors.has(connector)); - for (const record of records) { - if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id); - record.node.remove(); - this.model.conceptMap.removeItem(record.id); - } - if (records.size && this.unresolvedConnectors.length) { - const deletedIds = new Set(Array.from(records).map((record) => Number(record.id))); - this.unresolvedConnectors = this.unresolvedConnectors.filter((connector) => - !deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId))); - } - this.items = this.items.filter((item) => !records.has(item)); - this.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId)); - this.reconcilePhraseMembership(); - this.refreshConceptMapReferences(); - this.refreshSubmapVisibility(); - this.notifySelection(); - debug("selection deleted", { - itemIds: Array.from(records).map((item) => item.id), - connectorIds: Array.from(connectors).map((connector) => connector.id) - }); - this.scheduleHistoryCommit(); - return true; - } - - logicalCanvasWidth() { - const surface = this.surfaceElement(); - return Math.max(this.canvas.clientWidth / this.zoomFactor, - surface ? surface.scrollWidth : 0); - } - - logicalCanvasHeight() { - const surface = this.surfaceElement(); - return Math.max(this.canvas.clientHeight / this.zoomFactor, - surface ? surface.scrollHeight : 0); - } - - canvasPoint(event) { - const rect = this.canvas.getBoundingClientRect(); - return { - x: (event.clientX - rect.left + this.canvas.scrollLeft) / this.zoomFactor, - y: (event.clientY - rect.top + this.canvas.scrollTop) / this.zoomFactor - }; - } - - itemAt(clientX, clientY) { - const element = document.elementFromPoint(clientX, clientY); - const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null; - if (!itemElement) return null; - const id = Number(itemElement.dataset.rwCmapItemId); - return this.items.find((item) => item.id === id) || null; - } - - itemCenter(record) { - return { - x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2), - y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2) - }; - } - - itemHtml(record) { - if (record.kind === "phrase") { - return `
${escapeHtml(record.label || "?????")}
`; - } - if (this.renderItem) return this.renderItem(record); - return `
${escapeHtml(record.label)}
`; - } - - notifySelection() { - if (this.onSelectionChange) { - this.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll()); - } - } - } - - let lastEditor = null; - - window.RacketWikiCmap = { - version: "0.2.122", - createEditor(canvas, options) { - lastEditor = new CmapEditor(canvas, options); - return lastEditor; - }, - debugSelection() { - if (!lastEditor) { - debug("debugSelection: no editor has been created"); - return null; - } - const record = lastEditor.selected(); - const element = record ? record.node.element() : null; - const result = { - selectedId: record ? record.id : null, - selectedIds: lastEditor.selectedAll().map((item) => item.id), - selectedKind: record ? record.kind : null, - selectedLabel: record ? record.label : null, - element: elementDescription(element), - selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")), - handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0, - computedStyle: selectionStyle(element) - }; - debug("manual selection inspection", result); - return result; - } - }; -})(); diff --git a/static/index.html b/static/index.html index 97d8ccc..7ece7ab 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,7 @@ - + @@ -555,6 +555,10 @@

CMap details

+