feat: implement DeesDashboardgrid component with drag-and-drop functionality
- Added DeesDashboardgrid class for managing a grid of dashboard widgets. - Implemented widget dragging and resizing capabilities. - Introduced layout management with collision detection and margin resolution. - Created styles for grid layout, widget appearance, and animations. - Added support for customizable margins, cell height, and grid lines. - Included methods for adding, removing, and updating widgets dynamically. - Implemented context menu for widget actions and keyboard navigation support. - Established a responsive design with breakpoint handling for different layouts.
This commit is contained in:
231
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
231
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
DashboardResolvedMargins,
|
||||
DashboardMargin,
|
||||
DashboardWidget,
|
||||
DashboardLayoutItem,
|
||||
GridCellMetrics,
|
||||
LayoutDirection,
|
||||
} from './types.js';
|
||||
|
||||
export const DEFAULT_MARGIN = 10;
|
||||
|
||||
export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => {
|
||||
if (typeof margin === 'number') {
|
||||
return {
|
||||
horizontal: margin,
|
||||
vertical: margin,
|
||||
top: margin,
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
left: margin,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
top: margin.top ?? DEFAULT_MARGIN,
|
||||
right: margin.right ?? DEFAULT_MARGIN,
|
||||
bottom: margin.bottom ?? DEFAULT_MARGIN,
|
||||
left: margin.left ?? DEFAULT_MARGIN,
|
||||
};
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
horizontal: (resolved.left + resolved.right) / 2,
|
||||
vertical: (resolved.top + resolved.bottom) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateCellMetrics = (
|
||||
containerWidth: number,
|
||||
columns: number,
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
cellHeightUnit: string,
|
||||
): GridCellMetrics => {
|
||||
const totalMarginWidth = margins.horizontal * (columns + 1);
|
||||
const availableWidth = Math.max(containerWidth - totalMarginWidth, 0);
|
||||
const cellWidthPx = columns > 0 ? availableWidth / columns : 0;
|
||||
const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight;
|
||||
|
||||
return {
|
||||
containerWidth,
|
||||
cellWidthPx,
|
||||
marginHorizontalPx: margins.horizontal,
|
||||
cellHeightPx,
|
||||
marginVerticalPx: margins.vertical,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateGridHeight = (
|
||||
widgets: DashboardWidget[],
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
): number => {
|
||||
if (widgets.length === 0) return 0;
|
||||
const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0);
|
||||
return maxY * cellHeight + (maxY + 1) * margins.vertical;
|
||||
};
|
||||
|
||||
const overlaps = (
|
||||
widget: DashboardWidget,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y;
|
||||
|
||||
export const collectCollisions = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
nextW: number = target.w,
|
||||
nextH: number = target.h,
|
||||
): DashboardWidget[] => {
|
||||
return widgets.filter(widget => {
|
||||
if (widget.id === target.id) return false;
|
||||
return overlaps(widget, nextX, nextY, nextW, nextH);
|
||||
});
|
||||
};
|
||||
|
||||
export const checkCollision = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0;
|
||||
|
||||
export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget });
|
||||
|
||||
export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget);
|
||||
|
||||
export const findAvailablePosition = (
|
||||
widgets: DashboardWidget[],
|
||||
width: number,
|
||||
height: number,
|
||||
columns: number,
|
||||
): { x: number; y: number } => {
|
||||
for (let y = 0; y < 200; y++) {
|
||||
for (let x = 0; x <= columns - width; x++) {
|
||||
const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height));
|
||||
if (isFree) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
};
|
||||
|
||||
export interface PlacementResult {
|
||||
widgets: DashboardWidget[];
|
||||
movedWidgets: string[];
|
||||
swappedWith?: string;
|
||||
}
|
||||
|
||||
export const resolveWidgetPlacement = (
|
||||
widgets: DashboardWidget[],
|
||||
widgetId: string,
|
||||
next: { x: number; y: number; w?: number; h?: number },
|
||||
columns: number,
|
||||
): PlacementResult | null => {
|
||||
const sourceWidgets = cloneWidgets(widgets);
|
||||
const moving = sourceWidgets.find(widget => widget.id === widgetId);
|
||||
const original = widgets.find(widget => widget.id === widgetId);
|
||||
if (!moving || !original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
w: next.w ?? moving.w,
|
||||
h: next.h ?? moving.h,
|
||||
};
|
||||
|
||||
moving.x = target.x;
|
||||
moving.y = target.y;
|
||||
moving.w = target.w;
|
||||
moving.h = target.h;
|
||||
|
||||
const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h);
|
||||
|
||||
if (collisions.length === 0) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id] };
|
||||
}
|
||||
|
||||
if (collisions.length === 1) {
|
||||
const other = collisions[0];
|
||||
if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) {
|
||||
const otherClone = sourceWidgets.find(widget => widget.id === other.id);
|
||||
if (otherClone) {
|
||||
otherClone.x = original.x;
|
||||
otherClone.y = original.y;
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attempt displacement cascade
|
||||
const movedIds = new Set<string>([moving.id]);
|
||||
for (const offending of collisions) {
|
||||
if (offending.locked || offending.noMove) {
|
||||
return null;
|
||||
}
|
||||
const clone = sourceWidgets.find(widget => widget.id === offending.id);
|
||||
if (!clone) continue;
|
||||
const remaining = sourceWidgets.filter(widget => widget.id !== offending.id);
|
||||
const position = findAvailablePosition(remaining, clone.w, clone.h, columns);
|
||||
clone.x = position.x;
|
||||
clone.y = position.y;
|
||||
movedIds.add(clone.id);
|
||||
}
|
||||
|
||||
// verify no overlaps remain
|
||||
const verify = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h);
|
||||
if (verify.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { widgets: sourceWidgets, movedWidgets: Array.from(movedIds) };
|
||||
};
|
||||
|
||||
export const compactLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
direction: LayoutDirection = 'vertical',
|
||||
) => {
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
}
|
||||
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
for (const widget of sorted) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
while (widget.y > 0 && !checkCollision(widgets, widget, widget.x, widget.y - 1)) {
|
||||
widget.y -= 1;
|
||||
}
|
||||
} else {
|
||||
while (widget.x > 0 && !checkCollision(widgets, widget, widget.x - 1, widget.y)) {
|
||||
widget.x -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const applyLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
layout: DashboardLayoutItem[],
|
||||
): DashboardWidget[] => {
|
||||
return widgets.map(widget => {
|
||||
const layoutItem = layout.find(item => item.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
};
|
Reference in New Issue
Block a user