kritzel-stencil 0.3.21 → 0.3.23

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 (104) hide show
  1. package/dist/cjs/index.cjs.js +3 -1
  2. package/dist/cjs/kritzel-active-users_45.cjs.entry.js +507 -288
  3. package/dist/cjs/{schema.constants-IBG5VW_J.js → schema.constants-Lr_zNkox.js} +713 -47
  4. package/dist/collection/classes/core/core.class.js +83 -5
  5. package/dist/collection/classes/core/store.class.js +9 -0
  6. package/dist/collection/classes/handlers/key.handler.js +1 -1
  7. package/dist/collection/classes/handlers/selection.handler.js +13 -9
  8. package/dist/collection/classes/objects/base-object.class.js +20 -8
  9. package/dist/collection/classes/objects/custom-element.class.js +330 -8
  10. package/dist/collection/classes/objects/image.class.js +0 -1
  11. package/dist/collection/classes/objects/selection-group.class.js +12 -0
  12. package/dist/collection/classes/providers/assets/asset-resolver.class.js +24 -14
  13. package/dist/collection/classes/providers/assets/indexeddb-asset-provider.class.js +0 -8
  14. package/dist/collection/classes/registries/custom-element-renderer.registry.js +52 -0
  15. package/dist/collection/classes/structures/app-state-map.structure.js +17 -3
  16. package/dist/collection/classes/structures/object-map.structure.js +17 -3
  17. package/dist/collection/classes/tools/selection-tool.class.js +34 -6
  18. package/dist/collection/components/core/kritzel-editor/kritzel-editor.js +9 -8
  19. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +211 -78
  20. package/dist/collection/components/ui/kritzel-settings/kritzel-settings.js +2 -1
  21. package/dist/collection/configs/default-engine-config.js +3 -2
  22. package/dist/collection/constants/version.js +1 -1
  23. package/dist/collection/helpers/html.helper.js +52 -0
  24. package/dist/collection/helpers/object.helper.js +3 -0
  25. package/dist/collection/helpers/svg-export.helper.js +35 -0
  26. package/dist/collection/helpers/wheel.helper.js +113 -0
  27. package/dist/collection/index.js +3 -0
  28. package/dist/collection/interfaces/custom-element-renderer.interface.js +1 -0
  29. package/dist/components/index.js +1 -1
  30. package/dist/components/kritzel-awareness-cursors.js +1 -1
  31. package/dist/components/kritzel-controls.js +1 -1
  32. package/dist/components/kritzel-current-user-dialog.js +1 -1
  33. package/dist/components/kritzel-current-user.js +1 -1
  34. package/dist/components/kritzel-dialog.js +1 -1
  35. package/dist/components/kritzel-editor.js +1 -1
  36. package/dist/components/kritzel-engine.js +1 -1
  37. package/dist/components/kritzel-export.js +1 -1
  38. package/dist/components/kritzel-login-dialog.js +1 -1
  39. package/dist/components/kritzel-menu-item.js +1 -1
  40. package/dist/components/kritzel-menu.js +1 -1
  41. package/dist/components/kritzel-more-menu.js +1 -1
  42. package/dist/components/kritzel-portal.js +1 -1
  43. package/dist/components/kritzel-settings.js +1 -1
  44. package/dist/components/kritzel-share-dialog.js +1 -1
  45. package/dist/components/kritzel-split-button.js +1 -1
  46. package/dist/components/kritzel-tool-config.js +1 -1
  47. package/dist/components/kritzel-tooltip.js +1 -1
  48. package/dist/components/kritzel-workspace-manager.js +1 -1
  49. package/dist/components/{p-BwuemYET.js → p-B4WfPDj0.js} +1 -1
  50. package/dist/components/p-BNu3Akp5.js +1 -0
  51. package/dist/components/p-BYveczcb.js +1 -0
  52. package/dist/components/{p-D28ZylHn.js → p-BixEMVtW.js} +1 -1
  53. package/dist/components/{p-Ndib1Jb3.js → p-BntMEYss.js} +1 -1
  54. package/dist/components/p-C48bv_0f.js +9 -0
  55. package/dist/components/{p-BO1CnEOT.js → p-CPMpf9ku.js} +1 -1
  56. package/dist/components/{p-BhODnvMO.js → p-CZQz_mul.js} +1 -1
  57. package/dist/components/{p-COj8EVzL.js → p-Ckpa5tHF.js} +1 -1
  58. package/dist/components/{p-BPoDE87Q.js → p-DJ3KI725.js} +1 -1
  59. package/dist/components/{p-DRsD38Nl.js → p-DPlEzPKO.js} +1 -1
  60. package/dist/components/{p-C-RiEw9X.js → p-DSd5xTAH.js} +1 -1
  61. package/dist/components/{p-CbMLbDou.js → p-DTqMF94b.js} +1 -1
  62. package/dist/components/{p-CHrSFOSI.js → p-DX2nriaq.js} +1 -1
  63. package/dist/components/{p-BAN5dnHX.js → p-Dd-pMvlr.js} +1 -1
  64. package/dist/components/{p-C5TZjIE1.js → p-M9m9-OEY.js} +1 -1
  65. package/dist/components/p-WUdQqYnu.js +1 -0
  66. package/dist/components/{p-YFR180v-.js → p-XDNjFYRB.js} +1 -1
  67. package/dist/components/{p-CJEIUs0x.js → p-X_pyPCe8.js} +1 -1
  68. package/dist/components/{p-CpR9lc-o.js → p-meY80XnF.js} +1 -1
  69. package/dist/components/{p-6Ca7Eolf.js → p-y5T0_Wvi.js} +1 -1
  70. package/dist/esm/index.js +2 -2
  71. package/dist/esm/kritzel-active-users_45.entry.js +500 -281
  72. package/dist/esm/{schema.constants-D35cwCYd.js → schema.constants-DMPBDrZh.js} +711 -47
  73. package/dist/stencil/index.esm.js +1 -1
  74. package/dist/stencil/p-27d41f6f.entry.js +9 -0
  75. package/dist/stencil/p-DMPBDrZh.js +1 -0
  76. package/dist/stencil/stencil.esm.js +1 -1
  77. package/dist/types/classes/core/core.class.d.ts +15 -0
  78. package/dist/types/classes/core/store.class.d.ts +6 -0
  79. package/dist/types/classes/objects/base-object.class.d.ts +3 -2
  80. package/dist/types/classes/objects/custom-element.class.d.ts +87 -21
  81. package/dist/types/classes/objects/image.class.d.ts +0 -1
  82. package/dist/types/classes/objects/selection-group.class.d.ts +1 -0
  83. package/dist/types/classes/providers/assets/asset-resolver.class.d.ts +3 -0
  84. package/dist/types/classes/providers/assets/indexeddb-asset-provider.class.d.ts +0 -1
  85. package/dist/types/classes/registries/custom-element-renderer.registry.d.ts +32 -0
  86. package/dist/types/components/core/kritzel-engine/kritzel-engine.d.ts +7 -1
  87. package/dist/types/components.d.ts +2 -2
  88. package/dist/types/constants/version.d.ts +1 -1
  89. package/dist/types/helpers/html.helper.d.ts +2 -0
  90. package/dist/types/helpers/object.helper.d.ts +1 -0
  91. package/dist/types/helpers/svg-export.helper.d.ts +9 -0
  92. package/dist/types/helpers/wheel.helper.d.ts +9 -0
  93. package/dist/types/index.d.ts +3 -0
  94. package/dist/types/interfaces/asset-storage-config.interface.d.ts +0 -4
  95. package/dist/types/interfaces/custom-element-renderer.interface.d.ts +40 -0
  96. package/dist/types/interfaces/debug-info.interface.d.ts +1 -0
  97. package/dist/types/interfaces/object.interface.d.ts +0 -1
  98. package/package.json +2 -2
  99. package/dist/components/p-BML28BJR.js +0 -1
  100. package/dist/components/p-BYzaazty.js +0 -1
  101. package/dist/components/p-CVV-HzFR.js +0 -1
  102. package/dist/components/p-DWhWFDcC.js +0 -9
  103. package/dist/stencil/p-60842099.entry.js +0 -9
  104. package/dist/stencil/p-D35cwCYd.js +0 -1
