diff --git a/e2e/floorplan.spec.ts b/e2e/floorplan.spec.ts index 0d9806c..0aa0bfe 100644 --- a/e2e/floorplan.spec.ts +++ b/e2e/floorplan.spec.ts @@ -81,6 +81,17 @@ async function clickWallMidpoint(page: Page, wallIndex = 0) { await page.mouse.click(box.x + midpoint.x * 40, box.y + midpoint.y * 40); } +async function drawTwoBoxesAndMarqueeSelect(page: Page) { + await drawBox(page, -200, -60, 80, 80); + await drawBox(page, 80, -60, 80, 80); + await page.getByTestId('tool-select').click(); + const { cx, cy } = await canvasCenter(page); + await page.mouse.move(cx - 240, cy - 100); + await page.mouse.down(); + await page.mouse.move(cx + 200, cy + 60, { steps: 10 }); + await page.mouse.up(); +} + // ─── App shell ─────────────────────────────────────────────────────────────── test.describe('App shell', () => { @@ -110,72 +121,59 @@ test.describe('App shell', () => { await expect(page.getByTestId('scale-bar')).toContainText('1'); }); - test('toolbar fits within an iPhone SE portrait viewport', async ({ page }) => { - await page.setViewportSize({ width: 320, height: 568 }); - await page.reload(); - const help = page.getByTestId('help-overlay'); - if (await help.isVisible()) { - await page.keyboard.press('Escape'); - } - - const toolbar = page.locator('[role="toolbar"]'); - await expect(toolbar).toBeVisible(); - const box = await toolbar.boundingBox(); - const metrics = await toolbar.evaluate((element) => { - const computed = window.getComputedStyle(element); - return { - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - overflowX: computed.overflowX, - }; + test.describe('iPhone SE portrait viewport', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.reload(); + await dismissHelp(page); }); - expect(box).not.toBeNull(); - expect(box!.x).toBeGreaterThanOrEqual(0); - expect(box!.y).toBeGreaterThanOrEqual(0); - expect(box!.x + box!.width).toBeLessThanOrEqual(320); - expect(box!.y + box!.height).toBeLessThanOrEqual(568); - expect(metrics.overflowX).toBe('auto'); - expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth); - }); - test('fit button sits above the toolbar on an iPhone SE portrait viewport', async ({ page }) => { - await page.setViewportSize({ width: 320, height: 568 }); - await page.reload(); - const help = page.getByTestId('help-overlay'); - if (await help.isVisible()) { - await page.keyboard.press('Escape'); - } - - const fitButton = page.getByTestId('fit-to-content'); - const toolbar = page.locator('[role="toolbar"]'); - await expect(fitButton).toBeVisible(); - await expect(toolbar).toBeVisible(); - - const fitBox = await fitButton.boundingBox(); - const toolbarBox = await toolbar.boundingBox(); - expect(fitBox).not.toBeNull(); - expect(toolbarBox).not.toBeNull(); - expect(fitBox!.y + fitBox!.height).toBeLessThanOrEqual(toolbarBox!.y - 8); - }); - - test('scale bar sits above the toolbar on an iPhone SE portrait viewport', async ({ page }) => { - await page.setViewportSize({ width: 320, height: 568 }); - await page.reload(); - const help = page.getByTestId('help-overlay'); - if (await help.isVisible()) { - await page.keyboard.press('Escape'); - } + test('toolbar fits within an iPhone SE portrait viewport', async ({ page }) => { + const toolbar = page.locator('[role="toolbar"]'); + await expect(toolbar).toBeVisible(); + const box = await toolbar.boundingBox(); + const metrics = await toolbar.evaluate((element) => { + const computed = window.getComputedStyle(element); + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + overflowX: computed.overflowX, + }; + }); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(320); + expect(box!.y + box!.height).toBeLessThanOrEqual(568); + expect(metrics.overflowX).toBe('auto'); + expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth); + }); - const scaleBar = page.getByTestId('scale-bar'); - const toolbar = page.locator('[role="toolbar"]'); - await expect(scaleBar).toBeVisible(); - await expect(toolbar).toBeVisible(); + test('fit button sits above the toolbar on an iPhone SE portrait viewport', async ({ page }) => { + const fitButton = page.getByTestId('fit-to-content'); + const toolbar = page.locator('[role="toolbar"]'); + await expect(fitButton).toBeVisible(); + await expect(toolbar).toBeVisible(); + + const fitBox = await fitButton.boundingBox(); + const toolbarBox = await toolbar.boundingBox(); + expect(fitBox).not.toBeNull(); + expect(toolbarBox).not.toBeNull(); + expect(fitBox!.y + fitBox!.height).toBeLessThanOrEqual(toolbarBox!.y - 8); + }); - const scaleBox = await scaleBar.boundingBox(); - const toolbarBox = await toolbar.boundingBox(); - expect(scaleBox).not.toBeNull(); - expect(toolbarBox).not.toBeNull(); - expect(scaleBox!.y + scaleBox!.height).toBeLessThanOrEqual(toolbarBox!.y - 8); + test('scale bar sits above the toolbar on an iPhone SE portrait viewport', async ({ page }) => { + const scaleBar = page.getByTestId('scale-bar'); + const toolbar = page.locator('[role="toolbar"]'); + await expect(scaleBar).toBeVisible(); + await expect(toolbar).toBeVisible(); + + const scaleBox = await scaleBar.boundingBox(); + const toolbarBox = await toolbar.boundingBox(); + expect(scaleBox).not.toBeNull(); + expect(toolbarBox).not.toBeNull(); + expect(scaleBox!.y + scaleBox!.height).toBeLessThanOrEqual(toolbarBox!.y - 8); + }); }); }); @@ -184,14 +182,12 @@ test.describe('App shell', () => { test.describe('Tool switching', () => { test.beforeEach(({ page }) => setup(page)); - test('toolbar clicks switch tools', async ({ page }) => { + test('toolbar clicks and keyboard shortcuts switch tools', async ({ page }) => { await page.getByTestId('tool-box').click(); await expect(page.getByTestId('tool-box')).toHaveAttribute('aria-pressed', 'true'); await page.getByTestId('tool-select').click(); await expect(page.getByTestId('tool-select')).toHaveAttribute('aria-pressed', 'true'); - }); - test('keyboard shortcuts S/W/B switch tools', async ({ page }) => { await page.keyboard.press('s'); await expect(page.getByTestId('tool-select')).toHaveAttribute('aria-pressed', 'true'); await page.keyboard.press('b'); @@ -256,24 +252,19 @@ test.describe('Floor plan management', () => { test.describe('Box drawing', () => { test.beforeEach(({ page }) => setup(page)); - test('drag creates a box and shows properties panel', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); + let boxCenter: { centerX: number; centerY: number }; + test.beforeEach(async ({ page }) => { + boxCenter = await drawBox(page); await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); - await expect(page.getByTestId('properties-panel')).toBeVisible(); + await page.mouse.click(boxCenter.centerX, boxCenter.centerY); }); - test('properties panel shows Box title for a box', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); + test('selecting a drawn box shows the properties panel with Box title', async ({ page }) => { + await expect(page.getByTestId('properties-panel')).toBeVisible(); await expect(page.getByTestId('properties-panel')).toContainText('Box'); }); test('can edit box width in properties panel', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); const widthInput = page.getByTestId('box-width-input'); await widthInput.fill("10'"); await widthInput.press('Enter'); @@ -281,9 +272,6 @@ test.describe('Box drawing', () => { }); test('can edit box height in properties panel', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); const heightInput = page.getByTestId('box-height-input'); await heightInput.fill("5'"); await heightInput.press('Enter'); @@ -291,9 +279,6 @@ test.describe('Box drawing', () => { }); test('can edit box rotation in properties panel', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); const rotInput = page.getByTestId('box-rotation-input'); await rotInput.fill('45'); await rotInput.press('Enter'); @@ -301,26 +286,17 @@ test.describe('Box drawing', () => { }); test('can delete a box from properties panel', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); await page.getByTestId('delete-element').click(); await expect(page.getByTestId('properties-panel')).not.toBeVisible(); }); test('delete key removes selected box', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); await expect(page.getByTestId('properties-panel')).toBeVisible(); await page.keyboard.press('Delete'); await expect(page.getByTestId('properties-panel')).not.toBeVisible(); }); test('clicking empty canvas deselects', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); - await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); await expect(page.getByTestId('properties-panel')).toBeVisible(); await page.getByTestId('drawing-canvas').click({ position: { x: 40, y: 40 } }); await expect(page.getByTestId('properties-panel')).not.toBeVisible(); @@ -490,6 +466,26 @@ test.describe('Wall drawing', () => { } }); + test('dragging a wall endpoint moves it without crashing', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + await drawWall(page, cx - 80, cy, cx + 80, cy); + await page.getByTestId('tool-select').click(); + await clickWallMidpoint(page); + + const before = await getActivePlanElements(page); + const beforeLen = Math.abs(before[0].points[1].x - before[0].points[0].x); + + // Drag the right endpoint further right + await page.mouse.move(cx + 80, cy); + await page.mouse.down(); + await page.mouse.move(cx + 160, cy, { steps: 10 }); + await page.mouse.up(); + + const after = await getActivePlanElements(page); + const afterLen = Math.abs(after[0].points[1].x - after[0].points[0].x); + expect(afterLen).toBeGreaterThan(beforeLen); + }); + test('can edit wall length in properties panel', async ({ page }) => { const { cx, cy } = await canvasCenter(page); await drawWall(page, cx - 80, cy, cx + 80, cy); @@ -518,32 +514,14 @@ test.describe('Multi-select', () => { test.beforeEach(({ page }) => setup(page)); test('marquee-selecting two boxes shows multi-select bar', async ({ page }) => { - // Draw two boxes side by side - await drawBox(page, -200, -60, 80, 80); - await drawBox(page, 80, -60, 80, 80); - - // Marquee-select both - await page.getByTestId('tool-select').click(); - const { cx, cy } = await canvasCenter(page); - await page.mouse.move(cx - 240, cy - 100); - await page.mouse.down(); - await page.mouse.move(cx + 200, cy + 60, { steps: 10 }); - await page.mouse.up(); + await drawTwoBoxesAndMarqueeSelect(page); await expect(page.getByTestId('multi-select-bar')).toBeVisible(); await expect(page.getByTestId('multi-select-bar')).toContainText('2'); }); test('Delete all removes all selected elements', async ({ page }) => { - await drawBox(page, -200, -60, 80, 80); - await drawBox(page, 80, -60, 80, 80); - - await page.getByTestId('tool-select').click(); - const { cx, cy } = await canvasCenter(page); - await page.mouse.move(cx - 240, cy - 100); - await page.mouse.down(); - await page.mouse.move(cx + 200, cy + 60, { steps: 10 }); - await page.mouse.up(); + await drawTwoBoxesAndMarqueeSelect(page); await expect(page.getByTestId('multi-select-bar')).toBeVisible(); await page.getByTestId('delete-selected').click(); @@ -707,7 +685,7 @@ test.describe('Undo and Redo', () => { test.describe('Arrow key movement', () => { test.beforeEach(({ page }) => setup(page)); - test('arrow keys move a selected box', async ({ page }) => { + test('arrow keys move a selected box and movement is undoable', async ({ page }) => { const { centerX, centerY } = await drawBox(page); await page.getByTestId('tool-select').click(); await page.mouse.click(centerX, centerY); @@ -716,14 +694,6 @@ test.describe('Arrow key movement', () => { // Press right arrow — box should still be selected (not deselected) await page.keyboard.press('ArrowRight'); await expect(page.getByTestId('properties-panel')).toBeVisible(); - }); - - test('arrow movement is undoable', async ({ page }) => { - await drawBox(page); - await page.getByTestId('tool-select').click(); - const { cx, cy } = await canvasCenter(page); - await page.mouse.click(cx + 80, cy + 60); - await page.keyboard.press('ArrowRight'); // Two undo steps: one for the move, one for the draw await page.keyboard.press('Meta+z'); @@ -759,24 +729,20 @@ test.describe('Measure tool', () => { await expect(page.getByTestId('tool-measure')).toBeVisible(); }); - test('Escape cancels an in-progress measurement', async ({ page }) => { + test('Escape cancels or clears a measurement', async ({ page }) => { const { cx, cy } = await canvasCenter(page); await page.getByTestId('tool-measure').click(); + // Cancel in-progress measurement await page.mouse.click(cx, cy); await page.keyboard.press('Escape'); // After Escape, a fresh click starts a new measurement without error await page.mouse.click(cx + 50, cy + 50); await expect(page.getByTestId('tool-measure')).toBeVisible(); - }); - test('Escape after completed measurement clears it', async ({ page }) => { - const { cx, cy } = await canvasCenter(page); - await page.getByTestId('tool-measure').click(); - - await page.mouse.click(cx, cy); - await page.mouse.click(cx + 200, cy); + // Complete a measurement and Escape it + await page.mouse.click(cx + 200, cy + 50); await page.keyboard.press('Escape'); // App still functional @@ -800,19 +766,19 @@ test.describe('Measure tool', () => { test.describe('Canvas keyboard shortcuts', () => { test.beforeEach(({ page }) => setup(page)); - test('Backspace key deletes selected element', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); + test('Backspace deletes and Escape clears selection', async ({ page }) => { + // Draw a box, select it, press Backspace — panel should hide (element deleted) + const { centerX: cx1, centerY: cy1 } = await drawBox(page); await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); + await page.mouse.click(cx1, cy1); await expect(page.getByTestId('properties-panel')).toBeVisible(); await page.keyboard.press('Backspace'); await expect(page.getByTestId('properties-panel')).not.toBeVisible(); - }); - test('Escape clears selection', async ({ page }) => { - const { centerX, centerY } = await drawBox(page); + // Draw another box, select it, press Escape — panel should hide (deselected) + const { centerX: cx2, centerY: cy2 } = await drawBox(page); await page.getByTestId('tool-select').click(); - await page.mouse.click(centerX, centerY); + await page.mouse.click(cx2, cy2); await expect(page.getByTestId('properties-panel')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.getByTestId('properties-panel')).not.toBeVisible(); @@ -1114,10 +1080,223 @@ test.describe('Touch gestures', () => { const elementsBefore = await getActivePlanElements(page); await simulatePinch(page, { midX: cx, midY: cy, startRadius: 40, endRadius: 120 }); - // Wait a tick for any deferred state updates - await page.waitForTimeout(100); - const elementsAfter = await getActivePlanElements(page); - expect(elementsAfter.length).toBe(elementsBefore.length); + await expect.poll(() => getActivePlanElements(page)).toHaveLength(elementsBefore.length); + }); +}); + +async function simulateTwist( + page: Page, + opts: { midX: number; midY: number; radius: number; endAngle: number; steps?: number }, +) { + const { midX, midY, radius, endAngle, steps = 8 } = opts; + + await page.evaluate( + ({ midX, midY, radius, endAngle, steps }) => { + const target = document.querySelector('[data-testid="drawing-canvas"]') as HTMLElement; + if (!target) throw new Error('canvas not found'); + + function makeTouch(id: number, x: number, y: number): Touch { + return new Touch({ identifier: id, target, clientX: x, clientY: y, pageX: x, pageY: y }); + } + + function fire(type: string, t1: Touch, t2: Touch) { + const evt = new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [t1, t2], + changedTouches: [t1, t2], + targetTouches: type === 'touchend' ? [] : [t1, t2], + }); + target.dispatchEvent(evt); + } + + // Start: fingers horizontal (angle 0) + fire( + 'touchstart', + makeTouch(1, midX - radius, midY), + makeTouch(2, midX + radius, midY), + ); + + // Rotate from 0 to endAngle + for (let i = 1; i <= steps; i++) { + const angleRad = ((endAngle * i) / steps) * (Math.PI / 180); + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + fire( + 'touchmove', + makeTouch(1, midX - radius * cos, midY - radius * sin), + makeTouch(2, midX + radius * cos, midY + radius * sin), + ); + } + + // touchend + const finalRad = endAngle * (Math.PI / 180); + fire( + 'touchend', + makeTouch(1, midX - radius * Math.cos(finalRad), midY - radius * Math.sin(finalRad)), + makeTouch(2, midX + radius * Math.cos(finalRad), midY + radius * Math.sin(finalRad)), + ); + }, + { midX, midY, radius, endAngle, steps }, + ); +} + +test.describe('Mobile selection UX', () => { + test.beforeEach(async ({ page }) => { + await setup(page); + await page.setViewportSize({ width: 375, height: 812 }); + }); + + test('tapping a box on mobile shows mobile selection bar instead of properties panel', async ({ + page, + }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + await expect(page.getByTestId('mobile-selection-bar')).toBeVisible(); + await expect(page.getByTestId('properties-panel')).not.toBeVisible(); + }); + + test('toolbar hides when selection bar is visible and reappears on deselect', async ({ + page, + }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + const { box: canvasBox } = await canvasCenter(page); + + // Select box — toolbar should hide + await page.mouse.click(centerX, centerY); + await expect(page.getByTestId('mobile-selection-bar')).toBeVisible(); + await expect(page.getByTestId('drawing-toolbar')).toBeHidden(); + + // Click empty upper-left corner of canvas (well away from the box) to deselect + await page.mouse.click(canvasBox.x + 20, canvasBox.y + 20); + await expect(page.getByTestId('mobile-selection-bar')).not.toBeVisible(); + await expect(page.getByTestId('drawing-toolbar')).toBeVisible(); + }); + + test('tapping Edit opens properties panel and bar switches to Cancel/Done mode', async ({ + page, + }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + await page.getByTestId('mobile-selection-edit').click(); + await expect(page.getByTestId('properties-panel')).toBeVisible(); + await expect(page.getByTestId('mobile-selection-bar')).toBeVisible(); + await expect(page.getByTestId('mobile-selection-cancel')).toBeVisible(); + await expect(page.getByTestId('mobile-selection-done')).toBeVisible(); + }); + + test('tapping Done closes properties panel and returns to selection bar', async ({ page }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + await page.getByTestId('mobile-selection-edit').click(); + await page.getByTestId('mobile-selection-done').click(); + await expect(page.getByTestId('properties-panel')).not.toBeVisible(); + await expect(page.getByTestId('mobile-selection-edit')).toBeVisible(); + }); + + test('tapping Cancel reverts property changes and closes the panel', async ({ page }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + await page.getByTestId('mobile-selection-edit').click(); + + // Change the width + const widthInput = page.getByTestId('box-width-input'); + const originalWidth = await widthInput.inputValue(); + await widthInput.fill("20'"); + await widthInput.press('Enter'); + expect(await widthInput.inputValue()).toBe("20'"); + + // Cancel — width should revert + await page.getByTestId('mobile-selection-cancel').click(); + await expect(page.getByTestId('properties-panel')).not.toBeVisible(); + await expect(page.getByTestId('mobile-selection-edit')).toBeVisible(); + + // Re-open panel and confirm value was restored + await page.getByTestId('mobile-selection-edit').click(); + await expect(page.getByTestId('box-width-input')).toHaveValue(originalWidth); + }); + + test('tapping Delete in the mobile selection bar removes the element', async ({ page }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + await page.getByTestId('mobile-selection-delete').click(); + const elements = await getActivePlanElements(page); + expect(elements).toHaveLength(0); + }); + + test('undo is enabled after drawing and redo is disabled until after an undo', async ({ + page, + }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + await expect(page.getByTestId('mobile-selection-undo')).toBeEnabled(); + await expect(page.getByTestId('mobile-selection-redo')).toBeDisabled(); + }); + + test('undo and redo buttons work for rotation without leaving the bar', async ({ page }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + + // Rotate — box stays selected, bar stays visible + await simulateTwist(page, { midX: centerX, midY: centerY, radius: 60, endAngle: 90 }); + await expect.poll(async () => (await getActivePlanElements(page))[0]?.rotation).toBe(90); + + // Undo rotation via bar button — box still selected, redo now available + await page.getByTestId('mobile-selection-undo').click(); + await expect.poll(async () => (await getActivePlanElements(page))[0]?.rotation).toBe(0); + await expect(page.getByTestId('mobile-selection-redo')).toBeEnabled(); + + // Redo rotation via bar button + await page.getByTestId('mobile-selection-redo').click(); + await expect.poll(async () => (await getActivePlanElements(page))[0]?.rotation).toBe(90); + }); +}); + +test.describe('Two-finger rotation', () => { + test.beforeEach(async ({ page }) => { + await setup(page); + await page.setViewportSize({ width: 375, height: 812 }); + }); + + test('two-finger twist rotates the selected box', async ({ page }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + + const before = await getActivePlanElements(page); + expect(before[0].rotation).toBe(0); + + await simulateTwist(page, { midX: centerX, midY: centerY, radius: 60, endAngle: 90 }); + await expect.poll(async () => (await getActivePlanElements(page))[0]?.rotation).toBe(90); + }); + + test('two-finger twist rotation undoes in a single step', async ({ page }) => { + const { centerX, centerY } = await drawBox(page); + await page.getByTestId('tool-select').click(); + await page.mouse.click(centerX, centerY); + + await simulateTwist(page, { midX: centerX, midY: centerY, radius: 60, endAngle: 90 }); + await expect.poll(async () => (await getActivePlanElements(page))[0]?.rotation).toBe(90); + + // One undo step should restore original rotation + await page.keyboard.press('Meta+z'); + await expect.poll(async () => (await getActivePlanElements(page))[0]?.rotation).toBe(0); + }); + + test('two-finger pinch-zoom still works when no box is selected', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + const before = await getCanvasZoom(page); + await simulatePinch(page, { midX: cx, midY: cy, startRadius: 40, endRadius: 120 }); + const after = await getCanvasZoom(page); + expect(after).toBeGreaterThan(before); }); }); diff --git a/index.html b/index.html index b3be874..bb81927 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ + diff --git a/src/App.tsx b/src/App.tsx index f944aef..a7abc80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { PropertiesPanel } from './components/PropertiesPanel/PropertiesPanel'; import { HelpOverlay } from './components/HelpOverlay/HelpOverlay'; import { ScaleBar } from './components/Canvas/ScaleBar/ScaleBar'; import { MultiSelectBar } from './components/Canvas/MultiSelectBar/MultiSelectBar'; +import { MobileSelectionBar } from './components/Canvas/MobileSelectionBar/MobileSelectionBar'; import { useFloorplanStore } from './store/useFloorplanStore/useFloorplanStore'; import { decodePlanFromUrl } from './utils/storage/storage'; import styles from './App.module.css'; @@ -65,6 +66,7 @@ export default function App() { + {showHelp && setShowHelp(false)} />} ); diff --git a/src/components/Canvas/BoxElement/BoxElement.tsx b/src/components/Canvas/BoxElement/BoxElement.tsx index 524ad08..034fa9c 100644 --- a/src/components/Canvas/BoxElement/BoxElement.tsx +++ b/src/components/Canvas/BoxElement/BoxElement.tsx @@ -132,7 +132,7 @@ export function BoxElement({ box, selected, onSelect, onGroupDrag }: Props) { onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd} - onClick={(e) => onSelect(Boolean(e.evt.shiftKey))} + onClick={(e) => onSelect(Boolean(e.evt?.shiftKey))} onTap={() => onSelect(false)} > (null); const isTwoFingerActiveRef = useRef(false); @@ -189,17 +193,50 @@ export function DrawingCanvas() { }; } + function getTouchAngle(touches: TouchList) { + const t1 = touches[0]; + const t2 = touches[1]; + return Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX) * (180 / Math.PI); + } + function handleTouchStart(e: TouchEvent) { if (e.touches.length === 2) { isTwoFingerActiveRef.current = true; const t1 = e.touches[0]; const t2 = e.touches[1]; const { zoom: currentZoom, pan: currentPan } = useToolStore.getState(); + + // Find selected box for rotation + const toolState = useToolStore.getState(); + const selIds = toolState.selectedIds; + let selectedBoxId: string | null = null; + let initialBoxRotation = 0; + if (selIds.size === 1) { + const [id] = selIds; + const el = useFloorplanStore + .getState() + .activePlan() + ?.elements.find((e) => e.id === id); + if (el?.type === 'box') { + selectedBoxId = id; + initialBoxRotation = el.rotation; + useFloorplanStore.getState().snapshotForUndo(); + } + } + + // Hide the properties panel during the gesture + const panelWasOpen = toolState.propertiesPanelOpen; + useToolStore.getState().setPropertiesPanelOpen(false); + touchGestureRef.current = { initialDist: getTouchDist(t1, t2), initialZoom: currentZoom, initialPan: { ...currentPan }, initialMidpoint: getTouchMidpoint(t1, t2), + initialAngle: getTouchAngle(e.touches), + selectedBoxId, + initialBoxRotation, + panelWasOpen, }; // Cancel any in-progress single-finger action setPointerDown(null); @@ -213,7 +250,15 @@ export function DrawingCanvas() { e.preventDefault(); const t1 = e.touches[0]; const t2 = e.touches[1]; - const { initialDist, initialZoom, initialPan, initialMidpoint } = touchGestureRef.current; + const { + initialDist, + initialZoom, + initialPan, + initialMidpoint, + initialAngle, + selectedBoxId, + initialBoxRotation, + } = touchGestureRef.current; const currentDist = getTouchDist(t1, t2); const currentMidpoint = getTouchMidpoint(t1, t2); @@ -234,11 +279,22 @@ export function DrawingCanvas() { y: currentMidpoint.y - worldUnderMidpoint.y * newZoom, }, }); + + // Apply rotation to selected box + if (selectedBoxId !== null) { + const deltaAngle = getTouchAngle(e.touches) - initialAngle; + const raw = initialBoxRotation + deltaAngle; + const snapped = Math.round((((raw % 360) + 360) % 360) / 5) * 5; + useFloorplanStore.getState().updateElementSilent(selectedBoxId, { rotation: snapped }); + } } } function handleTouchEnd(e: TouchEvent) { if (e.touches.length < 2) { + if (touchGestureRef.current) { + useToolStore.getState().setPropertiesPanelOpen(touchGestureRef.current.panelWasOpen); + } touchGestureRef.current = null; // Brief delay so the finger-lift pointer event doesn't trigger a tap setTimeout(() => { @@ -403,7 +459,7 @@ export function DrawingCanvas() { setCursorSnappedToAxis(false); } else { const gridIncrement = - activeTool === 'wall' ? getWallSnapIncrement(Boolean(e.evt.shiftKey)) : undefined; + activeTool === 'wall' ? getWallSnapIncrement(Boolean(e.evt?.shiftKey)) : undefined; const { point, snappedToEndpoint, snappedToSegment, snappedToAxis } = snapWithInfo( world, gridIncrement, @@ -432,7 +488,7 @@ export function DrawingCanvas() { const dragDist = distance(pointerDown.pos, world); const isDrag = dragDist > DRAG_THRESHOLD_FT; - const wallSnapIncrement = getWallSnapIncrement(Boolean(e.evt.shiftKey)); + const wallSnapIncrement = getWallSnapIncrement(Boolean(e.evt?.shiftKey)); const snappedEnd = activeTool === 'wall' ? snap(world, wallSnapIncrement) : snap(world); if (activeTool === 'measure') { @@ -481,6 +537,9 @@ export function DrawingCanvas() { const rh = Math.abs(marquee.end.y - marquee.start.y); const hit = elements.filter((el) => elementOverlapsRect(el, rx, ry, rw, rh)); setSelectedIds(new Set(hit.map((el) => el.id))); + if (hit.length === 1 && !shouldUseMobileOverlayLayout(window.innerWidth)) { + useToolStore.getState().setPropertiesPanelOpen(true); + } } else if (isCanvasBackgroundTarget(e.target)) { clearSelection(); } @@ -801,10 +860,18 @@ export function DrawingCanvas() { onSelect={(extendSelection) => { if (activeTool !== 'select') return; const toolStore = useToolStore.getState(); + const isMobile = shouldUseMobileOverlayLayout(window.innerWidth); if (extendSelection) { toolStore.toggleSelectedId(el.id); + const single = useToolStore.getState().selectedIds.size === 1; + toolStore.setPropertiesPanelOpen(!isMobile && single); } else { + const alreadySelected = + toolStore.selectedIds.has(el.id) && toolStore.selectedIds.size === 1; toolStore.setSelectedId(el.id); + if (!isMobile || alreadySelected) { + toolStore.setPropertiesPanelOpen(true); + } } }} onGroupDrag={handleGroupDrag} @@ -817,10 +884,18 @@ export function DrawingCanvas() { onSelect={(extendSelection) => { if (activeTool !== 'select') return; const toolStore = useToolStore.getState(); + const isMobile = shouldUseMobileOverlayLayout(window.innerWidth); if (extendSelection) { toolStore.toggleSelectedId(el.id); + const single = useToolStore.getState().selectedIds.size === 1; + toolStore.setPropertiesPanelOpen(!isMobile && single); } else { + const alreadySelected = + toolStore.selectedIds.has(el.id) && toolStore.selectedIds.size === 1; toolStore.setSelectedId(el.id); + if (!isMobile || alreadySelected) { + toolStore.setPropertiesPanelOpen(true); + } } }} onGroupDrag={handleGroupDrag} diff --git a/src/components/Canvas/MobileSelectionBar/MobileSelectionBar.module.css b/src/components/Canvas/MobileSelectionBar/MobileSelectionBar.module.css new file mode 100644 index 0000000..ac755f2 --- /dev/null +++ b/src/components/Canvas/MobileSelectionBar/MobileSelectionBar.module.css @@ -0,0 +1,98 @@ +.bar { + position: fixed; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border: 1px solid #c8c4bc; + border-radius: 14px; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + font-family: 'Courier New', monospace; + z-index: 150; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12); + user-select: none; +} + +.btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + background: transparent; + border: none; + border-radius: 10px; + color: #262626; + font-family: inherit; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 10px 14px; + cursor: pointer; + min-height: 60px; + min-width: 60px; + touch-action: manipulation; + transition: background 0.15s, color 0.15s; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; +} + +.icon svg { + width: 100%; + height: 100%; +} + +.btn:hover { + background: #f0ede8; +} + +.btn:disabled { + opacity: 0.35; + cursor: default; +} + +.btn:disabled:hover { + background: transparent; +} + +.btn:focus-visible { + outline: 2px solid #4a6fa5; + outline-offset: 2px; +} + +.editBtn { + composes: btn; +} + +.deleteBtn { + composes: btn; + color: #b83333; +} + +.deleteBtn:hover { + background: #fce8e8; +} + +.cancelBtn { + composes: btn; +} + +.doneBtn { + composes: btn; + color: #2a7a4a; +} + +.doneBtn:hover { + background: #e8f5ee; +} diff --git a/src/components/Canvas/MobileSelectionBar/MobileSelectionBar.tsx b/src/components/Canvas/MobileSelectionBar/MobileSelectionBar.tsx new file mode 100644 index 0000000..9683770 --- /dev/null +++ b/src/components/Canvas/MobileSelectionBar/MobileSelectionBar.tsx @@ -0,0 +1,132 @@ +import { useRef } from 'react'; +import { Undo2, Redo2, SlidersHorizontal, Trash2, Check, X } from 'lucide-react'; +import { useToolStore } from '../../../store/useToolStore/useToolStore'; +import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore'; +import { shouldUseMobileOverlayLayout } from '../layout'; +import type { Element } from '../../../types'; +import styles from './MobileSelectionBar.module.css'; + +export function MobileSelectionBar() { + const { selectedIds, selectedId, clearSelection, setPropertiesPanelOpen, propertiesPanelOpen } = + useToolStore(); + const { activePlan, deleteElements, undo, redo, past, future, updateElementSilent } = + useFloorplanStore(); + + const editSnapshotRef = useRef(null); + + const isMobile = shouldUseMobileOverlayLayout(window.innerWidth); + if (!isMobile || selectedIds.size !== 1) return null; + + const plan = activePlan(); + const element = plan?.elements.find((el) => el.id === selectedId) ?? null; + if (!element) return null; + + function handleEdit() { + editSnapshotRef.current = { ...element } as Element; + setPropertiesPanelOpen(true); + } + + function handleCancel() { + const snapshot = editSnapshotRef.current; + if (snapshot) { + updateElementSilent(snapshot.id, snapshot); + editSnapshotRef.current = null; + } + setPropertiesPanelOpen(false); + } + + function handleDelete() { + deleteElements(selectedIds); + clearSelection(); + } + + if (propertiesPanelOpen) { + return ( + + + + + + Cancel + + setPropertiesPanelOpen(false)} + aria-label="Done editing" + data-testid="mobile-selection-done" + > + + + + Done + + + ); + } + + return ( + + + + + + Undo + + + + + + Redo + + + + + + Edit + + + + + + Delete + + + ); +} diff --git a/src/components/Canvas/WallElement/WallElement.tsx b/src/components/Canvas/WallElement/WallElement.tsx index b9f4edd..9d2b8b7 100644 --- a/src/components/Canvas/WallElement/WallElement.tsx +++ b/src/components/Canvas/WallElement/WallElement.tsx @@ -171,7 +171,7 @@ export function WallElement({ wall, selected, onSelect, onGroupDrag, onEndpointD onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd} - onClick={(e) => onSelect(Boolean(e.evt.shiftKey))} + onClick={(e) => onSelect(Boolean(e.evt?.shiftKey))} onTap={() => onSelect(false)} hitStrokeWidth={16 / zoom} data-testid={`wall-${wall.id}`} @@ -225,7 +225,7 @@ export function WallElement({ wall, selected, onSelect, onGroupDrag, onEndpointD const rawFt = { x: pxToFt(node.x()), y: pxToFt(node.y()) }; const otherEndpoints = getOtherEndpoints(targetIdx); const nearest = findNearestEndpoint(rawFt, otherEndpoints); - const snapIncrement = getWallSnapIncrement(Boolean(e.evt.shiftKey)); + const snapIncrement = getWallSnapIncrement(Boolean(e.evt?.shiftKey)); setEndpointSnapTarget(nearest ?? null); const snappedPos = nearest ?? { x: snapToGrid(rawFt.x, snapIncrement), @@ -253,7 +253,7 @@ export function WallElement({ wall, selected, onSelect, onGroupDrag, onEndpointD // If connected, redirect the resize to the OTHER (free) endpoint const resolvedIdx = connected ? (idx === 0 ? wall.points.length - 1 : 0) : idx; const otherEndpoints = getOtherEndpoints(resolvedIdx); - const snapIncrement = getWallSnapIncrement(Boolean(e.evt.shiftKey)); + const snapIncrement = getWallSnapIncrement(Boolean(e.evt?.shiftKey)); const snapped = findNearestEndpoint(rawFt, otherEndpoints) ?? { x: snapToGrid(rawFt.x, snapIncrement), diff --git a/src/components/PropertiesPanel/PropertiesPanel.tsx b/src/components/PropertiesPanel/PropertiesPanel.tsx index 4ebc25c..b33f163 100644 --- a/src/components/PropertiesPanel/PropertiesPanel.tsx +++ b/src/components/PropertiesPanel/PropertiesPanel.tsx @@ -3,10 +3,19 @@ import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanSto import { useToolStore } from '../../store/useToolStore/useToolStore'; import type { Box, Element, Wall } from '../../types'; import { collectConnectedWallIds, formatFeet } from '../../utils/geometry/geometry'; +import { shouldUseMobileOverlayLayout } from '../Canvas/layout'; import { FtInInput } from './FtInInput/FtInInput'; import styles from './PropertiesPanel.module.css'; -function WallProperties({ wall, onDelete }: { wall: Wall; onDelete: () => void }) { +function WallProperties({ + wall, + onDelete, + showDelete, +}: { + wall: Wall; + onDelete: () => void; + showDelete: boolean; +}) { const { updateElements, activePlan } = useFloorplanStore(); const allElements = activePlan()?.elements ?? []; @@ -71,19 +80,29 @@ function WallProperties({ wall, onDelete }: { wall: Wall; onDelete: () => void } testId={isSimple ? 'wall-length-input' : `wall-segment-${i}-input`} /> ))} - - Delete - + {showDelete && ( + + Delete + + )} ); } -function BoxProperties({ box, onDelete }: { box: Box; onDelete: () => void }) { +function BoxProperties({ + box, + onDelete, + showDelete, +}: { + box: Box; + onDelete: () => void; + showDelete: boolean; +}) { const { updateElement } = useFloorplanStore(); const [rotationDraft, setRotationDraft] = useState(null); const [labelDraft, setLabelDraft] = useState(null); @@ -150,21 +169,23 @@ function BoxProperties({ box, onDelete }: { box: Box; onDelete: () => void }) { data-testid="box-label-input" /> - - Delete - + {showDelete && ( + + Delete + + )} ); } export function PropertiesPanel() { const { activePlan, deleteElement } = useFloorplanStore(); - const { selectedId, selectedIds, setSelectedId } = useToolStore(); + const { selectedId, selectedIds, setSelectedId, propertiesPanelOpen } = useToolStore(); const plan = activePlan(); const element = @@ -176,7 +197,9 @@ export function PropertiesPanel() { setSelectedId(null); } - if (!element) return null; + if (!element || !propertiesPanelOpen) return null; + + const showDelete = !shouldUseMobileOverlayLayout(window.innerWidth); return ( {element.type === 'wall' ? 'Wall' : 'Box'} {element.type === 'wall' ? ( - + ) : ( - + )} ); diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index 6d03d9c..e11bffb 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -10,6 +10,7 @@ import { } from 'lucide-react'; import { useToolStore } from '../../store/useToolStore/useToolStore'; import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore'; +import { shouldUseMobileOverlayLayout } from '../Canvas/layout'; import type { ToolType } from '../../types'; import styles from './Toolbar.module.css'; @@ -25,9 +26,12 @@ type Props = { }; export function Toolbar({ onHelpOpen }: Props) { - const { activeTool, setActiveTool } = useToolStore(); + const { activeTool, setActiveTool, selectedIds } = useToolStore(); const { undo, redo, past, future } = useFloorplanStore(); + const selectionBarVisible = + shouldUseMobileOverlayLayout(window.innerWidth) && selectedIds.size === 1; + useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement) return; @@ -41,7 +45,13 @@ export function Toolbar({ onHelpOpen }: Props) { }, [setActiveTool]); return ( - + {TOOLS.map((tool) => ( { }); }); +describe('updateElementSilent', () => { + it('updates the element without pushing to undo history', () => { + useFloorplanStore.getState().addElement(box()); + const pastBefore = useFloorplanStore.getState().past.length; + useFloorplanStore.getState().updateElementSilent('b1', { rotation: 45 }); + const el = getElements()[0] as Extract; + expect(el.rotation).toBe(45); + expect(useFloorplanStore.getState().past.length).toBe(pastBefore); // no new history entry + }); + + it('does not clear future history', () => { + useFloorplanStore.getState().addElement(box()); + useFloorplanStore.getState().updateElement('b1', { rotation: 10 }); + useFloorplanStore.getState().undo(); + const futureLength = useFloorplanStore.getState().future.length; + expect(futureLength).toBeGreaterThan(0); + useFloorplanStore.getState().updateElementSilent('b1', { rotation: 20 }); + expect(useFloorplanStore.getState().future.length).toBe(futureLength); // future unchanged + }); +}); + +describe('snapshotForUndo', () => { + it('pushes current elements to past without changing elements', () => { + useFloorplanStore.getState().addElement(box()); + const elementsBefore = getElements(); + const pastLengthBefore = useFloorplanStore.getState().past.length; + useFloorplanStore.getState().snapshotForUndo(); + expect(useFloorplanStore.getState().past.length).toBe(pastLengthBefore + 1); + expect(getElements()).toEqual(elementsBefore); // elements unchanged + }); + + it('clears future when called', () => { + useFloorplanStore.getState().addElement(box()); + useFloorplanStore.getState().updateElement('b1', { rotation: 10 }); + useFloorplanStore.getState().undo(); + expect(useFloorplanStore.getState().future.length).toBeGreaterThan(0); + useFloorplanStore.getState().snapshotForUndo(); + expect(useFloorplanStore.getState().future.length).toBe(0); + }); + + it('is a no-op on empty plan', () => { + const pastBefore = useFloorplanStore.getState().past.length; + useFloorplanStore.getState().snapshotForUndo(); + expect(useFloorplanStore.getState().past.length).toBe(pastBefore); + }); + + it('snapshot + silent updates = single undo step', () => { + useFloorplanStore.getState().addElement(box()); + useFloorplanStore.getState().snapshotForUndo(); + useFloorplanStore.getState().updateElementSilent('b1', { rotation: 30 }); + useFloorplanStore.getState().updateElementSilent('b1', { rotation: 60 }); + useFloorplanStore.getState().updateElementSilent('b1', { rotation: 90 }); + expect((getElements()[0] as Extract).rotation).toBe(90); + useFloorplanStore.getState().undo(); + expect((getElements()[0] as Extract).rotation).toBe(0); // back to original + }); +}); + describe('deleteElement', () => { it('removes the element', () => { useFloorplanStore.getState().addElement(wall()); diff --git a/src/store/useFloorplanStore/useFloorplanStore.ts b/src/store/useFloorplanStore/useFloorplanStore.ts index 2c617c5..44da994 100644 --- a/src/store/useFloorplanStore/useFloorplanStore.ts +++ b/src/store/useFloorplanStore/useFloorplanStore.ts @@ -32,10 +32,14 @@ type FloorplanStore = { // Element management addElement: (element: Element) => void; updateElement: (id: string, updates: Partial) => void; + updateElementSilent: (id: string, updates: Partial) => void; updateElements: (updates: Record>) => void; deleteElement: (id: string) => void; deleteElements: (ids: Iterable) => void; + // Undo snapshot without changing elements (use before a silent-update gesture) + snapshotForUndo: () => void; + // Undo / redo undo: () => void; redo: () => void; @@ -208,6 +212,23 @@ export const useFloorplanStore = create((set, get) => ({ }); }, + updateElementSilent: (id, updates) => { + set((state) => { + const current = state.plans.find((p) => p.id === state.activeId)?.elements ?? []; + const newElements = current.map((el) => + el.id === id ? ({ ...el, ...updates } as Element) : el, + ); + if (sameElements(current, newElements)) return {}; + const plans = state.plans.map((p) => + p.id === state.activeId + ? { ...p, elements: cloneElements(newElements), updatedAt: new Date().toISOString() } + : p, + ); + persist(plans, state.activeId); + return { plans }; + }); + }, + updateElements: (updates) => { set((state) => { const current = state.plans.find((p) => p.id === state.activeId)?.elements ?? []; @@ -240,6 +261,15 @@ export const useFloorplanStore = create((set, get) => ({ }); }, + snapshotForUndo: () => { + set((state) => { + const current = state.plans.find((p) => p.id === state.activeId)?.elements ?? []; + if (current.length === 0) return {}; + const past = [...state.past.slice(-(MAX_HISTORY - 1)), cloneElements(current)]; + return { past, future: [] }; + }); + }, + undo: () => { set((state) => { if (state.past.length === 0) return {}; diff --git a/src/store/useToolStore/useToolStore.test.ts b/src/store/useToolStore/useToolStore.test.ts index b5f2578..a643ca4 100644 --- a/src/store/useToolStore/useToolStore.test.ts +++ b/src/store/useToolStore/useToolStore.test.ts @@ -13,6 +13,7 @@ beforeEach(() => { measureEnd: null, zoom: 1, pan: { x: 0, y: 0 }, + propertiesPanelOpen: false, }); }); @@ -142,6 +143,31 @@ describe('selection', () => { }); }); +describe('propertiesPanelOpen', () => { + it('defaults to false', () => { + expect(useToolStore.getState().propertiesPanelOpen).toBe(false); + }); + + it('setPropertiesPanelOpen toggles the flag', () => { + useToolStore.getState().setPropertiesPanelOpen(true); + expect(useToolStore.getState().propertiesPanelOpen).toBe(true); + useToolStore.getState().setPropertiesPanelOpen(false); + expect(useToolStore.getState().propertiesPanelOpen).toBe(false); + }); + + it('clearSelection resets propertiesPanelOpen to false', () => { + useToolStore.getState().setPropertiesPanelOpen(true); + useToolStore.getState().clearSelection(); + expect(useToolStore.getState().propertiesPanelOpen).toBe(false); + }); + + it('setActiveTool resets propertiesPanelOpen to false', () => { + useToolStore.getState().setPropertiesPanelOpen(true); + useToolStore.getState().setActiveTool('box'); + expect(useToolStore.getState().propertiesPanelOpen).toBe(false); + }); +}); + describe('zoom and pan', () => { it('setZoom updates the zoom level', () => { useToolStore.getState().setZoom(2.5); diff --git a/src/store/useToolStore/useToolStore.ts b/src/store/useToolStore/useToolStore.ts index 64fd810..59b0036 100644 --- a/src/store/useToolStore/useToolStore.ts +++ b/src/store/useToolStore/useToolStore.ts @@ -28,6 +28,10 @@ type ToolStore = { completeMeasurement: (pt: Point) => void; clearMeasurement: () => void; + // Properties panel visibility — controlled explicitly so mobile can keep canvas clear + propertiesPanelOpen: boolean; + setPropertiesPanelOpen: (v: boolean) => void; + // View — zoom is a multiplier (1 = default, 2 = 2x zoom in) zoom: number; setZoom: (zoom: number) => void; @@ -46,6 +50,7 @@ export const useToolStore = create((set) => ({ isChainArmed: false, measureStart: null, measureEnd: null, + propertiesPanelOpen: false, }), chainPoints: [], @@ -85,7 +90,8 @@ export const useToolStore = create((set) => ({ selectedId: selectedIds.size === 1 ? [...selectedIds][0] : null, }), - clearSelection: () => set({ selectedId: null, selectedIds: new Set() }), + clearSelection: () => + set({ selectedId: null, selectedIds: new Set(), propertiesPanelOpen: false }), measureStart: null, measureEnd: null, @@ -95,6 +101,9 @@ export const useToolStore = create((set) => ({ completeMeasurement: (measureEnd) => set({ measureEnd }), clearMeasurement: () => set({ measureStart: null, measureEnd: null }), + propertiesPanelOpen: false, + setPropertiesPanelOpen: (propertiesPanelOpen) => set({ propertiesPanelOpen }), + zoom: 1, setZoom: (zoom) => set({ zoom }), pan: { x: 0, y: 0 }, diff --git a/vite.config.ts b/vite.config.ts index fc5e775..38ede29 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,9 @@ export default defineConfig({ }, }), ], + build: { + chunkSizeWarningLimit: 600, + }, test: { environment: 'jsdom', globals: true,