diff --git a/src/workspaces/components/Workspace/DragOverlay.tsx b/src/workspaces/components/Workspace/DragOverlay.tsx new file mode 100644 index 00000000..0de1dff7 --- /dev/null +++ b/src/workspaces/components/Workspace/DragOverlay.tsx @@ -0,0 +1,99 @@ +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(null); + const panelRectsRef = useRef>(new Map()); + + useEffect(() => { + if (!dragSourceId) { + panelRectsRef.current.clear(); + setTarget(null); + return; + } + const panels = document.querySelectorAll('[data-panel-id]'); + const map = new Map(); + 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 ( +
+ {target && ( +
+ )} +
+ ); +}; diff --git a/src/workspaces/components/Workspace/PanelSlot.tsx b/src/workspaces/components/Workspace/PanelSlot.tsx index 8587248a..7c860768 100644 --- a/src/workspaces/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/components/Workspace/PanelSlot.tsx @@ -1,4 +1,5 @@ -import { ArrowLeftRight } from 'lucide-react'; +import { useCallback } from 'react'; +import { ArrowLeftRight, GripVertical } from 'lucide-react'; import type { LayoutPanel, AppRegistry, PanelComponents } from './types'; import { useWorkspace } from './WorkspaceContext'; import { Card } from '../Card'; @@ -48,9 +49,7 @@ const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onC )} - {swapSourceId === panelId ? ( - setSwapSourceId(null)}>Cancel swap - ) : swapSourceId ? ( + {swapSourceId ? ( setSwapSourceId(null)}>Cancel swap ) : ( setSwapSourceId(panelId)}>Swap with... @@ -61,7 +60,7 @@ const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onC }; const SwapOverlay = ({ panelId }: { panelId: string }) => { - const { swapSourceId, setSwapSourceId, onSwap } = useWorkspace(); + const { swapSourceId, onSwap } = useWorkspace(); if (!swapSourceId || swapSourceId === panelId) return null; @@ -99,6 +98,35 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => { ); }; +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 ( +
+ +
+ ); +}; + +const DragSourceDimmer = ({ panelId }: { panelId: string }) => { + const { dragSourceId } = useWorkspace(); + if (dragSourceId !== panelId) return null; + return
; +}; + export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => { const PanelComponent = components?.[panel.id]; const entry = panel.appType ? registry[panel.appType] : null; @@ -112,14 +140,14 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane ) : (content: React.ReactNode) => <>{content}; - const swapOverlays = interactive - ? ( - <> - - - - ) - : null; + const overlays = interactive ? ( + <> + + + + + + ) : null; if (!AppComponent) { if (!interactive) { @@ -131,28 +159,28 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane } return contextMenu( -
+
onSetApp(panel.id, type)} /> - {swapOverlays} + {overlays}
, ); } if (entry?.transparent) { return contextMenu( -
+
- {swapOverlays} + {overlays}
, ); } return contextMenu( -
+
- {swapOverlays} + {overlays}
, ); }; diff --git a/src/workspaces/components/Workspace/WorkspaceContext.ts b/src/workspaces/components/Workspace/WorkspaceContext.ts index c9a9f8f6..42527d45 100644 --- a/src/workspaces/components/Workspace/WorkspaceContext.ts +++ b/src/workspaces/components/Workspace/WorkspaceContext.ts @@ -1,11 +1,16 @@ 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; }; const noop = () => {}; @@ -16,6 +21,9 @@ const WorkspaceContext = createContext({ swapSourceId: null, setSwapSourceId: noop, onSwap: noop, + dragSourceId: null, + setDragSourceId: noop, + onMove: noop, }); export const WorkspaceProvider = WorkspaceContext.Provider; diff --git a/src/workspaces/components/Workspace/WorkspaceLayout.tsx b/src/workspaces/components/Workspace/WorkspaceLayout.tsx index 48978d7a..35627954 100644 --- a/src/workspaces/components/Workspace/WorkspaceLayout.tsx +++ b/src/workspaces/components/Workspace/WorkspaceLayout.tsx @@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components, ); return ( - + { const [swapSourceId, setSwapSourceId] = useState(null); + const [dragSourceId, setDragSourceId] = useState(null); const handleSetApp = useCallback( (panelId: string, appType: string | null) => { @@ -51,14 +54,33 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W [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) return; + if (!swapSourceId && !dragSourceId) return; const onKeyDown = (ev: KeyboardEvent) => { - if (ev.key === 'Escape') setSwapSourceId(null); + if (ev.key === 'Escape') { + setSwapSourceId(null); + setDragSourceId(null); + } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [swapSourceId]); + }, [swapSourceId, dragSourceId]); return ( + ); }; diff --git a/src/workspaces/components/Workspace/index.ts b/src/workspaces/components/Workspace/index.ts index 9716515a..7af99951 100644 --- a/src/workspaces/components/Workspace/index.ts +++ b/src/workspaces/components/Workspace/index.ts @@ -1,5 +1,6 @@ export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types'; -export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils'; +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'; diff --git a/src/workspaces/components/Workspace/layout-utils.ts b/src/workspaces/components/Workspace/layout-utils.ts index 5c2274c5..82e8c261 100644 --- a/src/workspaces/components/Workspace/layout-utils.ts +++ b/src/workspaces/components/Workspace/layout-utils.ts @@ -139,6 +139,54 @@ export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null { }; } +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);