@@ -1,5 +1,7 @@
1
1
  import { KritzelBaseObject } from "./base-object.class";
2
2
  import { KritzelMathHelper } from "../../helpers/math.helper";
3
+ import { KritzelCustomElementRendererRegistry } from "../registries/custom-element-renderer.registry";
4
+ import { KritzelHTMLHelper } from "../../helpers/html.helper";
3
5
  /**
4
6
  * Represents a custom HTML element that can be placed and manipulated on the Kritzel canvas.
5
7
  * Extends KritzelBaseObject to inherit common object functionality like positioning,
@@ -9,8 +11,17 @@ export class KritzelCustomElement extends KritzelBaseObject {
9
11
  __class__ = 'KritzelCustomElement';
10
12
  /** The HTML element to be rendered on the canvas. */
11
13
  element;
12
- /** Whether the element responds to user interactions. Defaults to true. */
13
- isInteractive = true;
14
+ /**
15
+ * Optional identifier for a registered renderer (e.g., 'angular-component', 'react-default').
16
+ * When set, mount() resolves the renderer from a global registry and delegates lifecycle.
17
+ * If null, falls back to DOM element mounting.
18
+ */
19
+ rendererKey = null;
20
+ /**
21
+ * Custom data payload for the renderer (e.g., Angular component inputs, React props, Vue data).
22
+ * Only meaningful when rendererKey is set. Captured on unmount and restored on rehydrate.
23
+ */
24
+ rendererData;
14
25
  /**
15
26
  * Creates a new KritzelCustomElement instance.
16
27
  * @param config - Optional configuration object for the custom element.
@@ -28,9 +39,11 @@ export class KritzelCustomElement extends KritzelBaseObject {
28
39
  this.translateY = config.translateY || 0;
29
40
  this.rotation = KritzelMathHelper.degreesToRadians(config.rotation ?? 0);
30
41
  this.scale = config.scale || 1;
31
- this.element = config.element;
42
+ this.element = config.element ?? this.createPlaceholder();
32
43
  this.height = config.height || 0;
33
44
  this.width = config.width || 0;
45
+ this.rendererKey = config.rendererKey ?? null;
46
+ this.rendererData = config.rendererData;
34
47
  }
35
48
  }
36
49
  /**
@@ -52,11 +65,13 @@ export class KritzelCustomElement extends KritzelBaseObject {
52
65
  object.id = object.generateId();
53
66
  object.workspaceId = core.store.state.activeWorkspace.id;
54
67
  object.userId = core.user?.id;
68
+ object.zIndex = core.store.currentZIndex;
55
69
  return object;
56
70
  }
57
71
  /**
58
72
  * Mounts the custom element to a container element in the DOM.
59
- * Clears any existing content in the container and appends this element.
73
+ * If a renderer is registered for this.rendererKey, delegates to the renderer.
74
+ * Otherwise, uses the standard DOM element mounting fallback.
60
75
  * Skips mounting if already mounted to the same container with content attached.
61
76
  * @param element - The container HTML element to mount this custom element into.
62
77
  */
