documentatie

This commit is contained in:
2026-09-03 17:24:56 +02:00
parent e971dfe942
commit 42fcea9af8
14 changed files with 135 additions and 16 deletions
+16 -4
View File
@@ -49,9 +49,17 @@ objects, and CMap state is not persisted in browser storage.
`view/appearance-editor.js` presents the appearance model in the concept dialog
and coordinates explicit style and palette changes with its repository.
The concrete dialog controllers live in `../wiki/cmap/dialogs/`. They own
their fields, validation and browser events. The workspace supplies the active
CMap and performs editor transitions, while the controllers use repository
APIs for stored CMaps and people instead of calling backend routes directly.
their fields, validation and browser events. The wiki-specific modules under
`../wiki/cmap/` form the editor host layer. `CmapEditorHost` owns creation and
destruction of the active editor; `CmapEditorUiController` owns the standard
toolbar actions; `CmapStorageController` owns dirty state, autosave and save
ordering; `CmapNavigationController` owns routes and browser context;
`CmapTransferController` owns import and export; `CmapMapController` owns
stored-map workflows; and `CmapConceptController` owns dialog-backed concept
workflows. `CmapWorkspaceController` composes these modules, supplies the
active CMap and registers the remaining dialog and DOM integration. Controllers
use repository APIs for stored CMaps and people instead of calling backend
routes directly.
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
state, content-based initial sizing, resize and relation controls. Its view
records connect pure model ids to drawing nodes without making those nodes part
@@ -98,7 +106,11 @@ metadata remain intact, but its document no longer depends on the parent CMap.
formatting, positions, connectors, recursive submap membership and promoted
map references. The public `currentModel`, `loadModel` and `replaceModel`
methods let the workspace pass complete domain models to and from the editor.
The CMap repository, rather than the workspace, persists those models.
The CMap repository, rather than the workspace, persists those models. The
editor also provides `snapshotSvg()` for an embed render. That SVG is saved in
the separate `cmap_renders` backend relation and is never inserted into the
editable CMap document. `CmapEmbedView` renders this stored SVG as a linkable
image for `{{cmap:...}}` Markdown embeds.
## JSON interchange
@@ -48,6 +48,12 @@ export class CmapConceptController {
this.getDialog()?.openNew(context, this.dialogResources());
}
/**
* goal : Apply one validated concept-dialog submission as one editor history step.
* pre : record identifies an existing placement, or createContext describes a new placement.
* post : Shared concept content and placement presentation are updated and autosave is scheduled.
* result : False when the editor or required create context is unavailable.
*/
async saveConcept(record, createContext, values) {
const editor = this.getEditor();
if ((!record && !createContext) || !editor) return false;
@@ -101,6 +107,7 @@ export class CmapConceptController {
return true;
}
/** Update selection-dependent layout and concept action availability in the toolbar. */
updateSelectionToolbar(editor, selectedRecords = [], connector = null) {
const toolbar = document.getElementById("cmap-selection-toolbar");
const selected = Array.isArray(selectedRecords) ? selectedRecords : [];
@@ -127,6 +134,12 @@ export class CmapConceptController {
parts.slug.toLocaleLowerCase();
}
/**
* goal : Move selected concept description pages into the active CMap namespace.
* pre : An active CMap namespace exists and selected records may have description references.
* post : Existing pages are renamed, editor references are changed and the map is autosaved.
* result : False when no applicable selection exists or a target-page conflict is found.
*/
async moveSelectedDescriptionsToNamespace() {
const editor = this.getEditor();
const namespace = this.getNamespace();
@@ -187,6 +200,7 @@ export class CmapConceptController {
return options;
}
/** Open inline phrase editing or the concept dialog for the selected editor record. */
editSelected(record = null) {
const editor = this.getEditor();
const selectedRecord = record || editor?.selected();
@@ -201,6 +215,7 @@ export class CmapConceptController {
this.openDialog(selectedRecord);
}
/** Group the current editor selection into a named inline submap. */
groupSelection() {
const editor = this.getEditor();
if (!editor || !editor.canGroupSelection()) return false;
@@ -216,6 +231,7 @@ export class CmapConceptController {
});
}
/** Populate a newly opened sample submap with its initial child records. */
populateSubmap(record, editor) {
const baseX = Number(record.node.attr("x"));
const baseY = Number(record.node.attr("y"));
+6
View File
@@ -12,6 +12,11 @@ export class CmapEditorHost {
this.editor = null;
}
/**
* goal : Replace any active editor with a newly configured editor instance.
* post : The previous editor is destroyed; an optional model is loaded before onCreated runs.
* result : The active editor, or null when no factory is configured.
*/
create(options = null, model = null) {
this.destroy();
if (!this.factory) return null;
@@ -23,6 +28,7 @@ export class CmapEditorHost {
return this.editor;
}
/** Destroy the active editor once and release the host's reference to it. */
destroy() {
if (!this.editor) return;
this.editor.destroy();
@@ -17,6 +17,10 @@ export class CmapEditorUiController {
this.listeners.push(() => element.removeEventListener(event, listener));
}
/**
* Bind every standard CMap toolbar action once.
* result : This controller, so the host may retain the initialized instance.
*/
initialize() {
this.bind("#cmap-save-map", "click", () => this.actions.save?.());
this.bind("#cmap-undo", "click", (_event, editor) => editor?.undo());
@@ -47,6 +51,7 @@ export class CmapEditorUiController {
return this;
}
/** Remove every listener installed by initialize before the host is disposed. */
destroy() {
for (const remove of this.listeners) remove();
this.listeners = [];
@@ -29,6 +29,11 @@ export class CmapMapController {
this.refreshPageConnections = refreshPageConnections;
}
/**
* goal : Refresh the CMap catalogue and its lightweight global reference index.
* post : Workspace CMap lists, selector and usage indicators reflect the repository.
* result : The current list of stored CMaps.
*/
async loadMaps() {
const [conceptMaps, placements] = await Promise.all([
this.repository.list(),
@@ -65,6 +70,11 @@ export class CmapMapController {
this.renderSelector();
}
/**
* goal : Open a stored CMap or its source map when it is a derived submap view.
* pre : slug identifies a stored CMap; a later request supersedes this load safely.
* post : Active map state, editor document and saved snapshot are synchronized.
*/
async open(slug) {
if (!slug) return;
const loadSequence = ++this.state.cmapLoadSequence;
@@ -103,6 +113,7 @@ export class CmapMapController {
this.status(this.translate("concept-map-loaded", "CMap loaded"), true);
}
/** Restore the source submap context for a stored derived CMap. */
openDerivedView(conceptMap, derivedView) {
const editor = this.getEditor();
const root = editor.itemRecord(derivedView.rootItemId);
@@ -124,6 +135,10 @@ export class CmapMapController {
editor.openSubmapMap(root);
}
/**
* goal : Load one immutable CMap version for review or subsequent explicit save.
* post : The editor shows the historical model while the current map remains dirty.
*/
async loadHistoricalVersion(version) {
const conceptMap = this.getCurrentStorageMap();
if (!conceptMap) return;
@@ -145,6 +160,11 @@ export class CmapMapController {
location.hash = this.cmapRoute(conceptMap.slug);
}
/**
* goal : Extract the selected inline submap into a complete stored CMap.
* post : Parent and child map are saved consistently; cross-boundary relations remain on the parent.
* result : Whether extraction completed or was deliberately cancelled.
*/
async promoteSelectedSubmap() {
const editor = this.getEditor();
const record = editor ? editor.selected() : null;
@@ -263,6 +283,11 @@ export class CmapMapController {
{ tags: [], summary: "", explanationPageSlug: "" };
}
/**
* goal : Persist map metadata through either the source editor or a derived map model.
* post : Namespace, tags, summary and explanation-page reference are stored together.
* result : False when there is no editable map or saving fails.
*/
async saveMetadata(metadata) {
const conceptMap = this.state.currentConceptMap;
const editor = this.getEditor();
@@ -290,6 +315,11 @@ export class CmapMapController {
return true;
}
/**
* goal : Archive the active CMap after explicit title confirmation.
* post : Active map state is cleared and navigation continues to another CMap or overview.
* result : False when confirmation is cancelled, mismatched or persistence fails.
*/
async archive() {
this.cancelAutosave();
const conceptMap = this.state.currentConceptMap;
@@ -324,6 +354,7 @@ export class CmapMapController {
}
}
/** Create an explicit, named immutable snapshot of the active stored CMap. */
async createSnapshot() {
if (!this.getCurrentStorageMap()) return false;
const description = window.prompt(this.translate("snapshot-description", "Snapshot description"), "");
@@ -14,10 +14,12 @@ export class CmapNavigationController {
this.location = locationObject;
}
/** Open a stored CMap through the central hash-navigation contract. */
open(slug) {
return this.navigateToHash(this.cmapRoute(slug));
}
/** Remember the active separate-submap root before following a linked CMap. */
openLinked(record) {
if (!record?.cmapSlug) return false;
this.rememberContext();
@@ -25,6 +27,7 @@ export class CmapNavigationController {
return true;
}
/** Return to a source CMap when present, otherwise leave one inline submap level. */
openParent() {
const source = this.getParentMap();
if (source?.slug) {
@@ -39,6 +42,7 @@ export class CmapNavigationController {
return this.storage.requestTransition(action);
}
/** Store the active separate-submap context in the current browser history entry. */
rememberContext() {
const editor = this.getEditor();
const current = this.getCurrentMap();
@@ -50,6 +54,10 @@ export class CmapNavigationController {
} }, "", this.location.href);
}
/**
* Restore a remembered separate-submap root after browser navigation.
* post : A consumed context is removed from history state to prevent repeated restoration.
*/
async restoreContext(slug) {
const context = this.history.state?.cmapContext;
if (!context || context.mapSlug !== slug) return false;
@@ -49,6 +49,10 @@ export class CmapStorageController {
}
}
/**
* goal : Queue one delayed autosave for the current dirty CMap.
* post : Any preceding autosave timer is replaced; non-editable or clean maps are not queued.
*/
scheduleAutosave() {
this.cancelAutosave();
if (!this.canEdit() || !this.getCurrentMap() || !this.hasUnsavedChanges()) return;
@@ -59,6 +63,10 @@ export class CmapStorageController {
}, 1500);
}
/**
* goal : Guard a route or view transition while unsaved CMap changes exist.
* result : True only when the action ran after saving, discarding or finding no changes.
*/
async requestTransition(action) {
if (!this.hasUnsavedChanges()) {
await action();
@@ -93,6 +101,11 @@ export class CmapStorageController {
if (this.savePromise) await this.savePromise;
}
/**
* goal : Persist the active editor model and its derived SVG render in one ordered workflow.
* post : A successful save updates the current map, saved snapshot and CMap catalogue.
* result : False for cancellation, unavailable editor or persistence failure.
*/
async save({ automatic = false, force = false, summary = null,
snapshotVersion = false, historyMode = null } = {}) {
const editor = this.getEditor();
@@ -12,6 +12,7 @@ export class CmapTransferController {
});
}
/** Save pending changes, then create the requested readable Markdown export. */
async exportMarkdown(options) {
const map = this.getCurrentMap();
if (!map) throw new Error(this.translate("cmap-export-unavailable", "CMap export is unavailable."));
@@ -19,6 +20,7 @@ export class CmapTransferController {
return this.markdownExporter.export(map, options);
}
/** Save pending changes, then create the requested portable JSON bundle. */
async exportJson(depth) {
const map = this.getCurrentMap();
if (!map) throw new Error(this.translate("cmap-json-unavailable", "CMap JSON export is unavailable."));
@@ -26,6 +28,11 @@ export class CmapTransferController {
return this.jsonExporter.export(map, depth);
}
/**
* goal : Import a validated CMap bundle after presenting replacement conflicts.
* post : Page and CMap catalogues are refreshed and navigation opens the imported root CMap.
* result : The importer summary.
*/
async importFile(file) {
const bundle = await this.jsonImporter.read(file);
await this.saveBeforeTransfer();
@@ -292,6 +292,19 @@ export class CmapWorkspaceController {
return text.length > 150 ? `${text.slice(0, 147)}` : text;
}
function showCmapDescriptionTooltip(button) {
const item = button.closest("[data-rw-cmap-item-id]");
const editor = cmapPrototypeState().editor;
const record = item && editor ? editor.itemRecord(Number(item.dataset.rwCmapItemId)) : null;
if (record?.descriptionPageSlug) {
cmapDescriptionPreview.show(button, record.descriptionPageSlug, tr("loading", "Loading…"));
}
}
function hideCmapDescriptionTooltip() {
cmapDescriptionPreview.hide();
}
function addCmapPrototypeNode(options = {}) {
return conceptController.addNode(options);
}