feat(editor): handles for a finger on documentation items, and a grip that moves a text

The focused rectangle or text shows 44 px handles on its focus band: corners,
and the middles of long enough sides; on an item smaller than two targets only
the bottom right one, outside its corner. A text gets a grip above its top
left corner, since its middle is the field typed into. The three-dot button
moves above the top right corner; grip and button step aside at the surface's
edge. A tap on a handle is one on what lies under it, a long press opens the
menu, a second finger zooms. WebKit hands a tap's end to the handle despite
the capture, so the handle passes its events on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 22:04:40 +00:00
co-authored by Claude Opus 5.5
parent 12b26f4abe
commit 8c4e5049f6
9 changed files with 588 additions and 12 deletions
+1
View File
@@ -277,6 +277,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/design/SideBar.tsx` (neu), `src/design/Palette.tsx`, `src/design/DocumentationSidebar.tsx`, `src/Editor.tsx`, `src/sheet/sheet.css` (`.side-bar`), `src/i18n/*.json` (`design.foldBar`, `design.unfoldBar`), `browser/design.spec.ts`, `browser/documentation.spec.ts` | Java `GUIDesignSidebar`, `GUIDocumentationSidebar`; Dashboard `AppShell` („Leiste einklappen“) | erweitert (10.10.2026, auf Wunsch des Nutzers): die Leiste links (Palette, Dokumentationsleiste) klappt mit einem Knopf oben ein und aus — eingeklappt eine schmale Spalte (57 px statt 137) mit nur dem Knopf, nach dem Muster der Dashboard-Leiste; der Knopf steht in beiden Zuständen an derselben Stelle. Ein Zustand für beide Leisten, in diesem Browser gemerkt (`filius.barFolded`). Gemessen: die Fläche gewinnt 80 px (iPad hochkant mit Blatt 291 → 371 px, Handy 253 → 333 px). Abweichend von Java: dort ist die Leiste fest | | `src/design/SideBar.tsx` (neu), `src/design/Palette.tsx`, `src/design/DocumentationSidebar.tsx`, `src/Editor.tsx`, `src/sheet/sheet.css` (`.side-bar`), `src/i18n/*.json` (`design.foldBar`, `design.unfoldBar`), `browser/design.spec.ts`, `browser/documentation.spec.ts` | Java `GUIDesignSidebar`, `GUIDocumentationSidebar`; Dashboard `AppShell` („Leiste einklappen“) | erweitert (10.10.2026, auf Wunsch des Nutzers): die Leiste links (Palette, Dokumentationsleiste) klappt mit einem Knopf oben ein und aus — eingeklappt eine schmale Spalte (57 px statt 137) mit nur dem Knopf, nach dem Muster der Dashboard-Leiste; der Knopf steht in beiden Zuständen an derselben Stelle. Ein Zustand für beide Leisten, in diesem Browser gemerkt (`filius.barFolded`). Gemessen: die Fläche gewinnt 80 px (iPad hochkant mit Blatt 291 → 371 px, Handy 253 → 333 px). Abweichend von Java: dort ist die Leiste fest |
| `src/sheet/sheet.css` (`.editor-root`, Handy), `src/Editor.tsx` (Wurzel), `browser/sheet.spec.ts` | — | geändert (10.10.2026, auf Wunsch des Nutzers): auf dem Handy (bis 639 px) nimmt das Geräte-Blatt den ganzen Editor ein, über der Leiste und den Modusknöpfen; eine Meldung bleibt darüber, Schließen gibt die Fläche zurück. Vorher stand es unter der Fläche (höchstens halb so hoch), neben einer Leiste von 137 px blieben ihm 253 von 390 px. Folge: die DHCP-Segmente stehen auf einem 390-px-Handy jetzt nebeneinander, untereinander erst unter etwa 335 px | | `src/sheet/sheet.css` (`.editor-root`, Handy), `src/Editor.tsx` (Wurzel), `browser/sheet.spec.ts` | — | geändert (10.10.2026, auf Wunsch des Nutzers): auf dem Handy (bis 639 px) nimmt das Geräte-Blatt den ganzen Editor ein, über der Leiste und den Modusknöpfen; eine Meldung bleibt darüber, Schließen gibt die Fläche zurück. Vorher stand es unter der Fläche (höchstens halb so hoch), neben einer Leiste von 137 px blieben ihm 253 von 390 px. Folge: die DHCP-Segmente stehen auf einem 390-px-Handy jetzt nebeneinander, untereinander erst unter etwa 335 px |
| `src/simulation/PropertiesSheet.tsx` (neu), `src/simulation/DropHold.tsx` (vorher `DropControl.tsx`), `src/simulation/useSimulation.ts` (`restart`), `src/Editor.tsx`, `src/sheet/sheet.css`, `src/desktop/desktop.css`, `src/i18n/*.json` (`properties.*`; `drop.list` entfällt), `browser/viewers.spec.ts`, `browser/shared.spec.ts`, `browser/fromF.spec.ts`, `tools/live/acceptance.ts` | Java `SimulationControl` | geändert (10.10.2026, auf Wunsch des Nutzers): im Aktionsmodus ein eigener Inspector „Eigenschaften“ (Knopf über der Fläche, Blatt neben ihr wie die Geräte-Einstellungen, auf dem Handy über dem ganzen Editor) statt Hinweis und Knöpfen in der Werkzeugleiste: wer die Simulation gestartet hat, „Simulation neu starten“ (für alle, nach einer Rückfrage; die Tabellen des Datenaustauschs verlieren ihre Auswahl), „Pakete verwerfen“ und darunter die verworfenen Pakete samt Anzahl. Wer nur zusieht, sieht Starter und Liste, ohne Neustart und Verwerfen | | `src/simulation/PropertiesSheet.tsx` (neu), `src/simulation/DropHold.tsx` (vorher `DropControl.tsx`), `src/simulation/useSimulation.ts` (`restart`), `src/Editor.tsx`, `src/sheet/sheet.css`, `src/desktop/desktop.css`, `src/i18n/*.json` (`properties.*`; `drop.list` entfällt), `browser/viewers.spec.ts`, `browser/shared.spec.ts`, `browser/fromF.spec.ts`, `tools/live/acceptance.ts` | Java `SimulationControl` | geändert (10.10.2026, auf Wunsch des Nutzers): im Aktionsmodus ein eigener Inspector „Eigenschaften“ (Knopf über der Fläche, Blatt neben ihr wie die Geräte-Einstellungen, auf dem Handy über dem ganzen Editor) statt Hinweis und Knöpfen in der Werkzeugleiste: wer die Simulation gestartet hat, „Simulation neu starten“ (für alle, nach einer Rückfrage; die Tabellen des Datenaustauschs verlieren ihre Auswahl), „Pakete verwerfen“ und darunter die verworfenen Pakete samt Anzahl. Wer nur zusieht, sieht Starter und Liste, ohne Neustart und Verwerfen |
| `src/canvas/handles.ts` (neu), `src/canvas/Canvas.tsx` (Griffe, Durchtippen, Platz von „⋯“), `src/sheet/sheet.css` (`.doc-handle`), `src/i18n/*.json` (`docu.resize`, `docu.move`), `test/handles.test.ts`, `browser/touch.spec.ts`, `browser/documentation.spec.ts` | Java `JDocuElement` (Rand von 7 zum Greifen) | erweitert (10.10.2026, auf Wunsch des Nutzers): im Dokumentationsmodus zeigt das fokussierte Element Griffe für den Finger — Trefferfläche 44 px, Punkt in Javas Fokusblau auf dem Fokusband: an den Ecken, ab drei Zielen Länge auch an den Kantenmitten; auf einem Element unter zwei Zielen nur rechts unten, außen an der Ecke, damit seine Mitte verschiebt und das Menü öffnet. Ein Text hat über seiner linken oberen Ecke einen Verschiebe-Griff (seine Mitte ist das Eingabefeld). „⋯“ steht über der rechten oberen Ecke; Griff und „⋯“ weichen am Rand der Fläche aus. Ein Tippen auf einen Griff gilt dem, was darunter liegt; langes Drücken öffnet das Menü. Gemessen: Javas Rand war 10–14 Bildschirm-Pixel breit und unsichtbar. WebKit gibt das Ende eines Tippens dem Griff statt der Fläche, die die Berührung gefangen hat; der Griff reicht seine Ereignisse deshalb weiter. Javas Rand für die Maus bleibt. Abweichend von Java: dort keine Griffe |
## packages/server ## packages/server
+19 -2
View File
@@ -3,7 +3,7 @@
// the sidebar's rectangle dragged onto the surface (its middle under the // the sidebar's rectangle dragged onto the surface (its middle under the
// pointer), moved, resized to Java's smallest size, coloured from Java's // pointer), moved, resized to Java's smallest size, coloured from Java's
// list; the sidebar's text tapped into the view, typed into, made large and // list; the sidebar's text tapped into the view, typed into, made large and
// italic; Java's paint in the documentation mode and in the design; Delete; // italic; its handles by mouse; Java's paint in the documentation mode and in the design; Delete;
// a simulation started ends the mode; „Exportieren“ (F4.8c): the image // a simulation started ends the mode; „Exportieren“ (F4.8c): the image
// saved under the project's name, cut and painted as Java's, and Java's // saved under the project's name, cut and painted as Java's, and Java's
// message when saving fails; and a phone. // message when saving fails; and a phone.
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { JAVA_LABEL_FONT } from '../src/canvas/layout.js'; import { JAVA_LABEL_FONT } from '../src/canvas/layout.js';
import { documentUpdates, drag, drawing, openEditor, serveEditor } from './editorPage.js'; import { documentUpdates, drag, drawing, openEditor, scaleOf, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> }; let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -122,6 +122,23 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
}) })
.toEqual([18, true, false]); .toEqual([18, true, false]);
await expect(field).toHaveCSS('font-style', 'italic'); await expect(field).toHaveCSS('font-style', 'italic');
// Its handles take the mouse too, even where one jump of it lands over the field typed into: the corner follows.
const sized = (await docOf(page, text.id))!;
const scale = await scaleOf(page);
const cornerHandle = (await page.locator('[data-handle="bottom-right"]').boundingBox())!;
const from = { x: cornerHandle.x + cornerHandle.width / 2, y: cornerHandle.y + cornerHandle.height / 2 };
const fieldBox = (await field.boundingBox())!;
const into = { x: fieldBox.x + fieldBox.width - 4, y: fieldBox.y + fieldBox.height - 4 };
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(into.x, into.y);
await page.mouse.up();
await expect
.poll(async () => {
const item = (await docOf(page, text.id))!;
return [item.width, item.height].map(Math.round);
})
.toEqual([Math.round(Math.max(88, sized.width + (into.x - from.x) / scale)), Math.round(Math.max(20, sized.height + (into.y - from.y) / scale))]);
// Escape ends the focus: the text drawn, with Java's grey border in the documentation mode. // Escape ends the focus: the text drawn, with Java's grey border in the documentation mode.
await field.press('Escape'); await field.press('Escape');
+99 -2
View File
@@ -7,7 +7,7 @@
// target at least 44 px, nothing beyond the screen's edge. // target at least 44 px, nothing beyond the screen's edge.
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js'; import { drawing, onDevice, openEditor, scaleOf, serveEditor } from './editorPage.js';
import { fingers, type Fingers, type Point } from './fingers.js'; import { fingers, type Fingers, type Point } from './fingers.js';
let server: { readonly base: string; readonly close: () => Promise<void> }; let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -172,6 +172,103 @@ test('puts a device or an item down where it is when a second finger comes or th
await expect.poll(async () => (await drawing(page)).docs.find((item) => item.id === rectangle.id)!.x).not.toBe(rectangle.x); await expect.poll(async () => (await drawing(page)).docs.find((item) => item.id === rectangle.id)!.x).not.toBe(rectangle.x);
}); });
test("resizes a rectangle of the documentation by its handles, and moves and resizes a text by its grip and handles, with a finger", async ({ page }) => {
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
const finger = await fingers(page);
await page.locator('button[name="documentationMode"]').tap();
const docOf = async (id: string) => (await drawing(page)).docs.find((item) => item.id === id)!;
const frameOf = async (id: string) => {
const item = await docOf(id);
return [item.x, item.y, item.width, item.height].map(Math.round);
};
const handle = (name: string) => page.locator(`[data-handle="${name}"]`);
const scale = await scaleOf(page);
// A rectangle tapped: its handles, each a finger's target, the three-dot button clear of them.
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
await finger.tap(await centreOf(page, `g[data-doc="${rectangle.id}"] .doc-rectangle`));
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).toHaveAttribute('data-focused', 'true');
const names = await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getAttribute('data-handle')));
expect(names).toEqual(expect.arrayContaining(['top-left', 'top-right', 'bottom-right', 'bottom-left']));
for (const box of await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect))) expect([box.width, box.height]).toEqual([44, 44]);
const menu = (await page.locator('.device-menu-button').boundingBox())!;
for (const box of await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect))) {
expect(box.y >= menu.y + menu.height || box.y + box.height <= menu.y || box.x >= menu.x + menu.width || box.x + box.width <= menu.x).toBe(true);
}
// The bottom right corner dragged 60 right and 40 down: wider and higher by as much in the drawing, the top left kept.
const [x, y, width, height] = await frameOf(rectangle.id);
const bottomRight = await centreOf(page, '[data-handle="bottom-right"]');
await finger.drag(bottomRight, { x: bottomRight.x + 60, y: bottomRight.y + 40 });
await expect.poll(() => frameOf(rectangle.id)).toEqual([x, y, Math.round(width! + 60 / scale), Math.round(height! + 40 / scale)]);
// The top left one 30 left: the right edge kept.
const wider = await frameOf(rectangle.id);
const topLeft = await centreOf(page, '[data-handle="top-left"]');
await finger.drag(topLeft, { x: topLeft.x - 30, y: topLeft.y });
await expect.poll(async () => (await frameOf(rectangle.id))[0]).toBe(Math.round(wider[0]! - 30 / scale));
const [left, , across] = await frameOf(rectangle.id);
expect(Math.abs(left! + across! - (wider[0]! + wider[2]!))).toBeLessThanOrEqual(1);
// A long press on a handle opens the rectangle's menu, as on the rectangle.
await finger.press(await centreOf(page, '[data-handle="top-left"]'));
await expect(page.getByRole('menu').getByRole('group', { name: 'Farbe' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toHaveCount(0);
// The text in its top edge lies partly under a handle: tapped there, the text is chosen and typed into, not the rectangle.
const below = (await drawing(page)).docs.find((item) => item.kind === 'text' && item.text.startsWith('Hosts'))!;
const words = (await page.locator(`g[data-doc="${below.id}"] .doc-text`).boundingBox())!;
const covering = (await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect)))
.map((box) => ({ left: Math.max(box.x, words.x), top: Math.max(box.y, words.y), right: Math.min(box.x + box.width, words.x + words.width), bottom: Math.min(box.y + box.height, words.y + words.height) }))
.find((both) => both.right - both.left > 4 && both.bottom - both.top > 4)!;
expect(covering).toBeDefined();
await finger.tap({ x: (covering.left + covering.right) / 2, y: (covering.top + covering.bottom) / 2 });
await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible();
await expect(page.locator(`g[data-doc="${below.id}"]`)).toHaveAttribute('data-focused', 'true');
// A text with room around it, tapped: a grip above its top left corner moves it — its size kept.
await page.getByRole('textbox', { name: 'Text' }).press('Escape');
const text = (await drawing(page)).docs.find((item) => item.kind === 'text' && item.text.startsWith('Traffic'))!;
await finger.tap(await centreOf(page, `g[data-doc="${text.id}"] .doc-text`));
await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible();
await expect(handle('move')).toBeVisible();
// The handles sit on the blue band around the text (where Java takes hold of it), not on its words.
const bandBox = (await page.locator(`g[data-doc="${text.id}"] .doc-focus`).boundingBox())!;
const cornerAt = await centreOf(page, '[data-handle="bottom-right"]');
expect(Math.abs(cornerAt.x - (bandBox.x + bandBox.width + scale))).toBeLessThanOrEqual(1.5);
expect(Math.abs(cornerAt.y - (bandBox.y + bandBox.height + scale))).toBeLessThanOrEqual(1.5);
// Every point of a handle's target takes the finger, also where it lies over the field typed into.
const hidden = await page.locator('[data-handle]').evaluateAll((all) =>
all.flatMap((each) => {
const box = each.getBoundingClientRect();
const points = [
[box.left + 3, box.top + 3],
[box.right - 3, box.top + 3],
[box.left + 3, box.bottom - 3],
[box.right - 3, box.bottom - 3],
];
return points.filter(([x, y]) => document.elementFromPoint(x!, y!)?.closest('[data-handle]') !== each).map(() => each.getAttribute('data-handle'));
}),
);
expect(hidden).toEqual([]);
const before = await frameOf(text.id);
const grip = await centreOf(page, '[data-handle="move"]');
await finger.drag(grip, { x: grip.x + 50, y: grip.y + 30 });
await expect.poll(() => frameOf(text.id)).toEqual([Math.round(before[0]! + 50 / scale), Math.round(before[1]! + 30 / scale), before[2], before[3]]);
// Its bottom right handle: wider and higher; what it says is unchanged.
const moved = await frameOf(text.id);
const corner = await centreOf(page, '[data-handle="bottom-right"]');
await finger.drag(corner, { x: corner.x + 40, y: corner.y + 20 });
await expect.poll(() => frameOf(text.id)).toEqual([moved[0], moved[1], Math.round(moved[2]! + 40 / scale), Math.round(moved[3]! + 20 / scale)]);
expect((await docOf(text.id)).text).toBe(text.text);
// Two fingers, the second on a handle: they zoom, and the text keeps its size.
const sized = await frameOf(text.id);
const onHandle = await centreOf(page, '[data-handle="bottom-right"]');
await finger.pinch({ x: onHandle.x - 40, y: onHandle.y }, 40, 120);
await expect.poll(() => scaleOf(page)).toBeGreaterThan(scale * 1.5);
expect(await frameOf(text.id)).toEqual(sized);
});
test('reaches every action without a right click: a long press, the three-dot button, the documentation', async ({ page }) => { test('reaches every action without a right click: a long press, the three-dot button, the documentation', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const finger = await fingers(page); const finger = await fingers(page);
@@ -220,7 +317,7 @@ test('gives every target 44 px at least and keeps the editor within the screen',
.map(({ name, box }) => `${name} ${Math.round(box.width)}×${Math.round(box.height)}`), .map(({ name, box }) => `${name} ${Math.round(box.width)}×${Math.round(box.height)}`),
); );
const targets = const targets =
'.editor-toolbar button, [data-palette], [data-docu], nav button, [data-zoom] ~ button, .zoom-bar button, .device-menu-button, aside[aria-label="Einstellungen"] button, aside[aria-label="Einstellungen"] [role="tab"]'; '.editor-toolbar button, [data-palette], [data-docu], nav button, [data-zoom] ~ button, .zoom-bar button, .device-menu-button, [data-handle], aside[aria-label="Einstellungen"] button, aside[aria-label="Einstellungen"] [role="tab"]';
expect(await fits()).toBe(true); expect(await fits()).toBe(true);
expect(await small(targets)).toEqual([]); expect(await small(targets)).toEqual([]);
// With a device chosen: its sheet and its three-dot button. // With a device chosen: its sheet and its three-dot button.
+90 -8
View File
@@ -13,7 +13,14 @@
// and take the pointer (Java's documentation panel moves to the top): a // 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 // tap gives one the focus, a drag on a rectangle (on a text: on the band
// around it while it has the focus) moves it, on its edge resizes it, // around it while it has the focus) moves it, on its edge resizes it,
// a long press opens its menu. The devices take nothing. // a long press opens its menu. The devices take nothing. For a finger
// (Java has none) the focused item shows handles of 44 px: its corners and
// the middles of its long enough sides resize it, and a text has one above
// its top left corner that moves it — its middle is the field typed into.
// Its three-dot button stands above its top right corner, clear of them
// (each moves aside where the surface ends). A tap on a handle is one on
// what lies under it, a long press opens the item's menu; on an item too
// small for handles on its edge the one left stands outside its corner.
// //
// The pointer is captured only once it has moved: captured at once, it would // The pointer is captured only once it has moved: captured at once, it would
// take the click from whatever lies under it (pointer-capture trap; jsdom // take the click from whatever lies under it (pointer-capture trap; jsdom
@@ -28,13 +35,13 @@ import { ICON_URLS } from './icons.js';
import { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type DeviceBox } from './layout.js'; import { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js'; import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
import { ZoomBar } from './ZoomBar.js'; import { ZoomBar } from './ZoomBar.js';
import { gripPlace, handlePlace, HANDLES, menuPlace, NO_EDGE, shownHandles, TARGET, type Edges } from './handles.js';
import { EDGE_BAND, edgesAt, FOCUS_BAND, FOCUS_BORDER, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, textColor, type Frame } from './documentationPaint.js'; import { EDGE_BAND, edgesAt, FOCUS_BAND, FOCUS_BORDER, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, textColor, type Frame } from './documentationPaint.js';
/** How far a pointer moves before a press becomes a drag. */ /** How far a pointer moves before a press becomes a drag. */
export const DRAG_THRESHOLD = 4; export const DRAG_THRESHOLD = 4;
/** How long a press lasts before it opens the menu. */ /** How long a press lasts before it opens the menu. */
export const LONG_PRESS_MILLISECONDS = 500; export const LONG_PRESS_MILLISECONDS = 500;
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string } | { readonly kind: 'doc'; readonly id: string }; export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string } | { readonly kind: 'doc'; readonly id: string };
export type Tool = 'select' | 'cable'; 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. */ /** Where on a home router's button a cable meets it: Java takes the left half for WAN, the right for LAN. */
@@ -254,6 +261,8 @@ type Gesture =
readonly origin: Frame; readonly origin: Frame;
/** The edges a drag moves; none: it moves the item; null: it does nothing (a text without the focus). */ /** The edges a drag moves; none: it moves the item; null: it does nothing (a text without the focus). */
readonly edges: { left: boolean; right: boolean; top: boolean; bottom: boolean } | null; readonly edges: { left: boolean; right: boolean; top: boolean; bottom: boolean } | null;
/** Begun on a handle: a tap without a drag is one on what lies under it. */
readonly handle?: boolean;
moved: boolean; moved: boolean;
longPressed: boolean; longPressed: boolean;
}; };
@@ -398,7 +407,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
/** Whether a gesture was on a device or an item: its end lets the editor write what it was dragging, if anything (a tap, a long press, the cable tool drag nothing). */ /** Whether a gesture was on a device or an item: its end lets the editor write what it was dragging, if anything (a tap, a long press, the cable tool drag nothing). */
const draggedSomething = (now: Gesture | null) => now?.kind === 'device' || now?.kind === 'doc'; const draggedSomething = (now: Gesture | null) => now?.kind === 'device' || now?.kind === 'doc';
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => { const onPointerDown = (event: React.PointerEvent<Element>) => {
const point = local(event.clientX, event.clientY); const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point); pointers.current.set(event.pointerId, point);
// A press that takes the place of a drag ends it where it is: a second finger (a pinch), or WebKit's // A press that takes the place of a drag ends it where it is: a second finger (a pinch), or WebKit's
@@ -464,7 +473,33 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
} }
}; };
const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => { /** A finger on a handle of the focused item: the drag resizes it by those edges, or (none) moves it. */
const onHandleDown = (id: string, edges: Edges) => (event: React.PointerEvent<HTMLDivElement>) => {
// A second finger: a pinch, as on the surface.
if (pointers.current.size > 0) {
onPointerDown(event);
return;
}
const item = view.docs.find((each) => each.id === id);
if (item === undefined) return;
const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point);
gesture.current = { kind: 'doc', id, start: point, origin: { x: item.x, y: item.y, width: item.width, height: item.height }, edges: { ...edges }, handle: true, moved: false, longPressed: false };
// A long press opens the item's menu, as on the item.
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'doc' || now.moved) return;
now.longPressed = true;
onMenu?.({ kind: 'doc', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
// Captured at once: a handle is no button, there is no click to take; the surface follows the drag from here.
// WebKit still gives a tap's end to the handle (measured: pointerup on the handle, with the capture on the
// surface), so the handle hands its events on too — else the finger would stay down, and the next one pinch.
capture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<Element>) => {
if (!pointers.current.has(event.pointerId)) return; if (!pointers.current.has(event.pointerId)) return;
const point = local(event.clientX, event.clientY); const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point); pointers.current.set(event.pointerId, point);
@@ -522,7 +557,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
} }
}; };
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => { const onPointerUp = (event: React.PointerEvent<Element>) => {
const point = pointers.current.get(event.pointerId) ?? local(event.clientX, event.clientY); const point = pointers.current.get(event.pointerId) ?? local(event.clientX, event.clientY);
pointers.current.delete(event.pointerId); pointers.current.delete(event.pointerId);
if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(event.pointerId); if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(event.pointerId);
@@ -540,7 +575,11 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (now.kind === 'doc') { if (now.kind === 'doc') {
if (now.longPressed) return; if (now.longPressed) return;
if (now.moved) onMoveEnd?.(); if (now.moved) onMoveEnd?.();
else onSelect?.({ kind: 'doc', id: now.id }); else if (now.handle === true) {
// A handle lies over what is near the item's edge (a text in a rectangle's corner): a tap is meant for that.
const under = docAt(now.start);
onSelect?.(under === null ? null : { kind: 'doc', id: under.item.id });
} else onSelect?.({ kind: 'doc', id: now.id });
return; return;
} }
if (now.kind === 'background') { if (now.kind === 'background') {
@@ -600,6 +639,9 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
return () => element.removeEventListener('wheel', onWheel); return () => element.removeEventListener('wheel', onWheel);
}, [local]); }, [local]);
/** A handle's own moves and ends, handed on (see onHandleDown). */
const handleEvents = { onPointerMove, onPointerUp, onPointerCancel: onPointerUp };
// 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). // 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 => { const menuAnchor = ((): Point | null => {
if (selection?.kind === 'device') { if (selection?.kind === 'device') {
@@ -608,7 +650,9 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
} }
if (selection?.kind === 'doc') { if (selection?.kind === 'doc') {
const item = view.docs.find((each) => each.id === selection.id); const item = view.docs.find((each) => each.id === selection.id);
return item === undefined ? null : { x: item.x + item.width, y: item.y }; if (item === undefined) return null;
const hold = holdFrame(item, item.id === focusedDoc);
return { x: hold.x + hold.width, y: hold.y };
} }
if (selection?.kind === 'link') { if (selection?.kind === 'link') {
const link = view.links.find((each) => each.id === selection.id); const link = view.links.find((each) => each.id === selection.id);
@@ -617,7 +661,18 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
} }
return null; return null;
})(); })();
const menuButton = menuAnchor === null ? null : toScreen(current, menuAnchor); // The focused item's handles and, for a text, its grip, in screen pixels; none for who only reads.
const handled = ((): { readonly item: DocumentationView; readonly frame: { x: number; y: number; width: number; height: number } } | null => {
const item = focusedDoc === null || locked ? undefined : view.docs.find((each) => each.id === focusedDoc);
if (item === undefined) return null;
const hold = holdFrame(item, true);
const corner = toScreen(current, { x: hold.x, y: hold.y });
return { item, frame: { x: corner.x, y: corner.y, width: hold.width * current.scale, height: hold.height * current.scale } };
})();
// A text's grip and the three-dot button, clear of the handles and of the surface's edge (handles.ts).
const grip = handled === null || handled.item.kind !== 'text' ? null : gripPlace(handled.frame, size);
const anchored = menuAnchor === null ? null : toScreen(current, menuAnchor);
const menuButton = anchored === null || selection?.kind !== 'doc' || handled === null ? anchored : menuPlace(handled.frame, size, grip);
const centre = { x: size.width / 2, y: size.height / 2 }; const centre = { x: size.width / 2, y: size.height / 2 };
const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' }; const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' };
@@ -708,6 +763,33 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const corner = toScreen(current, { x: item.x, y: item.y }); const corner = toScreen(current, { x: item.x, y: item.y });
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale }); return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
})()} })()}
{handled === null ? null : (
<div className="doc-handles" data-handles={handled.item.id}>
{HANDLES.filter(({ name }) => shownHandles(handled.frame).includes(name)).map(({ name, edges, cursor }) => {
const { centre, dot } = handlePlace(name, handled.frame);
return (
<div
key={name}
className="doc-handle"
data-handle={name}
title={t('docu.resize')}
onPointerDown={onHandleDown(handled.item.id, edges)}
{...handleEvents}
style={{ left: centre.x - TARGET / 2, top: centre.y - TARGET / 2, cursor }}
>
<span style={{ borderColor: FOCUS_BORDER, transform: `translate(${dot.x}px, ${dot.y}px)` }} />
</div>
);
})}
{grip === null ? null : (
<div className="doc-handle doc-grip" data-handle="move" title={t('docu.move')} onPointerDown={onHandleDown(handled.item.id, NO_EDGE)} {...handleEvents} style={{ left: grip.x - TARGET / 2, top: grip.y - TARGET / 2 }}>
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3v18M3 12h18M12 3l-3 3M12 3l3 3M12 21l-3-3M12 21l3-3M3 12l3-3M3 12l3 3M21 12l-3-3M21 12l-3 3" />
</svg>
</div>
)}
</div>
)}
{menuButton === null || selection === null || (simulating ? hasMenu?.(selection) !== true : locked) ? null : ( {menuButton === null || selection === null || (simulating ? hasMenu?.(selection) !== true : locked) ? null : (
<button <button
type="button" type="button"
+170
View File
@@ -0,0 +1,170 @@
// A focused documentation item's handles for a finger (Java has none: its
// edges are a band of 7 for the mouse): where they stand on the screen, and
// where its grip and its three-dot button go, clear of them and of the
// surface's edge. All in screen pixels; Canvas draws them and takes the drag.
import type { Point, Size } from "./viewport.js";
/** A finger's target: a handle, the grip, the three-dot button. */
export const TARGET = 44;
/** How far the grip and the three-dot button stand from the item's corners. */
export const ABOVE = 48;
export interface Edges {
readonly left: boolean;
readonly right: boolean;
readonly top: boolean;
readonly bottom: boolean;
}
export const NO_EDGE: Edges = {
left: false,
right: false,
top: false,
bottom: false,
};
/** The item's frame on the screen. */
export interface ScreenRect extends Point, Size {}
/** Each handle: where on the frame (0 to 1 across and down), the edges a drag on it moves, the mouse's cursor. */
export const HANDLES: readonly {
readonly name: string;
readonly at: Point;
readonly edges: Edges;
readonly cursor: string;
}[] = [
{
name: "top-left",
at: { x: 0, y: 0 },
edges: { ...NO_EDGE, left: true, top: true },
cursor: "nwse-resize",
},
{
name: "top",
at: { x: 0.5, y: 0 },
edges: { ...NO_EDGE, top: true },
cursor: "ns-resize",
},
{
name: "top-right",
at: { x: 1, y: 0 },
edges: { ...NO_EDGE, right: true, top: true },
cursor: "nesw-resize",
},
{
name: "right",
at: { x: 1, y: 0.5 },
edges: { ...NO_EDGE, right: true },
cursor: "ew-resize",
},
{
name: "bottom-right",
at: { x: 1, y: 1 },
edges: { ...NO_EDGE, right: true, bottom: true },
cursor: "nwse-resize",
},
{
name: "bottom",
at: { x: 0.5, y: 1 },
edges: { ...NO_EDGE, bottom: true },
cursor: "ns-resize",
},
{
name: "bottom-left",
at: { x: 0, y: 1 },
edges: { ...NO_EDGE, left: true, bottom: true },
cursor: "nesw-resize",
},
{
name: "left",
at: { x: 0, y: 0.5 },
edges: { ...NO_EDGE, left: true },
cursor: "ew-resize",
},
];
/**
* The handles an item shows at its size on the screen: its corners while it
* is two targets wide and high (else the bottom right one only, so that its
* middle still moves it), the middle of a side three targets long.
*/
export function shownHandles(size: Size): readonly string[] {
if (size.width < 2 * TARGET || size.height < 2 * TARGET)
return ["bottom-right"];
return HANDLES.filter(({ at }) => {
const corner = at.x !== 0.5 && at.y !== 0.5;
return corner || (at.x === 0.5 ? size.width : size.height) >= 3 * TARGET;
}).map(({ name }) => name);
}
/** How far into the item a handle's target reaches where the item is too small for targets on its edge. */
const OVERLAP = 6;
/**
* Where a handle's target stands (its middle) and its dot, from the target's
* middle. On its corner or edge, the dot in the middle; on an item too small
* for that (only the bottom right one shown), the target lies outside it,
* reaching in by OVERLAP, with the dot still on the corner — else it would
* cover the item, and its middle would neither move it nor open its menu.
*/
export function handlePlace(
name: string,
frame: ScreenRect,
): { readonly centre: Point; readonly dot: Point } {
const { at } = HANDLES.find((each) => each.name === name)!;
const point = {
x: frame.x + at.x * frame.width,
y: frame.y + at.y * frame.height,
};
if (shownHandles(frame).length > 1)
return { centre: point, dot: { x: 0, y: 0 } };
const out = TARGET / 2 - OVERLAP;
return {
centre: { x: point.x + out, y: point.y + out },
dot: { x: -out, y: -out },
};
}
/** Whether a target's middle leaves the whole target on the surface. */
const fits = (surface: Size) => (at: Point) =>
at.x >= TARGET / 2 &&
at.x <= surface.width - TARGET / 2 &&
at.y >= TARGET / 2 &&
at.y <= surface.height - TARGET / 2;
/** A text's grip, its middle: above its top left corner, else left of it, else below its bottom left one — the first on the surface. */
export function gripPlace(frame: ScreenRect, surface: Size): Point {
const places = [
{ x: frame.x, y: frame.y - ABOVE },
{ x: frame.x - ABOVE, y: frame.y },
{ x: frame.x, y: frame.y + frame.height + ABOVE },
];
return places.find(fits(surface)) ?? places[0]!;
}
/**
* The three-dot button's middle: above the top right corner (right of a
* narrow text's grip), else right of it, else below the bottom right one —
* the first on the surface and clear of the grip.
*/
export function menuPlace(
frame: ScreenRect,
surface: Size,
grip: Point | null,
): Point {
const right = frame.x + frame.width;
const places = [
{
x: grip === null ? right : Math.max(right, grip.x + TARGET + 4),
y: frame.y - ABOVE,
},
{ x: right + ABOVE, y: frame.y },
{ x: right, y: frame.y + frame.height + ABOVE },
];
const clear = (at: Point) =>
grip === null ||
Math.abs(at.x - grip.x) >= TARGET ||
Math.abs(at.y - grip.y) >= TARGET;
return places.find((at) => fits(surface)(at) && clear(at)) ?? places[0]!;
}
+2
View File
@@ -115,9 +115,11 @@
"docu.exportHint": "Als Bild exportieren", "docu.exportHint": "Als Bild exportieren",
"docu.fontSize": "Schriftgröße", "docu.fontSize": "Schriftgröße",
"docu.fontStyle": "Schriftstil", "docu.fontStyle": "Schriftstil",
"docu.move": "Verschieben",
"docu.rectangle": "Gliederungsfläche", "docu.rectangle": "Gliederungsfläche",
"docu.report": "Report", "docu.report": "Report",
"docu.reportHint": "Report als PDF-Dokument erstellen", "docu.reportHint": "Report als PDF-Dokument erstellen",
"docu.resize": "Größe ändern",
"docu.saveFailed": "Fehler beim Speichern aufgetreten!", "docu.saveFailed": "Fehler beim Speichern aufgetreten!",
"docu.sidebar": "Dokumentation", "docu.sidebar": "Dokumentation",
"docu.sizeLarge": "Groß", "docu.sizeLarge": "Groß",
+2
View File
@@ -115,9 +115,11 @@
"docu.exportHint": "Export as Image", "docu.exportHint": "Export as Image",
"docu.fontSize": "Font Size", "docu.fontSize": "Font Size",
"docu.fontStyle": "Font Style", "docu.fontStyle": "Font Style",
"docu.move": "Move",
"docu.rectangle": "Structure Field", "docu.rectangle": "Structure Field",
"docu.report": "Report", "docu.report": "Report",
"docu.reportHint": "Create report as PDF document", "docu.reportHint": "Create report as PDF document",
"docu.resize": "Resize",
"docu.saveFailed": "Error occurred!", "docu.saveFailed": "Error occurred!",
"docu.sidebar": "Documentation", "docu.sidebar": "Documentation",
"docu.sizeLarge": "Large", "docu.sizeLarge": "Large",
+39
View File
@@ -50,6 +50,45 @@
overflow-y: auto; overflow-y: auto;
} }
/* A focused documentation item's handles (Canvas): a finger's target of
* 44 px around a dot in Java's focus blue, over the field a text is typed
* into; a text's grip a round button above its corner. */
.doc-handle {
position: absolute;
z-index: 4;
display: grid;
place-items: center;
width: 44px;
height: 44px;
touch-action: none;
}
.doc-handle > span {
box-sizing: border-box;
width: 14px;
height: 14px;
border: 2px solid;
border-radius: 7px;
background: var(--surface-raised);
box-shadow: 0 1px 2px color-mix(in srgb, var(--shadow-color) 25%, transparent);
}
.doc-grip {
cursor: move;
}
.doc-grip > svg {
box-sizing: border-box;
width: 36px;
height: 36px;
padding: 7px;
border: 1px solid var(--border-default);
border-radius: 18px;
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: 0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent);
}
/* The device sheet beside the surface: the surface shrinks instead of being /* The device sheet beside the surface: the surface shrinks instead of being
* covered, so the device stays in sight. */ * covered, so the device stays in sight. */
.editor-work { .editor-work {
+166
View File
@@ -0,0 +1,166 @@
// A focused documentation item's handles for a finger: which it shows at
// which size on the screen (each boundary on both sides), and where its grip
// and three-dot button go — clear of each other and of the surface's edge.
import { describe, expect, it } from "vitest";
import {
ABOVE,
gripPlace,
handlePlace,
HANDLES,
menuPlace,
shownHandles,
TARGET,
} from "../src/canvas/handles.js";
const SURFACE = { width: 1000, height: 800 };
const ALL = HANDLES.map(({ name }) => name);
const CORNERS = ["top-left", "top-right", "bottom-right", "bottom-left"];
describe("the handles shown", () => {
it("shows all eight on an item three targets wide and high", () => {
expect(shownHandles({ width: 3 * TARGET, height: 3 * TARGET })).toEqual(
ALL,
);
});
it("shows a side's middle only from three targets of its length", () => {
const narrow = shownHandles({
width: 3 * TARGET - 0.5,
height: 3 * TARGET,
});
expect(narrow).not.toContain("top");
expect(narrow).not.toContain("bottom");
expect(narrow).toEqual(
expect.arrayContaining(["left", "right", ...CORNERS]),
);
const low = shownHandles({ width: 3 * TARGET, height: 3 * TARGET - 0.5 });
expect(low).toEqual(expect.arrayContaining(["top", "bottom", ...CORNERS]));
expect(low).not.toContain("left");
});
it("shows the four corners from two targets, below that only the bottom right one, so that the middle still moves it", () => {
expect(shownHandles({ width: 2 * TARGET, height: 2 * TARGET })).toEqual([
"top-left",
"top-right",
"bottom-right",
"bottom-left",
]);
expect(shownHandles({ width: 2 * TARGET - 0.5, height: 400 })).toEqual([
"bottom-right",
]);
expect(shownHandles({ width: 400, height: 2 * TARGET - 0.5 })).toEqual([
"bottom-right",
]);
});
it("puts a target on its corner or edge, and on an item too small for that outside its corner, reaching in by 6, the dot on the corner", () => {
const big = { x: 100, y: 100, width: 2 * TARGET, height: 2 * TARGET };
expect(handlePlace("bottom-right", big)).toEqual({
centre: { x: 100 + 2 * TARGET, y: 100 + 2 * TARGET },
dot: { x: 0, y: 0 },
});
expect(handlePlace("top", { ...big, width: 3 * TARGET })).toEqual({
centre: { x: 100 + 1.5 * TARGET, y: 100 },
dot: { x: 0, y: 0 },
});
const small = { x: 100, y: 100, width: 20, height: 20 };
const { centre, dot } = handlePlace("bottom-right", small);
expect(centre.x - TARGET / 2).toBe(120 - 6);
expect(centre.y - TARGET / 2).toBe(120 - 6);
expect([centre.x + dot.x, centre.y + dot.y]).toEqual([120, 120]);
});
it("names each handle's edges as its place says", () => {
for (const { at, edges } of HANDLES) {
expect(edges).toEqual({
left: at.x === 0,
right: at.x === 1,
top: at.y === 0,
bottom: at.y === 1,
});
}
});
});
describe("the grip and the three-dot button", () => {
const frame = { x: 300, y: 300, width: 200, height: 100 };
it("stand above the top corners, the button right of a narrow text’s grip", () => {
const grip = gripPlace(frame, SURFACE);
expect(grip).toEqual({ x: 300, y: 300 - ABOVE });
expect(menuPlace(frame, SURFACE, grip)).toEqual({ x: 500, y: 300 - ABOVE });
expect(menuPlace(frame, SURFACE, null)).toEqual({ x: 500, y: 300 - ABOVE });
const narrow = { ...frame, width: 20 };
expect(menuPlace(narrow, SURFACE, gripPlace(narrow, SURFACE))).toEqual({
x: 300 + TARGET + 4,
y: 300 - ABOVE,
});
});
it("move aside where the surface ends above: the grip to the left, the button to the right, each exactly at the boundary", () => {
const room = TARGET / 2 + ABOVE;
expect(gripPlace({ ...frame, y: room }, SURFACE)).toEqual({
x: 300,
y: TARGET / 2,
});
expect(gripPlace({ ...frame, y: room - 1 }, SURFACE)).toEqual({
x: 300 - ABOVE,
y: room - 1,
});
expect(menuPlace({ ...frame, y: room }, SURFACE, null)).toEqual({
x: 500,
y: TARGET / 2,
});
expect(menuPlace({ ...frame, y: room - 1 }, SURFACE, null)).toEqual({
x: 500 + ABOVE,
y: room - 1,
});
});
it("go below where there is no room above nor beside", () => {
const cornered = { x: 30, y: 10, width: SURFACE.width - 60, height: 200 };
expect(gripPlace(cornered, SURFACE)).toEqual({
x: 30,
y: 10 + 200 + ABOVE,
});
expect(menuPlace(cornered, SURFACE, null)).toEqual({
x: SURFACE.width - 30,
y: 10 + 200 + ABOVE,
});
});
it("keep the button off the grip where both would go below", () => {
const cornered = { x: 30, y: 10, width: 30, height: 200 };
const grip = gripPlace(cornered, SURFACE);
const button = menuPlace(cornered, SURFACE, grip);
expect(
Math.abs(button.x - grip.x) >= TARGET ||
Math.abs(button.y - grip.y) >= TARGET,
).toBe(true);
});
it("go not below where the surface ends there too", () => {
const tall = {
x: 30,
y: 10,
width: 100,
height: 300 - 10 - ABOVE - TARGET / 2 + 1,
};
expect(gripPlace(tall, { width: 1000, height: 300 })).toEqual({
x: 30,
y: 10 - ABOVE,
});
expect(
gripPlace(
{ ...tall, height: tall.height - 1 },
{ width: 1000, height: 300 },
),
).toEqual({ x: 30, y: 300 - TARGET / 2 });
});
it("keep to the first place where nothing fits", () => {
const tiny = { width: 30, height: 30 };
expect(gripPlace(frame, tiny)).toEqual({ x: 300, y: 300 - ABOVE });
});
});