feat: improve goView iframe/video import + editor parity
This commit is contained in:
parent
947946edc6
commit
acf61d9bee
@ -115,7 +115,7 @@ export function ContextMenu(props: {
|
|||||||
const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds;
|
const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds;
|
||||||
|
|
||||||
const hasSelection = selectionIds.length > 0;
|
const hasSelection = selectionIds.length > 0;
|
||||||
const canModifySelection = selectionInSync && hasSelection && props.selectionHasUnlocked;
|
const canModifySelection = hasSelection && props.selectionHasUnlocked;
|
||||||
const hasTarget = ctx.kind === 'node';
|
const hasTarget = ctx.kind === 'node';
|
||||||
const targetId = hasTarget ? ctx.targetId : undefined;
|
const targetId = hasTarget ? ctx.targetId : undefined;
|
||||||
const targetInSelection = !!targetId && selectionIds.includes(targetId);
|
const targetInSelection = !!targetId && selectionIds.includes(targetId);
|
||||||
@ -198,7 +198,7 @@ export function ContextMenu(props: {
|
|||||||
? 'Toggle Lock'
|
? 'Toggle Lock'
|
||||||
: 'Lock'
|
: 'Lock'
|
||||||
}
|
}
|
||||||
disabled={!selectionInSync || !hasSelection || !props.onToggleLockSelected}
|
disabled={!hasSelection || !props.onToggleLockSelected}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onToggleLockSelected?.();
|
props.onToggleLockSelected?.();
|
||||||
onClose();
|
onClose();
|
||||||
@ -212,7 +212,7 @@ export function ContextMenu(props: {
|
|||||||
? 'Toggle Visibility'
|
? 'Toggle Visibility'
|
||||||
: 'Hide'
|
: 'Hide'
|
||||||
}
|
}
|
||||||
disabled={!selectionInSync || !hasSelection || !props.onToggleHideSelected}
|
disabled={!hasSelection || !props.onToggleHideSelected}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onToggleHideSelected?.();
|
props.onToggleHideSelected?.();
|
||||||
onClose();
|
onClose();
|
||||||
@ -223,7 +223,7 @@ export function ContextMenu(props: {
|
|||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Bring To Front"
|
label="Bring To Front"
|
||||||
disabled={!selectionInSync || !hasSelection || !props.onBringToFrontSelected}
|
disabled={!hasSelection || !props.onBringToFrontSelected}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onBringToFrontSelected?.();
|
props.onBringToFrontSelected?.();
|
||||||
onClose();
|
onClose();
|
||||||
@ -231,7 +231,7 @@ export function ContextMenu(props: {
|
|||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Send To Back"
|
label="Send To Back"
|
||||||
disabled={!selectionInSync || !hasSelection || !props.onSendToBackSelected}
|
disabled={!hasSelection || !props.onSendToBackSelected}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onSendToBackSelected?.();
|
props.onSendToBackSelected?.();
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@ -460,10 +460,18 @@ export function EditorApp() {
|
|||||||
|
|
||||||
<Inspector
|
<Inspector
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
|
onUpdateTextProps={(id, props) =>
|
||||||
onUpdateImageProps={(id, props) => dispatch({ type: 'updateImageProps', id, props })}
|
dispatch({ type: 'updateWidgetProps', widgetType: 'text', id, props })
|
||||||
onUpdateIframeProps={(id, props) => dispatch({ type: 'updateIframeProps', id, props })}
|
}
|
||||||
onUpdateVideoProps={(id, props) => dispatch({ type: 'updateVideoProps', 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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Input, InputNumber, Select, Space, Typography } from 'antd';
|
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 ImageWidgetNode = WidgetNodeByType['image'];
|
||||||
type IframeWidgetNode = Extract<WidgetNode, { type: 'iframe' }>;
|
type IframeWidgetNode = WidgetNodeByType['iframe'];
|
||||||
type VideoWidgetNode = Extract<WidgetNode, { type: 'video' }>;
|
type VideoWidgetNode = WidgetNodeByType['video'];
|
||||||
|
|
||||||
export function Inspector(props: {
|
export function Inspector(props: {
|
||||||
selected?: WidgetNode;
|
selected?: WidgetNode;
|
||||||
@ -18,7 +18,8 @@ export function Inspector(props: {
|
|||||||
return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>;
|
return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'image') {
|
switch (node.type) {
|
||||||
|
case 'image':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
@ -56,9 +57,8 @@ export function Inspector(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'iframe') {
|
case 'iframe':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
@ -82,9 +82,8 @@ export function Inspector(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'video') {
|
case 'video':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
@ -164,14 +163,8 @@ export function Inspector(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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;
|
const fontWeight = node.props.fontWeight ?? 400;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -371,3 +364,8 @@ export function Inspector(props: {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return assertNever(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import {
|
|||||||
createEmptyScreen,
|
createEmptyScreen,
|
||||||
migrateScreen,
|
migrateScreen,
|
||||||
type Rect,
|
type Rect,
|
||||||
type ImageWidgetNode,
|
|
||||||
type IframeWidgetNode,
|
|
||||||
type VideoWidgetNode,
|
|
||||||
type Screen,
|
type Screen,
|
||||||
type TextWidgetNode,
|
type TextWidgetNode,
|
||||||
type WidgetNode,
|
type WidgetNode,
|
||||||
|
type WidgetKind,
|
||||||
|
type WidgetNodeByType,
|
||||||
|
type WidgetPropsByType,
|
||||||
} from '@astralview/sdk';
|
} from '@astralview/sdk';
|
||||||
import { rectContains, rectFromPoints } from './geometry';
|
import { rectContains, rectFromPoints } from './geometry';
|
||||||
import { didRectsChange } from './history';
|
import { didRectsChange } from './history';
|
||||||
@ -53,10 +53,13 @@ export type EditorAction =
|
|||||||
| { type: 'toggleHideSelected' }
|
| { type: 'toggleHideSelected' }
|
||||||
| { type: 'bringToFrontSelected' }
|
| { type: 'bringToFrontSelected' }
|
||||||
| { type: 'sendToBackSelected' }
|
| { type: 'sendToBackSelected' }
|
||||||
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
|
| UpdateWidgetPropsAction;
|
||||||
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> }
|
|
||||||
| { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> }
|
type UpdateWidgetPropsAction =
|
||||||
| { type: 'updateVideoProps'; id: string; props: Partial<VideoWidgetNode['props']> };
|
| { 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 {
|
interface DragSession {
|
||||||
kind: 'move' | 'resize';
|
kind: 'move' | 'resize';
|
||||||
@ -86,6 +89,39 @@ type EditorRuntimeState = EditorState & {
|
|||||||
__drag?: DragSession;
|
__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 {
|
function historyPush(state: EditorRuntimeState): EditorRuntimeState {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -217,72 +253,19 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'updateTextProps': {
|
case 'updateWidgetProps': {
|
||||||
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
|
switch (action.widgetType) {
|
||||||
if (!node || node.type !== 'text') return state;
|
case 'text':
|
||||||
return {
|
return updateWidgetProps(state, action);
|
||||||
...historyPush(state),
|
case 'image':
|
||||||
doc: {
|
return updateWidgetProps(state, action);
|
||||||
screen: {
|
case 'iframe':
|
||||||
...state.doc.screen,
|
return updateWidgetProps(state, action);
|
||||||
nodes: state.doc.screen.nodes.map((n) => {
|
case 'video':
|
||||||
if (n.id !== action.id || n.type !== 'text') return n;
|
return updateWidgetProps(state, action);
|
||||||
return { ...n, props: { ...n.props, ...action.props } };
|
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': {
|
case 'deleteSelected': {
|
||||||
@ -823,7 +806,7 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'importJSON': {
|
case 'importJSON': {
|
||||||
const parsed = JSON.parse(action.json) as unknown;
|
const parsed: unknown = JSON.parse(action.json);
|
||||||
try {
|
try {
|
||||||
const screen = migrateScreen(parsed);
|
const screen = migrateScreen(parsed);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -90,6 +90,19 @@ export interface VideoWidgetNode extends WidgetNodeBase {
|
|||||||
|
|
||||||
export type WidgetNode = TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode;
|
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 {
|
export interface Screen {
|
||||||
version: SchemaVersion;
|
version: SchemaVersion;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -19,6 +19,15 @@ export interface GoViewIframeOption {
|
|||||||
webUrl?: unknown;
|
webUrl?: unknown;
|
||||||
webpageUrl?: 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)
|
// list-ish shapes (some low-code editors model embeds as a list even for a single item)
|
||||||
sources?: unknown;
|
sources?: unknown;
|
||||||
sourceList?: unknown;
|
sourceList?: unknown;
|
||||||
@ -54,6 +63,12 @@ function looksLikeHtml(input: string): boolean {
|
|||||||
return trimmed.startsWith('<') && trimmed.includes('>');
|
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 {
|
function extractSrcFromEmbedHtml(html: string): string {
|
||||||
// Many low-code editors store iframe widgets as an embed code string.
|
// Many low-code editors store iframe widgets as an embed code string.
|
||||||
// Prefer extracting the actual src to keep the resulting screen portable.
|
// 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() : '';
|
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 {
|
function toMaybeNumber(v: unknown): number | undefined {
|
||||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') {
|
||||||
@ -134,6 +164,31 @@ function pickFirstUrlFromList(input: unknown): string {
|
|||||||
return pickUrlLike(input, 2);
|
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 {
|
function pickSrc(option: GoViewIframeOption): string {
|
||||||
// 1) Prefer explicit iframe-ish URL fields.
|
// 1) Prefer explicit iframe-ish URL fields.
|
||||||
const url =
|
const url =
|
||||||
@ -160,26 +215,7 @@ function pickSrc(option: GoViewIframeOption): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) Some exports store raw HTML instead of a URL.
|
// 2) Some exports store raw HTML instead of a URL.
|
||||||
const html = pickFromNested(
|
const html = pickHtmlString(option);
|
||||||
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,
|
|
||||||
);
|
|
||||||
if (html) {
|
if (html) {
|
||||||
const extracted = extractSrcFromEmbedHtml(html);
|
const extracted = extractSrcFromEmbedHtml(html);
|
||||||
return extracted || toDataHtmlUrl(html);
|
return extracted || toDataHtmlUrl(html);
|
||||||
@ -251,11 +287,14 @@ function pickBorderRadius(option: GoViewIframeOption): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
|
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
|
||||||
|
const html = pickHtmlString(option);
|
||||||
|
const htmlAttrs = html ? extractIframeAttrsFromHtml(html) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
src: pickSrc(option),
|
src: pickSrc(option),
|
||||||
allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']),
|
allow: pickStringLike(option, ['allow', 'allowList', 'permissions', 'permission']) ?? htmlAttrs?.allow,
|
||||||
sandbox: pickStringLike(option, ['sandbox', 'sandboxList']),
|
sandbox: pickStringLike(option, ['sandbox', 'sandboxList']) ?? htmlAttrs?.sandbox,
|
||||||
title: pickStringLike(option, ['title', 'name', 'label']),
|
title: pickStringLike(option, ['title', 'name', 'label']) ?? htmlAttrs?.title,
|
||||||
fit: pickFit(option),
|
fit: pickFit(option),
|
||||||
aspectRatio: pickAspectRatio(option),
|
aspectRatio: pickAspectRatio(option),
|
||||||
borderRadius: pickBorderRadius(option),
|
borderRadius: pickBorderRadius(option),
|
||||||
|
|||||||
@ -38,6 +38,15 @@ export interface GoViewVideoOption {
|
|||||||
thumbnail?: unknown;
|
thumbnail?: unknown;
|
||||||
thumbnailUrl?: unknown;
|
thumbnailUrl?: unknown;
|
||||||
|
|
||||||
|
// HTML/embed variants
|
||||||
|
html?: unknown;
|
||||||
|
htmlString?: unknown;
|
||||||
|
embedCode?: unknown;
|
||||||
|
template?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
srcdoc?: unknown;
|
||||||
|
srcDoc?: unknown;
|
||||||
|
|
||||||
fit?: unknown;
|
fit?: unknown;
|
||||||
objectFit?: unknown;
|
objectFit?: unknown;
|
||||||
|
|
||||||
@ -63,6 +72,24 @@ function looksLikeHtml(input: string): boolean {
|
|||||||
return trimmed.startsWith('<') && trimmed.includes('>');
|
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 {
|
function extractSrcFromVideoHtml(html: string): string {
|
||||||
// Some low-code exports store a whole <video> or <source> tag string.
|
// Some low-code exports store a whole <video> or <source> tag string.
|
||||||
// Examples:
|
// Examples:
|
||||||
@ -81,6 +108,25 @@ function extractSrcFromVideoHtml(html: string): string {
|
|||||||
return typeof videoSrc === 'string' ? videoSrc.trim() : '';
|
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 {
|
function toMaybeBoolean(v: unknown): boolean | undefined {
|
||||||
if (typeof v === 'boolean') return v;
|
if (typeof v === 'boolean') return v;
|
||||||
if (typeof v === 'number') return v !== 0;
|
if (typeof v === 'number') return v !== 0;
|
||||||
@ -363,13 +409,34 @@ function pickAspectRatio(option: GoViewVideoOption): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
|
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 {
|
return {
|
||||||
src: pickSrc(option),
|
src: pickSrc(option),
|
||||||
autoplay: pickAutoplay(option),
|
autoplay: pickAutoplay(option) ?? htmlAttrs?.autoplay,
|
||||||
controls: pickControls(option),
|
controls: pickControls(option) ?? htmlAttrs?.controls,
|
||||||
loop: pickBooleanLike(option, ['loop', 'isLoop']),
|
loop: pickBooleanLike(option, ['loop', 'isLoop']) ?? htmlAttrs?.loop,
|
||||||
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
|
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']) ?? htmlAttrs?.muted,
|
||||||
poster: pickPoster(option),
|
poster: pickPoster(option) ?? htmlAttrs?.poster,
|
||||||
fit: pickFit(option),
|
fit: pickFit(option),
|
||||||
aspectRatio: pickAspectRatio(option),
|
aspectRatio: pickAspectRatio(option),
|
||||||
borderRadius: pickBorderRadius(option),
|
borderRadius: pickBorderRadius(option),
|
||||||
|
|||||||
@ -13,6 +13,9 @@ export type {
|
|||||||
Transform,
|
Transform,
|
||||||
Screen,
|
Screen,
|
||||||
WidgetNode,
|
WidgetNode,
|
||||||
|
WidgetKind,
|
||||||
|
WidgetNodeByType,
|
||||||
|
WidgetPropsByType,
|
||||||
TextWidgetNode,
|
TextWidgetNode,
|
||||||
ImageWidgetNode,
|
ImageWidgetNode,
|
||||||
IframeWidgetNode,
|
IframeWidgetNode,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user