editor: layers panel lock/hide toggles + drag reorder
This commit is contained in:
parent
3ea6aa8fb3
commit
a9e6d4b0d0
@ -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<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), []);
|
||||
|
||||
// selectionKeyOf imported from ./selection
|
||||
@ -614,6 +639,27 @@ export function EditorApp() {
|
||||
return (
|
||||
<div
|
||||
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) => {
|
||||
if (e.button === 2) return;
|
||||
e.preventDefault();
|
||||
@ -664,14 +710,70 @@ export function EditorApp() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: 12 }}>{node.type}</span>
|
||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }}>{node.id}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.type}</span>
|
||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{node.id}
|
||||
</span>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user