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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user