diff --git a/ts_web/elements/dees-dashboardgrid.ts b/ts_web/elements/dees-dashboardgrid.ts deleted file mode 100644 index d833a04..0000000 --- a/ts_web/elements/dees-dashboardgrid.ts +++ /dev/null @@ -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` -
- -
No widgets configured
-
Add widgets to populate the dashboard
-
- `; - } - - 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` -
- ${this.showGridLines ? this.renderGridLines(gridHeight) : ''} - ${this.widgets.map(widget => this.renderWidget(widget))} - ${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''} -
- `; - } - - 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` -
- `); - } - - // 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` -
- `); - } - - return html` -
- ${verticalLines} - ${horizontalLines} -
- `; - } - - 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` -
-
- ${widget.title ? html` -
this.startDrag(e, widget) : null} - > - ${widget.icon ? html`` : ''} - ${widget.title} -
- ` : ''} -
- ${widget.content} -
- ${!isLocked && !widget.noResize ? html` -
this.startResize(e, widget, 'e')}>
-
this.startResize(e, widget, 's')}>
-
this.startResize(e, widget, 'se')}>
- ` : ''} -
-
- `; - } - - 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` -
-
-
- `; - } - - 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) { - 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(); - } -} \ No newline at end of file diff --git a/ts_web/elements/dees-dashboardgrid/README.md b/ts_web/elements/dees-dashboardgrid/README.md new file mode 100644 index 0000000..aaab6a4 --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/README.md @@ -0,0 +1,47 @@ +# 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. diff --git a/ts_web/elements/dees-dashboardgrid/contextmenu.ts b/ts_web/elements/dees-dashboardgrid/contextmenu.ts new file mode 100644 index 0000000..a96ec54 --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/contextmenu.ts @@ -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); +}; diff --git a/ts_web/elements/dees-dashboardgrid.demo.ts b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts similarity index 50% rename from ts_web/elements/dees-dashboardgrid.demo.ts rename to ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts index 945ee59..a9f413d 100644 --- a/ts_web/elements/dees-dashboardgrid.demo.ts +++ b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts @@ -6,9 +6,8 @@ export const demoFunc = () => { return html` { const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid; - - // Set initial widgets - grid.widgets = [ + + const seedWidgets = [ { id: 'metrics1', x: 0, @@ -22,7 +21,7 @@ export const demoFunc = () => {
$124,563
↑ 12.5% from last month
- ` + `, }, { id: 'metrics2', @@ -37,7 +36,7 @@ export const demoFunc = () => {
8,234
↑ 5.2% from last week
- ` + `, }, { id: 'chart1', @@ -54,71 +53,126 @@ export const demoFunc = () => {
Chart visualization area
- ` - } + `, + }, ]; - - // Configure grid + + 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 ('addEventListener' in mediaQuery) { + mediaQuery.addEventListener('change', handleBreakpoint); + } else { + mediaQuery.addListener(handleBreakpoint); + } + handleBreakpoint(); + 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` -
-
New widget content
-
${Math.floor(Math.random() * 1000)}
-
- ` - }; - 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'; - }); + + 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` +
+
New widget content
+
${Math.floor( + Math.random() * 1000, + )}
+
+ `, + }; + 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; } }); - - // Listen to grid events + grid.addEventListener('widget-move', (e: CustomEvent) => { - console.log('Widget moved:', e.detail.widget); + console.log('Widget moved:', e.detail.widget, 'Displaced:', e.detail.displaced); }); - grid.addEventListener('widget-resize', (e: CustomEvent) => { - console.log('Widget resized:', e.detail.widget); + 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(); }}> @@ -163,29 +225,31 @@ export const demoFunc = () => { Toggle Animation - + Toggle Grid Lines - + Add Widget Compact Grid + Reset Layout - + Toggle Edit Mode - +
- +
- Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning +
Drag to reposition, resize from handles, or right-click a header to delete a tile.
+
`; -}; \ No newline at end of file +}; diff --git a/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts new file mode 100644 index 0000000..02617c5 --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts @@ -0,0 +1,688 @@ +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; + 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; + + @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; + + private containerBounds: DOMRect | null = null; + private dragState: DragState | null = null; + private resizeState: ResizeState | null = null; + private resizeObserver?: ResizeObserver; + private interactionActive = false; + + connectedCallback(): void { + super.connectedCallback(); + this.computeMetrics(); + this.observeResize(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.disconnectResizeObserver(); + this.releasePointerEvents(); + } + + protected updated(changed: Map): 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 { + if (this.widgets.length === 0) { + return html` +
+ +
No widgets configured
+
Add widgets to populate the dashboard
+
+ `; + } + + const metrics = this.ensureMetrics(); + const margins = this.resolvedMargins ?? resolveMargins(this.margin); + const cellHeight = metrics.cellHeightPx; + const gridHeight = calculateGridHeight(this.widgets, margins, cellHeight); + + return html` +
+ ${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null} + ${this.widgets.map(widget => this.renderWidget(widget, metrics, margins))} + ${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null} +
+ `; + } + + 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`
`); + } + + const rows = Math.ceil(gridHeight / cellPlusMarginY); + for (let row = 0; row <= rows; row++) { + const top = row * cellPlusMarginY; + horizontal.push(html`
`); + } + + return html` +
+ ${vertical} + ${horizontal} +
+ `; + } + + private renderWidget( + widget: DashboardWidget, + metrics: GridCellMetrics, + margins: DashboardResolvedMargins, + ): TemplateResult { + const isDragging = this.dragState?.widgetId === widget.id; + const isResizing = this.resizeState?.widgetId === widget.id; + const isLocked = widget.locked || !this.editable; + const rect = this.computeWidgetRect(widget, 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` +
+
+ ${widget.title + ? html` +
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`` : null} + ${widget.title} +
+ ` + : null} +
+ ${widget.content} +
+ ${!isLocked && !widget.noResize + ? html` +
this.startResize(evt, widget, 'e')} + >
+
this.startResize(evt, widget, 's')} + >
+
this.startResize(evt, widget, 'se')} + >
+ ` + : null} +
+
+ `; + } + + 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` +
+
+
+ `; + } + + 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 }, + 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 widget = this.widgets.find(item => item.id === this.dragState!.widgetId); + if (!widget) return; + + event.preventDefault(); + + 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(this.widgets, widget.id, { x: coords.x, y: coords.y }, this.columns); + if (placement) { + this.dragState = { + ...this.dragState, + currentPointer: { clientX: event.clientX, clientY: event.clientY }, + lastPlacement: placement, + }; + this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h }; + } + + this.requestUpdate(); + }; + + private handleDragEnd = (event: PointerEvent): void => { + const dragState = this.dragState; + if (!dragState || event.pointerId !== dragState.pointerId) { + return; + } + + const target = this.placeholderPosition ?? dragState.start; + const placement = + dragState.lastPlacement ?? + resolveWidgetPlacement(this.widgets, dragState.widgetId, { x: target.x, y: target.y }, this.columns); + + 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 widget = this.widgets.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( + this.widgets, + widget.id, + { x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height }, + this.columns, + ); + + if (placement) { + this.resizeState = { ...this.resizeState, lastPlacement: placement }; + this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height }; + } + + this.requestUpdate(); + }; + + private handleResizeEnd = (event: PointerEvent): void => { + const resizeState = this.resizeState; + if (!resizeState || event.pointerId !== resizeState.pointerId) { + return; + } + + const placement = resizeState.lastPlacement; + + 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 = { + 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.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): 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, + 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 }; + } +} diff --git a/ts_web/elements/dees-dashboardgrid/index.ts b/ts_web/elements/dees-dashboardgrid/index.ts new file mode 100644 index 0000000..a131b52 --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/index.ts @@ -0,0 +1,2 @@ +export * from './dees-dashboardgrid.js'; +export * from './types.js'; diff --git a/ts_web/elements/dees-dashboardgrid/interaction.ts b/ts_web/elements/dees-dashboardgrid/interaction.ts new file mode 100644 index 0000000..6be6d4c --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/interaction.ts @@ -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, + }; +}; diff --git a/ts_web/elements/dees-dashboardgrid/layout.ts b/ts_web/elements/dees-dashboardgrid/layout.ts new file mode 100644 index 0000000..018cee4 --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/layout.ts @@ -0,0 +1,231 @@ +import type { + DashboardResolvedMargins, + DashboardMargin, + DashboardWidget, + DashboardLayoutItem, + GridCellMetrics, + LayoutDirection, +} from './types.js'; + +export const DEFAULT_MARGIN = 10; + +export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => { + if (typeof margin === 'number') { + return { + horizontal: margin, + vertical: margin, + top: margin, + right: margin, + bottom: margin, + left: margin, + }; + } + + const resolved = { + top: margin.top ?? DEFAULT_MARGIN, + right: margin.right ?? DEFAULT_MARGIN, + bottom: margin.bottom ?? DEFAULT_MARGIN, + left: margin.left ?? DEFAULT_MARGIN, + }; + + return { + ...resolved, + horizontal: (resolved.left + resolved.right) / 2, + vertical: (resolved.top + resolved.bottom) / 2, + }; +}; + +export const calculateCellMetrics = ( + containerWidth: number, + columns: number, + margins: DashboardResolvedMargins, + cellHeight: number, + cellHeightUnit: string, +): GridCellMetrics => { + const totalMarginWidth = margins.horizontal * (columns + 1); + const availableWidth = Math.max(containerWidth - totalMarginWidth, 0); + const cellWidthPx = columns > 0 ? availableWidth / columns : 0; + const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight; + + return { + containerWidth, + cellWidthPx, + marginHorizontalPx: margins.horizontal, + cellHeightPx, + marginVerticalPx: margins.vertical, + }; +}; + +export const calculateGridHeight = ( + widgets: DashboardWidget[], + margins: DashboardResolvedMargins, + cellHeight: number, +): number => { + if (widgets.length === 0) return 0; + const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0); + return maxY * cellHeight + (maxY + 1) * margins.vertical; +}; + +const overlaps = ( + widget: DashboardWidget, + x: number, + y: number, + w: number, + h: number, +) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y; + +export const collectCollisions = ( + widgets: DashboardWidget[], + target: DashboardWidget, + nextX: number, + nextY: number, + nextW: number = target.w, + nextH: number = target.h, +): DashboardWidget[] => { + return widgets.filter(widget => { + if (widget.id === target.id) return false; + return overlaps(widget, nextX, nextY, nextW, nextH); + }); +}; + +export const checkCollision = ( + widgets: DashboardWidget[], + target: DashboardWidget, + nextX: number, + nextY: number, +): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0; + +export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget }); + +export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget); + +export const findAvailablePosition = ( + widgets: DashboardWidget[], + width: number, + height: number, + columns: number, +): { x: number; y: number } => { + for (let y = 0; y < 200; y++) { + for (let x = 0; x <= columns - width; x++) { + const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height)); + if (isFree) { + return { x, y }; + } + } + } + + const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0); + return { x: 0, y: maxY }; +}; + +export interface PlacementResult { + widgets: DashboardWidget[]; + movedWidgets: string[]; + swappedWith?: string; +} + +export const resolveWidgetPlacement = ( + widgets: DashboardWidget[], + widgetId: string, + next: { x: number; y: number; w?: number; h?: number }, + columns: number, +): PlacementResult | null => { + const sourceWidgets = cloneWidgets(widgets); + const moving = sourceWidgets.find(widget => widget.id === widgetId); + const original = widgets.find(widget => widget.id === widgetId); + if (!moving || !original) { + return null; + } + + const target = { + x: next.x, + y: next.y, + w: next.w ?? moving.w, + h: next.h ?? moving.h, + }; + + moving.x = target.x; + moving.y = target.y; + moving.w = target.w; + moving.h = target.h; + + const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h); + + if (collisions.length === 0) { + return { widgets: sourceWidgets, movedWidgets: [moving.id] }; + } + + if (collisions.length === 1) { + const other = collisions[0]; + if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) { + const otherClone = sourceWidgets.find(widget => widget.id === other.id); + if (otherClone) { + otherClone.x = original.x; + otherClone.y = original.y; + return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id }; + } + } + } + + // attempt displacement cascade + const movedIds = new Set([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; + }); +}; diff --git a/ts_web/elements/dees-dashboardgrid/styles.ts b/ts_web/elements/dees-dashboardgrid/styles.ts new file mode 100644 index 0000000..41e4d8e --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/styles.ts @@ -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; + } + `, +]; diff --git a/ts_web/elements/dees-dashboardgrid/types.ts b/ts_web/elements/dees-dashboardgrid/types.ts new file mode 100644 index 0000000..3a80395 --- /dev/null +++ b/ts_web/elements/dees-dashboardgrid/types.ts @@ -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; +} diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 853e97a..301ae6c 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -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'; diff --git a/ts_web/elements/wysiwyg/CLEANUP-STATUS.md b/ts_web/elements/wysiwyg/CLEANUP-STATUS.md deleted file mode 100644 index 9904dee..0000000 --- a/ts_web/elements/wysiwyg/CLEANUP-STATUS.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/MIGRATION-STATUS.md b/ts_web/elements/wysiwyg/MIGRATION-STATUS.md deleted file mode 100644 index 6b9e2d9..0000000 --- a/ts_web/elements/wysiwyg/MIGRATION-STATUS.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/instructions.md b/ts_web/elements/wysiwyg/instructions.md deleted file mode 100644 index 6c7a07e..0000000 --- a/ts_web/elements/wysiwyg/instructions.md +++ /dev/null @@ -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.