feat: improve goView iframe/video import + editor parity

This commit is contained in:
clawdbot 2026-01-28 20:44:51 +08:00
parent 947946edc6
commit acf61d9bee
8 changed files with 572 additions and 461 deletions

View File

@ -115,7 +115,7 @@ export function ContextMenu(props: {
const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds;
const hasSelection = selectionIds.length > 0;
const canModifySelection = selectionInSync && hasSelection && props.selectionHasUnlocked;
const canModifySelection = hasSelection && props.selectionHasUnlocked;
const hasTarget = ctx.kind === 'node';
const targetId = hasTarget ? ctx.targetId : undefined;
const targetInSelection = !!targetId && selectionIds.includes(targetId);
@ -198,7 +198,7 @@ export function ContextMenu(props: {
? 'Toggle Lock'
: 'Lock'
}
disabled={!selectionInSync || !hasSelection || !props.onToggleLockSelected}
disabled={!hasSelection || !props.onToggleLockSelected}
onClick={() => {
props.onToggleLockSelected?.();
onClose();
@ -212,7 +212,7 @@ export function ContextMenu(props: {
? 'Toggle Visibility'
: 'Hide'
}
disabled={!selectionInSync || !hasSelection || !props.onToggleHideSelected}
disabled={!hasSelection || !props.onToggleHideSelected}
onClick={() => {
props.onToggleHideSelected?.();
onClose();
@ -223,7 +223,7 @@ export function ContextMenu(props: {
<MenuItem
label="Bring To Front"
disabled={!selectionInSync || !hasSelection || !props.onBringToFrontSelected}
disabled={!hasSelection || !props.onBringToFrontSelected}
onClick={() => {
props.onBringToFrontSelected?.();
onClose();
@ -231,7 +231,7 @@ export function ContextMenu(props: {
/>
<MenuItem
label="Send To Back"
disabled={!selectionInSync || !hasSelection || !props.onSendToBackSelected}
disabled={!hasSelection || !props.onSendToBackSelected}
onClick={() => {
props.onSendToBackSelected?.();
onClose();

View File

@ -460,10 +460,18 @@ export function EditorApp() {
<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 })}
onUpdateTextProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'text', id, props })
}
onUpdateImageProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'image', id, props })
}
onUpdateIframeProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'iframe', id, props })
}
onUpdateVideoProps={(id, props) =>
dispatch({ type: 'updateWidgetProps', widgetType: 'video', id, props })
}
/>
</div>
</>

View File

@ -1,9 +1,9 @@
import { Input, InputNumber, Select, Space, Typography } from 'antd';
import type { WidgetNode, TextWidgetNode } from '@astralview/sdk';
import { assertNever, type WidgetNode, type TextWidgetNode, type WidgetNodeByType } from '@astralview/sdk';
type ImageWidgetNode = Extract<WidgetNode, { type: 'image' }>;
type IframeWidgetNode = Extract<WidgetNode, { type: 'iframe' }>;
type VideoWidgetNode = Extract<WidgetNode, { type: 'video' }>;
type ImageWidgetNode = WidgetNodeByType['image'];
type IframeWidgetNode = WidgetNodeByType['iframe'];
type VideoWidgetNode = WidgetNodeByType['video'];
export function Inspector(props: {
selected?: WidgetNode;
@ -18,7 +18,8 @@ export function Inspector(props: {
return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>;
}
if (node.type === 'image') {
switch (node.type) {
case 'image':
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
@ -56,9 +57,8 @@ export function Inspector(props: {
/>
</div>
);
}
if (node.type === 'iframe') {
case 'iframe':
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
@ -82,9 +82,8 @@ export function Inspector(props: {
/>
</div>
);
}
if (node.type === 'video') {
case 'video':
return (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
@ -164,14 +163,8 @@ export function Inspector(props: {
/>
</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.</Typography.Paragraph>;
}
case 'text': {
const fontWeight = node.props.fontWeight ?? 400;
return (
@ -371,3 +364,8 @@ export function Inspector(props: {
</div>
);
}
default:
return assertNever(node);
}
}

View File

@ -4,12 +4,12 @@ import {
createEmptyScreen,
migrateScreen,
type Rect,
type ImageWidgetNode,
type IframeWidgetNode,
type VideoWidgetNode,
type Screen,
type TextWidgetNode,
type WidgetNode,
type WidgetKind,
type WidgetNodeByType,
type WidgetPropsByType,
} from '@astralview/sdk';
import { rectContains, rectFromPoints } from './geometry';
import { didRectsChange } from './history';
@ -53,10 +53,13 @@ export type EditorAction =
| { 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']> };
| UpdateWidgetPropsAction;
type UpdateWidgetPropsAction =
| { type: 'updateWidgetProps'; widgetType: 'text'; id: string; props: Partial<WidgetPropsByType['text']> }
| { type: 'updateWidgetProps'; widgetType: 'image'; id: string; props: Partial<WidgetPropsByType['image']> }
| { type: 'updateWidgetProps'; widgetType: 'iframe'; id: string; props: Partial<WidgetPropsByType['iframe']> }
| { type: 'updateWidgetProps'; widgetType: 'video'; id: string; props: Partial<WidgetPropsByType['video']> };
interface DragSession {
kind: 'move' | 'resize';
@ -86,6 +89,39 @@ type EditorRuntimeState = EditorState & {
__drag?: DragSession;
};
function isWidgetType<K extends WidgetKind>(
node: WidgetNode,
widgetType: K,
): node is WidgetNodeByType[K] {
return node.type === widgetType;
}
function updateWidgetProps<K extends WidgetKind>(
state: EditorRuntimeState,
action: Extract<UpdateWidgetPropsAction, { widgetType: K }>,
): EditorRuntimeState {
const target = state.doc.screen.nodes.find(
(n): n is WidgetNodeByType[K] => n.id === action.id && n.type === action.widgetType,
);
if (!target) return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || !isWidgetType(n, action.widgetType)) return n;
return {
...n,
props: { ...n.props, ...action.props },
};
}),
},
},
};
}
function historyPush(state: EditorRuntimeState): EditorRuntimeState {
return {
...state,
@ -217,72 +253,19 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
};
}
case 'updateTextProps': {
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
if (!node || node.type !== 'text') return state;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => {
if (n.id !== action.id || n.type !== 'text') return n;
return { ...n, props: { ...n.props, ...action.props } };
}),
},
},
};
case 'updateWidgetProps': {
switch (action.widgetType) {
case 'text':
return updateWidgetProps(state, action);
case 'image':
return updateWidgetProps(state, action);
case 'iframe':
return updateWidgetProps(state, action);
case 'video':
return updateWidgetProps(state, action);
default:
return assertNever(action.widgetType);
}
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': {
@ -823,7 +806,7 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
}
case 'importJSON': {
const parsed = JSON.parse(action.json) as unknown;
const parsed: unknown = JSON.parse(action.json);
try {
const screen = migrateScreen(parsed);
return {

View File

@ -90,6 +90,19 @@ export interface VideoWidgetNode extends WidgetNodeBase {
export type WidgetNode = TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode;
export type WidgetKind = WidgetNode['type'];
export type WidgetNodeByType = {
text: TextWidgetNode;
image: ImageWidgetNode;
iframe: IframeWidgetNode;
video: VideoWidgetNode;
};
export type WidgetPropsByType = {
[K in WidgetKind]: WidgetNodeByType[K]['props'];
};
export interface Screen {
version: SchemaVersion;
id: string;

View File

@ -19,6 +19,15 @@ export interface GoViewIframeOption {
webUrl?: unknown;
webpageUrl?: unknown;
// HTML/embed variants
html?: unknown;
htmlString?: unknown;
embedCode?: unknown;
template?: unknown;
content?: unknown;
srcdoc?: unknown;
srcDoc?: unknown;
// list-ish shapes (some low-code editors model embeds as a list even for a single item)
sources?: unknown;
sourceList?: unknown;
@ -54,6 +63,12 @@ function looksLikeHtml(input: string): boolean {
return trimmed.startsWith('<') && trimmed.includes('>');
}
function extractHtmlAttribute(html: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+))`, 'i');
const match = re.exec(html);
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function extractSrcFromEmbedHtml(html: string): string {
// Many low-code editors store iframe widgets as an embed code string.
// Prefer extracting the actual src to keep the resulting screen portable.
@ -69,6 +84,21 @@ function extractSrcFromEmbedHtml(html: string): string {
return typeof src === 'string' ? src.trim() : '';
}
function extractIframeAttrsFromHtml(html: string): {
src?: string;
allow?: string;
sandbox?: string;
title?: string;
} {
if (!looksLikeHtml(html)) return {};
return {
src: extractSrcFromEmbedHtml(html) || undefined,
allow: extractHtmlAttribute(html, 'allow'),
sandbox: extractHtmlAttribute(html, 'sandbox'),
title: extractHtmlAttribute(html, 'title') ?? extractHtmlAttribute(html, 'name'),
};
}
function toMaybeNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
@ -134,6 +164,31 @@ function pickFirstUrlFromList(input: unknown): string {
return pickUrlLike(input, 2);
}
function pickHtmlString(option: GoViewIframeOption): string | undefined {
if (typeof option === 'string' && looksLikeHtml(option)) return option;
return pickFromNested(
option,
(obj) => {
const v =
obj.srcdoc ??
obj.srcDoc ??
obj.html ??
obj.htmlContent ??
obj.htmlString ??
obj.embedHtml ??
obj.iframeHtml ??
obj.embedCode ??
obj.iframeCode ??
obj.code ??
obj.content ??
obj.template;
return typeof v === 'string' ? v : undefined;
},
3,
);
}
function pickSrc(option: GoViewIframeOption): string {
// 1) Prefer explicit iframe-ish URL fields.
const url =
@ -160,26 +215,7 @@ function pickSrc(option: GoViewIframeOption): string {
}
// 2) Some exports store raw HTML instead of a URL.
const html = pickFromNested(
option,
(obj) => {
const v =
obj.srcdoc ??
obj.srcDoc ??
obj.html ??
obj.htmlContent ??
obj.htmlString ??
obj.embedHtml ??
obj.iframeHtml ??
obj.embedCode ??
obj.iframeCode ??
obj.code ??
obj.content ??
obj.template;
return typeof v === 'string' ? v : undefined;
},
3,
);
const html = pickHtmlString(option);
if (html) {
const extracted = extractSrcFromEmbedHtml(html);
return extracted || toDataHtmlUrl(html);
@ -251,11 +287,14 @@ function pickBorderRadius(option: GoViewIframeOption): number | undefined {
}
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
const html = pickHtmlString(option);
const htmlAttrs = html ? extractIframeAttrsFromHtml(html) : undefined;
return {
src: pickSrc(option),
allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']),
sandbox: pickStringLike(option, ['sandbox', 'sandboxList']),
title: pickStringLike(option, ['title', 'name', 'label']),
allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']) ?? htmlAttrs?.allow,
sandbox: pickStringLike(option, ['sandbox', 'sandboxList']) ?? htmlAttrs?.sandbox,
title: pickStringLike(option, ['title', 'name', 'label']) ?? htmlAttrs?.title,
fit: pickFit(option),
aspectRatio: pickAspectRatio(option),
borderRadius: pickBorderRadius(option),

View File

@ -38,6 +38,15 @@ export interface GoViewVideoOption {
thumbnail?: unknown;
thumbnailUrl?: unknown;
// HTML/embed variants
html?: unknown;
htmlString?: unknown;
embedCode?: unknown;
template?: unknown;
content?: unknown;
srcdoc?: unknown;
srcDoc?: unknown;
fit?: unknown;
objectFit?: unknown;
@ -63,6 +72,24 @@ function looksLikeHtml(input: string): boolean {
return trimmed.startsWith('<') && trimmed.includes('>');
}
function extractHtmlAttribute(html: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+))`, 'i');
const match = re.exec(html);
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function extractHtmlBooleanAttribute(html: string, name: string): boolean | undefined {
const re = new RegExp(`\\b${name}\\b(\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+)))?`, 'i');
const match = re.exec(html);
if (!match) return undefined;
const value = match[2] ?? match[3] ?? match[4];
if (!value) return true;
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
return true;
}
function extractSrcFromVideoHtml(html: string): string {
// Some low-code exports store a whole <video> or <source> tag string.
// Examples:
@ -81,6 +108,25 @@ function extractSrcFromVideoHtml(html: string): string {
return typeof videoSrc === 'string' ? videoSrc.trim() : '';
}
function extractVideoAttrsFromHtml(html: string): {
src?: string;
autoplay?: boolean;
controls?: boolean;
muted?: boolean;
loop?: boolean;
poster?: string;
} {
if (!looksLikeHtml(html)) return {};
return {
src: extractSrcFromVideoHtml(html) || undefined,
autoplay: extractHtmlBooleanAttribute(html, 'autoplay'),
controls: extractHtmlBooleanAttribute(html, 'controls'),
muted: extractHtmlBooleanAttribute(html, 'muted'),
loop: extractHtmlBooleanAttribute(html, 'loop'),
poster: extractHtmlAttribute(html, 'poster'),
};
}
function toMaybeBoolean(v: unknown): boolean | undefined {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v !== 0;
@ -363,13 +409,34 @@ function pickAspectRatio(option: GoViewVideoOption): number | undefined {
}
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
const html =
typeof option === 'string' && looksLikeHtml(option)
? option
: pickFromNested(
option,
(obj) => {
const v =
obj.html ??
obj.htmlString ??
obj.code ??
obj.embedCode ??
obj.template ??
obj.content ??
obj.srcdoc ??
obj.srcDoc;
return typeof v === 'string' ? v : undefined;
},
3,
);
const htmlAttrs = html ? extractVideoAttrsFromHtml(html) : undefined;
return {
src: pickSrc(option),
autoplay: pickAutoplay(option),
controls: pickControls(option),
loop: pickBooleanLike(option, ['loop', 'isLoop']),
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
poster: pickPoster(option),
autoplay: pickAutoplay(option) ?? htmlAttrs?.autoplay,
controls: pickControls(option) ?? htmlAttrs?.controls,
loop: pickBooleanLike(option, ['loop', 'isLoop']) ?? htmlAttrs?.loop,
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']) ?? htmlAttrs?.muted,
poster: pickPoster(option) ?? htmlAttrs?.poster,
fit: pickFit(option),
aspectRatio: pickAspectRatio(option),
borderRadius: pickBorderRadius(option),

View File

@ -13,6 +13,9 @@ export type {
Transform,
Screen,
WidgetNode,
WidgetKind,
WidgetNodeByType,
WidgetPropsByType,
TextWidgetNode,
ImageWidgetNode,
IframeWidgetNode,