@radix-ng/primitives 1.0.0-beta.0 → 1.0.0-beta.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 (117) hide show
  1. package/fesm2022/radix-ng-primitives-accordion.mjs +2 -2
  2. package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
  3. package/fesm2022/radix-ng-primitives-calendar.mjs +109 -84
  4. package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
  5. package/fesm2022/radix-ng-primitives-checkbox.mjs +2 -2
  6. package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
  7. package/fesm2022/radix-ng-primitives-collapsible.mjs +1 -1
  8. package/fesm2022/radix-ng-primitives-collapsible.mjs.map +1 -1
  9. package/fesm2022/radix-ng-primitives-combobox.mjs +1923 -0
  10. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -0
  11. package/fesm2022/radix-ng-primitives-context-menu.mjs +1 -1
  12. package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
  13. package/fesm2022/radix-ng-primitives-core.mjs +591 -470
  14. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  15. package/fesm2022/radix-ng-primitives-cropper.mjs +287 -308
  16. package/fesm2022/radix-ng-primitives-cropper.mjs.map +1 -1
  17. package/fesm2022/radix-ng-primitives-date-field.mjs +66 -15
  18. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  19. package/fesm2022/radix-ng-primitives-dialog.mjs +1 -1
  20. package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
  21. package/fesm2022/radix-ng-primitives-drawer.mjs +7 -106
  22. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  23. package/fesm2022/radix-ng-primitives-editable.mjs +305 -24
  24. package/fesm2022/radix-ng-primitives-editable.mjs.map +1 -1
  25. package/fesm2022/radix-ng-primitives-field.mjs +86 -6
  26. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  27. package/fesm2022/radix-ng-primitives-fieldset.mjs +1 -1
  28. package/fesm2022/radix-ng-primitives-fieldset.mjs.map +1 -1
  29. package/fesm2022/radix-ng-primitives-focus-scope.mjs +1 -1
  30. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  31. package/fesm2022/radix-ng-primitives-form.mjs +207 -0
  32. package/fesm2022/radix-ng-primitives-form.mjs.map +1 -0
  33. package/fesm2022/radix-ng-primitives-input.mjs +85 -4
  34. package/fesm2022/radix-ng-primitives-input.mjs.map +1 -1
  35. package/fesm2022/radix-ng-primitives-menu.mjs +413 -5
  36. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  37. package/fesm2022/radix-ng-primitives-menubar.mjs +1 -1
  38. package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
  39. package/fesm2022/radix-ng-primitives-meter.mjs +1 -1
  40. package/fesm2022/radix-ng-primitives-meter.mjs.map +1 -1
  41. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +1 -1
  42. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  43. package/fesm2022/radix-ng-primitives-number-field.mjs +2 -2
  44. package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
  45. package/fesm2022/radix-ng-primitives-popover.mjs +1 -1
  46. package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
  47. package/fesm2022/radix-ng-primitives-popper.mjs +22 -5
  48. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  49. package/fesm2022/radix-ng-primitives-portal.mjs.map +1 -1
  50. package/fesm2022/radix-ng-primitives-preview-card.mjs +1 -1
  51. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  52. package/fesm2022/radix-ng-primitives-progress.mjs +1 -1
  53. package/fesm2022/radix-ng-primitives-progress.mjs.map +1 -1
  54. package/fesm2022/radix-ng-primitives-roving-focus.mjs +1 -1
  55. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  56. package/fesm2022/radix-ng-primitives-scroll-area.mjs +923 -0
  57. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -0
  58. package/fesm2022/radix-ng-primitives-select.mjs +421 -224
  59. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  60. package/fesm2022/radix-ng-primitives-slider.mjs +1 -1
  61. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  62. package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
  63. package/fesm2022/radix-ng-primitives-switch.mjs +3 -2
  64. package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
  65. package/fesm2022/radix-ng-primitives-tabs.mjs +12 -3
  66. package/fesm2022/radix-ng-primitives-tabs.mjs.map +1 -1
  67. package/fesm2022/radix-ng-primitives-time-field.mjs +27 -3
  68. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  69. package/fesm2022/radix-ng-primitives-toast.mjs +839 -0
  70. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -0
  71. package/fesm2022/radix-ng-primitives-toggle-group.mjs +1 -1
  72. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  73. package/fesm2022/radix-ng-primitives-toolbar.mjs +2 -2
  74. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  75. package/fesm2022/radix-ng-primitives-tooltip.mjs +11 -3
  76. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  77. package/package.json +18 -2
  78. package/schematics/ng-add/index.js +57 -0
  79. package/schematics/ng-add/index.js.map +1 -1
  80. package/schematics/ng-add/schema.d.ts +1 -0
  81. package/schematics/ng-add/schema.json +6 -0
  82. package/types/radix-ng-primitives-accordion.d.ts +3 -2
  83. package/types/radix-ng-primitives-calendar.d.ts +38 -18
  84. package/types/radix-ng-primitives-checkbox.d.ts +5 -5
  85. package/types/radix-ng-primitives-collapsible.d.ts +2 -1
  86. package/types/radix-ng-primitives-combobox.d.ts +1265 -0
  87. package/types/radix-ng-primitives-context-menu.d.ts +3 -2
  88. package/types/radix-ng-primitives-core.d.ts +187 -56
  89. package/types/radix-ng-primitives-cropper.d.ts +89 -56
  90. package/types/radix-ng-primitives-date-field.d.ts +11 -5
  91. package/types/radix-ng-primitives-dialog.d.ts +2 -1
  92. package/types/radix-ng-primitives-drawer.d.ts +5 -27
  93. package/types/radix-ng-primitives-editable.d.ts +90 -13
  94. package/types/radix-ng-primitives-field.d.ts +74 -4
  95. package/types/radix-ng-primitives-fieldset.d.ts +3 -2
  96. package/types/radix-ng-primitives-focus-scope.d.ts +2 -1
  97. package/types/radix-ng-primitives-form.d.ts +124 -0
  98. package/types/radix-ng-primitives-input.d.ts +75 -5
  99. package/types/radix-ng-primitives-menu.d.ts +16 -4
  100. package/types/radix-ng-primitives-menubar.d.ts +2 -1
  101. package/types/radix-ng-primitives-meter.d.ts +3 -2
  102. package/types/radix-ng-primitives-navigation-menu.d.ts +1 -1
  103. package/types/radix-ng-primitives-number-field.d.ts +6 -6
  104. package/types/radix-ng-primitives-popover.d.ts +2 -1
  105. package/types/radix-ng-primitives-popper.d.ts +19 -2
  106. package/types/radix-ng-primitives-preview-card.d.ts +1 -1
  107. package/types/radix-ng-primitives-progress.d.ts +3 -2
  108. package/types/radix-ng-primitives-roving-focus.d.ts +4 -3
  109. package/types/radix-ng-primitives-scroll-area.d.ts +253 -0
  110. package/types/radix-ng-primitives-select.d.ts +296 -136
  111. package/types/radix-ng-primitives-slider.d.ts +1 -1
  112. package/types/radix-ng-primitives-switch.d.ts +1 -1
  113. package/types/radix-ng-primitives-tabs.d.ts +1 -1
  114. package/types/radix-ng-primitives-toast.d.ts +378 -0
  115. package/types/radix-ng-primitives-toggle-group.d.ts +2 -1
  116. package/types/radix-ng-primitives-toolbar.d.ts +3 -2
  117. package/types/radix-ng-primitives-tooltip.d.ts +3 -2
@@ -1,25 +1,23 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, Directive, input, computed, Component, ElementRef, numberAttribute, output, signal, afterNextRender, effect, NgModule } from '@angular/core';
3
- import { clamp, provideToken } from '@radix-ng/primitives/core';
2
+ import { Directive, input, Component, inject, ElementRef, Injector, PLATFORM_ID, numberAttribute, booleanAttribute, output, signal, computed, afterNextRender, effect, untracked, NgModule } from '@angular/core';
3
+ import { createContext, clamp, injectId, resizeEffect } from '@radix-ng/primitives/core';
4
+ import { isPlatformBrowser } from '@angular/common';
4
5
 
