refactor: improve goView video/iframe import + editor parity

This commit is contained in:
clawdbot 2026-01-28 18:04:07 +08:00
parent e111184acc
commit 54f3cab08f
7 changed files with 252 additions and 9 deletions

View File

@ -502,15 +502,18 @@ function NodeView(props: {
> >
<iframe <iframe
src={node.props.src} src={node.props.src}
allow={node.props.allow}
sandbox={node.props.sandbox}
width={rect.w} width={rect.w}
height={rect.h} height={rect.h}
style={{ style={{
border: 0, border: 0,
display: 'block', display: 'block',
objectFit: node.props.fit,
// Editor parity: iframes steal pointer events; disable so selection/context menu works. // Editor parity: iframes steal pointer events; disable so selection/context menu works.
pointerEvents: 'none', pointerEvents: 'none',
}} }}
title={node.id} title={node.props.title ?? node.id}
/> />
</div> </div>
); );
@ -530,9 +533,11 @@ function NodeView(props: {
width={rect.w} width={rect.w}
height={rect.h} height={rect.h}
autoPlay={node.props.autoplay ?? false} autoPlay={node.props.autoplay ?? false}
controls={node.props.controls ?? false}
playsInline playsInline
loop={node.props.loop ?? false} loop={node.props.loop ?? false}
muted={node.props.muted ?? false} muted={node.props.muted ?? false}
poster={node.props.poster}
style={{ style={{
display: 'block', display: 'block',
width: '100%', width: '100%',

View File

@ -93,6 +93,19 @@ export function EditorApp() {
const ctxMenuSyncedRef = useRef(false); const ctxMenuSyncedRef = useRef(false);
const dispatchWithMenuSelection = useCallback(
(action: Parameters<typeof dispatch>[0]) => {
if (ctxMenu) {
const currentKey = selectionKeyOf(state.selection.ids);
if (currentKey !== ctxMenu.selectionKey) {
dispatch({ type: 'setSelection', ids: ctxMenu.selectionIds });
}
}
dispatch(action);
},
[ctxMenu, dispatch, selectionKeyOf, state.selection.ids],
);
useEffect(() => { useEffect(() => {
// Reset the sync gate whenever a new context menu opens/closes. // Reset the sync gate whenever a new context menu opens/closes.
ctxMenuSyncedRef.current = false; ctxMenuSyncedRef.current = false;
@ -509,7 +522,7 @@ export function EditorApp() {
: additive : additive
? [...state.selection.ids, node.id] ? [...state.selection.ids, node.id]
: [node.id]; : [node.id];
const selectionKey = nextSelectionIds.join('|'); const selectionKey = selectionKeyOf(nextSelectionIds);
if (!state.selection.ids.includes(node.id)) { if (!state.selection.ids.includes(node.id)) {
if (additive) { if (additive) {
dispatch({ type: 'toggleSelect', id: node.id }); dispatch({ type: 'toggleSelect', id: node.id });
@ -566,12 +579,12 @@ export function EditorApp() {
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })} onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })} onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onSelectAll={() => dispatch({ type: 'selectAll' })} onSelectAll={() => dispatch({ type: 'selectAll' })}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })} onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })} onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })} onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })} onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })} onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })} onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
/> />
</Layout> </Layout>
); );

View File

