fabricjs-document-engine 1.1.0 → 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 (70) hide show
  1. package/README.md +39 -1
  2. package/README.zh-CN.md +39 -1
  3. package/dist/assets/asset-pipeline.cjs +10 -8
  4. package/dist/assets/asset-pipeline.d.cts +1 -1
  5. package/dist/assets/asset-pipeline.d.ts +1 -1
  6. package/dist/assets/asset-pipeline.js +10 -8
  7. package/dist/assets/image-check.cjs +4 -3
  8. package/dist/assets/image-check.d.cts +1 -1
  9. package/dist/assets/image-check.d.ts +1 -1
  10. package/dist/assets/image-check.js +4 -3
  11. package/dist/commands/clipboard.cjs +5 -0
  12. package/dist/commands/clipboard.js +5 -0
  13. package/dist/document/document-format.d.cts +15 -0
  14. package/dist/document/document-format.d.ts +15 -0
  15. package/dist/document/validate-document.cjs +8 -0
  16. package/dist/document/validate-document.js +8 -0
  17. package/dist/engine/create-document-engine.cjs +115 -21
  18. package/dist/engine/create-document-engine.d.cts +24 -6
  19. package/dist/engine/create-document-engine.d.ts +24 -6
  20. package/dist/engine/create-document-engine.js +116 -22
  21. package/dist/engine/errors.d.cts +1 -1
  22. package/dist/engine/errors.d.ts +1 -1
  23. package/dist/export/export-options.cjs +3 -0
  24. package/dist/export/export-options.d.cts +6 -0
  25. package/dist/export/export-options.d.ts +6 -0
  26. package/dist/export/export-options.js +3 -0
  27. package/dist/export/preflight-export.d.cts +1 -1
  28. package/dist/export/preflight-export.d.ts +1 -1
  29. package/dist/export/render-export.cjs +42 -4
  30. package/dist/export/render-export.js +43 -5
  31. package/dist/export/svg/clip-masks.cjs +39 -0
  32. package/dist/export/svg/clip-masks.js +37 -0
  33. package/dist/export/svg/embed-assets.cjs +1 -0
  34. package/dist/export/svg/embed-assets.js +1 -0
  35. package/dist/export/svg/raster-object.cjs +82 -0
  36. package/dist/export/svg/raster-object.js +81 -0
  37. package/dist/{pdf → export/svg}/text-decorations.cjs +3 -3
  38. package/dist/{pdf → export/svg}/text-decorations.js +3 -3
  39. package/dist/export/svg/text-on-path.cjs +0 -10
  40. package/dist/export/svg/text-on-path.js +1 -10
  41. package/dist/fabric/fabric-adapter.cjs +5 -3
  42. package/dist/fabric/fabric-adapter.js +5 -3
  43. package/dist/fabric/page-state.cjs +69 -0
  44. package/dist/fabric/page-state.js +64 -0
  45. package/dist/history/apply-state.cjs +19 -2
  46. package/dist/history/apply-state.js +19 -2
  47. package/dist/history/create-history.cjs +53 -13
  48. package/dist/history/create-history.d.cts +8 -0
  49. package/dist/history/create-history.d.ts +8 -0
  50. package/dist/history/create-history.js +53 -13
  51. package/dist/history/snapshot.cjs +10 -1
  52. package/dist/history/snapshot.js +10 -2
  53. package/dist/index.d.cts +3 -3
  54. package/dist/index.d.ts +3 -3
  55. package/dist/migrations/migrate-document.cjs +2 -1
  56. package/dist/migrations/migrate-document.js +2 -1
  57. package/dist/pdf/export-pdf.cjs +8 -67
  58. package/dist/pdf/export-pdf.js +8 -67
  59. package/dist/recovery/recovery-controller.cjs +148 -29
  60. package/dist/recovery/recovery-controller.d.cts +8 -0
  61. package/dist/recovery/recovery-controller.d.ts +8 -0
  62. package/dist/recovery/recovery-controller.js +148 -29
  63. package/dist/security/content-limits.cjs +43 -3
  64. package/dist/security/content-limits.d.cts +8 -0
  65. package/dist/security/content-limits.d.ts +8 -0
  66. package/dist/security/content-limits.js +39 -4
  67. package/dist/util/concurrency.cjs +16 -3
  68. package/dist/util/concurrency.js +16 -3
  69. package/package.json +1 -1
  70. package/schema/document-v1.schema.json +5 -1
