Bettern cmap handling. Lots of changes.

This commit is contained in:
2026-08-17 23:06:23 +02:00
parent 8997f7f94a
commit 12f1ed2764
27 changed files with 10351 additions and 217 deletions
-18
View File
@@ -1,18 +0,0 @@
#lang racket-makefile
(target all
(displayln "Use (make clean), (make ver), (make zip), etc."))
(target ver
(displayln (git* next-version)))
(target zip
(deps clean)
(zip-package #:exclude '("wiki-data")))
(target clean
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
(for-each (λ (d) (displayln d) (rm-rf d)) (list-dirs "." #px"(compiled|doc|docs)$" #:recursive #t))
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "scrbl" #px"[.](css|js|html)$"))
)
+27
View File
@@ -0,0 +1,27 @@
#lang rash
(require rash-coreutils
rash-makefile
rash-git-cli)
(makefile racket-wiki
(target all
(displayln "Use (make clean), (make ver), (make zip), etc."))
(target ver
(displayln (git* next-version)))
(target zip
(deps clean)
(zip-package #:exclude '("wiki-data")))
(target clean
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
(for-each (λ (d) (displayln d) (rm-rf d)) (list-dirs "." #px"(compiled|doc|docs)$" #:recursive #t))
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "scrbl" #px"[.](css|js|html)$"))
)
)
+45
View File
@@ -0,0 +1,45 @@
#lang rash
(require rash-coreutils
rash-makefile
rash-git-cli
racket-sprintf)
(makefile racket-wiki
(target all
(displayln "Use (make clean), (make ver), (make zip), etc."))
(target ver
(displayln (git* next-version)))
(target status
(map (λ (e)
(displayln
(sprintf "%-40.40s: %-15.15s %-15.15s"
(caddr e) (cadr e) (car e))))
(git 'status)))
(target sync
{
git add -A
git commit
git push
})
(target zip
(deps clean)
(zip-package #:exclude '("wiki-data")))
(target clean
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
(for-each (λ (d) (displayln d) (rm-rf d)) (list-dirs "." #px"(compiled|doc|docs)$" #:recursive #t))
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "scrbl" #px"[.](css|js|html)$"))
)
(target refresh
(refresh-makefile))
)
+310 -8
View File
@@ -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.31**.
Current development version: **0.2.84**.
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
@@ -18,7 +18,7 @@ Passwords use PBKDF2-HMAC-SHA256 through the libcrypto/OpenSSL provider. Session
The editor uses EasyMDE 2.21.0. EasyMDE bundles CodeMirror for editing and Marked for Markdown rendering. The application uses `easyMDE.markdown(...)` for the editor preview, normal page display and historical page display. DOMPurify sanitizes rendered Markdown, highlight.js highlights fenced code blocks, and diff2html renders page-version differences.
Font Awesome is installed locally by the setup procedure and supplies the EasyMDE toolbar icons.
Lucide supplies the sidebar and EasyMDE toolbar icons. Its pinned browser build is installed locally by the setup procedure, so normal wiki use does not contact a CDN.
## Roles
@@ -40,12 +40,13 @@ sessions
pages
page_versions
attachments
concept_maps
wiki_schema
```
`pages.markdown` contains the current Markdown source. Every save increments `current_version` and writes the matching immutable snapshot to `page_versions` in the same PostgreSQL transaction. Optimistic locking prevents an editor from silently overwriting a newer version.
The page search vector gives titles a higher weight than body Markdown and is indexed with a PostgreSQL GIN index. The `simple` text search configuration is used deliberately because a technical wiki commonly mixes Dutch, English, identifiers and package names.
The page search vector gives titles a higher weight than body Markdown and is indexed with a PostgreSQL GIN index. Concept maps are searched by their title, slug and the labels and synopses in their JSON document. The `simple` text search configuration is used deliberately because a technical wiki commonly mixes Dutch, English, identifiers and package names.
The filesystem contains only installation/configuration assets during normal 0.2.9 operation:
@@ -97,8 +98,7 @@ Setup is considered complete only when the PostgreSQL schema is reachable, at le
The web setup downloads these pinned browser builds:
```text
font-awesome/css/font-awesome.min.css
font-awesome/fonts/fontawesome-webfont.woff2
lucide.min.js
easymde.min.js
easymde.min.css
purify.min.js
@@ -229,9 +229,9 @@ The WikiWord portion still follows the strict letter-only classic rule. Todo and
EasyMDE supplies the Markdown editor, Markdown-aware styling while editing, line numbers, keyboard shortcuts, preview, side-by-side mode and fullscreen mode. On a normal desktop-sized window racket-wiki opens the editor in side-by-side mode by default. EasyMDE is configured with `sideBySideFullscreen: false`, so split editing remains inside the normal wiki page rather than taking over the complete browser window.
The toolbar uses the normal EasyMDE-style Font Awesome icons, installed locally during setup. It includes formatting, headings, lists, checklists, code blocks, tables, links, image upload, general file upload, undo/redo, preview, split view and fullscreen.
The toolbar uses Lucide SVG icons, installed locally during setup. It includes formatting, headings, lists, checklists, code blocks, tables, links, image upload, general file upload, undo/redo, preview, split view and fullscreen.
The sidebar gives the current page table of contents priority. The global page list is secondary and collapsible. The search box performs real PostgreSQL full-text search rather than filtering page titles in the browser.
The sidebar gives the current page table of contents priority. The global page list is secondary and collapsible. The search box performs real PostgreSQL full-text search across pages and concept maps rather than filtering titles in the browser.
A fenced block such as:
@@ -255,7 +255,7 @@ When an editor or administrator follows a wiki link whose target does not exist,
## Full-text search
The sidebar search field calls the authenticated `/api/search` endpoint. PostgreSQL builds a `tsquery` with `websearch_to_tsquery('simple', ...)`, ranks matches with `ts_rank`, and returns a short `ts_headline` excerpt. Search results are relevance sorted, with title terms weighted above body terms. The search vector is updated atomically whenever a page is created or saved.
The sidebar search field calls the authenticated `/api/search` endpoint. PostgreSQL builds a `tsquery` with `websearch_to_tsquery('simple', ...)`, ranks matches with `ts_rank`, and returns a short `ts_headline` excerpt. Page results use the indexed search vector that is updated atomically whenever a page is created or saved. Concept-map results derive a weighted vector from the stored map title, slug, concept labels and synopses without indexing embedded image data. Both result sets are merged by relevance, marked as `Wiki page` or `Concept map`, and limited to fifty results together.
## Uploads
@@ -426,3 +426,305 @@ CamelCase is now wiki syntax during rendering. Every CamelCase token becomes an
## 0.2.28
Implicit CamelCase/wiki mention linking now runs as a lightweight Markdown pre-render step instead of walking the rendered DOM. The stored Markdown is unchanged. Ordinary text is resolved before Marked renders it, so implicit links work consistently in paragraphs, lists, tables, blockquotes and headings. Existing Markdown links and images, URLs, inline code, indented code, fenced code blocks and Todo markers are excluded. A unique match is rendered with the actual target page title.
## Alias cleanup (0.2.34)
Admin -> Page aliases now shows current and historical uses of each retained page alias.
`Clean up references` replaces recognized references in current Markdown with the canonical page address.
Every changed page receives a normal immutable history version; historical versions themselves are never rewritten.
An alias can be deleted only after no current page still contains a recognized reference. If historical
versions still use the alias, the administration page warns that deleting it may make those old links stop
resolving.
The cleanup recognizes explicit Markdown destinations, old WikiWord references derived from the retained
title, and upload URLs. Fenced code, indented code and lines containing inline code are deliberately left
untouched.
0.2.34 also fixes the Windows path-separator regexp in `uploaded-file` (`#px"[/\\]"` at regexp level).
## Page rename and aliases (0.2.33)
Editors can rename a page or move it to another namespace without changing its database identity. The old namespace/slug is retained as an alias, so old page links and upload URLs continue to work. Ordinary Edit no longer changes the namespace; address changes go through Rename so the alias is always recorded. Admin shows the running software version and lists retained aliases together with current pages that still contain the old address.
## 0.2.36 context graph
The context graph now shows both incoming and outgoing direct wiki links explicitly. The current page is placed in the center, incoming pages on the left, outgoing pages on the right, and pages linked in both directions above the focus page. Directed arrows make the relation direction visible.
The `(context)` link beside Contents opens the context graph as an overlay without leaving the page. From the overlay it can be docked to the right of the page or above the page, or opened in the normal full-size graph view. A docked context graph stays available while reading and refreshes when navigating to another page.
## 0.2.37 concept-map prototype
This release adds an isolated in-memory concept-map prototype based on the MIT-licensed
`ionstage/cmap` browser library. The frontend dependency is installed by the normal web setup
as `cmap.js`; no npm or client build step is required.
The prototype is deliberately not stored in PostgreSQL yet. It is intended to validate the
editor model before a permanent CMap schema is designed. The CMaps view demonstrates draggable
free concepts, wiki-page concepts, sub-CMap placeholders, labelled directed relations, a short
synopsis, background colour and font settings. Double-clicking a wiki-page concept opens its
page. Sub-CMap nodes are placeholders in this prototype.
The existing automatic wiki graph remains separate: it is derived from actual wiki links,
whereas a CMap is intended to become a manually modelled semantic map with stable positions and
named relations.
On wide screens, docking the automatic context graph to the right now keeps the established
900px document width and uses otherwise unused horizontal space for a wider context panel.
## 0.2.40
Fixed serving of the bundled `/vendor-extensions/` frontend directory so the racket-wiki CMap interaction layer is loaded next to upstream `cmap.js`.
### 0.2.40
CMap concept selection now uses delegated pointer-up selection with a drag threshold, so selecting and dragging no longer compete with ionstage/cmap event handling.
### 0.2.42
Concept selection now also works with the original cmap 0.1.3 rendering model. During setup and at startup, racket-wiki adjusts the installed `cmap.js` so concept DOM nodes accept pointer events; cmap's existing coordinate-based drag handling continues to work. The generic renderer remains in `cmap.js`, while `cmap-racket-wiki.js` contains only the wiki-specific editor behaviour.
Both CMap script URLs receive the same fresh numeric `id` query parameter whenever the application HTML is loaded. This prevents a browser or caching proxy from silently mixing an older `cmap.js` or `cmap-racket-wiki.js` with the current application version during development and testing.
### 0.2.43
CMap selection is now a separate, immediate interaction. Pointer-down selects a concept before cmap starts moving it, while pointer-down on empty canvas space clears the selection. The selected concept receives a CMapTools-like purple selection ring, a compact relation handle above the concept and a resize handle at its lower-right corner. All concept-map presentation and interaction rules have moved from `wiki.css` to the separate `cmap.css`. Both stylesheets receive the same per-page cache id as both CMap scripts so the visual selection state cannot remain hidden by an older cached stylesheet.
### 0.2.44
The CMap prototype now reports its complete selection path to the browser console with prefixes `[racket-wiki:cmap 0.2.44]` and `[racket-wiki:cmap-host 0.2.44]`. Logging covers script and stylesheet loading, editor and item creation, pointer targets, item matching, selection and clearing, installed handles and the computed pointer, outline, shadow, overflow and z-index styles before and after browser redraw. `RacketWikiCmap.debugSelection()` can be entered manually in the console to inspect the current selection.
### 0.2.45
The concept-map renderer is now maintained as an application component below `static/cmap/` instead of being downloaded and patched as a vendor dependency. The directory contains `cmap.js`, the wiki interaction layer `cmap-racket-wiki.js`, its separate `cmap.css`, and notes about the local component boundary.
Selection now follows cmap's own coordinate hit test. The renderer exposes an `onSelection` callback for the public node or link wrapper it found, and `onRendered` callbacks report when the asynchronously drawn DOM element is actually available. The wiki layer therefore no longer has to infer selection from a DOM pointer target or decorate a node before it exists. Clicking a concept applies the CMapTools-like selection ring and controls; clicking empty map space clears it.
`cmap.js`, `cmap-racket-wiki.js`, and `cmap.css` share the same fresh numeric cache id in every served HTML response.
### 0.2.46
New concepts without an explicit width or height now receive a compact initial size based on their rendered title and synopsis. Longer text wraps at a practical maximum width instead of producing an excessively wide concept. Content-based sizing remains active while editing such a concept, but stops as soon as the user resizes it manually.
### 0.2.47
Selected concepts now have a separate edit handle at the lower-left, opposite the resize handle. It opens a modal editor for the visible concept label, synopsis, background colour, font and font size. A page concept keeps its stable `pageSlug` while its visible concept text can be changed independently; double-clicking the concept still opens the linked wiki page.
The concept editor also accepts an image file, shows a preview and can remove the image again. In this in-memory prototype the browser stores the image as a data URL with the node. This can be migrated to the normal wiki upload storage when CMaps themselves become persistent.
### 0.2.48
Double-click activation is now recognized by cmap's own hit-test and drag lifecycle instead of depending on the browser's DOM `dblclick` event. Two stationary clicks on the same component within 500 milliseconds produce an `onActivation` callback. This reliably opens the linked wiki page for a page concept while movement beyond a small tolerance remains a drag.
The concept editor now offers a font-family selection instead of a free-form field. Font size is shown, entered and stored in typographic points (`pt`); existing pixel values are converted to points when opened.
### 0.2.49
The concept editor now includes a text-colour picker and an explicit wiki-page selector. A free concept can be linked to any existing page and becomes a page concept without forcing its visible label to match the page title. Removing the link turns it back into a free concept. Double-click navigation calls `openPage` directly, so opening also works when the browser hash already names that same page.
Linking phrases no longer look like bordered concept blocks. They are compact, borderless labels placed in a visual gap between the two connector segments. A phrase can be dragged along its source-to-target line; cmap projects the requested position onto that line and keeps ordinary concept dragging unconstrained.
### 0.2.50
Dragging a relation handle onto empty map space now opens the normal concept dialog. Saving creates the concept at the drop position, connects it to the source and immediately opens the new linking phrase for editing. The toolbar's Add concept action uses that same dialog.
The CMap work surface grows to the right and downward when concepts or a relation draft reach its edge. Zoom controls in the upper-left provide minus, plus, an editable percentage and a 100% reset. Zoom is implemented in the bundled cmap layer and its pointer coordinates are normalized, so selection, double-clicking, dragging and resizing keep working between 25% and 300%.
### 0.2.51
New connections are straightened after both endpoints are attached and again when an endpoint or automatically sized linking phrase changes. This removes the unnecessary control-point bends inherited from the original cmap implementation. The growing map no longer has its own scrollbars: its element expands and normal page scrolling exposes the additional work area.
Sub-CMaps can now be expanded and collapsed with their plus/minus control or by double-clicking. Their concepts and relations appear in place inside a cloud-like boundary and remain fully selectable, draggable, editable and connectable. Child visibility is handled by cmap's own hit testing, so collapsed contents cannot accidentally be selected. The sample includes a nested sub-CMap to exercise recursive expansion.
A selected inline submap can also be promoted to a separately named concept map with **Make separate CMap**. In this prototype that reference and its contents remain in memory; durable concept-map pages and wiki embedding belong to the next storage-model step.
### 0.2.52
Connected links now calculate their node-edge endpoints and midpoint synchronously before their first render. This removes the remaining lines that incorrectly ran through the upper-left corner. Every later node movement rebuilds a straight connection from the current endpoints.
Submap membership is spatial. A concept created or dropped inside the deepest expanded cloud becomes part of that submap. A member dragged beyond the cloud's boundary is detached, while dropping it inside another cloud reparents it. The cloud boundary is frozen during the drag so it does not expand ahead of a departing node. Multiple external relations remain allowed: relations may connect to the collapsed submap head, and while expanded individual internal concepts may also connect outside.
Promoting a submap now collapses the inline instance and immediately opens its separate in-memory concept-map view. A persistent navigation control returns to the parent map and also supports nested separate maps. The former header toolbar has become a right-click context menu, also reachable through the vertical-dots button beside the sticky zoom controls.
While the CMap view is active, only the wiki's central `#main` content area scrolls. The sidebar remains outside that scroll container, and the expanding map itself no longer has a private scrollbar.
### 0.2.53
The submap entry is now also the main concept of its expanded or separately opened map; expansion no longer creates a duplicate main-concept node. Dragging a member beyond its current cloud asks for confirmation. Confirming detaches or reparents it, while declining preserves membership so the cloud grows around its new position.
Cross-boundary relations retain their real internal endpoint. When the submap is collapsed that endpoint is drawn from the visible submap entry, and when expanded it is rebound to the original concept. This also works recursively for nested submaps. Linking phrases are no longer projected along a straight connection: their free position forms the intentional bend point between the two relation segments.
### 0.2.54
Moving the head concept while its separate CMap view is open no longer asks whether that concept should leave its original parent submap. The head remains the stable link between parent and child maps while its position in the active map can still be changed normally.
### 0.2.55
F2 edits the selected CMap item. Concepts and submap heads open the existing concept dialog with the label selected; linking phrases switch directly to their inline name editor. The shortcut is active only in the CMap view and does not intercept F2 while a form field or editable element already has focus.
### 0.2.56
A promoted submap now has one unambiguous presentation. In its parent map the arrow opens the separate CMap and inline expansion is disabled. Inside that separate map the same entry is the main concept, so its self-referential navigation arrow is removed. Stale expanded state is cleared defensively, preventing a separate CMap and its inline cloud from being displayed at the same time. Ordinary, non-promoted submaps keep their plus/minus expansion control.
### 0.2.57
Concept maps are now durable wiki data. Schema migration 9 adds the `concept_maps` table with relational identity, audit fields, soft deletion and optimistic version numbers; the evolving editor document is stored as validated JSONB. The database operations live in the separate `private/cmap-storage.rkt` module, while `private/storage.rkt` remains focused on wiki pages and their existing metadata. Reader endpoints list and retrieve CMaps, and editor endpoints create, update and archive them.
The CMap view selects stored maps and lets editors create and save them. Loading restores concept positions and formatting, directed connectors, nested submap membership and promoted-map references. A normal concept can now link either to a wiki page or to an independently stored CMap. Inside a separately opened child map it can instead link back to its parent CMap. Activating either CMap link navigates without incorrectly expanding the node as an inline submap.
The concept dialog now places searchable filter fields above both the wiki-page and CMap selectors. The two link types are mutually exclusive, and CMap-linked concepts receive their own visual link indicator.
### 0.2.58
The stored-map selector and the wiki-page and CMap link selectors are now searchable comboboxes. Typing filters the browser's suggestion list directly, while the displayed title and stable slug remain unambiguous. An arbitrary value is not silently stored as a link: the value must be selected from the list or the field must be cleared.
Selecting a stored CMap now follows one explicit asynchronous load path. The UI reports loading and completion, stale responses from quickly changed selections are ignored, and the console reports the received and reconstructed item and connector counts. This also fixes the earlier selector path that could leave the canvas showing the previous map.
### 0.2.59
PostgreSQL JSONB parameters are now sent explicitly as text before conversion to JSONB. This prevents Racket's database driver from treating an already serialized CMap document as a JSON string value. Existing double-encoded rows remain readable: the storage layer recognizes and decodes them, and the next save writes them back as a normal JSON object.
The three CMap selectors now use a local, dependency-free styled combobox rather than the browser's visually limited `datalist`. The control follows the input-plus-choice-list pattern of Thibault Jan Beyer's MIT-licensed ComboBox.js, but uses a modern ARIA listbox, separate stable values and labels, title/slug descriptions, contains filtering and keyboard control tailored to racket-wiki. Both the combobox script and the main wiki script receive the per-request cache identifier.
The duplicate CSP meta element has been removed because the server already sends the complete policy as an HTTP header. This prevents Firefox's misleading `frame-ancestors` meta warning without weakening the policy. A blocked `sandbox eval code` inline script still comes from browser or extension-injected code and remains deliberately blocked by `script-src 'self'`.
### 0.2.60
Ctrl-S saves the CMap while the CMap workspace is active. Dirty-state detection compares the complete current editor document with the last successfully loaded or saved snapshot, so movement, resizing, formatting, relations and submap changes are all covered without maintaining a second incomplete list of change events.
Leaving a changed CMap, switching to another stored CMap or following a page/CMap link opens a three-way dialog: Save, Don't save or Cancel. Browser refresh and tab closing use the browser's native unsaved-data warning. Hash navigation is restored while the choice is pending, so Cancel genuinely keeps both the map and location in place.
The zoom percentage is stored in browser `localStorage`, keyed by stored CMap slug and by root or promoted child map. Because local storage is scoped to the wiki origin and browser profile, every device/browser environment naturally retains its own zoom value for each CMap without adding a tracking identifier or database state.
### 0.2.61
Expanded inline submaps now use a clean rounded rectangular frame instead of a cloud outline. The frame derives its background and border colours from the submap concept, encloses nested concepts and relations, and grows with its contents. Its collapse button sits halfway along the right border; the compact submap concept retains the plus button used to expand it.
### 0.2.62
An inline submap now behaves as one movable compound object. Moving its main concept, including while the submap is collapsed, applies the same displacement to every descendant concept, nested submap and linking phrase. Reopening therefore preserves the complete internal layout relative to the main concept.
The expanded frame is interactive rather than decorative. Clicking it selects the submap and exposes the existing concept editing controls, double-clicking opens the edit dialog directly, and dragging the frame moves the complete submap. Background and border colour changes made through the dialog are applied to both the main concept and its frame.
### 0.2.63
The main concept and its expanded sub-CMap frame now have independent presentation properties. Editing a submap shows separate colour controls for the main concept background, the sub-CMap background and the sub-CMap border. These values are persisted independently and survive the normal CMap document round trip. Older documents without the new frame fields receive the standard green submap frame without changing their main concept colour.
### 0.2.64
The main concept of an expanded sub-CMap now moves independently inside its frame, just like every other concept. Dragging the frame remains the single operation that moves the complete sub-CMap and all descendants. When the sub-CMap is collapsed, its visible main concept acts as the proxy for the hidden group, so moving that compact representation still preserves the complete relative layout.
### 0.2.65
Wiki pages can now link directly to stored concept maps. The EasyMDE toolbar contains a CMap-link action with the same searchable combobox used elsewhere in the editor. It inserts readable Markdown in the form `[Title](cmap:map-slug)`; selected editor text is retained as the link label.
During rendering, CMap targets are converted into durable `#cmap/map-slug` routes. Opening such a link switches to the CMap workspace and loads that exact stored map, including after a browser reload or when the URL is shared. A missing or archived target produces an explicit not-found status and is never silently replaced by another map.
### 0.2.66
Stored CMaps can also be referenced with wiki-style shorthand such as `cmap:DitIsEenCmap`. The renderer resolves the WikiWord against the known CMap titles and slugs and turns a unique match into a direct CMap link. Unknown or ambiguous names remain plain text; they are no longer misinterpreted as pages in a `cmap` namespace. Explicit links such as `[Andere tekst](cmap:dit-is-een-cmap)` remain supported.
### 0.2.67
Page and CMap navigation now consistently passes through hash routes before content is loaded. Following a page concept from a CMap, following a CMap concept, selecting another stored map and opening the CMaps workspace therefore create normal browser-history entries. A sequence such as home page -> CMap -> linked page can be retraced one step at a time with Back and Forward.
Internal CMap navigation checks for unsaved changes before changing the URL, preventing the save/discard dialog from creating a spurious history entry. The general CMaps workspace also has the reproducible `#cmaps` route. Saving a previously unnamed map replaces that generic route with its durable `#cmap/map-slug` permalink.
### 0.2.68
The CMap workspace now uses one compact toolbar instead of a page title, database note and permanently visible instruction paragraph. Help is available behind the small question-mark control. Loading and save confirmations fade automatically, while errors remain visible. The zoom controls are fixed at the lower-right edge of the viewport so they remain reachable on a large map.
The decorative square grid has been replaced by subtle A4 landscape page boundaries. Their dimensions scale with the map zoom, providing a useful indication for printing and future image export without dominating the canvas. Page boundaries can be toggled from the CMap tools menu, and the preference is retained in browser local storage.
### 0.2.69
The CMap controls now form one compact, sticky toolbar above the workspace. Map selection, New and Save remain at the left, followed by the tools menu; flexible space keeps zoom and help aligned at the right. This restores the convenient top-edge zoom controls without letting them cover the map. On narrower screens the toolbar wraps to keep every control usable.
### 0.2.70
CMaps support persistent multi-selection and logical groups. Ctrl/Command-click and Shift-click add or remove a concept or an existing group from the selection; dragging over empty canvas space selects everything intersecting the selection rectangle. Dragging one selected element moves the complete selection, while grouped elements are selected and moved together after saving and reopening the map.
The tools menu contains Select all, Group selection, Ungroup and Delete selection. The same operations are available through Ctrl/Command+A, Ctrl/Command+G, Ctrl/Command+Shift+G, Delete/Backspace and Escape. Groups are deliberately limited to one sub-CMap level so that group membership cannot contradict submap ownership. Deleting a selected submap also removes its descendants and attached relations.
### 0.2.71
The sidebar's five text links are now one compact Lucide icon row: Bookmarks, Todo, Recent, Navigation graph and CMaps. Every icon retains a translated tooltip, accessible name and visually hidden label. The EasyMDE toolbar has also moved from the Font Awesome webfont to Lucide SVG icons, allowing the Font Awesome runtime dependency to be removed completely. Lucide 1.31.0 is pinned and installed locally through the existing setup/repair procedure.
The CMap question-mark popover now contains a complete shortcut reference for multi-selection, Select all, Group, Ungroup, F2 editing, deletion, clearing the selection and saving.
### 0.2.72
Deleting a concept now also removes a linking phrase when that phrase has lost its final incoming or outgoing concept. This removes both the orphaned relation name and every remaining line segment belonging to it, while retaining a genuinely branching relation until its last endpoint on either side disappears.
Grouping is now a structural CMap operation rather than an invisible movement flag. Group selection asks for the main concept name and creates an expanded sub-CMap containing the selected items. Ungrouping a selected sub-CMap dissolves its container into a normal concept and moves its direct contents one level outward; selected child items can also be detached one level from their current inline sub-CMap.
### 0.2.73
Reopening or resetting a CMap now disposes the previous editor's marquee-selection listener and any selection rectangle that is still active. Repeated map loads therefore no longer create overlapping blue selection rectangles.
Dropping a relation dragged from a linking phrase onto empty space now creates the new concept and only the outgoing line segment. It no longer inserts a second linking phrase between the existing phrase and the new concept.
Wiki search now combines normal page results with stored concept maps. CMaps are searched by title, slug, concept labels and concept synopses. Every result is explicitly marked as either a Wiki page or Concept map and follows the correct page or CMap route when opened.
### 0.2.74
Fixes a Racket reader error in `search-pages`: the combined page/CMap search change in 0.2.73 accidentally left one surplus closing parenthesis at the end of the page result mapping.
### 0.2.75
The navigation graph is now shared by wiki pages and stored CMaps. A wiki-page node links to the pages and CMaps referenced by its rendered Markdown; a CMap node links to the pages and other stored CMaps referenced by its concepts. Page and CMap identities remain separate even when they use the same slug.
Wiki pages retain their blue graph dot and CMaps use a green dot. Activating a green node opens the corresponding CMap. CMaps also appear as incoming or outgoing neighbours in the context graph of a wiki page, and the full-graph summary reports page and CMap counts separately.
### 0.2.76
Alias-reference cleanup no longer calls the nonexistent `showToast` function. The alias administration view now has its own accessible status line and reports the number of changed pages there after cleanup.
### 0.2.77
Navigation graph, Recent, Bookmarks, Todo, Search and every administration view now have real hash routes. They therefore create normal browser-history entries instead of only replacing the visible application section while leaving the previous page URL unchanged.
Opening a graph node uses the central hash-navigation function. Returning from the navigation graph to the page that was already active before opening the graph therefore reloads that page correctly. Browser Back and Forward now retrace page, special-view, main-admin and admin-detail transitions in order.
### 0.2.78
Recent changes now combines wiki pages and stored concept maps in one chronological list. Each row is marked as either a Wiki page or Concept map, and opens through the corresponding application route. The fifty newest changes are selected after merging both storage sources.
### 0.2.79
The CMap editor now keeps an Undo/Redo history of up to one hundred complete document states. Node edits, additions, deletion, relations, grouping, sub-CMap changes, movement and resizing can be reversed without special cases for each document field. One drag or resize gesture is recorded as one history step.
Undo and Redo are available from the CMap context menu. The keyboard shortcuts are `Ctrl/Cmd+Z` for Undo and both `Ctrl/Cmd+Y` and `Ctrl/Cmd+Shift+Z` for Redo; these shortcuts are listed in the CMap help popover.
### 0.2.80
Undo transactions now end with the current browser event instead of after a 50 millisecond debounce period. Synchronous parts of one operation, such as adding a concept together with its relation, remain one Undo step. A following click or dialog confirmation, such as renaming another concept, is always recorded as a separate step.
### 0.2.81
Stored CMaps can now be renamed or removed from the CMap tools menu. Renaming changes the visible title while deliberately retaining the stable CMap address, so existing `cmap:` links keep working. It uses a dedicated version-checked storage operation and therefore does not accidentally save other unsaved editor changes.
Deletion uses the existing soft-delete storage operation, asks for confirmation and explains that existing references will remain but no longer open the removed map. After deletion, the editor opens the next available CMap or an empty CMap workspace.
The tools menu now distinguishes **Redo** from **Reload CMap**. In Dutch these are labelled **Opnieuw uitvoeren** and **CMap opnieuw laden**, removing the previous duplicate **Opnieuw** entries.
### 0.2.82
The CMap toolbar now keeps only the map combobox and tools button on its left side. **New CMap** and **Save now** have moved into the tools menu together with rename and delete, so all map-level commands are grouped in one place while zoom remains immediately available at the right.
Stored CMaps are saved automatically 1.5 seconds after a completed editor operation. A later operation restarts that delay, and changes made while a save request is in flight are queued for a following save instead of being lost. The compact status text distinguishes pending changes, automatic saving and a completed automatic save. Ctrl/Cmd+S remains available as **Save now** and is documented under that name in the help popover. The existing leave-page warning remains as a safety net when a save is still pending or has failed.
### 0.2.83
Restores the scheduling contract of the central wiki navigation guard. Version 0.2.82 made this function itself asynchronous in order to flush a pending CMap autosave before navigation. Because the same function also drives normal wiki routes, that change could leave an ordinary wiki page refresh waiting indefinitely.
CMap autosave and Ctrl/Cmd+S remain available. Navigation again follows the proven 0.2.81 flow: clean transitions proceed through the original promise chain, while the existing save/discard dialog protects a CMap whose autosave is still pending or has failed.
### 0.2.84
Creating an item and editing another item are now guaranteed to form two separate Undo transactions. Item creation and content editing start their history transaction before the CMap node is redrawn. A synchronous render callback can therefore no longer replace the pre-edit history snapshot while automatically fitting the node to its text.
The same transaction ordering is used for inline linking-phrase edits. Automatic sizing remains part of the edit that caused it, rather than becoming a separate Undo step.
+2 -2
View File
@@ -1,7 +1,7 @@
#lang info
(define pkg-authors '(hnmdijkema))
(define version "0.2.31")
(define version "0.2.84")
(define license 'MIT)
(define collection "racket-wiki")
(define pkg-desc
@@ -21,4 +21,4 @@
(define build-deps
'("racket-doc"
"rackunit-lib"
"scribble-lib"))
"scribble-lib"))
+32 -12
View File
@@ -18,28 +18,48 @@
(define (attachment-rows db)
(query-rows db
#<<SQL
SELECT a.id, p.slug, a.stored_name, p.namespace
SELECT a.id, a.page_id, a.stored_name
FROM attachments a
JOIN pages p ON p.id = a.page_id
ORDER BY a.id
SQL
))
(define (attachment-url slug stored-name)
(format "/uploads/~a/~a" slug stored-name))
(define (attachment-url reference stored-name)
(format "/uploads/~a/~a" reference stored-name))
(define (page-references db page-id)
(define current
(query-row db
"SELECT namespace, slug FROM pages WHERE id = $1"
page-id))
(define references
(list (if (string=? (vector-ref current 0) "")
(vector-ref current 1)
(string-append (vector-ref current 0) ":" (vector-ref current 1)))))
(define aliases-available?
(query-value db "SELECT to_regclass('page_aliases') IS NOT NULL"))
(when aliases-available?
(for ((row (in-list
(query-rows db
"SELECT namespace, slug FROM page_aliases WHERE page_id = $1 ORDER BY id"
page-id))))
(define reference
(if (string=? (vector-ref row 0) "")
(vector-ref row 1)
(string-append (vector-ref row 0) ":" (vector-ref row 1))))
(set! references (cons reference references))))
references)
(define (record-references! db page-id page-version-id markdown current? referenced-at)
(for ((row (in-list (attachment-rows db))))
(define attachment-id (vector-ref row 0))
(define slug (vector-ref row 1))
(define owner-page-id (vector-ref row 1))
(define stored-name (vector-ref row 2))
(define namespace (vector-ref row 3))
(define reference (if (string=? namespace "") slug (string-append namespace ":" slug)))
(define current-url (attachment-url reference stored-name))
(define legacy-url (attachment-url slug stored-name))
(when (or (string-contains? markdown current-url)
(and (not (string=? namespace ""))
(string-contains? markdown legacy-url)))
(define found? #f)
(for ((reference (in-list (page-references db owner-page-id))))
(when (string-contains? markdown (attachment-url reference stored-name))
(set! found? #t)))
(when found?
(query-exec db
#<<SQL
INSERT INTO attachment_references
+338
View File
@@ -0,0 +1,338 @@
#lang racket/base
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PostgreSQL-backed concept-map storage.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(require db
json
racket/string
"database.rkt"
"storage.rkt")
(provide list-concept-maps
list-recent-concept-maps
search-concept-maps
read-concept-map
create-concept-map!
rename-concept-map!
update-concept-map!
archive-concept-map!)
(define maximum-document-size (* 10 1024 1024))
(define concept-map-columns
"slug, title, document::text, current_version, created_at, updated_at, created_by, updated_by")
(define (document->text document)
(unless (jsexpr? document)
(raise-argument-error 'document->text "jsexpr?" document))
(define text (jsexpr->string document))
(when (> (bytes-length (string->bytes/utf-8 text)) maximum-document-size)
(error 'document->text "concept map document exceeds 10 MiB"))
text)
(define (text->document text)
(with-handlers ((exn:fail?
(λ (e)
(error 'text->document
"invalid stored concept map document: ~a"
(exn-message e)))))
(define parsed (string->jsexpr text))
(define document
(if (string? parsed)
(string->jsexpr parsed)
parsed))
(unless (hash? document)
(error 'text->document "stored concept map document is not a JSON object"))
document))
(define (row->concept-map row [include-document? #t])
(define result
(hash 'slug (vector-ref row 0)
'title (vector-ref row 1)
'currentVersion (vector-ref row 3)
'createdAt (vector-ref row 4)
'updatedAt (vector-ref row 5)
'createdBy (vector-ref row 6)
'updatedBy (vector-ref row 7)))
(if include-document?
(hash-set result 'document (text->document (vector-ref row 2)))
result))
(define (validate-concept-map-input who slug title document)
(unless (valid-slug? slug)
(error who "invalid concept map address: ~a" slug))
(when (string=? (string-trim title) "")
(error who "title is required"))
(unless (hash? document)
(raise-argument-error who "hash?" document))
(document->text document)
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List current concept-map metadata.
; pre : Database schema migration 9 has been installed.
; post : No database state is changed.
; result : A title-sorted list without the potentially large documents.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-concept-maps config)
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows
db
(string-append
"SELECT " concept-map-columns
" FROM concept_maps WHERE archived = FALSE"
" ORDER BY lower(title), title")))))
(row->concept-map row #f)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List recently edited current concept maps.
; pre : Database schema migration 9 has been installed.
; post : Concept-map rows have only been read.
; result : At most limit metadata hashes, newest first.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-recent-concept-maps config [limit 50])
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows
db
(string-append
"SELECT " concept-map-columns
" FROM concept_maps WHERE archived = FALSE"
" ORDER BY updated_at DESC, lower(title), title"
" LIMIT $1")
limit))))
(row->concept-map row #f)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Search current concept maps by map title, address and concept text.
; pre : query-text is a string and database schema 9 is installed.
; post : Concept-map documents have only been read.
; result : Up to 50 relevance-sorted search result hashes without documents.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (search-concept-maps config query-text)
(if (string=? (string-trim query-text) "")
'()
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows
db
#<<SQL
WITH q AS (
SELECT websearch_to_tsquery('simple', $1) AS query
), map_documents AS (
SELECT cm.slug,
cm.title,
coalesce((
SELECT string_agg(
concat_ws(' ', item ->> 'label', item ->> 'synopsis'),
' ')
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items'
ELSE '[]'::jsonb
END) AS item
), '') AS concept_text
FROM concept_maps cm
WHERE cm.archived = FALSE
), ranked AS (
SELECT slug,
title,
concept_text,
setweight(to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(slug, '')), 'A') ||
setweight(to_tsvector('simple', concept_text), 'B') AS search_document
FROM map_documents
)
SELECT ranked.slug,
ranked.title,
ts_rank(ranked.search_document, q.query) AS rank,
ts_headline(
'simple',
concat_ws(' ', ranked.title, ranked.concept_text),
q.query,
'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet
FROM ranked, q
WHERE ranked.search_document @@ q.query
ORDER BY rank DESC, lower(ranked.title), ranked.title
LIMIT 50
SQL
query-text))))
(hash 'slug (vector-ref row 0)
'title (vector-ref row 1)
'rank (vector-ref row 2)
'snippet (vector-ref row 3)
'type "cmap"))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Read one current concept map including its editor document.
; pre : slug is a string.
; post : No database state is changed.
; result : Concept-map hash, or #f when no current map has that slug.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (read-concept-map config slug)
(if (not (valid-slug? slug))
#f
(call-with-wiki-database
config
(λ (db)
(define row
(query-maybe-row
db
(string-append
"SELECT " concept-map-columns
" FROM concept_maps WHERE slug = $1 AND archived = FALSE")
slug))
(if row (row->concept-map row) #f)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; 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.
; result : The newly stored concept-map hash.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (create-concept-map! config slug title document author)
(define clean-slug (string-trim slug))
(define clean-title (string-trim title))
(validate-concept-map-input 'create-concept-map! clean-slug clean-title document)
(define document-text (document->text document))
(define now (current-seconds))
(call-with-wiki-database
config
(λ (db)
(query-exec
db
#<<SQL
INSERT INTO concept_maps
(slug, title, document, current_version, created_at, updated_at, created_by, updated_by)
VALUES ($1, $2, $3::text::jsonb, 1, $4, $4, $5, $5)
SQL
clean-slug clean-title document-text now author)))
(read-concept-map config clean-slug))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Rename a concept map without storing unsaved editor content.
; pre : slug identifies a current map and base-version is current.
; post : Title, version and audit fields are updated atomically.
; result : The renamed concept-map hash with its unchanged document.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (rename-concept-map! config slug title author base-version)
(define clean-title (string-trim title))
(when (string=? clean-title "")
(error 'rename-concept-map! "title is required"))
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define row
(query-maybe-row
db
"SELECT id, current_version FROM concept_maps WHERE slug = $1 AND archived = FALSE FOR UPDATE"
slug))
(unless row
(error 'rename-concept-map! "unknown concept map: ~a" slug))
(define current-version (vector-ref row 1))
(define supplied-version
(if (number? base-version)
base-version
(string->number (format "~a" base-version))))
(unless (and supplied-version (= supplied-version current-version))
(error 'rename-concept-map! "version-conflict"))
(query-exec
db
#<<SQL
UPDATE concept_maps
SET title = $1,
current_version = $2,
updated_at = $3,
updated_by = $4
WHERE id = $5
SQL
clean-title
(+ current-version 1)
(current-seconds)
author
(vector-ref row 0))))))
(read-concept-map config slug))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Store a new version of an existing concept map.
; pre : base-version equals the current database version.
; post : Title, document, version and audit fields are updated atomically.
; result : The updated concept-map hash.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (update-concept-map! config slug title document author base-version)
(define clean-title (string-trim title))
(validate-concept-map-input 'update-concept-map! slug clean-title document)
(define document-text (document->text document))
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define row
(query-maybe-row
db
"SELECT id, current_version FROM concept_maps WHERE slug = $1 AND archived = FALSE FOR UPDATE"
slug))
(unless row
(error 'update-concept-map! "unknown concept map: ~a" slug))
(define current-version (vector-ref row 1))
(define supplied-version
(if (number? base-version)
base-version
(string->number (format "~a" base-version))))
(unless (and supplied-version (= supplied-version current-version))
(error 'update-concept-map! "version-conflict"))
(query-exec
db
#<<SQL
UPDATE concept_maps
SET title = $1,
document = $2::text::jsonb,
current_version = $3,
updated_at = $4,
updated_by = $5
WHERE id = $6
SQL
clean-title
document-text
(+ current-version 1)
(current-seconds)
author
(vector-ref row 0))))))
(read-concept-map config slug))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Soft-delete one concept map.
; pre : slug identifies a current map.
; post : The map is excluded from normal list/read operations.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (archive-concept-map! config slug author)
(call-with-wiki-database
config
(λ (db)
(define changed
(query-exec
db
#<<SQL
UPDATE concept_maps
SET archived = TRUE, archived_at = $1, archived_by = $2,
updated_at = $1, updated_by = $2
WHERE slug = $3 AND archived = FALSE
SQL
(current-seconds) author slug))
(void changed)))
(void))
+1
View File
@@ -131,4 +131,5 @@
(and (query-value db "SELECT to_regclass('public.users') IS NOT NULL")
(query-value db "SELECT to_regclass('public.pages') IS NOT NULL")
(query-value db "SELECT to_regclass('public.page_versions') IS NOT NULL")
(query-value db "SELECT to_regclass('public.concept_maps') IS NOT NULL")
(query-value db "SELECT to_regclass('public.sessions') IS NOT NULL")))))))
+49 -1
View File
@@ -20,7 +20,7 @@
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define current-schema-version 7)
(define current-schema-version 9)
(define schema-1-statements
(list
@@ -281,6 +281,48 @@ SQL
(query-exec db "CREATE INDEX IF NOT EXISTS pages_namespace_idx ON pages(lower(namespace), lower(title), title)")
(record-schema-version! db 7))
(define (migrate-7->8! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS page_aliases (
id BIGSERIAL PRIMARY KEY,
namespace TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL,
title TEXT NOT NULL,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
created_by TEXT NOT NULL,
UNIQUE(namespace, slug)
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS page_aliases_page_idx ON page_aliases(page_id, created_at DESC)")
(record-schema-version! db 8))
(define (migrate-8->9! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS concept_maps (
id BIGSERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
document JSONB NOT NULL,
current_version BIGINT NOT NULL DEFAULT 1,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at BIGINT,
archived_by TEXT
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS concept_maps_title_idx ON concept_maps(lower(title))")
(record-schema-version! db 9))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
; pre : db is a writable PostgreSQL connection and config identifies the
@@ -313,6 +355,12 @@ SQL
(define after-attachment-references (database-schema-version db))
(when (= after-attachment-references 6)
(migrate-6->7! db))
(define after-namespaces (database-schema-version db))
(when (= after-namespaces 7)
(migrate-7->8! db))
(define after-page-aliases (database-schema-version db))
(when (= after-page-aliases 8)
(migrate-8->9! db))
(define resulting-version (database-schema-version db))
(when (> resulting-version current-schema-version)
(error 'migrate-database!
+438 -32
View File
@@ -25,6 +25,7 @@
read-page
create-page!
update-page!
rename-page!
archive-page!
page-history
read-version
@@ -36,6 +37,10 @@
delete-bookmark!
list-orphaned-uploads
delete-orphaned-upload!
list-page-aliases
list-page-alias-details
cleanup-page-alias!
delete-page-alias!
save-upload!
uploaded-file)
@@ -168,6 +173,25 @@
(define page-columns
"slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version, namespace")
(define page-columns/prefixed
"p.slug, p.title, p.markdown, p.created_at, p.updated_at, p.created_by, p.updated_by, p.tags, p.current_version, p.namespace")
(define (page-id/db db namespace slug)
(define current-id
(query-maybe-value db
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
namespace slug))
(if current-id
current-id
(query-maybe-value db
#<<SQL
SELECT p.id
FROM page_aliases a
JOIN pages p ON p.id = a.page_id
WHERE a.namespace = $1 AND a.slug = $2 AND p.archived = FALSE
SQL
namespace slug)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List current wiki page metadata.
; pre : The PostgreSQL schema is initialized.
@@ -202,8 +226,16 @@
(string-append "SELECT " page-columns
" FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE")
namespace slug))
(if row (row->page row) #f))))))
(define resolved-row
(if row
row
(query-maybe-row db
(string-append
"SELECT " page-columns/prefixed
" FROM page_aliases a JOIN pages p ON p.id = a.page_id"
" WHERE a.namespace = $1 AND a.slug = $2 AND p.archived = FALSE")
namespace slug)))
(if resolved-row (row->page resolved-row) #f))))))
(define (replace-todos! db page-id markdown)
(query-exec db "DELETE FROM todo_items WHERE page_id = $1" page-id)
@@ -286,14 +318,10 @@ SQL
(error 'update-page! "version-conflict"))
(define page-tags
(if tags tags (text->tags (vector-ref row 2))))
(define target-namespace
(if (eq? new-namespace #f)
(vector-ref row 3)
(string-trim new-namespace)))
(unless (or (string=? target-namespace "")
(and (valid-slug? target-namespace)
(<= (string-length target-namespace) 80)))
(error 'update-page! "invalid namespace: ~a" target-namespace))
(define target-namespace (vector-ref row 3))
(when (and (not (eq? new-namespace #f))
(not (string=? (string-trim new-namespace) target-namespace)))
(error 'update-page! "use rename-page! to change a page namespace"))
(define next-version (+ current-version 1))
(define now (current-seconds))
(query-exec db
@@ -312,7 +340,85 @@ SQL
(replace-todos! db page-id markdown)
(replace-current-attachment-references! db page-id markdown now)
(record-version-attachment-references! db page-id page-version-id markdown now)))))
(read-page config (page-reference (if (eq? new-namespace #f) namespace (string-trim new-namespace)) slug)))
(read-page config (page-reference namespace slug)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Rename or move a page while keeping its old address as an alias.
; pre : reference identifies a current page; target namespace/slug are valid and unused.
; post : The same page_id has the new address/title, the old address remains an alias,
; and a new immutable page version records the rename.
; result : The renamed page metadata with Markdown.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (rename-page! config reference title target-namespace target-slug author [summary "Renamed page"])
(define-values (namespace slug) (split-page-reference reference))
(define clean-namespace (string-trim target-namespace))
(define clean-slug (string-trim target-slug))
(unless (valid-page-reference? (page-reference clean-namespace clean-slug))
(error 'rename-page! "invalid page address: ~a" (page-reference clean-namespace clean-slug)))
(when (string=? (string-trim title) "")
(error 'rename-page! "title is required"))
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define row
(query-maybe-row db
"SELECT id, title, markdown, tags, current_version FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE FOR UPDATE"
namespace slug))
(unless row
(error 'rename-page! "unknown page: ~a" reference))
(define page-id (vector-ref row 0))
(define old-title (vector-ref row 1))
(define markdown (vector-ref row 2))
(define tags (text->tags (vector-ref row 3)))
(define current-version (vector-ref row 4))
(define address-changed?
(or (not (string=? namespace clean-namespace))
(not (string=? slug clean-slug))))
(when address-changed?
(define target-page-id
(query-maybe-value db
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
clean-namespace clean-slug))
(when (and target-page-id (not (= target-page-id page-id)))
(error 'rename-page! "page address is already in use: ~a"
(page-reference clean-namespace clean-slug)))
(define target-alias-page-id
(query-maybe-value db
"SELECT page_id FROM page_aliases WHERE namespace = $1 AND slug = $2"
clean-namespace clean-slug))
(when (and target-alias-page-id (not (= target-alias-page-id page-id)))
(error 'rename-page! "page address is already an alias: ~a"
(page-reference clean-namespace clean-slug)))
(when target-alias-page-id
(query-exec db
"DELETE FROM page_aliases WHERE namespace = $1 AND slug = $2 AND page_id = $3"
clean-namespace clean-slug page-id))
(query-exec db
#<<SQL
INSERT INTO page_aliases(namespace, slug, title, page_id, created_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (namespace, slug) DO NOTHING
SQL
namespace slug old-title page-id (current-seconds) author))
(define next-version (+ current-version 1))
(define now (current-seconds))
(query-exec db
#<<SQL
UPDATE pages
SET namespace = $1, slug = $2, title = $3, current_version = $4,
updated_at = $5, updated_by = $6,
search_document = setweight(to_tsvector('simple', coalesce($3, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(markdown, '')), 'B')
WHERE id = $7
SQL
clean-namespace clean-slug title next-version now author page-id)
(define page-version-id
(insert-version! db page-id next-version title markdown author "rename" summary now tags))
(record-version-attachment-references! db page-id page-version-id markdown now)))))
(read-page config (page-reference clean-namespace clean-slug)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Archive an existing wiki page.
@@ -346,9 +452,7 @@ SQL
(call-with-wiki-database
config
(λ (db)
(query-maybe-value db
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
namespace slug))))
(page-id/db db namespace slug))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Read the version history for a wiki page.
@@ -361,8 +465,7 @@ SQL
(call-with-wiki-database
config
(λ (db)
(define id
(query-maybe-value db "SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE" namespace slug))
(define id (page-id/db db namespace slug))
(unless id
(error 'page-history "unknown page: ~a" slug))
(for/list ((row (in-list
@@ -396,15 +499,16 @@ SQL
(call-with-wiki-database
config
(λ (db)
(define id (page-id/db db namespace slug))
(define row
(query-maybe-row db
#<<SQL
SELECT v.version, v.title, v.markdown, v.author, v.action, v.summary, v.tags, v.created_at
FROM page_versions v
JOIN pages p ON p.id = v.page_id
WHERE p.namespace = $1 AND p.slug = $2 AND p.archived = FALSE AND v.version = $3
(and id
(query-maybe-row db
#<<SQL
SELECT version, title, markdown, author, action, summary, tags, created_at
FROM page_versions
WHERE page_id = $1 AND version = $2
SQL
namespace slug version-number))
id version-number)))
(and row
(hash 'version (vector-ref row 0)
'title (vector-ref row 1)
@@ -448,7 +552,8 @@ SQL
'namespace (vector-ref row 4)
'title (vector-ref row 1)
'rank (vector-ref row 2)
'snippet (vector-ref row 3)))))))
'snippet (vector-ref row 3)
'type "page"))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List unresolved todo(...) markers from all current wiki pages.
@@ -679,6 +784,307 @@ SQL
(error 'delete-orphaned-upload! "unknown attachment: ~a" attachment-id))))))
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Page alias cleanup
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (title->wiki-word title)
(define words '())
(define out (open-output-string))
(define (finish-word!)
(define word (get-output-string out))
(when (> (string-length word) 0)
(set! words (append words (list word))))
(set! out (open-output-string)))
(for ((char (in-string title)))
(if (char-alphabetic? char)
(write-char char out)
(finish-word!)))
(finish-word!)
(apply string-append
(for/list ((word (in-list words)))
(string-titlecase word))))
(define (classic-wiki-word? text)
(regexp-match? #px"^(?:[A-Z][a-z]+){2,}$" text))
(define (alias-wiki-reference namespace title)
(define wiki-word (title->wiki-word title))
(if (classic-wiki-word? wiki-word)
(if (string=? namespace "")
wiki-word
(string-append namespace ":" wiki-word))
#f))
(define (replace-wiki-token line old-token new-text)
(define out (open-output-string))
(define length (string-length line))
(let loop ((index 0))
(when (< index length)
(define char (string-ref line index))
(if (or (char-alphabetic? char) (char=? char #\:))
(let find-end ((end index))
(if (and (< end length)
(let ((candidate (string-ref line end)))
(or (char-alphabetic? candidate) (char=? candidate #\:))))
(find-end (+ end 1))
(let ((token (substring line index end)))
(display (if (string=? token old-token) new-text token) out)
(loop end))))
(begin
(write-char char out)
(loop (+ index 1))))))
(get-output-string out))
(define (replace-alias-reference-in-line line old-reference new-reference old-wiki new-wiki)
(define result line)
(set! result
(string-replace result
(string-append "(" old-reference ")")
(string-append "(" new-reference ")")))
(set! result
(string-replace result
(string-append "(" old-reference " ")
(string-append "(" new-reference " ")))
(set! result
(string-replace result
(string-append "/uploads/" old-reference "/")
(string-append "/uploads/" new-reference "/")))
(if old-wiki
(replace-wiki-token result old-wiki new-wiki)
result))
(define (replace-alias-reference markdown old-namespace old-slug old-title
new-namespace new-slug new-title)
(define old-reference (page-reference old-namespace old-slug))
(define new-reference (page-reference new-namespace new-slug))
(define old-wiki (alias-wiki-reference old-namespace old-title))
(define target-wiki (alias-wiki-reference new-namespace new-title))
(define new-wiki
(if target-wiki
target-wiki
(format "[~a](~a)" new-title new-reference)))
(define in-fence? #f)
(define result
(for/list ((line (in-list (string-split markdown "\n" #:trim? #f))))
(define fence? (regexp-match? #px"^[ \t]*(```|~~~)" line))
(cond
(fence?
(set! in-fence? (not in-fence?))
line)
((or in-fence?
(string-prefix? line " ")
(string-prefix? line "\t")
(string-contains? line "`"))
line)
(else
(replace-alias-reference-in-line line old-reference new-reference old-wiki new-wiki)))))
(string-join result "\n"))
(define (page-alias-row db alias-id)
(query-maybe-row db
#<<SQL
SELECT a.id, a.namespace, a.slug, a.title, a.page_id,
p.namespace, p.slug, p.title
FROM page_aliases a
JOIN pages p ON p.id = a.page_id
WHERE a.id = $1 AND p.archived = FALSE
SQL
alias-id))
(define (alias-current-reference-pages db alias-row)
(define old-namespace (vector-ref alias-row 1))
(define old-slug (vector-ref alias-row 2))
(define old-title (vector-ref alias-row 3))
(define new-namespace (vector-ref alias-row 5))
(define new-slug (vector-ref alias-row 6))
(define new-title (vector-ref alias-row 7))
(filter
(λ (item) (hash-ref item 'changed #f))
(for/list ((row (in-list
(query-rows db
"SELECT id, namespace, slug, title, markdown, current_version, tags FROM pages WHERE archived = FALSE ORDER BY lower(namespace), lower(title), title"))))
(define markdown (vector-ref row 4))
(define replaced
(replace-alias-reference markdown
old-namespace old-slug old-title
new-namespace new-slug new-title))
(hash 'id (vector-ref row 0)
'namespace (vector-ref row 1)
'pageSlug (vector-ref row 2)
'slug (page-reference (vector-ref row 1) (vector-ref row 2))
'title (vector-ref row 3)
'markdown markdown
'replacement replaced
'currentVersion (vector-ref row 5)
'tags (text->tags (vector-ref row 6))
'changed (not (string=? markdown replaced))))))
(define (alias-historical-reference-pages db alias-row)
(define old-namespace (vector-ref alias-row 1))
(define old-slug (vector-ref alias-row 2))
(define old-title (vector-ref alias-row 3))
(define new-namespace (vector-ref alias-row 5))
(define new-slug (vector-ref alias-row 6))
(define new-title (vector-ref alias-row 7))
(filter
(λ (item) (hash-ref item 'changed #f))
(for/list ((row (in-list
(query-rows db
#<<SQL
SELECT p.namespace, p.slug, p.title, pv.version, pv.markdown, pv.created_at
FROM page_versions pv
JOIN pages p ON p.id = pv.page_id
ORDER BY pv.created_at DESC, pv.id DESC
SQL
))))
(define markdown (vector-ref row 4))
(define replaced
(replace-alias-reference markdown
old-namespace old-slug old-title
new-namespace new-slug new-title))
(hash 'slug (page-reference (vector-ref row 0) (vector-ref row 1))
'title (vector-ref row 2)
'version (vector-ref row 3)
'createdAt (vector-ref row 5)
'changed (not (string=? markdown replaced))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Replace current references to one retained alias with its canonical page address.
; pre : alias-id identifies a retained alias and author is the administrator performing cleanup.
; post : Every changed current page receives a normal immutable version; history itself is untouched.
; result : A hash containing the number of pages changed and historical references left untouched.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (cleanup-page-alias! config alias-id author)
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define alias-row (page-alias-row db alias-id))
(unless alias-row
(error 'cleanup-page-alias! "unknown page alias: ~a" alias-id))
(define old-reference
(page-reference (vector-ref alias-row 1) (vector-ref alias-row 2)))
(define new-reference
(page-reference (vector-ref alias-row 5) (vector-ref alias-row 6)))
(define pages (alias-current-reference-pages db alias-row))
(define now (current-seconds))
(for ((page (in-list pages)))
(define page-id (hash-ref page 'id))
(define next-version (+ (hash-ref page 'currentVersion) 1))
(define markdown (hash-ref page 'replacement))
(define summary (format "Updated page alias ~a -> ~a" old-reference new-reference))
(query-exec db
#<<SQL
UPDATE pages
SET markdown = $1, current_version = $2, updated_at = $3, updated_by = $4,
search_document = setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce($1, '')), 'B')
WHERE id = $5
SQL
markdown next-version now author page-id)
(define page-version-id
(insert-version! db page-id next-version
(hash-ref page 'title) markdown author "alias-cleanup" summary now
(hash-ref page 'tags)))
(replace-todos! db page-id markdown)
(replace-current-attachment-references! db page-id markdown now)
(record-version-attachment-references! db page-id page-version-id markdown now))
(hash 'changedPages (length pages)
'historicalReferences (length (alias-historical-reference-pages db alias-row))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Remove one retained page alias after current content no longer refers to it.
; pre : alias-id identifies an alias and no current page still contains a recognized old reference.
; post : The alias row is deleted; historical page versions are never modified.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (delete-page-alias! config alias-id)
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define alias-row (page-alias-row db alias-id))
(unless alias-row
(error 'delete-page-alias! "unknown page alias: ~a" alias-id))
(define current-pages (alias-current-reference-pages db alias-row))
(when (> (length current-pages) 0)
(error 'delete-page-alias! "page alias still has ~a current reference(s)" (length current-pages)))
(query-exec db "DELETE FROM page_aliases WHERE id = $1" alias-id)))))
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List retained page aliases and current pages that still contain the old reference.
; pre : PostgreSQL schema 8 or newer is initialized.
; post : Alias and page rows have only been read.
; result : Newest-first alias hashes with canonical targets and literal reference pages.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-page-aliases config)
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows db
#<<SQL
SELECT a.id, a.namespace, a.slug, a.title, a.created_at, a.created_by,
p.namespace, p.slug, p.title
FROM page_aliases a
JOIN pages p ON p.id = a.page_id
WHERE p.archived = FALSE
ORDER BY a.created_at DESC, a.id DESC
SQL
))))
(hash 'id (vector-ref row 0)
'namespace (vector-ref row 1)
'pageSlug (vector-ref row 2)
'title (vector-ref row 3)
'slug (page-reference (vector-ref row 1) (vector-ref row 2))
'createdAt (vector-ref row 4)
'createdBy (vector-ref row 5)
'targetNamespace (vector-ref row 6)
'targetPageSlug (vector-ref row 7)
'targetSlug (page-reference (vector-ref row 6) (vector-ref row 7))
'targetTitle (vector-ref row 8))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List page aliases with current pages that still contain the old address.
; pre : PostgreSQL schema 8 or newer is initialized.
; post : Alias and current page rows have only been read.
; result : Alias hashes augmented with a references list for administration.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-page-alias-details config)
(define aliases (list-page-aliases config))
(call-with-wiki-database
config
(λ (db)
(for/list ((alias (in-list aliases)))
(define alias-row (page-alias-row db (hash-ref alias 'id)))
(define current-references (alias-current-reference-pages db alias-row))
(define historical-references (alias-historical-reference-pages db alias-row))
(define with-references
(hash-set
alias
'references
(for/list ((page (in-list current-references)))
(hash 'slug (hash-ref page 'slug)
'title (hash-ref page 'title)))))
(define with-history
(hash-set
with-references
'historicalReferences
(for/list ((page (in-list (take historical-references (min 10 (length historical-references))))))
(hash 'slug (hash-ref page 'slug)
'title (hash-ref page 'title)
'version (hash-ref page 'version)
'createdAt (hash-ref page 'createdAt)))))
(hash-set with-history 'historicalReferenceCount (length historical-references))))))
(define (safe-file-name name)
(define clean
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
@@ -743,17 +1149,17 @@ SQL
(call-with-wiki-database
config
(λ (db)
(define id (page-id/db db namespace slug))
(define row
(query-maybe-row db
#<<SQL
SELECT a.original_name, a.stored_name, a.mime_type, a.content, a.size
FROM attachments a
JOIN pages p ON p.id = a.page_id
WHERE p.slug = $1 AND p.archived = FALSE AND a.stored_name = $2
ORDER BY CASE WHEN p.namespace = $3 THEN 0 ELSE 1 END, a.id
(and id
(query-maybe-row db
#<<SQL
SELECT original_name, stored_name, mime_type, content, size
FROM attachments
WHERE page_id = $1 AND stored_name = $2
LIMIT 1
SQL
slug stored-name namespace))
id stored-name)))
(if row
(hash 'originalName (vector-ref row 0)
'storedName (vector-ref row 1)
+2 -4
View File
@@ -17,10 +17,8 @@
(define vendor-files
(list
(cons "font-awesome/css/font-awesome.min.css"
"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css")
(cons "font-awesome/fonts/fontawesome-webfont.woff2"
"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/fonts/fontawesome-webfont.woff2")
(cons "lucide.min.js"
"https://cdn.jsdelivr.net/npm/lucide@1.31.0/dist/umd/lucide.min.js")
(cons "easymde.min.js"
"https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js")
(cons "easymde.min.css"
+18
View File
@@ -0,0 +1,18 @@
#lang racket/base
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Single source for the racket-wiki software version.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(require racket/runtime-path
setup/getinfo
)
(provide racket-wiki-version)
(define-runtime-path info-path "..")
(define racket-wiki-version
(let ((info (get-info/full info-path)))
(info 'version)))
+2 -2
View File
@@ -52,7 +52,7 @@ normal application requests are redirected to @tt{/setup}.
The setup page itself requires no JavaScript. On a fresh installation it asks for
PostgreSQL server, port, database, user, password and SSL mode. The database must
already exist. After testing the connection, setup creates the racket-wiki schema,
asks for the first administrator, and downloads the pinned EasyMDE, Font Awesome,
asks for the first administrator, and downloads the pinned EasyMDE, Lucide,
DOMPurify, highlight.js, and diff2html browser files. PostgreSQL connection
settings are stored in @tt{database.rktd} below the configured data directory.
After setup the browser is redirected to @tt{/login}; setup does not create an
@@ -94,7 +94,7 @@ while a page is edited.
EasyMDE and the rendered wiki page use matching body and heading sizes. The
source editor keeps Markdown syntax visible, but heading text uses the same
scale as the rendered page. Toolbar icons are provided by a locally installed
Font Awesome copy.
Lucide copy.
@section{Page navigation and metadata}
+217 -7
View File
@@ -15,10 +15,12 @@
web-server/http
web-server/servlet-env
"private/auth.rkt"
"private/cmap-storage.rkt"
"private/config.rkt"
"private/http-util.rkt"
"private/setup.rkt"
"private/storage.rkt"
"private/version.rkt"
"translate.rkt")
(provide start-wiki-server)
@@ -181,18 +183,140 @@ CSS
(require-role
config req 'reader
(λ (_session)
(json-response (hash 'pages (list-pages config))))))
(json-response (hash 'pages (list-pages config)
'aliases (list-page-aliases config))))))
(define (concept-map-list-handler config req)
(require-role
config req 'reader
(λ (_session)
(json-response (hash 'conceptMaps (list-concept-maps config))))))
(define (request-concept-map-document body)
(define document (hash-ref body 'document #f))
(unless (hash? document)
(raise-argument-error 'request-concept-map-document "hash?" document))
document)
(define (concept-map-create-handler config req)
(require-write-role
config req 'editor
(λ (session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(define body (request-json req))
(define title (string-trim (hash-ref body 'title "")))
(define requested-slug (string-trim (hash-ref body 'slug "")))
(define slug
(if (string=? requested-slug "")
(title->slug title)
requested-slug))
(define document (request-concept-map-document body))
(cond
((string=? title "") (json-error 400 "Title is required"))
((string=? slug "") (json-error 400 "The title cannot be converted to a concept map address"))
((read-concept-map config slug)
(json-error 409 "A concept map with this address already exists"))
(else
(json-response
(create-concept-map! config
slug
title
document
(wiki-user-username (wiki-session-user session)))
#:code 201)))))))
(define (concept-map-get-handler config req slug)
(require-role
config req 'reader
(λ (_session)
(define concept-map (read-concept-map config slug))
(if concept-map
(json-response concept-map)
(json-error 404 "Concept map not found")))))
(define (concept-map-update-handler config req slug)
(require-write-role
config req 'editor
(λ (session)
(with-handlers ((exn:fail?
(λ (e)
(if (string=? (exn-message e) "update-concept-map!: version-conflict")
(json-error 409 "Concept map changed since it was opened")
(json-error 400 (exn-message e))))))
(define body (request-json req))
(define title (string-trim (hash-ref body 'title "")))
(define document (request-concept-map-document body))
(define base-version (hash-ref body 'baseVersion ""))
(json-response
(update-concept-map! config
slug
title
document
(wiki-user-username (wiki-session-user session))
base-version))))))
(define (concept-map-rename-handler config req slug)
(require-write-role
config req 'editor
(λ (session)
(with-handlers ((exn:fail?
(λ (e)
(if (string=? (exn-message e) "rename-concept-map!: version-conflict")
(json-error 409 "Concept map changed since it was opened")
(json-error 400 (exn-message e))))))
(define body (request-json req))
(define title (string-trim (hash-ref body 'title "")))
(define base-version (hash-ref body 'baseVersion ""))
(json-response
(rename-concept-map! config
slug
title
(wiki-user-username (wiki-session-user session))
base-version))))))
(define (concept-map-delete-handler config req slug)
(require-write-role
config req 'editor
(λ (session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(archive-concept-map! config
slug
(wiki-user-username (wiki-session-user session)))
(json-response (hash 'ok #t))))))
(define (request-query-value req key [default ""])
(define found (assoc key (url-query (request-uri req))))
(if found (cdr found) default))
(define (search-result-before? first-result second-result)
(define first-rank (hash-ref first-result 'rank 0))
(define second-rank (hash-ref second-result 'rank 0))
(if (= first-rank second-rank)
(string-ci<? (hash-ref first-result 'title "")
(hash-ref second-result 'title ""))
(> first-rank second-rank)))
(define (recent-result-before? first-result second-result)
(define first-updated-at (hash-ref first-result 'updatedAt 0))
(define second-updated-at (hash-ref second-result 'updatedAt 0))
(if (= first-updated-at second-updated-at)
(string-ci<? (hash-ref first-result 'title "")
(hash-ref second-result 'title ""))
(> first-updated-at second-updated-at)))
(define (search-handler config req)
(require-role
config req 'reader
(λ (_session)
(define query-text (request-query-value req 'q))
(json-response (hash 'results (search-pages config query-text))))))
(define page-results (search-pages config query-text))
(define concept-map-results (search-concept-maps config query-text))
(define sorted-results
(sort (append page-results concept-map-results)
search-result-before?))
(define result-count (min 50 (length sorted-results)))
(json-response
(hash 'results (take sorted-results result-count))))))
(define (todo-list-handler config req)
(require-role
@@ -205,7 +329,17 @@ CSS
(require-role
config req 'reader
(λ (_session)
(json-response (hash 'pages (list-recent-pages config))))))
(define page-results
(map (λ (page) (hash-set page 'type "page"))
(list-recent-pages config)))
(define concept-map-results
(map (λ (concept-map) (hash-set concept-map 'type "cmap"))
(list-recent-concept-maps config)))
(define sorted-results
(sort (append page-results concept-map-results)
recent-result-before?))
(define result-count (min 50 (length sorted-results)))
(json-response (hash 'items (take sorted-results result-count))))))
(define (bookmark-list-handler config req)
(require-role
@@ -313,7 +447,6 @@ CSS
(json-error 400 (exn-message e))))))
(define body (request-json req))
(define title (hash-ref body 'title ""))
(define namespace (hash-ref body 'namespace #f))
(define markdown (hash-ref body 'markdown ""))
(define tags (request-tags body))
(define base-version (hash-ref body 'baseVersion ""))
@@ -327,7 +460,28 @@ CSS
base-version
summary
tags
namespace))))))
#f))))))
(define (page-rename-handler config req reference)
(require-write-role
config req 'editor
(λ (session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(when (string=? reference (translation-page-slug))
(error 'page-rename-handler "the translations system page cannot be renamed"))
(define body (request-json req))
(define title (string-trim (hash-ref body 'title "")))
(define namespace (string-trim (hash-ref body 'namespace "")))
(define slug (string-trim (hash-ref body 'slug "")))
(define summary (hash-ref body 'summary "Renamed page"))
(json-response
(rename-page! config
reference
title
namespace
slug
(wiki-user-username (wiki-session-user session))
summary))))))
(define (page-delete-handler config req slug)
(require-write-role
@@ -420,6 +574,35 @@ CSS
(delete-orphaned-upload! config id)
(json-response (hash 'ok #t))))))
(define (admin-info-handler config req)
(require-role
config req 'admin
(λ (_session)
(json-response (hash 'softwareVersion racket-wiki-version)))))
(define (admin-page-aliases-handler config req)
(require-role
config req 'admin
(λ (_session)
(json-response (hash 'aliases (list-page-alias-details config))))))
(define (admin-cleanup-page-alias-handler config req id)
(require-write-role
config req 'admin
(λ (session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(define author (wiki-user-username (wiki-session-user session)))
(json-response (cleanup-page-alias! config id author))))))
(define (admin-delete-page-alias-handler config req id)
(require-write-role
config req 'admin
(λ (_session)
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
(delete-page-alias! config id)
(json-response (hash 'ok #t))))))
(define (admin-users-handler config req)
(require-role
config req 'admin
@@ -476,13 +659,18 @@ CSS
(string-append "/" (string-join segments "/")))
(define (index-response)
(define cmap-cache-id (number->string (random 1000000000)))
(define index-html
(file->string (build-path static-directory "index.html")))
(define cache-busted-index-html
(string-replace index-html "__CMAP_CACHE_ID__" cmap-cache-id))
(bytes-response
(file->bytes (build-path static-directory "index.html"))
(string->bytes/utf-8 cache-busted-index-html)
#"text/html; charset=utf-8"
#:headers (list (make-header #"Cache-Control" #"no-cache"))))
(define (static-request-path? path)
(or (regexp-match? #px"^/(vendor|css|js)/" path)
(or (regexp-match? #px"^/(vendor|cmap|css|js)/" path)
(string=? path "/favicon.ico")))
(define (application-api-path? path)
@@ -547,12 +735,26 @@ CSS
(λ (req) (search-handler config req))]
[("api" "pages") #:method "get"
(λ (req) (page-list-handler config req))]
[("api" "cmaps") #:method "get"
(λ (req) (concept-map-list-handler config req))]
[("api" "cmaps") #:method "post"
(λ (req) (concept-map-create-handler config req))]
[("api" "cmaps" (string-arg)) #:method "get"
(λ (req slug) (concept-map-get-handler config req slug))]
[("api" "cmaps" (string-arg)) #:method "put"
(λ (req slug) (concept-map-update-handler config req slug))]
[("api" "cmaps" (string-arg) "rename") #:method "post"
(λ (req slug) (concept-map-rename-handler config req slug))]
[("api" "cmaps" (string-arg)) #:method "delete"
(λ (req slug) (concept-map-delete-handler config req slug))]
[("api" "pages") #:method "post"
(λ (req) (page-create-handler config req))]
[("api" "pages" (string-arg)) #:method "get"
(λ (req slug) (page-get-handler config req slug))]
[("api" "pages" (string-arg)) #:method "put"
(λ (req slug) (page-update-handler config req slug))]
[("api" "pages" (string-arg) "rename") #:method "post"
(λ (req slug) (page-rename-handler config req slug))]
[("api" "pages" (string-arg)) #:method "delete"
(λ (req slug) (page-delete-handler config req slug))]
[("api" "pages" (string-arg) "history") #:method "get"
@@ -563,6 +765,14 @@ CSS
(λ (req slug) (upload-handler config req slug))]
[("uploads" (string-arg) (string-arg)) #:method "get"
(λ (req slug stored-name) (upload-get-handler config req slug stored-name))]
[("api" "admin" "info") #:method "get"
(λ (req) (admin-info-handler config req))]
[("api" "admin" "aliases") #:method "get"
(λ (req) (admin-page-aliases-handler config req))]
[("api" "admin" "aliases" (integer-arg) "cleanup") #:method "post"
(λ (req id) (admin-cleanup-page-alias-handler config req id))]
[("api" "admin" "aliases" (integer-arg)) #:method "delete"
(λ (req id) (admin-delete-page-alias-handler config req id))]
[("api" "admin" "uploads" "orphaned") #:method "get"
(λ (req) (admin-orphaned-uploads-handler config req))]
[("api" "admin" "uploads" "orphaned" (integer-arg)) #:method "delete"
+50
View File
@@ -0,0 +1,50 @@
# Racket Wiki CMap component
This directory contains the complete browser-side CMap component.
`cmap.js` is the drawing and hit-testing engine. It is based on the
MIT-licensed ionstage/cmap 0.1.3 source and is now maintained as part of
racket-wiki. Its public additions include `onSelection` on a map and
`onActivation` and `onRendered` callbacks. Activation is recognized inside the
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`dblclick` event that may be suppressed by dragging.
`cmap-racket-wiki.js` contains the wiki-specific editor model, selection state,
content-based initial sizing, resize and relation controls, page concepts and
submap concepts. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
their independent display label, synopsis, background colour, typography and
optional image. Page concepts retain their linked `pageSlug`; double-click is
reserved for opening that wiki page. Concepts may alternatively retain a
`cmapSlug` for another stored CMap or a `parentCmapLink` for navigation back
from a child map. Images are data URLs inside the persisted CMap JSON document.
Font families are selected from a practical list and font sizes are stored in
typographic points. The host can change the linked page or CMap independently
of the visible label and provides background and text-colour pickers.
Linking phrases are compact borderless nodes at the intentional bend between
two connector segments. Their position is stored with the rest of the map.
When an endpoint is deleted, a genuinely branching phrase is retained while it
still has at least one incoming and one outgoing concept. Losing the final
endpoint on either side removes the phrase and all its remaining segments.
Multiple selected items can be moved or deleted together. Grouping is a
structural operation: the editor creates an expanded sub-CMap with a named main
concept and adopts the selection as its contents. Ungrouping dissolves a
selected inline sub-CMap or moves selected child items one level outward.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
map references. The wiki host persists this JSON document through its CMap API.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
for keyboard shortcuts and context-menu state.
`cmap.css` contains all CMap presentation and interaction styles.
The source files deliberately remain separate but form one component and are
loaded directly from `/cmap/`; there is no runtime download or vendor patch.
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
/* Racket Wiki CMap view, concepts and CMapTools-like interactions. */
body.cmap-mode {
overflow: hidden;
}
body.cmap-mode #app {
height: 100vh;
min-height: 0;
}
body.cmap-mode #main {
height: 100vh;
min-height: 0;
overflow: auto;
}
#cmap-view {
max-width: none;
}
.cmap-page-header {
position: sticky;
z-index: 160;
top: 0;
margin: 0 0 10px;
padding: 6px 0;
background: rgb(255 255 255 / 96%);
box-shadow: 0 1px 0 rgb(128 139 150 / 22%);
backdrop-filter: blur(4px);
}
.cmap-storage-controls {
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.cmap-toolbar-separator {
flex: 0 0 1px;
width: 1px;
height: 24px;
background: #d5d9de;
}
.cmap-toolbar-spacer {
flex: 1 1 auto;
min-width: 16px;
}
.cmap-storage-controls input[type="search"] {
box-sizing: border-box;
min-width: 220px;
padding: 7px 9px;
border: 1px solid #aab2bd;
border-radius: 4px;
font: inherit;
}
.wiki-combobox {
position: relative;
min-width: 220px;
font-weight: 400;
}
.wiki-combobox input[type="search"] {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding-right: 38px;
}
.wiki-combobox-toggle {
position: absolute;
top: 1px;
right: 1px;
width: 34px;
height: calc(100% - 2px);
padding: 0;
border: 0;
border-left: 1px solid #c4cad1;
border-radius: 0 4px 4px 0;
background: #f3f5f7;
color: #485563;
font-size: 18px;
cursor: pointer;
}
.wiki-combobox:focus-within input,
.wiki-combobox:focus-within .wiki-combobox-toggle {
border-color: #527da8;
}
.wiki-combobox-list {
position: absolute;
top: calc(100% + 3px);
right: 0;
left: 0;
z-index: 180;
max-height: 280px;
overflow: auto;
padding: 4px;
border: 1px solid #8794a2;
border-radius: 5px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 20%);
}
.wiki-combobox-option {
display: grid;
gap: 2px;
padding: 7px 9px;
border-radius: 4px;
cursor: default;
}
.wiki-combobox-option:hover,
.wiki-combobox-option.active {
background: #e8f1fa;
}
.wiki-combobox-option[aria-selected="true"] {
box-shadow: inset 3px 0 #527da8;
}
.wiki-combobox-option-label {
overflow: hidden;
color: #1e2a35;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiki-combobox-option-description {
overflow: hidden;
color: #697784;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
#cmap-save-status {
max-width: 240px;
opacity: 0;
transition: opacity .2s ease;
}
#cmap-save-status.cmap-status-visible {
opacity: 1;
}
.cmap-help-menu {
position: relative;
}
.cmap-help-menu summary {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid #aab2bd;
border-radius: 50%;
background: #fff;
color: #536273;
cursor: pointer;
font-weight: 700;
list-style: none;
}
.cmap-help-menu summary::-webkit-details-marker {
display: none;
}
.cmap-help-popover {
position: absolute;
z-index: 220;
top: calc(100% + 6px);
right: 0;
width: min(520px, calc(100vw - 72px));
padding: 10px 12px;
border: 1px solid #aab2bd;
border-radius: 6px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
line-height: 1.4;
}
.cmap-help-popover > p {
margin: 0 0 10px;
}
.cmap-shortcut-list {
display: grid;
gap: 5px;
margin: 0;
}
.cmap-shortcut-list > div {
display: grid;
grid-template-columns: minmax(155px, auto) 1fr;
gap: 12px;
align-items: baseline;
}
.cmap-shortcut-list dt,
.cmap-shortcut-list dd {
margin: 0;
}
.cmap-shortcut-list kbd {
display: inline-block;
min-width: 22px;
padding: 1px 4px;
border: 1px solid #bbc1c8;
border-bottom-width: 2px;
border-radius: 3px;
background: #f7f8f9;
color: #28313a;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: center;
}
.cmap-workspace {
position: relative;
width: 100%;
}
.cmap-canvas {
--cmap-a4-width: 1123px;
--cmap-a4-height: 794px;
position: relative;
width: max-content;
min-width: max(760px, 100%);
min-height: 794px;
overflow: visible;
border: 0;
background-color: #fff;
background-image:
linear-gradient(to right, transparent calc(100% - 1px), #d9dde2 calc(100% - 1px)),
linear-gradient(to bottom, transparent calc(100% - 1px), #d9dde2 calc(100% - 1px));
background-size: var(--cmap-a4-width) var(--cmap-a4-height);
box-shadow: inset 1px 1px #d9dde2;
}
.cmap-canvas.cmap-page-guides-hidden {
background-image: none;
box-shadow: none;
}
.cmap-canvas > .rw-cmap-surface {
overflow: visible !important;
min-width: 100%;
min-height: 100%;
transform-origin: 0 0;
}
.cmap-zoom-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
width: auto;
margin: 0;
}
.cmap-zoom-controls button,
.cmap-zoom-controls input {
box-sizing: border-box;
height: 30px;
font: inherit;
}
.cmap-map-navigation {
display: flex;
align-items: center;
gap: 5px;
max-width: 260px;
}
.cmap-map-navigation strong {
overflow: hidden;
max-width: 190px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmap-context-menu {
position: fixed;
z-index: 500;
display: grid;
min-width: 210px;
padding: 5px;
border: 1px solid #8c95a3;
border-radius: 6px;
background: #fff;
box-shadow: 0 8px 28px rgb(0 0 0 / 24%);
}
.cmap-context-menu button {
padding: 7px 10px;
border: 0;
border-radius: 4px;
background: transparent;
text-align: left;
}
.cmap-context-menu button:hover:not(:disabled),
.cmap-context-menu button:focus-visible {
background: #e9eef5;
}
.cmap-context-menu button:disabled {
color: #999;
}
.cmap-context-menu .danger:not(:disabled) {
color: #a32626;
}
.cmap-context-separator {
height: 1px;
margin: 4px 5px;
background: #d8dde3;
}
#cmap-toggle-page-guides[aria-checked="true"]::before {
content: "✓ ";
}
.cmap-zoom-controls button {
min-width: 30px;
padding: 2px 7px;
}
.cmap-zoom-controls label {
display: flex;
align-items: center;
gap: 2px;
}
.cmap-zoom-controls input {
width: 62px;
padding: 2px 4px;
text-align: right;
}
.cmap-prototype-node {
box-sizing: border-box;
cursor: move;
border-radius: 6px;
box-shadow: 0 1px 2px rgb(0 0 0 / 10%);
font-family: Arial, Helvetica, sans-serif;
font-size: 11pt;
line-height: 1.25;
}
.cmap-card {
height: 100%;
box-sizing: border-box;
padding: 9px 11px;
overflow: hidden;
}
.cmap-card-title {
margin-bottom: .3rem;
font-size: 1rem;
font-weight: 700;
}
.cmap-card-synopsis {
color: inherit;
opacity: .78;
font-size: .84rem;
line-height: 1.25;
}
.cmap-card-image {
display: block;
width: auto;
max-width: min(220px, 100%);
max-height: 150px;
margin: 0 auto 8px;
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;
}
.rw-cmap-submap-toggle {
position: absolute;
top: 6px;
right: 7px;
z-index: 45;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid #57834a;
border-radius: 50%;
background: #fff;
color: #35672c;
cursor: pointer;
font: 700 17px/18px Arial, Helvetica, sans-serif;
}
.cmap-card-submap {
padding-right: 38px;
}
.rw-cmap-submap-frame {
position: absolute;
z-index: 0;
box-sizing: border-box;
border: 2px solid var(--rw-cmap-submap-border, #7a9d70);
border-radius: 12px;
background: color-mix(
in srgb,
var(--rw-cmap-submap-background, #edf7e8) 82%,
transparent
);
box-shadow: 0 1px 3px rgb(35 55 30 / 18%);
cursor: move;
pointer-events: auto;
user-select: none;
}
.rw-cmap-submap-frame-selected {
outline: 3px solid #9994ff;
outline-offset: 2px;
box-shadow: 0 0 0 5px rgb(132 126 255 / 28%);
}
.rw-cmap-submap-frame-toggle {
position: absolute;
top: 50%;
right: -11px;
width: 22px;
height: 22px;
padding: 0;
transform: translateY(-50%);
border: 1px solid var(--rw-cmap-submap-border, #7a9d70);
border-radius: 4px;
background: #fff;
color: #35672c;
cursor: pointer;
font: 700 15px/18px Arial, Helvetica, sans-serif;
pointer-events: auto;
}
.rw-cmap-selected {
z-index: 20 !important;
outline: 3px solid #9994ff !important;
outline-offset: 2px;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(132 126 255 / 38%) !important;
}
.rw-cmap-marquee {
position: absolute;
z-index: 10000;
box-sizing: border-box;
border: 1px solid #5368d9;
background: rgb(83 104 217 / 14%);
pointer-events: none;
}
.rw-cmap-handle {
position: absolute;
z-index: 60;
display: grid;
place-items: center;
width: 12px;
height: 12px;
padding: 0;
border: 1px solid #111;
border-radius: 0;
background: #fff;
box-shadow: none;
color: #111;
cursor: crosshair;
font: 10px/1 Arial, Helvetica, sans-serif;
}
.rw-cmap-relation-handle {
top: -21px;
left: 50%;
width: 34px;
height: 12px;
transform: translateX(-50%);
}
.rw-cmap-relation-handle::before,
.rw-cmap-relation-handle::after {
content: "";
position: absolute;
top: 5px;
width: 15px;
border-top: 1px solid #111;
}
.rw-cmap-relation-handle::before {
left: 2px;
transform: rotate(18deg);
transform-origin: right center;
}
.rw-cmap-relation-handle::after {
right: 2px;
transform: rotate(-18deg);
transform-origin: left center;
}
.rw-cmap-resize-handle {
right: -8px;
bottom: -8px;
background:
linear-gradient(135deg,
transparent 0 42%,
#111 43% 51%,
transparent 52% 63%,
#111 64% 72%,
transparent 73%),
#fff;
cursor: nwse-resize;
}
.rw-cmap-edit-handle {
left: -8px;
bottom: -8px;
cursor: pointer;
}
.rw-cmap-edit-handle::before {
content: "✎";
position: relative;
top: -1px;
font-size: 12px;
line-height: 1;
}
.rw-cmap-item-phrase {
border: 0 !important;
border-radius: 0 !important;
background: #fbfbf8 !important;
box-shadow: none !important;
cursor: move;
}
.rw-cmap-phrase-label {
display: grid;
place-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 2px 6px;
font-size: 14px;
line-height: 1.1;
text-align: center;
}
.rw-cmap-phrase-input {
width: calc(100% - 8px);
height: calc(100% - 8px);
box-sizing: border-box;
margin: 4px;
padding: 2px 5px;
border: 1px solid #5966d6;
background: #fff;
font: inherit;
text-align: center;
}
.rw-cmap-draft-layer {
position: absolute;
top: 0;
left: 0;
z-index: 50;
overflow: visible;
pointer-events: none;
}
.rw-cmap-draft-line {
stroke: #4f5ee8;
stroke-width: 2.5;
stroke-dasharray: 6 4;
}
.rw-cmap-connector {
cursor: pointer;
}
.cmap-concept-dialog {
width: min(520px, calc(100vw - 32px));
padding: 0;
border: 1px solid #8c95a3;
border-radius: 8px;
box-shadow: 0 18px 60px rgb(0 0 0 / 24%);
}
.cmap-concept-dialog::backdrop {
background: rgb(22 30 44 / 38%);
}
.cmap-unsaved-dialog {
width: min(480px, calc(100vw - 32px));
padding: 0;
border: 1px solid #8c95a3;
border-radius: 8px;
box-shadow: 0 18px 60px rgb(0 0 0 / 24%);
}
.cmap-unsaved-dialog::backdrop {
background: rgb(22 30 44 / 38%);
}
.cmap-unsaved-dialog form {
display: grid;
gap: 14px;
padding: 22px;
}
.cmap-unsaved-dialog h2,
.cmap-unsaved-dialog p {
margin: 0;
}
.cmap-concept-dialog form {
display: grid;
gap: 14px;
padding: 22px;
}
.cmap-concept-dialog h2 {
margin: 0 0 2px;
}
.cmap-concept-dialog label {
display: grid;
gap: 5px;
font-weight: 600;
}
.cmap-concept-dialog input[type="text"],
.cmap-concept-dialog input[type="search"],
.cmap-concept-dialog input[type="number"],
.cmap-concept-dialog select,
.cmap-concept-dialog textarea {
box-sizing: border-box;
width: 100%;
padding: 7px 9px;
border: 1px solid #aab2bd;
border-radius: 4px;
font: inherit;
font-weight: 400;
}
.cmap-concept-dialog input[type="color"] {
width: 72px;
height: 36px;
padding: 2px;
border: 1px solid #aab2bd;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
.cmap-concept-image-fields,
.cmap-submap-style-fields {
display: grid;
gap: 9px;
margin: 0;
padding: 10px 12px 12px;
border: 1px solid #c4cad2;
border-radius: 4px;
}
.cmap-concept-image-fields legend,
.cmap-submap-style-fields legend {
padding: 0 4px;
font-weight: 600;
}
.cmap-submap-style-fields {
grid-template-columns: 1fr 1fr;
}
.cmap-submap-style-fields legend {
grid-column: 1 / -1;
}
.cmap-concept-image-preview-row {
display: flex;
align-items: flex-end;
gap: 12px;
}
.cmap-concept-image-preview {
display: block;
}
#cmap-concept-image-preview {
max-width: 180px;
max-height: 120px;
border: 1px solid #d1d5db;
background: #fff;
object-fit: contain;
}
.cmap-concept-dialog textarea {
resize: vertical;
}
.cmap-concept-page-row {
display: grid;
gap: 4px;
padding: 9px 11px;
border: 1px solid #c8d8e6;
border-radius: 4px;
background: #eef6fc;
}
.cmap-concept-page-row select {
width: 100%;
}
.cmap-concept-page-row input[type="search"] {
width: 100%;
}
.cmap-font-size-input {
display: grid;
grid-template-columns: 90px auto;
align-items: center;
gap: 7px;
font-weight: 400;
}
.cmap-concept-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
@media (max-width: 1100px) {
.cmap-storage-controls {
flex-wrap: wrap;
}
.cmap-toolbar-spacer {
display: none;
}
.cmap-canvas {
min-width: 0;
height: 620px;
}
}
+2235
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
/* CMap view, concept presentation and CMapTools-like interactions. */
#cmap-view {
max-width: none;
}
.cmap-page-header {
align-items: flex-start;
}
.cmap-toolbar {
display: flex;
gap: .45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.cmap-toolbar button {
font: inherit;
padding: .35rem .65rem;
}
.cmap-help {
margin: .25rem 0 .75rem;
}
.cmap-canvas {
position: relative;
width: min(1380px, 100%);
min-width: 760px;
height: 720px;
overflow: auto;
border: 1px solid var(--wiki-border);
background-color: #fbfbf8;
background-image:
linear-gradient(#ecece7 1px, transparent 1px),
linear-gradient(90deg, #ecece7 1px, transparent 1px);
background-size: 24px 24px;
}
.cmap-prototype-node {
box-sizing: border-box;
cursor: move;
border-radius: 6px;
box-shadow: 0 1px 2px rgb(0 0 0 / 10%);
font-family: Arial, Helvetica, sans-serif;
font-size: 15px;
line-height: 1.25;
}
.cmap-card {
height: 100%;
box-sizing: border-box;
padding: 9px 11px;
overflow: hidden;
}
.cmap-card-title {
margin-bottom: .3rem;
font-size: 1rem;
font-weight: 700;
}
.cmap-card-synopsis {
color: #4b4b4b;
font-size: .84rem;
line-height: 1.25;
}
.cmap-card-page .cmap-card-title::after {
content: " ↗";
color: #4479a1;
font-weight: 400;
}
.cmap-card-submap .cmap-card-title::after {
content: " [+]";
color: #57834a;
font-weight: 400;
}
.rw-cmap-item {
overflow: visible !important;
}
.rw-cmap-selected {
z-index: 20 !important;
outline: 3px solid #9994ff !important;
outline-offset: 2px;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(132 126 255 / 38%) !important;
}
.rw-cmap-handle {
position: absolute;
z-index: 60;
display: grid;
place-items: center;
width: 12px;
height: 12px;
padding: 0;
border: 1px solid #111;
border-radius: 0;
background: #fff;
box-shadow: none;
color: #111;
cursor: crosshair;
font: 10px/1 Arial, Helvetica, sans-serif;
}
.rw-cmap-relation-handle {
top: -21px;
left: 50%;
width: 34px;
height: 12px;
transform: translateX(-50%);
}
.rw-cmap-relation-handle::before,
.rw-cmap-relation-handle::after {
content: "";
position: absolute;
top: 5px;
width: 15px;
border-top: 1px solid #111;
}
.rw-cmap-relation-handle::before {
left: 2px;
transform: rotate(18deg);
transform-origin: right center;
}
.rw-cmap-relation-handle::after {
right: 2px;
transform: rotate(-18deg);
transform-origin: left center;
}
.rw-cmap-resize-handle {
right: -8px;
bottom: -8px;
background:
linear-gradient(135deg,
transparent 0 42%,
#111 43% 51%,
transparent 52% 63%,
#111 64% 72%,
transparent 73%),
#fff;
cursor: nwse-resize;
}
.rw-cmap-item-phrase {
border-radius: 4px !important;
cursor: move;
}
.rw-cmap-phrase-label {
display: grid;
place-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 4px 8px;
font-size: 14px;
line-height: 1.1;
text-align: center;
}
.rw-cmap-phrase-input {
width: calc(100% - 8px);
height: calc(100% - 8px);
box-sizing: border-box;
margin: 4px;
padding: 2px 5px;
border: 1px solid #5966d6;
background: #fff;
font: inherit;
text-align: center;
}
.rw-cmap-draft-layer {
position: absolute;
top: 0;
left: 0;
z-index: 50;
overflow: visible;
pointer-events: none;
}
.rw-cmap-draft-line {
stroke: #4f5ee8;
stroke-width: 2.5;
stroke-dasharray: 6 4;
}
.rw-cmap-connector {
cursor: pointer;
}
@media (max-width: 1100px) {
.cmap-canvas {
min-width: 0;
height: 620px;
}
}
+283 -4
View File
@@ -9,6 +9,17 @@ body { margin: 0; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.hidden { display: none !important; }
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.muted { color: #6b7280; font-size: 0.9rem; }
.error { color: #b42318; margin-top: .75rem; }
.danger { color: #b42318; }
@@ -91,6 +102,14 @@ button { cursor: pointer; }
background: #e5e7eb;
}
.EasyMDEContainer .editor-toolbar button.rw-mde-button svg {
width: 17px;
height: 17px;
vertical-align: middle;
stroke-width: 1.9;
pointer-events: none;
}
.EasyMDEContainer .editor-toolbar button.rw-mde-bold {
font-weight: 750;
}
@@ -207,6 +226,40 @@ body {
text-decoration: underline;
}
.sidebar-icon-nav {
display: flex;
align-items: center;
justify-content: center;
margin: -2px 0 14px;
}
.sidebar-icon-link {
position: relative;
display: grid;
width: 38px;
height: 32px;
place-items: center;
color: var(--wiki-link);
text-decoration: none;
}
.sidebar-icon-link + .sidebar-icon-link {
border-left: 1px solid #c8cbd0;
}
.sidebar-icon-link:hover,
.sidebar-icon-link:focus-visible {
background: #fff;
color: #24356f;
outline: 0;
}
.sidebar-icon-link svg {
width: 19px;
height: 19px;
stroke-width: 1.8;
}
#user-box,
#sidebar .muted {
color: var(--wiki-muted);
@@ -452,10 +505,6 @@ body {
color: #333;
}
.EasyMDEContainer .editor-toolbar button.fa::before {
font-family: FontAwesome;
}
@media (max-width: 900px) {
#app { grid-template-columns: 1fr; }
#sidebar {
@@ -565,10 +614,45 @@ body {
}
.search-result h2 {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin: 0 0 5px;
font-size: 1.08rem;
}
.search-result-type {
display: inline-block;
padding: 2px 7px;
border: 1px solid #b7c0ca;
border-radius: 999px;
background: #f3f5f7;
color: #536273;
font-size: .72rem;
font-weight: 650;
line-height: 1.35;
}
.search-result[data-result-type="cmap"] .search-result-type {
border-color: #86a77c;
background: #edf7e8;
color: #35672c;
}
.special-page-heading {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.special-page-row[data-result-type="cmap"] .search-result-type {
border-color: #86a77c;
background: #edf7e8;
color: #35672c;
}
.search-result h2 a {
color: var(--wiki-link);
text-decoration: none;
@@ -1057,6 +1141,16 @@ body.editor-mode .editor-metadata-row {
stroke: #1e2f6d;
}
.wiki-graph-node-cmap circle {
fill: #57834a;
}
.wiki-graph-node-cmap:hover circle,
.wiki-graph-node-cmap:focus circle {
fill: #315f2b;
stroke: #315f2b;
}
/* 0.2.15 context navigation and sticky-anchor correction ---------------- */
.context-link {
@@ -1218,3 +1312,188 @@ body.editor-mode .editor-metadata-row {
font-size: 1rem;
font-weight: 600;
}
/* Rename and alias administration */
.rename-form { max-width: 48rem; }
.rename-form label { display: block; margin: 0 0 1rem; }
.rename-form input { display: block; width: 100%; margin-top: .3rem; }
.admin-version { margin: 0 0 1rem; }
.alias-row { padding: .75rem 0; border-bottom: 1px solid #d9d9d9; }
.alias-row .alias-address { font-family: monospace; }
.alias-references { margin: .35rem 0 0 1.25rem; }
.alias-summary { margin-top: .35rem; }
.alias-history { color: #666; }
.alias-actions { display: flex; gap: .5rem; margin-top: .6rem; }
.alias-actions button { font: inherit; }
/* 0.2.36 context graph overlay and docking ------------------------------ */
.page-reading-layout {
min-width: 0;
}
.page-reading-layout.context-dock-right {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 42%);
gap: 1rem;
align-items: start;
}
.page-reading-layout.context-dock-top {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-reading-layout.context-dock-top .context-dock {
order: -1;
}
.context-dock {
border: 1px solid var(--wiki-border);
background: #fafafa;
padding: .75rem;
position: sticky;
top: 84px;
max-height: calc(100vh - 100px);
overflow: auto;
}
.context-dock-header,
.context-overlay-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.context-graph-actions {
display: flex;
gap: .7rem;
flex-wrap: wrap;
font-size: .82rem;
}
.context-graph-actions a {
color: var(--wiki-link);
text-decoration: none;
}
.context-graph-actions a:hover {
text-decoration: underline;
}
.context-graph-small {
min-width: 0;
min-height: 320px;
max-height: 520px;
}
.context-overlay {
position: fixed;
inset: 0;
z-index: 500;
background: rgb(0 0 0 / 35%);
display: grid;
place-items: center;
padding: 2rem;
}
.context-overlay-panel {
width: min(1180px, 96vw);
max-height: 92vh;
overflow: auto;
background: white;
border: 1px solid var(--wiki-border);
box-shadow: 0 14px 44px rgb(0 0 0 / 24%);
padding: 1rem;
}
.context-overlay-header h2 {
margin: 0 0 .2rem;
}
.context-overlay-graph-shell {
margin-top: .8rem;
}
.wiki-graph-edges marker path {
fill: #9a9a9a;
}
.wiki-graph-edge-incoming {
stroke: #7a5d9e !important;
}
.wiki-graph-edge-outgoing {
stroke: #46639b !important;
}
@media (max-width: 1100px) {
.page-reading-layout.context-dock-right {
display: flex;
flex-direction: column;
}
.page-reading-layout.context-dock-right .context-dock {
order: -1;
width: 100%;
position: static;
max-height: none;
}
}
@media (max-width: 700px) {
.context-overlay {
padding: .5rem;
}
.context-overlay-panel {
width: 100%;
max-height: 96vh;
}
.context-overlay-header {
display: block;
}
.context-graph-actions {
margin-top: .5rem;
}
}
/* Keep the established document width and use otherwise empty desktop
space for the docked context view. */
#page-view:has(.context-dock-right) {
max-width: none;
}
.page-reading-layout.context-dock-right {
grid-template-columns: minmax(0, 900px) minmax(420px, 520px);
justify-content: start;
gap: 1.5rem;
}
.page-reading-layout.context-dock-right .markdown-body {
width: 100%;
}
.page-reading-layout.context-dock-right .context-dock {
width: 100%;
}
.context-dock .wiki-graph-node text {
font-size: 20px;
font-weight: 600;
}
.context-dock .wiki-graph-node-focus text {
font-size: 22px;
}
@media (max-width: 1450px) {
.page-reading-layout.context-dock-right {
grid-template-columns: minmax(0, 760px) minmax(360px, 440px);
}
}
+266 -15
View File
@@ -3,25 +3,27 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
<title>Racket Wiki</title>
<link rel="stylesheet" href="/vendor/font-awesome/css/font-awesome.min.css">
<link rel="stylesheet" href="/vendor/easymde.min.css">
<link rel="stylesheet" href="/vendor/highlight-github.min.css">
<link rel="stylesheet" href="/vendor/diff2html.min.css">
<link rel="stylesheet" href="/css/wiki.css">
<link rel="stylesheet" href="/css/wiki.css?id=__CMAP_CACHE_ID__">
<link rel="stylesheet" href="/cmap/cmap.css?id=__CMAP_CACHE_ID__">
</head>
<body>
<div id="connection-status" class="connection-status hidden" role="status"></div>
<div id="app">
<aside id="sidebar">
<a id="wiki-brand" class="brand" href="#">Racket Wiki</a>
<a id="recent-link" class="sidebar-primary-link" href="#">* <span data-tr="recent">Recent</span></a>
<a id="bookmarks-link" class="sidebar-primary-link" href="#">* <span data-tr="bookmarks">Bookmarks</span></a>
<a id="todo-link" class="sidebar-primary-link" href="#">* <span data-tr="todo-list">Todo list</span></a>
<a id="graph-link" class="sidebar-primary-link" href="#">* <span data-tr="graph">Graph</span></a>
<nav class="sidebar-icon-nav" aria-label="Wiki navigation">
<a id="bookmarks-link" class="sidebar-icon-link" href="#bookmarks" title="Bookmarks" data-tr-title="bookmarks" aria-label="Bookmarks" data-tr-aria-label="bookmarks"><i data-lucide="star"></i><span class="visually-hidden" data-tr="bookmarks">Bookmarks</span></a>
<a id="todo-link" class="sidebar-icon-link" href="#todos" title="Todo list" data-tr-title="todo-list" aria-label="Todo list" data-tr-aria-label="todo-list"><i data-lucide="list-checks"></i><span class="visually-hidden" data-tr="todo-list">Todo list</span></a>
<a id="recent-link" class="sidebar-icon-link" href="#recent" title="Recent" data-tr-title="recent" aria-label="Recent" data-tr-aria-label="recent"><i data-lucide="square-stack"></i><span class="visually-hidden" data-tr="recent">Recent</span></a>
<a id="graph-link" class="sidebar-icon-link" href="#graph" title="Navigation graph" data-tr-title="navigation-graph" aria-label="Navigation graph" data-tr-aria-label="navigation-graph"><i data-lucide="ship-wheel"></i><span class="visually-hidden" data-tr="navigation-graph">Navigation graph</span></a>
<a id="cmaps-link" class="sidebar-icon-link" href="#" title="CMaps" data-tr-title="cmaps" aria-label="CMaps" data-tr-aria-label="cmaps"><i data-lucide="share-2"></i><span class="visually-hidden" data-tr="cmaps">CMaps</span></a>
</nav>
<form id="search-form" class="wiki-search" role="search">
<input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki">
<input id="search-input" type="search" placeholder="Search pages and concept maps" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search pages and concept maps" data-tr-aria-label="search-wiki">
</form>
<section class="sidebar-section toc-section">
<div class="sidebar-heading"><span data-tr="contents">Contents</span> <a id="context-link" class="context-link hidden" href="#">(<span data-tr="context">context</span>)</a></div>
@@ -42,13 +44,14 @@
<span class="account-separator" aria-hidden="true">·</span>
<a id="logout-link" href="#" data-tr="sign-out">Sign out</a>
<span class="account-separator admin-only hidden" aria-hidden="true">·</span>
<a id="admin-link" class="admin-only hidden" href="#" data-tr="admin">Admin</a>
<a id="admin-link" class="admin-only hidden" href="#admin" data-tr="admin">Admin</a>
</div>
<div class="navigation-row">
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
<div id="page-action-links" class="page-action-links hidden">
<a id="bookmark-page" href="#" data-tr="bookmark">Bookmark</a>
<a id="edit-page" class="editor-only hidden" href="#" data-tr="edit">Edit</a>
<a id="rename-page" class="editor-only hidden" href="#" data-tr="rename">Rename</a>
<a id="history-page" href="#" data-tr="history">History</a>
<a id="delete-page" class="editor-only danger hidden" href="#" data-tr="delete">Delete</a>
</div>
@@ -62,7 +65,20 @@
<div id="page-meta" class="muted"></div>
</div>
</header>
<article id="markdown-preview" class="markdown-body"></article>
<div id="page-reading-layout" class="page-reading-layout">
<article id="markdown-preview" class="markdown-body"></article>
<aside id="context-dock" class="context-dock hidden" aria-label="Context graph">
<div class="context-dock-header">
<strong data-tr="context-graph">Context graph</strong>
<span class="context-graph-actions">
<a id="context-dock-popup" href="#" data-tr="popup">Popup</a>
<a id="context-dock-close" href="#" data-tr="close">Close</a>
</span>
</div>
<div id="context-dock-summary" class="muted"></div>
<svg id="context-dock-graph" class="wiki-graph context-graph-small" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
</aside>
</div>
<footer id="page-details" class="page-details"></footer>
</section>
@@ -146,6 +162,93 @@
</div>
</section>
<section id="cmap-view" class="hidden">
<header class="cmap-page-header">
<div class="cmap-storage-controls">
<span id="cmap-map-navigation" class="cmap-map-navigation hidden"><button id="cmap-map-back" type="button" aria-label="Back to parent map"></button><strong id="cmap-active-map-title"></strong></span>
<div id="cmap-map-combobox" class="wiki-combobox">
<input id="cmap-map-select" type="search" role="combobox"
aria-label="Concept map" data-tr-placeholder="select-concept-map"
placeholder="Select a CMap">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="cmap-map-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
<span class="cmap-toolbar-separator" aria-hidden="true"></span>
<button id="cmap-tools-menu" class="editor-only" type="button" aria-label="Concept map tools" aria-haspopup="menu"></button>
<span id="cmap-save-status" class="muted" role="status"></span>
<span class="cmap-toolbar-spacer" aria-hidden="true"></span>
<div class="cmap-zoom-controls" aria-label="Zoom controls">
<button id="cmap-zoom-out" type="button" aria-label="Zoom out"></button>
<label><input id="cmap-zoom-percent" type="number" min="25" max="300" step="10" value="100" aria-label="Zoom percentage"><span>%</span></label>
<button id="cmap-zoom-in" type="button" aria-label="Zoom in">+</button>
<button id="cmap-zoom-reset" type="button">100%</button>
</div>
<span class="cmap-toolbar-separator" aria-hidden="true"></span>
<details class="cmap-help-menu">
<summary aria-label="Concept map help">?</summary>
<div class="cmap-help-popover muted">
<p data-tr="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.</p>
<dl class="cmap-shortcut-list">
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>/<kbd>Shift</kbd> + <span data-tr="click">click</span></dt><dd data-tr="cmap-help-add-selection">Add to or remove from the selection</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>A</kbd></dt><dd data-tr="select-all">Select all</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>G</kbd></dt><dd data-tr="group-selected">Group selection as sub-CMap</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></dt><dd data-tr="ungroup-selected">Detach from sub-CMap</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></dt><dd data-tr="undo">Undo</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Y</kbd> / <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></dt><dd data-tr="redo-action">Redo</dd></div>
<div><dt><kbd>F2</kbd></dt><dd data-tr="cmap-help-rename">Edit the selected concept</dd></div>
<div><dt><kbd>Delete</kbd>/<kbd>Backspace</kbd></dt><dd data-tr="delete-selected">Delete selection</dd></div>
<div><dt><kbd>Esc</kbd></dt><dd data-tr="cmap-help-clear-selection">Clear selection</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>S</kbd></dt><dd data-tr="save-now">Save now</dd></div>
</dl>
</div>
</details>
</div>
</header>
<div class="cmap-workspace">
<div id="cmap-canvas" class="cmap-canvas" aria-label="Concept map"></div>
<nav id="cmap-context-menu" class="cmap-context-menu hidden" role="menu" aria-label="Concept map tools">
<button id="cmap-new-map" class="editor-only" type="button" role="menuitem" data-tr="new-concept-map">New CMap</button>
<button id="cmap-save-map" class="editor-only" type="button" role="menuitem" data-tr="save-now">Save now</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-rename-map" type="button" role="menuitem" data-tr="rename-concept-map" disabled>Rename CMap</button>
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="delete-concept-map" disabled>Delete CMap</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-add-concept" type="button" role="menuitem" data-tr="add-concept">Add concept</button>
<button id="cmap-add-page" type="button" role="menuitem" data-tr="add-page-node">Add current page</button>
<button id="cmap-add-submap" type="button" role="menuitem" data-tr="add-submap">Add sub-CMap</button>
<button id="cmap-promote-submap" type="button" role="menuitem" data-tr="promote-submap" disabled>Make separate CMap</button>
<button id="cmap-edit-selected" type="button" role="menuitem" data-tr="edit-selected-concept">Edit selected</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-undo" type="button" role="menuitem" data-tr="undo" disabled>Undo</button>
<button id="cmap-redo" type="button" role="menuitem" data-tr="redo-action" disabled>Redo</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-select-all" type="button" role="menuitem" data-tr="select-all">Select all</button>
<button id="cmap-group-selected" type="button" role="menuitem" data-tr="group-selected" disabled>Group selection as sub-CMap</button>
<button id="cmap-ungroup-selected" type="button" role="menuitem" data-tr="ungroup-selected" disabled>Detach from sub-CMap</button>
<button id="cmap-delete-selected" class="danger" type="button" role="menuitem" data-tr="delete-selected" disabled>Delete selection</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-toggle-page-guides" type="button" role="menuitemcheckbox" aria-checked="true" data-tr="a4-page-boundaries">A4 page boundaries</button>
<button id="cmap-reset" type="button" role="menuitem" data-tr="reload-concept-map">Reload CMap</button>
</nav>
</div>
</section>
<section id="rename-view" class="hidden">
<div class="section-header">
<h1 data-tr="rename-page">Rename page</h1>
</div>
<div class="rename-form">
<label><span data-tr="page-title">Page title</span><input id="rename-title" type="text"></label>
<label><span data-tr="namespace">Namespace</span><input id="rename-namespace" type="text"></label>
<label><span data-tr="slug">Slug</span><input id="rename-slug" type="text"></label>
<div class="editor-buttons">
<button id="save-rename" type="button" data-tr="save">Save</button>
<button id="cancel-rename" type="button" data-tr="cancel">Cancel</button>
</div>
<p id="rename-status" class="muted"></p>
</div>
</section>
<section id="history-view" class="hidden">
<header class="page-header">
<h1 data-tr="page-history">Page history</h1>
@@ -159,25 +262,36 @@
<header class="page-header">
<h1 data-tr="admin">Admin</h1>
</header>
<p class="admin-version"><span data-tr="software-version">Software version</span>: <strong id="admin-software-version"></strong></p>
<nav class="admin-menu">
<a id="admin-users-link" href="#" data-tr="users">Users</a>
<a id="admin-users-link" href="#admin/users" data-tr="users">Users</a>
<a id="admin-aliases-link" href="#admin/aliases" data-tr="page-aliases">Page aliases</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
<a id="admin-orphaned-uploads-link" href="#" data-tr="orphaned-uploads">Orphaned uploads</a>
<a id="admin-orphaned-uploads-link" href="#admin/orphaned-uploads" data-tr="orphaned-uploads">Orphaned uploads</a>
</nav>
</section>
<section id="orphaned-uploads-view" class="hidden">
<header class="page-header">
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
<a id="close-orphaned-uploads" href="#" data-tr="back">Back</a>
<a id="close-orphaned-uploads" href="#admin" data-tr="back">Back</a>
</header>
<div id="orphaned-uploads-list" class="orphaned-uploads-list"></div>
</section>
<section id="alias-admin-view" class="hidden">
<div class="section-header">
<h1 data-tr="page-aliases">Page aliases</h1>
<a id="close-alias-admin" href="#admin" data-tr="back">Back</a>
</div>
<p id="alias-admin-status" class="muted" role="status" aria-live="polite"></p>
<div id="alias-list"></div>
</section>
<section id="user-admin-view" class="hidden">
<header class="page-header">
<h1 data-tr="user-administration">User administration</h1>
<a id="close-user-admin" href="#" data-tr="back">Back</a>
<a id="close-user-admin" href="#admin" data-tr="back">Back</a>
</header>
<form id="new-user-form" class="user-form">
<input id="new-username" placeholder="Username" data-tr-placeholder="username" required>
@@ -195,11 +309,148 @@
</main>
</div>
<div id="context-overlay" class="context-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="context-overlay-title">
<div class="context-overlay-panel">
<div class="context-overlay-header">
<div>
<h2 id="context-overlay-title" data-tr="context-graph">Context graph</h2>
<div id="context-overlay-summary" class="muted"></div>
</div>
<nav class="context-graph-actions">
<a id="context-dock-right" href="#" data-tr="dock-right">Dock right</a>
<a id="context-dock-top" href="#" data-tr="dock-above">Dock above</a>
<a id="context-open-full" href="#" data-tr="open-full-graph">Open full graph</a>
<a id="context-overlay-close" href="#" data-tr="close">Close</a>
</nav>
</div>
<div class="wiki-graph-shell context-overlay-graph-shell">
<svg id="context-overlay-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
</div>
</div>
</div>
<dialog id="cmap-concept-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-concept-dialog-title">
<form id="cmap-concept-form">
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
<label id="cmap-concept-page-row" class="cmap-concept-page-row">
<span data-tr="linked-page">Linked wiki page</span>
<div id="cmap-concept-page-combobox" class="wiki-combobox">
<input id="cmap-concept-page" type="search" role="combobox"
data-tr-placeholder="filter-pages" placeholder="Filter pages">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show wiki pages"></button>
<div id="cmap-concept-page-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label id="cmap-concept-cmap-row" class="cmap-concept-page-row">
<span data-tr="linked-concept-map">Linked concept map</span>
<div id="cmap-concept-cmap-combobox" class="wiki-combobox">
<input id="cmap-concept-cmap" type="search" role="combobox"
data-tr-placeholder="filter-concept-maps" placeholder="Filter concept maps">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="cmap-concept-cmap-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label>
<span data-tr="label">Label</span>
<input id="cmap-concept-label" type="text" required>
</label>
<label>
<span data-tr="synopsis">Synopsis</span>
<textarea id="cmap-concept-synopsis" rows="4"></textarea>
</label>
<label>
<span id="cmap-concept-background-label" data-tr="background-color">Background color</span>
<input id="cmap-concept-background" type="color" value="#f3f6f8">
</label>
<fieldset id="cmap-submap-style-fields" class="cmap-submap-style-fields hidden">
<legend data-tr="submap-appearance">Sub-CMap appearance</legend>
<label>
<span data-tr="submap-background-color">Sub-CMap background color</span>
<input id="cmap-submap-background" type="color" value="#edf7e8">
</label>
<label>
<span data-tr="submap-border-color">Sub-CMap border color</span>
<input id="cmap-submap-border" type="color" value="#57834a">
</label>
</fieldset>
<label>
<span data-tr="text-color">Text color</span>
<input id="cmap-concept-text-color" type="color" value="#222222">
</label>
<fieldset class="cmap-concept-image-fields">
<legend data-tr="concept-image">Concept image</legend>
<input id="cmap-concept-image" type="file" accept="image/*">
<div id="cmap-concept-image-preview-row" class="cmap-concept-image-preview-row hidden">
<img id="cmap-concept-image-preview" alt="">
<button id="cmap-concept-image-remove" type="button" data-tr="remove-image">Remove image</button>
</div>
</fieldset>
<label>
<span data-tr="font-family">Font family</span>
<select id="cmap-concept-font-family">
<option value="Arial, Helvetica, sans-serif">Arial</option>
<option value="Verdana, Geneva, sans-serif">Verdana</option>
<option value="Tahoma, Geneva, sans-serif">Tahoma</option>
<option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option>
<option value="Georgia, Times New Roman, serif">Georgia</option>
<option value="Times New Roman, Times, serif">Times New Roman</option>
<option value="Courier New, Courier, monospace">Courier New</option>
<option value="system-ui, sans-serif">System UI</option>
</select>
</label>
<label>
<span data-tr="font-size">Font size</span>
<span class="cmap-font-size-input"><input id="cmap-concept-font-size" type="number" min="6" max="54" step="1"><span>pt</span></span>
</label>
<div class="cmap-concept-dialog-actions">
<button id="cmap-concept-cancel" type="button" data-tr="cancel">Cancel</button>
<button type="submit" class="primary" data-tr="save">Save</button>
</div>
</form>
</dialog>
<dialog id="wiki-cmap-link-dialog" class="cmap-concept-dialog" aria-labelledby="wiki-cmap-link-title">
<form id="wiki-cmap-link-form">
<h2 id="wiki-cmap-link-title" data-tr="insert-concept-map-link">Insert CMap link</h2>
<label>
<span data-tr="linked-concept-map">Linked concept map</span>
<div id="wiki-cmap-link-combobox" class="wiki-combobox">
<input id="wiki-cmap-link" type="search" role="combobox"
data-tr-placeholder="filter-concept-maps" placeholder="Filter concept maps">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="wiki-cmap-link-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<p class="muted" data-tr="concept-map-link-help">The selected CMap is inserted as a normal Markdown link.</p>
<div class="cmap-concept-dialog-actions">
<button id="wiki-cmap-link-cancel" type="button" data-tr="cancel">Cancel</button>
<button id="wiki-cmap-link-submit" type="submit" class="primary" data-tr="insert">Insert</button>
</div>
</form>
</dialog>
<dialog id="cmap-unsaved-dialog" class="cmap-unsaved-dialog" aria-labelledby="cmap-unsaved-title">
<form method="dialog">
<h2 id="cmap-unsaved-title" data-tr="unsaved-concept-map">Unsaved concept map</h2>
<p data-tr="save-concept-map-before-leaving">Save the changes to this concept map before leaving?</p>
<div class="cmap-concept-dialog-actions">
<button id="cmap-unsaved-cancel" type="button" data-tr="cancel">Cancel</button>
<button id="cmap-unsaved-discard" type="button" data-tr="dont-save">Don't save</button>
<button id="cmap-unsaved-save" type="button" class="primary editor-only" data-tr="save">Save</button>
</div>
</form>
</dialog>
<script src="/vendor/purify.min.js"></script>
<script src="/vendor/highlight.min.js"></script>
<script src="/vendor/highlight-scheme.min.js"></script>
<script src="/vendor/easymde.min.js"></script>
<script src="/vendor/lucide.min.js"></script>
<script src="/vendor/diff2html-ui-base.min.js"></script>
<script src="/js/wiki.js"></script>
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
<script src="/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
/* Accessible, dependency-free combobox used by racket-wiki. */
(() => {
"use strict";
class RacketWikiComboBox {
constructor(root) {
this.root = root;
this.input = root.querySelector("input");
this.button = root.querySelector("button");
this.list = root.querySelector('[role="listbox"]');
this.options = [];
this.filteredOptions = [];
this.selectedValue = "";
this.activeIndex = -1;
this.selectionHandlers = [];
this.input.setAttribute("aria-controls", this.list.id);
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("autocomplete", "off");
this.input.addEventListener("input", () => {
this.selectedValue = "";
this.input.setCustomValidity("");
this.open();
this.renderOptions(false);
});
this.input.addEventListener("focus", () => {
this.open();
this.renderOptions(true);
});
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
this.input.addEventListener("blur", () => {
window.setTimeout(() => this.close(), 100);
});
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
this.button.addEventListener("click", () => {
if (this.isOpen()) {
this.close();
} else {
this.open();
this.renderOptions(true);
this.input.focus({ preventScroll: true });
}
});
}
setOptions(options, selectedValue = "") {
this.options = options.map((option) => ({
value: String(option.value),
label: String(option.label),
description: String(option.description || "")
}));
const selected = this.options.find((option) => option.value === selectedValue) || null;
this.selectedValue = selected ? selected.value : "";
this.input.value = selected ? selected.label : "";
this.activeIndex = -1;
this.renderOptions();
}
value() {
const text = this.input.value.trim().toLocaleLowerCase();
if (!text) {
this.selectedValue = "";
return "";
}
if (this.selectedValue) {
const selected = this.options.find((option) => option.value === this.selectedValue);
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
}
const selected = this.options.find((option) =>
option.label.toLocaleLowerCase() === text ||
option.value.toLocaleLowerCase() === text) || null;
this.selectedValue = selected ? selected.value : "";
return selected ? selected.value : null;
}
clear() {
this.selectedValue = "";
this.input.value = "";
this.input.setCustomValidity("");
this.activeIndex = -1;
this.renderOptions();
}
onSelect(handler) {
this.selectionHandlers.push(handler);
return this;
}
isOpen() {
return !this.list.classList.contains("hidden");
}
open() {
this.list.classList.remove("hidden");
this.input.setAttribute("aria-expanded", "true");
this.button.setAttribute("aria-expanded", "true");
}
close() {
this.list.classList.add("hidden");
this.input.setAttribute("aria-expanded", "false");
this.button.setAttribute("aria-expanded", "false");
this.activeIndex = -1;
this.input.removeAttribute("aria-activedescendant");
}
renderOptions(showAll = false) {
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
this.filteredOptions = this.options.filter((option) =>
!query || option.label.toLocaleLowerCase().includes(query) ||
option.description.toLocaleLowerCase().includes(query) ||
option.value.toLocaleLowerCase().includes(query));
this.list.replaceChildren();
for (const [index, option] of this.filteredOptions.entries()) {
const item = document.createElement("div");
item.id = `${this.list.id}-option-${index}`;
item.className = "wiki-combobox-option";
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
item.dataset.index = String(index);
const label = document.createElement("span");
label.className = "wiki-combobox-option-label";
label.textContent = option.label;
item.append(label);
if (option.description && option.description !== option.label) {
const description = document.createElement("span");
description.className = "wiki-combobox-option-description";
description.textContent = option.description;
item.append(description);
}
item.addEventListener("pointerdown", (event) => {
event.preventDefault();
this.selectOption(option);
});
this.list.append(item);
}
this.updateActiveOption();
}
selectOption(option) {
this.selectedValue = option.value;
this.input.value = option.label;
this.input.setCustomValidity("");
this.close();
for (const handler of this.selectionHandlers) handler(option.value, option);
this.input.dispatchEvent(new Event("change", { bubbles: true }));
}
handleKeydown(event) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!this.isOpen()) {
this.open();
this.renderOptions();
}
const direction = event.key === "ArrowDown" ? 1 : -1;
const maximum = this.filteredOptions.length - 1;
if (maximum < 0) return;
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
this.updateActiveOption();
return;
}
if (event.key === "Enter" && this.isOpen()) {
const exactText = this.input.value.trim().toLocaleLowerCase();
const exact = this.filteredOptions.find((option) =>
option.label.toLocaleLowerCase() === exactText ||
option.value.toLocaleLowerCase() === exactText) || null;
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
if (selected) {
event.preventDefault();
this.selectOption(selected);
return;
}
}
if (event.key === "Escape") {
event.preventDefault();
this.close();
}
}
updateActiveOption() {
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
for (const [index, element] of elements.entries()) {
element.classList.toggle("active", index === this.activeIndex);
}
const active = elements[this.activeIndex];
if (active) {
this.input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
} else {
this.input.removeAttribute("aria-activedescendant");
}
}
}
window.RacketWikiComboBox = RacketWikiComboBox;
})();
+2005 -103
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# Vendor extensions
`cmap-racket-wiki.js` is the racket-wiki interaction layer for upstream
`ionstage/cmap` 0.1.3. The upstream library is installed as `/vendor/cmap.js`
by the normal frontend setup. Racket-wiki adjusts its `Node.prototype.style`
so rendered concept nodes accept pointer events; upstream's link hit testing is
left unchanged.
The extension remains separate because selection handles, resize controls,
drag-to-connect relations, separate linking-phrase nodes and wiki page/submap
behaviour belong to the racket-wiki editor rather than the generic drawing
library.
@@ -0,0 +1,672 @@
/*
* Racket Wiki interaction layer for ionstage/cmap 0.1.3.
*
* This file contains the racket-wiki-specific editor model and interactions.
* The installed upstream cmap.js is adjusted separately so its rendered nodes
* accept pointer events; concepts can then be selected as normal DOM targets.
*/
(() => {
"use strict";
const debugPrefix = "[racket-wiki:cmap 0.2.44]";
function debug(message, details) {
if (details === undefined) {
console.info(debugPrefix, message);
return;
}
console.info(debugPrefix, message, details);
}
function elementDescription(element) {
if (!(element instanceof Element)) return String(element);
return {
tag: element.tagName,
id: element.id || null,
classes: Array.from(element.classList),
itemId: element.dataset.rwCmapItemId || null
};
}
function selectionStyle(element) {
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
const style = window.getComputedStyle(element);
return {
pointerEvents: style.pointerEvents,
outline: style.outline,
outlineOffset: style.outlineOffset,
boxShadow: style.boxShadow,
overflow: style.overflow,
zIndex: style.zIndex
};
}
debug("cmap-racket-wiki.js loaded", {
script: document.currentScript ? document.currentScript.src : null,
cmapAvailable: typeof window.Cmap === "function",
stylesheets: Array.from(document.styleSheets || [])
.map((sheet) => sheet.href)
.filter((href) => href && href.includes("cmap.css"))
});
//////////////////////////////////////////////////////////////////////////////
// Small helpers
//////////////////////////////////////////////////////////////////////////////
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
//////////////////////////////////////////////////////////////////////////////
// Editor
//////////////////////////////////////////////////////////////////////////////
/**
* goal : Add CmapTools-like selection, resize and relation drawing on top
* of ionstage/cmap without changing the upstream library.
* pre : canvas is a DOM element and window.Cmap is available.
* post : Concepts and linking phrases can be selected and connected by
* direct manipulation.
* result : A CmapEditor instance.
*/
class CmapEditor {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.CmapFactory = options.Cmap || window.Cmap;
this.renderItem = options.renderItem || null;
this.onOpenPage = options.onOpenPage || null;
this.onOpenSubMap = options.onOpenSubMap || null;
this.onSelectionChange = options.onSelectionChange || null;
this.labels = {
createRelation: options.createRelationLabel || "Create relation",
resizeConcept: options.resizeConceptLabel || "Resize concept",
relation: options.relationLabel || "Relation"
};
this.map = this.CmapFactory(canvas);
this.items = [];
this.connectors = [];
this.selectedItem = null;
this.selectedConnector = null;
this.nextId = 1;
this.nextConnectorId = 1;
this.dragRelation = null;
this.installCanvasHandlers();
debug("editor created", {
canvas: elementDescription(canvas),
cmapFactoryAvailable: typeof this.CmapFactory === "function"
});
}
/**
* goal : Add a draggable concept or linking-phrase node.
* pre : options may contain the normal ionstage/cmap node attributes.
* post : The item is drawn and receives selection/relation/resize UI.
* result : The item record used by the editor.
*/
addItem(options = {}) {
const id = this.nextId++;
const kind = options.kind || "concept";
const record = {
id,
kind,
label: options.label || "Concept",
synopsis: options.synopsis || "",
pageSlug: options.pageSlug || null,
childMap: options.childMap || null,
backgroundColor: options.backgroundColor || "#f3f6f8",
borderColor: options.borderColor || "#5d6d7e",
fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif",
fontSize: options.fontSize || "15px",
width: options.width || (kind === "phrase" ? 145 : 220),
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
node: null
};
const node = this.map.node({
content: this.itemHtml(record),
contentType: "html",
x: numberOr(options.x, 80 + ((id * 37) % 420)),
y: numberOr(options.y, 80 + ((id * 83) % 360)),
width: record.width,
height: record.height,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
borderWidth: kind === "phrase" ? 1 : 2,
textColor: "#222"
});
record.node = node;
this.items.push(record);
node.redraw();
this.decorateItem(record);
debug("item added", {
id: record.id,
kind: record.kind,
label: record.label,
element: elementDescription(record.node.element()),
style: selectionStyle(record.node.element())
});
return record;
}
/**
* goal : Change presentation/content of an existing item.
* pre : record belongs to this editor.
* post : The ionstage node and interaction handles are redrawn.
*/
updateItem(record, changes = {}) {
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
record.width = numberOr(Number(record.width), record.node.attr("width"));
record.height = numberOr(Number(record.height), record.node.attr("height"));
record.node.attr({
content: this.itemHtml(record),
width: record.width,
height: record.height,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor
});
record.node.redraw();
this.decorateItem(record);
this.redrawConnectorsFor(record);
}
/**
* goal : Connect source to target with a separate linking phrase.
* pre : source and target are items in this editor.
* post : source -> phrase -> target is visible; the phrase can branch.
* result : The newly created linking-phrase item.
*/
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
const a = this.itemCenter(source);
const b = this.itemCenter(target);
const phrase = this.addItem({
kind: "phrase",
label,
x: ((a.x + b.x) / 2) - 72,
y: ((a.y + b.y) / 2) - 18,
width: 145,
height: 36,
backgroundColor: "#fffdf7",
borderColor: "#8c7a4f"
});
this.addConnector(source, phrase, false);
this.addConnector(phrase, target, true);
this.selectItem(phrase);
if (editImmediately) this.editPhraseInline(phrase);
return phrase;
}
/**
* goal : Add one directed connector between two existing map items.
* pre : source and target are items in this editor.
* post : A selectable ionstage/cmap link joins them.
* result : Connector record.
*/
addConnector(source, target, hasArrow = true) {
const link = this.map.link({
content: "",
width: 1,
height: 1,
backgroundColor: "transparent",
borderColor: "transparent",
borderWidth: 0,
lineColor: "#333",
lineWidth: 2,
hasArrow
});
link.sourceNode(source.node).targetNode(target.node);
link.draggable(true);
link.redraw();
const record = {
id: this.nextConnectorId++,
link,
source,
target,
hasArrow,
lineColor: "#333",
lineWidth: 2
};
this.connectors.push(record);
this.decorateConnector(record);
return record;
}
/**
* goal : Select a concept/linking phrase and expose its handles.
* pre : record belongs to this editor.
* post : Previous selection is cleared and record is visually selected.
*/
selectItem(record) {
debug("selectItem called", {
requestedId: record ? record.id : null,
requestedKind: record ? record.kind : null,
previousId: this.selectedItem ? this.selectedItem.id : null
});
this.clearSelection();
this.selectedItem = record;
record.node.toFront();
const element = record.node.element();
if (element) {
element.classList.add("rw-cmap-selected");
element.setAttribute("aria-selected", "true");
this.ensureHandles(record, element);
}
debug("selection applied", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
elementFound: Boolean(element),
element: elementDescription(element),
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
computedStyle: selectionStyle(element)
});
if (element && typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(() => {
debug("selection after browser redraw", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
selectedClassPresent: element.classList.contains("rw-cmap-selected"),
connected: element.isConnected,
handleCount: element.querySelectorAll(":scope > .rw-cmap-handle").length,
computedStyle: selectionStyle(element)
});
});
}
this.notifySelection();
}
/**
* goal : Select a connector so its line becomes clearly visible.
* pre : record belongs to this editor.
* post : Previous selection is cleared and the connector is highlighted.
*/
selectConnector(record) {
this.clearSelection();
this.selectedConnector = record;
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
record.link.redraw();
this.decorateConnector(record);
this.notifySelection();
}
/**
* goal : Remove the current item/connector selection.
* post : No item handles or connector highlight remain.
*/
clearSelection() {
const clearedItemId = this.selectedItem ? this.selectedItem.id : null;
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
if (this.selectedItem) {
const element = this.selectedItem.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.removeAttribute("aria-selected");
this.removeHandles(element);
}
}
if (this.selectedConnector) {
const connector = this.selectedConnector;
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
connector.link.redraw();
this.decorateConnector(connector);
}
this.selectedItem = null;
this.selectedConnector = null;
if (clearedItemId || clearedConnectorId) {
debug("selection cleared", { itemId: clearedItemId, connectorId: clearedConnectorId });
}
this.notifySelection();
}
selected() {
return this.selectedItem;
}
/**
* goal : Start direct editing of a linking phrase.
* pre : record.kind is "phrase".
* post : An input appears in the relation-name node and receives focus.
*/
editPhraseInline(record) {
if (!record || record.kind !== "phrase") return;
const value = record.label || "?????";
record.node.attr("content",
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.labels.relation)}">`);
record.node.redraw();
this.decorateItem(record);
const element = record.node.element();
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
if (!input) return;
const commit = () => {
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.decorateItem(record);
this.selectItem(record);
};
input.addEventListener("pointerdown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
input.value = value;
input.blur();
}
});
input.addEventListener("blur", commit, { once: true });
input.focus();
input.select();
}
/**
* goal : Redraw selection controls after ionstage/cmap updates a node.
* pre : record.node.redraw() has made a DOM element available.
* post : Selection, drag-to-link and resize interactions are attached.
*/
decorateItem(record) {
const element = record.node.element();
if (!element) return;
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
element.dataset.rwCmapItemId = String(record.id);
element.style.fontFamily = record.fontFamily;
element.style.fontSize = record.fontSize;
element.style.overflow = "visible";
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
id: record.id,
kind: record.kind,
element: elementDescription(element),
style: selectionStyle(element)
});
element.addEventListener("dblclick", (event) => {
if (event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) return;
event.preventDefault();
event.stopPropagation();
if (record.kind === "phrase") {
this.editPhraseInline(record);
return;
}
if (record.pageSlug && this.onOpenPage) {
this.onOpenPage(record);
return;
}
if (record.childMap && this.onOpenSubMap) this.onOpenSubMap(record);
});
}
if (this.selectedItem === record) {
element.classList.add("rw-cmap-selected");
this.ensureHandles(record, element);
}
}
decorateConnector(record) {
const element = record.link.element();
if (!element) return;
element.classList.add("rw-cmap-connector");
element.dataset.rwCmapConnectorId = String(record.id);
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
element.addEventListener("pointerdown", (event) => {
event.stopPropagation();
this.selectConnector(record);
}, true);
}
}
redrawConnectorsFor(record) {
for (const connector of this.connectors) {
if (connector.source === record || connector.target === record) {
connector.link.redraw();
this.decorateConnector(connector);
}
}
}
/**
* goal : Select concepts before ionstage/cmap starts a possible drag.
* pre : Item DOM elements contain data-rw-cmap-item-id attributes.
* post : Pointer-down on an item selects it immediately; pointer-down on
* empty canvas space clears the selection.
*/
installCanvasHandlers() {
const itemFromEvent = (event) => {
if (!(event.target instanceof Element)) return null;
const element = event.target.closest("[data-rw-cmap-item-id]");
if (!element || !this.canvas.contains(element)) return null;
const id = Number(element.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
};
this.canvas.addEventListener("pointerdown", (event) => {
debug("canvas pointerdown", {
pointerId: event.pointerId,
pointerType: event.pointerType,
button: event.button,
target: elementDescription(event.target)
});
if (event.target instanceof Element &&
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
debug("pointerdown belongs to a selection control", elementDescription(event.target));
return;
}
const item = itemFromEvent(event);
if (item) {
debug("pointerdown matched item", { id: item.id, kind: item.kind, label: item.label });
this.selectItem(item);
return;
}
debug("pointerdown did not match an item", {
targetIsCanvas: event.target === this.canvas,
target: elementDescription(event.target)
});
if (event.target === this.canvas) this.clearSelection();
}, true);
}
ensureHandles(record, element) {
this.removeHandles(element);
const relation = document.createElement("button");
relation.type = "button";
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
relation.title = this.labels.createRelation;
relation.setAttribute("aria-label", this.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record));
element.append(relation);
if (record.kind !== "phrase") {
const resize = document.createElement("button");
resize.type = "button";
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
resize.title = this.labels.resizeConcept;
resize.setAttribute("aria-label", this.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
startResize(event, record) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = Number(record.node.attr("width"));
const startHeight = Number(record.node.attr("height"));
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
record.width = Math.max(100, startWidth + (moveEvent.clientX - startX));
record.height = Math.max(42, startHeight + (moveEvent.clientY - startY));
record.node.attr({ width: record.width, height: record.height });
record.node.redraw();
this.decorateItem(record);
this.redrawConnectorsFor(record);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.decorateItem(record);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
startRelationDrag(event, source) {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const start = this.itemCenter(source);
const draft = this.createDraftLine(start);
this.dragRelation = { source, draft };
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const point = this.canvasPoint(moveEvent);
draft.line.setAttribute("x2", String(point.x));
draft.line.setAttribute("y2", String(point.y));
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
draft.svg.remove();
this.dragRelation = null;
const target = this.itemAt(upEvent.clientX, upEvent.clientY);
if (!target || target === source) return;
this.finishRelation(source, target);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
finishRelation(source, target) {
if (source.kind === "phrase" && target.kind !== "phrase") {
this.addConnector(source, target, true);
this.selectItem(source);
return;
}
if (source.kind !== "phrase" && target.kind === "phrase") {
this.addConnector(source, target, false);
this.selectItem(target);
return;
}
if (source.kind === "phrase" && target.kind === "phrase") return;
this.connectWithPhrase(source, target, "?????", true);
}
createDraftLine(start) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.classList.add("rw-cmap-draft-layer");
svg.setAttribute("width", String(Math.max(this.canvas.scrollWidth, this.canvas.clientWidth)));
svg.setAttribute("height", String(Math.max(this.canvas.scrollHeight, this.canvas.clientHeight)));
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", String(start.x));
line.setAttribute("y1", String(start.y));
line.setAttribute("x2", String(start.x));
line.setAttribute("y2", String(start.y));
line.setAttribute("class", "rw-cmap-draft-line");
svg.append(line);
this.canvas.append(svg);
return { svg, line };
}
canvasPoint(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left + this.canvas.scrollLeft,
y: event.clientY - rect.top + this.canvas.scrollTop
};
}
itemAt(clientX, clientY) {
const element = document.elementFromPoint(clientX, clientY);
const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null;
if (!itemElement) return null;
const id = Number(itemElement.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
}
itemCenter(record) {
return {
x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2),
y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2)
};
}
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.renderItem) return this.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
notifySelection() {
if (this.onSelectionChange) this.onSelectionChange(this.selectedItem, this.selectedConnector);
}
}
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.44",
createEditor(canvas, options) {
lastEditor = new CmapEditor(canvas, options);
return lastEditor;
},
debugSelection() {
if (!lastEditor) {
debug("debugSelection: no editor has been created");
return null;
}
const record = lastEditor.selected();
const element = record ? record.node.element() : null;
const result = {
selectedId: record ? record.id : null,
selectedKind: record ? record.kind : null,
selectedLabel: record ? record.label : null,
element: elementDescription(element),
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
computedStyle: selectionStyle(element)
};
debug("manual selection inspection", result);
return result;
}
};
})();
+4
View File
@@ -4,6 +4,7 @@ racket-wiki 0.2.3 no longer writes downloaded browser libraries into this packag
Pinned sources:
- Lucide 1.31.0: `https://cdn.jsdelivr.net/npm/lucide@1.31.0/dist/umd/lucide.min.js`
- EasyMDE 2.21.0: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js`
- EasyMDE 2.21.0 CSS: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css`
- DOMPurify 3.4.13: `https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js`
@@ -14,3 +15,6 @@ Pinned sources:
- diff2html 3.4.56 CSS: `https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/css/diff2html.min.css`
EasyMDE bundles CodeMirror and Marked, so no separate CodeMirror or Markdown renderer is downloaded.
The CMap implementation is maintained with racket-wiki itself below
`static/cmap/`; it is no longer installed as a runtime vendor dependency.
+51 -9
View File
@@ -24,9 +24,28 @@
(define english
(hash
'users "Users" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Root"
'edit "Edit" '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"
'users "Users" 'page-aliases "Page aliases" 'no-page-aliases "No page aliases." 'old-address "Old address" 'current-address "Current address" 'referenced-by "Referenced by" 'current-references "Current references" 'historical-references "Historical references" 'cleanup-alias "Clean up references" 'cleanup-alias-confirm "Replace all current references to this old address with the current address? Historical versions will not be changed." 'cleanup-complete "Cleanup complete" 'delete-alias "Delete alias" 'delete-alias-confirm "Delete this alias?" 'delete-alias-history-confirm "Delete this alias? Historical page versions still contain this old address and those links may stop resolving." 'software-version "Software version" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'no-recent-changes "No recent changes." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Concept maps" 'concept-map-storage-note "Concept maps are stored in the wiki database." 'new-concept-map "New CMap" 'concept-map-name "Concept map name" 'concept-map-saved "CMap saved" 'no-concept-maps "No saved CMaps" 'concept-map-prototype "Concept map prototype" 'concept-map-prototype-note "In-memory prototype; changes are not saved yet." 'concept-map-help "Select a concept to show its relation and resize handles. Drag the relation handle to another concept; edit the new relation name immediately. Double-click a page concept to open the wiki page." 'add-concept "Add concept" 'add-page-node "Add current page" 'add-submap "Add sub-CMap" 'connect-selected "Connect selected" 'edit-selected-concept "Edit selected" 'reset "Reset" 'relation "Relation" 'create-relation "Create relation" 'resize-concept "Resize concept" 'select-one-concept "Select one concept first." 'label "Label" 'synopsis "Synopsis" 'background-color "Background color" 'font-family "Font family" 'font-size "Font size" 'sub-concept-map "Sub concept map" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'context-graph-direction-summary "{incoming} incoming, {outgoing} outgoing" 'popup "Popup" 'close "Close" 'dock-right "Dock right" 'dock-above "Dock above" 'open-full-graph "Open full graph" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'select-all "Select all" 'group-selected "Group selection as sub-CMap"
'ungroup-selected "Detach from sub-CMap" 'delete-selected "Delete selection"
'group-submap-name "Name of the main concept for the new sub-CMap"
'grouped-submap-synopsis "Grouped sub-concept map."
'navigation-graph "Navigation graph" 'click "click"
'cmap-help-add-selection "Add to or remove from the selection"
'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."
'detach-submap-confirm "Place \"{concept}\" outside submap \"{submap}\"?"
'promote-submap "Make separate CMap" 'submap-name "Name of the new concept map"
'rename-concept-map "Rename CMap" 'delete-concept-map "Delete CMap" 'redo-action "Redo" 'save-now "Save now"
'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"
'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"
'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 "The selected CMap is inserted as a normal Markdown link." 'concept-map-not-found "CMap not found" 'insert "Insert" '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"
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
'back "Back" 'user-administration "User administration" 'username "Username"
@@ -53,13 +72,34 @@
'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen" 'raw-markdown "Raw Markdown" 'automatic-wiki-link "Automatic wiki link"
'language "Language" 'language-en "English" 'language-nl "Dutch"
'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete"
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."))
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."
'combined-wiki-graph "Wiki and CMap graph"
'combined-graph-summary "{pages} pages, {cmaps} concept maps, {links} links"))
(define dutch
(hash
'users "Gebruikers" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Hoofdnamespace"
'edit "Bewerken" '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"
'users "Gebruikers" 'page-aliases "Pagina-aliassen" 'no-page-aliases "Geen pagina-aliassen." 'old-address "Oud adres" 'current-address "Huidig adres" 'referenced-by "Gerefereerd door" 'current-references "Actuele verwijzingen" 'historical-references "Historische verwijzingen" 'cleanup-alias "Verwijzingen opschonen" 'cleanup-alias-confirm "Alle actuele verwijzingen naar dit oude adres vervangen door het huidige adres? Historische versies worden niet gewijzigd." 'cleanup-complete "Opschonen voltooid" 'delete-alias "Alias verwijderen" 'delete-alias-confirm "Deze alias verwijderen?" 'delete-alias-history-confirm "Deze alias verwijderen? Historische paginaversies bevatten dit oude adres nog en die links werken daarna mogelijk niet meer." 'software-version "Softwareversie" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'no-recent-changes "Nog geen recente wijzigingen." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Conceptmaps" 'concept-map-storage-note "Conceptmaps worden in de wikidatabase opgeslagen." 'new-concept-map "Nieuwe CMap" 'concept-map-name "Naam van de conceptmap" 'concept-map-saved "CMap opgeslagen" 'no-concept-maps "Geen opgeslagen CMaps" 'concept-map-prototype "Conceptmap-prototype" 'concept-map-prototype-note "Prototype in geheugen; wijzigingen worden nog niet opgeslagen." 'concept-map-help "Selecteer een concept om de relatie- en formaatgrepen te tonen. Sleep de relatiegreep naar een ander concept en bewerk daarna direct de relatienaam. Dubbelklik op een paginaconcept om de wikipagina te openen." 'add-concept "Concept toevoegen" 'add-page-node "Huidige pagina toevoegen" 'add-submap "Sub-CMap toevoegen" 'connect-selected "Selectie verbinden" 'edit-selected-concept "Selectie bewerken" 'reset "Opnieuw" 'relation "Relatie" 'create-relation "Relatie maken" 'resize-concept "Concept vergroten/verkleinen" 'select-one-concept "Selecteer eerst één concept." 'label "Naam" 'synopsis "Samenvatting" 'background-color "Achtergrondkleur" 'font-family "Lettertype" 'font-size "Lettergrootte" 'sub-concept-map "Sub-conceptmap" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'context-graph-direction-summary "{incoming} inkomend, {outgoing} uitgaand" 'popup "Popup" 'close "Sluiten" 'dock-right "Rechts vastzetten" 'dock-above "Boven vastzetten" 'open-full-graph "Volledig openen" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'select-all "Alles selecteren" 'group-selected "Selectie groeperen als sub-CMap"
'ungroup-selected "Losmaken van sub-CMap" 'delete-selected "Selectie verwijderen"
'group-submap-name "Naam van het hoofdconcept van de nieuwe sub-CMap"
'grouped-submap-synopsis "Gegroepeerde sub-conceptmap."
'navigation-graph "Navigatiegraaf" 'click "klik"
'cmap-help-add-selection "Aan de selectie toevoegen of eruit verwijderen"
'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."
'detach-submap-confirm "\"{concept}\" buiten submap \"{submap}\" plaatsen?"
'promote-submap "Aparte CMap maken" 'submap-name "Naam van de nieuwe conceptmap"
'rename-concept-map "CMap hernoemen" 'delete-concept-map "CMap verwijderen" 'redo-action "Opnieuw uitvoeren" 'save-now "Nu opslaan"
'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"
'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"
'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-link invoegen" 'concept-map-link-help "De geselecteerde CMap wordt als gewone Markdown-link ingevoegd." 'concept-map-not-found "CMap niet gevonden" 'insert "Invoegen" '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"
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam"
@@ -82,11 +122,13 @@
'saving "Opslaan…" 'created-page "Pagina aangemaakt" 'edited-page "Pagina gewijzigd" 'line "regel"
'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat"
'bulleted-list "Opsomming" 'numbered-list "Genummerde lijst" 'checklist "Checklist" 'code-block "Codeblok"
'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw"
'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw uitvoeren"
'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm" 'raw-markdown "Ruwe Markdown" 'automatic-wiki-link "Automatische wikilink"
'language "Taal" 'language-en "Engels" 'language-nl "Nederlands"
'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid"
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."))
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."
'combined-wiki-graph "Wiki- en CMap-graaf"
'combined-graph-summary "{pages} pagina's, {cmaps} conceptmaps, {links} links"))
(define (base-translations language)
(if (string-ci=? language "nl") dutch english))