refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
@@ -0,0 +1,66 @@
"use strict";
import { escapeHtml } from "../../cmap/cmap-utils.js";
/** Render wiki-specific HTML content for CMap editor items. */
export class CmapEditorPresentation {
constructor({ state, translate, canonicalPageReference, referenceIndex }) {
this.state = state;
this.translate = translate;
this.canonicalPageReference = canonicalPageReference;
this.referenceIndex = referenceIndex;
}
renderItem(record) {
const safeLabel = escapeHtml(record.label || "");
const safeSynopsis = escapeHtml(record.synopsis || "");
const safeKind = escapeHtml(record.kind || "concept");
const safeImageSource = escapeHtml(record.imageSource || "");
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
.join("");
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
const personNames = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => String(tag.value));
const peopleLabel = personNames.length ?
this.translate("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
const personTags = personNames.length ?
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">&#128100;</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
const usageCount = record.conceptId && record.kind !== "phrase" ?
this.conceptUsage(record) : null;
const usageLabel = usageCount === null ? "" :
this.translate("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(usageCount));
const usage = usageCount === null ? "" :
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = this.canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
this.state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(this.translate("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(this.translate("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
const externalButton = record.externalUrl ?
`<button type="button" class="rw-cmap-open-external" aria-label="${escapeHtml(this.translate("open-external-web-page", "Open external web page"))}">↗</button>` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
}
conceptUsage(record) {
const maps = this.referenceIndex.byConceptId.get(record.conceptId);
const storedTotal = maps ? Array.from(maps.values()).reduce((sum, entry) =>
sum + (typeof entry === "number" ? entry : Number(entry.count) || 0), 0) : 0;
const editor = this.state.cmapPrototype?.editor;
if (!editor || !editor.containsItemRecord(record)) {
return Math.max(1, storedTotal || Number(record.usageCount) || 1);
}
const currentSlug = this.state.currentConceptMapSource?.slug ||
this.state.currentConceptMap?.slug || null;
const entry = currentSlug && maps ? maps.get(currentSlug) : null;
const current = typeof entry === "number" ? entry : Number(entry?.count) || 0;
return Math.max(1, storedTotal - current + (Number(record.usageCount) || 1));
}
}
+58
View File
@@ -0,0 +1,58 @@
"use strict";
/** Render stored CMap snapshots as linked images inside wiki pages. */
export class CmapEmbedView {
constructor({ repository, conceptMaps, resolveReference, route, translate }) {
this.repository = repository;
this.conceptMaps = conceptMaps;
this.resolveReference = resolveReference;
this.route = route;
this.translate = translate;
}
async render(root) {
const embeds = Array.from(root.querySelectorAll(
".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) await this.renderOne(embed);
}
async renderOne(embed) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = this.resolveReference(
embed.dataset.cmapReference || "", this.conceptMaps());
if (!conceptMap) {
this.showError(embed, this.translate("concept-map-not-found", "CMap not found"));
return;
}
try {
const stored = await this.repository.load(conceptMap.slug);
if (!stored.renderedSvg) {
throw new Error("This embedded CMap has no rendered image yet.");
}
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
const link = document.createElement("a");
link.href = this.route(conceptMap.slug);
link.className = "rw-cmap-embed-link";
link.title = this.translate("embedded-concept-map-help", "Open this CMap.");
const image = document.createElement("img");
image.className = "rw-cmap-embed-image";
image.alt = conceptMap.title;
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stored.renderedSvg)}`;
link.append(image);
embed.append(link);
embed.dataset.cmapHydrated = "ready";
} catch (error) {
this.showError(embed, error.message);
}
}
showError(embed, message) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const node = document.createElement("p");
node.className = "error";
node.textContent = message;
embed.append(node);
}
}
@@ -1,17 +1,20 @@
import {
newPageReference
newPageReference,
pageReference,
splitPageReference
} from "../reference.js";
import { cmapRoute, pageRoute } from "../routes.js";
import { cmapMentionTarget, escapeHtml } from "../markdown.js";
import { CmapModel } from "../../../cmap/model/concept-map.js";
import { CmapAppearanceRepository } from "../../../cmap/model/appearance-repository.js";
import { CmapRepository } from "../../../cmap/model/cmap-repository.js";
import { CmapJsonExporter } from "../../../cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../../../cmap/model/json-importer.js";
import { CmapMarkdownExporter } from "../../../cmap/model/markdown-exporter.js";
import { CmapSettingsRepository } from "../../../cmap/model/settings-repository.js";
import { PeopleRepository } from "../../../cmap/model/people-repository.js";
import { CmapAppearanceEditor } from "../../../cmap/view/appearance-editor.js";
import { CmapModel } from "../../cmap/model/concept-map.js";
import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js";
import { CmapRepository } from "../../cmap/model/cmap-repository.js";
import { CmapJsonExporter } from "../../cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../../cmap/model/json-importer.js";
import { CmapMarkdownExporter } from "../../cmap/model/markdown-exporter.js";
import { CmapSettingsRepository } from "../../cmap/model/settings-repository.js";
import { CmapReferenceIndex } from "../../cmap/model/cmap-reference-index.js";
import { PeopleRepository } from "../../cmap/model/people-repository.js";
import { CmapAppearanceEditor } from "../../cmap/view/appearance-editor.js";
import { ComboBox } from "../../widgets/combobox.js";
import { DescriptionPreview } from "../../widgets/description-preview.js";
import { PopupMenu } from "../../widgets/popup-menu.js";
@@ -22,6 +25,8 @@ 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 { CmapEmbedView } from "./cmap-embed-view.js";
import { CmapEditorPresentation } from "./cmap-editor-presentation.js";
/**
* Own the complete browser-side CMap workspace.
@@ -30,7 +35,7 @@ import { CmapUnsavedDialog } from "./dialogs/unsaved-dialog.js";
* persistence, history and browser event handling. The wiki application
* supplies only the concrete services needed to cross the module boundary.
*/
export class CmapWorkspace {
export class CmapWorkspaceController {
constructor(
state,
api,
@@ -59,6 +64,20 @@ export class CmapWorkspace {
const cmapRepository = new CmapRepository(api);
const appearanceRepository = new CmapAppearanceRepository(api);
const settingsRepository = new CmapSettingsRepository(api);
const cmapReferenceIndex = new CmapReferenceIndex();
const presentation = new CmapEditorPresentation({
state,
translate: tr,
canonicalPageReference,
referenceIndex: cmapReferenceIndex
});
const embedView = new CmapEmbedView({
repository: cmapRepository,
conceptMaps: () => state.conceptMaps,
resolveReference: cmapMentionTarget,
route: cmapRoute,
translate: tr
});
const peopleRepository = new PeopleRepository(api);
let appearanceEditor = null;
let conceptDialog = null;
@@ -110,92 +129,10 @@ export class CmapWorkspace {
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
cmapEmbedHydrationTimer = window.setTimeout(() => {
cmapEmbedHydrationTimer = null;
hydrateCmapEmbeds(document).catch((error) => console.error(error));
embedView.render(document).catch((error) => console.error(error));
}, 0);
}
/** Render all pending CMap embeds below root as read-only CMap editors. */
async function hydrateCmapEmbeds(root) {
const embeds = Array.from(root.querySelectorAll(
".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = cmapMentionTarget(
embed.dataset.cmapReference || "",
state.conceptMaps);
if (!conceptMap) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = tr("concept-map-not-found", "CMap not found");
embed.append(message);
continue;
}
try {
const stored = await cmapRepository.load(conceptMap.slug);
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
const header = document.createElement("header");
const title = document.createElement("strong");
title.textContent = conceptMap.title;
const hint = document.createElement("span");
hint.textContent = tr(
"embedded-concept-map-help", "Double-click to open this CMap.");
header.append(title, hint);
const viewport = document.createElement("div");
viewport.className = "rw-cmap-embed-viewport";
const canvas = document.createElement("div");
canvas.className =
"cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
viewport.append(canvas);
embed.append(header, viewport);
const editor = window.RacketWikiCmap.createEditor(canvas, {
renderItem: (record) => cmapNodeHtml(record),
onOpenExternalUrl: (record) => openCmapExternalUrl(record)
});
editor.loadModel(stored.model);
const visible = editor.visibleItemRecords();
if (visible.length) {
const left = Math.min(...visible.map((item) =>
Number(item.node.attr("x")))) - 24;
const top = Math.min(...visible.map((item) =>
Number(item.node.attr("y")))) - 24;
const right = Math.max(...visible.map((item) =>
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
const bottom = Math.max(...visible.map((item) =>
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
const availableWidth = Math.max(320, embed.clientWidth - 2);
const scale = Math.min(
1,
availableWidth / Math.max(1, right - left),
520 / Math.max(1, bottom - top));
editor.zoomFactor = scale;
editor.map.zoom(scale);
viewport.style.height =
`${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
window.requestAnimationFrame(() => {
viewport.scrollLeft = Math.max(0, left * scale);
viewport.scrollTop = Math.max(0, top * scale);
});
}
embed.dataset.cmapHydrated = "ready";
embed.addEventListener("dblclick", () => {
navigateToHash(cmapRoute(conceptMap.slug))
.catch((error) => console.error(error));
});
} catch (error) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = error.message;
embed.append(message);
}
}
}
function startCmapSlug() {
return settingsRepository.startCmapSlug;
}
@@ -222,83 +159,14 @@ export class CmapWorkspace {
return state.cmapPrototype;
}
/**
* goal : Render one concept card inside an ionstage/cmap node.
* pre : record contains label, synopsis and kind.
* result : Safe HTML used as the node's content.
*/
function cmapNodeHtml(record) {
const safeLabel = escapeHtml(record.label || "");
const safeSynopsis = escapeHtml(record.synopsis || "");
const safeKind = escapeHtml(record.kind || "concept");
const safeImageSource = escapeHtml(record.imageSource || "");
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
const aspects = (Array.isArray(record.aspects) ? record.aspects : [])
.map((aspect) => `<span class="cmap-card-aspect">${escapeHtml(aspect)}</span>`)
.join("");
const aspectList = aspects ? `<div class="cmap-card-aspects">${aspects}</div>` : "";
const personNames = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => String(tag.value));
const peopleLabel = personNames.length ?
tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : "";
const personTags = personNames.length ?
`<div class="cmap-card-people" title="${escapeHtml(peopleLabel)}"><span aria-hidden="true">&#128100;</span> ${personNames.map(escapeHtml).join(" · ")}</div>` : "";
const usageCount = record.conceptId && record.kind !== "phrase" ?
effectiveCmapConceptUsage(record) : null;
const usageLabel = usageCount === null ? "" :
tr("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(usageCount));
const usage = usageCount === null ? "" :
`<span class="cmap-card-usage" title="${escapeHtml(usageLabel)}">(${usageCount})</span>`;
const descriptionReference = canonicalPageReference(record.descriptionPageSlug || "");
const descriptionExists = Boolean(descriptionReference &&
state.pages.some((page) => page.slug === descriptionReference));
const descriptionButton = record.descriptionPageSlug ?
`<button type="button" class="rw-cmap-view-description ${descriptionExists ? "is-filled" : "is-empty"}" aria-label="${escapeHtml(tr("view-concept-description", "View concept description"))}">I</button>` : "";
const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink;
const linkedButton = linkedTarget && record.kind !== "submap" ?
`<button type="button" class="rw-cmap-open-linked" title="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}" aria-label="${escapeHtml(tr("open-linked-item", "Open linked page or CMap"))}">↗</button>` : "";
const externalButton = record.externalUrl ?
`<button type="button" class="rw-cmap-open-external" title="${escapeHtml(tr("open-external-web-page", "Open external web page"))}" aria-label="${escapeHtml(tr("open-external-web-page", "Open external web page"))}">↗</button>` : "";
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${descriptionButton}${linkedButton}${externalButton}${image}<div class="cmap-card-title">${safeLabel} ${usage}</div>${aspectList}${personTags}${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
return presentation.renderItem(record);
}
function effectiveCmapConceptUsage(record) {
const byMap = state.cmapConceptUsage.get(record.conceptId);
const storedTotal = byMap ? Array.from(byMap.values()).reduce((sum, count) => sum + count, 0) : 0;
const prototype = cmapPrototypeState();
const isActiveEditorRecord = Boolean(
prototype.editor && prototype.editor.containsItemRecord(record));
if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1);
const currentSlug = state.currentConceptMapSource?.slug ||
state.currentConceptMap?.slug || null;
const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0;
return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1));
return presentation.conceptUsage(record);
}
function hideCmapDescriptionTooltip() {
cmapDescriptionPreview.hide();
}
async function showCmapDescriptionTooltip(button) {
if (!button.classList.contains("is-filled")) return;
const itemElement = button.closest("[data-rw-cmap-item-id]");
const prototype = cmapPrototypeState();
const record = itemElement && prototype.editor ?
prototype.editor.itemRecord(itemElement.dataset.rwCmapItemId) : null;
if (!record || !record.descriptionPageSlug) return;
const reference = canonicalPageReference(record.descriptionPageSlug);
await cmapDescriptionPreview.show(button, reference, tr("loading", "Loading…"));
}
/**
* goal : Derive a compact synopsis from the currently opened wiki page.
* pre : state.currentPage may be #f/null when no page is open.
* result : Plain text of at most about 150 characters.
*/
function currentPageSynopsis() {
if (!state.currentPage) return "";
const container = document.createElement("div");
@@ -307,12 +175,6 @@ export class CmapWorkspace {
return text.length > 150 ? `${text.slice(0, 147)}` : text;
}
/**
* goal : Add a concept-like item to the active CMap prototype.
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
* post : The item is draggable, selectable, resizable and linkable.
* result : The item record created by the interaction layer.
*/
function addCmapPrototypeNode(options = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return null;
@@ -350,10 +212,36 @@ export class CmapWorkspace {
function openLinkedCmap(record) {
if (!record || !record.cmapSlug) return false;
rememberActiveCmapContext();
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
return true;
}
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);
}
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;
}
function openParentCmap() {
const source = state.currentConceptMapSource;
if (source && source.slug) {
@@ -375,7 +263,10 @@ export class CmapWorkspace {
function currentConceptDialogResources() {
const prototype = cmapPrototypeState();
const namespaceModel = state.currentConceptMapSource?.model ||
state.currentConceptMap?.model;
return {
cmapNamespace: namespaceModel?.metadata()?.namespace || "",
pages: state.pages,
conceptMaps: state.conceptMaps,
parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot)
@@ -745,7 +636,7 @@ export class CmapWorkspace {
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
cmapStylesheet: Array.from(document.styleSheets)
.map((sheet) => sheet.href)
.find((href) => href && href.includes("/cmap/cmap.css")) || null
.find((href) => href && href.includes("/js/cmap/cmap.css")) || null
});
if (!window.RacketWikiCmap) {
@@ -759,9 +650,23 @@ export class CmapWorkspace {
const prototype = cmapPrototypeState();
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
renderItem: (record) => cmapNodeHtml(record),
boundaryReferenceMapTitle: state.currentConceptMapSource?.title ||
state.currentConceptMap?.title || "",
onOpenPage: (record) => {
if (record.pageSlug) {
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
const namespace = state.currentConceptMapSource?.model?.metadata()?.namespace ||
state.currentConceptMap?.model?.metadata()?.namespace || "";
const isDescriptionReference = record.descriptionPageSlug === record.pageSlug;
const page = isDescriptionReference ? splitPageReference(record.pageSlug) : null;
const target = namespace && page?.namespace === "cmap" ?
pageReference(namespace, page.slug) : record.pageSlug;
if (isDescriptionReference && target !== record.pageSlug) {
record.pageSlug = target;
}
if (!state.pages.some((pageRecord) => pageRecord.slug === target)) {
state.newPageSuggestedTitle = record.label || "";
}
navigateToHash(pageRoute(target)).catch((error) => console.error(error));
}
},
onOpenCmap: (record) => {
@@ -934,48 +839,14 @@ export class CmapWorkspace {
})
]);
state.conceptMaps = conceptMaps;
state.cmapConceptUsage = new Map();
state.cmapConceptIdsByName = new Map();
state.cmapPageConcepts = new Map();
const pageByConcept = new Map();
for (const placement of placements) {
if (!placement?.conceptId || !placement.pageSlug) continue;
pageByConcept.set(
placement.conceptId,
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
}
for (const placement of placements) {
if (!placement || !placement.conceptId || !placement.cmapSlug) continue;
const nameKey = normalizeCmapConceptName(placement.label);
if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId);
if (!state.cmapConceptUsage.has(placement.conceptId)) {
state.cmapConceptUsage.set(placement.conceptId, new Map());
}
state.cmapConceptUsage.get(placement.conceptId)
.set(placement.cmapSlug, Number(placement.count) || 0);
const pageKey = pageByConcept.get(placement.conceptId);
if (!pageKey) continue;
if (!state.cmapPageConcepts.has(pageKey)) state.cmapPageConcepts.set(pageKey, new Map());
const pageConcepts = state.cmapPageConcepts.get(pageKey);
if (!pageConcepts.has(placement.conceptId)) {
pageConcepts.set(placement.conceptId, {
conceptId: placement.conceptId,
label: placement.label || tr("concept", "Concept"),
count: 0,
maps: new Map()
});
}
const concept = pageConcepts.get(placement.conceptId);
const count = Number(placement.count) || 0;
concept.count += count;
const existingMap = concept.maps.get(placement.cmapSlug);
concept.maps.set(placement.cmapSlug, {
slug: placement.cmapSlug,
title: placement.cmapTitle || placement.cmapSlug,
count: count + (existingMap?.count || 0)
});
}
cmapReferenceIndex.load(
placements,
conceptMaps,
canonicalPageReference,
normalizeCmapConceptName);
state.cmapConceptUsage = cmapReferenceIndex.byConceptId;
state.cmapConceptIdsByName = cmapReferenceIndex.byName;
state.cmapPageConcepts = cmapReferenceIndex.byPage;
renderConceptMapSelector();
const prototype = cmapPrototypeState();
if (prototype.editor) prototype.editor.refreshConceptUsageIndicators();
@@ -989,6 +860,17 @@ export class CmapWorkspace {
return JSON.stringify(prototype.editor.toDocument());
}
function currentCmapRenderedSvg() {
const editor = cmapPrototypeState().editor;
if (!editor || typeof editor.snapshotSvg !== "function") return "";
try {
return editor.snapshotSvg();
} catch (error) {
console.warn("The CMap SVG snapshot could not be created.", error);
return "";
}
}
function currentCmapStorageMap() {
return state.currentConceptMapSource || state.currentConceptMap;
}
@@ -1000,8 +882,14 @@ export class CmapWorkspace {
function cmapHasUnsavedChanges() {
if ($("cmap-view").classList.contains("hidden")) return false;
const currentSnapshot = currentCmapSnapshot();
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
currentSnapshot !== state.cmapSavedSnapshot;
if (currentSnapshot === null || state.cmapSavedSnapshot === null) return false;
if (currentSnapshot === state.cmapSavedSnapshot) return false;
const editor = cmapPrototypeState().editor;
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
state.cmapSavedSnapshot = currentSnapshot;
return false;
}
return true;
}
function cancelCmapAutosave() {
@@ -1249,6 +1137,7 @@ export class CmapWorkspace {
if (currentModel?.derivedView()) {
const metadata = currentModel.metadata();
return {
namespace: metadata.namespace || "",
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
summary: metadata.summary || "",
explanationPageSlug: metadata.explanationPageSlug || ""
@@ -1269,6 +1158,13 @@ export class CmapWorkspace {
const conceptMap = state.currentConceptMap;
const editor = cmapPrototypeState().editor;
if (!conceptMap || !editor) return false;
const legacyExplanation = `cmap:${conceptMap.slug}`;
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
metadata = {
...metadata,
explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}`
};
}
if (conceptMap.model.derivedView()) {
const updatedModel = conceptMap.model.withMetadata(metadata);
@@ -1430,6 +1326,7 @@ export class CmapWorkspace {
}
const model = prototype.editor.currentModel();
const renderedSvg = currentCmapRenderedSvg();
const conceptMapAtStart = currentCmapStorageMap();
let saveSucceeded = false;
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
@@ -1442,7 +1339,7 @@ export class CmapWorkspace {
showCmapStatus("");
return false;
}
state.currentConceptMap = await cmapRepository.create(title.trim(), model);
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;
@@ -1456,7 +1353,8 @@ export class CmapWorkspace {
tr("automatic-save", "Automatic save") :
tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode
saveKind: effectiveHistoryMode,
renderedSvg
});
if (state.currentConceptMapSource &&
state.currentConceptMapSource.slug === conceptMapAtStart.slug) {
@@ -1510,6 +1408,7 @@ export class CmapWorkspace {
await loadConceptMaps();
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
await openStoredConceptMap(requestedSlug);
if (await restoreActiveCmapContext(requestedSlug)) markCurrentCmapSaved();
} else if (requestedSlug) {
state.currentConceptMap = null;
state.currentConceptMapSource = null;
@@ -46,6 +46,7 @@ export class CmapConceptDialog {
open(record, resources) {
if (!record || record.kind === "phrase") return;
this.record = record;
this.resources = resources;
this.createContext = null;
this.imageSource = record.imageSource || "";
this.imageRead = Promise.resolve();
@@ -72,6 +73,7 @@ export class CmapConceptDialog {
/** Open the dialog for a new concept at the supplied editor context. */
openNew(createContext, resources) {
this.record = null;
this.resources = resources;
this.createContext = createContext;
this.imageSource = "";
this.imageRead = Promise.resolve();
@@ -208,7 +210,8 @@ export class CmapConceptDialog {
const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page");
const descriptionText = descriptionInput.value.trim();
const descriptionPage = descriptionText ? newPageReference(descriptionText) :
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
pageReference(this.resources?.cmapNamespace || "cmap",
splitPageReference(newPageReference(label) || "concept").slug);
if (!descriptionPage) {
this.tabs.select("content");
descriptionInput.setCustomValidity(
@@ -11,6 +11,7 @@ export class CmapMetadataDialog {
this.normalizePageReference = normalizePageReference;
this.form = dialog.querySelector("form");
this.summary = dialog.querySelector("#cmap-metadata-summary");
this.namespace = dialog.querySelector("#cmap-metadata-namespace");
this.tags = dialog.querySelector("#cmap-metadata-tags");
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
this.saveHandler = null;
@@ -31,6 +32,7 @@ export class CmapMetadataDialog {
}
open(metadata) {
this.namespace.value = metadata.namespace || "";
this.summary.value = metadata.summary || "";
this.tags.value = (metadata.tags || []).join(", ");
this.explanationPage.value = metadata.explanationPageSlug || "";
@@ -40,6 +42,14 @@ export class CmapMetadataDialog {
}
metadata() {
const namespace = this.namespace.value.trim().toLocaleLowerCase();
if (namespace && !/^[\p{L}\p{N}][\p{L}\p{N}-]*$/u.test(namespace)) {
this.namespace.setCustomValidity(
this.tr("invalid-concept-map-namespace", "Use letters, numbers and hyphens for the namespace."));
this.namespace.reportValidity();
return null;
}
this.namespace.setCustomValidity("");
const explanationInput = this.explanationPage.value.trim();
const explanationPageSlug = explanationInput ?
this.normalizePageReference(explanationInput) : "";
@@ -50,6 +60,7 @@ export class CmapMetadataDialog {
return null;
}
return {
namespace,
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
summary: this.summary.value.trim(),
explanationPageSlug
+29
View File
@@ -182,6 +182,34 @@ function positionIsProtected(start, end, ranges) {
return false;
}
/** Prefix every unprotected CamelCase WikiWord with the literal escape marker. */
function protectCamelCaseWikiWords(markdown) {
const wikiWordPattern = /(?<![!\p{L}\p{N}._-])((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-])/gu;
let fence = null;
return String(markdown || "").split("\n").map((line) => {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
fence = fence === null ? marker : (fence === marker ? null : fence);
return line;
}
if (fence !== null || /^\s{4}/.test(line)) return line;
const ranges = markdownProtectedRanges(line);
const matches = Array.from(line.matchAll(wikiWordPattern));
let result = line;
for (let index = matches.length - 1; index >= 0; index -= 1) {
const match = matches[index];
const start = match.index;
const end = start + match[0].length;
if (!positionIsProtected(start, end, ranges)) {
result = result.slice(0, start) + `!${match[0]}` + result.slice(end);
}
}
return result;
}).join("\n");
}
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
function literalWikiWord(namespace, wikiWord) {
const characters = Array.from(wikiWord);
@@ -335,6 +363,7 @@ export {
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
protectCamelCaseWikiWords,
extractCmapEmbeds,
restoreCmapEmbeds
};