Geometry
Everything here works in world units, the coordinates the elements are stored in. A Point is { x, y } and a Rect is { x, y, w, h }. Converting a pointer from screen pixels through the zoom and pan of the canvas is the app's job; no function reads the DOM.
import { useState, type PointerEvent } from 'react';
import { boundsOf, boundsOfElements, DEFAULT_SVG_MARGIN, elementAt, handlePoint, RESIZE_HANDLES, toSvg } from '@adecore/drawing';
import type { DrawingElement } from '@adecore/drawing/protocol';
import { THEME_EDGE, THEME_PALETTE, THEME_PAPER } from '../shared/canvas-theme.ts';
import { SKETCH } from '../shared/sketch.ts';
const SVG = toSvg(SKETCH, { palette: THEME_PALETTE, paper: THEME_PAPER, edge: THEME_EDGE });
// The same box toSvg gives its viewBox, so a pointer maps onto world units.
const bounds = boundsOfElements(SKETCH)!;
const VIEW = { x: bounds.x - DEFAULT_SVG_MARGIN, y: bounds.y - DEFAULT_SVG_MARGIN, w: bounds.w + DEFAULT_SVG_MARGIN * 2, h: bounds.h + DEFAULT_SVG_MARGIN * 2 };
export default function DrawingHitTestDemo() {
const [hovered, setHovered] = useState<DrawingElement | null>(null);
function onPointerMove(event: PointerEvent<HTMLDivElement>) {
const area = event.currentTarget.getBoundingClientRect();
const point = {
x: VIEW.x + ((event.clientX - area.left) / area.width) * VIEW.w,
y: VIEW.y + ((event.clientY - area.top) / area.height) * VIEW.h
};
setHovered(elementAt(SKETCH, point, 6) ?? null);
}
const box = hovered ? boundsOf(hovered) : null;
return (
<div className="flex w-full flex-col gap-3">
<div className="relative" onPointerMove={onPointerMove} onPointerLeave={() => setHovered(null)}>
<div className="[&_svg]:h-auto [&_svg]:w-full" dangerouslySetInnerHTML={{ __html: SVG }} />
{box && (
<svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox={`${VIEW.x} ${VIEW.y} ${VIEW.w} ${VIEW.h}`}>
<rect x={box.x} y={box.y} width={box.w} height={box.h} fill="none" stroke="var(--accent)" strokeDasharray="4 4" />
{RESIZE_HANDLES.map((handle) => {
const at = handlePoint(box, handle);
return <rect key={handle} x={at.x - 4} y={at.y - 4} width={8} height={8} fill="var(--surface)" stroke="var(--accent)" />;
})}
</svg>
)}
</div>
<p className="font-mono text-code text-text-muted">elementAt: {hovered ? `${hovered.kind} "${hovered.id}"` : 'undefined'}</p>
</div>
);
}The demo maps the pointer onto the viewBox of the exported SVG, asks elementAt what is under it, and draws the boundsOf that element with a handle at every handlePoint. The inside of the ellipse is not a hit, because the ellipse has no fill.
Bounds
boundsOf(element)is the stored box of one element.boundsOfElements(elements)is the box around all of them, ornullfor none.unionOf(rects)is the box around any rects, also when one has a negative width or height, ornullfor an empty list.centerOf(rect)is its middle.roundPoint(point)rounds both coordinates to whole units.rectFromPoints(from, to)is the box of a drag in either direction, always with a positive size.intersects(a, b)is true when two boxes overlap. Boxes that only touch do not.distanceToSegment(point, a, b)is the distance from a point to the segment betweenaandb.
A box is the stored x, y, w and h. It leaves out the rotation, the wobble of a rough shape, the stroke width, arrow heads and text that runs past a note. Add room for those yourself when you fit a view to its content.
Rotation
angle turns an element around the center of its box, in radians. rotatePoint(point, around, angle) turns a point. toLocal(element, point) turns a point back by the element's angle, so a test can treat the element as unturned; the result is still in world units. absolutePoints(element) is the points of a line or freehand stroke in world units, without the rotation, and [] for any other kind.
Hit tests
import { elementAt, elementsIn, rectFromPoints } from '@adecore/drawing';
const hit = elementAt(elements, pointer, 6 / zoom);
const picked = elementsIn(elements, rectFromPoints(dragStart, pointer));hitsElement(element, point, tolerance) decides one element, after undoing its rotation:
| Kind | A hit |
|---|---|
rect, diamond, ellipse | Anywhere inside when it has a fill, else near the outline |
text, note | Anywhere inside the box; the tolerance does not widen it |
line, freehand | Near the polyline through its points, or near the only point of a stroke |
"Near" is within tolerance or the stroke width, whichever is larger. The test uses the ideal outline: the corner radius of a rect and the wobble of a rough path do not change it.
elementAt(elements, point, tolerance) returns the topmost element that is hit, or undefined. elementsIn(elements, rect) returns every element whose box overlaps the rect, in stacking order. Both skip locked elements; hitsElement does not. A lock only keeps an element out of selection, so check it again where the app writes.
Resizing
RESIZE_HANDLES lists the eight handles of a box (ResizeHandle), clockwise from nw, and handlePoint(rect, handle) places one. While a handle is dragged, resizeRect(rect, handle, pointer, aspect) returns the new box: the handle follows the pointer and the opposite side stays put. With aspect set, as Shift does in most editors, the box keeps its proportions and a corner follows the smaller pull.
resizeRect does not clamp. A handle dragged past the opposite side gives a negative width or height, which the app normalizes or refuses before it stores the box.
import { boundsOf, resizeRect, scaleElement } from '@adecore/drawing';
const from = boundsOf(element);
const to = resizeRect(from, 'se', pointer, event.shiftKey);
const resized = scaleElement(element, from, to);scaleElement(element, from, to) moves and scales an element from one box to another; for a selection of several, pass the box around all of them as from. It scales the points of a line or stroke and keeps the pressure of a stroke. A text gets sized: true, so from then on it wraps to its box instead of growing with its text. The angle, seed, stroke width, font size and corner radius stay as they were. A box with no width or height scales by 1 along that axis. Both functions work on the unturned box; turn the pointer into the element's frame first for a rotated element.
Arrow heads
arrowHead(tip, from, size) returns the two strokes of an arrow head at tip, pointing away from from. arrowHeadSize(strokeWidth) is the size the package draws for a stroke width: 12 + strokeWidth * 4.