editor: context menu align + distribute
This commit is contained in:
parent
2edcb214ad
commit
78b7fbd9a7
@ -43,6 +43,14 @@ export function ContextMenu(props: {
|
||||
onBringForwardSelected?: () => void;
|
||||
onSendBackwardSelected?: () => void;
|
||||
onSendToBackSelected?: () => void;
|
||||
onAlignLeftSelected?: () => void;
|
||||
onAlignCenterSelected?: () => void;
|
||||
onAlignRightSelected?: () => void;
|
||||
onAlignTopSelected?: () => void;
|
||||
onAlignMiddleSelected?: () => void;
|
||||
onAlignBottomSelected?: () => void;
|
||||
onDistributeHorizontalSelected?: () => void;
|
||||
onDistributeVerticalSelected?: () => void;
|
||||
onDeleteSelected?: () => void;
|
||||
}) {
|
||||
const { onClose } = props;
|
||||
@ -196,6 +204,10 @@ export function ContextMenu(props: {
|
||||
props.onSendBackwardSelected &&
|
||||
orderIds.some((id, i) => movableSet.has(id) && i > 0 && !movableSet.has(orderIds[i - 1]!));
|
||||
|
||||
const movableCount = movableSelectionIds.length;
|
||||
const canAlign = canModifySelection && movableCount >= 2;
|
||||
const canDistribute = canModifySelection && movableCount >= 3;
|
||||
|
||||
const hasTarget = ctx.kind === 'node';
|
||||
const targetId = hasTarget ? ctx.targetId : undefined;
|
||||
const targetInSelection = !!targetId && selectionIds.includes(targetId);
|
||||
@ -352,6 +364,74 @@ export function ContextMenu(props: {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<MenuItem
|
||||
label="Align Left"
|
||||
disabled={!canAlign || !props.onAlignLeftSelected}
|
||||
onClick={() => {
|
||||
props.onAlignLeftSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Align Center"
|
||||
disabled={!canAlign || !props.onAlignCenterSelected}
|
||||
onClick={() => {
|
||||
props.onAlignCenterSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Align Right"
|
||||
disabled={!canAlign || !props.onAlignRightSelected}
|
||||
onClick={() => {
|
||||
props.onAlignRightSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Align Top"
|
||||
disabled={!canAlign || !props.onAlignTopSelected}
|
||||
onClick={() => {
|
||||
props.onAlignTopSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Align Middle"
|
||||
disabled={!canAlign || !props.onAlignMiddleSelected}
|
||||
onClick={() => {
|
||||
props.onAlignMiddleSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Align Bottom"
|
||||
disabled={!canAlign || !props.onAlignBottomSelected}
|
||||
onClick={() => {
|
||||
props.onAlignBottomSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
label="Distribute Horizontally"
|
||||
disabled={!canDistribute || !props.onDistributeHorizontalSelected}
|
||||
onClick={() => {
|
||||
props.onDistributeHorizontalSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Distribute Vertically"
|
||||
disabled={!canDistribute || !props.onDistributeVerticalSelected}
|
||||
onClick={() => {
|
||||
props.onDistributeVerticalSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
label="Delete"
|
||||
danger
|
||||
|
||||
@ -825,6 +825,14 @@ export function EditorApp() {
|
||||
onBringForwardSelected={() => dispatchWithMenuSelection({ type: 'bringForwardSelected' })}
|
||||
onSendBackwardSelected={() => dispatchWithMenuSelection({ type: 'sendBackwardSelected' })}
|
||||
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
|
||||
onAlignLeftSelected={() => dispatchWithMenuSelection({ type: 'alignSelected', axis: 'x', kind: 'min' })}
|
||||
onAlignCenterSelected={() => dispatchWithMenuSelection({ type: 'alignSelected', axis: 'x', kind: 'center' })}
|
||||
onAlignRightSelected={() => dispatchWithMenuSelection({ type: 'alignSelected', axis: 'x', kind: 'max' })}
|
||||
onAlignTopSelected={() => dispatchWithMenuSelection({ type: 'alignSelected', axis: 'y', kind: 'min' })}
|
||||
onAlignMiddleSelected={() => dispatchWithMenuSelection({ type: 'alignSelected', axis: 'y', kind: 'center' })}
|
||||
onAlignBottomSelected={() => dispatchWithMenuSelection({ type: 'alignSelected', axis: 'y', kind: 'max' })}
|
||||
onDistributeHorizontalSelected={() => dispatchWithMenuSelection({ type: 'distributeSelected', axis: 'x' })}
|
||||
onDistributeVerticalSelected={() => dispatchWithMenuSelection({ type: 'distributeSelected', axis: 'y' })}
|
||||
onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
@ -72,6 +72,8 @@ export type EditorAction =
|
||||
| { type: 'bringForwardSelected' }
|
||||
| { type: 'sendBackwardSelected' }
|
||||
| { type: 'sendToBackSelected' }
|
||||
| { type: 'alignSelected'; axis: 'x' | 'y'; kind: 'min' | 'center' | 'max' }
|
||||
| { type: 'distributeSelected'; axis: 'x' | 'y' }
|
||||
| { type: 'updateWidgetRect'; id: string; rect: Partial<Rect> }
|
||||
| UpdateWidgetPropsAction;
|
||||
|
||||
@ -711,6 +713,109 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
||||
};
|
||||
}
|
||||
|
||||
case 'alignSelected': {
|
||||
const ids = new Set(state.selection.ids);
|
||||
const targets = state.doc.screen.nodes.filter((n) => ids.has(n.id) && !n.locked && !n.hidden);
|
||||
if (targets.length < 2) return state;
|
||||
|
||||
const bounds = { w: state.doc.screen.width, h: state.doc.screen.height };
|
||||
|
||||
const bbox: Rect = {
|
||||
x: Math.min(...targets.map((n) => n.rect.x)),
|
||||
y: Math.min(...targets.map((n) => n.rect.y)),
|
||||
w: Math.max(...targets.map((n) => n.rect.x + n.rect.w)) - Math.min(...targets.map((n) => n.rect.x)),
|
||||
h: Math.max(...targets.map((n) => n.rect.y + n.rect.h)) - Math.min(...targets.map((n) => n.rect.y)),
|
||||
};
|
||||
|
||||
const nextNodes = state.doc.screen.nodes.map((n) => {
|
||||
if (!ids.has(n.id)) return n;
|
||||
if (n.locked || n.hidden) return n;
|
||||
|
||||
if (action.axis === 'x') {
|
||||
const x =
|
||||
action.kind === 'min'
|
||||
? bbox.x
|
||||
: action.kind === 'center'
|
||||
? bbox.x + (bbox.w - n.rect.w) / 2
|
||||
: bbox.x + bbox.w - n.rect.w;
|
||||
return { ...n, rect: clampRectToBounds({ ...n.rect, x: Math.round(x) }, bounds, 50) };
|
||||
}
|
||||
|
||||
const y =
|
||||
action.kind === 'min'
|
||||
? bbox.y
|
||||
: action.kind === 'center'
|
||||
? bbox.y + (bbox.h - n.rect.h) / 2
|
||||
: bbox.y + bbox.h - n.rect.h;
|
||||
return { ...n, rect: clampRectToBounds({ ...n.rect, y: Math.round(y) }, bounds, 50) };
|
||||
});
|
||||
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen: { ...state.doc.screen, nodes: nextNodes } },
|
||||
};
|
||||
}
|
||||
|
||||
case 'distributeSelected': {
|
||||
const ids = new Set(state.selection.ids);
|
||||
const targets = state.doc.screen.nodes
|
||||
.filter((n) => ids.has(n.id) && !n.locked && !n.hidden)
|
||||
.map((n) => ({ ...n, rect: { ...n.rect } }));
|
||||
if (targets.length < 3) return state;
|
||||
|
||||
const bounds = { w: state.doc.screen.width, h: state.doc.screen.height };
|
||||
|
||||
if (action.axis === 'x') {
|
||||
const sorted = [...targets].sort((a, b) => a.rect.x - b.rect.x);
|
||||
const minLeft = Math.min(...sorted.map((n) => n.rect.x));
|
||||
const maxRight = Math.max(...sorted.map((n) => n.rect.x + n.rect.w));
|
||||
const totalW = sorted.reduce((sum, n) => sum + n.rect.w, 0);
|
||||
const span = maxRight - minLeft;
|
||||
const gap = (span - totalW) / (sorted.length - 1);
|
||||
|
||||
let x = minLeft;
|
||||
const nextById = new Map<string, Rect>();
|
||||
for (const n of sorted) {
|
||||
nextById.set(n.id, clampRectToBounds({ ...n.rect, x: Math.round(x) }, bounds, 50));
|
||||
x += n.rect.w + gap;
|
||||
}
|
||||
|
||||
const nextNodes = state.doc.screen.nodes.map((n) => {
|
||||
const r = nextById.get(n.id);
|
||||
return r ? { ...n, rect: r } : n;
|
||||
});
|
||||
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen: { ...state.doc.screen, nodes: nextNodes } },
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...targets].sort((a, b) => a.rect.y - b.rect.y);
|
||||
const minTop = Math.min(...sorted.map((n) => n.rect.y));
|
||||
const maxBottom = Math.max(...sorted.map((n) => n.rect.y + n.rect.h));
|
||||
const totalH = sorted.reduce((sum, n) => sum + n.rect.h, 0);
|
||||
const span = maxBottom - minTop;
|
||||
const gap = (span - totalH) / (sorted.length - 1);
|
||||
|
||||
let y = minTop;
|
||||
const nextById = new Map<string, Rect>();
|
||||
for (const n of sorted) {
|
||||
nextById.set(n.id, clampRectToBounds({ ...n.rect, y: Math.round(y) }, bounds, 50));
|
||||
y += n.rect.h + gap;
|
||||
}
|
||||
|
||||
const nextNodes = state.doc.screen.nodes.map((n) => {
|
||||
const r = nextById.get(n.id);
|
||||
return r ? { ...n, rect: r } : n;
|
||||
});
|
||||
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen: { ...state.doc.screen, nodes: nextNodes } },
|
||||
};
|
||||
}
|
||||
|
||||
case 'beginPan': {
|
||||
return {
|
||||
...state,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user