@dragonworks/ngx-dashboard 20.0.0
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.
- package/README.md +63 -0
- package/fesm2022/dragonworks-ngx-dashboard.mjs +2192 -0
- package/fesm2022/dragonworks-ngx-dashboard.mjs.map +1 -0
- package/index.d.ts +678 -0
- package/package.json +25 -0
|
@@ -0,0 +1,2192 @@
|
|
|
1
|
+
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
|
2
|
+
import * as i0 from '@angular/core';
|
|
3
|
+
import { signal, computed, ChangeDetectionStrategy, Component, Injectable, inject, Inject, InjectionToken, input, model, output, viewChild, ViewContainerRef, DestroyRef, Renderer2, ElementRef, effect, viewChildren, afterNextRender, PLATFORM_ID, untracked } from '@angular/core';
|
|
4
|
+
import { signalStoreFeature, withState, withMethods, patchState, withComputed, signalStore, withProps } from '@ngrx/signals';
|
|
5
|
+
import * as i1 from '@angular/material/icon';
|
|
6
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
7
|
+
import * as i2 from '@angular/material/tooltip';
|
|
8
|
+
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
9
|
+
import * as i1$1 from '@angular/material/dialog';
|
|
10
|
+
import { MAT_DIALOG_DATA, MatDialogModule, MatDialog } from '@angular/material/dialog';
|
|
11
|
+
import { firstValueFrom } from 'rxjs';
|
|
12
|
+
import * as i2$1 from '@angular/forms';
|
|
13
|
+
import { FormsModule } from '@angular/forms';
|
|
14
|
+
import * as i3 from '@angular/material/button';
|
|
15
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
16
|
+
import * as i4 from '@angular/material/radio';
|
|
17
|
+
import { MatRadioModule } from '@angular/material/radio';
|
|
18
|
+
import * as i1$2 from '@angular/material/menu';
|
|
19
|
+
import { MatMenuTrigger, MatMenuModule } from '@angular/material/menu';
|
|
20
|
+
import * as i3$1 from '@angular/material/divider';
|
|
21
|
+
import { MatDividerModule } from '@angular/material/divider';
|
|
22
|
+
import { DomSanitizer } from '@angular/platform-browser';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Maximum number of columns supported by the grid.
|
|
26
|
+
* This determines the encoding scheme for cell coordinates.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_COLUMNS = 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Utility functions for working with CellId branded type.
|
|
31
|
+
*/
|
|
32
|
+
const CellIdUtils = {
|
|
33
|
+
/**
|
|
34
|
+
* Creates a CellId from row and column coordinates.
|
|
35
|
+
* @param row - The row number (1-based)
|
|
36
|
+
* @param col - The column number (1-based)
|
|
37
|
+
* @returns A branded CellId that encodes both coordinates
|
|
38
|
+
* @throws Error if row or col is less than 1, or if col exceeds MAX_COLUMNS
|
|
39
|
+
*/
|
|
40
|
+
create(row, col) {
|
|
41
|
+
if (row < 1 || col < 1) {
|
|
42
|
+
throw new Error(`Invalid cell coordinates: row=${row}, col=${col}. Both must be >= 1`);
|
|
43
|
+
}
|
|
44
|
+
if (col >= MAX_COLUMNS) {
|
|
45
|
+
throw new Error(`Column ${col} exceeds maximum of ${MAX_COLUMNS - 1}`);
|
|
46
|
+
}
|
|
47
|
+
return (row * MAX_COLUMNS + col);
|
|
48
|
+
},
|
|
49
|
+
/**
|
|
50
|
+
* Decodes a CellId back into row and column coordinates.
|
|
51
|
+
* @param cellId - The CellId to decode
|
|
52
|
+
* @returns A tuple of [row, col] coordinates (1-based)
|
|
53
|
+
*/
|
|
54
|
+
decode(cellId) {
|
|
55
|
+
const id = cellId;
|
|
56
|
+
const row = Math.floor(id / MAX_COLUMNS);
|
|
57
|
+
const col = id % MAX_COLUMNS;
|
|
58
|
+
return [row, col];
|
|
59
|
+
},
|
|
60
|
+
/**
|
|
61
|
+
* Gets the row coordinate from a CellId.
|
|
62
|
+
* @param cellId - The CellId to extract row from
|
|
63
|
+
* @returns The row number (1-based)
|
|
64
|
+
*/
|
|
65
|
+
getRow(cellId) {
|
|
66
|
+
return Math.floor(cellId / MAX_COLUMNS);
|
|
67
|
+
},
|
|
68
|
+
/**
|
|
69
|
+
* Gets the column coordinate from a CellId.
|
|
70
|
+
* @param cellId - The CellId to extract column from
|
|
71
|
+
* @returns The column number (1-based)
|
|
72
|
+
*/
|
|
73
|
+
getCol(cellId) {
|
|
74
|
+
return cellId % MAX_COLUMNS;
|
|
75
|
+
},
|
|
76
|
+
/**
|
|
77
|
+
* Creates a string representation of a CellId for debugging/display purposes.
|
|
78
|
+
* @param cellId - The CellId to convert to string
|
|
79
|
+
* @returns A string in the format "row-col"
|
|
80
|
+
*/
|
|
81
|
+
toString(cellId) {
|
|
82
|
+
const [row, col] = this.decode(cellId);
|
|
83
|
+
return `${row}-${col}`;
|
|
84
|
+
},
|
|
85
|
+
/**
|
|
86
|
+
* Checks if two CellIds are equal.
|
|
87
|
+
* @param a - First CellId
|
|
88
|
+
* @param b - Second CellId
|
|
89
|
+
* @returns True if the CellIds represent the same cell
|
|
90
|
+
*/
|
|
91
|
+
equals(a, b) {
|
|
92
|
+
return a === b;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates an empty dashboard configuration with the specified dimensions.
|
|
98
|
+
* This is a convenience function for creating a basic dashboard without any cells.
|
|
99
|
+
*
|
|
100
|
+
* @param dashboardId - Unique identifier for the dashboard (managed by client)
|
|
101
|
+
* @param rows - Number of rows in the dashboard grid
|
|
102
|
+
* @param columns - Number of columns in the dashboard grid
|
|
103
|
+
* @param gutterSize - CSS size for the gutter between cells (default: '0.5em')
|
|
104
|
+
* @returns A DashboardDataDto configured with the specified dimensions and no cells
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* // Create an 8x16 dashboard with default gutter
|
|
108
|
+
* const dashboard = createEmptyDashboard('my-dashboard-1', 8, 16);
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* // Create a 5x10 dashboard with custom gutter
|
|
112
|
+
* const dashboard = createEmptyDashboard('my-dashboard-2', 5, 10, '0.5rem');
|
|
113
|
+
*/
|
|
114
|
+
function createEmptyDashboard(dashboardId, rows, columns, gutterSize = '0.5em') {
|
|
115
|
+
return {
|
|
116
|
+
version: '1.0.0',
|
|
117
|
+
dashboardId,
|
|
118
|
+
rows,
|
|
119
|
+
columns,
|
|
120
|
+
gutterSize,
|
|
121
|
+
cells: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Creates a default dashboard configuration with standard dimensions.
|
|
126
|
+
* This provides a reasonable starting point for most use cases.
|
|
127
|
+
*
|
|
128
|
+
* @param dashboardId - Unique identifier for the dashboard (managed by client)
|
|
129
|
+
* @returns A DashboardDataDto with 8 rows, 16 columns, and 0.5em gutter
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const dashboard = createDefaultDashboard('my-dashboard-id');
|
|
133
|
+
*/
|
|
134
|
+
function createDefaultDashboard(dashboardId) {
|
|
135
|
+
return createEmptyDashboard(dashboardId, 8, 16, '0.5em');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Default reserved space when none is specified
|
|
140
|
+
*/
|
|
141
|
+
const DEFAULT_RESERVED_SPACE = {
|
|
142
|
+
top: 0,
|
|
143
|
+
right: 0,
|
|
144
|
+
bottom: 0,
|
|
145
|
+
left: 0
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function createFactoryFromComponent(component) {
|
|
149
|
+
return {
|
|
150
|
+
...component.metadata,
|
|
151
|
+
createInstance(container, state) {
|
|
152
|
+
const ref = container.createComponent(component);
|
|
153
|
+
ref.instance.dashboardSetState?.(state);
|
|
154
|
+
return ref;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class UnknownWidgetComponent {
|
|
160
|
+
static metadata = {
|
|
161
|
+
widgetTypeid: '__internal/unknown-widget',
|
|
162
|
+
name: 'Unknown Widget',
|
|
163
|
+
description: 'Fallback widget for unknown widget types',
|
|
164
|
+
svgIcon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>',
|
|
165
|
+
};
|
|
166
|
+
state = signal({
|
|
167
|
+
originalWidgetTypeid: 'unknown',
|
|
168
|
+
});
|
|
169
|
+
tooltipText = computed(() => `${this.state().originalWidgetTypeid}`);
|
|
170
|
+
dashboardSetState(state) {
|
|
171
|
+
if (state && typeof state === 'object' && 'originalWidgetTypeid' in state) {
|
|
172
|
+
this.state.set(state);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
dashboardGetState() {
|
|
176
|
+
return this.state();
|
|
177
|
+
}
|
|
178
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: UnknownWidgetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
179
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.6", type: UnknownWidgetComponent, isStandalone: true, selector: "lib-unknown-widget", ngImport: i0, template: `
|
|
180
|
+
<div class="unknown-widget-container" [matTooltip]="tooltipText()">
|
|
181
|
+
<mat-icon class="unknown-widget-icon">error_outline</mat-icon>
|
|
182
|
+
</div>
|
|
183
|
+
`, isInline: true, styles: [".unknown-widget-container{display:flex;align-items:center;justify-content:center;width:100%;height:100%;background-color:var(--mat-sys-error);border-radius:8px;container-type:size}.unknown-widget-icon{color:var(--mat-sys-on-error);font-size:clamp(12px,75cqmin,68px);width:clamp(12px,75cqmin,68px);height:clamp(12px,75cqmin,68px)}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
184
|
+
}
|
|
185
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: UnknownWidgetComponent, decorators: [{
|
|
186
|
+
type: Component,
|
|
187
|
+
args: [{ selector: 'lib-unknown-widget', imports: [MatIconModule, MatTooltipModule], template: `
|
|
188
|
+
<div class="unknown-widget-container" [matTooltip]="tooltipText()">
|
|
189
|
+
<mat-icon class="unknown-widget-icon">error_outline</mat-icon>
|
|
190
|
+
</div>
|
|
191
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".unknown-widget-container{display:flex;align-items:center;justify-content:center;width:100%;height:100%;background-color:var(--mat-sys-error);border-radius:8px;container-type:size}.unknown-widget-icon{color:var(--mat-sys-on-error);font-size:clamp(12px,75cqmin,68px);width:clamp(12px,75cqmin,68px);height:clamp(12px,75cqmin,68px)}\n"] }]
|
|
192
|
+
}] });
|
|
193
|
+
|
|
194
|
+
// dashboard.service.ts
|
|
195
|
+
class DashboardService {
|
|
196
|
+
#widgetTypes = signal([]);
|
|
197
|
+
#widgetFactoryMap = new Map();
|
|
198
|
+
#unknownWidgetFactory = createFactoryFromComponent(UnknownWidgetComponent);
|
|
199
|
+
widgetTypes = this.#widgetTypes.asReadonly(); // make the widget list available as a readonly signal
|
|
200
|
+
registerWidgetType(widget) {
|
|
201
|
+
if (this.#widgetTypes().some((w) => w.metadata.widgetTypeid === widget.metadata.widgetTypeid)) {
|
|
202
|
+
throw new Error(`Widget type '${widget.metadata.widgetTypeid}' is already registered`);
|
|
203
|
+
}
|
|
204
|
+
this.#widgetFactoryMap.set(widget.metadata.widgetTypeid, createFactoryFromComponent(widget));
|
|
205
|
+
this.#widgetTypes.set([...this.#widgetTypes(), widget]);
|
|
206
|
+
}
|
|
207
|
+
getFactory(widgetTypeid) {
|
|
208
|
+
const factory = this.#widgetFactoryMap.get(widgetTypeid);
|
|
209
|
+
if (factory) {
|
|
210
|
+
return factory;
|
|
211
|
+
}
|
|
212
|
+
// Return fallback factory for unknown widget types
|
|
213
|
+
console.warn(`Unknown widget type: ${widgetTypeid}, using fallback error widget`);
|
|
214
|
+
// Create a custom factory that preserves the original widget type ID in state
|
|
215
|
+
return {
|
|
216
|
+
...this.#unknownWidgetFactory,
|
|
217
|
+
createInstance: (container, state) => {
|
|
218
|
+
const ref = this.#unknownWidgetFactory.createInstance(container, {
|
|
219
|
+
originalWidgetTypeid: widgetTypeid,
|
|
220
|
+
...(state || {}),
|
|
221
|
+
});
|
|
222
|
+
return ref;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
227
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardService, providedIn: 'root' });
|
|
228
|
+
}
|
|
229
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardService, decorators: [{
|
|
230
|
+
type: Injectable,
|
|
231
|
+
args: [{
|
|
232
|
+
providedIn: 'root',
|
|
233
|
+
}]
|
|
234
|
+
}] });
|
|
235
|
+
|
|
236
|
+
// Internal utility functions used by collision detection, resize logic, and tests
|
|
237
|
+
const GridQueryInternalUtils = {
|
|
238
|
+
isCellOccupied(cells, row, col, excludeId) {
|
|
239
|
+
return cells.some((cell) => {
|
|
240
|
+
// Skip checking against the cell being dragged
|
|
241
|
+
if (excludeId && CellIdUtils.equals(cell.cellId, excludeId))
|
|
242
|
+
return false;
|
|
243
|
+
const endRow = cell.row + cell.rowSpan - 1;
|
|
244
|
+
const endCol = cell.col + cell.colSpan - 1;
|
|
245
|
+
return (cell.row <= row && row <= endRow && cell.col <= col && col <= endCol);
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
isOutOfBounds(targetRow, targetCol, spanRow, spanCol, maxRows, maxColumns) {
|
|
249
|
+
const rowLimit = targetRow + spanRow - 1;
|
|
250
|
+
const colLimit = targetCol + spanCol - 1;
|
|
251
|
+
return rowLimit > maxRows || colLimit > maxColumns;
|
|
252
|
+
},
|
|
253
|
+
getCellAt(cells, row, col) {
|
|
254
|
+
return cells.find((cell) => cell.row === row && cell.col === col) ?? null;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function calculateCollisionInfo(dragData, hovered, cells, rows, columns) {
|
|
259
|
+
if (!dragData || !hovered) {
|
|
260
|
+
return {
|
|
261
|
+
hasCollisions: false,
|
|
262
|
+
invalidCells: [],
|
|
263
|
+
outOfBounds: false,
|
|
264
|
+
footprint: [],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const isCell = dragData.kind === 'cell';
|
|
268
|
+
const rowSpan = isCell ? dragData.content.rowSpan : 1;
|
|
269
|
+
const colSpan = isCell ? dragData.content.colSpan : 1;
|
|
270
|
+
// Check bounds
|
|
271
|
+
const outOfBounds = GridQueryInternalUtils.isOutOfBounds(hovered.row, hovered.col, rowSpan, colSpan, rows, columns);
|
|
272
|
+
// Generate footprint
|
|
273
|
+
const footprint = [];
|
|
274
|
+
for (let r = 0; r < rowSpan; r++) {
|
|
275
|
+
for (let c = 0; c < colSpan; c++) {
|
|
276
|
+
footprint.push({ row: hovered.row + r, col: hovered.col + c });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const excludeId = isCell ? dragData.content.cellId : undefined;
|
|
280
|
+
// Check for actual collisions with other widgets (not self)
|
|
281
|
+
const hasCollisions = footprint.some((pos) => GridQueryInternalUtils.isCellOccupied(cells, pos.row, pos.col, excludeId));
|
|
282
|
+
// Generate invalid cell IDs
|
|
283
|
+
const invalidCells = [];
|
|
284
|
+
if (hasCollisions || outOfBounds) {
|
|
285
|
+
for (const pos of footprint) {
|
|
286
|
+
invalidCells.push(CellIdUtils.create(pos.row, pos.col));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
hasCollisions,
|
|
291
|
+
invalidCells,
|
|
292
|
+
outOfBounds,
|
|
293
|
+
footprint,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function calculateHighlightedZones(dragData, hovered) {
|
|
297
|
+
if (!dragData || !hovered)
|
|
298
|
+
return [];
|
|
299
|
+
const zones = [];
|
|
300
|
+
const rowSpan = dragData.kind === 'cell' ? dragData.content.rowSpan : 1;
|
|
301
|
+
const colSpan = dragData.kind === 'cell' ? dragData.content.colSpan : 1;
|
|
302
|
+
for (let r = 0; r < rowSpan; r++) {
|
|
303
|
+
for (let c = 0; c < colSpan; c++) {
|
|
304
|
+
zones.push({ row: hovered.row + r, col: hovered.col + c });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return zones;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const initialGridConfigState = {
|
|
311
|
+
rows: 8,
|
|
312
|
+
columns: 16,
|
|
313
|
+
gutterSize: '0.5em',
|
|
314
|
+
isEditMode: false,
|
|
315
|
+
gridCellDimensions: { width: 0, height: 0 },
|
|
316
|
+
};
|
|
317
|
+
const withGridConfig = () => signalStoreFeature(withState(initialGridConfigState), withMethods((store) => ({
|
|
318
|
+
setGridConfig(config) {
|
|
319
|
+
patchState(store, config);
|
|
320
|
+
},
|
|
321
|
+
setGridCellDimensions(width, height) {
|
|
322
|
+
patchState(store, { gridCellDimensions: { width, height } });
|
|
323
|
+
},
|
|
324
|
+
toggleEditMode() {
|
|
325
|
+
patchState(store, { isEditMode: !store.isEditMode() });
|
|
326
|
+
},
|
|
327
|
+
setEditMode(isEditMode) {
|
|
328
|
+
patchState(store, { isEditMode });
|
|
329
|
+
},
|
|
330
|
+
})));
|
|
331
|
+
|
|
332
|
+
const initialWidgetManagementState = {
|
|
333
|
+
cellsById: {},
|
|
334
|
+
};
|
|
335
|
+
const withWidgetManagement = () => signalStoreFeature(withState(initialWidgetManagementState),
|
|
336
|
+
// Computed cells array - lazy evaluation, automatic memoization
|
|
337
|
+
withComputed((store) => ({
|
|
338
|
+
cells: computed(() => Object.values(store.cellsById())),
|
|
339
|
+
})), withMethods((store) => ({
|
|
340
|
+
addWidget(cell) {
|
|
341
|
+
const cellKey = CellIdUtils.toString(cell.cellId);
|
|
342
|
+
patchState(store, {
|
|
343
|
+
cellsById: { ...store.cellsById(), [cellKey]: cell },
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
removeWidget(cellId) {
|
|
347
|
+
const cellKey = CellIdUtils.toString(cellId);
|
|
348
|
+
const { [cellKey]: removed, ...remaining } = store.cellsById();
|
|
349
|
+
patchState(store, { cellsById: remaining });
|
|
350
|
+
},
|
|
351
|
+
updateWidgetPosition(cellId, row, col) {
|
|
352
|
+
const cellKey = CellIdUtils.toString(cellId);
|
|
353
|
+
const existingCell = store.cellsById()[cellKey];
|
|
354
|
+
if (existingCell) {
|
|
355
|
+
patchState(store, {
|
|
356
|
+
cellsById: {
|
|
357
|
+
...store.cellsById(),
|
|
358
|
+
[cellKey]: { ...existingCell, row, col },
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
createWidget(row, col, widgetFactory, widgetState) {
|
|
364
|
+
const cellId = CellIdUtils.create(row, col);
|
|
365
|
+
const cell = {
|
|
366
|
+
cellId,
|
|
367
|
+
row,
|
|
368
|
+
col,
|
|
369
|
+
rowSpan: 1,
|
|
370
|
+
colSpan: 1,
|
|
371
|
+
widgetFactory,
|
|
372
|
+
widgetState,
|
|
373
|
+
};
|
|
374
|
+
const cellKey = CellIdUtils.toString(cellId);
|
|
375
|
+
patchState(store, {
|
|
376
|
+
cellsById: { ...store.cellsById(), [cellKey]: cell },
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
updateCellSettings(id, flat) {
|
|
380
|
+
const cellKey = CellIdUtils.toString(id);
|
|
381
|
+
const existingCell = store.cellsById()[cellKey];
|
|
382
|
+
if (existingCell) {
|
|
383
|
+
patchState(store, {
|
|
384
|
+
cellsById: {
|
|
385
|
+
...store.cellsById(),
|
|
386
|
+
[cellKey]: { ...existingCell, flat },
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
updateWidgetSpan(id, rowSpan, colSpan) {
|
|
392
|
+
const cellKey = CellIdUtils.toString(id);
|
|
393
|
+
const existingCell = store.cellsById()[cellKey];
|
|
394
|
+
if (existingCell) {
|
|
395
|
+
patchState(store, {
|
|
396
|
+
cellsById: {
|
|
397
|
+
...store.cellsById(),
|
|
398
|
+
[cellKey]: { ...existingCell, rowSpan, colSpan },
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
updateWidgetState(cellId, widgetState) {
|
|
404
|
+
const cellKey = CellIdUtils.toString(cellId);
|
|
405
|
+
const existingCell = store.cellsById()[cellKey];
|
|
406
|
+
if (existingCell) {
|
|
407
|
+
patchState(store, {
|
|
408
|
+
cellsById: {
|
|
409
|
+
...store.cellsById(),
|
|
410
|
+
[cellKey]: { ...existingCell, widgetState },
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
updateAllWidgetStates(widgetStates) {
|
|
416
|
+
const updatedCellsById = { ...store.cellsById() };
|
|
417
|
+
for (const [cellIdString, newState] of widgetStates) {
|
|
418
|
+
const existingCell = updatedCellsById[cellIdString];
|
|
419
|
+
if (existingCell) {
|
|
420
|
+
updatedCellsById[cellIdString] = { ...existingCell, widgetState: newState };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
patchState(store, { cellsById: updatedCellsById });
|
|
424
|
+
},
|
|
425
|
+
clearDashboard() {
|
|
426
|
+
patchState(store, { cellsById: {} });
|
|
427
|
+
},
|
|
428
|
+
})));
|
|
429
|
+
|
|
430
|
+
const initialDragDropState = {
|
|
431
|
+
dragData: null,
|
|
432
|
+
hoveredDropZone: null,
|
|
433
|
+
};
|
|
434
|
+
const withDragDrop = () => signalStoreFeature(withState(initialDragDropState), withComputed((store) => ({
|
|
435
|
+
// Highlighted zones during drag
|
|
436
|
+
highlightedZones: computed(() => calculateHighlightedZones(store.dragData(), store.hoveredDropZone())),
|
|
437
|
+
})), withComputed((store) => ({
|
|
438
|
+
// Map for quick highlight lookup - reuse highlightedZones computation
|
|
439
|
+
highlightMap: computed(() => {
|
|
440
|
+
const zones = store.highlightedZones();
|
|
441
|
+
const map = new Set();
|
|
442
|
+
for (const z of zones) {
|
|
443
|
+
map.add(CellIdUtils.create(z.row, z.col));
|
|
444
|
+
}
|
|
445
|
+
return map;
|
|
446
|
+
}),
|
|
447
|
+
})), withMethods((store) => ({
|
|
448
|
+
syncDragState(dragData) {
|
|
449
|
+
patchState(store, { dragData });
|
|
450
|
+
},
|
|
451
|
+
startDrag(dragData) {
|
|
452
|
+
patchState(store, { dragData });
|
|
453
|
+
},
|
|
454
|
+
endDrag() {
|
|
455
|
+
patchState(store, {
|
|
456
|
+
dragData: null,
|
|
457
|
+
hoveredDropZone: null,
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
setHoveredDropZone(zone) {
|
|
461
|
+
patchState(store, { hoveredDropZone: zone });
|
|
462
|
+
},
|
|
463
|
+
})),
|
|
464
|
+
// Second withMethods block for drop handling that can access endDrag
|
|
465
|
+
withMethods((store) => ({
|
|
466
|
+
// Drop handling logic with dependency injection
|
|
467
|
+
_handleDrop(dragData, targetPosition, dependencies) {
|
|
468
|
+
// 1. Validate placement using existing collision detection
|
|
469
|
+
const collisionInfo = calculateCollisionInfo(dragData, targetPosition, dependencies.cells, dependencies.rows, dependencies.columns);
|
|
470
|
+
// 2. End drag state first
|
|
471
|
+
store.endDrag();
|
|
472
|
+
// 3. Early return if invalid placement
|
|
473
|
+
if (collisionInfo.hasCollisions || collisionInfo.outOfBounds) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
// 4. Handle widget creation from palette
|
|
477
|
+
if (dragData.kind === 'widget') {
|
|
478
|
+
const factory = dependencies.dashboardService.getFactory(dragData.content.widgetTypeid);
|
|
479
|
+
dependencies.createWidget(targetPosition.row, targetPosition.col, factory, undefined);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
// 5. Handle cell movement
|
|
483
|
+
if (dragData.kind === 'cell') {
|
|
484
|
+
dependencies.updateWidgetPosition(dragData.content.cellId, targetPosition.row, targetPosition.col);
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
488
|
+
},
|
|
489
|
+
})));
|
|
490
|
+
|
|
491
|
+
function getMaxColSpan(cellId, row, col, cells, columns) {
|
|
492
|
+
const currentCell = cells.find((c) => CellIdUtils.equals(c.cellId, cellId));
|
|
493
|
+
if (!currentCell)
|
|
494
|
+
return 1;
|
|
495
|
+
// Start from current position and check each column until we hit a boundary or collision
|
|
496
|
+
let maxSpan = 1;
|
|
497
|
+
for (let testCol = col + 1; testCol <= columns; testCol++) {
|
|
498
|
+
// Check if this column is free for all rows the widget spans
|
|
499
|
+
let columnIsFree = true;
|
|
500
|
+
for (let testRow = row; testRow < row + currentCell.rowSpan; testRow++) {
|
|
501
|
+
const occupied = cells.some((cell) => {
|
|
502
|
+
if (CellIdUtils.equals(cell.cellId, cellId))
|
|
503
|
+
return false;
|
|
504
|
+
const wStartCol = cell.col;
|
|
505
|
+
const wEndCol = cell.col + cell.colSpan - 1;
|
|
506
|
+
const wStartRow = cell.row;
|
|
507
|
+
const wEndRow = cell.row + cell.rowSpan - 1;
|
|
508
|
+
return (testCol >= wStartCol &&
|
|
509
|
+
testCol <= wEndCol &&
|
|
510
|
+
testRow >= wStartRow &&
|
|
511
|
+
testRow <= wEndRow);
|
|
512
|
+
});
|
|
513
|
+
if (occupied) {
|
|
514
|
+
columnIsFree = false;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (!columnIsFree) {
|
|
519
|
+
break; // Hit a collision, stop here
|
|
520
|
+
}
|
|
521
|
+
maxSpan = testCol - col + 1; // Update max span to include this column
|
|
522
|
+
}
|
|
523
|
+
return maxSpan;
|
|
524
|
+
}
|
|
525
|
+
function getMaxRowSpan(cellId, row, col, cells, rows) {
|
|
526
|
+
const currentCell = cells.find((c) => CellIdUtils.equals(c.cellId, cellId));
|
|
527
|
+
if (!currentCell)
|
|
528
|
+
return 1;
|
|
529
|
+
// Start from current position and check each row until we hit a boundary or collision
|
|
530
|
+
let maxSpan = 1;
|
|
531
|
+
for (let testRow = row + 1; testRow <= rows; testRow++) {
|
|
532
|
+
// Check if this row is free for all columns the widget spans
|
|
533
|
+
let rowIsFree = true;
|
|
534
|
+
for (let testCol = col; testCol < col + currentCell.colSpan; testCol++) {
|
|
535
|
+
const occupied = cells.some((cell) => {
|
|
536
|
+
if (CellIdUtils.equals(cell.cellId, cellId))
|
|
537
|
+
return false;
|
|
538
|
+
const wStartRow = cell.row;
|
|
539
|
+
const wEndRow = cell.row + cell.rowSpan - 1;
|
|
540
|
+
const wStartCol = cell.col;
|
|
541
|
+
const wEndCol = cell.col + cell.colSpan - 1;
|
|
542
|
+
return (testRow >= wStartRow &&
|
|
543
|
+
testRow <= wEndRow &&
|
|
544
|
+
testCol >= wStartCol &&
|
|
545
|
+
testCol <= wEndCol);
|
|
546
|
+
});
|
|
547
|
+
if (occupied) {
|
|
548
|
+
rowIsFree = false;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (!rowIsFree) {
|
|
553
|
+
break; // Hit a collision, stop here
|
|
554
|
+
}
|
|
555
|
+
maxSpan = testRow - row + 1; // Update max span to include this row
|
|
556
|
+
}
|
|
557
|
+
return maxSpan;
|
|
558
|
+
}
|
|
559
|
+
function calculateResizePreview(resizeData, direction, delta, cells, rows, columns) {
|
|
560
|
+
const cell = cells.find((c) => CellIdUtils.equals(c.cellId, resizeData.cellId));
|
|
561
|
+
if (!cell)
|
|
562
|
+
return null;
|
|
563
|
+
if (direction === 'horizontal') {
|
|
564
|
+
// Calculate the desired span based on the delta
|
|
565
|
+
const desiredColSpan = Math.max(1, resizeData.originalColSpan + delta);
|
|
566
|
+
// Get the maximum allowed span
|
|
567
|
+
const maxColSpan = getMaxColSpan(cell.cellId, cell.row, cell.col, cells, columns);
|
|
568
|
+
// Clamp to the maximum
|
|
569
|
+
const newColSpan = Math.min(desiredColSpan, maxColSpan);
|
|
570
|
+
return {
|
|
571
|
+
rowSpan: resizeData.previewRowSpan,
|
|
572
|
+
colSpan: newColSpan,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// Calculate the desired span based on the delta
|
|
577
|
+
const desiredRowSpan = Math.max(1, resizeData.originalRowSpan + delta);
|
|
578
|
+
// Get the maximum allowed span
|
|
579
|
+
const maxRowSpan = getMaxRowSpan(cell.cellId, cell.row, cell.col, cells, rows);
|
|
580
|
+
// Clamp to the maximum
|
|
581
|
+
const newRowSpan = Math.min(desiredRowSpan, maxRowSpan);
|
|
582
|
+
return {
|
|
583
|
+
rowSpan: newRowSpan,
|
|
584
|
+
colSpan: resizeData.previewColSpan,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const initialResizeState = {
|
|
590
|
+
resizeData: null,
|
|
591
|
+
};
|
|
592
|
+
// Utility functions for resize preview computations
|
|
593
|
+
const ResizePreviewUtils = {
|
|
594
|
+
computePreviewCells(resizeData, cells) {
|
|
595
|
+
if (!resizeData)
|
|
596
|
+
return [];
|
|
597
|
+
const cell = cells.find((cell) => CellIdUtils.equals(cell.cellId, resizeData.cellId));
|
|
598
|
+
if (!cell)
|
|
599
|
+
return [];
|
|
600
|
+
const previewCells = [];
|
|
601
|
+
for (let r = 0; r < resizeData.previewRowSpan; r++) {
|
|
602
|
+
for (let c = 0; c < resizeData.previewColSpan; c++) {
|
|
603
|
+
previewCells.push({
|
|
604
|
+
row: cell.row + r,
|
|
605
|
+
col: cell.col + c,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return previewCells;
|
|
610
|
+
},
|
|
611
|
+
computePreviewMap(previewCells) {
|
|
612
|
+
const map = new Set();
|
|
613
|
+
for (const cell of previewCells) {
|
|
614
|
+
map.add(CellIdUtils.create(cell.row, cell.col));
|
|
615
|
+
}
|
|
616
|
+
return map;
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
const withResize = () => signalStoreFeature(withState(initialResizeState), withMethods((store) => ({
|
|
620
|
+
// Resize methods that need cross-feature dependencies
|
|
621
|
+
_startResize(cellId, dependencies) {
|
|
622
|
+
const cell = dependencies.cells.find((c) => CellIdUtils.equals(c.cellId, cellId));
|
|
623
|
+
if (!cell)
|
|
624
|
+
return;
|
|
625
|
+
patchState(store, {
|
|
626
|
+
resizeData: {
|
|
627
|
+
cellId,
|
|
628
|
+
originalRowSpan: cell.rowSpan,
|
|
629
|
+
originalColSpan: cell.colSpan,
|
|
630
|
+
previewRowSpan: cell.rowSpan,
|
|
631
|
+
previewColSpan: cell.colSpan,
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
},
|
|
635
|
+
_updateResizePreview(direction, delta, dependencies) {
|
|
636
|
+
const resizeData = store.resizeData();
|
|
637
|
+
if (!resizeData)
|
|
638
|
+
return;
|
|
639
|
+
const newSpans = calculateResizePreview(resizeData, direction, delta, dependencies.cells, dependencies.rows, dependencies.columns);
|
|
640
|
+
if (newSpans) {
|
|
641
|
+
patchState(store, {
|
|
642
|
+
resizeData: {
|
|
643
|
+
...resizeData,
|
|
644
|
+
previewRowSpan: newSpans.rowSpan,
|
|
645
|
+
previewColSpan: newSpans.colSpan,
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
_endResize(apply, dependencies) {
|
|
651
|
+
const resizeData = store.resizeData();
|
|
652
|
+
if (!resizeData)
|
|
653
|
+
return;
|
|
654
|
+
if (apply &&
|
|
655
|
+
(resizeData.previewRowSpan !== resizeData.originalRowSpan ||
|
|
656
|
+
resizeData.previewColSpan !== resizeData.originalColSpan)) {
|
|
657
|
+
dependencies.updateWidgetSpan(resizeData.cellId, resizeData.previewRowSpan, resizeData.previewColSpan);
|
|
658
|
+
}
|
|
659
|
+
patchState(store, { resizeData: null });
|
|
660
|
+
},
|
|
661
|
+
})));
|
|
662
|
+
|
|
663
|
+
const initialState = {
|
|
664
|
+
dashboardId: '',
|
|
665
|
+
};
|
|
666
|
+
const DashboardStore = signalStore(withState(initialState), withProps(() => ({
|
|
667
|
+
dashboardService: inject(DashboardService),
|
|
668
|
+
})), withGridConfig(), withWidgetManagement(), withResize(), withDragDrop(),
|
|
669
|
+
// Cross-feature computed properties (need access to multiple features)
|
|
670
|
+
withComputed((store) => ({
|
|
671
|
+
// Invalid zones (collision detection)
|
|
672
|
+
invalidHighlightMap: computed(() => {
|
|
673
|
+
const collisionInfo = calculateCollisionInfo(store.dragData(), store.hoveredDropZone(), store.cells(), store.rows(), store.columns());
|
|
674
|
+
return new Set(collisionInfo.invalidCells);
|
|
675
|
+
}),
|
|
676
|
+
// Check if placement would be valid (for drop validation)
|
|
677
|
+
isValidPlacement: computed(() => {
|
|
678
|
+
const collisionInfo = calculateCollisionInfo(store.dragData(), store.hoveredDropZone(), store.cells(), store.rows(), store.columns());
|
|
679
|
+
return !collisionInfo.hasCollisions && !collisionInfo.outOfBounds;
|
|
680
|
+
}),
|
|
681
|
+
})),
|
|
682
|
+
// Cross-feature methods (need access to multiple features)
|
|
683
|
+
withMethods((store) => ({
|
|
684
|
+
// DROP HANDLING (delegate to drag-drop feature with dependency injection)
|
|
685
|
+
handleDrop(dragData, targetPosition) {
|
|
686
|
+
return store._handleDrop(dragData, targetPosition, {
|
|
687
|
+
cells: store.cells(),
|
|
688
|
+
rows: store.rows(),
|
|
689
|
+
columns: store.columns(),
|
|
690
|
+
dashboardService: store.dashboardService,
|
|
691
|
+
createWidget: store.createWidget,
|
|
692
|
+
updateWidgetPosition: store.updateWidgetPosition,
|
|
693
|
+
});
|
|
694
|
+
},
|
|
695
|
+
// RESIZE METHODS (delegate to resize feature with dependency injection)
|
|
696
|
+
startResize(cellId) {
|
|
697
|
+
store._startResize(cellId, {
|
|
698
|
+
cells: store.cells(),
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
updateResizePreview(direction, delta) {
|
|
702
|
+
store._updateResizePreview(direction, delta, {
|
|
703
|
+
cells: store.cells(),
|
|
704
|
+
rows: store.rows(),
|
|
705
|
+
columns: store.columns(),
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
endResize(apply) {
|
|
709
|
+
store._endResize(apply, {
|
|
710
|
+
updateWidgetSpan: store.updateWidgetSpan,
|
|
711
|
+
});
|
|
712
|
+
},
|
|
713
|
+
// EXPORT/IMPORT METHODS (need access to multiple features)
|
|
714
|
+
exportDashboard(getCurrentWidgetStates) {
|
|
715
|
+
// Get live widget states if callback provided, otherwise use stored states
|
|
716
|
+
const liveWidgetStates = getCurrentWidgetStates?.() || new Map();
|
|
717
|
+
return {
|
|
718
|
+
version: '1.0.0',
|
|
719
|
+
dashboardId: store.dashboardId(),
|
|
720
|
+
rows: store.rows(),
|
|
721
|
+
columns: store.columns(),
|
|
722
|
+
gutterSize: store.gutterSize(),
|
|
723
|
+
cells: store.cells()
|
|
724
|
+
.filter((cell) => cell.widgetFactory.widgetTypeid !== '__internal/unknown-widget')
|
|
725
|
+
.map((cell) => {
|
|
726
|
+
const cellIdString = CellIdUtils.toString(cell.cellId);
|
|
727
|
+
const currentState = liveWidgetStates.get(cellIdString);
|
|
728
|
+
return {
|
|
729
|
+
row: cell.row,
|
|
730
|
+
col: cell.col,
|
|
731
|
+
rowSpan: cell.rowSpan,
|
|
732
|
+
colSpan: cell.colSpan,
|
|
733
|
+
flat: cell.flat,
|
|
734
|
+
widgetTypeid: cell.widgetFactory.widgetTypeid,
|
|
735
|
+
widgetState: currentState !== undefined ? currentState : cell.widgetState,
|
|
736
|
+
};
|
|
737
|
+
}),
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
loadDashboard(data) {
|
|
741
|
+
// Import full dashboard data with grid configuration
|
|
742
|
+
const cellsById = {};
|
|
743
|
+
data.cells.forEach((cellData) => {
|
|
744
|
+
const factory = store.dashboardService.getFactory(cellData.widgetTypeid);
|
|
745
|
+
const cell = {
|
|
746
|
+
cellId: CellIdUtils.create(cellData.row, cellData.col),
|
|
747
|
+
row: cellData.row,
|
|
748
|
+
col: cellData.col,
|
|
749
|
+
rowSpan: cellData.rowSpan,
|
|
750
|
+
colSpan: cellData.colSpan,
|
|
751
|
+
flat: cellData.flat,
|
|
752
|
+
widgetFactory: factory,
|
|
753
|
+
widgetState: cellData.widgetState,
|
|
754
|
+
};
|
|
755
|
+
cellsById[CellIdUtils.toString(cell.cellId)] = cell;
|
|
756
|
+
});
|
|
757
|
+
patchState(store, {
|
|
758
|
+
dashboardId: data.dashboardId,
|
|
759
|
+
rows: data.rows,
|
|
760
|
+
columns: data.columns,
|
|
761
|
+
gutterSize: data.gutterSize,
|
|
762
|
+
cellsById,
|
|
763
|
+
});
|
|
764
|
+
},
|
|
765
|
+
// INITIALIZATION METHODS
|
|
766
|
+
initializeFromDto(dashboardData) {
|
|
767
|
+
// Inline the loadDashboard logic since it's defined later in the same methods block
|
|
768
|
+
const cellsById = {};
|
|
769
|
+
dashboardData.cells.forEach((cellData) => {
|
|
770
|
+
const factory = store.dashboardService.getFactory(cellData.widgetTypeid);
|
|
771
|
+
const cell = {
|
|
772
|
+
cellId: CellIdUtils.create(cellData.row, cellData.col),
|
|
773
|
+
row: cellData.row,
|
|
774
|
+
col: cellData.col,
|
|
775
|
+
rowSpan: cellData.rowSpan,
|
|
776
|
+
colSpan: cellData.colSpan,
|
|
777
|
+
flat: cellData.flat,
|
|
778
|
+
widgetFactory: factory,
|
|
779
|
+
widgetState: cellData.widgetState,
|
|
780
|
+
};
|
|
781
|
+
cellsById[CellIdUtils.toString(cell.cellId)] = cell;
|
|
782
|
+
});
|
|
783
|
+
patchState(store, {
|
|
784
|
+
dashboardId: dashboardData.dashboardId,
|
|
785
|
+
rows: dashboardData.rows,
|
|
786
|
+
columns: dashboardData.columns,
|
|
787
|
+
gutterSize: dashboardData.gutterSize,
|
|
788
|
+
cellsById,
|
|
789
|
+
});
|
|
790
|
+
},
|
|
791
|
+
})),
|
|
792
|
+
// Cross-feature computed properties that depend on resize + widget data (using utility functions)
|
|
793
|
+
withComputed((store) => ({
|
|
794
|
+
// Compute preview cells during resize using utility function
|
|
795
|
+
resizePreviewCells: computed(() => {
|
|
796
|
+
return ResizePreviewUtils.computePreviewCells(store.resizeData(), store.cells());
|
|
797
|
+
}),
|
|
798
|
+
})),
|
|
799
|
+
// Second computed block that depends on the first
|
|
800
|
+
withComputed((store) => ({
|
|
801
|
+
// Map for resize preview highlighting using utility function
|
|
802
|
+
resizePreviewMap: computed(() => {
|
|
803
|
+
return ResizePreviewUtils.computePreviewMap(store.resizePreviewCells());
|
|
804
|
+
}),
|
|
805
|
+
})));
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Abstract provider for cell settings dialogs.
|
|
809
|
+
* Implement this to provide custom dialog solutions.
|
|
810
|
+
*/
|
|
811
|
+
class CellSettingsDialogProvider {
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
class CellSettingsDialogComponent {
|
|
815
|
+
data;
|
|
816
|
+
dialogRef;
|
|
817
|
+
selectedMode;
|
|
818
|
+
currentMode;
|
|
819
|
+
constructor(data, dialogRef) {
|
|
820
|
+
this.data = data;
|
|
821
|
+
this.dialogRef = dialogRef;
|
|
822
|
+
this.currentMode = data.flat ? 'flat' : 'normal';
|
|
823
|
+
this.selectedMode = this.currentMode;
|
|
824
|
+
}
|
|
825
|
+
onCancel() {
|
|
826
|
+
this.dialogRef.close();
|
|
827
|
+
}
|
|
828
|
+
save() {
|
|
829
|
+
const newData = {
|
|
830
|
+
...this.data,
|
|
831
|
+
flat: this.selectedMode === 'flat'
|
|
832
|
+
};
|
|
833
|
+
this.dialogRef.close(newData);
|
|
834
|
+
}
|
|
835
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellSettingsDialogComponent, deps: [{ token: MAT_DIALOG_DATA }, { token: i1$1.MatDialogRef }], target: i0.ɵɵFactoryTarget.Component });
|
|
836
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.6", type: CellSettingsDialogComponent, isStandalone: true, selector: "lib-cell-settings-dialog", ngImport: i0, template: `
|
|
837
|
+
<h2 mat-dialog-title>Cell Display Settings</h2>
|
|
838
|
+
<mat-dialog-content>
|
|
839
|
+
<p class="cell-info">Cell ID: <strong>{{ data.id }}</strong></p>
|
|
840
|
+
|
|
841
|
+
<div class="radio-group">
|
|
842
|
+
<mat-radio-group [(ngModel)]="selectedMode" name="displayMode">
|
|
843
|
+
<mat-radio-button value="normal">
|
|
844
|
+
<div class="radio-option">
|
|
845
|
+
<div class="option-title">Normal</div>
|
|
846
|
+
<div class="option-description">Standard cell display with full content visibility</div>
|
|
847
|
+
</div>
|
|
848
|
+
</mat-radio-button>
|
|
849
|
+
|
|
850
|
+
<mat-radio-button value="flat">
|
|
851
|
+
<div class="radio-option">
|
|
852
|
+
<div class="option-title">Flat</div>
|
|
853
|
+
<div class="option-description">Simplified display with reduced visual emphasis</div>
|
|
854
|
+
</div>
|
|
855
|
+
</mat-radio-button>
|
|
856
|
+
</mat-radio-group>
|
|
857
|
+
</div>
|
|
858
|
+
</mat-dialog-content>
|
|
859
|
+
<mat-dialog-actions align="end">
|
|
860
|
+
<button mat-button (click)="onCancel()">Cancel</button>
|
|
861
|
+
<button
|
|
862
|
+
mat-flat-button
|
|
863
|
+
(click)="save()"
|
|
864
|
+
[disabled]="selectedMode === currentMode">
|
|
865
|
+
Apply
|
|
866
|
+
</button>
|
|
867
|
+
</mat-dialog-actions>
|
|
868
|
+
`, isInline: true, styles: ["mat-dialog-content{display:block;overflow-y:auto;overflow-x:hidden;padding-top:.5rem}.cell-info{margin:0 0 1.5rem;padding-bottom:1rem}.radio-group{width:100%}mat-radio-group{display:block}mat-radio-button{width:100%;display:block;margin-bottom:1rem}mat-radio-button:last-child{margin-bottom:0}.radio-option{margin-left:.75rem;padding:.25rem 0}.option-title{display:block;margin-bottom:.25rem}.option-description{display:block}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i1$1.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i1$1.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i1$1.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatRadioModule }, { kind: "directive", type: i4.MatRadioGroup, selector: "mat-radio-group", inputs: ["color", "name", "labelPosition", "value", "selected", "disabled", "required", "disabledInteractive"], outputs: ["change"], exportAs: ["matRadioGroup"] }, { kind: "component", type: i4.MatRadioButton, selector: "mat-radio-button", inputs: ["id", "name", "aria-label", "aria-labelledby", "aria-describedby", "disableRipple", "tabIndex", "checked", "value", "labelPosition", "disabled", "required", "color", "disabledInteractive"], outputs: ["change"], exportAs: ["matRadioButton"] }] });
|
|
869
|
+
}
|
|
870
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellSettingsDialogComponent, decorators: [{
|
|
871
|
+
type: Component,
|
|
872
|
+
args: [{ selector: 'lib-cell-settings-dialog', standalone: true, imports: [
|
|
873
|
+
CommonModule,
|
|
874
|
+
FormsModule,
|
|
875
|
+
MatDialogModule,
|
|
876
|
+
MatButtonModule,
|
|
877
|
+
MatRadioModule,
|
|
878
|
+
], template: `
|
|
879
|
+
<h2 mat-dialog-title>Cell Display Settings</h2>
|
|
880
|
+
<mat-dialog-content>
|
|
881
|
+
<p class="cell-info">Cell ID: <strong>{{ data.id }}</strong></p>
|
|
882
|
+
|
|
883
|
+
<div class="radio-group">
|
|
884
|
+
<mat-radio-group [(ngModel)]="selectedMode" name="displayMode">
|
|
885
|
+
<mat-radio-button value="normal">
|
|
886
|
+
<div class="radio-option">
|
|
887
|
+
<div class="option-title">Normal</div>
|
|
888
|
+
<div class="option-description">Standard cell display with full content visibility</div>
|
|
889
|
+
</div>
|
|
890
|
+
</mat-radio-button>
|
|
891
|
+
|
|
892
|
+
<mat-radio-button value="flat">
|
|
893
|
+
<div class="radio-option">
|
|
894
|
+
<div class="option-title">Flat</div>
|
|
895
|
+
<div class="option-description">Simplified display with reduced visual emphasis</div>
|
|
896
|
+
</div>
|
|
897
|
+
</mat-radio-button>
|
|
898
|
+
</mat-radio-group>
|
|
899
|
+
</div>
|
|
900
|
+
</mat-dialog-content>
|
|
901
|
+
<mat-dialog-actions align="end">
|
|
902
|
+
<button mat-button (click)="onCancel()">Cancel</button>
|
|
903
|
+
<button
|
|
904
|
+
mat-flat-button
|
|
905
|
+
(click)="save()"
|
|
906
|
+
[disabled]="selectedMode === currentMode">
|
|
907
|
+
Apply
|
|
908
|
+
</button>
|
|
909
|
+
</mat-dialog-actions>
|
|
910
|
+
`, styles: ["mat-dialog-content{display:block;overflow-y:auto;overflow-x:hidden;padding-top:.5rem}.cell-info{margin:0 0 1.5rem;padding-bottom:1rem}.radio-group{width:100%}mat-radio-group{display:block}mat-radio-button{width:100%;display:block;margin-bottom:1rem}mat-radio-button:last-child{margin-bottom:0}.radio-option{margin-left:.75rem;padding:.25rem 0}.option-title{display:block;margin-bottom:.25rem}.option-description{display:block}\n"] }]
|
|
911
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
912
|
+
type: Inject,
|
|
913
|
+
args: [MAT_DIALOG_DATA]
|
|
914
|
+
}] }, { type: i1$1.MatDialogRef }] });
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Default cell dialog provider that uses Material Design dialogs.
|
|
918
|
+
* Provides a modern, accessible dialog experience for cell settings.
|
|
919
|
+
*/
|
|
920
|
+
class DefaultCellSettingsDialogProvider extends CellSettingsDialogProvider {
|
|
921
|
+
dialog = inject(MatDialog);
|
|
922
|
+
async openCellSettings(data) {
|
|
923
|
+
const dialogRef = this.dialog.open(CellSettingsDialogComponent, {
|
|
924
|
+
data,
|
|
925
|
+
width: '400px',
|
|
926
|
+
maxWidth: '90vw',
|
|
927
|
+
disableClose: false,
|
|
928
|
+
autoFocus: false,
|
|
929
|
+
});
|
|
930
|
+
const result = await firstValueFrom(dialogRef.afterClosed());
|
|
931
|
+
return result;
|
|
932
|
+
}
|
|
933
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DefaultCellSettingsDialogProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
934
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DefaultCellSettingsDialogProvider, providedIn: 'root' });
|
|
935
|
+
}
|
|
936
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DefaultCellSettingsDialogProvider, decorators: [{
|
|
937
|
+
type: Injectable,
|
|
938
|
+
args: [{
|
|
939
|
+
providedIn: 'root',
|
|
940
|
+
}]
|
|
941
|
+
}] });
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Injection token for the cell dialog provider.
|
|
945
|
+
* Use this to provide your custom dialog implementation.
|
|
946
|
+
*
|
|
947
|
+
* @example
|
|
948
|
+
* ```typescript
|
|
949
|
+
* providers: [
|
|
950
|
+
* { provide: CELL_SETTINGS_DIALOG_PROVIDER, useClass: MyCellSettingsDialogProvider }
|
|
951
|
+
* ]
|
|
952
|
+
* ```
|
|
953
|
+
*/
|
|
954
|
+
const CELL_SETTINGS_DIALOG_PROVIDER = new InjectionToken('CellSettingsDialogProvider', {
|
|
955
|
+
providedIn: 'root',
|
|
956
|
+
factory: () => new DefaultCellSettingsDialogProvider(),
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
class CellContextMenuService {
|
|
960
|
+
#activeMenu = signal(null);
|
|
961
|
+
activeMenu = this.#activeMenu.asReadonly();
|
|
962
|
+
show(x, y, items) {
|
|
963
|
+
this.#activeMenu.set({ x, y, items });
|
|
964
|
+
}
|
|
965
|
+
hide() {
|
|
966
|
+
this.#activeMenu.set(null);
|
|
967
|
+
}
|
|
968
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellContextMenuService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
969
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellContextMenuService });
|
|
970
|
+
}
|
|
971
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellContextMenuService, decorators: [{
|
|
972
|
+
type: Injectable
|
|
973
|
+
}] });
|
|
974
|
+
|
|
975
|
+
// cell.component.ts
|
|
976
|
+
class CellComponent {
|
|
977
|
+
id = input.required();
|
|
978
|
+
widgetFactory = input(undefined);
|
|
979
|
+
widgetState = input(undefined);
|
|
980
|
+
isEditMode = input(false);
|
|
981
|
+
flat = input(undefined);
|
|
982
|
+
row = model.required();
|
|
983
|
+
column = model.required();
|
|
984
|
+
rowSpan = input(1);
|
|
985
|
+
colSpan = input(1);
|
|
986
|
+
draggable = input(false);
|
|
987
|
+
dragStart = output();
|
|
988
|
+
dragEnd = output();
|
|
989
|
+
edit = output();
|
|
990
|
+
delete = output();
|
|
991
|
+
settings = output();
|
|
992
|
+
resizeStart = output();
|
|
993
|
+
resizeMove = output();
|
|
994
|
+
resizeEnd = output();
|
|
995
|
+
container = viewChild.required('container', { read: ViewContainerRef });
|
|
996
|
+
#store = inject(DashboardStore);
|
|
997
|
+
#dialogProvider = inject(CELL_SETTINGS_DIALOG_PROVIDER);
|
|
998
|
+
#destroyRef = inject(DestroyRef);
|
|
999
|
+
#renderer = inject(Renderer2);
|
|
1000
|
+
#contextMenuService = inject(CellContextMenuService, {
|
|
1001
|
+
optional: true,
|
|
1002
|
+
});
|
|
1003
|
+
#elementRef = inject(ElementRef);
|
|
1004
|
+
#widgetRef;
|
|
1005
|
+
// Document event listeners cleanup function
|
|
1006
|
+
// Performance: Only created when actively resizing, not for every cell
|
|
1007
|
+
#documentListeners;
|
|
1008
|
+
isDragging = signal(false);
|
|
1009
|
+
gridRowStyle = computed(() => `${this.row()} / span ${this.rowSpan()}`);
|
|
1010
|
+
gridColumnStyle = computed(() => `${this.column()} / span ${this.colSpan()}`);
|
|
1011
|
+
isResizing = computed(() => {
|
|
1012
|
+
const resizeData = this.#store.resizeData();
|
|
1013
|
+
return resizeData
|
|
1014
|
+
? CellIdUtils.equals(resizeData.cellId, this.id())
|
|
1015
|
+
: false;
|
|
1016
|
+
});
|
|
1017
|
+
isDragActive = computed(() => !!this.#store.dragData());
|
|
1018
|
+
resizeData = this.#store.resizeData;
|
|
1019
|
+
gridCellDimensions = this.#store.gridCellDimensions;
|
|
1020
|
+
resizeDirection = signal(null);
|
|
1021
|
+
resizeStartPos = signal({ x: 0, y: 0 });
|
|
1022
|
+
constructor() {
|
|
1023
|
+
// widget creation - triggers when factory or state changes
|
|
1024
|
+
effect(() => {
|
|
1025
|
+
const factory = this.widgetFactory();
|
|
1026
|
+
const state = this.widgetState();
|
|
1027
|
+
const container = this.container();
|
|
1028
|
+
if (factory && container) {
|
|
1029
|
+
// Clean up previous widget
|
|
1030
|
+
this.#widgetRef?.destroy();
|
|
1031
|
+
// Create new widget
|
|
1032
|
+
container.clear();
|
|
1033
|
+
try {
|
|
1034
|
+
this.#widgetRef = factory.createInstance(container, state);
|
|
1035
|
+
}
|
|
1036
|
+
catch (error) {
|
|
1037
|
+
console.error('Failed to create widget:', error);
|
|
1038
|
+
this.#widgetRef = undefined;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
// Auto cleanup on destroy
|
|
1043
|
+
this.#destroyRef.onDestroy(() => {
|
|
1044
|
+
this.#widgetRef?.destroy();
|
|
1045
|
+
this.#widgetRef = undefined;
|
|
1046
|
+
// Clean up any active document listeners
|
|
1047
|
+
this.#cleanupDocumentListeners();
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Setup document-level event listeners for resize operations
|
|
1052
|
+
* Performance: Only creates listeners when actively resizing (not for every cell)
|
|
1053
|
+
* Angular-idiomatic: Uses Renderer2 for dynamic listener management
|
|
1054
|
+
*/
|
|
1055
|
+
setupDocumentListeners() {
|
|
1056
|
+
// Clean up any existing listeners first
|
|
1057
|
+
this.#cleanupDocumentListeners();
|
|
1058
|
+
// Create document listeners with proper cleanup functions
|
|
1059
|
+
const unlistenMove = this.#renderer.listen('document', 'mousemove', this.handleResizeMove.bind(this));
|
|
1060
|
+
const unlistenUp = this.#renderer.listen('document', 'mouseup', this.handleResizeEnd.bind(this));
|
|
1061
|
+
// Store cleanup function for later use
|
|
1062
|
+
this.#documentListeners = () => {
|
|
1063
|
+
unlistenMove();
|
|
1064
|
+
unlistenUp();
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Clean up document-level event listeners
|
|
1069
|
+
* Called on resize end and component destruction
|
|
1070
|
+
*/
|
|
1071
|
+
#cleanupDocumentListeners() {
|
|
1072
|
+
if (this.#documentListeners) {
|
|
1073
|
+
this.#documentListeners();
|
|
1074
|
+
this.#documentListeners = undefined;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
setPosition(row, column) {
|
|
1078
|
+
this.row.set(row);
|
|
1079
|
+
this.column.set(column);
|
|
1080
|
+
}
|
|
1081
|
+
onDragStart(event) {
|
|
1082
|
+
if (!event.dataTransfer)
|
|
1083
|
+
return;
|
|
1084
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
1085
|
+
const cell = {
|
|
1086
|
+
cellId: this.id(),
|
|
1087
|
+
row: this.row(),
|
|
1088
|
+
col: this.column(),
|
|
1089
|
+
rowSpan: this.rowSpan(),
|
|
1090
|
+
colSpan: this.colSpan(),
|
|
1091
|
+
};
|
|
1092
|
+
const content = { kind: 'cell', content: cell };
|
|
1093
|
+
this.dragStart.emit(content);
|
|
1094
|
+
this.isDragging.set(true);
|
|
1095
|
+
}
|
|
1096
|
+
onDragEnd( /*_: DragEvent*/) {
|
|
1097
|
+
this.isDragging.set(false);
|
|
1098
|
+
this.dragEnd.emit();
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Handle context menu events (called from template)
|
|
1102
|
+
* Performance: Element-specific event binding, not document-level
|
|
1103
|
+
* Angular-idiomatic: Template event binding instead of fromEvent
|
|
1104
|
+
*/
|
|
1105
|
+
onContextMenu(event) {
|
|
1106
|
+
if (!this.isEditMode() || !this.#contextMenuService)
|
|
1107
|
+
return;
|
|
1108
|
+
event.preventDefault();
|
|
1109
|
+
event.stopPropagation();
|
|
1110
|
+
const items = [
|
|
1111
|
+
{
|
|
1112
|
+
label: 'Edit Widget',
|
|
1113
|
+
icon: 'edit',
|
|
1114
|
+
action: () => this.onEdit(),
|
|
1115
|
+
disabled: !this.canEdit(),
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
label: 'Settings',
|
|
1119
|
+
icon: 'settings',
|
|
1120
|
+
action: () => this.onSettings(),
|
|
1121
|
+
},
|
|
1122
|
+
{ divider: true },
|
|
1123
|
+
{
|
|
1124
|
+
label: 'Delete',
|
|
1125
|
+
icon: 'delete',
|
|
1126
|
+
action: () => this.onDelete(),
|
|
1127
|
+
},
|
|
1128
|
+
];
|
|
1129
|
+
// Position menu at exact mouse coordinates
|
|
1130
|
+
this.#contextMenuService.show(event.clientX, event.clientY, items);
|
|
1131
|
+
}
|
|
1132
|
+
canEdit() {
|
|
1133
|
+
if (this.#widgetRef?.instance?.dashboardEditState) {
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
onEdit() {
|
|
1139
|
+
this.edit.emit(this.id());
|
|
1140
|
+
// Call the widget's edit dialog method if it exists
|
|
1141
|
+
if (this.#widgetRef?.instance?.dashboardEditState) {
|
|
1142
|
+
this.#widgetRef.instance.dashboardEditState();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
onDelete() {
|
|
1146
|
+
this.delete.emit(this.id());
|
|
1147
|
+
}
|
|
1148
|
+
async onSettings() {
|
|
1149
|
+
const currentSettings = {
|
|
1150
|
+
id: CellIdUtils.toString(this.id()),
|
|
1151
|
+
flat: this.flat(),
|
|
1152
|
+
};
|
|
1153
|
+
try {
|
|
1154
|
+
const result = await this.#dialogProvider.openCellSettings(currentSettings);
|
|
1155
|
+
if (result) {
|
|
1156
|
+
this.settings.emit({
|
|
1157
|
+
id: this.id(),
|
|
1158
|
+
flat: result.flat ?? false,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
console.error('Error opening cell settings dialog:', error);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Start resize operation and setup document listeners
|
|
1168
|
+
* Performance: Only THIS cell creates document listeners when actively resizing
|
|
1169
|
+
* RxJS-free: Uses Renderer2 for dynamic listener management
|
|
1170
|
+
*/
|
|
1171
|
+
onResizeStart(event, direction) {
|
|
1172
|
+
event.preventDefault();
|
|
1173
|
+
event.stopPropagation();
|
|
1174
|
+
this.resizeDirection.set(direction);
|
|
1175
|
+
this.resizeStartPos.set({ x: event.clientX, y: event.clientY });
|
|
1176
|
+
this.resizeStart.emit({ id: this.id(), direction });
|
|
1177
|
+
// Setup document listeners only when actively resizing
|
|
1178
|
+
this.setupDocumentListeners();
|
|
1179
|
+
const cursorClass = direction === 'horizontal' ? 'cursor-col-resize' : 'cursor-row-resize';
|
|
1180
|
+
this.#renderer.addClass(document.body, cursorClass);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Handle resize move events (called from document listener)
|
|
1184
|
+
* Performance: Only called for the actively resizing cell
|
|
1185
|
+
* Bound method: Maintains component context without arrow functions
|
|
1186
|
+
*/
|
|
1187
|
+
handleResizeMove(event) {
|
|
1188
|
+
const direction = this.resizeDirection();
|
|
1189
|
+
if (!direction)
|
|
1190
|
+
return;
|
|
1191
|
+
const startPos = this.resizeStartPos();
|
|
1192
|
+
const cellSize = this.gridCellDimensions();
|
|
1193
|
+
if (direction === 'horizontal') {
|
|
1194
|
+
const deltaX = event.clientX - startPos.x;
|
|
1195
|
+
const deltaSpan = Math.round(deltaX / cellSize.width);
|
|
1196
|
+
this.resizeMove.emit({ id: this.id(), direction, delta: deltaSpan });
|
|
1197
|
+
}
|
|
1198
|
+
else {
|
|
1199
|
+
const deltaY = event.clientY - startPos.y;
|
|
1200
|
+
const deltaSpan = Math.round(deltaY / cellSize.height);
|
|
1201
|
+
this.resizeMove.emit({ id: this.id(), direction, delta: deltaSpan });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Handle resize end events (called from document listener)
|
|
1206
|
+
* Performance: Cleans up document listeners immediately after resize
|
|
1207
|
+
* State cleanup: Resets resize direction to stop further event processing
|
|
1208
|
+
*/
|
|
1209
|
+
handleResizeEnd() {
|
|
1210
|
+
this.#renderer.removeClass(document.body, 'cursor-col-resize');
|
|
1211
|
+
this.#renderer.removeClass(document.body, 'cursor-row-resize');
|
|
1212
|
+
// Clean up document listeners immediately
|
|
1213
|
+
this.#cleanupDocumentListeners();
|
|
1214
|
+
this.resizeEnd.emit({ id: this.id(), apply: true });
|
|
1215
|
+
this.resizeDirection.set(null);
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Get the current widget state by calling dashboardGetState() on the widget instance.
|
|
1219
|
+
* Used during dashboard export to get live widget state instead of stale stored state.
|
|
1220
|
+
*/
|
|
1221
|
+
getCurrentWidgetState() {
|
|
1222
|
+
if (!this.#widgetRef?.instance) {
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
// Call dashboardGetState() if the widget implements it
|
|
1226
|
+
if (typeof this.#widgetRef.instance.dashboardGetState === 'function') {
|
|
1227
|
+
return this.#widgetRef.instance.dashboardGetState();
|
|
1228
|
+
}
|
|
1229
|
+
// Fall back to stored state if widget doesn't implement dashboardGetState
|
|
1230
|
+
return this.widgetState();
|
|
1231
|
+
}
|
|
1232
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1233
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: CellComponent, isStandalone: true, selector: "lib-cell", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: true, transformFunction: null }, widgetFactory: { classPropertyName: "widgetFactory", publicName: "widgetFactory", isSignal: true, isRequired: false, transformFunction: null }, widgetState: { classPropertyName: "widgetState", publicName: "widgetState", isSignal: true, isRequired: false, transformFunction: null }, isEditMode: { classPropertyName: "isEditMode", publicName: "isEditMode", isSignal: true, isRequired: false, transformFunction: null }, flat: { classPropertyName: "flat", publicName: "flat", isSignal: true, isRequired: false, transformFunction: null }, row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: true, transformFunction: null }, column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null }, rowSpan: { classPropertyName: "rowSpan", publicName: "rowSpan", isSignal: true, isRequired: false, transformFunction: null }, colSpan: { classPropertyName: "colSpan", publicName: "colSpan", isSignal: true, isRequired: false, transformFunction: null }, draggable: { classPropertyName: "draggable", publicName: "draggable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { row: "rowChange", column: "columnChange", dragStart: "dragStart", dragEnd: "dragEnd", edit: "edit", delete: "delete", settings: "settings", resizeStart: "resizeStart", resizeMove: "resizeMove", resizeEnd: "resizeEnd" }, host: { properties: { "style.grid-row": "gridRowStyle()", "style.grid-column": "gridColumnStyle()", "class.is-dragging": "isDragging()", "class.drag-active": "isDragActive()", "class.flat": "flat() === true" } }, viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, read: ViewContainerRef, isSignal: true }], ngImport: i0, template: "<!-- cell.component.html -->\r\n<div\r\n class=\"cell\"\r\n [class.is-resizing]=\"isResizing()\"\r\n [class.flat]=\"flat() === true\"\r\n [draggable]=\"draggable()\"\r\n (dragstart)=\"onDragStart($event)\"\r\n (dragend)=\"onDragEnd()\"\r\n (contextmenu)=\"onContextMenu($event)\"\r\n>\r\n <div class=\"content-area\">\r\n <ng-template #container></ng-template>\r\n </div>\r\n @if (isEditMode() && !isDragging()) {\r\n <!-- Right resize handle -->\r\n <div\r\n class=\"resize-handle resize-handle--right\"\r\n (mousedown)=\"onResizeStart($event, 'horizontal')\"\r\n >\r\n <div class=\"resize-handle-line\"></div>\r\n </div>\r\n <!-- Bottom resize handle -->\r\n <div\r\n class=\"resize-handle resize-handle--bottom\"\r\n (mousedown)=\"onResizeStart($event, 'vertical')\"\r\n >\r\n <div class=\"resize-handle-line\"></div>\r\n </div>\r\n }\r\n</div>\r\n\r\n@if (isResizing()) {\r\n<div class=\"resize-preview\">\r\n {{ resizeData()?.previewColSpan ?? colSpan() }} \u00D7\r\n {{ resizeData()?.previewRowSpan ?? rowSpan() }}\r\n</div>\r\n}\r\n", styles: [":host{display:block;width:100%;height:100%;position:relative;z-index:1;container-type:inline-size}:host(.drag-active):not(.is-dragging){pointer-events:none}:host(.is-dragging){z-index:100;opacity:.5;pointer-events:none}:host(:hover) .resize-handle{opacity:1}.cell{width:100%;height:100%;border-radius:4px;box-shadow:0 2px 6px #0000001a;padding:0;box-sizing:border-box;overflow:hidden;position:relative;container-type:inline-size}.cell:hover{box-shadow:0 4px 10px #00000026;transform:translateY(-2px)}.cell.flat{box-shadow:none;border:none}.cell.flat:hover{box-shadow:none;transform:none;border-color:#bdbdbd}.cell.resizing{-webkit-user-select:none;user-select:none}.content-area{width:100%;height:100%;overflow:auto}.resize-handle{position:absolute;z-index:20}.resize-handle--right{cursor:col-resize;width:16px;height:100%;right:-8px;top:0;display:flex;align-items:center;justify-content:center;opacity:0}.resize-handle--right:hover{opacity:1}.resize-handle--right:hover .resize-handle-line{background-color:var(--mat-sys-primary-container)}.resize-handle--bottom{cursor:row-resize;width:100%;height:16px;bottom:-8px;left:0;display:flex;align-items:center;justify-content:center;opacity:0}.resize-handle--bottom:hover{opacity:1}.resize-handle--bottom:hover .resize-handle-line{background-color:var(--mat-sys-primary-container)}.resize-handle-line{background-color:#0000001a}.resize-handle--right .resize-handle-line{width:8px;height:40px;border-radius:2px}.resize-handle--bottom .resize-handle-line{width:40px;height:8px;border-radius:2px}.resize-preview{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background-color:var(--mat-sys-primary);color:var(--mat-sys-on-primary);padding:4px 12px;border-radius:4px;font-size:14px;font-weight:500;pointer-events:none;z-index:30}.cell.is-resizing{opacity:.6}.cell.is-resizing .resize-handle{background-color:#2196f380}:root .cursor-col-resize{cursor:col-resize!important}:root .cursor-row-resize{cursor:row-resize!important}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1234
|
+
}
|
|
1235
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellComponent, decorators: [{
|
|
1236
|
+
type: Component,
|
|
1237
|
+
args: [{ selector: 'lib-cell', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1238
|
+
'[style.grid-row]': 'gridRowStyle()',
|
|
1239
|
+
'[style.grid-column]': 'gridColumnStyle()',
|
|
1240
|
+
'[class.is-dragging]': 'isDragging()',
|
|
1241
|
+
'[class.drag-active]': 'isDragActive()',
|
|
1242
|
+
'[class.flat]': 'flat() === true',
|
|
1243
|
+
}, template: "<!-- cell.component.html -->\r\n<div\r\n class=\"cell\"\r\n [class.is-resizing]=\"isResizing()\"\r\n [class.flat]=\"flat() === true\"\r\n [draggable]=\"draggable()\"\r\n (dragstart)=\"onDragStart($event)\"\r\n (dragend)=\"onDragEnd()\"\r\n (contextmenu)=\"onContextMenu($event)\"\r\n>\r\n <div class=\"content-area\">\r\n <ng-template #container></ng-template>\r\n </div>\r\n @if (isEditMode() && !isDragging()) {\r\n <!-- Right resize handle -->\r\n <div\r\n class=\"resize-handle resize-handle--right\"\r\n (mousedown)=\"onResizeStart($event, 'horizontal')\"\r\n >\r\n <div class=\"resize-handle-line\"></div>\r\n </div>\r\n <!-- Bottom resize handle -->\r\n <div\r\n class=\"resize-handle resize-handle--bottom\"\r\n (mousedown)=\"onResizeStart($event, 'vertical')\"\r\n >\r\n <div class=\"resize-handle-line\"></div>\r\n </div>\r\n }\r\n</div>\r\n\r\n@if (isResizing()) {\r\n<div class=\"resize-preview\">\r\n {{ resizeData()?.previewColSpan ?? colSpan() }} \u00D7\r\n {{ resizeData()?.previewRowSpan ?? rowSpan() }}\r\n</div>\r\n}\r\n", styles: [":host{display:block;width:100%;height:100%;position:relative;z-index:1;container-type:inline-size}:host(.drag-active):not(.is-dragging){pointer-events:none}:host(.is-dragging){z-index:100;opacity:.5;pointer-events:none}:host(:hover) .resize-handle{opacity:1}.cell{width:100%;height:100%;border-radius:4px;box-shadow:0 2px 6px #0000001a;padding:0;box-sizing:border-box;overflow:hidden;position:relative;container-type:inline-size}.cell:hover{box-shadow:0 4px 10px #00000026;transform:translateY(-2px)}.cell.flat{box-shadow:none;border:none}.cell.flat:hover{box-shadow:none;transform:none;border-color:#bdbdbd}.cell.resizing{-webkit-user-select:none;user-select:none}.content-area{width:100%;height:100%;overflow:auto}.resize-handle{position:absolute;z-index:20}.resize-handle--right{cursor:col-resize;width:16px;height:100%;right:-8px;top:0;display:flex;align-items:center;justify-content:center;opacity:0}.resize-handle--right:hover{opacity:1}.resize-handle--right:hover .resize-handle-line{background-color:var(--mat-sys-primary-container)}.resize-handle--bottom{cursor:row-resize;width:100%;height:16px;bottom:-8px;left:0;display:flex;align-items:center;justify-content:center;opacity:0}.resize-handle--bottom:hover{opacity:1}.resize-handle--bottom:hover .resize-handle-line{background-color:var(--mat-sys-primary-container)}.resize-handle-line{background-color:#0000001a}.resize-handle--right .resize-handle-line{width:8px;height:40px;border-radius:2px}.resize-handle--bottom .resize-handle-line{width:40px;height:8px;border-radius:2px}.resize-preview{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background-color:var(--mat-sys-primary);color:var(--mat-sys-on-primary);padding:4px 12px;border-radius:4px;font-size:14px;font-weight:500;pointer-events:none;z-index:30}.cell.is-resizing{opacity:.6}.cell.is-resizing .resize-handle{background-color:#2196f380}:root .cursor-col-resize{cursor:col-resize!important}:root .cursor-row-resize{cursor:row-resize!important}\n"] }]
|
|
1244
|
+
}], ctorParameters: () => [] });
|
|
1245
|
+
|
|
1246
|
+
class DashboardViewerComponent {
|
|
1247
|
+
#store = inject(DashboardStore);
|
|
1248
|
+
cellComponents = viewChildren(CellComponent);
|
|
1249
|
+
rows = input.required();
|
|
1250
|
+
columns = input.required();
|
|
1251
|
+
gutterSize = input('1em');
|
|
1252
|
+
gutters = computed(() => this.columns() + 1);
|
|
1253
|
+
// store signals - read-only
|
|
1254
|
+
cells = this.#store.cells;
|
|
1255
|
+
constructor() {
|
|
1256
|
+
// Sync grid configuration with store when inputs change
|
|
1257
|
+
effect(() => {
|
|
1258
|
+
this.#store.setGridConfig({
|
|
1259
|
+
rows: this.rows(),
|
|
1260
|
+
columns: this.columns(),
|
|
1261
|
+
gutterSize: this.gutterSize(),
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Get current widget states from all cell components.
|
|
1267
|
+
* Used during dashboard export to get live widget states.
|
|
1268
|
+
*/
|
|
1269
|
+
getCurrentWidgetStates() {
|
|
1270
|
+
const stateMap = new Map();
|
|
1271
|
+
const cells = this.cellComponents();
|
|
1272
|
+
for (const cell of cells) {
|
|
1273
|
+
const cellId = cell.id();
|
|
1274
|
+
const currentState = cell.getCurrentWidgetState();
|
|
1275
|
+
if (currentState !== undefined) {
|
|
1276
|
+
stateMap.set(CellIdUtils.toString(cellId), currentState);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return stateMap;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Export dashboard with live widget states from current component instances.
|
|
1283
|
+
* This ensures the most up-to-date widget states are captured.
|
|
1284
|
+
*/
|
|
1285
|
+
exportDashboard() {
|
|
1286
|
+
return this.#store.exportDashboard(() => this.getCurrentWidgetStates());
|
|
1287
|
+
}
|
|
1288
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardViewerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1289
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: DashboardViewerComponent, isStandalone: true, selector: "ngx-dashboard-viewer", inputs: { rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: true, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: true, transformFunction: null }, gutterSize: { classPropertyName: "gutterSize", publicName: "gutterSize", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.--rows": "rows()", "style.--columns": "columns()", "style.--gutter-size": "gutterSize()", "style.--gutters": "gutters()" } }, viewQueries: [{ propertyName: "cellComponents", predicate: CellComponent, descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Dashboard viewer - read-only grid -->\r\n<div class=\"grid top-grid\">\r\n @for (cell of cells(); track cell.cellId) {\r\n <lib-cell\r\n class=\"grid-cell\"\r\n [id]=\"cell.cellId\"\r\n [isEditMode]=\"false\"\r\n [draggable]=\"false\"\r\n [row]=\"cell.row\"\r\n [column]=\"cell.col\"\r\n [rowSpan]=\"cell.rowSpan\"\r\n [colSpan]=\"cell.colSpan\"\r\n [flat]=\"cell.flat\"\r\n [widgetFactory]=\"cell.widgetFactory\"\r\n [widgetState]=\"cell.widgetState\"\r\n >\r\n </lib-cell>\r\n }\r\n</div>\r\n", styles: ["@charset \"UTF-8\";:host{--cell-size: calc( 100cqi / var(--columns) - var(--gutter-size) * var(--gutters) / var(--columns) );--tile-size: calc(var(--cell-size) + var(--gutter-size));--tile-offset: calc( var(--gutter-size) + var(--cell-size) + var(--gutter-size) / 2 );display:block;container-type:inline-size;box-sizing:border-box;aspect-ratio:var(--columns)/var(--rows);width:100%;height:auto;background-color:var(--mat-sys-surface-container)}.grid{display:grid;gap:var(--gutter-size);padding:var(--gutter-size);width:100%;height:100%;box-sizing:border-box;grid-template-columns:repeat(var(--columns),var(--cell-size));grid-template-rows:repeat(var(--rows),var(--cell-size))}.grid-cell{pointer-events:none}.grid-cell:not(.flat){pointer-events:auto;cursor:default}.grid-cell:not(.flat) .content-area{pointer-events:none}.top-grid{z-index:2;pointer-events:none}\n"], dependencies: [{ kind: "component", type: CellComponent, selector: "lib-cell", inputs: ["id", "widgetFactory", "widgetState", "isEditMode", "flat", "row", "column", "rowSpan", "colSpan", "draggable"], outputs: ["rowChange", "columnChange", "dragStart", "dragEnd", "edit", "delete", "settings", "resizeStart", "resizeMove", "resizeEnd"] }, { kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1290
|
+
}
|
|
1291
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardViewerComponent, decorators: [{
|
|
1292
|
+
type: Component,
|
|
1293
|
+
args: [{ selector: 'ngx-dashboard-viewer', standalone: true, imports: [CellComponent, CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1294
|
+
'[style.--rows]': 'rows()',
|
|
1295
|
+
'[style.--columns]': 'columns()',
|
|
1296
|
+
'[style.--gutter-size]': 'gutterSize()',
|
|
1297
|
+
'[style.--gutters]': 'gutters()',
|
|
1298
|
+
}, template: "<!-- Dashboard viewer - read-only grid -->\r\n<div class=\"grid top-grid\">\r\n @for (cell of cells(); track cell.cellId) {\r\n <lib-cell\r\n class=\"grid-cell\"\r\n [id]=\"cell.cellId\"\r\n [isEditMode]=\"false\"\r\n [draggable]=\"false\"\r\n [row]=\"cell.row\"\r\n [column]=\"cell.col\"\r\n [rowSpan]=\"cell.rowSpan\"\r\n [colSpan]=\"cell.colSpan\"\r\n [flat]=\"cell.flat\"\r\n [widgetFactory]=\"cell.widgetFactory\"\r\n [widgetState]=\"cell.widgetState\"\r\n >\r\n </lib-cell>\r\n }\r\n</div>\r\n", styles: ["@charset \"UTF-8\";:host{--cell-size: calc( 100cqi / var(--columns) - var(--gutter-size) * var(--gutters) / var(--columns) );--tile-size: calc(var(--cell-size) + var(--gutter-size));--tile-offset: calc( var(--gutter-size) + var(--cell-size) + var(--gutter-size) / 2 );display:block;container-type:inline-size;box-sizing:border-box;aspect-ratio:var(--columns)/var(--rows);width:100%;height:auto;background-color:var(--mat-sys-surface-container)}.grid{display:grid;gap:var(--gutter-size);padding:var(--gutter-size);width:100%;height:100%;box-sizing:border-box;grid-template-columns:repeat(var(--columns),var(--cell-size));grid-template-rows:repeat(var(--rows),var(--cell-size))}.grid-cell{pointer-events:none}.grid-cell:not(.flat){pointer-events:auto;cursor:default}.grid-cell:not(.flat) .content-area{pointer-events:none}.top-grid{z-index:2;pointer-events:none}\n"] }]
|
|
1299
|
+
}], ctorParameters: () => [] });
|
|
1300
|
+
|
|
1301
|
+
class CellContextMenuComponent {
|
|
1302
|
+
menuTrigger = viewChild.required('menuTrigger', { read: MatMenuTrigger });
|
|
1303
|
+
menuService = inject(CellContextMenuService);
|
|
1304
|
+
menuPosition = computed(() => {
|
|
1305
|
+
const menu = this.menuService.activeMenu();
|
|
1306
|
+
return menu ? { left: `${menu.x}px`, top: `${menu.y}px` } : { left: '0px', top: '0px' };
|
|
1307
|
+
});
|
|
1308
|
+
menuItems = computed(() => {
|
|
1309
|
+
const menu = this.menuService.activeMenu();
|
|
1310
|
+
return menu?.items || [];
|
|
1311
|
+
});
|
|
1312
|
+
constructor() {
|
|
1313
|
+
effect(() => {
|
|
1314
|
+
const menu = this.menuService.activeMenu();
|
|
1315
|
+
if (menu) {
|
|
1316
|
+
// Use queueMicrotask to ensure the view is fully initialized
|
|
1317
|
+
// This fixes the issue where the menu disappears on first right-click
|
|
1318
|
+
queueMicrotask(() => {
|
|
1319
|
+
const trigger = this.menuTrigger();
|
|
1320
|
+
if (trigger) {
|
|
1321
|
+
// Close any existing menu first
|
|
1322
|
+
if (trigger.menuOpen) {
|
|
1323
|
+
trigger.closeMenu();
|
|
1324
|
+
}
|
|
1325
|
+
// Open menu - position is handled by signal
|
|
1326
|
+
trigger.openMenu();
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
const trigger = this.menuTrigger();
|
|
1332
|
+
if (trigger) {
|
|
1333
|
+
trigger.closeMenu();
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
// Hide service menu when Material menu closes
|
|
1338
|
+
effect(() => {
|
|
1339
|
+
const trigger = this.menuTrigger();
|
|
1340
|
+
if (trigger) {
|
|
1341
|
+
trigger.menuClosed.subscribe(() => {
|
|
1342
|
+
if (this.menuService.activeMenu()) {
|
|
1343
|
+
this.menuService.hide();
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
ngAfterViewInit() {
|
|
1350
|
+
// Effects moved to constructor to be within injection context
|
|
1351
|
+
}
|
|
1352
|
+
executeAction(item) {
|
|
1353
|
+
if (!item.divider && item.action) {
|
|
1354
|
+
item.action();
|
|
1355
|
+
this.menuService.hide();
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellContextMenuComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1359
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: CellContextMenuComponent, isStandalone: true, selector: "lib-cell-context-menu", viewQueries: [{ propertyName: "menuTrigger", first: true, predicate: ["menuTrigger"], descendants: true, read: MatMenuTrigger, isSignal: true }], ngImport: i0, template: `
|
|
1360
|
+
<!-- Hidden trigger for menu positioned at exact mouse coordinates
|
|
1361
|
+
|
|
1362
|
+
IMPORTANT: Angular Material applies its own positioning logic to menus,
|
|
1363
|
+
which by default offsets the menu from the trigger element to avoid overlap.
|
|
1364
|
+
To achieve precise positioning at mouse coordinates, we use these workarounds:
|
|
1365
|
+
|
|
1366
|
+
1. The trigger container is 1x1px (not 0x0) because Material needs a physical
|
|
1367
|
+
element to calculate position against. Zero-sized elements cause unpredictable
|
|
1368
|
+
positioning.
|
|
1369
|
+
|
|
1370
|
+
2. We use opacity:0 instead of visibility:hidden to keep the element in the
|
|
1371
|
+
layout flow while making it invisible.
|
|
1372
|
+
|
|
1373
|
+
3. The button itself is styled to 1x1px with no padding to serve as a precise
|
|
1374
|
+
anchor point for the menu.
|
|
1375
|
+
|
|
1376
|
+
4. The mat-menu uses [overlapTrigger]="true" to allow the menu to appear
|
|
1377
|
+
directly at the trigger position rather than offset from it.
|
|
1378
|
+
|
|
1379
|
+
This approach ensures the menu appears at the exact mouse coordinates passed
|
|
1380
|
+
from the cell component's right-click handler.
|
|
1381
|
+
-->
|
|
1382
|
+
<div
|
|
1383
|
+
style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
|
|
1384
|
+
[style]="menuPosition()">
|
|
1385
|
+
<button
|
|
1386
|
+
mat-button
|
|
1387
|
+
#menuTrigger="matMenuTrigger"
|
|
1388
|
+
[matMenuTriggerFor]="contextMenu"
|
|
1389
|
+
style="width: 1px; height: 1px; padding: 0; min-width: 0; line-height: 0;">
|
|
1390
|
+
</button>
|
|
1391
|
+
</div>
|
|
1392
|
+
|
|
1393
|
+
<!-- Context menu -->
|
|
1394
|
+
<mat-menu #contextMenu="matMenu" [overlapTrigger]="true">
|
|
1395
|
+
@for (item of menuItems(); track $index) {
|
|
1396
|
+
@if (item.divider) {
|
|
1397
|
+
<mat-divider></mat-divider>
|
|
1398
|
+
} @else {
|
|
1399
|
+
<button
|
|
1400
|
+
mat-menu-item
|
|
1401
|
+
(click)="executeAction(item)"
|
|
1402
|
+
[disabled]="item.disabled">
|
|
1403
|
+
@if (item.icon) {
|
|
1404
|
+
<mat-icon>{{ item.icon }}</mat-icon>
|
|
1405
|
+
}
|
|
1406
|
+
<span>{{ item.label }}</span>
|
|
1407
|
+
</button>
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
</mat-menu>
|
|
1411
|
+
`, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i1$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatDividerModule }, { kind: "component", type: i3$1.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1412
|
+
}
|
|
1413
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: CellContextMenuComponent, decorators: [{
|
|
1414
|
+
type: Component,
|
|
1415
|
+
args: [{ selector: 'lib-cell-context-menu', standalone: true, imports: [MatMenuModule, MatIconModule, MatDividerModule, MatButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
1416
|
+
<!-- Hidden trigger for menu positioned at exact mouse coordinates
|
|
1417
|
+
|
|
1418
|
+
IMPORTANT: Angular Material applies its own positioning logic to menus,
|
|
1419
|
+
which by default offsets the menu from the trigger element to avoid overlap.
|
|
1420
|
+
To achieve precise positioning at mouse coordinates, we use these workarounds:
|
|
1421
|
+
|
|
1422
|
+
1. The trigger container is 1x1px (not 0x0) because Material needs a physical
|
|
1423
|
+
element to calculate position against. Zero-sized elements cause unpredictable
|
|
1424
|
+
positioning.
|
|
1425
|
+
|
|
1426
|
+
2. We use opacity:0 instead of visibility:hidden to keep the element in the
|
|
1427
|
+
layout flow while making it invisible.
|
|
1428
|
+
|
|
1429
|
+
3. The button itself is styled to 1x1px with no padding to serve as a precise
|
|
1430
|
+
anchor point for the menu.
|
|
1431
|
+
|
|
1432
|
+
4. The mat-menu uses [overlapTrigger]="true" to allow the menu to appear
|
|
1433
|
+
directly at the trigger position rather than offset from it.
|
|
1434
|
+
|
|
1435
|
+
This approach ensures the menu appears at the exact mouse coordinates passed
|
|
1436
|
+
from the cell component's right-click handler.
|
|
1437
|
+
-->
|
|
1438
|
+
<div
|
|
1439
|
+
style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
|
|
1440
|
+
[style]="menuPosition()">
|
|
1441
|
+
<button
|
|
1442
|
+
mat-button
|
|
1443
|
+
#menuTrigger="matMenuTrigger"
|
|
1444
|
+
[matMenuTriggerFor]="contextMenu"
|
|
1445
|
+
style="width: 1px; height: 1px; padding: 0; min-width: 0; line-height: 0;">
|
|
1446
|
+
</button>
|
|
1447
|
+
</div>
|
|
1448
|
+
|
|
1449
|
+
<!-- Context menu -->
|
|
1450
|
+
<mat-menu #contextMenu="matMenu" [overlapTrigger]="true">
|
|
1451
|
+
@for (item of menuItems(); track $index) {
|
|
1452
|
+
@if (item.divider) {
|
|
1453
|
+
<mat-divider></mat-divider>
|
|
1454
|
+
} @else {
|
|
1455
|
+
<button
|
|
1456
|
+
mat-menu-item
|
|
1457
|
+
(click)="executeAction(item)"
|
|
1458
|
+
[disabled]="item.disabled">
|
|
1459
|
+
@if (item.icon) {
|
|
1460
|
+
<mat-icon>{{ item.icon }}</mat-icon>
|
|
1461
|
+
}
|
|
1462
|
+
<span>{{ item.label }}</span>
|
|
1463
|
+
</button>
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
</mat-menu>
|
|
1467
|
+
`, styles: [":host{display:contents}\n"] }]
|
|
1468
|
+
}], ctorParameters: () => [] });
|
|
1469
|
+
|
|
1470
|
+
// drop-zone.component.ts
|
|
1471
|
+
class DropZoneComponent {
|
|
1472
|
+
// Required inputs
|
|
1473
|
+
row = input.required();
|
|
1474
|
+
col = input.required();
|
|
1475
|
+
index = input.required();
|
|
1476
|
+
// Optional inputs with defaults
|
|
1477
|
+
highlight = input(false);
|
|
1478
|
+
highlightInvalid = input(false);
|
|
1479
|
+
highlightResize = input(false);
|
|
1480
|
+
editMode = input(false);
|
|
1481
|
+
// Outputs
|
|
1482
|
+
dragEnter = output();
|
|
1483
|
+
dragExit = output();
|
|
1484
|
+
dragOver = output();
|
|
1485
|
+
dragDrop = output();
|
|
1486
|
+
// Computed properties
|
|
1487
|
+
dropZoneId = computed(() => `drop-zone-${this.row()}-${this.col()}`);
|
|
1488
|
+
dropData = computed(() => ({
|
|
1489
|
+
row: this.row(),
|
|
1490
|
+
col: this.col(),
|
|
1491
|
+
}));
|
|
1492
|
+
// Abstract drag state from store
|
|
1493
|
+
dragData = computed(() => this.#store.dragData());
|
|
1494
|
+
// Computed drop effect based on drag data and validity
|
|
1495
|
+
dropEffect = computed(() => {
|
|
1496
|
+
const data = this.dragData();
|
|
1497
|
+
if (!data || this.highlightInvalid()) {
|
|
1498
|
+
return 'none';
|
|
1499
|
+
}
|
|
1500
|
+
return data.kind === 'cell' ? 'move' : 'copy';
|
|
1501
|
+
});
|
|
1502
|
+
#store = inject(DashboardStore);
|
|
1503
|
+
#elementRef = inject(ElementRef);
|
|
1504
|
+
get nativeElement() {
|
|
1505
|
+
return this.#elementRef.nativeElement;
|
|
1506
|
+
}
|
|
1507
|
+
onDragEnter(event) {
|
|
1508
|
+
event.preventDefault();
|
|
1509
|
+
event.stopPropagation();
|
|
1510
|
+
this.dragEnter.emit({ row: this.row(), col: this.col() });
|
|
1511
|
+
}
|
|
1512
|
+
onDragOver(event) {
|
|
1513
|
+
event.preventDefault();
|
|
1514
|
+
event.stopPropagation();
|
|
1515
|
+
if (event.dataTransfer && this.dragData()) {
|
|
1516
|
+
event.dataTransfer.dropEffect = this.dropEffect();
|
|
1517
|
+
}
|
|
1518
|
+
this.dragOver.emit({ row: this.row(), col: this.col() });
|
|
1519
|
+
}
|
|
1520
|
+
onDragLeave(event) {
|
|
1521
|
+
event.preventDefault();
|
|
1522
|
+
event.stopPropagation();
|
|
1523
|
+
// Only emit if actually leaving the element (not entering a child)
|
|
1524
|
+
if (this.#isLeavingElement(event)) {
|
|
1525
|
+
this.dragExit.emit();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
#isLeavingElement(event) {
|
|
1529
|
+
const rect = this.#elementRef.nativeElement.getBoundingClientRect();
|
|
1530
|
+
return (event.clientX <= rect.left ||
|
|
1531
|
+
event.clientX >= rect.right ||
|
|
1532
|
+
event.clientY <= rect.top ||
|
|
1533
|
+
event.clientY >= rect.bottom);
|
|
1534
|
+
}
|
|
1535
|
+
onDrop(event) {
|
|
1536
|
+
event.preventDefault();
|
|
1537
|
+
event.stopPropagation();
|
|
1538
|
+
if (!event.dataTransfer)
|
|
1539
|
+
return;
|
|
1540
|
+
const data = this.dragData();
|
|
1541
|
+
if (data) {
|
|
1542
|
+
this.dragDrop.emit({
|
|
1543
|
+
data,
|
|
1544
|
+
target: { row: this.row(), col: this.col() },
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DropZoneComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1549
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: DropZoneComponent, isStandalone: true, selector: "lib-drop-zone", inputs: { row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: true, transformFunction: null }, col: { classPropertyName: "col", publicName: "col", isSignal: true, isRequired: true, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: true, transformFunction: null }, highlight: { classPropertyName: "highlight", publicName: "highlight", isSignal: true, isRequired: false, transformFunction: null }, highlightInvalid: { classPropertyName: "highlightInvalid", publicName: "highlightInvalid", isSignal: true, isRequired: false, transformFunction: null }, highlightResize: { classPropertyName: "highlightResize", publicName: "highlightResize", isSignal: true, isRequired: false, transformFunction: null }, editMode: { classPropertyName: "editMode", publicName: "editMode", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragEnter: "dragEnter", dragExit: "dragExit", dragOver: "dragOver", dragDrop: "dragDrop" }, ngImport: i0, template: "<!-- drop-zone.component.html -->\r\n<div\r\n class=\"drop-zone\"\r\n [class.drop-zone--highlight]=\"highlight() && !highlightInvalid()\"\r\n [class.drop-zone--invalid]=\"highlightInvalid()\"\r\n [class.drop-zone--resize]=\"highlightResize()\"\r\n [style.grid-row]=\"row()\"\r\n [style.grid-column]=\"col()\"\r\n (dragenter)=\"onDragEnter($event)\"\r\n (dragover)=\"onDragOver($event)\"\r\n (dragleave)=\"onDragLeave($event)\"\r\n (drop)=\"onDrop($event)\"\r\n>\r\n @if (editMode()) {\r\n <div class=\"edit-mode-cell-number\">\r\n {{ index() }}<br />\r\n {{ row() }},{{ col() }}\r\n </div>\r\n }\r\n</div>\r\n", styles: [".drop-zone{width:100%;height:100%;z-index:0;align-self:stretch;justify-self:stretch;display:block;box-sizing:border-box}.drop-zone--active,.drop-zone--highlight{background-color:#80808080}.drop-zone--invalid{background-color:light-dark(color-mix(in srgb,var(--mat-sys-error) 40%,white),color-mix(in srgb,var(--mat-sys-error) 80%,black))}.drop-zone--resize{background-color:#2196f34d;outline:1px solid rgba(33,150,243,.6)}.edit-mode-cell-number{font-size:10px;color:#64646499;pointer-events:none;-webkit-user-select:none;user-select:none;z-index:-1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;width:100%;height:100%}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1550
|
+
}
|
|
1551
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DropZoneComponent, decorators: [{
|
|
1552
|
+
type: Component,
|
|
1553
|
+
args: [{ selector: 'lib-drop-zone', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- drop-zone.component.html -->\r\n<div\r\n class=\"drop-zone\"\r\n [class.drop-zone--highlight]=\"highlight() && !highlightInvalid()\"\r\n [class.drop-zone--invalid]=\"highlightInvalid()\"\r\n [class.drop-zone--resize]=\"highlightResize()\"\r\n [style.grid-row]=\"row()\"\r\n [style.grid-column]=\"col()\"\r\n (dragenter)=\"onDragEnter($event)\"\r\n (dragover)=\"onDragOver($event)\"\r\n (dragleave)=\"onDragLeave($event)\"\r\n (drop)=\"onDrop($event)\"\r\n>\r\n @if (editMode()) {\r\n <div class=\"edit-mode-cell-number\">\r\n {{ index() }}<br />\r\n {{ row() }},{{ col() }}\r\n </div>\r\n }\r\n</div>\r\n", styles: [".drop-zone{width:100%;height:100%;z-index:0;align-self:stretch;justify-self:stretch;display:block;box-sizing:border-box}.drop-zone--active,.drop-zone--highlight{background-color:#80808080}.drop-zone--invalid{background-color:light-dark(color-mix(in srgb,var(--mat-sys-error) 40%,white),color-mix(in srgb,var(--mat-sys-error) 80%,black))}.drop-zone--resize{background-color:#2196f34d;outline:1px solid rgba(33,150,243,.6)}.edit-mode-cell-number{font-size:10px;color:#64646499;pointer-events:none;-webkit-user-select:none;user-select:none;z-index:-1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;width:100%;height:100%}\n"] }]
|
|
1554
|
+
}] });
|
|
1555
|
+
|
|
1556
|
+
// dashboard-editor.component.ts
|
|
1557
|
+
class DashboardEditorComponent {
|
|
1558
|
+
bottomGridRef = viewChild.required('bottomGrid');
|
|
1559
|
+
dropZones = viewChildren(DropZoneComponent);
|
|
1560
|
+
cellComponents = viewChildren(CellComponent);
|
|
1561
|
+
#store = inject(DashboardStore);
|
|
1562
|
+
#destroyRef = inject(DestroyRef);
|
|
1563
|
+
#resizeObserver;
|
|
1564
|
+
rows = input.required();
|
|
1565
|
+
columns = input.required();
|
|
1566
|
+
gutterSize = input('1em');
|
|
1567
|
+
gutters = computed(() => this.columns() + 1);
|
|
1568
|
+
// store signals
|
|
1569
|
+
cells = this.#store.cells;
|
|
1570
|
+
highlightedZones = this.#store.highlightedZones;
|
|
1571
|
+
highlightMap = this.#store.highlightMap;
|
|
1572
|
+
invalidHighlightMap = this.#store.invalidHighlightMap;
|
|
1573
|
+
hoveredDropZone = this.#store.hoveredDropZone;
|
|
1574
|
+
resizePreviewMap = this.#store.resizePreviewMap;
|
|
1575
|
+
// Generate all possible cell positions for the grid
|
|
1576
|
+
dropzonePositions = computed(() => {
|
|
1577
|
+
const positions = [];
|
|
1578
|
+
for (let row = 1; row <= this.rows(); row++) {
|
|
1579
|
+
for (let col = 1; col <= this.columns(); col++) {
|
|
1580
|
+
positions.push({
|
|
1581
|
+
row,
|
|
1582
|
+
col,
|
|
1583
|
+
id: `dropzone-${row}-${col}`,
|
|
1584
|
+
index: (row - 1) * this.columns() + col,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return positions;
|
|
1589
|
+
});
|
|
1590
|
+
// Helper method for template
|
|
1591
|
+
createCellId(row, col) {
|
|
1592
|
+
return CellIdUtils.create(row, col);
|
|
1593
|
+
}
|
|
1594
|
+
constructor() {
|
|
1595
|
+
// Sync grid configuration with store when inputs change
|
|
1596
|
+
effect(() => {
|
|
1597
|
+
this.#store.setGridConfig({
|
|
1598
|
+
rows: this.rows(),
|
|
1599
|
+
columns: this.columns(),
|
|
1600
|
+
gutterSize: this.gutterSize(),
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
// Observe grid size after rendering
|
|
1604
|
+
afterNextRender(() => {
|
|
1605
|
+
this.#observeGridSize();
|
|
1606
|
+
});
|
|
1607
|
+
// Always set edit mode to true
|
|
1608
|
+
effect(() => {
|
|
1609
|
+
this.#store.setEditMode(true);
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
#observeGridSize() {
|
|
1613
|
+
const gridEl = this.bottomGridRef()?.nativeElement;
|
|
1614
|
+
if (!gridEl || this.#resizeObserver)
|
|
1615
|
+
return;
|
|
1616
|
+
this.#resizeObserver = new ResizeObserver(() => {
|
|
1617
|
+
const dropZonesList = this.dropZones();
|
|
1618
|
+
const firstDropZone = dropZonesList[0];
|
|
1619
|
+
if (!firstDropZone)
|
|
1620
|
+
return;
|
|
1621
|
+
const el = firstDropZone.nativeElement;
|
|
1622
|
+
if (!el)
|
|
1623
|
+
return;
|
|
1624
|
+
const rect = el.getBoundingClientRect();
|
|
1625
|
+
const width = rect.width;
|
|
1626
|
+
const height = rect.height;
|
|
1627
|
+
this.#store.setGridCellDimensions(width, height);
|
|
1628
|
+
});
|
|
1629
|
+
this.#resizeObserver.observe(gridEl);
|
|
1630
|
+
// Register cleanup with DestroyRef for automatic memory management
|
|
1631
|
+
this.#destroyRef.onDestroy(() => {
|
|
1632
|
+
this.#resizeObserver?.disconnect();
|
|
1633
|
+
this.#resizeObserver = undefined;
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
// Pure delegation methods - no business logic in component
|
|
1637
|
+
addWidget = (cellData) => this.#store.addWidget(cellData);
|
|
1638
|
+
updateCellPosition = (id, row, column) => this.#store.updateWidgetPosition(id, row, column);
|
|
1639
|
+
updateCellSpan = (id, colSpan, rowSpan) => this.#store.updateWidgetSpan(id, rowSpan, colSpan);
|
|
1640
|
+
updateCellSettings = (id, flat) => this.#store.updateCellSettings(id, flat);
|
|
1641
|
+
// Pure delegation - drag and drop event handlers
|
|
1642
|
+
onDragOver = (event) => this.#store.setHoveredDropZone(event);
|
|
1643
|
+
onDragEnter = (event) => this.onDragOver(event);
|
|
1644
|
+
onDragExit = () => this.#store.setHoveredDropZone(null);
|
|
1645
|
+
dragEnd = () => this.#store.endDrag();
|
|
1646
|
+
// Pure delegation - cell event handlers
|
|
1647
|
+
onCellDelete = (id) => this.#store.removeWidget(id);
|
|
1648
|
+
onCellSettings = (event) => this.updateCellSettings(event.id, event.flat);
|
|
1649
|
+
onCellResize = (event) => this.updateCellSpan(event.id, event.colSpan, event.rowSpan);
|
|
1650
|
+
// Handle drag events from cell component
|
|
1651
|
+
onCellDragStart = (dragData) => this.#store.startDrag(dragData);
|
|
1652
|
+
// Handle resize events from cell component
|
|
1653
|
+
onCellResizeStart = (event) => this.#store.startResize(event.id);
|
|
1654
|
+
onCellResizeMove = (event) => this.#store.updateResizePreview(event.direction, event.delta);
|
|
1655
|
+
onCellResizeEnd = (event) => this.#store.endResize(event.apply);
|
|
1656
|
+
// Handle drop events by delegating to store's business logic
|
|
1657
|
+
onDragDrop(event) {
|
|
1658
|
+
this.#store.handleDrop(event.data, event.target);
|
|
1659
|
+
// Note: Store handles all validation and error handling internally
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Get current widget states from all cell components.
|
|
1663
|
+
* Used during dashboard export to get live widget states.
|
|
1664
|
+
*/
|
|
1665
|
+
getCurrentWidgetStates() {
|
|
1666
|
+
const stateMap = new Map();
|
|
1667
|
+
const cells = this.cellComponents();
|
|
1668
|
+
for (const cell of cells) {
|
|
1669
|
+
const cellId = cell.id();
|
|
1670
|
+
const currentState = cell.getCurrentWidgetState();
|
|
1671
|
+
if (currentState !== undefined) {
|
|
1672
|
+
stateMap.set(CellIdUtils.toString(cellId), currentState);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return stateMap;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Export dashboard with live widget states from current component instances.
|
|
1679
|
+
* This ensures the most up-to-date widget states are captured.
|
|
1680
|
+
*/
|
|
1681
|
+
exportDashboard() {
|
|
1682
|
+
return this.#store.exportDashboard(() => this.getCurrentWidgetStates());
|
|
1683
|
+
}
|
|
1684
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1685
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: DashboardEditorComponent, isStandalone: true, selector: "ngx-dashboard-editor", inputs: { rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: true, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: true, transformFunction: null }, gutterSize: { classPropertyName: "gutterSize", publicName: "gutterSize", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.--rows": "rows()", "style.--columns": "columns()", "style.--gutter-size": "gutterSize()", "style.--gutters": "gutters()", "class.is-edit-mode": "true" } }, providers: [
|
|
1686
|
+
CellContextMenuService,
|
|
1687
|
+
], viewQueries: [{ propertyName: "bottomGridRef", first: true, predicate: ["bottomGrid"], descendants: true, isSignal: true }, { propertyName: "dropZones", predicate: DropZoneComponent, descendants: true, isSignal: true }, { propertyName: "cellComponents", predicate: CellComponent, descendants: true, isSignal: true }], ngImport: i0, template: "<!-- dashboard-editor.component.html -->\n<div class=\"grid-container\">\n <!-- Bottom grid with drop zones -->\n <div class=\"grid\" id=\"bottom-grid\" #bottomGrid>\n @for (position of dropzonePositions(); track position.id) {\n <lib-drop-zone\n class=\"drop-zone\"\n [row]=\"position.row\"\n [col]=\"position.col\"\n [index]=\"position.index\"\n [highlight]=\"highlightMap().has(createCellId(position.row, position.col))\"\n [highlightInvalid]=\"\n invalidHighlightMap().has(createCellId(position.row, position.col))\n \"\n [highlightResize]=\"\n resizePreviewMap().has(createCellId(position.row, position.col))\n \"\n [editMode]=\"true\"\n (dragEnter)=\"onDragEnter($event)\"\n (dragExit)=\"onDragExit()\"\n (dragOver)=\"onDragOver($event)\"\n (dragDrop)=\"onDragDrop($event)\"\n ></lib-drop-zone>\n }\n </div>\n\n <!-- Top grid with interactive cells -->\n <div class=\"grid\" id=\"top-grid\">\n @for (cell of cells(); track cell.cellId) {\n <lib-cell\n class=\"grid-cell\"\n [id]=\"cell.cellId\"\n [isEditMode]=\"true\"\n [draggable]=\"true\"\n [row]=\"cell.row\"\n [column]=\"cell.col\"\n [rowSpan]=\"cell.rowSpan\"\n [colSpan]=\"cell.colSpan\"\n [flat]=\"cell.flat\"\n [widgetFactory]=\"cell.widgetFactory\"\n [widgetState]=\"cell.widgetState\"\n (dragStart)=\"onCellDragStart($event)\"\n (dragEnd)=\"dragEnd()\"\n (delete)=\"onCellDelete($event)\"\n (settings)=\"onCellSettings($event)\"\n (resizeStart)=\"onCellResizeStart($event)\"\n (resizeMove)=\"onCellResizeMove($event)\"\n (resizeEnd)=\"onCellResizeEnd($event)\"\n >\n </lib-cell>\n }\n </div>\n</div>\n\n<!-- Context menu -->\n<lib-cell-context-menu></lib-cell-context-menu>\n", styles: ["@charset \"UTF-8\";:host{--cell-size: calc( 100cqi / var(--columns) - var(--gutter-size) * var(--gutters) / var(--columns) );--tile-size: calc(var(--cell-size) + var(--gutter-size));--tile-offset: calc( var(--gutter-size) + var(--cell-size) + var(--gutter-size) / 2 );display:block;container-type:inline-size;box-sizing:border-box;aspect-ratio:var(--columns)/var(--rows);width:100%;height:auto}:host .grid{background-image:linear-gradient(to right,rgba(100,100,100,.12) 1px,transparent 1px),linear-gradient(to bottom,rgba(100,100,100,.12) 1px,transparent 1px),linear-gradient(to bottom,rgba(100,100,100,.12) 1px,transparent 1px);background-size:var(--tile-size) var(--tile-size),var(--tile-size) var(--tile-size),100% 1px;background-position:var(--tile-offset) var(--tile-offset),var(--tile-offset) var(--tile-offset),bottom;background-repeat:repeat,repeat,no-repeat}.grid-container{position:relative;width:100%;height:100%}.grid{display:grid;gap:var(--gutter-size);padding:var(--gutter-size);position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;align-items:stretch;justify-items:stretch;grid-template-columns:repeat(var(--columns),var(--cell-size));grid-template-rows:repeat(var(--rows),var(--cell-size))}#bottom-grid{z-index:1}#top-grid{z-index:2;pointer-events:none}.grid-cell{pointer-events:auto}.grid-cell.is-dragging{pointer-events:none;opacity:.5}\n"], dependencies: [{ kind: "component", type: CellComponent, selector: "lib-cell", inputs: ["id", "widgetFactory", "widgetState", "isEditMode", "flat", "row", "column", "rowSpan", "colSpan", "draggable"], outputs: ["rowChange", "columnChange", "dragStart", "dragEnd", "edit", "delete", "settings", "resizeStart", "resizeMove", "resizeEnd"] }, { kind: "ngmodule", type: CommonModule }, { kind: "component", type: DropZoneComponent, selector: "lib-drop-zone", inputs: ["row", "col", "index", "highlight", "highlightInvalid", "highlightResize", "editMode"], outputs: ["dragEnter", "dragExit", "dragOver", "dragDrop"] }, { kind: "component", type: CellContextMenuComponent, selector: "lib-cell-context-menu" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1688
|
+
}
|
|
1689
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardEditorComponent, decorators: [{
|
|
1690
|
+
type: Component,
|
|
1691
|
+
args: [{ selector: 'ngx-dashboard-editor', standalone: true, imports: [
|
|
1692
|
+
CellComponent,
|
|
1693
|
+
CommonModule,
|
|
1694
|
+
DropZoneComponent,
|
|
1695
|
+
CellContextMenuComponent,
|
|
1696
|
+
], providers: [
|
|
1697
|
+
CellContextMenuService,
|
|
1698
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1699
|
+
'[style.--rows]': 'rows()',
|
|
1700
|
+
'[style.--columns]': 'columns()',
|
|
1701
|
+
'[style.--gutter-size]': 'gutterSize()',
|
|
1702
|
+
'[style.--gutters]': 'gutters()',
|
|
1703
|
+
'[class.is-edit-mode]': 'true', // Always in edit mode
|
|
1704
|
+
}, template: "<!-- dashboard-editor.component.html -->\n<div class=\"grid-container\">\n <!-- Bottom grid with drop zones -->\n <div class=\"grid\" id=\"bottom-grid\" #bottomGrid>\n @for (position of dropzonePositions(); track position.id) {\n <lib-drop-zone\n class=\"drop-zone\"\n [row]=\"position.row\"\n [col]=\"position.col\"\n [index]=\"position.index\"\n [highlight]=\"highlightMap().has(createCellId(position.row, position.col))\"\n [highlightInvalid]=\"\n invalidHighlightMap().has(createCellId(position.row, position.col))\n \"\n [highlightResize]=\"\n resizePreviewMap().has(createCellId(position.row, position.col))\n \"\n [editMode]=\"true\"\n (dragEnter)=\"onDragEnter($event)\"\n (dragExit)=\"onDragExit()\"\n (dragOver)=\"onDragOver($event)\"\n (dragDrop)=\"onDragDrop($event)\"\n ></lib-drop-zone>\n }\n </div>\n\n <!-- Top grid with interactive cells -->\n <div class=\"grid\" id=\"top-grid\">\n @for (cell of cells(); track cell.cellId) {\n <lib-cell\n class=\"grid-cell\"\n [id]=\"cell.cellId\"\n [isEditMode]=\"true\"\n [draggable]=\"true\"\n [row]=\"cell.row\"\n [column]=\"cell.col\"\n [rowSpan]=\"cell.rowSpan\"\n [colSpan]=\"cell.colSpan\"\n [flat]=\"cell.flat\"\n [widgetFactory]=\"cell.widgetFactory\"\n [widgetState]=\"cell.widgetState\"\n (dragStart)=\"onCellDragStart($event)\"\n (dragEnd)=\"dragEnd()\"\n (delete)=\"onCellDelete($event)\"\n (settings)=\"onCellSettings($event)\"\n (resizeStart)=\"onCellResizeStart($event)\"\n (resizeMove)=\"onCellResizeMove($event)\"\n (resizeEnd)=\"onCellResizeEnd($event)\"\n >\n </lib-cell>\n }\n </div>\n</div>\n\n<!-- Context menu -->\n<lib-cell-context-menu></lib-cell-context-menu>\n", styles: ["@charset \"UTF-8\";:host{--cell-size: calc( 100cqi / var(--columns) - var(--gutter-size) * var(--gutters) / var(--columns) );--tile-size: calc(var(--cell-size) + var(--gutter-size));--tile-offset: calc( var(--gutter-size) + var(--cell-size) + var(--gutter-size) / 2 );display:block;container-type:inline-size;box-sizing:border-box;aspect-ratio:var(--columns)/var(--rows);width:100%;height:auto}:host .grid{background-image:linear-gradient(to right,rgba(100,100,100,.12) 1px,transparent 1px),linear-gradient(to bottom,rgba(100,100,100,.12) 1px,transparent 1px),linear-gradient(to bottom,rgba(100,100,100,.12) 1px,transparent 1px);background-size:var(--tile-size) var(--tile-size),var(--tile-size) var(--tile-size),100% 1px;background-position:var(--tile-offset) var(--tile-offset),var(--tile-offset) var(--tile-offset),bottom;background-repeat:repeat,repeat,no-repeat}.grid-container{position:relative;width:100%;height:100%}.grid{display:grid;gap:var(--gutter-size);padding:var(--gutter-size);position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;align-items:stretch;justify-items:stretch;grid-template-columns:repeat(var(--columns),var(--cell-size));grid-template-rows:repeat(var(--rows),var(--cell-size))}#bottom-grid{z-index:1}#top-grid{z-index:2;pointer-events:none}.grid-cell{pointer-events:auto}.grid-cell.is-dragging{pointer-events:none;opacity:.5}\n"] }]
|
|
1705
|
+
}], ctorParameters: () => [] });
|
|
1706
|
+
|
|
1707
|
+
// dashboard-bridge.service.ts
|
|
1708
|
+
/**
|
|
1709
|
+
* Internal bridge service that coordinates between component-scoped DashboardStore instances
|
|
1710
|
+
* and standalone components like WidgetListComponent.
|
|
1711
|
+
*
|
|
1712
|
+
* This service is NOT part of the public API and should remain internal to the library.
|
|
1713
|
+
*/
|
|
1714
|
+
class DashboardBridgeService {
|
|
1715
|
+
// Map of registered dashboard instances with their reactive dimensions
|
|
1716
|
+
dashboards = signal(new Map());
|
|
1717
|
+
/**
|
|
1718
|
+
* Register a dashboard store instance using its dashboard ID
|
|
1719
|
+
*/
|
|
1720
|
+
registerDashboard(store) {
|
|
1721
|
+
const dashboardId = store.dashboardId();
|
|
1722
|
+
// If dashboard ID is not set yet, we'll register later when it's available
|
|
1723
|
+
if (!dashboardId) {
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
this.dashboards.update(dashboards => {
|
|
1727
|
+
const newMap = new Map(dashboards);
|
|
1728
|
+
newMap.set(dashboardId, {
|
|
1729
|
+
store,
|
|
1730
|
+
dimensions: store.gridCellDimensions
|
|
1731
|
+
});
|
|
1732
|
+
return newMap;
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Unregister a dashboard store instance
|
|
1737
|
+
*/
|
|
1738
|
+
unregisterDashboard(store) {
|
|
1739
|
+
const dashboardId = store.dashboardId();
|
|
1740
|
+
if (!dashboardId) {
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
this.dashboards.update(dashboards => {
|
|
1744
|
+
const newMap = new Map(dashboards);
|
|
1745
|
+
newMap.delete(dashboardId);
|
|
1746
|
+
return newMap;
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Get cell dimensions for a specific dashboard instance
|
|
1751
|
+
*/
|
|
1752
|
+
getDashboardDimensions(dashboardId) {
|
|
1753
|
+
const dashboard = this.dashboards().get(dashboardId);
|
|
1754
|
+
return dashboard?.dimensions() || { width: 100, height: 100 };
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Get all available dashboard dimensions (for widget lists to choose from)
|
|
1758
|
+
* Returns the first available dashboard's dimensions as fallback
|
|
1759
|
+
*/
|
|
1760
|
+
availableDimensions = computed(() => {
|
|
1761
|
+
const dashboardEntries = Array.from(this.dashboards().values());
|
|
1762
|
+
if (dashboardEntries.length === 0) {
|
|
1763
|
+
return { width: 100, height: 100 }; // fallback
|
|
1764
|
+
}
|
|
1765
|
+
// Return dimensions from first available dashboard with fallback for undefined
|
|
1766
|
+
return dashboardEntries[0].dimensions() || { width: 100, height: 100 };
|
|
1767
|
+
});
|
|
1768
|
+
/**
|
|
1769
|
+
* Start drag operation on the first available dashboard
|
|
1770
|
+
* (Widget lists need some dashboard to coordinate with during drag)
|
|
1771
|
+
*/
|
|
1772
|
+
startDrag(dragData) {
|
|
1773
|
+
const dashboardEntries = Array.from(this.dashboards().values());
|
|
1774
|
+
if (dashboardEntries.length > 0) {
|
|
1775
|
+
dashboardEntries[0].store.startDrag(dragData);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* End drag operation on all dashboards
|
|
1780
|
+
* (Safer to notify all in case drag state got distributed)
|
|
1781
|
+
*/
|
|
1782
|
+
endDrag() {
|
|
1783
|
+
for (const dashboard of this.dashboards().values()) {
|
|
1784
|
+
dashboard.store.endDrag();
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Get all registered dashboard IDs
|
|
1789
|
+
*/
|
|
1790
|
+
registeredDashboards = computed(() => Array.from(this.dashboards().keys()));
|
|
1791
|
+
/**
|
|
1792
|
+
* Get the number of registered dashboards
|
|
1793
|
+
*/
|
|
1794
|
+
dashboardCount = computed(() => this.dashboards().size);
|
|
1795
|
+
/**
|
|
1796
|
+
* Check if any dashboards are registered
|
|
1797
|
+
*/
|
|
1798
|
+
hasDashboards = computed(() => this.dashboards().size > 0);
|
|
1799
|
+
/**
|
|
1800
|
+
* Update registration for a dashboard store when its ID becomes available
|
|
1801
|
+
*/
|
|
1802
|
+
updateDashboardRegistration(store) {
|
|
1803
|
+
this.registerDashboard(store);
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Get grid configuration for a specific dashboard
|
|
1807
|
+
*/
|
|
1808
|
+
getDashboardGridConfig(dashboardId) {
|
|
1809
|
+
const dashboard = this.dashboards().get(dashboardId);
|
|
1810
|
+
if (!dashboard) {
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
const store = dashboard.store;
|
|
1814
|
+
return {
|
|
1815
|
+
rows: store.rows(),
|
|
1816
|
+
columns: store.columns(),
|
|
1817
|
+
gutterSize: store.gutterSize()
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Get the store instance for a specific dashboard ID
|
|
1822
|
+
*/
|
|
1823
|
+
getDashboardStore(dashboardId) {
|
|
1824
|
+
const dashboard = this.dashboards().get(dashboardId);
|
|
1825
|
+
return dashboard?.store || null;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Get all registered dashboards (for viewport service integration)
|
|
1829
|
+
*/
|
|
1830
|
+
getAllDashboards() {
|
|
1831
|
+
return this.dashboards();
|
|
1832
|
+
}
|
|
1833
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardBridgeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1834
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardBridgeService, providedIn: 'root' });
|
|
1835
|
+
}
|
|
1836
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardBridgeService, decorators: [{
|
|
1837
|
+
type: Injectable,
|
|
1838
|
+
args: [{ providedIn: 'root' }]
|
|
1839
|
+
}] });
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Internal component-scoped service that provides viewport-aware constraints for a single dashboard.
|
|
1843
|
+
* Each dashboard component gets its own instance of this service.
|
|
1844
|
+
*
|
|
1845
|
+
* This service is NOT part of the public API and should remain internal to the library.
|
|
1846
|
+
*/
|
|
1847
|
+
class DashboardViewportService {
|
|
1848
|
+
platformId = inject(PLATFORM_ID);
|
|
1849
|
+
destroyRef = inject(DestroyRef);
|
|
1850
|
+
store = inject(DashboardStore);
|
|
1851
|
+
viewportSize = signal({ width: 0, height: 0 });
|
|
1852
|
+
reservedSpace = signal(DEFAULT_RESERVED_SPACE);
|
|
1853
|
+
resizeObserver = null;
|
|
1854
|
+
constructor() {
|
|
1855
|
+
if (isPlatformBrowser(this.platformId)) {
|
|
1856
|
+
this.initializeViewportTracking();
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Initialize viewport size tracking using ResizeObserver on the window
|
|
1861
|
+
*/
|
|
1862
|
+
initializeViewportTracking() {
|
|
1863
|
+
// Use ResizeObserver on document.documentElement for accurate viewport tracking
|
|
1864
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
1865
|
+
if (entries.length > 0) {
|
|
1866
|
+
const entry = entries[0];
|
|
1867
|
+
const { inlineSize, blockSize } = entry.contentBoxSize[0];
|
|
1868
|
+
this.viewportSize.set({
|
|
1869
|
+
width: inlineSize,
|
|
1870
|
+
height: blockSize
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
this.resizeObserver.observe(document.documentElement);
|
|
1875
|
+
// Initial size
|
|
1876
|
+
this.viewportSize.set({
|
|
1877
|
+
width: window.innerWidth,
|
|
1878
|
+
height: window.innerHeight
|
|
1879
|
+
});
|
|
1880
|
+
// Cleanup on destroy
|
|
1881
|
+
this.destroyRef.onDestroy(() => {
|
|
1882
|
+
this.resizeObserver?.disconnect();
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Set reserved space that should be excluded from dashboard calculations
|
|
1887
|
+
* (e.g., toolbar height, widget list width, padding)
|
|
1888
|
+
*/
|
|
1889
|
+
setReservedSpace(space) {
|
|
1890
|
+
this.reservedSpace.set(space);
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Get current viewport size
|
|
1894
|
+
*/
|
|
1895
|
+
currentViewportSize = this.viewportSize.asReadonly();
|
|
1896
|
+
/**
|
|
1897
|
+
* Get current reserved space
|
|
1898
|
+
*/
|
|
1899
|
+
currentReservedSpace = this.reservedSpace.asReadonly();
|
|
1900
|
+
/**
|
|
1901
|
+
* Calculate available space for dashboard after accounting for reserved areas
|
|
1902
|
+
*/
|
|
1903
|
+
availableSpace = computed(() => {
|
|
1904
|
+
const viewport = this.viewportSize();
|
|
1905
|
+
const reserved = this.reservedSpace();
|
|
1906
|
+
return {
|
|
1907
|
+
width: Math.max(0, viewport.width - reserved.left - reserved.right),
|
|
1908
|
+
height: Math.max(0, viewport.height - reserved.top - reserved.bottom)
|
|
1909
|
+
};
|
|
1910
|
+
});
|
|
1911
|
+
/**
|
|
1912
|
+
* Calculate dashboard constraints for this dashboard instance
|
|
1913
|
+
*/
|
|
1914
|
+
constraints = computed(() => {
|
|
1915
|
+
const availableSize = this.availableSpace();
|
|
1916
|
+
// Get grid configuration from our component's store
|
|
1917
|
+
const rows = this.store.rows();
|
|
1918
|
+
const columns = this.store.columns();
|
|
1919
|
+
if (rows === 0 || columns === 0) {
|
|
1920
|
+
return {
|
|
1921
|
+
maxWidth: availableSize.width,
|
|
1922
|
+
maxHeight: availableSize.height,
|
|
1923
|
+
constrainedBy: 'none'
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
// Calculate aspect ratio
|
|
1927
|
+
const aspectRatio = columns / rows;
|
|
1928
|
+
// Calculate maximum size that fits within available space
|
|
1929
|
+
const maxWidthFromHeight = availableSize.height * aspectRatio;
|
|
1930
|
+
const maxHeightFromWidth = availableSize.width / aspectRatio;
|
|
1931
|
+
let maxWidth;
|
|
1932
|
+
let maxHeight;
|
|
1933
|
+
let constrainedBy;
|
|
1934
|
+
if (maxWidthFromHeight <= availableSize.width) {
|
|
1935
|
+
// Height is the limiting factor
|
|
1936
|
+
maxWidth = maxWidthFromHeight;
|
|
1937
|
+
maxHeight = availableSize.height;
|
|
1938
|
+
constrainedBy = 'height';
|
|
1939
|
+
}
|
|
1940
|
+
else {
|
|
1941
|
+
// Width is the limiting factor
|
|
1942
|
+
maxWidth = availableSize.width;
|
|
1943
|
+
maxHeight = maxHeightFromWidth;
|
|
1944
|
+
constrainedBy = 'width';
|
|
1945
|
+
}
|
|
1946
|
+
return {
|
|
1947
|
+
maxWidth: Math.max(0, maxWidth),
|
|
1948
|
+
maxHeight: Math.max(0, maxHeight),
|
|
1949
|
+
constrainedBy
|
|
1950
|
+
};
|
|
1951
|
+
});
|
|
1952
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardViewportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1953
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardViewportService });
|
|
1954
|
+
}
|
|
1955
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardViewportService, decorators: [{
|
|
1956
|
+
type: Injectable
|
|
1957
|
+
}], ctorParameters: () => [] });
|
|
1958
|
+
|
|
1959
|
+
// dashboard.component.ts
|
|
1960
|
+
//
|
|
1961
|
+
// A performant, modular, and fully reactive Angular dashboard container that orchestrates between
|
|
1962
|
+
// editing and viewing modes — with clean component separation and no external dependencies.
|
|
1963
|
+
class DashboardComponent {
|
|
1964
|
+
#store = inject(DashboardStore);
|
|
1965
|
+
#bridge = inject(DashboardBridgeService);
|
|
1966
|
+
#viewport = inject(DashboardViewportService);
|
|
1967
|
+
#destroyRef = inject(DestroyRef);
|
|
1968
|
+
// Public accessors for template
|
|
1969
|
+
store = this.#store;
|
|
1970
|
+
viewport = this.#viewport;
|
|
1971
|
+
// Component inputs
|
|
1972
|
+
dashboardData = input.required();
|
|
1973
|
+
editMode = input(false);
|
|
1974
|
+
reservedSpace = input();
|
|
1975
|
+
// Store signals - shared by both child components
|
|
1976
|
+
cells = this.#store.cells;
|
|
1977
|
+
// ViewChild references for export/import functionality
|
|
1978
|
+
dashboardEditor = viewChild(DashboardEditorComponent);
|
|
1979
|
+
dashboardViewer = viewChild(DashboardViewerComponent);
|
|
1980
|
+
// Track if we're in the middle of preserving states
|
|
1981
|
+
#isPreservingStates = false;
|
|
1982
|
+
// Track if component has been initialized
|
|
1983
|
+
#isInitialized = false;
|
|
1984
|
+
constructor() {
|
|
1985
|
+
// Cleanup registration when component is destroyed
|
|
1986
|
+
this.#destroyRef.onDestroy(() => {
|
|
1987
|
+
this.#bridge.unregisterDashboard(this.#store);
|
|
1988
|
+
});
|
|
1989
|
+
// Initialize from dashboardData
|
|
1990
|
+
effect(() => {
|
|
1991
|
+
const data = this.dashboardData();
|
|
1992
|
+
if (data) {
|
|
1993
|
+
this.#store.initializeFromDto(data);
|
|
1994
|
+
// Register with bridge service after dashboard ID is set
|
|
1995
|
+
this.#bridge.updateDashboardRegistration(this.#store);
|
|
1996
|
+
this.#isInitialized = true;
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1999
|
+
// Sync edit mode with store (without triggering state preservation)
|
|
2000
|
+
effect(() => {
|
|
2001
|
+
const editMode = this.editMode();
|
|
2002
|
+
untracked(() => {
|
|
2003
|
+
this.#store.setEditMode(editMode);
|
|
2004
|
+
});
|
|
2005
|
+
});
|
|
2006
|
+
// Sync reserved space input with viewport service
|
|
2007
|
+
effect(() => {
|
|
2008
|
+
const reserved = this.reservedSpace();
|
|
2009
|
+
if (reserved) {
|
|
2010
|
+
this.#viewport.setReservedSpace(reserved);
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
ngOnChanges(changes) {
|
|
2015
|
+
// Handle edit mode changes after initialization
|
|
2016
|
+
if (changes['editMode'] &&
|
|
2017
|
+
!changes['editMode'].firstChange &&
|
|
2018
|
+
this.#isInitialized) {
|
|
2019
|
+
const previousValue = changes['editMode'].previousValue;
|
|
2020
|
+
const currentValue = changes['editMode'].currentValue;
|
|
2021
|
+
if (previousValue !== currentValue) {
|
|
2022
|
+
// Preserve widget states before the mode switch
|
|
2023
|
+
this.#preserveWidgetStatesBeforeModeSwitch(previousValue);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
// Public export/import methods
|
|
2028
|
+
exportDashboard() {
|
|
2029
|
+
// Delegate to the active child component
|
|
2030
|
+
if (this.editMode()) {
|
|
2031
|
+
const editor = this.dashboardEditor();
|
|
2032
|
+
return editor ? editor.exportDashboard() : this.#store.exportDashboard();
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
const viewer = this.dashboardViewer();
|
|
2036
|
+
return viewer ? viewer.exportDashboard() : this.#store.exportDashboard();
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
loadDashboard(data) {
|
|
2040
|
+
this.#store.loadDashboard(data);
|
|
2041
|
+
}
|
|
2042
|
+
getCurrentDashboardData() {
|
|
2043
|
+
return this.exportDashboard();
|
|
2044
|
+
}
|
|
2045
|
+
clearDashboard() {
|
|
2046
|
+
this.#store.clearDashboard();
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Preserve widget states before switching modes by collecting live states
|
|
2050
|
+
* from the currently active component and updating the store.
|
|
2051
|
+
*/
|
|
2052
|
+
#preserveWidgetStatesBeforeModeSwitch(previousEditMode) {
|
|
2053
|
+
// Prevent re-entrant calls
|
|
2054
|
+
if (this.#isPreservingStates) {
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
this.#isPreservingStates = true;
|
|
2058
|
+
try {
|
|
2059
|
+
let currentWidgetStates = null;
|
|
2060
|
+
if (previousEditMode) {
|
|
2061
|
+
// Previously in edit mode, collect states from editor
|
|
2062
|
+
const editor = this.dashboardEditor();
|
|
2063
|
+
if (editor) {
|
|
2064
|
+
currentWidgetStates = editor.getCurrentWidgetStates();
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
else {
|
|
2068
|
+
// Previously in view mode, collect states from viewer
|
|
2069
|
+
const viewer = this.dashboardViewer();
|
|
2070
|
+
if (viewer) {
|
|
2071
|
+
currentWidgetStates = viewer.getCurrentWidgetStates();
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
// Update the store with the live widget states using untracked to avoid triggering effects
|
|
2075
|
+
if (currentWidgetStates && currentWidgetStates.size > 0) {
|
|
2076
|
+
untracked(() => {
|
|
2077
|
+
this.#store.updateAllWidgetStates(currentWidgetStates);
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
finally {
|
|
2082
|
+
this.#isPreservingStates = false;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2086
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: DashboardComponent, isStandalone: true, selector: "ngx-dashboard", inputs: { dashboardData: { classPropertyName: "dashboardData", publicName: "dashboardData", isSignal: true, isRequired: true, transformFunction: null }, editMode: { classPropertyName: "editMode", publicName: "editMode", isSignal: true, isRequired: false, transformFunction: null }, reservedSpace: { classPropertyName: "reservedSpace", publicName: "reservedSpace", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.--rows": "store.rows()", "style.--columns": "store.columns()", "style.--gutter-size": "store.gutterSize()", "style.--gutters": "store.columns() + 1", "class.is-edit-mode": "editMode()", "style.max-width.px": "viewport.constraints().maxWidth", "style.max-height.px": "viewport.constraints().maxHeight" } }, providers: [DashboardStore, DashboardViewportService], viewQueries: [{ propertyName: "dashboardEditor", first: true, predicate: DashboardEditorComponent, descendants: true, isSignal: true }, { propertyName: "dashboardViewer", first: true, predicate: DashboardViewerComponent, descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: "<!-- dashboard.component.html -->\r\n<div class=\"grid-container\">\r\n @if (editMode()) {\r\n <!-- Full editor with drag & drop capabilities -->\r\n <ngx-dashboard-editor\r\n [rows]=\"store.rows()\"\r\n [columns]=\"store.columns()\"\r\n [gutterSize]=\"store.gutterSize()\"\r\n ></ngx-dashboard-editor>\r\n } @else {\r\n <!-- Read-only viewer -->\r\n <ngx-dashboard-viewer\r\n [rows]=\"store.rows()\"\r\n [columns]=\"store.columns()\"\r\n [gutterSize]=\"store.gutterSize()\"\r\n ></ngx-dashboard-viewer>\r\n }\r\n</div>\r\n", styles: [":host{display:block;container-type:inline-size;box-sizing:border-box;aspect-ratio:var(--columns)/var(--rows);width:100%;height:auto}.grid-container{position:relative;width:100%;height:100%}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: DashboardViewerComponent, selector: "ngx-dashboard-viewer", inputs: ["rows", "columns", "gutterSize"] }, { kind: "component", type: DashboardEditorComponent, selector: "ngx-dashboard-editor", inputs: ["rows", "columns", "gutterSize"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2087
|
+
}
|
|
2088
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: DashboardComponent, decorators: [{
|
|
2089
|
+
type: Component,
|
|
2090
|
+
args: [{ selector: 'ngx-dashboard', standalone: true, imports: [CommonModule, DashboardViewerComponent, DashboardEditorComponent], providers: [DashboardStore, DashboardViewportService], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
2091
|
+
'[style.--rows]': 'store.rows()',
|
|
2092
|
+
'[style.--columns]': 'store.columns()',
|
|
2093
|
+
'[style.--gutter-size]': 'store.gutterSize()',
|
|
2094
|
+
'[style.--gutters]': 'store.columns() + 1',
|
|
2095
|
+
'[class.is-edit-mode]': 'editMode()',
|
|
2096
|
+
'[style.max-width.px]': 'viewport.constraints().maxWidth',
|
|
2097
|
+
'[style.max-height.px]': 'viewport.constraints().maxHeight',
|
|
2098
|
+
}, template: "<!-- dashboard.component.html -->\r\n<div class=\"grid-container\">\r\n @if (editMode()) {\r\n <!-- Full editor with drag & drop capabilities -->\r\n <ngx-dashboard-editor\r\n [rows]=\"store.rows()\"\r\n [columns]=\"store.columns()\"\r\n [gutterSize]=\"store.gutterSize()\"\r\n ></ngx-dashboard-editor>\r\n } @else {\r\n <!-- Read-only viewer -->\r\n <ngx-dashboard-viewer\r\n [rows]=\"store.rows()\"\r\n [columns]=\"store.columns()\"\r\n [gutterSize]=\"store.gutterSize()\"\r\n ></ngx-dashboard-viewer>\r\n }\r\n</div>\r\n", styles: [":host{display:block;container-type:inline-size;box-sizing:border-box;aspect-ratio:var(--columns)/var(--rows);width:100%;height:auto}.grid-container{position:relative;width:100%;height:100%}\n"] }]
|
|
2099
|
+
}], ctorParameters: () => [] });
|
|
2100
|
+
|
|
2101
|
+
// widget-list.component.ts
|
|
2102
|
+
class WidgetListComponent {
|
|
2103
|
+
#service = inject(DashboardService);
|
|
2104
|
+
#sanitizer = inject(DomSanitizer);
|
|
2105
|
+
#renderer = inject(Renderer2);
|
|
2106
|
+
#bridge = inject(DashboardBridgeService);
|
|
2107
|
+
activeWidget = signal(null);
|
|
2108
|
+
// Get grid cell dimensions from bridge service (uses first available dashboard)
|
|
2109
|
+
gridCellDimensions = this.#bridge.availableDimensions;
|
|
2110
|
+
widgets = computed(() => this.#service.widgetTypes().map((w) => ({
|
|
2111
|
+
...w.metadata,
|
|
2112
|
+
safeSvgIcon: this.#sanitizer.bypassSecurityTrustHtml(w.metadata.svgIcon),
|
|
2113
|
+
})));
|
|
2114
|
+
onDragStart(event, widget) {
|
|
2115
|
+
if (!event.dataTransfer)
|
|
2116
|
+
return;
|
|
2117
|
+
event.dataTransfer.effectAllowed = 'copy';
|
|
2118
|
+
const dragData = {
|
|
2119
|
+
kind: 'widget',
|
|
2120
|
+
content: widget,
|
|
2121
|
+
};
|
|
2122
|
+
this.activeWidget.set(widget.widgetTypeid);
|
|
2123
|
+
this.#bridge.startDrag(dragData);
|
|
2124
|
+
// Create custom drag ghost for better UX
|
|
2125
|
+
const ghost = this.#createDragGhost(widget.svgIcon);
|
|
2126
|
+
document.body.appendChild(ghost);
|
|
2127
|
+
// Force reflow to ensure element is rendered
|
|
2128
|
+
const _reflow = ghost.offsetHeight;
|
|
2129
|
+
event.dataTransfer.setDragImage(ghost, 10, 10);
|
|
2130
|
+
// Delay removal to ensure browser has time to snapshot the drag image
|
|
2131
|
+
setTimeout(() => ghost.remove());
|
|
2132
|
+
}
|
|
2133
|
+
onDragEnd() {
|
|
2134
|
+
this.activeWidget.set(null);
|
|
2135
|
+
this.#bridge.endDrag();
|
|
2136
|
+
}
|
|
2137
|
+
#createDragGhost(svgIcon) {
|
|
2138
|
+
const dimensions = this.gridCellDimensions();
|
|
2139
|
+
const el = this.#renderer.createElement('div');
|
|
2140
|
+
el.classList.add('drag-ghost');
|
|
2141
|
+
if (svgIcon) {
|
|
2142
|
+
const iconWrapper = this.#renderer.createElement('div');
|
|
2143
|
+
this.#renderer.addClass(iconWrapper, 'icon');
|
|
2144
|
+
iconWrapper.innerHTML = svgIcon;
|
|
2145
|
+
const svg = iconWrapper.querySelector('svg');
|
|
2146
|
+
if (svg) {
|
|
2147
|
+
svg.setAttribute('width', `${dimensions.width * 0.8}`);
|
|
2148
|
+
svg.setAttribute('height', `${dimensions.height * 0.8}`);
|
|
2149
|
+
svg.setAttribute('fill', '#000000');
|
|
2150
|
+
svg.setAttribute('opacity', '0.3');
|
|
2151
|
+
}
|
|
2152
|
+
el.appendChild(iconWrapper);
|
|
2153
|
+
}
|
|
2154
|
+
Object.assign(el.style, {
|
|
2155
|
+
boxSizing: 'border-box',
|
|
2156
|
+
position: 'absolute',
|
|
2157
|
+
top: '0',
|
|
2158
|
+
left: '0',
|
|
2159
|
+
width: `${dimensions.width}px`,
|
|
2160
|
+
height: `${dimensions.height}px`,
|
|
2161
|
+
zIndex: '9999',
|
|
2162
|
+
margin: '0',
|
|
2163
|
+
background: 'white',
|
|
2164
|
+
border: '1px solid #ccc',
|
|
2165
|
+
borderRadius: '4px',
|
|
2166
|
+
pointerEvents: 'none',
|
|
2167
|
+
display: 'flex',
|
|
2168
|
+
alignItems: 'center',
|
|
2169
|
+
justifyContent: 'center',
|
|
2170
|
+
opacity: 0.7,
|
|
2171
|
+
});
|
|
2172
|
+
return el;
|
|
2173
|
+
}
|
|
2174
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: WidgetListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2175
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", type: WidgetListComponent, isStandalone: true, selector: "ngx-dashboard-widget-list", ngImport: i0, template: "<!-- widget-list.component.html -->\r\n<div class=\"widget-list\">\r\n @for (widget of widgets(); track widget.widgetTypeid) {\r\n <div\r\n class=\"widget-list-item\"\r\n [class.active]=\"activeWidget() === widget.widgetTypeid\"\r\n draggable=\"true\"\r\n (dragstart)=\"onDragStart($event, widget)\"\r\n (dragend)=\"onDragEnd()\"\r\n >\r\n <div class=\"icon\" [innerHTML]=\"widget.safeSvgIcon\"></div>\r\n <div class=\"content\">\r\n <strong>{{ widget.name }}</strong>\r\n <small>{{ widget.description }}</small>\r\n </div>\r\n </div>\r\n }\r\n</div>\r\n", styles: [":host{background-color:var(--mat-sys-surface-container)}.widget-list{display:flex;flex-direction:column;gap:.5em}.widget-list-item{display:flex;align-items:start;gap:.75em;background:#fff;border:1px solid #ccc;padding:.75em;border-radius:4px;cursor:grab;box-shadow:0 1px 2px #0000000d;transition:background .2s}.widget-list-item .icon{width:24px;height:24px;flex-shrink:0}.widget-list-item .icon svg{width:100%;height:100%;display:block}.widget-list-item .content{display:flex;flex-direction:column;line-height:1.2}.widget-list-item:hover{background:#f0f0f0}.widget-list-item:active{cursor:grabbing}.widget-list-item.active{background:#e6f7ff;border-color:#91d5ff}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2176
|
+
}
|
|
2177
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.6", ngImport: i0, type: WidgetListComponent, decorators: [{
|
|
2178
|
+
type: Component,
|
|
2179
|
+
args: [{ selector: 'ngx-dashboard-widget-list', standalone: true, imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- widget-list.component.html -->\r\n<div class=\"widget-list\">\r\n @for (widget of widgets(); track widget.widgetTypeid) {\r\n <div\r\n class=\"widget-list-item\"\r\n [class.active]=\"activeWidget() === widget.widgetTypeid\"\r\n draggable=\"true\"\r\n (dragstart)=\"onDragStart($event, widget)\"\r\n (dragend)=\"onDragEnd()\"\r\n >\r\n <div class=\"icon\" [innerHTML]=\"widget.safeSvgIcon\"></div>\r\n <div class=\"content\">\r\n <strong>{{ widget.name }}</strong>\r\n <small>{{ widget.description }}</small>\r\n </div>\r\n </div>\r\n }\r\n</div>\r\n", styles: [":host{background-color:var(--mat-sys-surface-container)}.widget-list{display:flex;flex-direction:column;gap:.5em}.widget-list-item{display:flex;align-items:start;gap:.75em;background:#fff;border:1px solid #ccc;padding:.75em;border-radius:4px;cursor:grab;box-shadow:0 1px 2px #0000000d;transition:background .2s}.widget-list-item .icon{width:24px;height:24px;flex-shrink:0}.widget-list-item .icon svg{width:100%;height:100%;display:block}.widget-list-item .content{display:flex;flex-direction:column;line-height:1.2}.widget-list-item:hover{background:#f0f0f0}.widget-list-item:active{cursor:grabbing}.widget-list-item.active{background:#e6f7ff;border-color:#91d5ff}\n"] }]
|
|
2180
|
+
}] });
|
|
2181
|
+
|
|
2182
|
+
/*
|
|
2183
|
+
* Public API Surface of ngx-dashboard
|
|
2184
|
+
*/
|
|
2185
|
+
// Main dashboard components
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* Generated bundle index. Do not edit.
|
|
2189
|
+
*/
|
|
2190
|
+
|
|
2191
|
+
export { CELL_SETTINGS_DIALOG_PROVIDER, CellIdUtils, CellSettingsDialogProvider, DEFAULT_RESERVED_SPACE, DashboardComponent, DashboardEditorComponent, DashboardService, DashboardViewerComponent, DefaultCellSettingsDialogProvider, WidgetListComponent, createDefaultDashboard, createEmptyDashboard, createFactoryFromComponent };
|
|
2192
|
+
//# sourceMappingURL=dragonworks-ngx-dashboard.mjs.map
|