Signal-Desktop/ts/mediaEditor/useFabricHistory.ts

228 lines
7.4 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useCallback, useEffect, useState } from 'react';
import { fabric } from 'fabric';
import * as log from '../logging/log';
import type { ImageStateType } from './ImageStateType';
import { MediaEditorFabricIText } from './MediaEditorFabricIText';
import { MediaEditorFabricPath } from './MediaEditorFabricPath';
import { MediaEditorFabricSticker } from './MediaEditorFabricSticker';
import { fabricEffectListener } from './fabricEffectListener';
import { strictAssert } from '../util/assert';
type SnapshotStateType = {
canvasState: string;
imageState: ImageStateType;
};
const SNAPSHOT_LIMIT = 1000;
/**
* 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,
});
const { highWatermark, snapshots } = state;
const isTimeTraveling = getIsTimeTraveling(state);
const desiredSnapshot: undefined | SnapshotStateType =
snapshots[highWatermark - 1];
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
),
}
);
}, []);
// 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;
// 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,
]);
// Taking snapshots when objects are added, modified, and removed
useEffect(() => {
if (!fabricCanvas || isTimeTraveling) {
return;
}
return fabricEffectListener(
fabricCanvas,
// We want to take snapshots when objects are added, removed, and modified. The
// first two are obvious. We DON'T want to take snapshots before those things
// happen (like `object:moving`), and we also don't want to take redundant ones
// (which is why we don't listen to both `object:modified` and `object:rotated`).
//
// See <http://fabricjs.com/docs/fabric.Canvas.html#Canvas> for the list of events.
['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]);
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;
}