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
JavaScript
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