From 20c1584016fd788800278f7f8605e964449c5098 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Sat, 22 Aug 2026 13:51:15 +0200 Subject: [PATCH] Lots of changes to the cmap stuff --- HANDOFF.md | 210 ++++++ README.md | 109 ++- architecture/pages/data-en-versies.md | 9 +- architecture/pages/gedrag.md | 6 +- architecture/pages/testbaarheid.md | 2 +- info.rkt | 2 +- migrate-cmap-subpages.rkt | 235 +++++++ private/cmap-storage.rkt | 157 ++++- private/migrations.rkt | 42 +- server.rkt | 33 +- static/cmap/cmap-racket-wiki.js | 963 ++++++++++++++++++++++++-- static/cmap/cmap.css | 402 ++++++++++- static/cmap/cmap.js | 42 +- static/css/wiki.css | 59 ++ static/index.html | 132 +++- static/js/wiki.js | 852 +++++++++++++++++++++-- translate.rkt | 54 +- 17 files changed, 3105 insertions(+), 204 deletions(-) create mode 100644 HANDOFF.md create mode 100644 migrate-cmap-subpages.rkt diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..52dbffd --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,210 @@ +# Overdracht racket-wiki + +Bijgewerkt: 22 augustus 2026 + +## Project in het kort + +`racket-wiki` is een kleine, zelfgehoste wiki met: + +- een Racket-backend op `web-server-lib`; +- PostgreSQL als opslag voor gebruikers, sessies, pagina's, versies, bijlagen en concept maps (CMaps); +- een frontend in gewone HTML, CSS en JavaScript, zonder npm- of bundlerstap; +- 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.99** (`info.rkt`). De huidige PostgreSQL-schemaversie is **12** (`private/migrations.rkt`). + +## Belangrijk: huidige werkboom + +De branch is `main` en volgt `origin/main`. De laatste commit is: + +```text +63b7ca0 cmap functions, email, architecture documentation. +``` + +Er staan belangrijke, nog niet gecommitte wijzigingen in de werkboom. Die vormen samen de ontwikkeling van 0.2.95 t/m 0.2.99 en moeten worden behouden. Bij de laatste inventarisatie waren gewijzigd: + +```text +README.md +architecture/pages/data-en-versies.md +architecture/pages/gedrag.md +architecture/pages/testbaarheid.md +info.rkt +private/cmap-storage.rkt +private/migrations.rkt +server.rkt +static/cmap/cmap-racket-wiki.js +static/cmap/cmap.css +static/cmap/cmap.js +static/css/wiki.css +static/index.html +static/js/wiki.js +translate.rkt +``` + +Nieuw en nog untracked: + +```text +migrate-cmap-subpages.rkt +``` + +Voer dus geen reset, checkout of brede formattering uit voordat deze wijzigingen zijn beoordeeld en veilig gecommitte. `wiki-data/` en `compiled/` zijn lokale/gegenereerde directories en staan in `.gitignore`. + +## Snel starten + +Benodigd: + +- Racket met de dependencies uit `info.rkt` (`crypto-lib`, `db-lib`, `net-lib`, `net-cookies-lib`, `openssl-lib` en `web-server-lib`); +- PostgreSQL; de database moet vooraf bestaan en de opgegeven rol moet tabellen en indexen mogen maken; +- internettoegang tijdens de eerste setup om de vastgepinde browserbibliotheken te downloaden. + +In deze ontwikkelomgeving is Racket 9.1 gebruikt. Start vanuit de projectroot: + +```sh +racket main.rkt --data ./wiki-data --port 8080 +``` + +Open daarna `http://127.0.0.1:8080/`. Een incomplete installatie gaat automatisch naar `/setup`. Daar worden de databaseverbinding, migraties, frontendbestanden en eerste administrator geregeld. Luisteren op alle interfaces kan met: + +```sh +racket main.rkt --data ./wiki-data --listen '*' --port 8080 +``` + +Gebruik achter HTTPS altijd `--secure-cookie`. Andere relevante opties zijn `--title`, `--language en|nl` en de hersteloptie `--create-admin USER PASSWORD`. + +De database-instellingen, inclusief eventueel het wachtwoord, staan in `wiki-data/database.rktd`. Dit bestand hoort niet in Git of in logs. De applicatie probeert het bestand met mode `0600` aan te maken. + +Als alleen de browserdependencies hersteld moeten worden: + +```sh +racket setup-vendor.rkt --data ./wiki-data +``` + +## Codekaart + +| Pad | Verantwoordelijkheid | +| --- | --- | +| `main.rkt` | CLI en programmatische entrypoints `start` en `start-wiki` | +| `server.rkt` | HTTP-routering, setup/loginpagina's en alle JSON/API-handlers | +| `private/config.rkt` | runtimeconfiguratie en paden onder de datadirectory | +| `private/setup.rkt` | eerste websetup en herstelcontrole | +| `private/database.rkt` | PostgreSQL-configuratie, verbindingen en migratiestart | +| `private/migrations.rkt` | geordende, transactionele databaseschemamigraties | +| `private/storage.rkt` | pagina's, historie, zoeken, todo's, bookmarks, aliassen en uploads | +| `private/cmap-storage.rkt` | opslag, historie, zoeken en gebruikstellingen voor CMaps | +| `private/auth.rkt` | wachtwoorden, sessies, CSRF, rollen en password-reset-tokens | +| `private/mail.rkt` | SMTP-instellingen en resetmails | +| `translate.rkt` | Engelse/Nederlandse server- en frontendvertalingen | +| `static/index.html` | skelet van de ingelogde single-page-interface | +| `static/js/wiki.js` | applicatiestatus, API-calls, pagina-editor en CMap-hostintegratie | +| `static/cmap/` | lokaal onderhouden CMap-renderer, editorlaag en styling | +| `architecture/` | importeerbare Nederlandse architectuurpagina's en twee CMaps | +| `scrbl/racket-wiki.scrbl` | package/API-documentatie in Scribble | + +De browserfrontend heeft bewust geen buildstap. Wijzig bronbestanden rechtstreeks en test ze in de browser. Externe browserlibraries staan runtime onder `wiki-data/static/vendor/`; wijzig die niet als applicatiebron. + +## Belangrijke invarianten + +- Er is geen anonieme wiki-toegang. `reader` leest, `editor` schrijft en `admin` beheert gebruikers en instellingen. +- Schrijvende API-calls vereisen naast een sessie ook de CSRF-token. +- Pagina's hebben een aparte `namespace` en stabiele `slug`; de combinatie is uniek. Een titelwijziging verandert de slug niet. +- Elke paginasave wijzigt `pages` en schrijft in dezelfde transactie een onveranderlijke `page_versions`-rij. +- Pagina-updates en CMap-updates gebruiken optimistic locking en horen bij een verouderde basisversie HTTP 409 te geven. +- Pagina's en CMaps worden gearchiveerd (soft delete); histories blijven behouden. +- Bijlagen staan als `BYTEA` in PostgreSQL. Alleen vertrouwde rasterformaten worden inline geserveerd; andere bestanden worden downloads. +- Markdown gaat vóór invoegen in de DOM door DOMPurify. +- Verhoog `current-schema-version` alleen samen met een nieuwe, opeenvolgende migratie. Een database met een nieuwer schema dan de code moet geweigerd blijven. + +## Lopend werk: 0.2.95–0.2.99 + +De niet-gecommitte reeks bouwt vooral de persistente CMap-functionaliteit uit: + +- stabiele, wiki-brede `conceptId`-identiteiten en gekoppelde plaatsingen met een eigen layout; +- conceptaspecten, beschrijvingspagina's, stijlpresets, afzonderlijke titel-/synopsisopmaak en een bewerkbaar kleurenpalet; +- directe relaties met Alt, gelinkte copy/paste en robuust herstel bij relaties met ontbrekende eindpunten; +- afgeleide, opgeslagen sub-CMapweergaven die naar de canonieke parentgraph verwijzen; +- keuze van een start-CMap en per-view verborgen concepten; +- autosave zonder historievervuiling, maximaal vijf handmatige saves en onbeperkte snapshots; +- verwijderen van afzonderlijke handmatige CMap-history-items/snapshots; +- een paneel op wikipagina's met alle gekoppelde CMap-concepten en plaatsingstellingen. + +Migratie 12 schoont oude CMap-autosavehistorie op en beperkt herkenbare handmatige saves tot vijf. `migrate-cmap-subpages.rkt` is een aparte contentmigratie voor oudere inline sub-CMaps; dit is geen databaseschemamigratie. + +Gebruik die contentmigratie altijd eerst als dry-run: + +```sh +racket migrate-cmap-subpages.rkt --data ./wiki-data +racket migrate-cmap-subpages.rkt --data ./wiki-data --apply +``` + +Zonder `--apply` wordt niets gewijzigd. Met `--apply` gebeurt de omzetting in één PostgreSQL-transactie. Maak desondanks eerst een databasebackup en beoordeel de gemelde slugs. + +## Testen en huidige verificatiestatus + +De automatische testdekking is beperkt. Op 22 augustus 2026 zijn de volgende controles succesvol uitgevoerd: + +```sh +raco test architecture/import.rkt +# 12 tests passed + +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/combobox.js +node --check static/cmap/cmap.js +node --check static/cmap/cmap-racket-wiki.js +``` + +Er is nog geen geautomatiseerde backendintegratietest tegen PostgreSQL en geen browsertestsuite. De huidige 0.2.95–0.2.99-werkboom is dus wel compileerbaar en syntactisch geldig, maar nog niet in deze overdracht end-to-end gevalideerd. + +Voer vóór commit/release minimaal deze smoke tests uit op een kopie of aparte testdatabase: + +1. Verse `/setup`, login/logout en de drie rollen. +2. Pagina maken, wijzigen, hernoemen, history bekijken, conflict (409), namespace-link en upload. +3. CMap maken, autosave, expliciet opslaan, snapshot maken/verwijderen en historylimiet controleren. +4. Een concept meerdere keren en in meerdere CMaps plaatsen; controleer tellingen en het paneel op de gekoppelde wikipagina. +5. Een inline sub-CMap met de dry-run en daarna `--apply` migreren; controleer parent, derived view en oude histories. +6. Start-CMap, verborgen concepten, linked copy/paste, undo/redo en beschadigde relatie zonder endpoint controleren. +7. Zoekresultaten, bookmarks, todo's, aliassen en adminoverzichten nalopen. +8. Indien mail relevant is: SMTP-test en de volledige vergeten-wachtwoordflow. + +## Architectuurdocumentatie importeren + +Valideren en wijzigingen vooraf bekijken: + +```sh +racket architecture/import.rkt --data ./wiki-data --dry-run +``` + +Importeren: + +```sh +racket architecture/import.rkt --data ./wiki-data --author "Naam ontwikkelaar" +``` + +De import is herhaalbaar en gebruikt source hashes. Lokaal aangepaste geïmporteerde pagina's/CMaps worden standaard overgeslagen. Gebruik `--overwrite-modified` alleen na controle van hun histories. + +## Mailconfiguratie + +Password-resetmail kan via de admininterface/database of via omgevingsvariabelen worden ingesteld. Ondersteund zijn: + +```text +RACKET_WIKI_PUBLIC_URL +RACKET_WIKI_SMTP_HOST +RACKET_WIKI_SMTP_PORT +RACKET_WIKI_SMTP_FROM +RACKET_WIKI_SMTP_USER +RACKET_WIKI_SMTP_PASSWORD +RACKET_WIKI_SMTP_TLS +RACKET_WIKI_SMTP_ACCEPT_UNTRUSTED_CERTIFICATES +RACKET_WIKI_RESET_LIMIT +``` + +Accepteer onbetrouwbare certificaten alleen bewust voor een vertrouwde lokale SMTP-server. Zet secrets nooit in documentatie, commits of testoutput. + +## Aanbevolen eerstvolgende stap + +Maak eerst een databasebackup en test de volledige niet-gecommitte 0.2.95–0.2.99-reeks met bovenstaande smoke tests. Beoordeel daarna de diff als één samenhangende CMap-wijziging, werk zo nodig `README.md`, Scribble-documentatie en architectuurpagina's gelijk bij, en commit pas wanneer migratie 12 en `migrate-cmap-subpages.rkt` op representatieve data zijn geverifieerd. + +Voor functionele details en releasehistorie is `README.md` de uitgebreidste bron. De bestanden onder `architecture/pages/` beschrijven ontwerpbeslissingen en kwaliteitsafspraken; `architecture/pages/code-regels.md` is de beste start voor projectconventies. diff --git a/README.md b/README.md index c59d6a0..9615630 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Version 0.2.31 adds page namespaces as database metadata and extends wiki references to forms such as `RWS:ModelTreeWalker` and `[roadmap](racket:roadmap)`. Todo items and bookmarks are grouped by namespace. The source has also been documented more thoroughly, especially `static/js/wiki.js`. -Current development version: **0.2.94**. +Current development version: **0.2.99**. A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. @@ -834,3 +834,110 @@ instead of passing the invalid alphabetic escapes `\\r` and `\\n` to the regexp parser. The CMap-embed expression uses an actual newline character in its exclusion class for the same reason. Regression tests cover LF and CRLF source markers and single-line CMap embeds. + +### 0.2.95 + +CMap concepts can link to missing wiki pages by entering a new title or address; opening +that link uses the normal missing-page creation flow. Every concept can also have aspects +and a Markdown description page, defaulting to the `cmap` namespace. Separate icons open +the description and linked target, while double-clicking opens the concept editor. + +The CMap document now separates stable concept identity from diagram-specific placement. +Position, size, colours and typography are retained per root/child-map context. Linked +copies made with Ctrl/Cmd+C and Ctrl/Cmd+V share their label, synopsis, aspects and link +targets while keeping independent presentation. Existing version-1 documents are upgraded +in memory when loaded. + +Holding Alt while drawing a relation creates a direct arrow without a linking phrase. The +tools menu is moved outside the drawing stacking context so it remains above large maps. +The active stored CMap now shows its title and `cmap:slug`; clicking it copies ready-to-use +Markdown link syntax. + +Concept descriptions now appear as rendered Markdown previews when the serif `I` is hovered +or focused. Its light-grey/white state shows whether a description exists. The compact concept +dialog adds local colour palettes, reusable style presets, normal/bold and italic controls, and +wrapped synopsis text. + +Switching between stored CMaps now destroys the previous drawing surface before another one is +created. Deferred redraws from the old map are ignored, preventing duplicate layers and relations +whose visual endpoints no longer match their concepts. CMap content also scrolls underneath the +sticky toolbar instead of over it. + +The sticky CMap header now paints through the main view's former top padding, so scrolled concepts +are clipped underneath the complete header area and cannot remain visible above its controls. + +Deleting a concept or relation now limits orphan cleanup to linking phrases directly affected by +that deletion. Unrelated incomplete relations already present in a document are left untouched, +so deleting one relation cannot unexpectedly remove other parts of the map. + +A stored relation whose source or target concept is missing no longer prevents the complete CMap +from opening. Such a damaged relation is omitted from the drawing but retained verbatim in the +document, including across automatic saves, until its remaining endpoint is explicitly deleted. + +Every ordinary concept placement shows a compact `(n)` usage indicator. It counts all placements +sharing that concept identity across every active stored CMap and updates immediately for unsaved +changes in the open map. New concepts receive wiki-wide UUID-based identities. Legacy document-local +IDs are qualified with their CMap slug so coincidentally equal old IDs are never conflated. + +### 0.2.96 + +**Make separate CMap** now creates a stored derived view of a sub-CMap instead of copying or moving +its graph. The parent remains the canonical owner of every concept and relation. Its sub-CMap can +still expand and collapse in place, while a separate arrow opens the same elements with their own +page-specific layout at a normal `cmap:slug` address. + +Elements inside an expanded nested view can be hidden only in the parent context. A toolbar dropdown +lists those hidden concepts and restores them individually. The standalone view continues to contain +all elements. Relations crossing the submap boundary remain in the canonical graph; on the standalone +page they appear from the viewport edge with the external concept name, which navigates back to the +source CMap when clicked. + +An expanded sub-CMap has an independently movable group frame. Its concept remains at its anchor +position and a quiet grey line connects that anchor to the group. Collapsing removes only the frame +and line; expanding restores the group at its last position for that CMap context. + +The tools menu can mark the current map as the browser's start CMap. The sidebar CMaps icon and the +plain `#cmaps` route open that preference, falling back to the first available map if it no longer +exists. + +For a complete installation, `racket migrate-cmap-subpages.rkt` first reports every legacy internal +page and the target slug without changing data. Run `racket migrate-cmap-subpages.rkt --apply` to +perform the conversion in one PostgreSQL transaction. The parent retains its complete document and +receives only the child-view slugs; each stored child contains a `derivedView` reference to the parent +slug and submap root id. Every preceding parent and child version remains available. A target slug is +only reused when it belongs to the earlier subpage migration; an unrelated existing map aborts the +transaction. + +### 0.2.97 + +CMap autosave now updates only the current document and optimistic version number. It no longer +creates a history row. Explicit saves from Ctrl/Cmd-S or **Save now** retain at most the five newest +versions per CMap, while explicitly named snapshots remain unlimited. The history dialog displays +only those manual versions and snapshots and allows an editor to delete either kind after a +confirmation; deleting history never changes the current CMap. + +Database migration 12 removes legacy automatic-save history rows, reclassifies recognizable legacy +manual saves, and transactionally trims those manual saves to five per CMap. Migration, repair and +other internal safety versions remain stored outside the everyday history list. + +### 0.2.98 + +Concept headings and synopsis text now have independent text colour, font family, point size, bold +and italic controls. These presentation fields belong to the placement and its CMap context; linked +concept identity remains shared while each view can keep its own typography. Legacy concepts inherit +their existing shared typography for both text parts until an editor changes either section. + +The compact colour palette is now editable. A single click applies a palette colour to the current +form, while a double-click on a palette tile opens the browser's native HTML colour picker and +replaces that reusable tile. Double-clicking the current-colour swatch opens the same picker for a +one-off colour. The palette is stored locally in the browser, whereas every concept placement stores +its selected hexadecimal colour directly; changing a palette tile therefore never recolours existing +concepts. + +### 0.2.99 + +A wiki page now shows a **Connected CMap concepts** panel when one or more current concepts link to +that page. Concepts are grouped by stable `conceptId`; each row shows the concept label, total +placement count and clickable chips for every CMap containing it. Repeated placements in one CMap +show their local count. The panel follows page aliases and uses only current, non-archived CMap +documents—not historical versions. diff --git a/architecture/pages/data-en-versies.md b/architecture/pages/data-en-versies.md index 6c36879..5d8a7e1 100644 --- a/architecture/pages/data-en-versies.md +++ b/architecture/pages/data-en-versies.md @@ -16,7 +16,7 @@ De database bevat zowel de huidige toestand als de auditgeschiedenis. De browser | `attachments` | Metadata en volledige binaire inhoud van uploads | | `attachment_references` | Huidige en historische verwijzingen vanuit paginaversies | | `concept_maps` | Actuele titel, JSONB-document en versieteller per CMap | -| `concept_map_versions` | Volledige onveranderlijke CMap-snapshots | +| `concept_map_versions` | Expliciete CMap-snapshots en maximaal vijf handmatige opslagversies per CMap | | `password_reset_tokens` | Gehashte, tijdelijke en eenmalige herstelcodes | | `wiki_settings` | Beheerinstellingen, momenteel vooral e-mail | | `wiki_schema` | Geïnstalleerde migratieversie | @@ -39,13 +39,13 @@ Een paginaopslag is één atomaire transactie: 6. Vervang huidige bijlageverwijzingen en leg historische verwijzingen voor de nieuwe versie vast. 7. Commit alles, of niets. -Een CMap-opslag volgt hetzelfde kernpatroon: rij vergrendelen, versienummer vergelijken, actuele JSONB bijwerken en dezelfde volledige toestand aan `concept_map_versions` toevoegen. +Een CMap-opslag volgt voor de actuele projectie hetzelfde kernpatroon: rij vergrendelen, versienummer vergelijken en actuele JSONB bijwerken. Autosave verhoogt wel het optimistische versienummer, maar maakt geen historieregel. Een expliciete opslag voegt een volledige toestand aan `concept_map_versions` toe en verwijdert oudere handmatige versies boven de grens van vijf. Benoemde snapshots vallen niet onder die grens. ## Waarom volledige snapshots Volledige snapshots zijn eenvoudig te begrijpen, herstellen en vergelijken. Er is geen keten van patches nodig om versie 37 te reconstrueren en een defecte diff kan de historie niet onleesbaar maken. Voor de verwachte wikiomvang is deze eenvoud belangrijker dan maximale opslagcompactheid. -De prijs is lineaire databasegroei met het aantal versies maal de documentgrootte. Vooral autosave van grote CMaps kan daardoor op termijn veel JSONB opslaan. Bewaarbeleid of deduplicatie hoort pas te worden ontworpen nadat echte datagroei is gemeten; zie [Verwachte performance](racket-wiki:performance). +De prijs is lineaire databasegroei met het aantal versies maal de documentgrootte. Voor pagina's en benoemde CMap-snapshots is dat bewust; CMap-autosaves zijn daarom uitgesloten van historie en handmatige CMap-opslag is begrensd op vijf versies. Zie [Verwachte performance](racket-wiki:performance). ## Afgeleide gegevens @@ -75,7 +75,8 @@ Een nieuw schema vereist: De volgende regels mogen niet alleen in de browser staan: -- iedere actuele versie heeft een overeenkomstige onveranderlijke snapshot; +- iedere actuele paginaversie heeft een overeenkomstige onveranderlijke snapshot; +- CMap-autosave wijzigt alleen de actuele projectie; CMap-historie bevat alleen snapshots en maximaal vijf handmatige versies; - versienummers nemen per object strikt toe; - een stale editor overschrijft geen nieuwere toestand; - e-mailadressen zijn hoofdletterongevoelig uniek wanneer ingevuld; diff --git a/architecture/pages/gedrag.md b/architecture/pages/gedrag.md index d6a76a3..e9c17e9 100644 --- a/architecture/pages/gedrag.md +++ b/architecture/pages/gedrag.md @@ -40,9 +40,11 @@ Hernoemen of verplaatsen behoudt dezelfde pagina-id en maakt een alias voor het ## Conceptmap bewerken -De CMap-editor houdt items, verbindingszinnen, connectors, groepen en sub-CMaps in één document met `schemaVersion: 1`. Bewerkingen gaan eerst door een lokale Undo/Redo-historie. Na een afgeronde handeling plant de browser een autosave; een nieuwe handeling verschuift die timer. Een lopende save kan een volgende save noodzakelijk maken, zodat tussentijdse wijzigingen niet verdwijnen. +De CMap-editor houdt conceptidentiteit, plaatsingen, verbindingszinnen, connectors, groepen en sub-CMaps in één document met `schemaVersion: 2`. Positie en vormgeving horen bij de plaatsing en CMap-context. Kop en subtekst hebben daarbinnen elk hun eigen typografie. Het bewerkbare kleurenpalet is alleen browserinstelling; een toegepaste kleur wordt als concrete hexwaarde bij de plaatsing opgeslagen en verandert dus niet wanneer het palette later wijzigt. Bewerkingen gaan eerst door een lokale Undo/Redo-historie. Na een afgeronde handeling plant de browser een autosave; een nieuwe handeling verschuift die timer. Een lopende save kan een volgende save noodzakelijk maken, zodat tussentijdse wijzigingen niet verdwijnen. -Iedere serveropslag controleert opnieuw het basisversienummer en schrijft een volledige CMap-snapshot. Autosave beschermt de werkstand. **Snapshot maken** schrijft ook zonder inhoudsverschil een herkenbaar historisch moment met een omschrijving. Een historische versie laden verandert alleen de editor; pas een volgende opslag maakt daarvan een nieuwe actuele versie. +Iedere serveropslag controleert opnieuw het basisversienummer en schrijft de actuele CMap-toestand. Autosave beschermt de werkstand zonder een historieregel te maken. Ctrl/Cmd-S en **Nu opslaan** maken een handmatige historieversie, waarvan per CMap de vijf nieuwste blijven staan. **Snapshot maken** schrijft ook zonder inhoudsverschil een herkenbaar, onbeperkt bewaard moment met een omschrijving. Een historische versie laden verandert alleen de editor; pas een volgende opslag maakt daarvan een nieuwe actuele versie. Een editor kan een afzonderlijk geschiedenisitem verwijderen zonder de actuele CMap te veranderen. + +Wanneer een actueel CMap-concept een `pageSlug` heeft, toont de gelezen wikipagina onder de inhoud een verwijzing naar dat concept. Plaatsingen worden op `conceptId` gegroepeerd en iedere betrokken CMap is rechtstreeks aanklikbaar. Alleen actuele, niet-gearchiveerde CMap-documenten tellen mee; historische CMap-versies leveren geen navigatielinks op. ## Zoeken, Recent en navigatiegraaf diff --git a/architecture/pages/testbaarheid.md b/architecture/pages/testbaarheid.md index d84bca6..27e77d4 100644 --- a/architecture/pages/testbaarheid.md +++ b/architecture/pages/testbaarheid.md @@ -30,7 +30,7 @@ De eerste volwaardige suite moet deze risico's afdekken: 6. sessie-, CSRF- en herstelcodes worden correct gevalideerd en ingetrokken; 7. CMap-documenten maken een stabiele load/save-roundtrip; 8. slepen op een sub-CMapframe verplaatst alle afstammelingen, slepen op het hoofdconcept niet; -9. autosave, handmatige save en snapshot krijgen het juiste historische `action` en `summary`; +9. autosave maakt geen historie, handmatige opslag houdt er maximaal vijf en snapshots blijven onbeperkt; 10. migratie vanaf iedere ondersteunde schemaversie levert dezelfde actuele structuur. ## Tijdelijke PostgreSQL-database diff --git a/info.rkt b/info.rkt index 1460a34..93f07a8 100644 --- a/info.rkt +++ b/info.rkt @@ -1,7 +1,7 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.2.94") +(define version "0.2.99") (define license 'MIT) (define collection "racket-wiki") (define pkg-desc diff --git a/migrate-cmap-subpages.rkt b/migrate-cmap-subpages.rkt new file mode 100644 index 0000000..a5528c0 --- /dev/null +++ b/migrate-cmap-subpages.rkt @@ -0,0 +1,235 @@ +#lang racket/base + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Migrate document-internal CMap pages to shared stored views. +;; +;; The parent document remains the canonical graph. A stored child CMap only +;; records which submap root it displays, so concepts, relations and layout +;; contexts are never copied or removed from the parent. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require db + json + racket/cmdline + racket/list + racket/path + racket/string + "private/config.rkt" + "private/database.rkt" + "private/storage.rkt") + +(define migration-author "system:cmap-shared-submap-migration") +(define replaceable-authors + '("system:cmap-subpage-migration" "system:cmap-subpage-migration-repair")) + +(define (json-list document key) + (define value (hash-ref document key '())) + (if (list? value) value '())) + +(define (parse-document text) + (define first (string->jsexpr text)) + (define document (if (string? first) (string->jsexpr first) first)) + (unless (hash? document) + (error 'parse-document "stored CMap document is not an object")) + document) + +(define (legacy-submap? item) + (and (equal? (hash-ref item 'kind "") "submap") + (eq? (hash-ref item 'separateMap #f) #t) + (let ((slug (hash-ref item 'cmapSlug 'null))) + (or (eq? slug 'null) + (and (string? slug) (string=? (string-trim slug) "")))))) + +(define (submap-title item) + (define child-map (hash-ref item 'childMap "")) + (define label (hash-ref item 'label "")) + (cond ((and (string? child-map) (not (string=? (string-trim child-map) ""))) + (string-trim child-map)) + ((and (string? label) (not (string=? (string-trim label) ""))) + (string-trim label)) + (else "Sub-CMap"))) + +(define (derived-document source-slug root-id) + (hash 'schemaVersion 2 + 'derivedView (hash 'sourceCmapSlug source-slug 'rootItemId root-id) + 'concepts '() + 'items '() + 'connectors '() + 'conceptMaps '())) + +(define (link-parent-document document roots) + (define slugs + (for/hash ((root (in-list roots))) + (values (hash-ref root 'id) (title->slug (submap-title root))))) + (hash-set + document + 'items + (for/list ((item (in-list (json-list document 'items)))) + (define slug (hash-ref slugs (hash-ref item 'id #f) #f)) + (if slug + (hash-set (hash-set item 'cmapSlug slug) 'separateMap #t) + item)))) + +(define (load-parent-rows connection [lock? #f]) + (query-rows + connection + (string-append + "SELECT id, slug, title, document::text, current_version " + "FROM concept_maps WHERE archived=FALSE " + "AND EXISTS (SELECT 1 FROM jsonb_array_elements(" + "CASE WHEN jsonb_typeof(document->'items')='array' " + "THEN document->'items' ELSE '[]'::jsonb END) item " + "WHERE coalesce(item->>'kind','')='submap' " + "AND coalesce((item->>'separateMap')::boolean,FALSE)=TRUE " + "AND coalesce(item->>'cmapSlug','')='') " + "ORDER BY id" + (if lock? " FOR UPDATE" "")))) + +(define (existing-child connection slug [lock? #f]) + (query-maybe-row + connection + (string-append + "SELECT id, title, current_version, created_by FROM concept_maps " + "WHERE slug=$1 AND archived=FALSE" + (if lock? " FOR UPDATE" "")) + slug)) + +(define (replaceable-child? row) + (and row (member (vector-ref row 3) replaceable-authors))) + +(define (check-child! connection parent-slug root [lock? #f]) + (define slug (title->slug (submap-title root))) + (define row (existing-child connection slug lock?)) + (when (and row (not (replaceable-child? row))) + (error 'migrate-cmap-subpages + "target CMap ~a already exists and was not created by the earlier migration" + slug)) + (hash 'slug slug + 'title (submap-title root) + 'rootId (hash-ref root 'id) + 'sourceSlug parent-slug + 'existing row)) + +(define (plans-for-row connection row [lock? #f]) + (define document (parse-document (vector-ref row 3))) + (define roots (filter legacy-submap? (json-list document 'items))) + (define children + (for/list ((root (in-list roots))) + (check-child! connection (vector-ref row 1) root lock?))) + (values children (link-parent-document document roots))) + +(define (report-plan connection) + (define total 0) + (for ((row (in-list (load-parent-rows connection)))) + (define-values (children ignored-parent) (plans-for-row connection row)) + (printf "Parent ~a (~a): ~a shared submap view(s)\n" + (vector-ref row 1) (vector-ref row 2) (length children)) + (for ((child (in-list children))) + (set! total (add1 total)) + (printf " ~a -> ~a (~a)\n" + (hash-ref child 'title) + (hash-ref child 'slug) + (if (hash-ref child 'existing) "replace earlier migration result" "create")))) + (printf "Total: ~a shared view(s). No data changed.\n" total) + total) + +(define (write-child! connection child now) + (define document-text + (jsexpr->string + (derived-document (hash-ref child 'sourceSlug) (hash-ref child 'rootId)))) + (define existing (hash-ref child 'existing)) + (if existing + (let* ((map-id (vector-ref existing 0)) + (title (vector-ref existing 1)) + (next-version (add1 (vector-ref existing 2)))) + (query-exec + connection + #<string parent-document)) + (query-exec + connection + #<complete-path directory))))) + (("--apply") + "Apply the migration; without this flag only a dry-run is performed" + (set! apply? #t))) + (call-with-wiki-database + config + (lambda (connection) + (if apply? (apply-migration! connection) (report-plan connection))))) diff --git a/private/cmap-storage.rkt b/private/cmap-storage.rkt index 9892457..c5e2ca3 100644 --- a/private/cmap-storage.rkt +++ b/private/cmap-storage.rkt @@ -11,11 +11,13 @@ "storage.rkt") (provide list-concept-maps + list-concept-usage list-recent-concept-maps search-concept-maps read-concept-map concept-map-history read-concept-map-version + delete-concept-map-version! create-concept-map! rename-concept-map! update-concept-map! @@ -81,6 +83,23 @@ VALUES ($1, $2, $3, $4::text::jsonb, $5, $6, $7, $8) SQL map-id version title document-text author action summary now)) +(define (prune-manual-concept-map-versions! db map-id) + (query-exec + db + #<concept-map row #f))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Count current concept placements across every active CMap. +; pre : Concept-map documents use an items array when placements exist. +; post : No database state is changed. +; result : Rows containing global concept identity, linked page, concept/map +; labels, CMap slug and placement count. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (list-concept-usage config) + (call-with-wiki-database + config + (λ (db) + (for/list ((row (in-list + (query-rows + db + #<> 'conceptId' ~ '^concept-[0-9]+$' + THEN concat('legacy:', cm.slug, ':', item ->> 'conceptId') + ELSE item ->> 'conceptId' + END AS concept_id, + coalesce(nullif(repository.document ->> 'label', ''), + nullif(item ->> 'label', ''), + 'Concept') AS concept_label, + coalesce(nullif(repository.document ->> 'pageSlug', ''), + nullif(item ->> 'pageSlug', '')) AS page_slug + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' + ELSE '[]'::jsonb + END + ) AS item + LEFT JOIN LATERAL ( + SELECT concept_value AS document + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' + ELSE '[]'::jsonb + END + ) AS concept_entry(concept_value) + WHERE concept_value ->> 'id' = item ->> 'conceptId' + LIMIT 1 + ) repository ON TRUE + WHERE cm.archived = FALSE + AND nullif(item ->> 'conceptId', '') IS NOT NULL + AND coalesce(item ->> 'kind', 'concept') NOT IN ('phrase', 'submap') +) +SELECT concept_id, slug, cmap_title, concept_label, page_slug, count(*) +FROM placements +GROUP BY concept_id, slug, cmap_title, concept_label, page_slug +ORDER BY concept_id, lower(cmap_title), cmap_title +SQL + )))) + (hash 'conceptId (vector-ref row 0) + 'cmapSlug (vector-ref row 1) + 'cmapTitle (vector-ref row 2) + 'label (vector-ref row 3) + 'pageSlug (if (sql-null? (vector-ref row 4)) + 'null + (vector-ref row 4)) + 'count (vector-ref row 5)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List recently edited current concept maps. ; pre : Database schema migration 9 has been installed. @@ -207,7 +293,8 @@ SQL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a persistent concept map. ; pre : slug is unused, title is non-empty and document is a JSON object. -; post : One concept_maps row exists at version 1. +; post : One concept_maps row exists at version 1; history starts only when +; the user explicitly saves or creates a snapshot. ; result : The newly stored concept-map hash. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (create-concept-map! config slug title document author) @@ -222,24 +309,22 @@ SQL (call-with-transaction db (λ () - (define map-id - (query-value - db - #<text document)) @@ -338,9 +424,12 @@ SQL now author (vector-ref row 0)) - (insert-concept-map-version! - db (vector-ref row 0) next-version clean-title document-text - author action summary now))))) + (unless (string=? action "autosave") + (insert-concept-map-version! + db (vector-ref row 0) next-version clean-title document-text + author action summary now)) + (when (string=? action "manual") + (prune-manual-concept-map-versions! db (vector-ref row 0))))))) (read-concept-map config slug)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -365,7 +454,7 @@ SQL #<number version))) + (and version-number + (call-with-wiki-database + config + (lambda (db) + (and + (query-maybe-value + db + #<12! db) + ;; Older clients stored both automatic and explicit saves as `edit`. Preserve + ;; the explicit saves, discard redundant autosave copies, and enforce the new + ;; five-item manual history limit per CMap. + (query-exec db + #< 5 +SQL + ) + (record-schema-version! db 12)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Bring a racket-wiki PostgreSQL database to the current schema. ; pre : db is a writable PostgreSQL connection and config identifies the @@ -428,6 +465,9 @@ SQL (define after-user-profiles (database-schema-version db)) (when (= after-user-profiles 10) (migrate-10->11! db)) + (define after-concept-map-history (database-schema-version db)) + (when (= after-concept-map-history 11) + (migrate-11->12! db)) (define resulting-version (database-schema-version db)) (when (> resulting-version current-schema-version) (error 'migrate-database! diff --git a/server.rkt b/server.rkt index dc72430..63842e1 100644 --- a/server.rkt +++ b/server.rkt @@ -342,6 +342,12 @@ CSS (λ (_session) (json-response (hash 'conceptMaps (list-concept-maps config)))))) +(define (concept-usage-handler config req) + (require-role + config req 'reader + (λ (_session) + (json-response (hash 'placements (list-concept-usage config)))))) + (define (request-concept-map-document body) (define document (hash-ref body 'document #f)) (unless (hash? document) @@ -400,6 +406,14 @@ CSS (json-response result) (json-error 404 "Concept map version not found"))))) +(define (concept-map-version-delete-handler config req slug version) + (require-write-role + config req 'editor + (lambda (_session) + (if (delete-concept-map-version! config slug version) + (json-response (hash 'ok #t)) + (json-error 404 "Concept map history item not found"))))) + (define (concept-map-update-handler config req slug) (require-write-role config req 'editor @@ -414,12 +428,25 @@ CSS (define document (request-concept-map-document body)) (define base-version (hash-ref body 'baseVersion "")) (define snapshot? (eq? (hash-ref body 'snapshot #f) #t)) + (define supplied-save-kind (hash-ref body 'saveKind #f)) (define supplied-summary (hash-ref body 'summary "Edited CMap")) (define summary (if (and (string? supplied-summary) (not (string=? (string-trim supplied-summary) ""))) (substring supplied-summary 0 (min 500 (string-length supplied-summary))) "Edited CMap")) + (define action + (cond + (snapshot? "snapshot") + ((member supplied-save-kind '("autosave" "manual" "snapshot")) + supplied-save-kind) + ((not supplied-save-kind) + ;; Compatibility with the client version from before saveKind was + ;; explicit. Known automatic summaries must not enter history. + (if (member summary '("Automatic save" "Automatisch opgeslagen")) + "autosave" + "manual")) + (else (error 'concept-map-update-handler "invalid save kind")))) (json-response (update-concept-map! config slug @@ -428,7 +455,7 @@ CSS (wiki-user-username (wiki-session-user session)) base-version summary - (if snapshot? "snapshot" "edit"))))))) + action)))))) (define (concept-map-rename-handler config req slug) (require-write-role @@ -998,12 +1025,16 @@ CSS (λ (req) (concept-map-list-handler config req))] [("api" "cmaps") #:method "post" (λ (req) (concept-map-create-handler config req))] + [("api" "cmaps" "concept-usage") #:method "get" + (λ (req) (concept-usage-handler config req))] [("api" "cmaps" (string-arg)) #:method "get" (λ (req slug) (concept-map-get-handler config req slug))] [("api" "cmaps" (string-arg) "history") #:method "get" (λ (req slug) (concept-map-history-handler config req slug))] [("api" "cmaps" (string-arg) "versions" (string-arg)) #:method "get" (λ (req slug version) (concept-map-version-handler config req slug version))] + [("api" "cmaps" (string-arg) "versions" (string-arg)) #:method "delete" + (λ (req slug version) (concept-map-version-delete-handler config req slug version))] [("api" "cmaps" (string-arg)) #:method "put" (λ (req slug) (concept-map-update-handler config req slug))] [("api" "cmaps" (string-arg) "rename") #:method "post" diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js index 226764c..e0c9064 100644 --- a/static/cmap/cmap-racket-wiki.js +++ b/static/cmap/cmap-racket-wiki.js @@ -7,7 +7,7 @@ (() => { "use strict"; - const debugPrefix = "[racket-wiki:cmap 0.2.94]"; + const debugPrefix = "[racket-wiki:cmap 0.2.99]"; function debug(message, details) { if (details === undefined) { @@ -65,6 +65,29 @@ return Number.isFinite(value) ? value : fallback; } + function conceptDescriptionReference(label, id) { + const slug = String(label || "") + .normalize("NFKD") + .toLocaleLowerCase() + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120) + .replace(/-+$/g, ""); + return `cmap:${slug || `concept-${id}`}`; + } + + function newConceptId() { + if (window.crypto && typeof window.crypto.randomUUID === "function") { + try { + return `concept-${window.crypto.randomUUID()}`; + } catch (_error) { + // randomUUID can be exposed but forbidden in an insecure/file context. + } + } + return `concept-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + } + ////////////////////////////////////////////////////////////////////////////// // Editor ////////////////////////////////////////////////////////////////////////////// @@ -87,6 +110,9 @@ this.onSubMapPromoted = options.onSubMapPromoted || null; this.onMapChange = options.onMapChange || null; this.onOpenCmap = options.onOpenCmap || null; + this.onOpenStoredSubMap = options.onOpenStoredSubMap || null; + this.onOpenBoundaryReference = options.onOpenBoundaryReference || null; + this.onVisibilityChange = options.onVisibilityChange || null; this.onConfirmDetachFromSubmap = options.onConfirmDetachFromSubmap || null; this.onEditItem = options.onEditItem || null; this.onCreateConnectedItem = options.onCreateConnectedItem || null; @@ -103,12 +129,15 @@ this.map.onActivation((component, event) => this.handleMapActivation(component, event)); this.items = []; this.connectors = []; + this.unresolvedConnectors = []; this.conceptMaps = new Map(); this.activeMapRoot = null; this.mapHistory = []; this.selectedItem = null; this.selectedItems = new Set(); this.selectedConnector = null; + this.copiedConceptIds = []; + this.destroyed = false; this.nextId = 1; this.nextConnectorId = 1; this.nextGroupId = 1; @@ -123,6 +152,9 @@ this.historyReady = false; this.historyRestoring = false; this.historyLimit = 100; + this.boundaryLayer = null; + this.boundaryScrollHandler = () => this.refreshBoundaryReferences(); + this.canvas.addEventListener("scroll", this.boundaryScrollHandler, { passive: true }); this.installMarqueeSelection(); debug("editor created", { canvas: elementDescription(canvas), @@ -200,15 +232,21 @@ for (const connector of this.connectors) connector.link.remove(); for (const record of this.items) { if (record.submapFrameElement) record.submapFrameElement.remove(); + if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove(); record.node.remove(); } this.items = []; this.connectors = []; + this.unresolvedConnectors = []; this.conceptMaps = new Map(); this.activeMapRoot = null; this.mapHistory = []; this.nextId = 1; this.nextConnectorId = 1; + if (this.boundaryLayer) { + this.boundaryLayer.remove(); + this.boundaryLayer = null; + } this.nextGroupId = 1; } @@ -223,7 +261,7 @@ this.mapHistory = mapHistoryIds .map((id) => this.items.find((item) => item.id === id)) .filter(Boolean); - this.refreshSubmapVisibility(); + this.applyCurrentContextLayout(); const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); } finally { @@ -279,9 +317,13 @@ const autoHeight = options.height === undefined || options.height === null; const record = { id, + conceptId: options.conceptId || (kind === "phrase" ? null : newConceptId()), kind, label: options.label || "Concept", synopsis: options.synopsis || "", + aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [], + descriptionPageSlug: kind === "phrase" ? null : + (options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)), pageSlug: options.pageSlug || null, cmapSlug: options.cmapSlug || null, parentCmapLink: Boolean(options.parentCmapLink), @@ -293,7 +335,34 @@ submapInitialized: false, separateMap: Boolean(options.separateMap), mapReference: options.mapReference || null, + hiddenContexts: new Set(Array.isArray(options.hiddenContexts) ? + options.hiddenContexts.map(String) : []), + layouts: options.layouts && typeof options.layouts === "object" ? + Object.fromEntries(Object.entries(options.layouts).map(([context, layout]) => [context, { + x: numberOr(Number(layout.x), numberOr(options.x, 0)), + y: numberOr(Number(layout.y), numberOr(options.y, 0)), + width: numberOr(Number(layout.width), numberOr(options.width, 220)), + height: numberOr(Number(layout.height), numberOr(options.height, 70)), + backgroundColor: layout.backgroundColor || options.backgroundColor || "#f3f6f8", + borderColor: layout.borderColor || options.borderColor || "#5d6d7e", + textColor: layout.textColor || options.textColor || "#222222", + fontFamily: layout.fontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", + fontSize: layout.fontSize || options.fontSize || "11pt", + fontWeight: layout.fontWeight || options.fontWeight || "700", + fontStyle: layout.fontStyle || options.fontStyle || "normal", + synopsisTextColor: layout.synopsisTextColor || layout.textColor || + options.synopsisTextColor || options.textColor || "#222222", + synopsisFontFamily: layout.synopsisFontFamily || layout.fontFamily || + options.synopsisFontFamily || options.fontFamily || "Arial, Helvetica, sans-serif", + synopsisFontSize: layout.synopsisFontSize || + options.synopsisFontSize || "0.84em", + synopsisFontWeight: layout.synopsisFontWeight || layout.fontWeight || + options.synopsisFontWeight || options.fontWeight || "700", + synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle || + options.synopsisFontStyle || options.fontStyle || "normal" + }])) : {}, submapFrameElement: null, + submapAnchorLineElement: null, imageSource: options.imageSource || "", backgroundColor: options.backgroundColor || "#f3f6f8", borderColor: options.borderColor || "#5d6d7e", @@ -304,6 +373,14 @@ textColor: options.textColor || "#222222", fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif", fontSize: options.fontSize || "11pt", + fontWeight: options.fontWeight || "700", + fontStyle: options.fontStyle || "normal", + synopsisTextColor: options.synopsisTextColor || options.textColor || "#222222", + synopsisFontFamily: options.synopsisFontFamily || options.fontFamily || + "Arial, Helvetica, sans-serif", + synopsisFontSize: options.synopsisFontSize || "0.84em", + synopsisFontWeight: options.synopsisFontWeight || options.fontWeight || "700", + synopsisFontStyle: options.synopsisFontStyle || options.fontStyle || "normal", width: options.width || (kind === "phrase" ? 145 : 220), height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)), autoWidth, @@ -311,6 +388,9 @@ fitContentPending: autoWidth || autoHeight, node: null }; + record.usageCount = record.conceptId && kind !== "phrase" && kind !== "submap" ? + this.items.filter((item) => item.conceptId === record.conceptId && + item.kind !== "phrase" && item.kind !== "submap").length + 1 : null; const node = this.map.node({ content: this.itemHtml(record), @@ -326,6 +406,7 @@ }); record.node = node; this.items.push(record); + this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []); this.refreshConceptMapReferences(); node.onRendered((_renderedNode, element) => this.decorateItem(record, element)); node.onMove((_movedNode, x, y) => this.handleItemMove(record, x, y)); @@ -338,6 +419,21 @@ return record; } + refreshConceptUsageIndicators(conceptIds = null) { + const ids = conceptIds ? new Set(conceptIds.filter(Boolean)) : new Set(this.items + .map((item) => item.conceptId).filter(Boolean)); + for (const conceptId of ids) { + const peers = this.items.filter((item) => item.conceptId === conceptId && + item.kind !== "phrase" && item.kind !== "submap"); + for (const peer of peers) { + peer.usageCount = peers.length; + if (!peer.node) continue; + peer.node.attr({ content: this.itemHtml(peer) }); + peer.node.redraw(); + } + } + } + addSubmapItem(parentSubmap, options = {}) { if (!parentSubmap || parentSubmap.kind !== "submap") { throw TypeError("A submap parent is required"); @@ -360,6 +456,227 @@ } } + mapContextKey(root = this.activeMapRoot) { + return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root"; + } + + itemLayout(record) { + return { + x: Number(record.node.attr("x")), + y: Number(record.node.attr("y")), + width: Number(record.node.attr("width")), + height: Number(record.node.attr("height")), + backgroundColor: record.backgroundColor, + borderColor: record.borderColor, + textColor: record.textColor, + fontFamily: record.fontFamily, + fontSize: record.fontSize, + fontWeight: record.fontWeight, + fontStyle: record.fontStyle, + synopsisTextColor: record.synopsisTextColor, + synopsisFontFamily: record.synopsisFontFamily, + synopsisFontSize: record.synopsisFontSize, + synopsisFontWeight: record.synopsisFontWeight, + synopsisFontStyle: record.synopsisFontStyle + }; + } + + saveCurrentContextLayout() { + const context = this.mapContextKey(); + for (const record of this.items.filter((item) => this.isEffectiveItemVisible(item))) { + record.layouts[context] = this.itemLayout(record); + } + } + + applyCurrentContextLayout() { + const context = this.mapContextKey(); + const visible = this.items.filter((item) => this.isEffectiveItemVisible(item)); + let offset = { x: 0, y: 0 }; + if (this.activeMapRoot && !this.activeMapRoot.layouts[context]) { + const rootLayout = this.itemLayout(this.activeMapRoot); + offset = { x: 80 - rootLayout.x, y: 80 - rootLayout.y }; + } + + for (const record of visible) { + if (!record.layouts[context]) { + const current = this.itemLayout(record); + record.layouts[context] = { + ...current, + x: current.x + offset.x, + y: current.y + offset.y + }; + } + const layout = record.layouts[context]; + record.width = layout.width; + record.height = layout.height; + record.backgroundColor = layout.backgroundColor || record.backgroundColor; + record.borderColor = layout.borderColor || record.borderColor; + record.textColor = layout.textColor || record.textColor; + record.fontFamily = layout.fontFamily || record.fontFamily; + record.fontSize = layout.fontSize || record.fontSize; + record.fontWeight = layout.fontWeight || record.fontWeight; + record.fontStyle = layout.fontStyle || record.fontStyle; + record.synopsisTextColor = layout.synopsisTextColor || layout.textColor || + record.synopsisTextColor; + record.synopsisFontFamily = layout.synopsisFontFamily || layout.fontFamily || + record.synopsisFontFamily; + record.synopsisFontSize = layout.synopsisFontSize || record.synopsisFontSize; + record.synopsisFontWeight = layout.synopsisFontWeight || layout.fontWeight || + record.synopsisFontWeight; + record.synopsisFontStyle = layout.synopsisFontStyle || layout.fontStyle || + record.synopsisFontStyle; + record.node.attr({ + x: layout.x, + y: layout.y, + width: layout.width, + height: layout.height, + content: this.itemHtml(record), + backgroundColor: record.backgroundColor, + borderColor: record.borderColor, + textColor: record.textColor + }); + record.node.redraw(); + } + this.refreshSubmapVisibility(); + this.refreshConnectorGeometry(); + window.requestAnimationFrame(() => { + if (!this.destroyed && this.canvas.isConnected) this.refreshConnectorGeometry(); + }); + } + + refreshConnectorGeometry() { + for (const connector of this.connectors) { + const source = this.connectorEndpoint(connector.source); + const target = this.connectorEndpoint(connector.target); + this.applyConnectorVisualEndpoints(connector, source, target); + if (source && target && source !== target) { + if (connector.link.sourceNode() !== source.node) connector.link.sourceNode(source.node); + if (connector.link.targetNode() !== target.node) connector.link.targetNode(target.node); + connector.visualSource = source; + connector.visualTarget = target; + connector.link.straighten(); + connector.link.redraw(); + } + } + this.refreshBoundaryReferences(); + } + + itemInsideActiveMap(record) { + return Boolean(this.activeMapRoot && + (record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot))); + } + + boundaryConceptFor(record, crossedConnector, inside) { + if (!record || record.kind !== "phrase") return record; + for (const connector of this.connectors) { + if (connector === crossedConnector) continue; + if (connector.source !== record && connector.target !== record) continue; + const neighbour = connector.source === record ? connector.target : connector.source; + if (neighbour.kind === "phrase") continue; + if (this.itemInsideActiveMap(neighbour) === inside) return neighbour; + } + return record; + } + + refreshBoundaryReferences() { + if (this.boundaryLayer) { + this.boundaryLayer.remove(); + this.boundaryLayer = null; + } + if (!this.activeMapRoot) return; + const surface = this.surfaceElement(); + if (!surface) return; + const crossings = []; + for (const connector of this.connectors) { + const sourceInside = this.itemInsideActiveMap(connector.source); + const targetInside = this.itemInsideActiveMap(connector.target); + if (sourceInside === targetInside) continue; + const insideRecord = sourceInside ? connector.source : connector.target; + const outsideRecord = sourceInside ? connector.target : connector.source; + const insideConcept = this.boundaryConceptFor(insideRecord, connector, true); + const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false); + if (!insideConcept || !outsideConcept || !this.isItemVisible(insideConcept)) continue; + crossings.push({ connector, sourceInside, insideConcept, outsideConcept }); + } + if (!crossings.length) return; + + const layer = document.createElement("div"); + layer.className = "rw-cmap-boundary-layer"; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("rw-cmap-boundary-lines"); + const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); + marker.setAttribute("id", "rw-cmap-boundary-arrow"); + marker.setAttribute("viewBox", "0 0 10 10"); + marker.setAttribute("refX", "9"); + marker.setAttribute("refY", "5"); + marker.setAttribute("markerWidth", "7"); + marker.setAttribute("markerHeight", "7"); + marker.setAttribute("orient", "auto-start-reverse"); + const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path"); + arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z"); + arrow.setAttribute("fill", "#4a5560"); + marker.append(arrow); + definitions.append(marker); + svg.append(definitions); + layer.append(svg); + surface.append(layer); + this.boundaryLayer = layer; + + const viewLeft = this.canvas.scrollLeft / this.zoomFactor; + const viewTop = this.canvas.scrollTop / this.zoomFactor; + const viewWidth = this.canvas.clientWidth / this.zoomFactor; + const viewHeight = this.canvas.clientHeight / this.zoomFactor; + const buttonWidth = 190; + const occupied = { left: [], right: [] }; + const reserveY = (side, desired) => { + let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40)); + while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34; + if (y > viewTop + viewHeight - 40) y = viewTop + 12; + occupied[side].push(y); + return y; + }; + + for (const crossing of crossings) { + const insideX = Number(crossing.insideConcept.node.attr("x")) + + (Number(crossing.insideConcept.node.attr("width")) / 2); + const insideY = Number(crossing.insideConcept.node.attr("y")) + + (Number(crossing.insideConcept.node.attr("height")) / 2); + const outsideX = Number(crossing.outsideConcept.node.attr("x")) + + (Number(crossing.outsideConcept.node.attr("width")) / 2); + const side = outsideX < insideX ? "left" : "right"; + const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12; + const y = reserveY(side, insideY - 15); + const button = document.createElement("button"); + button.type = "button"; + button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`; + button.style.transform = `translate(${x}px, ${y}px)`; + button.style.width = `${buttonWidth}px`; + button.textContent = crossing.outsideConcept.label || "External concept"; + button.title = "Open the concept map containing this connection"; + button.addEventListener("click", () => { + if (this.onOpenBoundaryReference) { + this.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector); + } + }); + layer.append(button); + + const boundaryX = side === "left" ? x + buttonWidth : x; + const boundaryY = y + 15; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const startX = crossing.sourceInside ? insideX : boundaryX; + const startY = crossing.sourceInside ? insideY : boundaryY; + const endX = crossing.sourceInside ? boundaryX : insideX; + const endY = crossing.sourceInside ? boundaryY : insideY; + path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", crossing.connector.lineColor || "#4a5560"); + path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2)); + if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)"); + svg.append(path); + } + } + isDescendantOf(record, submap) { let parent = record.parentSubmap; while (parent) { @@ -370,6 +687,8 @@ } isItemVisible(record) { + const context = this.mapContextKey(); + if (record !== this.activeMapRoot && record.hiddenContexts.has(context)) return false; if (this.activeMapRoot) { if (record === this.activeMapRoot) return true; if (!this.isDescendantOf(record, this.activeMapRoot)) return false; @@ -389,6 +708,44 @@ return true; } + hiddenItemsInCurrentContext() { + const context = this.mapContextKey(); + return this.items + .filter((item) => item !== this.activeMapRoot && item.kind !== "phrase" && + item.hiddenContexts.has(context)) + .sort((left, right) => left.label.localeCompare(right.label)); + } + + canHideSelectionInCurrentContext() { + return !this.activeMapRoot && this.selectedAll().some((item) => item.parentSubmap && + item.kind !== "phrase" && this.isItemVisible(item)); + } + + hideSelectionInCurrentContext() { + const context = this.mapContextKey(); + if (this.activeMapRoot) return false; + const selected = this.selectedAll().filter((item) => item.parentSubmap && + item.kind !== "phrase" && this.isItemVisible(item)); + if (!selected.length) return false; + this.scheduleHistoryCommit(); + for (const item of selected) item.hiddenContexts.add(context); + this.clearSelection(); + this.refreshSubmapVisibility(); + if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext()); + return true; + } + + showItemInCurrentContext(record) { + if (!record) return false; + const context = this.mapContextKey(); + if (!record.hiddenContexts.has(context)) return false; + this.scheduleHistoryCommit(); + record.hiddenContexts.delete(context); + this.refreshSubmapVisibility(); + if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext()); + return true; + } + ensureSubmapContents(record) { if (record.submapInitialized) return; record.submapInitialized = true; @@ -397,7 +754,10 @@ toggleSubmap(record, expanded = !record.expanded) { if (!record || record.kind !== "submap") return false; - if (record.separateMap) { + // Legacy embedded pages had only an "open" state. New shared sub-CMaps + // keep their expand/collapse state in the parent and use a separate + // button for the standalone route. + if (record.separateMap && !record.cmapSlug) { record.expanded = false; if (record === this.activeMapRoot) { this.refreshSubmapVisibility(); @@ -405,9 +765,14 @@ } return this.openSubmapMap(record); } + // Persist the complete placement before hiding descendants. Reapplying + // the same document-local context on expansion guarantees that a + // submap opens at the same coordinates and dimensions every time. + this.saveCurrentContextLayout(); if (expanded) this.ensureSubmapContents(record); record.expanded = Boolean(expanded); - this.refreshSubmapVisibility(); + if (record.expanded) this.applyCurrentContextLayout(); + else this.refreshSubmapVisibility(); const element = record.node.element(); if (element) this.ensureSubmapToggle(record, element); debug("submap toggled", { @@ -425,9 +790,10 @@ if (record === this.activeMapRoot) return true; this.ensureSubmapContents(record); this.clearSelection(); + this.saveCurrentContextLayout(); if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot); this.activeMapRoot = record; - this.refreshSubmapVisibility(); + this.applyCurrentContextLayout(); if (this.onMapChange) this.onMapChange(record.mapReference, record); debug("separate concept map opened", { id: record.id, mapReference: record.mapReference }); return true; @@ -436,9 +802,10 @@ openRootMap() { if (!this.activeMapRoot) return false; this.clearSelection(); + this.saveCurrentContextLayout(); this.activeMapRoot = null; this.mapHistory = []; - this.refreshSubmapVisibility(); + this.applyCurrentContextLayout(); if (this.onMapChange) this.onMapChange(null, null); debug("root concept map opened"); return true; @@ -447,8 +814,9 @@ openParentMap() { if (!this.activeMapRoot) return false; this.clearSelection(); + this.saveCurrentContextLayout(); this.activeMapRoot = this.mapHistory.pop() || null; - this.refreshSubmapVisibility(); + this.applyCurrentContextLayout(); const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null; if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot); debug("parent concept map opened", { @@ -482,6 +850,200 @@ return record.mapReference; } + prepareStoredSubmapExtraction(record, targetSlug) { + console.warn(`${debugPrefix} destructive submap extraction is disabled; use a shared derived view`, { + itemId: record ? record.id : null, + targetSlug + }); + return null; + /* istanbul ignore next -- retained only to read historical documents */ + if (!record || record.kind !== "submap" || !targetSlug) return null; + this.ensureSubmapContents(record); + const sourceDocument = this.toDocument(); + const descendants = this.items.filter((item) => this.isDescendantOf(item, record)); + const descendantIds = new Set(descendants.map((item) => item.id)); + const allItemIds = new Set(this.items.map((item) => item.id)); + if (!descendantIds.size) return null; + + const descendantDocuments = sourceDocument.items.filter((item) => descendantIds.has(item.id)); + const headSource = sourceDocument.items.find((item) => Number(item.id) === Number(record.id)); + if (!headSource) return null; + const scopedDocuments = [headSource, ...descendantDocuments]; + const left = Math.min(...scopedDocuments.map((item) => Number(item.x) || 0)); + const top = Math.min(...scopedDocuments.map((item) => Number(item.y) || 0)); + const placeInChild = (item, parentSubmapId, submapDepth) => { + const x = (Number(item.x) || 0) - left + 80; + const y = (Number(item.y) || 0) - top + 80; + const rootLayout = item.layouts && item.layouts.root ? item.layouts.root : {}; + return { + ...item, + parentSubmapId, + submapDepth, + x, + y, + layouts: { + ...(item.layouts || {}), + root: { + ...rootLayout, + x, + y, + width: Number(item.width), + height: Number(item.height) + } + } + }; + }; + const childHead = { + ...placeInChild(headSource, null, 0), + kind: "concept", + childMap: null, + expanded: false, + submapInitialized: false, + separateMap: false, + mapReference: null, + pageSlug: null, + // The placement in the parent links to this map. The head on the + // child itself must not be a link back to the same map. + cmapSlug: null, + parentCmapLink: false + }; + const childItems = [childHead, ...descendantDocuments.map((item) => + placeInChild( + item, + Number(item.parentSubmapId) === Number(record.id) ? null : item.parentSubmapId, + Math.max(0, Number(item.submapDepth || 0) - record.submapDepth - 1) + ))]; + + const parentConnectors = []; + const childConnectors = []; + const parentConnectorKeys = new Set(); + for (const connector of sourceDocument.connectors) { + const sourceInside = descendantIds.has(Number(connector.sourceId)); + const targetInside = descendantIds.has(Number(connector.targetId)); + const sourceIsHead = Number(connector.sourceId) === Number(record.id); + const targetIsHead = Number(connector.targetId) === Number(record.id); + if ((sourceInside && targetInside) || + (sourceIsHead && targetInside) || + (sourceInside && targetIsHead)) { + childConnectors.push({ ...connector }); + continue; + } + if (sourceInside || targetInside) { + const outsideId = Number(sourceInside ? connector.targetId : connector.sourceId); + if (!allItemIds.has(outsideId)) { + childConnectors.push({ ...connector }); + continue; + } + const rewired = { + ...connector, + sourceId: sourceInside ? record.id : connector.sourceId, + targetId: targetInside ? record.id : connector.targetId + }; + if (Number(rewired.sourceId) === Number(rewired.targetId)) continue; + const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`; + if (parentConnectorKeys.has(key)) continue; + parentConnectorKeys.add(key); + parentConnectors.push(rewired); + continue; + } + const key = `${connector.sourceId}\u0000${connector.targetId}\u0000${connector.hasArrow !== false}`; + if (!parentConnectorKeys.has(key)) { + parentConnectorKeys.add(key); + parentConnectors.push({ ...connector }); + } + } + + const childConceptIds = new Set(childItems.map((item) => item.conceptId).filter(Boolean)); + const parentItems = sourceDocument.items + .filter((item) => !descendantIds.has(item.id)) + .map((item) => item.id === record.id ? { + ...item, + kind: "concept", + childMap: null, + expanded: false, + submapInitialized: false, + separateMap: false, + mapReference: null, + cmapSlug: targetSlug, + parentCmapLink: false, + borderColor: "#57834a" + } : item); + const parentConceptIds = new Set(parentItems.map((item) => item.conceptId).filter(Boolean)); + const conceptById = new Map(sourceDocument.concepts.map((concept) => [concept.id, concept])); + const childConceptById = new Map(conceptById); + const parentConceptById = new Map(conceptById); + const headDocument = parentItems.find((item) => item.id === record.id); + if (headDocument && headDocument.conceptId) { + parentConceptById.set(headDocument.conceptId, { + id: headDocument.conceptId, + kind: "concept", + label: headDocument.label, + synopsis: headDocument.synopsis, + aspects: headDocument.aspects || [], + descriptionPageSlug: headDocument.descriptionPageSlug, + pageSlug: null, + cmapSlug: targetSlug, + parentCmapLink: false, + imageSource: headDocument.imageSource || "" + }); + childConceptById.set(headDocument.conceptId, { + id: headDocument.conceptId, + kind: "concept", + label: childHead.label, + synopsis: childHead.synopsis, + aspects: childHead.aspects || [], + descriptionPageSlug: childHead.descriptionPageSlug, + pageSlug: null, + cmapSlug: null, + parentCmapLink: false, + imageSource: childHead.imageSource || "" + }); + } + + return { + childDocument: { + schemaVersion: 2, + concepts: Array.from(childConceptById.values()).filter((concept) => childConceptIds.has(concept.id)), + items: childItems, + connectors: childConnectors, + conceptMaps: sourceDocument.conceptMaps.filter((reference) => + descendantIds.has(Number(reference.rootItemId))) + }, + parentDocument: { + ...sourceDocument, + concepts: Array.from(parentConceptById.values()).filter((concept) => parentConceptIds.has(concept.id)), + items: parentItems, + connectors: parentConnectors, + conceptMaps: sourceDocument.conceptMaps.filter((reference) => + Number(reference.rootItemId) !== record.id && + !descendantIds.has(Number(reference.rootItemId))) + } + }; + } + + replaceDocument(document) { + if (!document || typeof document !== "object") return false; + this.commitHistory(); + const previousSnapshot = this.historySnapshotValue || this.historySnapshot(); + this.historyRestoring = true; + try { + this.clearDocument(); + this.loadDocument(document); + } finally { + this.historyRestoring = false; + } + const nextSnapshot = this.historySnapshot(); + if (previousSnapshot !== nextSnapshot) { + this.undoStack.push(previousSnapshot); + if (this.undoStack.length > this.historyLimit) this.undoStack.shift(); + } + this.historySnapshotValue = nextSnapshot; + this.redoStack = []; + this.notifySelection(); + this.notifyHistory(); + return true; + } + setZoom(percent) { const next = Math.max(25, Math.min(300, Number(percent) || 100)); this.zoomFactor = next / 100; @@ -526,6 +1088,20 @@ if (value !== undefined) record[key] = value; } + const identityKeys = ["kind", "label", "synopsis", "aspects", "descriptionPageSlug", + "pageSlug", "cmapSlug", + "parentCmapLink", "imageSource"]; + if (record.conceptId && record.kind !== "submap") { + for (const peer of this.items) { + if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "submap") continue; + for (const key of identityKeys) { + if (changes[key] !== undefined) peer[key] = changes[key]; + } + peer.node.attr({ content: this.itemHtml(peer) }); + peer.node.redraw(); + } + } + if (record.autoWidth || record.autoHeight) record.fitContentPending = true; record.width = numberOr(Number(record.width), record.node.attr("width")); @@ -770,6 +1346,62 @@ return Array.from(this.selectedItems); } + copySelectionReferences() { + this.copiedConceptIds = Array.from(new Set(this.selectedAll() + .filter((item) => item.conceptId && item.kind !== "phrase" && item.kind !== "submap") + .map((item) => item.conceptId))); + this.notifySelection(); + return this.copiedConceptIds.length; + } + + canPasteConceptReferences() { + return this.copiedConceptIds.length > 0; + } + + pasteConceptReferences() { + const sources = this.copiedConceptIds + .map((conceptId) => this.items.find((item) => item.conceptId === conceptId)) + .filter(Boolean); + if (!sources.length) return []; + this.clearSelection(false); + const parentSubmap = this.activeMapRoot || null; + const pasted = sources.map((source, index) => this.addItem({ + conceptId: source.conceptId, + kind: source.kind, + label: source.label, + synopsis: source.synopsis, + aspects: source.aspects, + descriptionPageSlug: source.descriptionPageSlug, + pageSlug: source.pageSlug, + cmapSlug: source.cmapSlug, + parentCmapLink: source.parentCmapLink, + imageSource: source.imageSource, + parentSubmap, + submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, + x: 120 + (index * 36), + y: 120 + (index * 36), + width: Number(source.node.attr("width")), + height: Number(source.node.attr("height")), + backgroundColor: source.backgroundColor, + borderColor: source.borderColor, + textColor: source.textColor, + fontFamily: source.fontFamily, + fontSize: source.fontSize, + fontWeight: source.fontWeight, + fontStyle: source.fontStyle, + synopsisTextColor: source.synopsisTextColor, + synopsisFontFamily: source.synopsisFontFamily, + synopsisFontSize: source.synopsisFontSize, + synopsisFontWeight: source.synopsisFontWeight, + synopsisFontStyle: source.synopsisFontStyle + })); + for (const record of pasted) this.selectedItems.add(record); + this.selectedItem = pasted.at(-1) || null; + this.refreshSelectionDecoration(); + this.notifySelection(); + return pasted; + } + selectAll() { this.clearSelection(false); for (const item of this.items) { @@ -817,7 +1449,7 @@ } this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); - this.refreshSubmapVisibility(); + this.applyCurrentContextLayout(); this.selectItem(submap); debug("selection grouped as submap", { submapId: submap.id, @@ -882,14 +1514,33 @@ } toDocument() { + this.saveCurrentContextLayout(); this.refreshConceptMapReferences(); - return { - schemaVersion: 1, - items: this.items.map((record) => ({ - id: record.id, + const concepts = Array.from(new Map(this.items + .filter((record) => record.conceptId && record.kind !== "phrase" && record.kind !== "submap") + .map((record) => [record.conceptId, { + id: record.conceptId, kind: record.kind, label: record.label, synopsis: record.synopsis, + aspects: record.aspects, + descriptionPageSlug: record.descriptionPageSlug, + pageSlug: record.pageSlug, + cmapSlug: record.cmapSlug, + parentCmapLink: record.parentCmapLink, + imageSource: record.imageSource + }])).values()); + return { + schemaVersion: 2, + concepts, + items: this.items.map((record) => ({ + id: record.id, + conceptId: record.conceptId, + kind: record.kind, + label: record.label, + synopsis: record.synopsis, + aspects: record.aspects, + descriptionPageSlug: record.descriptionPageSlug, pageSlug: record.pageSlug, cmapSlug: record.cmapSlug, parentCmapLink: record.parentCmapLink, @@ -901,6 +1552,8 @@ submapInitialized: record.submapInitialized, separateMap: record.separateMap, mapReference: record.mapReference, + hiddenContexts: Array.from(record.hiddenContexts), + layouts: record.layouts, imageSource: record.imageSource, backgroundColor: record.backgroundColor, borderColor: record.borderColor, @@ -909,6 +1562,13 @@ textColor: record.textColor, fontFamily: record.fontFamily, fontSize: record.fontSize, + fontWeight: record.fontWeight, + fontStyle: record.fontStyle, + synopsisTextColor: record.synopsisTextColor, + synopsisFontFamily: record.synopsisFontFamily, + synopsisFontSize: record.synopsisFontSize, + synopsisFontWeight: record.synopsisFontWeight, + synopsisFontStyle: record.synopsisFontStyle, width: Number(record.node.attr("width")), height: Number(record.node.attr("height")), autoWidth: record.autoWidth, @@ -923,21 +1583,25 @@ hasArrow: connector.hasArrow, lineColor: connector.lineColor, lineWidth: connector.lineWidth - })), + })).concat(this.unresolvedConnectors.map((connector) => ({ ...connector }))), conceptMaps: Array.from(this.conceptMaps.values()) }; } loadDocument(document = {}) { - if (this.items.length || this.connectors.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"); } const itemDocuments = Array.isArray(document.items) ? document.items : []; const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : []; + const concepts = new Map((Array.isArray(document.concepts) ? document.concepts : []) + .filter((concept) => concept && concept.id) + .map((concept) => [concept.id, concept])); const records = new Map(); for (const itemDocument of itemDocuments) { - const record = this.addItem({ ...itemDocument, parentSubmap: null }); + const concept = concepts.get(itemDocument.conceptId) || {}; + const record = this.addItem({ ...itemDocument, ...concept, parentSubmap: null }); record.autoWidth = Boolean(itemDocument.autoWidth); record.autoHeight = Boolean(itemDocument.autoHeight); record.fitContentPending = false; @@ -958,7 +1622,21 @@ for (const connectorDocument of connectorDocuments) { const source = records.get(Number(connectorDocument.sourceId)); const target = records.get(Number(connectorDocument.targetId)); - if (!source || !target) continue; + if (!source || !target) { + this.unresolvedConnectors.push({ ...connectorDocument }); + const unresolvedId = Number(connectorDocument.id); + if (Number.isInteger(unresolvedId) && unresolvedId > 0) { + this.nextConnectorId = Math.max(this.nextConnectorId, unresolvedId + 1); + } + console.warn(`${debugPrefix} relation retained without rendering`, { + relationId: connectorDocument.id || null, + sourceId: connectorDocument.sourceId, + targetId: connectorDocument.targetId, + missingSource: !source, + missingTarget: !target + }); + continue; + } this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument); } this.conceptMaps = new Map( @@ -967,7 +1645,7 @@ .map((reference) => [reference.id, reference])); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); - this.refreshSubmapVisibility(); + this.applyCurrentContextLayout(); this.clearSelection(); if (!this.historyRestoring) this.resetHistory(); return this; @@ -1036,6 +1714,30 @@ input.select(); } + /** + * Apply the independently stored heading and synopsis typography. + * Legacy records have already inherited their old shared typography in + * addItem, so merely opening an existing CMap does not restyle it. + */ + applyItemTypography(record, element) { + const title = element.querySelector(".cmap-card-title"); + if (title) { + title.style.color = record.textColor; + title.style.fontFamily = record.fontFamily; + title.style.fontSize = record.fontSize; + title.style.fontWeight = record.fontWeight; + title.style.fontStyle = record.fontStyle; + } + const synopsis = element.querySelector(".cmap-card-synopsis"); + if (synopsis) { + synopsis.style.color = record.synopsisTextColor; + synopsis.style.fontFamily = record.synopsisFontFamily; + synopsis.style.fontSize = record.synopsisFontSize; + synopsis.style.fontWeight = record.synopsisFontWeight; + synopsis.style.fontStyle = record.synopsisFontStyle; + } + } + /** * goal : Redraw selection controls after ionstage/cmap updates a node. * pre : record.node.redraw() has made a DOM element available. @@ -1049,7 +1751,10 @@ element.dataset.rwCmapItemId = String(record.id); element.style.fontFamily = record.fontFamily; element.style.fontSize = record.fontSize; + element.style.fontWeight = record.fontWeight; + element.style.fontStyle = record.fontStyle; element.style.overflow = "visible"; + this.applyItemTypography(record, element); if (record.fitContentPending) { this.fitItemToContent(record, element); @@ -1065,6 +1770,42 @@ }, { once: true }); } + const descriptionButton = element.querySelector(".rw-cmap-view-description"); + if (descriptionButton && descriptionButton.dataset.rwCmapBound !== "1") { + descriptionButton.dataset.rwCmapBound = "1"; + descriptionButton.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + descriptionButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (record.descriptionPageSlug && this.onOpenPage) { + this.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug }); + } + }); + } + + const linkedButton = element.querySelector(".rw-cmap-open-linked"); + if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") { + linkedButton.dataset.rwCmapBound = "1"; + linkedButton.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + linkedButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (record.parentCmapLink) { + this.openParentMap(); + } else if (record.cmapSlug && this.onOpenCmap) { + this.onOpenCmap(record); + } else if (record.pageSlug && this.onOpenPage) { + this.onOpenPage(record); + } + }); + } + if (element.dataset.rwCmapBound !== "1") { element.dataset.rwCmapBound = "1"; debug("item pointer handlers attached", { @@ -1128,6 +1869,8 @@ boxSizing: "border-box", fontFamily: record.fontFamily, fontSize: record.fontSize, + fontWeight: record.fontWeight, + fontStyle: record.fontStyle, lineHeight: "1.25", overflow: "visible", pointerEvents: "none", @@ -1135,6 +1878,7 @@ visibility: "hidden", whiteSpace: "normal" }); + this.applyItemTypography(record, probe); const content = probe.firstElementChild; if (content) { @@ -1188,8 +1932,9 @@ } handleItemMove(record, x, y) { - const movesCompleteSubmap = record.kind === "submap" && - !record.expanded && record !== this.activeMapRoot; + // A submap node is an anchor placement. Moving the anchor never moves + // the separately positioned expanded group; the frame has its own drag. + const movesCompleteSubmap = false; const movesSelection = this.selectedItems.has(record) && this.selectedItems.size > 1; const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection); if (movesCompleteSubmap || movesSelection) { @@ -1397,7 +2142,7 @@ submapBounds(record, excludedRecord = null) { const visibleItems = this.items.filter((item) => - (item === record || this.isDescendantOf(item, record)) && + this.isDescendantOf(item, record) && item !== excludedRecord && (!excludedRecord || !this.isDescendantOf(item, excludedRecord)) && this.isItemVisible(item)); @@ -1459,9 +2204,6 @@ } refreshSubmapVisibility() { - for (const item of this.items) { - if (item.kind === "submap" && item.separateMap) item.expanded = false; - } for (const item of this.items) item.node.visible(this.isEffectiveItemVisible(item)); for (const connector of this.connectors) { this.applyConnectorVisualEndpoints(connector, @@ -1480,13 +2222,17 @@ updateSubmapFrame(record) { const surface = this.surfaceElement(); - const shouldShow = !record.separateMap && record !== this.activeMapRoot && + const shouldShow = record !== this.activeMapRoot && record.expanded && this.isItemVisible(record); if (!shouldShow || !surface) { if (record.submapFrameElement) { record.submapFrameElement.remove(); record.submapFrameElement = null; } + if (record.submapAnchorLineElement) { + record.submapAnchorLineElement.remove(); + record.submapAnchorLineElement = null; + } return; } @@ -1548,9 +2294,29 @@ record.submapFrameElement.classList.toggle( "rw-cmap-submap-frame-selected", this.selectedItems.has(record)); record.submapFrameElement.setAttribute("aria-label", record.label); + this.updateSubmapAnchorLine(record, bounds, surface); this.ensureCanvasExtent(right, bottom); } + updateSubmapAnchorLine(record, bounds, surface = this.surfaceElement()) { + if (!surface || !bounds || record === this.activeMapRoot || !record.expanded) return; + if (!record.submapAnchorLineElement) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("rw-cmap-submap-anchor-line"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + svg.append(path); + surface.prepend(svg); + record.submapAnchorLineElement = svg; + } + const anchor = this.itemCenter(record); + const target = { + x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)), + y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom)) + }; + record.submapAnchorLineElement.querySelector("path") + .setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`); + } + startSubmapFrameDrag(event, record) { event.preventDefault(); event.stopPropagation(); @@ -1565,7 +2331,22 @@ const pointerId = event.pointerId; const startClientX = event.clientX; const startClientY = event.clientY; - const moveMembership = this.beginItemMove(record, true); + const bounds = this.submapBounds(record); + if (!bounds) return; + const moveMembership = { + parent: record.parentSubmap, + parentBounds: null, + startX: bounds.left, + startY: bounds.top, + groupPositions: this.items + .filter((item) => this.isDescendantOf(item, record)) + .map((item) => ({ + item, + x: Number(item.node.attr("x")), + y: Number(item.node.attr("y")) + })) + }; + record.moveMembership = moveMembership; const move = (moveEvent) => { if (moveEvent.pointerId !== pointerId) return; @@ -1581,7 +2362,10 @@ if (upEvent.pointerId !== pointerId) return; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); - this.handleItemMoveEnd(record); + record.moveMembership = null; + this.saveCurrentContextLayout(); + this.refreshSubmapVisibility(); + this.scheduleHistoryCommit(); }; window.addEventListener("pointermove", move); @@ -1681,6 +2465,7 @@ } destroy() { + this.destroyed = true; if (this.historyTimer !== null) { window.clearTimeout(this.historyTimer); this.historyTimer = null; @@ -1689,13 +2474,22 @@ this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler); this.marqueeMouseDownHandler = null; } + if (this.boundaryScrollHandler) { + this.canvas.removeEventListener("scroll", this.boundaryScrollHandler); + this.boundaryScrollHandler = null; + } if (this.activeMarqueeCleanup) this.activeMarqueeCleanup(); for (const item of this.items) { if (item.submapFrameElement) { item.submapFrameElement.remove(); item.submapFrameElement = null; } + if (item.submapAnchorLineElement) { + item.submapAnchorLineElement.remove(); + item.submapAnchorLineElement = null; + } } + if (this.map && typeof this.map.destroy === "function") this.map.destroy(); debug("editor destroyed"); } @@ -1710,18 +2504,34 @@ } const connectors = new Set(this.connectors.filter((connector) => connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target))); + const affectedPhrases = new Set(); + for (const connector of connectors) { + if (connector.source.kind === "phrase" && !records.has(connector.source)) { + affectedPhrases.add(connector.source); + } + if (connector.target.kind === "phrase" && !records.has(connector.target)) { + affectedPhrases.add(connector.target); + } + } let foundOrphan = true; while (foundOrphan) { foundOrphan = false; const remainingConnectors = this.connectors.filter((connector) => !connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target)); - for (const phrase of this.items.filter((item) => item.kind === "phrase" && !records.has(item))) { + for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) { const hasSource = remainingConnectors.some((connector) => connector.target === phrase); const hasTarget = remainingConnectors.some((connector) => connector.source === phrase); if (hasSource && hasTarget) continue; records.add(phrase); for (const connector of this.connectors) { - if (connector.source === phrase || connector.target === phrase) connectors.add(connector); + if (connector.source !== phrase && connector.target !== phrase) continue; + connectors.add(connector); + if (connector.source.kind === "phrase" && !records.has(connector.source)) { + affectedPhrases.add(connector.source); + } + if (connector.target.kind === "phrase" && !records.has(connector.target)) { + affectedPhrases.add(connector.target); + } } foundOrphan = true; } @@ -1736,7 +2546,13 @@ if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id); record.node.remove(); } + if (records.size && this.unresolvedConnectors.length) { + const deletedIds = new Set(Array.from(records).map((record) => Number(record.id))); + this.unresolvedConnectors = this.unresolvedConnectors.filter((connector) => + !deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId))); + } this.items = this.items.filter((item) => !records.has(item)); + this.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId)); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.refreshSubmapVisibility(); @@ -1811,6 +2627,11 @@ this.toggleSubmap(item); return; } + if (this.onEditItem) { + this.selectItem(item); + this.onEditItem(item); + return; + } if (item.parentCmapLink) { this.openParentMap(); return; @@ -1827,35 +2648,69 @@ ensureSubmapToggle(record, element) { let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle"); + let open = element.querySelector(":scope > .rw-cmap-submap-open"); if (record.kind !== "submap") { if (toggle) toggle.remove(); + if (open) open.remove(); return; } - if (record === this.activeMapRoot || (!record.separateMap && record.expanded)) { + if (record === this.activeMapRoot) { if (toggle) toggle.remove(); + if (open) open.remove(); return; } + const legacySeparateMap = record.separateMap && !record.cmapSlug; + if (record.expanded && !legacySeparateMap) { + if (toggle) toggle.remove(); + toggle = null; + } if (!toggle) { - toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "rw-cmap-submap-toggle"; - toggle.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - toggle.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.selectItem(record); - this.toggleSubmap(record); - }); - element.append(toggle); + if (!record.expanded || legacySeparateMap) { + toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "rw-cmap-submap-toggle"; + toggle.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + toggle.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + this.selectItem(record); + this.toggleSubmap(record); + }); + element.append(toggle); + } + } + if (toggle) { + toggle.textContent = legacySeparateMap ? "↗" : "+"; + toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap"; + toggle.setAttribute("aria-label", toggle.title); + toggle.setAttribute("aria-expanded", String(record.expanded)); + } + if (record.cmapSlug && this.onOpenStoredSubMap) { + if (!open) { + open = document.createElement("button"); + open.type = "button"; + open.className = "rw-cmap-submap-open"; + open.textContent = "↗"; + open.title = "Open as separate concept map"; + open.setAttribute("aria-label", open.title); + open.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + open.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + this.selectItem(record); + this.onOpenStoredSubMap(record); + }); + element.append(open); + } + } else if (open) { + open.remove(); } - toggle.textContent = record.separateMap ? "↗" : (record.expanded ? "−" : "+"); - toggle.title = record.separateMap ? "Open concept map" : - (record.expanded ? "Collapse submap" : "Expand submap"); - toggle.setAttribute("aria-label", toggle.title); - toggle.setAttribute("aria-expanded", String(record.expanded)); } ensureHandles(record, element) { @@ -1985,14 +2840,14 @@ return; } if (target === source) return; - this.finishRelation(source, target); + this.finishRelation(source, target, event.altKey || upEvent.altKey); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } - finishRelation(source, target) { + finishRelation(source, target, direct = false) { if (source.kind === "phrase" && target.kind !== "phrase") { this.addConnector(source, target, true); this.reconcilePhraseMembership(source); @@ -2008,6 +2863,12 @@ return; } if (source.kind === "phrase" && target.kind === "phrase") return; + if (direct) { + const connector = this.addConnector(source, target, true); + this.refreshSubmapVisibility(); + this.selectConnector(connector); + return; + } this.connectWithPhrase(source, target, "?????", true); } @@ -2081,7 +2942,7 @@ let lastEditor = null; window.RacketWikiCmap = { - version: "0.2.94", + version: "0.2.99", createEditor(canvas, options) { lastEditor = new CmapEditor(canvas, options); return lastEditor; diff --git a/static/cmap/cmap.css b/static/cmap/cmap.css index 1654b30..52430d3 100644 --- a/static/cmap/cmap.css +++ b/static/cmap/cmap.css @@ -13,6 +13,7 @@ body.cmap-mode #main { height: 100vh; min-height: 0; overflow: auto; + padding-top: 0; } #cmap-view { @@ -24,7 +25,7 @@ body.cmap-mode #main { z-index: 160; top: 0; margin: 0 0 10px; - padding: 6px 0; + padding: 34px 0 6px; background: rgb(255 255 255 / 96%); box-shadow: 0 1px 0 rgb(128 139 150 / 22%); backdrop-filter: blur(4px); @@ -51,6 +52,45 @@ body.cmap-mode #main { min-width: 16px; } +.cmap-hidden-items { + position: relative; + flex: 0 0 auto; +} + +.cmap-hidden-items > summary { + cursor: pointer; + white-space: nowrap; +} + +.cmap-hidden-item-options { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 10020; + display: grid; + min-width: 220px; + max-height: 320px; + overflow: auto; + padding: 6px; + border: 1px solid #c8ced5; + border-radius: 5px; + background: #fff; + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); +} + +.cmap-hidden-item-options button { + padding: 7px 9px; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.cmap-hidden-item-options button:hover, +.cmap-hidden-item-options button:focus-visible { + background: #eef2f6; +} + .cmap-storage-controls input[type="search"] { box-sizing: border-box; min-width: 220px; @@ -60,6 +100,20 @@ body.cmap-mode #main { font: inherit; } +.cmap-wiki-reference { + max-width: 300px; + overflow: hidden; + padding: 5px 8px; + border: 1px solid #c3cad2; + border-radius: 4px; + background: #f7f9fa; + color: #34495e; + cursor: copy; + font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + .wiki-combobox { position: relative; min-width: 220px; @@ -225,6 +279,8 @@ body.cmap-mode #main { .cmap-workspace { position: relative; + z-index: 0; + isolation: isolate; width: 100%; } @@ -337,7 +393,7 @@ body.cmap-mode #main { .cmap-context-menu { position: fixed; - z-index: 500; + z-index: 100000; display: grid; min-width: 210px; padding: 5px; @@ -406,23 +462,131 @@ body.cmap-mode #main { } .cmap-card { + position: relative; height: 100%; box-sizing: border-box; padding: 9px 11px; overflow: hidden; } +.rw-cmap-view-description { + position: absolute; + top: 3px; + left: 3px; + z-index: 46; + display: grid; + width: 20px; + height: 20px; + padding: 0; + place-items: center; + border: 1px solid rgb(70 82 94 / 45%); + border-radius: 50%; + background: #fff; + color: #34495e; + cursor: pointer; + font: 700 14px/1 Georgia, "Times New Roman", serif; +} + +.rw-cmap-view-description.is-empty { + border-color: #c8cdd2; + background: #e5e7e9; + color: #7a828a; +} + +.rw-cmap-view-description.is-filled { + border-color: #87919b; + background: #fff; + color: #263746; +} + +.cmap-description-tooltip { + position: fixed; + z-index: 110000; + width: min(420px, calc(100vw - 16px)); + max-height: min(440px, calc(100vh - 24px)); + overflow: auto; + padding: 12px 14px; + border: 1px solid #9ca6b0; + border-radius: 7px; + background: #fff; + box-shadow: 0 10px 32px rgb(0 0 0 / 24%); + color: #222; + font: 14px/1.45 system-ui, sans-serif; + pointer-events: none; +} + +.cmap-description-tooltip .markdown-body > :first-child { margin-top: 0; } +.cmap-description-tooltip .markdown-body > :last-child { margin-bottom: 0; } + +.rw-cmap-open-linked { + position: absolute; + z-index: 46; + top: 3px; + right: 3px; + display: grid; + width: 20px; + height: 20px; + padding: 0; + place-items: center; + border: 1px solid rgb(70 82 94 / 45%); + border-radius: 50%; + background: rgb(255 255 255 / 86%); + color: #34495e; + cursor: pointer; + font: 700 12px/1 Arial, Helvetica, sans-serif; +} + +.rw-cmap-view-description + .cmap-card-image, +.rw-cmap-view-description ~ .cmap-card-title { + padding-left: 16px; +} + +.rw-cmap-open-linked ~ .cmap-card-title { + padding-right: 16px; +} + .cmap-card-title { margin-bottom: .3rem; - font-size: 1rem; - font-weight: 700; + font-size: 1em; + font-weight: inherit; + font-style: inherit; +} + +.cmap-card-usage { + opacity: .62; + font-size: .72em; + font-style: normal; + font-weight: 400; + white-space: nowrap; +} + +.cmap-card-aspects { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin: 0 0 .35rem; +} + +.cmap-card-aspect { + padding: 1px 5px; + border: 1px solid color-mix(in srgb, currentColor 35%, transparent); + border-radius: 999px; + background: rgb(255 255 255 / 58%); + font-size: .68em; + font-weight: 600; + line-height: 1.35; } .cmap-card-synopsis { color: inherit; opacity: .78; - font-size: .84rem; + font-size: .84em; line-height: 1.25; + font-weight: inherit; + font-style: inherit; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; } .cmap-card-image { @@ -434,18 +598,6 @@ body.cmap-mode #main { object-fit: contain; } -.cmap-card-page .cmap-card-title::after { - content: " ↗"; - color: #4479a1; - font-weight: 400; -} - -.cmap-card-linked-cmap .cmap-card-title::after { - content: " ↗"; - color: #477b3e; - font-weight: 400; -} - .rw-cmap-item { overflow: visible !important; } @@ -466,10 +618,30 @@ body.cmap-mode #main { font: 700 17px/18px Arial, Helvetica, sans-serif; } +.rw-cmap-submap-open { + position: absolute; + top: 6px; + right: 34px; + z-index: 45; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid #57834a; + border-radius: 50%; + background: #fff; + color: #35672c; + cursor: pointer; + font: 700 14px/18px Arial, Helvetica, sans-serif; +} + .cmap-card-submap { padding-right: 38px; } +.rw-cmap-item:has(> .rw-cmap-submap-open) .cmap-card-submap { + padding-right: 64px; +} + .rw-cmap-submap-frame { position: absolute; z-index: 0; @@ -487,6 +659,23 @@ body.cmap-mode #main { user-select: none; } +.rw-cmap-submap-anchor-line { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.rw-cmap-submap-anchor-line path { + fill: none; + stroke: #a6adb4; + stroke-width: 2; + stroke-linecap: round; +} + .rw-cmap-submap-frame-selected { outline: 3px solid #9994ff; outline-offset: 2px; @@ -510,6 +699,43 @@ body.cmap-mode #main { pointer-events: auto; } +.rw-cmap-boundary-layer, +.rw-cmap-boundary-lines { + position: absolute; + inset: 0; + z-index: 16; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.rw-cmap-boundary-reference { + position: absolute; + z-index: 17; + min-height: 30px; + overflow: hidden; + padding: 5px 9px; + border: 1px solid #77838e; + border-radius: 5px; + background: #fff; + color: #25313c; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; + box-shadow: 0 2px 6px rgb(0 0 0 / 14%); +} + +.rw-cmap-boundary-reference-left::before { + content: "← "; +} + +.rw-cmap-boundary-reference-right::after { + content: " →"; +} + .rw-cmap-selected { z-index: 20 !important; outline: 3px solid #9994ff !important; @@ -660,6 +886,11 @@ body.cmap-mode #main { box-shadow: 0 18px 60px rgb(0 0 0 / 24%); } +#cmap-concept-dialog { + width: min(780px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); +} + .cmap-concept-dialog::backdrop { background: rgb(22 30 44 / 38%); } @@ -695,6 +926,8 @@ body.cmap-mode #main { #cmap-history-list { max-height: min(520px, 60vh); overflow: auto; } .cmap-history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 0; border-bottom: 1px solid #dfe3e8; } .cmap-history-row:last-child { border-bottom: 0; } +.cmap-history-actions { display: flex; gap: 8px; align-items: center; } +.cmap-history-empty { margin: 18px 0 4px; } .cmap-unsaved-dialog form { display: grid; @@ -713,6 +946,21 @@ body.cmap-mode #main { padding: 22px; } +#cmap-concept-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px 14px; + max-height: calc(100vh - 34px); + overflow: auto; + padding: 18px; +} + +#cmap-concept-form > h2, +#cmap-concept-form > .cmap-dialog-wide, +#cmap-concept-form > .cmap-submap-style-fields, +#cmap-concept-form > .cmap-concept-dialog-actions { + grid-column: 1 / -1; +} + .cmap-concept-dialog h2 { margin: 0 0 2px; } @@ -737,18 +985,72 @@ body.cmap-mode #main { font-weight: 400; } -.cmap-concept-dialog input[type="color"] { - width: 72px; - height: 36px; - padding: 2px; - border: 1px solid #aab2bd; +.cmap-color-control { + position: relative; + display: flex; + align-items: center; + gap: 6px; + font-weight: 400; +} + +.cmap-concept-dialog .cmap-color-input { + width: 96px; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.cmap-color-swatch { + width: 34px; + height: 34px; + padding: 0; + border: 1px solid #8f99a4; border-radius: 4px; - background: #fff; cursor: pointer; } +.cmap-color-palette { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + left: 0; + display: grid; + grid-template-columns: repeat(6, 28px); + gap: 5px; + padding: 7px; + border: 1px solid #929ca6; + border-radius: 6px; + background: #fff; + box-shadow: 0 7px 22px rgb(0 0 0 / 20%); +} + +.cmap-color-palette button { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid #727b84; + border-radius: 3px; +} + +.cmap-color-palette button:hover, +.cmap-color-palette button:focus-visible { + outline: 2px solid #315f91; + outline-offset: 1px; +} + +.cmap-native-color-picker { + position: fixed; + left: -100px; + top: -100px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + .cmap-concept-image-fields, -.cmap-submap-style-fields { +.cmap-submap-style-fields, +.cmap-appearance-fields, +.cmap-typography-fields { display: grid; gap: 9px; margin: 0; @@ -758,7 +1060,9 @@ body.cmap-mode #main { } .cmap-concept-image-fields legend, -.cmap-submap-style-fields legend { +.cmap-submap-style-fields legend, +.cmap-appearance-fields legend, +.cmap-typography-fields legend { padding: 0 4px; font-weight: 600; } @@ -767,6 +1071,40 @@ body.cmap-mode #main { grid-template-columns: 1fr 1fr; } +.cmap-appearance-fields { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.cmap-typography-fields { + grid-column: 1 / -1; + grid-template-columns: repeat(5, minmax(0, 1fr)); + min-width: 0; + padding: 8px 10px 10px; +} + +.cmap-appearance-fields legend, +.cmap-style-preset-row { + grid-column: 1 / -1; +} + +.cmap-inline-control { + display: flex; + gap: 7px; +} + +.cmap-inline-control select { flex: 1 1 auto; } + +#cmap-concept-form .cmap-check-label { + display: flex; + flex-direction: row; + gap: 7px; + align-items: center; + align-self: end; + min-height: 36px; +} + +.cmap-check-label input { margin: 0; } + .cmap-submap-style-fields legend { grid-column: 1 / -1; } @@ -825,6 +1163,20 @@ body.cmap-mode #main { margin-top: 4px; } +@media (max-width: 720px) { + #cmap-concept-form, + .cmap-appearance-fields, + .cmap-typography-fields { + grid-template-columns: 1fr; + } + + #cmap-concept-form > *, + .cmap-appearance-fields > *, + .cmap-typography-fields > * { + grid-column: 1; + } +} + @media (max-width: 1100px) { .cmap-storage-controls { flex-wrap: wrap; diff --git a/static/cmap/cmap.js b/static/cmap/cmap.js index ed6af17..1fd4513 100644 --- a/static/cmap/cmap.js +++ b/static/cmap/cmap.js @@ -1,5 +1,5 @@ /** - * racket-wiki cmap component 0.2.94 + * racket-wiki cmap component 0.2.95 * Based on cmap v0.1.3. * (c) 2015 iOnStage * Released under the MIT License. @@ -370,6 +370,12 @@ var Component = function() {}; + Component.prototype.disposed = false; + + Component.prototype.dispose = function() { + this.disposed = true; + }; + Component.prototype.prop = function(initialValue, defaultValue, converter) { if (typeof converter !== 'function') converter = helper.identity; @@ -407,8 +413,11 @@ var updateRelations = function(index) { for (var i = index, len = dirtyComponents.length; i < len; i++) { var component = dirtyComponents[i]; + if (component.disposed) + continue; component.relations().forEach(function(relation) { - relation.update(component); + if (!relation.disposed) + relation.update(component); }); } @@ -421,7 +430,8 @@ updateRelations(0); dirtyComponents.forEach(function(component) { - component.redraw(); + if (!component.disposed) + component.redraw(); }); dirtyComponents = []; @@ -429,7 +439,7 @@ }; return function() { - if (dom.disabled()) + if (dom.disabled() || this.disposed) return; if (dirtyComponents.indexOf(this) === -1) @@ -1808,6 +1818,9 @@ }; Cmap.prototype.redraw = function() { + if (this.disposed) + return; + var rootElement = this.rootElement(); if (!rootElement) { @@ -1820,6 +1833,7 @@ this.rootElement(rootElement); } + var previousElement = this.element(); var element = dom.el('
'); element.className = 'rw-cmap-surface'; dom.draggable(element, function(x, y, event) { @@ -1844,6 +1858,8 @@ this.unfixScrollSize(); dom.css(element, this.style()); + if (previousElement && previousElement.parentNode) + previousElement.parentNode.removeChild(previousElement); dom.append(rootElement, element); }; @@ -2190,6 +2206,24 @@ this.activationHandler = handler; }; + CmapModule.prototype.destroy = function() { + var component = this.component; + var element = component.element(); + + this.selectionHandler = null; + this.activationHandler = null; + component.selectionHandler = null; + component.activationHandler = null; + component.componentList().toArray().forEach(function(child) { + child.dispose(); + child.parentElement(null); + }); + component.dispose(); + + if (element && element.parentNode) + element.parentNode.removeChild(element); + }; + CmapModule.prototype.zoom = function(value) { if (typeof value === 'undefined') return this.component.zoomFactor; diff --git a/static/css/wiki.css b/static/css/wiki.css index 1e3c420..1ccd75b 100644 --- a/static/css/wiki.css +++ b/static/css/wiki.css @@ -410,6 +410,65 @@ body { max-width: 900px; } +.page-cmap-connections { + box-sizing: border-box; + max-width: 900px; + margin-top: 2rem; + padding: 14px 16px; + border: 1px solid #c8d8c3; + border-radius: 7px; + background: #f5faf3; +} + +.page-cmap-connections h2 { + margin: 0 0 10px; + font-size: 1rem; +} + +.page-cmap-concept-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(220px, 2fr); + gap: 12px 18px; + align-items: center; + padding: 9px 0; + border-top: 1px solid #d9e5d5; +} + +.page-cmap-concept-row:first-child { border-top: 0; } + +.page-cmap-concept-description { + display: grid; + gap: 2px; +} + +.page-cmap-concept-description .muted { font-size: .82rem; } + +.page-cmap-concept-maps { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.page-cmap-concept-maps a { + padding: 4px 8px; + border: 1px solid #96b48d; + border-radius: 999px; + background: #fff; + color: #315f2b; + font-size: .86rem; + text-decoration: none; +} + +.page-cmap-concept-maps a:hover, +.page-cmap-concept-maps a:focus-visible { + background: #e6f3e2; + text-decoration: underline; +} + +@media (max-width: 680px) { + .page-cmap-concept-row { grid-template-columns: 1fr; } +} + .markdown-body h1, .EasyMDEContainer .editor-preview h1, .EasyMDEContainer .editor-preview-side h1 { diff --git a/static/index.html b/static/index.html index e9a5f33..a5cf1fe 100644 --- a/static/index.html +++ b/static/index.html @@ -81,6 +81,10 @@
+
@@ -175,9 +179,14 @@ + +
@@ -189,10 +198,11 @@
?
-

Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. Grouping creates a new sub-CMap around the selection. Double-click a page or sub-CMap to open it; right-click for editing tools.

