diff --git a/packages/react-grab/e2e/freeform-drawing.spec.ts b/packages/react-grab/e2e/freeform-drawing.spec.ts new file mode 100644 index 000000000..0f5dfa0ea --- /dev/null +++ b/packages/react-grab/e2e/freeform-drawing.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from "./fixtures.js"; + +const FREEFORM_IDLE_TIMEOUT_MS = 600; +const GESTURE_SETTLE_BUFFER_MS = 300; + +test.describe("Freeform Drawing", () => { + test("should enter comment mode via arrow gesture", async ({ reactGrab }) => { + await reactGrab.activate(); + + const listItem = reactGrab.page.locator("li").first(); + const box = await listItem.boundingBox(); + if (!box) throw new Error("Could not get bounding box"); + + const targetX = box.x + box.width / 2; + const targetY = box.y + box.height / 2; + + await reactGrab.page.keyboard.down("Alt"); + await reactGrab.page.mouse.move(targetX - 150, targetY - 80); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(targetX, targetY, { steps: 20 }); + await reactGrab.page.mouse.up(); + await reactGrab.page.keyboard.up("Alt"); + + await expect + .poll(() => reactGrab.isPromptModeActive(), { + timeout: FREEFORM_IDLE_TIMEOUT_MS + GESTURE_SETTLE_BUFFER_MS + 2000, + }) + .toBe(true); + }); + + test("should enter comment mode via circle gesture", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + + const firstItem = reactGrab.page.locator("li").first(); + const thirdItem = reactGrab.page.locator("li").nth(2); + const firstBox = await firstItem.boundingBox(); + const thirdBox = await thirdItem.boundingBox(); + if (!firstBox || !thirdBox) + throw new Error("Could not get bounding boxes"); + + const centerX = (firstBox.x + thirdBox.x + thirdBox.width) / 2; + const centerY = (firstBox.y + thirdBox.y + thirdBox.height) / 2; + const radiusX = (thirdBox.x + thirdBox.width - firstBox.x) / 2 + 30; + const radiusY = (thirdBox.y + thirdBox.height - firstBox.y) / 2 + 30; + + const strokeSteps = 40; + const totalAngle = Math.PI * 1.8; + + await reactGrab.page.keyboard.down("Alt"); + + const startX = centerX + radiusX; + const startY = centerY; + await reactGrab.page.mouse.move(startX, startY); + await reactGrab.page.mouse.down(); + + for (let stepIndex = 1; stepIndex <= strokeSteps; stepIndex++) { + const angle = (stepIndex / strokeSteps) * totalAngle; + await reactGrab.page.mouse.move( + centerX + radiusX * Math.cos(angle), + centerY + radiusY * Math.sin(angle), + ); + } + + await reactGrab.page.mouse.up(); + await reactGrab.page.keyboard.up("Alt"); + + await expect + .poll(() => reactGrab.isPromptModeActive(), { + timeout: FREEFORM_IDLE_TIMEOUT_MS + GESTURE_SETTLE_BUFFER_MS + 2000, + }) + .toBe(true); + }); + + test("should hide crosshair during freeform session", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + + await reactGrab.page.mouse.move(400, 300); + await reactGrab.page.waitForTimeout(100); + + const crosshairBefore = await reactGrab.isCrosshairVisible(); + expect(crosshairBefore).toBe(true); + + await reactGrab.page.keyboard.down("Alt"); + await reactGrab.page.mouse.move(200, 200); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(300, 300, { steps: 10 }); + + const crosshairDuring = await reactGrab.isCrosshairVisible(); + expect(crosshairDuring).toBe(false); + + await reactGrab.page.mouse.up(); + await reactGrab.page.keyboard.up("Alt"); + }); + + test("should not start freeform when overlay is inactive", async ({ + reactGrab, + }) => { + await reactGrab.page.keyboard.down("Alt"); + await reactGrab.page.mouse.move(300, 300); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(400, 400, { steps: 10 }); + await reactGrab.page.mouse.up(); + await reactGrab.page.keyboard.up("Alt"); + + await reactGrab.page.waitForTimeout( + FREEFORM_IDLE_TIMEOUT_MS + GESTURE_SETTLE_BUFFER_MS, + ); + + const state = await reactGrab.getState(); + expect(state.isActive).toBe(false); + }); + + test("should keep overlay active in comment mode after freeform gesture", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + + const listItem = reactGrab.page.locator("li").first(); + const box = await listItem.boundingBox(); + if (!box) throw new Error("Could not get bounding box"); + + const targetX = box.x + box.width / 2; + const targetY = box.y + box.height / 2; + + await reactGrab.page.keyboard.down("Alt"); + await reactGrab.page.mouse.move(targetX - 150, targetY - 80); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(targetX, targetY, { steps: 20 }); + await reactGrab.page.mouse.up(); + await reactGrab.page.keyboard.up("Alt"); + + await expect + .poll(() => reactGrab.isPromptModeActive(), { + timeout: FREEFORM_IDLE_TIMEOUT_MS + GESTURE_SETTLE_BUFFER_MS + 2000, + }) + .toBe(true); + + const isActive = await reactGrab.isOverlayVisible(); + expect(isActive).toBe(true); + }); + + test("should discard gesture with insufficient points", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + + await reactGrab.page.keyboard.down("Alt"); + await reactGrab.page.mouse.move(300, 300); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(305, 305); + await reactGrab.page.mouse.up(); + await reactGrab.page.keyboard.up("Alt"); + + await reactGrab.page.waitForTimeout( + FREEFORM_IDLE_TIMEOUT_MS + GESTURE_SETTLE_BUFFER_MS, + ); + + const isActive = await reactGrab.isOverlayVisible(); + expect(isActive).toBe(true); + + const isPromptMode = await reactGrab.isPromptModeActive(); + expect(isPromptMode).toBe(false); + }); +}); diff --git a/packages/react-grab/src/components/icons/icon-draw.tsx b/packages/react-grab/src/components/icons/icon-draw.tsx new file mode 100644 index 000000000..d80694f92 --- /dev/null +++ b/packages/react-grab/src/components/icons/icon-draw.tsx @@ -0,0 +1,28 @@ +import type { Component } from "solid-js"; + +interface IconDrawProps { + size?: number; + class?: string; +} + +export const IconDraw: Component = (props) => { + const size = () => props.size ?? 14; + + return ( + + + + + + ); +}; diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index 0ec7b98c3..03994ae59 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -4,6 +4,7 @@ import type { OverlayBounds, SelectionLabelInstance, AgentSession, + StrokePoint, } from "../types.js"; import { lerp } from "../utils/lerp.js"; import { @@ -19,6 +20,9 @@ import { OVERLAY_FILL_COLOR_DRAG, OVERLAY_BORDER_COLOR_DEFAULT, OVERLAY_FILL_COLOR_DEFAULT, + FREEFORM_STROKE_COLOR, + FREEFORM_STROKE_MIN_WIDTH_PX, + FREEFORM_STROKE_MAX_WIDTH_PX, } from "../constants.js"; const LAYER_STYLES = { @@ -44,7 +48,13 @@ const LAYER_STYLES = { }, } as const; -type LayerName = "crosshair" | "drag" | "selection" | "grabbed" | "processing"; +type LayerName = + | "crosshair" + | "drag" + | "selection" + | "grabbed" + | "processing" + | "freeform"; interface OffscreenLayer { canvas: OffscreenCanvas | null; @@ -90,6 +100,10 @@ export interface OverlayCanvasProps { agentSessions?: Map; labelInstances?: SelectionLabelInstance[]; + + freeformStrokePoints?: StrokePoint[][]; + freeformStrokeVisible?: boolean; + freeformStrokeCompletedAt?: number | null; } export const OverlayCanvas: Component = (props) => { @@ -106,6 +120,7 @@ export const OverlayCanvas: Component = (props) => { selection: { canvas: null, context: null }, grabbed: { canvas: null, context: null }, processing: { canvas: null, context: null }, + freeform: { canvas: null, context: null }, }; const crosshairCurrentPosition: Position = { x: 0, y: 0 }; @@ -114,6 +129,8 @@ export const OverlayCanvas: Component = (props) => { let dragAnimation: AnimatedBounds | null = null; let grabbedAnimations: AnimatedBounds[] = []; let processingAnimations: AnimatedBounds[] = []; + let freeformFadeOpacity = 1; + let freeformCompletedAtTimestamp: number | null = null; const createOffscreenLayer = ( layerWidth: number, @@ -366,6 +383,46 @@ export const OverlayCanvas: Component = (props) => { } }; + const renderFreeformLayer = () => { + const layer = layers.freeform; + if (!layer.context) return; + + const context = layer.context; + context.clearRect(0, 0, canvasWidth, canvasHeight); + + const strokes = props.freeformStrokePoints; + if (!strokes || strokes.length === 0) return; + if (freeformFadeOpacity <= 0) return; + + context.lineCap = "round"; + context.lineJoin = "round"; + context.globalAlpha = freeformFadeOpacity; + context.strokeStyle = FREEFORM_STROKE_COLOR; + + for (const stroke of strokes) { + if (stroke.length < 2) continue; + + let pressureSum = 0; + for (const point of stroke) { + pressureSum += point.pressure; + } + const averagePressure = pressureSum / stroke.length; + context.lineWidth = + FREEFORM_STROKE_MIN_WIDTH_PX + + averagePressure * + (FREEFORM_STROKE_MAX_WIDTH_PX - FREEFORM_STROKE_MIN_WIDTH_PX); + + context.beginPath(); + context.moveTo(stroke[0].x, stroke[0].y); + for (let pointIndex = 1; pointIndex < stroke.length; pointIndex++) { + context.lineTo(stroke[pointIndex].x, stroke[pointIndex].y); + } + context.stroke(); + } + + context.globalAlpha = 1; + }; + const compositeAllLayers = () => { if (!mainContext || !canvasRef) return; @@ -375,6 +432,7 @@ export const OverlayCanvas: Component = (props) => { renderCrosshairLayer(); renderDragLayer(); + renderFreeformLayer(); renderSelectionLayer(); renderGrabbedLayer(); renderProcessingLayer(); @@ -382,6 +440,7 @@ export const OverlayCanvas: Component = (props) => { const layerRenderOrder: LayerName[] = [ "crosshair", "drag", + "freeform", "selection", "grabbed", "processing", @@ -517,6 +576,28 @@ export const OverlayCanvas: Component = (props) => { } } + if (freeformCompletedAtTimestamp !== null) { + const freeformElapsed = Date.now() - freeformCompletedAtTimestamp; + const freeformFadeDeadline = FEEDBACK_DURATION_MS + FADE_OUT_BUFFER_MS; + + if (freeformElapsed >= freeformFadeDeadline) { + freeformFadeOpacity = 0; + } else if (freeformElapsed > FEEDBACK_DURATION_MS) { + freeformFadeOpacity = + 1 - (freeformElapsed - FEEDBACK_DURATION_MS) / FADE_OUT_BUFFER_MS; + shouldContinueAnimating = true; + } else { + freeformFadeOpacity = 1; + shouldContinueAnimating = true; + } + } else if ( + props.freeformStrokeVisible && + props.freeformStrokePoints && + props.freeformStrokePoints.length > 0 + ) { + shouldContinueAnimating = true; + } + compositeAllLayers(); if (shouldContinueAnimating) { @@ -755,6 +836,26 @@ export const OverlayCanvas: Component = (props) => { ), ); + createEffect( + on( + () => + [ + props.freeformStrokePoints, + props.freeformStrokeVisible, + props.freeformStrokeCompletedAt, + ] as const, + ([, , completedAt]) => { + if (completedAt != null) { + freeformCompletedAtTimestamp = completedAt; + } else { + freeformCompletedAtTimestamp = null; + freeformFadeOpacity = 1; + } + scheduleAnimationFrame(); + }, + ), + ); + onMount(() => { initializeCanvas(); scheduleAnimationFrame(); diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 8d8bead2f..c01a94724 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -32,6 +32,9 @@ export const ReactGrabRenderer: Component = (props) => { grabbedBoxes={props.grabbedBoxes} agentSessions={props.agentSessions} labelInstances={props.labelInstances} + freeformStrokePoints={props.freeformStrokePoints} + freeformStrokeVisible={props.freeformStrokeVisible} + freeformStrokeCompletedAt={props.freeformStrokeCompletedAt} />
= (props) => { toolbarActions={props.toolbarActions} onToggleMenu={props.onToggleMenu} isMenuOpen={Boolean(props.toolbarMenuPosition)} + isDrawMode={props.isDrawMode} + onToggleDraw={props.onToggleDraw} /> diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 42285da5f..588095ecc 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -19,6 +19,7 @@ import { IconSelect } from "../icons/icon-select.jsx"; import { IconChevron } from "../icons/icon-chevron.jsx"; import { IconInbox, IconInboxUnread } from "../icons/icon-inbox.jsx"; import { IconEllipsis } from "../icons/icon-ellipsis.jsx"; +import { IconDraw } from "../icons/icon-draw.jsx"; import { TOOLBAR_SNAP_MARGIN_PX, TOOLBAR_FADE_IN_DELAY_MS, @@ -73,6 +74,8 @@ interface ToolbarProps { toolbarActions?: ToolbarMenuAction[]; onToggleMenu?: () => void; isMenuOpen?: boolean; + isDrawMode?: boolean; + onToggleDraw?: () => void; } interface FreezeHandlersOptions { @@ -116,6 +119,7 @@ export const Toolbar: Component = (props) => { const [isHistoryTooltipVisible, setIsHistoryTooltipVisible] = createSignal(false); const [isMenuTooltipVisible, setIsMenuTooltipVisible] = createSignal(false); + const [isDrawTooltipVisible, setIsDrawTooltipVisible] = createSignal(false); const hasToolbarActions = () => (props.toolbarActions ?? []).length > 0; @@ -603,6 +607,8 @@ export const Toolbar: Component = (props) => { const handleToggleMenu = createDragAwareHandler(() => props.onToggleMenu?.()); + const handleToggleDraw = createDragAwareHandler(() => props.onToggleDraw?.()); + const handleToggleCollapse = createDragAwareHandler(() => { const rect = containerRef?.getBoundingClientRect(); const wasCollapsed = isCollapsed(); @@ -1500,6 +1506,51 @@ export const Toolbar: Component = (props) => {
+
+
+ + + Draw to select + +
+
{ x: number; y: number; } | null>(null); + + const [isFreeformDrawing, setIsFreeformDrawing] = createSignal(false); + const [isFreeformSessionActive, setIsFreeformSessionActive] = + createSignal(false); + const [isDrawMode, setIsDrawMode] = createSignal(false); + const [freeformStrokes, setFreeformStrokes] = createSignal( + [], + ); + const [freeformStrokeCompletedAt, setFreeformStrokeCompletedAt] = + createSignal(null); + let freeformCleanupTimerId: ReturnType | null = null; + let freeformIdleTimerId: ReturnType | null = null; + + const createStrokePoint = ( + clientX: number, + clientY: number, + pressure: number, + ): StrokePoint => ({ + x: clientX, + y: clientY, + pressure: pressure || FREEFORM_STROKE_DEFAULT_PRESSURE, + }); + const scheduleDragPreviewUpdate = (clientX: number, clientY: number) => { if (dragPreviewDebounceTimerId !== null) { clearTimeout(dragPreviewDebounceTimerId); @@ -524,6 +557,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { pluginRegistry.store.theme.crosshair.enabled && isRendererActive() && !isDragging() && + !isFreeformSessionActive() && !store.isTouchMode && !isToggleFrozen() && !isPromptMode() && @@ -1491,6 +1525,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { arrowNavigator.clearHistory(); keyboardSelectedElement = null; isPendingContextMenuSelect = false; + setIsDrawMode(false); if (wasDragging) { document.body.style.userSelect = ""; } @@ -1708,6 +1743,23 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } }; + const handleToggleDraw = () => { + if (!isEnabled()) return; + + if (isDrawMode()) { + setIsDrawMode(false); + if (isActivated()) { + deactivateRenderer(); + } + return; + } + + setIsDrawMode(true); + if (!isActivated()) { + toggleActivate(); + } + }; + const enterCommentModeForElement = ( element: Element, positionX: number, @@ -1955,6 +2007,175 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { document.body.style.userSelect = ""; }; + const clearFreeformStrokes = () => { + setFreeformStrokes([]); + setFreeformStrokeCompletedAt(null); + setIsFreeformSessionActive(false); + }; + + const scheduleFreeformCleanup = () => { + const cleanupDelay = + FEEDBACK_DURATION_MS + + FADE_COMPLETE_BUFFER_MS + + FREEFORM_CLEANUP_GRACE_PERIOD_MS; + freeformCleanupTimerId = setTimeout(() => { + clearFreeformStrokes(); + freeformCleanupTimerId = null; + }, cleanupDelay); + }; + + const startFreeformDraw = ( + clientX: number, + clientY: number, + pressure: number, + ) => { + if (freeformIdleTimerId !== null) { + clearTimeout(freeformIdleTimerId); + freeformIdleTimerId = null; + } + if (freeformCleanupTimerId !== null) { + clearTimeout(freeformCleanupTimerId); + freeformCleanupTimerId = null; + } + + setIsFreeformDrawing(true); + setIsFreeformSessionActive(true); + setFreeformStrokeCompletedAt(null); + + const point = createStrokePoint(clientX, clientY, pressure); + setFreeformStrokes((previousStrokes) => [...previousStrokes, [point]]); + document.body.style.userSelect = "none"; + }; + + const addFreeformPoint = ( + clientX: number, + clientY: number, + pressure: number, + ) => { + const point = createStrokePoint(clientX, clientY, pressure); + setFreeformStrokes((previousStrokes) => { + if (previousStrokes.length === 0) return [[point]]; + const updated = [...previousStrokes]; + const lastStrokeIndex = updated.length - 1; + updated[lastStrokeIndex] = [...updated[lastStrokeIndex], point]; + return updated; + }); + }; + + const handleFreeformCircle = (allPoints: StrokePoint[]) => { + const boundingRect = computeStrokeBoundingRect(allPoints); + const elements = getElementsInDrag(boundingRect, isValidGrabbableElement); + const selectedElements = + elements.length > 0 + ? elements + : getElementsInDrag(boundingRect, isValidGrabbableElement, false); + + if (selectedElements.length === 0) { + clearFreeformStrokes(); + return; + } + + freezeAllAnimations(selectedElements); + + pluginRegistry.hooks.onDragEnd(selectedElements, boundingRect); + const firstElement = selectedElements[0]; + const center = getBoundsCenter(createElementBounds(firstElement)); + + batch(() => { + actions.setPointer(center); + actions.setFrozenElements(selectedElements); + const pageRect = createPageRectFromBounds(boundingRect); + actions.setFrozenDragRect(pageRect); + actions.freeze(); + actions.setLastGrabbed(firstElement); + + setFreeformStrokeCompletedAt(Date.now()); + setIsFreeformSessionActive(false); + setIsDrawMode(false); + + enterCommentModeForElement(firstElement, center.x, center.y); + }); + + scheduleFreeformCleanup(); + }; + + const handleFreeformArrow = (allPoints: StrokePoint[]) => { + const endpoint = allPoints[allPoints.length - 1]; + + const element = getElementAtPosition(endpoint.x, endpoint.y); + if (!element || !isValidGrabbableElement(element)) { + clearFreeformStrokes(); + return; + } + + freezeAllAnimations([element]); + + const center = getBoundsCenter(createElementBounds(element)); + + batch(() => { + actions.setPointer(center); + actions.setFrozenElement(element); + actions.freeze(); + actions.setLastGrabbed(element); + + setFreeformStrokeCompletedAt(Date.now()); + setIsFreeformSessionActive(false); + setIsDrawMode(false); + + enterCommentModeForElement(element, center.x, center.y); + }); + + scheduleFreeformCleanup(); + }; + + const finalizeFreeformSession = () => { + if (freeformIdleTimerId !== null) { + clearTimeout(freeformIdleTimerId); + freeformIdleTimerId = null; + } + + const strokes = freeformStrokes(); + const allPoints = strokes.flat(); + + if (allPoints.length < FREEFORM_MIN_POINTS_FOR_GESTURE) { + clearFreeformStrokes(); + return; + } + + const gesture = classifyStrokeGesture( + allPoints, + FREEFORM_CIRCLE_MIN_ANGULAR_SWEEP_RAD, + ); + + if (gesture === "circle") { + handleFreeformCircle(allPoints); + } else { + handleFreeformArrow(allPoints); + } + }; + + const endFreeformStroke = () => { + setIsFreeformDrawing(false); + document.body.style.userSelect = ""; + + freeformIdleTimerId = setTimeout(() => { + freeformIdleTimerId = null; + finalizeFreeformSession(); + }, FREEFORM_IDLE_TIMEOUT_MS); + }; + + const cancelFreeformDraw = () => { + if (isFreeformDrawing()) { + setIsFreeformDrawing(false); + document.body.style.userSelect = ""; + } + if (freeformIdleTimerId !== null) { + clearTimeout(freeformIdleTimerId); + freeformIdleTimerId = null; + } + clearFreeformStrokes(); + }; + const handlePointerUp = ( clientX: number, clientY: number, @@ -2740,6 +2961,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setTouchMode(isTouchPointer); if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; if (store.contextMenuPosition !== null) return; + + if (isFreeformDrawing()) { + addFreeformPoint(event.clientX, event.clientY, event.pressure); + return; + } + + if (isFreeformSessionActive()) return; + if (isTouchPointer && !isHoldingKeys() && !isActivated()) return; const isActiveState = isTouchPointer ? isHoldingKeys() : isActivated(); if (isActiveState && !isPromptMode() && isToggleFrozen()) { @@ -2778,6 +3007,25 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return; } + const shouldStartFreeformStroke = + (event.altKey || isDrawMode()) && + (freeformIdleTimerId !== null || + (isRendererActive() && !isCopying())); + + if (shouldStartFreeformStroke) { + startFreeformDraw(event.clientX, event.clientY, event.pressure); + document.documentElement.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + + if (freeformIdleTimerId !== null) { + finalizeFreeformSession(); + return; + } + const didHandle = handlePointerDown(event.clientX, event.clientY); if (didHandle) { document.documentElement.setPointerCapture(event.pointerId); @@ -2796,6 +3044,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!event.isPrimary) return; if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; if (store.contextMenuPosition !== null) return; + + if (isFreeformDrawing()) { + endFreeformStroke(); + return; + } + const hasModifierKeyHeld = event.metaKey || event.ctrlKey; handlePointerUp(event.clientX, event.clientY, hasModifierKeyHeld); }, @@ -2842,6 +3096,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { "pointercancel", (event: PointerEvent) => { if (!event.isPrimary) return; + cancelFreeformDraw(); cancelActiveDrag(); }, ); @@ -2857,7 +3112,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { event.stopPropagation(); event.stopImmediatePropagation(); - if (store.wasActivatedByToggle && !isCopying() && !isPromptMode()) { + if (store.wasActivatedByToggle && !isCopying() && !isPromptMode() && !isFreeformSessionActive()) { if (!isHoldingKeys()) { deactivateRenderer(); } else { @@ -3067,6 +3322,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const selectionVisible = createMemo(() => { if (!isThemeEnabled()) return false; if (!isSelectionBoxThemeEnabled()) return false; + if (isFreeformSessionActive()) return false; if (isSelectionSuppressed()) return false; if (hasDragPreviewBounds()) return true; return isSelectionElementVisible(); @@ -3105,6 +3361,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const selectionLabelVisible = createMemo(() => { if (store.contextMenuPosition !== null) return false; if (!isElementLabelThemeEnabled()) return false; + if (isFreeformSessionActive()) return false; if (isSelectionSuppressed()) return false; return isSelectionElementVisible(); @@ -3191,6 +3448,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const labelVisible = createMemo(() => { if (!isThemeEnabled()) return false; + if (isFreeformSessionActive()) return false; const themeEnabled = isElementLabelThemeEnabled(); const inPromptMode = isPromptMode(); const copying = isCopying(); @@ -3947,6 +4205,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { labelInstances={computedLabelInstances()} dragVisible={dragVisible()} dragBounds={dragBounds()} + freeformStrokePoints={freeformStrokes()} + freeformStrokeVisible={ + isFreeformDrawing() || freeformStrokes().length > 0 + } + freeformStrokeCompletedAt={freeformStrokeCompletedAt()} grabbedBoxes={computedGrabbedBoxes()} labelZIndex={Z_INDEX_LABEL} mouseX={ @@ -3991,6 +4254,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { onToggleActive={handleToggleActive} enabled={isEnabled()} onToggleEnabled={handleToggleEnabled} + isDrawMode={isDrawMode()} + onToggleDraw={handleToggleDraw} shakeCount={toolbarShakeCount()} onToolbarStateChange={(state) => { setCurrentToolbarState(state); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 9d36ed291..7346936c1 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -501,6 +501,9 @@ export interface ReactGrabRendererProps { labelInstances?: SelectionLabelInstance[]; dragVisible?: boolean; dragBounds?: OverlayBounds; + freeformStrokePoints?: StrokePoint[][]; + freeformStrokeVisible?: boolean; + freeformStrokeCompletedAt?: number | null; grabbedBoxes?: Array<{ id: string; bounds: OverlayBounds; @@ -542,6 +545,8 @@ export interface ReactGrabRendererProps { toolbarVisible?: boolean; isActive?: boolean; onToggleActive?: () => void; + isDrawMode?: boolean; + onToggleDraw?: () => void; enabled?: boolean; onToggleEnabled?: () => void; shakeCount?: number; @@ -604,6 +609,12 @@ export interface DragRect { height: number; } +export interface StrokePoint { + x: number; + y: number; + pressure: number; +} + export type ArrowPosition = "bottom" | "top"; export interface ArrowProps { diff --git a/packages/react-grab/src/utils/classify-stroke-gesture.ts b/packages/react-grab/src/utils/classify-stroke-gesture.ts new file mode 100644 index 000000000..6b6f878c6 --- /dev/null +++ b/packages/react-grab/src/utils/classify-stroke-gesture.ts @@ -0,0 +1,75 @@ +import type { StrokePoint, DragRect } from "../types.js"; + +export type StrokeGesture = "circle" | "arrow"; + +const computeAngularSweep = (points: StrokePoint[]): number => { + if (points.length < 3) return 0; + + let centroidX = 0; + let centroidY = 0; + for (const point of points) { + centroidX += point.x; + centroidY += point.y; + } + centroidX /= points.length; + centroidY /= points.length; + + let totalAngleChange = 0; + let previousAngle = Math.atan2( + points[0].y - centroidY, + points[0].x - centroidX, + ); + + for (let pointIndex = 1; pointIndex < points.length; pointIndex++) { + const currentAngle = Math.atan2( + points[pointIndex].y - centroidY, + points[pointIndex].x - centroidX, + ); + let angleDelta = currentAngle - previousAngle; + + while (angleDelta > Math.PI) angleDelta -= 2 * Math.PI; + while (angleDelta < -Math.PI) angleDelta += 2 * Math.PI; + + totalAngleChange += angleDelta; + previousAngle = currentAngle; + } + + return Math.abs(totalAngleChange); +}; + +export const classifyStrokeGesture = ( + points: StrokePoint[], + minAngularSweepRad: number, +): StrokeGesture => { + if (points.length < 5) return "arrow"; + + const angularSweep = computeAngularSweep(points); + if (angularSweep >= minAngularSweepRad) { + return "circle"; + } + + return "arrow"; +}; + +export const computeStrokeBoundingRect = ( + points: StrokePoint[], +): DragRect => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const point of points) { + if (point.x < minX) minX = point.x; + if (point.y < minY) minY = point.y; + if (point.x > maxX) maxX = point.x; + if (point.y > maxY) maxY = point.y; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +};