refactoring of cmaps, widgets, etc.
This commit is contained in:
+3
-3
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; goal : Read the shared CMap appearance styles.
|
||||
; 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.
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
(define (read-cmap-styles config)
|
||||
(call-with-wiki-database
|
||||
config
|
||||
(λ (db)
|
||||
(let ((stored
|
||||
(query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key)))
|
||||
(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-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)))))))
|
||||
(normalize (string->jsexpr stored) who)
|
||||
(normalize fallback who)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; 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 : Read the complete shared CMap appearance.
|
||||
; pre : The wiki database is configured and its schema is initialized.
|
||||
; post : The database is not changed; absent settings use model defaults.
|
||||
; result : A hash containing validated styles and colour palette.
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
(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"))
|
||||
(define (read-cmap-appearance config)
|
||||
(call-with-wiki-database
|
||||
config
|
||||
(λ (db)
|
||||
(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 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-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"
|
||||
setting-key encoded (current-seconds))))
|
||||
normalized))
|
||||
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?
|
||||
(λ ()
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+61
-9
@@ -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 (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 'styles
|
||||
(save-cmap-styles! config (hash-ref body 'styles '())))))))))
|
||||
(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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 & 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 & 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__",
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+289
-1615
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: "",
|
||||
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
@@ -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: ""
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(), []);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
"HomeopathieWiki en NieuwePagina en cmap:ArchitectuurKaart.");
|
||||
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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user