package/README.md CHANGED
@@ -593,6 +593,36 @@ getLayers(engine); // [{ id, type, name, index, visible, locked }], top first
593
593
 
594
594
  The engine saves each object's `name`, so a layers panel keeps its labels. In React, `useLayers(engine)` from `fabricjs-document-engine/react` returns the same list and updates on every change.
595
595
 
596
+ ## Edits that are never lost
597
+
598
+ These guarantees hold however the user and your code interleave work:
599
+
600
+ ```ts
601
+ // Page settings are one undo step and count as unsaved work
602
+ engine.setPage({ width: 1080, height: 1080, background: '#fff8e7' }, 'Square post');
603
+
604
+ // All or nothing: a failure leaves the canvas as it was
605
+ await engine.transaction('Apply template', async () => {
606
+ await addTemplateObjects(engine.canvas);
607
+ }, { rollback: true });
608
+
609
+ // A load refuses to overwrite edits made while it ran
610
+ try {
611
+ await engine.load('poster-42');
612
+ } catch (error) {
613
+ if (isDocumentEngineError(error) && error.code === 'LOAD_CONFLICT') askBeforeReplacing();
614
+ }
615
+ ```
616
+
617
+ - **The whole page is saved.** Background images, overlays and a mask on the canvas (`canvas.backgroundImage`, `overlayImage`, `clipPath`) are saved, reopened, checked for missing images and kept in versions and recovery copies.
618
+ - **Page changes are undoable.** `setPage` changes the size, background, overlay or mask as one undo step, and changes made to the canvas inside a `transaction` count too.
619
+ - **Typing counts at once.** Each keystroke marks the document unsaved and feeds autosave and recovery, while the whole edit stays one undo step.
620
+ - **Loads keep edits.** If the canvas is edited while a document loads, the load stops with `LOAD_CONFLICT` and the edits stay, unless you pass `discardUnsavedChanges: true`.
621
+ - **Async work stays in its document.** An SVG import, image replacement or paste that finishes after another document was opened is dropped with `DOCUMENT_CHANGED`, instead of landing in the wrong document.
622
+ - **Each tab has its own recovery copy.** Two tabs editing the same document no longer overwrite each other's copy, and a save removes only the copy it covers.
623
+ - **Sizes are checked before drawing.** Pages, exports and images over the browser's canvas limits are refused before any canvas is made, so a huge file cannot crash the tab. Limits are set with `limits`.
624
+ - **Rollback when you want it.** `transaction(label, work, { rollback: true })` undoes everything `work` changed when it fails.
625
+
596
626
  ## Document format
597
627
 
