Fix media editor undo state bugs
This commit is contained in:
parent
7273e580bd
commit
dca2364ba4
4 changed files with 287 additions and 255 deletions
|
@ -1,152 +1,221 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
|
||||
import type { ImageStateType } from './ImageStateType';
|
||||
import { MediaEditorFabricIText } from './MediaEditorFabricIText';
|
||||
import { MediaEditorFabricPath } from './MediaEditorFabricPath';
|
||||
import { MediaEditorFabricSticker } from './MediaEditorFabricSticker';
|
||||
|
||||
export function useFabricHistory(
|
||||
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;
|
||||
import { fabricEffectListener } from './fabricEffectListener';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
||||
type SnapshotStateType = {
|
||||
canvasState: string;
|
||||
imageState: ImageStateType;
|
||||
};
|
||||
|
||||
export class FabricHistory extends EventEmitter {
|
||||
private readonly canvas: fabric.Canvas;
|
||||
const SNAPSHOT_LIMIT = 1000;
|
||||
|
||||
private highWatermark: number;
|
||||
private isTimeTraveling: boolean;
|
||||
private snapshots: Array<SnapshotStateType>;
|
||||
/**
|
||||
* A helper hook to manage `<MediaEditor>`'s undo/redo state.
|
||||
*
|
||||
* 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) {
|
||||
super();
|
||||
const { highWatermark, snapshots } = state;
|
||||
const isTimeTraveling = getIsTimeTraveling(state);
|
||||
const desiredSnapshot: undefined | SnapshotStateType =
|
||||
snapshots[highWatermark - 1];
|
||||
|
||||
this.canvas = canvas;
|
||||
this.highWatermark = 0;
|
||||
this.isTimeTraveling = false;
|
||||
this.snapshots = [];
|
||||
|
||||
this.canvas.on('object:added', this.onObjectModified.bind(this));
|
||||
this.canvas.on('object:modified', this.onObjectModified.bind(this));
|
||||
this.canvas.on('object:removed', this.onObjectModified.bind(this));
|
||||
}
|
||||
|
||||
private applyState({ canvasState, imageState }: SnapshotStateType): void {
|
||||
this.canvas.loadFromJSON(canvasState, () => {
|
||||
this.emit('appliedState', imageState);
|
||||
this.emit('historyChanged');
|
||||
this.isTimeTraveling = false;
|
||||
const takeSnapshotInternal = useCallback((snapshot: SnapshotStateType) => {
|
||||
setState(oldState => {
|
||||
const newSnapshots = oldState.snapshots.slice(0, oldState.highWatermark);
|
||||
newSnapshots.push(snapshot);
|
||||
while (newSnapshots.length > SNAPSHOT_LIMIT) {
|
||||
newSnapshots.shift();
|
||||
}
|
||||
return {
|
||||
snapshots: newSnapshots,
|
||||
highWatermark: newSnapshots.length,
|
||||
appliedHighWatermark: newSnapshots.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
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 {
|
||||
return JSON.stringify(this.canvas.toDatalessJSON());
|
||||
}
|
||||
// Global Fabric overrides
|
||||
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 {
|
||||
if (target?.excludeFromExport) {
|
||||
// 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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Moving between different snapshots
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas || !isTimeTraveling || !desiredSnapshot) {
|
||||
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');
|
||||
}
|
||||
|
||||
private getUndoState(): SnapshotStateType | undefined {
|
||||
if (!this.canUndo()) {
|
||||
// Taking snapshots when objects are added, modified, and removed
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas || isTimeTraveling) {
|
||||
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 this.snapshots[this.highWatermark];
|
||||
}
|
||||
|
||||
private getRedoState(): SnapshotStateType | undefined {
|
||||
if (this.canRedo()) {
|
||||
this.highWatermark += 1;
|
||||
}
|
||||
|
||||
return this.snapshots[this.highWatermark];
|
||||
}
|
||||
|
||||
public takeSnapshot(imageState: ImageStateType): void {
|
||||
if (this.isTimeTraveling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.canRedo()) {
|
||||
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;
|
||||
}
|
||||
return {
|
||||
canRedo: highWatermark < snapshots.length,
|
||||
canUndo: highWatermark > 1,
|
||||
redoIfPossible,
|
||||
takeSnapshot,
|
||||
undoIfPossible,
|
||||
};
|
||||
}
|
||||
|
||||
function getCanvasState(fabricCanvas: fabric.Canvas): string {
|
||||
return JSON.stringify(fabricCanvas.toDatalessJSON());
|
||||
}
|
||||
|
||||
function getIsTimeTraveling({
|
||||
highWatermark,
|
||||
appliedHighWatermark,
|
||||
}: Readonly<{ highWatermark: number; appliedHighWatermark: number }>): boolean {
|
||||
return highWatermark !== appliedHighWatermark;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue