feat(editor): the simulation opens sheets, not windows in the window
A tap on a device in the simulation now only chooses it; its three-dot button (or a long press) opens what Java's click opened: the desktop, and newly a switch's SAT table and a home router's NAT table, beside each card's data exchange. The desktop, the tables and the data exchange are no windows in the window any more but the simulation's sheet (SimulationSheet), after the dashboard's AppSheet: below the toolbar to the bottom, a head with icon, title, who else has it open, see-through and close. One at a time — it covers the surface. It stays over a return to the design with the program open on a desktop; a sheet whose device was removed closes. See-through fades the body (0.55) and keeps the head; the choice is kept in the browser. Java opens these as movable windows, several at once, with a click; named in CHANGES.md. Tests open through the menu (showFromMenu, showDesktop in editorPage.ts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+91
-100
@@ -2,13 +2,16 @@
|
||||
// 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. In the
|
||||
// frame light up, and a tap chooses a device: its three-dot button (or a long
|
||||
// press) opens its desktop, its table, its data exchange — each as the
|
||||
// simulation's sheet (SimulationSheet), one at a time, never as a window in
|
||||
// the window. In the
|
||||
// documentation mode (F4.8b) the sidebar makes texts and rectangles, which
|
||||
// lie over the network and are moved, resized, typed into and coloured
|
||||
// there; for the room it is the design (Java stops a simulation for it). Desktops left
|
||||
// open stay over a return to the design and show again at the next start,
|
||||
// as Java's do (GUIContainer.closeDialogs, reopenDialogs); so does the data
|
||||
// exchange window with its tabs (F4.7).
|
||||
// there; for the room it is the design (Java stops a simulation for it). A sheet left
|
||||
// open stays over a return to the design and shows again at the next start,
|
||||
// as Java's windows do (GUIContainer.closeDialogs, reopenDialogs); the data
|
||||
// exchange keeps its tabs (F4.7), a desktop the program open on it.
|
||||
//
|
||||
// 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
|
||||
@@ -17,11 +20,11 @@
|
||||
// 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
|
||||
// stopped by them; desktops and the data exchange open to watch. The
|
||||
// room's simulation names who started it.
|
||||
//
|
||||
// On F the others are shown (plan §7.4, presence.ts): a frame in their
|
||||
// colour around a device they have chosen, a dot on a window they have open
|
||||
// colour around a device they have chosen, a dot on a sheet they have open
|
||||
// too, a circle each beside the mode buttons, a device or an item they drag
|
||||
// where it is now; this editor tells them its own choice, windows and drag,
|
||||
// and nothing more. A drag goes into the document once, when it is let go
|
||||
@@ -60,13 +63,14 @@ import { saveFile } from './download.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 { DesktopSheet, type DesktopWindowName } from './desktop/DesktopSheet.js';
|
||||
import { translate, useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { CaptureWindow } from './simulation/CaptureWindow.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 { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.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';
|
||||
import { PeopleRow } from './remote/PresenceMarks.js';
|
||||
import { commitMove, movedView, type Moving } from './moving.js';
|
||||
@@ -122,21 +126,19 @@ 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));
|
||||
|
||||
/** The data exchange window's place among the desktops (it is one window, with a tab per card). */
|
||||
const CAPTURE_WINDOW = 'capture';
|
||||
/** The data exchange's id as a sheet (it is one sheet, with a tab per card). */
|
||||
const CAPTURE_SHEET = 'capture';
|
||||
|
||||
/**
|
||||
* A desktop open on the surface — or a switch's or home router's viewer
|
||||
* (`viewer`), or the data exchange window (`capture`) —: whose, where its
|
||||
* window lies, and how far up (the last one touched on top).
|
||||
* The sheet open in the simulation: a computer's desktop — or a switch's or
|
||||
* home router's table (`viewer`), or the data exchange (`capture`) —, and the
|
||||
* window open on a desktop (kept over a return to the design, as Java's
|
||||
* desktop keeps it; closed, the desktop begins at its home screen again).
|
||||
*/
|
||||
interface OpenDesktop {
|
||||
interface OpenSheet {
|
||||
readonly id: string;
|
||||
readonly viewer?: ViewerKind | 'capture';
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly program: DesktopWindowName | null;
|
||||
readonly program?: DesktopWindowName | null;
|
||||
}
|
||||
|
||||
/** A card's tab in the data exchange window: whose card, the frame chosen in it, whether it scrolls along (AggregatedMessageTable's own). */
|
||||
@@ -147,12 +149,6 @@ interface OpenCaptureTab {
|
||||
readonly autoscroll: boolean;
|
||||
}
|
||||
|
||||
/** 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 interface EditorProps {
|
||||
readonly doc: Y.Doc;
|
||||
readonly createSimHost?: SimHostFactory;
|
||||
@@ -175,7 +171,20 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
const [documenting, setDocumenting] = useState(false);
|
||||
// The simulation comes first: started, it ends the documentation mode (changeMode).
|
||||
const editorMode: EditorMode = simulating ? 'simulation' : documenting ? 'documentation' : 'design';
|
||||
const [desktops, setDesktops] = useState<readonly OpenDesktop[]>([]);
|
||||
const [sheet, setSheet] = useState<OpenSheet | null>(null);
|
||||
const [seeThrough, setSeeThrough] = useSeeThrough();
|
||||
// The sheet begins below the toolbar, which wraps on a narrow screen.
|
||||
const toolbar = useRef<HTMLDivElement>(null);
|
||||
const [sheetTop, setSheetTop] = useState(68);
|
||||
useEffect(() => {
|
||||
const bar = toolbar.current;
|
||||
if (bar === null || typeof ResizeObserver === 'undefined') return undefined;
|
||||
const measure = () => setSheetTop(bar.offsetTop + bar.offsetHeight + 8);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(bar);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []);
|
||||
const { manager, canUndo, canRedo } = useUndo(doc, origin);
|
||||
const canvas = useRef<CanvasHandle>(null);
|
||||
@@ -228,19 +237,19 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
// 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); so does a viewer whose switch or home router is.
|
||||
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs); so does a table whose switch or home router is.
|
||||
useEffect(() => {
|
||||
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
|
||||
const fits = (each: OpenDesktop) => {
|
||||
const fits = (each: OpenSheet) => {
|
||||
const kind = kinds.get(each.id);
|
||||
if (each.viewer === 'capture') return true;
|
||||
return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook';
|
||||
};
|
||||
setDesktops((open) => (open.every(fits) ? open : open.filter(fits)));
|
||||
setSheet((open) => (open === null || fits(open) ? open : null));
|
||||
}, [view]);
|
||||
|
||||
// The data exchange window's tabs (Java keeps them when the window is closed); a card that is gone loses its tab
|
||||
// (Java: clearUnavailableComponents). Without a tab the window shows nothing; it keeps its place, as Java's dialog does.
|
||||
// The data exchange's tabs (Java keeps them when its window is closed); a card that is gone loses its tab
|
||||
// (Java: clearUnavailableComponents). Without a tab the sheet shows nothing.
|
||||
const [captureTabs, setCaptureTabs] = useState<{ readonly tabs: readonly OpenCaptureTab[]; readonly selected: string | null }>({ tabs: [], selected: null });
|
||||
const changeCaptureTab = useCallback(
|
||||
(port: string, change: Partial<OpenCaptureTab>) => setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => (tab.port === port ? { ...tab, ...change } : tab)) })),
|
||||
@@ -255,14 +264,8 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
});
|
||||
}, [view, doc]);
|
||||
|
||||
/** Opens a computer's desktop, or a switch's or home router's viewer, or the data exchange window, or brings it to the front. */
|
||||
const openDesktop = useCallback((id: string, viewer?: ViewerKind | 'capture') => {
|
||||
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, ...(viewer === undefined ? {} : { viewer }), x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }];
|
||||
});
|
||||
}, []);
|
||||
/** Opens a computer's desktop, or a switch's or home router's table, or the data exchange, as the sheet (it covers the surface: one at a time). */
|
||||
const openSheet = useCallback((id: string, viewer?: ViewerKind | 'capture') => setSheet({ id, ...(viewer === undefined ? {} : { viewer }) }), []);
|
||||
|
||||
// What is selected may be gone (removed here or by someone else).
|
||||
useEffect(() => {
|
||||
@@ -318,9 +321,9 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
const openCapture = useCallback(
|
||||
(node: string, port: string) => {
|
||||
setCaptureTabs((before) => ({ tabs: before.tabs.some((tab) => tab.port === port) ? before.tabs : [...before.tabs, { node, port, chosen: undefined, autoscroll: true }], selected: port }));
|
||||
openDesktop(CAPTURE_WINDOW, 'capture');
|
||||
openSheet(CAPTURE_SHEET, 'capture');
|
||||
},
|
||||
[openDesktop],
|
||||
[openSheet],
|
||||
);
|
||||
|
||||
/** A text or rectangle from the sidebar: its middle where it was dropped (Java), or in the middle of the view; it has the focus. */
|
||||
@@ -358,10 +361,18 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
const menuItems = (target: Selection): MenuItem[] => {
|
||||
if (target.kind === 'doc') return [];
|
||||
if (simulating) {
|
||||
// Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address.
|
||||
// Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address. Before them,
|
||||
// what Java's click on the device opens — a switch's or home router's table —: a tap here only chooses.
|
||||
if (target.kind !== 'device') return [];
|
||||
const node = view.nodes.find((each) => each.id === target.id);
|
||||
const items: MenuItem[] = node?.kind === 'pc' || node?.kind === 'notebook' ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
|
||||
const items: MenuItem[] =
|
||||
node?.kind === 'pc' || node?.kind === 'notebook'
|
||||
? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openSheet(target.id) }]
|
||||
: node?.kind === 'networkSwitch'
|
||||
? [{ key: 'showSat', label: t('menu.showSat'), onChoose: () => openSheet(target.id, 'sat') }]
|
||||
: node?.kind === 'gateway'
|
||||
? [{ key: 'showNat', label: t('menu.showNat'), onChoose: () => openSheet(target.id, 'nat') }]
|
||||
: [];
|
||||
for (const card of captureCards(doc, target.id)) {
|
||||
items.push({ key: `showCapture-${card.portId}`, label: `${t('menu.showCapture')} (${card.ipAddress})`, onChoose: () => openCapture(target.id, card.portId) });
|
||||
}
|
||||
@@ -389,7 +400,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
const selected = selection?.kind === 'device' ? view.nodes.find((node) => node.id === selection.id) : undefined;
|
||||
|
||||
// The others: told this editor's chosen device and open windows (a window is open only while the simulation runs).
|
||||
const windows = simulating ? desktops.map((desktop) => (desktop.viewer === 'capture' ? windowKey('capture') : windowKey(desktop.viewer ?? 'desktop', desktop.id))) : [];
|
||||
const windows = simulating && sheet !== null ? [sheet.viewer === 'capture' ? windowKey('capture') : windowKey(sheet.viewer ?? 'desktop', sheet.id)] : [];
|
||||
// A device chosen in the simulation only carries its three-dot button: the others see it chosen only in the design.
|
||||
const told = JSON.stringify([simulating ? null : (selected?.id ?? null), windows, moving]);
|
||||
const publish = presence?.publish;
|
||||
@@ -478,13 +489,6 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
);
|
||||
}}
|
||||
activeLinks={simulation.activeLinks}
|
||||
onActivate={(id) => {
|
||||
const kind = view.nodes.find((node) => node.id === id)?.kind;
|
||||
if (kind === 'pc' || kind === 'notebook') openDesktop(id);
|
||||
// Java's click on a switch shows its table, on a home router its NAT table.
|
||||
else if (kind === 'networkSwitch') openDesktop(id, 'sat');
|
||||
else if (kind === 'gateway') openDesktop(id, 'nat');
|
||||
}}
|
||||
hasMenu={(target) => menuItems(target).length > 0}
|
||||
onMenu={(target, at) => {
|
||||
if (readOnly && !simulating) return;
|
||||
@@ -502,7 +506,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
setMenu({ target, at });
|
||||
}}
|
||||
/>
|
||||
<div role="toolbar" className="editor-toolbar">
|
||||
<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">
|
||||
@@ -526,18 +530,16 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{simulating
|
||||
? desktops.map((desktop) =>
|
||||
desktop.viewer === 'capture' ? (
|
||||
captureTabs.selected === null ? null : (
|
||||
<CaptureWindow
|
||||
key={desktop.id}
|
||||
{simulating && sheet !== null
|
||||
? (() => {
|
||||
const frame = (key: string): SheetFrame => ({ top: sheetTop, seeThrough, onSeeThrough: setSeeThrough, onClose: () => setSheet(null), watchers: watchers.get(key) });
|
||||
if (sheet.viewer === 'capture') {
|
||||
return captureTabs.selected === null ? null : (
|
||||
<CaptureSheet
|
||||
frame={frame(windowKey('capture'))}
|
||||
tabs={captureTabs.tabs.map((tab) => ({ ...tab, title: captureCards(doc, tab.node).find((card) => card.portId === tab.port)?.title ?? '' }))}
|
||||
selected={captureTabs.selected}
|
||||
simulation={simulation}
|
||||
x={desktop.x}
|
||||
y={desktop.y}
|
||||
z={desktop.z}
|
||||
onSelect={(port) => setCaptureTabs((before) => ({ ...before, selected: port }))}
|
||||
onCloseTab={(port) =>
|
||||
setCaptureTabs((before) => {
|
||||
@@ -552,47 +554,36 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) }));
|
||||
simulation.clearCapture();
|
||||
}}
|
||||
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))}
|
||||
watchers={watchers.get(windowKey('capture'))}
|
||||
/>
|
||||
)
|
||||
) : desktop.viewer !== undefined ? (
|
||||
<ViewerWindow
|
||||
key={desktop.id}
|
||||
kind={desktop.viewer}
|
||||
id={desktop.id}
|
||||
label={view.nodes.find((node) => node.id === desktop.id)?.label ?? ''}
|
||||
);
|
||||
}
|
||||
if (sheet.viewer !== undefined) {
|
||||
return (
|
||||
<ViewerSheet
|
||||
key={sheet.id}
|
||||
frame={frame(windowKey(sheet.viewer, sheet.id))}
|
||||
kind={sheet.viewer}
|
||||
id={sheet.id}
|
||||
label={view.nodes.find((node) => node.id === sheet.id)?.label ?? ''}
|
||||
simulation={simulation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const id = sheet.id;
|
||||
return (
|
||||
<DesktopSheet
|
||||
key={id}
|
||||
frame={frame(windowKey('desktop', id))}
|
||||
simulation={simulation}
|
||||
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))}
|
||||
watchers={watchers.get(windowKey(desktop.viewer, desktop.id))}
|
||||
doc={doc}
|
||||
id={id}
|
||||
active={view.links.some((link) => (link.sourceNode === id || link.targetNode === id) && simulation.activeLinks.has(link.id))}
|
||||
program={sheet.program ?? null}
|
||||
origin={origin}
|
||||
onProgram={(program) => setSheet((open) => (open?.id === id ? { ...open, program } : open))}
|
||||
/>
|
||||
) : (
|
||||
<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))}
|
||||
watchers={watchers.get(windowKey('desktop', desktop.id))}
|
||||
/>
|
||||
),
|
||||
)
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{menu === null ? null : menu.target.kind === 'doc' ? (
|
||||
(() => {
|
||||
|
||||
@@ -77,10 +77,10 @@ export interface CanvasProps {
|
||||
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
|
||||
readonly highlightedLink?: string | null;
|
||||
/**
|
||||
* The simulation: a tap on a device activates it (a computer's desktop)
|
||||
* and chooses it, so that its three-dot button shows as in the design; a
|
||||
* long press opens its menu; nothing moves, as in Java's simulation mode.
|
||||
* The documentation mode: see above.
|
||||
* The simulation: a tap on a device only chooses it, so that its
|
||||
* three-dot button shows as in the design (Java's click opens a desktop or
|
||||
* a table; here the menu does); a long press opens its menu; nothing
|
||||
* moves, as in Java's simulation mode. The documentation mode: see above.
|
||||
*/
|
||||
readonly mode?: 'design' | 'simulation' | 'documentation';
|
||||
/**
|
||||
@@ -97,7 +97,6 @@ export interface CanvasProps {
|
||||
readonly renderDocText?: (item: DocumentationView, frame: ScreenFrame) => React.ReactNode;
|
||||
/** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */
|
||||
readonly activeLinks?: ReadonlySet<string>;
|
||||
readonly onActivate?: (id: string) => void;
|
||||
/** In the simulation: whether the chosen device has a menu at all (a switch has none) — only then its three-dot button. */
|
||||
readonly hasMenu?: (target: Selection) => boolean;
|
||||
}
|
||||
@@ -276,7 +275,6 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
locked = false,
|
||||
chosenBy = NO_CHOICES,
|
||||
activeLinks = NO_LINKS,
|
||||
onActivate,
|
||||
hasMenu,
|
||||
onDocFrame,
|
||||
renderDocText,
|
||||
@@ -558,10 +556,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
}
|
||||
if (now.longPressed) return;
|
||||
if (simulating) {
|
||||
if (!now.moved) {
|
||||
onSelect?.({ kind: 'device', id: now.id });
|
||||
onActivate?.(now.id);
|
||||
}
|
||||
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
|
||||
return;
|
||||
}
|
||||
if (locked) {
|
||||
|
||||
+11
-44
@@ -1,5 +1,5 @@
|
||||
// A computer's desktop in the simulation, after Java's GUIDesktopWindow and
|
||||
// GUIDesktopPanel: a window titled with the computer's label and address
|
||||
// GUIDesktopPanel: a sheet titled with the computer's label and address
|
||||
// (the label alone where it is the address), Java's background, the
|
||||
// software installation's icon first and then the installed programs in the
|
||||
// order of Java's list, a grey taskbar with "home screen" in the middle and,
|
||||
@@ -8,8 +8,8 @@
|
||||
// has a "change" button under read-only fields, which writes back what they
|
||||
// show; it is left out.
|
||||
//
|
||||
// On a wide screen the window lies over the surface and moves by its title
|
||||
// bar, as Java's windows do; on a phone it fills the surface. A program's
|
||||
// Java's window is the simulation's sheet here (SimulationSheet), not a
|
||||
// window in the window. A program's
|
||||
// icon opens its window over the desktop (ProgramFrame), the home button
|
||||
// leaves it; so far the command line (F4.6b), the web browser and the web
|
||||
// server (F4.6d), the e-mail program and server (F4.6e), the text editor
|
||||
@@ -19,7 +19,7 @@
|
||||
// text editor's from its open file, the image viewer's from its picture, as
|
||||
// Java's do.
|
||||
|
||||
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { desktopView } from '@mdg-filius/doc';
|
||||
import { browserTitle } from '@mdg-filius/protocol';
|
||||
@@ -46,31 +46,22 @@ import { GnutellaPanel } from './GnutellaPanel.js';
|
||||
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
|
||||
import { WebServerPanel } from './WebServerPanel.js';
|
||||
import './desktop.css';
|
||||
import { WindowWatchers } from '../remote/PresenceMarks.js';
|
||||
import type { PresenceUser } from '../remote/presence.js';
|
||||
import { SimulationSheet, type SheetFrame } from '../simulation/SimulationSheet.js';
|
||||
|
||||
export interface DesktopWindowProps {
|
||||
/** Who else in the project has this window open (plan §7.4). */
|
||||
readonly watchers?: readonly PresenceUser[] | undefined;
|
||||
export interface DesktopSheetProps {
|
||||
/** The sheet's place, its see-through and close, who else has it open. */
|
||||
readonly frame: SheetFrame;
|
||||
readonly doc: Y.Doc;
|
||||
readonly id: string;
|
||||
/** The room: the computer's network as it runs, its programs' windows. */
|
||||
readonly simulation: Simulation;
|
||||
/** Whether its cable carried a frame at the last tick. */
|
||||
readonly active: boolean;
|
||||
/** The window's top left corner on the surface. */
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** Its place in the stack of desktops. */
|
||||
readonly z: number;
|
||||
/** The window open on it — a program's or the installation's —, kept by the editor over a return to the design (Java's desktop keeps it). */
|
||||
readonly program: DesktopWindowName | null;
|
||||
readonly onProgram: (program: DesktopWindowName | null) => void;
|
||||
/** The person's origin, for what the installation changes in the document. */
|
||||
readonly origin: object;
|
||||
readonly onMove: (x: number, y: number) => void;
|
||||
readonly onFront: () => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** What can be open on a desktop: a program's window or the software installation. */
|
||||
@@ -91,7 +82,7 @@ function useDesktopView(doc: Y.Doc, id: string) {
|
||||
return view;
|
||||
}
|
||||
|
||||
export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, origin, onMove, onFront, onClose, watchers }: DesktopWindowProps) {
|
||||
export function DesktopSheet({ frame, doc, id, simulation, active, program: open, onProgram: setOpen, origin }: DesktopSheetProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useDesktopView(doc, id);
|
||||
@@ -102,7 +93,6 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
const settings = simulation.nodes.get(id);
|
||||
const [info, setInfo] = useState(false);
|
||||
const home = useCallback(() => setOpen(null), [setOpen]);
|
||||
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
|
||||
if (view === undefined) return null;
|
||||
const programs = desktopPrograms(view.programs, language);
|
||||
// A program removed while its window was open: the desktop shows.
|
||||
@@ -110,21 +100,6 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
const address = settings?.ipAddress ?? '';
|
||||
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
|
||||
|
||||
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if ((event.target as Element).closest('button') !== null) return;
|
||||
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
|
||||
};
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (drag === null || drag.pointer !== event.pointerId) return;
|
||||
if (!drag.moved) {
|
||||
drag.moved = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
|
||||
};
|
||||
const onPointerUp = () => setDrag(null);
|
||||
|
||||
const rows: readonly [string, string][] = [
|
||||
[t('network.ipAddress'), address],
|
||||
[t('network.subnetMask'), settings?.subnetMask ?? ''],
|
||||
@@ -134,15 +109,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="desktop-window" role="dialog" aria-label={title} data-desktop={id} style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
|
||||
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
|
||||
<img src={ICON_URLS[view.kind === 'notebook' ? 'laptop' : 'server']} alt="" />
|
||||
<h2>{title}</h2>
|
||||
<WindowWatchers watchers={watchers} />
|
||||
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<SimulationSheet frame={frame} title={title} icon={ICON_URLS[view.kind === 'notebook' ? 'laptop' : 'server']} attributes={{ 'data-desktop': id }}>
|
||||
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
|
||||
<ul className="desktop-icons">
|
||||
<li>
|
||||
@@ -230,6 +197,6 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
<img src={active ? networkOn : networkOff} alt="" />
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</SimulationSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,92 @@
|
||||
/* A computer's desktop after Java's GUIDesktopPanel: 640 × 460 pixels below
|
||||
* the title bar, a grey taskbar at the bottom (180, 180, 180; 44 pixels high
|
||||
* for a finger, Java's has 30), icons of 120 × 96 with white names on Java's
|
||||
* background. On a phone it fills the surface. Java's colours stay in both
|
||||
* themes: the screen is the simulated computer's, not the editor's. */
|
||||
.desktop-window {
|
||||
/* The simulation's sheet (SimulationSheet), after the dashboard's AppSheet:
|
||||
* it comes out of the toolbar's lower edge and covers the surface to the
|
||||
* bottom — a head (icon, title, who else has it open, see-through, ×) and the
|
||||
* one body. Not a window in the window: it neither moves nor stacks. A
|
||||
* computer's desktop inside keeps Java's colours in both themes (the screen is
|
||||
* the simulated computer's, not the editor's) and a grey taskbar (180, 180,
|
||||
* 180; 44 pixels high for a finger, Java's has 30). */
|
||||
.simulation-sheet {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
height: calc(460px + 14px + 36px);
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 25%, transparent);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 -4px 16px color-mix(in srgb, var(--shadow-color) 18%, transparent);
|
||||
animation: simulation-sheet-in 140ms ease-out;
|
||||
}
|
||||
|
||||
/* Below the mode buttons, which stay in reach. */
|
||||
@media (max-width: 639px) {
|
||||
.desktop-window {
|
||||
inset: 72px 0 0 0 !important;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
/* „Durchsichtig“: the network shows through the body, the sheet still takes
|
||||
* the pointer; the head stays as it is, so that its title and buttons read. */
|
||||
.simulation-sheet[data-see-through='true'] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.simulation-sheet[data-see-through='true'] .simulation-sheet__body {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@keyframes simulation-sheet-in {
|
||||
from {
|
||||
transform: translateY(-1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-titlebar {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.simulation-sheet {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.simulation-sheet__body {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-sheet__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 0 0 0 8px;
|
||||
background: var(--surface-raised);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 0 4px 0 12px;
|
||||
border-bottom: 1px solid var(--border-subtle, var(--border-default));
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.desktop-titlebar img {
|
||||
.simulation-sheet__head img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.desktop-titlebar h2 {
|
||||
.simulation-sheet__head h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.desktop-close {
|
||||
.simulation-sheet__see-through,
|
||||
.simulation-sheet__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
min-width: 44px;
|
||||
min-height: 36px;
|
||||
min-height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
@@ -71,6 +94,20 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.simulation-sheet__see-through[aria-pressed='true'] {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.simulation-sheet__body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: opacity 140ms ease-out;
|
||||
}
|
||||
|
||||
.desktop-screen {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -1252,13 +1289,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A switch's or home router's viewer (SatViewer, NatViewer): a table and its
|
||||
* reset, in a window as small as Java's dialog allows the NAT table's six columns. */
|
||||
.viewer-window {
|
||||
width: 600px;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
/* A switch's or home router's table (SatViewer, NatViewer) and its reset. */
|
||||
.viewer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1342,15 +1373,9 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The data exchange window (AggregatedExchangeDialog): Java's is three
|
||||
* quarters of the screen wide and four fifths high; the tabs, a bar for what
|
||||
/* 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. */
|
||||
.capture-window {
|
||||
width: 960px;
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.capture-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -316,6 +316,8 @@
|
||||
"menu.removeCable": "Verbindung entfernen",
|
||||
"menu.showCapture": "Datenaustausch anzeigen",
|
||||
"menu.showDesktop": "Desktop anzeigen",
|
||||
"menu.showNat": "NAT-Tabelle anzeigen",
|
||||
"menu.showSat": "SAT Tabelle anzeigen",
|
||||
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
|
||||
"mode.design": "Wechselt in den Entwurfsmodus",
|
||||
"mode.documentation": "Wechselt in den Dokumentationsmodus",
|
||||
@@ -407,6 +409,7 @@
|
||||
"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",
|
||||
|
||||
@@ -316,6 +316,8 @@
|
||||
"menu.removeCable": "Remove cable",
|
||||
"menu.showCapture": "Show data exchange",
|
||||
"menu.showDesktop": "Show desktop",
|
||||
"menu.showNat": "Show NAT table",
|
||||
"menu.showSat": "Show SAT table",
|
||||
"mode.currentSpeed": "Current Simulation Velocity",
|
||||
"mode.design": "Change to Design Mode",
|
||||
"mode.documentation": "Change to Documentation Mode",
|
||||
@@ -407,6 +409,7 @@
|
||||
"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",
|
||||
|
||||
+12
-41
@@ -1,6 +1,7 @@
|
||||
// The data exchange window while the simulation runs, after Java's
|
||||
// The data exchange while the simulation runs, after Java's
|
||||
// AggregatedExchangeDialog (measured with tools/java-oracle's
|
||||
// ExchangeWindowRun): a tab per card ("<label> - <address>", or the address
|
||||
// ExchangeWindowRun), as the simulation's sheet (SimulationSheet) instead of
|
||||
// Java's window: a tab per card ("<label> - <address>", or the address
|
||||
// where the label is it), each with a close button; the card's frames, one
|
||||
// row per frame in its highest layer's colour (Java's LayerColorVar); the
|
||||
// chosen frame's details below; „Tabellen löschen“ (every card's list),
|
||||
@@ -12,16 +13,15 @@
|
||||
// details take the row's index for it); an empty comment is exported empty
|
||||
// (Java writes "null").
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import type { TraceFrame } from '@mdg-filius/protocol';
|
||||
import { saveFile } from '../download.js';
|
||||
import { useLanguage, useTranslate } from '../i18n/i18n.js';
|
||||
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
|
||||
import type { Simulation } from './useSimulation.js';
|
||||
import { WindowWatchers } from '../remote/PresenceMarks.js';
|
||||
import type { PresenceUser } from '../remote/presence.js';
|
||||
import { SimulationSheet, type SheetFrame } from './SimulationSheet.js';
|
||||
|
||||
/** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the window is closed. */
|
||||
/** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the sheet is closed. */
|
||||
export interface CaptureTab {
|
||||
readonly port: string;
|
||||
readonly title: string;
|
||||
@@ -29,24 +29,18 @@ export interface CaptureTab {
|
||||
readonly autoscroll: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureWindowProps {
|
||||
/** Who else in the project has this window open (plan §7.4). */
|
||||
readonly watchers?: readonly PresenceUser[] | undefined;
|
||||
export interface CaptureSheetProps {
|
||||
/** The sheet's place, its see-through and close, who else has it open. */
|
||||
readonly frame: SheetFrame;
|
||||
readonly tabs: readonly CaptureTab[];
|
||||
readonly selected: string;
|
||||
readonly simulation: Simulation;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly onSelect: (port: string) => void;
|
||||
readonly onCloseTab: (port: string) => void;
|
||||
readonly onChoose: (port: string, number: number) => void;
|
||||
readonly onAutoscroll: (port: string, on: boolean) => void;
|
||||
/** "Tabellen löschen": every card's frames, and what was chosen in them. */
|
||||
readonly onClear: () => void;
|
||||
readonly onMove: (x: number, y: number) => void;
|
||||
readonly onFront: () => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
const FrameRow = memo(function FrameRow({
|
||||
@@ -80,7 +74,7 @@ const FrameRow = memo(function FrameRow({
|
||||
);
|
||||
});
|
||||
|
||||
export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose, watchers }: CaptureWindowProps) {
|
||||
export function CaptureSheet({ frame, tabs, selected, simulation, onSelect, onCloseTab, onChoose, onAutoscroll, onClear }: CaptureSheetProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const words = useMemo(() => captureWords(t, language), [t, language]);
|
||||
@@ -88,7 +82,6 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o
|
||||
useEffect(() => watch([`trace:${selected}`]), [watch, selected]);
|
||||
const frames = simulation.traces.get(selected) ?? [];
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
|
||||
const title = t('capture.title');
|
||||
const tab = tabs.find((each) => each.port === selected);
|
||||
const chosenFrame = frames.find((frame) => frame.number === tab?.chosen);
|
||||
@@ -99,34 +92,12 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o
|
||||
if (autoscroll && scroller.current !== null) scroller.current.scrollTop = scroller.current.scrollHeight;
|
||||
}, [autoscroll, frames.length, selected]);
|
||||
|
||||
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if ((event.target as Element).closest('button') !== null) return;
|
||||
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
|
||||
};
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (drag === null || drag.pointer !== event.pointerId) return;
|
||||
if (!drag.moved) {
|
||||
drag.moved = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
|
||||
};
|
||||
const onPointerUp = () => setDrag(null);
|
||||
|
||||
const choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]);
|
||||
|
||||
const download = () => saveFile(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }), `${tab?.title ?? title}.txt`);
|
||||
|
||||
return (
|
||||
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
|
||||
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
|
||||
<h2>{title}</h2>
|
||||
<WindowWatchers watchers={watchers} />
|
||||
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<SimulationSheet frame={frame} title={title} attributes={{ 'data-capture': '' }} className="capture-sheet">
|
||||
<div className="capture-tabs" role="tablist">
|
||||
{tabs.map((each) => (
|
||||
<span key={each.port} className="capture-tab">
|
||||
@@ -176,6 +147,6 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</SimulationSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// The sheet of the simulation. What Java opens as a window of its own — a
|
||||
// computer's desktop, a switch's or home router's table, the data exchange —
|
||||
// opens here as the dashboard's sheet does (its AppSheet), not as a window in
|
||||
// the window: over the surface, below the toolbar (the mode buttons stay in
|
||||
// reach), with a head — icon, title, who else has it open, ×. One at a time:
|
||||
// it covers the surface, so another is opened once it is closed.
|
||||
//
|
||||
// Beyond the dashboard's sheet: „Durchsichtig“ lets the network show through
|
||||
// the sheet (the cables light up while one types a ping), the sheet still
|
||||
// takes the pointer. The choice is the person's, kept in this browser.
|
||||
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslate } from "../i18n/i18n.js";
|
||||
import { WindowWatchers } from "../remote/PresenceMarks.js";
|
||||
import type { PresenceUser } from "../remote/presence.js";
|
||||
|
||||
/** What every sheet of the simulation gets from the editor: where it begins, whether it is see-through, how it closes, who else has it open. */
|
||||
export interface SheetFrame {
|
||||
/** Its top edge in the editor's area: below the toolbar. */
|
||||
readonly top: number;
|
||||
readonly seeThrough: boolean;
|
||||
readonly onSeeThrough: (on: boolean) => void;
|
||||
readonly onClose: () => void;
|
||||
/** Who else in the project has it open (plan §7.4). */
|
||||
readonly watchers?: readonly PresenceUser[] | undefined;
|
||||
}
|
||||
|
||||
export interface SimulationSheetProps {
|
||||
readonly frame: SheetFrame;
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
/** Marks for what it shows (`data-desktop`, `data-viewer`, `data-capture`). */
|
||||
readonly attributes: Readonly<Record<`data-${string}`, string>>;
|
||||
readonly className?: string;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
export function SimulationSheet({
|
||||
frame,
|
||||
title,
|
||||
icon,
|
||||
attributes,
|
||||
className,
|
||||
children,
|
||||
}: SimulationSheetProps) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<section
|
||||
className={`simulation-sheet${className === undefined ? "" : ` ${className}`}`}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
data-see-through={frame.seeThrough ? "true" : undefined}
|
||||
style={{ top: frame.top }}
|
||||
{...attributes}
|
||||
>
|
||||
<header className="simulation-sheet__head">
|
||||
{icon === undefined ? null : <img src={icon} alt="" />}
|
||||
<h2 title={title}>{title}</h2>
|
||||
<WindowWatchers watchers={frame.watchers} />
|
||||
<button
|
||||
type="button"
|
||||
className="simulation-sheet__see-through"
|
||||
aria-pressed={frame.seeThrough}
|
||||
aria-label={t("sheet.seeThrough")}
|
||||
title={t("sheet.seeThrough")}
|
||||
onClick={() => frame.onSeeThrough(!frame.seeThrough)}
|
||||
>
|
||||
<SeeThroughIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="simulation-sheet__close"
|
||||
aria-label={t("sheet.close")}
|
||||
title={t("sheet.close")}
|
||||
onClick={frame.onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="simulation-sheet__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two overlapping squares, the front one half filled: something showing through. */
|
||||
function SeeThroughIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<rect
|
||||
x="2.5"
|
||||
y="2.5"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="7.5"
|
||||
y="7.5"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="1.5"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.35"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const SEE_THROUGH_KEY = "filius.sheetSeeThrough";
|
||||
|
||||
/** „Durchsichtig“, remembered in this browser; storage that is not there (a private window, blocked) only forgets it. */
|
||||
export function useSeeThrough(): [boolean, (on: boolean) => void] {
|
||||
const [on, setOn] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(SEE_THROUGH_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const change = useCallback((next: boolean) => {
|
||||
setOn(next);
|
||||
try {
|
||||
window.localStorage.setItem(SEE_THROUGH_KEY, String(next));
|
||||
} catch {
|
||||
// Not kept; it still holds for this page.
|
||||
}
|
||||
}, []);
|
||||
return [on, change];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// A switch's or a home router's viewer while the simulation runs, after
|
||||
// Java's SatViewer and NatViewer (measured with tools/java-oracle's
|
||||
// ViewersRun): titled "SAT Tabelle <name>" or "NAT-Tabelle <name>", the
|
||||
// table — MAC, port, last update; protocol, port mapping, WAN address, LAN
|
||||
// address, LAN port, last update — and the table reset. Java opens it as a
|
||||
// window with a click on the device; here it is the simulation's sheet
|
||||
// (SimulationSheet), opened from the device's menu. The tables are the room's.
|
||||
//
|
||||
// Better than Java: the reset is a button (Java's is in a menu of the right
|
||||
// mouse button), and a switch's addresses come in their order (Java's in its
|
||||
// hash map's).
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { NatView, SatView } from '@mdg-filius/protocol';
|
||||
import { ICON_URLS } from '../canvas/icons.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { timeOfDay } from './timeOfDay.js';
|
||||
import type { Simulation } from './useSimulation.js';
|
||||
import { SimulationSheet, type SheetFrame } from './SimulationSheet.js';
|
||||
|
||||
export type ViewerKind = 'sat' | 'nat';
|
||||
|
||||
export interface ViewerSheetProps {
|
||||
/** The sheet's place, its see-through and close, who else has it open. */
|
||||
readonly frame: SheetFrame;
|
||||
readonly kind: ViewerKind;
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly simulation: Simulation;
|
||||
}
|
||||
|
||||
export function ViewerSheet({ frame, kind, id, label, simulation }: ViewerSheetProps) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${id}/${kind}` as const;
|
||||
const { watch, act, clock } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const view = simulation.views.get(topic) as SatView | NatView | null | undefined;
|
||||
const title = `${t(kind === 'sat' ? 'tables.satTitle' : 'tables.natTitle')} ${label}`;
|
||||
|
||||
const columns =
|
||||
kind === 'sat' ? ['tables.mac', 'tables.port', 'tables.lastUpdate'] : ['tables.protocol', 'tables.portMapping', 'tables.wanAddress', 'tables.lanAddress', 'tables.lanPort', 'tables.lastUpdate'];
|
||||
const rows: readonly (readonly string[])[] =
|
||||
view === null || view === undefined
|
||||
? []
|
||||
: kind === 'sat'
|
||||
? (view as SatView).rows.map((row) => [row.macAddress, `${t('tables.portNumber')} ${row.port}`, timeOfDay(clock, row.updatedAtMilliseconds)])
|
||||
: (view as NatView).rows.map((row) => [...row.cells, row.updatedAtMilliseconds === undefined ? '-' : timeOfDay(clock, row.updatedAtMilliseconds)]);
|
||||
|
||||
return (
|
||||
<SimulationSheet frame={frame} title={title} icon={ICON_URLS[kind === 'sat' ? 'switch' : 'router']} attributes={{ 'data-viewer': id }}>
|
||||
<div className="viewer-body">
|
||||
<table className="viewer-table" aria-label={title}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column}>{t(column)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((cells, row) => (
|
||||
<tr key={row}>
|
||||
{cells.map((cell, column) => (
|
||||
<td key={column}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="viewer-bar">
|
||||
<button type="button" onClick={() => act(id, kind, 'reset')}>
|
||||
{t(kind === 'sat' ? 'tables.satReset' : 'tables.natReset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SimulationSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// A switch's or a home router's viewer while the simulation runs, after
|
||||
// Java's SatViewer and NatViewer (measured with tools/java-oracle's
|
||||
// ViewersRun): a window titled "SAT Tabelle <name>" or "NAT-Tabelle <name>"
|
||||
// with the table — MAC, port, last update; protocol, port mapping, WAN
|
||||
// address, LAN address, LAN port, last update — and the table reset. A tap on
|
||||
// the device opens it, as Java's click does. The tables are the room's.
|
||||
//
|
||||
// Better than Java: the reset is a button (Java's is in a menu of the right
|
||||
// mouse button), and a switch's addresses come in their order (Java's in its
|
||||
// hash map's).
|
||||
|
||||
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { NatView, SatView } from '@mdg-filius/protocol';
|
||||
import { ICON_URLS } from '../canvas/icons.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { timeOfDay } from './timeOfDay.js';
|
||||
import type { Simulation } from './useSimulation.js';
|
||||
import { WindowWatchers } from '../remote/PresenceMarks.js';
|
||||
import type { PresenceUser } from '../remote/presence.js';
|
||||
|
||||
export type ViewerKind = 'sat' | 'nat';
|
||||
|
||||
export interface ViewerWindowProps {
|
||||
/** Who else in the project has this window open (plan §7.4). */
|
||||
readonly watchers?: readonly PresenceUser[] | undefined;
|
||||
readonly kind: ViewerKind;
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly simulation: Simulation;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly onMove: (x: number, y: number) => void;
|
||||
readonly onFront: () => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function ViewerWindow({ kind, id, label, simulation, x, y, z, onMove, onFront, onClose, watchers }: ViewerWindowProps) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${id}/${kind}` as const;
|
||||
const { watch, act, clock } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const view = simulation.views.get(topic) as SatView | NatView | null | undefined;
|
||||
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
|
||||
const title = `${t(kind === 'sat' ? 'tables.satTitle' : 'tables.natTitle')} ${label}`;
|
||||
|
||||
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if ((event.target as Element).closest('button') !== null) return;
|
||||
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
|
||||
};
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (drag === null || drag.pointer !== event.pointerId) return;
|
||||
if (!drag.moved) {
|
||||
drag.moved = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
|
||||
};
|
||||
const onPointerUp = () => setDrag(null);
|
||||
|
||||
const columns =
|
||||
kind === 'sat' ? ['tables.mac', 'tables.port', 'tables.lastUpdate'] : ['tables.protocol', 'tables.portMapping', 'tables.wanAddress', 'tables.lanAddress', 'tables.lanPort', 'tables.lastUpdate'];
|
||||
const rows: readonly (readonly string[])[] =
|
||||
view === null || view === undefined
|
||||
? []
|
||||
: kind === 'sat'
|
||||
? (view as SatView).rows.map((row) => [row.macAddress, `${t('tables.portNumber')} ${row.port}`, timeOfDay(clock, row.updatedAtMilliseconds)])
|
||||
: (view as NatView).rows.map((row) => [...row.cells, row.updatedAtMilliseconds === undefined ? '-' : timeOfDay(clock, row.updatedAtMilliseconds)]);
|
||||
|
||||
return (
|
||||
<section className="desktop-window viewer-window" role="dialog" aria-label={title} data-viewer={id} style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
|
||||
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
|
||||
<img src={ICON_URLS[kind === 'sat' ? 'switch' : 'router']} alt="" />
|
||||
<h2>{title}</h2>
|
||||
<WindowWatchers watchers={watchers} />
|
||||
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="viewer-body">
|
||||
<table className="viewer-table" aria-label={title}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column}>{t(column)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((cells, row) => (
|
||||
<tr key={row}>
|
||||
{cells.map((cell, column) => (
|
||||
<td key={column}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="viewer-bar">
|
||||
<button type="button" onClick={() => act(id, kind, 'reset')}>
|
||||
{t(kind === 'sat' ? 'tables.satReset' : 'tables.natReset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user