@ -28,6 +28,7 @@ export type EditorAction =
| { type: 'updatePan'; current: { screenX: number; screenY: number } } | { type: 'updatePan'; current: { screenX: number; screenY: number } }
| { type: 'endPan' } | { type: 'endPan' }
| { type: 'selectSingle'; id?: string } | { type: 'selectSingle'; id?: string }
| { type: 'setSelection'; ids: string[] }
| { type: 'selectAll' } | { type: 'selectAll' }
| { type: 'toggleSelect'; id: string } | { type: 'toggleSelect'; id: string }
| { | {
@ -494,6 +495,11 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
case 'selectSingle': case 'selectSingle':
return { ...state, selection: { ids: action.id ? [action.id] : [] } }; return { ...state, selection: { ids: action.id ? [action.id] : [] } };
case 'setSelection': {
const ids = Array.from(new Set(action.ids));
return { ...state, selection: { ids } };
}
case 'selectAll': case 'selectAll':
return { ...state, selection: { ids: state.doc.screen.nodes.map((n) => n.id) } }; return { ...state, selection: { ids: state.doc.screen.nodes.map((n) => n.id) } };

View File

@ -284,6 +284,44 @@ function hasAnyKeyDeep(input: unknown, keys: string[], depth = 2): boolean {
return false; return false;
} }
function containsVideoHtmlDeep(input: unknown, depth = 2): boolean {
if (!input || typeof input !== 'object') return false;
const obj = input as Record<string, unknown>;
for (const key of [
'srcdoc',
'srcDoc',
'html',
'htmlContent',
'htmlString',
'iframeHtml',
'embedHtml',
'embedCode',
'iframeCode',
'iframeEmbed',
'embed',
'code',
'content',
'template',
]) {
const v = obj[key];
if (typeof v === 'string') {
const s = v.trim();
if (s.startsWith('<') && s.includes('>') && (/\bvideo\b/i.test(s) || /\bsource\b/i.test(s))) {
return true;
}
}
}
if (depth <= 0) return false;
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
if (containsVideoHtmlDeep(obj[nestKey], depth - 1)) return true;
}
return false;
}
function looksLikeIframeOption(option: unknown): boolean { function looksLikeIframeOption(option: unknown): boolean {
if (!option) return false; if (!option) return false;
@ -302,6 +340,10 @@ function looksLikeIframeOption(option: unknown): boolean {
// Prefer explicit iframe-ish keys when option is an object (including nested shapes). // Prefer explicit iframe-ish keys when option is an object (including nested shapes).
if (typeof option === 'object') { if (typeof option === 'object') {
// If the embed/html content is clearly a <video> snippet, don't classify as iframe.
// (Some exporters put a <video> tag under fields like `html` / `code`.)
if (containsVideoHtmlDeep(option, 2)) return false;
if ( if (
hasAnyKeyDeep(option, ['iframeUrl', 'iframeSrc', 'embedUrl', 'frameUrl', 'frameSrc'], 2) || hasAnyKeyDeep(option, ['iframeUrl', 'iframeSrc', 'embedUrl', 'frameUrl', 'frameSrc'], 2) ||
// Some exports store raw HTML instead of a URL. // Some exports store raw HTML instead of a URL.
@ -406,6 +448,9 @@ function looksLikeVideoOption(option: unknown): boolean {
'autoplay', 'autoplay',
'autoPlay', 'autoPlay',
'isAutoPlay', 'isAutoPlay',
'controls',
'showControls',
'showControl',
// common UI fields // common UI fields
'poster', 'poster',
'posterUrl', 'posterUrl',
@ -531,6 +576,22 @@ function pickSizeLike(option: unknown): { w?: number; h?: number } {
return { w, h }; return { w, h };
} }
function pickAspectRatioLike(option: unknown): number | undefined {
const ratio = pickNumberLike(option, ['aspectRatio', 'aspect', 'ratio', 'aspect_ratio']);
if (ratio === undefined) return undefined;
return ratio > 0 ? ratio : undefined;
}
function applyAspectRatioToSize(
size: { w?: number; h?: number },
aspectRatio?: number,
): { w?: number; h?: number } {
if (!aspectRatio || aspectRatio <= 0) return size;
if (size.w && !size.h) return { ...size, h: Math.round(size.w / aspectRatio) };
if (size.h && !size.w) return { ...size, w: Math.round(size.h * aspectRatio) };
return size;
}
function normalizeRect( function normalizeRect(
rect: { x: number; y: number; w: number; h: number }, rect: { x: number; y: number; w: number; h: number },
fallback: { w: number; h: number }, fallback: { w: number; h: number },
@ -654,7 +715,7 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
? { w: 480, h: 270 } ? { w: 480, h: 270 }
: { w: 320, h: 60 }; : { w: 320, h: 60 };
const optSize = pickSizeLike(option); const optSize = applyAspectRatioToSize(pickSizeLike(option), pickAspectRatioLike(option));
const attr = c.attr as unknown as Record<string, unknown> | undefined; const attr = c.attr as unknown as Record<string, unknown> | undefined;
const rawRect = attr const rawRect = attr

View File

@ -64,6 +64,11 @@ export interface IframeWidgetNode extends WidgetNodeBase {
type: 'iframe'; type: 'iframe';
props: { props: {
src: string; src: string;
allow?: string;
sandbox?: string;
title?: string;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
aspectRatio?: number;
borderRadius?: number; borderRadius?: number;
}; };
} }
@ -73,9 +78,12 @@ export interface VideoWidgetNode extends WidgetNodeBase {
props: { props: {
src: string; src: string;
autoplay?: boolean; autoplay?: boolean;
controls?: boolean;
loop?: boolean; loop?: boolean;
muted?: boolean; muted?: boolean;
poster?: string;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
aspectRatio?: number;
borderRadius?: number; borderRadius?: number;
}; };
} }

View File

@ -20,6 +20,16 @@ export interface GoViewIframeOption {
sourceList?: unknown; sourceList?: unknown;
urlList?: unknown; urlList?: unknown;
allow?: unknown;
sandbox?: unknown;
title?: unknown;
fit?: unknown;
objectFit?: unknown;
aspectRatio?: unknown;
ratio?: unknown;
borderRadius?: number; borderRadius?: number;
} }
@ -64,6 +74,20 @@ function toMaybeNumber(v: unknown): number | undefined {
return undefined; return undefined;
} }
function toMaybeString(v: unknown): string | undefined {
if (typeof v === 'string') {
const s = v.trim();
return s ? s : undefined;
}
if (!v) return undefined;
if (Array.isArray(v)) {
const parts = v.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
if (!parts.length) return undefined;
return parts.join('; ');
}
return undefined;
}
function pickFromNested<T>( function pickFromNested<T>(
input: unknown, input: unknown,
picker: (obj: Record<string, unknown>) => T | undefined, picker: (obj: Record<string, unknown>) => T | undefined,
@ -84,6 +108,12 @@ function pickFromNested<T>(
return undefined; return undefined;
} }
function asString(v: unknown): string {
if (typeof v === 'string') return v;
if (!v) return '';
return '';
}
function pickFirstUrlFromList(input: unknown): string { function pickFirstUrlFromList(input: unknown): string {
if (!input) return ''; if (!input) return '';
if (typeof input === 'string') return input; if (typeof input === 'string') return input;
@ -161,6 +191,44 @@ function pickSrc(option: GoViewIframeOption): string {
return listUrl; return listUrl;
} }
function pickFit(option: GoViewIframeOption): IframeWidgetNode['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 IframeWidgetNode['props']['fit'];
}
return undefined;
}
function pickAspectRatio(option: GoViewIframeOption): number | undefined {
const direct = toMaybeNumber(option.aspectRatio ?? option.ratio);
if (direct !== undefined && direct > 0) return direct;
const nested = pickFromNested(option, (obj) => toMaybeNumber(obj.aspectRatio ?? obj.aspect ?? obj.ratio), 2);
if (nested !== undefined && nested > 0) return nested;
return undefined;
}
function pickStringLike(option: GoViewIframeOption, keys: string[]): string | undefined {
const direct = toMaybeString(
keys
.map((key) => (option as Record<string, unknown>)[key])
.find((v) => toMaybeString(v) !== undefined),
);
if (direct) return direct;
return pickFromNested(
option,
(obj) => {
for (const key of keys) {
const v = toMaybeString(obj[key]);
if (v) return v;
}
return undefined;
},
2,
);
}
function pickBorderRadius(option: GoViewIframeOption): number | undefined { function pickBorderRadius(option: GoViewIframeOption): number | undefined {
const direct = toMaybeNumber(option.borderRadius); const direct = toMaybeNumber(option.borderRadius);
if (direct !== undefined) return direct; if (direct !== undefined) return direct;
@ -170,6 +238,11 @@ function pickBorderRadius(option: GoViewIframeOption): number | undefined {
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] { export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
return { return {
src: pickSrc(option), src: pickSrc(option),
allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']),
sandbox: pickStringLike(option, ['sandbox', 'sandboxList']),
title: pickStringLike(option, ['title', 'name', 'label']),
fit: pickFit(option),
aspectRatio: pickAspectRatio(option),
borderRadius: pickBorderRadius(option), borderRadius: pickBorderRadius(option),
}; };
} }

View File

@ -22,12 +22,24 @@ export interface GoViewVideoOption {
autoPlay?: boolean; autoPlay?: boolean;
isAutoPlay?: boolean; isAutoPlay?: boolean;
controls?: unknown;
loop?: boolean; loop?: boolean;
muted?: boolean; muted?: boolean;
poster?: unknown;
posterUrl?: unknown;
cover?: unknown;
coverUrl?: unknown;
thumbnail?: unknown;
thumbnailUrl?: unknown;
fit?: unknown; fit?: unknown;
objectFit?: unknown; objectFit?: unknown;
aspectRatio?: unknown;
ratio?: unknown;
borderRadius?: number; borderRadius?: number;
} }
@ -256,6 +268,18 @@ function pickAutoplay(option: GoViewVideoOption): boolean | undefined {
return pickBooleanLike(option, ['autoplay', 'autoPlay', 'auto_play', 'isAutoPlay']); return pickBooleanLike(option, ['autoplay', 'autoPlay', 'auto_play', 'isAutoPlay']);
} }
function pickControls(option: GoViewVideoOption): boolean | undefined {
return pickBooleanLike(option, [
'controls',
'showControls',
'showControl',
'controlsVisible',
'showControlBar',
'controlBar',
'showBar',
]);
}
function pickFitFromNested(option: GoViewVideoOption): string { function pickFitFromNested(option: GoViewVideoOption): string {
const direct = asString(option.fit) || asString(option.objectFit); const direct = asString(option.fit) || asString(option.objectFit);
if (direct) return direct; if (direct) return direct;
@ -277,13 +301,66 @@ function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | u
return undefined; return undefined;
} }
function pickPoster(option: GoViewVideoOption): string | undefined {
const direct =
pickUrlLike({
poster: option.poster,
posterUrl: option.posterUrl,
cover: option.cover,
coverUrl: option.coverUrl,
thumbnail: option.thumbnail,
thumbnailUrl: option.thumbnailUrl,
}) ?? '';
if (direct) return direct;
return pickFromNested(
option,
(obj) => {
for (const key of [
'poster',
'posterUrl',
'posterURL',
'cover',
'coverUrl',
'coverURL',
'thumbnail',
'thumbnailUrl',
'preview',
'previewImage',
'previewUrl',
'posterImage',
]) {
const v = obj[key];
if (typeof v === 'string' && v) return v;
if (typeof v === 'object') {
const url = pickUrlLike(v, 1);
if (url) return url;
}
}
return undefined;
},
2,
);
}
function pickAspectRatio(option: GoViewVideoOption): number | undefined {
const direct = toMaybeNumber(option.aspectRatio ?? option.ratio);
if (direct !== undefined && direct > 0) return direct;
const nested = pickFromNested(option, (obj) => toMaybeNumber(obj.aspectRatio ?? obj.aspect ?? obj.ratio), 2);
if (nested !== undefined && nested > 0) return nested;
return undefined;
}
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] { export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
return { return {
src: pickSrc(option), src: pickSrc(option),
autoplay: pickAutoplay(option), autoplay: pickAutoplay(option),
controls: pickControls(option),
loop: pickBooleanLike(option, ['loop', 'isLoop']), loop: pickBooleanLike(option, ['loop', 'isLoop']),
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']), muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
poster: pickPoster(option),
fit: pickFit(option), fit: pickFit(option),
aspectRatio: pickAspectRatio(option),
borderRadius: pickBorderRadius(option), borderRadius: pickBorderRadius(option),
}; };
} }