From 63273916b552efa6f7689885f62f3b1af74c2cf5 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 3 Oct 2026 09:05:25 +0000 Subject: [PATCH] feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's default hardware icons, the device button of JSidebarButton, cables as JCablePanel's quadratic curve between the buttons' middles, wireless cables dashed, documentation items below. The surface moves with mouse and finger (the pointer captured only once it moved) and zooms with Ctrl and the wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows all. German and English catalogs with Java's device names, the dashboard's colour tokens, the bridge with its origin check. packages/doc gets the drawing's light view of a document. Java's Switch.cloud (the Internet in the examples) was lost on reading; it is now read, kept in the model and written, with FlsOracle and the corpus, written and exported checks renewed. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 15 + README.md | 2 +- corpus/corpus.java.json | 41 + package-lock.json | 723 ++++++++++++++++++ package.json | 2 +- packages/core/src/runtime/config.ts | 2 + packages/doc/src/index.ts | 1 + packages/doc/src/view.ts | 117 +++ packages/doc/test/view.test.ts | 72 ++ packages/editor/browser/canvas.spec.ts | 237 ++++++ packages/editor/index.html | 12 + packages/editor/package.json | 27 + packages/editor/src/Editor.tsx | 24 + packages/editor/src/assets/hardware/cloud.png | Bin 0 -> 7646 bytes .../editor/src/assets/hardware/gateway.png | Bin 0 -> 13675 bytes .../editor/src/assets/hardware/laptop.png | Bin 0 -> 8685 bytes packages/editor/src/assets/hardware/modem.png | Bin 0 -> 3804 bytes .../editor/src/assets/hardware/router.png | Bin 0 -> 4202 bytes .../editor/src/assets/hardware/server.png | Bin 0 -> 3878 bytes .../editor/src/assets/hardware/switch.png | Bin 0 -> 4165 bytes packages/editor/src/bridge/dashboardBridge.ts | 50 ++ packages/editor/src/canvas/Canvas.tsx | 232 ++++++ packages/editor/src/canvas/ZoomBar.tsx | 52 ++ packages/editor/src/canvas/icons.ts | 13 + packages/editor/src/canvas/layout.ts | 111 +++ packages/editor/src/canvas/viewport.ts | 99 +++ packages/editor/src/i18n/de.json | 18 + packages/editor/src/i18n/en.json | 18 + packages/editor/src/i18n/i18n.tsx | 30 + packages/editor/src/main.tsx | 98 +++ packages/editor/src/theme/tokens.css | 74 ++ packages/editor/src/vite-env.d.ts | 1 + packages/editor/test/bridge.test.ts | 53 ++ packages/editor/test/i18n.test.ts | 58 ++ packages/editor/test/layout.test.ts | 58 ++ packages/editor/test/tsconfig.json | 15 + packages/editor/test/viewport.test.ts | 59 ++ packages/editor/tsconfig.json | 15 + packages/editor/vite.config.ts | 13 + packages/fls/src/exportProject.ts | 1 + packages/fls/src/importProject.ts | 1 + packages/fls/src/topology.ts | 3 + packages/fls/src/topologyWriter.ts | 5 +- packages/fls/test/exported.java.json | 46 +- packages/fls/test/importProject.test.ts | 1 + packages/fls/test/topology.test.ts | 1 + packages/fls/test/written.java.json | 46 +- scripts/code_language_allow.txt | 2 + tools/java-oracle/src/FlsOracle.java | 2 + 49 files changed, 2442 insertions(+), 8 deletions(-) create mode 100644 packages/doc/src/view.ts create mode 100644 packages/doc/test/view.test.ts create mode 100644 packages/editor/browser/canvas.spec.ts create mode 100644 packages/editor/index.html create mode 100644 packages/editor/package.json create mode 100644 packages/editor/src/Editor.tsx create mode 100644 packages/editor/src/assets/hardware/cloud.png create mode 100644 packages/editor/src/assets/hardware/gateway.png create mode 100644 packages/editor/src/assets/hardware/laptop.png create mode 100644 packages/editor/src/assets/hardware/modem.png create mode 100644 packages/editor/src/assets/hardware/router.png create mode 100644 packages/editor/src/assets/hardware/server.png create mode 100644 packages/editor/src/assets/hardware/switch.png create mode 100644 packages/editor/src/bridge/dashboardBridge.ts create mode 100644 packages/editor/src/canvas/Canvas.tsx create mode 100644 packages/editor/src/canvas/ZoomBar.tsx create mode 100644 packages/editor/src/canvas/icons.ts create mode 100644 packages/editor/src/canvas/layout.ts create mode 100644 packages/editor/src/canvas/viewport.ts create mode 100644 packages/editor/src/i18n/de.json create mode 100644 packages/editor/src/i18n/en.json create mode 100644 packages/editor/src/i18n/i18n.tsx create mode 100644 packages/editor/src/main.tsx create mode 100644 packages/editor/src/theme/tokens.css create mode 100644 packages/editor/src/vite-env.d.ts create mode 100644 packages/editor/test/bridge.test.ts create mode 100644 packages/editor/test/i18n.test.ts create mode 100644 packages/editor/test/layout.test.ts create mode 100644 packages/editor/test/tsconfig.json create mode 100644 packages/editor/test/viewport.test.ts create mode 100644 packages/editor/tsconfig.json create mode 100644 packages/editor/vite.config.ts diff --git a/CHANGES.md b/CHANGES.md index 153d139..f503919 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -96,6 +96,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/projectSnapshot.ts` | Swift `TopologyNetworkRuntimeTopologySnapshot(editorState:)` | neu (Abnahme F2): das importierte Projekt als Schnappschuss des Kerns; nur die Kabel verbinden (Swifts Verbindung über die SSID nicht übernommen), nur eingeschaltete DNS-Server laufen; seit F3.1 jeder installierte Echo-Server (ein oder aus, er lässt sich zur Laufzeit schalten) und einfache Client; seit F3.2a jeder Webserver und Browser und die Dateisysteme; seit F3.2c der Rückweg `projectWithSimulationChanges`: die Firewalls aus dem Schnappschuss des Laufs ins Projekt, wie Java sie beim Speichern hat; seit F3.3a die installierten E-Mail-Server (ein oder aus) und auf dem Rückweg ihre Postfächer; seit F3.3b die installierten E-Mail-Programme und auf dem Rückweg ihre Ordner; seit F3.4a die Knoten mit der Befehlszeile (ihre Dateien sind die des Projekts) | | `test/examplesJava.test.ts`, `test/examples/*.java.json` | — | neu (Abnahme F2): fünf Beispiele von Filius (`internetwork_komplett`, `einfaches_rechnernetz_komplett`, `dns_server`, `nat_with_mapped_port`, `Internet_Komplett_…_DE`) mit dem eigenen Leser eingelesen und im Kern gespielt, gegen Java-Filius (`ExampleRun`): je Karte Frames, Zeiten und Tabellenzeilen, die Aufrufe, die Adressen am Ende; gleichzeitig startende DHCP-Clients je Client; zwei benannte Java-Fehler (`JAVA_BUGS`: Ping ohne Gateway wirft eine `NullPointerException`; NAT-Eintrag nur für die erste Gegenstelle) | | `test/classicsJava.test.ts`, `test/exampleRun.ts`, `test/examples/{web,email,gnutella}.java.json` | — | neu (Abnahme F3): die Unterrichtsklassiker auf Beispielen von Filius gegen Java-Filius (`ExampleRun`): Webseite über ihren DNS-Namen (`internetwork_komplett`), E-Mail zwischen zwei Netzen durch NAT und Weitergabe von Server zu Server (`Internet_Komplett_…_DE`), Datei per Gnutella über einen Router finden und holen, Ping, Pong und Query weitergereicht (`zwei_rechnernetze_komplett`, Programme vom Test eingesetzt); je Karte Frames, Zeiten und Tabellenzeilen (ohne RIP), die Zeilen jedes Programms, Mails, Teilnehmer, Ergebnisse und Dateien am Ende; Javas Fehler aus F3.6b und `closedPort` benannt (`JAVA_BUGS`); gleich lange RIP-Wege nach Javas Wahl (`equalRipTies`). `exampleRun.ts`: was beide Beispiel-Tests teilen (Datei öffnen, Javas Namen, Karten nach MAC) | +| `src/topology.ts`, `src/importProject.ts`, `src/exportProject.ts`, `src/topologyWriter.ts` (Wolke) | Java `Switch.cloud`, `JSwitchKonfiguration` | nachgetragen (F4.3): ein Switch, als Wolke gezeichnet, wird gelesen, im Modell gehalten (`SwitchConfiguration.showsAsCloud`) und wieder geschrieben — vorher ging die Eigenschaft beim Lesen verloren (`nat.fls`, `man-in-the-middle.fls`); `FlsOracle` schreibt sie, Korpus und Schreib-/Exportproben neu geprüft | | `src/topologyWriter.ts` | Java `SzenarioVerwaltung.netzwerkSpeichern`, Konstruktoren von `NetzwerkInterface`, `Port`, `GUIKabelItem` und der Systemsoftware | neu nach Java: `konfiguration.xml` aus Javas Sicht — Geräte, Schnittstellen (vom Konstruktor angelegte über Getter, weitere per `add`), Ports über Getter-Verweise, Kabel samt Zeichen-Enden (`kabelpanel`), Doku-Elemente; Systemsoftware über ihre Setter: SSID, Lernzeit, RIP, manuelle Routen, DHCP-Client und -Server, statische NAT, Modem, Router-Firewall (über das `konfig`-Plug-in des Webservers, wie XMLEncoder sie erreicht); eigene Anwendungen eines Projekts, wie die Datei sie hatte; Dateisysteme (an die Wurzel des Konstruktors angehängt wie bei XMLEncoder, beim Router hinter dessen Vorlagen); installierte Programme nach den Dateien (Webserver, Tauschbörse und E-Mail-Server legen ihre Ordner nur an, wo sie fehlen; der DNS-Server liest `/dns/hosts` beim Installieren), je unter ihrem Schlüssel mit der Systemsoftware wie `installApp`, samt Einstellungen und persönlicher Firewall; E-Mail: Domain und Bean-Konten des Servers samt Postfächern, Ordner und Adressbuch des Clients (sein Konto steht in `/konten.txt`, einer Datei); Javas Fehler nicht übernommen: ein Empfänger, den eine ältere Datei als Zeichenkette hält, wird als der `AddressEntry` geschrieben, den Javas `AddressEntry(String)` daraus macht (Java legt die Zeichenkette ungeprüft in die typisierte Liste, sein E-Mail-Fenster scheitert daran), ein `null`-Empfänger fällt weg; Kopf „Filius version: 2.14.0 (mdg-filius)“; seit der Abnahme F2 `ipForwardingEnabled`, wo es vom Standard der Klasse abweicht | | `test/topologyWriter.test.ts`, `test/written.java.json` | — | neu: alle 23 Korpus-Dateien gelesen und neu geschrieben, von Java geladen — Topologie, Systemsoftware, Dateisysteme und Programme samt E-Mail wie beim Original (bis auf die benannte Abweichung bei Empfängern als Zeichenkette, nur in `man-in-the-middle.fls`) (Java gegen Java); dazu eine künstliche Topologie mit dem, was der Korpus nicht hat (u. a. eingeschaltete Router-Firewall mit Regeln jedes Protokolls, alle Firewall-Schalter abseits der Vorgabe, Modem mit eigenem Port und Ziel, verschachtelter Dateibaum mit umbenannter Wurzel, namenlosem Ordner, Datei ohne Typ und Inhalt mit CR/CRLF und Zeichen jenseits der BMP, Heimrouter mit eigener Wurzel, jedes Filius-Programm mit Einstellungen abseits der Vorgabe samt persönlicher Firewall, von Java angelegte Ordner `/peer2peer` und `/mailserver`, E-Mail-Server und -Client mit Konto, Postfach, drei Ordnern, Adressbuch und Empfängern aller Formen) | | `src/exportProject.ts` | Gegenstück zu `importProject.ts`; `saveProjectArchive` schreibt eine ganze `.fls` samt der übrigen Archiveinträge (Gegenstück: `openProjectArchive` in `importProject.ts`, das sie in `archiveEntries` des Modells legt); Java `JHostKonfiguration.updateWifiConnection`, `InternetKnotenBetriebssystem.getStandardGateway`/`setStandardGateway` | neu: Editor-Modell → Javas Sicht für den Schreiber, Schritt X1 Topologie — Geräte, Schnittstellen (MAC wie in der Simulation: aus der Datei oder aus der Port-ID abgeleitet), Kabel samt Funk (Karte drahtlos und Switch, wie Java verbindet), Gateway und DNS an allen Schnittstellen wie Javas eigene Setter, Doku-Elemente; X2 Systemsoftware — SSIDs, Lernzeit, Routen, RIP, DHCP-Client und -Server, Portweiterleitung, Firewall (Namen des Kerns als Javas Zahlen), Modems aus den Paaren des Modells (Port aus dem Paarnamen, der erste eines Paars dient, Ziel `localhost`); X3 Dateien von Rechner und Notebook — Typ aus der Inhaltsart (Text nach Endung, sonst `text`; Bild und Binärdaten nach Medientyp), Bilder und Binärdaten in Base64 wie Filius' `Base64.encodeBytes` (Umbruch nach 76 Zeichen, an 19 Längen mit Java gemessen); X4a Programme — je Programm die Filius-Klasse unter ihrem Namen als Schlüssel, eingeschaltete Server, Ports, virtuelle Hosts, DNS-Rekursion, Ziel des Clients (leer als `null` wie Javas Konstruktor), Teilnehmerzahl, persönliche Firewall; X4b E-Mail — Beans des Servers (Domain, Konten, Postfächer) und des Clients (drei Ordner, Adressbuch), `/konten.txt` und `/mailserver/konten.txt` aus den Konten des Modells neu geschrieben im Format von Filius 2.14 (`EmailKonto.toString`, `listeBenutzerkontenZuString`), ohne Javas Fehler (leere Felder statt "null"); ein Zeichen, das ein Feld im Format nicht tragen kann, wird dort zum Leerzeichen, mit Warnung; seit der Abnahme F2 die IP-Weiterleitung aus dem Modell | @@ -117,6 +118,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/values.ts` | — | neu (F4.1): Einstellungen als `Y.Map` je Feld und Listen als `Y.Array`, damit gleichzeitige Änderungen verschiedener Felder und Zeilen beide bleiben | | `src/janitor.ts` | Plan §5.3 | neu (F4.1): der Hausmeister, I1 (Kabel ins Leere), I2 (zwei Kabel an einem Port: kleinere Kennung bleibt), I4 (fehlender Ordner, ohne Rücksicht auf Groß-/Kleinschreibung); deterministisch, idempotent, Ursprung `janitor` | | `src/snapshot.ts` | — | neu (F4.1): Schnappschuss des Kerns und `.fls`-Export eines Dokuments, beide über das Projekt (`projectSnapshot`, `saveProjectArchive`) | +| `src/view.ts` | Java `Host.holeAnzeigeName`; `exportProject.joinsWirelessly` | neu (F4.3): die Zeichensicht ohne Dateisysteme — Geräte mit Javas Beschriftung und Wolke, Kabel mit Funk-Kennung, Doku-Elemente in der Zeichenfolge des Modells | | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | | `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien | @@ -136,6 +138,19 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/room.test.ts`, `test/localSimHost.test.ts` | — | neu (F4.2): der Raum gegen die von Hand gesteuerte Engine (dieselben Rahmen, dieselben Zeilen), Abonnieren, Geschwindigkeit, Nachholen, Fehler; Host und Worker-Seite über einen `MessageChannel` | | `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | +## packages/editor + +| Datei | Quelle | Art der Übertragung | +| --- | --- | --- | +| `src/assets/hardware/*.png` | Java-Filius `src/main/resources/gfx/hardware/` (GPL) | unverändert kopiert: Javas Standardsymbole (`IconMode.DEFAULT`), damit Arbeitsblätter passen | +| `src/canvas/viewport.ts` | Dashboard `frontend/src/apps/kanban/zoom.js` (Rechte bei uns, Plan §7.1) | kopiert und umgebaut (F4.3): Grenzen, Stufen, Einpassen, Fingerrechnung; statt Rollposition ein Ausschnitt aus Maßstab und Versatz | +| `src/canvas/layout.ts` | Java `JSidebarButton.getWidth/getHeight`, `JCablePanel.updateBounds/paintComponent`, `IconMode`, `Host.holeAnzeigeName` | neu nach Java (F4.3): Knopfgröße, Symbol oben mittig, Kabel als Javas quadratische Kurve zwischen den Knopfmitten (ganzzahlig wie Java); Beschriftungsschrift nach den Maßen der Dateien ab Java 2.5 | +| `src/canvas/Canvas.tsx`, `src/canvas/ZoomBar.tsx`, `src/canvas/icons.ts`, `src/Editor.tsx`, `src/main.tsx` | Plan §7.1, §7.2 | neu (F4.3): die Fläche (SVG) mit Geräten, Kabeln (Funk gestrichelt), Doku-Elementen; Verschieben (Zeiger erst bei Bewegung gefangen), Strg+Rad und Trackpad-Pinch, zwei Finger, Zoomleiste mit 44-px-Zielen; Entwicklungsseite mit Beispielen | +| `src/i18n/*` | Java `MessagesBundle_de_DE`, `_en_GB` | neu (F4.3): Kataloge de/en mit englischen Schlüsseln, Javas Wörter für Geräte (M11) | +| `src/theme/tokens.css` | Dashboard `frontend/src/index.css` (semantische Token) | neu (F4.3): dieselben Namen und Werte, hell und dunkel; dazu die Farben der Fläche nach Java (`JCablePanel`) | +| `src/bridge/dashboardBridge.ts` | Dashboard `frontend/src/apps/scratch/lib/bridge.js` (Muster) | neu (F4.3): Version, Namen, Herkunftsprüfung; `setLocale`, `setTheme`, `ready` | +| `test/*.test.ts`, `browser/canvas.spec.ts` | — | neu (F4.3): Rechnung von Ausschnitt und Java-Anordnung, Kataloge gegen Javas Bündel, Brücke; im Browser jedes Beispiel gegen die in der Datei gespeicherten Knopfmaße (ab Java 2.5), Gesten mit Maus, Rad und zwei Fingern (CDP), Sprache und Dunkel | + ## tools/swift-oracle Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei diff --git a/README.md b/README.md index b0a074a..11fc473 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`). | `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister | | `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) | | `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | -| `packages/editor` | Oberfläche im Iframe des Dashboards *(folgt)* | +| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): bisher die Fläche; `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | | `server` | Filius-Dienst auf Server F *(folgt)* | Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. diff --git a/corpus/corpus.java.json b/corpus/corpus.java.json index 7049298..c0a3399 100644 --- a/corpus/corpus.java.json +++ b/corpus/corpus.java.json @@ -417,6 +417,7 @@ 99, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -813,6 +814,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -1109,6 +1111,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -1407,6 +1410,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -1448,6 +1452,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -2000,6 +2005,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -3527,6 +3533,7 @@ 110, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -3831,6 +3838,7 @@ 143, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -4741,6 +4749,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -6322,6 +6331,7 @@ 110, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -6620,6 +6630,7 @@ 143, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -7749,6 +7760,7 @@ 99, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -8247,6 +8259,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -8527,6 +8540,7 @@ 121, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -8823,6 +8837,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -9119,6 +9134,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -9733,6 +9749,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -9946,6 +9963,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -9987,6 +10005,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -10607,6 +10626,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -11089,6 +11109,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -11130,6 +11151,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -11420,6 +11442,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -11820,6 +11843,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -12697,6 +12721,7 @@ 79, 19 ], + "cloud": true, "ports": [ {}, {}, @@ -13655,6 +13680,7 @@ 121, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -13967,6 +13993,7 @@ 79, 19 ], + "cloud": true, "ports": [ {}, {}, @@ -14067,6 +14094,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -14108,6 +14136,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -14779,6 +14808,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -15035,6 +15065,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -15500,6 +15531,7 @@ 121, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -15748,6 +15780,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -16114,6 +16147,7 @@ 155, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -16359,6 +16393,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -17330,6 +17365,7 @@ 68, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -17413,6 +17449,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -17454,6 +17491,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -18239,6 +18277,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -18452,6 +18491,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, @@ -18493,6 +18533,7 @@ 80, 19 ], + "cloud": false, "ports": [ {}, {}, diff --git a/package-lock.json b/package-lock.json index c675821..b375f9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,295 @@ "node": ">=20" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -462,6 +751,38 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -469,6 +790,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mdg-filius/core": { "resolved": "packages/core", "link": true @@ -477,6 +809,10 @@ "resolved": "packages/doc", "link": true }, + "node_modules/@mdg-filius/editor": { + "resolved": "packages/editor", + "link": true + }, "node_modules/@mdg-filius/fls": { "resolved": "packages/fls", "link": true @@ -522,6 +858,13 @@ "node": ">=20" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", @@ -872,6 +1215,51 @@ "win32" ] }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -907,6 +1295,55 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1032,6 +1469,53 @@ "node": ">=12" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1042,6 +1526,27 @@ "node": ">=8" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1069,6 +1574,20 @@ "node": ">= 16" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1097,6 +1616,13 @@ "node": ">=6" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1146,6 +1672,16 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1205,6 +1741,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", @@ -1222,6 +1768,32 @@ "dev": true, "license": "MIT" }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/lib0": { "version": "0.2.119", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", @@ -1243,6 +1815,24 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1250,6 +1840,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1286,6 +1886,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1399,6 +2009,41 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.63.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", @@ -1457,6 +2102,25 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1583,6 +2247,37 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1777,6 +2472,13 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yjs": { "version": "13.6.33", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", @@ -1809,6 +2511,27 @@ "yjs": "^13.6.33" } }, + "packages/editor": { + "name": "@mdg-filius/editor", + "version": "0.0.0", + "license": "GPL-3.0-only", + "dependencies": { + "@mdg-filius/core": "0.0.0", + "@mdg-filius/doc": "0.0.0", + "@mdg-filius/fls": "0.0.0", + "@mdg-filius/protocol": "0.0.0", + "@mdg-filius/sim": "0.0.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "yjs": "^13.6.33" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.2.0", + "vite": "^7.3.6" + } + }, "packages/fls": { "name": "@mdg-filius/fls", "version": "0.0.0", diff --git a/package.json b/package.json index 220ed3c..f82510a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test && tsc -p packages/protocol/test && tsc -p packages/sim/test && tsc -p packages/sim/browser", + "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test && tsc -p packages/protocol/test && tsc -p packages/sim/test && tsc -p packages/sim/browser && tsc -p packages/editor && tsc -p packages/editor/test", "test:browser": "playwright test", "check": "npm run typecheck && npm run test", "hooks": "git config core.hooksPath scripts/hooks", diff --git a/packages/core/src/runtime/config.ts b/packages/core/src/runtime/config.ts index dd83186..bf04e9c 100644 --- a/packages/core/src/runtime/config.ts +++ b/packages/core/src/runtime/config.ts @@ -66,6 +66,8 @@ export function bestMatchingManualRoute( export interface SwitchConfiguration { readonly ssid: string; readonly retentionTimeMilliseconds: number; + /** Drawn as a cloud (Java's Switch.cloud, the Internet in the examples); only set when true. The simulation ignores it. */ + readonly showsAsCloud?: boolean; } /** Java SwitchFirmware keeps a learned MAC address for five minutes. */ diff --git a/packages/doc/src/index.ts b/packages/doc/src/index.ts index b6f63fe..5b7101c 100644 --- a/packages/doc/src/index.ts +++ b/packages/doc/src/index.ts @@ -3,3 +3,4 @@ export * from './janitor.js'; export * from './projectDoc.js'; export * from './snapshot.js'; export * from './values.js'; +export * from './view.js'; diff --git a/packages/doc/src/view.ts b/packages/doc/src/view.ts new file mode 100644 index 0000000..3912e09 --- /dev/null +++ b/packages/doc/src/view.ts @@ -0,0 +1,117 @@ +// What the editor draws, read straight from the document: the devices with +// their labels, the cables, the documentation items. Lighter than +// readProject (no file systems, no settings beyond the few a drawing needs), +// so that it can run on every change while someone drags a device. + +import * as Y from 'yjs'; +import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core'; +import { inOrder, ROOTS } from './projectDoc.js'; + +export interface NodeView { + readonly id: string; + readonly kind: NodeKind; + readonly name: string; + /** What Java writes under the icon (Host.holeAnzeigeName): the name, or the first card's address. */ + readonly label: string; + /** Java's position of the device's button: its top left corner. */ + readonly x: number; + readonly y: number; + /** A switch drawn as a cloud. */ + readonly cloud: boolean; + readonly ports: readonly { readonly id: string; readonly label: string }[]; +} + +export interface LinkView { + readonly id: string; + readonly sourceNode: string; + readonly sourcePort: string; + readonly targetNode: string; + readonly targetPort: string; + /** A host's wireless card joining a switch: Java draws it dashed and grey. */ + readonly wireless: boolean; +} + +export interface DocumentationView { + readonly id: string; + readonly kind: DocumentationItemKind; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly text: string; + /** Components 0 to 1. */ + readonly color: { readonly red: number; readonly green: number; readonly blue: number; readonly alpha: number }; + readonly font: string; + readonly size: number; + readonly bold: boolean; + readonly order: number; +} + +export interface TopologyView { + readonly nodes: readonly NodeView[]; + readonly links: readonly LinkView[]; + readonly docs: readonly DocumentationView[]; +} + +const settingsOf = (node: Y.Map) => node.get('settings') as Y.Map> | undefined; + +/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks. */ +function labelOf(node: Y.Map, kind: NodeKind, name: string): string { + const policy = node.get('labelPolicy') as HostLabelPolicy; + if ((kind !== 'pc' && kind !== 'notebook') || policy === 'manual') return name; + const ip = (settingsOf(node)?.get('device')?.get('ipAddress') as string | undefined) ?? ''; + const port = (node.get('ports') as Y.Array>).get(0); + const mac = port === undefined ? '' : ((port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(port.get('id') as string)); + if (policy === 'ipAddress') return ip; + if (policy === 'macAddress') return mac; + return `${ip} (${mac})`; +} + +/** The drawing of the document, in the order of its devices and cables. */ +export function topologyView(doc: Y.Doc): TopologyView { + const kinds = new Map(); + const wirelessHosts = new Set(); + const nodes = inOrder(doc.getMap>(ROOTS.nodes)).map(([id, node]): NodeView => { + const kind = node.get('kind') as NodeKind; + const name = node.get('name') as string; + kinds.set(id, kind); + if (settingsOf(node)?.get('wireless')?.get('isEnabled') === true && (kind === 'pc' || kind === 'notebook')) wirelessHosts.add(id); + return { + id, + kind, + name, + label: labelOf(node, kind, name), + x: node.get('x') as number, + y: node.get('y') as number, + cloud: kind === 'networkSwitch' && settingsOf(node)?.get('switch')?.get('showsAsCloud') === true, + ports: (node.get('ports') as Y.Array>).toArray().map((port) => ({ id: port.get('id') as string, label: port.get('label') as string })), + }; + }); + // exportProject's joinsWirelessly: a host with a wireless card and a switch. + const wireless = (first: string, second: string) => (wirelessHosts.has(first) && kinds.get(second) === 'networkSwitch') || (wirelessHosts.has(second) && kinds.get(first) === 'networkSwitch'); + const links = inOrder(doc.getMap>(ROOTS.links)).map(([id, link]): LinkView => { + const sourceNode = link.get('sourceNode') as string; + const targetNode = link.get('targetNode') as string; + return { id, sourceNode, sourcePort: link.get('sourcePort') as string, targetNode, targetPort: link.get('targetPort') as string, wireless: wireless(sourceNode, targetNode) }; + }); + const docs = [...doc.getMap>(ROOTS.docs).entries()].map( + ([id, item]): DocumentationView => ({ + id, + kind: item.get('kind') as DocumentationItemKind, + x: item.get('x') as number, + y: item.get('y') as number, + width: item.get('width') as number, + height: item.get('height') as number, + text: (item.get('text') as Y.Text).toString(), + color: { red: item.get('red') as number, green: item.get('green') as number, blue: item.get('blue') as number, alpha: item.get('alpha') as number }, + font: item.get('font') as string, + size: item.get('size') as number, + bold: item.get('bold') as boolean, + order: item.get('order') as number, + }), + ); + // The model's render order (inDeterministicRenderOrder): rectangles below texts, then by order, then by id. + const layer = (item: DocumentationView) => (item.kind === 'rectangle' ? 0 : 1); + docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id)); + return { nodes, links, docs }; +} diff --git a/packages/doc/test/view.test.ts b/packages/doc/test/view.test.ts new file mode 100644 index 0000000..2fd2957 --- /dev/null +++ b/packages/doc/test/view.test.ts @@ -0,0 +1,72 @@ +// The drawing of a document against the project it holds, for every file of +// the corpus: the same devices in the same order at the same places, Java's +// labels, the cables as the export draws them (wireless or not), the +// documentation items in the model's render order. + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { stableGeneratedMacAddress } from '@mdg-filius/core'; +import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls'; +import { readProject, topologyView, writeProject } from '../src/index.js'; + +const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); +const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) => + readdirSync(join(corpus, folder)) + .filter((name) => name.endsWith('.fls')) + .map((name) => join(folder, name)), +); + +function docOf(file: string, change: (project: ImportedProject) => void = () => {}): Y.Doc { + let counter = 0; + const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(0xffff_ffff - ++counter).toString(16).padStart(12, '0')}`); + change(project); + const doc = new Y.Doc(); + writeProject(doc, project); + return doc; +} + +describe('the drawing of a document', () => { + for (const file of files) { + it(file, () => { + const doc = docOf(file); + const project = readProject(doc); + const view = topologyView(doc); + expect(view.nodes.map((node) => [node.id, node.kind, node.name, node.x, node.y])).toEqual( + project.graph.nodes.map((node) => [node.id, node.kind, node.displayName, node.position.x, node.position.y]), + ); + expect(view.nodes.map((node) => node.ports)).toEqual(project.graph.nodes.map((node) => node.ports.map((port) => ({ id: port.id, label: port.label })))); + expect(view.links.map((link) => [link.id, link.sourceNode, link.sourcePort, link.targetNode, link.targetPort])).toEqual( + project.graph.links.map((link) => [link.id, link.sourceNodeId, link.sourcePortId, link.targetNodeId, link.targetPortId]), + ); + expect(view.links.map((link) => link.wireless)).toEqual(exportProject(project).topology.links.map((link) => link.wireless)); + expect(view.docs.map(({ id, kind, x, y, width, height, text, color, font, size, bold, order }) => ({ id, kind, frame: { x, y, width, height }, text, color, fontName: font, fontSize: size, isBold: bold, order }))).toEqual(project.documentationItems); + }); + } + + it('draws the Internet of the NAT example as a cloud', () => { + const view = topologyView(docOf('java-examples/nat.fls')); + expect(view.nodes.filter((node) => node.cloud).map((node) => node.name)).toEqual(['Internet']); + }); + + it("writes a host's label as Java does: its name, address, MAC address or both", () => { + const labels = (policy: 'manual' | 'ipAddress' | 'macAddress' | 'ipAndMAC') => { + const doc = docOf('java-examples/zwei_rechnernetze_komplett.fls', (project) => { + for (const node of project.graph.nodes) node.hostLabelPolicy = policy; + }); + const project = readProject(doc); + const notebook = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!; + const view = topologyView(doc); + return { label: view.nodes.find((node) => node.id === notebook.id)!.label, mac: notebook.ports[0]!.importedMacAddress ?? stableGeneratedMacAddress(notebook.ports[0]!.id), switchLabel: view.nodes.find((node) => node.kind === 'networkSwitch')!.label }; + }; + expect(labels('manual').label).toBe('Notebook 1.10'); + expect(labels('ipAddress').label).toBe('141.99.1.10'); + const { label, mac, switchLabel } = labels('ipAndMAC'); + expect(label).toBe(`141.99.1.10 (${mac})`); + expect(labels('macAddress').label).toBe(mac); + // Only hosts show an address (Host.holeAnzeigeName). + expect(switchLabel).toBe('Switch 1'); + }); +}); diff --git a/packages/editor/browser/canvas.spec.ts b/packages/editor/browser/canvas.spec.ts new file mode 100644 index 0000000..9d3b42b --- /dev/null +++ b/packages/editor/browser/canvas.spec.ts @@ -0,0 +1,237 @@ +// The editor's surface in Chromium (Playwright), on its built page. +// +// - Every FILIUS example drawn: its devices and cables, each device as large +// as Java made its button when it saved the file — the bounds in the files +// saved by Java 2.5 and later (before, the button's width left the label +// out, and older ones had other borders); the height within the font +// heights of the saving machines (15 to 17), the width where the label is +// wider than the icon within 16 % (the machines' fonts differ). +// - The gestures: the wheel with Ctrl zooms around the pointer, a drag moves +// the surface, a press without moving does nothing, the buttons step and fit. +// - English and dark. + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test, type Page } from '@playwright/test'; +import { build, preview, type PreviewServer } from 'vite'; +import { decodeJavaBeans, isJavaObject, listItems, readFlsArchive } from '@mdg-filius/fls'; +import { ICONS, iconOf } from '../src/canvas/layout.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const config = join(here, '../vite.config.ts'); +const examples = join(here, '../../../corpus/java-examples'); + +let server: PreviewServer; +let base: string; + +test.beforeAll(async () => { + await build({ configFile: config, logLevel: 'silent' }); + server = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' }); + base = server.resolvedUrls!.local[0]!; +}); + +test.afterAll(async () => { + await new Promise((resolve) => server.httpServer.close(() => resolve())); +}); + +/** The width and height Java gave each device's button when it saved the file, in the file's order. */ +function savedBounds(file: string): { width: number; height: number; javaClass: string; cloud: boolean }[] { + const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(join(examples, file)))).configurationXml); + const nodes = listItems(values[typeof values[0] === 'string' ? 1 : 0]); + return nodes.flatMap((item) => { + if (!isJavaObject(item)) return []; + const label = item.properties.get('imageLabel'); + const device = item.properties.get('knoten'); + const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined; + if (!isJavaObject(bounds) || !isJavaObject(device)) return []; + const field = (name: string, index: number) => (bounds.properties.get(name) ?? bounds.args[index]) as number; + return [{ width: field('width', 2), height: field('height', 3), javaClass: device.className.slice(device.className.lastIndexOf('.') + 1), cloud: device.properties.get('cloud') === true }]; + }); +} + +/** The version line Java wrote at the file's start. */ +function savedVersion(file: string): string | undefined { + const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(join(examples, file)))).configurationXml); + return typeof values[0] === 'string' ? values[0] : undefined; +} + +async function open(page: Page, example: string, query = '') { + await page.goto(`${base}?example=${example}${query}`); + await page.waitForSelector('g.device'); +} + +const KIND_OF_CLASS: Record = { + Rechner: 'pc', + Notebook: 'notebook', + Switch: 'networkSwitch', + Vermittlungsrechner: 'router', + Gateway: 'gateway', + Modem: 'remoteLink', +}; + +test('draws every example with devices as large as Java made them', async ({ page }) => { + const deviations: string[] = []; + let compared = 0; + for (const file of readdirSync(examples).filter((name) => name.endsWith('.fls'))) { + await open(page, file); + const saved = savedBounds(file); + const version = /Filius version: (\d+)\.(\d+)/.exec(savedVersion(file) ?? ''); + const comparable = version !== null && (Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 5)); + const devices = await page.locator('g.device').evaluateAll((elements) => + elements.map((element) => ({ box: element.getAttribute('data-box')!.split(' ').map(Number), label: element.getAttribute('aria-label') })), + ); + expect(devices.length, file).toBe(saved.length); + expect(await page.locator('path[data-link]').count(), file).toBeGreaterThan(0); + for (const [index, { box, label }] of devices.entries()) { + const java = saved[index]!; + if (!comparable) continue; + compared += 1; + const [, , width, height] = box as [number, number, number, number]; + const icon = ICONS[iconOf(KIND_OF_CLASS[java.javaClass]!, java.cloud)]; + // The height: icon plus 5 plus Java's font height on the machine that saved it (15 to 17). + const heightOk = height - java.height >= 0 && height - java.height <= 2; + // The width: exact where the icon is wider than the label; else the label's width, which depends on + // the saving machine's font (Java takes the system's look and feel): within 16 %. + const tolerance = java.width === icon.width + 21 && width === icon.width + 21 ? 0 : Math.max(4, java.width * 0.16); + if (!heightOk || Math.abs(width - java.width) > tolerance) deviations.push(`${file} #${index + 1} ${label}: Java ${java.width}×${java.height}, here ${width}×${height}`); + } + } + expect(compared).toBeGreaterThan(80); + expect(deviations).toEqual([]); +}); + +test('zooms around the pointer with Ctrl and the wheel, and moves with a drag', async ({ page }) => { + await open(page, 'internetwork_komplett.fls'); + const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); + const device = page.locator('g.device').first(); + const before = (await device.boundingBox())!; + const start = await zoom(); + // Zoom in at the device's middle: it stays where it is, and grows. + const point = { x: before.x + before.width / 2, y: before.y + before.height / 2 }; + await page.mouse.move(point.x, point.y); + await page.keyboard.down('Control'); + await page.mouse.wheel(0, -100); + await page.keyboard.up('Control'); + await expect.poll(zoom).toBeGreaterThan(start); + const after = (await device.boundingBox())!; + expect(Math.abs(after.x + after.width / 2 - point.x)).toBeLessThan(1.5); + expect(Math.abs(after.y + after.height / 2 - point.y)).toBeLessThan(1.5); + expect(after.width).toBeGreaterThan(before.width); + + // A press without moving leaves the view; a drag moves it as far as the pointer went. + const surface = (await page.locator('svg.filius-canvas').boundingBox())!; + const empty = { x: surface.x + 20, y: surface.y + surface.height - 20 }; + await page.mouse.move(empty.x, empty.y); + await page.mouse.down(); + await page.mouse.up(); + expect((await device.boundingBox())!.x).toBeCloseTo(after.x, 1); + await page.mouse.move(empty.x, empty.y); + await page.mouse.down(); + await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 6 }); + await page.mouse.up(); + const moved = (await device.boundingBox())!; + expect(moved.x - after.x).toBeCloseTo(60, 0); + expect(moved.y - after.y).toBeCloseTo(-40, 0); +}); + +test("steps with the buttons, shows all, and keeps to Java's limits", async ({ page }) => { + await open(page, 'internetwork_komplett.fls'); + const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); + await page.getByRole('button', { name: 'Vergrößern' }).click(); + expect(await zoom()).toBe(1.25); + for (let press = 0; press < 6; press += 1) await page.getByRole('button', { name: 'Vergrößern' }).click({ force: true }); + expect(await zoom()).toBe(2); + await expect(page.getByRole('button', { name: 'Vergrößern' })).toBeDisabled(); + await page.getByRole('button', { name: 'Alles zeigen' }).click(); + const surface = (await page.locator('svg.filius-canvas').boundingBox())!; + for (const box of await page.locator('g.device').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON() as DOMRect))) { + expect(box.left).toBeGreaterThanOrEqual(surface.x); + expect(box.right).toBeLessThanOrEqual(surface.x + surface.width); + expect(box.top).toBeGreaterThanOrEqual(surface.y); + expect(box.bottom).toBeLessThanOrEqual(surface.y + surface.height); + } + for (let press = 0; press < 8; press += 1) await page.getByRole('button', { name: 'Verkleinern' }).click({ force: true }); + expect(await zoom()).toBe(0.25); +}); + +test('speaks English and goes dark when asked', async ({ page }) => { + await open(page, 'nat.fls', '&lang=en&theme=dark'); + await expect(page.getByRole('img', { name: 'Home Router: Heimrouter' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe('dark'); + await expect(page.getByRole('img', { name: 'Switch / Wi-Fi: Internet' })).toBeVisible(); +}); + +test('zooms with two fingers around their middle, and moves with one', async ({ browser }) => { + const context = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } }); + const page = await context.newPage(); + await open(page, 'internetwork_komplett.fls'); + const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); + const cdp = await context.newCDPSession(page); + const touch = (type: 'touchStart' | 'touchMove' | 'touchEnd', points: readonly [number, number][]) => + cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) }); + const device = page.locator('g.device').nth(4); + const before = (await device.boundingBox())!; + const centre = { x: before.x + before.width / 2, y: before.y + before.height / 2 }; + const start = await zoom(); + // Two fingers 40 px either side of the device, spread to 80. + await touch('touchStart', [ + [centre.x - 40, centre.y], + [centre.x + 40, centre.y], + ]); + for (const distance of [50, 60, 70, 80]) { + await touch('touchMove', [ + [centre.x - distance, centre.y], + [centre.x + distance, centre.y], + ]); + } + await touch('touchEnd', []); + expect(await zoom()).toBeCloseTo(start * 2 > 2 ? 2 : start * 2, 1); + const after = (await device.boundingBox())!; + expect(Math.abs(after.x + after.width / 2 - centre.x)).toBeLessThan(2); + expect(Math.abs(after.y + after.height / 2 - centre.y)).toBeLessThan(2); + + // One finger moves the surface. + await touch('touchStart', [[500, 700]]); + for (const step of [10, 20, 30, 40, 50]) await touch('touchMove', [[500 + step, 700]]); + await touch('touchEnd', []); + expect((await device.boundingBox())!.x - after.x).toBeCloseTo(50, 0); + await context.close(); +}); + +test('moves with the wheel alone, and takes a press that hardly moved for no drag', async ({ page }) => { + await open(page, 'internetwork_komplett.fls'); + const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); + const device = page.locator('g.device').first(); + const before = (await device.boundingBox())!; + const start = await zoom(); + await page.mouse.move(before.x, before.y); + await page.mouse.wheel(30, 50); + await expect.poll(async () => (await device.boundingBox())!.y).toBeCloseTo(before.y - 50, 0); + expect((await device.boundingBox())!.x).toBeCloseTo(before.x - 30, 0); + expect(await zoom()).toBe(start); + + // Two pixels are a press, not a drag (DRAG_THRESHOLD): the surface stays. + const placed = (await device.boundingBox())!; + const surface = (await page.locator('svg.filius-canvas').boundingBox())!; + await page.mouse.move(surface.x + 20, surface.y + surface.height - 20); + await page.mouse.down(); + await page.mouse.move(surface.x + 22, surface.y + surface.height - 20); + await page.mouse.up(); + expect((await device.boundingBox())!.x).toBe(placed.x); +}); + +test('shows all of a large example at first', async ({ page }) => { + await open(page, 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'); + await page.waitForTimeout(100); + const zoom = Number(await page.locator('[data-zoom]').getAttribute('data-zoom')); + expect(zoom).toBeLessThan(1); + const surface = (await page.locator('svg.filius-canvas').boundingBox())!; + for (const box of await page.locator('g.device').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON() as DOMRect))) { + expect(box.left).toBeGreaterThanOrEqual(surface.x); + expect(box.right).toBeLessThanOrEqual(surface.x + surface.width); + expect(box.top).toBeGreaterThanOrEqual(surface.y); + expect(box.bottom).toBeLessThanOrEqual(surface.y + surface.height); + } +}); diff --git a/packages/editor/index.html b/packages/editor/index.html new file mode 100644 index 0000000..18c19eb --- /dev/null +++ b/packages/editor/index.html @@ -0,0 +1,12 @@ + + + + + + Filius + + +
+ + + diff --git a/packages/editor/package.json b/packages/editor/package.json new file mode 100644 index 0000000..b29374a --- /dev/null +++ b/packages/editor/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mdg-filius/editor", + "version": "0.0.0", + "private": true, + "license": "GPL-3.0-only", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@mdg-filius/core": "0.0.0", + "@mdg-filius/doc": "0.0.0", + "@mdg-filius/fls": "0.0.0", + "@mdg-filius/protocol": "0.0.0", + "@mdg-filius/sim": "0.0.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "yjs": "^13.6.33" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.2.0", + "vite": "^7.3.6" + } +} diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx new file mode 100644 index 0000000..59832d2 --- /dev/null +++ b/packages/editor/src/Editor.tsx @@ -0,0 +1,24 @@ +// The editor on a document: for now the surface that draws it (F4.3); the +// tools, the device sheet and the simulation follow (F4.4–F4.8). + +import { useEffect, useState } from 'react'; +import type * as Y from 'yjs'; +import { topologyView, type TopologyView } from '@mdg-filius/doc'; +import { Canvas } from './canvas/Canvas.js'; + +/** The document's drawing, read anew after every change. */ +export function useTopologyView(doc: Y.Doc): TopologyView { + const [view, setView] = useState(() => topologyView(doc)); + useEffect(() => { + setView(topologyView(doc)); + const update = () => setView(topologyView(doc)); + doc.on('update', update); + return () => doc.off('update', update); + }, [doc]); + return view; +} + +export function Editor({ doc }: { readonly doc: Y.Doc }) { + const view = useTopologyView(doc); + return ; +} diff --git a/packages/editor/src/assets/hardware/cloud.png b/packages/editor/src/assets/hardware/cloud.png new file mode 100644 index 0000000000000000000000000000000000000000..a76041639779fa4933055f02f59e85617c7ee12b GIT binary patch literal 7646 zcmV<49UPx#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2ipN1 z6#xZ6xpk@l03BXQL_t(&-o<-)lx1ge-><5^@7}lcGTl8hJ>4_fMw%HJAvPh|k`eOZ zBoI3|7(#4g<2V?EB`hQa0t6-y7Mpz$U+A3KTT0Lelt+D6jM zXf!ju_gn7$sw#hc_q`s0eT-vl-#PDe_w;-Bez$JbZ>jp4@Q-+0e)*O7)nENM0N@q> z?ln05^iwf4)rN!7+w9@k<417auY3#uaLqMuf(;lS`Q-Zm09Rf4mT=mhJ)N>u%t+vA z5Tb|-;k?%CZ>}CadURuc;VixC%9npn)=KzCJ{ES*Xl;R1cT0y85*W0KfdM4@?%V^4VosykLHQ;k1Q?#rDL+L@6PNrsV4z>*?s@hx;q5 zEBBcBAJtX5?N9#fmd!u-;^%Pn)$hPZKl;vp`iyziE8c*~DIubO=l{?z`M>zFC!hX9 zAN*uX82e)pFDQ!Q+)k%G-)gl>B0@DB4tsw}GmzOOUmaclJ7y)(TNBn3|gU;raPp-?w}B zp2=;~(`AfBWF}Hgy1KSDICSW-^`l20|Ety2)$2FbH^aih;>*rG_Zg?n&dkh)7+N7n zWMN?@CJ;N^&0JM=c>LJ0<-6{_^X`?EmCyXqA3l8hAK&nS|L?$rzss91yX@+5P+oua zTVsE)cFII|;;fmO+0!Pv6T8~&_GBnx0ducY-5(4FC)U^39$Q;mdbm!l*}1vh|MKhu zXP>pOxO=KqmJ<@FU=|@J21>L$?Rs{0c5-fRuKmq#-Z9G@zEJ9g~keN}zVZMT2@C6`<>|JWbjfD^BN#bx;HZ+!Y6E-+WW?rr$! z$KKO?|K?dBU2x{!ea~80SlqjF$IjX5>22+HyH$t;0i5f))?TkyFRv`EA3OHMYE{)! zXYSoMw`*acTNXu|BS#SqK{yaGg8(-U%pCXZKBesUhBI}QFgrU}iAZ2(Vc{SmQAC0; z3y26gV+;V?QS4e+m^yX&v~j@vGr!1>pIdr+==)$4KH=RWeiDWx~O?pl2KWAAG+ z<;{!jR_jOh??3ze(@#HRar^ciQ)O9pM7R{;z|0JQ2?U2}N?NV1trh)VzgrZAh7g9` ziLSR=We7oH5RS~kAcvWonrXk+*JDSQN|C@Jgr%}o3}TE$j8cRsMF-{f_>FV$2&~|JI$YcI&5}`Hb@}IQ{g! z^WBNgLjG;d@J?Ujx#2^wmWFS>@)1>wC z(hAmAHz$gsWFoA_A}*I@A+eAm1}P+lA_R#{B<7Bk)KaZl*R|DEN_Cy;2Ohlt=PxTe#zKDj{PVv5 zIcJ`E)~(R4D9m7CCW48GvQS%uJ0j8!Q3_#U5V5|? zz3*^$$I6Q0t6#n8uLs|EzWtZ+j(5Ek0Px9Ae5SL!d}8m)`tb|*?>qZN2lgM>UzEiJ zbIuA8CT5aMoaD?z%tR6d1adQTcZmvvyTjeV%qU`%fm=D~_c1Zi0WmQFFc7OMxp@#K z0^k719c?ouH}1kwfJopV5D2*_oCu(d#sCN`3})_x7zRPaTJ6#&Cc52i+h%4K7Z-OO zKYr}Ad+)jXDTj|7`qOv3{oOYZQSYzKn5$lUExvloSMa49{}(>^{_Bd1FMjTU_4W0a z&dtrfc>lh$FFEVX{ZE~koSKUvbV8I;L`o4UVwAwbQAA=05+#TPk-)+NfZZYPPG$yE zBXcJ+2i$o$ti3FwO9%uI%*=*^p{}p>B1<3#Ioz0tVpF1lC5Q;+0|a*)og3y(rUo;I zyFpda?X>F{BQsO;VkwHEn4FxNn4e#m6Oq%GmY23&e9hoAWIpTkRE@^7Yk{q^ThO-=vI`R8A7{xhF>-szK*)2FnG)=UUNSVWkaM1)0{ zSTb~st}LAYj|l8$tY&O#Waez{Y-(&~Z00;14!N#t2SA$C%*?7`rJEar$js!X1VC6M zNMOkL?ud|cgUtXlgPOwB-OMSMl_RBu?nJvTipb0?M8w3DKTXUr#$w0zo!wSh&YxI5 zUS4$JbMJZgFTK420C~-2uf-=m`C+{2&F{qIM7iCeKe2!R*)O{IITxR?xVUFW5a|y3 z!$}FTXd+H5qW};IGl&R83}yxqsL2#+0zhs~rcRd2iQS#tom7qMx+Ztfa8OY-9NJ(w zwBfLh0I`|piVaLmWGw8XTtT)2Ia|!#%v{YWmoMOMoK$^ks%tS8#7qnjH5FAqbcskz zjolH)6g(>8EL6w=cL%vUR2A+_;_gJ$q`148(YMP3B7%qjfP@gqOu4F!hJ%XgswRZ| zbm1UZhq1xbVWgfKC^4ff?9AcjCf5|V{GTDU{TjN99HchF%SVcX}FOq z)P%~Bvj7Z$+(2%Cn!(M;%%N&D$%O(q_j-fC#6oBs5s?#9Vs3a1 zl%IFLCYd_SjPm(G?#Av;EWpg{G(?1vk=!XOf<`F2A$tmw5V$N`@zCMNRxkd+=iLzi zpg*kk?%#jP3!eIvr|lGxB0tc)0wx|~eP#%!hGE>y$>B^2Ff&{-i&&Ez?hPB6!%Si3 zFn1Una0eU^VK56AmSKZR%BqH$09Q3g z4-pZVh?Fvi5diXx;R$z)~?o9E?P&Q3RTBvo``nas@GVcr~OFf`%;4sRrZJKTkXOiyo{KX&Zc zWC(#LPCxC8{X4hMPvv(~lXdwkGrI`ars^TYai+k{&5ekdgc*PW%5d|OGYsydfcQ9j zMhOGBm#qTKAa?>2V9Y4X0=>-vn1j1HnHyPobrsY4 z+NL$7s06YczYIn>h-jcGpC-iE5ea2zwaTeo^NTx(Ipzp9c-nk01h%4#Rey7+4v&p+ zgQ}6i5hUbv1~&pOGe(XLjRON~O2`01zJU;#%2v?`Ja&SCK`6_D2K^z-l!yf6#Ht!W zP{3W7VHt01#Bz-!ATy_wDx_4W!C=U#Rw9BCvoo`X5S&?*05$W`%W`J9)m5!5tRcqO z>9jjtY?X*Ji<`L%8CUWzjNr^yiY`(>~-f%aOdlr#N6UtV}Aw+^x zPKa8ATEzfJm^Fl$SVY}mW}e*K>XdYCeRYM1HbX}8T$xx0hZ3^4}lPD~(%Kw}y(b5$!+N>Wud4~G?k#5%;7+}#{V zPaJ*RHa6D3<&O2BYW;q%H*kkGjALfTM50CxVkZWaS*oh8DjN(6k;oy$u`V&q4-oEP zBADi;BSvDU!5jx*ZioeAW_A)~tE2`X;7l;fCnO@o7}08%=xq*=G-XL+Aob)8TM}`W zQ*h+cb~R?8+wFL(EVG+xuFTFP;v|8=%&lrz@nAS~s_VK4#jvhxz5o9E)`jCuM~)q? zLehHe=o62hP}4oc#JOMOO74IetWvJ3s;H`}#YCZ2g(lDHD+Gr-jY@`$U~o`_#Y9b_ zV~e^&AWTk8pxtgG8%Knw8cp%#*fU6ic(>c~cDqBd2;S&V zjQE_?EvpCupb!$2WhRTv$OW@GXg#J)Kkys zHl6}ziTk~NSJk$aWzj8)Qeq5bZUlgdI9mWSm>JBCvWT-ryC}@_@4-N;RbXPWgHERd z79I!0bgL!Nh%;DDFJ$tv90+qtNl8<3iITN15m=bYR$<+4$J^~T$0Cq0k%)k^x%H8~ zqVbb!z%G}qZ)~h@+;`u-fAQ#{NB`_=U%N$r{^zd9?)rt#f9dguAN|(C{LWqT(^E60 z!=%^mcinxq)oM)^MZql0EbOW&kP{bWiG0y4?A(p4QP_FS1PO$qi0E`$=ycmC%Mv0C zH#Dt*Q9v^M*+_<&XVpf48@y>%5HpdPqSNVEj1jG}@K(F!?RH6JS#XGfM3^K5l0Xoa zY-~qlkg**OYc!12aNlgT+iNSU%Y)%?c*|FAzH$9)-?$|Q=H|crl0Eb3=N&qJ{E1T+ z7j{oD@syeFY`5D}MG=KXNQ5B5P)($25<@_cKrx6*5MmJuB8WvqQACuj06^V9pdfQqU)>V#^p})QGLzw*iowS0HmEclU0mgR+Q-MMPOd3PlbGGebl?1VPhW z9ocCICM5KnJeB_aXkAL|qH{J~Z{1Y#JB>>>Wg`Yiq-~IRf{-6Bm zA3k_u>A06=s~d}go6g*r7DEVQdrSl{3o$c72q?=EWl^FiO2k+oga~HN;HQS922({! z2G!h1%Doh8OrVik2GYEKr`)!5YW#?Y5S%4v0&|uG1UL~n6OGD-L>MAM*^6a2@x<>Z-T20Dyn@3!ld;f9@CX#mfoT|IaVncF(j0HN$Hg&YrpF%>A>ob0We$VlrZ?0JH%wK^~bo zl&u2&%^|r9TfULeWk$3MhrvcGb_aXT<&lRXZl3K)y{l2HWYpF;nW<8pnua?&HHo#Q zekikijy+5*n|sP@Gc$+hiuH`U?%9Q(e4yK%5F(n%kF_~|>*HSo3-8>s=ag+CL5SH! zq-N$GGjA~SIuUKK&}OII-VA|$3&V*VsQ)PQO(gM4##QYo1lw80rO&2CPWnlc-; zE1`Kb2~7zpC66ImQ@-QKjk^hEKB9j!LrR8}6e+1|t*%McOp}|dxoHOb#%4E5CSnjM zJZXIL*wRf1A#R(QnQJw|slixw5mIKZGmx>z5Ue}d!TRb3t9ijBAkI1po;!C;3~~T$ zx%!D6w`DtKmUERrPjH_fsq@8ZNuBy7~FRjKX zrPCNZGA^4N8gL_k+)fS(k&is)Nr#W2*T$m5>r|VYQrYfib)BmK>F!$!k=bn!xWAQ2$F$58D zz+D~8BMpSHgmqUCF?{qGo2+cw97;q9KpxVJGscpctEp>UCq4SaSpA`S`?AG-S%*GWizC@f`>o>VO)q!U&h@=yp>TpcU(_y z6z0+xu|&oTRS7)obSJ9Kjb5qdtw34Tt2U;p$xb%PL)nv=Z_BQ^nN`eO!ClqeET@f{ zdNNZrb31Y3cv@LmxpVKH{U@F@iM-*)>+$rbKbv9*7oWZVDU+0QK6^3FiRnCM+Ho)0 z%#do04p2&2umO&y>)1qMobS1aMm?Zx@S3TDySl1sQg^FkA^mQ*({HuPI;G@wm16Gn zs`-`fzUQv>m6hfHbnV+;_vm-buJ@g? z_rzm|4nDH7y1cM$+l&yI8NtZNz))5q0x4i)eqyN~q7 zw_(GUUvk$P?tK;>7G+VlJFOH$P&j!}6x!hi6uD9|gwEb!~NJc<|tZcL01h09Y^xm zW@%t*&LUQng|*7IhFGWwC$J}nCo|18?Wzu|nJI-3>KNljr_)(#w>ujl!~sCHswP!U zDOqjisVK@#hpkn0UFGg|#ywRxRr8e3YDx1IQf;25epN8gcPG+jMy1uG+ z-+kBeLk~Xi=ZlMbzVP~MF53WrJMaAZlLK?_z2C&EFZ~64@qc^~7yZB`#~wQPz)ZK> zU7VZS9)&X>jC{oi8nUQ>0We}tbyb_H2?#cZcXJRLo#^Z>C$lz2ve1$-(0MdBY z16ZDCAP+a1s$k~ij;g9FRaMSyAKH>cd1UKmR=L><5iJo>-E>gh%)Qs^;mDCg)tz_U zapd454}NKG`_A8gtFfK9aUfZ`v1jMS6qu<`SgeC=UjNnqmMj# za5{!!=eBJ#A;yBXtV|bj~`H8Ft%U~K83@u%}xP_0t4RRWfYXy|sb zMH*{$^UNgqAKZwzc3@a1tx{dPs^$^q$ZDAn0AC{FB_gU^-G{>=R@YYS@R395p@$z> zy6;=}+_|>C_Qz+QzVA=3zUJ~J0J!2c*Wgp1`M`I#Iq~OT_1Z-cUUXpp+0S{(Ip@yI z@7NVZIArULyiWzA{U!v5Q#Gt)Fc@^*d`d!W2MIzbN!mRCrwgUb(vIK1hh7LoALPZWA5&&!n^{ap_!qss?;A0`n}%9^4j{^ zWBq>b-ihwy?K`%g^2je-_3|MA{M(mZiQoF2kN@qvHm`c^wYcsR?}NMJ=P$i-hiZNC zu3d}IJ^i#ZPCs++zH(+}CUD-vK^bCb)VhMZp{i;b4u@?sn+y`VvDwnpU`7w_Ys9op z#8uN>8?R<$zPGyRqiODwL^9azOlZ3%hc5LLq#lV zVhSQmF+`0;S$8^{tj9BC>nO_MNkP z_MB4A&o9KuiAjku3KKJU%y}nmE^2Vc+>$2i^)}P-6UX$>p~tExo_PHDMsMTpYFK?E zY5LZl-Dezs$2+fSHaeGh(Tjf=zyF2b`|G#n{>*Fe+28)u-){r-r9bm}{Mv7CZ83lE zd#>Y$AGyB+!<2~3fw0}oX54MU&0}r?=|Ig^9e!MEeIlu?7DYsi#asw-1`!>IMRDq6 zcVcdOdRu#HYFn6?=t`?>BZR=j|q?ypli zQkL!2>pt=l}a>$3MU6L1zNW_490%72eb!J`)5{|L(3Ais0Ywe{$U48a9K5pMlK>kVoUg-y)b|HS?hn|P& zZIc*O3EjyK*4HYeY6Bno=zG57{jb03y;#^aiRGo^IDGgBmlFkdT9Y9Lkr0L5iQxb< zSzXs=?m8Isb#zUM-_yYSKgOg!yUD7?e*gdg M07*qoM6N<$f`e!d*Z=?k literal 0 HcmV?d00001 diff --git a/packages/editor/src/assets/hardware/gateway.png b/packages/editor/src/assets/hardware/gateway.png new file mode 100644 index 0000000000000000000000000000000000000000..aadef2c150253f8f491ffe6000f08082162f4ae9 GIT binary patch literal 13675 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%0D^Av%&Q>{JMcOHC};n3xZ> zJv}`r*G4UuMjEcJwdUzN_VU`6(T$%{E7CMLEL;hNqN2>85ru}CNnG0<;DV}U(S z8jHAjc9R_xK`~W?IncD$8PJF&D)(dYIxm&J3%&r2#d4eBwK6>(8a-aD%hd0|U^{W{ z@4&V_OCr@&y*70B#H&*I`Cyow1_$MEd<$HTp3>i;pMrkfWy-P%!pgMsyNI19z|d|% VwB2pT7VHoRP?pz_tCO(`|3Ay93xxmx literal 0 HcmV?d00001 diff --git a/packages/editor/src/assets/hardware/laptop.png b/packages/editor/src/assets/hardware/laptop.png new file mode 100644 index 0000000000000000000000000000000000000000..41b6f34908200d8a28a728d6f0e0302de40cc2ab GIT binary patch literal 8685 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 KsaOLw5Bncd;~(b$ literal 0 HcmV?d00001 diff --git a/packages/editor/src/assets/hardware/modem.png b/packages/editor/src/assets/hardware/modem.png new file mode 100644 index 0000000000000000000000000000000000000000..a837af4499d1150d15ac1860c1cc800cc67fa5d0 GIT binary patch literal 3804 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 Z$}x>if^gwdZ-Z4o1Yu}lP^pKC{U3)U5)%Lb literal 0 HcmV?d00001 diff --git a/packages/editor/src/assets/hardware/server.png b/packages/editor/src/assets/hardware/server.png new file mode 100644 index 0000000000000000000000000000000000000000..fa7f7c4fc1afe1f3a26b8d4725591ba062affd51 GIT binary patch literal 3878 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 literal 0 HcmV?d00001 diff --git a/packages/editor/src/bridge/dashboardBridge.ts b/packages/editor/src/bridge/dashboardBridge.ts new file mode 100644 index 0000000..36520bd --- /dev/null +++ b/packages/editor/src/bridge/dashboardBridge.ts @@ -0,0 +1,50 @@ +// The bridge to the dashboard (plan §9; the pattern of Scratch's +// lib/bridge.js and its twin dashboard-bridge.js): the editor runs in an +// iframe on another host, and between the two there is only postMessage. +// This file holds names, a version and the origin check, nothing that could +// drift apart from its twin in the dashboard (lib/bridge.js, F6). Every +// message is checked against the expected origin, both ways. + +export const BRIDGE_VERSION = 1; + +/** Dashboard → editor. */ +export const COMMANDS = { + /** `{ locale: 'de' | 'en' }` */ + SET_LOCALE: 'setLocale', + /** `{ theme: 'light' | 'dark', tokens?: { [name]: value } }`: the dashboard's resolved colours. */ + SET_THEME: 'setTheme', +} as const; + +/** Editor → dashboard. */ +export const EVENTS = { + /** The editor listens. */ + READY: 'ready', +} as const; + +export interface BridgeHandlers { + readonly onLocale: (locale: string) => void; + readonly onTheme: (theme: 'light' | 'dark', tokens: Readonly>) => void; +} + +interface Window { + readonly parent: { postMessage(message: unknown, targetOrigin: string): void }; + addEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void; + removeEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void; +} + +/** Listens to the dashboard at `parentOrigin` and tells it the editor is ready; returns how to stop. */ +export function startBridge(window: Window, parentOrigin: string, handlers: BridgeHandlers): () => void { + const listener = (event: { readonly origin: string; readonly data: unknown }) => { + if (event.origin !== parentOrigin) return; + const data = event.data as { v?: unknown; type?: unknown; locale?: unknown; theme?: unknown; tokens?: unknown } | null; + if (data === null || typeof data !== 'object' || data.v !== BRIDGE_VERSION) return; + if (data.type === COMMANDS.SET_LOCALE && typeof data.locale === 'string') handlers.onLocale(data.locale); + if (data.type === COMMANDS.SET_THEME && (data.theme === 'light' || data.theme === 'dark')) { + const tokens = data.tokens !== null && typeof data.tokens === 'object' ? (data.tokens as Record) : {}; + handlers.onTheme(data.theme, Object.fromEntries(Object.entries(tokens).filter((entry): entry is [string, string] => entry[0].startsWith('--') && typeof entry[1] === 'string'))); + } + }; + window.addEventListener('message', listener); + window.parent.postMessage({ v: BRIDGE_VERSION, type: EVENTS.READY }, parentOrigin); + return () => window.removeEventListener('message', listener); +} diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx new file mode 100644 index 0000000..318fcc8 --- /dev/null +++ b/packages/editor/src/canvas/Canvas.tsx @@ -0,0 +1,232 @@ +// The surface (plan §7.1): the devices with Java's icons and labels, the +// cables, the documentation items, in an SVG that one moves with a finger or +// the mouse and zooms with two fingers, a pinch on the trackpad, Ctrl and the +// wheel or the buttons. +// +// 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 +// never shows it, Playwright does). The surface sets touch-action: none, so +// that the browser's own zoom and scroll stay out of a gesture on it (M7). + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +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, JAVA_LABEL_FONT, type DeviceBox } from './layout.js'; +import { fitted, middle, shifted, spread, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js'; +import { ZoomBar } from './ZoomBar.js'; + +/** How far a pointer moves before a press becomes a drag. */ +const DRAG_THRESHOLD = 4; + +/** The label's width in Java's font, measured once per text. */ +function useTextWidth(): (text: string) => number { + const cache = useRef(new Map()); + const context = useMemo(() => document.createElement('canvas').getContext('2d'), []); + return useCallback( + (text: string) => { + const known = cache.current.get(text); + if (known !== undefined) return known; + if (context === null) return text.length * 7; + context.font = JAVA_LABEL_FONT; + const width = context.measureText(text).width; + cache.current.set(text, width); + return width; + }, + [context], + ); +} + +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 }) { + if (item.kind === 'rectangle') { + return ; + } + return ( + +
+ {item.text} +
+
+ ); +} + +function Device({ node, box, kindName }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string }) { + return ( + + + + {node.label} + + + ); +} + +export interface CanvasProps { + readonly view: TopologyView; +} + +export function Canvas({ view }: CanvasProps) { + const t = useTranslate(); + const surface = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + const [viewport, setViewport] = useState(null); + const textWidth = useTextWidth(); + + const boxes = useMemo(() => new Map(view.nodes.map((node) => [node.id, deviceBox(node, textWidth(node.label))])), [view.nodes, textWidth]); + const content = useMemo(() => bounds([...boxes.values(), ...view.docs.map((item) => ({ x: item.x, y: item.y, width: item.width, height: item.height }))]), [boxes, view.docs]); + + useLayoutEffect(() => { + const element = surface.current; + if (element === null) return undefined; + const observer = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height })); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + // The first view shows all; after that the view is the user's. + useEffect(() => { + if (viewport === null && size.width > 0 && size.height > 0) setViewport(fitted(content, size)); + }, [viewport, size, content]); + + const current = viewport ?? { scale: 1, x: 0, y: 0 }; + const local = useCallback((clientX: number, clientY: number): Point => { + const rect = surface.current!.getBoundingClientRect(); + return { x: clientX - rect.left, y: clientY - rect.top }; + }, []); + + // Pointers down on the surface, and what they started. + const pointers = useRef(new Map()); + const gesture = useRef<{ kind: 'press'; start: Point } | { kind: 'pan'; last: Point } | { kind: 'pinch'; spread: number; middle: Point } | null>(null); + + const onPointerDown = (event: React.PointerEvent) => { + const point = local(event.clientX, event.clientY); + pointers.current.set(event.pointerId, point); + if (pointers.current.size === 2) { + const [first, second] = [...pointers.current.values()] as [Point, Point]; + gesture.current = { kind: 'pinch', spread: spread(first, second), middle: middle(first, second) }; + for (const id of pointers.current.keys()) if (!surface.current!.hasPointerCapture(id)) surface.current!.setPointerCapture(id); + } else if (pointers.current.size === 1) { + gesture.current = { kind: 'press', start: point }; + } + }; + + const onPointerMove = (event: React.PointerEvent) => { + if (!pointers.current.has(event.pointerId)) return; + const point = local(event.clientX, event.clientY); + pointers.current.set(event.pointerId, point); + const now = gesture.current; + if (now === null) return; + if (now.kind === 'press') { + if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return; + // Captured only now that it is a drag. + surface.current!.setPointerCapture(event.pointerId); + gesture.current = { kind: 'pan', last: now.start }; + } + const active = gesture.current!; + if (active.kind === 'pan') { + setViewport((before) => shifted(before ?? current, { x: point.x - active.last.x, y: point.y - active.last.y })); + gesture.current = { kind: 'pan', last: point }; + } else if (active.kind === 'pinch' && pointers.current.size === 2) { + const [first, second] = [...pointers.current.values()] as [Point, Point]; + const nextSpread = spread(first, second); + const nextMiddle = middle(first, second); + setViewport((before) => { + const base = before ?? current; + const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, active.spread)), active.middle); + return shifted(zoomed, { x: nextMiddle.x - active.middle.x, y: nextMiddle.y - active.middle.y }); + }); + gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle }; + } + }; + + const onPointerUp = (event: React.PointerEvent) => { + pointers.current.delete(event.pointerId); + if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(event.pointerId); + // A pinch that loses a finger goes on as a pan with the other. + const [remaining] = [...pointers.current.values()]; + gesture.current = remaining === undefined ? null : { kind: 'pan', last: remaining }; + }; + + // The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves. + // Registered by hand: React's wheel listener is passive and could not keep the page from zooming. + useEffect(() => { + const element = surface.current; + if (element === null) return undefined; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const point = local(event.clientX, event.clientY); + setViewport((before) => { + const base = before ?? { scale: 1, x: 0, y: 0 }; + if (event.ctrlKey || event.metaKey) return zoomedAt(base, base.scale * Math.exp(-event.deltaY * 0.01), point); + return shifted(base, { x: -event.deltaX, y: -event.deltaY }); + }); + }; + element.addEventListener('wheel', onWheel, { passive: false }); + return () => element.removeEventListener('wheel', onWheel); + }, [local]); + + const centre = { x: size.width / 2, y: size.height / 2 }; + const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' }; + return ( +
+ + + + {view.docs.map((item) => ( + + ))} + + + {view.links.map((link) => { + const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)]; + // Drawn tolerantly: a cable whose device is gone (before the janitor) is left out. + if (source === undefined || target === undefined) return null; + return ( + + ); + })} + + + {view.nodes.map((node) => ( + + ))} + + + + setViewport(zoomedAt(current, zoomIn(current.scale), centre))} + onZoomOut={() => setViewport(zoomedAt(current, zoomOut(current.scale), centre))} + onFit={() => setViewport(fitted(content, size))} + /> +
+ ); +} diff --git a/packages/editor/src/canvas/ZoomBar.tsx b/packages/editor/src/canvas/ZoomBar.tsx new file mode 100644 index 0000000..f9ab889 --- /dev/null +++ b/packages/editor/src/canvas/ZoomBar.tsx @@ -0,0 +1,52 @@ +// The zoom's buttons: out, the scale, in, show all. Each a target of at least +// 44 px for a finger (plan §7.2). + +import type { CSSProperties } from 'react'; +import { useTranslate } from '../i18n/i18n.js'; +import { MAX_ZOOM, MIN_ZOOM } from './viewport.js'; + +const bar: CSSProperties = { + position: 'absolute', + right: 12, + bottom: 12, + display: 'flex', + alignItems: 'center', + gap: 2, + padding: 2, + borderRadius: 8, + background: 'var(--surface-raised)', + border: '1px solid var(--border-default)', + boxShadow: '0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent)', +}; + +const button: CSSProperties = { + minWidth: 44, + height: 44, + border: 'none', + borderRadius: 6, + background: 'transparent', + color: 'var(--text-primary)', + font: 'inherit', + fontSize: 18, + cursor: 'pointer', +}; + +export function ZoomBar({ scale, onZoomIn, onZoomOut, onFit }: { readonly scale: number; readonly onZoomIn: () => void; readonly onZoomOut: () => void; readonly onFit: () => void }) { + const t = useTranslate(); + return ( +
+ + + {t('zoom.level', { percent: Math.round(scale * 100) })} + + + +
+ ); +} diff --git a/packages/editor/src/canvas/icons.ts b/packages/editor/src/canvas/icons.ts new file mode 100644 index 0000000..a03a47c --- /dev/null +++ b/packages/editor/src/canvas/icons.ts @@ -0,0 +1,13 @@ +// Java FILIUS' own hardware icons (GPL, src/main/resources/gfx/hardware), +// so that teachers recognise their worksheets (plan §7.1). + +import cloud from '../assets/hardware/cloud.png'; +import gateway from '../assets/hardware/gateway.png'; +import laptop from '../assets/hardware/laptop.png'; +import modem from '../assets/hardware/modem.png'; +import router from '../assets/hardware/router.png'; +import server from '../assets/hardware/server.png'; +import networkSwitch from '../assets/hardware/switch.png'; +import type { IconName } from './layout.js'; + +export const ICON_URLS: Readonly> = { cloud, gateway, laptop, modem, router, server, switch: networkSwitch }; diff --git a/packages/editor/src/canvas/layout.ts b/packages/editor/src/canvas/layout.ts new file mode 100644 index 0000000..549549f --- /dev/null +++ b/packages/editor/src/canvas/layout.ts @@ -0,0 +1,111 @@ +// Where Java draws a device and a cable (filius.gui.netzwerksicht), so that a +// project looks as on the school's PCs and worksheets fit. +// +// A device is a JSidebarButton: its icon above, its label below, centred. The +// position a file stores is the button's top left corner. Its width is the +// wider of label and icon plus 5 and twice the border of 8, its height the +// font's height plus the icon's plus 5 (JSidebarButton.getWidth/getHeight); +// the icon sits at the top. A cable runs between the middles of the two +// buttons — a little below the icons' middles — as a quadratic curve whose +// control point lies a quarter, or for a falling line three quarters, across +// the rectangle the two ends span (JCablePanel.paintComponent, Java's +// default icons; integer arithmetic as Java's). + +import type { NodeKind } from '@mdg-filius/core'; +import type { Point, Rect } from './viewport.js'; + +/** + * The label's font. Java takes the system's look and feel (Main: the system + * look and feel), so its labels are as large as the saving machine's: in the + * bounds of the files Java 2.5 and later saved, mostly a font 17 high whose + * widths are Arial's at 14 px ("Vermittlungsrechner" 126 wide in Java, 125 + * here). Arial is what most of them ran on (Windows) and what the editor + * mostly runs on (Helvetica on an iPad has its widths). + */ +export const JAVA_LABEL_FONT = '14px Arial, Helvetica, "Liberation Sans", "DejaVu Sans", sans-serif'; +/** Its FontMetrics.getHeight, as most of the files' bounds have it. */ +export const JAVA_LABEL_FONT_HEIGHT = 17; +const JAVA_LABEL_ASCENT = 14; +const BORDER = 8; + +/** Java's default icons (IconMode.DEFAULT) and their sizes. */ +export const ICONS = { + server: { width: 64, height: 94 }, + laptop: { width: 84, height: 80 }, + switch: { width: 70, height: 52 }, + cloud: { width: 70, height: 52 }, + router: { width: 77, height: 58 }, + gateway: { width: 77, height: 58 }, + modem: { width: 80, height: 56 }, +} as const; + +export type IconName = keyof typeof ICONS; + +export function iconOf(kind: NodeKind, cloud: boolean): IconName { + switch (kind) { + case 'pc': + return 'server'; + case 'notebook': + return 'laptop'; + case 'networkSwitch': + return cloud ? 'cloud' : 'switch'; + case 'router': + return 'router'; + case 'gateway': + return 'gateway'; + case 'remoteLink': + return 'modem'; + case 'unsupported': + // As the iPad app draws a device it does not know. + return 'cloud'; + } +} + +export interface DeviceBox extends Rect { + readonly icon: IconName; + readonly iconRect: Rect; + /** The label's baseline, centred at x. */ + readonly label: Point; + /** Where cables meet the device. */ + readonly center: Point; +} + +/** A device's button, as JSidebarButton lays it out, with the label's width as the browser measures it. */ +export function deviceBox(node: { readonly kind: NodeKind; readonly cloud: boolean; readonly x: number; readonly y: number }, labelWidth: number): DeviceBox { + const icon = iconOf(node.kind, node.cloud); + const size = ICONS[icon]; + const width = Math.max(Math.round(labelWidth), size.width) + 5 + 2 * BORDER; + const height = JAVA_LABEL_FONT_HEIGHT + size.height + 5; + return { + x: node.x, + y: node.y, + width, + height, + icon, + iconRect: { x: node.x + (width - size.width) / 2, y: node.y, width: size.width, height: size.height }, + label: { x: node.x + width / 2, y: node.y + size.height + JAVA_LABEL_ASCENT }, + center: { x: Math.trunc(node.x + 0.5 * width), y: Math.trunc(node.y + 0.5 * height) }, + }; +} + +/** JCablePanel's curve between two devices' middles, as an SVG path. */ +export function cablePath(first: Point, second: Point): string { + const [x1, y1, x2, y2] = [first.x, first.y, second.x, second.y]; + // The panel spans the two ends with 2 to spare (updateBounds); the control point is relative to it. + const left = Math.min(x1, x2) - 2; + const top = Math.min(y1, y2) - 2; + let controlX = Math.trunc((x1 - left + x2 - left) / 4); + if ((x1 > x2 && y1 > y2) || (x1 < x2 && y1 < y2)) controlX = 3 * controlX; + const controlY = Math.trunc((y1 - top + y2 - top) / 4); + return `M ${x1} ${y1} Q ${left + controlX} ${top + controlY} ${x2} ${y2}`; +} + +/** The rectangle around all of them. */ +export function bounds(rects: readonly Rect[]): Rect { + if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + return { x: left, y: top, width: right - left, height: bottom - top }; +} diff --git a/packages/editor/src/canvas/viewport.ts b/packages/editor/src/canvas/viewport.ts new file mode 100644 index 0000000..7621544 --- /dev/null +++ b/packages/editor/src/canvas/viewport.ts @@ -0,0 +1,99 @@ +// The view onto the surface: how far it is zoomed and shifted — the +// arithmetic behind it, without a browser. +// +// Copied from the dashboard's Kanban pinboard (frontend/src/apps/kanban/ +// zoom.js; the rights are ours, plan §7.1) and changed from a scrolled +// container to a transform, as an SVG surface wants it: +// +// screen pixel = surface pixel × scale + shift +// +// Surface pixels are Java's: what the document stores and the .fls file has. +// Screen pixels are what the browser reports for a pointer. + +export const MIN_ZOOM = 0.25; +export const MAX_ZOOM = 2; + +/** The steps of the two buttons: a fixed ladder, so that one gets back to 100 % without searching. */ +export const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2] as const; + +export interface Point { + readonly x: number; + readonly y: number; +} + +export interface Size { + readonly width: number; + readonly height: number; +} + +export interface Rect extends Point, Size {} + +export interface Viewport { + readonly scale: number; + /** Where the surface's origin is on the screen. */ + readonly x: number; + readonly y: number; +} + +export const clampZoom = (zoom: number): number => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); + +/** The next step up, from any value in between. */ +export function zoomIn(zoom: number): number { + return ZOOM_STEPS.find((step) => step > zoom + 0.001) ?? MAX_ZOOM; +} + +/** The next step down. */ +export function zoomOut(zoom: number): number { + const smaller = ZOOM_STEPS.filter((step) => step < zoom - 0.001); + return smaller.at(-1) ?? MIN_ZOOM; +} + +export function toSurface(viewport: Viewport, point: Point): Point { + return { x: (point.x - viewport.x) / viewport.scale, y: (point.y - viewport.y) / viewport.scale }; +} + +export function toScreen(viewport: Viewport, point: Point): Point { + return { x: point.x * viewport.scale + viewport.x, y: point.y * viewport.scale + viewport.y }; +} + +/** + * Zoomed to a scale so that a point stays put — the pointer for the wheel, + * the middle between two fingers, the middle of the view for the buttons. + * Without it the content jumps away from where one zooms. + */ +export function zoomedAt(viewport: Viewport, scale: number, point: Point): Viewport { + const to = clampZoom(scale); + const surface = toSurface(viewport, point); + return { scale: to, x: point.x - surface.x * to, y: point.y - surface.y * to }; +} + +export function shifted(viewport: Viewport, by: Point): Viewport { + return { ...viewport, x: viewport.x + by.x, y: viewport.y + by.y }; +} + +/** + * The view in which the content fits, in the middle, with a margin. Never + * above 100 %: fitting means zooming out until all is in view; three devices + * on a large screen at 200 % is not what anyone means by it. + */ +export function fitted(content: Rect, view: Size, margin = 24): Viewport { + if (!(content.width > 0) || !(content.height > 0) || !(view.width > 2 * margin) || !(view.height > 2 * margin)) { + return { scale: 1, x: margin - (content.width > 0 ? content.x : 0), y: margin - (content.height > 0 ? content.y : 0) }; + } + const scale = clampZoom(Math.min(1, (view.width - 2 * margin) / content.width, (view.height - 2 * margin) / content.height)); + return { + scale, + x: (view.width - content.width * scale) / 2 - content.x * scale, + y: (view.height - content.height * scale) / 2 - content.y * scale, + }; +} + +/** The distance between two touches: the measure of the pinch. */ +export function spread(first: Point, second: Point): number { + return Math.hypot(second.x - first.x, second.y - first.y); +} + +/** The middle between two touches. */ +export function middle(first: Point, second: Point): Point { + return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; +} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json new file mode 100644 index 0000000..20a9f97 --- /dev/null +++ b/packages/editor/src/i18n/de.json @@ -0,0 +1,18 @@ +{ + "canvas.label": "Netzwerk", + "device.cable": "Verbindung", + "device.gateway": "Heimrouter", + "device.networkSwitch": "Switch / WLAN", + "device.notebook": "Notebook", + "device.pc": "Rechner", + "device.remoteLink": "Modem", + "device.router": "Vermittlungsrechner", + "device.unsupported": "Unbekanntes Gerät", + "dev.example": "Beispiel", + "dev.openFile": "Datei öffnen …", + "dev.title": "Filius-Editor (Entwicklung)", + "zoom.fit": "Alles zeigen", + "zoom.in": "Vergrößern", + "zoom.level": "{percent} %", + "zoom.out": "Verkleinern" +} diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json new file mode 100644 index 0000000..512f92e --- /dev/null +++ b/packages/editor/src/i18n/en.json @@ -0,0 +1,18 @@ +{ + "canvas.label": "Network", + "device.cable": "Connection", + "device.gateway": "Home Router", + "device.networkSwitch": "Switch / Wi-Fi", + "device.notebook": "Notebook", + "device.pc": "Computer", + "device.remoteLink": "Modem", + "device.router": "Router", + "device.unsupported": "Unknown device", + "dev.example": "Example", + "dev.openFile": "Open file …", + "dev.title": "Filius editor (development)", + "zoom.fit": "Show all", + "zoom.in": "Zoom in", + "zoom.level": "{percent} %", + "zoom.out": "Zoom out" +} diff --git a/packages/editor/src/i18n/i18n.tsx b/packages/editor/src/i18n/i18n.tsx new file mode 100644 index 0000000..2342eca --- /dev/null +++ b/packages/editor/src/i18n/i18n.tsx @@ -0,0 +1,30 @@ +// The editor's visible texts, German and English (plan §7.5): English keys, +// the words in i18n/{de,en}.json — where Java FILIUS has a word for a thing, +// Java's (MessagesBundle), so that the terms on worksheets fit. The language +// comes from the dashboard over the bridge. + +import { createContext, useContext, type ReactNode } from 'react'; +import de from './de.json'; +import en from './en.json'; + +export type Language = 'de' | 'en'; +export const CATALOGS: Readonly>>> = { de, en }; + +export type Translate = (key: string, values?: Readonly>) => string; + +/** The text of a key in a language, its {placeholders} filled; the key itself where the catalog has none. */ +export function translate(language: Language, key: string, values: Readonly> = {}): string { + const text = CATALOGS[language][key] ?? key; + return text.replace(/\{(\w+)\}/g, (whole, name: string) => (name in values ? String(values[name]) : whole)); +} + +const LanguageContext = createContext('de'); + +export function LanguageProvider({ language, children }: { readonly language: Language; readonly children: ReactNode }) { + return {children}; +} + +export function useTranslate(): Translate { + const language = useContext(LanguageContext); + return (key, values) => translate(language, key, values); +} diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx new file mode 100644 index 0000000..5b2b99d --- /dev/null +++ b/packages/editor/src/main.tsx @@ -0,0 +1,98 @@ +// The editor's page. Embedded in the dashboard (F6) it gets its document and +// language over the bridge; on its own — for development and the browser +// tests — it opens one of FILIUS' examples or a file (`?example=` +// opens that example at once, `?lang=en` and `?theme=dark` set the rest). + +import { StrictMode, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import * as Y from 'yjs'; +import { writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import { startBridge } from './bridge/dashboardBridge.js'; +import { Editor } from './Editor.js'; +import { LanguageProvider, translate, type Language } from './i18n/i18n.js'; +import './theme/tokens.css'; + +const EXAMPLES = import.meta.glob('../../../corpus/java-examples/*.fls', { query: '?url', import: 'default', eager: true }) as Record; +const exampleUrl = (name: string) => Object.entries(EXAMPLES).find(([path]) => path.endsWith(`/${name}`))?.[1]; + +function docOf(bytes: Uint8Array): Y.Doc { + const doc = new Y.Doc(); + writeProject(doc, openProjectArchive(bytes, () => crypto.randomUUID())); + return doc; +} + +function App() { + const params = new URLSearchParams(window.location.search); + const [language, setLanguage] = useState(params.get('lang') === 'en' ? 'en' : 'de'); + const [doc, setDoc] = useState(null); + const [example, setExample] = useState(params.get('example') ?? ''); + + useEffect(() => { + if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark'; + const parent = params.get('parent'); + if (parent === null) return undefined; + return startBridge(window, parent, { + onLocale: (locale) => setLanguage(locale.startsWith('en') ? 'en' : 'de'), + onTheme: (theme, tokens) => { + document.documentElement.dataset.theme = theme; + for (const [name, value] of Object.entries(tokens)) document.documentElement.style.setProperty(name, value); + }, + }); + // The page's parameters are read once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const url = exampleUrl(example); + if (url === undefined) return; + void fetch(url) + .then((response) => response.arrayBuffer()) + .then((buffer) => setDoc(docOf(new Uint8Array(buffer)))); + }, [example]); + + const t = (key: string) => translate(language, key); + return ( + +
+
+ {t('dev.title')} + + +
+
+ {doc === null ? null : } +
+
+
+ ); +} + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/editor/src/theme/tokens.css b/packages/editor/src/theme/tokens.css new file mode 100644 index 0000000..a67bc02 --- /dev/null +++ b/packages/editor/src/theme/tokens.css @@ -0,0 +1,74 @@ +/* The editor's colours: the dashboard's semantic tokens under the same names + * (frontend/src/index.css), light by default and dark when the dashboard says + * so over the bridge (data-theme on ) — never by the system's setting, + * which the dashboard has already resolved. The bridge may set any of them + * to the dashboard's own values. */ +:root { + --surface-page: #f5f6f8; + --surface-card: #ffffff; + --surface-raised: #ffffff; + --surface-sunken: #f1f3f5; + --surface-hover: #eff1f4; + --surface-selected: #e8f0f6; + --text-primary: #1d2939; + --text-secondary: #667085; + --text-muted: #78839a; + --border-default: #dee2e6; + --border-subtle: #e9ebef; + --border-strong: #c7ced8; + --border-focus: #2c9c32; + --accent: #1a5984; + --accent-hover: #2980b9; + --accent-soft: #e8f0f6; + --accent-on-soft: #1a5984; + --text-on-accent: #ffffff; + --shadow-color: #101828; + --danger: #e74c3c; + --danger-soft: #f8d7da; + --danger-on-soft: #721c24; + /* The surface itself: Java's white design area and its dark grey cables (JCablePanel). */ + --canvas-bg: #ffffff; + --canvas-cable: rgb(64 64 64); + --canvas-cable-wireless: rgb(192 192 192); + --canvas-label: #000000; +} + +:root[data-theme='dark'] { + --surface-page: #14171c; + --surface-card: #1b1f26; + --surface-raised: #232830; + --surface-sunken: #101318; + --surface-hover: #262c35; + --surface-selected: #1e3448; + --text-primary: #e6e9ef; + --text-secondary: #a7b0bf; + --text-muted: #7d8797; + --border-default: #333a45; + --border-subtle: #272d36; + --border-strong: #4a5361; + --border-focus: #3ecf4a; + --accent: #4a9fd5; + --accent-hover: #6cb6e4; + --accent-soft: #16344b; + --accent-on-soft: #8ec5e8; + --text-on-accent: #0d1b26; + --shadow-color: #000000; + --danger: #f2695a; + --danger-soft: #3a1a17; + --danger-on-soft: #ffa79b; + /* Java's icons are drawn for a light ground: the surface stays light, a shade darker. */ + --canvas-bg: #e9ecf1; + --canvas-cable: rgb(64 64 64); + --canvas-cable-wireless: rgb(150 150 150); + --canvas-label: #000000; +} + +html, +body, +#root { + margin: 0; + height: 100%; + background: var(--surface-page); + color: var(--text-primary); + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; +} diff --git a/packages/editor/src/vite-env.d.ts b/packages/editor/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/editor/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/editor/test/bridge.test.ts b/packages/editor/test/bridge.test.ts new file mode 100644 index 0000000..772e734 --- /dev/null +++ b/packages/editor/test/bridge.test.ts @@ -0,0 +1,53 @@ +// The bridge to the dashboard: only the expected origin, only this version, +// and the editor says when it listens. + +import { describe, expect, it } from 'vitest'; +import { BRIDGE_VERSION, startBridge } from '../src/bridge/dashboardBridge.js'; + +function fakeWindow() { + const posted: [unknown, string][] = []; + const listeners = new Set<(event: { origin: string; data: unknown }) => void>(); + return { + posted, + window: { + parent: { postMessage: (message: unknown, origin: string) => posted.push([message, origin]) }, + addEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.add(listener), + removeEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.delete(listener), + }, + send: (origin: string, data: unknown) => { + for (const listener of listeners) listener({ origin, data }); + }, + listening: () => listeners.size, + }; +} + +describe('the bridge to the dashboard', () => { + it('says it is ready, to the dashboard only', () => { + const fake = fakeWindow(); + startBridge(fake.window, 'https://dashboard.example', { onLocale: () => {}, onTheme: () => {} }); + expect(fake.posted).toEqual([[{ v: BRIDGE_VERSION, type: 'ready' }, 'https://dashboard.example']]); + }); + + it('takes language and colours from the dashboard, and nothing from elsewhere', () => { + const fake = fakeWindow(); + const got: unknown[] = []; + const stop = startBridge(fake.window, 'https://dashboard.example', { + onLocale: (locale) => got.push(['locale', locale]), + onTheme: (theme, tokens) => got.push(['theme', theme, tokens]), + }); + fake.send('https://evil.example', { v: 1, type: 'setLocale', locale: 'en' }); + fake.send('https://dashboard.example', { v: 2, type: 'setLocale', locale: 'en' }); + fake.send('https://dashboard.example', { v: 1, type: 'setLocale', locale: 7 }); + fake.send('https://dashboard.example', { v: 1, type: 'setTheme', theme: 'pink' }); + fake.send('https://dashboard.example', null); + expect(got).toEqual([]); + fake.send('https://dashboard.example', { v: 1, type: 'setLocale', locale: 'en-GB' }); + fake.send('https://dashboard.example', { v: 1, type: 'setTheme', theme: 'dark', tokens: { '--accent': '#123456', color: 'red', '--bad': 3 } }); + expect(got).toEqual([ + ['locale', 'en-GB'], + ['theme', 'dark', { '--accent': '#123456' }], + ]); + stop(); + expect(fake.listening()).toBe(0); + }); +}); diff --git a/packages/editor/test/i18n.test.ts b/packages/editor/test/i18n.test.ts new file mode 100644 index 0000000..a91f2aa --- /dev/null +++ b/packages/editor/test/i18n.test.ts @@ -0,0 +1,58 @@ +// The catalogs: the same keys in both languages, placeholders filled, and — +// where Java FILIUS names a thing — Java's word in its bundle (M11). + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { CATALOGS, translate } from '../src/i18n/i18n.js'; + +const bundles = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/filius/messages'); + +/** A Java .properties file: ISO-8859-1, \uXXXX escapes, `key = value`. */ +function properties(file: string): Map { + const text = new TextDecoder('latin1').decode(readFileSync(join(bundles, file))); + const entries = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = /^([\w.]+)\s*=\s*(.*)$/.exec(line); + if (match !== null) entries.set(match[1]!, match[2]!.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))).trim()); + } + return entries; +} + +/** The editor's keys whose words are Java's, and the key of each in Java's bundle. */ +const FROM_JAVA: Readonly> = { + 'device.pc': 'hw_rechner_msg1', + 'device.notebook': 'hw_notebook_msg1', + 'device.networkSwitch': 'hw_switch_msg1', + 'device.router': 'hw_vermittlungsrechner_msg1', + 'device.gateway': 'hw_gateway_msg1', + 'device.cable': 'hw_kabel_msg1', +}; + +describe('the catalogs', () => { + it('have the same keys in German and English, each with a text', () => { + expect(Object.keys(CATALOGS.en).sort()).toEqual(Object.keys(CATALOGS.de).sort()); + for (const catalog of Object.values(CATALOGS)) for (const text of Object.values(catalog)) expect(text.trim()).not.toBe(''); + }); + + it('fill placeholders, and show a key they lack', () => { + expect(translate('de', 'zoom.level', { percent: 75 })).toBe('75 %'); + expect(translate('en', 'zoom.level')).toBe('{percent} %'); + expect(translate('de', 'no.such.key')).toBe('no.such.key'); + }); + + it('name every kind of device', () => { + for (const kind of ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink', 'unsupported']) expect(CATALOGS.de[`device.${kind}`]).toBeDefined(); + // Java's Modem.TYPE, the same in every language. + expect([CATALOGS.de['device.remoteLink'], CATALOGS.en['device.remoteLink']]).toEqual(['Modem', 'Modem']); + }); + + it.skipIf(!existsSync(bundles))("use Java's words where Java has them (M11)", () => { + const [de, en] = [properties('MessagesBundle_de_DE.properties'), properties('MessagesBundle_en_GB.properties')]; + for (const [key, javaKey] of Object.entries(FROM_JAVA)) { + expect(CATALOGS.de[key], key).toBe(de.get(javaKey)); + expect(CATALOGS.en[key], key).toBe(en.get(javaKey)); + } + }); +}); diff --git a/packages/editor/test/layout.test.ts b/packages/editor/test/layout.test.ts new file mode 100644 index 0000000..86499fa --- /dev/null +++ b/packages/editor/test/layout.test.ts @@ -0,0 +1,58 @@ +// Java's drawing of a device and a cable (JSidebarButton, JCablePanel). + +import { describe, expect, it } from 'vitest'; +import { bounds, cablePath, deviceBox, iconOf, ICONS, JAVA_LABEL_FONT_HEIGHT } from '../src/canvas/layout.js'; + +describe("Java's drawing", () => { + it('picks the icon of each kind, the cloud for a switch drawn as one', () => { + expect((['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink', 'unsupported'] as const).map((kind) => iconOf(kind, false))).toEqual([ + 'server', + 'laptop', + 'switch', + 'router', + 'gateway', + 'modem', + 'cloud', + ]); + expect(iconOf('networkSwitch', true)).toBe('cloud'); + expect(iconOf('router', true)).toBe('router'); + }); + + it('makes a button as wide as its icon or its label, plus 5 and twice the border, and as high as both plus 5', () => { + const short = deviceBox({ kind: 'pc', cloud: false, x: 100, y: 200 }, 30); + expect(short).toMatchObject({ x: 100, y: 200, width: 64 + 21, height: JAVA_LABEL_FONT_HEIGHT + 94 + 5, icon: 'server' }); + // The icon at the top, in the middle; the label's baseline under it, in the middle. + expect(short.iconRect).toEqual({ x: 100 + (85 - 64) / 2, y: 200, width: 64, height: 94 }); + expect(short.label).toEqual({ x: 100 + 85 / 2, y: 200 + 94 + 14 }); + const long = deviceBox({ kind: 'router', cloud: false, x: 0, y: 0 }, 124.6); + expect(long.width).toBe(125 + 21); + expect(long.iconRect.x).toBe((146 - 77) / 2); + }); + + it('meets cables in the middle of the button, in whole pixels as Java does', () => { + const box = deviceBox({ kind: 'notebook', cloud: false, x: 11, y: 7 }, 10); + // 84 + 21 = 105 wide, 17 + 80 + 5 = 102 high. + expect(box.center).toEqual({ x: Math.trunc(11 + 52.5), y: Math.trunc(7 + 51) }); + }); + + it("draws a cable as JCablePanel's curve", () => { + // Rising to the right (y falls): the control point a quarter across the panel. + // Panel from (98, 48); control (98 + trunc((2 + 302) / 4), 48 + trunc((52 + 2) / 4)) = (174, 61). + expect(cablePath({ x: 100, y: 100 }, { x: 400, y: 50 })).toBe('M 100 100 Q 174 61 400 50'); + // Falling to the right: three quarters across. + expect(cablePath({ x: 100, y: 50 }, { x: 400, y: 100 })).toBe(`M 100 50 Q ${98 + 3 * Math.trunc(304 / 4)} 61 400 100`); + // The same either way round. + expect(cablePath({ x: 400, y: 100 }, { x: 100, y: 50 })).toBe(`M 400 100 Q ${98 + 3 * Math.trunc(304 / 4)} 61 100 50`); + // Straight down: not falling (x does not change); the panel from (8, 8), control (8 + trunc(4 / 4), 8 + trunc(84 / 4)). + expect(cablePath({ x: 10, y: 10 }, { x: 10, y: 90 })).toBe('M 10 10 Q 9 29 10 90'); + }); + + it('knows every icon it draws', () => { + expect(Object.keys(ICONS).sort()).toEqual(['cloud', 'gateway', 'laptop', 'modem', 'router', 'server', 'switch']); + }); + + it('spans the rectangles around everything', () => { + expect(bounds([])).toEqual({ x: 0, y: 0, width: 0, height: 0 }); + expect(bounds([{ x: 10, y: 20, width: 5, height: 5 }, { x: -5, y: 30, width: 10, height: 10 }])).toEqual({ x: -5, y: 20, width: 20, height: 20 }); + }); +}); diff --git a/packages/editor/test/tsconfig.json b/packages/editor/test/tsconfig.json new file mode 100644 index 0000000..52966d6 --- /dev/null +++ b/packages/editor/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"], + "resolveJsonModule": true + }, + "include": [".", "../browser", "../src"] +} diff --git a/packages/editor/test/viewport.test.ts b/packages/editor/test/viewport.test.ts new file mode 100644 index 0000000..4b6ab55 --- /dev/null +++ b/packages/editor/test/viewport.test.ts @@ -0,0 +1,59 @@ +// The view onto the surface: zoom steps and limits, zooming around a point, +// fitting the content in. + +import { describe, expect, it } from 'vitest'; +import { clampZoom, fitted, MAX_ZOOM, middle, MIN_ZOOM, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, ZOOM_STEPS } from '../src/canvas/viewport.js'; + +describe('the view onto the surface', () => { + it('keeps the zoom between a quarter and twice', () => { + expect([clampZoom(0.1), clampZoom(1.3), clampZoom(9)]).toEqual([MIN_ZOOM, 1.3, MAX_ZOOM]); + }); + + it('steps along the ladder from any value, and back to where it was', () => { + expect(ZOOM_STEPS.map(zoomIn)).toEqual([0.5, 0.75, 1, 1.25, 1.5, 2, 2]); + expect(ZOOM_STEPS.map(zoomOut)).toEqual([0.25, 0.25, 0.5, 0.75, 1, 1.25, 1.5]); + expect([zoomIn(0.8), zoomOut(0.8), zoomIn(1.0005)]).toEqual([1, 0.75, 1.25]); + for (const step of ZOOM_STEPS.slice(1, -1)) expect(zoomOut(zoomIn(step))).toBe(step); + }); + + it('turns screen and surface pixels into each other', () => { + const view = { scale: 0.5, x: 100, y: -20 }; + expect(toScreen(view, { x: 40, y: 80 })).toEqual({ x: 120, y: 20 }); + expect(toSurface(view, toScreen(view, { x: 40, y: 80 }))).toEqual({ x: 40, y: 80 }); + }); + + it('zooms so that the point zoomed at stays put, within the limits', () => { + const view = { scale: 1, x: 30, y: 40 }; + const point = { x: 200, y: 150 }; + const zoomed = zoomedAt(view, 1.5, point); + expect(zoomed.scale).toBe(1.5); + expect(toScreen(zoomed, toSurface(view, point))).toEqual(point); + expect(zoomedAt(view, 10, point).scale).toBe(MAX_ZOOM); + expect(toScreen(zoomedAt(view, 10, point), toSurface(view, point))).toEqual(point); + }); + + it('moves by what the pointer moved', () => { + expect(shifted({ scale: 2, x: 1, y: 2 }, { x: 10, y: -5 })).toEqual({ scale: 2, x: 11, y: -3 }); + }); + + it('fits the content in the middle with a margin, never above 100 %', () => { + const content = { x: 100, y: 50, width: 800, height: 400 }; + const view = fitted(content, { width: 448, height: 448 }); + expect(view.scale).toBe(0.5); + // Left and right margin alike, the content in the middle vertically. + expect(toScreen(view, { x: 100, y: 50 })).toEqual({ x: 24, y: 124 }); + expect(toScreen(view, { x: 900, y: 450 })).toEqual({ x: 424, y: 324 }); + expect(fitted({ x: 0, y: 0, width: 100, height: 100 }, { width: 1000, height: 1000 }).scale).toBe(1); + expect(fitted({ x: 0, y: 0, width: 100_000, height: 10 }, { width: 500, height: 500 }).scale).toBe(MIN_ZOOM); + }); + + it('shows an empty surface at 100 % with its origin at the margin', () => { + expect(fitted({ x: 0, y: 0, width: 0, height: 0 }, { width: 500, height: 500 })).toEqual({ scale: 1, x: 24, y: 24 }); + expect(fitted({ x: 10, y: 10, width: 50, height: 50 }, { width: 0, height: 0 })).toEqual({ scale: 1, x: 14, y: 14 }); + }); + + it('measures two fingers', () => { + expect(spread({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5); + expect(middle({ x: 0, y: 10 }, { x: 4, y: 20 })).toEqual({ x: 2, y: 15 }); + }); +}); diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json new file mode 100644 index 0000000..b36f7c2 --- /dev/null +++ b/packages/editor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "resolveJsonModule": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/editor/vite.config.ts b/packages/editor/vite.config.ts new file mode 100644 index 0000000..08fb7da --- /dev/null +++ b/packages/editor/vite.config.ts @@ -0,0 +1,13 @@ +import react from '@vitejs/plugin-react'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +// The editor's page. The corpus (FILIUS' examples, for the development page +// and the browser tests) lies outside the package. +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + base: './', + plugins: [react()], + server: { fs: { allow: [fileURLToPath(new URL('../..', import.meta.url))] } }, + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/packages/fls/src/exportProject.ts b/packages/fls/src/exportProject.ts index 1da9781..2b27e7e 100644 --- a/packages/fls/src/exportProject.ts +++ b/packages/fls/src/exportProject.ts @@ -226,6 +226,7 @@ function exportNode(node: TopologyNode, project: ProjectToExport, servingModems: useMacAsName: showsMac(node.hostLabelPolicy), interfaces, portCount: javaClass === 'Switch' || javaClass === 'Modem' ? node.ports.length : interfaces.length, + ...(node.kind === 'networkSwitch' && project.switchConfigurations.get(node.id)?.showsAsCloud === true ? { cloud: true } : {}), system: exportSystem(node, project, servingModems, (message) => warnings.push(`node '${node.displayName}': ${message}`)), }; } diff --git a/packages/fls/src/importProject.ts b/packages/fls/src/importProject.ts index 0602871..5881f87 100644 --- a/packages/fls/src/importProject.ts +++ b/packages/fls/src/importProject.ts @@ -602,6 +602,7 @@ function registerSystemConfigurations( system.switchConfigurations.set(node.id, { ssid: ssid === '' ? defaultSwitchConfiguration(node.id).ssid : ssid, retentionTimeMilliseconds: source.system.retentionTime ?? 300_000, + ...(source.cloud ? { showsAsCloud: true } : {}), }); } // Java joins a wireless network when the card is wireless and an SSID is set diff --git a/packages/fls/src/topology.ts b/packages/fls/src/topology.ts index 3ff16a0..3573c65 100644 --- a/packages/fls/src/topology.ts +++ b/packages/fls/src/topology.ts @@ -43,6 +43,8 @@ export interface FlsNode { /** Hosts only (Rechner, Notebook); false for the others. */ useIpAsName: boolean; useMacAsName: boolean; + /** A Switch drawn as a cloud (Switch.cloud, the Internet in the examples); false for the others. */ + cloud: boolean; /** The interfaces of a Rechner, Notebook, Vermittlungsrechner or Gateway; empty for the others. */ interfaces: FlsInterface[]; /** Interfaces, or the ports of a Switch or Modem. */ @@ -204,6 +206,7 @@ class Reader { y, useIpAsName: info.host && this.booleanProperty(device, 'useIPAsName', `node ${itemIndex + 1}`), useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`), + cloud: simpleName === 'Switch' && this.booleanProperty(device, 'cloud', `node ${itemIndex + 1}`), interfaces: [], portCount: 0, system: readSystemSoftware(device, simpleName, (message) => diff --git a/packages/fls/src/topologyWriter.ts b/packages/fls/src/topologyWriter.ts index c4e5a11..d99d8b7 100644 --- a/packages/fls/src/topologyWriter.ts +++ b/packages/fls/src/topologyWriter.ts @@ -70,7 +70,7 @@ export type SystemToWrite = Omit & { export type NodeToWrite = Pick< FlsNode, 'javaClass' | 'name' | 'x' | 'y' | 'useIpAsName' | 'useMacAsName' | 'interfaces' | 'portCount' -> & { readonly system: SystemToWrite }; +> & { readonly system: SystemToWrite; readonly cloud?: boolean }; export interface TopologyToWrite { readonly nodes: readonly NodeToWrite[]; @@ -133,7 +133,8 @@ function nodeItem(node: NodeToWrite, ports: BeanValue[]): BeanObject { function device(node: NodeToWrite, ports: BeanValue[]): BeanObject { const constructed = CONSTRUCTED[node.javaClass]; - const statements: BeanStatement[] = [{ set: 'name', value: node.name }]; + // A switch drawn as a cloud: written only when true, before the name, as XMLEncoder orders Switch's properties. + const statements: BeanStatement[] = [...(node.cloud === true ? [{ set: 'cloud', value: true }] : []), { set: 'name', value: node.name }]; if (constructed.host) { statements.push({ set: 'useIPAsName', value: node.useIpAsName }, { set: 'useMACAsName', value: node.useMacAsName }); } diff --git a/packages/fls/test/exported.java.json b/packages/fls/test/exported.java.json index 46a8795..eac37c0 100644 --- a/packages/fls/test/exported.java.json +++ b/packages/fls/test/exported.java.json @@ -417,6 +417,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -822,6 +823,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1118,6 +1120,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1417,6 +1420,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1458,6 +1462,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1999,6 +2004,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -3502,6 +3508,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -3806,6 +3813,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -4737,6 +4745,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -6294,6 +6303,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -6592,6 +6602,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -7742,6 +7753,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -8249,6 +8261,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -8530,6 +8543,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -8827,6 +8841,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9123,6 +9138,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9738,6 +9754,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9951,6 +9968,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9992,6 +10010,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -10613,6 +10632,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11096,6 +11116,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11137,6 +11158,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11427,6 +11449,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11827,6 +11850,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -12669,6 +12693,7 @@ 21, 20 ], + "cloud": true, "ports": [ {}, {}, @@ -13201,7 +13226,7 @@ ] } ], - "sha256": "5f474b24ec3cd2a206ac86bccd0adb63e3b3c24bc22060643fa434e8edbd0c0c" + "sha256": "222f57095cb88eb285a82b85212bf1a970484c088a5993333705939406085a77" }, "java-examples/modem_beispiel.fls": { "success": true, @@ -13633,6 +13658,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -13946,6 +13972,7 @@ 21, 20 ], + "cloud": true, "ports": [ {}, {}, @@ -14032,7 +14059,7 @@ } ], "documentation": [], - "sha256": "353c4d3571e9dbc9dda20f6ef41cc1d56b5d96375e35679ae65d1bf1d279e69e" + "sha256": "6f878280aef6f517d1eb4bf5eb056298cce51e3aaeea8ebf872ee98bfe5fc6fd" }, "java-examples/nat_with_mapped_port.fls": { "success": true, @@ -14047,6 +14074,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -14088,6 +14116,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -14759,6 +14788,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -15015,6 +15045,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -15493,6 +15524,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -15741,6 +15773,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -16116,6 +16149,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -16361,6 +16395,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -17349,6 +17384,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -17433,6 +17469,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -17474,6 +17511,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -18248,6 +18286,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -18461,6 +18500,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -18502,6 +18542,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -19182,6 +19223,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, diff --git a/packages/fls/test/importProject.test.ts b/packages/fls/test/importProject.test.ts index c936649..be1ff2f 100644 --- a/packages/fls/test/importProject.test.ts +++ b/packages/fls/test/importProject.test.ts @@ -536,6 +536,7 @@ function flsNode(javaClass: FlsNode['javaClass'], fields: Partial = {}) y: 0, useIpAsName: false, useMacAsName: false, + cloud: false, interfaces: [], portCount: 0, system: {}, diff --git a/packages/fls/test/topology.test.ts b/packages/fls/test/topology.test.ts index afe8e83..0451b61 100644 --- a/packages/fls/test/topology.test.ts +++ b/packages/fls/test/topology.test.ts @@ -171,6 +171,7 @@ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) { ...(node.javaClass === 'Rechner' || node.javaClass === 'Notebook' ? { useIpAsName: node.useIpAsName, useMacAsName: node.useMacAsName } : {}), + ...(node.javaClass === 'Switch' ? { cloud: node.cloud } : {}), ports: node.javaClass === 'Switch' || node.javaClass === 'Modem' ? Array.from({ length: node.portCount }, () => ({})) diff --git a/packages/fls/test/written.java.json b/packages/fls/test/written.java.json index 55a1622..a1361eb 100644 --- a/packages/fls/test/written.java.json +++ b/packages/fls/test/written.java.json @@ -417,6 +417,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -814,6 +815,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1110,6 +1112,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1409,6 +1412,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -1450,6 +1454,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -2003,6 +2008,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -3530,6 +3536,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -3834,6 +3841,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -4745,6 +4753,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -6326,6 +6335,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -6624,6 +6634,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -7754,6 +7765,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -8253,6 +8265,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -8534,6 +8547,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -8831,6 +8845,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9127,6 +9142,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9742,6 +9758,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9955,6 +9972,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -9996,6 +10014,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -10617,6 +10636,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11100,6 +11120,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11141,6 +11162,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11431,6 +11453,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -11831,6 +11854,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -12673,6 +12697,7 @@ 21, 20 ], + "cloud": true, "ports": [ {}, {}, @@ -13201,7 +13226,7 @@ "font": null } ], - "sha256": "f34f202638d8723ab709a39933495ef5028c2ba1b7dd41a7952c7af3c68f1102" + "sha256": "307f7d281eff71dbed8f9ab29c12fac9fba3113765655cbbee167481f9cdad52" }, "java-examples/modem_beispiel.fls": { "success": true, @@ -13633,6 +13658,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -13946,6 +13972,7 @@ 21, 20 ], + "cloud": true, "ports": [ {}, {}, @@ -14032,7 +14059,7 @@ } ], "documentation": [], - "sha256": "181fd85daa8b7828e6619fb7733295c442cbe862f44435a99984b557607c4a5d" + "sha256": "9ab5152668178899b1366703c34ffecba77d3bff0a9a01e1a0cfb90e2b9f0454" }, "java-examples/nat_with_mapped_port.fls": { "success": true, @@ -14047,6 +14074,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -14088,6 +14116,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -14759,6 +14788,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -15015,6 +15045,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -15481,6 +15512,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -15729,6 +15761,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -16096,6 +16129,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -16341,6 +16375,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -17313,6 +17348,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -17397,6 +17433,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -17438,6 +17475,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -18224,6 +18262,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -18437,6 +18476,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -18478,6 +18518,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, @@ -19087,6 +19128,7 @@ 21, 20 ], + "cloud": false, "ports": [ {}, {}, diff --git a/scripts/code_language_allow.txt b/scripts/code_language_allow.txt index 50cf058..e89e35b 100644 --- a/scripts/code_language_allow.txt +++ b/scripts/code_language_allow.txt @@ -95,3 +95,5 @@ packages/core/src/runtime/apps/gnutella.ts::hallo packages/core/src/runtime/apps/gnutella.ts::welt # GMX: the mail provider of FILIUS' example Internet_Komplett_…_DE (mail.gmx.de, herbert@gmx.de), a proper name. packages/fls/test/classicsJava.test.ts::gmx +# Rechner, Vermittlungsrechner …: Java FILIUS' device classes, by which the browser test reads the bounds a file saved. +packages/editor/browser/canvas.spec.ts::rechner diff --git a/tools/java-oracle/src/FlsOracle.java b/tools/java-oracle/src/FlsOracle.java index f8ee307..3cba1b6 100644 --- a/tools/java-oracle/src/FlsOracle.java +++ b/tools/java-oracle/src/FlsOracle.java @@ -201,6 +201,8 @@ public class FlsOracle { json.put("useIpAsName", host.isUseIPAsName()); json.put("useMacAsName", host.isUseMACAsName()); } + // Drawn as a cloud (the Internet in the examples): JSwitchKonfiguration's check box. + if (node instanceof filius.hardware.knoten.Switch networkSwitch) json.put("cloud", networkSwitch.isCloud()); List ports = new ArrayList<>(); if (node instanceof InternetKnoten internet) { List nics = internet.getNetzwerkInterfaces();