mono/packages/ui/src/modules/layout/commands.ts
2026-03-21 20:18:25 +01:00

1701 lines
63 KiB
TypeScript

import { Command, CommandContext } from './types';
// Force rebuild
import { WidgetInstance, PageLayout, LayoutContainer, FlexibleContainer, AnyContainer, UnifiedLayoutManager, ColumnDef } from '@/modules/layout/LayoutManager';
// Helper to find a container by ID in the layout tree
const findContainer = (containers: AnyContainer[], id: string): AnyContainer | null => {
for (const container of containers) {
if (container.id === id) return container;
if (container.type === 'container' && container.children) {
const found = findContainer(container.children, id);
if (found) return found;
}
}
return null;
};
// Helper to find a LayoutContainer specifically
const findLayoutContainer = (containers: AnyContainer[], id: string): LayoutContainer | null => {
const found = findContainer(containers, id);
return found && found.type === 'container' ? found as LayoutContainer : null;
};
// Helper to finding parent container
const findParentContainer = (containers: AnyContainer[], childId: string): LayoutContainer | null => {
for (const container of containers) {
if (container.type === 'container') {
if (container.children.some(c => c.id === childId)) return container;
const found = findParentContainer(container.children, childId);
if (found) return found;
}
}
return null;
}
// Helper to find widget location
const findWidgetLocation = (containers: AnyContainer[], widgetId: string): { container: AnyContainer, index: number, widget: WidgetInstance } | null => {
for (const container of containers) {
const idx = container.widgets.findIndex(w => w.id === widgetId);
if (idx !== -1) {
return { container, index: idx, widget: container.widgets[idx] };
}
if (container.type === 'container' && container.children) {
const found = findWidgetLocation(container.children, widgetId);
if (found) return found;
}
}
return null;
};
// --- Add Widget Command ---
export class AddWidgetCommand implements Command {
id: string;
type = 'ADD_WIDGET';
timestamp: number;
private pageId: string;
private containerId: string;
private widget: WidgetInstance;
private index: number;
constructor(pageId: string, containerId: string, widget: WidgetInstance, index: number = -1) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.widget = widget;
this.index = index;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// Clone layout to avoid mutation
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId);
if (!container) throw new Error(`Container not found: ${this.containerId}`);
if (this.index === -1) {
container.widgets.push(this.widget);
} else {
container.widgets.splice(this.index, 0, this.widget);
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
// Find widget anywhere (robust against moves)
const location = findWidgetLocation(newLayout.containers, this.widget.id);
if (location) {
location.container.widgets.splice(location.index, 1);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
} else {
console.warn(`Widget ${this.widget.id} not found for undo add`);
}
}
}
// --- Remove Widget Command ---
export class RemoveWidgetCommand implements Command {
id: string;
type = 'REMOVE_WIDGET';
timestamp: number;
private pageId: string;
private widgetId: string;
// State capture for undo
private containerId: string | null = null;
private index: number = -1;
private widget: WidgetInstance | null = null;
constructor(pageId: string, widgetId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.widgetId = widgetId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// 1. Capture state BEFORE modification
const location = findWidgetLocation(layout.containers, this.widgetId);
if (!location) {
console.warn(`Widget ${this.widgetId} not found for removal`);
return;
}
this.containerId = location.container.id;
this.index = location.index;
this.widget = location.widget;
// 2. Perform Removal
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const newLocation = findWidgetLocation(newLayout.containers, this.widgetId);
if (newLocation) {
newLocation.container.widgets.splice(newLocation.index, 1);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (!this.containerId || !this.widget || this.index === -1) {
throw new Error("Cannot undo remove: State was not captured correctly");
}
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId);
if (!container) throw new Error(`Original container ${this.containerId} not found`);
// Restore widget at original index
// Ensure index is valid
if (this.index > container.widgets.length) {
container.widgets.push(this.widget);
} else {
container.widgets.splice(this.index, 0, this.widget);
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Update Widget Settings Command ---
export class UpdateWidgetSettingsCommand implements Command {
id: string;
type = 'UPDATE_WIDGET_SETTINGS';
timestamp: number;
private pageId: string;
private widgetId: string;
private newSettings: Record<string, any>; // Store full new settings or partial? Partial is what comes in.
// State capture for undo
private oldSettings: Record<string, any> | null = null;
constructor(pageId: string, widgetId: string, newSettings: Record<string, any>) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.widgetId = widgetId;
this.newSettings = newSettings;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// 1. Capture state (snapshot of current props) BEFORE modification
// We need to find the widget in the CURRENT layout to get old props
const location = findWidgetLocation(layout.containers, this.widgetId);
if (!location) {
console.warn(`Widget ${this.widgetId} not found for update`);
return;
}
// Only capture oldSettings the FIRST time execute is called (or if we want to support re-execution)
// Since we create a NEW command instance for every user action, this is fine.
// But for Redo, we don't want to recapture 'oldSettings' from the *already updated* state if we were in a weird state.
// Actually, Redo implies we are going from Undo -> Redo.
// Undo restored oldSettings. So Redo will see oldSettings.
// So capturing it again is fine, OR we check if it's null.
if (!this.oldSettings) {
this.oldSettings = JSON.parse(JSON.stringify(location.widget.props || {}));
}
// 2. Perform Update
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const newLocation = findWidgetLocation(newLayout.containers, this.widgetId);
if (newLocation) {
// merge
newLocation.widget.props = { ...newLocation.widget.props, ...this.newSettings };
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (!this.oldSettings) {
console.warn("Cannot undo update: State was not captured");
return;
}
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const location = findWidgetLocation(newLayout.containers, this.widgetId);
if (location) {
// Restore exact old props
location.widget.props = this.oldSettings;
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
} else {
console.warn(`Widget ${this.widgetId} not found for undo update`);
}
}
}
// --- Update Page Parent Command ---
export class UpdatePageParentCommand implements Command {
id: string;
type = 'UPDATE_PAGE_PARENT';
timestamp: number;
private pageId: string;
private oldParent: { id: string | null, title?: string, slug?: string } | null;
private newParent: { id: string | null, title?: string, slug?: string } | null;
private onUpdate?: (parent: { id: string | null, title?: string, slug?: string } | null) => void;
constructor(
pageId: string,
oldParent: { id: string | null, title?: string, slug?: string } | null,
newParent: { id: string | null, title?: string, slug?: string } | null,
onUpdate?: (parent: { id: string | null, title?: string, slug?: string } | null) => void
) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.oldParent = oldParent;
this.newParent = newParent;
this.onUpdate = onUpdate;
}
async execute(context: CommandContext): Promise<void> {
// Update local metadata state through context
context.updatePageMetadata(this.pageId, { parent: this.newParent?.id ?? null });
if (this.onUpdate) this.onUpdate(this.newParent);
}
async undo(context: CommandContext): Promise<void> {
// Revert local metadata state
context.updatePageMetadata(this.pageId, { parent: this.oldParent?.id ?? null });
if (this.onUpdate) this.onUpdate(this.oldParent);
}
}
// --- Update Page Meta Command ---
export class UpdatePageMetaCommand implements Command {
id: string;
type = 'UPDATE_PAGE_META';
timestamp: number;
private pageId: string;
private oldMeta: Record<string, any>;
private newMeta: Record<string, any>;
private onUpdate?: (meta: Record<string, any>) => void;
constructor(
pageId: string,
oldMeta: Record<string, any>,
newMeta: Record<string, any>,
onUpdate?: (meta: Record<string, any>) => void
) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.oldMeta = oldMeta;
this.newMeta = newMeta;
this.onUpdate = onUpdate;
}
async execute(context: CommandContext): Promise<void> {
context.updatePageMetadata(this.pageId, this.newMeta);
if (this.onUpdate) this.onUpdate(this.newMeta);
}
async undo(context: CommandContext): Promise<void> {
context.updatePageMetadata(this.pageId, this.oldMeta);
if (this.onUpdate) this.onUpdate(this.oldMeta);
}
}
// --- Add Container Command ---
export class AddContainerCommand implements Command {
id: string;
type = 'ADD_CONTAINER';
timestamp: number;
private pageId: string;
private parentContainerId?: string;
private container: LayoutContainer;
constructor(pageId: string, container: LayoutContainer, parentContainerId?: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.container = container;
this.parentContainerId = parentContainerId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (this.parentContainerId) {
const parent = findLayoutContainer(newLayout.containers, this.parentContainerId);
if (parent) {
this.container.order = parent.children.length;
parent.children.push(this.container);
}
} else {
this.container.order = newLayout.containers.length;
newLayout.containers.push(this.container);
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const remove = (containers: AnyContainer[]): boolean => {
const idx = containers.findIndex(c => c.id === this.container.id);
if (idx !== -1) {
containers.splice(idx, 1);
containers.forEach((c, i) => c.order = i);
return true;
}
for (const c of containers) {
if (c.type === 'container' && remove(c.children)) return true;
}
return false;
};
remove(newLayout.containers);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Batch Add Containers Command ---
// Adds multiple containers in a single execute() against one layout snapshot.
// Avoids React state-batching issue where sequential AddContainerCommand calls
// each read the same stale snapshot and only the last one survives.
export class BatchAddContainersCommand implements Command {
id: string;
type = 'BATCH_ADD_CONTAINERS';
timestamp: number;
private pageId: string;
private containers: LayoutContainer[];
constructor(pageId: string, containers: LayoutContainer[]) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containers = containers;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
for (const container of this.containers) {
container.order = newLayout.containers.length;
newLayout.containers.push(container);
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const idsToRemove = new Set(this.containers.map(c => c.id));
newLayout.containers = newLayout.containers.filter(c => !idsToRemove.has(c.id));
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Remove Container Command ---
export class RemoveContainerCommand implements Command {
id: string;
type = 'REMOVE_CONTAINER';
timestamp: number;
private pageId: string;
private containerId: string;
// State capture
private parentId: string | null = null;
private index: number = -1;
private container: AnyContainer | null = null;
constructor(pageId: string, containerId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// Capture state
const findAndCapture = (containers: AnyContainer[], parentId: string | null) => {
for (let i = 0; i < containers.length; i++) {
if (containers[i].id === this.containerId) {
this.parentId = parentId;
this.index = i;
this.container = JSON.parse(JSON.stringify(containers[i])); // Deep clone
return true;
}
if (containers[i].type === 'container') {
if (findAndCapture((containers[i] as LayoutContainer).children, containers[i].id)) return true;
}
}
return false;
};
if (findAndCapture(layout.containers, null)) {
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
newLayout.updatedAt = Date.now();
UnifiedLayoutManager.removeContainer(newLayout, this.containerId, true);
context.updateLayout(this.pageId, newLayout);
} else {
console.warn(`Container ${this.containerId} not found for removal`);
}
}
async undo(context: CommandContext): Promise<void> {
if (!this.container || this.index === -1) {
console.warn("Cannot undo remove container: State not captured");
return;
}
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (this.parentId === null) {
// Root container
if (this.index >= newLayout.containers.length) {
newLayout.containers.push(this.container);
} else {
newLayout.containers.splice(this.index, 0, this.container);
}
newLayout.containers.forEach((c, i) => c.order = i);
} else {
const parent = findLayoutContainer(newLayout.containers, this.parentId);
if (parent) {
if (this.index >= parent.children.length) {
parent.children.push(this.container as LayoutContainer);
} else {
parent.children.splice(this.index, 0, this.container as LayoutContainer);
}
parent.children.forEach((c, i) => c.order = i);
}
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Move Container Command ---
export class MoveContainerCommand implements Command {
id: string;
type = 'MOVE_CONTAINER';
timestamp: number;
private pageId: string;
private containerId: string;
private direction: 'up' | 'down';
constructor(pageId: string, containerId: string, direction: 'up' | 'down') {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.direction = direction;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.moveRootContainer(newLayout, this.containerId, this.direction)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const reverseDirection = this.direction === 'up' ? 'down' : 'up';
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.moveRootContainer(newLayout, this.containerId, reverseDirection)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
}
// --- Move Widget Command ---
export class MoveWidgetCommand implements Command {
id: string;
type = 'MOVE_WIDGET';
timestamp: number;
private pageId: string;
private widgetId: string;
private direction: 'up' | 'down' | 'left' | 'right';
constructor(pageId: string, widgetId: string, direction: 'up' | 'down' | 'left' | 'right') {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.widgetId = widgetId;
this.direction = direction;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const moved = UnifiedLayoutManager.moveWidgetInContainer(newLayout, this.widgetId, this.direction)
|| UnifiedLayoutManager.moveFlexWidget(newLayout, this.widgetId, this.direction);
if (moved) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
let reverseDirection: 'up' | 'down' | 'left' | 'right' = 'up';
switch (this.direction) {
case 'up': reverseDirection = 'down'; break;
case 'down': reverseDirection = 'up'; break;
case 'left': reverseDirection = 'right'; break;
case 'right': reverseDirection = 'left'; break;
}
const moved = UnifiedLayoutManager.moveWidgetInContainer(newLayout, this.widgetId, reverseDirection)
|| UnifiedLayoutManager.moveFlexWidget(newLayout, this.widgetId, reverseDirection);
if (moved) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
}
// --- Move Widget To Target Command (DnD cross-container) ---
export class MoveWidgetToTargetCommand implements Command {
id: string;
type = 'MOVE_WIDGET_TO_TARGET';
timestamp: number;
private pageId: string;
private widgetId: string;
private target: { containerId: string; index?: number; rowId?: string; column?: number };
private originalPosition: { containerId: string; index: number; rowId?: string; column?: number } | null = null;
constructor(
pageId: string,
widgetId: string,
target: { containerId: string; index?: number; rowId?: string; column?: number }
) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.widgetId = widgetId;
this.target = target;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const original = UnifiedLayoutManager.moveWidgetToTarget(newLayout, this.widgetId, this.target);
if (original) {
this.originalPosition = original;
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (!this.originalPosition) return;
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const result = UnifiedLayoutManager.moveWidgetToTarget(newLayout, this.widgetId, this.originalPosition);
if (result) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
}
// --- Update Container Columns Command ---
export class UpdateContainerColumnsCommand implements Command {
id: string;
type = 'UPDATE_CONTAINER_COLUMNS';
timestamp: number;
private pageId: string;
private containerId: string;
private newColumns: number;
private oldColumns: number | null = null;
constructor(pageId: string, containerId: string, columns: number) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.newColumns = columns;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
// Capture old
if (this.oldColumns === null) {
const container = findLayoutContainer(layout.containers, this.containerId);
if (container) this.oldColumns = container.columns;
}
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.updateContainerColumns(newLayout, this.containerId, this.newColumns)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (this.oldColumns === null) return;
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.updateContainerColumns(newLayout, this.containerId, this.oldColumns)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
}
// --- Update Container Settings Command ---
export class UpdateContainerSettingsCommand implements Command {
id: string;
type = 'UPDATE_CONTAINER_SETTINGS';
timestamp: number;
private pageId: string;
private containerId: string;
private newSettings: Partial<LayoutContainer['settings']>;
private oldSettings: Partial<LayoutContainer['settings']> | null = null;
constructor(pageId: string, containerId: string, settings: Partial<LayoutContainer['settings']>) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.newSettings = settings;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
// Capture old
if (this.oldSettings === null) {
const container = findContainer(layout.containers, this.containerId);
if (container) this.oldSettings = { ...container.settings };
}
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.updateContainerSettings(newLayout, this.containerId, this.newSettings)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (this.oldSettings === null) return;
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.updateContainerSettings(newLayout, this.containerId, this.oldSettings)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
}
// --- Rename Widget Command ---
export class RenameWidgetCommand implements Command {
id: string;
type = 'RENAME_WIDGET';
timestamp: number;
private pageId: string;
private oldId: string;
private newId: string;
constructor(pageId: string, oldId: string, newId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.oldId = oldId;
this.newId = newId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.renameWidget(newLayout, this.oldId, this.newId)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
} else {
throw new Error(`Failed to rename widget: ${this.newId} might already exist or widget not found`);
}
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) return;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (UnifiedLayoutManager.renameWidget(newLayout, this.newId, this.oldId)) {
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
}
// --- Replace Layout Command ---
export class ReplaceLayoutCommand implements Command {
id: string;
type = 'REPLACE_LAYOUT';
timestamp: number;
private pageId: string;
private newLayout: PageLayout;
private oldLayout: PageLayout | null = null;
constructor(pageId: string, newLayout: PageLayout) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.newLayout = newLayout;
}
async execute(context: CommandContext): Promise<void> {
const currentLayout = context.layouts.get(this.pageId);
// If current layout not found, we might be importing for the first time or initializing.
// But for undo history, we need something to undo TO.
// If no current layout, maybe we just set it.
// But context usually requires existing key.
// Let's assume layout exists if we are in editor.
if (currentLayout) {
this.oldLayout = JSON.parse(JSON.stringify(currentLayout));
}
// Apply new layout
const layoutToApply = JSON.parse(JSON.stringify(this.newLayout)) as PageLayout;
layoutToApply.id = this.pageId;
layoutToApply.updatedAt = Date.now();
context.updateLayout(this.pageId, layoutToApply);
}
async undo(context: CommandContext): Promise<void> {
if (this.oldLayout) {
this.oldLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, this.oldLayout);
}
}
}
// --- Paste Widgets Command ---
export class PasteWidgetsCommand implements Command {
id: string;
type = 'PASTE_WIDGETS';
timestamp: number;
private pageId: string;
private containerId: string;
private widgets: WidgetInstance[];
private pastedIds: string[] = [];
constructor(pageId: string, containerId: string, widgets: WidgetInstance[]) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
// Deep clone and assign new IDs
this.widgets = widgets.map(w => {
const suffix = crypto.randomUUID().slice(0, 6);
const newId = `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`;
return { ...JSON.parse(JSON.stringify(w)), id: newId };
});
this.pastedIds = this.widgets.map(w => w.id);
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId);
if (!container) throw new Error(`Container not found: ${this.containerId}`);
for (const widget of this.widgets) {
container.widgets.push(JSON.parse(JSON.stringify(widget)));
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
for (const pastedId of this.pastedIds) {
const location = findWidgetLocation(newLayout.containers, pastedId);
if (location) {
location.container.widgets.splice(location.index, 1);
}
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Paste Containers Command ---
export class PasteContainersCommand implements Command {
id: string;
type = 'PASTE_CONTAINERS';
timestamp: number;
private pageId: string;
private containers: LayoutContainer[];
private insertIndex: number; // -1 means append
private pastedIds: string[] = [];
constructor(pageId: string, containers: LayoutContainer[], insertIndex: number = -1) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.insertIndex = insertIndex;
// Deep clone containers with new IDs
const cloneContainer = (c: LayoutContainer): LayoutContainer => {
const suffix = crypto.randomUUID().slice(0, 6);
return {
...JSON.parse(JSON.stringify(c)),
id: `${c.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`,
widgets: c.widgets.map(w => ({
...JSON.parse(JSON.stringify(w)),
id: `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${crypto.randomUUID().slice(0, 6)}`
})),
children: c.children ? c.children.map(cloneContainer) : []
};
};
this.containers = containers.map(cloneContainer);
this.pastedIds = this.containers.map(c => c.id);
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (this.insertIndex >= 0 && this.insertIndex < newLayout.containers.length) {
// Insert after the given index
newLayout.containers.splice(this.insertIndex + 1, 0, ...this.containers.map(c => JSON.parse(JSON.stringify(c))));
} else {
// Append at end
for (const container of this.containers) {
newLayout.containers.push(JSON.parse(JSON.stringify(container)));
}
}
// Reorder
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const idsToRemove = new Set(this.pastedIds);
newLayout.containers = newLayout.containers.filter(c => !idsToRemove.has(c.id));
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Add Flexible Container Command ---
export class AddFlexContainerCommand implements Command {
id: string;
type = 'ADD_FLEX_CONTAINER';
timestamp: number;
private pageId: string;
private container: FlexibleContainer;
constructor(pageId: string, container: FlexibleContainer) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.container = container;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
this.container.order = newLayout.containers.length;
newLayout.containers.push(this.container);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
newLayout.containers = newLayout.containers.filter(c => c.id !== this.container.id);
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Flex Add Row Command ---
export class FlexAddRowCommand implements Command {
id: string;
type = 'FLEX_ADD_ROW';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
constructor(pageId: string, containerId: string, rowId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId);
if (!container || container.type !== 'flex-container') return;
(container as FlexibleContainer).rows.push({
id: this.rowId,
columns: [{ width: 1, unit: 'fr' }, { width: 1, unit: 'fr' }],
sizing: 'constrained',
});
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId);
if (!container || container.type !== 'flex-container') return;
const fc = container as FlexibleContainer;
fc.rows = fc.rows.filter(r => r.id !== this.rowId);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Flex Remove Row Command ---
export class FlexRemoveRowCommand implements Command {
id: string;
type = 'FLEX_REMOVE_ROW';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private capturedRow: any = null;
private capturedIndex: number = -1;
constructor(pageId: string, containerId: string, rowId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// Capture state before modification
const origContainer = findContainer(layout.containers, this.containerId);
if (!origContainer || origContainer.type !== 'flex-container') return;
const fc = origContainer as FlexibleContainer;
const idx = fc.rows.findIndex(r => r.id === this.rowId);
if (idx === -1) return;
this.capturedRow = JSON.parse(JSON.stringify(fc.rows[idx]));
this.capturedIndex = idx;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const newContainer = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
newContainer.rows.splice(idx, 1);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
if (!this.capturedRow || this.capturedIndex === -1) return;
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
container.rows.splice(this.capturedIndex, 0, this.capturedRow);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Flex Add Column Command ---
export class FlexAddColumnCommand implements Command {
id: string;
type = 'FLEX_ADD_COLUMN';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
constructor(pageId: string, containerId: string, rowId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.columns.push({ width: 1, unit: 'fr' });
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row || row.columns.length <= 1) return;
row.columns.pop();
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Flex Remove Column Command ---
export class FlexRemoveColumnCommand implements Command {
id: string;
type = 'FLEX_REMOVE_COLUMN';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private columnIndex: number;
private capturedColumn: any = null;
private capturedWidgets: any[] = [];
constructor(pageId: string, containerId: string, rowId: string, columnIndex: number) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
this.columnIndex = columnIndex;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// Capture before modification
const origContainer = findContainer(layout.containers, this.containerId) as FlexibleContainer;
if (!origContainer) return;
const origRow = origContainer.rows.find(r => r.id === this.rowId);
if (!origRow || this.columnIndex >= origRow.columns.length) return;
this.capturedColumn = JSON.parse(JSON.stringify(origRow.columns[this.columnIndex]));
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
const row = container.rows.find(r => r.id === this.rowId);
if (!row || row.columns.length <= 1) return;
row.columns.splice(this.columnIndex, 1);
// Capture widgets in the deleted column before removing them
this.capturedWidgets = JSON.parse(JSON.stringify(
container.widgets.filter((w: any) => w.rowId === this.rowId && w.column === this.columnIndex)
));
// Remove widgets in the deleted column and shift higher columns down
container.widgets = container.widgets.filter(
(w: any) => !(w.rowId === this.rowId && w.column === this.columnIndex)
);
container.widgets.forEach((w: any) => {
if (w.rowId === this.rowId && w.column > this.columnIndex) {
w.column -= 1;
}
});
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
if (!this.capturedColumn) return;
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
// Shift higher columns back up before re-inserting
container.widgets.forEach((w: any) => {
if (w.rowId === this.rowId && w.column >= this.columnIndex) {
w.column += 1;
}
});
// Restore column definition and captured widgets
row.columns.splice(this.columnIndex, 0, this.capturedColumn);
container.widgets.push(...JSON.parse(JSON.stringify(this.capturedWidgets)));
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// ─── Flex: Set columns preset ───────────────────────────────────────────
export class FlexSetColumnsPresetCommand implements Command {
id: string;
type = 'FLEX_SET_COLUMNS_PRESET';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private newColumns: ColumnDef[];
private capturedColumns: ColumnDef[] | null = null;
constructor(pageId: string, containerId: string, rowId: string, newColumns: ColumnDef[]) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
this.newColumns = newColumns;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// Capture current columns for undo
const origContainer = findContainer(layout.containers, this.containerId) as FlexibleContainer;
if (!origContainer) return;
const origRow = origContainer.rows.find(r => r.id === this.rowId);
if (!origRow) return;
this.capturedColumns = JSON.parse(JSON.stringify(origRow.columns));
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.columns = JSON.parse(JSON.stringify(this.newColumns));
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
if (!this.capturedColumns) return;
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.columns = JSON.parse(JSON.stringify(this.capturedColumns));
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// ─── Flex: Move row up/down ─────────────────────────────────────────────
export class FlexMoveRowCommand implements Command {
id: string;
type = 'FLEX_MOVE_ROW';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private direction: 'up' | 'down';
constructor(pageId: string, containerId: string, rowId: string, direction: 'up' | 'down') {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
this.direction = direction;
}
private swap(context: CommandContext, dir: 'up' | 'down'): void {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const idx = container.rows.findIndex(r => r.id === this.rowId);
if (idx === -1) return;
const targetIdx = dir === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= container.rows.length) return;
// Swap
[container.rows[idx], container.rows[targetIdx]] = [container.rows[targetIdx], container.rows[idx]];
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async execute(context: CommandContext): Promise<void> {
this.swap(context, this.direction);
}
async undo(context: CommandContext): Promise<void> {
this.swap(context, this.direction === 'up' ? 'down' : 'up');
}
}
// ─── Flex: Set row sizing mode ──────────────────────────────────────────
export class FlexSetRowSizingCommand implements Command {
id: string;
type = 'FLEX_SET_ROW_SIZING';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private newSizing: 'constrained' | 'unconstrained';
private capturedSizing: 'constrained' | 'unconstrained' | undefined;
constructor(pageId: string, containerId: string, rowId: string, newSizing: 'constrained' | 'unconstrained') {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
this.newSizing = newSizing;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const origContainer = findContainer(layout.containers, this.containerId) as FlexibleContainer;
if (!origContainer) return;
const origRow = origContainer.rows.find(r => r.id === this.rowId);
if (!origRow) return;
this.capturedSizing = origRow.sizing;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.sizing = this.newSizing;
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.sizing = this.capturedSizing;
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// ─── Flex: Set row padding ──────────────────────────────────────────────
export class FlexSetRowPaddingCommand implements Command {
id: string;
type = 'FLEX_SET_ROW_PADDING';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private newPadding: string | undefined;
private capturedPadding: string | undefined;
constructor(pageId: string, containerId: string, rowId: string, newPadding: string | undefined) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
this.newPadding = newPadding;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const origContainer = findContainer(layout.containers, this.containerId) as FlexibleContainer;
if (!origContainer) return;
const origRow = origContainer.rows.find(r => r.id === this.rowId);
if (!origRow) return;
this.capturedPadding = origRow.padding;
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.padding = this.newPadding;
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
row.padding = this.capturedPadding;
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// ─── Flex: Duplicate row ────────────────────────────────────────────────
export class FlexDuplicateRowCommand implements Command {
id: string;
type = 'FLEX_DUPLICATE_ROW';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private newRowId: string | null = null;
constructor(pageId: string, containerId: string, rowId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const idx = container.rows.findIndex(r => r.id === this.rowId);
if (idx === -1) return;
const sourceRow = container.rows[idx];
this.newRowId = UnifiedLayoutManager.generateRowId();
// Clone row definition with new ID
const clonedRow = JSON.parse(JSON.stringify(sourceRow));
clonedRow.id = this.newRowId;
// Clone widgets that belong to this row
const sourceWidgets = container.widgets.filter((w: any) => w.rowId === this.rowId);
const clonedWidgets = sourceWidgets.map((w: any) => {
const clone = JSON.parse(JSON.stringify(w));
clone.id = crypto.randomUUID();
clone.rowId = this.newRowId;
return clone;
});
container.rows.splice(idx + 1, 0, clonedRow);
container.widgets.push(...clonedWidgets);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
if (!this.newRowId) return;
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
// Remove cloned row
container.rows = container.rows.filter(r => r.id !== this.newRowId);
// Remove cloned widgets
container.widgets = container.widgets.filter((w: any) => w.rowId !== this.newRowId);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// ─── Flex: Duplicate column ─────────────────────────────────────────────
export class FlexDuplicateColumnCommand implements Command {
id: string;
type = 'FLEX_DUPLICATE_COLUMN';
timestamp: number;
private pageId: string;
private containerId: string;
private rowId: string;
private columnIndex: number;
constructor(pageId: string, containerId: string, rowId: string, columnIndex: number) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.rowId = rowId;
this.columnIndex = columnIndex;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row || this.columnIndex >= row.columns.length) return;
// Clone column definition
const clonedCol = JSON.parse(JSON.stringify(row.columns[this.columnIndex]));
row.columns.splice(this.columnIndex + 1, 0, clonedCol);
// Clone widgets in that column, shift to new column index
const newColIdx = this.columnIndex + 1;
const sourceWidgets = container.widgets.filter(
(w: any) => w.rowId === this.rowId && w.column === this.columnIndex
);
const clonedWidgets = sourceWidgets.map((w: any) => {
const clone = JSON.parse(JSON.stringify(w));
clone.id = crypto.randomUUID();
clone.column = newColIdx;
return clone;
});
// Shift existing widgets in columns after the duplicated one
container.widgets.forEach((w: any) => {
if (w.rowId === this.rowId && w.column > this.columnIndex) {
w.column += 1;
}
});
container.widgets.push(...clonedWidgets);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId) as FlexibleContainer;
if (!container) return;
const row = container.rows.find(r => r.id === this.rowId);
if (!row) return;
const removedColIdx = this.columnIndex + 1;
// Remove duplicated column
row.columns.splice(removedColIdx, 1);
// Remove widgets in the duplicated column
container.widgets = container.widgets.filter(
(w: any) => !(w.rowId === this.rowId && w.column === removedColIdx)
);
// Shift widgets back
container.widgets.forEach((w: any) => {
if (w.rowId === this.rowId && w.column > removedColIdx) {
w.column -= 1;
}
});
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}