fabricjs-document-engine 0.0.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/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/document/create-document.cjs +13 -0
- package/dist/document/create-document.d.cts +6 -0
- package/dist/document/create-document.d.ts +6 -0
- package/dist/document/create-document.js +13 -0
- package/dist/document/document-format.cjs +4 -0
- package/dist/document/document-format.d.cts +31 -0
- package/dist/document/document-format.d.ts +31 -0
- package/dist/document/document-format.js +4 -0
- package/dist/document/ids.cjs +11 -0
- package/dist/document/ids.js +11 -0
- package/dist/document/validate-document.cjs +70 -0
- package/dist/document/validate-document.d.cts +4 -0
- package/dist/document/validate-document.d.ts +4 -0
- package/dist/document/validate-document.js +70 -0
- package/dist/engine/create-document-engine.cjs +218 -0
- package/dist/engine/create-document-engine.d.cts +56 -0
- package/dist/engine/create-document-engine.d.ts +56 -0
- package/dist/engine/create-document-engine.js +218 -0
- package/dist/engine/errors.cjs +17 -0
- package/dist/engine/errors.d.cts +21 -0
- package/dist/engine/errors.d.ts +21 -0
- package/dist/engine/errors.js +16 -0
- package/dist/engine/event-emitter.cjs +24 -0
- package/dist/engine/event-emitter.d.cts +3 -0
- package/dist/engine/event-emitter.d.ts +3 -0
- package/dist/engine/event-emitter.js +24 -0
- package/dist/fabric/fabric-adapter.cjs +68 -0
- package/dist/fabric/fabric-adapter.js +67 -0
- package/dist/fabric/object-ids.cjs +11 -0
- package/dist/fabric/object-ids.js +10 -0
- package/dist/fabric/object-registry.cjs +21 -0
- package/dist/fabric/object-registry.d.cts +9 -0
- package/dist/fabric/object-registry.d.ts +9 -0
- package/dist/fabric/object-registry.js +21 -0
- package/dist/fabric/walk-objects.cjs +29 -0
- package/dist/fabric/walk-objects.js +27 -0
- package/dist/index.cjs +10 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/package.json +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sohail Khan (resohail.me)
|
|
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,140 @@
|
|
|
1
|
+
# fabricjs-document-engine
|
|
2
|
+
|
|
3
|
+
Turn an existing [Fabric.js](https://fabricjs.com) canvas into a dependable editable document.
|
|
4
|
+
|
|
5
|
+
Fabric already draws objects, handles interaction and serializes to JSON. This package coordinates those pieces into a document workflow you can trust. You keep your own canvas, toolbar and UI.
|
|
6
|
+
|
|
7
|
+
- **Stable object ids.** Every object, including children of groups, gets an id that survives moving, styling, grouping, saving and reopening.
|
|
8
|
+
- **A versioned document format.** It records the schema version, canvas size, background, object order and your own metadata.
|
|
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
|
+
- **Custom objects.** Register your own Fabric classes and the extra properties they need to keep.
|
|
11
|
+
- **Your storage.** Plug in any backend with two functions. No hosted service is needed.
|
|
12
|
+
- **Fabric 6 and 7.** Every release is tested against both.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install fabricjs-document-engine fabric
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Canvas, Rect } from 'fabric';
|
|
24
|
+
import { createDocumentEngine } from 'fabricjs-document-engine';
|
|
25
|
+
|
|
26
|
+
const canvas = new Canvas('editor', { width: 800, height: 600 });
|
|
27
|
+
const engine = createDocumentEngine({ canvas });
|
|
28
|
+
|
|
29
|
+
canvas.add(new Rect({ width: 100, height: 80, fill: 'tomato' }));
|
|
30
|
+
|
|
31
|
+
const document = engine.toDocument();
|
|
32
|
+
localStorage.setItem(document.id, JSON.stringify(document));
|
|
33
|
+
|
|
34
|
+
await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Saving and loading through storage
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const engine = createDocumentEngine({
|
|
41
|
+
canvas,
|
|
42
|
+
storage: {
|
|
43
|
+
loadDocument: (id) => fetch(`/api/documents/${id}`).then((response) => response.json()),
|
|
44
|
+
saveDocument: (document) =>
|
|
45
|
+
fetch(`/api/documents/${document.id}`, { method: 'PUT', body: JSON.stringify(document) }).then(() => {}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await engine.load('project-42');
|
|
50
|
+
await engine.save();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Custom objects
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { Rect } from 'fabric';
|
|
57
|
+
|
|
58
|
+
class Sticker extends Rect {
|
|
59
|
+
static type = 'Sticker';
|
|
60
|
+
declare label: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const engine = createDocumentEngine({
|
|
64
|
+
canvas,
|
|
65
|
+
customObjects: [{ fabricClass: Sticker, properties: ['label'] }],
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
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
|
+
## Document format
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
interface FabricDocument {
|
|
75
|
+
schemaVersion: number;
|
|
76
|
+
id: string;
|
|
77
|
+
createdAt: string;
|
|
78
|
+
updatedAt: string;
|
|
79
|
+
fabricVersion?: string;
|
|
80
|
+
canvas: { width: number; height: number; background?: unknown };
|
|
81
|
+
objects: SerializedFabricObject[];
|
|
82
|
+
metadata: Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Keep project data such as titles, owners and tags in `metadata` with `engine.updateMetadata()`, rather than on Fabric objects.
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
| Member | What it does |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `createDocumentEngine({ canvas, storage?, customObjects?, document? })` | Connects the engine to your canvas. |
|
|
93
|
+
| `engine.toDocument()` | Serializes the canvas into the versioned document format. |
|
|
94
|
+
| `engine.loadDocument(document, { restoreCanvasSize? })` | Validates and loads a document. Resolves when the objects are on the canvas. |
|
|
95
|
+
| `engine.load(id)` / `engine.save()` | Reads from or writes to your storage adapter. |
|
|
96
|
+
| `engine.newDocument({ id?, metadata? })` | Clears the canvas and starts a fresh document. |
|
|
97
|
+
| `engine.getDocumentInfo()` | Returns the current document's id, dates and metadata. |
|
|
98
|
+
| `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
|
|
99
|
+
| `engine.getObjectById(id)` | Finds any object by id, including objects inside groups. |
|
|
100
|
+
| `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. |
|
|
102
|
+
| `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
|
|
103
|
+
| `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
Every failure is a `DocumentEngineError` with a `code` you can switch on:
|
|
108
|
+
|
|
109
|
+
| Code | Meaning |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `INVALID_DOCUMENT` | The document shape is wrong. `error.issues` lists each path, such as `objects[3].objects[1].type`. |
|
|
112
|
+
| `UNSUPPORTED_SCHEMA` | The document was written by a newer version of this package. |
|
|
113
|
+
| `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
|
|
114
|
+
| `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
|
+
| `LOAD_ABORTED` | A newer load started before this one finished. |
|
|
116
|
+
| `SAVE_FAILED` | Your storage adapter rejected the save. |
|
|
117
|
+
| `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
|
|
118
|
+
| `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
|
|
119
|
+
| `ENGINE_DESTROYED` | The engine was used after `destroy()`. |
|
|
120
|
+
|
|
121
|
+
A failed load never clears or half-fills your canvas.
|
|
122
|
+
|
|
123
|
+
## Roadmap
|
|
124
|
+
|
|
125
|
+
| Version | Focus |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| 0.1 | Document foundation: ids, save and load, validation, custom objects |
|
|
128
|
+
| 0.2 | Undo and redo with transactions |
|
|
129
|
+
| 0.3 | Safe saving: dirty state, autosave, stale-response protection |
|
|
130
|
+
| 0.4 | Assets and fonts |
|
|
131
|
+
| 0.5 | Recovery after a refresh or crash |
|
|
132
|
+
| 0.6 | PNG, JPEG, SVG and JSON export with preflight checks |
|
|
133
|
+
| 0.7 | Named versions and schema migrations |
|
|
134
|
+
| 0.8 | React adapter and examples |
|
|
135
|
+
| 0.9 | Hardening and benchmarks |
|
|
136
|
+
| 1.0 | Stable API |
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const require_ids = require("./ids.cjs");
|
|
2
|
+
//#region src/document/create-document.ts
|
|
3
|
+
function createDocumentInfo(options = {}) {
|
|
4
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5
|
+
return {
|
|
6
|
+
id: options.id ?? require_ids.createId(),
|
|
7
|
+
createdAt: now,
|
|
8
|
+
updatedAt: now,
|
|
9
|
+
metadata: { ...options.metadata }
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
exports.createDocumentInfo = createDocumentInfo;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createId } from "./ids.js";
|
|
2
|
+
//#region src/document/create-document.ts
|
|
3
|
+
function createDocumentInfo(options = {}) {
|
|
4
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5
|
+
return {
|
|
6
|
+
id: options.id ?? createId(),
|
|
7
|
+
createdAt: now,
|
|
8
|
+
updatedAt: now,
|
|
9
|
+
metadata: { ...options.metadata }
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { createDocumentInfo };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/document/document-format.d.ts
|
|
2
|
+
export declare const CURRENT_SCHEMA_VERSION = 1;
|
|
3
|
+
export interface SerializedFabricObject {
|
|
4
|
+
type: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
objects?: SerializedFabricObject[];
|
|
7
|
+
clipPath?: SerializedFabricObject;
|
|
8
|
+
[property: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface DocumentCanvas {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
background?: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface FabricDocument {
|
|
16
|
+
schemaVersion: number;
|
|
17
|
+
id: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
fabricVersion?: string;
|
|
21
|
+
canvas: DocumentCanvas;
|
|
22
|
+
objects: SerializedFabricObject[];
|
|
23
|
+
metadata: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface DocumentInfo {
|
|
26
|
+
id: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
metadata: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/document/document-format.d.ts
|
|
2
|
+
export declare const CURRENT_SCHEMA_VERSION = 1;
|
|
3
|
+
export interface SerializedFabricObject {
|
|
4
|
+
type: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
objects?: SerializedFabricObject[];
|
|
7
|
+
clipPath?: SerializedFabricObject;
|
|
8
|
+
[property: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface DocumentCanvas {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
background?: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface FabricDocument {
|
|
16
|
+
schemaVersion: number;
|
|
17
|
+
id: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
fabricVersion?: string;
|
|
21
|
+
canvas: DocumentCanvas;
|
|
22
|
+
objects: SerializedFabricObject[];
|
|
23
|
+
metadata: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface DocumentInfo {
|
|
26
|
+
id: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
metadata: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/document/ids.ts
|
|
2
|
+
let fallbackCounter = 0;
|
|
3
|
+
function createId() {
|
|
4
|
+
const cryptoApi = globalThis.crypto;
|
|
5
|
+
if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
|
|
6
|
+
fallbackCounter += 1;
|
|
7
|
+
const randomPart = Math.random().toString(36).slice(2, 10);
|
|
8
|
+
return `${Date.now().toString(36)}-${fallbackCounter.toString(36)}-${randomPart}`;
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
exports.createId = createId;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/document/ids.ts
|
|
2
|
+
let fallbackCounter = 0;
|
|
3
|
+
function createId() {
|
|
4
|
+
const cryptoApi = globalThis.crypto;
|
|
5
|
+
if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
|
|
6
|
+
fallbackCounter += 1;
|
|
7
|
+
const randomPart = Math.random().toString(36).slice(2, 10);
|
|
8
|
+
return `${Date.now().toString(36)}-${fallbackCounter.toString(36)}-${randomPart}`;
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { createId };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require("./document-format.cjs");
|
|
2
|
+
//#region src/document/validate-document.ts
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function isPositiveNumber(value) {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
8
|
+
}
|
|
9
|
+
function invalid(path, message) {
|
|
10
|
+
return {
|
|
11
|
+
code: "INVALID_DOCUMENT",
|
|
12
|
+
path,
|
|
13
|
+
message
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function validateObjectList(list, path, issues) {
|
|
17
|
+
if (!Array.isArray(list)) {
|
|
18
|
+
issues.push(invalid(path, "must be an array of objects"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const pending = list.map((value, index) => ({
|
|
22
|
+
value,
|
|
23
|
+
path: `${path}[${index}]`
|
|
24
|
+
}));
|
|
25
|
+
while (pending.length > 0) {
|
|
26
|
+
const { value, path: objectPath } = pending.pop();
|
|
27
|
+
if (!isPlainObject(value)) {
|
|
28
|
+
issues.push(invalid(objectPath, "must be an object"));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (typeof value.type !== "string" || value.type.length === 0) issues.push(invalid(`${objectPath}.type`, "must be a non-empty string"));
|
|
32
|
+
if (value.id !== void 0 && typeof value.id !== "string") issues.push(invalid(`${objectPath}.id`, "must be a string when present"));
|
|
33
|
+
if (value.objects !== void 0) {
|
|
34
|
+
if (!Array.isArray(value.objects)) issues.push(invalid(`${objectPath}.objects`, "must be an array of objects"));
|
|
35
|
+
else value.objects.forEach((child, index) => {
|
|
36
|
+
pending.push({
|
|
37
|
+
value: child,
|
|
38
|
+
path: `${objectPath}.objects[${index}]`
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (value.clipPath !== void 0) pending.push({
|
|
43
|
+
value: value.clipPath,
|
|
44
|
+
path: `${objectPath}.clipPath`
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function validateDocument(value) {
|
|
49
|
+
if (!isPlainObject(value)) return [invalid("", "document must be an object")];
|
|
50
|
+
if (typeof value.schemaVersion !== "number" || !Number.isInteger(value.schemaVersion)) return [invalid("schemaVersion", "must be an integer")];
|
|
51
|
+
if (value.schemaVersion > 1 || value.schemaVersion < 1) return [{
|
|
52
|
+
code: "UNSUPPORTED_SCHEMA",
|
|
53
|
+
path: "schemaVersion",
|
|
54
|
+
message: `schema version ${value.schemaVersion} is not supported, expected 1`
|
|
55
|
+
}];
|
|
56
|
+
const issues = [];
|
|
57
|
+
if (typeof value.id !== "string" || value.id.length === 0) issues.push(invalid("id", "must be a non-empty string"));
|
|
58
|
+
if (typeof value.createdAt !== "string") issues.push(invalid("createdAt", "must be an ISO date string"));
|
|
59
|
+
if (typeof value.updatedAt !== "string") issues.push(invalid("updatedAt", "must be an ISO date string"));
|
|
60
|
+
if (!isPlainObject(value.metadata)) issues.push(invalid("metadata", "must be an object"));
|
|
61
|
+
if (!isPlainObject(value.canvas)) issues.push(invalid("canvas", "must be an object"));
|
|
62
|
+
else {
|
|
63
|
+
if (!isPositiveNumber(value.canvas.width)) issues.push(invalid("canvas.width", "must be a positive number"));
|
|
64
|
+
if (!isPositiveNumber(value.canvas.height)) issues.push(invalid("canvas.height", "must be a positive number"));
|
|
65
|
+
}
|
|
66
|
+
validateObjectList(value.objects, "objects", issues);
|
|
67
|
+
return issues;
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
exports.validateDocument = validateDocument;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import "./document-format.js";
|
|
2
|
+
//#region src/document/validate-document.ts
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function isPositiveNumber(value) {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
8
|
+
}
|
|
9
|
+
function invalid(path, message) {
|
|
10
|
+
return {
|
|
11
|
+
code: "INVALID_DOCUMENT",
|
|
12
|
+
path,
|
|
13
|
+
message
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function validateObjectList(list, path, issues) {
|
|
17
|
+
if (!Array.isArray(list)) {
|
|
18
|
+
issues.push(invalid(path, "must be an array of objects"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const pending = list.map((value, index) => ({
|
|
22
|
+
value,
|
|
23
|
+
path: `${path}[${index}]`
|
|
24
|
+
}));
|
|
25
|
+
while (pending.length > 0) {
|
|
26
|
+
const { value, path: objectPath } = pending.pop();
|
|
27
|
+
if (!isPlainObject(value)) {
|
|
28
|
+
issues.push(invalid(objectPath, "must be an object"));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (typeof value.type !== "string" || value.type.length === 0) issues.push(invalid(`${objectPath}.type`, "must be a non-empty string"));
|
|
32
|
+
if (value.id !== void 0 && typeof value.id !== "string") issues.push(invalid(`${objectPath}.id`, "must be a string when present"));
|
|
33
|
+
if (value.objects !== void 0) {
|
|
34
|
+
if (!Array.isArray(value.objects)) issues.push(invalid(`${objectPath}.objects`, "must be an array of objects"));
|
|
35
|
+
else value.objects.forEach((child, index) => {
|
|
36
|
+
pending.push({
|
|
37
|
+
value: child,
|
|
38
|
+
path: `${objectPath}.objects[${index}]`
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (value.clipPath !== void 0) pending.push({
|
|
43
|
+
value: value.clipPath,
|
|
44
|
+
path: `${objectPath}.clipPath`
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function validateDocument(value) {
|
|
49
|
+
if (!isPlainObject(value)) return [invalid("", "document must be an object")];
|
|
50
|
+
if (typeof value.schemaVersion !== "number" || !Number.isInteger(value.schemaVersion)) return [invalid("schemaVersion", "must be an integer")];
|
|
51
|
+
if (value.schemaVersion > 1 || value.schemaVersion < 1) return [{
|
|
52
|
+
code: "UNSUPPORTED_SCHEMA",
|
|
53
|
+
path: "schemaVersion",
|
|
54
|
+
message: `schema version ${value.schemaVersion} is not supported, expected 1`
|
|
55
|
+
}];
|
|
56
|
+
const issues = [];
|
|
57
|
+
if (typeof value.id !== "string" || value.id.length === 0) issues.push(invalid("id", "must be a non-empty string"));
|
|
58
|
+
if (typeof value.createdAt !== "string") issues.push(invalid("createdAt", "must be an ISO date string"));
|
|
59
|
+
if (typeof value.updatedAt !== "string") issues.push(invalid("updatedAt", "must be an ISO date string"));
|
|
60
|
+
if (!isPlainObject(value.metadata)) issues.push(invalid("metadata", "must be an object"));
|
|
61
|
+
if (!isPlainObject(value.canvas)) issues.push(invalid("canvas", "must be an object"));
|
|
62
|
+
else {
|
|
63
|
+
if (!isPositiveNumber(value.canvas.width)) issues.push(invalid("canvas.width", "must be a positive number"));
|
|
64
|
+
if (!isPositiveNumber(value.canvas.height)) issues.push(invalid("canvas.height", "must be a positive number"));
|
|
65
|
+
}
|
|
66
|
+
validateObjectList(value.objects, "objects", issues);
|
|
67
|
+
return issues;
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { validateDocument };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require("../document/document-format.cjs");
|
|
2
|
+
const require_ids = require("../document/ids.cjs");
|
|
3
|
+
const require_create_document = require("../document/create-document.cjs");
|
|
4
|
+
const require_validate_document = require("../document/validate-document.cjs");
|
|
5
|
+
const require_walk_objects = require("../fabric/walk-objects.cjs");
|
|
6
|
+
const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
|
|
7
|
+
const require_object_ids = require("../fabric/object-ids.cjs");
|
|
8
|
+
const require_errors = require("./errors.cjs");
|
|
9
|
+
const require_object_registry = require("../fabric/object-registry.cjs");
|
|
10
|
+
const require_event_emitter = require("./event-emitter.cjs");
|
|
11
|
+
//#region src/engine/create-document-engine.ts
|
|
12
|
+
function describeDocumentId(value) {
|
|
13
|
+
const id = value?.id;
|
|
14
|
+
return typeof id === "string" ? id : void 0;
|
|
15
|
+
}
|
|
16
|
+
function createDocumentEngine(options) {
|
|
17
|
+
const { canvas, storage } = options;
|
|
18
|
+
const registry = require_object_registry.createObjectRegistry(options.customObjects);
|
|
19
|
+
const events = require_event_emitter.createEventEmitter();
|
|
20
|
+
const objectsById = /* @__PURE__ */ new Map();
|
|
21
|
+
let documentInfo = require_create_document.createDocumentInfo(options.document);
|
|
22
|
+
let activeLoad = null;
|
|
23
|
+
let destroyed = false;
|
|
24
|
+
function ensureUsable() {
|
|
25
|
+
if (destroyed) throw new require_errors.DocumentEngineError("ENGINE_DESTROYED", "This document engine was destroyed");
|
|
26
|
+
}
|
|
27
|
+
function indexObjectTree(root) {
|
|
28
|
+
require_walk_objects.walkObjects([root], (object) => {
|
|
29
|
+
let id = require_object_ids.readObjectId(object);
|
|
30
|
+
const owner = id === void 0 ? void 0 : objectsById.get(id);
|
|
31
|
+
if (id === void 0 || owner !== void 0 && owner !== object) {
|
|
32
|
+
id = require_ids.createId();
|
|
33
|
+
require_object_ids.writeObjectId(object, id);
|
|
34
|
+
}
|
|
35
|
+
objectsById.set(id, object);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function removeObjectTreeFromIndex(root) {
|
|
39
|
+
require_walk_objects.walkObjects([root], (object) => {
|
|
40
|
+
const id = require_object_ids.readObjectId(object);
|
|
41
|
+
if (id !== void 0 && objectsById.get(id) === object) objectsById.delete(id);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function rebuildIndex() {
|
|
45
|
+
objectsById.clear();
|
|
46
|
+
canvas.getObjects().forEach(indexObjectTree);
|
|
47
|
+
}
|
|
48
|
+
const handleObjectAdded = ({ target }) => indexObjectTree(target);
|
|
49
|
+
const handleObjectRemoved = ({ target }) => removeObjectTreeFromIndex(target);
|
|
50
|
+
canvas.on("object:added", handleObjectAdded);
|
|
51
|
+
canvas.on("object:removed", handleObjectRemoved);
|
|
52
|
+
rebuildIndex();
|
|
53
|
+
function toDocument() {
|
|
54
|
+
ensureUsable();
|
|
55
|
+
rebuildIndex();
|
|
56
|
+
const serialized = require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude());
|
|
57
|
+
documentInfo = {
|
|
58
|
+
...documentInfo,
|
|
59
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
60
|
+
};
|
|
61
|
+
const document = {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
id: documentInfo.id,
|
|
64
|
+
createdAt: documentInfo.createdAt,
|
|
65
|
+
updatedAt: documentInfo.updatedAt,
|
|
66
|
+
canvas: {
|
|
67
|
+
width: canvas.getWidth(),
|
|
68
|
+
height: canvas.getHeight()
|
|
69
|
+
},
|
|
70
|
+
objects: serialized.objects,
|
|
71
|
+
metadata: { ...documentInfo.metadata }
|
|
72
|
+
};
|
|
73
|
+
if (serialized.version !== void 0) document.fabricVersion = serialized.version;
|
|
74
|
+
if (serialized.background !== void 0) document.canvas.background = serialized.background;
|
|
75
|
+
return document;
|
|
76
|
+
}
|
|
77
|
+
function toLoadError(error, signal) {
|
|
78
|
+
if (require_errors.isDocumentEngineError(error)) return error;
|
|
79
|
+
if (signal.aborted) return new require_errors.DocumentEngineError("LOAD_ABORTED", "Loading stopped because a newer load started", { cause: error });
|
|
80
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return new require_errors.DocumentEngineError("LOAD_FAILED", `Fabric could not load the document: ${reason}`, { cause: error });
|
|
82
|
+
}
|
|
83
|
+
function checkDocument(input) {
|
|
84
|
+
const issues = require_validate_document.validateDocument(input);
|
|
85
|
+
if (issues.length > 0) {
|
|
86
|
+
const summary = issues.map((issue) => `${issue.path || "document"} ${issue.message}`).join("; ");
|
|
87
|
+
throw new require_errors.DocumentEngineError(issues[0].code, `The document is not valid: ${summary}`, { issues });
|
|
88
|
+
}
|
|
89
|
+
const document = input;
|
|
90
|
+
const unknownTypes = registry.findUnknownTypes(require_walk_objects.collectSerializedTypes(document.objects));
|
|
91
|
+
if (unknownTypes.length > 0) throw new require_errors.DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The document uses object types that are not registered: ${unknownTypes.join(", ")}. Register them with customObjects or engine.registerObject before loading.`, { unknownTypes });
|
|
92
|
+
return document;
|
|
93
|
+
}
|
|
94
|
+
async function loadDocument(input, loadOptions = {}) {
|
|
95
|
+
ensureUsable();
|
|
96
|
+
activeLoad?.abort();
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
activeLoad = controller;
|
|
99
|
+
events.emit("load:start", { documentId: describeDocumentId(input) });
|
|
100
|
+
try {
|
|
101
|
+
const document = checkDocument(input);
|
|
102
|
+
await require_fabric_adapter.loadIntoCanvas(canvas, {
|
|
103
|
+
version: document.fabricVersion,
|
|
104
|
+
background: document.canvas.background,
|
|
105
|
+
objects: document.objects
|
|
106
|
+
}, controller.signal);
|
|
107
|
+
if (controller.signal.aborted) throw new Error("aborted");
|
|
108
|
+
if (loadOptions.restoreCanvasSize ?? true) canvas.setDimensions({
|
|
109
|
+
width: document.canvas.width,
|
|
110
|
+
height: document.canvas.height
|
|
111
|
+
});
|
|
112
|
+
documentInfo = {
|
|
113
|
+
id: document.id,
|
|
114
|
+
createdAt: document.createdAt,
|
|
115
|
+
updatedAt: document.updatedAt,
|
|
116
|
+
metadata: { ...document.metadata }
|
|
117
|
+
};
|
|
118
|
+
rebuildIndex();
|
|
119
|
+
canvas.requestRenderAll();
|
|
120
|
+
events.emit("load:success", { document });
|
|
121
|
+
return document;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const engineError = toLoadError(error, controller.signal);
|
|
124
|
+
events.emit("load:error", { error: engineError });
|
|
125
|
+
throw engineError;
|
|
126
|
+
} finally {
|
|
127
|
+
if (activeLoad === controller) activeLoad = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function requireStorage() {
|
|
131
|
+
if (!storage) throw new require_errors.DocumentEngineError("STORAGE_MISSING", "Pass a storage adapter to createDocumentEngine to use load and save");
|
|
132
|
+
return storage;
|
|
133
|
+
}
|
|
134
|
+
async function load(documentId, loadOptions) {
|
|
135
|
+
ensureUsable();
|
|
136
|
+
const source = requireStorage();
|
|
137
|
+
let stored;
|
|
138
|
+
try {
|
|
139
|
+
stored = await source.loadDocument(documentId);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
142
|
+
const engineError = new require_errors.DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
|
|
143
|
+
events.emit("load:error", { error: engineError });
|
|
144
|
+
throw engineError;
|
|
145
|
+
}
|
|
146
|
+
return loadDocument(stored, loadOptions);
|
|
147
|
+
}
|
|
148
|
+
async function save() {
|
|
149
|
+
ensureUsable();
|
|
150
|
+
const target = requireStorage();
|
|
151
|
+
const document = toDocument();
|
|
152
|
+
events.emit("save:start", { document });
|
|
153
|
+
try {
|
|
154
|
+
await target.saveDocument(document);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
157
|
+
const engineError = new require_errors.DocumentEngineError("SAVE_FAILED", `Storage could not save the document: ${reason}`, { cause: error });
|
|
158
|
+
events.emit("save:error", { error: engineError });
|
|
159
|
+
throw engineError;
|
|
160
|
+
}
|
|
161
|
+
events.emit("save:success", { document });
|
|
162
|
+
return document;
|
|
163
|
+
}
|
|
164
|
+
function newDocument(newOptions) {
|
|
165
|
+
ensureUsable();
|
|
166
|
+
activeLoad?.abort();
|
|
167
|
+
canvas.clear();
|
|
168
|
+
objectsById.clear();
|
|
169
|
+
documentInfo = require_create_document.createDocumentInfo(newOptions);
|
|
170
|
+
}
|
|
171
|
+
function getObjectById(id) {
|
|
172
|
+
ensureUsable();
|
|
173
|
+
const indexed = objectsById.get(id);
|
|
174
|
+
if (indexed !== void 0) return indexed;
|
|
175
|
+
rebuildIndex();
|
|
176
|
+
return objectsById.get(id);
|
|
177
|
+
}
|
|
178
|
+
function destroy() {
|
|
179
|
+
if (destroyed) return;
|
|
180
|
+
destroyed = true;
|
|
181
|
+
activeLoad?.abort();
|
|
182
|
+
canvas.off("object:added", handleObjectAdded);
|
|
183
|
+
canvas.off("object:removed", handleObjectRemoved);
|
|
184
|
+
objectsById.clear();
|
|
185
|
+
events.clear();
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
canvas,
|
|
189
|
+
getDocumentInfo: () => ({
|
|
190
|
+
...documentInfo,
|
|
191
|
+
metadata: { ...documentInfo.metadata }
|
|
192
|
+
}),
|
|
193
|
+
updateMetadata(changes) {
|
|
194
|
+
ensureUsable();
|
|
195
|
+
documentInfo = {
|
|
196
|
+
...documentInfo,
|
|
197
|
+
metadata: {
|
|
198
|
+
...documentInfo.metadata,
|
|
199
|
+
...changes
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
newDocument,
|
|
204
|
+
toDocument,
|
|
205
|
+
loadDocument,
|
|
206
|
+
load,
|
|
207
|
+
save,
|
|
208
|
+
registerObject(definition) {
|
|
209
|
+
ensureUsable();
|
|
210
|
+
registry.register(definition);
|
|
211
|
+
},
|
|
212
|
+
getObjectById,
|
|
213
|
+
on: (name, handler) => events.on(name, handler),
|
|
214
|
+
destroy
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
exports.createDocumentEngine = createDocumentEngine;
|