Files
odysseus/tests/e2e/photo-editor/text-shapes.spec.js
T

335 lines
16 KiB
JavaScript

const { test, expect } = require('@playwright/test');
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 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);
const editor = page.locator('.ge-direct-text-editor');
await expect(editor).toBeVisible();
await editor.fill('Editable canvas title');
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
const before = await editorState(page);
const textLayer = before.layers.find(layer => layer.kind === 'text');
expect(textLayer.text).toMatchObject({
content: 'Editable canvas title',
fontFamily: 'Georgia',
lineHeight: 1.4,
letterSpacing: 3.5,
autoWidth: true,
frameHeight: 180,
verticalAlign: 'bottom',
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
});
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();
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
});
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 expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
for (let index = 0; index < types.length; index += 1) {
const type = types[index];
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
await page.locator(`[data-shape-type="${type}"]`).click();
await dragOnCanvas(
page,
{ x: 0.12 + index * 0.18, y: 0.2 },
{ x: 0.25 + index * 0.18, y: 0.42 },
);
}
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
current = await editorState(page);
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();
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();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.id === polygon.id);
expect(transformed.kind).toBe('shape');
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
});
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 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) => {
const section = angle.closest('#ge-shape-section');
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
const shape = before.layers.find(layer => layer.kind === 'shape');
expect(shape.shape).toMatchObject({
fillType: 'linear-gradient',
gradientStart: '#ff0000',
gradientMid: '#00ff00',
gradientMidEnabled: true,
gradientMidPosition: 42,
gradientEnd: '#0000ff',
gradientAngle: 35,
});
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
return {
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
};
}));
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
});
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 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(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
await page.evaluate(() => {
const color = document.querySelector('[data-gradient-stop-color]');
color.value = '#00ff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = document.querySelector('[data-gradient-stop-position]');
position.value = '50';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
await page.evaluate(() => {
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
const setRow = (row, colorValue, positionValue) => {
const color = row.querySelector('[data-gradient-stop-color]');
color.value = colorValue;
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = positionValue;
position.dispatchEvent(new Event('input', { bubbles: true }));
};
setRow(rows[0], '#00ff00', '50');
setRow(rows[1], '#ffff00', '75');
});
let current = await editorState(page);
const shape = current.layers.find(layer => layer.kind === 'shape');
expect(shape.shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 50, color: '#00ff00' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
});
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
const result = await page.evaluate(async () => {
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
position: index * 3,
color: `#${String(index).padStart(6, '0')}`,
})));
return { max: MAX_GRADIENT_STOPS, stops };
});
expect(result.stops).toHaveLength(result.max);
expect(result.stops[0].position).toBe(0);
expect(result.stops.at(-1).position).toBe(100);
});
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 expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const midpoint = document.getElementById('ge-gradient-mid');
midpoint.value = '#00ff00';
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
const enabled = document.getElementById('ge-gradient-mid-enabled');
enabled.checked = true;
enabled.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.locator('#ge-gradient-add-stop').click();
await page.evaluate(() => {
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
const color = row.querySelector('[data-gradient-stop-color]');
color.value = '#ffff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = '25';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(box.x + 8, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const samples = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const rendered = state.documentCompositeCanvas;
const ctx = rendered.getContext('2d');
const y = Math.floor(rendered.height / 2);
return [
ctx.getImageData(4, y, 1, 1).data,
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
];
});
expect(samples[0][0]).toBeGreaterThan(220);
expect(samples[0][2]).toBeLessThan(40);
expect(samples[1][1]).toBeGreaterThan(180);
expect(samples[1][0]).toBeLessThan(80);
expect(samples[1][2]).toBeLessThan(80);
expect(samples[2][2]).toBeGreaterThan(220);
expect(samples[2][0]).toBeLessThan(40);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
expect(reopenedGradient).toBeTruthy();
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 25, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const edited = (await editorState(page)).layers
.flatMap(layer => layer.effects || [])
.find(effect => effect.type === 'linear-gradient');
expect(edited.params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 30, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
});
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 page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
start.value = '#ffffff';
start.dispatchEvent(new Event('input', { bubbles: true }));
const end = document.getElementById('ge-gradient-end');
end.value = '#000000';
end.dispatchEvent(new Event('input', { bubbles: true }));
});
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
let current = await editorState(page);
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
expect(gradient).toBeTruthy();
expect(gradient.params.stops).toEqual([
{ position: 0, color: '#ffffff', alpha: 1 },
{ position: 100, color: '#000000', alpha: 1 },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
});