editor: layers panel lock/hide toggles + drag reorder

This commit is contained in:
clawdbot 2026-01-29 08:01:44 +08:00
parent 3ea6aa8fb3
commit a9e6d4b0d0
2 changed files with 180 additions and 4 deletions

View File

@ -11,6 +11,12 @@ import {
message, message,
} from 'antd'; } from 'antd';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import {
EyeInvisibleOutlined,
EyeOutlined,
LockOutlined,
UnlockOutlined,
} from '@ant-design/icons';
import { bindEditorHotkeys } from './hotkeys'; import { bindEditorHotkeys } from './hotkeys';
import { Canvas } from './Canvas'; import { Canvas } from './Canvas';
import { ContextMenu, type ContextMenuState } from './ContextMenu'; import { ContextMenu, type ContextMenuState } from './ContextMenu';
@ -72,6 +78,25 @@ export function EditorApp() {
.map((entry) => entry.node); .map((entry) => entry.node);
}, [state.doc.screen.nodes]); }, [state.doc.screen.nodes]);
const layerRefs = useRef(new Map<string, HTMLDivElement>());
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), []); const closeContextMenu = useCallback(() => setCtxMenu(null), []);
// selectionKeyOf imported from ./selection // selectionKeyOf imported from ./selection
@ -614,6 +639,27 @@ export function EditorApp() {
return ( return (
<div <div
key={node.id} key={node.id}
ref={setLayerRef(node.id)}
draggable
onDragStart={(e) => {
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) => { onPointerDown={(e) => {
if (e.button === 2) return; if (e.button === 2) return;
e.preventDefault(); e.preventDefault();
@ -664,14 +710,70 @@ export function EditorApp() {
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none',
}} }}
> >
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<span style={{ fontSize: 12 }}>{node.type}</span> <span style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.type}</span>
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }}>{node.id}</span> <span style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{node.id}
</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
title={node.locked ? 'Unlock' : 'Lock'}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
dispatch({ type: 'toggleLockIds', ids: [node.id] });
}}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.12)',
background: 'transparent',
color: 'rgba(255,255,255,0.75)',
cursor: 'pointer',
}}
>
{node.locked ? <LockOutlined /> : <UnlockOutlined />}
</button>
<button
type="button"
title={node.hidden ? 'Show' : 'Hide'}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
dispatch({ type: 'toggleHideIds', ids: [node.id] });
}}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.12)',
background: 'transparent',
color: 'rgba(255,255,255,0.75)',
cursor: 'pointer',
}}
>
{node.hidden ? <EyeInvisibleOutlined /> : <EyeOutlined />}
</button>
{status ? <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{status}</span> : null} {status ? <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{status}</span> : null}
</div> </div>
</div>
); );
}) })
)} )}

View File

@ -63,6 +63,9 @@ export type EditorAction =
| { type: 'pasteClipboard'; at?: { x: number; y: number } } | { type: 'pasteClipboard'; at?: { x: number; y: number } }
| { type: 'toggleLockSelected' } | { type: 'toggleLockSelected' }
| { type: 'toggleHideSelected' } | { type: 'toggleHideSelected' }
| { type: 'toggleLockIds'; ids: string[] }
| { type: 'toggleHideIds'; ids: string[] }
| { type: 'reorderLayer'; sourceId: string; targetId: string; position: 'above' | 'below' }
| { type: 'bringToFrontSelected' } | { type: 'bringToFrontSelected' }
| { type: 'bringForwardSelected' } | { type: 'bringForwardSelected' }
| { type: 'sendBackwardSelected' } | { type: 'sendBackwardSelected' }
@ -220,6 +223,32 @@ function reorderSelected(
return normalizeZIndexBackToFront(arr); 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 { export function createInitialState(): EditorRuntimeState {
const screen = createEmptyScreen({ const screen = createEmptyScreen({
width: 1920, 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': { case 'bringToFrontSelected': {
if (!state.selection.ids.length) return state; if (!state.selection.ids.length) return state;
const ids = new Set( const ids = new Set(