Compare commits

..

No commits in common. "e4cca026a0761b759677ba26c9f3951e19c408ca" and "6b18665ef93f26641099f181c52393b1ce18dba3" have entirely different histories.

4 changed files with 34 additions and 302 deletions

View File

@ -29,19 +29,14 @@ export function ContextMenu(props: {
state: ContextMenuState | null; state: ContextMenuState | null;
nodes: WidgetNode[]; nodes: WidgetNode[];
selectionIds: string[]; selectionIds: string[];
clipboardHasNodes: boolean;
onClose: () => void; onClose: () => void;
onAddTextAt: (x: number, y: number) => void; onAddTextAt: (x: number, y: number) => void;
onSelectSingle?: (id?: string) => void; onSelectSingle?: (id?: string) => void;
onSelectAll?: () => void; onSelectAll?: () => void;
onCopySelected?: () => void;
onPasteAt?: (x: number, y: number) => void;
onDuplicateSelected?: () => void; onDuplicateSelected?: () => void;
onToggleLockSelected?: () => void; onToggleLockSelected?: () => void;
onToggleHideSelected?: () => void; onToggleHideSelected?: () => void;
onBringToFrontSelected?: () => void; onBringToFrontSelected?: () => void;
onBringForwardSelected?: () => void;
onSendBackwardSelected?: () => void;
onSendToBackSelected?: () => void; onSendToBackSelected?: () => void;
onDeleteSelected?: () => void; onDeleteSelected?: () => void;
}) { }) {
@ -199,24 +194,6 @@ export function ContextMenu(props: {
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} /> <div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Copy"
disabled={!hasSelection || !props.onCopySelected}
onClick={() => {
props.onCopySelected?.();
onClose();
}}
/>
<MenuItem
label="Paste"
disabled={!props.clipboardHasNodes || !props.onPasteAt}
onClick={() => {
props.onPasteAt?.(ctx.worldX, ctx.worldY);
onClose();
}}
/>
<MenuItem <MenuItem
label="Duplicate" label="Duplicate"
disabled={!canModifySelection || !props.onDuplicateSelected} disabled={!canModifySelection || !props.onDuplicateSelected}
@ -265,22 +242,6 @@ export function ContextMenu(props: {
onClose(); onClose();
}} }}
/> />
<MenuItem
label="Bring Forward"
disabled={!canModifySelection || !props.onBringForwardSelected}
onClick={() => {
props.onBringForwardSelected?.();
onClose();
}}
/>
<MenuItem
label="Send Backward"
disabled={!canModifySelection || !props.onSendBackwardSelected}
onClick={() => {
props.onSendBackwardSelected?.();
onClose();
}}
/>
<MenuItem <MenuItem
label="Send To Back" label="Send To Back"
disabled={!canModifySelection || !props.onSendToBackSelected} disabled={!canModifySelection || !props.onSendToBackSelected}

View File

@ -502,60 +502,14 @@ export function EditorApp() {
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text> <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<Space size={8}> <Space size={8}>
<InputNumber <InputNumber size="small" value={selected?.rect.w ?? 0} controls={false} readOnly style={{ width: 120 }} />
size="small" <InputNumber size="small" value={selected?.rect.h ?? 0} controls={false} readOnly style={{ width: 120 }} />
value={selected?.rect.w ?? 0}
controls={false}
min={1}
disabled={!selected || selected.locked}
onChange={(v) => {
if (!selected) return;
if (typeof v !== 'number') return;
dispatch({ type: 'updateWidgetRect', id: selected.id, rect: { w: v } });
}}
style={{ width: 120 }}
/>
<InputNumber
size="small"
value={selected?.rect.h ?? 0}
controls={false}
min={1}
disabled={!selected || selected.locked}
onChange={(v) => {
if (!selected) return;
if (typeof v !== 'number') return;
dispatch({ type: 'updateWidgetRect', id: selected.id, rect: { h: v } });
}}
style={{ width: 120 }}
/>
</Space> </Space>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text> <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<Space size={8}> <Space size={8}>
<InputNumber <InputNumber size="small" value={selected?.rect.x ?? 0} controls={false} readOnly style={{ width: 120 }} />
size="small" <InputNumber size="small" value={selected?.rect.y ?? 0} controls={false} readOnly style={{ width: 120 }} />
value={selected?.rect.x ?? 0}
controls={false}
disabled={!selected || selected.locked}
onChange={(v) => {
if (!selected) return;
if (typeof v !== 'number') return;
dispatch({ type: 'updateWidgetRect', id: selected.id, rect: { x: v } });
}}
style={{ width: 120 }}
/>
<InputNumber
size="small"
value={selected?.rect.y ?? 0}
controls={false}
disabled={!selected || selected.locked}
onChange={(v) => {
if (!selected) return;
if (typeof v !== 'number') return;
dispatch({ type: 'updateWidgetRect', id: selected.id, rect: { y: v } });
}}
style={{ width: 120 }}
/>
</Space> </Space>
</div> </div>
@ -682,19 +636,14 @@ export function EditorApp() {
state={ctxMenu} state={ctxMenu}
nodes={state.doc.screen.nodes} nodes={state.doc.screen.nodes}
selectionIds={state.selection.ids} selectionIds={state.selection.ids}
clipboardHasNodes={state.clipboard.nodes.length > 0}
onClose={closeContextMenu} onClose={closeContextMenu}
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })} onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })} onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onSelectAll={() => dispatch({ type: 'selectAll' })} onSelectAll={() => dispatch({ type: 'selectAll' })}
onCopySelected={() => dispatchWithMenuSelection({ type: 'copySelected' })}
onPasteAt={(x, y) => dispatch({ type: 'pasteClipboard', at: { x, y } })}
onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })} onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })} onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })} onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })} onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
onBringForwardSelected={() => dispatchWithMenuSelection({ type: 'bringForwardSelected' })}
onSendBackwardSelected={() => dispatchWithMenuSelection({ type: 'sendBackwardSelected' })}
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })} onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })} onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
/> />

