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
+229 -8
View File
@@ -1,10 +1,19 @@
// The editor on a document: for now the surface that draws it (F4.3); the
// tools, the device sheet and the simulation follow (F4.4–F4.8).
// The editor on a document: the palette, the surface, the tools of the design
// mode (F4.4b). The device sheet and the simulation follow (F4.5, F4.6).
//
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
// the others in the room.
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs';
import { topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas } from './canvas/Canvas.js';
import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
import type { Point } from './canvas/viewport.js';
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { useTranslate } from './i18n/i18n.js';
/** The document's drawing, read anew after every change. */
export function useTopologyView(doc: Y.Doc): TopologyView {
@@ -18,7 +27,219 @@ export function useTopologyView(doc: Y.Doc): TopologyView {
return view;
}
export function Editor({ doc }: { readonly doc: Y.Doc }) {
const view = useTopologyView(doc);
return <Canvas view={view} />;
/**
* This person's undo and redo, and whether there is something to take back
* or bring back. Made in the effect, not in a memo: React's strict mode runs
* an effect's cleanup once while developing, and a destroyed manager must
* not be used again.
*/
function useUndo(doc: Y.Doc, origin: object) {
const [manager, setManager] = useState<Y.UndoManager | null>(null);
const [stacks, setStacks] = useState({ undo: 0, redo: 0 });
useEffect(() => {
const made = createUndoManager(doc, origin);
const changed = () => setStacks({ undo: made.undoStack.length, redo: made.redoStack.length });
made.on('stack-item-added', changed);
made.on('stack-item-popped', changed);
made.on('stack-cleared', changed);
setManager(made);
setStacks({ undo: 0, redo: 0 });
return () => made.destroy();
}, [doc, origin]);
return { manager, canUndo: stacks.undo > 0, canRedo: stacks.redo > 0 };
}
const toolButton: CSSProperties = {
minWidth: 44,
minHeight: 44,
padding: '0 12px',
border: '1px solid var(--border-default)',
borderRadius: 8,
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
font: 'inherit',
cursor: 'pointer',
};
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
export function Editor({ doc }: { readonly doc: Y.Doc }) {
const t = useTranslate();
const view = useTopologyView(doc);
const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []);
const { manager, canUndo, canRedo } = useUndo(doc, origin);
const canvas = useRef<CanvasHandle>(null);
const [cableTool, setCableTool] = useState(false);
const [selection, setSelection] = useState<Selection | null>(null);
const [cableFrom, setCableFrom] = useState<{ readonly id: string; readonly side: Side } | null>(null);
const [menu, setMenu] = useState<{ readonly target: Selection; readonly at: Point } | null>(null);
const [routerAt, setRouterAt] = useState<Point | null>(null);
const [message, setMessage] = useState<string | null>(null);
// A message goes after a while.
useEffect(() => {
if (message === null) return undefined;
const timer = setTimeout(() => setMessage(null), 4_000);
return () => clearTimeout(timer);
}, [message]);
// What is selected may be gone (removed here or by someone else).
useEffect(() => {
if (selection?.kind === 'device' && !view.nodes.some((node) => node.id === selection.id)) setSelection(null);
if (selection?.kind === 'link' && !view.links.some((link) => link.id === selection.id)) setSelection(null);
if (cableFrom !== null && !view.nodes.some((node) => node.id === cableFrom.id)) setCableFrom(null);
}, [view, selection, cableFrom]);
const place = useCallback(
(kind: PaletteKind, at: Point, interfaces?: number) => {
const id = crypto.randomUUID();
addDevice(doc, { kind, id, name: t(`device.${kind}`), x: at.x, y: at.y, newId: () => crypto.randomUUID(), ...(interfaces === undefined ? {} : { interfaces }) }, origin);
manager?.stopCapturing();
// Java selects the new device.
setSelection({ kind: 'device', id });
},
[doc, origin, manager, t],
);
const onDevice = (kind: PaletteKind, at: { readonly clientX: number; readonly clientY: number } | null) => {
const name = t(`device.${kind}`);
const corner = at === null ? canvas.current?.placeInView(kind, name) : canvas.current?.placeAt(kind, name, at.clientX, at.clientY);
if (corner === null || corner === undefined) return;
if (kind === 'router') setRouterAt(corner);
else place(kind, corner);
};
const onCable = (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => {
// A home router's LAN port where the right half of its button was met (Java), else its WAN port.
const end = (each: { readonly id: string; readonly side: Side }) => {
const node = view.nodes.find((candidate) => candidate.id === each.id);
return node?.kind === 'gateway' && each.side === 'right' ? { node: each.id, port: node.ports[1]!.id } : { node: each.id };
};
const result = connect(doc, end(from), end(to), crypto.randomUUID(), origin);
manager?.stopCapturing();
setCableFrom(null);
if ('error' in result) setMessage(t(`cable.${result.error}`));
};
const remove = useCallback(
(target: Selection) => {
if (target.kind === 'device') removeDevice(doc, target.id, origin);
else removeLink(doc, target.id, origin);
manager?.stopCapturing();
setSelection(null);
},
[doc, origin, manager],
);
const menuItems = (target: Selection): MenuItem[] => {
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id);
const cables = view.links.some((link) => link.sourceNode === target.id || link.targetNode === target.id);
// Java names the item by how many cables the kind takes; shown only where there is one to remove.
const items: MenuItem[] = [];
if (cables) {
items.push({
key: 'removeCables',
label: t(node?.kind === 'pc' || node?.kind === 'notebook' ? 'menu.removeCable' : 'menu.removeAllCables'),
onChoose: () => {
removeLinksOf(doc, target.id, origin);
manager?.stopCapturing();
},
});
}
items.push({ key: 'delete', label: t('menu.delete'), danger: true, onChoose: () => remove(target) });
return items;
};
// The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (typing(event.target)) return;
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault();
if (event.shiftKey) manager?.redo();
else manager?.undo();
} else if (command && event.key.toLowerCase() === 'y') {
event.preventDefault();
manager?.redo();
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selection !== null && menu === null && routerAt === null) {
event.preventDefault();
remove(selection);
} else if (event.key === 'Escape') {
setCableFrom(null);
setSelection(null);
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove]);
return (
<div style={{ display: 'flex', height: '100%' }}>
<Palette
cableTool={cableTool}
onCableTool={(on) => {
setCableTool(on);
setCableFrom(null);
}}
onDevice={onDevice}
/>
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<Canvas
ref={canvas}
view={view}
tool={cableTool ? 'cable' : 'select'}
selection={selection}
cableFrom={cableFrom}
onSelect={setSelection}
onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)}
onMoveEnd={() => manager?.stopCapturing()}
onCableStart={setCableFrom}
onCable={onCable}
onMenu={(target, at) => {
setSelection(target);
setMenu({ target, at });
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
</div>
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
<RouterDialog
onChoose={(interfaces) => {
place('router', routerAt, interfaces);
setRouterAt(null);
}}
/>
)}
{message === null ? null : (
<div
role="alert"
style={{
position: 'absolute',
left: '50%',
bottom: 72,
transform: 'translateX(-50%)',
padding: '10px 16px',
borderRadius: 8,
background: 'var(--danger-soft)',
color: 'var(--danger-on-soft)',
border: '1px solid var(--danger)',
maxWidth: '80%',
}}
>
{message}
</div>
)}
</div>
</div>
);
}