feat: add iframe/video widgets and importer support

This commit is contained in:
ErSan 2026-01-27 19:27:23 +08:00
parent 576ad74370
commit 98b47749e9
15 changed files with 416 additions and 34 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

@ -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

@ -512,6 +512,57 @@ function NodeView(props: {
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>

View File

@ -80,6 +80,8 @@ export function EditorApp() {
selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])}
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
onUpdateImageProps={(id, props) => dispatch({ type: 'updateImageProps', id, props })}
onUpdateIframeProps={(id, props) => dispatch({ type: 'updateIframeProps', id, props })}
onUpdateVideoProps={(id, props) => dispatch({ type: 'updateVideoProps', id, props })}
/>
<Divider />

View File

@ -2,11 +2,15 @@ import { Input, InputNumber, Select, Space, Typography } from 'antd';
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;
@ -54,8 +58,101 @@ export function Inspector(props: {
);
}
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.
// For now, we only support text/image/iframe/video.
if (node.type !== 'text') {
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type.</Typography.Paragraph>;
}

View File

@ -4,6 +4,8 @@ import {
migrateScreen,
type Rect,
type ImageWidgetNode,
type IframeWidgetNode,
type VideoWidgetNode,
type Screen,
type TextWidgetNode,
type WidgetNode,
@ -41,7 +43,9 @@ export type EditorAction =
| { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['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';
@ -217,6 +221,40 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
};
}
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);

View File

@ -1,12 +1,23 @@
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 { convertLegacyIframeOptionToNodeProps, type LegacyIframeOption } from '../widgets/iframe';
import { convertLegacyVideoOptionToNodeProps, type LegacyVideoOption } from '../widgets/video';
import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text';
export interface GoViewComponentLike {
id?: string;
// goView component identity
// component identity
key?: string; // e.g. "TextCommon" (sometimes)
chartConfig?: { key?: string }; // goView standard location
chartConfig?: { key?: string };
// geometry
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
@ -14,7 +25,7 @@ export interface GoViewComponentLike {
// state
status?: { lock?: boolean; hide?: boolean };
// goView uses "option" for widget-specific config
// widget-specific config
option?: unknown;
}
@ -31,23 +42,43 @@ export interface GoViewStorageLike {
}
export interface GoViewProjectLike {
// very loose input shape; goView has different versions/branches.
width?: number;
height?: number;
canvas?: { width?: number; height?: number };
componentList?: GoViewComponentLike[];
// goView persisted store shape
// persisted store shape (some variants)
editCanvasConfig?: GoViewEditCanvasConfigLike;
}
function keyOf(c: GoViewComponentLike): string {
return (c.chartConfig?.key ?? c.key ?? '').toLowerCase();
}
function isTextCommon(c: GoViewComponentLike): boolean {
const k = (c.chartConfig?.key ?? c.key ?? '').toLowerCase();
const k = keyOf(c);
if (k === 'textcommon') return true;
// fallback heuristic
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.
return k === 'iframe' || k.includes('iframe');
}
function isVideo(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Video", "VideoCommon", etc.
return k === 'video' || k.includes('video');
}
export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen {
const editCanvasConfig = (input as GoViewStorageLike).editCanvasConfig;
@ -56,7 +87,7 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
const height =
editCanvasConfig?.height ?? (input as GoViewProjectLike).canvas?.height ?? (input as GoViewProjectLike).height ?? 1080;
const name = editCanvasConfig?.projectName ?? 'Imported from goView';
const name = editCanvasConfig?.projectName ?? 'Imported Project';
const background = editCanvasConfig?.background;
const screen = createEmptyScreen({
@ -70,16 +101,17 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
const componentList = (input as GoViewStorageLike).componentList ?? (input as GoViewProjectLike).componentList ?? [];
const nodes: TextWidgetNode[] = [];
const nodes: Array<TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode> = [];
for (const c of componentList) {
// Only first: TextCommon-like
if (!isTextCommon(c)) continue;
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 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 };
if (isTextCommon(c)) {
const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) 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,
@ -87,6 +119,50 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (isImage(c)) {
const props = convertGoViewImageOptionToNodeProps((c.option ?? {}) as GoViewImageOption);
nodes.push({
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
type: 'image',
rect,
zIndex: c.attr?.zIndex,
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (isIframe(c)) {
const props = convertLegacyIframeOptionToNodeProps((c.option ?? {}) as LegacyIframeOption);
nodes.push({
id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
type: 'iframe',
rect,
zIndex: c.attr?.zIndex,
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (isVideo(c)) {
const props = convertLegacyVideoOptionToNodeProps((c.option ?? {}) as LegacyVideoOption);
nodes.push({
id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`,
type: 'video',
rect,
zIndex: c.attr?.zIndex,
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,13 @@
import type { IframeWidgetNode } from '../schema';
export interface LegacyIframeOption {
dataset: string;
borderRadius?: number;
}
export function convertLegacyIframeOptionToNodeProps(option: LegacyIframeOption): IframeWidgetNode['props'] {
return {
src: option.dataset ?? '',
borderRadius: option.borderRadius,
};
}

View File

@ -0,0 +1,41 @@
import type { ImageWidgetNode } from '../schema';
/**
* 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?: string;
/**
* Other variants seen in the wild.
*/
src?: string;
url?: string;
/**
* Styling.
*/
fit?: ImageWidgetNode['props']['fit'];
objectFit?: ImageWidgetNode['props']['fit'];
borderRadius?: number;
}
function pickSrc(option: GoViewImageOption): string {
return option.dataset ?? option.src ?? option.url ?? '';
}
function pickFit(option: GoViewImageOption): ImageWidgetNode['props']['fit'] | undefined {
return option.fit ?? option.objectFit;
}
export function convertGoViewImageOptionToNodeProps(option: GoViewImageOption): ImageWidgetNode['props'] {
return {
src: pickSrc(option),
fit: pickFit(option),
borderRadius: option.borderRadius,
};
}

View File

@ -0,0 +1,19 @@
import type { VideoWidgetNode } from '../schema';
export interface LegacyVideoOption {
dataset: string;
loop?: boolean;
muted?: boolean;
fit?: VideoWidgetNode['props']['fit'];
borderRadius?: number;
}
export function convertLegacyVideoOptionToNodeProps(option: LegacyVideoOption): VideoWidgetNode['props'] {
return {
src: option.dataset ?? '',
loop: option.loop,
muted: option.muted,
fit: option.fit,
borderRadius: option.borderRadius,
};
}

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 { LegacyIframeOption } from './core/widgets/iframe';
export { convertLegacyIframeOptionToNodeProps } from './core/widgets/iframe';
export type { LegacyVideoOption } from './core/widgets/video';
export { 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