mono/packages/ui/docs/dnd.md
2026-03-21 20:18:25 +01:00

259 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Drag-and-Drop Integration with dnd-kit
Proposal for adding drag-and-drop widget management to the page editor using `@dnd-kit/core` (6.3.1), `@dnd-kit/sortable` (10.0.0), and `@dnd-kit/utilities` (3.2.2) — all already installed.
---
## Current State
| Concern | Implementation | File |
|---------|---------------|------|
| Widget insertion | Click-to-add via `onToggleWidget` | `PageRibbonBar.tsx` |
| Widget reorder | `MoveWidgetCommand` (arrow D-pad) | `commands.ts`, `LayoutManager.ts` |
| Native file/URL drop | Window-level `drop` listener | `GlobalDragDrop.tsx` |
| Native drag state | `DragDropContext` (custom React context) | `contexts/DragDropContext.tsx` |
**Key detail:** The custom `DragDropContext` already has a `polymech/internal` discriminator — it skips drags with `types.includes('polymech/internal')`, so dnd-kit drags (which use `setData` with custom types) will **not** trigger the global file-drop overlay. No conflict.
---
## Proposed Architecture
```
┌─────────────────────────────────────────────────────────┐
│ DndContext (from @dnd-kit/core) │
│ └─ mounted in UserPageEdit.tsx (wraps canvas + ribbon) │
│ ├─ DragOverlay: ghost preview of dragged widget │
│ ├─ Draggable sources: │
│ │ ├─ RibbonItemSmall (drag from ribbon) │
│ │ └─ WidgetItem / FlexWidgetItem (drag to reorder)│
│ └─ Droppable targets: │
│ ├─ Container cells (LayoutContainer grid slots) │
│ └─ Flex cells (row:column in FlexContainer) │
└─────────────────────────────────────────────────────────┘
```
### Data Model
All draggables attach structured data via `useDraggable({ data })`:
```ts
// Dragging a new widget from the ribbon
{ type: 'new-widget', widgetId: 'photo-card' }
// Dragging an existing widget within/across containers
{ type: 'existing-widget', widgetInstanceId: 'widget-123', sourceContainerId: 'container-456' }
```
All droppables identify themselves via `useDroppable({ data })`:
```ts
// Regular container cell
{ type: 'container-cell', containerId: 'container-456', index: 3 }
// Flex container cell
{ type: 'flex-cell', containerId: 'container-789', rowId: 'row-abc', column: 1 }
```
---
## Implementation Plan
### Phase 1: DndContext Setup
#### `UserPageEdit.tsx` — Wrap editor in `DndContext`
```tsx
import { DndContext, DragOverlay, closestCenter, pointerWithin } from '@dnd-kit/core';
// Inside the component:
const [activeDrag, setActiveDrag] = useState<DragData | null>(null);
<DndContext
collisionDetection={pointerWithin}
onDragStart={({ active }) => setActiveDrag(active.data.current)}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveDrag(null)}
>
<PageRibbonBar ... />
<GenericCanvas ... />
<DragOverlay>
{activeDrag && <DragPreview data={activeDrag} />}
</DragOverlay>
</DndContext>
```
`handleDragEnd` logic:
```ts
function handleDragEnd({ active, over }) {
setActiveDrag(null);
if (!over) return;
const src = active.data.current;
const dst = over.data.current;
if (src.type === 'new-widget') {
// Insert new widget at drop target
if (dst.type === 'container-cell') {
addWidgetToPage(pageId, dst.containerId, src.widgetId, dst.index);
} else if (dst.type === 'flex-cell') {
addWidgetToPage(pageId, dst.containerId, src.widgetId, dst.column, dst.rowId);
}
} else if (src.type === 'existing-widget') {
// Move widget via command (for undo/redo)
executeCommand(new MoveWidgetToTargetCommand(pageId, src.widgetInstanceId, dst));
}
}
```
---
### Phase 2: Draggable Ribbon Items
#### `PageRibbonBar.tsx` — Make widget buttons draggable
Wrap `RibbonItemSmall` with `useDraggable`. Click still works (dnd-kit only initiates drag after movement threshold via `activationConstraint`).
```tsx
const DraggableRibbonItem = ({ widgetId, ...props }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `ribbon-${widgetId}`,
data: { type: 'new-widget', widgetId },
});
return (
<div ref={setNodeRef} {...listeners} {...attributes}
style={{ opacity: isDragging ? 0.5 : 1 }}>
<RibbonItemSmall {...props} />
</div>
);
};
```
> [!TIP]
> Use `activationConstraint: { distance: 8 }` on the `PointerSensor` to prevent accidental drags from clicks.
---
### Phase 3: Droppable Container Cells
#### `LayoutContainer.tsx` — Grid cells as drop targets
Each column slot becomes a `useDroppable` target:
```tsx
const DroppableCell = ({ containerId, index, children }) => {
const { setNodeRef, isOver } = useDroppable({
id: `cell-${containerId}-${index}`,
data: { type: 'container-cell', containerId, index },
});
return (
<div ref={setNodeRef} className={cn(isOver && 'ring-2 ring-blue-400 bg-blue-50/20')}>
{children}
</div>
);
};
```
#### `FlexibleContainerRenderer.tsx` — Flex cells as drop targets
Each `(rowId, column)` pair becomes a drop target:
```tsx
const DroppableFlexCell = ({ containerId, rowId, column, children }) => {
const { setNodeRef, isOver } = useDroppable({
id: `flex-${containerId}-${rowId}-${column}`,
data: { type: 'flex-cell', containerId, rowId, column },
});
return (
<div ref={setNodeRef} className={cn(isOver && 'ring-2 ring-purple-400 bg-purple-50/20')}>
{children}
</div>
);
};
```
---
### Phase 4: Draggable Existing Widgets (Reordering)
#### `LayoutContainer.tsx` and `FlexibleContainerRenderer.tsx`
Use `useSortable` from `@dnd-kit/sortable` for intra-container reordering, with `useDraggable` for cross-container moves:
```tsx
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: widget.id,
data: {
type: 'existing-widget',
widgetInstanceId: widget.id,
sourceContainerId: container.id,
},
});
```
---
### Phase 5: New Command for Targeted Moves
#### `commands.ts` — `MoveWidgetToTargetCommand`
A new command that moves a widget to a specific target location (container + position), supporting undo by capturing the original location:
```ts
export class MoveWidgetToTargetCommand implements Command {
// Captures source (containerId, index/rowId/column) and target
// execute(): removes from source, inserts at target
// undo(): removes from target, re-inserts at source
}
```
---
## Coexistence with GlobalDragDrop
The custom `DragDropContext` checks `e.dataTransfer?.types.includes('polymech/internal')` and **skips** internal drags. dnd-kit uses pointer events, not native HTML5 drag events, so:
1. **No event collision** — dnd-kit pointer captures don't fire `dragenter`/`dragleave` on `window`
2. **Overlay suppression**`GlobalDragDrop` only renders when `isDragging && !isLocalZoneActive`. During dnd-kit drags, `isDragging` stays `false` in the custom context
3. **Native file drops still work** — External file/URL drops bypass dnd-kit (which only tracks its registered draggables)
> [!IMPORTANT]
> No changes needed to `GlobalDragDrop.tsx` or `DragDropContext.tsx`. The two systems operate on completely separate event channels.
---
## Files to Modify
| File | Change |
|------|--------|
| `UserPageEdit.tsx` | Wrap in `DndContext`, add `DragOverlay`, `handleDragEnd` |
| `PageRibbonBar.tsx` | Wrap widget buttons with `useDraggable` |
| `LayoutContainer.tsx` | Add `useDroppable` to grid cells, `useSortable` to widgets |
| `FlexibleContainerRenderer.tsx` | Add `useDroppable` to flex cells, `useSortable` to widgets |
| `commands.ts` | Add `MoveWidgetToTargetCommand` |
| `LayoutManager.ts` | Add `moveWidgetToTarget()` static method |
### New Files
| File | Purpose |
|------|---------|
| `DragPreview.tsx` | `DragOverlay` content — shows widget name + icon while dragging |
---
## Phasing
| Phase | Scope | Effort |
|-------|-------|--------|
| 1 | DndContext setup + handler skeleton | Small |
| 2 | Draggable ribbon items (drag-to-add) | Small |
| 3 | Droppable container/flex cells | Medium |
| 4 | Draggable existing widgets (reorder + cross-container) | Medium |
| 5 | New command for cross-container targeted moves | Small |
Phases 13 deliver the most impactful feature (drag from ribbon to canvas). Phase 45 adds reordering, which is lower priority since the D-pad already covers this.