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 0000000..a760416 Binary files /dev/null and b/packages/editor/src/assets/hardware/cloud.png differ diff --git a/packages/editor/src/assets/hardware/gateway.png b/packages/editor/src/assets/hardware/gateway.png new file mode 100644 index 0000000..aadef2c Binary files /dev/null and b/packages/editor/src/assets/hardware/gateway.png differ diff --git a/packages/editor/src/assets/hardware/laptop.png b/packages/editor/src/assets/hardware/laptop.png new file mode 100644 index 0000000..41b6f34 Binary files /dev/null and b/packages/editor/src/assets/hardware/laptop.png differ diff --git a/packages/editor/src/assets/hardware/modem.png b/packages/editor/src/assets/hardware/modem.png new file mode 100644 index 0000000..a837af4 Binary files /dev/null and b/packages/editor/src/assets/hardware/modem.png differ diff --git a/packages/editor/src/assets/hardware/router.png b/packages/editor/src/assets/hardware/router.png new file mode 100644 index 0000000..824d5cb Binary files /dev/null and b/packages/editor/src/assets/hardware/router.png differ diff --git a/packages/editor/src/assets/hardware/server.png b/packages/editor/src/assets/hardware/server.png new file mode 100644 index 0000000..fa7f7c4 Binary files /dev/null and b/packages/editor/src/assets/hardware/server.png differ diff --git a/packages/editor/src/assets/hardware/switch.png b/packages/editor/src/assets/hardware/switch.png new file mode 100644 index 0000000..a099fd2 Binary files /dev/null and b/packages/editor/src/assets/hardware/switch.png differ 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();