The oracle BrowserWindowRun uses Java's browser and web server windows as a person does; the room keeps a browser window per computer and sends a program window's view, the start names the person's language. The editor shows the page in a sandboxed frame, links and forms go to the room, the web server has its button, virtual hosts and lines. Better than Java: relative images and links count from the page's folder, a mark stays in the page, the error page from Java's template, the virtual hosts' file with complete rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
// 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<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));
|
|
|
|
/** 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<readonly OpenDesktop[]>([]);
|
|
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);
|
|
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
|
|
const [highlightedLink, setHighlightedLink] = 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]);
|
|
|
|
// 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 (
|
|
<div style={{ display: 'flex', height: '100%' }}>
|
|
{simulating ? null : (
|
|
<Palette
|
|
cableTool={cableTool}
|
|
onCableTool={(on) => {
|
|
setCableTool(on);
|
|
setCableFrom(null);
|
|
}}
|
|
onDevice={onDevice}
|
|
/>
|
|
)}
|
|
<div className="editor-work">
|
|
<div className="editor-area">
|
|
<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}
|
|
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 });
|
|
}}
|
|
/>
|
|
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
|
|
{simulating ? null : (
|
|
<>
|
|
<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>
|
|
{simulating
|
|
? desktops.map((desktop) => (
|
|
<DesktopWindow
|
|
key={desktop.id}
|
|
simulation={simulation}
|
|
doc={doc}
|
|
id={desktop.id}
|
|
active={view.links.some((link) => (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 : <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',
|
|
zIndex: 60,
|
|
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>
|
|
{/* 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 : (
|
|
<DeviceSheet
|
|
key={selected.id}
|
|
doc={doc}
|
|
id={selected.id}
|
|
label={selected.label}
|
|
origin={origin}
|
|
onChanged={() => manager?.stopCapturing()}
|
|
onMessage={setMessage}
|
|
onHighlight={setHighlightedLink}
|
|
onClose={() => setSelection(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|