diff --git a/AGENTS.md b/AGENTS.md index 419d25ef1..0f689a113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,12 +65,11 @@ The canvas (`Canvas.tsx`) positions nodes using CSS transforms. Panel positions Panel definitions are centralised in `src/shared/panels.ts`. The detachable panel types (`PanelType` in `src/shared/types.ts`) are: terminal, browser, editor, -canvas, agent, document, review. Renderer components live in `src/renderer/panels/`: -- **EditorPanel** — Monaco Editor with integrated Files and Search navigation +canvas, agent, review. Renderer components live in `src/renderer/panels/`: +- **EditorPanel** — Files with Monaco editing, image/PDF/DOCX previews, and integrated Files and Search navigation - **TerminalPanel** — xterm.js terminal with WebGL renderer, backed by node-pty - **BrowserPanel** — embedded webview (file:// allowed for local HTML) - **CanvasPanel** — nested canvas -- **DocumentPanel** — PDF / docx / image preview - **AgentPanel** — Codex agent thread (sidebar + dock) The file tree and Search are hosted by EditorPanel. Source Control and Pull diff --git a/docs/dock-rules.md b/docs/dock-rules.md new file mode 100644 index 000000000..0b85b202e --- /dev/null +++ b/docs/dock-rules.md @@ -0,0 +1,55 @@ +# Dock behavior and test matrix + +This document defines the supported dock state transitions. “Maximize” means a +reversible presentation: a main-dock split is merged into tabs, or one pane of a +canvas node is promoted beside its containing canvas. “Minimize” means restoring +that saved layout. A presentation is restorable only while both its destination +and, for a promoted canvas pane, its source topology remain unchanged. + +## Drag and placement rules + +| Source | Drop target | Result | Automated coverage | +|---|---|---|---| +| Dock tab | Same stack tab bar | Reorder tabs; a one-tab self-drop is a no-op | `dockStore.rules.test.ts`, `drag/resolve.test.ts`, `drag/commit.test.ts` | +| Dock tab | Another dock stack tab bar | Move into that stack as a tab | `dockStore.presentation.test.ts`, `drag/commit.test.ts` | +| Dock tab | Left/right dock edge | Horizontal split, before/after the target | `dockStore.rules.test.ts`, `three-way-split.spec.ts` | +| Dock tab | Top/bottom dock edge | Vertical split, before/after the target | `dockStore.rules.test.ts`, `drag/commit.test.ts` | +| Dock tab | Empty canvas | Create a canvas node containing the panel | `dock-rules.spec.ts` | +| Dock tab | Canvas-node tab bar | Add the panel as a tab and remove it from the main dock | `drag/commit.test.ts` | +| Dock tab | Canvas-node edge | Add the panel as a split pane and remove it from the main dock | `drag/commit.test.ts` | +| Canvas node/pane | Empty area of the same canvas | Reposition the node; preserve its tabs/splits | `drag-move.spec.ts`, `drag-split.spec.ts` | +| Canvas node/pane | Another canvas-node tab bar | Merge into the target as tabs; remove an emptied source node | `drag-split.spec.ts` | +| Canvas node/pane | Another canvas-node edge | Split the target; remove an emptied source node | `drag-split.spec.ts` | +| Canvas node/pane | Main-dock tab bar | Move into the dock as a tab | `dock-rules.spec.ts` | +| Canvas node/pane | Main-dock edge | Move into the dock as a split | `drag/commit.test.ts` | +| Canvas node/pane | Outside the application window | Detach into a new dock window | `drag-detach.spec.ts` | +| Canvas panel | A canvas node | Reject recursive canvas nesting | `drag-canvas-into-canvas.spec.ts` | +| Detached-window panel | Main dock or canvas | Commit only after receiver acknowledgement; otherwise recover in the detached session | `detached-panels.spec.ts`, `existing-window-drop.test.ts`, `windowPanelSync.test.ts` | + +For every split edge, placement order is fixed: left/top inserts before the +target and right/bottom inserts after it. Same-direction splits gain an equal +sibling instead of creating an unnecessary nested split. + +## Split, maximize, minimize, and invalidation rules + +| Starting state | Action | Defined result | Restore status | Automated coverage | +|---|---|---|---|---| +| One dock stack | Split right | A new surface in a horizontal sibling | Not applicable | `three-way-split.spec.ts`, `dock-rules.spec.ts` | +| Main dock with any split tree | Maximize a leaf | Flatten the zone into one tab stack, keeping deterministic tree order and the selected leaf active | Valid | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` | +| Maximized main dock | Select a merged tab | Only active selection changes | Remains valid | `dockStore.rules.test.ts` | +| Maximized main dock | Resize/toggle another zone, add a panel to another zone, or take a snapshot | Presented topology is unchanged | Remains valid | `dockStore.rules.test.ts` | +| Maximized main dock | Add/remove/reorder/move a presented tab, split/collapse its stack, or restore a snapshot | Keep the user’s new topology | Permanently invalidated | `dockStore.rules.test.ts`, `dock-rules.spec.ts` | +| Maximized main dock, unchanged | Minimize | Restore the exact pre-merge split tree | Consumed | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` | +| Singleton canvas node | Maximize | Remove the empty node and promote its panel beside the canvas | Valid | `CanvasNode.groupDrag.test.tsx` | +| Tabbed or split canvas node | Maximize one pane | Promote only the active pane; preserve the remaining node | Valid | `CanvasNode.groupDrag.test.tsx`, `dock-rules.spec.ts` | +| Promoted canvas pane | Select canvas/promoted tab, resize surrounding split, resize/toggle an unrelated zone, add to another zone, or take a snapshot | No structural change to either saved topology | Remains valid | `dockStore.presentation.test.ts` | +| Promoted canvas pane | Structurally change the source canvas node | Keep both the promoted pane and the edited source | Permanently invalidated | `CanvasNode.groupDrag.test.tsx`, `dock-rules.spec.ts` | +| Promoted canvas pane | Add/remove/reorder/move/split/collapse in the destination, including moving away and back | Keep the user’s new destination | Permanently invalidated | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` | +| Promoted canvas pane, both sides unchanged | Minimize | Restore the exact node id, position, size, tabs, split tree, and active pane | Consumed | `CanvasNode.groupDrag.test.tsx`, `dock-rules.spec.ts` | +| Any active presentation | Maximize another stack/pane | Ignore the second request; presentations never nest | Existing presentation remains valid | `dockStore.presentation.test.ts` | +| Invalidated presentation | Minimize | No-op; the restore control is removed | Unavailable | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` | + +The invalidation rule is intentionally structural. Tab selection and split +ratios are presentation details and are safe; panel identity, order, tree shape, +and source-node existence are ownership/topology and cannot be overwritten by a +later restore. diff --git a/e2e/detached-panels.spec.ts b/e2e/detached-panels.spec.ts index ca232764d..91d9f11d4 100644 --- a/e2e/detached-panels.spec.ts +++ b/e2e/detached-panels.spec.ts @@ -60,6 +60,86 @@ test('Files root terminal action places the terminal in the visible detached doc await expect(detached.locator(`[data-tab-panel-id="${terminal}"]`)).toBeVisible() }) +test('screenshot preview and comment editing stay in the detached canvas window', async () => { + await app.evaluate(({ ipcMain }) => { + const svg = `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}` + const shots = [ + { id: 'detached-shot', filePath: '/tmp/detached-shot.png', dataUrl: svg }, + { id: 'detached-shot-2', filePath: '/tmp/detached-shot-2.png', dataUrl: svg }, + ] + ipcMain.removeHandler('recentScreenshot:get') + ipcMain.removeHandler('recentScreenshot:read') + ipcMain.removeHandler('recentScreenshot:save') + ipcMain.handle('recentScreenshot:get', () => shots) + ipcMain.handle('recentScreenshot:read', () => svg) + ipcMain.handle('recentScreenshot:save', (_event, _id, dataUrl) => { + ;(globalThis as typeof globalThis & { __detachedAnnotation?: string }).__detachedAnnotation = dataUrl + return { ...shots[0], id: 'detached-saved', filePath: '/tmp/detached-saved.png', dataUrl, annotated: true } + }) + }) + const id = await main.evaluate(() => window.__cateE2E!.createPanel('canvas')) + const detached = await detach(id) + const previewButton = detached.getByRole('button', { name: 'Open screenshot preview' }).first() + await expect(previewButton).toBeVisible() + await previewButton.click() + const viewer = detached.getByRole('dialog', { name: 'Screenshot preview' }) + await expect(viewer).toBeVisible() + await expect(main.getByRole('dialog', { name: 'Screenshot preview' })).toHaveCount(0) + await expect(detached.getByRole('link', { name: 'Download screenshot' })).toHaveAttribute('download', 'detached-shot.png') + await viewer.getByRole('button', { name: 'Next screenshot' }).click() + await expect(detached.getByText('2 / 2')).toBeVisible() + await expect(viewer.getByRole('img', { name: 'Screenshot 2 of 2' })).toBeVisible() + await viewer.getByRole('button', { name: 'Zoom in' }).click() + await expect(viewer.getByRole('button', { name: 'Fit screenshot' })).not.toHaveText('100%') + await expect(viewer.getByRole('toolbar', { name: 'Screenshot drawing tools' })).toBeVisible() + const annotation = detached.getByLabel('Annotate screenshot') + const box = await annotation.boundingBox() + if (!box) throw new Error('Missing detached screenshot annotation bounds') + await detached.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.2) + await detached.mouse.down() + await detached.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.3) + await detached.mouse.up() + await detached.getByRole('button', { name: 'Add comment' }).click() + await annotation.locator('rect').click({ position: { x: box.width / 2, y: box.height / 2 } }) + await detached.getByRole('textbox', { name: 'Comment 1' }).fill('Detached canvas comment') + const comment = detached.locator('foreignObject').last() + const initialX = Number(await comment.getAttribute('x')) + const handle = detached.getByRole('button', { name: 'Move comment 1' }) + const handleBox = await handle.boundingBox() + if (!handleBox) throw new Error('Missing detached comment drag handle') + await detached.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2) + await detached.mouse.down() + await detached.mouse.move(handleBox.x + handleBox.width / 2 + 50, handleBox.y + handleBox.height / 2 + 20) + await detached.mouse.up() + await expect.poll(async () => Number(await comment.getAttribute('x'))).toBeGreaterThan(initialX) + const initialWidth = Number(await comment.getAttribute('width')) + const initialHeight = Number(await comment.getAttribute('height')) + const scaleHandle = detached.locator('[aria-label="Resize comment 1"]') + const scaleBox = await scaleHandle.boundingBox() + if (!scaleBox) throw new Error('Missing detached comment scale handle') + await detached.mouse.move(scaleBox.x + scaleBox.width - 2, scaleBox.y + scaleBox.height / 2) + await detached.mouse.down() + await detached.mouse.move(scaleBox.x + scaleBox.width + 38, scaleBox.y + scaleBox.height / 2) + await detached.mouse.up() + await expect.poll(async () => Number(await comment.getAttribute('width'))).toBeGreaterThan(initialWidth) + await expect.poll(async () => Number(await comment.getAttribute('height'))).toBe(initialHeight) + const calloutGroup = comment.locator('..') + const initialTransform = await calloutGroup.getAttribute('transform') + const rotateHandle = detached.locator('[aria-label="Rotate comment 1"]') + await expect(rotateHandle).toHaveCSS('cursor', /url/) + const rotateBox = await rotateHandle.boundingBox() + if (!rotateBox) throw new Error('Missing detached comment rotation handle') + await detached.mouse.move(rotateBox.x + rotateBox.width / 2, rotateBox.y + 2) + await detached.mouse.down() + await detached.mouse.move(rotateBox.x + rotateBox.width / 2 + 45, rotateBox.y + 27) + await detached.mouse.up() + await expect.poll(() => calloutGroup.getAttribute('transform')).not.toBe(initialTransform) + await detached.getByRole('button', { name: 'Save' }).click() + await expect(detached.getByRole('dialog', { name: 'Screenshot preview' })).toHaveCount(0) + await expect(detached.getByLabel('Annotated screenshot')).toBeVisible() + await expect.poll(() => app.evaluate(() => (globalThis as typeof globalThis & { __detachedAnnotation?: string }).__detachedAnnotation?.startsWith('data:image/png;base64,'))).toBe(true) +}) + async function chooseNativeMenu(choice: string) { await app.evaluate(({ ipcMain }, choice) => { ipcMain.removeHandler('menu:showContext') @@ -70,11 +150,11 @@ async function chooseNativeMenu(choice: string) { }, choice) } -for (const type of ['terminal', 'browser', 'editor', 'canvas', 'agent', 'document', 'review', 'surface'] as const) { +for (const type of ['terminal', 'browser', 'editor', 'canvas', 'agent', 'review', 'surface'] as const) { test(`${type}: real detach, palette, overview reveal and owner-routed close`, async () => { const image = path.join(directory, 'preview.svg') writeFileSync(image, '') - const id = await main.evaluate(({ type, image }) => window.__cateE2E!.createPanel(type, type === 'document' ? image : undefined), { type, image }) + const id = await main.evaluate(({ type, image }) => window.__cateE2E!.createPanel(type, type === 'editor' ? image : undefined), { type, image }) expect(id).toBeTruthy() const detached = await detach(id) await expect.poll(() => main.evaluate(id => window.__cateE2E!.panels().some(p => p.id === id), id)).toBe(false) diff --git a/e2e/dock-rules.spec.ts b/e2e/dock-rules.spec.ts new file mode 100644 index 000000000..cb1d89e20 --- /dev/null +++ b/e2e/dock-rules.spec.ts @@ -0,0 +1,200 @@ +import { expect, test, type Locator } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { + closeApp, + dragMouse, + getNodeRect, + launchApp, + resetViewport, + seedTerminal, + setZoom, + titleBarCentre, +} from './fixtures/electron-app' + +let app: ElectronApplication +let page: Page +let canvasId: string + +test.beforeEach(async () => { + ;({ electronApp: app, mainWindow: page } = await launchApp()) + const window = await app.browserWindow(page) + await window.evaluate((browserWindow) => browserWindow.setContentSize(1400, 850)) + await page.evaluate(() => window.__cateE2E!.setSidebarHidden(true)) + canvasId = await page.evaluate(() => window.__cateE2E!.activeCanvasPanelId()!) +}) + +test.afterEach(async () => { + if (app) await closeApp(app) +}) + +function stackFor(panelId: string): Locator { + return page.locator('[data-dock-stack-id]').filter({ + has: page.locator(`[data-tab-panel-id="${panelId}"]`), + }) +} + +async function dragTabTo(panelId: string, point: { x: number; y: number }): Promise { + const box = await page.locator(`[data-tab-panel-id="${panelId}"]`).boundingBox() + if (!box) throw new Error(`Panel tab ${panelId} is not visible`) + await dragMouse( + page, + { x: box.x + box.width / 2, y: box.y + box.height / 2 }, + point, + { steps: 30, pauseAtEnd: 100 }, + ) +} + +async function splitCanvasNode(): Promise<{ + nodeId: string + panelIds: string[] +}> { + await setZoom(page, 0.65) + await resetViewport(page) + const source = await seedTerminal(page, { x: 300, y: 100 }) + const target = await seedTerminal(page, { x: 1000, y: 100 }) + const sourceGrab = await titleBarCentre(page, source) + const targetRect = await getNodeRect(page, target) + if (!sourceGrab || !targetRect) throw new Error('Canvas fixture did not render') + await dragMouse( + page, + sourceGrab, + { x: targetRect.x + 12, y: targetRect.y + targetRect.height / 2 }, + { steps: 30, pauseAtEnd: 100 }, + ) + await expect(page.locator(`[data-node-id="${source}"]`)).toHaveCount(0) + await expect.poll(async () => { + return page.evaluate((id) => window.__cateE2E!.canvasDebug().find((node) => node.id === id)?.leafCount, target) + }).toBe(2) + const panelIds = await page.evaluate((id) => { + return window.__cateE2E!.canvasDebug().find((node) => node.id === id)!.panelIds + }, target) + return { nodeId: target, panelIds } +} + +async function promoteFirstPane(nodeId: string): Promise { + const node = page.locator(`[data-node-id="${nodeId}"]`) + const overlay = node.locator('[data-unfocused-overlay]') + if (await overlay.count()) await overlay.click({ position: { x: 5, y: 5 } }) + await node.getByRole('button', { name: 'Move panel into dock' }).first().click() +} + +test('main-dock split can be maximized, restored, and permanently invalidated by a new split', async () => { + await page.evaluate(() => window.__cateE2E!.clearCanvas()) + const first = await page.evaluate(() => window.__cateE2E!.createPanel('surface')) + await stackFor(first).getByRole('button', { name: 'Split Right', exact: true }).click() + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug().zones.center.leafCount)).toBe(2) + + await page.getByRole('button', { name: 'Merge splits into tabs' }).first().click() + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug())).toMatchObject({ + zones: { center: { leafCount: 1 } }, + presentation: { canRestore: true }, + }) + + await page.getByRole('button', { name: 'Restore previous layout' }).click() + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug())).toMatchObject({ + zones: { center: { leafCount: 2 } }, + presentation: null, + }) + + await page.getByRole('button', { name: 'Merge splits into tabs' }).first().click() + await page.getByRole('button', { name: 'Split Right', exact: true }).click() + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug())).toMatchObject({ + zones: { center: { leafCount: 2 } }, + presentation: null, + }) + await expect(page.getByRole('button', { name: 'Restore previous layout' })).toHaveCount(0) +}) + +test('a panel can move from the dock into the canvas and back into the dock', async () => { + const panelId = await page.evaluate(() => window.__cateE2E!.createPanel('surface')) + await page.locator(`[data-tab-panel-id="${canvasId}"]`).click() + const canvas = await page.locator(`[data-canvas-panel-id="${canvasId}"]`).boundingBox() + if (!canvas) throw new Error('Canvas is not visible') + await dragTabTo(panelId, { x: canvas.x + canvas.width * 0.72, y: canvas.y + canvas.height * 0.7 }) + + await expect.poll(() => page.evaluate((id) => { + const e2e = window.__cateE2E! + return { + docked: e2e.dockDebug().zones.center.panelIds.includes(id), + canvas: e2e.canvasDebug().some((node) => node.panelIds.includes(id)), + } + }, panelId)).toEqual({ docked: false, canvas: true }) + + const nodeId = await page.evaluate((id) => { + return window.__cateE2E!.canvasDebug().find((node) => node.panelIds.includes(id))!.id + }, panelId) + const grab = await titleBarCentre(page, nodeId) + const canvasTab = await page.locator(`[data-tab-panel-id="${canvasId}"]`).boundingBox() + if (!grab || !canvasTab) throw new Error('Dock round-trip fixture is not visible') + await dragMouse( + page, + grab, + { x: canvasTab.x + canvasTab.width / 2, y: canvasTab.y + canvasTab.height / 2 }, + { steps: 30, pauseAtEnd: 100 }, + ) + + await expect.poll(() => page.evaluate((id) => { + const e2e = window.__cateE2E! + return { + docked: e2e.dockDebug().zones.center.panelIds.includes(id), + canvas: e2e.canvasDebug().some((node) => node.panelIds.includes(id)), + } + }, panelId)).toEqual({ docked: true, canvas: false }) +}) + +test('a split canvas node promotes one pane and restores the exact mini-dock', async () => { + const fixture = await splitCanvasNode() + await promoteFirstPane(fixture.nodeId) + + await expect.poll(() => page.evaluate(() => { + return window.__cateE2E!.dockDebug().presentation?.panelId ?? null + })).not.toBeNull() + const promotedId = await page.evaluate(() => window.__cateE2E!.dockDebug().presentation!.panelId!) + expect(fixture.panelIds).toContain(promotedId) + await expect.poll(() => page.evaluate(({ canvasId, nodeId }) => { + return window.__cateE2E!.canvasDebug(canvasId).find((node) => node.id === nodeId) + }, { canvasId, nodeId: fixture.nodeId })).toMatchObject({ leafCount: 1 }) + + await page.getByRole('button', { name: 'Restore previous layout' }).click() + await expect.poll(() => page.evaluate((id) => { + return window.__cateE2E!.canvasDebug().find((node) => node.id === id) + }, fixture.nodeId)).toMatchObject({ panelIds: fixture.panelIds, leafCount: 2 }) + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug().presentation)).toBeNull() +}) + +test('editing the source canvas after promotion invalidates restore', async () => { + const fixture = await splitCanvasNode() + await promoteFirstPane(fixture.nodeId) + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug().presentation?.canRestore)).toBe(true) + + await page.locator(`[data-tab-panel-id="${canvasId}"]`).click() + const node = page.locator(`[data-node-id="${fixture.nodeId}"]`) + await node.getByRole('button', { name: 'New Tab' }).click() + await page.getByRole('menu', { name: 'New Tab' }).getByRole('menuitem', { name: 'Files' }).click() + + await expect.poll(() => page.evaluate(() => window.__cateE2E!.dockDebug().presentation)).toBeNull() + await expect(page.getByRole('button', { name: 'Restore previous layout' })).toHaveCount(0) +}) + +test('dragging a promoted pane back into the canvas invalidates restore', async () => { + const fixture = await splitCanvasNode() + await promoteFirstPane(fixture.nodeId) + await expect.poll(() => page.evaluate(() => { + return window.__cateE2E!.dockDebug().presentation?.panelId ?? null + })).not.toBeNull() + const promotedId = await page.evaluate(() => window.__cateE2E!.dockDebug().presentation!.panelId!) + + await page.locator(`[data-tab-panel-id="${canvasId}"]`).click() + const canvas = await page.locator(`[data-canvas-panel-id="${canvasId}"]`).boundingBox() + if (!canvas) throw new Error('Canvas is not visible') + await dragTabTo(promotedId, { x: canvas.x + canvas.width * 0.78, y: canvas.y + canvas.height * 0.72 }) + + await expect.poll(() => page.evaluate((id) => { + const e2e = window.__cateE2E! + return { + presentation: e2e.dockDebug().presentation, + inCanvas: e2e.canvasDebug().some((node) => node.panelIds.includes(id)), + } + }, promotedId)).toEqual({ presentation: null, inCanvas: true }) + await expect(page.getByRole('button', { name: 'Restore previous layout' })).toHaveCount(0) +}) diff --git a/e2e/drag-canvas-into-canvas.spec.ts b/e2e/drag-canvas-into-canvas.spec.ts index 75f2dc2cf..38ac918ee 100644 --- a/e2e/drag-canvas-into-canvas.spec.ts +++ b/e2e/drag-canvas-into-canvas.spec.ts @@ -3,7 +3,6 @@ import { launchApp, closeApp, seedTerminal, - seedCanvasPanel, resetViewport, titleBarCentre, getNodeRect, @@ -16,6 +15,8 @@ let page: Page test.beforeEach(async () => { ;({ electronApp: app, mainWindow: page } = await launchApp()) + const window = await app.browserWindow(page) + await window.evaluate((browserWindow) => browserWindow.setContentSize(1600, 900)) // Collapse the left sidebar. It's a real flex item that PUSHES the canvas now // (#295), stealing ~260px of width — enough that a node seeded at canvas x=700 // has its centre fall off the right window edge, so a drop aimed there misses @@ -26,34 +27,32 @@ test.beforeEach(async () => { test.afterEach(async () => closeApp(app)) test('canvas panel cannot be docked into a canvas-node mini-dock', async () => { - // Set up: a terminal node (the would-be drop target) and a canvas-typed node - // (the would-be source). Canvas-in-canvas is forbidden. - const target = await seedTerminal(page, { x: 700, y: 200 }) - const source = await seedCanvasPanel(page, { x: 200, y: 200 }) - // The canvas panel may have landed inside a sub-canvas or as a workspace - // panel — if it didn't appear as a canvas-node, skip with a clear note. - const sourceEl = await page.$(`[data-node-id="${source}"]`) - test.skip(!sourceEl, 'createCanvasPanel did not produce a canvas-node in the active canvas') + const canvasId = await page.evaluate(() => window.__cateE2E!.activeCanvasPanelId()!) + const target = await seedTerminal(page, { x: 1000, y: 200 }) + const source = await page.evaluate(() => window.__cateE2E!.createPanel('canvas')) + await page.locator(`[data-tab-panel-id="${canvasId}"]`).click() - const grab = await titleBarCentre(page, source) + const sourceTab = await page.locator(`[data-tab-panel-id="${source}"]`).boundingBox() const tRect = await getNodeRect(page, target) + if (!sourceTab || !tRect) throw new Error('Canvas rejection fixture is not visible') // Aim at the target's tab-bar (would be 'tab' drop for a non-canvas source). const dropPoint = { x: tRect!.x + tRect!.width / 2, y: tRect!.y + 10 } - await dragMouse(page, grab!, dropPoint, { steps: 20, pauseAtEnd: 50 }) - await page.waitForTimeout(150) + await dragMouse(page, { + x: sourceTab.x + sourceTab.width / 2, + y: sourceTab.y + sourceTab.height / 2, + }, dropPoint, { steps: 20, pauseAtEnd: 50 }) - // Canvas source must NOT have been absorbed into target's stack. - // It may have moved (canvas-add elsewhere) or stayed put — but it MUST still - // exist as a canvas-node somewhere. - const sourceStill = await page.$(`[data-node-id="${source}"]`) - expect(sourceStill).not.toBeNull() + await expect.poll(() => page.evaluate((id) => ({ + docked: window.__cateE2E!.dockDebug().zones.center.panelIds.includes(id), + nested: window.__cateE2E!.canvasDebug().some((node) => node.panelIds.includes(id)), + }), source)).toEqual({ docked: true, nested: false }) }) test('non-canvas tab is accepted into a canvas-node mini-dock', async () => { // Regression guard: the rejection above must be specific to canvas — a // terminal tab still docks normally. - const target = await seedTerminal(page, { x: 700, y: 200 }) - const source = await seedTerminal(page, { x: 200, y: 200 }) + const target = await seedTerminal(page, { x: 1000, y: 200 }) + const source = await seedTerminal(page, { x: 300, y: 200 }) const grab = await titleBarCentre(page, source) const tRect = await getNodeRect(page, target) const dropPoint = { x: tRect!.x + tRect!.width / 2, y: tRect!.y + 10 } diff --git a/e2e/drag-move.spec.ts b/e2e/drag-move.spec.ts index 20072c232..f221cb2ab 100644 --- a/e2e/drag-move.spec.ts +++ b/e2e/drag-move.spec.ts @@ -104,12 +104,14 @@ test('source node is hidden while dragging', async () => { await page.mouse.move(grab!.x, grab!.y) await page.mouse.down() await page.mouse.move(grab!.x + 100, grab!.y + 80, { steps: 10 }) - await page.waitForTimeout(250) // wait for opacity transition (150ms) + slop - const opacity = await page.evaluate( - (id) => getComputedStyle(document.querySelector(`[data-node-id="${id}"]`)!).opacity, + const style = await page.evaluate( + (id) => { + const computed = getComputedStyle(document.querySelector(`[data-node-id="${id}"]`)!) + return { visibility: computed.visibility, pointerEvents: computed.pointerEvents } + }, nodeId, ) - expect(parseFloat(opacity)).toBe(0) + expect(style).toEqual({ visibility: 'hidden', pointerEvents: 'none' }) await page.mouse.up() }) diff --git a/e2e/drag-split.spec.ts b/e2e/drag-split.spec.ts index 87b927cee..03966315c 100644 --- a/e2e/drag-split.spec.ts +++ b/e2e/drag-split.spec.ts @@ -16,6 +16,8 @@ let page: Page test.beforeEach(async () => { ;({ electronApp: app, mainWindow: page } = await launchApp()) + const window = await app.browserWindow(page) + await window.evaluate((browserWindow) => browserWindow.setContentSize(1600, 900)) // Collapse the left sidebar. It's now a real flex item that PUSHES the canvas // (it used to overlay it), stealing ~260px of canvas width. With it open, a // node seeded at canvas x=1000 renders off the right window edge, so edge-drops diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 6baa8379f..bbfa5c13a 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -184,6 +184,26 @@ export async function seedTerminal( return nodeId } +export async function seedEditor( + page: Page, + point: { x: number; y: number } = { x: 200, y: 200 }, +): Promise { + const hint = await page.evaluate((p) => window.__cateE2E!.createEditor(p), point) + const nodeId = await page + .waitForFunction( + (h) => { + const n = window.__cateE2E!.nodes().find((x) => x.id === h || x.panelId === h) + return n ? n.id : null + }, + hint, + { timeout: 15_000 }, + ) + .then((handle) => handle.jsonValue() as Promise) + await page.waitForSelector(`[data-node-id="${nodeId}"]`) + await page.waitForTimeout(100) + return nodeId +} + export async function seedCanvasPanel( page: Page, point: { x: number; y: number } = { x: 200, y: 200 }, diff --git a/src/main/ipc/recentScreenshot.test.ts b/src/main/ipc/recentScreenshot.test.ts index 9df6664f7..04193e97a 100644 --- a/src/main/ipc/recentScreenshot.test.ts +++ b/src/main/ipc/recentScreenshot.test.ts @@ -1,28 +1,32 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RECENT_SCREENSHOT_GET, RECENT_SCREENSHOT_DRAG, RECENT_SCREENSHOT_CHANGED } from '../../shared/ipc-channels' +import { RECENT_SCREENSHOT_GET, RECENT_SCREENSHOT_DRAG, RECENT_SCREENSHOT_CHANGED, RECENT_SCREENSHOT_READ, RECENT_SCREENSHOT_SAVE } from '../../shared/ipc-channels' import { registerRecentScreenshotHandlers } from './recentScreenshot' const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), watch: vi.fn(), close: vi.fn(), watcherOn: vi.fn(), run: vi.fn(), stat: vi.fn(), thumbnail: vi.fn(), broadcast: vi.fn(), grant: vi.fn(), drag: vi.fn(), + runtimeGrant: vi.fn(), writeFile: vi.fn(), decode: vi.fn(), fromPath: vi.fn(), })) vi.mock('node:child_process', () => ({ execFile: (...args: unknown[]) => mocks.run(...args) })) vi.mock('node:util', () => ({ promisify: () => (...args: unknown[]) => new Promise((resolve, reject) => { mocks.run(...args, (error: Error | null, stdout: string, stderr: string) => error ? reject(error) : resolve({ stdout, stderr })) }) })) -vi.mock('node:fs/promises', () => ({ stat: mocks.stat })) +vi.mock('node:fs/promises', () => ({ stat: mocks.stat, writeFile: mocks.writeFile })) vi.mock('chokidar', () => ({ watch: mocks.watch })) vi.mock('electron', () => ({ app: { getPath: (key: string) => key === 'desktop' ? '/desktop' : '/home', on: mocks.on }, ipcMain: { handle: mocks.handle }, BrowserWindow: { getAllWindows: () => [{ id: 1 }, { id: 2 }] }, - nativeImage: { createThumbnailFromPath: mocks.thumbnail, createFromDataURL: () => 'icon' }, + nativeImage: { createThumbnailFromPath: mocks.thumbnail, createFromDataURL: mocks.decode, createFromPath: mocks.fromPath }, })) vi.mock('../windowRegistry', () => ({ broadcastToAll: mocks.broadcast, windowFromEvent: () => ({ id: 1 }) })) vi.mock('./pathValidation', () => ({ grantFileAccess: mocks.grant })) +vi.mock('../runtime/runtimeManager', () => ({ + resolveLocator: (path: string) => ({ path, runtime: { grantFileAccess: mocks.runtimeGrant } }), +})) -const invoke = (channel: string, value?: string) => mocks.handle.mock.calls.find(([name]) => name === channel)![1]({ sender: { startDrag: mocks.drag } }, value) +const invoke = (channel: string, value?: string, dataUrl?: string) => mocks.handle.mock.calls.find(([name]) => name === channel)![1]({ sender: { startDrag: mocks.drag } }, value, dataUrl) const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() } const emit = async (event: string, filePath: string) => { mocks.watcherOn.mock.calls.find(([name]) => name === event)![1](filePath) @@ -33,6 +37,10 @@ beforeEach(async () => { vi.resetAllMocks() vi.useFakeTimers() vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mocks.grant.mockImplementation(async (_windowId, filePath) => filePath) + mocks.decode.mockReturnValue('icon') + mocks.fromPath.mockReturnValue({ isEmpty: () => false, toDataURL: () => 'data:image/png;base64,converted' }) + mocks.runtimeGrant.mockResolvedValue(undefined) mocks.run.mockImplementation((_command, _args, _options, callback) => callback(null, '', '')) mocks.watch.mockReturnValue({ on: mocks.watcherOn, close: mocks.close }) mocks.stat.mockImplementation(async () => ({ isFile: () => true, mtimeMs: Date.now() })) @@ -53,9 +61,30 @@ describe('recent macOS screenshot', () => { expect(mocks.run).toHaveBeenCalledWith('/usr/bin/xattr', ['-p', 'com.apple.metadata:kMDItemIsScreenCapture', '/desktop/localized-name.png'], expect.anything(), expect.any(Function)) expect(mocks.grant).toHaveBeenCalledWith(1, '/desktop/localized-name.png') expect(mocks.grant).toHaveBeenCalledWith(2, '/desktop/localized-name.png') + expect(mocks.runtimeGrant).toHaveBeenCalledWith('/desktop/localized-name.png', 1) + expect(mocks.runtimeGrant).toHaveBeenCalledWith('/desktop/localized-name.png', 2) expect(await invoke(RECENT_SCREENSHOT_GET)).toMatchObject([{ filePath: '/desktop/localized-name.png' }]) }) + it('waits for daemon access before publishing and regrants on a fresh read', async () => { + const releases: Array<() => void> = [] + mocks.runtimeGrant.mockImplementation(() => new Promise(resolve => { releases.push(resolve) })) + await emit('add', '/desktop/waiting.png') + expect(mocks.broadcast).not.toHaveBeenCalled() + mocks.runtimeGrant.mockResolvedValue(undefined) + // Both windows must acknowledge the grant. + expect(releases).toHaveLength(2) + releases[0]() + await settle() + expect(mocks.broadcast).not.toHaveBeenCalled() + releases[1]() + await settle() + expect(mocks.broadcast).toHaveBeenCalledOnce() + mocks.runtimeGrant.mockClear() + expect(await invoke(RECENT_SCREENSHOT_GET)).toHaveLength(1) + expect(mocks.runtimeGrant).toHaveBeenCalledWith('/desktop/waiting.png', 1) + }) + it('retains recent screenshots without a deadline', async () => { await emit('add', '/desktop/first.png') await vi.advanceTimersByTimeAsync(30_000) @@ -104,6 +133,33 @@ describe('recent macOS screenshot', () => { expect(await invoke(RECENT_SCREENSHOT_GET)).toHaveLength(4) }) + it('stores annotated copies, grants access and publishes them as new screenshots', async () => { + await emit('add', '/desktop/shot.png') + const [shot] = await invoke(RECENT_SCREENSHOT_GET) + const png = Buffer.from('full-resolution-annotated-png') + mocks.decode.mockReturnValue({ isEmpty: () => false, toPNG: () => png }) + const saved = await invoke(RECENT_SCREENSHOT_SAVE, shot.id, 'data:image/png;base64,test') + expect(saved.annotated).toBe(true) + expect(path.dirname(saved.filePath)).toBe(path.dirname(path.normalize('/desktop/shot.png'))) + expect(path.basename(saved.filePath)).toMatch(/^shot-annotated-.*\.png$/) + expect(mocks.writeFile).toHaveBeenCalledWith(saved.filePath, png, { flag: 'wx' }) + expect(mocks.runtimeGrant).toHaveBeenCalledWith(saved.filePath, 1) + expect(mocks.runtimeGrant).toHaveBeenCalledWith(saved.filePath, 2) + expect(await invoke(RECENT_SCREENSHOT_GET)).toEqual([saved, shot]) + expect(mocks.broadcast).toHaveBeenLastCalledWith(RECENT_SCREENSHOT_CHANGED, [saved, shot]) + await invoke(RECENT_SCREENSHOT_DRAG, saved.id) + expect(mocks.drag).toHaveBeenCalledWith({ file: saved.filePath, icon: expect.anything() }) + await expect(invoke(RECENT_SCREENSHOT_SAVE, 'evicted-id', 'data:image/png;base64,test')).rejects.toThrow('no longer available') + }) + + it('converts the original through nativeImage before sending it to the renderer', async () => { + await emit('add', '/desktop/shot.heic') + const [shot] = await invoke(RECENT_SCREENSHOT_GET) + await expect(invoke(RECENT_SCREENSHOT_READ, shot.id)).resolves.toBe('data:image/png;base64,converted') + expect(mocks.fromPath).toHaveBeenCalledWith('/desktop/shot.heic') + await expect(invoke(RECENT_SCREENSHOT_READ, 'missing')).rejects.toThrow('no longer available') + }) + it('switches to a custom screenshot directory and closes watchers on quit', async () => { mocks.run.mockImplementation((_command, _args, _options, callback) => callback(null, '~/Pictures/Shots\n', '')) await vi.advanceTimersByTimeAsync(5000) diff --git a/src/main/ipc/recentScreenshot.ts b/src/main/ipc/recentScreenshot.ts index 232ad0397..5f79cca47 100644 --- a/src/main/ipc/recentScreenshot.ts +++ b/src/main/ipc/recentScreenshot.ts @@ -2,17 +2,27 @@ import { app, BrowserWindow, ipcMain, nativeImage } from 'electron' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import path from 'node:path' -import { stat } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { stat, writeFile } from 'node:fs/promises' import { watch, type FSWatcher } from 'chokidar' import type { RecentScreenshot } from '../../shared/recentScreenshot' -import { RECENT_SCREENSHOT_GET, RECENT_SCREENSHOT_CHANGED, RECENT_SCREENSHOT_DRAG } from '../../shared/ipc-channels' +import { RECENT_SCREENSHOT_GET, RECENT_SCREENSHOT_CHANGED, RECENT_SCREENSHOT_DRAG, RECENT_SCREENSHOT_READ, RECENT_SCREENSHOT_SAVE } from '../../shared/ipc-channels' import { broadcastToAll, windowFromEvent } from '../windowRegistry' import { grantFileAccess } from './pathValidation' import { wrapHandler } from './handlerError' import log from '../logger' +import { resolveLocator } from '../runtime/runtimeManager' const run = promisify(execFile) +async function grantScreenshotAccess(windowId: number, filePath: string): Promise { + const safePath = await grantFileAccess(windowId, filePath) + const { runtime, path: runtimePath } = resolveLocator(safePath) + // Binary reads run in the daemon, which owns a separate permission map. + // Complete its grant before publishing a thumbnail that can be opened. + await runtime.grantFileAccess(runtimePath, windowId) +} + export function registerRecentScreenshotHandlers(): void { let current: RecentScreenshot[] = [] let watcher: FSWatcher | undefined @@ -24,7 +34,7 @@ export function registerRecentScreenshotHandlers(): void { ipcMain.handle(RECENT_SCREENSHOT_GET, wrapHandler('[recentScreenshot:get]', async (event) => { const win = windowFromEvent(event) if (!win) return [] - await Promise.all(current.map(screenshot => grantFileAccess(win.id, screenshot.filePath))) + await Promise.all(current.map(screenshot => grantScreenshotAccess(win.id, screenshot.filePath))) return current })) ipcMain.handle(RECENT_SCREENSHOT_DRAG, wrapHandler('[recentScreenshot:drag]', async (event, id: string) => { @@ -38,6 +48,35 @@ export function registerRecentScreenshotHandlers(): void { }) })) + ipcMain.handle(RECENT_SCREENSHOT_READ, wrapHandler('[recentScreenshot:read]', async (event, id: string) => { + if (!windowFromEvent(event)) throw new Error('Screenshot window is no longer available.') + const screenshot = current.find(shot => shot.id === id) + if (!screenshot) throw new Error('Screenshot is no longer available.') + const image = nativeImage.createFromPath(screenshot.filePath) + if (image.isEmpty()) throw new Error('Could not read the screenshot.') + return image.toDataURL() + })) + + ipcMain.handle(RECENT_SCREENSHOT_SAVE, wrapHandler('[recentScreenshot:save]', async (event, id: string, dataUrl: string) => { + const win = windowFromEvent(event) + const screenshot = current.find(shot => shot.id === id) + if (!win) throw new Error('Screenshot window is no longer available.') + if (!screenshot) throw new Error('Screenshot is no longer available.') + if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) throw new Error('Expected a PNG image.') + const image = nativeImage.createFromDataURL(dataUrl) + if (image.isEmpty()) throw new Error('Could not read the annotated image.') + const directory = path.dirname(screenshot.filePath) + const name = path.basename(screenshot.filePath, path.extname(screenshot.filePath)) + const filePath = path.join(directory, `${name}-annotated-${randomUUID()}.png`) + await writeFile(filePath, image.toPNG(), { flag: 'wx' }) + const thumbnail = await nativeImage.createThumbnailFromPath(filePath, { width: 128, height: 128 }) + await Promise.all(BrowserWindow.getAllWindows().map(window => grantScreenshotAccess(window.id, filePath))) + const saved: RecentScreenshot = { id: randomUUID(), filePath, dataUrl: thumbnail.toDataURL(), annotated: true } + current = [saved, ...current].slice(0, 5) + broadcastToAll(RECENT_SCREENSHOT_CHANGED, current) + return saved + })) + if (process.platform !== 'darwin') return const inspect = async (filePath: string) => { @@ -49,7 +88,7 @@ export function registerRecentScreenshotHandlers(): void { await run('/usr/bin/xattr', ['-p', 'com.apple.metadata:kMDItemIsScreenCapture', filePath], { timeout: 2000 }) const thumbnail = await nativeImage.createThumbnailFromPath(filePath, { width: 128, height: 128 }) if (stopped || thumbnail.isEmpty() || info.mtimeMs <= newestMtime) return - await Promise.all(BrowserWindow.getAllWindows().map(win => grantFileAccess(win.id, filePath))) + await Promise.all(BrowserWindow.getAllWindows().map(win => grantScreenshotAccess(win.id, filePath))) if (stopped || info.mtimeMs <= newestMtime) return newestMtime = info.mtimeMs current = [{ id: `${filePath}:${info.mtimeMs}`, filePath, dataUrl: thumbnail.toDataURL() }, diff --git a/src/main/runtime/runtime-loopback.test.ts b/src/main/runtime/runtime-loopback.test.ts index caf215c93..c124284f6 100644 --- a/src/main/runtime/runtime-loopback.test.ts +++ b/src/main/runtime/runtime-loopback.test.ts @@ -175,15 +175,19 @@ describe('runtime loopback (real daemon capabilities over the wire)', () => { // A file OUTSIDE any allowed root (not under rootDir, not under tmpdir): the // daemon's strict validation must reject it until the grant is forwarded. const outsideDir = await fs.realpath(await fs.mkdtemp(path.join(process.cwd(), 'cate-grant-'))) - const outsideFile = path.join(outsideDir, 'granted.txt') - await fs.writeFile(outsideFile, 'secret\n') + const outsideFile = path.join(outsideDir, 'screenshot.png') + const original = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a3ioAAAAASUVORK5CYII=', 'base64') + await fs.writeFile(outsideFile, original) try { await expect(remote.validatePathStrict(outsideFile, 1)).rejects.toThrow(/Access denied/) + await expect(remote.file.readBinary(outsideFile, { ownerWindowId: 1 })).rejects.toThrow(/Access denied/) await remote.grantFileAccess(outsideFile, 1) // Same window id now passes the daemon's authoritative strict check. await expect(remote.validatePathStrict(outsideFile, 1)).resolves.toBe(outsideFile) + await expect(remote.file.readBinary(outsideFile, { ownerWindowId: 1 })).resolves.toEqual(original) // A different window without the grant is still denied. await expect(remote.validatePathStrict(outsideFile, 2)).rejects.toThrow(/Access denied/) + await expect(remote.file.readBinary(outsideFile, { ownerWindowId: 2 })).rejects.toThrow(/Access denied/) } finally { clearFileGrantsForWindow(1) await fs.rm(outsideDir, { recursive: true, force: true }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 52db4981d..138978d1d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -224,6 +224,8 @@ import { RECENT_SCREENSHOT_GET, RECENT_SCREENSHOT_CHANGED, RECENT_SCREENSHOT_DRAG, + RECENT_SCREENSHOT_SAVE, + RECENT_SCREENSHOT_READ, AGENT_HARNESS_GET_PANEL_URL, AGENT_HARNESS_GET_USAGE_URL, PULL_REQUESTS_LIST, @@ -485,6 +487,8 @@ const invokeForwarders = { nativeFileDrag: makeInvoker<'nativeFileDrag'>(NATIVE_FILE_DRAG), getRecentScreenshot: makeInvoker<'getRecentScreenshot'>(RECENT_SCREENSHOT_GET), dragRecentScreenshot: makeInvoker<'dragRecentScreenshot'>(RECENT_SCREENSHOT_DRAG), + readRecentScreenshot: makeInvoker<'readRecentScreenshot'>(RECENT_SCREENSHOT_READ), + saveRecentScreenshot: makeInvoker<'saveRecentScreenshot'>(RECENT_SCREENSHOT_SAVE), onRecentScreenshotChanged(callback: (screenshot: RecentScreenshot[]) => void): () => void { return createIpcListener(RECENT_SCREENSHOT_CHANGED, callback) }, diff --git a/src/renderer/canvas/Canvas.targetInteraction.test.tsx b/src/renderer/canvas/Canvas.targetInteraction.test.tsx index 9b3de379c..dd35a6cf5 100644 --- a/src/renderer/canvas/Canvas.targetInteraction.test.tsx +++ b/src/renderer/canvas/Canvas.targetInteraction.test.tsx @@ -78,7 +78,7 @@ function startTarget(availability: 'new' | 'existing' | 'both', onSelected = vi. } describe('canvas chrome and panel-target gestures', () => { - const panelTypes: PanelType[] = ['terminal', 'browser', 'editor', 'agent', 'document', 'review'] + const panelTypes: PanelType[] = ['terminal', 'browser', 'editor', 'agent', 'review'] it.each(panelTypes.flatMap((panelType) => [0.75, 1, 1.5].map((zoom) => ({ panelType, zoom }))))( '$panelType creation at zoom $zoom preserves focus and offers multiple recommendations', ({ panelType, zoom }) => { store.setState({ zoomLevel: zoom }) diff --git a/src/renderer/canvas/Canvas.topOverlay.test.tsx b/src/renderer/canvas/Canvas.topOverlay.test.tsx index 12c01c9e5..cb263c9a2 100644 --- a/src/renderer/canvas/Canvas.topOverlay.test.tsx +++ b/src/renderer/canvas/Canvas.topOverlay.test.tsx @@ -80,5 +80,7 @@ describe('Canvas top overlay', () => { expect(overlay.querySelector('[data-glow-probe]')).not.toBeNull() expect(overlay.querySelector('[data-toolbar-probe]')).not.toBeNull() expect(container.querySelector('[data-canvas-marquee]')).toBeNull() + + expect(world.style.transform).toBe('scale(2) translate(15px, 20px)') }) }) diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 0d503ff30..72ce0df81 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -372,7 +372,6 @@ const Canvas: React.FC = ({ children, overlayChildren, onCreateAtPo let prevRect = el.getBoundingClientRect() let prevWindowWidth = window.innerWidth - const observer = new ResizeObserver((entries) => { for (const entry of entries) { const size = { diff --git a/src/renderer/canvas/CanvasNode.dragPreview.test.tsx b/src/renderer/canvas/CanvasNode.dragPreview.test.tsx new file mode 100644 index 000000000..f7f804d7e --- /dev/null +++ b/src/renderer/canvas/CanvasNode.dragPreview.test.tsx @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/terminal/terminalRegistry', () => ({ + terminalRegistry: { + release: vi.fn(), + dispose: vi.fn(), + disposeWorkspace: vi.fn(), + has: () => false, + getEntry: () => undefined, + }, +})) + +vi.mock('../stores/useWorktrees', () => ({ useWorktrees: () => [] })) + +import * as React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { StoreApi } from 'zustand' +import type { PanelType, Point, Size, WindowDockState } from '../../shared/types' +import CanvasNode from './CanvasNode' +import DragOverlay from '../drag/Overlay' +import { useDragStore } from '../drag' +import { INITIAL_DRAG_STATE, type DragState } from '../drag/types' +import { CanvasStoreProvider } from '../stores/CanvasStoreContext' +import { createCanvasStore, type CanvasStore } from '../stores/canvasStore' +import { createDefaultDockState, createDockStore } from '../stores/dockStore' +import { useAppStore } from '../stores/appStore' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let container: HTMLDivElement +let root: Root + +function addNode( + store: StoreApi, + id: string, + panelId: string, + panelType: PanelType, + origin: Point, + size: Size, +) { + const created = store.getState().addNode(panelId, panelType, origin, size) + store.setState((state) => { + const node = state.nodes[created] + if (!node) return state + const nodes = { ...state.nodes } + delete nodes[created] + nodes[id] = { ...node, id, origin, size, animationState: 'idle' } + return { nodes } + }) +} + +function renderNode(panelType: 'terminal' | 'browser') { + const panelId = `${panelType}-panel` + const workspaceId = useAppStore.getState().addWorkspace('WS', '/tmp/ws', `ws-${panelType}`) + useAppStore.getState().addPanel(workspaceId, { + id: panelId, + type: panelType, + title: panelType, + isDirty: false, + }) + + const canvasStore = createCanvasStore() + canvasStore.getState().setZoomAndOffset(1, { x: 0, y: 0 }) + addNode(canvasStore, 'node', panelId, panelType, { x: 100, y: 100 }, { width: 300, height: 200 }) + const zones: WindowDockState = { + ...createDefaultDockState(), + center: { + position: 'center', + visible: true, + size: 0, + layout: { type: 'tabs', id: 'stack', panelIds: [panelId], activeIndex: 0 }, + }, + } + const dockStore = createDockStore({ zones }) + + act(() => root.render( + + panelType === 'browser' + ? React.createElement('webview', { 'data-panel-surface': panelType }) + :
terminal output
} + /> + +
, + )) + + return { canvasStore, panelId } +} + +function startDrag( + canvasStore: StoreApi, + panelId: string, + panelType: 'terminal' | 'browser', + target: DragState['target'], +) { + act(() => useDragStore.getState().applyDragState({ + ...INITIAL_DRAG_STATE, + isDragging: true, + source: { + panelId, + origin: { kind: 'canvas-node', canvasStoreApi: canvasStore, nodeId: 'node' }, + }, + panel: { id: panelId, type: panelType, title: panelType }, + grab: { x: 20, y: 10 }, + ghostSize: { width: 300, height: 200 }, + cursor: { client: { x: 200, y: 170 }, screen: { x: 200, y: 170 }, insideWindow: true }, + target, + })) +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + useAppStore.setState({ workspaces: [], selectedWorkspaceId: '' }) + useDragStore.getState().applyDragState(INITIAL_DRAG_STATE) + container = document.createElement('div') + document.body.appendChild(container) + act(() => { root = createRoot(container) }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useDragStore.getState().applyDragState(INITIAL_DRAG_STATE) + vi.unstubAllGlobals() +}) + +describe('CanvasNode drag preview', () => { + it.each(['terminal', 'browser'] as const)('hides the live %s surface and always renders the ghost', (panelType) => { + const { canvasStore, panelId } = renderNode(panelType) + startDrag(canvasStore, panelId, panelType, { + kind: 'canvas-reposition', + canvasStoreApi: canvasStore, + nodeId: 'node', + origin: { x: 180, y: 160 }, + }) + + const node = container.querySelector('[data-node-id="node"]')! + expect(node.style.left).toBe('100px') + expect(node.style.top).toBe('100px') + expect(node.style.visibility).toBe('hidden') + expect(node.querySelector(`[data-panel-surface="${panelType}"]`)).not.toBeNull() + expect(document.querySelector('[data-drag-overlay-ghost="true"]')).not.toBeNull() + }) +}) diff --git a/src/renderer/canvas/CanvasNode.groupDrag.test.tsx b/src/renderer/canvas/CanvasNode.groupDrag.test.tsx index 8705048e6..c5e05e973 100644 --- a/src/renderer/canvas/CanvasNode.groupDrag.test.tsx +++ b/src/renderer/canvas/CanvasNode.groupDrag.test.tsx @@ -178,6 +178,108 @@ describe('CanvasNode — file drags into an unfocused webview', () => { }) }) +it('promotes one tab from a canvas node and restores the full mini-dock while unchanged', () => { + const wsId = useAppStore.getState().addWorkspace('WS', '/tmp/ws', 'ws-present') + useAppStore.getState().addPanel(wsId, { id: 'canvas', type: 'canvas', title: 'Canvas', isDirty: false }) + useAppStore.getState().addPanel(wsId, { id: 'editor', type: 'editor', title: 'Editor', isDirty: false }) + useAppStore.getState().addPanel(wsId, { id: 'sibling', type: 'browser', title: 'Browser', isDirty: false }) + const canvas = freshCanvasStore() + addNode(canvas, 'node', 'editor', { x: 120, y: 230 }, { width: 400, height: 300 }) + const nodeDock = tabsDockStore('editor') + nodeDock.getState().dockPanel('sibling', 'center', { type: 'tab', stackId: 'stack-editor' }) + nodeDock.getState().setActiveTab('stack-editor', 0) + canvas.getState().setNodeDockLayout('node', nodeDock.getState().zones.center.layout) + const unsubscribe = nodeDock.subscribe((state, previous) => { + const layout = state.zones.center.layout + if (layout !== previous.zones.center.layout && layout) canvas.getState().setNodeDockLayout('node', layout) + }) + const outerDock = createDockStore() + outerDock.getState().dockPanel('canvas', 'center') + const canvasLocation = outerDock.getState().getPanelLocation('canvas')! + if (canvasLocation.type !== 'dock') throw new Error('Expected dock location') + + act(() => root.render( + +
} + /> + , + )) + + act(() => container.querySelector('[aria-label="Move panel into dock"]')!.click()) + expect(canvas.getState().nodes.node.dockLayout).toMatchObject({ panelIds: ['sibling'] }) + expect(outerDock.getState().zones.center.layout).toMatchObject({ + type: 'tabs', panelIds: ['canvas', 'editor'], activeIndex: 1, + }) + expect(outerDock.getState().canRestorePresentation(canvasLocation.stackId)).toBe(true) + + act(() => { outerDock.getState().restorePresentation(canvasLocation.stackId) }) + expect(outerDock.getState().zones.center.layout).toMatchObject({ type: 'tabs', panelIds: ['canvas'] }) + expect(canvas.getState().nodes.node).toMatchObject({ + origin: { x: 120, y: 230 }, size: { width: 400, height: 300 }, + dockLayout: { panelIds: ['editor', 'sibling'], activeIndex: 0 }, + }) + + // A later source edit consumes the next reverse transaction permanently. + // This covers the multi-panel case where the original canvas node survives + // promotion and can itself be tabbed/split while the promoted panel is open. + act(() => nodeDock.getState().dockPanel('editor', 'center', { + type: 'tab', stackId: 'stack-editor', index: 0, + })) + act(() => container.querySelector('[aria-label="Move panel into dock"]')!.click()) + expect(outerDock.getState().presentation).not.toBeNull() + act(() => nodeDock.getState().dockPanel('changed-source', 'center', { + type: 'tab', stackId: 'stack-editor', + })) + expect(outerDock.getState().presentation).toBeNull() + unsubscribe() +}) + +it('removes and restores a singleton canvas node when its panel is promoted', () => { + const wsId = useAppStore.getState().addWorkspace('WS', '/tmp/ws', 'ws-present-single') + useAppStore.getState().addPanel(wsId, { id: 'canvas', type: 'canvas', title: 'Canvas', isDirty: false }) + useAppStore.getState().addPanel(wsId, { id: 'editor', type: 'editor', title: 'Editor', isDirty: false }) + const canvas = freshCanvasStore() + addNode(canvas, 'node', 'editor', { x: 120, y: 230 }, { width: 400, height: 300 }) + const nodeDock = tabsDockStore('editor') + const outerDock = createDockStore() + outerDock.getState().dockPanel('canvas', 'center') + const canvasLocation = outerDock.getState().getPanelLocation('canvas')! + if (canvasLocation.type !== 'dock') throw new Error('Expected dock location') + + act(() => root.render( + +
} + /> + , + )) + + act(() => container.querySelector('[aria-label="Move panel into dock"]')!.click()) + expect(canvas.getState().nodes.node).toBeUndefined() + expect(outerDock.getState().zones.center.layout).toMatchObject({ + type: 'tabs', panelIds: ['canvas', 'editor'], activeIndex: 1, + }) + + act(() => { outerDock.getState().restorePresentation(canvasLocation.stackId) }) + expect(outerDock.getState().zones.center.layout).toMatchObject({ panelIds: ['canvas'] }) + expect(canvas.getState().nodes.node).toMatchObject({ + origin: { x: 120, y: 230 }, + size: { width: 400, height: 300 }, + dockLayout: { panelIds: ['editor'] }, + }) +}) + describe('CanvasNode — group drag from the title bar', () => { it('grabbing a multi-selected node by its tab bar arms a GROUP drag carrying the whole selection', () => { const wsId = useAppStore.getState().addWorkspace('WS', '/tmp/ws', 'ws-group-drag') diff --git a/src/renderer/canvas/CanvasNode.tsx b/src/renderer/canvas/CanvasNode.tsx index 252d8f869..55c2d6ea7 100644 --- a/src/renderer/canvas/CanvasNode.tsx +++ b/src/renderer/canvas/CanvasNode.tsx @@ -11,8 +11,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useRenderCount } from '../lib/perf/perfClient' import type { StoreApi } from 'zustand' -import type { NodeActivityState, DockTabStack as DockTabStackNode, PanelType } from '../../shared/types' -import { isMaximized as checkMaximized } from '../../shared/types' +import type { NodeActivityState, DockLayoutNode, DockTabStack as DockTabStackNode, PanelType } from '../../shared/types' import { useCanvasStoreContext, useCanvasStoreApi } from '../stores/CanvasStoreContext' import { useAppStore, useSelectedWorkspace } from '../stores/appStore' import { useUIStore } from '../stores/uiStore' @@ -23,7 +22,7 @@ import { useCanvasNodeDrag } from './useCanvasNodeDrag' import { isSelected as isNodeSelected, isGroupDragMember } from '../stores/canvas/selectionModel' import { useNodeResizeCursor } from './useNodeResizeCursor' import { NodeResizeOverlay } from './NodeResizeOverlay' -import type { DockStore } from '../stores/dockStore' +import { removePanelFromTree, type DockStore } from '../stores/dockStore' import { DockStoreProvider } from '../stores/DockStoreContext' import DockTabStack from '../docking/DockTabStack' import { activeLeafPanelId } from '../panels/nodeDockRegistry' @@ -33,7 +32,7 @@ import { Tooltip } from '../ui/Tooltip' import DockLayoutRenderer from '../docking/DockLayoutRenderer' import { confirmClosePanels } from '../lib/confirmClosePanels' import { collectPanelIds } from '../../shared/collectPanelIds' -import { Maximize as ArrowsOutSimple, Minimize as ArrowsInSimple, X, Lock, LockOpen } from 'lucide-react' +import { X, Lock, LockOpen } from 'lucide-react' import { PANEL_DEFINITIONS } from '../../shared/panels' import { captureRendererException } from '../lib/sentry' import { useCanvasTopOverlayTarget } from './CanvasTopOverlayContext' @@ -58,10 +57,13 @@ function handToolPanShouldWin(e: React.MouseEvent): boolean { export interface CanvasNodeProps { nodeId: string + canvasPanelId?: string isFocused: boolean activityState?: NodeActivityState /** Per-node DockStore that owns the layout for this node. Created in CanvasPanel. */ dockStoreApi: StoreApi + /** Dock store which owns the canvas panel containing this node. */ + outerDockStoreApi?: StoreApi /** Render the panel content for a given panelId. */ renderPanel: (panelId: string) => React.ReactNode /** Title used in tooltips / context when there's no dock panel. */ @@ -79,6 +81,21 @@ const CANVAS_EXCLUDED_TYPES = (Object.values(PANEL_DEFINITIONS) .filter((definition) => !definition.canLiveOnCanvas) .map((definition) => definition.type)) satisfies PanelType[] +function sameDockTopology(a: DockLayoutNode | null, b: DockLayoutNode | null): boolean { + if (!a || !b) return a === b + if (a.type !== b.type || a.id !== b.id) return false + if (a.type === 'tabs' && b.type === 'tabs') { + return a.panelIds.length === b.panelIds.length + && a.panelIds.every((panelId, index) => panelId === b.panelIds[index]) + } + if (a.type === 'split' && b.type === 'split') { + return a.direction === b.direction + && a.children.length === b.children.length + && a.children.every((child, index) => sameDockTopology(child, b.children[index])) + } + return false +} + // ----------------------------------------------------------------------------- // Pulse animation keyframes (injected once) // ----------------------------------------------------------------------------- @@ -104,6 +121,11 @@ let keyframesInjected = false function ensureKeyframes() { if (keyframesInjected) return if (typeof document === 'undefined') return + // Replace earlier module versions during hot reload, including the old + // window-wide maximize rules, instead of accumulating conflicting styles. + for (const previous of document.head.querySelectorAll('style')) { + if (previous.textContent?.includes('@keyframes pulseActivity')) previous.remove() + } const style = document.createElement('style') style.textContent = PULSE_KEYFRAMES document.head.appendChild(style) @@ -118,11 +140,13 @@ function GrabButton({ title, onClick, color, + compact, children, }: { title: string onClick: (e: React.MouseEvent) => void color?: string + compact: boolean children: React.ReactNode }) { const baseColor = color ?? 'var(--text-secondary)' @@ -132,7 +156,7 @@ function GrabButton({ data-grab-button aria-label={title} onClick={onClick} - className="flex items-center justify-center w-[18px] h-[18px] rounded text-secondary hover:text-primary hover:bg-hover" + className={`flex items-center justify-center self-center rounded-[10px] text-muted hover:text-primary hover:bg-hover ${compact ? 'w-[22px] h-[22px]' : 'w-6 h-6'}`} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: baseColor }} > {children} @@ -141,7 +165,6 @@ function GrabButton({ ) } -const TAB_ICON_SIZE = 12 // ----------------------------------------------------------------------------- // Component @@ -149,9 +172,11 @@ const TAB_ICON_SIZE = 12 const CanvasNode: React.FC = ({ nodeId, + canvasPanelId, isFocused, activityState, dockStoreApi, + outerDockStoreApi, renderPanel, title: _title = 'Panel', }) => { @@ -159,10 +184,21 @@ const CanvasNode: React.FC = ({ useRenderCount('CanvasNode') const canvasApi = useCanvasStoreApi() + const [outerPresentationActive, setOuterPresentationActive] = useState( + () => !!outerDockStoreApi?.getState().presentation, + ) + useEffect(() => { + if (!outerDockStoreApi) return + setOuterPresentationActive(!!outerDockStoreApi.getState().presentation) + return outerDockStoreApi.subscribe((state, previous) => { + if (!!state.presentation !== !!previous.presentation) { + setOuterPresentationActive(!!state.presentation) + } + }) + }, [outerDockStoreApi]) const topOverlayTarget = useCanvasTopOverlayTarget() const nodeRef = useRef(null) const [isHovered, setIsHovered] = useState(false) - const [isAnimatingLayout, setIsAnimatingLayout] = useState(false) // True while a file/panel drag is hovering an unfocused node, so the dim // overlay lets the drop fall through to the panel content that owns it. const [fileDragOver, setFileDragOver] = useState(false) @@ -207,7 +243,6 @@ const CanvasNode: React.FC = ({ ) const focusNode = useCanvasStoreContext((s) => s.focusNode) const removeNode = useCanvasStoreContext((s) => s.removeNode) - const toggleMaximize = useCanvasStoreContext((s) => s.toggleMaximize) const isSelected = useCanvasStoreContext((s) => isNodeSelected(s, nodeId)) const isDockDragging = useDragStore((s) => s.isDragging) const { hidden: isWholeNodeDragSource } = useDragSourceVisibility(nodeId) @@ -244,8 +279,6 @@ const CanvasNode: React.FC = ({ handleDragStart(e) }, [handleDragStart, handleTabDetachStart, dockStoreApi, canvasApi, nodeId]) - const maximized = node ? checkMaximized(node) : false - const { handleResizeStart } = useNodeResize(nodeId, primaryPanelType, canvasApi) // Under the Hand tool, edge presses pan instead of resizing. const handleResizeStartGuarded = useCallback( @@ -356,42 +389,74 @@ const CanvasNode: React.FC = ({ removeNode(nodeId) }, [removeNode, nodeId, layout, confirmCloseForPanels, wsId]) - const handleToggleMaximize = useCallback(() => { - setIsAnimatingLayout(true) - const viewportSize = { width: window.innerWidth, height: window.innerHeight } - toggleMaximize(nodeId, viewportSize) - setTimeout(() => setIsAnimatingLayout(false), 300) - }, [toggleMaximize, nodeId]) - - // Spring-load: when ANY dock drag is active AND this node is maximized - // (covering the canvas), un-maximize after a short delay so the user can - // see the canvas underneath and target a drop point. - const toggleMaximizeRef = useRef(handleToggleMaximize) - toggleMaximizeRef.current = handleToggleMaximize - const maximizedRef = useRef(maximized) - maximizedRef.current = maximized - useEffect(() => { - let timerId: number | null = null - const tryArm = () => { - const s = useDragStore.getState() - if (!s.isDragging || s.panel?.type === 'canvas') return - if (!maximizedRef.current) return - if (timerId !== null) return - timerId = window.setTimeout(() => { - timerId = null - if (maximizedRef.current) toggleMaximizeRef.current() - }, 200) - } - const cancel = () => { - if (timerId !== null) { window.clearTimeout(timerId); timerId = null } + const handlePresentPanel = useCallback((panelId: string) => { + if (!outerDockStoreApi || !canvasPanelId) return + const outer = outerDockStoreApi.getState() + if (outer.presentation) return + const canvasLocation = outer.getPanelLocation(canvasPanelId) + if (!canvasLocation || canvasLocation.type !== 'dock') return + const restoreLayout = outer.zones[canvasLocation.zone].layout + const sourceNode = canvasApi.getState().nodes[nodeId] + const sourceLayout = dockStoreApi.getState().zones.center.layout + if (!restoreLayout || !sourceNode || !sourceLayout || !collectPanelIds(sourceLayout).includes(panelId)) return + + const expectedSourceLayout = removePanelFromTree(sourceLayout, panelId) + dockStoreApi.getState().undockPanel(panelId) + if (!expectedSourceLayout) canvasApi.getState().finalizeRemoveNode(nodeId) + + outerDockStoreApi.getState().dockPanel(panelId, canvasLocation.zone, { + type: 'tab', + stackId: canvasLocation.stackId, + }) + const expectedLayout = outerDockStoreApi.getState().zones[canvasLocation.zone].layout + if (!expectedLayout) return + + let unsubscribeSource = () => {} + const presentation = { + stackId: canvasLocation.stackId, + panelId, + zone: canvasLocation.zone, + restoreLayout, + expectedLayout, + canRestoreExternal: () => { + const current = canvasApi.getState().nodes[nodeId] + return expectedSourceLayout + ? !!current && sameDockTopology(current.dockLayout, expectedSourceLayout) + : !current + }, + restoreExternal: () => { + // The canvas panel is normally unmounted while its promoted sibling is + // active, so its per-node DockStore may no longer be registered. Restore + // the persisted canvas projection directly; remount will seed the live + // mini-dock from this exact layout. + canvasApi.setState((state) => ({ + nodes: { + ...state.nodes, + [nodeId]: { + ...(state.nodes[nodeId] ?? sourceNode), + dockLayout: sourceLayout, + animationState: 'idle', + }, + }, + selection: [nodeId], + selectionActive: true, + focusEpoch: state.focusEpoch + 1, + })) + setActivePanel(canvasPanelId) + }, + dispose: () => unsubscribeSource(), } - tryArm() - const unsub = useDragStore.subscribe((s, prev) => { - if (s.isDragging && !prev.isDragging) tryArm() - else if (!s.isDragging && prev.isDragging) cancel() + outerDockStoreApi.getState().beginPresentation(presentation) + unsubscribeSource = canvasApi.subscribe(() => { + const current = outerDockStoreApi.getState().presentation + if (current !== presentation) { + unsubscribeSource() + return + } + if (!presentation.canRestoreExternal()) outerDockStoreApi.getState().discardPresentation() }) - return () => { cancel(); unsub() } - }, []) + setActivePanel(panelId) + }, [outerDockStoreApi, canvasPanelId, canvasApi, nodeId, dockStoreApi]) const handleTogglePin = useCallback(() => { canvasApi.getState().togglePin(nodeId) @@ -436,30 +501,25 @@ const CanvasNode: React.FC = ({ return () => canvasApi.getState().setNodeActiveWorktree(nodeId, null) }, [nodeId, canvasApi]) + const tabIconSize = 12 const nodeControlButtons = ( <> { e.stopPropagation(); handleTogglePin() }} color={node?.isPinned ? 'var(--focus-blue)' : undefined} > {node?.isPinned - ? - : } - - { e.stopPropagation(); handleToggleMaximize() }} - > - {maximized - ? - : } + ? + : } { e.stopPropagation(); handleClose() }} > - + ) @@ -481,6 +541,7 @@ const CanvasNode: React.FC = ({ excludePanelTypes={CANVAS_EXCLUDED_TYPES} localOnly compact + onPresentPanel={outerDockStoreApi && canvasPanelId && !outerPresentationActive ? handlePresentPanel : undefined} onTabBarMouseDown={isHeaderHost ? handleHeaderMouseDown : undefined} trailingControls={isHeaderHost ? nodeControlButtons : undefined} dropDisabled={isWholeNodeDragSource} @@ -597,13 +658,14 @@ const CanvasNode: React.FC = ({ const target = e.target as HTMLElement if (target.closest('[data-grab-button]')) return e.stopPropagation() - if (e.detail === 2) { - handleToggleMaximize() + if (e.detail === 2 && !outerPresentationActive) { + const panelId = activeLeafPanelId(dockStoreApi.getState().zones.center.layout) + if (panelId) handlePresentPanel(panelId) return } handleDragStart(e) }, - [handleDragStart, handleToggleMaximize], + [handleDragStart, handlePresentPanel, dockStoreApi, outerPresentationActive], ) const handleGrabStripContextMenu = useCallback( @@ -612,7 +674,7 @@ const CanvasNode: React.FC = ({ e.stopPropagation() if (!window.electronAPI) return const id = await window.electronAPI.showContextMenu([ - { id: 'maximize', label: maximized ? 'Restore' : 'Maximize' }, + ...(!outerPresentationActive ? [{ id: 'maximize', label: 'Move into Dock' }] : []), { id: 'pin', label: node?.isPinned ? 'Unlock' : 'Lock' }, { type: 'separator' }, { id: 'front', label: 'Move to Front' }, @@ -621,14 +683,18 @@ const CanvasNode: React.FC = ({ { id: 'close', label: 'Close', accelerator: 'Cmd+W' }, ]) switch (id) { - case 'maximize': handleToggleMaximize(); break + case 'maximize': { + const panelId = activeLeafPanelId(dockStoreApi.getState().zones.center.layout) + if (panelId) handlePresentPanel(panelId) + break + } case 'pin': handleTogglePin(); break case 'front': canvasApi.getState().moveToFront(nodeId); break case 'back': canvasApi.getState().moveToBack(nodeId); break case 'close': handleClose(); break } }, - [maximized, node?.isPinned, handleToggleMaximize, handleTogglePin, handleClose, canvasApi, nodeId], + [node?.isPinned, handlePresentPanel, handleTogglePin, handleClose, canvasApi, dockStoreApi, nodeId, outerPresentationActive], ) // --- Computed styles ------------------------------------------------------- @@ -638,7 +704,6 @@ const CanvasNode: React.FC = ({ isFocused, isSelected, activityState, - isAnimatingLayout, isHovered, chromeTint, isWholeNodeDragSource, @@ -847,9 +912,11 @@ const CanvasNode: React.FC = ({ export default React.memo(CanvasNode, (prev, next) => { return ( prev.nodeId === next.nodeId && + prev.canvasPanelId === next.canvasPanelId && prev.isFocused === next.isFocused && prev.activityState === next.activityState && prev.dockStoreApi === next.dockStoreApi && + prev.outerDockStoreApi === next.outerDockStoreApi && prev.renderPanel === next.renderPanel && prev.title === next.title ) diff --git a/src/renderer/canvas/CanvasToolbar.test.ts b/src/renderer/canvas/CanvasToolbar.test.ts index 2ff829c13..51d2ec9dc 100644 --- a/src/renderer/canvas/CanvasToolbar.test.ts +++ b/src/renderer/canvas/CanvasToolbar.test.ts @@ -36,12 +36,14 @@ describe('CanvasToolbar — minimap section', () => { expect(SOURCE).not.toMatch(/\{showMinimap && \(/) }) - it('collapses from measured overlap and expands vertically from the bottom-right', () => { + it('collapses from measured overlap and keeps compact actions left of a bottom-right minimap', () => { expect(SOURCE).toContain('horizontalCardRef.current') expect(SOURCE).toContain('shrink-0 w-max pointer-events-auto') expect(SOURCE).toContain('centeredRight <= areaWidth - bottomRightInset') expect(SOURCE).toContain('className="absolute bottom-4 z-50 pointer-events-none"') - expect(SOURCE).toContain("style={{ right: '1rem' }}") + expect(SOURCE).toContain('const compactToolbarRight = mmBottom && mmRight ? 16 + minimapWidth + 8 : 16') + expect(SOURCE).toContain('style={{ right: compactToolbarRight }}') + expect(SOURCE).toContain("...(mmRight ? { right: '1rem' } : { left: '1rem' })") expect(SOURCE).toContain('card.isConnected && card.offsetWidth > 0') expect(SOURCE).not.toContain('onMouseEnter={() => setHovered(true)}') }) diff --git a/src/renderer/canvas/CanvasToolbar.tsx b/src/renderer/canvas/CanvasToolbar.tsx index 9b92b94d6..1d3e0d791 100644 --- a/src/renderer/canvas/CanvasToolbar.tsx +++ b/src/renderer/canvas/CanvasToolbar.tsx @@ -191,6 +191,7 @@ const CanvasToolbar: React.FC = ({ const minimapWidth = minimapOpen ? 220 : 44 const bottomLeftInset = mmBottom && !mmRight ? 16 + minimapWidth + 8 : 16 const bottomRightInset = mmBottom && mmRight ? 16 + minimapWidth + 8 : 16 + const compactToolbarRight = mmBottom && mmRight ? 16 + minimapWidth + 8 : 16 const centeredLeft = (areaWidth - toolbarWidth) / 2 const centeredRight = centeredLeft + toolbarWidth const isHorizontal = areaWidth === 0 || toolbarWidth === 0 || ( @@ -358,11 +359,11 @@ const CanvasToolbar: React.FC = ({
) : ( /* Narrow canvas: one bottom-right button expands the toolbar upward. If - the minimap occupies that corner, keep the button directly beside it. */ + the minimap occupies that corner, keep the actions directly left of it. */
= ({ className="absolute z-50 flex gap-2 pointer-events-auto" style={{ ...(mmBottom ? { bottom: '1rem' } : { top: '1rem' }), - ...(mmRight ? { right: !isHorizontal && mmBottom ? 'calc(1rem + 52px)' : '1rem' } : { left: '1rem' }), + ...(mmRight ? { right: '1rem' } : { left: '1rem' }), flexDirection: mmRight ? 'row' : 'row-reverse', alignItems: mmBottom ? 'flex-end' : 'flex-start', }} diff --git a/src/renderer/canvas/Minimap.test.tsx b/src/renderer/canvas/Minimap.test.tsx index 356a83c10..1f16fa415 100644 --- a/src/renderer/canvas/Minimap.test.tsx +++ b/src/renderer/canvas/Minimap.test.tsx @@ -5,7 +5,7 @@ import { PANEL_DEFINITIONS } from '../../shared/panels' const h = vi.hoisted(() => ({ canvas: { nodes: { - one: { id: 'one', origin: { x: 0, y: 0 }, size: { width: 600, height: 400 }, dockLayout: { type: 'tabs', id: 'one', panelIds: ['document'], activeIndex: 0 } }, + one: { id: 'one', origin: { x: 0, y: 0 }, size: { width: 600, height: 400 }, dockLayout: { type: 'tabs', id: 'one', panelIds: ['editor'], activeIndex: 0 } }, two: { id: 'two', origin: { x: 700, y: 0 }, size: { width: 600, height: 400 }, dockLayout: { type: 'tabs', id: 'two', panelIds: ['review'], activeIndex: 0 } }, }, zoomLevel: 1, containerSize: { width: 1000, height: 800 }, viewportOffset: { x: 0, y: 0 }, @@ -17,7 +17,7 @@ vi.mock('../stores/CanvasStoreContext', () => ({ })) vi.mock('../stores/appStore', () => ({ useAppStore: (select: any) => select({ selectedWorkspaceId: 'ws' }), - useWorkspacePanels: () => ({ document: { type: 'document' }, review: { type: 'review' } }), + useWorkspacePanels: () => ({ editor: { type: 'editor' }, review: { type: 'review' } }), })) vi.mock('../hooks/useAgentPanelInfo', () => ({ useAgentInfoByPanel: () => ({}) })) vi.mock('./worktree/useWorktreeMembership', () => ({ useWorktreeMembership: () => ({ groups: [] }) })) @@ -30,7 +30,7 @@ it('uses registered document and review colors in the minimap', () => { try { act(() => root.render()) const colors = [...host.querySelectorAll('[style]')].map(element => element.style.backgroundColor) - for (const type of ['document', 'review'] as const) { + for (const type of ['editor', 'review'] as const) { expect(colors).toContain(`var(--panel-${type}, ${PANEL_DEFINITIONS[type].mutedColor})`) } } finally { act(() => root.unmount()) } diff --git a/src/renderer/canvas/RecentScreenshotButton.test.tsx b/src/renderer/canvas/RecentScreenshotButton.test.tsx index 7e1a1011d..6b0d2fd9b 100644 --- a/src/renderer/canvas/RecentScreenshotButton.test.tsx +++ b/src/renderer/canvas/RecentScreenshotButton.test.tsx @@ -73,3 +73,119 @@ it('does not crash the canvas when an older preload lacks screenshot APIs', asyn vi.unstubAllGlobals() } }) + +it('renders the annotation marker for a saved screenshot', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const originalAPI = window.electronAPI + window.electronAPI = { + ...originalAPI, + getRecentScreenshot: vi.fn().mockResolvedValue([{ + id: 'annotated', filePath: '/annotated.png', dataUrl: 'data:image/png;base64,test', annotated: true, + }]), + onRecentScreenshotChanged: vi.fn(() => () => {}), + dragRecentScreenshot: vi.fn().mockResolvedValue(undefined), + } + const host = document.createElement('div') + const root = createRoot(host) + try { + await act(async () => root.render()) + expect(host.querySelector('[aria-label="Annotated screenshot"]')).not.toBeNull() + } finally { + act(() => root.unmount()) + window.electronAPI = originalAPI + vi.unstubAllGlobals() + } +}) + +it('opens clicked screenshots, navigates with overlay controls and keys, and closes the viewer', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const originalAPI = window.electronAPI + let nextImageWidth = 800 + let nextImageHeight = window.innerHeight - 160 + vi.stubGlobal('Image', class { + src = '' + naturalWidth = nextImageWidth + naturalHeight = nextImageHeight + decode = async () => {} + }) + const shots = ['first', 'second', 'third'].map(id => ({ id, filePath: `/${id}.png`, dataUrl: `data:image/png;base64,${id}` })) + window.electronAPI = { + ...originalAPI, + getRecentScreenshot: vi.fn().mockResolvedValue(shots), + readRecentScreenshot: vi.fn().mockResolvedValue('data:image/png;base64,iVBORw=='), + shellOpenPath: vi.fn().mockResolvedValue({ ok: true }), + onRecentScreenshotChanged: vi.fn(() => () => {}), + dragRecentScreenshot: vi.fn().mockResolvedValue(undefined), + } + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + const dialog = () => document.querySelector('[role="dialog"]')! + const key = (value: string) => act(async () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: value, bubbles: true }))) + const expectOriginal = (index: number) => { + expect(window.electronAPI.readRecentScreenshot).toHaveBeenLastCalledWith(shots[index].id) + expect(dialog().querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,iVBORw==') + } + try { + await act(async () => root.render()) + const thumbnail = host.querySelectorAll('[draggable="true"]')[1] + act(() => thumbnail.focus()) + await act(async () => thumbnail.click()) + expectOriginal(1) + expect(dialog().contains(document.activeElement)).toBe(true) + expect(dialog().querySelector('[aria-label="Annotate screenshot"]')).not.toBeNull() + nextImageWidth = 4000 + nextImageHeight = 100 + await key('ArrowRight') + expectOriginal(2) + expect(parseFloat((dialog().querySelector('img') as HTMLImageElement).style.height)).toBeCloseTo((window.innerWidth - 96) / 40) + expect((dialog().querySelector('img') as HTMLImageElement).style.width).toBe(`${window.innerWidth - 96}px`) + nextImageWidth = 800 + nextImageHeight = window.innerHeight - 160 + await key('ArrowRight') + expectOriginal(0) + await key('ArrowLeft') + expectOriginal(2) + await key('ArrowLeft') + expectOriginal(1) + await act(async () => (dialog().querySelector('[aria-label="Next screenshot"]') as HTMLButtonElement).click()) + expectOriginal(2) + await act(async () => (dialog().querySelector('[aria-label="Previous screenshot"]') as HTMLButtonElement).click()) + expectOriginal(1) + await key('Tab') + expect(document.activeElement?.getAttribute('aria-label')).toBe('Draw with pen') + expect(dialog().querySelector('img')?.className).toContain('rounded-xl') + expect(window.electronAPI.shellOpenPath).not.toHaveBeenCalled() + expect(dialog().querySelector('[aria-label="Annotate screenshot"]')).not.toBeNull() + await act(async () => (dialog().querySelector('[aria-label="Add comment"]') as HTMLButtonElement).click()) + const annotation = dialog().querySelector('[aria-label="Annotate screenshot"]') as SVGSVGElement + annotation.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}) }) + await act(async () => annotation.dispatchEvent(new MouseEvent('click', { clientX: 400, clientY: 300, bubbles: true }))) + const comment = dialog().querySelector('[aria-label="Comment 1"]') as HTMLTextAreaElement + expect(comment).not.toBeNull() + const caretKey = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }) + await act(async () => comment.dispatchEvent(caretKey)) + expect(caretKey.defaultPrevented).toBe(false) + expect(dialog().querySelector('[aria-label="Download screenshot"]')?.getAttribute('href')).toBe('data:image/png;base64,iVBORw==') + expect(dialog().querySelector('[aria-label="Download screenshot"]')?.getAttribute('download')).toBe('second.png') + await act(async () => (dialog().querySelector('[aria-label="Zoom in"]') as HTMLButtonElement).click()) + expect(dialog().querySelector('[aria-label="Fit screenshot"]')?.textContent).toBe('120%') + await act(async () => (dialog().querySelector('[aria-label="Fit screenshot"]') as HTMLButtonElement).click()) + expect(dialog().querySelector('[aria-label="Fit screenshot"]')?.textContent).toBe('100%') + await key('Escape') + expect(dialog()).toBeNull() + expect(document.activeElement).toBe(thumbnail) + await act(async () => thumbnail.click()) + await act(async () => (dialog().querySelector('[aria-label="Close screenshot preview"]') as HTMLButtonElement).click()) + expect(dialog()).toBeNull() + await act(async () => thumbnail.dispatchEvent(new Event('dragstart', { bubbles: true, cancelable: true }))) + await act(async () => thumbnail.click()) + expect(dialog()).toBeNull() + expect(window.electronAPI.dragRecentScreenshot).toHaveBeenCalledWith('second') + } finally { + act(() => root.unmount()) + host.remove() + window.electronAPI = originalAPI + vi.unstubAllGlobals() + } +}) diff --git a/src/renderer/canvas/RecentScreenshotButton.tsx b/src/renderer/canvas/RecentScreenshotButton.tsx index a74fadf22..eab7f61b7 100644 --- a/src/renderer/canvas/RecentScreenshotButton.tsx +++ b/src/renderer/canvas/RecentScreenshotButton.tsx @@ -1,13 +1,176 @@ -import { useEffect, useState } from 'react' -import { X } from '@phosphor-icons/react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { CaretLeft, CaretRight, DownloadSimple, Minus, PencilSimple, Plus, X } from '@phosphor-icons/react' import type { RecentScreenshot } from '../../shared/recentScreenshot' import { Tooltip } from '../ui/Tooltip' +import { createPortal } from 'react-dom' +import { ScreenshotDrawing } from './ScreenshotDrawing' + +function ScreenshotViewer({ screenshots, initialIndex, onClose, onSaved }: { + screenshots: RecentScreenshot[] + initialIndex: number + onClose: () => void + onSaved: (screenshot: RecentScreenshot) => void +}) { + const [index, setIndex] = useState(initialIndex) + const [image, setImage] = useState<{ id: string; url: string; width: number; height: number } | null>(null) + const [errorId, setErrorId] = useState(null) + const [zoom, setZoom] = useState(null) + const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight }) + const [drawingToolbarHost, setDrawingToolbarHost] = useState(null) + const [direction, setDirection] = useState(1) + const scrollRef = useRef(null) + useLayoutEffect(() => { + if (scrollRef.current) { scrollRef.current.scrollTop = 0; scrollRef.current.scrollLeft = 0 } + }, [image?.id]) + const imageElementRef = useRef(null) + const directionRef = useRef(direction) + directionRef.current = direction + const dialogRef = useRef(null) + useEffect(() => { + const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight }) + window.addEventListener('resize', resize) + const previous = document.activeElement + dialogRef.current?.focus({ preventScroll: true }) + return () => { + window.removeEventListener('resize', resize) + if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }) + } + }, []) + const screenshot = screenshots[index] + useEffect(() => { + let active = true + setErrorId(null) + const readImage = typeof window.electronAPI.readRecentScreenshot === 'function' + ? window.electronAPI.readRecentScreenshot(screenshot.id) + : window.electronAPI.fsReadBinary(screenshot.filePath).then(bytes => { + const data = new Uint8Array(bytes) + let binary = '' + for (let offset = 0; offset < data.length; offset += 8192) { + binary += String.fromCharCode(...data.subarray(offset, offset + 8192)) + } + const extension = screenshot.filePath.split('.').pop()?.toLowerCase() + const format = extension === 'jpg' ? 'jpeg' : extension === 'tif' ? 'tiff' : extension + return `data:image/${format || 'png'};base64,${btoa(binary)}` + }) + void readImage.then(async url => { + if (!active) return + const decoded = new Image() + decoded.src = url + await decoded.decode() + if (!active) return + const previous = imageElementRef.current + if (previous?.animate && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + const animation = previous.animate([ + { opacity: 1, transform: 'translateX(0)' }, + { opacity: 0, transform: `translateX(${-directionRef.current * 28}px)` }, + ], { duration: 120, easing: 'ease-in', fill: 'forwards' }) + await animation.finished.catch(() => {}) + if (!active) { animation.cancel(); return } + } + setZoom(null) + setImage({ id: screenshot.id, url, width: decoded.naturalWidth, height: decoded.naturalHeight }) + }).catch(() => { + if (active) setErrorId(screenshot.id) + }) + return () => { + active = false + } + }, [screenshot.id, screenshot.filePath]) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'Escape', 'Tab'].includes(event.key)) return + const target = event.target + const editingText = target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || (target instanceof HTMLElement && target.isContentEditable) + if (editingText && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) return + event.preventDefault() + event.stopImmediatePropagation() + if (event.key === 'Escape') { onClose(); return } + if (event.key === 'Tab') { + const buttons = Array.from(dialogRef.current?.querySelectorAll('button:not(:disabled), a[href]') ?? []) + const current = buttons.indexOf(document.activeElement as HTMLElement) + buttons[(current + (event.shiftKey ? -1 : 1) + buttons.length) % buttons.length]?.focus() + return + } + setDirection(event.key === 'ArrowLeft' ? -1 : 1) + setIndex(current => (current + (event.key === 'ArrowLeft' ? -1 : 1) + screenshots.length) % screenshots.length) + } + document.addEventListener('keydown', onKey, true) + return () => document.removeEventListener('keydown', onKey, true) + }, [screenshots.length, onClose]) + + const displayHeight = Math.max(80, viewport.height - 160) + const fit = image ? Math.min(1, Math.max(1, viewport.width - 96) / image.width, displayHeight / image.height) : 1 + const scale = zoom ?? fit + const controlClass = 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white/90 transition-colors hover:bg-white/20 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:opacity-30' + + return createPortal( +
event.stopPropagation()} onPointerMove={event => event.stopPropagation()} + onMouseDown={event => event.stopPropagation()} onMouseMove={event => event.stopPropagation()} + onWheel={event => event.stopPropagation()}> +
+
+
event.stopPropagation()}> + {image?.id === screenshot.id && } + +
+
+
+
+ {errorId === screenshot.id ? ( +

Could not load the original screenshot.

+ ) : image ? ( +
0 ? 'screenshot-slide-from-right' : 'screenshot-slide-from-left' }}> + {`Screenshot shot.id === image.id) + 1} of ${screenshots.length}`} + onClick={event => event.stopPropagation()} onError={() => setErrorId(screenshot.id)} draggable={false} + style={{ width: image.width * scale, height: image.height * scale }} + className="block max-w-none rounded-xl shadow-2xl" /> + onSaved(saved)} /> +
+ ) :

