feat(editor): F4.4b the design tools: palette, moving, cables, menus, undo

The palette in Java's order with Java's icons and names: a device dragged
onto the surface lands with its icon at the drop point, a tapped one in the
middle of the view; a new router asks for 2 to 8 interfaces as Java does.
A tap selects, a drag moves (divided by the scale), a long press or the
three-dot button opens Java's menu. The cable tool plugs by a drag or by
two taps, shows WAN or LAN on a home router's halves and Java's messages
when refused. Delete removes the selection; undo and redo by button and
Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse
and touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:37:49 +00:00
co-authored by Claude Opus 5.5
parent 27ab7a1f49
commit 806df6baca
16 changed files with 1218 additions and 74 deletions
+4
View File
@@ -152,6 +152,10 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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/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` | | `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 | | `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 |
| `src/design/Palette.tsx`, `src/assets/hardware/kabel.png` | Java `GUIDesignSidebar` (Reihenfolge, Kabel zuerst), Javas Symbol `kabel.png` | neu (F4.4b): die Palette — Gerät aufs Feld ziehen (Symbol mittig am Ablegepunkt, wie Javas Vorschau) oder antippen (Mitte der Ansicht), das Kabel als Werkzeug |
| `src/design/ContextMenu.tsx`, `src/design/RouterDialog.tsx` | Java `GUIEvents.kontextMenueEntwurfsmodus` (`guievents_msg5`–`7`), `GUIContainer.neuerKnoten` (`guicontainer_msg1`/`2`) | neu (F4.4b): Menü eines Geräts oder Kabels (Langdruck oder Dreipunkt-Knopf), Wahl der Schnittstellenzahl eines neuen Vermittlungsrechners (2 bis 8, ohne Wahl 2); Javas Tippfehler „Vermittlungsrechnerns“ ausgebessert |
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Werkzeuge) | Java `GUIEvents` (Kabel ziehen, Heimrouter-Hälften, Meldungen `guievents_msg1/12/22–26`) | neu (F4.4b): Auswählen, Gerät ziehen, Langdruck, Kabel ziehen oder zweimal antippen mit Gummiband und WAN/LAN-Hinweis, Javas Meldungen, Entf-Taste, Rückgängig/Wiederholen per Knopf und Strg+Z/Strg+Y; jede Änderung mit dem eigenen Ursprung des Editors |
| `browser/design.spec.ts`, `browser/editorPage.ts` | — | neu (F4.4b): die Werkzeuge in Chromium mit Maus und Berührung (CDP) |
## tools/swift-oracle ## tools/swift-oracle
+9 -2
View File
@@ -29,8 +29,8 @@ import {
import type { ImportedProject } from '@mdg-filius/fls'; import type { ImportedProject } from '@mdg-filius/fls';
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js'; import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar). */ /** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink']; export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink'];
/** A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is closed). */ /** A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is closed). */
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const; export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const;
@@ -141,6 +141,13 @@ export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => links(doc).delete(id), origin); doc.transact(() => links(doc).delete(id), origin);
} }
/** Java's "remove all cables" of a device (its context menu). */
export function removeLinksOf(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => {
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
}, origin);
}
/** A cable's end: a device, and the port when one was asked for. */ /** A cable's end: a device, and the port when one was asked for. */
export interface CableEnd { export interface CableEnd {
readonly node: Id; readonly node: Id;
+15 -1
View File
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { interfaceKey, NetworkRuntimeEngine, type Id } from '@mdg-filius/core'; import { interfaceKey, NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { openProjectArchive, projectSnapshot, type ImportedProject } from '@mdg-filius/fls'; import { openProjectArchive, projectSnapshot, type ImportedProject } from '@mdg-filius/fls';
import { addDevice, connect, createUndoManager, docSnapshot, moveDevice, newDeviceProject, readProject, removeDevice, removeLink, repair, ROOTS, topologyView, type NewDevice } from '../src/index.js'; import { addDevice, connect, createUndoManager, docSnapshot, moveDevice, newDeviceProject, PALETTE, readProject, removeDevice, removeLink, removeLinksOf, repair, ROOTS, topologyView, type NewDevice } from '../src/index.js';
const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/new-devices.fls'); const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/new-devices.fls');
@@ -75,6 +75,10 @@ describe('a new device', () => {
} }
}); });
it("comes from a palette in Java's order", () => {
expect(PALETTE).toEqual(['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink']);
});
it('keeps a router between 2 and 8 interfaces, 2 when not asked', () => { it('keeps a router between 2 and 8 interfaces, 2 when not asked', () => {
const count = (interfaces?: number) => newDeviceProject(device('router', interfaces === undefined ? {} : { interfaces })).graph.nodes[0]!.ports.length; const count = (interfaces?: number) => newDeviceProject(device('router', interfaces === undefined ? {} : { interfaces })).graph.nodes[0]!.ports.length;
expect([count(), count(1), count(5), count(12)]).toEqual([2, 2, 5, 8]); expect([count(), count(1), count(5), count(12)]).toEqual([2, 2, 5, 8]);
@@ -156,6 +160,16 @@ describe('a cable', () => {
expect(topologyView(doc).links[0]!.wireless).toBe(true); expect(topologyView(doc).links[0]!.wireless).toBe(true);
}); });
it('is pulled with all of a device\'s cables, the device staying', () => {
const { doc, pcA, pcB, hub, router, home } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me');
expect(connect(doc, { node: router.id }, { node: home.id }, 'link-3', 'me')).toEqual({ link: 'link-3' });
removeLinksOf(doc, hub.id, 'me');
expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-3']);
expect(readProject(doc).graph.nodes.some((node) => node.id === hub.id)).toBe(true);
});
it('is pulled alone, and with the device it is plugged into', () => { it('is pulled alone, and with the device it is plugged into', () => {
const { doc, pcA, pcB, hub } = network(); const { doc, pcA, pcB, hub } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me'); connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
+5 -7
View File
@@ -14,25 +14,23 @@ import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test'; 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 { decodeJavaBeans, isJavaObject, listItems, readFlsArchive } from '@mdg-filius/fls';
import { ICONS, iconOf } from '../src/canvas/layout.js'; import { ICONS, iconOf } from '../src/canvas/layout.js';
import { serveEditor } from './editorPage.js';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const config = join(here, '../vite.config.ts');
const examples = join(here, '../../../corpus/java-examples'); const examples = join(here, '../../../corpus/java-examples');
let server: PreviewServer; let server: { readonly base: string; readonly close: () => Promise<void> };
let base: string; let base: string;
test.beforeAll(async () => { test.beforeAll(async () => {
await build({ configFile: config, logLevel: 'silent' }); server = await serveEditor();
server = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' }); base = server.base;
base = server.resolvedUrls!.local[0]!;
}); });
test.afterAll(async () => { test.afterAll(async () => {
await new Promise<void>((resolve) => server.httpServer.close(() => resolve())); await server.close();
}); });
/** The width and height Java gave each device's button when it saved the file, in the file's order. */ /** The width and height Java gave each device's button when it saved the file, in the file's order. */
+247
View File
@@ -0,0 +1,247 @@
// The design mode's tools in Chromium (Playwright): devices from the palette,
// moving them, undo, cables by drag and by two taps with Java's refusals, a
// home router's WAN and LAN halves, the menus by long press and button,
// deleting by key — with the mouse and, where a finger differs, by touch.
import { expect, test, type Page } from '@playwright/test';
import { boxOf, drag, drawing, iconCentre, onDevice, openEditor, scaleOf, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
async function paletteCentre(page: Page, kind: string): Promise<{ x: number; y: number }> {
const box = (await page.locator(`[data-palette="${kind}"]`).boundingBox())!;
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** Drags a device from the palette to a point of the screen; its id. */
async function dropDevice(page: Page, kind: string, at: { x: number; y: number }): Promise<string> {
const before = new Set((await drawing(page)).nodes.map((node) => node.id));
await drag(page, await paletteCentre(page, kind), at);
if (kind === 'router') await page.getByRole('dialog').getByRole('button', { name: '2', exact: true }).click();
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(before.size + 1);
return (await drawing(page)).nodes.find((node) => !before.has(node.id))!.id;
}
async function surfaceAt(page: Page, x: number, y: number): Promise<{ x: number; y: number }> {
const box = (await page.locator('svg.filius-canvas').boundingBox())!;
return { x: box.x + x, y: box.y + y };
}
test('places a device from the palette where it is dropped, named as Java names it, and selects it', async ({ page }) => {
await openEditor(page, server.base, 'new');
const at = await surfaceAt(page, 300, 250);
const id = await dropDevice(page, 'pc', at);
const node = (await drawing(page)).nodes[0]!;
expect([node.kind, node.name, node.label]).toEqual(['pc', 'Rechner', 'Rechner']);
const centre = await iconCentre(page, id);
expect(Math.abs(centre.x - at.x)).toBeLessThan(1.5);
expect(Math.abs(centre.y - at.y)).toBeLessThan(1.5);
await expect(page.locator(`g.device[data-node="${id}"]`)).toHaveAttribute('data-selected', 'true');
// A tap on the palette puts one in the middle of the view; no second one from the click after a drag.
await page.locator('[data-palette="notebook"]').click();
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(2);
const notebook = (await drawing(page)).nodes[1]!;
expect(notebook.name).toBe('Notebook');
const middle = await surfaceAt(page, 0, 0);
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
const placed = await iconCentre(page, notebook.id);
expect(Math.abs(placed.x - (middle.x + surface.width / 2))).toBeLessThan(1.5);
expect(Math.abs(placed.y - (middle.y + surface.height / 2))).toBeLessThan(1.5);
});
test("asks a router's interfaces and makes as many, two when the dialog is left", async ({ page }) => {
await openEditor(page, server.base, 'new');
await drag(page, await paletteCentre(page, 'router'), await surfaceAt(page, 300, 200));
const dialog = page.getByRole('dialog', { name: 'Anzahl Schnittstellen' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: '5', exact: true }).click();
await expect(dialog).toBeHidden();
expect((await drawing(page)).nodes[0]!.ports.map((port) => port.label)).toEqual(['rt1', 'rt2', 'rt3', 'rt4', 'rt5']);
await drag(page, await paletteCentre(page, 'router'), await surfaceAt(page, 500, 200));
await expect(dialog).toBeVisible();
await page.keyboard.press('Escape');
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(2);
expect((await drawing(page)).nodes[1]!.ports).toHaveLength(2);
expect((await drawing(page)).nodes.map((node) => node.name)).toEqual(['Vermittlungsrechner', 'Vermittlungsrechner']);
});
test('moves a device with a drag, and takes it back and brings it again', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = (await drawing(page)).nodes.find((node) => node.name === 'Notebook 1.10')!.id;
// Zoomed in: the pointer's way is divided by the scale.
await page.getByRole('button', { name: 'Vergrößern' }).click();
const before = await boxOf(page, id);
const scale = await scaleOf(page);
expect(scale).not.toBe(1);
const from = await onDevice(page, id);
await drag(page, from, { x: from.x + 80, y: from.y + 40 });
const moved = await boxOf(page, id);
expect(moved.x).toBe(Math.round(before.x + 80 / scale));
expect(moved.y).toBe(Math.round(before.y + 40 / scale));
await page.getByRole('button', { name: 'Rückgängig' }).click();
expect(await boxOf(page, id)).toEqual(before);
await page.getByRole('button', { name: 'Wiederholen' }).click();
expect(await boxOf(page, id)).toEqual(moved);
await page.keyboard.press('Control+z');
expect(await boxOf(page, id)).toEqual(before);
await expect(page.getByRole('button', { name: 'Rückgängig' })).toBeDisabled();
// Two drags right after each other are two steps back, not one.
const start = await onDevice(page, id);
await drag(page, start, { x: start.x + 50, y: start.y });
const first = await boxOf(page, id);
await drag(page, { x: start.x + 50, y: start.y }, { x: start.x + 50, y: start.y + 50 });
await page.keyboard.press('Control+z');
expect(await boxOf(page, id)).toEqual(first);
});
test('plugs cables with a drag and with two taps, and says why not as Java does', async ({ page }) => {
await openEditor(page, server.base, 'new');
const pcA = await dropDevice(page, 'pc', await surfaceAt(page, 150, 150));
const pcB = await dropDevice(page, 'pc', await surfaceAt(page, 150, 400));
const hub = await dropDevice(page, 'networkSwitch', await surfaceAt(page, 450, 280));
const router = await dropDevice(page, 'router', await surfaceAt(page, 700, 280));
await page.locator('[data-palette="cable"]').click();
await expect(page.locator('[data-palette="cable"]')).toHaveAttribute('aria-pressed', 'true');
await drag(page, await onDevice(page, pcA), await onDevice(page, hub));
await expect.poll(async () => (await drawing(page)).links.length).toBe(1);
// Two taps: the first marks the start, the second plugs.
await page.mouse.click(...Object.values(await onDevice(page, pcB)) as [number, number]);
await expect(page.locator(`g.device[data-node="${pcB}"] rect[stroke-dasharray]`)).toBeVisible();
await page.mouse.click(...Object.values(await onDevice(page, hub)) as [number, number]);
await expect.poll(async () => (await drawing(page)).links.length).toBe(2);
const links = (await drawing(page)).links;
expect(links.map((link) => [link.sourceNode, link.targetNode])).toEqual([
[pcA, hub],
[pcB, hub],
]);
await drag(page, await onDevice(page, pcA), await onDevice(page, router));
await expect(page.getByRole('alert')).toHaveText('Maximale Anzahl angeschlossener Geräte überschritten');
await drag(page, await onDevice(page, router), await onDevice(page, hub));
await drag(page, await onDevice(page, hub), await onDevice(page, router));
await expect(page.getByRole('alert')).toHaveText('Komponenten bereits verbunden');
expect((await drawing(page)).links).toHaveLength(3);
await expect(page.locator('path[data-link]')).toHaveCount(3);
});
test("joins a home router at WAN on its button's left half and LAN on its right, and says which while dragging", async ({ page }) => {
await openEditor(page, server.base, 'new');
const router = await dropDevice(page, 'router', await surfaceAt(page, 200, 150));
const hub = await dropDevice(page, 'networkSwitch', await surfaceAt(page, 200, 400));
const home = await dropDevice(page, 'gateway', await surfaceAt(page, 550, 280));
await page.locator('[data-palette="cable"]').click();
const left = await onDevice(page, home, 0.25);
await page.mouse.move(...Object.values(await onDevice(page, router)) as [number, number]);
await page.mouse.down();
await page.mouse.move(left.x, left.y, { steps: 8 });
await expect(page.locator('[data-hint]')).toHaveText('WAN-Anschluss (extern)');
await page.mouse.up();
await expect(page.locator('[data-hint]')).toHaveCount(0);
const right = await onDevice(page, home, 0.75);
await page.mouse.move(...Object.values(await onDevice(page, hub)) as [number, number]);
await page.mouse.down();
await page.mouse.move(right.x, right.y, { steps: 8 });
await expect(page.locator('[data-hint]')).toHaveText('LAN-Anschluss (intern)');
await page.mouse.up();
const view = await drawing(page);
const ports = view.nodes.find((node) => node.id === home)!.ports.map((port) => port.id);
expect(view.links.map((link) => link.targetPort)).toEqual([ports[0], ports[1]]);
});
test("opens a device's menu with a long press and with its button, and removes cables and devices", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const view = await drawing(page);
const hub = view.nodes.find((node) => node.name === 'Switch 1')!.id;
const notebook = view.nodes.find((node) => node.name === 'Notebook 1.10')!.id;
// A notebook with its cable: "remove cable", one (Java names the item by the kind).
await page.mouse.click(...Object.values(await onDevice(page, notebook)) as [number, number]);
await page.locator('.device-menu-button').click();
await expect(page.getByRole('menuitem')).toHaveText(['Verbindung entfernen', 'Löschen']);
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toBeHidden();
const press = await onDevice(page, hub);
await page.mouse.move(press.x, press.y);
await page.mouse.down();
await page.waitForTimeout(700);
await expect(page.getByRole('menu')).toBeVisible();
await page.mouse.up();
await expect(page.getByRole('menuitem')).toHaveText(['Alle Verbindungen entfernen', 'Löschen']);
await page.getByRole('menuitem', { name: 'Alle Verbindungen entfernen' }).click();
const after = await drawing(page);
expect(after.links.some((link) => link.sourceNode === hub || link.targetNode === hub)).toBe(false);
expect(after.nodes.some((node) => node.id === hub)).toBe(true);
expect(after.links.length).toBe(view.links.length - 3);
// The notebook's only cable went with the switch's: its menu has only "delete".
await page.mouse.click(...Object.values(await onDevice(page, notebook)) as [number, number]);
await page.locator('.device-menu-button').click();
await expect(page.getByRole('menuitem')).toHaveText(['Löschen']);
await page.getByRole('menuitem', { name: 'Löschen' }).click();
expect((await drawing(page)).nodes.some((node) => node.id === notebook)).toBe(false);
await page.keyboard.press('Control+z');
expect((await drawing(page)).nodes.some((node) => node.id === notebook)).toBe(true);
});
test('selects a cable with a tap and removes it with the Delete key', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const link = (await drawing(page)).links[0]!.id;
const point = await page.locator(`path[data-link="${link}"]`).evaluate((path: SVGPathElement) => {
const at = path.getPointAtLength(path.getTotalLength() / 2);
const screen = at.matrixTransform(path.getScreenCTM()!);
return { x: screen.x, y: screen.y };
});
await page.mouse.click(point.x, point.y);
await expect(page.locator(`path[data-link="${link}"]`)).toHaveAttribute('data-selected', 'true');
await page.keyboard.press('Delete');
expect((await drawing(page)).links.some((each) => each.id === link)).toBe(false);
});
test('selects a device with a tap right after the surface was moved', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
await drag(page, { x: surface.x + 30, y: surface.y + surface.height - 30 }, { x: surface.x + 90, y: surface.y + surface.height - 60 });
const id = (await drawing(page)).nodes.find((node) => node.name === 'DNS-Server 2.10')!.id;
await page.mouse.click(...Object.values(await onDevice(page, id)) as [number, number]);
await expect(page.locator(`g.device[data-node="${id}"]`)).toHaveAttribute('data-selected', 'true');
});
test('by touch: a long press opens the menu, a finger drags a device', async ({ browser }) => {
const context = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } });
const page = await context.newPage();
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const cdp = await context.newCDPSession(page);
const touch = (type: 'touchStart' | 'touchMove' | 'touchEnd', points: readonly { x: number; y: number }[]) =>
cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(({ x, y }, id) => ({ x, y, id })) });
const id = (await drawing(page)).nodes.find((node) => node.name === 'Notebook 1.11')!.id;
const at = await onDevice(page, id);
await touch('touchStart', [at]);
await page.waitForTimeout(700);
await touch('touchEnd', []);
await expect(page.getByRole('menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toBeHidden();
const before = await boxOf(page, id);
const scale = await scaleOf(page);
await touch('touchStart', [at]);
for (let step = 1; step <= 6; step += 1) await touch('touchMove', [{ x: at.x + step * 10, y: at.y }]);
await touch('touchEnd', []);
expect((await boxOf(page, id)).x).toBe(Math.round(before.x + 60 / scale));
await expect(page.getByRole('menu')).toBeHidden();
await context.close();
});
+62
View File
@@ -0,0 +1,62 @@
// What the editor's browser tests share: the built page served once per file,
// and reading the surface (its transform, a device's place on the screen, the
// document's drawing through the development page's filiusDev).
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Page } from '@playwright/test';
import { build, preview, type PreviewServer } from 'vite';
import type { TopologyView } from '@mdg-filius/doc';
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
export async function serveEditor(): Promise<{ readonly base: string; readonly close: () => Promise<void> }> {
await build({ configFile: config, logLevel: 'silent' });
const server: PreviewServer = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' });
return {
base: server.resolvedUrls!.local[0]!,
close: () => new Promise<void>((resolve) => server.httpServer.close(() => resolve())),
};
}
export async function openEditor(page: Page, base: string, example: string, query = ''): Promise<void> {
await page.goto(`${base}?example=${example}${query}`);
await page.waitForSelector('svg.filius-canvas');
await page.waitForFunction(() => (window as unknown as { filiusDev?: unknown }).filiusDev !== undefined);
}
/** The document's drawing. */
export function drawing(page: Page): Promise<TopologyView> {
return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view());
}
/** A device's button as data-box says it (surface pixels). */
export async function boxOf(page: Page, id: string): Promise<{ x: number; y: number; width: number; height: number }> {
const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number];
return { x, y, width, height };
}
/** Where a device's icon's middle is on the screen. */
export async function iconCentre(page: Page, id: string): Promise<{ x: number; y: number }> {
const box = (await page.locator(`g.device[data-node="${id}"] image`).boundingBox())!;
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** A point of a device's button on the screen, by its share of the button's width and height. */
export async function onDevice(page: Page, id: string, across = 0.5, down = 0.4): Promise<{ x: number; y: number }> {
const box = (await page.locator(`g.device[data-node="${id}"] > rect`).first().boundingBox())!;
return { x: box.x + box.width * across, y: box.y + box.height * down };
}
/** The surface's scale. */
export async function scaleOf(page: Page): Promise<number> {
return Number(await page.locator('[data-zoom]').getAttribute('data-zoom'));
}
/** Drags with the mouse from one point to another in steps. */
export async function drag(page: Page, from: { x: number; y: number }, to: { x: number; y: number }): Promise<void> {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 8 });
await page.mouse.up();
}
+229 -8
View File
@@ -1,10 +1,19 @@
// The editor on a document: for now the surface that draws it (F4.3); the // The editor on a document: the palette, the surface, the tools of the design
// tools, the device sheet and the simulation follow (F4.4–F4.8). // mode (F4.4b). The device sheet and the simulation follow (F4.5, F4.6).
//
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
// the others in the room.
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs'; import type * as Y from 'yjs';
import { topologyView, type TopologyView } from '@mdg-filius/doc'; import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas } from './canvas/Canvas.js'; import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
import type { Point } from './canvas/viewport.js';
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { useTranslate } from './i18n/i18n.js';
/** The document's drawing, read anew after every change. */ /** The document's drawing, read anew after every change. */
export function useTopologyView(doc: Y.Doc): TopologyView { export function useTopologyView(doc: Y.Doc): TopologyView {
@@ -18,7 +27,219 @@ export function useTopologyView(doc: Y.Doc): TopologyView {
return view; return view;
} }
export function Editor({ doc }: { readonly doc: Y.Doc }) { /**
const view = useTopologyView(doc); * This person's undo and redo, and whether there is something to take back
return <Canvas view={view} />; * or bring back. Made in the effect, not in a memo: React's strict mode runs
* an effect's cleanup once while developing, and a destroyed manager must
* not be used again.
*/
function useUndo(doc: Y.Doc, origin: object) {
const [manager, setManager] = useState<Y.UndoManager | null>(null);
const [stacks, setStacks] = useState({ undo: 0, redo: 0 });
useEffect(() => {
const made = createUndoManager(doc, origin);
const changed = () => setStacks({ undo: made.undoStack.length, redo: made.redoStack.length });
made.on('stack-item-added', changed);
made.on('stack-item-popped', changed);
made.on('stack-cleared', changed);
setManager(made);
setStacks({ undo: 0, redo: 0 });
return () => made.destroy();
}, [doc, origin]);
return { manager, canUndo: stacks.undo > 0, canRedo: stacks.redo > 0 };
}
const toolButton: CSSProperties = {
minWidth: 44,
minHeight: 44,
padding: '0 12px',
border: '1px solid var(--border-default)',
borderRadius: 8,
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
font: 'inherit',
cursor: 'pointer',
};
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
export function Editor({ doc }: { readonly doc: Y.Doc }) {
const t = useTranslate();
const view = useTopologyView(doc);
const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []);
const { manager, canUndo, canRedo } = useUndo(doc, origin);
const canvas = useRef<CanvasHandle>(null);
const [cableTool, setCableTool] = useState(false);
const [selection, setSelection] = useState<Selection | null>(null);
const [cableFrom, setCableFrom] = useState<{ readonly id: string; readonly side: Side } | null>(null);
const [menu, setMenu] = useState<{ readonly target: Selection; readonly at: Point } | null>(null);
const [routerAt, setRouterAt] = useState<Point | null>(null);
const [message, setMessage] = useState<string | null>(null);
// A message goes after a while.
useEffect(() => {
if (message === null) return undefined;
const timer = setTimeout(() => setMessage(null), 4_000);
return () => clearTimeout(timer);
}, [message]);
// What is selected may be gone (removed here or by someone else).
useEffect(() => {
if (selection?.kind === 'device' && !view.nodes.some((node) => node.id === selection.id)) setSelection(null);
if (selection?.kind === 'link' && !view.links.some((link) => link.id === selection.id)) setSelection(null);
if (cableFrom !== null && !view.nodes.some((node) => node.id === cableFrom.id)) setCableFrom(null);
}, [view, selection, cableFrom]);
const place = useCallback(
(kind: PaletteKind, at: Point, interfaces?: number) => {
const id = crypto.randomUUID();
addDevice(doc, { kind, id, name: t(`device.${kind}`), x: at.x, y: at.y, newId: () => crypto.randomUUID(), ...(interfaces === undefined ? {} : { interfaces }) }, origin);
manager?.stopCapturing();
// Java selects the new device.
setSelection({ kind: 'device', id });
},
[doc, origin, manager, t],
);
const onDevice = (kind: PaletteKind, at: { readonly clientX: number; readonly clientY: number } | null) => {
const name = t(`device.${kind}`);
const corner = at === null ? canvas.current?.placeInView(kind, name) : canvas.current?.placeAt(kind, name, at.clientX, at.clientY);
if (corner === null || corner === undefined) return;
if (kind === 'router') setRouterAt(corner);
else place(kind, corner);
};
const onCable = (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => {
// A home router's LAN port where the right half of its button was met (Java), else its WAN port.
const end = (each: { readonly id: string; readonly side: Side }) => {
const node = view.nodes.find((candidate) => candidate.id === each.id);
return node?.kind === 'gateway' && each.side === 'right' ? { node: each.id, port: node.ports[1]!.id } : { node: each.id };
};
const result = connect(doc, end(from), end(to), crypto.randomUUID(), origin);
manager?.stopCapturing();
setCableFrom(null);
if ('error' in result) setMessage(t(`cable.${result.error}`));
};
const remove = useCallback(
(target: Selection) => {
if (target.kind === 'device') removeDevice(doc, target.id, origin);
else removeLink(doc, target.id, origin);
manager?.stopCapturing();
setSelection(null);
},
[doc, origin, manager],
);
const menuItems = (target: Selection): MenuItem[] => {
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id);
const cables = view.links.some((link) => link.sourceNode === target.id || link.targetNode === target.id);
// Java names the item by how many cables the kind takes; shown only where there is one to remove.
const items: MenuItem[] = [];
if (cables) {
items.push({
key: 'removeCables',
label: t(node?.kind === 'pc' || node?.kind === 'notebook' ? 'menu.removeCable' : 'menu.removeAllCables'),
onChoose: () => {
removeLinksOf(doc, target.id, origin);
manager?.stopCapturing();
},
});
}
items.push({ key: 'delete', label: t('menu.delete'), danger: true, onChoose: () => remove(target) });
return items;
};
// The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (typing(event.target)) return;
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault();
if (event.shiftKey) manager?.redo();
else manager?.undo();
} else if (command && event.key.toLowerCase() === 'y') {
event.preventDefault();
manager?.redo();
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selection !== null && menu === null && routerAt === null) {
event.preventDefault();
remove(selection);
} else if (event.key === 'Escape') {
setCableFrom(null);
setSelection(null);
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove]);
return (
<div style={{ display: 'flex', height: '100%' }}>
<Palette
cableTool={cableTool}
onCableTool={(on) => {
setCableTool(on);
setCableFrom(null);
}}
onDevice={onDevice}
/>
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<Canvas
ref={canvas}
view={view}
tool={cableTool ? 'cable' : 'select'}
selection={selection}
cableFrom={cableFrom}
onSelect={setSelection}
onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)}
onMoveEnd={() => manager?.stopCapturing()}
onCableStart={setCableFrom}
onCable={onCable}
onMenu={(target, at) => {
setSelection(target);
setMenu({ target, at });
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
</div>
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
<RouterDialog
onChoose={(interfaces) => {
place('router', routerAt, interfaces);
setRouterAt(null);
}}
/>
)}
{message === null ? null : (
<div
role="alert"
style={{
position: 'absolute',
left: '50%',
bottom: 72,
transform: 'translateX(-50%)',
padding: '10px 16px',
borderRadius: 8,
background: 'var(--danger-soft)',
color: 'var(--danger-on-soft)',
border: '1px solid var(--danger)',
maxWidth: '80%',
}}
>
{message}
</div>
)}
</div>
</div>
);
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+308 -43
View File
@@ -1,23 +1,60 @@
// The surface (plan §7.1): the devices with Java's icons and labels, the // The surface (plan §7.1, §7.2): the devices with Java's icons and labels,
// cables, the documentation items, in an SVG that one moves with a finger or // the cables, the documentation items, in an SVG; and what a finger or the
// the mouse and zooms with two fingers, a pinch on the trackpad, Ctrl and the // mouse does on it.
// wheel or the buttons. //
// - On the empty surface: a drag moves it, two fingers zoom, a tap clears
// the selection. The wheel moves it, with Ctrl (a trackpad's pinch) zooms.
// - On a device: a tap selects it, a drag moves it, a long press (or the
// three-dot button of the selected one) opens its menu. With the cable
// tool a drag from one device to another plugs a cable, and so do two
// taps, one on each (Java's way).
// - On a cable: a tap selects it, a long press opens its menu.
// //
// The pointer is captured only once it has moved: captured at once, it would // 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 // take the click from whatever lies under it (pointer-capture trap; jsdom
// never shows it, Playwright does). The surface sets touch-action: none, so // 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). // 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 { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type { NodeKind } from '@mdg-filius/core';
import type { DocumentationView, NodeView, TopologyView } from '@mdg-filius/doc'; import type { DocumentationView, NodeView, TopologyView } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js'; import { useTranslate } from '../i18n/i18n.js';
import { ICON_URLS } from './icons.js'; import { ICON_URLS } from './icons.js';
import { bounds, cablePath, deviceBox, JAVA_LABEL_FONT, type DeviceBox } from './layout.js'; import { bounds, cablePath, deviceBox, ICONS, iconOf, 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 { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
import { ZoomBar } from './ZoomBar.js'; import { ZoomBar } from './ZoomBar.js';
/** How far a pointer moves before a press becomes a drag. */ /** How far a pointer moves before a press becomes a drag. */
const DRAG_THRESHOLD = 4; export const DRAG_THRESHOLD = 4;
/** How long a press lasts before it opens the menu. */
export const LONG_PRESS_MILLISECONDS = 500;
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string };
export type Tool = 'select' | 'cable';
/** Where on a home router's button a cable meets it: Java takes the left half for WAN, the right for LAN. */
export type Side = 'left' | 'right';
export interface CanvasHandle {
/** The top left corner for a new device whose icon's middle is at a point of the screen; null outside the surface. */
placeAt(kind: NodeKind, name: string, clientX: number, clientY: number): Point | null;
/** The same, in the middle of what is in view. */
placeInView(kind: NodeKind, name: string): Point;
}
export interface CanvasProps {
readonly view: TopologyView;
readonly tool?: Tool;
readonly selection?: Selection | null;
/** The device a cable starts at, after a first tap with the cable tool. */
readonly cableFrom?: { readonly id: string; readonly side: Side } | null;
readonly onSelect?: (selection: Selection | null) => void;
/** A device dragged: its button's new top left corner. */
readonly onMoveDevice?: (id: string, x: number, y: number) => void;
readonly onMoveEnd?: () => void;
readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void;
readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void;
readonly onMenu?: (target: Selection, at: Point) => void;
}
/** The label's width in Java's font, measured once per text. */ /** The label's width in Java's font, measured once per text. */
function useTextWidth(): (text: string) => number { function useTextWidth(): (text: string) => number {
@@ -62,9 +99,32 @@ function Documentation({ item }: { readonly item: DocumentationView }) {
); );
} }
function Device({ node, box, kindName }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string }) { function Device({ node, box, kindName, selected, pending }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string; readonly selected: boolean; readonly pending: boolean }) {
return ( return (
<g className="device" data-node={node.id} data-box={`${box.x} ${box.y} ${box.width} ${box.height}`} role="img" aria-label={`${kindName}: ${node.label}`}> <g
className="device"
data-node={node.id}
data-box={`${box.x} ${box.y} ${box.width} ${box.height}`}
data-selected={selected ? 'true' : undefined}
role="img"
aria-label={`${kindName}: ${node.label}`}
style={{ cursor: 'pointer' }}
>
{/* The whole button takes the pointer, as Java's label does. */}
<rect x={box.x} y={box.y} width={box.width} height={box.height} fill="transparent" />
{selected || pending ? (
<rect
x={box.x - 2}
y={box.y - 2}
width={box.width + 4}
height={box.height + 4}
rx={6}
fill="color-mix(in srgb, var(--accent) 12%, transparent)"
stroke="var(--accent)"
strokeWidth={2}
strokeDasharray={pending ? '6 4' : undefined}
/>
) : null}
<image href={ICON_URLS[box.icon]} x={box.iconRect.x} y={box.iconRect.y} width={box.iconRect.width} height={box.iconRect.height} /> <image href={ICON_URLS[box.icon]} x={box.iconRect.x} y={box.iconRect.y} width={box.iconRect.width} height={box.iconRect.height} />
<text x={box.label.x} y={box.label.y} textAnchor="middle" style={{ font: JAVA_LABEL_FONT, fill: 'var(--canvas-label)' }}> <text x={box.label.x} y={box.label.y} textAnchor="middle" style={{ font: JAVA_LABEL_FONT, fill: 'var(--canvas-label)' }}>
{node.label} {node.label}
@@ -73,19 +133,26 @@ function Device({ node, box, kindName }: { readonly node: NodeView; readonly box
); );
} }
export interface CanvasProps { type Gesture =
readonly view: TopologyView; | { readonly kind: 'background'; readonly start: Point; moved: boolean; last: Point }
} | { readonly kind: 'pinch'; spread: number; middle: Point }
| { readonly kind: 'device'; readonly id: string; readonly start: Point; readonly origin: Point; readonly side: Side; moved: boolean; longPressed: boolean }
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
export function Canvas({ view }: CanvasProps) { export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu },
handle,
) {
const t = useTranslate(); const t = useTranslate();
const surface = useRef<SVGSVGElement>(null); const surface = useRef<SVGSVGElement>(null);
const [size, setSize] = useState<Size>({ width: 0, height: 0 }); const [size, setSize] = useState<Size>({ width: 0, height: 0 });
const [viewport, setViewport] = useState<Viewport | null>(null); const [viewport, setViewport] = useState<Viewport | null>(null);
const [band, setBand] = useState<{ readonly from: Point; readonly to: Point; readonly hint: string | null } | null>(null);
const textWidth = useTextWidth(); const textWidth = useTextWidth();
const boxes = useMemo(() => new Map(view.nodes.map((node) => [node.id, deviceBox(node, textWidth(node.label))])), [view.nodes, textWidth]); 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]); 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]);
const kinds = useMemo(() => new Map(view.nodes.map((node) => [node.id, node.kind])), [view.nodes]);
useLayoutEffect(() => { useLayoutEffect(() => {
const element = surface.current; const element = surface.current;
@@ -101,24 +168,105 @@ export function Canvas({ view }: CanvasProps) {
}, [viewport, size, content]); }, [viewport, size, content]);
const current = viewport ?? { scale: 1, x: 0, y: 0 }; const current = viewport ?? { scale: 1, x: 0, y: 0 };
const currentRef = useRef(current);
currentRef.current = current;
const local = useCallback((clientX: number, clientY: number): Point => { const local = useCallback((clientX: number, clientY: number): Point => {
const rect = surface.current!.getBoundingClientRect(); const rect = surface.current!.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top }; return { x: clientX - rect.left, y: clientY - rect.top };
}, []); }, []);
// Pointers down on the surface, and what they started. /** A new device's top left corner, its icon's middle at a point of the surface. */
const cornerFor = useCallback(
(kind: NodeKind, name: string, point: Point): Point => {
const box = deviceBox({ kind, cloud: false, x: 0, y: 0 }, textWidth(name));
return { x: point.x - box.width / 2, y: point.y - ICONS[iconOf(kind, false)].height / 2 };
},
[textWidth],
);
useImperativeHandle(
handle,
() => ({
placeAt(kind, name, clientX, clientY) {
const rect = surface.current?.getBoundingClientRect();
if (rect === undefined || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;
return cornerFor(kind, name, toSurface(currentRef.current, local(clientX, clientY)));
},
placeInView(kind, name) {
const rect = surface.current?.getBoundingClientRect();
return cornerFor(kind, name, toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 }));
},
}),
[cornerFor, local],
);
/** The device under a screen point, and which half of its button. */
const deviceAt = useCallback(
(point: Point): { id: string; side: Side } | null => {
const surfacePoint = toSurface(currentRef.current, point);
// The topmost (drawn last) first.
for (const node of [...view.nodes].reverse()) {
const box = boxes.get(node.id)!;
if (surfacePoint.x >= box.x && surfacePoint.x <= box.x + box.width && surfacePoint.y >= box.y && surfacePoint.y <= box.y + box.height) {
return { id: node.id, side: surfacePoint.x - box.x < box.width / 2 ? 'left' : 'right' };
}
}
return null;
},
[view.nodes, boxes],
);
const hintAt = useCallback(
(target: { id: string; side: Side } | null) => (target !== null && kinds.get(target.id) === 'gateway' ? t(target.side === 'left' ? 'cable.wan' : 'cable.lan') : null),
[kinds, t],
);
const pointers = useRef(new Map<number, Point>()); const pointers = useRef(new Map<number, Point>());
const gesture = useRef<{ kind: 'press'; start: Point } | { kind: 'pan'; last: Point } | { kind: 'pinch'; spread: number; middle: Point } | null>(null); const gesture = useRef<Gesture | null>(null);
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearLongPress = () => {
if (longPress.current !== null) clearTimeout(longPress.current);
longPress.current = null;
};
const capture = (pointerId: number) => {
if (surface.current !== null && !surface.current.hasPointerCapture(pointerId)) surface.current.setPointerCapture(pointerId);
};
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => { const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
const point = local(event.clientX, event.clientY); const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point); pointers.current.set(event.pointerId, point);
if (pointers.current.size === 2) { if (pointers.current.size === 2) {
// A second finger: a pinch, whatever the first one did.
clearLongPress();
setBand(null);
const [first, second] = [...pointers.current.values()] as [Point, Point]; const [first, second] = [...pointers.current.values()] as [Point, Point];
gesture.current = { kind: 'pinch', spread: spread(first, second), middle: middle(first, second) }; 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); for (const id of pointers.current.keys()) capture(id);
} else if (pointers.current.size === 1) { return;
gesture.current = { kind: 'press', start: point }; }
if (pointers.current.size > 2) return;
const target = event.target as Element;
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
const device = deviceAt(point);
if (device !== null) {
const box = boxes.get(device.id)!;
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'device' || now.moved) return;
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'link' || now.moved) return;
now.longPressed = true;
onMenu?.({ kind: 'link', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else {
gesture.current = { kind: 'background', start: point, moved: false, last: point };
} }
}; };
@@ -128,35 +276,85 @@ export function Canvas({ view }: CanvasProps) {
pointers.current.set(event.pointerId, point); pointers.current.set(event.pointerId, point);
const now = gesture.current; const now = gesture.current;
if (now === null) return; if (now === null) return;
if (now.kind === 'press') { if (now.kind === 'pinch') {
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return; if (pointers.current.size !== 2) 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 [first, second] = [...pointers.current.values()] as [Point, Point];
const nextSpread = spread(first, second); const nextSpread = spread(first, second);
const nextMiddle = middle(first, second); const nextMiddle = middle(first, second);
setViewport((before) => { setViewport((before) => {
const base = before ?? current; const base = before ?? currentRef.current;
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, active.spread)), active.middle); const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, now.spread)), now.middle);
return shifted(zoomed, { x: nextMiddle.x - active.middle.x, y: nextMiddle.y - active.middle.y }); return shifted(zoomed, { x: nextMiddle.x - now.middle.x, y: nextMiddle.y - now.middle.y });
}); });
gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle }; gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle };
return;
}
if (!now.moved) {
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return;
// A drag from now on: captured only now (see above).
now.moved = true;
clearLongPress();
capture(event.pointerId);
}
if (now.kind === 'background') {
// The step taken now: the updater runs later, when `last` has moved on.
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
setBand({ from, to: toSurface(currentRef.current, point), hint: target?.id === now.id ? null : hintAt(target) });
} else {
const scale = currentRef.current.scale;
onMoveDevice?.(now.id, now.origin.x + (point.x - now.start.x) / scale, now.origin.y + (point.y - now.start.y) / scale);
}
} }
}; };
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => { const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
const point = pointers.current.get(event.pointerId) ?? local(event.clientX, event.clientY);
pointers.current.delete(event.pointerId); pointers.current.delete(event.pointerId);
if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(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. clearLongPress();
const now = gesture.current;
const [remaining] = [...pointers.current.values()]; const [remaining] = [...pointers.current.values()];
gesture.current = remaining === undefined ? null : { kind: 'pan', last: remaining }; // A pinch that loses a finger goes on as a move of the surface with the other.
gesture.current = remaining === undefined ? null : { kind: 'background', start: remaining, moved: true, last: remaining };
if (now === null || now.kind === 'pinch' || event.type === 'pointercancel') {
setBand(null);
return;
}
if (now.kind === 'background') {
if (!now.moved) {
onSelect?.(null);
if (cableFrom !== null) onCableStart?.(null);
}
return;
}
if (now.kind === 'link') {
if (!now.moved && !now.longPressed) onSelect?.({ kind: 'link', id: now.id });
return;
}
if (now.longPressed) return;
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
if (now.moved) {
const target = deviceAt(point);
if (target !== null && target.id !== now.id) onCable?.(from, target);
} else if (cableFrom === null) {
onCableStart?.(from);
} else if (cableFrom.id === now.id) {
onCableStart?.(null);
} else {
onCable?.(cableFrom, from);
}
return;
}
if (now.moved) onMoveEnd?.();
else onSelect?.({ kind: 'device', id: now.id });
}; };
// The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves. // The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves.
@@ -177,20 +375,37 @@ export function Canvas({ view }: CanvasProps) {
return () => element.removeEventListener('wheel', onWheel); return () => element.removeEventListener('wheel', onWheel);
}, [local]); }, [local]);
// The three-dot button of what is selected — at a device's top right corner, in a cable's middle — in screen pixels (44 px for a finger).
const menuAnchor = ((): Point | null => {
if (selection?.kind === 'device') {
const box = boxes.get(selection.id);
return box === undefined ? null : { x: box.x + box.width, y: box.y };
}
if (selection?.kind === 'link') {
const link = view.links.find((each) => each.id === selection.id);
const [source, target] = [boxes.get(link?.sourceNode ?? ''), boxes.get(link?.targetNode ?? '')];
return source === undefined || target === undefined ? null : middle(source.center, target.center);
}
return null;
})();
const menuButton = menuAnchor === null ? null : toScreen(current, menuAnchor);
const centre = { x: size.width / 2, y: size.height / 2 }; 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' }; const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' };
return ( return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<svg <svg
ref={surface} ref={surface}
className="filius-canvas" className="filius-canvas"
data-tool={tool}
role="application" role="application"
aria-label={t('canvas.label')} aria-label={t('canvas.label')}
style={style} style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerUp} onPointerCancel={onPointerUp}
onContextMenu={(event) => event.preventDefault()}
> >
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}> <g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
<g className="docs"> <g className="docs">
@@ -198,29 +413,79 @@ export function Canvas({ view }: CanvasProps) {
<Documentation key={item.id} item={item} /> <Documentation key={item.id} item={item} />
))} ))}
</g> </g>
<g className="cables" fill="none" strokeWidth={2}> <g className="cables" fill="none">
{view.links.map((link) => { {view.links.map((link) => {
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)]; 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. // Drawn tolerantly: a cable whose device is gone (before the janitor) is left out.
if (source === undefined || target === undefined) return null; if (source === undefined || target === undefined) return null;
const d = cablePath(source.center, target.center);
const selected = selection?.kind === 'link' && selection.id === link.id;
return ( return (
<g key={link.id}>
<path <path
key={link.id}
data-link={link.id} data-link={link.id}
d={cablePath(source.center, target.center)} data-selected={selected ? 'true' : undefined}
stroke={link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'} d={d}
strokeWidth={selected ? 4 : 2}
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
strokeDasharray={link.wireless ? '10' : undefined} strokeDasharray={link.wireless ? '10' : undefined}
/> />
{/* A wide, invisible line to tap: 2 px is no target for a finger. */}
<path data-link-hit={link.id} d={d} stroke="transparent" strokeWidth={16 / current.scale} style={{ cursor: 'pointer' }} />
</g>
); );
})} })}
</g> </g>
<g className="devices"> <g className="devices">
{view.nodes.map((node) => ( {view.nodes.map((node) => (
<Device key={node.id} node={node} box={boxes.get(node.id)!} kindName={t(`device.${node.kind}`)} /> <Device
key={node.id}
node={node}
box={boxes.get(node.id)!}
kindName={t(`device.${node.kind}`)}
selected={selection?.kind === 'device' && selection.id === node.id}
pending={cableFrom?.id === node.id}
/>
))} ))}
</g> </g>
{band === null ? null : (
<g className="cable-preview" pointerEvents="none">
<line x1={band.from.x} y1={band.from.y} x2={band.to.x} y2={band.to.y} stroke="var(--accent)" strokeWidth={2 / current.scale} strokeDasharray="6 4" />
{band.hint === null ? null : (
<text x={band.to.x + 12 / current.scale} y={band.to.y - 12 / current.scale} style={{ font: JAVA_LABEL_FONT, fill: 'var(--accent)' }} data-hint>
{band.hint}
</text>
)}
</g>
)}
</g> </g>
</svg> </svg>
{menuButton === null || selection === null ? null : (
<button
type="button"
className="device-menu-button"
aria-label={t('design.actions')}
title={t('design.actions')}
onClick={(event) => onMenu?.(selection, local(event.clientX, event.clientY))}
style={{
position: 'absolute',
left: Math.min(Math.max(0, menuButton.x - 22), Math.max(0, size.width - 44)),
top: Math.min(Math.max(0, menuButton.y - 22), Math.max(0, size.height - 44)),
width: 44,
height: 44,
borderRadius: 22,
border: '1px solid var(--border-default)',
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
fontSize: 20,
lineHeight: 1,
cursor: 'pointer',
boxShadow: '0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent)',
}}
>
⋯
</button>
)}
<ZoomBar <ZoomBar
scale={current.scale} scale={current.scale}
onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))} onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))}
@@ -229,4 +494,4 @@ export function Canvas({ view }: CanvasProps) {
/> />
</div> </div>
); );
} });
@@ -0,0 +1,80 @@
// The menu of a device or a cable (Java's design mode context menu,
// GUIEvents.kontextMenueEntwurfsmodus): on a device "remove cable" (a PC or
// notebook, which has one) or "remove all cables", and "delete"; on a cable
// "remove cable". Opened by a long press or the three-dot button; every item
// a 44 px target.
import { useEffect, useRef, type CSSProperties } from 'react';
import type { Point } from '../canvas/viewport.js';
export interface MenuItem {
readonly key: string;
readonly label: string;
readonly danger?: boolean;
readonly onChoose: () => void;
}
const entry: CSSProperties = {
display: 'block',
width: '100%',
minHeight: 44,
padding: '0 16px',
border: 'none',
background: 'transparent',
color: 'var(--text-primary)',
font: 'inherit',
textAlign: 'left',
cursor: 'pointer',
};
export function ContextMenu({ at, items, onClose }: { readonly at: Point; readonly items: readonly MenuItem[]; readonly onClose: () => void }) {
const menu = useRef<HTMLDivElement>(null);
useEffect(() => {
menu.current?.querySelector('button')?.focus();
const away = (event: PointerEvent) => {
if (!menu.current?.contains(event.target as Node)) onClose();
};
const key = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('pointerdown', away, true);
window.addEventListener('keydown', key);
return () => {
window.removeEventListener('pointerdown', away, true);
window.removeEventListener('keydown', key);
};
}, [onClose]);
return (
<div
ref={menu}
role="menu"
style={{
position: 'absolute',
left: at.x,
top: at.y,
minWidth: 200,
padding: '4px 0',
borderRadius: 8,
background: 'var(--surface-raised)',
border: '1px solid var(--border-default)',
boxShadow: '0 4px 12px color-mix(in srgb, var(--shadow-color) 25%, transparent)',
zIndex: 5,
}}
>
{items.map((each) => (
<button
key={each.key}
type="button"
role="menuitem"
style={{ ...entry, color: each.danger === true ? 'var(--danger)' : entry.color }}
onClick={() => {
onClose();
each.onChoose();
}}
>
{each.label}
</button>
))}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
// The palette (Java's design sidebar, GUIDesignSidebar): the cable tool
// first, then the devices in Java's order, each with Java's icon and name.
// A device is dragged onto the surface, or tapped to land in the middle of
// the view; the cable is a tool that stays on until tapped again.
import { useRef, useState, type CSSProperties } from 'react';
import { PALETTE } from '@mdg-filius/doc';
import type { NodeKind } from '@mdg-filius/core';
import { useTranslate } from '../i18n/i18n.js';
import { ICON_URLS } from '../canvas/icons.js';
import { iconOf } from '../canvas/layout.js';
import cableIcon from '../assets/hardware/kabel.png';
import { DRAG_THRESHOLD } from '../canvas/Canvas.js';
export type PaletteKind = (typeof PALETTE)[number];
export interface PaletteProps {
readonly cableTool: boolean;
readonly onCableTool: (on: boolean) => void;
/** A device dropped at a point of the screen, or tapped (no point). */
readonly onDevice: (kind: PaletteKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
}
const item: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
minHeight: 64,
padding: '6px 4px',
border: '2px solid transparent',
borderRadius: 8,
background: 'transparent',
color: 'var(--text-primary)',
font: 'inherit',
fontSize: 12,
textAlign: 'center',
overflowWrap: 'anywhere',
cursor: 'grab',
touchAction: 'none',
};
export function Palette({ cableTool, onCableTool, onDevice }: PaletteProps) {
const t = useTranslate();
const [ghost, setGhost] = useState<{ kind: PaletteKind; x: number; y: number } | null>(null);
const press = useRef<{ kind: PaletteKind; x: number; y: number; dragging: boolean } | null>(null);
/** The click the browser sends after a drag ends on the button: not a tap. */
const afterDrag = useRef(false);
const onPointerDown = (kind: PaletteKind) => (event: React.PointerEvent<HTMLButtonElement>) => {
press.current = { kind, x: event.clientX, y: event.clientY, dragging: false };
// Captured at once, or a quick drag leaves the button before its first move reaches it. The button is
// the click's own target, so capturing takes no click from anything inside it (unlike on the surface).
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const now = press.current;
if (now === null) return;
if (!now.dragging) {
if (Math.hypot(event.clientX - now.x, event.clientY - now.y) < DRAG_THRESHOLD) return;
now.dragging = true;
}
setGhost({ kind: now.kind, x: event.clientX, y: event.clientY });
};
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const now = press.current;
press.current = null;
setGhost(null);
if (now === null) return;
if (now.dragging) {
afterDrag.current = true;
onDevice(now.kind, { clientX: event.clientX, clientY: event.clientY });
}
};
return (
<nav aria-label={t('design.palette')} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: 6, overflowY: 'auto', background: 'var(--surface-card)', borderRight: '1px solid var(--border-default)', width: 124, flexShrink: 0 }}>
<button
type="button"
style={{ ...item, cursor: 'pointer', borderColor: cableTool ? 'var(--accent)' : 'transparent', background: cableTool ? 'var(--accent-soft)' : 'transparent' }}
aria-pressed={cableTool}
onClick={() => onCableTool(!cableTool)}
data-palette="cable"
>
<img src={cableIcon} alt="" width={56} height={39} draggable={false} />
{t('device.cable')}
</button>
{PALETTE.map((kind) => (
<button
key={kind}
type="button"
style={item}
data-palette={kind}
onPointerDown={onPointerDown(kind)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => {
press.current = null;
setGhost(null);
}}
onClick={() => {
// A click without a drag (a tap, or the keyboard): the device lands in the middle of the view.
if (afterDrag.current) afterDrag.current = false;
else onDevice(kind, null);
}}
>
<img src={ICON_URLS[iconOf(kind as NodeKind, false)]} alt="" style={{ maxWidth: 56, maxHeight: 44 }} draggable={false} />
{t(`device.${kind}`)}
</button>
))}
{ghost === null ? null : (
<img
src={ICON_URLS[iconOf(ghost.kind as NodeKind, false)]}
alt=""
data-ghost
style={{ position: 'fixed', left: ghost.x, top: ghost.y, transform: 'translate(-50%, -50%)', opacity: 0.7, pointerEvents: 'none', zIndex: 10 }}
/>
)}
</nav>
);
}
@@ -0,0 +1,46 @@
// Java asks how many interfaces a new router gets (GUIContainer.neuerKnoten:
// 2 to 8, 2 when the dialog is closed). Here as chips, a finger's size each.
import { useEffect, useRef } from 'react';
import { ROUTER_INTERFACES } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
export function RouterDialog({ onChoose }: { readonly onChoose: (interfaces: number) => void }) {
const t = useTranslate();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus();
const key = (event: KeyboardEvent) => {
if (event.key === 'Escape') onChoose(ROUTER_INTERFACES.standard);
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [onChoose]);
const counts = Array.from({ length: ROUTER_INTERFACES.maximum - ROUTER_INTERFACES.minimum + 1 }, (_, index) => ROUTER_INTERFACES.minimum + index);
return (
<div role="presentation" style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', background: 'color-mix(in srgb, var(--shadow-color) 30%, transparent)', zIndex: 6 }}>
<div role="dialog" aria-modal="true" aria-label={t('router.title')} style={{ padding: 20, borderRadius: 12, background: 'var(--surface-raised)', border: '1px solid var(--border-default)', maxWidth: 420 }}>
<h2 style={{ margin: '0 0 8px', fontSize: 18 }}>{t('router.title')}</h2>
<p style={{ margin: '0 0 16px', color: 'var(--text-secondary)' }}>{t('router.prompt')}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{counts.map((count) => (
<button
key={count}
ref={count === ROUTER_INTERFACES.standard ? first : undefined}
type="button"
onClick={() => onChoose(count)}
style={{ minWidth: 44, minHeight: 44, borderRadius: 8, border: '1px solid var(--border-strong)', background: 'var(--surface-card)', color: 'var(--text-primary)', font: 'inherit', fontSize: 16, cursor: 'pointer' }}
>
{count}
</button>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button type="button" onClick={() => onChoose(ROUTER_INTERFACES.standard)} style={{ minHeight: 44, padding: '0 16px', borderRadius: 8, border: 'none', background: 'transparent', color: 'var(--accent)', font: 'inherit', cursor: 'pointer' }}>
{t('router.cancel')}
</button>
</div>
</div>
</div>
);
}
+25 -3
View File
@@ -1,5 +1,24 @@
{ {
"cable.alreadyConnected": "Komponenten bereits verbunden",
"cable.lan": "LAN-Anschluss (intern)",
"cable.lanOccupied": "LAN-Anschluss (intern) bereits belegt!",
"cable.noFreePort": "Maximale Anzahl angeschlossener Geräte überschritten",
"cable.portTaken": "Dieser Anschluss ist schon belegt.",
"cable.sameDevice": "Ein Gerät lässt sich nicht mit sich selbst verbinden.",
"cable.unknownDevice": "Dieses Gerät gibt es nicht mehr.",
"cable.unknownPort": "Diesen Anschluss gibt es nicht.",
"cable.wan": "WAN-Anschluss (extern)",
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
"canvas.label": "Netzwerk", "canvas.label": "Netzwerk",
"design.actions": "Aktionen",
"design.palette": "Komponenten",
"design.redo": "Wiederholen",
"design.undo": "Rückgängig",
"dev.example": "Beispiel",
"dev.newProject": "Neues Projekt",
"dev.openFile": "Datei öffnen …",
"dev.title": "Filius-Editor (Entwicklung)",
"device.cable": "Verbindung", "device.cable": "Verbindung",
"device.gateway": "Heimrouter", "device.gateway": "Heimrouter",
"device.networkSwitch": "Switch / WLAN", "device.networkSwitch": "Switch / WLAN",
@@ -8,9 +27,12 @@
"device.remoteLink": "Modem", "device.remoteLink": "Modem",
"device.router": "Vermittlungsrechner", "device.router": "Vermittlungsrechner",
"device.unsupported": "Unbekanntes Gerät", "device.unsupported": "Unbekanntes Gerät",
"dev.example": "Beispiel", "menu.delete": "Löschen",
"dev.openFile": "Datei öffnen …", "menu.removeAllCables": "Alle Verbindungen entfernen",
"dev.title": "Filius-Editor (Entwicklung)", "menu.removeCable": "Verbindung entfernen",
"router.cancel": "Abbrechen",
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
"router.title": "Anzahl Schnittstellen",
"zoom.fit": "Alles zeigen", "zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern", "zoom.in": "Vergrößern",
"zoom.level": "{percent} %", "zoom.level": "{percent} %",
+25 -3
View File
@@ -1,5 +1,24 @@
{ {
"cable.alreadyConnected": "Components already connected",
"cable.lan": "LAN port (internal)",
"cable.lanOccupied": "LAN port (internal) already occupied!",
"cable.noFreePort": "No connector available",
"cable.portTaken": "This port is already taken.",
"cable.sameDevice": "A device cannot be connected to itself.",
"cable.unknownDevice": "This device no longer exists.",
"cable.unknownPort": "There is no such port.",
"cable.wan": "WAN port (external)",
"cable.wanOccupied": "WAN port (external) already occupied!",
"cable.wirelessNotPossible": "Wireless connection not possible",
"canvas.label": "Network", "canvas.label": "Network",
"design.actions": "Actions",
"design.palette": "Components",
"design.redo": "Redo",
"design.undo": "Undo",
"dev.example": "Example",
"dev.newProject": "New project",
"dev.openFile": "Open file …",
"dev.title": "Filius editor (development)",
"device.cable": "Connection", "device.cable": "Connection",
"device.gateway": "Home Router", "device.gateway": "Home Router",
"device.networkSwitch": "Switch / Wi-Fi", "device.networkSwitch": "Switch / Wi-Fi",
@@ -8,9 +27,12 @@
"device.remoteLink": "Modem", "device.remoteLink": "Modem",
"device.router": "Router", "device.router": "Router",
"device.unsupported": "Unknown device", "device.unsupported": "Unknown device",
"dev.example": "Example", "menu.delete": "Delete",
"dev.openFile": "Open file …", "menu.removeAllCables": "Remove all cables",
"dev.title": "Filius editor (development)", "menu.removeCable": "Remove cable",
"router.cancel": "Cancel",
"router.prompt": "Choose number of NIC of the router!",
"router.title": "Number of NIC",
"zoom.fit": "Show all", "zoom.fit": "Show all",
"zoom.in": "Zoom in", "zoom.in": "Zoom in",
"zoom.level": "{percent} %", "zoom.level": "{percent} %",
+18 -3
View File
@@ -1,12 +1,13 @@
// The editor's page. Embedded in the dashboard (F6) it gets its document and // 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 // language over the bridge; on its own — for development and the browser
// tests — it opens one of FILIUS' examples or a file (`?example=<name>` // tests — it opens one of FILIUS' examples, an empty project or a file
// opens that example at once, `?lang=en` and `?theme=dark` set the rest). // (`?example=<name>` opens that example at once, `?example=new` an empty
// project, `?lang=en` and `?theme=dark` set the rest).
import { StrictMode, useEffect, useState } from 'react'; import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { writeProject } from '@mdg-filius/doc'; import { emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls'; import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js'; import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js'; import { Editor } from './Editor.js';
@@ -14,6 +15,8 @@ import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
import './theme/tokens.css'; import './theme/tokens.css';
const EXAMPLES = import.meta.glob('../../../corpus/java-examples/*.fls', { query: '?url', import: 'default', eager: true }) as Record<string, string>; const EXAMPLES = import.meta.glob('../../../corpus/java-examples/*.fls', { query: '?url', import: 'default', eager: true }) as Record<string, string>;
/** `?example=new`: an empty project. */
const NEW_PROJECT = 'new';
const exampleUrl = (name: string) => Object.entries(EXAMPLES).find(([path]) => path.endsWith(`/${name}`))?.[1]; const exampleUrl = (name: string) => Object.entries(EXAMPLES).find(([path]) => path.endsWith(`/${name}`))?.[1];
function docOf(bytes: Uint8Array): Y.Doc { function docOf(bytes: Uint8Array): Y.Doc {
@@ -44,6 +47,12 @@ function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (example === NEW_PROJECT) {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
setDoc(doc);
return;
}
const url = exampleUrl(example); const url = exampleUrl(example);
if (url === undefined) return; if (url === undefined) return;
void fetch(url) void fetch(url)
@@ -51,6 +60,11 @@ function App() {
.then((buffer) => setDoc(docOf(new Uint8Array(buffer)))); .then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
}, [example]); }, [example]);
// The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
useEffect(() => {
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc) };
}, [doc]);
const t = (key: string) => translate(language, key); const t = (key: string) => translate(language, key);
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
@@ -61,6 +75,7 @@ function App() {
{t('dev.example')}{' '} {t('dev.example')}{' '}
<select value={example} onChange={(event) => setExample(event.target.value)}> <select value={example} onChange={(event) => setExample(event.target.value)}>
<option value="" /> <option value="" />
<option value={NEW_PROJECT}>{t('dev.newProject')}</option>
{Object.keys(EXAMPLES) {Object.keys(EXAMPLES)
.map((path) => path.slice(path.lastIndexOf('/') + 1)) .map((path) => path.slice(path.lastIndexOf('/') + 1))
.sort() .sort()
+20
View File
@@ -28,6 +28,22 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'device.router': 'hw_vermittlungsrechner_msg1', 'device.router': 'hw_vermittlungsrechner_msg1',
'device.gateway': 'hw_gateway_msg1', 'device.gateway': 'hw_gateway_msg1',
'device.cable': 'hw_kabel_msg1', 'device.cable': 'hw_kabel_msg1',
'menu.removeCable': 'guievents_msg5',
'menu.removeAllCables': 'guievents_msg6',
'menu.delete': 'guievents_msg7',
'cable.noFreePort': 'guievents_msg1',
'cable.alreadyConnected': 'guievents_msg12',
'cable.wanOccupied': 'guievents_msg22',
'cable.lanOccupied': 'guievents_msg23',
'cable.wan': 'guievents_msg24',
'cable.lan': 'guievents_msg25',
'cable.wirelessNotPossible': 'guievents_msg26',
'router.title': 'guicontainer_msg2',
};
/** Java's words with an error of Java's mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string) => string]>> = {
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')],
}; };
describe('the catalogs', () => { describe('the catalogs', () => {
@@ -54,5 +70,9 @@ describe('the catalogs', () => {
expect(CATALOGS.de[key], key).toBe(de.get(javaKey)); expect(CATALOGS.de[key], key).toBe(de.get(javaKey));
expect(CATALOGS.en[key], key).toBe(en.get(javaKey)); expect(CATALOGS.en[key], key).toBe(en.get(javaKey));
} }
for (const [key, [javaKey, mend]] of Object.entries(FROM_JAVA_MENDED)) {
expect(CATALOGS.de[key], key).toBe(mend(de.get(javaKey)!));
expect(CATALOGS.en[key], key).toBe(mend(en.get(javaKey)!));
}
}); });
}); });