feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)

Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const {
dragOnCanvas,
encodedImagePixelDigest,
@@ -19,7 +20,7 @@ async function addAdjustment(page, type) {
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
@@ -36,9 +37,9 @@ test('Levels is a retained stack layer with clipping, masks, history, and persis
expect(await flattenedPixelDigest(page)).not.toEqual(before);
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
@@ -89,7 +90,7 @@ test('Curves supports editable RGB and channel points with reset and cancel', as
expect(curves.adjustment.params.points.blue).toHaveLength(3);
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
await page.locator('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
@@ -134,7 +135,7 @@ test('color adjustment controls retain neutral reusable parameters', async ({ pa
const nextDigest = await flattenedPixelDigest(page);
expect(nextDigest).not.toBe(priorDigest);
if (item.compare) {
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
@@ -247,7 +248,7 @@ test('all adjustment popups remain usable on a narrow phone viewport', async ({
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
@@ -301,7 +302,7 @@ test('retained Sharpen keeps editable amount and survives draft reopen', async (
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
await page.mouse.down();
@@ -345,7 +346,7 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
+2 -1
View File
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor } = require('./helpers.js');
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
+14 -11
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
@@ -8,11 +9,13 @@ async function canvasPoint(page, xRatio, yRatio) {
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
await page.locator('#ge-brush-spacing').fill('28');
await page.locator('#ge-brush-smoothing').fill('52');
await page.locator('#ge-brush-blend').selectOption('multiply');
// The pen-pressure and preset rows live behind the options-bar overflow ('...').
await page.locator('#ge-options-more').click();
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
@@ -31,7 +34,7 @@ test('brush presets and shared stroke controls remain reusable', async ({ page }
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700');
await page.locator('#ge-brush-spacing').fill('8');
await page.locator('#ge-brush-smoothing').fill('65');
@@ -59,7 +62,7 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
@@ -67,7 +70,7 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
const sample = await canvasPoint(page, 0.3, 0.3);
await page.mouse.click(sample.x, sample.y);
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
await selectTool(page, 'eyedropper');
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
@@ -82,7 +85,7 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
for (const tool of ['dodge', 'burn']) {
const before = await flattenedPixelDigest(page);
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
await selectTool(page, tool);
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
await page.mouse.move(from.x, from.y);
@@ -95,13 +98,13 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#111111';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.mouse.click(blemish.x, blemish.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await selectTool(page, 'heal');
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
@@ -115,7 +118,7 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
test('healing brush can use an optional sampled source', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
@@ -124,7 +127,7 @@ test('healing brush can use an optional sampled source', async ({ page }) => {
const source = await canvasPoint(page, 0.28, 0.5);
await page.mouse.click(source.x, source.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await selectTool(page, 'heal');
await page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
@@ -153,7 +156,7 @@ test('healing brush can use an optional sampled source', async ({ page }) => {
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
@@ -169,7 +172,7 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
const y = Math.round(canvas.height * 0.5);
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
await selectTool(page, 'smudge');
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.mouse.move(source.x, source.y);
await page.mouse.down();
+2 -1
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
@@ -26,7 +27,7 @@ test('before compare is visual-only and toggles back to the edited document', as
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const baselineHash = await mainCanvasHash(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
+6 -5
View File
@@ -1,5 +1,6 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const {
compareExportPixels,
dragOnCanvas,
@@ -16,7 +17,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
@@ -25,11 +26,11 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
await editItem.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await editItem.locator('.ge-true-mask-btn').click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
@@ -43,7 +44,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
await expect(page.locator('#ge-text-section')).toBeVisible();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
@@ -52,7 +53,7 @@ test('layered document survives edit, mask, transform, crop, reopen, and export'
await page.locator('#ge-text-size').press('Enter');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
await page.locator('.ge-crop-apply-btn').click();
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
@@ -6,7 +7,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
@@ -14,7 +15,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
await selectTool(page, 'move');
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
@@ -22,7 +23,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const beforeMove = await editorState(page);
const bounds = beforeMove.selection.bounds;
@@ -33,7 +34,7 @@ test('tool switching cancels incomplete crop and selection gestures without stal
await page.mouse.down();
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
await selectTool(page, 'brush');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
@@ -49,7 +50,7 @@ test('Escape cancels an active crop without changing the document', async ({ pag
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
@@ -80,7 +81,7 @@ test('touch crop and selection gestures complete through the shared lifecycle',
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
@@ -90,7 +91,7 @@ test('touch crop and selection gestures complete through the shared lifecycle',
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
@@ -117,7 +118,7 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
@@ -144,7 +145,7 @@ test('touch cancellation rolls back a partial brush stroke', async ({ browser, b
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
@@ -168,7 +169,7 @@ test('touch cancellation rolls back a partial brush stroke', async ({ browser, b
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await selectTool(page, 'crop');
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
+5 -4
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs');
const {
compareExportPixels,
@@ -23,7 +24,7 @@ async function downloadPng(page, filename) {
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
@@ -37,7 +38,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
@@ -73,7 +74,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
@@ -83,7 +84,7 @@ test('group retained effects export the visible composite and survive reopen', a
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
+5 -4
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
@@ -16,17 +17,17 @@ test('group mask paints, toggles, survives history and reopen, and protects ungr
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-group-mask-btn').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
await expect(page.locator('.ge-layer-controls:visible button[title="Delete group masks before ungrouping"]')).toBeDisabled();
let current = await editorState(page);
expect(current.groups[0].masks).toHaveLength(1);
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
@@ -62,7 +63,7 @@ test('group mask paints, toggles, survives history and reopen, and protects ungr
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
await groupRow.click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
+12
View File
@@ -362,6 +362,17 @@ async function compareExportPixels(page, bytes, size) {
}, { base64: bytes.toString('base64'), targetSize: size });
}
async function selectTool(page, toolId) {
const face = page.locator(`.ge-tool-slot-face[data-tool="${toolId}"]`);
if (await face.count() > 0) {
await face.first().click();
return;
}
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a
// click directly so the helper does not depend on hover/flyout visibility.
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click');
}
module.exports = {
compareExportPixels,
dragOnCanvas,
@@ -371,5 +382,6 @@ module.exports = {
openBlankEditor,
openExportDialog,
reopenDraft,
selectTool,
waitForDraft,
};
@@ -26,7 +26,7 @@ test('clipping mask uses base alpha and survives undo, redo, and server reopen',
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
await expect(editRow).toHaveClass(/clipped/);
await expect(editRow.locator('.ge-layer-clipped-marker')).toBeVisible();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
@@ -45,13 +45,13 @@ test('clipping mask uses base alpha and survives undo, redo, and server reopen',
expect((await sampleComposite(page)).right[3]).toBe(0);
await expect(page.locator('.ge-layer-item.clipped .ge-layer-clipped-marker')).toBeVisible();
await page.locator('#ge-layer-tools button[title="Merge down into layer below"]').click();
await page.locator('.ge-layer-controls:visible button[title="Merge down into layer below"]').click();
expect((await editorState(page)).layers).toHaveLength(1);
expect((await sampleComposite(page)).right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
+3 -2
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
@@ -23,7 +24,7 @@ test('independent layer locks enforce operations and survive undo and reopen', a
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await expect(lockButton).toHaveClass(/active/);
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.55, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
@@ -35,7 +36,7 @@ test('independent layer locks enforce operations and survive undo and reopen', a
await toggleLock('pixels');
const emptyHash = (await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash;
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('pixels');
@@ -1,15 +1,16 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('layer mask link controls independent movement and survives reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Linked layer mask');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.35 }, { x: 0.75, y: 0.6 });
let current = await editorState(page);
const layerId = current.activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
@@ -63,7 +64,7 @@ test('layer mask link controls independent movement and survives reopen', async
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
current = await editorState(page);
@@ -90,7 +91,7 @@ test('layer mask link controls independent movement and survives reopen', async
}).toEqual(fixedDocumentMask);
const beforeTransform = layer;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill('280');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
@@ -133,19 +134,19 @@ test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', asyn
test('move tool can auto-select the topmost visible layer under the pointer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Auto-select E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 });
const shape = (await editorState(page)).layers.find(layer => layer.kind === 'shape');
expect(shape).toBeTruthy();
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
await page.locator('.ge-auto-select-option').click();
await expect(page.locator('#ge-auto-select-layer')).toBeChecked();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
@@ -163,7 +164,7 @@ test('multi-selected layers move together and support history-backed bulk action
expect((await editorState(page)).selectedLayerIds).toHaveLength(2);
await expect(page.locator('#ge-layer-selection-bar')).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.52, y: 0.50 });
let current = await editorState(page);
const edit = current.layers.find(layer => layer.name === 'Edit');
@@ -236,7 +237,7 @@ test('layer groups composite, lock, collapse, undo, and survive server reopen',
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
await page.locator('.ge-layer-group-row').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await dragOnCanvas(page, { x: 0.40, y: 0.40 }, { x: 0.52, y: 0.48 });
current = await editorState(page);
expect(current.layers[0].offset).toEqual(current.layers[1].offset);
@@ -260,7 +261,7 @@ test('layer groups composite, lock, collapse, undo, and survive server reopen',
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await page.locator('.ge-layer-group-row').click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toHaveLength(1);
@@ -318,7 +319,7 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
await page.locator('.ge-layer-controls:visible button[title="Ungroup layers"]').click();
current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
@@ -18,7 +19,7 @@ test('mobile tool and layer sheets do not overlap mask controls', async ({ page
const layerId = (await editorState(page)).activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
const maskName = maskRow.locator('.ge-layer-name');
@@ -39,15 +40,15 @@ test('mobile tool and layer sheets do not overlap mask controls', async ({ page
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await expect(controls).not.toHaveClass(/dismissed/);
await expect(layerSheet).toHaveClass(/minimized/);
const controlsBox = await controls.boundingBox();
const sheetBox = await layerSheet.boundingBox();
expect(controlsBox.y).toBeLessThan(sheetBox.y + sheetBox.height);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(controls).toHaveClass(/dismissed/);
});
@@ -79,17 +80,17 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
const before = (await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash;
// A new layer mask is fully white (revealed), so Brush would paint white
// onto white. Eraser makes the first touch edit observable by hiding pixels.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
// Collapse the bottom-sheet controls so the full canvas is available to the
// touch gesture, matching the canvas-first mobile editing mode.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await page.context().newCDPSession(page);
const visibleCanvasPoint = await page.evaluate(() => {
@@ -169,12 +170,12 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
await maskRow.locator('.ge-mask-link-btn').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await selectTool(page, 'move');
await selectTool(page, 'move');
const point = await page.evaluate(() => {
const target = document.querySelector('.ge-main-canvas');
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs/promises');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
@@ -14,9 +15,9 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
await openBlankEditor(page, { width: 400, height: 300 }, 'Multi-transform E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.35, canvasBox.y + canvasBox.height * 0.25);
await page.locator('#ge-text-content').fill('Transform together');
@@ -25,7 +26,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await page.locator('.ge-layer-controls:visible .ge-layer-clip-btn').click();
await editRow.click();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
@@ -36,7 +37,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
expect(initial.selectedLayerIds).toHaveLength(3);
const bounds = selectionBounds(initial.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform 3 layers');
expect(Number(await page.locator('#ge-transform-w').inputValue())).toBe(bounds.width);
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height);
@@ -71,7 +72,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const secondWidth = await page.locator('#ge-transform-w').inputValue();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
@@ -87,7 +88,7 @@ test('shared transform preserves relative layout, masks, retained text, undo, ca
expect(cancelled.groups).toEqual(transformed.groups);
expect(cancelled.redo).toBe(0);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await page.locator('#ge-transform-flip-h').click();
@@ -0,0 +1,40 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
async function gaps(page) {
return page.evaluate(() => {
const rect = selector => document.querySelector(selector).getBoundingClientRect();
const canvas = rect('.ge-main-canvas');
const footer = rect('.ge-editor-footer');
const sheet = rect('.ge-right-panel');
return {
canvasToFooter: Math.abs(canvas.bottom - footer.top),
footerToSheet: Math.abs(footer.bottom - sheet.top),
footerLeft: footer.left,
footerRight: footer.right,
sheetTop: sheet.top,
};
});
}
test('mobile footer docks flush between the canvas and the layers sheet', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer dock');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
await expect(page.locator('.ge-editor-footer')).toHaveClass(/ge-footer-floating/);
await expect(page.locator('.ge-layers-grab')).toBeHidden();
// Wait until the dock has settled: canvas bottom == footer top == sheet top.
await expect.poll(async () => {
const g = await gaps(page);
return g.canvasToFooter <= 2 && g.footerToSheet <= 2;
}, { timeout: 10_000 }).toBe(true);
const g = await gaps(page);
expect(g.canvasToFooter).toBeLessThanOrEqual(2);
expect(g.footerToSheet).toBeLessThanOrEqual(2);
expect(g.footerLeft).toBeLessThanOrEqual(1);
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
});
+13 -12
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function patternedPng(page, width, height, colors) {
@@ -30,12 +31,12 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
const sourceHash = imported.placed.sourcePixelHash;
const row = page.locator(`.ge-layer-item[data-layer-id="${imported.id}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth / 2)));
await page.locator('#ge-transform-apply').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill(String(originalWidth));
await page.locator('#ge-transform-apply').click();
@@ -48,7 +49,7 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
const replacement = await patternedPng(page, 20, 40, ['#111111', '#f8f8f8', '#ff00aa']);
const replaceChooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-layer-tools button[title="Replace placed image"]').click();
await page.locator('.ge-layer-controls:visible button[title="Replace placed image"]').click();
const replaceChooser = await replaceChooserPromise;
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
await expect.poll(async () => {
@@ -62,7 +63,7 @@ test('placed image transforms from source, replaces in place, rasterizes, and re
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const rasterized = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(rasterized.kind).toBe('raster');
expect(rasterized.placed).toBeNull();
@@ -92,11 +93,11 @@ test('raster layers can be converted to editable sources without changing pixels
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const raster = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const before = { pixelHash: raster.pixelHash, size: raster.size, offset: raster.offset };
await page.locator('#ge-layer-tools button[title="Convert to editable source"]').click();
await page.locator('.ge-layer-controls:visible button[title="Convert to editable source"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === placed.id).kind).toBe('placed');
const converted = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(converted.placed.sourceSize).toEqual(before.size);
@@ -139,7 +140,7 @@ test('duplicating a placed layer copies its source independently', async ({ page
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const original = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator(`.ge-layer-item[data-layer-id="${original.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Duplicate layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Duplicate layer"]').click();
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(2);
const sourceOwnership = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -165,10 +166,10 @@ test('raster transforms preserve source pixels by default and can opt out', asyn
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const sourceHash = rasterBeforeTransform.pixelHash;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-w').fill('84');
await page.locator('#ge-transform-apply').click();
@@ -191,8 +192,8 @@ test('raster transforms can explicitly replace source pixels', async ({ page, re
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('.ge-layer-controls:visible button[title="Rasterize placed layer"]').click();
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-preserve-source').uncheck();
await page.locator('#ge-transform-w').fill('60');
@@ -108,13 +108,13 @@ test('new project size dialog cancels cleanly with Escape', async ({ page }) =>
await expect(page.locator('#gallery-editor-new')).toBeVisible();
});
test('editor topbar uses uppercase action labels', async ({ page }) => {
test('editor topbar uses Photoshop text menu labels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Topbar labels');
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/VIEW/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/IMAGE/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/SELECT/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/FILTER/);
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/View/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/Image/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/Select/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/Filter/);
await expect(page.locator('#ge-import-topbar')).toHaveText(/IMPORT/);
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
});
@@ -1,28 +1,29 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor, editorState } = require('./helpers.js');
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + 80, canvas.y + 80);
const text = page.locator('.ge-direct-text-editor');
await text.fill('Keep editable');
await text.press('Control+Enter');
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
await page.keyboard.press('Escape');
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await selectTool(page, 'eraser');
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
await page.keyboard.press('Escape');
});
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test('active editor draft reopens after a hard refresh', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(page);
@@ -11,8 +12,15 @@ test('active editor draft reopens after a hard refresh', async ({ page }) => {
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
const readState = async (key) => {
try {
return (await editorState(page))[key];
} catch {
return null;
}
};
await expect.poll(() => readState('draftId'), { timeout: 20_000 }).toBe(draftId);
await expect.poll(() => readState('documentRenderReady'), { timeout: 20_000 }).toBe(true);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
@@ -46,7 +47,7 @@ test('selection boundary animates, moves independently, and supports Quick Mask'
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await page.locator('#ge-marquee-constraint').selectOption('size');
await page.locator('#ge-marquee-width').fill('80');
@@ -54,7 +55,7 @@ test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page
await page.locator('#ge-marquee-height').fill('60');
await page.locator('#ge-marquee-height').blur();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.75);
await page.mouse.click(Math.round(box.x + box.width * 0.8), Math.round(box.y + box.height * 0.75));
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
await page.locator('#ge-marquee-clear').click();
@@ -72,7 +73,7 @@ test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
const original = await editorState(page);
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
@@ -128,7 +129,7 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
@@ -179,3 +180,56 @@ test('named selections support reselect, load, delete, and server-draft reopen',
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
test('marquee released over the right panel commits a clamped selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Marquee overshoot');
await selectTool(page, 'marquee');
const setup = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
const panel = document.querySelector('.ge-right-panel');
const panelBox = panel ? panel.getBoundingClientRect() : null;
return {
document: { width: state.imgWidth, height: state.imgHeight },
start: { x: rect.left + rect.width * 0.2, y: rect.top + rect.height * 0.2 },
panelPoint: panelBox
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height * 0.6 }
: null,
};
});
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
await page.mouse.move(setup.start.x, setup.start.y);
await page.mouse.down();
// Drag straight onto the right panel. The window mousemove guard must
// keep the raw rect from extending off-canvas, and the release must
// commit a clamped selection rather than discard it.
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
await page.mouse.up();
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
if (!state.wandMask) return { active: false };
const data = state.wandMask.getContext('2d')
.getImageData(0, 0, state.wandMask.width, state.wandMask.height).data;
let left = state.wandMask.width, top = state.wandMask.height, right = -1, bottom = -1;
for (let y = 0; y < state.wandMask.height; y += 1) {
for (let x = 0; x < state.wandMask.width; x += 1) {
if (data[(y * state.wandMask.width + x) * 4 + 3] < 1) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x); bottom = Math.max(bottom, y);
}
}
return { active: true, left, top, right, bottom, width: state.wandMask.width, height: state.wandMask.height };
});
expect(result.active).toBe(true);
expect(result.right).toBeGreaterThanOrEqual(result.left);
expect(result.bottom).toBeGreaterThanOrEqual(result.top);
// Clamped: never beyond the document edge on any side.
expect(result.left).toBeGreaterThanOrEqual(0);
expect(result.top).toBeGreaterThanOrEqual(0);
expect(result.right).toBeLessThan(result.width);
expect(result.bottom).toBeLessThan(result.height);
expect(result.right - result.left).toBeGreaterThan(0);
expect(result.bottom - result.top).toBeGreaterThan(0);
});
+15 -12
View File
@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
@@ -23,9 +24,9 @@ async function drawLasso(page, points) {
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
@@ -55,11 +56,11 @@ test('selection clipboard copy and paste creates an undoable independent layer',
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
test('cut selection removes source pixels in one undoable step', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
@@ -76,9 +77,11 @@ test('cut selection is one undoable move from source to new layer', async ({ pag
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
// Cut copies to the clipboard and erases the selected pixels in place —
// it does not create a new layer (that is Ctrl+Shift+J, Layer via Cut).
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(cut.layers).toHaveLength(before.layers.length);
expect(cut.selection).toBeNull();
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
@@ -93,9 +96,9 @@ test('completed lasso copy preserves the source layer coordinate space', async (
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await selectTool(page, 'lasso');
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
@@ -110,7 +113,7 @@ test('completed lasso copy preserves the source layer coordinate space', async (
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await selectTool(page, 'marquee');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
@@ -132,7 +135,7 @@ test('selection refine previews safely and layer masks round-trip to selection',
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
expect(refined.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-layer-controls:visible .ge-true-mask-btn').click();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
@@ -149,7 +152,7 @@ test('selection refine previews safely and layer masks round-trip to selection',
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await selectTool(page, 'lasso');
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
const first = (await editorState(page)).selection;
expect(first.source).toBe('lasso');
@@ -0,0 +1,41 @@
const { test, expect } = require('@playwright/test');
const crypto = require('crypto');
const { openBlankEditor } = require('./helpers');
const FONTS = [
'Inter', 'Roboto', 'Playfair Display', 'Lobster',
'Bebas Neue', 'Roboto Mono', 'Georgia', 'Courier New',
];
const md5 = (buf) => crypto.createHash('md5').update(buf).digest('hex');
test('text font dropdown renders a distinct face for each font', async ({ page }) => {
await openBlankEditor(page, { width: 900, height: 600 }, 'Font swap');
await page.evaluate(() => {
document.querySelectorAll('.ge-tour-close,.ge-protip,.ge-tip,[class*="protip"]').forEach((e) => e.remove());
});
await page.evaluate(async () => {
const tool = document.querySelector('[data-tool="text"]');
if (tool) tool.click();
await window.geEditorControl.invoke('text.add', { text: 'AWjgyq 128', x: 60, y: 80 });
});
await page.waitForTimeout(400);
const box = await page.locator('.ge-main-canvas').boundingBox();
expect(box).toBeTruthy();
const hashes = {};
for (const font of FONTS) {
await page.evaluate((f) => {
const sel = document.getElementById('ge-text-font');
sel.value = f;
sel.dispatchEvent(new Event('input', { bubbles: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
}, font);
await page.waitForTimeout(250);
const shot = await page.screenshot({ clip: box });
hashes[font] = md5(shot);
}
const distinct = new Set(Object.values(hashes)).size;
expect(distinct, `distinct font hashes: ${JSON.stringify(hashes)}`).toBeGreaterThanOrEqual(5);
});
+9 -8
View File
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
@@ -42,7 +43,7 @@ test('text edits directly on canvas and remains retained after reopen', async ({
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await selectTool(page, 'text');
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
@@ -52,7 +53,7 @@ test('text paragraph controls remain usable in a narrow phone viewport', async (
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
@@ -77,7 +78,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
await page.locator('#ge-transform-apply').click();
@@ -94,7 +95,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
@@ -140,7 +141,7 @@ test('gradient shape fill remains editable after reopening the project', async (
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
@@ -220,7 +221,7 @@ test('shape gradient stop normalization keeps imported endpoints bounded', async
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await selectTool(page, 'gradient');
await expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
@@ -309,7 +310,7 @@ test('gradient tool paints a reversible drag on the active layer', async ({ page
test('radial gradient remains retained and survives reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await selectTool(page, 'gradient');
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
+54 -6
View File
@@ -1,9 +1,10 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor } = require('./helpers');
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const frame = await page.evaluate(async () => {
@@ -43,7 +44,7 @@ test('transform frame exposes eight accurate handles and supports edge resize',
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot').fill('90');
const frame = await page.evaluate(async () => {
@@ -84,7 +85,7 @@ test('rotated edge resize follows the frame axis and keeps its opposite edge anc
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
@@ -182,14 +183,14 @@ test('transform previews remain source-derived and reject unsafe allocations', a
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterSequentialPreviews = await layerSnapshot();
await page.locator('#ge-transform-cancel-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
@@ -214,7 +215,7 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await selectTool(page, 'transform');
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const cdp = await context.newCDPSession(page);
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
@@ -318,3 +319,50 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
await context.close();
}
});
test('transform handle keeps tracking when the pointer crosses the right panel', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform overshoot');
await selectTool(page, 'transform');
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const setup = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
const rect = state.mainCanvas.getBoundingClientRect();
const panel = document.querySelector('.ge-right-panel');
const panelBox = panel ? panel.getBoundingClientRect() : null;
return {
width: state.transformPendingW,
handle: {
x: rect.left + right.x * rect.width / state.mainCanvas.width,
y: rect.top + right.y * rect.height / state.mainCanvas.height,
},
// A point safely inside the right panel (editor chrome).
panelPoint: panelBox
? { x: panelBox.left + panelBox.width / 2, y: rect.top + rect.height / 2 }
: null,
};
});
expect(setup.panelPoint, 'right panel must be visible on desktop').toBeTruthy();
await page.mouse.move(setup.handle.x, setup.handle.y);
await page.mouse.down();
// Resize far to the right, ending over the right panel. The handle must
// keep tracking across chrome — an overshoot must not freeze the drag.
await page.mouse.move(setup.panelPoint.x, setup.panelPoint.y, { steps: 12 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
expect(after).toBeGreaterThan(setup.width);
});