@dragonworks/ngx-dashboard 20.0.4 → 20.0.6
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/ng-package.json +7 -0
- package/package.json +34 -45
- package/src/lib/__tests__/dashboard-component-widget-state-integration.spec.ts +537 -0
- package/src/lib/cell/__tests__/cell-resize.component.spec.ts +442 -0
- package/src/lib/cell/__tests__/cell.component.spec.ts +541 -0
- package/src/lib/cell/cell-context-menu.component.ts +138 -0
- package/src/lib/cell/cell-context-menu.service.ts +36 -0
- package/src/lib/cell/cell.component.html +37 -0
- package/src/lib/cell/cell.component.scss +198 -0
- package/src/lib/cell/cell.component.ts +375 -0
- package/src/lib/dashboard/dashboard.component.html +18 -0
- package/src/lib/dashboard/dashboard.component.scss +17 -0
- package/src/lib/dashboard/dashboard.component.ts +187 -0
- package/src/lib/dashboard-editor/dashboard-editor.component.html +57 -0
- package/src/lib/dashboard-editor/dashboard-editor.component.scss +87 -0
- package/src/lib/dashboard-editor/dashboard-editor.component.ts +219 -0
- package/src/lib/dashboard-viewer/__tests__/dashboard-viewer.component.spec.ts +258 -0
- package/src/lib/dashboard-viewer/dashboard-viewer.component.html +20 -0
- package/src/lib/dashboard-viewer/dashboard-viewer.component.scss +50 -0
- package/src/lib/dashboard-viewer/dashboard-viewer.component.ts +70 -0
- package/src/lib/drop-zone/__tests__/drop-zone.component.spec.ts +465 -0
- package/src/lib/drop-zone/drop-zone.component.html +20 -0
- package/src/lib/drop-zone/drop-zone.component.scss +67 -0
- package/src/lib/drop-zone/drop-zone.component.ts +122 -0
- package/src/lib/internal-widgets/unknown-widget/unknown-widget.component.ts +72 -0
- package/src/lib/models/cell-data.ts +13 -0
- package/src/lib/models/cell-dialog.ts +7 -0
- package/src/lib/models/cell-id.ts +85 -0
- package/src/lib/models/cell-position.ts +15 -0
- package/src/lib/models/dashboard-data.dto.ts +44 -0
- package/src/lib/models/dashboard-data.utils.ts +49 -0
- package/src/lib/models/drag-data.ts +6 -0
- package/src/lib/models/index.ts +11 -0
- package/src/lib/models/reserved-space.ts +24 -0
- package/src/lib/models/widget-factory.ts +33 -0
- package/src/lib/models/widget-id.ts +70 -0
- package/src/lib/models/widget.ts +21 -0
- package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.component.ts +127 -0
- package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.provider.ts +15 -0
- package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.tokens.ts +20 -0
- package/src/lib/providers/cell-settings-dialog/default-cell-settings-dialog.provider.ts +32 -0
- package/src/lib/providers/cell-settings-dialog/index.ts +3 -0
- package/src/lib/providers/index.ts +1 -0
- package/src/lib/services/__tests__/dashboard-bridge.service.spec.ts +220 -0
- package/src/lib/services/__tests__/dashboard-viewport.service.spec.ts +362 -0
- package/src/lib/services/dashboard-bridge.service.ts +155 -0
- package/src/lib/services/dashboard-viewport.service.ts +148 -0
- package/src/lib/services/dashboard.service.ts +62 -0
- package/src/lib/store/__tests__/dashboard-store-collision-detection.spec.ts +756 -0
- package/src/lib/store/__tests__/dashboard-store-computed-properties.spec.ts +974 -0
- package/src/lib/store/__tests__/dashboard-store-drag-drop.spec.ts +279 -0
- package/src/lib/store/__tests__/dashboard-store-export-import.spec.ts +780 -0
- package/src/lib/store/__tests__/dashboard-store-grid-config.spec.ts +128 -0
- package/src/lib/store/__tests__/dashboard-store-query-methods.spec.ts +229 -0
- package/src/lib/store/__tests__/dashboard-store-resize-operations.spec.ts +652 -0
- package/src/lib/store/__tests__/dashboard-store-widget-management.spec.ts +461 -0
- package/src/lib/store/__tests__/dashboard-store-widget-state-preservation.spec.ts +369 -0
- package/src/lib/store/dashboard-store.ts +239 -0
- package/src/lib/store/features/drag-drop.feature.ts +140 -0
- package/src/lib/store/features/grid-config.feature.ts +43 -0
- package/src/lib/store/features/resize.feature.ts +140 -0
- package/src/lib/store/features/utils/collision.utils.ts +89 -0
- package/src/lib/store/features/utils/grid-query-internal.utils.ts +37 -0
- package/src/lib/store/features/utils/resize.utils.ts +165 -0
- package/src/lib/store/features/widget-management.feature.ts +158 -0
- package/src/lib/styles/_dashboard-grid-vars.scss +11 -0
- package/src/lib/widget-list/__tests__/widget-list-bridge-integration.spec.ts +137 -0
- package/src/lib/widget-list/widget-list.component.html +22 -0
- package/src/lib/widget-list/widget-list.component.scss +154 -0
- package/src/lib/widget-list/widget-list.component.ts +106 -0
- package/src/public-api.ts +21 -0
- package/src/test-setup.ts +10 -0
- package/tsconfig.lib.json +15 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +14 -0
- package/fesm2022/dragonworks-ngx-dashboard.mjs +0 -2192
- package/fesm2022/dragonworks-ngx-dashboard.mjs.map +0 -1
- package/index.d.ts +0 -678
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export type CellContextMenuItem =
|
|
4
|
+
| {
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: string; // Material icon name (e.g., 'edit', 'settings', 'delete')
|
|
7
|
+
action: () => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
divider?: false;
|
|
10
|
+
}
|
|
11
|
+
| {
|
|
12
|
+
divider: true;
|
|
13
|
+
label?: never;
|
|
14
|
+
icon?: never;
|
|
15
|
+
action?: never;
|
|
16
|
+
disabled?: never;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class CellContextMenuService {
|
|
21
|
+
#activeMenu = signal<{
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
items: CellContextMenuItem[];
|
|
25
|
+
} | null>(null);
|
|
26
|
+
|
|
27
|
+
activeMenu = this.#activeMenu.asReadonly();
|
|
28
|
+
|
|
29
|
+
show(x: number, y: number, items: CellContextMenuItem[]) {
|
|
30
|
+
this.#activeMenu.set({ x, y, items });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
hide() {
|
|
34
|
+
this.#activeMenu.set(null);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!-- cell.component.html -->
|
|
2
|
+
<div
|
|
3
|
+
class="cell"
|
|
4
|
+
[class.is-resizing]="isResizing()"
|
|
5
|
+
[class.flat]="flat() === true"
|
|
6
|
+
[draggable]="draggable()"
|
|
7
|
+
(dragstart)="onDragStart($event)"
|
|
8
|
+
(dragend)="onDragEnd()"
|
|
9
|
+
(contextmenu)="onContextMenu($event)"
|
|
10
|
+
>
|
|
11
|
+
<div class="content-area">
|
|
12
|
+
<ng-template #container></ng-template>
|
|
13
|
+
</div>
|
|
14
|
+
@if (isEditMode() && !isDragging()) {
|
|
15
|
+
<!-- Right resize handle -->
|
|
16
|
+
<div
|
|
17
|
+
class="resize-handle resize-handle--right"
|
|
18
|
+
(mousedown)="onResizeStart($event, 'horizontal')"
|
|
19
|
+
>
|
|
20
|
+
<div class="resize-handle-line"></div>
|
|
21
|
+
</div>
|
|
22
|
+
<!-- Bottom resize handle -->
|
|
23
|
+
<div
|
|
24
|
+
class="resize-handle resize-handle--bottom"
|
|
25
|
+
(mousedown)="onResizeStart($event, 'vertical')"
|
|
26
|
+
>
|
|
27
|
+
<div class="resize-handle-line"></div>
|
|
28
|
+
</div>
|
|
29
|
+
}
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
@if (isResizing()) {
|
|
33
|
+
<div class="resize-preview">
|
|
34
|
+
{{ resizeData()?.previewColSpan ?? colSpan() }} ×
|
|
35
|
+
{{ resizeData()?.previewRowSpan ?? rowSpan() }}
|
|
36
|
+
</div>
|
|
37
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// cell.component.sccs
|
|
2
|
+
|
|
3
|
+
:host {
|
|
4
|
+
display: block;
|
|
5
|
+
width: 100%;
|
|
6
|
+
height: 100%;
|
|
7
|
+
position: relative;
|
|
8
|
+
z-index: 1;
|
|
9
|
+
container-type: inline-size; // Enable container queries
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// When any drag is active, disable pointer events on non-dragging cells
|
|
13
|
+
// This allows drag events to pass through to drop zones underneath
|
|
14
|
+
:host(.drag-active):not(.is-dragging) {
|
|
15
|
+
pointer-events: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
:host(.is-dragging) {
|
|
19
|
+
z-index: 100; // While dragging
|
|
20
|
+
opacity: 0.5;
|
|
21
|
+
pointer-events: none; // Allow drag events to pass through to drop zones
|
|
22
|
+
|
|
23
|
+
// Ensure content area also doesn't block events during drag
|
|
24
|
+
.content-area {
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
:host(:hover) .resize-handle {
|
|
30
|
+
opacity: 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.cell {
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
36
|
+
// background-color: white;
|
|
37
|
+
border-radius: 4px;
|
|
38
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
|
39
|
+
padding: 0;
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
position: relative; // for overlay positioning
|
|
43
|
+
container-type: inline-size; // enable container queries on cell
|
|
44
|
+
|
|
45
|
+
&:hover {
|
|
46
|
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
|
47
|
+
transform: translateY(-2px);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Flat mode styles
|
|
51
|
+
&.flat {
|
|
52
|
+
box-shadow: none;
|
|
53
|
+
border: none; //1px solid #e0e0e0;
|
|
54
|
+
|
|
55
|
+
&:hover {
|
|
56
|
+
box-shadow: none;
|
|
57
|
+
transform: none;
|
|
58
|
+
border-color: #bdbdbd;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// prevent text selection during resize
|
|
63
|
+
&.resizing {
|
|
64
|
+
user-select: none;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.content-area {
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: 100%;
|
|
71
|
+
overflow: auto;
|
|
72
|
+
// Ensure widget hover events work independently of cell hover state
|
|
73
|
+
pointer-events: auto;
|
|
74
|
+
position: relative;
|
|
75
|
+
z-index: 1;
|
|
76
|
+
|
|
77
|
+
// Isolate widget content from parent hover effects
|
|
78
|
+
&:hover {
|
|
79
|
+
// Reset any inherited hover effects that might interfere with widget hover
|
|
80
|
+
transform: initial;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ensure flat mode doesn't interfere with widget hover events
|
|
85
|
+
.cell.flat .content-area {
|
|
86
|
+
// Explicitly ensure widget hover works in flat mode
|
|
87
|
+
pointer-events: auto;
|
|
88
|
+
|
|
89
|
+
&:hover {
|
|
90
|
+
// Prevent flat mode hover reset from affecting widget content
|
|
91
|
+
transform: initial;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
///
|
|
96
|
+
/// Resize stuff
|
|
97
|
+
///
|
|
98
|
+
.resize-handle {
|
|
99
|
+
position: absolute;
|
|
100
|
+
z-index: 20;
|
|
101
|
+
// transition: opacity 0.2s ease;
|
|
102
|
+
|
|
103
|
+
&--right {
|
|
104
|
+
cursor: col-resize;
|
|
105
|
+
width: 16px;
|
|
106
|
+
height: 100%;
|
|
107
|
+
right: -8px;
|
|
108
|
+
top: 0;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
justify-content: center;
|
|
112
|
+
opacity: 0;
|
|
113
|
+
|
|
114
|
+
&:hover {
|
|
115
|
+
opacity: 1;
|
|
116
|
+
|
|
117
|
+
.resize-handle-line {
|
|
118
|
+
background-color: var(--mat-sys-primary-container);
|
|
119
|
+
// background-color: #2196f3;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
&--bottom {
|
|
125
|
+
cursor: row-resize;
|
|
126
|
+
width: 100%;
|
|
127
|
+
height: 16px;
|
|
128
|
+
bottom: -8px;
|
|
129
|
+
left: 0;
|
|
130
|
+
display: flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
justify-content: center;
|
|
133
|
+
opacity: 0;
|
|
134
|
+
|
|
135
|
+
&:hover {
|
|
136
|
+
opacity: 1;
|
|
137
|
+
|
|
138
|
+
.resize-handle-line {
|
|
139
|
+
background-color: var(--mat-sys-primary-container);
|
|
140
|
+
// background-color: #2196f3;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.resize-handle-line {
|
|
147
|
+
background-color: rgba(0, 0, 0, 0.1);
|
|
148
|
+
// transition: background-color 0.2s ease;
|
|
149
|
+
|
|
150
|
+
.resize-handle--right & {
|
|
151
|
+
width: 8px;
|
|
152
|
+
height: 40px;
|
|
153
|
+
border-radius: 2px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.resize-handle--bottom & {
|
|
157
|
+
width: 40px;
|
|
158
|
+
height: 8px;
|
|
159
|
+
border-radius: 2px;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.resize-preview {
|
|
164
|
+
position: absolute;
|
|
165
|
+
top: 50%;
|
|
166
|
+
left: 50%;
|
|
167
|
+
transform: translate(-50%, -50%);
|
|
168
|
+
background-color: var(--mat-sys-primary);
|
|
169
|
+
color: var(--mat-sys-on-primary);
|
|
170
|
+
|
|
171
|
+
// background: rgba(33, 150, 243, 0.9);
|
|
172
|
+
// color: white;
|
|
173
|
+
padding: 4px 12px;
|
|
174
|
+
border-radius: 4px;
|
|
175
|
+
font-size: 14px;
|
|
176
|
+
font-weight: 500;
|
|
177
|
+
pointer-events: none;
|
|
178
|
+
z-index: 30;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.cell.is-resizing {
|
|
182
|
+
opacity: 0.6;
|
|
183
|
+
.resize-handle {
|
|
184
|
+
background-color: rgba(33, 150, 243, 0.5);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Global cursor classes for resize operations
|
|
189
|
+
// These are applied to document.body to ensure cursor shows everywhere during resize
|
|
190
|
+
:root {
|
|
191
|
+
.cursor-col-resize {
|
|
192
|
+
cursor: col-resize !important;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.cursor-row-resize {
|
|
196
|
+
cursor: row-resize !important;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// cell.component.ts
|
|
2
|
+
import {
|
|
3
|
+
Component,
|
|
4
|
+
ComponentRef,
|
|
5
|
+
computed,
|
|
6
|
+
DestroyRef,
|
|
7
|
+
effect,
|
|
8
|
+
inject,
|
|
9
|
+
input,
|
|
10
|
+
model,
|
|
11
|
+
signal,
|
|
12
|
+
ViewContainerRef,
|
|
13
|
+
output,
|
|
14
|
+
ElementRef,
|
|
15
|
+
viewChild,
|
|
16
|
+
Renderer2,
|
|
17
|
+
ChangeDetectionStrategy,
|
|
18
|
+
} from '@angular/core';
|
|
19
|
+
// RxJS removed: Using native DOM events with Renderer2 for performance
|
|
20
|
+
// - Context menu uses template event binding (element-specific)
|
|
21
|
+
// - Resize uses conditional document listeners (only when actively resizing)
|
|
22
|
+
// - Eliminates N*mousemove performance issue with @HostListener approach
|
|
23
|
+
import { CommonModule } from '@angular/common';
|
|
24
|
+
import {
|
|
25
|
+
CellId,
|
|
26
|
+
CellIdUtils,
|
|
27
|
+
WidgetId,
|
|
28
|
+
DragData,
|
|
29
|
+
WidgetFactory,
|
|
30
|
+
Widget,
|
|
31
|
+
} from '../models';
|
|
32
|
+
import { DashboardStore } from '../store/dashboard-store';
|
|
33
|
+
import { CellDisplayData } from '../models';
|
|
34
|
+
import { CELL_SETTINGS_DIALOG_PROVIDER } from '../providers/cell-settings-dialog';
|
|
35
|
+
import { CellContextMenuService, CellContextMenuItem } from './cell-context-menu.service';
|
|
36
|
+
|
|
37
|
+
@Component({
|
|
38
|
+
selector: 'lib-cell',
|
|
39
|
+
standalone: true,
|
|
40
|
+
imports: [CommonModule],
|
|
41
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
42
|
+
templateUrl: './cell.component.html',
|
|
43
|
+
styleUrl: './cell.component.scss',
|
|
44
|
+
host: {
|
|
45
|
+
'[style.grid-row]': 'gridRowStyle()',
|
|
46
|
+
'[style.grid-column]': 'gridColumnStyle()',
|
|
47
|
+
'[class.is-dragging]': 'isDragging()',
|
|
48
|
+
'[class.drag-active]': 'isDragActive()',
|
|
49
|
+
'[class.flat]': 'flat() === true',
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
export class CellComponent {
|
|
53
|
+
widgetId = input.required<WidgetId>(); // Unique widget instance identifier
|
|
54
|
+
cellId = input.required<CellId>(); // Current grid position
|
|
55
|
+
widgetFactory = input<WidgetFactory | undefined>(undefined);
|
|
56
|
+
widgetState = input<unknown | undefined>(undefined);
|
|
57
|
+
isEditMode = input<boolean>(false);
|
|
58
|
+
flat = input<boolean | undefined>(undefined);
|
|
59
|
+
|
|
60
|
+
row = model.required<number>();
|
|
61
|
+
column = model.required<number>();
|
|
62
|
+
rowSpan = input<number>(1);
|
|
63
|
+
colSpan = input<number>(1);
|
|
64
|
+
draggable = input<boolean>(false);
|
|
65
|
+
|
|
66
|
+
dragStart = output<DragData>();
|
|
67
|
+
dragEnd = output<void>();
|
|
68
|
+
|
|
69
|
+
edit = output<WidgetId>();
|
|
70
|
+
delete = output<WidgetId>();
|
|
71
|
+
settings = output<{ id: WidgetId; flat: boolean }>();
|
|
72
|
+
resizeStart = output<{ cellId: CellId; direction: 'horizontal' | 'vertical' }>();
|
|
73
|
+
resizeMove = output<{
|
|
74
|
+
cellId: CellId;
|
|
75
|
+
direction: 'horizontal' | 'vertical';
|
|
76
|
+
delta: number;
|
|
77
|
+
}>();
|
|
78
|
+
resizeEnd = output<{ cellId: CellId; apply: boolean }>();
|
|
79
|
+
|
|
80
|
+
private container = viewChild.required<ElementRef, ViewContainerRef>(
|
|
81
|
+
'container',
|
|
82
|
+
{ read: ViewContainerRef }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
readonly #store = inject(DashboardStore);
|
|
86
|
+
readonly #dialogProvider = inject(CELL_SETTINGS_DIALOG_PROVIDER);
|
|
87
|
+
readonly #destroyRef = inject(DestroyRef);
|
|
88
|
+
readonly #renderer = inject(Renderer2);
|
|
89
|
+
readonly #contextMenuService = inject(CellContextMenuService, {
|
|
90
|
+
optional: true,
|
|
91
|
+
});
|
|
92
|
+
readonly #elementRef = inject(ElementRef);
|
|
93
|
+
|
|
94
|
+
#widgetRef?: ComponentRef<Widget>;
|
|
95
|
+
|
|
96
|
+
// Document event listeners cleanup function
|
|
97
|
+
// Performance: Only created when actively resizing, not for every cell
|
|
98
|
+
#documentListeners?: () => void;
|
|
99
|
+
|
|
100
|
+
isDragging = signal(false);
|
|
101
|
+
|
|
102
|
+
readonly gridRowStyle = computed(
|
|
103
|
+
() => `${this.row()} / span ${this.rowSpan()}`
|
|
104
|
+
);
|
|
105
|
+
readonly gridColumnStyle = computed(
|
|
106
|
+
() => `${this.column()} / span ${this.colSpan()}`
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
isResizing = computed(() => {
|
|
110
|
+
const resizeData = this.#store.resizeData();
|
|
111
|
+
return resizeData
|
|
112
|
+
? CellIdUtils.equals(resizeData.cellId, this.cellId())
|
|
113
|
+
: false;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
isDragActive = computed(() => !!this.#store.dragData());
|
|
117
|
+
|
|
118
|
+
resizeData = this.#store.resizeData;
|
|
119
|
+
gridCellDimensions = this.#store.gridCellDimensions;
|
|
120
|
+
private resizeDirection = signal<'horizontal' | 'vertical' | null>(null);
|
|
121
|
+
private resizeStartPos = signal({ x: 0, y: 0 });
|
|
122
|
+
|
|
123
|
+
constructor() {
|
|
124
|
+
// widget creation - triggers when factory or state changes
|
|
125
|
+
effect(() => {
|
|
126
|
+
const factory = this.widgetFactory();
|
|
127
|
+
const state = this.widgetState();
|
|
128
|
+
const container = this.container();
|
|
129
|
+
|
|
130
|
+
if (factory && container) {
|
|
131
|
+
// Clean up previous widget
|
|
132
|
+
this.#widgetRef?.destroy();
|
|
133
|
+
|
|
134
|
+
// Create new widget
|
|
135
|
+
container.clear();
|
|
136
|
+
try {
|
|
137
|
+
this.#widgetRef = factory.createInstance(container, state);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('Failed to create widget:', error);
|
|
140
|
+
this.#widgetRef = undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Auto cleanup on destroy
|
|
146
|
+
this.#destroyRef.onDestroy(() => {
|
|
147
|
+
this.#widgetRef?.destroy();
|
|
148
|
+
this.#widgetRef = undefined;
|
|
149
|
+
// Clean up any active document listeners
|
|
150
|
+
this.#cleanupDocumentListeners();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Setup document-level event listeners for resize operations
|
|
156
|
+
* Performance: Only creates listeners when actively resizing (not for every cell)
|
|
157
|
+
* Angular-idiomatic: Uses Renderer2 for dynamic listener management
|
|
158
|
+
*/
|
|
159
|
+
private setupDocumentListeners(): void {
|
|
160
|
+
// Clean up any existing listeners first
|
|
161
|
+
this.#cleanupDocumentListeners();
|
|
162
|
+
|
|
163
|
+
// Create document listeners with proper cleanup functions
|
|
164
|
+
const unlistenMove = this.#renderer.listen(
|
|
165
|
+
'document',
|
|
166
|
+
'mousemove',
|
|
167
|
+
this.handleResizeMove.bind(this)
|
|
168
|
+
);
|
|
169
|
+
const unlistenUp = this.#renderer.listen(
|
|
170
|
+
'document',
|
|
171
|
+
'mouseup',
|
|
172
|
+
this.handleResizeEnd.bind(this)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Store cleanup function for later use
|
|
176
|
+
this.#documentListeners = () => {
|
|
177
|
+
unlistenMove();
|
|
178
|
+
unlistenUp();
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Clean up document-level event listeners
|
|
184
|
+
* Called on resize end and component destruction
|
|
185
|
+
*/
|
|
186
|
+
#cleanupDocumentListeners(): void {
|
|
187
|
+
if (this.#documentListeners) {
|
|
188
|
+
this.#documentListeners();
|
|
189
|
+
this.#documentListeners = undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setPosition(row: number, column: number): void {
|
|
194
|
+
this.row.set(row);
|
|
195
|
+
this.column.set(column);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
onDragStart(event: DragEvent): void {
|
|
199
|
+
if (!event.dataTransfer) return;
|
|
200
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
201
|
+
|
|
202
|
+
const cell = {
|
|
203
|
+
cellId: this.cellId(),
|
|
204
|
+
widgetId: this.widgetId(),
|
|
205
|
+
row: this.row(),
|
|
206
|
+
col: this.column(),
|
|
207
|
+
rowSpan: this.rowSpan(),
|
|
208
|
+
colSpan: this.colSpan(),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const content: DragData = { kind: 'cell', content: cell };
|
|
212
|
+
this.dragStart.emit(content);
|
|
213
|
+
this.isDragging.set(true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
onDragEnd(/*_: DragEvent*/): void {
|
|
217
|
+
this.isDragging.set(false);
|
|
218
|
+
this.dragEnd.emit();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Handle context menu events (called from template)
|
|
223
|
+
* Performance: Element-specific event binding, not document-level
|
|
224
|
+
* Angular-idiomatic: Template event binding instead of fromEvent
|
|
225
|
+
*/
|
|
226
|
+
onContextMenu(event: MouseEvent): void {
|
|
227
|
+
if (!this.isEditMode() || !this.#contextMenuService) return;
|
|
228
|
+
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
|
|
232
|
+
const items: CellContextMenuItem[] = [
|
|
233
|
+
{
|
|
234
|
+
label: 'Edit Widget',
|
|
235
|
+
icon: 'edit',
|
|
236
|
+
action: () => this.onEdit(),
|
|
237
|
+
disabled: !this.canEdit(),
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
label: 'Settings',
|
|
241
|
+
icon: 'settings',
|
|
242
|
+
action: () => this.onSettings(),
|
|
243
|
+
},
|
|
244
|
+
{ divider: true },
|
|
245
|
+
{
|
|
246
|
+
label: 'Delete',
|
|
247
|
+
icon: 'delete',
|
|
248
|
+
action: () => this.onDelete(),
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
// Position menu at exact mouse coordinates
|
|
253
|
+
this.#contextMenuService.show(event.clientX, event.clientY, items);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
canEdit(): boolean {
|
|
257
|
+
if (this.#widgetRef?.instance?.dashboardEditState) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
onEdit(): void {
|
|
264
|
+
this.edit.emit(this.widgetId());
|
|
265
|
+
|
|
266
|
+
// Call the widget's edit dialog method if it exists
|
|
267
|
+
if (this.#widgetRef?.instance?.dashboardEditState) {
|
|
268
|
+
this.#widgetRef.instance.dashboardEditState();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
onDelete(): void {
|
|
273
|
+
this.delete.emit(this.widgetId());
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async onSettings(): Promise<void> {
|
|
277
|
+
const currentSettings: CellDisplayData = {
|
|
278
|
+
id: CellIdUtils.toString(this.cellId()), // Use cellId for display position
|
|
279
|
+
flat: this.flat(),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const result = await this.#dialogProvider.openCellSettings(
|
|
284
|
+
currentSettings
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
if (result) {
|
|
288
|
+
this.settings.emit({
|
|
289
|
+
id: this.widgetId(),
|
|
290
|
+
flat: result.flat ?? false,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error('Error opening cell settings dialog:', error);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Start resize operation and setup document listeners
|
|
300
|
+
* Performance: Only THIS cell creates document listeners when actively resizing
|
|
301
|
+
* RxJS-free: Uses Renderer2 for dynamic listener management
|
|
302
|
+
*/
|
|
303
|
+
onResizeStart(event: MouseEvent, direction: 'horizontal' | 'vertical'): void {
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
event.stopPropagation();
|
|
306
|
+
|
|
307
|
+
this.resizeDirection.set(direction);
|
|
308
|
+
this.resizeStartPos.set({ x: event.clientX, y: event.clientY });
|
|
309
|
+
this.resizeStart.emit({ cellId: this.cellId(), direction });
|
|
310
|
+
|
|
311
|
+
// Setup document listeners only when actively resizing
|
|
312
|
+
this.setupDocumentListeners();
|
|
313
|
+
|
|
314
|
+
const cursorClass =
|
|
315
|
+
direction === 'horizontal' ? 'cursor-col-resize' : 'cursor-row-resize';
|
|
316
|
+
this.#renderer.addClass(document.body, cursorClass);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handle resize move events (called from document listener)
|
|
321
|
+
* Performance: Only called for the actively resizing cell
|
|
322
|
+
* Bound method: Maintains component context without arrow functions
|
|
323
|
+
*/
|
|
324
|
+
private handleResizeMove(event: MouseEvent): void {
|
|
325
|
+
const direction = this.resizeDirection();
|
|
326
|
+
if (!direction) return;
|
|
327
|
+
|
|
328
|
+
const startPos = this.resizeStartPos();
|
|
329
|
+
const cellSize = this.gridCellDimensions();
|
|
330
|
+
|
|
331
|
+
if (direction === 'horizontal') {
|
|
332
|
+
const deltaX = event.clientX - startPos.x;
|
|
333
|
+
const deltaSpan = Math.round(deltaX / cellSize.width);
|
|
334
|
+
this.resizeMove.emit({ cellId: this.cellId(), direction, delta: deltaSpan });
|
|
335
|
+
} else {
|
|
336
|
+
const deltaY = event.clientY - startPos.y;
|
|
337
|
+
const deltaSpan = Math.round(deltaY / cellSize.height);
|
|
338
|
+
this.resizeMove.emit({ cellId: this.cellId(), direction, delta: deltaSpan });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Handle resize end events (called from document listener)
|
|
344
|
+
* Performance: Cleans up document listeners immediately after resize
|
|
345
|
+
* State cleanup: Resets resize direction to stop further event processing
|
|
346
|
+
*/
|
|
347
|
+
private handleResizeEnd(): void {
|
|
348
|
+
this.#renderer.removeClass(document.body, 'cursor-col-resize');
|
|
349
|
+
this.#renderer.removeClass(document.body, 'cursor-row-resize');
|
|
350
|
+
|
|
351
|
+
// Clean up document listeners immediately
|
|
352
|
+
this.#cleanupDocumentListeners();
|
|
353
|
+
|
|
354
|
+
this.resizeEnd.emit({ cellId: this.cellId(), apply: true });
|
|
355
|
+
this.resizeDirection.set(null);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get the current widget state by calling dashboardGetState() on the widget instance.
|
|
360
|
+
* Used during dashboard export to get live widget state instead of stale stored state.
|
|
361
|
+
*/
|
|
362
|
+
getCurrentWidgetState(): unknown | undefined {
|
|
363
|
+
if (!this.#widgetRef?.instance) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Call dashboardGetState() if the widget implements it
|
|
368
|
+
if (typeof this.#widgetRef.instance.dashboardGetState === 'function') {
|
|
369
|
+
return this.#widgetRef.instance.dashboardGetState();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Fall back to stored state if widget doesn't implement dashboardGetState
|
|
373
|
+
return this.widgetState();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- dashboard.component.html -->
|
|
2
|
+
<div class="grid-container">
|
|
3
|
+
@if (editMode()) {
|
|
4
|
+
<!-- Full editor with drag & drop capabilities -->
|
|
5
|
+
<ngx-dashboard-editor
|
|
6
|
+
[rows]="store.rows()"
|
|
7
|
+
[columns]="store.columns()"
|
|
8
|
+
[gutterSize]="store.gutterSize()"
|
|
9
|
+
></ngx-dashboard-editor>
|
|
10
|
+
} @else {
|
|
11
|
+
<!-- Read-only viewer -->
|
|
12
|
+
<ngx-dashboard-viewer
|
|
13
|
+
[rows]="store.rows()"
|
|
14
|
+
[columns]="store.columns()"
|
|
15
|
+
[gutterSize]="store.gutterSize()"
|
|
16
|
+
></ngx-dashboard-viewer>
|
|
17
|
+
}
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* dashboard.component.scss */
|
|
2
|
+
:host {
|
|
3
|
+
display: block;
|
|
4
|
+
container-type: inline-size;
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
aspect-ratio: var(--columns) / var(--rows);
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: auto;
|
|
9
|
+
|
|
10
|
+
// background-color: var(--mat-sys-surface-container);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.grid-container {
|
|
14
|
+
position: relative;
|
|
15
|
+
width: 100%;
|
|
16
|
+
height: 100%;
|
|
17
|
+
}
|