fix: resolve 41 bugs and wire 21 unimplemented features in image editor

Canvas rendering:
- Fix Konva filter application order (filters before cache)
- Implement 6 missing filters (motionBlur, radialBlur, surfaceBlur, vignette, grain, sharpen)
- Implement exposure, vibrance, warmth adjustments as custom Konva filters
- Apply layer blend modes via globalCompositeOperation
- Apply object effects (drop shadow, outer glow, stroke) to all shapes
- Mount SmartGuidesOverlay during move tool drag
- Clip pixel grid to visible viewport (200-line cap for performance)

Store logic:
- resizeImage now scales all objects proportionally (points, radii, fontSize)
- rotate/flip/trim handle line/arrow points arrays and center-based objects
- applyCrop creates cropped source image via offscreen canvas
- invertSelection creates mask from bounds when no mask exists
- cutObjects uses single atomic set() to prevent race conditions
- sendToBack respects layer ordering in multi-layer documents
- Add batchNudge() and commitHistory() for undoable nudge operations
- Add updateLayerThumbnail() method

Tool hooks:
- Fix clone stamp/dodge/burn perf (toDataURL only on mouseUp, not every move)
- Fix magic wand zoom/pixelRatio with explicit stage.toCanvas() viewport
- Fix eyedropper sampling with unzoomed canvas export
- Fix selection tool stale closure via isDrawingRef
- Implement polygonal lasso (click-to-place vertices, double-click to close)
- Implement selection subtract mode (geometric and mask-based)
- Implement gradient live preview during drag
- Fix transform/move tool to persist changes and handle ellipse/polygon/star

UI wiring:
- Mount rulers and guidelines in editor page
- Wire histogram with live canvas imageData
- Wire autosave recovery with blob-to-dataURL conversion
- Wire fill dialog to Shift+Backspace shortcut
- Wire eyedropper and transform options to options bar
- Fix history panel undo/redo button reactive state via useSyncExternalStore
- Fix layer row name click to select layer (timer-based click/dblclick)
- Fix zoom animation coordinate drift with progressive store sync
- Fix copy merged to use Konva stage composite export

Tests:
- 49 new unit tests (store fixes + konva filters)
- 8 new E2E test files with 39 test cases
This commit is contained in:
SnapOtter
2026-05-08 16:43:27 +08:00
parent 3a2b1ee105
commit dd73a8a50a
30 changed files with 3404 additions and 203 deletions
+83
View File
@@ -0,0 +1,83 @@
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
test.describe("Editor Autosave", () => {
test("editor saves state periodically (check localStorage has autosave data)", async ({
editorPage: page,
}) => {
test.slow();
await createNewDocument(page);
// Draw something to mark the canvas dirty
await selectTool(page, "brush");
await drawOnCanvas(page, 100, 100, 300, 300);
await page.waitForTimeout(300);
// Manually trigger autosave by calling saveEditorState from the page context.
// The autosave interval is 60s which is too long for E2E, so invoke directly.
await page.evaluate(async () => {
// The saveEditorState function writes to localStorage under this key
const { saveEditorState } = await import("/src/components/editor/common/export-dialog.tsx");
await saveEditorState();
});
await page.waitForTimeout(500);
// Verify localStorage contains the autosave key
const autosaveData = await page.evaluate(() => {
return localStorage.getItem("snapotter-editor-autosave");
});
expect(autosaveData).not.toBeNull();
// Parse and verify the structure
const parsed = JSON.parse(autosaveData!);
expect(parsed.version).toBe(1);
expect(parsed.timestamp).toBeGreaterThan(0);
expect(parsed.state).toBeDefined();
expect(parsed.state.canvasSize).toBeDefined();
expect(parsed.state.canvasSize.width).toBeGreaterThan(0);
expect(parsed.state.canvasSize.height).toBeGreaterThan(0);
expect(parsed.state.layers).toBeDefined();
expect(Array.isArray(parsed.state.layers)).toBe(true);
});
test("modified content persists across page reload", async ({ editorPage: page }) => {
test.slow();
await createNewDocument(page);
// Draw something to create content
await selectTool(page, "brush");
await drawOnCanvas(page, 100, 100, 300, 300);
await page.waitForTimeout(300);
// Trigger autosave manually
await page.evaluate(async () => {
const { saveEditorState } = await import("/src/components/editor/common/export-dialog.tsx");
await saveEditorState();
});
await page.waitForTimeout(500);
// Verify autosave data exists before reload
const dataBefore = await page.evaluate(() => {
return localStorage.getItem("snapotter-editor-autosave");
});
expect(dataBefore).not.toBeNull();
// Reload the page
await page.reload();
await page.waitForTimeout(3000);
// After reload, localStorage should still have the autosave data
const dataAfter = await page.evaluate(() => {
return localStorage.getItem("snapotter-editor-autosave");
});
expect(dataAfter).not.toBeNull();
// The data should contain the same canvas dimensions
const parsedBefore = JSON.parse(dataBefore!);
const parsedAfter = JSON.parse(dataAfter!);
expect(parsedAfter.state.canvasSize.width).toBe(parsedBefore.state.canvasSize.width);
expect(parsedAfter.state.canvasSize.height).toBe(parsedBefore.state.canvasSize.height);
});
});