diff --git a/static/index.html b/static/index.html
index 7ece7ab..124d4f5 100644
--- a/static/index.html
+++ b/static/index.html
@@ -249,6 +249,7 @@
+
diff --git a/static/js/wiki/cmap/cmap-editor-host.js b/static/js/wiki/cmap/cmap-editor-host.js
new file mode 100644
index 0000000..7753eca
--- /dev/null
+++ b/static/js/wiki/cmap/cmap-editor-host.js
@@ -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;
+ }
+}
diff --git a/static/js/wiki/cmap/cmap-editor-presentation.js b/static/js/wiki/cmap/cmap-editor-presentation.js
index 8f44576..8fce9af 100644
--- a/static/js/wiki/cmap/cmap-editor-presentation.js
+++ b/static/js/wiki/cmap/cmap-editor-presentation.js
@@ -1,6 +1,7 @@
"use strict";
import { escapeHtml } from "../../cmap/cmap-utils.js";
+import { splitPageReference } from "../reference.js";
/** Render wiki-specific HTML content for CMap editor items. */
export class CmapEditorPresentation {
@@ -37,7 +38,7 @@ export class CmapEditorPresentation {
`(${usageCount})`;
const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || "");
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 linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
@@ -63,4 +64,13 @@ export class CmapEditorPresentation {
const current = typeof entry === "number" ? entry : Number(entry?.count) || 0;
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();
+ }
}
diff --git a/static/js/wiki/cmap/cmap-editor-ui-controller.js b/static/js/wiki/cmap/cmap-editor-ui-controller.js
new file mode 100644
index 0000000..23916f4
--- /dev/null
+++ b/static/js/wiki/cmap/cmap-editor-ui-controller.js
@@ -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 = [];
+ }
+}
diff --git a/static/js/wiki/cmap/cmap-navigation-controller.js b/static/js/wiki/cmap/cmap-navigation-controller.js
new file mode 100644
index 0000000..9ff583a
--- /dev/null
+++ b/static/js/wiki/cmap/cmap-navigation-controller.js
@@ -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;
+ }
+}
diff --git a/static/js/wiki/cmap/cmap-storage-controller.js b/static/js/wiki/cmap/cmap-storage-controller.js
new file mode 100644
index 0000000..dde64db
--- /dev/null
+++ b/static/js/wiki/cmap/cmap-storage-controller.js
@@ -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();
+ }
+}
diff --git a/static/js/wiki/cmap/cmap-transfer-controller.js b/static/js/wiki/cmap/cmap-transfer-controller.js
new file mode 100644
index 0000000..08e40f2
--- /dev/null
+++ b/static/js/wiki/cmap/cmap-transfer-controller.js
@@ -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;
+ }
+}
diff --git a/static/js/wiki/cmap/cmap-workspace-controller.js b/static/js/wiki/cmap/cmap-workspace-controller.js
index 50bd313..f6e0a9d 100644
--- a/static/js/wiki/cmap/cmap-workspace-controller.js
+++ b/static/js/wiki/cmap/cmap-workspace-controller.js
@@ -25,6 +25,11 @@ import { CmapHistoryDialog } from "./dialogs/history-dialog.js";
import { CmapMetadataDialog } from "./dialogs/metadata-dialog.js";
import { CmapPeopleDialog } from "./dialogs/people-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 { CmapEditorPresentation } from "./cmap-editor-presentation.js";
@@ -117,10 +122,84 @@ export class CmapWorkspaceController {
const exportDialog = new CmapExportDialog(
$("cmap-export-dialog"), tr, buildCurrentCmapMarkdownExport, buildCurrentCmapJsonExport);
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;
const CMAP_AUTOSAVE_DELAY = 1500;
@@ -211,45 +290,19 @@ export class CmapWorkspaceController {
}
function openLinkedCmap(record) {
- if (!record || !record.cmapSlug) return false;
- rememberActiveCmapContext();
- navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
- return true;
+ return navigation.openLinked(record);
}
function rememberActiveCmapContext() {
- const prototype = cmapPrototypeState();
- 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);
+ return navigation.rememberContext();
}
async function restoreActiveCmapContext(slug) {
- const context = history.state?.cmapContext;
- 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;
+ return navigation.restoreContext(slug);
}
function openParentCmap() {
- const source = state.currentConceptMapSource;
- 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());
+ return navigation.openParent();
}
function titledCmapComboboxEntry(record) {
@@ -441,6 +494,68 @@ export class CmapWorkspaceController {
actionButtons.get("group").disabled = !editor || !editor.canGroupSelection();
actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection();
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 = {}) {
@@ -623,10 +738,7 @@ export class CmapWorkspaceController {
closeCmapContextMenu();
$("cmap-map-navigation").classList.add("hidden");
$("cmap-active-map-title").textContent = "";
- if (state.cmapPrototype && state.cmapPrototype.editor &&
- typeof state.cmapPrototype.editor.destroy === "function") {
- state.cmapPrototype.editor.destroy();
- }
+ editorHost.destroy();
canvas.replaceChildren();
state.cmapPrototype = null;
updateCmapSelectionToolbar(null, [], null);
@@ -648,7 +760,7 @@ export class CmapWorkspaceController {
}
const prototype = cmapPrototypeState();
- prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
+ prototype.editor = editorHost.create({
renderItem: (record) => cmapNodeHtml(record),
boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
state.currentConceptMap?.title || "",
@@ -658,7 +770,9 @@ export class CmapWorkspaceController {
state.currentConceptMap?.model?.metadata()?.namespace || "";
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
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;
if (isDescriptionReference && target !== record.pageSlug) {
record.pageSlug = target;
@@ -855,9 +969,7 @@ export class CmapWorkspaceController {
}
function currentCmapSnapshot() {
- const prototype = cmapPrototypeState();
- if (!prototype.editor) return null;
- return JSON.stringify(prototype.editor.toDocument());
+ return storage.currentSnapshot();
}
function currentCmapRenderedSvg() {
@@ -876,37 +988,20 @@ export class CmapWorkspaceController {
}
function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) {
+ storage.markSaved(snapshot);
state.cmapSavedSnapshot = snapshot;
}
function cmapHasUnsavedChanges() {
- if ($("cmap-view").classList.contains("hidden")) return false;
- 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;
+ return storage.hasUnsavedChanges();
}
function cancelCmapAutosave() {
- if (cmapAutosaveTimer !== null) {
- window.clearTimeout(cmapAutosaveTimer);
- cmapAutosaveTimer = null;
- }
+ storage.cancelAutosave();
}
function scheduleCmapAutosave() {
- cancelCmapAutosave();
- 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);
+ storage.scheduleAutosave();
}
function updateStoredConceptMapSummary(conceptMap) {
@@ -922,29 +1017,7 @@ export class CmapWorkspaceController {
}
async function requestCmapTransition(action) {
- if (!cmapHasUnsavedChanges()) {
- 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;
- }
+ return navigation.requestTransition(action);
}
async function openStoredConceptMap(slug) {
@@ -1192,14 +1265,7 @@ export class CmapWorkspaceController {
}
async function buildCurrentCmapMarkdownExport({ depth, includeWikiPages }) {
- if (!state.currentConceptMap) {
- 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, {
+ return transfer.exportMarkdown({
maxDepth: depth,
includeWikiPages,
language: state.language
@@ -1207,43 +1273,11 @@ export class CmapWorkspaceController {
}
async function buildCurrentCmapJsonExport(depth) {
- if (!state.currentConceptMap) {
- 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);
+ return transfer.exportJson(depth);
}
async function importCmapBundleFile(file) {
- const bundle = await jsonImporter.read(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;
+ return transfer.importFile(file);
}
async function handleCmapImportFile(file) {
@@ -1266,7 +1300,7 @@ export class CmapWorkspaceController {
async function deleteStoredConceptMap() {
cancelCmapAutosave();
- if (cmapSavePromise) await cmapSavePromise;
+ await storage.waitForSave();
const conceptMap = state.currentConceptMap;
if (!conceptMap) return false;
const question = tr(
@@ -1304,85 +1338,7 @@ export class CmapWorkspaceController {
async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false,
historyMode = null } = {}) {
- const prototype = cmapPrototypeState();
- 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;
+ return storage.save({ automatic, force, summary, snapshotVersion, historyMode });
}
async function createConceptMapSnapshot() {
@@ -1479,7 +1435,6 @@ export class CmapWorkspaceController {
showCmapStatus(error.message);
});
});
- $("cmap-save-map").addEventListener("click", () => saveStoredConceptMap());
$("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap());
$("cmap-edit-metadata").addEventListener("click", openCmapMetadataDialog);
$("cmap-export-markdown").addEventListener("click", openCmapExportDialog);
@@ -1491,6 +1446,10 @@ export class CmapWorkspaceController {
$("cmap-manage-people").addEventListener("click", () => {
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-create-snapshot").addEventListener("click", () => {
createConceptMapSnapshot().catch((error) => showCmapStatus(error.message));
@@ -1521,83 +1480,10 @@ export class CmapWorkspaceController {
});
});
}
- $("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode());
$("cmap-cut-selected").addEventListener("click", () => {
const prototype = cmapPrototypeState();
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", () => {
if (state.currentConceptMap) {
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
@@ -1649,10 +1535,6 @@ export class CmapWorkspaceController {
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) => {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (file) handleCmapImportFile(file);
@@ -1662,7 +1544,7 @@ export class CmapWorkspaceController {
!$("cmap-view").classList.contains("hidden")) {
event.preventDefault();
if (can("editor")) {
- if (pendingCmapTransition) {
+ if (storage.hasPendingTransition()) {
unsavedDialog.chooseSave();
return;
}
@@ -1674,6 +1556,8 @@ export class CmapWorkspaceController {
handleCmapKeyboardShortcut(event);
});
+ editorUi.initialize();
+
/**
* Load persistent CMap preferences and the data needed by the workspace.
*/
@@ -1702,6 +1586,9 @@ export class CmapWorkspaceController {
this.normalizeExternalUrl = normalizedCmapExternalUrl;
this.clearDescriptionPreviews = () => cmapDescriptionPreview.clear();
this.requestTransition = requestCmapTransition;
+ this.navigation = navigation;
+ this.transfer = transfer;
+ this.storage = storage;
this.show = showCmapPrototype;
this.hasUnsavedChanges = cmapHasUnsavedChanges;
this.startSlug = startCmapSlug;