Compare commits
No commits in common. "78b7fbd9a7b6c473363e86763bf7211aeecb2a03" and "a9e6d4b0d0e1c0aa0287e11f115a5e913b79fe76" have entirely different histories.
78b7fbd9a7
...
a9e6d4b0d0
@ -14,7 +14,6 @@ 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;
|
||||||
@ -277,19 +276,6 @@ 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
|
||||||
|
|||||||
@ -43,14 +43,6 @@ 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;
|
||||||
@ -204,10 +196,6 @@ 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);
|
||||||
@ -364,74 +352,6 @@ 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
|
||||||
|
|||||||
@ -213,31 +213,6 @@ 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>
|
||||||
@ -471,7 +446,6 @@ 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 })}
|
||||||
@ -825,14 +799,6 @@ 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>
|
||||||
|
|||||||
@ -34,8 +34,6 @@ 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' }
|
||||||
@ -72,8 +70,6 @@ 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;
|
||||||
|
|
||||||
@ -275,8 +271,6 @@ 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,
|
||||||
@ -372,28 +366,6 @@ 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;
|
||||||
@ -713,109 +685,6 @@ 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,
|
||||||
@ -1005,8 +874,8 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
|||||||
|
|
||||||
const bounds = action.bounds;
|
const bounds = action.bounds;
|
||||||
|
|
||||||
// goView-like: only snap when enabled and a single node is selected.
|
// goView-like: only snap when a single node is selected.
|
||||||
const canSnap = state.canvas.snapEnabled && state.selection.ids.length === 1;
|
const canSnap = state.selection.ids.length === 1;
|
||||||
const movingId = state.selection.ids[0];
|
const movingId = state.selection.ids[0];
|
||||||
|
|
||||||
const others = canSnap
|
const others = canSnap
|
||||||
@ -1189,8 +1058,8 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
|||||||
h: Math.max(0, newH),
|
h: Math.max(0, newH),
|
||||||
};
|
};
|
||||||
|
|
||||||
// goView-like: only snap when enabled and resizing a single selected node.
|
// goView-like: only snap when resizing a single selected node.
|
||||||
const canSnap = state.canvas.snapEnabled && state.selection.ids.length === 1;
|
const canSnap = state.selection.ids.length === 1;
|
||||||
const movingId = drag.targetId;
|
const movingId = drag.targetId;
|
||||||
|
|
||||||
const others = canSnap
|
const others = canSnap
|
||||||
|
|||||||
@ -16,8 +16,6 @@ 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[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user