@@ -64,15 +79,268 @@ export class KritzelCustomElement extends KritzelBaseObject {
64
79
  if (element === null) {
65
80
  return;
66
81
  }
67
- // If already mounted to the same element and content is still attached, skip
68
- if (this.isMounted && this.elementRef === element && this.element.parentElement === element) {
69
- return;
82
+ this.applyInteractiveState();
83
+ if (this.isMounted && this.elementRef === element) {
84
+ // Renderer-based mounts should not be re-invoked on every engine rerender.
85
+ if (this.rendererKey) {
86
+ return;
87
+ }
88
+ // For direct DOM fallback, remount only if content was detached.
89
+ if (this.element.parentElement === element) {
90
+ return;
91
+ }
70
92
  }
71
93
  this.elementRef = element;
72
94
  this.isMounted = true;
73
- // Clear existing content and append the element
95
+ // Try renderer-based approach if a key is set
96
+ if (this.rendererKey) {
97
+ const renderer = KritzelCustomElementRendererRegistry.get(this.rendererKey);
98
+ if (renderer) {
99
+ element.innerHTML = ''; // Clear container
100
+ renderer.onMount({
101
+ object: this,
102
+ container: element,
103
+ data: this.rendererData,
104
+ });
105
+ this.applyInteractiveState();
106
+ return;
107
+ }
108
+ }
109
+ // Fallback to standard DOM mounting
74
110
  this.elementRef.innerHTML = '';
75
111
  this.elementRef.appendChild(this.element);
112
+ this.applyInteractiveState();
113
+ }
114
+ /**
115
+ * Updates interactivity state for embedded HTML content.
116
+ * When disabling interactivity, any focused descendant is blurred so
117
+ * keyboard input is released immediately.
118
+ */
119
+ setIsInteractive(isInteractive) {
120
+ const hasChanged = this.isInteractive !== isInteractive;
121
+ this.isInteractive = isInteractive;
122
+ this.applyInteractiveState();
123
+ if (hasChanged && !isInteractive) {
124
+ this.blurFocusedDescendant();
125
+ }
126
+ }
127
+ applyInteractiveState() {
128
+ const pointerEvents = this.isInteractive ? 'auto' : 'none';
129
+ const contentCursor = this.isInteractive ? 'auto' : '';
130
+ if (this.element) {
131
+ this.element.style.pointerEvents = pointerEvents;
132
+ this.element.style.cursor = contentCursor;
133
+ }
134
+ if (this.elementRef instanceof HTMLElement) {
135
+ // Keep the mount container clickable so unselected custom elements can still be selected.
136
+ this.elementRef.style.pointerEvents = 'auto';
137
+ this.elementRef.style.cursor = '';
138
+ Array.from(this.elementRef.children).forEach(child => {
139
+ if (child instanceof HTMLElement) {
140
+ child.style.pointerEvents = pointerEvents;
141
+ child.style.cursor = contentCursor;
142
+ }
143
+ });
144
+ }
145
+ }
146
+ blurFocusedDescendant() {
147
+ if (!this.element && !(this.elementRef instanceof HTMLElement)) {
148
+ return;
149
+ }
150
+ const activeElement = this.getDeepActiveElement();
151
+ const isInsideElement = !!this.element && this.isNodeInsideHost(activeElement, this.element);
152
+ const isInsideMountedContainer = this.elementRef instanceof HTMLElement && this.isNodeInsideHost(activeElement, this.elementRef);
153
+ if (activeElement && (isInsideElement || isInsideMountedContainer)) {
154
+ activeElement.blur();
155
+ }
156
+ }
157
+ /**
158
+ * Returns true when a text-input-like element inside this custom element
159
+ * currently owns focus.
160
+ */
161
+ hasFocusedInputElement() {
162
+ if (!this.isInteractive) {
163
+ return false;
164
+ }
165
+ const focusHosts = this.getFocusHosts();
166
+ // Primary path: locate the focused input-like element by walking from each host through
167
+ // `:focus` (piercing open shadow roots). This works in real browsers even when the engine
168
+ // and editor are nested web components, because it never relies on the document-level
169
+ // active element resolving to a descendant of the host.
170
+ if (focusHosts.some(host => this.hasFocusedInputDescendant(host))) {
171
+ return true;
172
+ }
173
+ // Fallback path: resolve the deep active element and confirm it is input-like and inside a
174
+ // host. Covers contenteditable content inside nested shadow roots (which `querySelector`
175
+ // cannot reach) and test environments that mock the active element without real focus.
176
+ const activeElement = this.getDeepActiveElement();
177
+ if (activeElement && this.isInputLikeElement(activeElement) && focusHosts.some(host => this.isNodeInsideHost(activeElement, host))) {
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+ getFocusHosts() {
183
+ const hosts = [];
184
+ if (this.element) {
185
+ hosts.push(this.element);
186
+ }
187
+ if (this.elementRef instanceof HTMLElement) {
188
+ hosts.push(this.elementRef);
189
+ const firstChild = this.elementRef.firstElementChild;
190
+ if (firstChild instanceof HTMLElement) {
191
+ hosts.push(firstChild);
192
+ }
193
+ }
194
+ return hosts;
195
+ }
196
+ /**
197
+ * Returns true when the given host (or, via the flat tree, one of its shadow descendants)
198
+ * currently contains a focused input-like element.
199
+ */
200
+ hasFocusedInputDescendant(host) {
201
+ if (this.isInputLikeElement(host) && this.matchesFocus(host)) {
202
+ return true;
203
+ }
204
+ // `:focus-within` crosses shadow boundaries for the host, so it is a cheap guard that tells
205
+ // us whether focus lives anywhere inside this host before doing the deeper traversal.
206
+ if (typeof host.matches === 'function') {
207
+ try {
208
+ if (!host.matches(':focus-within')) {
209
+ return false;
210
+ }
211
+ }
212
+ catch {
213
+ // Selector unsupported in this environment; fall through to the traversal below.
214
+ }
215
+ }
216
+ return this.findFocusedInputLikeElement(host) !== null;
217
+ }
218
+ /**
219
+ * Walks the `:focus` chain from a tree scope, descending into open shadow roots, and returns
220
+ * the focused element when it is input-like.
221
+ */
222
+ findFocusedInputLikeElement(root) {
223
+ let focused = null;
224
+ try {
225
+ focused = root.querySelector(':focus');
226
+ }
227
+ catch {
228
+ focused = null;
229
+ }
230
+ if (!(focused instanceof HTMLElement)) {
231
+ return null;
232
+ }
233
+ if (this.isInputLikeElement(focused)) {
234
+ return focused;
235
+ }
236
+ if (focused.shadowRoot) {
237
+ return this.findFocusedInputLikeElement(focused.shadowRoot);
238
+ }
239
+ return null;
240
+ }
241
+ matchesFocus(element) {
242
+ if (typeof element.matches !== 'function') {
243
+ return false;
244
+ }
245
+ try {
246
+ return element.matches(':focus');
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ }
252
+ isInputLikeElement(element) {
253
+ const tag = element.tagName;
254
+ return (tag === 'INPUT' ||
255
+ tag === 'TEXTAREA' ||
256
+ tag === 'SELECT' ||
257
+ element.isContentEditable);
258
+ }
259
+ isNodeInsideHost(node, host) {
260
+ if (!node) {
261
+ return false;
262
+ }
263
+ let current = node;
264
+ while (current) {
265
+ if (current === host) {
266
+ return true;
267
+ }
268
+ if (current.parentNode) {
269
+ current = current.parentNode;
270
+ continue;
271
+ }
272
+ const root = current.getRootNode();
273
+ if (root instanceof ShadowRoot) {
274
+ current = root.host;
275
+ continue;
276
+ }
277
+ break;
278
+ }
279
+ return false;
280
+ }
281
+ getDeepActiveElement(root = document) {
282
+ const activeElement = root.activeElement;
283
+ if (!(activeElement instanceof HTMLElement)) {
284
+ return null;
285
+ }
286
+ if (activeElement.shadowRoot) {
287
+ const nested = this.getDeepActiveElement(activeElement.shadowRoot);
288
+ return nested ?? activeElement;
289
+ }
290
+ return activeElement;
291
+ }
292
+ /**
293
+ * Unmounts the custom element and performs cleanup.
294
+ * If a renderer is registered, delegates to renderer.onUnmount and captures returned state.
295
+ * Otherwise, simply removes from DOM.
296
+ * Called when object is deleted, viewport culled, or editor is unloading.
297
+ */
298
+ unmount() {
299
+ if (!this.isMounted || !this.elementRef) {
300
+ return;
301
+ }
302
+ // Try renderer-based unmount if renderer is registered
303
+ if (this.rendererKey) {
304
+ const renderer = KritzelCustomElementRendererRegistry.get(this.rendererKey);
305
+ if (renderer) {
306
+ // Capture any updated data returned by renderer
307
+ const updatedData = renderer.onUnmount({
308
+ object: this,
309
+ container: this.elementRef,
310
+ data: this.rendererData,
311
+ });
312
+ if (updatedData !== undefined) {
313
+ this.rendererData = updatedData;
314
+ }
315
+ }
316
+ }
317
+ this.isMounted = false;
318
+ this.elementRef = null;
319
+ }
320
+ /**
321
+ * Returns whether the rendered content contains a scrollable element.
322
+ * Traverses light DOM, open shadow roots, and slotted content so nested
323
+ * framework components are detected as well.
324
+ */
325
+ isElementScrollable() {
326
+ return !!KritzelHTMLHelper.findScrollableElement(this.element);
327
+ }
328
+ /**
329
+ * Creates a placeholder element when HTML deserialization fails.
330
+ * Used as fallback to ensure element is always defined.
331
+ * @returns A div element with placeholder styling
332
+ */
333
+ createPlaceholder() {
334
+ const el = document.createElement('div');
335
+ el.textContent = '[Custom Element]';
336
+ el.style.border = '1px dashed #ccc';
337
+ el.style.display = 'flex';
338
+ el.style.alignItems = 'center';
339
+ el.style.justifyContent = 'center';
340
+ el.style.backgroundColor = '#f9f9f9';
341
+ el.style.color = '#999';
342
+ el.style.fontSize = '12px';
343
+ return el;
76
344
  }
77
345
  /**
78
346
  * Resizes and repositions the custom element on the canvas.
@@ -109,7 +377,61 @@ export class KritzelCustomElement extends KritzelBaseObject {
109
377
  Object.assign(copiedObject, this);
110
378
  copiedObject.id = this.generateId();
111
379
  copiedObject.isMounted = false;
380
+ copiedObject.isSelected = false;
381
+ copiedObject.isHovered = false;
382
+ copiedObject.isInteractive = false;
383
+ copiedObject.resizing = false;
384
+ copiedObject.markedForRemoval = false;
385
+ copiedObject.isVisible = true;
112
386
  copiedObject.element = this.element.cloneNode(true);
113
387
  return copiedObject;
114
388
  }
389
+ /**
390
+ * Serializes the custom element to a plain JavaScript object for persistence.
391
+ * Stores HTML as a string (safe for JSON/Yjs) and captures dimension metadata.
392
+ * @returns A serializable object with htmlString and dimension snapshots
393
+ */
394
+ serialize() {
395
+ const { _core, _elementRef, element, totalWidth, totalHeight, isSelected, isHovered, isMounted, isInteractive, resizing, markedForRemoval, isVisible, ...props } = this;
396
+ const cloned = structuredClone(props);
397
+ // Include rendererKey and rendererData for lifecycle-driven rendering
398
+ cloned.rendererKey = this.rendererKey;
399
+ cloned.rendererData = this.rendererData;
400
+ // Store HTML as string for fallback/compatibility when renderer is unavailable
401
+ cloned.htmlString = element ? element.outerHTML : '';
402
+ // Capture dimension snapshot at serialization time
403
+ cloned.serializedWidth = this.width;
404
+ cloned.serializedHeight = this.height;
405
+ return cloned;
406
+ }
407
+ /**
408
+ * Deserializes the custom element from a plain object.
409
+ * Reconstructs the element from stored HTML string with fallback to placeholder.
410
+ * @template T - The expected return type
411
+ * @param object - The serialized object data to restore from
412
+ * @returns This instance cast to type T
413
+ */
414
+ deserialize(object) {
415
+ super.deserialize(object);
416
+ // Legacy compatibility: this deprecated field should not persist on revived objects.
417
+ delete this.allowEmbeddedInteraction;
418
+ // Restore renderer metadata
419
+ this.rendererKey = object.rendererKey ?? null;
420
+ this.rendererData = object.rendererData;
421
+ // Reconstruct element from stored HTML, or create placeholder
422
+ if (object.htmlString) {
423
+ try {
424
+ const temp = document.createElement('div');
425
+ temp.innerHTML = object.htmlString;
426
+ this.element = temp.firstElementChild || this.createPlaceholder();
427
+ }
428
+ catch {
429
+ this.element = this.createPlaceholder();
430
+ }
431
+ }
432
+ else {
433
+ this.element = this.createPlaceholder();
434
+ }
435
+ return this;
436
+ }
115
437
  }
@@ -39,7 +39,6 @@ export class KritzelImage extends KritzelBaseObject {
39
39
  maxWidth = 300;
40
40
  maxHeight = 300;
41
41
  maxCompressionSize = 1200;
42
- isDebugInfoVisible = true;
43
42
  // --- Transient (not persisted to Yjs) ---
44
43
  /**
45
44
  * URL resolved from the asset storage layer (e.g. a `blob:` or https URL).
@@ -82,6 +82,12 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
82
82
  this._cachedObjectIdsHash = currentHash;
83
83
  return this._cachedObjects;
84
84
  }
85
+ syncChildSelectionState(children) {
86
+ const shouldBeSelected = this.isSelected === true;
87
+ for (const child of children) {
88
+ child.isSelected = shouldBeSelected;
89
+ }
90
+ }
85
91
  /**
86
92
  * Gets the number of objects in the selection group.
87
93
  * @returns The count of selected objects
@@ -127,6 +133,7 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
127
133
  for (const object of objects) {
128
134
  if (!this._objectIds.includes(object.id)) {
129
135
  this._objectIds.push(object.id);
136
+ object.isSelected = true;
130
137
  changed = true;
131
138
  }
132
139
  }
@@ -154,9 +161,11 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
154
161
  const index = this._objectIds.findIndex(id => id === object.id);
155
162
  if (index === -1) {
156
163
  this._objectIds.push(object.id);
164
+ object.isSelected = true;
157
165
  }
158
166
  else {
159
167
  this._objectIds.splice(index, 1);
168
+ object.isSelected = false;
160
169
  }
161
170
  this._cachedObjects = null; // Invalidate cache
162
171
  const children = this.objects; // Get cached objects once
@@ -298,6 +307,8 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
298
307
  deserialize(object) {
299
308
  // First, deserialize all base properties using parent's deserialize
300
309
  super.deserialize(object);
310
+ // Selection groups intentionally persist their selected state.
311
+ this.isSelected = object.isSelected === true;
301
312
  // Restore objectIds through setter
302
313
  if (object.objectIds) {
303
314
  this.objectIds = object.objectIds;
@@ -306,6 +317,7 @@ export class KritzelSelectionGroup extends KritzelBaseObject {
306
317
  if (object.unchangedObjectSnapshots) {
307
318
  this.unchangedObjectSnapshots = new Map(Object.entries(object.unchangedObjectSnapshots));
308
319
  }
320
+ this.syncChildSelectionState(this.objects);
309
321
  return this;
310
322
  }
311
323
  /**
@@ -60,9 +60,13 @@ export class KritzelAssetResolver {
60
60
  _pending = new Map();
61
61
  _flushTimer = null;
62
62
  _destroyed = false;
63
- _quiet = false;
63
+ _quiet = true;
64
64
  /** Dedupes concurrent resolves of the same id. */
65
65
  _resolvePromises = new Map();
66
+ /** Enables or disables resolver diagnostic logging. */
67
+ setShowInfo(show) {
68
+ this._quiet = !show;
69
+ }
66
70
  /**
67
71
  * Whether any remote provider is configured. Used by the engine to
68
72
  * decide whether to warn about a missing remote provider when Yjs is
@@ -88,7 +92,6 @@ export class KritzelAssetResolver {
88
92
  // Ensure the deferred ready promise exists before any async work so
89
93
  // concurrent callers awaiting `ensureReady()` are unblocked together.
90
94
  this.getReadyPromise();
91
- this._quiet = config?.quiet ?? false;
92
95
  const providers = this.instantiateProviders(config?.providers ?? []);
93
96
  // Ensure a local provider always exists so uploads can be optimistic
94
97
  // and remote misses can be cached for later offline access.
@@ -121,8 +124,10 @@ export class KritzelAssetResolver {
121
124
  await provider.init();
122
125
  }
123
126
  catch (err) {
124
- console.warn(`[KritzelAssetResolver] Remote provider "${provider.name}" failed to initialize. ` +
125
- `Assets will be served from the local provider until the remote becomes available.`, err);
127
+ if (this.canLogInfo()) {
128
+ console.warn(`[Kritzel] Asset resolver remote provider "${provider.name}" failed to initialize. ` +
129
+ `Assets will be served from the local provider until the remote becomes available.`, err);
130
+ }
126
131
  }
127
132
  }
128
133
  // Re-enqueue any uploads that were interrupted before completing.
@@ -134,7 +139,7 @@ export class KritzelAssetResolver {
134
139
  this._resolveReady = null;
135
140
  this._rejectReady = null;
136
141
  }
137
- if (!this._quiet) {
142
+ if (this.canLogInfo()) {
138
143
  this.logProviderConfiguration();
139
144
  }
140
145
  if (this._pending.size > 0) {
@@ -143,8 +148,8 @@ export class KritzelAssetResolver {
143
148
  }
144
149
  /** Tears down the resolver and all providers. */
145
150
  destroy() {
146
- if (!this._quiet) {
147
- console.info('Asset resolver destroyed');
151
+ if (this.canLogInfo()) {
152
+ console.info('[Kritzel] Asset resolver destroyed');
148
153
  }
149
154
  this._destroyed = true;
150
155
  if (this._flushTimer) {
@@ -340,7 +345,7 @@ export class KritzelAssetResolver {
340
345
  // ------------------------------------------------------------------
341
346
  logProviderConfiguration() {
342
347
  const lines = [
343
- `Asset resolver initialized with ${this._providers.length} provider(s):`,
348
+ `[Kritzel] Asset resolver initialized with ${this._providers.length} provider(s):`,
344
349
  ];
345
350
  for (const provider of this._providers) {
346
351
  const role = provider === this._local ? 'local (primary write target)' : provider.type;
@@ -399,8 +404,8 @@ export class KritzelAssetResolver {
399
404
  recoveredCount++;
400
405
  }
401
406
  }
402
- if (!this._quiet && recoveredCount > 0) {
403
- console.info(`Asset resolver: recovered ${recoveredCount} pending upload(s)`);
407
+ if (this.canLogInfo() && recoveredCount > 0) {
408
+ console.info(`[Kritzel] Asset resolver recovered ${recoveredCount} pending upload(s)`);
404
409
  }
405
410
  }
406
411
  scheduleFlush(delayMs) {
@@ -455,8 +460,8 @@ export class KritzelAssetResolver {
455
460
  });
456
461
  this._pending.delete(key);
457
462
  await this.clearPendingRemote(job.id, job.providerName);
458
- if (!this._quiet) {
459
- console.info(`Asset resolver: uploaded ${job.id} to ${provider.name}`);
463
+ if (this.canLogInfo()) {
464
+ console.info(`[Kritzel] Asset resolver uploaded ${job.id} to ${provider.name}`);
460
465
  }
461
466
  }
462
467
  catch (err) {
@@ -464,8 +469,10 @@ export class KritzelAssetResolver {
464
469
  const delay = Math.min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS * Math.pow(2, job.attempts - 1));
465
470
  job.nextAttemptAt = Date.now() + delay;
466
471
  nextRetryIn = Math.min(nextRetryIn, delay);
467
- console.warn(`[KritzelAssetResolver] Upload to ${provider.name} failed for ${job.id} (attempt ${job.attempts}). ` +
468
- `Retrying in ${Math.round(delay / 1000)}s.`, err);
472
+ if (this.canLogInfo()) {
473
+ console.warn(`[Kritzel] Asset resolver upload to ${provider.name} failed for ${job.id} (attempt ${job.attempts}). ` +
474
+ `Retrying in ${Math.round(delay / 1000)}s.`, err);
475
+ }
469
476
  }
470
477
  }
471
478
  if (this._pending.size > 0 && Number.isFinite(nextRetryIn)) {
@@ -523,4 +530,7 @@ export class KritzelAssetResolver {
523
530
  const res = await fetch(dataUrl);
524
531
  return res.blob();
525
532
  }
533
+ canLogInfo() {
534
+ return !this._quiet;
535
+ }
526
536
  }
@@ -20,7 +20,6 @@ export class IndexedDBAssetProvider {
20
20
  type = 'local';
21
21
  name = 'IndexedDBAssetProvider';
22
22
  _dbName;
23
- _quiet;
24
23
  _db = null;
25
24
  /** In-memory cache of id -> object URL to avoid re-creating blob URLs. */
26
25
  _urlCache = new Map();
@@ -28,7 +27,6 @@ export class IndexedDBAssetProvider {
28
27
  _knownIds = new Set();
29
28
  constructor(options) {
30
29
  this._dbName = options?.dbName ?? DEFAULT_DB_NAME;
31
- this._quiet = options?.quiet ?? false;
32
30
  }
33
31
  /**
34
32
  * Factory helper enabling use as `IndexedDBAssetProvider.with({ dbName: '...' })`
@@ -43,9 +41,6 @@ export class IndexedDBAssetProvider {
43
41
  this._db = await this.openDatabase();
44
42
  // Warm up the known-ids cache so canResolve can answer synchronously.
45
43
  await this.loadKnownIds();
46
- if (!this._quiet) {
47
- console.info(`IndexedDBAssetProvider initialized: ${this._dbName}`);
48
- }
49
44
  }
50
45
  destroy() {
51
46
  for (const url of this._urlCache.values()) {
@@ -62,9 +57,6 @@ export class IndexedDBAssetProvider {
62
57
  this._db.close();
63
58
  this._db = null;
64
59
  }
65
- if (!this._quiet) {
66
- console.info(`IndexedDBAssetProvider destroyed: ${this._dbName}`);
67
- }
68
60
  }
69
61
  canResolve(id) {
70
62
  return this._knownIds.has(id);
@@ -0,0 +1,52 @@
1
+ const KRITZEL_RENDERER_REGISTRY_KEY = '__kritzelCustomElementRendererRegistry__';
2
+ const getGlobalRegistryStore = () => {
3
+ const globalScope = globalThis;
4
+ const existing = globalScope[KRITZEL_RENDERER_REGISTRY_KEY];
5
+ if (existing) {
6
+ return existing;
7
+ }
8
+ const created = {
9
+ renderers: new Map(),
10
+ };
11
+ globalScope[KRITZEL_RENDERER_REGISTRY_KEY] = created;
12
+ return created;
13
+ };
14
+ /**
15
+ * Global registry for custom element renderers.
16
+ * Allows wrapper frameworks (Angular, React, Vue) to register renderer implementations
17
+ * without coupling to core, and enables deterministic rehydration across reloads and
18
+ * clipboard paste operations.
19
+ *
20
+ * Renderer keys are opaque strings that must be stable (e.g., 'angular-todo', 'react-editor').
21
+ * When a custom element is deserialized with a rendererKey, the registry is consulted to
22
+ * resolve the renderer and mount with the persisted rendererData.
23
+ */
24
+ export class KritzelCustomElementRendererRegistry {
25
+ static get _renderers() {
26
+ return getGlobalRegistryStore().renderers;
27
+ }
28
+ /**
29
+ * Registers a renderer implementation for a given key.
30
+ * @param key - Stable identifier for this renderer (e.g., 'angular-my-component')
31
+ * @param renderer - Renderer implementation with mount/unmount/update handlers
32
+ */
33
+ static register(key, renderer) {
34
+ this._renderers.set(key, renderer);
35
+ }
36
+ /**
37
+ * Retrieves a registered renderer by key.
38
+ * @param key - Renderer identifier
39
+ * @returns The registered renderer, or undefined if not found
40
+ */
41
+ static get(key) {
42
+ return this._renderers.get(key);
43
+ }
44
+ /**
45
+ * Unregisters a renderer by key.
46
+ * @param key - Renderer identifier to remove
47
+ * @returns True if the renderer was found and removed; false otherwise
48
+ */
49
+ static unregister(key) {
50
+ return this._renderers.delete(key);
51
+ }
52
+ }