Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions frontend/e2e/atlas-shape-tool.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
17 changes: 12 additions & 5 deletions frontend/src/atlas/AtlasBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
})

Expand Down Expand Up @@ -454,6 +458,9 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
</AtlasDragHighlightContext.Provider>
{marqueeStyle && <div className={styles.marquee} data-testid="atlas-area-marquee" style={marqueeStyle} />}
{pencilDraw.localPoints && <AtlasPencilLivePreview points={pencilDraw.localPoints} color={pencilStyle.color} size={pencilStyle.size} />}
{shapeDraw.localStart && shapeDraw.localCurrent && (
<AtlasShapeLivePreview shapeType={shapeStyle.shapeType} stroke={shapeStyle.stroke} strokeWidth={shapeStyle.strokeWidth} start={shapeDraw.localStart} current={shapeDraw.localCurrent} />
)}
{eraserDraw.localPoints && <AtlasEraserLiveTrail points={eraserDraw.localPoints} />}
{laserDraw.points.length > 0 && <AtlasLaserTrail points={laserDraw.points} now={laserDraw.now} />}
{slotDrag.dragLine && <AtlasSlotDragLine line={slotDrag.dragLine} />}
Expand Down
34 changes: 31 additions & 3 deletions frontend/src/atlas/AtlasBoardObjectNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
Expand All @@ -23,13 +24,20 @@ export type AtlasBoardObjectRFNode = RFNode<AtlasBoardObjectData>
// 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<AtlasBoardObjectRFNode>) {
// 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<AtlasBoardObjectRFNode>) {
const { t } = useTranslation('atlas')
const { object } = data
const [src, setSrc] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const isShape = object.Kind === 'shape'

useEffect(() => {
if (isShape) return
let stale = false
setSrc(null)
setFailed(false)
Expand All @@ -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 (
<div className={styles.object} data-testid="atlas-board-object" data-object-kind="shape" data-shape-type={object.Payload?.shapeType} role="img" aria-label={t('boardObject.shapeAriaLabel')}>
<AtlasShapeContent object={object} />
</div>
)
}

const Glyph = object.Kind === 'ink' ? PencilIcon : ImageIcon

Expand All @@ -70,4 +86,16 @@ export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }:
)}
</div>
)
})
}

// 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)
Loading
Loading