598
628
  ```ts
@@ -603,7 +633,15 @@ interface FabricDocument {
603
633
  updatedAt: string;
604
634
  revision?: number;
605
635
  fabricVersion?: string;
606
- canvas: { width: number; height: number; background?: unknown };
636
+ canvas: {
637
+ width: number;
638
+ height: number;
639
+ background?: unknown; // color, gradient or pattern
640
+ backgroundImage?: object; // Fabric image behind every object
641
+ overlay?: unknown; // color drawn over every object
642
+ overlayImage?: object; // Fabric image over every object
643
+ clipPath?: object; // mask for the whole canvas
644
+ };
607
645
  objects: SerializedFabricObject[];
608
646
  assets?: {
609
647
  images: Array<{ url: string; objectIds: string[] }>;
package/README.zh-CN.md CHANGED
@@ -593,6 +593,36 @@ getLayers(engine); // [{ id, type, name, index, visible, locked }], top first
593
593
 
594
594
  引擎会保存每个对象的 `name`,所以图层面板的名称不会丢。在 React 中,`fabricjs-document-engine/react` 的 `useLayers(engine)` 返回同样的列表,并在每次改动后更新。
595
595
 
596
+ ## 不会丢失的修改
597
+
598
+ 无论用户的操作和你的代码怎样交错,下面这些保证都成立:
599
+
600
+ ```ts
601
+ // Page settings are one undo step and count as unsaved work
602
+ engine.setPage({ width: 1080, height: 1080, background: '#fff8e7' }, 'Square post');
603
+
604
+ // All or nothing: a failure leaves the canvas as it was
605
+ await engine.transaction('Apply template', async () => {
606
+ await addTemplateObjects(engine.canvas);
607
+ }, { rollback: true });
608
+
609
+ // A load refuses to overwrite edits made while it ran
610
+ try {
611
+ await engine.load('poster-42');
612
+ } catch (error) {
613
+ if (isDocumentEngineError(error) && error.code === 'LOAD_CONFLICT') askBeforeReplacing();
614
+ }
615
+ ```
616
+
617
+ - **整个页面都会保存。** 画布上的背景图片、叠加层和蒙版(`canvas.backgroundImage`、`overlayImage`、`clipPath`)会被保存、重新打开、检查缺失图片,也会进入版本和恢复副本。
618
+ - **页面修改可以撤销。** `setPage` 修改尺寸、背景、叠加层或蒙版,只算一步撤销;在 `transaction` 里直接修改画布也会被记录。
619
+ - **输入立即算作修改。** 每次按键都会把文档标记为未保存,并触发自动保存和恢复副本,而整次编辑仍然只算一步撤销。
620
+ - **加载不会覆盖修改。** 如果文档加载期间画布被修改,加载会以 `LOAD_CONFLICT` 停止并保留这些修改,除非传入 `discardUnsavedChanges: true`。
621
+ - **异步操作留在原来的文档里。** 在打开另一份文档之后才完成的 SVG 导入、图片替换或粘贴会以 `DOCUMENT_CHANGED` 被丢弃,而不会落到错误的文档里。
622
+ - **每个标签页有自己的恢复副本。** 两个标签页编辑同一份文档时不再互相覆盖副本,保存时只删除它覆盖的那一份。
623
+ - **绘制前先检查尺寸。** 超过浏览器画布限制的页面、导出和图片,会在创建任何画布之前被拒绝,大文件不会让标签页崩溃。限制用 `limits` 设置。
624
+ - **需要时可以回滚。** `transaction(label, work, { rollback: true })` 在 `work` 失败时撤回它做的所有修改。
625
+
596
626
  ## 文档格式
597
627
 
598
628
  ```ts
