@anth0nycodes/fabric-history 0.1.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/.claude/settings.local.json +14 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +45 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +36 -0
- package/.github/pull_request_template.md +44 -0
- package/.prettierignore +23 -0
- package/.prettierrc +18 -0
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/canvas.d.ts +70 -0
- package/dist/canvas.d.ts.map +1 -0
- package/dist/canvas.js +171 -0
- package/dist/canvas.js.map +1 -0
- package/dist/canvas.test.d.ts +2 -0
- package/dist/canvas.test.d.ts.map +1 -0
- package/dist/canvas.test.js +137 -0
- package/dist/canvas.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/canvas.test.ts +178 -0
- package/src/canvas.ts +189 -0
- package/src/index.ts +1 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +9 -0
- package/vitest.setup.ts +34 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Bug Report
|
|
3
|
+
about: Report a bug or unexpected behavior
|
|
4
|
+
title: "[BUG] "
|
|
5
|
+
labels: bug
|
|
6
|
+
assignees: ""
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Description
|
|
10
|
+
|
|
11
|
+
<!-- A clear and concise description of the bug -->
|
|
12
|
+
|
|
13
|
+
## Steps to Reproduce
|
|
14
|
+
|
|
15
|
+
1.
|
|
16
|
+
2.
|
|
17
|
+
3.
|
|
18
|
+
4.
|
|
19
|
+
|
|
20
|
+
## Expected Behavior
|
|
21
|
+
|
|
22
|
+
<!-- What you expected to happen -->
|
|
23
|
+
|
|
24
|
+
## Actual Behavior
|
|
25
|
+
|
|
26
|
+
<!-- What actually happened -->
|
|
27
|
+
|
|
28
|
+
## Environment
|
|
29
|
+
|
|
30
|
+
- **OS:** <!-- e.g., macOS 14.0, Windows 11, Ubuntu 22.04 -->
|
|
31
|
+
- **Node.js version:** <!-- run `node --version` -->
|
|
32
|
+
- **Package version:** <!-- run `npm list -g @anth0nycodes/fabric-history` -->
|
|
33
|
+
- **Installation method:** <!-- npm or pnpm -->
|
|
34
|
+
|
|
35
|
+
## Error Output
|
|
36
|
+
|
|
37
|
+
<!-- If applicable, paste any error messages or screenshots -->
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Paste error output here
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Additional Context
|
|
44
|
+
|
|
45
|
+
<!-- Add any other context about the problem here -->
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Feature Request
|
|
3
|
+
about: Suggest a new feature or enhancement
|
|
4
|
+
title: "[FEATURE] "
|
|
5
|
+
labels: enhancement
|
|
6
|
+
assignees: ""
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Feature Description
|
|
10
|
+
|
|
11
|
+
<!-- A clear and concise description of the feature you'd like to see -->
|
|
12
|
+
|
|
13
|
+
## Problem/Use Case
|
|
14
|
+
|
|
15
|
+
<!-- What problem does this solve? Why would this feature be useful? -->
|
|
16
|
+
|
|
17
|
+
## Proposed Solution
|
|
18
|
+
|
|
19
|
+
<!-- How do you envision this feature working? -->
|
|
20
|
+
|
|
21
|
+
## Alternative Solutions
|
|
22
|
+
|
|
23
|
+
<!-- Have you considered any alternative solutions or features? -->
|
|
24
|
+
|
|
25
|
+
## Examples
|
|
26
|
+
|
|
27
|
+
<!-- If applicable, provide examples of how this feature would be used -->
|
|
28
|
+
|
|
29
|
+
## Additional Context
|
|
30
|
+
|
|
31
|
+
<!-- Add any other context, screenshots, or references about the feature request -->
|
|
32
|
+
|
|
33
|
+
## Would you like to contribute this feature?
|
|
34
|
+
|
|
35
|
+
- [ ] Yes, I'd like to work on this
|
|
36
|
+
- [ ] No, just suggesting
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
## Description
|
|
2
|
+
|
|
3
|
+
<!-- Provide a brief description of the changes in this PR -->
|
|
4
|
+
|
|
5
|
+
## Type of Change
|
|
6
|
+
|
|
7
|
+
<!-- Mark the relevant option with an "x" -->
|
|
8
|
+
|
|
9
|
+
- [ ] Bug fix (non-breaking change which fixes an issue)
|
|
10
|
+
- [ ] New feature (non-breaking change which adds functionality)
|
|
11
|
+
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
|
12
|
+
- [ ] Documentation update
|
|
13
|
+
- [ ] Refactoring (no functional changes)
|
|
14
|
+
- [ ] Other (please describe):
|
|
15
|
+
|
|
16
|
+
## Related Issues
|
|
17
|
+
|
|
18
|
+
<!-- Link to related issues using #issue_number -->
|
|
19
|
+
|
|
20
|
+
Fixes #
|
|
21
|
+
|
|
22
|
+
## Changes Made
|
|
23
|
+
|
|
24
|
+
<!-- List the specific changes made in this PR -->
|
|
25
|
+
|
|
26
|
+
-
|
|
27
|
+
-
|
|
28
|
+
-
|
|
29
|
+
|
|
30
|
+
## Testing
|
|
31
|
+
|
|
32
|
+
<!-- Describe the testing you've done -->
|
|
33
|
+
|
|
34
|
+
- [ ] Tests pass locally with `pnpm test`
|
|
35
|
+
- [ ] Type checking passes with `pnpm check`
|
|
36
|
+
- [ ] Build succeeds with `pnpm build`
|
|
37
|
+
|
|
38
|
+
## Checklist
|
|
39
|
+
|
|
40
|
+
- [ ] My code follows the project's coding style
|
|
41
|
+
- [ ] I have performed a self-review of my own code
|
|
42
|
+
- [ ] I have commented my code where necessary
|
|
43
|
+
- [ ] My changes generate no new warnings or errors
|
|
44
|
+
- [ ] I have updated the documentation (if applicable)
|
package/.prettierignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules
|
|
3
|
+
|
|
4
|
+
# Build outputs
|
|
5
|
+
dist
|
|
6
|
+
build
|
|
7
|
+
.next
|
|
8
|
+
out
|
|
9
|
+
|
|
10
|
+
# Coverage
|
|
11
|
+
coverage
|
|
12
|
+
|
|
13
|
+
# Lock files
|
|
14
|
+
pnpm-lock.yaml
|
|
15
|
+
package-lock.json
|
|
16
|
+
yarn.lock
|
|
17
|
+
|
|
18
|
+
# Environment files
|
|
19
|
+
.env
|
|
20
|
+
.env.*
|
|
21
|
+
|
|
22
|
+
# Generated files
|
|
23
|
+
*.log
|
package/.prettierrc
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"semi": true,
|
|
3
|
+
"singleQuote": false,
|
|
4
|
+
"tabWidth": 2,
|
|
5
|
+
"trailingComma": "es5",
|
|
6
|
+
"printWidth": 80,
|
|
7
|
+
"plugins": [
|
|
8
|
+
"@ianvs/prettier-plugin-sort-imports",
|
|
9
|
+
"prettier-plugin-tailwindcss"
|
|
10
|
+
],
|
|
11
|
+
"importOrder": [
|
|
12
|
+
"^react",
|
|
13
|
+
"^next",
|
|
14
|
+
"<THIRD_PARTY_MODULES>",
|
|
15
|
+
"^@/(.*)$",
|
|
16
|
+
"^[./]"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Contributing to fabric-history
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing to fabric-history! We welcome contributions from the community.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
1. Fork the repository
|
|
8
|
+
2. Clone your fork:
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/YOUR_USERNAME/fabric-history.git
|
|
11
|
+
cd fabric-history
|
|
12
|
+
```
|
|
13
|
+
3. Install dependencies:
|
|
14
|
+
```bash
|
|
15
|
+
pnpm install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Development Workflow
|
|
19
|
+
|
|
20
|
+
1. Create a new branch for your feature or bug fix:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git checkout -b feature/your-feature-name
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Make your changes and ensure they follow the project's coding standards
|
|
27
|
+
|
|
28
|
+
3. Run the test suite to ensure all tests pass:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm test
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For test coverage:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm coverage
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. Run type checking to ensure there are no TypeScript errors:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pnpm check
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
5. Build the project to verify everything compiles:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm build
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
7. Commit your changes with a clear and descriptive commit message:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git commit -m "Add: description of your changes"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
8. Push to your fork:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git push origin feature/your-feature-name
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
9. Open a pull request against the main repository
|
|
65
|
+
|
|
66
|
+
## Reporting Issues
|
|
67
|
+
|
|
68
|
+
If you find a bug or have a feature request, please create an issue on GitHub with:
|
|
69
|
+
|
|
70
|
+
- A clear title and description
|
|
71
|
+
- Steps to reproduce (for bugs)
|
|
72
|
+
- Expected vs actual behavior
|
|
73
|
+
- Your environment details (fabric.js version, browser, etc.)
|
|
74
|
+
|
|
75
|
+
## Questions?
|
|
76
|
+
|
|
77
|
+
Feel free to open an issue for any questions or concerns.
|
|
78
|
+
|
|
79
|
+
Thank you for contributing!
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anth0nycodes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# fabric-history
|
|
2
|
+
|
|
3
|
+
A library built on top of fabric.js that provides undo/redo history management for canvas operations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Undo/Redo functionality for fabric.js canvases
|
|
8
|
+
- Automatic history tracking for object additions, removals, and modifications
|
|
9
|
+
- Support for path creation and erasing events
|
|
10
|
+
- Easy integration with existing fabric.js projects
|
|
11
|
+
- Support for fabric.js v6 and v7
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @anth0nycodes/fabric-history
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or with pnpm:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @anth0nycodes/fabric-history
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
or with yarn:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
yarn add @anth0nycodes/fabric-history
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { CanvasWithHistory } from "@anth0nycodes/fabric-history";
|
|
35
|
+
import { Rect } from "fabric";
|
|
36
|
+
|
|
37
|
+
// Create a canvas with history support (same constructor as fabric.Canvas)
|
|
38
|
+
const canvas = new CanvasWithHistory("my-canvas", {
|
|
39
|
+
width: 800,
|
|
40
|
+
height: 600,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Add objects - history is tracked automatically
|
|
44
|
+
const rect = new Rect({
|
|
45
|
+
left: 100,
|
|
46
|
+
top: 100,
|
|
47
|
+
width: 50,
|
|
48
|
+
height: 50,
|
|
49
|
+
fill: "red",
|
|
50
|
+
});
|
|
51
|
+
canvas.add(rect);
|
|
52
|
+
|
|
53
|
+
// Undo the last action
|
|
54
|
+
await canvas.undo();
|
|
55
|
+
|
|
56
|
+
// Redo the undone action
|
|
57
|
+
await canvas.redo();
|
|
58
|
+
|
|
59
|
+
// Clear history if needed
|
|
60
|
+
canvas.clearHistory();
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
### `CanvasWithHistory`
|
|
66
|
+
|
|
67
|
+
Extends fabric.js `Canvas` class with history management capabilities.
|
|
68
|
+
|
|
69
|
+
#### Methods
|
|
70
|
+
|
|
71
|
+
| Method | Returns | Description |
|
|
72
|
+
| ---------------- | --------------- | ----------------------------------------------- |
|
|
73
|
+
| `undo()` | `Promise<void>` | Undo the most recent action |
|
|
74
|
+
| `redo()` | `Promise<void>` | Redo the most recently undone action |
|
|
75
|
+
| `canUndo()` | `boolean` | Check if an undo action is available |
|
|
76
|
+
| `canRedo()` | `boolean` | Check if a redo action is available |
|
|
77
|
+
| `clearHistory()` | `void` | Clear all undo and redo history |
|
|
78
|
+
| `dispose()` | `void` | Clean up event listeners and dispose the canvas |
|
|
79
|
+
|
|
80
|
+
#### Tracked Events
|
|
81
|
+
|
|
82
|
+
History is automatically saved when these fabric.js events occur:
|
|
83
|
+
|
|
84
|
+
- `object:added` - When an object is added to the canvas
|
|
85
|
+
- `object:removed` - When an object is removed from the canvas
|
|
86
|
+
- `object:modified` - When an object is modified (moved, scaled, rotated, etc.)
|
|
87
|
+
- `path:created` - When a path is created (e.g., freehand drawing)
|
|
88
|
+
- `erasing:end` - When an erasing operation completes
|
|
89
|
+
- `canvas:cleared` - When the canvas is cleared
|
|
90
|
+
|
|
91
|
+
## Requirements
|
|
92
|
+
|
|
93
|
+
- fabric.js 6.x or 7.x
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Install dependencies
|
|
99
|
+
pnpm install
|
|
100
|
+
|
|
101
|
+
# Run development server
|
|
102
|
+
pnpm dev
|
|
103
|
+
|
|
104
|
+
# Build the project
|
|
105
|
+
pnpm build
|
|
106
|
+
|
|
107
|
+
# Type check
|
|
108
|
+
pnpm check
|
|
109
|
+
|
|
110
|
+
# Run tests
|
|
111
|
+
pnpm test
|
|
112
|
+
|
|
113
|
+
# Run tests with coverage
|
|
114
|
+
pnpm coverage
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Contributing
|
|
118
|
+
|
|
119
|
+
Contributions are welcome! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to submit pull requests.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
124
|
+
|
|
125
|
+
## Author
|
|
126
|
+
|
|
127
|
+
Anthony Hoang
|
package/dist/canvas.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Canvas } from "fabric";
|
|
2
|
+
export declare class CanvasWithHistory extends Canvas {
|
|
3
|
+
private _historyUndo;
|
|
4
|
+
private _historyRedo;
|
|
5
|
+
private _isMoving;
|
|
6
|
+
private _historyProcessing;
|
|
7
|
+
private _historyCurrentState;
|
|
8
|
+
constructor(...args: ConstructorParameters<typeof Canvas>);
|
|
9
|
+
/**
|
|
10
|
+
* Binds all relevant fabric event listeners.
|
|
11
|
+
*/
|
|
12
|
+
private _bindEventListeners;
|
|
13
|
+
/**
|
|
14
|
+
* Saves the initial state of the canvas.
|
|
15
|
+
*/
|
|
16
|
+
private _saveInitialState;
|
|
17
|
+
/**
|
|
18
|
+
* Starts the movement event listener for objects.
|
|
19
|
+
*/
|
|
20
|
+
private _objectMoving;
|
|
21
|
+
/**
|
|
22
|
+
* Handles object modification events, including moving, resizing, rotating,
|
|
23
|
+
* scaling, and skewing.
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://fabricjs.com/api/type-aliases/ObjectModificationEvents/ | Fabric.js ObjectModificationEvents}
|
|
26
|
+
*/
|
|
27
|
+
private _handleObjectModified;
|
|
28
|
+
/**
|
|
29
|
+
* Returns the current state of the canvas as a string.
|
|
30
|
+
*
|
|
31
|
+
* @see {@link https://fabricjs.com/docs/old-docs/fabric-intro-part-3/#serialization | Fabric.js Serialization} for why we use toDatalessJSON() instead of toJSON().
|
|
32
|
+
*/
|
|
33
|
+
private _historyCurrent;
|
|
34
|
+
/**
|
|
35
|
+
* Records the current canvas, object, or path state into the history stack for undo/redo.
|
|
36
|
+
*/
|
|
37
|
+
private _historySaveAction;
|
|
38
|
+
/**
|
|
39
|
+
* Undo the most recent action.
|
|
40
|
+
*/
|
|
41
|
+
undo(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Checks for whether or not an action can be undone.
|
|
44
|
+
*/
|
|
45
|
+
canUndo(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Redo the most recently undone action.
|
|
48
|
+
*/
|
|
49
|
+
redo(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Checks for whether or not an action can be redone.
|
|
52
|
+
*/
|
|
53
|
+
canRedo(): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Loads a canvas history state from the history stack and renders it on the canvas.
|
|
56
|
+
*
|
|
57
|
+
* @param historyState - The JSON string representing the canvas history state to load.
|
|
58
|
+
*/
|
|
59
|
+
private _loadFromHistory;
|
|
60
|
+
/**
|
|
61
|
+
* Clears the history stacks for undo and redo.
|
|
62
|
+
*/
|
|
63
|
+
clearHistory(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Unsubscribes all relevant fabric event listeners.
|
|
66
|
+
*/
|
|
67
|
+
private _disposeEventListeners;
|
|
68
|
+
dispose(): Promise<boolean>;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=canvas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,qBAAa,iBAAkB,SAAQ,MAAM;IAE3C,OAAO,CAAC,YAAY,CAAW;IAC/B,OAAO,CAAC,YAAY,CAAW;IAG/B,OAAO,CAAC,SAAS,CAAU;IAC3B,OAAO,CAAC,kBAAkB,CAAU;IAEpC,OAAO,CAAC,oBAAoB,CAAS;gBAEzB,GAAG,IAAI,EAAE,qBAAqB,CAAC,OAAO,MAAM,CAAC;IAazD;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAY3B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAMzB;;OAEG;IACH,OAAO,CAAC,aAAa;IAIrB;;;;;OAKG;IACH,OAAO,CAAC,qBAAqB;IAM7B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAIvB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAU1B;;OAEG;IACG,IAAI;IAgBV;;OAEG;IACH,OAAO;IAIP;;OAEG;IACG,IAAI;IAcV;;OAEG;IACH,OAAO;IAIP;;;;OAIG;YACW,gBAAgB;IAe9B;;OAEG;IACH,YAAY;IAKZ;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAY9B,OAAO;CAIR"}
|
package/dist/canvas.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Canvas } from "fabric";
|
|
2
|
+
export class CanvasWithHistory extends Canvas {
|
|
3
|
+
// History stacks
|
|
4
|
+
_historyUndo;
|
|
5
|
+
_historyRedo;
|
|
6
|
+
// Boolean values to determine whether or not we should save to history
|
|
7
|
+
_isMoving;
|
|
8
|
+
_historyProcessing;
|
|
9
|
+
_historyCurrentState;
|
|
10
|
+
constructor(...args) {
|
|
11
|
+
super(...args);
|
|
12
|
+
this._historyUndo = [];
|
|
13
|
+
this._historyRedo = [];
|
|
14
|
+
this._isMoving = false;
|
|
15
|
+
this._historyProcessing = false;
|
|
16
|
+
this._historyCurrentState = this._historyCurrent();
|
|
17
|
+
this._bindEventListeners();
|
|
18
|
+
this._saveInitialState();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Binds all relevant fabric event listeners.
|
|
22
|
+
*/
|
|
23
|
+
_bindEventListeners() {
|
|
24
|
+
this.on({
|
|
25
|
+
"path:created": this._historySaveAction.bind(this),
|
|
26
|
+
"erasing:end": this._historySaveAction.bind(this),
|
|
27
|
+
"object:added": this._historySaveAction.bind(this),
|
|
28
|
+
"object:removed": this._historySaveAction.bind(this),
|
|
29
|
+
"object:moving": this._objectMoving.bind(this),
|
|
30
|
+
"object:modified": this._handleObjectModified.bind(this),
|
|
31
|
+
"canvas:cleared": this._historySaveAction.bind(this),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Saves the initial state of the canvas.
|
|
36
|
+
*/
|
|
37
|
+
_saveInitialState() {
|
|
38
|
+
const initialState = this._historyCurrent();
|
|
39
|
+
this._historyUndo = [initialState];
|
|
40
|
+
this._historyCurrentState = initialState;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Starts the movement event listener for objects.
|
|
44
|
+
*/
|
|
45
|
+
_objectMoving() {
|
|
46
|
+
this._isMoving = true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Handles object modification events, including moving, resizing, rotating,
|
|
50
|
+
* scaling, and skewing.
|
|
51
|
+
*
|
|
52
|
+
* @see {@link https://fabricjs.com/api/type-aliases/ObjectModificationEvents/ | Fabric.js ObjectModificationEvents}
|
|
53
|
+
*/
|
|
54
|
+
_handleObjectModified() {
|
|
55
|
+
// object:moving -> object:modified - modification is triggered as soon as the movement of an object halts
|
|
56
|
+
this._isMoving = false;
|
|
57
|
+
this._historySaveAction();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Returns the current state of the canvas as a string.
|
|
61
|
+
*
|
|
62
|
+
* @see {@link https://fabricjs.com/docs/old-docs/fabric-intro-part-3/#serialization | Fabric.js Serialization} for why we use toDatalessJSON() instead of toJSON().
|
|
63
|
+
*/
|
|
64
|
+
_historyCurrent() {
|
|
65
|
+
return JSON.stringify(this.toDatalessJSON());
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Records the current canvas, object, or path state into the history stack for undo/redo.
|
|
69
|
+
*/
|
|
70
|
+
_historySaveAction() {
|
|
71
|
+
if (this._historyProcessing || this._isMoving)
|
|
72
|
+
return;
|
|
73
|
+
const latestJSON = this._historyCurrent();
|
|
74
|
+
if (this._historyCurrentState === latestJSON)
|
|
75
|
+
return;
|
|
76
|
+
this._historyUndo.push(latestJSON);
|
|
77
|
+
this._historyCurrentState = latestJSON;
|
|
78
|
+
this._historyRedo = [];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Undo the most recent action.
|
|
82
|
+
*/
|
|
83
|
+
async undo() {
|
|
84
|
+
if (this._historyUndo.length <= 1)
|
|
85
|
+
return;
|
|
86
|
+
this._historyProcessing = true;
|
|
87
|
+
const poppedState = this._historyUndo.pop();
|
|
88
|
+
if (!poppedState)
|
|
89
|
+
return;
|
|
90
|
+
this._historyRedo.push(poppedState);
|
|
91
|
+
// refresh canvas to load what remains on the undo stack after popping
|
|
92
|
+
const previousState = this._historyUndo[this._historyUndo.length - 1];
|
|
93
|
+
if (!previousState)
|
|
94
|
+
return;
|
|
95
|
+
this._historyCurrentState = previousState;
|
|
96
|
+
await this._loadFromHistory(previousState);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Checks for whether or not an action can be undone.
|
|
100
|
+
*/
|
|
101
|
+
canUndo() {
|
|
102
|
+
return this._historyUndo.length > 1;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Redo the most recently undone action.
|
|
106
|
+
*/
|
|
107
|
+
async redo() {
|
|
108
|
+
if (this._historyRedo.length === 0)
|
|
109
|
+
return;
|
|
110
|
+
this._historyProcessing = true;
|
|
111
|
+
const poppedState = this._historyRedo.pop();
|
|
112
|
+
if (!poppedState)
|
|
113
|
+
return;
|
|
114
|
+
this._historyUndo.push(poppedState);
|
|
115
|
+
// refresh canvas to load the popped state
|
|
116
|
+
this._historyCurrentState = poppedState;
|
|
117
|
+
await this._loadFromHistory(poppedState);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Checks for whether or not an action can be redone.
|
|
121
|
+
*/
|
|
122
|
+
canRedo() {
|
|
123
|
+
return this._historyRedo.length > 0;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Loads a canvas history state from the history stack and renders it on the canvas.
|
|
127
|
+
*
|
|
128
|
+
* @param historyState - The JSON string representing the canvas history state to load.
|
|
129
|
+
*/
|
|
130
|
+
async _loadFromHistory(historyState) {
|
|
131
|
+
this.clear();
|
|
132
|
+
this.discardActiveObject();
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(historyState);
|
|
135
|
+
await this.loadFromJSON(parsed);
|
|
136
|
+
this.renderAll();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
console.error("Error loading from history:", error);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
this._historyProcessing = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Clears the history stacks for undo and redo.
|
|
147
|
+
*/
|
|
148
|
+
clearHistory() {
|
|
149
|
+
this._historyUndo = [];
|
|
150
|
+
this._historyRedo = [];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Unsubscribes all relevant fabric event listeners.
|
|
154
|
+
*/
|
|
155
|
+
_disposeEventListeners() {
|
|
156
|
+
this.off({
|
|
157
|
+
"path:created": this._historySaveAction.bind(this),
|
|
158
|
+
"erasing:end": this._historySaveAction.bind(this),
|
|
159
|
+
"object:added": this._historySaveAction.bind(this),
|
|
160
|
+
"object:removed": this._historySaveAction.bind(this),
|
|
161
|
+
"object:moving": this._objectMoving.bind(this),
|
|
162
|
+
"object:modified": this._handleObjectModified.bind(this),
|
|
163
|
+
"canvas:cleared": this._historySaveAction.bind(this),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
dispose() {
|
|
167
|
+
this._disposeEventListeners();
|
|
168
|
+
return super.dispose();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=canvas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.js","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.test.d.ts","sourceRoot":"","sources":["../src/canvas.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { Circle, Path, Rect } from "fabric";
|
|
3
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
4
|
+
import { CanvasWithHistory } from "./canvas";
|
|
5
|
+
describe("canvas operations with history management", () => {
|
|
6
|
+
let canvas;
|
|
7
|
+
let circle;
|
|
8
|
+
let path;
|
|
9
|
+
let rect;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
const canvasEl = document.createElement("canvas");
|
|
12
|
+
canvas = new CanvasWithHistory(canvasEl);
|
|
13
|
+
circle = new Circle({
|
|
14
|
+
radius: 20,
|
|
15
|
+
fill: "green",
|
|
16
|
+
left: 100,
|
|
17
|
+
top: 100,
|
|
18
|
+
});
|
|
19
|
+
path = new Path("M 0 0 L 100 100 L 0 100 z", {
|
|
20
|
+
fill: "",
|
|
21
|
+
stroke: "red",
|
|
22
|
+
});
|
|
23
|
+
rect = new Rect({
|
|
24
|
+
left: 100,
|
|
25
|
+
top: 100,
|
|
26
|
+
fill: "red",
|
|
27
|
+
width: 50,
|
|
28
|
+
height: 50,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
test("canvas only contains rect and circle after undo", async () => {
|
|
32
|
+
canvas.add(rect);
|
|
33
|
+
canvas.add(circle);
|
|
34
|
+
canvas.add(path);
|
|
35
|
+
await canvas.undo();
|
|
36
|
+
// After undo: canvas should only contain rect and circle object
|
|
37
|
+
expect(canvas.contains(path)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
test("canvas only contains circle after undo", async () => {
|
|
40
|
+
canvas.add(circle);
|
|
41
|
+
canvas.add(rect);
|
|
42
|
+
canvas.add(path);
|
|
43
|
+
await canvas.undo();
|
|
44
|
+
await canvas.undo();
|
|
45
|
+
// After undo: canvas should only contain circle object
|
|
46
|
+
expect(canvas.contains(rect) && canvas.contains(path)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
test("canvas contains nothing after undo", async () => {
|
|
49
|
+
canvas.add(path);
|
|
50
|
+
await canvas.undo();
|
|
51
|
+
// After undo: canvas should have 0 objects
|
|
52
|
+
expect(canvas.contains(path)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
// Redo tests
|
|
55
|
+
test("canvas contains path again after undo then redo", async () => {
|
|
56
|
+
canvas.add(rect);
|
|
57
|
+
canvas.add(circle);
|
|
58
|
+
canvas.add(path);
|
|
59
|
+
await canvas.undo();
|
|
60
|
+
await canvas.redo();
|
|
61
|
+
// After redo: canvas should contain all 3 objects again
|
|
62
|
+
expect(canvas.getObjects().length).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
test("canvas contains rect and path after two undos then one redo", async () => {
|
|
65
|
+
canvas.add(rect);
|
|
66
|
+
canvas.add(circle);
|
|
67
|
+
canvas.add(path);
|
|
68
|
+
await canvas.undo();
|
|
69
|
+
await canvas.undo();
|
|
70
|
+
await canvas.redo();
|
|
71
|
+
// After 2 undos and 1 redo: canvas should contain rect and circle
|
|
72
|
+
expect(canvas.getObjects().length).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
test("canvas contains all objects after multiple undos then multiple redos", async () => {
|
|
75
|
+
canvas.add(rect);
|
|
76
|
+
canvas.add(circle);
|
|
77
|
+
canvas.add(path);
|
|
78
|
+
await canvas.undo();
|
|
79
|
+
await canvas.undo();
|
|
80
|
+
await canvas.undo();
|
|
81
|
+
// After 3 undos: canvas should be empty
|
|
82
|
+
expect(canvas.getObjects().length).toBe(0);
|
|
83
|
+
await canvas.redo();
|
|
84
|
+
await canvas.redo();
|
|
85
|
+
await canvas.redo();
|
|
86
|
+
// After 3 redos: canvas should contain all 3 objects
|
|
87
|
+
expect(canvas.getObjects().length).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
test("redo does nothing when there is nothing to redo", async () => {
|
|
90
|
+
canvas.add(rect);
|
|
91
|
+
canvas.add(circle);
|
|
92
|
+
await canvas.redo();
|
|
93
|
+
// Redo with no prior undo: canvas should still contain 2 objects
|
|
94
|
+
expect(canvas.getObjects().length).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
test("redo stack is cleared after new action", async () => {
|
|
97
|
+
canvas.add(rect);
|
|
98
|
+
canvas.add(circle);
|
|
99
|
+
canvas.add(path);
|
|
100
|
+
await canvas.undo();
|
|
101
|
+
// Add a new object after undo (this should clear redo stack)
|
|
102
|
+
const newRect = new Rect({
|
|
103
|
+
left: 200,
|
|
104
|
+
top: 200,
|
|
105
|
+
fill: "blue",
|
|
106
|
+
width: 30,
|
|
107
|
+
height: 30,
|
|
108
|
+
});
|
|
109
|
+
canvas.add(newRect);
|
|
110
|
+
// Try to redo - should do nothing since redo stack was cleared
|
|
111
|
+
await canvas.redo();
|
|
112
|
+
// Canvas should still have 3 objects (rect, circle, newRect) - path should not come back
|
|
113
|
+
expect(canvas.getObjects().length).toBe(3);
|
|
114
|
+
expect(canvas.canRedo()).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
test("canUndo returns correct value", async () => {
|
|
117
|
+
expect(canvas.canUndo()).toBe(false);
|
|
118
|
+
canvas.add(rect);
|
|
119
|
+
expect(canvas.canUndo()).toBe(true);
|
|
120
|
+
await canvas.undo();
|
|
121
|
+
expect(canvas.canUndo()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
test("canRedo returns correct value", async () => {
|
|
124
|
+
canvas.add(rect);
|
|
125
|
+
expect(canvas.canRedo()).toBe(false);
|
|
126
|
+
await canvas.undo();
|
|
127
|
+
expect(canvas.canRedo()).toBe(true);
|
|
128
|
+
await canvas.redo();
|
|
129
|
+
expect(canvas.canRedo()).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
test("undo does nothing when there is nothing to undo", async () => {
|
|
132
|
+
await canvas.undo();
|
|
133
|
+
// Undo on empty canvas: should still be empty with no errors
|
|
134
|
+
expect(canvas.getObjects().length).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
//# sourceMappingURL=canvas.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.test.js","sourceRoot":"","sources":["../src/canvas.test.ts"],"names":[],"mappings":"AAAA,4BAA4B;AAE5B,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,QAAQ,CAAC,2CAA2C,EAAE,GAAG,EAAE;IACzD,IAAI,MAAyB,CAAC;IAC9B,IAAI,MAAc,CAAC;IACnB,IAAI,IAAU,CAAC;IACf,IAAI,IAAU,CAAC;IAEf,UAAU,CAAC,GAAG,EAAE;QACd,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAClD,MAAM,GAAG,IAAI,iBAAiB,CAAC,QAAQ,CAAC,CAAC;QAEzC,MAAM,GAAG,IAAI,MAAM,CAAC;YAClB,MAAM,EAAE,EAAE;YACV,IAAI,EAAE,OAAO;YACb,IAAI,EAAE,GAAG;YACT,GAAG,EAAE,GAAG;SACT,CAAC,CAAC;QAEH,IAAI,GAAG,IAAI,IAAI,CAAC,2BAA2B,EAAE;YAC3C,IAAI,EAAE,EAAE;YACR,MAAM,EAAE,KAAK;SACd,CAAC,CAAC;QAEH,IAAI,GAAG,IAAI,IAAI,CAAC;YACd,IAAI,EAAE,GAAG;YACT,GAAG,EAAE,GAAG;YACR,IAAI,EAAE,KAAK;YACX,KAAK,EAAE,EAAE;YACT,MAAM,EAAE,EAAE;SACX,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QACjE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,gEAAgE;QAChE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;QACxD,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,uDAAuD;QACvD,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACrE,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oCAAoC,EAAE,KAAK,IAAI,EAAE;QACpD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,2CAA2C;QAC3C,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,aAAa;IACb,IAAI,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QACjE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,wDAAwD;QACxD,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;QAC7E,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,kEAAkE;QAClE,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACtF,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,wCAAwC;QACxC,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAE3C,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,qDAAqD;QACrD,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QACjE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAEnB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,iEAAiE;QACjE,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;QACxD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,6DAA6D;QAC7D,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC;YACvB,IAAI,EAAE,GAAG;YACT,GAAG,EAAE,GAAG;YACR,IAAI,EAAE,MAAM;YACZ,KAAK,EAAE,EAAE;YACT,MAAM,EAAE,EAAE;SACX,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAEpB,+DAA+D;QAC/D,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,yFAAyF;QACzF,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;QAC/C,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAErC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAEpC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;QAC/C,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAErC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAEpC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QACjE,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpB,6DAA6D;QAC7D,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anth0nycodes/fabric-history",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A library built on top of fabric.js that allows for easy access to canvas history.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"fabric",
|
|
9
|
+
"fabric.js",
|
|
10
|
+
"canvas history",
|
|
11
|
+
"canvas undo",
|
|
12
|
+
"canvas redo",
|
|
13
|
+
"canvas"
|
|
14
|
+
],
|
|
15
|
+
"author": "Anthony Hoang",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/anth0nycodes/fabric-history.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/anth0nycodes/fabric-history/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/anth0nycodes/fabric-history#readme",
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"fabric": ">=6.0.0 <8.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
|
30
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
31
|
+
"canvas": "^3.2.1",
|
|
32
|
+
"jsdom": "^28.0.0",
|
|
33
|
+
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
34
|
+
"tsx": "^4.21.0",
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^4.0.18"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx src/index.ts",
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"test": "vitest",
|
|
42
|
+
"coverage": "vitest run --coverage",
|
|
43
|
+
"start": "node dist/index.js",
|
|
44
|
+
"check": "tsc --noEmit",
|
|
45
|
+
"publish:npm": "pnpm publish --access public"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
});
|
package/src/canvas.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Canvas } from "fabric";
|
|
2
|
+
|
|
3
|
+
export class CanvasWithHistory extends Canvas {
|
|
4
|
+
// History stacks
|
|
5
|
+
private _historyUndo: string[];
|
|
6
|
+
private _historyRedo: string[];
|
|
7
|
+
|
|
8
|
+
// Boolean values to determine whether or not we should save to history
|
|
9
|
+
private _isMoving: boolean;
|
|
10
|
+
private _historyProcessing: boolean;
|
|
11
|
+
|
|
12
|
+
private _historyCurrentState: string;
|
|
13
|
+
|
|
14
|
+
constructor(...args: ConstructorParameters<typeof Canvas>) {
|
|
15
|
+
super(...args);
|
|
16
|
+
|
|
17
|
+
this._historyUndo = [];
|
|
18
|
+
this._historyRedo = [];
|
|
19
|
+
this._isMoving = false;
|
|
20
|
+
this._historyProcessing = false;
|
|
21
|
+
this._historyCurrentState = this._historyCurrent();
|
|
22
|
+
|
|
23
|
+
this._bindEventListeners();
|
|
24
|
+
this._saveInitialState();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Binds all relevant fabric event listeners.
|
|
29
|
+
*/
|
|
30
|
+
private _bindEventListeners() {
|
|
31
|
+
this.on({
|
|
32
|
+
"path:created": this._historySaveAction.bind(this),
|
|
33
|
+
"erasing:end": this._historySaveAction.bind(this),
|
|
34
|
+
"object:added": this._historySaveAction.bind(this),
|
|
35
|
+
"object:removed": this._historySaveAction.bind(this),
|
|
36
|
+
"object:moving": this._objectMoving.bind(this),
|
|
37
|
+
"object:modified": this._handleObjectModified.bind(this),
|
|
38
|
+
"canvas:cleared": this._historySaveAction.bind(this),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Saves the initial state of the canvas.
|
|
44
|
+
*/
|
|
45
|
+
private _saveInitialState() {
|
|
46
|
+
const initialState = this._historyCurrent();
|
|
47
|
+
this._historyUndo = [initialState];
|
|
48
|
+
this._historyCurrentState = initialState;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts the movement event listener for objects.
|
|
53
|
+
*/
|
|
54
|
+
private _objectMoving() {
|
|
55
|
+
this._isMoving = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Handles object modification events, including moving, resizing, rotating,
|
|
60
|
+
* scaling, and skewing.
|
|
61
|
+
*
|
|
62
|
+
* @see {@link https://fabricjs.com/api/type-aliases/ObjectModificationEvents/ | Fabric.js ObjectModificationEvents}
|
|
63
|
+
*/
|
|
64
|
+
private _handleObjectModified() {
|
|
65
|
+
// object:moving -> object:modified - modification is triggered as soon as the movement of an object halts
|
|
66
|
+
this._isMoving = false;
|
|
67
|
+
this._historySaveAction();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns the current state of the canvas as a string.
|
|
72
|
+
*
|
|
73
|
+
* @see {@link https://fabricjs.com/docs/old-docs/fabric-intro-part-3/#serialization | Fabric.js Serialization} for why we use toDatalessJSON() instead of toJSON().
|
|
74
|
+
*/
|
|
75
|
+
private _historyCurrent() {
|
|
76
|
+
return JSON.stringify(this.toDatalessJSON());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Records the current canvas, object, or path state into the history stack for undo/redo.
|
|
81
|
+
*/
|
|
82
|
+
private _historySaveAction() {
|
|
83
|
+
if (this._historyProcessing || this._isMoving) return;
|
|
84
|
+
const latestJSON = this._historyCurrent();
|
|
85
|
+
|
|
86
|
+
if (this._historyCurrentState === latestJSON) return;
|
|
87
|
+
this._historyUndo.push(latestJSON);
|
|
88
|
+
this._historyCurrentState = latestJSON;
|
|
89
|
+
this._historyRedo = [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Undo the most recent action.
|
|
94
|
+
*/
|
|
95
|
+
async undo() {
|
|
96
|
+
if (this._historyUndo.length <= 1) return;
|
|
97
|
+
this._historyProcessing = true;
|
|
98
|
+
|
|
99
|
+
const poppedState = this._historyUndo.pop();
|
|
100
|
+
if (!poppedState) return;
|
|
101
|
+
|
|
102
|
+
this._historyRedo.push(poppedState);
|
|
103
|
+
|
|
104
|
+
// refresh canvas to load what remains on the undo stack after popping
|
|
105
|
+
const previousState = this._historyUndo[this._historyUndo.length - 1];
|
|
106
|
+
if (!previousState) return;
|
|
107
|
+
this._historyCurrentState = previousState;
|
|
108
|
+
await this._loadFromHistory(previousState);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks for whether or not an action can be undone.
|
|
113
|
+
*/
|
|
114
|
+
canUndo() {
|
|
115
|
+
return this._historyUndo.length > 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Redo the most recently undone action.
|
|
120
|
+
*/
|
|
121
|
+
async redo() {
|
|
122
|
+
if (this._historyRedo.length === 0) return;
|
|
123
|
+
this._historyProcessing = true;
|
|
124
|
+
|
|
125
|
+
const poppedState = this._historyRedo.pop();
|
|
126
|
+
if (!poppedState) return;
|
|
127
|
+
|
|
128
|
+
this._historyUndo.push(poppedState);
|
|
129
|
+
|
|
130
|
+
// refresh canvas to load the popped state
|
|
131
|
+
this._historyCurrentState = poppedState;
|
|
132
|
+
await this._loadFromHistory(poppedState);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Checks for whether or not an action can be redone.
|
|
137
|
+
*/
|
|
138
|
+
canRedo() {
|
|
139
|
+
return this._historyRedo.length > 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Loads a canvas history state from the history stack and renders it on the canvas.
|
|
144
|
+
*
|
|
145
|
+
* @param historyState - The JSON string representing the canvas history state to load.
|
|
146
|
+
*/
|
|
147
|
+
private async _loadFromHistory(historyState: string) {
|
|
148
|
+
this.clear();
|
|
149
|
+
this.discardActiveObject();
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(historyState);
|
|
153
|
+
await this.loadFromJSON(parsed);
|
|
154
|
+
this.renderAll();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error("Error loading from history:", error);
|
|
157
|
+
} finally {
|
|
158
|
+
this._historyProcessing = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Clears the history stacks for undo and redo.
|
|
164
|
+
*/
|
|
165
|
+
clearHistory() {
|
|
166
|
+
this._historyUndo = [];
|
|
167
|
+
this._historyRedo = [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Unsubscribes all relevant fabric event listeners.
|
|
172
|
+
*/
|
|
173
|
+
private _disposeEventListeners() {
|
|
174
|
+
this.off({
|
|
175
|
+
"path:created": this._historySaveAction.bind(this),
|
|
176
|
+
"erasing:end": this._historySaveAction.bind(this),
|
|
177
|
+
"object:added": this._historySaveAction.bind(this),
|
|
178
|
+
"object:removed": this._historySaveAction.bind(this),
|
|
179
|
+
"object:moving": this._objectMoving.bind(this),
|
|
180
|
+
"object:modified": this._handleObjectModified.bind(this),
|
|
181
|
+
"canvas:cleared": this._historySaveAction.bind(this),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
dispose() {
|
|
186
|
+
this._disposeEventListeners();
|
|
187
|
+
return super.dispose();
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CanvasWithHistory } from "./canvas.js";
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"lib": ["ES2022", "dom"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"skipDefaultLibCheck": true,
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"checkJs": false,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src",
|
|
13
|
+
"strict": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"types": ["node"]
|
|
21
|
+
},
|
|
22
|
+
"include": ["src/**/*"],
|
|
23
|
+
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
|
24
|
+
}
|
package/vitest.config.ts
ADDED
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock canvas getContext for jsdom
|
|
4
|
+
const getContext = vi.fn(() => ({
|
|
5
|
+
scale: vi.fn(),
|
|
6
|
+
clearRect: vi.fn(),
|
|
7
|
+
fillRect: vi.fn(),
|
|
8
|
+
getImageData: vi.fn(() => ({ data: [] })),
|
|
9
|
+
putImageData: vi.fn(),
|
|
10
|
+
createImageData: vi.fn(() => []),
|
|
11
|
+
setTransform: vi.fn(),
|
|
12
|
+
drawImage: vi.fn(),
|
|
13
|
+
save: vi.fn(),
|
|
14
|
+
restore: vi.fn(),
|
|
15
|
+
beginPath: vi.fn(),
|
|
16
|
+
moveTo: vi.fn(),
|
|
17
|
+
lineTo: vi.fn(),
|
|
18
|
+
closePath: vi.fn(),
|
|
19
|
+
stroke: vi.fn(),
|
|
20
|
+
fill: vi.fn(),
|
|
21
|
+
translate: vi.fn(),
|
|
22
|
+
rotate: vi.fn(),
|
|
23
|
+
arc: vi.fn(),
|
|
24
|
+
rect: vi.fn(),
|
|
25
|
+
clip: vi.fn(),
|
|
26
|
+
measureText: vi.fn(() => ({ width: 0 })),
|
|
27
|
+
transform: vi.fn(),
|
|
28
|
+
canvas: {
|
|
29
|
+
width: 300,
|
|
30
|
+
height: 150,
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
HTMLCanvasElement.prototype.getContext = getContext as any;
|