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
+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 });
});
});