Files
racket-wiki/static/vendor-extensions/cmap-racket-wiki.js
T

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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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;
}
};
})();