editor: layers panel lock/hide toggles + drag reorder
This commit is contained in:
parent
3ea6aa8fb3
commit
a9e6d4b0d0
@ -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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user