diff --git a/CHANGES.md b/CHANGES.md index 939a25d..0409627 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/packages/doc/src/commands.ts b/packages/doc/src/commands.ts index bce6649..2152bde 100644 --- a/packages/doc/src/commands.ts +++ b/packages/doc/src/commands.ts @@ -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[] = ['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[] = ['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; diff --git a/packages/doc/test/devices.test.ts b/packages/doc/test/devices.test.ts index 9b0049d..6c71a70 100644 --- a/packages/doc/test/devices.test.ts +++ b/packages/doc/test/devices.test.ts @@ -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'); diff --git a/packages/editor/browser/canvas.spec.ts b/packages/editor/browser/canvas.spec.ts index 9d3b42b..92be52a 100644 --- a/packages/editor/browser/canvas.spec.ts +++ b/packages/editor/browser/canvas.spec.ts @@ -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 }; 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((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. */ diff --git a/packages/editor/browser/design.spec.ts b/packages/editor/browser/design.spec.ts new file mode 100644 index 0000000..9e82df8 --- /dev/null +++ b/packages/editor/browser/design.spec.ts @@ -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 }; + +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 { + 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(); +}); diff --git a/packages/editor/browser/editorPage.ts b/packages/editor/browser/editorPage.ts new file mode 100644 index 0000000..893f28e --- /dev/null +++ b/packages/editor/browser/editorPage.ts @@ -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 }> { + 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((resolve) => server.httpServer.close(() => resolve())), + }; +} + +export async function openEditor(page: Page, base: string, example: string, query = ''): Promise { + 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 { + 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 { + 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 { + 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(); +} diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 59832d2..3780845 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -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 ; +/** + * 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(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(null); + const [cableTool, setCableTool] = useState(false); + const [selection, setSelection] = useState(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(null); + const [message, setMessage] = useState(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 ( +
+ { + setCableTool(on); + setCableFrom(null); + }} + onDevice={onDevice} + /> +
+ moveDevice(doc, id, x, y, origin)} + onMoveEnd={() => manager?.stopCapturing()} + onCableStart={setCableFrom} + onCable={onCable} + onMenu={(target, at) => { + setSelection(target); + setMenu({ target, at }); + }} + /> +
+ + +
+ {menu === null ? null : setMenu(null)} />} + {routerAt === null ? null : ( + { + place('router', routerAt, interfaces); + setRouterAt(null); + }} + /> + )} + {message === null ? null : ( +
+ {message} +
+ )} +
+
+ ); } diff --git a/packages/editor/src/assets/hardware/kabel.png b/packages/editor/src/assets/hardware/kabel.png new file mode 100644 index 0000000..358f83c Binary files /dev/null and b/packages/editor/src/assets/hardware/kabel.png differ diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx index 318fcc8..53b1cb9 100644 --- a/packages/editor/src/canvas/Canvas.tsx +++ b/packages/editor/src/canvas/Canvas.tsx @@ -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 ( - + + {/* The whole button takes the pointer, as Java's label does. */} + + {selected || pending ? ( + + ) : null} {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(function Canvas( + { view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu }, + handle, +) { const t = useTranslate(); const surface = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); const [viewport, setViewport] = useState(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()); - const gesture = useRef<{ kind: 'press'; start: Point } | { kind: 'pan'; last: Point } | { kind: 'pinch'; spread: number; middle: Point } | null>(null); + const gesture = useRef(null); + const longPress = useRef | 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) => { 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) => { + 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 ( -
+
event.preventDefault()} > @@ -198,29 +413,79 @@ export function Canvas({ view }: CanvasProps) { ))} - + {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 ( - + + + {/* A wide, invisible line to tap: 2 px is no target for a finger. */} + + ); })} {view.nodes.map((node) => ( - + ))} + {band === null ? null : ( + + + {band.hint === null ? null : ( + + {band.hint} + + )} + + )} + {menuButton === null || selection === null ? null : ( + + )} setViewport(zoomedAt(current, zoomIn(current.scale), centre))} @@ -229,4 +494,4 @@ export function Canvas({ view }: CanvasProps) { />
); -} +}); diff --git a/packages/editor/src/design/ContextMenu.tsx b/packages/editor/src/design/ContextMenu.tsx new file mode 100644 index 0000000..5d1dd8d --- /dev/null +++ b/packages/editor/src/design/ContextMenu.tsx @@ -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(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 ( +
+ {items.map((each) => ( + + ))} +
+ ); +} diff --git a/packages/editor/src/design/Palette.tsx b/packages/editor/src/design/Palette.tsx new file mode 100644 index 0000000..2faabaa --- /dev/null +++ b/packages/editor/src/design/Palette.tsx @@ -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) => { + 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) => { + 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) => { + 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 ( + + ); +} diff --git a/packages/editor/src/design/RouterDialog.tsx b/packages/editor/src/design/RouterDialog.tsx new file mode 100644 index 0000000..57d9088 --- /dev/null +++ b/packages/editor/src/design/RouterDialog.tsx @@ -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(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 ( +
+
+

{t('router.title')}

+

{t('router.prompt')}

+
+ {counts.map((count) => ( + + ))} +
+
+ +
+
+
+ ); +} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 20a9f97..578f1bf 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -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} %", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index 512f92e..ab8a598 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -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} %", diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index 5b2b99d..f360b2a 100644 --- a/packages/editor/src/main.tsx +++ b/packages/editor/src/main.tsx @@ -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=` -// 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=` 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; +/** `?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 ( @@ -61,6 +75,7 @@ function App() { {t('dev.example')}{' '}