kritzel-stencil 0.2.1 → 0.2.2

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 (65) hide show
  1. package/dist/cjs/index.cjs.js +1 -1
  2. package/dist/cjs/kritzel-active-users_42.cjs.entry.js +80 -39
  3. package/dist/cjs/loader.cjs.js +1 -1
  4. package/dist/cjs/stencil.cjs.js +1 -1
  5. package/dist/cjs/{workspace.migrations-TAWnOE7r.js → workspace.migrations-BPwtowiJ.js} +159 -22
  6. package/dist/collection/classes/core/core.class.js +27 -19
  7. package/dist/collection/classes/handlers/selection.handler.js +36 -2
  8. package/dist/collection/classes/objects/group.class.js +69 -12
  9. package/dist/collection/classes/tools/text-tool.class.js +44 -8
  10. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +34 -17
  11. package/dist/collection/components/shared/kritzel-portal/kritzel-portal.js +23 -1
  12. package/dist/collection/constants/version.js +1 -1
  13. package/dist/collection/themes/dark-theme.js +5 -0
  14. package/dist/collection/themes/light-theme.js +5 -0
  15. package/dist/components/index.js +1 -1
  16. package/dist/components/kritzel-awareness-cursors.js +1 -1
  17. package/dist/components/kritzel-color-palette.js +1 -1
  18. package/dist/components/kritzel-color.js +1 -1
  19. package/dist/components/kritzel-controls.js +1 -1
  20. package/dist/components/kritzel-editor.js +1 -1
  21. package/dist/components/kritzel-engine.js +1 -1
  22. package/dist/components/kritzel-menu-item.js +1 -1
  23. package/dist/components/kritzel-menu.js +1 -1
  24. package/dist/components/kritzel-more-menu.js +1 -1
  25. package/dist/components/kritzel-portal.js +1 -1
  26. package/dist/components/kritzel-settings.js +1 -1
  27. package/dist/components/kritzel-split-button.js +1 -1
  28. package/dist/components/kritzel-stroke-size.js +1 -1
  29. package/dist/components/kritzel-tool-config.js +1 -1
  30. package/dist/components/kritzel-workspace-manager.js +1 -1
  31. package/dist/components/{p-CFzvz-B2.js → p-0YBCp8Wh.js} +1 -1
  32. package/dist/components/{p-DkT0CXfN.js → p-574MVXxi.js} +1 -1
  33. package/dist/components/p-BCzbwL4m.js +1 -0
  34. package/dist/components/p-BLjdzUzs.js +1 -0
  35. package/dist/components/{p-BFQVg_eQ.js → p-BSEdLfq2.js} +1 -1
  36. package/dist/components/{p-C3Dwuqka.js → p-BWrxz4mM.js} +1 -1
  37. package/dist/components/{p-ChqeIKg_.js → p-BYOIzv_f.js} +1 -1
  38. package/dist/components/{p-CekG3_ce.js → p-Bfa-Amjn.js} +1 -1
  39. package/dist/components/{p-mDz63oKF.js → p-BmcAX-1k.js} +1 -1
  40. package/dist/components/{p-CzIuqMQA.js → p-BtJB7FsW.js} +1 -1
  41. package/dist/components/{p-CVQBfO3r.js → p-C6Td7I4k.js} +1 -1
  42. package/dist/components/{p-ChQNi67Z.js → p-D9ifYAtg.js} +1 -1
  43. package/dist/components/{p-DoIOS3fS.js → p-DE2xDwUM.js} +1 -1
  44. package/dist/components/{p-B4wyWc66.js → p-DFeyobdy.js} +2 -2
  45. package/dist/components/{p--T9W9erA.js → p-DfB7uJ0N.js} +1 -1
  46. package/dist/components/{p-B2kHVHa_.js → p-u-827ZX7.js} +1 -1
  47. package/dist/esm/index.js +2 -2
  48. package/dist/esm/kritzel-active-users_42.entry.js +80 -39
  49. package/dist/esm/loader.js +1 -1
  50. package/dist/esm/stencil.js +1 -1
  51. package/dist/esm/{workspace.migrations-Dta1Yewh.js → workspace.migrations-C_uxbvuH.js} +159 -22
  52. package/dist/stencil/index.esm.js +1 -1
  53. package/dist/stencil/{p-22753671.entry.js → p-4d28c496.entry.js} +2 -2
  54. package/dist/stencil/p-C_uxbvuH.js +1 -0
  55. package/dist/stencil/stencil.esm.js +1 -1
  56. package/dist/types/classes/handlers/selection.handler.d.ts +15 -0
  57. package/dist/types/classes/objects/group.class.d.ts +15 -0
  58. package/dist/types/classes/tools/text-tool.class.d.ts +26 -8
  59. package/dist/types/components/shared/kritzel-portal/kritzel-portal.d.ts +1 -0
  60. package/dist/types/constants/version.d.ts +1 -1
  61. package/dist/types/interfaces/theme.interface.d.ts +12 -4
  62. package/package.json +1 -1
  63. package/dist/components/p-BYX50YSd.js +0 -1
  64. package/dist/components/p-CjazGGq3.js +0 -1
  65. package/dist/stencil/p-Dta1Yewh.js +0 -1
