feat(editor): align layout skeleton
This commit is contained in:
parent
f96f77ad9c
commit
b859d1e3b6
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user