fix(canvas): discard stale zoom fallback pictures

Backing installation advances the preview baseline but previously stamped old whole-scene pictures with the new scene version. Zooming outside backing coverage could replay deleted content and hide newly created shapes.

Discard mismatched pictures before advancing that baseline, preserving valid pictures. Cover scene, preview, page and font invalidation plus exact visible pixels through zoom reversals in retained and tiled renderers.
This commit is contained in:
Danila Poyarkov 2026-09-15 17:40:34 +03:00
parent 453b27f15f
commit 79552129b5
5 changed files with 167 additions and 0 deletions

View file

@ -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.

View file

@ -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

View file

@ -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<HTMLCanvasElement>('[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()
}
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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()