fabricjs / fabric.js

Javascript Canvas Library, SVG-to-Canvas (& canvas-to-SVG) Parser
http://fabricjs.com
Other
29.03k stars 3.51k forks source link

[Feature]: Fabricjs 6.0 Undo/Redo (history) #10011

Open anzhel-milenov opened 3 months ago

anzhel-milenov commented 3 months ago

CheckList

Description

We are trying to implement some "history" events like (Undo/Redo) into our fabricjs 6.0 project I haven't found any native solutions, so we are using fabric-history 2.0 npm package.

They seem to not have fabricjs 6.0 support, and I found this comment in here (https://github.com/alimozdemir/fabric-history/issues/48) where user "StringKe" provides some solution (maybe custom reworked the code to fit into the 6.0 ts logic).

Unfortunately, it is just not working properly somehow.

Do you think is possible to have some easier way to make this happen, no matter with package or custom? Maybe there are changes of loadFromJSON, that are working differently?

Current State

Here I can put a simple code how I am trying to use the fabric-history. I am using Vuejs3, but I think the code is readable

<template>
  <div class="mt-3 canvas-wrapper">
    <div>
      <button @click="undo">Undo</button>
      <button @click="redo">Redo</button>
    </div>
    <div class="mt-2">
      <canvas ref="canvasRef"></canvas>
    </div>
  </div>
</template>
<script setup lang="ts">
import { Canvas, Rect} from 'fabric'; // Adjust imports for Fabric.js v4+
import { onMounted, ref } from 'vue';
import HistoryEventCallback from './fabric-history'; // Adjust the path as necessary

let fabricCanvas: Canvas;
let historyEventCallback: HistoryEventCallback;
const canvasRef = ref<HTMLCanvasElement | null>(null);

const renderCanvas = () => {
  if (canvasRef.value) {
    const canvasWidth = 500;
    const canvasHeight = 300;
      fabricCanvas = new Canvas(canvasRef.value, {
        width: canvasWidth,
        height: canvasHeight,
        selection: false,
        preserveObjectStacking: true,
        controlsAboveOverlay: true,
        fill: 'white',
        stroke: 'black',
        selectable: false,
      });

    historyEventCallback = new HistoryEventCallback(fabricCanvas);

    fabricCanvas.add(new Rect({
      left: fabricCanvas.width/2,
      top: fabricCanvas.height/2,
      height: 100,
      width: 100,
      fill: '#faa',
      originX: 'center',
      originY: 'center',  
      centeredRotation: true
    }));
    fabricCanvas.renderAll();
  }
}

const undo = () => {
  historyEventCallback.undo();
};

const redo = () => {
  historyEventCallback.redo();
};

//END Render
onMounted(() => {
  renderCanvas();
});
</script>
<style lang="scss" scoped>
@import "@/assets/variables.scss";
.canvas-wrapper {
  position: relative;
    canvas {
      position: absolute;
      top: 0;
      left: 0;
      border: 1px solid #000;
    }
}
</style>

And this is the fabric-history.ts code:

import type { Canvas } from 'fabric';

interface HistoryEvent {
  json: string;
}

export class HistoryEventCallback {
  private canvas: Canvas;
  private history: HistoryEvent[] = [];
  private redoHistory: HistoryEvent[] = [];
  private historyLimit: number;

  constructor(canvas: Canvas, historyLimit: number = 50) {
    this.canvas = canvas;
    this.historyLimit = historyLimit;
    this.init();
  }

  private init() {
    this.canvas.on('object:added', this.saveHistory.bind(this));
    this.canvas.on('object:modified', this.saveHistory.bind(this));
    this.canvas.on('object:removed', this.saveHistory.bind(this));
  }

  private saveHistory() {
    if (this.history.length >= this.historyLimit) {
      this.history.shift(); // Remove the oldest history event
    }
    this.history.push({ json: this.canvas.toJSON() });
    this.redoHistory = []; // Clear redo history on new changes
  }

  public undo() {
    if (this.history.length > 1) {
      const lastHistory = this.history.pop();
      console.log(this.history);
      if (lastHistory) {
        this.redoHistory.push(lastHistory);
      }
      const previousHistory = this.history[this.history.length - 1];
      this.canvas.loadFromJSON(previousHistory.json, this.canvas.renderAll.bind(this.canvas));
    }
  }

  public redo() {
    if (this.redoHistory.length > 0) {
      const redoHistoryEvent = this.redoHistory.pop();
      if (redoHistoryEvent) {
        this.history.push(redoHistoryEvent);
        this.canvas.loadFromJSON(redoHistoryEvent.json, this.canvas.renderAll.bind(this.canvas));
      }
    }
  }

  public clearHistory() {
    this.history = [];
    this.redoHistory = [];
    this.saveHistory(); // Save the current state
  }
}

export default HistoryEventCallback;

Additional Context

Here are a few screenshots of the behavior.

1. Initial state: Screenshot_1

2. Moved object Screenshot_2

3. Clicked button Undo(object is on the previous position but disappeared. I see it by the mouse changing the pointer when I move around the previous position on the center; Screenshot_3

4. Clicked on the invisible object rect and is shown again (Feels like is rendered, again, have no clue what this behavior is). Screenshot_4

anzhel-milenov commented 3 months ago

OK I've found the issue it is because of the 6.0 Promises instead of function callback, so

this: this.canvas.loadFromJSON(redoHistoryEvent.json, this.canvas.renderAll.bind(this.canvas));

becomes this:

this.canvas.loadFromJSON(redoHistoryEvent.json).then(() => {
          this.canvas.renderAll();
        });

But remains the issue of identifying objects after they are loaded from JSON

AlvesJorge commented 3 months ago

Here's my implementation of a history

  saveCanvasState(fabricCanvas = this.fabricCanvas, history = this.history) {
    const jsonCanvas = fabricCanvas.toObject();
    history.push(jsonCanvas);
  }

  clear(fabricCanvas = this.fabricCanvas) {
    fabricCanvas.remove(...fabricCanvas.getObjects());
  }

  undo(fabricCanvas = this.fabricCanvas, history = this.history) {
    if (history.length === 1) return;
    this.clear();
    history.pop();
    fabricCanvas.off("object:added");
    fabric.util.enlivenObjects(history[history.length - 1].objects)
      .then((objs) => {
        objs.forEach((obj) => fabricCanvas.add(obj));
        fabricCanvas.on("object:added", () => this.saveCanvasState(fabricCanvas));
      });
  }
SupremeDeity commented 2 months ago

Here's my implementation of a history

  saveCanvasState(fabricCanvas = this.fabricCanvas, history = this.history) {
    const jsonCanvas = fabricCanvas.toObject();
    history.push(jsonCanvas);
  }

  clear(fabricCanvas = this.fabricCanvas) {
    fabricCanvas.remove(...fabricCanvas.getObjects());
  }

  undo(fabricCanvas = this.fabricCanvas, history = this.history) {
    if (history.length === 1) return;
    this.clear();
    history.pop();
    fabricCanvas.off("object:added");
    fabric.util.enlivenObjects(history[history.length - 1].objects)
      .then((objs) => {
        objs.forEach((obj) => fabricCanvas.add(obj));
        fabricCanvas.on("object:added", () => this.saveCanvasState(fabricCanvas));
      });
  }

Oh my god, thank you lol. This really helped me. I have modified the code to work with "modified" and "removed" events as well and if everything works out and i remember to do it, i will post the completed code here.

SupremeDeity commented 1 month ago

Here is what i have ended up with, its a modified version of @AlvesJorge, ironing out any quirks i could find. Kinda inefficient but it is what it is:

import * as fabric from "fabric";

class CanvasHistory {
  constructor(canvas) {
    this.canvas = canvas;
    this.history = [];
    this.historyRedo = [];
    this._isClearingCanvas = false; // Flag to avoid tracking during canvas clearing

    this._init();
  }

  _init() {
    this._saveCanvasState(); // Save initial state
    // Automatically save canvas state on object addition
    this.canvas.on("custom:added", () => this._saveCanvasState());
    this.canvas.on("object:modified", () => this._saveCanvasState());
    this.canvas.on("object:removed", () => {
      if (!this._isClearingCanvas) {
        this._saveCanvasState();
      }
    });
  }

  _saveCanvasState() {
    const jsonCanvas = structuredClone(this.canvas.toObject().objects)
    this.history.push(jsonCanvas);
  }

  _clearCanvas() {
    this._isClearingCanvas = true;
    this.canvas.remove(...this.canvas.getObjects());
    this._isClearingCanvas = false;
  }

  async undo() {
    if (this.history.length <= 1) return; // Prevent undoing beyond the initial state

    this._clearCanvas();

    this.historyRedo.push(this.history.pop()); // Remove the current state
    const lastState = this.history[this.history.length - 1];
    const objects = await fabric.util.enlivenObjects(lastState);

    this._applyState(objects)
  }

  async redo() {
    if (this.historyRedo.length === 0) return; // Prevent undoing beyond the initial state
    console.log(this.historyRedo)
    this._clearCanvas();
    const lastState = this.historyRedo.pop();
    this.history.push(lastState)

    const objects = await fabric.util.enlivenObjects(lastState);

    this._applyState(objects)
  }

  _applyState(objects) {
    this.canvas.off("custom:added");
    this.canvas.off("object:modified");
    this.canvas.off("object:removed");

    objects.forEach((obj) => {
      this.canvas.add(obj)
    });

    // Re-enable event listeners
    this.canvas.on("custom:added", () => this._saveCanvasState());
    this.canvas.on("object:modified", () => this._saveCanvasState());
    this.canvas.on("object:removed", () => {
      if (!this._isClearingCanvas) {
        this._saveCanvasState();
      }
    });
    this.canvas.renderAll()
  }
}

export default CanvasHistory;

NOTE: I am using a custom event here, instead of "object:added", i have "custom:added". This is useful for drawing apps where the drawing's shape should only be registered when the mouse up event has taken place.