refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
+7 -7
View File
@@ -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
```
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+28 -4
View File
@@ -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
#<<SQL
SELECT r.content
FROM cmap_renders r
JOIN concept_maps m ON m.id = r.concept_map_id
WHERE m.slug = $1 AND r.document_version = $2
SQL
cmap-slug version)))
(and row (vector-ref row 0))))
;;; Converts SQL NULL to JSON null and otherwise returns the selected row value.
(define (nullable-row-value row index)
(let ((value (vector-ref row index)))
@@ -732,9 +744,12 @@ SQL
" FROM concept_maps WHERE slug = $1 AND archived = FALSE")
slug)))
(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
#<<SQL
INSERT INTO concept_maps
@@ -774,7 +789,12 @@ INSERT INTO concept_maps
VALUES ($1, $2, $3::text::jsonb, 1, $4, $4, $5, $5)
RETURNING id
SQL
clean-slug clean-title document-text now author))))))
clean-slug clean-title document-text now author)))
(unless (string=? rendered-svg "")
(query-exec db
"INSERT INTO cmap_renders (concept_map_id, document_version, mime_type, content, created_at) VALUES ($1, $2, $3, $4, $5)"
cmap-id 1 "image/svg+xml" rendered-svg now))
cmap-id))))))
(read-concept-map config clean-slug)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -845,7 +865,7 @@ SQL
; is pruned by prune-manual-concept-map-versions! before read-back.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (update-concept-map! config slug title document author base-version
[summary "Edited CMap"] [action "manual"])
[summary "Edited CMap"] [action "manual"] [rendered-svg ""])
(unless (member action '("autosave" "manual" "snapshot"))
(error 'update-concept-map! "invalid save action: ~a" action))
(let ((clean-title (string-trim title)))
@@ -898,6 +918,10 @@ SQL
author
(vector-ref row 0))
(sync-person-tags! db canonical-document)
(unless (string=? rendered-svg "")
(query-exec db
"INSERT INTO cmap_renders (concept_map_id, document_version, mime_type, content, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (concept_map_id, document_version) DO UPDATE SET content = EXCLUDED.content, created_at = EXCLUDED.created_at"
(vector-ref row 0) next-version "image/svg+xml" rendered-svg now))
(unless (string=? action "autosave")
(insert-concept-map-version!
db (vector-ref row 0) next-version clean-title document-text
+37 -1
View File
@@ -21,7 +21,7 @@
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define current-schema-version 22)
(define current-schema-version 23)
(define schema-1-statements
(list
@@ -1240,6 +1240,41 @@ SQL
)
(record-schema-version! db 22))
(define (migrate-22->23! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS cmap_renders (
id BIGSERIAL PRIMARY KEY,
concept_map_id BIGINT NOT NULL REFERENCES concept_maps(id) ON DELETE CASCADE,
document_version BIGINT NOT NULL,
mime_type TEXT NOT NULL,
content TEXT NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE(concept_map_id, document_version)
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS cmap_renders_map_idx ON cmap_renders(concept_map_id, document_version DESC)")
(query-exec db
#<<SQL
INSERT INTO cmap_renders
(concept_map_id, document_version, mime_type, content, created_at)
SELECT id, current_version, 'image/svg+xml', document ->> '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
#<<SQL
UPDATE concept_maps
SET document = document - 'renderedSvg'
WHERE jsonb_typeof(document -> '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)"
+6 -2
View File
@@ -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
File diff suppressed because it is too large Load Diff
+36 -32
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/vendor/highlight-github.min.css">
<link rel="stylesheet" href="/vendor/diff2html.min.css">
<link rel="stylesheet" href="/css/wiki.css?id=__CMAP_CACHE_ID__">
<link rel="stylesheet" href="/cmap/cmap.css?id=__CMAP_CACHE_ID__">
<link rel="stylesheet" href="/js/cmap/cmap.css?id=__CMAP_CACHE_ID__">
</head>
<body>
<div id="connection-status" class="connection-status hidden" role="status"></div>
@@ -555,6 +555,10 @@
<dialog id="cmap-metadata-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-metadata-title">
<form id="cmap-metadata-form">
<h2 id="cmap-metadata-title" data-tr="concept-map-details">CMap details</h2>
<label>
<span data-tr="concept-map-namespace">Information page namespace</span>
<input id="cmap-metadata-namespace" type="text" data-tr-placeholder="concept-map-namespace-placeholder" placeholder="review-zuidas">
</label>
<label>
<span data-tr="summary">Summary</span>
<textarea id="cmap-metadata-summary" rows="5"></textarea>
@@ -633,37 +637,37 @@
<script type="importmap">
{
"imports": {
"/cmap/cmap-racket-wiki.js": "/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
"/cmap/cmap-utils.js": "/cmap/cmap-utils.js?id=__CMAP_CACHE_ID__",
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
"/cmap/cmap.js": "/cmap/cmap.js?id=__CMAP_CACHE_ID__",
"/cmap/controller/cmap-history.js": "/cmap/controller/cmap-history.js?id=__CMAP_CACHE_ID__",
"/cmap/controller/cmap-interaction-controller.js": "/cmap/controller/cmap-interaction-controller.js?id=__CMAP_CACHE_ID__",
"/cmap/controller/cmap-selection-controller.js": "/cmap/controller/cmap-selection-controller.js?id=__CMAP_CACHE_ID__",
"/cmap/controller/cmap-submap-controller.js": "/cmap/controller/cmap-submap-controller.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/diagram-component.js": "/cmap/engine/diagram-component.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/diagram-engine.js": "/cmap/engine/diagram-engine.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/diagram-group.js": "/cmap/engine/diagram-group.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/diagram-link.js": "/cmap/engine/diagram-link.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/diagram-node.js": "/cmap/engine/diagram-node.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/drawing-collections.js": "/cmap/engine/drawing-collections.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/drawing-components.js": "/cmap/engine/drawing-components.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/drawing-core.js": "/cmap/engine/drawing-core.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/drawing-relations.js": "/cmap/engine/drawing-relations.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/drawing-support.js": "/cmap/engine/drawing-support.js?id=__CMAP_CACHE_ID__",
"/cmap/engine/drawing-surface.js": "/cmap/engine/drawing-surface.js?id=__CMAP_CACHE_ID__",
"/cmap/model/concept-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
"/cmap/model/appearance.js": "/cmap/model/appearance.js?id=__CMAP_CACHE_ID__",
"/cmap/model/appearance-repository.js": "/cmap/model/appearance-repository.js?id=__CMAP_CACHE_ID__",
"/cmap/model/cmap-repository.js": "/cmap/model/cmap-repository.js?id=__CMAP_CACHE_ID__",
"/cmap/model/interchange.js": "/cmap/model/interchange.js?id=__CMAP_CACHE_ID__",
"/cmap/model/json-exporter.js": "/cmap/model/json-exporter.js?id=__CMAP_CACHE_ID__",
"/cmap/model/json-importer.js": "/cmap/model/json-importer.js?id=__CMAP_CACHE_ID__",
"/cmap/model/markdown-exporter.js": "/cmap/model/markdown-exporter.js?id=__CMAP_CACHE_ID__",
"/cmap/model/settings-repository.js": "/cmap/model/settings-repository.js?id=__CMAP_CACHE_ID__",
"/cmap/view/appearance-editor.js": "/cmap/view/appearance-editor.js?id=__CMAP_CACHE_ID__",
"/cmap/view/cmap-item-decorator.js": "/cmap/view/cmap-item-decorator.js?id=__CMAP_CACHE_ID__",
"/js/cmap/cmap-racket-wiki.js": "/js/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
"/js/cmap/cmap-utils.js": "/js/cmap/cmap-utils.js?id=__CMAP_CACHE_ID__",
"/js/cmap/cmap-view.js": "/js/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
"/js/cmap/cmap.js": "/js/cmap/cmap.js?id=__CMAP_CACHE_ID__",
"/js/cmap/controller/cmap-history.js": "/js/cmap/controller/cmap-history.js?id=__CMAP_CACHE_ID__",
"/js/cmap/controller/cmap-interaction-controller.js": "/js/cmap/controller/cmap-interaction-controller.js?id=__CMAP_CACHE_ID__",
"/js/cmap/controller/cmap-selection-controller.js": "/js/cmap/controller/cmap-selection-controller.js?id=__CMAP_CACHE_ID__",
"/js/cmap/controller/cmap-submap-controller.js": "/js/cmap/controller/cmap-submap-controller.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/diagram-component.js": "/js/cmap/engine/diagram-component.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/diagram-engine.js": "/js/cmap/engine/diagram-engine.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/diagram-group.js": "/js/cmap/engine/diagram-group.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/diagram-link.js": "/js/cmap/engine/diagram-link.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/diagram-node.js": "/js/cmap/engine/diagram-node.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/drawing-collections.js": "/js/cmap/engine/drawing-collections.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/drawing-components.js": "/js/cmap/engine/drawing-components.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/drawing-core.js": "/js/cmap/engine/drawing-core.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/drawing-relations.js": "/js/cmap/engine/drawing-relations.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/drawing-support.js": "/js/cmap/engine/drawing-support.js?id=__CMAP_CACHE_ID__",
"/js/cmap/engine/drawing-surface.js": "/js/cmap/engine/drawing-surface.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/concept-repository.js": "/js/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/concept-map.js": "/js/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/appearance.js": "/js/cmap/model/appearance.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/appearance-repository.js": "/js/cmap/model/appearance-repository.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/cmap-repository.js": "/js/cmap/model/cmap-repository.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/interchange.js": "/js/cmap/model/interchange.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/json-exporter.js": "/js/cmap/model/json-exporter.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/json-importer.js": "/js/cmap/model/json-importer.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/markdown-exporter.js": "/js/cmap/model/markdown-exporter.js?id=__CMAP_CACHE_ID__",
"/js/cmap/model/settings-repository.js": "/js/cmap/model/settings-repository.js?id=__CMAP_CACHE_ID__",
"/js/cmap/view/appearance-editor.js": "/js/cmap/view/appearance-editor.js?id=__CMAP_CACHE_ID__",
"/js/cmap/view/cmap-item-decorator.js": "/js/cmap/view/cmap-item-decorator.js?id=__CMAP_CACHE_ID__",
"/js/wiki/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
"/js/wiki/routes.js": "/js/wiki/routes.js?id=__CMAP_CACHE_ID__",
"/js/wiki/markdown.js": "/js/wiki/markdown.js?id=__CMAP_CACHE_ID__",
@@ -48,7 +48,7 @@ objects, and CMap state is not persisted in browser storage.
`cmap-view.js` owns the canvas and its concrete `DiagramEngine` instance.
`view/appearance-editor.js` presents the appearance model in the concept dialog
and coordinates explicit style and palette changes with its repository.
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
The concrete dialog controllers live in `../wiki/cmap/dialogs/`. They own
their fields, validation and browser events. The workspace supplies the active
CMap and performs editor transitions, while the controllers use repository
APIs for stored CMaps and people instead of calling backend routes directly.
@@ -144,4 +144,4 @@ for keyboard shortcuts and context-menu state.
`cmap.css` contains all CMap presentation and interaction styles.
The source files deliberately remain separate but form one component and are
loaded directly from `/cmap/`; there is no runtime download or vendor patch.
loaded directly from `/js/cmap/`; there is no runtime download or vendor patch.
+872
View File
@@ -0,0 +1,872 @@
import { CmapModel } from "./model/concept-map.js";
import { CmapHistory } from "./controller/cmap-history.js";
import { CmapSubmapController } from "./controller/cmap-submap-controller.js";
import { CmapDocumentController } from "./controller/cmap-document-controller.js";
import { CmapGeometryController } from "./controller/cmap-geometry-controller.js";
import { CmapRelationController } from "./controller/cmap-relation-controller.js";
import { CmapLayoutController } from "./controller/cmap-layout-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 { CmapBoundaryReferenceView } from "./view/cmap-boundary-reference-view.js";
import { CmapSnapshotSvgRenderer } from "./view/cmap-snapshot-svg-renderer.js";
import { CmapView } from "./cmap-view.js";
import {
debug,
elementDescription,
selectionStyle,
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"))
});
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.boundaryReferenceMapTitle = options.boundaryReferenceMapTitle || "";
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.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.documents = new CmapDocumentController(this);
this.geometry = new CmapGeometryController(this);
this.relations = new CmapRelationController(this);
this.layouts = new CmapLayoutController(this);
this.selection = new CmapSelectionController(this);
this.interaction = new CmapInteractionController(this);
this.decorator = new CmapItemDecorator(this);
this.boundaryReferenceView = new CmapBoundaryReferenceView({
canvas,
zoomFactor: () => this.zoomFactor,
mapTitle: this.boundaryReferenceMapTitle,
surfaceElement: () => this.surfaceElement(),
itemCenter: (record) => this.itemCenter(record),
onOpenReference: (reference) => {
if (this.onOpenBoundaryReference) {
this.onOpenBoundaryReference(reference.outsideRecord, reference.connector);
}
}
});
this.snapshotRenderer = new CmapSnapshotSvgRenderer();
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
});
}
itemRecord(id) {
return this.items.find((record) => Number(record.id) === Number(id)) || null;
}
itemRecords() {
return [...this.items];
}
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;
}
historySnapshot() {
return this.documents.historySnapshot();
}
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();
}
registerModelItem(record) { return this.documents.registerModelItem(record); }
bindRecordToModel(record, modelItem) { return this.documents.bindRecordToModel(record, modelItem); }
attachRecordToModel(record) { return this.documents.attachRecordToModel(record); }
attachConnectorToModel(record) { return this.documents.attachConnectorToModel(record); }
synchronizeModel() { return this.documents.synchronizeModel(); }
clearDocument() { return this.documents.clearDocument(); }
restoreHistoryDocument(snapshot) { return this.documents.restoreHistoryDocument(snapshot); }
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.documents.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 this.layouts.itemLayout(record);
}
saveCurrentContextLayout() {
return this.layouts.saveCurrentContextLayout();
}
applyCurrentContextLayout() {
return this.layouts.applyCurrentContextLayout();
}
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() {
this.documents.synchronizeModel();
const references = this.model.boundaryReferencesFor(this.activeMapRoot?.id)
.map((reference) => ({
...reference,
insideRecord: this.itemRecord(reference.inside.id),
outsideRecord: this.itemRecord(reference.outside.id)
}))
.filter((reference) => reference.insideRecord && reference.outsideRecord);
this.boundaryReferenceView.render(references);
}
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) { return this.documents.replaceDocument(document); }
/** Replace the editor contents with a public CMap domain model. */
replaceModel(model) { return this.documents.replaceModel(model); }
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();
}
snapshotSvg(options) {
return this.snapshotRenderer.render(this.surfaceElement(), options);
}
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) {
return this.relations.connectWithPhrase(source, target, label, editImmediately);
}
submapChain(record) {
return this.relations.submapChain(record);
}
commonSubmapParent(records) {
return this.relations.commonSubmapParent(records);
}
reconcilePhraseMembership(phrase = null) {
return this.relations.reconcilePhraseMembership(phrase);
}
/**
* 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 = {}) {
return this.relations.addConnector(source, target, hasArrow, options);
}
/**
* 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) {
return this.relations.handleConnectorConnectionChange(connector, type, node);
}
// 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 {
namespace: this.documentMetadata.namespace,
tags: [...this.documentMetadata.tags],
summary: this.documentMetadata.summary,
explanationPageSlug: this.documentMetadata.explanationPageSlug
};
}
setDocumentMetadata(metadata = {}) {
this.scheduleHistoryCommit();
this.documentMetadata = {
namespace: String(metadata.namespace || "").trim(),
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() { return this.documents.toDocument(); }
/** Return the synchronized domain model currently edited by this view. */
currentModel() { return this.documents.currentModel(); }
/** Load a public CMap domain model into an empty editor. */
loadModel(model) { return this.documents.loadModel(model); }
loadDocument(document = {}) { return this.documents.loadDocument(document); }
// 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); }
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) {
return this.geometry.updateSubmapDepth(record, depth);
}
pointInBounds(point, bounds) {
return this.geometry.pointInBounds(point, bounds);
}
pointNearConnector(point, tolerance = 8 / this.zoomFactor) {
return this.geometry.pointNearConnector(point, tolerance);
}
submapAtPoint(point, excludedRecord = null, excludedRecords = []) {
return this.geometry.submapAtPoint(point, excludedRecord, excludedRecords);
}
submapBounds(record, excludedRecord = null) {
return this.geometry.submapBounds(record, excludedRecord);
}
visualEndpointFor(record) {
return this.geometry.visualEndpointFor(record);
}
isEffectiveItemVisible(record) {
return this.geometry.isEffectiveItemVisible(record);
}
connectorEndpoint(record) {
return this.geometry.connectorEndpoint(record);
}
applyConnectorVisualEndpoints(connector, source, target) {
return this.geometry.applyConnectorVisualEndpoints(connector, source, target);
}
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();
this.boundaryReferenceView.destroy();
for (const item of this.items) {
if (item.submapAnchorLineElement) {
item.submapAnchorLineElement.remove();
item.submapAnchorLineElement = null;
}
}
this.view.destroy();
debug("editor destroyed");
}
logicalCanvasWidth() {
return this.geometry.logicalCanvasWidth();
}
logicalCanvasHeight() {
return this.geometry.logicalCanvasHeight();
}
canvasPoint(event) {
return this.geometry.canvasPoint(event);
}
itemAt(clientX, clientY) {
return this.geometry.itemAt(clientX, clientY);
}
itemCenter(record) {
return this.geometry.itemCenter(record);
}
}
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;
}
};
})();
@@ -414,6 +414,16 @@ body.cmap-mode #main {
cursor: zoom-in;
}
.rw-cmap-embed-link {
display: block;
}
.rw-cmap-embed-image {
display: block;
width: 100%;
height: auto;
}
.rw-cmap-embed > header {
display: flex;
justify-content: space-between;
@@ -879,27 +889,29 @@ body.cmap-mode #main {
.rw-cmap-boundary-reference {
position: absolute;
z-index: 17;
min-height: 30px;
min-height: 48px;
overflow: hidden;
padding: 5px 9px;
border: 1px solid #77838e;
border: 1px solid #808080;
border-radius: 5px;
background: #fff;
color: #25313c;
color: #808080;
font-size: 75%;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
line-height: 1.2;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 2px 6px rgb(0 0 0 / 14%);
}
.rw-cmap-boundary-reference-left::before {
content: "";
content: "";
}
.rw-cmap-boundary-reference-right::after {
content: "";
content: "";
}
.rw-cmap-selected {
@@ -0,0 +1,333 @@
"use strict";
import {
CmapModel,
ConceptMapConcept,
ConceptMapConnector,
ConceptMapPhrase,
PLACEMENT_FIELDS
} from "../model/concept-map.js";
import { CONCEPT_FIELDS } from "../model/concept-repository.js";
import { debugPrefix } from "../cmap-utils.js";
/**
* Keeps the editor's rendered records synchronized with its domain model.
*
* The editor remains the owner of drawing records and delegates document
* operations here. This controller does not store a second copy of items.
*/
export class CmapDocumentController {
constructor(editor) {
this.editor = editor;
}
get model() { return this.editor.model; }
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get unresolvedConnectors() { return this.editor.unresolvedConnectors; }
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;
}
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.editor.itemRecord(modelItem.parentSubmapId),
set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; }
});
return record;
}
attachRecordToModel(record) {
return this.bindRecordToModel(record, this.registerModelItem(record));
}
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.editor.itemRecord(modelConnector[idField]),
set: (value) => { modelConnector[idField] = Number(value.id); }
});
}
return record;
}
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.editor.documentMetadata;
this.model.conceptMap.setConceptMapReferences([...this.editor.conceptMaps.values()]);
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
return this.model;
}
historySnapshot() {
return JSON.stringify(this.toDocument());
}
clearDocument() {
this.editor.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.editor.items = [];
this.editor.connectors = [];
this.editor.unresolvedConnectors = [];
this.editor.model = CmapModel.fromDocument({});
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
this.editor.documentMetadata = this.model.conceptMap.metadata;
this.editor.activeMapRoot = null;
this.editor.mapHistory = [];
this.editor.nextId = 1;
this.editor.nextConnectorId = 1;
if (this.editor.boundaryReferenceView) this.editor.boundaryReferenceView.clear();
this.editor.nextGroupId = 1;
}
restoreHistoryDocument(snapshot) {
const activeMapRootId = this.editor.activeMapRoot ? this.editor.activeMapRoot.id : null;
const mapHistoryIds = this.editor.mapHistory.map((record) => record.id);
this.clearDocument();
this.editor.loadDocument(JSON.parse(snapshot));
this.editor.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null;
this.editor.mapHistory = mapHistoryIds
.map((id) => this.items.find((item) => item.id === id))
.filter(Boolean);
this.editor.applyCurrentContextLayout();
const reference = this.editor.activeMapRoot ? this.editor.activeMapRoot.mapReference : null;
if (this.editor.onMapChange) this.editor.onMapChange(reference, this.editor.activeMapRoot);
this.editor.notifySelection();
}
replaceDocument(document) {
if (!document || typeof document !== "object") return false;
this.editor.history.replace(() => {
this.clearDocument();
this.loadDocument(document);
});
this.editor.notifySelection();
return true;
}
replaceModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return this.replaceDocument(model.toDocument());
}
toDocument() {
this.editor.saveCurrentContextLayout();
this.editor.refreshConceptMapReferences();
return this.synchronizeModel().toDocument();
}
currentModel() {
this.editor.saveCurrentContextLayout();
this.editor.refreshConceptMapReferences();
return this.synchronizeModel();
}
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.editor.model = CmapModel.fromDocument(document);
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
this.editor.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.editor.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.editor.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.editor.unresolvedConnectors.push({ ...connectorDocument });
const unresolvedId = Number(connectorDocument.id);
if (Number.isInteger(unresolvedId) && unresolvedId > 0) {
this.editor.nextConnectorId = Math.max(this.editor.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.editor.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
}
this.editor.reconcilePhraseMembership();
this.editor.refreshConceptMapReferences();
this.editor.applyCurrentContextLayout();
this.editor.clearSelection();
if (!this.editor.history.isRestoring) this.editor.resetHistory();
return this.editor;
}
}
@@ -0,0 +1,154 @@
"use strict";
/** Own logical diagram geometry, bounds, hit-testing and endpoint projection. */
export class CmapGeometryController {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
get zoomFactor() { return this.editor.zoomFactor; }
logicalCanvasWidth() {
const surface = this.editor.surfaceElement();
return Math.max(this.editor.canvas.clientWidth / this.zoomFactor,
surface ? surface.scrollWidth : 0);
}
logicalCanvasHeight() {
const surface = this.editor.surfaceElement();
return Math.max(this.editor.canvas.clientHeight / this.zoomFactor,
surface ? surface.scrollHeight : 0);
}
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 lengthSquared = (segmentX * segmentX) + (segmentY * segmentY);
if (lengthSquared === 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)) / lengthSquared));
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.editor.connectorEndpoint(connector.source);
const target = connector.visualTarget || this.editor.connectorEndpoint(connector.target);
return source && target && source !== target &&
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.editor.isDescendantOf(item, excludedRecord)) &&
!excludedRecords.some((record) => this.editor.isDescendantOf(item, record)) &&
this.editor.isItemVisible(item))
.sort((a, b) => b.submapDepth - a.submapDepth);
const match = candidates.find((item) =>
this.pointInBounds(point, this.submapBounds(item, excludedRecord)));
return match || (this.editor.activeMapRoot && this.editor.activeMapRoot !== excludedRecord &&
!exclusions.has(this.editor.activeMapRoot) ? this.editor.activeMapRoot : null);
}
submapBounds(record, excludedRecord = null) {
const visibleItems = this.items.filter((item) =>
this.editor.isDescendantOf(item, record) && item !== excludedRecord &&
(!excludedRecord || !this.editor.isDescendantOf(item, excludedRecord)) &&
this.editor.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.editor.isItemVisible(record)) return record;
if (record.kind === "phrase" || this.editor.activeMapRoot) return null;
let parent = record.parentSubmap;
while (parent) {
if (this.editor.isItemVisible(parent)) return parent;
parent = parent.parentSubmap;
}
return null;
}
isEffectiveItemVisible(record) {
if (!this.editor.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) {
return record.kind === "phrase" ?
(this.isEffectiveItemVisible(record) ? record : null) : 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();
}
canvasPoint(event) {
const rect = this.editor.canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left + this.editor.canvas.scrollLeft) / this.zoomFactor,
y: (event.clientY - rect.top + this.editor.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)
};
}
}
@@ -117,6 +117,10 @@ export class CmapHistory {
return this.redoStack.length > 0;
}
hasPendingCommit() {
return this.timer !== null;
}
get undoCount() {
return this.undoStack.length;
}
@@ -53,7 +53,6 @@ export class CmapInteractionController {
}
groupItems = Array.from(new Set(expanded));
}
groupItems = this.includeLinkedMapItems(groupItems);
record.moveMembership = {
parent: record.parentSubmap,
parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null,
@@ -68,23 +67,6 @@ export class CmapInteractionController {
return record.moveMembership;
}
includeLinkedMapItems(groupItems) {
const group = new Set(groupItems);
const pending = [...groupItems];
while (pending.length) {
const current = pending.pop();
for (const connector of this.connectors) {
if (connector.source !== current && connector.target !== current) continue;
const other = connector.source === current ? connector.target : connector.source;
const include = other.kind === "phrase" || Boolean(other.cmapSlug);
if (!include || group.has(other)) continue;
group.add(other);
pending.push(other);
}
}
return Array.from(group);
}
moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
const deltaX = x - moveMembership.startX;
const deltaY = y - moveMembership.startY;
@@ -0,0 +1,87 @@
"use strict";
/** Own map-context layouts and apply them to the rendered editor records. */
export class CmapLayoutController {
constructor(editor) {
this.editor = editor;
}
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.editor.mapContextKey();
for (const record of this.editor.items.filter((item) => this.editor.isEffectiveItemVisible(item))) {
record.layouts[context] = this.itemLayout(record);
}
}
applyCurrentContextLayout() {
const context = this.editor.mapContextKey();
const visible = this.editor.items.filter((record) => this.editor.isEffectiveItemVisible(record));
let offset = { x: 0, y: 0 };
if (this.editor.activeMapRoot && !this.editor.activeMapRoot.layouts[context]) {
const rootLayout = this.itemLayout(this.editor.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.editor.itemHtml(record),
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
textColor: record.textColor
});
record.node.redraw();
}
this.editor.refreshSubmapVisibility();
this.editor.refreshConnectorGeometry();
window.requestAnimationFrame(() => {
if (!this.editor.destroyed && this.editor.canvas.isConnected) {
this.editor.refreshSubmapVisibility();
this.editor.refreshConnectorGeometry();
}
});
}
}
@@ -0,0 +1,147 @@
"use strict";
import { ConceptMapConnector } from "../model/concept-map.js";
import { debug, numberOr } from "../cmap-utils.js";
/** Own map-local connector mutations and linking-phrase membership. */
export class CmapRelationController {
constructor(editor) {
this.editor = editor;
}
get items() { return this.editor.items; }
get connectors() { return this.editor.connectors; }
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
const a = this.editor.itemCenter(source);
const b = this.editor.itemCenter(target);
const parentSubmap = this.commonSubmapParent([source, target]);
const phrase = this.editor.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.editor.addConnector(source, phrase, false);
this.editor.addConnector(phrase, target, true);
this.reconcilePhraseMembership(phrase);
this.editor.selectItem(phrase);
if (editImmediately) this.editor.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;
}
}
addConnector(source, target, hasArrow = true, options = {}) {
const sourceCenter = this.editor.itemCenter(source);
const targetCenter = this.editor.itemCenter(target);
const link = this.editor.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.editor.nextConnectorId,
link,
source,
target,
visualSource: source,
visualTarget: target,
hasArrow,
lineColor: options.lineColor || "#333",
lineWidth: numberOr(Number(options.lineWidth), 2)
};
this.editor.nextConnectorId = Math.max(this.editor.nextConnectorId, record.id + 1);
this.editor.documents.attachConnectorToModel(record);
this.connectors.push(record);
link.onRendered((_renderedLink, element) => this.editor.decorateConnector(record, element));
link.onConnectionChange((_changedLink, type, node) =>
this.editor.handleConnectorConnectionChange(record, type, node));
link.visible(this.editor.isItemVisible(source) && this.editor.isItemVisible(target));
this.editor.scheduleHistoryCommit();
return record;
}
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";
if (!endpoint || endpoint === connector[otherType]) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
this.editor.applyConnectorVisualEndpoints(connector,
this.editor.connectorEndpoint(connector.source),
this.editor.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.editor.refreshSubmapVisibility();
this.editor.refreshConnectorGeometry();
debug("connector endpoint changed", {
connectorId: connector.id,
type,
previousItemId: previous ? previous.id : null,
itemId: endpoint.id
});
this.editor.scheduleHistoryCommit();
return true;
}
}
@@ -0,0 +1,94 @@
"use strict";
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeConceptName(value) {
return String(value || "").trim().toLocaleLowerCase();
}
/** Index lightweight concept placements across stored CMaps. */
export class CmapReferenceIndex {
constructor() {
this.byConceptId = new Map();
this.byName = new Map();
this.byPage = new Map();
}
clear() {
this.byConceptId.clear();
this.byName.clear();
this.byPage.clear();
}
load(placements = [], conceptMaps = [], canonicalPageReference = (value) => value,
normalizeName = normalizeConceptName) {
this.clear();
const mapTitles = new Map(conceptMaps
.filter((map) => map && map.slug)
.map((map) => [map.slug, map.title || map.slug]));
const pageByConcept = new Map();
for (const placement of placements) {
if (!placement?.conceptId || !placement.pageSlug) continue;
pageByConcept.set(placement.conceptId,
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
}
for (const placement of placements) {
if (!placement?.conceptId || !placement.cmapSlug) continue;
const conceptId = String(placement.conceptId);
const slug = String(placement.cmapSlug);
const nameKey = normalizeName(placement.label);
if (nameKey) this.byName.set(nameKey, conceptId);
if (!this.byConceptId.has(conceptId)) this.byConceptId.set(conceptId, new Map());
const maps = this.byConceptId.get(conceptId);
const existing = maps.get(slug);
maps.set(slug, {
slug,
title: mapTitles.get(slug) || placement.cmapTitle || slug,
count: (existing?.count || 0) + (Number(placement.count) || 0)
});
const pageKey = pageByConcept.get(conceptId);
if (!pageKey) continue;
if (!this.byPage.has(pageKey)) this.byPage.set(pageKey, new Map());
const pageConcepts = this.byPage.get(pageKey);
if (!pageConcepts.has(conceptId)) {
pageConcepts.set(conceptId, {
conceptId,
label: placement.label || "Concept",
count: 0,
maps: new Map()
});
}
const concept = pageConcepts.get(conceptId);
const count = Number(placement.count) || 0;
concept.count += count;
const pageMap = concept.maps.get(slug);
concept.maps.set(slug, {
slug,
title: mapTitles.get(slug) || placement.cmapTitle || slug,
count: count + (pageMap?.count || 0)
});
}
return this;
}
mapsFor(conceptId) {
return Array.from(this.byConceptId.get(String(conceptId))?.values() || [])
.map(copy);
}
countFor(conceptId) {
return this.mapsFor(conceptId).reduce((sum, map) => sum + map.count, 0);
}
conceptIdForName(label) {
return this.byName.get(normalizeConceptName(label)) || null;
}
pageConcepts(pageReference) {
return Array.from(this.byPage.get(String(pageReference).toLocaleLowerCase())?.values() || [])
.map((concept) => ({ ...copy(concept), maps: new Map(concept.maps) }));
}
}
@@ -62,6 +62,7 @@ export class StoredConceptMap {
this._createdAt = record.createdAt || null;
this._updatedAt = record.updatedAt || null;
this._author = record.author || null;
this._renderedSvg = typeof record.renderedSvg === "string" ? record.renderedSvg : "";
this._model = model;
}
@@ -73,6 +74,7 @@ export class StoredConceptMap {
get updatedAt() { return this._updatedAt; }
get author() { return this._author; }
get model() { return this._model; }
get renderedSvg() { return this._renderedSvg; }
/** Return a detached storage document for comparisons and interchange. */
toDocument() {
@@ -115,8 +117,12 @@ export class CmapRepository {
}
/** Create a persisted CMap from a domain model. */
async create(title, model, slug = null) {
const body = { title: String(title).trim(), document: modelDocument(model) };
async create(title, model, slug = null, saveInformation = {}) {
const body = {
title: String(title).trim(),
document: modelDocument(model),
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
};
if (slug) body.slug = String(slug);
const record = await this.api("/api/cmaps", {
method: "POST",
@@ -136,7 +142,8 @@ export class CmapRepository {
summary: saveInformation.summary || "",
saveKind: saveInformation.saveKind || "manual",
snapshot: Boolean(saveInformation.snapshot),
document: modelDocument(model)
document: modelDocument(model),
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
};
const record = await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "PUT",
@@ -128,6 +128,7 @@ export class ConceptMap {
constructor(values = {}) {
this.schemaVersion = Number(values.schemaVersion) || 2;
this.metadata = {
namespace: String(values.metadata?.namespace || "").trim(),
tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [],
summary: String(values.metadata?.summary || ""),
explanationPageSlug: String(values.metadata?.explanationPageSlug || "")
@@ -242,6 +243,67 @@ export class CmapModel {
return copy(this.conceptMap.derivedView);
}
/** Return connectors that cross the active submap boundary. */
boundaryReferencesFor(activeRootId) {
if (activeRootId === null || activeRootId === undefined) return [];
const rootId = Number(activeRootId);
const itemInside = (item) => {
if (!item) return false;
if (item.id === rootId) return true;
let parentId = item.parentSubmapId;
while (parentId !== null && parentId !== undefined) {
if (Number(parentId) === rootId) return true;
parentId = this.conceptMap.item(parentId)?.parentSubmapId;
}
return false;
};
const conceptFor = (item) => item.kind === "phrase" ? null :
this.repository.concept(item.conceptId);
const placement = (item) => ({
id: item.id,
x: Number(item.x) || 0,
y: Number(item.y) || 0,
width: Number(item.width) || 0,
height: Number(item.height) || 0
});
const itemForSide = (item, excludedItem, inside) => {
if (item.kind !== "phrase") return item;
const adjacent = this.conceptMap.connectors()
.filter((candidate) => candidate.sourceId === item.id || candidate.targetId === item.id)
.map((candidate) => this.conceptMap.item(
candidate.sourceId === item.id ? candidate.targetId : candidate.sourceId))
.find((candidate) => candidate && candidate !== excludedItem &&
candidate.kind !== "phrase" && itemInside(candidate) === inside);
return adjacent || item;
};
const result = [];
for (const connector of this.conceptMap.connectors()) {
const source = this.conceptMap.item(connector.sourceId);
const target = this.conceptMap.item(connector.targetId);
if (!source || !target) continue;
const sourceInside = itemInside(source);
const targetInside = itemInside(target);
if (sourceInside === targetInside) continue;
const rawInside = sourceInside ? source : target;
const rawOutside = sourceInside ? target : source;
const inside = itemForSide(rawInside, rawOutside, true);
const outside = itemForSide(rawOutside, rawInside, false);
if (!itemInside(inside)) continue;
result.push({
connector: connector.toDocument(),
inside: placement(inside),
outside: placement(outside),
sourceInside,
conceptId: conceptFor(outside)?.id || null,
label: conceptFor(outside)?.label || outside.label || "External concept",
linkingPhraseLabel: rawInside.kind === "phrase" ? rawInside.label :
(rawOutside.kind === "phrase" ? rawOutside.label : null),
targetMaps: []
});
}
return result;
}
/** Return a new model with changed map metadata and unchanged contents. */
withMetadata(metadata) {
const document = this.toDocument();
@@ -0,0 +1,113 @@
"use strict";
/** Render references from the active diagram to concepts linked from other CMaps. */
export class CmapBoundaryReferenceView {
constructor({ canvas, zoomFactor, mapTitle, surfaceElement, itemCenter, onOpenReference }) {
this.canvas = canvas;
this.zoomFactor = zoomFactor;
this.mapTitle = mapTitle || "";
this.surfaceElement = surfaceElement;
this.itemCenter = itemCenter;
this.onOpenReference = onOpenReference;
this.layer = null;
}
clear() {
if (this.layer) this.layer.remove();
this.layer = null;
}
render(references = []) {
this.clear();
if (!references.length) return;
const surface = this.surfaceElement();
if (!surface) 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", "#808080");
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.layer = layer;
const factor = this.zoomFactor();
const viewLeft = this.canvas.scrollLeft / factor;
const viewTop = this.canvas.scrollTop / factor;
const viewWidth = this.canvas.clientWidth / factor;
const viewHeight = this.canvas.clientHeight / factor;
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 reference of references) {
const inside = this.itemCenter(reference.insideRecord);
const side = inside.x < viewLeft + (viewWidth / 2) ? "left" : "right";
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
const y = reserveY(side, inside.y - 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`;
if (reference.linkingPhraseLabel) {
button.append(document.createTextNode(reference.linkingPhraseLabel));
button.append(document.createTextNode(" →"));
button.append(document.createElement("br"));
}
button.append(document.createTextNode(reference.label));
if (this.mapTitle) {
button.append(document.createElement("br"));
const mapLabel = document.createElement("small");
const mapName = document.createElement("em");
mapName.textContent = `(${this.mapTitle})`;
mapLabel.append(mapName);
button.append(mapLabel);
}
button.title = "Open the concept map containing this connection";
button.addEventListener("click", () => {
if (this.onOpenReference) this.onOpenReference(reference);
});
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 = reference.sourceInside ? inside.x : boundaryX;
const startY = reference.sourceInside ? inside.y : boundaryY;
const endX = reference.sourceInside ? boundaryX : inside.x;
const endY = reference.sourceInside ? boundaryY : inside.y;
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#808080");
path.setAttribute("stroke-width", String(reference.connector.lineWidth || 2));
if (reference.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
svg.append(path);
}
}
destroy() {
this.clear();
}
}
@@ -0,0 +1,102 @@
"use strict";
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
/**
* Convert the rendered CMap surface into a standalone SVG snapshot.
*
* HTML concept cards remain inside an XHTML foreignObject while connector SVG
* elements stay vector based. The renderer reads the current DOM only; it has
* no knowledge of the editor model or persistence layer.
*/
export class CmapSnapshotSvgRenderer {
constructor({ stylesheetFilter = (sheet) => Boolean(sheet.href?.includes("cmap.css")) } = {}) {
this.stylesheetFilter = stylesheetFilter;
}
render(surface, { width = null, height = null, includeStyles = true } = {}) {
if (!surface || typeof surface.cloneNode !== "function") {
throw new TypeError("A rendered CMap surface is required");
}
const dimensions = this.dimensions(surface, width, height);
const clone = surface.cloneNode(true);
this.removeInteractiveElements(clone);
clone.removeAttribute("id");
clone.style.zoom = "1";
clone.style.transform = "none";
clone.style.width = `${dimensions.width}px`;
clone.style.height = `${dimensions.height}px`;
const svg = document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("xmlns", SVG_NAMESPACE);
svg.setAttribute("xmlns:xhtml", XHTML_NAMESPACE);
svg.setAttribute("version", "1.1");
svg.setAttribute("width", String(dimensions.width));
svg.setAttribute("height", String(dimensions.height));
svg.setAttribute("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`);
svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
if (includeStyles) {
const style = document.createElementNS(SVG_NAMESPACE, "style");
style.textContent = this.stylesheetText();
svg.append(style);
}
const foreignObject = document.createElementNS(SVG_NAMESPACE, "foreignObject");
foreignObject.setAttribute("x", "0");
foreignObject.setAttribute("y", "0");
foreignObject.setAttribute("width", String(dimensions.width));
foreignObject.setAttribute("height", String(dimensions.height));
clone.setAttribute("xmlns", XHTML_NAMESPACE);
foreignObject.append(clone);
svg.append(foreignObject);
return new XMLSerializer().serializeToString(svg);
}
dimensions(surface, requestedWidth, requestedHeight) {
const width = Number(requestedWidth) || Math.ceil(Math.max(
surface.scrollWidth || 0,
surface.getBoundingClientRect?.().width || 0,
...Array.from(surface.children || []).map((child) =>
(Number.parseFloat(child.style.left) || 0) + (child.offsetWidth || 0))
));
const height = Number(requestedHeight) || Math.ceil(Math.max(
surface.scrollHeight || 0,
surface.getBoundingClientRect?.().height || 0,
...Array.from(surface.children || []).map((child) =>
(Number.parseFloat(child.style.top) || 0) + (child.offsetHeight || 0))
));
return {
width: Math.max(1, width || 1),
height: Math.max(1, height || 1)
};
}
removeInteractiveElements(root) {
for (const element of root.querySelectorAll(
".rw-cmap-handle, .rw-cmap-resize-handle, .rw-cmap-relation-handle, " +
".rw-cmap-edit-handle, .rw-cmap-submap-toggle, .rw-cmap-submap-open")) {
element.remove();
}
for (const element of root.querySelectorAll("[aria-selected], [data-rw-cmap-bound]") ) {
element.removeAttribute("aria-selected");
element.removeAttribute("data-rw-cmap-bound");
}
}
stylesheetText() {
if (typeof document === "undefined") return "";
const rules = [];
for (const sheet of Array.from(document.styleSheets || [])) {
if (!this.stylesheetFilter(sheet)) continue;
try {
rules.push(...Array.from(sheet.cssRules || []).map((rule) => rule.cssText));
} catch (_error) {
// Cross-origin stylesheets cannot be inspected and are skipped.
}
}
return rules.join("\n");
}
}
+25 -5
View File
@@ -1,4 +1,4 @@
import "/cmap/cmap-racket-wiki.js";
import "/js/cmap/cmap-racket-wiki.js";
import {
newPageReference,
@@ -13,6 +13,7 @@ import {
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
protectCamelCaseWikiWords,
extractCmapEmbeds,
restoreCmapEmbeds
} from "./wiki/markdown.js";
@@ -24,7 +25,7 @@ import { MailAdmin } from "./wiki/admin/mail-admin.js";
import { loadAdminOverview } from "./wiki/admin/overview.js";
import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
import { UserAdmin } from "./wiki/admin/user-admin.js";
import { CmapWorkspace } from "./wiki/cmap/workspace.js";
import { CmapWorkspaceController } from "./wiki/cmap/cmap-workspace-controller.js";
import { ComboBox } from "./widgets/combobox.js";
(() => {
@@ -47,6 +48,7 @@ import { ComboBox } from "./widgets/combobox.js";
currentPage: null,
editingNew: false,
newPageSlug: null,
newPageSuggestedTitle: "",
previousView: "page-view",
translations: {},
translationPage: "wiki-translations",
@@ -77,7 +79,7 @@ import { ComboBox } from "./widgets/combobox.js";
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
const cmapWorkspace = new CmapWorkspace(
const cmapWorkspace = new CmapWorkspaceController(
state,
api,
tr,
@@ -434,6 +436,16 @@ import { ComboBox } from "./widgets/combobox.js";
easyMDE.codemirror.refresh();
}
function protectCamelCaseInPage() {
if (!easyMDE) return;
const source = easyMDE.value();
const protectedMarkdown = protectCamelCaseWikiWords(source);
if (protectedMarkdown === source) return;
easyMDE.value(protectedMarkdown);
renderEditorToc();
$("save-status").textContent = tr("camelcase-protected", "CamelCase links protected; save the page to keep the changes.");
}
/**
* goal : Configure the single EasyMDE instance used for page editing.
* pre : Vendor scripts and the editor textarea are loaded.
@@ -504,6 +516,7 @@ import { ComboBox } from "./widgets/combobox.js";
console.error(error);
});
}, "share-2", tr("link-concept-map", "Link to CMap")),
toolbarButton("protect-camelcase", protectCamelCaseInPage, "ban", tr("protect-camelcase", "Protect CamelCase")),
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", tr("upload-image", "Upload image")),
toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")),
toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")),
@@ -976,12 +989,13 @@ import { ComboBox } from "./widgets/combobox.js";
* pre : requestedSlug is null or a compact root/namespaced page reference.
* post : Namespace, title, template and Markdown controls are initialized for creation.
*/
function beginNewPage(requestedSlug = null) {
function beginNewPage(requestedSlug = null, suggestedTitle = "") {
state.editingNew = true;
state.currentPage = null;
state.newPageSlug = requestedSlug;
const translationPage = requestedSlug && requestedSlug === state.translationPage;
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
$("editor-title").value = translationPage ? tr("translations", "Translations") :
(suggestedTitle || "");
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
$("editor-namespace").disabled = Boolean(translationPage);
$("editor-tags").value = "";
@@ -1580,6 +1594,12 @@ import { ComboBox } from "./widgets/combobox.js";
}
function showMissingEditablePage(slug) {
const suggestedTitle = state.newPageSuggestedTitle;
state.newPageSuggestedTitle = "";
if (suggestedTitle) {
beginNewPage(slug, suggestedTitle);
return;
}
state.currentPage = null;
state.editingNew = false;
state.newPageSlug = slug;
@@ -0,0 +1,66 @@
"use strict";
import { escapeHtml } from "../../cmap/cmap-utils.js";
/** Render wiki-specific HTML content for CMap editor items. */
export class CmapEditorPresentation {
constructor({ state, translate, canonicalPageReference, referenceIndex }) {
this.state = state;
this.translate = translate;
this.canonicalPageReference = canonicalPageReference;
this.referenceIndex = referenceIndex;
}
renderItem(record) {
const safeLabel = escapeHtml(record.label || "");
const safeSynopsis = escapeHtml(record.synopsis || "");
const safeKind = escapeHtml(record.kind || "concept");
const safeImageSource = escapeHtml(record.imageSource || "");
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
.join("");
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
const personNames = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => String(tag.value));
const peopleLabel = personNames.length ?
this.translate("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
const personTags = personNames.length ?
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">&#128100;</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
const usageCount = record.conceptId && record.kind !== "phrase" ?
this.conceptUsage(record) : null;
const usageLabel = usageCount === null ? "" :
this.translate("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(usageCount));
const usage = usageCount === null ? "" :
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
this.state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(this.translate("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
const externalButton = record.externalUrl ?
`<button type="button" class="rw-cmap-open-external" aria-label="${escapeHtml(this.translate("open-external-web-page", "Open external web page"))}">↗</button>` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
}
conceptUsage(record) {
const maps = this.referenceIndex.byConceptId.get(record.conceptId);
const storedTotal = maps ? Array.from(maps.values()).reduce((sum, entry) =>
sum + (typeof entry === "number" ? entry : Number(entry.count) || 0), 0) : 0;
const editor = this.state.cmapPrototype?.editor;
if (!editor || !editor.containsItemRecord(record)) {
return Math.max(1, storedTotal || Number(record.usageCount) || 1);
}
const currentSlug = this.state.currentConceptMapSource?.slug ||
this.state.currentConceptMap?.slug || null;
const entry = currentSlug && maps ? maps.get(currentSlug) : null;
const current = typeof entry === "number" ? entry : Number(entry?.count) || 0;
return Math.max(1, storedTotal - current + (Number(record.usageCount) || 1));
}
}
+58
View File
@@ -0,0 +1,58 @@
"use strict";
/** Render stored CMap snapshots as linked images inside wiki pages. */
export class CmapEmbedView {
constructor({ repository, conceptMaps, resolveReference, route, translate }) {
this.repository = repository;
this.conceptMaps = conceptMaps;
this.resolveReference = resolveReference;
this.route = route;
this.translate = translate;
}
async render(root) {
const embeds = Array.from(root.querySelectorAll(
".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) await this.renderOne(embed);
}
async renderOne(embed) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = this.resolveReference(
embed.dataset.cmapReference || "", this.conceptMaps());
if (!conceptMap) {
this.showError(embed, this.translate("concept-map-not-found", "CMap not found"));
return;
}
try {
const stored = await this.repository.load(conceptMap.slug);
if (!stored.renderedSvg) {
throw new Error("This embedded CMap has no rendered image yet.");
}
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
const link = document.createElement("a");
link.href = this.route(conceptMap.slug);
link.className = "rw-cmap-embed-link";
link.title = this.translate("embedded-concept-map-help", "Open this CMap.");
const image = document.createElement("img");
image.className = "rw-cmap-embed-image";
image.alt = conceptMap.title;
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stored.renderedSvg)}`;
link.append(image);
embed.append(link);
embed.dataset.cmapHydrated = "ready";
} catch (error) {
this.showError(embed, error.message);
}
}
showError(embed, message) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const node = document.createElement("p");
node.className = "error";
node.textContent = message;
embed.append(node);
}
}
@@ -1,17 +1,20 @@
import {
newPageReference
newPageReference,
pageReference,
splitPageReference
} from "../reference.js";
import { cmapRoute, pageRoute } from "../routes.js";
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
import { CmapModel } from "../../../cmap/model/concept-map.js";
import { CmapAppearanceRepository } from "../../../cmap/model/appearance-repository.js";
import { CmapRepository } from "../../../cmap/model/cmap-repository.js";
import { CmapJsonExporter } from "../../../cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../../../cmap/model/json-importer.js";
import { CmapMarkdownExporter } from "../../../cmap/model/markdown-exporter.js";
import { CmapSettingsRepository } from "../../../cmap/model/settings-repository.js";
import { PeopleRepository } from "../../../cmap/model/people-repository.js";
import { CmapAppearanceEditor } from "../../../cmap/view/appearance-editor.js";
import { CmapModel } from "../../cmap/model/concept-map.js";
import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js";
import { CmapRepository } from "../../cmap/model/cmap-repository.js";
import { CmapJsonExporter } from "../../cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../../cmap/model/json-importer.js";
import { CmapMarkdownExporter } from "../../cmap/model/markdown-exporter.js";
import { CmapSettingsRepository } from "../../cmap/model/settings-repository.js";
import { CmapReferenceIndex } from "../../cmap/model/cmap-reference-index.js";
import { PeopleRepository } from "../../cmap/model/people-repository.js";
import { CmapAppearanceEditor } from "../../cmap/view/appearance-editor.js";
import { ComboBox } from "../../widgets/combobox.js";
import { DescriptionPreview } from "../../widgets/description-preview.js";
import { PopupMenu } from "../../widgets/popup-menu.js";
@@ -22,6 +25,8 @@ import { CmapHistoryDialog } from "./dialogs/history-dialog.js";
import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js";
import { CmapPeopleDialog } from "./dialogs/people-dialog.js";
import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
import { CmapEmbedView } from "./cmap-embed-view.js";
import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
/**
* Own the complete browser-side CMap workspace.
@@ -30,7 +35,7 @@ import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
* persistence, history and browser event handling. The wiki application
* supplies only the concrete services needed to cross the module boundary.
*/
export class CmapWorkspace {
export class CmapWorkspaceController {
constructor(
state,
api,
@@ -59,6 +64,20 @@ export class CmapWorkspace {
const cmapRepository = new CmapRepository(api);
const appearanceRepository = new CmapAppearanceRepository(api);
const settingsRepository = new CmapSettingsRepository(api);
const cmapReferenceIndex = new CmapReferenceIndex();
const presentation = new CmapEditorPresentation({
state,
translate: tr,
canonicalPageReference,
referenceIndex: cmapReferenceIndex
});
const embedView = new CmapEmbedView({
repository: cmapRepository,
conceptMaps: () => state.conceptMaps,
resolveReference: cmapMentionTarget,
route: cmapRoute,
translate: tr
});
const peopleRepository = new PeopleRepository(api);
let appearanceEditor = null;
let conceptDialog = null;
@@ -110,92 +129,10 @@ export class CmapWorkspace {
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
cmapEmbedHydrationTimer = window.setTimeout(() => {
cmapEmbedHydrationTimer = null;
hydrateCmapEmbeds(document).catch((error) => console.error(error));
embedView.render(document).catch((error) => console.error(error));
}, 0);
}
/** Render all pending CMap embeds below root as read-only CMap editors. */
async function hydrateCmapEmbeds(root) {
const embeds = Array.from(root.querySelectorAll(
".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = cmapMentionTarget(
embed.dataset.cmapReference || "",
state.conceptMaps);
if (!conceptMap) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = tr("concept-map-not-found", "CMap not found");
embed.append(message);
continue;
}
try {
const stored = await cmapRepository.load(conceptMap.slug);
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
const header = document.createElement("header");
const title = document.createElement("strong");
title.textContent = conceptMap.title;
const hint = document.createElement("span");
hint.textContent = tr(
"embedded-concept-map-help", "Double-click to open this CMap.");
header.append(title, hint);
const viewport = document.createElement("div");
viewport.className = "rw-cmap-embed-viewport";
const canvas = document.createElement("div");
canvas.className =
"cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
viewport.append(canvas);
embed.append(header, viewport);
const editor = window.RacketWikiCmap.createEditor(canvas, {
renderItem: (record) => cmapNodeHtml(record),
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
});
editor.loadModel(stored.model);
const visible = editor.visibleItemRecords();
if (visible.length) {
const left = Math.min(...visible.map((item) =>
Number(item.node.attr("x")))) - 24;
const top = Math.min(...visible.map((item) =>
Number(item.node.attr("y")))) - 24;
const right = Math.max(...visible.map((item) =>
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
const bottom = Math.max(...visible.map((item) =>
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
const availableWidth = Math.max(320, embed.clientWidth - 2);
const scale = Math.min(
1,
availableWidth / Math.max(1, right - left),
520 / Math.max(1, bottom - top));
editor.zoomFactor = scale;
editor.map.zoom(scale);
viewport.style.height =
`${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
window.requestAnimationFrame(() => {
viewport.scrollLeft = Math.max(0, left * scale);
viewport.scrollTop = Math.max(0, top * scale);
});
}
embed.dataset.cmapHydrated = "ready";
embed.addEventListener("dblclick", () => {
navigateToHash(cmapRoute(conceptMap.slug))
.catch((error) => console.error(error));
});
} catch (error) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = error.message;
embed.append(message);
}
}
}
function startCmapSlug() {
return settingsRepository.startCmapSlug;
}
@@ -222,83 +159,14 @@ export class CmapWorkspace {
return state.cmapPrototype;
}
/**
* goal : Render one concept card inside an ionstage/cmap node.
* pre : record contains label, synopsis and kind.
* result : Safe HTML used as the node's content.
*/
function cmapNodeHtml(record) {
const safeLabel = escapeHtml(record.label || "");
const safeSynopsis = escapeHtml(record.synopsis || "");
const safeKind = escapeHtml(record.kind || "concept");
const safeImageSource = escapeHtml(record.imageSource || "");
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
.join("");
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
const personNames = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => String(tag.value));
const peopleLabel = personNames.length ?
tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
const personTags = personNames.length ?
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">&#128100;</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
const usageCount = record.conceptId && record.kind !== "phrase" ?
effectiveCmapConceptUsage(record) : null;
const usageLabel = usageCount === null ? "" :
tr("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(usageCount));
const usage = usageCount === null ? "" :
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(tr("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
const externalButton = record.externalUrl ?
`<button type="button" class="rw-cmap-open-external" title="${escapeHtml(tr("open-external-web-page", "Open external web page"))}" aria-label="${escapeHtml(tr("open-external-web-page", "Open external web page"))}">↗</button>` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
return presentation.renderItem(record);
}
function effectiveCmapConceptUsage(record) {
const byMap = state.cmapConceptUsage.get(record.conceptId);
const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0;
const prototype = cmapPrototypeState();
const isActiveEditorRecord = Boolean(
prototype.editor && prototype.editor.containsItemRecord(record));
if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1);
const currentSlug = state.currentConceptMapSource?.slug ||
state.currentConceptMap?.slug || null;
const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0;
return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1));
return presentation.conceptUsage(record);
}
function hideCmapDescriptionTooltip() {
cmapDescriptionPreview.hide();
}
async function showCmapDescriptionTooltip(button) {
if (!button.classList.contains("is-filled")) return;
const itemElement = button.closest("[data-rw-cmap-item-id]");
const prototype = cmapPrototypeState();
const record = itemElement && prototype.editor ?
prototype.editor.itemRecord(itemElement.dataset.rwCmapItemId) : null;
if (!record || !record.descriptionPageSlug) return;
const reference = canonicalPageReference(record.descriptionPageSlug);
await cmapDescriptionPreview.show(button, reference, tr("loading", "Loading…"));
}
/**
* goal : Derive a compact synopsis from the currently opened wiki page.
* pre : state.currentPage may be #f/null when no page is open.
* result : Plain text of at most about 150 characters.
*/
function currentPageSynopsis() {
if (!state.currentPage) return "";
const container = document.createElement("div");
@@ -307,12 +175,6 @@ export class CmapWorkspace {
return text.length > 150 ? `${text.slice(0, 147)}` : text;
}
/**
* goal : Add a concept-like item to the active CMap prototype.
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
* post : The item is draggable, selectable, resizable and linkable.
* result : The item record created by the interaction layer.
*/
function addCmapPrototypeNode(options = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
@@ -350,10 +212,36 @@ export class CmapWorkspace {
function openLinkedCmap(record) {
if (!record || !record.cmapSlug) return false;
rememberActiveCmapContext();
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
return true;
}
function rememberActiveCmapContext() {
const prototype = cmapPrototypeState();
const editor = prototype.editor;
const mapSlug = state.currentConceptMap?.slug;
const root = editor?.activeMapRoot;
if (!mapSlug || !root) return;
const context = {
mapSlug,
rootItemId: Number(root.id)
};
history.replaceState({ ...history.state, cmapContext: context }, "", location.href);
}
async function restoreActiveCmapContext(slug) {
const context = history.state?.cmapContext;
if (!context || context.mapSlug !== slug) return false;
const prototype = cmapPrototypeState();
const root = prototype.editor?.itemRecord(context.rootItemId);
if (root && root.kind === "submap" && root.separateMap) {
prototype.editor.openSubmapMap(root);
}
history.replaceState({ ...history.state, cmapContext: null }, "", location.href);
return true;
}
function openParentCmap() {
const source = state.currentConceptMapSource;
if (source && source.slug) {
@@ -375,7 +263,10 @@ export class CmapWorkspace {
function currentConceptDialogResources() {
const prototype = cmapPrototypeState();
const namespaceModel = state.currentConceptMapSource?.model ||
state.currentConceptMap?.model;
return {
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
pages: state.pages,
conceptMaps: state.conceptMaps,
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
@@ -745,7 +636,7 @@ export class CmapWorkspace {
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
cmapStylesheet: Array.from(document.styleSheets)
.map((sheet) => sheet.href)
.find((href) => href && href.includes("/cmap/cmap.css")) || null
.find((href) => href && href.includes("/js/cmap/cmap.css")) || null
});
if (!window.RacketWikiCmap) {
@@ -759,9 +650,23 @@ export class CmapWorkspace {
const prototype = cmapPrototypeState();
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
renderItem: (record) => cmapNodeHtml(record),
boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
state.currentConceptMap?.title || "",
onOpenPage: (record) => {
if (record.pageSlug) {
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
const namespace = state.currentConceptMapSource?.model?.metadata()?.namespace ||
state.currentConceptMap?.model?.metadata()?.namespace || "";
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null;
const target = namespace && page?.namespace === "cmap" ?
pageReference(namespace, page.slug) : record.pageSlug;
if (isDescriptionReference && target !== record.pageSlug) {
record.pageSlug = target;
}
if (!state.pages.some((pageRecord) => pageRecord.slug === target)) {
state.newPageSuggestedTitle = record.label || "";
}
navigateToHash(pageRoute(target)).catch((error) => console.error(error));
}
},
onOpenCmap: (record) => {
@@ -934,48 +839,14 @@ export class CmapWorkspace {
})
]);
state.conceptMaps = conceptMaps;
state.cmapConceptUsage = new Map();
state.cmapConceptIdsByName = new Map();
state.cmapPageConcepts = new Map();
const pageByConcept = new Map();
for (const placement of placements) {
if (!placement?.conceptId || !placement.pageSlug) continue;
pageByConcept.set(
placement.conceptId,
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
}
for (const placement of placements) {
if (!placement || !placement.conceptId || !placement.cmapSlug) continue;
const nameKey = normalizeCmapConceptName(placement.label);
if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId);
if (!state.cmapConceptUsage.has(placement.conceptId)) {
state.cmapConceptUsage.set(placement.conceptId, new Map());
}
state.cmapConceptUsage.get(placement.conceptId)
.set(placement.cmapSlug, Number(placement.count) || 0);
const pageKey = pageByConcept.get(placement.conceptId);
if (!pageKey) continue;
if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map());
const pageConcepts = state.cmapPageConcepts.get(pageKey);
if (!pageConcepts.has(placement.conceptId)) {
pageConcepts.set(placement.conceptId, {
conceptId: placement.conceptId,
label: placement.label || tr("concept", "Concept"),
count: 0,
maps: new Map()
});
}
const concept = pageConcepts.get(placement.conceptId);
const count = Number(placement.count) || 0;
concept.count += count;
const existingMap = concept.maps.get(placement.cmapSlug);
concept.maps.set(placement.cmapSlug, {
slug: placement.cmapSlug,
title: placement.cmapTitle || placement.cmapSlug,
count: count + (existingMap?.count || 0)
});
}
cmapReferenceIndex.load(
placements,
conceptMaps,
canonicalPageReference,
normalizeCmapConceptName);
state.cmapConceptUsage = cmapReferenceIndex.byConceptId;
state.cmapConceptIdsByName = cmapReferenceIndex.byName;
state.cmapPageConcepts = cmapReferenceIndex.byPage;
renderConceptMapSelector();
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
@@ -989,6 +860,17 @@ export class CmapWorkspace {
return JSON.stringify(prototype.editor.toDocument());
}
function currentCmapRenderedSvg() {
const editor = cmapPrototypeState().editor;
if (!editor || typeof editor.snapshotSvg !== "function") return "";
try {
return editor.snapshotSvg();
} catch (error) {
console.warn("The CMap SVG snapshot could not be created.", error);
return "";
}
}
function currentCmapStorageMap() {
return state.currentConceptMapSource || state.currentConceptMap;
}
@@ -1000,8 +882,14 @@ export class CmapWorkspace {
function cmapHasUnsavedChanges() {
if ($("cmap-view").classList.contains("hidden")) return false;
const currentSnapshot = currentCmapSnapshot();
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
currentSnapshot !== state.cmapSavedSnapshot;
if (currentSnapshot === null || state.cmapSavedSnapshot === null) return false;
if (currentSnapshot === state.cmapSavedSnapshot) return false;
const editor = cmapPrototypeState().editor;
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
state.cmapSavedSnapshot = currentSnapshot;
return false;
}
return true;
}
function cancelCmapAutosave() {
@@ -1249,6 +1137,7 @@ export class CmapWorkspace {
if (currentModel?.derivedView()) {
const metadata = currentModel.metadata();
return {
namespace: metadata.namespace || "",
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
summary: metadata.summary || "",
explanationPageSlug: metadata.explanationPageSlug || ""
@@ -1269,6 +1158,13 @@ export class CmapWorkspace {
const conceptMap = state.currentConceptMap;
const editor = cmapPrototypeState().editor;
if (!conceptMap || !editor) return false;
const legacyExplanation = `cmap:${conceptMap.slug}`;
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
metadata = {
...metadata,
explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}`
};
}
if (conceptMap.model.derivedView()) {
const updatedModel = conceptMap.model.withMetadata(metadata);
@@ -1430,6 +1326,7 @@ export class CmapWorkspace {
}
const model = prototype.editor.currentModel();
const renderedSvg = currentCmapRenderedSvg();
const conceptMapAtStart = currentCmapStorageMap();
let saveSucceeded = false;
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
@@ -1442,7 +1339,7 @@ export class CmapWorkspace {
showCmapStatus("");
return false;
}
state.currentConceptMap = await cmapRepository.create(title.trim(), model);
state.currentConceptMap = await cmapRepository.create(title.trim(), model, null, { renderedSvg });
const savedRoute = cmapRoute(state.currentConceptMap.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
@@ -1456,7 +1353,8 @@ export class CmapWorkspace {
tr("automatic-save", "Automatic save") :
tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode
saveKind: effectiveHistoryMode,
renderedSvg
});
if (state.currentConceptMapSource &&
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
@@ -1510,6 +1408,7 @@ export class CmapWorkspace {
await loadConceptMaps();
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
await openStoredConceptMap(requestedSlug);
if (await restoreActiveCmapContext(requestedSlug)) markCurrentCmapSaved();
} else if (requestedSlug) {
state.currentConceptMap = null;
state.currentConceptMapSource = null;
@@ -46,6 +46,7 @@ export class CmapConceptDialog {
open(record, resources) {
if (!record || record.kind === "phrase") return;
this.record = record;
this.resources = resources;
this.createContext = null;
this.imageSource = record.imageSource || "";
this.imageRead = Promise.resolve();
@@ -72,6 +73,7 @@ export class CmapConceptDialog {
/** Open the dialog for a new concept at the supplied editor context. */
openNew(createContext, resources) {
this.record = null;
this.resources = resources;
this.createContext = createContext;
this.imageSource = "";
this.imageRead = Promise.resolve();
@@ -208,7 +210,8 @@ export class CmapConceptDialog {
const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page");
const descriptionText = descriptionInput.value.trim();
const descriptionPage = descriptionText ? newPageReference(descriptionText) :
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
pageReference(this.resources?.cmapNamespace || "cmap",
splitPageReference(newPageReference(label) || "concept").slug);
if (!descriptionPage) {
this.tabs.select("content");
descriptionInput.setCustomValidity(
@@ -11,6 +11,7 @@ export class CmapMetadataDialog {
this.normalizePageReference = normalizePageReference;
this.form = dialog.querySelector("form");
this.summary = dialog.querySelector("#cmap-metadata-summary");
this.namespace = dialog.querySelector("#cmap-metadata-namespace");
this.tags = dialog.querySelector("#cmap-metadata-tags");
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
this.saveHandler = null;
@@ -31,6 +32,7 @@ export class CmapMetadataDialog {
}
open(metadata) {
this.namespace.value = metadata.namespace || "";
this.summary.value = metadata.summary || "";
this.tags.value = (metadata.tags || []).join(", ");
this.explanationPage.value = metadata.explanationPageSlug || "";
@@ -40,6 +42,14 @@ export class CmapMetadataDialog {
}
metadata() {
const namespace = this.namespace.value.trim().toLocaleLowerCase();
if (namespace && !/^[\p{L}\p{N}][\p{L}\p{N}-]*$/u.test(namespace)) {
this.namespace.setCustomValidity(
this.tr("invalid-concept-map-namespace", "Use letters, numbers and hyphens for the namespace."));
this.namespace.reportValidity();
return null;
}
this.namespace.setCustomValidity("");
const explanationInput = this.explanationPage.value.trim();
const explanationPageSlug = explanationInput ?
this.normalizePageReference(explanationInput) : "";
@@ -50,6 +60,7 @@ export class CmapMetadataDialog {
return null;
}
return {
namespace,
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
summary: this.summary.value.trim(),
explanationPageSlug
+29
View File
@@ -182,6 +182,34 @@ function positionIsProtected(start, end, ranges) {
return false;
}
/** Prefix every unprotected CamelCase WikiWord with the literal escape marker. */
function protectCamelCaseWikiWords(markdown) {
const wikiWordPattern = /(?<![!\p{L}\p{N}._-])((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-])/gu;
let fence = null;
return String(markdown || "").split("\n").map((line) => {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
fence = fence === null ? marker : (fence === marker ? null : fence);
return line;
}
if (fence !== null || /^\s{4}/.test(line)) return line;
const ranges = markdownProtectedRanges(line);
const matches = Array.from(line.matchAll(wikiWordPattern));
let result = line;
for (let index = matches.length - 1; index >= 0; index -= 1) {
const match = matches[index];
const start = match.index;
const end = start + match[0].length;
if (!positionIsProtected(start, end, ranges)) {
result = result.slice(0, start) + `!${match[0]}` + result.slice(end);
}
}
return result;
}).join("\n");
}
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
function literalWikiWord(namespace, wikiWord) {
const characters = Array.from(wikiWord);
@@ -335,6 +363,7 @@ export {
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
protectCamelCaseWikiWords,
extractCmapEmbeds,
restoreCmapEmbeds
};
+1 -1
View File
@@ -18,4 +18,4 @@ Pinned sources:
EasyMDE bundles CodeMirror and Marked, so no separate CodeMirror or Markdown renderer is downloaded.
The CMap implementation is maintained with racket-wiki itself below
`static/cmap/`; it is no longer installed as a runtime vendor dependency.
`static/js/cmap/`; it is no longer installed as a runtime vendor dependency.
+1 -1
View File
@@ -5,7 +5,7 @@ import {
CmapAppearance,
cmapFontSizeInPoints,
normalizedCmapFontSize
} from "../static/cmap/model/appearance.js";
} from "../static/js/cmap/model/appearance.js";
const defaultValues = {
backgroundColor: "#fff4cf", textColor: "#222222",
+5 -5
View File
@@ -88,7 +88,7 @@ async function loadEditorFactory() {
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
await import("../static/cmap/cmap-racket-wiki.js");
await import("../static/js/cmap/cmap-racket-wiki.js");
global.window.RacketWikiCmap = global.window.RacketWikiCmap || previousFactory;
return global.window.RacketWikiCmap;
}
@@ -175,7 +175,7 @@ test("a dragged connector endpoint survives collapsing and expanding its submap"
}]);
});
test("moving a concept also moves connected concepts that link to another CMap", async () => {
test("moving a concept does not move connected concepts or linking phrases", async () => {
const factory = await loadEditorFactory();
const map = {
onSelection() {},
@@ -206,7 +206,7 @@ test("moving a concept also moves connected concepts that link to another CMap",
editor.addConnector(phrase, linked, true, { id: 2 });
editor.selectItem(owner);
editor.handleItemMove(owner, 180, 140);
owner.node.attr(editor.handleItemMove(owner, 180, 140));
assert.deepEqual({
owner: [owner.node.attr("x"), owner.node.attr("y")],
@@ -214,8 +214,8 @@ test("moving a concept also moves connected concepts that link to another CMap",
linked: [linked.node.attr("x"), linked.node.attr("y")]
}, {
owner: [180, 140],
phrase: [340, 140],
linked: [500, 140]
phrase: [260, 100],
linked: [420, 100]
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ async function loadEditorFactory() {
createElement: () => fakeElement()
};
global.window = {};
await import("../static/cmap/cmap-racket-wiki.js");
await import("../static/js/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
+3 -3
View File
@@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DiagramGroup } from "../static/cmap/engine/diagram-group.js";
import { DiagramComponent } from "../static/cmap/engine/diagram-component.js";
import { DiagramEngine } from "../static/cmap/engine/diagram-engine.js";
import { DiagramGroup } from "../static/js/cmap/engine/diagram-group.js";
import { DiagramComponent } from "../static/js/cmap/engine/diagram-component.js";
import { DiagramEngine } from "../static/js/cmap/engine/diagram-engine.js";
function component(engine, attributes) {
const state = { ...attributes };
+1 -1
View File
@@ -6,7 +6,7 @@ let CmapMarkdownExporter;
test.before(async () => {
({ CmapMarkdownExporter } = await import(
"../static/cmap/model/markdown-exporter.js"));
"../static/js/cmap/model/markdown-exporter.js"));
});
function map(slug, title, document) {
+1 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CmapHistory } from "../static/cmap/controller/cmap-history.js";
import { CmapHistory } from "../static/js/cmap/controller/cmap-history.js";
function history(initial = "one") {
let value = initial;
+1 -1
View File
@@ -15,7 +15,7 @@ test.before(async () => {
preparedMapDocument,
attachmentUrls,
replaceAttachmentUrls
} = await import("../static/cmap/model/interchange.js"));
} = await import("../static/js/cmap/model/interchange.js"));
});
const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555";
+4 -4
View File
@@ -1,13 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CmapJsonExporter } from "../static/cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../static/cmap/model/json-importer.js";
import { CmapJsonExporter } from "../static/js/cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../static/js/cmap/model/json-importer.js";
import {
CmapRepository,
StoredConceptMap
} from "../static/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/cmap/model/concept-map.js";
} from "../static/js/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/js/cmap/model/concept-map.js";
const conceptId = "11111111-1111-4111-8111-111111111111";
const rootMap = {
+1 -1
View File
@@ -88,7 +88,7 @@ test("relations render and hit-test behind concept nodes", async () => {
requestAnimationFrame: (callback) => setImmediate(callback)
};
const { DiagramEngine } = await import("../static/cmap/cmap.js");
const { DiagramEngine } = await import("../static/js/cmap/cmap.js");
assert.equal(global.window.Cmap, undefined,
"the ES-module engine does not install a legacy global");
const map = new DiagramEngine(root);
+77 -3
View File
@@ -6,15 +6,16 @@ import {
ConceptOwnership,
ConceptRelation,
ConceptRepository
} from "../static/cmap/model/concept-repository.js";
} from "../static/js/cmap/model/concept-repository.js";
import {
CmapModel,
ConceptMap,
ConceptMapConcept,
ConceptMapConnector,
ConceptMapPhrase
} from "../static/cmap/model/concept-map.js";
import { buildBundle } from "../static/cmap/model/interchange.js";
} from "../static/js/cmap/model/concept-map.js";
import { buildBundle } from "../static/js/cmap/model/interchange.js";
import { CmapReferenceIndex } from "../static/js/cmap/model/cmap-reference-index.js";
const firstId = "11111111-1111-4111-8111-111111111111";
const secondId = "22222222-2222-4222-8222-222222222222";
@@ -24,6 +25,7 @@ function sampleDocument() {
return {
schemaVersion: 2,
metadata: {
namespace: "",
tags: ["architecture"],
summary: "System overview",
explanationPageSlug: "cmap:architecture"
@@ -246,6 +248,7 @@ test("an embedded submap becomes an independent map linked from its owner", () =
{ tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: "" }
);
assert.deepEqual(convertedDerivedMap.childModel.metadata(), {
namespace: "",
tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: ""
});
});
@@ -274,3 +277,74 @@ test("a model document is accepted by the complete JSON bundle export", async ()
assert.deepEqual(bundle.cmaps[0].document.items, model.toDocument().items);
assert.equal(bundle.concepts[0].label, "Application");
});
test("CMap reference index stores lightweight destinations per concept", () => {
const index = new CmapReferenceIndex().load([
{ conceptId: firstId, label: "Application", cmapSlug: "architecture", count: 2 },
{ conceptId: firstId, label: "Application", cmapSlug: "operations", count: 1 }
], [
{ slug: "architecture", title: "Architecture" },
{ slug: "operations", title: "Operations" }
]);
assert.deepEqual(index.mapsFor(firstId), [
{ slug: "architecture", title: "Architecture", count: 2 },
{ slug: "operations", title: "Operations", count: 1 }
]);
assert.equal(index.countFor(firstId), 3);
assert.equal(index.conceptIdForName("Application"), firstId);
});
test("model finds boundary references from active submap crossings", () => {
const model = CmapModel.fromDocument({
concepts: [
{ id: firstId, label: "Submap" },
{ id: secondId, label: "Inside" },
{ id: thirdId, label: "Remote", cmapSlug: "remote-map" }
],
items: [
{ id: 10, kind: "submap", conceptId: firstId, x: 0, y: 0, width: 800, height: 500 },
{ id: 1, conceptId: secondId, parentSubmapId: 10, x: 100, y: 120, width: 200, height: 60 },
{ id: 2, conceptId: thirdId, x: 420, y: 120, width: 200, height: 60 }
],
connectors: [{ id: 1, sourceId: 1, targetId: 2 }],
conceptMaps: [{ id: "remote-map", title: "Remote map" }]
});
assert.deepEqual(model.boundaryReferencesFor(10), [{
connector: {
id: 1, sourceId: 1, targetId: 2, hasArrow: true,
lineColor: "#333", lineWidth: 2
},
inside: { id: 1, x: 100, y: 120, width: 200, height: 60 },
outside: { id: 2, x: 420, y: 120, width: 200, height: 60 },
sourceInside: true,
conceptId: thirdId,
label: "Remote",
linkingPhraseLabel: null,
targetMaps: []
}]);
});
test("model anchors boundary references on the connected concept beyond a phrase", () => {
const model = CmapModel.fromDocument({
concepts: [
{ id: firstId, label: "Submap" },
{ id: secondId, label: "Inside" },
{ id: thirdId, label: "Remote", cmapSlug: "remote-map" }
],
items: [
{ id: 10, kind: "submap", conceptId: firstId, x: 0, y: 0, width: 800, height: 500 },
{ id: 1, conceptId: secondId, parentSubmapId: 10, x: 100, y: 120, width: 200, height: 60 },
{ id: 2, kind: "phrase", label: "relates", parentSubmapId: 10, x: 330, y: 120, width: 80, height: 30 },
{ id: 3, conceptId: thirdId, x: 480, y: 120, width: 200, height: 60 }
],
connectors: [
{ id: 1, sourceId: 1, targetId: 2 },
{ id: 2, sourceId: 2, targetId: 3 }
],
conceptMaps: [{ id: "remote-map", title: "Remote map" }]
});
assert.equal(model.boundaryReferencesFor(10)[0].inside.id, 1);
});
+1 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PeopleRepository } from "../static/cmap/model/people-repository.js";
import { PeopleRepository } from "../static/js/cmap/model/people-repository.js";
test("PeopleRepository keeps people API routes behind its public methods", async () => {
const calls = [];
+8 -4
View File
@@ -4,8 +4,8 @@ import assert from "node:assert/strict";
import {
CmapRepository,
StoredConceptMap
} from "../static/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/cmap/model/concept-map.js";
} from "../static/js/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/js/cmap/model/concept-map.js";
const documentValue = {
schemaVersion: 2,
@@ -19,7 +19,8 @@ test("CmapRepository decodes backend documents and normalizes legacy concept ids
slug: "roadmap",
title: "Roadmap",
currentVersion: 3,
document: JSON.stringify(JSON.stringify(documentValue))
document: JSON.stringify(JSON.stringify(documentValue)),
renderedSvg: "<svg></svg>"
}));
const storedMap = await repository.load("roadmap");
@@ -29,6 +30,7 @@ test("CmapRepository decodes backend documents and normalizes legacy concept ids
assert.equal(storedMap.currentVersion, 3);
assert.equal(storedMap.toDocument().concepts[0].id, "legacy:roadmap:concept-1");
assert.equal(storedMap.toDocument().items[0].conceptId, "legacy:roadmap:concept-1");
assert.equal(storedMap.renderedSvg, "<svg></svg>");
});
test("CmapRepository serializes model saves with backend version information", async () => {
@@ -47,7 +49,8 @@ test("CmapRepository serializes model saves with backend version information", a
const savedMap = await repository.save(storedMap, storedMap.model, {
summary: "Manual save",
saveKind: "manual",
snapshot: true
snapshot: true,
renderedSvg: "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"
});
assert.equal(savedMap.currentVersion, 2);
@@ -57,6 +60,7 @@ test("CmapRepository serializes model saves with backend version information", a
assert.equal(body.summary, "Manual save");
assert.equal(body.saveKind, "manual");
assert.equal(body.snapshot, true);
assert.equal(body.renderedSvg, "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
assert.deepEqual(body.document.concepts, storedMap.toDocument().concepts);
});
+1 -1
View File
@@ -29,7 +29,7 @@ async function loadEditorFactory() {
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
await import("../static/cmap/cmap-racket-wiki.js");
await import("../static/js/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
+3 -3
View File
@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CmapAppearance } from "../static/cmap/model/appearance.js";
import { CmapAppearanceRepository } from "../static/cmap/model/appearance-repository.js";
import { CmapSettingsRepository } from "../static/cmap/model/settings-repository.js";
import { CmapAppearance } from "../static/js/cmap/model/appearance.js";
import { CmapAppearanceRepository } from "../static/js/cmap/model/appearance-repository.js";
import { CmapSettingsRepository } from "../static/js/cmap/model/settings-repository.js";
test("appearance repository owns the complete backend appearance envelope", async () => {
const requests = [];
+2 -2
View File
@@ -46,7 +46,7 @@ async function loadEditorFactory() {
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
await import("../static/cmap/cmap-racket-wiki.js");
await import("../static/js/cmap/cmap-racket-wiki.js");
editorFactory = global.window.RacketWikiCmap;
return editorFactory;
}
@@ -139,7 +139,7 @@ test("single-click selects and double-click edits every kind of concept", async
test("a linked CMap opens only through its arrow decorator", async () => {
await loadEditorFactory();
const { CmapItemDecorator } = await import(
"../static/cmap/view/cmap-item-decorator.js");
"../static/js/cmap/view/cmap-item-decorator.js");
const listeners = new Map();
const linkedButton = {
dataset: {},
+7
View File
@@ -9,6 +9,7 @@ import {
expandTodoMarkup,
expandWikiMentions,
extractCmapEmbeds,
protectCamelCaseWikiWords,
restoreCmapEmbeds
} from "../static/js/wiki/markdown.js";
@@ -66,6 +67,12 @@ test("the WikiWord escape remains literal in protected Markdown", () => {
assert.equal(expandWikiMentions(markdown, [], [], []), markdown);
});
test("protects unprotected CamelCase WikiWords without changing Markdown syntax", () => {
const markdown = "HomeopathieWiki and [ExistingLink](https://example.test)\n`CodeWord`\n!AlreadyLiteral\n IndentedCodeWord";
assert.equal(protectCamelCaseWikiWords(markdown),
"!HomeopathieWiki and [ExistingLink](https://example.test)\n`CodeWord`\n!AlreadyLiteral\n IndentedCodeWord");
});
test("ambiguous CMap mentions are not resolved", () => {
const conceptMaps = [
{ slug: "first", title: "Zelfde Kaart" },