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>
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
// 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];
|
||
}
|