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
+174 -287
View File
@@ -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;