feat: F5.3b the editor on a project of server F
?project=<uuid> opens the shared document over /sync and the room over /sim (RemoteSimHost: messages wait for the socket, a lost socket comes back with what it watched, F's refusal or parting ends it); instead of the editor, a word why when F refuses or lets go. F tells each connection its access at once. Who only reads sees everything and changes nothing: no palette or undo, a locked surface, the sheet's fields disabled (tabs stay), no starting or stopping; thawed, the document comes anew. The toolbar names who started the simulation. Refusals carry no message, so Hocuspocus writes nothing outside F's JSON log. A browser test runs three people against a real F. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,12 @@
|
||||
// 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.
|
||||
//
|
||||
// Who only reads (on F: a read grant, or a frozen project) sees everything
|
||||
// and changes nothing: no palette, no undo, nothing moves or has a menu, the
|
||||
// sheet's fields are disabled, the simulation is neither started nor
|
||||
// stopped by them; desktops and the data exchange window open to watch. The
|
||||
// room's simulation names who started it.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
@@ -50,7 +56,7 @@ 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 { translate, useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
||||
import { captureWords, reportTraffic } from './simulation/captureCells.js';
|
||||
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
|
||||
@@ -143,9 +149,13 @@ export interface EditorProps {
|
||||
readonly createSimHost?: SimHostFactory;
|
||||
/** The project's name (its file's), for what is exported. */
|
||||
readonly projectName?: string;
|
||||
/** This person only reads (on F). */
|
||||
readonly readOnly?: boolean;
|
||||
/** …because the project is frozen (its deadline passed). */
|
||||
readonly frozen?: boolean;
|
||||
}
|
||||
|
||||
export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false }: EditorProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useTopologyView(doc);
|
||||
@@ -167,6 +177,11 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
|
||||
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
|
||||
|
||||
// The room said no: told as a message, once each time.
|
||||
useEffect(() => {
|
||||
if (simulation.refusal !== null) setMessage(translate(language, `access.${simulation.refusal.code}Refused`));
|
||||
}, [simulation.refusal, language]);
|
||||
|
||||
// A message goes after a while.
|
||||
useEffect(() => {
|
||||
if (message === null) return undefined;
|
||||
@@ -352,7 +367,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
// The keyboard: undo and redo, delete what is selected, Escape lets go.
|
||||
useEffect(() => {
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (typing(event.target) || simulating) return;
|
||||
if (typing(event.target) || simulating || readOnly) return;
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
if (command && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
@@ -371,13 +386,13 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
};
|
||||
window.addEventListener('keydown', key);
|
||||
return () => window.removeEventListener('keydown', key);
|
||||
}, [manager, selection, menu, routerAt, remove, simulating]);
|
||||
}, [manager, selection, menu, routerAt, remove, simulating, readOnly]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
{simulating ? null : documenting ? (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} />
|
||||
) : (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} />
|
||||
) : readOnly ? null : (
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
onCableTool={(on) => {
|
||||
@@ -402,6 +417,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
onCable={onCable}
|
||||
highlightedLink={highlightedLink}
|
||||
mode={editorMode}
|
||||
locked={readOnly}
|
||||
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
|
||||
renderDocText={(item, frame) => {
|
||||
const text = documentationText(doc, item.id);
|
||||
@@ -426,6 +442,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
else if (kind === 'gateway') openDesktop(id, 'nat');
|
||||
}}
|
||||
onMenu={(target, at) => {
|
||||
if (readOnly && !simulating) return;
|
||||
if (editorMode === 'documentation') {
|
||||
if (target.kind !== 'doc') return;
|
||||
setSelection(target);
|
||||
@@ -441,8 +458,18 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
}}
|
||||
/>
|
||||
<div role="toolbar" className="editor-toolbar">
|
||||
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} />
|
||||
{simulating ? <DropControl simulation={simulation} /> : (
|
||||
<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}
|
||||
{readOnly ? (
|
||||
<span className="editor-note" data-note="readOnly">
|
||||
{t(frozen ? 'access.frozen' : 'access.readOnly')}
|
||||
</span>
|
||||
) : null}
|
||||
{simulating ? (readOnly ? null : <DropControl simulation={simulation} />) : readOnly ? null : (
|
||||
<>
|
||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
||||
↶
|
||||
@@ -578,6 +605,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
onMessage={setMessage}
|
||||
onHighlight={setHighlightedLink}
|
||||
onClose={() => setSelection(null)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,12 @@ export interface CanvasProps {
|
||||
* Java's simulation mode. The documentation mode: see above.
|
||||
*/
|
||||
readonly mode?: 'design' | 'simulation' | 'documentation';
|
||||
/**
|
||||
* Who only reads (on F): a tap still chooses a device (its sheet shows
|
||||
* what it holds), but nothing moves, no cable is begun or chosen, no
|
||||
* documentation item is chosen or changed, and nothing has a menu.
|
||||
*/
|
||||
readonly locked?: boolean;
|
||||
/** A documentation item moved or resized: its new frame. */
|
||||
readonly onDocFrame?: (id: string, frame: Frame) => void;
|
||||
/** The field a focused text is typed into, over the surface at its frame. */
|
||||
@@ -228,6 +234,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
onMenu,
|
||||
highlightedLink = null,
|
||||
mode = 'design',
|
||||
locked = false,
|
||||
activeLinks = NO_LINKS,
|
||||
onActivate,
|
||||
onDocFrame,
|
||||
@@ -364,6 +371,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
if (pointers.current.size > 2) return;
|
||||
const target = event.target as Element;
|
||||
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
|
||||
if (documenting && locked) {
|
||||
gesture.current = { kind: 'background', start: point, moved: false, last: point };
|
||||
return;
|
||||
}
|
||||
if (documenting) {
|
||||
const found = docAt(point);
|
||||
if (found === null) {
|
||||
@@ -391,11 +402,11 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
|
||||
longPress.current = setTimeout(() => {
|
||||
const now = gesture.current;
|
||||
if (now?.kind !== 'device' || now.moved) return;
|
||||
if (now?.kind !== 'device' || now.moved || (locked && !simulating)) return;
|
||||
now.longPressed = true;
|
||||
onMenu?.({ kind: 'device', id: now.id }, now.start);
|
||||
}, LONG_PRESS_MILLISECONDS);
|
||||
} else if (linkId !== undefined && linkId !== null && !simulating) {
|
||||
} else if (linkId !== undefined && linkId !== null && !simulating && !locked) {
|
||||
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
|
||||
longPress.current = setTimeout(() => {
|
||||
const now = gesture.current;
|
||||
@@ -454,7 +465,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
|
||||
now.last = point;
|
||||
setViewport((before) => shifted(before ?? currentRef.current, step));
|
||||
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
|
||||
} else if (now.kind === 'device' && !now.longPressed && !simulating && !locked) {
|
||||
if (tool === 'cable') {
|
||||
const from = boxes.get(now.id)!.center;
|
||||
const target = deviceAt(point);
|
||||
@@ -501,6 +512,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
if (!now.moved) onActivate?.(now.id);
|
||||
return;
|
||||
}
|
||||
if (locked) {
|
||||
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
|
||||
return;
|
||||
}
|
||||
if (tool === 'cable') {
|
||||
setBand(null);
|
||||
const from = { id: now.id, side: now.side };
|
||||
@@ -645,7 +660,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
const corner = toScreen(current, { x: item.x, y: item.y });
|
||||
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
|
||||
})()}
|
||||
{menuButton === null || selection === null || simulating ? null : (
|
||||
{menuButton === null || selection === null || simulating || locked ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="device-menu-button"
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface DocumentationSidebarProps {
|
||||
readonly onExport: () => Promise<void>;
|
||||
/** „Report“: the report saved as a PDF; rejected, it failed. */
|
||||
readonly onReport: () => Promise<void>;
|
||||
/** Who only reads makes no texts or rectangles; the picture and the report stay. */
|
||||
readonly readOnly?: boolean;
|
||||
}
|
||||
|
||||
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
||||
@@ -48,7 +50,7 @@ const item: CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport }: DocumentationSidebarProps) {
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false }: DocumentationSidebarProps) {
|
||||
const t = useTranslate();
|
||||
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
||||
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||
@@ -106,7 +108,7 @@ export function DocumentationSidebar({ onItem, onExport, onReport }: Documentati
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{ITEMS.map((each) => (
|
||||
{(readOnly ? [] : ITEMS).map((each) => (
|
||||
<button
|
||||
key={each.kind}
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"access.frozen": "Eingefroren – nur ansehen",
|
||||
"access.frozenRefused": "Das Projekt ist eingefroren; die Simulation startet nicht.",
|
||||
"access.readOnly": "Nur ansehen",
|
||||
"access.readOnlyRefused": "Hier können Sie nur zuschauen.",
|
||||
"access.startedBy": "Simulation gestartet von {name}",
|
||||
"browser.address": "Adresse",
|
||||
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
|
||||
"browser.errorTitle": "Fehler {message} ({code})",
|
||||
@@ -352,6 +357,14 @@
|
||||
"program.textEditor": "Text-Editor",
|
||||
"program.webBrowser": "Webbrowser",
|
||||
"program.webServer": "Webserver",
|
||||
"remote.connecting": "Verbindung zum Projekt …",
|
||||
"remote.deleted": "Das Projekt wurde gelöscht.",
|
||||
"remote.noAccess": "Sie haben keinen Zugang zu diesem Projekt.",
|
||||
"remote.notAProject": "Diese Adresse nennt kein Projekt.",
|
||||
"remote.notSignedIn": "Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.",
|
||||
"remote.refused": "Der Filius-Dienst hat die Verbindung abgelehnt ({reason}).",
|
||||
"remote.revoked": "Ihr Zugang zu diesem Projekt wurde beendet.",
|
||||
"remote.unknownProject": "Dieses Projekt gibt es nicht mehr.",
|
||||
"report.apps": "Anwendungen",
|
||||
"report.baseConfig": "Basiskonfiguration",
|
||||
"report.components": "Komponenten",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"access.frozen": "Frozen – view only",
|
||||
"access.frozenRefused": "The project is frozen; the simulation does not start.",
|
||||
"access.readOnly": "View only",
|
||||
"access.readOnlyRefused": "You can only watch here.",
|
||||
"access.startedBy": "Simulation started by {name}",
|
||||
"browser.address": "Address",
|
||||
"browser.errorText": "An error occurred (HTTP-Status: {code})",
|
||||
"browser.errorTitle": "Error {message} ({code})",
|
||||
@@ -352,6 +357,14 @@
|
||||
"program.textEditor": "Text editor",
|
||||
"program.webBrowser": "Webbrowser",
|
||||
"program.webServer": "Webserver",
|
||||
"remote.connecting": "Connecting to the project …",
|
||||
"remote.deleted": "The project was deleted.",
|
||||
"remote.noAccess": "You have no access to this project.",
|
||||
"remote.notAProject": "This address names no project.",
|
||||
"remote.notSignedIn": "Your sign-in has expired. Open the project again from the dashboard.",
|
||||
"remote.refused": "The Filius service refused the connection ({reason}).",
|
||||
"remote.revoked": "Your access to this project has ended.",
|
||||
"remote.unknownProject": "This project no longer exists.",
|
||||
"report.apps": "Applications",
|
||||
"report.baseConfig": "Base Configuration",
|
||||
"report.components": "Components",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// The editor's page. Embedded in the dashboard (F6) it gets its document and
|
||||
// language over the bridge; on its own — for development and the browser
|
||||
// tests — it opens one of FILIUS' examples, an empty project or a file
|
||||
// (`?example=<name>` opens that example at once, `?example=new` an empty
|
||||
// project, `?lang=en` and `?theme=dark` set the rest).
|
||||
// The editor's page. On a project of server F (`?project=<uuid>`, and
|
||||
// `&server=<address>` when F is not where the page comes from) it opens the
|
||||
// shared document and the room there (SharedProject); embedded in the
|
||||
// dashboard (F6) it gets its language and theme over the bridge. On its own
|
||||
// — for development and the browser tests — it opens one of FILIUS'
|
||||
// examples, an empty project or a file (`?example=<name>` opens that example
|
||||
// at once, `?example=new` an empty project, `?lang=en` and `?theme=dark` set
|
||||
// the rest), with its room in a worker of the page.
|
||||
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
@@ -11,6 +14,7 @@ import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topology
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { startBridge } from './bridge/dashboardBridge.js';
|
||||
import { Editor } from './Editor.js';
|
||||
import { SharedProject } from './remote/SharedProject.js';
|
||||
import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
|
||||
import './theme/tokens.css';
|
||||
|
||||
@@ -32,6 +36,7 @@ function App() {
|
||||
/** The open file's name, for what the editor exports. */
|
||||
const [fileName, setFileName] = useState<string | undefined>(undefined);
|
||||
const [example, setExample] = useState(params.get('example') ?? '');
|
||||
const project = params.get('project');
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
||||
@@ -81,6 +86,14 @@ function App() {
|
||||
}, [doc]);
|
||||
|
||||
const t = (key: string) => translate(language, key);
|
||||
if (project !== null)
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<main style={{ height: '100%' }}>
|
||||
<SharedProject server={params.get('server') ?? window.location.origin} project={project} />
|
||||
</main>
|
||||
</LanguageProvider>
|
||||
);
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// The editor on a project of server F (F5.3b): the shared document over
|
||||
// /sync and the room over /sim, both signed in by the cookie F set at
|
||||
// /__auth. Until the document is there, a word that it is coming; when F
|
||||
// refuses or lets go — of the document or the room — a word why, instead of
|
||||
// the editor. Who only reads gets the editor locked (Editor's readOnly).
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Editor } from '../Editor.js';
|
||||
import { CATALOGS, useLanguage, useTranslate } from '../i18n/i18n.js';
|
||||
import type { SimHostFactory } from '../simulation/useSimulation.js';
|
||||
import { RemoteSimHost, simUrl } from './remoteSimHost.js';
|
||||
import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
|
||||
|
||||
export interface SharedProjectProps {
|
||||
/** F's address. */
|
||||
readonly server: string;
|
||||
readonly project: string;
|
||||
}
|
||||
|
||||
export function SharedProject({ server, project }: SharedProjectProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const [state, setState] = useState<SharedDocState>({ kind: 'connecting' });
|
||||
/** F ended the room for this page: the editor goes as when the document is let go. */
|
||||
const [roomEnd, setRoomEnd] = useState<string | null>(null);
|
||||
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
|
||||
const createSimHost = useMemo<SimHostFactory>(
|
||||
() => () => {
|
||||
const host = new RemoteSimHost(simUrl(server, project));
|
||||
host.onEnd((end) => setRoomEnd(end.reason));
|
||||
return host;
|
||||
},
|
||||
[server, project],
|
||||
);
|
||||
const ended = roomEnd ?? (state.kind === 'refused' || state.kind === 'closed' ? state.reason : null);
|
||||
if (ended !== null) {
|
||||
const key = `remote.${ended}`;
|
||||
return (
|
||||
<p role="alert" className="remote-message">
|
||||
{key in CATALOGS[language] ? t(key) : t('remote.refused', { reason: ended })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.kind !== 'ready')
|
||||
return (
|
||||
<p role="status" className="remote-message">
|
||||
{t('remote.connecting')}
|
||||
</p>
|
||||
);
|
||||
return <Editor key={state.doc.guid} doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// The simulation room on server F (plan §6.1): a WebSocket to
|
||||
// /sim?project=…, the same messages as the room in a worker of the page
|
||||
// (LocalSimHost). The socket comes back after it is lost (the network, F
|
||||
// restarting, the room's thread failing), with the topics it watched; F's
|
||||
// own word ends it for good — refused (no session, no grant) or let go
|
||||
// (revoked, deleted) — and is told to whoever listens (`onEnd`).
|
||||
|
||||
import {
|
||||
parseHostMessage,
|
||||
SIM_CLOSED_CODE,
|
||||
SIM_PROTOCOL_VERSION,
|
||||
SIM_REFUSED_CODE,
|
||||
type ClientMessage,
|
||||
type HostMessage,
|
||||
type SimHost,
|
||||
type SyncClosing,
|
||||
type SyncRefusal,
|
||||
type Topic,
|
||||
} from '@mdg-filius/protocol';
|
||||
|
||||
/** The first wait before the socket comes back, doubled each time up to RECONNECT_MAXIMUM_MILLISECONDS. */
|
||||
export const RECONNECT_FIRST_MILLISECONDS = 500;
|
||||
export const RECONNECT_MAXIMUM_MILLISECONDS = 10_000;
|
||||
|
||||
/** Why F ended the room for this page. */
|
||||
export type RoomEnd = { readonly kind: 'refused'; readonly reason: SyncRefusal } | { readonly kind: 'closed'; readonly reason: SyncClosing };
|
||||
|
||||
/** What RemoteSimHost needs of a WebSocket (the browser's, or ws in tests). */
|
||||
export interface SocketLike {
|
||||
readonly readyState: number;
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((event: { readonly data: unknown }) => void) | null;
|
||||
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null;
|
||||
}
|
||||
|
||||
export type SocketFactory = (url: string) => SocketLike;
|
||||
|
||||
const OPEN = 1;
|
||||
|
||||
export class RemoteSimHost implements SimHost {
|
||||
private socket: SocketLike | undefined;
|
||||
private readonly listeners = new Set<(message: HostMessage) => void>();
|
||||
private readonly endListeners = new Set<(end: RoomEnd) => void>();
|
||||
/** What waits for the socket to open. */
|
||||
private queue: ClientMessage[] = [];
|
||||
/** What this page watches: sent again when the socket comes back. */
|
||||
private readonly topics = new Set<Topic>();
|
||||
private wait = RECONNECT_FIRST_MILLISECONDS;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private over = false;
|
||||
private openedBefore = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly open: SocketFactory = (address) => new WebSocket(address) as unknown as SocketLike,
|
||||
) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
const socket = this.open(this.url);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.wait = RECONNECT_FIRST_MILLISECONDS;
|
||||
let queued = this.queue;
|
||||
this.queue = [];
|
||||
// Back after a loss, in a room that knows nothing of this page: all it watches, before anything else.
|
||||
if (this.openedBefore) {
|
||||
if (this.topics.size > 0) socket.send(JSON.stringify({ v: 1, kind: 'sub', topics: [...this.topics] }));
|
||||
queued = queued.filter((message) => message.kind !== 'sub' && message.kind !== 'unsub');
|
||||
}
|
||||
this.openedBefore = true;
|
||||
for (const message of queued) socket.send(JSON.stringify(message));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
value = undefined;
|
||||
}
|
||||
const parsed = parseHostMessage(value);
|
||||
const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail };
|
||||
for (const listener of this.listeners) listener(message);
|
||||
};
|
||||
socket.onclose = ({ code, reason }) => {
|
||||
if (this.over) return;
|
||||
if (code === SIM_REFUSED_CODE || code === SIM_CLOSED_CODE) {
|
||||
this.over = true;
|
||||
const end = (code === SIM_REFUSED_CODE ? { kind: 'refused', reason } : { kind: 'closed', reason }) as RoomEnd;
|
||||
for (const listener of this.endListeners) listener(end);
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => this.connect(), this.wait);
|
||||
this.wait = Math.min(RECONNECT_MAXIMUM_MILLISECONDS, this.wait * 2);
|
||||
};
|
||||
}
|
||||
|
||||
send(message: ClientMessage): void {
|
||||
if (message.kind === 'sub') for (const topic of message.topics) this.topics.add(topic);
|
||||
if (message.kind === 'unsub') for (const topic of message.topics) this.topics.delete(topic);
|
||||
if (this.over) return;
|
||||
if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(message));
|
||||
// Sent once the socket is open (again).
|
||||
else this.queue.push(message);
|
||||
}
|
||||
|
||||
onMessage(listener: (message: HostMessage) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Hears F end the room for this page; returns how to stop hearing. */
|
||||
onEnd(listener: (end: RoomEnd) => void): () => void {
|
||||
this.endListeners.add(listener);
|
||||
return () => this.endListeners.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.over = true;
|
||||
if (this.timer !== undefined) clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
this.endListeners.clear();
|
||||
this.socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The address of a project's room on F, from F's address (http or https). */
|
||||
export function simUrl(server: string, project: string): string {
|
||||
const url = new URL('/sim', server);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.searchParams.set('project', project);
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// The project's shared document on server F (plan §5): Hocuspocus' client
|
||||
// on /sync, signed in by the cookie F set at /__auth. What it says is the
|
||||
// editor's state: still connecting; ready — with the document, whether this
|
||||
// person only reads, and whether because the project is frozen; refused
|
||||
// (no session, no grant, no such project); or let go (revoked, deleted).
|
||||
//
|
||||
// A person who read and may write again (a frozen project thawed) gets the
|
||||
// document anew: a change made while reading stayed in the page's copy and
|
||||
// F did not take it, and Yjs on F would hold back every later change that
|
||||
// builds on it (measured in F5.2). The editor locks editing while it reads,
|
||||
// so that nothing gets there; the fresh copy is for what got there anyway.
|
||||
|
||||
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { SYNC_CLOSINGS, type AccessMessage } from '@mdg-filius/protocol';
|
||||
|
||||
export type SharedDocState =
|
||||
| { readonly kind: 'connecting' }
|
||||
| { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean }
|
||||
/** F refused the connection; the reason is one of SYNC_REFUSALS (or Hocuspocus' own word). */
|
||||
| { readonly kind: 'refused'; readonly reason: string }
|
||||
/** F let the connection go; the reason is one of SYNC_CLOSINGS. */
|
||||
| { readonly kind: 'closed'; readonly reason: string };
|
||||
|
||||
export interface SharedDocOptions {
|
||||
/** F's address (http or https). */
|
||||
readonly server: string;
|
||||
readonly project: string;
|
||||
readonly onState: (state: SharedDocState) => void;
|
||||
/** A WebSocket other than the page's (tests in Node). */
|
||||
readonly WebSocketPolyfill?: unknown;
|
||||
}
|
||||
|
||||
/** The address of F's shared documents, from F's address. */
|
||||
export function syncUrl(server: string): string {
|
||||
const url = new URL('/sync', server);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** Connects to the project's document; returns how to let it go. */
|
||||
export function connectSharedDoc({ server, project, onState, WebSocketPolyfill }: SharedDocOptions): () => void {
|
||||
let current: { readonly provider: HocuspocusProvider; readonly socket: HocuspocusProviderWebsocket } | undefined;
|
||||
const letGo = () => {
|
||||
current?.provider.destroy();
|
||||
current?.socket.destroy();
|
||||
current = undefined;
|
||||
};
|
||||
const end = (state: SharedDocState) => {
|
||||
letGo();
|
||||
onState(state);
|
||||
};
|
||||
const open = () => {
|
||||
const doc = new Y.Doc();
|
||||
const socket = new HocuspocusProviderWebsocket({ url: syncUrl(server), ...(WebSocketPolyfill === undefined ? {} : { WebSocketPolyfill }) });
|
||||
let readOnly = false;
|
||||
let frozen = false;
|
||||
let synced = false;
|
||||
/** F tells the access at once (`access`): ready only with it, so that a reader never sees a writable editor. */
|
||||
let told = false;
|
||||
const tell = () => {
|
||||
if (synced && told) onState({ kind: 'ready', doc, readOnly, frozen });
|
||||
};
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: project,
|
||||
document: doc,
|
||||
// Who comes in is told by the cookie; the token is not looked at.
|
||||
token: 'cookie',
|
||||
onSynced: () => {
|
||||
if (synced) return;
|
||||
synced = true;
|
||||
tell();
|
||||
},
|
||||
onAuthenticationFailed: ({ reason }) => end({ kind: 'refused', reason }),
|
||||
onStateless: ({ payload }) => {
|
||||
let message: Partial<AccessMessage>;
|
||||
try {
|
||||
message = JSON.parse(payload) as Partial<AccessMessage>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.kind !== 'access' || typeof message.readOnly !== 'boolean') return;
|
||||
if (readOnly && !message.readOnly && synced) {
|
||||
letGo();
|
||||
onState({ kind: 'connecting' });
|
||||
open();
|
||||
return;
|
||||
}
|
||||
readOnly = message.readOnly;
|
||||
frozen = message.frozen === true;
|
||||
told = true;
|
||||
tell();
|
||||
},
|
||||
onClose: ({ event }) => {
|
||||
if ((SYNC_CLOSINGS as readonly string[]).includes(event.reason)) end({ kind: 'closed', reason: event.reason });
|
||||
},
|
||||
});
|
||||
provider.attach();
|
||||
current = { provider, socket };
|
||||
};
|
||||
onState({ kind: 'connecting' });
|
||||
open();
|
||||
return letGo;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { ConnectionsPanel } from './ConnectionsPanel.js';
|
||||
import { FirewallPanel } from './FirewallPanel.js';
|
||||
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetLock, SheetLocked } from './SheetLock.js';
|
||||
import './sheet.css';
|
||||
|
||||
/** The device's sheet, read anew after every change of the document. */
|
||||
@@ -60,6 +61,8 @@ export interface DeviceSheetProps {
|
||||
readonly onClose: () => void;
|
||||
/** The cable to draw active (a router's card whose tab is open), or none. */
|
||||
readonly onHighlight: (link: string | null) => void;
|
||||
/** Who only reads (on F): everything shown, nothing changed (SheetLock). */
|
||||
readonly readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** A page of the sheet that Java opens as a dialog. */
|
||||
@@ -67,7 +70,7 @@ export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections';
|
||||
|
||||
const PAGE_TITLES: Readonly<Record<SheetPage, string>> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' };
|
||||
|
||||
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
|
||||
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false }: DeviceSheetProps) {
|
||||
const t = useTranslate();
|
||||
const sheet = useDeviceSheet(doc, id);
|
||||
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields.
|
||||
@@ -86,6 +89,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
};
|
||||
const title = t(`device.${sheet.kind}`);
|
||||
return (
|
||||
<SheetLocked.Provider value={readOnly}>
|
||||
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
|
||||
<header>
|
||||
{open === null ? (
|
||||
@@ -103,17 +107,25 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
{/* A field per device: a text typed for one device is not offered to the next. Kept while a page is open, so that its tab stays as Java's panel does behind a dialog. */}
|
||||
<div key={id} hidden={open !== null}>
|
||||
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
|
||||
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
|
||||
<SheetLock>
|
||||
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
|
||||
</SheetLock>
|
||||
) : sheet.kind === 'networkSwitch' ? (
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
) : sheet.kind === 'remoteLink' ? (
|
||||
<ModemFields sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<ModemFields sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
) : sheet.kind === 'router' ? (
|
||||
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
|
||||
) : sheet.kind === 'gateway' ? (
|
||||
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
|
||||
) : (
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<SheetLock>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
</SheetLock>
|
||||
)}
|
||||
</div>
|
||||
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? (
|
||||
@@ -126,14 +138,19 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
</div>
|
||||
) : open === 'connections' && sheet.kind === 'router' ? (
|
||||
<div key={`${id}-connections`}>
|
||||
<ConnectionsPanel sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<ConnectionsPanel sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
</div>
|
||||
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
|
||||
<div key={`${id}-forwarding`}>
|
||||
<PortForwardingPanel sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<PortForwardingPanel sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</SheetLocked.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Change } from './NameField.js';
|
||||
import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
|
||||
import { SheetTabs } from './SheetTabs.js';
|
||||
import { SheetPanel } from './SheetLock.js';
|
||||
|
||||
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
|
||||
const PROTOCOL_NAMES: Readonly<Record<FirewallProtocol, string>> = { all: '*', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
|
||||
@@ -51,7 +52,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
|
||||
selected={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
<div role="tabpanel" key={tab}>
|
||||
<SheetPanel key={tab}>
|
||||
{tab === 'general' ? (
|
||||
<>
|
||||
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
|
||||
@@ -174,7 +175,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { NameField, type Change } from './NameField.js';
|
||||
import { RowsEditor } from './RowsEditor.js';
|
||||
import { SheetTabs, type Tab } from './SheetTabs.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetPanel } from './SheetLock.js';
|
||||
|
||||
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
|
||||
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
|
||||
@@ -94,7 +95,7 @@ export function RouterFields({
|
||||
<>
|
||||
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
|
||||
{/* A panel per tab: a text typed on one card is not offered to the next. */}
|
||||
<div role="tabpanel" key={shown}>
|
||||
<SheetPanel key={shown}>
|
||||
{shown === 'general' ? (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
@@ -115,7 +116,7 @@ export function RouterFields({
|
||||
) : (
|
||||
<RouteTable sheet={sheet} change={change} />
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +145,7 @@ export function HomeRouterFields({
|
||||
return (
|
||||
<>
|
||||
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
|
||||
<div role="tabpanel" key={tab}>
|
||||
<SheetPanel key={tab}>
|
||||
{tab === 'general' ? (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
@@ -175,7 +176,7 @@ export function HomeRouterFields({
|
||||
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { Change } from './NameField.js';
|
||||
import { RowsEditor, type RowsColumn } from './RowsEditor.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetTabs } from './SheetTabs.js';
|
||||
import { SheetPanel } from './SheetLock.js';
|
||||
|
||||
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
@@ -44,7 +45,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
|
||||
selected={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
<div role="tabpanel" key={tab}>
|
||||
<SheetPanel key={tab}>
|
||||
{tab === 'basic' ? (
|
||||
<>
|
||||
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} />
|
||||
@@ -105,7 +106,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
|
||||
onRemove={(index) => change.run(() => removeDhcpStaticAssignment(change.doc, change.id, index, change.origin))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// The sheet of a person who only reads (on F): what it holds is shown, every
|
||||
// field, box and button that changes something is disabled — a fieldset
|
||||
// around a sheet's body and around each tab's panel, so that the tabs stay
|
||||
// to be read through.
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
export const SheetLocked = createContext(false);
|
||||
|
||||
/** A sheet's body, disabled while the sheet is locked. */
|
||||
export function SheetLock({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="sheet-lock" disabled={useContext(SheetLocked)}>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tab's panel, disabled while the sheet is locked. */
|
||||
export function SheetPanel({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="sheet-lock" role="tabpanel" disabled={useContext(SheetLocked)}>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,18 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* A word beside the mode buttons: who started the simulation, that this person only reads. */
|
||||
.editor-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -336,3 +348,18 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
/* A locked sheet's body and tab panels (SheetLock): a fieldset without its own frame. */
|
||||
.sheet-lock {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Instead of the editor on a project of F: that it is coming, or why not. */
|
||||
.remote-message {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Java's mode buttons (GUIMainMenu: documentation, design and simulation
|
||||
// mode, in Java's order, with Java's icons) and, while the simulation runs, its speed slider with the per cent
|
||||
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
|
||||
// beside it. Who only reads (on F) neither starts nor stops the room's
|
||||
// simulation nor sets its speed; the documentation mode is a view of one's own. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
|
||||
// a browser they bookmark and reload the page.
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
@@ -25,7 +26,20 @@ const modeButton: CSSProperties = {
|
||||
/** The editor's modes: the room's two, and the documentation mode, which is the design's for the room. */
|
||||
export type EditorMode = 'documentation' | 'design' | 'simulation';
|
||||
|
||||
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: EditorMode; readonly speed: number; readonly onMode: (mode: EditorMode) => void; readonly onSpeed: (value: number) => void }) {
|
||||
export function ModeBar({
|
||||
mode,
|
||||
speed,
|
||||
onMode,
|
||||
onSpeed,
|
||||
canRun = true,
|
||||
}: {
|
||||
readonly mode: EditorMode;
|
||||
readonly speed: number;
|
||||
readonly onMode: (mode: EditorMode) => void;
|
||||
readonly onSpeed: (value: number) => void;
|
||||
/** Whether this person may start and stop the simulation (a write grant on F). */
|
||||
readonly canRun?: boolean;
|
||||
}) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<>
|
||||
@@ -36,19 +50,20 @@ export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: Edito
|
||||
aria-pressed={mode === 'documentation'}
|
||||
aria-label={t('mode.documentation')}
|
||||
title={t('mode.documentation')}
|
||||
disabled={!canRun && mode === 'simulation'}
|
||||
onClick={() => onMode('documentation')}
|
||||
>
|
||||
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
|
||||
</button>
|
||||
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
|
||||
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} disabled={!canRun && mode === 'simulation'} onClick={() => onMode('design')}>
|
||||
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
|
||||
</button>
|
||||
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} onClick={() => onMode('simulation')}>
|
||||
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} disabled={!canRun} onClick={() => onMode('simulation')}>
|
||||
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
|
||||
</button>
|
||||
{mode === 'simulation' ? (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
|
||||
<input type="range" name="speed" min={1} max={100} value={speed} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
|
||||
<input type="range" name="speed" min={1} max={100} value={speed} disabled={!canRun} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
|
||||
<span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{speed}%
|
||||
</span>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// the cables that carry frames, a computer's network as it runs, the lines
|
||||
// and views of the programs' windows and the actions taken in them, the
|
||||
// cards' frames for the data exchange window. The room
|
||||
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
|
||||
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
|
||||
// runs behind a SimHost — on F for everyone in the project (RemoteSimHost),
|
||||
// or in a worker of this page (LocalSimHost) on the development page; the
|
||||
// editor keeps no simulation of its own.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
@@ -41,6 +42,10 @@ export interface Simulation {
|
||||
readonly views: ReadonlyMap<string, unknown>;
|
||||
/** Whether the core failed and the room went back to design mode, since the last start. */
|
||||
readonly failed: boolean;
|
||||
/** Who started the simulation that runs, on F. */
|
||||
readonly startedBy: string | undefined;
|
||||
/** The room's last no to this person — it only reads, the project is frozen — counted, so that each is told. */
|
||||
readonly refusal: { readonly code: 'readOnly' | 'frozen'; readonly count: number } | null;
|
||||
/** The time of day (milliseconds since 1970) the simulation's time 0 stands for: a viewer's times of day are written from it. */
|
||||
readonly clock: number;
|
||||
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
|
||||
@@ -75,6 +80,8 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
|
||||
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [startedBy, setStartedBy] = useState<string | undefined>(undefined);
|
||||
const [refusal, setRefusal] = useState<Simulation['refusal']>(null);
|
||||
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
|
||||
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
|
||||
const [clock, setClock] = useState(0);
|
||||
@@ -90,6 +97,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
setMode(message.mode);
|
||||
setSpeedState(message.speed);
|
||||
setClock(message.clock);
|
||||
setStartedBy(message.startedBy);
|
||||
if (message.mode === 'design') setActiveLinks(NO_LINKS);
|
||||
return;
|
||||
case 'activity':
|
||||
@@ -125,6 +133,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
return;
|
||||
case 'error':
|
||||
if (message.code === 'engineFailed') setFailed(true);
|
||||
if (message.code === 'readOnly' || message.code === 'frozen') {
|
||||
const code = message.code;
|
||||
setRefusal((before) => ({ code, count: (before?.count ?? 0) + 1 }));
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
@@ -199,5 +211,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, 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, setSpeed, drop, clearCapture, watch, captures, act };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user