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
@@ -29,9 +29,17 @@ function cssFamily(family) {
29
29
  function fontDescriptor(font, size = 32) {
30
30
  return `${font.style} ${font.weight} ${size}px ${cssFamily(firstFamily(font.family))}`;
31
31
  }
32
+ let measuringContext;
33
+ /** One small canvas for every check, rather than a new one each time. */
32
34
  function createMeasuringContext() {
33
35
  if (typeof document === "undefined") return null;
34
- return document.createElement("canvas").getContext("2d");
36
+ if (measuringContext === void 0) {
37
+ const canvas = document.createElement("canvas");
38
+ canvas.width = 1;
39
+ canvas.height = 1;
40
+ measuringContext = canvas.getContext("2d");
41
+ }
42
+ return measuringContext;
35
43
  }
36
44
  function isFontRenderable(font, context = createMeasuringContext()) {
37
45
  const family = firstFamily(font.family);
@@ -29,9 +29,17 @@ function cssFamily(family) {
29
29
  function fontDescriptor(font, size = 32) {
30
30
  return `${font.style} ${font.weight} ${size}px ${cssFamily(firstFamily(font.family))}`;
31
31
  }
32
+ let measuringContext;
33
+ /** One small canvas for every check, rather than a new one each time. */
32
34
  function createMeasuringContext() {
33
35
  if (typeof document === "undefined") return null;
34
- return document.createElement("canvas").getContext("2d");
36
+ if (measuringContext === void 0) {
37
+ const canvas = document.createElement("canvas");
38
+ canvas.width = 1;
39
+ canvas.height = 1;
40
+ measuringContext = canvas.getContext("2d");
41
+ }
42
+ return measuringContext;
35
43
  }
36
44
  function isFontRenderable(font, context = createMeasuringContext()) {
37
45
  const family = firstFamily(font.family);
@@ -1,5 +1,7 @@
1
+ const require_concurrency = require("../util/concurrency.cjs");
1
2
  let fabric = require("fabric");
2
3
  //#region src/assets/image-check.ts
4
+ const DEFAULT_IMAGE_TIMEOUT_MS = 3e4;
3
5
  function isCrossOriginUrl(url) {
4
6
  const location = globalThis.location;
5
7
  if (!location || url.startsWith("data:") || url.startsWith("blob:")) return false;
@@ -22,14 +24,95 @@ function isSameUrl(first, second) {
22
24
  return false;
23
25
  }
24
26
  }
25
- async function findMissingImages(images, signal) {
26
- const results = await Promise.allSettled(images.map((image) => fabric.util.loadImage(image.url, {
27
- signal,
28
- crossOrigin: image.crossOrigin ?? null
29
- })));
30
- return images.filter((_, index) => results[index].status === "rejected").map((image) => image.url);
27
+ function shortUrl(url) {
28
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
29
+ }
30
+ function failure(url, reason, message, extra = {}) {
31
+ return {
32
+ url,
33
+ reason,
34
+ message: `The image ${shortUrl(url)} ${message}`,
35
+ ...extra
36
+ };
37
+ }
38
+ async function fetchHeaders(url, init) {
39
+ const response = await fetch(url, {
40
+ cache: "no-store",
41
+ ...init
42
+ });
43
+ response.body?.cancel().catch(() => void 0);
44
+ return response;
45
+ }
46
+ /** Works out why an image failed to load, after it already failed. */
47
+ async function explainFailure(image, signal) {
48
+ const { url } = image;
49
+ if (url.startsWith("data:")) return failure(url, "DECODE", "is embedded data the browser cannot decode as a picture");
50
+ if (typeof fetch !== "function") return failure(url, "NETWORK", "could not be loaded");
51
+ const credentials = image.crossOrigin === "use-credentials" ? "include" : "same-origin";
52
+ try {
53
+ const response = await fetchHeaders(url, {
54
+ mode: "cors",
55
+ credentials,
56
+ signal
57
+ });
58
+ if (response.ok) return failure(url, "DECODE", "downloaded, but the browser could not decode it as a picture", { status: response.status });
59
+ if (response.status === 404 || response.status === 410) return failure(url, "NOT_FOUND", `was not found (HTTP ${response.status})`, { status: response.status });
60
+ return failure(url, "HTTP_ERROR", `could not be downloaded (HTTP ${response.status})`, { status: response.status });
61
+ } catch {
62
+ if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
63
+ }
64
+ if (isCrossOriginUrl(url)) try {
65
+ await fetchHeaders(url, {
66
+ mode: "no-cors",
67
+ signal
68
+ });
69
+ if (image.crossOrigin) return failure(url, "CORS", "comes from another site that does not send CORS headers (Access-Control-Allow-Origin) for this page");
70
+ return failure(url, "NETWORK", "comes from another site that answered, but the browser hides the reason it failed. Check the URL and the server.");
71
+ } catch {
72
+ if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
73
+ }
74
+ return failure(url, "NETWORK", "could not be reached. Check the URL and the network connection.");
75
+ }
76
+ async function checkImage(image, signal, timeoutMs, maxPixels) {
77
+ const attempt = new AbortController();
78
+ let timedOut = false;
79
+ const stop = () => attempt.abort();
80
+ signal.addEventListener("abort", stop, { once: true });
81
+ const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => {
82
+ timedOut = true;
83
+ attempt.abort();
84
+ }, timeoutMs) : void 0;
85
+ try {
86
+ if (signal.aborted) attempt.abort();
87
+ const element = await fabric.util.loadImage(image.url, {
88
+ signal: attempt.signal,
89
+ crossOrigin: image.crossOrigin ?? null
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}`);
92
+ return;
93
+ } catch {
94
+ if (timedOut) return failure(image.url, "TIMEOUT", `did not finish loading within ${timeoutMs} ms`, { timeoutMs });
95
+ if (signal.aborted) return failure(image.url, "ABORTED", "stopped loading because the work was cancelled");
96
+ return explainFailure(image, signal);
97
+ } finally {
98
+ if (timer !== void 0) clearTimeout(timer);
99
+ signal.removeEventListener("abort", stop);
100
+ }
101
+ }
102
+ /** Loads every image, a few at a time, and returns the ones that failed. */
103
+ async function findMissingImages(images, signal, options = {}) {
104
+ const timeoutMs = options.timeoutMs ?? 3e4;
105
+ let done = 0;
106
+ options.onProgress?.(0, images.length);
107
+ return (await require_concurrency.mapWithConcurrency(images, options.concurrency ?? 6, async (image) => {
108
+ const result = await checkImage(image, signal, timeoutMs, options.maxPixels ?? Infinity);
109
+ done += 1;
110
+ options.onProgress?.(done, images.length);
111
+ return result;
112
+ })).filter((result) => result !== void 0);
31
113
  }
32
114
  //#endregion
115
+ exports.DEFAULT_IMAGE_TIMEOUT_MS = DEFAULT_IMAGE_TIMEOUT_MS;
33
116
  exports.findMissingImages = findMissingImages;
34
117
  exports.isCrossOriginUrl = isCrossOriginUrl;
35
118
  exports.isPortableUrl = isPortableUrl;
@@ -0,0 +1,17 @@
1
+ //#region src/assets/image-check.d.ts
2
+ /**
3
+ * Why an image could not be loaded. Browsers hide some details on purpose:
4
+ * a server on another site that sends no CORS headers looks the same as a
5
+ * network failure unless the image asked for CORS.
6
+ */
7
+ export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED" | "TOO_LARGE";
8
+ export interface ImageLoadFailure {
9
+ url: string;
10
+ reason: ImageFailureReason;
11
+ message: string;
12
+ /** The HTTP status, when the browser lets the page read it. */
13
+ status?: number;
14
+ /** The time limit that ran out, for `TIMEOUT`. */
15
+ timeoutMs?: number;
16
+ }
17
+ //#endregion
@@ -0,0 +1,17 @@
1
+ //#region src/assets/image-check.d.ts
2
+ /**
3
+ * Why an image could not be loaded. Browsers hide some details on purpose:
4
+ * a server on another site that sends no CORS headers looks the same as a
5
+ * network failure unless the image asked for CORS.
6
+ */
7
+ export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED" | "TOO_LARGE";
8
+ export interface ImageLoadFailure {
9
+ url: string;
10
+ reason: ImageFailureReason;
11
+ message: string;
12
+ /** The HTTP status, when the browser lets the page read it. */
13
+ status?: number;
14
+ /** The time limit that ran out, for `TIMEOUT`. */
15
+ timeoutMs?: number;
16
+ }
17
+ //#endregion
@@ -1,5 +1,7 @@
1
+ import { mapWithConcurrency } from "../util/concurrency.js";
1
2
  import { util } from "fabric";
2
3
  //#region src/assets/image-check.ts
4
+ const DEFAULT_IMAGE_TIMEOUT_MS = 3e4;
3
5
  function isCrossOriginUrl(url) {
4
6
  const location = globalThis.location;
5
7
  if (!location || url.startsWith("data:") || url.startsWith("blob:")) return false;
@@ -22,12 +24,92 @@ function isSameUrl(first, second) {
22
24
  return false;
23
25
  }
24
26
  }
25
- async function findMissingImages(images, signal) {
26
- const results = await Promise.allSettled(images.map((image) => util.loadImage(image.url, {
27
- signal,
28
- crossOrigin: image.crossOrigin ?? null
29
- })));
30
- return images.filter((_, index) => results[index].status === "rejected").map((image) => image.url);
27
+ function shortUrl(url) {
28
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
29
+ }
30
+ function failure(url, reason, message, extra = {}) {
31
+ return {
32
+ url,
33
+ reason,
34
+ message: `The image ${shortUrl(url)} ${message}`,
35
+ ...extra
36
+ };
37
+ }
38
+ async function fetchHeaders(url, init) {
39
+ const response = await fetch(url, {
40
+ cache: "no-store",
41
+ ...init
42
+ });
43
+ response.body?.cancel().catch(() => void 0);
44
+ return response;
45
+ }
46
+ /** Works out why an image failed to load, after it already failed. */
47
+ async function explainFailure(image, signal) {
48
+ const { url } = image;
49
+ if (url.startsWith("data:")) return failure(url, "DECODE", "is embedded data the browser cannot decode as a picture");
50
+ if (typeof fetch !== "function") return failure(url, "NETWORK", "could not be loaded");
51
+ const credentials = image.crossOrigin === "use-credentials" ? "include" : "same-origin";
52
+ try {
53
+ const response = await fetchHeaders(url, {
54
+ mode: "cors",
55
+ credentials,
56
+ signal
57
+ });
58
+ if (response.ok) return failure(url, "DECODE", "downloaded, but the browser could not decode it as a picture", { status: response.status });
59
+ if (response.status === 404 || response.status === 410) return failure(url, "NOT_FOUND", `was not found (HTTP ${response.status})`, { status: response.status });
60
+ return failure(url, "HTTP_ERROR", `could not be downloaded (HTTP ${response.status})`, { status: response.status });
61
+ } catch {
62
+ if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
63
+ }
64
+ if (isCrossOriginUrl(url)) try {
65
+ await fetchHeaders(url, {
66
+ mode: "no-cors",
67
+ signal
68
+ });
69
+ if (image.crossOrigin) return failure(url, "CORS", "comes from another site that does not send CORS headers (Access-Control-Allow-Origin) for this page");
70
+ return failure(url, "NETWORK", "comes from another site that answered, but the browser hides the reason it failed. Check the URL and the server.");
71
+ } catch {
72
+ if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
73
+ }
74
+ return failure(url, "NETWORK", "could not be reached. Check the URL and the network connection.");
75
+ }
76
+ async function checkImage(image, signal, timeoutMs, maxPixels) {
77
+ const attempt = new AbortController();
78
+ let timedOut = false;
79
+ const stop = () => attempt.abort();
80
+ signal.addEventListener("abort", stop, { once: true });
81
+ const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => {
82
+ timedOut = true;
83
+ attempt.abort();
84
+ }, timeoutMs) : void 0;
85
+ try {
86
+ if (signal.aborted) attempt.abort();
87
+ const element = await util.loadImage(image.url, {
88
+ signal: attempt.signal,
89
+ crossOrigin: image.crossOrigin ?? null
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}`);
92
+ return;
93
+ } catch {
94
+ if (timedOut) return failure(image.url, "TIMEOUT", `did not finish loading within ${timeoutMs} ms`, { timeoutMs });
95
+ if (signal.aborted) return failure(image.url, "ABORTED", "stopped loading because the work was cancelled");
96
+ return explainFailure(image, signal);
97
+ } finally {
98
+ if (timer !== void 0) clearTimeout(timer);
99
+ signal.removeEventListener("abort", stop);
100
+ }
101
+ }
102
+ /** Loads every image, a few at a time, and returns the ones that failed. */
103
+ async function findMissingImages(images, signal, options = {}) {
104
+ const timeoutMs = options.timeoutMs ?? 3e4;
105
+ let done = 0;
106
+ options.onProgress?.(0, images.length);
107
+ return (await mapWithConcurrency(images, options.concurrency ?? 6, async (image) => {
108
+ const result = await checkImage(image, signal, timeoutMs, options.maxPixels ?? Infinity);
109
+ done += 1;
110
+ options.onProgress?.(done, images.length);
111
+ return result;
112
+ })).filter((result) => result !== void 0);
31
113
  }
32
114
  //#endregion
33
- export { findMissingImages, isCrossOriginUrl, isPortableUrl, isSameUrl };
115
+ export { DEFAULT_IMAGE_TIMEOUT_MS, findMissingImages, isCrossOriginUrl, isPortableUrl, isSameUrl };
@@ -0,0 +1,141 @@
1
+ const require_errors = require("../engine/errors.cjs");
2
+ const require_content_limits = require("../security/content-limits.cjs");
3
+ const require_object_ids = require("../fabric/object-ids.cjs");
4
+ const require_walk_objects = require("../fabric/walk-objects.cjs");
5
+ const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
6
+ let fabric = require("fabric");
7
+ //#region src/commands/clipboard.ts
8
+ const CLIPBOARD_FORMAT = "fabricjs-document-engine/objects";
9
+ function clone(value) {
10
+ return JSON.parse(JSON.stringify(value));
11
+ }
12
+ /** Removes every id, so the engine gives each pasted object, child and clip path a new one. */
13
+ function withoutIds(object) {
14
+ const copy = { ...object };
15
+ delete copy.id;
16
+ if (Array.isArray(copy.objects)) copy.objects = copy.objects.map(withoutIds);
17
+ if (copy.clipPath && typeof copy.clipPath === "object") copy.clipPath = withoutIds(copy.clipPath);
18
+ return copy;
19
+ }
20
+ function moved(object, distance) {
21
+ const left = typeof object.left === "number" ? object.left : 0;
22
+ const top = typeof object.top === "number" ? object.top : 0;
23
+ return {
24
+ ...object,
25
+ left: left + distance,
26
+ top: top + distance
27
+ };
28
+ }
29
+ function refuseInvalidContent(message) {
30
+ throw new require_errors.DocumentEngineError("INVALID_DOCUMENT", `The clipboard content is not valid: ${message}`);
31
+ }
32
+ function checkContent(content, limits) {
33
+ if (typeof content !== "object" || content === null) refuseInvalidContent("it is not an object");
34
+ const candidate = content;
35
+ if (candidate.format !== "fabricjs-document-engine/objects") refuseInvalidContent(`its format is not "${CLIPBOARD_FORMAT}"`);
36
+ if (candidate.version !== 1) refuseInvalidContent("its version is not 1");
37
+ if (!Array.isArray(candidate.objects)) refuseInvalidContent("it has no objects list");
38
+ const secured = require_content_limits.secureDocument({ objects: candidate.objects }, limits);
39
+ const objects = secured.objects.filter((object) => typeof object === "object" && object !== null && typeof object.type === "string");
40
+ if (objects.length !== secured.objects.length) refuseInvalidContent("every object needs a type");
41
+ require_content_limits.refuseUnsafeImageUrls(objects, limits?.isAllowedUrl);
42
+ return {
43
+ format: CLIPBOARD_FORMAT,
44
+ version: 1,
45
+ objects
46
+ };
47
+ }
48
+ function topLevelInOrder(engine, objects) {
49
+ const stack = engine.canvas.getObjects();
50
+ const wanted = new Set(objects);
51
+ return stack.filter((object) => wanted.has(object));
52
+ }
53
+ function selectionOf(engine) {
54
+ return engine.canvas.getActiveObjects?.() ?? [];
55
+ }
56
+ function refuseUnknownTypes(objects) {
57
+ const unknownTypes = [...require_walk_objects.collectSerializedTypes(objects)].filter((type) => !fabric.classRegistry.has(type));
58
+ if (unknownTypes.length === 0) return;
59
+ throw new require_errors.DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The clipboard holds object types that are not registered: ${unknownTypes.join(", ")}`, { unknownTypes });
60
+ }
61
+ function select(engine, objects) {
62
+ const canvas = engine.canvas;
63
+ if (typeof canvas.setActiveObject !== "function" || objects.length === 0) return;
64
+ canvas.discardActiveObject?.();
65
+ canvas.setActiveObject(objects.length === 1 ? objects[0] : new fabric.ActiveSelection(objects, { canvas: engine.canvas }));
66
+ }
67
+ /**
68
+ * A clipboard for one engine. Copies keep custom properties and the exact
69
+ * position of objects in groups and selections; pastes get fresh ids for
70
+ * every object and child and count as one undo step.
71
+ */
72
+ function createClipboard(engine, options = {}) {
73
+ const defaultOffset = options.offset ?? 10;
74
+ let content;
75
+ let pastes = 0;
76
+ function copyObjects(objects) {
77
+ const chosen = topLevelInOrder(engine, objects);
78
+ if (chosen.length === 0) return 0;
79
+ const document = engine.toDocument();
80
+ const serialized = chosen.map((object) => require_object_ids.readObjectId(object)).map((id) => document.objects.find((object) => object.id === id)).filter((object) => object !== void 0);
81
+ content = {
82
+ format: CLIPBOARD_FORMAT,
83
+ version: 1,
84
+ objects: clone(serialized)
85
+ };
86
+ return serialized.length;
87
+ }
88
+ return {
89
+ copy(objects) {
90
+ const count = copyObjects(objects ?? selectionOf(engine));
91
+ if (count > 0) pastes = 0;
92
+ return count;
93
+ },
94
+ cut(objects) {
95
+ const chosen = topLevelInOrder(engine, objects ?? selectionOf(engine));
96
+ const count = copyObjects(chosen);
97
+ if (count === 0) return 0;
98
+ pastes = -1;
99
+ engine.transaction(count === 1 ? "Cut" : `Cut ${count} objects`, () => {
100
+ engine.canvas.discardActiveObject?.();
101
+ engine.canvas.remove(...chosen);
102
+ });
103
+ engine.canvas.requestRenderAll();
104
+ return count;
105
+ },
106
+ async paste(pasteOptions = {}) {
107
+ if (!content) return [];
108
+ const target = pasteOptions.target ?? engine;
109
+ const offset = pasteOptions.offset ?? defaultOffset;
110
+ pastes += 1;
111
+ const distance = offset * pastes;
112
+ const serialized = content.objects.map((object) => moved(withoutIds(object), distance));
113
+ refuseUnknownTypes(serialized);
114
+ const startedIn = target.getDocumentInfo().session;
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
+ }
120
+ target.transaction(created.length === 1 ? "Paste" : `Paste ${created.length} objects`, () => {
121
+ target.canvas.add(...created);
122
+ });
123
+ if (pasteOptions.select ?? true) select(target, created);
124
+ target.canvas.requestRenderAll();
125
+ return created;
126
+ },
127
+ hasContent: () => content !== void 0,
128
+ read: () => content ? clone(content) : void 0,
129
+ write(next) {
130
+ content = checkContent(next, options.limits);
131
+ pastes = 0;
132
+ },
133
+ clear() {
134
+ content = void 0;
135
+ pastes = 0;
136
+ }
137
+ };
138
+ }
139
+ //#endregion
140
+ exports.CLIPBOARD_FORMAT = CLIPBOARD_FORMAT;
141
+ exports.createClipboard = createClipboard;
@@ -0,0 +1,47 @@
1
+ import { SerializedFabricObject } from "../document/document-format.cjs";
2
+ import { ContentLimits } from "../security/content-limits.cjs";
3
+ import { DocumentEngine } from "../engine/create-document-engine.cjs";
4
+ import { FabricObject } from "fabric";
5
+ //#region src/commands/clipboard.d.ts
6
+ export declare const CLIPBOARD_FORMAT = "fabricjs-document-engine/objects";
7
+ /** What the clipboard holds. Plain JSON, so an app can put it on the system clipboard. */
8
+ export interface ClipboardContent {
9
+ format: typeof CLIPBOARD_FORMAT;
10
+ version: 1;
11
+ objects: SerializedFabricObject[];
12
+ }
13
+ export interface ClipboardOptions {
14
+ /** How far each paste moves from the last one, in canvas units. Default 10. */
15
+ offset?: number;
16
+ /** Limits for content passed to `write`, as for loading documents. */
17
+ limits?: ContentLimits;
18
+ }
19
+ export interface PasteOptions {
20
+ /** The engine to paste into. Defaults to the engine the clipboard was made for. */
21
+ target?: DocumentEngine;
22
+ /** Overrides the offset for this paste. */
23
+ offset?: number;
24
+ /** Selects the pasted objects on an interactive canvas. Default true. */
25
+ select?: boolean;
26
+ }
27
+ export interface Clipboard {
28
+ /** Copies objects, or the current selection. Returns how many objects were copied. */
29
+ copy(objects?: readonly FabricObject[]): number;
30
+ /** Copies and then removes objects, as one undo step. Returns how many objects were cut. */
31
+ cut(objects?: readonly FabricObject[]): number;
32
+ /** Adds a copy of the clipboard with fresh ids, as one undo step. Returns the new objects. */
33
+ paste(options?: PasteOptions): Promise<FabricObject[]>;
34
+ hasContent(): boolean;
35
+ /** The copied objects as JSON, or `undefined` when the clipboard is empty. */
36
+ read(): ClipboardContent | undefined;
37
+ /** Replaces the clipboard with content from `read`, for example from another tab. */
38
+ write(content: unknown): void;
39
+ clear(): void;
40
+ }
41
+ /**
42
+ * A clipboard for one engine. Copies keep custom properties and the exact
43
+ * position of objects in groups and selections; pastes get fresh ids for
44
+ * every object and child and count as one undo step.
45
+ */
46
+ export declare function createClipboard(engine: DocumentEngine, options?: ClipboardOptions): Clipboard;
47
+ //#endregion
@@ -0,0 +1,47 @@
1
+ import { SerializedFabricObject } from "../document/document-format.js";
2
+ import { ContentLimits } from "../security/content-limits.js";
3
+ import { DocumentEngine } from "../engine/create-document-engine.js";
4
+ import { FabricObject } from "fabric";
5
+ //#region src/commands/clipboard.d.ts
6
+ export declare const CLIPBOARD_FORMAT = "fabricjs-document-engine/objects";
7
+ /** What the clipboard holds. Plain JSON, so an app can put it on the system clipboard. */
8
+ export interface ClipboardContent {
9
+ format: typeof CLIPBOARD_FORMAT;
10
+ version: 1;
11
+ objects: SerializedFabricObject[];
12
+ }
13
+ export interface ClipboardOptions {
14
+ /** How far each paste moves from the last one, in canvas units. Default 10. */
15
+ offset?: number;
16
+ /** Limits for content passed to `write`, as for loading documents. */
17
+ limits?: ContentLimits;
18
+ }
19
+ export interface PasteOptions {
20
+ /** The engine to paste into. Defaults to the engine the clipboard was made for. */
21
+ target?: DocumentEngine;
22
+ /** Overrides the offset for this paste. */
23
+ offset?: number;
24
+ /** Selects the pasted objects on an interactive canvas. Default true. */
25
+ select?: boolean;
26
+ }
27
+ export interface Clipboard {
28
+ /** Copies objects, or the current selection. Returns how many objects were copied. */
29
+ copy(objects?: readonly FabricObject[]): number;
30
+ /** Copies and then removes objects, as one undo step. Returns how many objects were cut. */
31
+ cut(objects?: readonly FabricObject[]): number;
32
+ /** Adds a copy of the clipboard with fresh ids, as one undo step. Returns the new objects. */
33
+ paste(options?: PasteOptions): Promise<FabricObject[]>;
34
+ hasContent(): boolean;
35
+ /** The copied objects as JSON, or `undefined` when the clipboard is empty. */
36
+ read(): ClipboardContent | undefined;
37
+ /** Replaces the clipboard with content from `read`, for example from another tab. */
38
+ write(content: unknown): void;
39
+ clear(): void;
40
+ }
41
+ /**
42
+ * A clipboard for one engine. Copies keep custom properties and the exact
43
+ * position of objects in groups and selections; pastes get fresh ids for
44
+ * every object and child and count as one undo step.
45
+ */
46
+ export declare function createClipboard(engine: DocumentEngine, options?: ClipboardOptions): Clipboard;
47
+ //#endregion
@@ -0,0 +1,140 @@
1
+ import { DocumentEngineError } from "../engine/errors.js";
2
+ import { refuseUnsafeImageUrls, secureDocument } from "../security/content-limits.js";
3
+ import { readObjectId } from "../fabric/object-ids.js";
4
+ import { collectSerializedTypes } from "../fabric/walk-objects.js";
5
+ import { createObjects } from "../fabric/fabric-adapter.js";
6
+ import { ActiveSelection, classRegistry } from "fabric";
7
+ //#region src/commands/clipboard.ts
8
+ const CLIPBOARD_FORMAT = "fabricjs-document-engine/objects";
9
+ function clone(value) {
10
+ return JSON.parse(JSON.stringify(value));
11
+ }
12
+ /** Removes every id, so the engine gives each pasted object, child and clip path a new one. */
13
+ function withoutIds(object) {
14
+ const copy = { ...object };
15
+ delete copy.id;
16
+ if (Array.isArray(copy.objects)) copy.objects = copy.objects.map(withoutIds);
17
+ if (copy.clipPath && typeof copy.clipPath === "object") copy.clipPath = withoutIds(copy.clipPath);
18
+ return copy;
19
+ }
20
+ function moved(object, distance) {
21
+ const left = typeof object.left === "number" ? object.left : 0;
22
+ const top = typeof object.top === "number" ? object.top : 0;
23
+ return {
24
+ ...object,
25
+ left: left + distance,
26
+ top: top + distance
27
+ };
28
+ }
29
+ function refuseInvalidContent(message) {
30
+ throw new DocumentEngineError("INVALID_DOCUMENT", `The clipboard content is not valid: ${message}`);
31
+ }
32
+ function checkContent(content, limits) {
33
+ if (typeof content !== "object" || content === null) refuseInvalidContent("it is not an object");
34
+ const candidate = content;
35
+ if (candidate.format !== "fabricjs-document-engine/objects") refuseInvalidContent(`its format is not "${CLIPBOARD_FORMAT}"`);
36
+ if (candidate.version !== 1) refuseInvalidContent("its version is not 1");
37
+ if (!Array.isArray(candidate.objects)) refuseInvalidContent("it has no objects list");
38
+ const secured = secureDocument({ objects: candidate.objects }, limits);
39
+ const objects = secured.objects.filter((object) => typeof object === "object" && object !== null && typeof object.type === "string");
40
+ if (objects.length !== secured.objects.length) refuseInvalidContent("every object needs a type");
41
+ refuseUnsafeImageUrls(objects, limits?.isAllowedUrl);
42
+ return {
43
+ format: CLIPBOARD_FORMAT,
44
+ version: 1,
45
+ objects
46
+ };
47
+ }
48
+ function topLevelInOrder(engine, objects) {
49
+ const stack = engine.canvas.getObjects();
50
+ const wanted = new Set(objects);
51
+ return stack.filter((object) => wanted.has(object));
52
+ }
53
+ function selectionOf(engine) {
54
+ return engine.canvas.getActiveObjects?.() ?? [];
55
+ }
56
+ function refuseUnknownTypes(objects) {
57
+ const unknownTypes = [...collectSerializedTypes(objects)].filter((type) => !classRegistry.has(type));
58
+ if (unknownTypes.length === 0) return;
59
+ throw new DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The clipboard holds object types that are not registered: ${unknownTypes.join(", ")}`, { unknownTypes });
60
+ }
61
+ function select(engine, objects) {
62
+ const canvas = engine.canvas;
63
+ if (typeof canvas.setActiveObject !== "function" || objects.length === 0) return;
64
+ canvas.discardActiveObject?.();
65
+ canvas.setActiveObject(objects.length === 1 ? objects[0] : new ActiveSelection(objects, { canvas: engine.canvas }));
66
+ }
67
+ /**
68
+ * A clipboard for one engine. Copies keep custom properties and the exact
69
+ * position of objects in groups and selections; pastes get fresh ids for
70
+ * every object and child and count as one undo step.
71
+ */
72
+ function createClipboard(engine, options = {}) {
73
+ const defaultOffset = options.offset ?? 10;
74
+ let content;
75
+ let pastes = 0;
76
+ function copyObjects(objects) {
77
+ const chosen = topLevelInOrder(engine, objects);
78
+ if (chosen.length === 0) return 0;
79
+ const document = engine.toDocument();
80
+ const serialized = chosen.map((object) => readObjectId(object)).map((id) => document.objects.find((object) => object.id === id)).filter((object) => object !== void 0);
81
+ content = {
82
+ format: CLIPBOARD_FORMAT,
83
+ version: 1,
84
+ objects: clone(serialized)
85
+ };
86
+ return serialized.length;
87
+ }
88
+ return {
89
+ copy(objects) {
90
+ const count = copyObjects(objects ?? selectionOf(engine));
91
+ if (count > 0) pastes = 0;
92
+ return count;
93
+ },
94
+ cut(objects) {
95
+ const chosen = topLevelInOrder(engine, objects ?? selectionOf(engine));
96
+ const count = copyObjects(chosen);
97
+ if (count === 0) return 0;
98
+ pastes = -1;
99
+ engine.transaction(count === 1 ? "Cut" : `Cut ${count} objects`, () => {
100
+ engine.canvas.discardActiveObject?.();
101
+ engine.canvas.remove(...chosen);
102
+ });
103
+ engine.canvas.requestRenderAll();
104
+ return count;
105
+ },
106
+ async paste(pasteOptions = {}) {
107
+ if (!content) return [];
108
+ const target = pasteOptions.target ?? engine;
109
+ const offset = pasteOptions.offset ?? defaultOffset;
110
+ pastes += 1;
111
+ const distance = offset * pastes;
112
+ const serialized = content.objects.map((object) => moved(withoutIds(object), distance));
113
+ refuseUnknownTypes(serialized);
114
+ const startedIn = target.getDocumentInfo().session;
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
+ }
120
+ target.transaction(created.length === 1 ? "Paste" : `Paste ${created.length} objects`, () => {
121
+ target.canvas.add(...created);
122
+ });
123
+ if (pasteOptions.select ?? true) select(target, created);
124
+ target.canvas.requestRenderAll();
125
+ return created;
126
+ },
127
+ hasContent: () => content !== void 0,
128
+ read: () => content ? clone(content) : void 0,
129
+ write(next) {
130
+ content = checkContent(next, options.limits);
131
+ pastes = 0;
132
+ },
133
+ clear() {
134
+ content = void 0;
135
+ pastes = 0;
136
+ }
137
+ };
138
+ }
139
+ //#endregion
140
+ export { CLIPBOARD_FORMAT, createClipboard };