fabricjs-document-engine 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -1
- package/README.zh-CN.md +71 -1
- package/dist/assets/asset-pipeline.cjs +10 -8
- package/dist/assets/asset-pipeline.d.cts +1 -1
- package/dist/assets/asset-pipeline.d.ts +1 -1
- package/dist/assets/asset-pipeline.js +10 -8
- package/dist/assets/image-check.cjs +4 -3
- package/dist/assets/image-check.d.cts +1 -1
- package/dist/assets/image-check.d.ts +1 -1
- package/dist/assets/image-check.js +4 -3
- package/dist/commands/clipboard.cjs +5 -0
- package/dist/commands/clipboard.js +5 -0
- package/dist/commands/text-edit.cjs +223 -0
- package/dist/commands/text-edit.d.cts +23 -0
- package/dist/commands/text-edit.d.ts +23 -0
- package/dist/commands/text-edit.js +218 -0
- package/dist/document/document-format.d.cts +15 -0
- package/dist/document/document-format.d.ts +15 -0
- package/dist/document/validate-document.cjs +8 -0
- package/dist/document/validate-document.js +8 -0
- package/dist/engine/create-document-engine.cjs +115 -21
- package/dist/engine/create-document-engine.d.cts +24 -6
- package/dist/engine/create-document-engine.d.ts +24 -6
- package/dist/engine/create-document-engine.js +116 -22
- package/dist/engine/errors.d.cts +1 -1
- package/dist/engine/errors.d.ts +1 -1
- package/dist/export/export-options.cjs +3 -0
- package/dist/export/export-options.d.cts +6 -0
- package/dist/export/export-options.d.ts +6 -0
- package/dist/export/export-options.js +3 -0
- package/dist/export/preflight-export.d.cts +1 -1
- package/dist/export/preflight-export.d.ts +1 -1
- package/dist/export/render-export.cjs +42 -4
- package/dist/export/render-export.js +43 -5
- package/dist/export/svg/clip-masks.cjs +39 -0
- package/dist/export/svg/clip-masks.js +37 -0
- package/dist/export/svg/embed-assets.cjs +1 -0
- package/dist/export/svg/embed-assets.js +1 -0
- package/dist/export/svg/raster-object.cjs +82 -0
- package/dist/export/svg/raster-object.js +81 -0
- package/dist/{pdf → export/svg}/text-decorations.cjs +3 -3
- package/dist/{pdf → export/svg}/text-decorations.js +3 -3
- package/dist/export/svg/text-on-path.cjs +0 -10
- package/dist/export/svg/text-on-path.js +1 -10
- package/dist/fabric/fabric-adapter.cjs +5 -3
- package/dist/fabric/fabric-adapter.js +5 -3
- package/dist/fabric/object-registry.cjs +7 -1
- package/dist/fabric/object-registry.js +7 -1
- package/dist/fabric/page-state.cjs +69 -0
- package/dist/fabric/page-state.js +64 -0
- package/dist/filters/filter-worker.cjs +281 -0
- package/dist/filters/filter-worker.d.cts +26 -0
- package/dist/filters/filter-worker.d.ts +26 -0
- package/dist/filters/filter-worker.js +281 -0
- package/dist/filters/pipeline.cjs +91 -0
- package/dist/filters/pipeline.d.cts +35 -0
- package/dist/filters/pipeline.d.ts +35 -0
- package/dist/filters/pipeline.js +87 -0
- package/dist/filters/worker-entry.cjs +78 -0
- package/dist/filters/worker-entry.d.cts +29 -0
- package/dist/filters/worker-entry.d.ts +29 -0
- package/dist/filters/worker-entry.js +78 -0
- package/dist/filters.cjs +9 -0
- package/dist/filters.d.cts +3 -0
- package/dist/filters.d.ts +3 -0
- package/dist/filters.js +3 -0
- package/dist/history/apply-state.cjs +19 -2
- package/dist/history/apply-state.js +19 -2
- package/dist/history/create-history.cjs +53 -13
- package/dist/history/create-history.d.cts +8 -0
- package/dist/history/create-history.d.ts +8 -0
- package/dist/history/create-history.js +53 -13
- package/dist/history/snapshot.cjs +10 -1
- package/dist/history/snapshot.js +10 -2
- package/dist/import/svg-import.cjs +102 -0
- package/dist/import/svg-import.d.cts +1 -0
- package/dist/import/svg-import.d.ts +1 -0
- package/dist/import/svg-import.js +103 -1
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/migrations/migrate-document.cjs +2 -1
- package/dist/migrations/migrate-document.js +2 -1
- package/dist/pdf/export-pdf.cjs +8 -67
- package/dist/pdf/export-pdf.js +8 -67
- package/dist/performance/batch.cjs +31 -0
- package/dist/performance/batch.d.cts +4 -0
- package/dist/performance/batch.d.ts +4 -0
- package/dist/performance/batch.js +31 -0
- package/dist/performance/dirty-regions.cjs +378 -0
- package/dist/performance/dirty-regions.d.cts +23 -0
- package/dist/performance/dirty-regions.d.ts +23 -0
- package/dist/performance/dirty-regions.js +378 -0
- package/dist/performance/monitor.cjs +60 -0
- package/dist/performance/monitor.d.cts +20 -0
- package/dist/performance/monitor.d.ts +20 -0
- package/dist/performance/monitor.js +60 -0
- package/dist/performance/spatial-index.cjs +78 -0
- package/dist/performance/spatial-index.d.cts +21 -0
- package/dist/performance/spatial-index.d.ts +21 -0
- package/dist/performance/spatial-index.js +78 -0
- package/dist/performance.cjs +9 -0
- package/dist/performance.d.cts +5 -0
- package/dist/performance.d.ts +5 -0
- package/dist/performance.js +5 -0
- package/dist/recovery/recovery-controller.cjs +148 -29
- package/dist/recovery/recovery-controller.d.cts +8 -0
- package/dist/recovery/recovery-controller.d.ts +8 -0
- package/dist/recovery/recovery-controller.js +148 -29
- package/dist/security/content-limits.cjs +43 -3
- package/dist/security/content-limits.d.cts +8 -0
- package/dist/security/content-limits.d.ts +8 -0
- package/dist/security/content-limits.js +39 -4
- package/dist/text/bounded-textbox.cjs +306 -0
- package/dist/text/bounded-textbox.d.cts +53 -0
- package/dist/text/bounded-textbox.d.ts +53 -0
- package/dist/text/bounded-textbox.js +306 -0
- package/dist/text/mobile-input.cjs +146 -0
- package/dist/text/mobile-input.d.cts +19 -0
- package/dist/text/mobile-input.d.ts +19 -0
- package/dist/text/mobile-input.js +145 -0
- package/dist/text/register.cjs +17 -0
- package/dist/text/register.d.cts +5 -0
- package/dist/text/register.d.ts +5 -0
- package/dist/text/register.js +16 -0
- package/dist/text/shaping.cjs +125 -0
- package/dist/text/shaping.d.cts +28 -0
- package/dist/text/shaping.d.ts +28 -0
- package/dist/text/shaping.js +121 -0
- package/dist/text/vertical-orientation.cjs +192 -0
- package/dist/text/vertical-orientation.d.cts +10 -0
- package/dist/text/vertical-orientation.d.ts +10 -0
- package/dist/text/vertical-orientation.js +190 -0
- package/dist/text/vertical-text.cjs +336 -0
- package/dist/text/vertical-text.d.cts +54 -0
- package/dist/text/vertical-text.d.ts +54 -0
- package/dist/text/vertical-text.js +336 -0
- package/dist/text.cjs +21 -0
- package/dist/text.d.cts +7 -0
- package/dist/text.d.ts +7 -0
- package/dist/text.js +7 -0
- package/dist/util/concurrency.cjs +16 -3
- package/dist/util/concurrency.js +16 -3
- package/package.json +31 -1
- package/schema/document-v1.schema.json +5 -1
package/README.md
CHANGED
|
@@ -74,6 +74,8 @@ That is the whole setup for a first test. localStorage is fine here; in a real a
|
|
|
74
74
|
- **Versions and migration.** Keep named versions, restore any of them as a new revision, and open plain Fabric JSON or documents saved by older versions of this package.
|
|
75
75
|
- **Large documents.** Objects are created in chunks so the page stays responsive. Loads report progress and can be cancelled with an `AbortSignal`, and a cancelled load leaves the canvas as it was.
|
|
76
76
|
- **Copy, paste and layers.** A clipboard that keeps group transforms and custom properties and gives every pasted object a new id, plus bring-to-front and send-to-back commands that keep a pinned background in place. Each is one undo step.
|
|
77
|
+
- **Text that behaves.** A Textbox that keeps its width and can clip, add "…" or shrink to fit; cursor positions that match Arabic and ligatures; editable vertical Chinese, Japanese and Korean text; correct typing with Android and iOS keyboards; and text edits in code that keep each letter's style.
|
|
78
|
+
- **Fast with many objects.** Dirty-region rendering redraws only what changed, and image filters run in a Web Worker with progress and cancel.
|
|
77
79
|
- **Undo and redo.** One user action is one undo step. Transactions group several code changes into one labelled step, and ids survive undo and redo.
|
|
78
80
|
- **React ready, framework free.** Hooks for React, and a small state store for any other framework.
|
|
79
81
|
- **Hardened.** Imported documents and SVG files are cleaned and size-limited, undo history has a memory budget, and every feature is tested on Fabric 6 and 7 in Chromium, Firefox and WebKit. Exports are checked pixel by pixel against the canvas.
|
|
@@ -428,6 +430,7 @@ const { objects, viewport, warnings } = await engine.importSvg(svgText, {
|
|
|
428
430
|
- The result is one group with a fixed layout the size of the viewport, or separate objects with `as: 'objects'`. Either way it is one undo step, and every object gets an id.
|
|
429
431
|
- Scripts, event handlers, `foreignObject`, links to other files and image addresses that `limits.isAllowedUrl` refuses are removed, and `warnings` says what was removed.
|
|
430
432
|
- Size limits apply as for documents, so a huge or deeply nested SVG is refused with `UNSAFE_DOCUMENT`.
|
|
433
|
+
- `preserveGroups: true` keeps the SVG's `<g>` groups as Fabric groups, with their ids, opacity, clip paths, classes and `data-*` attributes (Fabric flattens them).
|
|
431
434
|
|
|
432
435
|
## Version history
|
|
433
436
|
|
|
@@ -593,6 +596,64 @@ getLayers(engine); // [{ id, type, name, index, visible, locked }], top first
|
|
|
593
596
|
|
|
594
597
|
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
598
|
|
|
599
|
+
## Text, rendering speed and filters
|
|
600
|
+
|
|
601
|
+
These fix long-standing Fabric.js issues. Each was reproduced on Fabric 6 and 7 first, and each fix is tested in Chromium, Firefox and WebKit.
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
import { BoundedTextbox, VerticalText, ShapedIText, attachMobileTextInput, textObjects } from 'fabricjs-document-engine/text';
|
|
605
|
+
import { enableDirtyRegionRendering } from 'fabricjs-document-engine/performance';
|
|
606
|
+
import { createFilterWorker } from 'fabricjs-document-engine/filters';
|
|
607
|
+
import { createTextCommands } from 'fabricjs-document-engine';
|
|
608
|
+
|
|
609
|
+
const engine = createDocumentEngine({ canvas, customObjects: textObjects });
|
|
610
|
+
canvas.add(new BoundedTextbox(longText, { width: 240, maxHeight: 120, overflow: 'ellipsis' }));
|
|
611
|
+
canvas.add(new VerticalText('縦書きのテキスト', { fontSize: 32 }));
|
|
612
|
+
canvas.add(new ShapedIText('مرحبا بالعالم', { direction: 'rtl' }));
|
|
613
|
+
attachMobileTextInput(canvas);
|
|
614
|
+
createTextCommands(engine).insertText(title, 0, 'New: ');
|
|
615
|
+
enableDirtyRegionRendering(canvas);
|
|
616
|
+
await createFilterWorker({ engine }).apply(image, [new filters.Blur({ blur: 0.2 })], { onProgress });
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
- **`BoundedTextbox` (#2376).** Fabric widens a Textbox to its longest word. This one breaks such a word between letters, as CSS `overflow-wrap: anywhere` does. With `maxHeight`, extra lines are clipped or end in "…", or `fit: 'shrink'` makes the font smaller until the text fits. The saved font size does not change.
|
|
620
|
+
- **`ShapedIText` and `ShapedTextbox` (#4815).** Fabric measures each letter alone but draws joined letters, so in Arabic the cursor can land two letters away. These measure from the text as the browser shapes it.
|
|
621
|
+
- **`VerticalText` (#511).** Columns from right to left, upright CJK characters, turned Latin words (Unicode UAX #50), and two-digit numbers side by side. Arrow keys, selection, IME, SVG and PDF all work.
|
|
622
|
+
- **`attachMobileTextInput` (#6588).** Reads each edit from the text itself, so Android autocorrect, suggestions and cursor swipes put letters and styles in the right place. It also opens the keyboard on tap and stops iOS from zooming.
|
|
623
|
+
- **`createTextCommands` (#6133).** Insert, delete, replace and restyle text from code with styles that stay on their letters, one undo step each.
|
|
624
|
+
- **`enableDirtyRegionRendering` (#9847).** Moving one of 5,000 shapes draws about 25 of them instead of all 5,000: under 2 ms per frame in place of nearly a second in headless Chromium.
|
|
625
|
+
- **`createFilterWorker` (#9532).** Fabric's filters run in a worker on transferred bitmaps, with the same pixels as Fabric, progress, cancel, and only the newest run applied while a slider moves.
|
|
626
|
+
|
|
627
|
+
## Edits that are never lost
|
|
628
|
+
|
|
629
|
+
These guarantees hold however the user and your code interleave work:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
// Page settings are one undo step and count as unsaved work
|
|
633
|
+
engine.setPage({ width: 1080, height: 1080, background: '#fff8e7' }, 'Square post');
|
|
634
|
+
|
|
635
|
+
// All or nothing: a failure leaves the canvas as it was
|
|
636
|
+
await engine.transaction('Apply template', async () => {
|
|
637
|
+
await addTemplateObjects(engine.canvas);
|
|
638
|
+
}, { rollback: true });
|
|
639
|
+
|
|
640
|
+
// A load refuses to overwrite edits made while it ran
|
|
641
|
+
try {
|
|
642
|
+
await engine.load('poster-42');
|
|
643
|
+
} catch (error) {
|
|
644
|
+
if (isDocumentEngineError(error) && error.code === 'LOAD_CONFLICT') askBeforeReplacing();
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
- **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.
|
|
649
|
+
- **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.
|
|
650
|
+
- **Typing counts at once.** Each keystroke marks the document unsaved and feeds autosave and recovery, while the whole edit stays one undo step.
|
|
651
|
+
- **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`.
|
|
652
|
+
- **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.
|
|
653
|
+
- **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.
|
|
654
|
+
- **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`.
|
|
655
|
+
- **Rollback when you want it.** `transaction(label, work, { rollback: true })` undoes everything `work` changed when it fails.
|
|
656
|
+
|
|
596
657
|
## Document format
|
|
597
658
|
|
|
598
659
|
```ts
|
|
@@ -603,7 +664,15 @@ interface FabricDocument {
|
|
|
603
664
|
updatedAt: string;
|
|
604
665
|
revision?: number;
|
|
605
666
|
fabricVersion?: string;
|
|
606
|
-
canvas: {
|
|
667
|
+
canvas: {
|
|
668
|
+
width: number;
|
|
669
|
+
height: number;
|
|
670
|
+
background?: unknown; // color, gradient or pattern
|
|
671
|
+
backgroundImage?: object; // Fabric image behind every object
|
|
672
|
+
overlay?: unknown; // color drawn over every object
|
|
673
|
+
overlayImage?: object; // Fabric image over every object
|
|
674
|
+
clipPath?: object; // mask for the whole canvas
|
|
675
|
+
};
|
|
607
676
|
objects: SerializedFabricObject[];
|
|
608
677
|
assets?: {
|
|
609
678
|
images: Array<{ url: string; objectIds: string[] }>;
|
package/README.zh-CN.md
CHANGED
|
@@ -74,6 +74,8 @@ await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
|
|
|
74
74
|
- **版本和迁移。** 保存命名版本,把任意版本恢复为新的修订,还能打开纯 Fabric JSON 或旧版本这个包保存的文档。
|
|
75
75
|
- **大文档。** 对象分批创建,页面保持响应。加载会报告进度,并能用 `AbortSignal` 取消;取消后画布保持原样。
|
|
76
76
|
- **复制、粘贴和图层。** 剪贴板会保留编组的变换和自定义属性,并给每个粘贴出的对象一个新 id;置顶、置底等图层命令可以让固定的背景保持不动。每个操作都是一步撤销。
|
|
77
|
+
- **好用的文字。** 保持宽度、可以裁剪、加“…”或自动缩小字号的文本框;与阿拉伯文和连字一致的光标位置;可编辑的中日韩竖排文字;在 Android 和 iOS 键盘上正确输入;在代码里修改文字时每个字的样式保持不变。
|
|
78
|
+
- **对象多也流畅。** 脏区域渲染只重绘变化的部分,图片滤镜在 Web Worker 中运行,支持进度和取消。
|
|
77
79
|
- **撤销和重做。** 用户的一次操作就是一步撤销。事务可以把代码里的多处改动合成一个带标签的步骤,撤销和重做后 id 保持不变。
|
|
78
80
|
- **支持 React,不绑定框架。** 为 React 提供 hooks,为其他框架提供一个小的状态 store。
|
|
79
81
|
- **经过加固。** 导入的文档和 SVG 文件会被清理并限制大小,撤销历史有内存上限,每个功能都在 Fabric 6 和 7、Chromium、Firefox 和 WebKit 中测试过。导出结果会和画布逐像素比对。
|
|
@@ -428,6 +430,7 @@ const { objects, viewport, warnings } = await engine.importSvg(svgText, {
|
|
|
428
430
|
- 结果是一个固定布局、大小等于视口的编组;使用 `as: 'objects'` 时则是分开的对象。两种方式都只算一步撤销,每个对象都有 id。
|
|
429
431
|
- 脚本、事件处理器、`foreignObject`、指向其他文件的链接,以及 `limits.isAllowedUrl` 拒绝的图片地址都会被移除,`warnings` 会说明移除了什么。
|
|
430
432
|
- 大小限制和文档相同,过大或嵌套过深的 SVG 会以 `UNSAFE_DOCUMENT` 被拒绝。
|
|
433
|
+
- 使用 `preserveGroups: true` 时,SVG 的 `<g>` 编组会保留为 Fabric 编组,连同 id、透明度、裁剪路径、class 和 `data-*` 属性(Fabric 自己会把它们拍平)。
|
|
431
434
|
|
|
432
435
|
## 版本历史
|
|
433
436
|
|
|
@@ -593,6 +596,65 @@ getLayers(engine); // [{ id, type, name, index, visible, locked }], top first
|
|
|
593
596
|
|
|
594
597
|
引擎会保存每个对象的 `name`,所以图层面板的名称不会丢。在 React 中,`fabricjs-document-engine/react` 的 `useLayers(engine)` 返回同样的列表,并在每次改动后更新。
|
|
595
598
|
|
|
599
|
+
## 文字、渲染速度和滤镜
|
|
600
|
+
|
|
601
|
+
这些功能修复了 Fabric.js 中长期存在的问题。每个问题都先在 Fabric 6 和 7 上复现,每个修复都在 Chromium、Firefox 和 WebKit 中测试过。
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
import { BoundedTextbox, VerticalText, ShapedIText, attachMobileTextInput, textObjects } from 'fabricjs-document-engine/text';
|
|
605
|
+
import { enableDirtyRegionRendering } from 'fabricjs-document-engine/performance';
|
|
606
|
+
import { createFilterWorker } from 'fabricjs-document-engine/filters';
|
|
607
|
+
import { createTextCommands } from 'fabricjs-document-engine';
|
|
608
|
+
|
|
609
|
+
const engine = createDocumentEngine({ canvas, customObjects: textObjects });
|
|
610
|
+
|
|
611
|
+
canvas.add(new BoundedTextbox(longText, { width: 240, maxHeight: 120, overflow: 'ellipsis' }));
|
|
612
|
+
canvas.add(new VerticalText('縦書きのテキスト', { fontSize: 32 }));
|
|
613
|
+
canvas.add(new ShapedIText('مرحبا بالعالم', { direction: 'rtl' }));
|
|
614
|
+
attachMobileTextInput(canvas);
|
|
615
|
+
createTextCommands(engine).insertText(title, 0, 'New: ');
|
|
616
|
+
enableDirtyRegionRendering(canvas);
|
|
617
|
+
await createFilterWorker({ engine }).apply(image, [new filters.Blur({ blur: 0.2 })], { onProgress });
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
- **`BoundedTextbox`(#2376)。** Fabric 会把文本框撑到最长单词的宽度。它会像 CSS `overflow-wrap: anywhere` 一样在字母之间断开过长的单词。设置 `maxHeight` 后,多出的行可以被裁剪或以“…”结尾,或者用 `fit: 'shrink'` 缩小字号直到放得下。保存的字号不会改变。
|
|
621
|
+
- **`ShapedIText` 和 `ShapedTextbox`(#4815)。** Fabric 单独测量每个字母,但绘制时字母是连在一起的,所以阿拉伯文的光标可能偏两个字母。它们按浏览器实际排版的文字来测量。
|
|
622
|
+
- **`VerticalText`(#511)。** 从右到左的列,直立的中日韩字符,旋转的拉丁单词(Unicode UAX #50),两位数字并排放在一个方格里。方向键、选择、输入法、SVG 和 PDF 都可以使用。
|
|
623
|
+
- **`attachMobileTextInput`(#6588)。** 从文字本身读取每次修改,所以 Android 的自动更正、联想和光标滑动都会把文字和样式放在正确的位置。它还会在点击时打开键盘,并防止 iOS 缩放页面。
|
|
624
|
+
- **`createTextCommands`(#6133)。** 在代码中插入、删除、替换文字和修改样式,样式跟着字母走,每次一步撤销。
|
|
625
|
+
- **`enableDirtyRegionRendering`(#9847)。** 在 5,000 个图形中移动一个时,只绘制大约 25 个,而不是全部 5,000 个:在无头 Chromium 中每帧不到 2 毫秒,原来接近一秒。
|
|
626
|
+
- **`createFilterWorker`(#9532)。** Fabric 的滤镜在 Worker 中处理转移过去的位图,像素和 Fabric 相同,支持进度和取消,拖动滑块时只应用最新的一次。
|
|
627
|
+
|
|
628
|
+
## 不会丢失的修改
|
|
629
|
+
|
|
630
|
+
无论用户的操作和你的代码怎样交错,下面这些保证都成立:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
// Page settings are one undo step and count as unsaved work
|
|
634
|
+
engine.setPage({ width: 1080, height: 1080, background: '#fff8e7' }, 'Square post');
|
|
635
|
+
|
|
636
|
+
// All or nothing: a failure leaves the canvas as it was
|
|
637
|
+
await engine.transaction('Apply template', async () => {
|
|
638
|
+
await addTemplateObjects(engine.canvas);
|
|
639
|
+
}, { rollback: true });
|
|
640
|
+
|
|
641
|
+
// A load refuses to overwrite edits made while it ran
|
|
642
|
+
try {
|
|
643
|
+
await engine.load('poster-42');
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (isDocumentEngineError(error) && error.code === 'LOAD_CONFLICT') askBeforeReplacing();
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- **整个页面都会保存。** 画布上的背景图片、叠加层和蒙版(`canvas.backgroundImage`、`overlayImage`、`clipPath`)会被保存、重新打开、检查缺失图片,也会进入版本和恢复副本。
|
|
650
|
+
- **页面修改可以撤销。** `setPage` 修改尺寸、背景、叠加层或蒙版,只算一步撤销;在 `transaction` 里直接修改画布也会被记录。
|
|
651
|
+
- **输入立即算作修改。** 每次按键都会把文档标记为未保存,并触发自动保存和恢复副本,而整次编辑仍然只算一步撤销。
|
|
652
|
+
- **加载不会覆盖修改。** 如果文档加载期间画布被修改,加载会以 `LOAD_CONFLICT` 停止并保留这些修改,除非传入 `discardUnsavedChanges: true`。
|
|
653
|
+
- **异步操作留在原来的文档里。** 在打开另一份文档之后才完成的 SVG 导入、图片替换或粘贴会以 `DOCUMENT_CHANGED` 被丢弃,而不会落到错误的文档里。
|
|
654
|
+
- **每个标签页有自己的恢复副本。** 两个标签页编辑同一份文档时不再互相覆盖副本,保存时只删除它覆盖的那一份。
|
|
655
|
+
- **绘制前先检查尺寸。** 超过浏览器画布限制的页面、导出和图片,会在创建任何画布之前被拒绝,大文件不会让标签页崩溃。限制用 `limits` 设置。
|
|
656
|
+
- **需要时可以回滚。** `transaction(label, work, { rollback: true })` 在 `work` 失败时撤回它做的所有修改。
|
|
657
|
+
|
|
596
658
|
## 文档格式
|
|
597
659
|
|
|
598
660
|
```ts
|
|
@@ -603,7 +665,15 @@ interface FabricDocument {
|
|
|
603
665
|
updatedAt: string;
|
|
604
666
|
revision?: number;
|
|
605
667
|
fabricVersion?: string;
|
|
606
|
-
canvas: {
|
|
668
|
+
canvas: {
|
|
669
|
+
width: number;
|
|
670
|
+
height: number;
|
|
671
|
+
background?: unknown; // color, gradient or pattern
|
|
672
|
+
backgroundImage?: object; // Fabric image behind every object
|
|
673
|
+
overlay?: unknown; // color drawn over every object
|
|
674
|
+
overlayImage?: object; // Fabric image over every object
|
|
675
|
+
clipPath?: object; // mask for the whole canvas
|
|
676
|
+
};
|
|
607
677
|
objects: SerializedFabricObject[];
|
|
608
678
|
assets?: {
|
|
609
679
|
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
|
|
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
|
|
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
|
|
65
|
-
const uniqueImages = [...groupByUrl(require_asset_references.findImageReferences(document
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
65
|
-
const uniqueImages = [...groupByUrl(findImageReferences(document
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|