fabricjs-document-engine 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +220 -11
  2. package/README.zh-CN.md +220 -11
  3. package/dist/assets/asset-manifest.d.cts +3 -0
  4. package/dist/assets/asset-manifest.d.ts +3 -0
  5. package/dist/assets/asset-pipeline.cjs +31 -17
  6. package/dist/assets/asset-pipeline.d.cts +5 -1
  7. package/dist/assets/asset-pipeline.d.ts +5 -1
  8. package/dist/assets/asset-pipeline.js +31 -17
  9. package/dist/assets/font-check.cjs +9 -1
  10. package/dist/assets/font-check.js +9 -1
  11. package/dist/assets/image-check.cjs +89 -6
  12. package/dist/assets/image-check.d.cts +17 -0
  13. package/dist/assets/image-check.d.ts +17 -0
  14. package/dist/assets/image-check.js +89 -7
  15. package/dist/commands/clipboard.cjs +141 -0
  16. package/dist/commands/clipboard.d.cts +47 -0
  17. package/dist/commands/clipboard.d.ts +47 -0
  18. package/dist/commands/clipboard.js +140 -0
  19. package/dist/commands/layers.cjs +97 -0
  20. package/dist/commands/layers.d.cts +39 -0
  21. package/dist/commands/layers.d.ts +39 -0
  22. package/dist/commands/layers.js +92 -0
  23. package/dist/document/document-format.d.cts +15 -0
  24. package/dist/document/document-format.d.ts +15 -0
  25. package/dist/document/validate-document.cjs +8 -0
  26. package/dist/document/validate-document.js +8 -0
  27. package/dist/engine/create-document-engine.cjs +196 -31
  28. package/dist/engine/create-document-engine.d.cts +44 -7
  29. package/dist/engine/create-document-engine.d.ts +44 -7
  30. package/dist/engine/create-document-engine.js +197 -32
  31. package/dist/engine/errors.d.cts +1 -1
  32. package/dist/engine/errors.d.ts +1 -1
  33. package/dist/export/batch-render.cjs +172 -0
  34. package/dist/export/batch-render.d.cts +43 -0
  35. package/dist/export/batch-render.d.ts +43 -0
  36. package/dist/export/batch-render.js +171 -0
  37. package/dist/export/export-options.cjs +21 -1
  38. package/dist/export/export-options.d.cts +29 -0
  39. package/dist/export/export-options.d.ts +29 -0
  40. package/dist/export/export-options.js +21 -2
  41. package/dist/export/preflight-export.cjs +11 -2
  42. package/dist/export/preflight-export.d.cts +1 -1
  43. package/dist/export/preflight-export.d.ts +1 -1
  44. package/dist/export/preflight-export.js +11 -2
  45. package/dist/export/render-export.cjs +57 -4
  46. package/dist/export/render-export.js +57 -5
  47. package/dist/export/svg/clip-masks.cjs +39 -0
  48. package/dist/export/svg/clip-masks.js +37 -0
  49. package/dist/export/svg/embed-assets.cjs +250 -0
  50. package/dist/export/svg/embed-assets.js +250 -0
  51. package/dist/export/svg/overrides.cjs +32 -0
  52. package/dist/export/svg/overrides.js +32 -0
  53. package/dist/export/svg/raster-object.cjs +82 -0
  54. package/dist/export/svg/raster-object.js +81 -0
  55. package/dist/export/svg/text-decorations.cjs +108 -0
  56. package/dist/export/svg/text-decorations.js +107 -0
  57. package/dist/export/svg/text-on-path.cjs +242 -0
  58. package/dist/export/svg/text-on-path.js +240 -0
  59. package/dist/fabric/fabric-adapter.cjs +49 -6
  60. package/dist/fabric/fabric-adapter.js +49 -6
  61. package/dist/fabric/object-registry.cjs +1 -1
  62. package/dist/fabric/object-registry.js +1 -1
  63. package/dist/fabric/page-state.cjs +69 -0
  64. package/dist/fabric/page-state.js +64 -0
  65. package/dist/history/apply-state.cjs +19 -2
  66. package/dist/history/apply-state.js +19 -2
  67. package/dist/history/create-history.cjs +53 -13
  68. package/dist/history/create-history.d.cts +8 -0
  69. package/dist/history/create-history.d.ts +8 -0
  70. package/dist/history/create-history.js +53 -13
  71. package/dist/history/snapshot.cjs +10 -1
  72. package/dist/history/snapshot.js +10 -2
  73. package/dist/import/svg-import.cjs +256 -0
  74. package/dist/import/svg-import.d.cts +50 -0
  75. package/dist/import/svg-import.d.ts +50 -0
  76. package/dist/import/svg-import.js +256 -0
  77. package/dist/index.cjs +12 -0
  78. package/dist/index.d.cts +10 -5
  79. package/dist/index.d.ts +10 -5
  80. package/dist/index.js +4 -1
  81. package/dist/migrations/migrate-document.cjs +2 -1
  82. package/dist/migrations/migrate-document.js +2 -1
  83. package/dist/pdf/export-pdf.cjs +381 -0
  84. package/dist/pdf/export-pdf.d.cts +73 -0
  85. package/dist/pdf/export-pdf.d.ts +73 -0
  86. package/dist/pdf/export-pdf.js +381 -0
  87. package/dist/pdf/fonts.cjs +115 -0
  88. package/dist/pdf/fonts.d.cts +12 -0
  89. package/dist/pdf/fonts.d.ts +12 -0
  90. package/dist/pdf/fonts.js +112 -0
  91. package/dist/pdf/page-layout.cjs +79 -0
  92. package/dist/pdf/page-layout.d.cts +51 -0
  93. package/dist/pdf/page-layout.d.ts +51 -0
  94. package/dist/pdf/page-layout.js +76 -0
  95. package/dist/pdf.cjs +7 -0
  96. package/dist/pdf.d.cts +4 -0
  97. package/dist/pdf.d.ts +4 -0
  98. package/dist/pdf.js +3 -0
  99. package/dist/react/use-layers.cjs +53 -0
  100. package/dist/react/use-layers.d.cts +10 -0
  101. package/dist/react/use-layers.d.ts +10 -0
  102. package/dist/react/use-layers.js +53 -0
  103. package/dist/react.cjs +2 -0
  104. package/dist/react.d.cts +3 -1
  105. package/dist/react.d.ts +3 -1
  106. package/dist/react.js +2 -1
  107. package/dist/recovery/recovery-controller.cjs +148 -29
  108. package/dist/recovery/recovery-controller.d.cts +8 -0
  109. package/dist/recovery/recovery-controller.d.ts +8 -0
  110. package/dist/recovery/recovery-controller.js +148 -29
  111. package/dist/security/content-limits.cjs +43 -3
  112. package/dist/security/content-limits.d.cts +8 -0
  113. package/dist/security/content-limits.d.ts +8 -0
  114. package/dist/security/content-limits.js +39 -4
  115. package/dist/util/concurrency.cjs +41 -0
  116. package/dist/util/concurrency.js +40 -0
  117. package/package.json +30 -3
  118. package/schema/document-v1.schema.json +5 -1
