Compare commits
14 Commits
ca2636f489
...
e0d39b1a8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d39b1a8c | ||
| b859d1e3b6 | |||
| f96f77ad9c | |||
| 5d6aae77ef | |||
| a5ec482bd5 | |||
| 3e353c6322 | |||
| 5b708faf7b | |||
| de4243ca10 | |||
| b6f69b8d6a | |||
| 299326e060 | |||
| 98b47749e9 | |||
| 576ad74370 | |||
| 003f23ee78 | |||
| f416a393d8 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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**.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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' },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
35
packages/sdk/src/core/widgets/iframe.ts
Normal file
35
packages/sdk/src/core/widgets/iframe.ts
Normal 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;
|
||||
56
packages/sdk/src/core/widgets/image.ts
Normal file
56
packages/sdk/src/core/widgets/image.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
75
packages/sdk/src/core/widgets/urlLike.ts
Normal file
75
packages/sdk/src/core/widgets/urlLike.ts
Normal 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 '';
|
||||
}
|
||||
64
packages/sdk/src/core/widgets/video.ts
Normal file
64
packages/sdk/src/core/widgets/video.ts
Normal 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;
|
||||
@ -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
Loading…
Reference in New Issue
Block a user