Loading screenshot...

} +
+
+
+
+
event.stopPropagation()}> + + {index + 1} / {screenshots.length} + +
+
event.stopPropagation()}> + + + +
+
+
+
, + document.body, + ) +} export function RecentScreenshotButton({ expandDown = false }: { expandDown?: boolean }) { const [screenshots, setScreenshots] = useState([]) const [dismissed, setDismissed] = useState([]) const [expanded, setExpanded] = useState(false) const [hoveredId, setHoveredId] = useState(null) + const [viewer, setViewer] = useState<{ screenshots: RecentScreenshot[]; initialIndex: number } | null>(null) + const dragged = useRef(false) useEffect(() => { // Renderer hot reload can run against an older preload bridge until restart. @@ -34,8 +197,9 @@ export function RecentScreenshotButton({ expandDown = false }: { expandDown?: bo }, []) const visible = screenshots.filter(screenshot => !dismissed.includes(screenshot.id)) - if (!visible.length) return null + if (!visible.length && !viewer) return null return ( + <>
- + + {screenshot.annotated && + + }
+ {viewer && setViewer(null)} onSaved={saved => { + setScreenshots(current => [saved, ...current.filter(shot => shot.id !== saved.id)].slice(0, 5)) + setViewer(null) + }} />} + ) } diff --git a/src/renderer/canvas/ScreenshotDrawing.test.tsx b/src/renderer/canvas/ScreenshotDrawing.test.tsx new file mode 100644 index 000000000..5ebee5e5a --- /dev/null +++ b/src/renderer/canvas/ScreenshotDrawing.test.tsx @@ -0,0 +1,61 @@ +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, expect, it, vi } from 'vitest' +import { ScreenshotDrawing } from './ScreenshotDrawing' + +afterEach(() => vi.unstubAllGlobals()) + +it('adds an editable speech-bubble comment and composites it into the saved PNG', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('Image', class { src = ''; decode = async () => {} }) + const context = { + drawImage: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), quadraticCurveTo: vi.fn(), closePath: vi.fn(), + save: vi.fn(), restore: vi.fn(), translate: vi.fn(), rotate: vi.fn(), + fill: vi.fn(), stroke: vi.fn(), measureText: vi.fn(() => ({ width: 40 })), fillText: vi.fn(), + set lineCap(_value: string) {}, set lineJoin(_value: string) {}, set strokeStyle(_value: string) {}, + set fillStyle(_value: string) {}, set lineWidth(_value: number) {}, set font(_value: string) {}, set textBaseline(_value: string) {}, + } + const originalGetContext = HTMLCanvasElement.prototype.getContext + const originalToDataURL = HTMLCanvasElement.prototype.toDataURL + HTMLCanvasElement.prototype.getContext = vi.fn(() => context) as never + HTMLCanvasElement.prototype.toDataURL = vi.fn(() => 'data:image/png;base64,annotated') + const originalAPI = window.electronAPI + const saved = { id: 'saved', filePath: '/desktop/saved.png', dataUrl: 'data:image/png;base64,thumb', annotated: true } + window.electronAPI = { ...originalAPI, saveRecentScreenshot: vi.fn().mockResolvedValue(saved) } + const host = document.createElement('div') + const toolbar = document.createElement('div') + document.body.append(host, toolbar) + const root = createRoot(host) + const onSaved = vi.fn() + try { + await act(async () => root.render()) + const svg = host.querySelector('svg')! + svg.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}) }) + await act(async () => (toolbar.querySelector('[aria-label="Add comment"]') as HTMLButtonElement).click()) + await act(async () => svg.dispatchEvent(new MouseEvent('click', { clientX: 400, clientY: 300, bubbles: true }))) + const input = host.querySelector('[aria-label="Comment 1"]') as HTMLTextAreaElement + expect(host.querySelector('[aria-label="Move comment 1"]')).not.toBeNull() + expect(host.querySelector('polygon')).toBeNull() + expect(host.querySelector('[aria-label="Resize comment 1"] circle')).toBeNull() + expect(host.querySelector('[aria-label="Rotate comment 1"] circle')).toBeNull() + expect((host.querySelector('[aria-label="Rotate comment 1"]') as SVGGElement).style.cursor).toContain('data:image/svg+xml') + expect(host.querySelector('foreignObject > div')?.className).toContain('bg-black/30') + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set! + setter.call(input, 'Please align this section') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(Number(host.querySelector('foreignObject')?.getAttribute('height'))).toBeLessThan(60) + await act(async () => [...toolbar.querySelectorAll('button')].find(button => button.textContent === 'Save')!.click()) + expect(context.fillText).toHaveBeenCalledWith('Please align this section', expect.any(Number), expect.any(Number), expect.any(Number)) + expect(window.electronAPI.saveRecentScreenshot).toHaveBeenCalledWith('shot', 'data:image/png;base64,annotated') + expect(onSaved).toHaveBeenCalledWith(saved, 'data:image/png;base64,annotated') + } finally { + await act(async () => root.unmount()) + host.remove(); toolbar.remove() + window.electronAPI = originalAPI + HTMLCanvasElement.prototype.getContext = originalGetContext + HTMLCanvasElement.prototype.toDataURL = originalToDataURL + } +}) diff --git a/src/renderer/canvas/ScreenshotDrawing.tsx b/src/renderer/canvas/ScreenshotDrawing.tsx new file mode 100644 index 000000000..971b70e32 --- /dev/null +++ b/src/renderer/canvas/ScreenshotDrawing.tsx @@ -0,0 +1,311 @@ +import { useRef, useState } from 'react' +import type { RecentScreenshot } from '../../shared/recentScreenshot' +import { createPortal } from 'react-dom' +import { ArrowCounterClockwise, ChatCircleText, DotsSix, DownloadSimple, PencilSimple, Trash } from '@phosphor-icons/react' + +type Point = { x: number; y: number } +type Stroke = { color: string; width: number; points: Point[] } +type Callout = Point & { id: string; text: string; width: number; height: number; rotation: number; manuallySized: boolean } +type Tool = 'pen' | 'comment' +type ResizeEdges = { left: boolean; right: boolean; top: boolean; bottom: boolean } + +const ROTATE_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18 8a7 7 0 1 0 1 7M18 4v5h-5' fill='none' stroke='white' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M18 8a7 7 0 1 0 1 7M18 4v5h-5' fill='none' stroke='%23171717' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 12 12, crosshair` + +function unrotatePoint(value: Point, center: Point, rotation: number): Point { + const angle = -rotation * Math.PI / 180 + const dx = value.x - center.x + const dy = value.y - center.y + return { x: center.x + dx * Math.cos(angle) - dy * Math.sin(angle), y: center.y + dx * Math.sin(angle) + dy * Math.cos(angle) } +} + +function calloutFontSize(imageHeight: number): number { + return Math.max(14, Math.min(24, imageHeight / 36)) +} + +function sizeCallout(text: string, imageWidth: number, imageHeight: number): Pick { + const fontSize = calloutFontSize(imageHeight) + const lineHeight = fontSize * 1.25 + const maxTextWidth = Math.max(80, Math.min(imageWidth * 0.6, 420 * imageHeight / 600)) + const lines = text.split('\n') + const widths = lines.map(line => Math.max(fontSize * 2, line.length * fontSize * 0.58)) + const wrappedLines = widths.reduce((total, lineWidth) => total + Math.max(1, Math.ceil(lineWidth / maxTextWidth)), 0) + return { + width: Math.min(imageWidth, Math.max(112, Math.min(maxTextWidth, Math.max(...widths)) + 70)), + height: Math.min(imageHeight, Math.max(44, wrappedLines * lineHeight + 20)), + } +} + +function bubblePath(context: CanvasRenderingContext2D, callout: Callout, radius: number): void { + const { x, y, width, height } = callout + context.beginPath() + context.moveTo(x + radius, y) + context.lineTo(x + width - radius, y) + context.quadraticCurveTo(x + width, y, x + width, y + radius) + context.lineTo(x + width, y + height - radius) + context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + context.lineTo(x + radius, y + height) + context.quadraticCurveTo(x, y + height, x, y + height - radius) + context.lineTo(x, y + radius) + context.quadraticCurveTo(x, y, x + radius, y) + context.closePath() +} + +function drawCallout(context: CanvasRenderingContext2D, callout: Callout, imageHeight: number): void { + const centerX = callout.x + callout.width / 2 + const centerY = callout.y + callout.height / 2 + context.save() + context.translate(centerX, centerY) + context.rotate(callout.rotation * Math.PI / 180) + context.translate(-centerX, -centerY) + bubblePath(context, callout, Math.min(18, callout.height / 5)) + context.fillStyle = 'rgba(18,18,18,0.32)' + context.fill() + context.strokeStyle = 'rgba(255,255,255,0.32)' + context.lineWidth = Math.max(1, imageHeight / 900) + context.stroke() + const fontSize = calloutFontSize(imageHeight) + const lineHeight = fontSize * 1.25 + context.fillStyle = '#ffffff' + context.font = `600 ${fontSize}px system-ui, sans-serif` + context.textBaseline = 'top' + const maxWidth = callout.width - 28 + const lines: string[] = [] + for (const paragraph of callout.text.trim().split('\n')) { + const paragraphLines: string[] = [] + for (const word of paragraph.split(/\s+/)) { + const candidate = paragraphLines.length ? `${paragraphLines[paragraphLines.length - 1]} ${word}` : word + if (paragraphLines.length && context.measureText(candidate).width > maxWidth) paragraphLines.push(word) + else if (paragraphLines.length) paragraphLines[paragraphLines.length - 1] = candidate + else paragraphLines.push(candidate) + } + lines.push(...(paragraphLines.length ? paragraphLines : [''])) + } + lines.slice(0, Math.max(1, Math.floor((callout.height - 24) / lineHeight))).forEach((line, index) => { + context.fillText(line, callout.x + 14, callout.y + 12 + index * lineHeight, maxWidth) + }) + context.restore() +} + +export function ScreenshotDrawing({ id, url, width, height, toolbarHost, onClose, onSaved }: { + id: string; url: string; width: number; height: number; toolbarHost: HTMLElement | null; onClose: () => void; onSaved: (screenshot: RecentScreenshot, url: string) => void +}) { + const [strokes, setStrokes] = useState([]) + const [callouts, setCallouts] = useState([]) + const [tool, setTool] = useState('pen') + const [color, setColor] = useState('#ff453a') + const [brush, setBrush] = useState(4) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const activePointer = useRef(null) + const calloutDrag = useRef<{ id: string; pointerId: number; point: Point; x: number; y: number } | null>(null) + const calloutResize = useRef<{ id: string; pointerId: number; point: Point; x: number; y: number; width: number; height: number; rotation: number; edges: ResizeEdges } | null>(null) + const calloutRotation = useRef<{ id: string; pointerId: number; center: Point; angle: number; rotation: number } | null>(null) + const svgRef = useRef(null) + const point = (event: { clientX: number; clientY: number }): Point => { + const bounds = svgRef.current?.getBoundingClientRect() + if (!bounds) return { x: 0, y: 0 } + return { + x: Math.max(0, Math.min(width, (event.clientX - bounds.left) * width / bounds.width)), + y: Math.max(0, Math.min(height, (event.clientY - bounds.top) * height / bounds.height)), + } + } + const save = async () => { + setSaving(true) + setMessage('') + try { + const original = new Image() + original.src = url + await original.decode() + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) throw new Error('Drawing is unavailable.') + context.drawImage(original, 0, 0, width, height) + context.lineCap = 'round' + context.lineJoin = 'round' + for (const stroke of strokes) { + context.strokeStyle = stroke.color + context.lineWidth = stroke.width + context.beginPath() + context.moveTo(stroke.points[0].x, stroke.points[0].y) + for (const p of stroke.points.slice(1)) context.lineTo(p.x, p.y) + context.stroke() + } + for (const callout of callouts) { + if (callout.text.trim()) drawCallout(context, callout, height) + } + const dataUrl = canvas.toDataURL('image/png') + if (typeof window.electronAPI.saveRecentScreenshot !== 'function') { + setMessage('Restart Cate to enable saving to the screenshot stack. Your drawing is still here.') + return + } + const saved = await window.electronAPI.saveRecentScreenshot(id, dataUrl) + onSaved(saved, dataUrl) + } catch (error) { + console.error('Could not save annotated screenshot', error) + setMessage('Could not save. Your drawing is still here; try again.') + } finally { + setSaving(false) + } + } + const buttonClass = 'flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium text-white/80 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white' + return <> + { + event.stopPropagation() + if (tool !== 'comment' || saving) return + const p = point(event) + const { width: bubbleWidth, height: bubbleHeight } = sizeCallout('', width, height) + setCallouts(current => [...current, { + id: crypto.randomUUID(), text: '', width: bubbleWidth, height: bubbleHeight, rotation: 0, manuallySized: false, + x: Math.max(0, Math.min(width - bubbleWidth, p.x - bubbleWidth / 2)), + y: Math.max(0, Math.min(height - bubbleHeight, p.y - bubbleHeight / 2)), + }]) + }} + onPointerDown={event => { + if (tool !== 'pen' || event.button !== 0 || saving || activePointer.current !== null) return + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + activePointer.current = event.pointerId + const p = point(event) + setMessage('') + setStrokes(current => [...current, { color, width: brush * height / 600, points: [p, { x: p.x + 0.01, y: p.y }] }]) + }} + onPointerMove={event => { + if (activePointer.current !== event.pointerId) return + const p = point(event) + setStrokes(current => current.map((stroke, index) => index === current.length - 1 ? { ...stroke, points: [...stroke.points, p] } : stroke)) + }} + onPointerUp={event => { if (activePointer.current === event.pointerId) activePointer.current = null }} + onPointerCancel={() => { activePointer.current = null }} + onLostPointerCapture={() => { activePointer.current = null }}> + + {strokes.map((stroke, index) => `${p.x},${p.y}`).join(' ')} + fill="none" stroke={stroke.color} strokeWidth={stroke.width} strokeLinecap="round" strokeLinejoin="round" />)} + {callouts.map((callout, index) => { + const center = { x: callout.x + callout.width / 2, y: callout.y + callout.height / 2 } + return + event.stopPropagation()}> +
+ + +