From f0562a06cc7e671b0ece2bc4073999cf6aed3279 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Wed, 2 Sep 2026 16:06:26 +0200 Subject: [PATCH] refactoring of cmaps, widgets, etc. --- HANDOFF.md | 6 +- README.md | 14 +- .../{cmap-styles.rkt => cmap-appearance.rkt} | 106 +- private/cmap-settings.rkt | 132 ++ private/migrations.rkt | 29 +- scrbl/racket-wiki.scrbl | 5 +- server.rkt | 72 +- static/cmap/README.md | 39 +- static/cmap/cmap-racket-wiki.js | 19 + static/cmap/model/appearance-repository.js | 30 + static/cmap/model/appearance.js | 181 ++ static/cmap/model/cmap-repository.js | 206 ++ static/cmap/model/concept-map.js | 24 +- .../wiki/cmap => cmap/model}/interchange.js | 2 +- static/cmap/model/json-exporter.js | 69 + static/cmap/model/json-importer.js | 166 ++ .../model/markdown-exporter.js} | 49 +- static/cmap/model/people-repository.js | 25 + static/cmap/model/settings-repository.js | 67 + static/cmap/view/appearance-editor.js | 398 ++++ static/index.html | 16 +- static/js/combobox.js | 202 -- static/js/widgets/README.md | 18 + static/js/widgets/combobox.js | 209 ++ static/js/widgets/description-preview.js | 65 + static/js/widgets/package.json | 3 + static/js/widgets/popup-menu.js | 75 + static/js/widgets/status-field.js | 27 + static/js/widgets/tab-set.js | 47 + static/js/widgets/tooltip.js | 42 + static/js/wiki.js | 7 +- static/js/wiki/cmap/dialogs/concept-dialog.js | 292 +++ static/js/wiki/cmap/dialogs/export-dialog.js | 107 + static/js/wiki/cmap/dialogs/history-dialog.js | 115 + .../js/wiki/cmap/dialogs/metadata-dialog.js | 66 + static/js/wiki/cmap/dialogs/people-dialog.js | 158 ++ static/js/wiki/cmap/dialogs/unsaved-dialog.js | 44 + static/js/wiki/cmap/workspace.js | 1924 +++-------------- static/js/wiki/markdown.js | 37 +- static/vendor-extensions/README.md | 12 - static/vendor-extensions/cmap-racket-wiki.js | 672 ------ test/cmap-appearance.test.mjs | 71 + test/cmap-export.test.js | 72 +- test/cmap-interchange.test.js | 2 +- test/cmap-json-import-export.test.mjs | 103 + test/cmap-model.test.mjs | 30 +- test/cmap-people-repository.test.mjs | 32 + test/cmap-repository.test.mjs | 90 + test/cmap-selection-layout.test.js | 4 +- test/cmap-settings-repositories.test.mjs | 68 + test/wiki-markdown.test.mjs | 17 + translate.rkt | 2 + 52 files changed, 3626 insertions(+), 2642 deletions(-) rename private/{cmap-styles.rkt => cmap-appearance.rkt} (70%) create mode 100644 private/cmap-settings.rkt create mode 100644 static/cmap/model/appearance-repository.js create mode 100644 static/cmap/model/appearance.js create mode 100644 static/cmap/model/cmap-repository.js rename static/{js/wiki/cmap => cmap/model}/interchange.js (99%) create mode 100644 static/cmap/model/json-exporter.js create mode 100644 static/cmap/model/json-importer.js rename static/{js/cmap-export.js => cmap/model/markdown-exporter.js} (91%) create mode 100644 static/cmap/model/people-repository.js create mode 100644 static/cmap/model/settings-repository.js create mode 100644 static/cmap/view/appearance-editor.js delete mode 100644 static/js/combobox.js create mode 100644 static/js/widgets/README.md create mode 100644 static/js/widgets/combobox.js create mode 100644 static/js/widgets/description-preview.js create mode 100644 static/js/widgets/package.json create mode 100644 static/js/widgets/popup-menu.js create mode 100644 static/js/widgets/status-field.js create mode 100644 static/js/widgets/tab-set.js create mode 100644 static/js/widgets/tooltip.js create mode 100644 static/js/wiki/cmap/dialogs/concept-dialog.js create mode 100644 static/js/wiki/cmap/dialogs/export-dialog.js create mode 100644 static/js/wiki/cmap/dialogs/history-dialog.js create mode 100644 static/js/wiki/cmap/dialogs/metadata-dialog.js create mode 100644 static/js/wiki/cmap/dialogs/people-dialog.js create mode 100644 static/js/wiki/cmap/dialogs/unsaved-dialog.js delete mode 100644 static/vendor-extensions/README.md delete mode 100644 static/vendor-extensions/cmap-racket-wiki.js create mode 100644 test/cmap-appearance.test.mjs create mode 100644 test/cmap-json-import-export.test.mjs create mode 100644 test/cmap-people-repository.test.mjs create mode 100644 test/cmap-repository.test.mjs create mode 100644 test/cmap-settings-repositories.test.mjs diff --git a/HANDOFF.md b/HANDOFF.md index fab2569..da34d0a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -12,7 +12,7 @@ Bijgewerkt: 25 augustus 2026 - EasyMDE/Marked voor Markdown, DOMPurify voor sanitizing, highlight.js voor code en diff2html voor versieverschillen; - lokale, tijdens setup gedownloade frontendbibliotheken, zodat normaal gebruik geen CDN nodig heeft. -De ontwikkelversie is **0.2.122** (`info.rkt`). De huidige PostgreSQL-schemaversie is **21** (`private/migrations.rkt`). +De ontwikkelversie is **0.2.122** (`info.rkt`). De huidige PostgreSQL-schemaversie is **22** (`private/migrations.rkt`). ## Belangrijk: huidige werkboom @@ -40,7 +40,7 @@ static/cmap/cmap-racket-wiki.js static/cmap/cmap.css static/index.html static/js/wiki.js -static/js/cmap-export.js +static/cmap/model/markdown-exporter.js test/cmap-export.test.js translate.rkt ``` @@ -143,7 +143,7 @@ raco make main.rkt server.rkt setup-vendor.rkt \ migrate-cmap-subpages.rkt architecture/import.rkt node --check static/js/wiki.js -node --check static/js/cmap-export.js +node --check static/cmap/model/markdown-exporter.js node --check static/js/combobox.js node --check static/cmap/cmap.js node --check static/cmap/cmap-racket-wiki.js diff --git a/README.md b/README.md index b3e6e89..7b2fe5a 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,19 @@ Classic WikiWords may also be namespace-qualified: RWS:ModelTreeWalker ``` -The WikiWord portion still follows the strict letter-only classic rule. Todo and Bookmark views group their entries by namespace; bookmark user sections remain available as a second grouping level. +Prefix a WikiWord with an exclamation mark when it should remain ordinary text: + +```text +!ModelTreeWalker +``` + +This renders as `ModelTreeWalker`, without a link or visible exclamation mark. +The same escape works for a namespace-qualified WikiWord. The WikiWord portion +still follows the strict letter-only classic rule. The render transformation is +idempotent, so a preview or component that processes the prepared Markdown a +second time cannot turn the literal WikiWord back into a link. Todo and Bookmark views group +their entries by namespace; bookmark user sections remain available as a second +grouping level. ## Editing diff --git a/private/cmap-styles.rkt b/private/cmap-appearance.rkt similarity index 70% rename from private/cmap-styles.rkt rename to private/cmap-appearance.rkt index e489ed0..e4fcd86 100644 --- a/private/cmap-styles.rkt +++ b/private/cmap-appearance.rkt @@ -1,7 +1,7 @@ #lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Wiki-wide CMap appearance styles stored in wiki_settings. +;; Wiki-wide CMap appearance stored in wiki_settings. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (require db @@ -10,10 +10,11 @@ racket/string "database.rkt") -(provide read-cmap-styles - save-cmap-styles!) +(provide read-cmap-appearance + save-cmap-appearance!) -(define setting-key "cmap.styles.v1") +(define styles-setting-key "cmap.styles.v1") +(define palette-setting-key "cmap.palette.v1") (define maximum-style-count 100) (define maximum-style-name-length 80) (define permitted-name-keys @@ -50,6 +51,10 @@ (seeded-style "warning" "style-warning" "#fff0d5" "#713b00" "Arial, Helvetica, sans-serif" 11 #t #t) (seeded-style "success" "style-success" "#e6f4e2" "#285b27" "Arial, Helvetica, sans-serif" 11 #f #t))) +(define initial-cmap-palette + '("#ffffff" "#f1f3f5" "#e7f2fb" "#e6f4e2" "#fff4cf" "#fff0d5" + "#f7dede" "#dcd8f7" "#222222" "#4479a1" "#57834a" "#a97c00")) + (define (required-string who value description [maximum-length #f]) (unless (and (string? value) (not (string=? (string-trim value) "")) @@ -130,49 +135,73 @@ (error who "the default style is required")) normalized)) +(define (normalize-cmap-palette palette [who 'cmap-palette]) + (unless (and (list? palette) (= (length palette) (length initial-cmap-palette))) + (error who "the colour palette must contain ~a colours" (length initial-cmap-palette))) + (for/list ((color (in-list palette))) + (style-color who color 'palette))) + +(define (read-setting db key fallback normalize who) + (define stored (query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" key)) + (if stored + (normalize (string->jsexpr stored) who) + (normalize fallback who))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -; goal : Read the shared CMap appearance styles. +; goal : Read the complete shared CMap appearance. ; pre : The wiki database is configured and its schema is initialized. -; post : Default styles are inserted when no style setting exists yet. -; result : A validated, normalized non-empty list of style hashes. +; post : The database is not changed; absent settings use model defaults. +; result : A hash containing validated styles and colour palette. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (read-cmap-styles config) +(define (read-cmap-appearance config) (call-with-wiki-database config (λ (db) - (let ((stored - (query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key))) - (if stored - (normalize-cmap-styles (string->jsexpr stored) 'read-cmap-styles) - (let ((encoded (jsexpr->string (normalize-cmap-styles initial-cmap-styles)))) - (query-exec - db - "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO NOTHING" - setting-key encoded (current-seconds)) - (normalize-cmap-styles - (string->jsexpr - (query-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key)) - 'read-cmap-styles))))))) + (hash 'styles + (read-setting db + styles-setting-key + initial-cmap-styles + normalize-cmap-styles + 'read-cmap-appearance) + 'palette + (read-setting db + palette-setting-key + initial-cmap-palette + normalize-cmap-palette + 'read-cmap-appearance))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -; goal : Validate and store the shared CMap appearance styles. -; pre : styles is a non-empty list containing the required default style. -; post : The normalized style setting is stored atomically in the database. -; result : The normalized list of stored style hashes. +; goal : Validate and store the complete shared CMap appearance. +; pre : appearance contains styles and palette values accepted by this module. +; post : Both normalized settings are stored in one database transaction. +; result : The normalized appearance hash. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (save-cmap-styles! config styles) - (let* ((normalized (normalize-cmap-styles styles 'save-cmap-styles!)) - (encoded (jsexpr->string normalized))) - (when (> (bytes-length (string->bytes/utf-8 encoded)) (* 128 1024)) - (error 'save-cmap-styles! "style data is too large")) - (call-with-wiki-database - config - (λ (db) - (query-exec - db - "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" - setting-key encoded (current-seconds)))) - normalized)) +(define (save-cmap-appearance! config appearance) + (unless (hash? appearance) + (error 'save-cmap-appearance! "appearance must be an object")) + (define styles + (normalize-cmap-styles (hash-ref appearance 'styles #f) 'save-cmap-appearance!)) + (define palette + (normalize-cmap-palette (hash-ref appearance 'palette #f) 'save-cmap-appearance!)) + (define encoded-styles (jsexpr->string styles)) + (define encoded-palette (jsexpr->string palette)) + (when (> (bytes-length (string->bytes/utf-8 encoded-styles)) (* 128 1024)) + (error 'save-cmap-appearance! "style data is too large")) + (call-with-wiki-database + config + (λ (db) + (call-with-transaction + db + (λ () + (query-exec + db + "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + styles-setting-key encoded-styles (current-seconds)) + (query-exec + db + "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + palette-setting-key encoded-palette (current-seconds)))))) + (hash 'styles styles 'palette palette)) (module+ test (require rackunit) @@ -187,6 +216,7 @@ (list (hash 'id "default" 'nameKey "style-default" 'values values)))) (check-equal? (hash-ref (hash-ref (first normalized) 'values) 'backgroundColor) "#fff4cf") (check-equal? (length (normalize-cmap-styles initial-cmap-styles)) 5) + (check-equal? (normalize-cmap-palette initial-cmap-palette) initial-cmap-palette) (check-exn exn:fail? (λ () (normalize-cmap-styles '()))) (check-exn exn:fail? (λ () diff --git a/private/cmap-settings.rkt b/private/cmap-settings.rkt new file mode 100644 index 0000000..dcab667 --- /dev/null +++ b/private/cmap-settings.rkt @@ -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) diff --git a/private/migrations.rkt b/private/migrations.rkt index 7c00ed2..2075e61 100644 --- a/private/migrations.rkt +++ b/private/migrations.rkt @@ -21,7 +21,7 @@ ;; Supporting functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define current-schema-version 21) +(define current-schema-version 22) (define schema-1-statements (list @@ -1214,6 +1214,32 @@ SQL ) (record-schema-version! db 21))) +(define (migrate-21->22! db) + (query-exec + db + #<19! db) (loop (database-schema-version db))) ((= version 19) (migrate-19->20! db) (loop (database-schema-version db))) ((= version 20) (migrate-20->21! db) (loop (database-schema-version db))) + ((= version 21) (migrate-21->22! db) (loop (database-schema-version db))) ((> version current-schema-version) (error 'migrate-database! "database schema ~a is newer than this racket-wiki supports (~a)" diff --git a/scrbl/racket-wiki.scrbl b/scrbl/racket-wiki.scrbl index 57bbb45..a63b6e9 100644 --- a/scrbl/racket-wiki.scrbl +++ b/scrbl/racket-wiki.scrbl @@ -79,7 +79,10 @@ Pages have an explicit namespace in addition to their stable slug. Existing page are migrated to the root namespace. A compact page reference uses @tt{namespace:slug}, for example @tt{racket:roadmap}. Explicit Markdown links may therefore use @tt{[Roadmap](racket:roadmap)}. A classic WikiWord can also be -qualified, for example @tt{RWS:ModelTreeWalker}. +qualified, for example @tt{RWS:ModelTreeWalker}. Prefixing a WikiWord with an +exclamation mark suppresses the implicit link. Thus @tt{!ModelTreeWalker} +renders as the ordinary text @tt{ModelTreeWalker}; the exclamation mark is not +shown. The same escape applies to a namespace-qualified WikiWord. The namespace and slug are stored separately in PostgreSQL and their combination is unique. Todo items and bookmarks are grouped by namespace in the browser. diff --git a/server.rkt b/server.rkt index 87abe38..a04baa3 100644 --- a/server.rkt +++ b/server.rkt @@ -15,8 +15,9 @@ web-server/http web-server/servlet-env "private/auth.rkt" + "private/cmap-appearance.rkt" + "private/cmap-settings.rkt" "private/cmap-storage.rkt" - "private/cmap-styles.rkt" "private/config.rkt" (only-in "private/database.rkt" call-with-wiki-database) "private/http-util.rkt" @@ -837,20 +838,63 @@ CSS (json-response (hash 'conceptMaps (list-archived-concept-maps config)))))) -(define (cmap-styles-handler config req) +(define (cmap-appearance-handler config req) (if (string-ci=? (bytes->string/latin-1 (request-method req)) "GET") (require-role config req 'reader (λ (_session) - (json-response (hash 'styles (or (read-cmap-styles config) 'null))))) + (json-response (read-cmap-appearance config)))) (require-write-role config req 'editor (λ (_session) (with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))]) (define body (request-json req)) - (json-response - (hash 'styles - (save-cmap-styles! config (hash-ref body 'styles '()))))))))) + (json-response (save-cmap-appearance! config body))))))) + +(define (cmap-settings-handler config req) + (require-role + config req 'reader + (λ (session) + (json-response + (read-cmap-settings config (wiki-user-id (wiki-session-user session))))))) + +(define (cmap-start-setting-handler config req) + (require-write-role + config req 'editor + (λ (_session) + (with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))]) + (define body (request-json req)) + (json-response + (hash 'startCmapSlug + (save-start-cmap! config (hash-ref body 'startCmapSlug "")))))))) + +(define (cmap-page-guides-setting-handler config req) + (require-write-role + config req 'reader + (λ (session) + (with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))]) + (define body (request-json req)) + (json-response + (hash 'pageGuidesVisible + (save-cmap-page-guides! + config + (wiki-user-id (wiki-session-user session)) + (hash-ref body 'pageGuidesVisible 'missing)))))))) + +(define (cmap-zoom-setting-handler config req) + (require-write-role + config req 'reader + (λ (session) + (with-handlers ([exn:fail? (λ (e) (json-error 400 (exn-message e)))]) + (define body (request-json req)) + (json-response + (hash 'zoomPercent + (save-cmap-zoom! + config + (wiki-user-id (wiki-session-user session)) + (hash-ref body 'cmapSlug "") + (hash-ref body 'contextKey "") + (hash-ref body 'zoomPercent #f)))))))) (define (admin-restore-concept-map-handler config req slug) (require-write-role @@ -1104,10 +1148,18 @@ CSS (λ (req) (concept-map-list-handler config req))] [("api" "cmaps") #:method "post" (λ (req) (concept-map-create-handler config req))] - [("api" "cmap-styles") #:method "get" - (λ (req) (cmap-styles-handler config req))] - [("api" "cmap-styles") #:method "put" - (λ (req) (cmap-styles-handler config req))] + [("api" "cmap-appearance") #:method "get" + (λ (req) (cmap-appearance-handler config req))] + [("api" "cmap-appearance") #:method "put" + (λ (req) (cmap-appearance-handler config req))] + [("api" "cmap-settings") #:method "get" + (λ (req) (cmap-settings-handler config req))] + [("api" "cmap-settings" "start") #:method "put" + (λ (req) (cmap-start-setting-handler config req))] + [("api" "cmap-settings" "page-guides") #:method "put" + (λ (req) (cmap-page-guides-setting-handler config req))] + [("api" "cmap-settings" "zoom") #:method "put" + (λ (req) (cmap-zoom-setting-handler config req))] [("api" "cmaps" "concept-usage") #:method "get" (λ (req) (concept-usage-handler config req))] [("api" "people") #:method "get" diff --git a/static/cmap/README.md b/static/cmap/README.md index f04ad81..282d3fe 100644 --- a/static/cmap/README.md +++ b/static/cmap/README.md @@ -12,9 +12,26 @@ same hit-test and drag lifecycle as selection, so it does not depend on a DOM `model/concept-repository.js` owns shared concepts, semantic concept relations and concept ownership. `model/concept-map.js` owns one map's concept placements, linking phrases, connectors, metadata and presentation values. -Neither model module contains DOM or drawing-engine objects. +`model/cmap-repository.js` owns the server boundary: it decodes stored CMaps, +normalizes legacy concept identities and converts backend documents to and +from `CmapModel`. It is the only browser-side CMap model module that knows the +`/api/cmaps` routes. `model/appearance.js` owns the shared named styles, colour +palette, validation and style matching. `model/appearance-repository.js` +serializes that model through the appearance API. `model/settings-repository.js` +owns the wiki start CMap and the authenticated user's A4-guide and +per-CMap-context zoom settings. `model/people-repository.js` is the server +boundary for the people referenced by concept tags. +The repository objects keep only an in-memory session copy; the backend remains +authoritative. None of these model modules contains DOM or drawing-engine +objects, and CMap state is not persisted in browser storage. `cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance. +`view/appearance-editor.js` presents the appearance model in the concept dialog +and coordinates explicit style and palette changes with its repository. +The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own +their fields, validation and browser events. The workspace supplies the active +CMap and performs editor transitions, while the controllers use repository +APIs for stored CMaps and people instead of calling backend routes directly. `cmap-racket-wiki.js` contains the wiki-specific editor controller, selection state, content-based initial sizing, resize and relation controls. Its view records connect pure model ids to drawing nodes without making those nodes part @@ -59,12 +76,19 @@ metadata remain intact, but its document no longer depends on the parent CMap. `toDocument` and `loadDocument` round-trip the complete editor model: items, formatting, positions, connectors, recursive submap membership and promoted -map references. The wiki host persists this JSON document through its CMap API. +map references. The public `currentModel`, `loadModel` and `replaceModel` +methods let the workspace pass complete domain models to and from the editor. +The CMap repository, rather than the workspace, persists those models. ## JSON interchange -`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle` -format. A bundle mirrors the normalized database model: `cmaps[]` contains the +`model/interchange.js` defines and validates the versioned +`racket-wiki-cmap-bundle` format. `model/json-exporter.js` builds complete bundles +through `CmapRepository` and the supplied page and attachment loaders. +`model/json-importer.js` reads those bundles, stores CMaps through the same +repository and performs the required page and attachment writes. The workspace +decides only how conflicts are presented to the user. +A bundle mirrors the normalized database model: `cmaps[]` contains the complete placement and presentation document, `concepts[]` contains shared content once per UUID, and `pages[]` contains the current Markdown, tags and referenced attachments of linked wiki and explanation pages. Attachments retain @@ -85,6 +109,13 @@ a temporary identity into a UUID through the central Racket UUID helper. UUIDs already present in an export remain unchanged. Imported CMap slugs likewise remain unchanged. +`model/markdown-exporter.js` reads stored models through `CmapRepository` but +produces a readable +report instead of an importable bundle. It is independent of the JSON +exporter because the report follows linked maps and optional wiki pages for a +different purpose. Additional output formats can therefore be implemented as +separate exporters without adding format-specific code to the workspace. + The editor keeps up to one hundred complete document states for Undo and Redo. One drag or resize gesture forms one history step. The public `undo`, `redo`, `canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js index cbdb89e..c26e1ae 100644 --- a/static/cmap/cmap-racket-wiki.js +++ b/static/cmap/cmap-racket-wiki.js @@ -1153,6 +1153,12 @@ import { CmapView } from "./cmap-view.js"; return true; } + /** Replace the editor contents with a public CMap domain model. */ + replaceModel(model) { + if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required"); + return this.replaceDocument(model.toDocument()); + } + setZoom(percent) { const next = Math.max(25, Math.min(300, Number(percent) || 100)); this.zoomFactor = next / 100; @@ -1866,6 +1872,19 @@ import { CmapView } from "./cmap-view.js"; return this.synchronizeModel().toDocument(); } + /** Return the synchronized domain model currently edited by this view. */ + currentModel() { + this.saveCurrentContextLayout(); + this.refreshConceptMapReferences(); + return this.synchronizeModel(); + } + + /** Load a public CMap domain model into an empty editor. */ + loadModel(model) { + if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required"); + this.loadDocument(model.toDocument()); + } + loadDocument(document = {}) { if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) { throw new Error("A concept map document can only be loaded into an empty editor"); diff --git a/static/cmap/model/appearance-repository.js b/static/cmap/model/appearance-repository.js new file mode 100644 index 0000000..0a2a76d --- /dev/null +++ b/static/cmap/model/appearance-repository.js @@ -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); + } +} diff --git a/static/cmap/model/appearance.js b/static/cmap/model/appearance.js new file mode 100644 index 0000000..4b1c53f --- /dev/null +++ b/static/cmap/model/appearance.js @@ -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 +}; diff --git a/static/cmap/model/cmap-repository.js b/static/cmap/model/cmap-repository.js new file mode 100644 index 0000000..7541a3a --- /dev/null +++ b/static/cmap/model/cmap-repository.js @@ -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)); + } +} diff --git a/static/cmap/model/concept-map.js b/static/cmap/model/concept-map.js index 72e7df7..44cdf79 100644 --- a/static/cmap/model/concept-map.js +++ b/static/cmap/model/concept-map.js @@ -232,8 +232,25 @@ export class CmapModel { return new CmapModel(repository, conceptMap); } + /** Return a detached copy of the map metadata. */ + metadata() { + return copy(this.conceptMap.metadata); + } + + /** Return a detached derived-view reference, or null for an independent map. */ + derivedView() { + return copy(this.conceptMap.derivedView); + } + + /** Return a new model with changed map metadata and unchanged contents. */ + withMetadata(metadata) { + const document = this.toDocument(); + document.metadata = copy(metadata); + return CmapModel.fromDocument(document); + } + /** - * Split an embedded submap into a standalone map and its remaining parent. + * Split an embedded submap into standalone child and remaining parent models. * The owner stays in the parent as an ordinary concept and links to targetSlug. * Descendant placements become the complete contents of the child map. */ @@ -401,7 +418,10 @@ export class CmapModel { if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships; else delete parentDocument.conceptOwnerships; - return { parentDocument, childDocument }; + return { + parentModel: CmapModel.fromDocument(parentDocument), + childModel: CmapModel.fromDocument(childDocument) + }; } toDocument() { diff --git a/static/js/wiki/cmap/interchange.js b/static/cmap/model/interchange.js similarity index 99% rename from static/js/wiki/cmap/interchange.js rename to static/cmap/model/interchange.js index 74afc35..9e72837 100644 --- a/static/js/wiki/cmap/interchange.js +++ b/static/cmap/model/interchange.js @@ -1,4 +1,4 @@ -/* Versioned JSON interchange for Racket Wiki concept maps. */ +/* Versioned JSON interchange for the Racket Wiki CMap model. */ const FORMAT = "racket-wiki-cmap-bundle"; const FORMAT_VERSION = 1; diff --git a/static/cmap/model/json-exporter.js b/static/cmap/model/json-exporter.js new file mode 100644 index 0000000..a0e6f76 --- /dev/null +++ b/static/cmap/model/json-exporter.js @@ -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) + }; + } +} diff --git a/static/cmap/model/json-importer.js b/static/cmap/model/json-importer.js new file mode 100644 index 0000000..e2d4fc5 --- /dev/null +++ b/static/cmap/model/json-importer.js @@ -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); + } +} diff --git a/static/js/cmap-export.js b/static/cmap/model/markdown-exporter.js similarity index 91% rename from static/js/cmap-export.js rename to static/cmap/model/markdown-exporter.js index 8084e62..df143b3 100644 --- a/static/js/cmap-export.js +++ b/static/cmap/model/markdown-exporter.js @@ -1,10 +1,4 @@ -/* Build a self-contained Markdown report from one stored CMap and its links. */ -((root, factory) => { - const api = factory(); - if (typeof module === "object" && module.exports) module.exports = api; - if (root) root.RacketWikiCmapExport = api; -})(typeof window !== "undefined" ? window : globalThis, () => { - "use strict"; +/* Build a self-contained Markdown report from one stored CMap model and its links. */ const labels = { en: { @@ -329,5 +323,42 @@ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`; } - return { generateMarkdown, decodedDocument, derivedDocument, relationLines }; -}); +/** + * Export a CMap and optionally its linked maps and wiki pages as Markdown. + * The exporter reads stored models through their repository and has no + * knowledge of dialogs, downloads or other browser presentation. + */ +export class CmapMarkdownExporter { + constructor(cmapRepository, loadWikiPage) { + if (!cmapRepository || typeof cmapRepository.load !== "function" || + typeof loadWikiPage !== "function") { + throw new TypeError("A CMap repository and wiki-page loader are required"); + } + this.cmapRepository = cmapRepository; + this.loadWikiPage = loadWikiPage; + } + + /** Build the complete Markdown report without changing source data. */ + async export(rootMap, options = {}) { + return generateMarkdown({ + rootMap: this.exportMap(rootMap), + maxDepth: options.maxDepth, + includeWikiPages: options.includeWikiPages, + language: options.language, + loadConceptMap: async (slug) => this.exportMap(await this.cmapRepository.load(slug)), + loadWikiPage: this.loadWikiPage + }); + } + + /** Present one stored model through the record shape consumed by the report builder. */ + exportMap(storedMap) { + if (!storedMap?.slug || typeof storedMap.toDocument !== "function") { + throw new TypeError("Markdown export requires a stored CMap"); + } + return { + slug: storedMap.slug, + title: storedMap.title, + document: storedMap.toDocument() + }; + } +} diff --git a/static/cmap/model/people-repository.js b/static/cmap/model/people-repository.js new file mode 100644 index 0000000..72cee15 --- /dev/null +++ b/static/cmap/model/people-repository.js @@ -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 }) + }); + } +} diff --git a/static/cmap/model/settings-repository.js b/static/cmap/model/settings-repository.js new file mode 100644 index 0000000..0c8469d --- /dev/null +++ b/static/cmap/model/settings-repository.js @@ -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; + } +} diff --git a/static/cmap/view/appearance-editor.js b/static/cmap/view/appearance-editor.js new file mode 100644 index 0000000..50423b3 --- /dev/null +++ b/static/cmap/view/appearance-editor.js @@ -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(); + }); + } + } +} diff --git a/static/index.html b/static/index.html index dec8299..a46ca7a 100644 --- a/static/index.html +++ b/static/index.html @@ -398,8 +398,8 @@

Edit concept

- - + +
@@ -631,9 +631,7 @@ - -