Fix media editor undo state bugs

This commit is contained in:
Evan Hahn 2022-01-03 15:29:19 -08:00 committed by GitHub
parent 7273e580bd
commit dca2364ba4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 287 additions and 255 deletions

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure'; import Measure from 'react-measure';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { fabric } from 'fabric'; import { fabric } from 'fabric';
@ -27,6 +27,7 @@ import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPe
import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect'; import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect';
import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText'; import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText';
import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker'; import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker';
import { fabricEffectListener } from '../mediaEditor/fabricEffectListener';
import { getRGBA, getHSL } from '../mediaEditor/util/color'; import { getRGBA, getHSL } from '../mediaEditor/util/color';
import { import {
TextStyle, TextStyle,
@ -40,6 +41,16 @@ export type PropsType = {
onDone: (data: Uint8Array) => unknown; onDone: (data: Uint8Array) => unknown;
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>; } & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>;
const INITIAL_IMAGE_STATE: ImageStateType = {
angle: 0,
cropX: 0,
cropY: 0,
flipX: false,
flipY: false,
height: 0,
width: 0,
};
enum EditMode { enum EditMode {
Crop = 'Crop', Crop = 'Crop',
Draw = 'Draw', Draw = 'Draw',
@ -85,19 +96,18 @@ export const MediaEditor = ({
const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>(); const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
const [image, setImage] = useState<HTMLImageElement>(new Image()); const [image, setImage] = useState<HTMLImageElement>(new Image());
const isRestoringImageState = useRef(false);
const canvasId = useUniqueId(); const canvasId = useUniqueId();
const [imageState, setImageState] = useState<ImageStateType>({ const [imageState, setImageState] =
angle: 0, useState<ImageStateType>(INITIAL_IMAGE_STATE);
cropX: 0,
cropY: 0, // History state
flipX: false, const { canRedo, canUndo, redoIfPossible, takeSnapshot, undoIfPossible } =
flipY: false, useFabricHistory({
height: image.height, fabricCanvas,
width: image.width, imageState,
}); setImageState,
});
// Initial image load and Fabric canvas setup // Initial image load and Fabric canvas setup
useEffect(() => { useEffect(() => {
@ -114,11 +124,14 @@ export const MediaEditor = ({
const canvas = new fabric.Canvas(canvasId); const canvas = new fabric.Canvas(canvasId);
canvas.selection = false; canvas.selection = false;
setFabricCanvas(canvas); setFabricCanvas(canvas);
setImageState(curr => ({
...curr, const newImageState = {
...INITIAL_IMAGE_STATE,
height: img.height, height: img.height,
width: img.width, width: img.width,
})); };
setImageState(newImageState);
takeSnapshot('initial state', newImageState, canvas);
}; };
img.onerror = () => { img.onerror = () => {
// This is a bad experience, but it should be impossible. // This is a bad experience, but it should be impossible.
@ -130,9 +143,7 @@ export const MediaEditor = ({
img.onload = noop; img.onload = noop;
img.onerror = noop; img.onerror = noop;
}; };
}, [canvasId, fabricCanvas, imageSrc, onClose]); }, [canvasId, fabricCanvas, imageSrc, onClose, takeSnapshot]);
const history = useFabricHistory(fabricCanvas);
// Keyboard support // Keyboard support
useEffect(() => { useEffect(() => {
@ -155,22 +166,8 @@ export const MediaEditor = ({
ev => isCmdOrCtrl(ev) && ev.key === 't', ev => isCmdOrCtrl(ev) && ev.key === 't',
() => setEditMode(EditMode.Text), () => setEditMode(EditMode.Text),
], ],
[ [ev => isCmdOrCtrl(ev) && ev.key === 'z', undoIfPossible],
ev => isCmdOrCtrl(ev) && ev.key === 'z', [ev => isCmdOrCtrl(ev) && ev.shiftKey && ev.key === 'z', redoIfPossible],
() => {
if (history?.canUndo()) {
history?.undo();
}
},
],
[
ev => isCmdOrCtrl(ev) && ev.shiftKey && ev.key === 'z',
() => {
if (history?.canRedo()) {
history?.redo();
}
},
],
[ [
ev => ev.key === 'Escape', ev => ev.key === 'Escape',
() => { () => {
@ -308,20 +305,7 @@ export const MediaEditor = ({
return () => { return () => {
document.removeEventListener('keydown', handleKeydown); document.removeEventListener('keydown', handleKeydown);
}; };
}, [fabricCanvas, history]); }, [fabricCanvas, redoIfPossible, undoIfPossible]);
// Take a snapshot of history whenever imageState changes
useEffect(() => {
if (
!imageState.height ||
!imageState.width ||
isRestoringImageState.current
) {
isRestoringImageState.current = false;
return;
}
history?.takeSnapshot(imageState);
}, [history, imageState]);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0); const [containerHeight, setContainerHeight] = useState(0);
@ -359,8 +343,6 @@ export const MediaEditor = ({
drawFabricBackgroundImage({ fabricCanvas, image, imageState }); drawFabricBackgroundImage({ fabricCanvas, image, imageState });
}, [fabricCanvas, image, imageState]); }, [fabricCanvas, image, imageState]);
const [canRedo, setCanRedo] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canCrop, setCanCrop] = useState(false); const [canCrop, setCanCrop] = useState(false);
const [cropAspectRatioLock, setCropAspectRatioLock] = useState(false); const [cropAspectRatioLock, setCropAspectRatioLock] = useState(false);
const [drawTool, setDrawTool] = useState<DrawTool>(DrawTool.Pen); const [drawTool, setDrawTool] = useState<DrawTool>(DrawTool.Pen);
@ -369,64 +351,22 @@ export const MediaEditor = ({
const [sliderValue, setSliderValue] = useState<number>(0); const [sliderValue, setSliderValue] = useState<number>(0);
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular); const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
// Check if we can undo/redo & restore the image state on undo/undo
useEffect(() => {
if (!history) {
return;
}
function refreshUndoState() {
if (!history) {
return;
}
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
}
function restoreImageState(prevImageState: ImageStateType) {
isRestoringImageState.current = true;
setImageState(curr => ({ ...curr, ...prevImageState }));
}
function takeSnapshot() {
history?.takeSnapshot({ ...imageState });
}
history.on('appliedState', restoreImageState);
history.on('historyChanged', refreshUndoState);
history.on('pleaseTakeSnapshot', takeSnapshot);
return () => {
history.off('appliedState', restoreImageState);
history.off('historyChanged', refreshUndoState);
history.off('pleaseTakeSnapshot', takeSnapshot);
};
}, [history, imageState]);
// If you select a text path auto enter edit mode // If you select a text path auto enter edit mode
useEffect(() => { useEffect(() => {
if (!fabricCanvas) { if (!fabricCanvas) {
return; return;
} }
return fabricEffectListener(
function updateEditMode() { fabricCanvas,
if (fabricCanvas?.getActiveObject() instanceof MediaEditorFabricIText) { ['selection:created', 'selection:updated', 'selection:cleared'],
setEditMode(EditMode.Text); () => {
} else if (editMode === EditMode.Text) { if (fabricCanvas?.getActiveObject() instanceof MediaEditorFabricIText) {
setEditMode(undefined); setEditMode(EditMode.Text);
} else if (editMode === EditMode.Text) {
setEditMode(undefined);
}
} }
} );
fabricCanvas.on('selection:created', updateEditMode);
fabricCanvas.on('selection:updated', updateEditMode);
fabricCanvas.on('selection:cleared', updateEditMode);
return () => {
fabricCanvas.off('selection:created', updateEditMode);
fabricCanvas.off('selection:updated', updateEditMode);
fabricCanvas.off('selection:cleared', updateEditMode);
};
}, [editMode, fabricCanvas]); }, [editMode, fabricCanvas]);
// Ensure scaling is in locked|unlocked state only when cropping // Ensure scaling is in locked|unlocked state only when cropping
@ -769,15 +709,13 @@ export const MediaEditor = ({
return; return;
} }
setImageState({ const newImageState = {
angle: 0, ...INITIAL_IMAGE_STATE,
cropX: 0,
cropY: 0,
flipX: false,
flipY: false,
height: image.height, height: image.height,
width: image.width, width: image.width,
}); };
setImageState(newImageState);
takeSnapshot('reset', newImageState);
}} }}
type="button" type="button"
> >
@ -808,12 +746,14 @@ export const MediaEditor = ({
obj.setCoords(); obj.setCoords();
}); });
setImageState(curr => ({ const newImageState = {
...curr, ...imageState,
angle: (curr.angle + 270) % 360, angle: (imageState.angle + 270) % 360,
height: curr.width, height: imageState.width,
width: curr.height, width: imageState.height,
})); };
setImageState(newImageState);
takeSnapshot('rotate', newImageState);
}} }}
type="button" type="button"
/> />
@ -825,12 +765,14 @@ export const MediaEditor = ({
return; return;
} }
setImageState(curr => ({ const newImageState = {
...curr, ...imageState,
...(curr.angle % 180 ...(imageState.angle % 180
? { flipY: !curr.flipY } ? { flipY: !imageState.flipY }
: { flipX: !curr.flipX }), : { flipX: !imageState.flipX }),
})); };
setImageState(newImageState);
takeSnapshot('flip', newImageState);
}} }}
type="button" type="button"
/> />
@ -863,8 +805,13 @@ export const MediaEditor = ({
return; return;
} }
setImageState(curr => getNewImageStateFromCrop(curr, pendingCrop)); const newImageState = getNewImageStateFromCrop(
imageState,
pendingCrop
);
setImageState(newImageState);
moveFabricObjectsForCrop(fabricCanvas, pendingCrop); moveFabricObjectsForCrop(fabricCanvas, pendingCrop);
takeSnapshot('crop', newImageState);
setEditMode(undefined); setEditMode(undefined);
}} }}
type="button" type="button"
@ -1031,7 +978,7 @@ export const MediaEditor = ({
if (editMode === EditMode.Crop) { if (editMode === EditMode.Crop) {
setEditMode(undefined); setEditMode(undefined);
} }
history?.undo(); undoIfPossible();
}} }}
type="button" type="button"
/> />
@ -1043,7 +990,7 @@ export const MediaEditor = ({
if (editMode === EditMode.Crop) { if (editMode === EditMode.Crop) {
setEditMode(undefined); setEditMode(undefined);
} }
history?.redo(); redoIfPossible();
}} }}
type="button" type="button"
/> />

