diff --git a/ts/components/MediaEditor.dom.tsx b/ts/components/MediaEditor.dom.tsx index 0672b66682..97d3db8428 100644 --- a/ts/components/MediaEditor.dom.tsx +++ b/ts/components/MediaEditor.dom.tsx @@ -325,7 +325,6 @@ export function MediaEditor({ width: img.width, }; setImageState(newImageState); - takeSnapshot('initial state', newImageState, canvas); }; img.onerror = ( event: Event | string, @@ -588,6 +587,20 @@ export function MediaEditor({ drawFabricBackgroundImage({ fabricCanvas, image, imageState }); }, [fabricCanvas, image, imageState]); + const initialSnapshotTaken = useRef(false); + useEffect(() => { + if ( + !fabricCanvas || + !fabricCanvas.backgroundImage || + initialSnapshotTaken.current + ) { + return; + } + + takeSnapshot('initial state', imageState, fabricCanvas); + initialSnapshotTaken.current = true; + }, [fabricCanvas, imageState, takeSnapshot]); + const [canCrop, setCanCrop] = useState(false); const [cropAspectRatioLock, setCropAspectRatioLock] = useState(false); const [drawTool, setDrawTool] = useState(DrawTool.Pen); diff --git a/ts/test-mock/messaging/media_editor_test.ts b/ts/test-mock/messaging/media_editor_test.ts new file mode 100644 index 0000000000..18dc5ea8ea --- /dev/null +++ b/ts/test-mock/messaging/media_editor_test.ts @@ -0,0 +1,174 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { expect } from 'playwright/test'; +import { type PrimaryDevice, StorageState } from '@signalapp/mock-server'; +import * as path from 'node:path'; + +import type { App } from '../playwright.js'; +import { Bootstrap } from '../bootstrap.js'; +import { composerAttachImages } from '../helpers.js'; +import * as durations from '../../util/durations/index.js'; + +const CAT_PATH = path.join( + __dirname, + '..', + '..', + '..', + 'fixtures', + 'cat-screenshot.png' +); + +describe('MediaEditor', function (this: Mocha.Suite) { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let pinned: PrimaryDevice; + + beforeEach(async () => { + bootstrap = new Bootstrap(); + await bootstrap.init(); + + let state = StorageState.getEmpty(); + + const { phone, contacts } = bootstrap; + [pinned] = contacts; + + state = state.addContact(pinned, { + identityKey: pinned.publicKey.serialize(), + profileKey: pinned.profileKey.serialize(), + whitelisted: true, + }); + + state = state.pin(pinned); + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function (this: Mocha.Context) { + if (!bootstrap) { + return; + } + + await bootstrap.maybeSaveLogs(this.currentTest, app); + await app.close(); + await bootstrap.teardown(); + }); + + async function openMediaEditor(page: Awaited>) { + await page.getByTestId(pinned.device.aci).click(); + + await composerAttachImages(page, [CAT_PATH]); + + const AttachmentsList = page.locator('.module-attachments'); + await AttachmentsList.waitFor({ state: 'visible' }); + + const EditableAttachment = AttachmentsList.locator( + '.module-attachments--editable' + ).first(); + await EditableAttachment.waitFor({ state: 'visible' }); + + const StagedImage = EditableAttachment.locator('.module-image--loaded'); + await StagedImage.waitFor({ state: 'visible' }); + + await StagedImage.click(); + + const MediaEditor = page.locator('.MediaEditor'); + await MediaEditor.waitFor({ state: 'visible' }); + + return MediaEditor; + } + + async function drawLineOnCanvas( + page: Awaited>, + MediaEditor: Awaited>, + options?: { + startX?: number; + startY?: number; + endX?: number; + endY?: number; + } + ) { + const canvas = MediaEditor.locator('.MediaEditor__media--canvas').first(); + const canvasBox = await canvas.boundingBox(); + + if (!canvasBox) { + throw new Error('Canvas bounding box not found'); + } + + // Draw diagonal line by default + const startX = options?.startX ?? canvasBox.x + canvasBox.width * 0.3; + const startY = options?.startY ?? canvasBox.y + canvasBox.height * 0.3; + const endX = options?.endX ?? canvasBox.x + canvasBox.width * 0.7; + const endY = options?.endY ?? canvasBox.y + canvasBox.height * 0.7; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY); + await page.mouse.up(); + } + + it('can undo after drawing a line', async () => { + const page = await app.getWindow(); + const MediaEditor = await openMediaEditor(page); + + const canvas = MediaEditor.locator('.MediaEditor__media--canvas').first(); + + const screenshotBeforeDrawing = await canvas.screenshot(); + + const DrawButton = MediaEditor.locator('.MediaEditor__control--pen'); + await DrawButton.click(); + + await page.waitForTimeout(100); + + await drawLineOnCanvas(page, MediaEditor); + + await page.waitForTimeout(100); + + const screenshotAfterDrawing = await canvas.screenshot(); + + const UndoButton = MediaEditor.locator('.MediaEditor__control--undo'); + await expect(UndoButton).toBeEnabled(); + await UndoButton.click(); + + await page.waitForTimeout(100); + + const screenshotAfterUndo = await canvas.screenshot(); + + expect( + Buffer.compare(screenshotBeforeDrawing, screenshotAfterDrawing), + 'screenshots before and after drawing should be different' + ).not.toBe(0); + + expect( + Buffer.compare(screenshotBeforeDrawing, screenshotAfterUndo), + 'screenshot before drawing should be the same as after undo' + ).toBe(0); + }); + + it('undo button is disabled when there is nothing to undo', async () => { + const page = await app.getWindow(); + const MediaEditor = await openMediaEditor(page); + + const UndoButton = MediaEditor.locator('.MediaEditor__control--undo'); + await expect(UndoButton).toBeDisabled(); + + const DrawButton = MediaEditor.locator('.MediaEditor__control--pen'); + await DrawButton.click(); + + await page.waitForTimeout(100); + + await drawLineOnCanvas(page, MediaEditor); + + await page.waitForTimeout(100); + + await expect(UndoButton).toBeEnabled(); + await UndoButton.click(); + + await page.waitForTimeout(100); + + await expect(UndoButton).toBeDisabled(); + }); +}); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 16d7d7562d..18af37ce33 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2327,5 +2327,12 @@ "line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');", "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/MediaEditor.tsx", + "line": " const initialSnapshotTaken = useRef(false);", + "reasonCategory": "usageTrusted", + "updated": "2025-10-10T09:45:25.353Z" } ]