@anth0nycodes/fabric-history 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -8
- package/dist/canvas.d.ts +72 -6
- package/dist/canvas.d.ts.map +1 -1
- package/dist/canvas.js +133 -27
- package/dist/canvas.js.map +1 -1
- package/package.json +5 -1
- package/src/canvas.ts +178 -28
- package/src/canvas.test.ts +0 -178
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@ A library built on top of fabric.js that provides undo/redo history management f
|
|
|
6
6
|
|
|
7
7
|
- Undo/Redo functionality for fabric.js canvases
|
|
8
8
|
- Automatic history tracking for object additions, removals, and modifications
|
|
9
|
+
- Multi-selection batching: operations on multiple selected objects are recorded as a single history entry
|
|
9
10
|
- Support for path creation and erasing events
|
|
11
|
+
- Custom events for history state changes
|
|
10
12
|
- Easy integration with existing fabric.js projects
|
|
11
13
|
- Support for fabric.js v6 and v7
|
|
12
14
|
|
|
@@ -65,13 +67,15 @@ Extends fabric.js `Canvas` class with history management capabilities.
|
|
|
65
67
|
|
|
66
68
|
#### Methods
|
|
67
69
|
|
|
68
|
-
| Method
|
|
69
|
-
|
|
|
70
|
-
| `undo()`
|
|
71
|
-
| `redo()`
|
|
72
|
-
| `canUndo()`
|
|
73
|
-
| `canRedo()`
|
|
74
|
-
| `
|
|
70
|
+
| Method | Returns | Description |
|
|
71
|
+
| ---------------- | --------------- | ----------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| `undo()` | `Promise<void>` | Undo the most recent action |
|
|
73
|
+
| `redo()` | `Promise<void>` | Redo the most recently undone action |
|
|
74
|
+
| `canUndo()` | `boolean` | Check if an undo action is available |
|
|
75
|
+
| `canRedo()` | `boolean` | Check if a redo action is available |
|
|
76
|
+
| `clearHistory()` | `void` | Clear the undo and redo history stacks |
|
|
77
|
+
| `clearCanvas()` | `void` | Clear the canvas and save the cleared state to history (use this instead of the inherited `clear()`) |
|
|
78
|
+
| `dispose()` | `void` | Clean up event listeners and dispose the canvas |
|
|
75
79
|
|
|
76
80
|
#### Tracked Events
|
|
77
81
|
|
|
@@ -84,6 +88,29 @@ History is automatically saved when these fabric.js events occur:
|
|
|
84
88
|
- `erasing:end` - When an erasing operation completes
|
|
85
89
|
- `canvas:cleared` - When the canvas is cleared
|
|
86
90
|
|
|
91
|
+
#### Custom Events
|
|
92
|
+
|
|
93
|
+
`CanvasWithHistory` fires custom events that you can listen to for history state changes:
|
|
94
|
+
|
|
95
|
+
| Event | Payload | Description |
|
|
96
|
+
| ----------------- | ------------------------------------------------ | ---------------------------------- |
|
|
97
|
+
| `history:append` | `{ json: string, initial: boolean }` | Fired when a state is saved |
|
|
98
|
+
| `history:undo` | `{ lastUndoAction: string }` | Fired when an undo is performed |
|
|
99
|
+
| `history:redo` | `{ lastRedoAction: string }` | Fired when a redo is performed |
|
|
100
|
+
| `history:cleared` | `{}` | Fired when history stacks cleared |
|
|
101
|
+
|
|
102
|
+
**Example:**
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
canvas.on("history:append", ({ json, initial }) => {
|
|
106
|
+
console.log("State saved:", initial ? "initial" : "action");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
canvas.on("history:undo", ({ lastUndoAction }) => {
|
|
110
|
+
console.log("Undo performed");
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
87
114
|
## Requirements
|
|
88
115
|
|
|
89
116
|
- fabric.js 6.x or 7.x
|
|
@@ -103,9 +130,15 @@ pnpm build
|
|
|
103
130
|
# Type check
|
|
104
131
|
pnpm check
|
|
105
132
|
|
|
106
|
-
# Run tests
|
|
133
|
+
# Run all tests
|
|
107
134
|
pnpm test
|
|
108
135
|
|
|
136
|
+
# Run integration tests only
|
|
137
|
+
pnpm test:it
|
|
138
|
+
|
|
139
|
+
# Run E2E tests only (uses Playwright)
|
|
140
|
+
pnpm test:e2e
|
|
141
|
+
|
|
109
142
|
# Run tests with coverage
|
|
110
143
|
pnpm coverage
|
|
111
144
|
```
|
package/dist/canvas.d.ts
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
|
-
import { Canvas } from "fabric";
|
|
1
|
+
import { Canvas, type TEvent } from "fabric";
|
|
2
|
+
declare module "fabric" {
|
|
3
|
+
interface CanvasEvents {
|
|
4
|
+
"history:append": Partial<TEvent> & {
|
|
5
|
+
/**
|
|
6
|
+
* Serialized canvas state that was saved to history.
|
|
7
|
+
*/
|
|
8
|
+
json: string;
|
|
9
|
+
/**
|
|
10
|
+
* Boolean flag indicating whether or not the appended history action is the initial state of the canvas.
|
|
11
|
+
*/
|
|
12
|
+
initial: boolean;
|
|
13
|
+
};
|
|
14
|
+
"history:undo": Partial<TEvent> & {
|
|
15
|
+
/**
|
|
16
|
+
* Serialized canvas state that was most recently undone.
|
|
17
|
+
*/
|
|
18
|
+
lastUndoAction: string;
|
|
19
|
+
};
|
|
20
|
+
"history:redo": Partial<TEvent> & {
|
|
21
|
+
/**
|
|
22
|
+
* Serialized canvas state that was most recently redone.
|
|
23
|
+
*/
|
|
24
|
+
lastRedoAction: string;
|
|
25
|
+
};
|
|
26
|
+
"history:cleared": Partial<TEvent>;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
2
29
|
export declare class CanvasWithHistory extends Canvas {
|
|
3
30
|
private _historyUndo;
|
|
4
31
|
private _historyRedo;
|
|
5
|
-
private
|
|
32
|
+
private _selectedObjects;
|
|
33
|
+
private _isMultiSelection;
|
|
34
|
+
private _historyIsMoving;
|
|
6
35
|
private _historyProcessing;
|
|
7
36
|
private _historyCurrentState;
|
|
8
37
|
constructor(...args: ConstructorParameters<typeof Canvas>);
|
|
@@ -10,14 +39,36 @@ export declare class CanvasWithHistory extends Canvas {
|
|
|
10
39
|
* Binds all relevant fabric event listeners.
|
|
11
40
|
*/
|
|
12
41
|
private _bindEventListeners;
|
|
42
|
+
/**
|
|
43
|
+
* Stores the multi-selection state inside `_selectedObjects` and sets the `_isMultiSelection` flag to true.
|
|
44
|
+
*
|
|
45
|
+
* @param options - The options object containing the selected objects.
|
|
46
|
+
*/
|
|
47
|
+
private _handleSelectionCreated;
|
|
48
|
+
/**
|
|
49
|
+
* Stores the updated multi-selection state inside `_selectedObjects` and sets the `_isMultiSelection` flag to true if there are more than 1 objects selected.
|
|
50
|
+
*
|
|
51
|
+
* @param options - The options object containing the updated selected objects.
|
|
52
|
+
*/
|
|
53
|
+
private _handleSelectionUpdated;
|
|
54
|
+
/**
|
|
55
|
+
* Clears the multi-selection state and sets the `_isMultiSelection` flag to false.
|
|
56
|
+
*/
|
|
57
|
+
private _handleSelectionCleared;
|
|
13
58
|
/**
|
|
14
59
|
* Saves the initial state of the canvas.
|
|
15
60
|
*/
|
|
16
|
-
private
|
|
61
|
+
private _historySaveInitialState;
|
|
62
|
+
/**
|
|
63
|
+
* Handles object removal events.
|
|
64
|
+
*
|
|
65
|
+
* @param options - The options object containing details about the removed object.
|
|
66
|
+
*/
|
|
67
|
+
private _handleObjectRemoved;
|
|
17
68
|
/**
|
|
18
69
|
* Starts the movement event listener for objects.
|
|
19
70
|
*/
|
|
20
|
-
private
|
|
71
|
+
private _handleObjectMoving;
|
|
21
72
|
/**
|
|
22
73
|
* Handles object modification events, including moving, resizing, rotating,
|
|
23
74
|
* scaling, and skewing.
|
|
@@ -32,7 +83,7 @@ export declare class CanvasWithHistory extends Canvas {
|
|
|
32
83
|
*/
|
|
33
84
|
private _historyCurrent;
|
|
34
85
|
/**
|
|
35
|
-
* Records the current canvas
|
|
86
|
+
* Records the current state of the canvas to the history stack if the state has changed since the last recorded state. This method is called after relevant canvas events such as object modifications, additions, and removals.
|
|
36
87
|
*/
|
|
37
88
|
private _historySaveAction;
|
|
38
89
|
/**
|
|
@@ -60,11 +111,26 @@ export declare class CanvasWithHistory extends Canvas {
|
|
|
60
111
|
/**
|
|
61
112
|
* Clears the history stacks for undo and redo.
|
|
62
113
|
*/
|
|
63
|
-
|
|
114
|
+
clearHistory(): void;
|
|
115
|
+
/**
|
|
116
|
+
* Debug method to log relevant events to the console. Always remember to remove before pushing once you're done debugging locally!
|
|
117
|
+
*/
|
|
118
|
+
private _historyDebug;
|
|
64
119
|
/**
|
|
65
120
|
* Unsubscribes all relevant fabric event listeners.
|
|
66
121
|
*/
|
|
67
122
|
private _disposeEventListeners;
|
|
123
|
+
/**
|
|
124
|
+
* Cleans up event listeners and history stacks before disposing of the canvas instance.
|
|
125
|
+
*/
|
|
68
126
|
dispose(): Promise<boolean>;
|
|
127
|
+
/**
|
|
128
|
+
* Clears the canvas and saves the cleared state to history.
|
|
129
|
+
*
|
|
130
|
+
* @remarks
|
|
131
|
+
* When using `CanvasWithHistory`, use this method instead of `clear()`.
|
|
132
|
+
* The inherited `clear()` method does not record to history.
|
|
133
|
+
*/
|
|
134
|
+
clearCanvas(): void;
|
|
69
135
|
}
|
|
70
136
|
//# sourceMappingURL=canvas.d.ts.map
|
package/dist/canvas.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EAGN,KAAK,MAAM,EACZ,MAAM,QAAQ,CAAC;AAGhB,OAAO,QAAQ,QAAQ,CAAC;IACtB,UAAU,YAAY;QACpB,gBAAgB,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG;YAClC;;eAEG;YACH,IAAI,EAAE,MAAM,CAAC;YACb;;eAEG;YACH,OAAO,EAAE,OAAO,CAAC;SAClB,CAAC;QACF,cAAc,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG;YAChC;;eAEG;YACH,cAAc,EAAE,MAAM,CAAC;SACxB,CAAC;QACF,cAAc,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG;YAChC;;eAEG;YACH,cAAc,EAAE,MAAM,CAAC;SACxB,CAAC;QACF,iBAAiB,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;KACpC;CACF;AAED,qBAAa,iBAAkB,SAAQ,MAAM;IAE3C,OAAO,CAAC,YAAY,CAAW;IAC/B,OAAO,CAAC,YAAY,CAAW;IAG/B,OAAO,CAAC,gBAAgB,CAAiB;IACzC,OAAO,CAAC,iBAAiB,CAAU;IAGnC,OAAO,CAAC,gBAAgB,CAAU;IAClC,OAAO,CAAC,kBAAkB,CAAU;IAEpC,OAAO,CAAC,oBAAoB,CAAS;gBAEzB,GAAG,IAAI,EAAE,qBAAqB,CAAC,OAAO,MAAM,CAAC;IAezD;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAc3B;;;;OAIG;IACH,OAAO,CAAC,uBAAuB;IAS/B;;;;OAIG;IACH,OAAO,CAAC,uBAAuB;IAM/B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAK/B;;OAEG;IACH,OAAO,CAAC,wBAAwB;IAMhC;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAsB5B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAI3B;;;;;OAKG;IACH,OAAO,CAAC,qBAAqB;IAM7B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAIvB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAW1B;;OAEG;IACG,IAAI;IAiBV;;OAEG;IACH,OAAO;IAIP;;OAEG;IACG,IAAI;IAeV;;OAEG;IACH,OAAO;IAIP;;;;OAIG;YACW,gBAAgB;IAa9B;;OAEG;IACH,YAAY;IAMZ;;OAEG;IACH,OAAO,CAAC,aAAa;IAwBrB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAc9B;;OAEG;IACH,OAAO;IAMP;;;;;;OAMG;IACH,WAAW;CAMZ"}
|
package/dist/canvas.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { Canvas } from "fabric";
|
|
1
|
+
import { Canvas, } from "fabric";
|
|
2
2
|
export class CanvasWithHistory extends Canvas {
|
|
3
|
-
// History
|
|
3
|
+
// History stack properties
|
|
4
4
|
_historyUndo;
|
|
5
5
|
_historyRedo;
|
|
6
|
-
//
|
|
7
|
-
|
|
6
|
+
// Multi-selection properties
|
|
7
|
+
_selectedObjects;
|
|
8
|
+
_isMultiSelection;
|
|
9
|
+
// Boolean properties to determine whether or not we should save to history
|
|
10
|
+
_historyIsMoving;
|
|
8
11
|
_historyProcessing;
|
|
9
12
|
_historyCurrentState;
|
|
10
13
|
constructor(...args) {
|
|
11
14
|
super(...args);
|
|
12
15
|
this._historyUndo = [];
|
|
13
16
|
this._historyRedo = [];
|
|
14
|
-
this.
|
|
17
|
+
this._selectedObjects = [];
|
|
18
|
+
this._isMultiSelection = false;
|
|
19
|
+
this._historyIsMoving = false;
|
|
15
20
|
this._historyProcessing = false;
|
|
16
21
|
this._historyCurrentState = this._historyCurrent();
|
|
17
22
|
this._bindEventListeners();
|
|
18
|
-
this.
|
|
23
|
+
this._historySaveInitialState();
|
|
19
24
|
}
|
|
20
25
|
/**
|
|
21
26
|
* Binds all relevant fabric event listeners.
|
|
@@ -25,25 +30,82 @@ export class CanvasWithHistory extends Canvas {
|
|
|
25
30
|
"path:created": this._historySaveAction.bind(this),
|
|
26
31
|
"erasing:end": this._historySaveAction.bind(this),
|
|
27
32
|
"object:added": this._historySaveAction.bind(this),
|
|
28
|
-
"object:removed": this.
|
|
29
|
-
"object:moving": this.
|
|
33
|
+
"object:removed": this._handleObjectRemoved.bind(this),
|
|
34
|
+
"object:moving": this._handleObjectMoving.bind(this),
|
|
30
35
|
"object:modified": this._handleObjectModified.bind(this),
|
|
31
|
-
"
|
|
36
|
+
"selection:created": this._handleSelectionCreated.bind(this),
|
|
37
|
+
"selection:updated": this._handleSelectionUpdated.bind(this),
|
|
38
|
+
"selection:cleared": this._handleSelectionCleared.bind(this),
|
|
32
39
|
});
|
|
33
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Stores the multi-selection state inside `_selectedObjects` and sets the `_isMultiSelection` flag to true.
|
|
43
|
+
*
|
|
44
|
+
* @param options - The options object containing the selected objects.
|
|
45
|
+
*/
|
|
46
|
+
_handleSelectionCreated(options) {
|
|
47
|
+
const currentSelectedObjects = options.selected;
|
|
48
|
+
if (currentSelectedObjects.length > 1) {
|
|
49
|
+
this._selectedObjects = currentSelectedObjects;
|
|
50
|
+
this._isMultiSelection = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Stores the updated multi-selection state inside `_selectedObjects` and sets the `_isMultiSelection` flag to true if there are more than 1 objects selected.
|
|
55
|
+
*
|
|
56
|
+
* @param options - The options object containing the updated selected objects.
|
|
57
|
+
*/
|
|
58
|
+
_handleSelectionUpdated(options) {
|
|
59
|
+
const allSelectedObjects = this.getActiveObjects();
|
|
60
|
+
this._selectedObjects = allSelectedObjects;
|
|
61
|
+
this._isMultiSelection = allSelectedObjects.length > 1;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Clears the multi-selection state and sets the `_isMultiSelection` flag to false.
|
|
65
|
+
*/
|
|
66
|
+
_handleSelectionCleared() {
|
|
67
|
+
this._selectedObjects = [];
|
|
68
|
+
this._isMultiSelection = false;
|
|
69
|
+
}
|
|
34
70
|
/**
|
|
35
71
|
* Saves the initial state of the canvas.
|
|
36
72
|
*/
|
|
37
|
-
|
|
73
|
+
_historySaveInitialState() {
|
|
38
74
|
const initialState = this._historyCurrent();
|
|
39
75
|
this._historyUndo = [initialState];
|
|
40
76
|
this._historyCurrentState = initialState;
|
|
41
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Handles object removal events.
|
|
80
|
+
*
|
|
81
|
+
* @param options - The options object containing details about the removed object.
|
|
82
|
+
*/
|
|
83
|
+
_handleObjectRemoved(options) {
|
|
84
|
+
/*
|
|
85
|
+
Check !_historyProcessing to prevent recursion: this.remove() fires
|
|
86
|
+
object:removed events, which would re-enter this handler while we're
|
|
87
|
+
still processing the first removal.
|
|
88
|
+
*/
|
|
89
|
+
if (!this._historyProcessing &&
|
|
90
|
+
this._isMultiSelection &&
|
|
91
|
+
this._selectedObjects.some((obj) => obj === options.target)) {
|
|
92
|
+
this._historyProcessing = true;
|
|
93
|
+
const objectsToRemove = [...this._selectedObjects];
|
|
94
|
+
this._selectedObjects = [];
|
|
95
|
+
this.remove(...objectsToRemove);
|
|
96
|
+
this.discardActiveObject();
|
|
97
|
+
this._historyProcessing = false;
|
|
98
|
+
this._historySaveAction();
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this._historySaveAction();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
42
104
|
/**
|
|
43
105
|
* Starts the movement event listener for objects.
|
|
44
106
|
*/
|
|
45
|
-
|
|
46
|
-
this.
|
|
107
|
+
_handleObjectMoving() {
|
|
108
|
+
this._historyIsMoving = true;
|
|
47
109
|
}
|
|
48
110
|
/**
|
|
49
111
|
* Handles object modification events, including moving, resizing, rotating,
|
|
@@ -53,7 +115,7 @@ export class CanvasWithHistory extends Canvas {
|
|
|
53
115
|
*/
|
|
54
116
|
_handleObjectModified() {
|
|
55
117
|
// object:moving -> object:modified - modification is triggered as soon as the movement of an object halts
|
|
56
|
-
this.
|
|
118
|
+
this._historyIsMoving = false;
|
|
57
119
|
this._historySaveAction();
|
|
58
120
|
}
|
|
59
121
|
/**
|
|
@@ -65,17 +127,18 @@ export class CanvasWithHistory extends Canvas {
|
|
|
65
127
|
return JSON.stringify(this.toDatalessJSON());
|
|
66
128
|
}
|
|
67
129
|
/**
|
|
68
|
-
* Records the current canvas
|
|
130
|
+
* Records the current state of the canvas to the history stack if the state has changed since the last recorded state. This method is called after relevant canvas events such as object modifications, additions, and removals.
|
|
69
131
|
*/
|
|
70
132
|
_historySaveAction() {
|
|
71
|
-
if (this._historyProcessing || this.
|
|
133
|
+
if (this._historyProcessing || this._historyIsMoving)
|
|
72
134
|
return;
|
|
73
135
|
const latestJSON = this._historyCurrent();
|
|
74
136
|
if (this._historyCurrentState === latestJSON)
|
|
75
|
-
return;
|
|
137
|
+
return; // skips duplicates
|
|
76
138
|
this._historyUndo.push(latestJSON);
|
|
77
139
|
this._historyCurrentState = latestJSON;
|
|
78
140
|
this._historyRedo = [];
|
|
141
|
+
this.fire("history:append", { json: latestJSON, initial: false });
|
|
79
142
|
}
|
|
80
143
|
/**
|
|
81
144
|
* Undo the most recent action.
|
|
@@ -94,6 +157,7 @@ export class CanvasWithHistory extends Canvas {
|
|
|
94
157
|
return;
|
|
95
158
|
this._historyCurrentState = previousState;
|
|
96
159
|
await this._loadFromHistory(previousState);
|
|
160
|
+
this.fire("history:undo", { lastUndoAction: poppedState });
|
|
97
161
|
}
|
|
98
162
|
/**
|
|
99
163
|
* Checks for whether or not an action can be undone.
|
|
@@ -115,6 +179,7 @@ export class CanvasWithHistory extends Canvas {
|
|
|
115
179
|
// refresh canvas to load the popped state
|
|
116
180
|
this._historyCurrentState = poppedState;
|
|
117
181
|
await this._loadFromHistory(poppedState);
|
|
182
|
+
this.fire("history:redo", { lastRedoAction: poppedState });
|
|
118
183
|
}
|
|
119
184
|
/**
|
|
120
185
|
* Checks for whether or not an action can be redone.
|
|
@@ -128,12 +193,11 @@ export class CanvasWithHistory extends Canvas {
|
|
|
128
193
|
* @param historyState - The JSON string representing the canvas history state to load.
|
|
129
194
|
*/
|
|
130
195
|
async _loadFromHistory(historyState) {
|
|
131
|
-
this.clear();
|
|
132
|
-
this.discardActiveObject();
|
|
133
196
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this.
|
|
197
|
+
this.clear();
|
|
198
|
+
this.discardActiveObject();
|
|
199
|
+
await this.loadFromJSON(JSON.parse(historyState));
|
|
200
|
+
this.requestRenderAll();
|
|
137
201
|
}
|
|
138
202
|
catch (error) {
|
|
139
203
|
console.error("Error loading from history:", error);
|
|
@@ -145,9 +209,33 @@ export class CanvasWithHistory extends Canvas {
|
|
|
145
209
|
/**
|
|
146
210
|
* Clears the history stacks for undo and redo.
|
|
147
211
|
*/
|
|
148
|
-
|
|
149
|
-
this.
|
|
212
|
+
clearHistory() {
|
|
213
|
+
this._historySaveInitialState();
|
|
150
214
|
this._historyRedo = [];
|
|
215
|
+
this.fire("history:cleared");
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Debug method to log relevant events to the console. Always remember to remove before pushing once you're done debugging locally!
|
|
219
|
+
*/
|
|
220
|
+
_historyDebug() {
|
|
221
|
+
const EVENTS = [
|
|
222
|
+
"path:created",
|
|
223
|
+
"erasing:end",
|
|
224
|
+
"object:added",
|
|
225
|
+
"object:removed",
|
|
226
|
+
"object:moving",
|
|
227
|
+
"object:modified",
|
|
228
|
+
"selection:created",
|
|
229
|
+
"selection:cleared",
|
|
230
|
+
"canvas:cleared",
|
|
231
|
+
"history:append",
|
|
232
|
+
"history:undo",
|
|
233
|
+
"history:redo",
|
|
234
|
+
"history:cleared",
|
|
235
|
+
];
|
|
236
|
+
EVENTS.forEach((e) => {
|
|
237
|
+
this.on(e, () => console.log(`📝 Event triggered: ${e}`));
|
|
238
|
+
});
|
|
151
239
|
}
|
|
152
240
|
/**
|
|
153
241
|
* Unsubscribes all relevant fabric event listeners.
|
|
@@ -157,16 +245,34 @@ export class CanvasWithHistory extends Canvas {
|
|
|
157
245
|
"path:created": this._historySaveAction.bind(this),
|
|
158
246
|
"erasing:end": this._historySaveAction.bind(this),
|
|
159
247
|
"object:added": this._historySaveAction.bind(this),
|
|
160
|
-
"object:removed": this.
|
|
161
|
-
"object:moving": this.
|
|
248
|
+
"object:removed": this._handleObjectRemoved.bind(this),
|
|
249
|
+
"object:moving": this._handleObjectMoving.bind(this),
|
|
162
250
|
"object:modified": this._handleObjectModified.bind(this),
|
|
163
|
-
"
|
|
251
|
+
"selection:created": this._handleSelectionCreated.bind(this),
|
|
252
|
+
"selection:updated": this._handleSelectionUpdated.bind(this),
|
|
253
|
+
"selection:cleared": this._handleSelectionCleared.bind(this),
|
|
164
254
|
});
|
|
165
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Cleans up event listeners and history stacks before disposing of the canvas instance.
|
|
258
|
+
*/
|
|
166
259
|
dispose() {
|
|
167
260
|
this._disposeEventListeners();
|
|
168
|
-
this.
|
|
261
|
+
this.clearHistory();
|
|
169
262
|
return super.dispose();
|
|
170
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Clears the canvas and saves the cleared state to history.
|
|
266
|
+
*
|
|
267
|
+
* @remarks
|
|
268
|
+
* When using `CanvasWithHistory`, use this method instead of `clear()`.
|
|
269
|
+
* The inherited `clear()` method does not record to history.
|
|
270
|
+
*/
|
|
271
|
+
clearCanvas() {
|
|
272
|
+
this._historyProcessing = true;
|
|
273
|
+
this.clear();
|
|
274
|
+
this._historyProcessing = false;
|
|
275
|
+
this._historySaveAction();
|
|
276
|
+
}
|
|
171
277
|
}
|
|
172
278
|
//# sourceMappingURL=canvas.js.map
|
package/dist/canvas.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"canvas.js","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"canvas.js","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,GAIP,MAAM,QAAQ,CAAC;AA+BhB,MAAM,OAAO,iBAAkB,SAAQ,MAAM;IAC3C,2BAA2B;IACnB,YAAY,CAAW;IACvB,YAAY,CAAW;IAE/B,6BAA6B;IACrB,gBAAgB,CAAiB;IACjC,iBAAiB,CAAU;IAEnC,2EAA2E;IACnE,gBAAgB,CAAU;IAC1B,kBAAkB,CAAU;IAE5B,oBAAoB,CAAS;IAErC,YAAY,GAAG,IAA0C;QACvD,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;QAEf,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;QAC3B,IAAI,CAAC,iBAAiB,GAAG,KAAK,CAAC;QAC/B,IAAI,CAAC,gBAAgB,GAAG,KAAK,CAAC;QAC9B,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,oBAAoB,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;QAEnD,IAAI,CAAC,mBAAmB,EAAE,CAAC;QAC3B,IAAI,CAAC,wBAAwB,EAAE,CAAC;IAClC,CAAC;IAED;;OAEG;IACK,mBAAmB;QACzB,IAAI,CAAC,EAAE,CAAC;YACN,cAAc,EAAE,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC;YAClD,aAAa,EAAE,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC;YACjD,cAAc,EAAE,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC;YAClD,gBAAgB,EAAE,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC;YACtD,eAAe,EAAE,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC;YACpD,iBAAiB,EAAE,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC;YACxD,mBAAmB,EAAE,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;YAC5D,mBAAmB,EAAE,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;YAC5D,mBAAmB,EAAE,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;SAC7D,CAAC,CAAC;IACL,CAAC;IAED;;;;OAIG;IACK,uBAAuB,CAAC,OAAqC;QACnE,MAAM,sBAAsB,GAAG,OAAO,CAAC,QAAQ,CAAC;QAEhD,IAAI,sBAAsB,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACtC,IAAI,CAAC,gBAAgB,GAAG,sBAAsB,CAAC;YAC/C,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC;QAChC,CAAC;IACH,CAAC;IAED;;;;OAIG;IACK,uBAAuB,CAAC,OAAqC;QACnE,MAAM,kBAAkB,GAAG,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACnD,IAAI,CAAC,gBAAgB,GAAG,kBAAkB,CAAC;QAC3C,IAAI,CAAC,iBAAiB,GAAG,kBAAkB,CAAC,MAAM,GAAG,CAAC,CAAC;IACzD,CAAC;IAED;;OAEG;IACK,uBAAuB;QAC7B,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;QAC3B,IAAI,CAAC,iBAAiB,GAAG,KAAK,CAAC;IACjC,CAAC;IAED;;OAEG;IACK,wBAAwB;QAC9B,MAAM,YAAY,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;QAC5C,IAAI,CAAC,YAAY,GAAG,CAAC,YAAY,CAAC,CAAC;QACnC,IAAI,CAAC,oBAAoB,GAAG,YAAY,CAAC;IAC3C,CAAC;IAED;;;;OAIG;IACK,oBAAoB,CAAC,OAAiC;QAC5D;;;;UAIE;QACF,IACE,CAAC,IAAI,CAAC,kBAAkB;YACxB,IAAI,CAAC,iBAAiB;YACtB,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,OAAO,CAAC,MAAM,CAAC,EAC3D,CAAC;YACD,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;YAC/B,MAAM,eAAe,GAAG,CAAC,GAAG,IAAI,CAAC,gBAAgB,CAAC,CAAC;YACnD,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;YAC3B,IAAI,CAAC,MAAM,CAAC,GAAG,eAAe,CAAC,CAAC;YAChC,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC3B,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC;YAChC,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC5B,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC5B,CAAC;IACH,CAAC;IACD;;OAEG;IACK,mBAAmB;QACzB,IAAI,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAC/B,CAAC;IAED;;;;;OAKG;IACK,qBAAqB;QAC3B,0GAA0G;QAC1G,IAAI,CAAC,gBAAgB,GAAG,KAAK,CAAC;QAC9B,IAAI,CAAC,kBAAkB,EAAE,CAAC;IAC5B,CAAC;IAED;;;;OAIG;IACK,eAAe;QACrB,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED;;OAEG;IACK,kBAAkB;QACxB,IAAI,IAAI,CAAC,kBAAkB,IAAI,IAAI,CAAC,gBAAgB;YAAE,OAAO;QAC7D,MAAM,UAAU,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;QAE1C,IAAI,IAAI,CAAC,oBAAoB,KAAK,UAAU;YAAE,OAAO,CAAC,mBAAmB;QACzE,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACnC,IAAI,CAAC,oBAAoB,GAAG,UAAU,CAAC;QACvC,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;IACpE,CAAC;IAED;;OAEG;IACH,KAAK,CAAC,IAAI;QACR,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,IAAI,CAAC;YAAE,OAAO;QAC1C,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAE/B,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,CAAC;QAC5C,IAAI,CAAC,WAAW;YAAE,OAAO;QAEzB,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAEpC,sEAAsE;QACtE,MAAM,aAAa,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACtE,IAAI,CAAC,aAAa;YAAE,OAAO;QAC3B,IAAI,CAAC,oBAAoB,GAAG,aAAa,CAAC;QAC1C,MAAM,IAAI,CAAC,gBAAgB,CAAC,aAAa,CAAC,CAAC;QAC3C,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,CAAC,CAAC;IAC7D,CAAC;IAED;;OAEG;IACH,OAAO;QACL,OAAO,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;IACtC,CAAC;IAED;;OAEG;IACH,KAAK,CAAC,IAAI;QACR,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC3C,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAE/B,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,CAAC;QAC5C,IAAI,CAAC,WAAW;YAAE,OAAO;QAEzB,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAEpC,0CAA0C;QAC1C,IAAI,CAAC,oBAAoB,GAAG,WAAW,CAAC;QACxC,MAAM,IAAI,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAC;QACzC,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,CAAC,CAAC;IAC7D,CAAC;IAED;;OAEG;IACH,OAAO;QACL,OAAO,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;IACtC,CAAC;IAED;;;;OAIG;IACK,KAAK,CAAC,gBAAgB,CAAC,YAAoB;QACjD,IAAI,CAAC;YACH,IAAI,CAAC,KAAK,EAAE,CAAC;YACb,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC3B,MAAM,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC;YAClD,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC1B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;QACtD,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC;QAClC,CAAC;IACH,CAAC;IAED;;OAEG;IACH,YAAY;QACV,IAAI,CAAC,wBAAwB,EAAE,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;IAC/B,CAAC;IAED;;OAEG;IACK,aAAa;QACnB,MAAM,MAAM,GAAG;YACb,cAAc;YACd,aAAa;YACb,cAAc;YACd,gBAAgB;YAChB,eAAe;YACf,iBAAiB;YACjB,mBAAmB;YACnB,mBAAmB;YACnB,gBAAgB;YAChB,gBAAgB;YAChB,cAAc;YACd,cAAc;YACd,iBAAiB;SAClB,CAAC;QAEF,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;YACnB,IAAI,CAAC,EAAE,CAAC,CAAuB,EAAE,GAAG,EAAE,CACpC,OAAO,CAAC,GAAG,CAAC,uBAAuB,CAAC,EAAE,CAAC,CACxC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACK,sBAAsB;QAC5B,IAAI,CAAC,GAAG,CAAC;YACP,cAAc,EAAE,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC;YAClD,aAAa,EAAE,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC;YACjD,cAAc,EAAE,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC;YAClD,gBAAgB,EAAE,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC;YACtD,eAAe,EAAE,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC;YACpD,iBAAiB,EAAE,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC;YACxD,mBAAmB,EAAE,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;YAC5D,mBAAmB,EAAE,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;YAC5D,mBAAmB,EAAE,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;SAC7D,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACH,OAAO;QACL,IAAI,CAAC,sBAAsB,EAAE,CAAC;QAC9B,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,OAAO,KAAK,CAAC,OAAO,EAAE,CAAC;IACzB,CAAC;IAED;;;;;;OAMG;IACH,WAAW;QACT,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAC/B,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,kBAAkB,EAAE,CAAC;IAC5B,CAAC;CACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anth0nycodes/fabric-history",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A library built on top of fabric.js that allows for easy access to canvas history.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
|
35
|
+
"@vitest/browser-playwright": "^4.0.18",
|
|
35
36
|
"@vitest/coverage-v8": "^4.0.18",
|
|
37
|
+
"@vitest/ui": "^4.0.18",
|
|
36
38
|
"canvas": "^3.2.1",
|
|
37
39
|
"jsdom": "^28.0.0",
|
|
38
40
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
@@ -44,6 +46,8 @@
|
|
|
44
46
|
"dev": "tsx src/index.ts",
|
|
45
47
|
"build": "tsc",
|
|
46
48
|
"test": "vitest",
|
|
49
|
+
"test:it": "vitest --project integration",
|
|
50
|
+
"test:e2e": "vitest --project e2e",
|
|
47
51
|
"coverage": "vitest run --coverage",
|
|
48
52
|
"start": "node dist/index.js",
|
|
49
53
|
"check": "tsc --noEmit",
|
package/src/canvas.ts
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Canvas,
|
|
3
|
+
type CanvasEvents,
|
|
4
|
+
type FabricObject,
|
|
5
|
+
type TEvent,
|
|
6
|
+
} from "fabric";
|
|
7
|
+
|
|
8
|
+
// Defines custom event listeners for history events
|
|
9
|
+
declare module "fabric" {
|
|
10
|
+
interface CanvasEvents {
|
|
11
|
+
"history:append": Partial<TEvent> & {
|
|
12
|
+
/**
|
|
13
|
+
* Serialized canvas state that was saved to history.
|
|
14
|
+
*/
|
|
15
|
+
json: string;
|
|
16
|
+
/**
|
|
17
|
+
* Boolean flag indicating whether or not the appended history action is the initial state of the canvas.
|
|
18
|
+
*/
|
|
19
|
+
initial: boolean;
|
|
20
|
+
};
|
|
21
|
+
"history:undo": Partial<TEvent> & {
|
|
22
|
+
/**
|
|
23
|
+
* Serialized canvas state that was most recently undone.
|
|
24
|
+
*/
|
|
25
|
+
lastUndoAction: string;
|
|
26
|
+
};
|
|
27
|
+
"history:redo": Partial<TEvent> & {
|
|
28
|
+
/**
|
|
29
|
+
* Serialized canvas state that was most recently redone.
|
|
30
|
+
*/
|
|
31
|
+
lastRedoAction: string;
|
|
32
|
+
};
|
|
33
|
+
"history:cleared": Partial<TEvent>;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
2
36
|
|
|
3
37
|
export class CanvasWithHistory extends Canvas {
|
|
4
|
-
// History
|
|
38
|
+
// History stack properties
|
|
5
39
|
private _historyUndo: string[];
|
|
6
40
|
private _historyRedo: string[];
|
|
7
41
|
|
|
8
|
-
//
|
|
9
|
-
private
|
|
42
|
+
// Multi-selection properties
|
|
43
|
+
private _selectedObjects: FabricObject[];
|
|
44
|
+
private _isMultiSelection: boolean;
|
|
45
|
+
|
|
46
|
+
// Boolean properties to determine whether or not we should save to history
|
|
47
|
+
private _historyIsMoving: boolean;
|
|
10
48
|
private _historyProcessing: boolean;
|
|
11
49
|
|
|
12
50
|
private _historyCurrentState: string;
|
|
@@ -16,12 +54,14 @@ export class CanvasWithHistory extends Canvas {
|
|
|
16
54
|
|
|
17
55
|
this._historyUndo = [];
|
|
18
56
|
this._historyRedo = [];
|
|
19
|
-
this.
|
|
57
|
+
this._selectedObjects = [];
|
|
58
|
+
this._isMultiSelection = false;
|
|
59
|
+
this._historyIsMoving = false;
|
|
20
60
|
this._historyProcessing = false;
|
|
21
61
|
this._historyCurrentState = this._historyCurrent();
|
|
22
62
|
|
|
23
63
|
this._bindEventListeners();
|
|
24
|
-
this.
|
|
64
|
+
this._historySaveInitialState();
|
|
25
65
|
}
|
|
26
66
|
|
|
27
67
|
/**
|
|
@@ -32,27 +72,89 @@ export class CanvasWithHistory extends Canvas {
|
|
|
32
72
|
"path:created": this._historySaveAction.bind(this),
|
|
33
73
|
"erasing:end": this._historySaveAction.bind(this),
|
|
34
74
|
"object:added": this._historySaveAction.bind(this),
|
|
35
|
-
"object:removed": this.
|
|
36
|
-
"object:moving": this.
|
|
75
|
+
"object:removed": this._handleObjectRemoved.bind(this),
|
|
76
|
+
"object:moving": this._handleObjectMoving.bind(this),
|
|
37
77
|
"object:modified": this._handleObjectModified.bind(this),
|
|
38
|
-
"
|
|
78
|
+
"selection:created": this._handleSelectionCreated.bind(this),
|
|
79
|
+
"selection:updated": this._handleSelectionUpdated.bind(this),
|
|
80
|
+
"selection:cleared": this._handleSelectionCleared.bind(this),
|
|
39
81
|
});
|
|
40
82
|
}
|
|
41
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Stores the multi-selection state inside `_selectedObjects` and sets the `_isMultiSelection` flag to true.
|
|
86
|
+
*
|
|
87
|
+
* @param options - The options object containing the selected objects.
|
|
88
|
+
*/
|
|
89
|
+
private _handleSelectionCreated(options: { selected: FabricObject[] }) {
|
|
90
|
+
const currentSelectedObjects = options.selected;
|
|
91
|
+
|
|
92
|
+
if (currentSelectedObjects.length > 1) {
|
|
93
|
+
this._selectedObjects = currentSelectedObjects;
|
|
94
|
+
this._isMultiSelection = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Stores the updated multi-selection state inside `_selectedObjects` and sets the `_isMultiSelection` flag to true if there are more than 1 objects selected.
|
|
100
|
+
*
|
|
101
|
+
* @param options - The options object containing the updated selected objects.
|
|
102
|
+
*/
|
|
103
|
+
private _handleSelectionUpdated(options: { selected: FabricObject[] }) {
|
|
104
|
+
const allSelectedObjects = this.getActiveObjects();
|
|
105
|
+
this._selectedObjects = allSelectedObjects;
|
|
106
|
+
this._isMultiSelection = allSelectedObjects.length > 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Clears the multi-selection state and sets the `_isMultiSelection` flag to false.
|
|
111
|
+
*/
|
|
112
|
+
private _handleSelectionCleared() {
|
|
113
|
+
this._selectedObjects = [];
|
|
114
|
+
this._isMultiSelection = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
42
117
|
/**
|
|
43
118
|
* Saves the initial state of the canvas.
|
|
44
119
|
*/
|
|
45
|
-
private
|
|
120
|
+
private _historySaveInitialState() {
|
|
46
121
|
const initialState = this._historyCurrent();
|
|
47
122
|
this._historyUndo = [initialState];
|
|
48
123
|
this._historyCurrentState = initialState;
|
|
49
124
|
}
|
|
50
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Handles object removal events.
|
|
128
|
+
*
|
|
129
|
+
* @param options - The options object containing details about the removed object.
|
|
130
|
+
*/
|
|
131
|
+
private _handleObjectRemoved(options: { target: FabricObject }) {
|
|
132
|
+
/*
|
|
133
|
+
Check !_historyProcessing to prevent recursion: this.remove() fires
|
|
134
|
+
object:removed events, which would re-enter this handler while we're
|
|
135
|
+
still processing the first removal.
|
|
136
|
+
*/
|
|
137
|
+
if (
|
|
138
|
+
!this._historyProcessing &&
|
|
139
|
+
this._isMultiSelection &&
|
|
140
|
+
this._selectedObjects.some((obj) => obj === options.target)
|
|
141
|
+
) {
|
|
142
|
+
this._historyProcessing = true;
|
|
143
|
+
const objectsToRemove = [...this._selectedObjects];
|
|
144
|
+
this._selectedObjects = [];
|
|
145
|
+
this.remove(...objectsToRemove);
|
|
146
|
+
this.discardActiveObject();
|
|
147
|
+
this._historyProcessing = false;
|
|
148
|
+
this._historySaveAction();
|
|
149
|
+
} else {
|
|
150
|
+
this._historySaveAction();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
51
153
|
/**
|
|
52
154
|
* Starts the movement event listener for objects.
|
|
53
155
|
*/
|
|
54
|
-
private
|
|
55
|
-
this.
|
|
156
|
+
private _handleObjectMoving() {
|
|
157
|
+
this._historyIsMoving = true;
|
|
56
158
|
}
|
|
57
159
|
|
|
58
160
|
/**
|
|
@@ -63,7 +165,7 @@ export class CanvasWithHistory extends Canvas {
|
|
|
63
165
|
*/
|
|
64
166
|
private _handleObjectModified() {
|
|
65
167
|
// object:moving -> object:modified - modification is triggered as soon as the movement of an object halts
|
|
66
|
-
this.
|
|
168
|
+
this._historyIsMoving = false;
|
|
67
169
|
this._historySaveAction();
|
|
68
170
|
}
|
|
69
171
|
|
|
@@ -77,16 +179,17 @@ export class CanvasWithHistory extends Canvas {
|
|
|
77
179
|
}
|
|
78
180
|
|
|
79
181
|
/**
|
|
80
|
-
* Records the current canvas
|
|
182
|
+
* Records the current state of the canvas to the history stack if the state has changed since the last recorded state. This method is called after relevant canvas events such as object modifications, additions, and removals.
|
|
81
183
|
*/
|
|
82
184
|
private _historySaveAction() {
|
|
83
|
-
if (this._historyProcessing || this.
|
|
185
|
+
if (this._historyProcessing || this._historyIsMoving) return;
|
|
84
186
|
const latestJSON = this._historyCurrent();
|
|
85
187
|
|
|
86
|
-
if (this._historyCurrentState === latestJSON) return;
|
|
188
|
+
if (this._historyCurrentState === latestJSON) return; // skips duplicates
|
|
87
189
|
this._historyUndo.push(latestJSON);
|
|
88
190
|
this._historyCurrentState = latestJSON;
|
|
89
191
|
this._historyRedo = [];
|
|
192
|
+
this.fire("history:append", { json: latestJSON, initial: false });
|
|
90
193
|
}
|
|
91
194
|
|
|
92
195
|
/**
|
|
@@ -106,6 +209,7 @@ export class CanvasWithHistory extends Canvas {
|
|
|
106
209
|
if (!previousState) return;
|
|
107
210
|
this._historyCurrentState = previousState;
|
|
108
211
|
await this._loadFromHistory(previousState);
|
|
212
|
+
this.fire("history:undo", { lastUndoAction: poppedState });
|
|
109
213
|
}
|
|
110
214
|
|
|
111
215
|
/**
|
|
@@ -130,6 +234,7 @@ export class CanvasWithHistory extends Canvas {
|
|
|
130
234
|
// refresh canvas to load the popped state
|
|
131
235
|
this._historyCurrentState = poppedState;
|
|
132
236
|
await this._loadFromHistory(poppedState);
|
|
237
|
+
this.fire("history:redo", { lastRedoAction: poppedState });
|
|
133
238
|
}
|
|
134
239
|
|
|
135
240
|
/**
|
|
@@ -145,13 +250,11 @@ export class CanvasWithHistory extends Canvas {
|
|
|
145
250
|
* @param historyState - The JSON string representing the canvas history state to load.
|
|
146
251
|
*/
|
|
147
252
|
private async _loadFromHistory(historyState: string) {
|
|
148
|
-
this.clear();
|
|
149
|
-
this.discardActiveObject();
|
|
150
|
-
|
|
151
253
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.
|
|
254
|
+
this.clear();
|
|
255
|
+
this.discardActiveObject();
|
|
256
|
+
await this.loadFromJSON(JSON.parse(historyState));
|
|
257
|
+
this.requestRenderAll();
|
|
155
258
|
} catch (error) {
|
|
156
259
|
console.error("Error loading from history:", error);
|
|
157
260
|
} finally {
|
|
@@ -162,9 +265,37 @@ export class CanvasWithHistory extends Canvas {
|
|
|
162
265
|
/**
|
|
163
266
|
* Clears the history stacks for undo and redo.
|
|
164
267
|
*/
|
|
165
|
-
|
|
166
|
-
this.
|
|
268
|
+
clearHistory() {
|
|
269
|
+
this._historySaveInitialState();
|
|
167
270
|
this._historyRedo = [];
|
|
271
|
+
this.fire("history:cleared");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Debug method to log relevant events to the console. Always remember to remove before pushing once you're done debugging locally!
|
|
276
|
+
*/
|
|
277
|
+
private _historyDebug() {
|
|
278
|
+
const EVENTS = [
|
|
279
|
+
"path:created",
|
|
280
|
+
"erasing:end",
|
|
281
|
+
"object:added",
|
|
282
|
+
"object:removed",
|
|
283
|
+
"object:moving",
|
|
284
|
+
"object:modified",
|
|
285
|
+
"selection:created",
|
|
286
|
+
"selection:cleared",
|
|
287
|
+
"canvas:cleared",
|
|
288
|
+
"history:append",
|
|
289
|
+
"history:undo",
|
|
290
|
+
"history:redo",
|
|
291
|
+
"history:cleared",
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
EVENTS.forEach((e) => {
|
|
295
|
+
this.on(e as keyof CanvasEvents, () =>
|
|
296
|
+
console.log(`📝 Event triggered: ${e}`)
|
|
297
|
+
);
|
|
298
|
+
});
|
|
168
299
|
}
|
|
169
300
|
|
|
170
301
|
/**
|
|
@@ -175,16 +306,35 @@ export class CanvasWithHistory extends Canvas {
|
|
|
175
306
|
"path:created": this._historySaveAction.bind(this),
|
|
176
307
|
"erasing:end": this._historySaveAction.bind(this),
|
|
177
308
|
"object:added": this._historySaveAction.bind(this),
|
|
178
|
-
"object:removed": this.
|
|
179
|
-
"object:moving": this.
|
|
309
|
+
"object:removed": this._handleObjectRemoved.bind(this),
|
|
310
|
+
"object:moving": this._handleObjectMoving.bind(this),
|
|
180
311
|
"object:modified": this._handleObjectModified.bind(this),
|
|
181
|
-
"
|
|
312
|
+
"selection:created": this._handleSelectionCreated.bind(this),
|
|
313
|
+
"selection:updated": this._handleSelectionUpdated.bind(this),
|
|
314
|
+
"selection:cleared": this._handleSelectionCleared.bind(this),
|
|
182
315
|
});
|
|
183
316
|
}
|
|
184
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Cleans up event listeners and history stacks before disposing of the canvas instance.
|
|
320
|
+
*/
|
|
185
321
|
dispose() {
|
|
186
322
|
this._disposeEventListeners();
|
|
187
|
-
this.
|
|
323
|
+
this.clearHistory();
|
|
188
324
|
return super.dispose();
|
|
189
325
|
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Clears the canvas and saves the cleared state to history.
|
|
329
|
+
*
|
|
330
|
+
* @remarks
|
|
331
|
+
* When using `CanvasWithHistory`, use this method instead of `clear()`.
|
|
332
|
+
* The inherited `clear()` method does not record to history.
|
|
333
|
+
*/
|
|
334
|
+
clearCanvas() {
|
|
335
|
+
this._historyProcessing = true;
|
|
336
|
+
this.clear();
|
|
337
|
+
this._historyProcessing = false;
|
|
338
|
+
this._historySaveAction();
|
|
339
|
+
}
|
|
190
340
|
}
|
package/src/canvas.test.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
|
|
3
|
-
import { Circle, Path, Rect } from "fabric";
|
|
4
|
-
import { beforeEach, describe, expect, test } from "vitest";
|
|
5
|
-
import { CanvasWithHistory } from "./canvas";
|
|
6
|
-
|
|
7
|
-
describe("canvas operations with history management", () => {
|
|
8
|
-
let canvas: CanvasWithHistory;
|
|
9
|
-
let circle: Circle;
|
|
10
|
-
let path: Path;
|
|
11
|
-
let rect: Rect;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
const canvasEl = document.createElement("canvas");
|
|
15
|
-
canvas = new CanvasWithHistory(canvasEl);
|
|
16
|
-
|
|
17
|
-
circle = new Circle({
|
|
18
|
-
radius: 20,
|
|
19
|
-
fill: "green",
|
|
20
|
-
left: 100,
|
|
21
|
-
top: 100,
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
path = new Path("M 0 0 L 100 100 L 0 100 z", {
|
|
25
|
-
fill: "",
|
|
26
|
-
stroke: "red",
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
rect = new Rect({
|
|
30
|
-
left: 100,
|
|
31
|
-
top: 100,
|
|
32
|
-
fill: "red",
|
|
33
|
-
width: 50,
|
|
34
|
-
height: 50,
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("canvas only contains rect and circle after undo", async () => {
|
|
39
|
-
canvas.add(rect);
|
|
40
|
-
canvas.add(circle);
|
|
41
|
-
canvas.add(path);
|
|
42
|
-
|
|
43
|
-
await canvas.undo();
|
|
44
|
-
|
|
45
|
-
// After undo: canvas should only contain rect and circle object
|
|
46
|
-
expect(canvas.contains(path)).toBe(false);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("canvas only contains circle after undo", async () => {
|
|
50
|
-
canvas.add(circle);
|
|
51
|
-
canvas.add(rect);
|
|
52
|
-
canvas.add(path);
|
|
53
|
-
|
|
54
|
-
await canvas.undo();
|
|
55
|
-
await canvas.undo();
|
|
56
|
-
|
|
57
|
-
// After undo: canvas should only contain circle object
|
|
58
|
-
expect(canvas.contains(rect) && canvas.contains(path)).toBe(false);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("canvas contains nothing after undo", async () => {
|
|
62
|
-
canvas.add(path);
|
|
63
|
-
|
|
64
|
-
await canvas.undo();
|
|
65
|
-
|
|
66
|
-
// After undo: canvas should have 0 objects
|
|
67
|
-
expect(canvas.contains(path)).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Redo tests
|
|
71
|
-
test("canvas contains path again after undo then redo", async () => {
|
|
72
|
-
canvas.add(rect);
|
|
73
|
-
canvas.add(circle);
|
|
74
|
-
canvas.add(path);
|
|
75
|
-
|
|
76
|
-
await canvas.undo();
|
|
77
|
-
await canvas.redo();
|
|
78
|
-
|
|
79
|
-
// After redo: canvas should contain all 3 objects again
|
|
80
|
-
expect(canvas.getObjects().length).toBe(3);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("canvas contains rect and path after two undos then one redo", async () => {
|
|
84
|
-
canvas.add(rect);
|
|
85
|
-
canvas.add(circle);
|
|
86
|
-
canvas.add(path);
|
|
87
|
-
|
|
88
|
-
await canvas.undo();
|
|
89
|
-
await canvas.undo();
|
|
90
|
-
await canvas.redo();
|
|
91
|
-
|
|
92
|
-
// After 2 undos and 1 redo: canvas should contain rect and circle
|
|
93
|
-
expect(canvas.getObjects().length).toBe(2);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("canvas contains all objects after multiple undos then multiple redos", async () => {
|
|
97
|
-
canvas.add(rect);
|
|
98
|
-
canvas.add(circle);
|
|
99
|
-
canvas.add(path);
|
|
100
|
-
|
|
101
|
-
await canvas.undo();
|
|
102
|
-
await canvas.undo();
|
|
103
|
-
await canvas.undo();
|
|
104
|
-
|
|
105
|
-
// After 3 undos: canvas should be empty
|
|
106
|
-
expect(canvas.getObjects().length).toBe(0);
|
|
107
|
-
|
|
108
|
-
await canvas.redo();
|
|
109
|
-
await canvas.redo();
|
|
110
|
-
await canvas.redo();
|
|
111
|
-
|
|
112
|
-
// After 3 redos: canvas should contain all 3 objects
|
|
113
|
-
expect(canvas.getObjects().length).toBe(3);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("redo does nothing when there is nothing to redo", async () => {
|
|
117
|
-
canvas.add(rect);
|
|
118
|
-
canvas.add(circle);
|
|
119
|
-
|
|
120
|
-
await canvas.redo();
|
|
121
|
-
|
|
122
|
-
// Redo with no prior undo: canvas should still contain 2 objects
|
|
123
|
-
expect(canvas.getObjects().length).toBe(2);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("redo stack is cleared after new action", async () => {
|
|
127
|
-
canvas.add(rect);
|
|
128
|
-
canvas.add(circle);
|
|
129
|
-
canvas.add(path);
|
|
130
|
-
|
|
131
|
-
await canvas.undo();
|
|
132
|
-
|
|
133
|
-
// Add a new object after undo (this should clear redo stack)
|
|
134
|
-
const newRect = new Rect({
|
|
135
|
-
left: 200,
|
|
136
|
-
top: 200,
|
|
137
|
-
fill: "blue",
|
|
138
|
-
width: 30,
|
|
139
|
-
height: 30,
|
|
140
|
-
});
|
|
141
|
-
canvas.add(newRect);
|
|
142
|
-
|
|
143
|
-
// Try to redo - should do nothing since redo stack was cleared
|
|
144
|
-
await canvas.redo();
|
|
145
|
-
|
|
146
|
-
// Canvas should still have 3 objects (rect, circle, newRect) - path should not come back
|
|
147
|
-
expect(canvas.getObjects().length).toBe(3);
|
|
148
|
-
expect(canvas.canRedo()).toBe(false);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("canUndo returns correct value", async () => {
|
|
152
|
-
expect(canvas.canUndo()).toBe(false);
|
|
153
|
-
|
|
154
|
-
canvas.add(rect);
|
|
155
|
-
expect(canvas.canUndo()).toBe(true);
|
|
156
|
-
|
|
157
|
-
await canvas.undo();
|
|
158
|
-
expect(canvas.canUndo()).toBe(false);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("canRedo returns correct value", async () => {
|
|
162
|
-
canvas.add(rect);
|
|
163
|
-
expect(canvas.canRedo()).toBe(false);
|
|
164
|
-
|
|
165
|
-
await canvas.undo();
|
|
166
|
-
expect(canvas.canRedo()).toBe(true);
|
|
167
|
-
|
|
168
|
-
await canvas.redo();
|
|
169
|
-
expect(canvas.canRedo()).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("undo does nothing when there is nothing to undo", async () => {
|
|
173
|
-
await canvas.undo();
|
|
174
|
-
|
|
175
|
-
// Undo on empty canvas: should still be empty with no errors
|
|
176
|
-
expect(canvas.getObjects().length).toBe(0);
|
|
177
|
-
});
|
|
178
|
-
});
|