@image-marker/web 0.1.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 (124) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +100 -0
  3. package/api-contract.json +224 -0
  4. package/lib/commonjs/core/batch.d.ts +32 -0
  5. package/lib/commonjs/core/batch.js +85 -0
  6. package/lib/commonjs/core/content-credentials.d.ts +53 -0
  7. package/lib/commonjs/core/content-credentials.js +96 -0
  8. package/lib/commonjs/core/image-info.d.ts +30 -0
  9. package/lib/commonjs/core/image-info.js +348 -0
  10. package/lib/commonjs/core/index.d.ts +1014 -0
  11. package/lib/commonjs/core/index.js +92 -0
  12. package/lib/commonjs/core/invisible-watermark.d.ts +102 -0
  13. package/lib/commonjs/core/invisible-watermark.js +913 -0
  14. package/lib/commonjs/core/job.d.ts +57 -0
  15. package/lib/commonjs/core/job.js +151 -0
  16. package/lib/commonjs/core/layout.d.ts +19 -0
  17. package/lib/commonjs/core/layout.js +75 -0
  18. package/lib/commonjs/core/marker.d.ts +1 -0
  19. package/lib/commonjs/core/marker.js +11 -0
  20. package/lib/commonjs/core/recipe.d.ts +44 -0
  21. package/lib/commonjs/core/recipe.js +175 -0
  22. package/lib/commonjs/core/result.d.ts +34 -0
  23. package/lib/commonjs/core/result.js +41 -0
  24. package/lib/commonjs/core/trace-runtime.d.ts +43 -0
  25. package/lib/commonjs/core/trace-runtime.js +92 -0
  26. package/lib/commonjs/core/validate.d.ts +4 -0
  27. package/lib/commonjs/core/validate.js +192 -0
  28. package/lib/commonjs/core/web/browser.d.ts +105 -0
  29. package/lib/commonjs/core/web/browser.js +434 -0
  30. package/lib/commonjs/core/web/helpers.d.ts +41 -0
  31. package/lib/commonjs/core/web/helpers.js +264 -0
  32. package/lib/commonjs/core/web/index.d.ts +59 -0
  33. package/lib/commonjs/core/web/index.js +724 -0
  34. package/lib/commonjs/core/web/invisible-worker-client.d.ts +13 -0
  35. package/lib/commonjs/core/web/invisible-worker-client.js +140 -0
  36. package/lib/commonjs/core/web/renderer.d.ts +20 -0
  37. package/lib/commonjs/core/web/renderer.js +665 -0
  38. package/lib/commonjs/core/web/runtime.d.ts +106 -0
  39. package/lib/commonjs/core/web/runtime.js +2 -0
  40. package/lib/commonjs/editor/adapter.d.ts +26 -0
  41. package/lib/commonjs/editor/adapter.js +102 -0
  42. package/lib/commonjs/editor/controller.d.ts +106 -0
  43. package/lib/commonjs/editor/controller.js +919 -0
  44. package/lib/commonjs/editor/core-contract.d.ts +2 -0
  45. package/lib/commonjs/editor/core-contract.js +23 -0
  46. package/lib/commonjs/editor/geometry.d.ts +8 -0
  47. package/lib/commonjs/editor/geometry.js +109 -0
  48. package/lib/commonjs/editor/headless.d.ts +5 -0
  49. package/lib/commonjs/editor/headless.js +22 -0
  50. package/lib/commonjs/editor/presets.d.ts +19 -0
  51. package/lib/commonjs/editor/presets.js +64 -0
  52. package/lib/commonjs/editor/projection.d.ts +12 -0
  53. package/lib/commonjs/editor/projection.js +195 -0
  54. package/lib/commonjs/editor/types.d.ts +166 -0
  55. package/lib/commonjs/editor/types.js +2 -0
  56. package/lib/commonjs/editor-adapter.d.ts +7 -0
  57. package/lib/commonjs/editor-adapter.js +17 -0
  58. package/lib/commonjs/headless.d.ts +2 -0
  59. package/lib/commonjs/headless.js +26 -0
  60. package/lib/commonjs/index.d.ts +5 -0
  61. package/lib/commonjs/index.js +27 -0
  62. package/lib/commonjs/package.json +1 -0
  63. package/lib/module/core/batch.d.ts +32 -0
  64. package/lib/module/core/batch.js +82 -0
  65. package/lib/module/core/content-credentials.d.ts +53 -0
  66. package/lib/module/core/content-credentials.js +92 -0
  67. package/lib/module/core/image-info.d.ts +30 -0
  68. package/lib/module/core/image-info.js +343 -0
  69. package/lib/module/core/index.d.ts +1014 -0
  70. package/lib/module/core/index.js +56 -0
  71. package/lib/module/core/invisible-watermark.d.ts +102 -0
  72. package/lib/module/core/invisible-watermark.js +897 -0
  73. package/lib/module/core/job.d.ts +57 -0
  74. package/lib/module/core/job.js +143 -0
  75. package/lib/module/core/layout.d.ts +19 -0
  76. package/lib/module/core/layout.js +70 -0
  77. package/lib/module/core/marker.d.ts +1 -0
  78. package/lib/module/core/marker.js +4 -0
  79. package/lib/module/core/recipe.d.ts +44 -0
  80. package/lib/module/core/recipe.js +169 -0
  81. package/lib/module/core/result.d.ts +34 -0
  82. package/lib/module/core/result.js +38 -0
  83. package/lib/module/core/trace-runtime.d.ts +43 -0
  84. package/lib/module/core/trace-runtime.js +89 -0
  85. package/lib/module/core/validate.d.ts +4 -0
  86. package/lib/module/core/validate.js +187 -0
  87. package/lib/module/core/web/browser.d.ts +105 -0
  88. package/lib/module/core/web/browser.js +427 -0
  89. package/lib/module/core/web/helpers.d.ts +41 -0
  90. package/lib/module/core/web/helpers.js +251 -0
  91. package/lib/module/core/web/index.d.ts +59 -0
  92. package/lib/module/core/web/index.js +709 -0
  93. package/lib/module/core/web/invisible-worker-client.d.ts +13 -0
  94. package/lib/module/core/web/invisible-worker-client.js +137 -0
  95. package/lib/module/core/web/renderer.d.ts +20 -0
  96. package/lib/module/core/web/renderer.js +661 -0
  97. package/lib/module/core/web/runtime.d.ts +106 -0
  98. package/lib/module/core/web/runtime.js +1 -0
  99. package/lib/module/editor/adapter.d.ts +26 -0
  100. package/lib/module/editor/adapter.js +99 -0
  101. package/lib/module/editor/controller.d.ts +106 -0
  102. package/lib/module/editor/controller.js +915 -0
  103. package/lib/module/editor/core-contract.d.ts +2 -0
  104. package/lib/module/editor/core-contract.js +2 -0
  105. package/lib/module/editor/geometry.d.ts +8 -0
  106. package/lib/module/editor/geometry.js +103 -0
  107. package/lib/module/editor/headless.d.ts +5 -0
  108. package/lib/module/editor/headless.js +4 -0
  109. package/lib/module/editor/presets.d.ts +19 -0
  110. package/lib/module/editor/presets.js +57 -0
  111. package/lib/module/editor/projection.d.ts +12 -0
  112. package/lib/module/editor/projection.js +188 -0
  113. package/lib/module/editor/types.d.ts +166 -0
  114. package/lib/module/editor/types.js +1 -0
  115. package/lib/module/editor-adapter.d.ts +7 -0
  116. package/lib/module/editor-adapter.js +10 -0
  117. package/lib/module/headless.d.ts +2 -0
  118. package/lib/module/headless.js +2 -0
  119. package/lib/module/index.d.ts +5 -0
  120. package/lib/module/index.js +3 -0
  121. package/lib/module/package.json +1 -0
  122. package/lib/source-manifest.json +34 -0
  123. package/lib/worker/invisible-watermark.js +1 -0
  124. package/package.json +85 -0