+

Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. Hold Alt while drawing a relation to create a direct line without a linking phrase. Grouping creates a new sub-CMap around the selection. Double-click a concept to edit it or a sub-CMap to open it; right-click for more tools.

Ctrl/Cmd/Shift + click
Add to or remove from the selection
Ctrl/Cmd+A
Select all
+
Ctrl/Cmd+C/V
Copy/paste linked concepts
Ctrl/Cmd+G
Group selection as sub-CMap
Ctrl/Cmd+Shift+G
Detach from sub-CMap
Ctrl/Cmd+Z
Undo
@@ -213,6 +223,7 @@ + @@ -222,6 +233,8 @@ + + @@ -229,6 +242,7 @@ + @@ -405,29 +419,102 @@ + + - +
+ Appearance + + +
+ Heading + + + + + +
+
+ Subtext + + + + + +
+
- -
+
Concept image
- -
@@ -498,7 +568,7 @@

CMap history

-

Load a historical version into the editor to inspect it. Saving it creates a new current version.

+

Snapshots are kept indefinitely. Up to five explicit saves are kept; automatic saves are not added to history.

diff --git a/static/js/wiki.js b/static/js/wiki.js index 650d811..5fee49c 100644 --- a/static/js/wiki.js +++ b/static/js/wiki.js @@ -29,7 +29,10 @@ contextDockPosition: null, contextDockData: null, conceptMaps: [], + cmapConceptUsage: new Map(), + cmapPageConcepts: new Map(), currentConceptMap: null, + currentConceptMapSource: null, cmapLoadSequence: 0, cmapSavedSnapshot: null, cmapGuardHash: "", @@ -44,6 +47,9 @@ let cmapAutosaveTimer = null; let cmapSavePromise = null; let cmapEmbedHydrationTimer = null; + const cmapDescriptionPreviewCache = new Map(); + let cmapDescriptionTooltip = null; + let cmapDescriptionTooltipToken = 0; const CMAP_AUTOSAVE_DELAY = 1500; @@ -121,6 +127,28 @@ return cleanNamespace ? `${cleanNamespace}:${slug}` : slug; } + /** + * goal : Convert a freely entered page title/address to a wiki reference. + * pre : input is the text from the CMap linked-page combobox. + * post : No page is created; the result can point at a missing page. + * result : A normalized root/namespaced reference, or null when empty/invalid. + */ + function newPageReference(input) { + const parts = splitPageReference(String(input || "").trim()); + const slugify = (value, maximum) => String(value || "") + .normalize("NFKD") + .toLocaleLowerCase() + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") + .slice(0, maximum) + .replace(/-+$/g, ""); + const namespace = parts.namespace ? slugify(parts.namespace, 80) : ""; + const slug = slugify(parts.slug, 120); + if (!slug || (parts.namespace && !namespace)) return null; + return pageReference(namespace, slug); + } + function pageRoute(reference) { return `#/${encodeURIComponent(reference)}`; } @@ -129,6 +157,25 @@ return `#cmap/${encodeURIComponent(slug)}`; } + const CMAP_START_SLUG_KEY = "racket-wiki:start-cmap"; + + function startCmapSlug() { + try { + return window.localStorage.getItem(CMAP_START_SLUG_KEY) || ""; + } catch (_error) { + return ""; + } + } + + function setStartCmapSlug(slug) { + try { + if (slug) window.localStorage.setItem(CMAP_START_SLUG_KEY, slug); + else window.localStorage.removeItem(CMAP_START_SLUG_KEY); + } catch (error) { + console.warn("The start CMap preference could not be stored.", error); + } + } + function navigateToHash(targetHash) { const navigate = () => { if (location.hash === targetHash) return route(); @@ -690,6 +737,45 @@ details.append(tagField); } + function renderPageCmapConnections(page) { + const section = $("page-cmap-connections"); + const list = $("page-cmap-connections-list"); + const pageKey = canonicalPageReference(page.slug).toLocaleLowerCase(); + const concepts = [...(state.cmapPageConcepts.get(pageKey)?.values() || [])] + .sort((first, second) => first.label.localeCompare(second.label)); + list.replaceChildren(); + section.classList.toggle("hidden", concepts.length === 0); + if (!concepts.length) return; + + for (const concept of concepts) { + const row = document.createElement("div"); + row.className = "page-cmap-concept-row"; + const description = document.createElement("div"); + description.className = "page-cmap-concept-description"; + const label = document.createElement("strong"); + label.textContent = concept.label; + const usage = document.createElement("span"); + usage.className = "muted"; + usage.textContent = tr("concept-usage-count", "{count} placements across all concept maps") + .replace("{count}", String(concept.count)); + description.append(label, usage); + + const maps = document.createElement("nav"); + maps.className = "page-cmap-concept-maps"; + maps.setAttribute("aria-label", tr("concept-map-locations", "Concept map locations")); + for (const placement of [...concept.maps.values()] + .sort((first, second) => first.title.localeCompare(second.title))) { + const link = document.createElement("a"); + link.href = cmapRoute(placement.slug); + link.textContent = placement.count > 1 ? + `${placement.title} (${placement.count})` : placement.title; + maps.append(link); + } + row.append(description, maps); + list.append(row); + } + } + function wikiSlugFromHref(href) { if (!href) return null; @@ -1315,6 +1401,7 @@ const result = await api("/api/pages"); state.pages = result.pages; state.pageAliases = result.aliases || []; + cmapDescriptionPreviewCache.clear(); state.graphData = null; updateWikiIdentity(); renderPageList(); @@ -1410,6 +1497,7 @@ $("page-meta").textContent = ""; $("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug); renderPageDetails(page); + renderPageCmapConnections(page); pageBreadcrumbs(page); show("page-view"); setPageActionVisibility(true); @@ -2311,6 +2399,8 @@ $("page-meta").textContent = tr("this-page-missing", "This page does not exist yet."); $("markdown-preview").innerHTML = ""; $("page-details").replaceChildren(); + $("page-cmap-connections").classList.add("hidden"); + $("page-cmap-connections-list").replaceChildren(); renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: slugTitle(slug) || slug } @@ -2676,8 +2766,99 @@ const safeKind = escapeHtml(record.kind || "concept"); const safeImageSource = escapeHtml(record.imageSource || ""); const image = safeImageSource ? `` : ""; + const aspects = (Array.isArray(record.aspects) ? record.aspects : []) + .map((aspect) => `${escapeHtml(aspect)}`) + .join(""); + const aspectList = aspects ? `
${aspects}
` : ""; + const usageCount = record.conceptId && record.kind !== "phrase" && record.kind !== "submap" ? + effectiveCmapConceptUsage(record) : null; + const usageLabel = usageCount === null ? "" : + tr("concept-usage-count", "{count} placements across all concept maps") + .replace("{count}", String(usageCount)); + const usage = usageCount === null ? "" : + `(${usageCount})`; + const descriptionReference = canonicalPageReference(record.descriptionPageSlug || ""); + const descriptionExists = Boolean(descriptionReference && + state.pages.some((page) => page.slug === descriptionReference)); + const descriptionButton = record.descriptionPageSlug ? + `` : ""; + const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink; + const linkedButton = linkedTarget && record.kind !== "submap" ? + `` : ""; const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : ""; - return `
${image}
${safeLabel}
${safeSynopsis ? `
${safeSynopsis}
` : ""}
`; + return `
${descriptionButton}${linkedButton}${image}
${safeLabel} ${usage}
${aspectList}${safeSynopsis ? `
${safeSynopsis}
` : ""}
`; + } + + function effectiveCmapConceptUsage(record) { + const byMap = state.cmapConceptUsage.get(record.conceptId); + const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0; + const prototype = cmapPrototypeState(); + const isActiveEditorRecord = Boolean(prototype.editor && prototype.editor.items.includes(record)); + if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1); + const currentSlug = state.currentConceptMap ? state.currentConceptMap.slug : null; + const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0; + return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1)); + } + + function ensureCmapDescriptionTooltip() { + if (cmapDescriptionTooltip) return cmapDescriptionTooltip; + cmapDescriptionTooltip = document.createElement("div"); + cmapDescriptionTooltip.id = "cmap-description-tooltip"; + cmapDescriptionTooltip.className = "cmap-description-tooltip hidden"; + cmapDescriptionTooltip.setAttribute("role", "tooltip"); + document.body.append(cmapDescriptionTooltip); + return cmapDescriptionTooltip; + } + + function hideCmapDescriptionTooltip() { + cmapDescriptionTooltipToken += 1; + if (cmapDescriptionTooltip) cmapDescriptionTooltip.classList.add("hidden"); + } + + async function showCmapDescriptionTooltip(button) { + if (!button.classList.contains("is-filled")) return; + const itemElement = button.closest("[data-rw-cmap-item-id]"); + const prototype = cmapPrototypeState(); + const record = itemElement && prototype.editor ? prototype.editor.items.find((item) => + item.id === Number(itemElement.dataset.rwCmapItemId)) : null; + if (!record || !record.descriptionPageSlug) return; + + const reference = canonicalPageReference(record.descriptionPageSlug); + const tooltip = ensureCmapDescriptionTooltip(); + const token = ++cmapDescriptionTooltipToken; + button.setAttribute("aria-describedby", tooltip.id); + tooltip.classList.remove("hidden"); + tooltip.textContent = tr("loading", "Loading…"); + const rect = button.getBoundingClientRect(); + tooltip.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 440))}px`; + tooltip.style.top = `${Math.min(window.innerHeight - 180, rect.bottom + 8)}px`; + + try { + let preview = cmapDescriptionPreviewCache.get(reference); + if (preview === undefined) { + const page = await api(`/api/pages/${encodeURIComponent(reference)}`); + preview = String(page.markdown || "").trim() ? renderMarkdown(page.markdown, page.slug) : null; + cmapDescriptionPreviewCache.set(reference, preview); + } + if (token !== cmapDescriptionTooltipToken) return; + if (!preview) { + button.classList.remove("is-filled"); + button.classList.add("is-empty"); + tooltip.classList.add("hidden"); + return; + } + tooltip.innerHTML = `
${preview}
`; + const height = tooltip.offsetHeight; + if (rect.bottom + 8 + height > window.innerHeight) { + tooltip.style.top = `${Math.max(8, rect.top - height - 8)}px`; + } + } catch (error) { + if (token !== cmapDescriptionTooltipToken) return; + button.classList.remove("is-filled"); + button.classList.add("is-empty"); + tooltip.classList.add("hidden"); + if (error.status !== 404) console.error(error); + } } /** @@ -2715,15 +2896,21 @@ return /^#[0-9a-f]{6}$/i.test(value || "") ? value : fallback; } - function cmapFontSizeInPoints(value) { + function cmapFontSizeInPoints(value, baseSize = 11) { const size = Number.parseFloat(value); if (!Number.isFinite(size)) return 11; 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; } - function selectCmapFont(fontFamily) { - const select = $("cmap-concept-font-family"); + function displayCmapFontSize(value) { + return String(Math.round(value * 100) / 100); + } + + function selectCmapFont(fontFamily, selectId = "cmap-concept-font-family") { + const select = $(selectId); const value = fontFamily || "Arial, Helvetica, sans-serif"; const existing = Array.from(select.options).find((option) => option.value === value); if (!existing) { @@ -2810,6 +2997,11 @@ cmapDialogImageRead = Promise.resolve(); $("cmap-concept-label").value = record.label || ""; $("cmap-concept-synopsis").value = record.synopsis || ""; + $("cmap-concept-aspects").value = (record.aspects || []).join(", "); + $("cmap-concept-description-page").value = record.descriptionPageSlug || ""; + $("cmap-concept-description-link").href = record.descriptionPageSlug ? + pageRoute(record.descriptionPageSlug) : "#"; + $("cmap-concept-description-link").classList.toggle("hidden", !record.descriptionPageSlug); $("cmap-concept-background").value = cmapColorValue(record.backgroundColor); $("cmap-concept-background-label").textContent = record.kind === "submap" ? tr("main-concept-background-color", "Main concept background color") : @@ -2821,11 +3013,29 @@ record.submapBorderColor, "#57834a"); $("cmap-concept-text-color").value = cmapColorValue(record.textColor, "#222222"); selectCmapFont(record.fontFamily); - $("cmap-concept-font-size").value = String(Math.round(cmapFontSizeInPoints(record.fontSize))); + const titleFontSize = cmapFontSizeInPoints(record.fontSize); + $("cmap-concept-font-size").value = displayCmapFontSize(titleFontSize); + $("cmap-concept-bold").checked = String(record.fontWeight || "700") !== "400"; + $("cmap-concept-italic").checked = record.fontStyle === "italic"; + $("cmap-concept-synopsis-text-color").value = cmapColorValue( + record.synopsisTextColor || record.textColor, "#222222"); + selectCmapFont( + record.synopsisFontFamily || record.fontFamily, + "cmap-concept-synopsis-font-family"); + $("cmap-concept-synopsis-font-size").value = displayCmapFontSize( + cmapFontSizeInPoints(record.synopsisFontSize || "0.84em", titleFontSize)); + $("cmap-concept-synopsis-bold").checked = String( + record.synopsisFontWeight || record.fontWeight || "700") !== "400"; + $("cmap-concept-synopsis-italic").checked = + (record.synopsisFontStyle || record.fontStyle) === "italic"; + $("cmap-concept-style-preset").value = "default"; $("cmap-concept-image").value = ""; populateCmapPageOptions(record); populateCmapLinkOptions(record); updateCmapImagePreview(); + for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) { + updateCmapColorControl(input); + } $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); @@ -2839,6 +3049,9 @@ $("cmap-concept-dialog-title").textContent = tr("add-concept", "Add concept"); $("cmap-concept-label").value = "New concept"; $("cmap-concept-synopsis").value = ""; + $("cmap-concept-aspects").value = ""; + $("cmap-concept-description-page").value = ""; + $("cmap-concept-description-link").classList.add("hidden"); $("cmap-concept-background").value = "#fff4cf"; $("cmap-concept-background-label").textContent = tr("background-color", "Background color"); $("cmap-submap-style-fields").classList.add("hidden"); @@ -2847,15 +3060,169 @@ $("cmap-concept-text-color").value = "#222222"; selectCmapFont("Arial, Helvetica, sans-serif"); $("cmap-concept-font-size").value = "11"; + $("cmap-concept-bold").checked = true; + $("cmap-concept-italic").checked = false; + $("cmap-concept-synopsis-text-color").value = "#4d4d4d"; + selectCmapFont("Arial, Helvetica, sans-serif", "cmap-concept-synopsis-font-family"); + $("cmap-concept-synopsis-font-size").value = "9"; + $("cmap-concept-synopsis-bold").checked = false; + $("cmap-concept-synopsis-italic").checked = false; + $("cmap-concept-style-preset").value = "default"; $("cmap-concept-image").value = ""; populateCmapPageOptions({ kind: "concept", pageSlug: null }); populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false }); updateCmapImagePreview(); + for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) { + updateCmapColorControl(input); + } $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); } + const cmapStylePresets = { + default: { background: "#fff4cf", text: "#222222", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: false }, + subtle: { background: "#f1f3f5", text: "#56616b", font: "system-ui, sans-serif", size: 10, bold: false, italic: false }, + emphasis: { background: "#e7f2fb", text: "#173b57", font: "Georgia, Times New Roman, serif", size: 12, bold: true, italic: false }, + warning: { background: "#fff0d5", text: "#713b00", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: true }, + success: { background: "#e6f4e2", text: "#285b27", font: "Arial, Helvetica, sans-serif", size: 11, bold: false, italic: true } + }; + + const defaultCmapColorPalette = ["#ffffff", "#f1f3f5", "#e7f2fb", "#e6f4e2", "#fff4cf", "#fff0d5", + "#f7dede", "#dcd8f7", "#222222", "#4479a1", "#57834a", "#a97c00"]; + const cmapColorPaletteStorageKey = "racket-wiki:cmap-color-palette:v1"; + + function loadCmapColorPalette() { + try { + const stored = JSON.parse(window.localStorage.getItem(cmapColorPaletteStorageKey) || "null"); + if (Array.isArray(stored) && stored.length === defaultCmapColorPalette.length) { + return stored.map((color, index) => + cmapColorValue(color, defaultCmapColorPalette[index])); + } + } catch (error) { + console.warn("The CMap color palette could not be read.", error); + } + return [...defaultCmapColorPalette]; + } + + function storeCmapColorPalette(colors) { + try { + window.localStorage.setItem(cmapColorPaletteStorageKey, JSON.stringify(colors)); + } catch (error) { + console.warn("The CMap color palette could not be stored.", error); + } + } + + function openNativeCmapColorPicker(initialColor, onInput) { + 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", () => 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(); + } + } + + function updateCmapColorControl(input) { + const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch"); + if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff"); + } + + function installCmapColorPickers() { + const colors = loadCmapColorPalette(); + 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 = 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"; + colors.forEach((color, index) => { + const choice = document.createElement("button"); + choice.type = "button"; + choice.dataset.cmapColorIndex = String(index); + choice.style.backgroundColor = color; + choice.title = `${color} — ${tr("change-palette-color", "double-click to change")}`; + choice.setAttribute("aria-label", color); + let clickTimer = null; + choice.addEventListener("click", () => { + if (clickTimer !== null) window.clearTimeout(clickTimer); + clickTimer = window.setTimeout(() => { + clickTimer = null; + input.value = colors[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; + openNativeCmapColorPicker(colors[index], (newColor) => { + colors[index] = newColor; + for (const matchingChoice of document.querySelectorAll( + `.cmap-color-palette button[data-cmap-color-index="${index}"]`)) { + matchingChoice.style.backgroundColor = newColor; + matchingChoice.title = `${newColor} — ${tr("change-palette-color", "double-click to change")}`; + matchingChoice.setAttribute("aria-label", newColor); + } + storeCmapColorPalette(colors); + }); + }); + 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"); + openNativeCmapColorPicker(input.value, (newColor) => { + input.value = newColor; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + }); + input.addEventListener("input", () => updateCmapColorControl(input)); + updateCmapColorControl(input); + } + } + + function applySelectedCmapStyle() { + const preset = cmapStylePresets[$("cmap-concept-style-preset").value]; + if (!preset) return; + $("cmap-concept-background").value = preset.background; + $("cmap-concept-text-color").value = preset.text; + selectCmapFont(preset.font); + $("cmap-concept-font-size").value = String(preset.size); + $("cmap-concept-bold").checked = preset.bold; + $("cmap-concept-italic").checked = preset.italic; + $("cmap-concept-synopsis-text-color").value = preset.text; + selectCmapFont(preset.font, "cmap-concept-synopsis-font-family"); + $("cmap-concept-synopsis-font-size").value = String(Math.max(6, preset.size - 2)); + $("cmap-concept-synopsis-bold").checked = false; + $("cmap-concept-synopsis-italic").checked = preset.italic; + updateCmapColorControl($("cmap-concept-background")); + updateCmapColorControl($("cmap-concept-text-color")); + updateCmapColorControl($("cmap-concept-synopsis-text-color")); + } + function setCmapZoom(value) { const prototype = cmapPrototypeState(); const percent = Math.max(25, Math.min(300, Number(value) || 100)); @@ -2904,6 +3271,29 @@ } } + function renderHiddenCmapItems() { + const details = $("cmap-hidden-items"); + const options = $("cmap-hidden-item-options"); + const prototype = cmapPrototypeState(); + const hidden = prototype.editor ? prototype.editor.hiddenItemsInCurrentContext() : []; + details.classList.toggle("hidden", hidden.length === 0); + details.querySelector("summary").textContent = hidden.length ? + `${tr("hidden-concepts", "Hidden concepts")} (${hidden.length})` : + tr("hidden-concepts", "Hidden concepts"); + options.replaceChildren(); + for (const record of hidden) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = record.label; + button.title = tr("show-hidden-concept", "Show this concept again"); + button.addEventListener("click", () => { + prototype.editor.showItemInCurrentContext(record); + details.open = false; + }); + options.append(button); + } + } + function cmapZoomStorageKey() { const storedMap = state.currentConceptMap ? state.currentConceptMap.slug : "unsaved"; const prototype = cmapPrototypeState(); @@ -2930,6 +3320,9 @@ function openCmapContextMenu(clientX, clientY, createContext = {}) { const menu = $("cmap-context-menu"); cmapContextCreateContext = createContext; + // The CMap library assigns ever-increasing z-indices to map components. + // Keep this fixed-position menu outside that stacking context. + if (menu.parentElement !== document.body) document.body.append(menu); menu.classList.remove("hidden"); const left = Math.max(8, Math.min(clientX, window.innerWidth - menu.offsetWidth - 8)); const top = Math.max(8, Math.min(clientY, window.innerHeight - menu.offsetHeight - 8)); @@ -2991,6 +3384,17 @@ editor.selectAll(); return; } + if (commandKey && !event.altKey && key === "c") { + if (editor.copySelectionReferences()) event.preventDefault(); + return; + } + if (commandKey && !event.altKey && key === "v") { + if (editor.canPasteConceptReferences()) { + event.preventDefault(); + editor.pasteConceptReferences(); + } + return; + } if (commandKey && !event.altKey && key === "z") { event.preventDefault(); if (event.shiftKey) { @@ -3104,7 +3508,7 @@ canvas.replaceChildren(); state.cmapPrototype = null; - console.info("[racket-wiki:cmap-host 0.2.94] resetCmapPrototype", { + console.info("[racket-wiki:cmap-host 0.2.99] resetCmapPrototype", { cmapAvailable: typeof window.Cmap === "function", interactionLayerAvailable: Boolean(window.RacketWikiCmap), interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null, @@ -3135,8 +3539,19 @@ navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error)); } }, + onOpenStoredSubMap: (record) => { + if (record.cmapSlug) { + navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error)); + } + }, + onOpenBoundaryReference: () => { + const source = state.currentConceptMapSource; + if (source) { + navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error)); + } + }, onOpenSubMap: (record) => { - console.info("[racket-wiki:cmap-host 0.2.94] submap state changed", { + console.info("[racket-wiki:cmap-host 0.2.99] submap state changed", { id: record.id, expanded: record.expanded, childMap: record.childMap @@ -3150,11 +3565,16 @@ onSelectionChange: (record, connector, selectedRecords) => { const selected = Array.isArray(selectedRecords) ? selectedRecords : (record ? [record] : []); $("cmap-promote-submap").disabled = !(selected.length === 1 && - record && record.kind === "submap" && !record.separateMap); + record && record.kind === "submap" && !record.cmapSlug); + $("cmap-promote-submap").title = ""; $("cmap-edit-selected").disabled = selected.length !== 1; $("cmap-group-selected").disabled = !prototype.editor.canGroupSelection(); $("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection(); + $("cmap-hide-selected").disabled = !prototype.editor.canHideSelectionInCurrentContext(); $("cmap-delete-selected").disabled = selected.length === 0 && !connector; + $("cmap-copy-selected").disabled = !selected.some((item) => + item.conceptId && item.kind !== "phrase" && item.kind !== "submap"); + $("cmap-paste-concepts").disabled = !prototype.editor.canPasteConceptReferences(); }, onHistoryChange: ({ canUndo, canRedo }) => { $("cmap-undo").disabled = !canUndo; @@ -3165,7 +3585,9 @@ $("cmap-map-navigation").classList.toggle("hidden", !mapReference); $("cmap-active-map-title").textContent = mapReference ? mapReference.title : ""; restoreCmapZoom(); + renderHiddenCmapItems(); }, + onVisibilityChange: () => renderHiddenCmapItems(), onEditItem: (record) => editSelectedCmapNode(record), onCreateConnectedItem: (context) => openNewCmapConceptDialog(context), createRelationLabel: tr("create-relation", "Create relation"), @@ -3174,13 +3596,14 @@ relationLabel: tr("relation", "Relation") }); restoreCmapZoom(); - console.info("[racket-wiki:cmap-host 0.2.94] editor stored", { + console.info("[racket-wiki:cmap-host 0.2.99] editor stored", { editorAvailable: Boolean(prototype.editor), canvasChildCount: canvas.children.length }); if (cmapDocument) { prototype.editor.loadDocument(cmapDocument); + renderHiddenCmapItems(); return; } if (!includeSample) { @@ -3239,34 +3662,114 @@ state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)), state.currentConceptMap ? state.currentConceptMap.slug : ""); const hasStoredMap = Boolean(state.currentConceptMap); + const referenceButton = $("cmap-wiki-reference"); + referenceButton.classList.toggle("hidden", !hasStoredMap); + if (hasStoredMap) { + referenceButton.textContent = `${state.currentConceptMap.title} · cmap:${state.currentConceptMap.slug}`; + referenceButton.dataset.markdown = + `[${escapeMarkdownLinkLabel(state.currentConceptMap.title)}](cmap:${state.currentConceptMap.slug})`; + } else { + referenceButton.textContent = ""; + delete referenceButton.dataset.markdown; + } $("cmap-rename-map").disabled = !hasStoredMap; + $("cmap-set-start-map").disabled = !hasStoredMap; + $("cmap-set-start-map").textContent = hasStoredMap && + startCmapSlug() === state.currentConceptMap.slug ? + tr("start-concept-map", "Start CMap") : + tr("set-start-concept-map", "Use as start CMap"); $("cmap-delete-map").disabled = !hasStoredMap; $("cmap-create-snapshot").disabled = !hasStoredMap; $("cmap-history").disabled = !hasStoredMap; } async function loadConceptMaps() { - const result = await api("/api/cmaps"); + const [result, usageResult] = await Promise.all([ + api("/api/cmaps"), + api("/api/cmaps/concept-usage").catch((error) => { + console.warn("[racket-wiki:cmap-host 0.2.99] concept usage is unavailable; CMaps continue without global counts", error); + return { placements: [] }; + }) + ]); state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : []; + state.cmapConceptUsage = new Map(); + state.cmapPageConcepts = new Map(); + const placements = Array.isArray(usageResult.placements) ? usageResult.placements : []; + const pageByConcept = new Map(); + for (const placement of placements) { + if (!placement?.conceptId || !placement.pageSlug) continue; + pageByConcept.set( + placement.conceptId, + canonicalPageReference(placement.pageSlug).toLocaleLowerCase()); + } + for (const placement of placements) { + if (!placement || !placement.conceptId || !placement.cmapSlug) continue; + if (!state.cmapConceptUsage.has(placement.conceptId)) { + state.cmapConceptUsage.set(placement.conceptId, new Map()); + } + state.cmapConceptUsage.get(placement.conceptId) + .set(placement.cmapSlug, Number(placement.count) || 0); + + const pageKey = pageByConcept.get(placement.conceptId); + if (!pageKey) continue; + if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map()); + const pageConcepts = state.cmapPageConcepts.get(pageKey); + if (!pageConcepts.has(placement.conceptId)) { + pageConcepts.set(placement.conceptId, { + conceptId: placement.conceptId, + label: placement.label || tr("concept", "Concept"), + count: 0, + maps: new Map() + }); + } + const concept = pageConcepts.get(placement.conceptId); + const count = Number(placement.count) || 0; + concept.count += count; + const existingMap = concept.maps.get(placement.cmapSlug); + concept.maps.set(placement.cmapSlug, { + slug: placement.cmapSlug, + title: placement.cmapTitle || placement.cmapSlug, + count: count + (existingMap?.count || 0) + }); + } state.graphData = null; renderConceptMapSelector(); + const prototype = cmapPrototypeState(); + if (prototype.editor) prototype.editor.refreshConceptUsageIndicators(); + if (state.currentPage) renderPageCmapConnections(state.currentPage); return state.conceptMaps; } + function normalizeCmapConceptIdentities(documentValue, cmapSlug) { + const legacyIds = new Map(); + const normalize = (conceptId) => { + 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; + } + function decodeStoredConceptMapDocument(conceptMap) { let documentValue = conceptMap.document; for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) { documentValue = JSON.parse(documentValue); } if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) { - console.error("[racket-wiki:cmap-host 0.2.94] invalid stored CMap document", { + console.error("[racket-wiki:cmap-host 0.2.99] invalid stored CMap document", { slug: conceptMap.slug, valueType: Array.isArray(documentValue) ? "array" : typeof documentValue, value: documentValue }); throw new Error("The stored CMap document is not a JSON object."); } - return documentValue; + return normalizeCmapConceptIdentities(documentValue, conceptMap.slug || ""); } function currentCmapSnapshot() { @@ -3275,6 +3778,10 @@ return JSON.stringify(prototype.editor.toDocument()); } + function currentCmapStorageMap() { + return state.currentConceptMapSource || state.currentConceptMap; + } + function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) { state.cmapSavedSnapshot = snapshot; } @@ -3359,18 +3866,52 @@ const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`); if (loadSequence !== state.cmapLoadSequence) return; conceptMap.document = decodeStoredConceptMapDocument(conceptMap); - console.info("[racket-wiki:cmap-host 0.2.94] stored CMap received", { + const derivedView = conceptMap.document.derivedView; + let sourceMap = null; + let editorDocument = conceptMap.document; + if (derivedView && typeof derivedView === "object" && + typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug && + Number.isInteger(Number(derivedView.rootItemId))) { + sourceMap = await api(`/api/cmaps/${encodeURIComponent(derivedView.sourceCmapSlug)}`); + if (loadSequence !== state.cmapLoadSequence) return; + sourceMap.document = decodeStoredConceptMapDocument(sourceMap); + editorDocument = sourceMap.document; + } + console.info("[racket-wiki:cmap-host 0.2.99] stored CMap received", { slug: conceptMap.slug, version: conceptMap.currentVersion, itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0, connectorCount: Array.isArray(conceptMap.document.connectors) ? conceptMap.document.connectors.length : 0 }); state.currentConceptMap = conceptMap; + state.currentConceptMapSource = sourceMap; renderConceptMapSelector(); - resetCmapPrototype(conceptMap.document, false); + resetCmapPrototype(editorDocument, false); + if (sourceMap) { + const editor = cmapPrototypeState().editor; + const root = editor.items.find((item) => + Number(item.id) === Number(derivedView.rootItemId)); + if (!root || root.kind !== "submap") { + throw new Error("The source sub-CMap no longer exists."); + } + root.separateMap = true; + root.cmapSlug = conceptMap.slug; + root.childMap = conceptMap.title; + if (!root.mapReference) { + root.mapReference = { + id: `cmap-${root.id}`, + title: conceptMap.title, + rootItemId: root.id, + itemIds: editor.items.filter((item) => editor.isDescendantOf(item, root)) + .map((item) => item.id) + }; + editor.conceptMaps.set(root.mapReference.id, root.mapReference); + } + editor.openSubmapMap(root); + } markCurrentCmapSaved(); const loadedEditor = cmapPrototypeState().editor; - console.info("[racket-wiki:cmap-host 0.2.94] stored CMap loaded", { + console.info("[racket-wiki:cmap-host 0.2.99] stored CMap loaded", { slug: conceptMap.slug, editorAvailable: Boolean(loadedEditor), itemCount: loadedEditor ? loadedEditor.items.length : 0, @@ -3380,7 +3921,7 @@ } async function loadHistoricalConceptMapVersion(version) { - const conceptMap = state.currentConceptMap; + const conceptMap = currentCmapStorageMap(); if (!conceptMap) return; const historical = await api( `/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`); @@ -3395,12 +3936,19 @@ } async function showConceptMapHistory() { - const conceptMap = state.currentConceptMap; + const conceptMap = currentCmapStorageMap(); if (!conceptMap) return; const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`); const list = $("cmap-history-list"); list.replaceChildren(); - for (const version of result.versions || []) { + const versions = result.versions || []; + if (!versions.length) { + const empty = document.createElement("p"); + empty.className = "muted cmap-history-empty"; + empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet."); + list.append(empty); + } + for (const version of versions) { const row = document.createElement("div"); row.className = "cmap-history-row"; const label = document.createElement("div"); @@ -3435,7 +3983,40 @@ requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version)) .catch((error) => showCmapStatus(error.message)); }); - row.append(label, load); + const actions = document.createElement("div"); + actions.className = "cmap-history-actions"; + actions.append(load); + if (can("editor")) { + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = tr("delete-history-item", "Delete"); + remove.addEventListener("click", async () => { + const question = 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 api( + `/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version.version)}`, + { method: "DELETE" }); + row.remove(); + if (!list.querySelector(".cmap-history-row")) { + const empty = document.createElement("p"); + empty.className = "muted cmap-history-empty"; + empty.textContent = tr("no-concept-map-history", "No snapshots or manual saves yet."); + list.append(empty); + } + showCmapStatus(tr("concept-map-history-deleted", "History item deleted"), true); + } catch (error) { + remove.disabled = false; + showCmapStatus(error.message); + } + }); + actions.append(remove); + } + row.append(label, actions); list.append(row); } $("cmap-history-dialog").showModal(); @@ -3455,6 +4036,69 @@ location.hash = cmapRoute(conceptMap.slug); } + async function promoteSelectedSubmapToStoredMap() { + const prototype = cmapPrototypeState(); + const editor = prototype.editor; + const record = editor ? editor.selected() : null; + if (!record || record.kind !== "submap") return false; + if (record.cmapSlug) { + await navigateToHash(cmapRoute(record.cmapSlug)); + return true; + } + const title = window.prompt( + tr("submap-name", "Name of the new concept map"), + record.childMap || record.label); + if (!title || !title.trim()) return false; + + const button = $("cmap-promote-submap"); + button.disabled = true; + try { + const sourceSaved = await saveStoredConceptMap({ + force: true, + snapshotVersion: true, + summary: tr("before-submap-extraction", "Before creating shared sub-CMap view") + }); + if (!sourceSaved) return false; + + const sourceMap = currentCmapStorageMap(); + if (!sourceMap) return false; + const created = await api("/api/cmaps", { + method: "POST", + body: JSON.stringify({ + title: title.trim(), + document: { + schemaVersion: 2, + derivedView: { + sourceCmapSlug: sourceMap.slug, + rootItemId: record.id + }, + concepts: [], + items: [], + connectors: [], + conceptMaps: [] + } + }) + }); + editor.promoteSubmap(record, title.trim()); + editor.updateItem(record, { cmapSlug: created.slug }); + const parentSaved = await saveStoredConceptMap({ + force: true, + historyMode: "autosave", + summary: tr("submap-derived", "Standalone view created for sub-CMap") + }); + if (!parentSaved) { + showCmapStatus(tr("submap-created-parent-unsaved", + "The new CMap was created, but the parent CMap still needs to be saved.")); + return false; + } + location.hash = cmapRoute(created.slug); + return true; + } finally { + const selected = editor.selected(); + button.disabled = !(selected && selected.kind === "submap" && !selected.cmapSlug); + } + } + async function renameStoredConceptMap() { cancelCmapAutosave(); if (cmapHasUnsavedChanges() && !await saveStoredConceptMap({ automatic: true })) return false; @@ -3497,6 +4141,7 @@ await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { method: "DELETE" }); markCurrentCmapSaved(); state.currentConceptMap = null; + state.currentConceptMapSource = null; await loadConceptMaps(); const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps"; await navigateToHash(target); @@ -3509,29 +4154,31 @@ } async function saveStoredConceptMap({ automatic = false, force = false, - summary = null, snapshotVersion = false } = {}) { + summary = null, snapshotVersion = false, + historyMode = null } = {}) { const prototype = cmapPrototypeState(); if (!prototype.editor) return false; cancelCmapAutosave(); - if (automatic && !state.currentConceptMap) return false; + if (automatic && !currentCmapStorageMap()) return false; + const effectiveHistoryMode = historyMode || + (snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual")); if (cmapSavePromise) { const firstSaveSucceeded = await cmapSavePromise; if (!firstSaveSucceeded) return false; - if (force || cmapHasUnsavedChanges()) { - return saveStoredConceptMap({ automatic, force, summary, snapshotVersion }); + if (force || cmapHasUnsavedChanges() || effectiveHistoryMode !== "autosave") { + return saveStoredConceptMap({ automatic, force, summary, snapshotVersion, historyMode }); } return true; } const snapshot = currentCmapSnapshot(); if (snapshot === null) return false; - if (!force && snapshot === state.cmapSavedSnapshot) { - if (!automatic) showCmapStatus(tr("concept-map-saved", "CMap saved"), true); + if (!force && snapshot === state.cmapSavedSnapshot && effectiveHistoryMode === "autosave") { return true; } const document = JSON.parse(snapshot); - const conceptMapAtStart = state.currentConceptMap; + const conceptMapAtStart = currentCmapStorageMap(); let saveSucceeded = false; showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") : (automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…"))); @@ -3561,15 +4208,22 @@ baseVersion: conceptMapAtStart.currentVersion, summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")), snapshot: snapshotVersion, + saveKind: effectiveHistoryMode, document }) }); - if (state.currentConceptMap && state.currentConceptMap.slug === conceptMapAtStart.slug) { + if (state.currentConceptMapSource && + state.currentConceptMapSource.slug === conceptMapAtStart.slug) { + state.currentConceptMapSource = savedConceptMap; + updateStoredConceptMapSummary(savedConceptMap); + } else if (state.currentConceptMap && + state.currentConceptMap.slug === conceptMapAtStart.slug) { state.currentConceptMap = savedConceptMap; updateStoredConceptMapSummary(savedConceptMap); } } markCurrentCmapSaved(snapshot); + await loadConceptMaps(); showCmapStatus( snapshotVersion ? tr("snapshot-created", "Snapshot created") : (automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")), @@ -3588,7 +4242,7 @@ } async function createConceptMapSnapshot() { - if (!state.currentConceptMap) return false; + if (!currentCmapStorageMap()) return false; const description = window.prompt(tr("snapshot-description", "Snapshot description"), ""); if (description === null) return false; return saveStoredConceptMap({ @@ -3612,17 +4266,19 @@ await openStoredConceptMap(requestedSlug); } else if (requestedSlug) { state.currentConceptMap = null; + state.currentConceptMapSource = null; resetCmapPrototype(null, false); markCurrentCmapSaved(); renderConceptMapSelector(); showCmapStatus(tr("concept-map-not-found", "CMap not found")); - } else if (state.currentConceptMap && - state.conceptMaps.some((conceptMap) => conceptMap.slug === state.currentConceptMap.slug)) { - await openStoredConceptMap(state.currentConceptMap.slug); } else if (state.conceptMaps.length) { - await openStoredConceptMap(state.conceptMaps[0].slug); + const preferred = startCmapSlug(); + const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) || + state.conceptMaps[0]; + await openStoredConceptMap(target.slug); } else { state.currentConceptMap = null; + state.currentConceptMapSource = null; resetCmapPrototype(); markCurrentCmapSaved(); renderConceptMapSelector(); @@ -4164,10 +4820,10 @@ }); $("cmaps-link").addEventListener("click", (event) => { event.preventDefault(); - const currentSlug = state.currentConceptMap && - state.conceptMaps.some((conceptMap) => conceptMap.slug === state.currentConceptMap.slug) ? - state.currentConceptMap.slug : null; - navigateToHash(currentSlug ? cmapRoute(currentSlug) : "#cmaps") + const preferred = startCmapSlug(); + const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) || + state.conceptMaps[0] || null; + navigateToHash(target ? cmapRoute(target.slug) : "#cmaps") .catch((error) => console.error(error)); }); function openSelectedConceptMap() { @@ -4184,6 +4840,30 @@ }); } $("cmap-map-select").addEventListener("change", openSelectedConceptMap); + $("cmap-wiki-reference").addEventListener("click", async (event) => { + const markdown = event.currentTarget.dataset.markdown || ""; + if (!markdown) return; + try { + await navigator.clipboard.writeText(markdown); + } catch (_error) { + const input = document.createElement("textarea"); + input.value = markdown; + input.style.position = "fixed"; + input.style.left = "-10000px"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); + } + showCmapStatus(tr("wiki-link-copied", "Wiki link copied")); + }); + $("cmap-set-start-map").addEventListener("click", () => { + if (!state.currentConceptMap) return; + setStartCmapSlug(state.currentConceptMap.slug); + renderConceptMapSelector(); + showCmapStatus(tr("start-concept-map-set", "Start CMap set"), true); + closeCmapContextMenu(); + }); $("cmap-new-map").addEventListener("click", () => { requestCmapTransition(() => createStoredConceptMap()) .catch((error) => { @@ -4212,16 +4892,20 @@ addCmapPrototypeNode({ ...cmapPlacementOptions(), label, synopsis: "Expandable child-map placeholder.", kind: "submap", childMap: label, backgroundColor: "#edf7e8", borderColor: "#57834a" }); }); $("cmap-promote-submap").addEventListener("click", () => { - const prototype = cmapPrototypeState(); - const record = prototype.editor ? prototype.editor.selected() : null; - if (!record || record.kind !== "submap") return; - const name = window.prompt(tr("submap-name", "Name of the new concept map"), record.childMap || record.label); - if (!name || !name.trim()) return; - prototype.editor.promoteSubmap(record, name.trim()); - prototype.editor.openSubmapMap(record); - $("cmap-promote-submap").disabled = true; + promoteSelectedSubmapToStoredMap().catch((error) => { + console.error(error); + showCmapStatus(error.message); + }); }); $("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode()); + $("cmap-copy-selected").addEventListener("click", () => { + const prototype = cmapPrototypeState(); + if (prototype.editor) prototype.editor.copySelectionReferences(); + }); + $("cmap-paste-concepts").addEventListener("click", () => { + const prototype = cmapPrototypeState(); + if (prototype.editor) prototype.editor.pasteConceptReferences(); + }); $("cmap-undo").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.undo(); @@ -4241,6 +4925,10 @@ const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.ungroupSelection(); }); + $("cmap-hide-selected").addEventListener("click", () => { + const prototype = cmapPrototypeState(); + if (prototype.editor) prototype.editor.hideSelectionInCurrentContext(); + }); $("cmap-delete-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.deleteSelection(); @@ -4259,6 +4947,11 @@ } }); $("cmap-map-back").addEventListener("click", () => { + if (state.currentConceptMapSource) { + navigateToHash(cmapRoute(state.currentConceptMapSource.slug)) + .catch((error) => console.error(error)); + return; + } const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.openParentMap(); }); @@ -4272,6 +4965,20 @@ parentSubmap: prototype.editor.submapAtPoint(point) }); }); + $("cmap-canvas").addEventListener("pointerover", (event) => { + const button = event.target.closest(".rw-cmap-view-description"); + if (button) showCmapDescriptionTooltip(button); + }); + $("cmap-canvas").addEventListener("pointerout", (event) => { + if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip(); + }); + $("cmap-canvas").addEventListener("focusin", (event) => { + const button = event.target.closest(".rw-cmap-view-description"); + if (button) showCmapDescriptionTooltip(button); + }); + $("cmap-canvas").addEventListener("focusout", (event) => { + if (event.target.closest(".rw-cmap-view-description")) hideCmapDescriptionTooltip(); + }); $("cmap-tools-menu").addEventListener("click", (event) => { const prototype = cmapPrototypeState(); if (!prototype.editor) return; @@ -4293,6 +5000,11 @@ $("cmap-context-menu").addEventListener("click", () => closeCmapContextMenu()); document.addEventListener("pointerdown", (event) => { if (!event.target.closest("#cmap-context-menu, #cmap-tools-menu")) closeCmapContextMenu(); + if (!event.target.closest(".cmap-color-control")) { + for (const palette of document.querySelectorAll(".cmap-color-palette")) { + palette.classList.add("hidden"); + } + } }); $("cmap-zoom-out").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) - 10)); $("cmap-zoom-in").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) + 10)); @@ -4307,6 +5019,12 @@ for (const id of ["cmap-concept-page", "cmap-concept-cmap"]) { $(id).addEventListener("input", (event) => event.target.setCustomValidity("")); } + $("cmap-concept-description-page").addEventListener("input", (event) => + event.target.setCustomValidity("")); + $("cmap-concept-description-link").addEventListener("click", () => + $("cmap-concept-dialog").close()); + installCmapColorPickers(); + $("cmap-apply-style").addEventListener("click", applySelectedCmapStyle); $("cmap-concept-image").addEventListener("change", (event) => { const file = event.target.files && event.target.files[0]; if (!file) return; @@ -4342,12 +5060,15 @@ return; } const fontSize = Math.max(6, Math.min(54, Number($("cmap-concept-font-size").value) || 11)); + const synopsisFontSize = Math.max(6, Math.min(54, + Number($("cmap-concept-synopsis-font-size").value) || 9)); const pageInput = $("cmap-concept-page"); const cmapInput = $("cmap-concept-cmap"); const selectedPage = record && record.kind === "submap" ? "" : cmapPageCombobox.value(); const selectedCmap = record && record.kind === "submap" ? "" : cmapLinkCombobox.value(); - if (selectedPage === null) { - pageInput.setCustomValidity(tr("select-listed-page", "Select a wiki page from the list or clear the field.")); + const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage; + if (selectedPage === null && !linkedPage) { + pageInput.setCustomValidity(tr("invalid-new-page", "Enter a page title or valid wiki address.")); pageInput.reportValidity(); return; } @@ -4356,29 +5077,50 @@ cmapInput.reportValidity(); return; } - const linkedPage = selectedPage || null; + const linkedPageValue = linkedPage || null; + const descriptionInput = $("cmap-concept-description-page").value.trim(); + const descriptionPage = descriptionInput ? newPageReference(descriptionInput) : + pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug); + if (!descriptionPage) { + $("cmap-concept-description-page").setCustomValidity( + tr("invalid-description-page", "Enter a valid description page address.")); + $("cmap-concept-description-page").reportValidity(); + return; + } const linkedCmapValue = selectedCmap || ""; const parentCmapLink = linkedCmapValue === "__parent__"; const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null; const changes = { - kind: record && record.kind === "submap" ? "submap" : (linkedPage ? "page" : "concept"), + kind: record && record.kind === "submap" ? "submap" : (linkedPageValue ? "page" : "concept"), label, synopsis: $("cmap-concept-synopsis").value, - pageSlug: linkedPage, + aspects: $("cmap-concept-aspects").value.split(",") + .map((aspect) => aspect.trim()).filter(Boolean), + descriptionPageSlug: descriptionPage, + pageSlug: linkedPageValue, cmapSlug: linkedCmap, parentCmapLink, imageSource: cmapDialogImageSource, - backgroundColor: $("cmap-concept-background").value, - textColor: $("cmap-concept-text-color").value, + backgroundColor: cmapColorValue($("cmap-concept-background").value), + textColor: cmapColorValue($("cmap-concept-text-color").value, "#222222"), fontFamily: $("cmap-concept-font-family").value || "Arial, Helvetica, sans-serif", - fontSize: `${fontSize}pt` + fontSize: `${fontSize}pt`, + fontWeight: $("cmap-concept-bold").checked ? "700" : "400", + fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal", + synopsisTextColor: cmapColorValue( + $("cmap-concept-synopsis-text-color").value, "#4d4d4d"), + synopsisFontFamily: $("cmap-concept-synopsis-font-family").value || + "Arial, Helvetica, sans-serif", + synopsisFontSize: `${synopsisFontSize}pt`, + synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400", + synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal" }; if (!record || record.kind !== "submap") { - changes.borderColor = linkedPage ? "#4479a1" : + changes.borderColor = linkedPageValue ? "#4479a1" : ((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00"); } else { - changes.submapBackgroundColor = $("cmap-submap-background").value; - changes.submapBorderColor = $("cmap-submap-border").value; + changes.submapBackgroundColor = cmapColorValue($("cmap-submap-background").value, "#edf7e8"); + changes.submapBorderColor = cmapColorValue($("cmap-submap-border").value, "#57834a"); } if (record) { prototype.editor.updateItem(record, changes); diff --git a/translate.rkt b/translate.rkt index b4e822a..bebb566 100644 --- a/translate.rkt +++ b/translate.rkt @@ -31,24 +31,43 @@ 'grouped-submap-synopsis "Grouped sub-concept map." 'navigation-graph "Navigation graph" 'click "click" 'cmap-help-add-selection "Add to or remove from the selection" + 'copy-paste-linked-concepts "Copy/paste linked concepts" + 'copy-linked-concepts "Copy concept reference" 'paste-linked-concepts "Paste linked concept" + 'concept-usage-count "{count} placements across all concept maps" + 'connected-cmap-concepts "Connected CMap concepts" 'concept-map-locations "Concept map locations" + 'copy-wiki-link "Copy wiki link" 'wiki-link-copied "Wiki link copied" + 'open-linked-item "Open linked page or CMap" + 'appearance "Appearance" 'style "Style" 'apply-style "Apply" + 'concept-heading-appearance "Heading" 'concept-subtext-appearance "Subtext" + 'change-palette-color "double-click to change" + 'color-swatch-help "Click for the palette; double-click for a custom color" + 'style-default "Default" 'style-subtle "Subtle" 'style-emphasis "Emphasis" 'style-warning "Warning" 'style-success "Success" + 'bold "Bold" 'italic "Italic" 'cmap-help-rename "Edit the selected concept" 'cmap-help-clear-selection "Clear selection" - 'concept-map-help-expanded "Select a concept to show its handles. Drag a relation to a concept or empty space. Double-click a page concept to open it; double-click a sub-CMap to expand or collapse it." - 'concept-map-help-context "Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. Grouping creates a new sub-CMap around the selection. Double-click a page or sub-CMap to open it; right-click for editing tools." + 'concept-map-help-expanded "Select a concept to show its handles. Drag a relation to a concept or empty space. Hold Alt while dropping it on a concept to create a direct line. Double-click a concept to edit it; double-click a sub-CMap to expand or collapse it." + 'concept-map-help-context "Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. Hold Alt while drawing a relation to create a direct line without a linking phrase. Grouping creates a new sub-CMap around the selection. Double-click a concept to edit it or a sub-CMap to open it; right-click for more tools." 'detach-submap-confirm "Place \"{concept}\" outside submap \"{submap}\"?" 'promote-submap "Make separate CMap" 'submap-name "Name of the new concept map" + 'submap-derived "Standalone view created for sub-CMap" + 'hidden-concepts "Hidden concepts" 'hide-in-this-view "Hide in this view" + 'show-hidden-concept "Show this concept again" + 'before-submap-extraction "Before creating shared sub-CMap view" 'submap-extracted "Sub-CMap moved to a separate CMap" 'empty-submap "This sub-CMap has no concepts to move." 'submap-created-parent-unsaved "The new CMap was created, but the parent CMap still needs to be saved." 'rename-concept-map "Rename CMap" 'delete-concept-map "Delete CMap" 'redo-action "Redo" 'save-now "Save now" + 'set-start-concept-map "Use as start CMap" 'start-concept-map "Start CMap" 'start-concept-map-set "Start CMap set" 'autosave-pending "Changes waiting to be saved" 'autosaving "Saving automatically…" 'concept-map-autosaved "CMap saved automatically" 'concept-map-renamed "CMap renamed" 'concept-map-deleted "CMap deleted" - 'concept-map-history "CMap history" 'concept-map-history-help "Load a historical version into the editor to inspect it. Saving it creates a new current version." + 'concept-map-history "CMap history" 'concept-map-history-help "Snapshots are kept indefinitely. Up to five explicit saves are kept; automatic saves are not added to history." 'create-concept-map-snapshot "Make snapshot…" 'snapshot-description "Snapshot description" 'snapshot "Snapshot" 'creating-snapshot "Creating snapshot…" 'snapshot-created "Snapshot created" 'concept-map-version-loaded "Version {version} loaded; save to make it current." 'load-version "Load version" 'current-version "Current" + 'no-concept-map-history "No snapshots or manual saves yet." 'delete-history-item "Delete" + 'delete-concept-map-history-confirm "Delete CMap history version {version}? The current CMap will not be changed." 'concept-map-history-deleted "History item deleted" 'automatic-save "Automatic save" 'manual-save "Manual save" 'concept-map-created-version "CMap created" 'concept-map-renamed-version "CMap renamed" 'concept-map-initial-version "Initial available version" 'delete-concept-map-confirm "Delete concept map \"{title}\"? Links to it will remain but will no longer open the map." 'reload-concept-map "Reload CMap" - 'edit-concept "Edit concept" 'linked-page "Linked wiki page" 'no-linked-page "No linked page" 'filter-pages "Filter pages" 'linked-concept-map "Linked concept map" 'no-linked-concept-map "No linked concept map" 'filter-concept-maps "Filter concept maps" 'parent-concept-map "Parent concept map" 'select-concept-map "Select a CMap" 'concept-map-loaded "CMap loaded" 'select-listed-page "Select a wiki page from the list or clear the field." 'select-listed-concept-map "Select a CMap from the list or clear the field." 'unsaved-concept-map "Unsaved concept map" 'save-concept-map-before-leaving "Save the changes to this concept map before leaving?" 'dont-save "Don't save" + 'edit-concept "Edit concept" 'aspects "Aspects" 'aspects-placeholder "Security, performance" 'concept-description-page "Concept description page" 'concept-description-page-placeholder "cmap:concept-name" 'view-concept-description "View concept description" 'view-description-page "View description page" 'invalid-description-page "Enter a valid description page address." 'linked-page "Linked wiki page" 'no-linked-page "No linked page" 'filter-pages "Select or enter a new page" 'linked-concept-map "Linked concept map" 'no-linked-concept-map "No linked concept map" 'filter-concept-maps "Filter concept maps" 'parent-concept-map "Parent concept map" 'select-concept-map "Select a CMap" 'concept-map-loaded "CMap loaded" 'select-listed-page "Select a wiki page from the list or clear the field." 'invalid-new-page "Enter a page title or valid wiki address." 'select-listed-concept-map "Select a CMap from the list or clear the field." 'unsaved-concept-map "Unsaved concept map" 'save-concept-map-before-leaving "Save the changes to this concept map before leaving?" 'dont-save "Don't save" 'concept-image "Concept image" 'remove-image "Remove image" 'text-color "Text color" 'main-concept-background-color "Main concept background color" 'submap-appearance "Sub-CMap appearance" 'submap-background-color "Sub-CMap background color" 'submap-border-color "Sub-CMap border color" 'link-concept-map "Link to CMap" 'insert-concept-map-link "Insert CMap link" 'concept-map-link-help "Insert the selected CMap as a link or as a read-only view." 'concept-map-not-found "CMap not found" 'insert "Insert" 'insert-link "Insert link" 'embed-concept-map "Embed CMap" 'embedded-concept-map-help "Double-click to open this CMap." 'a4-page-boundaries "A4 page boundaries" 'search-wiki "Search pages and concept maps" 'contents "Contents" 'pages "Pages" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Root" 'wiki-page "Wiki page" 'concept-map "Concept map" 'no-matching-search-results "No matching pages or concept maps." 'edit "Edit" 'rename "Rename" 'rename-page "Rename page" 'slug "Slug" 'rename-summary "Renamed page" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found" @@ -105,24 +124,43 @@ 'grouped-submap-synopsis "Gegroepeerde sub-conceptmap." 'navigation-graph "Navigatiegraaf" 'click "klik" 'cmap-help-add-selection "Aan de selectie toevoegen of eruit verwijderen" + 'copy-paste-linked-concepts "Gekoppelde concepten kopiëren/plakken" + 'copy-linked-concepts "Conceptverwijzing kopiëren" 'paste-linked-concepts "Gekoppeld concept plakken" + 'concept-usage-count "{count} plaatsingen in alle conceptmaps" + 'connected-cmap-concepts "Verbonden CMap-concepten" 'concept-map-locations "Locaties in conceptmaps" + 'copy-wiki-link "Wikilink kopiëren" 'wiki-link-copied "Wikilink gekopieerd" + 'open-linked-item "Gekoppelde pagina of CMap openen" + 'appearance "Vormgeving" 'style "Stijl" 'apply-style "Toepassen" + 'concept-heading-appearance "Kop" 'concept-subtext-appearance "Subtekst" + 'change-palette-color "dubbelklik om te wijzigen" + 'color-swatch-help "Klik voor het palet; dubbelklik voor een eigen kleur" + 'style-default "Standaard" 'style-subtle "Subtiel" 'style-emphasis "Nadruk" 'style-warning "Waarschuwing" 'style-success "Succes" + 'bold "Vet" 'italic "Cursief" 'cmap-help-rename "Het geselecteerde concept bewerken" 'cmap-help-clear-selection "Selectie opheffen" - 'concept-map-help-expanded "Selecteer een concept om de grepen te tonen. Sleep een relatie naar een concept of naar lege ruimte. Dubbelklik op een paginaconcept om het te openen; dubbelklik op een sub-CMap om die uit of in te klappen." - 'concept-map-help-context "Selecteer met Ctrl/Cmd/Shift-klik of door over lege ruimte te slepen. Sleep een geselecteerd element om de volledige selectie te verplaatsen. Groeperen maakt een nieuwe sub-CMap rond de selectie. Dubbelklik op een pagina of sub-CMap om die te openen; klik met rechts voor de bewerkingsfuncties." + 'concept-map-help-expanded "Selecteer een concept om de grepen te tonen. Sleep een relatie naar een concept of naar lege ruimte. Houd Alt ingedrukt wanneer u hem op een concept loslaat om een directe lijn te maken. Dubbelklik op een concept om het te bewerken; dubbelklik op een sub-CMap om die uit of in te klappen." + 'concept-map-help-context "Selecteer met Ctrl/Cmd/Shift-klik of door over lege ruimte te slepen. Sleep een geselecteerd element om de volledige selectie te verplaatsen. Houd Alt ingedrukt tijdens het tekenen van een relatie om een directe lijn zonder relatietekst te maken. Groeperen maakt een nieuwe sub-CMap rond de selectie. Dubbelklik op een concept om het te bewerken of op een sub-CMap om die te openen; klik met rechts voor meer functies." 'detach-submap-confirm "\"{concept}\" buiten submap \"{submap}\" plaatsen?" 'promote-submap "Aparte CMap maken" 'submap-name "Naam van de nieuwe conceptmap" + 'submap-derived "Zelfstandige weergave voor sub-CMap aangemaakt" + 'hidden-concepts "Verborgen concepten" 'hide-in-this-view "In deze weergave verbergen" + 'show-hidden-concept "Dit concept weer tonen" + 'before-submap-extraction "Voor het maken van de gedeelde sub-CMap-weergave" 'submap-extracted "Sub-CMap verplaatst naar een aparte CMap" 'empty-submap "Deze sub-CMap bevat geen concepten om te verplaatsen." 'submap-created-parent-unsaved "De nieuwe CMap is aangemaakt, maar de bovenliggende CMap moet nog worden opgeslagen." 'rename-concept-map "CMap hernoemen" 'delete-concept-map "CMap verwijderen" 'redo-action "Opnieuw uitvoeren" 'save-now "Nu opslaan" + 'set-start-concept-map "Als start-CMap gebruiken" 'start-concept-map "Start-CMap" 'start-concept-map-set "Start-CMap ingesteld" 'autosave-pending "Wijzigingen wachten op opslaan" 'autosaving "Automatisch opslaan…" 'concept-map-autosaved "CMap automatisch opgeslagen" 'concept-map-renamed "CMap hernoemd" 'concept-map-deleted "CMap verwijderd" - 'concept-map-history "CMap-geschiedenis" 'concept-map-history-help "Laad een historische versie in de editor om hem te bekijken. Opslaan maakt hiervan een nieuwe actuele versie." + 'concept-map-history "CMap-geschiedenis" 'concept-map-history-help "Snapshots blijven onbeperkt bewaard. Van expliciete opslag worden maximaal vijf versies bewaard; automatische opslag komt niet in de geschiedenis." 'create-concept-map-snapshot "Snapshot maken…" 'snapshot-description "Omschrijving van snapshot" 'snapshot "Snapshot" 'creating-snapshot "Snapshot maken…" 'snapshot-created "Snapshot gemaakt" 'concept-map-version-loaded "Versie {version} geladen; sla op om hem actueel te maken." 'load-version "Versie laden" 'current-version "Actueel" + 'no-concept-map-history "Nog geen snapshots of handmatig opgeslagen versies." 'delete-history-item "Verwijderen" + 'delete-concept-map-history-confirm "CMap-geschiedenisversie {version} verwijderen? De actuele CMap verandert niet." 'concept-map-history-deleted "Geschiedenisitem verwijderd" 'automatic-save "Automatisch opgeslagen" 'manual-save "Handmatig opgeslagen" 'concept-map-created-version "CMap aangemaakt" 'concept-map-renamed-version "CMap hernoemd" 'concept-map-initial-version "Eerste beschikbare versie" 'delete-concept-map-confirm "Conceptmap \"{title}\" verwijderen? Links ernaartoe blijven bestaan, maar kunnen de conceptmap niet meer openen." 'reload-concept-map "CMap opnieuw laden" - 'edit-concept "Concept bewerken" 'linked-page "Gekoppelde wikipagina" 'no-linked-page "Geen gekoppelde pagina" 'filter-pages "Filter pagina's" 'linked-concept-map "Gekoppelde conceptmap" 'no-linked-concept-map "Geen gekoppelde conceptmap" 'filter-concept-maps "Filter conceptmaps" 'parent-concept-map "Bovenliggende conceptmap" 'select-concept-map "Selecteer een CMap" 'concept-map-loaded "CMap geladen" 'select-listed-page "Selecteer een wikipagina uit de lijst of maak het veld leeg." 'select-listed-concept-map "Selecteer een CMap uit de lijst of maak het veld leeg." 'unsaved-concept-map "Niet-opgeslagen conceptmap" 'save-concept-map-before-leaving "De wijzigingen aan deze conceptmap opslaan voordat u hem verlaat?" 'dont-save "Niet opslaan" + 'edit-concept "Concept bewerken" 'aspects "Aspecten" 'aspects-placeholder "Beveiliging, performance" 'concept-description-page "Beschrijvingspagina van concept" 'concept-description-page-placeholder "cmap:conceptnaam" 'view-concept-description "Conceptbeschrijving bekijken" 'view-description-page "Beschrijvingspagina bekijken" 'invalid-description-page "Voer een geldig adres voor de beschrijvingspagina in." 'linked-page "Gekoppelde wikipagina" 'no-linked-page "Geen gekoppelde pagina" 'filter-pages "Selecteer of typ een nieuwe pagina" 'linked-concept-map "Gekoppelde conceptmap" 'no-linked-concept-map "Geen gekoppelde conceptmap" 'filter-concept-maps "Filter conceptmaps" 'parent-concept-map "Bovenliggende conceptmap" 'select-concept-map "Selecteer een CMap" 'concept-map-loaded "CMap geladen" 'select-listed-page "Selecteer een wikipagina uit de lijst of maak het veld leeg." 'invalid-new-page "Voer een paginatitel of geldig wiki-adres in." 'select-listed-concept-map "Selecteer een CMap uit de lijst of maak het veld leeg." 'unsaved-concept-map "Niet-opgeslagen conceptmap" 'save-concept-map-before-leaving "De wijzigingen aan deze conceptmap opslaan voordat u hem verlaat?" 'dont-save "Niet opslaan" 'concept-image "Afbeelding bij concept" 'remove-image "Afbeelding verwijderen" 'text-color "Tekstkleur" 'main-concept-background-color "Achtergrondkleur hoofdconcept" 'submap-appearance "Vormgeving sub-CMap" 'submap-background-color "Achtergrondkleur sub-CMap" 'submap-border-color "Randkleur sub-CMap" 'link-concept-map "Link naar CMap" 'insert-concept-map-link "CMap invoegen" 'concept-map-link-help "Voeg de geselecteerde CMap in als link of als alleen-lezen weergave." 'concept-map-not-found "CMap niet gevonden" 'insert "Invoegen" 'insert-link "Link invoegen" 'embed-concept-map "CMap insluiten" 'embedded-concept-map-help "Dubbelklik om deze CMap te openen." 'a4-page-boundaries "A4-paginagrenzen" 'search-wiki "Pagina's en conceptmaps doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Hoofdnamespace" 'wiki-page "Wikipagina" 'concept-map "Conceptmap" 'no-matching-search-results "Geen overeenkomende wikipagina's of conceptmaps." 'edit "Bewerken" 'rename "Hernoemen" 'rename-page "Pagina hernoemen" 'slug "Slug" 'rename-summary "Pagina hernoemd" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"