import { type PointerEvent as ReactPointerEvent, useRef } from 'react'
import { type BoxModel, Editor, useValue, VecLike } from 'tldraw'
import { regionDraft } from './state'
/** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
* handles, so the box itself takes no pointer events. Positioned in viewport space, as a sibling
* of the pins in the canvas layer. */
export function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
const rect = useValue(
'region rect',
() => {
// Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
const zoom = editor.getZoomLevel()
return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
},
[editor, box.x, box.y, box.w, box.h]
)
return
}
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
export function RegionDraftBox({ editor }: { editor: Editor }) {
const box = useValue('region draft', () => regionDraft.get(editor), [editor])
if (!box) return null
return
}
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
// controlled by that handle — the resize math reads the spot rather than special-casing corners.
export interface RegionHandle {
x: number
y: number
cursor: string
}
// The four corners, each resizing both axes.
export const REGION_CORNERS: readonly RegionHandle[] = [
{ x: 0, y: 0, cursor: 'nwse-resize' },
{ x: 1, y: 0, cursor: 'nesw-resize' },
{ x: 0, y: 1, cursor: 'nesw-resize' },
{ x: 1, y: 1, cursor: 'nwse-resize' },
]
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
// the handles (which sit on the edge) are comfortably reachable.
export const REGION_HANDLE_MARGIN_PX = 12
/** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
* handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
* handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
const controlsX = handle.x !== 0.5
const controlsY = handle.y !== 0.5
const fixedX = box.x + (1 - handle.x) * box.w
const fixedY = box.y + (1 - handle.y) * box.h
return {
x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
}
}
/** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
* option. Previews live, commits on release. */
export function RegionResizeHandles({
editor,
box,
handles,
onPreview,
onCommit,
}: {
editor: Editor
box: BoxModel
handles: readonly RegionHandle[]
onPreview(bounds: BoxModel | null): void
onCommit(bounds: BoxModel): void
}) {
// The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
// the fixed edges mid-drag.
const boxRef = useRef(null)
const points = useValue(
'region handle points',
() =>
handles.map((h) => {
const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
}),
[editor, box.x, box.y, box.w, box.h, handles]
)
const startResize = (e: ReactPointerEvent) => {
e.stopPropagation()
boxRef.current = box
e.currentTarget.setPointerCapture(e.pointerId)
}
const resizedTo = (h: RegionHandle, e: ReactPointerEvent): BoxModel =>
resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
const onResize = (h: RegionHandle) => (e: ReactPointerEvent) => {
if (boxRef.current) onPreview(resizedTo(h, e))
}
const endResize = (h: RegionHandle) => (e: ReactPointerEvent) => {
if (!boxRef.current) return
const bounds = resizedTo(h, e)
boxRef.current = null
if (e.currentTarget.hasPointerCapture(e.pointerId))
e.currentTarget.releasePointerCapture(e.pointerId)
onCommit(bounds)
}
return (
<>
{points.map((h) => (
))}
>
)
}