diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ff9eab1..bbf5e34b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ ### Fixed +- Keep newly created and edited objects visible during zoom instead of replaying outdated scene content. - Keep property fields and paint previews live during editing, and keep rotated selection labels aligned and object edges stable when previews settle. - Keep Undo and Redo commands available as edit history changes, without requiring another scene edit. - Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic. diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index 46e8d9379..1f53619e0 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -400,6 +400,17 @@ function installSceneBackingImage( fontGeneration: r.fontGeneration, ...sceneBackingMetrics(backing) } + // Advancing the preview baseline must not relabel an older whole-scene + // picture as current: navigation can still fall back to that picture. + if ( + r.scenePictureVersion !== sceneVersion || + r.scenePicturePositionPreviewVersion !== positionPreviewVersion || + r.scenePicturePageId !== r.pageId || + r.scenePictureFontGeneration !== r.fontGeneration + ) { + r.scenePicture?.delete() + r.scenePicture = null + } r.scenePictureVersion = sceneVersion r.scenePicturePositionPreviewVersion = positionPreviewVersion r.scenePicturePageId = r.pageId diff --git a/tests/e2e/canvas/scene-picture-freshness.spec.ts b/tests/e2e/canvas/scene-picture-freshness.spec.ts new file mode 100644 index 000000000..2877d43d2 --- /dev/null +++ b/tests/e2e/canvas/scene-picture-freshness.spec.ts @@ -0,0 +1,127 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '#tests/e2e/fixtures' +import { expectDefined } from '#tests/helpers/assert' +import { CanvasHelper } from '#tests/helpers/canvas' + +async function observeScenePixels(page: Page) { + return page.evaluateHandle(() => { + const store = window.openPencil?.getStore?.() + const renderer = store?.canvasRenderers.find((r) => r.tracksSceneSettlement) + const node = store?.getSelectedNodes()[0] + const scene = document.querySelector('[data-test-id="scene-canvas-element"]') + if (!store || !renderer || !node || !scene) throw new Error('Scene unavailable') + const copy = document.createElement('canvas') + copy.width = copy.height = 1 + const context = copy.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Readback unavailable') + const surface = renderer.surface + const flush = surface.flush + const samples: { zoom: number; pixel: number[] }[] = [] + surface.flush = (...args) => { + const result = flush.apply(surface, args) + const position = store.graph.getAbsolutePosition(node.id) + const x = Math.floor( + (renderer.panX + (position.x + node.width / 2) * renderer.zoom) * renderer.dpr + ) + const y = Math.floor( + (renderer.panY + (position.y + node.height / 2) * renderer.zoom) * renderer.dpr + ) + context.clearRect(0, 0, 1, 1) + context.drawImage(scene, x, y, 1, 1, 0, 0, 1, 1) + samples.push({ + zoom: renderer.zoom, + pixel: Array.from(context.getImageData(0, 0, 1, 1).data) + }) + return result + } + return { + samples: () => samples, + dispose() { + surface.flush = flush + copy.width = 0 + } + } + }) +} + +test.use({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 }) + +for (const renderer of ['retained', 'tiled'] as const) { + test(`${renderer}: zoom fallback does not resurrect a picture from before creation`, async ({ + page + }) => { + await page.goto(`/?test&no-rulers&renderer=${renderer}&navigation-benchmark`) + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('Editor unavailable') + const node = store.graph.createNode('RECTANGLE', store.state.currentPageId, { + x: 300, + y: 250, + width: 220, + height: 140, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + store.select([node.id]) + store.requestRender() + }) + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + const box = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds') + const x = box.x + 300 + const y = box.y + 250 + await page.mouse.move(x + 110, y + 70) + await page.keyboard.down('Control') + try { + // Leave backing coverage to record a whole-scene fallback, then return. + await page.mouse.wheel(0, 240) + await canvas.waitForRender() + await page.mouse.wheel(0, -240) + } finally { + await page.keyboard.up('Control') + } + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + expect( + await page.evaluate(() => + window.openPencil + ?.getStore?.() + .canvasRenderers.some((r) => r.tracksSceneSettlement && r.scenePicture !== null) + ) + ).toBe(true) + await page.keyboard.press('Backspace') + await page.keyboard.press('r') + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x + 220, y + 140) + const probe = await observeScenePixels(page) + try { + await page.mouse.up() + await page.mouse.move(x + 110, y + 70) + await page.keyboard.down('Control') + for (const delta of [60, 60, 60, -180, 180, -180]) { + await page.mouse.wheel(0, delta) + await canvas.waitForRender() + } + await page.keyboard.up('Control') + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + const samples = await probe.evaluate((p) => p.samples()) + expect(samples.length).toBeGreaterThan(4) + expect(samples.some((sample) => sample.zoom < 0.3)).toBe(true) + for (const sample of samples) { + expect(sample.pixel, JSON.stringify(sample)).toEqual([212, 212, 212, 255]) + } + await expect(page).toHaveScreenshot('fresh-scene-picture.png', { + clip: { x: x - 20, y: y - 20, width: 260, height: 200 }, + maxDiffPixels: 0, + threshold: 0 + }) + canvas.assertNoErrors() + } finally { + await page.keyboard.up('Control') + await page.mouse.up() + await probe.evaluate((p) => p.dispose()) + await probe.dispose() + } + }) +} diff --git a/tests/e2e/canvas/scene-picture-freshness.spec.ts-snapshots/fresh-scene-picture-openpencil-darwin.png b/tests/e2e/canvas/scene-picture-freshness.spec.ts-snapshots/fresh-scene-picture-openpencil-darwin.png new file mode 100644 index 000000000..c84cd3858 Binary files /dev/null and b/tests/e2e/canvas/scene-picture-freshness.spec.ts-snapshots/fresh-scene-picture-openpencil-darwin.png differ diff --git a/tests/engine/render/canvas/retained-backing-pixels.test.ts b/tests/engine/render/canvas/retained-backing-pixels.test.ts index e3432435b..b810c9db2 100644 --- a/tests/engine/render/canvas/retained-backing-pixels.test.ts +++ b/tests/engine/render/canvas/retained-backing-pixels.test.ts @@ -5,6 +5,7 @@ import { SceneGraph } from '@open-pencil/scene-graph' import { initCanvasKit } from '#cli/headless' import { SkiaRenderer } from '#core/canvas' import { render } from '#core/canvas/renderer/pipeline' +import { renderSceneBacking } from '#core/canvas/renderer/retained-backing' import { expectDefined } from '#tests/helpers/assert' @@ -42,6 +43,33 @@ function createRenderer(pageId: string) { return renderer } +for (const scope of ['matching', 'scene', 'preview', 'page', 'font'] as const) { + test(`backing installation preserves only a matching whole-scene picture: ${scope}`, () => { + const { graph, pageId } = createFixture() + const renderer = createRenderer(pageId) + try { + render(renderer, graph, new Set(), {}, 1, 'full') + const picture = expectDefined(renderer.scenePicture, 'whole-scene picture') + const deletion = spyOn(picture, 'delete') + try { + if (scope === 'scene') renderer.scenePictureVersion-- + if (scope === 'preview') renderer.scenePicturePositionPreviewVersion-- + if (scope === 'page') renderer.scenePicturePageId = null + if (scope === 'font') renderer.scenePictureFontGeneration-- + expect(renderSceneBacking(renderer, renderer.surface.getCanvas(), graph, 1)).not.toBe(false) + expect(renderer.scenePicture).toBe(scope === 'matching' ? picture : null) + expect(deletion).toHaveBeenCalledTimes(scope === 'matching' ? 0 : 1) + expect(renderer.scenePictureVersion).toBe(1) + expect(renderer.scenePicturePositionPreviewVersion).toBe(graph.positionPreviewVersion) + } finally { + deletion.mockRestore() + } + } finally { + renderer.destroy() + } + }) +} + function pixels(renderer: SkiaRenderer) { renderer.surface.flush() const image = renderer.surface.makeImageSnapshot()