5
- const CROPPER_ROOT_CONTEXT = new InjectionToken('CROPPER_ROOT_CONTEXT');
6
- function injectCropperRootContext() {
7
- return inject(CROPPER_ROOT_CONTEXT);
8
- }
6
+ const [injectCropperRootContext, provideCropperRootContext] = createContext('CropperRoot', 'components/cropper');
9
7
 
10
8
  class RdxCropperCropAreaDirective {
11
9
  constructor() {
12
10
  this.rootContext = injectCropperRootContext();
13
11
  }
14
12
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperCropAreaDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
15
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxCropperCropAreaDirective, isStandalone: true, selector: "[rdxCropperCropArea]", host: { properties: { "style": "rootContext.getCropAreaStyle()" } }, ngImport: i0 }); }
13
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxCropperCropAreaDirective, isStandalone: true, selector: "[rdxCropperCropArea]", host: { properties: { "style": "rootContext.cropAreaStyle()" } }, ngImport: i0 }); }
16
14
  }
17
15
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperCropAreaDirective, decorators: [{
18
16
  type: Directive,
19
17
  args: [{
20
18
  selector: '[rdxCropperCropArea]',
21
19
  host: {
22
- '[style]': 'rootContext.getCropAreaStyle()'
20
+ '[style]': 'rootContext.cropAreaStyle()'
23
21
  }
24
22
  }]
25
23
  }] });
@@ -29,14 +27,14 @@ class RdxCropperDescriptionDirective {
29
27
  this.rootContext = injectCropperRootContext();
30
28
  }
31
29
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperDescriptionDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
32
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxCropperDescriptionDirective, isStandalone: true, selector: "[rdxCropperDescription]", host: { properties: { "attr.id": "rootContext.descriptionId()" } }, ngImport: i0 }); }
30
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxCropperDescriptionDirective, isStandalone: true, selector: "[rdxCropperDescription]", host: { properties: { "attr.id": "rootContext.descriptionId" } }, ngImport: i0 }); }
33
31
  }
34
32
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperDescriptionDirective, decorators: [{
35
33
  type: Directive,
36
34
  args: [{
37
35
  selector: '[rdxCropperDescription]',
38
36
  host: {
39
- '[attr.id]': 'rootContext.descriptionId()'
37
+ '[attr.id]': 'rootContext.descriptionId'
40
38
  }
41
39
  }]
42
40
  }] });
@@ -46,18 +44,21 @@ class RdxCropperImageComponent {
46
44
  this.rootContext = injectCropperRootContext();
47
45
  this.imgClass = input(...(ngDevMode ? [undefined, { debugName: "imgClass" }] : /* istanbul ignore next */ []));
48
46
  this.imgStyles = input(...(ngDevMode ? [undefined, { debugName: "imgStyles" }] : /* istanbul ignore next */ []));
49
- this.imgClasses = computed(() => this.imgClass(), ...(ngDevMode ? [{ debugName: "imgClasses" }] : /* istanbul ignore next */ []));
50
- this.imgStyless = computed(() => this.imgStyles(), ...(ngDevMode ? [{ debugName: "imgStyless" }] : /* istanbul ignore next */ []));
47
+ /**
48
+ * `alt` text for the rendered image. Defaults to `''` (decorative screen readers skip it, since
49
+ * the cropper widget describes itself via the root's label/description). Set a non-empty value to
50
+ * give the image a meaningful accessible name.
51
+ */
52
+ this.imgAlt = input('', ...(ngDevMode ? [{ debugName: "imgAlt" }] : /* istanbul ignore next */ []));
51
53
  }
52
54
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperImageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
53
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.9", type: RdxCropperImageComponent, isStandalone: true, selector: "[rdxCropperImage]", inputs: { imgClass: { classPropertyName: "imgClass", publicName: "imgClass", isSignal: true, isRequired: false, transformFunction: null }, imgStyles: { classPropertyName: "imgStyles", publicName: "imgStyles", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style": "rootContext.getImageWrapperStyle()" } }, ngImport: i0, template: `
55
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.9", type: RdxCropperImageComponent, isStandalone: true, selector: "[rdxCropperImage]", inputs: { imgClass: { classPropertyName: "imgClass", publicName: "imgClass", isSignal: true, isRequired: false, transformFunction: null }, imgStyles: { classPropertyName: "imgStyles", publicName: "imgStyles", isSignal: true, isRequired: false, transformFunction: null }, imgAlt: { classPropertyName: "imgAlt", publicName: "imgAlt", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style": "rootContext.imageWrapperStyle()" } }, ngImport: i0, template: `
54
56
  <img
55
- [class]="imgClasses()"
56
- [src]="rootContext.getImageProps()['src']"
57
- [alt]="rootContext.getImageProps()['alt']"
58
- [draggable]="rootContext.getImageProps()['draggable']"
59
- [style]="imgStyless()"
60
- aria-hidden="true"
57
+ [class]="imgClass()"
58
+ [src]="rootContext.image()"
59
+ [style]="imgStyles()"
60
+ [draggable]="false"
61
+ [alt]="imgAlt()"
61
62
  />
62
63
  `, isInline: true }); }
63
64
  }
@@ -66,25 +67,97 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
66
67
  args: [{
67
68
  selector: '[rdxCropperImage]',
68
69
  host: {
69
- '[style]': 'rootContext.getImageWrapperStyle()'
70
+ '[style]': 'rootContext.imageWrapperStyle()'
70
71
  },
71
72
  template: `
72
73
  <img
73
- [class]="imgClasses()"
74
- [src]="rootContext.getImageProps()['src']"
75
- [alt]="rootContext.getImageProps()['alt']"
76
- [draggable]="rootContext.getImageProps()['draggable']"
77
- [style]="imgStyless()"
78
- aria-hidden="true"
74
+ [class]="imgClass()"
75
+ [src]="rootContext.image()"
76
+ [style]="imgStyles()"
77
+ [draggable]="false"
78
+ [alt]="imgAlt()"
79
79
  />
