UNPKG

ttabs-svelte

Version:

A flexible layout management system with draggable, resizable tiles and tabs for Svelte applications. Like in VSCode

1,249 lines 78.1 kB
import { generateId } from './utils/tile-utils'; import { DEFAULT_THEME, resolveTheme } from './types/theme-types'; import { parseSizeValue, calculateSizes } from './utils/size-utils'; import { Column, Grid, Panel, Row, Tab } from './ttabsObjects'; import { createValidationMiddleware } from './validation'; /** * Ttabs class implementation */ export class TTabs { tiles = $state({}); activePanel = $state(null); focusedActiveTab = $state(null); rootGridId = $state(''); componentRegistry = $state({}); theme = $state(DEFAULT_THEME); // State change listeners stateChangeListeners = []; debouncedStateChangeListeners = []; pendingNotification = null; pendingStateChanges = false; defaultComponentIdForEmptyTiles; // Validation middleware validationMiddleware; setupFromScratchCallback; /** * Find the root grid ID from the current tiles * @returns The ID of the root grid * @throws Error if no root grid is found * @private */ findRootGridId() { const rootGridId = Object.values(this.tiles) .find(tile => tile.type === 'grid' && !tile.parent)?.id || ''; if (rootGridId === '') { throw new Error('No root grid found'); } return rootGridId; } constructor(options = {}) { // Initialize validation middleware this.validationMiddleware = createValidationMiddleware(this, options.validators || [], options.defaultLayoutCreator); this.setupFromScratchCallback = options.setupFromScratch; // Initialize state if (options.tiles) { if (Array.isArray(options.tiles)) { // Convert array to record this.tiles = {}; // Add each tile to the state options.tiles.forEach(tile => { this.tiles[tile.id] = tile; }); // Find the root grid in the initial state this.rootGridId = this.findRootGridId(); } else { // Record format provided directly this.tiles = options.tiles; // Find the root grid this.rootGridId = this.findRootGridId(); } // Validate initial layout when created with tiles this.validateLayout(); } else { // Auto-create a root grid if no initial state is provided this.rootGridId = this.addGrid(); } // Initialize theme with resolution for inheritance if (options.theme) { this.theme = resolveTheme(options.theme); } else { this.theme = DEFAULT_THEME; } // Set focused tab if provided if (options.focusedTab && this.getTile(options.focusedTab)) { this.focusedActiveTab = options.focusedTab; } this.defaultComponentIdForEmptyTiles = options.defaultComponentIdForEmptyTiles; } /** * Subscribe to state changes * @param callback Function to call when state changes * @returns Unsubscribe function */ subscribe(callback) { this.stateChangeListeners.push(callback); // Call immediately with current state callback(this.tiles); // Return unsubscribe function return () => { this.stateChangeListeners = this.stateChangeListeners.filter(cb => cb !== callback); }; } /** * Subscribe to state changes with debouncing (calls at the end of the frame) * @param callback Function to call when state changes * @returns Unsubscribe function */ subscribeDebounced(callback) { this.debouncedStateChangeListeners.push(callback); // Call immediately with current state callback(this.tiles); // Return unsubscribe function return () => { this.debouncedStateChangeListeners = this.debouncedStateChangeListeners.filter(cb => cb !== callback); }; } /** * Notify all subscribers of state change */ notifyStateChange() { // Immediately notify regular subscribers this.stateChangeListeners.forEach(callback => { callback(this.tiles); }); // Mark that we have pending state changes this.pendingStateChanges = true; // Schedule debounced notifications at the end of the frame if (this.debouncedStateChangeListeners.length > 0 && this.pendingNotification === null) { this.pendingNotification = requestAnimationFrame(() => { // Only process if we have pending changes if (this.pendingStateChanges) { this.debouncedStateChangeListeners.forEach(callback => { callback(this.tiles); }); // Reset the pending state changes flag this.pendingStateChanges = false; } this.pendingNotification = null; }); } } /** * Register a component for content rendering * @param componentId Unique identifier for the component * @param component Svelte component to render * @param defaultProps Optional default props for the component */ registerComponent(componentId, component, defaultProps = {}) { this.componentRegistry[componentId] = { component, defaultProps }; } /** * Get a registered component by ID * @param componentId The component identifier * @returns The component and its default props, or null if not found */ getContentComponent(componentId) { return this.componentRegistry[componentId] || null; } /** * Check if a component is registered * @param componentId The component identifier * @returns True if the component is registered */ hasContentComponent(componentId) { return !!this.componentRegistry[componentId]; } /** * Set component to a column or a tab * @param parentId ID of the parent column or tab * @param componentId ID of the registered component * @param props Props to pass to the component * @returns ID of the new content with component */ setComponent(parentId, componentId, props = {}) { // Verify component exists if (!this.hasContentComponent(componentId)) { throw new Error(`Component with ID ${componentId} is not registered`); } // Get parent tile const parent = this.getTile(parentId); if (!parent) { throw new Error(`Parent tile with ID ${parentId} not found`); } // Handle different parent types if (parent.type === 'tab') { // Tab content handling const tab = parent; // Check if tab already has content - update it instead of creating new if (tab.content && this.getTile(tab.content)) { const existingContent = this.getTile(tab.content); if (existingContent && existingContent.type === 'content') { this.updateTile(tab.content, { componentId, data: { ...existingContent.data, componentProps: props } }); return tab.content; } } // Create the content const contentId = this.addTile({ type: 'content', parent: parentId, componentId, data: { componentProps: props } }); // Update tab's content this.updateTile(parentId, { content: contentId }); return contentId; } else if (parent.type === 'column') { // Column content handling const column = parent; // Check if column already has a child if (column.child && this.getTile(column.child)) { // Remove existing child if it's content type const existingChild = this.getTile(column.child); if (existingChild && existingChild.type === 'content') { // Update existing content instead of creating new this.updateTile(column.child, { componentId, data: { componentProps: props } }); return column.child; } else { // Remove existing child that's not content type this.removeTile(column.child); } } // Create the content const contentId = this.addTile({ type: 'content', parent: parentId, componentId, data: { componentProps: props } }); // Update column's child reference this.updateTile(parentId, { child: contentId }); return contentId; } else { throw new Error(`Cannot add content to a parent of type ${parent.type}. Content can only be child of a tab or column.`); } } /** * Get all tiles */ getTiles() { return this.tiles; } /** * Get the active panel ID */ getActivePanel() { return this.activePanel; } /** * Get a specific tile by ID with type casting */ getTile(id) { return (this.tiles[id] || null); } /** * Get a grid by ID, throwing an error if not found * @throws Error if the tile is not found or not a grid */ getGrid(id) { const tile = this.tiles[id]; if (!tile) { throw new Error(`Grid not found: ${id}`); } if (tile.type !== 'grid') { throw new Error(`Tile ${id} is not a grid, found ${tile.type}`); } return tile; } /** * Get a row by ID, throwing an error if not found * @throws Error if the tile is not found or not a row */ getRow(id) { const tile = this.tiles[id]; if (!tile) { throw new Error(`Row not found: ${id}`); } if (tile.type !== 'row') { throw new Error(`Tile ${id} is not a row, found ${tile.type}`); } return tile; } /** * Get a column by ID, throwing an error if not found * @throws Error if the tile is not found or not a column */ getColumn(id) { const tile = this.tiles[id]; if (!tile) { throw new Error(`Column not found: ${id}`); } if (tile.type !== 'column') { throw new Error(`Tile ${id} is not a column, found ${tile.type}`); } return tile; } /** * Get a panel by ID, throwing an error if not found * @throws Error if the tile is not found or not a panel */ getPanel(id) { const tile = this.tiles[id]; if (!tile) { throw new Error(`Panel not found: ${id}`); } if (tile.type !== 'panel') { throw new Error(`Tile ${id} is not a panel, found ${tile.type}`); } return tile; } /** * Get a tab by ID, throwing an error if not found * @throws Error if the tile is not found or not a tab */ getTab(id) { const tile = this.tiles[id]; if (!tile) { throw new Error(`Tab not found: ${id}`); } if (tile.type !== 'tab') { throw new Error(`Tile ${id} is not a tab, found ${tile.type}`); } return tile; } /** * Get children of a tile filtered by type */ getChildren(parentId, tileType = null) { return Object.values(this.tiles) .filter((tile) => Boolean(tile)) .filter(tile => tile.parent === parentId) .filter(tile => !tileType || tile.type === tileType); } /** * Get the currently active panel */ getActivePanelTile() { return this.activePanel ? this.getTile(this.activePanel) : null; } /** * Get the active tab of the active panel */ getActivePanelTab() { const panel = this.getActivePanelTile(); if (!panel || !panel.activeTab) return null; return this.getTile(panel.activeTab); } /** * Get the content associated with a tab */ getTabContent(tabId) { const tab = this.getTile(tabId); if (!tab) return null; return this.getTile(tab.content); } /** * Add a new tile to the layout * @param tile Tile to add (requires type property) * @returns ID of the new tile */ addTile(tile) { // Generate ID if not provided const id = tile.id || generateId(); // Create a new tile with the ID const newTile = { id, ...tile }; // Add to state this.tiles[id] = newTile; // Notify state changes this.notifyStateChange(); return id; } /** * Update a tile with the given changes * @param id ID of the tile to update * @param updates Changes to apply * @returns True if successful */ updateTile(id, updates) { const tile = this.getTile(id); if (!tile) return false; // Special handling for grid rows to ensure it's always an array if (tile.type === 'grid' && 'rows' in updates) { updates = { ...updates, rows: updates.rows || [] }; } // Create the new tile state const newTile = { ...tile, ...updates }; // Check if anything actually changed if (JSON.stringify(tile) === JSON.stringify(newTile)) { // No changes, return without updating or notifying return true; } // Apply changes this.tiles[id] = newTile; // Notify state changes this.notifyStateChange(); return true; } /** * Find all tiles that reference the specified tile * @param tileId ID of the tile to find references to * @returns Array of tiles that reference the specified tile */ findTilesReferencingTile(tileId) { return Object.values(this.tiles).filter(tile => { if (tile.type === 'grid' && tile.rows.includes(tileId)) { return true; } if (tile.type === 'row' && tile.columns.includes(tileId)) { return true; } if (tile.type === 'column' && tile.child === tileId) { return true; } if (tile.type === 'panel' && tile.tabs.includes(tileId)) { return true; } if (tile.type === 'tab' && tile.content === tileId) { return true; } return false; }); } /** * Remove a tile from the layout * @param id ID of the tile to remove * @returns True if successful */ removeTile(id) { const tile = this.getTile(id); if (!tile) return false; // Check for references to this tile throughout the layout const referencingTiles = this.findTilesReferencingTile(id); // Remove references to this tile referencingTiles.forEach((referencingTile) => { if (referencingTile.type === 'grid' && 'rows' in referencingTile) { this.updateTile(referencingTile.id, { rows: referencingTile.rows.filter((rowId) => rowId !== id) }); } else if (referencingTile.type === 'row' && 'columns' in referencingTile) { this.updateTile(referencingTile.id, { columns: referencingTile.columns.filter((colId) => colId !== id) }); } else if (referencingTile.type === 'column' && 'child' in referencingTile) { if (referencingTile.child === id) { this.updateTile(referencingTile.id, { child: '' }); } } else if (referencingTile.type === 'panel' && 'tabs' in referencingTile) { this.updateTile(referencingTile.id, { tabs: referencingTile.tabs.filter((tabId) => tabId !== id), activeTab: referencingTile.activeTab === id ? null : referencingTile.activeTab }); } else if (referencingTile.type === 'tab' && 'content' in referencingTile) { if (referencingTile.content === id) { this.updateTile(referencingTile.id, { content: '' }); } } }); // Delete the tile itself delete this.tiles[id]; // Notify state changes this.notifyStateChange(); return true; } /** * Set the active panel */ setActivePanel(id) { const panel = this.getTile(id); if (!panel || panel.type !== 'panel') return false; this.activePanel = id; return true; } /** * Set the active tab */ setActiveTab(tabId) { const tab = this.getTile(tabId); if (!tab || tab.type !== 'tab') return false; const panelId = tab.parent; if (!panelId) return false; const panel = this.getTile(panelId); if (!panel || panel.type !== 'panel') return false; this.updateTile(panelId, { activeTab: tabId }); this.setActivePanel(panelId); // Also set as focused tab this.focusedActiveTab = tabId; return true; } /** * Set the focused active tab * @param tabId ID of the tab to focus * @returns True if successful */ setFocusedActiveTab(tabId) { const tab = this.getTile(tabId); if (!tab || tab.type !== 'tab') return false; // Ensure the tab is active in its panel const panelId = tab.parent; if (!panelId) return false; const panel = this.getTile(panelId); if (!panel || panel.type !== 'panel') return false; // If the tab is not the active one in its panel, make it active if (panel.activeTab !== tabId) { this.updateTile(panelId, { activeTab: tabId }); } // Set the panel as active if it's not already if (this.activePanel !== panelId) { this.setActivePanel(panelId); } // Set the focused tab this.focusedActiveTab = tabId; return true; } /** * Get the focused active tab */ getFocusedActiveTabTile() { return this.focusedActiveTab ? this.getTile(this.focusedActiveTab) : null; } /** * Reorder tabs within a panel */ reorderTabs(panelId, oldIndex, newIndex) { const panel = this.getTile(panelId); if (!panel || panel.type !== 'panel') return false; // Make sure indices are valid if (oldIndex < 0 || oldIndex >= panel.tabs.length || newIndex < 0 || newIndex >= panel.tabs.length) { return false; } // Create a new array with reordered tabs const reorderedTabs = [...panel.tabs]; const [movedTab] = reorderedTabs.splice(oldIndex, 1); reorderedTabs.splice(newIndex, 0, movedTab); // Update the panel this.updateTile(panelId, { tabs: reorderedTabs }); return true; } /** * Move a tab from one panel to another * @param tabId The tab to move * @param targetPanelId The panel to move the tab to * @param targetIndex Optional index where to insert the tab in the target panel * @returns boolean True if the operation was successful */ moveTab(tabId, targetPanelId, targetIndex) { try { // Get the tab and determine source panel const tab = this.getTab(tabId); const sourcePanelId = tab.parent; if (!sourcePanelId) { throw new Error(`Tab ${tabId} has no parent panel`); } // Get the source and target panels const sourcePanel = this.getPanel(sourcePanelId); const targetPanel = this.getPanel(targetPanelId); // Check if the tab is in the source panel if (!sourcePanel.tabs.includes(tabId)) { throw new Error(`Tab ${tabId} is not in source panel ${sourcePanelId}`); } // Create copies of the tab arrays const sourceTabs = [...sourcePanel.tabs]; const targetTabs = [...targetPanel.tabs]; // Remove the tab from the source panel const sourceIndex = sourceTabs.indexOf(tabId); sourceTabs.splice(sourceIndex, 1); // Find a new active tab for the source panel if needed let newSourceActiveTab = sourcePanel.activeTab; if (sourcePanel.activeTab === tabId) { // Get the next tab, or the previous one if there is no next newSourceActiveTab = sourceTabs.length > 0 ? sourceTabs[Math.min(sourceIndex, sourceTabs.length - 1)] : null; } // Add the tab to the target panel at the specified index or at the end if (targetIndex !== undefined && targetIndex >= 0 && targetIndex <= targetTabs.length) { targetTabs.splice(targetIndex, 0, tabId); } else { // Append to the end targetTabs.push(tabId); } // Update the tab's parent reference this.updateTile(tabId, { parent: targetPanelId }); // Update the source panel this.updateTile(sourcePanelId, { tabs: sourceTabs, activeTab: newSourceActiveTab }); // Update the target panel and make the moved tab active this.updateTile(targetPanelId, { tabs: targetTabs, activeTab: tabId }); // Set the target panel as active this.setActivePanel(targetPanelId); // Check if source panel is now empty and should be cleaned up this.cleanupContainers(sourcePanelId); return true; } catch (error) { console.error('Error in moveTab:', error); return false; } } /** * Split a panel to create a new layout * @param tabId The tab to move to the new panel * @param targetPanelId The panel being split * @param direction The direction to split ('top', 'right', 'bottom', 'left') * @returns boolean True if the split operation was successful */ splitPanel(tabId, targetPanelId, direction) { try { // Get the tab and determine source panel const tab = this.getTab(tabId); const sourcePanelId = tab.parent; if (!sourcePanelId) { throw new Error(`Tab ${tabId} has no parent panel`); } // Get the source and target panels const sourcePanel = this.getPanel(sourcePanelId); const targetPanel = this.getPanel(targetPanelId); // Check if the tab is in the source panel if (!sourcePanel.tabs.includes(tabId)) { throw new Error(`Tab ${tabId} is not in source panel ${sourcePanelId}`); } // Prevent splitting a panel with its only tab if (sourcePanelId === targetPanelId && sourcePanel.tabs.length === 1) { console.warn('Cannot split a panel with its only tab'); return false; } // Find the column containing the target panel const targetParentId = targetPanel.parent; if (!targetParentId) { throw new Error(`Target panel ${targetPanelId} has no parent`); } const parentColumn = this.getColumn(targetParentId); // Different approach based on split direction if (direction === 'left' || direction === 'right') { // Horizontal split: Find the row containing the column with the target panel const rowId = parentColumn.parent; if (!rowId) { throw new Error(`Column ${parentColumn.id} has no parent row`); } const row = this.getRow(rowId); // Find the index of the current column in the row const columnIndex = row.columns.indexOf(parentColumn.id); if (columnIndex === -1) { throw new Error(`Column ${parentColumn.id} not found in row ${rowId}`); } // Get the target column width let targetColumnWidth = parentColumn.width; // Calculate new width - half of the current column's width const newWidth = { value: targetColumnWidth.value / 2, unit: targetColumnWidth.unit }; // Update existing column width this.updateTile(parentColumn.id, { width: newWidth }); // Create a new column const newColumnId = this.addTile({ type: 'column', parent: rowId, width: newWidth, child: undefined }); // Create a new panel in the new column const newPanelId = this.addTile({ type: 'panel', parent: newColumnId, tabs: [], activeTab: null }); // Update new column with the new panel this.updateTile(newColumnId, { child: newPanelId }); // Update row with the new column in the correct position (left or right of existing) const newColumns = [...row.columns]; if (direction === 'right') { // Add after the current column newColumns.splice(columnIndex + 1, 0, newColumnId); } else { // Add before the current column newColumns.splice(columnIndex, 0, newColumnId); } this.updateTile(rowId, { columns: newColumns }); // Update the target panel's parent reference if needed if (parentColumn.child === targetPanelId) { this.updateTile(targetPanelId, { parent: parentColumn.id }); } // Move the tab to the new panel return this.moveTab(tabId, newPanelId); } else if (direction === 'top' || direction === 'bottom') { // Vertical split // Check if the column already contains a grid let gridId; if (parentColumn.child === targetPanelId) { // Column directly contains the panel, need to create a grid // Create new grid gridId = this.addTile({ type: 'grid', parent: parentColumn.id, rows: [] }); // Create two rows for the existing panel and the new one const rowHeight = { value: 50, unit: '%' }; // 50% each // Create rows in appropriate order based on direction const firstRowId = this.addTile({ type: 'row', parent: gridId, height: rowHeight, columns: [] }); const secondRowId = this.addTile({ type: 'row', parent: gridId, height: rowHeight, columns: [] }); // Create a column for the existing panel const existingPanelColumnId = this.addTile({ type: 'column', parent: direction === 'top' ? secondRowId : firstRowId, width: { value: 100, unit: '%' }, // 100% of row width child: targetPanelId }); // Create a column for the new panel const newPanelColumnId = this.addTile({ type: 'column', parent: direction === 'top' ? firstRowId : secondRowId, width: { value: 100, unit: '%' }, // 100% of row width child: undefined }); // Create a new panel const newPanelId = this.addTile({ type: 'panel', parent: newPanelColumnId, tabs: [], activeTab: null }); // Update column with new panel this.updateTile(newPanelColumnId, { child: newPanelId }); // Update rows with their columns this.updateTile(firstRowId, { columns: [direction === 'top' ? newPanelColumnId : existingPanelColumnId] }); this.updateTile(secondRowId, { columns: [direction === 'top' ? existingPanelColumnId : newPanelColumnId] }); // Update grid with rows this.updateTile(gridId, { rows: [firstRowId, secondRowId] }); // Update target panel parent to point to its new column this.updateTile(targetPanelId, { parent: existingPanelColumnId }); // Update column to point to the grid instead of directly to the panel this.updateTile(parentColumn.id, { child: gridId }); // Move the tab to the new panel return this.moveTab(tabId, newPanelId); } else { // Column already contains a grid or something else, need to handle differently const existingChild = parentColumn.child; if (!existingChild) { throw new Error(`Column ${parentColumn.id} has no child`); } try { // Try to get the child as a grid const grid = this.getGrid(existingChild); gridId = existingChild; // Find the row containing the target panel let targetRowId = null; let targetRow = null; for (const rowId of grid.rows) { try { const row = this.getRow(rowId); // Check if any column in this row contains the target panel for (const colId of row.columns) { try { const col = this.getColumn(colId); if (col.child === targetPanelId) { targetRowId = rowId; targetRow = row; break; } } catch (error) { console.error(`Error checking column ${colId}:`, error); // Continue to next column } } if (targetRowId) break; } catch (error) { console.error(`Error checking row ${rowId}:`, error); // Continue to next row } } if (!targetRowId || !targetRow) { throw new Error(`Could not find row containing panel ${targetPanelId}`); } // Get the target row height let targetRowHeight = targetRow.height; // Calculate new height - half of the current row's height const newHeight = { value: targetRowHeight.unit === '%' ? targetRowHeight.value / 2 : 50, unit: targetRowHeight.unit }; // Update existing row height this.updateTile(targetRowId, { height: newHeight }); // Create a new row const newRowId = this.addTile({ type: 'row', parent: gridId, height: newHeight, columns: [] }); // Create a new column in the row const newColumnId = this.addTile({ type: 'column', parent: newRowId, width: { value: 100, unit: '%' }, // 100% of the row child: undefined }); // Create a new panel in the column const newPanelId = this.addTile({ type: 'panel', parent: newColumnId, tabs: [], activeTab: null }); // Update column with panel this.updateTile(newColumnId, { child: newPanelId }); // Update row with column this.updateTile(newRowId, { columns: [newColumnId] }); // Update grid with new row in the right position const newRows = [...grid.rows]; const rowIndex = grid.rows.indexOf(targetRowId); if (direction === 'bottom') { // Add after the current row newRows.splice(rowIndex + 1, 0, newRowId); } else { // Add before the current row newRows.splice(rowIndex, 0, newRowId); } this.updateTile(gridId, { rows: newRows }); // Move the tab to the new panel return this.moveTab(tabId, newPanelId); } catch (error) { // The child is not a grid console.error(`Error handling existing child ${existingChild}:`, error); return false; } } } return false; } catch (error) { console.error('Error in splitPanel:', error); return false; } } /** * Recursively checks and cleans up empty containers * Traverses up the hierarchy to remove unnecessary container structures */ cleanupContainers(tileId) { const tile = this.getTile(tileId); if (!tile) return; let parentId = tile.parent; if (tile.dontClean) { if (parentId) { // If the tile is marked as dontClean, we don't try to clean it but // go up the hierarchy in case if the parent's parent needs cleaning. this.cleanupContainers(parentId); } return; } let shouldRemove = false; // Check if the tile should be removed based on its type switch (tile.type) { case 'panel': // Remove the panel if it has no tabs const panel = tile; shouldRemove = panel.tabs.length === 0; break; case 'column': // Remove the column if it has no valid child const column = tile; shouldRemove = !column.child || !this.tiles[column.child]; // Redistribute the width to siblings if removing if (shouldRemove && parentId) { const row = this.getRow(parentId); const siblingColumns = row.columns.filter(id => id !== tileId); if (siblingColumns.length > 0) { this.redistributeWidths(column); } } break; case 'row': // Remove the row if it has no columns const row = tile; shouldRemove = row.columns.length === 0; // Redistribute the height to siblings if removing if (shouldRemove && parentId) { const grid = this.getGrid(parentId); const siblingRows = grid.rows.filter(id => id !== tileId); if (siblingRows.length > 0) { this.redistributeHeights(row); } } break; case 'grid': // Remove the grid if it has no rows and is not the root const grid = tile; shouldRemove = grid.rows.length === 0 && !!grid.parent; // Check if we can simplify the grid hierarchy (for non root grids) if (!shouldRemove && grid.parent) { // In case if we have a grid with a single row and a single column, we can remove // the grid and replace it with the only child of the column from that grid. if (grid.rows.length === 1) { const row = this.getRow(grid.rows[0]); // Just one row with a single column if (row.columns.length === 1) { const column = this.getColumn(row.columns[0]); const child = column.child; if (child) { const parentColumn = this.getColumn(grid.parent); this.updateTile(parentColumn.id, { child: child }); this.updateTile(child, { parent: parentColumn.id }); this.updateTile(grid.id, { rows: [] }); this.removeTile(grid.id); } } } } break; } // Remove the tile if necessary and continue cleanup with parent if (shouldRemove) { this.removeTile(tileId); } if (parentId) { this.cleanupContainers(parentId); } } /** * Redistributes width from a removed column to its sibling columns * @param removedColumnId ID of the column being removed */ redistributeWidths(removedColumn) { // Get the available width to redistribute const availableWidth = removedColumn.width; if (availableWidth.value <= 0) return; // Get the parent row to find siblings const parentRowId = removedColumn.parent; if (!parentRowId) return; const parentRow = this.getTile(parentRowId); if (!parentRow || parentRow.type !== 'row') return; // Get sibling columns (excluding the one being removed) const siblingColumnIds = parentRow.columns.filter(id => id !== removedColumn.id); if (siblingColumnIds.length === 0) return; // Get the sibling column objects const siblingColumns = siblingColumnIds .map(id => this.getTile(id)) .filter(Boolean); if (siblingColumns.length === 0) return; // Check if any siblings have % units const percentColumns = siblingColumns.filter(col => col.width.unit === '%'); // If we have percentage-based columns, only distribute to those const targetColumns = percentColumns.length > 0 ? percentColumns : siblingColumns; const totalExistingWidth = targetColumns.reduce((sum, col) => sum + col.width.value, 0); if (totalExistingWidth <= 0) return; // Prevent division by zero // Distribute proportionally among target columns targetColumns.forEach(column => { const proportion = column.width.value / totalExistingWidth; const newWidth = column.width.value + (availableWidth.value * proportion); this.updateTile(column.id, { width: { value: newWidth, unit: column.width.unit } }); }); } /** * Redistributes height from a removed row to its sibling rows * @param removedRow The row being removed */ redistributeHeights(removedRow) { // Get the available height to redistribute const availableHeight = removedRow.height; if (availableHeight.value <= 0) return; // Get the parent grid to find siblings const parentGridId = removedRow.parent; if (!parentGridId) return; const parentGrid = this.getTile(parentGridId); if (!parentGrid || parentGrid.type !== 'grid') return; // Get sibling rows (excluding the one being removed) const siblingRowIds = parentGrid.rows.filter(id => id !== removedRow.id); if (siblingRowIds.length === 0) return; // Get the sibling row objects const siblingRows = siblingRowIds .map(id => this.getTile(id)) .filter(Boolean); if (siblingRows.length === 0) return; // Check if any siblings have % units const percentRows = siblingRows.filter(row => row.height.unit === '%'); // If we have percentage-based rows, only distribute to those const targetRows = percentRows.length > 0 ? percentRows : siblingRows; const totalExistingHeight = targetRows.reduce((sum, row) => sum + row.height.value, 0); if (totalExistingHeight <= 0) return; // Prevent division by zero // Distribute proportionally among target rows targetRows.forEach(row => { const proportion = row.height.value / totalExistingHeight; const newHeight = row.height.value + (availableHeight.value * proportion); this.updateTile(row.id, { height: { value: newHeight, unit: row.height.unit } }); }); } /** * Reset the layout (but keeping the theme, components, etc.) */ /** * Reset the layout (but keeping the theme, components, etc.) */ resetTiles() { this.tiles = {}; this.activePanel = null; this.focusedActiveTab = null; this.rootGridId = ""; } /** * Validate the current layout * @returns True if layout is valid, false otherwise */ validateLayout() { return this.validationMiddleware.validate(this); } /** * Reset to the default layout */ resetToDefaultLayout() { this.validationMiddleware.resetToDefault(this); } /** * Add a custom validator to the validation middleware * @param validator The validator to add */ addValidator(validator) { this.validationMiddleware.validators.push(validator); } /** * Set the default layout creator function * @param creator Function that creates a default layout */ setDefaultLayoutCreator(creator) { this.validationMiddleware.defaultLayoutCreator = creator; } /** * Subscribe to layout validation errors * @param handler Function to call when validation errors occur * @returns Unsubscribe function */ onValidationError(handler) { this.validationMiddleware.addErrorHandler(handler); // Return unsubscribe function return () => { this.validationMiddleware.errorHandlers = this.validationMiddleware.errorHandlers.filter(h => h !== handler); }; } /** * Adds a grid to the layout * @param parentId Optional parent column ID * @returns ID of the new grid * @throws Error if parent hierarchy rules are violated */ addGrid(parentId) { if (parentId) { const parent = this.getTile(parentId); if (!parent) { throw new Error(`Parent tile with ID ${parentId} not found`); } // Validate parent hierarchy rules if (parent.type === 'column') { // A grid can be added to a column } else { throw new Error(`Cannot add a grid to a parent of type ${parent.type}. Grids can only be children of columns.`); } } else { if (this.rootGridId) { throw new Error("Cannot add a grid to the root grid"); } } const gridId = this.addTile({ type: 'grid', parent: parentId || null, rows: [] }); if (!parentId) { this.rootGridId = gridId; } // If parent is a column, update the column's child reference if (parentId) { const parent = this.getTile(parentId); if (parent && parent.type === 'column') { this.updateTile(parentId, { child: gridId }); } } return gridId; } /** * Adds a row to a grid * @param parentId ID of the parent grid * @param height Height of the row as a string (e.g., "100%", "260px") * @returns ID of the new row * @throws Error if parent hierarchy rules are violated */ addRow(parentId, height) { const parent = this.getTile(parentId); if (!parent) { throw new Error(`Parent tile with ID ${parentId} not found`); } // Validate parent hierarchy rules if (parent.type !== 'grid') { throw new Error(`Cannot add a row to a parent of type ${parent.type}. Rows can only be children of grids.`); } const grid = parent; let sizeInfo; if (!height) { if (grid.rows.length === 0) { height = '100%'; } else { // Calculate default percentage for the new row const newRowPercentage = 100 / (grid.rows.length + 1); // Get existing percentage-based rows const percentageRows = grid.rows .map(id => this.getTile(id)) .filter((row) => !!row && row.height.unit === '%'); if (percentageRows.length > 0) { // Calculate total existing percentage const totalExistingPercentage = percentageRows.reduce((sum, row) => sum + row.height.value, 0); // Calculate scaling factor to redistribute remaining space const remainingPercentage = 100 - newRowPercentage; const scalingFactor = remainingPercentage / totalExistingPercentage; // Update existing rows proportionally percentageRows.forEach(row => { const newPercentage = row.height.value * scalingFactor; this.updateTile(row.id, { height: { value: newPercentage, unit: '%' } }); }); } height = `${newRowPercentage}%`; } } // Parse the height value sizeInfo = parseSizeValue(height); // Create the row const rowId = this.addTile({ type: 'row', parent: parentId, columns: [], height: sizeInfo }); // Update grid's rows this.updateTile(parentId, { rows: [...grid.rows, rowId] }); //this.recalculateLayout(parentId); return rowId; } /** * Adds a column to a row * @param parentId ID of the parent row * @param width Width of the column as a string (e.g., "100%", "260px") * @returns ID of the new column * @throws Error if parent hierarchy rules are violated */ addColumn(parentId, width) { const parent = this.getTile(parentId); if (!parent) { throw new Error(`Parent tile with ID ${parentId} not found`); } // Validate parent hierarchy rules if (parent.type !== 'row') { throw new Err