WorkspaceLayout in officerdev workspace
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
import type { AppRegistry } from './types';
|
||||
|
||||
type AppPickerProps = {
|
||||
registry: AppRegistry;
|
||||
onSelect: (appType: string) => void;
|
||||
};
|
||||
|
||||
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
const entries = Object.entries(registry).filter(([, entry]) => entry.availableOnPanel !== false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 max-w-md">
|
||||
{entries.map(([key, entry]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-full border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-1.5 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
|
||||
type DropTarget = {
|
||||
panelId: string;
|
||||
zone: DropPosition;
|
||||
rect: DOMRect;
|
||||
};
|
||||
|
||||
function getDropZone(rect: DOMRect, x: number, y: number): DropPosition {
|
||||
const fx = (x - rect.left) / rect.width;
|
||||
const fy = (y - rect.top) / rect.height;
|
||||
|
||||
if (fx > 0.3 && fx < 0.7 && fy > 0.3 && fy < 0.7) return 'center';
|
||||
|
||||
const edges: [DropPosition, number][] = [
|
||||
['left', fx],
|
||||
['right', 1 - fx],
|
||||
['top', fy],
|
||||
['bottom', 1 - fy],
|
||||
];
|
||||
|
||||
return edges.reduce((min, cur) => (cur[1] < min[1] ? cur : min))[0];
|
||||
}
|
||||
|
||||
function getIndicatorStyle(rect: DOMRect, zone: DropPosition): React.CSSProperties {
|
||||
switch (zone) {
|
||||
case 'center':
|
||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
||||
case 'top':
|
||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height / 2 };
|
||||
case 'bottom':
|
||||
return { left: rect.left, top: rect.top + rect.height / 2, width: rect.width, height: rect.height / 2 };
|
||||
case 'left':
|
||||
return { left: rect.left, top: rect.top, width: rect.width / 2, height: rect.height };
|
||||
case 'right':
|
||||
return { left: rect.left + rect.width / 2, top: rect.top, width: rect.width / 2, height: rect.height };
|
||||
}
|
||||
}
|
||||
|
||||
export const DragOverlay = () => {
|
||||
const { dragSourceId, setDragSourceId, onMove } = useWorkspace();
|
||||
const [target, setTarget] = useState<DropTarget | null>(null);
|
||||
const panelRectsRef = useRef<Map<string, DOMRect>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragSourceId) {
|
||||
panelRectsRef.current.clear();
|
||||
setTarget(null);
|
||||
return;
|
||||
}
|
||||
const panels = document.querySelectorAll<HTMLElement>('[data-panel-id]');
|
||||
const map = new Map<string, DOMRect>();
|
||||
panels.forEach((el) => {
|
||||
const id = el.dataset.panelId!;
|
||||
if (id !== dragSourceId) map.set(id, el.getBoundingClientRect());
|
||||
});
|
||||
panelRectsRef.current = map;
|
||||
}, [dragSourceId]);
|
||||
|
||||
const handlePointerMove = useCallback((ev: React.PointerEvent) => {
|
||||
const { clientX: x, clientY: y } = ev;
|
||||
let found: DropTarget | null = null;
|
||||
|
||||
for (const [panelId, rect] of panelRectsRef.current) {
|
||||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
||||
found = { panelId, zone: getDropZone(rect, x, y), rect };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setTarget(found);
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (dragSourceId && target) {
|
||||
onMove(dragSourceId, target.panelId, target.zone);
|
||||
}
|
||||
setDragSourceId(null);
|
||||
}, [dragSourceId, target, onMove, setDragSourceId]);
|
||||
|
||||
if (!dragSourceId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 cursor-grabbing"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{target && (
|
||||
<div
|
||||
className="absolute rounded-lg border-2 border-duck-teal/60 bg-duck-teal/15 pointer-events-none"
|
||||
style={{ ...getIndicatorStyle(target.rect, target.zone), transition: 'all 75ms ease-out' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Columns, Rows, Trash2, X } from 'lucide-react';
|
||||
|
||||
type LayoutEditorProps = {
|
||||
panelId: string;
|
||||
hasWidget: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onClearApp: () => void;
|
||||
};
|
||||
|
||||
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearApp }: LayoutEditorProps) => (
|
||||
<div className="absolute top-1 right-1 flex gap-1 z-10">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||
onClick={() => onSplit(panelId, 'horizontal')}
|
||||
title="Split Horizontal"
|
||||
>
|
||||
<Columns size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||
onClick={() => onSplit(panelId, 'vertical')}
|
||||
title="Split Vertical"
|
||||
>
|
||||
<Rows size={14} />
|
||||
</button>
|
||||
{hasWidget && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||
onClick={onClearApp}
|
||||
title="Clear Widget"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
{!isLastPanel && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-red-400/10 border border-red-400/30 text-red-400/70 hover:text-red-400 hover:bg-red-400/20 cursor-pointer"
|
||||
onClick={() => onRemove(panelId)}
|
||||
title="Remove Panel"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,334 +0,0 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftRight, X, Minus } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import { Card } from '../Card';
|
||||
import { AppPicker } from './AppPicker';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from '../ui/context-menu';
|
||||
|
||||
type PanelSlotProps = {
|
||||
panel: LayoutPanel;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
interactive: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
};
|
||||
|
||||
type PanelContextMenuProps = {
|
||||
panelId: string;
|
||||
hasApp: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onClearApp: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry =>
|
||||
typeof v === 'object' && v !== null && 'component' in v;
|
||||
|
||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
||||
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panelId;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
|
||||
{isMaximized ? 'Restore' : 'Maximize'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
||||
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
||||
{!isLastPanel && (
|
||||
<ContextMenuItem className="text-red-500 focus:text-red-500" onClick={() => onRemove(panelId)}>
|
||||
Remove panel
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
{swapSourceId ? (
|
||||
<ContextMenuItem onClick={() => setSwapSourceId(null)}>Cancel swap</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem onClick={() => setSwapSourceId(panelId)}>Swap with...</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const SwapOverlay = ({ panelId }: { panelId: string }) => {
|
||||
const { swapSourceId, onSwap } = useWorkspace();
|
||||
|
||||
if (!swapSourceId || swapSourceId === panelId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-duck-teal/10 hover:bg-duck-teal/20 transition-colors cursor-pointer"
|
||||
onClick={() => onSwap(swapSourceId, panelId)}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-background/90 backdrop-blur-sm px-3 py-2 shadow-lg border border-duck-teal/30">
|
||||
<ArrowLeftRight className="h-4 w-4 text-duck-teal" />
|
||||
<span className="text-sm font-medium text-duck-teal">Swap here</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
||||
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
||||
|
||||
if (swapSourceId !== panelId) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 pointer-events-none ring-2 ring-inset ring-duck-teal/50 rounded-lg">
|
||||
<div className="absolute top-2 left-2 pointer-events-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSwapSourceId(null)}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal/90 px-2.5 py-1 text-xs font-medium text-white shadow cursor-pointer hover:bg-duck-teal"
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
Swapping... click to cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => {
|
||||
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panelId;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClearApp();
|
||||
}, [onClearApp]);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
setMaximizedPanelId(null);
|
||||
}, [setMaximizedPanelId]);
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
setMaximizedPanelId(panelId);
|
||||
}, [panelId, setMaximizedPanelId]);
|
||||
|
||||
if (isMaximized) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||
title="Restore"
|
||||
>
|
||||
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||
title={isLastPanel ? 'Clear app' : 'Close panel'}
|
||||
>
|
||||
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMaximize}
|
||||
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||
title="Maximize"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
|
||||
// const DragHandle = ({ panelId }: { panelId: string }) => {
|
||||
// const { setDragSourceId, dragSourceId } = useWorkspace();
|
||||
// const onPointerDown = useCallback((ev: React.PointerEvent) => { ev.preventDefault(); setDragSourceId(panelId); }, [panelId, setDragSourceId]);
|
||||
// if (dragSourceId) return null;
|
||||
// return (
|
||||
// <div className="absolute top-2 right-2 z-10 p-1.5 rounded-md bg-duck-dark/5 text-duck-dark/30 opacity-0 group-hover/panel:opacity-100 hover:!bg-duck-dark/10 hover:!text-duck-dark/60 transition-all cursor-grab" onPointerDown={onPointerDown}>
|
||||
// <GripVertical className="h-5 w-5" />
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// const DragSourceDimmer = ({ panelId }: { panelId: string }) => {
|
||||
// const { dragSourceId } = useWorkspace();
|
||||
// if (dragSourceId !== panelId) return null;
|
||||
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
|
||||
// };
|
||||
|
||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const { maximizedPanelId, transitioningPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panel.id;
|
||||
|
||||
const rawPanelComponent = components?.[panel.id];
|
||||
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
|
||||
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
|
||||
|
||||
const entry = panel.appType ? registry[panel.appType] : null;
|
||||
const AppComponent = PanelComponent ?? entry?.component;
|
||||
|
||||
// Resolve header, provider, onClose from PanelComponentEntry or registry
|
||||
const HeaderComponent = panelEntry?.header ?? entry?.header;
|
||||
const ProviderComponent = panelEntry?.provider ?? entry?.provider;
|
||||
const onClose = panelEntry?.onClose;
|
||||
|
||||
const contextMenu = interactive
|
||||
? (content: React.ReactNode) => (
|
||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||
{content}
|
||||
</PanelContextMenu>
|
||||
)
|
||||
: (content: React.ReactNode) => <>{content}</>;
|
||||
|
||||
const overlays = interactive ? (
|
||||
<>
|
||||
<SwapOverlay panelId={panel.id} />
|
||||
<SwapSourceIndicator panelId={panel.id} />
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (!AppComponent) {
|
||||
if (!interactive) {
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return contextMenu(
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
|
||||
<AppPicker registry={registry} onSelect={(type) => onSetApp(panel.id, type)} />
|
||||
</Card>
|
||||
{overlays}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry?.transparent) {
|
||||
return contextMenu(
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</div>
|
||||
{overlays}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
|
||||
const DefaultHeader = entry ? () => (
|
||||
<>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
||||
|
||||
const trafficLights = interactive ? (
|
||||
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
||||
) : null;
|
||||
|
||||
const headerContent = (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{trafficLights}
|
||||
</div>
|
||||
);
|
||||
|
||||
const headerBar = interactive ? (
|
||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||
{headerContent}
|
||||
</PanelContextMenu>
|
||||
) : headerContent;
|
||||
|
||||
const body = (
|
||||
<div className="flex-1 min-h-0">
|
||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inner = ProviderComponent ? (
|
||||
<ProviderComponent panelId={panel.id}>
|
||||
{headerBar}
|
||||
{body}
|
||||
</ProviderComponent>
|
||||
) : (
|
||||
<>
|
||||
{headerBar}
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMaximized) {
|
||||
return (
|
||||
<>
|
||||
{/* Placeholder to preserve layout space */}
|
||||
<div data-panel-id={panel.id} className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
|
||||
</div>
|
||||
{/* Maximized overlay — portalled to escape stacking contexts */}
|
||||
{createPortal(
|
||||
<div className="fixed inset-0 z-50 p-2">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
|
||||
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
{overlays}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { DropPosition } from './layout-utils';
|
||||
|
||||
type WorkspaceContextValue = {
|
||||
workspaceId: string | null;
|
||||
cwd: string;
|
||||
swapSourceId: string | null;
|
||||
setSwapSourceId: (id: string | null) => void;
|
||||
onSwap: (sourceId: string, targetId: string) => void;
|
||||
dragSourceId: string | null;
|
||||
setDragSourceId: (id: string | null) => void;
|
||||
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
|
||||
maximizedPanelId: string | null;
|
||||
setMaximizedPanelId: (id: string | null) => void;
|
||||
transitioningPanelId: string | null;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||
workspaceId: null,
|
||||
cwd: '~',
|
||||
swapSourceId: null,
|
||||
setSwapSourceId: noop,
|
||||
onSwap: noop,
|
||||
dragSourceId: null,
|
||||
setDragSourceId: noop,
|
||||
onMove: noop,
|
||||
maximizedPanelId: null,
|
||||
setMaximizedPanelId: noop,
|
||||
transitioningPanelId: null,
|
||||
});
|
||||
|
||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||
|
||||
export const useWorkspace = () => useContext(WorkspaceContext);
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Pencil, Check } from 'lucide-react';
|
||||
|
||||
type WorkspaceHeaderProps = {
|
||||
name: string;
|
||||
editing: boolean;
|
||||
onToggleEdit: () => void;
|
||||
};
|
||||
|
||||
export const WorkspaceHeader = ({ name, editing, onToggleEdit }: WorkspaceHeaderProps) => (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-duck-teal/10">
|
||||
<h2 className="text-sm font-bold text-duck-teal">{name}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer ${
|
||||
editing
|
||||
? 'bg-duck-teal/15 text-duck-teal'
|
||||
: 'text-duck-teal/60 hover:text-duck-teal hover:bg-duck-teal/10'
|
||||
}`}
|
||||
onClick={onToggleEdit}
|
||||
>
|
||||
{editing ? <Check size={14} /> : <Pencil size={14} />}
|
||||
{editing ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
|
||||
import { updateSizes } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
import { useAppRegistry } from 'officerdev';
|
||||
|
||||
type WorkspaceLayoutProps = {
|
||||
layout: LayoutNode;
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
registry?: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
workspaceId?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, workspaceId, cwd }: WorkspaceLayoutProps) => {
|
||||
const { registry: globalRegistry } = useAppRegistry();
|
||||
const registry = registryProp ?? globalRegistry;
|
||||
|
||||
const handleResized = useCallback(
|
||||
(groupId: string, sizes: number[]) => {
|
||||
onLayoutChange(updateSizes(layout, groupId, sizes));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
|
||||
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
|
||||
import { countPanels } from './layout-utils';
|
||||
import { PanelSlot } from './PanelSlot';
|
||||
|
||||
type WorkspaceRendererProps = {
|
||||
layout: LayoutNode;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
interactive?: boolean;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
};
|
||||
|
||||
export const WorkspaceRenderer = ({
|
||||
layout,
|
||||
registry,
|
||||
components,
|
||||
interactive = false,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: WorkspaceRendererProps) => {
|
||||
const totalPanels = countPanels(layout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LayoutNodeRendererProps = {
|
||||
node: LayoutNode;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
interactive: boolean;
|
||||
totalPanels: number;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
};
|
||||
|
||||
const getFixedHeight = (node: LayoutNode, registry: AppRegistry): number | undefined => {
|
||||
if (node.type === 'panel' && node.appType) return registry[node.appType]?.fixedHeight;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const LayoutNodeRenderer = ({
|
||||
node,
|
||||
registry,
|
||||
components,
|
||||
interactive,
|
||||
totalPanels,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: LayoutNodeRendererProps) => {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(sizes: number[]) => {
|
||||
if (node.type !== 'group') return;
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onResized(node.id, sizes);
|
||||
}, 500);
|
||||
},
|
||||
[node, onResized],
|
||||
);
|
||||
|
||||
if (node.type === 'panel') {
|
||||
return (
|
||||
<PanelSlot
|
||||
panel={node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
isLastPanel={totalPanels <= 1}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hasFixedChild = node.direction === 'vertical' && node.children.some((c) => getFixedHeight(c.node, registry) !== undefined);
|
||||
|
||||
if (hasFixedChild) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{node.children.map((child) => {
|
||||
const fixed = getFixedHeight(child.node, registry);
|
||||
return (
|
||||
<div key={child.node.id} className={fixed !== undefined ? 'shrink-0' : 'min-h-0 flex-1'} style={fixed !== undefined ? { height: fixed } : undefined}>
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup id={node.id} direction={node.direction} onLayout={handleLayout} className="h-full w-full">
|
||||
{node.children.map((child, i) => (
|
||||
<ChildEntry key={child.node.id} index={i} total={node.children.length}>
|
||||
<ResizablePanel id={child.node.id} order={i} defaultSize={child.size} minSize={5}>
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ChildEntry>
|
||||
))}
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
type ChildEntryProps = {
|
||||
index: number;
|
||||
total: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ChildEntry = ({ index, total, children }: ChildEntryProps) => {
|
||||
if (index === 0) return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
|
||||
import type { LayoutNode, WorkspaceState, EphemeralPanels } from './types';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
import { useAppRegistry } from 'officerdev';
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
workspace: WorkspaceState;
|
||||
cwd?: string;
|
||||
ephemeral?: EphemeralPanels | null;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const WorkspaceView = ({ workspace, cwd = '~', ephemeral }: WorkspaceViewProps) => {
|
||||
const { registry } = useAppRegistry();
|
||||
|
||||
const layout = workspace.value;
|
||||
const onLayoutChange = workspace.setValue;
|
||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
|
||||
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
||||
|
||||
const setMaximizedAnimated = useCallback((id: string | null) => {
|
||||
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
|
||||
const panelId = maximizedPanelId ?? id;
|
||||
if (doc.startViewTransition && panelId) {
|
||||
setTransitioningPanelId(panelId);
|
||||
requestAnimationFrame(() => {
|
||||
const transition = doc.startViewTransition(() => flushSync(() => setMaximizedPanelId(id)));
|
||||
transition.finished.finally(() => setTransitioningPanelId(null));
|
||||
});
|
||||
} else {
|
||||
setMaximizedPanelId(id);
|
||||
}
|
||||
}, [maximizedPanelId]);
|
||||
|
||||
const handleSetApp = useCallback(
|
||||
(panelId: string, appType: string | null) => {
|
||||
onLayoutChange(setApp(layout, panelId, appType));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleSplit = useCallback(
|
||||
(panelId: string, direction: 'horizontal' | 'vertical') => {
|
||||
onLayoutChange(splitPanel(layout, panelId, direction));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(panelId: string) => {
|
||||
if (countPanels(layout) <= 1) return;
|
||||
onLayoutChange(removePanel(layout, panelId));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleResized = useCallback(
|
||||
(groupId: string, sizes: number[]) => {
|
||||
onLayoutChange(updateSizes(layout, groupId, sizes));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleSwap = useCallback(
|
||||
(sourceId: string, targetId: string) => {
|
||||
onLayoutChange(swapPanels(layout, sourceId, targetId));
|
||||
setSwapSourceId(null);
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(sourceId: string, targetId: string, position: DropPosition) => {
|
||||
onLayoutChange(movePanel(layout, sourceId, targetId, position));
|
||||
setDragSourceId(null);
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(id: string | null) => {
|
||||
setSwapSourceId(null);
|
||||
setDragSourceId(id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!swapSourceId && !dragSourceId && !maximizedPanelId) return;
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setSwapSourceId(null);
|
||||
setDragSourceId(null);
|
||||
setMaximizedAnimated(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
|
||||
|
||||
if (!workspace.isLoaded) return null;
|
||||
|
||||
const baseRenderer = (
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
interactive
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = ephemeral ? (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel defaultSize={ephemeral.defaultBaseSize ?? 40} minSize={15}>
|
||||
{baseRenderer}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" />
|
||||
<ResizablePanel defaultSize={100 - (ephemeral.defaultBaseSize ?? 40)} minSize={15}>
|
||||
<WorkspaceRenderer
|
||||
layout={ephemeral.layout}
|
||||
registry={registry}
|
||||
components={ephemeral.components}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={noop}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
baseRenderer
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceId: workspace.key,
|
||||
cwd,
|
||||
swapSourceId,
|
||||
setSwapSourceId,
|
||||
onSwap: handleSwap,
|
||||
dragSourceId,
|
||||
setDragSourceId: startDrag,
|
||||
onMove: handleMove,
|
||||
maximizedPanelId,
|
||||
setMaximizedPanelId: setMaximizedAnimated,
|
||||
transitioningPanelId,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels } from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
export { WorkspaceView } from './WorkspaceView';
|
||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||
@@ -1,214 +0,0 @@
|
||||
import type { LayoutNode, LayoutPanel, LayoutGroup } from './types';
|
||||
|
||||
let counter = 0;
|
||||
const uid = () => `p-${Date.now()}-${++counter}`;
|
||||
|
||||
export const createDefaultLayout = (): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id: uid(),
|
||||
appType: null,
|
||||
});
|
||||
|
||||
export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
|
||||
return splitInner(root, panelId, direction);
|
||||
}
|
||||
|
||||
function splitInner(node: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
if (node.id !== panelId) return node;
|
||||
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
const group: LayoutGroup = {
|
||||
type: 'group',
|
||||
id: uid(),
|
||||
direction,
|
||||
children: [
|
||||
{ node, size: 50 },
|
||||
{ node: newPanel, size: 50 },
|
||||
],
|
||||
};
|
||||
return group;
|
||||
}
|
||||
|
||||
// Check if the target panel is a direct child and the directions match — append as sibling
|
||||
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === panelId);
|
||||
if (childIdx !== -1 && node.direction === direction) {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
const newChildren = [
|
||||
...node.children.slice(0, childIdx + 1),
|
||||
{ node: newPanel, size: 0 },
|
||||
...node.children.slice(childIdx + 1),
|
||||
];
|
||||
const size = 100 / newChildren.length;
|
||||
return { ...node, children: newChildren.map((c) => ({ ...c, size })) };
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
const newChildren = node.children.map((child) => ({
|
||||
...child,
|
||||
node: splitInner(child.node, panelId, direction),
|
||||
}));
|
||||
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
|
||||
return { ...node, children: newChildren };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
|
||||
if (root.type === 'panel') return root;
|
||||
if (countPanels(root) <= 1) return root;
|
||||
|
||||
return removePanelInner(root);
|
||||
|
||||
function removePanelInner(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') return node;
|
||||
|
||||
const filtered = node.children.filter((c) => !(c.node.type === 'panel' && c.node.id === panelId));
|
||||
|
||||
if (filtered.length < node.children.length) {
|
||||
if (filtered.length === 0) return createDefaultLayout();
|
||||
if (filtered.length === 1) return filtered[0]!.node;
|
||||
const total = filtered.reduce((sum, c) => sum + c.size, 0);
|
||||
return {
|
||||
...node,
|
||||
children: filtered.map((c) => ({ ...c, size: (c.size / total) * 100 })),
|
||||
};
|
||||
}
|
||||
|
||||
const newChildren = node.children.map((child) => ({
|
||||
...child,
|
||||
node: removePanelInner(child.node),
|
||||
}));
|
||||
|
||||
const unwrapped = newChildren.map((child) => {
|
||||
if (child.node.type === 'group' && child.node.children.length === 1) {
|
||||
return { ...child, node: child.node.children[0]!.node };
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return { ...node, children: unwrapped };
|
||||
}
|
||||
}
|
||||
|
||||
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
return root.id === panelId ? { ...root, appType } : root;
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: setApp(child.node, panelId, appType),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
|
||||
export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]): LayoutNode {
|
||||
if (root.type === 'panel') return root;
|
||||
if (root.id === groupId) {
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map((child, i) => ({ ...child, size: sizes[i] ?? child.size })),
|
||||
};
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: updateSizes(child.node, groupId, sizes),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
|
||||
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
||||
if (root.type === 'panel') {
|
||||
return root.appType ? root : null;
|
||||
}
|
||||
|
||||
const pruned = root.children
|
||||
.map((child) => {
|
||||
const node = pruneEmptyPanels(child.node);
|
||||
return node ? { ...child, node } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (pruned.length === 0) return null;
|
||||
if (pruned.length === 1) return pruned[0]!.node;
|
||||
|
||||
const total = pruned.reduce((sum, c) => sum + c.size, 0);
|
||||
return {
|
||||
...root,
|
||||
children: pruned.map((c) => ({ ...c, size: (c.size / total) * 100 })),
|
||||
};
|
||||
}
|
||||
|
||||
export type DropPosition = 'top' | 'bottom' | 'left' | 'right' | 'center';
|
||||
|
||||
export function movePanel(root: LayoutNode, sourceId: string, targetId: string, position: DropPosition): LayoutNode {
|
||||
if (sourceId === targetId) return root;
|
||||
if (position === 'center') return swapPanels(root, sourceId, targetId);
|
||||
|
||||
const sourceApp = findPanelApp(root, sourceId);
|
||||
if (sourceApp === undefined) return root;
|
||||
|
||||
let result = removePanel(root, sourceId);
|
||||
const direction = position === 'top' || position === 'bottom' ? 'vertical' : 'horizontal';
|
||||
const before = position === 'top' || position === 'left';
|
||||
return insertPanel(result, targetId, direction, before, sourceApp);
|
||||
}
|
||||
|
||||
function insertPanel(node: LayoutNode, targetId: string, direction: 'horizontal' | 'vertical', before: boolean, appType: string | null): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
if (node.id !== targetId) return node;
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
|
||||
const children = before
|
||||
? [{ node: newPanel, size: 50 }, { node, size: 50 }]
|
||||
: [{ node, size: 50 }, { node: newPanel, size: 50 }];
|
||||
return { type: 'group', id: uid(), direction, children };
|
||||
}
|
||||
|
||||
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === targetId);
|
||||
if (childIdx !== -1 && node.direction === direction) {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
|
||||
const insertIdx = before ? childIdx : childIdx + 1;
|
||||
const newChildren = [
|
||||
...node.children.slice(0, insertIdx),
|
||||
{ node: newPanel, size: 0 },
|
||||
...node.children.slice(insertIdx),
|
||||
];
|
||||
const size = 100 / newChildren.length;
|
||||
return { ...node, children: newChildren.map((c) => ({ ...c, size })) };
|
||||
}
|
||||
|
||||
const newChildren = node.children.map((child) => ({
|
||||
...child,
|
||||
node: insertPanel(child.node, targetId, direction, before, appType),
|
||||
}));
|
||||
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
|
||||
return { ...node, children: newChildren };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function swapPanels(root: LayoutNode, idA: string, idB: string): LayoutNode {
|
||||
const appA = findPanelApp(root, idA);
|
||||
const appB = findPanelApp(root, idB);
|
||||
if (appA === undefined || appB === undefined) return root;
|
||||
return setApp(setApp(root, idA, appB), idB, appA);
|
||||
}
|
||||
|
||||
function findPanelApp(node: LayoutNode, panelId: string): string | null | undefined {
|
||||
if (node.type === 'panel') return node.id === panelId ? node.appType : undefined;
|
||||
for (const child of node.children) {
|
||||
const result = findPanelApp(child.node, panelId);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function countPanels(node: LayoutNode): number {
|
||||
if (node.type === 'panel') return 1;
|
||||
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||
}
|
||||
|
||||
export function hasAnyApp(node: LayoutNode): boolean {
|
||||
if (node.type === 'panel') return node.appType !== null;
|
||||
return node.children.some((child) => hasAnyApp(child.node));
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type LayoutGroup = {
|
||||
type: 'group';
|
||||
id: string;
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: { node: LayoutNode; size: number }[];
|
||||
};
|
||||
|
||||
export type LayoutPanel = {
|
||||
type: 'panel';
|
||||
id: string;
|
||||
appType: string | null;
|
||||
};
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
|
||||
export type WorkspaceDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
description?: string;
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
key: string;
|
||||
value: LayoutNode;
|
||||
setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
export type ProjectType = 'landing-page' | 'website' | 'app';
|
||||
|
||||
export type ProjectDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
description?: string;
|
||||
projectType: ProjectType;
|
||||
hasBackend?: boolean;
|
||||
hasAuth?: boolean;
|
||||
gitRepo?: string;
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type AppRegistryEntry = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
header?: ComponentType<{ panelId: string }>;
|
||||
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
availableOnPanel?: boolean;
|
||||
};
|
||||
|
||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||
|
||||
export type PanelComponentEntry = {
|
||||
component: ComponentType;
|
||||
header?: ComponentType;
|
||||
provider?: ComponentType<{ children: ReactNode }>;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>;
|
||||
|
||||
export type EphemeralPanels = {
|
||||
layout: LayoutNode;
|
||||
components: PanelComponents;
|
||||
defaultBaseSize?: number;
|
||||
};
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./Workspace": "./Workspace/index.ts",
|
||||
"./*": "./*.tsx",
|
||||
"./ui/*": "./ui/*.tsx"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user