80
80
  `
81
81
  }]
82
- }], propDecorators: { imgClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "imgClass", required: false }] }], imgStyles: [{ type: i0.Input, args: [{ isSignal: true, alias: "imgStyles", required: false }] }] } });
82
+ }], propDecorators: { imgClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "imgClass", required: false }] }], imgStyles: [{ type: i0.Input, args: [{ isSignal: true, alias: "imgStyles", required: false }] }], imgAlt: [{ type: i0.Input, args: [{ isSignal: true, alias: "imgAlt", required: false }] }] } });
83
+
84
+ /**
85
+ * Clamp a pan offset so the zoomed image always fully covers the crop window — the user can never
86
+ * drag an image edge inside the crop frame. The offset is measured from the centered position, so the
87
+ * allowed travel on each axis is half the overflow of the scaled wrapper past the crop window.
88
+ *
89
+ * Returns `{ x: 0, y: 0 }` when the geometry is not ready yet (any dimension `<= 0`).
90
+ */
91
+ function restrictOffset(offsetX, offsetY, zoom, geometry) {
92
+ const { wrapperWidth, wrapperHeight, cropWidth, cropHeight } = geometry;
93
+ if (wrapperWidth <= 0 || wrapperHeight <= 0 || cropWidth <= 0 || cropHeight <= 0) {
94
+ return { x: 0, y: 0 };
95
+ }
96
+ const maxDragX = Math.max(0, (wrapperWidth * zoom - cropWidth) / 2);
97
+ const maxDragY = Math.max(0, (wrapperHeight * zoom - cropHeight) / 2);
98
+ return {
99
+ x: clamp(offsetX, -maxDragX, maxDragX),
100
+ y: clamp(offsetY, -maxDragY, maxDragY)
101
+ };
102
+ }
103
+ /**
104
+ * Project the current pan/zoom into a crop rectangle in the source image's natural pixels.
105
+ *
106
+ * The wrapper is centered in the crop window and scaled by `zoom`; `baseScale` converts rendered px
107
+ * back to natural px. The result is rounded and clamped to the image bounds.
108
+ *
109
+ * Returns `null` when the geometry/image is not ready (any dimension `<= 0` or no image), or when the
110
+ * computed crop collapses to zero area.
111
+ */
112
+ function calculateCropData(offsetX, offsetY, zoom, geometry, image) {
113
+ const { wrapperWidth, wrapperHeight, cropWidth, cropHeight } = geometry;
114
+ const imgW = image.width;
115
+ const imgH = image.height;
116
+ if (!imgW || !imgH || wrapperWidth <= 0 || wrapperHeight <= 0 || cropWidth <= 0 || cropHeight <= 0) {
117
+ return null;
118
+ }
119
+ const scaledWrapperWidth = wrapperWidth * zoom;
120
+ const scaledWrapperHeight = wrapperHeight * zoom;
121
+ const topLeftOffsetX = offsetX + (cropWidth - scaledWrapperWidth) / 2;
122
+ const topLeftOffsetY = offsetY + (cropHeight - scaledWrapperHeight) / 2;
123
+ const baseScale = imgW / wrapperWidth;
124
+ if (isNaN(baseScale) || baseScale === 0) {
125
+ return null;
126
+ }
127
+ const sx = (-topLeftOffsetX * baseScale) / zoom;
128
+ const sy = (-topLeftOffsetY * baseScale) / zoom;
129
+ const sWidth = (cropWidth * baseScale) / zoom;
130
+ const sHeight = (cropHeight * baseScale) / zoom;
131
+ const finalX = clamp(Math.round(sx), 0, imgW);
132
+ const finalY = clamp(Math.round(sy), 0, imgH);
133
+ const finalWidth = clamp(Math.round(sWidth), 0, imgW - finalX);
134
+ const finalHeight = clamp(Math.round(sHeight), 0, imgH - finalY);
135
+ if (finalWidth <= 0 || finalHeight <= 0) {
136
+ return null;
137
+ }
138
+ return { x: finalX, y: finalY, width: finalWidth, height: finalHeight };
139
+ }
83
140
 
84
141
  // Based on https://github.com/origin-space/image-cropper/blob/main/src/Cropper.tsx
142
+ /** Value equality for an `Area` so the crop `computed` only notifies when the rectangle changes. */
143
+ const areaEqual = (a, b) => a === b || (!!a && !!b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height);
144
+ /** Value equality for an `{x,y}` point so the clamped-offset `computed` only notifies on real moves. */
145
+ const pointEqual = (a, b) => a.x === b.x && a.y === b.y;
146
+ /** Exposes the root's public state to the child parts (image, crop-area, description). */
147
+ const rootContext = () => {
148
+ const instance = inject(RdxCropperRootDirective);
149
+ return {
150
+ image: instance.image,
151
+ imageWrapperStyle: instance.imageWrapperStyle,
152
+ cropAreaStyle: instance.cropAreaStyle,
153
+ descriptionId: instance.descriptionId
154
+ };
155
+ };
85
156
  class RdxCropperRootDirective {
86
157
  constructor() {
87
158
  this.elementRef = inject((ElementRef));
159
+ this.injector = inject(Injector);
160
+ this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
88
161
  this.CROPPER_DESC_WARN_MESSAGE = `Warning: \`Cropper\` requires a description element for accessibility.`;
89
162
  this.image = input.required(...(ngDevMode ? [{ debugName: "image" }] : /* istanbul ignore next */ []));
