Compare commits

..

2 Commits

Author SHA1 Message Date
clawdbot
78b7fbd9a7 editor: context menu align + distribute 2026-01-29 09:19:46 +08:00
clawdbot
2edcb214ad editor: add grid + snap toggles 2026-01-29 08:40:40 +08:00
5 changed files with 265 additions and 4 deletions

View File

@ -14,6 +14,7 @@ export interface CanvasProps {
scale: number; scale: number;
panX: number; panX: number;
panY: number; panY: number;
gridEnabled: boolean;
guides: { xs: number[]; ys: number[] }; guides: { xs: number[]; ys: number[] };
onSelectSingle(id?: string): void; onSelectSingle(id?: string): void;
onToggleSelect(id: string): void; onToggleSelect(id: string): void;
@ -276,6 +277,19 @@ export function Canvas(props: CanvasProps) {
position: 'relative', position: 'relative',
}} }}
> >
{props.gridEnabled ? (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)',
backgroundSize: '20px 20px',
pointerEvents: 'none',
}}
/>
) : null}
{/* guides (snap lines) */} {/* guides (snap lines) */}
{props.guides.xs.map((x) => ( {props.guides.xs.map((x) => (
<div <div

View File

@ -43,6 +43,14 @@ export function ContextMenu(props: {
onBringForwardSelected?: () => void; onBringForwardSelected?: () => void;
onSendBackwardSelected?: () => void; onSendBackwardSelected?: () => void;
onSendToBackSelected?: () => void; onSendToBackSelected?: () => void;
onAlignLeftSelected?: () => void;
onAlignCenterSelected?: () => void;
onAlignRightSelected?: () => void;
onAlignTopSelected?: () => void;
onAlignMiddleSelected?: () => void;
onAlignBottomSelected?: () => void;
onDistributeHorizontalSelected?: () => void;
onDistributeVerticalSelected?: () => void;
onDeleteSelected?: () => void; onDeleteSelected?: () => void;
}) { }) {
const { onClose } = props; const { onClose } = props;
@ -196,6 +204,10 @@ export function ContextMenu(props: {
props.onSendBackwardSelected && props.onSendBackwardSelected &&
orderIds.some((id, i) => movableSet.has(id) && i > 0 && !movableSet.has(orderIds[i - 1]!)); 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 hasTarget = ctx.kind === 'node';
const targetId = hasTarget ? ctx.targetId : undefined; const targetId = hasTarget ? ctx.targetId : undefined;
const targetInSelection = !!targetId && selectionIds.includes(targetId); 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 <MenuItem
label="Delete" label="Delete"
danger danger

View File

@ -213,6 +213,31 @@ export function EditorApp() {
</Space> </Space>
<Space size={8}> <Space size={8}>
<Button
size="small"
type={state.canvas.gridEnabled ? 'default' : 'text'}
onClick={() => dispatch({ type: 'toggleGrid' })}
style={
state.canvas.gridEnabled
? { background: 'rgba(59,130,246,0.16)', color: '#e2e8f0', border: '1px solid rgba(59,130,246,0.35)' }
: { color: '#94a3b8' }
}
>
Grid
</Button>
<Button
size="small"
type={state.canvas.snapEnabled ? 'default' : 'text'}
onClick={() => dispatch({ type: 'toggleSnap' })}
style={
state.canvas.snapEnabled
? { background: 'rgba(34,197,94,0.12)', color: '#e2e8f0', border: '1px solid rgba(34,197,94,0.30)' }
: { color: '#94a3b8' }
}
>
Snap
</Button>
<Divider type="vertical" style={{ borderColor: 'rgba(255,255,255,0.10)' }} />
<Button size="small" style={{ background: 'rgba(255,255,255,0.06)', color: '#e2e8f0', border: 'none' }}> <Button size="small" style={{ background: 'rgba(255,255,255,0.06)', color: '#e2e8f0', border: 'none' }}>
Preview Preview
</Button> </Button>
@ -446,6 +471,7 @@ export function EditorApp() {
scale={state.canvas.scale} scale={state.canvas.scale}
panX={state.canvas.panX} panX={state.canvas.panX}
panY={state.canvas.panY} panY={state.canvas.panY}
gridEnabled={state.canvas.gridEnabled}
guides={state.canvas.guides} guides={state.canvas.guides}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })} onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })} onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
@ -799,6 +825,14 @@ export function EditorApp() {
onBringForwardSelected={() => dispatchWithMenuSelection({ type: 'bringForwardSelected' })} onBringForwardSelected={() => dispatchWithMenuSelection({ type: 'bringForwardSelected' })}
onSendBackwardSelected={() => dispatchWithMenuSelection({ type: 'sendBackwardSelected' })} onSendBackwardSelected={() => dispatchWithMenuSelection({ type: 'sendBackwardSelected' })}
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })} 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' })} onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
/> />
</Layout> </Layout>

View File

@ -34,6 +34,8 @@ export type EditorAction =
| { type: 'setScale'; scale: number } | { type: 'setScale'; scale: number }
| { type: 'panBy'; dx: number; dy: number } | { type: 'panBy'; dx: number; dy: number }
| { type: 'zoomAt'; scale: number; anchor: { x: number; y: number } } | { type: 'zoomAt'; scale: number; anchor: { x: number; y: number } }
| { type: 'toggleGrid' }
| { type: 'toggleSnap' }
| { type: 'beginPan'; start: { screenX: number; screenY: number } } | { type: 'beginPan'; start: { screenX: number; screenY: number } }
| { type: 'updatePan'; current: { screenX: number; screenY: number } } | { type: 'updatePan'; current: { screenX: number; screenY: number } }
| { type: 'endPan' } | { type: 'endPan' }
@ -70,6 +72,8 @@ export type EditorAction =
| { type: 'bringForwardSelected' } | { type: 'bringForwardSelected' }
| { type: 'sendBackwardSelected' } | { type: 'sendBackwardSelected' }
| { type: 'sendToBackSelected' } | { type: 'sendToBackSelected' }
| { type: 'alignSelected'; axis: 'x' | 'y'; kind: 'min' | 'center' | 'max' }
| { type: 'distributeSelected'; axis: 'x' | 'y' }
| { type: 'updateWidgetRect'; id: string; rect: Partial<Rect> } | { type: 'updateWidgetRect'; id: string; rect: Partial<Rect> }
| UpdateWidgetPropsAction; | UpdateWidgetPropsAction;
@ -271,6 +275,8 @@ export function createInitialState(): EditorRuntimeState {
scale: 1, scale: 1,
panX: 0, panX: 0,
panY: 0, panY: 0,
gridEnabled: false,
snapEnabled: true,
guides: { xs: [], ys: [] }, guides: { xs: [], ys: [] },
isDragging: false, isDragging: false,
isPanning: false, isPanning: false,
@ -366,6 +372,28 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
}; };
} }
case 'toggleGrid': {
return {
...state,
canvas: {
...state.canvas,
gridEnabled: !state.canvas.gridEnabled,
},
};
}
case 'toggleSnap': {
// Clearing guides prevents stale guide lines when turning snap off mid-drag.
return {
...state,
canvas: {
...state.canvas,
snapEnabled: !state.canvas.snapEnabled,
guides: { xs: [], ys: [] },
},
};
}
case 'updateWidgetRect': { case 'updateWidgetRect': {
const target = state.doc.screen.nodes.find((n) => n.id === action.id); const target = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!target) return state; if (!target) return state;
@ -685,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': { case 'beginPan': {
return { return {
...state, ...state,
@ -874,8 +1005,8 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
const bounds = action.bounds; const bounds = action.bounds;
// goView-like: only snap when a single node is selected. // goView-like: only snap when enabled and a single node is selected.
const canSnap = state.selection.ids.length === 1; const canSnap = state.canvas.snapEnabled && state.selection.ids.length === 1;
const movingId = state.selection.ids[0]; const movingId = state.selection.ids[0];
const others = canSnap const others = canSnap
@ -1058,8 +1189,8 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
h: Math.max(0, newH), h: Math.max(0, newH),
}; };
// goView-like: only snap when resizing a single selected node. // goView-like: only snap when enabled and resizing a single selected node.
const canSnap = state.selection.ids.length === 1; const canSnap = state.canvas.snapEnabled && state.selection.ids.length === 1;
const movingId = drag.targetId; const movingId = drag.targetId;
const others = canSnap const others = canSnap

View File

@ -16,6 +16,8 @@ export interface EditorCanvasState {
scale: number; scale: number;
panX: number; panX: number;
panY: number; panY: number;
gridEnabled: boolean;
snapEnabled: boolean;
guides: { guides: {
xs: number[]; xs: number[];
ys: number[]; ys: number[];