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;