feat(editor): a tap opens a device's menu in the simulation; undo and redo drawn

In the simulation a tap on a device chooses it and opens its menu where
the finger was; the three-dot button is gone there, the long press stays.
The context menu moves in from the surface's edge instead of being cut off.
Undo and redo show drawn arrows: the characters took whatever font the
device had and looked squashed on an iPad (the buttons measured 44 x 44 at
every width; the size was not the cause).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 08:04:57 +00:00
co-authored by Claude Opus 5.5
parent ae10ee6c31
commit 15498a2bed
8 changed files with 108 additions and 42 deletions
+35 -16
View File
@@ -208,51 +208,70 @@ test("shows a card's frames as Java's data exchange window does, with their deta
await expect(window).toHaveCount(0);
});
test('chooses a device with a tap in the simulation, its three-dot button as in the design, and opens nothing by itself', async ({ page }) => {
test('opens a device\'s menu with a tap in the simulation, chooses it, and opens nothing else by itself', async ({ page }) => {
await openPages(page);
await start(page);
const button = page.locator('.device-menu-button');
const menu = page.getByRole('menu');
const sheet = page.locator('aside[aria-label="Einstellungen"]');
// A router: the button opens the same menu as the long press.
// A router: the tap chooses it and opens the same menu as the long press, where the finger was; no three-dot button.
const router = await onDevice(page, await idOf(page, 'Router'));
await page.mouse.click(router.x, router.y);
await expect(page.locator(`g.device[data-node="${await idOf(page, 'Router')}"]`)).toHaveAttribute('data-selected', 'true');
await button.click();
await expect(page.getByRole('menuitem')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
const opened = (await menu.boundingBox())!;
expect([opened.x, opened.y]).toEqual([router.x, router.y]);
await expect(page.locator('.device-menu-button')).toHaveCount(0);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (10.0.0.1)' }).click();
await expect(page.locator('section[data-capture]').getByRole('tab', { selected: true })).toHaveText('Router - 10.0.0.1');
await page.locator('section[data-capture] .simulation-sheet__close').click();
// A computer: the tap opens no desktop (Java's click does), it only chooses; its settings stay closed, the design is locked.
// A computer: the tap opens its menu, no desktop by itself (Java's click does); its settings stay closed, the design is locked.
const browser = await idOf(page, 'Browser');
const at = await onDevice(page, browser);
await page.mouse.click(at.x, at.y);
await expect(button).toBeVisible();
await expect(page.getByRole('menuitem')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (192.168.0.10)']);
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await expect(sheet).toHaveCount(0);
await button.click();
await expect(page.getByRole('menuitem')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (192.168.0.10)']);
// A tap on another device beside the open menu: that one's menu (the menu lies over what is below it on the right).
await page.mouse.click(router.x, router.y);
await expect(page.getByRole('menuitem')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
await page.keyboard.press('Escape');
// A switch: the tap opens no table either; its menu does.
const lanSwitch = (await drawing(page)).nodes.find((node) => node.kind === 'networkSwitch')!.id;
const hub = await onDevice(page, lanSwitch);
await page.mouse.click(hub.x, hub.y);
await expect(page.locator(`g.device[data-node="${lanSwitch}"]`)).toHaveAttribute('data-selected', 'true');
await expect(page.locator('section[data-viewer]')).toHaveCount(0);
await button.click();
await expect(page.getByRole('menuitem')).toHaveText(['SAT Tabelle anzeigen']);
await expect(page.locator('section[data-viewer]')).toHaveCount(0);
await page.getByRole('menuitem', { name: 'SAT Tabelle anzeigen' }).click();
await expect(page.locator(`section[data-viewer="${lanSwitch}"]`)).toBeVisible();
await page.locator('.simulation-sheet__close').click();
// A tap beside lets go.
// A tap beside closes the menu and lets go.
await page.mouse.click(at.x, at.y);
await expect(button).toBeVisible();
await expect(menu).toBeVisible();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
await page.mouse.click(surface.x + surface.width - 30, surface.y + surface.height - 80);
await expect(button).toHaveCount(0);
// Back in the design nothing stays chosen.
await expect(menu).toHaveCount(0);
await expect(page.locator(`g.device[data-node="${browser}"]`)).not.toHaveAttribute('data-selected', 'true');
// A device at the surface's edge: its menu moves in, whole on the surface.
await page.mouse.click(at.x, at.y);
await page.keyboard.press('Escape');
const area = (await page.locator('.editor-area').boundingBox())!;
// The wheel moves the surface (a drag on a device in the simulation moves nothing).
await page.mouse.move(at.x, at.y);
await page.mouse.wheel(-(area.x + area.width - 12 - at.x), -(area.y + area.height - 12 - at.y));
await expect.poll(async () => (await onDevice(page, browser)).x).toBeGreaterThan(area.x + area.width - 40);
const edge = await onDevice(page, browser);
await page.mouse.click(edge.x, edge.y);
const moved = (await menu.boundingBox())!;
expect(moved.x + moved.width).toBeLessThanOrEqual(area.x + area.width + 0.5);
expect(moved.y + moved.height).toBeLessThanOrEqual(area.y + area.height + 0.5);
expect(moved.x).toBeLessThan(edge.x);
expect(moved.y).toBeLessThan(edge.y);
await page.keyboard.press('Escape');
// Back in the design nothing stays chosen.
await page.mouse.click(edge.x, edge.y);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
await expect(button).toHaveCount(0);
await expect(page.locator('.device-menu-button')).toHaveCount(0);
await expect(sheet).toHaveCount(0);
});
+1 -2
View File
@@ -78,9 +78,8 @@ export async function showFromMenu(page: Page, id: string, item: string | RegExp
const open = page.locator('.simulation-sheet__close');
if ((await open.count()) > 0) await (touch ? open.tap() : open.click());
const at = await onDevice(page, id);
// In the simulation a tap opens the device's menu.
await (touch ? page.touchscreen.tap(at.x, at.y) : page.mouse.click(at.x, at.y));
const button = page.locator('.device-menu-button');
await (touch ? button.tap() : button.click());
const entry = page.getByRole('menuitem', { name: item, exact: true });
await (touch ? entry.tap() : entry.click());
}
+4 -3
View File
@@ -95,10 +95,12 @@ test("opens a computer's desktop from its menu: Java's title, its programs in Ja
const id = await deviceNamed(page, 'DNS-Server 2.10');
expect((await desktopOf(page, id))!.programs).toHaveLength(3);
await start(page);
// A tap only chooses the computer (Java's click opens its desktop): its three-dot button is there, no desktop.
// A tap opens the computer's menu (Java's click opens its desktop, its menu holds it), no desktop by itself, no three-dot button.
await tap(page, id);
await expect(page.locator('.device-menu-button')).toBeVisible();
await expect(page.getByRole('menuitem')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (141.99.2.10)']);
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await expect(page.locator('.device-menu-button')).toHaveCount(0);
await page.keyboard.press('Escape');
const window = await showDesktop(page, id);
await expect(window.getByRole('heading')).toHaveText('DNS-Server 2.10 - 141.99.2.10');
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Datei-Explorer', 'DNS-Server', 'Webserver']);
@@ -117,7 +119,6 @@ test("opens a computer's desktop from its menu: Java's title, its programs in Ja
await window.getByRole('button', { name: 'Schließen' }).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await tap(page, await deviceNamed(page, 'Switch 1'));
await page.locator('.device-menu-button').click();
await expect(page.getByRole('menuitem')).toHaveText(['SAT Tabelle anzeigen']);
await page.keyboard.press('Escape');
+19 -3
View File
@@ -4,7 +4,8 @@
// anew, set up and pinged across, without a mouse —; two fingers zoom the
// surface and never the page (M7); every action without a right click
// (long press, the three-dot button, the documentation's menu); every
// target at least 44 px, nothing beyond the screen's edge.
// target at least 44 px, nothing beyond the screen's edge; in the simulation
// a tap opens a device's menu.
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, scaleOf, serveEditor } from './editorPage.js';
@@ -104,9 +105,8 @@ test('builds FILIUS\' „zwei Rechnernetze“ by finger and pings across (the ac
// The simulation; the command line installed on the first computer; a ping across the router.
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).tap();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
// A tap chooses the computer; its three-dot button opens its menu, „Desktop anzeigen“ its desktop as the sheet.
// A tap opens the computer's menu, „Desktop anzeigen“ its desktop as the sheet.
await finger.tap(await onDevice(page, pcA));
await page.getByRole('button', { name: 'Aktionen' }).tap();
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).tap();
const desktop = page.locator(`section[data-desktop="${pcA}"]`);
await desktop.locator('[data-program="installation"]').tap();
@@ -332,3 +332,19 @@ test('gives every target 44 px at least and keeps the editor within the screen',
expect(await fits()).toBe(true);
expect(await small(targets)).toEqual([]);
});
test('draws undo and redo as arrows of their own, in the middle of their buttons, not as a character of whatever font the device has', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
for (const name of ['Rückgängig', 'Wiederholen']) {
const button = page.getByRole('button', { name, exact: true });
await expect(button).toHaveText('');
const [outer, arrow] = [(await button.boundingBox())!, (await button.locator('svg').boundingBox())!];
expect([arrow.width, arrow.height]).toEqual([20, 20]);
expect(Math.abs(arrow.x + arrow.width / 2 - (outer.x + outer.width / 2))).toBeLessThanOrEqual(0.5);
expect(Math.abs(arrow.y + arrow.height / 2 - (outer.y + outer.height / 2))).toBeLessThanOrEqual(0.5);
expect(Math.min(outer.width, outer.height)).toBeGreaterThanOrEqual(44);
}
// Redo is undo's mirror image.
const paths = await page.locator('.editor-toolbar svg g').evaluateAll((groups) => groups.map((group) => group.getAttribute('transform')));
expect(paths).toEqual([null, 'matrix(-1 0 0 1 24 0)']);
});
+21 -4
View File
@@ -122,8 +122,26 @@ const toolButton: CSSProperties = {
color: 'var(--text-primary)',
font: 'inherit',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
/**
* Undo's and redo's arrows, drawn: as characters (↶ ↷) each device took
* them from whatever font it had, small and askew on an iPad.
*/
function HistoryIcon({ redo }: { readonly redo: boolean }) {
return (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<g transform={redo ? 'matrix(-1 0 0 1 24 0)' : undefined}>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11" />
</g>
</svg>
);
}
/** A tool that is on: the sheet it opens is open. */
const pressedButton: CSSProperties = { borderColor: 'var(--accent)', background: 'var(--accent-soft)' };
@@ -369,7 +387,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
if (target.kind === 'doc') return [];
if (simulating) {
// Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address. Before them,
// what Java's click on the device opens — a switch's or home router's table —: a tap here only chooses.
// what Java's click on the device opens — a switch's or home router's table —: a tap here opens this menu.
if (target.kind !== 'device') return [];
const node = view.nodes.find((each) => each.id === target.id);
const items: MenuItem[] =
@@ -498,7 +516,6 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
);
}}
activeLinks={simulation.activeLinks}
hasMenu={(target) => menuItems(target).length > 0}
onMenu={(target, at) => {
if (readOnly && !simulating) return;
if (editorMode === 'documentation') {
@@ -530,10 +547,10 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
) : readOnly ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
<HistoryIcon redo={false} />
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
<HistoryIcon redo />
</button>
</>
)}
+12 -9
View File
@@ -9,6 +9,9 @@
// 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.
// - In the simulation a tap on a device chooses it and opens its menu at
// once (Java's click opens a desktop or a table; its menu holds both), and
// nothing moves; there is no three-dot button.
// - In the documentation mode the documentation items lie over the network
// and take the pointer (Java's documentation panel moves to the top): a
// tap gives one the focus, a drag on a rectangle (on a text: on the band
@@ -84,10 +87,10 @@ export interface CanvasProps {
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
readonly highlightedLink?: string | null;
/**
* The simulation: a tap on a device only chooses it, so that its
* three-dot button shows as in the design (Java's click opens a desktop or
* a table; here the menu does); a long press opens its menu; nothing
* moves, as in Java's simulation mode. The documentation mode: see above.
* The simulation: a tap on a device chooses it and opens its menu (Java's
* click opens a desktop or a table; here the menu holds them), as a long
* press does; nothing moves, as in Java's simulation mode. The
* documentation mode: see above.
*/
readonly mode?: 'design' | 'simulation' | 'documentation';
/**
@@ -104,8 +107,6 @@ export interface CanvasProps {
readonly renderDocText?: (item: DocumentationView, frame: ScreenFrame) => React.ReactNode;
/** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */
readonly activeLinks?: ReadonlySet<string>;
/** In the simulation: whether the chosen device has a menu at all (a switch has none) — only then its three-dot button. */
readonly hasMenu?: (target: Selection) => boolean;
}
const NO_LINKS: ReadonlySet<string> = new Set();
@@ -287,7 +288,6 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
locked = false,
chosenBy = NO_CHOICES,
activeLinks = NO_LINKS,
hasMenu,
onDocFrame,
renderDocText,
},
@@ -598,7 +598,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
}
if (now.longPressed) return;
if (simulating) {
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
if (now.moved) return;
const target = { kind: 'device', id: now.id } as const;
onSelect?.(target);
onMenu?.(target, now.start);
return;
}
if (locked) {
@@ -793,7 +796,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
)}
</div>
)}
{menuButton === null || selection === null || (simulating ? hasMenu?.(selection) !== true : locked) ? null : (
{menuButton === null || selection === null || simulating || locked ? null : (
<button
type="button"
className="device-menu-button"
+15 -5
View File
@@ -1,10 +1,12 @@
// 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.
// "remove cable"; in the simulation Java's menu there (a desktop, a table, a
// data exchange). Opened by a long press or the three-dot button, in the
// simulation by a tap; every item a 44 px target. It opens at the finger and
// moves in from the surface's edge as far as it has to.
import { useEffect, useRef, type CSSProperties } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import type { Point } from '../canvas/viewport.js';
export interface MenuItem {
@@ -29,6 +31,14 @@ const entry: CSSProperties = {
export function ContextMenu({ at, items, onClose }: { readonly at: Point; readonly items: readonly MenuItem[]; readonly onClose: () => void }) {
const menu = useRef<HTMLDivElement>(null);
const [place, setPlace] = useState(at);
useLayoutEffect(() => {
const element = menu.current;
const room = element?.offsetParent;
if (element === null || !(room instanceof HTMLElement)) return;
const inside = (from: number, size: number, length: number) => Math.max(0, Math.min(from, length - size));
setPlace({ x: inside(at.x, element.offsetWidth, room.clientWidth), y: inside(at.y, element.offsetHeight, room.clientHeight) });
}, [at]);
useEffect(() => {
menu.current?.querySelector('button')?.focus();
const away = (event: PointerEvent) => {
@@ -50,8 +60,8 @@ export function ContextMenu({ at, items, onClose }: { readonly at: Point; readon
role="menu"
style={{
position: 'absolute',
left: at.x,
top: at.y,
left: place.x,
top: place.y,
minWidth: 200,
padding: '4px 0',
borderRadius: 8,