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:
@@ -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/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 |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
import type { ImportedProject } from '@mdg-filius/fls';
|
||||
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
|
||||
|
||||
/** The kinds of the palette, in Java's order (GUIDesignSidebar). */
|
||||
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink'];
|
||||
/** 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', 'gateway', 'router', 'remoteLink'];
|
||||
|
||||
/** 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;
|
||||
@@ -141,6 +141,13 @@ export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void {
|
||||
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. */
|
||||
export interface CableEnd {
|
||||
readonly node: Id;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { interfaceKey, NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
|
||||
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');
|
||||
|
||||
@@ -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', () => {
|
||||
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]);
|
||||
@@ -156,6 +160,16 @@ describe('a cable', () => {
|
||||
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', () => {
|
||||
const { doc, pcA, pcB, hub } = network();
|
||||
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
|
||||
|
||||
@@ -14,25 +14,23 @@ 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';
|
||||
import { serveEditor } from './editorPage.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 server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
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]!;
|
||||
server = await serveEditor();
|
||||
base = server.base;
|
||||
});
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
// 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).
|
||||
// The editor on a document: the palette, the surface, the tools of the design
|
||||
// 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 { topologyView, type TopologyView } from '@mdg-filius/doc';
|
||||
import { Canvas } from './canvas/Canvas.js';
|
||||
import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
|
||||
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. */
|
||||
export function useTopologyView(doc: Y.Doc): TopologyView {
|
||||
@@ -18,7 +27,219 @@ export function useTopologyView(doc: Y.Doc): TopologyView {
|
||||
return view;
|
||||
}
|
||||
|
||||
export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
const view = useTopologyView(doc);
|
||||
return <Canvas view={view} />;
|
||||
/**
|
||||
* This person's undo and redo, and whether there is something to take back
|
||||
* 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 |
@@ -1,23 +1,60 @@
|
||||
// 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 surface (plan §7.1, §7.2): the devices with Java's icons and labels,
|
||||
// the cables, the documentation items, in an SVG; and what a finger or the
|
||||
// mouse does on it.
|
||||
//
|
||||
// - 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
|
||||
// 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 { 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 { 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 { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
|
||||
import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
|
||||
import { ZoomBar } from './ZoomBar.js';
|
||||
|
||||
/** 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. */
|
||||
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 (
|
||||
<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} />
|
||||
<text x={box.label.x} y={box.label.y} textAnchor="middle" style={{ font: JAVA_LABEL_FONT, fill: 'var(--canvas-label)' }}>
|
||||
{node.label}
|
||||
@@ -73,19 +133,26 @@ function Device({ node, box, kindName }: { readonly node: NodeView; readonly box
|
||||
);
|
||||
}
|
||||
|
||||
export interface CanvasProps {
|
||||
readonly view: TopologyView;
|
||||
}
|
||||
type Gesture =
|
||||
| { readonly kind: 'background'; readonly start: Point; moved: boolean; last: Point }
|
||||
| { readonly kind: 'pinch'; spread: number; middle: Point }
|
||||
| { readonly kind: 'device'; readonly id: string; readonly start: Point; readonly origin: Point; readonly side: Side; moved: boolean; longPressed: boolean }
|
||||
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
|
||||
|
||||
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 surface = useRef<SVGSVGElement>(null);
|
||||
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
|
||||
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 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 kinds = useMemo(() => new Map(view.nodes.map((node) => [node.id, node.kind])), [view.nodes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = surface.current;
|
||||
@@ -101,24 +168,105 @@ export function Canvas({ view }: CanvasProps) {
|
||||
}, [viewport, size, content]);
|
||||
|
||||
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 rect = surface.current!.getBoundingClientRect();
|
||||
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 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 point = local(event.clientX, event.clientY);
|
||||
pointers.current.set(event.pointerId, point);
|
||||
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];
|
||||
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 };
|
||||
for (const id of pointers.current.keys()) capture(id);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
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) {
|
||||
if (now.kind === 'pinch') {
|
||||
if (pointers.current.size !== 2) return;
|
||||
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 });
|
||||
const base = before ?? currentRef.current;
|
||||
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, now.spread)), now.middle);
|
||||
return shifted(zoomed, { x: nextMiddle.x - now.middle.x, y: nextMiddle.y - now.middle.y });
|
||||
});
|
||||
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 point = pointers.current.get(event.pointerId) ?? local(event.clientX, event.clientY);
|
||||
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.
|
||||
clearLongPress();
|
||||
const now = gesture.current;
|
||||
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.
|
||||
@@ -177,20 +375,37 @@ export function Canvas({ view }: CanvasProps) {
|
||||
return () => element.removeEventListener('wheel', onWheel);
|
||||
}, [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 style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' };
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<svg
|
||||
ref={surface}
|
||||
className="filius-canvas"
|
||||
data-tool={tool}
|
||||
role="application"
|
||||
aria-label={t('canvas.label')}
|
||||
style={style}
|
||||
style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
|
||||
<g className="docs">
|
||||
@@ -198,29 +413,79 @@ export function Canvas({ view }: CanvasProps) {
|
||||
<Documentation key={item.id} item={item} />
|
||||
))}
|
||||
</g>
|
||||
<g className="cables" fill="none" strokeWidth={2}>
|
||||
<g className="cables" fill="none">
|
||||
{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;
|
||||
const d = cablePath(source.center, target.center);
|
||||
const selected = selection?.kind === 'link' && selection.id === link.id;
|
||||
return (
|
||||
<path
|
||||
key={link.id}
|
||||
data-link={link.id}
|
||||
d={cablePath(source.center, target.center)}
|
||||
stroke={link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
strokeDasharray={link.wireless ? '10' : undefined}
|
||||
/>
|
||||
<g key={link.id}>
|
||||
<path
|
||||
data-link={link.id}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
d={d}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
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 className="devices">
|
||||
{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>
|
||||
{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>
|
||||
</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
|
||||
scale={current.scale}
|
||||
onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))}
|
||||
@@ -229,4 +494,4 @@ export function Canvas({ view }: CanvasProps) {
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
"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.gateway": "Heimrouter",
|
||||
"device.networkSwitch": "Switch / WLAN",
|
||||
@@ -8,9 +27,12 @@
|
||||
"device.remoteLink": "Modem",
|
||||
"device.router": "Vermittlungsrechner",
|
||||
"device.unsupported": "Unbekanntes Gerät",
|
||||
"dev.example": "Beispiel",
|
||||
"dev.openFile": "Datei öffnen …",
|
||||
"dev.title": "Filius-Editor (Entwicklung)",
|
||||
"menu.delete": "Löschen",
|
||||
"menu.removeAllCables": "Alle Verbindungen entfernen",
|
||||
"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.in": "Vergrößern",
|
||||
"zoom.level": "{percent} %",
|
||||
|
||||
@@ -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",
|
||||
"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.gateway": "Home Router",
|
||||
"device.networkSwitch": "Switch / Wi-Fi",
|
||||
@@ -8,9 +27,12 @@
|
||||
"device.remoteLink": "Modem",
|
||||
"device.router": "Router",
|
||||
"device.unsupported": "Unknown device",
|
||||
"dev.example": "Example",
|
||||
"dev.openFile": "Open file …",
|
||||
"dev.title": "Filius editor (development)",
|
||||
"menu.delete": "Delete",
|
||||
"menu.removeAllCables": "Remove all cables",
|
||||
"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.in": "Zoom in",
|
||||
"zoom.level": "{percent} %",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// 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=<name>`
|
||||
// opens that example at once, `?lang=en` and `?theme=dark` set the rest).
|
||||
// tests — it opens one of FILIUS' examples, an empty project or a file
|
||||
// (`?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 { createRoot } from 'react-dom/client';
|
||||
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 { startBridge } from './bridge/dashboardBridge.js';
|
||||
import { Editor } from './Editor.js';
|
||||
@@ -14,6 +15,8 @@ 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<string, string>;
|
||||
/** `?example=new`: an empty project. */
|
||||
const NEW_PROJECT = 'new';
|
||||
const exampleUrl = (name: string) => Object.entries(EXAMPLES).find(([path]) => path.endsWith(`/${name}`))?.[1];
|
||||
|
||||
function docOf(bytes: Uint8Array): Y.Doc {
|
||||
@@ -44,6 +47,12 @@ function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (example === NEW_PROJECT) {
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, emptyProject());
|
||||
setDoc(doc);
|
||||
return;
|
||||
}
|
||||
const url = exampleUrl(example);
|
||||
if (url === undefined) return;
|
||||
void fetch(url)
|
||||
@@ -51,6 +60,11 @@ function App() {
|
||||
.then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
|
||||
}, [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);
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
@@ -61,6 +75,7 @@ function App() {
|
||||
{t('dev.example')}{' '}
|
||||
<select value={example} onChange={(event) => setExample(event.target.value)}>
|
||||
<option value="" />
|
||||
<option value={NEW_PROJECT}>{t('dev.newProject')}</option>
|
||||
{Object.keys(EXAMPLES)
|
||||
.map((path) => path.slice(path.lastIndexOf('/') + 1))
|
||||
.sort()
|
||||
|
||||
@@ -28,6 +28,22 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'device.router': 'hw_vermittlungsrechner_msg1',
|
||||
'device.gateway': 'hw_gateway_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', () => {
|
||||
@@ -54,5 +70,9 @@ describe('the catalogs', () => {
|
||||
expect(CATALOGS.de[key], key).toBe(de.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)!));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user