feat(editor): F4.4b the design tools: palette, moving, cables, menus, undo

The palette in Java's order with Java's icons and names: a device dragged
onto the surface lands with its icon at the drop point, a tapped one in the
middle of the view; a new router asks for 2 to 8 interfaces as Java does.
A tap selects, a drag moves (divided by the scale), a long press or the
three-dot button opens Java's menu. The cable tool plugs by a drag or by
two taps, shows WAN or LAN on a home router's halves and Java's messages
when refused. Delete removes the selection; undo and redo by button and
Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse
and touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:37:49 +00:00
co-authored by Claude Opus 5.5
parent 27ab7a1f49
commit 806df6baca
16 changed files with 1218 additions and 74 deletions
+312 -47
View File
@@ -1,23 +1,60 @@
// The surface (plan §7.1): the devices with Java's icons and labels, the
// cables, the documentation items, in an SVG that one moves with a finger or
// the mouse and zooms with two fingers, a pinch on the trackpad, Ctrl and the
// wheel or the buttons.
// The surface (plan §7.1, §7.2): the devices with Java's icons and labels,
// the cables, the documentation items, in an SVG; and what a finger or the
// mouse does on it.
//
// - On the empty surface: a drag moves it, two fingers zoom, a tap clears
// the selection. The wheel moves it, with Ctrl (a trackpad's pinch) zooms.
// - On a device: a tap selects it, a drag moves it, a long press (or the
// three-dot button of the selected one) opens its menu. With the cable
// tool a drag from one device to another plugs a cable, and so do two
// taps, one on each (Java's way).
// - On a cable: a tap selects it, a long press opens its menu.
//
// The pointer is captured only once it has moved: captured at once, it would
// take the click from whatever lies under it (pointer-capture trap; jsdom
// never shows it, Playwright does). The surface sets touch-action: none, so
// that the browser's own zoom and scroll stay out of a gesture on it (M7).
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type { NodeKind } from '@mdg-filius/core';
import type { DocumentationView, NodeView, TopologyView } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { ICON_URLS } from './icons.js';
import { bounds, cablePath, deviceBox, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
import { fitted, middle, shifted, spread, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
import { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
import { ZoomBar } from './ZoomBar.js';
/** How far a pointer moves before a press becomes a drag. */
const DRAG_THRESHOLD = 4;
export const DRAG_THRESHOLD = 4;
/** How long a press lasts before it opens the menu. */
export const LONG_PRESS_MILLISECONDS = 500;
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string };
export type Tool = 'select' | 'cable';
/** Where on a home router's button a cable meets it: Java takes the left half for WAN, the right for LAN. */
export type Side = 'left' | 'right';
export interface CanvasHandle {
/** The top left corner for a new device whose icon's middle is at a point of the screen; null outside the surface. */
placeAt(kind: NodeKind, name: string, clientX: number, clientY: number): Point | null;
/** The same, in the middle of what is in view. */
placeInView(kind: NodeKind, name: string): Point;
}
export interface CanvasProps {
readonly view: TopologyView;
readonly tool?: Tool;
readonly selection?: Selection | null;
/** The device a cable starts at, after a first tap with the cable tool. */
readonly cableFrom?: { readonly id: string; readonly side: Side } | null;
readonly onSelect?: (selection: Selection | null) => void;
/** A device dragged: its button's new top left corner. */
readonly onMoveDevice?: (id: string, x: number, y: number) => void;
readonly onMoveEnd?: () => void;
readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void;
readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void;
readonly onMenu?: (target: Selection, at: Point) => void;
}
/** The label's width in Java's font, measured once per text. */
function useTextWidth(): (text: string) => number {
@@ -62,9 +99,32 @@ function Documentation({ item }: { readonly item: DocumentationView }) {
);
}
function Device({ node, box, kindName }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string }) {
function Device({ node, box, kindName, selected, pending }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string; readonly selected: boolean; readonly pending: boolean }) {
return (
<g className="device" data-node={node.id} data-box={`${box.x} ${box.y} ${box.width} ${box.height}`} role="img" aria-label={`${kindName}: ${node.label}`}>
<g
className="device"
data-node={node.id}
data-box={`${box.x} ${box.y} ${box.width} ${box.height}`}
data-selected={selected ? 'true' : undefined}
role="img"
aria-label={`${kindName}: ${node.label}`}
style={{ cursor: 'pointer' }}
>
{/* The whole button takes the pointer, as Java's label does. */}
<rect x={box.x} y={box.y} width={box.width} height={box.height} fill="transparent" />
{selected || pending ? (
<rect
x={box.x - 2}
y={box.y - 2}
width={box.width + 4}
height={box.height + 4}
rx={6}
fill="color-mix(in srgb, var(--accent) 12%, transparent)"
stroke="var(--accent)"
strokeWidth={2}
strokeDasharray={pending ? '6 4' : undefined}
/>
) : null}
<image href={ICON_URLS[box.icon]} x={box.iconRect.x} y={box.iconRect.y} width={box.iconRect.width} height={box.iconRect.height} />
<text x={box.label.x} y={box.label.y} textAnchor="middle" style={{ font: JAVA_LABEL_FONT, fill: 'var(--canvas-label)' }}>
{node.label}
@@ -73,19 +133,26 @@ function Device({ node, box, kindName }: { readonly node: NodeView; readonly box
);
}
export interface CanvasProps {
readonly view: TopologyView;
}
type Gesture =
| { readonly kind: 'background'; readonly start: Point; moved: boolean; last: Point }
| { readonly kind: 'pinch'; spread: number; middle: Point }
| { readonly kind: 'device'; readonly id: string; readonly start: Point; readonly origin: Point; readonly side: Side; moved: boolean; longPressed: boolean }
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
export function Canvas({ view }: CanvasProps) {
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu },
handle,
) {
const t = useTranslate();
const surface = useRef<SVGSVGElement>(null);
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
const [viewport, setViewport] = useState<Viewport | null>(null);
const [band, setBand] = useState<{ readonly from: Point; readonly to: Point; readonly hint: string | null } | null>(null);
const textWidth = useTextWidth();
const boxes = useMemo(() => new Map(view.nodes.map((node) => [node.id, deviceBox(node, textWidth(node.label))])), [view.nodes, textWidth]);
const content = useMemo(() => bounds([...boxes.values(), ...view.docs.map((item) => ({ x: item.x, y: item.y, width: item.width, height: item.height }))]), [boxes, view.docs]);
const kinds = useMemo(() => new Map(view.nodes.map((node) => [node.id, node.kind])), [view.nodes]);
useLayoutEffect(() => {
const element = surface.current;
@@ -101,24 +168,105 @@ export function Canvas({ view }: CanvasProps) {
}, [viewport, size, content]);
const current = viewport ?? { scale: 1, x: 0, y: 0 };
const currentRef = useRef(current);
currentRef.current = current;
const local = useCallback((clientX: number, clientY: number): Point => {
const rect = surface.current!.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top };
}, []);
// Pointers down on the surface, and what they started.
/** A new device's top left corner, its icon's middle at a point of the surface. */
const cornerFor = useCallback(
(kind: NodeKind, name: string, point: Point): Point => {
const box = deviceBox({ kind, cloud: false, x: 0, y: 0 }, textWidth(name));
return { x: point.x - box.width / 2, y: point.y - ICONS[iconOf(kind, false)].height / 2 };
},
[textWidth],
);
useImperativeHandle(
handle,
() => ({
placeAt(kind, name, clientX, clientY) {
const rect = surface.current?.getBoundingClientRect();
if (rect === undefined || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;
return cornerFor(kind, name, toSurface(currentRef.current, local(clientX, clientY)));
},
placeInView(kind, name) {
const rect = surface.current?.getBoundingClientRect();
return cornerFor(kind, name, toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 }));
},
}),
[cornerFor, local],
);
/** The device under a screen point, and which half of its button. */
const deviceAt = useCallback(
(point: Point): { id: string; side: Side } | null => {
const surfacePoint = toSurface(currentRef.current, point);
// The topmost (drawn last) first.
for (const node of [...view.nodes].reverse()) {
const box = boxes.get(node.id)!;
if (surfacePoint.x >= box.x && surfacePoint.x <= box.x + box.width && surfacePoint.y >= box.y && surfacePoint.y <= box.y + box.height) {
return { id: node.id, side: surfacePoint.x - box.x < box.width / 2 ? 'left' : 'right' };
}
}
return null;
},
[view.nodes, boxes],
);
const hintAt = useCallback(
(target: { id: string; side: Side } | null) => (target !== null && kinds.get(target.id) === 'gateway' ? t(target.side === 'left' ? 'cable.wan' : 'cable.lan') : null),
[kinds, t],
);
const pointers = useRef(new Map<number, Point>());
const gesture = useRef<{ kind: 'press'; start: Point } | { kind: 'pan'; last: Point } | { kind: 'pinch'; spread: number; middle: Point } | null>(null);
const gesture = useRef<Gesture | null>(null);
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearLongPress = () => {
if (longPress.current !== null) clearTimeout(longPress.current);
longPress.current = null;
};
const capture = (pointerId: number) => {
if (surface.current !== null && !surface.current.hasPointerCapture(pointerId)) surface.current.setPointerCapture(pointerId);
};
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point);
if (pointers.current.size === 2) {
// A second finger: a pinch, whatever the first one did.
clearLongPress();
setBand(null);
const [first, second] = [...pointers.current.values()] as [Point, Point];
gesture.current = { kind: 'pinch', spread: spread(first, second), middle: middle(first, second) };
for (const id of pointers.current.keys()) if (!surface.current!.hasPointerCapture(id)) surface.current!.setPointerCapture(id);
} else if (pointers.current.size === 1) {
gesture.current = { kind: 'press', start: point };
for (const id of pointers.current.keys()) capture(id);
return;
}
if (pointers.current.size > 2) return;
const target = event.target as Element;
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
const device = deviceAt(point);
if (device !== null) {
const box = boxes.get(device.id)!;
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'device' || now.moved) return;
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'link' || now.moved) return;
now.longPressed = true;
onMenu?.({ kind: 'link', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else {
gesture.current = { kind: 'background', start: point, moved: false, last: point };
}
};
@@ -128,35 +276,85 @@ export function Canvas({ view }: CanvasProps) {
pointers.current.set(event.pointerId, point);
const now = gesture.current;
if (now === null) return;
if (now.kind === 'press') {
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return;
// Captured only now that it is a drag.
surface.current!.setPointerCapture(event.pointerId);
gesture.current = { kind: 'pan', last: now.start };
}
const active = gesture.current!;
if (active.kind === 'pan') {
setViewport((before) => shifted(before ?? current, { x: point.x - active.last.x, y: point.y - active.last.y }));
gesture.current = { kind: 'pan', last: point };
} else if (active.kind === 'pinch' && pointers.current.size === 2) {
if (now.kind === 'pinch') {
if (pointers.current.size !== 2) return;
const [first, second] = [...pointers.current.values()] as [Point, Point];
const nextSpread = spread(first, second);
const nextMiddle = middle(first, second);
setViewport((before) => {
const base = before ?? current;
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, active.spread)), active.middle);
return shifted(zoomed, { x: nextMiddle.x - active.middle.x, y: nextMiddle.y - active.middle.y });
const base = before ?? currentRef.current;
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, now.spread)), now.middle);
return shifted(zoomed, { x: nextMiddle.x - now.middle.x, y: nextMiddle.y - now.middle.y });
});
gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle };
return;
}
if (!now.moved) {
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return;
// A drag from now on: captured only now (see above).
now.moved = true;
clearLongPress();
capture(event.pointerId);
}
if (now.kind === 'background') {
// The step taken now: the updater runs later, when `last` has moved on.
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
setBand({ from, to: toSurface(currentRef.current, point), hint: target?.id === now.id ? null : hintAt(target) });
} else {
const scale = currentRef.current.scale;
onMoveDevice?.(now.id, now.origin.x + (point.x - now.start.x) / scale, now.origin.y + (point.y - now.start.y) / scale);
}
}
};
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
const point = pointers.current.get(event.pointerId) ?? local(event.clientX, event.clientY);
pointers.current.delete(event.pointerId);
if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(event.pointerId);
// A pinch that loses a finger goes on as a pan with the other.
clearLongPress();
const now = gesture.current;
const [remaining] = [...pointers.current.values()];
gesture.current = remaining === undefined ? null : { kind: 'pan', last: remaining };
// A pinch that loses a finger goes on as a move of the surface with the other.
gesture.current = remaining === undefined ? null : { kind: 'background', start: remaining, moved: true, last: remaining };
if (now === null || now.kind === 'pinch' || event.type === 'pointercancel') {
setBand(null);
return;
}
if (now.kind === 'background') {
if (!now.moved) {
onSelect?.(null);
if (cableFrom !== null) onCableStart?.(null);
}
return;
}
if (now.kind === 'link') {
if (!now.moved && !now.longPressed) onSelect?.({ kind: 'link', id: now.id });
return;
}
if (now.longPressed) return;
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
if (now.moved) {
const target = deviceAt(point);
if (target !== null && target.id !== now.id) onCable?.(from, target);
} else if (cableFrom === null) {
onCableStart?.(from);
} else if (cableFrom.id === now.id) {
onCableStart?.(null);
} else {
onCable?.(cableFrom, from);
}
return;
}
if (now.moved) onMoveEnd?.();
else onSelect?.({ kind: 'device', id: now.id });
};
// The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves.
@@ -177,20 +375,37 @@ export function Canvas({ view }: CanvasProps) {
return () => element.removeEventListener('wheel', onWheel);
}, [local]);
// The three-dot button of what is selected — at a device's top right corner, in a cable's middle — in screen pixels (44 px for a finger).
const menuAnchor = ((): Point | null => {
if (selection?.kind === 'device') {
const box = boxes.get(selection.id);
return box === undefined ? null : { x: box.x + box.width, y: box.y };
}
if (selection?.kind === 'link') {
const link = view.links.find((each) => each.id === selection.id);
const [source, target] = [boxes.get(link?.sourceNode ?? ''), boxes.get(link?.targetNode ?? '')];
return source === undefined || target === undefined ? null : middle(source.center, target.center);
}
return null;
})();
const menuButton = menuAnchor === null ? null : toScreen(current, menuAnchor);
const centre = { x: size.width / 2, y: size.height / 2 };
const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' };
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<svg
ref={surface}
className="filius-canvas"
data-tool={tool}
role="application"
aria-label={t('canvas.label')}
style={style}
style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onContextMenu={(event) => event.preventDefault()}
>
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
<g className="docs">
@@ -198,29 +413,79 @@ export function Canvas({ view }: CanvasProps) {
<Documentation key={item.id} item={item} />
))}
</g>
<g className="cables" fill="none" strokeWidth={2}>
<g className="cables" fill="none">
{view.links.map((link) => {
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
// Drawn tolerantly: a cable whose device is gone (before the janitor) is left out.
if (source === undefined || target === undefined) return null;
const d = cablePath(source.center, target.center);
const selected = selection?.kind === 'link' && selection.id === link.id;
return (
<path
key={link.id}
data-link={link.id}
d={cablePath(source.center, target.center)}
stroke={link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
strokeDasharray={link.wireless ? '10' : undefined}
/>
<g key={link.id}>
<path
data-link={link.id}
data-selected={selected ? 'true' : undefined}
d={d}
strokeWidth={selected ? 4 : 2}
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
strokeDasharray={link.wireless ? '10' : undefined}
/>
{/* A wide, invisible line to tap: 2 px is no target for a finger. */}
<path data-link-hit={link.id} d={d} stroke="transparent" strokeWidth={16 / current.scale} style={{ cursor: 'pointer' }} />
</g>
);
})}
</g>
<g className="devices">
{view.nodes.map((node) => (
<Device key={node.id} node={node} box={boxes.get(node.id)!} kindName={t(`device.${node.kind}`)} />
<Device
key={node.id}
node={node}
box={boxes.get(node.id)!}
kindName={t(`device.${node.kind}`)}
selected={selection?.kind === 'device' && selection.id === node.id}
pending={cableFrom?.id === node.id}
/>
))}
</g>
{band === null ? null : (
<g className="cable-preview" pointerEvents="none">
<line x1={band.from.x} y1={band.from.y} x2={band.to.x} y2={band.to.y} stroke="var(--accent)" strokeWidth={2 / current.scale} strokeDasharray="6 4" />
{band.hint === null ? null : (
<text x={band.to.x + 12 / current.scale} y={band.to.y - 12 / current.scale} style={{ font: JAVA_LABEL_FONT, fill: 'var(--accent)' }} data-hint>
{band.hint}
</text>
)}
</g>
)}
</g>
</svg>
{menuButton === null || selection === null ? null : (
<button
type="button"
className="device-menu-button"
aria-label={t('design.actions')}
title={t('design.actions')}
onClick={(event) => onMenu?.(selection, local(event.clientX, event.clientY))}
style={{
position: 'absolute',
left: Math.min(Math.max(0, menuButton.x - 22), Math.max(0, size.width - 44)),
top: Math.min(Math.max(0, menuButton.y - 22), Math.max(0, size.height - 44)),
width: 44,
height: 44,
borderRadius: 22,
border: '1px solid var(--border-default)',
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
fontSize: 20,
lineHeight: 1,
cursor: 'pointer',
boxShadow: '0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent)',
}}
>
⋯
</button>
)}
<ZoomBar
scale={current.scale}
onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))}
@@ -229,4 +494,4 @@ export function Canvas({ view }: CanvasProps) {
/>
</div>
);
}
});