// The editor on a document: the palette, the surface, the tools of the design // mode (F4.4b), the device sheet of the selected device (F4.5), Java's mode // buttons and the simulation (F4.6): while it runs, the design is locked as // in Java — no palette, no sheet, nothing moves — the cables that carry a // frame light up, and a tap on a computer opens its desktop. Desktops left // open stay over a return to the design and show again at the next start, // as Java's do (GUIContainer.closeDialogs, reopenDialogs). // // 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 { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import type * as Y from 'yjs'; 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 { DeviceSheet } from './sheet/DeviceSheet.js'; import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js'; import { useLanguage, useTranslate } from './i18n/i18n.js'; import { ModeBar } from './simulation/ModeBar.js'; import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js'; /** The document's drawing, read anew after every change. */ export function useTopologyView(doc: Y.Doc): TopologyView { const [view, setView] = useState(() => topologyView(doc)); useEffect(() => { setView(topologyView(doc)); const update = () => setView(topologyView(doc)); doc.on('update', update); return () => doc.off('update', update); }, [doc]); return 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(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)); /** A desktop open on the surface: whose, where its window lies, and how far up (the last one touched on top). */ interface OpenDesktop { readonly id: string; readonly x: number; readonly y: number; readonly z: number; readonly program: DesktopWindowName | null; } /** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => { const top = Math.max(0, ...open.map((each) => each.z)); return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each)); }; export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) { const t = useTranslate(); const language = useLanguage(); const view = useTopologyView(doc); const simulation = useSimulation(doc, createSimHost); const simulating = simulation.mode === 'simulation'; const [desktops, setDesktops] = useState([]); const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []); const { manager, canUndo, canRedo } = useUndo(doc, origin); const canvas = useRef(null); const [cableTool, setCableTool] = useState(false); const [selection, setSelection] = useState(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(null); const [message, setMessage] = useState(null); // The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv). const [highlightedLink, setHighlightedLink] = useState(null); // A message goes after a while. useEffect(() => { if (message === null) return undefined; const timer = setTimeout(() => setMessage(null), 4_000); return () => clearTimeout(timer); }, [message]); // The simulation locks the design: what was chosen or begun is let go. useEffect(() => { if (!simulating) return; setSelection(null); setCableTool(false); setCableFrom(null); setMenu(null); setRouterAt(null); }, [simulating]); useEffect(() => { if (simulation.failed) setMessage(t('sim.failed')); // The message is shown once per failure. // eslint-disable-next-line react-hooks/exhaustive-deps }, [simulation.failed]); // A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs). useEffect(() => { const computers = new Set(view.nodes.filter((node) => node.kind === 'pc' || node.kind === 'notebook').map((node) => node.id)); setDesktops((open) => (open.every((each) => computers.has(each.id)) ? open : open.filter((each) => computers.has(each.id)))); }, [view]); /** Opens a computer's desktop, or brings it to the front. */ const openDesktop = useCallback((id: string) => { setDesktops((open) => { if (open.some((each) => each.id === id)) return toFront(open, id); // Java stacks a desktop that does not fit beside the others a little further on. return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }]; }); }, []); // 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 (simulating) { // Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7). const node = view.nodes.find((each) => each.id === target.id); return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : []; } 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; }; const selected = selection?.kind === 'device' ? view.nodes.find((node) => node.id === selection.id) : undefined; // The keyboard: undo and redo, delete what is selected, Escape lets go. useEffect(() => { const key = (event: KeyboardEvent) => { if (typing(event.target) || simulating) 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, simulating]); return (
{simulating ? null : ( { setCableTool(on); setCableFrom(null); }} onDevice={onDevice} /> )}
moveDevice(doc, id, x, y, origin)} onMoveEnd={() => manager?.stopCapturing()} onCableStart={setCableFrom} onCable={onCable} highlightedLink={highlightedLink} mode={simulation.mode} activeLinks={simulation.activeLinks} onActivate={(id) => { const kind = view.nodes.find((node) => node.id === id)?.kind; if (kind === 'pc' || kind === 'notebook') openDesktop(id); }} onMenu={(target, at) => { if (simulating) { if (menuItems(target).length > 0) setMenu({ target, at }); return; } setSelection(target); setMenu({ target, at }); }} />
(mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} /> {simulating ? null : ( <> )}
{simulating ? desktops.map((desktop) => ( (link.sourceNode === desktop.id || link.targetNode === desktop.id) && simulation.activeLinks.has(link.id))} x={desktop.x} y={desktop.y} z={desktop.z} program={desktop.program} origin={origin} onProgram={(program) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, program } : each)))} onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))} onFront={() => setDesktops((open) => toFront(open, desktop.id))} onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))} /> )) : null} {menu === null ? null : setMenu(null)} />} {routerAt === null ? null : ( { place('router', routerAt, interfaces); setRouterAt(null); }} /> )} {message === null ? null : (
{message}
)}
{/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */} {selected === undefined ? null : ( manager?.stopCapturing()} onMessage={setMessage} onHighlight={setHighlightedLink} onClose={() => setSelection(null)} /> )}
); }