@@ -14512,6 +14512,11 @@ const lightTheme = {
14512
14512
  engine: {
14513
14513
  backgroundColor: '#ffffff',
14514
14514
  },
14515
+ snap: {
14516
+ indicatorFill: 'rgba(59, 130, 246, 0.3)',
14517
+ indicatorStroke: '#007bff',
14518
+ lineStroke: 'rgba(0, 0, 0, 0.2)',
14519
+ },
14515
14520
  fontSize: {
14516
14521
  hoverBackgroundColor: '#ebebeb',
14517
14522
  selectedBackgroundColor: '#ebebeb',
@@ -14776,6 +14781,11 @@ const darkTheme = {
14776
14781
  engine: {
14777
14782
  backgroundColor: '#1a1a1a',
14778
14783
  },
14784
+ snap: {
14785
+ indicatorFill: 'rgba(10, 132, 255, 0.35)',
14786
+ indicatorStroke: '#0A84FF',
14787
+ lineStroke: 'rgba(255, 255, 255, 0.35)',
14788
+ },
14779
14789
  fontSize: {
14780
14790
  hoverBackgroundColor: '#3a3a3a',
14781
14791
  selectedBackgroundColor: '#3a3a3a',
@@ -18268,6 +18278,23 @@ class KritzelGroup extends KritzelBaseObject {
18268
18278
  }
18269
18279
  return null;
18270
18280
  }
18281
+ /**
18282
+ * Recursively collects the IDs of all descendants of the given group (children,
18283
+ * grandchildren, etc.). Useful for detecting when a selection contains both an
18284
+ * ancestor group and one of its descendants — a configuration that would otherwise
18285
+ * cause the descendant to be transformed twice (once via the ancestor, once directly).
18286
+ * @param group - The group whose descendants should be collected.
18287
+ * @param out - A set that will be populated with all descendant object IDs.
18288
+ */
18289
+ static collectDescendantIds(group, out = new Set()) {
18290
+ for (const child of group.children) {
18291
+ out.add(child.id);
18292
+ if (child instanceof KritzelGroup) {
18293
+ KritzelGroup.collectDescendantIds(child, out);
18294
+ }
18295
+ }
18296
+ return out;
18297
+ }
18271
18298
  /**
18272
18299
  * Adds a child object to this group.
18273
18300
  * If the object is already a child, no action is taken.
@@ -18311,8 +18338,19 @@ class KritzelGroup extends KritzelBaseObject {
18311
18338
  * Finalizes the group after children have been positioned (e.g., after paste).
18312
18339
  * Refreshes the bounding box to encompass all children and captures
18313
18340
  * child snapshots for subsequent transformation operations.
18341
+ *
18342
+ * Recursively finalizes nested child groups first, so that when a transform
18343
+ * (rotate/resize) cascades through nested groups, every group's snapshots are
18344
+ * aligned with the current visual state. Without this, a nested group's
18345
+ * `snapshotRotation` could be stale, causing the inner group to be offset
18346
+ * relative to its parent during rotation.
18314
18347
  */
18315
18348
  finalize() {
18349
+ for (const child of this.children) {
18350
+ if (child instanceof KritzelGroup) {
18351
+ child.finalize();
18352
+ }
18353
+ }
18316
18354
  this.refreshBoundingBox();
18317
18355
  this.captureChildSnapshots();
18318
18356
  }
@@ -18469,11 +18507,23 @@ class KritzelGroup extends KritzelBaseObject {
18469
18507
  * @param height - The new height of the group's content area.
18470
18508
  */
18471
18509
  resize(x, y, width, height) {
18472
- const widthScaleFactor = width / this.width;
18473
- const heightScaleFactor = height / this.height;
18474
- // Calculate old center
18475
- const oldCenterX = this.translateX + this.totalWidth / 2 / this.scale;
18476
- const oldCenterY = this.translateY + this.totalHeight / 2 / this.scale;
18510
+ // Use snapshot dimensions (stable across the gesture) instead of `this.*`,
18511
+ // which mutates each frame and gets distorted by descendants like KritzelText
18512
+ // that clamp to a uniform scale, causing the cascade factor to drift.
18513
+ const baseWidth = this.snapshotTotalWidth > 0
18514
+ ? this.snapshotTotalWidth - this.padding * 2
18515
+ : this.width;
18516
+ const baseHeight = this.snapshotTotalHeight > 0
18517
+ ? this.snapshotTotalHeight - this.padding * 2
18518
+ : this.height;
18519
+ const baseScale = this.snapshotScale || this.scale || 1;
18520
+ const widthScaleFactor = baseWidth !== 0 ? width / baseWidth : 1;
18521
+ const heightScaleFactor = baseHeight !== 0 ? height / baseHeight : 1;
18522
+ // Calculate old center from snapshot (stable across the gesture)
18523
+ const snapshotTotalWidth = this.snapshotTotalWidth || (this.width + this.padding * 2);
18524
+ const snapshotTotalHeight = this.snapshotTotalHeight || (this.height + this.padding * 2);
18525
+ const oldCenterX = this.snapshotTranslateX + snapshotTotalWidth / 2 / baseScale;
18526
+ const oldCenterY = this.snapshotTranslateY + snapshotTotalHeight / 2 / baseScale;
18477
18527
  // Calculate new center
18478
18528
  const newTotalWidth = width + this.padding * 2;
18479
18529
  const newTotalHeight = height + this.padding * 2;
@@ -18486,9 +18536,20 @@ class KritzelGroup extends KritzelBaseObject {
18486
18536
  const sinR = Math.sin(rotation);
18487
18537
  this._core.store.objects.transaction(() => {
18488
18538
  this.children.forEach(child => {
18489
- // Calculate child center
18490
- const childCenterX = child.translateX + child.totalWidth / 2 / child.scale;
18491
- const childCenterY = child.translateY + child.totalHeight / 2 / child.scale;
18539
+ // Use snapshot values for the child to avoid compounding distortions caused
18540
+ // by descendants like text that cannot honour requested aspect ratios.
18541
+ const snapshot = this.unchangedChildSnapshots.get(child.id);
18542
+ const childTranslateX = snapshot ? snapshot.translateX : child.translateX;
18543
+ const childTranslateY = snapshot ? snapshot.translateY : child.translateY;
18544
+ const childTotalWidth = snapshot ? snapshot.totalWidth : child.totalWidth;
18545
+ const childTotalHeight = snapshot ? snapshot.totalHeight : child.totalHeight;
18546
+ const childWidth = snapshot ? snapshot.width : child.width;
18547
+ const childHeight = snapshot ? snapshot.height : child.height;
18548
+ const childRotation = snapshot ? snapshot.rotation : child.rotation;
18549
+ const childScale = (snapshot ? snapshot.scale : child.scale) || 1;
18550
+ // Calculate child center from snapshot
18551
+ const childCenterX = childTranslateX + childTotalWidth / 2 / childScale;
18552
+ const childCenterY = childTranslateY + childTotalHeight / 2 / childScale;
18492
18553
  // Vector from old group center to child center
18493
18554
  const dx = childCenterX - oldCenterX;
18494
18555
  const dy = childCenterY - oldCenterY;
@@ -18505,13 +18566,13 @@ class KritzelGroup extends KritzelBaseObject {
18505
18566
  const newChildCenterX = newCenterX + rotatedX;
18506
18567
  const newChildCenterY = newCenterY + rotatedY;
18507
18568
  // Calculate relative rotation for scaling
18508
- const relativeRotation = child.rotation - rotation;
18569
+ const relativeRotation = childRotation - rotation;
18509
18570
  const cosRel = Math.cos(relativeRotation);
18510
18571
  const sinRel = Math.sin(relativeRotation);
18511
18572
  const newChildWidthScale = Math.sqrt(Math.pow(widthScaleFactor * cosRel, 2) + Math.pow(heightScaleFactor * sinRel, 2));
18512
18573
  const newChildHeightScale = Math.sqrt(Math.pow(widthScaleFactor * sinRel, 2) + Math.pow(heightScaleFactor * cosRel, 2));
18513
- const updatedWidth = child.width * newChildWidthScale;
18514
- const updatedHeight = child.height * newChildHeightScale;
18574
+ const updatedWidth = childWidth * newChildWidthScale;
18575
+ const updatedHeight = childHeight * newChildHeightScale;
18515
18576
  const updatedTotalWidth = updatedWidth + child.padding * 2;
18516
18577
  const updatedTotalHeight = updatedHeight + child.padding * 2;
18517
18578
  const updatedX = newChildCenterX - updatedTotalWidth / 2 / child.scale;
@@ -18521,7 +18582,13 @@ class KritzelGroup extends KritzelBaseObject {
18521
18582
  this._core.anchorManager.updateAnchorsForObject(child.id);
18522
18583
  });
18523
18584
  this.refreshBoundingBox();
18524
- this.captureChildSnapshots();
18585
+ // Don't recapture snapshots while an interactive resize is in progress —
18586
+ // recapturing each frame would defeat the purpose of using stable snapshots
18587
+ // and re-introduce the compounding distortion described above. Snapshots are
18588
+ // refreshed at the start of the next gesture via beginTransform/finalize.
18589
+ if (!this._core.store.state.isResizing) {
18590
+ this.captureChildSnapshots();
18591
+ }
18525
18592
  this._core.store.objects.update(this);
18526
18593
  });
18527
18594
  }
@@ -20817,14 +20884,38 @@ class KritzelImageTool extends KritzelBaseTool {
20817
20884
  * Supports configurable font family, size, color, and opacity.
20818
20885
  */
20819
20886
  class KritzelTextTool extends KritzelBaseTool {
20820
- /** The font family for new text objects */
20821
- fontFamily = 'Arial';
20822
- /** The font size for new text objects in pixels */
20823
- fontSize = 16;
20824
- /** The font color for new text objects (supports theme-aware light/dark colors) */
20825
- fontColor = DEFAULT_COLOR_PALETTE[0];
20826
- /** The opacity of new text objects (0-1) */
20827
- opacity = 1;
20887
+ /** Backing field for {@link fontFamily}. */
20888
+ _fontFamily = 'Arial';
20889
+ /** Backing field for {@link fontSize}. */
20890
+ _fontSize = 16;
20891
+ /** Backing field for {@link fontColor}. */
20892
+ _fontColor = DEFAULT_COLOR_PALETTE[0];
20893
+ /** Backing field for {@link opacity}. */
20894
+ _opacity = 1;
20895
+ /** The font family for new text objects. */
20896
+ get fontFamily() { return this._fontFamily; }
20897
+ set fontFamily(value) {
20898
+ this._fontFamily = value;
20899
+ this.applyToActiveText({ fontFamily: value });
20900
+ }
20901
+ /** The font size for new text objects in pixels. */
20902
+ get fontSize() { return this._fontSize; }
20903
+ set fontSize(value) {
20904
+ this._fontSize = value;
20905
+ this.applyToActiveText({ fontSize: value });
20906
+ }
20907
+ /** The font color for new text objects (supports theme-aware light/dark colors). */
20908
+ get fontColor() { return this._fontColor; }
20909
+ set fontColor(value) {
20910
+ this._fontColor = value;
20911
+ this.applyToActiveText({ fontColor: value });
20912
+ }
20913
+ /** The opacity of new text objects (0-1). */
20914
+ get opacity() { return this._opacity; }
20915
+ set opacity(value) {
20916
+ this._opacity = value;
20917
+ this.applyToActiveText({ opacity: value });
20918
+ }
20828
20919
  /** Available color palette for the text tool */
20829
20920
  palette = [...DEFAULT_COLOR_PALETTE];
20830
20921
  /**
@@ -20834,6 +20925,18 @@ class KritzelTextTool extends KritzelBaseTool {
20834
20925
  constructor(core) {
20835
20926
  super(core);
20836
20927
  }
20928
+ /**
20929
+ * Propagates a property change to the text object that is currently being edited, if any.
20930
+ * Mirrors the selection tool pattern of pushing tool-config changes down to live objects,
20931
+ * but scoped to the single text in edit mode rather than the current selection.
20932
+ */
20933
+ applyToActiveText(updatedProperties) {
20934
+ const activeText = this._core?.store?.activeText;
20935
+ if (!activeText)
20936
+ return;
20937
+ this._core.updateObject(activeText, updatedProperties);
20938
+ this._core.rerender();
20939
+ }
20837
20940
  /**
20838
20941
  * Handles pointer down events for text creation and editing.
20839
20942
  * If clicking on an existing text object, enters edit mode for that object.
@@ -22561,6 +22664,7 @@ class KritzelSelectionHandler extends KritzelBaseHandler {
22561
22664
  const existingGroup = this._core.store.selectionGroup;
22562
22665
  if (shiftKey && existingGroup) {
22563
22666
  existingGroup.addOrRemove(objectToSelect);
22667
+ this.removeDescendantsOfSelectedGroups(existingGroup);
22564
22668
  if (existingGroup.objects.length === 0) {
22565
22669
  this._core.removeSelectionGroup();
22566
22670
  }
@@ -22606,15 +22710,17 @@ class KritzelSelectionHandler extends KritzelBaseHandler {
22606
22710
  obj.isSelected = false;
22607
22711
  }
22608
22712
  const unrolledObjects = Array.from(resolvedObjects.values());
22713
+ const filteredObjects = this.filterDescendantsOfSelectedGroups(unrolledObjects);
22609
22714
  const existingGroup = this._core.store.selectionGroup;
22610
22715
  if (shiftKey && existingGroup) {
22611
- existingGroup.addObjects(unrolledObjects);
22716
+ existingGroup.addObjects(filteredObjects);
22717
+ this.removeDescendantsOfSelectedGroups(existingGroup);
22612
22718
  this._core.rerender();
22613
22719
  }
22614
22720
  else {
22615
22721
  // Create selection group and set all IDs at once (no per-item refresh)
22616
22722
  const selectionGroup = KritzelSelectionGroup.create(this._core);
22617
- selectionGroup.objectIds = Array.from(resolvedObjects.keys());
22723
+ selectionGroup.objectIds = filteredObjects.map(o => o.id);
22618
22724
  // Only refresh dimensions and capture snapshots ONCE at the end - O(n) instead of O(n²)
22619
22725
  if (selectionGroup.length === 1) {
22620
22726
  selectionGroup.rotation = selectionGroup.objects[0].rotation;
@@ -22626,6 +22732,37 @@ class KritzelSelectionHandler extends KritzelBaseHandler {
22626
22732
  this._core.rerender();
22627
22733
  }
22628
22734
  }
22735
+ /**
22736
+ * Removes objects that are descendants of any KritzelGroup also present in the list.
22737
+ * Prevents an ancestor group and one of its descendants from coexisting in a selection,
22738
+ * which would otherwise cause the descendant to be transformed twice (once via the
22739
+ * ancestor group, and once directly).
22740
+ * @param objects - The candidate objects for the selection.
22741
+ * @returns A new array with descendants of selected groups removed.
22742
+ */
22743
+ filterDescendantsOfSelectedGroups(objects) {
22744
+ const descendantIds = new Set();
22745
+ for (const obj of objects) {
22746
+ if (obj instanceof KritzelGroup) {
22747
+ KritzelGroup.collectDescendantIds(obj, descendantIds);
22748
+ }
22749
+ }
22750
+ if (descendantIds.size === 0) {
22751
+ return objects;
22752
+ }
22753
+ return objects.filter(o => !descendantIds.has(o.id));
22754
+ }
22755
+ /**
22756
+ * Mutates an existing selection group to remove any descendants of groups
22757
+ * already present in it. See {@link filterDescendantsOfSelectedGroups}.
22758
+ * @param selectionGroup - The selection group to clean up in place.
22759
+ */
22760
+ removeDescendantsOfSelectedGroups(selectionGroup) {
22761
+ const filtered = this.filterDescendantsOfSelectedGroups(selectionGroup.objects);
22762
+ if (filtered.length !== selectionGroup.objects.length) {
22763
+ selectionGroup.objectIds = filtered.map(o => o.id);
22764
+ }
22765
+ }
22629
22766
  }
22630
22767
 
22631
22768
  /**
@@ -710,6 +710,31 @@ export class KritzelCore {
710
710
  // Batch all inserts and updates in a single Y.js transaction to avoid
711
711
  // N separate observer callbacks and N rerenders (fires only once at commit)
712
712
  this._store.objects.transaction(() => {
713
+ // Recursively flush a group's pending children (and any nested groups'
714
+ // pending children) into the store. Without recursion, nested groups
715
+ // would have their grandchildren stranded in `_pendingChildren` and they
716
+ // would never appear on the canvas, even though the parent group's
717
+ // bounding box looks correct.
718
+ const flushPendingChildren = (group, parentZIndex) => {
719
+ if (group._pendingChildren.length === 0) {
720
+ return;
721
+ }
722
+ const pendingChildren = group._pendingChildren;
723
+ group._pendingChildren = [];
724
+ pendingChildren.forEach((child, childIndex) => {
725
+ if (child.workspaceId !== activeWorkspace.id) {
726
+ child.workspaceId = activeWorkspace.id;
727
+ }
728
+ child.updatePosition(child.translateX + offsetX, child.translateY + offsetY);
729
+ child.zIndex = parentZIndex + childIndex + 1;
730
+ // Recurse before adding so grandchildren are inserted as well
731
+ if (KritzelClassHelper.isInstanceOf(child, 'KritzelGroup')) {
732
+ flushPendingChildren(child, child.zIndex);
733
+ }
734
+ this.addObject(child);
735
+ });
736
+ group.finalize();
737
+ };
713
738
  // First add all copied objects to the objectsMap with updated positions
714
739
  copiedObjects.forEach((obj, i) => {
715
740
  // Update workspace if pasting to a different workspace
@@ -720,26 +745,9 @@ export class KritzelCore {
720
745
  obj.updatePosition(obj.translateX + offsetX, obj.translateY + offsetY);
721
746
  // Update z-index
722
747
  obj.zIndex = baseZIndex + i;
723
- // Handle KritzelGroup: also add pending children with offset
748
+ // Handle KritzelGroup: also add pending children (and grandchildren) with offset
724
749
  if (KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
725
- if (obj._pendingChildren.length > 0) {
726
- obj._pendingChildren.forEach((child, childIndex) => {
727
- // Update workspace
728
- if (child.workspaceId !== activeWorkspace.id) {
729
- child.workspaceId = activeWorkspace.id;
730
- }
731
- // Update position with offset
732
- child.updatePosition(child.translateX + offsetX, child.translateY + offsetY);
733
- // Update z-index
734
- child.zIndex = baseZIndex + i + childIndex;
735
- // Add child to store
736
- this.addObject(child);
737
- });
738
- // Clear pending children
739
- obj._pendingChildren = [];
740
- // Finalize the group's bounding box and snapshots
741
- obj.finalize();
742
- }
750
+ flushPendingChildren(obj, obj.zIndex);
743
751
  }
744
752
  // Add to objectsMap
745
753
  this.addObject(obj);
@@ -407,6 +407,7 @@ export class KritzelSelectionHandler extends KritzelBaseHandler {
407
407
  const existingGroup = this._core.store.selectionGroup;
408
408
  if (shiftKey && existingGroup) {
409
409
  existingGroup.addOrRemove(objectToSelect);
410
+ this.removeDescendantsOfSelectedGroups(existingGroup);
410
411
  if (existingGroup.objects.length === 0) {
411
412
  this._core.removeSelectionGroup();
412
413
  }
@@ -452,15 +453,17 @@ export class KritzelSelectionHandler extends KritzelBaseHandler {
452
453
  obj.isSelected = false;
453
454
  }
454
455
  const unrolledObjects = Array.from(resolvedObjects.values());
456
+ const filteredObjects = this.filterDescendantsOfSelectedGroups(unrolledObjects);
455
457
  const existingGroup = this._core.store.selectionGroup;
456
458
  if (shiftKey && existingGroup) {
457
- existingGroup.addObjects(unrolledObjects);
459
+ existingGroup.addObjects(filteredObjects);
460
+ this.removeDescendantsOfSelectedGroups(existingGroup);
458
461
  this._core.rerender();
459
462
  }
460
463
  else {
461
464
  // Create selection group and set all IDs at once (no per-item refresh)
462
465
  const selectionGroup = KritzelSelectionGroup.create(this._core);
463
- selectionGroup.objectIds = Array.from(resolvedObjects.keys());
466
+ selectionGroup.objectIds = filteredObjects.map(o => o.id);
464
467
  // Only refresh dimensions and capture snapshots ONCE at the end - O(n) instead of O(n²)
465
468
  if (selectionGroup.length === 1) {
466
469
  selectionGroup.rotation = selectionGroup.objects[0].rotation;
@@ -472,4 +475,35 @@ export class KritzelSelectionHandler extends KritzelBaseHandler {
472
475
  this._core.rerender();
473
476
  }
474
477
  }
478
+ /**
479
+ * Removes objects that are descendants of any KritzelGroup also present in the list.
480
+ * Prevents an ancestor group and one of its descendants from coexisting in a selection,
481
+ * which would otherwise cause the descendant to be transformed twice (once via the
482
+ * ancestor group, and once directly).
483
+ * @param objects - The candidate objects for the selection.
484
+ * @returns A new array with descendants of selected groups removed.
485
+ */
486
+ filterDescendantsOfSelectedGroups(objects) {
487
+ const descendantIds = new Set();
488
+ for (const obj of objects) {
489
+ if (obj instanceof KritzelGroup) {
490
+ KritzelGroup.collectDescendantIds(obj, descendantIds);
491
+ }
492
+ }
493
+ if (descendantIds.size === 0) {
494
+ return objects;
495
+ }
496
+ return objects.filter(o => !descendantIds.has(o.id));
497
+ }
498
+ /**
499
+ * Mutates an existing selection group to remove any descendants of groups
500
+ * already present in it. See {@link filterDescendantsOfSelectedGroups}.
501
+ * @param selectionGroup - The selection group to clean up in place.
502
+ */
503
+ removeDescendantsOfSelectedGroups(selectionGroup) {
504
+ const filtered = this.filterDescendantsOfSelectedGroups(selectionGroup.objects);
505
+ if (filtered.length !== selectionGroup.objects.length) {
506
+ selectionGroup.objectIds = filtered.map(o => o.id);
507
+ }
508
+ }
475
509
  }
@@ -111,6 +111,23 @@ export class KritzelGroup extends KritzelBaseObject {
111
111
  }
112
112
  return null;
113
113
  }
114
+ /**
115
+ * Recursively collects the IDs of all descendants of the given group (children,
116
+ * grandchildren, etc.). Useful for detecting when a selection contains both an
117
+ * ancestor group and one of its descendants — a configuration that would otherwise
118
+ * cause the descendant to be transformed twice (once via the ancestor, once directly).
119
+ * @param group - The group whose descendants should be collected.
120
+ * @param out - A set that will be populated with all descendant object IDs.
121
+ */
122
+ static collectDescendantIds(group, out = new Set()) {
123
+ for (const child of group.children) {
124
+ out.add(child.id);
125
+ if (child instanceof KritzelGroup) {
126
+ KritzelGroup.collectDescendantIds(child, out);
127
+ }
128
+ }
129
+ return out;
130
+ }
114
131
  /**
115
132
  * Adds a child object to this group.
116
133
  * If the object is already a child, no action is taken.
@@ -154,8 +171,19 @@ export class KritzelGroup extends KritzelBaseObject {
154
171
  * Finalizes the group after children have been positioned (e.g., after paste).
155
172
  * Refreshes the bounding box to encompass all children and captures
156
173
  * child snapshots for subsequent transformation operations.
174
+ *
175
+ * Recursively finalizes nested child groups first, so that when a transform
176
+ * (rotate/resize) cascades through nested groups, every group's snapshots are
177
+ * aligned with the current visual state. Without this, a nested group's
178
+ * `snapshotRotation` could be stale, causing the inner group to be offset
179
+ * relative to its parent during rotation.
157
180
  */
158
181
  finalize() {
182
+ for (const child of this.children) {
183
+ if (child instanceof KritzelGroup) {
184
+ child.finalize();
185
+ }
186
+ }
159
187
  this.refreshBoundingBox();
160
188
  this.captureChildSnapshots();
161
189
  }
@@ -312,11 +340,23 @@ export class KritzelGroup extends KritzelBaseObject {
312
340
  * @param height - The new height of the group's content area.
313
341
  */
314
342
  resize(x, y, width, height) {
315
- const widthScaleFactor = width / this.width;
316
- const heightScaleFactor = height / this.height;
317
- // Calculate old center
318
- const oldCenterX = this.translateX + this.totalWidth / 2 / this.scale;
319
- const oldCenterY = this.translateY + this.totalHeight / 2 / this.scale;
343
+ // Use snapshot dimensions (stable across the gesture) instead of `this.*`,
344
+ // which mutates each frame and gets distorted by descendants like KritzelText
345
+ // that clamp to a uniform scale, causing the cascade factor to drift.
346
+ const baseWidth = this.snapshotTotalWidth > 0
347
+ ? this.snapshotTotalWidth - this.padding * 2
348
+ : this.width;
349
+ const baseHeight = this.snapshotTotalHeight > 0
350
+ ? this.snapshotTotalHeight - this.padding * 2
351
+ : this.height;
352
+ const baseScale = this.snapshotScale || this.scale || 1;
353
+ const widthScaleFactor = baseWidth !== 0 ? width / baseWidth : 1;
354
+ const heightScaleFactor = baseHeight !== 0 ? height / baseHeight : 1;
355
+ // Calculate old center from snapshot (stable across the gesture)
356
+ const snapshotTotalWidth = this.snapshotTotalWidth || (this.width + this.padding * 2);
357
+ const snapshotTotalHeight = this.snapshotTotalHeight || (this.height + this.padding * 2);
358
+ const oldCenterX = this.snapshotTranslateX + snapshotTotalWidth / 2 / baseScale;
359
+ const oldCenterY = this.snapshotTranslateY + snapshotTotalHeight / 2 / baseScale;
320
360
  // Calculate new center
321
361
  const newTotalWidth = width + this.padding * 2;
322
362
  const newTotalHeight = height + this.padding * 2;
@@ -329,9 +369,20 @@ export class KritzelGroup extends KritzelBaseObject {
329
369
  const sinR = Math.sin(rotation);
330
370
  this._core.store.objects.transaction(() => {
331
371
  this.children.forEach(child => {
332
- // Calculate child center
333
- const childCenterX = child.translateX + child.totalWidth / 2 / child.scale;
334
- const childCenterY = child.translateY + child.totalHeight / 2 / child.scale;
372
+ // Use snapshot values for the child to avoid compounding distortions caused
373
+ // by descendants like text that cannot honour requested aspect ratios.
374
+ const snapshot = this.unchangedChildSnapshots.get(child.id);
375
+ const childTranslateX = snapshot ? snapshot.translateX : child.translateX;
376
+ const childTranslateY = snapshot ? snapshot.translateY : child.translateY;
377
+ const childTotalWidth = snapshot ? snapshot.totalWidth : child.totalWidth;
378
+ const childTotalHeight = snapshot ? snapshot.totalHeight : child.totalHeight;
379
+ const childWidth = snapshot ? snapshot.width : child.width;
380
+ const childHeight = snapshot ? snapshot.height : child.height;
381
+ const childRotation = snapshot ? snapshot.rotation : child.rotation;
382
+ const childScale = (snapshot ? snapshot.scale : child.scale) || 1;
383
+ // Calculate child center from snapshot
384
+ const childCenterX = childTranslateX + childTotalWidth / 2 / childScale;
385
+ const childCenterY = childTranslateY + childTotalHeight / 2 / childScale;
335
386
  // Vector from old group center to child center
336
387
  const dx = childCenterX - oldCenterX;
337
388
  const dy = childCenterY - oldCenterY;
@@ -348,13 +399,13 @@ export class KritzelGroup extends KritzelBaseObject {
348
399
  const newChildCenterX = newCenterX + rotatedX;
349
400
  const newChildCenterY = newCenterY + rotatedY;
350
401
  // Calculate relative rotation for scaling
351
- const relativeRotation = child.rotation - rotation;
402
+ const relativeRotation = childRotation - rotation;
352
403
  const cosRel = Math.cos(relativeRotation);
353
404
  const sinRel = Math.sin(relativeRotation);
354
405
  const newChildWidthScale = Math.sqrt(Math.pow(widthScaleFactor * cosRel, 2) + Math.pow(heightScaleFactor * sinRel, 2));
355
406
  const newChildHeightScale = Math.sqrt(Math.pow(widthScaleFactor * sinRel, 2) + Math.pow(heightScaleFactor * cosRel, 2));
356
- const updatedWidth = child.width * newChildWidthScale;
357
- const updatedHeight = child.height * newChildHeightScale;
407
+ const updatedWidth = childWidth * newChildWidthScale;
408
+ const updatedHeight = childHeight * newChildHeightScale;
358
409
  const updatedTotalWidth = updatedWidth + child.padding * 2;
359
410
  const updatedTotalHeight = updatedHeight + child.padding * 2;
360
411
  const updatedX = newChildCenterX - updatedTotalWidth / 2 / child.scale;
@@ -364,7 +415,13 @@ export class KritzelGroup extends KritzelBaseObject {
364
415
  this._core.anchorManager.updateAnchorsForObject(child.id);
365
416
  });
366
417
  this.refreshBoundingBox();
367
- this.captureChildSnapshots();
418
+ // Don't recapture snapshots while an interactive resize is in progress —
419
+ // recapturing each frame would defeat the purpose of using stable snapshots
420
+ // and re-introduce the compounding distortion described above. Snapshots are
421
+ // refreshed at the start of the next gesture via beginTransform/finalize.
422
+ if (!this._core.store.state.isResizing) {
423
+ this.captureChildSnapshots();
424
+ }
368
425
  this._core.store.objects.update(this);
369
426
  });
370
427
  }
@@ -10,14 +10,38 @@ import { DEFAULT_COLOR_PALETTE } from "../../constants/color-palette.constants";
10
10
  * Supports configurable font family, size, color, and opacity.
11
11
  */
12
12
  export class KritzelTextTool extends KritzelBaseTool {
13
- /** The font family for new text objects */
14
- fontFamily = 'Arial';
15
- /** The font size for new text objects in pixels */
16
- fontSize = 16;
17
- /** The font color for new text objects (supports theme-aware light/dark colors) */
18
- fontColor = DEFAULT_COLOR_PALETTE[0];
19
- /** The opacity of new text objects (0-1) */
20
- opacity = 1;
13
+ /** Backing field for {@link fontFamily}. */
14
+ _fontFamily = 'Arial';
15
+ /** Backing field for {@link fontSize}. */
16
+ _fontSize = 16;
17
+ /** Backing field for {@link fontColor}. */
18
+ _fontColor = DEFAULT_COLOR_PALETTE[0];
19
+ /** Backing field for {@link opacity}. */
20
+ _opacity = 1;
21
+ /** The font family for new text objects. */
22
+ get fontFamily() { return this._fontFamily; }
23
+ set fontFamily(value) {
24
+ this._fontFamily = value;
25
+ this.applyToActiveText({ fontFamily: value });
26
+ }
27
+ /** The font size for new text objects in pixels. */
28
+ get fontSize() { return this._fontSize; }
29
+ set fontSize(value) {
30
+ this._fontSize = value;
31
+ this.applyToActiveText({ fontSize: value });
32
+ }
33
+ /** The font color for new text objects (supports theme-aware light/dark colors). */
34
+ get fontColor() { return this._fontColor; }
35
+ set fontColor(value) {
36
+ this._fontColor = value;
37
+ this.applyToActiveText({ fontColor: value });
38
+ }
39
+ /** The opacity of new text objects (0-1). */
40
+ get opacity() { return this._opacity; }
41
+ set opacity(value) {
42
+ this._opacity = value;
43
+ this.applyToActiveText({ opacity: value });
44
+ }
21
45
  /** Available color palette for the text tool */
22
46
  palette = [...DEFAULT_COLOR_PALETTE];
23
47
  /**
@@ -27,6 +51,18 @@ export class KritzelTextTool extends KritzelBaseTool {
27
51
  constructor(core) {
28
52
  super(core);
29
53
  }
54
+ /**
55
+ * Propagates a property change to the text object that is currently being edited, if any.
56
+ * Mirrors the selection tool pattern of pushing tool-config changes down to live objects,
57
+ * but scoped to the single text in edit mode rather than the current selection.
58
+ */
59
+ applyToActiveText(updatedProperties) {
60
+ const activeText = this._core?.store?.activeText;
61
+ if (!activeText)
62
+ return;
63
+ this._core.updateObject(activeText, updatedProperties);
64
+ this._core.rerender();
65
+ }
30
66
  /**
31
67
  * Handles pointer down events for text creation and editing.
32
68
  * If clicking on an existing text object, enters edit mode for that object.