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:
2026-10-10 17:18:25 +00:00
co-authored by Claude Opus 5.5
parent 1593726447
commit 16d9cc2095
26 changed files with 585 additions and 497 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/remote/remoteBlobs.ts`, `src/remote/SharedProject.tsx` (Blob-Seite), `src/desktop/fileExplorer.ts` (Bildbetrachter), `src/i18n/*.json` | Plan §5.1 | neu (F5.5): die Blob-Seite des Editors auf F — was das Dokument nennt, wird geholt (mit dem Keks; was F noch nicht hat, ein paar Mal im Abstand neu gefragt), was hier entsteht (eine importierte Datei), sofort gehalten und zu F gebracht (einmal wiederholt, dann gesagt); der Bildbetrachter zeichnet neu, wenn die Bytes kommen |
| `test/remoteBlobs.test.ts` | — | neu (F5.5): Hochladen, Wiederholen, Scheitern (auch bei falschem Namen), Holen je Blob einmal, nachfragen bei 404, Netzverlust |
| `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b), erweitert (F5.5: ein großes Bild, von Mia importiert, bei Ben im Bildbetrachter, in F's Speicher unter seinem SHA-256): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt |
| `src/canvas/Canvas.tsx` (`hasMenu`), `src/Editor.tsx` (Aktionsmodus), `browser/capture.spec.ts`, `browser/shared.spec.ts` | Java `GUIEvents.kontextMenueAktionsmodus` | erweitert (F6.5, auf Wunsch des Nutzers): im Aktionsmodus wählt ein Tipp das Gerät auch aus, sein Dreipunkt-Knopf erscheint wie im Entwurf — nur wo das Menü des Aktionsmodus etwas anbietet (Desktop, Datenaustausch je Karte; ein Switch hat keins); Javas Aktion des Tipps bleibt (Desktop, Switch- und NAT-Tabelle), ein Tipp daneben lässt los. Das Geräteblatt bleibt zu (der Entwurf ist gesperrt), und die anderen sehen keinen Rahmen. Abweichend von Java: dort öffnet sich das Menü nur mit Rechtsklick |
| `src/canvas/Canvas.tsx` (`hasMenu`), `src/Editor.tsx` (Aktionsmodus), `src/simulation/SimulationSheet.tsx`, `src/desktop/DesktopSheet.tsx` (vorher `DesktopWindow.tsx`), `src/simulation/ViewerSheet.tsx` (vorher `ViewerWindow.tsx`), `src/simulation/CaptureSheet.tsx` (vorher `CaptureWindow.tsx`), `src/desktop/desktop.css`, `src/i18n/*.json` (`menu.showSat`, `menu.showNat`, `sheet.seeThrough`), `browser/*.spec.ts` (`showFromMenu`, `showDesktop`), `tools/live/acceptance.ts` | Java `GUIEvents.kontextMenueAktionsmodus`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIDesktopWindow`, `SatViewer`, `NatViewer`, `AggregatedExchangeDialog` | geändert (F6.5, auf Wunsch des Nutzers): im Aktionsmodus wählt ein Tipp das Gerät nur aus, sein Dreipunkt-Knopf erscheint wie im Entwurf (nur wo das Menü etwas anbietet); geöffnet wird über das Menü — Desktop, „SAT Tabelle anzeigen“ am Switch, „NAT-Tabelle anzeigen“ am Heimrouter, Datenaustausch je Karte. Desktop, Tabellen und Datenaustausch sind keine Fenster im Fenster mehr, sondern das Blatt des Aktionsmodus nach dem Muster des Dashboard-Blatts (`AppSheet`): unter der Werkzeugleiste bis zum unteren Rand, Kopf mit Symbol, Titel, Anwesenden, „Durchsichtig“ und ×; eins zur Zeit (es deckt die Fläche, ein anderes öffnet man nach dem Schließen); ein Blatt, dessen Gerät entfernt wurde, schließt; es übersteht die Rückkehr in den Entwurf, ein Desktop behält sein offenes Programm. „Durchsichtig“ (Deckkraft 0,55, im Browser gemerkt) lässt das Netz durchscheinen. Das Geräteblatt bleibt im Aktionsmodus zu, die anderen sehen keinen Rahmen um ein dort gewähltes Gerät. Abweichend von Java: Java öffnet Desktop und Tabellen mit einem Klick aufs Gerät, als verschiebbare Fenster, mehrere zugleich, und sein Menü nur mit Rechtsklick |
## packages/server
+22 -21
View File
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
import { drawing, onDevice, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -62,20 +62,18 @@ test("shows a card's frames as Java's data exchange window does, with their deta
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
await expect(window.locator('th')).toHaveText(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
await expect(window.locator('tbody tr')).toHaveCount(0);
// Closed, it keeps its tab (Java hides its dialog); it lies over the devices.
await window.locator('.desktop-close').click();
// Closed, it keeps its tab (Java hides its dialog).
await window.locator('.simulation-sheet__close').click();
await expect(window).toHaveCount(0);
// The browser fetches the server's page: a row per frame, its highest layer's, in its colour.
const at = await onDevice(page, await idOf(page, 'Browser'));
await page.mouse.click(at.x, at.y);
const desktop = page.locator('section[data-desktop]');
const desktop = await showDesktop(page, await idOf(page, 'Browser'));
await desktop.locator('[data-program="webBrowser"]').click();
const address = desktop.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(desktop.locator('.program-titlebar h3')).toHaveText('Seiten & Formulare', { timeout: 30_000 });
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
const rows = window.locator('tbody tr');
@@ -135,7 +133,7 @@ test("shows a card's frames as Java's data exchange window does, with their deta
expect(text.match(/^\+-/gm)?.length).toBe(await rows.count());
// A router's cards: one tab each, the one chosen in front.
await window.locator('.desktop-close').click();
await window.locator('.simulation-sheet__close').click();
await expect(await menuOf(page, 'Router')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.1)' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1']);
@@ -153,13 +151,12 @@ test("shows a card's frames as Java's data exchange window does, with their deta
// Without "Automatisches Scrollen", fetched again (its tab kept over the window closed): the table stays at its start, nothing chosen.
await window.getByLabel('Automatisches Scrollen').uncheck();
await window.locator('.desktop-close').click();
await page.mouse.click(at.x, at.y);
await showDesktop(page, await idOf(page, 'Browser'));
await desktop.locator('[data-program="webBrowser"]').click();
await address.fill('192.168.0.20');
await address.press('Enter');
await page.waitForTimeout(6_000);
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByLabel('Automatisches Scrollen')).not.toBeChecked();
await expect.poll(() => rows.count()).toBeGreaterThan(15);
@@ -168,7 +165,7 @@ test("shows a card's frames as Java's data exchange window does, with their deta
expect(await scrolled()).toMatchObject({ overflows: true, top: 0 });
// A third tab; a tab closed beside the chosen one leaves it chosen.
await window.locator('.desktop-close').click();
await window.locator('.simulation-sheet__close').click();
await (await menuOf(page, 'Server')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1', 'Server - 192.168.0.20']);
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
@@ -190,12 +187,12 @@ test("shows a card's frames as Java's data exchange window does, with their deta
await expect(window).toHaveCount(0);
});
test('chooses a device with a tap in the simulation, its three-dot button as in the design — only where it has a menu', async ({ page }) => {
test('chooses a device with a tap in the simulation, its three-dot button as in the design, and opens nothing by itself', async ({ page }) => {
await openPages(page);
await start(page);
const button = page.locator('.device-menu-button');
const sheet = page.locator('aside[aria-label="Einstellungen"]');
// A router: Java's tap does nothing else; the button opens the same menu as the long press.
// A router: the button opens the same menu as the long press.
const router = await onDevice(page, await idOf(page, 'Router'));
await page.mouse.click(router.x, router.y);
await expect(page.locator(`g.device[data-node="${await idOf(page, 'Router')}"]`)).toHaveAttribute('data-selected', 'true');
@@ -203,25 +200,29 @@ test('chooses a device with a tap in the simulation, its three-dot button as in
await expect(page.getByRole('menuitem')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (10.0.0.1)' }).click();
await expect(page.locator('section[data-capture]').getByRole('tab', { selected: true })).toHaveText('Router - 10.0.0.1');
await page.locator('section[data-capture] .desktop-close').click();
// A computer: the tap still opens its desktop (Java), and chooses it; its settings stay closed, the design is locked.
await page.locator('section[data-capture] .simulation-sheet__close').click();
// A computer: the tap opens no desktop (Java's click does), it only chooses; its settings stay closed, the design is locked.
const browser = await idOf(page, 'Browser');
const at = await onDevice(page, browser);
await page.mouse.click(at.x, at.y);
await expect(page.locator(`section[data-desktop="${browser}"]`)).toBeVisible();
await expect(button).toBeVisible();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await expect(sheet).toHaveCount(0);
await page.locator(`section[data-desktop="${browser}"] .desktop-close`).click();
await button.click();
await expect(page.getByRole('menuitem')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (192.168.0.10)']);
await page.keyboard.press('Escape');
// A switch has no menu in the simulation, so no button; a tap beside lets go.
// A switch: the tap opens no table either; its menu does.
const lanSwitch = (await drawing(page)).nodes.find((node) => node.kind === 'networkSwitch')!.id;
const hub = await onDevice(page, lanSwitch);
await page.mouse.click(hub.x, hub.y);
await expect(page.locator(`g.device[data-node="${lanSwitch}"]`)).toHaveAttribute('data-selected', 'true');
await expect(button).toHaveCount(0);
await page.locator(`section[data-viewer="${lanSwitch}"] .desktop-close`).click();
await expect(page.locator('section[data-viewer]')).toHaveCount(0);
await button.click();
await expect(page.getByRole('menuitem')).toHaveText(['SAT Tabelle anzeigen']);
await page.getByRole('menuitem', { name: 'SAT Tabelle anzeigen' }).click();
await expect(page.locator(`section[data-viewer="${lanSwitch}"]`)).toBeVisible();
await page.locator('.simulation-sheet__close').click();
// A tap beside lets go.
await page.mouse.click(at.x, at.y);
await expect(button).toBeVisible();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
+4 -6
View File
@@ -5,7 +5,7 @@
// server stopped; and another computer's command line resolving the new name.
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
import { drawing, onDevice, openEditor, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -19,9 +19,7 @@ test.afterAll(async () => {
async function desktopOf(page: Page, name: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
return page.locator(`section[data-desktop="${id}"]`);
return showDesktop(page, id);
}
test('keeps the records as Java’s DNS window does, and the server answers with them', async ({ page }) => {
@@ -65,7 +63,7 @@ test('keeps the records as Java’s DNS window does, and the server answers with
await expect(cell).toHaveValue('10.0.0.2');
// A computer whose DNS server it is resolves the new name (the desktop closed, which covered it).
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
const web = await desktopOf(page, 'Web-Server');
await web.locator('[data-program="installation"]').click();
const installation = web.locator('[data-program-window="installation"]');
@@ -77,7 +75,7 @@ test('keeps the records as Java’s DNS window does, and the server answers with
await input.fill('host neu.schule.de');
await input.press('Enter');
await expect(web.locator('.terminal-text')).toContainText('10.0.0.2', { timeout: 30_000 });
await web.locator('.desktop-close').click();
await web.locator('.simulation-sheet__close').click();
await desktopOf(page, 'DNS-Server');
await desktop.locator('[data-program="dnsServer"]').click();
+3 -5
View File
@@ -7,7 +7,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
import { drawing, onDevice, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -33,9 +33,7 @@ async function openPages(page: Page): Promise<void> {
/** A computer's desktop opened with a tap, a program installed on it, and its window opened. */
async function install(page: Page, name: string, program: string, label: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
const desktop = await showDesktop(page, id);
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: label }).click();
@@ -63,7 +61,7 @@ test('echoes a message from the generic client, as Java’s windows do', async (
await expect(port).toHaveAttribute('readonly', '');
const serverLog = echo.window.getByLabel('Ausgabe');
await expect(serverLog).toHaveText('Annahme von Verbindungsanfragen gestartet\n');
await echo.desktop.locator('.desktop-close').click();
await echo.desktop.locator('.simulation-sheet__close').click();
const client = await install(page, 'Browser', 'simpleClient', 'Einfacher Client');
const send = client.window.getByRole('button', { name: 'Senden' });
+23
View File
@@ -68,6 +68,29 @@ export async function onDevice(page: Page, id: string, across = 0.5, down = 0.4)
return { x: box.x + box.width * across, y: box.y + box.height * down };
}
/**
* In the simulation: a tap chooses the device, its three-dot button opens its
* menu, the item opens its sheet — a desktop, a table, the data exchange. A
* sheet already open covers the surface; it is closed first. With `touch`,
* by finger.
*/
export async function showFromMenu(page: Page, id: string, item: string | RegExp, touch = false): Promise<void> {
const open = page.locator('.simulation-sheet__close');
if ((await open.count()) > 0) await (touch ? open.tap() : open.click());
const at = await onDevice(page, id);
await (touch ? page.touchscreen.tap(at.x, at.y) : page.mouse.click(at.x, at.y));
const button = page.locator('.device-menu-button');
await (touch ? button.tap() : button.click());
const entry = page.getByRole('menuitem', { name: item, exact: true });
await (touch ? entry.tap() : entry.click());
}
/** A computer's desktop as the simulation's sheet (showFromMenu), in either language; its section. */
export async function showDesktop(page: Page, id: string, touch = false) {
await showFromMenu(page, id, /^(Desktop anzeigen|Show desktop)$/, touch);
return page.locator(`section[data-desktop="${id}"]`);
}
/** The surface's scale. */
export async function scaleOf(page: Page): Promise<number> {
return Number(await page.locator('[data-zoom]').getAttribute('data-zoom'));
+2 -4
View File
@@ -9,7 +9,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
import { drawing, onDevice, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -38,9 +38,7 @@ test('makes, copies, pastes, renames, deletes and imports as Java’s explorer d
test.setTimeout(120_000);
await openPages(page);
const id = (await drawing(page)).nodes.find((node) => node.name === 'Server')!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
const desktop = await showDesktop(page, id);
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
const available = installation.locator('[data-list="available"] [role="option"]');
+6 -8
View File
@@ -6,7 +6,7 @@
// changed), the rule chosen and removed.
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
import { drawing, onDevice, openEditor, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -20,9 +20,7 @@ test.afterAll(async () => {
async function desktopOf(page: Page, name: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
return page.locator(`section[data-desktop="${id}"]`);
return showDesktop(page, id);
}
async function install(page: Page, desktop: ReturnType<Page['locator']>, program: string) {
@@ -65,7 +63,7 @@ test('keeps a computer’s firewall as Java’s window does, and the firewall le
await expect(port).toHaveValue('');
// A notebook's browser is let through by the rule; the log says so.
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
const notebook = await desktopOf(page, 'Rechner 1');
await notebook.locator('[data-program="webBrowser"]').click();
const browser = notebook.locator('[data-program-window="webBrowser"]');
@@ -75,12 +73,12 @@ test('keeps a computer’s firewall as Java’s window does, and the firewall le
await expect(browser.frameLocator('iframe[title="Seite"]').locator('body')).not.toBeEmpty({ timeout: 30_000 });
// ICMP no longer allowed: the notebook's ping is dropped.
await notebook.locator('.desktop-close').click();
await notebook.locator('.simulation-sheet__close').click();
await desktopOf(page, 'Web-Server');
await desktop.locator('[data-program="firewall"]').click();
await icmp.click();
await expect(icmp).not.toBeChecked();
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
await desktopOf(page, 'Rechner 1');
await install(page, notebook, 'Befehlszeile');
await notebook.locator('[data-program="commandPrompt"]').click();
@@ -88,7 +86,7 @@ test('keeps a computer’s firewall as Java’s window does, and the firewall le
await input.fill('ping 141.99.50.231');
await input.press('Enter');
await expect(notebook.locator('.terminal-text')).toContainText('0 Paket(e) empfangen', { timeout: 60_000 });
await notebook.locator('.desktop-close').click();
await notebook.locator('.simulation-sheet__close').click();
await desktopOf(page, 'Web-Server');
await desktop.locator('[data-program="firewall"]').click();
await window.getByRole('tab', { name: 'Log' }).click();
+3 -5
View File
@@ -6,7 +6,7 @@
// network left.
import { expect, test, type Locator, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
import { drawing, onDevice, openEditor, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -24,9 +24,7 @@ async function idOf(page: Page, name: string) {
async function desktopOf(page: Page, name: string) {
const id = await idOf(page, name);
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
return page.locator(`section[data-desktop="${id}"]`);
return showDesktop(page, id);
}
async function install(desktop: Locator, program: string) {
@@ -51,7 +49,7 @@ test('joins, searches and downloads as Java’s Gnutella window does', async ({
(id) => (window as unknown as { filiusDev: { writeFile: (id: string, path: string, text: string) => void } }).filiusDev.writeFile(id, '/peer2peer/lied.txt', 'La la la'),
otherId,
);
await other.locator('.desktop-close').click();
await other.locator('.simulation-sheet__close').click();
const desktop = await desktopOf(page, 'Rechner 1');
await install(desktop, 'Gnutella');
+2 -4
View File
@@ -5,7 +5,7 @@
// its question before it removes one, its domain only while it is off.
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
import { drawing, onDevice, openEditor, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -25,9 +25,7 @@ async function start(page: Page): Promise<void> {
/** Opens a computer's desktop with a tap and a program's window on it. */
async function openProgram(page: Page, name: string, program: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
const desktop = await showDesktop(page, id);
await desktop.locator(`[data-program="${program}"]`).click();
return { desktop, window: desktop.locator(`[data-program-window="${program}"]`) };
}
+3 -6
View File
@@ -15,7 +15,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, test, type Browser, type Page } from '@playwright/test';
import { BLOB_THRESHOLD_BYTES } from '@mdg-filius/doc';
import { drag, onDevice, serveEditor } from './editorPage.js';
import { drag, onDevice, serveEditor, showDesktop } from './editorPage.js';
import { colorOf } from '../src/remote/presence.js';
import { startServerF, type ServerF } from './serverF.js';
@@ -48,8 +48,7 @@ const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="
const note = (page: Page, kind: string) => page.locator(`[data-note="${kind}"]`);
async function commandLine(page: Page, id: string) {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
await showDesktop(page, id);
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') };
@@ -206,9 +205,7 @@ test('takes a large picture one person imports to F as a blob, and shows it to a
await mia.locator('button[name="simulationMode"]').click();
for (const page of [mia, ben]) await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation');
const open = async (page: Page) => {
const at = await onDevice(page, notebook);
await page.mouse.click(at.x, at.y);
return desktop(page, notebook);
return showDesktop(page, notebook);
};
const miaDesktop = await open(mia);
await miaDesktop.locator('[data-program="installation"]').click();
+82 -54
View File
@@ -1,10 +1,11 @@
// The simulation in Chromium (F4.6a): Java's mode buttons, the design locked
// while it runs, the speed, the cables that carry frames, a computer's
// desktop opened with a tap — Java's title, its programs in Java's order, its
// network as it runs — and desktops kept over a return to the design.
// desktop opened from its menu — Java's title, its programs in Java's order,
// its network as it runs — as the simulation's sheet, one at a time, below
// the toolbar, see-through on request, kept over a return to the design.
import { expect, test, type Page } from '@playwright/test';
import { desktopOf, drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
import { expect, test, type Locator, type Page } from '@playwright/test';
import { desktopOf, drawing, onDevice, openEditor, serveEditor, showDesktop, showFromMenu } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -29,6 +30,9 @@ const simulationButton = (page: Page) => page.getByRole('button', { name: 'Wechs
const designButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' });
const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`);
/** Once the sheet has come out of the toolbar (its 140 ms), where it rests. */
const settled = (sheet: Locator) => sheet.evaluate((element) => Promise.all(element.getAnimations().map((animation) => animation.finished)));
/** Starts the simulation and waits until the room says it runs (it answers from its worker). */
async function start(page: Page): Promise<void> {
await simulationButton(page).click();
@@ -86,13 +90,16 @@ test("lights a cable while it carries a frame, in Java's green", async ({ page }
await expect(page.locator('path[data-link][data-active]')).toHaveCount(0);
});
test("opens a computer's desktop with a tap: Java's title, its programs in Java's order, its network as it runs", async ({ page }) => {
test("opens a computer's desktop from its menu: Java's title, its programs in Java's order, its network as it runs", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'DNS-Server 2.10');
expect((await desktopOf(page, id))!.programs).toHaveLength(3);
await start(page);
// A tap only chooses the computer (Java's click opens its desktop): its three-dot button is there, no desktop.
await tap(page, id);
const window = desktop(page, id);
await expect(page.locator('.device-menu-button')).toBeVisible();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
const window = await showDesktop(page, id);
await expect(window.getByRole('heading')).toHaveText('DNS-Server 2.10 - 141.99.2.10');
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Datei-Explorer', 'DNS-Server', 'Webserver']);
await expect(window.getByRole('button', { name: 'Startbildschirm' })).toBeVisible();
@@ -106,11 +113,13 @@ test("opens a computer's desktop with a tap: Java's title, its programs in Java'
await window.locator('.desktop-info-layer').click({ position: { x: 20, y: 20 } });
await expect(window.locator('.desktop-network')).toHaveCount(0);
// A switch has no desktop; its long-press menu has nothing either.
await tap(page, await deviceNamed(page, 'Switch 1'));
await expect(page.locator('section[data-desktop]')).toHaveCount(1);
// A switch has no desktop: its menu offers its table, nothing more (it has no address for a data exchange).
await window.getByRole('button', { name: 'Schließen' }).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await tap(page, await deviceNamed(page, 'Switch 1'));
await page.locator('.device-menu-button').click();
await expect(page.getByRole('menuitem')).toHaveText(['SAT Tabelle anzeigen']);
await page.keyboard.press('Escape');
// Java's menu: "show desktop".
const at = await onDevice(page, await deviceNamed(page, 'Notebook 1.10'));
@@ -123,36 +132,50 @@ test("opens a computer's desktop with a tap: Java's title, its programs in Java'
await expect(page.locator('section[data-desktop] .desktop-icon span')).toHaveText(['Software-Installation', 'Webbrowser']);
});
test('moves a desktop by its title bar, brings the one touched to the front, and keeps them over a return to the design', async ({ page }) => {
test('opens one sheet at a time below the toolbar, see-through on request, kept with its program over a return to the design', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const [first, second] = [await deviceNamed(page, 'Notebook 1.10'), await deviceNamed(page, 'Notebook 1.11')];
await start(page);
await tap(page, first);
// Moved by its title bar into the half of the page the second notebook is not in.
const at = await onDevice(page, second);
const width = page.viewportSize()!.width;
const bar = (await desktop(page, first).locator('.desktop-titlebar h2').boundingBox())!;
const before = (await desktop(page, first).boundingBox())!;
const step = { x: (at.x < width / 2 ? width - 650 : 0) - before.x, y: 30 };
await page.mouse.move(bar.x + 10, bar.y + 10);
await page.mouse.down();
await page.mouse.move(bar.x + 10 + step.x, bar.y + 10 + step.y, { steps: 8 });
await page.mouse.up();
const after = (await desktop(page, first).boundingBox())!;
expect([Math.round(after.x - before.x), Math.round(after.y - before.y)]).toEqual([Math.round(step.x), step.y]);
await tap(page, second);
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
const z = async (id: string) => Number(await desktop(page, id).evaluate((element) => getComputedStyle(element).zIndex));
expect(await z(second)).toBeGreaterThan(await z(first));
await desktop(page, first).locator('.desktop-titlebar h2').click();
expect(await z(first)).toBeGreaterThan(await z(second));
const sheet = await showDesktop(page, first);
await settled(sheet);
// The dashboard's sheet: from below the toolbar to the bottom, the area's width — no window in the window.
const area = (await page.locator('.editor-area').boundingBox())!;
const toolbar = (await page.locator('.editor-toolbar').boundingBox())!;
const box = (await sheet.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(toolbar.y + toolbar.height);
expect(box.x).toBeCloseTo(area.x, 0);
expect(box.width).toBeCloseTo(area.width, 0);
expect(box.y + box.height).toBeCloseTo(area.y + area.height, 0);
// Its program stays open over a return to the design.
await sheet.locator('[data-program="installation"]').click();
await designButton(page).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await simulationButton(page).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
expect(Math.round((await desktop(page, first).boundingBox())!.x)).toBe(Math.round(after.x));
await expect(sheet.locator('[data-program-window="installation"]')).toBeVisible();
// Closed (it covers the surface), another opens: one at a time.
await showDesktop(page, second);
await expect(page.locator('section[data-desktop]')).toHaveCount(1);
await expect(desktop(page, second)).toBeVisible();
// „Durchsichtig“: the network shows through; the choice holds for the next sheet and is kept in this browser.
const seeThrough = desktop(page, second).getByRole('button', { name: 'Durchsichtig' });
await expect(seeThrough).toHaveAttribute('aria-pressed', 'false');
const body = (id: string) => desktop(page, id).locator('.simulation-sheet__body');
await expect(body(second)).toHaveCSS('opacity', '1');
await seeThrough.click();
await expect(seeThrough).toHaveAttribute('aria-pressed', 'true');
await expect(body(second)).toHaveCSS('opacity', '0.55');
// Only the body fades: the sheet itself — and so its head, with title and buttons — stays as it is (opacity is not inherited, so the sheet is asked).
await expect(desktop(page, second)).toHaveCSS('opacity', '1');
expect(await page.evaluate(() => window.localStorage.getItem('filius.sheetSeeThrough'))).toBe('true');
// The next sheet is see-through too; the first desktop, closed in between, begins at its home screen.
await showDesktop(page, first);
await expect(desktop(page, first)).toHaveAttribute('data-see-through', 'true');
await expect(desktop(page, first).locator('[data-program-window]')).toHaveCount(0);
await desktop(page, first).getByRole('button', { name: 'Durchsichtig' }).click();
await expect(body(first)).toHaveCSS('opacity', '1');
});
test('takes nothing back and keeps no design tool while the simulation runs', async ({ page }) => {
@@ -185,8 +208,7 @@ test("titles a desktop with the address alone where the address is the computer'
await page.getByRole('complementary', { name: 'Einstellungen' }).getByRole('button', { name: 'Schließen' }).click();
// The notebooks ask for their addresses by DHCP as the simulation starts; the switch passes their broadcasts on to Rechner 1.
await start(page);
await tap(page, id);
const window = desktop(page, id);
const window = await showDesktop(page, id);
await expect(window.getByRole('heading')).toHaveText('192.168.0.11');
await expect(window.locator('.desktop-network-icon[data-active="true"]')).toBeAttached({ timeout: 10_000 });
await window.getByRole('button', { name: 'Netzwerk' }).click();
@@ -199,7 +221,7 @@ test("closes a desktop whose computer is removed while the design is shown", asy
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
await start(page);
await tap(page, id);
await showDesktop(page, id);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await tap(page, id);
@@ -207,27 +229,36 @@ test("closes a desktop whose computer is removed while the design is shown", asy
await simulationButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
// The next desktop takes the first place: the gone one is not counted.
// The next desktop opens as ever.
const next = await deviceNamed(page, 'Notebook 1.11');
await tap(page, next);
const area = (await page.locator('.editor-area').boundingBox())!;
const box = (await desktop(page, next).boundingBox())!;
expect([Math.round(box.x - area.x), Math.round(box.y - area.y)]).toEqual([24, 76]);
await showDesktop(page, next);
await expect(page.locator('section[data-desktop]')).toHaveCount(1);
// So does a switch's table whose switch is removed (a table would otherwise stand there empty, under no name).
const hub = await deviceNamed(page, 'Switch 1');
await showFromMenu(page, hub, 'SAT Tabelle anzeigen');
await expect(page.locator(`section[data-viewer="${hub}"]`)).toBeVisible();
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await tap(page, hub);
await page.keyboard.press('Delete');
await expect.poll(async () => (await drawing(page)).nodes.some((node) => node.id === hub)).toBe(false);
await simulationButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(page.locator('section[data-viewer]')).toHaveCount(0);
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('fills the surface with a desktop opened by a tap, below the mode buttons', async ({ page }) => {
test('fills the surface with a desktop opened from its menu, below the mode buttons', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
await simulationButton(page).tap();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
const window = desktop(page, id);
await expect(window).toBeVisible();
const window = await showDesktop(page, id, true);
await settled(window);
const box = (await window.boundingBox())!;
const area = (await page.locator('.editor-area').boundingBox())!;
const modes = (await simulationButton(page).boundingBox())!;
@@ -244,8 +275,7 @@ test.describe('on a phone', () => {
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
await showDesktop(page, id, true);
await desktop(page, id).locator('[data-program="commandPrompt"]').tap();
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
for (const name of ['ipconfig', 'Vorheriger Befehl', 'Befehl abbrechen (Strg+C)']) {
@@ -264,8 +294,7 @@ test.describe('on a phone', () => {
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
await showDesktop(page, id, true);
await desktop(page, id).locator('[data-program="installation"]').tap();
const panel = desktop(page, id).locator('[data-program-window="installation"]');
const first = (await panel.locator('[data-list="available"] [role="option"]').first().boundingBox())!;
@@ -281,7 +310,7 @@ test.describe('on a phone', () => {
/** Opens a computer's command line on its desktop. */
async function commandLine(page: Page, name: string) {
const id = await deviceNamed(page, name);
await tap(page, id);
await showDesktop(page, id);
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
await expect(window.getByRole('heading')).toHaveText('Befehlszeile');
@@ -398,8 +427,7 @@ test("installs and removes programs as Java's installation does, and a program i
const id = await deviceNamed(page, 'Rechner 3.10');
expect((await desktopOf(page, id))!.programs).toEqual([]);
await start(page);
await tap(page, id);
const window = desktop(page, id);
const window = await showDesktop(page, id);
await window.locator('[data-program="installation"]').click();
const panel = window.locator('[data-program-window="installation"]');
await expect(panel.getByRole('heading')).toHaveText('Software-Installation');
@@ -464,7 +492,7 @@ test('speaks English when asked', async ({ page }) => {
await page.getByRole('button', { name: 'Change to Simulation Mode' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
const id = await deviceNamed(page, 'DNS-Server 2.10');
await tap(page, id);
await showDesktop(page, id);
await expect(desktop(page, id).locator('.desktop-icon span')).toHaveText(['Software Installation', 'DNS server', 'File explorer', 'Webserver']);
await expect(desktop(page, id).getByRole('button', { name: 'Home Screen' })).toBeVisible();
});
+4 -6
View File
@@ -9,7 +9,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
import { drawing, onDevice, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -40,9 +40,7 @@ async function openPages(page: Page): Promise<void> {
/** A computer's desktop, opened with a tap. */
async function desktopOf(page: Page, name: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
return page.locator(`section[data-desktop="${id}"]`);
return showDesktop(page, id);
}
test('writes a page into the web server, which serves it while the simulation runs, and changes it there', async ({ page }) => {
@@ -88,7 +86,7 @@ test('writes a page into the web server, which serves it while the simulation ru
// The other computer's browser fetches it from the running web server (the desktops closed in between, which covered the computers).
const fetchPage = async (expected: string) => {
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
const browserDesktop = await desktopOf(page, 'Browser');
await browserDesktop.locator('[data-program="webBrowser"]').click();
const browser = browserDesktop.locator('[data-program-window="webBrowser"]');
@@ -96,7 +94,7 @@ test('writes a page into the web server, which serves it while the simulation ru
await address.fill('192.168.0.20/neu.html');
await address.press('Enter');
await expect(browser.frameLocator('iframe[title="Seite"]').locator('body')).toHaveText(expected, { timeout: 30_000 });
await browserDesktop.locator('.desktop-close').click();
await browserDesktop.locator('.simulation-sheet__close').click();
await desktopOf(page, 'Server');
await desktop.locator('[data-program="textEditor"]').click();
};
+3
View File
@@ -104,7 +104,10 @@ test('builds FILIUS\' „zwei Rechnernetze“ by finger and pings across (the ac
// The simulation; the command line installed on the first computer; a ping across the router.
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).tap();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
// A tap chooses the computer; its three-dot button opens its menu, „Desktop anzeigen“ its desktop as the sheet.
await finger.tap(await onDevice(page, pcA));
await page.getByRole('button', { name: 'Aktionen' }).tap();
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).tap();
const desktop = page.locator(`section[data-desktop="${pcA}"]`);
await desktop.locator('[data-program="installation"]').tap();
const installation = desktop.locator('[data-program-window="installation"]');
+10 -15
View File
@@ -6,7 +6,7 @@
// list shows the dropped frames in Java's words.
import { expect, test, type Locator, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
import { drawing, openEditor, serveEditor, showDesktop, showFromMenu } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -18,11 +18,6 @@ test.afterAll(async () => {
await server.close();
});
async function tap(page: Page, id: string) {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
}
async function install(desktop: Locator, program: string) {
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
@@ -41,17 +36,17 @@ test('shows the tables of a home router and a switch, and drops while “Pakete
// The home router with the port forwarding: its entry, with Java's columns.
const gateway = idOf('Heimrouter Vincent');
await tap(page, gateway);
await showFromMenu(page, gateway, 'NAT-Tabelle anzeigen');
const nat = page.locator(`section[data-viewer="${gateway}"]`);
await expect(nat.locator('h2')).toHaveText('NAT-Tabelle Heimrouter Vincent');
await expect(nat.locator('th')).toHaveText(['Protokoll', 'Portfreigabe', 'WAN-Adresse', 'LAN-Adresse', 'LAN-Port', 'Letzte Aktualisierung']);
await expect(nat.locator('tbody tr')).toHaveCount(1);
await expect(nat.locator('tbody tr').first().locator('td')).toHaveText(['TCP', '80', '*', '192.168.0.10', '80', '-']);
await nat.locator('.desktop-close').click();
await nat.locator('.simulation-sheet__close').click();
// The web server behind it pings out.
const web = idOf('Webserver - 192.168.0.10');
await tap(page, web);
await showDesktop(page, web);
const desktop = page.locator(`section[data-desktop="${web}"]`);
await install(desktop, 'Befehlszeile');
await desktop.locator('[data-program="commandPrompt"]').click();
@@ -59,29 +54,29 @@ test('shows the tables of a home router and a switch, and drops while “Pakete
await input.fill('ping 141.99.4.10');
await input.press('Enter');
await expect(desktop.locator('.terminal-text')).toContainText('4 Paket(e) empfangen', { timeout: 30_000 });
await desktop.locator('.desktop-close').click();
await desktop.locator('.simulation-sheet__close').click();
await tap(page, gateway);
await showFromMenu(page, gateway, 'NAT-Tabelle anzeigen');
await expect(nat.locator('tbody tr')).toHaveCount(2);
await expect(nat.locator('tbody tr').nth(1).locator('td').nth(0)).toHaveText('ICMP');
await expect(nat.locator('tbody tr').nth(1).locator('td').nth(5)).toHaveText(/^\d\d:\d\d:\d\d$/);
await nat.getByRole('button', { name: 'Tabelle zurücksetzen' }).click();
await expect(nat.locator('tbody tr')).toHaveCount(1);
await nat.locator('.desktop-close').click();
await nat.locator('.simulation-sheet__close').click();
// The switch the web server hangs on: the addresses the ping taught it.
const link = (await drawing(page)).links.find((each) => each.sourceNode === web || each.targetNode === web)!;
const lanSwitch = link.sourceNode === web ? link.targetNode : link.sourceNode;
await tap(page, lanSwitch);
await showFromMenu(page, lanSwitch, 'SAT Tabelle anzeigen');
const sat = page.locator(`section[data-viewer="${lanSwitch}"]`);
await expect(sat.locator('th')).toHaveText(['MAC', 'Port', 'Letzte Aktualisierung']);
await expect(sat.locator('tbody tr').first().locator('td')).toHaveText([/^[0-9A-F:]{17}$/, /^Port \d+$/, /^\d\d:\d\d:\d\d$/]);
await sat.getByRole('button', { name: 'Tabelle löschen' }).click();
await expect(sat.locator('tbody tr')).toHaveCount(0);
await sat.locator('.desktop-close').click();
await sat.locator('.simulation-sheet__close').click();
// "Pakete verwerfen" held while a ping runs: answers lost, the frames listed.
await tap(page, web);
await showDesktop(page, web);
await desktop.locator('[data-program="commandPrompt"]').click();
await input.fill('ping 141.99.4.10');
await input.press('Enter');
+2 -4
View File
@@ -8,7 +8,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
import { drawing, onDevice, serveEditor, showDesktop } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -37,9 +37,7 @@ async function start(page: Page, name = 'Wechselt in den Aktionsmodus'): Promise
/** Opens a computer's desktop with a tap and a program's window on it. */
async function openProgram(page: Page, name: string, program: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
const desktop = await showDesktop(page, id);
await desktop.locator(`[data-program="${program}"]`).click();
const window = desktop.locator(`[data-program-window="${program}"]`);
return { desktop, window, title: window.locator('.program-titlebar h3') };
+91 -100
View File
@@ -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' ? (
(() => {
+5 -10
View File
@@ -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) {
@@ -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>
);
}
+76 -51
View File
@@ -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;
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -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>
);
}
+2 -4
View File
@@ -12,7 +12,7 @@
import { randomUUID } from 'node:crypto';
import { chromium, type Browser, type Page } from '@playwright/test';
import { nodeFiles, readProject } from '@mdg-filius/doc';
import { drag, onDevice } from '../../packages/editor/browser/editorPage.js';
import { drag, onDevice, showDesktop } from '../../packages/editor/browser/editorPage.js';
import { cleanUp, joinDoc, seededProject, signIn, signInAddress } from './lib.js';
const results: [string, boolean, string][] = [];
@@ -43,9 +43,7 @@ async function person(browser: Browser, project: string, name: string, user: str
}
async function commandLine(page: Page, id: string) {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
const desktop = await showDesktop(page, id);
await desktop.locator('[data-program="commandPrompt"]').click();
const window = desktop.locator('[data-program-window="commandPrompt"]');
return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') };