Compare commits

...

14 Commits

19 changed files with 1394 additions and 138 deletions

6
.gitignore vendored
View File

@ -22,3 +22,9 @@ Thumbs.db
# docs build
packages/docs/docs/.vitepress/cache
packages/docs/docs/.vitepress/dist
# TypeScript build info
*.tsbuildinfo
# local traces
trace.txt

View File

@ -7,7 +7,8 @@
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"format": "prettier . --write",
"typecheck": "pnpm -r typecheck"
"typecheck": "pnpm -r typecheck",
"check": "pnpm lint && pnpm typecheck && pnpm build"
},
"devDependencies": {
"@eslint/js": "^9.20.0",

View File

@ -64,15 +64,15 @@ Represents one dashboard/screen.
- Clipboard (copy/paste)
- History (command pattern)
## Migration Plan (from go-view)
## Migration Plan (from legacy)
We will *not* transplant Vue UI. We will extract **behavior + data contracts** into `sdk`, then rebuild UI in `editor`.
High-signal sources in go-view:
High-signal sources in the legacy codebase:
- `src/views/chart/ContentEdit/*` (drag, selection, guides, tools)
- `src/store/modules/chartEditStore` (editor state)
- `src/hooks/useChartDataFetch.hook.ts` (data fetch)
- `src/views/preview/*` (preview scaling + rendering)
- `src/packages/*` (widget catalog + configs)
Next step: write a detailed mapping table (go-view module → sdk/editor target) and implement the first vertical slice: **canvas + one widget + import/export**.
Next step: write a detailed mapping table (legacy module → sdk/editor target) and implement the first vertical slice: **canvas + one widget + import/export**.

View File

@ -3,4 +3,4 @@
- [Architecture](./architecture)
- [Migration Plan](./migration)
This site documents the AstralView monorepo and the ongoing refactor from go-view.
This site documents the AstralView monorepo and the ongoing refactor from a legacy third-party editor codebase.

View File

@ -1,8 +1,8 @@
# Migration Plan (go-view → AstralView)
# Migration Plan (Legacy → AstralView)
## Strategy
- Keep go-view as reference under `third_party/go-view`.
- Keep the legacy codebase as reference under `third_party/`.
- Extract domain and behaviors into `@astralview/sdk`.
- Rebuild UI in `@astralview/editor` (React + Ant Design).

View File

@ -20,7 +20,7 @@ export interface CanvasProps {
onBeginMove(e: React.PointerEvent): void;
onUpdateMove(e: PointerEvent): void;
onEndMove(): void;
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number): void;
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number, additive: boolean): void;
onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void;
onEndBoxSelect(): void;
onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): void;
@ -31,10 +31,14 @@ export interface CanvasProps {
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
onDeleteSelected?(): void;
onDuplicateSelected?(): void;
onToggleLockSelected?(): void;
onToggleHideSelected?(): void;
onBringToFrontSelected?(): void;
onSendToBackSelected?(): void;
}
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;
}
@ -53,6 +57,31 @@ export function Canvas(props: CanvasProps) {
const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]);
const selection = useMemo(() => {
const ids = new Set(props.selectionIds);
return props.screen.nodes.filter((n) => ids.has(n.id));
}, [props.screen.nodes, props.selectionIds]);
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
const ctxMenuPos = useMemo(() => {
if (!ctx) return null;
// Rough clamp so the menu stays inside the viewport.
// (We don't measure actual size to keep this simple + stable.)
const w = 220;
const h = 320;
const vw = typeof window === 'undefined' ? 10_000 : window.innerWidth;
const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
return {
x: Math.max(8, Math.min(ctx.clientX, vw - w - 8)),
y: Math.max(8, Math.min(ctx.clientY, vh - h - 8)),
};
}, [ctx]);
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
const el = ref.current;
if (!el) return null;
@ -163,10 +192,22 @@ export function Canvas(props: CanvasProps) {
const p = clientToWorld(e.clientX, e.clientY);
if (!p) return;
if (targetId && !props.selectionIds.includes(targetId)) {
const additive = (e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey;
if (targetId) {
if (!props.selectionIds.includes(targetId)) {
// goView-ish: right click selects the item.
// Ctrl/Cmd keeps multi-select parity (add to selection instead of replacing).
if (additive) {
props.onToggleSelect(targetId);
} else {
props.onSelectSingle(targetId);
}
}
} else {
// Editor parity: right-click on empty space clears selection (unless additive).
if (!additive) props.onSelectSingle(undefined);
}
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
};
@ -190,8 +231,9 @@ export function Canvas(props: CanvasProps) {
const p = clientToWorld(e.clientX, e.clientY);
if (!p) return;
props.onSelectSingle(undefined);
props.onBeginBoxSelect(e, p.x, p.y);
const additive = e.ctrlKey || e.metaKey;
if (!additive) props.onSelectSingle(undefined);
props.onBeginBoxSelect(e, p.x, p.y, additive);
setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y }));
};
@ -246,12 +288,12 @@ export function Canvas(props: CanvasProps) {
cursor: props.keyboard.space ? 'grab' : 'default',
}}
>
{ctx && (
{ctx && ctxMenuPos && (
<div
style={{
position: 'fixed',
left: ctx.clientX,
top: ctx.clientY,
left: ctxMenuPos.x,
top: ctxMenuPos.y,
zIndex: 10_000,
background: '#111827',
border: '1px solid rgba(255,255,255,0.12)',
@ -283,6 +325,43 @@ export function Canvas(props: CanvasProps) {
setCtx(null);
}}
/>
<MenuItem
label={selectionAllLocked ? 'Unlock' : 'Lock'}
disabled={!props.selectionIds.length || !props.onToggleLockSelected}
onClick={() => {
props.onToggleLockSelected?.();
setCtx(null);
}}
/>
<MenuItem
label={selectionAllHidden ? 'Show' : 'Hide'}
disabled={!props.selectionIds.length || !props.onToggleHideSelected}
onClick={() => {
props.onToggleHideSelected?.();
setCtx(null);
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Bring To Front"
disabled={!props.selectionIds.length || !props.onBringToFrontSelected}
onClick={() => {
props.onBringToFrontSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Send To Back"
disabled={!props.selectionIds.length || !props.onSendToBackSelected}
onClick={() => {
props.onSendToBackSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Delete"
danger
@ -348,6 +427,10 @@ export function Canvas(props: CanvasProps) {
e.stopPropagation();
if (node.locked) return;
// Right-click selection is handled by the contextmenu handler.
// Important: don't collapse a multi-selection on pointerdown.
if (e.button === 2) return;
// ctrl click: multi-select toggle
if (props.keyboard.ctrl) {
props.onToggleSelect(node.id);
@ -355,9 +438,6 @@ export function Canvas(props: CanvasProps) {
}
props.onSelectSingle(node.id);
// right click should not start move
if (e.button === 2) return;
props.onBeginMove(e);
}}
onContextMenu={(e) => openContextMenu(e, node.id)}
@ -495,9 +575,78 @@ function NodeView(props: {
{node.props.text}
</span>
</div>
) : (
<div>{node.type}</div>
)}
) : node.type === 'image' ? (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<img
src={node.props.src}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
display: 'block',
// Editor parity: allow selecting/dragging the widget even when clicking the media.
pointerEvents: 'none',
}}
/>
</div>
) : node.type === 'iframe' ? (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<iframe
src={node.props.src}
width={rect.w}
height={rect.h}
style={{
border: 0,
display: 'block',
// Editor parity: iframes steal pointer events; disable so selection/context menu works.
pointerEvents: 'none',
}}
title={node.id}
/>
</div>
) : node.type === 'video' ? (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<video
src={node.props.src}
width={rect.w}
height={rect.h}
autoPlay
playsInline
loop={node.props.loop ?? false}
muted={node.props.muted ?? false}
style={{
display: 'block',
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
// Editor parity: allow selecting/dragging even when clicking the video surface.
pointerEvents: 'none',
}}
/>
</div>
) : null}
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
</div>

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,72 +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
<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,
}}
>
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>
{/* 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>
{/* 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>
{/* 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>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<PanelTitle title="导入 / 导出" />
<Input.TextArea
value={importText}
onChange={(e) => setImportText(e.target.value)}
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 })}
disabled={!importText.trim()}
>
Import
</Button>
<Button
size="small"
onClick={() => {
const json = api.exportJSON();
setImportText(json);
void navigator.clipboard?.writeText(json);
}}
>
Export JSON (copy)
Export
</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>
<Inspector
selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])}
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
/>
<Divider />
<Typography.Title level={5}>Import/Export</Typography.Title>
<Input.TextArea
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={10}
placeholder="Paste screen JSON here"
/>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
onClick={() => {
dispatch({ type: 'importJSON', json: importText });
}}
disabled={!importText.trim()}
>
Import
</Button>
<Button onClick={() => setImportText(api.exportJSON())}>Load current</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}
@ -123,42 +287,128 @@ export function EditorApp() {
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
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 } });
}}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
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 } })}
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) => {
dispatch({ type: 'beginBoxSelect', 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 } });
}}
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 } })
}
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>
);

View File

@ -1,9 +1,16 @@
import { Input, InputNumber, Select, Space, Typography } from 'antd';
import type { TextWidgetNode, WidgetNode } from '@astralview/sdk';
import type { WidgetNode, TextWidgetNode } from '@astralview/sdk';
type ImageWidgetNode = Extract<WidgetNode, { type: 'image' }>;
type IframeWidgetNode = Extract<WidgetNode, { type: 'iframe' }>;
type VideoWidgetNode = Extract<WidgetNode, { type: 'video' }>;
export function Inspector(props: {
selected?: WidgetNode;
onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void;
onUpdateImageProps: (id: string, patch: Partial<ImageWidgetNode['props']>) => void;
onUpdateIframeProps: (id: string, patch: Partial<IframeWidgetNode['props']>) => void;
onUpdateVideoProps: (id: string, patch: Partial<VideoWidgetNode['props']>) => void;
}) {
const node = props.selected;
@ -11,8 +18,143 @@ export function Inspector(props: {
return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>;
}
if (node.type === 'image') {
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Image
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateImageProps(node.id, { src: e.target.value })}
placeholder="https://..."
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Fit</Typography.Text>
<Select
value={node.props.fit ?? 'contain'}
onChange={(v) => props.onUpdateImageProps(node.id, { fit: v })}
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'contain', label: 'contain' },
{ value: 'cover', label: 'cover' },
{ value: 'fill', label: 'fill' },
{ value: 'none', label: 'none' },
{ value: 'scale-down', label: 'scale-down' },
]}
/>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateImageProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
}
if (node.type === 'iframe') {
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Iframe
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateIframeProps(node.id, { src: e.target.value })}
placeholder="https://..."
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
}
if (node.type === 'video') {
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
Video
</Typography.Title>
<Typography.Text type="secondary">Source</Typography.Text>
<Input
value={node.props.src}
onChange={(e) => props.onUpdateVideoProps(node.id, { src: e.target.value })}
placeholder="https://..."
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary">Fit</Typography.Text>
<Select
value={node.props.fit ?? 'contain'}
onChange={(v) => props.onUpdateVideoProps(node.id, { fit: v })}
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'contain', label: 'contain' },
{ value: 'cover', label: 'cover' },
{ value: 'fill', label: 'fill' },
{ value: 'none', label: 'none' },
{ value: 'scale-down', label: 'scale-down' },
]}
/>
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Loop</Typography.Text>
<Select
value={String(node.props.loop ?? false)}
onChange={(v) => props.onUpdateVideoProps(node.id, { loop: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary">Muted</Typography.Text>
<Select
value={String(node.props.muted ?? false)}
onChange={(v) => props.onUpdateVideoProps(node.id, { muted: v === 'true' })}
style={{ width: '100%' }}
options={[
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
]}
/>
</div>
</Space>
<Typography.Text type="secondary">Border radius</Typography.Text>
<InputNumber
value={node.props.borderRadius ?? 0}
min={0}
onChange={(v) => props.onUpdateVideoProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
style={{ width: '100%' }}
/>
</div>
);
}
// If more widget types are added, handle them above.
// For now, we only support text/image/iframe/video.
if (node.type !== 'text') {
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type: {node.type}</Typography.Paragraph>;
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type.</Typography.Paragraph>;
}
const fontWeight = node.props.fontWeight ?? 400;
@ -144,7 +286,7 @@ export function Inspector(props: {
style={{ width: '100%', marginBottom: 12 }}
options={[
{ value: 'horizontal-tb', label: 'Horizontal' },
{ value: 'vertical-rl', label: 'Vertical' }
{ value: 'vertical-rl', label: 'Vertical' },
]}
/>

View File

@ -3,6 +3,9 @@ import {
createEmptyScreen,
migrateScreen,
type Rect,
type ImageWidgetNode,
type IframeWidgetNode,
type VideoWidgetNode,
type Screen,
type TextWidgetNode,
type WidgetNode,
@ -25,7 +28,11 @@ export type EditorAction =
| { type: 'endPan' }
| { type: 'selectSingle'; id?: string }
| { type: 'toggleSelect'; id: string }
| { type: 'beginBoxSelect'; start: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
| {
type: 'beginBoxSelect';
additive: boolean;
start: { offsetX: number; offsetY: number; screenX: number; screenY: number };
}
| { type: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
| { type: 'endBoxSelect' }
| { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
@ -39,7 +46,14 @@ export type EditorAction =
| { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> };
| { type: 'toggleLockSelected' }
| { type: 'toggleHideSelected' }
| { type: 'bringToFrontSelected' }
| { type: 'sendToBackSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> }
| { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> }
| { type: 'updateVideoProps'; id: string; props: Partial<VideoWidgetNode['props']> };
interface DragSession {
kind: 'move' | 'resize';
@ -198,6 +212,57 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
};
}
case 'updateImageProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'image') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'image') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
}
case 'updateIframeProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'iframe') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'iframe') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
}
case 'updateVideoProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'video') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'video') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
}
case 'deleteSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
@ -259,6 +324,101 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
};
}
case 'toggleLockSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id));
if (!selected.length) return state;
const shouldLock = selected.some((n) => !n.locked);
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, locked: shouldLock } : n)),
},
},
};
}
case 'toggleHideSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id));
if (!selected.length) return state;
const shouldHide = selected.some((n) => !n.hidden);
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)),
},
},
selection: shouldHide ? { ids: [] } : state.selection,
};
}
case 'bringToFrontSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes;
let maxZ = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
const z = n.zIndex ?? i;
if (z > maxZ) maxZ = z;
}
let bump = 0;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: nodes.map((n) => {
if (!ids.has(n.id)) return n;
bump += 1;
return { ...n, zIndex: maxZ + bump };
}),
},
},
};
}
case 'sendToBackSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes;
let minZ = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
const z = n.zIndex ?? i;
if (z < minZ) minZ = z;
}
let bump = 0;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: nodes.map((n) => {
if (!ids.has(n.id)) return n;
bump += 1;
return { ...n, zIndex: minZ - bump };
}),
},
},
};
}
case 'beginPan': {
return {
...state,
@ -319,6 +479,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'beginBoxSelect': {
if (action.start.screenX === 0 && action.start.screenY === 0) return state;
const baseIds = action.additive ? state.selection.ids : [];
return {
...state,
canvas: {
@ -336,8 +499,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
screenY: action.start.screenY,
},
},
selection: { ids: [] },
};
selection: { ids: baseIds },
__boxSelect: { additive: action.additive, baseIds },
} as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } };
}
case 'updateBoxSelect': {
@ -358,9 +522,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
if (rectContains(box, node.rect)) selected.push(node.id);
}
const boxState = (state as EditorState & { __boxSelect?: { additive: boolean; baseIds: string[] } }).__boxSelect;
const ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected;
return {
...state,
selection: { ids: selected },
selection: { ids },
canvas: {
...state.canvas,
mouse: {
@ -376,8 +543,10 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'endBoxSelect': {
if (!state.canvas.isBoxSelecting) return state;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __boxSelect: _box, ...rest } = state as EditorState & { __boxSelect?: unknown };
return {
...state,
...rest,
canvas: {
...state.canvas,
isBoxSelecting: false,

View File

@ -4,4 +4,19 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// Keep bundle sizes manageable (and make caching better) by splitting common deps.
manualChunks(id) {
if (!id.includes('node_modules')) return
// Keep this match tight: many packages contain "react" in their name.
if (/node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) return 'react'
return 'vendor'
},
},
},
},
})

View File

@ -1,13 +1,16 @@
import type { Screen } from './schema';
import { convertGoViewProjectToScreen } from './goview/convert';
/**
* goView JSON converter (stub).
* goView JSON converter.
*
* The legacy goView format isn't implemented yet; this is a placeholder so we can
* start wiring UI + migration flows without committing to the full mapping.
* We keep this permissive because goView exports / storage snapshots vary across
* versions and forks. The heavy lifting lives in `goview/convert.ts`.
*/
export function convertGoViewJSONToScreen(input: unknown): Screen {
// keep reference to avoid unused-vars lint until implemented
void input;
throw new Error('convertGoViewJSONToScreen: not implemented yet');
if (!input || typeof input !== 'object') {
throw new Error('convertGoViewJSONToScreen: expected object');
}
return convertGoViewProjectToScreen(input as unknown as object);
}

View File

@ -1,52 +1,304 @@
import { ASTRALVIEW_SCHEMA_VERSION, createEmptyScreen, type Screen, type TextWidgetNode } from '../schema';
import {
ASTRALVIEW_SCHEMA_VERSION,
createEmptyScreen,
type ImageWidgetNode,
type IframeWidgetNode,
type Screen,
type TextWidgetNode,
type VideoWidgetNode,
} from '../schema';
import { convertGoViewImageOptionToNodeProps, type GoViewImageOption } from '../widgets/image';
import { convertGoViewIframeOptionToNodeProps, type GoViewIframeOption } from '../widgets/iframe';
import { convertGoViewVideoOptionToNodeProps, type GoViewVideoOption } from '../widgets/video';
import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text';
import { pickUrlLike } from '../widgets/urlLike';
export interface GoViewComponentLike {
id?: string;
// some exports wrap the actual component under a nested field
component?: GoViewComponentLike;
// component identity
key?: string; // e.g. "TextCommon" (sometimes)
componentKey?: string;
chartConfig?: {
key?: string;
chartKey?: string;
type?: string;
option?: unknown;
data?: unknown;
};
// geometry
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
// state
status?: { lock?: boolean; hide?: boolean };
// widget-specific config
option?: unknown;
}
export interface GoViewEditCanvasConfigLike {
projectName?: string;
width?: number;
height?: number;
background?: string;
}
export interface GoViewStorageLike {
editCanvasConfig?: GoViewEditCanvasConfigLike;
componentList?: GoViewComponentLike[];
}
export interface GoViewProjectLike {
// very loose input shape; goView has different versions/branches.
width?: number;
height?: number;
canvas?: { width?: number; height?: number };
componentList?: GoViewComponentLike[];
// persisted store shape (some variants)
editCanvasConfig?: GoViewEditCanvasConfigLike;
}
export function convertGoViewProjectToScreen(input: GoViewProjectLike): Screen {
const width = input.canvas?.width ?? input.width ?? 1920;
const height = input.canvas?.height ?? input.height ?? 1080;
function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike {
// Prefer the nested component shape but keep outer fields as fallback.
// This handles exports like: { id, attr, component: { chartConfig, option } }
const inner = c.component;
if (!inner) return c;
return {
// Prefer outer for geometry/id, but prefer inner for identity/option when present.
...c,
...inner,
// ensure the nested component doesn't get lost
component: inner.component,
};
}
function keyOf(cIn: GoViewComponentLike): string {
const c = unwrapComponent(cIn);
return (
c.chartConfig?.key ??
c.chartConfig?.chartKey ??
c.chartConfig?.type ??
c.componentKey ??
c.key ??
''
).toLowerCase();
}
function isTextCommon(c: GoViewComponentLike): boolean {
const k = keyOf(c);
if (k === 'textcommon') return true;
return k.includes('text');
}
function isImage(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Image", "image", sometimes with suffixes.
return k === 'image' || k.includes('image') || k.includes('picture');
}
function isIframe(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Iframe", "IframeCommon", etc.
if (k === 'iframe' || k.includes('iframe')) return true;
// Other names seen in low-code editors for embedded web content.
return k.includes('embed') || k.includes('web') || k.includes('webview') || k.includes('html');
}
function isVideo(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Video", "VideoCommon", etc.
if (k === 'video' || k.includes('video')) return true;
// Other names seen in the wild.
return k.includes('mp4') || k.includes('media') || k.includes('player') || k.includes('stream');
}
function looksLikeIframeOption(option: unknown): boolean {
if (!option || typeof option !== 'object') return false;
const o = option as Record<string, unknown>;
// Prefer explicit iframe-ish keys.
if ('iframeUrl' in o || 'iframeSrc' in o || 'embedUrl' in o) return true;
const url = pickUrlLike(option);
if (!url) return false;
// If it isn't an obvious media URL, it's often an iframe/embed.
// (We deliberately keep this conservative; image/video are handled earlier.)
return /^https?:\/\//i.test(url) && !/\.(mp4|m3u8|flv)(\?|#|$)/i.test(url);
}
function looksLikeVideoOption(option: unknown): boolean {
if (!option || typeof option !== 'object') return false;
const o = option as Record<string, unknown>;
// Prefer explicit video-ish keys.
if ('videoUrl' in o || 'videoSrc' in o || 'mp4' in o || 'm3u8' in o || 'flv' in o || 'hls' in o || 'rtsp' in o) return true;
const url = pickUrlLike(option);
if (!url) return false;
// Common direct URL patterns.
if (/\.(mp4|m3u8|flv)(\?|#|$)/i.test(url)) return true;
if (/\bm3u8\b/i.test(url)) return true;
return false;
}
function pick<T>(...values: Array<T | undefined | null>): T | undefined {
for (const v of values) {
if (v !== undefined && v !== null) return v as T;
}
return undefined;
}
function optionOf(cIn: GoViewComponentLike): unknown {
const c = unwrapComponent(cIn);
const chartData = c.chartConfig?.data as Record<string, unknown> | undefined;
return (
c.option ??
c.chartConfig?.option ??
chartData?.option ??
chartData?.options ??
chartData?.config ??
chartData ??
{}
);
}
function toNumber(v: unknown, fallback: number): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return fallback;
}
export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen {
// goView exports vary a lot; attempt a few common nesting shapes.
const root = input as unknown as Record<string, unknown>;
const data = (root.data as Record<string, unknown> | undefined) ?? undefined;
const state = (root.state as Record<string, unknown> | undefined) ?? undefined;
const project = (root.project as Record<string, unknown> | undefined) ?? undefined;
const editCanvasConfig = pick<GoViewEditCanvasConfigLike>(
(input as GoViewStorageLike).editCanvasConfig,
data?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
state?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
project?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
);
const width =
editCanvasConfig?.width ??
(input as GoViewProjectLike).canvas?.width ??
(input as GoViewProjectLike).width ??
(data?.width as number | undefined) ??
1920;
const height =
editCanvasConfig?.height ??
(input as GoViewProjectLike).canvas?.height ??
(input as GoViewProjectLike).height ??
(data?.height as number | undefined) ??
1080;
const name = editCanvasConfig?.projectName ?? 'Imported Project';
const background = editCanvasConfig?.background;
const screen = createEmptyScreen({
version: ASTRALVIEW_SCHEMA_VERSION,
width,
height,
name: 'Imported from goView',
name,
background: background ? { color: background } : undefined,
nodes: [],
});
const nodes: TextWidgetNode[] = [];
for (const c of input.componentList ?? []) {
// Only first: TextCommon-like
const key = c.key ?? '';
if (!/text/i.test(key)) continue;
const componentList =
(input as GoViewStorageLike).componentList ??
(input as GoViewProjectLike).componentList ??
(data?.componentList as GoViewComponentLike[] | undefined) ??
(state?.componentList as GoViewComponentLike[] | undefined) ??
(project?.componentList as GoViewComponentLike[] | undefined) ??
[];
const rect = c.attr ? { x: c.attr.x, y: c.attr.y, w: c.attr.w, h: c.attr.h } : { x: 0, y: 0, w: 320, h: 60 };
const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) as GoViewTextOption);
const nodes: Array<TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode> = [];
for (const raw of componentList) {
const c = unwrapComponent(raw);
const rect = c.attr
? {
x: toNumber((c.attr as unknown as Record<string, unknown>).x, 0),
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 0),
w: toNumber((c.attr as unknown as Record<string, unknown>).w, 320),
h: toNumber((c.attr as unknown as Record<string, unknown>).h, 60),
}
: { x: 0, y: 0, w: 320, h: 60 };
if (isTextCommon(c)) {
const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption);
nodes.push({
id: c.id ?? `goview_text_${Math.random().toString(16).slice(2)}`,
id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`,
type: 'text',
rect,
zIndex: c.attr?.zIndex,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (isImage(c)) {
const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption);
nodes.push({
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
type: 'image',
rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
const option = optionOf(c);
if (isIframe(c) || looksLikeIframeOption(option)) {
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
nodes.push({
id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
type: 'iframe',
rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (isVideo(c) || looksLikeVideoOption(option)) {
const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption);
nodes.push({
id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`,
type: 'video',
rect,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
}
return {

View File

@ -34,7 +34,7 @@ export interface TextWidgetNode extends WidgetNodeBase {
color?: string;
fontWeight?: number | string;
// goView parity (TextCommon)
// legacy parity (TextCommon)
paddingX?: number;
paddingY?: number;
letterSpacing?: number;
@ -51,7 +51,35 @@ export interface TextWidgetNode extends WidgetNodeBase {
};
}
export type WidgetNode = TextWidgetNode;
export interface ImageWidgetNode extends WidgetNodeBase {
type: 'image';
props: {
src: string;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
borderRadius?: number;
};
}
export interface IframeWidgetNode extends WidgetNodeBase {
type: 'iframe';
props: {
src: string;
borderRadius?: number;
};
}
export interface VideoWidgetNode extends WidgetNodeBase {
type: 'video';
props: {
src: string;
loop?: boolean;
muted?: boolean;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
borderRadius?: number;
};
}
export type WidgetNode = TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode;
export interface Screen {
version: SchemaVersion;

View File

@ -0,0 +1,35 @@
import type { IframeWidgetNode } from '../schema';
import { pickUrlLike } from './urlLike';
/**
* goView iframe option shape varies across versions.
* Keep it permissive and normalize the common fields.
*/
export interface GoViewIframeOption {
dataset?: unknown;
src?: unknown;
url?: unknown;
borderRadius?: number;
}
/**
* Back-compat alias (older code used "LegacyIframeOption").
*/
export type LegacyIframeOption = GoViewIframeOption;
function pickSrc(option: GoViewIframeOption): string {
// Prefer the whole option first (covers iframeUrl/embedUrl variants directly on the object).
return pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
}
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
return {
src: pickSrc(option),
borderRadius: option.borderRadius,
};
}
/**
* Back-compat export.
*/
export const convertLegacyIframeOptionToNodeProps = convertGoViewIframeOptionToNodeProps;

View File

@ -0,0 +1,56 @@
import type { ImageWidgetNode } from '../schema';
import { pickUrlLike } from './urlLike';
/**
* goView Image option shape varies across versions. We keep this intentionally
* permissive and normalize the common fields.
*/
export interface GoViewImageOption {
/**
* Common in existing legacy widgets (same as iframe/video).
*/
dataset?: unknown;
/**
* Other variants seen in the wild.
*/
src?: unknown;
url?: unknown;
/**
* Styling.
*/
fit?: unknown;
objectFit?: unknown;
borderRadius?: number;
}
function pickSrc(option: GoViewImageOption): string {
return pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
}
function asString(v: unknown): string {
if (typeof v === 'string') return v;
if (!v) return '';
return '';
}
function pickFit(option: GoViewImageOption): ImageWidgetNode['props']['fit'] | undefined {
const raw = asString(option.fit) || asString(option.objectFit);
if (!raw) return undefined;
const v = raw.toLowerCase();
if (v === 'contain' || v === 'cover' || v === 'fill' || v === 'none' || v === 'scale-down') {
return v as ImageWidgetNode['props']['fit'];
}
return undefined;
}
export function convertGoViewImageOptionToNodeProps(option: GoViewImageOption): ImageWidgetNode['props'] {
return {
src: pickSrc(option),
fit: pickFit(option),
borderRadius: option.borderRadius,
};
}

View File

@ -0,0 +1,75 @@
/**
* Small helper for permissive imports (goView / low-code exports).
*
* Many widgets store their URL-ish source in slightly different shapes:
* - string
* - { value: string }
* - { url: string }
* - { src: string }
* - { dataset: string | { value/url/src } }
* - nested objects under `data` / `config` / `options`
*/
export function pickUrlLike(input: unknown, maxDepth = 3): string {
return pickUrlLikeInner(input, maxDepth);
}
function pickUrlLikeInner(input: unknown, depth: number): string {
if (typeof input === 'string') return input;
if (!input) return '';
if (Array.isArray(input)) {
for (const item of input) {
const v = pickUrlLikeInner(item, depth - 1);
if (v) return v;
}
return '';
}
if (typeof input !== 'object') return '';
const obj = input as Record<string, unknown>;
// Common direct keys.
// Keep this list generous; imports come from many low-code editors.
for (const key of [
'value',
'url',
'src',
'href',
'link',
'path',
'source',
'address',
// iframe-ish
'iframeUrl',
'iframeSrc',
'embedUrl',
// video-ish
'videoUrl',
'videoSrc',
'mp4',
'm3u8',
'flv',
// other streaming-ish keys
'hls',
'hlsUrl',
'stream',
'streamUrl',
'rtsp',
'rtspUrl',
]) {
const v = obj[key];
if (typeof v === 'string' && v) return v;
}
if (depth <= 0) return '';
// Common nesting keys.
for (const key of ['dataset', 'data', 'config', 'option', 'options', 'props', 'source', 'media']) {
const v = obj[key];
const nested = pickUrlLikeInner(v, depth - 1);
if (nested) return nested;
}
return '';
}

View File

@ -0,0 +1,64 @@
import type { VideoWidgetNode } from '../schema';
import { pickUrlLike } from './urlLike';
/**
* goView video option shape varies across versions.
* Keep it permissive and normalize the common fields.
*/
export interface GoViewVideoOption {
dataset?: unknown;
src?: unknown;
url?: unknown;
loop?: boolean;
muted?: boolean;
fit?: unknown;
objectFit?: unknown;
borderRadius?: number;
}
/**
* Back-compat alias (older code used "LegacyVideoOption").
*/
export type LegacyVideoOption = GoViewVideoOption;
function pickSrc(option: GoViewVideoOption): string {
// Prefer the whole option first (covers videoUrl/mp4/m3u8/flv/etc directly on the object).
return pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
}
function asString(v: unknown): string {
if (typeof v === 'string') return v;
if (!v) return '';
return '';
}
function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | undefined {
const raw = asString(option.fit) || asString(option.objectFit);
if (!raw) return undefined;
// normalize common variants
const v = raw.toLowerCase();
if (v === 'contain' || v === 'cover' || v === 'fill' || v === 'none' || v === 'scale-down') {
return v as VideoWidgetNode['props']['fit'];
}
return undefined;
}
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
return {
src: pickSrc(option),
loop: option.loop,
muted: option.muted,
fit: pickFit(option),
borderRadius: option.borderRadius,
};
}
/**
* Back-compat export.
*/
export const convertLegacyVideoOptionToNodeProps = convertGoViewVideoOptionToNodeProps;

View File

@ -14,6 +14,9 @@ export type {
Screen,
WidgetNode,
TextWidgetNode,
ImageWidgetNode,
IframeWidgetNode,
VideoWidgetNode,
} from './core/schema';
export { migrateScreen } from './core/migrate';
@ -21,6 +24,15 @@ export { migrateScreen } from './core/migrate';
export type { GoViewTextOption } from './core/widgets/text';
export { convertGoViewTextOptionToNodeProps } from './core/widgets/text';
export type { GoViewImageOption } from './core/widgets/image';
export { convertGoViewImageOptionToNodeProps } from './core/widgets/image';
export type { GoViewIframeOption, LegacyIframeOption } from './core/widgets/iframe';
export { convertGoViewIframeOptionToNodeProps, convertLegacyIframeOptionToNodeProps } from './core/widgets/iframe';
export type { GoViewVideoOption, LegacyVideoOption } from './core/widgets/video';
export { convertGoViewVideoOptionToNodeProps, convertLegacyVideoOptionToNodeProps } from './core/widgets/video';
export type { GoViewProjectLike, GoViewComponentLike } from './core/goview/convert';
export { convertGoViewProjectToScreen } from './core/goview/convert';
export { convertGoViewJSONToScreen } from './core/goview';

File diff suppressed because one or more lines are too long