refactor: improve goView video/iframe import + editor parity
This commit is contained in:
parent
e111184acc
commit
54f3cab08f
@ -502,15 +502,18 @@ function NodeView(props: {
|
||||
>
|
||||
<iframe
|
||||
src={node.props.src}
|
||||
allow={node.props.allow}
|
||||
sandbox={node.props.sandbox}
|
||||
width={rect.w}
|
||||
height={rect.h}
|
||||
style={{
|
||||
border: 0,
|
||||
display: 'block',
|
||||
objectFit: node.props.fit,
|
||||
// Editor parity: iframes steal pointer events; disable so selection/context menu works.
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
title={node.id}
|
||||
title={node.props.title ?? node.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -530,9 +533,11 @@ function NodeView(props: {
|
||||
width={rect.w}
|
||||
height={rect.h}
|
||||
autoPlay={node.props.autoplay ?? false}
|
||||
controls={node.props.controls ?? false}
|
||||
playsInline
|
||||
loop={node.props.loop ?? false}
|
||||
muted={node.props.muted ?? false}
|
||||
poster={node.props.poster}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
|
||||
@ -93,6 +93,19 @@ export function EditorApp() {
|
||||
|
||||
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(() => {
|
||||
// Reset the sync gate whenever a new context menu opens/closes.
|
||||
ctxMenuSyncedRef.current = false;
|
||||
@ -509,7 +522,7 @@ export function EditorApp() {
|
||||
: additive
|
||||
? [...state.selection.ids, node.id]
|
||||
: [node.id];
|
||||
const selectionKey = nextSelectionIds.join('|');
|
||||
const selectionKey = selectionKeyOf(nextSelectionIds);
|
||||
if (!state.selection.ids.includes(node.id)) {
|
||||
if (additive) {
|
||||
dispatch({ type: 'toggleSelect', id: node.id });
|
||||
@ -566,12 +579,12 @@ export function EditorApp() {
|
||||
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
||||
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
||||
onSelectAll={() => dispatch({ type: 'selectAll' })}
|
||||
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
||||
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
|
||||
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
|
||||
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
|
||||
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
|
||||
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
||||
onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
|
||||
onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
|
||||
onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
|
||||
onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
|
||||
onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
|
||||
onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@ -28,6 +28,7 @@ export type EditorAction =
|
||||
| { type: 'updatePan'; current: { screenX: number; screenY: number } }
|
||||
| { type: 'endPan' }
|
||||
| { type: 'selectSingle'; id?: string }
|
||||
| { type: 'setSelection'; ids: string[] }
|
||||
| { type: 'selectAll' }
|
||||
| { type: 'toggleSelect'; id: string }
|
||||
| {
|
||||
@ -494,6 +495,11 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
||||
case 'selectSingle':
|
||||
return { ...state, selection: { ids: action.id ? [action.id] : [] } };
|
||||
|
||||
case 'setSelection': {
|
||||
const ids = Array.from(new Set(action.ids));
|
||||
return { ...state, selection: { ids } };
|
||||
}
|
||||
|
||||
case 'selectAll':
|
||||
return { ...state, selection: { ids: state.doc.screen.nodes.map((n) => n.id) } };
|
||||
|
||||
|
||||
@ -284,6 +284,44 @@ function hasAnyKeyDeep(input: unknown, keys: string[], depth = 2): boolean {
|
||||
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 {
|
||||
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).
|
||||
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 (
|
||||
hasAnyKeyDeep(option, ['iframeUrl', 'iframeSrc', 'embedUrl', 'frameUrl', 'frameSrc'], 2) ||
|
||||
// Some exports store raw HTML instead of a URL.
|
||||
@ -406,6 +448,9 @@ function looksLikeVideoOption(option: unknown): boolean {
|
||||
'autoplay',
|
||||
'autoPlay',
|
||||
'isAutoPlay',
|
||||
'controls',
|
||||
'showControls',
|
||||
'showControl',
|
||||
// common UI fields
|
||||
'poster',
|
||||
'posterUrl',
|
||||
@ -531,6 +576,22 @@ function pickSizeLike(option: unknown): { w?: number; h?: number } {
|
||||
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(
|
||||
rect: { x: number; y: number; w: number; h: number },
|
||||
fallback: { w: number; h: number },
|
||||
@ -654,7 +715,7 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
|
||||
? { w: 480, h: 270 }
|
||||
: { 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 rawRect = attr
|
||||
|
||||
@ -64,6 +64,11 @@ export interface IframeWidgetNode extends WidgetNodeBase {
|
||||
type: 'iframe';
|
||||
props: {
|
||||
src: string;
|
||||
allow?: string;
|
||||
sandbox?: string;
|
||||
title?: string;
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
aspectRatio?: number;
|
||||
borderRadius?: number;
|
||||
};
|
||||
}
|
||||
@ -73,9 +78,12 @@ export interface VideoWidgetNode extends WidgetNodeBase {
|
||||
props: {
|
||||
src: string;
|
||||
autoplay?: boolean;
|
||||
controls?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
poster?: string;
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
aspectRatio?: number;
|
||||
borderRadius?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,6 +20,16 @@ export interface GoViewIframeOption {
|
||||
sourceList?: unknown;
|
||||
urlList?: unknown;
|
||||
|
||||
allow?: unknown;
|
||||
sandbox?: unknown;
|
||||
title?: unknown;
|
||||
|
||||
fit?: unknown;
|
||||
objectFit?: unknown;
|
||||
|
||||
aspectRatio?: unknown;
|
||||
ratio?: unknown;
|
||||
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
@ -64,6 +74,20 @@ function toMaybeNumber(v: unknown): number | 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>(
|
||||
input: unknown,
|
||||
picker: (obj: Record<string, unknown>) => T | undefined,
|
||||
@ -84,6 +108,12 @@ function pickFromNested<T>(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asString(v: unknown): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (!v) return '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickFirstUrlFromList(input: unknown): string {
|
||||
if (!input) return '';
|
||||
if (typeof input === 'string') return input;
|
||||
@ -161,6 +191,44 @@ function pickSrc(option: GoViewIframeOption): string {
|
||||
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 {
|
||||
const direct = toMaybeNumber(option.borderRadius);
|
||||
if (direct !== undefined) return direct;
|
||||
@ -170,6 +238,11 @@ function pickBorderRadius(option: GoViewIframeOption): number | undefined {
|
||||
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
|
||||
return {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,12 +22,24 @@ export interface GoViewVideoOption {
|
||||
autoPlay?: boolean;
|
||||
isAutoPlay?: boolean;
|
||||
|
||||
controls?: unknown;
|
||||
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
|
||||
poster?: unknown;
|
||||
posterUrl?: unknown;
|
||||
cover?: unknown;
|
||||
coverUrl?: unknown;
|
||||
thumbnail?: unknown;
|
||||
thumbnailUrl?: unknown;
|
||||
|
||||
fit?: unknown;
|
||||
objectFit?: unknown;
|
||||
|
||||
aspectRatio?: unknown;
|
||||
ratio?: unknown;
|
||||
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
@ -256,6 +268,18 @@ function pickAutoplay(option: GoViewVideoOption): boolean | undefined {
|
||||
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 {
|
||||
const direct = asString(option.fit) || asString(option.objectFit);
|
||||
if (direct) return direct;
|
||||
@ -277,13 +301,66 @@ function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | u
|
||||
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'] {
|
||||
return {
|
||||
src: pickSrc(option),
|
||||
autoplay: pickAutoplay(option),
|
||||
controls: pickControls(option),
|
||||
loop: pickBooleanLike(option, ['loop', 'isLoop']),
|
||||
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
|
||||
poster: pickPoster(option),
|
||||
fit: pickFit(option),
|
||||
aspectRatio: pickAspectRatio(option),
|
||||
borderRadius: pickBorderRadius(option),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user