diff --git a/packages/editor/src/editor/ContextMenu.tsx b/packages/editor/src/editor/ContextMenu.tsx index 7b716cc..b550d63 100644 --- a/packages/editor/src/editor/ContextMenu.tsx +++ b/packages/editor/src/editor/ContextMenu.tsx @@ -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: { { props.onBringToFrontSelected?.(); onClose(); @@ -231,7 +231,7 @@ export function ContextMenu(props: { /> { props.onSendToBackSelected?.(); onClose(); diff --git a/packages/editor/src/editor/EditorApp.tsx b/packages/editor/src/editor/EditorApp.tsx index d19fd37..7b0fb15 100644 --- a/packages/editor/src/editor/EditorApp.tsx +++ b/packages/editor/src/editor/EditorApp.tsx @@ -460,10 +460,18 @@ export function EditorApp() { 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 }) + } /> diff --git a/packages/editor/src/editor/Inspector.tsx b/packages/editor/src/editor/Inspector.tsx index 7d23a27..331ffe1 100644 --- a/packages/editor/src/editor/Inspector.tsx +++ b/packages/editor/src/editor/Inspector.tsx @@ -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; -type IframeWidgetNode = Extract; -type VideoWidgetNode = Extract; +type ImageWidgetNode = WidgetNodeByType['image']; +type IframeWidgetNode = WidgetNodeByType['iframe']; +type VideoWidgetNode = WidgetNodeByType['video']; export function Inspector(props: { selected?: WidgetNode; @@ -18,356 +18,354 @@ export function Inspector(props: { return No selection.; } - if (node.type === 'image') { - return ( -
- - Image - + switch (node.type) { + case 'image': + return ( +
+ + Image + - Source - props.onUpdateImageProps(node.id, { src: e.target.value })} - placeholder="https://..." - style={{ marginBottom: 12 }} - /> - - Fit - props.onUpdateIframeProps(node.id, { src: e.target.value })} - placeholder="https://..." - style={{ marginBottom: 12 }} - /> - - Border radius - props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })} - style={{ width: '100%' }} - /> -
- ); - } - - if (node.type === 'video') { - return ( -
- - Video - - - Source - props.onUpdateVideoProps(node.id, { src: e.target.value })} - placeholder="https://..." - style={{ marginBottom: 12 }} - /> - - Fit - props.onUpdateVideoProps(node.id, { loop: v === 'true' })} - style={{ width: '100%' }} - options={[ - { value: 'true', label: 'true' }, - { value: 'false', label: 'false' }, - ]} - /> -
-
- Muted - props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })} - style={{ width: '100%' }} - options={[ - { value: 'true', label: 'true' }, - { value: 'false', label: 'false' }, - ]} - /> -
- - - Border radius - props.onUpdateVideoProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })} - style={{ width: '100%' }} - /> -
- ); - } - - // If more widget types are added, handle them above. - // For now, we only support text/image/iframe/video. - if (node.type !== 'text') { - return Unsupported widget type.; - } - - const fontWeight = node.props.fontWeight ?? 400; - - return ( -
- - Text - - - Content - props.onUpdateTextProps(node.id, { text: e.target.value })} - placeholder="Text" - style={{ marginBottom: 12 }} - /> - - -
- Font size -
- props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })} - style={{ width: '100%' }} - /> -
-
- -
- Weight -
- props.onUpdateTextProps(node.id, { color: e.target.value })} - placeholder="#ffffff" - style={{ marginBottom: 12 }} - /> - - -
- Padding X -
- props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })} - style={{ width: '100%' }} - /> -
-
-
- Padding Y -
- props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })} - style={{ width: '100%' }} - /> -
-
-
- - -
- Letter spacing -
- props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })} - style={{ width: '100%' }} - /> -
-
-
- Align -
- props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })} - placeholder="transparent or #00000000" - style={{ marginBottom: 12 }} - /> - - Writing mode - props.onUpdateTextProps(node.id, { linkHead: e.target.value })} - style={{ width: 120 }} - /> - props.onUpdateTextProps(node.id, { link: e.target.value })} - placeholder="example.com" - /> - - - Border - -
- props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })} - style={{ width: '100%' }} - addonBefore="W" - /> -
-
+ Source props.onUpdateTextProps(node.id, { borderColor: e.target.value })} - placeholder="#ffffff" - addonBefore="C" + value={node.props.src} + onChange={(e) => props.onUpdateImageProps(node.id, { src: e.target.value })} + placeholder="https://..." + style={{ marginBottom: 12 }} + /> + + Fit + props.onUpdateIframeProps(node.id, { src: e.target.value })} + placeholder="https://..." + style={{ marginBottom: 12 }} /> - ))} -
-
- ); + + Border radius + props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + /> +
+ ); + + case 'video': + return ( +
+ + Video + + + Source + props.onUpdateVideoProps(node.id, { src: e.target.value })} + placeholder="https://..." + style={{ marginBottom: 12 }} + /> + + Fit + props.onUpdateVideoProps(node.id, { loop: v === 'true' })} + style={{ width: '100%' }} + options={[ + { value: 'true', label: 'true' }, + { value: 'false', label: 'false' }, + ]} + /> +
+
+ Muted + props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })} + style={{ width: '100%' }} + options={[ + { value: 'true', label: 'true' }, + { value: 'false', label: 'false' }, + ]} + /> +
+
+ + Border radius + props.onUpdateVideoProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + /> +
+ ); + + case 'text': { + const fontWeight = node.props.fontWeight ?? 400; + + return ( +
+ + Text + + + Content + props.onUpdateTextProps(node.id, { text: e.target.value })} + placeholder="Text" + style={{ marginBottom: 12 }} + /> + + +
+ Font size +
+ props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })} + style={{ width: '100%' }} + /> +
+
+ +
+ Weight +
+ props.onUpdateTextProps(node.id, { color: e.target.value })} + placeholder="#ffffff" + style={{ marginBottom: 12 }} + /> + + +
+ Padding X +
+ props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + /> +
+
+
+ Padding Y +
+ props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + /> +
+
+
+ + +
+ Letter spacing +
+ props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + /> +
+
+
+ Align +
+ props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })} + placeholder="transparent or #00000000" + style={{ marginBottom: 12 }} + /> + + Writing mode + props.onUpdateTextProps(node.id, { linkHead: e.target.value })} + style={{ width: 120 }} + /> + props.onUpdateTextProps(node.id, { link: e.target.value })} + placeholder="example.com" + /> + + + Border + +
+ props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + addonBefore="W" + /> +
+
+ props.onUpdateTextProps(node.id, { borderColor: e.target.value })} + placeholder="#ffffff" + addonBefore="C" + /> +
+
+
+ props.onUpdateTextProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })} + style={{ width: '100%' }} + addonBefore="R" + /> +
+ + Quick colors +
+ {['#ffffff', '#d1d5db', '#93c5fd', '#a7f3d0', '#fca5a5', '#fbbf24'].map((c) => ( +
+
+ ); + } + + default: + return assertNever(node); + } } diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts index dd28ef7..4f1a416 100644 --- a/packages/editor/src/editor/store.ts +++ b/packages/editor/src/editor/store.ts @@ -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 } - | { type: 'updateImageProps'; id: string; props: Partial } - | { type: 'updateIframeProps'; id: string; props: Partial } - | { type: 'updateVideoProps'; id: string; props: Partial }; + | UpdateWidgetPropsAction; + +type UpdateWidgetPropsAction = + | { type: 'updateWidgetProps'; widgetType: 'text'; id: string; props: Partial } + | { type: 'updateWidgetProps'; widgetType: 'image'; id: string; props: Partial } + | { type: 'updateWidgetProps'; widgetType: 'iframe'; id: string; props: Partial } + | { type: 'updateWidgetProps'; widgetType: 'video'; id: string; props: Partial }; interface DragSession { kind: 'move' | 'resize'; @@ -86,6 +89,39 @@ type EditorRuntimeState = EditorState & { __drag?: DragSession; }; +function isWidgetType( + node: WidgetNode, + widgetType: K, +): node is WidgetNodeByType[K] { + return node.type === widgetType; +} + +function updateWidgetProps( + state: EditorRuntimeState, + action: Extract, +): 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 '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 '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 '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 { diff --git a/packages/sdk/src/core/schema.ts b/packages/sdk/src/core/schema.ts index abd15c2..060e233 100644 --- a/packages/sdk/src/core/schema.ts +++ b/packages/sdk/src/core/schema.ts @@ -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; diff --git a/packages/sdk/src/core/widgets/iframe.ts b/packages/sdk/src/core/widgets/iframe.ts index 80a89db..e246fd0 100644 --- a/packages/sdk/src/core/widgets/iframe.ts +++ b/packages/sdk/src/core/widgets/iframe.ts @@ -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), diff --git a/packages/sdk/src/core/widgets/video.ts b/packages/sdk/src/core/widgets/video.ts index 0ee1661..6825e61 100644 --- a/packages/sdk/src/core/widgets/video.ts +++ b/packages/sdk/src/core/widgets/video.ts @@ -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