@@ -0,0 +1,915 @@
1
+ import { migrateWatermarkRecipe, } from './core-contract.js';
2
+ import { normalizeSafeArea, snapLayerPosition } from './geometry.js';
3
+ function cloneSource(value) {
4
+ if (Array.isArray(value)) {
5
+ return value.map(cloneSource);
6
+ }
7
+ if (value && typeof value === 'object') {
8
+ const prototype = Object.getPrototypeOf(value);
9
+ if (prototype === Object.prototype || prototype === null) {
10
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneSource(item)]));
11
+ }
12
+ }
13
+ return value;
14
+ }
15
+ function cloneLayer(layer) {
16
+ return cloneSource(layer);
17
+ }
18
+ function cloneRecipe(recipe) {
19
+ return cloneSource(recipe);
20
+ }
21
+ function cloneExportOptions(options) {
22
+ return {
23
+ invisible: options.invisible ? { ...options.invisible } : undefined,
24
+ contentCredentials: options.contentCredentials
25
+ ? { ...options.contentCredentials }
26
+ : undefined,
27
+ };
28
+ }
29
+ function cloneViewport(viewport) {
30
+ return { ...viewport, pan: { ...viewport.pan } };
31
+ }
32
+ function coordinate(value) {
33
+ if (typeof value === 'number')
34
+ return value;
35
+ if (typeof value === 'string' && !value.trim().endsWith('%')) {
36
+ const parsed = Number(value);
37
+ if (Number.isFinite(parsed))
38
+ return parsed;
39
+ }
40
+ return 0;
41
+ }
42
+ function clamp(value, minimum, maximum) {
43
+ return Math.max(minimum, Math.min(value, maximum));
44
+ }
45
+ function clampScale(value) {
46
+ return clamp(value, 0.01, 100);
47
+ }
48
+ function normalizeDegrees(value) {
49
+ const normalized = value % 360;
50
+ return normalized < 0 ? normalized + 360 : normalized;
51
+ }
52
+ function unique(values) {
53
+ return [...new Set(values)];
54
+ }
55
+ function isControllerOptions(value) {
56
+ return 'document' in value;
57
+ }
58
+ function isClipboard(value) {
59
+ if (!value || typeof value !== 'object')
60
+ return false;
61
+ const candidate = value;
62
+ return (candidate.kind === 'image-marker/editor-layers' &&
63
+ candidate.version === 1 &&
64
+ Array.isArray(candidate.layers));
65
+ }
66
+ /**
67
+ * Headless state engine shared by native and Web editor surfaces.
68
+ *
69
+ * Recipe state, multi-selection, history, clipboard, grouping, alignment,
70
+ * viewport state, and optional autosave live here. Rendering and application
71
+ * design-system choices remain injectable.
72
+ */
73
+ export class ImageMarkerEditorController {
74
+ constructor(documentOrOptions, legacyHistoryLimit = 100) {
75
+ this.selectedLayerIds = [];
76
+ this.exportOptions = {};
77
+ this.viewport = {
78
+ zoom: 1,
79
+ pan: { x: 0, y: 0 },
80
+ fitMode: 'contain',
81
+ };
82
+ this.snapGuides = [];
83
+ this.past = [];
84
+ this.future = [];
85
+ this.listeners = new Set();
86
+ this.idSequence = 0;
87
+ this.groupSequence = 0;
88
+ this.historyGroupActive = false;
89
+ this.historyGroupCaptured = false;
90
+ const options = isControllerOptions(documentOrOptions)
91
+ ? documentOrOptions
92
+ : {
93
+ document: documentOrOptions,
94
+ historyLimit: legacyHistoryLimit,
95
+ };
96
+ this.recipe = migrateWatermarkRecipe(options.document);
97
+ this.safeArea = normalizeSafeArea();
98
+ this.historyLimit = Math.max(1, options.historyLimit ?? 100);
99
+ this.autosave = options.autosave;
100
+ this.onChange = options.onChange;
101
+ this.idSequence = this.recipe.layers.length;
102
+ }
103
+ getState() {
104
+ const selectedLayerId = this.selectedLayerIds.at(-1);
105
+ return {
106
+ recipe: cloneRecipe(this.recipe),
107
+ selectedLayerIds: [...this.selectedLayerIds],
108
+ selectedLayerId,
109
+ safeArea: { ...this.safeArea },
110
+ snapGuides: this.snapGuides.map((guide) => ({ ...guide })),
111
+ exportOptions: cloneExportOptions(this.exportOptions),
112
+ viewport: cloneViewport(this.viewport),
113
+ canUndo: this.past.length > 0,
114
+ canRedo: this.future.length > 0,
115
+ };
116
+ }
117
+ subscribe(listener) {
118
+ this.listeners.add(listener);
119
+ listener(this.getState());
120
+ return () => this.listeners.delete(listener);
121
+ }
122
+ dispose() {
123
+ if (this.autosaveTimer)
124
+ clearTimeout(this.autosaveTimer);
125
+ this.autosaveTimer = undefined;
126
+ this.listeners.clear();
127
+ }
128
+ beginHistoryGroup() {
129
+ this.historyGroupActive = true;
130
+ this.historyGroupCaptured = false;
131
+ }
132
+ endHistoryGroup() {
133
+ this.historyGroupActive = false;
134
+ this.historyGroupCaptured = false;
135
+ this.snapGuides = [];
136
+ this.emit();
137
+ }
138
+ selectLayer(id, mode = 'replace') {
139
+ if (!id || !this.recipe.layers.some((layer) => layer.id === id)) {
140
+ if (mode === 'replace')
141
+ this.selectedLayerIds = [];
142
+ this.emit(true);
143
+ return;
144
+ }
145
+ if (mode === 'add') {
146
+ this.selectedLayerIds = unique([...this.selectedLayerIds, id]);
147
+ }
148
+ else if (mode === 'toggle') {
149
+ this.selectedLayerIds = this.selectedLayerIds.includes(id)
150
+ ? this.selectedLayerIds.filter((value) => value !== id)
151
+ : [...this.selectedLayerIds, id];
152
+ }
153
+ else {
154
+ this.selectedLayerIds = [id];
155
+ }
156
+ this.emit(true);
157
+ }
158
+ selectLayers(ids) {
159
+ const existing = new Set(this.recipe.layers.map((layer) => layer.id));
160
+ this.selectedLayerIds = unique(ids).filter((id) => existing.has(id));
161
+ this.emit(true);
162
+ }
163
+ selectAll() {
164
+ this.selectedLayerIds = this.recipe.layers.map((layer) => layer.id);
165
+ this.emit(true);
166
+ }
167
+ clearSelection() {
168
+ this.selectLayer(undefined);
169
+ }
170
+ /**
171
+ * Synchronize a controlled Editor state. History capture is opt-in so parent
172
+ * renders do not create undo entries.
173
+ */
174
+ replaceState(state, recordHistory = false) {
175
+ const apply = () => {
176
+ this.recipe = cloneRecipe(state.recipe);
177
+ this.selectedLayerIds = [...state.selectedLayerIds];
178
+ this.safeArea = { ...state.safeArea };
179
+ this.exportOptions = cloneExportOptions(state.exportOptions);
180
+ this.viewport = cloneViewport(state.viewport);
181
+ this.snapGuides = state.snapGuides.map((guide) => ({ ...guide }));
182
+ };
183
+ if (recordHistory)
184
+ this.commit(apply);
185
+ else {
186
+ apply();
187
+ this.emit(true);
188
+ }
189
+ }
190
+ importRecipe(document) {
191
+ this.commit(() => {
192
+ this.recipe = migrateWatermarkRecipe(document);
193
+ this.selectedLayerIds = [];
194
+ this.idSequence = this.recipe.layers.length;
195
+ });
196
+ }
197
+ applyTemplate(template) {
198
+ this.importRecipe(template.recipe);
199
+ }
200
+ exportRecipe() {
201
+ return cloneRecipe(this.recipe);
202
+ }
203
+ addLayer(layer, index = this.recipe.layers.length) {
204
+ const id = layer.id ?? this.nextLayerId();
205
+ if (this.recipe.layers.some((item) => item.id === id)) {
206
+ throw new Error(`Layer id "${id}" already exists.`);
207
+ }
208
+ const definition = migrateWatermarkRecipe({
209
+ schemaVersion: 2,
210
+ layers: [{ ...layer, id }],
211
+ output: {},
212
+ }).layers[0];
213
+ if (!definition) {
214
+ throw new Error('Unable to create the editor layer.');
215
+ }
216
+ this.commit(() => {
217
+ const insertion = Math.max(0, Math.min(index, this.recipe.layers.length));
218
+ this.recipe.layers.splice(insertion, 0, definition);
219
+ this.selectedLayerIds = [id];
220
+ });
221
+ return id;
222
+ }
223
+ duplicateLayers(ids = this.selectedLayerIds) {
224
+ const selected = this.layersByIds(ids);
225
+ if (selected.length === 0)
226
+ return [];
227
+ const created = [];
228
+ this.commit(() => {
229
+ for (const source of selected) {
230
+ const index = this.layerIndex(source.id);
231
+ const id = this.uniqueLayerId(`${source.id}-copy`);
232
+ const copy = cloneLayer(source);
233
+ copy.id = id;
234
+ copy.name = source.name ? `${source.name} copy` : undefined;
235
+ copy.locked = false;
236
+ copy.position = {
237
+ ...copy.position,
238
+ X: coordinate(copy.position?.X) + 16,
239
+ Y: coordinate(copy.position?.Y) + 16,
240
+ };
241
+ this.recipe.layers.splice(index + 1, 0, copy);
242
+ created.push(id);
243
+ }
244
+ this.selectedLayerIds = created;
245
+ });
246
+ return created;
247
+ }
248
+ removeLayer(id = this.selectedLayerIds.at(-1)) {
249
+ if (!id)
250
+ return false;
251
+ return this.removeLayers([id]) > 0;
252
+ }
253
+ removeLayers(ids = this.selectedLayerIds) {
254
+ const selected = this.editableLayers(ids);
255
+ if (selected.length === 0)
256
+ return 0;
257
+ const selectedIds = new Set(selected.map((layer) => layer.id));
258
+ this.commit(() => {
259
+ this.recipe.layers = this.recipe.layers.filter((layer) => !selectedIds.has(layer.id));
260
+ this.selectedLayerIds = this.selectedLayerIds.filter((id) => !selectedIds.has(id));
261
+ });
262
+ return selected.length;
263
+ }
264
+ setLayerVisible(id, visible) {
265
+ this.patchLayer(id, { visible });
266
+ }
267
+ setLayerLocked(id, locked) {
268
+ const index = this.layerIndex(id);
269
+ if (index < 0)
270
+ throw new Error(`Unknown layer id "${id}".`);
271
+ this.commit(() => {
272
+ const layer = this.recipe.layers[index];
273
+ if (layer)
274
+ this.recipe.layers[index] = { ...layer, locked };
275
+ });
276
+ }
277
+ renameLayer(id, name) {
278
+ const normalized = name?.trim();
279
+ this.patchLayer(id, { name: normalized || undefined });
280
+ }
281
+ patchLayer(id, patch) {
282
+ this.updateLayer(id, (layer) => {
283
+ if (patch.id !== undefined && patch.id !== id) {
284
+ throw new Error('Layer IDs cannot be changed through update operations.');
285
+ }
286
+ if (patch.type !== undefined && patch.type !== layer.type) {
287
+ throw new Error('Layer types cannot be changed through update operations.');
288
+ }
289
+ return {
290
+ ...layer,
291
+ ...cloneSource(patch),
292
+ id,
293
+ type: layer.type,
294
+ };
295
+ });
296
+ }
297
+ updateTextLayer(id, patch) {
298
+ this.updateLayer(id, (layer) => {
299
+ if (layer.type !== 'text') {
300
+ throw new Error(`Layer "${id}" is not a text layer.`);
301
+ }
302
+ return {
303
+ ...layer,
304
+ ...patch,
305
+ style: patch.style
306
+ ? { ...layer.style, ...cloneSource(patch.style) }
307
+ : layer.style,
308
+ };
309
+ });
310
+ }
311
+ replaceImage(id, source) {
312
+ this.updateLayer(id, (layer) => {
313
+ if (layer.type !== 'image') {
314
+ throw new Error(`Layer "${id}" is not an image layer.`);
315
+ }
316
+ return { ...layer, src: source };
317
+ });
318
+ }
319
+ moveLayer(id, point, snapContext) {
320
+ const layer = this.requireEditableLayer(id);
321
+ const previous = {
322
+ x: coordinate(layer.position?.X),
323
+ y: coordinate(layer.position?.Y),
324
+ };
325
+ const snapped = snapContext
326
+ ? snapLayerPosition(point, {
327
+ ...snapContext,
328
+ safeArea: snapContext.safeArea ?? this.safeArea,
329
+ })
330
+ : { point, guides: [] };
331
+ const transformIds = this.transformLayerIds(id);
332
+ this.editableLayers(transformIds);
333
+ const delta = {
334
+ x: snapped.point.x - previous.x,
335
+ y: snapped.point.y - previous.y,
336
+ };
337
+ this.commit(() => {
338
+ this.moveLayersBy(transformIds, delta);
339
+ this.snapGuides = snapped.guides;
340
+ });
341
+ return snapped.point;
342
+ }
343
+ nudgeLayer(id, delta) {
344
+ const layer = this.requireEditableLayer(id);
345
+ this.moveLayer(id, {
346
+ x: coordinate(layer.position?.X) + delta.x,
347
+ y: coordinate(layer.position?.Y) + delta.y,
348
+ });
349
+ }
350
+ nudgeSelection(delta) {
351
+ const layers = this.editableLayers(this.selectedLayerIds);
352
+ if (layers.length === 0)
353
+ return;
354
+ this.commit(() => this.moveLayersBy(layers.map((layer) => layer.id), delta));
355
+ }
356
+ scaleLayer(id, scale) {
357
+ if (!Number.isFinite(scale))
358
+ throw new Error('scale must be finite.');
359
+ this.updateLayer(id, (current) => {
360
+ if (current.type === 'image') {
361
+ return { ...current, scale: clampScale(scale) };
362
+ }
363
+ return {
364
+ ...current,
365
+ style: {
366
+ ...current.style,
367
+ fontSize: clampScale(14 * scale),
368
+ fontSizeRatio: undefined,
369
+ },
370
+ };
371
+ });
372
+ }
373
+ rotateLayer(id, degrees) {
374
+ if (!Number.isFinite(degrees)) {
375
+ throw new Error('rotation must be finite.');
376
+ }
377
+ this.updateLayer(id, (layer) => layer.type === 'image'
378
+ ? { ...layer, rotate: normalizeDegrees(degrees) }
379
+ : {
380
+ ...layer,
381
+ style: {
382
+ ...layer.style,
383
+ rotate: normalizeDegrees(degrees),
384
+ },
385
+ });
386
+ }
387
+ reorderLayer(id, targetIndex) {
388
+ const currentIndex = this.layerIndex(id);
389
+ if (currentIndex < 0)
390
+ throw new Error(`Unknown layer id "${id}".`);
391
+ this.requireEditableLayer(id);
392
+ const bounded = Math.max(0, Math.min(Math.trunc(targetIndex), this.recipe.layers.length - 1));
393
+ if (bounded === currentIndex)
394
+ return;
395
+ this.commit(() => {
396
+ const [layer] = this.recipe.layers.splice(currentIndex, 1);
397
+ if (layer)
398
+ this.recipe.layers.splice(bounded, 0, layer);
399
+ });
400
+ }
401
+ groupLayers(ids = this.selectedLayerIds, requestedGroupId) {
402
+ const layers = this.editableLayers(unique(ids));
403
+ if (layers.length < 2) {
404
+ throw new Error('Grouping requires at least two editable layers.');
405
+ }
406
+ const groupId = this.uniqueGroupId(requestedGroupId ?? 'group');
407
+ const selected = new Set(layers.map((layer) => layer.id));
408
+ this.commit(() => {
409
+ this.recipe.layers = this.recipe.layers.map((layer) => selected.has(layer.id) ? { ...layer, groupId } : layer);
410
+ this.selectedLayerIds = layers.map((layer) => layer.id);
411
+ });
412
+ return groupId;
413
+ }
414
+ ungroupLayers(ids = this.selectedLayerIds) {
415
+ const layers = this.editableLayers(ids);
416
+ const groupIds = unique(layers
417
+ .map((layer) => layer.groupId)
418
+ .filter((value) => Boolean(value)));
419
+ if (groupIds.length === 0)
420
+ return [];
421
+ const selectedGroups = new Set(groupIds);
422
+ this.editableLayers(this.recipe.layers
423
+ .filter((layer) => layer.groupId && selectedGroups.has(layer.groupId))
424
+ .map((layer) => layer.id));
425
+ this.commit(() => {
426
+ this.recipe.layers = this.recipe.layers.map((layer) => {
427
+ if (!layer.groupId || !selectedGroups.has(layer.groupId))
428
+ return layer;
429
+ const next = { ...layer };
430
+ delete next.groupId;
431
+ return next;
432
+ });
433
+ });
434
+ return groupIds;
435
+ }
436
+ alignLayers(alignment, bounds, canvas) {
437
+ const layers = this.editableLayers(this.selectedLayerIds);
438
+ const byId = new Map(bounds.map((bound) => [bound.id, bound]));
439
+ const selectedBounds = layers.map((layer) => {
440
+ const bound = byId.get(layer.id);
441
+ if (!bound)
442
+ throw new Error(`Missing bounds for layer "${layer.id}".`);
443
+ return bound;
444
+ });
445
+ if (selectedBounds.length === 0)
446
+ return;
447
+ const minX = canvas ? 0 : Math.min(...selectedBounds.map((item) => item.x));
448
+ const minY = canvas ? 0 : Math.min(...selectedBounds.map((item) => item.y));
449
+ const maxX = canvas
450
+ ? canvas.width
451
+ : Math.max(...selectedBounds.map((item) => item.x + item.width));
452
+ const maxY = canvas
453
+ ? canvas.height
454
+ : Math.max(...selectedBounds.map((item) => item.y + item.height));
455
+ this.commit(() => {
456
+ for (const bound of selectedBounds) {
457
+ const layer = this.recipe.layers[this.layerIndex(bound.id)];
458
+ if (!layer)
459
+ continue;
460
+ let x = coordinate(layer.position?.X);
461
+ let y = coordinate(layer.position?.Y);
462
+ if (alignment === 'left')
463
+ x = minX;
464
+ else if (alignment === 'center') {
465
+ x = (minX + maxX - bound.width) / 2;
466
+ }
467
+ else if (alignment === 'right')
468
+ x = maxX - bound.width;
469
+ else if (alignment === 'top')
470
+ y = minY;
471
+ else if (alignment === 'middle') {
472
+ y = (minY + maxY - bound.height) / 2;
473
+ }
474
+ else
475
+ y = maxY - bound.height;
476
+ layer.position = { ...layer.position, X: x, Y: y };
477
+ }
478
+ });
479
+ }
480
+ distributeLayers(direction, bounds) {
481
+ const layers = this.editableLayers(this.selectedLayerIds);
482
+ if (layers.length < 3) {
483
+ throw new Error('Distribution requires at least three editable layers.');
484
+ }
485
+ const selected = new Set(layers.map((layer) => layer.id));
486
+ const items = bounds
487
+ .filter((bound) => selected.has(bound.id))
488
+ .sort((left, right) => direction === 'horizontal' ? left.x - right.x : left.y - right.y);
489
+ if (items.length !== layers.length) {
490
+ throw new Error('Bounds are required for every selected layer.');
491
+ }
492
+ const first = items[0];
493
+ const last = items.at(-1);
494
+ const start = direction === 'horizontal' ? first.x : first.y;
495
+ const end = direction === 'horizontal' ? last.x + last.width : last.y + last.height;
496
+ const occupied = items.reduce((total, item) => total + (direction === 'horizontal' ? item.width : item.height), 0);
497
+ const gap = (end - start - occupied) / (items.length - 1);
498
+ this.commit(() => {
499
+ let cursor = start;
500
+ for (const item of items) {
501
+ const layer = this.recipe.layers[this.layerIndex(item.id)];
502
+ if (!layer)
503
+ continue;
504
+ layer.position = {
505
+ ...layer.position,
506
+ X: direction === 'horizontal' ? cursor : coordinate(layer.position?.X),
507
+ Y: direction === 'vertical' ? cursor : coordinate(layer.position?.Y),
508
+ };
509
+ cursor += (direction === 'horizontal' ? item.width : item.height) + gap;
510
+ }
511
+ });
512
+ }
513
+ copyLayers(ids = this.selectedLayerIds) {
514
+ const selected = new Set(ids);
515
+ const clipboard = {
516
+ kind: 'image-marker/editor-layers',
517
+ version: 1,
518
+ layers: this.recipe.layers
519
+ .filter((layer) => selected.has(layer.id))
520
+ .map(cloneLayer),
521
+ };
522
+ if (clipboard.layers.length === 0) {
523
+ throw new Error('Copy requires at least one existing layer.');
524
+ }
525
+ this.clipboard = cloneSource(clipboard);
526
+ this.emit();
527
+ return JSON.stringify(clipboard);
528
+ }
529
+ canPaste() {
530
+ return Boolean(this.clipboard?.layers.length);
531
+ }
532
+ pasteLayers(serialized, offset = { x: 20, y: 20 }) {
533
+ const parsed = typeof serialized === 'string'
534
+ ? JSON.parse(serialized)
535
+ : serialized ?? this.clipboard;
536
+ if (!isClipboard(parsed)) {
537
+ throw new Error('Unsupported editor clipboard payload.');
538
+ }
539
+ const created = [];
540
+ const groupIds = new Map();
541
+ this.commit(() => {
542
+ for (const source of parsed.layers) {
543
+ const copy = cloneLayer(source);
544
+ copy.id = this.uniqueLayerId(`${source.id}-copy`);
545
+ if (copy.groupId) {
546
+ const mapped = groupIds.get(copy.groupId) ??
547
+ this.uniqueGroupId(`${copy.groupId}-copy`);
548
+ groupIds.set(copy.groupId, mapped);
549
+ copy.groupId = mapped;
550
+ }
551
+ copy.position = {
552
+ ...copy.position,
553
+ X: coordinate(copy.position?.X) + offset.x,
554
+ Y: coordinate(copy.position?.Y) + offset.y,
555
+ };
556
+ this.recipe.layers.push(copy);
557
+ created.push(copy.id);
558
+ }
559
+ this.selectedLayerIds = created;
560
+ });
561
+ return created;
562
+ }
563
+ setSafeArea(safeArea) {
564
+ this.commit(() => {
565
+ this.safeArea = normalizeSafeArea(safeArea);
566
+ });
567
+ }
568
+ setExportOptions(options) {
569
+ this.commit(() => {
570
+ this.exportOptions = cloneExportOptions(options);
571
+ });
572
+ }
573
+ setViewportZoom(zoom, anchor) {
574
+ if (!Number.isFinite(zoom))
575
+ throw new Error('zoom must be finite.');
576
+ const nextZoom = clamp(zoom, 0.1, 16);
577
+ const previousZoom = this.viewport.zoom;
578
+ const nextPan = anchor
579
+ ? {
580
+ x: anchor.x -
581
+ ((anchor.x - this.viewport.pan.x) * nextZoom) / previousZoom,
582
+ y: anchor.y -
583
+ ((anchor.y - this.viewport.pan.y) * nextZoom) / previousZoom,
584
+ }
585
+ : this.viewport.pan;
586
+ this.viewport = {
587
+ zoom: nextZoom,
588
+ pan: nextPan,
589
+ fitMode: nextZoom === 1 ? 'contain' : 'manual',
590
+ };
591
+ this.emit(true);
592
+ }
593
+ zoomViewportBy(factor, anchor) {
594
+ this.setViewportZoom(this.viewport.zoom * factor, anchor);
595
+ }
596
+ panViewport(delta) {
597
+ this.viewport = {
598
+ ...this.viewport,
599
+ pan: {
600
+ x: this.viewport.pan.x + delta.x,
601
+ y: this.viewport.pan.y + delta.y,
602
+ },
603
+ fitMode: 'manual',
604
+ };
605
+ this.emit(true);
606
+ }
607
+ fitViewport() {
608
+ this.viewport = {
609
+ zoom: 1,
610
+ pan: { x: 0, y: 0 },
611
+ fitMode: 'contain',
612
+ };
613
+ this.emit(true);
614
+ }
615
+ handleKeyCommand(command) {
616
+ const modifier = command.metaKey || command.ctrlKey;
617
+ const key = command.key.toLowerCase();
618
+ if (modifier && key === 'z') {
619
+ if (command.shiftKey)
620
+ this.redo();
621
+ else
622
+ this.undo();
623
+ return true;
624
+ }
625
+ if (modifier && key === 'y') {
626
+ this.redo();
627
+ return true;
628
+ }
629
+ if (modifier && key === 'a') {
630
+ this.selectAll();
631
+ return true;
632
+ }
633
+ if (modifier && key === 'c') {
634
+ this.copyLayers();
635
+ return true;
636
+ }
637
+ if (modifier && key === 'x') {
638
+ this.copyLayers();
639
+ this.removeLayers();
640
+ return true;
641
+ }
642
+ if (modifier && key === 'v') {
643
+ this.pasteLayers();
644
+ return true;
645
+ }
646
+ if (modifier && key === 'd') {
647
+ this.duplicateLayers();
648
+ return true;
649
+ }
650
+ if (modifier && key === 'g') {
651
+ if (command.shiftKey)
652
+ this.ungroupLayers();
653
+ else
654
+ this.groupLayers();
655
+ return true;
656
+ }
657
+ if (modifier && key === '0') {
658
+ this.fitViewport();
659
+ return true;
660
+ }
661
+ if (modifier && (key === '+' || key === '=')) {
662
+ this.zoomViewportBy(1.2);
663
+ return true;
664
+ }
665
+ if (modifier && key === '-') {
666
+ this.zoomViewportBy(1 / 1.2);
667
+ return true;
668
+ }
669
+ const id = this.selectedLayerIds.at(-1);
670
+ if (!id)
671
+ return false;
672
+ if (key === 'delete' || key === 'backspace') {
673
+ return this.removeLayers() > 0;
674
+ }
675
+ const amount = command.shiftKey ? 10 : 1;
676
+ if (key === 'arrowleft') {
677
+ this.nudgeSelection({ x: -amount, y: 0 });
678
+ }
679
+ else if (key === 'arrowright') {
680
+ this.nudgeSelection({ x: amount, y: 0 });
681
+ }
682
+ else if (key === 'arrowup') {
683
+ this.nudgeSelection({ x: 0, y: -amount });
684
+ }
685
+ else if (key === 'arrowdown') {
686
+ this.nudgeSelection({ x: 0, y: amount });
687
+ }
688
+ else if (key === '[') {
689
+ this.reorderLayer(id, this.layerIndex(id) - 1);
690
+ }
691
+ else if (key === ']') {
692
+ this.reorderLayer(id, this.layerIndex(id) + 1);
693
+ }
694
+ else {
695
+ return false;
696
+ }
697
+ return true;
698
+ }
699
+ undo() {
700
+ const previous = this.past.pop();
701
+ if (!previous)
702
+ return false;
703
+ this.future.push(this.snapshot());
704
+ this.restore(previous);
705
+ this.emit(true);
706
+ return true;
707
+ }
708
+ redo() {
709
+ const next = this.future.pop();
710
+ if (!next)
711
+ return false;
712
+ this.past.push(this.snapshot());
713
+ this.restore(next);
714
+ this.emit(true);
715
+ return true;
716
+ }
717
+ serializeState() {
718
+ const snapshot = {
719
+ version: 1,
720
+ recipe: cloneRecipe(this.recipe),
721
+ selectedLayerIds: [...this.selectedLayerIds],
722
+ safeArea: { ...this.safeArea },
723
+ // Do not persist invisible-watermark keys or signing adapters.
724
+ exportOptions: {},
725
+ viewport: cloneViewport(this.viewport),
726
+ };
727
+ return JSON.stringify(snapshot);
728
+ }
729
+ restoreSerializedState(serialized) {
730
+ const parsed = JSON.parse(serialized);
731
+ if (parsed.version !== 1 || !parsed.recipe) {
732
+ throw new Error('Unsupported editor autosave document.');
733
+ }
734
+ const recipe = migrateWatermarkRecipe(parsed.recipe);
735
+ const existing = new Set(recipe.layers.map((layer) => layer.id));
736
+ this.recipe = recipe;
737
+ this.selectedLayerIds = (parsed.selectedLayerIds ?? []).filter((id) => existing.has(id));
738
+ this.safeArea = normalizeSafeArea(parsed.safeArea);
739
+ this.exportOptions = cloneExportOptions(parsed.exportOptions ?? {});
740
+ this.viewport = parsed.viewport
741
+ ? cloneViewport(parsed.viewport)
742
+ : { zoom: 1, pan: { x: 0, y: 0 }, fitMode: 'contain' };
743
+ this.past.length = 0;
744
+ this.future.length = 0;
745
+ this.emit();
746
+ }
747
+ async restoreAutosave() {
748
+ if (!this.autosave)
749
+ return false;
750
+ const serialized = await this.autosave.storage.load(this.autosave.key);
751
+ if (!serialized)
752
+ return false;
753
+ this.restoreSerializedState(serialized);
754
+ return true;
755
+ }
756
+ async flushAutosave() {
757
+ if (!this.autosave)
758
+ return false;
759
+ if (this.autosaveTimer)
760
+ clearTimeout(this.autosaveTimer);
761
+ this.autosaveTimer = undefined;
762
+ await this.autosave.storage.save(this.autosave.key, this.serializeState());
763
+ return true;
764
+ }
765
+ async clearAutosave() {
766
+ if (!this.autosave?.storage.remove)
767
+ return false;
768
+ if (this.autosaveTimer)
769
+ clearTimeout(this.autosaveTimer);
770
+ this.autosaveTimer = undefined;
771
+ await this.autosave.storage.remove(this.autosave.key);
772
+ return true;
773
+ }
774
+ updateLayer(id, updater) {
775
+ const index = this.layerIndex(id);
776
+ const layer = this.requireEditableLayer(id);
777
+ this.commit(() => {
778
+ const updated = updater(cloneLayer(layer));
779
+ if (updated.id !== id) {
780
+ throw new Error('Layer IDs cannot be changed through update operations.');
781
+ }
782
+ this.recipe.layers[index] = updated;
783
+ });
784
+ }
785
+ requireEditableLayer(id) {
786
+ const layer = this.recipe.layers[this.layerIndex(id)];
787
+ if (!layer)
788
+ throw new Error(`Unknown layer id "${id}".`);
789
+ if (layer.locked)
790
+ throw new Error(`Layer "${id}" is locked.`);
791
+ return layer;
792
+ }
793
+ editableLayers(ids) {
794
+ return unique(ids).map((id) => this.requireEditableLayer(id));
795
+ }
796
+ layersByIds(ids) {
797
+ return unique(ids).map((id) => {
798
+ const layer = this.recipe.layers[this.layerIndex(id)];
799
+ if (!layer)
800
+ throw new Error(`Unknown layer id "${id}".`);
801
+ return layer;
802
+ });
803
+ }
804
+ transformLayerIds(id) {
805
+ if (this.selectedLayerIds.length > 1 &&
806
+ this.selectedLayerIds.includes(id)) {
807
+ return [...this.selectedLayerIds];
808
+ }
809
+ const layer = this.recipe.layers[this.layerIndex(id)];
810
+ if (layer?.groupId) {
811
+ return this.recipe.layers
812
+ .filter((item) => item.groupId === layer.groupId)
813
+ .map((item) => item.id);
814
+ }
815
+ return [id];
816
+ }
817
+ moveLayersBy(ids, delta) {
818
+ const selected = new Set(ids);
819
+ for (const layer of this.recipe.layers) {
820
+ if (!selected.has(layer.id))
821
+ continue;
822
+ layer.position = {
823
+ ...layer.position,
824
+ X: coordinate(layer.position?.X) + delta.x,
825
+ Y: coordinate(layer.position?.Y) + delta.y,
826
+ };
827
+ }
828
+ }
829
+ layerIndex(id) {
830
+ return this.recipe.layers.findIndex((layer) => layer.id === id);
831
+ }
832
+ nextLayerId() {
833
+ let id;
834
+ do {
835
+ this.idSequence += 1;
836
+ id = `layer-editor-${this.idSequence}`;
837
+ } while (this.recipe.layers.some((layer) => layer.id === id));
838
+ return id;
839
+ }
840
+ uniqueLayerId(requested) {
841
+ let id = requested;
842
+ let sequence = 2;
843
+ while (this.recipe.layers.some((layer) => layer.id === id)) {
844
+ id = `${requested}-${sequence}`;
845
+ sequence += 1;
846
+ }
847
+ return id;
848
+ }
849
+ uniqueGroupId(requested) {
850
+ const existing = new Set(this.recipe.layers
851
+ .map((layer) => layer.groupId)
852
+ .filter((value) => Boolean(value)));
853
+ let id = requested;
854
+ while (existing.has(id)) {
855
+ this.groupSequence += 1;
856
+ id = `${requested}-${this.groupSequence + 1}`;
857
+ }
858
+ return id;
859
+ }
860
+ commit(mutator) {
861
+ const shouldCapture = !this.historyGroupActive || !this.historyGroupCaptured;
862
+ if (shouldCapture) {
863
+ this.past.push(this.snapshot());
864
+ if (this.past.length > this.historyLimit)
865
+ this.past.shift();
866
+ this.future.length = 0;
867
+ if (this.historyGroupActive)
868
+ this.historyGroupCaptured = true;
869
+ }
870
+ mutator();
871
+ this.emit(true);
872
+ }
873
+ snapshot() {
874
+ return {
875
+ recipe: cloneRecipe(this.recipe),
876
+ selectedLayerIds: [...this.selectedLayerIds],
877
+ safeArea: { ...this.safeArea },
878
+ exportOptions: cloneExportOptions(this.exportOptions),
879
+ viewport: cloneViewport(this.viewport),
880
+ };
881
+ }
882
+ restore(snapshot) {
883
+ this.recipe = cloneRecipe(snapshot.recipe);
884
+ this.selectedLayerIds = [...snapshot.selectedLayerIds];
885
+ this.safeArea = { ...snapshot.safeArea };
886
+ this.exportOptions = cloneExportOptions(snapshot.exportOptions);
887
+ this.viewport = cloneViewport(snapshot.viewport);
888
+ this.snapGuides = [];
889
+ }
890
+ scheduleAutosave() {
891
+ if (!this.autosave)
892
+ return;
893
+ if (this.autosaveTimer)
894
+ clearTimeout(this.autosaveTimer);
895
+ this.autosaveTimer = setTimeout(() => {
896
+ this.autosaveTimer = undefined;
897
+ try {
898
+ Promise.resolve(this.autosave?.storage.save(this.autosave.key, this.serializeState())).catch((reason) => this.reportAutosaveError(reason));
899
+ }
900
+ catch (reason) {
901
+ this.reportAutosaveError(reason);
902
+ }
903
+ }, Math.max(0, this.autosave.debounceMs ?? 350));
904
+ }
905
+ reportAutosaveError(reason) {
906
+ this.autosave?.onError?.(reason instanceof Error ? reason : new Error(String(reason)));
907
+ }
908
+ emit(persist = false) {
909
+ const state = this.getState();
910
+ this.listeners.forEach((listener) => listener(state));
911
+ this.onChange?.(state);
912
+ if (persist)
913
+ this.scheduleAutosave();
914
+ }
915
+ }