refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -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">👤</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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+118
-219
@@ -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">👤</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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user