added mermaid and a lot of cmap changes
This commit is contained in:
+35
-3
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 & 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>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user