From 78b7fbd9a7b6c473363e86763bf7211aeecb2a03 Mon Sep 17 00:00:00 2001 From: clawdbot Date: Thu, 29 Jan 2026 09:19:46 +0800 Subject: [PATCH] editor: context menu align + distribute --- packages/editor/src/editor/ContextMenu.tsx | 80 ++++++++++++++++ packages/editor/src/editor/EditorApp.tsx | 8 ++ packages/editor/src/editor/store.ts | 105 +++++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/packages/editor/src/editor/ContextMenu.tsx b/packages/editor/src/editor/ContextMenu.tsx index 792464b..7561277 100644 --- a/packages/editor/src/editor/ContextMenu.tsx +++ b/packages/editor/src/editor/ContextMenu.tsx @@ -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: { }} /> +
+ + { + props.onAlignLeftSelected?.(); + onClose(); + }} + /> + { + props.onAlignCenterSelected?.(); + onClose(); + }} + /> + { + props.onAlignRightSelected?.(); + onClose(); + }} + /> + { + props.onAlignTopSelected?.(); + onClose(); + }} + /> + { + props.onAlignMiddleSelected?.(); + onClose(); + }} + /> + { + props.onAlignBottomSelected?.(); + onClose(); + }} + /> + + { + props.onDistributeHorizontalSelected?.(); + onClose(); + }} + /> + { + props.onDistributeVerticalSelected?.(); + onClose(); + }} + /> + 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' })} /> diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts index da7bc74..800f5a3 100644 --- a/packages/editor/src/editor/store.ts +++ b/packages/editor/src/editor/store.ts @@ -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 } | 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(); + 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(); + 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,