refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ Bijgewerkt: 25 augustus 2026
- EasyMDE/Marked voor Markdown, DOMPurify voor sanitizing, highlight.js voor code en diff2html voor versieverschillen;
- lokale, tijdens setup gedownloade frontendbibliotheken, zodat normaal gebruik geen CDN nodig heeft.
De ontwikkelversie is **0.2.122** (`info.rkt`). De huidige PostgreSQL-schemaversie is **21** (`private/migrations.rkt`).
De ontwikkelversie is **0.2.122** (`info.rkt`). De huidige PostgreSQL-schemaversie is **22** (`private/migrations.rkt`).
## Belangrijk: huidige werkboom
@@ -40,7 +40,7 @@ static/cmap/cmap-racket-wiki.js
static/cmap/cmap.css
static/index.html
static/js/wiki.js
static/js/cmap-export.js
static/cmap/model/markdown-exporter.js
test/cmap-export.test.js
translate.rkt
```
@@ -143,7 +143,7 @@ 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/js/cmap-export.js
node --check static/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
+13 -1
View File
@@ -244,7 +244,19 @@ Classic WikiWords may also be namespace-qualified:
RWS:ModelTreeWalker
```
The WikiWord portion still follows the strict letter-only classic rule. Todo and Bookmark views group their entries by namespace; bookmark user sections remain available as a second grouping level.
Prefix a WikiWord with an exclamation mark when it should remain ordinary text:
```text
!ModelTreeWalker
```
This renders as `ModelTreeWalker`, without a link or visible exclamation mark.
The same escape works for a namespace-qualified WikiWord. The WikiWord portion
still follows the strict letter-only classic rule. The render transformation is
idempotent, so a preview or component that processes the prepared Markdown a
second time cannot turn the literal WikiWord back into a link. Todo and Bookmark views group
their entries by namespace; bookmark user sections remain available as a second
grouping level.
## Editing
@@ -1,7 +1,7 @@
#lang racket/base
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Wiki-wide CMap appearance styles stored in wiki_settings.
;; Wiki-wide CMap appearance stored in wiki_settings.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(require db
@@ -10,10 +10,11 @@
racket/string
"database.rkt")
(provide read-cmap-styles
save-cmap-styles!)
(provide read-cmap-appearance
save-cmap-appearance!)
(define setting-key "cmap.styles.v1")
(define styles-setting-key "cmap.styles.v1")
(define palette-setting-key "cmap.palette.v1")
(define maximum-style-count 100)
(define maximum-style-name-length 80)
(define permitted-name-keys
@@ -50,6 +51,10 @@
(seeded-style "warning" "style-warning" "#fff0d5" "#713b00" "Arial, Helvetica, sans-serif" 11 #t #t)
(seeded-style "success" "style-success" "#e6f4e2" "#285b27" "Arial, Helvetica, sans-serif" 11 #f #t)))
(define initial-cmap-palette
'("#ffffff" "#f1f3f5" "#e7f2fb" "#e6f4e2" "#fff4cf" "#fff0d5"
"#f7dede" "#dcd8f7" "#222222" "#4479a1" "#57834a" "#a97c00"))
(define (required-string who value description [maximum-length #f])
(unless (and (string? value)
(not (string=? (string-trim value) ""))
@@ -130,49 +135,73 @@
(error who "the default style is required"))
normalized))
(define (normalize-cmap-palette palette [who 'cmap-palette])
(unless (and (list? palette) (= (length palette) (length initial-cmap-palette)))
(error who "the colour palette must contain ~a colours" (length initial-cmap-palette)))
(for/list ((color (in-list palette)))
(style-color who color 'palette)))
(define (read-setting db key fallback normalize who)
(define stored (query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" key))
(if stored
(normalize (string->jsexpr stored) who)
(normalize fallback who)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Read the shared CMap appearance styles.
; goal : Read the complete shared CMap appearance.
; pre : The wiki database is configured and its schema is initialized.
; post : Default styles are inserted when no style setting exists yet.
; result : A validated, normalized non-empty list of style hashes.
; post : The database is not changed; absent settings use model defaults.
; result : A hash containing validated styles and colour palette.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (read-cmap-styles config)
(define (read-cmap-appearance config)
(call-with-wiki-database
config
(λ (db)
(let ((stored
(query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key)))
(if stored
(normalize-cmap-styles (string->jsexpr stored) 'read-cmap-styles)
(let ((encoded (jsexpr->string (normalize-cmap-styles initial-cmap-styles))))
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO NOTHING"
setting-key encoded (current-seconds))
(normalize-cmap-styles
(string->jsexpr
(query-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key))
'read-cmap-styles)))))))
(hash 'styles
(read-setting db
styles-setting-key
initial-cmap-styles
normalize-cmap-styles
'read-cmap-appearance)
'palette
(read-setting db
palette-setting-key
initial-cmap-palette
normalize-cmap-palette
'read-cmap-appearance)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Validate and store the shared CMap appearance styles.
; pre : styles is a non-empty list containing the required default style.
; post : The normalized style setting is stored atomically in the database.
; result : The normalized list of stored style hashes.
; goal : Validate and store the complete shared CMap appearance.
; pre : appearance contains styles and palette values accepted by this module.
; post : Both normalized settings are stored in one database transaction.
; result : The normalized appearance hash.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (save-cmap-styles! config styles)
(let* ((normalized (normalize-cmap-styles styles 'save-cmap-styles!))
(encoded (jsexpr->string normalized)))
(when (> (bytes-length (string->bytes/utf-8 encoded)) (* 128 1024))
(error 'save-cmap-styles! "style data is too large"))
(call-with-wiki-database
config
(λ (db)
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
setting-key encoded (current-seconds))))
normalized))
(define (save-cmap-appearance! config appearance)
(unless (hash? appearance)
(error 'save-cmap-appearance! "appearance must be an object"))
(define styles
(normalize-cmap-styles (hash-ref appearance 'styles #f) 'save-cmap-appearance!))
(define palette
(normalize-cmap-palette (hash-ref appearance 'palette #f) 'save-cmap-appearance!))
(define encoded-styles (jsexpr->string styles))
(define encoded-palette (jsexpr->string palette))
(when (> (bytes-length (string->bytes/utf-8 encoded-styles)) (* 128 1024))
(error 'save-cmap-appearance! "style data is too large"))
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
styles-setting-key encoded-styles (current-seconds))
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
palette-setting-key encoded-palette (current-seconds))))))
(hash 'styles styles 'palette palette))
(module+ test
(require rackunit)
@@ -187,6 +216,7 @@
(list (hash 'id "default" 'nameKey "style-default" 'values values))))
(check-equal? (hash-ref (hash-ref (first normalized) 'values) 'backgroundColor) "#fff4cf")
(check-equal? (length (normalize-cmap-styles initial-cmap-styles)) 5)
(check-equal? (normalize-cmap-palette initial-cmap-palette) initial-cmap-palette)
(check-exn exn:fail? (λ () (normalize-cmap-styles '())))
(check-exn exn:fail?
(λ ()
+132
View File
@@ -0,0 +1,132 @@
#lang racket/base
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Wiki and user settings for the browser-side CMap workspace.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(require db
racket/string
"database.rkt")
(provide read-cmap-settings
save-start-cmap!
save-cmap-page-guides!
save-cmap-zoom!)
(define start-cmap-setting-key "cmap.start.v1")
(define (clean-context-key who context-key)
(unless (and (string? context-key)
(not (string=? (string-trim context-key) ""))
(<= (string-length context-key) 200))
(error who "invalid CMap context"))
context-key)
(define (clean-zoom who zoom)
(unless (and (exact-integer? zoom) (<= 25 zoom 300))
(error who "zoom must be an integer between 25 and 300"))
zoom)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Read the wiki start CMap and one user's CMap view preferences.
; pre : user-id identifies an authenticated wiki user.
; post : The database is unchanged; missing preferences receive defaults.
; result : A JSON-compatible settings hash.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (read-cmap-settings config user-id)
(call-with-wiki-database
config
(λ (db)
(define start-slug
(or (query-maybe-value
db
"SELECT value FROM wiki_settings WHERE key = $1"
start-cmap-setting-key)
""))
(define page-guide-row
(query-maybe-row
db
"SELECT page_guides_visible FROM user_cmap_preferences WHERE user_id = $1"
user-id))
(define page-guides-visible
(if page-guide-row (vector-ref page-guide-row 0) #t))
(define zoom-rows
(query-rows
db
"SELECT cmap_slug, context_key, zoom_percent FROM user_cmap_zoom_levels WHERE user_id = $1 ORDER BY cmap_slug, context_key"
user-id))
(define zooms
(for/list ((row (in-list zoom-rows)))
(hash 'cmapSlug (vector-ref row 0)
'contextKey (vector-ref row 1)
'zoomPercent (vector-ref row 2))))
(hash 'startCmapSlug start-slug
'pageGuidesVisible page-guides-visible
'zooms zooms))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Select the wiki-wide start CMap.
; pre : slug is empty, or identifies a current non-archived CMap.
; post : The start setting is removed or updated atomically.
; result : The stored slug, or the empty string when cleared.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (save-start-cmap! config slug)
(define clean-slug (if (string? slug) (string-trim slug) ""))
(call-with-wiki-database
config
(λ (db)
(cond
((string=? clean-slug "")
(query-exec db "DELETE FROM wiki_settings WHERE key = $1" start-cmap-setting-key))
((query-maybe-value
db
"SELECT 1 FROM concept_maps WHERE slug = $1 AND archived = FALSE"
clean-slug)
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
start-cmap-setting-key clean-slug (current-seconds)))
(else
(error 'save-start-cmap! "the selected start CMap does not exist")))))
clean-slug)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Store one user's wiki-wide A4 page-guide preference.
; pre : visible is a boolean and user-id identifies an authenticated user.
; post : The user's preference is inserted or updated atomically.
; result : The stored boolean.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (save-cmap-page-guides! config user-id visible)
(unless (boolean? visible)
(error 'save-cmap-page-guides! "pageGuidesVisible must be a boolean"))
(call-with-wiki-database
config
(λ (db)
(query-exec
db
"INSERT INTO user_cmap_preferences(user_id, page_guides_visible, updated_at) VALUES ($1, $2, $3) ON CONFLICT(user_id) DO UPDATE SET page_guides_visible = excluded.page_guides_visible, updated_at = excluded.updated_at"
user-id visible (current-seconds))))
visible)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Store one user's zoom for one CMap context.
; pre : The CMap exists, context-key is non-empty and zoom is 25 through 300.
; post : This single context preference is inserted or updated atomically.
; result : The normalized zoom percentage.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (save-cmap-zoom! config user-id cmap-slug context-key zoom)
(define clean-context (clean-context-key 'save-cmap-zoom! context-key))
(define clean-zoom-percent (clean-zoom 'save-cmap-zoom! zoom))
(call-with-wiki-database
config
(λ (db)
(unless (query-maybe-value
db
"SELECT 1 FROM concept_maps WHERE slug = $1 AND archived = FALSE"
cmap-slug)
(error 'save-cmap-zoom! "the selected CMap does not exist"))
(query-exec
db
"INSERT INTO user_cmap_zoom_levels(user_id, cmap_slug, context_key, zoom_percent, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT(user_id, cmap_slug, context_key) DO UPDATE SET zoom_percent = excluded.zoom_percent, updated_at = excluded.updated_at"
user-id cmap-slug clean-context clean-zoom-percent (current-seconds))))
clean-zoom-percent)
+28 -1
View File
@@ -21,7 +21,7 @@
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define current-schema-version 21)
(define current-schema-version 22)
(define schema-1-statements
(list
@@ -1214,6 +1214,32 @@ SQL
)
(record-schema-version! db 21)))
(define (migrate-21->22! db)
(query-exec
db
#<<SQL
CREATE TABLE IF NOT EXISTS user_cmap_preferences (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
page_guides_visible BOOLEAN NOT NULL DEFAULT TRUE,
updated_at BIGINT NOT NULL
)
SQL
)
(query-exec
db
#<<SQL
CREATE TABLE IF NOT EXISTS user_cmap_zoom_levels (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
cmap_slug TEXT NOT NULL REFERENCES concept_maps(slug) ON DELETE CASCADE,
context_key TEXT NOT NULL,
zoom_percent INTEGER NOT NULL CHECK(zoom_percent BETWEEN 25 AND 300),
updated_at BIGINT NOT NULL,
PRIMARY KEY(user_id, cmap_slug, context_key)
)
SQL
)
(record-schema-version! db 22))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
; pre : db is a writable PostgreSQL connection and config identifies the
@@ -1250,6 +1276,7 @@ SQL
((= version 18) (migrate-18->19! db) (loop (database-schema-version db)))
((= 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 current-schema-version)
(error 'migrate-database!
"database schema ~a is newer than this racket-wiki supports (~a)"
+4 -1
View File
@@ -79,7 +79,10 @@ Pages have an explicit namespace in addition to their stable slug. Existing page
are migrated to the root namespace. A compact page reference uses
@tt{namespace:slug}, for example @tt{racket:roadmap}. Explicit Markdown links
may therefore use @tt{[Roadmap](racket:roadmap)}. A classic WikiWord can also be
qualified, for example @tt{RWS:ModelTreeWalker}.
qualified, for example @tt{RWS:ModelTreeWalker}. Prefixing a WikiWord with an
exclamation mark suppresses the implicit link. Thus @tt{!ModelTreeWalker}
renders as the ordinary text @tt{ModelTreeWalker}; the exclamation mark is not
shown. The same escape applies to a namespace-qualified WikiWord.
The namespace and slug are stored separately in PostgreSQL and their combination
is unique. Todo items and bookmarks are grouped by namespace in the browser.
+62 -10
View File
@@ -15,8 +15,9 @@
web-server/http
web-server/servlet-env
"private/auth.rkt"
"private/cmap-appearance.rkt"
"private/cmap-settings.rkt"
"private/cmap-storage.rkt"
"private/cmap-styles.rkt"
"private/config.rkt"
(only-in "private/database.rkt" call-with-wiki-database)
"private/http-util.rkt"
@@ -837,20 +838,63 @@ CSS
(json-response
(hash 'conceptMaps (list-archived-concept-maps config))))))
(define (cmap-styles-handler config req)
(define (cmap-appearance-handler config req)
(if (string-ci=? (bytes->string/latin-1 (request-method req)) "GET")
(require-role
config req 'reader
(λ (_session)
(json-response (hash 'styles (or (read-cmap-styles config) 'null)))))
(json-response (read-cmap-appearance config))))
(require-write-role
config req 'editor
(λ (_session)
(with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))])
(define body (request-json req))
(json-response
(hash 'styles
(save-cmap-styles! config (hash-ref body 'styles '())))))))))
(json-response (save-cmap-appearance! config body)))))))
(define (cmap-settings-handler config req)
(require-role
config req 'reader
(λ (session)
(json-response
(read-cmap-settings config (wiki-user-id (wiki-session-user session)))))))
(define (cmap-start-setting-handler config req)
(require-write-role
config req 'editor
(λ (_session)
(with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))])
(define body (request-json req))
(json-response
(hash 'startCmapSlug
(save-start-cmap! config (hash-ref body 'startCmapSlug ""))))))))
(define (cmap-page-guides-setting-handler config req)
(require-write-role
config req 'reader
(λ (session)
(with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))])
(define body (request-json req))
(json-response
(hash 'pageGuidesVisible
(save-cmap-page-guides!
config
(wiki-user-id (wiki-session-user session))
(hash-ref body 'pageGuidesVisible 'missing))))))))
(define (cmap-zoom-setting-handler config req)
(require-write-role
config req 'reader
(λ (session)
(with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))])
(define body (request-json req))
(json-response
(hash 'zoomPercent
(save-cmap-zoom!
config
(wiki-user-id (wiki-session-user session))
(hash-ref body 'cmapSlug "")
(hash-ref body 'contextKey "")
(hash-ref body 'zoomPercent #f))))))))
(define (admin-restore-concept-map-handler config req slug)
(require-write-role
@@ -1104,10 +1148,18 @@ CSS
(λ (req) (concept-map-list-handler config req))]
[("api" "cmaps") #:method "post"
(λ (req) (concept-map-create-handler config req))]
[("api" "cmap-styles") #:method "get"
(λ (req) (cmap-styles-handler config req))]
[("api" "cmap-styles") #:method "put"
(λ (req) (cmap-styles-handler config req))]
[("api" "cmap-appearance") #:method "get"
(λ (req) (cmap-appearance-handler config req))]
[("api" "cmap-appearance") #:method "put"
(λ (req) (cmap-appearance-handler config req))]
[("api" "cmap-settings") #:method "get"
(λ (req) (cmap-settings-handler config req))]
[("api" "cmap-settings" "start") #:method "put"
(λ (req) (cmap-start-setting-handler config req))]
[("api" "cmap-settings" "page-guides") #:method "put"
(λ (req) (cmap-page-guides-setting-handler config req))]
[("api" "cmap-settings" "zoom") #:method "put"
(λ (req) (cmap-zoom-setting-handler config req))]
[("api" "cmaps" "concept-usage") #:method "get"
(λ (req) (concept-usage-handler config req))]
[("api" "people") #:method "get"
+35 -4
View File
@@ -12,9 +12,26 @@ same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
placements, linking phrases, connectors, metadata and presentation values.
Neither model module contains DOM or drawing-engine objects.
`model/cmap-repository.js` owns the server boundary: it decodes stored CMaps,
normalizes legacy concept identities and converts backend documents to and
from `CmapModel`. It is the only browser-side CMap model module that knows the
`/api/cmaps` routes. `model/appearance.js` owns the shared named styles, colour
palette, validation and style matching. `model/appearance-repository.js`
serializes that model through the appearance API. `model/settings-repository.js`
owns the wiki start CMap and the authenticated user's A4-guide and
per-CMap-context zoom settings. `model/people-repository.js` is the server
boundary for the people referenced by concept tags.
The repository objects keep only an in-memory session copy; the backend remains
authoritative. None of these model modules contains DOM or drawing-engine
objects, and CMap state is not persisted in browser storage.
`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing 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
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.
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
state, content-based initial sizing, resize and relation controls. Its view
records connect pure model ids to drawing nodes without making those nodes part
@@ -59,12 +76,19 @@ metadata remain intact, but its document no longer depends on the parent CMap.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
map references. The wiki host persists this JSON document through its CMap API.
map references. The public `currentModel`, `loadModel` and `replaceModel`
methods let the workspace pass complete domain models to and from the editor.
The CMap repository, rather than the workspace, persists those models.
## JSON interchange
`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle`
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
`model/interchange.js` defines and validates the versioned
`racket-wiki-cmap-bundle` format. `model/json-exporter.js` builds complete bundles
through `CmapRepository` and the supplied page and attachment loaders.
`model/json-importer.js` reads those bundles, stores CMaps through the same
repository and performs the required page and attachment writes. The workspace
decides only how conflicts are presented to the user.
A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
referenced attachments of linked wiki and explanation pages. Attachments retain
@@ -85,6 +109,13 @@ a temporary identity into a UUID through the central Racket UUID helper. UUIDs
already present in an export remain unchanged. Imported CMap slugs likewise
remain unchanged.
`model/markdown-exporter.js` reads stored models through `CmapRepository` but
produces a readable
report instead of an importable bundle. It is independent of the JSON
exporter because the report follows linked maps and optional wiki pages for a
different purpose. Additional output formats can therefore be implemented as
separate exporters without adding format-specific code to the workspace.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
+19
View File
@@ -1153,6 +1153,12 @@ import { CmapView } from "./cmap-view.js";
return true;
}
/** Replace the editor contents with a public CMap domain model. */
replaceModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return this.replaceDocument(model.toDocument());
}
setZoom(percent) {
const next = Math.max(25, Math.min(300, Number(percent) || 100));
this.zoomFactor = next / 100;
@@ -1866,6 +1872,19 @@ import { CmapView } from "./cmap-view.js";
return this.synchronizeModel().toDocument();
}
/** Return the synchronized domain model currently edited by this view. */
currentModel() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
return this.synchronizeModel();
}
/** Load a public CMap domain model into an empty editor. */
loadModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
this.loadDocument(model.toDocument());
}
loadDocument(document = {}) {
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
throw new Error("A concept map document can only be loaded into an empty editor");
@@ -0,0 +1,30 @@
import { CmapAppearance } from "./appearance.js";
/**
* Persist wiki-wide CMap appearance through the backend.
* Styles and the colour palette form one appearance aggregate.
*/
export class CmapAppearanceRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Load and deserialize the complete wiki-wide appearance aggregate. */
async load() {
const result = await this.api("/api/cmap-appearance");
return new CmapAppearance(result);
}
/** Persist one appearance aggregate and refresh it with backend normalization. */
async save(appearance) {
if (!(appearance instanceof CmapAppearance)) {
throw new TypeError("A CmapAppearance is required");
}
const result = await this.api("/api/cmap-appearance", {
method: "PUT",
body: JSON.stringify(appearance.toData())
});
return appearance.replace(result);
}
}
+181
View File
@@ -0,0 +1,181 @@
const STYLE_VALUE_KEYS = [
"backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle",
"synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "submapBackgroundColor", "submapBorderColor"
];
/** Return a detached value at the model boundary. */
function copy(value) {
return JSON.parse(JSON.stringify(value));
}
/** Return a six-digit HTML colour or the supplied fallback. */
function cmapColorValue(value, fallback = "#f3f6f8") {
return /^#[0-9a-f]{6}$/i.test(value || "") ? value.toLowerCase() : fallback;
}
/** Convert a stored CSS font size to typographic points. */
function cmapFontSizeInPoints(value, baseSize = 11) {
const size = Number.parseFloat(value);
if (!Number.isFinite(size)) return baseSize;
if (/px$/i.test(value || "")) return size * 0.75;
if (/em$/i.test(value || "")) return size * baseSize;
if (/%$/i.test(value || "")) return (size / 100) * baseSize;
return size;
}
/** Normalize an editable font size to the range accepted by the backend. */
function normalizedCmapFontSize(value, fallback) {
const size = Number(value);
const usableSize = Number.isFinite(size) ? size : fallback;
return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2));
}
/** Format a typographic point value for a form input. */
function displayCmapFontSize(value) {
return String(Math.round(value * 2) / 2);
}
/**
* Own the wiki-wide CMap styles and colour palette.
* The backend supplies the default style; this model validates UI changes
* against that style and exposes detached values to its consumers.
*/
export class CmapAppearance {
constructor(data) {
this.replace(data);
}
get styles() {
return copy(this._styles);
}
get palette() {
return [...this._palette];
}
get defaultValues() {
return copy(this._styles.find((style) => style.id === "default").values);
}
/** Replace the aggregate with a complete backend representation. */
replace(data) {
const styles = Array.isArray(data?.styles) ? data.styles : [];
const defaultStyle = styles.find((style) => style?.id === "default");
const palette = Array.isArray(data?.palette) ? data.palette : [];
const completeDefault = defaultStyle?.values &&
STYLE_VALUE_KEYS.every((key) => Object.hasOwn(defaultStyle.values, key));
if (!completeDefault || palette.length === 0) {
throw new TypeError("CMap appearance requires a default style and colour palette");
}
const seenIds = new Set();
this._styles = styles.map((style) => {
const id = typeof style?.id === "string" ? style.id.trim() : "";
const hasName = typeof style?.name === "string" && style.name.trim();
const hasNameKey = typeof style?.nameKey === "string" && style.nameKey.trim();
if (!/^[A-Za-z0-9_-]+$/.test(id) || seenIds.has(id) ||
(!hasName && !hasNameKey) || !style.values) {
throw new TypeError("CMap appearance contains an invalid style");
}
seenIds.add(id);
return {
id,
...(hasNameKey ? { nameKey: style.nameKey.trim() } : { name: style.name.trim() }),
protected: id === "default",
values: this.normalizeValues(style.values, defaultStyle.values)
};
});
this._palette = palette.map((color) => {
if (!/^#[0-9a-f]{6}$/i.test(color || "")) {
throw new TypeError("CMap appearance contains an invalid palette colour");
}
return color.toLowerCase();
});
return this;
}
/** Return the complete detached representation for backend storage. */
toData() {
return { styles: this.styles, palette: this.palette };
}
/** Normalize editable values using the backend-provided default style. */
normalizeValues(values, fallbackValues = null) {
if (!values || typeof values !== "object") return null;
const fallback = fallbackValues || this.defaultValues;
const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize);
return {
backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(values.textColor, fallback.textColor),
fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ?
values.fontFamily.trim() : fallback.fontFamily,
fontSize,
fontWeight: String(values.fontWeight) === "400" ? "400" : "700",
fontStyle: values.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor),
synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ?
values.synopsisFontFamily.trim() : fallback.synopsisFontFamily,
synopsisFontSize: normalizedCmapFontSize(
values.synopsisFontSize, Math.max(6, fontSize - 2)),
synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400",
synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal",
submapBackgroundColor: cmapColorValue(
values.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor)
};
}
style(id) {
const style = this._styles.find((candidate) => candidate.id === id);
return style ? copy(style) : null;
}
matchingStyleId(values) {
const normalized = this.normalizeValues(values);
if (!normalized) return "";
const style = this._styles.find((candidate) =>
STYLE_VALUE_KEYS.every((key) => candidate.values[key] === normalized[key]));
return style?.id || "";
}
/** Insert or replace one non-default named style. */
putStyle(style) {
const name = typeof style?.name === "string" ? style.name.trim() : "";
const id = typeof style?.id === "string" ? style.id.trim() : "";
if (!/^[A-Za-z0-9_-]+$/.test(id) || id === "default" || !name) {
throw new TypeError("A custom CMap style requires an id and name");
}
const normalized = {
id,
name,
protected: false,
values: this.normalizeValues(style.values)
};
const existingIndex = this._styles.findIndex((candidate) => candidate.id === normalized.id);
if (existingIndex < 0) this._styles.push(normalized);
else this._styles.splice(existingIndex, 1, normalized);
return copy(normalized);
}
/** Delete a custom style and refuse deletion of protected styles. */
deleteStyle(id) {
const style = this._styles.find((candidate) => candidate.id === id);
if (!style || style.protected) return false;
this._styles = this._styles.filter((candidate) => candidate.id !== id);
return true;
}
setPaletteColor(index, color) {
if (!Number.isInteger(index) || index < 0 || index >= this._palette.length ||
!/^#[0-9a-f]{6}$/i.test(color || "")) return false;
this._palette[index] = color.toLowerCase();
return true;
}
}
export {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize,
normalizedCmapFontSize
};
+206
View File
@@ -0,0 +1,206 @@
import { CmapModel } from "./concept-map.js";
/** Return a detached copy of data received from or sent to the backend. */
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Decode the historical string-wrapped CMap document representation. */
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
throw new Error("The stored CMap document is not a JSON object.");
}
return copy(documentValue);
}
/** Give legacy map-local concept ids a stable identity before model creation. */
function normalizeConceptIdentities(documentValue, cmapSlug) {
const legacyIds = new Map();
const normalize = (conceptId) => {
const prefixedUuid = typeof conceptId === "string" && conceptId.match(
/^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (prefixedUuid) return prefixedUuid[1].toLowerCase();
if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId;
if (!legacyIds.has(conceptId)) {
legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`);
}
return legacyIds.get(conceptId);
};
for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
concept.id = normalize(concept.id);
}
for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) {
item.conceptId = normalize(item.conceptId);
}
return documentValue;
}
/** Convert a public CMap model to the canonical backend document. */
function modelDocument(value) {
if (!(value instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return value.toDocument();
}
/**
* Represent one persisted CMap together with its decoded domain model.
* Backend version fields remain available without exposing the response object.
*/
export class StoredConceptMap {
constructor(record, model) {
if (!record?.slug || !(model instanceof CmapModel)) {
throw new TypeError("A stored CMap requires a slug and CmapModel");
}
this._slug = String(record.slug);
this._title = String(record.title || record.slug);
this._currentVersion = Number(record.currentVersion || record.version) || 0;
this._version = Number(record.version || record.currentVersion) || 0;
this._createdAt = record.createdAt || null;
this._updatedAt = record.updatedAt || null;
this._author = record.author || null;
this._model = model;
}
get slug() { return this._slug; }
get title() { return this._title; }
get currentVersion() { return this._currentVersion; }
get version() { return this._version; }
get createdAt() { return this._createdAt; }
get updatedAt() { return this._updatedAt; }
get author() { return this._author; }
get model() { return this._model; }
/** Return a detached storage document for comparisons and interchange. */
toDocument() {
return this._model.toDocument();
}
/** Return the metadata used by CMap selectors and workspace state. */
toSummary() {
return {
slug: this.slug,
title: this.title,
currentVersion: this.currentVersion,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
author: this.author
};
}
}
/**
* Store and retrieve concept maps through the Racket Wiki backend.
* This is the only CMap model class that knows API routes and storage envelopes.
*/
export class CmapRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Return the lightweight CMap records used by selectors. */
async list() {
const result = await this.api("/api/cmaps");
return Array.isArray(result.conceptMaps) ? result.conceptMaps.map(copy) : [];
}
/** Load one current CMap and deserialize its complete domain model. */
async load(slug) {
const record = await this.api(`/api/cmaps/${encodeURIComponent(slug)}`);
return this.storedMap(record, slug);
}
/** Create a persisted CMap from a domain model. */
async create(title, model, slug = null) {
const body = { title: String(title).trim(), document: modelDocument(model) };
if (slug) body.slug = String(slug);
const record = await this.api("/api/cmaps", {
method: "POST",
body: JSON.stringify(body)
});
return this.storedMap(record, record.slug || slug);
}
/** Save a new model version and return the freshly versioned stored CMap. */
async save(storedMap, model, saveInformation = {}) {
if (!(storedMap instanceof StoredConceptMap)) {
throw new TypeError("Saving requires a StoredConceptMap");
}
const body = {
title: saveInformation.title || storedMap.title,
baseVersion: storedMap.currentVersion,
summary: saveInformation.summary || "",
saveKind: saveInformation.saveKind || "manual",
snapshot: Boolean(saveInformation.snapshot),
document: modelDocument(model)
};
const record = await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "PUT",
body: JSON.stringify(body)
});
return this.storedMap(record, storedMap.slug);
}
/** Rename a stored CMap using optimistic backend versioning. */
async rename(storedMap, title) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/rename`, {
method: "POST",
body: JSON.stringify({
title: String(title).trim(),
baseVersion: storedMap.currentVersion
})
});
return this.storedMap(record, storedMap.slug);
}
/** Archive a stored CMap after the workspace has obtained title confirmation. */
async archive(storedMap, confirmationTitle) {
await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "DELETE",
body: JSON.stringify({
confirmTitle: confirmationTitle,
baseVersion: storedMap.currentVersion
})
});
}
/** Return version summaries for one stored CMap. */
async history(storedMap) {
const result = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/history`);
return Array.isArray(result.versions) ? result.versions.map(copy) : [];
}
/** Load and deserialize one historical version of a CMap. */
async loadVersion(storedMap, version) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`);
return this.storedMap(record, storedMap.slug);
}
/** Delete one history version without changing the current CMap. */
async deleteVersion(storedMap, version) {
await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`,
{ method: "DELETE" });
}
/** Return the backend projection of concept placements across all CMaps. */
async conceptUsage() {
const result = await this.api("/api/cmaps/concept-usage");
return Array.isArray(result.placements) ? result.placements.map(copy) : [];
}
/** Convert one backend record to the public stored-map representation. */
storedMap(record, fallbackSlug = null) {
const slug = record?.slug || fallbackSlug;
const documentValue = decodedDocument(record?.document);
const normalized = normalizeConceptIdentities(documentValue, slug || "");
return new StoredConceptMap({ ...record, slug }, CmapModel.fromDocument(normalized));
}
}
+22 -2
View File
@@ -232,8 +232,25 @@ export class CmapModel {
return new CmapModel(repository, conceptMap);
}
/** Return a detached copy of the map metadata. */
metadata() {
return copy(this.conceptMap.metadata);
}
/** Return a detached derived-view reference, or null for an independent map. */
derivedView() {
return copy(this.conceptMap.derivedView);
}
/** Return a new model with changed map metadata and unchanged contents. */
withMetadata(metadata) {
const document = this.toDocument();
document.metadata = copy(metadata);
return CmapModel.fromDocument(document);
}
/**
* Split an embedded submap into a standalone map and its remaining parent.
* Split an embedded submap into standalone child and remaining parent models.
* The owner stays in the parent as an ordinary concept and links to targetSlug.
* Descendant placements become the complete contents of the child map.
*/
@@ -401,7 +418,10 @@ export class CmapModel {
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
else delete parentDocument.conceptOwnerships;
return { parentDocument, childDocument };
return {
parentModel: CmapModel.fromDocument(parentDocument),
childModel: CmapModel.fromDocument(childDocument)
};
}
toDocument() {
@@ -1,4 +1,4 @@
/* Versioned JSON interchange for Racket Wiki concept maps. */
/* Versioned JSON interchange for the Racket Wiki CMap model. */
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
+69
View File
@@ -0,0 +1,69 @@
import { buildBundle } from "./interchange.js";
/** Convert an ArrayBuffer to the base64 representation used in JSON bundles. */
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
/**
* Export a stored CMap model and its linked resources as a complete JSON bundle.
* CMaps are loaded through their repository. Page loading, bundle construction
* and attachment encoding remain independent of workspace presentation.
*/
export class CmapJsonExporter {
constructor(cmapRepository, loadWikiPage, fetchFile, generator = "Racket Wiki") {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof loadWikiPage !== "function" || typeof fetchFile !== "function") {
throw new TypeError("A CMap repository, wiki-page loader and file loader are required");
}
this.cmapRepository = cmapRepository;
this.loadWikiPage = loadWikiPage;
this.fetchFile = fetchFile;
this.generator = generator;
}
/**
* Build a validated JSON bundle without changing the source CMaps.
* Linked CMaps are followed up to maxDepth; linked pages and attachments
* are included by the interchange format.
*/
async export(rootMap, maxDepth = 0) {
return buildBundle({
rootMap: this.bundleMap(rootMap),
maxDepth,
generator: this.generator,
loadConceptMap: async (slug) => this.bundleMap(await this.cmapRepository.load(slug)),
loadWikiPage: this.loadWikiPage,
loadAttachment: (url) => this.loadAttachment(url)
});
}
/** Present one stored model through the public interchange record shape. */
bundleMap(storedMap) {
if (!storedMap?.slug || typeof storedMap.toDocument !== "function") {
throw new TypeError("JSON export requires a stored CMap");
}
return {
slug: storedMap.slug,
title: storedMap.title,
document: storedMap.toDocument()
};
}
/** Load and encode one attachment referenced by an exported wiki page. */
async loadAttachment(url) {
const response = await this.fetchFile(url);
if (!response.ok) throw new Error(`Attachment could not be exported: ${url}`);
const content = await response.arrayBuffer();
return {
mimeType: response.headers.get("content-type") || "application/octet-stream",
contentBase64: arrayBufferToBase64(content)
};
}
}
+166
View File
@@ -0,0 +1,166 @@
import {
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
} from "./interchange.js";
import { CmapModel } from "./concept-map.js";
const MAXIMUM_FILE_SIZE = 256 * 1024 * 1024;
const MAXIMUM_ATTACHMENT_SIZE = 50 * 1024 * 1024;
/** Decode one bundle attachment as a Blob accepted by the upload API. */
function attachmentBlob(attachment) {
const binary = atob(String(attachment.contentBase64 || ""));
if (binary.length > MAXIMUM_ATTACHMENT_SIZE) {
throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`);
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], {
type: attachment.mimeType || "application/octet-stream"
});
}
/**
* Import a validated JSON bundle through the CMap repository and page API.
* Conflict policy is supplied by the workspace; this class performs only the
* deterministic page, attachment and CMap writes and reports their counts.
*/
export class CmapJsonImporter {
constructor(cmapRepository, pageApi, translate) {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof pageApi !== "function" || typeof translate !== "function") {
throw new TypeError("A CMap repository, page API and translator are required");
}
this.cmapRepository = cmapRepository;
this.pageApi = pageApi;
this.translate = translate;
}
/** Parse and validate a selected JSON file without writing wiki data. */
async read(file) {
if (file.size > MAXIMUM_FILE_SIZE) {
throw new Error(this.translate(
"cmap-json-too-large", "The CMap JSON file exceeds 256 MiB."));
}
const bundle = JSON.parse(await file.text());
return validateBundle(bundle);
}
/** Return the pages and CMaps whose slugs already occur in the wiki. */
conflicts(bundle, pages, conceptMaps) {
const existingPages = new Map(pages.map((page) => [page.slug, page]));
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
return {
pages: bundle.pages.filter((page) => existingPages.has(page.reference)),
conceptMaps: bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug))
};
}
/**
* Write all accepted bundle records and return created, updated and skipped counts.
* Existing records are replaced only when replaceExisting is true.
*/
async import(bundle, options = {}) {
validateBundle(bundle);
const pages = Array.isArray(options.pages) ? options.pages : [];
const conceptMaps = Array.isArray(options.conceptMaps) ? options.conceptMaps : [];
const replaceExisting = Boolean(options.replaceExisting);
const summary = String(options.summary || "Imported from CMap JSON");
const existingPages = new Map(pages.map((page) => [page.slug, page]));
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
const result = {
mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0,
pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0,
attachmentsImported: 0
};
for (const page of bundle.pages) {
const existing = existingPages.get(page.reference);
if (existing && !replaceExisting) {
result.pagesSkipped += 1;
continue;
}
await this.importPage(page, existing, summary);
if (existing) result.pagesUpdated += 1;
else result.pagesCreated += 1;
result.attachmentsImported += Array.isArray(page.attachments) ?
page.attachments.length : 0;
}
for (const cmap of bundle.cmaps) {
const existing = existingMaps.get(cmap.slug);
if (existing && !replaceExisting) {
result.mapsSkipped += 1;
continue;
}
await this.importConceptMap(bundle, cmap, existing, summary);
if (existing) result.mapsUpdated += 1;
else result.mapsCreated += 1;
}
return result;
}
/** Store one page and upload the attachments embedded in its Markdown. */
async importPage(page, existing, summary) {
const attachments = Array.isArray(page.attachments) ? page.attachments : [];
const body = {
slug: page.reference,
title: page.title,
markdown: page.markdown,
tags: page.tags,
summary
};
if (existing) {
body.markdown = await this.importAttachments(page);
body.baseVersion = existing.currentVersion;
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
return;
}
const created = await this.pageApi("/api/pages", {
method: "POST", body: JSON.stringify(body)
});
if (!attachments.length) return;
body.markdown = await this.importAttachments(page);
body.baseVersion = created.currentVersion;
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
}
/** Upload one page's attachments and rewrite their Markdown URLs. */
async importAttachments(page) {
const replacements = new Map();
for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) {
const uploaded = await this.pageApi(
`/api/pages/${encodeURIComponent(page.reference)}/upload`, {
method: "POST",
headers: { "X-File-Name": attachment.name },
body: attachmentBlob(attachment)
});
replacements.set(attachment.url, uploaded.url);
}
return replaceAttachmentUrls(page.markdown, replacements);
}
/** Store one CMap after rejoining shared concepts and placements. */
async importConceptMap(bundle, cmap, existing, summary) {
const documentValue = preparedMapDocument(bundle, cmap);
const model = CmapModel.fromDocument(documentValue);
if (existing) {
const storedMap = await this.cmapRepository.load(cmap.slug);
await this.cmapRepository.save(storedMap, model, {
title: cmap.title,
saveKind: "manual",
summary
});
return;
}
await this.cmapRepository.create(cmap.title, model, cmap.slug);
}
}
@@ -1,10 +1,4 @@
/* Build a self-contained Markdown report from one stored CMap and its links. */
((root, factory) => {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.RacketWikiCmapExport = api;
})(typeof window !== "undefined" ? window : globalThis, () => {
"use strict";
/* Build a self-contained Markdown report from one stored CMap model and its links. */
const labels = {
en: {
@@ -329,5 +323,42 @@
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
}
return { generateMarkdown, decodedDocument, derivedDocument, relationLines };
});
/**
* Export a CMap and optionally its linked maps and wiki pages as Markdown.
* The exporter reads stored models through their repository and has no
* knowledge of dialogs, downloads or other browser presentation.
*/
export class CmapMarkdownExporter {
constructor(cmapRepository, loadWikiPage) {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof loadWikiPage !== "function") {
throw new TypeError("A CMap repository and wiki-page loader are required");
}
this.cmapRepository = cmapRepository;
this.loadWikiPage = loadWikiPage;
}
/** Build the complete Markdown report without changing source data. */
async export(rootMap, options = {}) {
return generateMarkdown({
rootMap: this.exportMap(rootMap),
maxDepth: options.maxDepth,
includeWikiPages: options.includeWikiPages,
language: options.language,
loadConceptMap: async (slug) => this.exportMap(await this.cmapRepository.load(slug)),
loadWikiPage: this.loadWikiPage
});
}
/** Present one stored model through the record shape consumed by the report builder. */
exportMap(storedMap) {
if (!storedMap?.slug || typeof storedMap.toDocument !== "function") {
throw new TypeError("Markdown export requires a stored CMap");
}
return {
slug: storedMap.slug,
title: storedMap.title,
document: storedMap.toDocument()
};
}
}
+25
View File
@@ -0,0 +1,25 @@
/** Persist the people referenced by CMap concept tags through the wiki API. */
export class PeopleRepository {
constructor(api) {
this.api = api;
}
async all() {
const result = await this.api("/api/people");
return Array.isArray(result.people) ? result.people : [];
}
create(name) {
return this.api("/api/people", {
method: "POST",
body: JSON.stringify({ name })
});
}
update(person, active) {
return this.api(`/api/people/${person.id}`, {
method: "PUT",
body: JSON.stringify({ name: person.name, active })
});
}
}
+67
View File
@@ -0,0 +1,67 @@
/** Keep one context address unambiguous inside the in-memory lookup table. */
function zoomKey(cmapSlug, contextKey) {
return `${cmapSlug}\u0000${contextKey}`;
}
/**
* Represent wiki and user settings for the CMap workspace.
* The backend is authoritative; this object only caches the active session state.
*/
export class CmapSettingsRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
this.loaded = false;
this.startCmapSlug = "";
this.pageGuidesVisible = true;
this.zoomLevels = new Map();
}
async load() {
const result = await this.api("/api/cmap-settings");
this.startCmapSlug = typeof result.startCmapSlug === "string" ? result.startCmapSlug : "";
this.pageGuidesVisible = result.pageGuidesVisible !== false;
this.zoomLevels.clear();
for (const entry of (Array.isArray(result.zooms) ? result.zooms : [])) {
const zoom = Number(entry.zoomPercent);
if (entry.cmapSlug && entry.contextKey && zoom >= 25 && zoom <= 300) {
this.zoomLevels.set(zoomKey(entry.cmapSlug, entry.contextKey), zoom);
}
}
this.loaded = true;
return this;
}
zoom(cmapSlug, contextKey) {
return this.zoomLevels.get(zoomKey(cmapSlug, contextKey)) || 100;
}
async setStartCmap(slug) {
const result = await this.api("/api/cmap-settings/start", {
method: "PUT",
body: JSON.stringify({ startCmapSlug: slug || "" })
});
this.startCmapSlug = result.startCmapSlug || "";
return this.startCmapSlug;
}
async setPageGuidesVisible(visible) {
const result = await this.api("/api/cmap-settings/page-guides", {
method: "PUT",
body: JSON.stringify({ pageGuidesVisible: Boolean(visible) })
});
this.pageGuidesVisible = result.pageGuidesVisible !== false;
return this.pageGuidesVisible;
}
async setZoom(cmapSlug, contextKey, zoomPercent) {
if (!cmapSlug) return zoomPercent;
const result = await this.api("/api/cmap-settings/zoom", {
method: "PUT",
body: JSON.stringify({ cmapSlug, contextKey, zoomPercent })
});
const storedZoom = Number(result.zoomPercent);
this.zoomLevels.set(zoomKey(cmapSlug, contextKey), storedZoom);
return storedZoom;
}
}
+398
View File
@@ -0,0 +1,398 @@
import {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize
} from "../model/appearance.js";
/**
* Present and edit CMap appearance in the concept dialog.
* The editor translates DOM changes to CmapAppearance operations and asks the
* repository to persist the complete aggregate after style or palette changes.
*/
export class CmapAppearanceEditor {
constructor(appearance, repository, tr) {
this.appearance = appearance;
this.repository = repository;
this.tr = tr;
this.$ = (id) => document.getElementById(id);
this.installColorPickers();
this.installStyleControls();
}
/** Fill appearance fields from one existing concept placement. */
showRecord(record) {
const fallback = this.appearance.defaultValues;
const titleFontSize = cmapFontSizeInPoints(record.fontSize, fallback.fontSize);
this.$("cmap-concept-background-label").textContent = record.kind === "submap" ?
this.tr("main-concept-background-color", "Main concept background color") :
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
this.apply({
backgroundColor: cmapColorValue(record.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(record.textColor, fallback.textColor),
fontFamily: record.fontFamily || fallback.fontFamily,
fontSize: titleFontSize,
fontWeight: String(record.fontWeight || fallback.fontWeight) === "400" ? "400" : "700",
fontStyle: record.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
record.synopsisTextColor || record.textColor, fallback.synopsisTextColor),
synopsisFontFamily: record.synopsisFontFamily || record.fontFamily || fallback.synopsisFontFamily,
synopsisFontSize: cmapFontSizeInPoints(
record.synopsisFontSize || "0.84em", titleFontSize),
synopsisFontWeight: String(
record.synopsisFontWeight || record.fontWeight || fallback.synopsisFontWeight) === "700" ?
"700" : "400",
synopsisFontStyle: (record.synopsisFontStyle || record.fontStyle) === "italic" ?
"italic" : "normal",
submapBackgroundColor: cmapColorValue(
record.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(record.submapBorderColor, fallback.submapBorderColor)
});
this.renderStyleOptions();
}
/** Fill appearance fields for a new ordinary concept using the model default. */
showNewConcept() {
this.$("cmap-concept-background-label").textContent =
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.add("hidden");
this.apply(this.appearance.defaultValues);
this.renderStyleOptions();
}
/** Return normalized placement values from the appearance form. */
placementChanges(isSubmap) {
const values = this.capture();
const changes = {
backgroundColor: values.backgroundColor,
textColor: values.textColor,
fontFamily: values.fontFamily,
fontSize: `${values.fontSize}pt`,
fontWeight: values.fontWeight,
fontStyle: values.fontStyle,
synopsisTextColor: values.synopsisTextColor,
synopsisFontFamily: values.synopsisFontFamily,
synopsisFontSize: `${values.synopsisFontSize}pt`,
synopsisFontWeight: values.synopsisFontWeight,
synopsisFontStyle: values.synopsisFontStyle
};
if (isSubmap) {
changes.submapBackgroundColor = values.submapBackgroundColor;
changes.submapBorderColor = values.submapBorderColor;
}
return changes;
}
capture() {
return this.appearance.normalizeValues({
backgroundColor: this.$("cmap-concept-background").value,
textColor: this.$("cmap-concept-text-color").value,
fontFamily: this.$("cmap-concept-font-family").value,
fontSize: this.$("cmap-concept-font-size").value,
fontWeight: this.$("cmap-concept-bold").checked ? "700" : "400",
fontStyle: this.$("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: this.$("cmap-concept-synopsis-text-color").value,
synopsisFontFamily: this.$("cmap-concept-synopsis-font-family").value,
synopsisFontSize: this.$("cmap-concept-synopsis-font-size").value,
synopsisFontWeight: this.$("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: this.$("cmap-concept-synopsis-italic").checked ? "italic" : "normal",
submapBackgroundColor: this.$("cmap-submap-background").value,
submapBorderColor: this.$("cmap-submap-border").value
});
}
apply(values) {
const style = this.appearance.normalizeValues(values);
if (!style) return;
this.$("cmap-concept-background").value = style.backgroundColor;
this.$("cmap-concept-text-color").value = style.textColor;
this.selectFont(style.fontFamily);
this.$("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize);
this.$("cmap-concept-bold").checked = style.fontWeight === "700";
this.$("cmap-concept-italic").checked = style.fontStyle === "italic";
this.$("cmap-concept-synopsis-text-color").value = style.synopsisTextColor;
this.selectFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family");
this.$("cmap-concept-synopsis-font-size").value =
displayCmapFontSize(style.synopsisFontSize);
this.$("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700";
this.$("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic";
this.$("cmap-submap-background").value = style.submapBackgroundColor;
this.$("cmap-submap-border").value = style.submapBorderColor;
this.updateColorControls();
}
selectFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = this.$(selectId);
const value = fontFamily || this.appearance.defaultValues.fontFamily;
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
styleName(style) {
return style.nameKey ? this.tr(style.nameKey, style.nameKey) : style.name;
}
renderStyleOptions(selectedId = this.appearance.matchingStyleId(this.capture())) {
const styles = this.appearance.styles;
const usableId = styles.some((style) => style.id === selectedId) ? selectedId : "";
for (const select of [
this.$("cmap-concept-quick-style"),
this.$("cmap-concept-style-preset")
]) {
select.replaceChildren();
const custom = document.createElement("option");
custom.value = "";
custom.textContent = this.tr("custom-style", "Custom");
select.append(custom);
for (const style of styles) {
const option = document.createElement("option");
option.value = style.id;
option.textContent = this.styleName(style);
select.append(option);
}
select.value = usableId;
}
this.updateDeleteButton();
}
syncStyleSelection() {
const matchingId = this.appearance.matchingStyleId(this.capture());
this.$("cmap-concept-quick-style").value = matchingId;
this.$("cmap-concept-style-preset").value = matchingId;
this.updateDeleteButton();
}
updateDeleteButton() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
this.$("cmap-delete-style").disabled = !selected || selected.protected;
}
applySelectedStyle(event) {
const selectedId = event?.currentTarget?.value ??
this.$("cmap-concept-style-preset").value;
this.$("cmap-concept-quick-style").value = selectedId;
this.$("cmap-concept-style-preset").value = selectedId;
const style = this.appearance.style(selectedId);
if (style) this.apply(style.values);
this.updateDeleteButton();
}
newStyleId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return `custom-${window.crypto.randomUUID()}`;
}
} catch (_error) {
// A timestamp remains sufficient when randomUUID is unavailable.
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async saveCurrentStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
const proposedName = selected && !selected.nameKey ? selected.name : "";
const name = window.prompt(this.tr("style-name-prompt", "Name for this style"), proposedName);
if (name === null) return;
const cleanName = name.trim();
if (!cleanName) {
window.alert(this.tr("style-name-required", "Enter a style name."));
return;
}
const existing = this.appearance.styles.find((style) =>
this.styleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase());
if (existing?.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (existing && !window.confirm(this.tr(
"replace-style-confirm", 'Replace the existing style "{name}"?')
.replace("{name}", this.styleName(existing)))) return;
const previousAppearance = this.appearance.toData();
const replacement = this.appearance.putStyle({
id: existing?.id || this.newStyleId(),
name: cleanName,
values: this.capture()
});
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions(replacement.id);
}
async deleteSelectedStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
if (!selected) return;
if (selected.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (!window.confirm(this.tr(
"delete-style-confirm", 'Delete style "{name}"?')
.replace("{name}", this.styleName(selected)))) return;
const previousAppearance = this.appearance.toData();
this.appearance.deleteStyle(selected.id);
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions();
}
async store() {
try {
await this.repository.save(this.appearance);
return true;
} catch (error) {
console.warn("The CMap appearance could not be stored in the database.", error);
window.alert(this.tr(
"cmap-appearance-storage-failed",
"The CMap appearance could not be stored in the wiki database."));
return false;
}
}
openNativeColorPicker(initialColor, onInput, onCommit = null) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => {
if (onCommit) onCommit(cmapColorValue(picker.value, "#ffffff"));
window.setTimeout(cleanup, 0);
}, { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
updateColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
updateColorControls() {
for (const input of this.$("cmap-concept-panel-appearance").querySelectorAll(
".cmap-color-input")) {
this.updateColorControl(input);
}
}
updatePaletteChoices(index, color) {
for (const choice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
}
installColorPickers() {
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = this.tr(
"color-swatch-help", "Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
this.appearance.palette.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
this.updatePaletteChoice(choice, color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = this.appearance.palette[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
const previousAppearance = this.appearance.toData();
const updateColor = (newColor) => {
this.appearance.setPaletteColor(index, newColor);
this.updatePaletteChoices(index, newColor);
};
this.openNativeColorPicker(this.appearance.palette[index], updateColor, async () => {
if (!await this.store()) {
this.appearance.replace(previousAppearance);
this.updatePaletteChoices(index, previousAppearance.palette[index]);
}
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
this.openNativeColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => this.updateColorControl(input));
this.updateColorControl(input);
}
document.addEventListener("pointerdown", (event) => {
if (event.target.closest(".cmap-color-control")) return;
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
});
}
updatePaletteChoice(choice, color) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
installStyleControls() {
this.$("cmap-concept-quick-style").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-concept-style-preset").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-save-style").addEventListener("click", () => this.saveCurrentStyle());
this.$("cmap-delete-style").addEventListener("click", () => this.deleteSelectedStyle());
for (const eventName of ["input", "change"]) {
this.$("cmap-concept-panel-appearance").addEventListener(eventName, (event) => {
if (!event.target.closest(".cmap-style-manager")) this.syncStyleSelection();
});
}
}
}
+11 -5
View File
@@ -398,8 +398,8 @@
<form id="cmap-concept-form" novalidate>
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
<div class="cmap-concept-tabs" role="tablist" aria-label="Concept properties">
<button id="cmap-concept-tab-content" type="button" role="tab" data-cmap-concept-tab="content" aria-controls="cmap-concept-panel-content" aria-selected="true" data-tr="content-and-links">Content &amp; links</button>
<button id="cmap-concept-tab-appearance" type="button" role="tab" data-cmap-concept-tab="appearance" aria-controls="cmap-concept-panel-appearance" aria-selected="false" data-tr="specific-appearance">Specific appearance</button>
<button id="cmap-concept-tab-content" type="button" role="tab" data-tab="content" aria-controls="cmap-concept-panel-content" aria-selected="true" data-tr="content-and-links">Content &amp; links</button>
<button id="cmap-concept-tab-appearance" type="button" role="tab" data-tab="appearance" aria-controls="cmap-concept-panel-appearance" aria-selected="false" tabindex="-1" data-tr="specific-appearance">Specific appearance</button>
</div>
<div class="cmap-concept-dialog-body">
<section id="cmap-concept-panel-content" class="cmap-concept-panel" role="tabpanel" aria-labelledby="cmap-concept-tab-content">
@@ -631,9 +631,7 @@
<script src="/vendor/lucide.min.js"></script>
<script src="/vendor/diff2html-ui-base.min.js"></script>
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/cmap-export.js?id=__CMAP_CACHE_ID__"></script>
<script type="importmap">
{
"imports": {
@@ -641,7 +639,15 @@
"/cmap/cmap-view.js": "/cmap/cmap-view.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__",
"/js/wiki/cmap/interchange.js": "/js/wiki/cmap/interchange.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__",
"/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__",
-202
View File
@@ -1,202 +0,0 @@
/* Accessible, dependency-free combobox used by racket-wiki. */
(() => {
"use strict";
class RacketWikiComboBox {
constructor(root) {
this.root = root;
this.input = root.querySelector("input");
this.button = root.querySelector("button");
this.list = root.querySelector('[role="listbox"]');
this.options = [];
this.filteredOptions = [];
this.selectedValue = "";
this.activeIndex = -1;
this.selectionHandlers = [];
this.input.setAttribute("aria-controls", this.list.id);
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("autocomplete", "off");
this.input.addEventListener("input", () => {
this.selectedValue = "";
this.input.setCustomValidity("");
this.open();
this.renderOptions(false);
});
this.input.addEventListener("focus", () => {
this.open();
this.renderOptions(true);
});
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
this.input.addEventListener("blur", () => {
window.setTimeout(() => this.close(), 100);
});
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
this.button.addEventListener("click", () => {
if (this.isOpen()) {
this.close();
} else {
this.open();
this.renderOptions(true);
this.input.focus({ preventScroll: true });
}
});
}
setOptions(options, selectedValue = "") {
this.options = options.map((option) => ({
value: String(option.value),
label: String(option.label),
description: String(option.description || "")
}));
const selected = this.options.find((option) => option.value === selectedValue) || null;
this.selectedValue = selected ? selected.value : "";
this.input.value = selected ? selected.label : "";
this.activeIndex = -1;
this.renderOptions();
}
value() {
const text = this.input.value.trim().toLocaleLowerCase();
if (!text) {
this.selectedValue = "";
return "";
}
if (this.selectedValue) {
const selected = this.options.find((option) => option.value === this.selectedValue);
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
}
const selected = this.options.find((option) =>
option.label.toLocaleLowerCase() === text ||
option.value.toLocaleLowerCase() === text) || null;
this.selectedValue = selected ? selected.value : "";
return selected ? selected.value : null;
}
clear() {
this.selectedValue = "";
this.input.value = "";
this.input.setCustomValidity("");
this.activeIndex = -1;
this.renderOptions();
}
onSelect(handler) {
this.selectionHandlers.push(handler);
return this;
}
isOpen() {
return !this.list.classList.contains("hidden");
}
open() {
this.list.classList.remove("hidden");
this.input.setAttribute("aria-expanded", "true");
this.button.setAttribute("aria-expanded", "true");
}
close() {
this.list.classList.add("hidden");
this.input.setAttribute("aria-expanded", "false");
this.button.setAttribute("aria-expanded", "false");
this.activeIndex = -1;
this.input.removeAttribute("aria-activedescendant");
}
renderOptions(showAll = false) {
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
this.filteredOptions = this.options.filter((option) =>
!query || option.label.toLocaleLowerCase().includes(query) ||
option.description.toLocaleLowerCase().includes(query) ||
option.value.toLocaleLowerCase().includes(query));
this.list.replaceChildren();
for (const [index, option] of this.filteredOptions.entries()) {
const item = document.createElement("div");
item.id = `${this.list.id}-option-${index}`;
item.className = "wiki-combobox-option";
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
item.dataset.index = String(index);
const label = document.createElement("span");
label.className = "wiki-combobox-option-label";
label.textContent = option.label;
item.append(label);
if (option.description && option.description !== option.label) {
const description = document.createElement("span");
description.className = "wiki-combobox-option-description";
description.textContent = option.description;
item.append(description);
}
item.addEventListener("pointerdown", (event) => {
event.preventDefault();
this.selectOption(option);
});
this.list.append(item);
}
this.updateActiveOption();
}
selectOption(option) {
this.selectedValue = option.value;
this.input.value = option.label;
this.input.setCustomValidity("");
this.close();
for (const handler of this.selectionHandlers) handler(option.value, option);
this.input.dispatchEvent(new Event("change", { bubbles: true }));
}
handleKeydown(event) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!this.isOpen()) {
this.open();
this.renderOptions();
}
const direction = event.key === "ArrowDown" ? 1 : -1;
const maximum = this.filteredOptions.length - 1;
if (maximum < 0) return;
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
this.updateActiveOption();
return;
}
if (event.key === "Enter" && this.isOpen()) {
const exactText = this.input.value.trim().toLocaleLowerCase();
const exact = this.filteredOptions.find((option) =>
option.label.toLocaleLowerCase() === exactText ||
option.value.toLocaleLowerCase() === exactText) || null;
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
if (selected) {
event.preventDefault();
this.selectOption(selected);
return;
}
}
if (event.key === "Escape") {
event.preventDefault();
this.close();
}
}
updateActiveOption() {
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
for (const [index, element] of elements.entries()) {
element.classList.toggle("active", index === this.activeIndex);
}
const active = elements[this.activeIndex];
if (active) {
this.input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
} else {
this.input.removeAttribute("aria-activedescendant");
}
}
}
window.RacketWikiComboBox = RacketWikiComboBox;
})();
+18
View File
@@ -0,0 +1,18 @@
# Wiki widgets
This directory contains the small reusable interaction widgets used by the
wiki. A widget manages an existing DOM structure and its browser interaction;
it does not own wiki or CMap domain rules.
- `ComboBox` filters labelled options and returns their stable values.
- `PopupMenu` positions and dismisses an action menu and provides keyboard
navigation.
- `TabSet` coordinates tabs, panels and keyboard focus.
- `Tooltip` provides reusable tooltip visibility and placement.
- `DescriptionPreview` composes `Tooltip` with asynchronous wiki-page loading.
- `StatusField` presents persistent or temporary status messages.
Native buttons, inputs, selects and dialogs remain native elements. They do
not get wrapper classes until the application has reusable behaviour for such
a class to own. Functional dialog controllers can therefore be extracted
separately without putting CMap or wiki rules in a generic widget.
+209
View File
@@ -0,0 +1,209 @@
/**
* Manage an accessible editable combobox backed by an in-page option list.
*
* The existing DOM supplies the input, toggle button and listbox. This widget
* owns filtering, keyboard navigation and conversion from displayed labels to
* stable option values.
*/
export class ComboBox {
constructor(root) {
this.root = root;
this.input = root.querySelector('input[role="combobox"]');
this.button = root.querySelector("button");
this.list = root.querySelector('[role="listbox"]');
this.options = [];
this.filteredOptions = [];
this.selectedValue = "";
this.activeIndex = -1;
this.selectionHandlers = [];
if (!this.input || !this.button || !this.list) {
throw new Error("A ComboBox requires an input, toggle button and listbox.");
}
this.input.setAttribute("aria-controls", this.list.id);
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("autocomplete", "off");
this.button.setAttribute("aria-expanded", "false");
this.input.addEventListener("input", () => {
this.selectedValue = "";
this.input.setCustomValidity("");
this.open();
this.renderOptions(false);
});
this.input.addEventListener("focus", () => {
this.open();
this.renderOptions(true);
});
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
this.input.addEventListener("blur", () => {
window.setTimeout(() => this.close(), 100);
});
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
this.button.addEventListener("click", () => {
if (this.isOpen()) {
this.close();
} else {
this.open();
this.renderOptions(true);
this.input.focus({ preventScroll: true });
}
});
}
/** Replace the option collection and optionally select one stable value. */
setOptions(options, selectedValue = "") {
this.options = options.map((option) => ({
value: String(option.value),
label: String(option.label),
description: String(option.description || "")
}));
const selected = this.options.find((option) => option.value === selectedValue) || null;
this.selectedValue = selected ? selected.value : "";
this.input.value = selected ? selected.label : "";
this.activeIndex = -1;
this.renderOptions();
}
/** Return the selected stable value, an empty value, or null for invalid free text. */
value() {
const text = this.input.value.trim().toLocaleLowerCase();
if (!text) {
this.selectedValue = "";
return "";
}
if (this.selectedValue) {
const selected = this.options.find((option) => option.value === this.selectedValue);
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
}
const selected = this.options.find((option) =>
option.label.toLocaleLowerCase() === text ||
option.value.toLocaleLowerCase() === text) || null;
this.selectedValue = selected ? selected.value : "";
return selected ? selected.value : null;
}
clear() {
this.selectedValue = "";
this.input.value = "";
this.input.setCustomValidity("");
this.activeIndex = -1;
this.renderOptions();
}
onSelect(handler) {
this.selectionHandlers.push(handler);
return this;
}
isOpen() {
return !this.list.classList.contains("hidden");
}
open() {
this.list.classList.remove("hidden");
this.input.setAttribute("aria-expanded", "true");
this.button.setAttribute("aria-expanded", "true");
}
close() {
this.list.classList.add("hidden");
this.input.setAttribute("aria-expanded", "false");
this.button.setAttribute("aria-expanded", "false");
this.activeIndex = -1;
this.input.removeAttribute("aria-activedescendant");
}
renderOptions(showAll = false) {
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
this.filteredOptions = this.options.filter((option) =>
!query || option.label.toLocaleLowerCase().includes(query) ||
option.description.toLocaleLowerCase().includes(query) ||
option.value.toLocaleLowerCase().includes(query));
this.list.replaceChildren();
for (const [index, option] of this.filteredOptions.entries()) {
const item = document.createElement("div");
item.id = `${this.list.id}-option-${index}`;
item.className = "wiki-combobox-option";
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
item.dataset.index = String(index);
const label = document.createElement("span");
label.className = "wiki-combobox-option-label";
label.textContent = option.label;
item.append(label);
if (option.description && option.description !== option.label) {
const description = document.createElement("span");
description.className = "wiki-combobox-option-description";
description.textContent = option.description;
item.append(description);
}
item.addEventListener("pointerdown", (event) => {
event.preventDefault();
this.selectOption(option);
});
this.list.append(item);
}
this.updateActiveOption();
}
selectOption(option) {
this.selectedValue = option.value;
this.input.value = option.label;
this.input.setCustomValidity("");
this.close();
for (const handler of this.selectionHandlers) handler(option.value, option);
this.input.dispatchEvent(new Event("change", { bubbles: true }));
}
handleKeydown(event) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!this.isOpen()) {
this.open();
this.renderOptions();
}
const direction = event.key === "ArrowDown" ? 1 : -1;
const maximum = this.filteredOptions.length - 1;
if (maximum < 0) return;
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
this.updateActiveOption();
return;
}
if (event.key === "Enter" && this.isOpen()) {
const exactText = this.input.value.trim().toLocaleLowerCase();
const exact = this.filteredOptions.find((option) =>
option.label.toLocaleLowerCase() === exactText ||
option.value.toLocaleLowerCase() === exactText) || null;
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
if (selected) {
event.preventDefault();
this.selectOption(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
this.close();
}
}
updateActiveOption() {
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
for (const [index, element] of elements.entries()) {
element.classList.toggle("active", index === this.activeIndex);
}
const active = elements[this.activeIndex];
if (active) {
this.input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
} else {
this.input.removeAttribute("aria-activedescendant");
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import { Tooltip } from "./tooltip.js";
/**
* Load and display rendered wiki-page content as a concept description.
*
* DescriptionPreview caches rendered pages for the lifetime of the workspace.
* A sequence token prevents a slow request from replacing a newer preview.
*/
export class DescriptionPreview {
constructor(loadPage, renderMarkdown) {
const element = document.createElement("div");
element.id = "cmap-description-tooltip";
element.className = "cmap-description-tooltip hidden";
element.setAttribute("role", "tooltip");
document.body.append(element);
this.tooltip = new Tooltip(element);
this.loadPage = loadPage;
this.renderMarkdown = renderMarkdown;
this.cache = new Map();
this.sequence = 0;
}
hide() {
this.sequence += 1;
this.tooltip.hide();
}
clear() {
this.cache.clear();
this.hide();
}
/** Load a referenced wiki page and show it beside the supplied anchor. */
async show(anchor, reference, loadingText = "Loading…") {
if (!anchor.classList.contains("is-filled") || !reference) return;
const sequence = ++this.sequence;
this.tooltip.show(anchor);
this.tooltip.setText(loadingText);
try {
let preview = this.cache.get(reference);
if (preview === undefined) {
const page = await this.loadPage(reference);
preview = String(page.markdown || "").trim() ?
this.renderMarkdown(page.markdown, page.slug) : null;
this.cache.set(reference, preview);
}
if (sequence !== this.sequence) return;
if (!preview) {
anchor.classList.remove("is-filled");
anchor.classList.add("is-empty");
this.tooltip.hide();
return;
}
this.tooltip.setHtml(`<article class="markdown-body">${preview}</article>`);
} catch (error) {
if (sequence !== this.sequence) return;
anchor.classList.remove("is-filled");
anchor.classList.add("is-empty");
this.tooltip.hide();
if (error.status !== 404) console.error(error);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Present an existing menu at a viewport position.
*
* PopupMenu owns placement, dismissal and keyboard movement. The application
* remains responsible for menu actions and for enabling individual items.
*/
export class PopupMenu {
constructor(element, trigger = null) {
this.element = element;
this.trigger = trigger;
if (this.trigger) {
this.trigger.setAttribute("aria-haspopup", "menu");
this.trigger.setAttribute("aria-expanded", "false");
}
this.element.addEventListener("click", (event) => {
if (event.target.closest('[role^="menuitem"]')) this.close();
});
this.element.addEventListener("keydown", (event) => this.handleKeydown(event));
document.addEventListener("pointerdown", (event) => {
if (!this.isOpen()) return;
if (this.element.contains(event.target)) return;
if (this.trigger && this.trigger.contains(event.target)) return;
this.close();
});
}
isOpen() {
return !this.element.classList.contains("hidden");
}
/** Open the menu at client coordinates and keep it inside the viewport. */
openAt(clientX, clientY) {
if (this.element.parentElement !== document.body) document.body.append(this.element);
this.element.classList.remove("hidden");
const left = Math.max(8, Math.min(clientX, window.innerWidth - this.element.offsetWidth - 8));
const top = Math.max(8, Math.min(clientY, window.innerHeight - this.element.offsetHeight - 8));
this.element.style.left = `${left}px`;
this.element.style.top = `${top}px`;
if (this.trigger) this.trigger.setAttribute("aria-expanded", "true");
const first = this.items()[0];
if (first) first.focus({ preventScroll: true });
}
close() {
const returnFocus = this.element.contains(document.activeElement);
this.element.classList.add("hidden");
if (this.trigger) this.trigger.setAttribute("aria-expanded", "false");
if (returnFocus && this.trigger) this.trigger.focus({ preventScroll: true });
}
items() {
return Array.from(this.element.querySelectorAll('[role^="menuitem"]'))
.filter((item) => !item.disabled && !item.classList.contains("hidden"));
}
handleKeydown(event) {
const items = this.items();
const current = items.indexOf(document.activeElement);
let next = null;
if (event.key === "ArrowDown") next = current < items.length - 1 ? current + 1 : 0;
if (event.key === "ArrowUp") next = current > 0 ? current - 1 : items.length - 1;
if (event.key === "Home") next = 0;
if (event.key === "End") next = items.length - 1;
if (event.key === "Escape") {
event.preventDefault();
this.close();
return;
}
if (next === null || !items[next]) return;
event.preventDefault();
items[next].focus({ preventScroll: true });
}
}
+27
View File
@@ -0,0 +1,27 @@
/** Manage a status element and optional automatic clearing of its message. */
export class StatusField {
constructor(element, visibleClass = "") {
this.element = element;
this.visibleClass = visibleClass;
this.timer = null;
}
set(message) {
window.clearTimeout(this.timer);
this.timer = null;
this.element.textContent = message || "";
if (this.visibleClass) {
this.element.classList.toggle(this.visibleClass, Boolean(message));
}
}
showTemporarily(message, duration = 1800) {
this.set(message);
if (!message) return;
this.timer = window.setTimeout(() => this.clear(), duration);
}
clear() {
this.set("");
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Coordinate tabs and their aria-controls panels inside one tab list.
*
* Tabs identify their logical name with data-tab. Selecting a tab updates the
* ARIA state, panel visibility and roving keyboard focus.
*/
export class TabSet {
constructor(element) {
this.element = element;
this.tabs = Array.from(element.querySelectorAll('[role="tab"]'));
this.selectionHandlers = [];
this.element.addEventListener("click", (event) => {
const tab = event.target.closest('[role="tab"]');
if (tab && this.element.contains(tab)) this.select(tab.dataset.tab);
});
this.element.addEventListener("keydown", (event) => this.handleKeydown(event));
}
/** Select a named tab and show the panel named by its aria-controls value. */
select(name) {
for (const tab of this.tabs) {
const selected = tab.dataset.tab === name;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
const panel = document.getElementById(tab.getAttribute("aria-controls"));
if (panel) panel.classList.toggle("hidden", !selected);
}
for (const handler of this.selectionHandlers) handler(name);
}
onSelect(handler) {
this.selectionHandlers.push(handler);
return this;
}
handleKeydown(event) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
const current = this.tabs.indexOf(event.target.closest('[role="tab"]'));
if (current < 0) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? this.tabs.length - 1 :
(current + (event.key === "ArrowRight" ? 1 : -1) + this.tabs.length) % this.tabs.length;
this.select(this.tabs[next].dataset.tab);
this.tabs[next].focus();
}
}
+42
View File
@@ -0,0 +1,42 @@
/** Manage the content, visibility and viewport placement of one tooltip. */
export class Tooltip {
constructor(element) {
this.element = element;
this.anchor = null;
}
setText(text) {
this.element.textContent = text;
this.place();
}
setHtml(html) {
this.element.innerHTML = html;
this.place();
}
show(anchor) {
this.anchor = anchor;
anchor.setAttribute("aria-describedby", this.element.id);
this.element.classList.remove("hidden");
this.place();
}
hide() {
if (this.anchor) this.anchor.removeAttribute("aria-describedby");
this.anchor = null;
this.element.classList.add("hidden");
}
place() {
if (!this.anchor || this.element.classList.contains("hidden")) return;
const rect = this.anchor.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left, window.innerWidth - this.element.offsetWidth - 8));
let top = rect.bottom + 8;
if (top + this.element.offsetHeight > window.innerHeight - 8) {
top = Math.max(8, rect.top - this.element.offsetHeight - 8);
}
this.element.style.left = `${left}px`;
this.element.style.top = `${top}px`;
}
}
+3 -4
View File
@@ -25,6 +25,7 @@ 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 { ComboBox } from "./widgets/combobox.js";
(() => {
"use strict";
@@ -53,7 +54,6 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
siteTitle: "Racket Wiki",
bookmarks: [],
conceptMaps: [],
people: [],
cmapConceptUsage: new Map(),
cmapConceptIdsByName: new Map(),
cmapPageConcepts: new Map(),
@@ -63,7 +63,7 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
cmapSavedSnapshot: null,
cmapGuardHash: "",
cmapPrototype: null,
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
rawMarkdown: false
};
let easyMDE = null;
@@ -75,7 +75,7 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
const $ = (id) => document.getElementById(id);
const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox"));
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
const cmapWorkspace = new CmapWorkspace(
state,
@@ -430,7 +430,6 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
function toggleRawMarkdown() {
state.rawMarkdown = !state.rawMarkdown;
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
applyRawMarkdownMode();
easyMDE.codemirror.refresh();
}
@@ -0,0 +1,292 @@
import { pageReference, newPageReference, splitPageReference } from "../../reference.js";
import { pageRoute } from "../../routes.js";
import { ComboBox } from "../../../widgets/combobox.js";
import { TabSet } from "../../../widgets/tab-set.js";
/**
* Edit the content and placement-specific appearance of a CMap concept.
*
* The dialog owns its fields, tabs, image reader and input validation. It
* returns normalized values to the workspace, which performs the actual CMap
* editor transaction and persistence.
*/
export class CmapConceptDialog {
constructor(dialog, tr, appearanceEditor, peopleDialog, normalizeExternalUrl) {
this.dialog = dialog;
this.tr = tr;
this.appearanceEditor = appearanceEditor;
this.peopleDialog = peopleDialog;
this.normalizeExternalUrl = normalizeExternalUrl;
this.form = dialog.querySelector("form");
this.pageCombobox = new ComboBox(dialog.querySelector("#cmap-concept-page-combobox"));
this.cmapCombobox = new ComboBox(dialog.querySelector("#cmap-concept-cmap-combobox"));
this.tabs = new TabSet(dialog.querySelector(".cmap-concept-tabs"))
.onSelect(() => {
dialog.querySelector(".cmap-concept-dialog-body").scrollTop = 0;
});
this.record = null;
this.createContext = null;
this.imageSource = "";
this.imageRead = Promise.resolve();
this.saveHandler = null;
this.installEvents();
}
onSave(handler) {
this.saveHandler = handler;
return this;
}
isOpen() {
return this.dialog.open;
}
/** Open the dialog for an existing concept or sub-CMap placement. */
open(record, resources) {
if (!record || record.kind === "phrase") return;
this.record = record;
this.createContext = null;
this.imageSource = record.imageSource || "";
this.imageRead = Promise.resolve();
this.dialog.querySelector("#cmap-concept-dialog-title").textContent =
this.tr("edit-concept", "Edit concept");
this.dialog.querySelector("#cmap-concept-label").value = record.label || "";
this.dialog.querySelector("#cmap-concept-synopsis").value = record.synopsis || "";
this.dialog.querySelector("#cmap-concept-aspects").value = (record.aspects || []).join(", ");
const selectedPeople = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person")
.map((tag) => tag.value);
this.peopleDialog.showSelection(selectedPeople);
this.setLinkFields(record, resources);
this.appearanceEditor.showRecord(record);
this.dialog.querySelector("#cmap-concept-image").value = "";
this.updateImagePreview();
this.tabs.select("content");
this.dialog.showModal();
const label = this.dialog.querySelector("#cmap-concept-label");
label.focus();
label.select();
}
/** Open the dialog for a new concept at the supplied editor context. */
openNew(createContext, resources) {
this.record = null;
this.createContext = createContext;
this.imageSource = "";
this.imageRead = Promise.resolve();
this.dialog.querySelector("#cmap-concept-dialog-title").textContent =
this.tr("add-concept", "Add concept");
this.dialog.querySelector("#cmap-concept-label").value = "New concept";
this.dialog.querySelector("#cmap-concept-synopsis").value = "";
this.dialog.querySelector("#cmap-concept-aspects").value = "";
this.peopleDialog.showSelection([]);
this.setLinkFields({ kind: "concept", pageSlug: null, cmapSlug: null }, resources);
this.appearanceEditor.showNewConcept();
this.dialog.querySelector("#cmap-concept-image").value = "";
this.updateImagePreview();
this.tabs.select("content");
this.dialog.showModal();
const label = this.dialog.querySelector("#cmap-concept-label");
label.focus();
label.select();
}
setLinkFields(record, resources) {
const description = this.dialog.querySelector("#cmap-concept-description-page");
const descriptionLink = this.dialog.querySelector("#cmap-concept-description-link");
description.value = record.descriptionPageSlug || "";
descriptionLink.href = record.descriptionPageSlug ? pageRoute(record.descriptionPageSlug) : "#";
descriptionLink.classList.toggle("hidden", !record.descriptionPageSlug);
const externalUrl = this.dialog.querySelector("#cmap-concept-external-url");
externalUrl.value = record.externalUrl || "";
externalUrl.setCustomValidity("");
this.populatePageOptions(record, resources.pages);
this.populateCmapOptions(record, resources.conceptMaps, resources.parentMapAvailable);
}
populatePageOptions(record, pages) {
const sorted = [...pages].sort((a, b) => a.title.localeCompare(b.title));
const entries = sorted.map((page) => this.comboboxEntry(page));
if (record.pageSlug && !sorted.some((page) => page.slug === record.pageSlug)) {
entries.push({ value: record.pageSlug, label: record.pageSlug });
}
this.pageCombobox.setOptions(entries, record.pageSlug || "");
this.dialog.querySelector("#cmap-concept-page-row")
.classList.toggle("hidden", record.kind === "submap");
}
populateCmapOptions(record, conceptMaps, parentMapAvailable) {
const entries = [];
if (parentMapAvailable) {
entries.push({
value: "__parent__",
label: `${this.tr("parent-concept-map", "Parent concept map")}`
});
}
for (const conceptMap of [...conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) {
entries.push(this.comboboxEntry(conceptMap));
}
if (record.cmapSlug && !conceptMaps.some((item) => item.slug === record.cmapSlug)) {
entries.push({ value: record.cmapSlug, label: record.cmapSlug });
}
const selected = record.parentCmapLink ? "__parent__" : (record.cmapSlug || "");
this.cmapCombobox.setOptions(entries, selected);
this.dialog.querySelector("#cmap-concept-cmap-row")
.classList.toggle("hidden", record.kind === "submap");
}
comboboxEntry(record) {
return {
value: record.slug,
label: record.title || record.slug,
description: record.slug
};
}
updateImagePreview() {
const row = this.dialog.querySelector("#cmap-concept-image-preview-row");
const preview = this.dialog.querySelector("#cmap-concept-image-preview");
if (!this.imageSource) {
row.classList.add("hidden");
preview.removeAttribute("src");
return;
}
preview.src = this.imageSource;
row.classList.remove("hidden");
}
readImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true });
reader.addEventListener("error", () => reject(
reader.error || new Error("Image could not be read.")), { once: true });
reader.readAsDataURL(file);
});
}
/** Validate the form and return normalized concept values, or null on error. */
values() {
const labelInput = this.dialog.querySelector("#cmap-concept-label");
const label = labelInput.value.trim();
if (!label) {
this.tabs.select("content");
labelInput.focus();
return null;
}
const record = this.record;
const pageInput = this.dialog.querySelector("#cmap-concept-page");
const cmapInput = this.dialog.querySelector("#cmap-concept-cmap");
const selectedPage = record && record.kind === "submap" ? "" : this.pageCombobox.value();
const selectedCmap = record && record.kind === "submap" ? "" : this.cmapCombobox.value();
const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage;
if (selectedPage === null && !linkedPage) {
this.tabs.select("content");
pageInput.setCustomValidity(
this.tr("invalid-new-page", "Enter a page title or valid wiki address."));
pageInput.reportValidity();
return null;
}
if (selectedCmap === null) {
this.tabs.select("content");
cmapInput.setCustomValidity(this.tr(
"select-listed-concept-map", "Select a CMap from the list or clear the field."));
cmapInput.reportValidity();
return null;
}
const externalInput = this.dialog.querySelector("#cmap-concept-external-url");
const externalUrl = this.normalizeExternalUrl(externalInput.value);
if (externalUrl === null) {
this.tabs.select("content");
externalInput.setCustomValidity(this.tr(
"invalid-external-web-page", "Enter a complete http or https web address."));
externalInput.reportValidity();
externalInput.focus();
return null;
}
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);
if (!descriptionPage) {
this.tabs.select("content");
descriptionInput.setCustomValidity(
this.tr("invalid-description-page", "Enter a valid description page address."));
descriptionInput.reportValidity();
return null;
}
const linkedCmapValue = selectedCmap || "";
return {
label,
synopsis: this.dialog.querySelector("#cmap-concept-synopsis").value,
aspects: this.dialog.querySelector("#cmap-concept-aspects").value.split(",")
.map((aspect) => aspect.trim()).filter(Boolean),
personNames: this.peopleDialog.selectedNames(),
descriptionPageSlug: descriptionPage,
pageSlug: linkedPage || null,
cmapSlug: linkedCmapValue && linkedCmapValue !== "__parent__" ? linkedCmapValue : null,
externalUrl: externalUrl || null,
parentCmapLink: linkedCmapValue === "__parent__",
imageSource: this.imageSource,
appearance: this.appearanceEditor.placementChanges(
Boolean(record && record.kind === "submap"))
};
}
installEvents() {
this.dialog.querySelector("#cmap-concept-page").addEventListener("change", () => {
if (this.pageCombobox.value()) this.cmapCombobox.clear();
});
this.dialog.querySelector("#cmap-concept-cmap").addEventListener("change", () => {
if (this.cmapCombobox.value()) this.pageCombobox.clear();
});
for (const id of ["#cmap-concept-page", "#cmap-concept-cmap"]) {
this.dialog.querySelector(id).addEventListener("input", (event) =>
event.target.setCustomValidity(""));
}
this.dialog.querySelector("#cmap-concept-description-page")
.addEventListener("input", (event) => event.target.setCustomValidity(""));
this.dialog.querySelector("#cmap-concept-external-url")
.addEventListener("input", (event) => event.target.setCustomValidity(""));
this.dialog.querySelector("#cmap-concept-description-link")
.addEventListener("click", () => this.dialog.close());
this.dialog.querySelector("#cmap-concept-cancel")
.addEventListener("click", () => this.dialog.close());
this.dialog.querySelector("#cmap-concept-image").addEventListener("change", (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const record = this.record;
this.imageRead = this.readImage(file)
.then((imageSource) => {
if (this.record !== record) return;
this.imageSource = imageSource;
this.updateImagePreview();
})
.catch((error) => {
console.error(error);
window.alert(error.message);
});
});
this.dialog.querySelector("#cmap-concept-image-remove").addEventListener("click", () => {
this.imageSource = "";
this.imageRead = Promise.resolve();
this.dialog.querySelector("#cmap-concept-image").value = "";
this.updateImagePreview();
});
this.form.addEventListener("submit", async (event) => {
event.preventDefault();
await this.imageRead;
const values = this.values();
if (!values || !this.saveHandler) return;
const saved = await this.saveHandler(this.record, this.createContext, values);
if (saved) this.dialog.close();
});
this.dialog.addEventListener("close", () => {
this.record = null;
this.createContext = null;
this.imageSource = "";
this.imageRead = Promise.resolve();
});
}
}
@@ -0,0 +1,107 @@
import { StatusField } from "../../../widgets/status-field.js";
/**
* Run the user-facing Markdown and JSON export actions for one CMap.
*
* Export generation is supplied by the workspace. This controller owns export
* options, progress messages, clipboard handling and browser downloads.
*/
export class CmapExportDialog {
constructor(dialog, tr, buildMarkdown, buildJson) {
this.dialog = dialog;
this.tr = tr;
this.buildMarkdown = buildMarkdown;
this.buildJson = buildJson;
this.form = dialog.querySelector("form");
this.depth = dialog.querySelector("#cmap-export-depth");
this.pages = dialog.querySelector("#cmap-export-pages");
this.status = new StatusField(dialog.querySelector("#cmap-export-status"));
this.conceptMapSlug = "concept-map";
dialog.querySelector("#cmap-export-cancel").addEventListener("click", () => dialog.close());
dialog.querySelector("#cmap-export-copy").addEventListener("click", () => {
this.exportMarkdown(false);
});
dialog.querySelector("#cmap-export-json").addEventListener("click", () => {
this.exportJson();
});
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.exportMarkdown(true);
});
}
open(conceptMapSlug) {
this.conceptMapSlug = conceptMapSlug;
this.status.clear();
this.dialog.showModal();
this.depth.focus();
}
options() {
return {
depth: Math.max(0, Math.min(10, Number(this.depth.value) || 0)),
includeWikiPages: this.pages.checked
};
}
async exportJson() {
this.status.set(this.tr("preparing-cmap-json", "Preparing complete CMap JSON…"));
try {
const bundle = await this.buildJson(this.options().depth);
this.download(`${JSON.stringify(bundle, null, 2)}\n`,
`${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8");
this.status.set(this.tr("cmap-json-downloaded", "CMap JSON downloaded."));
return true;
} catch (error) {
this.status.set(error.message);
return false;
}
}
async exportMarkdown(download) {
this.status.set(this.tr("preparing-markdown-export", "Preparing Markdown export…"));
try {
const markdown = await this.buildMarkdown(this.options());
if (download) {
this.download(markdown, `${this.conceptMapSlug}-report.md`,
"text/markdown;charset=utf-8");
this.status.set(this.tr("markdown-export-downloaded", "Markdown export downloaded."));
} else {
await this.copy(markdown);
this.status.set(this.tr("markdown-export-copied", "Markdown export copied."));
}
return true;
} catch (error) {
this.status.set(error.message);
return false;
}
}
download(text, filename, contentType) {
const blob = new Blob([text], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
async copy(text) {
try {
await navigator.clipboard.writeText(text);
} catch (_error) {
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.left = "-10000px";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
}
}
@@ -0,0 +1,115 @@
/**
* Present and maintain the stored history of one CMap.
*
* History records come from CmapRepository. Loading a version is delegated to
* the workspace because it controls unsaved-change transitions and the editor.
*/
export class CmapHistoryDialog {
constructor(dialog, repository, tr, pageDisplayDate, can, loadVersion, showStatus) {
this.dialog = dialog;
this.repository = repository;
this.tr = tr;
this.pageDisplayDate = pageDisplayDate;
this.can = can;
this.loadVersion = loadVersion;
this.showStatus = showStatus;
this.list = dialog.querySelector("#cmap-history-list");
dialog.querySelector("#cmap-history-close").addEventListener("click", () => dialog.close());
}
async open(conceptMap) {
const versions = await this.repository.history(conceptMap);
this.list.replaceChildren();
if (!versions.length) this.showEmptyMessage();
for (const version of versions) this.list.append(this.versionRow(conceptMap, version));
this.dialog.showModal();
}
showEmptyMessage() {
const empty = document.createElement("p");
empty.className = "muted cmap-history-empty";
empty.textContent = this.tr(
"no-concept-map-history", "No snapshots or manual saves yet.");
this.list.append(empty);
}
versionRow(conceptMap, version) {
const row = document.createElement("div");
row.className = "cmap-history-row";
const label = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = `${this.tr("version", "Version")} ${version.version}${version.title}`;
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${this.pageDisplayDate(version.createdAt)} · ${version.author} · ${this.versionSummary(version)}`;
label.append(heading, document.createElement("br"), meta);
const actions = document.createElement("div");
actions.className = "cmap-history-actions";
actions.append(this.loadButton(conceptMap, version));
if (this.can("editor")) actions.append(this.deleteButton(conceptMap, version, row));
row.append(label, actions);
return row;
}
versionSummary(version) {
const knownSummaries = {
create: this.tr("concept-map-created-version", "CMap created"),
rename: this.tr("concept-map-renamed-version", "CMap renamed")
};
let summary = knownSummaries[version.action] || version.summary;
if (version.action === "snapshot") {
const snapshotLabel = this.tr("snapshot", "Snapshot");
if (version.summary === "Current state when CMap history was enabled") {
summary = this.tr("concept-map-initial-version", "Initial available version");
} else {
summary = version.summary === snapshotLabel ?
snapshotLabel : `${snapshotLabel}${version.summary}`;
}
}
if (version.summary === "Automatic save") {
summary = this.tr("automatic-save", "Automatic save");
}
if (version.summary === "Manual save") summary = this.tr("manual-save", "Manual save");
return summary;
}
loadButton(conceptMap, version) {
const load = document.createElement("button");
load.type = "button";
load.textContent = version.version === conceptMap.currentVersion ?
this.tr("current-version", "Current") : this.tr("load-version", "Load version");
load.disabled = version.version === conceptMap.currentVersion;
load.addEventListener("click", () => {
this.dialog.close();
this.loadVersion(version.version);
});
return load;
}
deleteButton(conceptMap, version, row) {
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = this.tr("delete-history-item", "Delete");
remove.addEventListener("click", async () => {
const question = this.tr(
"delete-concept-map-history-confirm",
"Delete CMap history version {version}? The current CMap will not be changed.")
.replace("{version}", String(version.version));
if (!window.confirm(question)) return;
remove.disabled = true;
try {
await this.repository.deleteVersion(conceptMap, version.version);
row.remove();
if (!this.list.querySelector(".cmap-history-row")) this.showEmptyMessage();
this.showStatus(
this.tr("concept-map-history-deleted", "History item deleted"), true);
} catch (error) {
remove.disabled = false;
this.showStatus(error.message);
}
});
return remove;
}
}
@@ -0,0 +1,66 @@
/**
* Edit CMap metadata without knowing how the active CMap is persisted.
*
* The controller owns field population and validation. Its save handler
* receives normalized metadata and reports success with a boolean result.
*/
export class CmapMetadataDialog {
constructor(dialog, tr, normalizePageReference) {
this.dialog = dialog;
this.tr = tr;
this.normalizePageReference = normalizePageReference;
this.form = dialog.querySelector("form");
this.summary = dialog.querySelector("#cmap-metadata-summary");
this.tags = dialog.querySelector("#cmap-metadata-tags");
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
this.saveHandler = null;
dialog.querySelector("#cmap-metadata-cancel")
.addEventListener("click", () => dialog.close());
this.explanationPage.addEventListener("input", () =>
this.explanationPage.setCustomValidity(""));
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.save().catch((error) => window.alert(error.message));
});
}
onSave(handler) {
this.saveHandler = handler;
return this;
}
open(metadata) {
this.summary.value = metadata.summary || "";
this.tags.value = (metadata.tags || []).join(", ");
this.explanationPage.value = metadata.explanationPageSlug || "";
this.explanationPage.setCustomValidity("");
this.dialog.showModal();
this.summary.focus();
}
metadata() {
const explanationInput = this.explanationPage.value.trim();
const explanationPageSlug = explanationInput ?
this.normalizePageReference(explanationInput) : "";
if (explanationInput && !explanationPageSlug) {
this.explanationPage.setCustomValidity(
this.tr("invalid-description-page", "Enter a valid description page address."));
this.explanationPage.reportValidity();
return null;
}
return {
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
summary: this.summary.value.trim(),
explanationPageSlug
};
}
async save() {
const metadata = this.metadata();
if (!metadata || !this.saveHandler) return false;
const saved = await this.saveHandler(metadata);
if (saved) this.dialog.close();
return saved;
}
}
@@ -0,0 +1,158 @@
/**
* Manage the shared people list used by the concept picker and people dialog.
*
* The controller loads and updates people through the wiki API, renders both
* views from the same collection and exposes only the selected person names to
* the concept editor.
*/
export class CmapPeopleDialog {
constructor(dialog, picker, repository, tr) {
this.dialog = dialog;
this.picker = picker;
this.repository = repository;
this.tr = tr;
this.people = [];
this.options = picker.querySelector("#cmap-person-tag-options");
this.managementList = dialog.querySelector("#cmap-people-list");
this.conceptInput = picker.querySelector("#cmap-person-new-name");
this.managementInput = dialog.querySelector("#cmap-people-new-name");
picker.querySelector("#cmap-person-add").addEventListener("click", () => {
this.createPerson(this.conceptInput, true)
.catch((error) => window.alert(error.message));
});
this.conceptInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
this.createPerson(this.conceptInput, true)
.catch((error) => window.alert(error.message));
});
dialog.querySelector("#cmap-people-add").addEventListener("click", () => {
this.createPerson(this.managementInput, false)
.then(() => this.renderManagement())
.catch((error) => window.alert(error.message));
});
this.managementInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
this.createPerson(this.managementInput, false)
.then(() => this.renderManagement())
.catch((error) => window.alert(error.message));
});
this.options.addEventListener("change", () => this.renderOptions(this.selectedNames()));
dialog.querySelector("#cmap-people-close").addEventListener("click", () => dialog.close());
}
async load() {
this.people = await this.repository.all();
return this.people;
}
selectedNames() {
return Array.from(this.options.querySelectorAll("input[type='checkbox']:checked"))
.map((input) => input.dataset.personName)
.filter(Boolean);
}
showSelection(selectedNames = []) {
this.renderOptions(selectedNames);
this.picker.open = false;
}
/** Render the picker while retaining inactive or missing selected people. */
renderOptions(selectedNames = []) {
const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase()));
this.options.replaceChildren();
const visiblePeople = this.people.filter((person) =>
person.active || selected.has(String(person.name).toLocaleLowerCase()));
for (const person of visiblePeople) {
this.options.append(this.personOption(person.name, person.active, selected));
}
const knownNames = new Set(
this.people.map((person) => String(person.name).toLocaleLowerCase()));
for (const selectedName of selectedNames) {
if (!knownNames.has(String(selectedName).toLocaleLowerCase())) {
this.options.append(this.personOption(selectedName, false, selected));
}
}
if (!this.options.childElementCount) {
const empty = document.createElement("span");
empty.className = "muted";
empty.textContent = this.tr("no-active-people", "No active people yet.");
this.options.append(empty);
}
const summary = this.picker.querySelector("summary");
summary.textContent = selected.size ?
this.tr("people-selected", "{count} people selected")
.replace("{count}", String(selected.size)) :
this.tr("select-people", "Select people");
}
personOption(name, active, selected) {
const label = document.createElement("label");
label.className = "cmap-person-tag-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.personName = name;
checkbox.checked = selected.has(String(name).toLocaleLowerCase());
const text = document.createElement("span");
text.textContent = active ? name : `${name} (${this.tr("inactive", "inactive")})`;
label.append(checkbox, text);
return label;
}
async createPerson(input, selectInConceptDialog) {
const name = input.value.trim();
if (!name) {
input.focus();
return null;
}
const selected = selectInConceptDialog ? this.selectedNames() : [];
const person = await this.repository.create(name);
input.value = "";
await this.load();
if (selectInConceptDialog) this.renderOptions([...selected, person.name]);
return person;
}
renderManagement() {
this.managementList.replaceChildren();
for (const person of this.people) {
const row = document.createElement("div");
row.className = "cmap-person-admin-row";
const name = document.createElement("strong");
name.textContent = person.name;
const activeLabel = document.createElement("label");
const active = document.createElement("input");
active.type = "checkbox";
active.checked = Boolean(person.active);
const activeText = document.createElement("span");
activeText.textContent = this.tr("active", "Active");
activeLabel.append(active, activeText);
const save = document.createElement("button");
save.type = "button";
save.textContent = this.tr("save", "Save");
save.addEventListener("click", async () => {
save.disabled = true;
try {
await this.repository.update(person, active.checked);
await this.load();
this.renderManagement();
} catch (error) {
window.alert(error.message);
save.disabled = false;
}
});
row.append(name, activeLabel, save);
this.managementList.append(row);
}
}
async open() {
await this.load();
this.renderManagement();
this.dialog.showModal();
}
}
@@ -0,0 +1,44 @@
/**
* Ask how pending CMap changes should be handled before navigation.
*
* Each call to choose resolves once with "save", "discard" or "cancel".
* Saving and discarding remain workspace responsibilities because they change
* the active editor and persistence state.
*/
export class CmapUnsavedDialog {
constructor(dialog) {
this.dialog = dialog;
this.resolveChoice = null;
dialog.querySelector("#cmap-unsaved-save")
.addEventListener("click", () => this.finish("save"));
dialog.querySelector("#cmap-unsaved-discard")
.addEventListener("click", () => this.finish("discard"));
dialog.querySelector("#cmap-unsaved-cancel")
.addEventListener("click", () => this.finish("cancel"));
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
this.finish("cancel");
});
}
choose() {
if (this.resolveChoice) return Promise.resolve("cancel");
this.dialog.showModal();
return new Promise((resolve) => {
this.resolveChoice = resolve;
});
}
chooseSave() {
this.finish("save");
}
finish(choice) {
const resolve = this.resolveChoice;
if (!resolve) return;
this.resolveChoice = null;
this.dialog.close();
resolve(choice);
}
}
File diff suppressed because it is too large Load Diff
+28 -9
View File
@@ -182,6 +182,14 @@ function positionIsProtected(start, end, ranges) {
return false;
}
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
function literalWikiWord(namespace, wikiWord) {
const characters = Array.from(wikiWord);
const initial = `&#${characters[0].codePointAt(0)};`;
const prefix = namespace ? `${namespace}:` : "";
return `${prefix}${initial}${characters.slice(1).join("")}`;
}
/**
* goal : Rewrite namespaced Markdown targets to internal hash routes.
* pre : markdown is source text; fenced and indented code must remain untouched.
@@ -219,15 +227,18 @@ function expandNamespacedMarkdownLinks(markdown) {
/**
* goal : Expand classic WikiWords to temporary Markdown links.
* pre : pages, pageAliases and conceptMaps are current catalogues.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
* result : Render-only Markdown with WikiWord links.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged;
* an exclamation mark directly before a WikiWord suppresses its link.
* result : Render-only Markdown with WikiWord links. Literal WikiWords contain
* an equivalent HTML character reference and remain literal when the
* transformation is applied again.
*/
function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSlug = null) {
const aliases = pageMentionMap(pages, pageAliases, currentSlug);
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(!?)(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-]|\.[\p{L}\p{N}_-])/gu;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
@@ -250,12 +261,20 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
const start = match.index;
const end = start + match[0].length;
if (positionIsProtected(start, end, protectedRanges)) continue;
const namespace = match[1] || "";
if (match[1] === "!") {
replacements.push({
start,
end,
text: literalWikiWord(match[2] || "", match[3])
});
continue;
}
const namespace = match[2] || "";
if (namespace.toLocaleLowerCase() === "cmap") {
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
const conceptMap = cmapMentionTarget(match[3], conceptMaps);
if (conceptMap) replacements.push({ start, end, conceptMap });
} else {
const page = wikiWordTarget(match[2], aliases, namespace);
const page = wikiWordTarget(match[3], aliases, namespace);
if (page) replacements.push({ start, end, page });
}
}
@@ -263,10 +282,10 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = replacement.conceptMap ?
const rendered = replacement.text || (replacement.conceptMap ?
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`);
expanded = expanded.slice(0, replacement.start) + rendered + expanded.slice(replacement.end);
}
result.push(expanded);
}
-12
View File
@@ -1,12 +0,0 @@
# Vendor extensions
`cmap-racket-wiki.js` is the racket-wiki interaction layer for upstream
`ionstage/cmap` 0.1.3. The upstream library is installed as `/vendor/cmap.js`
by the normal frontend setup. Racket-wiki adjusts its `Node.prototype.style`
so rendered concept nodes accept pointer events; upstream's link hit testing is
left unchanged.
The extension remains separate because selection handles, resize controls,
drag-to-connect relations, separate linking-phrase nodes and wiki page/submap
behaviour belong to the racket-wiki editor rather than the generic drawing
library.
@@ -1,672 +0,0 @@
/*
* Racket Wiki interaction layer for ionstage/cmap 0.1.3.
*
* This file contains the racket-wiki-specific editor model and interactions.
* The installed upstream cmap.js is adjusted separately so its rendered nodes
* accept pointer events; concepts can then be selected as normal DOM targets.
*/
(() => {
"use strict";
const debugPrefix = "[racket-wiki:cmap 0.2.44]";
function debug(message, details) {
if (details === undefined) {
console.info(debugPrefix, message);
return;
}
console.info(debugPrefix, message, details);
}
function elementDescription(element) {
if (!(element instanceof Element)) return String(element);
return {
tag: element.tagName,
id: element.id || null,
classes: Array.from(element.classList),
itemId: element.dataset.rwCmapItemId || null
};
}
function selectionStyle(element) {
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
const style = window.getComputedStyle(element);
return {
pointerEvents: style.pointerEvents,
outline: style.outline,
outlineOffset: style.outlineOffset,
boxShadow: style.boxShadow,
overflow: style.overflow,
zIndex: style.zIndex
};
}
debug("cmap-racket-wiki.js loaded", {
script: document.currentScript ? document.currentScript.src : null,
cmapAvailable: typeof window.Cmap === "function",
stylesheets: Array.from(document.styleSheets || [])
.map((sheet) => sheet.href)
.filter((href) => href && href.includes("cmap.css"))
});
//////////////////////////////////////////////////////////////////////////////
// Small helpers
//////////////////////////////////////////////////////////////////////////////
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
//////////////////////////////////////////////////////////////////////////////
// Editor
//////////////////////////////////////////////////////////////////////////////
/**
* goal : Add CmapTools-like selection, resize and relation drawing on top
* of ionstage/cmap without changing the upstream library.
* pre : canvas is a DOM element and window.Cmap is available.
* post : Concepts and linking phrases can be selected and connected by
* direct manipulation.
* result : A CmapEditor instance.
*/
class CmapEditor {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.CmapFactory = options.Cmap || window.Cmap;
this.renderItem = options.renderItem || null;
this.onOpenPage = options.onOpenPage || null;
this.onOpenSubMap = options.onOpenSubMap || null;
this.onSelectionChange = options.onSelectionChange || null;
this.labels = {
createRelation: options.createRelationLabel || "Create relation",
resizeConcept: options.resizeConceptLabel || "Resize concept",
relation: options.relationLabel || "Relation"
};
this.map = this.CmapFactory(canvas);
this.items = [];
this.connectors = [];
this.selectedItem = null;
this.selectedConnector = null;
this.nextId = 1;
this.nextConnectorId = 1;
this.dragRelation = null;
this.installCanvasHandlers();
debug("editor created", {
canvas: elementDescription(canvas),
cmapFactoryAvailable: typeof this.CmapFactory === "function"
});
}
/**
* 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 = {}) {
const id = this.nextId++;
const kind = options.kind || "concept";
const record = {
id,
kind,
label: options.label || "Concept",
synopsis: options.synopsis || "",
pageSlug: options.pageSlug || null,
childMap: options.childMap || null,
backgroundColor: options.backgroundColor || "#f3f6f8",
borderColor: options.borderColor || "#5d6d7e",
fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif",
fontSize: options.fontSize || "15px",
width: options.width || (kind === "phrase" ? 145 : 220),
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
node: null
};
const node = this.map.node({
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" ? 1 : 2,
textColor: "#222"
});
record.node = node;
this.items.push(record);
node.redraw();
this.decorateItem(record);
debug("item added", {
id: record.id,
kind: record.kind,
label: record.label,
element: elementDescription(record.node.element()),
style: selectionStyle(record.node.element())
});
return record;
}
/**
* 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 = {}) {
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
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
});
record.node.redraw();
this.decorateItem(record);
this.redrawConnectorsFor(record);
}
/**
* goal : Connect source to target with a separate linking phrase.
* pre : source and target are items in this editor.
* post : source -> phrase -> target is visible; the phrase can branch.
* result : The newly created linking-phrase item.
*/
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
const a = this.itemCenter(source);
const b = this.itemCenter(target);
const phrase = this.addItem({
kind: "phrase",
label,
x: ((a.x + b.x) / 2) - 72,
y: ((a.y + b.y) / 2) - 18,
width: 145,
height: 36,
backgroundColor: "#fffdf7",
borderColor: "#8c7a4f"
});
this.addConnector(source, phrase, false);
this.addConnector(phrase, target, true);
this.selectItem(phrase);
if (editImmediately) this.editPhraseInline(phrase);
return 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) {
const link = this.map.link({
content: "",
width: 1,
height: 1,
backgroundColor: "transparent",
borderColor: "transparent",
borderWidth: 0,
lineColor: "#333",
lineWidth: 2,
hasArrow
});
link.sourceNode(source.node).targetNode(target.node);
link.draggable(true);
link.redraw();
const record = {
id: this.nextConnectorId++,
link,
source,
target,
hasArrow,
lineColor: "#333",
lineWidth: 2
};
this.connectors.push(record);
this.decorateConnector(record);
return record;
}
/**
* goal : Select a concept/linking phrase and expose its handles.
* pre : record belongs to this editor.
* post : Previous selection is cleared and record is visually selected.
*/
selectItem(record) {
debug("selectItem called", {
requestedId: record ? record.id : null,
requestedKind: record ? record.kind : null,
previousId: this.selectedItem ? this.selectedItem.id : null
});
this.clearSelection();
this.selectedItem = record;
record.node.toFront();
const element = record.node.element();
if (element) {
element.classList.add("rw-cmap-selected");
element.setAttribute("aria-selected", "true");
this.ensureHandles(record, element);
}
debug("selection applied", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
elementFound: Boolean(element),
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)
});
if (element && typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(() => {
debug("selection after browser redraw", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
selectedClassPresent: element.classList.contains("rw-cmap-selected"),
connected: element.isConnected,
handleCount: element.querySelectorAll(":scope > .rw-cmap-handle").length,
computedStyle: selectionStyle(element)
});
});
}
this.notifySelection();
}
/**
* goal : Select a connector so its line becomes clearly visible.
* pre : record belongs to this editor.
* post : Previous selection is cleared and the connector is highlighted.
*/
selectConnector(record) {
this.clearSelection();
this.selectedConnector = record;
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
record.link.redraw();
this.decorateConnector(record);
this.notifySelection();
}
/**
* goal : Remove the current item/connector selection.
* post : No item handles or connector highlight remain.
*/
clearSelection() {
const clearedItemId = this.selectedItem ? this.selectedItem.id : null;
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
if (this.selectedItem) {
const element = this.selectedItem.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.removeAttribute("aria-selected");
this.removeHandles(element);
}
}
if (this.selectedConnector) {
const connector = this.selectedConnector;
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
connector.link.redraw();
this.decorateConnector(connector);
}
this.selectedItem = null;
this.selectedConnector = null;
if (clearedItemId || clearedConnectorId) {
debug("selection cleared", { itemId: clearedItemId, connectorId: clearedConnectorId });
}
this.notifySelection();
}
selected() {
return this.selectedItem;
}
/**
* goal : Start direct editing of a linking phrase.
* pre : record.kind is "phrase".
* post : An input appears in the relation-name node and receives focus.
*/
editPhraseInline(record) {
if (!record || record.kind !== "phrase") return;
const value = record.label || "?????";
record.node.attr("content",
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.labels.relation)}">`);
record.node.redraw();
this.decorateItem(record);
const element = record.node.element();
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
if (!input) return;
const commit = () => {
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.decorateItem(record);
this.selectItem(record);
};
input.addEventListener("pointerdown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
input.value = value;
input.blur();
}
});
input.addEventListener("blur", commit, { once: true });
input.focus();
input.select();
}
/**
* goal : Redraw selection controls after ionstage/cmap updates a node.
* pre : record.node.redraw() has made a DOM element available.
* post : Selection, drag-to-link and resize interactions are attached.
*/
decorateItem(record) {
const element = record.node.element();
if (!element) return;
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
element.dataset.rwCmapItemId = String(record.id);
element.style.fontFamily = record.fontFamily;
element.style.fontSize = record.fontSize;
element.style.overflow = "visible";
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
id: record.id,
kind: record.kind,
element: elementDescription(element),
style: selectionStyle(element)
});
element.addEventListener("dblclick", (event) => {
if (event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) return;
event.preventDefault();
event.stopPropagation();
if (record.kind === "phrase") {
this.editPhraseInline(record);
return;
}
if (record.pageSlug && this.onOpenPage) {
this.onOpenPage(record);
return;
}
if (record.childMap && this.onOpenSubMap) this.onOpenSubMap(record);
});
}
if (this.selectedItem === record) {
element.classList.add("rw-cmap-selected");
this.ensureHandles(record, element);
}
}
decorateConnector(record) {
const element = record.link.element();
if (!element) return;
element.classList.add("rw-cmap-connector");
element.dataset.rwCmapConnectorId = String(record.id);
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
element.addEventListener("pointerdown", (event) => {
event.stopPropagation();
this.selectConnector(record);
}, true);
}
}
redrawConnectorsFor(record) {
for (const connector of this.connectors) {
if (connector.source === record || connector.target === record) {
connector.link.redraw();
this.decorateConnector(connector);
}
}
}
/**
* goal : Select concepts before ionstage/cmap starts a possible drag.
* pre : Item DOM elements contain data-rw-cmap-item-id attributes.
* post : Pointer-down on an item selects it immediately; pointer-down on
* empty canvas space clears the selection.
*/
installCanvasHandlers() {
const itemFromEvent = (event) => {
if (!(event.target instanceof Element)) return null;
const element = event.target.closest("[data-rw-cmap-item-id]");
if (!element || !this.canvas.contains(element)) return null;
const id = Number(element.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
};
this.canvas.addEventListener("pointerdown", (event) => {
debug("canvas pointerdown", {
pointerId: event.pointerId,
pointerType: event.pointerType,
button: event.button,
target: elementDescription(event.target)
});
if (event.target instanceof Element &&
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
debug("pointerdown belongs to a selection control", elementDescription(event.target));
return;
}
const item = itemFromEvent(event);
if (item) {
debug("pointerdown matched item", { id: item.id, kind: item.kind, label: item.label });
this.selectItem(item);
return;
}
debug("pointerdown did not match an item", {
targetIsCanvas: event.target === this.canvas,
target: elementDescription(event.target)
});
if (event.target === this.canvas) this.clearSelection();
}, true);
}
ensureHandles(record, element) {
this.removeHandles(element);
const relation = document.createElement("button");
relation.type = "button";
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
relation.title = this.labels.createRelation;
relation.setAttribute("aria-label", this.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record));
element.append(relation);
if (record.kind !== "phrase") {
const resize = document.createElement("button");
resize.type = "button";
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
resize.title = this.labels.resizeConcept;
resize.setAttribute("aria-label", this.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
startResize(event, record) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = Number(record.node.attr("width"));
const startHeight = Number(record.node.attr("height"));
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
record.width = Math.max(100, startWidth + (moveEvent.clientX - startX));
record.height = Math.max(42, startHeight + (moveEvent.clientY - startY));
record.node.attr({ width: record.width, height: record.height });
record.node.redraw();
this.decorateItem(record);
this.redrawConnectorsFor(record);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.decorateItem(record);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
startRelationDrag(event, source) {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const start = this.itemCenter(source);
const draft = this.createDraftLine(start);
this.dragRelation = { source, draft };
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const point = this.canvasPoint(moveEvent);
draft.line.setAttribute("x2", String(point.x));
draft.line.setAttribute("y2", String(point.y));
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
draft.svg.remove();
this.dragRelation = null;
const target = this.itemAt(upEvent.clientX, upEvent.clientY);
if (!target || target === source) return;
this.finishRelation(source, target);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
finishRelation(source, target) {
if (source.kind === "phrase" && target.kind !== "phrase") {
this.addConnector(source, target, true);
this.selectItem(source);
return;
}
if (source.kind !== "phrase" && target.kind === "phrase") {
this.addConnector(source, target, false);
this.selectItem(target);
return;
}
if (source.kind === "phrase" && target.kind === "phrase") return;
this.connectWithPhrase(source, target, "?????", true);
}
createDraftLine(start) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.classList.add("rw-cmap-draft-layer");
svg.setAttribute("width", String(Math.max(this.canvas.scrollWidth, this.canvas.clientWidth)));
svg.setAttribute("height", String(Math.max(this.canvas.scrollHeight, this.canvas.clientHeight)));
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", String(start.x));
line.setAttribute("y1", String(start.y));
line.setAttribute("x2", String(start.x));
line.setAttribute("y2", String(start.y));
line.setAttribute("class", "rw-cmap-draft-line");
svg.append(line);
this.canvas.append(svg);
return { svg, line };
}
canvasPoint(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left + this.canvas.scrollLeft,
y: event.clientY - rect.top + this.canvas.scrollTop
};
}
itemAt(clientX, clientY) {
const element = document.elementFromPoint(clientX, clientY);
const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null;
if (!itemElement) return null;
const id = Number(itemElement.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
}
itemCenter(record) {
return {
x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2),
y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2)
};
}
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.renderItem) return this.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
notifySelection() {
if (this.onSelectionChange) this.onSelectionChange(this.selectedItem, this.selectedConnector);
}
}
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.44",
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,
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;
}
};
})();
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CmapAppearance,
cmapFontSizeInPoints,
normalizedCmapFontSize
} from "../static/cmap/model/appearance.js";
const defaultValues = {
backgroundColor: "#fff4cf", textColor: "#222222",
fontFamily: "Arial", fontSize: 11, fontWeight: "700", fontStyle: "normal",
synopsisTextColor: "#4d4d4d", synopsisFontFamily: "Arial",
synopsisFontSize: 9, synopsisFontWeight: "400", synopsisFontStyle: "normal",
submapBackgroundColor: "#edf7e8", submapBorderColor: "#57834a"
};
function appearance() {
return new CmapAppearance({
styles: [{
id: "default", nameKey: "style-default", protected: true, values: defaultValues
}],
palette: ["#ffffff", "#222222"]
});
}
test("CMap appearance owns detached styles and palette values", () => {
const model = appearance();
const styles = model.styles;
const palette = model.palette;
styles[0].values.backgroundColor = "#000000";
palette[0] = "#000000";
assert.equal(model.defaultValues.backgroundColor, "#fff4cf");
assert.equal(model.palette[0], "#ffffff");
});
test("CMap appearance normalizes form values and recognizes named styles", () => {
const model = appearance();
const values = model.normalizeValues({
...defaultValues,
backgroundColor: "#FFF4CF",
fontSize: "11.2"
});
assert.equal(values.backgroundColor, "#fff4cf");
assert.equal(values.fontSize, 11);
assert.equal(model.matchingStyleId(values), "default");
});
test("custom styles and palette colours change only through model methods", () => {
const model = appearance();
const style = model.putStyle({ id: "review", name: "Review", values: defaultValues });
assert.equal(model.style("review").name, "Review");
assert.equal(model.matchingStyleId(style.values), "default");
assert.equal(model.deleteStyle("default"), false);
assert.equal(model.deleteStyle("review"), true);
assert.equal(model.setPaletteColor(1, "#ABCDEF"), true);
assert.equal(model.palette[1], "#abcdef");
assert.equal(model.setPaletteColor(1, "not-a-colour"), false);
});
test("CMap appearance rejects incomplete backend aggregates", () => {
assert.throws(
() => new CmapAppearance({ styles: [], palette: ["#ffffff"] }),
/default style and colour palette/);
});
test("CMap font sizes convert and normalize to backend units", () => {
assert.equal(cmapFontSizeInPoints("16px"), 12);
assert.equal(cmapFontSizeInPoints("1.5em", 10), 15);
assert.equal(normalizedCmapFontSize("55", 11), 54);
});
+46 -26
View File
@@ -2,10 +2,19 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { generateMarkdown, derivedDocument, relationLines } = require("../static/js/cmap-export.js");
let CmapMarkdownExporter;
test.before(async () => {
({ CmapMarkdownExporter } = await import(
"../static/cmap/model/markdown-exporter.js"));
});
function map(slug, title, document) {
return { slug, title, document };
return { slug, title, toDocument: () => document };
}
function repository(maps) {
return { load: async (slug) => maps[slug] };
}
const root = map("root", "Root map", {
@@ -52,13 +61,13 @@ const pages = new Map([
]);
test("exports metadata, person tags, relations and linked maps to the selected depth", async () => {
const markdown = await generateMarkdown({
rootMap: root,
const exporter = new CmapMarkdownExporter(
repository({ root, child }),
async (slug) => pages.get(slug));
const markdown = await exporter.export(root, {
maxDepth: 1,
includeWikiPages: false,
language: "nl",
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
language: "nl"
});
assert.match(markdown, /CMap: Root map \(niveau 0\)/);
@@ -73,25 +82,25 @@ test("exports metadata, person tags, relations and linked maps to the selected d
});
test("depth zero exports only the selected map", async () => {
const markdown = await generateMarkdown({
rootMap: root,
const exporter = new CmapMarkdownExporter(
repository({ root, child }),
async (slug) => pages.get(slug));
const markdown = await exporter.export(root, {
maxDepth: 0,
includeWikiPages: false,
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
includeWikiPages: false
});
assert.match(markdown, /Root map/);
assert.doesNotMatch(markdown, /## CMap: Child map/);
});
test("optionally appends linked wiki pages and concept explanations", async () => {
const markdown = await generateMarkdown({
rootMap: root,
const exporter = new CmapMarkdownExporter(
repository({ root, child }),
async (slug) => pages.get(slug));
const markdown = await exporter.export(root, {
maxDepth: 0,
includeWikiPages: true,
language: "en",
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
language: "en"
});
assert.match(markdown, /## Linked wiki pages/);
assert.match(markdown, /### Approach/);
@@ -101,7 +110,7 @@ test("optionally appends linked wiki pages and concept explanations", async () =
assert.match(markdown, /Because\./);
});
test("derived views contain their root subtree and inherit useful root metadata", () => {
test("derived views contain their root subtree and inherit useful root metadata", async () => {
const source = {
schemaVersion: 2,
concepts: [
@@ -119,17 +128,22 @@ test("derived views contain their root subtree and inherit useful root metadata"
{ id: 2, sourceId: 11, targetId: 12 }
]
};
const result = derivedDocument(source, {
const derived = map("team", "Team", {
derivedView: { sourceCmapSlug: "source", rootItemId: 10 }
});
assert.deepEqual(result.items.map((item) => item.id), [10, 11]);
assert.deepEqual(result.connectors.map((connector) => connector.id), [1]);
assert.deepEqual(result.metadata.tags, ["people"]);
assert.equal(result.metadata.summary, "Team scope");
assert.equal(result.metadata.explanationPageSlug, "cmap:team");
const sourceMap = map("source", "Source", source);
const exporter = new CmapMarkdownExporter(
repository({ source: sourceMap }),
async (slug) => ({ slug, title: slug, markdown: "Team explanation", tags: [] }));
const markdown = await exporter.export(derived, { maxDepth: 0 });
assert.match(markdown, /Team scope/);
assert.match(markdown, /`people`/);
assert.match(markdown, /#### Work/);
assert.doesNotMatch(markdown, /#### Outside/);
assert.match(markdown, /Team explanation/);
});
test("direct and phrase relations receive readable Markdown", () => {
test("direct and phrase relations receive readable Markdown", async () => {
const documentValue = {
items: [
{ id: 1, label: "A" }, { id: 2, kind: "phrase", label: "supports" },
@@ -141,5 +155,11 @@ test("direct and phrase relations receive readable Markdown", () => {
{ sourceId: 3, targetId: 4, hasArrow: true }
]
};
assert.deepEqual(relationLines(documentValue), ["A — **supports** → B", "B → C"]);
const relationMap = map("relations", "Relations", documentValue);
const exporter = new CmapMarkdownExporter(
repository({ relations: relationMap }),
async () => null);
const markdown = await exporter.export(relationMap);
assert.match(markdown, /A — \*\*supports\*\* → B/);
assert.match(markdown, /B → C/);
});
+1 -1
View File
@@ -15,7 +15,7 @@ test.before(async () => {
preparedMapDocument,
attachmentUrls,
replaceAttachmentUrls
} = await import("../static/js/wiki/cmap/interchange.js"));
} = await import("../static/cmap/model/interchange.js"));
});
const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555";
+103
View File
@@ -0,0 +1,103 @@
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 {
CmapRepository,
StoredConceptMap
} from "../static/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/cmap/model/concept-map.js";
const conceptId = "11111111-1111-4111-8111-111111111111";
const rootMap = {
slug: "root",
title: "Root",
document: {
schemaVersion: 2,
metadata: { explanationPageSlug: "root-page" },
concepts: [{ id: conceptId, label: "Root concept" }],
items: [{ id: 1, conceptId, kind: "concept", x: 10, y: 20 }],
connectors: []
}
};
const page = {
slug: "root-page",
title: "Root page",
markdown: "![Image](/uploads/root-page/image.png)",
tags: []
};
const storedRootMap = new StoredConceptMap(rootMap, CmapModel.fromDocument(rootMap.document));
const cmapRepository = { load: async () => { throw new Error("no linked map expected"); } };
test("CmapJsonExporter builds a complete bundle including attachments", async () => {
const exporter = new CmapJsonExporter(
cmapRepository,
async () => page,
async () => ({
ok: true,
headers: { get: () => "image/png" },
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer
}),
"test"
);
const bundle = await exporter.export(storedRootMap);
assert.equal(bundle.generator, "test");
assert.equal(bundle.pages[0].attachments[0].contentBase64, "AQID");
});
test("CmapJsonImporter reports conflicts and imports pages, attachments and maps", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
if (path.endsWith("/upload")) return { url: "/uploads/root-page/imported-image.png" };
if (path === "/api/pages" && options.method === "POST") return { currentVersion: 1 };
if (path === "/api/cmaps" && options.method === "POST") {
const body = JSON.parse(options.body);
return {
slug: body.slug,
title: body.title,
currentVersion: 1,
document: body.document
};
}
return {};
};
const importer = new CmapJsonImporter(
new CmapRepository(api), api, (_key, fallback) => fallback);
const exporter = new CmapJsonExporter(
cmapRepository,
async () => page,
async () => ({
ok: true,
headers: { get: () => "image/png" },
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer
})
);
const bundle = await exporter.export(storedRootMap);
const file = { size: 100, text: async () => JSON.stringify(bundle) };
const parsed = await importer.read(file);
assert.equal(parsed.rootCmapSlug, "root");
assert.deepEqual(importer.conflicts(parsed, [{ slug: "root-page" }], []), {
pages: [parsed.pages[0]],
conceptMaps: []
});
const result = await importer.import(parsed, {
pages: [], conceptMaps: [], summary: "Imported"
});
assert.equal(result.pagesCreated, 1);
assert.equal(result.mapsCreated, 1);
assert.equal(result.attachmentsImported, 1);
assert.deepEqual(calls.map((call) => `${call.options.method} ${call.path}`), [
"POST /api/pages",
"POST /api/pages/root-page/upload",
"PUT /api/pages/root-page",
"POST /api/cmaps"
]);
const pageUpdate = JSON.parse(calls[2].options.body);
assert.match(pageUpdate.markdown, /imported-image\.png/);
});
+16 -14
View File
@@ -14,7 +14,7 @@ import {
ConceptMapConnector,
ConceptMapPhrase
} from "../static/cmap/model/concept-map.js";
import { buildBundle } from "../static/js/wiki/cmap/interchange.js";
import { buildBundle } from "../static/cmap/model/interchange.js";
const firstId = "11111111-1111-4111-8111-111111111111";
const secondId = "22222222-2222-4222-8222-222222222222";
@@ -217,33 +217,35 @@ test("an embedded submap becomes an independent map linked from its owner", () =
const extraction = CmapModel.fromDocument(document)
.extractSubmap(1, "nested-subject");
const parentDocument = extraction.parentModel.toDocument();
const childDocument = extraction.childModel.toDocument();
assert.deepEqual(extraction.parentDocument.items.map((item) => item.id), [1, 4]);
assert.equal(extraction.parentDocument.items[0].kind, "concept");
assert.equal(extraction.parentDocument.concepts
assert.deepEqual(parentDocument.items.map((item) => item.id), [1, 4]);
assert.equal(parentDocument.items[0].kind, "concept");
assert.equal(parentDocument.concepts
.find((concept) => concept.id === firstId).cmapSlug, "nested-subject");
assert.deepEqual(extraction.parentDocument.connectors.map((connector) => ({
assert.deepEqual(parentDocument.connectors.map((connector) => ({
sourceId: connector.sourceId,
targetId: connector.targetId
})), [{ sourceId: 4, targetId: 1 }]);
assert.deepEqual(extraction.parentDocument.conceptOwnerships, [
assert.deepEqual(parentDocument.conceptOwnerships, [
{ parentConceptId: firstId, childConceptId: thirdId }
]);
assert.deepEqual(extraction.childDocument.items.map((item) => item.id), [2, 3]);
assert.ok(extraction.childDocument.items.every((item) => item.parentSubmapId === null));
assert.deepEqual(extraction.childDocument.concepts.map((concept) => concept.id), [secondId]);
assert.deepEqual(extraction.childDocument.connectors.map((connector) => connector.id), [1]);
assert.equal(extraction.childDocument.conceptOwnerships, undefined);
assert.equal(extraction.childDocument.derivedView, undefined);
assert.equal(extraction.childDocument.metadata.summary, "Nested subject");
assert.deepEqual(childDocument.items.map((item) => item.id), [2, 3]);
assert.ok(childDocument.items.every((item) => item.parentSubmapId === null));
assert.deepEqual(childDocument.concepts.map((concept) => concept.id), [secondId]);
assert.deepEqual(childDocument.connectors.map((connector) => connector.id), [1]);
assert.equal(childDocument.conceptOwnerships, undefined);
assert.equal(childDocument.derivedView, undefined);
assert.equal(childDocument.metadata.summary, "Nested subject");
const convertedDerivedMap = CmapModel.fromDocument(document).extractSubmap(
1,
"nested-subject",
{ tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: "" }
);
assert.deepEqual(convertedDerivedMap.childDocument.metadata, {
assert.deepEqual(convertedDerivedMap.childModel.metadata(), {
tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: ""
});
});
+32
View File
@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PeopleRepository } from "../static/cmap/model/people-repository.js";
test("PeopleRepository keeps people API routes behind its public methods", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
if (!options.method) return { people: [{ id: 1, name: "Ada", active: true }] };
return { id: 1, name: "Ada", active: true };
};
const repository = new PeopleRepository(api);
const people = await repository.all();
const created = await repository.create("Ada");
await repository.update(created, false);
assert.equal(people[0].name, "Ada");
assert.deepEqual(calls.map((call) => call.path), [
"/api/people",
"/api/people",
"/api/people/1"
]);
assert.deepEqual(JSON.parse(calls[1].options.body), { name: "Ada" });
assert.deepEqual(JSON.parse(calls[2].options.body), { name: "Ada", active: false });
});
test("PeopleRepository normalizes a missing people collection to an empty list", async () => {
const repository = new PeopleRepository(async () => ({}));
assert.deepEqual(await repository.all(), []);
});
+90
View File
@@ -0,0 +1,90 @@
import test from "node:test";
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";
const documentValue = {
schemaVersion: 2,
concepts: [{ id: "concept-1", label: "Legacy concept" }],
items: [{ id: 1, conceptId: "concept-1", kind: "concept", x: 10, y: 20 }],
connectors: []
};
test("CmapRepository decodes backend documents and normalizes legacy concept ids", async () => {
const repository = new CmapRepository(async () => ({
slug: "roadmap",
title: "Roadmap",
currentVersion: 3,
document: JSON.stringify(JSON.stringify(documentValue))
}));
const storedMap = await repository.load("roadmap");
assert.ok(storedMap instanceof StoredConceptMap);
assert.ok(storedMap.model instanceof CmapModel);
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");
});
test("CmapRepository serializes model saves with backend version information", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
return {
slug: "roadmap",
title: "Roadmap",
currentVersion: calls.length,
document: documentValue
};
};
const repository = new CmapRepository(api);
const storedMap = await repository.load("roadmap");
const savedMap = await repository.save(storedMap, storedMap.model, {
summary: "Manual save",
saveKind: "manual",
snapshot: true
});
assert.equal(savedMap.currentVersion, 2);
assert.equal(calls[1].path, "/api/cmaps/roadmap");
const body = JSON.parse(calls[1].options.body);
assert.equal(body.baseVersion, 1);
assert.equal(body.summary, "Manual save");
assert.equal(body.saveKind, "manual");
assert.equal(body.snapshot, true);
assert.deepEqual(body.document.concepts, storedMap.toDocument().concepts);
});
test("CmapRepository keeps CMap routes behind its public storage API", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
if (path === "/api/cmaps") {
if (!options.method) return { conceptMaps: [{ slug: "one", title: "One" }] };
return { slug: "one", title: "One", currentVersion: 1, document: documentValue };
}
if (path.endsWith("/history")) return { versions: [{ version: 1 }] };
if (path === "/api/cmaps/concept-usage") return { placements: [{ conceptId: "one" }] };
return { slug: "one", title: "One", currentVersion: 2, document: documentValue };
};
const repository = new CmapRepository(api);
const summaries = await repository.list();
const storedMap = await repository.create("One", CmapModel.fromDocument(documentValue), "one");
const versions = await repository.history(storedMap);
const placements = await repository.conceptUsage();
await repository.deleteVersion(storedMap, 1);
await repository.archive(storedMap, "One");
assert.equal(summaries[0].slug, "one");
assert.equal(versions[0].version, 1);
assert.equal(placements[0].conceptId, "one");
assert.ok(calls.some((call) => call.path === "/api/cmaps/one/versions/1" &&
call.options.method === "DELETE"));
assert.ok(calls.some((call) => call.path === "/api/cmaps/one" &&
call.options.method === "DELETE"));
});
+2 -2
View File
@@ -66,11 +66,11 @@ test("the last selected concept determines target size and alignment", async ()
"external links are serialized as shared concept content"
);
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
reloaded.loadDocument(savedDocument);
reloaded.loadModel(editor.currentModel());
assert.equal(
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
"https://example.com/first",
"external links survive an editor save and reload round-trip"
"external links survive an editor model round-trip"
);
editor.selectItem(first);
editor.selectItem(third, { additive: true });
+68
View File
@@ -0,0 +1,68 @@
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";
test("appearance repository owns the complete backend appearance envelope", async () => {
const requests = [];
const appearance = {
styles: [{
id: "default",
nameKey: "style-default",
protected: true,
values: {
backgroundColor: "#fff4cf", textColor: "#222222",
fontFamily: "Arial", fontSize: 11, fontWeight: "700", fontStyle: "normal",
synopsisTextColor: "#4d4d4d", synopsisFontFamily: "Arial",
synopsisFontSize: 9, synopsisFontWeight: "400", synopsisFontStyle: "normal",
submapBackgroundColor: "#edf7e8", submapBorderColor: "#57834a"
}
}],
palette: ["#ffffff", "#f1f3f5"]
};
const repository = new CmapAppearanceRepository(async (route, options = {}) => {
requests.push({ route, options });
return appearance;
});
const loaded = await repository.load();
assert.ok(loaded instanceof CmapAppearance);
assert.deepEqual(loaded.toData(), appearance);
const stored = await repository.save(loaded);
assert.equal(stored, loaded);
assert.deepEqual(stored.toData(), appearance);
assert.equal(requests[0].route, "/api/cmap-appearance");
assert.equal(requests[1].options.method, "PUT");
assert.deepEqual(JSON.parse(requests[1].options.body), appearance);
});
test("settings repository keeps backend settings in session memory", async () => {
const requests = [];
const repository = new CmapSettingsRepository(async (route, options = {}) => {
requests.push({ route, options });
if (route === "/api/cmap-settings") {
return {
startCmapSlug: "architecture",
pageGuidesVisible: false,
zooms: [{ cmapSlug: "architecture", contextKey: "root", zoomPercent: 125 }]
};
}
if (route.endsWith("/start")) return { startCmapSlug: "review" };
if (route.endsWith("/page-guides")) return { pageGuidesVisible: true };
return { zoomPercent: 150 };
});
await repository.load();
assert.equal(repository.startCmapSlug, "architecture");
assert.equal(repository.pageGuidesVisible, false);
assert.equal(repository.zoom("architecture", "root"), 125);
assert.equal(repository.zoom("architecture", "unknown"), 100);
assert.equal(await repository.setStartCmap("review"), "review");
assert.equal(await repository.setPageGuidesVisible(true), true);
assert.equal(await repository.setZoom("architecture", "root", 150), 150);
assert.equal(repository.zoom("architecture", "root"), 150);
assert.equal(requests.length, 4);
});
+17
View File
@@ -49,6 +49,23 @@ test("WikiWords use page and CMap catalogues without expanding protected text",
"[Homeopathie Wiki](#/homeopathie) en [Architectuur Kaart](#cmap/architectuur), maar `NieuwePagina` niet.");
});
test("an exclamation mark renders a WikiWord literally without becoming visible", () => {
const pages = [
{ slug: "homeopathie", namespace: "", pageSlug: "homeopathie", title: "Homeopathie Wiki" }
];
const conceptMaps = [{ slug: "architectuur", title: "Architectuur Kaart" }];
const markdown = "!HomeopathieWiki en !NieuwePagina en !cmap:ArchitectuurKaart.";
const expanded = expandWikiMentions(markdown, pages, [], conceptMaps);
assert.equal(expanded,
"&#72;omeopathieWiki en &#78;ieuwePagina en cmap:&#65;rchitectuurKaart.");
assert.equal(expandWikiMentions(expanded, pages, [], conceptMaps), expanded);
});
test("the WikiWord escape remains literal in protected Markdown", () => {
const markdown = "`!NieuwePagina` en [!NieuwePagina](https://example.com)";
assert.equal(expandWikiMentions(markdown, [], [], []), markdown);
});
test("ambiguous CMap mentions are not resolved", () => {
const conceptMaps = [
{ slug: "first", title: "Zelfde Kaart" },
+2
View File
@@ -51,6 +51,7 @@
'replace-style-confirm "Replace the existing style \"{name}\"?" 'delete-style-confirm "Delete style \"{name}\"?"
'default-style-protected "The default style cannot be changed or deleted."
'style-storage-failed "The style could not be stored in the wiki database."
'cmap-appearance-storage-failed "The CMap appearance could not be stored in the wiki database."
'concept-heading-appearance "Heading" 'concept-subtext-appearance "Subtext"
'change-palette-color "double-click to change"
'color-swatch-help "Click for the palette; double-click for a custom color"
@@ -176,6 +177,7 @@
'replace-style-confirm "Bestaande stijl \"{name}\" vervangen?" 'delete-style-confirm "Stijl \"{name}\" verwijderen?"
'default-style-protected "De standaardstijl kan niet worden gewijzigd of verwijderd."
'style-storage-failed "De stijl kon niet in de wikidatabase worden opgeslagen."
'cmap-appearance-storage-failed "De CMap-opmaak kon niet in de wikidatabase worden opgeslagen."
'concept-heading-appearance "Kop" 'concept-subtext-appearance "Subtekst"
'change-palette-color "dubbelklik om te wijzigen"
'color-swatch-help "Klik voor het palet; dubbelklik voor een eigen kleur"