feat: F4.8b the documentation mode in the editor
Java's third mode button, the sidebar's text and structure field (dropped with its middle under the pointer), items over the network that are moved, resized by Java's rule, typed into on their Y.Text, coloured and set in size and style from Java's menu, deleted; Java's paint per mode, measured in F4.8a. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -235,6 +235,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/desktop/GnutellaPanel.tsx`, `src/desktop/DesktopWindow.tsx`, `src/assets/desktop/peertopeer_*.png`, `src/main.tsx` (`filiusDev.writeFile`) | Java `GUIApplicationPeerToPeerAnwendungWindow` (`peertopeeranwendung_msg1`–`31`), Javas Symbole der Reiter, gemessen mit `GnutellaWindowRun` | neu (F4.6g4): Gnutella — vier Reiter mit Javas Symbolen: Netzwerk (Adresse, anfangs Javas 192.168.0.1, rot, solange sie keine ist; „Netz beitreten“, Tabelle der Nachbarn, „Netz verlassen“), Suche (Suchbegriff, „Suchen“, „Stopp!“, Ergebnistabelle mit Wahl, „Herunterladen“, „Liste leeren“), Dateien, Einstellungen (Zahl der Teilnehmer, „Speichern“). Besser als Java: die Tabellen zeigen sofort, was das Programm hält (Javas Ergebnisse zeigen nach einer neuen Suche die alten, bis ein Treffer kommt; seine Dateien nur, was beim letzten Bescheid des Programms da war); jede ganze Zahl wird genommen; was nicht genommen wird, steht unter dem Feld statt in einem Dialog; „Herunterladen“ nur mit gewähltem Ergebnis. Die Dev-Seite schreibt mit `writeFile` eine Datei wie eine andere Person |
|
||||
| `src/simulation/ViewerWindow.tsx`, `src/simulation/DropControl.tsx`, `src/simulation/timeOfDay.ts`, `src/simulation/useSimulation.ts` (`clock`, `dropped`, `drop`), `src/Editor.tsx`, `src/sheet/sheet.css` (`.editor-toolbar`) | Java `SatViewer`, `NatViewer` (`guievents_msg8`–`10`, `27`–`34`, `36`, `sw_switchfirmware_msg1`), `SimulationControl` (`simulation_control_drop_packets`), `GUIKnotenItem` (Klick im Aktionsmodus), gemessen mit `ViewersRun` | neu (F4.6g5): ein Tippen auf einen Switch zeigt seine Tabelle, auf einen Heimrouter seine NAT-Tabelle, in einem verschiebbaren Fenster mit Javas Spalten und Uhrzeiten; „Pakete verwerfen“ wird gehalten (Finger, Maus, Leertaste/Enter), daneben die Zahl und aufklappbar die Liste der verworfenen Frames mit Uhrzeit und Javas Text. Besser als Java: „Tabelle löschen“/„zurücksetzen“ ist ein Knopf (Java: Menü der rechten Maustaste), die Liste klappt weg; die Werkzeugleiste bricht auf schmalen Bildschirmen um |
|
||||
| `src/simulation/CaptureWindow.tsx`, `src/simulation/captureCells.ts`, `src/simulation/useSimulation.ts` (`traces`, `clearCapture`), `src/Editor.tsx`, `src/desktop/desktop.css`, `src/theme/tokens.css` (`--capture-*`), Kataloge (`capture.*`, `menu.showCapture`) | Java `AggregatedExchangeDialog` (`lauscherdialog_msg1`), `AggregatedExchangePanel` (`buttontabcomponent_msg1`), `AggregatedMessageTable` (`nachrichtentabelle_msg7`–`9`), `LauscherTableCellRenderer`, `LayerColorVar`, `Lauscher` (`rp_lauscher_msg1`–`14`), `GUIEvents` (`guievents_msg4`), gemessen mit `ExchangeWindowRun` | neu (F4.7): im Menü eines Geräts je Karte „Datenaustausch anzeigen (Adresse)“; ein Fenster mit einem Reiter je Karte, die Zeilen in Javas Schichtenfarben (dunkle Varianten im dunklen Thema), die gewählte weiß auf schwarz, darunter ihre Details, „Tabellen löschen“, „Als Textdatei exportieren“ (nach dem Reiter benannt), „Automatisches Scrollen“; Wahl und Scrollen je Reiter, behalten, solange das Fenster zu ist; das Fenster geht mit dem Entwurf und kommt mit dem nächsten Start; der Reiter einer entfernten Karte verschwindet. Besser als Java: die drei sind Knöpfe und ein Kästchen statt eines Menüs der rechten Maustaste; ein Frame wird nach seiner Nummer gewählt (Javas Details nehmen den Zeilenindex dafür) |
|
||||
| `src/design/DocumentationSidebar.tsx`, `src/design/DocumentationText.tsx`, `src/design/DocumentationMenu.tsx`, `src/canvas/documentationPaint.ts`, `src/canvas/Canvas.tsx` (Dokumentationsmodus), `src/simulation/ModeBar.tsx` (dritter Knopf), `src/Editor.tsx`, `src/assets/modes/dokumodus*.png`, `src/assets/documentation/*.png`, Kataloge (`mode.documentation`, `docu.*`) | Java `GUIMainMenu` (`btDokumodus`, `guimainmemu_msg14`, Reihenfolge der Knöpfe), `GUIDocumentationSidebar` (`docusidebar_msg1`, `3`), `GUIContainer` (Ablegen aus der Seitenleiste, Ebenen), `JDocuElement` (Fokus, Ziehen, Menü `guievents_msg13`–`21`, Zeichnen), `GUIDocumentationPanel` (Klick daneben), gemessen mit `DocuRun` | neu (F4.8b): der Dokumentationsmodus — Javas dritter Knopf vorn, er stoppt eine Simulation; die Seitenleiste „Text“/„Gliederungsfläche“ (gezogen landet ein Element mittig unter dem Zeiger, mit Fokus); die Elemente liegen über dem Netz und nehmen den Zeiger: Tippen gibt den Fokus, eine Fläche wird an ihrer Mitte gezogen, ein Text in seinem Fokusband, am Rand ändert sich die Größe nach Javas Regel; der Text wird in ein Feld über ihm getippt (auf `Y.Text`, Escape beendet, Strg+Entf löscht); das Menü mit Schriftgröße, Schriftstil, Javas Farben und Löschen; Entf löscht; Javas Zeichnung je Modus (halbtransparent im Dokumentationsmodus, sonst deckend aufgehellt, Rand, grauer Textrand nur im Dokumentationsmodus, Fokus blau). Besser als Java: Tippen auf die Seitenleiste legt ein Element in die Mitte der Ansicht (Java legt nichts ab); das Menü zeigt Größen, Stile und Farben auf einmal statt in Untermenüs; die Ränder lassen sich bei kleinem Zoom mit dem Finger greifen (mindestens 10 Bildschirmpunkte) |
|
||||
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht |
|
||||
| `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) |
|
||||
| `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster |
|
||||
@@ -248,6 +249,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `browser/gnutella.spec.ts` | — | neu (F4.6g4): Gnutella in Chromium auf `email_komplett`, im Lauf auf zwei Notebooks installiert — vier Reiter, eine Adresse mit Javas Worten abgelehnt, ein Nachbar beigetreten, eine Datei des anderen gefunden und heruntergeladen (der Reiter Dateien zeigt sie), „Liste leeren“ hebt die Wahl auf, die Zahl der Teilnehmer gespeichert (12, die Java ablehnt; was keine Zahl ist, mit Javas Worten abgelehnt), das Netz verlassen |
|
||||
| `browser/viewers.spec.ts`, `test/timeOfDay.test.ts` | — | neu (F4.6g5): auf `nat_with_mapped_port` die NAT-Tabelle (Portweiterleitung, dann ein Ping, zurückgesetzt), die Switch-Tabelle (gelernt, gelöscht), „Pakete verwerfen“ gehalten, während ein Ping läuft (Antworten verloren, Liste in Javas Worten); auf einem Handy bleibt „Pakete verwerfen“ erreichbar; die Uhrzeit wie Javas `HH:mm:ss(.SSS)` |
|
||||
| `browser/capture.spec.ts`, `test/captureCells.test.ts` | — | neu (F4.7): auf `web-pages.fls` Javas Menü, eine Seite geladen (Zeile je Frame, Javas Farben, Details, Textdatei mit Javas Kopf), die Karten eines Routers, „Tabellen löschen“, „Automatisches Scrollen“ aus, ein Reiter geschlossen, das Fenster über Entwurf und Start, der Reiter eines entfernten Rechners weg; auf einem Handy; die Zellen in beiden Sprachen und Zahlenformaten |
|
||||
| `browser/documentation.spec.ts`, `test/documentationPaint.test.ts` | — | neu (F4.8b): auf `nat_with_mapped_port` Javas Knopfreihenfolge, eine Fläche gezogen (mittig, grau, Deckkraft 200), verschoben, auf 20 × 20 verkleinert, Farbe aus Javas zwölf; ein Text getippt, groß und kursiv; Escape, Javas Textrand; im Entwurf deckend aufgehellt, nicht wählbar; Entf; Start der Simulation beendet den Modus; Handy; die Zeichenregeln gegen Javas gemessene Pixel |
|
||||
| `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) |
|
||||
| `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben |
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// The documentation mode in Chromium (F4.8b), on FILIUS' example
|
||||
// nat_with_mapped_port, which has documentation: Java's third mode button;
|
||||
// the sidebar's rectangle dragged onto the surface (its middle under the
|
||||
// pointer), moved, resized to Java's smallest size, coloured from Java's
|
||||
// list; the sidebar's text tapped into the view, typed into, made large and
|
||||
// italic; Java's paint in the documentation mode and in the design; Delete;
|
||||
// a simulation started ends the mode; and a phone.
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { drag, drawing, openEditor, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await serveEditor();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const newDoc = async (page: Page, before: ReadonlySet<string>) => {
|
||||
await expect.poll(async () => (await drawing(page)).docs.filter((item) => !before.has(item.id)).length).toBe(1);
|
||||
return (await drawing(page)).docs.find((item) => !before.has(item.id))!;
|
||||
};
|
||||
const docOf = async (page: Page, id: string) => (await drawing(page)).docs.find((item) => item.id === id);
|
||||
const fill = (page: Page, id: string) => page.locator(`g[data-doc="${id}"] .doc-rectangle`).evaluate((rect) => getComputedStyle(rect).fill);
|
||||
|
||||
async function longPress(page: Page, at: { x: number; y: number }) {
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(700);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test('makes, moves, resizes, colours and types documentation as Java does, and paints it per mode', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||||
const canvas = page.locator('svg.filius-canvas');
|
||||
const existing = new Set((await drawing(page)).docs.map((item) => item.id));
|
||||
expect(existing.size).toBeGreaterThan(0);
|
||||
|
||||
// Java's order: documentation, design, simulation.
|
||||
expect(await page.locator('.editor-toolbar button[name$="Mode"]').evaluateAll((buttons) => buttons.map((each) => each.getAttribute('name')))).toEqual([
|
||||
'documentationMode',
|
||||
'designMode',
|
||||
'simulationMode',
|
||||
]);
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
|
||||
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
|
||||
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
|
||||
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche']);
|
||||
|
||||
// A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus.
|
||||
const surface = (await canvas.boundingBox())!;
|
||||
const drop = { x: surface.x + surface.width * 0.6, y: surface.y + surface.height * 0.3 };
|
||||
const button = (await sidebar.getByRole('button', { name: 'Gliederungsfläche' }).boundingBox())!;
|
||||
await drag(page, { x: button.x + button.width / 2, y: button.y + button.height / 2 }, drop);
|
||||
const rectangle = await newDoc(page, existing);
|
||||
expect([rectangle.kind, rectangle.width, rectangle.height]).toEqual(['rectangle', 100, 50]);
|
||||
const focusBox = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
|
||||
expect(Math.abs(focusBox.x + focusBox.width / 2 - drop.x)).toBeLessThan(2);
|
||||
expect(Math.abs(focusBox.y + focusBox.height / 2 - drop.y)).toBeLessThan(2);
|
||||
// Java's paint in the documentation mode: grey at an opacity of 200.
|
||||
expect(await fill(page, rectangle.id)).toBe('rgba(204, 204, 204, 0.784)');
|
||||
|
||||
// Moved by its middle, resized by its corner to Java's smallest (20 × 20).
|
||||
await drag(page, drop, { x: drop.x + 40, y: drop.y + 30 });
|
||||
await expect.poll(async () => (await docOf(page, rectangle.id))!.x - rectangle.x).toBeGreaterThan(20);
|
||||
const moved = (await docOf(page, rectangle.id))!;
|
||||
const corner = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
|
||||
await drag(page, { x: corner.x + corner.width - 2, y: corner.y + corner.height - 2 }, { x: corner.x - 50, y: corner.y - 50 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const item = (await docOf(page, rectangle.id))!;
|
||||
return [item.x, item.y, item.width, item.height];
|
||||
})
|
||||
.toEqual([moved.x, moved.y, 20, 20]);
|
||||
|
||||
// Its menu: only the colour (Java's twelve) and „Löschen“; a colour chosen.
|
||||
const small = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
|
||||
await longPress(page, { x: small.x + small.width / 2, y: small.y + small.height / 2 });
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu.getByRole('group')).toHaveCount(1);
|
||||
await expect(menu.getByRole('group', { name: 'Farbe' }).getByRole('menuitemradio')).toHaveCount(12);
|
||||
await expect(menu.getByRole('menuitemradio', { name: 'Farbe 2' })).toHaveAttribute('aria-checked', 'true');
|
||||
await menu.getByRole('menuitemradio', { name: 'Farbe 5' }).click();
|
||||
await expect.poll(async () => (await docOf(page, rectangle.id))!.color).toEqual({ red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 });
|
||||
|
||||
// A text tapped from the sidebar: in the middle of the view, typed into at once.
|
||||
const withRectangle = new Set((await drawing(page)).docs.map((item) => item.id));
|
||||
await sidebar.getByRole('button', { name: 'Text' }).click();
|
||||
const text = await newDoc(page, withRectangle);
|
||||
const field = page.getByRole('textbox', { name: 'Text' });
|
||||
await expect(field).toBeFocused();
|
||||
await page.keyboard.type('Netz A');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('zweite Zeile');
|
||||
await expect.poll(async () => (await docOf(page, text.id))!.text).toBe('Netz A\nzweite Zeile');
|
||||
// Its menu: size, style, Java's fifteen colours; „Groß“, then „Kursiv“.
|
||||
const textFocus = (await page.locator(`g[data-doc="${text.id}"] .doc-focus`).boundingBox())!;
|
||||
const band = { x: textFocus.x + textFocus.width / 2, y: textFocus.y + textFocus.height - 8 };
|
||||
await longPress(page, band);
|
||||
await expect(menu.getByRole('group')).toHaveText([/^Schriftgröße/, /^Schriftstil/, /^Farbe/]);
|
||||
await expect(menu.getByRole('group', { name: 'Farbe' }).getByRole('menuitemradio')).toHaveCount(15);
|
||||
await expect(menu.getByRole('group', { name: 'Schriftgröße' }).getByRole('menuitemradio', { checked: true })).toHaveAccessibleName('Normal');
|
||||
await menu.getByRole('menuitemradio', { name: 'Groß', exact: true }).click();
|
||||
await longPress(page, band);
|
||||
await menu.getByRole('group', { name: 'Schriftstil' }).getByRole('menuitemradio', { name: 'Kursiv' }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const item = (await docOf(page, text.id))!;
|
||||
return [item.size, item.italic, item.bold];
|
||||
})
|
||||
.toEqual([18, true, false]);
|
||||
await expect(field).toHaveCSS('font-style', 'italic');
|
||||
|
||||
// Escape ends the focus: the text drawn, with Java's grey border in the documentation mode.
|
||||
await field.press('Escape');
|
||||
await expect(field).toHaveCount(0);
|
||||
const words = page.locator(`g[data-doc="${text.id}"] .doc-text`);
|
||||
await expect(words).toHaveText('Netz A\nzweite Zeile');
|
||||
await expect(words).toHaveCSS('border-top-color', 'rgb(179, 179, 179)');
|
||||
await expect(words).toHaveCSS('font-size', '18px');
|
||||
await expect(words).toHaveCSS('font-style', 'italic');
|
||||
// A rectangle without the focus is moved at once (Java gives it the focus with the press).
|
||||
const before = (await docOf(page, rectangle.id))!;
|
||||
const unfocused = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`).boundingBox())!;
|
||||
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).not.toHaveAttribute('data-focused', 'true');
|
||||
await drag(page, { x: unfocused.x + unfocused.width / 2, y: unfocused.y + unfocused.height / 2 }, { x: unfocused.x + unfocused.width / 2 + 30, y: unfocused.y + unfocused.height / 2 });
|
||||
await expect.poll(async () => (await docOf(page, rectangle.id))!.x - before.x).toBeGreaterThan(10);
|
||||
|
||||
// The design: the rectangle opaque, a fifth of the way to white, below the devices; the text without its border; documentation is not chosen.
|
||||
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
|
||||
await expect(canvas).toHaveAttribute('data-mode', 'design');
|
||||
expect(await fill(page, rectangle.id)).toBe('rgb(255, 212, 212)');
|
||||
await expect(page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`)).toHaveCSS('stroke', 'rgb(204, 160, 160)');
|
||||
await expect(words).toHaveCSS('border-top-style', 'none');
|
||||
const wordsBox = (await words.boundingBox())!;
|
||||
await page.mouse.click(wordsBox.x + 4, wordsBox.y + 4);
|
||||
await expect(page.locator('[data-focused="true"]')).toHaveCount(0);
|
||||
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toBeVisible();
|
||||
|
||||
// Back in the documentation mode: Delete removes the focused rectangle.
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
const rectangleBox = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`).boundingBox())!;
|
||||
await page.mouse.click(rectangleBox.x + rectangleBox.width / 2, rectangleBox.y + rectangleBox.height / 2);
|
||||
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).toHaveAttribute('data-focused', 'true');
|
||||
await page.keyboard.press('Delete');
|
||||
await expect.poll(async () => (await docOf(page, rectangle.id)) === undefined).toBe(true);
|
||||
|
||||
// A simulation started ends the documentation mode, as Java's does.
|
||||
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
|
||||
await expect(canvas).toHaveAttribute('data-mode', 'simulation');
|
||||
await expect(sidebar).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
|
||||
await expect(page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('keeps the documentation mode within reach on a phone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 740 });
|
||||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Gliederungsfläche' })).toBeInViewport({ ratio: 1 });
|
||||
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
|
||||
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
|
||||
});
|
||||
@@ -2,7 +2,10 @@
|
||||
// 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. Desktops left
|
||||
// frame light up, and a tap on a computer opens its desktop. 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).
|
||||
@@ -13,17 +16,38 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { addDevice, captureCards, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
|
||||
import {
|
||||
addDevice,
|
||||
addDocumentation,
|
||||
captureCards,
|
||||
connect,
|
||||
createUndoManager,
|
||||
documentationText,
|
||||
moveDevice,
|
||||
removeDevice,
|
||||
removeDocumentation,
|
||||
removeLink,
|
||||
removeLinksOf,
|
||||
setDocumentationColor,
|
||||
setDocumentationFont,
|
||||
setDocumentationFrame,
|
||||
topologyView,
|
||||
type TopologyView,
|
||||
} from '@mdg-filius/doc';
|
||||
import { DOCUMENTATION_NEW_SIZE, type DocumentationItemKind } from '@mdg-filius/core';
|
||||
import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
|
||||
import type { Point } from './canvas/viewport.js';
|
||||
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
|
||||
import { DocumentationMenu } from './design/DocumentationMenu.js';
|
||||
import { DocumentationSidebar } from './design/DocumentationSidebar.js';
|
||||
import { DocumentationText } from './design/DocumentationText.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 { useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
||||
import { ModeBar } from './simulation/ModeBar.js';
|
||||
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
|
||||
import { DropControl } from './simulation/DropControl.js';
|
||||
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
|
||||
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
|
||||
@@ -114,6 +138,9 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
const view = useTopologyView(doc);
|
||||
const simulation = useSimulation(doc, createSimHost);
|
||||
const simulating = simulation.mode === 'simulation';
|
||||
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 origin = useMemo(() => ({ editor: crypto.randomUUID() }), []);
|
||||
const { manager, canUndo, canRedo } = useUndo(doc, origin);
|
||||
@@ -134,15 +161,14 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
return () => clearTimeout(timer);
|
||||
}, [message]);
|
||||
|
||||
// The simulation locks the design: what was chosen or begun is let go.
|
||||
// The simulation locks the design, the documentation mode takes the surface: what was chosen or begun is let go.
|
||||
useEffect(() => {
|
||||
if (!simulating) return;
|
||||
setSelection(null);
|
||||
setCableTool(false);
|
||||
setCableFrom(null);
|
||||
setMenu(null);
|
||||
setRouterAt(null);
|
||||
}, [simulating]);
|
||||
}, [editorMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (simulation.failed) setMessage(t('sim.failed'));
|
||||
@@ -190,6 +216,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
useEffect(() => {
|
||||
if (selection?.kind === 'device' && !view.nodes.some((node) => node.id === selection.id)) setSelection(null);
|
||||
if (selection?.kind === 'link' && !view.links.some((link) => link.id === selection.id)) setSelection(null);
|
||||
if (selection?.kind === 'doc' && !view.docs.some((item) => item.id === selection.id)) setSelection(null);
|
||||
if (cableFrom !== null && !view.nodes.some((node) => node.id === cableFrom.id)) setCableFrom(null);
|
||||
}, [view, selection, cableFrom]);
|
||||
|
||||
@@ -227,6 +254,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
const remove = useCallback(
|
||||
(target: Selection) => {
|
||||
if (target.kind === 'device') removeDevice(doc, target.id, origin);
|
||||
else if (target.kind === 'doc') removeDocumentation(doc, target.id, origin);
|
||||
else removeLink(doc, target.id, origin);
|
||||
manager?.stopCapturing();
|
||||
setSelection(null);
|
||||
@@ -243,7 +271,24 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
[openDesktop],
|
||||
);
|
||||
|
||||
/** 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. */
|
||||
const onDocumentationItem = (kind: DocumentationItemKind, at: { readonly clientX: number; readonly clientY: number } | null) => {
|
||||
const point = at === null ? (canvas.current?.surfaceCentre() ?? null) : (canvas.current?.surfacePointAt(at.clientX, at.clientY) ?? null);
|
||||
if (point === null) return;
|
||||
const id = crypto.randomUUID();
|
||||
addDocumentation(doc, { id, kind, x: point.x - DOCUMENTATION_NEW_SIZE.width / 2, y: point.y - DOCUMENTATION_NEW_SIZE.height / 2 }, origin);
|
||||
manager?.stopCapturing();
|
||||
setSelection({ kind: 'doc', id });
|
||||
};
|
||||
|
||||
const changeMode = (mode: EditorMode) => {
|
||||
setDocumenting(mode === 'documentation');
|
||||
if (mode === 'simulation') simulation.start(language);
|
||||
else if (simulating) simulation.stop();
|
||||
};
|
||||
|
||||
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.
|
||||
if (target.kind !== 'device') return [];
|
||||
@@ -301,7 +346,9 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
{simulating ? null : (
|
||||
{simulating ? null : documenting ? (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} />
|
||||
) : (
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
onCableTool={(on) => {
|
||||
@@ -325,7 +372,22 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
onCableStart={setCableFrom}
|
||||
onCable={onCable}
|
||||
highlightedLink={highlightedLink}
|
||||
mode={simulation.mode}
|
||||
mode={editorMode}
|
||||
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
|
||||
renderDocText={(item, frame) => {
|
||||
const text = documentationText(doc, item.id);
|
||||
return text === undefined ? null : (
|
||||
<DocumentationText
|
||||
key={item.id}
|
||||
item={item}
|
||||
frame={frame}
|
||||
text={text}
|
||||
origin={origin}
|
||||
onDone={() => setSelection(null)}
|
||||
onRemove={() => remove({ kind: 'doc', id: item.id })}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
activeLinks={simulation.activeLinks}
|
||||
onActivate={(id) => {
|
||||
const kind = view.nodes.find((node) => node.id === id)?.kind;
|
||||
@@ -335,6 +397,12 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
else if (kind === 'gateway') openDesktop(id, 'nat');
|
||||
}}
|
||||
onMenu={(target, at) => {
|
||||
if (editorMode === 'documentation') {
|
||||
if (target.kind !== 'doc') return;
|
||||
setSelection(target);
|
||||
setMenu({ target, at });
|
||||
return;
|
||||
}
|
||||
if (simulating) {
|
||||
if (menuItems(target).length > 0) setMenu({ target, at });
|
||||
return;
|
||||
@@ -344,7 +412,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
}}
|
||||
/>
|
||||
<div role="toolbar" className="editor-toolbar">
|
||||
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
|
||||
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} />
|
||||
{simulating ? <DropControl simulation={simulation} /> : (
|
||||
<>
|
||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
||||
@@ -421,7 +489,25 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
),
|
||||
)
|
||||
: null}
|
||||
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
|
||||
{menu === null ? null : menu.target.kind === 'doc' ? (
|
||||
(() => {
|
||||
const item = view.docs.find((each) => each.id === menu.target.id);
|
||||
const id = menu.target.id;
|
||||
return item === undefined ? null : (
|
||||
<DocumentationMenu
|
||||
item={item}
|
||||
at={menu.at}
|
||||
onSize={(size) => setDocumentationFont(doc, id, { size }, origin)}
|
||||
onStyle={(style) => setDocumentationFont(doc, id, { style }, origin)}
|
||||
onColor={(color) => setDocumentationColor(doc, id, color, origin)}
|
||||
onDelete={() => remove({ kind: 'doc', id })}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />
|
||||
)}
|
||||
{routerAt === null ? null : (
|
||||
<RouterDialog
|
||||
onChoose={(interfaces) => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -9,6 +9,11 @@
|
||||
// tool a drag from one device to another plugs a cable, and so do two
|
||||
// taps, one on each (Java's way).
|
||||
// - On a cable: a tap selects it, a long press opens its menu.
|
||||
// - In the documentation mode the documentation items lie over the network
|
||||
// and take the pointer (Java's documentation panel moves to the top): a
|
||||
// tap gives one the focus, a drag on a rectangle (on a text: on the band
|
||||
// around it while it has the focus) moves it, on its edge resizes it,
|
||||
// a long press opens its menu. The devices take nothing.
|
||||
//
|
||||
// The pointer is captured only once it has moved: captured at once, it would
|
||||
// take the click from whatever lies under it (pointer-capture trap; jsdom
|
||||
@@ -16,20 +21,21 @@
|
||||
// that the browser's own zoom and scroll stay out of a gesture on it (M7).
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { NodeKind } from '@mdg-filius/core';
|
||||
import { resizedFrame, type NodeKind } from '@mdg-filius/core';
|
||||
import type { DocumentationView, NodeView, TopologyView } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { ICON_URLS } from './icons.js';
|
||||
import { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
|
||||
import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
|
||||
import { ZoomBar } from './ZoomBar.js';
|
||||
import { EDGE_BAND, edgesAt, FOCUS_BAND, FOCUS_BORDER, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, textColor, type Frame } from './documentationPaint.js';
|
||||
|
||||
/** How far a pointer moves before a press becomes a drag. */
|
||||
export const DRAG_THRESHOLD = 4;
|
||||
/** How long a press lasts before it opens the menu. */
|
||||
export const LONG_PRESS_MILLISECONDS = 500;
|
||||
|
||||
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string };
|
||||
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string } | { readonly kind: 'doc'; readonly id: string };
|
||||
export type Tool = 'select' | 'cable';
|
||||
/** Where on a home router's button a cable meets it: Java takes the left half for WAN, the right for LAN. */
|
||||
export type Side = 'left' | 'right';
|
||||
@@ -39,6 +45,19 @@ export interface CanvasHandle {
|
||||
placeAt(kind: NodeKind, name: string, clientX: number, clientY: number): Point | null;
|
||||
/** The same, in the middle of what is in view. */
|
||||
placeInView(kind: NodeKind, name: string): Point;
|
||||
/** The drawing's point under a point of the screen; null outside the surface. */
|
||||
surfacePointAt(clientX: number, clientY: number): Point | null;
|
||||
/** The drawing's point in the middle of what is in view. */
|
||||
surfaceCentre(): Point;
|
||||
}
|
||||
|
||||
/** Where a focused text is typed into: its frame on the screen and the scale it is drawn at. */
|
||||
export interface ScreenFrame {
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly scale: number;
|
||||
}
|
||||
|
||||
export interface CanvasProps {
|
||||
@@ -59,9 +78,13 @@ export interface CanvasProps {
|
||||
/**
|
||||
* The simulation: a tap on a device activates it (a computer's desktop),
|
||||
* a long press opens its menu; nothing moves, nothing is selected, as in
|
||||
* Java's simulation mode.
|
||||
* Java's simulation mode. The documentation mode: see above.
|
||||
*/
|
||||
readonly mode?: 'design' | 'simulation';
|
||||
readonly mode?: 'design' | 'simulation' | 'documentation';
|
||||
/** A documentation item moved or resized: its new frame. */
|
||||
readonly onDocFrame?: (id: string, frame: Frame) => void;
|
||||
/** The field a focused text is typed into, over the surface at its frame. */
|
||||
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;
|
||||
@@ -87,27 +110,57 @@ function useTextWidth(): (text: string) => number {
|
||||
);
|
||||
}
|
||||
|
||||
const rgba = (color: DocumentationView['color']) => `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
|
||||
|
||||
function Documentation({ item }: { readonly item: DocumentationView }) {
|
||||
function Documentation({ item, documenting, focused }: { readonly item: DocumentationView; readonly documenting: boolean; readonly focused: boolean }) {
|
||||
const hold = holdFrame(item, focused);
|
||||
// The focus (documentation mode): a translucent blue band with a blue border, around a text, inside a rectangle.
|
||||
const focus = !focused ? null : (
|
||||
<rect className="doc-focus" x={hold.x + 1} y={hold.y + 1} width={hold.width - 2} height={hold.height - 2} rx={5} fill={FOCUS_BAND} stroke={FOCUS_BORDER} strokeWidth={2} />
|
||||
);
|
||||
if (item.kind === 'rectangle') {
|
||||
return <rect className="doc-rectangle" x={item.x} y={item.y} width={item.width} height={item.height} fill={rgba(item.color)} data-doc={item.id} />;
|
||||
const inset = focused ? EDGE_BAND : 1;
|
||||
return (
|
||||
<g data-doc={item.id} data-focused={focused ? 'true' : undefined}>
|
||||
{focus}
|
||||
<rect
|
||||
className="doc-rectangle"
|
||||
x={item.x + inset}
|
||||
y={item.y + inset}
|
||||
width={Math.max(0, item.width - 2 * inset)}
|
||||
height={Math.max(0, item.height - 2 * inset)}
|
||||
rx={5}
|
||||
fill={rectangleFill(item.color, documenting)}
|
||||
stroke={focused ? 'none' : rectangleBorder(item.color)}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<foreignObject x={item.x} y={item.y} width={item.width} height={item.height} data-doc={item.id}>
|
||||
<div
|
||||
style={{
|
||||
color: rgba(item.color),
|
||||
font: `${item.bold ? 'bold ' : ''}${item.size}px "${item.font}", Dialog, sans-serif`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
</foreignObject>
|
||||
<g data-doc={item.id} data-focused={focused ? 'true' : undefined}>
|
||||
{focus}
|
||||
{/* While it has the focus, the field typed into draws its words. */}
|
||||
<foreignObject x={item.x} y={item.y} width={item.width} height={item.height}>
|
||||
<div
|
||||
className="doc-text"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
padding: 1,
|
||||
color: textColor(item.color),
|
||||
font: `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: documenting && !focused ? `1px solid ${TEXT_BORDER}` : undefined,
|
||||
visibility: focused ? 'hidden' : undefined,
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,10 +202,37 @@ type Gesture =
|
||||
| { readonly kind: 'background'; readonly start: Point; moved: boolean; last: Point }
|
||||
| { readonly kind: 'pinch'; spread: number; middle: Point }
|
||||
| { readonly kind: 'device'; readonly id: string; readonly start: Point; readonly origin: Point; readonly side: Side; moved: boolean; longPressed: boolean }
|
||||
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
|
||||
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean }
|
||||
| {
|
||||
readonly kind: 'doc';
|
||||
readonly id: string;
|
||||
readonly start: Point;
|
||||
readonly origin: Frame;
|
||||
/** The edges a drag moves; none: it moves the item; null: it does nothing (a text without the focus). */
|
||||
readonly edges: { left: boolean; right: boolean; top: boolean; bottom: boolean } | null;
|
||||
moved: boolean;
|
||||
longPressed: boolean;
|
||||
};
|
||||
|
||||
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null, mode = 'design', activeLinks = NO_LINKS, onActivate },
|
||||
{
|
||||
view,
|
||||
tool = 'select',
|
||||
selection = null,
|
||||
cableFrom = null,
|
||||
onSelect,
|
||||
onMoveDevice,
|
||||
onMoveEnd,
|
||||
onCableStart,
|
||||
onCable,
|
||||
onMenu,
|
||||
highlightedLink = null,
|
||||
mode = 'design',
|
||||
activeLinks = NO_LINKS,
|
||||
onActivate,
|
||||
onDocFrame,
|
||||
renderDocText,
|
||||
},
|
||||
handle,
|
||||
) {
|
||||
const t = useTranslate();
|
||||
@@ -208,6 +288,15 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
const rect = surface.current?.getBoundingClientRect();
|
||||
return cornerFor(kind, name, toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 }));
|
||||
},
|
||||
surfacePointAt(clientX, clientY) {
|
||||
const rect = surface.current?.getBoundingClientRect();
|
||||
if (rect === undefined || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;
|
||||
return toSurface(currentRef.current, local(clientX, clientY));
|
||||
},
|
||||
surfaceCentre() {
|
||||
const rect = surface.current?.getBoundingClientRect();
|
||||
return toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 });
|
||||
},
|
||||
}),
|
||||
[cornerFor, local],
|
||||
);
|
||||
@@ -234,6 +323,21 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
);
|
||||
|
||||
const simulating = mode === 'simulation';
|
||||
const documenting = mode === 'documentation';
|
||||
const focusedDoc = documenting && selection?.kind === 'doc' ? selection.id : null;
|
||||
|
||||
/** The documentation item under a screen point, the topmost first (drawn last), with the focused text's band. */
|
||||
const docAt = useCallback(
|
||||
(point: Point): { item: DocumentationView; hold: Frame } | null => {
|
||||
const surfacePoint = toSurface(currentRef.current, point);
|
||||
for (const item of [...view.docs].reverse()) {
|
||||
const hold = holdFrame(item, item.id === focusedDoc);
|
||||
if (surfacePoint.x >= hold.x && surfacePoint.x <= hold.x + hold.width && surfacePoint.y >= hold.y && surfacePoint.y <= hold.y + hold.height) return { item, hold };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[view.docs, focusedDoc],
|
||||
);
|
||||
const pointers = useRef(new Map<number, Point>());
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -260,6 +364,27 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
if (pointers.current.size > 2) return;
|
||||
const target = event.target as Element;
|
||||
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
|
||||
if (documenting) {
|
||||
const found = docAt(point);
|
||||
if (found === null) {
|
||||
gesture.current = { kind: 'background', start: point, moved: false, last: point };
|
||||
return;
|
||||
}
|
||||
const { item, hold } = found;
|
||||
// An edge's band: Java's, wider for a finger at a small zoom, but never more than a third of the item (its middle moves it);
|
||||
// a text without the focus is only chosen (Java moves it in its band).
|
||||
const band = Math.max(EDGE_BAND, Math.min(10 / currentRef.current.scale, Math.min(hold.width, hold.height) / 3));
|
||||
const movable = item.kind === 'rectangle' || item.id === focusedDoc;
|
||||
const edges = movable ? edgesAt(hold, toSurface(currentRef.current, point), band) : null;
|
||||
gesture.current = { kind: 'doc', id: item.id, start: point, origin: { x: item.x, y: item.y, width: item.width, height: item.height }, edges, moved: false, longPressed: false };
|
||||
longPress.current = setTimeout(() => {
|
||||
const now = gesture.current;
|
||||
if (now?.kind !== 'doc' || now.moved) return;
|
||||
now.longPressed = true;
|
||||
onMenu?.({ kind: 'doc', id: now.id }, now.start);
|
||||
}, LONG_PRESS_MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
const device = deviceAt(point);
|
||||
if (device !== null) {
|
||||
const box = boxes.get(device.id)!;
|
||||
@@ -309,6 +434,21 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
clearLongPress();
|
||||
capture(event.pointerId);
|
||||
}
|
||||
if (now.kind === 'doc') {
|
||||
if (now.longPressed || now.edges === null) return;
|
||||
const scale = currentRef.current.scale;
|
||||
const delta = { x: (point.x - now.start.x) / scale, y: (point.y - now.start.y) / scale };
|
||||
const { left, right, top, bottom } = now.edges;
|
||||
const kind = view.docs.find((item) => item.id === now.id)?.kind ?? 'rectangle';
|
||||
if (!left && !right && !top && !bottom) {
|
||||
onDocFrame?.(now.id, { ...now.origin, x: now.origin.x + delta.x, y: now.origin.y + delta.y });
|
||||
} else {
|
||||
// The moved edge follows the pointer from where it was (JDocuElement.mouseDragged).
|
||||
const edge = { x: (left ? now.origin.x : now.origin.x + now.origin.width) + delta.x, y: (top ? now.origin.y : now.origin.y + now.origin.height) + delta.y };
|
||||
onDocFrame?.(now.id, resizedFrame(now.origin, kind, now.edges, edge));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (now.kind === 'background') {
|
||||
// The step taken now: the updater runs later, when `last` has moved on.
|
||||
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
|
||||
@@ -339,6 +479,12 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
setBand(null);
|
||||
return;
|
||||
}
|
||||
if (now.kind === 'doc') {
|
||||
if (now.longPressed) return;
|
||||
if (now.moved) onMoveEnd?.();
|
||||
else onSelect?.({ kind: 'doc', id: now.id });
|
||||
return;
|
||||
}
|
||||
if (now.kind === 'background') {
|
||||
if (!now.moved && !simulating) {
|
||||
onSelect?.(null);
|
||||
@@ -398,6 +544,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
const box = boxes.get(selection.id);
|
||||
return box === undefined ? null : { x: box.x + box.width, y: box.y };
|
||||
}
|
||||
if (selection?.kind === 'doc') {
|
||||
const item = view.docs.find((each) => each.id === selection.id);
|
||||
return item === undefined ? null : { x: item.x + item.width, y: item.y };
|
||||
}
|
||||
if (selection?.kind === 'link') {
|
||||
const link = view.links.find((each) => each.id === selection.id);
|
||||
const [source, target] = [boxes.get(link?.sourceNode ?? ''), boxes.get(link?.targetNode ?? '')];
|
||||
@@ -426,11 +576,13 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
|
||||
<g className="docs">
|
||||
{view.docs.map((item) => (
|
||||
<Documentation key={item.id} item={item} />
|
||||
))}
|
||||
</g>
|
||||
{documenting ? null : (
|
||||
<g className="docs">
|
||||
{view.docs.map((item) => (
|
||||
<Documentation key={item.id} item={item} documenting={false} focused={false} />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
<g className="cables" fill="none">
|
||||
{view.links.map((link) => {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
@@ -468,6 +620,13 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
{documenting ? (
|
||||
<g className="docs">
|
||||
{view.docs.map((item) => (
|
||||
<Documentation key={item.id} item={item} documenting focused={item.id === focusedDoc} />
|
||||
))}
|
||||
</g>
|
||||
) : null}
|
||||
{band === null ? null : (
|
||||
<g className="cable-preview" pointerEvents="none">
|
||||
<line x1={band.from.x} y1={band.from.y} x2={band.to.x} y2={band.to.y} stroke="var(--accent)" strokeWidth={2 / current.scale} strokeDasharray="6 4" />
|
||||
@@ -480,6 +639,12 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
{(() => {
|
||||
const item = focusedDoc === null ? undefined : view.docs.find((each) => each.id === focusedDoc && each.kind === 'text');
|
||||
if (item === undefined || renderDocText === undefined) return null;
|
||||
const corner = toScreen(current, { x: item.x, y: item.y });
|
||||
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
|
||||
})()}
|
||||
{menuButton === null || selection === null || simulating ? null : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// How Java paints a documentation item (JDocuElement.setColor, paintComponent;
|
||||
// measured with tools/java-oracle's DocuRun): a rectangle in the
|
||||
// documentation mode in its colour at an opacity of 200, else opaque and a
|
||||
// fifth of the way to white; its border four fifths of its colour; corners
|
||||
// rounded (an arc of 10). A text has a light grey border in the
|
||||
// documentation mode only. What has the focus there shows a translucent
|
||||
// blue band and a blue border; a text's frame then grows around it (6 to
|
||||
// the left and up, 12 wider, 28 higher), the band to take hold of.
|
||||
|
||||
import type { DocumentationItemKind } from '@mdg-filius/core';
|
||||
import type { DocumentationView } from '@mdg-filius/doc';
|
||||
|
||||
type Color = DocumentationView['color'];
|
||||
const byte = (unit: number) => Math.round(unit * 255);
|
||||
|
||||
/** Java's opacity of a rectangle in the documentation mode (transparentColor). */
|
||||
export const DOCUMENTATION_OPACITY = 200;
|
||||
/** JDocuElement.panelFocusColor and borderFocusColor; the text's border (textPanelBorderNormalColor). */
|
||||
export const FOCUS_BAND = 'rgba(56, 152, 248, 0.3)';
|
||||
export const FOCUS_BORDER = 'rgb(122, 186, 250)';
|
||||
export const TEXT_BORDER = 'rgb(179, 179, 179)';
|
||||
/** The width of the band an edge is taken by (TEXT_BORDER_WIDTH). */
|
||||
export const EDGE_BAND = 7;
|
||||
|
||||
/** A rectangle's fill: its colour at Java's opacity in the documentation mode, else opaque, a fifth of the way to white. */
|
||||
export function rectangleFill(color: Color, documenting: boolean): string {
|
||||
const [red, green, blue] = [byte(color.red), byte(color.green), byte(color.blue)];
|
||||
if (documenting) return `rgba(${red}, ${green}, ${blue}, ${DOCUMENTATION_OPACITY / 255})`;
|
||||
const lighter = (value: number) => 255 + Math.trunc(((value - 255) * 8) / 10);
|
||||
return `rgb(${lighter(red)}, ${lighter(green)}, ${lighter(blue)})`;
|
||||
}
|
||||
|
||||
/** A rectangle's border: four fifths of its colour. */
|
||||
export function rectangleBorder(color: Color): string {
|
||||
const darker = (unit: number) => Math.trunc((byte(unit) * 4) / 5);
|
||||
return `rgb(${darker(color.red)}, ${darker(color.green)}, ${darker(color.blue)})`;
|
||||
}
|
||||
|
||||
export function textColor(color: Color): string {
|
||||
return `rgba(${byte(color.red)}, ${byte(color.green)}, ${byte(color.blue)}, ${color.alpha})`;
|
||||
}
|
||||
|
||||
/** Java's logical font names as the browser has them; any other a font of that name, else sans-serif. */
|
||||
export function fontFamily(name: string): string {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'dialog':
|
||||
case 'sansserif':
|
||||
return 'sans-serif';
|
||||
case 'serif':
|
||||
return 'serif';
|
||||
case 'monospaced':
|
||||
case 'dialoginput':
|
||||
return 'monospace';
|
||||
default:
|
||||
return `"${name.replaceAll('"', '')}", sans-serif`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** Where a focused item is taken hold of: a rectangle by itself, a text by its frame grown around it. */
|
||||
export function holdFrame(item: Frame & { readonly kind: DocumentationItemKind }, focused: boolean): Frame {
|
||||
if (item.kind === 'rectangle' || !focused) return item;
|
||||
return { x: item.x - 6, y: item.y - 6, width: item.width + 12, height: item.height + 28 };
|
||||
}
|
||||
|
||||
/** The edges within `band` of a point inside a frame (none: the middle, which moves it); of two opposite ones the drag takes the left or upper. */
|
||||
export function edgesAt(frame: Frame, point: { readonly x: number; readonly y: number }, band: number): { left: boolean; right: boolean; top: boolean; bottom: boolean } {
|
||||
const left = point.x - frame.x <= band;
|
||||
const right = frame.x + frame.width - point.x <= band;
|
||||
const top = point.y - frame.y <= band;
|
||||
const bottom = frame.y + frame.height - point.y <= band;
|
||||
return { left, right, top, bottom };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// The menu of a documentation item (Java's JDocuElement.showPopupMenu): on a
|
||||
// text „Schriftgröße“ („Normal“, „Groß“, „Sehr Groß“), „Schriftstil“
|
||||
// („Normal“, „Kursiv“, „Fett“) and „Farbe“ (Java's fifteen), on a
|
||||
// rectangle „Farbe“ (Java's twelve), then „Löschen“; what the item has is
|
||||
// marked. Opened by a long press or the three-dot button of the focused
|
||||
// item.
|
||||
//
|
||||
// Better than Java: the sizes, styles and colours are all in view at once
|
||||
// (Java's are submenus), each a target a finger meets.
|
||||
|
||||
import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
import { DOCUMENTATION_FONT_SIZES, DOCUMENTATION_RECTANGLE_COLORS, DOCUMENTATION_TEXT_COLORS, type DocumentationColor, type DocumentationFontStyle } from '@mdg-filius/core';
|
||||
import type { DocumentationView } from '@mdg-filius/doc';
|
||||
import type { Point } from '../canvas/viewport.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
|
||||
export interface DocumentationMenuProps {
|
||||
readonly item: DocumentationView;
|
||||
readonly at: Point;
|
||||
readonly onSize: (size: number) => void;
|
||||
readonly onStyle: (style: DocumentationFontStyle) => void;
|
||||
readonly onColor: (color: DocumentationColor) => void;
|
||||
readonly onDelete: () => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
const SIZE_LABELS = ['docu.sizeNormal', 'docu.sizeLarge', 'docu.sizeVeryLarge'] as const;
|
||||
const STYLES: readonly { readonly style: DocumentationFontStyle; readonly label: string }[] = [
|
||||
{ style: 'plain', label: 'docu.stylePlain' },
|
||||
{ style: 'italic', label: 'docu.styleItalic' },
|
||||
{ style: 'bold', label: 'docu.styleBold' },
|
||||
];
|
||||
|
||||
const byte = (unit: number) => Math.round(unit * 255);
|
||||
const sameColor = (lhs: DocumentationColor, rhs: DocumentationColor) => byte(lhs.red) === byte(rhs.red) && byte(lhs.green) === byte(rhs.green) && byte(lhs.blue) === byte(rhs.blue);
|
||||
|
||||
const entry: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
minHeight: 44,
|
||||
padding: '0 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
const heading: CSSProperties = { padding: '6px 16px 2px', fontSize: 12, color: 'var(--text-secondary)' };
|
||||
|
||||
export function DocumentationMenu({ item, at, onSize, onStyle, onColor, onDelete, onClose }: DocumentationMenuProps) {
|
||||
const t = useTranslate();
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
menu.current?.querySelector('button')?.focus();
|
||||
const away = (event: PointerEvent) => {
|
||||
if (!menu.current?.contains(event.target as Node)) onClose();
|
||||
};
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('pointerdown', away, true);
|
||||
window.addEventListener('keydown', key);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', away, true);
|
||||
window.removeEventListener('keydown', key);
|
||||
};
|
||||
}, [onClose]);
|
||||
const choose = (action: () => void) => () => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
const style: DocumentationFontStyle | null = item.bold && item.italic ? null : item.bold ? 'bold' : item.italic ? 'italic' : 'plain';
|
||||
const colors = item.kind === 'text' ? DOCUMENTATION_TEXT_COLORS : DOCUMENTATION_RECTANGLE_COLORS;
|
||||
return (
|
||||
<div
|
||||
ref={menu}
|
||||
role="menu"
|
||||
aria-label={item.kind === 'text' ? t('docu.text') : t('docu.rectangle')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: at.x,
|
||||
top: at.y,
|
||||
width: 240,
|
||||
maxHeight: 'calc(100% - 16px)',
|
||||
overflowY: 'auto',
|
||||
padding: '4px 0',
|
||||
borderRadius: 8,
|
||||
background: 'var(--surface-raised)',
|
||||
border: '1px solid var(--border-default)',
|
||||
boxShadow: '0 4px 12px color-mix(in srgb, var(--shadow-color) 25%, transparent)',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{item.kind === 'text' ? (
|
||||
<>
|
||||
<div role="group" aria-label={t('docu.fontSize')}>
|
||||
<div style={heading}>{t('docu.fontSize')}</div>
|
||||
{DOCUMENTATION_FONT_SIZES.map((size, index) => (
|
||||
<button key={size} type="button" role="menuitemradio" aria-checked={item.size === size} style={entry} onClick={choose(() => onSize(size))}>
|
||||
<span aria-hidden="true" style={{ width: 12 }}>
|
||||
{item.size === size ? '●' : ''}
|
||||
</span>
|
||||
{t(SIZE_LABELS[index]!)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="group" aria-label={t('docu.fontStyle')}>
|
||||
<div style={heading}>{t('docu.fontStyle')}</div>
|
||||
{STYLES.map((each) => (
|
||||
<button key={each.style} type="button" role="menuitemradio" aria-checked={style === each.style} style={entry} onClick={choose(() => onStyle(each.style))}>
|
||||
<span aria-hidden="true" style={{ width: 12 }}>
|
||||
{style === each.style ? '●' : ''}
|
||||
</span>
|
||||
{t(each.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div role="group" aria-label={t('docu.color')}>
|
||||
<div style={heading}>{t('docu.color')}</div>
|
||||
{/* Java's rows: three, as many columns as that makes. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${colors.length / 3}, 36px)`, gap: 4, padding: '4px 16px 8px' }}>
|
||||
{colors.map((color, index) => {
|
||||
const chosen = sameColor(color, item.color);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={chosen}
|
||||
aria-label={t('docu.swatch', { index: index + 1 })}
|
||||
title={t('docu.swatch', { index: index + 1 })}
|
||||
onClick={choose(() => onColor(color))}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
padding: 0,
|
||||
borderRadius: 4,
|
||||
border: chosen ? '3px solid rgb(122, 186, 250)' : '1px solid rgb(64, 64, 64)',
|
||||
background: `rgb(${byte(color.red)}, ${byte(color.green)}, ${byte(color.blue)})`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div role="separator" style={{ height: 1, margin: '4px 0', background: 'var(--border-default)' }} />
|
||||
<button type="button" role="menuitem" style={{ ...entry, color: 'var(--danger)' }} onClick={choose(onDelete)}>
|
||||
{t('menu.delete')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// The documentation mode's sidebar (Java's GUIDocumentationSidebar): „Text“
|
||||
// and „Gliederungsfläche“ with Java's icons. An item is dragged onto the
|
||||
// surface, where it lands with its middle under the pointer, as Java's does;
|
||||
// tapped, it lands in the middle of the view (Java's tap makes nothing).
|
||||
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
import type { DocumentationItemKind } from '@mdg-filius/core';
|
||||
import { DRAG_THRESHOLD } from '../canvas/Canvas.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import addRectangle from '../assets/documentation/add_small.png';
|
||||
import addText from '../assets/documentation/add_text_small.png';
|
||||
|
||||
export interface DocumentationSidebarProps {
|
||||
/** An item dropped at a point of the screen, or tapped (no point). */
|
||||
readonly onItem: (kind: DocumentationItemKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
|
||||
}
|
||||
|
||||
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
||||
{ kind: 'text', icon: addText, label: 'docu.text' },
|
||||
{ kind: 'rectangle', icon: addRectangle, label: 'docu.rectangle' },
|
||||
];
|
||||
|
||||
const item: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minHeight: 64,
|
||||
padding: '6px 4px',
|
||||
border: '2px solid transparent',
|
||||
borderRadius: 8,
|
||||
background: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
font: 'inherit',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
overflowWrap: 'anywhere',
|
||||
cursor: 'grab',
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) {
|
||||
const t = useTranslate();
|
||||
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
||||
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||
/** The click the browser sends after a drag ends on the button: not a tap. */
|
||||
const afterDrag = useRef(false);
|
||||
|
||||
const onPointerDown = (kind: DocumentationItemKind) => (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
press.current = { kind, x: event.clientX, y: event.clientY, dragging: false };
|
||||
// The button is the click's own target: capturing at once takes no click from anything inside it.
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const now = press.current;
|
||||
if (now === null) return;
|
||||
if (!now.dragging) {
|
||||
if (Math.hypot(event.clientX - now.x, event.clientY - now.y) < DRAG_THRESHOLD) return;
|
||||
now.dragging = true;
|
||||
}
|
||||
setGhost({ kind: now.kind, x: event.clientX, y: event.clientY });
|
||||
};
|
||||
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const now = press.current;
|
||||
press.current = null;
|
||||
setGhost(null);
|
||||
if (now?.dragging !== true) return;
|
||||
afterDrag.current = true;
|
||||
onItem(now.kind, { clientX: event.clientX, clientY: event.clientY });
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t('docu.sidebar')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: 6,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--surface-card)',
|
||||
borderRight: '1px solid var(--border-default)',
|
||||
width: 124,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{ITEMS.map((each) => (
|
||||
<button
|
||||
key={each.kind}
|
||||
type="button"
|
||||
style={item}
|
||||
data-docu={each.kind}
|
||||
onPointerDown={onPointerDown(each.kind)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={() => {
|
||||
press.current = null;
|
||||
setGhost(null);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (afterDrag.current) afterDrag.current = false;
|
||||
else onItem(each.kind, null);
|
||||
}}
|
||||
>
|
||||
<img src={each.icon} alt="" width={40} height={40} draggable={false} />
|
||||
{t(each.label)}
|
||||
</button>
|
||||
))}
|
||||
{ghost === null ? null : (
|
||||
// The item's own size, its middle under the pointer, as Java drags it.
|
||||
<div
|
||||
data-ghost
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: ghost.x,
|
||||
top: ghost.y,
|
||||
width: 100,
|
||||
height: 50,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: `1px ${ghost.kind === 'text' ? 'solid rgb(179, 179, 179)' : 'solid rgb(163, 163, 163)'}`,
|
||||
borderRadius: 5,
|
||||
background: ghost.kind === 'rectangle' ? 'rgba(204, 204, 204, 0.78)' : 'transparent',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// The field a focused text is typed into (Java's JDocuTextArea): over the
|
||||
// text, at its size, in its font and colour, scaled with the view. It
|
||||
// follows the document's Y.Text — what others type comes in, the caret kept —
|
||||
// and writes what is typed with this editor's origin. Escape ends the focus;
|
||||
// Ctrl+Delete removes the text, as in Java (Delete alone types).
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { replaceText, type DocumentationView } from '@mdg-filius/doc';
|
||||
import type { ScreenFrame } from '../canvas/Canvas.js';
|
||||
import { fontFamily, textColor } from '../canvas/documentationPaint.js';
|
||||
import { followText } from '../desktop/textBinding.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
|
||||
export interface DocumentationTextProps {
|
||||
readonly item: DocumentationView;
|
||||
readonly frame: ScreenFrame;
|
||||
readonly text: Y.Text;
|
||||
readonly origin: object;
|
||||
readonly onDone: () => void;
|
||||
readonly onRemove: () => void;
|
||||
}
|
||||
|
||||
export function DocumentationText({ item, frame, text, origin, onDone, onRemove }: DocumentationTextProps) {
|
||||
const t = useTranslate();
|
||||
const area = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
const element = area.current;
|
||||
if (element === null) return undefined;
|
||||
const stop = followText(element, text, origin);
|
||||
// Java gives a text the focus when it gets it (a new one at once).
|
||||
element.focus();
|
||||
return stop;
|
||||
}, [text, origin]);
|
||||
return (
|
||||
<textarea
|
||||
ref={area}
|
||||
aria-label={t('docu.text')}
|
||||
data-doc-text={item.id}
|
||||
spellCheck={false}
|
||||
onInput={(event) => {
|
||||
const next = event.currentTarget.value;
|
||||
text.doc?.transact(() => replaceText(text, next), origin);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onDone();
|
||||
} else if (event.key === 'Delete' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
onRemove();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: frame.left,
|
||||
top: frame.top,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
transform: `scale(${frame.scale})`,
|
||||
transformOrigin: 'top left',
|
||||
boxSizing: 'border-box',
|
||||
margin: 0,
|
||||
padding: 1,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
overflow: 'hidden',
|
||||
background: 'transparent',
|
||||
color: textColor(item.color),
|
||||
font: `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`,
|
||||
lineHeight: 'normal',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,19 @@
|
||||
"dnsServer.tabMX": "Mailaustausch (MX)",
|
||||
"dnsServer.tabNS": "Nameserver (NS)",
|
||||
"dnsServer.tables": "Einträge",
|
||||
"docu.color": "Farbe",
|
||||
"docu.fontSize": "Schriftgröße",
|
||||
"docu.fontStyle": "Schriftstil",
|
||||
"docu.rectangle": "Gliederungsfläche",
|
||||
"docu.sidebar": "Dokumentation",
|
||||
"docu.sizeLarge": "Groß",
|
||||
"docu.sizeNormal": "Normal",
|
||||
"docu.sizeVeryLarge": "Sehr Groß",
|
||||
"docu.styleBold": "Fett",
|
||||
"docu.styleItalic": "Kursiv",
|
||||
"docu.stylePlain": "Normal",
|
||||
"docu.swatch": "Farbe {index}",
|
||||
"docu.text": "Text",
|
||||
"drop.hold": "Pakete verwerfen",
|
||||
"drop.list": "Verworfen: {count}",
|
||||
"drop.listLabel": "Verworfene Pakete",
|
||||
@@ -295,6 +308,7 @@
|
||||
"menu.showDesktop": "Desktop anzeigen",
|
||||
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
|
||||
"mode.design": "Wechselt in den Entwurfsmodus",
|
||||
"mode.documentation": "Wechselt in den Dokumentationsmodus",
|
||||
"mode.simulation": "Wechselt in den Aktionsmodus",
|
||||
"mode.speed": "Simulationsgeschwindigkeit anpassen",
|
||||
"network.dnsServer": "DNS-Server:",
|
||||
|
||||
@@ -103,6 +103,19 @@
|
||||
"dnsServer.tabMX": "Mail exchange (MX)",
|
||||
"dnsServer.tabNS": "Name server (NS)",
|
||||
"dnsServer.tables": "Records",
|
||||
"docu.color": "Colour",
|
||||
"docu.fontSize": "Font Size",
|
||||
"docu.fontStyle": "Font Style",
|
||||
"docu.rectangle": "Structure Field",
|
||||
"docu.sidebar": "Documentation",
|
||||
"docu.sizeLarge": "Large",
|
||||
"docu.sizeNormal": "Normal",
|
||||
"docu.sizeVeryLarge": "Very Large",
|
||||
"docu.styleBold": "Bold",
|
||||
"docu.styleItalic": "Italic",
|
||||
"docu.stylePlain": "Normal",
|
||||
"docu.swatch": "Colour {index}",
|
||||
"docu.text": "Text",
|
||||
"drop.hold": "Drop Packets",
|
||||
"drop.list": "Dropped: {count}",
|
||||
"drop.listLabel": "Dropped packets",
|
||||
@@ -295,6 +308,7 @@
|
||||
"menu.showDesktop": "Show desktop",
|
||||
"mode.currentSpeed": "Current Simulation Velocity",
|
||||
"mode.design": "Change to Design Mode",
|
||||
"mode.documentation": "Change to Documentation Mode",
|
||||
"mode.simulation": "Change to Simulation Mode",
|
||||
"mode.speed": "Adjust Simulation Velocity",
|
||||
"network.dnsServer": "DNS server:",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Java's mode buttons (GUIMainMenu: design and simulation mode, with Java's
|
||||
// icons) and, while the simulation runs, its speed slider with the per cent
|
||||
// Java's mode buttons (GUIMainMenu: documentation, design and simulation
|
||||
// mode, in Java's order, with Java's icons) and, while the simulation runs, its speed slider with the per cent
|
||||
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
|
||||
// a browser they bookmark and reload the page.
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { SimMode } from '@mdg-filius/protocol';
|
||||
import simulation from '../assets/modes/aktionsmodus.png';
|
||||
import simulationOn from '../assets/modes/aktionsmodus_aktiv.png';
|
||||
import design from '../assets/modes/entwurfsmodus.png';
|
||||
import designOn from '../assets/modes/entwurfsmodus_aktiv.png';
|
||||
import documentation from '../assets/modes/dokumodus.png';
|
||||
import documentationOn from '../assets/modes/dokumodus_aktiv.png';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
|
||||
const modeButton: CSSProperties = {
|
||||
@@ -21,10 +22,24 @@ const modeButton: CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: SimMode; readonly speed: number; readonly onMode: (mode: SimMode) => void; readonly onSpeed: (value: number) => void }) {
|
||||
/** The editor's modes: the room's two, and the documentation mode, which is the design's for the room. */
|
||||
export type EditorMode = 'documentation' | 'design' | 'simulation';
|
||||
|
||||
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: EditorMode; readonly speed: number; readonly onMode: (mode: EditorMode) => void; readonly onSpeed: (value: number) => void }) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
name="documentationMode"
|
||||
style={modeButton}
|
||||
aria-pressed={mode === 'documentation'}
|
||||
aria-label={t('mode.documentation')}
|
||||
title={t('mode.documentation')}
|
||||
onClick={() => onMode('documentation')}
|
||||
>
|
||||
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
|
||||
</button>
|
||||
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
|
||||
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// How the editor paints a documentation item, against the pixels Java's
|
||||
// JDocuElement paints (packages/doc/test/documentation.java.json, DocuRun):
|
||||
// a rectangle in the documentation mode and in the others, its border, the
|
||||
// text's border; the focused text's frame; where an edge is taken hold of.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { edgesAt, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, FOCUS_BORDER, FOCUS_BAND, textColor } from '../src/canvas/documentationPaint.js';
|
||||
|
||||
type Rgba = [number, number, number, number];
|
||||
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../doc/test/documentation.java.json'), 'utf8')) as {
|
||||
readonly painted: Record<string, Record<string, Rgba>>;
|
||||
readonly dropped: { readonly text: { readonly bounds: number[] }; readonly textUnfocused: { readonly bounds: number[] } };
|
||||
};
|
||||
const pink = { red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 };
|
||||
const css = ([red, green, blue, alpha]: Rgba) => (alpha === 255 ? `rgb(${red}, ${green}, ${blue})` : `rgba(${red}, ${green}, ${blue}, ${alpha / 255})`);
|
||||
|
||||
describe("Java's paint of the documentation", () => {
|
||||
it('fills a rectangle at an opacity of 200 in the documentation mode, else opaque and lighter; its border four fifths of its colour', () => {
|
||||
const { rectangleEnabled, rectangleDisabled, rectangleFocused, textEnabled, textDisabled } = java.painted;
|
||||
expect(rectangleFill(pink, true)).toBe(css(rectangleEnabled!['50,25']!));
|
||||
expect(rectangleFill(pink, false)).toBe(css(rectangleDisabled!['50,25']!));
|
||||
expect(rectangleBorder(pink)).toBe(css(rectangleEnabled!['1,25']!));
|
||||
expect(rectangleBorder(pink)).toBe(css(rectangleDisabled!['1,25']!));
|
||||
// The focus: Java's blue border and translucent band (its alpha 77 of 255 is 0.3).
|
||||
expect(FOCUS_BORDER).toBe(css(rectangleFocused!['1,25']!));
|
||||
const [red, green, blue, alpha] = rectangleFocused!['3,25']!;
|
||||
expect(FOCUS_BAND).toBe(`rgba(${red}, ${green}, ${blue}, ${Math.round((alpha / 255) * 10) / 10})`);
|
||||
// A text's border only in the documentation mode.
|
||||
expect(TEXT_BORDER).toBe(css(textEnabled!['0,25']!));
|
||||
expect(textDisabled!['0,25']![3]).toBe(0);
|
||||
// Java's integer arithmetic: 255 + (c − 255) · 8 / 10 cut towards zero, c · 4 / 5 cut.
|
||||
expect(rectangleFill({ red: 0, green: 1, blue: 7 / 255, alpha: 1 }, false)).toBe('rgb(51, 255, 57)');
|
||||
expect(rectangleBorder({ red: 0, green: 1, blue: 7 / 255, alpha: 1 })).toBe('rgb(0, 204, 5)');
|
||||
// Where rounding would differ: (3 − 255) · 8 / 10 = −201.6 is −201 in Java; 3 · 4 / 5 = 2.4 is 2, 254 · 4 / 5 = 203.2 is 203.
|
||||
expect(rectangleFill({ red: 3 / 255, green: 254 / 255, blue: 1, alpha: 1 }, false)).toBe('rgb(54, 255, 255)');
|
||||
expect(rectangleBorder({ red: 3 / 255, green: 254 / 255, blue: 4 / 255, alpha: 1 })).toBe('rgb(2, 203, 3)');
|
||||
expect(textColor({ red: 0, green: 0, blue: 1, alpha: 0.5 })).toBe('rgba(0, 0, 255, 0.5)');
|
||||
});
|
||||
|
||||
it("grows a focused text's frame as Java does, and finds the edges a drag takes", () => {
|
||||
const [x, y, width, height] = java.dropped.textUnfocused.bounds as [number, number, number, number];
|
||||
const [fx, fy, fw, fh] = java.dropped.text.bounds as [number, number, number, number];
|
||||
expect(holdFrame({ kind: 'text', x, y, width, height }, true)).toEqual({ x: fx, y: fy, width: fw, height: fh });
|
||||
expect(holdFrame({ kind: 'text', x, y, width, height }, false)).toEqual({ kind: 'text', x, y, width, height });
|
||||
expect(holdFrame({ kind: 'rectangle', x, y, width, height }, true)).toEqual({ kind: 'rectangle', x, y, width, height });
|
||||
const frame = { x: 0, y: 0, width: 100, height: 50 };
|
||||
expect(edgesAt(frame, { x: 50, y: 25 }, 7)).toEqual({ left: false, right: false, top: false, bottom: false });
|
||||
expect(edgesAt(frame, { x: 7, y: 25 }, 7)).toEqual({ left: true, right: false, top: false, bottom: false });
|
||||
expect(edgesAt(frame, { x: 93, y: 49 }, 7)).toEqual({ left: false, right: true, top: false, bottom: true });
|
||||
expect(edgesAt(frame, { x: 92, y: 8 }, 7)).toEqual({ left: false, right: false, top: false, bottom: false });
|
||||
expect(edgesAt(frame, { x: 3, y: 3 }, 7)).toEqual({ left: true, right: false, top: true, bottom: false });
|
||||
// Narrower than two bands: both edges, of which the drag takes the left (resizedFrame).
|
||||
expect(edgesAt({ x: 0, y: 0, width: 10, height: 10 }, { x: 5, y: 5 }, 7)).toEqual({ left: true, right: true, top: true, bottom: true });
|
||||
});
|
||||
|
||||
it("writes Java's logical fonts as the browser's", () => {
|
||||
expect(['Dialog', 'SansSerif', 'Serif', 'Monospaced', 'DialogInput', 'Comic "Neue"'].map(fontFamily)).toEqual([
|
||||
'sans-serif',
|
||||
'sans-serif',
|
||||
'serif',
|
||||
'monospace',
|
||||
'monospace',
|
||||
'"Comic Neue", sans-serif',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,18 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'installation.apply': 'installationsdialog_msg2',
|
||||
'mode.currentSpeed': 'guimainmemu_msg15',
|
||||
'menu.showDesktop': 'guievents_msg3',
|
||||
'mode.documentation': 'guimainmemu_msg14',
|
||||
'docu.text': 'docusidebar_msg1',
|
||||
'docu.rectangle': 'docusidebar_msg3',
|
||||
'docu.fontSize': 'guievents_msg15',
|
||||
'docu.sizeNormal': 'guievents_msg16',
|
||||
'docu.sizeLarge': 'guievents_msg17',
|
||||
'docu.sizeVeryLarge': 'guievents_msg18',
|
||||
'docu.fontStyle': 'guievents_msg14',
|
||||
'docu.stylePlain': 'guievents_msg19',
|
||||
'docu.styleItalic': 'guievents_msg20',
|
||||
'docu.styleBold': 'guievents_msg21',
|
||||
'docu.color': 'guievents_msg13',
|
||||
'capture.title': 'lauscherdialog_msg1',
|
||||
'capture.number': 'rp_lauscher_msg1',
|
||||
'capture.time': 'rp_lauscher_msg2',
|
||||
|
||||
Reference in New Issue
Block a user