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);
|
||||
|
||||
Reference in New Issue
Block a user