153 lines
3.9 KiB
TypeScript
153 lines
3.9 KiB
TypeScript
|
// Copyright 2021 Signal Messenger, LLC
|
||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||
|
|
||
|
import { useEffect, useState } from 'react';
|
||
|
import { fabric } from 'fabric';
|
||
|
import EventEmitter from 'events';
|
||
|
|
||
|
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;
|
||
|
|
||
|
type SnapshotStateType = {
|
||
|
canvasState: string;
|
||
|
imageState?: ImageStateType;
|
||
|
};
|
||
|
|
||
|
export class FabricHistory extends EventEmitter {
|
||
|
private readonly canvas: fabric.Canvas;
|
||
|
|
||
|
private highWatermark: number;
|
||
|
private isTimeTraveling: boolean;
|
||
|
private snapshots: Array<SnapshotStateType>;
|
||
|
|
||
|
constructor(canvas: fabric.Canvas) {
|
||
|
super();
|
||
|
|
||
|
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;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private getState(): string {
|
||
|
return JSON.stringify(this.canvas.toDatalessJSON());
|
||
|
}
|
||
|
|
||
|
private onObjectModified({ target }: fabric.IEvent): void {
|
||
|
if (target?.excludeFromExport) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.takeSnapshot();
|
||
|
}
|
||
|
|
||
|
private getUndoState(): SnapshotStateType | undefined {
|
||
|
if (!this.canUndo()) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
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, 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;
|
||
|
}
|
||
|
}
|