|
|
|
@@ -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;
|