fabricjs-document-engine 1.1.0 → 1.3.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 +70 -1
- package/README.zh-CN.md +71 -1
- package/dist/assets/asset-pipeline.cjs +10 -8
- package/dist/assets/asset-pipeline.d.cts +1 -1
- package/dist/assets/asset-pipeline.d.ts +1 -1
- package/dist/assets/asset-pipeline.js +10 -8
- package/dist/assets/image-check.cjs +4 -3
- package/dist/assets/image-check.d.cts +1 -1
- package/dist/assets/image-check.d.ts +1 -1
- package/dist/assets/image-check.js +4 -3
- package/dist/commands/clipboard.cjs +5 -0
- package/dist/commands/clipboard.js +5 -0
- package/dist/commands/text-edit.cjs +223 -0
- package/dist/commands/text-edit.d.cts +23 -0
- package/dist/commands/text-edit.d.ts +23 -0
- package/dist/commands/text-edit.js +218 -0
- package/dist/document/document-format.d.cts +15 -0
- package/dist/document/document-format.d.ts +15 -0
- package/dist/document/validate-document.cjs +8 -0
- package/dist/document/validate-document.js +8 -0
- package/dist/engine/create-document-engine.cjs +115 -21
- package/dist/engine/create-document-engine.d.cts +24 -6
- package/dist/engine/create-document-engine.d.ts +24 -6
- package/dist/engine/create-document-engine.js +116 -22
- package/dist/engine/errors.d.cts +1 -1
- package/dist/engine/errors.d.ts +1 -1
- package/dist/export/export-options.cjs +3 -0
- package/dist/export/export-options.d.cts +6 -0
- package/dist/export/export-options.d.ts +6 -0
- package/dist/export/export-options.js +3 -0
- package/dist/export/preflight-export.d.cts +1 -1
- package/dist/export/preflight-export.d.ts +1 -1
- package/dist/export/render-export.cjs +42 -4
- package/dist/export/render-export.js +43 -5
- package/dist/export/svg/clip-masks.cjs +39 -0
- package/dist/export/svg/clip-masks.js +37 -0
- package/dist/export/svg/embed-assets.cjs +1 -0
- package/dist/export/svg/embed-assets.js +1 -0
- package/dist/export/svg/raster-object.cjs +82 -0
- package/dist/export/svg/raster-object.js +81 -0
- package/dist/{pdf → export/svg}/text-decorations.cjs +3 -3
- package/dist/{pdf → export/svg}/text-decorations.js +3 -3
- package/dist/export/svg/text-on-path.cjs +0 -10
- package/dist/export/svg/text-on-path.js +1 -10
- package/dist/fabric/fabric-adapter.cjs +5 -3
- package/dist/fabric/fabric-adapter.js +5 -3
- package/dist/fabric/object-registry.cjs +7 -1
- package/dist/fabric/object-registry.js +7 -1
- package/dist/fabric/page-state.cjs +69 -0
- package/dist/fabric/page-state.js +64 -0
- package/dist/filters/filter-worker.cjs +281 -0
- package/dist/filters/filter-worker.d.cts +26 -0
- package/dist/filters/filter-worker.d.ts +26 -0
- package/dist/filters/filter-worker.js +281 -0
- package/dist/filters/pipeline.cjs +91 -0
- package/dist/filters/pipeline.d.cts +35 -0
- package/dist/filters/pipeline.d.ts +35 -0
- package/dist/filters/pipeline.js +87 -0
- package/dist/filters/worker-entry.cjs +78 -0
- package/dist/filters/worker-entry.d.cts +29 -0
- package/dist/filters/worker-entry.d.ts +29 -0
- package/dist/filters/worker-entry.js +78 -0
- package/dist/filters.cjs +9 -0
- package/dist/filters.d.cts +3 -0
- package/dist/filters.d.ts +3 -0
- package/dist/filters.js +3 -0
- package/dist/history/apply-state.cjs +19 -2
- package/dist/history/apply-state.js +19 -2
- package/dist/history/create-history.cjs +53 -13
- package/dist/history/create-history.d.cts +8 -0
- package/dist/history/create-history.d.ts +8 -0
- package/dist/history/create-history.js +53 -13
- package/dist/history/snapshot.cjs +10 -1
- package/dist/history/snapshot.js +10 -2
- package/dist/import/svg-import.cjs +102 -0
- package/dist/import/svg-import.d.cts +1 -0
- package/dist/import/svg-import.d.ts +1 -0
- package/dist/import/svg-import.js +103 -1
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/migrations/migrate-document.cjs +2 -1
- package/dist/migrations/migrate-document.js +2 -1
- package/dist/pdf/export-pdf.cjs +8 -67
- package/dist/pdf/export-pdf.js +8 -67
- package/dist/performance/batch.cjs +31 -0
- package/dist/performance/batch.d.cts +4 -0
- package/dist/performance/batch.d.ts +4 -0
- package/dist/performance/batch.js +31 -0
- package/dist/performance/dirty-regions.cjs +378 -0
- package/dist/performance/dirty-regions.d.cts +23 -0
- package/dist/performance/dirty-regions.d.ts +23 -0
- package/dist/performance/dirty-regions.js +378 -0
- package/dist/performance/monitor.cjs +60 -0
- package/dist/performance/monitor.d.cts +20 -0
- package/dist/performance/monitor.d.ts +20 -0
- package/dist/performance/monitor.js +60 -0
- package/dist/performance/spatial-index.cjs +78 -0
- package/dist/performance/spatial-index.d.cts +21 -0
- package/dist/performance/spatial-index.d.ts +21 -0
- package/dist/performance/spatial-index.js +78 -0
- package/dist/performance.cjs +9 -0
- package/dist/performance.d.cts +5 -0
- package/dist/performance.d.ts +5 -0
- package/dist/performance.js +5 -0
- package/dist/recovery/recovery-controller.cjs +148 -29
- package/dist/recovery/recovery-controller.d.cts +8 -0
- package/dist/recovery/recovery-controller.d.ts +8 -0
- package/dist/recovery/recovery-controller.js +148 -29
- package/dist/security/content-limits.cjs +43 -3
- package/dist/security/content-limits.d.cts +8 -0
- package/dist/security/content-limits.d.ts +8 -0
- package/dist/security/content-limits.js +39 -4
- package/dist/text/bounded-textbox.cjs +306 -0
- package/dist/text/bounded-textbox.d.cts +53 -0
- package/dist/text/bounded-textbox.d.ts +53 -0
- package/dist/text/bounded-textbox.js +306 -0
- package/dist/text/mobile-input.cjs +146 -0
- package/dist/text/mobile-input.d.cts +19 -0
- package/dist/text/mobile-input.d.ts +19 -0
- package/dist/text/mobile-input.js +145 -0
- package/dist/text/register.cjs +17 -0
- package/dist/text/register.d.cts +5 -0
- package/dist/text/register.d.ts +5 -0
- package/dist/text/register.js +16 -0
- package/dist/text/shaping.cjs +125 -0
- package/dist/text/shaping.d.cts +28 -0
- package/dist/text/shaping.d.ts +28 -0
- package/dist/text/shaping.js +121 -0
- package/dist/text/vertical-orientation.cjs +192 -0
- package/dist/text/vertical-orientation.d.cts +10 -0
- package/dist/text/vertical-orientation.d.ts +10 -0
- package/dist/text/vertical-orientation.js +190 -0
- package/dist/text/vertical-text.cjs +336 -0
- package/dist/text/vertical-text.d.cts +54 -0
- package/dist/text/vertical-text.d.ts +54 -0
- package/dist/text/vertical-text.js +336 -0
- package/dist/text.cjs +21 -0
- package/dist/text.d.cts +7 -0
- package/dist/text.d.ts +7 -0
- package/dist/text.js +7 -0
- package/dist/util/concurrency.cjs +16 -3
- package/dist/util/concurrency.js +16 -3
- package/package.json +31 -1
- package/schema/document-v1.schema.json +5 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
//#region src/performance/spatial-index.ts
|
|
2
|
+
const OFFSET = 1 << 20;
|
|
3
|
+
const keyOf = (column, row) => (column + OFFSET) * 2 ** 21 + (row + OFFSET);
|
|
4
|
+
function createSpatialIndex(options = {}) {
|
|
5
|
+
const cellSize = options.cellSize ?? 256;
|
|
6
|
+
const maxCells = options.maxCellsPerItem ?? 64;
|
|
7
|
+
const cells = /* @__PURE__ */ new Map();
|
|
8
|
+
const placed = /* @__PURE__ */ new Map();
|
|
9
|
+
const large = /* @__PURE__ */ new Set();
|
|
10
|
+
const span = (bounds) => [
|
|
11
|
+
Math.floor(bounds.left / cellSize),
|
|
12
|
+
Math.floor(bounds.top / cellSize),
|
|
13
|
+
Math.floor((bounds.left + Math.max(0, bounds.width)) / cellSize),
|
|
14
|
+
Math.floor((bounds.top + Math.max(0, bounds.height)) / cellSize)
|
|
15
|
+
];
|
|
16
|
+
function remove(item) {
|
|
17
|
+
const where = placed.get(item);
|
|
18
|
+
if (where === void 0) return false;
|
|
19
|
+
placed.delete(item);
|
|
20
|
+
if (where === "large") {
|
|
21
|
+
large.delete(item);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
for (const key of where) {
|
|
25
|
+
const bucket = cells.get(key);
|
|
26
|
+
bucket.delete(item);
|
|
27
|
+
if (bucket.size === 0) cells.delete(key);
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
set(item, bounds) {
|
|
33
|
+
remove(item);
|
|
34
|
+
const [left, top, right, bottom] = span(bounds);
|
|
35
|
+
const count = (right - left + 1) * (bottom - top + 1);
|
|
36
|
+
if (!Number.isFinite(count) || count > maxCells) {
|
|
37
|
+
large.add(item);
|
|
38
|
+
placed.set(item, "large");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const keys = [];
|
|
42
|
+
for (let column = left; column <= right; column += 1) for (let row = top; row <= bottom; row += 1) {
|
|
43
|
+
const key = keyOf(column, row);
|
|
44
|
+
let bucket = cells.get(key);
|
|
45
|
+
if (!bucket) cells.set(key, bucket = /* @__PURE__ */ new Set());
|
|
46
|
+
bucket.add(item);
|
|
47
|
+
keys.push(key);
|
|
48
|
+
}
|
|
49
|
+
placed.set(item, keys);
|
|
50
|
+
},
|
|
51
|
+
delete: remove,
|
|
52
|
+
has: (item) => placed.has(item),
|
|
53
|
+
query(bounds) {
|
|
54
|
+
const found = new Set(large);
|
|
55
|
+
const [left, top, right, bottom] = span(bounds);
|
|
56
|
+
if ((right - left + 1) * (bottom - top + 1) > cells.size) {
|
|
57
|
+
for (const [key, bucket] of cells) {
|
|
58
|
+
const column = Math.floor(key / 2 ** 21) - OFFSET;
|
|
59
|
+
const row = key % 2 ** 21 - OFFSET;
|
|
60
|
+
if (column >= left && column <= right && row >= top && row <= bottom) bucket.forEach((item) => found.add(item));
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
for (let column = left; column <= right; column += 1) for (let row = top; row <= bottom; row += 1) cells.get(keyOf(column, row))?.forEach((item) => found.add(item));
|
|
65
|
+
return found;
|
|
66
|
+
},
|
|
67
|
+
clear() {
|
|
68
|
+
cells.clear();
|
|
69
|
+
placed.clear();
|
|
70
|
+
large.clear();
|
|
71
|
+
},
|
|
72
|
+
get size() {
|
|
73
|
+
return placed.size;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
export { createSpatialIndex };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_batch = require("./performance/batch.cjs");
|
|
3
|
+
const require_spatial_index = require("./performance/spatial-index.cjs");
|
|
4
|
+
const require_dirty_regions = require("./performance/dirty-regions.cjs");
|
|
5
|
+
const require_monitor = require("./performance/monitor.cjs");
|
|
6
|
+
exports.batchCanvasUpdates = require_batch.batchCanvasUpdates;
|
|
7
|
+
exports.createPerformanceMonitor = require_monitor.createPerformanceMonitor;
|
|
8
|
+
exports.createSpatialIndex = require_spatial_index.createSpatialIndex;
|
|
9
|
+
exports.enableDirtyRegionRendering = require_dirty_regions.enableDirtyRegionRendering;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { batchCanvasUpdates } from "./performance/batch.cjs";
|
|
2
|
+
import { Rect, SpatialIndex, SpatialIndexOptions, createSpatialIndex } from "./performance/spatial-index.cjs";
|
|
3
|
+
import { DirtyRegionOptions, DirtyRegionRenderer, DirtyRegionStats, enableDirtyRegionRendering } from "./performance/dirty-regions.cjs";
|
|
4
|
+
import { FrameStats, PerformanceMonitor, PerformanceMonitorOptions, createPerformanceMonitor } from "./performance/monitor.cjs";
|
|
5
|
+
export { type DirtyRegionOptions, type DirtyRegionRenderer, type DirtyRegionStats, type FrameStats, type PerformanceMonitor, type PerformanceMonitorOptions, type Rect, type SpatialIndex, type SpatialIndexOptions, batchCanvasUpdates, createPerformanceMonitor, createSpatialIndex, enableDirtyRegionRendering };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { batchCanvasUpdates } from "./performance/batch.js";
|
|
2
|
+
import { Rect, SpatialIndex, SpatialIndexOptions, createSpatialIndex } from "./performance/spatial-index.js";
|
|
3
|
+
import { DirtyRegionOptions, DirtyRegionRenderer, DirtyRegionStats, enableDirtyRegionRendering } from "./performance/dirty-regions.js";
|
|
4
|
+
import { FrameStats, PerformanceMonitor, PerformanceMonitorOptions, createPerformanceMonitor } from "./performance/monitor.js";
|
|
5
|
+
export { type DirtyRegionOptions, type DirtyRegionRenderer, type DirtyRegionStats, type FrameStats, type PerformanceMonitor, type PerformanceMonitorOptions, type Rect, type SpatialIndex, type SpatialIndexOptions, batchCanvasUpdates, createPerformanceMonitor, createSpatialIndex, enableDirtyRegionRendering };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { batchCanvasUpdates } from "./performance/batch.js";
|
|
2
|
+
import { createSpatialIndex } from "./performance/spatial-index.js";
|
|
3
|
+
import { enableDirtyRegionRendering } from "./performance/dirty-regions.js";
|
|
4
|
+
import { createPerformanceMonitor } from "./performance/monitor.js";
|
|
5
|
+
export { batchCanvasUpdates, createPerformanceMonitor, createSpatialIndex, enableDirtyRegionRendering };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
|
|
2
|
+
const require_ids = require("../document/ids.cjs");
|
|
2
3
|
//#region src/recovery/recovery-controller.ts
|
|
3
4
|
async function toStoredFiles(files) {
|
|
4
5
|
const entries = await Promise.all(Object.entries(files).map(async ([url, blob]) => [url, {
|
|
@@ -12,22 +13,55 @@ function fromStoredFiles(files) {
|
|
|
12
13
|
for (const [url, file] of Object.entries(files ?? {})) restored[url] = file instanceof Blob ? file : new Blob([file.bytes], { type: file.type });
|
|
13
14
|
return restored;
|
|
14
15
|
}
|
|
15
|
-
function fromStoredRecord(value) {
|
|
16
|
+
function fromStoredRecord(value, sessionId, active) {
|
|
16
17
|
if (!isRecoveryRecord(value)) return void 0;
|
|
17
18
|
const stored = value;
|
|
18
19
|
return {
|
|
19
20
|
...stored,
|
|
21
|
+
sessionId,
|
|
22
|
+
active,
|
|
20
23
|
files: fromStoredFiles(stored.files)
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const LEGACY_SESSION = "legacy";
|
|
27
|
+
const checkpointPrefix = "checkpoint:";
|
|
28
|
+
const closingPrefix = "closing:";
|
|
29
|
+
const loadingPrefix = "loading:";
|
|
30
|
+
const legacyDocumentPrefix = "document:";
|
|
31
|
+
const legacyUnloadPrefix = "unload:";
|
|
32
|
+
const legacyLoadingKey = "loading";
|
|
33
|
+
function branchKeys({ sessionId, documentId }) {
|
|
34
|
+
if (sessionId === LEGACY_SESSION) return {
|
|
35
|
+
checkpoint: `${legacyDocumentPrefix}${documentId}`,
|
|
36
|
+
closing: `${legacyUnloadPrefix}${documentId}`
|
|
37
|
+
};
|
|
38
|
+
const document = encodeURIComponent(documentId);
|
|
39
|
+
return {
|
|
40
|
+
checkpoint: `${checkpointPrefix}${sessionId}:${document}`,
|
|
41
|
+
closing: `${closingPrefix}${sessionId}:${document}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** The branch a key belongs to, or undefined for keys that are not copies. */
|
|
45
|
+
function parseBranch(key) {
|
|
46
|
+
for (const prefix of [checkpointPrefix, closingPrefix]) {
|
|
47
|
+
if (!key.startsWith(prefix)) continue;
|
|
48
|
+
const rest = key.slice(prefix.length);
|
|
49
|
+
const separator = rest.indexOf(":");
|
|
50
|
+
if (separator <= 0) return void 0;
|
|
51
|
+
return {
|
|
52
|
+
sessionId: rest.slice(0, separator),
|
|
53
|
+
documentId: decodeURIComponent(rest.slice(separator + 1))
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
for (const prefix of [legacyDocumentPrefix, legacyUnloadPrefix]) if (key.startsWith(prefix)) return {
|
|
57
|
+
sessionId: LEGACY_SESSION,
|
|
58
|
+
documentId: key.slice(prefix.length)
|
|
59
|
+
};
|
|
28
60
|
}
|
|
29
|
-
|
|
30
|
-
|
|
61
|
+
const LOCK_PREFIX = "fabricjs-document-engine/recovery/";
|
|
62
|
+
function lockManager() {
|
|
63
|
+
const locks = globalThis.navigator?.locks;
|
|
64
|
+
return typeof locks?.request === "function" && typeof locks.query === "function" ? locks : void 0;
|
|
31
65
|
}
|
|
32
66
|
function newerRecord(checkpoint, unloadCopy) {
|
|
33
67
|
if (!unloadCopy) return checkpoint;
|
|
@@ -58,6 +92,24 @@ async function restoreRecordedFiles(record) {
|
|
|
58
92
|
}
|
|
59
93
|
function createRecoveryController(options) {
|
|
60
94
|
const { store } = options;
|
|
95
|
+
const sessionId = require_ids.createId();
|
|
96
|
+
const own = (documentId) => ({
|
|
97
|
+
sessionId,
|
|
98
|
+
documentId
|
|
99
|
+
});
|
|
100
|
+
const adopted = /* @__PURE__ */ new Map();
|
|
101
|
+
const locks = lockManager();
|
|
102
|
+
let releaseLock;
|
|
103
|
+
locks?.request(`${LOCK_PREFIX}${sessionId}`, () => new Promise((resolve) => releaseLock = resolve)).catch(() => void 0);
|
|
104
|
+
async function liveSessions() {
|
|
105
|
+
const live = /* @__PURE__ */ new Set([sessionId]);
|
|
106
|
+
if (!locks) return live;
|
|
107
|
+
try {
|
|
108
|
+
const { held = [] } = await locks.query();
|
|
109
|
+
for (const lock of held) if (lock.name?.startsWith(LOCK_PREFIX)) live.add(lock.name.slice(34));
|
|
110
|
+
} catch {}
|
|
111
|
+
return live;
|
|
112
|
+
}
|
|
61
113
|
const interval = options.interval ?? 2e3;
|
|
62
114
|
const capturedFiles = /* @__PURE__ */ new Map();
|
|
63
115
|
const readyFiles = /* @__PURE__ */ new Map();
|
|
@@ -88,6 +140,8 @@ function createRecoveryController(options) {
|
|
|
88
140
|
function createRecord(document, files) {
|
|
89
141
|
return {
|
|
90
142
|
documentId: document.id,
|
|
143
|
+
sessionId,
|
|
144
|
+
active: true,
|
|
91
145
|
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
92
146
|
baseRevision: document.revision ?? 0,
|
|
93
147
|
document,
|
|
@@ -99,7 +153,7 @@ function createRecoveryController(options) {
|
|
|
99
153
|
if (!options.shouldWrite()) return;
|
|
100
154
|
const document = options.createDocument();
|
|
101
155
|
const record = createRecord(document, await captureTabOnlyFiles(document));
|
|
102
|
-
await store.set(
|
|
156
|
+
await store.set(branchKeys(own(document.id)).checkpoint, {
|
|
103
157
|
...record,
|
|
104
158
|
files: await toStoredFiles(record.files)
|
|
105
159
|
});
|
|
@@ -109,20 +163,52 @@ function createRecoveryController(options) {
|
|
|
109
163
|
cancel();
|
|
110
164
|
if (!options.shouldWrite()) return;
|
|
111
165
|
const document = options.createDocument();
|
|
166
|
+
const keys = branchKeys(own(document.id));
|
|
112
167
|
if (store.setNow) {
|
|
113
|
-
store.setNow(
|
|
168
|
+
store.setNow(keys.closing, createRecord(document, {}));
|
|
114
169
|
return;
|
|
115
170
|
}
|
|
116
171
|
const record = createRecord(document, Object.fromEntries(readyFiles));
|
|
117
|
-
toStoredFiles(record.files).then((files) => store.set(
|
|
172
|
+
toStoredFiles(record.files).then((files) => store.set(keys.checkpoint, {
|
|
118
173
|
...record,
|
|
119
174
|
files
|
|
120
175
|
})).then(() => options.onCheckpoint(record), options.onError);
|
|
121
176
|
}
|
|
122
|
-
async function
|
|
177
|
+
async function readBranch(branch, live) {
|
|
178
|
+
const keys = branchKeys(branch);
|
|
179
|
+
const [checkpoint, closing] = await Promise.all([store.get(keys.checkpoint), store.get(keys.closing)]);
|
|
180
|
+
const active = live.has(branch.sessionId);
|
|
181
|
+
return newerRecord(fromStoredRecord(checkpoint, branch.sessionId, active), fromStoredRecord(closing, branch.sessionId, active));
|
|
182
|
+
}
|
|
183
|
+
/** Every branch in the store, de-duplicated, in one pass over the keys. */
|
|
184
|
+
async function branches(documentId) {
|
|
185
|
+
const found = /* @__PURE__ */ new Map();
|
|
186
|
+
for (const key of await store.keys()) {
|
|
187
|
+
const branch = parseBranch(key);
|
|
188
|
+
if (!branch || documentId !== void 0 && branch.documentId !== documentId) continue;
|
|
189
|
+
found.set(`${branch.sessionId}\u0000${branch.documentId}`, branch);
|
|
190
|
+
}
|
|
191
|
+
return [...found.values()];
|
|
192
|
+
}
|
|
193
|
+
async function readAll(documentId) {
|
|
123
194
|
await queue;
|
|
124
|
-
const
|
|
125
|
-
return
|
|
195
|
+
const live = await liveSessions();
|
|
196
|
+
return (await Promise.all((await branches(documentId)).map((branch) => readBranch(branch, live)))).filter((record) => record !== void 0).sort((first, second) => second.savedAt.localeCompare(first.savedAt));
|
|
197
|
+
}
|
|
198
|
+
async function read(documentId, branchSession) {
|
|
199
|
+
if (branchSession !== void 0) {
|
|
200
|
+
await queue;
|
|
201
|
+
return readBranch({
|
|
202
|
+
sessionId: branchSession,
|
|
203
|
+
documentId
|
|
204
|
+
}, await liveSessions());
|
|
205
|
+
}
|
|
206
|
+
return (await readAll(documentId))[0];
|
|
207
|
+
}
|
|
208
|
+
async function deleteBranch(branch) {
|
|
209
|
+
const keys = branchKeys(branch);
|
|
210
|
+
await store.delete(keys.closing);
|
|
211
|
+
await store.delete(keys.checkpoint);
|
|
126
212
|
}
|
|
127
213
|
function cancel() {
|
|
128
214
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -151,31 +237,64 @@ function createRecoveryController(options) {
|
|
|
151
237
|
flush,
|
|
152
238
|
writeNow: writeCheckpointBeforeThePageCloses,
|
|
153
239
|
cancel,
|
|
240
|
+
sessionId,
|
|
154
241
|
remove: (documentId) => enqueue(async () => {
|
|
155
|
-
await
|
|
156
|
-
await
|
|
242
|
+
await deleteBranch(own(documentId));
|
|
243
|
+
await deleteBranch({
|
|
244
|
+
sessionId: LEGACY_SESSION,
|
|
245
|
+
documentId
|
|
246
|
+
});
|
|
247
|
+
for (const other of adopted.get(documentId) ?? []) await deleteBranch({
|
|
248
|
+
sessionId: other,
|
|
249
|
+
documentId
|
|
250
|
+
});
|
|
251
|
+
adopted.delete(documentId);
|
|
157
252
|
}),
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (key.startsWith(documentKeyPrefix)) documentIds.add(key.slice(9));
|
|
164
|
-
if (key.startsWith(unloadKeyPrefix)) documentIds.add(key.slice(7));
|
|
165
|
-
}
|
|
166
|
-
return (await Promise.all([...documentIds].map(read))).filter((record) => record !== void 0).sort((first, second) => second.savedAt.localeCompare(first.savedAt));
|
|
253
|
+
adopt(documentId, other) {
|
|
254
|
+
if (other === sessionId) return;
|
|
255
|
+
const sessions = adopted.get(documentId) ?? /* @__PURE__ */ new Set();
|
|
256
|
+
sessions.add(other);
|
|
257
|
+
adopted.set(documentId, sessions);
|
|
167
258
|
},
|
|
168
|
-
|
|
259
|
+
discard: (documentId, branchSession) => enqueue(async () => {
|
|
260
|
+
const targets = branchSession === void 0 ? await branches(documentId) : [{
|
|
261
|
+
sessionId: branchSession,
|
|
262
|
+
documentId
|
|
263
|
+
}];
|
|
264
|
+
for (const branch of targets) await deleteBranch(branch);
|
|
265
|
+
}),
|
|
266
|
+
read,
|
|
267
|
+
list: () => readAll(),
|
|
268
|
+
markLoadStarted: (documentId) => store.set(`${loadingPrefix}${sessionId}`, {
|
|
169
269
|
documentId,
|
|
170
270
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
171
271
|
}).catch(options.onError),
|
|
172
|
-
markLoadFinished: () =>
|
|
272
|
+
markLoadFinished: async () => {
|
|
273
|
+
try {
|
|
274
|
+
await store.delete(`${loadingPrefix}${sessionId}`);
|
|
275
|
+
const live = await liveSessions();
|
|
276
|
+
for (const key of await store.keys()) {
|
|
277
|
+
const markerSession = key === legacyLoadingKey ? LEGACY_SESSION : key.startsWith(loadingPrefix) ? key.slice(8) : void 0;
|
|
278
|
+
if (markerSession !== void 0 && !live.has(markerSession)) await store.delete(key);
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
options.onError(error);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
173
284
|
async interruptedLoad() {
|
|
174
|
-
const
|
|
175
|
-
|
|
285
|
+
const live = await liveSessions();
|
|
286
|
+
let newest;
|
|
287
|
+
for (const key of await store.keys()) {
|
|
288
|
+
const markerSession = key === legacyLoadingKey ? LEGACY_SESSION : key.startsWith(loadingPrefix) ? key.slice(8) : void 0;
|
|
289
|
+
if (markerSession === void 0 || live.has(markerSession)) continue;
|
|
290
|
+
const marker = await store.get(key);
|
|
291
|
+
if (marker && typeof marker.startedAt === "string" && (!newest || marker.startedAt > newest.startedAt)) newest = marker;
|
|
292
|
+
}
|
|
293
|
+
return newest;
|
|
176
294
|
},
|
|
177
295
|
destroy() {
|
|
178
296
|
cancel();
|
|
297
|
+
releaseLock?.();
|
|
179
298
|
pageEvents?.removeEventListener("pagehide", handlePageHidden);
|
|
180
299
|
pageEvents?.document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
181
300
|
}
|
|
@@ -7,6 +7,14 @@ export interface RecoveryOptions {
|
|
|
7
7
|
}
|
|
8
8
|
export interface RecoveryRecord {
|
|
9
9
|
documentId: string;
|
|
10
|
+
/**
|
|
11
|
+
* The editor session (one per engine, so one per tab) that wrote this copy.
|
|
12
|
+
* Two tabs editing the same document keep separate copies. `legacy` marks
|
|
13
|
+
* a copy written before 1.2.
|
|
14
|
+
*/
|
|
15
|
+
sessionId: string;
|
|
16
|
+
/** True while the session that wrote the copy is still open in a tab, when the browser can tell. */
|
|
17
|
+
active: boolean;
|
|
10
18
|
savedAt: string;
|
|
11
19
|
baseRevision: number;
|
|
12
20
|
document: FabricDocument;
|
|
@@ -7,6 +7,14 @@ export interface RecoveryOptions {
|
|
|
7
7
|
}
|
|
8
8
|
export interface RecoveryRecord {
|
|
9
9
|
documentId: string;
|
|
10
|
+
/**
|
|
11
|
+
* The editor session (one per engine, so one per tab) that wrote this copy.
|
|
12
|
+
* Two tabs editing the same document keep separate copies. `legacy` marks
|
|
13
|
+
* a copy written before 1.2.
|
|
14
|
+
*/
|
|
15
|
+
sessionId: string;
|
|
16
|
+
/** True while the session that wrote the copy is still open in a tab, when the browser can tell. */
|
|
17
|
+
active: boolean;
|
|
10
18
|
savedAt: string;
|
|
11
19
|
baseRevision: number;
|
|
12
20
|
document: FabricDocument;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { rewriteUrls } from "../assets/asset-pipeline.js";
|
|
2
|
+
import { createId } from "../document/ids.js";
|
|
2
3
|
//#region src/recovery/recovery-controller.ts
|
|
3
4
|
async function toStoredFiles(files) {
|
|
4
5
|
const entries = await Promise.all(Object.entries(files).map(async ([url, blob]) => [url, {
|
|
@@ -12,22 +13,55 @@ function fromStoredFiles(files) {
|
|
|
12
13
|
for (const [url, file] of Object.entries(files ?? {})) restored[url] = file instanceof Blob ? file : new Blob([file.bytes], { type: file.type });
|
|
13
14
|
return restored;
|
|
14
15
|
}
|
|
15
|
-
function fromStoredRecord(value) {
|
|
16
|
+
function fromStoredRecord(value, sessionId, active) {
|
|
16
17
|
if (!isRecoveryRecord(value)) return void 0;
|
|
17
18
|
const stored = value;
|
|
18
19
|
return {
|
|
19
20
|
...stored,
|
|
21
|
+
sessionId,
|
|
22
|
+
active,
|
|
20
23
|
files: fromStoredFiles(stored.files)
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const LEGACY_SESSION = "legacy";
|
|
27
|
+
const checkpointPrefix = "checkpoint:";
|
|
28
|
+
const closingPrefix = "closing:";
|
|
29
|
+
const loadingPrefix = "loading:";
|
|
30
|
+
const legacyDocumentPrefix = "document:";
|
|
31
|
+
const legacyUnloadPrefix = "unload:";
|
|
32
|
+
const legacyLoadingKey = "loading";
|
|
33
|
+
function branchKeys({ sessionId, documentId }) {
|
|
34
|
+
if (sessionId === LEGACY_SESSION) return {
|
|
35
|
+
checkpoint: `${legacyDocumentPrefix}${documentId}`,
|
|
36
|
+
closing: `${legacyUnloadPrefix}${documentId}`
|
|
37
|
+
};
|
|
38
|
+
const document = encodeURIComponent(documentId);
|
|
39
|
+
return {
|
|
40
|
+
checkpoint: `${checkpointPrefix}${sessionId}:${document}`,
|
|
41
|
+
closing: `${closingPrefix}${sessionId}:${document}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** The branch a key belongs to, or undefined for keys that are not copies. */
|
|
45
|
+
function parseBranch(key) {
|
|
46
|
+
for (const prefix of [checkpointPrefix, closingPrefix]) {
|
|
47
|
+
if (!key.startsWith(prefix)) continue;
|
|
48
|
+
const rest = key.slice(prefix.length);
|
|
49
|
+
const separator = rest.indexOf(":");
|
|
50
|
+
if (separator <= 0) return void 0;
|
|
51
|
+
return {
|
|
52
|
+
sessionId: rest.slice(0, separator),
|
|
53
|
+
documentId: decodeURIComponent(rest.slice(separator + 1))
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
for (const prefix of [legacyDocumentPrefix, legacyUnloadPrefix]) if (key.startsWith(prefix)) return {
|
|
57
|
+
sessionId: LEGACY_SESSION,
|
|
58
|
+
documentId: key.slice(prefix.length)
|
|
59
|
+
};
|
|
28
60
|
}
|
|
29
|
-
|
|
30
|
-
|
|
61
|
+
const LOCK_PREFIX = "fabricjs-document-engine/recovery/";
|
|
62
|
+
function lockManager() {
|
|
63
|
+
const locks = globalThis.navigator?.locks;
|
|
64
|
+
return typeof locks?.request === "function" && typeof locks.query === "function" ? locks : void 0;
|
|
31
65
|
}
|
|
32
66
|
function newerRecord(checkpoint, unloadCopy) {
|
|
33
67
|
if (!unloadCopy) return checkpoint;
|
|
@@ -58,6 +92,24 @@ async function restoreRecordedFiles(record) {
|
|
|
58
92
|
}
|
|
59
93
|
function createRecoveryController(options) {
|
|
60
94
|
const { store } = options;
|
|
95
|
+
const sessionId = createId();
|
|
96
|
+
const own = (documentId) => ({
|
|
97
|
+
sessionId,
|
|
98
|
+
documentId
|
|
99
|
+
});
|
|
100
|
+
const adopted = /* @__PURE__ */ new Map();
|
|
101
|
+
const locks = lockManager();
|
|
102
|
+
let releaseLock;
|
|
103
|
+
locks?.request(`${LOCK_PREFIX}${sessionId}`, () => new Promise((resolve) => releaseLock = resolve)).catch(() => void 0);
|
|
104
|
+
async function liveSessions() {
|
|
105
|
+
const live = /* @__PURE__ */ new Set([sessionId]);
|
|
106
|
+
if (!locks) return live;
|
|
107
|
+
try {
|
|
108
|
+
const { held = [] } = await locks.query();
|
|
109
|
+
for (const lock of held) if (lock.name?.startsWith(LOCK_PREFIX)) live.add(lock.name.slice(34));
|
|
110
|
+
} catch {}
|
|
111
|
+
return live;
|
|
112
|
+
}
|
|
61
113
|
const interval = options.interval ?? 2e3;
|
|
62
114
|
const capturedFiles = /* @__PURE__ */ new Map();
|
|
63
115
|
const readyFiles = /* @__PURE__ */ new Map();
|
|
@@ -88,6 +140,8 @@ function createRecoveryController(options) {
|
|
|
88
140
|
function createRecord(document, files) {
|
|
89
141
|
return {
|
|
90
142
|
documentId: document.id,
|
|
143
|
+
sessionId,
|
|
144
|
+
active: true,
|
|
91
145
|
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
92
146
|
baseRevision: document.revision ?? 0,
|
|
93
147
|
document,
|
|
@@ -99,7 +153,7 @@ function createRecoveryController(options) {
|
|
|
99
153
|
if (!options.shouldWrite()) return;
|
|
100
154
|
const document = options.createDocument();
|
|
101
155
|
const record = createRecord(document, await captureTabOnlyFiles(document));
|
|
102
|
-
await store.set(
|
|
156
|
+
await store.set(branchKeys(own(document.id)).checkpoint, {
|
|
103
157
|
...record,
|
|
104
158
|
files: await toStoredFiles(record.files)
|
|
105
159
|
});
|
|
@@ -109,20 +163,52 @@ function createRecoveryController(options) {
|
|
|
109
163
|
cancel();
|
|
110
164
|
if (!options.shouldWrite()) return;
|
|
111
165
|
const document = options.createDocument();
|
|
166
|
+
const keys = branchKeys(own(document.id));
|
|
112
167
|
if (store.setNow) {
|
|
113
|
-
store.setNow(
|
|
168
|
+
store.setNow(keys.closing, createRecord(document, {}));
|
|
114
169
|
return;
|
|
115
170
|
}
|
|
116
171
|
const record = createRecord(document, Object.fromEntries(readyFiles));
|
|
117
|
-
toStoredFiles(record.files).then((files) => store.set(
|
|
172
|
+
toStoredFiles(record.files).then((files) => store.set(keys.checkpoint, {
|
|
118
173
|
...record,
|
|
119
174
|
files
|
|
120
175
|
})).then(() => options.onCheckpoint(record), options.onError);
|
|
121
176
|
}
|
|
122
|
-
async function
|
|
177
|
+
async function readBranch(branch, live) {
|
|
178
|
+
const keys = branchKeys(branch);
|
|
179
|
+
const [checkpoint, closing] = await Promise.all([store.get(keys.checkpoint), store.get(keys.closing)]);
|
|
180
|
+
const active = live.has(branch.sessionId);
|
|
181
|
+
return newerRecord(fromStoredRecord(checkpoint, branch.sessionId, active), fromStoredRecord(closing, branch.sessionId, active));
|
|
182
|
+
}
|
|
183
|
+
/** Every branch in the store, de-duplicated, in one pass over the keys. */
|
|
184
|
+
async function branches(documentId) {
|
|
185
|
+
const found = /* @__PURE__ */ new Map();
|
|
186
|
+
for (const key of await store.keys()) {
|
|
187
|
+
const branch = parseBranch(key);
|
|
188
|
+
if (!branch || documentId !== void 0 && branch.documentId !== documentId) continue;
|
|
189
|
+
found.set(`${branch.sessionId}\u0000${branch.documentId}`, branch);
|
|
190
|
+
}
|
|
191
|
+
return [...found.values()];
|
|
192
|
+
}
|
|
193
|
+
async function readAll(documentId) {
|
|
123
194
|
await queue;
|
|
124
|
-
const
|
|
125
|
-
return
|
|
195
|
+
const live = await liveSessions();
|
|
196
|
+
return (await Promise.all((await branches(documentId)).map((branch) => readBranch(branch, live)))).filter((record) => record !== void 0).sort((first, second) => second.savedAt.localeCompare(first.savedAt));
|
|
197
|
+
}
|
|
198
|
+
async function read(documentId, branchSession) {
|
|
199
|
+
if (branchSession !== void 0) {
|
|
200
|
+
await queue;
|
|
201
|
+
return readBranch({
|
|
202
|
+
sessionId: branchSession,
|
|
203
|
+
documentId
|
|
204
|
+
}, await liveSessions());
|
|
205
|
+
}
|
|
206
|
+
return (await readAll(documentId))[0];
|
|
207
|
+
}
|
|
208
|
+
async function deleteBranch(branch) {
|
|
209
|
+
const keys = branchKeys(branch);
|
|
210
|
+
await store.delete(keys.closing);
|
|
211
|
+
await store.delete(keys.checkpoint);
|
|
126
212
|
}
|
|
127
213
|
function cancel() {
|
|
128
214
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -151,31 +237,64 @@ function createRecoveryController(options) {
|
|
|
151
237
|
flush,
|
|
152
238
|
writeNow: writeCheckpointBeforeThePageCloses,
|
|
153
239
|
cancel,
|
|
240
|
+
sessionId,
|
|
154
241
|
remove: (documentId) => enqueue(async () => {
|
|
155
|
-
await
|
|
156
|
-
await
|
|
242
|
+
await deleteBranch(own(documentId));
|
|
243
|
+
await deleteBranch({
|
|
244
|
+
sessionId: LEGACY_SESSION,
|
|
245
|
+
documentId
|
|
246
|
+
});
|
|
247
|
+
for (const other of adopted.get(documentId) ?? []) await deleteBranch({
|
|
248
|
+
sessionId: other,
|
|
249
|
+
documentId
|
|
250
|
+
});
|
|
251
|
+
adopted.delete(documentId);
|
|
157
252
|
}),
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (key.startsWith(documentKeyPrefix)) documentIds.add(key.slice(9));
|
|
164
|
-
if (key.startsWith(unloadKeyPrefix)) documentIds.add(key.slice(7));
|
|
165
|
-
}
|
|
166
|
-
return (await Promise.all([...documentIds].map(read))).filter((record) => record !== void 0).sort((first, second) => second.savedAt.localeCompare(first.savedAt));
|
|
253
|
+
adopt(documentId, other) {
|
|
254
|
+
if (other === sessionId) return;
|
|
255
|
+
const sessions = adopted.get(documentId) ?? /* @__PURE__ */ new Set();
|
|
256
|
+
sessions.add(other);
|
|
257
|
+
adopted.set(documentId, sessions);
|
|
167
258
|
},
|
|
168
|
-
|
|
259
|
+
discard: (documentId, branchSession) => enqueue(async () => {
|
|
260
|
+
const targets = branchSession === void 0 ? await branches(documentId) : [{
|
|
261
|
+
sessionId: branchSession,
|
|
262
|
+
documentId
|
|
263
|
+
}];
|
|
264
|
+
for (const branch of targets) await deleteBranch(branch);
|
|
265
|
+
}),
|
|
266
|
+
read,
|
|
267
|
+
list: () => readAll(),
|
|
268
|
+
markLoadStarted: (documentId) => store.set(`${loadingPrefix}${sessionId}`, {
|
|
169
269
|
documentId,
|
|
170
270
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
171
271
|
}).catch(options.onError),
|
|
172
|
-
markLoadFinished: () =>
|
|
272
|
+
markLoadFinished: async () => {
|
|
273
|
+
try {
|
|
274
|
+
await store.delete(`${loadingPrefix}${sessionId}`);
|
|
275
|
+
const live = await liveSessions();
|
|
276
|
+
for (const key of await store.keys()) {
|
|
277
|
+
const markerSession = key === legacyLoadingKey ? LEGACY_SESSION : key.startsWith(loadingPrefix) ? key.slice(8) : void 0;
|
|
278
|
+
if (markerSession !== void 0 && !live.has(markerSession)) await store.delete(key);
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
options.onError(error);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
173
284
|
async interruptedLoad() {
|
|
174
|
-
const
|
|
175
|
-
|
|
285
|
+
const live = await liveSessions();
|
|
286
|
+
let newest;
|
|
287
|
+
for (const key of await store.keys()) {
|
|
288
|
+
const markerSession = key === legacyLoadingKey ? LEGACY_SESSION : key.startsWith(loadingPrefix) ? key.slice(8) : void 0;
|
|
289
|
+
if (markerSession === void 0 || live.has(markerSession)) continue;
|
|
290
|
+
const marker = await store.get(key);
|
|
291
|
+
if (marker && typeof marker.startedAt === "string" && (!newest || marker.startedAt > newest.startedAt)) newest = marker;
|
|
292
|
+
}
|
|
293
|
+
return newest;
|
|
176
294
|
},
|
|
177
295
|
destroy() {
|
|
178
296
|
cancel();
|
|
297
|
+
releaseLock?.();
|
|
179
298
|
pageEvents?.removeEventListener("pagehide", handlePageHidden);
|
|
180
299
|
pageEvents?.document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
181
300
|
}
|