90
163
  this.cropPadding = input(25, { ...(ngDevMode ? { debugName: "cropPadding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
@@ -92,49 +165,119 @@ class RdxCropperRootDirective {
92
165
  this.minZoom = input(1, { ...(ngDevMode ? { debugName: "minZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
93
166
  this.maxZoom = input(3, { ...(ngDevMode ? { debugName: "maxZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
94
167
  this.zoomSensitivity = input(0.005, { ...(ngDevMode ? { debugName: "zoomSensitivity" } : /* istanbul ignore next */ {}), transform: numberAttribute });
168
+ /** Pan distance (px) per arrow-key press. */
95
169
  this.keyboardStep = input(10, { ...(ngDevMode ? { debugName: "keyboardStep" } : /* istanbul ignore next */ {}), transform: numberAttribute });
170
+ /** Zoom delta per `+` / `-` / `PageUp` / `PageDown` press. */
171
+ this.zoomKeyboardStep = input(0.1, { ...(ngDevMode ? { debugName: "zoomKeyboardStep" } : /* istanbul ignore next */ {}), transform: numberAttribute });
96
172
  this.zoom = input(undefined, { ...(ngDevMode ? { debugName: "zoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
173
+ /** Accessible name for the cropper widget. */
174
+ this.ariaLabel = input('Interactive image cropper', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
175
+ /** Disables all interaction (drag, wheel/pinch zoom, keyboard); exposed as `data-disabled`. */
176
+ this.disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
97
177
  this.onCropChange = output();
98
178
  this.onZoomChange = output();
99
179
  // State signals
100
180
  this.imgWidth = signal(null, ...(ngDevMode ? [{ debugName: "imgWidth" }] : /* istanbul ignore next */ []));
101
181
  this.imgHeight = signal(null, ...(ngDevMode ? [{ debugName: "imgHeight" }] : /* istanbul ignore next */ []));
102
- this.cropAreaWidth = signal(0, ...(ngDevMode ? [{ debugName: "cropAreaWidth" }] : /* istanbul ignore next */ []));
103
- this.cropAreaHeight = signal(0, ...(ngDevMode ? [{ debugName: "cropAreaHeight" }] : /* istanbul ignore next */ []));
182
+ /** Raw content-box size (px) of the root, fed by the ResizeObserver / initial measure. */
183
+ this.containerSize = signal({ width: 0, height: 0 }, ...(ngDevMode ? [{ debugName: "containerSize" }] : /* istanbul ignore next */ []));
184
+ /**
185
+ * Crop-area size derived from the container minus padding, fitted to `aspectRatio`. A `computed`
186
+ * (not a written signal) so it stays reactive to `aspectRatio` / `cropPadding` changes, not only
187
+ * to container resizes — previously these inputs were read inside the ResizeObserver closure and
188
+ * never recomputed until the next resize.
189
+ */
190
+ this.cropAreaSize = computed(() => {
191
+ const { width, height } = this.containerSize();
192
+ if (width <= 0 || height <= 0) {
193
+ return { width: 0, height: 0 };
194
+ }
195
+ const maxPossibleWidth = Math.max(0, width - this.cropPadding() * 2);
196
+ const maxPossibleHeight = Math.max(0, height - this.cropPadding() * 2);
197
+ const aspectRatio = this.aspectRatio();
198
+ if (maxPossibleWidth / aspectRatio >= maxPossibleHeight) {
199
+ return { width: maxPossibleHeight * aspectRatio, height: maxPossibleHeight };
200
+ }
201
+ return { width: maxPossibleWidth, height: maxPossibleWidth / aspectRatio };
202
+ }, ...(ngDevMode ? [{ debugName: "cropAreaSize" }] : /* istanbul ignore next */ []));
203
+ this.cropAreaWidth = computed(() => this.cropAreaSize().width, ...(ngDevMode ? [{ debugName: "cropAreaWidth" }] : /* istanbul ignore next */ []));
204
+ this.cropAreaHeight = computed(() => this.cropAreaSize().height, ...(ngDevMode ? [{ debugName: "cropAreaHeight" }] : /* istanbul ignore next */ []));
104
205
  this.imageWrapperWidth = signal(0, ...(ngDevMode ? [{ debugName: "imageWrapperWidth" }] : /* istanbul ignore next */ []));
105
206
  this.imageWrapperHeight = signal(0, ...(ngDevMode ? [{ debugName: "imageWrapperHeight" }] : /* istanbul ignore next */ []));
106
- this.offsetX = signal(0, ...(ngDevMode ? [{ debugName: "offsetX" }] : /* istanbul ignore next */ []));
107
- this.offsetY = signal(0, ...(ngDevMode ? [{ debugName: "offsetY" }] : /* istanbul ignore next */ []));
207
+ /** Raw (unclamped) pan-offset intent (px) written by gestures; clamping lives in `clampedOffset`. */
208
+ this.offset = signal({ x: 0, y: 0 }, ...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
108
209
  this.internalZoom = signal(this.minZoom(), ...(ngDevMode ? [{ debugName: "internalZoom" }] : /* istanbul ignore next */ []));
109
210
  this.isDragging = signal(false, ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
110
- this.descriptionId = signal(`cropper-${Math.random().toString(36).substring(2, 9)}`, ...(ngDevMode ? [{ debugName: "descriptionId" }] : /* istanbul ignore next */ []));
211
+ // SSR-stable, deterministic id (the project's CDK-free `_IdGenerator` replacement) so the
212
+ // `aria-describedby` reference matches between server and client renders.
213
+ this.descriptionId = injectId('rdx-cropper-description-');
111
214
  this.isZoomControlled = computed(() => this.zoom() !== undefined, ...(ngDevMode ? [{ debugName: "isZoomControlled" }] : /* istanbul ignore next */ []));
112
215
  this.effectiveZoom = computed(() => (this.isZoomControlled() ? this.zoom() : this.internalZoom()), ...(ngDevMode ? [{ debugName: "effectiveZoom" }] : /* istanbul ignore next */ []));
113
- this.zoomValueText = computed(() => {
114
- const zoomPercent = this.effectiveZoom() * 100;
115
- return `Zoom: ${zoomPercent.toFixed(0)}%`;
116
- }, ...(ngDevMode ? [{ debugName: "zoomValueText" }] : /* istanbul ignore next */ []));
216
+ /**
217
+ * The applied pan offset (px): the raw intent clamped to keep the image covering the crop window
218
+ * at the current geometry/zoom. Derived (not an effect) so it self-corrects when the container
219
+ * resizes or the zoom/aspect-ratio changes no write-back, no `untracked` re-entrancy. This is the
220
+ * value the view renders and the crop math reads.
221
+ */
222
+ this.clampedOffset = computed(() => this.restrictOffset(this.offset().x, this.offset().y, this.effectiveZoom()), { ...(ngDevMode ? { debugName: "clampedOffset" } : /* istanbul ignore next */ {}), equal: pointEqual });
223
+ /**
224
+ * Crop rectangle derived from the rendered pan/zoom — the single source of truth for emission.
225
+ * `onCropChange` fires from one effect watching this, so interactions/handlers never emit directly
226
+ * (which previously double-emitted). Value equality keeps it from notifying on equal results.
227
+ */
228
+ this.cropData = computed(() => calculateCropData(this.clampedOffset().x, this.clampedOffset().y, this.effectiveZoom(), this.geometry(), {
229
+ width: this.imgWidth(),
230
+ height: this.imgHeight()
231
+ }), { ...(ngDevMode ? { debugName: "cropData" } : /* istanbul ignore next */ {}), equal: areaEqual });
117
232
  // Refs
118
233
  this.dragStartPoint = signal({ x: 0, y: 0 }, ...(ngDevMode ? [{ debugName: "dragStartPoint" }] : /* istanbul ignore next */ []));
119
234
  this.dragStartOffset = signal({ x: 0, y: 0 }, ...(ngDevMode ? [{ debugName: "dragStartOffset" }] : /* istanbul ignore next */ []));
120
- this.latestRestrictedOffset = signal({ x: 0, y: 0 }, ...(ngDevMode ? [{ debugName: "latestRestrictedOffset" }] : /* istanbul ignore next */ []));
121
235
  this.latestZoom = signal(this.minZoom(), ...(ngDevMode ? [{ debugName: "latestZoom" }] : /* istanbul ignore next */ []));
122
- this.isInitialSetupDone = signal(false, ...(ngDevMode ? [{ debugName: "isInitialSetupDone" }] : /* istanbul ignore next */ []));
123
236
  this.initialPinchDistance = signal(0, ...(ngDevMode ? [{ debugName: "initialPinchDistance" }] : /* istanbul ignore next */ []));
124
237
  this.initialPinchZoom = signal(1, ...(ngDevMode ? [{ debugName: "initialPinchZoom" }] : /* istanbul ignore next */ []));
125
238
  this.isPinching = signal(false, ...(ngDevMode ? [{ debugName: "isPinching" }] : /* istanbul ignore next */ []));
126
239
  this.hasWarned = signal(false, ...(ngDevMode ? [{ debugName: "hasWarned" }] : /* istanbul ignore next */ []));
240
+ /**
241
+ * Inline style for the image wrapper: measured size, centered in the root, then translated and
242
+ * scaled by the current pan offset and zoom. A `computed` (not a method) so the `[style]` binding
243
+ * only re-applies when an input actually changes — a per-change-detection method call would
244
+ * allocate a new object every tick and force a constant re-bind.
245
+ */
246
+ this.imageWrapperStyle = computed(() => {
247
+ const wrapperW = this.imageWrapperWidth();
248
+ const wrapperH = this.imageWrapperHeight();
249
+ const { x: offsetX, y: offsetY } = this.clampedOffset();
250
+ const zoom = this.effectiveZoom();
251
+ return {
252
+ width: `${wrapperW}px`,
253
+ height: `${wrapperH}px`,
254
+ transform: `translate3d(${offsetX}px, ${offsetY}px, 0px) scale(${zoom})`,
255
+ position: 'absolute',
256
+ left: `calc(50% - ${wrapperW / 2}px)`,
257
+ top: `calc(50% - ${wrapperH / 2}px)`,
258
+ willChange: 'transform'
259
+ };
260
+ }, ...(ngDevMode ? [{ debugName: "imageWrapperStyle" }] : /* istanbul ignore next */ []));
261
+ /** Inline style for the crop-area overlay (its measured width/height). */
262
+ this.cropAreaStyle = computed(() => ({
263
+ width: `${this.cropAreaWidth()}px`,
264
+ height: `${this.cropAreaHeight()}px`
265
+ }), ...(ngDevMode ? [{ debugName: "cropAreaStyle" }] : /* istanbul ignore next */ []));
127
266
  afterNextRender(() => {
128
267
  this.initializeContainerDimensions();
129
268
  });
130
269
  this.setupImageLoadEffect();
131
270
  this.setupDimensionsEffects();
132
- this.setupCropCalculationEffect();
133
271
  this.setupAccessibilityWarningEffect();
134
272
  this.setupEventListenersEffect();
135
273
  effect(() => {
136
274
  this.latestZoom.set(this.effectiveZoom());
137
275
  });
276
+ // Single source of crop emission: emit whenever the derived crop rectangle changes.
277
+ effect(() => {
278
+ const data = this.cropData();
279
+ untracked(() => this.onCropChange.emit(data));
280
+ });
138
281
  }
139
282
  updateZoom(newZoomValue) {
140
283
  const clampedZoom = clamp(newZoomValue, this.minZoom(), this.maxZoom());
@@ -147,20 +290,18 @@ class RdxCropperRootDirective {
147
290
  initializeContainerDimensions() {
148
291
  const element = this.elementRef.nativeElement;
149
292
  if (element && element.clientWidth > 0 && element.clientHeight > 0) {
150
- this.cropAreaWidth.set(Math.max(0, element.clientWidth - this.cropPadding() * 2));
151
- this.cropAreaHeight.set(Math.max(0, element.clientHeight - this.cropPadding() * 2));
293
+ // Seed the size for the first paint; the ResizeObserver keeps it in sync afterwards.
294
+ this.containerSize.set({ width: element.clientWidth, height: element.clientHeight });
152
295
  }
153
296
  }
154
297
  setupImageLoadEffect() {
155
298
  effect(() => {
156
299
  const image = this.image();
157
- this.offsetX.set(0);
158
- this.offsetY.set(0);
300
+ this.offset.set({ x: 0, y: 0 });
159
301
  if (!this.isZoomControlled()) {
160
302
  this.internalZoom.set(this.minZoom());
161
303
  }
162
- this.isInitialSetupDone.set(false);
163
- if (!image) {
304
+ if (!image || !this.isBrowser) {
164
305
  this.imgWidth.set(null);
165
306
  this.imgHeight.set(null);
166
307
  return;
@@ -186,44 +327,20 @@ class RdxCropperRootDirective {
186
327
  });
187
328
  }
188
329
  setupDimensionsEffects() {
189
- effect(() => {
190
- const element = this.elementRef.nativeElement;
191
- if (!element)
192
- return;
193
- const updateDimensions = (width, height) => {
194
- if (width <= 0 || height <= 0) {
195
- this.cropAreaWidth.set(0);
196
- this.cropAreaHeight.set(0);
197
- return;
198
- }
199
- const maxPossibleWidth = Math.max(0, width - this.cropPadding() * 2);
200
- const maxPossibleHeight = Math.max(0, height - this.cropPadding() * 2);
201
- let targetCropW, targetCropH;
202
- if (maxPossibleWidth / this.aspectRatio() >= maxPossibleHeight) {
203
- targetCropH = maxPossibleHeight;
204
- targetCropW = targetCropH * this.aspectRatio();
205
- }
206
- else {
207
- targetCropW = maxPossibleWidth;
208
- targetCropH = targetCropW / this.aspectRatio();
209
- }
210
- this.cropAreaWidth.set(targetCropW);
211
- this.cropAreaHeight.set(targetCropH);
212
- };
213
- const observer = new ResizeObserver((entries) => {
330
+ // Track the container's content-box size via the shared resize-observer effect; `cropAreaSize`
331
+ // derives crop dimensions from it reactively (so changing `aspectRatio` / `cropPadding`
332
+ // recomputes without waiting for a resize). `element` is null on the server, so no observer is
333
+ // constructed (SSR-safe).
334
+ resizeEffect({
335
+ injector: this.injector,
336
+ element: computed(() => (this.isBrowser ? this.elementRef.nativeElement : null)),
337
+ onResize: (entries) => {
214
338
  for (const entry of entries) {
215
339
  const { width, height } = entry.contentRect;
216
340
  if (width > 0 && height > 0)
217
- updateDimensions(width, height);
341
+ this.containerSize.set({ width, height });
218
342
  }
219
- });
220
- observer.observe(element);
221
- const initialWidth = element.clientWidth;
222
- const initialHeight = element.clientHeight;
223
- if (initialWidth > 0 && initialHeight > 0) {
224
- updateDimensions(initialWidth, initialHeight);
225
343
  }
226
- return () => observer.disconnect();
227
344
  });
228
345
  // Update image wrapper dimensions when crop area or image dimensions change
229
346
  effect(() => {
@@ -251,110 +368,25 @@ class RdxCropperRootDirective {
251
368
  this.imageWrapperHeight.set(targetWrapperHeight);
252
369
  });
253
370
  }
254
- restrictOffset(dragOffsetX, dragOffsetY, currentZoom) {
255
- const wrapperW = this.imageWrapperWidth();
256
- const wrapperH = this.imageWrapperHeight();
257
- const cropW = this.cropAreaWidth();
258
- const cropH = this.cropAreaHeight();
259
- if (wrapperW <= 0 || wrapperH <= 0 || cropW <= 0 || cropH <= 0) {
260
- return { x: 0, y: 0 };
261
- }
262
- const effectiveWrapperWidth = wrapperW * currentZoom;
263
- const effectiveWrapperHeight = wrapperH * currentZoom;
264
- const maxDragX = Math.max(0, (effectiveWrapperWidth - cropW) / 2);
265
- const maxDragY = Math.max(0, (effectiveWrapperHeight - cropH) / 2);
371
+ /** Current rendered geometry the crop math operates on, read from the state signals. */
372
+ geometry() {
266
373
  return {
267
- x: clamp(dragOffsetX, -maxDragX, maxDragX),
268
- y: clamp(dragOffsetY, -maxDragY, maxDragY)
374
+ wrapperWidth: this.imageWrapperWidth(),
375
+ wrapperHeight: this.imageWrapperHeight(),
376
+ cropWidth: this.cropAreaWidth(),
377
+ cropHeight: this.cropAreaHeight()
269
378
  };
270
379
  }
271
- calculateCropData(finalOffsetX, finalOffsetY, finalZoom) {
272
- const currentOffsetX = finalOffsetX ?? this.latestRestrictedOffset().x;
273
- const currentOffsetY = finalOffsetY ?? this.latestRestrictedOffset().y;
274
- const currentZoom = finalZoom ?? this.effectiveZoom();
275
- const imgW = this.imgWidth();
276
- const imgH = this.imgHeight();
277
- const wrapperW = this.imageWrapperWidth();
278
- const wrapperH = this.imageWrapperHeight();
279
- const cropW = this.cropAreaWidth();
280
- const cropH = this.cropAreaHeight();
281
- if (!imgW || !imgH || wrapperW <= 0 || wrapperH <= 0 || cropW <= 0 || cropH <= 0) {
282
- return null;
283
- }
284
- const scaledWrapperWidth = wrapperW * currentZoom;
285
- const scaledWrapperHeight = wrapperH * currentZoom;
286
- const topLeftOffsetX = currentOffsetX + (cropW - scaledWrapperWidth) / 2;
287
- const topLeftOffsetY = currentOffsetY + (cropH - scaledWrapperHeight) / 2;
288
- const baseScale = imgW / wrapperW;
289
- if (isNaN(baseScale) || baseScale === 0) {
290
- return null;
291
- }
292
- const sx = (-topLeftOffsetX * baseScale) / currentZoom;
293
- const sy = (-topLeftOffsetY * baseScale) / currentZoom;
294
- const sWidth = (cropW * baseScale) / currentZoom;
295
- const sHeight = (cropH * baseScale) / currentZoom;
296
- const finalX = clamp(Math.round(sx), 0, imgW);
297
- const finalY = clamp(Math.round(sy), 0, imgH);
298
- const finalWidth = clamp(Math.round(sWidth), 0, imgW - finalX);
299
- const finalHeight = clamp(Math.round(sHeight), 0, imgH - finalY);
300
- if (finalWidth <= 0 || finalHeight <= 0) {
301
- return null;
302
- }
303
- return { x: finalX, y: finalY, width: finalWidth, height: finalHeight };
304
- }
305
- setupCropCalculationEffect() {
306
- effect(() => {
307
- const wrapperW = this.imageWrapperWidth();
308
- const wrapperH = this.imageWrapperHeight();
309
- const cropW = this.cropAreaWidth();
310
- const cropH = this.cropAreaHeight();
311
- const currentZoom = this.effectiveZoom();
312
- if (wrapperW > 0 && wrapperH > 0 && cropW > 0 && cropH > 0) {
313
- if (!this.isInitialSetupDone()) {
314
- const restrictedInitial = this.restrictOffset(0, 0, currentZoom);
315
- this.offsetX.set(restrictedInitial.x);
316
- this.offsetY.set(restrictedInitial.y);
317
- if (!this.isZoomControlled()) {
318
- this.internalZoom.set(currentZoom);
319
- }
320
- this.dragStartOffset.set(restrictedInitial);
321
- this.latestRestrictedOffset.set(restrictedInitial);
322
- this.latestZoom.set(currentZoom);
323
- this.onCropChange.emit(this.calculateCropData(restrictedInitial.x, restrictedInitial.y, currentZoom));
324
- this.isInitialSetupDone.set(true);
325
- }
326
- else {
327
- const currentX = this.latestRestrictedOffset().x;
328
- const currentY = this.latestRestrictedOffset().y;
329
- const restrictedCurrent = this.restrictOffset(currentX, currentY, currentZoom);
330
- if (restrictedCurrent.x !== currentX || restrictedCurrent.y !== currentY) {
331
- this.offsetX.set(restrictedCurrent.x);
332
- this.offsetY.set(restrictedCurrent.y);
333
- this.latestRestrictedOffset.set(restrictedCurrent);
334
- this.dragStartOffset.set(restrictedCurrent);
335
- }
336
- this.onCropChange.emit(this.calculateCropData(restrictedCurrent.x, restrictedCurrent.y, currentZoom));
337
- }
338
- }
339
- else {
340
- this.isInitialSetupDone.set(false);
341
- this.offsetX.set(0);
342
- this.offsetY.set(0);
343
- if (!this.isZoomControlled()) {
344
- this.internalZoom.set(this.minZoom());
345
- }
346
- this.dragStartOffset.set({ x: 0, y: 0 });
347
- this.latestRestrictedOffset.set({ x: 0, y: 0 });
348
- this.latestZoom.set(currentZoom);
349
- this.onCropChange.emit(null);
350
- }
351
- });
380
+ restrictOffset(dragOffsetX, dragOffsetY, currentZoom) {
381
+ return restrictOffset(dragOffsetX, dragOffsetY, currentZoom, this.geometry());
352
382
  }
353
383
  setupAccessibilityWarningEffect() {
384
+ if (!this.isBrowser)
385
+ return;
354
386
  effect(() => {
355
387
  const checkTimeout = setTimeout(() => {
356
388
  if (this.elementRef.nativeElement && !this.hasWarned()) {
357
- const hasDescription = document.getElementById(this.descriptionId());
389
+ const hasDescription = document.getElementById(this.descriptionId);
358
390
  if (!hasDescription) {
359
391
  console.warn(this.CROPPER_DESC_WARN_MESSAGE);
360
392
  this.hasWarned.set(true);
@@ -364,22 +396,36 @@ class RdxCropperRootDirective {
364
396
  return () => clearTimeout(checkTimeout);
365
397
  });
366
398
  }
399
+ /**
400
+ * Single attachment point for every interaction listener. Re-runs on `disabled()`, so a disabled
401
+ * cropper has NO interaction listeners bound at all — there is no per-handler `disabled` check to
402
+ * forget, and a new gesture path can't accidentally bypass the gate. Uses `{ passive: false }` so
403
+ * the handlers can `preventDefault()` (wheel/touch scrolling, arrow-key page scroll).
404
+ */
367
405
  setupEventListenersEffect() {
406
+ if (!this.isBrowser)
407
+ return;
368
408
  effect((onCleanup) => {
369
409
  const node = this.elementRef.nativeElement;
370
- if (!node)
410
+ if (!node || this.disabled())
371
411
  return;
372
412
  const options = { passive: false };
413
+ const mouseDownHandler = (e) => this.onMouseDown(e);
414
+ const keyDownHandler = (e) => this.onKeyDown(e);
373
415
  const wheelHandler = (e) => this.handleWheel(e);
374
416
  const touchStartHandler = (e) => this.handleTouchStart(e);
375
417
  const touchMoveHandler = (e) => this.handleTouchMove(e);
376
418
  const touchEndHandler = (e) => this.handleTouchEnd(e);
419
+ node.addEventListener('mousedown', mouseDownHandler, options);
420
+ node.addEventListener('keydown', keyDownHandler, options);
377
421
  node.addEventListener('wheel', wheelHandler, options);
378
422
  node.addEventListener('touchstart', touchStartHandler, options);
379
423
  node.addEventListener('touchmove', touchMoveHandler, options);
380
424
  node.addEventListener('touchend', touchEndHandler, options);
381
425
  node.addEventListener('touchcancel', touchEndHandler, options);
382
426
  onCleanup(() => {
427
+ node.removeEventListener('mousedown', mouseDownHandler, options);
428
+ node.removeEventListener('keydown', keyDownHandler, options);
383
429
  node.removeEventListener('wheel', wheelHandler, options);
384
430
  node.removeEventListener('touchstart', touchStartHandler, options);
385
431
  node.removeEventListener('touchmove', touchMoveHandler, options);
@@ -388,13 +434,6 @@ class RdxCropperRootDirective {
388
434
  });
389
435
  });
390
436
  }
391
- handleInteractionEnd() {
392
- const finalData = this.calculateCropData(this.latestRestrictedOffset().x, this.latestRestrictedOffset().y, this.effectiveZoom());
393
- this.onCropChange.emit(finalData);
394
- }
395
- /**
396
- * @ignore
397
- */
398
437
  onMouseDown(e) {
399
438
  if (e.button !== 0)
400
439
  return;
@@ -402,52 +441,49 @@ class RdxCropperRootDirective {
402
441
  this.isDragging.set(true);
403
442
  this.isPinching.set(false);
404
443
  this.dragStartPoint.set({ x: e.clientX, y: e.clientY });
405
- this.dragStartOffset.set(this.latestRestrictedOffset());
444
+ this.dragStartOffset.set(this.clampedOffset());
406
445
  const handleMouseMove = (ev) => {
407
446
  const deltaX = ev.clientX - this.dragStartPoint().x;
408
447
  const deltaY = ev.clientY - this.dragStartPoint().y;
409
- const targetOffsetX = this.dragStartOffset().x + deltaX;
410
- const targetOffsetY = this.dragStartOffset().y + deltaY;
411
- const restricted = this.restrictOffset(targetOffsetX, targetOffsetY, this.effectiveZoom());
412
- this.latestRestrictedOffset.set(restricted);
413
- this.offsetX.set(restricted.x);
414
- this.offsetY.set(restricted.y);
448
+ this.offset.set({ x: this.dragStartOffset().x + deltaX, y: this.dragStartOffset().y + deltaY });
415
449
  };
416
450
  const handleMouseUp = () => {
417
451
  this.isDragging.set(false);
418
452
  window.removeEventListener('mousemove', handleMouseMove);
419
453
  window.removeEventListener('mouseup', handleMouseUp);
420
- this.handleInteractionEnd();
421
454
  };
422
455
  window.addEventListener('mousemove', handleMouseMove);
423
456
  window.addEventListener('mouseup', handleMouseUp);
424
457
  }
458
+ /**
459
+ * Zoom toward an anchor point (coordinates relative to the root's center). `fromZoom`/`fromOffset`
460
+ * are the zoom/offset the anchor is measured against — the live state for wheel/keyboard, the
461
+ * gesture-start baseline for pinch. Emits the zoom request via {@link updateZoom}, then re-anchors
462
+ * the pan offset **only when the zoom is uncontrolled**: in controlled mode the rendered zoom does
463
+ * not change until the parent writes `zoom` back, so writing an offset for a not-yet-applied zoom
464
+ * would pan the image without rescaling it (`clampedOffset` re-derives once the new zoom applies).
465
+ */
466
+ zoomToPoint(pointerX, pointerY, targetZoom, fromZoom, fromOffset) {
467
+ const clampedZoom = clamp(targetZoom, this.minZoom(), this.maxZoom());
468
+ if (clampedZoom === this.effectiveZoom())
469
+ return;
470
+ const imagePointX = (pointerX - fromOffset.x) / fromZoom;
471
+ const imagePointY = (pointerY - fromOffset.y) / fromZoom;
472
+ const finalNewZoom = this.updateZoom(clampedZoom);
473
+ if (this.isZoomControlled())
474
+ return;
475
+ this.offset.set({ x: pointerX - imagePointX * finalNewZoom, y: pointerY - imagePointY * finalNewZoom });
476
+ }
425
477
  handleWheel(e) {
426
478
  e.preventDefault();
427
479
  e.stopPropagation();
428
480
  if (!this.elementRef.nativeElement || this.imageWrapperWidth() <= 0 || this.imageWrapperHeight() <= 0)
429
481
  return;
430
- const currentZoom = this.latestZoom();
431
- const currentOffsetX = this.latestRestrictedOffset().x;
432
- const currentOffsetY = this.latestRestrictedOffset().y;
433
482
  const delta = e.deltaY * -this.zoomSensitivity();
434
- const targetZoom = currentZoom + delta;
435
- if (clamp(targetZoom, this.minZoom(), this.maxZoom()) === currentZoom)
436
- return;
437
483
  const rect = this.elementRef.nativeElement.getBoundingClientRect();
438
484
  const pointerX = e.clientX - rect.left - rect.width / 2;
439
485
  const pointerY = e.clientY - rect.top - rect.height / 2;
440
- const imagePointX = (pointerX - currentOffsetX) / currentZoom;
441
- const imagePointY = (pointerY - currentOffsetY) / currentZoom;
442
- const finalNewZoom = this.updateZoom(targetZoom);
443
- const newOffsetX = pointerX - imagePointX * finalNewZoom;
444
- const newOffsetY = pointerY - imagePointY * finalNewZoom;
445
- const restrictedNewOffset = this.restrictOffset(newOffsetX, newOffsetY, finalNewZoom);
446
- this.offsetX.set(restrictedNewOffset.x);
447
- this.offsetY.set(restrictedNewOffset.y);
448
- this.latestRestrictedOffset.set(restrictedNewOffset);
449
- const finalData = this.calculateCropData(restrictedNewOffset.x, restrictedNewOffset.y, finalNewZoom);
450
- this.onCropChange.emit(finalData);
486
+ this.zoomToPoint(pointerX, pointerY, this.effectiveZoom() + delta, this.effectiveZoom(), this.clampedOffset());
451
487
  }
452
488
  getPinchDistance(touches) {
453
489
  return Math.sqrt(Math.pow(touches[1].clientX - touches[0].clientX, 2) + Math.pow(touches[1].clientY - touches[0].clientY, 2));
@@ -467,14 +503,14 @@ class RdxCropperRootDirective {
467
503
  this.isDragging.set(true);
468
504
  this.isPinching.set(false);
469
505
  this.dragStartPoint.set({ x: touches[0].clientX, y: touches[0].clientY });
470
- this.dragStartOffset.set(this.latestRestrictedOffset());
506
+ this.dragStartOffset.set(this.clampedOffset());
471
507
  }
472
508
  else if (touches.length === 2) {
473
509
  this.isDragging.set(false);
474
510
  this.isPinching.set(true);
475
511
  this.initialPinchDistance.set(this.getPinchDistance(touches));
476
512
  this.initialPinchZoom.set(this.latestZoom());
477
- this.dragStartOffset.set(this.latestRestrictedOffset());
513
+ this.dragStartOffset.set(this.clampedOffset());
478
514
  }
479
515
  }
480
516
  handleTouchMove(e) {
@@ -485,37 +521,16 @@ class RdxCropperRootDirective {
485
521
  if (touches.length === 1 && this.isDragging() && !this.isPinching()) {
486
522
  const deltaX = touches[0].clientX - this.dragStartPoint().x;
487
523
  const deltaY = touches[0].clientY - this.dragStartPoint().y;
488
- const targetOffsetX = this.dragStartOffset().x + deltaX;
489
- const targetOffsetY = this.dragStartOffset().y + deltaY;
490
- const restricted = this.restrictOffset(targetOffsetX, targetOffsetY, this.effectiveZoom());
491
- this.latestRestrictedOffset.set(restricted);
492
- this.offsetX.set(restricted.x);
493
- this.offsetY.set(restricted.y);
524
+ this.offset.set({ x: this.dragStartOffset().x + deltaX, y: this.dragStartOffset().y + deltaY });
494
525
  }
495
526
  else if (touches.length === 2 && this.isPinching()) {
496
- const currentPinchDistance = this.getPinchDistance(touches);
497
- const scale = currentPinchDistance / this.initialPinchDistance();
498
- const currentZoom = this.initialPinchZoom();
499
- const targetZoom = currentZoom * scale;
500
- if (clamp(targetZoom, this.minZoom(), this.maxZoom()) === this.latestZoom())
501
- return;
527
+ const scale = this.getPinchDistance(touches) / this.initialPinchDistance();
502
528
  const pinchCenter = this.getPinchCenter(touches);
503
529
  const rect = this.elementRef.nativeElement.getBoundingClientRect();
504
530
  const pinchCenterX = pinchCenter.x - rect.left - rect.width / 2;
505
531
  const pinchCenterY = pinchCenter.y - rect.top - rect.height / 2;
506
- const currentOffsetX = this.dragStartOffset().x;
507
- const currentOffsetY = this.dragStartOffset().y;
508
- const imagePointX = (pinchCenterX - currentOffsetX) / currentZoom;
509
- const imagePointY = (pinchCenterY - currentOffsetY) / currentZoom;
510
- const finalNewZoom = this.updateZoom(targetZoom);
511
- const newOffsetX = pinchCenterX - imagePointX * finalNewZoom;
512
- const newOffsetY = pinchCenterY - imagePointY * finalNewZoom;
513
- const restrictedNewOffset = this.restrictOffset(newOffsetX, newOffsetY, finalNewZoom);
514
- this.offsetX.set(restrictedNewOffset.x);
515
- this.offsetY.set(restrictedNewOffset.y);
516
- this.latestRestrictedOffset.set(restrictedNewOffset);
517
- const finalData = this.calculateCropData(restrictedNewOffset.x, restrictedNewOffset.y, finalNewZoom);
518
- this.onCropChange.emit(finalData);
532
+ // Pinch is baseline-relative: anchor against the zoom/offset captured at gesture start.
533
+ this.zoomToPoint(pinchCenterX, pinchCenterY, this.initialPinchZoom() * scale, this.initialPinchZoom(), this.dragStartOffset());
519
534
  }
520
535
  }
521
536
  handleTouchEnd(e) {
@@ -526,26 +541,22 @@ class RdxCropperRootDirective {
526
541
  if (touches.length === 1) {
527
542
  this.isDragging.set(true);
528
543
  this.dragStartPoint.set({ x: touches[0].clientX, y: touches[0].clientY });
529
- this.dragStartOffset.set(this.latestRestrictedOffset());
544
+ this.dragStartOffset.set(this.clampedOffset());
530
545
  }
531
546
  else {
532
547
  this.isDragging.set(false);
533
- this.handleInteractionEnd();
534
548
  }
535
549
  }
536
550
  else if (this.isDragging() && touches.length === 0) {
537
551
  this.isDragging.set(false);
538
- this.handleInteractionEnd();
539
552
  }
540
553
  }
541
- /**
542
- * @ignore
543
- */
544
554
  onKeyDown(e) {
545
555
  if (this.imageWrapperWidth() <= 0)
546
556
  return;
547
- let targetOffsetX = this.latestRestrictedOffset().x;
548
- let targetOffsetY = this.latestRestrictedOffset().y;
557
+ const base = this.clampedOffset();
558
+ let targetOffsetX = base.x;
559
+ let targetOffsetY = base.y;
549
560
  // eslint-disable-next-line no-useless-assignment
550
561
  let moved = false;
551
562
  switch (e.key) {
@@ -565,89 +576,57 @@ class RdxCropperRootDirective {
565
576
  targetOffsetX -= this.keyboardStep();
566
577
  moved = true;
567
578
  break;
579
+ case '+':
580
+ case '=':
581
+ case 'PageUp':
582
+ e.preventDefault();
583
+ this.zoomFromCenter(this.zoomKeyboardStep());
584
+ return;
585
+ case '-':
586
+ case '_':
587
+ case 'PageDown':
588
+ e.preventDefault();
589
+ this.zoomFromCenter(-this.zoomKeyboardStep());
590
+ return;
568
591
  default:
569
592
  return;
570
593
  }
571
594
  if (moved) {
572
595
  e.preventDefault();
573
- const restricted = this.restrictOffset(targetOffsetX, targetOffsetY, this.effectiveZoom());
574
- if (restricted.x !== this.latestRestrictedOffset().x || restricted.y !== this.latestRestrictedOffset().y) {
575
- this.latestRestrictedOffset.set(restricted);
576
- this.offsetX.set(restricted.x);
577
- this.offsetY.set(restricted.y);
578
- }
596
+ // Write the raw target; `clampedOffset` clamps it (and dedups a no-op at the boundary).
597
+ this.offset.set({ x: targetOffsetX, y: targetOffsetY });
579
598
  }
580
599
  }
581
- /**
582
- * @ignore
583
- */
584
- onKeyUp(e) {
585
- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
586
- this.handleInteractionEnd();
587
- }
588
- }
589
- /**
590
- * @ignore
591
- */
592
- getImageProps() {
593
- return {
594
- src: this.image(),
595
- alt: 'Image being cropped',
596
- draggable: false,
597
- 'aria-hidden': true
598
- };
599
- }
600
- /**
601
- * @ignore
602
- */
603
- getImageWrapperStyle() {
604
- const wrapperW = this.imageWrapperWidth();
605
- const wrapperH = this.imageWrapperHeight();
606
- const offsetX = this.offsetX();
607
- const offsetY = this.offsetY();
608
- const zoom = this.effectiveZoom();
609
- return {
610
- width: `${wrapperW}px`,
611
- height: `${wrapperH}px`,
612
- transform: `translate3d(${offsetX}px, ${offsetY}px, 0px) scale(${zoom})`,
613
- position: 'absolute',
614
- left: `calc(50% - ${wrapperW / 2}px)`,
615
- top: `calc(50% - ${wrapperH / 2}px)`,
616
- willChange: 'transform'
617
- };
618
- }
619
- /**
620
- * @ignore
621
- */
622
- getCropAreaStyle() {
623
- return {
624
- width: `${this.cropAreaWidth()}px`,
625
- height: `${this.cropAreaHeight()}px`
626
- };
600
+ /** Zoom by `delta` keeping the crop center fixed (the keyboard counterpart of wheel/pinch zoom). */
601
+ zoomFromCenter(delta) {
602
+ if (this.imageWrapperWidth() <= 0 || this.imageWrapperHeight() <= 0)
603
+ return;
604
+ // Anchor at the crop center (pointer 0,0 relative to center) against the live zoom/offset.
605
+ this.zoomToPoint(0, 0, this.effectiveZoom() + delta, this.effectiveZoom(), this.clampedOffset());
627
606
  }
628
607
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperRootDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
629
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxCropperRootDirective, isStandalone: true, selector: "[rdxCropperRoot]", inputs: { image: { classPropertyName: "image", publicName: "image", isSignal: true, isRequired: true, transformFunction: null }, cropPadding: { classPropertyName: "cropPadding", publicName: "cropPadding", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, minZoom: { classPropertyName: "minZoom", publicName: "minZoom", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, zoomSensitivity: { classPropertyName: "zoomSensitivity", publicName: "zoomSensitivity", isSignal: true, isRequired: false, transformFunction: null }, keyboardStep: { classPropertyName: "keyboardStep", publicName: "keyboardStep", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { onCropChange: "onCropChange", onZoomChange: "onZoomChange" }, host: { attributes: { "tabindex": "0", "role": "application" }, listeners: { "mousedown": "onMouseDown($event)", "keydown": "onKeyDown($event)", "keyup": "onKeyUp($event)" }, properties: { "attr.aria-label": "\"Interactive image cropper\"", "attr.aria-describedby": "descriptionId()", "attr.aria-valuemin": "minZoom()", "attr.aria-valuemax": "maxZoom()", "attr.aria-valuenow": "effectiveZoom()", "attr.aria-valuetext": "zoomValueText()" } }, providers: [provideToken(CROPPER_ROOT_CONTEXT, RdxCropperRootDirective)], ngImport: i0 }); }
608
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxCropperRootDirective, isStandalone: true, selector: "[rdxCropperRoot]", inputs: { image: { classPropertyName: "image", publicName: "image", isSignal: true, isRequired: true, transformFunction: null }, cropPadding: { classPropertyName: "cropPadding", publicName: "cropPadding", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, minZoom: { classPropertyName: "minZoom", publicName: "minZoom", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, zoomSensitivity: { classPropertyName: "zoomSensitivity", publicName: "zoomSensitivity", isSignal: true, isRequired: false, transformFunction: null }, keyboardStep: { classPropertyName: "keyboardStep", publicName: "keyboardStep", isSignal: true, isRequired: false, transformFunction: null }, zoomKeyboardStep: { classPropertyName: "zoomKeyboardStep", publicName: "zoomKeyboardStep", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { onCropChange: "onCropChange", onZoomChange: "onZoomChange" }, host: { attributes: { "role": "application" }, properties: { "attr.tabindex": "disabled() ? -1 : 0", "attr.aria-label": "ariaLabel()", "attr.aria-describedby": "descriptionId", "attr.aria-disabled": "disabled() ? \"true\" : undefined", "attr.data-disabled": "disabled() ? \"\" : undefined", "attr.data-dragging": "isDragging() ? \"\" : undefined" } }, providers: [provideCropperRootContext(rootContext)], ngImport: i0 }); }
630
609
  }
631
610
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxCropperRootDirective, decorators: [{
632
611
  type: Directive,
633
612
  args: [{
634
613
  selector: '[rdxCropperRoot]',
635
- providers: [provideToken(CROPPER_ROOT_CONTEXT, RdxCropperRootDirective)],
614
+ providers: [provideCropperRootContext(rootContext)],
636
615
  host: {
637
- '[attr.aria-label]': '"Interactive image cropper"',
638
- '[attr.aria-describedby]': 'descriptionId()',
639
- '[attr.aria-valuemin]': 'minZoom()',
640
- '[attr.aria-valuemax]': 'maxZoom()',
641
- '[attr.aria-valuenow]': 'effectiveZoom()',
642
- '[attr.aria-valuetext]': 'zoomValueText()',
643
- tabindex: '0',
616
+ // `application` so a screen reader passes arrow/+/- keys straight to our handlers (pan + zoom)
617
+ // instead of intercepting them for browse-mode navigation; instructions come via the
618
+ // description element referenced by `aria-describedby`. The slider-style `aria-value*`
619
+ // attributes were removed — they are only honored on range roles, so they were dead here.
644
620
  role: 'application',
645
- '(mousedown)': 'onMouseDown($event)',
646
- '(keydown)': 'onKeyDown($event)',
647
- '(keyup)': 'onKeyUp($event)'
621
+ '[attr.tabindex]': 'disabled() ? -1 : 0',
622
+ '[attr.aria-label]': 'ariaLabel()',
623
+ '[attr.aria-describedby]': 'descriptionId',
624
+ '[attr.aria-disabled]': 'disabled() ? "true" : undefined',
625
+ '[attr.data-disabled]': 'disabled() ? "" : undefined',
626
+ '[attr.data-dragging]': 'isDragging() ? "" : undefined'
648
627
  }
649
628
  }]
650
- }], ctorParameters: () => [], propDecorators: { image: [{ type: i0.Input, args: [{ isSignal: true, alias: "image", required: true }] }], cropPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "cropPadding", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], minZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "minZoom", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], zoomSensitivity: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomSensitivity", required: false }] }], keyboardStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "keyboardStep", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }], onCropChange: [{ type: i0.Output, args: ["onCropChange"] }], onZoomChange: [{ type: i0.Output, args: ["onZoomChange"] }] } });
629
+ }], ctorParameters: () => [], propDecorators: { image: [{ type: i0.Input, args: [{ isSignal: true, alias: "image", required: true }] }], cropPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "cropPadding", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], minZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "minZoom", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], zoomSensitivity: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomSensitivity", required: false }] }], keyboardStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "keyboardStep", required: false }] }], zoomKeyboardStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomKeyboardStep", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], onCropChange: [{ type: i0.Output, args: ["onCropChange"] }], onZoomChange: [{ type: i0.Output, args: ["onZoomChange"] }] } });
651
630
 
652
631
  const _imports = [
653
632
  RdxCropperRootDirective,
@@ -678,5 +657,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
678
657
  * Generated bundle index. Do not edit.
679
658
  */
680
659
 
681
- export { CROPPER_ROOT_CONTEXT, RdxCropperCropAreaDirective, RdxCropperDescriptionDirective, RdxCropperImageComponent, RdxCropperModule, RdxCropperRootDirective, injectCropperRootContext };
660
+ export { RdxCropperCropAreaDirective, RdxCropperDescriptionDirective, RdxCropperImageComponent, RdxCropperModule, RdxCropperRootDirective, injectCropperRootContext, provideCropperRootContext };
682
661
  //# sourceMappingURL=radix-ng-primitives-cropper.mjs.map