web-mojo 2.2.57 → 2.2.59

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 (119) hide show
  1. package/dist/admin.cjs.js +1 -1
  2. package/dist/admin.cjs.js.map +1 -1
  3. package/dist/admin.es.js +1 -10105
  4. package/dist/admin.es.js.map +1 -1
  5. package/dist/auth.cjs.js +1 -1
  6. package/dist/auth.es.js +1 -588
  7. package/dist/auth.es.js.map +1 -1
  8. package/dist/charts.cjs.js +1 -1
  9. package/dist/charts.es.js +1 -571
  10. package/dist/charts.es.js.map +1 -1
  11. package/dist/chunks/ChatView-D4A9rIX3.js +2 -0
  12. package/dist/chunks/ChatView-D4A9rIX3.js.map +1 -0
  13. package/dist/chunks/ChatView-nxaq8aIo.js +2 -0
  14. package/dist/chunks/ChatView-nxaq8aIo.js.map +1 -0
  15. package/dist/chunks/Collection-1sPoIFvQ.js +2 -0
  16. package/dist/chunks/{Collection-DaiL0uGl.js.map → Collection-1sPoIFvQ.js.map} +1 -1
  17. package/dist/chunks/{Collection-CxbNKOas.js → Collection-DSBRXpwK.js} +2 -2
  18. package/dist/chunks/{Collection-CxbNKOas.js.map → Collection-DSBRXpwK.js.map} +1 -1
  19. package/dist/chunks/{ContextMenu-ClwHEbbD.js → ContextMenu-BWy7WqF4.js} +2 -2
  20. package/dist/chunks/{ContextMenu-ClwHEbbD.js.map → ContextMenu-BWy7WqF4.js.map} +1 -1
  21. package/dist/chunks/ContextMenu-BvniQz-N.js +3 -0
  22. package/dist/chunks/{ContextMenu-sgvgSACY.js.map → ContextMenu-BvniQz-N.js.map} +1 -1
  23. package/dist/chunks/DataView--nUWtq6r.js +2 -0
  24. package/dist/chunks/{DataView-Dzo0jbs2.js.map → DataView--nUWtq6r.js.map} +1 -1
  25. package/dist/chunks/{DataView-1xh3GFeC.js → DataView-CK3Z0TJH.js} +2 -2
  26. package/dist/chunks/{DataView-1xh3GFeC.js.map → DataView-CK3Z0TJH.js.map} +1 -1
  27. package/dist/chunks/Dialog-BcgSR01Z.js +2 -0
  28. package/dist/chunks/{Dialog-DOGDalUq.js.map → Dialog-BcgSR01Z.js.map} +1 -1
  29. package/dist/chunks/{Dialog-CQlTDhZS.js → Dialog-DwCTFV6O.js} +2 -2
  30. package/dist/chunks/{Dialog-CQlTDhZS.js.map → Dialog-DwCTFV6O.js.map} +1 -1
  31. package/dist/chunks/FormPlugins-DvQ-G5J5.js +2 -0
  32. package/dist/chunks/{FormPlugins-DY6e88YT.js.map → FormPlugins-DvQ-G5J5.js.map} +1 -1
  33. package/dist/chunks/{FormView-DaKA4Sys.js → FormView-CRmEReTC.js} +3 -3
  34. package/dist/chunks/{FormView-DaKA4Sys.js.map → FormView-CRmEReTC.js.map} +1 -1
  35. package/dist/chunks/FormView-OLA7t-yv.js +3 -0
  36. package/dist/chunks/{FormView-Dz3mYasQ.js.map → FormView-OLA7t-yv.js.map} +1 -1
  37. package/dist/chunks/ListView-6JQ6tRXs.js +2 -0
  38. package/dist/chunks/{ListView-X5w5jf51.js.map → ListView-6JQ6tRXs.js.map} +1 -1
  39. package/dist/chunks/{ListView-CDzKIpd8.js → ListView-DVStKiMi.js} +2 -2
  40. package/dist/chunks/{ListView-CDzKIpd8.js.map → ListView-DVStKiMi.js.map} +1 -1
  41. package/dist/chunks/{MetricsCountryMapView-Dx2cw7ya.js → MetricsCountryMapView-CnAEbUw_.js} +2 -2
  42. package/dist/chunks/{MetricsCountryMapView-Dx2cw7ya.js.map → MetricsCountryMapView-CnAEbUw_.js.map} +1 -1
  43. package/dist/chunks/MetricsCountryMapView-J067qrrt.js +2 -0
  44. package/dist/chunks/{MetricsCountryMapView-B2xz6zUw.js.map → MetricsCountryMapView-J067qrrt.js.map} +1 -1
  45. package/dist/chunks/{MetricsMiniChartWidget-CBuso0OE.js → MetricsMiniChartWidget-BeD1slGs.js} +2 -2
  46. package/dist/chunks/{MetricsMiniChartWidget-CBuso0OE.js.map → MetricsMiniChartWidget-BeD1slGs.js.map} +1 -1
  47. package/dist/chunks/MetricsMiniChartWidget-x2gFjHOU.js +2 -0
  48. package/dist/chunks/{MetricsMiniChartWidget-DvKd7Qrk.js.map → MetricsMiniChartWidget-x2gFjHOU.js.map} +1 -1
  49. package/dist/chunks/PDFViewer-CsyKn-gh.js +2 -0
  50. package/dist/chunks/{PDFViewer-EJ9cOfPF.js.map → PDFViewer-CsyKn-gh.js.map} +1 -1
  51. package/dist/chunks/{PDFViewer-ofMGdSaj.js → PDFViewer-DSa4BZCm.js} +2 -2
  52. package/dist/chunks/{PDFViewer-ofMGdSaj.js.map → PDFViewer-DSa4BZCm.js.map} +1 -1
  53. package/dist/chunks/Rest-DHbszkuP.js +2 -0
  54. package/dist/chunks/Rest-DHbszkuP.js.map +1 -0
  55. package/dist/chunks/Rest-Ds9e8tN8.js +2 -0
  56. package/dist/chunks/Rest-Ds9e8tN8.js.map +1 -0
  57. package/dist/chunks/TokenManager-D6SjKgPZ.js +2 -0
  58. package/dist/chunks/{TokenManager-DoN9e6q6.js.map → TokenManager-D6SjKgPZ.js.map} +1 -1
  59. package/dist/chunks/{TokenManager-Gqvj7SDX.js → TokenManager-REbha1Le.js} +2 -2
  60. package/dist/chunks/{TokenManager-Gqvj7SDX.js.map → TokenManager-REbha1Le.js.map} +1 -1
  61. package/dist/chunks/WebApp-CULZpO_0.js +2 -0
  62. package/dist/chunks/{WebApp-6qvqmOts.js.map → WebApp-CULZpO_0.js.map} +1 -1
  63. package/dist/chunks/{WebApp-_dgpwtFw.js → WebApp-DovLtA60.js} +2 -2
  64. package/dist/chunks/{WebApp-_dgpwtFw.js.map → WebApp-DovLtA60.js.map} +1 -1
  65. package/dist/chunks/WebSocketClient-B-wc3mez.js +2 -0
  66. package/dist/chunks/{WebSocketClient-DG2olXpH.js.map → WebSocketClient-B-wc3mez.js.map} +1 -1
  67. package/dist/chunks/{WebSocketClient-MFkFlSue.js → WebSocketClient-BdZ9QYll.js} +2 -2
  68. package/dist/chunks/{WebSocketClient-MFkFlSue.js.map → WebSocketClient-BdZ9QYll.js.map} +1 -1
  69. package/dist/chunks/version-C3dnl1bg.js +2 -0
  70. package/dist/chunks/version-C3dnl1bg.js.map +1 -0
  71. package/dist/chunks/{version-BVADfTA5.js → version-ioN546cp.js} +2 -2
  72. package/dist/chunks/{version-BVADfTA5.js.map → version-ioN546cp.js.map} +1 -1
  73. package/dist/css/web-mojo.css +1 -1
  74. package/dist/docit.cjs.js +1 -1
  75. package/dist/docit.es.js +1 -957
  76. package/dist/docit.es.js.map +1 -1
  77. package/dist/index.cjs.js +1 -1
  78. package/dist/index.es.js +1 -3252
  79. package/dist/index.es.js.map +1 -1
  80. package/dist/lightbox.cjs.js +1 -1
  81. package/dist/lightbox.es.js +1 -3737
  82. package/dist/lightbox.es.js.map +1 -1
  83. package/dist/loader.umd.js +2 -2
  84. package/dist/map.cjs.js +1 -1
  85. package/dist/map.es.js +1 -1032
  86. package/dist/map.es.js.map +1 -1
  87. package/dist/mojo-auth.es.js +338 -0
  88. package/dist/mojo-auth.umd.js +1 -0
  89. package/dist/timeline.cjs.js +1 -1
  90. package/dist/timeline.es.js +1 -224
  91. package/dist/timeline.es.js.map +1 -1
  92. package/dist/web-mojo.lite.iife.js +14 -3
  93. package/dist/web-mojo.lite.iife.js.map +1 -1
  94. package/dist/web-mojo.lite.iife.min.js +6 -6
  95. package/dist/web-mojo.lite.iife.min.js.map +1 -1
  96. package/package.json +2 -2
  97. package/dist/chunks/ChatView-9k6xBWXk.js +0 -7632
  98. package/dist/chunks/ChatView-9k6xBWXk.js.map +0 -1
  99. package/dist/chunks/ChatView-CdtuCDYm.js +0 -2
  100. package/dist/chunks/ChatView-CdtuCDYm.js.map +0 -1
  101. package/dist/chunks/Collection-DaiL0uGl.js +0 -1014
  102. package/dist/chunks/ContextMenu-sgvgSACY.js +0 -1535
  103. package/dist/chunks/DataView-Dzo0jbs2.js +0 -862
  104. package/dist/chunks/Dialog-DOGDalUq.js +0 -1579
  105. package/dist/chunks/FormPlugins-DY6e88YT.js +0 -124
  106. package/dist/chunks/FormView-Dz3mYasQ.js +0 -8636
  107. package/dist/chunks/ListView-X5w5jf51.js +0 -495
  108. package/dist/chunks/MetricsCountryMapView-B2xz6zUw.js +0 -1054
  109. package/dist/chunks/MetricsMiniChartWidget-DvKd7Qrk.js +0 -3283
  110. package/dist/chunks/PDFViewer-EJ9cOfPF.js +0 -946
  111. package/dist/chunks/Rest-CgSjfMaU.js +0 -2
  112. package/dist/chunks/Rest-CgSjfMaU.js.map +0 -1
  113. package/dist/chunks/Rest-W-sPfGh9.js +0 -4375
  114. package/dist/chunks/Rest-W-sPfGh9.js.map +0 -1
  115. package/dist/chunks/TokenManager-DoN9e6q6.js +0 -1423
  116. package/dist/chunks/WebApp-6qvqmOts.js +0 -1386
  117. package/dist/chunks/WebSocketClient-DG2olXpH.js +0 -209
  118. package/dist/chunks/version-OyPGnx30.js +0 -38
  119. package/dist/chunks/version-OyPGnx30.js.map +0 -1
