@mintplayer/ng-bootstrap 21.28.0 → 21.29.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.
|
@@ -0,0 +1,1332 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { input, output, viewChild, ChangeDetectionStrategy, Component, contentChildren, computed, effect, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
|
3
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
4
|
+
import { html, unsafeCSS, LitElement, nothing } from 'lit';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One tile inside a `<bs-tile-manager>`.
|
|
8
|
+
*
|
|
9
|
+
* The component captures two `TemplateRef`s — one for the header (a child
|
|
10
|
+
* `<bs-tile-header>`) and one for the body (everything else) — which the
|
|
11
|
+
* manager projects into named slots on the underlying `<mp-tile-manager>`
|
|
12
|
+
* web component (`${id}-header` and `${id}-content`).
|
|
13
|
+
*/
|
|
14
|
+
class BsTileComponent {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.id = input.required(...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
|
|
17
|
+
this.position = input.required(...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
|
|
18
|
+
this.disableMove = input(false, ...(ngDevMode ? [{ debugName: "disableMove" }] : /* istanbul ignore next */ []));
|
|
19
|
+
this.disableResize = input(false, ...(ngDevMode ? [{ debugName: "disableResize" }] : /* istanbul ignore next */ []));
|
|
20
|
+
this.label = input(null, ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
21
|
+
this.positionChange = output();
|
|
22
|
+
this.headerTpl = viewChild.required('headerTpl');
|
|
23
|
+
this.contentTpl = viewChild.required('contentTpl');
|
|
24
|
+
}
|
|
25
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsTileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
26
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.11", type: BsTileComponent, isStandalone: true, selector: "bs-tile", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: true, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: true, transformFunction: null }, disableMove: { classPropertyName: "disableMove", publicName: "disableMove", isSignal: true, isRequired: false, transformFunction: null }, disableResize: { classPropertyName: "disableResize", publicName: "disableResize", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { positionChange: "positionChange" }, viewQueries: [{ propertyName: "headerTpl", first: true, predicate: ["headerTpl"], descendants: true, isSignal: true }, { propertyName: "contentTpl", first: true, predicate: ["contentTpl"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
27
|
+
<ng-template #headerTpl>
|
|
28
|
+
<ng-content select="bs-tile-header"></ng-content>
|
|
29
|
+
</ng-template>
|
|
30
|
+
<ng-template #contentTpl>
|
|
31
|
+
<ng-content></ng-content>
|
|
32
|
+
</ng-template>
|
|
33
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
34
|
+
}
|
|
35
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsTileComponent, decorators: [{
|
|
36
|
+
type: Component,
|
|
37
|
+
args: [{
|
|
38
|
+
selector: 'bs-tile',
|
|
39
|
+
template: `
|
|
40
|
+
<ng-template #headerTpl>
|
|
41
|
+
<ng-content select="bs-tile-header"></ng-content>
|
|
42
|
+
</ng-template>
|
|
43
|
+
<ng-template #contentTpl>
|
|
44
|
+
<ng-content></ng-content>
|
|
45
|
+
</ng-template>
|
|
46
|
+
`,
|
|
47
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
48
|
+
}]
|
|
49
|
+
}], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: true }] }], position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: true }] }], disableMove: [{ type: i0.Input, args: [{ isSignal: true, alias: "disableMove", required: false }] }], disableResize: [{ type: i0.Input, args: [{ isSignal: true, alias: "disableResize", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], positionChange: [{ type: i0.Output, args: ["positionChange"] }], headerTpl: [{ type: i0.ViewChild, args: ['headerTpl', { isSignal: true }] }], contentTpl: [{ type: i0.ViewChild, args: ['contentTpl', { isSignal: true }] }] } });
|
|
50
|
+
|
|
51
|
+
class BsTileManagerComponent {
|
|
52
|
+
constructor() {
|
|
53
|
+
this.columnCount = input(null, ...(ngDevMode ? [{ debugName: "columnCount" }] : /* istanbul ignore next */ []));
|
|
54
|
+
this.minColumnWidth = input('200px', ...(ngDevMode ? [{ debugName: "minColumnWidth" }] : /* istanbul ignore next */ []));
|
|
55
|
+
this.minRowHeight = input('8rem', ...(ngDevMode ? [{ debugName: "minRowHeight" }] : /* istanbul ignore next */ []));
|
|
56
|
+
this.gap = input('0.5rem', ...(ngDevMode ? [{ debugName: "gap" }] : /* istanbul ignore next */ []));
|
|
57
|
+
this.dragMode = input('header', ...(ngDevMode ? [{ debugName: "dragMode" }] : /* istanbul ignore next */ []));
|
|
58
|
+
this.resizeMode = input('hover', ...(ngDevMode ? [{ debugName: "resizeMode" }] : /* istanbul ignore next */ []));
|
|
59
|
+
this.animateReflow = input(true, ...(ngDevMode ? [{ debugName: "animateReflow" }] : /* istanbul ignore next */ []));
|
|
60
|
+
this.label = input(null, ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
61
|
+
this.layoutChange = output();
|
|
62
|
+
this.gestureBlocked = output();
|
|
63
|
+
this.tiles = contentChildren(BsTileComponent, ...(ngDevMode ? [{ debugName: "tiles" }] : /* istanbul ignore next */ []));
|
|
64
|
+
this.managerRef = viewChild('manager', ...(ngDevMode ? [{ debugName: "managerRef" }] : /* istanbul ignore next */ []));
|
|
65
|
+
this.columnCountAttr = computed(() => {
|
|
66
|
+
const c = this.columnCount();
|
|
67
|
+
return c && c > 0 ? String(c) : null;
|
|
68
|
+
}, ...(ngDevMode ? [{ debugName: "columnCountAttr" }] : /* istanbul ignore next */ []));
|
|
69
|
+
// Snapshot of Angular tile state, derived from contentChildren signals.
|
|
70
|
+
// Re-runs whenever any tile's id, position, disableMove, disableResize, or label changes.
|
|
71
|
+
// contentChildren() can surface a child before Angular has finished binding its
|
|
72
|
+
// required inputs; reading them then throws NG0950. flatMap-with-try lets us
|
|
73
|
+
// skip the not-yet-bound entry — the effect re-runs once binding completes.
|
|
74
|
+
this.tilesSnapshot = computed(() => this.tiles().flatMap((t) => {
|
|
75
|
+
try {
|
|
76
|
+
return [{
|
|
77
|
+
id: t.id(),
|
|
78
|
+
position: t.position(),
|
|
79
|
+
disableMove: t.disableMove(),
|
|
80
|
+
disableResize: t.disableResize(),
|
|
81
|
+
label: t.label(),
|
|
82
|
+
}];
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}), ...(ngDevMode ? [{ debugName: "tilesSnapshot" }] : /* istanbul ignore next */ []));
|
|
88
|
+
effect(() => {
|
|
89
|
+
const snapshot = this.tilesSnapshot();
|
|
90
|
+
const ref = this.managerRef();
|
|
91
|
+
if (!ref)
|
|
92
|
+
return;
|
|
93
|
+
ref.nativeElement.tiles = snapshot;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
ngAfterViewInit() {
|
|
97
|
+
// First push happens via the constructor effect once the view is alive.
|
|
98
|
+
}
|
|
99
|
+
/** Returns the current layout. Mirrors `BsDockManagerComponent.captureLayout()`. */
|
|
100
|
+
captureLayout() {
|
|
101
|
+
const ref = this.managerRef();
|
|
102
|
+
if (!ref)
|
|
103
|
+
return [];
|
|
104
|
+
return Array.from(ref.nativeElement.tiles ?? []).map((t) => ({
|
|
105
|
+
id: t.id,
|
|
106
|
+
position: { ...t.position },
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
onLayoutChange(event) {
|
|
110
|
+
const detail = event.detail;
|
|
111
|
+
if (detail)
|
|
112
|
+
this.layoutChange.emit(detail);
|
|
113
|
+
}
|
|
114
|
+
onPositionChange(event) {
|
|
115
|
+
const detail = event.detail;
|
|
116
|
+
if (!detail)
|
|
117
|
+
return;
|
|
118
|
+
const tile = this.tiles().find((t) => t.id() === detail.id);
|
|
119
|
+
tile?.positionChange.emit(detail.position);
|
|
120
|
+
}
|
|
121
|
+
onGestureBlocked(event) {
|
|
122
|
+
const detail = event.detail;
|
|
123
|
+
if (detail)
|
|
124
|
+
this.gestureBlocked.emit(detail);
|
|
125
|
+
}
|
|
126
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsTileManagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
127
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", type: BsTileManagerComponent, isStandalone: true, selector: "bs-tile-manager", inputs: { columnCount: { classPropertyName: "columnCount", publicName: "columnCount", isSignal: true, isRequired: false, transformFunction: null }, minColumnWidth: { classPropertyName: "minColumnWidth", publicName: "minColumnWidth", isSignal: true, isRequired: false, transformFunction: null }, minRowHeight: { classPropertyName: "minRowHeight", publicName: "minRowHeight", isSignal: true, isRequired: false, transformFunction: null }, gap: { classPropertyName: "gap", publicName: "gap", isSignal: true, isRequired: false, transformFunction: null }, dragMode: { classPropertyName: "dragMode", publicName: "dragMode", isSignal: true, isRequired: false, transformFunction: null }, resizeMode: { classPropertyName: "resizeMode", publicName: "resizeMode", isSignal: true, isRequired: false, transformFunction: null }, animateReflow: { classPropertyName: "animateReflow", publicName: "animateReflow", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { layoutChange: "layoutChange", gestureBlocked: "gestureBlocked" }, queries: [{ propertyName: "tiles", predicate: BsTileComponent, isSignal: true }], viewQueries: [{ propertyName: "managerRef", first: true, predicate: ["manager"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
128
|
+
<mp-tile-manager
|
|
129
|
+
#manager
|
|
130
|
+
class="bs-tile-manager"
|
|
131
|
+
[attr.column-count]="columnCountAttr()"
|
|
132
|
+
[attr.min-column-width]="minColumnWidth()"
|
|
133
|
+
[attr.min-row-height]="minRowHeight()"
|
|
134
|
+
[attr.gap]="gap()"
|
|
135
|
+
[attr.drag-mode]="dragMode()"
|
|
136
|
+
[attr.resize-mode]="resizeMode()"
|
|
137
|
+
[attr.animate-reflow]="animateReflow() ? '' : null"
|
|
138
|
+
[attr.label]="label()"
|
|
139
|
+
(tilelayoutchange)="onLayoutChange($event)"
|
|
140
|
+
(tilepositionchange)="onPositionChange($event)"
|
|
141
|
+
(tilegestureblocked)="onGestureBlocked($event)"
|
|
142
|
+
>
|
|
143
|
+
@for (tile of tiles(); track tile.id()) {
|
|
144
|
+
<div [attr.slot]="tile.id() + '-header'" class="bs-tile-slot">
|
|
145
|
+
<ng-container *ngTemplateOutlet="tile.headerTpl()"></ng-container>
|
|
146
|
+
</div>
|
|
147
|
+
<div [attr.slot]="tile.id() + '-content'" class="bs-tile-slot">
|
|
148
|
+
<ng-container *ngTemplateOutlet="tile.contentTpl()"></ng-container>
|
|
149
|
+
</div>
|
|
150
|
+
}
|
|
151
|
+
</mp-tile-manager>
|
|
152
|
+
`, isInline: true, styles: [":host{display:block;width:100%}.bs-tile-manager{display:block;width:100%}.bs-tile-slot{display:contents}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
153
|
+
}
|
|
154
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsTileManagerComponent, decorators: [{
|
|
155
|
+
type: Component,
|
|
156
|
+
args: [{ selector: 'bs-tile-manager', template: `
|
|
157
|
+
<mp-tile-manager
|
|
158
|
+
#manager
|
|
159
|
+
class="bs-tile-manager"
|
|
160
|
+
[attr.column-count]="columnCountAttr()"
|
|
161
|
+
[attr.min-column-width]="minColumnWidth()"
|
|
162
|
+
[attr.min-row-height]="minRowHeight()"
|
|
163
|
+
[attr.gap]="gap()"
|
|
164
|
+
[attr.drag-mode]="dragMode()"
|
|
165
|
+
[attr.resize-mode]="resizeMode()"
|
|
166
|
+
[attr.animate-reflow]="animateReflow() ? '' : null"
|
|
167
|
+
[attr.label]="label()"
|
|
168
|
+
(tilelayoutchange)="onLayoutChange($event)"
|
|
169
|
+
(tilepositionchange)="onPositionChange($event)"
|
|
170
|
+
(tilegestureblocked)="onGestureBlocked($event)"
|
|
171
|
+
>
|
|
172
|
+
@for (tile of tiles(); track tile.id()) {
|
|
173
|
+
<div [attr.slot]="tile.id() + '-header'" class="bs-tile-slot">
|
|
174
|
+
<ng-container *ngTemplateOutlet="tile.headerTpl()"></ng-container>
|
|
175
|
+
</div>
|
|
176
|
+
<div [attr.slot]="tile.id() + '-content'" class="bs-tile-slot">
|
|
177
|
+
<ng-container *ngTemplateOutlet="tile.contentTpl()"></ng-container>
|
|
178
|
+
</div>
|
|
179
|
+
}
|
|
180
|
+
</mp-tile-manager>
|
|
181
|
+
`, imports: [NgTemplateOutlet], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;width:100%}.bs-tile-manager{display:block;width:100%}.bs-tile-slot{display:contents}\n"] }]
|
|
182
|
+
}], ctorParameters: () => [], propDecorators: { columnCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "columnCount", required: false }] }], minColumnWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minColumnWidth", required: false }] }], minRowHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minRowHeight", required: false }] }], gap: [{ type: i0.Input, args: [{ isSignal: true, alias: "gap", required: false }] }], dragMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragMode", required: false }] }], resizeMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "resizeMode", required: false }] }], animateReflow: [{ type: i0.Input, args: [{ isSignal: true, alias: "animateReflow", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], layoutChange: [{ type: i0.Output, args: ["layoutChange"] }], gestureBlocked: [{ type: i0.Output, args: ["gestureBlocked"] }], tiles: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => BsTileComponent), { isSignal: true }] }], managerRef: [{ type: i0.ViewChild, args: ['manager', { isSignal: true }] }] } });
|
|
183
|
+
|
|
184
|
+
class BsTileHeaderComponent {
|
|
185
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsTileHeaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
186
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: BsTileHeaderComponent, isStandalone: true, selector: "bs-tile-header", ngImport: i0, template: `<ng-content></ng-content>`, isInline: true, styles: [":host{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;font-size:.875rem;font-weight:500;color:var(--bs-body-color, inherit);background:linear-gradient(to bottom,rgba(var(--bs-primary-rgb, 13, 110, 253),.1),rgba(var(--bs-primary-rgb, 13, 110, 253),.04));border-bottom:1px solid var(--bs-border-color, #dee2e6);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
187
|
+
}
|
|
188
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsTileHeaderComponent, decorators: [{
|
|
189
|
+
type: Component,
|
|
190
|
+
args: [{ selector: 'bs-tile-header', template: `<ng-content></ng-content>`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;font-size:.875rem;font-weight:500;color:var(--bs-body-color, inherit);background:linear-gradient(to bottom,rgba(var(--bs-primary-rgb, 13, 110, 253),.1),rgba(var(--bs-primary-rgb, 13, 110, 253),.04));border-bottom:1px solid var(--bs-border-color, #dee2e6);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}\n"] }]
|
|
191
|
+
}] });
|
|
192
|
+
|
|
193
|
+
function rectsOverlap(a, b) {
|
|
194
|
+
return (a.colStart < b.colStart + b.colSpan &&
|
|
195
|
+
b.colStart < a.colStart + a.colSpan &&
|
|
196
|
+
a.rowStart < b.rowStart + b.rowSpan &&
|
|
197
|
+
b.rowStart < a.rowStart + a.rowSpan);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Vertical-compact packer. The pinned tile's rect is the user's gesture; all
|
|
202
|
+
* other tiles flow around it (locked tiles stay put; movable tiles get pushed
|
|
203
|
+
* out of the way and gravity-compacted upward).
|
|
204
|
+
*
|
|
205
|
+
* Returns `blocked: true` if the pinned rect overlaps a locked tile or no
|
|
206
|
+
* valid layout exists; the caller should snap back and not commit.
|
|
207
|
+
*/
|
|
208
|
+
function pack(tiles, pinned, columnCount) {
|
|
209
|
+
const pinnedTile = tiles.find((t) => t.id === pinned.id);
|
|
210
|
+
if (!pinnedTile) {
|
|
211
|
+
throw new Error(`pack: pinned tile "${pinned.id}" is not in the tiles array`);
|
|
212
|
+
}
|
|
213
|
+
const pinnedPlacement = { id: pinned.id, position: { ...pinned.rect } };
|
|
214
|
+
const lockedPlacements = tiles
|
|
215
|
+
.filter((t) => t.locked && t.id !== pinned.id)
|
|
216
|
+
.map((t) => ({ id: t.id, position: { ...t.position } }));
|
|
217
|
+
const lockedOverlap = lockedPlacements.some((l) => rectsOverlap(l.position, pinnedPlacement.position));
|
|
218
|
+
if (lockedOverlap) {
|
|
219
|
+
return { layout: snapshotFromInput(tiles), blocked: true };
|
|
220
|
+
}
|
|
221
|
+
const fixed = [pinnedPlacement, ...lockedPlacements];
|
|
222
|
+
const movables = tiles
|
|
223
|
+
.filter((t) => !t.locked && t.id !== pinned.id)
|
|
224
|
+
.slice()
|
|
225
|
+
.sort((a, b) => {
|
|
226
|
+
const dRow = a.position.rowStart - b.position.rowStart;
|
|
227
|
+
return dRow !== 0 ? dRow : a.position.colStart - b.position.colStart;
|
|
228
|
+
});
|
|
229
|
+
const placedMovables = movables.reduce((acc, t) => {
|
|
230
|
+
const all = [...fixed, ...acc];
|
|
231
|
+
const placed = placeTile(t, all, columnCount);
|
|
232
|
+
return placed ? [...acc, placed] : acc;
|
|
233
|
+
}, []);
|
|
234
|
+
if (placedMovables.length !== movables.length) {
|
|
235
|
+
return { layout: snapshotFromInput(tiles), blocked: true };
|
|
236
|
+
}
|
|
237
|
+
const compacted = compactStable(placedMovables, fixed, columnCount);
|
|
238
|
+
const placementById = new Map([pinnedPlacement, ...lockedPlacements, ...compacted].map((p) => [p.id, p.position]));
|
|
239
|
+
// Preserve the input tile order in the output snapshot — keeps layout
|
|
240
|
+
// identity stable across pack() calls with the same input.
|
|
241
|
+
const layout = tiles.map((t) => ({
|
|
242
|
+
id: t.id,
|
|
243
|
+
position: placementById.get(t.id) ?? { ...t.position },
|
|
244
|
+
}));
|
|
245
|
+
return { layout, blocked: false };
|
|
246
|
+
}
|
|
247
|
+
function snapshotFromInput(tiles) {
|
|
248
|
+
return tiles.map((t) => ({ id: t.id, position: { ...t.position } }));
|
|
249
|
+
}
|
|
250
|
+
function placeTile(tile, obstacles, columnCount) {
|
|
251
|
+
const { colSpan, rowSpan } = tile.position;
|
|
252
|
+
if (colSpan > columnCount)
|
|
253
|
+
return null;
|
|
254
|
+
const inPlaceFits = !obstacles.some((o) => rectsOverlap(tile.position, o.position));
|
|
255
|
+
if (inPlaceFits) {
|
|
256
|
+
return { id: tile.id, position: { ...tile.position } };
|
|
257
|
+
}
|
|
258
|
+
const candidate = firstFit({ colSpan, rowSpan }, obstacles, columnCount);
|
|
259
|
+
return candidate ? { id: tile.id, position: candidate } : null;
|
|
260
|
+
}
|
|
261
|
+
function firstFit(size, obstacles, columnCount) {
|
|
262
|
+
const lastRow = obstacles.reduce((max, o) => Math.max(max, o.position.rowStart + o.position.rowSpan - 1), 0);
|
|
263
|
+
// Worst case: stack right after the bottommost obstacle.
|
|
264
|
+
const rowsToTry = lastRow + size.rowSpan;
|
|
265
|
+
const cols = columnCount - size.colSpan + 1;
|
|
266
|
+
if (cols < 1)
|
|
267
|
+
return null;
|
|
268
|
+
// Imperative loops here are deliberate — this runs on every pointermove
|
|
269
|
+
// during a drag (live reflow) and the array-allocation patterns previously
|
|
270
|
+
// used here generated GC pressure visible as drag jank.
|
|
271
|
+
for (let rowStart = 1; rowStart <= rowsToTry; rowStart++) {
|
|
272
|
+
for (let c = 0; c < cols; c++) {
|
|
273
|
+
const colStart = c + 1;
|
|
274
|
+
let collides = false;
|
|
275
|
+
for (let i = 0; i < obstacles.length; i++) {
|
|
276
|
+
if (rectsOverlap({ rowStart, colStart, colSpan: size.colSpan, rowSpan: size.rowSpan }, obstacles[i].position)) {
|
|
277
|
+
collides = true;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (!collides) {
|
|
282
|
+
return { rowStart, colStart, colSpan: size.colSpan, rowSpan: size.rowSpan };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
function compactStable(placements, fixed, columnCount) {
|
|
289
|
+
return iterate(placements, fixed, columnCount, 10);
|
|
290
|
+
}
|
|
291
|
+
function iterate(placements, fixed, columnCount, iterations) {
|
|
292
|
+
if (iterations <= 0)
|
|
293
|
+
return placements;
|
|
294
|
+
const { result, changed } = compactOnce(placements, fixed, columnCount);
|
|
295
|
+
return changed ? iterate(result, fixed, columnCount, iterations - 1) : result;
|
|
296
|
+
}
|
|
297
|
+
function compactOnce(placements, fixed, columnCount) {
|
|
298
|
+
// Process in current visual order: top-to-bottom, then left-to-right.
|
|
299
|
+
const ordered = placements
|
|
300
|
+
.slice()
|
|
301
|
+
.sort((a, b) => {
|
|
302
|
+
const dRow = a.position.rowStart - b.position.rowStart;
|
|
303
|
+
return dRow !== 0 ? dRow : a.position.colStart - b.position.colStart;
|
|
304
|
+
});
|
|
305
|
+
return ordered.reduce((acc, p) => {
|
|
306
|
+
const others = [...fixed, ...acc.result.filter((r) => r.id !== p.id)];
|
|
307
|
+
const slid = slideUp(p.position, others);
|
|
308
|
+
const changed = slid.rowStart !== p.position.rowStart;
|
|
309
|
+
return {
|
|
310
|
+
result: [...acc.result, { id: p.id, position: slid }],
|
|
311
|
+
changed: acc.changed || changed,
|
|
312
|
+
};
|
|
313
|
+
}, { result: [], changed: false });
|
|
314
|
+
}
|
|
315
|
+
function slideUp(pos, others) {
|
|
316
|
+
if (pos.rowStart <= 1)
|
|
317
|
+
return pos;
|
|
318
|
+
// Same hot path as firstFit — keep imperative for GC-pressure reasons.
|
|
319
|
+
for (let rowStart = 1; rowStart < pos.rowStart; rowStart++) {
|
|
320
|
+
let collides = false;
|
|
321
|
+
for (let i = 0; i < others.length; i++) {
|
|
322
|
+
if (rectsOverlap({ ...pos, rowStart }, others[i].position)) {
|
|
323
|
+
collides = true;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!collides)
|
|
328
|
+
return { ...pos, rowStart };
|
|
329
|
+
}
|
|
330
|
+
return pos;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// AUTO-GENERATED — do not edit by hand.
|
|
334
|
+
// Source: mint-tile-manager.element.html + mint-tile-manager.element.scss
|
|
335
|
+
// Regenerate with the codegen-wc Nx target.
|
|
336
|
+
const template = html `<!-- Tile shells are rendered dynamically by render(). This file exists so the
|
|
337
|
+
codegen-wc target produces a \`styles\` export from the sibling .scss; the
|
|
338
|
+
\`template\` export it generates is intentionally unused. -->`;
|
|
339
|
+
const styles = unsafeCSS(`:host {
|
|
340
|
+
display: block;
|
|
341
|
+
position: relative;
|
|
342
|
+
box-sizing: border-box;
|
|
343
|
+
width: 100%;
|
|
344
|
+
contain: layout paint style;
|
|
345
|
+
--bs-tile-radius: 0.5rem;
|
|
346
|
+
--bs-tile-bg: var(--bs-body-bg, #fff);
|
|
347
|
+
--bs-tile-border: var(--bs-border-color, #dee2e6);
|
|
348
|
+
--bs-tile-shadow: var(--bs-box-shadow-sm, 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075));
|
|
349
|
+
--bs-tile-handle-color: var(--bs-secondary, #6c757d);
|
|
350
|
+
--bs-tile-blocked-color: var(--bs-danger, #dc3545);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.tile-grid {
|
|
354
|
+
display: grid;
|
|
355
|
+
grid-auto-rows: var(--mp-tile-row-height, 8rem);
|
|
356
|
+
gap: var(--mp-tile-gap, 0.5rem);
|
|
357
|
+
width: 100%;
|
|
358
|
+
position: relative;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.tile {
|
|
362
|
+
position: relative;
|
|
363
|
+
display: flex;
|
|
364
|
+
flex-direction: column;
|
|
365
|
+
background: var(--bs-tile-bg);
|
|
366
|
+
border: 1px solid var(--bs-tile-border);
|
|
367
|
+
border-radius: var(--bs-tile-radius);
|
|
368
|
+
box-shadow: var(--bs-tile-shadow);
|
|
369
|
+
overflow: hidden;
|
|
370
|
+
min-width: 0;
|
|
371
|
+
min-height: 0;
|
|
372
|
+
transition: transform 150ms cubic-bezier(0.2, 0, 0, 1);
|
|
373
|
+
will-change: transform;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.tile[data-dragging=true] {
|
|
377
|
+
z-index: 5;
|
|
378
|
+
cursor: grabbing;
|
|
379
|
+
box-shadow: var(--bs-box-shadow, 0 0.5rem 1rem rgba(0, 0, 0, 0.15));
|
|
380
|
+
transition: none;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.tile[data-resizing=true] {
|
|
384
|
+
z-index: 4;
|
|
385
|
+
transition: none;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.tile[data-blocked=true] {
|
|
389
|
+
outline: 2px solid var(--bs-tile-blocked-color);
|
|
390
|
+
outline-offset: -2px;
|
|
391
|
+
animation: tile-shake 200ms ease-in-out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@keyframes tile-shake {
|
|
395
|
+
0%, 100% {
|
|
396
|
+
transform: translateX(0);
|
|
397
|
+
}
|
|
398
|
+
25% {
|
|
399
|
+
transform: translateX(-3px);
|
|
400
|
+
}
|
|
401
|
+
75% {
|
|
402
|
+
transform: translateX(3px);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
.tile[data-pressing=true] {
|
|
406
|
+
filter: brightness(0.96);
|
|
407
|
+
transition: filter 100ms ease-out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.tile__header-shell {
|
|
411
|
+
flex: 0 0 auto;
|
|
412
|
+
user-select: none;
|
|
413
|
+
-webkit-user-select: none;
|
|
414
|
+
-webkit-touch-callout: none;
|
|
415
|
+
cursor: grab;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.tile[data-drag-mode=off] .tile__header-shell,
|
|
419
|
+
.tile[data-locked-move=true] .tile__header-shell {
|
|
420
|
+
cursor: default;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.tile__content-shell {
|
|
424
|
+
flex: 1 1 auto;
|
|
425
|
+
min-width: 0;
|
|
426
|
+
min-height: 0;
|
|
427
|
+
overflow: auto;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.tile__resize-side,
|
|
431
|
+
.tile__resize-bottom,
|
|
432
|
+
.tile__resize-corner {
|
|
433
|
+
position: absolute;
|
|
434
|
+
z-index: 2;
|
|
435
|
+
background: transparent;
|
|
436
|
+
touch-action: none;
|
|
437
|
+
-webkit-user-select: none;
|
|
438
|
+
user-select: none;
|
|
439
|
+
opacity: 0;
|
|
440
|
+
transition: opacity 120ms ease-out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.tile:hover .tile__resize-side,
|
|
444
|
+
.tile:hover .tile__resize-bottom,
|
|
445
|
+
.tile:hover .tile__resize-corner,
|
|
446
|
+
:host([resize-mode=always]) .tile__resize-side,
|
|
447
|
+
:host([resize-mode=always]) .tile__resize-bottom,
|
|
448
|
+
:host([resize-mode=always]) .tile__resize-corner {
|
|
449
|
+
opacity: 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
@media (pointer: coarse) {
|
|
453
|
+
.tile__resize-side,
|
|
454
|
+
.tile__resize-bottom,
|
|
455
|
+
.tile__resize-corner {
|
|
456
|
+
opacity: 1;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
.tile__resize-side {
|
|
460
|
+
right: -2px;
|
|
461
|
+
top: 25%;
|
|
462
|
+
bottom: 25%;
|
|
463
|
+
width: 6px;
|
|
464
|
+
cursor: ew-resize;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.tile__resize-bottom {
|
|
468
|
+
left: 25%;
|
|
469
|
+
right: 25%;
|
|
470
|
+
bottom: -2px;
|
|
471
|
+
height: 6px;
|
|
472
|
+
cursor: ns-resize;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.tile__resize-corner {
|
|
476
|
+
right: -2px;
|
|
477
|
+
bottom: -2px;
|
|
478
|
+
width: 14px;
|
|
479
|
+
height: 14px;
|
|
480
|
+
cursor: nwse-resize;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.tile__resize-corner::before {
|
|
484
|
+
content: "";
|
|
485
|
+
position: absolute;
|
|
486
|
+
right: 3px;
|
|
487
|
+
bottom: 3px;
|
|
488
|
+
width: 8px;
|
|
489
|
+
height: 8px;
|
|
490
|
+
border-right: 2px solid var(--bs-tile-handle-color);
|
|
491
|
+
border-bottom: 2px solid var(--bs-tile-handle-color);
|
|
492
|
+
border-bottom-right-radius: 2px;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
:host([resize-mode=off]) .tile__resize-side,
|
|
496
|
+
:host([resize-mode=off]) .tile__resize-bottom,
|
|
497
|
+
:host([resize-mode=off]) .tile__resize-corner {
|
|
498
|
+
display: none;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.tile[data-locked-resize=true] .tile__resize-side,
|
|
502
|
+
.tile[data-locked-resize=true] .tile__resize-bottom,
|
|
503
|
+
.tile[data-locked-resize=true] .tile__resize-corner {
|
|
504
|
+
display: none;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.tile-grid__live-region {
|
|
508
|
+
position: absolute;
|
|
509
|
+
width: 1px;
|
|
510
|
+
height: 1px;
|
|
511
|
+
margin: -1px;
|
|
512
|
+
padding: 0;
|
|
513
|
+
overflow: hidden;
|
|
514
|
+
clip: rect(0, 0, 0, 0);
|
|
515
|
+
white-space: nowrap;
|
|
516
|
+
border: 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
@media (prefers-reduced-motion: reduce) {
|
|
520
|
+
.tile {
|
|
521
|
+
transition: none !important;
|
|
522
|
+
}
|
|
523
|
+
.tile[data-blocked=true] {
|
|
524
|
+
animation: none;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
:host(:focus-visible) {
|
|
528
|
+
outline: 2px solid var(--bs-primary, #0d6efd);
|
|
529
|
+
outline-offset: 2px;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.tile:focus-visible {
|
|
533
|
+
outline: 2px solid var(--bs-primary, #0d6efd);
|
|
534
|
+
outline-offset: -2px;
|
|
535
|
+
}`);
|
|
536
|
+
|
|
537
|
+
const TOUCH_LONG_PRESS_MS = 600;
|
|
538
|
+
const TOUCH_LONG_PRESS_SLOP_PX = 10;
|
|
539
|
+
const TOUCH_PRESS_FEEDBACK_DELAY_MS = 150;
|
|
540
|
+
const POINTER_DRAG_THRESHOLD_PX = 5;
|
|
541
|
+
class MintTileManagerElement extends LitElement {
|
|
542
|
+
constructor() {
|
|
543
|
+
super(...arguments);
|
|
544
|
+
this.tiles = [];
|
|
545
|
+
this.columnCount = null;
|
|
546
|
+
this.minColumnWidth = '200px';
|
|
547
|
+
this.minRowHeight = '8rem';
|
|
548
|
+
this.gap = '0.5rem';
|
|
549
|
+
this.dragMode = 'header';
|
|
550
|
+
this.resizeMode = 'hover';
|
|
551
|
+
this.animateReflow = true;
|
|
552
|
+
this.label = null;
|
|
553
|
+
this.gestureState = { kind: 'idle' };
|
|
554
|
+
this.keyboardState = { kind: 'idle' };
|
|
555
|
+
// State-tracked redraw triggers (Lit re-renders when these change).
|
|
556
|
+
this.previewLayout = null;
|
|
557
|
+
this.gestureKind = 'idle';
|
|
558
|
+
this.blocked = false;
|
|
559
|
+
this.effectiveColumnCount = 1;
|
|
560
|
+
this.liveRegionMessage = '';
|
|
561
|
+
this.hostResizeObserver = null;
|
|
562
|
+
this.flipPreviousRects = new Map();
|
|
563
|
+
// Cached layout metrics. Refreshed lazily in updated()/firstUpdated() and on
|
|
564
|
+
// ResizeObserver ticks — never from render(), so the per-tile pointer-move
|
|
565
|
+
// path stays free of getComputedStyle / getBoundingClientRect calls.
|
|
566
|
+
this.cellMetrics = { width: 0, height: 0, gapX: 0, gapY: 0 };
|
|
567
|
+
// Bound handlers — created once so add/remove pair correctly.
|
|
568
|
+
this.onWindowPointerMove = (e) => this.handlePointerMove(e);
|
|
569
|
+
this.onWindowPointerUp = (e) => this.handlePointerUp(e);
|
|
570
|
+
this.onWindowPointerCancel = (e) => this.handlePointerCancel(e);
|
|
571
|
+
this.onWindowKeyDown = (e) => this.handleEscapeKey(e);
|
|
572
|
+
this.onVisibilityChange = () => {
|
|
573
|
+
if (document.visibilityState === 'hidden')
|
|
574
|
+
this.cancelGesture();
|
|
575
|
+
};
|
|
576
|
+
// ---------------- Pointer entry points ----------------
|
|
577
|
+
this.lastPointerPosition = null;
|
|
578
|
+
}
|
|
579
|
+
static { this.styles = [styles]; }
|
|
580
|
+
static get observedAttributes() {
|
|
581
|
+
return [
|
|
582
|
+
...(super.observedAttributes ?? []),
|
|
583
|
+
'column-count',
|
|
584
|
+
'min-column-width',
|
|
585
|
+
'min-row-height',
|
|
586
|
+
'gap',
|
|
587
|
+
'drag-mode',
|
|
588
|
+
'resize-mode',
|
|
589
|
+
'animate-reflow',
|
|
590
|
+
'label',
|
|
591
|
+
];
|
|
592
|
+
}
|
|
593
|
+
static { this.properties = {
|
|
594
|
+
tiles: { attribute: false },
|
|
595
|
+
columnCount: { attribute: 'column-count', type: Number },
|
|
596
|
+
minColumnWidth: { attribute: 'min-column-width', type: String },
|
|
597
|
+
minRowHeight: { attribute: 'min-row-height', type: String },
|
|
598
|
+
gap: { attribute: 'gap', type: String },
|
|
599
|
+
dragMode: { attribute: 'drag-mode', type: String, reflect: true },
|
|
600
|
+
resizeMode: { attribute: 'resize-mode', type: String, reflect: true },
|
|
601
|
+
animateReflow: { attribute: 'animate-reflow', type: Boolean },
|
|
602
|
+
label: { attribute: 'label', type: String },
|
|
603
|
+
previewLayout: { state: true },
|
|
604
|
+
gestureKind: { state: true },
|
|
605
|
+
blocked: { state: true },
|
|
606
|
+
effectiveColumnCount: { state: true },
|
|
607
|
+
keyboardMode: { state: true },
|
|
608
|
+
liveRegionMessage: { state: true },
|
|
609
|
+
}; }
|
|
610
|
+
render() {
|
|
611
|
+
const layoutSource = this.previewLayout ?? this.tiles.map((t) => ({ id: t.id, position: t.position }));
|
|
612
|
+
const tileById = new Map(this.tiles.map((t) => [t.id, t]));
|
|
613
|
+
const gridStyle = this.computeGridStyle();
|
|
614
|
+
// ARIA grid hierarchy is grid > row > gridcell. A single role="row"
|
|
615
|
+
// wrapper with display: contents lets us satisfy that without disturbing
|
|
616
|
+
// the CSS Grid placement of the gridcell children.
|
|
617
|
+
return html `
|
|
618
|
+
<div class="tile-grid" role="grid" aria-label=${this.label ?? nothing} style=${gridStyle}>
|
|
619
|
+
<div role="row" style="display: contents;">
|
|
620
|
+
${layoutSource.map((entry) => {
|
|
621
|
+
const tile = tileById.get(entry.id);
|
|
622
|
+
if (!tile)
|
|
623
|
+
return nothing;
|
|
624
|
+
return this.renderTile(tile, entry.position);
|
|
625
|
+
})}
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
<div class="tile-grid__live-region" aria-live="polite" aria-atomic="true">${this.liveRegionMessage}</div>
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
631
|
+
renderTile(tile, pos) {
|
|
632
|
+
const isDragging = this.gestureKind === 'drag' && this.activeTileId() === tile.id;
|
|
633
|
+
const isResizing = this.gestureKind === 'resize' && this.activeTileId() === tile.id;
|
|
634
|
+
const isBlocked = (isDragging || isResizing) && this.blocked;
|
|
635
|
+
const isPressing = this.gestureKind === 'arming-touch-drag' && this.activeTileId() === tile.id;
|
|
636
|
+
const transform = this.computeActiveTransform(tile.id);
|
|
637
|
+
const style = [
|
|
638
|
+
`grid-column: ${pos.colStart} / span ${pos.colSpan}`,
|
|
639
|
+
`grid-row: ${pos.rowStart} / span ${pos.rowSpan}`,
|
|
640
|
+
transform ? `transform: ${transform}` : '',
|
|
641
|
+
]
|
|
642
|
+
.filter(Boolean)
|
|
643
|
+
.join('; ');
|
|
644
|
+
return html `
|
|
645
|
+
<div
|
|
646
|
+
class="tile"
|
|
647
|
+
role="gridcell"
|
|
648
|
+
tabindex="0"
|
|
649
|
+
data-tile-id=${tile.id}
|
|
650
|
+
data-dragging=${isDragging ? 'true' : 'false'}
|
|
651
|
+
data-resizing=${isResizing ? 'true' : 'false'}
|
|
652
|
+
data-blocked=${isBlocked ? 'true' : 'false'}
|
|
653
|
+
data-pressing=${isPressing ? 'true' : 'false'}
|
|
654
|
+
data-drag-mode=${this.dragMode}
|
|
655
|
+
data-locked-move=${tile.disableMove ? 'true' : 'false'}
|
|
656
|
+
data-locked-resize=${tile.disableResize ? 'true' : 'false'}
|
|
657
|
+
aria-label=${tile.label ?? nothing}
|
|
658
|
+
style=${style}
|
|
659
|
+
@pointerdown=${(e) => this.onTilePointerDown(e, tile)}
|
|
660
|
+
@keydown=${(e) => this.onTileKeyDown(e, tile)}
|
|
661
|
+
>
|
|
662
|
+
<div class="tile__header-shell">
|
|
663
|
+
<slot name=${`${tile.id}-header`}></slot>
|
|
664
|
+
</div>
|
|
665
|
+
<div class="tile__content-shell">
|
|
666
|
+
<slot name=${`${tile.id}-content`}></slot>
|
|
667
|
+
</div>
|
|
668
|
+
${tile.disableResize || this.resizeMode === 'off'
|
|
669
|
+
? nothing
|
|
670
|
+
: html `
|
|
671
|
+
<div class="tile__resize-side" data-resize="side"
|
|
672
|
+
@pointerdown=${(e) => this.onResizeHandlePointerDown(e, tile, 'side')}></div>
|
|
673
|
+
<div class="tile__resize-bottom" data-resize="bottom"
|
|
674
|
+
@pointerdown=${(e) => this.onResizeHandlePointerDown(e, tile, 'bottom')}></div>
|
|
675
|
+
<div class="tile__resize-corner" data-resize="corner"
|
|
676
|
+
@pointerdown=${(e) => this.onResizeHandlePointerDown(e, tile, 'corner')}></div>
|
|
677
|
+
`}
|
|
678
|
+
</div>
|
|
679
|
+
`;
|
|
680
|
+
}
|
|
681
|
+
computeGridStyle() {
|
|
682
|
+
const tracks = this.columnCount && this.columnCount > 0
|
|
683
|
+
? `repeat(${this.columnCount}, minmax(0, 1fr))`
|
|
684
|
+
: `repeat(auto-fit, minmax(${this.minColumnWidth}, 1fr))`;
|
|
685
|
+
return [
|
|
686
|
+
`grid-template-columns: ${tracks}`,
|
|
687
|
+
`--mp-tile-row-height: ${this.minRowHeight}`,
|
|
688
|
+
`--mp-tile-gap: ${this.gap}`,
|
|
689
|
+
].join('; ');
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Refresh the cached layout metrics by reading layout / computed-style state
|
|
693
|
+
* once. Called from firstUpdated, the ResizeObserver tick, and updated()
|
|
694
|
+
* when an input that affects metrics changes — never from render().
|
|
695
|
+
*/
|
|
696
|
+
updateLayoutCache() {
|
|
697
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
698
|
+
if (!grid) {
|
|
699
|
+
this.effectiveColumnCount =
|
|
700
|
+
this.columnCount && this.columnCount > 0 ? this.columnCount : 1;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const cs = getComputedStyle(grid);
|
|
704
|
+
if (this.columnCount && this.columnCount > 0) {
|
|
705
|
+
this.effectiveColumnCount = this.columnCount;
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
const tracks = cs.gridTemplateColumns.split(/\s+/).filter(Boolean);
|
|
709
|
+
this.effectiveColumnCount = Math.max(1, tracks.length);
|
|
710
|
+
}
|
|
711
|
+
const rect = grid.getBoundingClientRect();
|
|
712
|
+
const cols = this.effectiveColumnCount;
|
|
713
|
+
const gapX = parseFloat(cs.columnGap) || 0;
|
|
714
|
+
const gapY = parseFloat(cs.rowGap) || 0;
|
|
715
|
+
const width = (rect.width - (cols - 1) * gapX) / cols;
|
|
716
|
+
const height = parseFloat(cs.gridAutoRows) || rect.width / cols;
|
|
717
|
+
this.cellMetrics = { width, height, gapX, gapY };
|
|
718
|
+
}
|
|
719
|
+
computeActiveTransform(id) {
|
|
720
|
+
const g = this.gestureState;
|
|
721
|
+
if (g.kind !== 'drag' || g.tileId !== id)
|
|
722
|
+
return null;
|
|
723
|
+
const pointer = this.lastPointerPosition;
|
|
724
|
+
if (!pointer)
|
|
725
|
+
return null;
|
|
726
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
727
|
+
if (!grid)
|
|
728
|
+
return null;
|
|
729
|
+
// Cached cellMetrics is stale by at most one frame after a resize — fine
|
|
730
|
+
// for the visual translate. gridRect read here is unavoidable: we need
|
|
731
|
+
// the live grid origin to convert pointer (viewport coords) into
|
|
732
|
+
// grid-relative space. One read per render, not one per tile.
|
|
733
|
+
const gridRect = grid.getBoundingClientRect();
|
|
734
|
+
const cell = this.cellMetrics;
|
|
735
|
+
const snapped = g.currentRect;
|
|
736
|
+
const desiredLeft = pointer.x - gridRect.left - g.pointerOffset.dx;
|
|
737
|
+
const desiredTop = pointer.y - gridRect.top - g.pointerOffset.dy;
|
|
738
|
+
const snappedLeft = (snapped.colStart - 1) * (cell.width + cell.gapX);
|
|
739
|
+
const snappedTop = (snapped.rowStart - 1) * (cell.height + cell.gapY);
|
|
740
|
+
return `translate(${Math.round(desiredLeft - snappedLeft)}px, ${Math.round(desiredTop - snappedTop)}px)`;
|
|
741
|
+
}
|
|
742
|
+
// ---------------- Lifecycle ----------------
|
|
743
|
+
connectedCallback() {
|
|
744
|
+
super.connectedCallback();
|
|
745
|
+
if (!this.hasAttribute('role')) {
|
|
746
|
+
this.setAttribute('role', 'application');
|
|
747
|
+
}
|
|
748
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
749
|
+
}
|
|
750
|
+
disconnectedCallback() {
|
|
751
|
+
this.cancelGesture();
|
|
752
|
+
this.detachWindowListeners();
|
|
753
|
+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
754
|
+
this.hostResizeObserver?.disconnect();
|
|
755
|
+
this.hostResizeObserver = null;
|
|
756
|
+
super.disconnectedCallback();
|
|
757
|
+
}
|
|
758
|
+
firstUpdated() {
|
|
759
|
+
// Seed the layout cache once the shadow DOM is in place. Subsequent
|
|
760
|
+
// refreshes happen on host resize and on relevant property changes.
|
|
761
|
+
this.updateLayoutCache();
|
|
762
|
+
let scheduled = false;
|
|
763
|
+
this.hostResizeObserver = new ResizeObserver(() => {
|
|
764
|
+
if (scheduled)
|
|
765
|
+
return;
|
|
766
|
+
scheduled = true;
|
|
767
|
+
// Defer to the next frame so we never write reactive state from inside
|
|
768
|
+
// the observer's callback — that path produces "ResizeObserver loop
|
|
769
|
+
// completed with undelivered notifications" warnings.
|
|
770
|
+
requestAnimationFrame(() => {
|
|
771
|
+
scheduled = false;
|
|
772
|
+
this.updateLayoutCache();
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
this.hostResizeObserver.observe(this);
|
|
776
|
+
}
|
|
777
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
778
|
+
super.attributeChangedCallback(name, oldValue, newValue);
|
|
779
|
+
if (name === 'animate-reflow') {
|
|
780
|
+
this.animateReflow = !(newValue === null || newValue === 'false' || newValue === '0');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// ---------------- FLIP animator ----------------
|
|
784
|
+
willUpdate(_changed) {
|
|
785
|
+
if (!this.shouldAnimate()) {
|
|
786
|
+
this.flipPreviousRects.clear();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
790
|
+
if (!grid)
|
|
791
|
+
return;
|
|
792
|
+
this.flipPreviousRects.clear();
|
|
793
|
+
grid.querySelectorAll('.tile').forEach((el) => {
|
|
794
|
+
const id = el.dataset['tileId'];
|
|
795
|
+
if (id)
|
|
796
|
+
this.flipPreviousRects.set(id, el.getBoundingClientRect());
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
updated(changed) {
|
|
800
|
+
// Refresh cached layout metrics when an input that determines them changes.
|
|
801
|
+
// Reading layout in updated() is safe — the new style is already applied.
|
|
802
|
+
if (changed.has('columnCount') ||
|
|
803
|
+
changed.has('minColumnWidth') ||
|
|
804
|
+
changed.has('minRowHeight') ||
|
|
805
|
+
changed.has('gap')) {
|
|
806
|
+
this.updateLayoutCache();
|
|
807
|
+
}
|
|
808
|
+
if (!this.shouldAnimate() || this.flipPreviousRects.size === 0)
|
|
809
|
+
return;
|
|
810
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
811
|
+
if (!grid)
|
|
812
|
+
return;
|
|
813
|
+
const activeId = this.activeTileId();
|
|
814
|
+
grid.querySelectorAll('.tile').forEach((el) => {
|
|
815
|
+
const id = el.dataset['tileId'];
|
|
816
|
+
if (!id || id === activeId)
|
|
817
|
+
return;
|
|
818
|
+
const prev = this.flipPreviousRects.get(id);
|
|
819
|
+
if (!prev)
|
|
820
|
+
return;
|
|
821
|
+
const next = el.getBoundingClientRect();
|
|
822
|
+
const dx = prev.left - next.left;
|
|
823
|
+
const dy = prev.top - next.top;
|
|
824
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)
|
|
825
|
+
return;
|
|
826
|
+
// Invert: jump back to old position with no transition…
|
|
827
|
+
const previous = el.style.transition;
|
|
828
|
+
el.style.transition = 'none';
|
|
829
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
830
|
+
// …then play forward on the next frame, restoring the transition.
|
|
831
|
+
requestAnimationFrame(() => {
|
|
832
|
+
el.style.transition = previous;
|
|
833
|
+
el.style.transform = '';
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
this.flipPreviousRects.clear();
|
|
837
|
+
}
|
|
838
|
+
shouldAnimate() {
|
|
839
|
+
if (!this.animateReflow)
|
|
840
|
+
return false;
|
|
841
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
842
|
+
return false;
|
|
843
|
+
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
844
|
+
}
|
|
845
|
+
onTilePointerDown(event, tile) {
|
|
846
|
+
if (event.button !== 0 && event.pointerType === 'mouse')
|
|
847
|
+
return;
|
|
848
|
+
if (this.gestureState.kind !== 'idle')
|
|
849
|
+
return;
|
|
850
|
+
if (this.dragMode === 'off' || tile.disableMove)
|
|
851
|
+
return;
|
|
852
|
+
// Resize handles consume their own pointerdown via stopPropagation; if we
|
|
853
|
+
// got here it's a drag-surface candidate.
|
|
854
|
+
if (this.dragMode === 'header' || event.pointerType === 'touch') {
|
|
855
|
+
// Header-only: only proceed if the click originated inside the header
|
|
856
|
+
// shell. The slotted (light-DOM) target won't find shadow-DOM ancestors
|
|
857
|
+
// via closest(), so walk the composed path instead.
|
|
858
|
+
const inHeader = event
|
|
859
|
+
.composedPath()
|
|
860
|
+
.some((node) => node instanceof HTMLElement && node.classList?.contains('tile__header-shell'));
|
|
861
|
+
if (!inHeader)
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (event.pointerType === 'touch') {
|
|
865
|
+
this.armTouchDrag(event, tile);
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
this.armPointerDrag(event, tile);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
onResizeHandlePointerDown(event, tile, mode) {
|
|
872
|
+
if (event.button !== 0 && event.pointerType === 'mouse')
|
|
873
|
+
return;
|
|
874
|
+
if (tile.disableResize || this.resizeMode === 'off')
|
|
875
|
+
return;
|
|
876
|
+
if (this.gestureState.kind !== 'idle')
|
|
877
|
+
return;
|
|
878
|
+
event.stopPropagation();
|
|
879
|
+
event.preventDefault();
|
|
880
|
+
this.beginResize(event, tile, mode);
|
|
881
|
+
}
|
|
882
|
+
// ---------------- Drag arming ----------------
|
|
883
|
+
armPointerDrag(event, tile) {
|
|
884
|
+
// Mouse / pen: arm immediately past the 5 px threshold. We track the
|
|
885
|
+
// pointer ourselves and convert to a real drag when distance exceeds
|
|
886
|
+
// POINTER_DRAG_THRESHOLD_PX.
|
|
887
|
+
const startX = event.clientX;
|
|
888
|
+
const startY = event.clientY;
|
|
889
|
+
const onMove = (e) => {
|
|
890
|
+
if (e.pointerId !== event.pointerId)
|
|
891
|
+
return;
|
|
892
|
+
const dx = e.clientX - startX;
|
|
893
|
+
const dy = e.clientY - startY;
|
|
894
|
+
if (Math.hypot(dx, dy) >= POINTER_DRAG_THRESHOLD_PX) {
|
|
895
|
+
window.removeEventListener('pointermove', onMove);
|
|
896
|
+
window.removeEventListener('pointerup', onCancel);
|
|
897
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
898
|
+
this.beginDrag(e, tile);
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
const onCancel = (e) => {
|
|
902
|
+
if (e.pointerId !== event.pointerId)
|
|
903
|
+
return;
|
|
904
|
+
window.removeEventListener('pointermove', onMove);
|
|
905
|
+
window.removeEventListener('pointerup', onCancel);
|
|
906
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
907
|
+
};
|
|
908
|
+
window.addEventListener('pointermove', onMove);
|
|
909
|
+
window.addEventListener('pointerup', onCancel);
|
|
910
|
+
window.addEventListener('pointercancel', onCancel);
|
|
911
|
+
}
|
|
912
|
+
armTouchDrag(event, tile) {
|
|
913
|
+
const startX = event.clientX;
|
|
914
|
+
const startY = event.clientY;
|
|
915
|
+
const pointerId = event.pointerId;
|
|
916
|
+
const pressingTimer = window.setTimeout(() => {
|
|
917
|
+
// Visual feedback only — gesture not yet armed.
|
|
918
|
+
if (this.gestureState.kind === 'arming-touch-drag' && this.gestureState.pointerId === pointerId) {
|
|
919
|
+
// Force a re-render to flip data-pressing.
|
|
920
|
+
this.requestUpdate();
|
|
921
|
+
}
|
|
922
|
+
}, TOUCH_PRESS_FEEDBACK_DELAY_MS);
|
|
923
|
+
const armTimer = window.setTimeout(() => {
|
|
924
|
+
if (this.gestureState.kind !== 'arming-touch-drag' || this.gestureState.pointerId !== pointerId)
|
|
925
|
+
return;
|
|
926
|
+
this.cleanupTouchArming();
|
|
927
|
+
try {
|
|
928
|
+
navigator.vibrate?.(10);
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// Silently ignore; vibrate isn't available everywhere.
|
|
932
|
+
}
|
|
933
|
+
this.beginDragFromTouchArm(tile, startX, startY, pointerId);
|
|
934
|
+
}, TOUCH_LONG_PRESS_MS);
|
|
935
|
+
this.gestureState = {
|
|
936
|
+
kind: 'arming-touch-drag',
|
|
937
|
+
pointerId,
|
|
938
|
+
tileId: tile.id,
|
|
939
|
+
startX,
|
|
940
|
+
startY,
|
|
941
|
+
timer: armTimer,
|
|
942
|
+
pressingTimer,
|
|
943
|
+
};
|
|
944
|
+
this.gestureKind = 'arming-touch-drag';
|
|
945
|
+
const onMove = (e) => {
|
|
946
|
+
if (e.pointerId !== pointerId)
|
|
947
|
+
return;
|
|
948
|
+
if (this.gestureState.kind !== 'arming-touch-drag')
|
|
949
|
+
return;
|
|
950
|
+
const dx = e.clientX - startX;
|
|
951
|
+
const dy = e.clientY - startY;
|
|
952
|
+
if (Math.hypot(dx, dy) > TOUCH_LONG_PRESS_SLOP_PX) {
|
|
953
|
+
this.cleanupTouchArming();
|
|
954
|
+
window.removeEventListener('pointermove', onMove);
|
|
955
|
+
window.removeEventListener('pointerup', onCancel);
|
|
956
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
const onCancel = (e) => {
|
|
960
|
+
if (e.pointerId !== pointerId)
|
|
961
|
+
return;
|
|
962
|
+
if (this.gestureState.kind === 'arming-touch-drag') {
|
|
963
|
+
this.cleanupTouchArming();
|
|
964
|
+
}
|
|
965
|
+
window.removeEventListener('pointermove', onMove);
|
|
966
|
+
window.removeEventListener('pointerup', onCancel);
|
|
967
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
968
|
+
};
|
|
969
|
+
window.addEventListener('pointermove', onMove);
|
|
970
|
+
window.addEventListener('pointerup', onCancel);
|
|
971
|
+
window.addEventListener('pointercancel', onCancel);
|
|
972
|
+
}
|
|
973
|
+
cleanupTouchArming() {
|
|
974
|
+
if (this.gestureState.kind !== 'arming-touch-drag')
|
|
975
|
+
return;
|
|
976
|
+
const { timer, pressingTimer } = this.gestureState;
|
|
977
|
+
if (timer !== null)
|
|
978
|
+
window.clearTimeout(timer);
|
|
979
|
+
if (pressingTimer !== null)
|
|
980
|
+
window.clearTimeout(pressingTimer);
|
|
981
|
+
this.gestureState = { kind: 'idle' };
|
|
982
|
+
this.gestureKind = 'idle';
|
|
983
|
+
this.requestUpdate();
|
|
984
|
+
}
|
|
985
|
+
beginDragFromTouchArm(tile, startX, startY, pointerId) {
|
|
986
|
+
// Synthesize the same begin call as for mouse, with a fake pointer event.
|
|
987
|
+
// Refresh the layout cache once at gesture start — guarantees fresh metrics
|
|
988
|
+
// even if the grid has resized since the last cache tick.
|
|
989
|
+
this.updateLayoutCache();
|
|
990
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
991
|
+
if (!grid)
|
|
992
|
+
return;
|
|
993
|
+
const cell = this.cellMetrics;
|
|
994
|
+
const gridRect = grid.getBoundingClientRect();
|
|
995
|
+
const tileLeft = gridRect.left + (tile.position.colStart - 1) * (cell.width + cell.gapX);
|
|
996
|
+
const tileTop = gridRect.top + (tile.position.rowStart - 1) * (cell.height + cell.gapY);
|
|
997
|
+
this.gestureState = {
|
|
998
|
+
kind: 'drag',
|
|
999
|
+
pointerId,
|
|
1000
|
+
tileId: tile.id,
|
|
1001
|
+
pointerOffset: { dx: startX - tileLeft, dy: startY - tileTop },
|
|
1002
|
+
currentRect: { ...tile.position },
|
|
1003
|
+
blocked: false,
|
|
1004
|
+
};
|
|
1005
|
+
this.gestureKind = 'drag';
|
|
1006
|
+
this.lastPointerPosition = { x: startX, y: startY };
|
|
1007
|
+
this.attachWindowListeners();
|
|
1008
|
+
this.requestUpdate();
|
|
1009
|
+
}
|
|
1010
|
+
beginDrag(event, tile) {
|
|
1011
|
+
this.updateLayoutCache();
|
|
1012
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
1013
|
+
if (!grid)
|
|
1014
|
+
return;
|
|
1015
|
+
const cell = this.cellMetrics;
|
|
1016
|
+
const gridRect = grid.getBoundingClientRect();
|
|
1017
|
+
const tileLeft = gridRect.left + (tile.position.colStart - 1) * (cell.width + cell.gapX);
|
|
1018
|
+
const tileTop = gridRect.top + (tile.position.rowStart - 1) * (cell.height + cell.gapY);
|
|
1019
|
+
this.gestureState = {
|
|
1020
|
+
kind: 'drag',
|
|
1021
|
+
pointerId: event.pointerId,
|
|
1022
|
+
tileId: tile.id,
|
|
1023
|
+
pointerOffset: { dx: event.clientX - tileLeft, dy: event.clientY - tileTop },
|
|
1024
|
+
currentRect: { ...tile.position },
|
|
1025
|
+
blocked: false,
|
|
1026
|
+
};
|
|
1027
|
+
this.gestureKind = 'drag';
|
|
1028
|
+
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
|
|
1029
|
+
this.attachWindowListeners();
|
|
1030
|
+
this.runPackerForCurrentGesture();
|
|
1031
|
+
}
|
|
1032
|
+
beginResize(event, tile, mode) {
|
|
1033
|
+
this.gestureState = {
|
|
1034
|
+
kind: 'resize',
|
|
1035
|
+
pointerId: event.pointerId,
|
|
1036
|
+
tileId: tile.id,
|
|
1037
|
+
mode,
|
|
1038
|
+
startPointer: { x: event.clientX, y: event.clientY },
|
|
1039
|
+
startSpans: { colSpan: tile.position.colSpan, rowSpan: tile.position.rowSpan },
|
|
1040
|
+
currentRect: { ...tile.position },
|
|
1041
|
+
blocked: false,
|
|
1042
|
+
};
|
|
1043
|
+
this.gestureKind = 'resize';
|
|
1044
|
+
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
|
|
1045
|
+
this.attachWindowListeners();
|
|
1046
|
+
this.runPackerForCurrentGesture();
|
|
1047
|
+
}
|
|
1048
|
+
attachWindowListeners() {
|
|
1049
|
+
window.addEventListener('pointermove', this.onWindowPointerMove);
|
|
1050
|
+
window.addEventListener('pointerup', this.onWindowPointerUp);
|
|
1051
|
+
window.addEventListener('pointercancel', this.onWindowPointerCancel);
|
|
1052
|
+
window.addEventListener('keydown', this.onWindowKeyDown);
|
|
1053
|
+
}
|
|
1054
|
+
detachWindowListeners() {
|
|
1055
|
+
window.removeEventListener('pointermove', this.onWindowPointerMove);
|
|
1056
|
+
window.removeEventListener('pointerup', this.onWindowPointerUp);
|
|
1057
|
+
window.removeEventListener('pointercancel', this.onWindowPointerCancel);
|
|
1058
|
+
window.removeEventListener('keydown', this.onWindowKeyDown);
|
|
1059
|
+
}
|
|
1060
|
+
handleEscapeKey(e) {
|
|
1061
|
+
if (e.key === 'Escape' && this.gestureState.kind !== 'idle') {
|
|
1062
|
+
e.preventDefault();
|
|
1063
|
+
this.cancelGesture();
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
// ---------------- Pointer move / up / cancel ----------------
|
|
1067
|
+
handlePointerMove(event) {
|
|
1068
|
+
const g = this.gestureState;
|
|
1069
|
+
if (g.kind === 'idle' || g.kind === 'arming-touch-drag')
|
|
1070
|
+
return;
|
|
1071
|
+
if (event.pointerId !== g.pointerId)
|
|
1072
|
+
return;
|
|
1073
|
+
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
|
|
1074
|
+
this.runPackerForCurrentGesture();
|
|
1075
|
+
}
|
|
1076
|
+
handlePointerUp(event) {
|
|
1077
|
+
const g = this.gestureState;
|
|
1078
|
+
if (g.kind === 'idle' || g.kind === 'arming-touch-drag')
|
|
1079
|
+
return;
|
|
1080
|
+
if (event.pointerId !== g.pointerId)
|
|
1081
|
+
return;
|
|
1082
|
+
if (g.blocked) {
|
|
1083
|
+
this.cancelGesture('blocked');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
this.commitGesture();
|
|
1087
|
+
}
|
|
1088
|
+
handlePointerCancel(event) {
|
|
1089
|
+
const g = this.gestureState;
|
|
1090
|
+
if (g.kind === 'idle')
|
|
1091
|
+
return;
|
|
1092
|
+
if (g.kind !== 'arming-touch-drag' && event.pointerId !== g.pointerId)
|
|
1093
|
+
return;
|
|
1094
|
+
this.cancelGesture();
|
|
1095
|
+
}
|
|
1096
|
+
// ---------------- Packer integration ----------------
|
|
1097
|
+
runPackerForCurrentGesture() {
|
|
1098
|
+
const g = this.gestureState;
|
|
1099
|
+
if (g.kind !== 'drag' && g.kind !== 'resize')
|
|
1100
|
+
return;
|
|
1101
|
+
const tile = this.tiles.find((t) => t.id === g.tileId);
|
|
1102
|
+
if (!tile) {
|
|
1103
|
+
this.cancelGesture();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const rect = g.kind === 'drag' ? this.computeDragRect(g, tile) : this.computeResizeRect(g, tile);
|
|
1107
|
+
if (!rect)
|
|
1108
|
+
return;
|
|
1109
|
+
g.currentRect = rect;
|
|
1110
|
+
const cols = this.effectiveColumnCount;
|
|
1111
|
+
const result = pack(this.tiles.map((t) => ({ id: t.id, position: t.position, locked: t.disableMove })), { id: tile.id, rect }, cols);
|
|
1112
|
+
this.previewLayout = result.layout;
|
|
1113
|
+
g.blocked = result.blocked;
|
|
1114
|
+
this.blocked = result.blocked;
|
|
1115
|
+
this.requestUpdate();
|
|
1116
|
+
}
|
|
1117
|
+
computeDragRect(g, tile) {
|
|
1118
|
+
const grid = this.shadowRoot?.querySelector('.tile-grid');
|
|
1119
|
+
if (!grid)
|
|
1120
|
+
return null;
|
|
1121
|
+
const cell = this.cellMetrics;
|
|
1122
|
+
const cols = this.effectiveColumnCount;
|
|
1123
|
+
const pointer = this.lastPointerPosition;
|
|
1124
|
+
if (!pointer)
|
|
1125
|
+
return null;
|
|
1126
|
+
const gridRect = grid.getBoundingClientRect();
|
|
1127
|
+
// Pointer position relative to grid origin, minus the where-on-the-tile offset.
|
|
1128
|
+
const localX = pointer.x - gridRect.left - g.pointerOffset.dx;
|
|
1129
|
+
const localY = pointer.y - gridRect.top - g.pointerOffset.dy;
|
|
1130
|
+
const colStart = Math.round(localX / (cell.width + cell.gapX)) + 1;
|
|
1131
|
+
const rowStart = Math.round(localY / (cell.height + cell.gapY)) + 1;
|
|
1132
|
+
return {
|
|
1133
|
+
colStart: Math.max(1, Math.min(colStart, cols - tile.position.colSpan + 1)),
|
|
1134
|
+
rowStart: Math.max(1, rowStart),
|
|
1135
|
+
colSpan: tile.position.colSpan,
|
|
1136
|
+
rowSpan: tile.position.rowSpan,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
computeResizeRect(g, tile) {
|
|
1140
|
+
const cell = this.cellMetrics;
|
|
1141
|
+
const cols = this.effectiveColumnCount;
|
|
1142
|
+
const pointer = this.lastPointerPosition;
|
|
1143
|
+
if (!pointer)
|
|
1144
|
+
return null;
|
|
1145
|
+
const dx = pointer.x - g.startPointer.x;
|
|
1146
|
+
const dy = pointer.y - g.startPointer.y;
|
|
1147
|
+
const colDelta = Math.round(dx / (cell.width + cell.gapX));
|
|
1148
|
+
const rowDelta = Math.round(dy / (cell.height + cell.gapY));
|
|
1149
|
+
const colSpan = g.mode === 'bottom'
|
|
1150
|
+
? g.startSpans.colSpan
|
|
1151
|
+
: Math.max(1, Math.min(g.startSpans.colSpan + colDelta, cols - tile.position.colStart + 1));
|
|
1152
|
+
const rowSpan = g.mode === 'side' ? g.startSpans.rowSpan : Math.max(1, g.startSpans.rowSpan + rowDelta);
|
|
1153
|
+
return {
|
|
1154
|
+
colStart: tile.position.colStart,
|
|
1155
|
+
rowStart: tile.position.rowStart,
|
|
1156
|
+
colSpan,
|
|
1157
|
+
rowSpan,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
// ---------------- Commit / cancel ----------------
|
|
1161
|
+
commitGesture() {
|
|
1162
|
+
const g = this.gestureState;
|
|
1163
|
+
if (g.kind !== 'drag' && g.kind !== 'resize')
|
|
1164
|
+
return;
|
|
1165
|
+
const finalLayout = this.previewLayout;
|
|
1166
|
+
if (!finalLayout) {
|
|
1167
|
+
this.cleanupGesture();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const previousById = new Map(this.tiles.map((t) => [t.id, t.position]));
|
|
1171
|
+
const newTiles = this.tiles.map((t) => {
|
|
1172
|
+
const placed = finalLayout.find((p) => p.id === t.id);
|
|
1173
|
+
return placed ? { ...t, position: placed.position } : t;
|
|
1174
|
+
});
|
|
1175
|
+
this.tiles = newTiles;
|
|
1176
|
+
// Dispatch per-tile change events.
|
|
1177
|
+
finalLayout.forEach((entry) => {
|
|
1178
|
+
const prev = previousById.get(entry.id);
|
|
1179
|
+
if (!prev || !this.positionsEqual(prev, entry.position)) {
|
|
1180
|
+
this.dispatchEvent(new CustomEvent('tilepositionchange', {
|
|
1181
|
+
detail: { id: entry.id, position: entry.position },
|
|
1182
|
+
bubbles: false,
|
|
1183
|
+
composed: true,
|
|
1184
|
+
}));
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
this.dispatchEvent(new CustomEvent('tilelayoutchange', {
|
|
1188
|
+
detail: this.cloneSnapshot(finalLayout),
|
|
1189
|
+
bubbles: false,
|
|
1190
|
+
composed: true,
|
|
1191
|
+
}));
|
|
1192
|
+
const movedTile = finalLayout.find((p) => p.id === g.tileId);
|
|
1193
|
+
if (movedTile) {
|
|
1194
|
+
this.liveRegionMessage =
|
|
1195
|
+
g.kind === 'drag'
|
|
1196
|
+
? `Tile moved to row ${movedTile.position.rowStart}, column ${movedTile.position.colStart}`
|
|
1197
|
+
: `Tile resized to ${movedTile.position.colSpan} columns by ${movedTile.position.rowSpan} rows`;
|
|
1198
|
+
}
|
|
1199
|
+
this.cleanupGesture();
|
|
1200
|
+
}
|
|
1201
|
+
cancelGesture(reason = 'cancel') {
|
|
1202
|
+
const g = this.gestureState;
|
|
1203
|
+
if (reason === 'blocked' && (g.kind === 'drag' || g.kind === 'resize')) {
|
|
1204
|
+
this.dispatchEvent(new CustomEvent('tilegestureblocked', {
|
|
1205
|
+
detail: { id: g.tileId, reason: 'locked-overlap' },
|
|
1206
|
+
bubbles: false,
|
|
1207
|
+
composed: true,
|
|
1208
|
+
}));
|
|
1209
|
+
}
|
|
1210
|
+
this.cleanupGesture();
|
|
1211
|
+
}
|
|
1212
|
+
cleanupGesture() {
|
|
1213
|
+
if (this.gestureState.kind === 'arming-touch-drag')
|
|
1214
|
+
this.cleanupTouchArming();
|
|
1215
|
+
this.detachWindowListeners();
|
|
1216
|
+
this.gestureState = { kind: 'idle' };
|
|
1217
|
+
this.gestureKind = 'idle';
|
|
1218
|
+
this.previewLayout = null;
|
|
1219
|
+
this.blocked = false;
|
|
1220
|
+
this.lastPointerPosition = null;
|
|
1221
|
+
this.requestUpdate();
|
|
1222
|
+
}
|
|
1223
|
+
positionsEqual(a, b) {
|
|
1224
|
+
return (a.colStart === b.colStart &&
|
|
1225
|
+
a.rowStart === b.rowStart &&
|
|
1226
|
+
a.colSpan === b.colSpan &&
|
|
1227
|
+
a.rowSpan === b.rowSpan);
|
|
1228
|
+
}
|
|
1229
|
+
cloneSnapshot(s) {
|
|
1230
|
+
return s.map((p) => ({ id: p.id, position: { ...p.position } }));
|
|
1231
|
+
}
|
|
1232
|
+
activeTileId() {
|
|
1233
|
+
const g = this.gestureState;
|
|
1234
|
+
if (g.kind === 'drag' || g.kind === 'resize' || g.kind === 'arming-touch-drag')
|
|
1235
|
+
return g.tileId;
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
// ---------------- Public API ----------------
|
|
1239
|
+
/** Read-only snapshot of the current layout. Mirrors `BsDockManagerComponent.captureLayout()`. */
|
|
1240
|
+
captureLayout() {
|
|
1241
|
+
return this.tiles.map((t) => ({ id: t.id, position: { ...t.position } }));
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* True while a drag or resize is in flight. The Angular wrapper uses this to
|
|
1245
|
+
* avoid clobbering `tiles` mid-gesture. Touch long-press arming does NOT
|
|
1246
|
+
* count — the WC isn't yet owning the layout during the hold.
|
|
1247
|
+
*/
|
|
1248
|
+
get isGestureActive() {
|
|
1249
|
+
return this.gestureState.kind === 'drag' || this.gestureState.kind === 'resize';
|
|
1250
|
+
}
|
|
1251
|
+
// ---------------- Keyboard ----------------
|
|
1252
|
+
onTileKeyDown(event, tile) {
|
|
1253
|
+
if (tile.disableMove && tile.disableResize)
|
|
1254
|
+
return;
|
|
1255
|
+
const km = this.keyboardState;
|
|
1256
|
+
if (km.kind === 'idle') {
|
|
1257
|
+
if (event.key === ' ' && !tile.disableMove) {
|
|
1258
|
+
event.preventDefault();
|
|
1259
|
+
this.keyboardState = { kind: 'move', tileId: tile.id };
|
|
1260
|
+
this.liveRegionMessage = 'Move mode enabled. Use arrow keys to move; Enter to commit, Escape to cancel.';
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (km.tileId !== tile.id)
|
|
1266
|
+
return;
|
|
1267
|
+
if (event.key === 'Escape' || event.key === 'Enter') {
|
|
1268
|
+
event.preventDefault();
|
|
1269
|
+
this.keyboardState = { kind: 'idle' };
|
|
1270
|
+
this.liveRegionMessage = event.key === 'Enter' ? 'Move committed.' : 'Move cancelled.';
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (event.key.startsWith('Arrow')) {
|
|
1274
|
+
event.preventDefault();
|
|
1275
|
+
const isResize = event.shiftKey;
|
|
1276
|
+
this.applyKeyboardStep(tile, event.key, isResize);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
applyKeyboardStep(tile, key, isResize) {
|
|
1280
|
+
const cols = this.effectiveColumnCount;
|
|
1281
|
+
const dx = key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0;
|
|
1282
|
+
const dy = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0;
|
|
1283
|
+
const newRect = isResize
|
|
1284
|
+
? {
|
|
1285
|
+
colStart: tile.position.colStart,
|
|
1286
|
+
rowStart: tile.position.rowStart,
|
|
1287
|
+
colSpan: Math.max(1, Math.min(tile.position.colSpan + dx, cols - tile.position.colStart + 1)),
|
|
1288
|
+
rowSpan: Math.max(1, tile.position.rowSpan + dy),
|
|
1289
|
+
}
|
|
1290
|
+
: {
|
|
1291
|
+
colStart: Math.max(1, Math.min(tile.position.colStart + dx, cols - tile.position.colSpan + 1)),
|
|
1292
|
+
rowStart: Math.max(1, tile.position.rowStart + dy),
|
|
1293
|
+
colSpan: tile.position.colSpan,
|
|
1294
|
+
rowSpan: tile.position.rowSpan,
|
|
1295
|
+
};
|
|
1296
|
+
const result = pack(this.tiles.map((t) => ({ id: t.id, position: t.position, locked: t.disableMove })), { id: tile.id, rect: newRect }, cols);
|
|
1297
|
+
if (result.blocked) {
|
|
1298
|
+
this.liveRegionMessage = 'Move blocked.';
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const newTiles = this.tiles.map((t) => {
|
|
1302
|
+
const placed = result.layout.find((p) => p.id === t.id);
|
|
1303
|
+
return placed ? { ...t, position: placed.position } : t;
|
|
1304
|
+
});
|
|
1305
|
+
this.tiles = newTiles;
|
|
1306
|
+
this.dispatchEvent(new CustomEvent('tilelayoutchange', {
|
|
1307
|
+
detail: this.cloneSnapshot(result.layout),
|
|
1308
|
+
bubbles: false,
|
|
1309
|
+
composed: true,
|
|
1310
|
+
}));
|
|
1311
|
+
result.layout.forEach((entry) => {
|
|
1312
|
+
this.dispatchEvent(new CustomEvent('tilepositionchange', {
|
|
1313
|
+
detail: { id: entry.id, position: entry.position },
|
|
1314
|
+
bubbles: false,
|
|
1315
|
+
composed: true,
|
|
1316
|
+
}));
|
|
1317
|
+
});
|
|
1318
|
+
this.liveRegionMessage = isResize
|
|
1319
|
+
? `Tile resized to ${newRect.colSpan} columns by ${newRect.rowSpan} rows`
|
|
1320
|
+
: `Tile moved to row ${newRect.rowStart}, column ${newRect.colStart}`;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (typeof customElements !== 'undefined' && !customElements.get('mp-tile-manager')) {
|
|
1324
|
+
customElements.define('mp-tile-manager', MintTileManagerElement);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Generated bundle index. Do not edit.
|
|
1329
|
+
*/
|
|
1330
|
+
|
|
1331
|
+
export { BsTileComponent, BsTileHeaderComponent, BsTileManagerComponent, MintTileManagerElement, rectsOverlap };
|
|
1332
|
+
//# sourceMappingURL=mintplayer-ng-bootstrap-tile-manager.mjs.map
|