@ngx-km/grid 0.0.1

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.
@@ -0,0 +1,820 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, ElementRef, input, Directive, computed, Component, InjectionToken, Injector, DestroyRef, signal, output, effect, untracked } from '@angular/core';
3
+ import { NgStyle, NgComponentOutlet } from '@angular/common';
4
+ import { DomSanitizer } from '@angular/platform-browser';
5
+
6
+ /**
7
+ * Directive that automatically registers/unregisters grid elements for dimension tracking
8
+ */
9
+ class GridElementRefDirective {
10
+ elementRef = inject(ElementRef);
11
+ gridComponent = inject(GridComponent);
12
+ /** The element ID to register */
13
+ gridElementRef = input.required(...(ngDevMode ? [{ debugName: "gridElementRef" }] : []));
14
+ ngOnInit() {
15
+ this.gridComponent.registerElement(this.gridElementRef(), this.elementRef.nativeElement);
16
+ }
17
+ ngOnDestroy() {
18
+ this.gridComponent.unregisterElement(this.gridElementRef());
19
+ }
20
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GridElementRefDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
21
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.15", type: GridElementRefDirective, isStandalone: true, selector: "[gridElementRef]", inputs: { gridElementRef: { classPropertyName: "gridElementRef", publicName: "gridElementRef", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
22
+ }
23
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GridElementRefDirective, decorators: [{
24
+ type: Directive,
25
+ args: [{
26
+ selector: '[gridElementRef]',
27
+ standalone: true,
28
+ }]
29
+ }], propDecorators: { gridElementRef: [{ type: i0.Input, args: [{ isSignal: true, alias: "gridElementRef", required: true }] }] } });
30
+
31
+ class ToolbarComponent {
32
+ sanitizer = inject(DomSanitizer);
33
+ /** Toolbar configuration */
34
+ config = input.required(...(ngDevMode ? [{ debugName: "config" }] : []));
35
+ /** Computed host classes based on position */
36
+ hostClasses = computed(() => {
37
+ const cfg = this.config();
38
+ const posClass = `pos-${cfg.position}`;
39
+ return cfg.className ? `${posClass} ${cfg.className}` : posClass;
40
+ }, ...(ngDevMode ? [{ debugName: "hostClasses" }] : []));
41
+ /** Computed classes for the toolbar div */
42
+ toolbarClasses = computed(() => {
43
+ return '';
44
+ }, ...(ngDevMode ? [{ debugName: "toolbarClasses" }] : []));
45
+ /** Sanitize HTML for safe rendering of SVG icons */
46
+ sanitizeHtml(html) {
47
+ return this.sanitizer.bypassSecurityTrustHtml(html);
48
+ }
49
+ /** Handle button click */
50
+ onButtonClick(button, event) {
51
+ event.stopPropagation();
52
+ if (!button.disabled && button.onClick) {
53
+ button.onClick();
54
+ }
55
+ }
56
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: ToolbarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
57
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.15", type: ToolbarComponent, isStandalone: true, selector: "ngx-grid-toolbar", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "class": "hostClasses()" } }, ngImport: i0, template: `
58
+ <div class="toolbar" [class]="toolbarClasses()">
59
+ @for (button of config().buttons; track button.id) {
60
+ <button
61
+ class="toolbar-btn"
62
+ [class.active]="button.active"
63
+ [class.disabled]="button.disabled"
64
+ [disabled]="button.disabled"
65
+ [title]="button.label"
66
+ (click)="onButtonClick(button, $event)">
67
+ @if (button.icon) {
68
+ <span class="btn-icon" [innerHTML]="sanitizeHtml(button.icon)"></span>
69
+ } @else {
70
+ <span class="btn-label">{{ button.label }}</span>
71
+ }
72
+ </button>
73
+ }
74
+ </div>
75
+ `, isInline: true, styles: [":host{position:absolute;z-index:100;pointer-events:none}.toolbar{display:flex;gap:4px;padding:6px;background:#1e1e2ef2;border:1px solid #444;border-radius:6px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);pointer-events:auto}:host(.pos-top-left){top:12px;left:12px}:host(.pos-top-center){top:12px;left:50%;transform:translate(-50%)}:host(.pos-top-right){top:12px;right:12px}:host(.pos-bottom-left){bottom:12px;left:12px}:host(.pos-bottom-center){bottom:12px;left:50%;transform:translate(-50%)}:host(.pos-bottom-right){bottom:12px;right:12px}.toolbar-btn{display:flex;align-items:center;justify-content:center;min-width:32px;height:32px;padding:0 8px;border:1px solid #555;background:#2a2a3e;color:#fff;border-radius:4px;cursor:pointer;transition:all .15s ease;font-size:.875rem}.toolbar-btn:hover:not(.disabled){background:#3a3a4e;border-color:#666}.toolbar-btn.active{background:#4a4a6e;border-color:#6a6a8e}.toolbar-btn.disabled{opacity:.5;cursor:not-allowed}.btn-icon{display:flex;align-items:center;justify-content:center;width:18px;height:18px;pointer-events:none}.btn-icon ::ng-deep svg{width:100%;height:100%;pointer-events:none}.btn-label{white-space:nowrap}\n"] });
76
+ }
77
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: ToolbarComponent, decorators: [{
78
+ type: Component,
79
+ args: [{ selector: 'ngx-grid-toolbar', standalone: true, template: `
80
+ <div class="toolbar" [class]="toolbarClasses()">
81
+ @for (button of config().buttons; track button.id) {
82
+ <button
83
+ class="toolbar-btn"
84
+ [class.active]="button.active"
85
+ [class.disabled]="button.disabled"
86
+ [disabled]="button.disabled"
87
+ [title]="button.label"
88
+ (click)="onButtonClick(button, $event)">
89
+ @if (button.icon) {
90
+ <span class="btn-icon" [innerHTML]="sanitizeHtml(button.icon)"></span>
91
+ } @else {
92
+ <span class="btn-label">{{ button.label }}</span>
93
+ }
94
+ </button>
95
+ }
96
+ </div>
97
+ `, host: {
98
+ '[class]': 'hostClasses()',
99
+ }, styles: [":host{position:absolute;z-index:100;pointer-events:none}.toolbar{display:flex;gap:4px;padding:6px;background:#1e1e2ef2;border:1px solid #444;border-radius:6px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);pointer-events:auto}:host(.pos-top-left){top:12px;left:12px}:host(.pos-top-center){top:12px;left:50%;transform:translate(-50%)}:host(.pos-top-right){top:12px;right:12px}:host(.pos-bottom-left){bottom:12px;left:12px}:host(.pos-bottom-center){bottom:12px;left:50%;transform:translate(-50%)}:host(.pos-bottom-right){bottom:12px;right:12px}.toolbar-btn{display:flex;align-items:center;justify-content:center;min-width:32px;height:32px;padding:0 8px;border:1px solid #555;background:#2a2a3e;color:#fff;border-radius:4px;cursor:pointer;transition:all .15s ease;font-size:.875rem}.toolbar-btn:hover:not(.disabled){background:#3a3a4e;border-color:#666}.toolbar-btn.active{background:#4a4a6e;border-color:#6a6a8e}.toolbar-btn.disabled{opacity:.5;cursor:not-allowed}.btn-icon{display:flex;align-items:center;justify-content:center;width:18px;height:18px;pointer-events:none}.btn-icon ::ng-deep svg{width:100%;height:100%;pointer-events:none}.btn-label{white-space:nowrap}\n"] }]
100
+ }], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }] } });
101
+
102
+ /**
103
+ * Injection token for accessing element data in dynamically rendered components.
104
+ * The data can be either a plain value or a Signal for reactive updates.
105
+ */
106
+ const GRID_ELEMENT_DATA = new InjectionToken('GRID_ELEMENT_DATA');
107
+ /** Default cell size in pixels */
108
+ const DEFAULT_CELL_SIZE = 20;
109
+ /** Default background pattern color */
110
+ const DEFAULT_PATTERN_COLOR = '#3a3a5a';
111
+ /** Default minimum zoom level */
112
+ const DEFAULT_ZOOM_MIN = 0.1;
113
+ /** Default maximum zoom level */
114
+ const DEFAULT_ZOOM_MAX = 5;
115
+ /** Default zoom speed multiplier */
116
+ const DEFAULT_ZOOM_SPEED = 0.001;
117
+ /** Default background color */
118
+ const DEFAULT_BACKGROUND_COLOR = '#1a1a2e';
119
+ /**
120
+ * Default grid configuration
121
+ */
122
+ const DEFAULT_GRID_CONFIG = {
123
+ dimensionMode: 'full',
124
+ backgroundMode: 'lines',
125
+ cellSize: DEFAULT_CELL_SIZE,
126
+ backgroundPatternColor: DEFAULT_PATTERN_COLOR,
127
+ backgroundPatternOpacity: 1,
128
+ backgroundColor: DEFAULT_BACKGROUND_COLOR,
129
+ panEnabled: true,
130
+ };
131
+ /**
132
+ * Default viewport state
133
+ */
134
+ const DEFAULT_VIEWPORT_STATE = {
135
+ x: 0,
136
+ y: 0,
137
+ zoom: 1,
138
+ };
139
+
140
+ class GridComponent {
141
+ elementRef = inject(ElementRef);
142
+ injector = inject(Injector);
143
+ destroyRef = inject(DestroyRef);
144
+ /** Grid configuration */
145
+ config = input(DEFAULT_GRID_CONFIG, ...(ngDevMode ? [{ debugName: "config" }] : []));
146
+ /** Elements to render on the grid */
147
+ elements = input([], ...(ngDevMode ? [{ debugName: "elements" }] : []));
148
+ /** Current viewport state (pan offset and zoom) */
149
+ viewport = signal({ ...DEFAULT_VIEWPORT_STATE }, ...(ngDevMode ? [{ debugName: "viewport" }] : []));
150
+ /** Emits when viewport changes */
151
+ viewportChange = output();
152
+ /** Emits when element dimensions are measured or change */
153
+ elementsRendered = output();
154
+ /** Emits when an element is dragged to a new position */
155
+ elementPositionChange = output();
156
+ /** Map of element IDs to their DOM elements for dimension tracking */
157
+ elementRefs = new Map();
158
+ /** ResizeObserver for tracking element dimension changes */
159
+ resizeObserver = null;
160
+ /** Debounce timer for batching dimension updates */
161
+ dimensionUpdateTimer = null;
162
+ /** Internal state for pan tracking */
163
+ isPanning = signal(false, ...(ngDevMode ? [{ debugName: "isPanning" }] : []));
164
+ panStartX = 0;
165
+ panStartY = 0;
166
+ panStartViewportX = 0;
167
+ panStartViewportY = 0;
168
+ /** Internal state for element drag tracking */
169
+ isDraggingElement = signal(false, ...(ngDevMode ? [{ debugName: "isDraggingElement" }] : []));
170
+ draggingElementId = null;
171
+ dragStartX = 0;
172
+ dragStartY = 0;
173
+ dragStartElementX = 0;
174
+ dragStartElementY = 0;
175
+ /** Internal copy of element positions for drag updates */
176
+ elementPositions = new Map();
177
+ /** Track if we're currently dragging to avoid overwriting drag position */
178
+ syncPositionsFromInput = true;
179
+ constructor() {
180
+ // Sync element positions from input when elements change
181
+ // This allows external code to update positions programmatically
182
+ effect(() => {
183
+ const inputElements = this.elements();
184
+ untracked(() => {
185
+ // Skip sync if currently dragging
186
+ if (this.isDraggingElement())
187
+ return;
188
+ // Update internal positions from input
189
+ inputElements.forEach(el => {
190
+ const currentPos = this.elementPositions.get(el.id);
191
+ // Only update if position is different (allows external updates)
192
+ if (!currentPos || currentPos.x !== el.x || currentPos.y !== el.y) {
193
+ this.elementPositions.set(el.id, { x: el.x, y: el.y });
194
+ }
195
+ });
196
+ // Remove positions for elements that no longer exist
197
+ const inputIds = new Set(inputElements.map(e => e.id));
198
+ for (const id of this.elementPositions.keys()) {
199
+ if (!inputIds.has(id)) {
200
+ this.elementPositions.delete(id);
201
+ }
202
+ }
203
+ });
204
+ });
205
+ }
206
+ /** Computed container styles based on config */
207
+ containerStyles = computed(() => {
208
+ const cfg = this.config();
209
+ const styles = {};
210
+ if (cfg.dimensionMode === 'fixed') {
211
+ if (cfg.width)
212
+ styles['width'] = `${cfg.width}px`;
213
+ if (cfg.height)
214
+ styles['height'] = `${cfg.height}px`;
215
+ }
216
+ else {
217
+ styles['width'] = '100%';
218
+ styles['height'] = '100%';
219
+ }
220
+ if (cfg.minWidth)
221
+ styles['min-width'] = `${cfg.minWidth}px`;
222
+ if (cfg.minHeight)
223
+ styles['min-height'] = `${cfg.minHeight}px`;
224
+ if (cfg.maxWidth)
225
+ styles['max-width'] = `${cfg.maxWidth}px`;
226
+ if (cfg.maxHeight)
227
+ styles['max-height'] = `${cfg.maxHeight}px`;
228
+ // Background color
229
+ styles['background-color'] = cfg.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
230
+ // Cursor style
231
+ const panEnabled = cfg.panEnabled ?? true;
232
+ if (panEnabled) {
233
+ styles['cursor'] = this.isPanning() ? 'grabbing' : 'grab';
234
+ }
235
+ return styles;
236
+ }, ...(ngDevMode ? [{ debugName: "containerStyles" }] : []));
237
+ /** Computed background styles for the pattern layer */
238
+ backgroundStyles = computed(() => {
239
+ const cfg = this.config();
240
+ const vp = this.viewport();
241
+ const mode = cfg.backgroundMode ?? 'lines';
242
+ const cellSize = cfg.cellSize ?? DEFAULT_CELL_SIZE;
243
+ const patternColor = cfg.backgroundPatternColor ?? DEFAULT_PATTERN_COLOR;
244
+ const opacity = cfg.backgroundPatternOpacity ?? 1;
245
+ const styles = {};
246
+ if (mode === 'none') {
247
+ return styles;
248
+ }
249
+ // Apply zoom to cell size
250
+ const scaledCellSize = cellSize * vp.zoom;
251
+ if (mode === 'lines') {
252
+ styles['background-image'] = `linear-gradient(to right, ${patternColor} 1px, transparent 1px), linear-gradient(to bottom, ${patternColor} 1px, transparent 1px)`;
253
+ styles['background-size'] = `${scaledCellSize}px ${scaledCellSize}px`;
254
+ }
255
+ else if (mode === 'dots') {
256
+ // Scale dot size with zoom
257
+ const dotSize = 1.5 * vp.zoom;
258
+ styles['background-image'] = `radial-gradient(circle, ${patternColor} ${dotSize}px, transparent ${dotSize}px)`;
259
+ styles['background-size'] = `${scaledCellSize}px ${scaledCellSize}px`;
260
+ }
261
+ // Apply pan offset to background position for seamless scrolling
262
+ styles['background-position'] = `${vp.x}px ${vp.y}px`;
263
+ styles['opacity'] = String(opacity);
264
+ return styles;
265
+ }, ...(ngDevMode ? [{ debugName: "backgroundStyles" }] : []));
266
+ /** Computed transform styles for the viewport layer (contains all elements) */
267
+ viewportTransform = computed(() => {
268
+ const vp = this.viewport();
269
+ return `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`;
270
+ }, ...(ngDevMode ? [{ debugName: "viewportTransform" }] : []));
271
+ /** Whether the integrated toolbar should be shown */
272
+ showToolbar = computed(() => !!this.config().toolbar, ...(ngDevMode ? [{ debugName: "showToolbar" }] : []));
273
+ /** Whether the loading overlay should be shown */
274
+ isLoading = computed(() => !!this.config().loading, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
275
+ /** Computed toolbar configuration for the integrated toolbar */
276
+ integratedToolbarConfig = computed(() => {
277
+ const cfg = this.config();
278
+ if (!cfg.toolbar)
279
+ return null;
280
+ const toolbarCfg = cfg.toolbar;
281
+ const buttons = [];
282
+ // Add reset view button if enabled (default: true)
283
+ if (toolbarCfg.showResetView !== false) {
284
+ buttons.push({
285
+ id: 'grid-reset-view',
286
+ label: 'Reset View',
287
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
288
+ onClick: () => this.resetViewport(),
289
+ });
290
+ }
291
+ // Add zoom controls if enabled (default: true)
292
+ if (toolbarCfg.showZoomControls !== false) {
293
+ buttons.push({
294
+ id: 'grid-zoom-in',
295
+ label: 'Zoom In',
296
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
297
+ onClick: () => this.zoomIn(),
298
+ });
299
+ buttons.push({
300
+ id: 'grid-zoom-out',
301
+ label: 'Zoom Out',
302
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
303
+ onClick: () => this.zoomOut(),
304
+ });
305
+ }
306
+ // Add custom buttons
307
+ if (toolbarCfg.customButtons?.length) {
308
+ buttons.push(...toolbarCfg.customButtons);
309
+ }
310
+ return {
311
+ position: toolbarCfg.position ?? 'top-left',
312
+ buttons,
313
+ };
314
+ }, ...(ngDevMode ? [{ debugName: "integratedToolbarConfig" }] : []));
315
+ /** Get screen position styles for an element */
316
+ getElementStyles(element) {
317
+ // Use tracked position if dragging, otherwise use input position
318
+ const pos = this.elementPositions.get(element.id) ?? { x: element.x, y: element.y };
319
+ const cfg = this.config();
320
+ const dragEnabled = cfg.dragEnabled ?? true;
321
+ return {
322
+ position: 'absolute',
323
+ left: `${pos.x}px`,
324
+ top: `${pos.y}px`,
325
+ 'transform-origin': 'top left',
326
+ cursor: dragEnabled ? (this.isDraggingElement() && this.draggingElementId === element.id ? 'grabbing' : 'grab') : 'default',
327
+ };
328
+ }
329
+ /** Create an injector for passing data to a dynamically rendered component */
330
+ createElementInjector(element) {
331
+ return Injector.create({
332
+ providers: [
333
+ { provide: GRID_ELEMENT_DATA, useValue: element.data },
334
+ ],
335
+ parent: this.injector,
336
+ });
337
+ }
338
+ /** Handle mouse down to start panning */
339
+ onMouseDown(event) {
340
+ const cfg = this.config();
341
+ if (!(cfg.panEnabled ?? true))
342
+ return;
343
+ // Only pan on left mouse button
344
+ if (event.button !== 0)
345
+ return;
346
+ this.isPanning.set(true);
347
+ this.panStartX = event.clientX;
348
+ this.panStartY = event.clientY;
349
+ const vp = this.viewport();
350
+ this.panStartViewportX = vp.x;
351
+ this.panStartViewportY = vp.y;
352
+ // Prevent text selection during drag
353
+ event.preventDefault();
354
+ // Add window-level listeners for move and up
355
+ window.addEventListener('mousemove', this.onMouseMove);
356
+ window.addEventListener('mouseup', this.onMouseUp);
357
+ }
358
+ /** Handle mouse move during panning */
359
+ onMouseMove = (event) => {
360
+ if (!this.isPanning())
361
+ return;
362
+ const deltaX = event.clientX - this.panStartX;
363
+ const deltaY = event.clientY - this.panStartY;
364
+ let newX = this.panStartViewportX + deltaX;
365
+ let newY = this.panStartViewportY + deltaY;
366
+ // Apply boundaries if configured
367
+ const cfg = this.config();
368
+ if (cfg.panMinX !== undefined)
369
+ newX = Math.max(cfg.panMinX, newX);
370
+ if (cfg.panMaxX !== undefined)
371
+ newX = Math.min(cfg.panMaxX, newX);
372
+ if (cfg.panMinY !== undefined)
373
+ newY = Math.max(cfg.panMinY, newY);
374
+ if (cfg.panMaxY !== undefined)
375
+ newY = Math.min(cfg.panMaxY, newY);
376
+ const currentVp = this.viewport();
377
+ const newViewport = { ...currentVp, x: newX, y: newY };
378
+ this.viewport.set(newViewport);
379
+ this.viewportChange.emit(newViewport);
380
+ };
381
+ /** Handle mouse up to stop panning */
382
+ onMouseUp = () => {
383
+ this.isPanning.set(false);
384
+ window.removeEventListener('mousemove', this.onMouseMove);
385
+ window.removeEventListener('mouseup', this.onMouseUp);
386
+ };
387
+ /** Handle mouse down on an element to start dragging */
388
+ onElementMouseDown(event, element) {
389
+ const cfg = this.config();
390
+ if (!(cfg.dragEnabled ?? true))
391
+ return;
392
+ // Only drag on left mouse button
393
+ if (event.button !== 0)
394
+ return;
395
+ // Stop propagation to prevent pan from starting
396
+ event.stopPropagation();
397
+ event.preventDefault();
398
+ // Initialize position tracking if not already done
399
+ if (!this.elementPositions.has(element.id)) {
400
+ this.elementPositions.set(element.id, { x: element.x, y: element.y });
401
+ }
402
+ const pos = this.elementPositions.get(element.id);
403
+ this.isDraggingElement.set(true);
404
+ this.draggingElementId = element.id;
405
+ this.dragStartX = event.clientX;
406
+ this.dragStartY = event.clientY;
407
+ this.dragStartElementX = pos.x;
408
+ this.dragStartElementY = pos.y;
409
+ // Add window-level listeners for move and up
410
+ window.addEventListener('mousemove', this.onElementMouseMove);
411
+ window.addEventListener('mouseup', this.onElementMouseUp);
412
+ }
413
+ /** Handle mouse move during element dragging */
414
+ onElementMouseMove = (event) => {
415
+ if (!this.isDraggingElement() || !this.draggingElementId)
416
+ return;
417
+ const cfg = this.config();
418
+ // Calculate delta in screen space, then convert to world space (account for zoom)
419
+ const vp = this.viewport();
420
+ const deltaX = (event.clientX - this.dragStartX) / vp.zoom;
421
+ const deltaY = (event.clientY - this.dragStartY) / vp.zoom;
422
+ let newX = this.dragStartElementX + deltaX;
423
+ let newY = this.dragStartElementY + deltaY;
424
+ // Apply snap if enabled
425
+ if (cfg.snapEnabled) {
426
+ const snapSize = cfg.snapGridSize ?? cfg.cellSize ?? DEFAULT_CELL_SIZE;
427
+ newX = Math.round(newX / snapSize) * snapSize;
428
+ newY = Math.round(newY / snapSize) * snapSize;
429
+ }
430
+ const previousPos = this.elementPositions.get(this.draggingElementId) ?? { x: this.dragStartElementX, y: this.dragStartElementY };
431
+ // Update internal position
432
+ this.elementPositions.set(this.draggingElementId, { x: newX, y: newY });
433
+ // Emit position change
434
+ this.elementPositionChange.emit({
435
+ id: this.draggingElementId,
436
+ x: newX,
437
+ y: newY,
438
+ previousX: previousPos.x,
439
+ previousY: previousPos.y,
440
+ });
441
+ };
442
+ /** Handle mouse up to stop element dragging */
443
+ onElementMouseUp = () => {
444
+ this.isDraggingElement.set(false);
445
+ this.draggingElementId = null;
446
+ window.removeEventListener('mousemove', this.onElementMouseMove);
447
+ window.removeEventListener('mouseup', this.onElementMouseUp);
448
+ };
449
+ /** Reset viewport to default position */
450
+ resetViewport() {
451
+ const newViewport = { ...DEFAULT_VIEWPORT_STATE };
452
+ this.viewport.set(newViewport);
453
+ this.viewportChange.emit(newViewport);
454
+ }
455
+ /**
456
+ * Fit all content to view
457
+ * Centers content in the viewport, optionally zooming out to fit everything
458
+ * @param fitZoom If true, zooms out if needed to fit all content (never zooms in beyond 1.0)
459
+ * @param padding Padding around content in pixels (default: 40)
460
+ */
461
+ fitToView(fitZoom = false, padding = 40) {
462
+ const currentElements = this.elements();
463
+ if (currentElements.length === 0) {
464
+ this.resetViewport();
465
+ return;
466
+ }
467
+ // Calculate bounding box of all elements
468
+ const bounds = this.calculateContentBounds();
469
+ if (!bounds) {
470
+ this.resetViewport();
471
+ return;
472
+ }
473
+ // Get container dimensions
474
+ const containerRect = this.elementRef.nativeElement.getBoundingClientRect();
475
+ const containerWidth = containerRect.width;
476
+ const containerHeight = containerRect.height;
477
+ // Available space for content (with padding)
478
+ const availableWidth = containerWidth - padding * 2;
479
+ const availableHeight = containerHeight - padding * 2;
480
+ let newZoom = 1;
481
+ if (fitZoom) {
482
+ // Calculate zoom needed to fit content
483
+ const scaleX = availableWidth / bounds.width;
484
+ const scaleY = availableHeight / bounds.height;
485
+ const fitScale = Math.min(scaleX, scaleY);
486
+ // Never zoom in beyond 1.0, only zoom out if needed
487
+ newZoom = Math.min(1, fitScale);
488
+ // Respect zoom limits
489
+ const cfg = this.config();
490
+ const zoomMin = cfg.zoomMin ?? DEFAULT_ZOOM_MIN;
491
+ newZoom = Math.max(zoomMin, newZoom);
492
+ }
493
+ // Calculate center of content in world space
494
+ const contentCenterX = bounds.x + bounds.width / 2;
495
+ const contentCenterY = bounds.y + bounds.height / 2;
496
+ // Calculate viewport offset to center content
497
+ // Container center = contentCenter * zoom + offset
498
+ // offset = containerCenter - contentCenter * zoom
499
+ const newX = containerWidth / 2 - contentCenterX * newZoom;
500
+ const newY = containerHeight / 2 - contentCenterY * newZoom;
501
+ const newViewport = {
502
+ x: newX,
503
+ y: newY,
504
+ zoom: newZoom,
505
+ };
506
+ this.viewport.set(newViewport);
507
+ this.viewportChange.emit(newViewport);
508
+ }
509
+ /**
510
+ * Check if an element is fully visible in the viewport
511
+ * @param elementId The element ID to check
512
+ * @param padding Padding from viewport edges (default: 0)
513
+ * @returns true if element is fully visible, false otherwise
514
+ */
515
+ isElementVisible(elementId, padding = 0) {
516
+ const bounds = this.getElementBounds(elementId);
517
+ if (!bounds)
518
+ return false;
519
+ const vp = this.viewport();
520
+ const containerRect = this.elementRef.nativeElement.getBoundingClientRect();
521
+ // Convert element bounds to screen space
522
+ const screenLeft = bounds.x * vp.zoom + vp.x;
523
+ const screenTop = bounds.y * vp.zoom + vp.y;
524
+ const screenRight = (bounds.x + bounds.width) * vp.zoom + vp.x;
525
+ const screenBottom = (bounds.y + bounds.height) * vp.zoom + vp.y;
526
+ // Check if fully within viewport (with padding)
527
+ return (screenLeft >= padding &&
528
+ screenTop >= padding &&
529
+ screenRight <= containerRect.width - padding &&
530
+ screenBottom <= containerRect.height - padding);
531
+ }
532
+ /**
533
+ * Center viewport on a specific element (no zoom change)
534
+ * @param elementId The element ID to center on
535
+ */
536
+ centerOnElement(elementId) {
537
+ const bounds = this.getElementBounds(elementId);
538
+ if (!bounds)
539
+ return;
540
+ const vp = this.viewport();
541
+ const containerRect = this.elementRef.nativeElement.getBoundingClientRect();
542
+ // Calculate element center in world space
543
+ const elementCenterX = bounds.x + bounds.width / 2;
544
+ const elementCenterY = bounds.y + bounds.height / 2;
545
+ // Calculate viewport offset to center element
546
+ const newX = containerRect.width / 2 - elementCenterX * vp.zoom;
547
+ const newY = containerRect.height / 2 - elementCenterY * vp.zoom;
548
+ const newViewport = {
549
+ x: newX,
550
+ y: newY,
551
+ zoom: vp.zoom,
552
+ };
553
+ this.viewport.set(newViewport);
554
+ this.viewportChange.emit(newViewport);
555
+ }
556
+ /**
557
+ * Scroll element into view with minimal pan
558
+ * Only pans if element is not fully visible
559
+ * @param elementId The element ID to scroll into view
560
+ * @param padding Padding from viewport edges (default: 40)
561
+ */
562
+ scrollToElement(elementId, padding = 40) {
563
+ if (this.isElementVisible(elementId, padding)) {
564
+ return; // Already visible, no need to scroll
565
+ }
566
+ const bounds = this.getElementBounds(elementId);
567
+ if (!bounds)
568
+ return;
569
+ const vp = this.viewport();
570
+ const containerRect = this.elementRef.nativeElement.getBoundingClientRect();
571
+ // Convert element bounds to screen space
572
+ const screenLeft = bounds.x * vp.zoom + vp.x;
573
+ const screenTop = bounds.y * vp.zoom + vp.y;
574
+ const screenRight = (bounds.x + bounds.width) * vp.zoom + vp.x;
575
+ const screenBottom = (bounds.y + bounds.height) * vp.zoom + vp.y;
576
+ let newX = vp.x;
577
+ let newY = vp.y;
578
+ // Calculate minimal pan needed to bring element into view
579
+ // Check horizontal
580
+ if (screenLeft < padding) {
581
+ // Element is too far left, pan right
582
+ newX = vp.x + (padding - screenLeft);
583
+ }
584
+ else if (screenRight > containerRect.width - padding) {
585
+ // Element is too far right, pan left
586
+ newX = vp.x - (screenRight - (containerRect.width - padding));
587
+ }
588
+ // Check vertical
589
+ if (screenTop < padding) {
590
+ // Element is too far up, pan down
591
+ newY = vp.y + (padding - screenTop);
592
+ }
593
+ else if (screenBottom > containerRect.height - padding) {
594
+ // Element is too far down, pan up
595
+ newY = vp.y - (screenBottom - (containerRect.height - padding));
596
+ }
597
+ if (newX !== vp.x || newY !== vp.y) {
598
+ const newViewport = {
599
+ x: newX,
600
+ y: newY,
601
+ zoom: vp.zoom,
602
+ };
603
+ this.viewport.set(newViewport);
604
+ this.viewportChange.emit(newViewport);
605
+ }
606
+ }
607
+ /**
608
+ * Get bounds of a specific element
609
+ * @param elementId The element ID
610
+ * @returns Element bounds in world coordinates, or null if not found
611
+ */
612
+ getElementBounds(elementId) {
613
+ const element = this.elements().find(e => e.id === elementId);
614
+ if (!element)
615
+ return null;
616
+ const pos = this.elementPositions.get(elementId) ?? { x: element.x, y: element.y };
617
+ const domElement = this.elementRefs.get(elementId);
618
+ let width = 100; // default
619
+ let height = 50; // default
620
+ if (domElement) {
621
+ const rect = domElement.getBoundingClientRect();
622
+ const zoom = this.viewport().zoom;
623
+ width = rect.width / zoom;
624
+ height = rect.height / zoom;
625
+ }
626
+ return {
627
+ x: pos.x,
628
+ y: pos.y,
629
+ width,
630
+ height,
631
+ };
632
+ }
633
+ /**
634
+ * Calculate the bounding box of all elements
635
+ * @returns Bounding box in world coordinates, or null if no elements
636
+ */
637
+ calculateContentBounds() {
638
+ const currentElements = this.elements();
639
+ if (currentElements.length === 0)
640
+ return null;
641
+ let minX = Infinity;
642
+ let minY = Infinity;
643
+ let maxX = -Infinity;
644
+ let maxY = -Infinity;
645
+ for (const element of currentElements) {
646
+ const bounds = this.getElementBounds(element.id);
647
+ if (!bounds)
648
+ continue;
649
+ minX = Math.min(minX, bounds.x);
650
+ minY = Math.min(minY, bounds.y);
651
+ maxX = Math.max(maxX, bounds.x + bounds.width);
652
+ maxY = Math.max(maxY, bounds.y + bounds.height);
653
+ }
654
+ if (minX === Infinity)
655
+ return null;
656
+ return {
657
+ x: minX,
658
+ y: minY,
659
+ width: maxX - minX,
660
+ height: maxY - minY,
661
+ };
662
+ }
663
+ /** Zoom in by a factor (default 1.25x) */
664
+ zoomIn(factor = 1.25) {
665
+ const cfg = this.config();
666
+ const zoomMax = cfg.zoomMax ?? DEFAULT_ZOOM_MAX;
667
+ const vp = this.viewport();
668
+ const newZoom = Math.min(zoomMax, vp.zoom * factor);
669
+ if (newZoom === vp.zoom)
670
+ return;
671
+ const newViewport = { ...vp, zoom: newZoom };
672
+ this.viewport.set(newViewport);
673
+ this.viewportChange.emit(newViewport);
674
+ }
675
+ /** Zoom out by a factor (default 1.25x) */
676
+ zoomOut(factor = 1.25) {
677
+ const cfg = this.config();
678
+ const zoomMin = cfg.zoomMin ?? DEFAULT_ZOOM_MIN;
679
+ const vp = this.viewport();
680
+ const newZoom = Math.max(zoomMin, vp.zoom / factor);
681
+ if (newZoom === vp.zoom)
682
+ return;
683
+ const newViewport = { ...vp, zoom: newZoom };
684
+ this.viewport.set(newViewport);
685
+ this.viewportChange.emit(newViewport);
686
+ }
687
+ /** Handle mouse wheel for zooming */
688
+ onWheel(event) {
689
+ const cfg = this.config();
690
+ if (!(cfg.zoomEnabled ?? true))
691
+ return;
692
+ // Prevent default scroll behavior
693
+ event.preventDefault();
694
+ const zoomMin = cfg.zoomMin ?? DEFAULT_ZOOM_MIN;
695
+ const zoomMax = cfg.zoomMax ?? DEFAULT_ZOOM_MAX;
696
+ const zoomSpeed = cfg.zoomSpeed ?? DEFAULT_ZOOM_SPEED;
697
+ const vp = this.viewport();
698
+ // Calculate new zoom level
699
+ const delta = -event.deltaY * zoomSpeed;
700
+ const newZoom = Math.max(zoomMin, Math.min(zoomMax, vp.zoom * (1 + delta)));
701
+ // If zoom hasn't changed (at limits), don't update
702
+ if (newZoom === vp.zoom)
703
+ return;
704
+ // Get cursor position relative to the grid container
705
+ const rect = this.elementRef.nativeElement.getBoundingClientRect();
706
+ const cursorX = event.clientX - rect.left;
707
+ const cursorY = event.clientY - rect.top;
708
+ // Calculate the world position under the cursor before zoom
709
+ // worldX = (cursorX - vp.x) / vp.zoom
710
+ const worldX = (cursorX - vp.x) / vp.zoom;
711
+ const worldY = (cursorY - vp.y) / vp.zoom;
712
+ // After zoom, we want the same world position under the cursor
713
+ // cursorX = worldX * newZoom + newVpX
714
+ // newVpX = cursorX - worldX * newZoom
715
+ const newX = cursorX - worldX * newZoom;
716
+ const newY = cursorY - worldY * newZoom;
717
+ const newViewport = {
718
+ x: newX,
719
+ y: newY,
720
+ zoom: newZoom,
721
+ };
722
+ this.viewport.set(newViewport);
723
+ this.viewportChange.emit(newViewport);
724
+ }
725
+ /** Initialize ResizeObserver after view is ready */
726
+ ngAfterViewInit() {
727
+ this.setupResizeObserver();
728
+ }
729
+ /** Clean up resources */
730
+ ngOnDestroy() {
731
+ this.cleanupResizeObserver();
732
+ if (this.dimensionUpdateTimer) {
733
+ clearTimeout(this.dimensionUpdateTimer);
734
+ }
735
+ }
736
+ /** Register an element for dimension tracking */
737
+ registerElement(elementId, element) {
738
+ this.elementRefs.set(elementId, element);
739
+ if (this.resizeObserver) {
740
+ this.resizeObserver.observe(element);
741
+ }
742
+ this.scheduleDimensionUpdate();
743
+ }
744
+ /** Unregister an element from dimension tracking */
745
+ unregisterElement(elementId) {
746
+ const element = this.elementRefs.get(elementId);
747
+ if (element && this.resizeObserver) {
748
+ this.resizeObserver.unobserve(element);
749
+ }
750
+ this.elementRefs.delete(elementId);
751
+ this.scheduleDimensionUpdate();
752
+ }
753
+ /** Set up the ResizeObserver to track element dimension changes */
754
+ setupResizeObserver() {
755
+ if (typeof ResizeObserver === 'undefined') {
756
+ return;
757
+ }
758
+ this.resizeObserver = new ResizeObserver(() => {
759
+ this.scheduleDimensionUpdate();
760
+ });
761
+ // Observe any already-registered elements
762
+ this.elementRefs.forEach((element) => {
763
+ this.resizeObserver.observe(element);
764
+ });
765
+ }
766
+ /** Clean up the ResizeObserver */
767
+ cleanupResizeObserver() {
768
+ if (this.resizeObserver) {
769
+ this.resizeObserver.disconnect();
770
+ this.resizeObserver = null;
771
+ }
772
+ }
773
+ /** Schedule a debounced dimension update */
774
+ scheduleDimensionUpdate() {
775
+ if (this.dimensionUpdateTimer) {
776
+ clearTimeout(this.dimensionUpdateTimer);
777
+ }
778
+ this.dimensionUpdateTimer = setTimeout(() => {
779
+ this.emitRenderedElements();
780
+ }, 10);
781
+ }
782
+ /** Collect and emit all rendered element dimensions */
783
+ emitRenderedElements() {
784
+ const currentElements = this.elements();
785
+ const renderedElements = [];
786
+ for (const element of currentElements) {
787
+ const domElement = this.elementRefs.get(element.id);
788
+ if (domElement) {
789
+ const rect = domElement.getBoundingClientRect();
790
+ // Get the actual size without zoom transform
791
+ const zoom = this.viewport().zoom;
792
+ renderedElements.push({
793
+ id: element.id,
794
+ x: element.x,
795
+ y: element.y,
796
+ width: rect.width / zoom,
797
+ height: rect.height / zoom,
798
+ });
799
+ }
800
+ }
801
+ if (renderedElements.length > 0) {
802
+ this.elementsRendered.emit(renderedElements);
803
+ }
804
+ }
805
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GridComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
806
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.15", type: GridComponent, isStandalone: true, selector: "ngx-grid", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: false, transformFunction: null }, elements: { classPropertyName: "elements", publicName: "elements", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { viewportChange: "viewportChange", elementsRendered: "elementsRendered", elementPositionChange: "elementPositionChange" }, ngImport: i0, template: "<div\n class=\"grid-container\"\n [ngStyle]=\"containerStyles()\"\n (mousedown)=\"onMouseDown($event)\"\n (wheel)=\"onWheel($event)\">\n <div class=\"grid-background\" [ngStyle]=\"backgroundStyles()\"></div>\n <div class=\"grid-viewport\" [style.transform]=\"viewportTransform()\">\n @for (element of elements(); track element.id) {\n <div\n class=\"grid-element\"\n [gridElementRef]=\"element.id\"\n [ngStyle]=\"getElementStyles(element)\"\n (mousedown)=\"onElementMouseDown($event, element)\">\n <ng-container\n *ngComponentOutlet=\"element.component; injector: createElementInjector(element)\" />\n </div>\n }\n </div>\n @if (showToolbar() && integratedToolbarConfig()) {\n <ngx-grid-toolbar [config]=\"integratedToolbarConfig()!\" />\n }\n @if (isLoading()) {\n <div class=\"loading-overlay\">\n <div class=\"loading-spinner\"></div>\n </div>\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%;position:absolute;inset:0}.grid-container{position:relative;overflow:hidden}.grid-background{position:absolute;inset:0;pointer-events:none}.grid-viewport{position:absolute;top:0;left:0;transform-origin:0 0;pointer-events:none}.grid-element{pointer-events:auto}.loading-overlay{position:absolute;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.loading-spinner{width:48px;height:48px;border:4px solid rgba(255,255,255,.2);border-top-color:#6366f1;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: GridElementRefDirective, selector: "[gridElementRef]", inputs: ["gridElementRef"] }, { kind: "component", type: ToolbarComponent, selector: "ngx-grid-toolbar", inputs: ["config"] }] });
807
+ }
808
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GridComponent, decorators: [{
809
+ type: Component,
810
+ args: [{ selector: 'ngx-grid', imports: [NgStyle, NgComponentOutlet, GridElementRefDirective, ToolbarComponent], template: "<div\n class=\"grid-container\"\n [ngStyle]=\"containerStyles()\"\n (mousedown)=\"onMouseDown($event)\"\n (wheel)=\"onWheel($event)\">\n <div class=\"grid-background\" [ngStyle]=\"backgroundStyles()\"></div>\n <div class=\"grid-viewport\" [style.transform]=\"viewportTransform()\">\n @for (element of elements(); track element.id) {\n <div\n class=\"grid-element\"\n [gridElementRef]=\"element.id\"\n [ngStyle]=\"getElementStyles(element)\"\n (mousedown)=\"onElementMouseDown($event, element)\">\n <ng-container\n *ngComponentOutlet=\"element.component; injector: createElementInjector(element)\" />\n </div>\n }\n </div>\n @if (showToolbar() && integratedToolbarConfig()) {\n <ngx-grid-toolbar [config]=\"integratedToolbarConfig()!\" />\n }\n @if (isLoading()) {\n <div class=\"loading-overlay\">\n <div class=\"loading-spinner\"></div>\n </div>\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%;position:absolute;inset:0}.grid-container{position:relative;overflow:hidden}.grid-background{position:absolute;inset:0;pointer-events:none}.grid-viewport{position:absolute;top:0;left:0;transform-origin:0 0;pointer-events:none}.grid-element{pointer-events:auto}.loading-overlay{position:absolute;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.loading-spinner{width:48px;height:48px;border:4px solid rgba(255,255,255,.2);border-top-color:#6366f1;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}\n"] }]
811
+ }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: false }] }], elements: [{ type: i0.Input, args: [{ isSignal: true, alias: "elements", required: false }] }], viewportChange: [{ type: i0.Output, args: ["viewportChange"] }], elementsRendered: [{ type: i0.Output, args: ["elementsRendered"] }], elementPositionChange: [{ type: i0.Output, args: ["elementPositionChange"] }] } });
812
+
813
+ // Components
814
+
815
+ /**
816
+ * Generated bundle index. Do not edit.
817
+ */
818
+
819
+ export { DEFAULT_BACKGROUND_COLOR, DEFAULT_CELL_SIZE, DEFAULT_GRID_CONFIG, DEFAULT_PATTERN_COLOR, DEFAULT_VIEWPORT_STATE, DEFAULT_ZOOM_MAX, DEFAULT_ZOOM_MIN, DEFAULT_ZOOM_SPEED, GRID_ELEMENT_DATA, GridComponent, ToolbarComponent };
820
+ //# sourceMappingURL=ngx-km-grid.mjs.map