From c24ba58f7806d0506edaea48b1326e92f97747df Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Mon, 24 Aug 2026 01:05:21 -0400 Subject: [PATCH] feat: geometric shapes on the Atlas board (goal 0169 slice 5) Rectangle, ellipse, and arrow join image/ink as a fourth board-local BoardObject kind ('shape'), drawn via the existing drag-to-draw interaction the pencil tool already proved. Fill/stroke/strokeWidth live directly in Payload as editable data (never baked into a mirror file the way ink's pixels are), rendering live from Payload + the generic Size field -- the first real use of SetBoardObjectSize outside a manual resize. Zero Go files touched: CreateBoardObject/ SetBoardObjectSize/PromoteBoardObject/DeleteBoardObject were already Kind-agnostic since goal 0179 S1. Fixes a structural bug in AtlasCreationTray.tsx surfaced by adding a second 'drag-to-draw' tool: a single shared anchor ref reused across every mapped drag-to-draw button would have silently mispositioned one tool's own options popover. Replaced with a per-tool-id ref map, and StylePicker resolution is now registry-driven (each tool names its own options-bar component) rather than a hardcoded branch. Shape's own armed state, style store, placement door, and drag hook are instantiated inside useAtlasDragTools.ts (following Eraser/Laser's own precedent, not Area/Pencil's, since Shape is introduced at this split) to keep AtlasBoard.tsx under the 500-line cap. Tray grows from 538px (8 tools) to 604px with the ninth icon-only button -- still narrower than the original six-button labeled tray (628px); the eraser/laser slice's pointer-events structural fix holds (atlas-page.spec.ts passes unmodified). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/atlas-shape-tool.spec.ts | 150 ++++++++++++++++++ frontend/src/atlas/AtlasBoard.tsx | 17 +- frontend/src/atlas/AtlasBoardObjectNode.tsx | 34 +++- frontend/src/atlas/AtlasCreationTray.tsx | 84 ++++++---- frontend/src/atlas/AtlasShapeContent.tsx | 51 ++++++ frontend/src/atlas/AtlasShapeLivePreview.tsx | 37 +++++ .../atlas/AtlasShapeStylePicker.module.css | 68 ++++++++ frontend/src/atlas/AtlasShapeStylePicker.tsx | 75 +++++++++ .../src/atlas/atlasBuildBoardObjectNodes.ts | 13 +- frontend/src/atlas/atlasShapeStyleStore.ts | 38 +++++ frontend/src/atlas/atlasShapeSvg.test.ts | 52 ++++++ frontend/src/atlas/atlasShapeSvg.ts | 51 ++++++ frontend/src/atlas/atlasTools.test.ts | 48 +++++- frontend/src/atlas/atlasTools.ts | 62 +++++++- frontend/src/atlas/useAtlasCreation.ts | 16 +- frontend/src/atlas/useAtlasDragTools.ts | 32 +++- frontend/src/atlas/useAtlasShapeCreate.ts | 41 +++++ frontend/src/atlas/useAtlasShapeDraw.ts | 90 +++++++++++ frontend/src/locales/en/atlas.json | 14 +- frontend/src/shared/atlasToolIdentity.ts | 4 +- 20 files changed, 909 insertions(+), 68 deletions(-) create mode 100644 frontend/e2e/atlas-shape-tool.spec.ts create mode 100644 frontend/src/atlas/AtlasShapeContent.tsx create mode 100644 frontend/src/atlas/AtlasShapeLivePreview.tsx create mode 100644 frontend/src/atlas/AtlasShapeStylePicker.module.css create mode 100644 frontend/src/atlas/AtlasShapeStylePicker.tsx create mode 100644 frontend/src/atlas/atlasShapeStyleStore.ts create mode 100644 frontend/src/atlas/atlasShapeSvg.test.ts create mode 100644 frontend/src/atlas/atlasShapeSvg.ts create mode 100644 frontend/src/atlas/useAtlasShapeCreate.ts create mode 100644 frontend/src/atlas/useAtlasShapeDraw.ts diff --git a/frontend/e2e/atlas-shape-tool.spec.ts b/frontend/e2e/atlas-shape-tool.spec.ts new file mode 100644 index 00000000..bbd6a011 --- /dev/null +++ b/frontend/e2e/atlas-shape-tool.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from './fixtures/server' +import { dragBetween } from './fixtures/atlasBoard' +import { contextMenu } from './fixtures/contextMenu' +import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker' + +// The shape tool (goal 0169 slice 5): drag-to-draw lands a rectangle/ +// ellipse/arrow as a board-local BoardObject -- NEVER a card, matching +// image/ink's own goal 0179 correction. One tray tool covers all three +// types, picked via the style picker while armed; drawing several never +// interrupts drawing to commit. Shared pool: every entity created here +// is deleted here. +// +// Real pointer-capture drag, not React Flow's own internal drag +// machinery (the class QUARANTINE.md's box-select/NodeResizer entries +// document as unreliable to synthesize) -- same wiring +// atlas-pencil-tool.spec.ts's own header comment documents. +async function boardPoint(board: import('@playwright/test').Locator, fx: number, fy: number): Promise<{ x: number; y: number }> { + const box = await board.boundingBox() + if (!box) throw new Error('board has no bounding box') + return { x: box.x + box.width * fx, y: box.y + box.height * fy } +} + +function shapeObjects(page: import('@playwright/test').Page) { + return page.locator('[data-testid="atlas-board-object"][data-object-kind="shape"]') +} + +test('dragging the shape tool lands a rectangle, never a card, and the tool stays armed for the next shape', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + const shapeTool = page.getByTestId('atlas-tray-shape') + await shapeTool.click() + await expect(shapeTool).toHaveAttribute('data-armed', 'true') + const picker = page.getByTestId('atlas-shape-style-picker') + await expect(picker).toBeVisible() + // Rectangle is the default type -- confirmed selected before any drag. + await expect(picker.getByTestId('atlas-shape-type-rectangle')).toHaveAttribute('data-selected', 'true') + + await dragBetween(page, await boardPoint(board, 0.05, 0.1), await boardPoint(board, 0.2, 0.25)) + + const shapes = shapeObjects(page) + await expect(shapes).toHaveCount(1) + await expect(shapes.first()).toHaveAttribute('data-shape-type', 'rectangle') + // The rule, absolute: drawing never creates a card the user didn't + // explicitly ask for. + await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'Rectangle' })).toHaveCount(0) + + // Drag-to-draw is a sticky tool: completing a shape never disarms it. + await expect(shapeTool).toHaveAttribute('data-armed', 'true') + await dragBetween(page, await boardPoint(board, 0.3, 0.1), await boardPoint(board, 0.45, 0.25)) + await expect(shapes).toHaveCount(2) + + for (let i = 0; i < 2; i++) { + await shapes.first().click({ button: 'right' }) + const menu = contextMenu(page) + await expect(menu).toBeVisible() + await menu.getByText('Delete', { exact: true }).click() + } + await expect(shapes).toHaveCount(0) +}) + +test('picking ellipse then arrow draws each type, and an arrow carries no Size (dx/dy only)', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + await page.getByTestId('atlas-tray-shape').click() + const picker = page.getByTestId('atlas-shape-style-picker') + await expect(picker).toBeVisible() + + await picker.getByTestId('atlas-shape-type-ellipse').click() + await expect(picker.getByTestId('atlas-shape-type-ellipse')).toHaveAttribute('data-selected', 'true') + // Both drags stay in the board's own TOP band, clear of the style + // picker's own popover (anchored 'outside-top' of the bottom-center + // tray, so it occupies the lower-middle of the viewport for as long + // as shape stays armed) -- a drag start point landing ON that popover + // would be swallowed by it rather than reaching the board's own + // pointer-capture handler, never producing a shape (goal 0184's + // class: verify the drag start point is reachable, don't assume it). + await dragBetween(page, await boardPoint(board, 0.05, 0.1), await boardPoint(board, 0.2, 0.25)) + const shapes = shapeObjects(page) + await expect(shapes).toHaveCount(1) + await expect(shapes.first()).toHaveAttribute('data-shape-type', 'ellipse') + + await picker.getByTestId('atlas-shape-type-arrow').click() + await dragBetween(page, await boardPoint(board, 0.3, 0.1), await boardPoint(board, 0.45, 0.25)) + await expect(shapes).toHaveCount(2) + await expect(page.locator('[data-testid="atlas-board-object"][data-shape-type="arrow"]')).toHaveCount(1) + + for (let i = 0; i < 2; i++) { + await shapes.first().click({ button: 'right' }) + const menu = contextMenu(page) + await expect(menu).toBeVisible() + await menu.getByText('Delete', { exact: true }).click() + } + await expect(shapes).toHaveCount(0) +}) + +test('the shape style choice survives a disarm/re-arm cycle, and Promote to card works the same way ink\'s does', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + const shapeTool = page.getByTestId('atlas-tray-shape') + await shapeTool.click() + const picker = page.getByTestId('atlas-shape-style-picker') + await expect(picker).toBeVisible() + + const chosenSwatch = picker.getByTestId('atlas-shape-stroke-da3633') + await expect(chosenSwatch).toHaveAttribute('data-selected', 'false') + await chosenSwatch.click() + await expect(chosenSwatch).toHaveAttribute('data-selected', 'true') + + // Disarm, then re-arm: the style picker REMOUNTS (AnchoredOverlay + // unmounts its children while closed) -- the swatch selection + // surviving proves it lives in the ephemeral store, not per-mount + // component state. + await shapeTool.click() + await expect(picker).not.toBeVisible() + await shapeTool.click() + await expect(picker).toBeVisible() + await expect(picker.getByTestId('atlas-shape-stroke-da3633')).toHaveAttribute('data-selected', 'true') + + const shapes = shapeObjects(page) + await dragBetween(page, await boardPoint(board, 0.55, 0.1), await boardPoint(board, 0.7, 0.25)) + await expect(shapes).toHaveCount(1) + + await shapes.first().click({ button: 'right' }) + const menu = contextMenu(page) + await expect(menu).toBeVisible() + await menu.getByText('Promote to card…', { exact: true }).click() + const popover = page.getByTestId('atlas-placement-popover') + await expect(popover).toBeVisible() + await expect(popover.getByTestId('atlas-placement-title')).toHaveValue('Rectangle') + await selectKind(popover, ATLAS_KIND_TOPIC) + await popover.getByTestId('atlas-placement-submit').click() + await expect(popover).not.toBeVisible() + await expect(shapes).toHaveCount(0) + const card = page.getByTestId('atlas-note-card').filter({ hasText: 'Rectangle' }) + await expect(card).toBeVisible() + + await card.click({ button: 'right' }) + await expect(menu).toBeVisible() + await menu.getByText('Delete', { exact: true }).click() + await expect(card).toHaveCount(0) +}) diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx index 4ffdf6e0..65040a6f 100644 --- a/frontend/src/atlas/AtlasBoard.tsx +++ b/frontend/src/atlas/AtlasBoard.tsx @@ -22,6 +22,7 @@ import { useAtlasPencilStyle } from './atlasPencilStyleStore' import { AtlasPencilLivePreview } from './AtlasPencilLivePreview' import { AtlasEraserLiveTrail } from './AtlasEraserLiveTrail' import { AtlasLaserTrail } from './AtlasLaserTrail' +import { AtlasShapeLivePreview } from './AtlasShapeLivePreview' import { useAtlasDragTools } from './useAtlasDragTools' import { useAtlasDragFiling, type FrameBox } from './useAtlasDragFiling' import { AtlasDragHighlightContext } from './atlasDragHighlightContext' @@ -314,11 +315,14 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f const r = areaDraw.dragLocalRect, marqueeStyle = r ? { left: r.x, top: r.y, width: r.width, height: r.height } : null - // Eraser + Laser's own arming and gesture hooks, plus the four-way - // activeDrag resolution across all of Area/Pencil/Eraser/Laser (goal - // 0169 slice 4) -- split into its own file at the 500-line seam. - const { eraserDraw, laserDraw, activeDrag, anyDragToolArmed } = useAtlasDragTools({ - isFree, readOnly, armedTool: creation.armedTool, screenToFlowPosition, topLevelBoxes, noteBoxes, wrapperRef, + // Eraser + Laser's own arming and gesture hooks, plus Shape's full + // wiring (armed flag, style store, placement door, drag hook -- goal + // 0169 slice 5, instantiated INSIDE this hook the same way Eraser/ + // Laser already are, unlike Area/Pencil which predate this split), + // plus the five-way activeDrag resolution across all of them -- split + // into its own file at the 500-line seam. + const { eraserDraw, laserDraw, shapeStyle, shapeDraw, activeDrag, anyDragToolArmed } = useAtlasDragTools({ + isFree, readOnly, armedTool: creation.armedTool, screenToFlowPosition, topLevelBoxes, noteBoxes, wrapperRef, parentID, onDeleteSelection, areaArmed, areaDraw, pencilArmed, pencilDraw, }) @@ -454,6 +458,9 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f {marqueeStyle &&
} {pencilDraw.localPoints && } + {shapeDraw.localStart && shapeDraw.localCurrent && ( + + )} {eraserDraw.localPoints && } {laserDraw.points.length > 0 && } {slotDrag.dragLine && } diff --git a/frontend/src/atlas/AtlasBoardObjectNode.tsx b/frontend/src/atlas/AtlasBoardObjectNode.tsx index a3e6b8e1..9c241314 100644 --- a/frontend/src/atlas/AtlasBoardObjectNode.tsx +++ b/frontend/src/atlas/AtlasBoardObjectNode.tsx @@ -4,6 +4,7 @@ import type { NodeProps, Node as RFNode } from '@xyflow/react' import { ImageIcon, PencilIcon } from '@primer/octicons-react' import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { AtlasService } from '../shared/bindings' +import { AtlasShapeContent } from './AtlasShapeContent' import styles from './AtlasBoardObjectNode.module.css' export interface AtlasBoardObjectData extends Record { @@ -23,13 +24,20 @@ export type AtlasBoardObjectRFNode = RFNode // at its own natural/intrinsic size (clamped by this module's own CSS // max-width/height so a full-resolution screenshot never dwarfs the // board) until a future resize persists BoardObject.Size. -export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }: NodeProps) { +// A shape (goal 0169 slice 5) is structurally NOT mirror-file-backed -- +// it renders straight from its own Payload/Size, so it takes an early, +// separate branch here rather than folding into the mirror-fetch effect +// below (which stays scoped to the two Kinds that actually have a +// mirror file, image and ink). +function AtlasBoardObjectNodeInner({ data }: NodeProps) { const { t } = useTranslation('atlas') const { object } = data const [src, setSrc] = useState(null) const [failed, setFailed] = useState(false) + const isShape = object.Kind === 'shape' useEffect(() => { + if (isShape) return let stale = false setSrc(null) setFailed(false) @@ -48,7 +56,15 @@ export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }: return () => { stale = true } - }, [object.ID, object.Payload]) + }, [object.ID, object.Payload, isShape]) + + if (isShape) { + return ( +
+ +
+ ) + } const Glyph = object.Kind === 'ink' ? PencilIcon : ImageIcon @@ -70,4 +86,16 @@ export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }: )}
) -}) +} + +// A board-local canvas object (goal 0179/0180's own correction: a +// canvas object is a thing in space, never a document) -- image, ink, +// and shape share this one component, discriminated by object.Kind +// purely for which content to render; none get a title, a flip, or +// connection handles -- structurally excluded from every card +// mechanism, the same way AtlasStickyNode's note is. Lands at its own +// natural/intrinsic size (clamped by this module's own CSS max-width/ +// height for image/ink so a full-resolution screenshot never dwarfs the +// board; a shape's own size is already user-drawn, so it carries no +// such clamp) until a future resize persists BoardObject.Size. +export const AtlasBoardObjectNode = memo(AtlasBoardObjectNodeInner) diff --git a/frontend/src/atlas/AtlasCreationTray.tsx b/frontend/src/atlas/AtlasCreationTray.tsx index 3734dc75..1ea7aa29 100644 --- a/frontend/src/atlas/AtlasCreationTray.tsx +++ b/frontend/src/atlas/AtlasCreationTray.tsx @@ -1,9 +1,9 @@ -import { Fragment, useRef } from 'react' +import { createRef, Fragment, useMemo } from 'react' +import type { RefObject } from 'react' import { useTranslation } from 'react-i18next' import { AnchoredOverlay, Button } from '@primer/react' import { AtlasTableSizePicker } from './AtlasTableSizePicker' import { AtlasImageInput } from './AtlasImageInput' -import { AtlasPencilStylePicker } from './AtlasPencilStylePicker' import { ATLAS_TOOLS, type AtlasArmableTool } from './atlasTools' import styles from './AtlasCreationTray.module.css' @@ -28,7 +28,10 @@ export const ATLAS_TOOL_DRAG_MIME = 'application/x-mill-atlas-tool' // on click too, but STAYS armed across strokes (AtlasBoard.tsx's own // drag hook owns completion, never disarming itself), with its own // colour/size options bar shown anchored for as long as it's the -// armed tool ('drag-to-draw'). Eraser and Laser ('drag-to-erase', +// armed tool ('drag-to-draw'); Shape (goal 0169 slice 5) shares that +// same interaction and branch, its own options bar swapped in via the +// tool's own StylePicker field rather than a second branch. Eraser and +// Laser ('drag-to-erase', // 'ephemeral-drag') fall through to the same plain arm-on-click button // the default branch below already renders for Card/Note/Area -- // neither needs an options popover, so no new branch was needed here @@ -61,9 +64,18 @@ export function AtlasCreationTray({ armedTool, onToggle, tablePickerOpen, onTabl }) { const { t } = useTranslation('atlas') const tools = ATLAS_TOOLS.filter((tool) => tool.tray === 'quick') - const tableButtonRef = useRef(null) - const imageButtonRef = useRef(null) - const pencilButtonRef = useRef(null) + // One anchor ref PER TOOL ID (not one shared ref reused across every + // mapped button) -- created once, since ATLAS_TOOLS is a module-level + // constant whose ids never change across renders. A single shared ref + // was the pre-slice-5 shape here: it silently worked only because + // pencil was the sole 'drag-to-draw' tool ever mounted at once: a + // second one (shape) reusing the SAME ref object would make both + // AnchoredOverlays anchor to whichever DOM node happened to render + // last. + const anchorRefs = useMemo( + () => Object.fromEntries(tools.map((tool) => [tool.id, createRef()])) as Record>, + [tools], + ) return (
@@ -73,7 +85,7 @@ export function AtlasCreationTray({ armedTool, onToggle, tablePickerOpen, onTabl return ( - {}} - anchorRef={pencilButtonRef} - renderAnchor={null} - side="outside-top" - > - - + {StylePicker && ( + {}} + anchorRef={anchorRefs[tool.id]} + renderAnchor={null} + side="outside-top" + > + + + )} ) } @@ -146,7 +166,7 @@ export function AtlasCreationTray({ armedTool, onToggle, tablePickerOpen, onTabl return ( + ))} +
+
+ {PENCIL_COLORS.map((c) => ( +
+
+ {SHAPE_STROKE_WIDTHS.map((w) => ( + + ))} +
+ + ) +} diff --git a/frontend/src/atlas/atlasBuildBoardObjectNodes.ts b/frontend/src/atlas/atlasBuildBoardObjectNodes.ts index cbcc8e96..2d600b65 100644 --- a/frontend/src/atlas/atlasBuildBoardObjectNodes.ts +++ b/frontend/src/atlas/atlasBuildBoardObjectNodes.ts @@ -14,12 +14,13 @@ import type { AtlasBoardObjectRFNode } from './AtlasBoardObjectNode' // intrinsic size (clamped by AtlasBoardObjectNode.module.css) rather // than a fixed card-shaped box. // -// zIndex fixes ink ABOVE image regardless of creation/array order (the -// acceptance contract's own "drawing over a screenshot works"): every -// other kind added later keeps this same z-order convention (ink is -// always the annotation layer) by simply not opting into the image -// tier here. -const OBJECT_Z_INDEX: Record = { image: 0, ink: 1 } +// zIndex fixes ink ABOVE image/shape regardless of creation/array order +// (the acceptance contract's own "drawing over a screenshot works", +// goal 0169 slice 5 extending it to "ink can be drawn on top of +// shapes"): shape joins image's own tier -- both are peer surfaces ink +// annotates -- so a third kind stays on this same tier by default +// unless it too needs to be an annotation layer. +const OBJECT_Z_INDEX: Record = { image: 0, shape: 0, ink: 1 } export function buildBoardObjectNodes({ objects, readOnly, isFree }: { objects: BoardObject[] diff --git a/frontend/src/atlas/atlasShapeStyleStore.ts b/frontend/src/atlas/atlasShapeStyleStore.ts new file mode 100644 index 00000000..d1ac63e9 --- /dev/null +++ b/frontend/src/atlas/atlasShapeStyleStore.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand' +import { PENCIL_COLORS } from './atlasPencilStyleStore' + +// The shape tool's own "current defaults" cache (goal 0169 slice 5's +// own styleDefaults dual model, mirroring pencilStyleStore.ts): seeds +// the NEXT shape's type/stroke/width. Deliberately in-memory only -- no +// persist middleware, no backend call -- so a fresh session starts back +// at the defaults below rather than resurrecting a prior session's +// choice as if it were saved content. A drawn shape's OWN style instead +// lives in its BoardObject.Payload (atlasTools.ts's shapeTool.commit), +// which IS persisted document data -- this store is never read back +// from there. Reuses PENCIL_COLORS rather than a second stroke palette, +// since both pickers offer the same swatch set. +export type AtlasShapeType = 'rectangle' | 'ellipse' | 'arrow' +export const SHAPE_STROKE_WIDTHS = [1, 2, 4] as const +// Every new shape starts unfilled -- the converged default across +// Excalidraw/tldraw/draw.io's own basic shape (docs/goals/0169's own +// research); a filled-vs-transparent picker is 0193's own style-editor +// scope, not this slice's. +export const SHAPE_DEFAULT_FILL = 'transparent' + +interface ShapeStyleState { + shapeType: AtlasShapeType + stroke: string + strokeWidth: number + setShapeType: (shapeType: AtlasShapeType) => void + setStroke: (stroke: string) => void + setStrokeWidth: (strokeWidth: number) => void +} + +export const useAtlasShapeStyle = create()((set) => ({ + shapeType: 'rectangle', + stroke: PENCIL_COLORS[0], + strokeWidth: SHAPE_STROKE_WIDTHS[1], + setShapeType: (shapeType) => set({ shapeType }), + setStroke: (stroke) => set({ stroke }), + setStrokeWidth: (strokeWidth) => set({ strokeWidth }), +})) diff --git a/frontend/src/atlas/atlasShapeSvg.test.ts b/frontend/src/atlas/atlasShapeSvg.test.ts new file mode 100644 index 00000000..d42f04f5 --- /dev/null +++ b/frontend/src/atlas/atlasShapeSvg.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { arrowGeometry, boxDimensions, shapePayload, shapeTitle } from './atlasShapeSvg' + +describe('boxDimensions', () => { + it('takes the absolute extent on each axis', () => { + expect(boxDimensions(-40, 60)).toEqual({ w: 40, h: 60 }) + }) + + it('floors at 8 so a near-zero drag still leaves a visible box', () => { + expect(boxDimensions(0, 0)).toEqual({ w: 8, h: 8 }) + }) +}) + +describe('arrowGeometry', () => { + it('places the line from (0,0) to (dx,dy) unshifted when the arrow points down-right', () => { + const g = arrowGeometry(40, 60, 2) + expect(g).toEqual({ w: 40, h: 60, x1: 0, y1: 0, x2: 40, y2: 60 }) + }) + + it('flips the start corner to preserve direction when the arrow points up-left', () => { + const g = arrowGeometry(-40, -60, 2) + expect(g).toEqual({ w: 40, h: 60, x1: 40, y1: 60, x2: 0, y2: 0 }) + }) + + it('floors each axis at 4x strokeWidth (min 8) for a near-axis-aligned arrow', () => { + const g = arrowGeometry(100, 0, 1) + expect(g.h).toBe(8) + expect(g.w).toBe(100) + }) +}) + +describe('shapePayload', () => { + it('stringifies every value, including strokeWidth', () => { + expect(shapePayload('rectangle', { fill: 'transparent', stroke: '#1f6feb', strokeWidth: 4 }, 'Rectangle')).toEqual({ + shapeType: 'rectangle', fill: 'transparent', stroke: '#1f6feb', strokeWidth: '4', title: 'Rectangle', + }) + }) + + it('carries dx/dy only when extra geometry is supplied', () => { + const payload = shapePayload('arrow', { fill: 'transparent', stroke: '#1f6feb', strokeWidth: 2 }, 'Arrow', { dx: -10, dy: 5 }) + expect(payload.dx).toBe('-10') + expect(payload.dy).toBe('5') + }) +}) + +describe('shapeTitle', () => { + it('names each of the three shape types', () => { + expect(shapeTitle('rectangle')).toBe('Rectangle') + expect(shapeTitle('ellipse')).toBe('Ellipse') + expect(shapeTitle('arrow')).toBe('Arrow') + }) +}) diff --git a/frontend/src/atlas/atlasShapeSvg.ts b/frontend/src/atlas/atlasShapeSvg.ts new file mode 100644 index 00000000..e13910c4 --- /dev/null +++ b/frontend/src/atlas/atlasShapeSvg.ts @@ -0,0 +1,51 @@ +import type { AtlasShapeType } from './atlasShapeStyleStore' + +export interface ShapeStyle { fill: string; stroke: string; strokeWidth: number } + +// A rectangle/ellipse's own bounding box IS its persisted BoardObject.Size +// (W/H) -- geometry needs no helper beyond a floor so a near-zero-extent +// drag still leaves a selectable/visible footprint. Matches arrowGeometry's +// own 8px floor below for visual consistency across all three shapes. +export function boxDimensions(dx: number, dy: number): { w: number; h: number } { + return { w: Math.max(8, Math.abs(dx)), h: Math.max(8, Math.abs(dy)) } +} + +export interface ArrowGeometry { w: number; h: number; x1: number; y1: number; x2: number; y2: number } + +// An arrow's own bounding box is derived purely from Payload.dx/dy +// (the vector from BoardObject.Position, its start point, to the end +// point) -- never stored separately, so create-time and render-time +// stay a single source of truth. Floored per axis at 4x strokeWidth so +// a perfectly horizontal/vertical arrow still gets a real cross-axis +// box for its own arrowhead marker to render inside (the itself +// renders with CSS overflow:visible, so a marker or thick stroke +// bleeding a few px past this nominal box is never clipped -- the floor +// only has to be "big enough to look intentional", not exact). +export function arrowGeometry(dx: number, dy: number, strokeWidth: number): ArrowGeometry { + const floor = Math.max(strokeWidth * 4, 8) + const w = Math.max(Math.abs(dx), floor) + const h = Math.max(Math.abs(dy), floor) + const x1 = dx < 0 ? w : 0 + const y1 = dy < 0 ? h : 0 + return { w, h, x1, y1, x2: x1 + dx, y2: y1 + dy } +} + +// Payload's own wire shape (goal 0179's Payload map[string]string +// contract): every value stays a plain string, matching how +// Card.Fields already carries data against typedfield.Field. +export function shapePayload(shapeType: AtlasShapeType, style: ShapeStyle, title: string, extra?: { dx: number; dy: number }): Record { + const base: Record = { + shapeType, fill: style.fill, stroke: style.stroke, strokeWidth: String(style.strokeWidth), title, + } + if (extra) { + base.dx = String(extra.dx) + base.dy = String(extra.dy) + } + return base +} + +export function shapeTitle(shapeType: AtlasShapeType): string { + if (shapeType === 'rectangle') return 'Rectangle' + if (shapeType === 'ellipse') return 'Ellipse' + return 'Arrow' +} diff --git a/frontend/src/atlas/atlasTools.test.ts b/frontend/src/atlas/atlasTools.test.ts index f632219d..b9847d69 100644 --- a/frontend/src/atlas/atlasTools.test.ts +++ b/frontend/src/atlas/atlasTools.test.ts @@ -8,7 +8,7 @@ vi.mock('../shared/bindings', () => ({ AtlasService: { SaveImageBytes: saveImageBytesMock }, })) -import { ATLAS_TOOLS, cardTool, noteTool, areaTool, tableTool, imageTool, pencilTool, eraserTool, laserTool } from './atlasTools' +import { ATLAS_TOOLS, cardTool, noteTool, areaTool, tableTool, imageTool, pencilTool, eraserTool, laserTool, shapeTool } from './atlasTools' import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' function kind(id: string): Kind { @@ -17,10 +17,10 @@ function kind(id: string): Kind { describe('ATLAS_TOOLS', () => { it('carries every registered tool in tray render order', () => { - expect(ATLAS_TOOLS.map((t) => t.id)).toEqual(['card', 'note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser']) + expect(ATLAS_TOOLS.map((t) => t.id)).toEqual(['card', 'note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) }) - it('scopes card/note/area to arm-then-click, table to pick-then-place, image to paste-or-drop, pencil to drag-to-draw, eraser to drag-to-erase, laser to ephemeral-drag', () => { + it('scopes card/note/area to arm-then-click, table to pick-then-place, image to paste-or-drop, pencil+shape to drag-to-draw, eraser to drag-to-erase, laser to ephemeral-drag', () => { const byID = Object.fromEntries(ATLAS_TOOLS.map((t) => [t.id, t.interaction])) expect(byID).toEqual({ card: 'arm-then-click', @@ -31,6 +31,7 @@ describe('ATLAS_TOOLS', () => { pencil: 'drag-to-draw', eraser: 'drag-to-erase', laser: 'ephemeral-drag', + shape: 'drag-to-draw', }) }) @@ -38,10 +39,15 @@ describe('ATLAS_TOOLS', () => { expect(ATLAS_TOOLS.every((t) => t.tray === 'quick')).toBe(true) }) - it('carries styleDefaults only on the pencil tool', () => { + it('carries styleDefaults only on the pencil tool (shape has no analogous field -- its style lives directly on AtlasShapeStylePicker\'s own store, never a registry-carried default object)', () => { const withDefaults = ATLAS_TOOLS.filter((t) => 'styleDefaults' in t && t.styleDefaults !== undefined) expect(withDefaults.map((t) => t.id)).toEqual(['pencil']) }) + + it('carries a StylePicker on both drag-to-draw tools, and no other', () => { + const withPicker = ATLAS_TOOLS.filter((t) => 'StylePicker' in t && t.StylePicker !== undefined) + expect(withPicker.map((t) => t.id)).toEqual(['pencil', 'shape']) + }) }) describe('cardTool.commit', () => { @@ -153,6 +159,40 @@ describe('pencilTool.commit', () => { }) }) +describe('shapeTool.commit', () => { + const style = { fill: 'transparent', stroke: '#1f6feb', strokeWidth: 2 } + + it('shapes a rectangle into a Size-bearing artifact, origin at the normalized top-left', () => { + const artifact = shapeTool.commit({ shapeType: 'rectangle', style, startFlow: { x: 50, y: 80 }, endFlow: { x: 10, y: 20 } }) + expect(artifact).toEqual({ + kind: 'shape', shapeType: 'rectangle', originFlow: { x: 10, y: 20 }, + payload: { shapeType: 'rectangle', fill: 'transparent', stroke: '#1f6feb', strokeWidth: '2', title: 'Rectangle' }, + size: { W: 40, H: 60 }, + }) + }) + + it('shapes an ellipse the same way, title Ellipse', () => { + const artifact = shapeTool.commit({ shapeType: 'ellipse', style, startFlow: { x: 0, y: 0 }, endFlow: { x: 30, y: 30 } }) + expect(artifact.shapeType).toBe('ellipse') + expect(artifact.payload.title).toBe('Ellipse') + expect(artifact.size).toEqual({ W: 30, H: 30 }) + }) + + it('shapes an arrow into a dx/dy payload with no Size at all, origin at the drag START point (direction-preserving, never normalized)', () => { + const artifact = shapeTool.commit({ shapeType: 'arrow', style, startFlow: { x: 100, y: 100 }, endFlow: { x: 40, y: 160 } }) + expect(artifact).toEqual({ + kind: 'shape', shapeType: 'arrow', originFlow: { x: 100, y: 100 }, + payload: { shapeType: 'arrow', fill: 'transparent', stroke: '#1f6feb', strokeWidth: '2', title: 'Arrow', dx: '-60', dy: '60' }, + size: null, + }) + }) + + it('floors a near-zero-extent rectangle drag at an 8-unit box rather than a degenerate sliver', () => { + const artifact = shapeTool.commit({ shapeType: 'rectangle', style, startFlow: { x: 0, y: 0 }, endFlow: { x: 1, y: 1 } }) + expect(artifact.size).toEqual({ W: 8, H: 8 }) + }) +}) + // Eraser and laser never produce a placeable artifact -- they destroy // board state or render an ephemeral overlay respectively, never // create anything -- so their own commit is a stub the real diff --git a/frontend/src/atlas/atlasTools.ts b/frontend/src/atlas/atlasTools.ts index ed81bdca..f17d38f6 100644 --- a/frontend/src/atlas/atlasTools.ts +++ b/frontend/src/atlas/atlasTools.ts @@ -1,4 +1,5 @@ -import { FileIcon, ImageIcon, NoteIcon, PencilIcon, SquareIcon, TableIcon, TrashIcon, ZapIcon } from '@primer/octicons-react' +import type { ComponentType } from 'react' +import { DiamondIcon, FileIcon, ImageIcon, NoteIcon, PencilIcon, SquareIcon, TableIcon, TrashIcon, ZapIcon } from '@primer/octicons-react' import type { Icon } from '@primer/octicons-react' import { Type as FieldType, type Field } from '../../bindings/github.com/alicoding/mill/internal/domain/typedfield/models' import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' @@ -7,6 +8,10 @@ import { ATLAS_TOOL_IDENTITIES, type AtlasToolIdentity } from '../shared/atlasTo import { fileToBase64 } from '../shared/base64Blob' import { lastUsedKindID, normalizeLocalPathInput, titleFromFilename } from './atlasCreateHelpers' import { buildPencilStrokeSvg, svgToBase64, type PencilPoint } from './atlasPencilSvg' +import { AtlasPencilStylePicker } from './AtlasPencilStylePicker' +import { AtlasShapeStylePicker } from './AtlasShapeStylePicker' +import type { AtlasShapeType } from './atlasShapeStyleStore' +import { boxDimensions, shapePayload, shapeTitle, type ShapeStyle } from './atlasShapeSvg' // The canvas tool registry (goal 0169 slice 1): every creatable // thing's own descriptor, in tray render order -- AtlasCreationTray, @@ -44,6 +49,12 @@ interface AtlasToolShape { tray: 'quick' | 'palette' interaction: AtlasToolInteraction styleDefaults?: AtlasToolStyleDefaults + // The tray's own options-bar component, shown anchored to this tool's + // button for as long as it's armed (AtlasCreationTray.tsx's own + // 'drag-to-draw' branch renders whichever tool carries one) -- + // registry-driven so a second drag-to-draw tool (this slice's shape, + // joining pencil) never needs a hardcoded branch naming it by id. + StylePicker?: ComponentType // Each concrete tool's own commit signature differs (a card commits // kind+title, a table mints a backing List); this base only has to // accept every one of them for the array's own element type to work, @@ -68,6 +79,14 @@ export interface AtlasImageArtifact { kind: 'image'; title: string; mirrorPath: // point through screenToFlowPosition -- the card lands where the // stroke was drawn, not at an arbitrary free slot. export interface AtlasPencilArtifact { kind: 'pencil'; title: string; mirrorPath: string; originX: number; originY: number } +// Unlike image/pencil, a shape never bakes to a mirror file -- fill/ +// stroke/strokeWidth stay live Payload data (this slice's own "style +// lives in Payload" contract, so a future style editor -- goal 0193 -- +// can change them without re-drawing). originFlow is the BoardObject's +// own Position; size is set via a follow-up SetBoardObjectSize call for +// rectangle/ellipse (an arrow's own geometry is entirely payload.dx/dy, +// so it carries no Size at all). +export interface AtlasShapeArtifact { kind: 'shape'; shapeType: AtlasShapeType; originFlow: { x: number; y: number }; payload: Record; size: { W: number; H: number } | null } const cardIdentity = identityOf('card') const noteIdentity = identityOf('note') @@ -77,6 +96,7 @@ const imageIdentity = identityOf('image') const pencilIdentity = identityOf('pencil') const eraserIdentity = identityOf('eraser') const laserIdentity = identityOf('laser') +const shapeIdentity = identityOf('shape') // Card's instant-placement default (goal 0144: the click IS the // creation, no form) resolves the last-used kind itself; a form-driven @@ -205,6 +225,7 @@ const pencilTool = { tray: 'quick', interaction: 'drag-to-draw', styleDefaults: PENCIL_DEFAULT_STYLE, + StylePicker: AtlasPencilStylePicker, commit: async (input: { points: PencilPoint[]; color: string; size: number }): Promise => { const doc = buildPencilStrokeSvg(input.points, input.color, input.size) if (!doc) return null @@ -264,9 +285,44 @@ const laserTool = { commit: (): null => null, } as const satisfies AtlasToolShape -export { cardTool, noteTool, areaTool, tableTool, imageTool, pencilTool, eraserTool, laserTool } +// Shape (goal 0169 slice 5): drag-to-draw's second proof, reusing the +// interaction shape unchanged rather than inventing a seventh. ONE +// tray tool covers all three geometric shapes -- rectangle, ellipse, +// arrow -- picked via AtlasShapeStylePicker while armed, the same +// "options bar anchored to the armed tool" surface pencil already +// established; this goal's own contract is "not a shape library, one +// tool", so the type lives in the style picker rather than three +// separate tray buttons. startFlow/endFlow are already flow-space +// (the caller, useAtlasShapeCreate.ts, runs screenToFlowPosition +// itself) so this stays a pure, synchronous function -- unlike every +// other drag-to-draw/paste-or-drop tool, a shape writes no bytes and +// touches no AtlasService call of its own; CreateBoardObject/ +// SetBoardObjectSize (already generic since goal 0179 S1) are the +// placement door's job, not this commit's. +const shapeTool = { + id: shapeIdentity.id, + icon: DiamondIcon, + label: shapeIdentity.commandLabel, + shortcutKey: shapeIdentity.shortcutKey, + tray: 'quick', + interaction: 'drag-to-draw', + StylePicker: AtlasShapeStylePicker, + commit: (input: { shapeType: AtlasShapeType; style: ShapeStyle; startFlow: { x: number; y: number }; endFlow: { x: number; y: number } }): AtlasShapeArtifact => { + const dx = input.endFlow.x - input.startFlow.x + const dy = input.endFlow.y - input.startFlow.y + const title = shapeTitle(input.shapeType) + if (input.shapeType === 'arrow') { + return { kind: 'shape', shapeType: 'arrow', originFlow: input.startFlow, payload: shapePayload('arrow', input.style, title, { dx, dy }), size: null } + } + const { w, h } = boxDimensions(dx, dy) + const originFlow = { x: Math.min(input.startFlow.x, input.endFlow.x), y: Math.min(input.startFlow.y, input.endFlow.y) } + return { kind: 'shape', shapeType: input.shapeType, originFlow, payload: shapePayload(input.shapeType, input.style, title), size: { W: w, H: h } } + }, +} as const satisfies AtlasToolShape + +export { cardTool, noteTool, areaTool, tableTool, imageTool, pencilTool, eraserTool, laserTool, shapeTool } -export const ATLAS_TOOLS = [cardTool, noteTool, areaTool, tableTool, imageTool, pencilTool, eraserTool, laserTool] as const +export const ATLAS_TOOLS = [cardTool, noteTool, areaTool, tableTool, imageTool, pencilTool, eraserTool, laserTool, shapeTool] as const export type AtlasToolID = (typeof ATLAS_TOOLS)[number]['id'] diff --git a/frontend/src/atlas/useAtlasCreation.ts b/frontend/src/atlas/useAtlasCreation.ts index 39ca1192..4602cf37 100644 --- a/frontend/src/atlas/useAtlasCreation.ts +++ b/frontend/src/atlas/useAtlasCreation.ts @@ -145,17 +145,17 @@ export function useAtlasCreation({ parentID, allCards, kinds, notes, objects, re // (one placement per arming, the LOCKED design's own rule) -- // explicitTool lets the right-click "Add card"/"Add note" pane menu // items place directly without going through the armed state at all. - // None of Area/Pencil/Eraser/Laser has click-based placement: each - // one's own drag gesture is handled entirely by its own hook + // None of Area/Pencil/Eraser/Laser/Shape has click-based placement: + // each one's own drag gesture is handled entirely by its own hook // (useAtlasAreaDraw, useAtlasPencilDraw, useAtlasEraserDraw, - // useAtlasLaserDraw -- all wired in AtlasBoard.tsx), which either - // disarms itself (Area) or stays armed and never routes through here - // at all (the other three) -- excluded regardless, so a stray click - // while one of them is armed never falls through to the note-draft - // branch below. + // useAtlasLaserDraw, useAtlasShapeDraw -- all wired in AtlasBoard.tsx), + // which either disarms itself (Area) or stays armed and never routes + // through here at all (the other four) -- excluded regardless, so a + // stray click while one of them is armed never falls through to the + // note-draft branch below. const placeAt = useCallback((screenPos: { x: number; y: number }, explicitTool?: AtlasCreationTool, parentIDOverride?: string, linkFromCardID?: string) => { const tool = explicitTool ?? armedTool - if (!tool || tool === 'area' || tool === 'pencil' || tool === 'eraser' || tool === 'laser' || readOnly) return + if (!tool || tool === 'area' || tool === 'pencil' || tool === 'eraser' || tool === 'laser' || tool === 'shape' || readOnly) return setArmedTool(null) // "Add linked card…" (goal 0081 slice A4) lands beside the linking // card, at a free spot in ITS parent -- never at the menu's own diff --git a/frontend/src/atlas/useAtlasDragTools.ts b/frontend/src/atlas/useAtlasDragTools.ts index 3674362e..463ebc37 100644 --- a/frontend/src/atlas/useAtlasDragTools.ts +++ b/frontend/src/atlas/useAtlasDragTools.ts @@ -1,6 +1,9 @@ import type { PointerEvent as ReactPointerEvent, RefObject } from 'react' import { useAtlasEraserDraw } from './useAtlasEraserDraw' import { useAtlasLaserDraw } from './useAtlasLaserDraw' +import { useAtlasShapeDraw } from './useAtlasShapeDraw' +import { useAtlasShapeCreate } from './useAtlasShapeCreate' +import { useAtlasShapeStyle } from './atlasShapeStyleStore' import type { FrameBox } from './useAtlasDragFiling' import type { AtlasArmableTool } from './atlasTools' @@ -25,10 +28,11 @@ function firstArmedDrag(entries: [boolean, DragGestureHandlers][]): DragGestureH // Pencil stay wired directly in AtlasBoard.tsx (they predate this // split and nothing about slice 4 required moving them), this hook // only takes their ALREADY-INSTANTIATED armed flag + drag handlers as -// input so `activeDrag`'s four-way resolution has one home regardless -// of where each tool's own hook call happens to live. +// input so `activeDrag`'s five-way resolution (goal 0169 slice 5 adds +// Shape) has one home regardless of where each tool's own hook call +// happens to live. export function useAtlasDragTools({ - isFree, readOnly, armedTool, screenToFlowPosition, topLevelBoxes, noteBoxes, wrapperRef, onDeleteSelection, + isFree, readOnly, armedTool, screenToFlowPosition, topLevelBoxes, noteBoxes, wrapperRef, parentID, onDeleteSelection, areaArmed, areaDraw, pencilArmed, pencilDraw, }: { isFree: boolean @@ -38,6 +42,7 @@ export function useAtlasDragTools({ topLevelBoxes: FrameBox[] noteBoxes: { id: string; x: number; y: number; width: number; height: number }[] wrapperRef: RefObject + parentID: string onDeleteSelection: (cardIDs: string[], noteIDs: string[]) => void areaArmed: boolean areaDraw: DragGestureHandlers @@ -64,8 +69,25 @@ export function useAtlasDragTools({ const laserArmed = isFree && !readOnly && armedTool === 'laser' const laserDraw = useAtlasLaserDraw({ armed: laserArmed, wrapperRef }) - const activeDrag = firstArmedDrag([[areaArmed, areaDraw], [pencilArmed, pencilDraw], [eraserArmed, eraserDraw], [laserArmed, laserDraw]]) + // Shape (goal 0169 slice 5): armed/wired the same sticky way Pencil is + // (its own drag hook never disarms on completion), but fully + // instantiated HERE rather than passed in -- Shape is introduced at + // this split, unlike Area/Pencil which predate it, so it follows + // Eraser/Laser's own precedent instead. shapeStyle is the ephemeral + // "current defaults" cache (atlasShapeStyleStore.ts); landShape bakes + // its current value into the created BoardObject's own Payload, which + // IS persisted document data. + const shapeArmed = isFree && !readOnly && armedTool === 'shape' + const shapeStyle = useAtlasShapeStyle() + const shapeCreate = useAtlasShapeCreate({ parentID, topLevelBoxes, screenToFlowPosition }) + const shapeDraw = useAtlasShapeDraw({ + armed: shapeArmed, + wrapperRef, + onComplete: (start, end) => void shapeCreate.landShape(start, end, shapeStyle).catch(console.error), + }) + + const activeDrag = firstArmedDrag([[areaArmed, areaDraw], [pencilArmed, pencilDraw], [eraserArmed, eraserDraw], [laserArmed, laserDraw], [shapeArmed, shapeDraw]]) const anyDragToolArmed = activeDrag !== null - return { eraserArmed, eraserDraw, laserArmed, laserDraw, activeDrag, anyDragToolArmed } + return { eraserArmed, eraserDraw, laserArmed, laserDraw, shapeArmed, shapeStyle, shapeDraw, activeDrag, anyDragToolArmed } } diff --git a/frontend/src/atlas/useAtlasShapeCreate.ts b/frontend/src/atlas/useAtlasShapeCreate.ts new file mode 100644 index 00000000..6666157b --- /dev/null +++ b/frontend/src/atlas/useAtlasShapeCreate.ts @@ -0,0 +1,41 @@ +import { AtlasService } from '../shared/bindings' +import { refreshAtlas } from './atlasStore' +import { shapeTool } from './atlasTools' +import { frameContainingPoint } from './atlasFramePoint' +import { SHAPE_DEFAULT_FILL, type AtlasShapeType } from './atlasShapeStyleStore' +import type { ShapePoint } from './useAtlasShapeDraw' +import type { FrameBox } from './useAtlasDragFiling' + +// The shape tool's own placement door (goal 0169 slice 5, following +// useAtlasPencilCreate.ts's own precedent): commits the drawn shape +// (atlasTools.ts's shapeTool.commit computes its geometry) and lands it +// as a board-local "shape" BoardObject -- never a card. A shape IS a +// spatial gesture like ink: it lands at the FLOW rect it was actually +// dragged over, filed into whichever frame its start corner was drawn +// over (the same frameContainingPoint resolution pencil/native-drop +// already apply). Unlike pencil, landing a rectangle/ellipse needs a +// second call (SetBoardObjectSize) -- CreateBoardObject itself never +// takes a Size, matching how every OTHER board object starts sizeless +// until an explicit resize; a shape's own "resize" simply happens to +// be its very first frame. +export function useAtlasShapeCreate({ parentID, topLevelBoxes, screenToFlowPosition }: { + parentID: string + topLevelBoxes: FrameBox[] + screenToFlowPosition: (p: { x: number; y: number }) => { x: number; y: number } +}) { + const landShape = async (start: ShapePoint, end: ShapePoint, style: { shapeType: AtlasShapeType; stroke: string; strokeWidth: number }) => { + const startFlow = screenToFlowPosition(start) + const endFlow = screenToFlowPosition(end) + const artifact = shapeTool.commit({ + shapeType: style.shapeType, + style: { fill: SHAPE_DEFAULT_FILL, stroke: style.stroke, strokeWidth: style.strokeWidth }, + startFlow, endFlow, + }) + const targetParentID = frameContainingPoint(topLevelBoxes, artifact.originFlow) ?? parentID + const created = await AtlasService.CreateBoardObject('shape', artifact.payload, { X: artifact.originFlow.x, Y: artifact.originFlow.y }, targetParentID) + if (artifact.size) await AtlasService.SetBoardObjectSize(created.ID, artifact.size) + await refreshAtlas() + } + + return { landShape } +} diff --git a/frontend/src/atlas/useAtlasShapeDraw.ts b/frontend/src/atlas/useAtlasShapeDraw.ts new file mode 100644 index 00000000..0c10cbeb --- /dev/null +++ b/frontend/src/atlas/useAtlasShapeDraw.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { PointerEvent as ReactPointerEvent, RefObject } from 'react' + +export interface ShapePoint { x: number; y: number } + +// A drag under this many screen pixels (either axis) is a stray click +// on the armed tool, not a draw -- same MIN_DRAG_PX convention +// useAtlasAreaDraw.ts/useAtlasPencilDraw.ts already use for the same +// reason (never leave an accidental point-sized shape behind). +const MIN_DRAG_PX = 6 + +// The shape tool's own drag-to-draw gesture (goal 0169 slice 5): +// pointerdown marks the start corner, pointermove tracks the live end +// point (localStart/localCurrent feed AtlasShapeLivePreview's own +// rect/ellipse/arrow rendering), pointerup hands the CLIENT-space +// start/end pair to onComplete. Like Pencil, this hook never disarms on +// its own -- shape is a sticky tool (drawing several in one session +// stays uninterrupted, the same "don't force a commit ceremony" +// correction goal 0179 made for ink). +// +// Wired as CAPTURE-phase pointer handlers for the same reason +// useAtlasAreaDraw.ts's own header comment documents: React Flow's pane +// calls preventDefault() on its own bubble-phase pointerdown to drive +// panning/node-drag, which silently suppresses the browser's own +// compatibility mouse events. Capturing pointerdown on an ANCESTOR of +// the pane and stopping propagation is what lets this hook own the +// gesture instead of racing React Flow for it. +export function useAtlasShapeDraw({ + armed, onComplete, wrapperRef, +}: { + armed: boolean + onComplete: (start: ShapePoint, end: ShapePoint) => void + wrapperRef: RefObject +}) { + const [localStart, setLocalStart] = useState(null) + const [localCurrent, setLocalCurrent] = useState(null) + const startRef = useRef(null) + + const toLocal = (p: ShapePoint): ShapePoint => { + const box = wrapperRef.current?.getBoundingClientRect() + return box ? { x: p.x - box.left, y: p.y - box.top } : p + } + + const onPointerDown = useCallback((e: ReactPointerEvent) => { + if (!armed || e.button !== 0) return + e.stopPropagation() + const point = { x: e.clientX, y: e.clientY } + startRef.current = point + setLocalStart(toLocal(point)) + setLocalCurrent(toLocal(point)) + // eslint-disable-next-line react-hooks/exhaustive-deps -- toLocal reads wrapperRef.current at call time, deliberately not a dependency + }, [armed]) + + const onPointerMove = useCallback((e: ReactPointerEvent) => { + if (!startRef.current) return + e.stopPropagation() + setLocalCurrent(toLocal({ x: e.clientX, y: e.clientY })) + // eslint-disable-next-line react-hooks/exhaustive-deps -- same as onPointerDown above + }, []) + + const onPointerUp = useCallback((e: ReactPointerEvent) => { + const start = startRef.current + startRef.current = null + setLocalStart(null) + setLocalCurrent(null) + if (!start) return + e.stopPropagation() + const end = { x: e.clientX, y: e.clientY } + if (Math.abs(end.x - start.x) < MIN_DRAG_PX && Math.abs(end.y - start.y) < MIN_DRAG_PX) return + onComplete(start, end) + }, [onComplete]) + + // Escape mid-draw cancels only the IN-PROGRESS shape, matching + // useAtlasAreaDraw.ts/useAtlasPencilDraw.ts's own Escape handling -- + // the global Escape listener (useAtlasCreation.ts's cancelAll) disarms + // the tool itself separately. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && startRef.current) { + startRef.current = null + setLocalStart(null) + setLocalCurrent(null) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + return { localStart, localCurrent, onPointerDown, onPointerMove, onPointerUp } +} diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index 663df306..af09ffb3 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -94,13 +94,24 @@ "eraserLabel": "Eraser", "eraserTooltip": "Drag to erase cards and notes (E)", "laserLabel": "Laser", - "laserTooltip": "Point at things on the board (L)" + "laserTooltip": "Point at things on the board (L)", + "shapeLabel": "Shape", + "shapeTooltip": "Draw a shape (S)" }, "pencilStyle": { "colorLabel": "Stroke colour", "sizeLabel": "Stroke size", "sizeOption": "{{size}}px" }, + "shapeStyle": { + "typeLabel": "Shape type", + "type_rectangle": "Rectangle", + "type_ellipse": "Ellipse", + "type_arrow": "Arrow", + "strokeLabel": "Stroke colour", + "widthLabel": "Stroke width", + "widthOption": "{{width}}px" + }, "imageInput": { "pathLabel": "Path", "pathPlaceholder": "/path/to/image.png", @@ -129,6 +140,7 @@ "boardObject": { "imageAriaLabel": "Image", "inkAriaLabel": "Ink drawing", + "shapeAriaLabel": "Shape", "loadFailed": "Couldn't load this file." }, "capture": { diff --git a/frontend/src/shared/atlasToolIdentity.ts b/frontend/src/shared/atlasToolIdentity.ts index 23c01d3f..cc9aca08 100644 --- a/frontend/src/shared/atlasToolIdentity.ts +++ b/frontend/src/shared/atlasToolIdentity.ts @@ -10,7 +10,7 @@ // regardless of which layer is asking. export type AtlasToolRequestKind = 'arm' | 'picker' | 'popover' -// Eight distinct members, not one member with a unioned id -- so that +// Nine distinct members, not one member with a unioned id -- so that // Extract (atlasTools.ts's own // lookup) can actually narrow to a single one. export type AtlasToolIdentity = @@ -22,6 +22,7 @@ export type AtlasToolIdentity = | { id: 'pencil'; shortcutKey: string; commandLabel: string; requestKind: 'arm' } | { id: 'eraser'; shortcutKey: string; commandLabel: string; requestKind: 'arm' } | { id: 'laser'; shortcutKey: string; commandLabel: string; requestKind: 'arm' } + | { id: 'shape'; shortcutKey: string; commandLabel: string; requestKind: 'arm' } export const ATLAS_TOOL_IDENTITIES: AtlasToolIdentity[] = [ { id: 'card', shortcutKey: 'C', commandLabel: 'Add a card', requestKind: 'arm' }, @@ -32,6 +33,7 @@ export const ATLAS_TOOL_IDENTITIES: AtlasToolIdentity[] = [ { id: 'pencil', shortcutKey: 'P', commandLabel: 'Draw with the pencil', requestKind: 'arm' }, { id: 'eraser', shortcutKey: 'E', commandLabel: 'Erase things on the board', requestKind: 'arm' }, { id: 'laser', shortcutKey: 'L', commandLabel: 'Point with the laser', requestKind: 'arm' }, + { id: 'shape', shortcutKey: 'S', commandLabel: 'Draw a shape', requestKind: 'arm' }, ] // Every identity whose bare key ARMS a placement (as opposed to