View file

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { fabric } from 'fabric';
/**
* A helper for setting Fabric events inside of React `useEffect`s.
*/
export function fabricEffectListener(
target: fabric.IObservable<unknown>,
eventNames: ReadonlyArray<string>,
handler: (event: fabric.IEvent) => unknown
): () => void {
for (const eventName of eventNames) {
target.on(eventName, handler);
}
return () => {
for (const eventName of eventNames) {
target.off(eventName, handler);
}
};
}

View file

@ -1,152 +1,221 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { fabric } from 'fabric'; import { fabric } from 'fabric';
import EventEmitter from 'events';
import * as log from '../logging/log';
import type { ImageStateType } from './ImageStateType'; import type { ImageStateType } from './ImageStateType';
import { MediaEditorFabricIText } from './MediaEditorFabricIText'; import { MediaEditorFabricIText } from './MediaEditorFabricIText';
import { MediaEditorFabricPath } from './MediaEditorFabricPath'; import { MediaEditorFabricPath } from './MediaEditorFabricPath';
import { MediaEditorFabricSticker } from './MediaEditorFabricSticker'; import { MediaEditorFabricSticker } from './MediaEditorFabricSticker';
import { fabricEffectListener } from './fabricEffectListener';
export function useFabricHistory( import { strictAssert } from '../util/assert';
canvas: fabric.Canvas | undefined
): FabricHistory | undefined {
const [history, setHistory] = useState<FabricHistory | undefined>();
// We need this type of precision so that when serializing/deserializing
// the floats don't get rounded off and we maintain proper image state.
// http://fabricjs.com/fabric-gotchas
fabric.Object.NUM_FRACTION_DIGITS = 16;
// Attach our custom classes to the global Fabric instance. Unfortunately, Fabric
// doesn't make it easy to deserialize into a custom class without polluting the
// global namespace. See <http://fabricjs.com/fabric-intro-part-3#subclassing>.
Object.assign(fabric, {
MediaEditorFabricIText,
MediaEditorFabricPath,
MediaEditorFabricSticker,
});
useEffect(() => {
if (canvas) {
const fabricHistory = new FabricHistory(canvas);
setHistory(fabricHistory);
}
}, [canvas]);
return history;
}
const LIMIT = 1000;
type SnapshotStateType = { type SnapshotStateType = {
canvasState: string; canvasState: string;
imageState: ImageStateType; imageState: ImageStateType;
}; };
export class FabricHistory extends EventEmitter { const SNAPSHOT_LIMIT = 1000;
private readonly canvas: fabric.Canvas;
private highWatermark: number; /**
private isTimeTraveling: boolean; * A helper hook to manage `<MediaEditor>`'s undo/redo state.
private snapshots: Array<SnapshotStateType>; *
* There are 3 pieces of state here:
*
* 1. `snapshots`, which include the "canvas state" (i.e., where all the objects are) and
* the "image state" (i.e., the dimensions/angle of the image). Once the image has
* loaded, this will always have a length of at least 1.
* 2. `highWatermark`, representing the snapshot that we *want* to be applied. If the
* user never hits Undo, this will always be `snapshots.length`.
* 3. `appliedHighWatermark`, representing the snapshot that *is* applied. Because undo
* and redo are asynchronous, this can lag behind `highWatermark`. The user is in the
* middle of a "time travel" if `highWatermark !== appliedHighWatermark`.
*
* When the user performs a normal operation (such as adding an object or cropping), we
* add a new snapshot and update `highWatermark` and `appliedHighWatermark` all at once.
* We can do this because it's a synchronous operation.
*
* When the user performs an undo/redo, we immediately update `highWatermark`, then
* asynchronously perform the operation, then update `appliedHighWatermark`. You can't
* undo/redo if you're already time traveling to help avoid race conditions.
*/
export function useFabricHistory({
fabricCanvas,
imageState,
setImageState,
}: {
fabricCanvas: fabric.Canvas | undefined;
imageState: Readonly<ImageStateType>;
setImageState: (_: ImageStateType) => unknown;
}): {
canRedo: boolean;
canUndo: boolean;
redoIfPossible: () => void;
takeSnapshot: (
logMessage: string,
imageState: ImageStateType,
canvasOverride?: fabric.Canvas
) => void;
undoIfPossible: () => void;
} {
// These are all in one object, instead of three `useState` calls, because we often
// need to update them all at once based on the previous state.
const [state, setState] = useState<
Readonly<{
snapshots: ReadonlyArray<SnapshotStateType>;
highWatermark: number;
appliedHighWatermark: number;
}>
>({
snapshots: [],
highWatermark: 0,
appliedHighWatermark: 0,
});
constructor(canvas: fabric.Canvas) { const { highWatermark, snapshots } = state;
super(); const isTimeTraveling = getIsTimeTraveling(state);
const desiredSnapshot: undefined | SnapshotStateType =
snapshots[highWatermark - 1];
this.canvas = canvas; const takeSnapshotInternal = useCallback((snapshot: SnapshotStateType) => {
this.highWatermark = 0; setState(oldState => {
this.isTimeTraveling = false; const newSnapshots = oldState.snapshots.slice(0, oldState.highWatermark);
this.snapshots = []; newSnapshots.push(snapshot);
while (newSnapshots.length > SNAPSHOT_LIMIT) {
this.canvas.on('object:added', this.onObjectModified.bind(this)); newSnapshots.shift();
this.canvas.on('object:modified', this.onObjectModified.bind(this)); }
this.canvas.on('object:removed', this.onObjectModified.bind(this)); return {
} snapshots: newSnapshots,
highWatermark: newSnapshots.length,
private applyState({ canvasState, imageState }: SnapshotStateType): void { appliedHighWatermark: newSnapshots.length,
this.canvas.loadFromJSON(canvasState, () => { };
this.emit('appliedState', imageState);
this.emit('historyChanged');
this.isTimeTraveling = false;
}); });
} }, []);
const takeSnapshot = useCallback(
(
logMessage: string,
newImageState: ImageStateType,
canvasOverride?: fabric.Canvas
) => {
const canvas = canvasOverride || fabricCanvas;
strictAssert(
canvas,
'Media editor: tried to take a snapshot without a canvas'
);
log.info(
`Media editor: taking snapshot of image state from ${logMessage}`
);
takeSnapshotInternal({
canvasState: getCanvasState(canvas),
imageState: newImageState,
});
},
[fabricCanvas, takeSnapshotInternal]
);
const undoIfPossible = useCallback(() => {
log.info('Media editor: undoing');
setState(oldState =>
getIsTimeTraveling(oldState)
? oldState
: {
...oldState,
highWatermark: Math.max(oldState.highWatermark - 1, 1),
}
);
}, []);
const redoIfPossible = useCallback(() => {
log.info('Media editor: redoing');
setState(oldState =>
getIsTimeTraveling(oldState)
? oldState
: {
...oldState,
highWatermark: Math.min(
oldState.highWatermark + 1,
oldState.snapshots.length
),
}
);
}, []);
private getState(): string { // Global Fabric overrides
return JSON.stringify(this.canvas.toDatalessJSON()); useEffect(() => {
} // We need this type of precision so that when serializing/deserializing
// the floats don't get rounded off and we maintain proper image state.
// http://fabricjs.com/fabric-gotchas
fabric.Object.NUM_FRACTION_DIGITS = 16;
private onObjectModified({ target }: fabric.IEvent): void { // Attach our custom classes to the global Fabric instance. Unfortunately, Fabric
if (target?.excludeFromExport) { // doesn't make it easy to deserialize into a custom class without polluting the
// global namespace. See <http://fabricjs.com/fabric-intro-part-3#subclassing>.
Object.assign(fabric, {
MediaEditorFabricIText,
MediaEditorFabricPath,
MediaEditorFabricSticker,
});
}, []);
// Moving between different snapshots
useEffect(() => {
if (!fabricCanvas || !isTimeTraveling || !desiredSnapshot) {
return; return;
} }
log.info(`Media editor: time-traveling to snapshot ${highWatermark}`);
fabricCanvas.loadFromJSON(desiredSnapshot.canvasState, () => {
setImageState(desiredSnapshot.imageState);
setState(oldState => ({
...oldState,
appliedHighWatermark: highWatermark,
}));
});
}, [
desiredSnapshot,
fabricCanvas,
highWatermark,
isTimeTraveling,
setImageState,
]);
this.emit('pleaseTakeSnapshot'); // Taking snapshots when objects are added, modified, and removed
} useEffect(() => {
if (!fabricCanvas || isTimeTraveling) {
private getUndoState(): SnapshotStateType | undefined {
if (!this.canUndo()) {
return; return;
} }
return fabricEffectListener(
fabricCanvas,
['object:added', 'object:modified', 'object:removed'],
({ target }) => {
if (isTimeTraveling || target?.excludeFromExport) {
return;
}
log.info('Media editor: taking snapshot from object change');
takeSnapshotInternal({
canvasState: getCanvasState(fabricCanvas),
imageState,
});
}
);
}, [takeSnapshotInternal, fabricCanvas, isTimeTraveling, imageState]);
this.highWatermark -= 1; return {
return this.snapshots[this.highWatermark]; canRedo: highWatermark < snapshots.length,
} canUndo: highWatermark > 1,
redoIfPossible,
private getRedoState(): SnapshotStateType | undefined { takeSnapshot,
if (this.canRedo()) { undoIfPossible,
this.highWatermark += 1; };
} }
return this.snapshots[this.highWatermark]; function getCanvasState(fabricCanvas: fabric.Canvas): string {
} return JSON.stringify(fabricCanvas.toDatalessJSON());
}
public takeSnapshot(imageState: ImageStateType): void {
if (this.isTimeTraveling) { function getIsTimeTraveling({
return; highWatermark,
} appliedHighWatermark,
}: Readonly<{ highWatermark: number; appliedHighWatermark: number }>): boolean {
if (this.canRedo()) { return highWatermark !== appliedHighWatermark;
this.snapshots.splice(this.highWatermark + 1, this.snapshots.length);
}
this.snapshots.push({ canvasState: this.getState(), imageState });
if (this.snapshots.length > LIMIT) {
this.snapshots.shift();
}
this.highWatermark = this.snapshots.length - 1;
this.emit('historyChanged');
}
public undo(): void {
const undoState = this.getUndoState();
if (!undoState) {
return;
}
this.isTimeTraveling = true;
this.applyState(undoState);
}
public redo(): void {
const redoState = this.getRedoState();
if (!redoState) {
return;
}
this.isTimeTraveling = true;
this.applyState(redoState);
}
public canUndo(): boolean {
return this.highWatermark > 0;
}
public canRedo(): boolean {
return this.highWatermark < this.snapshots.length - 1;
}
} }

View file

@ -7461,13 +7461,6 @@
"updated": "2020-02-14T20:02:37.507Z", "updated": "2020-02-14T20:02:37.507Z",
"reasonDetail": "Used only to set focus" "reasonDetail": "Used only to set focus"
}, },
{
"rule": "React-useRef",
"path": "ts/components/MediaEditor.tsx",
"line": " const isRestoringImageState = useRef(false);",
"reasonCategory": "usageTrusted",
"updated": "2021-12-01T01:13:59.892Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/Modal.tsx", "path": "ts/components/Modal.tsx",