mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 20:02:20 +02:00
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:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user