feat(editor): the simulation's properties in a sheet of their own, with a restart for everyone

Who started the simulation, "Pakete verwerfen" and the dropped packets move
from the toolbar into a properties sheet beside the surface. New in it: restart
the simulation, after asking. The room learns a restart message: stopped as by
stop, every capture, dropped frame and program window emptied, started anew
and named after who restarted; refused for readers and frozen projects, logged,
and waiting for asked blobs like a start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:01:41 +00:00
co-authored by Claude Opus 5.5
parent 317c0d9602
commit 12b26f4abe
23 changed files with 531 additions and 165 deletions
+23 -7
View File
@@ -69,7 +69,7 @@ import { translate, useLanguage, useTranslate } from './i18n/i18n.js';
import { CaptureSheet } from './simulation/CaptureSheet.js';
import { captureWords, reportTraffic } from './simulation/captureCells.js';
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
import { DropControl } from './simulation/DropControl.js';
import { PropertiesSheet } from './simulation/PropertiesSheet.js';
import { ViewerSheet, type ViewerKind } from './simulation/ViewerSheet.js';
import { useSeeThrough, type SheetFrame } from './simulation/SimulationSheet.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
@@ -124,6 +124,9 @@ const toolButton: CSSProperties = {
cursor: 'pointer',
};
/** A tool that is on: the sheet it opens is open. */
const pressedButton: CSSProperties = { borderColor: 'var(--accent)', background: 'var(--accent-soft)' };
/** 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));
@@ -175,6 +178,8 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
const [sheet, setSheet] = useState<OpenSheet | null>(null);
const [seeThrough, setSeeThrough] = useSeeThrough();
const [barFolded, foldBar] = useBarFolded();
// The simulation's „Eigenschaften“ beside the surface (PropertiesSheet).
const [properties, setProperties] = useState(false);
// The sheet begins below the toolbar, which wraps on a narrow screen.
const toolbar = useRef<HTMLDivElement>(null);
const [sheetTop, setSheetTop] = useState(68);
@@ -512,18 +517,17 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
/>
<div role="toolbar" className="editor-toolbar" ref={toolbar}>
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} canRun={!readOnly} />
{simulating && simulation.startedBy !== undefined ? (
<span className="editor-note" data-note="startedBy">
{t('access.startedBy', { name: simulation.startedBy })}
</span>
) : null}
<PeopleRow people={people} />
{readOnly ? (
<span className="editor-note" data-note="readOnly">
{t(frozen ? 'access.frozen' : 'access.readOnly')}
</span>
) : null}
{simulating ? (readOnly ? null : <DropControl simulation={simulation} />) : readOnly ? null : (
{simulating ? (
<button type="button" style={properties ? { ...toolButton, ...pressedButton } : toolButton} aria-pressed={properties} onClick={() => setProperties(!properties)}>
{t('properties.title')}
</button>
) : readOnly ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
@@ -637,6 +641,18 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
</div>
)}
</div>
{simulating && properties ? (
<PropertiesSheet
simulation={simulation}
readOnly={readOnly}
onClose={() => setProperties(false)}
onRestart={() => {
// The data exchange's tables begin anew, with nothing chosen, as after „Tabellen löschen“.
setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) }));
simulation.restart(language);
}}
/>
) : null}
{/* Java shows the settings of the device that is chosen (not in the simulation: the design is locked); closing them lets go of it, as a click beside it does. */}
{selected === undefined || simulating ? null : (
<DeviceSheet
+1 -43
View File
@@ -1320,8 +1320,7 @@
font-weight: 600;
}
.viewer-bar button,
.drop-control button {
.viewer-bar button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
@@ -1332,47 +1331,6 @@
cursor: pointer;
}
/* "Pakete verwerfen" (SimulationControl): held, then the list of dropped frames below. */
.drop-control {
position: relative;
}
.drop-control-bar {
display: flex;
gap: 6px;
}
.drop-control-hold {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.drop-control button[aria-pressed='true'] {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.drop-control-list {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: min(720px, calc(100vw - 32px));
max-height: 240px;
margin: 0;
overflow: auto;
padding: 6px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-primary);
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* The data exchange (AggregatedExchangeDialog): the tabs, a bar for what
* Java's right mouse button offers, the table (a row per frame in its layer's
* colour, Java's fonts by column) and the chosen frame's details below. */
+12 -4
View File
@@ -50,11 +50,11 @@
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Schnittstelle entfernen",
"design.actions": "Aktionen",
"design.palette": "Komponenten",
"design.foldBar": "Leiste einklappen",
"design.unfoldBar": "Leiste ausklappen",
"design.palette": "Komponenten",
"design.redo": "Wiederholen",
"design.undo": "Rückgängig",
"design.unfoldBar": "Leiste ausklappen",
"desktop.home": "Startbildschirm",
"desktop.installation": "Software-Installation",
"desktop.network": "Netzwerk",
@@ -129,7 +129,6 @@
"docu.swatch": "Farbe {index}",
"docu.text": "Text",
"drop.hold": "Pakete verwerfen",
"drop.list": "Verworfen: {count}",
"drop.listLabel": "Verworfene Pakete",
"echoServer.log": "Ausgabe",
"echoServer.port": "Port:",
@@ -363,6 +362,15 @@
"program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"properties.dropHint": "Solange der Knopf gedrückt ist, verwirft jedes Kabel, was es gerade überträgt.",
"properties.droppedNone": "Noch keine verworfenen Pakete.",
"properties.restart": "Simulation neu starten",
"properties.restartAsk": "Die Simulation für alle neu starten? Alle Protokolle und Datenpakete gehen verloren.",
"properties.restartHint": "Beendet die Simulation und startet sie neu, für alle im Projekt: Fenster der Programme, Datenaustausch und verworfene Pakete beginnen leer. Was die Geräte gespeichert haben, bleibt.",
"properties.restartNo": "Abbrechen",
"properties.restartYes": "Neu starten",
"properties.simulation": "Simulation",
"properties.title": "Eigenschaften",
"remote.connecting": "Verbindung zum Projekt …",
"remote.deleted": "Das Projekt wurde gelöscht.",
"remote.noAccess": "Sie haben keinen Zugang zu diesem Projekt.",
@@ -411,7 +419,6 @@
"sheet.cards": "Netzwerkkarten",
"sheet.chooseNetwork": "Bitte auswählen",
"sheet.close": "Schließen",
"sheet.seeThrough": "Durchsichtig",
"sheet.cloud": "als Wolke darstellen",
"sheet.connectedTo": "Verbunden mit {device}",
"sheet.connection": "Anschluss",
@@ -442,6 +449,7 @@
"sheet.portForwarding": "Portfreigaben",
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
"sheet.rip": "Automatisches Routing",
"sheet.seeThrough": "Durchsichtig",
"sheet.showAllEntries": "Alle Einträge anzeigen",
"sheet.subnetMask": "Netzmaske",
"sheet.switchSsid": "Name WLAN (SSID)",
+12 -4
View File
@@ -50,11 +50,11 @@
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Remove interface",
"design.actions": "Actions",
"design.palette": "Components",
"design.foldBar": "Collapse the bar",
"design.unfoldBar": "Expand the bar",
"design.palette": "Components",
"design.redo": "Redo",
"design.undo": "Undo",
"design.unfoldBar": "Expand the bar",
"desktop.home": "Home Screen",
"desktop.installation": "Software Installation",
"desktop.network": "Network",
@@ -129,7 +129,6 @@
"docu.swatch": "Colour {index}",
"docu.text": "Text",
"drop.hold": "Drop Packets",
"drop.list": "Dropped: {count}",
"drop.listLabel": "Dropped packets",
"echoServer.log": "Output",
"echoServer.port": "Port:",
@@ -363,6 +362,15 @@
"program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"properties.dropHint": "While the button is held, every cable drops what it is carrying.",
"properties.droppedNone": "No dropped packets yet.",
"properties.restart": "Restart simulation",
"properties.restartAsk": "Restart the simulation for everyone? All logs and data packets are lost.",
"properties.restartHint": "Ends the simulation and starts it again, for everyone in the project: the programs' windows, the data exchange and the dropped packets begin empty. What the devices saved stays.",
"properties.restartNo": "Cancel",
"properties.restartYes": "Restart",
"properties.simulation": "Simulation",
"properties.title": "Properties",
"remote.connecting": "Connecting to the project …",
"remote.deleted": "The project was deleted.",
"remote.noAccess": "You have no access to this project.",
@@ -411,7 +419,6 @@
"sheet.cards": "Network cards",
"sheet.chooseNetwork": "Please select",
"sheet.close": "Close",
"sheet.seeThrough": "See-through",
"sheet.cloud": "Display as Cloud",
"sheet.connectedTo": "Connected to {device}",
"sheet.connection": "Connection",
@@ -442,6 +449,7 @@
"sheet.portForwarding": "Port Mapping",
"sheet.retention": "Retention Time SAT Entries (Seconds)",
"sheet.rip": "Automatic Routing",
"sheet.seeThrough": "See-through",
"sheet.showAllEntries": "Show all entries",
"sheet.subnetMask": "Netmask",
"sheet.switchSsid": "Wi-Fi Name (SSID)",
+48 -2
View File
@@ -59,7 +59,7 @@
}
/* The mode buttons (and the design's undo and redo, the simulation's
* "Pakete verwerfen") over the surface: they wrap on a narrow screen; the
* „Eigenschaften“) over the surface: they wrap on a narrow screen; the
* space between them lets taps through to the surface. */
.editor-toolbar {
position: absolute;
@@ -78,7 +78,7 @@
pointer-events: auto;
}
/* A word beside the mode buttons: who started the simulation, that this person only reads. */
/* A word beside the mode buttons: that this person only reads. */
.editor-note {
display: flex;
align-items: center;
@@ -534,3 +534,49 @@
background: var(--danger-soft);
color: var(--danger-on-soft);
}
/* The simulation's „Eigenschaften“ (PropertiesSheet): its two parts apart,
* "Pakete verwerfen" held red, the dropped frames in Java's monospace. */
.properties-sheet section + section {
margin-top: 20px;
padding-top: 4px;
border-top: 1px solid var(--border-subtle);
}
.properties-confirm p {
margin: 8px 0 0;
font-size: 14px;
}
.sheet-buttons .properties-restart {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.drop-hold {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.sheet-buttons .drop-hold[aria-pressed='true'] {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.dropped-list {
max-height: 320px;
margin: 8px 0 0;
overflow: auto;
padding: 6px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@@ -1,78 +0,0 @@
// Java's "Pakete verwerfen" (SimulationControl): while the button is held,
// every cable drops what it carries; below it, the frames dropped in this
// simulation, each with its time of day and Java's words for it
// (EthernetFrame.toString). The button is held with a finger, the mouse, or
// Space or Enter; letting go (it lets the captured pointer go), a cancel or
// leaving the button by keyboard ends it.
//
// Better than Java: the list folds away and shows how many it holds (Java's
// panel takes the bottom of the window).
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { useTranslate } from '../i18n/i18n.js';
import { timeOfDay } from './timeOfDay.js';
import type { Simulation } from './useSimulation.js';
export function DropControl({ simulation }: { readonly simulation: Simulation }) {
const t = useTranslate();
const { watch, drop, dropped, clock } = simulation;
useEffect(() => watch(['dropped']), [watch]);
const [held, setHeld] = useState(false);
const [open, setOpen] = useState(false);
const holding = useRef(false);
const hold = (on: boolean) => {
if (holding.current === on) return;
holding.current = on;
setHeld(on);
drop(on);
};
// Let go when the control goes (the simulation stopped while it was held).
useEffect(
() => () => {
if (holding.current) drop(false);
},
[drop],
);
const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
hold(true);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
hold(true);
}
};
const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') hold(false);
};
return (
<div className="drop-control">
<div className="drop-control-bar">
<button
type="button"
className="drop-control-hold"
aria-pressed={held}
onPointerDown={onPointerDown}
onPointerCancel={() => hold(false)}
onLostPointerCapture={() => hold(false)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => hold(false)}
onContextMenu={(event) => event.preventDefault()}
>
{t('drop.hold')}
</button>
<button type="button" className="drop-control-toggle" aria-expanded={open} onClick={() => setOpen(!open)}>
{t('drop.list', { count: dropped.length })}
</button>
</div>
{open ? (
<pre className="drop-control-list" aria-label={t('drop.listLabel')} tabIndex={0}>
{dropped.map((unit) => `${timeOfDay(clock, unit.timeMilliseconds, true)} : ${unit.text}\n`).join('')}
</pre>
) : null}
</div>
);
}
@@ -0,0 +1,59 @@
// Java's "Pakete verwerfen" (SimulationControl): while the button is held,
// every cable drops what it carries. The button is held with a finger, the
// mouse, or Space or Enter; letting go (it lets the captured pointer go), a
// cancel or leaving the button by keyboard ends it, and so does the button
// going (the sheet closed, the simulation stopped). It stands in the
// simulation's „Eigenschaften“ (PropertiesSheet), the frames it dropped below it.
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from './useSimulation.js';
export function DropHold({ drop }: { readonly drop: Simulation['drop'] }) {
const t = useTranslate();
const [held, setHeld] = useState(false);
const holding = useRef(false);
const hold = (on: boolean) => {
if (holding.current === on) return;
holding.current = on;
setHeld(on);
drop(on);
};
// Let go when the button goes (the sheet closed, the simulation stopped) while it was held.
useEffect(
() => () => {
if (holding.current) drop(false);
},
[drop],
);
const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
hold(true);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
hold(true);
}
};
const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') hold(false);
};
return (
<button
type="button"
className="drop-hold"
aria-pressed={held}
onPointerDown={onPointerDown}
onPointerCancel={() => hold(false)}
onLostPointerCapture={() => hold(false)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => hold(false)}
onContextMenu={(event) => event.preventDefault()}
>
{t('drop.hold')}
</button>
);
}
@@ -0,0 +1,135 @@
// The simulation's „Eigenschaften“: a sheet beside the surface, as the
// device's settings are in the design (on a phone over the whole editor),
// opened from the button of that name over the surface. What Java puts
// around the surface while it simulates, and one thing Java has not:
//
// - who started the simulation that runs (on F; everyone in the project
// watches the same one),
// - „Simulation neu starten“: started anew for everyone, every program
// window, the data exchange and the dropped frames emptied — what the
// devices wrote stays. Java only stops and starts, and its windows and
// tables outlive that. Asked once before, since it empties the others' too.
// - Java's "Pakete verwerfen" (DropHold) and below it the frames it dropped in
// this simulation, each with its time of day and Java's words for it
// (EthernetFrame.toString); Java's panel takes the bottom of the window.
//
// Who only watches sees who started it and what was dropped, and neither
// starts anew nor drops.
import { useEffect, useState } from "react";
import { useTranslate } from "../i18n/i18n.js";
import { DropHold } from "./DropHold.js";
import { timeOfDay } from "./timeOfDay.js";
import type { Simulation } from "./useSimulation.js";
export function PropertiesSheet({
simulation,
onRestart,
onClose,
readOnly = false,
}: {
readonly simulation: Simulation;
/** „Simulation neu starten“ confirmed. */
readonly onRestart: () => void;
readonly onClose: () => void;
readonly readOnly?: boolean;
}) {
const t = useTranslate();
const { watch, drop, dropped, clock, startedBy } = simulation;
useEffect(() => watch(["dropped"]), [watch]);
const [asking, setAsking] = useState(false);
return (
<aside
className="device-sheet properties-sheet"
aria-label={t("properties.title")}
>
<header>
<h2>{t("properties.title")}</h2>
<button
type="button"
className="sheet-close"
onClick={onClose}
aria-label={t("sheet.close")}
title={t("sheet.close")}
>
×
</button>
</header>
<section aria-labelledby="properties-simulation">
<h3 className="sheet-subtitle" id="properties-simulation">
{t("properties.simulation")}
</h3>
{startedBy === undefined ? null : (
<p className="sheet-note" data-note="startedBy">
{t("access.startedBy", { name: startedBy })}
</p>
)}
{readOnly ? null : asking ? (
<div
className="properties-confirm"
role="group"
aria-label={t("properties.restartAsk")}
>
<p>{t("properties.restartAsk")}</p>
<div className="sheet-buttons">
<button
type="button"
className="properties-restart"
onClick={() => {
setAsking(false);
onRestart();
}}
>
{t("properties.restartYes")}
</button>
<button type="button" onClick={() => setAsking(false)}>
{t("properties.restartNo")}
</button>
</div>
</div>
) : (
<>
<div className="sheet-buttons">
<button type="button" onClick={() => setAsking(true)}>
{t("properties.restart")}
</button>
</div>
<p className="sheet-note">{t("properties.restartHint")}</p>
</>
)}
</section>
<section aria-labelledby="properties-drop">
<h3 className="sheet-subtitle" id="properties-drop">
{t("drop.listLabel")} ({dropped.length})
</h3>
{readOnly ? null : (
<>
<div className="sheet-buttons">
<DropHold drop={drop} />
</div>
<p className="sheet-note">{t("properties.dropHint")}</p>
</>
)}
{dropped.length === 0 ? (
<p className="sheet-note">{t("properties.droppedNone")}</p>
) : (
<pre
className="dropped-list"
aria-label={t("drop.listLabel")}
tabIndex={0}
>
{dropped
.map(
(unit) =>
`${timeOfDay(clock, unit.timeMilliseconds, true)} : ${unit.text}\n`,
)
.join("")}
</pre>
)}
</section>
</aside>
);
}
@@ -55,6 +55,8 @@ export interface Simulation {
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void;
stop(): void;
/** Starts anew for everyone: every window, list and capture emptied, what the devices wrote kept (not Java's). */
restart(language?: Language): void;
setSpeed(value: number): void;
/** Java's "Pakete verwerfen": held (true) or let go (false). */
drop(on: boolean): void;
@@ -155,6 +157,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send(language === undefined ? { v: 1, kind: 'start' } : { v: 1, kind: 'start', language });
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const restart = useCallback((language?: Language) => {
setFailed(false);
host.current?.send(language === undefined ? { v: 1, kind: 'restart' } : { v: 1, kind: 'restart', language });
}, []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []);
const clearCapture = useCallback(() => host.current?.send({ v: 1, kind: 'clearCapture' }), []);
@@ -211,5 +217,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, restart, setSpeed, drop, clearCapture, watch, captures, act };
}