diff --git a/packages/editor/src/editor/EditorApp.tsx b/packages/editor/src/editor/EditorApp.tsx index 6c007d7..07e71d7 100644 --- a/packages/editor/src/editor/EditorApp.tsx +++ b/packages/editor/src/editor/EditorApp.tsx @@ -11,6 +11,12 @@ import { message, } from 'antd'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { + EyeInvisibleOutlined, + EyeOutlined, + LockOutlined, + UnlockOutlined, +} from '@ant-design/icons'; import { bindEditorHotkeys } from './hotkeys'; import { Canvas } from './Canvas'; import { ContextMenu, type ContextMenuState } from './ContextMenu'; @@ -72,6 +78,25 @@ export function EditorApp() { .map((entry) => entry.node); }, [state.doc.screen.nodes]); + const layerRefs = useRef(new Map()); + const setLayerRef = useCallback((id: string) => { + return (el: HTMLDivElement | null) => { + if (!el) { + layerRefs.current.delete(id); + } else { + layerRefs.current.set(id, el); + } + }; + }, []); + + useEffect(() => { + const id = state.selection.ids[0]; + if (!id) return; + const el = layerRefs.current.get(id); + if (!el) return; + el.scrollIntoView({ block: 'nearest' }); + }, [state.selection.ids]); + const closeContextMenu = useCallback(() => setCtxMenu(null), []); // selectionKeyOf imported from ./selection @@ -614,6 +639,27 @@ export function EditorApp() { return (
{ + e.dataTransfer.setData('text/plain', node.id); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + // Required to allow dropping. + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const sourceId = e.dataTransfer.getData('text/plain'); + if (!sourceId) return; + + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); + const position = e.clientY > rect.top + rect.height / 2 ? 'below' : 'above'; + dispatch({ type: 'reorderLayer', sourceId, targetId: node.id, position }); + }} onPointerDown={(e) => { if (e.button === 2) return; e.preventDefault(); @@ -664,13 +710,69 @@ export function EditorApp() { alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', + userSelect: 'none', }} > -
- {node.type} - {node.id} +
+ {node.type} + + {node.id} + +
+ +
+ + + + + {status ? {status} : null}
- {status ? {status} : null}
); }) diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts index 4395f2f..00d3471 100644 --- a/packages/editor/src/editor/store.ts +++ b/packages/editor/src/editor/store.ts @@ -63,6 +63,9 @@ export type EditorAction = | { type: 'pasteClipboard'; at?: { x: number; y: number } } | { type: 'toggleLockSelected' } | { type: 'toggleHideSelected' } + | { type: 'toggleLockIds'; ids: string[] } + | { type: 'toggleHideIds'; ids: string[] } + | { type: 'reorderLayer'; sourceId: string; targetId: string; position: 'above' | 'below' } | { type: 'bringToFrontSelected' } | { type: 'bringForwardSelected' } | { type: 'sendBackwardSelected' } @@ -220,6 +223,32 @@ function reorderSelected( return normalizeZIndexBackToFront(arr); } +function reorderLayer( + nodes: WidgetNode[], + sourceId: string, + targetId: string, + position: 'above' | 'below', +): WidgetNode[] { + if (sourceId === targetId) return nodes; + + const backToFront = sortNodesBackToFront(nodes); + const frontToBack = [...backToFront].reverse(); + + const srcIndex = frontToBack.findIndex((n) => n.id === sourceId); + const tgtIndex0 = frontToBack.findIndex((n) => n.id === targetId); + if (srcIndex < 0 || tgtIndex0 < 0) return nodes; + + const src = frontToBack[srcIndex]!; + const rest = frontToBack.filter((n) => n.id !== sourceId); + + const tgtIndex = rest.findIndex((n) => n.id === targetId); + const insertAt = Math.max(0, Math.min(rest.length, tgtIndex + (position === 'below' ? 1 : 0))); + + const nextFrontToBack = [...rest.slice(0, insertAt), src, ...rest.slice(insertAt)]; + const nextBackToFront = [...nextFrontToBack].reverse(); + return normalizeZIndexBackToFront(nextBackToFront); +} + export function createInitialState(): EditorRuntimeState { const screen = createEmptyScreen({ width: 1920, @@ -531,6 +560,51 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction): }; } + case 'toggleLockIds': { + if (!action.ids.length) return state; + const ids = new Set(action.ids); + const picked = state.doc.screen.nodes.filter((n) => ids.has(n.id)); + if (!picked.length) return state; + const shouldLock = picked.some((n) => !n.locked); + + return { + ...historyPush(state), + doc: { + screen: { + ...state.doc.screen, + nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, locked: shouldLock } : n)), + }, + }, + }; + } + + case 'toggleHideIds': { + if (!action.ids.length) return state; + const ids = new Set(action.ids); + const picked = state.doc.screen.nodes.filter((n) => ids.has(n.id)); + if (!picked.length) return state; + const shouldHide = picked.some((n) => !n.hidden); + + return { + ...historyPush(state), + doc: { + screen: { + ...state.doc.screen, + nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)), + }, + }, + }; + } + + case 'reorderLayer': { + const nextNodes = reorderLayer(state.doc.screen.nodes, action.sourceId, action.targetId, action.position); + if (nextNodes === state.doc.screen.nodes) return state; + return { + ...historyPush(state), + doc: { screen: { ...state.doc.screen, nodes: nextNodes } }, + }; + } + case 'bringToFrontSelected': { if (!state.selection.ids.length) return state; const ids = new Set(