mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-08 23:33:24 +08:00
Replace raw JSON on card faces with key-value lists, simulate frosted glass on canvas, and use a list editor plus real backdrop blur for the selected overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
1637 lines
51 KiB
TypeScript
1637 lines
51 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
canvasFont,
|
|
clearShadow,
|
|
fillAccentWash,
|
|
paintAccentRail,
|
|
paintBadgeChip,
|
|
paintGlassGlow,
|
|
paintGlassHighlight,
|
|
paintNodeShadow,
|
|
} from "./canvas-chrome";
|
|
import {
|
|
collectSubtreeIds,
|
|
ghostPos,
|
|
isDescendantOrSelf,
|
|
type DragGhost,
|
|
} from "./drag-subtree";
|
|
import { HoverCard } from "./HoverCard";
|
|
import { getCachedImage } from "./image-cache";
|
|
import {
|
|
drawNodeShape,
|
|
nodeBadges,
|
|
nodeBoxSize,
|
|
wrapTextLines,
|
|
} from "./node-geometry";
|
|
import { drawPropSheet, NodePropOverlay } from "./node-props";
|
|
import { branchPalette, readSystemTheme } from "./system-theme";
|
|
import { useAppStore } from "./store";
|
|
import { useCanvasUi } from "./useCanvasUi";
|
|
|
|
const NODE_W = 200;
|
|
|
|
type DragMode = "none" | "pan" | "node" | "link";
|
|
type ToolMode = "select" | "pan" | "link";
|
|
/** branch = drag whole subtree; node = drag only the grabbed node */
|
|
type DragScope = "branch" | "node";
|
|
|
|
const MINI_W = 168;
|
|
const MINI_H = 118;
|
|
|
|
export function TopologyCanvas() {
|
|
const { U } = useCanvasUi();
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const miniRef = useRef<HTMLCanvasElement>(null);
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
const nodes = useAppStore((s) => s.nodes);
|
|
const edges = useAppStore((s) => s.edges);
|
|
const positions = useAppStore((s) => s.positions);
|
|
const selectedIds = useAppStore((s) => s.selectedIds);
|
|
const highlightIds = useAppStore((s) => s.highlightIds);
|
|
const highlightEdgeIds = useAppStore((s) => s.highlightEdgeIds);
|
|
const highlightStyle = useAppStore((s) => s.highlightStyle);
|
|
const canvas = useAppStore((s) => s.canvas);
|
|
const applyOps = useAppStore((s) => s.applyOps);
|
|
const setPrefs = useAppStore((s) => s.setPrefs);
|
|
const patchLocal = useAppStore((s) => s.patchLocal);
|
|
const setSelection = useAppStore((s) => s.setSelection);
|
|
const undo = useAppStore((s) => s.undo);
|
|
const lastChangeSetId = useAppStore((s) => s.lastChangeSetId);
|
|
const embed =
|
|
typeof window !== "undefined" &&
|
|
(() => {
|
|
try {
|
|
const q = new URLSearchParams(window.location.search);
|
|
return q.get("embed") === "1" || q.get("embed") === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const requestBackToRoster = useCallback(() => {
|
|
try {
|
|
window.parent?.postMessage({ type: "dsh-mind-map:back" }, "*");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
const toggleFullscreen = useCallback(async () => {
|
|
const el = hostRef.current;
|
|
if (!el) return;
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
return;
|
|
}
|
|
await el.requestFullscreen();
|
|
} catch {
|
|
// iframe: notify parent Tab fullscreen when needed
|
|
try {
|
|
window.parent?.postMessage({ type: "dsh-mind-map:fullscreen" }, "*");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onFs = () => setFullscreen(Boolean(document.fullscreenElement));
|
|
document.addEventListener("fullscreenchange", onFs);
|
|
return () => document.removeEventListener("fullscreenchange", onFs);
|
|
}, []);
|
|
|
|
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
|
|
const fitViewport = useAppStore((s) => s.fitViewport);
|
|
const consumeFitViewport = useAppStore((s) => s.consumeFitViewport);
|
|
|
|
useEffect(() => {
|
|
if (!fitViewport) return;
|
|
const vp = consumeFitViewport();
|
|
if (vp) setViewport(vp);
|
|
}, [fitViewport, consumeFitViewport]);
|
|
|
|
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
|
const [dragScope, setDragScope] = useState<DragScope>("node");
|
|
const [locked, setLocked] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [themeTick, setThemeTick] = useState(0);
|
|
const [imageTick, setImageTick] = useState(0);
|
|
const [editing, setEditing] = useState<{
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
text: string;
|
|
} | null>(null);
|
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
|
const [linkPreview, setLinkPreview] = useState<{
|
|
fromId: string;
|
|
x: number;
|
|
y: number;
|
|
} | null>(null);
|
|
const [ghost, setGhost] = useState<DragGhost | null>(null);
|
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
|
const [dropForbidden, setDropForbidden] = useState(false);
|
|
const [hostSize, setHostSize] = useState({ w: 1, h: 1 });
|
|
const [dragCursor, setDragCursor] = useState<string | null>(null);
|
|
|
|
const dragRef = useRef<{
|
|
mode: DragMode;
|
|
lastX: number;
|
|
lastY: number;
|
|
space: boolean;
|
|
nodeId: string | null;
|
|
startX: number;
|
|
startY: number;
|
|
moved: boolean;
|
|
alt: boolean;
|
|
forcePin: boolean;
|
|
/** World coords at pointer-down (for grab-point preserving delta). */
|
|
startWorldX: number;
|
|
startWorldY: number;
|
|
subtreeIds: string[];
|
|
}>({
|
|
mode: "none",
|
|
lastX: 0,
|
|
lastY: 0,
|
|
space: false,
|
|
nodeId: null,
|
|
startX: 0,
|
|
startY: 0,
|
|
moved: false,
|
|
alt: false,
|
|
forcePin: false,
|
|
startWorldX: 0,
|
|
startWorldY: 0,
|
|
subtreeIds: [],
|
|
});
|
|
const ghostRef = useRef<DragGhost | null>(null);
|
|
ghostRef.current = ghost;
|
|
const miniDragRef = useRef(false);
|
|
|
|
const theme = useMemo(() => readSystemTheme(), [themeTick]);
|
|
const palette = useMemo(() => branchPalette(theme.brand, 8), [theme.brand]);
|
|
|
|
useEffect(() => {
|
|
const onScheme = () => setThemeTick((n) => n + 1);
|
|
const mq = window.matchMedia("(prefers-color-scheme: light)");
|
|
mq.addEventListener?.("change", onScheme);
|
|
return () => mq.removeEventListener?.("change", onScheme);
|
|
}, []);
|
|
const dens = canvas?.prefs.density === "compact";
|
|
const cardView = canvas?.prefs.nodeViewMode !== "bubble";
|
|
const boxOf = useCallback(
|
|
(n: (typeof nodes)[number]) => nodeBoxSize(n, dens, { asCard: cardView }),
|
|
[dens, cardView],
|
|
);
|
|
const nw = dens ? 160 : NODE_W;
|
|
|
|
const branchColoring = canvas?.prefs.branchColoring !== false;
|
|
const branchColor = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
if (!branchColoring) return map;
|
|
const roots = nodes.filter((n) => {
|
|
if (n.deletedAt) return false;
|
|
return !edges.some(
|
|
(e) => e.kind === "hierarchy" && e.isPrimaryParent && e.to === n.id,
|
|
);
|
|
});
|
|
let i = 0;
|
|
for (const r of roots) {
|
|
map.set(r.id, palette[i % palette.length]!);
|
|
i++;
|
|
}
|
|
const walk = (id: string, color: string) => {
|
|
for (const e of edges) {
|
|
if (e.kind === "hierarchy" && e.isPrimaryParent && e.from === id) {
|
|
map.set(e.to, color);
|
|
walk(e.to, color);
|
|
}
|
|
}
|
|
};
|
|
for (const [id, color] of map) walk(id, color);
|
|
return map;
|
|
}, [nodes, edges, palette, branchColoring]);
|
|
|
|
const toWorld = useCallback(
|
|
(clientX: number, clientY: number) => {
|
|
const host = hostRef.current!;
|
|
const rect = host.getBoundingClientRect();
|
|
return {
|
|
x: (clientX - rect.left - rect.width / 2 - viewport.x) / viewport.zoom,
|
|
y: (clientY - rect.top - rect.height / 2 - viewport.y) / viewport.zoom,
|
|
};
|
|
},
|
|
[viewport],
|
|
);
|
|
|
|
const posOf = useCallback(
|
|
(id: string) => ghostPos(id, positions[id], ghost),
|
|
[positions, ghost],
|
|
);
|
|
|
|
const hitTest = useCallback(
|
|
(wx: number, wy: number, exclude?: Set<string>) => {
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const n = nodes[i]!;
|
|
if (n.deletedAt) continue;
|
|
if (exclude?.has(n.id)) continue;
|
|
const p = posOf(n.id);
|
|
if (!p) continue;
|
|
const { w, h } = boxOf(n);
|
|
if (wx >= p.x - w / 2 && wx <= p.x + w / 2 && wy >= p.y - h / 2 && wy <= p.y + h / 2) {
|
|
return n.id;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
[nodes, posOf, dens],
|
|
);
|
|
|
|
const worldBounds = useMemo(() => {
|
|
let minX = 0;
|
|
let maxX = 0;
|
|
let minY = 0;
|
|
let maxY = 0;
|
|
let any = false;
|
|
for (const n of nodes) {
|
|
if (n.deletedAt) continue;
|
|
const p = positions[n.id];
|
|
if (!p) continue;
|
|
if (!any) {
|
|
minX = p.x;
|
|
maxX = p.x;
|
|
minY = p.y;
|
|
maxY = p.y;
|
|
any = true;
|
|
} else {
|
|
minX = Math.min(minX, p.x);
|
|
maxX = Math.max(maxX, p.x);
|
|
minY = Math.min(minY, p.y);
|
|
maxY = Math.max(maxY, p.y);
|
|
}
|
|
}
|
|
if (!any) return { minX: -200, maxX: 200, minY: -120, maxY: 120 };
|
|
const pad = 80;
|
|
return {
|
|
minX: minX - pad,
|
|
maxX: maxX + pad,
|
|
minY: minY - pad,
|
|
maxY: maxY + pad,
|
|
};
|
|
}, [nodes, positions]);
|
|
|
|
const setZoomAroundCenter = useCallback((nextZoom: number) => {
|
|
setViewport((v) => ({
|
|
...v,
|
|
zoom: Math.min(4, Math.max(0.02, nextZoom)),
|
|
}));
|
|
}, []);
|
|
|
|
const fitView = useCallback(() => {
|
|
const { minX, maxX, minY, maxY } = worldBounds;
|
|
const bw = Math.max(40, maxX - minX);
|
|
const bh = Math.max(40, maxY - minY);
|
|
const w = hostSize.w || 1;
|
|
const h = hostSize.h || 1;
|
|
const zoom = Math.min(
|
|
2.5,
|
|
Math.max(0.12, Math.min((w * 0.86) / bw, (h * 0.86) / bh)),
|
|
);
|
|
const cx = (minX + maxX) / 2;
|
|
const cy = (minY + maxY) / 2;
|
|
setViewport({ x: -cx * zoom, y: -cy * zoom, zoom });
|
|
}, [worldBounds, hostSize]);
|
|
|
|
const panFromMini = useCallback(
|
|
(clientX: number, clientY: number) => {
|
|
const el = miniRef.current;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const { minX, maxX, minY, maxY } = worldBounds;
|
|
const bw = Math.max(1, maxX - minX);
|
|
const bh = Math.max(1, maxY - minY);
|
|
const wx = minX + ((clientX - rect.left) / rect.width) * bw;
|
|
const wy = minY + ((clientY - rect.top) / rect.height) * bh;
|
|
setViewport((v) => ({
|
|
...v,
|
|
x: -wx * v.zoom,
|
|
y: -wy * v.zoom,
|
|
}));
|
|
},
|
|
[worldBounds],
|
|
);
|
|
|
|
const draw = useCallback(() => {
|
|
const el = canvasRef.current;
|
|
if (!el) return;
|
|
const ctx = el.getContext("2d");
|
|
if (!ctx) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
const w = el.clientWidth;
|
|
const h = el.clientHeight;
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.fillStyle = theme.bgBase;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
ctx.save();
|
|
ctx.translate(w / 2 + viewport.x, h / 2 + viewport.y);
|
|
ctx.scale(viewport.zoom, viewport.zoom);
|
|
|
|
ctx.strokeStyle = theme.border;
|
|
ctx.lineWidth = 1 / viewport.zoom;
|
|
const grid = 48;
|
|
const startX =
|
|
Math.floor((-w / 2 - viewport.x) / viewport.zoom / grid) * grid - grid * 2;
|
|
const endX =
|
|
Math.ceil((w / 2 - viewport.x) / viewport.zoom / grid) * grid + grid * 2;
|
|
const startY =
|
|
Math.floor((-h / 2 - viewport.y) / viewport.zoom / grid) * grid - grid * 2;
|
|
const endY =
|
|
Math.ceil((h / 2 - viewport.y) / viewport.zoom / grid) * grid + grid * 2;
|
|
ctx.globalAlpha = 0.35;
|
|
ctx.beginPath();
|
|
for (let x = startX; x <= endX; x += grid) {
|
|
ctx.moveTo(x, startY);
|
|
ctx.lineTo(x, endY);
|
|
}
|
|
for (let y = startY; y <= endY; y += grid) {
|
|
ctx.moveTo(startX, y);
|
|
ctx.lineTo(endX, y);
|
|
}
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
|
|
for (const e of edges) {
|
|
if (canvas?.prefs.showRelationEdges === false && e.kind === "relation") continue;
|
|
const a = posOf(e.from);
|
|
const b = posOf(e.to);
|
|
if (!a || !b) continue;
|
|
const fromN = nodes.find((n) => n.id === e.from);
|
|
const toN = nodes.find((n) => n.id === e.to);
|
|
const aw = fromN ? boxOf(fromN).w : nw;
|
|
const bw = toN ? boxOf(toN).w : nw;
|
|
ctx.beginPath();
|
|
const x1 = a.x + (b.x >= a.x ? aw / 2 : -aw / 2);
|
|
const y1 = a.y;
|
|
const x2 = b.x + (b.x >= a.x ? -bw / 2 : bw / 2);
|
|
const y2 = b.y;
|
|
const cx = (x1 + x2) / 2;
|
|
ctx.moveTo(x1, y1);
|
|
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
|
|
const weight = Math.max(1, Math.min(5, e.weight ?? 1));
|
|
const style = e.lineStyle ?? (e.kind === "relation" ? "dashed" : "solid");
|
|
if (style === "dotted") {
|
|
ctx.setLineDash([2 / viewport.zoom, 4 / viewport.zoom]);
|
|
} else if (style === "dashed") {
|
|
ctx.setLineDash([6 / viewport.zoom, 4 / viewport.zoom]);
|
|
} else {
|
|
ctx.setLineDash([]);
|
|
}
|
|
const isEdgeFlash = highlightEdgeIds.includes(e.id);
|
|
const flashEdgeColor = highlightStyle?.edgeColor ?? theme.brand;
|
|
const flashEdgeWidth = highlightStyle?.edgeWidth ?? 3.6;
|
|
ctx.strokeStyle = isEdgeFlash
|
|
? flashEdgeColor
|
|
: e.kind === "relation"
|
|
? theme.labelSecondary
|
|
: branchColor.get(e.to) ?? theme.brand;
|
|
ctx.lineWidth =
|
|
(isEdgeFlash
|
|
? flashEdgeWidth
|
|
: 1.2 + (weight - 1) * 0.7) / viewport.zoom;
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
// Arrowheads: direction forward|both|none (relation default forward)
|
|
const dir =
|
|
e.direction ?? (e.kind === "relation" ? "forward" : "none");
|
|
if (dir === "forward" || dir === "both") {
|
|
const arrowAng = Math.atan2(0, x2 - cx || (x2 >= a.x ? 1 : -1));
|
|
const size = 8 / viewport.zoom;
|
|
const fill = isEdgeFlash
|
|
? flashEdgeColor
|
|
: e.kind === "relation"
|
|
? theme.labelSecondary
|
|
: branchColor.get(e.to) ?? theme.brand;
|
|
ctx.beginPath();
|
|
ctx.fillStyle = fill;
|
|
ctx.moveTo(x2, y2);
|
|
ctx.lineTo(
|
|
x2 - size * Math.cos(arrowAng - 0.45),
|
|
y2 - size * Math.sin(arrowAng - 0.45),
|
|
);
|
|
ctx.lineTo(
|
|
x2 - size * Math.cos(arrowAng + 0.45),
|
|
y2 - size * Math.sin(arrowAng + 0.45),
|
|
);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
if (dir === "both") {
|
|
const arrowAng = Math.atan2(0, x1 - cx || (a.x >= b.x ? 1 : -1));
|
|
const size = 8 / viewport.zoom;
|
|
const fill = isEdgeFlash
|
|
? flashEdgeColor
|
|
: e.kind === "relation"
|
|
? theme.labelSecondary
|
|
: branchColor.get(e.to) ?? theme.brand;
|
|
ctx.beginPath();
|
|
ctx.fillStyle = fill;
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(
|
|
x1 - size * Math.cos(arrowAng - 0.45),
|
|
y1 - size * Math.sin(arrowAng - 0.45),
|
|
);
|
|
ctx.lineTo(
|
|
x1 - size * Math.cos(arrowAng + 0.45),
|
|
y1 - size * Math.sin(arrowAng + 0.45),
|
|
);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
if (e.label && viewport.zoom >= 0.25) {
|
|
ctx.fillStyle = theme.labelSecondary;
|
|
ctx.font = `11px sans-serif`;
|
|
ctx.fillText(e.label, cx, (y1 + y2) / 2 - 6);
|
|
}
|
|
}
|
|
|
|
if (linkPreview) {
|
|
const a = posOf(linkPreview.fromId);
|
|
if (a) {
|
|
ctx.beginPath();
|
|
ctx.setLineDash([4 / viewport.zoom, 4 / viewport.zoom]);
|
|
ctx.strokeStyle = theme.brand;
|
|
ctx.moveTo(a.x, a.y);
|
|
ctx.lineTo(linkPreview.x, linkPreview.y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
|
|
const badgeMode = canvas?.prefs.nodeBadges ?? "icons";
|
|
|
|
for (const n of nodes) {
|
|
if (n.deletedAt) continue;
|
|
const p = posOf(n.id);
|
|
if (!p) continue;
|
|
const inGhost = Boolean(ghost?.ids.includes(n.id));
|
|
const { w: boxW, h: boxH } = boxOf(n);
|
|
const x = p.x - boxW / 2;
|
|
const y = p.y - boxH / 2;
|
|
const fill = n.accentColor ?? branchColor.get(n.id) ?? theme.bubble;
|
|
const isSel = selectedIds.includes(n.id);
|
|
const isFlash = highlightIds.includes(n.id);
|
|
const flashNodeColor = highlightStyle?.nodeColor ?? theme.brand;
|
|
const isHover = hoverId === n.id;
|
|
const isDrop = dropTargetId === n.id;
|
|
const isForbidden = dropForbidden && isDrop;
|
|
|
|
if (inGhost) ctx.globalAlpha = 0.7;
|
|
if (isFlash && highlightStyle?.nodeGlow !== false) {
|
|
ctx.shadowColor = flashNodeColor;
|
|
ctx.shadowBlur = 14 / viewport.zoom;
|
|
} else if (cardView) {
|
|
// Accent bloom pass (low alpha), then glass body with drop shadow
|
|
ctx.save();
|
|
paintGlassGlow(ctx, fill, viewport.zoom, isSel);
|
|
ctx.globalAlpha = (inGhost ? 0.7 : 1) * (isSel ? 0.28 : 0.16);
|
|
ctx.fillStyle = fill;
|
|
drawNodeShape(
|
|
ctx,
|
|
"card",
|
|
x,
|
|
y,
|
|
boxW,
|
|
boxH,
|
|
dens ? 14 : 16,
|
|
);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
if (inGhost) ctx.globalAlpha = 0.7;
|
|
paintNodeShadow(ctx, viewport.zoom, { selected: isSel });
|
|
} else {
|
|
paintNodeShadow(ctx, viewport.zoom, { selected: isSel });
|
|
}
|
|
ctx.fillStyle = cardView ? theme.glassFill : theme.bgLayer1;
|
|
const drawPreset = cardView ? "card" : n.stylePreset;
|
|
const cornerR = cardView
|
|
? dens
|
|
? 14
|
|
: 16
|
|
: n.stylePreset === "title"
|
|
? boxH / 2
|
|
: 12;
|
|
drawNodeShape(ctx, drawPreset, x, y, boxW, boxH, cornerR);
|
|
ctx.fill();
|
|
clearShadow(ctx);
|
|
ctx.globalAlpha = inGhost ? 0.7 : 1;
|
|
fillAccentWash(
|
|
ctx,
|
|
fill,
|
|
cardView ? 0.06 : n.stylePreset === "title" ? 0.22 : 0.14,
|
|
);
|
|
if (cardView) {
|
|
paintGlassHighlight(
|
|
ctx,
|
|
x,
|
|
y,
|
|
boxW,
|
|
boxH,
|
|
cornerR,
|
|
theme.glassHighlight,
|
|
);
|
|
}
|
|
if (
|
|
!cardView &&
|
|
n.stylePreset !== "decision" &&
|
|
n.stylePreset !== "title" &&
|
|
n.stylePreset !== "note"
|
|
) {
|
|
paintAccentRail(ctx, x, y, boxH, fill, 12);
|
|
}
|
|
if (!cardView && n.stylePreset === "note") {
|
|
ctx.setLineDash([4 / viewport.zoom, 3 / viewport.zoom]);
|
|
} else {
|
|
ctx.setLineDash([]);
|
|
}
|
|
// Card: thin glass border; soft accent only on hover/select (not thick colored frame)
|
|
if (cardView) {
|
|
ctx.strokeStyle = isForbidden
|
|
? theme.warn
|
|
: isDrop || isFlash
|
|
? theme.brand
|
|
: isSel || isHover
|
|
? fill
|
|
: theme.glassBorder;
|
|
ctx.lineWidth =
|
|
(isDrop || isFlash || isSel || isHover ? 1.6 : 1) / viewport.zoom;
|
|
} else {
|
|
ctx.strokeStyle = isForbidden
|
|
? theme.warn
|
|
: isDrop
|
|
? theme.brand
|
|
: n.stylePreset === "risk"
|
|
? theme.warn
|
|
: isFlash
|
|
? flashNodeColor
|
|
: isSel || isHover
|
|
? theme.brand
|
|
: fill;
|
|
ctx.lineWidth =
|
|
(isDrop ||
|
|
isFlash ||
|
|
isSel ||
|
|
n.stylePreset === "title" ||
|
|
n.stylePreset === "decision"
|
|
? 2.4
|
|
: 1.35) / viewport.zoom;
|
|
}
|
|
if (!cardView && n.stylePreset === "muted")
|
|
ctx.globalAlpha = inGhost ? 0.4 : 0.55;
|
|
clearShadow(ctx);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = inGhost ? 0.7 : 1;
|
|
|
|
if (cardView) {
|
|
const selected = selectedIds.includes(n.id);
|
|
if (!selected) {
|
|
drawPropSheet(
|
|
ctx,
|
|
n,
|
|
x,
|
|
y,
|
|
theme,
|
|
viewport.zoom,
|
|
dens,
|
|
fill,
|
|
() => setImageTick((t) => t + 1),
|
|
);
|
|
} else {
|
|
// Match glass header so overlay mount doesn't flash empty
|
|
ctx.fillStyle = theme.glassHeader;
|
|
ctx.fillRect(x, y, boxW, 40);
|
|
ctx.beginPath();
|
|
ctx.arc(x + 16, y + 20, 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = fill;
|
|
ctx.fill();
|
|
ctx.fillStyle = theme.labelPrimary;
|
|
ctx.font = canvasFont(14, 600);
|
|
ctx.textBaseline = "middle";
|
|
const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 44, 1);
|
|
ctx.fillText(ph[0] ?? "?", x + 28, y + 20);
|
|
}
|
|
} else {
|
|
const padX = 14;
|
|
const contentLeft = padX + (n.stylePreset === "decision" || n.stylePreset === "title" || n.stylePreset === "note" ? 0 : 4);
|
|
let contentTop = y + 12;
|
|
|
|
if (n.imageUrl) {
|
|
const ih = dens ? 64 : 78;
|
|
const iw = boxW - contentLeft - 12;
|
|
const img = getCachedImage(n.imageUrl, () =>
|
|
setImageTick((t) => t + 1),
|
|
);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
drawNodeShape(ctx, "default", x + contentLeft, contentTop, iw, ih, 8);
|
|
ctx.clip();
|
|
if (img && img.naturalWidth > 0) {
|
|
// Contain: fit image inside box, no crop
|
|
const scale = Math.min(
|
|
iw / img.naturalWidth,
|
|
ih / img.naturalHeight,
|
|
);
|
|
const dw = img.naturalWidth * scale;
|
|
const dh = img.naturalHeight * scale;
|
|
ctx.fillStyle = "rgba(0,0,0,0.22)";
|
|
ctx.fillRect(x + contentLeft, contentTop, iw, ih);
|
|
ctx.drawImage(
|
|
img,
|
|
x + contentLeft + (iw - dw) / 2,
|
|
contentTop + (ih - dh) / 2,
|
|
dw,
|
|
dh,
|
|
);
|
|
} else {
|
|
ctx.fillStyle = theme.border;
|
|
ctx.fillRect(x + contentLeft, contentTop, iw, ih);
|
|
}
|
|
ctx.restore();
|
|
contentTop += ih + 8;
|
|
}
|
|
|
|
ctx.fillStyle =
|
|
n.stylePreset === "muted" ? theme.labelSecondary : theme.labelPrimary;
|
|
const fontSize = dens
|
|
? 12
|
|
: n.stylePreset === "title"
|
|
? 15
|
|
: 13;
|
|
const weight =
|
|
n.stylePreset === "title" || n.stylePreset === "decision" ? 600 : 500;
|
|
ctx.font = canvasFont(fontSize, weight as 500 | 600);
|
|
ctx.textBaseline = "top";
|
|
const maxW = boxW - contentLeft - 12;
|
|
const maxLines = dens ? 2 : n.stylePreset === "title" ? 2 : 2;
|
|
const lines = wrapTextLines(ctx, n.text || U.unnamed, maxW, maxLines);
|
|
const lineH = fontSize + 3;
|
|
const textBlockH = lines.length * lineH;
|
|
const badges =
|
|
badgeMode !== "off" && viewport.zoom >= 0.35
|
|
? badgeMode === "tags"
|
|
? (n.tags ?? []).slice(0, 2).map((t) => `#${t}`)
|
|
: nodeBadges(n).slice(0, 4)
|
|
: [];
|
|
const badgeH = badges.length ? 20 : 0;
|
|
let titleY = contentTop;
|
|
if (!n.imageUrl) {
|
|
const available = boxH - 16 - badgeH;
|
|
titleY = y + Math.max(10, (available - textBlockH) / 2);
|
|
}
|
|
lines.forEach((line, i) => {
|
|
ctx.fillText(line, x + contentLeft, titleY + i * lineH);
|
|
});
|
|
|
|
if (badges.length) {
|
|
let bx = x + contentLeft;
|
|
const by = y + boxH - 22;
|
|
for (const b of badges) {
|
|
bx += paintBadgeChip(ctx, b, bx, by, theme, viewport.zoom);
|
|
}
|
|
}
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
ctx.restore();
|
|
}, [
|
|
theme,
|
|
viewport,
|
|
edges,
|
|
posOf,
|
|
nodes,
|
|
branchColor,
|
|
selectedIds,
|
|
highlightIds,
|
|
highlightEdgeIds,
|
|
highlightStyle,
|
|
hoverId,
|
|
linkPreview,
|
|
ghost,
|
|
dropTargetId,
|
|
dropForbidden,
|
|
nw,
|
|
dens,
|
|
cardView,
|
|
boxOf,
|
|
canvas?.prefs.showRelationEdges,
|
|
canvas?.prefs.nodeBadges,
|
|
imageTick,
|
|
U,
|
|
]);
|
|
|
|
const drawMini = useCallback(() => {
|
|
const el = miniRef.current;
|
|
if (!el) return;
|
|
const ctx = el.getContext("2d");
|
|
if (!ctx) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
el.width = Math.floor(MINI_W * dpr);
|
|
el.height = Math.floor(MINI_H * dpr);
|
|
el.style.width = `${MINI_W}px`;
|
|
el.style.height = `${MINI_H}px`;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, MINI_W, MINI_H);
|
|
ctx.fillStyle = theme.bgLayer1;
|
|
ctx.fillRect(0, 0, MINI_W, MINI_H);
|
|
|
|
const { minX, maxX, minY, maxY } = worldBounds;
|
|
const bw = Math.max(1, maxX - minX);
|
|
const bh = Math.max(1, maxY - minY);
|
|
const sx = MINI_W / bw;
|
|
const sy = MINI_H / bh;
|
|
const scale = Math.min(sx, sy);
|
|
const ox = (MINI_W - bw * scale) / 2;
|
|
const oy = (MINI_H - bh * scale) / 2;
|
|
const toMini = (wx: number, wy: number) => ({
|
|
x: ox + (wx - minX) * scale,
|
|
y: oy + (wy - minY) * scale,
|
|
});
|
|
|
|
ctx.strokeStyle = theme.border;
|
|
ctx.lineWidth = 1;
|
|
for (const e of edges) {
|
|
if (e.kind !== "hierarchy") continue;
|
|
const a = posOf(e.from);
|
|
const b = posOf(e.to);
|
|
if (!a || !b) continue;
|
|
const p1 = toMini(a.x, a.y);
|
|
const p2 = toMini(b.x, b.y);
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (const n of nodes) {
|
|
if (n.deletedAt) continue;
|
|
const p = posOf(n.id);
|
|
if (!p) continue;
|
|
const m = toMini(p.x, p.y);
|
|
ctx.fillStyle = branchColor.get(n.id) ?? theme.brand;
|
|
ctx.beginPath();
|
|
ctx.arc(m.x, m.y, 2.2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
const vw = hostSize.w / viewport.zoom;
|
|
const vh = hostSize.h / viewport.zoom;
|
|
const vcx = -viewport.x / viewport.zoom;
|
|
const vcy = -viewport.y / viewport.zoom;
|
|
const tl = toMini(vcx - vw / 2, vcy - vh / 2);
|
|
const br = toMini(vcx + vw / 2, vcy + vh / 2);
|
|
ctx.strokeStyle = theme.brand;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(tl.x, tl.y, Math.max(4, br.x - tl.x), Math.max(4, br.y - tl.y));
|
|
}, [
|
|
theme,
|
|
worldBounds,
|
|
edges,
|
|
posOf,
|
|
nodes,
|
|
branchColor,
|
|
hostSize,
|
|
viewport,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const el = canvasRef.current;
|
|
const host = hostRef.current;
|
|
if (!el || !host) return;
|
|
const resize = () => {
|
|
const rect = host.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
el.width = Math.floor(rect.width * dpr);
|
|
el.height = Math.floor(rect.height * dpr);
|
|
el.style.width = `${rect.width}px`;
|
|
el.style.height = `${rect.height}px`;
|
|
setHostSize({ w: rect.width, h: rect.height });
|
|
draw();
|
|
drawMini();
|
|
};
|
|
resize();
|
|
const ro = new ResizeObserver(resize);
|
|
ro.observe(host);
|
|
return () => ro.disconnect();
|
|
}, [draw, drawMini]);
|
|
|
|
useEffect(() => {
|
|
draw();
|
|
drawMini();
|
|
}, [draw, drawMini]);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const tag = (e.target as HTMLElement | null)?.tagName;
|
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
|
|
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
const k = e.key.toLowerCase();
|
|
if (k === "v") setToolMode("select");
|
|
if (k === "h") setToolMode("pan");
|
|
if (k === "c") setToolMode("link");
|
|
if (k === "b") setDragScope("branch");
|
|
if (k === "n") setDragScope("node");
|
|
if (k === "l") setLocked((v) => !v);
|
|
if (k === "f") fitView();
|
|
if (k === "=" || k === "+") setZoomAroundCenter(viewport.zoom * 1.15);
|
|
if (k === "-" || k === "_") setZoomAroundCenter(viewport.zoom / 1.15);
|
|
}
|
|
|
|
if (e.code === "Space") dragRef.current.space = true;
|
|
if (e.key === "Alt") dragRef.current.alt = true;
|
|
if (e.key === "Escape" && dragRef.current.mode === "node") {
|
|
e.preventDefault();
|
|
dragRef.current.mode = "none";
|
|
dragRef.current.nodeId = null;
|
|
dragRef.current.moved = false;
|
|
setGhost(null);
|
|
setDropTargetId(null);
|
|
setDropForbidden(false);
|
|
setDragCursor(null);
|
|
return;
|
|
}
|
|
if (locked) return;
|
|
|
|
if (e.key === "Tab" && !locked) {
|
|
e.preventDefault();
|
|
const parent = selectedIds[0];
|
|
void applyOps([
|
|
parent
|
|
? { type: "create_node", text: U.newNode, parentId: parent }
|
|
: { type: "create_node", text: U.newNode },
|
|
]);
|
|
}
|
|
if (e.key === "ArrowLeft" && selectedIds[0] && !editing) {
|
|
e.preventDefault();
|
|
void applyOps([
|
|
{
|
|
type: "create_node",
|
|
text: U.leftChild,
|
|
parentId: selectedIds[0],
|
|
sidePref: -1,
|
|
},
|
|
]);
|
|
}
|
|
if (e.key === "ArrowRight" && selectedIds[0] && !editing) {
|
|
e.preventDefault();
|
|
void applyOps([
|
|
{
|
|
type: "create_node",
|
|
text: U.rightChild,
|
|
parentId: selectedIds[0],
|
|
sidePref: 1,
|
|
},
|
|
]);
|
|
}
|
|
if ((e.key === "Delete" || e.key === "Backspace") && !editing) {
|
|
const id = selectedIds[0];
|
|
if (id) {
|
|
void applyOps([{ type: "delete_node", nodeId: id }]);
|
|
}
|
|
}
|
|
if (e.key === "Enter" && selectedIds[0] && !editing) {
|
|
e.preventDefault();
|
|
const parentOfSel =
|
|
edges.find(
|
|
(x) =>
|
|
x.kind === "hierarchy" &&
|
|
x.isPrimaryParent &&
|
|
x.to === selectedIds[0],
|
|
)?.from ?? null;
|
|
void applyOps([
|
|
parentOfSel
|
|
? {
|
|
type: "create_node",
|
|
text: U.sibling,
|
|
parentId: parentOfSel,
|
|
}
|
|
: { type: "create_node", text: U.newNode },
|
|
]);
|
|
}
|
|
};
|
|
const onKeyUp = (e: KeyboardEvent) => {
|
|
if (e.code === "Space") dragRef.current.space = false;
|
|
if (e.key === "Alt") dragRef.current.alt = false;
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("keyup", onKeyUp);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("keyup", onKeyUp);
|
|
};
|
|
}, [
|
|
applyOps,
|
|
canvas,
|
|
selectedIds,
|
|
editing,
|
|
edges,
|
|
locked,
|
|
fitView,
|
|
setZoomAroundCenter,
|
|
viewport.zoom,
|
|
U,
|
|
]);
|
|
|
|
const cursor =
|
|
dragCursor ??
|
|
(toolMode === "pan" || dragRef.current.space
|
|
? "grab"
|
|
: toolMode === "link"
|
|
? "crosshair"
|
|
: "default");
|
|
|
|
return (
|
|
<div
|
|
className={`canvas-host${locked ? " is-locked" : ""}`}
|
|
ref={hostRef}
|
|
style={{ cursor }}
|
|
onWheel={(e) => {
|
|
e.preventDefault();
|
|
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
setViewport((v) => ({
|
|
...v,
|
|
zoom: Math.min(4, Math.max(0.02, v.zoom * factor)),
|
|
}));
|
|
}}
|
|
onPointerDown={(e) => {
|
|
const t = e.target as HTMLElement | null;
|
|
if (
|
|
t?.closest?.(
|
|
".canvas-toolbar, .canvas-controls, .canvas-minimap, .edit-overlay, .node-handles, .hover-card, .inspector",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const world = toWorld(e.clientX, e.clientY);
|
|
const hit = hitTest(world.x, world.y);
|
|
dragRef.current.lastX = e.clientX;
|
|
dragRef.current.lastY = e.clientY;
|
|
dragRef.current.startX = e.clientX;
|
|
dragRef.current.startY = e.clientY;
|
|
dragRef.current.moved = false;
|
|
dragRef.current.alt = e.altKey;
|
|
dragRef.current.forcePin = e.ctrlKey || e.metaKey;
|
|
dragRef.current.startWorldX = world.x;
|
|
dragRef.current.startWorldY = world.y;
|
|
dragRef.current.subtreeIds = [];
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
|
|
const wantPan =
|
|
toolMode === "pan" ||
|
|
dragRef.current.space ||
|
|
e.button === 1 ||
|
|
e.button === 2 ||
|
|
(!hit && toolMode !== "link");
|
|
|
|
if (wantPan) {
|
|
dragRef.current.mode = "pan";
|
|
dragRef.current.nodeId = null;
|
|
// Blank click clears selection (still allows pan-drag).
|
|
if (!hit) setSelection([]);
|
|
setDragCursor("grabbing");
|
|
return;
|
|
}
|
|
|
|
if (locked) {
|
|
if (hit) setSelection([hit]);
|
|
else setSelection([]);
|
|
dragRef.current.mode = "none";
|
|
return;
|
|
}
|
|
|
|
if (hit && (toolMode === "link" || e.altKey)) {
|
|
dragRef.current.mode = "link";
|
|
dragRef.current.nodeId = hit;
|
|
setSelection([hit]);
|
|
setLinkPreview({ fromId: hit, x: world.x, y: world.y });
|
|
return;
|
|
}
|
|
|
|
if (hit && toolMode === "select") {
|
|
dragRef.current.mode = "node";
|
|
dragRef.current.nodeId = hit;
|
|
// Shift temporarily forces single-node; otherwise use toolbar scope
|
|
const single =
|
|
e.shiftKey || dragScope === "node";
|
|
dragRef.current.subtreeIds = single
|
|
? [hit]
|
|
: collectSubtreeIds(hit, edges);
|
|
setSelection([hit]);
|
|
return;
|
|
}
|
|
|
|
dragRef.current.mode = "pan";
|
|
setDragCursor("grabbing");
|
|
}}
|
|
onPointerMove={(e) => {
|
|
let world = toWorld(e.clientX, e.clientY);
|
|
const dx = e.clientX - dragRef.current.startX;
|
|
const dy = e.clientY - dragRef.current.startY;
|
|
if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true;
|
|
|
|
if (dragRef.current.mode === "pan") {
|
|
const pdx = e.clientX - dragRef.current.lastX;
|
|
const pdy = e.clientY - dragRef.current.lastY;
|
|
dragRef.current.lastX = e.clientX;
|
|
dragRef.current.lastY = e.clientY;
|
|
setViewport((v) => ({ ...v, x: v.x + pdx, y: v.y + pdy }));
|
|
return;
|
|
}
|
|
if (dragRef.current.mode === "link" && dragRef.current.nodeId) {
|
|
setLinkPreview({
|
|
fromId: dragRef.current.nodeId,
|
|
x: world.x,
|
|
y: world.y,
|
|
});
|
|
return;
|
|
}
|
|
if (dragRef.current.mode === "node" && dragRef.current.nodeId && dragRef.current.moved) {
|
|
const rootId = dragRef.current.nodeId;
|
|
|
|
// Edge auto-pan near viewport borders
|
|
const host = hostRef.current;
|
|
if (host) {
|
|
const rect = host.getBoundingClientRect();
|
|
const margin = 48;
|
|
let panX = 0;
|
|
let panY = 0;
|
|
if (e.clientX < rect.left + margin) panX = 12;
|
|
else if (e.clientX > rect.right - margin) panX = -12;
|
|
if (e.clientY < rect.top + margin) panY = 12;
|
|
else if (e.clientY > rect.bottom - margin) panY = -12;
|
|
if (panX || panY) {
|
|
setViewport((v) => ({ ...v, x: v.x + panX, y: v.y + panY }));
|
|
// Adjust start world so grab point stays consistent after pan
|
|
dragRef.current.startWorldX += panX / viewport.zoom;
|
|
dragRef.current.startWorldY += panY / viewport.zoom;
|
|
world = toWorld(e.clientX, e.clientY);
|
|
}
|
|
}
|
|
|
|
// Preserve grab point: delta = pointer movement in world space
|
|
const gdx = world.x - dragRef.current.startWorldX;
|
|
const gdy = world.y - dragRef.current.startWorldY;
|
|
const ids =
|
|
dragRef.current.subtreeIds.length > 0
|
|
? dragRef.current.subtreeIds
|
|
: collectSubtreeIds(rootId, edges);
|
|
setGhost({ rootId, dx: gdx, dy: gdy, ids });
|
|
setDragCursor("grabbing");
|
|
|
|
dragRef.current.forcePin = e.ctrlKey || e.metaKey;
|
|
if (dragRef.current.forcePin) {
|
|
setDropTargetId(null);
|
|
setDropForbidden(false);
|
|
setHoverId(null);
|
|
return;
|
|
}
|
|
|
|
const exclude = new Set(ids);
|
|
const hit = hitTest(world.x, world.y, exclude);
|
|
setHoverId(hit);
|
|
if (hit && hit !== rootId) {
|
|
const forbidden = isDescendantOrSelf(hit, rootId, edges);
|
|
setDropTargetId(hit);
|
|
setDropForbidden(forbidden);
|
|
} else {
|
|
setDropTargetId(null);
|
|
setDropForbidden(false);
|
|
}
|
|
}
|
|
}}
|
|
onPointerUp={(e) => {
|
|
const world = toWorld(e.clientX, e.clientY);
|
|
const mode = dragRef.current.mode;
|
|
const fromId = dragRef.current.nodeId;
|
|
const moved = dragRef.current.moved;
|
|
const g = ghostRef.current;
|
|
const forcePin = dragRef.current.forcePin || e.ctrlKey || e.metaKey;
|
|
|
|
if (!locked && mode === "link" && fromId) {
|
|
const hit = hitTest(world.x, world.y);
|
|
if (hit && hit !== fromId) {
|
|
void applyOps([
|
|
{
|
|
type: "link",
|
|
from: fromId,
|
|
to: hit,
|
|
kind: "relation",
|
|
label: U.relationLabel,
|
|
},
|
|
]);
|
|
}
|
|
setLinkPreview(null);
|
|
} else if (!locked && mode === "node" && fromId && moved && g) {
|
|
const exclude = new Set(g.ids);
|
|
const hit =
|
|
forcePin || dropForbidden ? null : hitTest(world.x, world.y, exclude);
|
|
const canReparent =
|
|
hit &&
|
|
hit !== fromId &&
|
|
!isDescendantOrSelf(hit, fromId, edges);
|
|
|
|
if (canReparent && hit) {
|
|
const sidePref: -1 | 0 | 1 =
|
|
(positions[hit]?.x ?? 0) <= 0 ? -1 : 1;
|
|
const nextPos = { ...positions };
|
|
for (const id of g.ids) {
|
|
const base = positions[id];
|
|
if (base) nextPos[id] = { x: base.x + g.dx, y: base.y + g.dy };
|
|
}
|
|
patchLocal({ positions: nextPos });
|
|
setGhost(null);
|
|
void applyOps([
|
|
{
|
|
type: "move_node",
|
|
nodeId: fromId,
|
|
newParentId: hit,
|
|
sidePref,
|
|
},
|
|
]);
|
|
} else {
|
|
// Free place -> pin every dragged id (branch = whole subtree, node = one)
|
|
const nextPos = { ...positions };
|
|
const pinOps: {
|
|
type: "set_pinned";
|
|
nodeId: string;
|
|
pinned: true;
|
|
pos: { x: number; y: number };
|
|
}[] = [];
|
|
const nextNodes = nodes.map((n) => {
|
|
if (!exclude.has(n.id)) return n;
|
|
const base = positions[n.id];
|
|
if (!base) return n;
|
|
const pos = { x: base.x + g.dx, y: base.y + g.dy };
|
|
nextPos[n.id] = pos;
|
|
pinOps.push({
|
|
type: "set_pinned",
|
|
nodeId: n.id,
|
|
pinned: true,
|
|
pos,
|
|
});
|
|
return { ...n, pinned: true, pos };
|
|
});
|
|
patchLocal({ positions: nextPos, nodes: nextNodes });
|
|
setGhost(null);
|
|
void applyOps(pinOps, "drag pin");
|
|
}
|
|
} else if (mode === "node" && fromId && !moved) {
|
|
setSelection([fromId]);
|
|
}
|
|
|
|
dragRef.current.mode = "none";
|
|
dragRef.current.nodeId = null;
|
|
dragRef.current.forcePin = false;
|
|
dragRef.current.subtreeIds = [];
|
|
setLinkPreview(null);
|
|
setGhost(null);
|
|
setDropTargetId(null);
|
|
setDropForbidden(false);
|
|
setDragCursor(null);
|
|
}}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onDoubleClick={(e) => {
|
|
if (locked) return;
|
|
const world = toWorld(e.clientX, e.clientY);
|
|
const hit = hitTest(world.x, world.y);
|
|
if (hit) {
|
|
const n = nodes.find((x) => x.id === hit)!;
|
|
const host = hostRef.current!.getBoundingClientRect();
|
|
const p = posOf(hit)!;
|
|
setEditing({
|
|
id: hit,
|
|
text: n.text,
|
|
x: host.width / 2 + viewport.x + p.x * viewport.zoom - 70,
|
|
y: host.height / 2 + viewport.y + p.y * viewport.zoom - 16,
|
|
});
|
|
} else if (toolMode === "select") {
|
|
void applyOps([
|
|
{
|
|
type: "create_node",
|
|
text: U.newNode,
|
|
...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),
|
|
sidePref: world.x < 0 ? -1 : 1,
|
|
},
|
|
]);
|
|
}
|
|
}}
|
|
>
|
|
<canvas ref={canvasRef} />
|
|
|
|
<div
|
|
className="canvas-toolbar"
|
|
role="toolbar"
|
|
aria-label={U.toolbarAria}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
{embed ? (
|
|
<>
|
|
<div className="canvas-toolbar__group">
|
|
<button
|
|
type="button"
|
|
className="canvas-tool-btn"
|
|
title={U.backRosterTitle}
|
|
onClick={requestBackToRoster}
|
|
>
|
|
{U.backRoster}
|
|
</button>
|
|
</div>
|
|
<div className="canvas-toolbar__sep" />
|
|
</>
|
|
) : null}
|
|
<div className="canvas-toolbar__group">
|
|
{(
|
|
[
|
|
["select", U.toolSelect, "V"],
|
|
["pan", U.toolPan, "H"],
|
|
["link", U.toolLink, "C"],
|
|
] as const
|
|
).map(([mode, label, key]) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
className={`canvas-tool-btn${toolMode === mode ? " is-active" : ""}`}
|
|
title={`${label} (${key})`}
|
|
aria-pressed={toolMode === mode}
|
|
onClick={() => setToolMode(mode)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="canvas-toolbar__sep" />
|
|
<div className="canvas-toolbar__group">
|
|
<button
|
|
type="button"
|
|
className={`canvas-tool-btn${dragScope === "node" ? " is-active" : ""}`}
|
|
title={U.dragNodeTitle}
|
|
aria-pressed={dragScope === "node"}
|
|
onClick={() => setDragScope("node")}
|
|
>
|
|
{U.dragNode}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`canvas-tool-btn${dragScope === "branch" ? " is-active" : ""}`}
|
|
title={U.dragBranchTitle}
|
|
aria-pressed={dragScope === "branch"}
|
|
onClick={() => setDragScope("branch")}
|
|
>
|
|
{U.dragBranch}
|
|
</button>
|
|
</div>
|
|
<div className="canvas-toolbar__sep" />
|
|
<div className="canvas-toolbar__group">
|
|
<button
|
|
type="button"
|
|
className={`canvas-tool-btn${cardView ? "" : " is-active"}`}
|
|
title={U.bubbleTitle}
|
|
aria-pressed={!cardView}
|
|
disabled={locked}
|
|
onClick={() => {
|
|
if (!cardView) return;
|
|
void setPrefs({ nodeViewMode: "bubble" });
|
|
}}
|
|
>
|
|
{U.bubble}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`canvas-tool-btn${cardView ? " is-active" : ""}`}
|
|
title={U.cardTitle}
|
|
aria-pressed={cardView}
|
|
disabled={locked}
|
|
onClick={() => {
|
|
if (cardView) return;
|
|
void setPrefs({ nodeViewMode: "card" });
|
|
}}
|
|
>
|
|
{U.card}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="canvas-tool-btn"
|
|
title={U.unpinTitle}
|
|
disabled={
|
|
locked ||
|
|
!selectedIds[0] ||
|
|
!nodes.find((n) => n.id === selectedIds[0])?.pinned
|
|
}
|
|
onClick={() => {
|
|
if (!selectedIds[0]) return;
|
|
void applyOps([
|
|
{ type: "set_pinned", nodeId: selectedIds[0], pinned: false },
|
|
]);
|
|
}}
|
|
>
|
|
{U.unpin}
|
|
</button>
|
|
</div>
|
|
<div className="canvas-toolbar__sep" />
|
|
<div className="canvas-toolbar__group">
|
|
<button
|
|
type="button"
|
|
className="canvas-tool-btn"
|
|
title={U.undo}
|
|
disabled={!lastChangeSetId}
|
|
onClick={() => void undo()}
|
|
>
|
|
{U.undo}
|
|
</button>
|
|
</div>
|
|
<span className="canvas-toolbar__zoom">
|
|
{Math.round(viewport.zoom * 100)}%
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className="canvas-controls"
|
|
role="toolbar"
|
|
aria-label={U.controlsAria}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="canvas-controls__btn"
|
|
title={fullscreen ? U.exitFullscreen : U.fullscreen}
|
|
aria-label={fullscreen ? U.exitFullscreen : U.fullscreen}
|
|
onClick={() => void toggleFullscreen()}
|
|
>
|
|
<FullscreenIcon exit={fullscreen} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="canvas-controls__btn"
|
|
title={U.zoomInTitle}
|
|
aria-label={U.zoomIn}
|
|
onClick={() => setZoomAroundCenter(viewport.zoom * 1.2)}
|
|
>
|
|
<ZoomInIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="canvas-controls__btn"
|
|
title={U.zoomOutTitle}
|
|
aria-label={U.zoomOut}
|
|
onClick={() => setZoomAroundCenter(viewport.zoom / 1.2)}
|
|
>
|
|
<ZoomOutIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="canvas-controls__btn"
|
|
title={U.fitTitle}
|
|
aria-label={U.fit}
|
|
onClick={fitView}
|
|
>
|
|
<FitIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`canvas-controls__btn${locked ? " is-active" : ""}`}
|
|
title={locked ? U.unlockTitle : U.lockTitle}
|
|
aria-label={locked ? U.unlock : U.lock}
|
|
aria-pressed={locked}
|
|
onClick={() => setLocked((v) => !v)}
|
|
>
|
|
<LockIcon locked={locked} />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="canvas-minimap"
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation();
|
|
miniDragRef.current = true;
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
panFromMini(e.clientX, e.clientY);
|
|
}}
|
|
onPointerMove={(e) => {
|
|
if (!miniDragRef.current) return;
|
|
panFromMini(e.clientX, e.clientY);
|
|
}}
|
|
onPointerUp={() => {
|
|
miniDragRef.current = false;
|
|
}}
|
|
onPointerCancel={() => {
|
|
miniDragRef.current = false;
|
|
}}
|
|
>
|
|
<canvas ref={miniRef} width={MINI_W} height={MINI_H} />
|
|
</div>
|
|
|
|
{editing && (
|
|
<input
|
|
className="edit-overlay"
|
|
style={{ left: editing.x, top: editing.y }}
|
|
autoFocus
|
|
value={editing.text}
|
|
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
|
|
onBlur={() => {
|
|
void applyOps([
|
|
{ type: "update_text", nodeId: editing.id, text: editing.text },
|
|
]);
|
|
setEditing(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
if (e.key === "Escape") setEditing(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!locked &&
|
|
selectedIds[0] &&
|
|
!ghost &&
|
|
positions[selectedIds[0]] &&
|
|
(() => {
|
|
const id = selectedIds[0]!;
|
|
const n = nodes.find((x) => x.id === id);
|
|
if (!n || n.deletedAt) return null;
|
|
const p = positions[id]!;
|
|
const { w: boxW } = boxOf(n);
|
|
const cx = hostSize.w / 2 + viewport.x + p.x * viewport.zoom;
|
|
const cy = hostSize.h / 2 + viewport.y + p.y * viewport.zoom;
|
|
const hw = (boxW / 2) * viewport.zoom;
|
|
// Center-to-center: parent half + child half + breathing room
|
|
const edgeGap = dens ? 100 : 140;
|
|
const addChild = (side: -1 | 1) => {
|
|
const text = side < 0 ? U.leftChild : U.rightChild;
|
|
const childW = dens ? 160 : 200;
|
|
void applyOps([
|
|
{
|
|
type: "create_node",
|
|
text,
|
|
parentId: id,
|
|
sidePref: side,
|
|
pos: {
|
|
x: p.x + side * (boxW / 2 + childW / 2 + edgeGap),
|
|
y: p.y,
|
|
},
|
|
},
|
|
]);
|
|
};
|
|
return (
|
|
<div className="node-handles" onPointerDown={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
className="node-handle node-handle--left"
|
|
style={{ left: cx - hw - 14, top: cy - 10 }}
|
|
title={U.addLeftChildTitle}
|
|
onClick={() => addChild(-1)}
|
|
>
|
|
+
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="node-handle node-handle--right"
|
|
style={{ left: cx + hw - 2, top: cy - 10 }}
|
|
title={U.addRightChildTitle}
|
|
onClick={() => addChild(1)}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{canvas?.prefs.showHoverCard !== false &&
|
|
hoverId &&
|
|
!ghost &&
|
|
!cardView &&
|
|
(() => {
|
|
const n = nodes.find((x) => x.id === hoverId);
|
|
if (!n) return null;
|
|
const p = n ? positions[n.id] : null;
|
|
if (!p) return null;
|
|
const { h: boxH } = boxOf(n);
|
|
const x = hostSize.w / 2 + viewport.x + p.x * viewport.zoom + 12;
|
|
const y =
|
|
hostSize.h / 2 + viewport.y + p.y * viewport.zoom + (boxH / 2) * viewport.zoom + 8;
|
|
return <HoverCard node={n} x={x} y={y} />;
|
|
})()}
|
|
|
|
{!locked &&
|
|
!ghost &&
|
|
cardView &&
|
|
selectedIds[0] &&
|
|
viewport.zoom >= 0.35 &&
|
|
(() => {
|
|
const id = selectedIds[0]!;
|
|
const n = nodes.find((x) => x.id === id);
|
|
if (!n || n.deletedAt) return null;
|
|
const p = positions[id];
|
|
if (!p) return null;
|
|
const { w } = boxOf(n);
|
|
const sx = hostSize.w / 2 + viewport.x + p.x * viewport.zoom;
|
|
const sy = hostSize.h / 2 + viewport.y + p.y * viewport.zoom;
|
|
return (
|
|
<NodePropOverlay
|
|
node={n}
|
|
applyOps={applyOps}
|
|
screenX={sx}
|
|
screenY={sy}
|
|
zoom={viewport.zoom}
|
|
width={w}
|
|
accent={branchColor.get(id)}
|
|
showStylePreset={false}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
<div className="canvas-hint muted">
|
|
{locked
|
|
? U.hintLocked
|
|
: dragScope === "node"
|
|
? U.hintNodeDrag
|
|
: U.hintBranchDrag}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function FullscreenIcon({ exit }: { exit?: boolean }) {
|
|
if (exit) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ZoomInIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ZoomOutIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path fill="currentColor" d="M19 13H5v-2h14v2z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FitIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M3 5v4h2V7h2V5H3zm0 10v4h4v-2H5v-2H3zm14-10v2h2v2h2V5h-4zm4 10h-2v2h-2v2h4v-4zM7 9h10v6H7V9z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LockIcon({ locked }: { locked: boolean }) {
|
|
if (locked) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6-7h-1V7a5 5 0 0 0-10 0v3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2zM9 7a3 3 0 0 1 6 0v3H9V7z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6-7h-1V7a5 5 0 0 0-9.9-1h2.1A3 3 0 0 1 15 7v3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|