673 lines
24 KiB
JavaScript
673 lines
24 KiB
JavaScript
/*
|
|
* Racket Wiki interaction layer for ionstage/cmap 0.1.3.
|
|
*
|
|
* This file contains the racket-wiki-specific editor model and interactions.
|
|
* The installed upstream cmap.js is adjusted separately so its rendered nodes
|
|
* accept pointer events; concepts can then be selected as normal DOM targets.
|
|
*/
|
|
(() => {
|
|
"use strict";
|
|
|
|
const debugPrefix = "[racket-wiki:cmap 0.2.44]";
|
|
|
|
function debug(message, details) {
|
|
if (details === undefined) {
|
|
console.info(debugPrefix, message);
|
|
return;
|
|
}
|
|
console.info(debugPrefix, message, details);
|
|
}
|
|
|
|
function elementDescription(element) {
|
|
if (!(element instanceof Element)) return String(element);
|
|
return {
|
|
tag: element.tagName,
|
|
id: element.id || null,
|
|
classes: Array.from(element.classList),
|
|
itemId: element.dataset.rwCmapItemId || null
|
|
};
|
|
}
|
|
|
|
function selectionStyle(element) {
|
|
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
|
|
const style = window.getComputedStyle(element);
|
|
return {
|
|
pointerEvents: style.pointerEvents,
|
|
outline: style.outline,
|
|
outlineOffset: style.outlineOffset,
|
|
boxShadow: style.boxShadow,
|
|
overflow: style.overflow,
|
|
zIndex: style.zIndex
|
|
};
|
|
}
|
|
|
|
debug("cmap-racket-wiki.js loaded", {
|
|
script: document.currentScript ? document.currentScript.src : null,
|
|
cmapAvailable: typeof window.Cmap === "function",
|
|
stylesheets: Array.from(document.styleSheets || [])
|
|
.map((sheet) => sheet.href)
|
|
.filter((href) => href && href.includes("cmap.css"))
|
|
});
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Small helpers
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function numberOr(value, fallback) {
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Editor
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
|
|
/**
|
|
* goal : Add CmapTools-like selection, resize and relation drawing on top
|
|
* of ionstage/cmap without changing the upstream library.
|
|
* pre : canvas is a DOM element and window.Cmap is available.
|
|
* post : Concepts and linking phrases can be selected and connected by
|
|
* direct manipulation.
|
|
* result : A CmapEditor instance.
|
|
*/
|
|
class CmapEditor {
|
|
constructor(canvas, options = {}) {
|
|
this.canvas = canvas;
|
|
this.CmapFactory = options.Cmap || window.Cmap;
|
|
this.renderItem = options.renderItem || null;
|
|
this.onOpenPage = options.onOpenPage || null;
|
|
this.onOpenSubMap = options.onOpenSubMap || null;
|
|
this.onSelectionChange = options.onSelectionChange || null;
|
|
this.labels = {
|
|
createRelation: options.createRelationLabel || "Create relation",
|
|
resizeConcept: options.resizeConceptLabel || "Resize concept",
|
|
relation: options.relationLabel || "Relation"
|
|
};
|
|
this.map = this.CmapFactory(canvas);
|
|
this.items = [];
|
|
this.connectors = [];
|
|
this.selectedItem = null;
|
|
this.selectedConnector = null;
|
|
this.nextId = 1;
|
|
this.nextConnectorId = 1;
|
|
this.dragRelation = null;
|
|
this.installCanvasHandlers();
|
|
debug("editor created", {
|
|
canvas: elementDescription(canvas),
|
|
cmapFactoryAvailable: typeof this.CmapFactory === "function"
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Add a draggable concept or linking-phrase node.
|
|
* pre : options may contain the normal ionstage/cmap node attributes.
|
|
* post : The item is drawn and receives selection/relation/resize UI.
|
|
* result : The item record used by the editor.
|
|
*/
|
|
addItem(options = {}) {
|
|
const id = this.nextId++;
|
|
const kind = options.kind || "concept";
|
|
const record = {
|
|
id,
|
|
kind,
|
|
label: options.label || "Concept",
|
|
synopsis: options.synopsis || "",
|
|
pageSlug: options.pageSlug || null,
|
|
childMap: options.childMap || null,
|
|
backgroundColor: options.backgroundColor || "#f3f6f8",
|
|
borderColor: options.borderColor || "#5d6d7e",
|
|
fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif",
|
|
fontSize: options.fontSize || "15px",
|
|
width: options.width || (kind === "phrase" ? 145 : 220),
|
|
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
|
|
node: null
|
|
};
|
|
|
|
const node = this.map.node({
|
|
content: this.itemHtml(record),
|
|
contentType: "html",
|
|
x: numberOr(options.x, 80 + ((id * 37) % 420)),
|
|
y: numberOr(options.y, 80 + ((id * 83) % 360)),
|
|
width: record.width,
|
|
height: record.height,
|
|
backgroundColor: record.backgroundColor,
|
|
borderColor: record.borderColor,
|
|
borderWidth: kind === "phrase" ? 1 : 2,
|
|
textColor: "#222"
|
|
});
|
|
record.node = node;
|
|
this.items.push(record);
|
|
node.redraw();
|
|
this.decorateItem(record);
|
|
debug("item added", {
|
|
id: record.id,
|
|
kind: record.kind,
|
|
label: record.label,
|
|
element: elementDescription(record.node.element()),
|
|
style: selectionStyle(record.node.element())
|
|
});
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* goal : Change presentation/content of an existing item.
|
|
* pre : record belongs to this editor.
|
|
* post : The ionstage node and interaction handles are redrawn.
|
|
*/
|
|
updateItem(record, changes = {}) {
|
|
for (const [key, value] of Object.entries(changes)) {
|
|
if (value !== undefined) record[key] = value;
|
|
}
|
|
|
|
record.width = numberOr(Number(record.width), record.node.attr("width"));
|
|
record.height = numberOr(Number(record.height), record.node.attr("height"));
|
|
record.node.attr({
|
|
content: this.itemHtml(record),
|
|
width: record.width,
|
|
height: record.height,
|
|
backgroundColor: record.backgroundColor,
|
|
borderColor: record.borderColor
|
|
});
|
|
record.node.redraw();
|
|
this.decorateItem(record);
|
|
this.redrawConnectorsFor(record);
|
|
}
|
|
|
|
/**
|
|
* goal : Connect source to target with a separate linking phrase.
|
|
* pre : source and target are items in this editor.
|
|
* post : source -> phrase -> target is visible; the phrase can branch.
|
|
* result : The newly created linking-phrase item.
|
|
*/
|
|
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
|
|
const a = this.itemCenter(source);
|
|
const b = this.itemCenter(target);
|
|
const phrase = this.addItem({
|
|
kind: "phrase",
|
|
label,
|
|
x: ((a.x + b.x) / 2) - 72,
|
|
y: ((a.y + b.y) / 2) - 18,
|
|
width: 145,
|
|
height: 36,
|
|
backgroundColor: "#fffdf7",
|
|
borderColor: "#8c7a4f"
|
|
});
|
|
this.addConnector(source, phrase, false);
|
|
this.addConnector(phrase, target, true);
|
|
this.selectItem(phrase);
|
|
if (editImmediately) this.editPhraseInline(phrase);
|
|
return phrase;
|
|
}
|
|
|
|
/**
|
|
* goal : Add one directed connector between two existing map items.
|
|
* pre : source and target are items in this editor.
|
|
* post : A selectable ionstage/cmap link joins them.
|
|
* result : Connector record.
|
|
*/
|
|
addConnector(source, target, hasArrow = true) {
|
|
const link = this.map.link({
|
|
content: "",
|
|
width: 1,
|
|
height: 1,
|
|
backgroundColor: "transparent",
|
|
borderColor: "transparent",
|
|
borderWidth: 0,
|
|
lineColor: "#333",
|
|
lineWidth: 2,
|
|
hasArrow
|
|
});
|
|
link.sourceNode(source.node).targetNode(target.node);
|
|
link.draggable(true);
|
|
link.redraw();
|
|
|
|
const record = {
|
|
id: this.nextConnectorId++,
|
|
link,
|
|
source,
|
|
target,
|
|
hasArrow,
|
|
lineColor: "#333",
|
|
lineWidth: 2
|
|
};
|
|
this.connectors.push(record);
|
|
this.decorateConnector(record);
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* goal : Select a concept/linking phrase and expose its handles.
|
|
* pre : record belongs to this editor.
|
|
* post : Previous selection is cleared and record is visually selected.
|
|
*/
|
|
selectItem(record) {
|
|
debug("selectItem called", {
|
|
requestedId: record ? record.id : null,
|
|
requestedKind: record ? record.kind : null,
|
|
previousId: this.selectedItem ? this.selectedItem.id : null
|
|
});
|
|
this.clearSelection();
|
|
this.selectedItem = record;
|
|
record.node.toFront();
|
|
const element = record.node.element();
|
|
if (element) {
|
|
element.classList.add("rw-cmap-selected");
|
|
element.setAttribute("aria-selected", "true");
|
|
this.ensureHandles(record, element);
|
|
}
|
|
debug("selection applied", {
|
|
selectedId: this.selectedItem ? this.selectedItem.id : null,
|
|
elementFound: Boolean(element),
|
|
element: elementDescription(element),
|
|
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
|
|
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
|
|
computedStyle: selectionStyle(element)
|
|
});
|
|
if (element && typeof window.requestAnimationFrame === "function") {
|
|
window.requestAnimationFrame(() => {
|
|
debug("selection after browser redraw", {
|
|
selectedId: this.selectedItem ? this.selectedItem.id : null,
|
|
selectedClassPresent: element.classList.contains("rw-cmap-selected"),
|
|
connected: element.isConnected,
|
|
handleCount: element.querySelectorAll(":scope > .rw-cmap-handle").length,
|
|
computedStyle: selectionStyle(element)
|
|
});
|
|
});
|
|
}
|
|
this.notifySelection();
|
|
}
|
|
|
|
/**
|
|
* goal : Select a connector so its line becomes clearly visible.
|
|
* pre : record belongs to this editor.
|
|
* post : Previous selection is cleared and the connector is highlighted.
|
|
*/
|
|
selectConnector(record) {
|
|
this.clearSelection();
|
|
this.selectedConnector = record;
|
|
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
|
|
record.link.redraw();
|
|
this.decorateConnector(record);
|
|
this.notifySelection();
|
|
}
|
|
|
|
/**
|
|
* goal : Remove the current item/connector selection.
|
|
* post : No item handles or connector highlight remain.
|
|
*/
|
|
clearSelection() {
|
|
const clearedItemId = this.selectedItem ? this.selectedItem.id : null;
|
|
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
|
|
if (this.selectedItem) {
|
|
const element = this.selectedItem.node.element();
|
|
if (element) {
|
|
element.classList.remove("rw-cmap-selected");
|
|
element.removeAttribute("aria-selected");
|
|
this.removeHandles(element);
|
|
}
|
|
}
|
|
if (this.selectedConnector) {
|
|
const connector = this.selectedConnector;
|
|
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
|
|
connector.link.redraw();
|
|
this.decorateConnector(connector);
|
|
}
|
|
this.selectedItem = null;
|
|
this.selectedConnector = null;
|
|
if (clearedItemId || clearedConnectorId) {
|
|
debug("selection cleared", { itemId: clearedItemId, connectorId: clearedConnectorId });
|
|
}
|
|
this.notifySelection();
|
|
}
|
|
|
|
selected() {
|
|
return this.selectedItem;
|
|
}
|
|
|
|
/**
|
|
* goal : Start direct editing of a linking phrase.
|
|
* pre : record.kind is "phrase".
|
|
* post : An input appears in the relation-name node and receives focus.
|
|
*/
|
|
editPhraseInline(record) {
|
|
if (!record || record.kind !== "phrase") return;
|
|
const value = record.label || "?????";
|
|
record.node.attr("content",
|
|
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.labels.relation)}">`);
|
|
record.node.redraw();
|
|
this.decorateItem(record);
|
|
const element = record.node.element();
|
|
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
|
|
if (!input) return;
|
|
|
|
const commit = () => {
|
|
const text = input.value.trim() || "?????";
|
|
record.label = text;
|
|
record.node.attr("content", this.itemHtml(record));
|
|
record.node.redraw();
|
|
this.decorateItem(record);
|
|
this.selectItem(record);
|
|
};
|
|
|
|
input.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
input.blur();
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
input.value = value;
|
|
input.blur();
|
|
}
|
|
});
|
|
input.addEventListener("blur", commit, { once: true });
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
|
|
/**
|
|
* goal : Redraw selection controls after ionstage/cmap updates a node.
|
|
* pre : record.node.redraw() has made a DOM element available.
|
|
* post : Selection, drag-to-link and resize interactions are attached.
|
|
*/
|
|
decorateItem(record) {
|
|
const element = record.node.element();
|
|
if (!element) return;
|
|
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
|
|
element.dataset.rwCmapItemId = String(record.id);
|
|
element.style.fontFamily = record.fontFamily;
|
|
element.style.fontSize = record.fontSize;
|
|
element.style.overflow = "visible";
|
|
|
|
if (element.dataset.rwCmapBound !== "1") {
|
|
element.dataset.rwCmapBound = "1";
|
|
debug("item pointer handlers attached", {
|
|
id: record.id,
|
|
kind: record.kind,
|
|
element: elementDescription(element),
|
|
style: selectionStyle(element)
|
|
});
|
|
element.addEventListener("dblclick", (event) => {
|
|
if (event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (record.kind === "phrase") {
|
|
this.editPhraseInline(record);
|
|
return;
|
|
}
|
|
if (record.pageSlug && this.onOpenPage) {
|
|
this.onOpenPage(record);
|
|
return;
|
|
}
|
|
if (record.childMap && this.onOpenSubMap) this.onOpenSubMap(record);
|
|
});
|
|
}
|
|
|
|
if (this.selectedItem === record) {
|
|
element.classList.add("rw-cmap-selected");
|
|
this.ensureHandles(record, element);
|
|
}
|
|
}
|
|
|
|
decorateConnector(record) {
|
|
const element = record.link.element();
|
|
if (!element) return;
|
|
element.classList.add("rw-cmap-connector");
|
|
element.dataset.rwCmapConnectorId = String(record.id);
|
|
if (element.dataset.rwCmapBound !== "1") {
|
|
element.dataset.rwCmapBound = "1";
|
|
element.addEventListener("pointerdown", (event) => {
|
|
event.stopPropagation();
|
|
this.selectConnector(record);
|
|
}, true);
|
|
}
|
|
}
|
|
|
|
redrawConnectorsFor(record) {
|
|
for (const connector of this.connectors) {
|
|
if (connector.source === record || connector.target === record) {
|
|
connector.link.redraw();
|
|
this.decorateConnector(connector);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Select concepts before ionstage/cmap starts a possible drag.
|
|
* pre : Item DOM elements contain data-rw-cmap-item-id attributes.
|
|
* post : Pointer-down on an item selects it immediately; pointer-down on
|
|
* empty canvas space clears the selection.
|
|
*/
|
|
installCanvasHandlers() {
|
|
const itemFromEvent = (event) => {
|
|
if (!(event.target instanceof Element)) return null;
|
|
const element = event.target.closest("[data-rw-cmap-item-id]");
|
|
if (!element || !this.canvas.contains(element)) return null;
|
|
const id = Number(element.dataset.rwCmapItemId);
|
|
return this.items.find((item) => item.id === id) || null;
|
|
};
|
|
|
|
this.canvas.addEventListener("pointerdown", (event) => {
|
|
debug("canvas pointerdown", {
|
|
pointerId: event.pointerId,
|
|
pointerType: event.pointerType,
|
|
button: event.button,
|
|
target: elementDescription(event.target)
|
|
});
|
|
if (event.target instanceof Element &&
|
|
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
|
|
debug("pointerdown belongs to a selection control", elementDescription(event.target));
|
|
return;
|
|
}
|
|
|
|
const item = itemFromEvent(event);
|
|
if (item) {
|
|
debug("pointerdown matched item", { id: item.id, kind: item.kind, label: item.label });
|
|
this.selectItem(item);
|
|
return;
|
|
}
|
|
debug("pointerdown did not match an item", {
|
|
targetIsCanvas: event.target === this.canvas,
|
|
target: elementDescription(event.target)
|
|
});
|
|
if (event.target === this.canvas) this.clearSelection();
|
|
}, true);
|
|
}
|
|
|
|
ensureHandles(record, element) {
|
|
this.removeHandles(element);
|
|
|
|
const relation = document.createElement("button");
|
|
relation.type = "button";
|
|
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
|
|
relation.title = this.labels.createRelation;
|
|
relation.setAttribute("aria-label", this.labels.createRelation);
|
|
relation.setAttribute("aria-hidden", "false");
|
|
relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record));
|
|
element.append(relation);
|
|
|
|
if (record.kind !== "phrase") {
|
|
const resize = document.createElement("button");
|
|
resize.type = "button";
|
|
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
|
|
resize.title = this.labels.resizeConcept;
|
|
resize.setAttribute("aria-label", this.labels.resizeConcept);
|
|
resize.setAttribute("aria-hidden", "false");
|
|
resize.addEventListener("pointerdown", (event) => this.startResize(event, record));
|
|
element.append(resize);
|
|
}
|
|
}
|
|
|
|
removeHandles(element) {
|
|
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
|
|
}
|
|
|
|
startResize(event, record) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const startX = event.clientX;
|
|
const startY = event.clientY;
|
|
const startWidth = Number(record.node.attr("width"));
|
|
const startHeight = Number(record.node.attr("height"));
|
|
const pointerId = event.pointerId;
|
|
event.currentTarget.setPointerCapture(pointerId);
|
|
|
|
const move = (moveEvent) => {
|
|
if (moveEvent.pointerId !== pointerId) return;
|
|
record.width = Math.max(100, startWidth + (moveEvent.clientX - startX));
|
|
record.height = Math.max(42, startHeight + (moveEvent.clientY - startY));
|
|
record.node.attr({ width: record.width, height: record.height });
|
|
record.node.redraw();
|
|
this.decorateItem(record);
|
|
this.redrawConnectorsFor(record);
|
|
};
|
|
|
|
const up = (upEvent) => {
|
|
if (upEvent.pointerId !== pointerId) return;
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", up);
|
|
this.decorateItem(record);
|
|
};
|
|
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", up);
|
|
}
|
|
|
|
startRelationDrag(event, source) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointerId = event.pointerId;
|
|
const start = this.itemCenter(source);
|
|
const draft = this.createDraftLine(start);
|
|
this.dragRelation = { source, draft };
|
|
event.currentTarget.setPointerCapture(pointerId);
|
|
|
|
const move = (moveEvent) => {
|
|
if (moveEvent.pointerId !== pointerId) return;
|
|
const point = this.canvasPoint(moveEvent);
|
|
draft.line.setAttribute("x2", String(point.x));
|
|
draft.line.setAttribute("y2", String(point.y));
|
|
};
|
|
|
|
const up = (upEvent) => {
|
|
if (upEvent.pointerId !== pointerId) return;
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", up);
|
|
draft.svg.remove();
|
|
this.dragRelation = null;
|
|
const target = this.itemAt(upEvent.clientX, upEvent.clientY);
|
|
if (!target || target === source) return;
|
|
this.finishRelation(source, target);
|
|
};
|
|
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", up);
|
|
}
|
|
|
|
finishRelation(source, target) {
|
|
if (source.kind === "phrase" && target.kind !== "phrase") {
|
|
this.addConnector(source, target, true);
|
|
this.selectItem(source);
|
|
return;
|
|
}
|
|
if (source.kind !== "phrase" && target.kind === "phrase") {
|
|
this.addConnector(source, target, false);
|
|
this.selectItem(target);
|
|
return;
|
|
}
|
|
if (source.kind === "phrase" && target.kind === "phrase") return;
|
|
this.connectWithPhrase(source, target, "?????", true);
|
|
}
|
|
|
|
createDraftLine(start) {
|
|
const ns = "http://www.w3.org/2000/svg";
|
|
const svg = document.createElementNS(ns, "svg");
|
|
svg.classList.add("rw-cmap-draft-layer");
|
|
svg.setAttribute("width", String(Math.max(this.canvas.scrollWidth, this.canvas.clientWidth)));
|
|
svg.setAttribute("height", String(Math.max(this.canvas.scrollHeight, this.canvas.clientHeight)));
|
|
const line = document.createElementNS(ns, "line");
|
|
line.setAttribute("x1", String(start.x));
|
|
line.setAttribute("y1", String(start.y));
|
|
line.setAttribute("x2", String(start.x));
|
|
line.setAttribute("y2", String(start.y));
|
|
line.setAttribute("class", "rw-cmap-draft-line");
|
|
svg.append(line);
|
|
this.canvas.append(svg);
|
|
return { svg, line };
|
|
}
|
|
|
|
canvasPoint(event) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
return {
|
|
x: event.clientX - rect.left + this.canvas.scrollLeft,
|
|
y: event.clientY - rect.top + this.canvas.scrollTop
|
|
};
|
|
}
|
|
|
|
itemAt(clientX, clientY) {
|
|
const element = document.elementFromPoint(clientX, clientY);
|
|
const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null;
|
|
if (!itemElement) return null;
|
|
const id = Number(itemElement.dataset.rwCmapItemId);
|
|
return this.items.find((item) => item.id === id) || null;
|
|
}
|
|
|
|
itemCenter(record) {
|
|
return {
|
|
x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2),
|
|
y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2)
|
|
};
|
|
}
|
|
|
|
itemHtml(record) {
|
|
if (record.kind === "phrase") {
|
|
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
|
|
}
|
|
if (this.renderItem) return this.renderItem(record);
|
|
return `<div>${escapeHtml(record.label)}</div>`;
|
|
}
|
|
|
|
notifySelection() {
|
|
if (this.onSelectionChange) this.onSelectionChange(this.selectedItem, this.selectedConnector);
|
|
}
|
|
}
|
|
|
|
let lastEditor = null;
|
|
|
|
window.RacketWikiCmap = {
|
|
version: "0.2.44",
|
|
createEditor(canvas, options) {
|
|
lastEditor = new CmapEditor(canvas, options);
|
|
return lastEditor;
|
|
},
|
|
debugSelection() {
|
|
if (!lastEditor) {
|
|
debug("debugSelection: no editor has been created");
|
|
return null;
|
|
}
|
|
const record = lastEditor.selected();
|
|
const element = record ? record.node.element() : null;
|
|
const result = {
|
|
selectedId: record ? record.id : null,
|
|
selectedKind: record ? record.kind : null,
|
|
selectedLabel: record ? record.label : null,
|
|
element: elementDescription(element),
|
|
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
|
|
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
|
|
computedStyle: selectionStyle(element)
|
|
};
|
|
debug("manual selection inspection", result);
|
|
return result;
|
|
}
|
|
};
|
|
})();
|