feat(editor): align layout skeleton

This commit is contained in:
ErSan 2026-01-27 21:21:25 +08:00
parent 5f093ed157
commit d2871da74b
2 changed files with 317 additions and 78 deletions

View File

@ -38,7 +38,7 @@ export interface CanvasProps {
}
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
// align with goView: ignore middle click in box select
// Ignore middle click in box select.
return (e as PointerEvent).buttons === 1 || (e as PointerEvent).button === 0;
}

View File

@ -1,22 +1,56 @@
import { Button, Divider, Input, Layout, Space, Typography } from 'antd';
import {
Button,
Divider,
Input,
InputNumber,
Layout,
Segmented,
Space,
Tabs,
Typography,
} from 'antd';
import { useEffect, useMemo, useReducer, useState } from 'react';
import { createInitialState, editorReducer, exportScreenJSON } from './store';
import { bindEditorHotkeys } from './hotkeys';
import { Canvas } from './Canvas';
import { Inspector } from './Inspector';
import { createInitialState, editorReducer, exportScreenJSON } from './store';
const { Header, Sider, Content } = Layout;
type RightPanelTabKey = 'custom' | 'animation' | 'data' | 'event';
type LeftCategoryKey =
| 'components'
| 'charts'
| 'info'
| 'list'
| 'widgets'
| 'image'
| 'icon';
function PanelTitle(props: { title: string; extra?: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12, letterSpacing: 0.6 }}>{props.title}</Typography.Text>
{props.extra}
</div>
);
}
export function EditorApp() {
const [state, dispatch] = useReducer(editorReducer, undefined, createInitialState);
const [importText, setImportText] = useState('');
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
const [rightTab, setRightTab] = useState<RightPanelTabKey>('custom');
const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
const hasSelection = state.selection.ids.length > 0;
const bounds = useMemo(
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
[state.doc.screen.width, state.doc.screen.height],
);
// keyboard tracking (ctrl/space) — goView uses window.$KeyboardActive
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space });
@ -33,9 +67,6 @@ export function EditorApp() {
};
}, [state.keyboard.space]);
// Canvas handles its own context menu.
// Hotkeys (goView-like)
useEffect(() => {
return bindEditorHotkeys(() => false, dispatch);
}, []);
@ -45,75 +76,205 @@ export function EditorApp() {
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0, color: '#fff' }}>
AstralView Editor
</Typography.Title>
<Space>
<Button onClick={() => dispatch({ type: 'undo' })} disabled={!state.history.past.length}>
Undo
<Layout style={{ minHeight: '100vh', background: '#0b1220' }}>
<Header
style={{
height: 44,
padding: '0 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#0f172a',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
<Space size={10}>
<Button size="small" type="text" style={{ color: '#cbd5e1' }}>
Home
</Button>
<Button onClick={() => dispatch({ type: 'redo' })} disabled={!state.history.future.length}>
Redo
</Button>
<Button
onClick={() => {
const json = api.exportJSON();
setImportText(json);
void navigator.clipboard?.writeText(json);
<Space size={6}>
<Button size="small" type="text" style={{ color: '#94a3b8' }}>
</Button>
<Button size="small" type="text" style={{ color: '#94a3b8' }}>
</Button>
</Space>
</Space>
<Space size={8}>
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}>Workspace</Typography.Text>
<div
style={{
height: 22,
padding: '0 8px',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.10)',
display: 'flex',
alignItems: 'center',
color: '#cbd5e1',
fontSize: 12,
}}
>
Export JSON (copy)
1
</div>
</Space>
<Space size={8}>
<Button size="small" style={{ background: 'rgba(255,255,255,0.06)', color: '#e2e8f0', border: 'none' }}>
Preview
</Button>
<Button size="small" type="primary">
Publish
</Button>
</Space>
</Header>
<Layout>
<Sider width={360} theme="light" style={{ padding: 16, overflow: 'auto' }}>
<Typography.Title level={5}>Inspector</Typography.Title>
<Typography.Paragraph style={{ marginBottom: 8 }}>
Selected: {state.selection.ids.length ? state.selection.ids.join(', ') : 'None'}
</Typography.Paragraph>
{/* Left icon rail */}
<Sider
width={56}
theme="dark"
style={{
background: '#111827',
borderRight: '1px solid rgba(255,255,255,0.06)',
paddingTop: 10,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '0 8px' }}>
{([
['components', '组件'],
['charts', '图表'],
['info', '信息'],
['list', '列表'],
['widgets', '小组件'],
['image', '图片'],
['icon', '图标'],
] as Array<[LeftCategoryKey, string]>).map(([key, label]) => {
const active = leftCategory === key;
return (
<button
key={key}
type="button"
onClick={() => setLeftCategory(key)}
style={{
height: 34,
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.06)',
background: active ? 'rgba(16,185,129,0.12)' : 'transparent',
color: active ? '#a7f3d0' : '#cbd5e1',
cursor: 'pointer',
fontSize: 12,
}}
title={label}
>
{label.slice(0, 1)}
</button>
);
})}
</div>
</Sider>
<Inspector
selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])}
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
onUpdateImageProps={(id, props) => dispatch({ type: 'updateImageProps', id, props })}
onUpdateIframeProps={(id, props) => dispatch({ type: 'updateIframeProps', id, props })}
onUpdateVideoProps={(id, props) => dispatch({ type: 'updateVideoProps', id, props })}
{/* Left library panel */}
<Sider
width={300}
theme="dark"
style={{
background: '#111827',
borderRight: '1px solid rgba(255,255,255,0.06)',
padding: 12,
overflow: 'auto',
}}
>
<PanelTitle title="组件" />
<Input
size="small"
placeholder="搜索组件"
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.10)', color: '#e2e8f0' }}
/>
<div style={{ marginTop: 10 }}>
<Segmented
size="small"
value="all"
options={[
{ label: '所有', value: 'all' },
{ label: '常用', value: 'common' },
]}
/>
</div>
<Divider />
{/* Placeholder: we will replace with real registry-based catalog */}
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{[
{ title: '柱状图', subtitle: 'Chart' },
{ title: '折线图', subtitle: 'Chart' },
{ title: '图片', subtitle: 'Media' },
{ title: '视频', subtitle: 'Media' },
{ title: '网页', subtitle: 'Embed' },
{ title: '文本', subtitle: 'Text' },
].map((it) => (
<div
key={it.title}
style={{
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.03)',
padding: 10,
display: 'flex',
gap: 10,
alignItems: 'center',
}}
>
<div
style={{
width: 72,
height: 44,
borderRadius: 8,
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.08)',
}}
/>
<div style={{ flex: 1 }}>
<div style={{ color: '#e2e8f0', fontSize: 13 }}>{it.title}</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{it.subtitle}</div>
</div>
</div>
))}
</div>
<Typography.Title level={5}>Import/Export</Typography.Title>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<PanelTitle title="导入 / 导出" />
<Input.TextArea
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={10}
placeholder="Paste screen JSON here"
rows={6}
placeholder="粘贴 JSON"
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.10)', color: '#e2e8f0' }}
/>
<Space style={{ marginTop: 8 }}>
<Button
size="small"
type="primary"
onClick={() => {
dispatch({ type: 'importJSON', json: importText });
}}
onClick={() => dispatch({ type: 'importJSON', json: importText })}
disabled={!importText.trim()}
>
Import
</Button>
<Button onClick={() => setImportText(api.exportJSON())}>Load current</Button>
<Button
size="small"
onClick={() => {
const json = api.exportJSON();
setImportText(json);
void navigator.clipboard?.writeText(json);
}}
>
Export
</Button>
</Space>
<Divider />
<Typography.Title level={5}>Notes</Typography.Title>
<Typography.Paragraph style={{ color: '#666' }}>
Interactions target goView semantics: Ctrl multi-select, box-select full containment, scale-aware movement.
</Typography.Paragraph>
</Sider>
<Content style={{ padding: 16, overflow: 'auto' }}>
{/* Center */}
<Content style={{ padding: 12, overflow: 'hidden', background: '#0b1220' }}>
<Canvas
screen={state.doc.screen}
selectionIds={state.selection.ids}
@ -130,46 +291,124 @@ export function EditorApp() {
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
onBeginPan={(e) => {
dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } });
}}
onUpdatePan={(e: PointerEvent) => {
dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } });
}}
onBeginPan={(e) => dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } })}
onUpdatePan={(e: PointerEvent) => dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } })}
onEndPan={() => dispatch({ type: 'endPan' })}
onBeginMove={(e) => {
dispatch({ type: 'beginMove', start: { screenX: e.screenX, screenY: e.screenY }, bounds });
}}
onUpdateMove={(e: PointerEvent) => {
dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
}}
onBeginMove={(e) => dispatch({ type: 'beginMove', start: { screenX: e.screenX, screenY: e.screenY }, bounds })}
onUpdateMove={(e: PointerEvent) => dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds })}
onEndMove={() => dispatch({ type: 'endMove' })}
onBeginBoxSelect={(e, offsetX, offsetY, additive) => {
onBeginBoxSelect={(e, offsetX, offsetY, additive) =>
dispatch({
type: 'beginBoxSelect',
additive,
start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY },
});
}}
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => {
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });
}}
})
}
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) =>
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } })
}
onEndBoxSelect={() => dispatch({ type: 'endBoxSelect' })}
onBeginResize={(e, id, handle) => {
dispatch({ type: 'beginResize', id, handle, start: { screenX: e.screenX, screenY: e.screenY }, bounds });
}}
onUpdateResize={(e: PointerEvent) => {
dispatch({ type: 'updateResize', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
}}
onBeginResize={(e, id, handle) =>
dispatch({ type: 'beginResize', id, handle, start: { screenX: e.screenX, screenY: e.screenY }, bounds })
}
onUpdateResize={(e: PointerEvent) => dispatch({ type: 'updateResize', current: { screenX: e.screenX, screenY: e.screenY }, bounds })}
onEndResize={() => dispatch({ type: 'endResize' })}
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onWheelPan={(dx, dy) => dispatch({ type: 'panBy', dx, dy })}
onZoomAt={(scale, anchorX, anchorY) => {
const next = Math.max(0.1, Math.min(4, scale));
dispatch({ type: 'zoomAt', scale: next, anchor: { x: anchorX, y: anchorY } });
}}
onZoomAt={(scale, anchorX, anchorY) => dispatch({ type: 'zoomAt', scale: Math.max(0.1, Math.min(4, scale)), anchor: { x: anchorX, y: anchorY } })}
/>
</Content>
{/* Right panel */}
<Sider
width={360}
theme="dark"
style={{
background: '#111827',
borderLeft: '1px solid rgba(255,255,255,0.06)',
padding: 12,
overflow: 'auto',
}}
>
{hasSelection ? (
<>
<Tabs
activeKey={rightTab}
onChange={(k) => setRightTab(k as RightPanelTabKey)}
size="small"
items={[
{ key: 'custom', label: '定制', children: null },
{ key: 'animation', label: '动画', children: null },
{ key: 'data', label: '数据', children: null },
{ key: 'event', label: '事件', children: null },
]}
/>
{rightTab === 'custom' ? (
<>
<div style={{ marginTop: 8 }}>
<PanelTitle
title="组件配置"
extra={
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>
{state.selection.ids.length}/12
</Typography.Text>
}
/>
<div style={{ display: 'grid', gridTemplateColumns: '64px 1fr', gap: 10, alignItems: 'center' }}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<Input
size="small"
value={selected?.type ?? ''}
disabled
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.10)', color: '#e2e8f0' }}
/>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<Space size={8}>
<InputNumber size="small" value={selected?.rect.w ?? 0} controls={false} readOnly style={{ width: 120 }} />
<InputNumber size="small" value={selected?.rect.h ?? 0} controls={false} readOnly style={{ width: 120 }} />
</Space>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<Space size={8}>
<InputNumber size="small" value={selected?.rect.x ?? 0} controls={false} readOnly style={{ width: 120 }} />
<InputNumber size="small" value={selected?.rect.y ?? 0} controls={false} readOnly style={{ width: 120 }} />
</Space>
</div>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Inspector
selected={selected}
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
onUpdateImageProps={(id, props) => dispatch({ type: 'updateImageProps', id, props })}
onUpdateIframeProps={(id, props) => dispatch({ type: 'updateIframeProps', id, props })}
onUpdateVideoProps={(id, props) => dispatch({ type: 'updateVideoProps', id, props })}
/>
</div>
</>
) : (
<Typography.Paragraph style={{ color: '#64748b' }}></Typography.Paragraph>
)}
</>
) : (
<>
<PanelTitle title="页面配置" />
<div style={{ display: 'grid', gridTemplateColumns: '64px 1fr', gap: 10, alignItems: 'center' }}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<InputNumber size="small" value={state.doc.screen.width} controls={false} readOnly style={{ width: 160 }} />
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
<InputNumber size="small" value={state.doc.screen.height} controls={false} readOnly style={{ width: 160 }} />
</div>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Typography.Paragraph style={{ color: '#64748b' }}> / / </Typography.Paragraph>
</>
)}
</Sider>
</Layout>
</Layout>
);