feat(editor): F4.6a simulation mode and a computer's desktop
Java's mode buttons start and stop the room, now in a worker of the editor's page (LocalSimHost), with Java's speed slider. While the simulation runs the design is locked as in Java: no palette, no sheet, nothing moves, no undo. Cables that carry a frame light up in Java's green, and a tap on a computer opens its desktop: Java's title, the software installation first, then the installed programs in the order of Java's list for the language, a taskbar with the network icon and the network as the simulation runs it. The room sends that over a new topic, node:<id>. Desktops stay over a return to the design and show again at the next start. The programs' windows follow from F4.6b. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+122
-20
@@ -1,6 +1,10 @@
|
||||
// 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). The
|
||||
// simulation follows (F4.6).
|
||||
// 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
|
||||
@@ -15,7 +19,10 @@ 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 } from './desktop/DesktopWindow.js';
|
||||
import { useTranslate } from './i18n/i18n.js';
|
||||
import { ModeBar } from './simulation/ModeBar.js';
|
||||
import { useSimulation, type Simulation, type SimHostFactory } from './simulation/useSimulation.js';
|
||||
|
||||
/** The document's drawing, read anew after every change. */
|
||||
export function useTopologyView(doc: Y.Doc): TopologyView {
|
||||
@@ -66,9 +73,33 @@ const toolButton: CSSProperties = {
|
||||
/** 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 }) {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
};
|
||||
|
||||
/** A desktop that watches its computer's network while it is shown. */
|
||||
function WatchedDesktop({ simulation, ...props }: Omit<Parameters<typeof DesktopWindow>[0], 'settings'> & { readonly simulation: Simulation }) {
|
||||
const { watch } = simulation;
|
||||
useEffect(() => watch([`node:${props.id}`]), [watch, props.id]);
|
||||
return <DesktopWindow {...props} settings={simulation.nodes.get(props.id)} />;
|
||||
}
|
||||
|
||||
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
|
||||
const t = useTranslate();
|
||||
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);
|
||||
@@ -88,6 +119,37 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
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 }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -137,6 +199,11 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -161,7 +228,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
// The keyboard: undo and redo, delete what is selected, Escape lets go.
|
||||
useEffect(() => {
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (typing(event.target)) return;
|
||||
if (typing(event.target) || simulating) return;
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
if (command && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
@@ -180,18 +247,20 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
};
|
||||
window.addEventListener('keydown', key);
|
||||
return () => window.removeEventListener('keydown', key);
|
||||
}, [manager, selection, menu, routerAt, remove]);
|
||||
}, [manager, selection, menu, routerAt, remove, simulating]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
onCableTool={(on) => {
|
||||
setCableTool(on);
|
||||
setCableFrom(null);
|
||||
}}
|
||||
onDevice={onDevice}
|
||||
/>
|
||||
{simulating ? null : (
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
onCableTool={(on) => {
|
||||
setCableTool(on);
|
||||
setCableFrom(null);
|
||||
}}
|
||||
onDevice={onDevice}
|
||||
/>
|
||||
)}
|
||||
<div className="editor-work">
|
||||
<div className="editor-area">
|
||||
<Canvas
|
||||
@@ -206,19 +275,51 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
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, 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 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() : 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) => (
|
||||
<WatchedDesktop
|
||||
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}
|
||||
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
|
||||
@@ -233,6 +334,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
role="alert"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 60,
|
||||
left: '50%',
|
||||
bottom: 72,
|
||||
transform: 'translateX(-50%)',
|
||||
|
||||
Reference in New Issue
Block a user