Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
9b39196195 | |||
ad59e3d334 | |||
0de4283fae | |||
6f9c92a866 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-09-18 - 1.11.8 - fix(ci)
|
||||
Add local tool permissions config to allow running pnpm scripts and enable mcp__zen__chat
|
||||
|
||||
- Add local settings file to grant permission to run pnpm scripts (Bash(pnpm run:*))
|
||||
- Enable mcp__zen__chat permission in local tool settings
|
||||
|
||||
## 2025-09-16 - 1.11.7 - fix(readme)
|
||||
Expand README with comprehensive component documentation, examples and developer guide; add local Claude settings
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.11.7",
|
||||
"version": "1.11.8",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
|
BIN
readme.plan.md
BIN
readme.plan.md
Binary file not shown.
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '1.11.7',
|
||||
version: '1.11.8',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
@@ -1,191 +0,0 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
|
||||
|
||||
// Set initial widgets
|
||||
grid.widgets = [
|
||||
{
|
||||
id: 'metrics1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Revenue',
|
||||
icon: 'lucide:dollarSign',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
|
||||
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'metrics2',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Users',
|
||||
icon: 'lucide:users',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
|
||||
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'chart1',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
title: 'Analytics',
|
||||
icon: 'lucide:lineChart',
|
||||
content: html`
|
||||
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="text-align: center; color: #71717a;">
|
||||
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
|
||||
<div>Chart visualization area</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
// Configure grid
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
// Control buttons
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
if (text === 'Toggle Animation') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
} else if (text === 'Toggle Grid Lines') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
} else if (text === 'Add Widget') {
|
||||
button.addEventListener('click', () => {
|
||||
const newWidget = {
|
||||
id: `widget${widgetCounter++}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
autoPosition: true,
|
||||
title: `Widget ${widgetCounter - 1}`,
|
||||
icon: 'lucide:package',
|
||||
content: html`
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<div style="color: #71717a;">New widget content</div>
|
||||
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(Math.random() * 1000)}</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
grid.addWidget(newWidget, true);
|
||||
});
|
||||
} else if (text === 'Compact Grid') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
} else if (text === 'Toggle Edit Mode') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to grid events
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
console.log('Widget moved:', e.detail.widget);
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
console.log('Widget resized:', e.detail.widget);
|
||||
});
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-controls dees-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container-wrapper {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-controls">
|
||||
<dees-button-group label="Animation:">
|
||||
<dees-button>Toggle Animation</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Display:">
|
||||
<dees-button>Toggle Grid Lines</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Add Widget</dees-button>
|
||||
<dees-button>Compact Grid</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Mode:">
|
||||
<dees-button>Toggle Edit Mode</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="grid-container-wrapper">
|
||||
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
@@ -1,813 +0,0 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import './dees-icon.js';
|
||||
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dashboardgrid': DeesDashboardgrid;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IDashboardWidget {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean; // Auto-position widget in first available space
|
||||
}
|
||||
|
||||
@customElement('dees-dashboardgrid')
|
||||
export class DeesDashboardgrid extends DeesElement {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
public widgets: IDashboardWidget[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public cellHeight: number = 80;
|
||||
|
||||
@property({ type: Object })
|
||||
public margin: number | { top?: number; right?: number; bottom?: number; left?: number } = 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: 'px' | 'em' | 'rem' | 'auto' = 'px';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rtl: boolean = false; // Right-to-left support
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showGridLines: boolean = false;
|
||||
|
||||
@state()
|
||||
private draggedWidget: IDashboardWidget | null = null;
|
||||
|
||||
@state()
|
||||
private draggedElement: HTMLElement | null = null;
|
||||
|
||||
@state()
|
||||
private dragOffsetX: number = 0;
|
||||
|
||||
@state()
|
||||
private dragOffsetY: number = 0;
|
||||
|
||||
@state()
|
||||
private dragMouseX: number = 0;
|
||||
|
||||
@state()
|
||||
private dragMouseY: number = 0;
|
||||
|
||||
@state()
|
||||
private placeholderPosition: { x: number; y: number } | null = null;
|
||||
|
||||
@state()
|
||||
private resizingWidget: IDashboardWidget | null = null;
|
||||
|
||||
@state()
|
||||
private resizeStartW: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartH: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartX: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartY: number = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-widget {
|
||||
position: absolute;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
:host([enableanimation]) .grid-widget {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.grid-widget.dragging {
|
||||
z-index: 1000;
|
||||
transition: none !important;
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder .widget-content {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid-widget.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'0 1px 3px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-widget:hover .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
}
|
||||
|
||||
.grid-widget.dragging .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 16px 48px rgba(0, 0, 0, 0.25)',
|
||||
'0 16px 48px rgba(0, 0, 0, 0.6)'
|
||||
)};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-header:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget-header.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-header.locked:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.widget-header dees-icon {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.widget-body.has-header {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.resize-handle-e {
|
||||
cursor: ew-resize;
|
||||
width: 12px;
|
||||
right: -6px;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.resize-handle-s {
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 80%;
|
||||
bottom: -6px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.resize-handle-se {
|
||||
cursor: se-resize;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.grid-widget:hover .resize-handle-se {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover::after {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.grid-placeholder {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.1;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-line-vertical {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.grid-line-horizontal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.widgets.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 margins = this.getMargins();
|
||||
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4);
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical;
|
||||
|
||||
return html`
|
||||
<div class="grid-container" style="height: ${gridHeight}px;">
|
||||
${this.showGridLines ? this.renderGridLines(gridHeight) : ''}
|
||||
${this.widgets.map(widget => this.renderWidget(widget))}
|
||||
${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGridLines(gridHeight: number): TemplateResult {
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const verticalLines = [];
|
||||
const horizontalLines = [];
|
||||
|
||||
// Vertical lines
|
||||
for (let i = 0; i <= this.columns; i++) {
|
||||
const left = i * cellWidth + i * marginHorizontalPercent;
|
||||
verticalLines.push(html`
|
||||
<div class="grid-line-vertical" style="left: ${left}%;"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical));
|
||||
for (let i = 0; i <= numHorizontalLines; i++) {
|
||||
const top = i * cellHeightValue + i * margins.vertical;
|
||||
horizontalLines.push(html`
|
||||
<div class="grid-line-horizontal" style="top: ${top}px;"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="grid-lines">
|
||||
${verticalLines}
|
||||
${horizontalLines}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWidget(widget: IDashboardWidget): TemplateResult {
|
||||
const isDragging = this.draggedWidget?.id === widget.id;
|
||||
const isResizing = this.resizingWidget?.id === widget.id;
|
||||
const isLocked = widget.locked || !this.editable;
|
||||
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage of container width for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent;
|
||||
const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical;
|
||||
const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent;
|
||||
const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical;
|
||||
|
||||
// Apply transform when dragging for smooth movement
|
||||
let transform = '';
|
||||
if (isDragging && this.draggedElement) {
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width);
|
||||
const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top;
|
||||
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
||||
style="
|
||||
${this.rtl ? 'right' : 'left'}: ${left}%;
|
||||
top: ${top}px;
|
||||
width: ${width}%;
|
||||
height: ${height}px;
|
||||
${transform}
|
||||
"
|
||||
data-widget-id="${widget.id}"
|
||||
>
|
||||
<div class="widget-content">
|
||||
${widget.title ? html`
|
||||
<div
|
||||
class="widget-header ${isLocked ? 'locked' : ''}"
|
||||
@mousedown=${!isLocked && !widget.noMove ? (e: MouseEvent) => this.startDrag(e, widget) : null}
|
||||
>
|
||||
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : ''}
|
||||
${widget.title}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
||||
${widget.content}
|
||||
</div>
|
||||
${!isLocked && !widget.noResize ? html`
|
||||
<div class="resize-handle resize-handle-e" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'e')}></div>
|
||||
<div class="resize-handle resize-handle-s" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 's')}></div>
|
||||
<div class="resize-handle resize-handle-se" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'se')}></div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): TemplateResult {
|
||||
if (!this.placeholderPosition || !this.draggedWidget) return html``;
|
||||
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage of container width for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent;
|
||||
const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical;
|
||||
const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent;
|
||||
const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget placeholder"
|
||||
style="
|
||||
${this.rtl ? 'right' : 'left'}: ${left}%;
|
||||
top: ${top}px;
|
||||
width: ${width}%;
|
||||
height: ${height}px;
|
||||
"
|
||||
>
|
||||
<div class="widget-content"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startDrag(e: MouseEvent, widget: IDashboardWidget) {
|
||||
e.preventDefault();
|
||||
this.draggedWidget = widget;
|
||||
this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement;
|
||||
|
||||
const rect = this.draggedElement.getBoundingClientRect();
|
||||
|
||||
this.dragOffsetX = e.clientX - rect.left;
|
||||
this.dragOffsetY = e.clientY - rect.top;
|
||||
|
||||
// Initialize mouse position
|
||||
this.dragMouseX = e.clientX;
|
||||
this.dragMouseY = e.clientY;
|
||||
|
||||
// Initialize placeholder at current widget position
|
||||
this.placeholderPosition = { x: widget.x, y: widget.y };
|
||||
|
||||
document.addEventListener('mousemove', this.handleDrag);
|
||||
document.addEventListener('mouseup', this.endDrag);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleDrag = (e: MouseEvent) => {
|
||||
if (!this.draggedWidget || !this.draggedElement) return;
|
||||
|
||||
// Update mouse position for smooth dragging
|
||||
this.dragMouseX = e.clientX;
|
||||
this.dragMouseY = e.clientY;
|
||||
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Get widget position relative to grid container
|
||||
const mouseX = e.clientX - containerRect.left - this.dragOffsetX;
|
||||
const mouseY = e.clientY - containerRect.top - this.dragOffsetY;
|
||||
|
||||
// Use pixel calculations for accuracy
|
||||
const totalWidth = containerRect.width;
|
||||
const totalMarginWidth = margins.horizontal * (this.columns + 1);
|
||||
const availableWidth = totalWidth - totalMarginWidth;
|
||||
const cellWidthPx = availableWidth / this.columns;
|
||||
|
||||
// Calculate grid X position
|
||||
// Account for the initial margin and then repeating pattern of cell+margin
|
||||
let gridX = 0;
|
||||
if (mouseX > margins.horizontal) {
|
||||
const adjustedX = mouseX - margins.horizontal;
|
||||
const cellPlusMargin = cellWidthPx + margins.horizontal;
|
||||
gridX = Math.floor(adjustedX / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
|
||||
}
|
||||
|
||||
// Calculate grid Y position
|
||||
let gridY = 0;
|
||||
if (mouseY > margins.vertical) {
|
||||
const adjustedY = mouseY - margins.vertical;
|
||||
const cellPlusMargin = cellHeightValue + margins.vertical;
|
||||
gridY = Math.floor(adjustedY / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
|
||||
}
|
||||
|
||||
const clampedX = Math.max(0, Math.min(gridX, this.columns - this.draggedWidget.w));
|
||||
const clampedY = Math.max(0, gridY);
|
||||
|
||||
// Update placeholder position instead of widget position during drag
|
||||
if (!this.placeholderPosition ||
|
||||
clampedX !== this.placeholderPosition.x ||
|
||||
clampedY !== this.placeholderPosition.y) {
|
||||
const collision = this.checkCollision(this.draggedWidget, clampedX, clampedY);
|
||||
if (!collision) {
|
||||
this.placeholderPosition = { x: clampedX, y: clampedY };
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private endDrag = () => {
|
||||
// Apply final position from placeholder
|
||||
if (this.draggedWidget && this.placeholderPosition) {
|
||||
this.draggedWidget.x = this.placeholderPosition.x;
|
||||
this.draggedWidget.y = this.placeholderPosition.y;
|
||||
|
||||
this.dispatchEvent(new CustomEvent('widget-move', {
|
||||
detail: { widget: this.draggedWidget },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear drag state
|
||||
this.draggedWidget = null;
|
||||
this.draggedElement = null;
|
||||
this.placeholderPosition = null;
|
||||
this.dragMouseX = 0;
|
||||
this.dragMouseY = 0;
|
||||
|
||||
document.removeEventListener('mousemove', this.handleDrag);
|
||||
document.removeEventListener('mouseup', this.endDrag);
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private startResize(e: MouseEvent, widget: IDashboardWidget, handle: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.resizingWidget = widget;
|
||||
this.resizeStartW = widget.w;
|
||||
this.resizeStartH = widget.h;
|
||||
this.resizeStartX = e.clientX;
|
||||
this.resizeStartY = e.clientY;
|
||||
|
||||
const handleResize = (e: MouseEvent) => {
|
||||
if (!this.resizingWidget) return;
|
||||
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
const cellWidth = (containerRect.width - margins.horizontal * (this.columns + 1)) / this.columns;
|
||||
|
||||
const deltaX = e.clientX - this.resizeStartX;
|
||||
const deltaY = e.clientY - this.resizeStartY;
|
||||
|
||||
if (handle.includes('e')) {
|
||||
const newW = Math.round(this.resizeStartW + deltaX / (cellWidth + margins.horizontal));
|
||||
const maxW = widget.maxW || (this.columns - this.resizingWidget.x);
|
||||
this.resizingWidget.w = Math.max(widget.minW || 1, Math.min(newW, maxW));
|
||||
}
|
||||
|
||||
if (handle.includes('s')) {
|
||||
const newH = Math.round(this.resizeStartH + deltaY / (cellHeightValue + margins.vertical));
|
||||
const maxH = widget.maxH || Infinity;
|
||||
this.resizingWidget.h = Math.max(widget.minH || 1, Math.min(newH, maxH));
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
this.dispatchEvent(new CustomEvent('widget-resize', {
|
||||
detail: { widget: this.resizingWidget },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const endResize = () => {
|
||||
this.resizingWidget = null;
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', endResize);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', endResize);
|
||||
}
|
||||
|
||||
|
||||
public removeWidget(widgetId: string) {
|
||||
this.widgets = this.widgets.filter(w => w.id !== widgetId);
|
||||
}
|
||||
|
||||
public updateWidget(widgetId: string, updates: Partial<IDashboardWidget>) {
|
||||
this.widgets = this.widgets.map(w =>
|
||||
w.id === widgetId ? { ...w, ...updates } : w
|
||||
);
|
||||
}
|
||||
|
||||
public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> {
|
||||
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
||||
}
|
||||
|
||||
public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) {
|
||||
this.widgets = this.widgets.map(widget => {
|
||||
const layoutItem = layout.find(l => l.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
}
|
||||
|
||||
public lockGrid() {
|
||||
this.editable = false;
|
||||
}
|
||||
|
||||
public unlockGrid() {
|
||||
this.editable = true;
|
||||
}
|
||||
|
||||
private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } {
|
||||
if (typeof this.margin === 'number') {
|
||||
return {
|
||||
horizontal: this.margin,
|
||||
vertical: this.margin,
|
||||
top: this.margin,
|
||||
right: this.margin,
|
||||
bottom: this.margin,
|
||||
left: this.margin,
|
||||
};
|
||||
}
|
||||
|
||||
const margins = {
|
||||
top: this.margin.top ?? 10,
|
||||
right: this.margin.right ?? 10,
|
||||
bottom: this.margin.bottom ?? 10,
|
||||
left: this.margin.left ?? 10,
|
||||
};
|
||||
|
||||
return {
|
||||
...margins,
|
||||
horizontal: (margins.left + margins.right) / 2,
|
||||
vertical: (margins.top + margins.bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
private getCellHeight(): number {
|
||||
if (this.cellHeightUnit === 'auto') {
|
||||
// Calculate square cells based on container width
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const margins = this.getMargins();
|
||||
const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns;
|
||||
return cellWidth;
|
||||
}
|
||||
|
||||
return this.cellHeight;
|
||||
}
|
||||
|
||||
private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean {
|
||||
const widgets = this.widgets.filter(w => w.id !== widget.id);
|
||||
|
||||
for (const other of widgets) {
|
||||
if (newX < other.x + other.w &&
|
||||
newX + widget.w > other.x &&
|
||||
newY < other.y + other.h &&
|
||||
newY + widget.h > other.y) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public addWidget(widget: IDashboardWidget, autoPosition = false) {
|
||||
if (autoPosition || widget.autoPosition) {
|
||||
// Find first available position
|
||||
const position = this.findAvailablePosition(widget.w, widget.h);
|
||||
widget.x = position.x;
|
||||
widget.y = position.y;
|
||||
}
|
||||
|
||||
this.widgets = [...this.widgets, widget];
|
||||
}
|
||||
|
||||
private findAvailablePosition(width: number, height: number): { x: number; y: number } {
|
||||
// Try to find space starting from top-left
|
||||
for (let y = 0; y < 100; y++) { // Reasonable limit
|
||||
for (let x = 0; x <= this.columns - width; x++) {
|
||||
const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget;
|
||||
if (!this.checkCollision(testWidget, x, y)) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no space found, place at bottom
|
||||
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
}
|
||||
|
||||
public compact(direction: 'vertical' | 'horizontal' = 'vertical') {
|
||||
const sortedWidgets = [...this.widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
} else {
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
}
|
||||
});
|
||||
|
||||
for (const widget of sortedWidgets) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// Move up as far as possible
|
||||
while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) {
|
||||
widget.y--;
|
||||
}
|
||||
} else {
|
||||
// Move left as far as possible
|
||||
while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) {
|
||||
widget.x--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# dees-dashboardgrid
|
||||
|
||||
`<dees-dashboardgrid>` renders a configurable dashboard layout with draggable and resizable tiles. The component is now grouped in its own folder alongside supporting utilities and styles.
|
||||
|
||||
## Key Features
|
||||
|
||||
- Pointer-driven drag and resize interactions with keyboard fallbacks (arrow keys to move, `Shift` + arrows to resize).
|
||||
- Collision-aware placement that swaps compatible tiles or displaces blocking tiles into the next free slot.
|
||||
- Context menu (right-click on a tile header) that exposes destructive actions such as tile removal via `dees-contextmenu`.
|
||||
- Layout persistence helpers via `getLayout()`, `setLayout(...)`, and the `layout-change` event.
|
||||
- Responsive presets through the `layouts` map and `applyBreakpointLayout(...)` helper to hydrate per-breakpoint arrangements.
|
||||
|
||||
## Public API Highlights
|
||||
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| `widgets` | Array of tile descriptors (`DashboardWidget`). |
|
||||
| `columns` | Number of grid columns. |
|
||||
| `layouts` | Optional record of named layout definitions. |
|
||||
| `activeBreakpoint` | Name of the currently applied breakpoint layout. |
|
||||
| `editable` | Toggles drag/resize affordances. |
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `addWidget(widget, autoPosition?)` | Adds a tile, optionally auto-placing it into the next free slot. |
|
||||
| `removeWidget(id)` | Removes a tile and emits `widget-remove`. |
|
||||
| `applyBreakpointLayout(name)` | Applies a layout from the `layouts` map. |
|
||||
| `getLayout()` / `setLayout(layout)` | Retrieve or apply persisted layouts. |
|
||||
| `compact(direction?)` | Densifies the grid vertically (default) or horizontally. |
|
||||
|
||||
| Event | Detail payload |
|
||||
| --- | --- |
|
||||
| `widget-move` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-resize` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-remove` | `{ widget }` |
|
||||
| `layout-change` | `{ layout }` |
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- **Right-click** a tile header to open the contextual menu and delete the tile.
|
||||
- When resizing, blocking tiles will automatically reflow into free space once the interaction completes.
|
||||
- Listen to `layout-change` to persist layouts to storage; rehydrate using `setLayout` or the `layouts` map.
|
||||
- For responsive dashboards, populate `grid.layouts = { base: [...], mobile: [...] }` and call `applyBreakpointLayout` based on your own breakpoint logic (see the co-located demo for an example).
|
||||
|
||||
## Demo
|
||||
|
||||
The updated `dees-dashboardgrid.demo.ts` showcases live breakpoint switching, layout persistence, and the context menu. Run the demo gallery to explore the interactions end-to-end.
|
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DashboardWidget } from './types.js';
|
||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
|
||||
export interface WidgetContextMenuOptions {
|
||||
widget: DashboardWidget;
|
||||
host: DeesDashboardgrid;
|
||||
event: MouseEvent;
|
||||
}
|
||||
|
||||
export const openWidgetContextMenu = ({
|
||||
widget,
|
||||
host,
|
||||
event,
|
||||
}: WidgetContextMenuOptions) => {
|
||||
const items: (plugins.tsclass.website.IMenuItem | { divider: true })[] = [
|
||||
{
|
||||
name: 'Delete tile',
|
||||
iconName: 'lucide:trash2' as any,
|
||||
action: async () => {
|
||||
host.removeWidget(widget.id);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, items as any);
|
||||
};
|
257
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
257
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
|
||||
|
||||
const seedWidgets = [
|
||||
{
|
||||
id: 'metrics1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Revenue',
|
||||
icon: 'lucide:dollarSign',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
|
||||
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'metrics2',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Users',
|
||||
icon: 'lucide:users',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
|
||||
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'chart1',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
title: 'Analytics',
|
||||
icon: 'lucide:lineChart',
|
||||
content: html`
|
||||
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="text-align: center; color: #71717a;">
|
||||
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
|
||||
<div>Chart visualization area</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
grid.widgets = seedWidgets.map(widget => ({ ...widget }));
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
const baseLayout = grid.getLayout().map(item => ({ ...item }));
|
||||
const mobileLayout = grid.widgets.map((widget, index) => ({
|
||||
id: widget.id,
|
||||
x: 0,
|
||||
y: index === 0 ? 0 : grid.widgets.slice(0, index).reduce((acc, prev) => acc + prev.h, 0),
|
||||
w: grid.columns,
|
||||
h: widget.h,
|
||||
}));
|
||||
|
||||
grid.layouts = {
|
||||
base: baseLayout,
|
||||
mobile: mobileLayout,
|
||||
};
|
||||
|
||||
const statusEl = elementArg.querySelector('#dashboardLayoutStatus') as HTMLElement;
|
||||
const updateStatus = () => {
|
||||
const layout = grid.getLayout();
|
||||
statusEl.textContent = `Active breakpoint: ${grid.activeBreakpoint} • Tiles: ${layout.length}`;
|
||||
};
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const handleBreakpoint = () => {
|
||||
const target = mediaQuery.matches ? 'mobile' : 'base';
|
||||
grid.applyBreakpointLayout(target);
|
||||
updateStatus();
|
||||
};
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', handleBreakpoint);
|
||||
} else {
|
||||
(mediaQuery as MediaQueryList & {
|
||||
addListener?: (listener: (this: MediaQueryList, ev: MediaQueryListEvent) => void) => void;
|
||||
}).addListener?.(handleBreakpoint);
|
||||
}
|
||||
handleBreakpoint();
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
switch (text) {
|
||||
case 'Toggle Animation':
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
break;
|
||||
case 'Toggle Grid Lines':
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
break;
|
||||
case 'Add Widget':
|
||||
button.addEventListener('click', () => {
|
||||
const newWidget = {
|
||||
id: `widget${widgetCounter++}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
autoPosition: true,
|
||||
title: `Widget ${widgetCounter - 1}`,
|
||||
icon: 'lucide:package',
|
||||
content: html`
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<div style="color: #71717a;">New widget content</div>
|
||||
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(
|
||||
Math.random() * 1000,
|
||||
)}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
grid.addWidget(newWidget, true);
|
||||
});
|
||||
break;
|
||||
case 'Compact Grid':
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
break;
|
||||
case 'Toggle Edit Mode':
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
break;
|
||||
case 'Reset Layout':
|
||||
button.addEventListener('click', () => {
|
||||
grid.applyBreakpointLayout(grid.activeBreakpoint);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
console.log('Widget moved:', e.detail.widget, 'Displaced:', e.detail.displaced);
|
||||
});
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
console.log('Widget resized:', e.detail.widget, 'Displaced:', e.detail.displaced);
|
||||
});
|
||||
grid.addEventListener('widget-remove', (e: CustomEvent) => {
|
||||
console.log('Widget removed:', e.detail.widget);
|
||||
updateStatus();
|
||||
});
|
||||
grid.addEventListener('layout-change', () => {
|
||||
console.log('Layout changed:', grid.getLayout());
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
updateStatus();
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-controls dees-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container-wrapper {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#dashboardLayoutStatus {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-controls">
|
||||
<dees-button-group label="Animation:">
|
||||
<dees-button>Toggle Animation</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Display:">
|
||||
<dees-button>Toggle Grid Lines</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Add Widget</dees-button>
|
||||
<dees-button>Compact Grid</dees-button>
|
||||
<dees-button>Reset Layout</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Mode:">
|
||||
<dees-button>Toggle Edit Mode</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="grid-container-wrapper">
|
||||
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div>Drag to reposition, resize from handles, or right-click a header to delete a tile.</div>
|
||||
<div id="dashboardLayoutStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
774
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
774
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
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;
|
||||
const target = this.placeholderPosition ?? dragState.start;
|
||||
const placement =
|
||||
dragState.lastPlacement ??
|
||||
resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
dragState.widgetId,
|
||||
{ x: target.x, y: target.y },
|
||||
this.columns,
|
||||
dragState.previousPosition,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, dragState.widgetId, 'widget-move');
|
||||
} else {
|
||||
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 };
|
||||
}
|
||||
}
|
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './types.js';
|
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { DashboardWidget, GridCellMetrics } from './types.js';
|
||||
|
||||
export interface PointerPosition {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface DragComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
columns: number;
|
||||
widget: DashboardWidget;
|
||||
rtl: boolean;
|
||||
dragOffsetX?: number;
|
||||
dragOffsetY?: number;
|
||||
}
|
||||
|
||||
export const computeGridCoordinates = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
columns,
|
||||
widget,
|
||||
rtl,
|
||||
dragOffsetX = 0,
|
||||
dragOffsetY = 0,
|
||||
}: DragComputationArgs): { x: number; y: number } => {
|
||||
const relativeX = pointer.clientX - containerRect.left - dragOffsetX;
|
||||
const relativeY = pointer.clientY - containerRect.top - dragOffsetY;
|
||||
|
||||
const marginX = metrics.marginHorizontalPx;
|
||||
const marginY = metrics.marginVerticalPx;
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const adjustedX = clamp(relativeX - marginX, 0, containerRect.width - marginX);
|
||||
const adjustedY = clamp(relativeY - marginY, 0, Number.POSITIVE_INFINITY);
|
||||
|
||||
const cellPlusMarginX = cellWidth + marginX;
|
||||
const cellPlusMarginY = cellHeight + marginY;
|
||||
|
||||
let gridX = Math.round(adjustedX / cellPlusMarginX);
|
||||
if (rtl) {
|
||||
gridX = columns - widget.w - gridX;
|
||||
}
|
||||
gridX = clamp(gridX, 0, columns - widget.w);
|
||||
|
||||
const gridY = clamp(Math.round(adjustedY / cellPlusMarginY), 0, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
return { x: gridX, y: gridY };
|
||||
};
|
||||
|
||||
export interface ResizeComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
startPointer: PointerPosition;
|
||||
handler: 'e' | 's' | 'se';
|
||||
widget: DashboardWidget;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
export const computeResizeDimensions = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
startWidth,
|
||||
startHeight,
|
||||
startPointer,
|
||||
handler,
|
||||
widget,
|
||||
columns,
|
||||
}: ResizeComputationArgs): { width: number; height: number } => {
|
||||
const deltaX = pointer.clientX - startPointer.clientX;
|
||||
const deltaY = pointer.clientY - startPointer.clientY;
|
||||
|
||||
let width = startWidth;
|
||||
let height = startHeight;
|
||||
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
if (handler.includes('e')) {
|
||||
const deltaCols = Math.round(deltaX / cellPlusMarginX);
|
||||
width = startWidth + deltaCols;
|
||||
}
|
||||
|
||||
if (handler.includes('s')) {
|
||||
const deltaRows = Math.round(deltaY / cellPlusMarginY);
|
||||
height = startHeight + deltaRows;
|
||||
}
|
||||
|
||||
const clampedWidth = Math.max(widget.minW || 1, Math.min(width, widget.maxW || columns - widget.x));
|
||||
const clampedHeight = Math.max(widget.minH || 1, Math.min(height, widget.maxH || Number.MAX_SAFE_INTEGER));
|
||||
|
||||
return {
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
};
|
||||
};
|
233
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
233
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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,
|
||||
previousPosition?: DashboardLayoutItem,
|
||||
): 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) {
|
||||
const swapTarget = previousPosition ?? original;
|
||||
otherClone.x = swapTarget.x;
|
||||
otherClone.y = swapTarget.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;
|
||||
});
|
||||
};
|
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const dashboardGridStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-widget {
|
||||
position: absolute;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
:host([enableanimation]) .grid-widget {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.grid-widget.dragging {
|
||||
z-index: 1000;
|
||||
transition: none !important;
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder .widget-content {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid-widget.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'0 1px 3px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-widget:hover .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
}
|
||||
|
||||
.grid-widget.dragging .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 16px 48px rgba(0, 0, 0, 0.25)',
|
||||
'0 16px 48px rgba(0, 0, 0, 0.6)'
|
||||
)};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-header:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget-header.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-header.locked:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.widget-header dees-icon {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.widget-body.has-header {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.resize-handle-e {
|
||||
cursor: ew-resize;
|
||||
width: 12px;
|
||||
right: -6px;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.resize-handle-s {
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 80%;
|
||||
bottom: -6px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.resize-handle-se {
|
||||
cursor: se-resize;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.grid-widget:hover .resize-handle-se {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover::after {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.grid-placeholder {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.1;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-line-vertical {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.grid-line-horizontal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
];
|
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { TemplateResult } from '@design.estate/dees-element';
|
||||
|
||||
export type CellHeightUnit = 'px' | 'em' | 'rem' | 'auto';
|
||||
|
||||
export interface DashboardMarginObject {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export type DashboardMargin = number | DashboardMarginObject;
|
||||
|
||||
export interface DashboardResolvedMargins {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface DashboardWidget extends DashboardLayoutItem {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean;
|
||||
}
|
||||
|
||||
export type LayoutDirection = 'vertical' | 'horizontal';
|
||||
|
||||
export interface GridCellMetrics {
|
||||
containerWidth: number;
|
||||
cellWidthPx: number;
|
||||
marginHorizontalPx: number;
|
||||
cellHeightPx: number;
|
||||
marginVerticalPx: number;
|
||||
}
|
@@ -18,7 +18,7 @@ export * from './dees-chips.js';
|
||||
export * from './dees-contextmenu.js';
|
||||
export * from './dees-dataview-codebox.js';
|
||||
export * from './dees-dataview-statusobject.js';
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './dees-dashboardgrid/index.js';
|
||||
export * from './dees-editor.js';
|
||||
export * from './dees-editor-markdown.js';
|
||||
export * from './dees-editor-markdownoutlet.js';
|
||||
|
@@ -1,65 +0,0 @@
|
||||
# WYSIWYG Block Cleanup Status
|
||||
|
||||
## Overview
|
||||
This document tracks the cleanup of `dees-wysiwyg-block.ts` after migrating all block types to the new block handler architecture.
|
||||
|
||||
## Completed ✅
|
||||
All cleanup tasks have been successfully completed on 2025-06-26.
|
||||
|
||||
## Cleanup Tasks
|
||||
|
||||
### 1. ✅ Remove Block-Specific Styles (lines 101-219)
|
||||
- [x] Remove `.block.heading-1/2/3` styles → Now in `heading.block.ts`
|
||||
- [x] Remove `.block.quote` styles → Now in `quote.block.ts`
|
||||
- [x] Remove `.block.list` styles → Now in `list.block.ts`
|
||||
- [x] Remove `.block.paragraph` styles → Now in `paragraph.block.ts`
|
||||
|
||||
### 2. ✅ Remove Code Block Specific Logic
|
||||
- [x] Remove code block rendering in `renderBlockContent()` (lines 508-521)
|
||||
- [x] Remove all `type === 'code'` conditional branches
|
||||
- [x] Simplify element selection to not special-case code blocks
|
||||
|
||||
### 3. ✅ Remove List Block Specific Logic
|
||||
- [x] Remove `focusListItem()` method (lines 814-821)
|
||||
- [x] Remove list-specific handling in `getContent()` (lines 732-734)
|
||||
- [x] Remove list-specific handling in `setContent()` (lines 764-765)
|
||||
- [x] Remove list content rendering in `firstUpdated()` (line 479)
|
||||
|
||||
### 4. ✅ Remove getPlaceholder() Method
|
||||
- [x] Remove entire method (lines 538-553)
|
||||
- [x] Update renderBlockContent() to not use placeholders
|
||||
|
||||
### 5. ✅ Clean Up Excessive Empty Lines
|
||||
- [x] Remove consecutive blank lines throughout the file
|
||||
|
||||
### 6. ✅ Centralize nonEditableTypes
|
||||
- [x] Create a single source of truth for non-editable block types
|
||||
- [x] Remove duplicate arrays
|
||||
|
||||
### 7. ✅ Simplify Handler Delegation
|
||||
- [x] Keep handler delegation pattern but ensure consistency
|
||||
|
||||
### 8. ✅ Remove Unused Properties (if confirmed unused)
|
||||
- [x] Keep `contentInitialized` - still used for tracking
|
||||
- [x] Keep `blockElement` - used for caching
|
||||
- [x] Keep cursor tracking properties - used for selection
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Block Types Now Fully Handled by Handlers:
|
||||
1. **Text blocks**: paragraph, heading-1/2/3, quote, code, list
|
||||
2. **Media blocks**: image, youtube, attachment
|
||||
3. **Content blocks**: divider, markdown, html
|
||||
|
||||
### Remaining Responsibilities of dees-wysiwyg-block.ts:
|
||||
1. Shadow DOM container management
|
||||
2. Handler delegation for all operations
|
||||
3. Generic block wrapper styles
|
||||
4. Selection/cursor tracking
|
||||
5. Event listener setup (until fully delegated to handlers)
|
||||
|
||||
## Future Improvements
|
||||
- Consider moving all event handling to block handlers
|
||||
- Simplify the handler delegation pattern
|
||||
- Move generic block styles to a shared location
|
||||
- Consider removing the need for special-casing any block types
|
@@ -1,87 +0,0 @@
|
||||
# WYSIWYG Block Migration Status
|
||||
|
||||
## Overview
|
||||
This document tracks the progress of migrating all WYSIWYG blocks to the new block handler architecture.
|
||||
|
||||
## Migration Progress
|
||||
|
||||
### ✅ Phase 1: Architecture Foundation
|
||||
- Created block handler base classes and interfaces
|
||||
- Created block registry system
|
||||
- Created common block styles and utilities
|
||||
|
||||
### ✅ Phase 2: Divider Block
|
||||
- Simple non-editable block as proof of concept
|
||||
- See `phase2-summary.md` for details
|
||||
|
||||
### ✅ Phase 3: Paragraph Block
|
||||
- First text block with full editing capabilities
|
||||
- Established patterns for text selection, cursor tracking, and content splitting
|
||||
- See commit history for implementation details
|
||||
|
||||
### ✅ Phase 4: Heading Blocks
|
||||
- All three heading levels (h1, h2, h3) using unified handler
|
||||
- See `phase4-summary.md` for details
|
||||
|
||||
### ✅ Phase 5: Other Text Blocks
|
||||
- [x] Quote block - Completed with custom styling
|
||||
- [x] Code block - Completed with syntax highlighting, line numbers, and copy button
|
||||
- [x] List block - Completed with bullet and numbered list support
|
||||
|
||||
### 🔄 Phase 6: Media Blocks (In Progress)
|
||||
- [x] Image block - Completed with click upload, drag-drop, and base64 encoding
|
||||
- [x] YouTube block - Completed with URL parsing and video embedding
|
||||
- [ ] Attachment block
|
||||
|
||||
### 📋 Phase 7: Content Blocks (Planned)
|
||||
- [ ] Markdown block
|
||||
- [ ] HTML block
|
||||
|
||||
## Block Handler Status
|
||||
|
||||
| Block Type | Handler Created | Registered | Tested | Notes |
|
||||
|------------|----------------|------------|---------|-------|
|
||||
| divider | ✅ | ✅ | ✅ | Complete |
|
||||
| paragraph | ✅ | ✅ | ✅ | Complete |
|
||||
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| quote | ✅ | ✅ | ✅ | Complete with custom styling |
|
||||
| code | ✅ | ✅ | ✅ | Complete with highlighting, line numbers, copy |
|
||||
| list | ✅ | ✅ | ✅ | Complete with bullet/numbered support |
|
||||
| image | ✅ | ✅ | ✅ | Complete with upload, drag-drop support |
|
||||
| youtube | ✅ | ✅ | ✅ | Complete with URL parsing, video embedding |
|
||||
| attachment | ❌ | ❌ | ❌ | Phase 6 |
|
||||
| markdown | ❌ | ❌ | ❌ | Phase 7 |
|
||||
| html | ❌ | ❌ | ❌ | Phase 7 |
|
||||
|
||||
## Files Modified During Migration
|
||||
|
||||
### Core Architecture Files
|
||||
- `blocks/block.base.ts` - Base handler interface and class
|
||||
- `blocks/block.registry.ts` - Registry for handlers
|
||||
- `blocks/block.styles.ts` - Common styles
|
||||
- `blocks/index.ts` - Main exports
|
||||
- `wysiwyg.blockregistration.ts` - Registration of all handlers
|
||||
|
||||
### Handler Files Created
|
||||
- `blocks/content/divider.block.ts`
|
||||
- `blocks/text/paragraph.block.ts`
|
||||
- `blocks/text/heading.block.ts`
|
||||
- `blocks/text/quote.block.ts`
|
||||
- `blocks/text/code.block.ts`
|
||||
- `blocks/text/list.block.ts`
|
||||
- `blocks/media/image.block.ts`
|
||||
- `blocks/media/youtube.block.ts`
|
||||
|
||||
### Main Component Updates
|
||||
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
|
||||
|
||||
## Next Steps
|
||||
1. Begin Phase 6: Media blocks migration
|
||||
- Start with image block (most common media type)
|
||||
- Implement YouTube block for video embedding
|
||||
- Create attachment block for file uploads
|
||||
2. Follow established patterns from existing handlers
|
||||
3. Test thoroughly after each migration
|
||||
4. Update documentation as blocks are completed
|
@@ -1,7 +0,0 @@
|
||||
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
|
||||
* We try to have separated concerns in different classes
|
||||
* We try to have clean concise and managable code
|
||||
* lets log whats happening, so if something goes wrong, we understand whats happening.
|
||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
||||
* Make sure to hand over correct shodowroots.
|
Reference in New Issue
Block a user