@pooder/kit 5.4.0 → 6.0.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 (69) hide show
  1. package/.test-dist/src/coordinate.js +74 -0
  2. package/.test-dist/src/extensions/background.js +547 -0
  3. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  4. package/.test-dist/src/extensions/constraints.js +237 -0
  5. package/.test-dist/src/extensions/dieline.js +931 -0
  6. package/.test-dist/src/extensions/dielineShape.js +66 -0
  7. package/.test-dist/src/extensions/edgeScale.js +12 -0
  8. package/.test-dist/src/extensions/feature.js +910 -0
  9. package/.test-dist/src/extensions/featureComplete.js +32 -0
  10. package/.test-dist/src/extensions/film.js +226 -0
  11. package/.test-dist/src/extensions/geometry.js +609 -0
  12. package/.test-dist/src/extensions/image.js +1613 -0
  13. package/.test-dist/src/extensions/index.js +28 -0
  14. package/.test-dist/src/extensions/maskOps.js +334 -0
  15. package/.test-dist/src/extensions/mirror.js +104 -0
  16. package/.test-dist/src/extensions/ruler.js +442 -0
  17. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  18. package/.test-dist/src/extensions/sceneLayoutModel.js +202 -0
  19. package/.test-dist/src/extensions/sceneVisibility.js +55 -0
  20. package/.test-dist/src/extensions/size.js +331 -0
  21. package/.test-dist/src/extensions/tracer.js +709 -0
  22. package/.test-dist/src/extensions/white-ink.js +1200 -0
  23. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  24. package/.test-dist/src/index.js +18 -0
  25. package/.test-dist/src/services/CanvasService.js +1011 -0
  26. package/.test-dist/src/services/ViewportSystem.js +76 -0
  27. package/.test-dist/src/services/index.js +25 -0
  28. package/.test-dist/src/services/renderSpec.js +2 -0
  29. package/.test-dist/src/services/visibility.js +54 -0
  30. package/.test-dist/src/units.js +30 -0
  31. package/.test-dist/tests/run.js +148 -0
  32. package/CHANGELOG.md +6 -0
  33. package/dist/index.d.mts +150 -62
  34. package/dist/index.d.ts +150 -62
  35. package/dist/index.js +2219 -1714
  36. package/dist/index.mjs +2226 -1718
  37. package/package.json +1 -1
  38. package/src/coordinate.ts +106 -106
  39. package/src/extensions/background.ts +716 -323
  40. package/src/extensions/bridgeSelection.ts +17 -17
  41. package/src/extensions/constraints.ts +322 -322
  42. package/src/extensions/dieline.ts +1169 -1149
  43. package/src/extensions/dielineShape.ts +109 -109
  44. package/src/extensions/edgeScale.ts +19 -19
  45. package/src/extensions/feature.ts +1140 -1137
  46. package/src/extensions/featureComplete.ts +46 -46
  47. package/src/extensions/film.ts +270 -266
  48. package/src/extensions/geometry.ts +851 -885
  49. package/src/extensions/image.ts +2007 -2054
  50. package/src/extensions/index.ts +10 -11
  51. package/src/extensions/maskOps.ts +283 -283
  52. package/src/extensions/mirror.ts +128 -128
  53. package/src/extensions/ruler.ts +664 -654
  54. package/src/extensions/sceneLayout.ts +140 -140
  55. package/src/extensions/sceneLayoutModel.ts +364 -364
  56. package/src/extensions/size.ts +389 -389
  57. package/src/extensions/tracer.ts +1019 -1019
  58. package/src/extensions/white-ink.ts +1508 -1575
  59. package/src/extensions/wrappedOffsets.ts +33 -33
  60. package/src/index.ts +2 -2
  61. package/src/services/CanvasService.ts +1286 -832
  62. package/src/services/ViewportSystem.ts +95 -95
  63. package/src/services/index.ts +4 -3
  64. package/src/services/renderSpec.ts +83 -53
  65. package/src/services/visibility.ts +78 -0
  66. package/src/units.ts +27 -27
  67. package/tests/run.ts +253 -118
  68. package/tsconfig.test.json +15 -15
  69. package/src/extensions/sceneVisibility.ts +0 -64
