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;
|
- 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.
|
- 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
|
## Belangrijk: huidige werkboom
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ static/cmap/cmap-racket-wiki.js
|
|||||||
static/cmap/cmap.css
|
static/cmap/cmap.css
|
||||||
static/index.html
|
static/index.html
|
||||||
static/js/wiki.js
|
static/js/wiki.js
|
||||||
static/js/cmap-export.js
|
static/cmap/model/markdown-exporter.js
|
||||||
test/cmap-export.test.js
|
test/cmap-export.test.js
|
||||||
translate.rkt
|
translate.rkt
|
||||||
```
|
```
|
||||||
@@ -143,7 +143,7 @@ raco make main.rkt server.rkt setup-vendor.rkt \
|
|||||||
migrate-cmap-subpages.rkt architecture/import.rkt
|
migrate-cmap-subpages.rkt architecture/import.rkt
|
||||||
|
|
||||||
node --check static/js/wiki.js
|
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/js/combobox.js
|
||||||
node --check static/cmap/cmap.js
|
node --check static/cmap/cmap.js
|
||||||
node --check static/cmap/cmap-racket-wiki.js
|
node --check static/cmap/cmap-racket-wiki.js
|
||||||
|
|||||||
@@ -244,7 +244,19 @@ Classic WikiWords may also be namespace-qualified:
|
|||||||
RWS:ModelTreeWalker
|
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
|
## Editing
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; Wiki-wide CMap appearance styles stored in wiki_settings.
|
;; Wiki-wide CMap appearance stored in wiki_settings.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require db
|
(require db
|
||||||
@@ -10,10 +10,11 @@
|
|||||||
racket/string
|
racket/string
|
||||||
"database.rkt")
|
"database.rkt")
|
||||||
|
|
||||||
(provide read-cmap-styles
|
(provide read-cmap-appearance
|
||||||
save-cmap-styles!)
|
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-count 100)
|
||||||
(define maximum-style-name-length 80)
|
(define maximum-style-name-length 80)
|
||||||
(define permitted-name-keys
|
(define permitted-name-keys
|
||||||
@@ -50,6 +51,10 @@
|
|||||||
(seeded-style "warning" "style-warning" "#fff0d5" "#713b00" "Arial, Helvetica, sans-serif" 11 #t #t)
|
(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)))
|
(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])
|
(define (required-string who value description [maximum-length #f])
|
||||||
(unless (and (string? value)
|
(unless (and (string? value)
|
||||||
(not (string=? (string-trim value) ""))
|
(not (string=? (string-trim value) ""))
|
||||||
@@ -130,49 +135,73 @@
|
|||||||
(error who "the default style is required"))
|
(error who "the default style is required"))
|
||||||
normalized))
|
normalized))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
(define (normalize-cmap-palette palette [who 'cmap-palette])
|
||||||
; goal : Read the shared CMap appearance styles.
|
(unless (and (list? palette) (= (length palette) (length initial-cmap-palette)))
|
||||||
; pre : The wiki database is configured and its schema is initialized.
|
(error who "the colour palette must contain ~a colours" (length initial-cmap-palette)))
|
||||||
; post : Default styles are inserted when no style setting exists yet.
|
(for/list ((color (in-list palette)))
|
||||||
; result : A validated, normalized non-empty list of style hashes.
|
(style-color who color 'palette)))
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
(define (read-cmap-styles config)
|
(define (read-setting db key fallback normalize who)
|
||||||
(call-with-wiki-database
|
(define stored (query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" key))
|
||||||
config
|
|
||||||
(λ (db)
|
|
||||||
(let ((stored
|
|
||||||
(query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key)))
|
|
||||||
(if stored
|
(if stored
|
||||||
(normalize-cmap-styles (string->jsexpr stored) 'read-cmap-styles)
|
(normalize (string->jsexpr stored) who)
|
||||||
(let ((encoded (jsexpr->string (normalize-cmap-styles initial-cmap-styles))))
|
(normalize fallback who)))
|
||||||
(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)))))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Validate and store the shared CMap appearance styles.
|
; goal : Read the complete shared CMap appearance.
|
||||||
; pre : styles is a non-empty list containing the required default style.
|
; pre : The wiki database is configured and its schema is initialized.
|
||||||
; post : The normalized style setting is stored atomically in the database.
|
; post : The database is not changed; absent settings use model defaults.
|
||||||
; result : The normalized list of stored style hashes.
|
; result : A hash containing validated styles and colour palette.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (save-cmap-styles! config styles)
|
(define (read-cmap-appearance config)
|
||||||
(let* ((normalized (normalize-cmap-styles styles 'save-cmap-styles!))
|
|
||||||
(encoded (jsexpr->string normalized)))
|
|
||||||
(when (> (bytes-length (string->bytes/utf-8 encoded)) (* 128 1024))
|
|
||||||
(error 'save-cmap-styles! "style data is too large"))
|
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (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
|
(query-exec
|
||||||
db
|
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"
|
"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))))
|
styles-setting-key encoded-styles (current-seconds))
|
||||||
normalized))
|
(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
|
(module+ test
|
||||||
(require rackunit)
|
(require rackunit)
|
||||||
@@ -187,6 +216,7 @@
|
|||||||
(list (hash 'id "default" 'nameKey "style-default" 'values values))))
|
(list (hash 'id "default" 'nameKey "style-default" 'values values))))
|
||||||
(check-equal? (hash-ref (hash-ref (first normalized) 'values) 'backgroundColor) "#fff4cf")
|
(check-equal? (hash-ref (hash-ref (first normalized) 'values) 'backgroundColor) "#fff4cf")
|
||||||
(check-equal? (length (normalize-cmap-styles initial-cmap-styles)) 5)
|
(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? (λ () (normalize-cmap-styles '())))
|
||||||
(check-exn exn:fail?
|
(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
|
;; Supporting functions
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(define current-schema-version 21)
|
(define current-schema-version 22)
|
||||||
|
|
||||||
(define schema-1-statements
|
(define schema-1-statements
|
||||||
(list
|
(list
|
||||||
@@ -1214,6 +1214,32 @@ SQL
|
|||||||
)
|
)
|
||||||
(record-schema-version! db 21)))
|
(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.
|
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
|
||||||
; pre : db is a writable PostgreSQL connection and config identifies the
|
; 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 18) (migrate-18->19! db) (loop (database-schema-version db)))
|
||||||
((= version 19) (migrate-19->20! 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 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)
|
((> version current-schema-version)
|
||||||
(error 'migrate-database!
|
(error 'migrate-database!
|
||||||
"database schema ~a is newer than this racket-wiki supports (~a)"
|
"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
|
are migrated to the root namespace. A compact page reference uses
|
||||||
@tt{namespace:slug}, for example @tt{racket:roadmap}. Explicit Markdown links
|
@tt{namespace:slug}, for example @tt{racket:roadmap}. Explicit Markdown links
|
||||||
may therefore use @tt{[Roadmap](racket:roadmap)}. A classic WikiWord can also be
|
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
|
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.
|
is unique. Todo items and bookmarks are grouped by namespace in the browser.
|
||||||
|
|||||||
+61
-9
@@ -15,8 +15,9 @@
|
|||||||
web-server/http
|
web-server/http
|
||||||
web-server/servlet-env
|
web-server/servlet-env
|
||||||
"private/auth.rkt"
|
"private/auth.rkt"
|
||||||
|
"private/cmap-appearance.rkt"
|
||||||
|
"private/cmap-settings.rkt"
|
||||||
"private/cmap-storage.rkt"
|
"private/cmap-storage.rkt"
|
||||||
"private/cmap-styles.rkt"
|
|
||||||
"private/config.rkt"
|
"private/config.rkt"
|
||||||
(only-in "private/database.rkt" call-with-wiki-database)
|
(only-in "private/database.rkt" call-with-wiki-database)
|
||||||
"private/http-util.rkt"
|
"private/http-util.rkt"
|
||||||
@@ -837,20 +838,63 @@ CSS
|
|||||||
(json-response
|
(json-response
|
||||||
(hash 'conceptMaps (list-archived-concept-maps config))))))
|
(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")
|
(if (string-ci=? (bytes->string/latin-1 (request-method req)) "GET")
|
||||||
(require-role
|
(require-role
|
||||||
config req 'reader
|
config req 'reader
|
||||||
(λ (_session)
|
(λ (_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
|
(require-write-role
|
||||||
config req 'editor
|
config req 'editor
|
||||||
(λ (_session)
|
(λ (_session)
|
||||||
(with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))])
|
(with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))])
|
||||||
(define body (request-json req))
|
(define body (request-json req))
|
||||||
(json-response
|
(json-response
|
||||||
(hash 'styles
|
(hash 'startCmapSlug
|
||||||
(save-cmap-styles! config (hash-ref body 'styles '())))))))))
|
(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)
|
(define (admin-restore-concept-map-handler config req slug)
|
||||||
(require-write-role
|
(require-write-role
|
||||||
@@ -1104,10 +1148,18 @@ CSS
|
|||||||
(λ (req) (concept-map-list-handler config req))]
|
(λ (req) (concept-map-list-handler config req))]
|
||||||
[("api" "cmaps") #:method "post"
|
[("api" "cmaps") #:method "post"
|
||||||
(λ (req) (concept-map-create-handler config req))]
|
(λ (req) (concept-map-create-handler config req))]
|
||||||
[("api" "cmap-styles") #:method "get"
|
[("api" "cmap-appearance") #:method "get"
|
||||||
(λ (req) (cmap-styles-handler config req))]
|
(λ (req) (cmap-appearance-handler config req))]
|
||||||
[("api" "cmap-styles") #:method "put"
|
[("api" "cmap-appearance") #:method "put"
|
||||||
(λ (req) (cmap-styles-handler config req))]
|
(λ (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"
|
[("api" "cmaps" "concept-usage") #:method "get"
|
||||||
(λ (req) (concept-usage-handler config req))]
|
(λ (req) (concept-usage-handler config req))]
|
||||||
[("api" "people") #:method "get"
|
[("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
|
`model/concept-repository.js` owns shared concepts, semantic concept relations
|
||||||
and concept ownership. `model/concept-map.js` owns one map's concept
|
and concept ownership. `model/concept-map.js` owns one map's concept
|
||||||
placements, linking phrases, connectors, metadata and presentation values.
|
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.
|
`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
|
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
|
||||||
state, content-based initial sizing, resize and relation controls. Its view
|
state, content-based initial sizing, resize and relation controls. Its view
|
||||||
records connect pure model ids to drawing nodes without making those nodes part
|
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,
|
`toDocument` and `loadDocument` round-trip the complete editor model: items,
|
||||||
formatting, positions, connectors, recursive submap membership and promoted
|
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
|
## JSON interchange
|
||||||
|
|
||||||
`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle`
|
`model/interchange.js` defines and validates the versioned
|
||||||
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
|
`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
|
complete placement and presentation document, `concepts[]` contains shared
|
||||||
content once per UUID, and `pages[]` contains the current Markdown, tags and
|
content once per UUID, and `pages[]` contains the current Markdown, tags and
|
||||||
referenced attachments of linked wiki and explanation pages. Attachments retain
|
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
|
already present in an export remain unchanged. Imported CMap slugs likewise
|
||||||
remain unchanged.
|
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.
|
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`,
|
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
|
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
|
||||||
|
|||||||
@@ -1153,6 +1153,12 @@ import { CmapView } from "./cmap-view.js";
|
|||||||
return true;
|
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) {
|
setZoom(percent) {
|
||||||
const next = Math.max(25, Math.min(300, Number(percent) || 100));
|
const next = Math.max(25, Math.min(300, Number(percent) || 100));
|
||||||
this.zoomFactor = next / 100;
|
this.zoomFactor = next / 100;
|
||||||
@@ -1866,6 +1872,19 @@ import { CmapView } from "./cmap-view.js";
|
|||||||
return this.synchronizeModel().toDocument();
|
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 = {}) {
|
loadDocument(document = {}) {
|
||||||
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
|
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");
|
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 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.
|
* The owner stays in the parent as an ordinary concept and links to targetSlug.
|
||||||
* Descendant placements become the complete contents of the child map.
|
* Descendant placements become the complete contents of the child map.
|
||||||
*/
|
*/
|
||||||
@@ -401,7 +418,10 @@ export class CmapModel {
|
|||||||
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
|
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
|
||||||
else delete parentDocument.conceptOwnerships;
|
else delete parentDocument.conceptOwnerships;
|
||||||
|
|
||||||
return { parentDocument, childDocument };
|
return {
|
||||||
|
parentModel: CmapModel.fromDocument(parentDocument),
|
||||||
|
childModel: CmapModel.fromDocument(childDocument)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toDocument() {
|
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 = "racket-wiki-cmap-bundle";
|
||||||
const FORMAT_VERSION = 1;
|
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. */
|
/* Build a self-contained Markdown report from one stored CMap model 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";
|
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
en: {
|
en: {
|
||||||
@@ -329,5 +323,42 @@
|
|||||||
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
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>
|
<form id="cmap-concept-form" novalidate>
|
||||||
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
|
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
|
||||||
<div class="cmap-concept-tabs" role="tablist" aria-label="Concept properties">
|
<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-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-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-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>
|
||||||
<div class="cmap-concept-dialog-body">
|
<div class="cmap-concept-dialog-body">
|
||||||
<section id="cmap-concept-panel-content" class="cmap-concept-panel" role="tabpanel" aria-labelledby="cmap-concept-tab-content">
|
<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/lucide.min.js"></script>
|
||||||
<script src="/vendor/diff2html-ui-base.min.js"></script>
|
<script src="/vendor/diff2html-ui-base.min.js"></script>
|
||||||
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></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/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||||
<script src="/js/cmap-export.js?id=__CMAP_CACHE_ID__"></script>
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -641,7 +639,15 @@
|
|||||||
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
|
"/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-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
|
||||||
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
|
"/cmap/model/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/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
|
||||||
"/js/wiki/routes.js": "/js/wiki/routes.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__",
|
"/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 { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
|
||||||
import { UserAdmin } from "./wiki/admin/user-admin.js";
|
import { UserAdmin } from "./wiki/admin/user-admin.js";
|
||||||
import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||||
|
import { ComboBox } from "./widgets/combobox.js";
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -53,7 +54,6 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
|||||||
siteTitle: "Racket Wiki",
|
siteTitle: "Racket Wiki",
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
conceptMaps: [],
|
conceptMaps: [],
|
||||||
people: [],
|
|
||||||
cmapConceptUsage: new Map(),
|
cmapConceptUsage: new Map(),
|
||||||
cmapConceptIdsByName: new Map(),
|
cmapConceptIdsByName: new Map(),
|
||||||
cmapPageConcepts: new Map(),
|
cmapPageConcepts: new Map(),
|
||||||
@@ -63,7 +63,7 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
|||||||
cmapSavedSnapshot: null,
|
cmapSavedSnapshot: null,
|
||||||
cmapGuardHash: "",
|
cmapGuardHash: "",
|
||||||
cmapPrototype: null,
|
cmapPrototype: null,
|
||||||
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
|
rawMarkdown: false
|
||||||
};
|
};
|
||||||
|
|
||||||
let easyMDE = null;
|
let easyMDE = null;
|
||||||
@@ -75,7 +75,7 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
|||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
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 breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
|
||||||
const cmapWorkspace = new CmapWorkspace(
|
const cmapWorkspace = new CmapWorkspace(
|
||||||
state,
|
state,
|
||||||
@@ -430,7 +430,6 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
|||||||
|
|
||||||
function toggleRawMarkdown() {
|
function toggleRawMarkdown() {
|
||||||
state.rawMarkdown = !state.rawMarkdown;
|
state.rawMarkdown = !state.rawMarkdown;
|
||||||
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
|
|
||||||
applyRawMarkdownMode();
|
applyRawMarkdownMode();
|
||||||
easyMDE.codemirror.refresh();
|
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;
|
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.
|
* goal : Rewrite namespaced Markdown targets to internal hash routes.
|
||||||
* pre : markdown is source text; fenced and indented code must remain untouched.
|
* 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.
|
* goal : Expand classic WikiWords to temporary Markdown links.
|
||||||
* pre : pages, pageAliases and conceptMaps are current catalogues.
|
* pre : pages, pageAliases and conceptMaps are current catalogues.
|
||||||
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
|
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged;
|
||||||
* result : Render-only Markdown with WikiWord links.
|
* 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) {
|
function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSlug = null) {
|
||||||
const aliases = pageMentionMap(pages, pageAliases, currentSlug);
|
const aliases = pageMentionMap(pages, pageAliases, currentSlug);
|
||||||
const lines = String(markdown || "").split("\n");
|
const lines = String(markdown || "").split("\n");
|
||||||
const result = [];
|
const result = [];
|
||||||
let fence = null;
|
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) {
|
for (const line of lines) {
|
||||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||||
@@ -250,12 +261,20 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
|
|||||||
const start = match.index;
|
const start = match.index;
|
||||||
const end = start + match[0].length;
|
const end = start + match[0].length;
|
||||||
if (positionIsProtected(start, end, protectedRanges)) continue;
|
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") {
|
if (namespace.toLocaleLowerCase() === "cmap") {
|
||||||
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
|
const conceptMap = cmapMentionTarget(match[3], conceptMaps);
|
||||||
if (conceptMap) replacements.push({ start, end, conceptMap });
|
if (conceptMap) replacements.push({ start, end, conceptMap });
|
||||||
} else {
|
} else {
|
||||||
const page = wikiWordTarget(match[2], aliases, namespace);
|
const page = wikiWordTarget(match[3], aliases, namespace);
|
||||||
if (page) replacements.push({ start, end, page });
|
if (page) replacements.push({ start, end, page });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,10 +282,10 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
|
|||||||
let expanded = line;
|
let expanded = line;
|
||||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
||||||
const replacement = replacements[index];
|
const replacement = replacements[index];
|
||||||
const link = replacement.conceptMap ?
|
const rendered = replacement.text || (replacement.conceptMap ?
|
||||||
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
|
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
|
||||||
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
|
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`);
|
||||||
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
|
expanded = expanded.slice(0, replacement.start) + rendered + expanded.slice(replacement.end);
|
||||||
}
|
}
|
||||||
result.push(expanded);
|
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 test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
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) {
|
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", {
|
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 () => {
|
test("exports metadata, person tags, relations and linked maps to the selected depth", async () => {
|
||||||
const markdown = await generateMarkdown({
|
const exporter = new CmapMarkdownExporter(
|
||||||
rootMap: root,
|
repository({ root, child }),
|
||||||
|
async (slug) => pages.get(slug));
|
||||||
|
const markdown = await exporter.export(root, {
|
||||||
maxDepth: 1,
|
maxDepth: 1,
|
||||||
includeWikiPages: false,
|
includeWikiPages: false,
|
||||||
language: "nl",
|
language: "nl"
|
||||||
loadConceptMap: async (slug) => ({ root, child }[slug]),
|
|
||||||
loadWikiPage: async (slug) => pages.get(slug)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(markdown, /CMap: Root map \(niveau 0\)/);
|
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 () => {
|
test("depth zero exports only the selected map", async () => {
|
||||||
const markdown = await generateMarkdown({
|
const exporter = new CmapMarkdownExporter(
|
||||||
rootMap: root,
|
repository({ root, child }),
|
||||||
|
async (slug) => pages.get(slug));
|
||||||
|
const markdown = await exporter.export(root, {
|
||||||
maxDepth: 0,
|
maxDepth: 0,
|
||||||
includeWikiPages: false,
|
includeWikiPages: false
|
||||||
loadConceptMap: async (slug) => ({ root, child }[slug]),
|
|
||||||
loadWikiPage: async (slug) => pages.get(slug)
|
|
||||||
});
|
});
|
||||||
assert.match(markdown, /Root map/);
|
assert.match(markdown, /Root map/);
|
||||||
assert.doesNotMatch(markdown, /## CMap: Child map/);
|
assert.doesNotMatch(markdown, /## CMap: Child map/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("optionally appends linked wiki pages and concept explanations", async () => {
|
test("optionally appends linked wiki pages and concept explanations", async () => {
|
||||||
const markdown = await generateMarkdown({
|
const exporter = new CmapMarkdownExporter(
|
||||||
rootMap: root,
|
repository({ root, child }),
|
||||||
|
async (slug) => pages.get(slug));
|
||||||
|
const markdown = await exporter.export(root, {
|
||||||
maxDepth: 0,
|
maxDepth: 0,
|
||||||
includeWikiPages: true,
|
includeWikiPages: true,
|
||||||
language: "en",
|
language: "en"
|
||||||
loadConceptMap: async (slug) => ({ root, child }[slug]),
|
|
||||||
loadWikiPage: async (slug) => pages.get(slug)
|
|
||||||
});
|
});
|
||||||
assert.match(markdown, /## Linked wiki pages/);
|
assert.match(markdown, /## Linked wiki pages/);
|
||||||
assert.match(markdown, /### Approach/);
|
assert.match(markdown, /### Approach/);
|
||||||
@@ -101,7 +110,7 @@ test("optionally appends linked wiki pages and concept explanations", async () =
|
|||||||
assert.match(markdown, /Because\./);
|
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 = {
|
const source = {
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
concepts: [
|
concepts: [
|
||||||
@@ -119,17 +128,22 @@ test("derived views contain their root subtree and inherit useful root metadata"
|
|||||||
{ id: 2, sourceId: 11, targetId: 12 }
|
{ id: 2, sourceId: 11, targetId: 12 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const result = derivedDocument(source, {
|
const derived = map("team", "Team", {
|
||||||
derivedView: { sourceCmapSlug: "source", rootItemId: 10 }
|
derivedView: { sourceCmapSlug: "source", rootItemId: 10 }
|
||||||
});
|
});
|
||||||
assert.deepEqual(result.items.map((item) => item.id), [10, 11]);
|
const sourceMap = map("source", "Source", source);
|
||||||
assert.deepEqual(result.connectors.map((connector) => connector.id), [1]);
|
const exporter = new CmapMarkdownExporter(
|
||||||
assert.deepEqual(result.metadata.tags, ["people"]);
|
repository({ source: sourceMap }),
|
||||||
assert.equal(result.metadata.summary, "Team scope");
|
async (slug) => ({ slug, title: slug, markdown: "Team explanation", tags: [] }));
|
||||||
assert.equal(result.metadata.explanationPageSlug, "cmap:team");
|
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 = {
|
const documentValue = {
|
||||||
items: [
|
items: [
|
||||||
{ id: 1, label: "A" }, { id: 2, kind: "phrase", label: "supports" },
|
{ 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 }
|
{ 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,
|
preparedMapDocument,
|
||||||
attachmentUrls,
|
attachmentUrls,
|
||||||
replaceAttachmentUrls
|
replaceAttachmentUrls
|
||||||
} = await import("../static/js/wiki/cmap/interchange.js"));
|
} = await import("../static/cmap/model/interchange.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555";
|
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,
|
ConceptMapConnector,
|
||||||
ConceptMapPhrase
|
ConceptMapPhrase
|
||||||
} from "../static/cmap/model/concept-map.js";
|
} 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 firstId = "11111111-1111-4111-8111-111111111111";
|
||||||
const secondId = "22222222-2222-4222-8222-222222222222";
|
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)
|
const extraction = CmapModel.fromDocument(document)
|
||||||
.extractSubmap(1, "nested-subject");
|
.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.deepEqual(parentDocument.items.map((item) => item.id), [1, 4]);
|
||||||
assert.equal(extraction.parentDocument.items[0].kind, "concept");
|
assert.equal(parentDocument.items[0].kind, "concept");
|
||||||
assert.equal(extraction.parentDocument.concepts
|
assert.equal(parentDocument.concepts
|
||||||
.find((concept) => concept.id === firstId).cmapSlug, "nested-subject");
|
.find((concept) => concept.id === firstId).cmapSlug, "nested-subject");
|
||||||
assert.deepEqual(extraction.parentDocument.connectors.map((connector) => ({
|
assert.deepEqual(parentDocument.connectors.map((connector) => ({
|
||||||
sourceId: connector.sourceId,
|
sourceId: connector.sourceId,
|
||||||
targetId: connector.targetId
|
targetId: connector.targetId
|
||||||
})), [{ sourceId: 4, targetId: 1 }]);
|
})), [{ sourceId: 4, targetId: 1 }]);
|
||||||
assert.deepEqual(extraction.parentDocument.conceptOwnerships, [
|
assert.deepEqual(parentDocument.conceptOwnerships, [
|
||||||
{ parentConceptId: firstId, childConceptId: thirdId }
|
{ parentConceptId: firstId, childConceptId: thirdId }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(extraction.childDocument.items.map((item) => item.id), [2, 3]);
|
assert.deepEqual(childDocument.items.map((item) => item.id), [2, 3]);
|
||||||
assert.ok(extraction.childDocument.items.every((item) => item.parentSubmapId === null));
|
assert.ok(childDocument.items.every((item) => item.parentSubmapId === null));
|
||||||
assert.deepEqual(extraction.childDocument.concepts.map((concept) => concept.id), [secondId]);
|
assert.deepEqual(childDocument.concepts.map((concept) => concept.id), [secondId]);
|
||||||
assert.deepEqual(extraction.childDocument.connectors.map((connector) => connector.id), [1]);
|
assert.deepEqual(childDocument.connectors.map((connector) => connector.id), [1]);
|
||||||
assert.equal(extraction.childDocument.conceptOwnerships, undefined);
|
assert.equal(childDocument.conceptOwnerships, undefined);
|
||||||
assert.equal(extraction.childDocument.derivedView, undefined);
|
assert.equal(childDocument.derivedView, undefined);
|
||||||
assert.equal(extraction.childDocument.metadata.summary, "Nested subject");
|
assert.equal(childDocument.metadata.summary, "Nested subject");
|
||||||
|
|
||||||
const convertedDerivedMap = CmapModel.fromDocument(document).extractSubmap(
|
const convertedDerivedMap = CmapModel.fromDocument(document).extractSubmap(
|
||||||
1,
|
1,
|
||||||
"nested-subject",
|
"nested-subject",
|
||||||
{ tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: "" }
|
{ tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: "" }
|
||||||
);
|
);
|
||||||
assert.deepEqual(convertedDerivedMap.childDocument.metadata, {
|
assert.deepEqual(convertedDerivedMap.childModel.metadata(), {
|
||||||
tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: ""
|
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"
|
"external links are serialized as shared concept content"
|
||||||
);
|
);
|
||||||
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
|
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
|
||||||
reloaded.loadDocument(savedDocument);
|
reloaded.loadModel(editor.currentModel());
|
||||||
assert.equal(
|
assert.equal(
|
||||||
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
|
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
|
||||||
"https://example.com/first",
|
"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(first);
|
||||||
editor.selectItem(third, { additive: true });
|
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.");
|
"[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", () => {
|
test("ambiguous CMap mentions are not resolved", () => {
|
||||||
const conceptMaps = [
|
const conceptMaps = [
|
||||||
{ slug: "first", title: "Zelfde Kaart" },
|
{ slug: "first", title: "Zelfde Kaart" },
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
'replace-style-confirm "Replace the existing style \"{name}\"?" 'delete-style-confirm "Delete style \"{name}\"?"
|
'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."
|
'default-style-protected "The default style cannot be changed or deleted."
|
||||||
'style-storage-failed "The style could not be stored in the wiki database."
|
'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"
|
'concept-heading-appearance "Heading" 'concept-subtext-appearance "Subtext"
|
||||||
'change-palette-color "double-click to change"
|
'change-palette-color "double-click to change"
|
||||||
'color-swatch-help "Click for the palette; double-click for a custom color"
|
'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?"
|
'replace-style-confirm "Bestaande stijl \"{name}\" vervangen?" 'delete-style-confirm "Stijl \"{name}\" verwijderen?"
|
||||||
'default-style-protected "De standaardstijl kan niet worden gewijzigd of verwijderd."
|
'default-style-protected "De standaardstijl kan niet worden gewijzigd of verwijderd."
|
||||||
'style-storage-failed "De stijl kon niet in de wikidatabase worden opgeslagen."
|
'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"
|
'concept-heading-appearance "Kop" 'concept-subtext-appearance "Subtekst"
|
||||||
'change-palette-color "dubbelklik om te wijzigen"
|
'change-palette-color "dubbelklik om te wijzigen"
|
||||||
'color-swatch-help "Klik voor het palet; dubbelklik voor een eigen kleur"
|
'color-swatch-help "Klik voor het palet; dubbelklik voor een eigen kleur"
|
||||||
|
|||||||
Reference in New Issue
Block a user