@@ -1,3738 +1,2 @@
1
- import { V as View } from "./chunks/Rest-W-sPfGh9.js";
2
- import Dialog from "./chunks/Dialog-DOGDalUq.js";
3
- import { L, P } from "./chunks/PDFViewer-EJ9cOfPF.js";
4
- import { W } from "./chunks/WebApp-6qvqmOts.js";
5
- import { B, a, V, b, c, d } from "./chunks/version-OyPGnx30.js";
6
- class ImageViewer extends View {
7
- constructor(options = {}) {
8
- super({
9
- ...options,
10
- className: `image-viewer ${options.className || ""}`,
11
- tagName: "div"
12
- });
13
- this.imageUrl = options.imageUrl || options.src || "";
14
- this.alt = options.alt || "Image";
15
- this.title = options.title || "";
16
- this.canvas = null;
17
- this.context = null;
18
- this.image = null;
19
- this.scale = 1;
20
- this.rotation = 0;
21
- this.translateX = 0;
22
- this.translateY = 0;
23
- this.minScale = 0.1;
24
- this.maxScale = 5;
25
- this.scaleStep = 0.1;
26
- this.isDragging = false;
27
- this.lastPointerX = 0;
28
- this.lastPointerY = 0;
29
- this.isLoaded = false;
30
- this.showControls = options.showControls !== false;
31
- this.allowRotate = options.allowRotate !== false;
32
- this.allowZoom = options.allowZoom !== false;
33
- this.allowPan = options.allowPan !== false;
34
- this.allowDownload = options.allowDownload !== false;
35
- this.autoFit = options.autoFit !== false;
36
- this.containerElement = null;
37
- this.controlsElement = null;
38
- }
39
- async getTemplate() {
40
- return `
41
- <div class="image-viewer-container d-flex flex-column h-100" data-container="imageContainer">
42
- <div class="image-viewer-content flex-grow-1 position-relative">
43
- <canvas class="image-viewer-canvas w-100 h-100" data-container="canvas"></canvas>
44
- <div class="image-viewer-overlay">
45
- <div class="image-viewer-loading">
46
- <div class="spinner-border text-light" role="status">
47
- <span class="visually-hidden">Loading...</span>
48
- </div>
49
- </div>
50
- </div>
51
- </div>
52
-
53
- {{#showControls}}
54
- <div class="image-viewer-controls position-absolute top-0 start-50 translate-middle-x mt-3" data-container="controls" style="z-index: 10;">
55
- <div class="btn-group" role="group">
56
- {{#allowZoom}}
57
- <button type="button" class="btn btn-dark btn-sm" data-action="zoom-in" title="Zoom In">
58
- <i class="bi bi-zoom-in"></i>
59
- </button>
60
- <button type="button" class="btn btn-dark btn-sm" data-action="zoom-out" title="Zoom Out">
61
- <i class="bi bi-zoom-out"></i>
62
- </button>
63
- <button type="button" class="btn btn-dark btn-sm" data-action="zoom-fit" title="Fit to Screen">
64
- <i class="bi bi-arrows-fullscreen"></i>
65
- </button>
66
- <button type="button" class="btn btn-dark btn-sm" data-action="zoom-actual" title="Actual Size">
67
- <i class="bi bi-1-square"></i>
68
- </button>
69
- {{/allowZoom}}
70
-
71
- {{#allowRotate}}
72
- <button type="button" class="btn btn-dark btn-sm" data-action="rotate-left" title="Rotate Left">
73
- <i class="bi bi-arrow-counterclockwise"></i>
74
- </button>
75
- <button type="button" class="btn btn-dark btn-sm" data-action="rotate-right" title="Rotate Right">
76
- <i class="bi bi-arrow-clockwise"></i>
77
- </button>
78
- {{/allowRotate}}
79
-
80
- <button type="button" class="btn btn-dark btn-sm" data-action="reset" title="Reset View">
81
- <i class="bi bi-arrow-repeat"></i>
82
- </button>
83
-
84
- {{#allowDownload}}
85
- <button type="button" class="btn btn-dark btn-sm" data-action="download" title="Download Image">
86
- <i class="bi bi-download"></i>
87
- </button>
88
- {{/allowDownload}}
89
- </div>
90
-
91
- <div class="image-viewer-info">
92
- <span class="zoom-level">{{scale}}%</span>
93
- </div>
94
- </div>
95
- {{/showControls}}
96
- </div>
97
- `;
98
- }
99
- async onAfterRender() {
100
- this.canvas = this.element.querySelector(".image-viewer-canvas");
101
- this.context = this.canvas.getContext("2d");
102
- this.containerElement = this.element.querySelector(".image-viewer-content");
103
- this.controlsElement = this.element.querySelector(".image-viewer-controls");
104
- this.setupCanvas();
105
- this.setupEventListeners();
106
- if (this.imageUrl) {
107
- this.loadImage(this.imageUrl);
108
- }
109
- }
110
- setupCanvas() {
111
- if (!this.canvas || !this.containerElement) return;
112
- if (this.context) {
113
- this.context.imageSmoothingEnabled = true;
114
- this.context.imageSmoothingQuality = "high";
115
- }
116
- setTimeout(() => {
117
- this.resizeCanvas();
118
- if (this.isLoaded && this.image) {
119
- this.renderCanvas();
120
- }
121
- }, 2e3);
122
- }
123
- resizeCanvas() {
124
- if (!this.canvas) return;
125
- const viewportWidth = window.innerWidth;
126
- const viewportHeight = window.innerHeight;
127
- const canvasWidth = Math.floor(viewportWidth * 0.8);
128
- const canvasHeight = Math.floor(viewportHeight * 0.8);
129
- if (canvasWidth === this.canvasWidth && canvasHeight === this.canvasHeight) {
130
- return;
131
- }
132
- const dpr = window.devicePixelRatio || 1;
133
- this.canvasWidth = canvasWidth;
134
- this.canvasHeight = canvasHeight;
135
- this.canvas.width = canvasWidth * dpr;
136
- this.canvas.height = canvasHeight * dpr;
137
- this.canvas.style.width = canvasWidth + "px";
138
- this.canvas.style.height = canvasHeight + "px";
139
- this.context.setTransform(dpr, 0, 0, dpr, 0, 0);
140
- if (this.isLoaded && this.image) {
141
- this.renderCanvas();
142
- }
143
- }
144
- setupEventListeners() {
145
- if (!this.canvas) return;
146
- if (this.allowPan) {
147
- this.canvas.addEventListener("mousedown", (e) => this.handleMouseDown(e));
148
- document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
149
- document.addEventListener("mouseup", (e) => this.handleMouseUp(e));
150
- }
151
- if (this.allowZoom) {
152
- this.canvas.addEventListener("wheel", (e) => this.handleWheel(e), { passive: false });
153
- }
154
- this.canvas.addEventListener("touchstart", (e) => this.handleTouchStart(e), { passive: false });
155
- this.canvas.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false });
156
- this.canvas.addEventListener("touchend", (e) => this.handleTouchEnd(e));
157
- this.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
158
- }
159
- // Action handlers
160
- async handleActionZoomIn() {
161
- this.zoomIn();
162
- }
163
- async handleActionZoomOut() {
164
- this.zoomOut();
165
- }
166
- async handleActionZoomFit() {
167
- this.fitToContainer();
168
- }
169
- async handleActionZoomActual() {
170
- this.setScale(1);
171
- this.renderCanvas();
172
- }
173
- async handleActionRotateLeft() {
174
- this.rotate(-90);
175
- }
176
- async handleActionRotateRight() {
177
- this.rotate(90);
178
- }
179
- async handleActionReset() {
180
- this.reset();
181
- }
182
- async handleActionDownload() {
183
- this.downloadImage();
184
- }
185
- // Image loading
186
- loadImage(imageUrl) {
187
- this.isLoaded = false;
188
- this.element.classList.remove("loaded");
189
- const img = new Image();
190
- img.crossOrigin = "anonymous";
191
- img.onload = () => {
192
- this.image = img;
193
- this.handleImageLoad();
194
- };
195
- img.onerror = () => {
196
- this.handleImageError();
197
- };
198
- img.src = imageUrl;
199
- }
200
- handleImageLoad() {
201
- this.isLoaded = true;
202
- this.element.classList.add("loaded");
203
- const ensureCanvasReady = () => {
204
- if (!this.canvasWidth || !this.canvasHeight) {
205
- this.resizeCanvas();
206
- }
207
- if (this.autoFit) {
208
- this.fitToContainer();
209
- } else {
210
- this.smartFit();
211
- }
212
- this.renderCanvas();
213
- this.updateControls();
214
- };
215
- if (!this.canvasWidth || !this.canvasHeight) {
216
- setTimeout(ensureCanvasReady, 2e3);
217
- } else {
218
- requestAnimationFrame(ensureCanvasReady);
219
- }
220
- const eventBus = this.getApp()?.events;
221
- if (eventBus) {
222
- eventBus.emit("imageviewer:loaded", {
223
- viewer: this,
224
- imageUrl: this.imageUrl,
225
- naturalWidth: this.image.naturalWidth,
226
- naturalHeight: this.image.naturalHeight
227
- });
228
- }
229
- }
230
- handleImageError() {
231
- console.error("Failed to load image:", this.imageUrl);
232
- const eventBus = this.getApp()?.events;
233
- if (eventBus) {
234
- eventBus.emit("imageviewer:error", {
235
- viewer: this,
236
- imageUrl: this.imageUrl,
237
- error: "Failed to load image"
238
- });
239
- }
240
- }
241
- // Mouse interaction
242
- handleMouseDown(e) {
243
- if (!this.allowPan || e.button !== 0) return;
244
- e.preventDefault();
245
- this.isDragging = true;
246
- const rect = this.canvas.getBoundingClientRect();
247
- this.lastPointerX = e.clientX - rect.left;
248
- this.lastPointerY = e.clientY - rect.top;
249
- this.canvas.style.cursor = "grabbing";
250
- }
251
- handleMouseMove(e) {
252
- if (!this.isDragging || !this.allowPan) return;
253
- e.preventDefault();
254
- const rect = this.canvas.getBoundingClientRect();
255
- const currentX = e.clientX - rect.left;
256
- const currentY = e.clientY - rect.top;
257
- const deltaX = currentX - this.lastPointerX;
258
- const deltaY = currentY - this.lastPointerY;
259
- this.pan(deltaX, deltaY);
260
- this.lastPointerX = currentX;
261
- this.lastPointerY = currentY;
262
- }
263
- handleMouseUp(e) {
264
- if (!this.isDragging) return;
265
- this.isDragging = false;
266
- this.canvas.style.cursor = this.allowPan ? "grab" : "default";
267
- }
268
- handleWheel(e) {
269
- if (!this.allowZoom) return;
270
- e.preventDefault();
271
- const rect = this.canvas.getBoundingClientRect();
272
- const x = e.clientX - rect.left;
273
- const y = e.clientY - rect.top;
274
- const delta = e.deltaY > 0 ? -this.scaleStep * 0.5 : this.scaleStep * 0.5;
275
- this.zoomAtPoint(this.scale + delta, x, y);
276
- }
277
- // Touch events
278
- handleTouchStart(e) {
279
- if (e.touches.length === 1 && this.allowPan) {
280
- e.preventDefault();
281
- const touch = e.touches[0];
282
- const rect = this.canvas.getBoundingClientRect();
283
- this.isDragging = true;
284
- this.lastPointerX = touch.clientX - rect.left;
285
- this.lastPointerY = touch.clientY - rect.top;
286
- }
287
- }
288
- handleTouchMove(e) {
289
- if (e.touches.length === 1 && this.isDragging && this.allowPan) {
290
- e.preventDefault();
291
- const touch = e.touches[0];
292
- const rect = this.canvas.getBoundingClientRect();
293
- const currentX = touch.clientX - rect.left;
294
- const currentY = touch.clientY - rect.top;
295
- const deltaX = currentX - this.lastPointerX;
296
- const deltaY = currentY - this.lastPointerY;
297
- this.pan(deltaX, deltaY);
298
- this.lastPointerX = currentX;
299
- this.lastPointerY = currentY;
300
- }
301
- }
302
- handleTouchEnd(e) {
303
- this.isDragging = false;
304
- }
305
- // Transform methods
306
- zoomIn() {
307
- this.setScale(this.scale + this.scaleStep);
308
- }
309
- zoomOut() {
310
- this.setScale(this.scale - this.scaleStep);
311
- }
312
- setScale(scale) {
313
- const oldScale = this.scale;
314
- this.scale = Math.max(this.minScale, Math.min(this.maxScale, scale));
315
- this.renderCanvas();
316
- this.updateControls();
317
- const eventBus = this.getApp()?.events;
318
- if (eventBus && oldScale !== this.scale) {
319
- eventBus.emit("imageviewer:scale-changed", {
320
- viewer: this,
321
- oldScale,
322
- newScale: this.scale
323
- });
324
- }
325
- }
326
- zoomAtPoint(scale, x, y) {
327
- if (!this.image) return;
328
- const oldScale = this.scale;
329
- this.setScale(scale);
330
- if (oldScale !== this.scale) {
331
- const scaleDiff = this.scale / oldScale;
332
- const centerX = this.canvasWidth / 2;
333
- const centerY = this.canvasHeight / 2;
334
- this.translateX = (this.translateX - (x - centerX)) * scaleDiff + (x - centerX);
335
- this.translateY = (this.translateY - (y - centerY)) * scaleDiff + (y - centerY);
336
- this.renderCanvas();
337
- }
338
- }
339
- pan(deltaX, deltaY) {
340
- this.translateX += deltaX;
341
- this.translateY += deltaY;
342
- this.renderCanvas();
343
- }
344
- rotate(degrees) {
345
- const oldRotation = this.rotation;
346
- this.rotation = (this.rotation + degrees) % 360;
347
- if (this.rotation < 0) this.rotation += 360;
348
- this.renderCanvas();
349
- const eventBus = this.getApp()?.events;
350
- if (eventBus) {
351
- eventBus.emit("imageviewer:rotated", {
352
- viewer: this,
353
- oldRotation,
354
- newRotation: this.rotation,
355
- degrees
356
- });
357
- }
358
- }
359
- center() {
360
- this.translateX = 0;
361
- this.translateY = 0;
362
- this.renderCanvas();
363
- }
364
- fitToContainer() {
365
- if (!this.image || !this.canvasWidth || !this.canvasHeight) return;
366
- const padding = 40;
367
- const availableWidth = this.canvasWidth - padding;
368
- const availableHeight = this.canvasHeight - padding;
369
- const scaleX = availableWidth / this.image.naturalWidth;
370
- const scaleY = availableHeight / this.image.naturalHeight;
371
- const scale = Math.min(scaleX, scaleY, 1);
372
- this.setScale(scale);
373
- this.renderCanvas();
374
- }
375
- smartFit() {
376
- if (!this.image || !this.canvasWidth || !this.canvasHeight) return;
377
- const padding = 80;
378
- const scaleX = (this.canvasWidth - padding) / this.image.naturalWidth;
379
- const scaleY = (this.canvasHeight - padding) / this.image.naturalHeight;
380
- const fitScale = Math.min(scaleX, scaleY);
381
- if (fitScale < 1) {
382
- this.setScale(fitScale);
383
- }
384
- this.renderCanvas();
385
- }
386
- reset() {
387
- this.scale = 1;
388
- this.rotation = 0;
389
- this.translateX = 0;
390
- this.translateY = 0;
391
- this.renderCanvas();
392
- this.updateControls();
393
- }
394
- // Canvas rendering
395
- renderCanvas() {
396
- if (!this.context || !this.canvasWidth || !this.canvasHeight) return;
397
- this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
398
- if (!this.image || !this.isLoaded) return;
399
- this.context.save();
400
- this.context.translate(
401
- this.canvasWidth / 2 + this.translateX,
402
- this.canvasHeight / 2 + this.translateY
403
- );
404
- this.context.scale(this.scale, this.scale);
405
- this.context.rotate(this.rotation * Math.PI / 180);
406
- this.context.drawImage(
407
- this.image,
408
- -this.image.naturalWidth / 2,
409
- -this.image.naturalHeight / 2
410
- );
411
- this.context.restore();
412
- }
413
- // Download functionality
414
- downloadImage() {
415
- if (!this.canvas) return;
416
- try {
417
- const link = document.createElement("a");
418
- link.download = this.getDownloadFilename();
419
- link.href = this.canvas.toDataURL("image/png");
420
- document.body.appendChild(link);
421
- link.click();
422
- document.body.removeChild(link);
423
- const eventBus = this.getApp()?.events;
424
- if (eventBus) {
425
- eventBus.emit("imageviewer:downloaded", {
426
- viewer: this,
427
- filename: link.download
428
- });
429
- }
430
- } catch (error) {
431
- console.error("Failed to download image:", error);
432
- const eventBus = this.getApp()?.events;
433
- if (eventBus) {
434
- eventBus.emit("imageviewer:download-error", {
435
- viewer: this,
436
- error: error.message
437
- });
438
- }
439
- }
440
- }
441
- getDownloadFilename() {
442
- if (this.title) {
443
- return `${this.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.png`;
444
- }
445
- try {
446
- const url = new URL(this.imageUrl);
447
- const pathname = url.pathname;
448
- const filename = pathname.split("/").pop();
449
- if (filename && filename.includes(".")) {
450
- return filename.replace(/\.[^.]+$/, ".png");
451
- }
452
- } catch (e) {
453
- }
454
- return "image.png";
455
- }
456
- updateControls() {
457
- if (!this.controlsElement) return;
458
- const zoomLevel = this.controlsElement.querySelector(".zoom-level");
459
- if (zoomLevel) {
460
- zoomLevel.textContent = `${Math.round(this.scale * 100)}%`;
461
- }
462
- const zoomInBtn = this.controlsElement.querySelector('[data-action="zoom-in"]');
463
- const zoomOutBtn = this.controlsElement.querySelector('[data-action="zoom-out"]');
464
- if (zoomInBtn) {
465
- zoomInBtn.disabled = this.scale >= this.maxScale;
466
- }
467
- if (zoomOutBtn) {
468
- zoomOutBtn.disabled = this.scale <= this.minScale;
469
- }
470
- }
471
- // Public API methods
472
- setImage(imageUrl, alt = "", title = "") {
473
- const oldImageUrl = this.imageUrl;
474
- this.imageUrl = imageUrl;
475
- this.alt = alt;
476
- this.title = title;
477
- this.reset();
478
- this.loadImage(imageUrl);
479
- const eventBus = this.getApp()?.events;
480
- if (eventBus) {
481
- eventBus.emit("imageviewer:image-changed", {
482
- viewer: this,
483
- oldImageUrl,
484
- newImageUrl: imageUrl
485
- });
486
- }
487
- }
488
- getCurrentState() {
489
- return {
490
- scale: this.scale,
491
- rotation: this.rotation,
492
- translateX: this.translateX,
493
- translateY: this.translateY
494
- };
495
- }
496
- setState(state) {
497
- if (state.scale !== void 0) this.scale = state.scale;
498
- if (state.rotation !== void 0) this.rotation = state.rotation;
499
- if (state.translateX !== void 0) this.translateX = state.translateX;
500
- if (state.translateY !== void 0) this.translateY = state.translateY;
501
- this.renderCanvas();
502
- this.updateControls();
503
- }
504
- async onBeforeDestroy() {
505
- if (this.isDragging) {
506
- this.isDragging = false;
507
- }
508
- const eventBus = this.getApp()?.events;
509
- if (eventBus) {
510
- eventBus.emit("imageviewer:destroyed", { viewer: this });
511
- }
512
- }
513
- // Static method to show image in a fullscreen dialog
514
- static async showDialog(imageUrl, options = {}) {
515
- const {
516
- title = "Image Viewer",
517
- alt = "Image",
518
- size = "fullscreen",
519
- showControls = true,
520
- allowRotate = true,
521
- allowZoom = true,
522
- allowPan = true,
523
- allowDownload = true,
524
- ...dialogOptions
525
- } = options;
526
- const viewer = new ImageViewer({
527
- imageUrl,
528
- alt,
529
- title,
530
- showControls,
531
- allowRotate,
532
- allowZoom,
533
- allowPan,
534
- allowDownload,
535
- autoFit: true
536
- });
537
- return Dialog.showDialog({
538
- title,
539
- body: viewer,
540
- size,
541
- centered: true,
542
- backdrop: "static",
543
- keyboard: true,
544
- buttons: [
545
- {
546
- text: "Close",
547
- action: "close",
548
- class: "btn btn-secondary",
549
- dismiss: true
550
- }
551
- ],
552
- ...dialogOptions
553
- });
554
- }
555
- }
556
- window.ImageViewer = ImageViewer;
557
- class ImageCanvasView extends View {
558
- constructor(options = {}) {
559
- super({
560
- ...options,
561
- className: `image-canvas-view ${options.className || ""}`,
562
- tagName: "div"
563
- });
564
- this.imageUrl = options.imageUrl || options.src || "";
565
- this.alt = options.alt || "Image";
566
- this.title = options.title || "";
567
- this.canvas = null;
568
- this.context = null;
569
- this.image = null;
570
- this.canvasWidth = 0;
571
- this.canvasHeight = 0;
572
- this.maxCanvasHeightPercent = options.maxCanvasHeightPercent || 0.7;
573
- this.maxCanvasWidthPercent = options.maxCanvasWidthPercent || 0.8;
574
- this.canvasSizes = {
575
- sm: { width: 400, height: 300 },
576
- // Small - thumbnails, previews
577
- md: { width: 600, height: 450 },
578
- // Medium - dialogs, cards
579
- lg: { width: 800, height: 600 },
580
- // Large - main editing
581
- xl: { width: 1e3, height: 750 },
582
- // Extra Large - detailed work
583
- fullscreen: { width: 0, height: 0 },
584
- // Special case - use viewport
585
- auto: { width: 0, height: 0 }
586
- // Auto-size based on image + viewport
587
- };
588
- this.canvasSize = options.canvasSize || "auto";
589
- this.isLoaded = false;
590
- this.isRendering = false;
591
- this.autoFit = options.autoFit !== false;
592
- this.crossOrigin = options.crossOrigin || "anonymous";
593
- }
594
- async getTemplate() {
595
- return `
596
- <div class="image-canvas-container d-flex flex-column h-100">
597
- <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">
598
- <canvas class="image-canvas w-100 h-100" data-container="canvas"></canvas>
599
-
600
- <!-- Loading Overlay -->
601
- <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"
602
- style="display: none; z-index: 10;">
603
- <div class="spinner-border text-primary" role="status">
604
- <span class="visually-hidden">Loading...</span>
605
- </div>
606
- </div>
607
- </div>
608
- </div>
609
- `;
610
- }
611
- async onAfterRender() {
612
- this.canvas = this.element.querySelector("canvas");
613
- this.context = this.canvas.getContext("2d");
614
- this.containerElement = this.element.querySelector(".image-canvas-content");
615
- this.loadingElement = this.element.querySelector(".image-canvas-loading");
616
- this.setupCanvas();
617
- if (this.imageUrl) {
618
- this.loadImage(this.imageUrl);
619
- }
620
- }
621
- setupCanvas() {
622
- if (!this.canvas || !this.containerElement) return;
623
- this.setCanvasSize(this.canvasSize);
624
- if (this.canvasSize === "fullscreen") {
625
- this._resizeHandler = () => this.setCanvasSize("fullscreen");
626
- window.addEventListener("resize", this._resizeHandler);
627
- }
628
- this.context.imageSmoothingEnabled = true;
629
- this.context.imageSmoothingQuality = "high";
630
- }
631
- setCanvasSize(size) {
632
- const preset = this.canvasSizes[size];
633
- if (!preset && size !== "auto") return;
634
- let canvasWidth, canvasHeight;
635
- if (size === "fullscreen") {
636
- canvasWidth = Math.min(1200, window.innerWidth * 0.9);
637
- canvasHeight = Math.min(900, window.innerHeight * 0.8);
638
- } else if (size === "auto" || !preset) {
639
- if (this.image) {
640
- const maxWidth = window.innerWidth * this.maxCanvasWidthPercent;
641
- const maxHeight = window.innerHeight * this.maxCanvasHeightPercent;
642
- const scaleX = maxWidth / this.image.naturalWidth;
643
- const scaleY = maxHeight / this.image.naturalHeight;
644
- const scale = Math.min(scaleX, scaleY, 1);
645
- canvasWidth = Math.floor(this.image.naturalWidth * scale);
646
- canvasHeight = Math.floor(this.image.naturalHeight * scale);
647
- canvasWidth = Math.max(300, canvasWidth);
648
- canvasHeight = Math.max(200, canvasHeight);
649
- } else {
650
- canvasWidth = Math.min(600, window.innerWidth * this.maxCanvasWidthPercent);
651
- canvasHeight = Math.min(450, window.innerHeight * this.maxCanvasHeightPercent);
652
- }
653
- } else {
654
- const maxWidth = window.innerWidth * this.maxCanvasWidthPercent;
655
- const maxHeight = window.innerHeight * this.maxCanvasHeightPercent;
656
- if (preset.width > maxWidth || preset.height > maxHeight) {
657
- if (this.image) {
658
- const scaleX = maxWidth / this.image.naturalWidth;
659
- const scaleY = maxHeight / this.image.naturalHeight;
660
- const scale = Math.min(scaleX, scaleY, 1);
661
- canvasWidth = Math.floor(this.image.naturalWidth * scale);
662
- canvasHeight = Math.floor(this.image.naturalHeight * scale);
663
- canvasWidth = Math.max(300, canvasWidth);
664
- canvasHeight = Math.max(200, canvasHeight);
665
- } else {
666
- canvasWidth = Math.min(600, maxWidth);
667
- canvasHeight = Math.min(450, maxHeight);
668
- }
669
- } else {
670
- canvasWidth = preset.width;
671
- canvasHeight = preset.height;
672
- }
673
- }
674
- canvasWidth = Math.min(canvasWidth, window.innerWidth * this.maxCanvasWidthPercent);
675
- canvasHeight = Math.min(canvasHeight, window.innerHeight * this.maxCanvasHeightPercent);
676
- if (Math.abs(canvasWidth - this.canvasWidth) < 10 && Math.abs(canvasHeight - this.canvasHeight) < 10) {
677
- return;
678
- }
679
- const dpr = window.devicePixelRatio || 1;
680
- this.canvasWidth = canvasWidth;
681
- this.canvasHeight = canvasHeight;
682
- this.canvas.width = canvasWidth * dpr;
683
- this.canvas.height = canvasHeight * dpr;
684
- this.canvas.style.width = canvasWidth + "px";
685
- this.canvas.style.height = canvasHeight + "px";
686
- this.context.setTransform(dpr, 0, 0, dpr, 0, 0);
687
- if (this.isLoaded) {
688
- this.renderCanvas();
689
- }
690
- }
691
- // Image loading
692
- loadImage(imageUrl) {
693
- if (!imageUrl) return;
694
- this.imageUrl = imageUrl;
695
- this.isLoaded = false;
696
- this.element.classList.remove("loaded");
697
- this.showLoading();
698
- const img = new Image();
699
- if (this.crossOrigin) {
700
- img.crossOrigin = this.crossOrigin;
701
- }
702
- img.onload = () => {
703
- this.image = img;
704
- this.handleImageLoad();
705
- };
706
- img.onerror = () => {
707
- this.handleImageError();
708
- };
709
- img.src = imageUrl;
710
- }
711
- handleImageLoad() {
712
- this.isLoaded = true;
713
- this.element.classList.add("loaded");
714
- this.hideLoading();
715
- if (this.canvasSize === "auto") {
716
- this.setCanvasSize("auto");
717
- } else if (this.autoFit) {
718
- this.fitToContainer();
719
- }
720
- this.renderCanvas();
721
- const eventBus = this.getApp()?.events;
722
- if (eventBus) {
723
- eventBus.emit("imagecanvas:loaded", {
724
- view: this,
725
- imageUrl: this.imageUrl,
726
- naturalWidth: this.image.naturalWidth,
727
- naturalHeight: this.image.naturalHeight
728
- });
729
- }
730
- }
731
- handleImageError() {
732
- console.error("Failed to load image:", this.imageUrl);
733
- this.hideLoading();
734
- const eventBus = this.getApp()?.events;
735
- if (eventBus) {
736
- eventBus.emit("imagecanvas:error", {
737
- view: this,
738
- imageUrl: this.imageUrl,
739
- error: "Failed to load image"
740
- });
741
- }
742
- }
743
- showLoading() {
744
- if (this.loadingElement) {
745
- this.loadingElement.style.display = "block";
746
- }
747
- }
748
- hideLoading() {
749
- if (this.loadingElement) {
750
- this.loadingElement.style.display = "none";
751
- }
752
- }
753
- // Base canvas rendering - to be extended by child classes
754
- renderCanvas() {
755
- if (!this.context || !this.canvasWidth || !this.canvasHeight || this.isRendering) return;
756
- this.isRendering = true;
757
- this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
758
- if (!this.image || !this.isLoaded) {
759
- this.isRendering = false;
760
- return;
761
- }
762
- this.context.save();
763
- this.renderImage();
764
- this.context.restore();
765
- this.isRendering = false;
766
- }
767
- // Basic image rendering with smart scaling - can be overridden
768
- renderImage() {
769
- if (!this.image) return;
770
- const scaleX = this.canvasWidth / this.image.naturalWidth;
771
- const scaleY = this.canvasHeight / this.image.naturalHeight;
772
- const scale = Math.min(scaleX, scaleY, 1);
773
- const scaledWidth = this.image.naturalWidth * scale;
774
- const scaledHeight = this.image.naturalHeight * scale;
775
- const x = (this.canvasWidth - scaledWidth) / 2;
776
- const y = (this.canvasHeight - scaledHeight) / 2;
777
- this.context.drawImage(this.image, x, y, scaledWidth, scaledHeight);
778
- }
779
- // Utility methods
780
- fitToContainer() {
781
- if (!this.image || !this.canvasWidth || !this.canvasHeight) return;
782
- const padding = 40;
783
- const availableWidth = this.canvasWidth - padding;
784
- const availableHeight = this.canvasHeight - padding;
785
- availableWidth / this.image.naturalWidth;
786
- availableHeight / this.image.naturalHeight;
787
- if (this.canvasSize === "auto") {
788
- this.setCanvasSize("auto");
789
- }
790
- this.renderCanvas();
791
- }
792
- center() {
793
- this.renderCanvas();
794
- }
795
- reset() {
796
- this.renderCanvas();
797
- }
798
- // Export functionality
799
- exportImageData() {
800
- if (!this.canvas) return null;
801
- try {
802
- return this.canvas.toDataURL("image/png");
803
- } catch (error) {
804
- console.error("Failed to export image data:", error);
805
- return null;
806
- }
807
- }
808
- exportImageBlob(quality = 0.9) {
809
- if (!this.canvas) return Promise.resolve(null);
810
- return new Promise((resolve) => {
811
- try {
812
- this.canvas.toBlob((blob) => {
813
- resolve(blob);
814
- }, "image/png", quality);
815
- } catch (error) {
816
- console.error("Failed to export image blob:", error);
817
- resolve(null);
818
- }
819
- });
820
- }
821
- // Public API
822
- setImage(imageUrl, alt = "", title = "") {
823
- const oldImageUrl = this.imageUrl;
824
- this.alt = alt;
825
- this.title = title;
826
- this.loadImage(imageUrl);
827
- const eventBus = this.getApp()?.events;
828
- if (eventBus) {
829
- eventBus.emit("imagecanvas:image-changed", {
830
- view: this,
831
- oldImageUrl,
832
- newImageUrl: imageUrl
833
- });
834
- }
835
- }
836
- getImageData() {
837
- return {
838
- imageUrl: this.imageUrl,
839
- alt: this.alt,
840
- title: this.title,
841
- naturalWidth: this.image?.naturalWidth || 0,
842
- naturalHeight: this.image?.naturalHeight || 0,
843
- isLoaded: this.isLoaded
844
- };
845
- }
846
- async onBeforeDestroy() {
847
- this.isLoaded = false;
848
- this.isRendering = false;
849
- this.image = null;
850
- if (this._resizeHandler) {
851
- window.removeEventListener("resize", this._resizeHandler);
852
- }
853
- const eventBus = this.getApp()?.events;
854
- if (eventBus) {
855
- eventBus.emit("imagecanvas:destroyed", { view: this });
856
- }
857
- }
858
- }
859
- window.ImageCanvasView = ImageCanvasView;
860
- class ImageTransformView extends ImageCanvasView {
861
- constructor(options = {}) {
862
- super({
863
- ...options,
864
- className: `image-transform-view ${options.className || ""}`
865
- });
866
- this.scale = 1;
867
- this.rotation = 0;
868
- this.translateX = 0;
869
- this.translateY = 0;
870
- this.minScale = 0.1;
871
- this.maxScale = 5;
872
- this.scaleStep = 0.02;
873
- this.isDragging = false;
874
- this.lastPointerX = 0;
875
- this.lastPointerY = 0;
876
- this.allowPan = options.allowPan !== false;
877
- this.allowZoom = options.allowZoom !== false;
878
- this.allowRotate = options.allowRotate !== false;
879
- this.allowKeyboard = options.allowKeyboard !== false;
880
- this._handleMouseMove = this.handleMouseMove.bind(this);
881
- this._handleMouseUp = this.handleMouseUp.bind(this);
882
- this._handleKeyboard = this.handleKeyboard.bind(this);
883
- if (!options.maxCanvasHeightPercent) {
884
- this.maxCanvasHeightPercent = 0.6;
885
- }
886
- }
887
- async getTemplate() {
888
- return `
889
- <div class="image-transform-container d-flex flex-column h-100">
890
- <!-- Transform Toolbar -->
891
- <div class="image-transform-toolbar bg-light border-bottom p-2">
892
- <div class="btn-toolbar justify-content-center" role="toolbar">
893
- <div class="btn-group me-2" role="group" aria-label="Zoom controls">
894
- <button type="button" class="btn btn-outline-primary btn-sm" data-action="zoom-in" title="Zoom In">
895
- <i class="bi bi-zoom-in"></i>
896
- </button>
897
- <button type="button" class="btn btn-outline-primary btn-sm" data-action="zoom-out" title="Zoom Out">
898
- <i class="bi bi-zoom-out"></i>
899
- </button>
900
- <button type="button" class="btn btn-outline-secondary btn-sm" data-action="fit-to-screen" title="Fit to Screen">
901
- <i class="bi bi-arrows-fullscreen"></i>
902
- </button>
903
- <button type="button" class="btn btn-outline-secondary btn-sm" data-action="actual-size" title="Actual Size">
904
- <i class="bi bi-1-square"></i>
905
- </button>
906
- </div>
907
-
908
- <div class="btn-group me-2" role="group" aria-label="Rotate controls">
909
- <button type="button" class="btn btn-outline-info btn-sm" data-action="rotate-left" title="Rotate Left">
910
- <i class="bi bi-arrow-counterclockwise"></i>
911
- </button>
912
- <button type="button" class="btn btn-outline-info btn-sm" data-action="rotate-right" title="Rotate Right">
913
- <i class="bi bi-arrow-clockwise"></i>
914
- </button>
915
- </div>
916
-
917
- <div class="btn-group" role="group" aria-label="Position controls">
918
- <button type="button" class="btn btn-outline-secondary btn-sm" data-action="center-image" title="Center Image">
919
- <i class="bi bi-bullseye"></i>
920
- </button>
921
- </div>
922
- </div>
923
- </div>
924
-
925
- <!-- Canvas Area -->
926
- <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">
927
- <canvas class="image-canvas" data-container="canvas"></canvas>
928
-
929
- <!-- Loading Overlay -->
930
- <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"
931
- style="display: none; z-index: 10;">
932
- <div class="spinner-border text-primary" role="status">
933
- <span class="visually-hidden">Loading...</span>
934
- </div>
935
- </div>
936
- </div>
937
- </div>
938
- `;
939
- }
940
- async onAfterRender() {
941
- await super.onAfterRender();
942
- this.setupInteractionListeners();
943
- }
944
- setupInteractionListeners() {
945
- if (!this.canvas) return;
946
- if (this.allowPan) {
947
- this.canvas.addEventListener("mousedown", (e) => this.handleMouseDown(e));
948
- document.addEventListener("mousemove", this._handleMouseMove);
949
- document.addEventListener("mouseup", this._handleMouseUp);
950
- }
951
- if (this.allowZoom) {
952
- this.canvas.addEventListener("wheel", (e) => this.handleWheel(e), { passive: false });
953
- }
954
- this.canvas.addEventListener("touchstart", (e) => this.handleTouchStart(e), { passive: false });
955
- this.canvas.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false });
956
- this.canvas.addEventListener("touchend", (e) => this.handleTouchEnd(e));
957
- if (this.allowKeyboard) {
958
- document.addEventListener("keydown", this._handleKeyboard);
959
- }
960
- this.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
961
- this.canvas.style.cursor = this.allowPan ? "grab" : "default";
962
- }
963
- // Override renderImage to apply transforms
964
- renderImage() {
965
- if (!this.image) return;
966
- this.context.translate(
967
- this.canvasWidth / 2 + this.translateX,
968
- this.canvasHeight / 2 + this.translateY
969
- );
970
- this.context.scale(this.scale, this.scale);
971
- this.context.rotate(this.rotation * Math.PI / 180);
972
- this.context.drawImage(
973
- this.image,
974
- -this.image.naturalWidth / 2,
975
- -this.image.naturalHeight / 2
976
- );
977
- }
978
- // Mouse interaction
979
- handleMouseDown(e) {
980
- if (!this.allowPan || e.button !== 0) return;
981
- e.preventDefault();
982
- this.isDragging = true;
983
- const rect = this.canvas.getBoundingClientRect();
984
- this.lastPointerX = e.clientX - rect.left;
985
- this.lastPointerY = e.clientY - rect.top;
986
- this.canvas.style.cursor = "grabbing";
987
- }
988
- handleMouseMove(e) {
989
- if (!this.isDragging || !this.allowPan) return;
990
- e.preventDefault();
991
- const rect = this.canvas.getBoundingClientRect();
992
- const currentX = e.clientX - rect.left;
993
- const currentY = e.clientY - rect.top;
994
- const deltaX = currentX - this.lastPointerX;
995
- const deltaY = currentY - this.lastPointerY;
996
- this.pan(deltaX, deltaY);
997
- this.lastPointerX = currentX;
998
- this.lastPointerY = currentY;
999
- }
1000
- handleMouseUp(e) {
1001
- if (!this.isDragging) return;
1002
- this.isDragging = false;
1003
- this.canvas.style.cursor = this.allowPan ? "grab" : "default";
1004
- }
1005
- handleWheel(e) {
1006
- if (!this.allowZoom) return;
1007
- e.preventDefault();
1008
- const rect = this.canvas.getBoundingClientRect();
1009
- const x = e.clientX - rect.left;
1010
- const y = e.clientY - rect.top;
1011
- const delta = e.deltaY > 0 ? -this.scaleStep * 0.5 : this.scaleStep * 0.5;
1012
- this.zoomAtPoint(this.scale + delta, x, y);
1013
- }
1014
- // Touch events
1015
- handleTouchStart(e) {
1016
- if (e.touches.length === 1 && this.allowPan) {
1017
- e.preventDefault();
1018
- const touch = e.touches[0];
1019
- const rect = this.canvas.getBoundingClientRect();
1020
- this.isDragging = true;
1021
- this.lastPointerX = touch.clientX - rect.left;
1022
- this.lastPointerY = touch.clientY - rect.top;
1023
- }
1024
- }
1025
- handleTouchMove(e) {
1026
- if (e.touches.length === 1 && this.isDragging && this.allowPan) {
1027
- e.preventDefault();
1028
- const touch = e.touches[0];
1029
- const rect = this.canvas.getBoundingClientRect();
1030
- const currentX = touch.clientX - rect.left;
1031
- const currentY = touch.clientY - rect.top;
1032
- const deltaX = currentX - this.lastPointerX;
1033
- const deltaY = currentY - this.lastPointerY;
1034
- this.pan(deltaX, deltaY);
1035
- this.lastPointerX = currentX;
1036
- this.lastPointerY = currentY;
1037
- }
1038
- }
1039
- handleTouchEnd(e) {
1040
- this.isDragging = false;
1041
- }
1042
- // Keyboard shortcuts
1043
- handleKeyboard(e) {
1044
- if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
1045
- switch (e.key) {
1046
- case "+":
1047
- case "=":
1048
- if (this.allowZoom) {
1049
- e.preventDefault();
1050
- this.zoomIn();
1051
- }
1052
- break;
1053
- case "-":
1054
- if (this.allowZoom) {
1055
- e.preventDefault();
1056
- this.zoomOut();
1057
- }
1058
- break;
1059
- case "0":
1060
- e.preventDefault();
1061
- this.fitToContainer();
1062
- break;
1063
- case "1":
1064
- e.preventDefault();
1065
- this.actualSize();
1066
- break;
1067
- case "r":
1068
- case "R":
1069
- if (this.allowRotate) {
1070
- e.preventDefault();
1071
- this.rotateRight();
1072
- }
1073
- break;
1074
- }
1075
- }
1076
- // Transform methods
1077
- zoomIn() {
1078
- this.setScale(this.scale + this.scaleStep);
1079
- }
1080
- zoomOut() {
1081
- this.setScale(this.scale - this.scaleStep);
1082
- }
1083
- setScale(scale) {
1084
- const oldScale = this.scale;
1085
- this.scale = Math.max(this.minScale, Math.min(this.maxScale, scale));
1086
- if (oldScale !== this.scale) {
1087
- this.renderCanvas();
1088
- this.emitTransformEvent("scale-changed", { oldScale, newScale: this.scale });
1089
- }
1090
- }
1091
- zoomAtPoint(scale, x, y) {
1092
- if (!this.image) return;
1093
- const oldScale = this.scale;
1094
- this.setScale(scale);
1095
- if (oldScale !== this.scale) {
1096
- const scaleDiff = this.scale / oldScale;
1097
- const centerX = this.canvasWidth / 2;
1098
- const centerY = this.canvasHeight / 2;
1099
- this.translateX = (this.translateX - (x - centerX)) * scaleDiff + (x - centerX);
1100
- this.translateY = (this.translateY - (y - centerY)) * scaleDiff + (y - centerY);
1101
- this.renderCanvas();
1102
- }
1103
- }
1104
- pan(deltaX, deltaY) {
1105
- this.translateX += deltaX;
1106
- this.translateY += deltaY;
1107
- this.renderCanvas();
1108
- this.emitTransformEvent("panned", { deltaX, deltaY });
1109
- }
1110
- rotate(degrees) {
1111
- const oldRotation = this.rotation;
1112
- this.rotation = (this.rotation + degrees) % 360;
1113
- if (this.rotation < 0) this.rotation += 360;
1114
- this.renderCanvas();
1115
- this.emitTransformEvent("rotated", { oldRotation, newRotation: this.rotation, degrees });
1116
- }
1117
- rotateLeft() {
1118
- this.rotate(-90);
1119
- }
1120
- rotateRight() {
1121
- this.rotate(90);
1122
- }
1123
- center() {
1124
- this.translateX = 0;
1125
- this.translateY = 0;
1126
- this.renderCanvas();
1127
- this.emitTransformEvent("centered");
1128
- }
1129
- actualSize() {
1130
- this.setScale(1);
1131
- this.center();
1132
- }
1133
- // Override fitToContainer with actual scaling logic
1134
- fitToContainer() {
1135
- if (!this.image || !this.canvasWidth || !this.canvasHeight) return;
1136
- const padding = 40;
1137
- const availableWidth = this.canvasWidth - padding;
1138
- const availableHeight = this.canvasHeight - padding;
1139
- const scaleX = availableWidth / this.image.naturalWidth;
1140
- const scaleY = availableHeight / this.image.naturalHeight;
1141
- const scale = Math.min(scaleX, scaleY, 1);
1142
- this.setScale(scale);
1143
- this.center();
1144
- }
1145
- smartFit() {
1146
- if (!this.image || !this.canvasWidth || !this.canvasHeight) return;
1147
- const padding = 80;
1148
- const scaleX = (this.canvasWidth - padding) / this.image.naturalWidth;
1149
- const scaleY = (this.canvasHeight - padding) / this.image.naturalHeight;
1150
- const fitScale = Math.min(scaleX, scaleY);
1151
- if (fitScale < 1) {
1152
- this.setScale(fitScale);
1153
- }
1154
- this.center();
1155
- }
1156
- // Override reset with transform-specific logic
1157
- reset() {
1158
- this.scale = 1;
1159
- this.rotation = 0;
1160
- this.translateX = 0;
1161
- this.translateY = 0;
1162
- this.renderCanvas();
1163
- this.emitTransformEvent("reset");
1164
- }
1165
- // Override handleImageLoad to apply initial transforms
1166
- handleImageLoad() {
1167
- super.handleImageLoad();
1168
- if (this.autoFit) {
1169
- this.fitToContainer();
1170
- } else {
1171
- this.smartFit();
1172
- }
1173
- }
1174
- // State management
1175
- getTransformState() {
1176
- return {
1177
- scale: this.scale,
1178
- rotation: this.rotation,
1179
- translateX: this.translateX,
1180
- translateY: this.translateY
1181
- };
1182
- }
1183
- setTransformState(state) {
1184
- if (state.scale !== void 0) this.scale = state.scale;
1185
- if (state.rotation !== void 0) this.rotation = state.rotation;
1186
- if (state.translateX !== void 0) this.translateX = state.translateX;
1187
- if (state.translateY !== void 0) this.translateY = state.translateY;
1188
- this.renderCanvas();
1189
- }
1190
- // Event emission
1191
- emitTransformEvent(type, data = {}) {
1192
- const eventBus = this.getApp()?.events;
1193
- if (eventBus) {
1194
- eventBus.emit(`imagetransform:${type}`, {
1195
- view: this,
1196
- transform: this.getTransformState(),
1197
- ...data
1198
- });
1199
- }
1200
- }
1201
- // Action handlers for toolbar buttons
1202
- async handleActionZoomIn() {
1203
- this.zoomIn();
1204
- }
1205
- async handleActionZoomOut() {
1206
- this.zoomOut();
1207
- }
1208
- async handleActionFitToScreen() {
1209
- this.fitToContainer();
1210
- }
1211
- async handleActionActualSize() {
1212
- this.actualSize();
1213
- }
1214
- async handleActionRotateLeft() {
1215
- this.rotateLeft();
1216
- }
1217
- async handleActionRotateRight() {
1218
- this.rotateRight();
1219
- }
1220
- async handleActionCenterImage() {
1221
- this.center();
1222
- }
1223
- // Cleanup
1224
- async onBeforeDestroy() {
1225
- await super.onBeforeDestroy();
1226
- if (this.isDragging) {
1227
- this.isDragging = false;
1228
- }
1229
- document.removeEventListener("mousemove", this._handleMouseMove);
1230
- document.removeEventListener("mouseup", this._handleMouseUp);
1231
- document.removeEventListener("keydown", this._handleKeyboard);
1232
- this.emitTransformEvent("destroyed");
1233
- }
1234
- // Static method to show transform view in a dialog for standalone testing
1235
- static async showDialog(imageUrl, options = {}) {
1236
- const {
1237
- title = "Transform Image",
1238
- alt = "Image",
1239
- size = "xl",
1240
- allowPan = true,
1241
- allowZoom = true,
1242
- allowRotate = true,
1243
- ...dialogOptions
1244
- } = options;
1245
- const transformView = new ImageTransformView({
1246
- imageUrl,
1247
- alt,
1248
- title,
1249
- allowPan,
1250
- allowZoom,
1251
- allowRotate
1252
- });
1253
- const dialog = new Dialog({
1254
- title,
1255
- body: transformView,
1256
- size,
1257
- centered: true,
1258
- backdrop: "static",
1259
- keyboard: true,
1260
- noBodyPadding: true,
1261
- maxCanvasHeightPercent: 0.5,
1262
- buttons: [
1263
- {
1264
- text: "Cancel",
1265
- action: "cancel",
1266
- class: "btn btn-secondary",
1267
- dismiss: true
1268
- },
1269
- {
1270
- text: "Apply Transform",
1271
- action: "apply-transform",
1272
- class: "btn btn-primary"
1273
- }
1274
- ],
1275
- ...dialogOptions
1276
- });
1277
- await dialog.render(true, document.body);
1278
- dialog.show();
1279
- return new Promise((resolve) => {
1280
- dialog.on("hidden", () => {
1281
- dialog.destroy();
1282
- resolve({ action: "cancel", view: transformView });
1283
- });
1284
- dialog.on("action:cancel", () => {
1285
- dialog.hide();
1286
- });
1287
- dialog.on("action:apply-transform", async () => {
1288
- const imageData = transformView.exportImageData();
1289
- dialog.hide();
1290
- resolve({
1291
- action: "transform",
1292
- view: transformView,
1293
- data: imageData,
1294
- transformState: transformView.getTransformState()
1295
- });
1296
- });
1297
- });
1298
- }
1299
- }
1300
- window.ImageTransformView = Image;
1301
- class ImageCropView extends ImageCanvasView {
1302
- constructor(options = {}) {
1303
- super({
1304
- ...options,
1305
- className: `image-crop-view ${options.className || ""}`
1306
- });
1307
- this.originalImageUrl = options.imageUrl;
1308
- this.cropMode = false;
1309
- this.cropBox = { x: 0, y: 0, width: 0, height: 0 };
1310
- this.aspectRatio = options.aspectRatio || null;
1311
- this.minCropSize = options.minCropSize || 50;
1312
- this.fixedCropSize = options.fixedCropSize || null;
1313
- this.cropAndScale = options.cropAndScale || null;
1314
- this.isDragging = false;
1315
- this.isResizing = false;
1316
- this.dragHandle = null;
1317
- this.dragStartImageX = 0;
1318
- this.dragStartImageY = 0;
1319
- this.initialCropBox = null;
1320
- this.newCropStart = null;
1321
- this.handles = {
1322
- "nw": { cursor: "nw-resize", x: 0, y: 0 },
1323
- "ne": { cursor: "ne-resize", x: 1, y: 0 },
1324
- "sw": { cursor: "sw-resize", x: 0, y: 1 },
1325
- "se": { cursor: "se-resize", x: 1, y: 1 },
1326
- "n": { cursor: "n-resize", x: 0.5, y: 0 },
1327
- "s": { cursor: "s-resize", x: 0.5, y: 1 },
1328
- "w": { cursor: "w-resize", x: 0, y: 0.5 },
1329
- "e": { cursor: "e-resize", x: 1, y: 0.5 }
1330
- };
1331
- this.handleSize = options.handleSize || 12;
1332
- this.showGrid = options.showGrid !== false;
1333
- this.showToolbar = options.showToolbar !== false;
1334
- this.autoFit = options.autoFit !== false;
1335
- this.imageOffsetX = 0;
1336
- this.imageOffsetY = 0;
1337
- this._handleMouseMove = this.handleMouseMove.bind(this);
1338
- this._handleMouseUp = this.handleMouseUp.bind(this);
1339
- if (!options.maxCanvasHeightPercent && this.showToolbar) {
1340
- this.maxCanvasHeightPercent = 0.6;
1341
- }
1342
- }
1343
- // Coordinate conversion helpers
1344
- imageToCanvas(imageCoords) {
1345
- if (!this.image) return imageCoords;
1346
- const scaleX = this.canvasWidth / this.image.naturalWidth;
1347
- const scaleY = this.canvasHeight / this.image.naturalHeight;
1348
- let imageScale;
1349
- if (this.autoFit) {
1350
- imageScale = Math.min(scaleX, scaleY, 1);
1351
- } else {
1352
- imageScale = 1;
1353
- }
1354
- const scaledImageWidth = this.image.naturalWidth * imageScale;
1355
- const scaledImageHeight = this.image.naturalHeight * imageScale;
1356
- const imageX = (this.canvasWidth - scaledImageWidth) / 2;
1357
- const imageY = (this.canvasHeight - scaledImageHeight) / 2;
1358
- return {
1359
- x: imageCoords.x * imageScale + imageX,
1360
- y: imageCoords.y * imageScale + imageY,
1361
- width: imageCoords.width * imageScale,
1362
- height: imageCoords.height * imageScale
1363
- };
1364
- }
1365
- canvasToImage(canvasCoords) {
1366
- if (!this.image) return canvasCoords;
1367
- const scaleX = this.canvasWidth / this.image.naturalWidth;
1368
- const scaleY = this.canvasHeight / this.image.naturalHeight;
1369
- let imageScale;
1370
- if (this.autoFit) {
1371
- imageScale = Math.min(scaleX, scaleY, 1);
1372
- } else {
1373
- imageScale = 1;
1374
- }
1375
- const scaledImageWidth = this.image.naturalWidth * imageScale;
1376
- const scaledImageHeight = this.image.naturalHeight * imageScale;
1377
- const imageX = (this.canvasWidth - scaledImageWidth) / 2;
1378
- const imageY = (this.canvasHeight - scaledImageHeight) / 2;
1379
- return {
1380
- x: (canvasCoords.x - imageX) / imageScale,
1381
- y: (canvasCoords.y - imageY) / imageScale,
1382
- width: canvasCoords.width / imageScale,
1383
- height: canvasCoords.height / imageScale
1384
- };
1385
- }
1386
- pointCanvasToImage(canvasX, canvasY) {
1387
- const result = this.canvasToImage({ x: canvasX, y: canvasY, width: 0, height: 0 });
1388
- return { x: result.x, y: result.y };
1389
- }
1390
- async getTemplate() {
1391
- return `
1392
- <div class="image-crop-container d-flex flex-column h-100">
1393
- {{#showToolbar}}
1394
- <!-- Crop Toolbar -->
1395
- <div class="image-crop-toolbar bg-light border-bottom p-2">
1396
- <div class="btn-toolbar justify-content-center" role="toolbar">
1397
- <div class="btn-group me-2" role="group" aria-label="Aspect ratio">
1398
- <button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
1399
- data-bs-toggle="dropdown" title="Aspect Ratio">
1400
- <i class="bi bi-aspect-ratio"></i> Ratio
1401
- </button>
1402
- <ul class="dropdown-menu">
1403
- <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="free">Free</a></li>
1404
- <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="1">1:1 Square</a></li>
1405
- <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="1.333">4:3</a></li>
1406
- <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="1.777">16:9</a></li>
1407
- <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="0.75">3:4 Portrait</a></li>
1408
- </ul>
1409
- </div>
1410
-
1411
- <div class="btn-group me-2" role="group" aria-label="Fit mode">
1412
- <button type="button" class="btn btn-outline-info btn-sm" data-action="toggle-auto-fit" title="Toggle Auto-fit">
1413
- <i class="bi bi-arrows-fullscreen"></i> <span class="auto-fit-text">Fit</span>
1414
- </button>
1415
- </div>
1416
-
1417
- <div class="btn-group me-2" role="group" aria-label="Crop actions">
1418
- <button type="button" class="btn btn-success btn-sm" data-action="apply-crop" title="Apply Crop">
1419
- <i class="bi bi-check"></i> Apply
1420
- </button>
1421
- <button type="button" class="btn btn-outline-secondary btn-sm" data-action="reset-crop" title="Reset Crop">
1422
- <i class="bi bi-arrow-repeat"></i> Reset
1423
- </button>
1424
- </div>
1425
- </div>
1426
- </div>
1427
- {{/showToolbar}}
1428
-
1429
- <!-- Canvas Area -->
1430
- <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">
1431
- <canvas class="image-crop-canvas" data-container="canvas"></canvas>
1432
-
1433
- <!-- Loading Overlay -->
1434
- <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"
1435
- style="display: none; z-index: 10;">
1436
- <div class="spinner-border text-primary" role="status">
1437
- <span class="visually-hidden">Loading...</span>
1438
- </div>
1439
- </div>
1440
- </div>
1441
- </div>
1442
- `;
1443
- }
1444
- async onAfterRender() {
1445
- await super.onAfterRender();
1446
- this.setupCropListeners();
1447
- this.updateAutoFitButtonState();
1448
- }
1449
- updateAutoFitButtonState() {
1450
- if (!this.showToolbar) return;
1451
- const button = this.element.querySelector('[data-action="toggle-auto-fit"]');
1452
- const textSpan = button?.querySelector(".auto-fit-text");
1453
- if (button && textSpan) {
1454
- if (this.autoFit) {
1455
- button.classList.remove("btn-outline-warning");
1456
- button.classList.add("btn-outline-info");
1457
- button.title = "Toggle Auto-fit (currently: fit to canvas)";
1458
- textSpan.textContent = "Fit";
1459
- } else {
1460
- button.classList.remove("btn-outline-info");
1461
- button.classList.add("btn-outline-warning");
1462
- button.title = "Toggle Auto-fit (currently: actual size)";
1463
- textSpan.textContent = "1:1";
1464
- }
1465
- }
1466
- }
1467
- handleImageLoad() {
1468
- super.handleImageLoad();
1469
- this.updateImageOffset();
1470
- setTimeout(() => {
1471
- if (this.isLoaded && this.canvasWidth > 0 && this.canvasHeight > 0) {
1472
- this.startCropMode();
1473
- }
1474
- }, 10);
1475
- }
1476
- updateImageOffset() {
1477
- if (!this.image) return;
1478
- const scaleX = this.canvasWidth / this.image.naturalWidth;
1479
- const scaleY = this.canvasHeight / this.image.naturalHeight;
1480
- let scale;
1481
- if (this.autoFit) {
1482
- scale = Math.min(scaleX, scaleY, 1);
1483
- } else {
1484
- scale = 1;
1485
- }
1486
- const scaledWidth = this.image.naturalWidth * scale;
1487
- const scaledHeight = this.image.naturalHeight * scale;
1488
- this.imageOffsetX = (this.canvasWidth - scaledWidth) / 2;
1489
- this.imageOffsetY = (this.canvasHeight - scaledHeight) / 2;
1490
- this.imageScale = scale;
1491
- console.log("Updated image offset:", this.imageOffsetX, this.imageOffsetY, "scale:", this.imageScale, "autoFit:", this.autoFit);
1492
- }
1493
- // Override setCanvasSize to update image offset when canvas is resized
1494
- setCanvasSize(size) {
1495
- super.setCanvasSize(size);
1496
- if (this.image && this.isLoaded) {
1497
- this.updateImageOffset();
1498
- }
1499
- }
1500
- // Override renderImage to scale and center the image (consistent with coordinate conversion)
1501
- renderImage() {
1502
- if (!this.image) return;
1503
- const scaleX = this.canvasWidth / this.image.naturalWidth;
1504
- const scaleY = this.canvasHeight / this.image.naturalHeight;
1505
- let scale;
1506
- if (this.autoFit) {
1507
- scale = Math.min(scaleX, scaleY, 1);
1508
- } else {
1509
- scale = 1;
1510
- }
1511
- const scaledWidth = this.image.naturalWidth * scale;
1512
- const scaledHeight = this.image.naturalHeight * scale;
1513
- const x = (this.canvasWidth - scaledWidth) / 2;
1514
- const y = (this.canvasHeight - scaledHeight) / 2;
1515
- this.context.drawImage(this.image, x, y, scaledWidth, scaledHeight);
1516
- }
1517
- setupCropListeners() {
1518
- if (!this.canvas) return;
1519
- this.canvas.addEventListener("mousedown", (e) => this.handleMouseDown(e));
1520
- document.addEventListener("mousemove", this._handleMouseMove);
1521
- document.addEventListener("mouseup", this._handleMouseUp);
1522
- this.canvas.addEventListener("touchstart", (e) => this.handleTouchStart(e), { passive: false });
1523
- this.canvas.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false });
1524
- this.canvas.addEventListener("touchend", (e) => this.handleTouchEnd(e));
1525
- this.canvas.style.cursor = "crosshair";
1526
- }
1527
- // Override renderCanvas to include crop overlay
1528
- renderCanvas() {
1529
- super.renderCanvas();
1530
- if (this.cropMode) {
1531
- this.renderCropOverlay();
1532
- }
1533
- }
1534
- renderCropOverlay() {
1535
- if (!this.cropMode || !this.cropBox) return;
1536
- const canvasBox = this.imageToCanvas(this.cropBox);
1537
- this.context.save();
1538
- this.context.globalAlpha = 0.5;
1539
- this.context.fillStyle = "#000000";
1540
- this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
1541
- this.context.globalCompositeOperation = "destination-out";
1542
- this.context.fillRect(
1543
- canvasBox.x,
1544
- canvasBox.y,
1545
- canvasBox.width,
1546
- canvasBox.height
1547
- );
1548
- this.context.globalCompositeOperation = "source-over";
1549
- this.context.globalAlpha = 1;
1550
- this.context.strokeStyle = "rgba(255, 255, 255, 0.9)";
1551
- this.context.lineWidth = 2;
1552
- this.context.strokeRect(
1553
- canvasBox.x,
1554
- canvasBox.y,
1555
- canvasBox.width,
1556
- canvasBox.height
1557
- );
1558
- if (this.showGrid) {
1559
- this.drawGrid();
1560
- }
1561
- this.drawHandles();
1562
- this.context.restore();
1563
- }
1564
- // Override parent's exportImageBlob to export only the cropped area without overlay
1565
- exportImageBlob(quality = 0.9) {
1566
- if (!this.canvas || !this.image || !this.isLoaded || !this.cropMode) {
1567
- return super.exportImageBlob(quality);
1568
- }
1569
- return new Promise((resolve) => {
1570
- try {
1571
- console.log("[ImageCropView] Exporting cropped image without overlay");
1572
- console.log("[ImageCropView] Crop box:", this.cropBox);
1573
- const cropArea = {
1574
- x: Math.max(0, Math.min(this.cropBox.x, this.image.naturalWidth)),
1575
- y: Math.max(0, Math.min(this.cropBox.y, this.image.naturalHeight)),
1576
- width: Math.min(this.cropBox.width, this.image.naturalWidth - this.cropBox.x),
1577
- height: Math.min(this.cropBox.height, this.image.naturalHeight - this.cropBox.y)
1578
- };
1579
- console.log("[ImageCropView] Crop area in image coords:", cropArea);
1580
- let outputWidth = cropArea.width;
1581
- let outputHeight = cropArea.height;
1582
- if (this.cropAndScale) {
1583
- outputWidth = this.cropAndScale.width;
1584
- outputHeight = this.cropAndScale.height;
1585
- console.log("[ImageCropView] Scaling to:", outputWidth, "x", outputHeight);
1586
- }
1587
- const tempCanvas = document.createElement("canvas");
1588
- tempCanvas.width = outputWidth;
1589
- tempCanvas.height = outputHeight;
1590
- const tempContext = tempCanvas.getContext("2d");
1591
- tempContext.drawImage(
1592
- this.image,
1593
- cropArea.x,
1594
- cropArea.y,
1595
- cropArea.width,
1596
- cropArea.height,
1597
- // Source rectangle
1598
- 0,
1599
- 0,
1600
- outputWidth,
1601
- outputHeight
1602
- // Destination rectangle
1603
- );
1604
- tempCanvas.toBlob((blob) => {
1605
- console.log("[ImageCropView] Successfully exported cropped image blob:", blob?.size, "bytes");
1606
- resolve(blob);
1607
- }, "image/png", quality);
1608
- } catch (error) {
1609
- console.error("Failed to export cropped image blob:", error);
1610
- resolve(null);
1611
- }
1612
- });
1613
- }
1614
- drawGrid() {
1615
- const canvasBox = this.imageToCanvas(this.cropBox);
1616
- this.context.globalAlpha = 0.6;
1617
- this.context.strokeStyle = "rgba(255, 255, 255, 0.7)";
1618
- this.context.lineWidth = 1;
1619
- const thirdW = canvasBox.width / 3;
1620
- const thirdH = canvasBox.height / 3;
1621
- for (let i = 1; i < 3; i++) {
1622
- const x = canvasBox.x + thirdW * i;
1623
- this.context.beginPath();
1624
- this.context.moveTo(x, canvasBox.y);
1625
- this.context.lineTo(x, canvasBox.y + canvasBox.height);
1626
- this.context.stroke();
1627
- }
1628
- for (let i = 1; i < 3; i++) {
1629
- const y = canvasBox.y + thirdH * i;
1630
- this.context.beginPath();
1631
- this.context.moveTo(canvasBox.x, y);
1632
- this.context.lineTo(canvasBox.x + canvasBox.width, y);
1633
- this.context.stroke();
1634
- }
1635
- }
1636
- drawHandles() {
1637
- if (this.fixedCropSize) return;
1638
- const canvasBox = this.imageToCanvas(this.cropBox);
1639
- this.context.globalAlpha = 1;
1640
- this.context.fillStyle = "#ffffff";
1641
- this.context.strokeStyle = "#000000";
1642
- this.context.lineWidth = 1;
1643
- Object.keys(this.handles).forEach((handleName) => {
1644
- const handle = this.handles[handleName];
1645
- const centerX = canvasBox.x + canvasBox.width * handle.x;
1646
- const centerY = canvasBox.y + canvasBox.height * handle.y;
1647
- const x = centerX - this.handleSize / 2;
1648
- const y = centerY - this.handleSize / 2;
1649
- this.context.fillRect(x, y, this.handleSize, this.handleSize);
1650
- this.context.strokeRect(x, y, this.handleSize, this.handleSize);
1651
- });
1652
- }
1653
- // Mouse interaction
1654
- handleMouseDown(e) {
1655
- if (!this.cropMode) return;
1656
- e.preventDefault();
1657
- const rect = this.canvas.getBoundingClientRect();
1658
- const canvasX = e.clientX - rect.left;
1659
- const canvasY = e.clientY - rect.top;
1660
- const imagePoint = this.pointCanvasToImage(canvasX, canvasY);
1661
- this.dragStartImageX = imagePoint.x;
1662
- this.dragStartImageY = imagePoint.y;
1663
- this.initialCropBox = { ...this.cropBox };
1664
- if (this.fixedCropSize) {
1665
- if (this.isPointInCropBox(canvasX, canvasY)) {
1666
- this.isDragging = true;
1667
- this.canvas.style.cursor = "move";
1668
- }
1669
- } else {
1670
- const handle = this.getHandleAt(canvasX, canvasY);
1671
- if (handle) {
1672
- this.isResizing = true;
1673
- this.dragHandle = handle;
1674
- this.canvas.style.cursor = this.handles[handle].cursor;
1675
- } else if (this.isPointInCropBox(canvasX, canvasY)) {
1676
- this.isDragging = true;
1677
- this.canvas.style.cursor = "move";
1678
- } else {
1679
- this.startNewCrop(imagePoint.x, imagePoint.y);
1680
- }
1681
- }
1682
- }
1683
- handleMouseMove(e) {
1684
- if (!this.cropMode) return;
1685
- const rect = this.canvas.getBoundingClientRect();
1686
- const canvasX = e.clientX - rect.left;
1687
- const canvasY = e.clientY - rect.top;
1688
- if (this.isResizing && this.dragHandle) {
1689
- this.resizeCropBox(canvasX, canvasY);
1690
- } else if (this.isDragging) {
1691
- this.moveCropBox(canvasX, canvasY);
1692
- } else if (!this.isDragging && !this.isResizing) {
1693
- this.updateCursor(canvasX, canvasY);
1694
- }
1695
- }
1696
- handleMouseUp(e) {
1697
- if (!this.cropMode) return;
1698
- this.isDragging = false;
1699
- this.isResizing = false;
1700
- this.dragHandle = null;
1701
- this.initialCropBox = null;
1702
- this.newCropStart = null;
1703
- const rect = this.canvas.getBoundingClientRect();
1704
- const canvasX = e.clientX - rect.left;
1705
- const canvasY = e.clientY - rect.top;
1706
- this.updateCursor(canvasX, canvasY);
1707
- }
1708
- // Touch events
1709
- handleTouchStart(e) {
1710
- if (!this.cropMode || e.touches.length !== 1) return;
1711
- e.preventDefault();
1712
- const touch = e.touches[0];
1713
- const rect = this.canvas.getBoundingClientRect();
1714
- touch.clientX - rect.left;
1715
- touch.clientY - rect.top;
1716
- this.handleMouseDown({ clientX: touch.clientX, clientY: touch.clientY, preventDefault: () => {
1717
- } });
1718
- }
1719
- handleTouchMove(_e) {
1720
- if (!this.cropMode || _e.touches.length !== 1) return;
1721
- _e.preventDefault();
1722
- const touch = _e.touches[0];
1723
- this.handleMouseMove({ clientX: touch.clientX, clientY: touch.clientY });
1724
- }
1725
- handleTouchEnd(_e) {
1726
- if (!this.cropMode) return;
1727
- this.handleMouseUp({});
1728
- }
1729
- // Crop utility methods
1730
- getHandleAt(canvasX, canvasY) {
1731
- const hitAreaPadding = 4;
1732
- const canvasBox = this.imageToCanvas(this.cropBox);
1733
- for (const [handleName, handle] of Object.entries(this.handles)) {
1734
- const handleCenterX = canvasBox.x + canvasBox.width * handle.x;
1735
- const handleCenterY = canvasBox.y + canvasBox.height * handle.y;
1736
- const hitAreaSize = this.handleSize + hitAreaPadding;
1737
- const handleX = handleCenterX - hitAreaSize / 2;
1738
- const handleY = handleCenterY - hitAreaSize / 2;
1739
- if (canvasX >= handleX && canvasX <= handleX + hitAreaSize && canvasY >= handleY && canvasY <= handleY + hitAreaSize) {
1740
- return handleName;
1741
- }
1742
- }
1743
- return null;
1744
- }
1745
- isPointInCropBox(canvasX, canvasY) {
1746
- const imagePoint = this.pointCanvasToImage(canvasX, canvasY);
1747
- return imagePoint.x >= this.cropBox.x && imagePoint.x <= this.cropBox.x + this.cropBox.width && imagePoint.y >= this.cropBox.y && imagePoint.y <= this.cropBox.y + this.cropBox.height;
1748
- }
1749
- updateCursor(canvasX, canvasY) {
1750
- if (!this.cropMode) return;
1751
- const imageX = canvasX;
1752
- const imageY = canvasY;
1753
- if (this.fixedCropSize) {
1754
- if (this.isPointInCropBox(imageX, imageY)) {
1755
- this.canvas.style.cursor = "move";
1756
- } else {
1757
- this.canvas.style.cursor = "default";
1758
- }
1759
- } else {
1760
- const handle = this.getHandleAt(canvasX, canvasY);
1761
- if (handle) {
1762
- this.canvas.style.cursor = this.handles[handle].cursor;
1763
- } else if (this.isPointInCropBox(imageX, imageY)) {
1764
- this.canvas.style.cursor = "move";
1765
- } else {
1766
- this.canvas.style.cursor = "crosshair";
1767
- }
1768
- }
1769
- }
1770
- startNewCrop(x, y) {
1771
- this.newCropStart = { x, y };
1772
- this.cropBox = {
1773
- x,
1774
- y,
1775
- width: 0,
1776
- height: 0
1777
- };
1778
- this.isResizing = true;
1779
- this.dragHandle = "se";
1780
- }
1781
- resizeCropBox(canvasX, canvasY) {
1782
- if (!this.dragHandle) return;
1783
- const imagePoint = this.pointCanvasToImage(canvasX, canvasY);
1784
- if (this.newCropStart) {
1785
- const startX = this.newCropStart.x;
1786
- const startY = this.newCropStart.y;
1787
- this.cropBox = {
1788
- x: Math.min(startX, imagePoint.x),
1789
- y: Math.min(startY, imagePoint.y),
1790
- width: Math.abs(imagePoint.x - startX),
1791
- height: Math.abs(imagePoint.y - startY)
1792
- };
1793
- if (this.aspectRatio) {
1794
- this.constrainToAspectRatio(this.cropBox, "se");
1795
- }
1796
- if (this.cropBox.width < this.minCropSize) {
1797
- this.cropBox.width = this.minCropSize;
1798
- }
1799
- if (this.cropBox.height < this.minCropSize) {
1800
- this.cropBox.height = this.minCropSize;
1801
- }
1802
- this.constrainCropBox(this.cropBox);
1803
- return;
1804
- }
1805
- if (!this.initialCropBox) return;
1806
- const deltaX = imagePoint.x - this.dragStartImageX;
1807
- const deltaY = imagePoint.y - this.dragStartImageY;
1808
- let newBox = { ...this.initialCropBox };
1809
- switch (this.dragHandle) {
1810
- case "nw":
1811
- newBox.x += deltaX;
1812
- newBox.y += deltaY;
1813
- newBox.width -= deltaX;
1814
- newBox.height -= deltaY;
1815
- break;
1816
- case "ne":
1817
- newBox.y += deltaY;
1818
- newBox.width += deltaX;
1819
- newBox.height -= deltaY;
1820
- break;
1821
- case "sw":
1822
- newBox.x += deltaX;
1823
- newBox.width -= deltaX;
1824
- newBox.height += deltaY;
1825
- break;
1826
- case "se":
1827
- newBox.width += deltaX;
1828
- newBox.height += deltaY;
1829
- break;
1830
- case "n":
1831
- newBox.y += deltaY;
1832
- newBox.height -= deltaY;
1833
- break;
1834
- case "s":
1835
- newBox.height += deltaY;
1836
- break;
1837
- case "w":
1838
- newBox.x += deltaX;
1839
- newBox.width -= deltaX;
1840
- break;
1841
- case "e":
1842
- newBox.width += deltaX;
1843
- break;
1844
- }
1845
- if (this.aspectRatio) {
1846
- this.constrainToAspectRatio(newBox, this.dragHandle);
1847
- }
1848
- this.constrainCropBox(newBox);
1849
- this.cropBox = newBox;
1850
- this.renderCanvas();
1851
- }
1852
- moveCropBox(canvasX, canvasY) {
1853
- if (!this.initialCropBox) return;
1854
- const imagePoint = this.pointCanvasToImage(canvasX, canvasY);
1855
- const deltaX = imagePoint.x - this.dragStartImageX;
1856
- const deltaY = imagePoint.y - this.dragStartImageY;
1857
- let newBox = {
1858
- x: this.initialCropBox.x + deltaX,
1859
- y: this.initialCropBox.y + deltaY,
1860
- width: this.initialCropBox.width,
1861
- height: this.initialCropBox.height
1862
- };
1863
- if (this.image) {
1864
- newBox.x = Math.max(0, Math.min(this.image.naturalWidth - newBox.width, newBox.x));
1865
- newBox.y = Math.max(0, Math.min(this.image.naturalHeight - newBox.height, newBox.y));
1866
- }
1867
- this.cropBox = newBox;
1868
- this.renderCanvas();
1869
- }
1870
- constrainToAspectRatio(box, handle) {
1871
- let ratio = this.aspectRatio;
1872
- if (this.cropAndScale) {
1873
- ratio = this.cropAndScale.width / this.cropAndScale.height;
1874
- }
1875
- if (!ratio) return;
1876
- let anchorX, anchorY;
1877
- if (["nw", "ne", "sw", "se"].includes(handle)) {
1878
- switch (handle) {
1879
- case "nw":
1880
- anchorX = box.x + box.width;
1881
- anchorY = box.y + box.height;
1882
- break;
1883
- case "ne":
1884
- anchorX = box.x;
1885
- anchorY = box.y + box.height;
1886
- break;
1887
- case "sw":
1888
- anchorX = box.x + box.width;
1889
- anchorY = box.y;
1890
- break;
1891
- case "se":
1892
- anchorX = box.x;
1893
- anchorY = box.y;
1894
- break;
1895
- }
1896
- if (box.width / box.height > ratio) {
1897
- box.width = box.height * ratio;
1898
- } else {
1899
- box.height = box.width / ratio;
1900
- }
1901
- switch (handle) {
1902
- case "nw":
1903
- box.x = anchorX - box.width;
1904
- box.y = anchorY - box.height;
1905
- break;
1906
- case "ne":
1907
- box.x = anchorX;
1908
- box.y = anchorY - box.height;
1909
- break;
1910
- case "sw":
1911
- box.x = anchorX - box.width;
1912
- box.y = anchorY;
1913
- break;
1914
- case "se":
1915
- box.x = anchorX;
1916
- box.y = anchorY;
1917
- break;
1918
- }
1919
- } else if (["n", "s"].includes(handle)) {
1920
- const centerX = box.x + box.width / 2;
1921
- box.width = box.height * ratio;
1922
- box.x = centerX - box.width / 2;
1923
- } else if (["w", "e"].includes(handle)) {
1924
- const centerY = box.y + box.height / 2;
1925
- box.height = box.width / ratio;
1926
- box.y = centerY - box.height / 2;
1927
- }
1928
- }
1929
- constrainCropBox(box) {
1930
- box.width = Math.max(this.minCropSize, box.width);
1931
- box.height = Math.max(this.minCropSize, box.height);
1932
- if (this.image) {
1933
- if (box.x < 0) {
1934
- box.width += box.x;
1935
- box.x = 0;
1936
- }
1937
- if (box.y < 0) {
1938
- box.height += box.y;
1939
- box.y = 0;
1940
- }
1941
- if (box.x + box.width > this.image.naturalWidth) {
1942
- box.width = this.image.naturalWidth - box.x;
1943
- }
1944
- if (box.y + box.height > this.image.naturalHeight) {
1945
- box.height = this.image.naturalHeight - box.y;
1946
- }
1947
- }
1948
- box.width = Math.max(0, box.width);
1949
- box.height = Math.max(0, box.height);
1950
- }
1951
- // Public API
1952
- startCropMode() {
1953
- if (this.cropMode) {
1954
- console.log("Crop mode already active, skipping initialization");
1955
- return;
1956
- }
1957
- this.cropMode = true;
1958
- this.initializeCropBox();
1959
- console.log("Crop mode started - SE handle should be at buffer coords (100, 100)");
1960
- this.renderCanvas();
1961
- this.emitCropEvent("crop-started");
1962
- }
1963
- exitCropMode() {
1964
- this.cropMode = false;
1965
- this.isDragging = false;
1966
- this.isResizing = false;
1967
- this.dragHandle = null;
1968
- this.canvas.style.cursor = "default";
1969
- this.renderCanvas();
1970
- this.emitCropEvent("crop-exited");
1971
- }
1972
- initializeCropBox() {
1973
- if (!this.canvasWidth || !this.canvasHeight || !this.image) return;
1974
- const imageWidth = this.image.naturalWidth;
1975
- const imageHeight = this.image.naturalHeight;
1976
- let cropWidth, cropHeight;
1977
- if (this.fixedCropSize) {
1978
- cropWidth = this.fixedCropSize.width;
1979
- cropHeight = this.fixedCropSize.height;
1980
- } else {
1981
- cropWidth = Math.floor(imageWidth * 0.8);
1982
- cropHeight = Math.floor(imageHeight * 0.8);
1983
- let aspectRatio = this.aspectRatio;
1984
- if (this.cropAndScale) {
1985
- aspectRatio = this.cropAndScale.width / this.cropAndScale.height;
1986
- }
1987
- this.aspectRatio = aspectRatio;
1988
- if (aspectRatio) {
1989
- if (cropWidth / cropHeight > aspectRatio) {
1990
- cropWidth = cropHeight * aspectRatio;
1991
- } else {
1992
- cropHeight = cropWidth / aspectRatio;
1993
- }
1994
- }
1995
- cropWidth = Math.max(this.minCropSize || 50, cropWidth);
1996
- cropHeight = Math.max(this.minCropSize || 50, cropHeight);
1997
- }
1998
- const x = Math.floor((imageWidth - cropWidth) / 2);
1999
- const y = Math.floor((imageHeight - cropHeight) / 2);
2000
- this.cropBox = {
2001
- x,
2002
- // relative to image, not canvas
2003
- y,
2004
- // relative to image, not canvas
2005
- width: cropWidth,
2006
- height: cropHeight
2007
- };
2008
- }
2009
- setAspectRatio(ratio) {
2010
- this.aspectRatio = ratio;
2011
- if (this.cropMode) {
2012
- this.initializeCropBox();
2013
- this.renderCanvas();
2014
- }
2015
- this.emitCropEvent("aspect-ratio-changed", { aspectRatio: ratio });
2016
- }
2017
- getCropData() {
2018
- if (!this.cropBox || !this.image) return null;
2019
- return {
2020
- x: Math.max(0, Math.min(this.cropBox.x, this.image.naturalWidth)),
2021
- y: Math.max(0, Math.min(this.cropBox.y, this.image.naturalHeight)),
2022
- width: Math.min(this.cropBox.width, this.image.naturalWidth - this.cropBox.x),
2023
- height: Math.min(this.cropBox.height, this.image.naturalHeight - this.cropBox.y),
2024
- originalWidth: this.image.naturalWidth,
2025
- originalHeight: this.image.naturalHeight
2026
- };
2027
- }
2028
- async applyCrop() {
2029
- const cropData = this.getCropData();
2030
- if (!cropData || !this.image) {
2031
- return null;
2032
- }
2033
- const croppedCanvas = document.createElement("canvas");
2034
- const croppedContext = croppedCanvas.getContext("2d");
2035
- if (this.cropAndScale) {
2036
- croppedCanvas.width = this.cropAndScale.width;
2037
- croppedCanvas.height = this.cropAndScale.height;
2038
- croppedContext.drawImage(
2039
- this.image,
2040
- cropData.x,
2041
- cropData.y,
2042
- cropData.width,
2043
- cropData.height,
2044
- 0,
2045
- 0,
2046
- this.cropAndScale.width,
2047
- this.cropAndScale.height
2048
- );
2049
- } else {
2050
- croppedCanvas.width = cropData.width;
2051
- croppedCanvas.height = cropData.height;
2052
- croppedContext.drawImage(
2053
- this.image,
2054
- cropData.x,
2055
- cropData.y,
2056
- cropData.width,
2057
- cropData.height,
2058
- 0,
2059
- 0,
2060
- cropData.width,
2061
- cropData.height
2062
- );
2063
- }
2064
- const croppedImageData = croppedCanvas.toDataURL("image/png");
2065
- return {
2066
- canvas: croppedCanvas,
2067
- imageData: croppedImageData,
2068
- cropData
2069
- };
2070
- }
2071
- // Event emission
2072
- emitCropEvent(type, data = {}) {
2073
- const eventBus = this.getApp()?.events;
2074
- if (eventBus) {
2075
- eventBus.emit(`imagecrop:${type}`, {
2076
- view: this,
2077
- cropBox: this.cropBox,
2078
- aspectRatio: this.aspectRatio,
2079
- ...data
2080
- });
2081
- }
2082
- }
2083
- // Toolbar control methods
2084
- showToolbarElement() {
2085
- if (!this.showToolbar) {
2086
- this.showToolbar = true;
2087
- const toolbar = this.element.querySelector(".image-crop-toolbar");
2088
- if (toolbar) {
2089
- toolbar.style.display = "block";
2090
- }
2091
- this.updateAutoFitButtonState();
2092
- }
2093
- }
2094
- hideToolbarElement() {
2095
- if (this.showToolbar) {
2096
- this.showToolbar = false;
2097
- const toolbar = this.element.querySelector(".image-crop-toolbar");
2098
- if (toolbar) {
2099
- toolbar.style.display = "none";
2100
- }
2101
- }
2102
- }
2103
- toggleToolbarElement() {
2104
- if (this.showToolbar) {
2105
- this.hideToolbarElement();
2106
- } else {
2107
- this.showToolbarElement();
2108
- }
2109
- }
2110
- // Cleanup
2111
- // Action handlers for toolbar buttons
2112
- async onPassThruActionSetAspectRatio(e, el) {
2113
- const ratio = el.getAttribute("data-ratio");
2114
- const aspectRatio = ratio === "free" ? null : parseFloat(ratio);
2115
- this.setAspectRatio(aspectRatio);
2116
- }
2117
- async handleActionApplyCrop() {
2118
- if (this.cropMode) {
2119
- const result = await this.applyCrop();
2120
- if (result && result.imageData) {
2121
- this.loadImage(result.imageData);
2122
- this.exitCropMode();
2123
- this.emitCropEvent("crop-applied", { result });
2124
- }
2125
- }
2126
- }
2127
- async handleActionToggleAutoFit() {
2128
- if (!this.showToolbar) return;
2129
- this.autoFit = !this.autoFit;
2130
- this.updateAutoFitButtonState();
2131
- this.updateImageOffset();
2132
- this.renderCanvas();
2133
- this.emitCropEvent("auto-fit-changed", { autoFit: this.autoFit });
2134
- }
2135
- async handleActionResetCrop() {
2136
- if (this.cropMode) {
2137
- this.exitCropMode();
2138
- }
2139
- if (this.originalImageUrl) {
2140
- await this.loadImage(this.originalImageUrl);
2141
- }
2142
- this.startCropMode();
2143
- this.emitCropEvent("crop-reset");
2144
- }
2145
- async onBeforeDestroy() {
2146
- await super.onBeforeDestroy();
2147
- this.cropMode = false;
2148
- this.isDragging = false;
2149
- this.isResizing = false;
2150
- document.removeEventListener("mousemove", this._handleMouseMove);
2151
- document.removeEventListener("mouseup", this._handleMouseUp);
2152
- this.emitCropEvent("destroyed");
2153
- }
2154
- // Static method to show crop view in a dialog for standalone testing
2155
- static async showDialog(imageUrl, options = {}) {
2156
- const {
2157
- title = "Crop Image",
2158
- alt = "Image",
2159
- size = "xl",
2160
- aspectRatio = null,
2161
- minCropSize = 50,
2162
- showGrid = true,
2163
- showToolbar = false,
2164
- autoFit = true,
2165
- fixedCropSize = null,
2166
- cropAndScale = null,
2167
- canvasSize = size || "auto",
2168
- ...dialogOptions
2169
- } = options;
2170
- const cropView = new ImageCropView({
2171
- imageUrl,
2172
- alt,
2173
- title,
2174
- aspectRatio,
2175
- minCropSize,
2176
- canvasSize: canvasSize || size || "md",
2177
- fixedCropSize,
2178
- cropAndScale,
2179
- showGrid,
2180
- showToolbar,
2181
- autoFit
2182
- });
2183
- const dialog = new Dialog({
2184
- title,
2185
- body: cropView,
2186
- size,
2187
- centered: true,
2188
- backdrop: "static",
2189
- keyboard: true,
2190
- noBodyPadding: true,
2191
- buttons: [
2192
- {
2193
- text: "Cancel",
2194
- action: "cancel",
2195
- class: "btn btn-secondary",
2196
- dismiss: true
2197
- },
2198
- {
2199
- text: "Apply Crop",
2200
- action: "apply-crop",
2201
- class: "btn btn-primary"
2202
- }
2203
- ],
2204
- ...dialogOptions
2205
- });
2206
- await dialog.render(true, document.body);
2207
- dialog.show();
2208
- const initializeCrop = () => {
2209
- if (cropView.setupCanvas) {
2210
- cropView.setupCanvas();
2211
- }
2212
- if (cropView.isLoaded && cropView.canvasWidth > 0) {
2213
- cropView.startCropMode();
2214
- } else {
2215
- const checkReady = setInterval(() => {
2216
- if (cropView.isLoaded && cropView.canvasWidth > 0) {
2217
- clearInterval(checkReady);
2218
- cropView.startCropMode();
2219
- }
2220
- }, 100);
2221
- setTimeout(() => clearInterval(checkReady), 5e3);
2222
- }
2223
- };
2224
- dialog.on("shown", initializeCrop);
2225
- return new Promise((resolve) => {
2226
- dialog.on("hidden", () => {
2227
- dialog.destroy();
2228
- resolve({ action: "cancel", view: cropView });
2229
- });
2230
- dialog.on("action:cancel", () => {
2231
- dialog.hide();
2232
- });
2233
- dialog.on("action:apply-crop", async () => {
2234
- let result;
2235
- if (cropView.cropMode && cropView.cropBox) {
2236
- result = await cropView.applyCrop();
2237
- } else {
2238
- const currentImageData = cropView.canvas.toDataURL("image/png");
2239
- result = {
2240
- canvas: cropView.canvas,
2241
- imageData: currentImageData,
2242
- cropData: null
2243
- // No crop was applied
2244
- };
2245
- }
2246
- dialog.hide();
2247
- resolve({
2248
- action: "crop",
2249
- view: cropView,
2250
- data: result?.imageData,
2251
- cropData: result?.cropData
2252
- });
2253
- });
2254
- });
2255
- }
2256
- }
2257
- window.ImageCropView = ImageCropView;
2258
- class ImageFiltersView extends ImageCanvasView {
2259
- constructor(options = {}) {
2260
- super({
2261
- ...options,
2262
- className: `image-filters-view ${options.className || ""}`
2263
- });
2264
- this.filters = {
2265
- brightness: 100,
2266
- contrast: 100,
2267
- saturation: 100,
2268
- hue: 0,
2269
- blur: 0,
2270
- grayscale: 0,
2271
- sepia: 0
2272
- };
2273
- this.showControls = options.showControls ?? true;
2274
- this.allowReset = options.allowReset ?? true;
2275
- this.showPresets = options.showPresets ?? true;
2276
- this.showBasicControls = options.showBasicControls ?? true;
2277
- this.showAdvancedControls = options.showAdvancedControls ?? true;
2278
- this.controlsInDropdowns = options.controlsInDropdowns ?? true;
2279
- this.presetEffects = {
2280
- none: { name: "Original", filters: {} },
2281
- blackWhite: { name: "Black & White", filters: { grayscale: 100 } },
2282
- sepia: { name: "Sepia", filters: { sepia: 100 } },
2283
- vintage: { name: "Vintage", filters: { sepia: 60, contrast: 110, brightness: 110, saturation: 80 } },
2284
- cool: { name: "Cool Tones", filters: { hue: 200, saturation: 120, brightness: 95 } },
2285
- warm: { name: "Warm Tones", filters: { hue: 25, saturation: 110, brightness: 105 } },
2286
- vibrant: { name: "Vibrant", filters: { brightness: 105, contrast: 115, saturation: 140, hue: 5 } },
2287
- dramatic: { name: "Dramatic", filters: { brightness: 90, contrast: 150, saturation: 120 } },
2288
- soft: { name: "Soft", filters: { brightness: 110, contrast: 85, blur: 1 } }
2289
- };
2290
- this.currentPreset = "none";
2291
- }
2292
- async getTemplate() {
2293
- return `
2294
- <div class="image-filters-container d-flex flex-column h-100">
2295
- {{#showControls}}
2296
- <!-- Filter Toolbar -->
2297
- <div class="image-filters-toolbar bg-light border-bottom p-2">
2298
- <div class="btn-toolbar justify-content-center flex-wrap" role="toolbar">
2299
-
2300
- {{#showPresets}}
2301
- <!-- Preset Effects -->
2302
- <div class="btn-group me-2 mb-2" role="group" aria-label="Preset effects">
2303
- <div class="dropdown">
2304
- <button type="button" class="btn btn-outline-primary btn-sm dropdown-toggle"
2305
- data-bs-toggle="dropdown" aria-expanded="false" title="Preset Effects">
2306
- <i class="bi bi-palette"></i> Effects
2307
- </button>
2308
- <ul class="dropdown-menu">
2309
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="none">Original</a></li>
2310
- <li><hr class="dropdown-divider"></li>
2311
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="blackWhite">Black & White</a></li>
2312
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="sepia">Sepia</a></li>
2313
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="vintage">Vintage</a></li>
2314
- <li><hr class="dropdown-divider"></li>
2315
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="cool">Cool Tones</a></li>
2316
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="warm">Warm Tones</a></li>
2317
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="vibrant">Vibrant</a></li>
2318
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="dramatic">Dramatic</a></li>
2319
- <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="soft">Soft</a></li>
2320
- </ul>
2321
- </div>
2322
- </div>
2323
- {{/showPresets}}
2324
-
2325
- {{#showBasicControls}}
2326
- {{#controlsInDropdowns}}
2327
- <!-- Basic Controls in Dropdown -->
2328
- <div class="btn-group me-2 mb-2" role="group" aria-label="Basic controls">
2329
- <div class="dropdown">
2330
- <button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
2331
- data-bs-toggle="dropdown" aria-expanded="false" title="Basic Adjustments">
2332
- <i class="bi bi-sliders"></i> Basic
2333
- </button>
2334
- <div class="dropdown-menu p-3" style="min-width: 300px;">
2335
- <div class="mb-3">
2336
- <label class="form-label small fw-bold">Brightness</label>
2337
- <input type="range" class="form-range"
2338
- min="0" max="200" value="{{filters.brightness}}"
2339
- data-change-action="filter-change" data-filter="brightness">
2340
- <div class="d-flex justify-content-between">
2341
- <small class="text-muted">0%</small>
2342
- <small class="text-muted filter-value" data-filter="brightness">{{filters.brightness}}%</small>
2343
- <small class="text-muted">200%</small>
2344
- </div>
2345
- </div>
2346
- <div class="mb-3">
2347
- <label class="form-label small fw-bold">Contrast</label>
2348
- <input type="range" class="form-range"
2349
- min="0" max="200" value="{{filters.contrast}}"
2350
- data-change-action="filter-change" data-filter="contrast">
2351
- <div class="d-flex justify-content-between">
2352
- <small class="text-muted">0%</small>
2353
- <small class="text-muted filter-value" data-filter="contrast">{{filters.contrast}}%</small>
2354
- <small class="text-muted">200%</small>
2355
- </div>
2356
- </div>
2357
- <div class="mb-0">
2358
- <label class="form-label small fw-bold">Saturation</label>
2359
- <input type="range" class="form-range"
2360
- min="0" max="200" value="{{filters.saturation}}"
2361
- data-change-action="filter-change" data-filter="saturation">
2362
- <div class="d-flex justify-content-between">
2363
- <small class="text-muted">0%</small>
2364
- <small class="text-muted filter-value" data-filter="saturation">{{filters.saturation}}%</small>
2365
- <small class="text-muted">200%</small>
2366
- </div>
2367
- </div>
2368
- </div>
2369
- </div>
2370
- </div>
2371
- {{/controlsInDropdowns}}
2372
- {{/showBasicControls}}
2373
-
2374
- {{#showAdvancedControls}}
2375
- {{#controlsInDropdowns}}
2376
- <!-- Advanced Controls in Dropdown -->
2377
- <div class="btn-group me-2 mb-2" role="group" aria-label="Advanced controls">
2378
- <div class="dropdown">
2379
- <button type="button" class="btn btn-outline-warning btn-sm dropdown-toggle"
2380
- data-bs-toggle="dropdown" aria-expanded="false" title="Advanced Adjustments">
2381
- <i class="bi bi-gear"></i> Advanced
2382
- </button>
2383
- <div class="dropdown-menu p-3" style="min-width: 300px;">
2384
- <div class="mb-3">
2385
- <label class="form-label small fw-bold">Hue</label>
2386
- <input type="range" class="form-range"
2387
- min="0" max="360" value="{{filters.hue}}"
2388
- data-change-action="filter-change" data-filter="hue">
2389
- <div class="d-flex justify-content-between">
2390
- <small class="text-muted">0°</small>
2391
- <small class="text-muted filter-value" data-filter="hue">{{filters.hue}}°</small>
2392
- <small class="text-muted">360°</small>
2393
- </div>
2394
- </div>
2395
- <div class="mb-3">
2396
- <label class="form-label small fw-bold">Blur</label>
2397
- <input type="range" class="form-range"
2398
- min="0" max="10" value="{{filters.blur}}"
2399
- data-change-action="filter-change" data-filter="blur">
2400
- <div class="d-flex justify-content-between">
2401
- <small class="text-muted">0px</small>
2402
- <small class="text-muted filter-value" data-filter="blur">{{filters.blur}}px</small>
2403
- <small class="text-muted">10px</small>
2404
- </div>
2405
- </div>
2406
- <div class="mb-3">
2407
- <label class="form-label small fw-bold">Grayscale</label>
2408
- <input type="range" class="form-range"
2409
- min="0" max="100" value="{{filters.grayscale}}"
2410
- data-change-action="filter-change" data-filter="grayscale">
2411
- <div class="d-flex justify-content-between">
2412
- <small class="text-muted">0%</small>
2413
- <small class="text-muted filter-value" data-filter="grayscale">{{filters.grayscale}}%</small>
2414
- <small class="text-muted">100%</small>
2415
- </div>
2416
- </div>
2417
- <div class="mb-0">
2418
- <label class="form-label small fw-bold">Sepia</label>
2419
- <input type="range" class="form-range"
2420
- min="0" max="100" value="{{filters.sepia}}"
2421
- data-change-action="filter-change" data-filter="sepia">
2422
- <div class="d-flex justify-content-between">
2423
- <small class="text-muted">0%</small>
2424
- <small class="text-muted filter-value" data-filter="sepia">{{filters.sepia}}%</small>
2425
- <small class="text-muted">100%</small>
2426
- </div>
2427
- </div>
2428
- </div>
2429
- </div>
2430
- </div>
2431
- {{/controlsInDropdowns}}
2432
- {{/showAdvancedControls}}
2433
-
2434
- {{#allowReset}}
2435
- <!-- Reset & Preview Controls -->
2436
- <div class="btn-group me-2 mb-2" role="group" aria-label="Reset controls">
2437
- <button type="button" class="btn btn-outline-secondary btn-sm" data-action="reset-filters" title="Reset All Filters">
2438
- <i class="bi bi-arrow-repeat"></i> Reset
2439
- </button>
2440
- <button type="button" class="btn btn-outline-info btn-sm" data-action="preview-original" title="Preview Original"
2441
- onmousedown="this.dataset.previewing='true'"
2442
- onmouseup="this.dataset.previewing='false'"
2443
- onmouseleave="this.dataset.previewing='false'">
2444
- <i class="bi bi-eye"></i> Original
2445
- </button>
2446
- </div>
2447
- {{/allowReset}}
2448
-
2449
- </div>
2450
- </div>
2451
- {{/showControls}}
2452
-
2453
- <!-- Canvas Area -->
2454
- <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">
2455
- <canvas class="image-filters-canvas" data-container="canvas"></canvas>
2456
-
2457
- <!-- Loading Overlay -->
2458
- <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"
2459
- style="display: none; z-index: 10;">
2460
- <div class="spinner-border text-primary" role="status">
2461
- <span class="visually-hidden">Loading...</span>
2462
- </div>
2463
- </div>
2464
- </div>
2465
-
2466
- {{#showControls}}
2467
- {{^controlsInDropdowns}}
2468
- <!-- Expanded Controls Panel (when not in dropdowns) -->
2469
- <div class="image-filters-controls bg-light border-top p-3" data-container="controls" style="max-height: 300px; overflow-y: auto;">
2470
- <div class="row g-3">
2471
- {{#showBasicControls}}
2472
- <div class="col-md-6 col-lg-4">
2473
- <label class="form-label small fw-bold">Brightness</label>
2474
- <input type="range" class="form-range"
2475
- min="0" max="200" value="{{filters.brightness}}"
2476
- data-change-action="filter-change" data-filter="brightness">
2477
- <div class="d-flex justify-content-between">
2478
- <small class="text-muted">0%</small>
2479
- <small class="text-muted filter-value" data-filter="brightness">{{filters.brightness}}%</small>
2480
- <small class="text-muted">200%</small>
2481
- </div>
2482
- </div>
2483
- <div class="col-md-6 col-lg-4">
2484
- <label class="form-label small fw-bold">Contrast</label>
2485
- <input type="range" class="form-range"
2486
- min="0" max="200" value="{{filters.contrast}}"
2487
- data-change-action="filter-change" data-filter="contrast">
2488
- <div class="d-flex justify-content-between">
2489
- <small class="text-muted">0%</small>
2490
- <small class="text-muted filter-value" data-filter="contrast">{{filters.contrast}}%</small>
2491
- <small class="text-muted">200%</small>
2492
- </div>
2493
- </div>
2494
- <div class="col-md-6 col-lg-4">
2495
- <label class="form-label small fw-bold">Saturation</label>
2496
- <input type="range" class="form-range"
2497
- min="0" max="200" value="{{filters.saturation}}"
2498
- data-change-action="filter-change" data-filter="saturation">
2499
- <div class="d-flex justify-content-between">
2500
- <small class="text-muted">0%</small>
2501
- <small class="text-muted filter-value" data-filter="saturation">{{filters.saturation}}%</small>
2502
- <small class="text-muted">200%</small>
2503
- </div>
2504
- </div>
2505
- {{/showBasicControls}}
2506
-
2507
- {{#showAdvancedControls}}
2508
- <div class="col-md-6 col-lg-4">
2509
- <label class="form-label small fw-bold">Hue</label>
2510
- <input type="range" class="form-range"
2511
- min="0" max="360" value="{{filters.hue}}"
2512
- data-change-action="filter-change" data-filter="hue">
2513
- <div class="d-flex justify-content-between">
2514
- <small class="text-muted">0°</small>
2515
- <small class="text-muted filter-value" data-filter="hue">{{filters.hue}}°</small>
2516
- <small class="text-muted">360°</small>
2517
- </div>
2518
- </div>
2519
- <div class="col-md-6 col-lg-4">
2520
- <label class="form-label small fw-bold">Blur</label>
2521
- <input type="range" class="form-range"
2522
- min="0" max="10" value="{{filters.blur}}"
2523
- data-change-action="filter-change" data-filter="blur">
2524
- <div class="d-flex justify-content-between">
2525
- <small class="text-muted">0px</small>
2526
- <small class="text-muted filter-value" data-filter="blur">{{filters.blur}}px</small>
2527
- <small class="text-muted">10px</small>
2528
- </div>
2529
- </div>
2530
- <div class="col-md-6 col-lg-4">
2531
- <label class="form-label small fw-bold">Grayscale</label>
2532
- <input type="range" class="form-range"
2533
- min="0" max="100" value="{{filters.grayscale}}"
2534
- data-change-action="filter-change" data-filter="grayscale">
2535
- <div class="d-flex justify-content-between">
2536
- <small class="text-muted">0%</small>
2537
- <small class="text-muted filter-value" data-filter="grayscale">{{filters.grayscale}}%</small>
2538
- <small class="text-muted">100%</small>
2539
- </div>
2540
- </div>
2541
- <div class="col-md-6 col-lg-4">
2542
- <label class="form-label small fw-bold">Sepia</label>
2543
- <input type="range" class="form-range"
2544
- min="0" max="100" value="{{filters.sepia}}"
2545
- data-change-action="filter-change" data-filter="sepia">
2546
- <div class="d-flex justify-content-between">
2547
- <small class="text-muted">0%</small>
2548
- <small class="text-muted filter-value" data-filter="sepia">{{filters.sepia}}%</small>
2549
- <small class="text-muted">100%</small>
2550
- </div>
2551
- </div>
2552
- {{/showAdvancedControls}}
2553
- </div>
2554
- </div>
2555
- {{/controlsInDropdowns}}
2556
- {{/showControls}}
2557
- </div>
2558
- `;
2559
- }
2560
- async onAfterRender() {
2561
- await super.onAfterRender();
2562
- this.controlsElement = this.element.querySelector(".image-filters-controls");
2563
- }
2564
- // Override canvas sizing for container-aware dimensions
2565
- setupCanvas() {
2566
- if (!this.canvas || !this.containerElement) return;
2567
- this.setCanvasSize(this.canvasSize);
2568
- if (this.canvasSize === "fullscreen" || this.canvasSize === "auto") {
2569
- this._resizeHandler = () => this.setCanvasSize(this.canvasSize);
2570
- window.addEventListener("resize", this._resizeHandler);
2571
- }
2572
- this.context.imageSmoothingEnabled = true;
2573
- this.context.imageSmoothingQuality = "high";
2574
- }
2575
- // Override setCanvasSize to be more container-aware for dialogs
2576
- setCanvasSize(size) {
2577
- if (!this.canvas || !this.containerElement) return;
2578
- if (size === "auto") {
2579
- const container = this.containerElement;
2580
- let availableWidth = container.clientWidth - 40;
2581
- let availableHeight = container.clientHeight - 40;
2582
- if (availableWidth <= 40 || availableHeight <= 40) {
2583
- let parent = container.parentElement;
2584
- while (parent && (parent.clientWidth <= 40 || parent.clientHeight <= 40)) {
2585
- parent = parent.parentElement;
2586
- if (parent && (parent.classList.contains("modal-body") || parent.classList.contains("card-body") || parent.classList.contains("dialog-body") || parent.tagName === "MAIN" || parent.tagName === "BODY")) {
2587
- break;
2588
- }
2589
- }
2590
- if (parent) {
2591
- availableWidth = parent.clientWidth - 80;
2592
- availableHeight = parent.clientHeight - 80;
2593
- }
2594
- }
2595
- if (availableWidth > 100 && availableHeight > 100) {
2596
- let canvasWidth, canvasHeight;
2597
- if (this.image) {
2598
- const imageAspect = this.image.naturalWidth / this.image.naturalHeight;
2599
- const availableAspect = availableWidth / availableHeight;
2600
- if (imageAspect > availableAspect) {
2601
- canvasWidth = availableWidth;
2602
- canvasHeight = availableWidth / imageAspect;
2603
- } else {
2604
- canvasHeight = availableHeight;
2605
- canvasWidth = availableHeight * imageAspect;
2606
- }
2607
- canvasWidth = Math.max(300, Math.floor(canvasWidth));
2608
- canvasHeight = Math.max(200, Math.floor(canvasHeight));
2609
- } else {
2610
- canvasWidth = Math.min(600, Math.max(300, availableWidth));
2611
- canvasHeight = Math.min(450, Math.max(200, availableHeight));
2612
- }
2613
- this.applyCanvasSize(canvasWidth, canvasHeight);
2614
- return;
2615
- }
2616
- }
2617
- super.setCanvasSize(size);
2618
- }
2619
- // Helper method to apply calculated canvas size
2620
- applyCanvasSize(canvasWidth, canvasHeight) {
2621
- if (Math.abs(canvasWidth - this.canvasWidth) < 10 && Math.abs(canvasHeight - this.canvasHeight) < 10) {
2622
- return;
2623
- }
2624
- const dpr = window.devicePixelRatio || 1;
2625
- this.canvasWidth = canvasWidth;
2626
- this.canvasHeight = canvasHeight;
2627
- this.canvas.width = canvasWidth * dpr;
2628
- this.canvas.height = canvasHeight * dpr;
2629
- this.canvas.style.width = canvasWidth + "px";
2630
- this.canvas.style.height = canvasHeight + "px";
2631
- this.context.setTransform(dpr, 0, 0, dpr, 0, 0);
2632
- if (this.isLoaded) {
2633
- this.renderCanvas();
2634
- }
2635
- }
2636
- // Override image loading to re-render with current filters
2637
- async loadImage(imageUrl) {
2638
- await super.loadImage(imageUrl);
2639
- this.renderCanvas();
2640
- }
2641
- // Override renderImage to apply filters consistently
2642
- renderImage() {
2643
- if (!this.image) return;
2644
- this.context.filter = this.getFilterString();
2645
- const imageScale = Math.min(
2646
- this.canvasWidth / this.image.naturalWidth,
2647
- this.canvasHeight / this.image.naturalHeight
2648
- );
2649
- const scaledWidth = this.image.naturalWidth * imageScale;
2650
- const scaledHeight = this.image.naturalHeight * imageScale;
2651
- const x = (this.canvasWidth - scaledWidth) / 2;
2652
- const y = (this.canvasHeight - scaledHeight) / 2;
2653
- this.context.drawImage(this.image, x, y, scaledWidth, scaledHeight);
2654
- this.context.filter = "none";
2655
- }
2656
- // Get combined filter values from both sliders and current preset
2657
- getCombinedFilters() {
2658
- const combined = { ...this.filters };
2659
- if (this.currentPreset !== "none" && this.presetEffects[this.currentPreset]) {
2660
- const presetFilters = this.presetEffects[this.currentPreset].filters;
2661
- if (presetFilters) {
2662
- Object.assign(combined, presetFilters);
2663
- }
2664
- }
2665
- return combined;
2666
- }
2667
- getFilterString() {
2668
- const filters = this.getCombinedFilters();
2669
- if (!this.hasFilters() && this.currentPreset === "none") return "none";
2670
- return [
2671
- `brightness(${filters.brightness}%)`,
2672
- `contrast(${filters.contrast}%)`,
2673
- `saturate(${filters.saturation}%)`,
2674
- `hue-rotate(${filters.hue}deg)`,
2675
- `blur(${filters.blur}px)`,
2676
- `grayscale(${filters.grayscale}%)`,
2677
- `sepia(${filters.sepia}%)`
2678
- ].join(" ");
2679
- }
2680
- hasFilters() {
2681
- return this.filters.brightness !== 100 || this.filters.contrast !== 100 || this.filters.saturation !== 100 || this.filters.hue !== 0 || this.filters.blur !== 0 || this.filters.grayscale !== 0 || this.filters.sepia !== 0;
2682
- }
2683
- // Action Handlers
2684
- async onPassThruActionResetFilters() {
2685
- this.resetFilters();
2686
- }
2687
- async onPassThruActionApplyPreset(e, el) {
2688
- e.preventDefault();
2689
- const presetName = el.getAttribute("data-preset");
2690
- if (presetName && this.presetEffects[presetName]) {
2691
- this.applyPreset(presetName);
2692
- }
2693
- }
2694
- async onPassThruActionPreviewOriginal(e, el) {
2695
- const isPreviewing = el.dataset.previewing === "true";
2696
- if (isPreviewing) {
2697
- this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
2698
- this.context.filter = "none";
2699
- const x = (this.canvasWidth - this.image.naturalWidth) / 2;
2700
- const y = (this.canvasHeight - this.image.naturalHeight) / 2;
2701
- this.context.drawImage(this.image, x, y);
2702
- } else {
2703
- this.renderCanvas();
2704
- }
2705
- }
2706
- // Change Handler for filter sliders
2707
- async onChangeFilterChange(e, el) {
2708
- const filterName = el.getAttribute("data-filter");
2709
- const value = parseFloat(el.value);
2710
- this.updateFilter(filterName, value);
2711
- }
2712
- // Filter methods
2713
- updateFilter(name, value) {
2714
- if (!(name in this.filters)) return;
2715
- const oldValue = this.filters[name];
2716
- this.filters[name] = value;
2717
- this.updateFilterDisplay(name, value);
2718
- this.renderCanvas();
2719
- this.emitFilterEvent("filter-changed", {
2720
- filter: name,
2721
- oldValue,
2722
- newValue: value,
2723
- allFilters: { ...this.filters }
2724
- });
2725
- }
2726
- updateFilterDisplay(name, value) {
2727
- const valueElement = this.element.querySelector(`[data-filter="${name}"].filter-value`);
2728
- if (valueElement) {
2729
- const unit = name === "hue" ? "°" : name === "blur" ? "px" : "%";
2730
- valueElement.textContent = `${value}${unit}`;
2731
- }
2732
- }
2733
- resetFilters() {
2734
- const oldFilters = { ...this.filters };
2735
- const oldPreset = this.currentPreset;
2736
- this.filters = {
2737
- brightness: 100,
2738
- contrast: 100,
2739
- saturation: 100,
2740
- hue: 0,
2741
- blur: 0,
2742
- grayscale: 0,
2743
- sepia: 0
2744
- };
2745
- this.currentPreset = "none";
2746
- this.updateAllFilterInputs();
2747
- this.renderCanvas();
2748
- this.emitFilterEvent("filters-reset", {
2749
- oldFilters,
2750
- newFilters: { ...this.filters },
2751
- oldPreset,
2752
- newPreset: this.currentPreset
2753
- });
2754
- }
2755
- updateAllFilterInputs() {
2756
- Object.keys(this.filters).forEach((filterName) => {
2757
- const input = this.element.querySelector(`[data-filter="${filterName}"][type="range"]`);
2758
- if (input) {
2759
- input.value = this.filters[filterName];
2760
- this.updateFilterDisplay(filterName, this.filters[filterName]);
2761
- }
2762
- });
2763
- }
2764
- // Enhanced preset system
2765
- applyPreset(presetName) {
2766
- if (!this.presetEffects[presetName]) return;
2767
- this.presetEffects[presetName];
2768
- this.currentPreset = presetName;
2769
- this.currentPreset = presetName;
2770
- this.renderCanvas();
2771
- this.emitFilterEvent("preset-applied", { preset: presetName, filters: { ...this.filters } });
2772
- }
2773
- // State management
2774
- getFilterState() {
2775
- return { ...this.filters };
2776
- }
2777
- setFilterState(filters) {
2778
- this.filters = { ...this.filters, ...filters };
2779
- this.updateAllFilterInputs();
2780
- this.renderCanvas();
2781
- this.emitFilterEvent("filters-set", { filters: { ...this.filters } });
2782
- }
2783
- // Export with filters applied
2784
- exportFilteredImageData() {
2785
- if (!this.canvas) return null;
2786
- return this.exportImageData();
2787
- }
2788
- async exportFilteredImageBlob(quality = 0.9) {
2789
- if (!this.canvas) return null;
2790
- return this.exportImageBlob(quality);
2791
- }
2792
- // Create a new canvas with original image + filters applied
2793
- createFilteredCanvas() {
2794
- if (!this.image) return null;
2795
- const canvas = document.createElement("canvas");
2796
- const context = canvas.getContext("2d");
2797
- canvas.width = this.image.naturalWidth;
2798
- canvas.height = this.image.naturalHeight;
2799
- context.filter = this.getFilterString();
2800
- context.drawImage(this.image, 0, 0);
2801
- context.filter = "none";
2802
- return canvas;
2803
- }
2804
- // Event emission
2805
- emitFilterEvent(type, data = {}) {
2806
- const eventBus = this.getApp()?.events;
2807
- if (eventBus) {
2808
- eventBus.emit(`imagefilters:${type}`, {
2809
- view: this,
2810
- hasFilters: this.hasFilters(),
2811
- filterString: this.getFilterString(),
2812
- ...data
2813
- });
2814
- }
2815
- }
2816
- // Override handleImageLoad to emit filter-ready event
2817
- handleImageLoad() {
2818
- super.handleImageLoad();
2819
- this.emitFilterEvent("ready", { filters: { ...this.filters } });
2820
- }
2821
- // Cleanup
2822
- async onBeforeDestroy() {
2823
- await super.onBeforeDestroy();
2824
- if (this._resizeHandler) {
2825
- window.removeEventListener("resize", this._resizeHandler);
2826
- }
2827
- this.emitFilterEvent("destroyed");
2828
- }
2829
- // Static method to show filters view in a dialog for standalone testing
2830
- static async showDialog(imageUrl, options = {}) {
2831
- const {
2832
- title = "Apply Filters",
2833
- alt = "Image",
2834
- size = "xl",
2835
- showControls = true,
2836
- allowReset = true,
2837
- showPresets = true,
2838
- showBasicControls = true,
2839
- showAdvancedControls = true,
2840
- controlsInDropdowns = true,
2841
- canvasSize = "auto",
2842
- autoFit = true,
2843
- crossOrigin = "anonymous",
2844
- ...dialogOptions
2845
- } = options;
2846
- const filtersView = new ImageFiltersView({
2847
- imageUrl,
2848
- alt,
2849
- title,
2850
- canvasSize,
2851
- autoFit,
2852
- crossOrigin,
2853
- showControls,
2854
- allowReset,
2855
- showPresets,
2856
- showBasicControls,
2857
- showAdvancedControls,
2858
- controlsInDropdowns
2859
- });
2860
- const dialog = new Dialog({
2861
- title,
2862
- body: filtersView,
2863
- size,
2864
- centered: true,
2865
- backdrop: "static",
2866
- keyboard: true,
2867
- noBodyPadding: true,
2868
- buttons: [
2869
- {
2870
- text: "Cancel",
2871
- action: "cancel",
2872
- class: "btn btn-secondary",
2873
- dismiss: true
2874
- },
2875
- {
2876
- text: "Apply Filters",
2877
- action: "apply-filters",
2878
- class: "btn btn-primary"
2879
- }
2880
- ],
2881
- ...dialogOptions
2882
- });
2883
- await dialog.render(true, document.body);
2884
- dialog.show();
2885
- return new Promise((resolve) => {
2886
- dialog.on("hidden", () => {
2887
- dialog.destroy();
2888
- resolve({ action: "cancel", view: filtersView });
2889
- });
2890
- dialog.on("action:cancel", () => {
2891
- dialog.hide();
2892
- });
2893
- dialog.on("action:apply-filters", async () => {
2894
- const imageData = filtersView.exportFilteredImageData();
2895
- dialog.hide();
2896
- resolve({
2897
- action: "filters",
2898
- view: filtersView,
2899
- data: imageData,
2900
- filterState: filtersView.getFilterState()
2901
- });
2902
- });
2903
- });
2904
- }
2905
- }
2906
- window.ImageFiltersView = ImageFiltersView;
2907
- class ImageEditor extends View {
2908
- constructor(options = {}) {
2909
- super({
2910
- ...options,
2911
- className: `image-editor ${options.className || ""}`,
2912
- tagName: "div"
2913
- });
2914
- this.imageUrl = options.imageUrl || options.src || "";
2915
- this.alt = options.alt || "Image";
2916
- this.title = options.title || "";
2917
- this.currentImageData = null;
2918
- this.currentMode = options.startMode || "transform";
2919
- this.history = [];
2920
- this.historyIndex = -1;
2921
- this.maxHistory = options.maxHistory || 20;
2922
- this.showToolbar = options.showToolbar !== false;
2923
- this.allowTransform = options.allowTransform !== false;
2924
- this.allowCrop = options.allowCrop !== false;
2925
- this.allowFilters = options.allowFilters !== false;
2926
- this.allowExport = options.allowExport !== false;
2927
- this.allowHistory = options.allowHistory !== false;
2928
- this.currentView = null;
2929
- this.isInitialized = false;
2930
- }
2931
- async getTemplate() {
2932
- return `
2933
- <div class="image-editor-container d-flex flex-column h-100">
2934
- {{#showToolbar}}
2935
- <!-- Toolbar -->
2936
- <div class="image-editor-toolbar bg-light border-bottom p-3" data-container="toolbar">
2937
- <div class="d-flex justify-content-between align-items-center">
2938
- <!-- Mode Buttons -->
2939
- <div class="btn-group" role="group" aria-label="Editing modes">
2940
- {{#allowTransform}}
2941
- <button type="button" class="btn btn-outline-primary mode-btn"
2942
- data-action="switch-mode" data-mode="transform"
2943
- title="Transform: Zoom, Pan, Rotate">
2944
- <i class="bi bi-arrows-move"></i> Transform
2945
- </button>
2946
- {{/allowTransform}}
2947
-
2948
- {{#allowCrop}}
2949
- <button type="button" class="btn btn-outline-primary mode-btn"
2950
- data-action="switch-mode" data-mode="crop"
2951
- title="Crop: Select and crop image">
2952
- <i class="bi bi-crop"></i> Crop
2953
- </button>
2954
- {{/allowCrop}}
2955
-
2956
- {{#allowFilters}}
2957
- <button type="button" class="btn btn-outline-primary mode-btn"
2958
- data-action="switch-mode" data-mode="filters"
2959
- title="Filters: Brightness, Contrast, Effects">
2960
- <i class="bi bi-palette"></i> Filters
2961
- </button>
2962
- {{/allowFilters}}
2963
- </div>
2964
-
2965
- <!-- Action Buttons -->
2966
- <div class="btn-group" role="group" aria-label="Actions">
2967
- {{#allowHistory}}
2968
- <button type="button" class="btn btn-outline-secondary btn-sm"
2969
- data-action="undo" title="Undo" disabled>
2970
- <i class="bi bi-arrow-counterclockwise"></i>
2971
- </button>
2972
- <button type="button" class="btn btn-outline-secondary btn-sm"
2973
- data-action="redo" title="Redo" disabled>
2974
- <i class="bi bi-arrow-clockwise"></i>
2975
- </button>
2976
- {{/allowHistory}}
2977
-
2978
- <button type="button" class="btn btn-outline-secondary btn-sm"
2979
- data-action="reset" title="Reset All Changes">
2980
- <i class="bi bi-arrow-repeat"></i>
2981
- </button>
2982
-
2983
- {{#allowExport}}
2984
- <button type="button" class="btn btn-success btn-sm"
2985
- data-action="export" title="Export Image">
2986
- <i class="bi bi-download"></i> Export
2987
- </button>
2988
- {{/allowExport}}
2989
- </div>
2990
- </div>
2991
-
2992
-
2993
- </div>
2994
- {{/showToolbar}}
2995
-
2996
- <!-- Main editing area where child views will be mounted -->
2997
- <div class="image-editor-workspace flex-grow-1 position-relative" data-container="image-workspace">
2998
- <!-- Child views will be added here dynamically -->
2999
- </div>
3000
-
3001
- <!-- Status bar -->
3002
- <div class="image-editor-status bg-light border-top p-2" data-container="status">
3003
- <div class="d-flex justify-content-between align-items-center">
3004
- <small class="text-muted">
3005
- Mode: <span class="current-mode fw-bold">Transform</span>
3006
- </small>
3007
- <small class="text-muted">
3008
- <span class="image-info">Ready</span>
3009
- </small>
3010
- </div>
3011
- </div>
3012
- </div>
3013
- `;
3014
- }
3015
- async onAfterRender() {
3016
- this.toolbarElement = this.element.querySelector(".image-editor-toolbar");
3017
- this.workspaceElement = this.element.querySelector(".image-editor-workspace");
3018
- this.statusElement = this.element.querySelector(".image-editor-status");
3019
- this.setupChildViewEvents();
3020
- await this.switchMode(this.currentMode, true);
3021
- this.saveState();
3022
- this.isInitialized = true;
3023
- }
3024
- createChildView(mode) {
3025
- const imageToUse = this.currentImageData || this.imageUrl;
3026
- console.log(
3027
- "[ImageEditor] Creating",
3028
- mode,
3029
- "view with:",
3030
- this.currentImageData ? "preserved canvas data" : "original image"
3031
- );
3032
- const childOptions = {
3033
- parent: this,
3034
- containerId: "image-workspace",
3035
- imageUrl: imageToUse,
3036
- alt: this.alt,
3037
- title: this.title
3038
- };
3039
- switch (mode) {
3040
- case "transform":
3041
- if (!this.allowTransform) return null;
3042
- return new ImageTransformView({
3043
- ...childOptions,
3044
- allowPan: true,
3045
- allowZoom: true,
3046
- allowRotate: true
3047
- });
3048
- case "crop":
3049
- if (!this.allowCrop) return null;
3050
- return new ImageCropView({
3051
- ...childOptions,
3052
- showGrid: true,
3053
- minCropSize: 50
3054
- });
3055
- case "filters":
3056
- if (!this.allowFilters) return null;
3057
- return new ImageFiltersView({
3058
- ...childOptions,
3059
- showControls: true,
3060
- allowReset: true
3061
- });
3062
- default:
3063
- return null;
3064
- }
3065
- }
3066
- setupChildViewEvents() {
3067
- const eventBus = this.getApp()?.events;
3068
- if (!eventBus) return;
3069
- eventBus.on("imagetransform:scale-changed", () => this.saveState());
3070
- eventBus.on("imagetransform:rotated", () => this.saveState());
3071
- eventBus.on("imagetransform:reset", () => this.saveState());
3072
- eventBus.on("imagecrop:crop-applied", (data) => {
3073
- const imageData = this.getCurrentImageData();
3074
- if (imageData) {
3075
- console.log("[ImageEditor] Crop applied - updating preserved canvas data");
3076
- this.currentImageData = imageData;
3077
- }
3078
- this.saveState();
3079
- this.updateStatus("Crop applied successfully");
3080
- this.updateHistoryButtons();
3081
- });
3082
- eventBus.on("imagefilters:filter-changed", () => {
3083
- this.saveState();
3084
- this.updateStatus("Filter applied");
3085
- });
3086
- eventBus.on("imagefilters:filters-reset", () => {
3087
- this.saveState();
3088
- this.updateStatus("Filters reset");
3089
- });
3090
- }
3091
- // Action handlers
3092
- async handleActionSwitchMode(e, el) {
3093
- const mode = el.getAttribute("data-mode");
3094
- await this.switchMode(mode);
3095
- }
3096
- async handleActionUndo() {
3097
- this.undo();
3098
- }
3099
- async handleActionRedo() {
3100
- this.redo();
3101
- }
3102
- async handleActionReset() {
3103
- await this.resetAll();
3104
- }
3105
- async handleActionExport() {
3106
- const result = await this.exportImage();
3107
- if (result) {
3108
- this.updateStatus("Image exported successfully");
3109
- }
3110
- }
3111
- // Mode management
3112
- async switchMode(mode, force = false) {
3113
- if (mode === this.currentMode && !force) return;
3114
- if (this.currentView && !force) {
3115
- const imageData = this.getCurrentImageData();
3116
- if (imageData) {
3117
- console.log("[ImageEditor] Preserving canvas state from", this.currentMode, "mode");
3118
- this.currentImageData = imageData;
3119
- } else {
3120
- console.log("[ImageEditor] No canvas data to preserve from", this.currentMode, "mode");
3121
- }
3122
- } else if (force) {
3123
- console.log("[ImageEditor] Force mode switch - not preserving canvas state");
3124
- }
3125
- if (this.currentView) {
3126
- await this.currentView.destroy();
3127
- this.currentView = null;
3128
- }
3129
- const modeButtons = this.element.querySelectorAll(".mode-btn");
3130
- modeButtons.forEach((btn) => {
3131
- btn.classList.remove("active");
3132
- if (btn.getAttribute("data-mode") === mode) {
3133
- btn.classList.add("active");
3134
- }
3135
- });
3136
- this.currentMode = mode;
3137
- this.currentView = this.createChildView(mode);
3138
- if (this.currentView) {
3139
- await this.currentView.render();
3140
- if (mode === "crop" && this.currentView.startCropMode) {
3141
- this.currentView.startCropMode();
3142
- this.updateStatus("Click and drag to select crop area");
3143
- } else if (mode === "transform") {
3144
- this.updateStatus("Use controls to transform the image");
3145
- } else if (mode === "filters") {
3146
- this.updateStatus("Adjust filters to enhance the image");
3147
- }
3148
- }
3149
- this.updateCurrentModeDisplay();
3150
- const eventBus = this.getApp()?.events;
3151
- if (eventBus) {
3152
- eventBus.emit("imageeditor:mode-changed", {
3153
- editor: this,
3154
- mode,
3155
- currentView: this.currentView
3156
- });
3157
- }
3158
- }
3159
- updateCurrentModeDisplay() {
3160
- const modeDisplay = this.element.querySelector(".current-mode");
3161
- if (modeDisplay) {
3162
- modeDisplay.textContent = this.currentMode.charAt(0).toUpperCase() + this.currentMode.slice(1);
3163
- }
3164
- }
3165
- updateStatus(message) {
3166
- const infoDisplay = this.element.querySelector(".image-info");
3167
- if (infoDisplay) {
3168
- infoDisplay.textContent = message;
3169
- }
3170
- }
3171
- // History management
3172
- saveState() {
3173
- if (!this.isInitialized) return;
3174
- const state = {
3175
- mode: this.currentMode,
3176
- transform: this.currentView?.getTransformState?.(),
3177
- filters: this.currentView?.getFilterState?.(),
3178
- imageData: this.currentImageData,
3179
- timestamp: Date.now()
3180
- };
3181
- this.history = this.history.slice(0, this.historyIndex + 1);
3182
- this.history.push(state);
3183
- this.historyIndex = this.history.length - 1;
3184
- if (this.history.length > this.maxHistory) {
3185
- this.history.shift();
3186
- this.historyIndex--;
3187
- }
3188
- this.updateHistoryButtons();
3189
- }
3190
- undo() {
3191
- if (this.historyIndex > 0) {
3192
- this.historyIndex--;
3193
- this.restoreState(this.history[this.historyIndex]);
3194
- }
3195
- }
3196
- redo() {
3197
- if (this.historyIndex < this.history.length - 1) {
3198
- this.historyIndex++;
3199
- this.restoreState(this.history[this.historyIndex]);
3200
- }
3201
- }
3202
- async restoreState(state) {
3203
- if (state.imageData) {
3204
- console.log("[ImageEditor] Restoring preserved canvas data from history");
3205
- this.currentImageData = state.imageData;
3206
- }
3207
- await this.switchMode(state.mode, true);
3208
- if (this.currentView) {
3209
- if (state.transform && this.currentView.setTransformState) {
3210
- this.currentView.setTransformState(state.transform);
3211
- }
3212
- if (state.filters && this.currentView.setFilterState) {
3213
- this.currentView.setFilterState(state.filters);
3214
- }
3215
- }
3216
- this.updateHistoryButtons();
3217
- this.updateStatus(`Restored to ${state.mode} mode`);
3218
- }
3219
- updateHistoryButtons() {
3220
- const undoBtn = this.element.querySelector('[data-action="undo"]');
3221
- const redoBtn = this.element.querySelector('[data-action="redo"]');
3222
- if (undoBtn) undoBtn.disabled = this.historyIndex <= 0;
3223
- if (redoBtn) redoBtn.disabled = this.historyIndex >= this.history.length - 1;
3224
- }
3225
- async resetAll() {
3226
- console.log("[ImageEditor] Resetting - clearing preserved canvas data");
3227
- this.currentImageData = null;
3228
- if (this.currentView && this.currentView.reset) {
3229
- this.currentView.reset();
3230
- }
3231
- await this.switchMode("transform", true);
3232
- this.history = [];
3233
- this.historyIndex = -1;
3234
- this.saveState();
3235
- this.updateStatus("All changes reset");
3236
- }
3237
- // Export functionality
3238
- async exportImage() {
3239
- if (!this.currentView) return null;
3240
- try {
3241
- let imageData = null;
3242
- imageData = this.getCurrentImageData();
3243
- if (imageData) {
3244
- const link = document.createElement("a");
3245
- link.download = this.getExportFilename();
3246
- link.href = imageData;
3247
- document.body.appendChild(link);
3248
- link.click();
3249
- document.body.removeChild(link);
3250
- const eventBus = this.getApp()?.events;
3251
- if (eventBus) {
3252
- eventBus.emit("imageeditor:exported", {
3253
- editor: this,
3254
- imageData,
3255
- filename: link.download
3256
- });
3257
- }
3258
- return { imageData, filename: link.download };
3259
- }
3260
- } catch (error) {
3261
- console.error("Export failed:", error);
3262
- this.updateStatus("Export failed");
3263
- }
3264
- return null;
3265
- }
3266
- getExportFilename() {
3267
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:\-]/g, "");
3268
- return `edited-image-${timestamp}.png`;
3269
- }
3270
- // Public API
3271
- async setImage(imageUrl, alt = "", title = "") {
3272
- console.log("[ImageEditor] Setting new image - clearing preserved canvas data");
3273
- this.imageUrl = imageUrl;
3274
- this.alt = alt;
3275
- this.title = title;
3276
- this.currentImageData = null;
3277
- if (this.currentView && this.currentView.setImage) {
3278
- this.currentView.setImage(imageUrl, alt, title);
3279
- }
3280
- await this.resetAll();
3281
- }
3282
- getCurrentImageData() {
3283
- if (!this.currentView) return null;
3284
- let imageData = null;
3285
- if (this.currentView.exportImageData) {
3286
- imageData = this.currentView.exportImageData();
3287
- } else if (this.currentView.exportFilteredImageData) {
3288
- imageData = this.currentView.exportFilteredImageData();
3289
- }
3290
- return imageData || null;
3291
- }
3292
- // Cleanup
3293
- async onBeforeDestroy() {
3294
- if (this.currentView) {
3295
- await this.currentView.destroy();
3296
- this.currentView = null;
3297
- }
3298
- const eventBus = this.getApp()?.events;
3299
- if (eventBus) {
3300
- eventBus.emit("imageeditor:destroyed", { editor: this });
3301
- }
3302
- }
3303
- // Static method to show editor in a fullscreen dialog
3304
- static async showDialog(imageUrl, options = {}) {
3305
- const {
3306
- title = "Image Editor",
3307
- alt = "Image",
3308
- size = "fullscreen",
3309
- showToolbar = true,
3310
- allowTransform = true,
3311
- allowCrop = true,
3312
- allowFilters = true,
3313
- allowExport = true,
3314
- ...dialogOptions
3315
- } = options;
3316
- const editor = new ImageEditor({
3317
- imageUrl,
3318
- alt,
3319
- title,
3320
- showToolbar,
3321
- allowTransform,
3322
- allowCrop,
3323
- allowFilters,
3324
- allowExport
3325
- });
3326
- const dialog = new Dialog({
3327
- title,
3328
- body: editor,
3329
- size,
3330
- centered: true,
3331
- backdrop: "static",
3332
- keyboard: true,
3333
- buttons: [
3334
- {
3335
- text: "Cancel",
3336
- action: "cancel",
3337
- class: "btn btn-secondary",
3338
- dismiss: true
3339
- },
3340
- {
3341
- text: "Export & Close",
3342
- action: "export-close",
3343
- class: "btn btn-primary"
3344
- }
3345
- ],
3346
- ...dialogOptions
3347
- });
3348
- await dialog.render(true, document.body);
3349
- window.lastDialog = dialog;
3350
- dialog.show();
3351
- return new Promise((resolve) => {
3352
- dialog.on("hidden", () => {
3353
- dialog.destroy();
3354
- resolve({ action: "cancel", editor });
3355
- });
3356
- dialog.on("action:cancel", () => {
3357
- dialog.hide();
3358
- });
3359
- dialog.on("action:export-close", async () => {
3360
- const result = await editor.exportImage();
3361
- dialog.hide();
3362
- resolve({
3363
- action: "export",
3364
- editor,
3365
- data: result?.imageData,
3366
- filename: result?.filename
3367
- });
3368
- });
3369
- });
3370
- }
3371
- }
3372
- window.ImageEditor = ImageEditor;
3373
- class ImageUploadView extends View {
3374
- constructor(options = {}) {
3375
- super({
3376
- ...options,
3377
- className: `image-upload-view ${options.className || ""}`,
3378
- tagName: "div"
3379
- });
3380
- this.autoUpload = options.autoUpload || false;
3381
- this.acceptedTypes = options.acceptedTypes || ["image/jpeg", "image/png", "image/gif", "image/webp"];
3382
- this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024;
3383
- this.uploadUrl = options.uploadUrl || null;
3384
- this.onUpload = options.onUpload || null;
3385
- this.selectedFile = null;
3386
- this.isUploading = false;
3387
- this.previewUrl = null;
3388
- this._handleDragOver = this.handleDragOver.bind(this);
3389
- this._handleDragLeave = this.handleDragLeave.bind(this);
3390
- this._handleDrop = this.handleDrop.bind(this);
3391
- this._handleFileSelect = this.handleFileSelect.bind(this);
3392
- this._preventDefaults = this.preventDefaults.bind(this);
3393
- }
3394
- async getTemplate() {
3395
- return `
3396
- <div class="image-upload-container">
3397
- <!-- Drop Zone -->
3398
- <div class="upload-drop-zone border-2 border-dashed rounded p-4 text-center position-relative"
3399
- style="border-color: #dee2e6; min-height: 200px; transition: all 0.2s ease;">
3400
-
3401
- <!-- Default State -->
3402
- <div class="upload-prompt">
3403
- <i class="bi bi-cloud-upload text-muted" style="font-size: 3rem;"></i>
3404
- <h5 class="mt-3 text-muted">Drop your image here</h5>
3405
- <p class="text-muted mb-3">or</p>
3406
- <button type="button" class="btn btn-outline-primary" data-action="select-file">
3407
- <i class="bi bi-folder2-open"></i> Choose File
3408
- </button>
3409
- <input type="file" class="upload-file-input d-none" accept="image/*" multiple="false">
3410
- <div class="mt-3">
3411
- <small class="text-muted">Supported: JPEG, PNG, GIF, WebP (max ${Math.round(this.maxFileSize / 1024 / 1024)}MB)</small>
3412
- </div>
3413
- </div>
3414
-
3415
- <!-- Preview State -->
3416
- <div class="upload-preview d-none">
3417
- <div class="preview-image-container mb-3">
3418
- <img class="preview-image img-fluid rounded shadow-sm" style="max-height: 300px; max-width: 100%;">
3419
- </div>
3420
- <div class="preview-info">
3421
- <div class="file-name fw-bold mb-2 text-truncate"></div>
3422
- <div class="file-details text-muted small mb-3"></div>
3423
- <div class="upload-actions">
3424
- {{#autoUpload}}
3425
- <button type="button" class="btn btn-outline-secondary" data-action="clear">
3426
- <i class="bi bi-x"></i> Clear
3427
- </button>
3428
- {{/autoUpload}}
3429
- {{^autoUpload}}
3430
- <button type="button" class="btn btn-success me-2" data-action="upload">
3431
- <i class="bi bi-cloud-arrow-up"></i> Upload
3432
- </button>
3433
- <button type="button" class="btn btn-outline-secondary" data-action="clear">
3434
- <i class="bi bi-x"></i> Clear
3435
- </button>
3436
- {{/autoUpload}}
3437
- </div>
3438
- </div>
3439
- </div>
3440
-
3441
- <!-- Loading State -->
3442
- <div class="upload-loading d-none">
3443
- <div class="spinner-border text-primary mb-3" role="status">
3444
- <span class="visually-hidden">Uploading...</span>
3445
- </div>
3446
- <div class="upload-progress">
3447
- <div class="progress mb-2" style="height: 8px;">
3448
- <div class="progress-bar progress-bar-striped progress-bar-animated"
3449
- role="progressbar" style="width: 0%"></div>
3450
- </div>
3451
- <small class="text-muted upload-status">Uploading...</small>
3452
- </div>
3453
- </div>
3454
- </div>
3455
-
3456
- <!-- Upload Result -->
3457
- <div class="upload-result mt-3 d-none">
3458
- <div class="alert" role="alert"></div>
3459
- </div>
3460
- </div>
3461
- `;
3462
- }
3463
- async onAfterRender() {
3464
- this.dropZone = this.element.querySelector(".upload-drop-zone");
3465
- this.fileInput = this.element.querySelector(".upload-file-input");
3466
- this.promptElement = this.element.querySelector(".upload-prompt");
3467
- this.previewElement = this.element.querySelector(".upload-preview");
3468
- this.loadingElement = this.element.querySelector(".upload-loading");
3469
- this.resultElement = this.element.querySelector(".upload-result");
3470
- this.previewImage = this.element.querySelector(".preview-image");
3471
- this.fileName = this.element.querySelector(".file-name");
3472
- this.fileDetails = this.element.querySelector(".file-details");
3473
- this.progressBar = this.element.querySelector(".progress-bar");
3474
- this.uploadStatus = this.element.querySelector(".upload-status");
3475
- this.setupEventListeners();
3476
- }
3477
- setupEventListeners() {
3478
- this.dropZone.addEventListener("dragenter", this._preventDefaults);
3479
- this.dropZone.addEventListener("dragover", this._handleDragOver);
3480
- this.dropZone.addEventListener("dragleave", this._handleDragLeave);
3481
- this.dropZone.addEventListener("drop", this._handleDrop);
3482
- this.fileInput.addEventListener("change", this._handleFileSelect);
3483
- ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
3484
- document.addEventListener(eventName, this._preventDefaults);
3485
- });
3486
- }
3487
- preventDefaults(e) {
3488
- e.preventDefault();
3489
- e.stopPropagation();
3490
- }
3491
- handleDragOver(e) {
3492
- this.preventDefaults(e);
3493
- this.dropZone.classList.add("border-primary", "bg-light");
3494
- this.dropZone.style.borderColor = "#0d6efd";
3495
- }
3496
- handleDragLeave(e) {
3497
- this.preventDefaults(e);
3498
- if (!this.dropZone.contains(e.relatedTarget)) {
3499
- this.dropZone.classList.remove("border-primary", "bg-light");
3500
- this.dropZone.style.borderColor = "#dee2e6";
3501
- }
3502
- }
3503
- async handleDrop(e) {
3504
- this.preventDefaults(e);
3505
- this.dropZone.classList.remove("border-primary", "bg-light");
3506
- this.dropZone.style.borderColor = "#dee2e6";
3507
- const files = Array.from(e.dataTransfer.files);
3508
- if (files.length > 0) {
3509
- await this.processFile(files[0]);
3510
- }
3511
- }
3512
- async handleFileSelect(e) {
3513
- const files = Array.from(e.target.files);
3514
- if (files.length > 0) {
3515
- await this.processFile(files[0]);
3516
- }
3517
- }
3518
- async processFile(file) {
3519
- const validation = this.validateFile(file);
3520
- if (!validation.valid) {
3521
- this.showError(validation.error);
3522
- return;
3523
- }
3524
- this.selectedFile = file;
3525
- await this.showPreview(file);
3526
- if (this.autoUpload) {
3527
- setTimeout(() => this.uploadFile(), 100);
3528
- }
3529
- }
3530
- validateFile(file) {
3531
- if (!this.acceptedTypes.includes(file.type)) {
3532
- return {
3533
- valid: false,
3534
- error: `File type "${file.type}" is not supported. Please use: ${this.acceptedTypes.map((t) => t.split("/")[1].toUpperCase()).join(", ")}`
3535
- };
3536
- }
3537
- if (file.size > this.maxFileSize) {
3538
- return {
3539
- valid: false,
3540
- error: `File size (${this.formatFileSize(file.size)}) exceeds maximum allowed size (${this.formatFileSize(this.maxFileSize)})`
3541
- };
3542
- }
3543
- return { valid: true };
3544
- }
3545
- async showPreview(file) {
3546
- if (this.previewUrl) {
3547
- URL.revokeObjectURL(this.previewUrl);
3548
- }
3549
- this.previewUrl = URL.createObjectURL(file);
3550
- this.previewImage.src = this.previewUrl;
3551
- this.fileName.textContent = file.name;
3552
- this.fileDetails.textContent = `${this.formatFileSize(file.size)} • ${file.type.split("/")[1].toUpperCase()}`;
3553
- this.promptElement.classList.add("d-none");
3554
- this.previewElement.classList.remove("d-none");
3555
- this.hideResult();
3556
- this.emitUploadEvent("preview", { file, previewUrl: this.previewUrl });
3557
- }
3558
- async uploadFile() {
3559
- if (!this.selectedFile || this.isUploading) return;
3560
- this.isUploading = true;
3561
- this.showLoading();
3562
- try {
3563
- let result;
3564
- if (this.onUpload && typeof this.onUpload === "function") {
3565
- result = await this.onUpload(this.selectedFile, this.updateProgress.bind(this));
3566
- } else if (this.uploadUrl) {
3567
- result = await this.uploadToUrl(this.selectedFile);
3568
- } else {
3569
- throw new Error("No upload method configured. Provide either uploadUrl or onUpload callback.");
3570
- }
3571
- this.showSuccess("File uploaded successfully!");
3572
- this.emitUploadEvent("upload-success", { file: this.selectedFile, result });
3573
- } catch (error) {
3574
- console.error("Upload failed:", error);
3575
- this.showError(`Upload failed: ${error.message}`);
3576
- this.emitUploadEvent("upload-error", { file: this.selectedFile, error });
3577
- } finally {
3578
- this.isUploading = false;
3579
- this.hideLoading();
3580
- }
3581
- }
3582
- async uploadToUrl(file) {
3583
- return new Promise((resolve, reject) => {
3584
- const formData = new FormData();
3585
- formData.append("image", file);
3586
- const xhr = new XMLHttpRequest();
3587
- xhr.upload.addEventListener("progress", (e) => {
3588
- if (e.lengthComputable) {
3589
- const progress = Math.round(e.loaded / e.total * 100);
3590
- this.updateProgress(progress);
3591
- }
3592
- });
3593
- xhr.addEventListener("load", () => {
3594
- if (xhr.status >= 200 && xhr.status < 300) {
3595
- try {
3596
- const response = JSON.parse(xhr.responseText);
3597
- resolve(response);
3598
- } catch (e) {
3599
- resolve({ success: true, response: xhr.responseText });
3600
- }
3601
- } else {
3602
- reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
3603
- }
3604
- });
3605
- xhr.addEventListener("error", () => {
3606
- reject(new Error("Network error occurred"));
3607
- });
3608
- xhr.addEventListener("timeout", () => {
3609
- reject(new Error("Upload timeout"));
3610
- });
3611
- xhr.open("POST", this.uploadUrl);
3612
- xhr.timeout = 3e4;
3613
- xhr.send(formData);
3614
- });
3615
- }
3616
- updateProgress(percent) {
3617
- if (this.progressBar) {
3618
- this.progressBar.style.width = `${percent}%`;
3619
- this.progressBar.setAttribute("aria-valuenow", percent);
3620
- }
3621
- if (this.uploadStatus) {
3622
- this.uploadStatus.textContent = `Uploading... ${percent}%`;
3623
- }
3624
- }
3625
- showLoading() {
3626
- this.previewElement.classList.add("d-none");
3627
- this.loadingElement.classList.remove("d-none");
3628
- this.updateProgress(0);
3629
- }
3630
- hideLoading() {
3631
- this.loadingElement.classList.add("d-none");
3632
- if (!this.autoUpload || this.selectedFile) {
3633
- this.previewElement.classList.remove("d-none");
3634
- }
3635
- }
3636
- showSuccess(message) {
3637
- this.showResult("success", message);
3638
- }
3639
- showError(message) {
3640
- this.showResult("danger", message);
3641
- }
3642
- showResult(type, message) {
3643
- const alertElement = this.resultElement.querySelector(".alert");
3644
- const icon = type === "success" ? "check-circle-fill" : "exclamation-triangle-fill";
3645
- alertElement.className = `alert alert-${type}`;
3646
- alertElement.innerHTML = `
3647
- <i class="bi bi-${icon} me-2"></i>
3648
- ${message}
3649
- `;
3650
- this.resultElement.classList.remove("d-none");
3651
- if (type === "success") {
3652
- setTimeout(() => this.hideResult(), 5e3);
3653
- }
3654
- }
3655
- hideResult() {
3656
- this.resultElement.classList.add("d-none");
3657
- }
3658
- clearFile() {
3659
- if (this.previewUrl) {
3660
- URL.revokeObjectURL(this.previewUrl);
3661
- this.previewUrl = null;
3662
- }
3663
- this.selectedFile = null;
3664
- this.isUploading = false;
3665
- this.fileInput.value = "";
3666
- this.previewElement.classList.add("d-none");
3667
- this.loadingElement.classList.add("d-none");
3668
- this.promptElement.classList.remove("d-none");
3669
- this.hideResult();
3670
- this.emitUploadEvent("cleared");
3671
- }
3672
- formatFileSize(bytes) {
3673
- if (bytes === 0) return "0 Bytes";
3674
- const k = 1024;
3675
- const sizes = ["Bytes", "KB", "MB", "GB"];
3676
- const i = Math.floor(Math.log(bytes) / Math.log(k));
3677
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
3678
- }
3679
- emitUploadEvent(type, data = {}) {
3680
- const eventBus = this.getApp()?.events;
3681
- if (eventBus) {
3682
- eventBus.emit(`imageupload:${type}`, {
3683
- view: this,
3684
- ...data
3685
- });
3686
- }
3687
- }
3688
- // Action handlers
3689
- async handleActionSelectFile() {
3690
- this.fileInput.click();
3691
- }
3692
- async handleActionUpload() {
3693
- await this.uploadFile();
3694
- }
3695
- async handleActionClear() {
3696
- this.clearFile();
3697
- }
3698
- // Cleanup
3699
- async onBeforeDestroy() {
3700
- if (this.previewUrl) {
3701
- URL.revokeObjectURL(this.previewUrl);
3702
- this.previewUrl = null;
3703
- }
3704
- if (this.dropZone) {
3705
- this.dropZone.removeEventListener("dragenter", this._preventDefaults);
3706
- this.dropZone.removeEventListener("dragover", this._handleDragOver);
3707
- this.dropZone.removeEventListener("dragleave", this._handleDragLeave);
3708
- this.dropZone.removeEventListener("drop", this._handleDrop);
3709
- }
3710
- if (this.fileInput) {
3711
- this.fileInput.removeEventListener("change", this._handleFileSelect);
3712
- }
3713
- ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
3714
- document.removeEventListener(eventName, this._preventDefaults);
3715
- });
3716
- this.emitUploadEvent("destroyed");
3717
- }
3718
- }
3719
- window.ImageUploadView = ImageUploadView;
3720
- export {
3721
- B as BUILD_TIME,
3722
- ImageCanvasView,
3723
- ImageCropView,
3724
- ImageEditor,
3725
- ImageFiltersView,
3726
- ImageTransformView,
3727
- ImageUploadView,
3728
- ImageViewer,
3729
- L as LightboxGallery,
3730
- P as PDFViewer,
3731
- a as VERSION,
3732
- V as VERSION_INFO,
3733
- b as VERSION_MAJOR,
3734
- c as VERSION_MINOR,
3735
- d as VERSION_REVISION,
3736
- W as WebApp
3737
- };
1
+ import{V as t}from"./chunks/Rest-DHbszkuP.js";import e from"./chunks/Dialog-BcgSR01Z.js";import{L as i,P as a}from"./chunks/PDFViewer-CsyKn-gh.js";import{W as s}from"./chunks/WebApp-CULZpO_0.js";import{B as n,V as o,a as r,b as l,c as h,d as c}from"./chunks/version-C3dnl1bg.js";class ImageViewer extends t{constructor(t={}){super({...t,className:`image-viewer ${t.className||""}`,tagName:"div"}),this.imageUrl=t.imageUrl||t.src||"",this.alt=t.alt||"Image",this.title=t.title||"",this.canvas=null,this.context=null,this.image=null,this.scale=1,this.rotation=0,this.translateX=0,this.translateY=0,this.minScale=.1,this.maxScale=5,this.scaleStep=.1,this.isDragging=!1,this.lastPointerX=0,this.lastPointerY=0,this.isLoaded=!1,this.showControls=!1!==t.showControls,this.allowRotate=!1!==t.allowRotate,this.allowZoom=!1!==t.allowZoom,this.allowPan=!1!==t.allowPan,this.allowDownload=!1!==t.allowDownload,this.autoFit=!1!==t.autoFit,this.containerElement=null,this.controlsElement=null}async getTemplate(){return'\n <div class="image-viewer-container d-flex flex-column h-100" data-container="imageContainer">\n <div class="image-viewer-content flex-grow-1 position-relative">\n <canvas class="image-viewer-canvas w-100 h-100" data-container="canvas"></canvas>\n <div class="image-viewer-overlay">\n <div class="image-viewer-loading">\n <div class="spinner-border text-light" role="status">\n <span class="visually-hidden">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n\n {{#showControls}}\n <div class="image-viewer-controls position-absolute top-0 start-50 translate-middle-x mt-3" data-container="controls" style="z-index: 10;">\n <div class="btn-group" role="group">\n {{#allowZoom}}\n <button type="button" class="btn btn-dark btn-sm" data-action="zoom-in" title="Zoom In">\n <i class="bi bi-zoom-in"></i>\n </button>\n <button type="button" class="btn btn-dark btn-sm" data-action="zoom-out" title="Zoom Out">\n <i class="bi bi-zoom-out"></i>\n </button>\n <button type="button" class="btn btn-dark btn-sm" data-action="zoom-fit" title="Fit to Screen">\n <i class="bi bi-arrows-fullscreen"></i>\n </button>\n <button type="button" class="btn btn-dark btn-sm" data-action="zoom-actual" title="Actual Size">\n <i class="bi bi-1-square"></i>\n </button>\n {{/allowZoom}}\n\n {{#allowRotate}}\n <button type="button" class="btn btn-dark btn-sm" data-action="rotate-left" title="Rotate Left">\n <i class="bi bi-arrow-counterclockwise"></i>\n </button>\n <button type="button" class="btn btn-dark btn-sm" data-action="rotate-right" title="Rotate Right">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n {{/allowRotate}}\n\n <button type="button" class="btn btn-dark btn-sm" data-action="reset" title="Reset View">\n <i class="bi bi-arrow-repeat"></i>\n </button>\n\n {{#allowDownload}}\n <button type="button" class="btn btn-dark btn-sm" data-action="download" title="Download Image">\n <i class="bi bi-download"></i>\n </button>\n {{/allowDownload}}\n </div>\n\n <div class="image-viewer-info">\n <span class="zoom-level">{{scale}}%</span>\n </div>\n </div>\n {{/showControls}}\n </div>\n '}async onAfterRender(){this.canvas=this.element.querySelector(".image-viewer-canvas"),this.context=this.canvas.getContext("2d"),this.containerElement=this.element.querySelector(".image-viewer-content"),this.controlsElement=this.element.querySelector(".image-viewer-controls"),this.setupCanvas(),this.setupEventListeners(),this.imageUrl&&this.loadImage(this.imageUrl)}setupCanvas(){this.canvas&&this.containerElement&&(this.context&&(this.context.imageSmoothingEnabled=!0,this.context.imageSmoothingQuality="high"),setTimeout(()=>{this.resizeCanvas(),this.isLoaded&&this.image&&this.renderCanvas()},2e3))}resizeCanvas(){if(!this.canvas)return;const t=window.innerWidth,e=window.innerHeight,i=Math.floor(.8*t),a=Math.floor(.8*e);if(i===this.canvasWidth&&a===this.canvasHeight)return;const s=window.devicePixelRatio||1;this.canvasWidth=i,this.canvasHeight=a,this.canvas.width=i*s,this.canvas.height=a*s,this.canvas.style.width=i+"px",this.canvas.style.height=a+"px",this.context.setTransform(s,0,0,s,0,0),this.isLoaded&&this.image&&this.renderCanvas()}setupEventListeners(){this.canvas&&(this.allowPan&&(this.canvas.addEventListener("mousedown",t=>this.handleMouseDown(t)),document.addEventListener("mousemove",t=>this.handleMouseMove(t)),document.addEventListener("mouseup",t=>this.handleMouseUp(t))),this.allowZoom&&this.canvas.addEventListener("wheel",t=>this.handleWheel(t),{passive:!1}),this.canvas.addEventListener("touchstart",t=>this.handleTouchStart(t),{passive:!1}),this.canvas.addEventListener("touchmove",t=>this.handleTouchMove(t),{passive:!1}),this.canvas.addEventListener("touchend",t=>this.handleTouchEnd(t)),this.canvas.addEventListener("contextmenu",t=>t.preventDefault()))}async handleActionZoomIn(){this.zoomIn()}async handleActionZoomOut(){this.zoomOut()}async handleActionZoomFit(){this.fitToContainer()}async handleActionZoomActual(){this.setScale(1),this.renderCanvas()}async handleActionRotateLeft(){this.rotate(-90)}async handleActionRotateRight(){this.rotate(90)}async handleActionReset(){this.reset()}async handleActionDownload(){this.downloadImage()}loadImage(t){this.isLoaded=!1,this.element.classList.remove("loaded");const e=new Image;e.crossOrigin="anonymous",e.onload=()=>{this.image=e,this.handleImageLoad()},e.onerror=()=>{this.handleImageError()},e.src=t}handleImageLoad(){this.isLoaded=!0,this.element.classList.add("loaded");const t=()=>{this.canvasWidth&&this.canvasHeight||this.resizeCanvas(),this.autoFit?this.fitToContainer():this.smartFit(),this.renderCanvas(),this.updateControls()};this.canvasWidth&&this.canvasHeight?requestAnimationFrame(t):setTimeout(t,2e3);const e=this.getApp()?.events;e&&e.emit("imageviewer:loaded",{viewer:this,imageUrl:this.imageUrl,naturalWidth:this.image.naturalWidth,naturalHeight:this.image.naturalHeight})}handleImageError(){console.error("Failed to load image:",this.imageUrl);const t=this.getApp()?.events;t&&t.emit("imageviewer:error",{viewer:this,imageUrl:this.imageUrl,error:"Failed to load image"})}handleMouseDown(t){if(!this.allowPan||0!==t.button)return;t.preventDefault(),this.isDragging=!0;const e=this.canvas.getBoundingClientRect();this.lastPointerX=t.clientX-e.left,this.lastPointerY=t.clientY-e.top,this.canvas.style.cursor="grabbing"}handleMouseMove(t){if(!this.isDragging||!this.allowPan)return;t.preventDefault();const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top,s=i-this.lastPointerX,n=a-this.lastPointerY;this.pan(s,n),this.lastPointerX=i,this.lastPointerY=a}handleMouseUp(t){this.isDragging&&(this.isDragging=!1,this.canvas.style.cursor=this.allowPan?"grab":"default")}handleWheel(t){if(!this.allowZoom)return;t.preventDefault();const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top,s=t.deltaY>0?.5*-this.scaleStep:.5*this.scaleStep;this.zoomAtPoint(this.scale+s,i,a)}handleTouchStart(t){if(1===t.touches.length&&this.allowPan){t.preventDefault();const e=t.touches[0],i=this.canvas.getBoundingClientRect();this.isDragging=!0,this.lastPointerX=e.clientX-i.left,this.lastPointerY=e.clientY-i.top}}handleTouchMove(t){if(1===t.touches.length&&this.isDragging&&this.allowPan){t.preventDefault();const e=t.touches[0],i=this.canvas.getBoundingClientRect(),a=e.clientX-i.left,s=e.clientY-i.top,n=a-this.lastPointerX,o=s-this.lastPointerY;this.pan(n,o),this.lastPointerX=a,this.lastPointerY=s}}handleTouchEnd(t){this.isDragging=!1}zoomIn(){this.setScale(this.scale+this.scaleStep)}zoomOut(){this.setScale(this.scale-this.scaleStep)}setScale(t){const e=this.scale;this.scale=Math.max(this.minScale,Math.min(this.maxScale,t)),this.renderCanvas(),this.updateControls();const i=this.getApp()?.events;i&&e!==this.scale&&i.emit("imageviewer:scale-changed",{viewer:this,oldScale:e,newScale:this.scale})}zoomAtPoint(t,e,i){if(!this.image)return;const a=this.scale;if(this.setScale(t),a!==this.scale){const t=this.scale/a,s=this.canvasWidth/2,n=this.canvasHeight/2;this.translateX=(this.translateX-(e-s))*t+(e-s),this.translateY=(this.translateY-(i-n))*t+(i-n),this.renderCanvas()}}pan(t,e){this.translateX+=t,this.translateY+=e,this.renderCanvas()}rotate(t){const e=this.rotation;this.rotation=(this.rotation+t)%360,this.rotation<0&&(this.rotation+=360),this.renderCanvas();const i=this.getApp()?.events;i&&i.emit("imageviewer:rotated",{viewer:this,oldRotation:e,newRotation:this.rotation,degrees:t})}center(){this.translateX=0,this.translateY=0,this.renderCanvas()}fitToContainer(){if(!this.image||!this.canvasWidth||!this.canvasHeight)return;const t=this.canvasWidth-40,e=this.canvasHeight-40,i=t/this.image.naturalWidth,a=e/this.image.naturalHeight,s=Math.min(i,a,1);this.setScale(s),this.renderCanvas()}smartFit(){if(!this.image||!this.canvasWidth||!this.canvasHeight)return;const t=(this.canvasWidth-80)/this.image.naturalWidth,e=(this.canvasHeight-80)/this.image.naturalHeight,i=Math.min(t,e);i<1&&this.setScale(i),this.renderCanvas()}reset(){this.scale=1,this.rotation=0,this.translateX=0,this.translateY=0,this.renderCanvas(),this.updateControls()}renderCanvas(){this.context&&this.canvasWidth&&this.canvasHeight&&(this.context.clearRect(0,0,this.canvasWidth,this.canvasHeight),this.image&&this.isLoaded&&(this.context.save(),this.context.translate(this.canvasWidth/2+this.translateX,this.canvasHeight/2+this.translateY),this.context.scale(this.scale,this.scale),this.context.rotate(this.rotation*Math.PI/180),this.context.drawImage(this.image,-this.image.naturalWidth/2,-this.image.naturalHeight/2),this.context.restore()))}downloadImage(){if(this.canvas)try{const t=document.createElement("a");t.download=this.getDownloadFilename(),t.href=this.canvas.toDataURL("image/png"),document.body.appendChild(t),t.click(),document.body.removeChild(t);const e=this.getApp()?.events;e&&e.emit("imageviewer:downloaded",{viewer:this,filename:t.download})}catch(t){console.error("Failed to download image:",t);const e=this.getApp()?.events;e&&e.emit("imageviewer:download-error",{viewer:this,error:t.message})}}getDownloadFilename(){if(this.title)return`${this.title.replace(/[^a-z0-9]/gi,"_").toLowerCase()}.png`;try{const t=new URL(this.imageUrl).pathname.split("/").pop();if(t&&t.includes("."))return t.replace(/\.[^.]+$/,".png")}catch(t){}return"image.png"}updateControls(){if(!this.controlsElement)return;const t=this.controlsElement.querySelector(".zoom-level");t&&(t.textContent=`${Math.round(100*this.scale)}%`);const e=this.controlsElement.querySelector('[data-action="zoom-in"]'),i=this.controlsElement.querySelector('[data-action="zoom-out"]');e&&(e.disabled=this.scale>=this.maxScale),i&&(i.disabled=this.scale<=this.minScale)}setImage(t,e="",i=""){const a=this.imageUrl;this.imageUrl=t,this.alt=e,this.title=i,this.reset(),this.loadImage(t);const s=this.getApp()?.events;s&&s.emit("imageviewer:image-changed",{viewer:this,oldImageUrl:a,newImageUrl:t})}getCurrentState(){return{scale:this.scale,rotation:this.rotation,translateX:this.translateX,translateY:this.translateY}}setState(t){void 0!==t.scale&&(this.scale=t.scale),void 0!==t.rotation&&(this.rotation=t.rotation),void 0!==t.translateX&&(this.translateX=t.translateX),void 0!==t.translateY&&(this.translateY=t.translateY),this.renderCanvas(),this.updateControls()}async onBeforeDestroy(){this.isDragging&&(this.isDragging=!1);const t=this.getApp()?.events;t&&t.emit("imageviewer:destroyed",{viewer:this})}static async showDialog(t,i={}){const{title:a="Image Viewer",alt:s="Image",size:n="fullscreen",showControls:o=!0,allowRotate:r=!0,allowZoom:l=!0,allowPan:h=!0,allowDownload:c=!0,...d}=i,m=new ImageViewer({imageUrl:t,alt:s,title:a,showControls:o,allowRotate:r,allowZoom:l,allowPan:h,allowDownload:c,autoFit:!0});return e.showDialog({title:a,body:m,size:n,centered:!0,backdrop:"static",keyboard:!0,buttons:[{text:"Close",action:"close",class:"btn btn-secondary",dismiss:!0}],...d})}}window.ImageViewer=ImageViewer;class ImageCanvasView extends t{constructor(t={}){super({...t,className:`image-canvas-view ${t.className||""}`,tagName:"div"}),this.imageUrl=t.imageUrl||t.src||"",this.alt=t.alt||"Image",this.title=t.title||"",this.canvas=null,this.context=null,this.image=null,this.canvasWidth=0,this.canvasHeight=0,this.maxCanvasHeightPercent=t.maxCanvasHeightPercent||.7,this.maxCanvasWidthPercent=t.maxCanvasWidthPercent||.8,this.canvasSizes={sm:{width:400,height:300},md:{width:600,height:450},lg:{width:800,height:600},xl:{width:1e3,height:750},fullscreen:{width:0,height:0},auto:{width:0,height:0}},this.canvasSize=t.canvasSize||"auto",this.isLoaded=!1,this.isRendering=!1,this.autoFit=!1!==t.autoFit,this.crossOrigin=t.crossOrigin||"anonymous"}async getTemplate(){return'\n <div class="image-canvas-container d-flex flex-column h-100">\n <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">\n <canvas class="image-canvas w-100 h-100" data-container="canvas"></canvas>\n\n \x3c!-- Loading Overlay --\x3e\n <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"\n style="display: none; z-index: 10;">\n <div class="spinner-border text-primary" role="status">\n <span class="visually-hidden">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n '}async onAfterRender(){this.canvas=this.element.querySelector("canvas"),this.context=this.canvas.getContext("2d"),this.containerElement=this.element.querySelector(".image-canvas-content"),this.loadingElement=this.element.querySelector(".image-canvas-loading"),this.setupCanvas(),this.imageUrl&&this.loadImage(this.imageUrl)}setupCanvas(){this.canvas&&this.containerElement&&(this.setCanvasSize(this.canvasSize),"fullscreen"===this.canvasSize&&(this._resizeHandler=()=>this.setCanvasSize("fullscreen"),window.addEventListener("resize",this._resizeHandler)),this.context.imageSmoothingEnabled=!0,this.context.imageSmoothingQuality="high")}setCanvasSize(t){const e=this.canvasSizes[t];if(!e&&"auto"!==t)return;let i,a;if("fullscreen"===t)i=Math.min(1200,.9*window.innerWidth),a=Math.min(900,.8*window.innerHeight);else if("auto"!==t&&e){const t=window.innerWidth*this.maxCanvasWidthPercent,s=window.innerHeight*this.maxCanvasHeightPercent;if(e.width>t||e.height>s)if(this.image){const e=t/this.image.naturalWidth,n=s/this.image.naturalHeight,o=Math.min(e,n,1);i=Math.floor(this.image.naturalWidth*o),a=Math.floor(this.image.naturalHeight*o),i=Math.max(300,i),a=Math.max(200,a)}else i=Math.min(600,t),a=Math.min(450,s);else i=e.width,a=e.height}else if(this.image){const t=window.innerWidth*this.maxCanvasWidthPercent,e=window.innerHeight*this.maxCanvasHeightPercent,s=t/this.image.naturalWidth,n=e/this.image.naturalHeight,o=Math.min(s,n,1);i=Math.floor(this.image.naturalWidth*o),a=Math.floor(this.image.naturalHeight*o),i=Math.max(300,i),a=Math.max(200,a)}else i=Math.min(600,window.innerWidth*this.maxCanvasWidthPercent),a=Math.min(450,window.innerHeight*this.maxCanvasHeightPercent);if(i=Math.min(i,window.innerWidth*this.maxCanvasWidthPercent),a=Math.min(a,window.innerHeight*this.maxCanvasHeightPercent),Math.abs(i-this.canvasWidth)<10&&Math.abs(a-this.canvasHeight)<10)return;const s=window.devicePixelRatio||1;this.canvasWidth=i,this.canvasHeight=a,this.canvas.width=i*s,this.canvas.height=a*s,this.canvas.style.width=i+"px",this.canvas.style.height=a+"px",this.context.setTransform(s,0,0,s,0,0),this.isLoaded&&this.renderCanvas()}loadImage(t){if(!t)return;this.imageUrl=t,this.isLoaded=!1,this.element.classList.remove("loaded"),this.showLoading();const e=new Image;this.crossOrigin&&(e.crossOrigin=this.crossOrigin),e.onload=()=>{this.image=e,this.handleImageLoad()},e.onerror=()=>{this.handleImageError()},e.src=t}handleImageLoad(){this.isLoaded=!0,this.element.classList.add("loaded"),this.hideLoading(),"auto"===this.canvasSize?this.setCanvasSize("auto"):this.autoFit&&this.fitToContainer(),this.renderCanvas();const t=this.getApp()?.events;t&&t.emit("imagecanvas:loaded",{view:this,imageUrl:this.imageUrl,naturalWidth:this.image.naturalWidth,naturalHeight:this.image.naturalHeight})}handleImageError(){console.error("Failed to load image:",this.imageUrl),this.hideLoading();const t=this.getApp()?.events;t&&t.emit("imagecanvas:error",{view:this,imageUrl:this.imageUrl,error:"Failed to load image"})}showLoading(){this.loadingElement&&(this.loadingElement.style.display="block")}hideLoading(){this.loadingElement&&(this.loadingElement.style.display="none")}renderCanvas(){this.context&&this.canvasWidth&&this.canvasHeight&&!this.isRendering&&(this.isRendering=!0,this.context.clearRect(0,0,this.canvasWidth,this.canvasHeight),this.image&&this.isLoaded?(this.context.save(),this.renderImage(),this.context.restore(),this.isRendering=!1):this.isRendering=!1)}renderImage(){if(!this.image)return;const t=this.canvasWidth/this.image.naturalWidth,e=this.canvasHeight/this.image.naturalHeight,i=Math.min(t,e,1),a=this.image.naturalWidth*i,s=this.image.naturalHeight*i,n=(this.canvasWidth-a)/2,o=(this.canvasHeight-s)/2;this.context.drawImage(this.image,n,o,a,s)}fitToContainer(){this.image&&this.canvasWidth&&this.canvasHeight&&(this.canvasWidth,this.canvasHeight,this.image.naturalWidth,this.image.naturalHeight,"auto"===this.canvasSize&&this.setCanvasSize("auto"),this.renderCanvas())}center(){this.renderCanvas()}reset(){this.renderCanvas()}exportImageData(){if(!this.canvas)return null;try{return this.canvas.toDataURL("image/png")}catch(t){return console.error("Failed to export image data:",t),null}}exportImageBlob(t=.9){return this.canvas?new Promise(e=>{try{this.canvas.toBlob(t=>{e(t)},"image/png",t)}catch(i){console.error("Failed to export image blob:",i),e(null)}}):Promise.resolve(null)}setImage(t,e="",i=""){const a=this.imageUrl;this.alt=e,this.title=i,this.loadImage(t);const s=this.getApp()?.events;s&&s.emit("imagecanvas:image-changed",{view:this,oldImageUrl:a,newImageUrl:t})}getImageData(){return{imageUrl:this.imageUrl,alt:this.alt,title:this.title,naturalWidth:this.image?.naturalWidth||0,naturalHeight:this.image?.naturalHeight||0,isLoaded:this.isLoaded}}async onBeforeDestroy(){this.isLoaded=!1,this.isRendering=!1,this.image=null,this._resizeHandler&&window.removeEventListener("resize",this._resizeHandler);const t=this.getApp()?.events;t&&t.emit("imagecanvas:destroyed",{view:this})}}window.ImageCanvasView=ImageCanvasView;class ImageTransformView extends ImageCanvasView{constructor(t={}){super({...t,className:`image-transform-view ${t.className||""}`}),this.scale=1,this.rotation=0,this.translateX=0,this.translateY=0,this.minScale=.1,this.maxScale=5,this.scaleStep=.02,this.isDragging=!1,this.lastPointerX=0,this.lastPointerY=0,this.allowPan=!1!==t.allowPan,this.allowZoom=!1!==t.allowZoom,this.allowRotate=!1!==t.allowRotate,this.allowKeyboard=!1!==t.allowKeyboard,this._handleMouseMove=this.handleMouseMove.bind(this),this._handleMouseUp=this.handleMouseUp.bind(this),this._handleKeyboard=this.handleKeyboard.bind(this),t.maxCanvasHeightPercent||(this.maxCanvasHeightPercent=.6)}async getTemplate(){return'\n <div class="image-transform-container d-flex flex-column h-100">\n \x3c!-- Transform Toolbar --\x3e\n <div class="image-transform-toolbar bg-light border-bottom p-2">\n <div class="btn-toolbar justify-content-center" role="toolbar">\n <div class="btn-group me-2" role="group" aria-label="Zoom controls">\n <button type="button" class="btn btn-outline-primary btn-sm" data-action="zoom-in" title="Zoom In">\n <i class="bi bi-zoom-in"></i>\n </button>\n <button type="button" class="btn btn-outline-primary btn-sm" data-action="zoom-out" title="Zoom Out">\n <i class="bi bi-zoom-out"></i>\n </button>\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="fit-to-screen" title="Fit to Screen">\n <i class="bi bi-arrows-fullscreen"></i>\n </button>\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="actual-size" title="Actual Size">\n <i class="bi bi-1-square"></i>\n </button>\n </div>\n\n <div class="btn-group me-2" role="group" aria-label="Rotate controls">\n <button type="button" class="btn btn-outline-info btn-sm" data-action="rotate-left" title="Rotate Left">\n <i class="bi bi-arrow-counterclockwise"></i>\n </button>\n <button type="button" class="btn btn-outline-info btn-sm" data-action="rotate-right" title="Rotate Right">\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n </div>\n\n <div class="btn-group" role="group" aria-label="Position controls">\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="center-image" title="Center Image">\n <i class="bi bi-bullseye"></i>\n </button>\n </div>\n </div>\n </div>\n\n \x3c!-- Canvas Area --\x3e\n <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">\n <canvas class="image-canvas" data-container="canvas"></canvas>\n\n \x3c!-- Loading Overlay --\x3e\n <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"\n style="display: none; z-index: 10;">\n <div class="spinner-border text-primary" role="status">\n <span class="visually-hidden">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n '}async onAfterRender(){await super.onAfterRender(),this.setupInteractionListeners()}setupInteractionListeners(){this.canvas&&(this.allowPan&&(this.canvas.addEventListener("mousedown",t=>this.handleMouseDown(t)),document.addEventListener("mousemove",this._handleMouseMove),document.addEventListener("mouseup",this._handleMouseUp)),this.allowZoom&&this.canvas.addEventListener("wheel",t=>this.handleWheel(t),{passive:!1}),this.canvas.addEventListener("touchstart",t=>this.handleTouchStart(t),{passive:!1}),this.canvas.addEventListener("touchmove",t=>this.handleTouchMove(t),{passive:!1}),this.canvas.addEventListener("touchend",t=>this.handleTouchEnd(t)),this.allowKeyboard&&document.addEventListener("keydown",this._handleKeyboard),this.canvas.addEventListener("contextmenu",t=>t.preventDefault()),this.canvas.style.cursor=this.allowPan?"grab":"default")}renderImage(){this.image&&(this.context.translate(this.canvasWidth/2+this.translateX,this.canvasHeight/2+this.translateY),this.context.scale(this.scale,this.scale),this.context.rotate(this.rotation*Math.PI/180),this.context.drawImage(this.image,-this.image.naturalWidth/2,-this.image.naturalHeight/2))}handleMouseDown(t){if(!this.allowPan||0!==t.button)return;t.preventDefault(),this.isDragging=!0;const e=this.canvas.getBoundingClientRect();this.lastPointerX=t.clientX-e.left,this.lastPointerY=t.clientY-e.top,this.canvas.style.cursor="grabbing"}handleMouseMove(t){if(!this.isDragging||!this.allowPan)return;t.preventDefault();const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top,s=i-this.lastPointerX,n=a-this.lastPointerY;this.pan(s,n),this.lastPointerX=i,this.lastPointerY=a}handleMouseUp(t){this.isDragging&&(this.isDragging=!1,this.canvas.style.cursor=this.allowPan?"grab":"default")}handleWheel(t){if(!this.allowZoom)return;t.preventDefault();const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top,s=t.deltaY>0?.5*-this.scaleStep:.5*this.scaleStep;this.zoomAtPoint(this.scale+s,i,a)}handleTouchStart(t){if(1===t.touches.length&&this.allowPan){t.preventDefault();const e=t.touches[0],i=this.canvas.getBoundingClientRect();this.isDragging=!0,this.lastPointerX=e.clientX-i.left,this.lastPointerY=e.clientY-i.top}}handleTouchMove(t){if(1===t.touches.length&&this.isDragging&&this.allowPan){t.preventDefault();const e=t.touches[0],i=this.canvas.getBoundingClientRect(),a=e.clientX-i.left,s=e.clientY-i.top,n=a-this.lastPointerX,o=s-this.lastPointerY;this.pan(n,o),this.lastPointerX=a,this.lastPointerY=s}}handleTouchEnd(t){this.isDragging=!1}handleKeyboard(t){if("INPUT"!==t.target.tagName&&"TEXTAREA"!==t.target.tagName)switch(t.key){case"+":case"=":this.allowZoom&&(t.preventDefault(),this.zoomIn());break;case"-":this.allowZoom&&(t.preventDefault(),this.zoomOut());break;case"0":t.preventDefault(),this.fitToContainer();break;case"1":t.preventDefault(),this.actualSize();break;case"r":case"R":this.allowRotate&&(t.preventDefault(),this.rotateRight())}}zoomIn(){this.setScale(this.scale+this.scaleStep)}zoomOut(){this.setScale(this.scale-this.scaleStep)}setScale(t){const e=this.scale;this.scale=Math.max(this.minScale,Math.min(this.maxScale,t)),e!==this.scale&&(this.renderCanvas(),this.emitTransformEvent("scale-changed",{oldScale:e,newScale:this.scale}))}zoomAtPoint(t,e,i){if(!this.image)return;const a=this.scale;if(this.setScale(t),a!==this.scale){const t=this.scale/a,s=this.canvasWidth/2,n=this.canvasHeight/2;this.translateX=(this.translateX-(e-s))*t+(e-s),this.translateY=(this.translateY-(i-n))*t+(i-n),this.renderCanvas()}}pan(t,e){this.translateX+=t,this.translateY+=e,this.renderCanvas(),this.emitTransformEvent("panned",{deltaX:t,deltaY:e})}rotate(t){const e=this.rotation;this.rotation=(this.rotation+t)%360,this.rotation<0&&(this.rotation+=360),this.renderCanvas(),this.emitTransformEvent("rotated",{oldRotation:e,newRotation:this.rotation,degrees:t})}rotateLeft(){this.rotate(-90)}rotateRight(){this.rotate(90)}center(){this.translateX=0,this.translateY=0,this.renderCanvas(),this.emitTransformEvent("centered")}actualSize(){this.setScale(1),this.center()}fitToContainer(){if(!this.image||!this.canvasWidth||!this.canvasHeight)return;const t=this.canvasWidth-40,e=this.canvasHeight-40,i=t/this.image.naturalWidth,a=e/this.image.naturalHeight,s=Math.min(i,a,1);this.setScale(s),this.center()}smartFit(){if(!this.image||!this.canvasWidth||!this.canvasHeight)return;const t=(this.canvasWidth-80)/this.image.naturalWidth,e=(this.canvasHeight-80)/this.image.naturalHeight,i=Math.min(t,e);i<1&&this.setScale(i),this.center()}reset(){this.scale=1,this.rotation=0,this.translateX=0,this.translateY=0,this.renderCanvas(),this.emitTransformEvent("reset")}handleImageLoad(){super.handleImageLoad(),this.autoFit?this.fitToContainer():this.smartFit()}getTransformState(){return{scale:this.scale,rotation:this.rotation,translateX:this.translateX,translateY:this.translateY}}setTransformState(t){void 0!==t.scale&&(this.scale=t.scale),void 0!==t.rotation&&(this.rotation=t.rotation),void 0!==t.translateX&&(this.translateX=t.translateX),void 0!==t.translateY&&(this.translateY=t.translateY),this.renderCanvas()}emitTransformEvent(t,e={}){const i=this.getApp()?.events;i&&i.emit(`imagetransform:${t}`,{view:this,transform:this.getTransformState(),...e})}async handleActionZoomIn(){this.zoomIn()}async handleActionZoomOut(){this.zoomOut()}async handleActionFitToScreen(){this.fitToContainer()}async handleActionActualSize(){this.actualSize()}async handleActionRotateLeft(){this.rotateLeft()}async handleActionRotateRight(){this.rotateRight()}async handleActionCenterImage(){this.center()}async onBeforeDestroy(){await super.onBeforeDestroy(),this.isDragging&&(this.isDragging=!1),document.removeEventListener("mousemove",this._handleMouseMove),document.removeEventListener("mouseup",this._handleMouseUp),document.removeEventListener("keydown",this._handleKeyboard),this.emitTransformEvent("destroyed")}static async showDialog(t,i={}){const{title:a="Transform Image",alt:s="Image",size:n="xl",allowPan:o=!0,allowZoom:r=!0,allowRotate:l=!0,...h}=i,c=new ImageTransformView({imageUrl:t,alt:s,title:a,allowPan:o,allowZoom:r,allowRotate:l}),d=new e({title:a,body:c,size:n,centered:!0,backdrop:"static",keyboard:!0,noBodyPadding:!0,maxCanvasHeightPercent:.5,buttons:[{text:"Cancel",action:"cancel",class:"btn btn-secondary",dismiss:!0},{text:"Apply Transform",action:"apply-transform",class:"btn btn-primary"}],...h});return await d.render(!0,document.body),d.show(),new Promise(t=>{d.on("hidden",()=>{d.destroy(),t({action:"cancel",view:c})}),d.on("action:cancel",()=>{d.hide()}),d.on("action:apply-transform",async()=>{const e=c.exportImageData();d.hide(),t({action:"transform",view:c,data:e,transformState:c.getTransformState()})})})}}window.ImageTransformView=Image;class ImageCropView extends ImageCanvasView{constructor(t={}){super({...t,className:`image-crop-view ${t.className||""}`}),this.originalImageUrl=t.imageUrl,this.cropMode=!1,this.cropBox={x:0,y:0,width:0,height:0},this.aspectRatio=t.aspectRatio||null,this.minCropSize=t.minCropSize||50,this.fixedCropSize=t.fixedCropSize||null,this.cropAndScale=t.cropAndScale||null,this.isDragging=!1,this.isResizing=!1,this.dragHandle=null,this.dragStartImageX=0,this.dragStartImageY=0,this.initialCropBox=null,this.newCropStart=null,this.handles={nw:{cursor:"nw-resize",x:0,y:0},ne:{cursor:"ne-resize",x:1,y:0},sw:{cursor:"sw-resize",x:0,y:1},se:{cursor:"se-resize",x:1,y:1},n:{cursor:"n-resize",x:.5,y:0},s:{cursor:"s-resize",x:.5,y:1},w:{cursor:"w-resize",x:0,y:.5},e:{cursor:"e-resize",x:1,y:.5}},this.handleSize=t.handleSize||12,this.showGrid=!1!==t.showGrid,this.showToolbar=!1!==t.showToolbar,this.autoFit=!1!==t.autoFit,this.imageOffsetX=0,this.imageOffsetY=0,this._handleMouseMove=this.handleMouseMove.bind(this),this._handleMouseUp=this.handleMouseUp.bind(this),!t.maxCanvasHeightPercent&&this.showToolbar&&(this.maxCanvasHeightPercent=.6)}imageToCanvas(t){if(!this.image)return t;const e=this.canvasWidth/this.image.naturalWidth,i=this.canvasHeight/this.image.naturalHeight;let a;a=this.autoFit?Math.min(e,i,1):1;const s=this.image.naturalWidth*a,n=this.image.naturalHeight*a,o=(this.canvasWidth-s)/2,r=(this.canvasHeight-n)/2;return{x:t.x*a+o,y:t.y*a+r,width:t.width*a,height:t.height*a}}canvasToImage(t){if(!this.image)return t;const e=this.canvasWidth/this.image.naturalWidth,i=this.canvasHeight/this.image.naturalHeight;let a;a=this.autoFit?Math.min(e,i,1):1;const s=this.image.naturalWidth*a,n=this.image.naturalHeight*a,o=(this.canvasWidth-s)/2,r=(this.canvasHeight-n)/2;return{x:(t.x-o)/a,y:(t.y-r)/a,width:t.width/a,height:t.height/a}}pointCanvasToImage(t,e){const i=this.canvasToImage({x:t,y:e,width:0,height:0});return{x:i.x,y:i.y}}async getTemplate(){return'\n <div class="image-crop-container d-flex flex-column h-100">\n {{#showToolbar}}\n \x3c!-- Crop Toolbar --\x3e\n <div class="image-crop-toolbar bg-light border-bottom p-2">\n <div class="btn-toolbar justify-content-center" role="toolbar">\n <div class="btn-group me-2" role="group" aria-label="Aspect ratio">\n <button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"\n data-bs-toggle="dropdown" title="Aspect Ratio">\n <i class="bi bi-aspect-ratio"></i> Ratio\n </button>\n <ul class="dropdown-menu">\n <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="free">Free</a></li>\n <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="1">1:1 Square</a></li>\n <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="1.333">4:3</a></li>\n <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="1.777">16:9</a></li>\n <li><a class="dropdown-item" href="#" data-action="set-aspect-ratio" data-ratio="0.75">3:4 Portrait</a></li>\n </ul>\n </div>\n\n <div class="btn-group me-2" role="group" aria-label="Fit mode">\n <button type="button" class="btn btn-outline-info btn-sm" data-action="toggle-auto-fit" title="Toggle Auto-fit">\n <i class="bi bi-arrows-fullscreen"></i> <span class="auto-fit-text">Fit</span>\n </button>\n </div>\n\n <div class="btn-group me-2" role="group" aria-label="Crop actions">\n <button type="button" class="btn btn-success btn-sm" data-action="apply-crop" title="Apply Crop">\n <i class="bi bi-check"></i> Apply\n </button>\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="reset-crop" title="Reset Crop">\n <i class="bi bi-arrow-repeat"></i> Reset\n </button>\n </div>\n </div>\n </div>\n {{/showToolbar}}\n\n \x3c!-- Canvas Area --\x3e\n <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">\n <canvas class="image-crop-canvas" data-container="canvas"></canvas>\n\n \x3c!-- Loading Overlay --\x3e\n <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"\n style="display: none; z-index: 10;">\n <div class="spinner-border text-primary" role="status">\n <span class="visually-hidden">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n '}async onAfterRender(){await super.onAfterRender(),this.setupCropListeners(),this.updateAutoFitButtonState()}updateAutoFitButtonState(){if(!this.showToolbar)return;const t=this.element.querySelector('[data-action="toggle-auto-fit"]'),e=t?.querySelector(".auto-fit-text");t&&e&&(this.autoFit?(t.classList.remove("btn-outline-warning"),t.classList.add("btn-outline-info"),t.title="Toggle Auto-fit (currently: fit to canvas)",e.textContent="Fit"):(t.classList.remove("btn-outline-info"),t.classList.add("btn-outline-warning"),t.title="Toggle Auto-fit (currently: actual size)",e.textContent="1:1"))}handleImageLoad(){super.handleImageLoad(),this.updateImageOffset(),setTimeout(()=>{this.isLoaded&&this.canvasWidth>0&&this.canvasHeight>0&&this.startCropMode()},10)}updateImageOffset(){if(!this.image)return;const t=this.canvasWidth/this.image.naturalWidth,e=this.canvasHeight/this.image.naturalHeight;let i;i=this.autoFit?Math.min(t,e,1):1;const a=this.image.naturalWidth*i,s=this.image.naturalHeight*i;this.imageOffsetX=(this.canvasWidth-a)/2,this.imageOffsetY=(this.canvasHeight-s)/2,this.imageScale=i,this.imageOffsetX,this.imageOffsetY,this.imageScale,this.autoFit}setCanvasSize(t){super.setCanvasSize(t),this.image&&this.isLoaded&&this.updateImageOffset()}renderImage(){if(!this.image)return;const t=this.canvasWidth/this.image.naturalWidth,e=this.canvasHeight/this.image.naturalHeight;let i;i=this.autoFit?Math.min(t,e,1):1;const a=this.image.naturalWidth*i,s=this.image.naturalHeight*i,n=(this.canvasWidth-a)/2,o=(this.canvasHeight-s)/2;this.context.drawImage(this.image,n,o,a,s)}setupCropListeners(){this.canvas&&(this.canvas.addEventListener("mousedown",t=>this.handleMouseDown(t)),document.addEventListener("mousemove",this._handleMouseMove),document.addEventListener("mouseup",this._handleMouseUp),this.canvas.addEventListener("touchstart",t=>this.handleTouchStart(t),{passive:!1}),this.canvas.addEventListener("touchmove",t=>this.handleTouchMove(t),{passive:!1}),this.canvas.addEventListener("touchend",t=>this.handleTouchEnd(t)),this.canvas.style.cursor="crosshair")}renderCanvas(){super.renderCanvas(),this.cropMode&&this.renderCropOverlay()}renderCropOverlay(){if(!this.cropMode||!this.cropBox)return;const t=this.imageToCanvas(this.cropBox);this.context.save(),this.context.globalAlpha=.5,this.context.fillStyle="#000000",this.context.fillRect(0,0,this.canvasWidth,this.canvasHeight),this.context.globalCompositeOperation="destination-out",this.context.fillRect(t.x,t.y,t.width,t.height),this.context.globalCompositeOperation="source-over",this.context.globalAlpha=1,this.context.strokeStyle="rgba(255, 255, 255, 0.9)",this.context.lineWidth=2,this.context.strokeRect(t.x,t.y,t.width,t.height),this.showGrid&&this.drawGrid(),this.drawHandles(),this.context.restore()}exportImageBlob(t=.9){return this.canvas&&this.image&&this.isLoaded&&this.cropMode?new Promise(e=>{try{this.cropBox;const i={x:Math.max(0,Math.min(this.cropBox.x,this.image.naturalWidth)),y:Math.max(0,Math.min(this.cropBox.y,this.image.naturalHeight)),width:Math.min(this.cropBox.width,this.image.naturalWidth-this.cropBox.x),height:Math.min(this.cropBox.height,this.image.naturalHeight-this.cropBox.y)};let a=i.width,s=i.height;this.cropAndScale&&(a=this.cropAndScale.width,s=this.cropAndScale.height);const n=document.createElement("canvas");n.width=a,n.height=s,n.getContext("2d").drawImage(this.image,i.x,i.y,i.width,i.height,0,0,a,s),n.toBlob(t=>{e(t)},"image/png",t)}catch(i){console.error("Failed to export cropped image blob:",i),e(null)}}):super.exportImageBlob(t)}drawGrid(){const t=this.imageToCanvas(this.cropBox);this.context.globalAlpha=.6,this.context.strokeStyle="rgba(255, 255, 255, 0.7)",this.context.lineWidth=1;const e=t.width/3,i=t.height/3;for(let a=1;a<3;a++){const i=t.x+e*a;this.context.beginPath(),this.context.moveTo(i,t.y),this.context.lineTo(i,t.y+t.height),this.context.stroke()}for(let a=1;a<3;a++){const e=t.y+i*a;this.context.beginPath(),this.context.moveTo(t.x,e),this.context.lineTo(t.x+t.width,e),this.context.stroke()}}drawHandles(){if(this.fixedCropSize)return;const t=this.imageToCanvas(this.cropBox);this.context.globalAlpha=1,this.context.fillStyle="#ffffff",this.context.strokeStyle="#000000",this.context.lineWidth=1,Object.keys(this.handles).forEach(e=>{const i=this.handles[e],a=t.x+t.width*i.x,s=t.y+t.height*i.y,n=a-this.handleSize/2,o=s-this.handleSize/2;this.context.fillRect(n,o,this.handleSize,this.handleSize),this.context.strokeRect(n,o,this.handleSize,this.handleSize)})}handleMouseDown(t){if(!this.cropMode)return;t.preventDefault();const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top,s=this.pointCanvasToImage(i,a);if(this.dragStartImageX=s.x,this.dragStartImageY=s.y,this.initialCropBox={...this.cropBox},this.fixedCropSize)this.isPointInCropBox(i,a)&&(this.isDragging=!0,this.canvas.style.cursor="move");else{const t=this.getHandleAt(i,a);t?(this.isResizing=!0,this.dragHandle=t,this.canvas.style.cursor=this.handles[t].cursor):this.isPointInCropBox(i,a)?(this.isDragging=!0,this.canvas.style.cursor="move"):this.startNewCrop(s.x,s.y)}}handleMouseMove(t){if(!this.cropMode)return;const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top;this.isResizing&&this.dragHandle?this.resizeCropBox(i,a):this.isDragging?this.moveCropBox(i,a):this.isDragging||this.isResizing||this.updateCursor(i,a)}handleMouseUp(t){if(!this.cropMode)return;this.isDragging=!1,this.isResizing=!1,this.dragHandle=null,this.initialCropBox=null,this.newCropStart=null;const e=this.canvas.getBoundingClientRect(),i=t.clientX-e.left,a=t.clientY-e.top;this.updateCursor(i,a)}handleTouchStart(t){if(!this.cropMode||1!==t.touches.length)return;t.preventDefault();const e=t.touches[0],i=this.canvas.getBoundingClientRect();e.clientX,i.left,e.clientY,i.top,this.handleMouseDown({clientX:e.clientX,clientY:e.clientY,preventDefault:()=>{}})}handleTouchMove(t){if(!this.cropMode||1!==t.touches.length)return;t.preventDefault();const e=t.touches[0];this.handleMouseMove({clientX:e.clientX,clientY:e.clientY})}handleTouchEnd(t){this.cropMode&&this.handleMouseUp({})}getHandleAt(t,e){const i=this.imageToCanvas(this.cropBox);for(const[a,s]of Object.entries(this.handles)){const n=i.x+i.width*s.x,o=i.y+i.height*s.y,r=this.handleSize+4,l=n-r/2,h=o-r/2;if(t>=l&&t<=l+r&&e>=h&&e<=h+r)return a}return null}isPointInCropBox(t,e){const i=this.pointCanvasToImage(t,e);return i.x>=this.cropBox.x&&i.x<=this.cropBox.x+this.cropBox.width&&i.y>=this.cropBox.y&&i.y<=this.cropBox.y+this.cropBox.height}updateCursor(t,e){if(!this.cropMode)return;const i=t,a=e;if(this.fixedCropSize)this.isPointInCropBox(i,a)?this.canvas.style.cursor="move":this.canvas.style.cursor="default";else{const s=this.getHandleAt(t,e);s?this.canvas.style.cursor=this.handles[s].cursor:this.isPointInCropBox(i,a)?this.canvas.style.cursor="move":this.canvas.style.cursor="crosshair"}}startNewCrop(t,e){this.newCropStart={x:t,y:e},this.cropBox={x:t,y:e,width:0,height:0},this.isResizing=!0,this.dragHandle="se"}resizeCropBox(t,e){if(!this.dragHandle)return;const i=this.pointCanvasToImage(t,e);if(this.newCropStart){const t=this.newCropStart.x,e=this.newCropStart.y;return this.cropBox={x:Math.min(t,i.x),y:Math.min(e,i.y),width:Math.abs(i.x-t),height:Math.abs(i.y-e)},this.aspectRatio&&this.constrainToAspectRatio(this.cropBox,"se"),this.cropBox.width<this.minCropSize&&(this.cropBox.width=this.minCropSize),this.cropBox.height<this.minCropSize&&(this.cropBox.height=this.minCropSize),void this.constrainCropBox(this.cropBox)}if(!this.initialCropBox)return;const a=i.x-this.dragStartImageX,s=i.y-this.dragStartImageY;let n={...this.initialCropBox};switch(this.dragHandle){case"nw":n.x+=a,n.y+=s,n.width-=a,n.height-=s;break;case"ne":n.y+=s,n.width+=a,n.height-=s;break;case"sw":n.x+=a,n.width-=a,n.height+=s;break;case"se":n.width+=a,n.height+=s;break;case"n":n.y+=s,n.height-=s;break;case"s":n.height+=s;break;case"w":n.x+=a,n.width-=a;break;case"e":n.width+=a}this.aspectRatio&&this.constrainToAspectRatio(n,this.dragHandle),this.constrainCropBox(n),this.cropBox=n,this.renderCanvas()}moveCropBox(t,e){if(!this.initialCropBox)return;const i=this.pointCanvasToImage(t,e),a=i.x-this.dragStartImageX,s=i.y-this.dragStartImageY;let n={x:this.initialCropBox.x+a,y:this.initialCropBox.y+s,width:this.initialCropBox.width,height:this.initialCropBox.height};this.image&&(n.x=Math.max(0,Math.min(this.image.naturalWidth-n.width,n.x)),n.y=Math.max(0,Math.min(this.image.naturalHeight-n.height,n.y))),this.cropBox=n,this.renderCanvas()}constrainToAspectRatio(t,e){let i,a,s=this.aspectRatio;if(this.cropAndScale&&(s=this.cropAndScale.width/this.cropAndScale.height),s)if(["nw","ne","sw","se"].includes(e)){switch(e){case"nw":i=t.x+t.width,a=t.y+t.height;break;case"ne":i=t.x,a=t.y+t.height;break;case"sw":i=t.x+t.width,a=t.y;break;case"se":i=t.x,a=t.y}switch(t.width/t.height>s?t.width=t.height*s:t.height=t.width/s,e){case"nw":t.x=i-t.width,t.y=a-t.height;break;case"ne":t.x=i,t.y=a-t.height;break;case"sw":t.x=i-t.width,t.y=a;break;case"se":t.x=i,t.y=a}}else if(["n","s"].includes(e)){const e=t.x+t.width/2;t.width=t.height*s,t.x=e-t.width/2}else if(["w","e"].includes(e)){const e=t.y+t.height/2;t.height=t.width/s,t.y=e-t.height/2}}constrainCropBox(t){t.width=Math.max(this.minCropSize,t.width),t.height=Math.max(this.minCropSize,t.height),this.image&&(t.x<0&&(t.width+=t.x,t.x=0),t.y<0&&(t.height+=t.y,t.y=0),t.x+t.width>this.image.naturalWidth&&(t.width=this.image.naturalWidth-t.x),t.y+t.height>this.image.naturalHeight&&(t.height=this.image.naturalHeight-t.y)),t.width=Math.max(0,t.width),t.height=Math.max(0,t.height)}startCropMode(){this.cropMode||(this.cropMode=!0,this.initializeCropBox(),this.renderCanvas(),this.emitCropEvent("crop-started"))}exitCropMode(){this.cropMode=!1,this.isDragging=!1,this.isResizing=!1,this.dragHandle=null,this.canvas.style.cursor="default",this.renderCanvas(),this.emitCropEvent("crop-exited")}initializeCropBox(){if(!this.canvasWidth||!this.canvasHeight||!this.image)return;const t=this.image.naturalWidth,e=this.image.naturalHeight;let i,a;if(this.fixedCropSize)i=this.fixedCropSize.width,a=this.fixedCropSize.height;else{i=Math.floor(.8*t),a=Math.floor(.8*e);let s=this.aspectRatio;this.cropAndScale&&(s=this.cropAndScale.width/this.cropAndScale.height),this.aspectRatio=s,s&&(i/a>s?i=a*s:a=i/s),i=Math.max(this.minCropSize||50,i),a=Math.max(this.minCropSize||50,a)}const s=Math.floor((t-i)/2),n=Math.floor((e-a)/2);this.cropBox={x:s,y:n,width:i,height:a}}setAspectRatio(t){this.aspectRatio=t,this.cropMode&&(this.initializeCropBox(),this.renderCanvas()),this.emitCropEvent("aspect-ratio-changed",{aspectRatio:t})}getCropData(){return this.cropBox&&this.image?{x:Math.max(0,Math.min(this.cropBox.x,this.image.naturalWidth)),y:Math.max(0,Math.min(this.cropBox.y,this.image.naturalHeight)),width:Math.min(this.cropBox.width,this.image.naturalWidth-this.cropBox.x),height:Math.min(this.cropBox.height,this.image.naturalHeight-this.cropBox.y),originalWidth:this.image.naturalWidth,originalHeight:this.image.naturalHeight}:null}async applyCrop(){const t=this.getCropData();if(!t||!this.image)return null;const e=document.createElement("canvas"),i=e.getContext("2d");this.cropAndScale?(e.width=this.cropAndScale.width,e.height=this.cropAndScale.height,i.drawImage(this.image,t.x,t.y,t.width,t.height,0,0,this.cropAndScale.width,this.cropAndScale.height)):(e.width=t.width,e.height=t.height,i.drawImage(this.image,t.x,t.y,t.width,t.height,0,0,t.width,t.height));const a=e.toDataURL("image/png");return{canvas:e,imageData:a,cropData:t}}emitCropEvent(t,e={}){const i=this.getApp()?.events;i&&i.emit(`imagecrop:${t}`,{view:this,cropBox:this.cropBox,aspectRatio:this.aspectRatio,...e})}showToolbarElement(){if(!this.showToolbar){this.showToolbar=!0;const t=this.element.querySelector(".image-crop-toolbar");t&&(t.style.display="block"),this.updateAutoFitButtonState()}}hideToolbarElement(){if(this.showToolbar){this.showToolbar=!1;const t=this.element.querySelector(".image-crop-toolbar");t&&(t.style.display="none")}}toggleToolbarElement(){this.showToolbar?this.hideToolbarElement():this.showToolbarElement()}async onPassThruActionSetAspectRatio(t,e){const i=e.getAttribute("data-ratio"),a="free"===i?null:parseFloat(i);this.setAspectRatio(a)}async handleActionApplyCrop(){if(this.cropMode){const t=await this.applyCrop();t&&t.imageData&&(this.loadImage(t.imageData),this.exitCropMode(),this.emitCropEvent("crop-applied",{result:t}))}}async handleActionToggleAutoFit(){this.showToolbar&&(this.autoFit=!this.autoFit,this.updateAutoFitButtonState(),this.updateImageOffset(),this.renderCanvas(),this.emitCropEvent("auto-fit-changed",{autoFit:this.autoFit}))}async handleActionResetCrop(){this.cropMode&&this.exitCropMode(),this.originalImageUrl&&await this.loadImage(this.originalImageUrl),this.startCropMode(),this.emitCropEvent("crop-reset")}async onBeforeDestroy(){await super.onBeforeDestroy(),this.cropMode=!1,this.isDragging=!1,this.isResizing=!1,document.removeEventListener("mousemove",this._handleMouseMove),document.removeEventListener("mouseup",this._handleMouseUp),this.emitCropEvent("destroyed")}static async showDialog(t,i={}){const{title:a="Crop Image",alt:s="Image",size:n="xl",aspectRatio:o=null,minCropSize:r=50,showGrid:l=!0,showToolbar:h=!1,autoFit:c=!0,fixedCropSize:d=null,cropAndScale:m=null,canvasSize:u=n||"auto",...g}=i,p=new ImageCropView({imageUrl:t,alt:s,title:a,aspectRatio:o,minCropSize:r,canvasSize:u||n||"md",fixedCropSize:d,cropAndScale:m,showGrid:l,showToolbar:h,autoFit:c}),v=new e({title:a,body:p,size:n,centered:!0,backdrop:"static",keyboard:!0,noBodyPadding:!0,buttons:[{text:"Cancel",action:"cancel",class:"btn btn-secondary",dismiss:!0},{text:"Apply Crop",action:"apply-crop",class:"btn btn-primary"}],...g});return await v.render(!0,document.body),v.show(),v.on("shown",()=>{if(p.setupCanvas&&p.setupCanvas(),p.isLoaded&&p.canvasWidth>0)p.startCropMode();else{const t=setInterval(()=>{p.isLoaded&&p.canvasWidth>0&&(clearInterval(t),p.startCropMode())},100);setTimeout(()=>clearInterval(t),5e3)}}),new Promise(t=>{v.on("hidden",()=>{v.destroy(),t({action:"cancel",view:p})}),v.on("action:cancel",()=>{v.hide()}),v.on("action:apply-crop",async()=>{let e;if(p.cropMode&&p.cropBox)e=await p.applyCrop();else{const t=p.canvas.toDataURL("image/png");e={canvas:p.canvas,imageData:t,cropData:null}}v.hide(),t({action:"crop",view:p,data:e?.imageData,cropData:e?.cropData})})})}}window.ImageCropView=ImageCropView;class ImageFiltersView extends ImageCanvasView{constructor(t={}){super({...t,className:`image-filters-view ${t.className||""}`}),this.filters={brightness:100,contrast:100,saturation:100,hue:0,blur:0,grayscale:0,sepia:0},this.showControls=t.showControls??!0,this.allowReset=t.allowReset??!0,this.showPresets=t.showPresets??!0,this.showBasicControls=t.showBasicControls??!0,this.showAdvancedControls=t.showAdvancedControls??!0,this.controlsInDropdowns=t.controlsInDropdowns??!0,this.presetEffects={none:{name:"Original",filters:{}},blackWhite:{name:"Black & White",filters:{grayscale:100}},sepia:{name:"Sepia",filters:{sepia:100}},vintage:{name:"Vintage",filters:{sepia:60,contrast:110,brightness:110,saturation:80}},cool:{name:"Cool Tones",filters:{hue:200,saturation:120,brightness:95}},warm:{name:"Warm Tones",filters:{hue:25,saturation:110,brightness:105}},vibrant:{name:"Vibrant",filters:{brightness:105,contrast:115,saturation:140,hue:5}},dramatic:{name:"Dramatic",filters:{brightness:90,contrast:150,saturation:120}},soft:{name:"Soft",filters:{brightness:110,contrast:85,blur:1}}},this.currentPreset="none"}async getTemplate(){return'\n <div class="image-filters-container d-flex flex-column h-100">\n {{#showControls}}\n \x3c!-- Filter Toolbar --\x3e\n <div class="image-filters-toolbar bg-light border-bottom p-2">\n <div class="btn-toolbar justify-content-center flex-wrap" role="toolbar">\n\n {{#showPresets}}\n \x3c!-- Preset Effects --\x3e\n <div class="btn-group me-2 mb-2" role="group" aria-label="Preset effects">\n <div class="dropdown">\n <button type="button" class="btn btn-outline-primary btn-sm dropdown-toggle"\n data-bs-toggle="dropdown" aria-expanded="false" title="Preset Effects">\n <i class="bi bi-palette"></i> Effects\n </button>\n <ul class="dropdown-menu">\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="none">Original</a></li>\n <li><hr class="dropdown-divider"></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="blackWhite">Black & White</a></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="sepia">Sepia</a></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="vintage">Vintage</a></li>\n <li><hr class="dropdown-divider"></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="cool">Cool Tones</a></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="warm">Warm Tones</a></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="vibrant">Vibrant</a></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="dramatic">Dramatic</a></li>\n <li><a class="dropdown-item" href="#" data-action="apply-preset" data-preset="soft">Soft</a></li>\n </ul>\n </div>\n </div>\n {{/showPresets}}\n\n {{#showBasicControls}}\n {{#controlsInDropdowns}}\n \x3c!-- Basic Controls in Dropdown --\x3e\n <div class="btn-group me-2 mb-2" role="group" aria-label="Basic controls">\n <div class="dropdown">\n <button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"\n data-bs-toggle="dropdown" aria-expanded="false" title="Basic Adjustments">\n <i class="bi bi-sliders"></i> Basic\n </button>\n <div class="dropdown-menu p-3" style="min-width: 300px;">\n <div class="mb-3">\n <label class="form-label small fw-bold">Brightness</label>\n <input type="range" class="form-range"\n min="0" max="200" value="{{filters.brightness}}"\n data-change-action="filter-change" data-filter="brightness">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="brightness">{{filters.brightness}}%</small>\n <small class="text-muted">200%</small>\n </div>\n </div>\n <div class="mb-3">\n <label class="form-label small fw-bold">Contrast</label>\n <input type="range" class="form-range"\n min="0" max="200" value="{{filters.contrast}}"\n data-change-action="filter-change" data-filter="contrast">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="contrast">{{filters.contrast}}%</small>\n <small class="text-muted">200%</small>\n </div>\n </div>\n <div class="mb-0">\n <label class="form-label small fw-bold">Saturation</label>\n <input type="range" class="form-range"\n min="0" max="200" value="{{filters.saturation}}"\n data-change-action="filter-change" data-filter="saturation">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="saturation">{{filters.saturation}}%</small>\n <small class="text-muted">200%</small>\n </div>\n </div>\n </div>\n </div>\n </div>\n {{/controlsInDropdowns}}\n {{/showBasicControls}}\n\n {{#showAdvancedControls}}\n {{#controlsInDropdowns}}\n \x3c!-- Advanced Controls in Dropdown --\x3e\n <div class="btn-group me-2 mb-2" role="group" aria-label="Advanced controls">\n <div class="dropdown">\n <button type="button" class="btn btn-outline-warning btn-sm dropdown-toggle"\n data-bs-toggle="dropdown" aria-expanded="false" title="Advanced Adjustments">\n <i class="bi bi-gear"></i> Advanced\n </button>\n <div class="dropdown-menu p-3" style="min-width: 300px;">\n <div class="mb-3">\n <label class="form-label small fw-bold">Hue</label>\n <input type="range" class="form-range"\n min="0" max="360" value="{{filters.hue}}"\n data-change-action="filter-change" data-filter="hue">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0°</small>\n <small class="text-muted filter-value" data-filter="hue">{{filters.hue}}°</small>\n <small class="text-muted">360°</small>\n </div>\n </div>\n <div class="mb-3">\n <label class="form-label small fw-bold">Blur</label>\n <input type="range" class="form-range"\n min="0" max="10" value="{{filters.blur}}"\n data-change-action="filter-change" data-filter="blur">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0px</small>\n <small class="text-muted filter-value" data-filter="blur">{{filters.blur}}px</small>\n <small class="text-muted">10px</small>\n </div>\n </div>\n <div class="mb-3">\n <label class="form-label small fw-bold">Grayscale</label>\n <input type="range" class="form-range"\n min="0" max="100" value="{{filters.grayscale}}"\n data-change-action="filter-change" data-filter="grayscale">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="grayscale">{{filters.grayscale}}%</small>\n <small class="text-muted">100%</small>\n </div>\n </div>\n <div class="mb-0">\n <label class="form-label small fw-bold">Sepia</label>\n <input type="range" class="form-range"\n min="0" max="100" value="{{filters.sepia}}"\n data-change-action="filter-change" data-filter="sepia">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="sepia">{{filters.sepia}}%</small>\n <small class="text-muted">100%</small>\n </div>\n </div>\n </div>\n </div>\n </div>\n {{/controlsInDropdowns}}\n {{/showAdvancedControls}}\n\n {{#allowReset}}\n \x3c!-- Reset & Preview Controls --\x3e\n <div class="btn-group me-2 mb-2" role="group" aria-label="Reset controls">\n <button type="button" class="btn btn-outline-secondary btn-sm" data-action="reset-filters" title="Reset All Filters">\n <i class="bi bi-arrow-repeat"></i> Reset\n </button>\n <button type="button" class="btn btn-outline-info btn-sm" data-action="preview-original" title="Preview Original"\n onmousedown="this.dataset.previewing=\'true\'"\n onmouseup="this.dataset.previewing=\'false\'"\n onmouseleave="this.dataset.previewing=\'false\'">\n <i class="bi bi-eye"></i> Original\n </button>\n </div>\n {{/allowReset}}\n\n </div>\n </div>\n {{/showControls}}\n\n \x3c!-- Canvas Area --\x3e\n <div class="image-canvas-content flex-grow-1 position-relative d-flex justify-content-center align-items-center">\n <canvas class="image-filters-canvas" data-container="canvas"></canvas>\n\n \x3c!-- Loading Overlay --\x3e\n <div class="image-canvas-loading position-absolute top-50 start-50 translate-middle"\n style="display: none; z-index: 10;">\n <div class="spinner-border text-primary" role="status">\n <span class="visually-hidden">Loading...</span>\n </div>\n </div>\n </div>\n\n {{#showControls}}\n {{^controlsInDropdowns}}\n \x3c!-- Expanded Controls Panel (when not in dropdowns) --\x3e\n <div class="image-filters-controls bg-light border-top p-3" data-container="controls" style="max-height: 300px; overflow-y: auto;">\n <div class="row g-3">\n {{#showBasicControls}}\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Brightness</label>\n <input type="range" class="form-range"\n min="0" max="200" value="{{filters.brightness}}"\n data-change-action="filter-change" data-filter="brightness">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="brightness">{{filters.brightness}}%</small>\n <small class="text-muted">200%</small>\n </div>\n </div>\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Contrast</label>\n <input type="range" class="form-range"\n min="0" max="200" value="{{filters.contrast}}"\n data-change-action="filter-change" data-filter="contrast">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="contrast">{{filters.contrast}}%</small>\n <small class="text-muted">200%</small>\n </div>\n </div>\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Saturation</label>\n <input type="range" class="form-range"\n min="0" max="200" value="{{filters.saturation}}"\n data-change-action="filter-change" data-filter="saturation">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="saturation">{{filters.saturation}}%</small>\n <small class="text-muted">200%</small>\n </div>\n </div>\n {{/showBasicControls}}\n\n {{#showAdvancedControls}}\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Hue</label>\n <input type="range" class="form-range"\n min="0" max="360" value="{{filters.hue}}"\n data-change-action="filter-change" data-filter="hue">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0°</small>\n <small class="text-muted filter-value" data-filter="hue">{{filters.hue}}°</small>\n <small class="text-muted">360°</small>\n </div>\n </div>\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Blur</label>\n <input type="range" class="form-range"\n min="0" max="10" value="{{filters.blur}}"\n data-change-action="filter-change" data-filter="blur">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0px</small>\n <small class="text-muted filter-value" data-filter="blur">{{filters.blur}}px</small>\n <small class="text-muted">10px</small>\n </div>\n </div>\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Grayscale</label>\n <input type="range" class="form-range"\n min="0" max="100" value="{{filters.grayscale}}"\n data-change-action="filter-change" data-filter="grayscale">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="grayscale">{{filters.grayscale}}%</small>\n <small class="text-muted">100%</small>\n </div>\n </div>\n <div class="col-md-6 col-lg-4">\n <label class="form-label small fw-bold">Sepia</label>\n <input type="range" class="form-range"\n min="0" max="100" value="{{filters.sepia}}"\n data-change-action="filter-change" data-filter="sepia">\n <div class="d-flex justify-content-between">\n <small class="text-muted">0%</small>\n <small class="text-muted filter-value" data-filter="sepia">{{filters.sepia}}%</small>\n <small class="text-muted">100%</small>\n </div>\n </div>\n {{/showAdvancedControls}}\n </div>\n </div>\n {{/controlsInDropdowns}}\n {{/showControls}}\n </div>\n '}async onAfterRender(){await super.onAfterRender(),this.controlsElement=this.element.querySelector(".image-filters-controls")}setupCanvas(){this.canvas&&this.containerElement&&(this.setCanvasSize(this.canvasSize),"fullscreen"!==this.canvasSize&&"auto"!==this.canvasSize||(this._resizeHandler=()=>this.setCanvasSize(this.canvasSize),window.addEventListener("resize",this._resizeHandler)),this.context.imageSmoothingEnabled=!0,this.context.imageSmoothingQuality="high")}setCanvasSize(t){if(this.canvas&&this.containerElement){if("auto"===t){const t=this.containerElement;let e=t.clientWidth-40,i=t.clientHeight-40;if(e<=40||i<=40){let a=t.parentElement;for(;a&&(a.clientWidth<=40||a.clientHeight<=40)&&(a=a.parentElement,!a||!(a.classList.contains("modal-body")||a.classList.contains("card-body")||a.classList.contains("dialog-body")||"MAIN"===a.tagName||"BODY"===a.tagName)););a&&(e=a.clientWidth-80,i=a.clientHeight-80)}if(e>100&&i>100){let t,a;if(this.image){const s=this.image.naturalWidth/this.image.naturalHeight;s>e/i?(t=e,a=e/s):(a=i,t=i*s),t=Math.max(300,Math.floor(t)),a=Math.max(200,Math.floor(a))}else t=Math.min(600,Math.max(300,e)),a=Math.min(450,Math.max(200,i));return void this.applyCanvasSize(t,a)}}super.setCanvasSize(t)}}applyCanvasSize(t,e){if(Math.abs(t-this.canvasWidth)<10&&Math.abs(e-this.canvasHeight)<10)return;const i=window.devicePixelRatio||1;this.canvasWidth=t,this.canvasHeight=e,this.canvas.width=t*i,this.canvas.height=e*i,this.canvas.style.width=t+"px",this.canvas.style.height=e+"px",this.context.setTransform(i,0,0,i,0,0),this.isLoaded&&this.renderCanvas()}async loadImage(t){await super.loadImage(t),this.renderCanvas()}renderImage(){if(!this.image)return;this.context.filter=this.getFilterString();const t=Math.min(this.canvasWidth/this.image.naturalWidth,this.canvasHeight/this.image.naturalHeight),e=this.image.naturalWidth*t,i=this.image.naturalHeight*t,a=(this.canvasWidth-e)/2,s=(this.canvasHeight-i)/2;this.context.drawImage(this.image,a,s,e,i),this.context.filter="none"}getCombinedFilters(){const t={...this.filters};if("none"!==this.currentPreset&&this.presetEffects[this.currentPreset]){const e=this.presetEffects[this.currentPreset].filters;e&&Object.assign(t,e)}return t}getFilterString(){const t=this.getCombinedFilters();return this.hasFilters()||"none"!==this.currentPreset?[`brightness(${t.brightness}%)`,`contrast(${t.contrast}%)`,`saturate(${t.saturation}%)`,`hue-rotate(${t.hue}deg)`,`blur(${t.blur}px)`,`grayscale(${t.grayscale}%)`,`sepia(${t.sepia}%)`].join(" "):"none"}hasFilters(){return 100!==this.filters.brightness||100!==this.filters.contrast||100!==this.filters.saturation||0!==this.filters.hue||0!==this.filters.blur||0!==this.filters.grayscale||0!==this.filters.sepia}async onPassThruActionResetFilters(){this.resetFilters()}async onPassThruActionApplyPreset(t,e){t.preventDefault();const i=e.getAttribute("data-preset");i&&this.presetEffects[i]&&this.applyPreset(i)}async onPassThruActionPreviewOriginal(t,e){if("true"===e.dataset.previewing){this.context.clearRect(0,0,this.canvasWidth,this.canvasHeight),this.context.filter="none";const t=(this.canvasWidth-this.image.naturalWidth)/2,e=(this.canvasHeight-this.image.naturalHeight)/2;this.context.drawImage(this.image,t,e)}else this.renderCanvas()}async onChangeFilterChange(t,e){const i=e.getAttribute("data-filter"),a=parseFloat(e.value);this.updateFilter(i,a)}updateFilter(t,e){if(!(t in this.filters))return;const i=this.filters[t];this.filters[t]=e,this.updateFilterDisplay(t,e),this.renderCanvas(),this.emitFilterEvent("filter-changed",{filter:t,oldValue:i,newValue:e,allFilters:{...this.filters}})}updateFilterDisplay(t,e){const i=this.element.querySelector(`[data-filter="${t}"].filter-value`);if(i){const a="hue"===t?"°":"blur"===t?"px":"%";i.textContent=`${e}${a}`}}resetFilters(){const t={...this.filters},e=this.currentPreset;this.filters={brightness:100,contrast:100,saturation:100,hue:0,blur:0,grayscale:0,sepia:0},this.currentPreset="none",this.updateAllFilterInputs(),this.renderCanvas(),this.emitFilterEvent("filters-reset",{oldFilters:t,newFilters:{...this.filters},oldPreset:e,newPreset:this.currentPreset})}updateAllFilterInputs(){Object.keys(this.filters).forEach(t=>{const e=this.element.querySelector(`[data-filter="${t}"][type="range"]`);e&&(e.value=this.filters[t],this.updateFilterDisplay(t,this.filters[t]))})}applyPreset(t){this.presetEffects[t]&&(this.presetEffects[t],this.currentPreset=t,this.currentPreset=t,this.renderCanvas(),this.emitFilterEvent("preset-applied",{preset:t,filters:{...this.filters}}))}getFilterState(){return{...this.filters}}setFilterState(t){this.filters={...this.filters,...t},this.updateAllFilterInputs(),this.renderCanvas(),this.emitFilterEvent("filters-set",{filters:{...this.filters}})}exportFilteredImageData(){return this.canvas?this.exportImageData():null}async exportFilteredImageBlob(t=.9){return this.canvas?this.exportImageBlob(t):null}createFilteredCanvas(){if(!this.image)return null;const t=document.createElement("canvas"),e=t.getContext("2d");return t.width=this.image.naturalWidth,t.height=this.image.naturalHeight,e.filter=this.getFilterString(),e.drawImage(this.image,0,0),e.filter="none",t}emitFilterEvent(t,e={}){const i=this.getApp()?.events;i&&i.emit(`imagefilters:${t}`,{view:this,hasFilters:this.hasFilters(),filterString:this.getFilterString(),...e})}handleImageLoad(){super.handleImageLoad(),this.emitFilterEvent("ready",{filters:{...this.filters}})}async onBeforeDestroy(){await super.onBeforeDestroy(),this._resizeHandler&&window.removeEventListener("resize",this._resizeHandler),this.emitFilterEvent("destroyed")}static async showDialog(t,i={}){const{title:a="Apply Filters",alt:s="Image",size:n="xl",showControls:o=!0,allowReset:r=!0,showPresets:l=!0,showBasicControls:h=!0,showAdvancedControls:c=!0,controlsInDropdowns:d=!0,canvasSize:m="auto",autoFit:u=!0,crossOrigin:g="anonymous",...p}=i,v=new ImageFiltersView({imageUrl:t,alt:s,title:a,canvasSize:m,autoFit:u,crossOrigin:g,showControls:o,allowReset:r,showPresets:l,showBasicControls:h,showAdvancedControls:c,controlsInDropdowns:d}),w=new e({title:a,body:v,size:n,centered:!0,backdrop:"static",keyboard:!0,noBodyPadding:!0,buttons:[{text:"Cancel",action:"cancel",class:"btn btn-secondary",dismiss:!0},{text:"Apply Filters",action:"apply-filters",class:"btn btn-primary"}],...p});return await w.render(!0,document.body),w.show(),new Promise(t=>{w.on("hidden",()=>{w.destroy(),t({action:"cancel",view:v})}),w.on("action:cancel",()=>{w.hide()}),w.on("action:apply-filters",async()=>{const e=v.exportFilteredImageData();w.hide(),t({action:"filters",view:v,data:e,filterState:v.getFilterState()})})})}}window.ImageFiltersView=ImageFiltersView;class ImageEditor extends t{constructor(t={}){super({...t,className:`image-editor ${t.className||""}`,tagName:"div"}),this.imageUrl=t.imageUrl||t.src||"",this.alt=t.alt||"Image",this.title=t.title||"",this.currentImageData=null,this.currentMode=t.startMode||"transform",this.history=[],this.historyIndex=-1,this.maxHistory=t.maxHistory||20,this.showToolbar=!1!==t.showToolbar,this.allowTransform=!1!==t.allowTransform,this.allowCrop=!1!==t.allowCrop,this.allowFilters=!1!==t.allowFilters,this.allowExport=!1!==t.allowExport,this.allowHistory=!1!==t.allowHistory,this.currentView=null,this.isInitialized=!1}async getTemplate(){return'\n <div class="image-editor-container d-flex flex-column h-100">\n {{#showToolbar}}\n \x3c!-- Toolbar --\x3e\n <div class="image-editor-toolbar bg-light border-bottom p-3" data-container="toolbar">\n <div class="d-flex justify-content-between align-items-center">\n \x3c!-- Mode Buttons --\x3e\n <div class="btn-group" role="group" aria-label="Editing modes">\n {{#allowTransform}}\n <button type="button" class="btn btn-outline-primary mode-btn"\n data-action="switch-mode" data-mode="transform"\n title="Transform: Zoom, Pan, Rotate">\n <i class="bi bi-arrows-move"></i> Transform\n </button>\n {{/allowTransform}}\n\n {{#allowCrop}}\n <button type="button" class="btn btn-outline-primary mode-btn"\n data-action="switch-mode" data-mode="crop"\n title="Crop: Select and crop image">\n <i class="bi bi-crop"></i> Crop\n </button>\n {{/allowCrop}}\n\n {{#allowFilters}}\n <button type="button" class="btn btn-outline-primary mode-btn"\n data-action="switch-mode" data-mode="filters"\n title="Filters: Brightness, Contrast, Effects">\n <i class="bi bi-palette"></i> Filters\n </button>\n {{/allowFilters}}\n </div>\n\n \x3c!-- Action Buttons --\x3e\n <div class="btn-group" role="group" aria-label="Actions">\n {{#allowHistory}}\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="undo" title="Undo" disabled>\n <i class="bi bi-arrow-counterclockwise"></i>\n </button>\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="redo" title="Redo" disabled>\n <i class="bi bi-arrow-clockwise"></i>\n </button>\n {{/allowHistory}}\n\n <button type="button" class="btn btn-outline-secondary btn-sm"\n data-action="reset" title="Reset All Changes">\n <i class="bi bi-arrow-repeat"></i>\n </button>\n\n {{#allowExport}}\n <button type="button" class="btn btn-success btn-sm"\n data-action="export" title="Export Image">\n <i class="bi bi-download"></i> Export\n </button>\n {{/allowExport}}\n </div>\n </div>\n\n\n </div>\n {{/showToolbar}}\n\n \x3c!-- Main editing area where child views will be mounted --\x3e\n <div class="image-editor-workspace flex-grow-1 position-relative" data-container="image-workspace">\n \x3c!-- Child views will be added here dynamically --\x3e\n </div>\n\n \x3c!-- Status bar --\x3e\n <div class="image-editor-status bg-light border-top p-2" data-container="status">\n <div class="d-flex justify-content-between align-items-center">\n <small class="text-muted">\n Mode: <span class="current-mode fw-bold">Transform</span>\n </small>\n <small class="text-muted">\n <span class="image-info">Ready</span>\n </small>\n </div>\n </div>\n </div>\n '}async onAfterRender(){this.toolbarElement=this.element.querySelector(".image-editor-toolbar"),this.workspaceElement=this.element.querySelector(".image-editor-workspace"),this.statusElement=this.element.querySelector(".image-editor-status"),this.setupChildViewEvents(),await this.switchMode(this.currentMode,!0),this.saveState(),this.isInitialized=!0}createChildView(t){const e=this.currentImageData||this.imageUrl;this.currentImageData;const i={parent:this,containerId:"image-workspace",imageUrl:e,alt:this.alt,title:this.title};switch(t){case"transform":return this.allowTransform?new ImageTransformView({...i,allowPan:!0,allowZoom:!0,allowRotate:!0}):null;case"crop":return this.allowCrop?new ImageCropView({...i,showGrid:!0,minCropSize:50}):null;case"filters":return this.allowFilters?new ImageFiltersView({...i,showControls:!0,allowReset:!0}):null;default:return null}}setupChildViewEvents(){const t=this.getApp()?.events;t&&(t.on("imagetransform:scale-changed",()=>this.saveState()),t.on("imagetransform:rotated",()=>this.saveState()),t.on("imagetransform:reset",()=>this.saveState()),t.on("imagecrop:crop-applied",t=>{const e=this.getCurrentImageData();e&&(this.currentImageData=e),this.saveState(),this.updateStatus("Crop applied successfully"),this.updateHistoryButtons()}),t.on("imagefilters:filter-changed",()=>{this.saveState(),this.updateStatus("Filter applied")}),t.on("imagefilters:filters-reset",()=>{this.saveState(),this.updateStatus("Filters reset")}))}async handleActionSwitchMode(t,e){const i=e.getAttribute("data-mode");await this.switchMode(i)}async handleActionUndo(){this.undo()}async handleActionRedo(){this.redo()}async handleActionReset(){await this.resetAll()}async handleActionExport(){await this.exportImage()&&this.updateStatus("Image exported successfully")}async switchMode(t,e=!1){if(t===this.currentMode&&!e)return;if(this.currentView&&!e){const t=this.getCurrentImageData();t?(this.currentMode,this.currentImageData=t):this.currentMode}this.currentView&&(await this.currentView.destroy(),this.currentView=null),this.element.querySelectorAll(".mode-btn").forEach(e=>{e.classList.remove("active"),e.getAttribute("data-mode")===t&&e.classList.add("active")}),this.currentMode=t,this.currentView=this.createChildView(t),this.currentView&&(await this.currentView.render(),"crop"===t&&this.currentView.startCropMode?(this.currentView.startCropMode(),this.updateStatus("Click and drag to select crop area")):"transform"===t?this.updateStatus("Use controls to transform the image"):"filters"===t&&this.updateStatus("Adjust filters to enhance the image")),this.updateCurrentModeDisplay();const i=this.getApp()?.events;i&&i.emit("imageeditor:mode-changed",{editor:this,mode:t,currentView:this.currentView})}updateCurrentModeDisplay(){const t=this.element.querySelector(".current-mode");t&&(t.textContent=this.currentMode.charAt(0).toUpperCase()+this.currentMode.slice(1))}updateStatus(t){const e=this.element.querySelector(".image-info");e&&(e.textContent=t)}saveState(){if(!this.isInitialized)return;const t={mode:this.currentMode,transform:this.currentView?.getTransformState?.(),filters:this.currentView?.getFilterState?.(),imageData:this.currentImageData,timestamp:Date.now()};this.history=this.history.slice(0,this.historyIndex+1),this.history.push(t),this.historyIndex=this.history.length-1,this.history.length>this.maxHistory&&(this.history.shift(),this.historyIndex--),this.updateHistoryButtons()}undo(){this.historyIndex>0&&(this.historyIndex--,this.restoreState(this.history[this.historyIndex]))}redo(){this.historyIndex<this.history.length-1&&(this.historyIndex++,this.restoreState(this.history[this.historyIndex]))}async restoreState(t){t.imageData&&(this.currentImageData=t.imageData),await this.switchMode(t.mode,!0),this.currentView&&(t.transform&&this.currentView.setTransformState&&this.currentView.setTransformState(t.transform),t.filters&&this.currentView.setFilterState&&this.currentView.setFilterState(t.filters)),this.updateHistoryButtons(),this.updateStatus(`Restored to ${t.mode} mode`)}updateHistoryButtons(){const t=this.element.querySelector('[data-action="undo"]'),e=this.element.querySelector('[data-action="redo"]');t&&(t.disabled=this.historyIndex<=0),e&&(e.disabled=this.historyIndex>=this.history.length-1)}async resetAll(){this.currentImageData=null,this.currentView&&this.currentView.reset&&this.currentView.reset(),await this.switchMode("transform",!0),this.history=[],this.historyIndex=-1,this.saveState(),this.updateStatus("All changes reset")}async exportImage(){if(!this.currentView)return null;try{let t=null;if(t=this.getCurrentImageData(),t){const e=document.createElement("a");e.download=this.getExportFilename(),e.href=t,document.body.appendChild(e),e.click(),document.body.removeChild(e);const i=this.getApp()?.events;return i&&i.emit("imageeditor:exported",{editor:this,imageData:t,filename:e.download}),{imageData:t,filename:e.download}}}catch(t){console.error("Export failed:",t),this.updateStatus("Export failed")}return null}getExportFilename(){return`edited-image-${/* @__PURE__ */(new Date).toISOString().slice(0,19).replace(/[:\-]/g,"")}.png`}async setImage(t,e="",i=""){this.imageUrl=t,this.alt=e,this.title=i,this.currentImageData=null,this.currentView&&this.currentView.setImage&&this.currentView.setImage(t,e,i),await this.resetAll()}getCurrentImageData(){if(!this.currentView)return null;let t=null;return this.currentView.exportImageData?t=this.currentView.exportImageData():this.currentView.exportFilteredImageData&&(t=this.currentView.exportFilteredImageData()),t||null}async onBeforeDestroy(){this.currentView&&(await this.currentView.destroy(),this.currentView=null);const t=this.getApp()?.events;t&&t.emit("imageeditor:destroyed",{editor:this})}static async showDialog(t,i={}){const{title:a="Image Editor",alt:s="Image",size:n="fullscreen",showToolbar:o=!0,allowTransform:r=!0,allowCrop:l=!0,allowFilters:h=!0,allowExport:c=!0,...d}=i,m=new ImageEditor({imageUrl:t,alt:s,title:a,showToolbar:o,allowTransform:r,allowCrop:l,allowFilters:h,allowExport:c}),u=new e({title:a,body:m,size:n,centered:!0,backdrop:"static",keyboard:!0,buttons:[{text:"Cancel",action:"cancel",class:"btn btn-secondary",dismiss:!0},{text:"Export & Close",action:"export-close",class:"btn btn-primary"}],...d});return await u.render(!0,document.body),window.lastDialog=u,u.show(),new Promise(t=>{u.on("hidden",()=>{u.destroy(),t({action:"cancel",editor:m})}),u.on("action:cancel",()=>{u.hide()}),u.on("action:export-close",async()=>{const e=await m.exportImage();u.hide(),t({action:"export",editor:m,data:e?.imageData,filename:e?.filename})})})}}window.ImageEditor=ImageEditor;class ImageUploadView extends t{constructor(t={}){super({...t,className:`image-upload-view ${t.className||""}`,tagName:"div"}),this.autoUpload=t.autoUpload||!1,this.acceptedTypes=t.acceptedTypes||["image/jpeg","image/png","image/gif","image/webp"],this.maxFileSize=t.maxFileSize||10485760,this.uploadUrl=t.uploadUrl||null,this.onUpload=t.onUpload||null,this.selectedFile=null,this.isUploading=!1,this.previewUrl=null,this._handleDragOver=this.handleDragOver.bind(this),this._handleDragLeave=this.handleDragLeave.bind(this),this._handleDrop=this.handleDrop.bind(this),this._handleFileSelect=this.handleFileSelect.bind(this),this._preventDefaults=this.preventDefaults.bind(this)}async getTemplate(){return`\n <div class="image-upload-container">\n \x3c!-- Drop Zone --\x3e\n <div class="upload-drop-zone border-2 border-dashed rounded p-4 text-center position-relative" \n style="border-color: #dee2e6; min-height: 200px; transition: all 0.2s ease;">\n \n \x3c!-- Default State --\x3e\n <div class="upload-prompt">\n <i class="bi bi-cloud-upload text-muted" style="font-size: 3rem;"></i>\n <h5 class="mt-3 text-muted">Drop your image here</h5>\n <p class="text-muted mb-3">or</p>\n <button type="button" class="btn btn-outline-primary" data-action="select-file">\n <i class="bi bi-folder2-open"></i> Choose File\n </button>\n <input type="file" class="upload-file-input d-none" accept="image/*" multiple="false">\n <div class="mt-3">\n <small class="text-muted">Supported: JPEG, PNG, GIF, WebP (max ${Math.round(this.maxFileSize/1024/1024)}MB)</small>\n </div>\n </div>\n \n \x3c!-- Preview State --\x3e\n <div class="upload-preview d-none">\n <div class="preview-image-container mb-3">\n <img class="preview-image img-fluid rounded shadow-sm" style="max-height: 300px; max-width: 100%;">\n </div>\n <div class="preview-info">\n <div class="file-name fw-bold mb-2 text-truncate"></div>\n <div class="file-details text-muted small mb-3"></div>\n <div class="upload-actions">\n {{#autoUpload}}\n <button type="button" class="btn btn-outline-secondary" data-action="clear">\n <i class="bi bi-x"></i> Clear\n </button>\n {{/autoUpload}}\n {{^autoUpload}}\n <button type="button" class="btn btn-success me-2" data-action="upload">\n <i class="bi bi-cloud-arrow-up"></i> Upload\n </button>\n <button type="button" class="btn btn-outline-secondary" data-action="clear">\n <i class="bi bi-x"></i> Clear\n </button>\n {{/autoUpload}}\n </div>\n </div>\n </div>\n \n \x3c!-- Loading State --\x3e\n <div class="upload-loading d-none">\n <div class="spinner-border text-primary mb-3" role="status">\n <span class="visually-hidden">Uploading...</span>\n </div>\n <div class="upload-progress">\n <div class="progress mb-2" style="height: 8px;">\n <div class="progress-bar progress-bar-striped progress-bar-animated" \n role="progressbar" style="width: 0%"></div>\n </div>\n <small class="text-muted upload-status">Uploading...</small>\n </div>\n </div>\n </div>\n \n \x3c!-- Upload Result --\x3e\n <div class="upload-result mt-3 d-none">\n <div class="alert" role="alert"></div>\n </div>\n </div>\n `}async onAfterRender(){this.dropZone=this.element.querySelector(".upload-drop-zone"),this.fileInput=this.element.querySelector(".upload-file-input"),this.promptElement=this.element.querySelector(".upload-prompt"),this.previewElement=this.element.querySelector(".upload-preview"),this.loadingElement=this.element.querySelector(".upload-loading"),this.resultElement=this.element.querySelector(".upload-result"),this.previewImage=this.element.querySelector(".preview-image"),this.fileName=this.element.querySelector(".file-name"),this.fileDetails=this.element.querySelector(".file-details"),this.progressBar=this.element.querySelector(".progress-bar"),this.uploadStatus=this.element.querySelector(".upload-status"),this.setupEventListeners()}setupEventListeners(){this.dropZone.addEventListener("dragenter",this._preventDefaults),this.dropZone.addEventListener("dragover",this._handleDragOver),this.dropZone.addEventListener("dragleave",this._handleDragLeave),this.dropZone.addEventListener("drop",this._handleDrop),this.fileInput.addEventListener("change",this._handleFileSelect),["dragenter","dragover","dragleave","drop"].forEach(t=>{document.addEventListener(t,this._preventDefaults)})}preventDefaults(t){t.preventDefault(),t.stopPropagation()}handleDragOver(t){this.preventDefaults(t),this.dropZone.classList.add("border-primary","bg-light"),this.dropZone.style.borderColor="#0d6efd"}handleDragLeave(t){this.preventDefaults(t),this.dropZone.contains(t.relatedTarget)||(this.dropZone.classList.remove("border-primary","bg-light"),this.dropZone.style.borderColor="#dee2e6")}async handleDrop(t){this.preventDefaults(t),this.dropZone.classList.remove("border-primary","bg-light"),this.dropZone.style.borderColor="#dee2e6";const e=Array.from(t.dataTransfer.files);e.length>0&&await this.processFile(e[0])}async handleFileSelect(t){const e=Array.from(t.target.files);e.length>0&&await this.processFile(e[0])}async processFile(t){const e=this.validateFile(t);e.valid?(this.selectedFile=t,await this.showPreview(t),this.autoUpload&&setTimeout(()=>this.uploadFile(),100)):this.showError(e.error)}validateFile(t){return this.acceptedTypes.includes(t.type)?t.size>this.maxFileSize?{valid:!1,error:`File size (${this.formatFileSize(t.size)}) exceeds maximum allowed size (${this.formatFileSize(this.maxFileSize)})`}:{valid:!0}:{valid:!1,error:`File type "${t.type}" is not supported. Please use: ${this.acceptedTypes.map(t=>t.split("/")[1].toUpperCase()).join(", ")}`}}async showPreview(t){this.previewUrl&&URL.revokeObjectURL(this.previewUrl),this.previewUrl=URL.createObjectURL(t),this.previewImage.src=this.previewUrl,this.fileName.textContent=t.name,this.fileDetails.textContent=`${this.formatFileSize(t.size)} • ${t.type.split("/")[1].toUpperCase()}`,this.promptElement.classList.add("d-none"),this.previewElement.classList.remove("d-none"),this.hideResult(),this.emitUploadEvent("preview",{file:t,previewUrl:this.previewUrl})}async uploadFile(){if(this.selectedFile&&!this.isUploading){this.isUploading=!0,this.showLoading();try{let t;if(this.onUpload&&"function"==typeof this.onUpload)t=await this.onUpload(this.selectedFile,this.updateProgress.bind(this));else{if(!this.uploadUrl)throw new Error("No upload method configured. Provide either uploadUrl or onUpload callback.");t=await this.uploadToUrl(this.selectedFile)}this.showSuccess("File uploaded successfully!"),this.emitUploadEvent("upload-success",{file:this.selectedFile,result:t})}catch(t){console.error("Upload failed:",t),this.showError(`Upload failed: ${t.message}`),this.emitUploadEvent("upload-error",{file:this.selectedFile,error:t})}finally{this.isUploading=!1,this.hideLoading()}}}async uploadToUrl(t){return new Promise((e,i)=>{const a=new FormData;a.append("image",t);const s=new XMLHttpRequest;s.upload.addEventListener("progress",t=>{if(t.lengthComputable){const e=Math.round(t.loaded/t.total*100);this.updateProgress(e)}}),s.addEventListener("load",()=>{if(s.status>=200&&s.status<300)try{const t=JSON.parse(s.responseText);e(t)}catch(t){e({success:!0,response:s.responseText})}else i(new Error(`HTTP ${s.status}: ${s.statusText}`))}),s.addEventListener("error",()=>{i(new Error("Network error occurred"))}),s.addEventListener("timeout",()=>{i(new Error("Upload timeout"))}),s.open("POST",this.uploadUrl),s.timeout=3e4,s.send(a)})}updateProgress(t){this.progressBar&&(this.progressBar.style.width=`${t}%`,this.progressBar.setAttribute("aria-valuenow",t)),this.uploadStatus&&(this.uploadStatus.textContent=`Uploading... ${t}%`)}showLoading(){this.previewElement.classList.add("d-none"),this.loadingElement.classList.remove("d-none"),this.updateProgress(0)}hideLoading(){this.loadingElement.classList.add("d-none"),this.autoUpload&&!this.selectedFile||this.previewElement.classList.remove("d-none")}showSuccess(t){this.showResult("success",t)}showError(t){this.showResult("danger",t)}showResult(t,e){const i=this.resultElement.querySelector(".alert"),a="success"===t?"check-circle-fill":"exclamation-triangle-fill";i.className=`alert alert-${t}`,i.innerHTML=`\n <i class="bi bi-${a} me-2"></i>\n ${e}\n `,this.resultElement.classList.remove("d-none"),"success"===t&&setTimeout(()=>this.hideResult(),5e3)}hideResult(){this.resultElement.classList.add("d-none")}clearFile(){this.previewUrl&&(URL.revokeObjectURL(this.previewUrl),this.previewUrl=null),this.selectedFile=null,this.isUploading=!1,this.fileInput.value="",this.previewElement.classList.add("d-none"),this.loadingElement.classList.add("d-none"),this.promptElement.classList.remove("d-none"),this.hideResult(),this.emitUploadEvent("cleared")}formatFileSize(t){if(0===t)return"0 Bytes";const e=Math.floor(Math.log(t)/Math.log(1024));return parseFloat((t/Math.pow(1024,e)).toFixed(2))+" "+["Bytes","KB","MB","GB"][e]}emitUploadEvent(t,e={}){const i=this.getApp()?.events;i&&i.emit(`imageupload:${t}`,{view:this,...e})}async handleActionSelectFile(){this.fileInput.click()}async handleActionUpload(){await this.uploadFile()}async handleActionClear(){this.clearFile()}async onBeforeDestroy(){this.previewUrl&&(URL.revokeObjectURL(this.previewUrl),this.previewUrl=null),this.dropZone&&(this.dropZone.removeEventListener("dragenter",this._preventDefaults),this.dropZone.removeEventListener("dragover",this._handleDragOver),this.dropZone.removeEventListener("dragleave",this._handleDragLeave),this.dropZone.removeEventListener("drop",this._handleDrop)),this.fileInput&&this.fileInput.removeEventListener("change",this._handleFileSelect),["dragenter","dragover","dragleave","drop"].forEach(t=>{document.removeEventListener(t,this._preventDefaults)}),this.emitUploadEvent("destroyed")}}window.ImageUploadView=ImageUploadView;export{n as BUILD_TIME,ImageCanvasView,ImageCropView,ImageEditor,ImageFiltersView,ImageTransformView,ImageUploadView,ImageViewer,i as LightboxGallery,a as PDFViewer,o as VERSION,r as VERSION_INFO,l as VERSION_MAJOR,h as VERSION_MINOR,c as VERSION_REVISION,s as WebApp};
3738
2
  //# sourceMappingURL=lightbox.es.js.map