@@ -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 documentKeyPrefix = "document:";
24
- const unloadKeyPrefix = "unload:";
25
- const loadingKey = "loading";
26
- function documentKey(documentId) {
27
- return `${documentKeyPrefix}${documentId}`;
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
- function unloadKey(documentId) {
30
- return `${unloadKeyPrefix}${documentId}`;
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(documentKey(document.id), {
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(unloadKey(document.id), createRecord(document, {}));
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(documentKey(document.id), {
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 read(documentId) {
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 [checkpoint, unloadCopy] = await Promise.all([store.get(documentKey(documentId)), store.get(unloadKey(documentId))]);
125
- return newerRecord(fromStoredRecord(checkpoint), fromStoredRecord(unloadCopy));
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 store.delete(unloadKey(documentId));
156
- await store.delete(documentKey(documentId));
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
- read,
159
- async list() {
160
- await queue;
161
- const documentIds = /* @__PURE__ */ new Set();
162
- for (const key of await store.keys()) {
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
- markLoadStarted: (documentId) => store.set(loadingKey, {
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: () => store.delete(loadingKey).catch(options.onError),
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 marker = await store.get(loadingKey);
175
- return marker && typeof marker.startedAt === "string" ? marker : void 0;
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 documentKeyPrefix = "document:";
24
- const unloadKeyPrefix = "unload:";
25
- const loadingKey = "loading";
26
- function documentKey(documentId) {
27
- return `${documentKeyPrefix}${documentId}`;
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
- function unloadKey(documentId) {
30
- return `${unloadKeyPrefix}${documentId}`;
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(documentKey(document.id), {
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(unloadKey(document.id), createRecord(document, {}));
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(documentKey(document.id), {
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 read(documentId) {
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 [checkpoint, unloadCopy] = await Promise.all([store.get(documentKey(documentId)), store.get(unloadKey(documentId))]);
125
- return newerRecord(fromStoredRecord(checkpoint), fromStoredRecord(unloadCopy));
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 store.delete(unloadKey(documentId));
156
- await store.delete(documentKey(documentId));
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
- read,
159
- async list() {
160
- await queue;
161
- const documentIds = /* @__PURE__ */ new Set();
162
- for (const key of await store.keys()) {
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
- markLoadStarted: (documentId) => store.set(loadingKey, {
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: () => store.delete(loadingKey).catch(options.onError),
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 marker = await store.get(loadingKey);
175
- return marker && typeof marker.startedAt === "string" ? marker : void 0;
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
  }
@@ -1,6 +1,23 @@
1
1
  const require_asset_references = require("../assets/asset-references.cjs");
2
2
  const require_errors = require("../engine/errors.cjs");
3
3
  //#region src/security/content-limits.ts
4
+ const DEFAULT_MAX_CANVAS_SIDE = 16384;
5
+ const DEFAULT_MAX_CANVAS_PIXELS = 67108864;
6
+ const DEFAULT_MAX_IMAGE_PIXELS = 67108864;
7
+ const DEFAULT_MAX_DOCUMENT_LENGTH = 1e8;
8
+ /**
9
+ * Why a drawing surface of this size is refused, or undefined when it fits.
10
+ * Pure arithmetic, run before any canvas is created: a canvas over the
11
+ * browser's limit fails silently or crashes the tab.
12
+ */
13
+ function canvasSizeProblem(width, height, limits = {}) {
14
+ const maxSide = limits.maxCanvasSide ?? 16384;
15
+ const maxPixels = limits.maxCanvasPixels ?? 67108864;
16
+ const w = Math.ceil(width);
17
+ const h = Math.ceil(height);
18
+ if (w > maxSide || h > maxSide) return `${w} × ${h} pixels is larger than the ${maxSide}-pixel limit per side`;
19
+ if (w * h > maxPixels) return `${w} × ${h} pixels is ${w * h} pixels, more than the limit of ${maxPixels}`;
20
+ }
4
21
  const dangerousKeys = /* @__PURE__ */ new Set([
5
22
  "__proto__",
6
23
  "constructor",
@@ -14,8 +31,18 @@ function isSafeImageUrl(url) {
14
31
  if (scheme === "data") return trimmed.startsWith("data:image/");
15
32
  return false;
16
33
  }
17
- function withoutDangerousKeys(value) {
18
- return JSON.parse(JSON.stringify(value), (key, nested) => dangerousKeys.has(key) ? void 0 : nested);
34
+ function withoutDangerousKeys(json) {
35
+ return JSON.parse(json, (key, nested) => dangerousKeys.has(key) ? void 0 : nested);
36
+ }
37
+ function pageSize(document) {
38
+ const canvas = document.canvas;
39
+ return typeof canvas === "object" && canvas !== null ? {
40
+ width: canvas.width,
41
+ height: canvas.height
42
+ } : {
43
+ width: document.width,
44
+ height: document.height
45
+ };
19
46
  }
20
47
  function deepestNesting(value) {
21
48
  let deepest = 0;
@@ -56,8 +83,16 @@ function secureDocument(input, limits = {}) {
56
83
  if (typeof input !== "object" || input === null) return input;
57
84
  const maxDepth = limits.maxDepth ?? 100;
58
85
  const maxObjects = limits.maxObjects ?? 5e4;
59
- const document = withoutDangerousKeys(input);
86
+ const json = JSON.stringify(input);
87
+ const maxLength = limits.maxDocumentLength ?? 1e8;
88
+ if (json.length > maxLength) refuseWhenUnsafe([unsafe("", `the document is ${json.length} characters of JSON, the limit is ${maxLength}`)]);
89
+ const document = withoutDangerousKeys(json);
60
90
  const issues = [];
91
+ const { width, height } = pageSize(document);
92
+ if (typeof width === "number" && typeof height === "number") {
93
+ const problem = canvasSizeProblem(width, height, limits);
94
+ if (problem) issues.push(unsafe("canvas", `the page is ${problem}`));
95
+ }
61
96
  const depth = deepestNesting(document);
62
97
  if (depth > maxDepth) issues.push(unsafe("", `the document is nested ${depth} levels deep, the limit is ${maxDepth}`));
63
98
  if (Array.isArray(document.objects) && issues.length === 0) {
@@ -81,6 +116,11 @@ function refuseUnsafeImageUrls(objects, isAllowedUrl = isSafeImageUrl) {
81
116
  refuseWhenUnsafe(issues);
82
117
  }
83
118
  //#endregion
119
+ exports.DEFAULT_MAX_CANVAS_PIXELS = DEFAULT_MAX_CANVAS_PIXELS;
120
+ exports.DEFAULT_MAX_CANVAS_SIDE = DEFAULT_MAX_CANVAS_SIDE;
121
+ exports.DEFAULT_MAX_DOCUMENT_LENGTH = DEFAULT_MAX_DOCUMENT_LENGTH;
122
+ exports.DEFAULT_MAX_IMAGE_PIXELS = DEFAULT_MAX_IMAGE_PIXELS;
123
+ exports.canvasSizeProblem = canvasSizeProblem;
84
124
  exports.isSafeImageUrl = isSafeImageUrl;
85
125
  exports.refuseUnsafeImageUrls = refuseUnsafeImageUrls;
86
126
  exports.secureDocument = secureDocument;
@@ -4,6 +4,14 @@ export interface ContentLimits {
4
4
  maxObjects?: number;
5
5
  maxDepth?: number;
6
6
  isAllowedUrl?: (url: string) => boolean;
7
+ /** Longest side of the page or of an export, in pixels. Default 16,384. */
8
+ maxCanvasSide?: number;
9
+ /** Largest page or export area, in pixels. Default 67,108,864 (8,192 × 8,192, what iOS 18 Safari can draw). */
10
+ maxCanvasPixels?: number;
11
+ /** Largest decoded image, in pixels. Default 67,108,864. */
12
+ maxImagePixels?: number;
13
+ /** Longest document, in characters of JSON. Default 100,000,000. */
14
+ maxDocumentLength?: number;
7
15
  }
8
16
  export declare function isSafeImageUrl(url: string): boolean;
9
17
  export declare function secureDocument(input: unknown, limits?: ContentLimits): unknown;
@@ -4,6 +4,14 @@ export interface ContentLimits {
4
4
  maxObjects?: number;
5
5
  maxDepth?: number;
6
6
  isAllowedUrl?: (url: string) => boolean;
7
+ /** Longest side of the page or of an export, in pixels. Default 16,384. */
8
+ maxCanvasSide?: number;
9
+ /** Largest page or export area, in pixels. Default 67,108,864 (8,192 × 8,192, what iOS 18 Safari can draw). */
10
+ maxCanvasPixels?: number;
11
+ /** Largest decoded image, in pixels. Default 67,108,864. */
12
+ maxImagePixels?: number;
13
+ /** Longest document, in characters of JSON. Default 100,000,000. */
14
+ maxDocumentLength?: number;
7
15
  }
8
16
  export declare function isSafeImageUrl(url: string): boolean;
9
17
  export declare function secureDocument(input: unknown, limits?: ContentLimits): unknown;