@@ -603,7 +633,15 @@ interface FabricDocument {
603
633
  updatedAt: string;
604
634
  revision?: number;
605
635
  fabricVersion?: string;
606
- canvas: { width: number; height: number; background?: unknown };
636
+ canvas: {
637
+ width: number;
638
+ height: number;
639
+ background?: unknown; // color, gradient or pattern
640
+ backgroundImage?: object; // Fabric image behind every object
641
+ overlay?: unknown; // color drawn over every object
642
+ overlayImage?: object; // Fabric image over every object
643
+ clipPath?: object; // mask for the whole canvas
644
+ };
607
645
  objects: SerializedFabricObject[];
608
646
  assets?: {
609
647
  images: Array<{ url: string; objectIds: string[] }>;
@@ -1,5 +1,6 @@
1
1
  const require_asset_references = require("./asset-references.cjs");
2
2
  const require_asset_manifest = require("./asset-manifest.cjs");
3
+ const require_page_state = require("../fabric/page-state.cjs");
3
4
  const require_errors = require("../engine/errors.cjs");
4
5
  const require_font_check = require("./font-check.cjs");
5
6
  const require_content_limits = require("../security/content-limits.cjs");
@@ -8,7 +9,8 @@ const require_image_check = require("./image-check.cjs");
8
9
  function imageCheckOptions(options) {
9
10
  return {
10
11
  timeoutMs: options.imageTimeout,
11
- concurrency: options.maxConcurrentImages
12
+ concurrency: options.maxConcurrentImages,
13
+ maxPixels: options.maxImagePixels
12
14
  };
13
15
  }
14
16
  function cloneDocument(document) {
@@ -33,7 +35,7 @@ function pointTo(references, url) {
33
35
  }
34
36
  }
35
37
  async function rewriteUrls(document, rewrite) {
36
- const groups = groupByUrl(require_asset_references.findImageReferences(document.objects));
38
+ const groups = groupByUrl(require_asset_references.findImageReferences(require_page_state.documentObjects(document)));
37
39
  await Promise.all([...groups].map(async ([url, references]) => {
38
40
  const next = await rewrite(url, references);
39
41
  if (next !== url) pointTo(references, next);
@@ -41,7 +43,7 @@ async function rewriteUrls(document, rewrite) {
41
43
  }
42
44
  function crossOriginWarnings(document) {
43
45
  const warnings = [];
44
- for (const [url, references] of groupByUrl(require_asset_references.findImageReferences(document.objects))) {
46
+ for (const [url, references] of groupByUrl(require_asset_references.findImageReferences(require_page_state.documentObjects(document)))) {
45
47
  if (!require_image_check.isCrossOriginUrl(url) || references.every((reference) => reference.crossOrigin)) continue;
46
48
  warnings.push({
47
49
  code: "IMAGE_CROSS_ORIGIN",
@@ -61,8 +63,8 @@ function fontWarnings(fonts) {
61
63
  }));
62
64
  }
63
65
  async function inspectAssets(document, options, signal, onImageProgress) {
64
- const manifest = require_asset_manifest.buildAssetManifest(document.objects);
65
- const uniqueImages = [...groupByUrl(require_asset_references.findImageReferences(document.objects).filter((reference) => !require_asset_manifest.isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
66
+ const manifest = require_asset_manifest.buildAssetManifest(require_page_state.documentObjects(document));
67
+ const uniqueImages = [...groupByUrl(require_asset_references.findImageReferences(require_page_state.documentObjects(document)).filter((reference) => !require_asset_manifest.isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
66
68
  url,
67
69
  crossOrigin: references[0].crossOrigin
68
70
  }));
@@ -98,7 +100,7 @@ async function replaceMissingImages(document, missingImages, options, signal) {
98
100
  })), signal, imageCheckOptions(options))).map((failure) => failure.url));
99
101
  const stillMissing = [];
100
102
  const warnings = [];
101
- const groups = groupByUrl(require_asset_references.findImageReferences(document.objects));
103
+ const groups = groupByUrl(require_asset_references.findImageReferences(require_page_state.documentObjects(document)));
102
104
  for (const image of missingImages) {
103
105
  const replacement = replacements.get(image.url);
104
106
  if (replacement === void 0 || brokenReplacements.has(replacement)) {
@@ -122,7 +124,7 @@ async function prepareAssetsForLoad(input, options, signal, isAllowedUrl, onImag
122
124
  const document = cloneDocument(input);
123
125
  const { resolveUrl } = options;
124
126
  if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
125
- require_content_limits.refuseUnsafeImageUrls(document.objects, isAllowedUrl);
127
+ require_content_limits.refuseUnsafeImageUrls(require_page_state.documentObjects(document), isAllowedUrl);
126
128
  const report = await inspectAssets(document, options, signal, onImageProgress);
127
129
  if (options.requireFonts && report.unavailableFonts.length > 0) {
128
130
  const families = report.unavailableFonts.map((font) => font.family).join(", ");
@@ -176,7 +178,7 @@ async function prepareAssetsForSave(document, options, uploadedUrls) {
176
178
  }
177
179
  return uploading;
178
180
  });
179
- document.assets = require_asset_manifest.buildAssetManifest(document.objects);
181
+ document.assets = require_asset_manifest.buildAssetManifest(require_page_state.documentObjects(document));
180
182
  return {
181
183
  document,
182
184
  warnings
@@ -1,7 +1,7 @@
1
1
  import { AssetManifest, FontAsset, ImageAsset } from "./asset-manifest.cjs";
2
2
  import { FontLoader } from "./font-check.cjs";
3
3
  //#region src/assets/asset-pipeline.d.ts
4
- export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED" | "IMAGE_NOT_EMBEDDED" | "FONT_NOT_EMBEDDED" | "TEXT_ON_PATH_APPROXIMATED";
4
+ export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED" | "IMAGE_NOT_EMBEDDED" | "FONT_NOT_EMBEDDED" | "TEXT_ON_PATH_APPROXIMATED" | "CLIP_PATH_RASTERIZED";
5
5
  export interface AssetWarning {
6
6
  code: AssetWarningCode;
7
7
  message: string;
@@ -1,7 +1,7 @@
1
1
  import { AssetManifest, FontAsset, ImageAsset } from "./asset-manifest.js";
2
2
  import { FontLoader } from "./font-check.js";
3
3
  //#region src/assets/asset-pipeline.d.ts
4
- export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED" | "IMAGE_NOT_EMBEDDED" | "FONT_NOT_EMBEDDED" | "TEXT_ON_PATH_APPROXIMATED";
4
+ export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED" | "IMAGE_NOT_EMBEDDED" | "FONT_NOT_EMBEDDED" | "TEXT_ON_PATH_APPROXIMATED" | "CLIP_PATH_RASTERIZED";
5
5
  export interface AssetWarning {
6
6
  code: AssetWarningCode;
7
7
  message: string;
@@ -1,5 +1,6 @@
1
1
  import { findImageReferences } from "./asset-references.js";
2
2
  import { buildAssetManifest, isEmbeddedUrl } from "./asset-manifest.js";
3
+ import { documentObjects } from "../fabric/page-state.js";
3
4
  import { DocumentEngineError, isDocumentEngineError } from "../engine/errors.js";
4
5
  import { findUnavailableFonts } from "./font-check.js";
5
6
  import { refuseUnsafeImageUrls } from "../security/content-limits.js";
@@ -8,7 +9,8 @@ import { findMissingImages, isCrossOriginUrl, isPortableUrl } from "./image-chec
8
9
  function imageCheckOptions(options) {
9
10
  return {
10
11
  timeoutMs: options.imageTimeout,
11
- concurrency: options.maxConcurrentImages
12
+ concurrency: options.maxConcurrentImages,
13
+ maxPixels: options.maxImagePixels
12
14
  };
13
15
  }
14
16
  function cloneDocument(document) {
@@ -33,7 +35,7 @@ function pointTo(references, url) {
33
35
  }
34
36
  }
35
37
  async function rewriteUrls(document, rewrite) {
36
- const groups = groupByUrl(findImageReferences(document.objects));
38
+ const groups = groupByUrl(findImageReferences(documentObjects(document)));
37
39
  await Promise.all([...groups].map(async ([url, references]) => {
38
40
  const next = await rewrite(url, references);
39
41
  if (next !== url) pointTo(references, next);
@@ -41,7 +43,7 @@ async function rewriteUrls(document, rewrite) {
41
43
  }
42
44
  function crossOriginWarnings(document) {
43
45
  const warnings = [];
44
- for (const [url, references] of groupByUrl(findImageReferences(document.objects))) {
46
+ for (const [url, references] of groupByUrl(findImageReferences(documentObjects(document)))) {
45
47
  if (!isCrossOriginUrl(url) || references.every((reference) => reference.crossOrigin)) continue;
46
48
  warnings.push({
47
49
  code: "IMAGE_CROSS_ORIGIN",
@@ -61,8 +63,8 @@ function fontWarnings(fonts) {
61
63
  }));
62
64
  }
63
65
  async function inspectAssets(document, options, signal, onImageProgress) {
64
- const manifest = buildAssetManifest(document.objects);
65
- const uniqueImages = [...groupByUrl(findImageReferences(document.objects).filter((reference) => !isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
66
+ const manifest = buildAssetManifest(documentObjects(document));
67
+ const uniqueImages = [...groupByUrl(findImageReferences(documentObjects(document)).filter((reference) => !isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
66
68
  url,
67
69
  crossOrigin: references[0].crossOrigin
68
70
  }));
@@ -98,7 +100,7 @@ async function replaceMissingImages(document, missingImages, options, signal) {
98
100
  })), signal, imageCheckOptions(options))).map((failure) => failure.url));
99
101
  const stillMissing = [];
100
102
  const warnings = [];
101
- const groups = groupByUrl(findImageReferences(document.objects));
103
+ const groups = groupByUrl(findImageReferences(documentObjects(document)));
102
104
  for (const image of missingImages) {
103
105
  const replacement = replacements.get(image.url);
104
106
  if (replacement === void 0 || brokenReplacements.has(replacement)) {
@@ -122,7 +124,7 @@ async function prepareAssetsForLoad(input, options, signal, isAllowedUrl, onImag
122
124
  const document = cloneDocument(input);
123
125
  const { resolveUrl } = options;
124
126
  if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
125
- refuseUnsafeImageUrls(document.objects, isAllowedUrl);
127
+ refuseUnsafeImageUrls(documentObjects(document), isAllowedUrl);
126
128
  const report = await inspectAssets(document, options, signal, onImageProgress);
127
129
  if (options.requireFonts && report.unavailableFonts.length > 0) {
128
130
  const families = report.unavailableFonts.map((font) => font.family).join(", ");
@@ -176,7 +178,7 @@ async function prepareAssetsForSave(document, options, uploadedUrls) {
176
178
  }
177
179
  return uploading;
178
180
  });
179
- document.assets = buildAssetManifest(document.objects);
181
+ document.assets = buildAssetManifest(documentObjects(document));
180
182
  return {
181
183
  document,
182
184
  warnings
@@ -73,7 +73,7 @@ async function explainFailure(image, signal) {
73
73
  }
74
74
  return failure(url, "NETWORK", "could not be reached. Check the URL and the network connection.");
75
75
  }
76
- async function checkImage(image, signal, timeoutMs) {
76
+ async function checkImage(image, signal, timeoutMs, maxPixels) {
77
77
  const attempt = new AbortController();
78
78
  let timedOut = false;
79
79
  const stop = () => attempt.abort();
@@ -84,10 +84,11 @@ async function checkImage(image, signal, timeoutMs) {
84
84
  }, timeoutMs) : void 0;
85
85
  try {
86
86
  if (signal.aborted) attempt.abort();
87
- await fabric.util.loadImage(image.url, {
87
+ const element = await fabric.util.loadImage(image.url, {
88
88
  signal: attempt.signal,
89
89
  crossOrigin: image.crossOrigin ?? null
90
90
  });
91
+ if (element.naturalWidth * element.naturalHeight > maxPixels) return failure(image.url, "TOO_LARGE", `is ${element.naturalWidth} × ${element.naturalHeight} pixels, more than the limit of ${maxPixels}`);
91
92
  return;
92
93
  } catch {
93
94
  if (timedOut) return failure(image.url, "TIMEOUT", `did not finish loading within ${timeoutMs} ms`, { timeoutMs });
@@ -104,7 +105,7 @@ async function findMissingImages(images, signal, options = {}) {
104
105
  let done = 0;
105
106
  options.onProgress?.(0, images.length);
106
107
  return (await require_concurrency.mapWithConcurrency(images, options.concurrency ?? 6, async (image) => {
107
- const result = await checkImage(image, signal, timeoutMs);
108
+ const result = await checkImage(image, signal, timeoutMs, options.maxPixels ?? Infinity);
108
109
  done += 1;
109
110
  options.onProgress?.(done, images.length);
110
111
  return result;
@@ -4,7 +4,7 @@
4
4
  * a server on another site that sends no CORS headers looks the same as a
5
5
  * network failure unless the image asked for CORS.
6
6
  */
7
- export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED";
7
+ export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED" | "TOO_LARGE";
8
8
  export interface ImageLoadFailure {
9
9
  url: string;
10
10
  reason: ImageFailureReason;
@@ -4,7 +4,7 @@
4
4
  * a server on another site that sends no CORS headers looks the same as a
5
5
  * network failure unless the image asked for CORS.
6
6
  */
7
- export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED";
7
+ export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED" | "TOO_LARGE";
8
8
  export interface ImageLoadFailure {
9
9
  url: string;
10
10
  reason: ImageFailureReason;
@@ -73,7 +73,7 @@ async function explainFailure(image, signal) {
73
73
  }
74
74
  return failure(url, "NETWORK", "could not be reached. Check the URL and the network connection.");
75
75
  }
76
- async function checkImage(image, signal, timeoutMs) {
76
+ async function checkImage(image, signal, timeoutMs, maxPixels) {
77
77
  const attempt = new AbortController();
78
78
  let timedOut = false;
79
79
  const stop = () => attempt.abort();
@@ -84,10 +84,11 @@ async function checkImage(image, signal, timeoutMs) {
84
84
  }, timeoutMs) : void 0;
85
85
  try {
86
86
  if (signal.aborted) attempt.abort();
87
- await util.loadImage(image.url, {
87
+ const element = await util.loadImage(image.url, {
88
88
  signal: attempt.signal,
89
89
  crossOrigin: image.crossOrigin ?? null
90
90
  });
91
+ if (element.naturalWidth * element.naturalHeight > maxPixels) return failure(image.url, "TOO_LARGE", `is ${element.naturalWidth} × ${element.naturalHeight} pixels, more than the limit of ${maxPixels}`);
91
92
  return;
92
93
  } catch {
93
94
  if (timedOut) return failure(image.url, "TIMEOUT", `did not finish loading within ${timeoutMs} ms`, { timeoutMs });
@@ -104,7 +105,7 @@ async function findMissingImages(images, signal, options = {}) {
104
105
  let done = 0;
105
106
  options.onProgress?.(0, images.length);
106
107
  return (await mapWithConcurrency(images, options.concurrency ?? 6, async (image) => {
107
- const result = await checkImage(image, signal, timeoutMs);
108
+ const result = await checkImage(image, signal, timeoutMs, options.maxPixels ?? Infinity);
108
109
  done += 1;
109
110
  options.onProgress?.(done, images.length);
110
111
  return result;
@@ -111,7 +111,12 @@ function createClipboard(engine, options = {}) {
111
111
  const distance = offset * pastes;
112
112
  const serialized = content.objects.map((object) => moved(withoutIds(object), distance));
113
113
  refuseUnknownTypes(serialized);
114
+ const startedIn = target.getDocumentInfo().session;
114
115
  const created = await require_fabric_adapter.createObjects(clone(serialized));
116
+ if (target.getDocumentInfo().session !== startedIn) {
117
+ created.forEach((object) => object.dispose?.());
118
+ throw new require_errors.DocumentEngineError("DOCUMENT_CHANGED", "The paste was dropped because another document was opened while it ran");
119
+ }
115
120
  target.transaction(created.length === 1 ? "Paste" : `Paste ${created.length} objects`, () => {
116
121
  target.canvas.add(...created);
117
122
  });
@@ -111,7 +111,12 @@ function createClipboard(engine, options = {}) {
111
111
  const distance = offset * pastes;
112
112
  const serialized = content.objects.map((object) => moved(withoutIds(object), distance));
113
113
  refuseUnknownTypes(serialized);
114
+ const startedIn = target.getDocumentInfo().session;
114
115
  const created = await createObjects(clone(serialized));
116
+ if (target.getDocumentInfo().session !== startedIn) {
117
+ created.forEach((object) => object.dispose?.());
118
+ throw new DocumentEngineError("DOCUMENT_CHANGED", "The paste was dropped because another document was opened while it ran");
119
+ }
115
120
  target.transaction(created.length === 1 ? "Paste" : `Paste ${created.length} objects`, () => {
116
121
  target.canvas.add(...created);
117
122
  });
@@ -11,7 +11,16 @@ export interface SerializedFabricObject {
11
11
  export interface DocumentCanvas {
12
12
  width: number;
13
13
  height: number;
14
+ /** A color, gradient or pattern behind everything. */
14
15
  background?: unknown;
16
+ /** A Fabric image drawn behind every object. */
17
+ backgroundImage?: SerializedFabricObject;
18
+ /** A color, gradient or pattern drawn over every object. */
19
+ overlay?: unknown;
20
+ /** A Fabric image drawn over every object. */
21
+ overlayImage?: SerializedFabricObject;
22
+ /** A Fabric object that clips the whole canvas. */
23
+ clipPath?: SerializedFabricObject;
15
24
  }
16
25
  export interface FabricDocument {
17
26
  schemaVersion: number;
@@ -30,5 +39,11 @@ export interface DocumentInfo {
30
39
  createdAt: string;
31
40
  updatedAt: string;
32
41
  metadata: Record<string, unknown>;
42
+ /**
43
+ * Goes up each time the canvas shows a different document: a load, a new
44
+ * document or a restore. Async work started under one session must not
45
+ * change the canvas in another. Set by `getDocumentInfo()`.
46
+ */
47
+ session?: number;
33
48
  }
34
49
  //#endregion
@@ -11,7 +11,16 @@ export interface SerializedFabricObject {
11
11
  export interface DocumentCanvas {
12
12
  width: number;
13
13
  height: number;
14
+ /** A color, gradient or pattern behind everything. */
14
15
  background?: unknown;
16
+ /** A Fabric image drawn behind every object. */
17
+ backgroundImage?: SerializedFabricObject;
18
+ /** A color, gradient or pattern drawn over every object. */
19
+ overlay?: unknown;
20
+ /** A Fabric image drawn over every object. */
21
+ overlayImage?: SerializedFabricObject;
22
+ /** A Fabric object that clips the whole canvas. */
23
+ clipPath?: SerializedFabricObject;
15
24
  }
16
25
  export interface FabricDocument {
17
26
  schemaVersion: number;
@@ -30,5 +39,11 @@ export interface DocumentInfo {
30
39
  createdAt: string;
31
40
  updatedAt: string;
32
41
  metadata: Record<string, unknown>;
42
+ /**
43
+ * Goes up each time the canvas shows a different document: a load, a new
44
+ * document or a restore. Async work started under one session must not
45
+ * change the canvas in another. Set by `getDocumentInfo()`.
46
+ */
47
+ session?: number;
33
48
  }
34
49
  //#endregion
@@ -67,6 +67,14 @@ function validateDocument(value) {
67
67
  else {
68
68
  if (!isPositiveNumber(value.canvas.width)) issues.push(invalid("canvas.width", "must be a positive number"));
69
69
  if (!isPositiveNumber(value.canvas.height)) issues.push(invalid("canvas.height", "must be a positive number"));
70
+ for (const field of [
71
+ "backgroundImage",
72
+ "overlayImage",
73
+ "clipPath"
74
+ ]) {
75
+ const pageObject = value.canvas[field];
76
+ if (pageObject !== void 0 && pageObject !== null) validateObjectList([pageObject], `canvas.${field}`, issues);
77
+ }
70
78
  }
71
79
  validateObjectList(value.objects, "objects", issues);
72
80
  return issues;
@@ -67,6 +67,14 @@ function validateDocument(value) {
67
67
  else {
68
68
  if (!isPositiveNumber(value.canvas.width)) issues.push(invalid("canvas.width", "must be a positive number"));
69
69
  if (!isPositiveNumber(value.canvas.height)) issues.push(invalid("canvas.height", "must be a positive number"));
70
+ for (const field of [
71
+ "backgroundImage",
72
+ "overlayImage",
73
+ "clipPath"
74
+ ]) {
75
+ const pageObject = value.canvas[field];
76
+ if (pageObject !== void 0 && pageObject !== null) validateObjectList([pageObject], `canvas.${field}`, issues);
77
+ }
70
78
  }
71
79
  validateObjectList(value.objects, "objects", issues);
72
80
  return issues;