797 lines
25 KiB
TypeScript
797 lines
25 KiB
TypeScript
import {
|
|
DeesElement,
|
|
customElement,
|
|
property,
|
|
state,
|
|
html,
|
|
type TemplateResult,
|
|
} from '@design.estate/dees-element';
|
|
|
|
import '../dees-icon.js';
|
|
import '../dees-contextmenu.js';
|
|
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
|
import { dashboardGridStyles } from './styles.js';
|
|
import {
|
|
resolveMargins,
|
|
calculateCellMetrics,
|
|
calculateGridHeight,
|
|
findAvailablePosition,
|
|
compactLayout,
|
|
applyLayout,
|
|
resolveWidgetPlacement,
|
|
type PlacementResult,
|
|
} from './layout.js';
|
|
import {
|
|
computeGridCoordinates,
|
|
computeResizeDimensions,
|
|
type PointerPosition,
|
|
} from './interaction.js';
|
|
import { openWidgetContextMenu } from './contextmenu.js';
|
|
import type {
|
|
DashboardWidget,
|
|
DashboardMargin,
|
|
DashboardResolvedMargins,
|
|
GridCellMetrics,
|
|
DashboardLayoutItem,
|
|
LayoutDirection,
|
|
CellHeightUnit,
|
|
} from './types.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-dashboardgrid': DeesDashboardgrid;
|
|
}
|
|
}
|
|
|
|
type DragState = {
|
|
widgetId: string;
|
|
pointerId: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
start: DashboardLayoutItem;
|
|
previousPosition: DashboardLayoutItem;
|
|
currentPointer: PointerPosition;
|
|
lastPlacement: PlacementResult | null;
|
|
};
|
|
|
|
type ResizeState = {
|
|
widgetId: string;
|
|
pointerId: number;
|
|
handler: 'e' | 's' | 'se';
|
|
startPointer: PointerPosition;
|
|
start: DashboardLayoutItem;
|
|
startWidth: number;
|
|
startHeight: number;
|
|
lastPlacement: PlacementResult | null;
|
|
};
|
|
|
|
@customElement('dees-dashboardgrid')
|
|
export class DeesDashboardgrid extends DeesElement {
|
|
public static demo = demoFunc;
|
|
public static styles = dashboardGridStyles;
|
|
|
|
@property({ type: Array })
|
|
public widgets: DashboardWidget[] = [];
|
|
|
|
@property({ type: Number })
|
|
public cellHeight: number = 80;
|
|
|
|
@property({ type: Object })
|
|
public margin: DashboardMargin = 10;
|
|
|
|
@property({ type: Number })
|
|
public columns: number = 12;
|
|
|
|
@property({ type: Boolean })
|
|
public editable: boolean = true;
|
|
|
|
@property({ type: Boolean, reflect: true })
|
|
public enableAnimation: boolean = true;
|
|
|
|
@property({ type: String })
|
|
public cellHeightUnit: CellHeightUnit = 'px';
|
|
|
|
@property({ type: Boolean })
|
|
public rtl: boolean = false;
|
|
|
|
@property({ type: Boolean })
|
|
public showGridLines: boolean = false;
|
|
|
|
@property({ attribute: false })
|
|
public layouts?: Record<string, DashboardLayoutItem[]>;
|
|
|
|
@property({ type: String })
|
|
public activeBreakpoint: string = 'base';
|
|
|
|
@state()
|
|
private placeholderPosition: DashboardLayoutItem | null = null;
|
|
|
|
@state()
|
|
private metrics: GridCellMetrics | null = null;
|
|
|
|
@state()
|
|
private resolvedMargins: DashboardResolvedMargins | null = null;
|
|
|
|
@state()
|
|
private previewWidgets: DashboardWidget[] | null = null;
|
|
|
|
private containerBounds: DOMRect | null = null;
|
|
private dragState: DragState | null = null;
|
|
private resizeState: ResizeState | null = null;
|
|
private resizeObserver?: ResizeObserver;
|
|
private interactionActive = false;
|
|
|
|
public override async connectedCallback(): Promise<void> {
|
|
await super.connectedCallback();
|
|
this.computeMetrics();
|
|
this.observeResize();
|
|
}
|
|
|
|
public override async disconnectedCallback(): Promise<void> {
|
|
await super.disconnectedCallback();
|
|
this.disconnectResizeObserver();
|
|
this.releasePointerEvents();
|
|
}
|
|
|
|
protected updated(changed: Map<string, unknown>): void {
|
|
if (
|
|
changed.has('margin') ||
|
|
changed.has('columns') ||
|
|
changed.has('cellHeight') ||
|
|
changed.has('cellHeightUnit')
|
|
) {
|
|
this.computeMetrics();
|
|
}
|
|
|
|
if (changed.has('widgets') && !this.interactionActive) {
|
|
this.notifyLayoutChange();
|
|
}
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
const baseWidgets = this.widgets;
|
|
if (baseWidgets.length === 0) {
|
|
return html`
|
|
<div class="empty-state">
|
|
<dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
|
|
<div>No widgets configured</div>
|
|
<div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const metrics = this.ensureMetrics();
|
|
const margins = this.resolvedMargins ?? resolveMargins(this.margin);
|
|
const cellHeight = metrics.cellHeightPx;
|
|
const layoutForHeight = this.previewWidgets ?? this.widgets;
|
|
const gridHeight = calculateGridHeight(layoutForHeight, margins, cellHeight);
|
|
const previewMap = this.previewWidgets ? new Map(this.previewWidgets.map(widget => [widget.id, widget])) : null;
|
|
|
|
return html`
|
|
<div class="grid-container" style="height: ${gridHeight}px;">
|
|
${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
|
|
${baseWidgets.map(widget => this.renderWidget(widget, metrics, margins, previewMap))}
|
|
${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderGridLines(metrics: GridCellMetrics, gridHeight: number): TemplateResult {
|
|
const vertical: TemplateResult[] = [];
|
|
const horizontal: TemplateResult[] = [];
|
|
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
|
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
|
|
|
for (let i = 0; i <= this.columns; i++) {
|
|
const leftPx = i * cellPlusMarginX + metrics.marginHorizontalPx;
|
|
const leftPercent = this.pxToPercent(leftPx, metrics.containerWidth);
|
|
vertical.push(html`<div class="grid-line-vertical" style="left: ${leftPercent}%;"></div>`);
|
|
}
|
|
|
|
const rows = Math.ceil(gridHeight / cellPlusMarginY);
|
|
for (let row = 0; row <= rows; row++) {
|
|
const top = row * cellPlusMarginY;
|
|
horizontal.push(html`<div class="grid-line-horizontal" style="top: ${top}px;"></div>`);
|
|
}
|
|
|
|
return html`
|
|
<div class="grid-lines">
|
|
${vertical}
|
|
${horizontal}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderWidget(
|
|
widget: DashboardWidget,
|
|
metrics: GridCellMetrics,
|
|
margins: DashboardResolvedMargins,
|
|
previewMap: Map<string, DashboardWidget> | null,
|
|
): TemplateResult {
|
|
const isDragging = this.dragState?.widgetId === widget.id;
|
|
const isResizing = this.resizeState?.widgetId === widget.id;
|
|
const isLocked = widget.locked || !this.editable;
|
|
const previewWidget = previewMap?.get(widget.id) ?? null;
|
|
const layoutForRender = isDragging ? widget : previewWidget ?? widget;
|
|
const rect = this.computeWidgetRect(layoutForRender, metrics, margins);
|
|
|
|
const sideProperty = this.rtl ? 'right' : 'left';
|
|
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
|
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
|
|
|
let transform = '';
|
|
if (isDragging && this.dragState?.currentPointer) {
|
|
const pointer = this.dragState.currentPointer;
|
|
const bounds = this.containerBounds ?? this.getBoundingClientRect();
|
|
const translateX = pointer.clientX - bounds.left - this.dragState.offsetX - rect.left;
|
|
const translateY = pointer.clientY - bounds.top - this.dragState.offsetY - rect.top;
|
|
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
|
style="
|
|
${sideProperty}: ${sideValue}%;
|
|
top: ${rect.top}px;
|
|
width: ${widthPercent}%;
|
|
height: ${rect.height}px;
|
|
${transform}
|
|
"
|
|
data-widget-id=${widget.id}
|
|
>
|
|
<div class="widget-content">
|
|
${widget.title
|
|
? html`
|
|
<div
|
|
class="widget-header ${isLocked ? 'locked' : ''}"
|
|
@pointerdown=${!isLocked && !widget.noMove
|
|
? (evt: PointerEvent) => this.startDrag(evt, widget)
|
|
: null}
|
|
@contextmenu=${(evt: MouseEvent) => this.handleWidgetContextMenu(evt, widget)}
|
|
tabindex=${!isLocked && !widget.noMove ? 0 : -1}
|
|
@keydown=${(evt: KeyboardEvent) => this.handleHeaderKeydown(evt, widget)}
|
|
>
|
|
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : null}
|
|
${widget.title}
|
|
</div>
|
|
`
|
|
: null}
|
|
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
|
${widget.content}
|
|
</div>
|
|
${!isLocked && !widget.noResize
|
|
? html`
|
|
<div
|
|
class="resize-handle resize-handle-e"
|
|
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'e')}
|
|
></div>
|
|
<div
|
|
class="resize-handle resize-handle-s"
|
|
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 's')}
|
|
></div>
|
|
<div
|
|
class="resize-handle resize-handle-se"
|
|
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'se')}
|
|
></div>
|
|
`
|
|
: null}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPlaceholder(
|
|
metrics: GridCellMetrics,
|
|
margins: DashboardResolvedMargins,
|
|
): TemplateResult {
|
|
if (!this.placeholderPosition) {
|
|
return html``;
|
|
}
|
|
|
|
const rect = this.computeWidgetRect(this.placeholderPosition, metrics, margins);
|
|
const sideProperty = this.rtl ? 'right' : 'left';
|
|
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
|
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
|
|
|
return html`
|
|
<div
|
|
class="grid-widget placeholder"
|
|
style="
|
|
${sideProperty}: ${sideValue}%;
|
|
top: ${rect.top}px;
|
|
width: ${widthPercent}%;
|
|
height: ${rect.height}px;
|
|
"
|
|
>
|
|
<div class="widget-content"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private startDrag(event: PointerEvent, widget: DashboardWidget): void {
|
|
if (!this.editable || widget.noMove || widget.locked) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const widgetElement = (event.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement | null;
|
|
if (!widgetElement) {
|
|
return;
|
|
}
|
|
|
|
const widgetRect = widgetElement.getBoundingClientRect();
|
|
this.containerBounds = this.getBoundingClientRect();
|
|
this.ensureMetrics();
|
|
|
|
this.dragState = {
|
|
widgetId: widget.id,
|
|
pointerId: event.pointerId,
|
|
offsetX: event.clientX - widgetRect.left,
|
|
offsetY: event.clientY - widgetRect.top,
|
|
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
|
previousPosition: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
|
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
|
lastPlacement: null,
|
|
};
|
|
|
|
this.interactionActive = true;
|
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
document.addEventListener('pointermove', this.handleDragMove);
|
|
document.addEventListener('pointerup', this.handleDragEnd);
|
|
|
|
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
|
}
|
|
|
|
private handleDragMove = (event: PointerEvent): void => {
|
|
if (!this.dragState) return;
|
|
const metrics = this.ensureMetrics();
|
|
const activeWidgets = this.widgets;
|
|
const widget = activeWidgets.find(item => item.id === this.dragState!.widgetId);
|
|
if (!widget) return;
|
|
|
|
event.preventDefault();
|
|
|
|
const previousPosition = this.dragState.previousPosition;
|
|
|
|
const coords = computeGridCoordinates({
|
|
pointer: { clientX: event.clientX, clientY: event.clientY },
|
|
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
|
metrics,
|
|
columns: this.columns,
|
|
widget,
|
|
rtl: this.rtl,
|
|
dragOffsetX: this.dragState.offsetX,
|
|
dragOffsetY: this.dragState.offsetY,
|
|
});
|
|
|
|
const placement = resolveWidgetPlacement(
|
|
activeWidgets,
|
|
widget.id,
|
|
{ x: coords.x, y: coords.y },
|
|
this.columns,
|
|
previousPosition,
|
|
);
|
|
if (placement) {
|
|
const updatedWidget = placement.widgets.find(item => item.id === widget.id);
|
|
this.dragState = {
|
|
...this.dragState,
|
|
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
|
lastPlacement: placement,
|
|
previousPosition: updatedWidget
|
|
? { id: updatedWidget.id, x: updatedWidget.x, y: updatedWidget.y, w: updatedWidget.w, h: updatedWidget.h }
|
|
: { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h },
|
|
};
|
|
this.previewWidgets = placement.widgets;
|
|
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
|
if (previewWidget) {
|
|
this.placeholderPosition = {
|
|
id: previewWidget.id,
|
|
x: previewWidget.x,
|
|
y: previewWidget.y,
|
|
w: previewWidget.w,
|
|
h: previewWidget.h,
|
|
};
|
|
} else {
|
|
this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h };
|
|
}
|
|
} else {
|
|
this.previewWidgets = null;
|
|
this.placeholderPosition = null;
|
|
}
|
|
|
|
this.requestUpdate();
|
|
};
|
|
|
|
private handleDragEnd = (event: PointerEvent): void => {
|
|
const dragState = this.dragState;
|
|
if (!dragState || event.pointerId !== dragState.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const layoutSource = this.widgets;
|
|
this.previewWidgets = null;
|
|
|
|
// Always validate the final position, don't rely on lastPlacement from drag
|
|
const target = this.placeholderPosition ?? dragState.start;
|
|
const placement = resolveWidgetPlacement(
|
|
layoutSource,
|
|
dragState.widgetId,
|
|
{ x: target.x, y: target.y },
|
|
this.columns,
|
|
dragState.previousPosition,
|
|
);
|
|
|
|
if (placement) {
|
|
// Verify that the placement doesn't result in overlapping widgets
|
|
const finalWidget = placement.widgets.find(w => w.id === dragState.widgetId);
|
|
if (finalWidget) {
|
|
const hasOverlap = placement.widgets.some(w => {
|
|
if (w.id === dragState.widgetId) return false;
|
|
return (
|
|
finalWidget.x < w.x + w.w &&
|
|
finalWidget.x + finalWidget.w > w.x &&
|
|
finalWidget.y < w.y + w.h &&
|
|
finalWidget.y + finalWidget.h > w.y
|
|
);
|
|
});
|
|
|
|
if (!hasOverlap) {
|
|
this.commitPlacement(placement, dragState.widgetId, 'widget-move');
|
|
} else {
|
|
// Return to start position if overlap detected
|
|
this.widgets = this.widgets.map(widget =>
|
|
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Return to start position if no valid placement
|
|
this.widgets = this.widgets.map(widget =>
|
|
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
|
);
|
|
}
|
|
|
|
this.placeholderPosition = null;
|
|
this.dragState = null;
|
|
this.interactionActive = false;
|
|
this.releasePointerEvents();
|
|
};
|
|
|
|
private startResize(event: PointerEvent, widget: DashboardWidget, handler: 'e' | 's' | 'se'): void {
|
|
if (!this.editable || widget.noResize || widget.locked) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
this.ensureMetrics();
|
|
|
|
this.resizeState = {
|
|
widgetId: widget.id,
|
|
pointerId: event.pointerId,
|
|
handler,
|
|
startPointer: { clientX: event.clientX, clientY: event.clientY },
|
|
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
|
startWidth: widget.w,
|
|
startHeight: widget.h,
|
|
lastPlacement: null,
|
|
};
|
|
|
|
this.interactionActive = true;
|
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
document.addEventListener('pointermove', this.handleResizeMove);
|
|
document.addEventListener('pointerup', this.handleResizeEnd);
|
|
|
|
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
|
}
|
|
|
|
private handleResizeMove = (event: PointerEvent): void => {
|
|
if (!this.resizeState) return;
|
|
const metrics = this.ensureMetrics();
|
|
const activeWidgets = this.widgets;
|
|
const widget = activeWidgets.find(item => item.id === this.resizeState!.widgetId);
|
|
if (!widget) return;
|
|
|
|
event.preventDefault();
|
|
|
|
const nextSize = computeResizeDimensions({
|
|
pointer: { clientX: event.clientX, clientY: event.clientY },
|
|
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
|
metrics,
|
|
startWidth: this.resizeState.startWidth,
|
|
startHeight: this.resizeState.startHeight,
|
|
startPointer: this.resizeState.startPointer,
|
|
handler: this.resizeState.handler,
|
|
widget,
|
|
columns: this.columns,
|
|
});
|
|
|
|
const placement = resolveWidgetPlacement(
|
|
activeWidgets,
|
|
widget.id,
|
|
{ x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height },
|
|
this.columns,
|
|
this.resizeState.start,
|
|
);
|
|
|
|
if (placement) {
|
|
this.resizeState = { ...this.resizeState, lastPlacement: placement };
|
|
this.previewWidgets = placement.widgets;
|
|
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
|
if (previewWidget) {
|
|
this.placeholderPosition = {
|
|
id: previewWidget.id,
|
|
x: previewWidget.x,
|
|
y: previewWidget.y,
|
|
w: previewWidget.w,
|
|
h: previewWidget.h,
|
|
};
|
|
} else {
|
|
this.placeholderPosition = {
|
|
id: widget.id,
|
|
x: widget.x,
|
|
y: widget.y,
|
|
w: nextSize.width,
|
|
h: nextSize.height,
|
|
};
|
|
}
|
|
} else {
|
|
this.previewWidgets = null;
|
|
this.placeholderPosition = null;
|
|
}
|
|
|
|
this.requestUpdate();
|
|
};
|
|
|
|
private handleResizeEnd = (event: PointerEvent): void => {
|
|
const resizeState = this.resizeState;
|
|
if (!resizeState || event.pointerId !== resizeState.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const layoutSource = this.widgets;
|
|
this.previewWidgets = null;
|
|
const placement =
|
|
resizeState.lastPlacement ??
|
|
resolveWidgetPlacement(
|
|
layoutSource,
|
|
resizeState.widgetId,
|
|
{
|
|
x: this.placeholderPosition?.x ?? resizeState.start.x,
|
|
y: this.placeholderPosition?.y ?? resizeState.start.y,
|
|
w: this.placeholderPosition?.w ?? resizeState.start.w,
|
|
h: this.placeholderPosition?.h ?? resizeState.start.h,
|
|
},
|
|
this.columns,
|
|
resizeState.start,
|
|
);
|
|
|
|
if (placement) {
|
|
this.commitPlacement(placement, resizeState.widgetId, 'widget-resize');
|
|
} else {
|
|
this.widgets = this.widgets.map(widget =>
|
|
widget.id === resizeState.widgetId ? { ...widget, w: resizeState.start.w, h: resizeState.start.h } : widget,
|
|
);
|
|
}
|
|
|
|
this.placeholderPosition = null;
|
|
this.resizeState = null;
|
|
this.interactionActive = false;
|
|
this.releasePointerEvents();
|
|
};
|
|
|
|
private handleHeaderKeydown(event: KeyboardEvent, widget: DashboardWidget): void {
|
|
if (!this.editable || widget.noMove || widget.locked) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key;
|
|
const isResize = event.shiftKey;
|
|
let placement: PlacementResult | null = null;
|
|
|
|
if (isResize && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) {
|
|
event.preventDefault();
|
|
const delta = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1;
|
|
|
|
if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
|
const maxWidth = widget.maxW ?? this.columns - widget.x;
|
|
const nextWidth = Math.max(widget.minW ?? 1, Math.min(maxWidth, widget.w + delta));
|
|
placement = resolveWidgetPlacement(
|
|
this.widgets,
|
|
widget.id,
|
|
{ x: widget.x, y: widget.y, w: nextWidth, h: widget.h },
|
|
this.columns,
|
|
);
|
|
} else {
|
|
const maxHeight = widget.maxH ?? Number.POSITIVE_INFINITY;
|
|
const nextHeight = Math.max(widget.minH ?? 1, Math.min(maxHeight, widget.h + delta));
|
|
placement = resolveWidgetPlacement(
|
|
this.widgets,
|
|
widget.id,
|
|
{ x: widget.x, y: widget.y, w: widget.w, h: nextHeight },
|
|
this.columns,
|
|
);
|
|
}
|
|
|
|
if (placement) {
|
|
this.commitPlacement(placement, widget.id, 'widget-resize');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const moveMap: Record<string, { dx: number; dy: number }> = {
|
|
ArrowLeft: { dx: -1, dy: 0 },
|
|
ArrowRight: { dx: 1, dy: 0 },
|
|
ArrowUp: { dx: 0, dy: -1 },
|
|
ArrowDown: { dx: 0, dy: 1 },
|
|
};
|
|
|
|
const delta = moveMap[key];
|
|
if (!delta) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const targetX = Math.max(0, Math.min(this.columns - widget.w, widget.x + delta.dx));
|
|
const targetY = Math.max(0, widget.y + delta.dy);
|
|
|
|
placement = resolveWidgetPlacement(this.widgets, widget.id, { x: targetX, y: targetY }, this.columns);
|
|
if (placement) {
|
|
this.commitPlacement(placement, widget.id, 'widget-move');
|
|
}
|
|
}
|
|
|
|
private handleWidgetContextMenu(event: MouseEvent, widget: DashboardWidget): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openWidgetContextMenu({ widget, host: this, event });
|
|
}
|
|
|
|
private commitPlacement(result: PlacementResult, widgetId: string, type: 'widget-move' | 'widget-resize'): void {
|
|
this.previewWidgets = null;
|
|
this.widgets = result.widgets;
|
|
const subject = this.widgets.find(item => item.id === widgetId);
|
|
if (subject) {
|
|
this.dispatchEvent(
|
|
new CustomEvent(type, {
|
|
detail: {
|
|
widget: subject,
|
|
displaced: result.movedWidgets.filter(id => id !== widgetId),
|
|
swappedWith: result.swappedWith,
|
|
},
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
public removeWidget(widgetId: string): void {
|
|
const target = this.widgets.find(widget => widget.id === widgetId);
|
|
if (!target) return;
|
|
this.widgets = this.widgets.filter(widget => widget.id !== widgetId);
|
|
this.dispatchEvent(
|
|
new CustomEvent('widget-remove', {
|
|
detail: { widget: target },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
public updateWidget(widgetId: string, updates: Partial<DashboardWidget>): void {
|
|
this.widgets = this.widgets.map(widget => (widget.id === widgetId ? { ...widget, ...updates } : widget));
|
|
}
|
|
|
|
public getLayout(): DashboardLayoutItem[] {
|
|
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
|
}
|
|
|
|
public setLayout(layout: DashboardLayoutItem[]): void {
|
|
this.widgets = applyLayout(this.widgets, layout);
|
|
}
|
|
|
|
public lockGrid(): void {
|
|
this.editable = false;
|
|
}
|
|
|
|
public unlockGrid(): void {
|
|
this.editable = true;
|
|
}
|
|
|
|
public addWidget(widget: DashboardWidget, autoPosition = false): void {
|
|
const nextWidget = { ...widget };
|
|
if (autoPosition || nextWidget.autoPosition) {
|
|
const position = findAvailablePosition(this.widgets, nextWidget.w, nextWidget.h, this.columns);
|
|
nextWidget.x = position.x;
|
|
nextWidget.y = position.y;
|
|
}
|
|
|
|
this.widgets = [...this.widgets, nextWidget];
|
|
}
|
|
|
|
public compact(direction: LayoutDirection = 'vertical'): void {
|
|
const nextWidgets = this.widgets.map(widget => ({ ...widget }));
|
|
compactLayout(nextWidgets, direction);
|
|
this.widgets = nextWidgets;
|
|
}
|
|
|
|
public applyBreakpointLayout(breakpoint: string): void {
|
|
this.activeBreakpoint = breakpoint;
|
|
const layout = this.layouts?.[breakpoint];
|
|
if (layout) {
|
|
this.setLayout(layout);
|
|
}
|
|
}
|
|
|
|
public notifyLayoutChange(): void {
|
|
this.dispatchEvent(
|
|
new CustomEvent('layout-change', {
|
|
detail: { layout: this.getLayout() },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private ensureMetrics(): GridCellMetrics {
|
|
if (!this.metrics) {
|
|
this.computeMetrics();
|
|
}
|
|
return this.metrics!;
|
|
}
|
|
|
|
private computeMetrics(): void {
|
|
if (!this.isConnected) return;
|
|
const bounds = this.getBoundingClientRect();
|
|
this.containerBounds = bounds;
|
|
const margins = resolveMargins(this.margin);
|
|
this.resolvedMargins = margins;
|
|
this.metrics = calculateCellMetrics(bounds.width, this.columns, margins, this.cellHeight, this.cellHeightUnit);
|
|
}
|
|
|
|
private observeResize(): void {
|
|
if (this.resizeObserver) return;
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.computeMetrics();
|
|
});
|
|
this.resizeObserver.observe(this);
|
|
}
|
|
|
|
private disconnectResizeObserver(): void {
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = undefined;
|
|
}
|
|
|
|
private releasePointerEvents(): void {
|
|
document.removeEventListener('pointermove', this.handleDragMove);
|
|
document.removeEventListener('pointerup', this.handleDragEnd);
|
|
document.removeEventListener('pointermove', this.handleResizeMove);
|
|
document.removeEventListener('pointerup', this.handleResizeEnd);
|
|
}
|
|
|
|
private pxToPercent(value: number, container: number): number {
|
|
if (!container) return 0;
|
|
return Number(((value / container) * 100).toFixed(4));
|
|
}
|
|
|
|
private computeWidgetRect(
|
|
widget: Pick<DashboardWidget, 'x' | 'y' | 'w' | 'h'>,
|
|
metrics: GridCellMetrics,
|
|
margins: DashboardResolvedMargins,
|
|
) {
|
|
const cellWidth = metrics.cellWidthPx;
|
|
const cellHeight = metrics.cellHeightPx;
|
|
const left = widget.x * (cellWidth + margins.horizontal) + margins.horizontal;
|
|
const top = widget.y * (cellHeight + margins.vertical) + margins.vertical;
|
|
const width = widget.w * cellWidth + Math.max(0, widget.w - 1) * margins.horizontal;
|
|
const height = widget.h * cellHeight + Math.max(0, widget.h - 1) * margins.vertical;
|
|
|
|
return { left, top, width, height };
|
|
}
|
|
}
|