From 8c4e5049f679e70ac8a223e9a54542625ee324cc Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 10 Oct 2026 22:04:40 +0000 Subject: [PATCH] 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 --- CHANGES.md | 1 + packages/editor/browser/documentation.spec.ts | 21 ++- packages/editor/browser/touch.spec.ts | 101 ++++++++++- packages/editor/src/canvas/Canvas.tsx | 98 +++++++++- packages/editor/src/canvas/handles.ts | 170 ++++++++++++++++++ packages/editor/src/i18n/de.json | 2 + packages/editor/src/i18n/en.json | 2 + packages/editor/src/sheet/sheet.css | 39 ++++ packages/editor/test/handles.test.ts | 166 +++++++++++++++++ 9 files changed, 588 insertions(+), 12 deletions(-) create mode 100644 packages/editor/src/canvas/handles.ts create mode 100644 packages/editor/test/handles.test.ts diff --git a/CHANGES.md b/CHANGES.md index 3fe2735..f23d069 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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/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/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 diff --git a/packages/editor/browser/documentation.spec.ts b/packages/editor/browser/documentation.spec.ts index 681e623..2702a83 100644 --- a/packages/editor/browser/documentation.spec.ts +++ b/packages/editor/browser/documentation.spec.ts @@ -3,7 +3,7 @@ // the sidebar's rectangle dragged onto the surface (its middle under the // 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 -// 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 // saved under the project's name, cut and painted as Java's, and Java's // message when saving fails; and a phone. @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs'; import { expect, test, type Page } from '@playwright/test'; 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 }; @@ -122,6 +122,23 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p }) .toEqual([18, true, false]); 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. await field.press('Escape'); diff --git a/packages/editor/browser/touch.spec.ts b/packages/editor/browser/touch.spec.ts index 4f9abf0..032c0b5 100644 --- a/packages/editor/browser/touch.spec.ts +++ b/packages/editor/browser/touch.spec.ts @@ -7,7 +7,7 @@ // target at least 44 px, nothing beyond the screen's edge. 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'; let server: { readonly base: string; readonly close: () => Promise }; @@ -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); }); +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 }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); 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)}`), ); 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 small(targets)).toEqual([]); // With a device chosen: its sheet and its three-dot button. diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx index d3614d7..42264ce 100644 --- a/packages/editor/src/canvas/Canvas.tsx +++ b/packages/editor/src/canvas/Canvas.tsx @@ -13,7 +13,14 @@ // 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 // 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 // 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 { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.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'; /** How far a pointer moves before a press becomes a drag. */ 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 } | { readonly kind: 'doc'; 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. */ @@ -254,6 +261,8 @@ type Gesture = readonly origin: Frame; /** 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; + /** Begun on a handle: a tap without a drag is one on what lies under it. */ + readonly handle?: boolean; moved: boolean; longPressed: boolean; }; @@ -398,7 +407,7 @@ export const Canvas = forwardRef(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). */ const draggedSomething = (now: Gesture | null) => now?.kind === 'device' || now?.kind === 'doc'; - const onPointerDown = (event: React.PointerEvent) => { + const onPointerDown = (event: React.PointerEvent) => { const point = local(event.clientX, event.clientY); 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 @@ -464,7 +473,33 @@ export const Canvas = forwardRef(function Canvas( } }; - const onPointerMove = (event: React.PointerEvent) => { + /** 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) => { + // 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) => { if (!pointers.current.has(event.pointerId)) return; const point = local(event.clientX, event.clientY); pointers.current.set(event.pointerId, point); @@ -522,7 +557,7 @@ export const Canvas = forwardRef(function Canvas( } }; - const onPointerUp = (event: React.PointerEvent) => { + 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); @@ -540,7 +575,11 @@ export const Canvas = forwardRef(function Canvas( if (now.kind === 'doc') { if (now.longPressed) return; 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; } if (now.kind === 'background') { @@ -600,6 +639,9 @@ export const Canvas = forwardRef(function Canvas( return () => element.removeEventListener('wheel', onWheel); }, [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). const menuAnchor = ((): Point | null => { if (selection?.kind === 'device') { @@ -608,7 +650,9 @@ export const Canvas = forwardRef(function Canvas( } if (selection?.kind === 'doc') { 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') { const link = view.links.find((each) => each.id === selection.id); @@ -617,7 +661,18 @@ export const Canvas = forwardRef(function Canvas( } 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 style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' }; @@ -708,6 +763,33 @@ export const Canvas = forwardRef(function Canvas( 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 }); })()} + {handled === null ? null : ( +
+ {HANDLES.filter(({ name }) => shownHandles(handled.frame).includes(name)).map(({ name, edges, cursor }) => { + const { centre, dot } = handlePlace(name, handled.frame); + return ( +
+ +
+ ); + })} + {grip === null ? null : ( +
+ +
+ )} +
+ )} {menuButton === null || selection === null || (simulating ? hasMenu?.(selection) !== true : locked) ? null : (