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
+90 -8
View File
@@ -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"
+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.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ß",
+2
View File
@@ -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",
+39
View File
@@ -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 {