@@ -1,323 +1,716 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationContribution,
7
- ConfigurationService,
8
- } from "@pooder/core";
9
- import { FabricImage } from "fabric";
10
- import { CanvasService, RenderObjectSpec } from "../services";
11
-
12
- interface SourceSize {
13
- width: number;
14
- height: number;
15
- }
16
-
17
- const BACKGROUND_LAYER_ID = "background";
18
- const BACKGROUND_RECT_ID = "background-color-rect";
19
- const BACKGROUND_IMAGE_ID = "background-image";
20
- const DEFAULT_WIDTH = 800;
21
- const DEFAULT_HEIGHT = 600;
22
-
23
- export class BackgroundTool implements Extension {
24
- id = "pooder.kit.background";
25
- public metadata = {
26
- name: "BackgroundTool",
27
- };
28
-
29
- private color: string = "";
30
- private url: string = "";
31
-
32
- private canvasService?: CanvasService;
33
- private specs: RenderObjectSpec[] = [];
34
- private renderProducerDisposable?: { dispose: () => void };
35
- private renderSeq = 0;
36
- private renderImageUrl = "";
37
- private sourceSizeBySrc: Map<string, SourceSize> = new Map();
38
- private pendingSizeBySrc: Map<string, Promise<SourceSize | null>> = new Map();
39
- private onCanvasResized = () => {
40
- this.updateBackground();
41
- };
42
-
43
- constructor(
44
- options?: Partial<{
45
- color: string;
46
- url: string;
47
- }>,
48
- ) {
49
- if (options) {
50
- Object.assign(this, options);
51
- }
52
- }
53
-
54
- activate(context: ExtensionContext) {
55
- this.canvasService = context.services.get<CanvasService>("CanvasService");
56
- if (!this.canvasService) {
57
- console.warn("CanvasService not found for BackgroundTool");
58
- return;
59
- }
60
-
61
- this.renderProducerDisposable?.dispose();
62
- this.renderProducerDisposable = this.canvasService.registerRenderProducer(
63
- this.id,
64
- () => ({
65
- layerSpecs: {
66
- [BACKGROUND_LAYER_ID]: this.specs,
67
- },
68
- }),
69
- { priority: 0 },
70
- );
71
-
72
- const configService = context.services.get<ConfigurationService>(
73
- "ConfigurationService",
74
- );
75
- if (configService) {
76
- // Load initial config
77
- this.color = configService.get("background.color", this.color);
78
- this.url = configService.get("background.url", this.url);
79
-
80
- // Listen for changes
81
- configService.onAnyChange((e: { key: string; value: any }) => {
82
- if (e.key.startsWith("background.")) {
83
- const prop = e.key.split(".")[1];
84
- console.log(
85
- `[BackgroundTool] Config change detected: ${e.key} -> ${e.value}, prop: ${prop}`,
86
- );
87
- if (prop && prop in this) {
88
- console.log(
89
- `[BackgroundTool] Updating option ${prop} to ${e.value}`,
90
- );
91
- (this as any)[prop] = e.value;
92
- this.updateBackground();
93
- } else {
94
- console.warn(
95
- `[BackgroundTool] Property ${prop} not found in options`,
96
- );
97
- }
98
- }
99
- });
100
- }
101
-
102
- context.eventBus.on("canvas:resized", this.onCanvasResized);
103
- this.updateBackground();
104
- }
105
-
106
- deactivate(context: ExtensionContext) {
107
- context.eventBus.off("canvas:resized", this.onCanvasResized);
108
- this.renderSeq += 1;
109
- this.specs = [];
110
- this.renderImageUrl = "";
111
- this.renderProducerDisposable?.dispose();
112
- this.renderProducerDisposable = undefined;
113
- if (!this.canvasService) return;
114
- const layer = this.canvasService.getLayer(BACKGROUND_LAYER_ID);
115
- if (layer) {
116
- this.canvasService.canvas.remove(layer);
117
- }
118
- void this.canvasService.flushRenderFromProducers();
119
- this.canvasService.requestRenderAll();
120
- this.canvasService = undefined;
121
- }
122
-
123
- contribute() {
124
- return {
125
- [ContributionPointIds.CONFIGURATIONS]: [
126
- {
127
- id: "background.color",
128
- type: "color",
129
- label: "Background Color",
130
- default: "",
131
- },
132
- {
133
- id: "background.url",
134
- type: "string",
135
- label: "Image URL",
136
- default: "",
137
- },
138
- ] as ConfigurationContribution[],
139
- [ContributionPointIds.COMMANDS]: [
140
- {
141
- command: "reset",
142
- title: "Reset Background",
143
- handler: () => {
144
- this.updateBackground();
145
- return true;
146
- },
147
- },
148
- {
149
- command: "clear",
150
- title: "Clear Background",
151
- handler: () => {
152
- this.color = "transparent";
153
- this.url = "";
154
- this.updateBackground();
155
- return true;
156
- },
157
- },
158
- {
159
- command: "setBackgroundColor",
160
- title: "Set Background Color",
161
- handler: (color: string) => {
162
- if (this.color === color) return true;
163
- this.color = color;
164
- this.updateBackground();
165
- return true;
166
- },
167
- },
168
- {
169
- command: "setBackgroundImage",
170
- title: "Set Background Image",
171
- handler: (url: string) => {
172
- if (this.url === url) return true;
173
- this.url = url;
174
- this.updateBackground();
175
- return true;
176
- },
177
- },
178
- ] as CommandContribution[],
179
- };
180
- }
181
-
182
- private getViewportSize(): { width: number; height: number } {
183
- const width = Number(this.canvasService?.canvas.width || 0);
184
- const height = Number(this.canvasService?.canvas.height || 0);
185
- return {
186
- width: width > 0 ? width : DEFAULT_WIDTH,
187
- height: height > 0 ? height : DEFAULT_HEIGHT,
188
- };
189
- }
190
-
191
- private buildBackgroundSpecs(
192
- color: string,
193
- imageUrl: string,
194
- ): RenderObjectSpec[] {
195
- const { width, height } = this.getViewportSize();
196
- const specs: RenderObjectSpec[] = [
197
- {
198
- id: BACKGROUND_RECT_ID,
199
- type: "rect",
200
- space: "screen",
201
- data: {
202
- id: BACKGROUND_RECT_ID,
203
- layerId: BACKGROUND_LAYER_ID,
204
- type: "background-color",
205
- },
206
- props: {
207
- left: 0,
208
- top: 0,
209
- width,
210
- height,
211
- originX: "left",
212
- originY: "top",
213
- fill: color,
214
- selectable: false,
215
- evented: false,
216
- excludeFromExport: true,
217
- },
218
- },
219
- ];
220
-
221
- if (!imageUrl) {
222
- return specs;
223
- }
224
-
225
- const sourceSize = this.sourceSizeBySrc.get(imageUrl);
226
- const sourceWidth = Math.max(1, Number(sourceSize?.width || width));
227
- const sourceHeight = Math.max(1, Number(sourceSize?.height || height));
228
- const coverScale = Math.max(width / sourceWidth, height / sourceHeight);
229
- specs.push({
230
- id: BACKGROUND_IMAGE_ID,
231
- type: "image",
232
- src: imageUrl,
233
- space: "screen",
234
- data: {
235
- id: BACKGROUND_IMAGE_ID,
236
- layerId: BACKGROUND_LAYER_ID,
237
- type: "background-image",
238
- },
239
- props: {
240
- left: 0,
241
- top: 0,
242
- originX: "left",
243
- originY: "top",
244
- scaleX: coverScale,
245
- scaleY: coverScale,
246
- selectable: false,
247
- evented: false,
248
- excludeFromExport: true,
249
- },
250
- });
251
-
252
- return specs;
253
- }
254
-
255
- private async ensureImageSize(src: string): Promise<SourceSize | null> {
256
- if (!src) return null;
257
- const cached = this.sourceSizeBySrc.get(src);
258
- if (cached) return cached;
259
-
260
- const pending = this.pendingSizeBySrc.get(src);
261
- if (pending) {
262
- return pending;
263
- }
264
-
265
- const task = this.loadImageSize(src);
266
- this.pendingSizeBySrc.set(src, task);
267
- try {
268
- return await task;
269
- } finally {
270
- if (this.pendingSizeBySrc.get(src) === task) {
271
- this.pendingSizeBySrc.delete(src);
272
- }
273
- }
274
- }
275
-
276
- private async loadImageSize(src: string): Promise<SourceSize | null> {
277
- try {
278
- const image = await FabricImage.fromURL(src, {
279
- crossOrigin: "anonymous",
280
- });
281
- const width = Number(image?.width || 0);
282
- const height = Number(image?.height || 0);
283
- if (width > 0 && height > 0) {
284
- const size = { width, height };
285
- this.sourceSizeBySrc.set(src, size);
286
- return size;
287
- }
288
- } catch (error) {
289
- console.error("[BackgroundTool] Failed to load image", src, error);
290
- }
291
- return null;
292
- }
293
-
294
- private updateBackground() {
295
- void this.updateBackgroundAsync();
296
- }
297
-
298
- private async updateBackgroundAsync() {
299
- if (!this.canvasService) return;
300
- const seq = ++this.renderSeq;
301
- const color = this.color;
302
- const nextUrl = String(this.url || "").trim();
303
-
304
- if (!nextUrl) {
305
- this.renderImageUrl = "";
306
- } else if (nextUrl !== this.renderImageUrl) {
307
- const loaded = await this.ensureImageSize(nextUrl);
308
- if (seq !== this.renderSeq) return;
309
- if (loaded) {
310
- this.renderImageUrl = nextUrl;
311
- }
312
- }
313
-
314
- this.specs = this.buildBackgroundSpecs(color, this.renderImageUrl);
315
- await this.canvasService.flushRenderFromProducers();
316
- if (seq !== this.renderSeq) return;
317
- const layer = this.canvasService.getLayer(BACKGROUND_LAYER_ID);
318
- if (layer) {
319
- this.canvasService.canvas.sendObjectToBack(layer);
320
- }
321
- this.canvasService.requestRenderAll();
322
- }
323
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ } from "@pooder/core";
9
+ import { FabricImage } from "fabric";
10
+ import { CanvasService, RenderObjectSpec } from "../services";
11
+ import {
12
+ computeSceneLayout,
13
+ readSizeState,
14
+ type SceneLayoutSnapshot,
15
+ } from "./sceneLayoutModel";
16
+
17
+ interface SourceSize {
18
+ width: number;
19
+ height: number;
20
+ }
21
+
22
+ interface Rect {
23
+ left: number;
24
+ top: number;
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ export type BackgroundLayerKind = "color" | "image";
30
+ export type BackgroundFitMode = "cover" | "contain" | "stretch";
31
+
32
+ export interface BackgroundLayer {
33
+ id: string;
34
+ kind: BackgroundLayerKind;
35
+ anchor: string;
36
+ fit: BackgroundFitMode;
37
+ opacity: number;
38
+ order: number;
39
+ enabled: boolean;
40
+ exportable: boolean;
41
+ color?: string;
42
+ src?: string;
43
+ }
44
+
45
+ export interface BackgroundConfig {
46
+ version: number;
47
+ layers: BackgroundLayer[];
48
+ }
49
+
50
+ const BACKGROUND_LAYER_ID = "background";
51
+ const BACKGROUND_CONFIG_KEY = "background.config";
52
+
53
+ const DEFAULT_WIDTH = 800;
54
+ const DEFAULT_HEIGHT = 600;
55
+
56
+ const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
57
+ version: 1,
58
+ layers: [
59
+ {
60
+ id: "base-color",
61
+ kind: "color",
62
+ anchor: "viewport",
63
+ fit: "cover",
64
+ opacity: 1,
65
+ order: 0,
66
+ enabled: true,
67
+ exportable: false,
68
+ color: "#fff",
69
+ },
70
+ ],
71
+ };
72
+
73
+ function clampOpacity(value: unknown, fallback: number): number {
74
+ const numeric = Number(value);
75
+ if (!Number.isFinite(numeric)) {
76
+ return Math.max(0, Math.min(1, fallback));
77
+ }
78
+ return Math.max(0, Math.min(1, numeric));
79
+ }
80
+
81
+ function normalizeLayerKind(
82
+ value: unknown,
83
+ fallback: BackgroundLayerKind,
84
+ ): BackgroundLayerKind {
85
+ if (value === "color" || value === "image") {
86
+ return value;
87
+ }
88
+ return fallback;
89
+ }
90
+
91
+ function normalizeFitMode(
92
+ value: unknown,
93
+ fallback: BackgroundFitMode,
94
+ ): BackgroundFitMode {
95
+ if (value === "contain" || value === "cover" || value === "stretch") {
96
+ return value;
97
+ }
98
+ return fallback;
99
+ }
100
+
101
+ function normalizeAnchor(value: unknown, fallback: string): string {
102
+ if (typeof value !== "string") return fallback;
103
+ const trimmed = value.trim();
104
+ return trimmed || fallback;
105
+ }
106
+
107
+ function normalizeOrder(value: unknown, fallback: number): number {
108
+ const numeric = Number(value);
109
+ if (!Number.isFinite(numeric)) return fallback;
110
+ return numeric;
111
+ }
112
+
113
+ function normalizeLayer(
114
+ raw: unknown,
115
+ index: number,
116
+ fallback?: BackgroundLayer,
117
+ ): BackgroundLayer {
118
+ const fallbackLayer: BackgroundLayer = fallback || {
119
+ id: `layer-${index + 1}`,
120
+ kind: "image",
121
+ anchor: "viewport",
122
+ fit: "contain",
123
+ opacity: 1,
124
+ order: index,
125
+ enabled: true,
126
+ exportable: false,
127
+ src: "",
128
+ };
129
+
130
+ if (!raw || typeof raw !== "object") {
131
+ return { ...fallbackLayer };
132
+ }
133
+
134
+ const input = raw as Partial<BackgroundLayer>;
135
+ const kind = normalizeLayerKind(input.kind, fallbackLayer.kind);
136
+ return {
137
+ id:
138
+ typeof input.id === "string" && input.id.trim().length > 0
139
+ ? input.id.trim()
140
+ : fallbackLayer.id,
141
+ kind,
142
+ anchor: normalizeAnchor(input.anchor, fallbackLayer.anchor),
143
+ fit: normalizeFitMode(input.fit, fallbackLayer.fit),
144
+ opacity: clampOpacity(input.opacity, fallbackLayer.opacity),
145
+ order: normalizeOrder(input.order, fallbackLayer.order),
146
+ enabled:
147
+ typeof input.enabled === "boolean"
148
+ ? input.enabled
149
+ : fallbackLayer.enabled,
150
+ exportable:
151
+ typeof input.exportable === "boolean"
152
+ ? input.exportable
153
+ : fallbackLayer.exportable,
154
+ color:
155
+ kind === "color"
156
+ ? typeof input.color === "string"
157
+ ? input.color
158
+ : typeof fallbackLayer.color === "string"
159
+ ? fallbackLayer.color
160
+ : "#ffffff"
161
+ : undefined,
162
+ src:
163
+ kind === "image"
164
+ ? typeof input.src === "string"
165
+ ? input.src.trim()
166
+ : typeof fallbackLayer.src === "string"
167
+ ? fallbackLayer.src
168
+ : ""
169
+ : undefined,
170
+ };
171
+ }
172
+
173
+ function normalizeConfig(raw: unknown): BackgroundConfig {
174
+ if (!raw || typeof raw !== "object") {
175
+ return cloneConfig(DEFAULT_BACKGROUND_CONFIG);
176
+ }
177
+
178
+ const input = raw as Partial<BackgroundConfig>;
179
+ const version = Number.isFinite(Number(input.version))
180
+ ? Number(input.version)
181
+ : DEFAULT_BACKGROUND_CONFIG.version;
182
+
183
+ const baseLayers = Array.isArray(input.layers)
184
+ ? input.layers.map((layer, index) => normalizeLayer(layer, index))
185
+ : cloneConfig(DEFAULT_BACKGROUND_CONFIG).layers;
186
+
187
+ const uniqueLayers: BackgroundLayer[] = [];
188
+ const seen = new Set<string>();
189
+
190
+ baseLayers.forEach((layer, index) => {
191
+ let nextId = layer.id || `layer-${index + 1}`;
192
+ let serial = 1;
193
+ while (seen.has(nextId)) {
194
+ serial += 1;
195
+ nextId = `${layer.id || `layer-${index + 1}`}-${serial}`;
196
+ }
197
+ seen.add(nextId);
198
+ uniqueLayers.push({ ...layer, id: nextId });
199
+ });
200
+
201
+ return {
202
+ version,
203
+ layers: uniqueLayers,
204
+ };
205
+ }
206
+
207
+ function cloneConfig(config: BackgroundConfig): BackgroundConfig {
208
+ return {
209
+ version: config.version,
210
+ layers: (config.layers || []).map((layer) => ({ ...layer })),
211
+ };
212
+ }
213
+
214
+ function mergeConfig(base: BackgroundConfig, patch: Partial<BackgroundConfig>) {
215
+ const merged: BackgroundConfig = {
216
+ version:
217
+ patch.version === undefined
218
+ ? base.version
219
+ : Number.isFinite(Number(patch.version))
220
+ ? Number(patch.version)
221
+ : base.version,
222
+ layers: Array.isArray(patch.layers)
223
+ ? patch.layers.map((layer, index) => normalizeLayer(layer, index))
224
+ : base.layers.map((layer) => ({ ...layer })),
225
+ };
226
+
227
+ return normalizeConfig(merged);
228
+ }
229
+
230
+ function configSignature(config: BackgroundConfig): string {
231
+ return JSON.stringify(config);
232
+ }
233
+
234
+ export class BackgroundTool implements Extension {
235
+ id = "pooder.kit.background";
236
+
237
+ public metadata = {
238
+ name: "BackgroundTool",
239
+ };
240
+
241
+ private config: BackgroundConfig = cloneConfig(DEFAULT_BACKGROUND_CONFIG);
242
+
243
+ private canvasService?: CanvasService;
244
+ private configService?: ConfigurationService;
245
+
246
+ private specs: RenderObjectSpec[] = [];
247
+ private renderProducerDisposable?: { dispose: () => void };
248
+ private configChangeDisposable?: { dispose: () => void };
249
+
250
+ private renderSeq = 0;
251
+ private latestSceneLayout: SceneLayoutSnapshot | null = null;
252
+
253
+ private sourceSizeBySrc: Map<string, SourceSize> = new Map();
254
+ private pendingSizeBySrc: Map<string, Promise<SourceSize | null>> = new Map();
255
+
256
+ private onCanvasResized = () => {
257
+ this.latestSceneLayout = null;
258
+ this.updateBackground();
259
+ };
260
+
261
+ private onSceneLayoutChanged = (layout: SceneLayoutSnapshot) => {
262
+ this.latestSceneLayout = layout;
263
+ this.updateBackground();
264
+ };
265
+
266
+ constructor(options?: Partial<BackgroundConfig>) {
267
+ if (options && typeof options === "object") {
268
+ this.config = mergeConfig(this.config, options);
269
+ }
270
+ }
271
+
272
+ activate(context: ExtensionContext) {
273
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
274
+ if (!this.canvasService) {
275
+ console.warn("CanvasService not found for BackgroundTool");
276
+ return;
277
+ }
278
+
279
+ this.configService = context.services.get<ConfigurationService>(
280
+ "ConfigurationService",
281
+ );
282
+ if (this.configService) {
283
+ this.config = normalizeConfig(
284
+ this.configService.get(
285
+ BACKGROUND_CONFIG_KEY,
286
+ DEFAULT_BACKGROUND_CONFIG,
287
+ ),
288
+ );
289
+ this.configChangeDisposable?.dispose();
290
+ this.configChangeDisposable = this.configService.onAnyChange(
291
+ (e: { key: string; value: any }) => {
292
+ if (e.key === BACKGROUND_CONFIG_KEY) {
293
+ this.config = normalizeConfig(e.value);
294
+ this.updateBackground();
295
+ return;
296
+ }
297
+
298
+ if (e.key.startsWith("size.")) {
299
+ this.latestSceneLayout = null;
300
+ this.updateBackground();
301
+ }
302
+ },
303
+ );
304
+ }
305
+
306
+ this.renderProducerDisposable?.dispose();
307
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
308
+ this.id,
309
+ () => ({
310
+ passes: [
311
+ {
312
+ id: BACKGROUND_LAYER_ID,
313
+ stack: 0,
314
+ order: 0,
315
+ objects: this.specs,
316
+ },
317
+ ],
318
+ }),
319
+ { priority: 0 },
320
+ );
321
+
322
+ context.eventBus.on("canvas:resized", this.onCanvasResized);
323
+ context.eventBus.on("scene:layout:change", this.onSceneLayoutChanged);
324
+ this.updateBackground();
325
+ }
326
+
327
+ deactivate(context: ExtensionContext) {
328
+ context.eventBus.off("canvas:resized", this.onCanvasResized);
329
+ context.eventBus.off("scene:layout:change", this.onSceneLayoutChanged);
330
+
331
+ this.renderSeq += 1;
332
+ this.specs = [];
333
+ this.latestSceneLayout = null;
334
+
335
+ this.configChangeDisposable?.dispose();
336
+ this.configChangeDisposable = undefined;
337
+
338
+ this.renderProducerDisposable?.dispose();
339
+ this.renderProducerDisposable = undefined;
340
+
341
+ if (!this.canvasService) return;
342
+
343
+ void this.canvasService.flushRenderFromProducers();
344
+ this.canvasService.requestRenderAll();
345
+
346
+ this.canvasService = undefined;
347
+ this.configService = undefined;
348
+ }
349
+
350
+ contribute() {
351
+ return {
352
+ [ContributionPointIds.CONFIGURATIONS]: [
353
+ {
354
+ id: BACKGROUND_CONFIG_KEY,
355
+ type: "json",
356
+ label: "Background Config",
357
+ default: cloneConfig(DEFAULT_BACKGROUND_CONFIG),
358
+ },
359
+ ] as ConfigurationContribution[],
360
+ [ContributionPointIds.COMMANDS]: [
361
+ {
362
+ command: "background.getConfig",
363
+ title: "Get Background Config",
364
+ handler: () => cloneConfig(this.config),
365
+ },
366
+ {
367
+ command: "background.resetConfig",
368
+ title: "Reset Background Config",
369
+ handler: () => {
370
+ this.commitConfig(cloneConfig(DEFAULT_BACKGROUND_CONFIG));
371
+ return true;
372
+ },
373
+ },
374
+ {
375
+ command: "background.replaceConfig",
376
+ title: "Replace Background Config",
377
+ handler: (config: BackgroundConfig) => {
378
+ this.commitConfig(normalizeConfig(config));
379
+ return true;
380
+ },
381
+ },
382
+ {
383
+ command: "background.patchConfig",
384
+ title: "Patch Background Config",
385
+ handler: (patch: Partial<BackgroundConfig>) => {
386
+ this.commitConfig(mergeConfig(this.config, patch || {}));
387
+ return true;
388
+ },
389
+ },
390
+ {
391
+ command: "background.upsertLayer",
392
+ title: "Upsert Background Layer",
393
+ handler: (layer: Partial<BackgroundLayer> & { id: string }) => {
394
+ const normalized = normalizeLayer(layer, 0);
395
+ const existingIndex = this.config.layers.findIndex(
396
+ (item) => item.id === normalized.id,
397
+ );
398
+ const nextLayers = [...this.config.layers];
399
+ if (existingIndex >= 0) {
400
+ nextLayers[existingIndex] = normalizeLayer(
401
+ { ...nextLayers[existingIndex], ...layer },
402
+ existingIndex,
403
+ nextLayers[existingIndex],
404
+ );
405
+ } else {
406
+ nextLayers.push(
407
+ normalizeLayer(
408
+ {
409
+ ...normalized,
410
+ order: Number.isFinite(Number(layer.order))
411
+ ? Number(layer.order)
412
+ : nextLayers.length,
413
+ },
414
+ nextLayers.length,
415
+ ),
416
+ );
417
+ }
418
+ this.commitConfig(
419
+ normalizeConfig({
420
+ ...this.config,
421
+ layers: nextLayers,
422
+ }),
423
+ );
424
+ return true;
425
+ },
426
+ },
427
+ {
428
+ command: "background.removeLayer",
429
+ title: "Remove Background Layer",
430
+ handler: (id: string) => {
431
+ const nextLayers = this.config.layers.filter(
432
+ (layer) => layer.id !== id,
433
+ );
434
+ this.commitConfig(
435
+ normalizeConfig({
436
+ ...this.config,
437
+ layers: nextLayers,
438
+ }),
439
+ );
440
+ return true;
441
+ },
442
+ },
443
+ ] as CommandContribution[],
444
+ };
445
+ }
446
+
447
+ private commitConfig(next: BackgroundConfig) {
448
+ const normalized = normalizeConfig(next);
449
+ if (configSignature(normalized) === configSignature(this.config)) {
450
+ return;
451
+ }
452
+
453
+ if (this.configService) {
454
+ this.configService.update(BACKGROUND_CONFIG_KEY, cloneConfig(normalized));
455
+ return;
456
+ }
457
+
458
+ this.config = normalized;
459
+ this.updateBackground();
460
+ }
461
+
462
+ private getViewportRect(): Rect {
463
+ const width = Number(this.canvasService?.canvas.width || 0);
464
+ const height = Number(this.canvasService?.canvas.height || 0);
465
+
466
+ return {
467
+ left: 0,
468
+ top: 0,
469
+ width: width > 0 ? width : DEFAULT_WIDTH,
470
+ height: height > 0 ? height : DEFAULT_HEIGHT,
471
+ };
472
+ }
473
+
474
+ private resolveSceneLayout(): SceneLayoutSnapshot | null {
475
+ if (this.latestSceneLayout) return this.latestSceneLayout;
476
+ if (!this.canvasService || !this.configService) return null;
477
+
478
+ const layout = computeSceneLayout(
479
+ this.canvasService,
480
+ readSizeState(this.configService),
481
+ );
482
+ this.latestSceneLayout = layout;
483
+ return layout;
484
+ }
485
+
486
+ private resolveFocusRect(): Rect | null {
487
+ const layout = this.resolveSceneLayout();
488
+ if (!layout) return null;
489
+
490
+ return {
491
+ left: layout.trimRect.left,
492
+ top: layout.trimRect.top,
493
+ width: layout.trimRect.width,
494
+ height: layout.trimRect.height,
495
+ };
496
+ }
497
+
498
+ private resolveAnchorRect(anchor: string): Rect {
499
+ if (anchor === "focus") {
500
+ return this.resolveFocusRect() || this.getViewportRect();
501
+ }
502
+
503
+ if (anchor !== "viewport") {
504
+ return this.getViewportRect();
505
+ }
506
+
507
+ return this.getViewportRect();
508
+ }
509
+
510
+ private resolveImagePlacement(
511
+ target: Rect,
512
+ sourceSize: SourceSize,
513
+ fit: BackgroundFitMode,
514
+ ): { left: number; top: number; scaleX: number; scaleY: number } {
515
+ const targetWidth = Math.max(1, Number(target.width || 0));
516
+ const targetHeight = Math.max(1, Number(target.height || 0));
517
+ const sourceWidth = Math.max(1, Number(sourceSize.width || 0));
518
+ const sourceHeight = Math.max(1, Number(sourceSize.height || 0));
519
+
520
+ if (fit === "stretch") {
521
+ return {
522
+ left: target.left,
523
+ top: target.top,
524
+ scaleX: targetWidth / sourceWidth,
525
+ scaleY: targetHeight / sourceHeight,
526
+ };
527
+ }
528
+
529
+ const scale =
530
+ fit === "contain"
531
+ ? Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight)
532
+ : Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
533
+
534
+ const renderWidth = sourceWidth * scale;
535
+ const renderHeight = sourceHeight * scale;
536
+
537
+ return {
538
+ left: target.left + (targetWidth - renderWidth) / 2,
539
+ top: target.top + (targetHeight - renderHeight) / 2,
540
+ scaleX: scale,
541
+ scaleY: scale,
542
+ };
543
+ }
544
+
545
+ private buildColorLayerSpec(layer: BackgroundLayer): RenderObjectSpec {
546
+ const rect = this.resolveAnchorRect(layer.anchor);
547
+
548
+ return {
549
+ id: `background.layer.${layer.id}.color`,
550
+ type: "rect",
551
+ space: "screen",
552
+ data: {
553
+ id: `background.layer.${layer.id}.color`,
554
+ layerId: BACKGROUND_LAYER_ID,
555
+ type: "background-layer",
556
+ layerRef: layer.id,
557
+ layerKind: layer.kind,
558
+ },
559
+ props: {
560
+ left: rect.left,
561
+ top: rect.top,
562
+ width: rect.width,
563
+ height: rect.height,
564
+ originX: "left",
565
+ originY: "top",
566
+ fill: layer.color || "transparent",
567
+ opacity: layer.opacity,
568
+ selectable: false,
569
+ evented: false,
570
+ excludeFromExport: !layer.exportable,
571
+ },
572
+ };
573
+ }
574
+
575
+ private buildImageLayerSpec(layer: BackgroundLayer): RenderObjectSpec[] {
576
+ const src = String(layer.src || "").trim();
577
+ if (!src) return [];
578
+
579
+ const sourceSize = this.sourceSizeBySrc.get(src);
580
+ if (!sourceSize) return [];
581
+
582
+ const rect = this.resolveAnchorRect(layer.anchor);
583
+ const placement = this.resolveImagePlacement(rect, sourceSize, layer.fit);
584
+
585
+ return [
586
+ {
587
+ id: `background.layer.${layer.id}.image`,
588
+ type: "image",
589
+ src,
590
+ space: "screen",
591
+ data: {
592
+ id: `background.layer.${layer.id}.image`,
593
+ layerId: BACKGROUND_LAYER_ID,
594
+ type: "background-layer",
595
+ layerRef: layer.id,
596
+ layerKind: layer.kind,
597
+ },
598
+ props: {
599
+ left: placement.left,
600
+ top: placement.top,
601
+ originX: "left",
602
+ originY: "top",
603
+ scaleX: placement.scaleX,
604
+ scaleY: placement.scaleY,
605
+ opacity: layer.opacity,
606
+ selectable: false,
607
+ evented: false,
608
+ excludeFromExport: !layer.exportable,
609
+ },
610
+ },
611
+ ];
612
+ }
613
+
614
+ private buildBackgroundSpecs(config: BackgroundConfig): RenderObjectSpec[] {
615
+ const activeLayers = (config.layers || [])
616
+ .filter((layer) => layer.enabled)
617
+ .map((layer, index) => ({ layer, index }))
618
+ .sort((a, b) => {
619
+ if (a.layer.order !== b.layer.order) {
620
+ return a.layer.order - b.layer.order;
621
+ }
622
+ return a.index - b.index;
623
+ });
624
+
625
+ const specs: RenderObjectSpec[] = [];
626
+
627
+ activeLayers.forEach(({ layer }) => {
628
+ if (layer.kind === "color") {
629
+ specs.push(this.buildColorLayerSpec(layer));
630
+ return;
631
+ }
632
+ specs.push(...this.buildImageLayerSpec(layer));
633
+ });
634
+
635
+ return specs;
636
+ }
637
+
638
+ private collectActiveImageUrls(config: BackgroundConfig): string[] {
639
+ const urls = new Set<string>();
640
+
641
+ (config.layers || []).forEach((layer) => {
642
+ if (!layer.enabled || layer.kind !== "image") return;
643
+ const src = String(layer.src || "").trim();
644
+ if (!src) return;
645
+ urls.add(src);
646
+ });
647
+
648
+ return Array.from(urls);
649
+ }
650
+
651
+ private async ensureImageSize(src: string): Promise<SourceSize | null> {
652
+ if (!src) return null;
653
+
654
+ const cached = this.sourceSizeBySrc.get(src);
655
+ if (cached) return cached;
656
+
657
+ const pending = this.pendingSizeBySrc.get(src);
658
+ if (pending) {
659
+ return pending;
660
+ }
661
+
662
+ const task = this.loadImageSize(src);
663
+ this.pendingSizeBySrc.set(src, task);
664
+
665
+ try {
666
+ return await task;
667
+ } finally {
668
+ if (this.pendingSizeBySrc.get(src) === task) {
669
+ this.pendingSizeBySrc.delete(src);
670
+ }
671
+ }
672
+ }
673
+
674
+ private async loadImageSize(src: string): Promise<SourceSize | null> {
675
+ try {
676
+ const image = await FabricImage.fromURL(src, {
677
+ crossOrigin: "anonymous",
678
+ });
679
+ const width = Number(image?.width || 0);
680
+ const height = Number(image?.height || 0);
681
+ if (width > 0 && height > 0) {
682
+ const size = { width, height };
683
+ this.sourceSizeBySrc.set(src, size);
684
+ return size;
685
+ }
686
+ } catch (error) {
687
+ console.error("[BackgroundTool] Failed to load image", src, error);
688
+ }
689
+
690
+ return null;
691
+ }
692
+
693
+ private updateBackground() {
694
+ void this.updateBackgroundAsync();
695
+ }
696
+
697
+ private async updateBackgroundAsync() {
698
+ if (!this.canvasService) return;
699
+
700
+ const seq = ++this.renderSeq;
701
+ const currentConfig = cloneConfig(this.config);
702
+ const activeUrls = this.collectActiveImageUrls(currentConfig);
703
+
704
+ if (activeUrls.length > 0) {
705
+ await Promise.all(activeUrls.map((url) => this.ensureImageSize(url)));
706
+ if (seq !== this.renderSeq) return;
707
+ }
708
+
709
+ this.specs = this.buildBackgroundSpecs(currentConfig);
710
+
711
+ await this.canvasService.flushRenderFromProducers();
712
+ if (seq !== this.renderSeq) return;
713
+
714
+ this.canvasService.requestRenderAll();
715
+ }
716
+ }