verdere refactoring

This commit is contained in:
2026-09-03 17:03:20 +02:00
parent bd1ef6bed0
commit 3684c5ddbf
8 changed files with 534 additions and 288 deletions
+1
View File
@@ -249,6 +249,7 @@
<button id="cmap-export-markdown" type="button" role="menuitem" data-tr="export-markdown" disabled>Export CMap…</button> <button id="cmap-export-markdown" type="button" role="menuitem" data-tr="export-markdown" disabled>Export CMap…</button>
<button id="cmap-import-json" class="editor-only" type="button" role="menuitem" data-tr="import-cmap-json">Import CMap JSON…</button> <button id="cmap-import-json" class="editor-only" type="button" role="menuitem" data-tr="import-cmap-json">Import CMap JSON…</button>
<button id="cmap-manage-people" class="editor-only" type="button" role="menuitem" data-tr="manage-people">Manage people</button> <button id="cmap-manage-people" class="editor-only" type="button" role="menuitem" data-tr="manage-people">Manage people</button>
<button id="cmap-move-selected-to-namespace" class="editor-only" type="button" role="menuitem" data-tr="move-selected-to-cmap-namespace" disabled>Move selected descriptions to diagram namespace</button>
<button id="cmap-set-start-map" type="button" role="menuitem" data-tr="set-start-concept-map" disabled>Use as start CMap</button> <button id="cmap-set-start-map" type="button" role="menuitem" data-tr="set-start-concept-map" disabled>Use as start CMap</button>
<button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button> <button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button>
<button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button> <button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button>
+32
View File
@@ -0,0 +1,32 @@
"use strict";
/** Own creation, loading and destruction of the wiki's active CMap editor. */
export class CmapEditorHost {
constructor({ canvas, factory, createOptions = null, getModel = null, onCreated = null, onDestroyed = null }) {
this.canvas = canvas;
this.factory = factory;
this.createOptions = createOptions;
this.getModel = getModel;
this.onCreated = onCreated;
this.onDestroyed = onDestroyed;
this.editor = null;
}
create(options = null, model = null) {
this.destroy();
if (!this.factory) return null;
const editorOptions = options ?? this.createOptions?.() ?? {};
this.editor = this.factory(this.canvas, editorOptions);
const loadedModel = model || this.getModel?.();
if (loadedModel) this.editor.loadModel(loadedModel);
if (this.onCreated) this.onCreated(this.editor);
return this.editor;
}
destroy() {
if (!this.editor) return;
this.editor.destroy();
if (this.onDestroyed) this.onDestroyed(this.editor);
this.editor = null;
}
}
@@ -1,6 +1,7 @@
"use strict"; "use strict";
import { escapeHtml } from "../../cmap/cmap-utils.js"; import { escapeHtml } from "../../cmap/cmap-utils.js";
import { splitPageReference } from "../reference.js";
/** Render wiki-specific HTML content for CMap editor items. */ /** Render wiki-specific HTML content for CMap editor items. */
export class CmapEditorPresentation { export class CmapEditorPresentation {
@@ -37,7 +38,7 @@ export class CmapEditorPresentation {
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`; `<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || ""); const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference && const descriptionExists = Boolean(descriptionReference &&
this.state.pages.some((page) => page.slug === descriptionReference)); this.state.pages.some((page) => this.pageMatchesReference(page, descriptionReference)));
const descriptionButton = record.descriptionPageSlug ? const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(this.translate("view-concept-description", "View concept description"))}">I</button>` : ""; `<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(this.translate("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink; const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
@@ -63,4 +64,13 @@ export class CmapEditorPresentation {
const current = typeof entry === "number" ? entry : Number(entry?.count) || 0; const current = typeof entry === "number" ? entry : Number(entry?.count) || 0;
return Math.max(1, storedTotal - current + (Number(record.usageCount) || 1)); return Math.max(1, storedTotal - current + (Number(record.usageCount) || 1));
} }
pageMatchesReference(page, reference) {
if (page.slug === reference) return true;
const parts = splitPageReference(reference);
return String(page.namespace || "").toLocaleLowerCase() ===
parts.namespace.toLocaleLowerCase() &&
String(page.pageSlug || page.slug || "").toLocaleLowerCase() ===
parts.slug.toLocaleLowerCase();
}
} }
@@ -0,0 +1,55 @@
"use strict";
/** Connect CMap toolbar actions to an editor and application callbacks. */
export class CmapEditorUiController {
constructor({ root, getEditor, actions = {} }) {
this.root = root;
this.getEditor = getEditor;
this.actions = actions;
this.listeners = [];
}
bind(selector, event, handler) {
const element = this.root.querySelector(selector);
if (!element) return;
const listener = (eventObject) => handler(eventObject, this.getEditor());
element.addEventListener(event, listener);
this.listeners.push(() => element.removeEventListener(event, listener));
}
initialize() {
this.bind("#cmap-save-map", "click", () => this.actions.save?.());
this.bind("#cmap-undo", "click", (_event, editor) => editor?.undo());
this.bind("#cmap-redo", "click", (_event, editor) => editor?.redo());
this.bind("#cmap-select-all", "click", (_event, editor) => editor?.selectAll());
this.bind("#cmap-delete-selected", "click", (_event, editor) => editor?.deleteSelection());
this.bind("#cmap-edit-selected", "click", () => this.actions.edit?.());
this.bind("#cmap-copy-selected", "click", (_event, editor) => editor?.copySelectionReferences());
this.bind("#cmap-cut-selected", "click", (_event, editor) => editor?.cutSelectionReferences());
this.bind("#cmap-paste-concepts", "click", (_event, editor) => editor?.pasteConceptReferences());
this.bind("#cmap-group-selected", "click", () => this.actions.group?.());
this.bind("#cmap-ungroup-selected", "click", (_event, editor) => editor?.ungroupSelection());
this.bind("#cmap-hide-selected", "click", (_event, editor) => editor?.hideSelectionInCurrentContext());
this.bind("#cmap-select-all", "click", (_event, editor) => editor?.selectAll());
this.bind("#cmap-zoom-out", "click", () => this.actions.zoom?.(-10));
this.bind("#cmap-zoom-in", "click", () => this.actions.zoom?.(10));
this.bind("#cmap-zoom-reset", "click", () => this.actions.zoom?.(100, true));
this.bind("#cmap-zoom-percent", "change", (event) => this.actions.zoom?.(event.target.value, true));
this.bind("#cmap-toggle-page-guides", "click", () => this.actions.togglePageGuides?.());
this.bind("#cmap-selection-toolbar", "click", (event, editor) => {
const button = event.target.closest("button");
if (!button || button.disabled || !editor) return;
if (button.dataset.cmapLayout) {
editor.applySelectionLayout(button.dataset.cmapLayout);
} else {
this.actions.selection?.(button.dataset.cmapSelectionAction, editor);
}
});
return this;
}
destroy() {
for (const remove of this.listeners) remove();
this.listeners = [];
}
}
@@ -0,0 +1,62 @@
"use strict";
/** Coordinate CMap routes, parent contexts and browser-history state. */
export class CmapNavigationController {
constructor({ navigateToHash, cmapRoute, getEditor, getCurrentMap,
getParentMap, storage, historyObject = window.history, locationObject = window.location }) {
this.navigateToHash = navigateToHash;
this.cmapRoute = cmapRoute;
this.getEditor = getEditor;
this.getCurrentMap = getCurrentMap;
this.getParentMap = getParentMap;
this.storage = storage;
this.history = historyObject;
this.location = locationObject;
}
open(slug) {
return this.navigateToHash(this.cmapRoute(slug));
}
openLinked(record) {
if (!record?.cmapSlug) return false;
this.rememberContext();
this.open(record.cmapSlug).catch((error) => console.error(error));
return true;
}
openParent() {
const source = this.getParentMap();
if (source?.slug) {
this.open(source.slug).catch((error) => console.error(error));
return true;
}
const editor = this.getEditor();
return Boolean(editor?.openParentMap());
}
requestTransition(action) {
return this.storage.requestTransition(action);
}
rememberContext() {
const editor = this.getEditor();
const current = this.getCurrentMap();
const root = editor?.activeMapRoot;
if (!current?.slug || !root) return;
this.history.replaceState({ ...this.history.state, cmapContext: {
mapSlug: current.slug,
rootItemId: Number(root.id)
} }, "", this.location.href);
}
async restoreContext(slug) {
const context = this.history.state?.cmapContext;
if (!context || context.mapSlug !== slug) return false;
const editor = this.getEditor();
const root = editor?.itemRecord(context.rootItemId);
if (root?.kind === "submap" && root.separateMap) editor.openSubmapMap(root);
this.history.replaceState({ ...this.history.state, cmapContext: null }, "", this.location.href);
return true;
}
}
@@ -0,0 +1,155 @@
"use strict";
/** Coordinate CMap persistence, dirty state, autosave and transitions. */
export class CmapStorageController {
constructor({ repository, getEditor, getCurrentMap, getViewVisible, renderSvg,
reloadMaps, status, translate, unsavedDialog, canEdit, onMapSaved }) {
this.repository = repository;
this.getEditor = getEditor;
this.getCurrentMap = getCurrentMap;
this.getViewVisible = getViewVisible;
this.renderSvg = renderSvg;
this.reloadMaps = reloadMaps;
this.status = status;
this.translate = translate;
this.unsavedDialog = unsavedDialog;
this.canEdit = canEdit;
this.onMapSaved = onMapSaved;
this.savedSnapshot = null;
this.autosaveTimer = null;
this.savePromise = null;
this.pendingTransition = null;
}
currentSnapshot() {
const editor = this.getEditor();
return editor ? JSON.stringify(editor.toDocument()) : null;
}
markSaved(snapshot = this.currentSnapshot()) {
this.savedSnapshot = snapshot;
}
hasUnsavedChanges() {
if (!this.getViewVisible()) return false;
const current = this.currentSnapshot();
if (current === null || this.savedSnapshot === null || current === this.savedSnapshot) return false;
const editor = this.getEditor();
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
this.savedSnapshot = current;
return false;
}
return true;
}
cancelAutosave() {
if (this.autosaveTimer !== null) {
window.clearTimeout(this.autosaveTimer);
this.autosaveTimer = null;
}
}
scheduleAutosave() {
this.cancelAutosave();
if (!this.canEdit() || !this.getCurrentMap() || !this.hasUnsavedChanges()) return;
this.status(this.translate("autosave-pending", "Changes waiting to be saved"));
this.autosaveTimer = window.setTimeout(() => {
this.autosaveTimer = null;
this.save({ automatic: true }).catch((error) => console.error(error));
}, 1500);
}
async requestTransition(action) {
if (!this.hasUnsavedChanges()) {
await action();
return true;
}
if (this.pendingTransition) return false;
const transition = (async () => {
const choice = await this.unsavedDialog.choose();
if (choice === "save" && !await this.save()) return false;
if (choice === "discard") this.markSaved();
if (choice === "cancel") return false;
await action();
return true;
})();
this.pendingTransition = transition;
try {
return await transition;
} finally {
if (this.pendingTransition === transition) this.pendingTransition = null;
}
}
hasPendingTransition() {
return this.pendingTransition !== null;
}
hasSaveInProgress() {
return this.savePromise !== null;
}
async waitForSave() {
if (this.savePromise) await this.savePromise;
}
async save({ automatic = false, force = false, summary = null,
snapshotVersion = false, historyMode = null } = {}) {
const editor = this.getEditor();
if (!editor) return false;
this.cancelAutosave();
if (automatic && !this.getCurrentMap()) return false;
const effectiveHistoryMode = historyMode ||
(snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual"));
if (this.savePromise) {
const succeeded = await this.savePromise;
if (!succeeded) return false;
if (force || this.hasUnsavedChanges() || effectiveHistoryMode !== "autosave") {
return this.save({ automatic, force, summary, snapshotVersion, historyMode });
}
return true;
}
const snapshot = this.currentSnapshot();
if (snapshot === null) return false;
if (!force && snapshot === this.savedSnapshot && effectiveHistoryMode === "autosave") return true;
const model = editor.currentModel();
const renderedSvg = typeof this.renderSvg === "function" ? this.renderSvg() : "";
const storedMap = this.getCurrentMap();
let succeeded = false;
this.status(this.translate("saving", "Saving…"));
this.savePromise = (async () => {
try {
if (!storedMap) {
const title = window.prompt(this.translate("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return false;
const created = await this.repository.create(title.trim(), model, null, { renderedSvg });
if (this.onMapSaved) this.onMapSaved(created);
} else {
const saved = await this.repository.save(storedMap, model, {
summary: summary || this.translate("manual-save", "Manual save"),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode,
renderedSvg
});
if (this.onMapSaved) this.onMapSaved(saved);
}
this.markSaved(snapshot);
await this.reloadMaps();
succeeded = true;
return true;
} catch (error) {
this.status(error.message);
return false;
}
})().finally(() => {
this.savePromise = null;
if (succeeded && this.hasUnsavedChanges()) this.scheduleAutosave();
});
return this.savePromise;
}
destroy() {
this.cancelAutosave();
}
}
@@ -0,0 +1,44 @@
"use strict";
/** Coordinate CMap import and export workflows without owning their formats. */
export class CmapTransferController {
constructor({ repository, jsonImporter, jsonExporter, markdownExporter,
getCurrentMap, getPages, getConceptMaps, loadPages, loadConceptMaps,
navigateToMap, saveBeforeTransfer, confirm, status, translate }) {
Object.assign(this, {
repository, jsonImporter, jsonExporter, markdownExporter, getCurrentMap,
getPages, getConceptMaps, loadPages, loadConceptMaps, navigateToMap,
saveBeforeTransfer, confirm, status, translate
});
}
async exportMarkdown(options) {
const map = this.getCurrentMap();
if (!map) throw new Error(this.translate("cmap-export-unavailable", "CMap export is unavailable."));
await this.saveBeforeTransfer();
return this.markdownExporter.export(map, options);
}
async exportJson(depth) {
const map = this.getCurrentMap();
if (!map) throw new Error(this.translate("cmap-json-unavailable", "CMap JSON export is unavailable."));
await this.saveBeforeTransfer();
return this.jsonExporter.export(map, depth);
}
async importFile(file) {
const bundle = await this.jsonImporter.read(file);
await this.saveBeforeTransfer();
const conflicts = this.jsonImporter.conflicts(bundle, this.getPages(), this.getConceptMaps());
const replaceExisting = (conflicts.pages.length || conflicts.conceptMaps.length) && this.confirm(
this.translate("cmap-import-conflicts", "The import contains existing CMaps or pages. Replace them?"));
const result = await this.jsonImporter.import(bundle, {
pages: this.getPages(), conceptMaps: this.getConceptMaps(), replaceExisting,
summary: this.translate("imported-from-cmap-json", "Imported from CMap JSON")
});
await this.loadPages();
await this.loadConceptMaps();
await this.navigateToMap(bundle.rootCmapSlug);
return result;
}
}
+174 -287
View File
@@ -25,6 +25,11 @@ import { CmapHistoryDialog } from "./dialogs/history-dialog.js";
import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js"; import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js";
import { CmapPeopleDialog } from "./dialogs/people-dialog.js"; import { CmapPeopleDialog } from "./dialogs/people-dialog.js";
import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js"; import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
import { CmapStorageController } from "./cmap-storage-controller.js";
import { CmapNavigationController } from "./cmap-navigation-controller.js";
import { CmapTransferController } from "./cmap-transfer-controller.js";
import { CmapEditorUiController } from "./cmap-editor-ui-controller.js";
import { CmapEditorHost } from "./cmap-editor-host.js";
import { CmapEmbedView } from "./cmap-embed-view.js"; import { CmapEmbedView } from "./cmap-embed-view.js";
import { CmapEditorPresentation } from "./cmap-editor-presentation.js"; import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
@@ -117,10 +122,84 @@ export class CmapWorkspaceController {
const exportDialog = new CmapExportDialog( const exportDialog = new CmapExportDialog(
$("cmap-export-dialog"), tr, buildCurrentCmapMarkdownExport, buildCurrentCmapJsonExport); $("cmap-export-dialog"), tr, buildCurrentCmapMarkdownExport, buildCurrentCmapJsonExport);
const unsavedDialog = new CmapUnsavedDialog($("cmap-unsaved-dialog")); const unsavedDialog = new CmapUnsavedDialog($("cmap-unsaved-dialog"));
const storage = new CmapStorageController({
repository: cmapRepository,
getEditor: () => cmapPrototypeState().editor,
getCurrentMap: currentCmapStorageMap,
getViewVisible: () => !$("cmap-view").classList.contains("hidden"),
renderSvg: currentCmapRenderedSvg,
reloadMaps: loadConceptMaps,
status: showCmapStatus,
translate: tr,
unsavedDialog,
canEdit: () => can("editor"),
onMapSaved: (saved) => {
if (!state.currentConceptMap && !state.currentConceptMapSource) {
state.currentConceptMap = saved;
const savedRoute = cmapRoute(saved.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
} else if (state.currentConceptMapSource?.slug === saved.slug) {
state.currentConceptMapSource = saved;
} else if (state.currentConceptMap?.slug === saved.slug) {
state.currentConceptMap = saved;
}
updateStoredConceptMapSummary(saved);
}
});
const navigation = new CmapNavigationController({
navigateToHash,
cmapRoute,
getEditor: () => cmapPrototypeState().editor,
getCurrentMap: currentCmapStorageMap,
getParentMap: () => state.currentConceptMapSource,
storage
});
const transfer = new CmapTransferController({
repository: cmapRepository,
jsonImporter,
jsonExporter,
markdownExporter,
getCurrentMap: () => state.currentConceptMap,
getPages: () => state.pages,
getConceptMaps: () => state.conceptMaps,
loadPages,
loadConceptMaps,
navigateToMap: (slug) => navigation.open(slug),
saveBeforeTransfer: () => cmapHasUnsavedChanges() ?
saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }) : true,
confirm: (message) => window.confirm(message),
status: showCmapStatus,
translate: tr
});
const editorUi = new CmapEditorUiController({
root: document,
getEditor: () => cmapPrototypeState().editor,
actions: {
save: () => saveStoredConceptMap(),
edit: () => editSelectedCmapNode(),
group: () => groupSelectedCmapItems(),
zoom: (value, absolute = false) => setCmapZoom(
absolute ? Number(value) : Number($("cmap-zoom-percent").value) + value),
togglePageGuides: () => {
const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true";
setCmapPageGuides(visible);
settingsRepository.setPageGuidesVisible(visible).catch((error) => showCmapStatus(error.message));
},
selection: (action, editor) => {
if (action === "edit") editSelectedCmapNode();
if (action === "group") groupSelectedCmapItems();
if (action === "ungroup") editor.ungroupSelection();
if (action === "hide") editor.hideSelectionInCurrentContext();
}
}
});
const editorHost = new CmapEditorHost({
canvas: $("cmap-canvas"),
factory: (canvas, options) => window.RacketWikiCmap.createEditor(canvas, options),
createOptions: () => ({})
});
let pendingCmapTransition = null;
let cmapAutosaveTimer = null;
let cmapSavePromise = null;
let cmapEmbedHydrationTimer = null; let cmapEmbedHydrationTimer = null;
const CMAP_AUTOSAVE_DELAY = 1500; const CMAP_AUTOSAVE_DELAY = 1500;
@@ -211,45 +290,19 @@ export class CmapWorkspaceController {
} }
function openLinkedCmap(record) { function openLinkedCmap(record) {
if (!record || !record.cmapSlug) return false; return navigation.openLinked(record);
rememberActiveCmapContext();
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
return true;
} }
function rememberActiveCmapContext() { function rememberActiveCmapContext() {
const prototype = cmapPrototypeState(); return navigation.rememberContext();
const editor = prototype.editor;
const mapSlug = state.currentConceptMap?.slug;
const root = editor?.activeMapRoot;
if (!mapSlug || !root) return;
const context = {
mapSlug,
rootItemId: Number(root.id)
};
history.replaceState({ ...history.state, cmapContext: context }, "", location.href);
} }
async function restoreActiveCmapContext(slug) { async function restoreActiveCmapContext(slug) {
const context = history.state?.cmapContext; return navigation.restoreContext(slug);
if (!context || context.mapSlug !== slug) return false;
const prototype = cmapPrototypeState();
const root = prototype.editor?.itemRecord(context.rootItemId);
if (root && root.kind === "submap" && root.separateMap) {
prototype.editor.openSubmapMap(root);
}
history.replaceState({ ...history.state, cmapContext: null }, "", location.href);
return true;
} }
function openParentCmap() { function openParentCmap() {
const source = state.currentConceptMapSource; return navigation.openParent();
if (source && source.slug) {
navigateToHash(cmapRoute(source.slug)).catch((error) => console.error(error));
return true;
}
const prototype = cmapPrototypeState();
return Boolean(prototype.editor && prototype.editor.openParentMap());
} }
function titledCmapComboboxEntry(record) { function titledCmapComboboxEntry(record) {
@@ -441,6 +494,68 @@ export class CmapWorkspaceController {
actionButtons.get("group").disabled = !editor || !editor.canGroupSelection(); actionButtons.get("group").disabled = !editor || !editor.canGroupSelection();
actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection(); actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection();
actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext(); actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext();
const namespaceButton = $("cmap-move-selected-to-namespace");
namespaceButton.disabled = !editor || !diagramNamespace() ||
!selected.some((item) => item.kind !== "phrase");
}
function diagramNamespace() {
const model = state.currentConceptMapSource?.model || state.currentConceptMap?.model;
return model?.metadata()?.namespace || "";
}
function pageMatchesReference(page, reference) {
if (page.slug === reference) return true;
const parts = splitPageReference(reference);
return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() &&
String(page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase() ===
parts.slug.toLocaleLowerCase();
}
async function moveSelectedDescriptionsToNamespace() {
const editor = cmapPrototypeState().editor;
const namespace = diagramNamespace();
if (!editor || !namespace) return false;
const records = editor.selectedAll().filter((record) => record.kind !== "phrase");
if (!records.length) return false;
const changes = [];
for (const record of records) {
const current = record.descriptionPageSlug;
if (!current) continue;
const parts = splitPageReference(current);
const target = pageReference(namespace, parts.slug);
if (target === current) continue;
const existing = state.pages.find((page) => pageMatchesReference(page, current));
const conflict = state.pages.find((page) => pageMatchesReference(page, target));
if (existing && conflict && existing !== conflict) {
showCmapStatus(tr("namespace-move-conflict", "Cannot move {page}: the target page already exists.")
.replace("{page}", target));
return false;
}
changes.push({ record, current, target, existing });
}
if (!changes.length) return false;
for (const change of changes) {
if (change.existing) {
const page = await api(`/api/pages/${encodeURIComponent(change.current)}/rename`, {
method: "POST",
body: JSON.stringify({
title: change.existing.title,
namespace,
slug: splitPageReference(change.current).slug,
summary: tr("move-description-page-summary", "Moved description page to CMap namespace")
})
});
change.target = page.slug;
}
editor.updateItem(change.record, { descriptionPageSlug: change.target });
}
await loadPages();
editor.commitHistory();
if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true });
showCmapStatus(tr("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.")
.replace("{namespace}", namespace), true);
return true;
} }
function openCmapContextMenu(clientX, clientY, createContext = {}) { function openCmapContextMenu(clientX, clientY, createContext = {}) {
@@ -623,10 +738,7 @@ export class CmapWorkspaceController {
closeCmapContextMenu(); closeCmapContextMenu();
$("cmap-map-navigation").classList.add("hidden"); $("cmap-map-navigation").classList.add("hidden");
$("cmap-active-map-title").textContent = ""; $("cmap-active-map-title").textContent = "";
if (state.cmapPrototype && state.cmapPrototype.editor && editorHost.destroy();
typeof state.cmapPrototype.editor.destroy === "function") {
state.cmapPrototype.editor.destroy();
}
canvas.replaceChildren(); canvas.replaceChildren();
state.cmapPrototype = null; state.cmapPrototype = null;
updateCmapSelectionToolbar(null, [], null); updateCmapSelectionToolbar(null, [], null);
@@ -648,7 +760,7 @@ export class CmapWorkspaceController {
} }
const prototype = cmapPrototypeState(); const prototype = cmapPrototypeState();
prototype.editor = window.RacketWikiCmap.createEditor(canvas, { prototype.editor = editorHost.create({
renderItem: (record) => cmapNodeHtml(record), renderItem: (record) => cmapNodeHtml(record),
boundaryReferenceMapTitle: state.currentConceptMapSource?.title || boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
state.currentConceptMap?.title || "", state.currentConceptMap?.title || "",
@@ -658,7 +770,9 @@ export class CmapWorkspaceController {
state.currentConceptMap?.model?.metadata()?.namespace || ""; state.currentConceptMap?.model?.metadata()?.namespace || "";
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug; const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null; const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null;
const target = namespace && page?.namespace === "cmap" ? const existingPage = state.pages.some((pageRecord) =>
pageRecord.slug === record.pageSlug);
const target = !existingPage && namespace && page?.namespace === "cmap" ?
pageReference(namespace, page.slug) : record.pageSlug; pageReference(namespace, page.slug) : record.pageSlug;
if (isDescriptionReference && target !== record.pageSlug) { if (isDescriptionReference && target !== record.pageSlug) {
record.pageSlug = target; record.pageSlug = target;
@@ -855,9 +969,7 @@ export class CmapWorkspaceController {
} }
function currentCmapSnapshot() { function currentCmapSnapshot() {
const prototype = cmapPrototypeState(); return storage.currentSnapshot();
if (!prototype.editor) return null;
return JSON.stringify(prototype.editor.toDocument());
} }
function currentCmapRenderedSvg() { function currentCmapRenderedSvg() {
@@ -876,37 +988,20 @@ export class CmapWorkspaceController {
} }
function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) { function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) {
storage.markSaved(snapshot);
state.cmapSavedSnapshot = snapshot; state.cmapSavedSnapshot = snapshot;
} }
function cmapHasUnsavedChanges() { function cmapHasUnsavedChanges() {
if ($("cmap-view").classList.contains("hidden")) return false; return storage.hasUnsavedChanges();
const currentSnapshot = currentCmapSnapshot();
if (currentSnapshot === null || state.cmapSavedSnapshot === null) return false;
if (currentSnapshot === state.cmapSavedSnapshot) return false;
const editor = cmapPrototypeState().editor;
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
state.cmapSavedSnapshot = currentSnapshot;
return false;
}
return true;
} }
function cancelCmapAutosave() { function cancelCmapAutosave() {
if (cmapAutosaveTimer !== null) { storage.cancelAutosave();
window.clearTimeout(cmapAutosaveTimer);
cmapAutosaveTimer = null;
}
} }
function scheduleCmapAutosave() { function scheduleCmapAutosave() {
cancelCmapAutosave(); storage.scheduleAutosave();
if (!can("editor") || !state.currentConceptMap || !cmapHasUnsavedChanges()) return;
showCmapStatus(tr("autosave-pending", "Changes waiting to be saved"));
cmapAutosaveTimer = window.setTimeout(() => {
cmapAutosaveTimer = null;
saveStoredConceptMap({ automatic: true }).catch((error) => console.error(error));
}, CMAP_AUTOSAVE_DELAY);
} }
function updateStoredConceptMapSummary(conceptMap) { function updateStoredConceptMapSummary(conceptMap) {
@@ -922,29 +1017,7 @@ export class CmapWorkspaceController {
} }
async function requestCmapTransition(action) { async function requestCmapTransition(action) {
if (!cmapHasUnsavedChanges()) { return navigation.requestTransition(action);
await action();
return true;
}
if (pendingCmapTransition) return false;
const transition = (async () => {
const choice = await unsavedDialog.choose();
if (choice === "save" && !await saveStoredConceptMap()) return false;
if (choice === "discard") markCurrentCmapSaved();
if (choice === "cancel") {
renderConceptMapSelector();
return false;
}
await action();
return true;
})();
pendingCmapTransition = transition;
try {
return await transition;
} finally {
if (pendingCmapTransition === transition) pendingCmapTransition = null;
}
} }
async function openStoredConceptMap(slug) { async function openStoredConceptMap(slug) {
@@ -1192,14 +1265,7 @@ export class CmapWorkspaceController {
} }
async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) { async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) {
if (!state.currentConceptMap) { return transfer.exportMarkdown({
throw new Error(tr("cmap-export-unavailable", "CMap export is unavailable."));
}
if (cmapHasUnsavedChanges() && can("editor")) {
const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" });
if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export."));
}
return markdownExporter.export(state.currentConceptMap, {
maxDepth: depth, maxDepth: depth,
includeWikiPages, includeWikiPages,
language: state.language language: state.language
@@ -1207,43 +1273,11 @@ export class CmapWorkspaceController {
} }
async function buildCurrentCmapJsonExport(depth) { async function buildCurrentCmapJsonExport(depth) {
if (!state.currentConceptMap) { return transfer.exportJson(depth);
throw new Error(tr("cmap-json-unavailable", "CMap JSON export is unavailable."));
}
if (cmapHasUnsavedChanges() && can("editor")) {
const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" });
if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export."));
}
return jsonExporter.export(state.currentConceptMap, depth);
} }
async function importCmapBundleFile(file) { async function importCmapBundleFile(file) {
const bundle = await jsonImporter.read(file); return transfer.importFile(file);
if (cmapHasUnsavedChanges()) {
const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" });
if (!saved) throw new Error(tr("save-before-import-failed", "The current CMap could not be saved before import."));
}
const conflicts = jsonImporter.conflicts(bundle, state.pages, state.conceptMaps);
let replaceExisting = false;
if (conflicts.pages.length || conflicts.conceptMaps.length) {
replaceExisting = window.confirm(tr(
"cmap-import-conflicts",
"The import contains {maps} existing CMaps and {pages} existing pages. Choose OK to replace them with the imported content, or Cancel to keep them and import only new records.")
.replace("{maps}", conflicts.conceptMaps.length)
.replace("{pages}", conflicts.pages.length));
}
const result = await jsonImporter.import(bundle, {
pages: state.pages,
conceptMaps: state.conceptMaps,
replaceExisting,
summary: tr("imported-from-cmap-json", "Imported from CMap JSON")
});
await loadPages();
await loadConceptMaps();
await navigateToHash(cmapRoute(bundle.rootCmapSlug));
return result;
} }
async function handleCmapImportFile(file) { async function handleCmapImportFile(file) {
@@ -1266,7 +1300,7 @@ export class CmapWorkspaceController {
async function deleteStoredConceptMap() { async function deleteStoredConceptMap() {
cancelCmapAutosave(); cancelCmapAutosave();
if (cmapSavePromise) await cmapSavePromise; await storage.waitForSave();
const conceptMap = state.currentConceptMap; const conceptMap = state.currentConceptMap;
if (!conceptMap) return false; if (!conceptMap) return false;
const question = tr( const question = tr(
@@ -1304,85 +1338,7 @@ export class CmapWorkspaceController {
async function saveStoredConceptMap({ automatic = false, force = false, async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false, summary = null, snapshotVersion = false,
historyMode = null } = {}) { historyMode = null } = {}) {
const prototype = cmapPrototypeState(); return storage.save({ automatic, force, summary, snapshotVersion, historyMode });
if (!prototype.editor) return false;
cancelCmapAutosave();
if (automatic && !currentCmapStorageMap()) return false;
const effectiveHistoryMode = historyMode ||
(snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual"));
if (cmapSavePromise) {
const firstSaveSucceeded = await cmapSavePromise;
if (!firstSaveSucceeded) return false;
if (force || cmapHasUnsavedChanges() || effectiveHistoryMode !== "autosave") {
return saveStoredConceptMap({ automatic, force, summary, snapshotVersion, historyMode });
}
return true;
}
const snapshot = currentCmapSnapshot();
if (snapshot === null) return false;
if (!force && snapshot === state.cmapSavedSnapshot && effectiveHistoryMode === "autosave") {
return true;
}
const model = prototype.editor.currentModel();
const renderedSvg = currentCmapRenderedSvg();
const conceptMapAtStart = currentCmapStorageMap();
let saveSucceeded = false;
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")));
cmapSavePromise = (async () => {
try {
if (!conceptMapAtStart) {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) {
showCmapStatus("");
return false;
}
state.currentConceptMap = await cmapRepository.create(title.trim(), model, null, { renderedSvg });
const savedRoute = cmapRoute(state.currentConceptMap.slug);
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
state.cmapGuardHash = savedRoute;
await loadConceptMaps();
} else {
const savedConceptMap = await cmapRepository.save(
conceptMapAtStart,
model,
{
summary: summary || (automatic ?
tr("automatic-save", "Automatic save") :
tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode,
renderedSvg
});
if (state.currentConceptMapSource &&
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
state.currentConceptMapSource = savedConceptMap;
updateStoredConceptMapSummary(savedConceptMap);
} else if (state.currentConceptMap &&
state.currentConceptMap.slug === conceptMapAtStart.slug) {
state.currentConceptMap = savedConceptMap;
updateStoredConceptMapSummary(savedConceptMap);
}
}
markCurrentCmapSaved(snapshot);
await loadConceptMaps();
showCmapStatus(
snapshotVersion ? tr("snapshot-created", "Snapshot created") :
(automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")),
true);
saveSucceeded = true;
return true;
} catch (error) {
showCmapStatus(error.message);
return false;
}
})().finally(() => {
cmapSavePromise = null;
if (saveSucceeded && cmapHasUnsavedChanges()) scheduleCmapAutosave();
});
return cmapSavePromise;
} }
async function createConceptMapSnapshot() { async function createConceptMapSnapshot() {
@@ -1479,7 +1435,6 @@ export class CmapWorkspaceController {
showCmapStatus(error.message); showCmapStatus(error.message);
}); });
}); });
$("cmap-save-map").addEventListener("click", () => saveStoredConceptMap());
$("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap()); $("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap());
$("cmap-edit-metadata").addEventListener("click", openCmapMetadataDialog); $("cmap-edit-metadata").addEventListener("click", openCmapMetadataDialog);
$("cmap-export-markdown").addEventListener("click", openCmapExportDialog); $("cmap-export-markdown").addEventListener("click", openCmapExportDialog);
@@ -1491,6 +1446,10 @@ export class CmapWorkspaceController {
$("cmap-manage-people").addEventListener("click", () => { $("cmap-manage-people").addEventListener("click", () => {
peopleDialog.open().catch((error) => showCmapStatus(error.message)); peopleDialog.open().catch((error) => showCmapStatus(error.message));
}); });
$("cmap-move-selected-to-namespace").addEventListener("click", () => {
moveSelectedDescriptionsToNamespace()
.catch((error) => showCmapStatus(error.message));
});
$("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap()); $("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap());
$("cmap-create-snapshot").addEventListener("click", () => { $("cmap-create-snapshot").addEventListener("click", () => {
createConceptMapSnapshot().catch((error) => showCmapStatus(error.message)); createConceptMapSnapshot().catch((error) => showCmapStatus(error.message));
@@ -1521,83 +1480,10 @@ export class CmapWorkspaceController {
}); });
}); });
} }
$("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode());
$("cmap-cut-selected").addEventListener("click", () => { $("cmap-cut-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState(); const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.cutSelectionReferences(); if (prototype.editor) prototype.editor.cutSelectionReferences();
}); });
$("cmap-copy-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.copySelectionReferences();
});
$("cmap-paste-concepts").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.pasteConceptReferences();
});
$("cmap-undo").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.undo();
});
$("cmap-redo").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.redo();
});
$("cmap-select-all").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.selectAll();
});
$("cmap-group-selected").addEventListener("click", () => {
groupSelectedCmapItems();
});
$("cmap-ungroup-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.ungroupSelection();
});
$("cmap-hide-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.hideSelectionInCurrentContext();
});
$("cmap-delete-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.deleteSelection();
});
$("cmap-selection-toolbar").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button || button.disabled) return;
const prototype = cmapPrototypeState();
const editor = prototype.editor;
if (!editor) return;
const layoutCommand = button.dataset.cmapLayout;
if (layoutCommand) {
editor.applySelectionLayout(layoutCommand);
return;
}
switch (button.dataset.cmapSelectionAction) {
case "edit":
editSelectedCmapNode();
break;
case "group":
groupSelectedCmapItems();
break;
case "ungroup":
editor.ungroupSelection();
break;
case "hide":
editor.hideSelectionInCurrentContext();
break;
default:
break;
}
});
$("cmap-toggle-page-guides").addEventListener("click", () => {
const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true";
setCmapPageGuides(visible);
settingsRepository.setPageGuidesVisible(visible)
.catch((error) => {
setCmapPageGuides(settingsRepository.pageGuidesVisible);
showCmapStatus(error.message);
});
});
$("cmap-reset").addEventListener("click", () => { $("cmap-reset").addEventListener("click", () => {
if (state.currentConceptMap) { if (state.currentConceptMap) {
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug)) requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
@@ -1649,10 +1535,6 @@ export class CmapWorkspaceController {
parentSubmap: prototype.editor.submapAtPoint(point) parentSubmap: prototype.editor.submapAtPoint(point)
}); });
}); });
$("cmap-zoom-out").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) - 10));
$("cmap-zoom-in").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) + 10));
$("cmap-zoom-reset").addEventListener("click", () => setCmapZoom(100));
$("cmap-zoom-percent").addEventListener("change", (event) => setCmapZoom(event.target.value));
$("cmap-import-file").addEventListener("change", (event) => { $("cmap-import-file").addEventListener("change", (event) => {
const file = event.currentTarget.files && event.currentTarget.files[0]; const file = event.currentTarget.files && event.currentTarget.files[0];
if (file) handleCmapImportFile(file); if (file) handleCmapImportFile(file);
@@ -1662,7 +1544,7 @@ export class CmapWorkspaceController {
!$("cmap-view").classList.contains("hidden")) { !$("cmap-view").classList.contains("hidden")) {
event.preventDefault(); event.preventDefault();
if (can("editor")) { if (can("editor")) {
if (pendingCmapTransition) { if (storage.hasPendingTransition()) {
unsavedDialog.chooseSave(); unsavedDialog.chooseSave();
return; return;
} }
@@ -1674,6 +1556,8 @@ export class CmapWorkspaceController {
handleCmapKeyboardShortcut(event); handleCmapKeyboardShortcut(event);
}); });
editorUi.initialize();
/** /**
* Load persistent CMap preferences and the data needed by the workspace. * Load persistent CMap preferences and the data needed by the workspace.
*/ */
@@ -1702,6 +1586,9 @@ export class CmapWorkspaceController {
this.normalizeExternalUrl = normalizedCmapExternalUrl; this.normalizeExternalUrl = normalizedCmapExternalUrl;
this.clearDescriptionPreviews = () => cmapDescriptionPreview.clear(); this.clearDescriptionPreviews = () => cmapDescriptionPreview.clear();
this.requestTransition = requestCmapTransition; this.requestTransition = requestCmapTransition;
this.navigation = navigation;
this.transfer = transfer;
this.storage = storage;
this.show = showCmapPrototype; this.show = showCmapPrototype;
this.hasUnsavedChanges = cmapHasUnsavedChanges; this.hasUnsavedChanges = cmapHasUnsavedChanges;
this.startSlug = startCmapSlug; this.startSlug = startCmapSlug;