added mermaid and a lot of cmap changes

This commit is contained in:
2026-08-27 13:13:39 +02:00
parent 20c1584016
commit 2215d1d04a
35 changed files with 6442 additions and 350 deletions
+35 -3
View File
@@ -15,11 +15,19 @@ submap concepts. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
their independent display label, synopsis, background colour, typography and
optional image. Page concepts retain their linked `pageSlug`; double-click is
their shared name, aspects, people, description page, synopsis, image, external
web address and linked wiki/CMap target, plus placement-specific background
colour and typography. The editor has one compact tab for content, links and
images, and a second tab for detailed colours, typography and submap
presentation. Detailed settings can be saved as named, wiki-wide database
styles; selected styles are applied immediately and non-default styles can be
deleted. Its action bar remains visible while a tab scrolls.
Structural kinds such as a sub-CMap head remain placement roles. Page concepts retain their linked `pageSlug`; double-click is
reserved for opening that wiki page. Concepts may alternatively retain a
`cmapSlug` for another stored CMap or a `parentCmapLink` for navigation back
from a child map. Images are data URLs inside the persisted CMap JSON document.
from a child map. An optional `externalUrl` accepts only HTTP(S), opens in a new
browser tab and is shared by every placement of that concept. Images are data
URLs inside the persisted CMap JSON document.
Font families are selected from a practical list and font sizes are stored in
typographic points. The host can change the linked page or CMap independently
of the visible label and provides background and text-colour pickers.
@@ -39,6 +47,30 @@ selected inline sub-CMap or moves selected child items one level outward.
formatting, positions, connectors, recursive submap membership and promoted
map references. The wiki host persists this JSON document through its CMap API.
## JSON interchange
`/js/cmap-interchange.js` implements the versioned `racket-wiki-cmap-bundle`
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
referenced attachments of linked wiki and explanation pages. Attachments retain
their MIME type and binary base64 content; import uploads them under the target
page and rewrites the Markdown to the newly allocated URL. The root CMap and every included CMap retain
their stable slug. Derived CMaps automatically include their source CMap.
Presentation includes coordinates, dimensions, colours, font settings,
sub-CMap membership, linking phrases and connectors. Connector endpoints use
local item ids; concept identity never depends on those local ids. The formal
contract is
[`racket-wiki-cmap-bundle-v1.schema.json`](../schemas/racket-wiki-cmap-bundle-v1.schema.json).
An external generator may use a temporary id such as `new:security-review` for
a new concept. Every concept must also occur in at least one non-phrase
`document.items[]` placement with coordinates. On import the server turns such
a temporary identity into a UUID through the central Racket UUID helper. UUIDs
already present in an export remain unchanged. Imported CMap slugs likewise
remain unchanged.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
+368 -45
View File
@@ -7,7 +7,10 @@
(() => {
"use strict";
const debugPrefix = "[racket-wiki:cmap 0.2.99]";
const debugPrefix = "[racket-wiki:cmap 0.2.122]";
// The editor is rebuilt when navigating between stored CMaps. Clipboard
// entries therefore belong to the shared module, not to one editor instance.
let copiedConceptReferences = [];
function debug(message, details) {
if (details === undefined) {
@@ -80,12 +83,35 @@
function newConceptId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
try {
return `concept-${window.crypto.randomUUID()}`;
return window.crypto.randomUUID().toLowerCase();
} catch (_error) {
// randomUUID can be exposed but forbidden in an insecure/file context.
}
}
return `concept-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const bytes = new Uint8Array(16);
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
window.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
function normalizeConceptTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
return {
type: String(tag.type || "label").trim() || "label",
value: String(tag.value || tag.name || "").trim()
};
}).filter((tag) => tag && tag.value);
}
//////////////////////////////////////////////////////////////////////////////
@@ -110,6 +136,7 @@
this.onSubMapPromoted = options.onSubMapPromoted || null;
this.onMapChange = options.onMapChange || null;
this.onOpenCmap = options.onOpenCmap || null;
this.onOpenExternalUrl = options.onOpenExternalUrl || null;
this.onOpenStoredSubMap = options.onOpenStoredSubMap || null;
this.onOpenBoundaryReference = options.onOpenBoundaryReference || null;
this.onVisibilityChange = options.onVisibilityChange || null;
@@ -118,6 +145,7 @@
this.onCreateConnectedItem = options.onCreateConnectedItem || null;
this.onSelectionChange = options.onSelectionChange || null;
this.onHistoryChange = options.onHistoryChange || null;
this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null;
this.labels = {
createRelation: options.createRelationLabel || "Create relation",
editConcept: options.editConceptLabel || "Edit concept",
@@ -131,12 +159,12 @@
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
this.activeMapRoot = null;
this.mapHistory = [];
this.selectedItem = null;
this.selectedItems = new Set();
this.selectedConnector = null;
this.copiedConceptIds = [];
this.destroyed = false;
this.nextId = 1;
this.nextConnectorId = 1;
@@ -239,6 +267,7 @@
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
this.activeMapRoot = null;
this.mapHistory = [];
this.nextId = 1;
@@ -322,10 +351,12 @@
label: options.label || "Concept",
synopsis: options.synopsis || "",
aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [],
tags: normalizeConceptTags(options.tags),
descriptionPageSlug: kind === "phrase" ? null :
(options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)),
pageSlug: options.pageSlug || null,
cmapSlug: options.cmapSlug || null,
externalUrl: options.externalUrl || null,
parentCmapLink: Boolean(options.parentCmapLink),
groupId: options.groupId || null,
childMap: options.childMap || null,
@@ -388,9 +419,9 @@
fitContentPending: autoWidth || autoHeight,
node: null
};
record.usageCount = record.conceptId && kind !== "phrase" && kind !== "submap" ?
record.usageCount = record.conceptId && kind !== "phrase" ?
this.items.filter((item) => item.conceptId === record.conceptId &&
item.kind !== "phrase" && item.kind !== "submap").length + 1 : null;
item.kind !== "phrase").length + 1 : null;
const node = this.map.node({
content: this.itemHtml(record),
@@ -424,7 +455,7 @@
.map((item) => item.conceptId).filter(Boolean));
for (const conceptId of ids) {
const peers = this.items.filter((item) => item.conceptId === conceptId &&
item.kind !== "phrase" && item.kind !== "submap");
item.kind !== "phrase");
for (const peer of peers) {
peer.usageCount = peers.length;
if (!peer.node) continue;
@@ -811,6 +842,10 @@
return true;
}
canStepBackWithinMap() {
return this.mapHistory.length > 0;
}
openParentMap() {
if (!this.activeMapRoot) return false;
this.clearSelection();
@@ -983,6 +1018,7 @@
descriptionPageSlug: headDocument.descriptionPageSlug,
pageSlug: null,
cmapSlug: targetSlug,
externalUrl: headDocument.externalUrl || null,
parentCmapLink: false,
imageSource: headDocument.imageSource || ""
});
@@ -995,6 +1031,7 @@
descriptionPageSlug: childHead.descriptionPageSlug,
pageSlug: null,
cmapSlug: null,
externalUrl: childHead.externalUrl || null,
parentCmapLink: false,
imageSource: childHead.imageSource || ""
});
@@ -1084,16 +1121,18 @@
// first, so that automatic sizing cannot turn the edited state into the
// history baseline before it has been committed as its own Undo step.
this.scheduleHistoryCommit();
if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags);
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
const identityKeys = ["kind", "label", "synopsis", "aspects", "descriptionPageSlug",
"pageSlug", "cmapSlug",
"parentCmapLink", "imageSource"];
if (record.conceptId && record.kind !== "submap") {
const identityKeys = ["label", "synopsis", "aspects", "tags",
"descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl",
"imageSource"];
if (record.conceptId && record.kind !== "phrase") {
for (const peer of this.items) {
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "submap") continue;
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue;
for (const key of identityKeys) {
if (changes[key] !== undefined) peer[key] = changes[key];
}
@@ -1225,11 +1264,55 @@
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
this.connectors.push(record);
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
link.onConnectionChange((_changedLink, type, node) =>
this.handleConnectorConnectionChange(record, type, node));
link.visible(this.isItemVisible(source) && this.isItemVisible(target));
this.scheduleHistoryCommit();
return record;
}
/**
* Keep a dragged ionstage/cmap link endpoint and the persisted wiki
* connector model in lockstep. The drawing library owns the endpoint
* handles; the wiki model owns sourceId/targetId and submap projection.
*/
handleConnectorConnectionChange(connector, type, node) {
if (!connector || (type !== "source" && type !== "target")) return false;
const endpoint = this.items.find((item) => item.node === node) || null;
const otherType = type === "source" ? "target" : "source";
// A connector is only a storable relation while both ends are attached
// to different items. Restore the previous logical projection when an
// endpoint is dropped on empty space or on its opposite endpoint.
if (!endpoint || endpoint === connector[otherType]) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
this.applyConnectorVisualEndpoints(connector,
this.connectorEndpoint(connector.source),
this.connectorEndpoint(connector.target));
return false;
}
if (connector[type] === endpoint) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
return false;
}
const previous = connector[type];
connector[type] = endpoint;
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
this.reconcilePhraseMembership();
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
debug("connector endpoint changed", {
connectorId: connector.id,
type,
previousItemId: previous ? previous.id : null,
itemId: endpoint.id
});
this.scheduleHistoryCommit();
return true;
}
/**
* goal : Select a concept/linking phrase, optionally beside the current selection.
* pre : record belongs to this editor.
@@ -1280,6 +1363,8 @@
const selected = this.selectedItems.has(item);
if (element) {
element.classList.toggle("rw-cmap-selected", selected);
element.classList.toggle(
"rw-cmap-selected-primary", selected && item === this.selectedItem);
if (selected) {
element.setAttribute("aria-selected", "true");
item.node.toFront();
@@ -1316,10 +1401,12 @@
for (const item of this.selectedItems) {
if (item.submapFrameElement) {
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected");
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected-primary");
}
const element = item.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.classList.remove("rw-cmap-selected-primary");
element.removeAttribute("aria-selected");
this.removeHandles(element);
}
@@ -1346,42 +1433,95 @@
return Array.from(this.selectedItems);
}
storeConceptReferences(records) {
if (!records.length) return 0;
const referencesById = new Map(records.map((source) =>
[source.conceptId, {
conceptId: source.conceptId,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
externalUrl: source.externalUrl,
imageSource: source.imageSource,
width: Number(source.node.attr("width")),
height: Number(source.node.attr("height")),
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
fontFamily: source.fontFamily,
fontSize: source.fontSize,
fontWeight: source.fontWeight,
fontStyle: source.fontStyle,
synopsisTextColor: source.synopsisTextColor,
synopsisFontFamily: source.synopsisFontFamily,
synopsisFontSize: source.synopsisFontSize,
synopsisFontWeight: source.synopsisFontWeight,
synopsisFontStyle: source.synopsisFontStyle
}]));
copiedConceptReferences = Array.from(referencesById.values());
return copiedConceptReferences.length;
}
copySelectionReferences() {
this.copiedConceptIds = Array.from(new Set(this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase" && item.kind !== "submap")
.map((item) => item.conceptId)));
const selected = this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase");
const copied = this.storeConceptReferences(selected);
if (!copied) return 0;
this.notifySelection();
return this.copiedConceptIds.length;
return copied;
}
canCutSelectionReferences() {
return this.selectedAll().some((item) =>
item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
}
cutSelectionReferences() {
const cuttable = this.selectedAll().filter((item) =>
item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
if (!cuttable.length) return 0;
const copied = this.storeConceptReferences(cuttable);
if (!copied) return 0;
this.clearSelection(false);
for (const record of cuttable) this.selectedItems.add(record);
this.selectedItem = cuttable.at(-1) || null;
this.deleteSelection();
return copied;
}
canPasteConceptReferences() {
return this.copiedConceptIds.length > 0;
return copiedConceptReferences.length > 0;
}
pasteConceptReferences() {
const sources = this.copiedConceptIds
.map((conceptId) => this.items.find((item) => item.conceptId === conceptId))
.filter(Boolean);
const sources = copiedConceptReferences;
if (!sources.length) return [];
this.clearSelection(false);
const parentSubmap = this.activeMapRoot || null;
const pasted = sources.map((source, index) => this.addItem({
conceptId: source.conceptId,
kind: source.kind,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: source.aspects,
tags: source.tags,
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
parentCmapLink: source.parentCmapLink,
externalUrl: source.externalUrl,
parentCmapLink: false,
imageSource: source.imageSource,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: 120 + (index * 36),
y: 120 + (index * 36),
width: Number(source.node.attr("width")),
height: Number(source.node.attr("height")),
width: source.width,
height: source.height,
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
@@ -1413,6 +1553,119 @@
return this.selectedAll();
}
layoutSelectionRecords() {
return this.selectedAll().filter((item) => this.isEffectiveItemVisible(item));
}
canLayoutSelection(command) {
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
return this.layoutSelectionRecords().length >= minimum;
}
applySelectionLayout(command) {
const records = this.layoutSelectionRecords();
if (!this.canLayoutSelection(command)) return false;
const boxes = records.map((record) => ({
record,
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y")),
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height"))
}));
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
const updates = new Map(boxes.map(({ record }) => [record, {}]));
const referenceRight = reference.x + reference.width;
const referenceCenter = reference.x + (reference.width / 2);
const referenceBottom = reference.y + reference.height;
const referenceMiddle = reference.y + (reference.height / 2);
if (["same-width", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).width = reference.width;
}
if (["same-height", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).height = reference.height;
}
if (command === "align-left") {
for (const box of boxes) updates.get(box.record).x = reference.x;
}
if (command === "align-right") {
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
}
if (command === "align-center") {
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
}
if (command === "align-top") {
for (const box of boxes) updates.get(box.record).y = reference.y;
}
if (command === "align-bottom") {
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
}
if (command === "align-middle") {
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
}
if (command === "distribute-horizontal") {
const ordered = [...boxes].sort((a, b) =>
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionLeft = ordered[0].x;
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
let cursor = distributionLeft;
for (const box of ordered) {
updates.get(box.record).x = cursor;
cursor += box.width + gap;
}
}
if (command === "distribute-vertical") {
const ordered = [...boxes].sort((a, b) =>
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionTop = ordered[0].y;
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
let cursor = distributionTop;
for (const box of ordered) {
updates.get(box.record).y = cursor;
cursor += box.height + gap;
}
}
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
"align-center", "align-top", "align-bottom", "align-middle",
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
this.scheduleHistoryCommit();
for (const record of records) {
const attributes = updates.get(record);
if (attributes.width !== undefined) {
record.width = attributes.width;
record.autoWidth = false;
}
if (attributes.height !== undefined) {
record.height = attributes.height;
record.autoHeight = false;
}
record.node.attr(attributes);
record.node.redraw();
}
this.saveCurrentContextLayout();
this.refreshConnectorGeometry();
for (const submap of this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth)) {
this.updateSubmapFrame(submap);
}
this.refreshSelectionDecoration();
debug("selection layout applied", {
command,
referenceItemId: reference.record.id,
itemIds: records.map((record) => record.id)
});
return true;
}
canGroupSelection() {
const selected = this.selectedAll();
return selected.length >= 2 &&
@@ -1513,36 +1766,54 @@
return true;
}
getDocumentMetadata() {
return {
tags: [...this.documentMetadata.tags],
summary: this.documentMetadata.summary,
explanationPageSlug: this.documentMetadata.explanationPageSlug
};
}
setDocumentMetadata(metadata = {}) {
this.scheduleHistoryCommit();
this.documentMetadata = {
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
.map((tag) => tag.trim()).filter(Boolean) : [],
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
return this.getDocumentMetadata();
}
toDocument() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
const concepts = Array.from(new Map(this.items
.filter((record) => record.conceptId && record.kind !== "phrase" && record.kind !== "submap")
.filter((record) => record.conceptId && record.kind !== "phrase")
.map((record) => [record.conceptId, {
id: record.conceptId,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"),
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
parentCmapLink: record.parentCmapLink,
externalUrl: record.externalUrl,
imageSource: record.imageSource
}])).values());
return {
schemaVersion: 2,
metadata: this.getDocumentMetadata(),
concepts,
items: this.items.map((record) => ({
id: record.id,
conceptId: record.conceptId,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
...(record.kind === "phrase" ? {
label: record.label,
synopsis: record.synopsis
} : {}),
parentCmapLink: record.parentCmapLink,
groupId: record.groupId,
childMap: record.childMap,
@@ -1554,7 +1825,6 @@
mapReference: record.mapReference,
hiddenContexts: Array.from(record.hiddenContexts),
layouts: record.layouts,
imageSource: record.imageSource,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
submapBackgroundColor: record.submapBackgroundColor,
@@ -1598,10 +1868,25 @@
.filter((concept) => concept && concept.id)
.map((concept) => [concept.id, concept]));
const records = new Map();
const metadata = document.metadata && typeof document.metadata === "object" ?
document.metadata : {};
this.documentMetadata = {
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
.map((tag) => tag.trim()).filter(Boolean) : [],
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
for (const itemDocument of itemDocuments) {
const concept = concepts.get(itemDocument.conceptId) || {};
const record = this.addItem({ ...itemDocument, ...concept, parentSubmap: null });
const record = this.addItem({
...itemDocument,
...concept,
id: itemDocument.id,
conceptId: itemDocument.conceptId,
kind: itemDocument.kind || concept.kind || "concept",
parentSubmap: null
});
record.autoWidth = Boolean(itemDocument.autoWidth);
record.autoHeight = Boolean(itemDocument.autoHeight);
record.fitContentPending = false;
@@ -1756,7 +2041,10 @@
element.style.overflow = "visible";
this.applyItemTypography(record, element);
if (record.fitContentPending) {
// Concept content is never presentation-optional. A context may retain a
// different width or a larger manual height, but it may not keep a
// height that clips part of the shared content.
if (record.fitContentPending || record.kind !== "phrase") {
this.fitItemToContent(record, element);
}
@@ -1764,7 +2052,7 @@
if (image && image.dataset.rwCmapFitBound !== "1") {
image.dataset.rwCmapFitBound = "1";
image.addEventListener("load", () => {
if (!record.autoWidth && !record.autoHeight) return;
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
record.fitContentPending = true;
this.fitItemToContent(record, element);
}, { once: true });
@@ -1806,6 +2094,20 @@
});
}
const externalButton = element.querySelector(".rw-cmap-open-external");
if (externalButton && externalButton.dataset.rwCmapBound !== "1") {
externalButton.dataset.rwCmapBound = "1";
externalButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
externalButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.externalUrl && this.onOpenExternalUrl) this.onOpenExternalUrl(record);
});
}
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
@@ -1818,6 +2120,7 @@
if (this.selectedItems.has(record)) {
element.classList.add("rw-cmap-selected");
element.classList.toggle("rw-cmap-selected-primary", this.selectedItem === record);
element.setAttribute("aria-selected", "true");
if (this.selectedItem === record) this.ensureHandles(record, element);
}
@@ -1847,14 +2150,16 @@
}
/**
* goal : Give a new, not manually sized item a compact content-based size.
* goal : Size automatic items and prevent fixed concept cards clipping content.
* pre : element has been rendered and contains the current item HTML.
* post : Automatic dimensions closely surround the text, with long text
* wrapping at a practical maximum width.
*/
fitItemToContent(record, element) {
record.fitContentPending = false;
if (!record.autoWidth && !record.autoHeight) return;
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
const fixedWidth = !record.autoWidth && record.kind !== "phrase";
const probe = document.createElement("div");
probe.className = element.className;
@@ -1863,9 +2168,9 @@
position: "fixed",
left: "-10000px",
top: "0",
width: "max-content",
width: fixedWidth ? `${record.width}px` : "max-content",
height: "auto",
maxWidth: record.kind === "phrase" ? "280px" : "380px",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
boxSizing: "border-box",
fontFamily: record.fontFamily,
fontSize: record.fontSize,
@@ -1883,9 +2188,9 @@
const content = probe.firstElementChild;
if (content) {
Object.assign(content.style, {
width: "max-content",
width: fixedWidth ? "100%" : "max-content",
height: "auto",
maxWidth: record.kind === "phrase" ? "276px" : "376px",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
overflow: "visible",
whiteSpace: "normal"
});
@@ -1900,9 +2205,14 @@
const measuredWidth = Math.ceil(bounds.width) + 4;
const measuredHeight = Math.ceil(bounds.height) + 4;
const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width;
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : record.height;
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) :
(record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
if (nextWidth === record.width && nextHeight === record.height) return;
// Rendering may finish after the host has recorded its saved baseline.
// Give the host both exact states so it can advance that baseline only
// when no user edit occurred in between.
const beforeAutomaticLayout = this.onAutomaticLayoutChange ? this.historySnapshot() : null;
const previousWidth = record.width;
const previousHeight = record.height;
const attributes = { width: nextWidth, height: nextHeight };
@@ -1922,6 +2232,16 @@
height: nextHeight
});
this.refreshHistorySnapshot();
if (this.onAutomaticLayoutChange) {
const afterAutomaticLayout = this.historySnapshot();
if (beforeAutomaticLayout !== afterAutomaticLayout) {
this.onAutomaticLayoutChange({
beforeSnapshot: beforeAutomaticLayout,
afterSnapshot: afterAutomaticLayout,
itemId: record.id
});
}
}
}
decorateConnector(record, renderedElement = null) {
@@ -2293,6 +2613,9 @@
"--rw-cmap-submap-border", record.submapBorderColor || "#57834a");
record.submapFrameElement.classList.toggle(
"rw-cmap-submap-frame-selected", this.selectedItems.has(record));
record.submapFrameElement.classList.toggle(
"rw-cmap-submap-frame-selected-primary",
this.selectedItems.has(record) && this.selectedItem === record);
record.submapFrameElement.setAttribute("aria-label", record.label);
this.updateSubmapAnchorLine(record, bounds, surface);
this.ensureCanvasExtent(right, bottom);
@@ -2942,7 +3265,7 @@
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.99",
version: "0.2.122",
createEditor(canvas, options) {
lastEditor = new CmapEditor(canvas, options);
return lastEditor;
+301 -17
View File
@@ -280,16 +280,86 @@ body.cmap-mode #main {
.cmap-workspace {
position: relative;
z-index: 0;
display: flex;
align-items: flex-start;
isolation: isolate;
width: 100%;
}
.cmap-selection-toolbar {
position: sticky;
z-index: 120;
top: 82px;
left: 0;
display: grid;
flex: 0 0 82px;
grid-template-columns: 1fr;
gap: 5px;
box-sizing: border-box;
width: 82px;
max-height: calc(100vh - 96px);
margin: 5px 8px 0 0;
padding: 7px;
overflow: auto;
border: 1px solid #c6ccd3;
border-radius: 7px;
background: #f2f4f6;
box-shadow: 0 3px 12px rgb(35 45 55 / 14%);
}
.cmap-selection-tool-group {
display: grid;
grid-template-columns: repeat(2, 30px);
gap: 5px;
}
.cmap-selection-tool-group button {
display: grid;
box-sizing: border-box;
width: 30px;
height: 30px;
padding: 5px;
place-items: center;
border: 1px solid #aeb7c1;
border-radius: 4px;
background: #fff;
color: #344454;
cursor: pointer;
}
.cmap-selection-tool-group button:hover:not(:disabled),
.cmap-selection-tool-group button:focus-visible {
border-color: #64788c;
background: #e4ebf2;
color: #172b3e;
}
.cmap-selection-tool-group button:disabled {
border-color: #d6dade;
background: #f7f8f9;
color: #aeb5bc;
cursor: default;
}
.cmap-selection-tool-group button svg,
.cmap-selection-tool-group button i {
width: 17px;
height: 17px;
}
.cmap-selection-tool-separator {
height: 1px;
margin: 1px 2px;
background: #cbd1d7;
}
.cmap-canvas {
--cmap-a4-width: 1123px;
--cmap-a4-height: 794px;
position: relative;
flex: 1 0 auto;
width: max-content;
min-width: max(760px, 100%);
min-width: max(760px, calc(100% - 90px));
min-height: 794px;
overflow: visible;
border: 0;
@@ -518,7 +588,8 @@ body.cmap-mode #main {
.cmap-description-tooltip .markdown-body > :first-child { margin-top: 0; }
.cmap-description-tooltip .markdown-body > :last-child { margin-bottom: 0; }
.rw-cmap-open-linked {
.rw-cmap-open-linked,
.rw-cmap-open-external {
position: absolute;
z-index: 46;
top: 3px;
@@ -536,15 +607,35 @@ body.cmap-mode #main {
font: 700 12px/1 Arial, Helvetica, sans-serif;
}
.rw-cmap-open-external {
position: absolute;
right: 3px;
border-color: #4479a1;
color: #245f8c;
}
.rw-cmap-open-linked + .rw-cmap-open-external {
right: 27px;
}
.cmap-card-submap .rw-cmap-open-external {
right: 52px;
}
.rw-cmap-view-description + .cmap-card-image,
.rw-cmap-view-description ~ .cmap-card-title {
padding-left: 16px;
}
.rw-cmap-open-linked ~ .cmap-card-title {
.rw-cmap-open-linked ~ .cmap-card-title,
.rw-cmap-open-external ~ .cmap-card-title {
padding-right: 16px;
}
.rw-cmap-open-linked + .rw-cmap-open-external ~ .cmap-card-title {
padding-right: 40px;
}
.cmap-card-title {
margin-bottom: .3rem;
font-size: 1em;
@@ -567,6 +658,24 @@ body.cmap-mode #main {
margin: 0 0 .35rem;
}
.cmap-card-people {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 3px;
margin-top: 4px;
padding: 2px 6px;
overflow: hidden;
border: 1px solid #8b6aa8;
border-radius: 999px;
background: #f1e9f7;
color: #4e3266;
font-size: 0.76em;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmap-card-aspect {
padding: 1px 5px;
border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
@@ -682,6 +791,11 @@ body.cmap-mode #main {
box-shadow: 0 0 0 5px rgb(132 126 255 / 28%);
}
.rw-cmap-submap-frame-selected-primary {
outline-color: #2473c7;
box-shadow: 0 0 0 5px rgb(36 115 199 / 32%);
}
.rw-cmap-submap-frame-toggle {
position: absolute;
top: 50%;
@@ -737,13 +851,19 @@ body.cmap-mode #main {
}
.rw-cmap-selected {
z-index: 20 !important;
z-index: 300000 !important;
outline: 3px solid #9994ff !important;
outline-offset: 2px;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(132 126 255 / 38%) !important;
}
.rw-cmap-selected-primary {
outline-color: #2473c7 !important;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(36 115 199 / 42%) !important;
}
.rw-cmap-marquee {
position: absolute;
z-index: 10000;
@@ -887,7 +1007,8 @@ body.cmap-mode #main {
}
#cmap-concept-dialog {
width: min(780px, calc(100vw - 32px));
width: min(860px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
max-height: calc(100vh - 32px);
}
@@ -947,20 +1068,88 @@ body.cmap-mode #main {
}
#cmap-concept-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px 14px;
max-height: calc(100vh - 34px);
overflow: auto;
padding: 18px;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0;
height: 100%;
max-height: none;
overflow: hidden;
padding: 0;
}
#cmap-concept-form > h2,
#cmap-concept-form > .cmap-dialog-wide,
#cmap-concept-form > .cmap-submap-style-fields,
#cmap-concept-form > .cmap-concept-dialog-actions {
#cmap-concept-form > h2 {
padding: 18px 20px 10px;
}
.cmap-concept-tabs {
display: flex;
gap: 4px;
padding: 0 20px;
border-bottom: 1px solid #cbd2da;
}
.cmap-concept-tabs button {
margin-bottom: -1px;
padding: 9px 14px;
border: 1px solid transparent;
border-bottom-color: #cbd2da;
border-radius: 5px 5px 0 0;
background: transparent;
cursor: pointer;
}
.cmap-concept-tabs button[aria-selected="true"] {
border-color: #cbd2da;
border-bottom-color: #fff;
background: #fff;
color: #173b57;
font-weight: 700;
}
.cmap-concept-dialog-body {
min-height: 0;
overflow: auto;
padding: 16px 20px;
}
.cmap-concept-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px 16px;
}
.cmap-concept-panel.hidden {
display: none;
}
.cmap-concept-panel > .cmap-dialog-wide,
.cmap-concept-panel > .cmap-submap-style-fields {
grid-column: 1 / -1;
}
.cmap-style-manager {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: end;
padding: 11px 12px;
border: 1px solid #b9c7d3;
border-radius: 5px;
background: #f3f7fa;
}
.cmap-style-manager-actions {
display: flex;
gap: 7px;
}
.cmap-style-manager small {
grid-column: 1 / -1;
}
.cmap-quick-style-field {
align-content: start;
}
.cmap-concept-dialog h2 {
margin: 0 0 2px;
}
@@ -973,6 +1162,7 @@ body.cmap-mode #main {
.cmap-concept-dialog input[type="text"],
.cmap-concept-dialog input[type="search"],
.cmap-concept-dialog input[type="url"],
.cmap-concept-dialog input[type="number"],
.cmap-concept-dialog select,
.cmap-concept-dialog textarea {
@@ -1105,6 +1295,89 @@ body.cmap-mode #main {
.cmap-check-label input { margin: 0; }
#cmap-export-form .cmap-check-label {
display: flex;
grid-template-columns: none;
flex-direction: row;
align-items: center;
gap: 8px;
}
.cmap-person-tags-field small {
font-weight: 400;
}
.cmap-concept-panel small {
font-weight: 400;
}
.cmap-person-picker {
position: relative;
font-weight: 400;
}
.cmap-person-picker > summary {
padding: 8px 10px;
border: 1px solid #aab2bd;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
.cmap-person-picker[open] {
padding: 0 9px 9px;
border: 1px solid #aab2bd;
border-radius: 4px;
background: #fff;
}
.cmap-person-picker[open] > summary {
margin: 0 -9px 8px;
border: 0;
border-bottom: 1px solid #d3d7dc;
border-radius: 0;
}
.cmap-person-tag-options,
.cmap-people-list {
display: grid;
gap: 6px;
max-height: 240px;
overflow: auto;
}
.cmap-person-tag-option {
display: flex !important;
grid-template-columns: none !important;
flex-direction: row;
align-items: center;
gap: 7px !important;
font-weight: 400 !important;
}
.cmap-person-add-row,
.cmap-person-admin-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
margin-top: 9px;
}
.cmap-person-admin-row {
grid-template-columns: minmax(180px, 1fr) auto auto;
margin: 0;
}
.cmap-person-admin-row label {
display: flex;
grid-template-columns: none;
flex-direction: row;
align-items: center;
gap: 6px;
font-weight: 400;
}
.cmap-submap-style-fields legend {
grid-column: 1 / -1;
}
@@ -1160,21 +1433,32 @@ body.cmap-mode #main {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
margin: 0;
padding: 12px 20px;
border-top: 1px solid #cbd2da;
background: #fff;
}
@media (max-width: 720px) {
#cmap-concept-form,
.cmap-concept-panel,
.cmap-appearance-fields,
.cmap-typography-fields {
grid-template-columns: 1fr;
}
#cmap-concept-form > *,
.cmap-concept-panel > *,
.cmap-appearance-fields > *,
.cmap-typography-fields > * {
grid-column: 1;
}
.cmap-style-manager {
grid-template-columns: 1fr;
}
.cmap-style-manager small {
grid-column: 1;
}
}
@media (max-width: 1100px) {
+54 -18
View File
@@ -1,5 +1,5 @@
/**
* racket-wiki cmap component 0.2.95
* racket-wiki cmap component 0.2.122
* Based on cmap v0.1.3.
* (c) 2015 iOnStage
* Released under the MIT License.
@@ -596,6 +596,7 @@
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
this.connectionChangeHandler = null;
}, Component);
Link.prototype.straighten = function(sx, sy, tx, ty) {
@@ -1241,25 +1242,30 @@
ComponentList.prototype.fromPoint = function(ctor, x, y) {
var data = this.data;
var closeComponent = null;
// The visual stack is connector controls, concepts and finally relations.
// Use that same priority for coordinate hit testing so a relation that is
// hidden behind a concept can never steal the concept's click.
var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
for (var i = data.length - 1; i >= 0; i--) {
var component = data[i];
for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
var tolerance = toleranceIndex === 0 ? 0 : 8;
for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
for (var i = data.length - 1; i >= 0; i--) {
var component = data[i];
if (!(component instanceof ctor))
continue;
if (!(component instanceof types[typeIndex]))
continue;
if (component.visible === false)
continue;
if (component.visible === false)
continue;
if (component.contains(x, y, 0))
return component;
if (!closeComponent && component.contains(x, y, 8))
closeComponent = component;
if (component.contains(x, y, tolerance))
return component;
}
}
}
return closeComponent;
return null;
};
var DisabledConnectorList = helper.inherits(function() {
@@ -1308,6 +1314,10 @@
this.markDirty();
}, Component);
Cmap.LINK_Z_INDEX_BASE = 100;
Cmap.NODE_Z_INDEX_BASE = 100000;
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
Cmap.prototype.add = function(component) {
component.parentElement(this.element());
this.componentList().add(component);
@@ -1331,12 +1341,17 @@
};
Cmap.prototype.updateZIndex = function() {
this.componentList().toArray().forEach(function(component, index) {
var linkIndex = 0;
var nodeIndex = 0;
this.componentList().toArray().forEach(function(component) {
if (component instanceof Connector)
return;
// update z-index of node/link
var zIndex = index * 10;
// Relations always occupy a lower band than concept nodes. Reordering a
// selected component therefore only changes its order inside that band.
var zIndex = component instanceof Link ?
Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
component.zIndex(zIndex);
if (!(component instanceof Link))
@@ -1344,7 +1359,7 @@
// update connector z-index of link
helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
relation.connector().zIndex(zIndex + index + 1);
relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
});
});
};
@@ -1756,6 +1771,15 @@
if (component instanceof Node && typeof component.moveEndHandler === 'function')
component.moveEndHandler(component.x(), component.y(), event);
if (component instanceof Connector) {
var link = context.link;
var triple = helper.firstInstance(link.relations(), Triple);
var connectedNode = triple ? triple[context.type + 'Node']() : null;
if (typeof link.connectionChangeHandler === 'function')
link.connectionChangeHandler(context.type, connectedNode, event);
}
this.unfixScrollSize();
};
@@ -2037,6 +2061,18 @@
return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_TARGET, node);
};
LinkModule.prototype.onConnectionChange = function(handler) {
var module = this;
if (handler !== null && typeof handler !== 'function')
throw TypeError('Invalid connection-change handler');
this.component.connectionChangeHandler = handler ? function(type, node, event) {
var nodeModule = node ? module.cmap.nodeModuleList.fromComponent(node) : null;
handler(module.wrapper, type, nodeModule ? nodeModule.wrapper : null, event);
} : null;
};
LinkModule.prototype.straighten = function() {
var link = this.component;
var triple = helper.firstInstance(link.relations(), Triple);
+49
View File
@@ -532,6 +532,32 @@ body {
font-size: .94em;
}
.rw-mermaid {
margin: 1.25em 0;
overflow-x: auto;
text-align: center;
}
.rw-mermaid svg {
display: inline-block;
max-width: 100%;
height: auto;
}
pre.rw-mermaid-error {
border-color: #b42318;
background: #fff4f2;
}
pre.rw-mermaid-error::before {
display: block;
margin-bottom: .6em;
color: #b42318;
content: "Mermaid diagram could not be rendered";
font-family: system-ui, sans-serif;
font-weight: 600;
}
.markdown-body blockquote,
.EasyMDEContainer .editor-preview blockquote,
.EasyMDEContainer .editor-preview-side blockquote {
@@ -1342,6 +1368,29 @@ body.editor-mode .editor-metadata-row {
gap: 12px;
}
.archived-cmaps-list {
display: grid;
gap: 12px;
}
.archived-cmap-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 16px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--wiki-border);
}
.archived-cmap-row .muted {
grid-column: 1;
}
.archived-cmap-row button {
grid-column: 2;
grid-row: 1 / span 2;
}
.orphaned-upload-row {
padding: 12px 0;
border-bottom: 1px solid var(--wiki-border);
+219 -121
View File
@@ -202,7 +202,7 @@
<dl class="cmap-shortcut-list">
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>/<kbd>Shift</kbd> + <span data-tr="click">click</span></dt><dd data-tr="cmap-help-add-selection">Add to or remove from the selection</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>A</kbd></dt><dd data-tr="select-all">Select all</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>C</kbd>/<kbd>V</kbd></dt><dd data-tr="copy-paste-linked-concepts">Copy/paste linked concepts</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>X</kbd>/<kbd>C</kbd>/<kbd>V</kbd></dt><dd data-tr="copy-paste-linked-concepts">Cut/copy/paste linked concepts</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>G</kbd></dt><dd data-tr="group-selected">Group selection as sub-CMap</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></dt><dd data-tr="ungroup-selected">Detach from sub-CMap</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></dt><dd data-tr="undo">Undo</dd></div>
@@ -217,14 +217,45 @@
</div>
</header>
<div class="cmap-workspace">
<aside id="cmap-selection-toolbar" class="cmap-selection-toolbar editor-only" aria-label="Selection and layout tools" data-tr-aria-label="selection-layout-tools">
<div class="cmap-selection-tool-group" role="group" aria-label="Selection tools" data-tr-aria-label="selection-tools">
<button type="button" data-cmap-selection-action="edit" title="Edit selected" data-tr-title="edit-selected-concept" aria-label="Edit selected" data-tr-aria-label="edit-selected-concept" disabled><i data-lucide="pencil" aria-hidden="true"></i></button>
<button type="button" data-cmap-selection-action="group" title="Group selection as sub-CMap" data-tr-title="group-selected" aria-label="Group selection as sub-CMap" data-tr-aria-label="group-selected" disabled><i data-lucide="group" aria-hidden="true"></i></button>
<button type="button" data-cmap-selection-action="ungroup" title="Detach from sub-CMap" data-tr-title="ungroup-selected" aria-label="Detach from sub-CMap" data-tr-aria-label="ungroup-selected" disabled><i data-lucide="ungroup" aria-hidden="true"></i></button>
<button type="button" data-cmap-selection-action="hide" title="Hide in this view" data-tr-title="hide-in-this-view" aria-label="Hide in this view" data-tr-aria-label="hide-in-this-view" disabled><i data-lucide="eye-off" aria-hidden="true"></i></button>
</div>
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
<div class="cmap-selection-tool-group" role="group" aria-label="Size tools" data-tr-aria-label="size-tools">
<button type="button" data-cmap-layout="same-width" title="Make same width" data-tr-title="make-same-width" aria-label="Make same width" data-tr-aria-label="make-same-width" disabled><i data-lucide="move-horizontal" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="same-height" title="Make same height" data-tr-title="make-same-height" aria-label="Make same height" data-tr-aria-label="make-same-height" disabled><i data-lucide="move-vertical" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="same-size" title="Make same width and height" data-tr-title="make-same-size" aria-label="Make same width and height" data-tr-aria-label="make-same-size" disabled><i data-lucide="maximize-2" aria-hidden="true"></i></button>
</div>
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
<div class="cmap-selection-tool-group" role="group" aria-label="Alignment tools" data-tr-aria-label="alignment-tools">
<button type="button" data-cmap-layout="align-left" title="Align left" data-tr-title="align-left" aria-label="Align left" data-tr-aria-label="align-left" disabled><i data-lucide="align-horizontal-justify-start" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="align-center" title="Align horizontal centers" data-tr-title="align-horizontal-centers" aria-label="Align horizontal centers" data-tr-aria-label="align-horizontal-centers" disabled><i data-lucide="align-horizontal-justify-center" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="align-right" title="Align right" data-tr-title="align-right" aria-label="Align right" data-tr-aria-label="align-right" disabled><i data-lucide="align-horizontal-justify-end" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="align-top" title="Align top" data-tr-title="align-top" aria-label="Align top" data-tr-aria-label="align-top" disabled><i data-lucide="align-vertical-justify-start" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="align-middle" title="Align vertical centers" data-tr-title="align-vertical-centers" aria-label="Align vertical centers" data-tr-aria-label="align-vertical-centers" disabled><i data-lucide="align-vertical-justify-center" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="align-bottom" title="Align bottom" data-tr-title="align-bottom" aria-label="Align bottom" data-tr-aria-label="align-bottom" disabled><i data-lucide="align-vertical-justify-end" aria-hidden="true"></i></button>
</div>
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
<div class="cmap-selection-tool-group" role="group" aria-label="Distribution tools" data-tr-aria-label="distribution-tools">
<button type="button" data-cmap-layout="distribute-horizontal" title="Distribute horizontally" data-tr-title="distribute-horizontally" aria-label="Distribute horizontally" data-tr-aria-label="distribute-horizontally" disabled><i data-lucide="align-horizontal-space-around" aria-hidden="true"></i></button>
<button type="button" data-cmap-layout="distribute-vertical" title="Distribute vertically" data-tr-title="distribute-vertically" aria-label="Distribute vertically" data-tr-aria-label="distribute-vertically" disabled><i data-lucide="align-vertical-space-around" aria-hidden="true"></i></button>
</div>
</aside>
<div id="cmap-canvas" class="cmap-canvas" aria-label="Concept map"></div>
<nav id="cmap-context-menu" class="cmap-context-menu hidden" role="menu" aria-label="Concept map tools">
<button id="cmap-new-map" class="editor-only" type="button" role="menuitem" data-tr="new-concept-map">New CMap</button>
<button id="cmap-save-map" class="editor-only" type="button" role="menuitem" data-tr="save-now">Save now</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-rename-map" type="button" role="menuitem" data-tr="rename-concept-map" disabled>Rename CMap</button>
<button id="cmap-edit-metadata" class="editor-only" type="button" role="menuitem" data-tr="concept-map-details" disabled>CMap details</button>
<button id="cmap-export-markdown" type="button" role="menuitem" data-tr="export-markdown" disabled>Export CMap…</button>
<button id="cmap-import-json" class="editor-only" type="button" role="menuitem" data-tr="import-cmap-json">Import CMap JSON…</button>
<button id="cmap-manage-people" class="editor-only" type="button" role="menuitem" data-tr="manage-people">Manage people</button>
<button id="cmap-set-start-map" type="button" role="menuitem" data-tr="set-start-concept-map" disabled>Use as start CMap</button>
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="delete-concept-map" disabled>Delete CMap</button>
<button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button>
<button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button>
<div class="cmap-context-separator" role="separator"></div>
@@ -233,6 +264,7 @@
<button id="cmap-add-submap" type="button" role="menuitem" data-tr="add-submap">Add sub-CMap</button>
<button id="cmap-promote-submap" type="button" role="menuitem" data-tr="promote-submap" disabled>Make separate CMap</button>
<button id="cmap-edit-selected" type="button" role="menuitem" data-tr="edit-selected-concept">Edit selected</button>
<button id="cmap-cut-selected" type="button" role="menuitem" data-tr="cut-linked-concepts" disabled>Cut concept reference</button>
<button id="cmap-copy-selected" type="button" role="menuitem" data-tr="copy-linked-concepts">Copy concept reference</button>
<button id="cmap-paste-concepts" type="button" role="menuitem" data-tr="paste-linked-concepts" disabled>Paste linked concept</button>
<div class="cmap-context-separator" role="separator"></div>
@@ -247,6 +279,8 @@
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-toggle-page-guides" type="button" role="menuitemcheckbox" aria-checked="true" data-tr="a4-page-boundaries">A4 page boundaries</button>
<button id="cmap-reset" type="button" role="menuitem" data-tr="reload-concept-map">Reload CMap</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="archive-entire-concept-map" disabled>Archive entire CMap…</button>
</nav>
</div>
</section>
@@ -300,15 +334,26 @@
<h1 data-tr="admin">Admin</h1>
</header>
<p class="admin-version"><span data-tr="software-version">Software version</span>: <strong id="admin-software-version"></strong></p>
<p class="admin-version"><span data-tr="database-schema-version">Database schema version</span>: <strong id="admin-database-schema-version"></strong></p>
<nav class="admin-menu">
<a id="admin-users-link" href="#admin/users" data-tr="users">Users</a>
<a id="admin-mail-link" href="#admin/mail" data-tr="email-and-password-reset">Email and password reset</a>
<a id="admin-aliases-link" href="#admin/aliases" data-tr="page-aliases">Page aliases</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
<a id="admin-orphaned-uploads-link" href="#admin/orphaned-uploads" data-tr="orphaned-uploads">Orphaned uploads</a>
<a id="admin-archived-cmaps-link" href="#admin/archived-cmaps" data-tr="archived-concept-maps">Archived CMaps</a>
</nav>
</section>
<section id="archived-cmaps-view" class="hidden">
<header class="page-header">
<h1 data-tr="archived-concept-maps">Archived CMaps</h1>
<a id="close-archived-cmaps" href="#admin" data-tr="back">Back</a>
</header>
<p id="archived-cmaps-status" class="muted" role="status" aria-live="polite"></p>
<div id="archived-cmaps-list" class="archived-cmaps-list"></div>
</section>
<section id="orphaned-uploads-view" class="hidden">
<header class="page-header">
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
@@ -394,134 +439,121 @@
</div>
<dialog id="cmap-concept-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-concept-dialog-title">
<form id="cmap-concept-form">
<form id="cmap-concept-form" novalidate>
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
<label id="cmap-concept-page-row" class="cmap-concept-page-row">
<span data-tr="linked-page">Linked wiki page</span>
<div id="cmap-concept-page-combobox" class="wiki-combobox">
<input id="cmap-concept-page" type="search" role="combobox"
data-tr-placeholder="filter-pages" placeholder="Filter pages">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show wiki pages"></button>
<div id="cmap-concept-page-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label id="cmap-concept-cmap-row" class="cmap-concept-page-row">
<span data-tr="linked-concept-map">Linked concept map</span>
<div id="cmap-concept-cmap-combobox" class="wiki-combobox">
<input id="cmap-concept-cmap" type="search" role="combobox"
data-tr-placeholder="filter-concept-maps" placeholder="Filter concept maps">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="cmap-concept-cmap-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label>
<span data-tr="label">Label</span>
<input id="cmap-concept-label" type="text" required>
</label>
<label>
<span data-tr="aspects">Aspects</span>
<input id="cmap-concept-aspects" type="text" data-tr-placeholder="aspects-placeholder" placeholder="Security, performance">
</label>
<label class="cmap-dialog-wide">
<span data-tr="concept-description-page">Concept description page</span>
<input id="cmap-concept-description-page" type="text" data-tr-placeholder="concept-description-page-placeholder" placeholder="cmap:concept-name">
<a id="cmap-concept-description-link" class="hidden" href="#" data-tr="view-description-page">View description page</a>
</label>
<label class="cmap-dialog-wide">
<span data-tr="synopsis">Synopsis</span>
<textarea id="cmap-concept-synopsis" rows="3"></textarea>
</label>
<fieldset class="cmap-appearance-fields cmap-dialog-wide">
<legend data-tr="appearance">Appearance</legend>
<label class="cmap-style-preset-row">
<span data-tr="style">Style</span>
<span class="cmap-inline-control">
<select id="cmap-concept-style-preset">
<option value="default" data-tr="style-default">Default</option>
<option value="subtle" data-tr="style-subtle">Subtle</option>
<option value="emphasis" data-tr="style-emphasis">Emphasis</option>
<option value="warning" data-tr="style-warning">Warning</option>
<option value="success" data-tr="style-success">Success</option>
</select>
<button id="cmap-apply-style" type="button" data-tr="apply-style">Apply</button>
</span>
</label>
<label>
<span id="cmap-concept-background-label" data-tr="background-color">Background color</span>
<span class="cmap-color-control"><input id="cmap-concept-background" class="cmap-color-input" type="text" value="#f3f6f8"><button type="button" class="cmap-color-swatch" aria-label="Choose background color"></button></span>
</label>
<fieldset class="cmap-typography-fields">
<legend data-tr="concept-heading-appearance">Heading</legend>
<div class="cmap-concept-tabs" role="tablist" aria-label="Concept properties">
<button id="cmap-concept-tab-content" type="button" role="tab" data-cmap-concept-tab="content" aria-controls="cmap-concept-panel-content" aria-selected="true" data-tr="content-and-links">Content &amp; links</button>
<button id="cmap-concept-tab-appearance" type="button" role="tab" data-cmap-concept-tab="appearance" aria-controls="cmap-concept-panel-appearance" aria-selected="false" data-tr="specific-appearance">Specific appearance</button>
</div>
<div class="cmap-concept-dialog-body">
<section id="cmap-concept-panel-content" class="cmap-concept-panel" role="tabpanel" aria-labelledby="cmap-concept-tab-content">
<label>
<span data-tr="text-color">Text color</span>
<span class="cmap-color-control"><input id="cmap-concept-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose heading text color"></button></span>
<span data-tr="label">Label</span>
<input id="cmap-concept-label" type="text" required>
</label>
<label>
<span data-tr="font-family">Font family</span>
<select id="cmap-concept-font-family">
<option value="Arial, Helvetica, sans-serif">Arial</option>
<option value="Verdana, Geneva, sans-serif">Verdana</option>
<option value="Tahoma, Geneva, sans-serif">Tahoma</option>
<option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option>
<option value="Georgia, Times New Roman, serif">Georgia</option>
<option value="Times New Roman, Times, serif">Times New Roman</option>
<option value="Courier New, Courier, monospace">Courier New</option>
<option value="system-ui, sans-serif">System UI</option>
</select>
<span data-tr="aspects">Aspects</span>
<input id="cmap-concept-aspects" type="text" data-tr-placeholder="aspects-placeholder" placeholder="Security, performance">
</label>
<label>
<span data-tr="font-size">Font size</span>
<span class="cmap-font-size-input"><input id="cmap-concept-font-size" type="number" min="6" max="54" step="0.01"><span>pt</span></span>
<label class="cmap-person-tags-field">
<span data-tr="person-tags">People (special tags)</span>
<details id="cmap-person-tags-picker" class="cmap-person-picker">
<summary data-tr="select-people">Select people</summary>
<div id="cmap-person-tag-options" class="cmap-person-tag-options"></div>
<div class="cmap-person-add-row editor-only">
<input id="cmap-person-new-name" type="text" data-tr-placeholder="person-name" placeholder="Person name">
<button id="cmap-person-add" type="button" data-tr="add-person">Add person</button>
</div>
</details>
<small class="muted" data-tr="person-tags-help">These person tags mark who owns the responsibility or action represented by this concept.</small>
</label>
<label class="cmap-check-label"><input id="cmap-concept-bold" type="checkbox" checked><span data-tr="bold">Bold</span></label>
<label class="cmap-check-label"><input id="cmap-concept-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
</fieldset>
<fieldset class="cmap-typography-fields">
<legend data-tr="concept-subtext-appearance">Subtext</legend>
<label>
<span data-tr="text-color">Text color</span>
<span class="cmap-color-control"><input id="cmap-concept-synopsis-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose subtext color"></button></span>
<label class="cmap-quick-style-field">
<span data-tr="style">Style</span>
<select id="cmap-concept-quick-style"></select>
</label>
<label>
<span data-tr="font-family">Font family</span>
<select id="cmap-concept-synopsis-font-family">
<option value="Arial, Helvetica, sans-serif">Arial</option>
<option value="Verdana, Geneva, sans-serif">Verdana</option>
<option value="Tahoma, Geneva, sans-serif">Tahoma</option>
<option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option>
<option value="Georgia, Times New Roman, serif">Georgia</option>
<option value="Times New Roman, Times, serif">Times New Roman</option>
<option value="Courier New, Courier, monospace">Courier New</option>
<option value="system-ui, sans-serif">System UI</option>
</select>
<label class="cmap-dialog-wide">
<span data-tr="synopsis">Synopsis</span>
<textarea id="cmap-concept-synopsis" rows="5"></textarea>
</label>
<label>
<span data-tr="font-size">Font size</span>
<span class="cmap-font-size-input"><input id="cmap-concept-synopsis-font-size" type="number" min="6" max="54" step="0.01"><span>pt</span></span>
<label id="cmap-concept-page-row" class="cmap-concept-page-row">
<span data-tr="linked-page">Linked wiki page</span>
<div id="cmap-concept-page-combobox" class="wiki-combobox">
<input id="cmap-concept-page" type="search" role="combobox"
data-tr-placeholder="filter-pages" placeholder="Filter pages">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show wiki pages"></button>
<div id="cmap-concept-page-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label class="cmap-check-label"><input id="cmap-concept-synopsis-bold" type="checkbox"><span data-tr="bold">Bold</span></label>
<label class="cmap-check-label"><input id="cmap-concept-synopsis-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
</fieldset>
</fieldset>
<fieldset id="cmap-submap-style-fields" class="cmap-submap-style-fields hidden">
<legend data-tr="submap-appearance">Sub-CMap appearance</legend>
<label>
<span data-tr="submap-background-color">Sub-CMap background color</span>
<span class="cmap-color-control"><input id="cmap-submap-background" class="cmap-color-input" type="text" value="#edf7e8"><button type="button" class="cmap-color-swatch" aria-label="Choose submap background color"></button></span>
</label>
<label>
<span data-tr="submap-border-color">Sub-CMap border color</span>
<span class="cmap-color-control"><input id="cmap-submap-border" class="cmap-color-input" type="text" value="#57834a"><button type="button" class="cmap-color-swatch" aria-label="Choose submap border color"></button></span>
</label>
</fieldset>
<fieldset class="cmap-concept-image-fields cmap-dialog-wide">
<legend data-tr="concept-image">Concept image</legend>
<input id="cmap-concept-image" type="file" accept="image/*">
<div id="cmap-concept-image-preview-row" class="cmap-concept-image-preview-row hidden">
<img id="cmap-concept-image-preview" alt="">
<button id="cmap-concept-image-remove" type="button" data-tr="remove-image">Remove image</button>
</div>
</fieldset>
<label id="cmap-concept-cmap-row" class="cmap-concept-page-row">
<span data-tr="linked-concept-map">Linked concept map</span>
<div id="cmap-concept-cmap-combobox" class="wiki-combobox">
<input id="cmap-concept-cmap" type="search" role="combobox"
data-tr-placeholder="filter-concept-maps" placeholder="Filter concept maps">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="cmap-concept-cmap-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label class="cmap-dialog-wide">
<span data-tr="external-web-page">External web page</span>
<input id="cmap-concept-external-url" type="url" inputmode="url" placeholder="https://example.com">
<small class="muted" data-tr="external-web-page-help">Opens in a new browser tab. Only http and https addresses are accepted.</small>
</label>
<label class="cmap-dialog-wide">
<span data-tr="concept-description-page">Concept description page</span>
<input id="cmap-concept-description-page" type="text" data-tr-placeholder="concept-description-page-placeholder" placeholder="cmap:concept-name">
<a id="cmap-concept-description-link" class="hidden" href="#" data-tr="view-description-page">View description page</a>
</label>
<fieldset class="cmap-concept-image-fields cmap-dialog-wide">
<legend data-tr="concept-image">Concept image</legend>
<input id="cmap-concept-image" type="file" accept="image/*">
<div id="cmap-concept-image-preview-row" class="cmap-concept-image-preview-row hidden">
<img id="cmap-concept-image-preview" alt="">
<button id="cmap-concept-image-remove" type="button" data-tr="remove-image">Remove image</button>
</div>
</fieldset>
</section>
<section id="cmap-concept-panel-appearance" class="cmap-concept-panel hidden" role="tabpanel" aria-labelledby="cmap-concept-tab-appearance">
<div class="cmap-style-manager cmap-dialog-wide">
<label>
<span data-tr="styles">Styles</span>
<select id="cmap-concept-style-preset" aria-describedby="cmap-style-storage-help"></select>
</label>
<div class="cmap-style-manager-actions">
<button id="cmap-save-style" type="button" data-tr="save-as-style">Save as style…</button>
<button id="cmap-delete-style" type="button" data-tr="delete-style">Delete style</button>
</div>
<small id="cmap-style-storage-help" class="muted" data-tr="styles-database-storage-help">Styles are shared through this wiki's database.</small>
</div>
<fieldset class="cmap-appearance-fields cmap-dialog-wide">
<legend data-tr="appearance">Appearance</legend>
<label>
<span id="cmap-concept-background-label" data-tr="background-color">Background color</span>
<span class="cmap-color-control"><input id="cmap-concept-background" class="cmap-color-input" type="text" value="#f3f6f8"><button type="button" class="cmap-color-swatch" aria-label="Choose background color"></button></span>
</label>
<fieldset class="cmap-typography-fields">
<legend data-tr="concept-heading-appearance">Heading</legend>
<label><span data-tr="text-color">Text color</span><span class="cmap-color-control"><input id="cmap-concept-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose heading text color"></button></span></label>
<label><span data-tr="font-family">Font family</span><select id="cmap-concept-font-family"><option value="Arial, Helvetica, sans-serif">Arial</option><option value="Verdana, Geneva, sans-serif">Verdana</option><option value="Tahoma, Geneva, sans-serif">Tahoma</option><option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option><option value="Georgia, Times New Roman, serif">Georgia</option><option value="Times New Roman, Times, serif">Times New Roman</option><option value="Courier New, Courier, monospace">Courier New</option><option value="system-ui, sans-serif">System UI</option></select></label>
<label><span data-tr="font-size">Font size</span><span class="cmap-font-size-input"><input id="cmap-concept-font-size" type="number" min="6" max="54" step="0.5"><span>pt</span></span></label>
<label class="cmap-check-label"><input id="cmap-concept-bold" type="checkbox" checked><span data-tr="bold">Bold</span></label>
<label class="cmap-check-label"><input id="cmap-concept-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
</fieldset>
<fieldset class="cmap-typography-fields">
<legend data-tr="concept-subtext-appearance">Subtext</legend>
<label><span data-tr="text-color">Text color</span><span class="cmap-color-control"><input id="cmap-concept-synopsis-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose subtext color"></button></span></label>
<label><span data-tr="font-family">Font family</span><select id="cmap-concept-synopsis-font-family"><option value="Arial, Helvetica, sans-serif">Arial</option><option value="Verdana, Geneva, sans-serif">Verdana</option><option value="Tahoma, Geneva, sans-serif">Tahoma</option><option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option><option value="Georgia, Times New Roman, serif">Georgia</option><option value="Times New Roman, Times, serif">Times New Roman</option><option value="Courier New, Courier, monospace">Courier New</option><option value="system-ui, sans-serif">System UI</option></select></label>
<label><span data-tr="font-size">Font size</span><span class="cmap-font-size-input"><input id="cmap-concept-synopsis-font-size" type="number" min="6" max="54" step="0.5"><span>pt</span></span></label>
<label class="cmap-check-label"><input id="cmap-concept-synopsis-bold" type="checkbox"><span data-tr="bold">Bold</span></label>
<label class="cmap-check-label"><input id="cmap-concept-synopsis-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
</fieldset>
</fieldset>
<fieldset id="cmap-submap-style-fields" class="cmap-submap-style-fields hidden">
<legend data-tr="submap-appearance">Sub-CMap appearance</legend>
<label><span data-tr="submap-background-color">Sub-CMap background color</span><span class="cmap-color-control"><input id="cmap-submap-background" class="cmap-color-input" type="text" value="#edf7e8"><button type="button" class="cmap-color-swatch" aria-label="Choose submap background color"></button></span></label>
<label><span data-tr="submap-border-color">Sub-CMap border color</span><span class="cmap-color-control"><input id="cmap-submap-border" class="cmap-color-input" type="text" value="#57834a"><button type="button" class="cmap-color-swatch" aria-label="Choose submap border color"></button></span></label>
</fieldset>
</section>
</div>
<div class="cmap-concept-dialog-actions">
<button id="cmap-concept-cancel" type="button" data-tr="cancel">Cancel</button>
<button type="submit" class="primary" data-tr="save">Save</button>
@@ -550,6 +582,68 @@
</form>
</dialog>
<dialog id="cmap-people-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-people-title">
<form id="cmap-people-form">
<h2 id="cmap-people-title" data-tr="manage-people">Manage people</h2>
<p class="muted" data-tr="manage-people-help">Inactive people remain on existing concepts but are hidden from the selection list for new tags.</p>
<div id="cmap-people-list" class="cmap-people-list"></div>
<div class="cmap-person-add-row">
<input id="cmap-people-new-name" type="text" data-tr-placeholder="person-name" placeholder="Person name">
<button id="cmap-people-add" type="button" data-tr="add-person">Add person</button>
</div>
<div class="cmap-concept-dialog-actions">
<button id="cmap-people-close" type="button" data-tr="close">Close</button>
</div>
</form>
</dialog>
<dialog id="cmap-metadata-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-metadata-title">
<form id="cmap-metadata-form">
<h2 id="cmap-metadata-title" data-tr="concept-map-details">CMap details</h2>
<label>
<span data-tr="summary">Summary</span>
<textarea id="cmap-metadata-summary" rows="5"></textarea>
</label>
<label>
<span data-tr="tags">Tags (comma separated)</span>
<input id="cmap-metadata-tags" type="text" data-tr-placeholder="tags" placeholder="Tags (comma separated)">
</label>
<label>
<span data-tr="concept-map-explanation-page">CMap explanation page</span>
<input id="cmap-metadata-explanation-page" type="text" data-tr-placeholder="concept-map-explanation-placeholder" placeholder="cmap:explanation">
</label>
<p class="muted" data-tr="concept-map-details-help">These details are included in Markdown exports. The explanation page is always included when it exists.</p>
<div class="cmap-concept-dialog-actions">
<button id="cmap-metadata-cancel" type="button" data-tr="cancel">Cancel</button>
<button type="submit" class="primary" data-tr="save">Save</button>
</div>
</form>
</dialog>
<dialog id="cmap-export-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-export-title">
<form id="cmap-export-form">
<h2 id="cmap-export-title" data-tr="export-markdown">Export CMap…</h2>
<label>
<span data-tr="linked-concept-map-depth">Depth of linked CMaps</span>
<input id="cmap-export-depth" type="number" min="0" max="10" step="1" value="1" required>
</label>
<label class="cmap-check-label">
<input id="cmap-export-pages" type="checkbox">
<span data-tr="include-linked-wiki-pages">Include linked wiki pages in Markdown (JSON always includes them)</span>
</label>
<p class="muted" data-tr="cmap-export-help">Markdown is a readable report. JSON is a complete importable bundle containing diagram layout, concepts and linked wiki pages. Level 0 exports only this CMap.</p>
<p id="cmap-export-status" class="muted" role="status"></p>
<div class="cmap-concept-dialog-actions">
<button id="cmap-export-cancel" type="button" data-tr="cancel">Cancel</button>
<button id="cmap-export-copy" type="button" data-tr="copy-markdown">Copy Markdown</button>
<button id="cmap-export-json" type="button" data-tr="download-cmap-json">Download JSON</button>
<button type="submit" class="primary" data-tr="download-markdown">Download Markdown</button>
</div>
</form>
</dialog>
<input id="cmap-import-file" class="hidden" type="file" accept=".json,application/json">
<dialog id="cmap-unsaved-dialog" class="cmap-unsaved-dialog" aria-labelledby="cmap-unsaved-title">
<form method="dialog">
<h2 id="cmap-unsaved-title" data-tr="unsaved-concept-map">Unsaved concept map</h2>
@@ -574,6 +668,7 @@
</dialog>
<script src="/vendor/purify.min.js"></script>
<script src="/vendor/mermaid.min.js"></script>
<script src="/vendor/highlight.min.js"></script>
<script src="/vendor/highlight-scheme.min.js"></script>
<script src="/vendor/easymde.min.js"></script>
@@ -582,6 +677,9 @@
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
<script src="/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/cmap-export.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/cmap-interchange.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
</body>
</html>
+333
View File
@@ -0,0 +1,333 @@
/* Build a self-contained Markdown report from one stored CMap and its links. */
((root, factory) => {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.RacketWikiCmapExport = api;
})(typeof window !== "undefined" ? window : globalThis, () => {
"use strict";
const labels = {
en: {
exportTitle: "CMap export", generated: "Generated", root: "Root CMap",
depth: "Linked CMap depth", pagesIncluded: "Linked wiki pages included",
yes: "yes", no: "no", cmap: "CMap", level: "level", address: "Address",
tags: "Tags", none: "none", summary: "Summary", noSummary: "No summary supplied.",
explanation: "CMap explanation", concepts: "Concepts", relations: "Relations",
linkedMaps: "Linked CMaps", noConcepts: "No concepts.", noRelations: "No relations.",
synopsis: "Summary", aspects: "Aspects", wikiPage: "Wiki page",
conceptExplanation: "Concept explanation", linkedCmap: "Linked CMap", webPage: "Web page",
people: "People (responsibility/action)",
placements: "Placements", sourceView: "Derived view of", sourceMissing: "Source CMap unavailable",
missingPage: "Page unavailable", linkedPages: "Linked wiki pages", page: "Page"
},
nl: {
exportTitle: "CMap-export", generated: "Gegenereerd", root: "Start-CMap",
depth: "Diepte gekoppelde CMaps", pagesIncluded: "Gekoppelde wikipagina's opgenomen",
yes: "ja", no: "nee", cmap: "CMap", level: "niveau", address: "Adres",
tags: "Tags", none: "geen", summary: "Samenvatting", noSummary: "Geen samenvatting opgegeven.",
explanation: "CMap-uitleg", concepts: "Concepten", relations: "Relaties",
linkedMaps: "Gekoppelde CMaps", noConcepts: "Geen concepten.", noRelations: "Geen relaties.",
synopsis: "Samenvatting", aspects: "Aspecten", wikiPage: "Wikipagina",
conceptExplanation: "Conceptuitleg", linkedCmap: "Gekoppelde CMap", webPage: "Webpagina",
people: "Personen (verantwoordelijkheid/actie)",
placements: "Plaatsingen", sourceView: "Afgeleide weergave van", sourceMissing: "Bron-CMap niet beschikbaar",
missingPage: "Pagina niet beschikbaar", linkedPages: "Gekoppelde wikipagina's", page: "Pagina"
}
};
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
documentValue : {};
}
function cleanMetadata(documentValue) {
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
documentValue.metadata : {};
return {
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String).map((tag) => tag.trim()).filter(Boolean) : [],
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
}
function itemRecords(documentValue) {
const concepts = new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && concept.id)
.map((concept) => [String(concept.id), concept]));
return (Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.id !== undefined)
.map((item) => ({
...item,
...(concepts.get(String(item.conceptId)) || {}),
id: item.id,
conceptId: item.conceptId
}));
}
function derivedDocument(sourceDocument, derivedDocumentValue) {
const pointer = derivedDocumentValue.derivedView || {};
const rootId = Number(pointer.rootItemId);
const allItems = Array.isArray(sourceDocument.items) ? sourceDocument.items : [];
const byId = new Map(allItems.map((item) => [Number(item.id), item]));
const belongsToRoot = (item) => {
if (Number(item.id) === rootId) return true;
let parentId = Number(item.parentSubmapId);
const seen = new Set();
while (Number.isInteger(parentId) && parentId > 0 && !seen.has(parentId)) {
if (parentId === rootId) return true;
seen.add(parentId);
parentId = Number(byId.get(parentId)?.parentSubmapId);
}
return false;
};
const items = allItems.filter(belongsToRoot);
const itemIds = new Set(items.map((item) => Number(item.id)));
const conceptIds = new Set(items.map((item) => item.conceptId).filter(Boolean).map(String));
const rootItem = itemRecords(sourceDocument).find((item) => Number(item.id) === rootId) ||
byId.get(rootId) || {};
const ownMetadata = cleanMetadata(derivedDocumentValue);
const metadata = {
tags: ownMetadata.tags.length ? ownMetadata.tags :
(Array.isArray(rootItem.aspects) ? rootItem.aspects.map(String) : []),
summary: ownMetadata.summary || String(rootItem.synopsis || "").trim(),
explanationPageSlug: ownMetadata.explanationPageSlug || String(rootItem.descriptionPageSlug || "").trim()
};
return {
...sourceDocument,
metadata,
items,
concepts: (Array.isArray(sourceDocument.concepts) ? sourceDocument.concepts : [])
.filter((concept) => conceptIds.has(String(concept.id))),
connectors: (Array.isArray(sourceDocument.connectors) ? sourceDocument.connectors : [])
.filter((connector) => itemIds.has(Number(connector.sourceId)) && itemIds.has(Number(connector.targetId)))
};
}
async function resolveMapDocument(map, loadConceptMap) {
const ownDocument = decodedDocument(map.document);
const pointer = ownDocument.derivedView;
if (!pointer || !pointer.sourceCmapSlug || !Number.isInteger(Number(pointer.rootItemId))) {
return { document: ownDocument, sourceSlug: "" };
}
try {
const sourceMap = await loadConceptMap(pointer.sourceCmapSlug);
return {
document: derivedDocument(decodedDocument(sourceMap.document), ownDocument),
sourceSlug: pointer.sourceCmapSlug
};
} catch (_error) {
return { document: ownDocument, sourceSlug: pointer.sourceCmapSlug, sourceMissing: true };
}
}
function linkedMapSlugs(documentValue) {
return [...new Set(itemRecords(documentValue)
.map((item) => String(item.cmapSlug || "").trim())
.filter(Boolean))];
}
function headingText(value) {
return String(value || "").replace(/[\r\n]+/g, " ").replace(/#+/g, "").trim();
}
function inlineText(value) {
return String(value || "").replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]])/g, "\\$1").trim();
}
function shiftHeadings(markdown, amount) {
let fenced = false;
return String(markdown || "").split("\n").map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
fenced = !fenced;
return line;
}
if (fenced) return line;
return line.replace(/^(#{1,6})\s+/, (match, hashes) =>
`${"#".repeat(Math.min(6, hashes.length + amount))} `);
}).join("\n");
}
function relationLines(documentValue) {
const items = itemRecords(documentValue);
const byId = new Map(items.map((item) => [Number(item.id), item]));
const connectors = (Array.isArray(documentValue.connectors) ? documentValue.connectors : [])
.filter((connector) => connector && connector.sourceId !== undefined && connector.targetId !== undefined);
const used = new Set();
const result = [];
const itemLabel = (id) => headingText(byId.get(Number(id))?.label || `[${id}]`);
for (const phrase of items.filter((item) => item.kind === "phrase")) {
const incoming = connectors.filter((connector) => Number(connector.targetId) === Number(phrase.id));
const outgoing = connectors.filter((connector) => Number(connector.sourceId) === Number(phrase.id));
for (const before of incoming) {
for (const after of outgoing) {
used.add(before);
used.add(after);
result.push(`${itemLabel(before.sourceId)} — **${inlineText(phrase.label || "")}** → ${itemLabel(after.targetId)}`);
}
}
}
for (const connector of connectors) {
if (used.has(connector)) continue;
result.push(`${itemLabel(connector.sourceId)} ${connector.hasArrow === false ? "—" : "→"} ${itemLabel(connector.targetId)}`);
}
return result;
}
function conceptEntries(documentValue) {
const entries = new Map();
for (const item of itemRecords(documentValue)) {
if (item.kind === "phrase") continue;
const key = item.conceptId ? `concept:${item.conceptId}` : `item:${item.id}`;
if (!entries.has(key)) entries.set(key, { ...item, placementCount: 0 });
entries.get(key).placementCount += 1;
}
return [...entries.values()];
}
async function generateMarkdown(options) {
const rootMap = options.rootMap;
if (!rootMap || !rootMap.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
const includeWikiPages = Boolean(options.includeWikiPages);
const locale = String(options.language || "nl").toLowerCase().startsWith("nl") ? "nl" : "en";
const t = labels[locale];
const maps = [];
const visited = new Set();
async function collectMap(map, depth) {
if (!map?.slug || visited.has(map.slug)) return;
visited.add(map.slug);
const resolved = await resolveMapDocument(map, options.loadConceptMap);
maps.push({ map, depth, ...resolved });
if (depth >= maximumDepth) return;
for (const slug of linkedMapSlugs(resolved.document)) {
if (visited.has(slug)) continue;
try {
await collectMap(await options.loadConceptMap(slug), depth + 1);
} catch (error) {
maps.push({ map: { slug, title: slug }, depth: depth + 1, document: {}, loadError: error });
visited.add(slug);
}
}
}
await collectMap(rootMap, 0);
const pageCache = new Map();
const explanationReferences = new Set();
async function loadPage(reference) {
if (!reference || typeof options.loadWikiPage !== "function") return null;
if (!pageCache.has(reference)) {
pageCache.set(reference, Promise.resolve().then(() => options.loadWikiPage(reference))
.catch((error) => ({ slug: reference, title: reference, loadError: error })));
}
return pageCache.get(reference);
}
for (const entry of maps) {
const metadata = cleanMetadata(entry.document);
if (metadata.explanationPageSlug) {
explanationReferences.add(metadata.explanationPageSlug);
await loadPage(metadata.explanationPageSlug);
}
if (includeWikiPages) {
for (const concept of conceptEntries(entry.document)) {
await loadPage(concept.pageSlug);
await loadPage(concept.descriptionPageSlug);
}
}
}
const lines = [
`# ${t.exportTitle}: ${headingText(rootMap.title || rootMap.slug)}`,
"",
`- **${t.generated}:** ${new Date().toISOString()}`,
`- **${t.root}:** \`cmap:${rootMap.slug}\``,
`- **${t.depth}:** ${maximumDepth}`,
`- **${t.pagesIncluded}:** ${includeWikiPages ? t.yes : t.no}`,
""
];
for (const entry of maps) {
const metadata = cleanMetadata(entry.document);
lines.push(`## ${t.cmap}: ${headingText(entry.map.title || entry.map.slug)} (${t.level} ${entry.depth})`, "");
lines.push(`- **${t.address}:** \`cmap:${entry.map.slug}\``);
lines.push(`- **${t.tags}:** ${metadata.tags.length ? metadata.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ") : t.none}`);
if (entry.sourceSlug) lines.push(`- **${t.sourceView}:** \`cmap:${entry.sourceSlug}\``);
if (entry.sourceMissing) lines.push(`- **${t.sourceMissing}:** \`cmap:${entry.sourceSlug}\``);
if (entry.loadError) lines.push(`- **Fout:** ${inlineText(entry.loadError.message || entry.loadError)}`);
lines.push("", `### ${t.summary}`, "", metadata.summary || t.noSummary, "");
if (metadata.explanationPageSlug) {
const page = await loadPage(metadata.explanationPageSlug);
lines.push(`### ${t.explanation}`, "", `**${t.page}:** \`${metadata.explanationPageSlug}\``, "");
if (page?.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
else if (page) {
if (Array.isArray(page.tags) && page.tags.length) {
lines.push(`**${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`, "");
}
lines.push(shiftHeadings(page.markdown || "", 3).trim() || t.noSummary, "");
}
}
lines.push(`### ${t.concepts}`, "");
const concepts = conceptEntries(entry.document);
if (!concepts.length) lines.push(`_${t.noConcepts}_`, "");
for (const concept of concepts) {
lines.push(`#### ${headingText(concept.label || concept.id || "Concept")}`, "");
if (concept.synopsis) lines.push(`- **${t.synopsis}:** ${inlineText(concept.synopsis)}`);
if (Array.isArray(concept.aspects) && concept.aspects.length) {
lines.push(`- **${t.aspects}:** ${concept.aspects.map(inlineText).join(", ")}`);
}
const people = (Array.isArray(concept.tags) ? concept.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
.map((tag) => tag.value);
if (people.length) {
lines.push(`- **${t.people}:** ${people.map(inlineText).join(", ")}`);
}
if (concept.pageSlug) lines.push(`- **${t.wikiPage}:** \`${inlineText(concept.pageSlug)}\``);
if (concept.descriptionPageSlug) lines.push(`- **${t.conceptExplanation}:** \`${inlineText(concept.descriptionPageSlug)}\``);
if (concept.cmapSlug) lines.push(`- **${t.linkedCmap}:** \`cmap:${inlineText(concept.cmapSlug)}\``);
if (concept.externalUrl) lines.push(`- **${t.webPage}:** ${inlineText(concept.externalUrl)}`);
if (concept.placementCount > 1) lines.push(`- **${t.placements}:** ${concept.placementCount}`);
lines.push("");
}
lines.push(`### ${t.relations}`, "");
const relations = relationLines(entry.document);
if (!relations.length) lines.push(`_${t.noRelations}_`, "");
else lines.push(...relations.map((relation) => `- ${relation}`), "");
const linked = linkedMapSlugs(entry.document);
if (linked.length) {
lines.push(`### ${t.linkedMaps}`, "", ...linked.map((slug) => `- \`cmap:${inlineText(slug)}\``), "");
}
}
if (includeWikiPages) {
const pages = [];
for (const [reference, promise] of pageCache) {
if (explanationReferences.has(reference)) continue;
pages.push(await promise);
}
if (pages.length) lines.push(`## ${t.linkedPages}`, "");
for (const page of pages) {
lines.push(`### ${headingText(page.title || page.slug)}`, "", `- **${t.address}:** \`${inlineText(page.slug)}\``);
if (Array.isArray(page.tags) && page.tags.length) {
lines.push(`- **${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`);
}
lines.push("");
if (page.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
else lines.push(shiftHeadings(page.markdown || "", 2).trim() || t.noSummary, "");
}
}
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
}
return { generateMarkdown, decodedDocument, derivedDocument, relationLines };
});
+436
View File
@@ -0,0 +1,436 @@
/* Versioned JSON interchange for Racket Wiki concept maps. */
((root, factory) => {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.RacketWikiCmapInterchange = api;
})(typeof window !== "undefined" ? window : globalThis, () => {
"use strict";
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
const SCHEMA = "/schemas/racket-wiki-cmap-bundle-v1.schema.json";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TEMPORARY_ID = /^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
const SLUG = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}$/u;
const CONCEPT_KEYS = [
"id", "label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function validPageReference(value) {
if (typeof value !== "string") return false;
const separator = value.indexOf(":");
if (separator < 0) return SLUG.test(value);
const namespace = value.slice(0, separator);
const slug = value.slice(separator + 1);
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
}
function validExternalUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === "http:" || url.protocol === "https:";
} catch (_error) {
return false;
}
}
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
documentValue : {};
}
function conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]);
}
return result;
}
function conceptsById(documentValue) {
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
.map((concept) => [concept.id, concept]));
}
function itemConceptIds(documentValue) {
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
.map((item) => item.conceptId))];
}
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
.map((id) => String(concepts.get(id)?.cmapSlug || "").trim())
.filter(Boolean))];
}
function linkedPageReferences(documentValue) {
const references = new Set();
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
documentValue.metadata : {};
if (typeof metadata.explanationPageSlug === "string" && metadata.explanationPageSlug.trim()) {
references.add(metadata.explanationPageSlug.trim());
}
const concepts = conceptsById(documentValue);
for (const id of itemConceptIds(documentValue)) {
const concept = concepts.get(id) || {};
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) references.add(concept[key].trim());
}
}
return references;
}
function attachmentUrls(markdown) {
const source = String(markdown || "");
const urls = new Set();
const add = (value) => {
const url = String(value || "").trim();
if (url.startsWith("/uploads/") && url.split("/").length >= 4) urls.add(url);
};
for (const match of source.matchAll(/!?\[[^\]]*\]\((\/uploads\/[^)]*)\)/g)) add(match[1]);
for (const match of source.matchAll(/(?:src|href)\s*=\s*["'](\/uploads\/[^"']+)["']/gi)) add(match[1]);
for (const match of source.matchAll(/\/uploads\/[^\s"'<>\\)]+/g)) add(match[0]);
const collected = [...urls];
return collected.filter((url) => !collected.some((other) =>
other !== url && other.startsWith(`${url} `)));
}
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
return decodeURIComponent(encoded) || "attachment.bin";
} catch (_error) {
return encoded || "attachment.bin";
}
}
function replaceAttachmentUrls(markdown, replacements) {
let result = String(markdown || "");
const entries = replacements instanceof Map ? [...replacements.entries()] :
Object.entries(replacements || {});
entries.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of entries) {
if (!source || source === target) continue;
result = result.split(source).join(String(target));
}
return result;
}
function placementDocument(documentValue) {
const documentCopy = clone(decodedDocument(documentValue));
const ids = itemConceptIds(documentCopy);
for (const concept of (Array.isArray(documentCopy.concepts) ? documentCopy.concepts : [])) {
if (concept && typeof concept.id === "string" && !ids.includes(concept.id)) ids.push(concept.id);
}
documentCopy.items = (Array.isArray(documentCopy.items) ? documentCopy.items : []).map((item) => {
if (!item || item.kind === "phrase") return item;
return Object.fromEntries(Object.entries(item)
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
});
documentCopy.concepts = ids.map((id) => ({ id }));
if (!Array.isArray(documentCopy.connectors)) documentCopy.connectors = [];
return documentCopy;
}
async function pageRecord(page, requestedReference, loadAttachment) {
const markdown = String(page.markdown || "");
const attachments = [];
for (const url of attachmentUrls(markdown)) {
if (typeof loadAttachment !== "function") {
throw new Error(`Attachment loader is required for ${url}.`);
}
const loaded = await loadAttachment(url, requestedReference);
if (!loaded || typeof loaded.contentBase64 !== "string") {
throw new Error(`Attachment ${url} did not provide base64 content.`);
}
attachments.push({
url,
name: String(loaded.name || attachmentName(url)),
mimeType: String(loaded.mimeType || "application/octet-stream"),
contentBase64: loaded.contentBase64
});
}
return {
reference: String(requestedReference),
title: String(page.title || page.slug || requestedReference),
markdown,
tags: Array.isArray(page.tags) ? page.tags.map(String) : [],
attachments
};
}
async function buildBundle(options) {
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
if (typeof options.loadWikiPage !== "function") throw new Error("loadWikiPage is required.");
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
const maps = [];
const concepts = new Map();
const pageReferences = new Set();
const visited = new Set();
const missingMaps = new Set();
async function collectMap(map, depth) {
if (!map?.slug || visited.has(map.slug)) return;
visited.add(map.slug);
const documentValue = decodedDocument(map.document);
maps.push({
slug: String(map.slug),
title: String(map.title || map.slug),
document: placementDocument(documentValue)
});
for (const concept of conceptsById(documentValue).values()) {
if (!concepts.has(concept.id)) concepts.set(concept.id, conceptContent(concept));
}
for (const reference of linkedPageReferences(documentValue)) pageReferences.add(reference);
const sourceSlug = String(documentValue.derivedView?.sourceCmapSlug || "").trim();
if (sourceSlug && !visited.has(sourceSlug)) {
try {
await collectMap(await options.loadConceptMap(sourceSlug), depth);
} catch (_error) {
missingMaps.add(sourceSlug);
}
}
if (depth >= maximumDepth) return;
for (const slug of linkedMapSlugs(documentValue)) {
if (visited.has(slug)) continue;
try {
await collectMap(await options.loadConceptMap(slug), depth + 1);
} catch (_error) {
missingMaps.add(slug);
}
}
}
await collectMap(options.rootMap, 0);
const pages = [];
const missingPages = [];
for (const reference of [...pageReferences].sort()) {
try {
pages.push(await pageRecord(
await options.loadWikiPage(reference), reference, options.loadAttachment));
} catch (_error) {
missingPages.push(reference);
}
}
const bundle = {
$schema: SCHEMA,
format: FORMAT,
formatVersion: FORMAT_VERSION,
exportedAt: options.exportedAt || new Date().toISOString(),
generator: options.generator || "Racket Wiki",
rootCmapSlug: String(options.rootMap.slug),
cmaps: maps,
concepts: [...concepts.values()],
pages,
missing: { cmaps: [...missingMaps].sort(), pages: missingPages }
};
validateBundle(bundle);
return bundle;
}
function validateBundle(bundle) {
const errors = [];
const issue = (path, message) => errors.push(`${path}: ${message}`);
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new Error("The import must be a JSON object.");
}
if (bundle.format !== FORMAT) issue("format", `must be ${FORMAT}`);
if (bundle.formatVersion !== FORMAT_VERSION) issue("formatVersion", `must be ${FORMAT_VERSION}`);
if (!Array.isArray(bundle.cmaps) || !bundle.cmaps.length) issue("cmaps", "must contain at least one CMap");
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
const conceptIds = new Set();
const conceptLabels = new Set();
const usedConceptIds = new Set();
const linkedPageReferences = new Set();
for (const [index, concept] of (Array.isArray(bundle.concepts) ? bundle.concepts : []).entries()) {
const path = `concepts[${index}]`;
if (!concept || typeof concept !== "object" || Array.isArray(concept)) {
issue(path, "must be an object");
continue;
}
if (typeof concept.id !== "string" || !(UUID.test(concept.id) || TEMPORARY_ID.test(concept.id))) {
issue(`${path}.id`, "must be a UUID or a new:<name> temporary id");
} else if (conceptIds.has(concept.id)) {
issue(`${path}.id`, "is duplicated");
} else conceptIds.add(concept.id);
if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required");
else {
const name = concept.label.trim().toLocaleLowerCase();
if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name");
else conceptLabels.add(name);
}
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) {
linkedPageReferences.add(concept[key].trim());
if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference");
}
}
if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) {
issue(`${path}.cmapSlug`, "must be a valid CMap slug");
}
if (concept.externalUrl !== undefined && concept.externalUrl !== null &&
(typeof concept.externalUrl !== "string" ||
!concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) {
issue(`${path}.externalUrl`, "must be a complete http or https URL");
}
}
const mapSlugs = new Set();
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
const path = `cmaps[${mapIndex}]`;
if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) {
issue(path, "must be an object");
continue;
}
if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug");
else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated");
else mapSlugs.add(cmap.slug);
if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required");
const documentValue = cmap.document;
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
issue(`${path}.document`, "must be an object");
continue;
}
const items = Array.isArray(documentValue.items) ? documentValue.items : [];
const metadataReference = documentValue.metadata?.explanationPageSlug;
if (typeof metadataReference === "string" && metadataReference.trim()) {
linkedPageReferences.add(metadataReference.trim());
if (!validPageReference(metadataReference.trim())) {
issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference");
}
}
const documentConceptIds = new Set();
for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ?
documentValue.concepts : []).entries()) {
const referencePath = `${path}.document.concepts[${referenceIndex}].id`;
if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) {
issue(referencePath, "must reference a concept in concepts[]");
} else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap");
else documentConceptIds.add(reference.id);
}
const itemIds = new Set();
for (const [itemIndex, item] of items.entries()) {
const itemPath = `${path}.document.items[${itemIndex}]`;
if (!item || typeof item !== "object" || Array.isArray(item)) {
issue(itemPath, "must be an object");
continue;
}
if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer");
else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap");
else itemIds.add(Number(item.id));
if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number");
if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number");
if (item.kind !== "phrase") {
if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) {
issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]");
} else usedConceptIds.add(item.conceptId);
}
}
for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ?
documentValue.connectors : []).entries()) {
const connectorPath = `${path}.document.connectors[${connectorIndex}]`;
if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item");
if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item");
}
}
if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) {
issue("rootCmapSlug", "must reference a CMap in cmaps[]");
}
for (const id of conceptIds) {
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
}
const pageReferences = new Set();
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
const path = `pages[${index}]`;
if (!page || typeof page !== "object" || Array.isArray(page)) {
issue(path, "must be an object");
continue;
}
if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference");
else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated");
else pageReferences.add(page.reference);
if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required");
if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string");
if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) {
issue(`${path}.tags`, "must be an array of strings");
}
if (page.attachments !== undefined && !Array.isArray(page.attachments)) {
issue(`${path}.attachments`, "must be an array");
}
const attachmentReferences = new Set();
for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ?
page.attachments : []).entries()) {
const attachmentPath = `${path}.attachments[${attachmentIndex}]`;
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
issue(attachmentPath, "must be an object");
continue;
}
if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) {
issue(`${attachmentPath}.url`, "must be a local /uploads/ URL");
} else if (attachmentReferences.has(attachment.url)) {
issue(`${attachmentPath}.url`, "is duplicated within the page");
} else attachmentReferences.add(attachment.url);
if (typeof attachment.name !== "string" || !attachment.name.trim()) {
issue(`${attachmentPath}.name`, "is required");
}
if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) {
issue(`${attachmentPath}.mimeType`, "is required");
}
if (typeof attachment.contentBase64 !== "string" ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) {
issue(`${attachmentPath}.contentBase64`, "must be valid base64");
}
}
}
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
for (const reference of linkedPageReferences) {
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation");
}
}
if (errors.length) {
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
error.validationErrors = errors;
throw error;
}
return bundle;
}
function preparedMapDocument(bundle, cmap) {
validateBundle(bundle);
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
const documentValue = clone(cmap.document);
const ids = itemConceptIds(documentValue);
for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id);
}
documentValue.concepts = ids.map((id) => clone(byId.get(id)));
return documentValue;
}
return {
FORMAT, FORMAT_VERSION, SCHEMA, buildBundle, validateBundle,
preparedMapDocument, placementDocument, decodedDocument,
attachmentUrls, replaceAttachmentUrls
};
});
+67
View File
@@ -0,0 +1,67 @@
(function (root, factory) {
"use strict";
const api = factory(root);
if (typeof module === "object" && module.exports) module.exports = api;
else root.RacketWikiMermaid = api;
})(typeof window === "object" ? window : globalThis, function (root) {
"use strict";
const selector = "pre > code.language-mermaid, pre > code.lang-mermaid";
let initialized = false;
let renderSequence = 0;
let hydrationTimer = null;
function initialize(mermaidApi = root.mermaid) {
if (initialized) return;
if (!mermaidApi || typeof mermaidApi.initialize !== "function") {
throw new Error("Mermaid is not installed. Open /setup to repair the frontend setup.");
}
mermaidApi.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true
});
initialized = true;
}
async function hydrate(container = root.document, mermaidApi = root.mermaid) {
initialize(mermaidApi);
const blocks = Array.from(container.querySelectorAll(selector));
for (const code of blocks) {
const source = code.parentElement;
if (!source || source.dataset.mermaidState) continue;
source.dataset.mermaidState = "rendering";
try {
const id = `racket-wiki-mermaid-${++renderSequence}`;
const rendered = await mermaidApi.render(id, code.textContent || "");
const diagram = source.ownerDocument.createElement("div");
diagram.className = "rw-mermaid";
diagram.setAttribute("role", "img");
diagram.setAttribute("aria-label", "Mermaid diagram");
diagram.innerHTML = rendered.svg;
source.replaceWith(diagram);
if (typeof rendered.bindFunctions === "function") rendered.bindFunctions(diagram);
} catch (error) {
source.dataset.mermaidState = "error";
source.classList.add("rw-mermaid-error");
source.title = error && error.message ? error.message : "Mermaid diagram could not be rendered";
if (root.console && typeof root.console.error === "function") {
root.console.error("Mermaid diagram could not be rendered", error);
}
}
}
}
function queue(container = root.document) {
if (hydrationTimer !== null) root.clearTimeout(hydrationTimer);
hydrationTimer = root.setTimeout(() => {
hydrationTimer = null;
hydrate(container).catch((error) => {
if (root.console && typeof root.console.error === "function") root.console.error(error);
});
}, 0);
}
return { hydrate, initialize, queue, selector };
});
+1216 -65
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://racket-wiki.local/schemas/racket-wiki-cmap-bundle-v1.schema.json",
"title": "Racket Wiki CMap bundle version 1",
"type": "object",
"required": ["format", "formatVersion", "rootCmapSlug", "cmaps", "concepts", "pages"],
"properties": {
"$schema": { "type": "string" },
"format": { "const": "racket-wiki-cmap-bundle" },
"formatVersion": { "const": 1 },
"exportedAt": { "type": "string", "format": "date-time" },
"generator": { "type": "string" },
"rootCmapSlug": { "$ref": "#/$defs/slug" },
"cmaps": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/cmap" }
},
"concepts": {
"type": "array",
"items": { "$ref": "#/$defs/concept" }
},
"pages": {
"type": "array",
"items": { "$ref": "#/$defs/page" }
},
"missing": {
"type": "object",
"properties": {
"cmaps": { "type": "array", "items": { "$ref": "#/$defs/slug" } },
"pages": { "type": "array", "items": { "type": "string", "minLength": 1 } }
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$defs": {
"slug": {
"type": "string",
"pattern": "^[\\p{L}\\p{N}][\\p{L}\\p{N}._-]{0,119}$"
},
"conceptId": {
"type": "string",
"anyOf": [
{ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" },
{ "pattern": "^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$" }
]
},
"concept": {
"type": "object",
"required": ["id", "label"],
"properties": {
"id": { "$ref": "#/$defs/conceptId" },
"label": { "type": "string", "minLength": 1 },
"synopsis": { "type": "string" },
"aspects": { "type": "array", "items": { "type": "string" } },
"tags": { "type": "array", "items": {} },
"descriptionPageSlug": { "type": ["string", "null"] },
"pageSlug": { "type": ["string", "null"] },
"cmapSlug": { "type": ["string", "null"] },
"externalUrl": { "type": ["string", "null"], "pattern": "^[hH][tT][tT][pP][sS]?://" },
"imageSource": { "type": ["string", "null"] }
},
"additionalProperties": false
},
"conceptReference": {
"type": "object",
"required": ["id"],
"properties": { "id": { "$ref": "#/$defs/conceptId" } },
"additionalProperties": false
},
"item": {
"type": "object",
"required": ["id", "x", "y"],
"properties": {
"id": { "type": "integer" },
"conceptId": { "$ref": "#/$defs/conceptId" },
"kind": { "enum": ["concept", "page", "submap", "phrase"] },
"label": { "type": "string" },
"x": { "type": "number" },
"y": { "type": "number" },
"width": { "type": "number" },
"height": { "type": "number" },
"parentSubmapId": { "type": ["integer", "null"] },
"hidden": { "type": "boolean" },
"backgroundColor": { "type": "string" },
"borderColor": { "type": "string" },
"textColor": { "type": "string" },
"fontFamily": { "type": "string" },
"fontSize": { "type": "number" },
"fontWeight": { "type": "string" },
"fontStyle": { "type": "string" },
"synopsisFontSize": { "type": "number" },
"synopsisFontWeight": { "type": "string" },
"synopsisFontStyle": { "type": "string" },
"submapBackgroundColor": { "type": "string" },
"submapBorderColor": { "type": "string" }
},
"allOf": [
{
"if": { "required": ["kind"], "properties": { "kind": { "const": "phrase" } } },
"then": { "required": ["label"] },
"else": { "required": ["conceptId"] }
}
],
"additionalProperties": true
},
"connector": {
"type": "object",
"required": ["sourceId", "targetId"],
"properties": {
"id": { "type": "integer" },
"sourceId": { "type": "integer" },
"targetId": { "type": "integer" },
"hasArrow": { "type": "boolean" }
},
"additionalProperties": true
},
"document": {
"type": "object",
"required": ["concepts", "items", "connectors"],
"properties": {
"schemaVersion": { "type": "integer" },
"metadata": { "type": "object", "additionalProperties": true },
"derivedView": {
"type": "object",
"required": ["sourceCmapSlug", "rootItemId"],
"properties": {
"sourceCmapSlug": { "$ref": "#/$defs/slug" },
"rootItemId": { "type": "integer" }
},
"additionalProperties": false
},
"concepts": { "type": "array", "items": { "$ref": "#/$defs/conceptReference" } },
"items": { "type": "array", "items": { "$ref": "#/$defs/item" } },
"connectors": { "type": "array", "items": { "$ref": "#/$defs/connector" } },
"conceptMaps": { "type": "array" },
"viewport": { "type": "object" }
},
"additionalProperties": true
},
"cmap": {
"type": "object",
"required": ["slug", "title", "document"],
"properties": {
"slug": { "$ref": "#/$defs/slug" },
"title": { "type": "string", "minLength": 1 },
"document": { "$ref": "#/$defs/document" }
},
"additionalProperties": false
},
"page": {
"type": "object",
"required": ["reference", "title", "markdown", "tags"],
"properties": {
"reference": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"markdown": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"attachments": {
"type": "array",
"items": { "$ref": "#/$defs/attachment" }
}
},
"additionalProperties": false
},
"attachment": {
"type": "object",
"required": ["url", "name", "mimeType", "contentBase64"],
"properties": {
"url": { "type": "string", "pattern": "^/uploads/" },
"name": { "type": "string", "minLength": 1 },
"mimeType": { "type": "string", "minLength": 1 },
"contentBase64": {
"type": "string",
"pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
}
},
"additionalProperties": false
}
}
}
+1
View File
@@ -8,6 +8,7 @@ Pinned sources:
- EasyMDE 2.21.0: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js`
- EasyMDE 2.21.0 CSS: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css`
- DOMPurify 3.4.13: `https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js`
- Mermaid 11.17.1: `https://cdn.jsdelivr.net/npm/mermaid@11.17.1/dist/mermaid.min.js`
- highlight.js 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js`
- highlight.js Scheme grammar 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/languages/scheme.min.js`
- highlight.js GitHub style 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css`