added mermaid and a lot of cmap changes

This commit is contained in:
2026-08-27 13:13:39 +02:00
parent 20c1584016
commit 2215d1d04a
35 changed files with 6442 additions and 350 deletions
+35 -3
View File
@@ -15,11 +15,19 @@ submap concepts. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
their independent display label, synopsis, background colour, typography and
optional image. Page concepts retain their linked `pageSlug`; double-click is
their shared name, aspects, people, description page, synopsis, image, external
web address and linked wiki/CMap target, plus placement-specific background
colour and typography. The editor has one compact tab for content, links and
images, and a second tab for detailed colours, typography and submap
presentation. Detailed settings can be saved as named, wiki-wide database
styles; selected styles are applied immediately and non-default styles can be
deleted. Its action bar remains visible while a tab scrolls.
Structural kinds such as a sub-CMap head remain placement roles. Page concepts retain their linked `pageSlug`; double-click is
reserved for opening that wiki page. Concepts may alternatively retain a
`cmapSlug` for another stored CMap or a `parentCmapLink` for navigation back
from a child map. Images are data URLs inside the persisted CMap JSON document.
from a child map. An optional `externalUrl` accepts only HTTP(S), opens in a new
browser tab and is shared by every placement of that concept. Images are data
URLs inside the persisted CMap JSON document.
Font families are selected from a practical list and font sizes are stored in
typographic points. The host can change the linked page or CMap independently
of the visible label and provides background and text-colour pickers.
@@ -39,6 +47,30 @@ selected inline sub-CMap or moves selected child items one level outward.
formatting, positions, connectors, recursive submap membership and promoted
map references. The wiki host persists this JSON document through its CMap API.
## JSON interchange
`/js/cmap-interchange.js` implements the versioned `racket-wiki-cmap-bundle`
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
referenced attachments of linked wiki and explanation pages. Attachments retain
their MIME type and binary base64 content; import uploads them under the target
page and rewrites the Markdown to the newly allocated URL. The root CMap and every included CMap retain
their stable slug. Derived CMaps automatically include their source CMap.
Presentation includes coordinates, dimensions, colours, font settings,
sub-CMap membership, linking phrases and connectors. Connector endpoints use
local item ids; concept identity never depends on those local ids. The formal
contract is
[`racket-wiki-cmap-bundle-v1.schema.json`](../schemas/racket-wiki-cmap-bundle-v1.schema.json).
An external generator may use a temporary id such as `new:security-review` for
a new concept. Every concept must also occur in at least one non-phrase
`document.items[]` placement with coordinates. On import the server turns such
a temporary identity into a UUID through the central Racket UUID helper. UUIDs
already present in an export remain unchanged. Imported CMap slugs likewise
remain unchanged.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
+368 -45
View File
@@ -7,7 +7,10 @@
(() => {
"use strict";
const debugPrefix = "[racket-wiki:cmap 0.2.99]";
const debugPrefix = "[racket-wiki:cmap 0.2.122]";
// The editor is rebuilt when navigating between stored CMaps. Clipboard
// entries therefore belong to the shared module, not to one editor instance.
let copiedConceptReferences = [];
function debug(message, details) {
if (details === undefined) {
@@ -80,12 +83,35 @@
function newConceptId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
try {
return `concept-${window.crypto.randomUUID()}`;
return window.crypto.randomUUID().toLowerCase();
} catch (_error) {
// randomUUID can be exposed but forbidden in an insecure/file context.
}
}
return `concept-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const bytes = new Uint8Array(16);
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
window.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
function normalizeConceptTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
return {
type: String(tag.type || "label").trim() || "label",
value: String(tag.value || tag.name || "").trim()
};
}).filter((tag) => tag && tag.value);
}
//////////////////////////////////////////////////////////////////////////////
@@ -110,6 +136,7 @@
this.onSubMapPromoted = options.onSubMapPromoted || null;
this.onMapChange = options.onMapChange || null;
this.onOpenCmap = options.onOpenCmap || null;
this.onOpenExternalUrl = options.onOpenExternalUrl || null;
this.onOpenStoredSubMap = options.onOpenStoredSubMap || null;
this.onOpenBoundaryReference = options.onOpenBoundaryReference || null;
this.onVisibilityChange = options.onVisibilityChange || null;
@@ -118,6 +145,7 @@
this.onCreateConnectedItem = options.onCreateConnectedItem || null;
this.onSelectionChange = options.onSelectionChange || null;
this.onHistoryChange = options.onHistoryChange || null;
this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null;
this.labels = {
createRelation: options.createRelationLabel || "Create relation",
editConcept: options.editConceptLabel || "Edit concept",
@@ -131,12 +159,12 @@
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
this.activeMapRoot = null;
this.mapHistory = [];
this.selectedItem = null;
this.selectedItems = new Set();
this.selectedConnector = null;
this.copiedConceptIds = [];
this.destroyed = false;
this.nextId = 1;
this.nextConnectorId = 1;
@@ -239,6 +267,7 @@
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
this.activeMapRoot = null;
this.mapHistory = [];
this.nextId = 1;
@@ -322,10 +351,12 @@
label: options.label || "Concept",
synopsis: options.synopsis || "",
aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [],
tags: normalizeConceptTags(options.tags),
descriptionPageSlug: kind === "phrase" ? null :
(options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)),
pageSlug: options.pageSlug || null,
cmapSlug: options.cmapSlug || null,
externalUrl: options.externalUrl || null,
parentCmapLink: Boolean(options.parentCmapLink),
groupId: options.groupId || null,
childMap: options.childMap || null,
@@ -388,9 +419,9 @@
fitContentPending: autoWidth || autoHeight,
node: null
};
record.usageCount = record.conceptId && kind !== "phrase" && kind !== "submap" ?
record.usageCount = record.conceptId && kind !== "phrase" ?
this.items.filter((item) => item.conceptId === record.conceptId &&
item.kind !== "phrase" && item.kind !== "submap").length + 1 : null;
item.kind !== "phrase").length + 1 : null;
const node = this.map.node({
content: this.itemHtml(record),
@@ -424,7 +455,7 @@
.map((item) => item.conceptId).filter(Boolean));
for (const conceptId of ids) {
const peers = this.items.filter((item) => item.conceptId === conceptId &&
item.kind !== "phrase" && item.kind !== "submap");
item.kind !== "phrase");
for (const peer of peers) {
peer.usageCount = peers.length;
if (!peer.node) continue;
@@ -811,6 +842,10 @@
return true;
}
canStepBackWithinMap() {
return this.mapHistory.length > 0;
}
openParentMap() {
if (!this.activeMapRoot) return false;
this.clearSelection();
@@ -983,6 +1018,7 @@
descriptionPageSlug: headDocument.descriptionPageSlug,
pageSlug: null,
cmapSlug: targetSlug,
externalUrl: headDocument.externalUrl || null,
parentCmapLink: false,
imageSource: headDocument.imageSource || ""
});
@@ -995,6 +1031,7 @@
descriptionPageSlug: childHead.descriptionPageSlug,
pageSlug: null,
cmapSlug: null,
externalUrl: childHead.externalUrl || null,
parentCmapLink: false,
imageSource: childHead.imageSource || ""
});
@@ -1084,16 +1121,18 @@
// first, so that automatic sizing cannot turn the edited state into the
// history baseline before it has been committed as its own Undo step.
this.scheduleHistoryCommit();
if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags);
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
const identityKeys = ["kind", "label", "synopsis", "aspects", "descriptionPageSlug",
"pageSlug", "cmapSlug",
"parentCmapLink", "imageSource"];
if (record.conceptId && record.kind !== "submap") {
const identityKeys = ["label", "synopsis", "aspects", "tags",
"descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl",
"imageSource"];
if (record.conceptId && record.kind !== "phrase") {
for (const peer of this.items) {
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "submap") continue;
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue;
for (const key of identityKeys) {
if (changes[key] !== undefined) peer[key] = changes[key];
}
@@ -1225,11 +1264,55 @@
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
this.connectors.push(record);
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
link.onConnectionChange((_changedLink, type, node) =>
this.handleConnectorConnectionChange(record, type, node));
link.visible(this.isItemVisible(source) && this.isItemVisible(target));
this.scheduleHistoryCommit();
return record;
}
/**
* Keep a dragged ionstage/cmap link endpoint and the persisted wiki
* connector model in lockstep. The drawing library owns the endpoint
* handles; the wiki model owns sourceId/targetId and submap projection.
*/
handleConnectorConnectionChange(connector, type, node) {
if (!connector || (type !== "source" && type !== "target")) return false;
const endpoint = this.items.find((item) => item.node === node) || null;
const otherType = type === "source" ? "target" : "source";
// A connector is only a storable relation while both ends are attached
// to different items. Restore the previous logical projection when an
// endpoint is dropped on empty space or on its opposite endpoint.
if (!endpoint || endpoint === connector[otherType]) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
this.applyConnectorVisualEndpoints(connector,
this.connectorEndpoint(connector.source),
this.connectorEndpoint(connector.target));
return false;
}
if (connector[type] === endpoint) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
return false;
}
const previous = connector[type];
connector[type] = endpoint;
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
this.reconcilePhraseMembership();
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
debug("connector endpoint changed", {
connectorId: connector.id,
type,
previousItemId: previous ? previous.id : null,
itemId: endpoint.id
});
this.scheduleHistoryCommit();
return true;
}
/**
* goal : Select a concept/linking phrase, optionally beside the current selection.
* pre : record belongs to this editor.
@@ -1280,6 +1363,8 @@
const selected = this.selectedItems.has(item);
if (element) {
element.classList.toggle("rw-cmap-selected", selected);
element.classList.toggle(
"rw-cmap-selected-primary", selected && item === this.selectedItem);
if (selected) {
element.setAttribute("aria-selected", "true");
item.node.toFront();
@@ -1316,10 +1401,12 @@
for (const item of this.selectedItems) {
if (item.submapFrameElement) {
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected");
item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected-primary");
}
const element = item.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.classList.remove("rw-cmap-selected-primary");
element.removeAttribute("aria-selected");
this.removeHandles(element);
}
@@ -1346,42 +1433,95 @@
return Array.from(this.selectedItems);
}
storeConceptReferences(records) {
if (!records.length) return 0;
const referencesById = new Map(records.map((source) =>
[source.conceptId, {
conceptId: source.conceptId,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
externalUrl: source.externalUrl,
imageSource: source.imageSource,
width: Number(source.node.attr("width")),
height: Number(source.node.attr("height")),
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
fontFamily: source.fontFamily,
fontSize: source.fontSize,
fontWeight: source.fontWeight,
fontStyle: source.fontStyle,
synopsisTextColor: source.synopsisTextColor,
synopsisFontFamily: source.synopsisFontFamily,
synopsisFontSize: source.synopsisFontSize,
synopsisFontWeight: source.synopsisFontWeight,
synopsisFontStyle: source.synopsisFontStyle
}]));
copiedConceptReferences = Array.from(referencesById.values());
return copiedConceptReferences.length;
}
copySelectionReferences() {
this.copiedConceptIds = Array.from(new Set(this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase" && item.kind !== "submap")
.map((item) => item.conceptId)));
const selected = this.selectedAll()
.filter((item) => item.conceptId && item.kind !== "phrase");
const copied = this.storeConceptReferences(selected);
if (!copied) return 0;
this.notifySelection();
return this.copiedConceptIds.length;
return copied;
}
canCutSelectionReferences() {
return this.selectedAll().some((item) =>
item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
}
cutSelectionReferences() {
const cuttable = this.selectedAll().filter((item) =>
item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
if (!cuttable.length) return 0;
const copied = this.storeConceptReferences(cuttable);
if (!copied) return 0;
this.clearSelection(false);
for (const record of cuttable) this.selectedItems.add(record);
this.selectedItem = cuttable.at(-1) || null;
this.deleteSelection();
return copied;
}
canPasteConceptReferences() {
return this.copiedConceptIds.length > 0;
return copiedConceptReferences.length > 0;
}
pasteConceptReferences() {
const sources = this.copiedConceptIds
.map((conceptId) => this.items.find((item) => item.conceptId === conceptId))
.filter(Boolean);
const sources = copiedConceptReferences;
if (!sources.length) return [];
this.clearSelection(false);
const parentSubmap = this.activeMapRoot || null;
const pasted = sources.map((source, index) => this.addItem({
conceptId: source.conceptId,
kind: source.kind,
kind: source.kind === "submap" ? "concept" : source.kind,
label: source.label,
synopsis: source.synopsis,
aspects: source.aspects,
tags: source.tags,
descriptionPageSlug: source.descriptionPageSlug,
pageSlug: source.pageSlug,
cmapSlug: source.cmapSlug,
parentCmapLink: source.parentCmapLink,
externalUrl: source.externalUrl,
parentCmapLink: false,
imageSource: source.imageSource,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: 120 + (index * 36),
y: 120 + (index * 36),
width: Number(source.node.attr("width")),
height: Number(source.node.attr("height")),
width: source.width,
height: source.height,
backgroundColor: source.backgroundColor,
borderColor: source.borderColor,
textColor: source.textColor,
@@ -1413,6 +1553,119 @@
return this.selectedAll();
}
layoutSelectionRecords() {
return this.selectedAll().filter((item) => this.isEffectiveItemVisible(item));
}
canLayoutSelection(command) {
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
return this.layoutSelectionRecords().length >= minimum;
}
applySelectionLayout(command) {
const records = this.layoutSelectionRecords();
if (!this.canLayoutSelection(command)) return false;
const boxes = records.map((record) => ({
record,
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y")),
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height"))
}));
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
const updates = new Map(boxes.map(({ record }) => [record, {}]));
const referenceRight = reference.x + reference.width;
const referenceCenter = reference.x + (reference.width / 2);
const referenceBottom = reference.y + reference.height;
const referenceMiddle = reference.y + (reference.height / 2);
if (["same-width", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).width = reference.width;
}
if (["same-height", "same-size"].includes(command)) {
for (const box of boxes) updates.get(box.record).height = reference.height;
}
if (command === "align-left") {
for (const box of boxes) updates.get(box.record).x = reference.x;
}
if (command === "align-right") {
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
}
if (command === "align-center") {
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
}
if (command === "align-top") {
for (const box of boxes) updates.get(box.record).y = reference.y;
}
if (command === "align-bottom") {
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
}
if (command === "align-middle") {
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
}
if (command === "distribute-horizontal") {
const ordered = [...boxes].sort((a, b) =>
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionLeft = ordered[0].x;
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
let cursor = distributionLeft;
for (const box of ordered) {
updates.get(box.record).x = cursor;
cursor += box.width + gap;
}
}
if (command === "distribute-vertical") {
const ordered = [...boxes].sort((a, b) =>
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
String(a.record.id).localeCompare(String(b.record.id)));
const distributionTop = ordered[0].y;
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
let cursor = distributionTop;
for (const box of ordered) {
updates.get(box.record).y = cursor;
cursor += box.height + gap;
}
}
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
"align-center", "align-top", "align-bottom", "align-middle",
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
this.scheduleHistoryCommit();
for (const record of records) {
const attributes = updates.get(record);
if (attributes.width !== undefined) {
record.width = attributes.width;
record.autoWidth = false;
}
if (attributes.height !== undefined) {
record.height = attributes.height;
record.autoHeight = false;
}
record.node.attr(attributes);
record.node.redraw();
}
this.saveCurrentContextLayout();
this.refreshConnectorGeometry();
for (const submap of this.items
.filter((item) => item.kind === "submap")
.sort((a, b) => b.submapDepth - a.submapDepth)) {
this.updateSubmapFrame(submap);
}
this.refreshSelectionDecoration();
debug("selection layout applied", {
command,
referenceItemId: reference.record.id,
itemIds: records.map((record) => record.id)
});
return true;
}
canGroupSelection() {
const selected = this.selectedAll();
return selected.length >= 2 &&
@@ -1513,36 +1766,54 @@
return true;
}
getDocumentMetadata() {
return {
tags: [...this.documentMetadata.tags],
summary: this.documentMetadata.summary,
explanationPageSlug: this.documentMetadata.explanationPageSlug
};
}
setDocumentMetadata(metadata = {}) {
this.scheduleHistoryCommit();
this.documentMetadata = {
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
.map((tag) => tag.trim()).filter(Boolean) : [],
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
return this.getDocumentMetadata();
}
toDocument() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
const concepts = Array.from(new Map(this.items
.filter((record) => record.conceptId && record.kind !== "phrase" && record.kind !== "submap")
.filter((record) => record.conceptId && record.kind !== "phrase")
.map((record) => [record.conceptId, {
id: record.conceptId,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"),
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
parentCmapLink: record.parentCmapLink,
externalUrl: record.externalUrl,
imageSource: record.imageSource
}])).values());
return {
schemaVersion: 2,
metadata: this.getDocumentMetadata(),
concepts,
items: this.items.map((record) => ({
id: record.id,
conceptId: record.conceptId,
kind: record.kind,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
...(record.kind === "phrase" ? {
label: record.label,
synopsis: record.synopsis
} : {}),
parentCmapLink: record.parentCmapLink,
groupId: record.groupId,
childMap: record.childMap,
@@ -1554,7 +1825,6 @@
mapReference: record.mapReference,
hiddenContexts: Array.from(record.hiddenContexts),
layouts: record.layouts,
imageSource: record.imageSource,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
submapBackgroundColor: record.submapBackgroundColor,
@@ -1598,10 +1868,25 @@
.filter((concept) => concept && concept.id)
.map((concept) => [concept.id, concept]));
const records = new Map();
const metadata = document.metadata && typeof document.metadata === "object" ?
document.metadata : {};
this.documentMetadata = {
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
.map((tag) => tag.trim()).filter(Boolean) : [],
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
for (const itemDocument of itemDocuments) {
const concept = concepts.get(itemDocument.conceptId) || {};
const record = this.addItem({ ...itemDocument, ...concept, parentSubmap: null });
const record = this.addItem({
...itemDocument,
...concept,
id: itemDocument.id,
conceptId: itemDocument.conceptId,
kind: itemDocument.kind || concept.kind || "concept",
parentSubmap: null
});
record.autoWidth = Boolean(itemDocument.autoWidth);
record.autoHeight = Boolean(itemDocument.autoHeight);
record.fitContentPending = false;
@@ -1756,7 +2041,10 @@
element.style.overflow = "visible";
this.applyItemTypography(record, element);
if (record.fitContentPending) {
// Concept content is never presentation-optional. A context may retain a
// different width or a larger manual height, but it may not keep a
// height that clips part of the shared content.
if (record.fitContentPending || record.kind !== "phrase") {
this.fitItemToContent(record, element);
}
@@ -1764,7 +2052,7 @@
if (image && image.dataset.rwCmapFitBound !== "1") {
image.dataset.rwCmapFitBound = "1";
image.addEventListener("load", () => {
if (!record.autoWidth && !record.autoHeight) return;
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
record.fitContentPending = true;
this.fitItemToContent(record, element);
}, { once: true });
@@ -1806,6 +2094,20 @@
});
}
const externalButton = element.querySelector(".rw-cmap-open-external");
if (externalButton && externalButton.dataset.rwCmapBound !== "1") {
externalButton.dataset.rwCmapBound = "1";
externalButton.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
});
externalButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (record.externalUrl && this.onOpenExternalUrl) this.onOpenExternalUrl(record);
});
}
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
@@ -1818,6 +2120,7 @@
if (this.selectedItems.has(record)) {
element.classList.add("rw-cmap-selected");
element.classList.toggle("rw-cmap-selected-primary", this.selectedItem === record);
element.setAttribute("aria-selected", "true");
if (this.selectedItem === record) this.ensureHandles(record, element);
}
@@ -1847,14 +2150,16 @@
}
/**
* goal : Give a new, not manually sized item a compact content-based size.
* goal : Size automatic items and prevent fixed concept cards clipping content.
* pre : element has been rendered and contains the current item HTML.
* post : Automatic dimensions closely surround the text, with long text
* wrapping at a practical maximum width.
*/
fitItemToContent(record, element) {
record.fitContentPending = false;
if (!record.autoWidth && !record.autoHeight) return;
if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
const fixedWidth = !record.autoWidth && record.kind !== "phrase";
const probe = document.createElement("div");
probe.className = element.className;
@@ -1863,9 +2168,9 @@
position: "fixed",
left: "-10000px",
top: "0",
width: "max-content",
width: fixedWidth ? `${record.width}px` : "max-content",
height: "auto",
maxWidth: record.kind === "phrase" ? "280px" : "380px",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
boxSizing: "border-box",
fontFamily: record.fontFamily,
fontSize: record.fontSize,
@@ -1883,9 +2188,9 @@
const content = probe.firstElementChild;
if (content) {
Object.assign(content.style, {
width: "max-content",
width: fixedWidth ? "100%" : "max-content",
height: "auto",
maxWidth: record.kind === "phrase" ? "276px" : "376px",
maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
overflow: "visible",
whiteSpace: "normal"
});
@@ -1900,9 +2205,14 @@
const measuredWidth = Math.ceil(bounds.width) + 4;
const measuredHeight = Math.ceil(bounds.height) + 4;
const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width;
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : record.height;
const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) :
(record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
if (nextWidth === record.width && nextHeight === record.height) return;
// Rendering may finish after the host has recorded its saved baseline.
// Give the host both exact states so it can advance that baseline only
// when no user edit occurred in between.
const beforeAutomaticLayout = this.onAutomaticLayoutChange ? this.historySnapshot() : null;
const previousWidth = record.width;
const previousHeight = record.height;
const attributes = { width: nextWidth, height: nextHeight };
@@ -1922,6 +2232,16 @@
height: nextHeight
});
this.refreshHistorySnapshot();
if (this.onAutomaticLayoutChange) {
const afterAutomaticLayout = this.historySnapshot();
if (beforeAutomaticLayout !== afterAutomaticLayout) {
this.onAutomaticLayoutChange({
beforeSnapshot: beforeAutomaticLayout,
afterSnapshot: afterAutomaticLayout,
itemId: record.id
});
}
}
}
decorateConnector(record, renderedElement = null) {
@@ -2293,6 +2613,9 @@
"--rw-cmap-submap-border", record.submapBorderColor || "#57834a");
record.submapFrameElement.classList.toggle(
"rw-cmap-submap-frame-selected", this.selectedItems.has(record));
record.submapFrameElement.classList.toggle(
"rw-cmap-submap-frame-selected-primary",
this.selectedItems.has(record) && this.selectedItem === record);
record.submapFrameElement.setAttribute("aria-label", record.label);
this.updateSubmapAnchorLine(record, bounds, surface);
this.ensureCanvasExtent(right, bottom);
@@ -2942,7 +3265,7 @@
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.99",
version: "0.2.122",
createEditor(canvas, options) {
lastEditor = new CmapEditor(canvas, options);
return lastEditor;
+301 -17
View File
@@ -280,16 +280,86 @@ body.cmap-mode #main {
.cmap-workspace {
position: relative;
z-index: 0;
display: flex;
align-items: flex-start;
isolation: isolate;
width: 100%;
}
.cmap-selection-toolbar {
position: sticky;
z-index: 120;
top: 82px;
left: 0;
display: grid;
flex: 0 0 82px;
grid-template-columns: 1fr;
gap: 5px;
box-sizing: border-box;
width: 82px;
max-height: calc(100vh - 96px);
margin: 5px 8px 0 0;
padding: 7px;
overflow: auto;
border: 1px solid #c6ccd3;
border-radius: 7px;
background: #f2f4f6;
box-shadow: 0 3px 12px rgb(35 45 55 / 14%);
}
.cmap-selection-tool-group {
display: grid;
grid-template-columns: repeat(2, 30px);
gap: 5px;
}
.cmap-selection-tool-group button {
display: grid;
box-sizing: border-box;
width: 30px;
height: 30px;
padding: 5px;
place-items: center;
border: 1px solid #aeb7c1;
border-radius: 4px;
background: #fff;
color: #344454;
cursor: pointer;
}
.cmap-selection-tool-group button:hover:not(:disabled),
.cmap-selection-tool-group button:focus-visible {
border-color: #64788c;
background: #e4ebf2;
color: #172b3e;
}
.cmap-selection-tool-group button:disabled {
border-color: #d6dade;
background: #f7f8f9;
color: #aeb5bc;
cursor: default;
}
.cmap-selection-tool-group button svg,
.cmap-selection-tool-group button i {
width: 17px;
height: 17px;
}
.cmap-selection-tool-separator {
height: 1px;
margin: 1px 2px;
background: #cbd1d7;
}
.cmap-canvas {
--cmap-a4-width: 1123px;
--cmap-a4-height: 794px;
position: relative;
flex: 1 0 auto;
width: max-content;
min-width: max(760px, 100%);
min-width: max(760px, calc(100% - 90px));
min-height: 794px;
overflow: visible;
border: 0;
@@ -518,7 +588,8 @@ body.cmap-mode #main {
.cmap-description-tooltip .markdown-body > :first-child { margin-top: 0; }
.cmap-description-tooltip .markdown-body > :last-child { margin-bottom: 0; }
.rw-cmap-open-linked {
.rw-cmap-open-linked,
.rw-cmap-open-external {
position: absolute;
z-index: 46;
top: 3px;
@@ -536,15 +607,35 @@ body.cmap-mode #main {
font: 700 12px/1 Arial, Helvetica, sans-serif;
}
.rw-cmap-open-external {
position: absolute;
right: 3px;
border-color: #4479a1;
color: #245f8c;
}
.rw-cmap-open-linked + .rw-cmap-open-external {
right: 27px;
}
.cmap-card-submap .rw-cmap-open-external {
right: 52px;
}
.rw-cmap-view-description + .cmap-card-image,
.rw-cmap-view-description ~ .cmap-card-title {
padding-left: 16px;
}
.rw-cmap-open-linked ~ .cmap-card-title {
.rw-cmap-open-linked ~ .cmap-card-title,
.rw-cmap-open-external ~ .cmap-card-title {
padding-right: 16px;
}
.rw-cmap-open-linked + .rw-cmap-open-external ~ .cmap-card-title {
padding-right: 40px;
}
.cmap-card-title {
margin-bottom: .3rem;
font-size: 1em;
@@ -567,6 +658,24 @@ body.cmap-mode #main {
margin: 0 0 .35rem;
}
.cmap-card-people {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 3px;
margin-top: 4px;
padding: 2px 6px;
overflow: hidden;
border: 1px solid #8b6aa8;
border-radius: 999px;
background: #f1e9f7;
color: #4e3266;
font-size: 0.76em;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmap-card-aspect {
padding: 1px 5px;
border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
@@ -682,6 +791,11 @@ body.cmap-mode #main {
box-shadow: 0 0 0 5px rgb(132 126 255 / 28%);
}
.rw-cmap-submap-frame-selected-primary {
outline-color: #2473c7;
box-shadow: 0 0 0 5px rgb(36 115 199 / 32%);
}
.rw-cmap-submap-frame-toggle {
position: absolute;
top: 50%;
@@ -737,13 +851,19 @@ body.cmap-mode #main {
}
.rw-cmap-selected {
z-index: 20 !important;
z-index: 300000 !important;
outline: 3px solid #9994ff !important;
outline-offset: 2px;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(132 126 255 / 38%) !important;
}
.rw-cmap-selected-primary {
outline-color: #2473c7 !important;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(36 115 199 / 42%) !important;
}
.rw-cmap-marquee {
position: absolute;
z-index: 10000;
@@ -887,7 +1007,8 @@ body.cmap-mode #main {
}
#cmap-concept-dialog {
width: min(780px, calc(100vw - 32px));
width: min(860px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
max-height: calc(100vh - 32px);
}
@@ -947,20 +1068,88 @@ body.cmap-mode #main {
}
#cmap-concept-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px 14px;
max-height: calc(100vh - 34px);
overflow: auto;
padding: 18px;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0;
height: 100%;
max-height: none;
overflow: hidden;
padding: 0;
}
#cmap-concept-form > h2,
#cmap-concept-form > .cmap-dialog-wide,
#cmap-concept-form > .cmap-submap-style-fields,
#cmap-concept-form > .cmap-concept-dialog-actions {
#cmap-concept-form > h2 {
padding: 18px 20px 10px;
}
.cmap-concept-tabs {
display: flex;
gap: 4px;
padding: 0 20px;
border-bottom: 1px solid #cbd2da;
}
.cmap-concept-tabs button {
margin-bottom: -1px;
padding: 9px 14px;
border: 1px solid transparent;
border-bottom-color: #cbd2da;
border-radius: 5px 5px 0 0;
background: transparent;
cursor: pointer;
}
.cmap-concept-tabs button[aria-selected="true"] {
border-color: #cbd2da;
border-bottom-color: #fff;
background: #fff;
color: #173b57;
font-weight: 700;
}
.cmap-concept-dialog-body {
min-height: 0;
overflow: auto;
padding: 16px 20px;
}
.cmap-concept-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px 16px;
}
.cmap-concept-panel.hidden {
display: none;
}
.cmap-concept-panel > .cmap-dialog-wide,
.cmap-concept-panel > .cmap-submap-style-fields {
grid-column: 1 / -1;
}
.cmap-style-manager {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: end;
padding: 11px 12px;
border: 1px solid #b9c7d3;
border-radius: 5px;
background: #f3f7fa;
}
.cmap-style-manager-actions {
display: flex;
gap: 7px;
}
.cmap-style-manager small {
grid-column: 1 / -1;
}
.cmap-quick-style-field {
align-content: start;
}
.cmap-concept-dialog h2 {
margin: 0 0 2px;
}
@@ -973,6 +1162,7 @@ body.cmap-mode #main {
.cmap-concept-dialog input[type="text"],
.cmap-concept-dialog input[type="search"],
.cmap-concept-dialog input[type="url"],
.cmap-concept-dialog input[type="number"],
.cmap-concept-dialog select,
.cmap-concept-dialog textarea {
@@ -1105,6 +1295,89 @@ body.cmap-mode #main {
.cmap-check-label input { margin: 0; }
#cmap-export-form .cmap-check-label {
display: flex;
grid-template-columns: none;
flex-direction: row;
align-items: center;
gap: 8px;
}
.cmap-person-tags-field small {
font-weight: 400;
}
.cmap-concept-panel small {
font-weight: 400;
}
.cmap-person-picker {
position: relative;
font-weight: 400;
}
.cmap-person-picker > summary {
padding: 8px 10px;
border: 1px solid #aab2bd;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
.cmap-person-picker[open] {
padding: 0 9px 9px;
border: 1px solid #aab2bd;
border-radius: 4px;
background: #fff;
}
.cmap-person-picker[open] > summary {
margin: 0 -9px 8px;
border: 0;
border-bottom: 1px solid #d3d7dc;
border-radius: 0;
}
.cmap-person-tag-options,
.cmap-people-list {
display: grid;
gap: 6px;
max-height: 240px;
overflow: auto;
}
.cmap-person-tag-option {
display: flex !important;
grid-template-columns: none !important;
flex-direction: row;
align-items: center;
gap: 7px !important;
font-weight: 400 !important;
}
.cmap-person-add-row,
.cmap-person-admin-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
margin-top: 9px;
}
.cmap-person-admin-row {
grid-template-columns: minmax(180px, 1fr) auto auto;
margin: 0;
}
.cmap-person-admin-row label {
display: flex;
grid-template-columns: none;
flex-direction: row;
align-items: center;
gap: 6px;
font-weight: 400;
}
.cmap-submap-style-fields legend {
grid-column: 1 / -1;
}
@@ -1160,21 +1433,32 @@ body.cmap-mode #main {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
margin: 0;
padding: 12px 20px;
border-top: 1px solid #cbd2da;
background: #fff;
}
@media (max-width: 720px) {
#cmap-concept-form,
.cmap-concept-panel,
.cmap-appearance-fields,
.cmap-typography-fields {
grid-template-columns: 1fr;
}
#cmap-concept-form > *,
.cmap-concept-panel > *,
.cmap-appearance-fields > *,
.cmap-typography-fields > * {
grid-column: 1;
}
.cmap-style-manager {
grid-template-columns: 1fr;
}
.cmap-style-manager small {
grid-column: 1;
}
}
@media (max-width: 1100px) {
+54 -18
View File
@@ -1,5 +1,5 @@
/**
* racket-wiki cmap component 0.2.95
* racket-wiki cmap component 0.2.122
* Based on cmap v0.1.3.
* (c) 2015 iOnStage
* Released under the MIT License.
@@ -596,6 +596,7 @@
this.parentElement = this.prop(null);
this.cache = this.prop({});
this.relations = this.prop([]);
this.connectionChangeHandler = null;
}, Component);
Link.prototype.straighten = function(sx, sy, tx, ty) {
@@ -1241,25 +1242,30 @@
ComponentList.prototype.fromPoint = function(ctor, x, y) {
var data = this.data;
var closeComponent = null;
// The visual stack is connector controls, concepts and finally relations.
// Use that same priority for coordinate hit testing so a relation that is
// hidden behind a concept can never steal the concept's click.
var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
for (var i = data.length - 1; i >= 0; i--) {
var component = data[i];
for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
var tolerance = toleranceIndex === 0 ? 0 : 8;
for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
for (var i = data.length - 1; i >= 0; i--) {
var component = data[i];
if (!(component instanceof ctor))
continue;
if (!(component instanceof types[typeIndex]))
continue;
if (component.visible === false)
continue;
if (component.visible === false)
continue;
if (component.contains(x, y, 0))
return component;
if (!closeComponent && component.contains(x, y, 8))
closeComponent = component;
if (component.contains(x, y, tolerance))
return component;
}
}
}
return closeComponent;
return null;
};
var DisabledConnectorList = helper.inherits(function() {
@@ -1308,6 +1314,10 @@
this.markDirty();
}, Component);
Cmap.LINK_Z_INDEX_BASE = 100;
Cmap.NODE_Z_INDEX_BASE = 100000;
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
Cmap.prototype.add = function(component) {
component.parentElement(this.element());
this.componentList().add(component);
@@ -1331,12 +1341,17 @@
};
Cmap.prototype.updateZIndex = function() {
this.componentList().toArray().forEach(function(component, index) {
var linkIndex = 0;
var nodeIndex = 0;
this.componentList().toArray().forEach(function(component) {
if (component instanceof Connector)
return;
// update z-index of node/link
var zIndex = index * 10;
// Relations always occupy a lower band than concept nodes. Reordering a
// selected component therefore only changes its order inside that band.
var zIndex = component instanceof Link ?
Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
component.zIndex(zIndex);
if (!(component instanceof Link))
@@ -1344,7 +1359,7 @@
// update connector z-index of link
helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
relation.connector().zIndex(zIndex + index + 1);
relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
});
});
};
@@ -1756,6 +1771,15 @@
if (component instanceof Node && typeof component.moveEndHandler === 'function')
component.moveEndHandler(component.x(), component.y(), event);
if (component instanceof Connector) {
var link = context.link;
var triple = helper.firstInstance(link.relations(), Triple);
var connectedNode = triple ? triple[context.type + 'Node']() : null;
if (typeof link.connectionChangeHandler === 'function')
link.connectionChangeHandler(context.type, connectedNode, event);
}
this.unfixScrollSize();
};
@@ -2037,6 +2061,18 @@
return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_TARGET, node);
};
LinkModule.prototype.onConnectionChange = function(handler) {
var module = this;
if (handler !== null && typeof handler !== 'function')
throw TypeError('Invalid connection-change handler');
this.component.connectionChangeHandler = handler ? function(type, node, event) {
var nodeModule = node ? module.cmap.nodeModuleList.fromComponent(node) : null;
handler(module.wrapper, type, nodeModule ? nodeModule.wrapper : null, event);
} : null;
};
LinkModule.prototype.straighten = function() {
var link = this.component;
var triple = helper.firstInstance(link.relations(), Triple);