fabricjs-document-engine 0.0.0 → 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/README.md CHANGED
@@ -9,6 +9,7 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
9
9
  - **Safe loading.** Documents are validated first. Unknown object types are refused before the canvas is touched. A missing image fails the load instead of silently disappearing. When loads overlap, the newest one wins.
10
10
  - **Custom objects.** Register your own Fabric classes and the extra properties they need to keep.
11
11
  - **Your storage.** Plug in any backend with two functions. No hosted service is needed.
12
+ - **Reliable undo and redo.** One user action is one undo step. Transactions group several code changes into one labelled step, and ids survive undo and redo.
12
13
  - **Fabric 6 and 7.** Every release is tested against both.
13
14
 
14
15
  ## Install
@@ -68,6 +69,54 @@ const engine = createDocumentEngine({
68
69
 
69
70
  If a document contains a type that has not been registered, loading fails with `UNKNOWN_OBJECT_TYPE` and lists the missing types. Your object is never turned into something else.
70
71
 
72
+ ## Undo and redo
73
+
74
+ History is on by default. The engine records these automatically:
75
+
76
+ - adding and deleting objects, where several changes in the same tick become one step
77
+ - pointer moves, resizes and rotations (Fabric's `object:modified`)
78
+ - finished text editing
79
+
80
+ Fabric fires no events for changes your code makes directly, such as `object.set('fill', 'red')` or `canvas.bringObjectForward(object)`. Wrap them in a transaction or call `commit`:
81
+
82
+ ```ts
83
+ engine.transaction('Arrange furniture', () => {
84
+ chair.set({ left: 120, top: 80 });
85
+ table.set('fill', 'oak');
86
+ canvas.bringObjectToFront(table);
87
+ });
88
+
89
+ canvas.sendObjectBackwards(rug);
90
+ engine.commit('Send rug backwards');
91
+
92
+ await engine.undo();
93
+ await engine.redo();
94
+ ```
95
+
96
+ - Transactions can be nested, and the outermost label is used. They can also be async: `await engine.transaction('Import', async () => { ... })`.
97
+ - Grouping and ungrouping are ordinary changes to the object list. Do the remove and the add inside one transaction and they take one undo step.
98
+ - Undo and redo rebuild the changed objects from their saved state, so they come back as new instances with the same ids. Look them up again with `engine.getObjectById(id)` rather than keeping old references.
99
+ - Keep the last 50 steps with `createDocumentEngine({ canvas, history: { limit: 50 } })`. The default is 100.
100
+
101
+ ### Keyboard shortcuts
102
+
103
+ ```ts
104
+ import { bindKeyboardShortcuts } from 'fabricjs-document-engine';
105
+
106
+ const unbind = bindKeyboardShortcuts(engine);
107
+ ```
108
+
109
+ Ctrl/Cmd + Z undoes. Ctrl/Cmd + Shift + Z and Ctrl + Y redo. Shortcuts are ignored while the user is typing in an input, a textarea, a contenteditable element or Fabric text, so native text undo keeps working there. Pass `{ target: element }` to listen somewhere other than `window`.
110
+
111
+ ### Toolbar state
112
+
113
+ ```ts
114
+ engine.on('history:change', ({ canUndo, canRedo, undoLabel, redoLabel }) => {
115
+ undoButton.disabled = !canUndo;
116
+ undoButton.title = undoLabel ? `Undo ${undoLabel}` : 'Undo';
117
+ });
118
+ ```
119
+
71
120
  ## Document format
72
121
 
73
122
  ```ts
@@ -98,7 +147,14 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
98
147
  | `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
99
148
  | `engine.getObjectById(id)` | Finds any object by id, including objects inside groups. |
100
149
  | `engine.registerObject({ fabricClass, properties })` | Registers a custom class after the engine is created. |
101
- | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success` or `save:error`. Returns an unsubscribe function. |
150
+ | `engine.transaction(label, work)` | Runs `work` and records everything it changed as one undo step. Returns what `work` returns. |
151
+ | `engine.commit(label?)` | Records changes made since the last step. Returns `false` when nothing changed. |
152
+ | `engine.undo()` / `engine.redo()` | Resolves to `true` when a step was applied. Calls run one after another. |
153
+ | `engine.canUndo()` / `engine.canRedo()` | Tells you whether a step is available. |
154
+ | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
155
+ | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
156
+ | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
157
+ | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
102
158
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
103
159
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
104
160
 
@@ -114,6 +170,7 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
114
170
  | `LOAD_FAILED` | Fabric or your storage could not load the document, for example because an image is missing. `error.cause` holds the original error. |
115
171
  | `LOAD_ABORTED` | A newer load started before this one finished. |
116
172
  | `SAVE_FAILED` | Your storage adapter rejected the save. |
173
+ | `HISTORY_FAILED` | Undo or redo could not rebuild an object, for example because an image is gone. The step is kept and the canvas is unchanged. |
117
174
  | `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
118
175
  | `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
119
176
  | `ENGINE_DESTROYED` | The engine was used after `destroy()`. |
@@ -124,8 +181,8 @@ A failed load never clears or half-fills your canvas.
124
181
 
125
182
  | Version | Focus |
126
183
  | --- | --- |
127
- | 0.1 | Document foundation: ids, save and load, validation, custom objects |
128
- | 0.2 | Undo and redo with transactions |
184
+ | 0.1 ✓ | Document foundation: ids, save and load, validation, custom objects |
185
+ | 0.2 ✓ | Undo and redo with transactions |
129
186
  | 0.3 | Safe saving: dirty state, autosave, stale-response protection |
130
187
  | 0.4 | Assets and fonts |
131
188
  | 0.5 | Recovery after a refresh or crash |
@@ -7,6 +7,7 @@ const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
7
7
  const require_object_ids = require("../fabric/object-ids.cjs");
8
8
  const require_errors = require("./errors.cjs");
9
9
  const require_object_registry = require("../fabric/object-registry.cjs");
10
+ const require_create_history = require("../history/create-history.cjs");
10
11
  const require_event_emitter = require("./event-emitter.cjs");
11
12
  //#region src/engine/create-document-engine.ts
12
13
  function describeDocumentId(value) {
@@ -50,6 +51,15 @@ function createDocumentEngine(options) {
50
51
  canvas.on("object:added", handleObjectAdded);
51
52
  canvas.on("object:removed", handleObjectRemoved);
52
53
  rebuildIndex();
54
+ const history = require_create_history.createHistory({
55
+ canvas,
56
+ limit: options.history?.limit,
57
+ serializeObjects: () => {
58
+ rebuildIndex();
59
+ return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
60
+ },
61
+ onChange: (state) => events.emit("history:change", state)
62
+ });
53
63
  function toDocument() {
54
64
  ensureUsable();
55
65
  rebuildIndex();
@@ -99,11 +109,11 @@ function createDocumentEngine(options) {
99
109
  events.emit("load:start", { documentId: describeDocumentId(input) });
100
110
  try {
101
111
  const document = checkDocument(input);
102
- await require_fabric_adapter.loadIntoCanvas(canvas, {
112
+ await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
103
113
  version: document.fabricVersion,
104
114
  background: document.canvas.background,
105
115
  objects: document.objects
106
- }, controller.signal);
116
+ }, controller.signal));
107
117
  if (controller.signal.aborted) throw new Error("aborted");
108
118
  if (loadOptions.restoreCanvasSize ?? true) canvas.setDimensions({
109
119
  width: document.canvas.width,
@@ -116,6 +126,7 @@ function createDocumentEngine(options) {
116
126
  metadata: { ...document.metadata }
117
127
  };
118
128
  rebuildIndex();
129
+ history.reset();
119
130
  canvas.requestRenderAll();
120
131
  events.emit("load:success", { document });
121
132
  return document;
@@ -164,9 +175,10 @@ function createDocumentEngine(options) {
164
175
  function newDocument(newOptions) {
165
176
  ensureUsable();
166
177
  activeLoad?.abort();
167
- canvas.clear();
178
+ history.withoutRecording(() => canvas.clear());
168
179
  objectsById.clear();
169
180
  documentInfo = require_create_document.createDocumentInfo(newOptions);
181
+ history.reset();
170
182
  }
171
183
  function getObjectById(id) {
172
184
  ensureUsable();
@@ -175,10 +187,22 @@ function createDocumentEngine(options) {
175
187
  rebuildIndex();
176
188
  return objectsById.get(id);
177
189
  }
190
+ async function travelThroughHistory(direction) {
191
+ ensureUsable();
192
+ try {
193
+ return await history[direction]();
194
+ } catch (error) {
195
+ const reason = error instanceof Error ? error.message : String(error);
196
+ const engineError = new require_errors.DocumentEngineError("HISTORY_FAILED", `Could not ${direction}: ${reason}`, { cause: error });
197
+ events.emit("history:error", { error: engineError });
198
+ throw engineError;
199
+ }
200
+ }
178
201
  function destroy() {
179
202
  if (destroyed) return;
180
203
  destroyed = true;
181
204
  activeLoad?.abort();
205
+ history.destroy();
182
206
  canvas.off("object:added", handleObjectAdded);
183
207
  canvas.off("object:removed", handleObjectRemoved);
184
208
  objectsById.clear();
@@ -210,6 +234,23 @@ function createDocumentEngine(options) {
210
234
  registry.register(definition);
211
235
  },
212
236
  getObjectById,
237
+ transaction(label, work) {
238
+ ensureUsable();
239
+ return history.transaction(label, work);
240
+ },
241
+ commit(label = "Edit") {
242
+ ensureUsable();
243
+ return history.commit(label);
244
+ },
245
+ undo: () => travelThroughHistory("undo"),
246
+ redo: () => travelThroughHistory("redo"),
247
+ canUndo: () => history.state().canUndo,
248
+ canRedo: () => history.state().canRedo,
249
+ getHistory: () => history.labels(),
250
+ clearHistory() {
251
+ ensureUsable();
252
+ history.reset();
253
+ },
213
254
  on: (name, handler) => events.on(name, handler),
214
255
  destroy
215
256
  };
@@ -1,6 +1,7 @@
1
1
  import { DocumentInfo, FabricDocument } from "../document/document-format.cjs";
2
2
  import { NewDocumentOptions } from "../document/create-document.cjs";
3
3
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
4
+ import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
4
5
  import { DocumentEngineError } from "./errors.cjs";
5
6
  import { Unsubscribe } from "./event-emitter.cjs";
6
7
  import { FabricObject, StaticCanvas } from "fabric";
@@ -14,6 +15,7 @@ export interface DocumentEngineOptions {
14
15
  storage?: DocumentStorage;
15
16
  customObjects?: CustomObjectDefinition[];
16
17
  document?: NewDocumentOptions;
18
+ history?: HistoryOptions;
17
19
  }
18
20
  export interface LoadOptions {
19
21
  restoreCanvasSize?: boolean;
@@ -37,6 +39,10 @@ export interface DocumentEngineEvents {
37
39
  "save:error": {
38
40
  error: DocumentEngineError;
39
41
  };
42
+ "history:change": HistoryState;
43
+ "history:error": {
44
+ error: DocumentEngineError;
45
+ };
40
46
  }
41
47
  export interface DocumentEngine {
42
48
  readonly canvas: StaticCanvas;
@@ -48,6 +54,17 @@ export interface DocumentEngine {
48
54
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
49
55
  save(): Promise<FabricDocument>;
50
56
  registerObject(definition: CustomObjectDefinition): void;
57
+ transaction<Result>(label: string, work: () => Result): Result;
58
+ commit(label?: string): boolean;
59
+ undo(): Promise<boolean>;
60
+ redo(): Promise<boolean>;
61
+ canUndo(): boolean;
62
+ canRedo(): boolean;
63
+ getHistory(): {
64
+ undo: string[];
65
+ redo: string[];
66
+ };
67
+ clearHistory(): void;
51
68
  getObjectById(id: string): FabricObject | undefined;
52
69
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
53
70
  destroy(): void;
@@ -1,6 +1,7 @@
1
1
  import { DocumentInfo, FabricDocument } from "../document/document-format.js";
2
2
  import { NewDocumentOptions } from "../document/create-document.js";
3
3
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
4
+ import { HistoryOptions, HistoryState } from "../history/create-history.js";
4
5
  import { DocumentEngineError } from "./errors.js";
5
6
  import { Unsubscribe } from "./event-emitter.js";
6
7
  import { FabricObject, StaticCanvas } from "fabric";
@@ -14,6 +15,7 @@ export interface DocumentEngineOptions {
14
15
  storage?: DocumentStorage;
15
16
  customObjects?: CustomObjectDefinition[];
16
17
  document?: NewDocumentOptions;
18
+ history?: HistoryOptions;
17
19
  }
18
20
  export interface LoadOptions {
19
21
  restoreCanvasSize?: boolean;
@@ -37,6 +39,10 @@ export interface DocumentEngineEvents {
37
39
  "save:error": {
38
40
  error: DocumentEngineError;
39
41
  };
42
+ "history:change": HistoryState;
43
+ "history:error": {
44
+ error: DocumentEngineError;
45
+ };
40
46
  }
41
47
  export interface DocumentEngine {
42
48
  readonly canvas: StaticCanvas;
@@ -48,6 +54,17 @@ export interface DocumentEngine {
48
54
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
49
55
  save(): Promise<FabricDocument>;
50
56
  registerObject(definition: CustomObjectDefinition): void;
57
+ transaction<Result>(label: string, work: () => Result): Result;
58
+ commit(label?: string): boolean;
59
+ undo(): Promise<boolean>;
60
+ redo(): Promise<boolean>;
61
+ canUndo(): boolean;
62
+ canRedo(): boolean;
63
+ getHistory(): {
64
+ undo: string[];
65
+ redo: string[];
66
+ };
67
+ clearHistory(): void;
51
68
  getObjectById(id: string): FabricObject | undefined;
52
69
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
53
70
  destroy(): void;
@@ -7,6 +7,7 @@ import { loadIntoCanvas, serializeCanvas } from "../fabric/fabric-adapter.js";
7
7
  import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
8
8
  import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
9
9
  import { createObjectRegistry } from "../fabric/object-registry.js";
10
+ import { createHistory } from "../history/create-history.js";
10
11
  import { createEventEmitter } from "./event-emitter.js";
11
12
  //#region src/engine/create-document-engine.ts
12
13
  function describeDocumentId(value) {
@@ -50,6 +51,15 @@ function createDocumentEngine(options) {
50
51
  canvas.on("object:added", handleObjectAdded);
51
52
  canvas.on("object:removed", handleObjectRemoved);
52
53
  rebuildIndex();
54
+ const history = createHistory({
55
+ canvas,
56
+ limit: options.history?.limit,
57
+ serializeObjects: () => {
58
+ rebuildIndex();
59
+ return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
60
+ },
61
+ onChange: (state) => events.emit("history:change", state)
62
+ });
53
63
  function toDocument() {
54
64
  ensureUsable();
55
65
  rebuildIndex();
@@ -99,11 +109,11 @@ function createDocumentEngine(options) {
99
109
  events.emit("load:start", { documentId: describeDocumentId(input) });
100
110
  try {
101
111
  const document = checkDocument(input);
102
- await loadIntoCanvas(canvas, {
112
+ await history.withoutRecording(() => loadIntoCanvas(canvas, {
103
113
  version: document.fabricVersion,
104
114
  background: document.canvas.background,
105
115
  objects: document.objects
106
- }, controller.signal);
116
+ }, controller.signal));
107
117
  if (controller.signal.aborted) throw new Error("aborted");
108
118
  if (loadOptions.restoreCanvasSize ?? true) canvas.setDimensions({
109
119
  width: document.canvas.width,
@@ -116,6 +126,7 @@ function createDocumentEngine(options) {
116
126
  metadata: { ...document.metadata }
117
127
  };
118
128
  rebuildIndex();
129
+ history.reset();
119
130
  canvas.requestRenderAll();
120
131
  events.emit("load:success", { document });
121
132
  return document;
@@ -164,9 +175,10 @@ function createDocumentEngine(options) {
164
175
  function newDocument(newOptions) {
165
176
  ensureUsable();
166
177
  activeLoad?.abort();
167
- canvas.clear();
178
+ history.withoutRecording(() => canvas.clear());
168
179
  objectsById.clear();
169
180
  documentInfo = createDocumentInfo(newOptions);
181
+ history.reset();
170
182
  }
171
183
  function getObjectById(id) {
172
184
  ensureUsable();
@@ -175,10 +187,22 @@ function createDocumentEngine(options) {
175
187
  rebuildIndex();
176
188
  return objectsById.get(id);
177
189
  }
190
+ async function travelThroughHistory(direction) {
191
+ ensureUsable();
192
+ try {
193
+ return await history[direction]();
194
+ } catch (error) {
195
+ const reason = error instanceof Error ? error.message : String(error);
196
+ const engineError = new DocumentEngineError("HISTORY_FAILED", `Could not ${direction}: ${reason}`, { cause: error });
197
+ events.emit("history:error", { error: engineError });
198
+ throw engineError;
199
+ }
200
+ }
178
201
  function destroy() {
179
202
  if (destroyed) return;
180
203
  destroyed = true;
181
204
  activeLoad?.abort();
205
+ history.destroy();
182
206
  canvas.off("object:added", handleObjectAdded);
183
207
  canvas.off("object:removed", handleObjectRemoved);
184
208
  objectsById.clear();
@@ -210,6 +234,23 @@ function createDocumentEngine(options) {
210
234
  registry.register(definition);
211
235
  },
212
236
  getObjectById,
237
+ transaction(label, work) {
238
+ ensureUsable();
239
+ return history.transaction(label, work);
240
+ },
241
+ commit(label = "Edit") {
242
+ ensureUsable();
243
+ return history.commit(label);
244
+ },
245
+ undo: () => travelThroughHistory("undo"),
246
+ redo: () => travelThroughHistory("redo"),
247
+ canUndo: () => history.state().canUndo,
248
+ canRedo: () => history.state().canRedo,
249
+ getHistory: () => history.labels(),
250
+ clearHistory() {
251
+ ensureUsable();
252
+ history.reset();
253
+ },
213
254
  on: (name, handler) => events.on(name, handler),
214
255
  destroy
215
256
  };
@@ -1,5 +1,5 @@
1
1
  //#region src/engine/errors.d.ts
2
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "ENGINE_DESTROYED";
2
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "HISTORY_FAILED" | "ENGINE_DESTROYED";
3
3
  export interface DocumentIssue {
4
4
  code: DocumentErrorCode;
5
5
  path: string;
@@ -1,5 +1,5 @@
1
1
  //#region src/engine/errors.d.ts
2
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "ENGINE_DESTROYED";
2
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "HISTORY_FAILED" | "ENGINE_DESTROYED";
3
3
  export interface DocumentIssue {
4
4
  code: DocumentErrorCode;
5
5
  path: string;
@@ -1,4 +1,5 @@
1
1
  const require_walk_objects = require("./walk-objects.cjs");
2
+ let fabric = require("fabric");
2
3
  //#region src/fabric/fabric-adapter.ts
3
4
  function keepOriginExplicit(liveObjects, serializedObjects) {
4
5
  const pending = [];
@@ -56,13 +57,17 @@ function refuseMissingObjects(serialized, created, error) {
56
57
  const actual = countLiveTree(created);
57
58
  if (actual !== expected) throw new Error(`Only ${actual} of ${expected} objects inside the ${describeObject(serialized)} could be created`);
58
59
  }
60
+ const strictReviver = refuseMissingObjects;
61
+ function createObjects(serializedObjects) {
62
+ return fabric.util.enlivenObjects(serializedObjects, { reviver: strictReviver });
63
+ }
59
64
  async function loadIntoCanvas(canvas, content, signal) {
60
65
  const json = { objects: content.objects };
61
66
  if (content.version !== void 0) json.version = content.version;
62
67
  if (content.background !== void 0) json.background = content.background;
63
- const reviver = refuseMissingObjects;
64
- await canvas.loadFromJSON(json, reviver, { signal });
68
+ await canvas.loadFromJSON(json, strictReviver, { signal });
65
69
  }
66
70
  //#endregion
71
+ exports.createObjects = createObjects;
67
72
  exports.loadIntoCanvas = loadIntoCanvas;
68
73
  exports.serializeCanvas = serializeCanvas;
@@ -1,4 +1,5 @@
1
1
  import { childrenOf, walkObjects } from "./walk-objects.js";
2
+ import { util } from "fabric";
2
3
  //#region src/fabric/fabric-adapter.ts
3
4
  function keepOriginExplicit(liveObjects, serializedObjects) {
4
5
  const pending = [];
@@ -56,12 +57,15 @@ function refuseMissingObjects(serialized, created, error) {
56
57
  const actual = countLiveTree(created);
57
58
  if (actual !== expected) throw new Error(`Only ${actual} of ${expected} objects inside the ${describeObject(serialized)} could be created`);
58
59
  }
60
+ const strictReviver = refuseMissingObjects;
61
+ function createObjects(serializedObjects) {
62
+ return util.enlivenObjects(serializedObjects, { reviver: strictReviver });
63
+ }
59
64
  async function loadIntoCanvas(canvas, content, signal) {
60
65
  const json = { objects: content.objects };
61
66
  if (content.version !== void 0) json.version = content.version;
62
67
  if (content.background !== void 0) json.background = content.background;
63
- const reviver = refuseMissingObjects;
64
- await canvas.loadFromJSON(json, reviver, { signal });
68
+ await canvas.loadFromJSON(json, strictReviver, { signal });
65
69
  }
66
70
  //#endregion
67
- export { loadIntoCanvas, serializeCanvas };
71
+ export { createObjects, loadIntoCanvas, serializeCanvas };
@@ -0,0 +1,48 @@
1
+ const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
2
+ const require_object_ids = require("../fabric/object-ids.cjs");
3
+ //#region src/history/apply-state.ts
4
+ function arrangeObjects(current, change, created) {
5
+ const arranged = [];
6
+ for (const object of current) {
7
+ const id = require_object_ids.readObjectId(object) ?? "";
8
+ if (!change.objects.has(id)) {
9
+ arranged.push(object);
10
+ continue;
11
+ }
12
+ const replacement = created.get(id);
13
+ if (replacement) {
14
+ arranged.push(replacement);
15
+ created.delete(id);
16
+ }
17
+ }
18
+ arranged.push(...created.values());
19
+ if (change.order === null) return arranged;
20
+ const position = new Map(change.order.map((id, index) => [id, index]));
21
+ return arranged.sort((first, second) => (position.get(require_object_ids.readObjectId(first) ?? "") ?? Infinity) - (position.get(require_object_ids.readObjectId(second) ?? "") ?? Infinity));
22
+ }
23
+ async function applyStateChange(canvas, change) {
24
+ const serializedObjects = [];
25
+ for (const json of change.objects.values()) if (json !== null) serializedObjects.push(JSON.parse(json));
26
+ const createdObjects = await require_fabric_adapter.createObjects(serializedObjects);
27
+ const createdById = new Map(createdObjects.map((object) => [require_object_ids.readObjectId(object) ?? "", object]));
28
+ canvas.discardActiveObject?.();
29
+ const current = canvas.getObjects();
30
+ const arranged = arrangeObjects(current, change, createdById);
31
+ const kept = new Set(arranged);
32
+ const renderOnAddRemove = canvas.renderOnAddRemove;
33
+ canvas.renderOnAddRemove = false;
34
+ try {
35
+ const outgoing = current.filter((object) => !kept.has(object));
36
+ if (outgoing.length > 0) canvas.remove(...outgoing);
37
+ arranged.forEach((object, index) => {
38
+ if (canvas.getObjects()[index] === object) return;
39
+ if (object.canvas === canvas) canvas.moveObjectTo(object, index);
40
+ else canvas.insertAt(index, object);
41
+ });
42
+ } finally {
43
+ canvas.renderOnAddRemove = renderOnAddRemove;
44
+ }
45
+ canvas.requestRenderAll();
46
+ }
47
+ //#endregion
48
+ exports.applyStateChange = applyStateChange;
@@ -0,0 +1,48 @@
1
+ import { createObjects } from "../fabric/fabric-adapter.js";
2
+ import { readObjectId } from "../fabric/object-ids.js";
3
+ //#region src/history/apply-state.ts
4
+ function arrangeObjects(current, change, created) {
5
+ const arranged = [];
6
+ for (const object of current) {
7
+ const id = readObjectId(object) ?? "";
8
+ if (!change.objects.has(id)) {
9
+ arranged.push(object);
10
+ continue;
11
+ }
12
+ const replacement = created.get(id);
13
+ if (replacement) {
14
+ arranged.push(replacement);
15
+ created.delete(id);
16
+ }
17
+ }
18
+ arranged.push(...created.values());
19
+ if (change.order === null) return arranged;
20
+ const position = new Map(change.order.map((id, index) => [id, index]));
21
+ return arranged.sort((first, second) => (position.get(readObjectId(first) ?? "") ?? Infinity) - (position.get(readObjectId(second) ?? "") ?? Infinity));
22
+ }
23
+ async function applyStateChange(canvas, change) {
24
+ const serializedObjects = [];
25
+ for (const json of change.objects.values()) if (json !== null) serializedObjects.push(JSON.parse(json));
26
+ const createdObjects = await createObjects(serializedObjects);
27
+ const createdById = new Map(createdObjects.map((object) => [readObjectId(object) ?? "", object]));
28
+ canvas.discardActiveObject?.();
29
+ const current = canvas.getObjects();
30
+ const arranged = arrangeObjects(current, change, createdById);
31
+ const kept = new Set(arranged);
32
+ const renderOnAddRemove = canvas.renderOnAddRemove;
33
+ canvas.renderOnAddRemove = false;
34
+ try {
35
+ const outgoing = current.filter((object) => !kept.has(object));
36
+ if (outgoing.length > 0) canvas.remove(...outgoing);
37
+ arranged.forEach((object, index) => {
38
+ if (canvas.getObjects()[index] === object) return;
39
+ if (object.canvas === canvas) canvas.moveObjectTo(object, index);
40
+ else canvas.insertAt(index, object);
41
+ });
42
+ } finally {
43
+ canvas.renderOnAddRemove = renderOnAddRemove;
44
+ }
45
+ canvas.requestRenderAll();
46
+ }
47
+ //#endregion
48
+ export { applyStateChange };
@@ -0,0 +1,191 @@
1
+ const require_apply_state = require("./apply-state.cjs");
2
+ const require_history_stack = require("./history-stack.cjs");
3
+ const require_snapshot = require("./snapshot.cjs");
4
+ //#region src/history/create-history.ts
5
+ const verbsByAction = {
6
+ drag: "Move",
7
+ rotate: "Rotate",
8
+ scale: "Resize",
9
+ scaleX: "Resize",
10
+ scaleY: "Resize",
11
+ resizing: "Resize",
12
+ skewX: "Skew",
13
+ skewY: "Skew"
14
+ };
15
+ function nameOf(object) {
16
+ return (object.constructor.type ?? object.type).toLowerCase();
17
+ }
18
+ function isPromiseLike(value) {
19
+ return typeof value?.then === "function";
20
+ }
21
+ function describePendingChanges(changes) {
22
+ if (new Set(changes.map((change) => change.verb)).size > 1) return "Edit objects";
23
+ const verb = changes[0]?.verb ?? "Edit";
24
+ return changes.length === 1 ? `${verb} ${changes[0].noun}` : `${verb} ${changes.length} objects`;
25
+ }
26
+ function createHistory(options) {
27
+ const { canvas, serializeObjects, onChange } = options;
28
+ const stack = require_history_stack.createHistoryStack(Math.max(1, options.limit ?? 100));
29
+ let snapshot = require_snapshot.createSnapshot(serializeObjects());
30
+ let pendingChanges = [];
31
+ let flushScheduled = false;
32
+ let transactionDepth = 0;
33
+ let pausedDepth = 0;
34
+ let applying = false;
35
+ let queue = Promise.resolve();
36
+ function state() {
37
+ const undoLabels = stack.undoLabels();
38
+ const redoLabels = stack.redoLabels();
39
+ return {
40
+ canUndo: undoLabels.length > 0,
41
+ canRedo: redoLabels.length > 0,
42
+ undoLabel: undoLabels[0],
43
+ redoLabel: redoLabels[0]
44
+ };
45
+ }
46
+ function commit(label) {
47
+ pendingChanges = [];
48
+ const nextSnapshot = require_snapshot.createSnapshot(serializeObjects());
49
+ const difference = require_snapshot.diffSnapshots(snapshot, nextSnapshot);
50
+ snapshot = nextSnapshot;
51
+ if (difference === null) return false;
52
+ stack.record({
53
+ label,
54
+ ...difference
55
+ });
56
+ onChange(state());
57
+ return true;
58
+ }
59
+ function isRecording() {
60
+ return !applying && pausedDepth === 0 && transactionDepth === 0;
61
+ }
62
+ function commitPendingChanges() {
63
+ flushScheduled = false;
64
+ if (pendingChanges.length === 0) return;
65
+ if (isRecording()) commit(describePendingChanges(pendingChanges));
66
+ pendingChanges = [];
67
+ }
68
+ function notice(verb, target) {
69
+ if (!isRecording()) return;
70
+ pendingChanges.push({
71
+ verb,
72
+ noun: nameOf(target)
73
+ });
74
+ if (flushScheduled) return;
75
+ flushScheduled = true;
76
+ queueMicrotask(commitPendingChanges);
77
+ }
78
+ const handleAdded = ({ target }) => notice("Add", target);
79
+ const handleRemoved = ({ target }) => notice("Delete", target);
80
+ const handleModified = (event) => {
81
+ const action = event.action ?? event.transform?.action ?? "";
82
+ notice(verbsByAction[action] ?? "Transform", event.target);
83
+ };
84
+ const handleTextEdited = ({ target }) => notice("Edit", target);
85
+ const listeners = [
86
+ ["object:added", handleAdded],
87
+ ["object:removed", handleRemoved],
88
+ ["object:modified", handleModified],
89
+ ["text:editing:exited", handleTextEdited]
90
+ ];
91
+ const eventTarget = canvas;
92
+ listeners.forEach(([name, handler]) => eventTarget.on(name, handler));
93
+ function transaction(label, work) {
94
+ if (transactionDepth === 0) commitPendingChanges();
95
+ transactionDepth += 1;
96
+ const finish = () => {
97
+ transactionDepth -= 1;
98
+ if (transactionDepth === 0) commit(label);
99
+ };
100
+ let result;
101
+ try {
102
+ result = work();
103
+ } catch (error) {
104
+ finish();
105
+ throw error;
106
+ }
107
+ if (!isPromiseLike(result)) {
108
+ finish();
109
+ return result;
110
+ }
111
+ return Promise.resolve(result).then((value) => {
112
+ finish();
113
+ return value;
114
+ }, (error) => {
115
+ finish();
116
+ throw error;
117
+ });
118
+ }
119
+ function withoutRecording(work) {
120
+ pausedDepth += 1;
121
+ let result;
122
+ try {
123
+ result = work();
124
+ } catch (error) {
125
+ pausedDepth -= 1;
126
+ throw error;
127
+ }
128
+ if (!isPromiseLike(result)) {
129
+ pausedDepth -= 1;
130
+ return result;
131
+ }
132
+ return Promise.resolve(result).finally(() => {
133
+ pausedDepth -= 1;
134
+ });
135
+ }
136
+ function runInOrder(task) {
137
+ const run = queue.then(task);
138
+ queue = run.catch(() => void 0);
139
+ return run;
140
+ }
141
+ function travel(takeStep, pickChange, putBack, moveTo) {
142
+ return runInOrder(async () => {
143
+ if (transactionDepth > 0) return false;
144
+ commitPendingChanges();
145
+ const step = takeStep();
146
+ if (step === void 0) return false;
147
+ applying = true;
148
+ try {
149
+ await require_apply_state.applyStateChange(canvas, pickChange(step));
150
+ } catch (error) {
151
+ putBack(step);
152
+ throw error;
153
+ } finally {
154
+ applying = false;
155
+ }
156
+ snapshot = require_snapshot.createSnapshot(serializeObjects());
157
+ moveTo(step);
158
+ onChange(state());
159
+ return true;
160
+ });
161
+ }
162
+ return {
163
+ commit(label) {
164
+ if (applying) return false;
165
+ return commit(label);
166
+ },
167
+ transaction,
168
+ undo: () => travel(() => stack.takeUndo(), (step) => step.before, (step) => stack.returnUndo(step), (step) => stack.returnRedo(step)),
169
+ redo: () => travel(() => stack.takeRedo(), (step) => step.after, (step) => stack.returnRedo(step), (step) => stack.returnUndo(step)),
170
+ state,
171
+ labels: () => ({
172
+ undo: stack.undoLabels(),
173
+ redo: stack.redoLabels()
174
+ }),
175
+ withoutRecording,
176
+ reset() {
177
+ pendingChanges = [];
178
+ stack.clear();
179
+ snapshot = require_snapshot.createSnapshot(serializeObjects());
180
+ onChange(state());
181
+ },
182
+ destroy() {
183
+ listeners.forEach(([name, handler]) => eventTarget.off(name, handler));
184
+ pendingChanges = [];
185
+ stack.clear();
186
+ }
187
+ };
188
+ }
189
+ //#endregion
190
+ exports.createHistory = createHistory;
191
+ exports.describePendingChanges = describePendingChanges;
@@ -0,0 +1,12 @@
1
+ import { StaticCanvas } from "fabric";
2
+ //#region src/history/create-history.d.ts
3
+ export interface HistoryOptions {
4
+ limit?: number;
5
+ }
6
+ export interface HistoryState {
7
+ canUndo: boolean;
8
+ canRedo: boolean;
9
+ undoLabel: string | undefined;
10
+ redoLabel: string | undefined;
11
+ }
12
+ //#endregion
@@ -0,0 +1,12 @@
1
+ import { StaticCanvas } from "fabric";
2
+ //#region src/history/create-history.d.ts
3
+ export interface HistoryOptions {
4
+ limit?: number;
5
+ }
6
+ export interface HistoryState {
7
+ canUndo: boolean;
8
+ canRedo: boolean;
9
+ undoLabel: string | undefined;
10
+ redoLabel: string | undefined;
11
+ }
12
+ //#endregion
@@ -0,0 +1,190 @@
1
+ import { applyStateChange } from "./apply-state.js";
2
+ import { createHistoryStack } from "./history-stack.js";
3
+ import { createSnapshot, diffSnapshots } from "./snapshot.js";
4
+ //#region src/history/create-history.ts
5
+ const verbsByAction = {
6
+ drag: "Move",
7
+ rotate: "Rotate",
8
+ scale: "Resize",
9
+ scaleX: "Resize",
10
+ scaleY: "Resize",
11
+ resizing: "Resize",
12
+ skewX: "Skew",
13
+ skewY: "Skew"
14
+ };
15
+ function nameOf(object) {
16
+ return (object.constructor.type ?? object.type).toLowerCase();
17
+ }
18
+ function isPromiseLike(value) {
19
+ return typeof value?.then === "function";
20
+ }
21
+ function describePendingChanges(changes) {
22
+ if (new Set(changes.map((change) => change.verb)).size > 1) return "Edit objects";
23
+ const verb = changes[0]?.verb ?? "Edit";
24
+ return changes.length === 1 ? `${verb} ${changes[0].noun}` : `${verb} ${changes.length} objects`;
25
+ }
26
+ function createHistory(options) {
27
+ const { canvas, serializeObjects, onChange } = options;
28
+ const stack = createHistoryStack(Math.max(1, options.limit ?? 100));
29
+ let snapshot = createSnapshot(serializeObjects());
30
+ let pendingChanges = [];
31
+ let flushScheduled = false;
32
+ let transactionDepth = 0;
33
+ let pausedDepth = 0;
34
+ let applying = false;
35
+ let queue = Promise.resolve();
36
+ function state() {
37
+ const undoLabels = stack.undoLabels();
38
+ const redoLabels = stack.redoLabels();
39
+ return {
40
+ canUndo: undoLabels.length > 0,
41
+ canRedo: redoLabels.length > 0,
42
+ undoLabel: undoLabels[0],
43
+ redoLabel: redoLabels[0]
44
+ };
45
+ }
46
+ function commit(label) {
47
+ pendingChanges = [];
48
+ const nextSnapshot = createSnapshot(serializeObjects());
49
+ const difference = diffSnapshots(snapshot, nextSnapshot);
50
+ snapshot = nextSnapshot;
51
+ if (difference === null) return false;
52
+ stack.record({
53
+ label,
54
+ ...difference
55
+ });
56
+ onChange(state());
57
+ return true;
58
+ }
59
+ function isRecording() {
60
+ return !applying && pausedDepth === 0 && transactionDepth === 0;
61
+ }
62
+ function commitPendingChanges() {
63
+ flushScheduled = false;
64
+ if (pendingChanges.length === 0) return;
65
+ if (isRecording()) commit(describePendingChanges(pendingChanges));
66
+ pendingChanges = [];
67
+ }
68
+ function notice(verb, target) {
69
+ if (!isRecording()) return;
70
+ pendingChanges.push({
71
+ verb,
72
+ noun: nameOf(target)
73
+ });
74
+ if (flushScheduled) return;
75
+ flushScheduled = true;
76
+ queueMicrotask(commitPendingChanges);
77
+ }
78
+ const handleAdded = ({ target }) => notice("Add", target);
79
+ const handleRemoved = ({ target }) => notice("Delete", target);
80
+ const handleModified = (event) => {
81
+ const action = event.action ?? event.transform?.action ?? "";
82
+ notice(verbsByAction[action] ?? "Transform", event.target);
83
+ };
84
+ const handleTextEdited = ({ target }) => notice("Edit", target);
85
+ const listeners = [
86
+ ["object:added", handleAdded],
87
+ ["object:removed", handleRemoved],
88
+ ["object:modified", handleModified],
89
+ ["text:editing:exited", handleTextEdited]
90
+ ];
91
+ const eventTarget = canvas;
92
+ listeners.forEach(([name, handler]) => eventTarget.on(name, handler));
93
+ function transaction(label, work) {
94
+ if (transactionDepth === 0) commitPendingChanges();
95
+ transactionDepth += 1;
96
+ const finish = () => {
97
+ transactionDepth -= 1;
98
+ if (transactionDepth === 0) commit(label);
99
+ };
100
+ let result;
101
+ try {
102
+ result = work();
103
+ } catch (error) {
104
+ finish();
105
+ throw error;
106
+ }
107
+ if (!isPromiseLike(result)) {
108
+ finish();
109
+ return result;
110
+ }
111
+ return Promise.resolve(result).then((value) => {
112
+ finish();
113
+ return value;
114
+ }, (error) => {
115
+ finish();
116
+ throw error;
117
+ });
118
+ }
119
+ function withoutRecording(work) {
120
+ pausedDepth += 1;
121
+ let result;
122
+ try {
123
+ result = work();
124
+ } catch (error) {
125
+ pausedDepth -= 1;
126
+ throw error;
127
+ }
128
+ if (!isPromiseLike(result)) {
129
+ pausedDepth -= 1;
130
+ return result;
131
+ }
132
+ return Promise.resolve(result).finally(() => {
133
+ pausedDepth -= 1;
134
+ });
135
+ }
136
+ function runInOrder(task) {
137
+ const run = queue.then(task);
138
+ queue = run.catch(() => void 0);
139
+ return run;
140
+ }
141
+ function travel(takeStep, pickChange, putBack, moveTo) {
142
+ return runInOrder(async () => {
143
+ if (transactionDepth > 0) return false;
144
+ commitPendingChanges();
145
+ const step = takeStep();
146
+ if (step === void 0) return false;
147
+ applying = true;
148
+ try {
149
+ await applyStateChange(canvas, pickChange(step));
150
+ } catch (error) {
151
+ putBack(step);
152
+ throw error;
153
+ } finally {
154
+ applying = false;
155
+ }
156
+ snapshot = createSnapshot(serializeObjects());
157
+ moveTo(step);
158
+ onChange(state());
159
+ return true;
160
+ });
161
+ }
162
+ return {
163
+ commit(label) {
164
+ if (applying) return false;
165
+ return commit(label);
166
+ },
167
+ transaction,
168
+ undo: () => travel(() => stack.takeUndo(), (step) => step.before, (step) => stack.returnUndo(step), (step) => stack.returnRedo(step)),
169
+ redo: () => travel(() => stack.takeRedo(), (step) => step.after, (step) => stack.returnRedo(step), (step) => stack.returnUndo(step)),
170
+ state,
171
+ labels: () => ({
172
+ undo: stack.undoLabels(),
173
+ redo: stack.redoLabels()
174
+ }),
175
+ withoutRecording,
176
+ reset() {
177
+ pendingChanges = [];
178
+ stack.clear();
179
+ snapshot = createSnapshot(serializeObjects());
180
+ onChange(state());
181
+ },
182
+ destroy() {
183
+ listeners.forEach(([name, handler]) => eventTarget.off(name, handler));
184
+ pendingChanges = [];
185
+ stack.clear();
186
+ }
187
+ };
188
+ }
189
+ //#endregion
190
+ export { createHistory, describePendingChanges };
@@ -0,0 +1,27 @@
1
+ //#region src/history/history-stack.ts
2
+ function createHistoryStack(limit) {
3
+ const undoSteps = [];
4
+ const redoSteps = [];
5
+ function pushWithinLimit(steps, step) {
6
+ steps.push(step);
7
+ if (steps.length > limit) steps.shift();
8
+ }
9
+ return {
10
+ record(step) {
11
+ pushWithinLimit(undoSteps, step);
12
+ redoSteps.length = 0;
13
+ },
14
+ takeUndo: () => undoSteps.pop(),
15
+ takeRedo: () => redoSteps.pop(),
16
+ returnUndo: (step) => pushWithinLimit(undoSteps, step),
17
+ returnRedo: (step) => pushWithinLimit(redoSteps, step),
18
+ undoLabels: () => undoSteps.map((step) => step.label).reverse(),
19
+ redoLabels: () => redoSteps.map((step) => step.label).reverse(),
20
+ clear() {
21
+ undoSteps.length = 0;
22
+ redoSteps.length = 0;
23
+ }
24
+ };
25
+ }
26
+ //#endregion
27
+ exports.createHistoryStack = createHistoryStack;
@@ -0,0 +1,27 @@
1
+ //#region src/history/history-stack.ts
2
+ function createHistoryStack(limit) {
3
+ const undoSteps = [];
4
+ const redoSteps = [];
5
+ function pushWithinLimit(steps, step) {
6
+ steps.push(step);
7
+ if (steps.length > limit) steps.shift();
8
+ }
9
+ return {
10
+ record(step) {
11
+ pushWithinLimit(undoSteps, step);
12
+ redoSteps.length = 0;
13
+ },
14
+ takeUndo: () => undoSteps.pop(),
15
+ takeRedo: () => redoSteps.pop(),
16
+ returnUndo: (step) => pushWithinLimit(undoSteps, step),
17
+ returnRedo: (step) => pushWithinLimit(redoSteps, step),
18
+ undoLabels: () => undoSteps.map((step) => step.label).reverse(),
19
+ redoLabels: () => redoSteps.map((step) => step.label).reverse(),
20
+ clear() {
21
+ undoSteps.length = 0;
22
+ redoSteps.length = 0;
23
+ }
24
+ };
25
+ }
26
+ //#endregion
27
+ export { createHistoryStack };
@@ -0,0 +1,32 @@
1
+ //#region src/history/keyboard-shortcuts.ts
2
+ function isTypingInto(element) {
3
+ if (!(element instanceof HTMLElement)) return false;
4
+ if (element.isContentEditable) return true;
5
+ return [
6
+ "INPUT",
7
+ "TEXTAREA",
8
+ "SELECT"
9
+ ].includes(element.tagName);
10
+ }
11
+ function chooseAction(event) {
12
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return null;
13
+ const key = event.key.toLowerCase();
14
+ if (key === "z") return event.shiftKey ? "redo" : "undo";
15
+ if (key === "y" && event.ctrlKey && !event.shiftKey) return "redo";
16
+ return null;
17
+ }
18
+ function bindKeyboardShortcuts(engine, options = {}) {
19
+ const target = options.target ?? globalThis.window;
20
+ const handleKeyDown = (event) => {
21
+ const keyboardEvent = event;
22
+ if (keyboardEvent.defaultPrevented || isTypingInto(keyboardEvent.target)) return;
23
+ const action = chooseAction(keyboardEvent);
24
+ if (action === null) return;
25
+ keyboardEvent.preventDefault();
26
+ engine[action]().catch(() => void 0);
27
+ };
28
+ target.addEventListener("keydown", handleKeyDown);
29
+ return () => target.removeEventListener("keydown", handleKeyDown);
30
+ }
31
+ //#endregion
32
+ exports.bindKeyboardShortcuts = bindKeyboardShortcuts;
@@ -0,0 +1,10 @@
1
+ //#region src/history/keyboard-shortcuts.d.ts
2
+ export interface UndoRedoTarget {
3
+ undo(): Promise<boolean>;
4
+ redo(): Promise<boolean>;
5
+ }
6
+ export interface KeyboardShortcutOptions {
7
+ target?: EventTarget;
8
+ }
9
+ export declare function bindKeyboardShortcuts(engine: UndoRedoTarget, options?: KeyboardShortcutOptions): () => void;
10
+ //#endregion
@@ -0,0 +1,10 @@
1
+ //#region src/history/keyboard-shortcuts.d.ts
2
+ export interface UndoRedoTarget {
3
+ undo(): Promise<boolean>;
4
+ redo(): Promise<boolean>;
5
+ }
6
+ export interface KeyboardShortcutOptions {
7
+ target?: EventTarget;
8
+ }
9
+ export declare function bindKeyboardShortcuts(engine: UndoRedoTarget, options?: KeyboardShortcutOptions): () => void;
10
+ //#endregion
@@ -0,0 +1,32 @@
1
+ //#region src/history/keyboard-shortcuts.ts
2
+ function isTypingInto(element) {
3
+ if (!(element instanceof HTMLElement)) return false;
4
+ if (element.isContentEditable) return true;
5
+ return [
6
+ "INPUT",
7
+ "TEXTAREA",
8
+ "SELECT"
9
+ ].includes(element.tagName);
10
+ }
11
+ function chooseAction(event) {
12
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return null;
13
+ const key = event.key.toLowerCase();
14
+ if (key === "z") return event.shiftKey ? "redo" : "undo";
15
+ if (key === "y" && event.ctrlKey && !event.shiftKey) return "redo";
16
+ return null;
17
+ }
18
+ function bindKeyboardShortcuts(engine, options = {}) {
19
+ const target = options.target ?? globalThis.window;
20
+ const handleKeyDown = (event) => {
21
+ const keyboardEvent = event;
22
+ if (keyboardEvent.defaultPrevented || isTypingInto(keyboardEvent.target)) return;
23
+ const action = chooseAction(keyboardEvent);
24
+ if (action === null) return;
25
+ keyboardEvent.preventDefault();
26
+ engine[action]().catch(() => void 0);
27
+ };
28
+ target.addEventListener("keydown", handleKeyDown);
29
+ return () => target.removeEventListener("keydown", handleKeyDown);
30
+ }
31
+ //#endregion
32
+ export { bindKeyboardShortcuts };
@@ -0,0 +1,47 @@
1
+ //#region src/history/snapshot.ts
2
+ function createSnapshot(objects) {
3
+ const objectsById = /* @__PURE__ */ new Map();
4
+ const order = [];
5
+ for (const object of objects) {
6
+ const id = object.id ?? "";
7
+ objectsById.set(id, JSON.stringify(object));
8
+ order.push(id);
9
+ }
10
+ return {
11
+ objectsById,
12
+ order
13
+ };
14
+ }
15
+ function haveSameOrder(first, second) {
16
+ return first.length === second.length && first.every((id, index) => id === second[index]);
17
+ }
18
+ function diffSnapshots(previous, next) {
19
+ const before = /* @__PURE__ */ new Map();
20
+ const after = /* @__PURE__ */ new Map();
21
+ for (const [id, json] of next.objectsById) {
22
+ const previousJson = previous.objectsById.get(id);
23
+ if (previousJson === json) continue;
24
+ before.set(id, previousJson ?? null);
25
+ after.set(id, json);
26
+ }
27
+ for (const [id, json] of previous.objectsById) {
28
+ if (next.objectsById.has(id)) continue;
29
+ before.set(id, json);
30
+ after.set(id, null);
31
+ }
32
+ const orderChanged = !haveSameOrder(previous.order, next.order);
33
+ if (before.size === 0 && !orderChanged) return null;
34
+ return {
35
+ before: {
36
+ objects: before,
37
+ order: orderChanged ? previous.order : null
38
+ },
39
+ after: {
40
+ objects: after,
41
+ order: orderChanged ? next.order : null
42
+ }
43
+ };
44
+ }
45
+ //#endregion
46
+ exports.createSnapshot = createSnapshot;
47
+ exports.diffSnapshots = diffSnapshots;
@@ -0,0 +1,46 @@
1
+ //#region src/history/snapshot.ts
2
+ function createSnapshot(objects) {
3
+ const objectsById = /* @__PURE__ */ new Map();
4
+ const order = [];
5
+ for (const object of objects) {
6
+ const id = object.id ?? "";
7
+ objectsById.set(id, JSON.stringify(object));
8
+ order.push(id);
9
+ }
10
+ return {
11
+ objectsById,
12
+ order
13
+ };
14
+ }
15
+ function haveSameOrder(first, second) {
16
+ return first.length === second.length && first.every((id, index) => id === second[index]);
17
+ }
18
+ function diffSnapshots(previous, next) {
19
+ const before = /* @__PURE__ */ new Map();
20
+ const after = /* @__PURE__ */ new Map();
21
+ for (const [id, json] of next.objectsById) {
22
+ const previousJson = previous.objectsById.get(id);
23
+ if (previousJson === json) continue;
24
+ before.set(id, previousJson ?? null);
25
+ after.set(id, json);
26
+ }
27
+ for (const [id, json] of previous.objectsById) {
28
+ if (next.objectsById.has(id)) continue;
29
+ before.set(id, json);
30
+ after.set(id, null);
31
+ }
32
+ const orderChanged = !haveSameOrder(previous.order, next.order);
33
+ if (before.size === 0 && !orderChanged) return null;
34
+ return {
35
+ before: {
36
+ objects: before,
37
+ order: orderChanged ? previous.order : null
38
+ },
39
+ after: {
40
+ objects: after,
41
+ order: orderChanged ? next.order : null
42
+ }
43
+ };
44
+ }
45
+ //#endregion
46
+ export { createSnapshot, diffSnapshots };
package/dist/index.cjs CHANGED
@@ -3,8 +3,10 @@ const require_document_format = require("./document/document-format.cjs");
3
3
  const require_validate_document = require("./document/validate-document.cjs");
4
4
  const require_errors = require("./engine/errors.cjs");
5
5
  const require_create_document_engine = require("./engine/create-document-engine.cjs");
6
+ const require_keyboard_shortcuts = require("./history/keyboard-shortcuts.cjs");
6
7
  exports.CURRENT_SCHEMA_VERSION = require_document_format.CURRENT_SCHEMA_VERSION;
7
8
  exports.DocumentEngineError = require_errors.DocumentEngineError;
9
+ exports.bindKeyboardShortcuts = require_keyboard_shortcuts.bindKeyboardShortcuts;
8
10
  exports.createDocumentEngine = require_create_document_engine.createDocumentEngine;
9
11
  exports.isDocumentEngineError = require_errors.isDocumentEngineError;
10
12
  exports.validateDocument = require_validate_document.validateDocument;
package/dist/index.d.cts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, SerializedFabricObject } from "./document/document-format.cjs";
2
2
  import { NewDocumentOptions } from "./document/create-document.cjs";
3
3
  import { CustomObjectDefinition } from "./fabric/object-registry.cjs";
4
+ import { HistoryOptions, HistoryState } from "./history/create-history.cjs";
4
5
  import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, DocumentIssue, isDocumentEngineError } from "./engine/errors.cjs";
5
6
  import { Unsubscribe } from "./engine/event-emitter.cjs";
6
7
  import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, DocumentStorage, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.cjs";
7
8
  import { validateDocument } from "./document/validate-document.cjs";
8
- export { CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type LoadOptions, type NewDocumentOptions, type SerializedFabricObject, type Unsubscribe, createDocumentEngine, isDocumentEngineError, validateDocument };
9
+ import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.cjs";
10
+ export { CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type HistoryOptions, type HistoryState, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type SerializedFabricObject, type UndoRedoTarget, type Unsubscribe, bindKeyboardShortcuts, createDocumentEngine, isDocumentEngineError, validateDocument };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, SerializedFabricObject } from "./document/document-format.js";
2
2
  import { NewDocumentOptions } from "./document/create-document.js";
3
3
  import { CustomObjectDefinition } from "./fabric/object-registry.js";
4
+ import { HistoryOptions, HistoryState } from "./history/create-history.js";
4
5
  import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, DocumentIssue, isDocumentEngineError } from "./engine/errors.js";
5
6
  import { Unsubscribe } from "./engine/event-emitter.js";
6
7
  import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, DocumentStorage, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.js";
7
8
  import { validateDocument } from "./document/validate-document.js";
8
- export { CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type LoadOptions, type NewDocumentOptions, type SerializedFabricObject, type Unsubscribe, createDocumentEngine, isDocumentEngineError, validateDocument };
9
+ import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
10
+ export { CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type HistoryOptions, type HistoryState, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type SerializedFabricObject, type UndoRedoTarget, type Unsubscribe, bindKeyboardShortcuts, createDocumentEngine, isDocumentEngineError, validateDocument };
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ import { CURRENT_SCHEMA_VERSION } from "./document/document-format.js";
2
2
  import { validateDocument } from "./document/validate-document.js";
3
3
  import { DocumentEngineError, isDocumentEngineError } from "./engine/errors.js";
4
4
  import { createDocumentEngine } from "./engine/create-document-engine.js";
5
- export { CURRENT_SCHEMA_VERSION, DocumentEngineError, createDocumentEngine, isDocumentEngineError, validateDocument };
5
+ import { bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
6
+ export { CURRENT_SCHEMA_VERSION, DocumentEngineError, bindKeyboardShortcuts, createDocumentEngine, isDocumentEngineError, validateDocument };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabricjs-document-engine",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Turn an existing Fabric.js canvas into a dependable editable document: stable object ids, versioned save and load, custom object registration and clear load errors.",
5
5
  "keywords": [
6
6
  "fabric",