View File

@ -59,15 +59,10 @@ export type EditorAction =
| { type: 'deleteSelected' } | { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number } | { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' } | { type: 'duplicateSelected' }
| { type: 'copySelected' }
| { type: 'pasteClipboard'; at?: { x: number; y: number } }
| { type: 'toggleLockSelected' } | { type: 'toggleLockSelected' }
| { type: 'toggleHideSelected' } | { type: 'toggleHideSelected' }
| { type: 'bringToFrontSelected' } | { type: 'bringToFrontSelected' }
| { type: 'bringForwardSelected' }
| { type: 'sendBackwardSelected' }
| { type: 'sendToBackSelected' } | { type: 'sendToBackSelected' }
| { type: 'updateWidgetRect'; id: string; rect: Partial<Rect> }
| UpdateWidgetPropsAction; | UpdateWidgetPropsAction;
interface DragSession { interface DragSession {
@ -160,66 +155,6 @@ function ensureSelected(state: EditorRuntimeState, id: string): EditorRuntimeSta
return { ...state, selection: { ids: [id] } }; return { ...state, selection: { ids: [id] } };
} }
function sortNodesBackToFront(nodes: WidgetNode[]): WidgetNode[] {
return nodes
.map((n, index) => ({ n, index, z: n.zIndex ?? index }))
.sort((a, b) => {
if (a.z !== b.z) return a.z - b.z;
return a.index - b.index;
})
.map((e) => e.n);
}
function normalizeZIndexBackToFront(nodesBackToFront: WidgetNode[]): WidgetNode[] {
return nodesBackToFront.map((n, idx) => ({ ...n, zIndex: idx }));
}
function reorderSelected(
nodes: WidgetNode[],
selectedIds: Set<string>,
mode: 'toFront' | 'toBack' | 'forward' | 'backward',
): WidgetNode[] {
const ordered = sortNodesBackToFront(nodes);
const isSelected = (n: WidgetNode) => selectedIds.has(n.id);
if (mode === 'toFront') {
const rest = ordered.filter((n) => !isSelected(n));
const picked = ordered.filter((n) => isSelected(n));
return normalizeZIndexBackToFront([...rest, ...picked]);
}
if (mode === 'toBack') {
const picked = ordered.filter((n) => isSelected(n));
const rest = ordered.filter((n) => !isSelected(n));
return normalizeZIndexBackToFront([...picked, ...rest]);
}
const arr = [...ordered];
if (mode === 'forward') {
// swap with next non-selected, from front backwards
for (let i = arr.length - 2; i >= 0; i--) {
if (!isSelected(arr[i]!)) continue;
if (isSelected(arr[i + 1]!)) continue;
const tmp = arr[i]!;
arr[i] = arr[i + 1]!;
arr[i + 1] = tmp;
}
return normalizeZIndexBackToFront(arr);
}
// mode === 'backward'
for (let i = 1; i < arr.length; i++) {
if (!isSelected(arr[i]!)) continue;
if (isSelected(arr[i - 1]!)) continue;
const tmp = arr[i]!;
arr[i] = arr[i - 1]!;
arr[i - 1] = tmp;
}
return normalizeZIndexBackToFront(arr);
}
export function createInitialState(): EditorRuntimeState { export function createInitialState(): EditorRuntimeState {
const screen = createEmptyScreen({ const screen = createEmptyScreen({
width: 1920, width: 1920,
@ -258,7 +193,6 @@ export function createInitialState(): EditorRuntimeState {
}, },
}, },
keyboard: { ctrl: false, space: false }, keyboard: { ctrl: false, space: false },
clipboard: { nodes: [] },
history: { past: [], future: [] }, history: { past: [], future: [] },
}; };
} }
@ -337,41 +271,6 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
}; };
} }
case 'updateWidgetRect': {
const target = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!target) return state;
if (target.locked) return state;
const next: Rect = {
x: typeof action.rect.x === 'number' ? action.rect.x : target.rect.x,
y: typeof action.rect.y === 'number' ? action.rect.y : target.rect.y,
w: typeof action.rect.w === 'number' ? action.rect.w : target.rect.w,
h: typeof action.rect.h === 'number' ? action.rect.h : target.rect.h,
};
const bounds = { w: state.doc.screen.width, h: state.doc.screen.height };
const rect = clampRectToBounds(
{
x: Math.round(next.x),
y: Math.round(next.y),
w: Math.round(next.w),
h: Math.round(next.h),
},
bounds,
50,
);
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => (n.id === action.id ? { ...n, rect } : n)),
},
},
};
}
case 'updateWidgetProps': { case 'updateWidgetProps': {
return updateWidgetProps(state, action); return updateWidgetProps(state, action);
} }
@ -441,56 +340,6 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
}; };
} }
case 'copySelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes.filter((n) => ids.has(n.id));
if (!nodes.length) return state;
return {
...state,
clipboard: {
nodes: nodes.map((n) => ({ ...n, rect: { ...n.rect } })),
},
};
}
case 'pasteClipboard': {
const clip = state.clipboard.nodes;
if (!clip.length) return state;
// Compute bounds of clipboard content.
let minX = Infinity;
let minY = Infinity;
for (const n of clip) {
minX = Math.min(minX, n.rect.x);
minY = Math.min(minY, n.rect.y);
}
const bounds = { w: state.doc.screen.width, h: state.doc.screen.height };
const dx = action.at ? Math.round(action.at.x - minX) : 20;
const dy = action.at ? Math.round(action.at.y - minY) : 20;
const clones: WidgetNode[] = clip.map((n) => {
const id = `${n.type}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const rect = clampRectToBounds({ ...n.rect, x: n.rect.x + dx, y: n.rect.y + dy }, bounds, 50);
return {
...n,
id,
rect,
locked: false,
hidden: false,
};
});
return {
...historyPush(state),
doc: { screen: { ...state.doc.screen, nodes: [...state.doc.screen.nodes, ...clones] } },
selection: { ids: clones.map((c) => c.id) },
};
}
case 'toggleLockSelected': { case 'toggleLockSelected': {
if (!state.selection.ids.length) return state; if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids); const ids = new Set(state.selection.ids);
@ -533,59 +382,27 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
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(state.selection.ids);
state.doc.screen.nodes const nodes = state.doc.screen.nodes;
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
return { let maxZ = 0;
...historyPush(state), for (let i = 0; i < nodes.length; i++) {
doc: { const n = nodes[i]!;
screen: { const z = n.zIndex ?? i;
...state.doc.screen, if (z > maxZ) maxZ = z;
nodes: reorderSelected(state.doc.screen.nodes, ids, 'toFront'),
},
},
};
} }
case 'bringForwardSelected': { let bump = 0;
if (!state.selection.ids.length) return state;
const ids = new Set(
state.doc.screen.nodes
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
return { return {
...historyPush(state), ...historyPush(state),
doc: { doc: {
screen: { screen: {
...state.doc.screen, ...state.doc.screen,
nodes: reorderSelected(state.doc.screen.nodes, ids, 'forward'), nodes: nodes.map((n) => {
}, if (!ids.has(n.id)) return n;
}, bump += 1;
}; return { ...n, zIndex: maxZ + bump };
} }),
case 'sendBackwardSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(
state.doc.screen.nodes
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: reorderSelected(state.doc.screen.nodes, ids, 'backward'),
}, },
}, },
}; };
@ -593,19 +410,27 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
case 'sendToBackSelected': { case 'sendToBackSelected': {
if (!state.selection.ids.length) return state; if (!state.selection.ids.length) return state;
const ids = new Set( const ids = new Set(state.selection.ids);
state.doc.screen.nodes const nodes = state.doc.screen.nodes;
.filter((n) => state.selection.ids.includes(n.id) && !n.locked)
.map((n) => n.id),
);
if (!ids.size) return state;
let minZ = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
const z = n.zIndex ?? i;
if (z < minZ) minZ = z;
}
let bump = 0;
return { return {
...historyPush(state), ...historyPush(state),
doc: { doc: {
screen: { screen: {
...state.doc.screen, ...state.doc.screen,
nodes: reorderSelected(state.doc.screen.nodes, ids, 'toBack'), nodes: nodes.map((n) => {
if (!ids.has(n.id)) return n;
bump += 1;
return { ...n, zIndex: minZ - bump };
}),
}, },
}, },
}; };

View File

@ -55,9 +55,6 @@ export interface EditorState {
ctrl: boolean; ctrl: boolean;
space: boolean; space: boolean;
}; };
clipboard: {
nodes: WidgetNode[];
};
history: { history: {
past: HistoryEntry[]; past: HistoryEntry[];
future: HistoryEntry[]; future: HistoryEntry[];