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:
@@ -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<void> };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<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). */
|
||||
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);
|
||||
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<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;
|
||||
const point = local(event.clientX, event.clientY);
|
||||
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);
|
||||
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<CanvasHandle, CanvasProps>(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<CanvasHandle, CanvasProps>(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<CanvasHandle, CanvasProps>(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<CanvasHandle, CanvasProps>(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<CanvasHandle, CanvasProps>(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 : (
|
||||
<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 : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -115,9 +115,11 @@
|
||||
"docu.exportHint": "Als Bild exportieren",
|
||||
"docu.fontSize": "Schriftgröße",
|
||||
"docu.fontStyle": "Schriftstil",
|
||||
"docu.move": "Verschieben",
|
||||
"docu.rectangle": "Gliederungsfläche",
|
||||
"docu.report": "Report",
|
||||
"docu.reportHint": "Report als PDF-Dokument erstellen",
|
||||
"docu.resize": "Größe ändern",
|
||||
"docu.saveFailed": "Fehler beim Speichern aufgetreten!",
|
||||
"docu.sidebar": "Dokumentation",
|
||||
"docu.sizeLarge": "Groß",
|
||||
|
||||
@@ -115,9 +115,11 @@
|
||||
"docu.exportHint": "Export as Image",
|
||||
"docu.fontSize": "Font Size",
|
||||
"docu.fontStyle": "Font Style",
|
||||
"docu.move": "Move",
|
||||
"docu.rectangle": "Structure Field",
|
||||
"docu.report": "Report",
|
||||
"docu.reportHint": "Create report as PDF document",
|
||||
"docu.resize": "Resize",
|
||||
"docu.saveFailed": "Error occurred!",
|
||||
"docu.sidebar": "Documentation",
|
||||
"docu.sizeLarge": "Large",
|
||||
|
||||
@@ -50,6 +50,45 @@
|
||||
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
|
||||
* covered, so the device stays in sight. */
|
||||
.editor-work {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user