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,356 +18,354 @@ 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) {
|
||||||
return (
|
case 'image':
|
||||||
<div>
|
return (
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<div>
|
||||||
Image
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
</Typography.Title>
|
Image
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
<Typography.Text type="secondary">Source</Typography.Text>
|
<Typography.Text type="secondary">Source</Typography.Text>
|
||||||
<Input
|
|
||||||
value={node.props.src}
|
|
||||||
onChange={(e) => props.onUpdateImageProps(node.id, { src: e.target.value })}
|
|
||||||
placeholder="https://..."
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Fit</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={node.props.fit ?? 'contain'}
|
|
||||||
onChange={(v) => props.onUpdateImageProps(node.id, { fit: v })}
|
|
||||||
style={{ width: '100%', marginBottom: 12 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'contain', label: 'contain' },
|
|
||||||
{ value: 'cover', label: 'cover' },
|
|
||||||
{ value: 'fill', label: 'fill' },
|
|
||||||
{ value: 'none', label: 'none' },
|
|
||||||
{ value: 'scale-down', label: 'scale-down' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Border radius</Typography.Text>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.borderRadius ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateImageProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'iframe') {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
Iframe
|
|
||||||
</Typography.Title>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Source</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={node.props.src}
|
|
||||||
onChange={(e) => props.onUpdateIframeProps(node.id, { src: e.target.value })}
|
|
||||||
placeholder="https://..."
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Border radius</Typography.Text>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.borderRadius ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'video') {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
Video
|
|
||||||
</Typography.Title>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Source</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={node.props.src}
|
|
||||||
onChange={(e) => props.onUpdateVideoProps(node.id, { src: e.target.value })}
|
|
||||||
placeholder="https://..."
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Fit</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={node.props.fit ?? 'contain'}
|
|
||||||
onChange={(v) => props.onUpdateVideoProps(node.id, { fit: v })}
|
|
||||||
style={{ width: '100%', marginBottom: 12 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'contain', label: 'contain' },
|
|
||||||
{ value: 'cover', label: 'cover' },
|
|
||||||
{ value: 'fill', label: 'fill' },
|
|
||||||
{ value: 'none', label: 'none' },
|
|
||||||
{ value: 'scale-down', label: 'scale-down' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Loop</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={String(node.props.loop ?? false)}
|
|
||||||
onChange={(v) => props.onUpdateVideoProps(node.id, { loop: v === 'true' })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: 'true', label: 'true' },
|
|
||||||
{ value: 'false', label: 'false' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Muted</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={String(node.props.muted ?? false)}
|
|
||||||
onChange={(v) => props.onUpdateVideoProps(node.id, { muted: v === 'true' })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: 'true', label: 'true' },
|
|
||||||
{ value: 'false', label: 'false' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Autoplay</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={String(node.props.autoplay ?? true)}
|
|
||||||
onChange={(v) => props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: 'true', label: 'true' },
|
|
||||||
{ value: 'false', label: 'false' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Border radius</Typography.Text>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.borderRadius ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateVideoProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontWeight = node.props.fontWeight ?? 400;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
Text
|
|
||||||
</Typography.Title>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Content</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={node.props.text}
|
|
||||||
onChange={(e) => props.onUpdateTextProps(node.id, { text: e.target.value })}
|
|
||||||
placeholder="Text"
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Space style={{ width: '100%' }} size={12}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Font size</Typography.Text>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.fontSize ?? 24}
|
|
||||||
min={1}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Weight</Typography.Text>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<Select
|
|
||||||
value={String(fontWeight)}
|
|
||||||
onChange={(v) => {
|
|
||||||
const asNum = Number(v);
|
|
||||||
props.onUpdateTextProps(node.id, { fontWeight: Number.isFinite(asNum) ? asNum : v });
|
|
||||||
}}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: '300', label: 'Light (300)' },
|
|
||||||
{ value: '400', label: 'Regular (400)' },
|
|
||||||
{ value: '500', label: 'Medium (500)' },
|
|
||||||
{ value: '600', label: 'Semibold (600)' },
|
|
||||||
{ value: '700', label: 'Bold (700)' },
|
|
||||||
{ value: '800', label: 'Extrabold (800)' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Color</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={node.props.color ?? '#ffffff'}
|
|
||||||
onChange={(e) => props.onUpdateTextProps(node.id, { color: e.target.value })}
|
|
||||||
placeholder="#ffffff"
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Space style={{ width: '100%' }} size={12}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Padding X</Typography.Text>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.paddingX ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Padding Y</Typography.Text>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.paddingY ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Space style={{ width: '100%' }} size={12}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Letter spacing</Typography.Text>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.letterSpacing ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary">Align</Typography.Text>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<Select
|
|
||||||
value={node.props.textAlign ?? 'center'}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { textAlign: v })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: 'left', label: 'Left' },
|
|
||||||
{ value: 'center', label: 'Center' },
|
|
||||||
{ value: 'right', label: 'Right' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Background</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={node.props.backgroundColor ?? 'transparent'}
|
|
||||||
onChange={(e) => props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })}
|
|
||||||
placeholder="transparent or #00000000"
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Writing mode</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={node.props.writingMode ?? 'horizontal-tb'}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { writingMode: v })}
|
|
||||||
style={{ width: '100%', marginBottom: 12 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'horizontal-tb', label: 'Horizontal' },
|
|
||||||
{ value: 'vertical-rl', label: 'Vertical' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Link</Typography.Text>
|
|
||||||
<Space style={{ width: '100%', marginBottom: 12 }} size={8}>
|
|
||||||
<Input
|
|
||||||
value={node.props.linkHead ?? 'http://'}
|
|
||||||
onChange={(e) => props.onUpdateTextProps(node.id, { linkHead: e.target.value })}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={node.props.link ?? ''}
|
|
||||||
onChange={(e) => props.onUpdateTextProps(node.id, { link: e.target.value })}
|
|
||||||
placeholder="example.com"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Border</Typography.Text>
|
|
||||||
<Space style={{ width: '100%' }} size={12}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.borderWidth ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
addonBefore="W"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 2 }}>
|
|
||||||
<Input
|
<Input
|
||||||
value={node.props.borderColor ?? '#ffffff'}
|
value={node.props.src}
|
||||||
onChange={(e) => props.onUpdateTextProps(node.id, { borderColor: e.target.value })}
|
onChange={(e) => props.onUpdateImageProps(node.id, { src: e.target.value })}
|
||||||
placeholder="#ffffff"
|
placeholder="https://..."
|
||||||
addonBefore="C"
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Fit</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={node.props.fit ?? 'contain'}
|
||||||
|
onChange={(v) => props.onUpdateImageProps(node.id, { fit: v })}
|
||||||
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'contain', label: 'contain' },
|
||||||
|
{ value: 'cover', label: 'cover' },
|
||||||
|
{ value: 'fill', label: 'fill' },
|
||||||
|
{ value: 'none', label: 'none' },
|
||||||
|
{ value: 'scale-down', label: 'scale-down' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Border radius</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.borderRadius ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateImageProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
);
|
||||||
<div style={{ marginTop: 8, marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
value={node.props.borderRadius ?? 0}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) => props.onUpdateTextProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
addonBefore="R"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Typography.Text type="secondary">Quick colors</Typography.Text>
|
case 'iframe':
|
||||||
<div style={{ marginTop: 6, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
return (
|
||||||
{['#ffffff', '#d1d5db', '#93c5fd', '#a7f3d0', '#fca5a5', '#fbbf24'].map((c) => (
|
<div>
|
||||||
<button
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
key={c}
|
Iframe
|
||||||
type="button"
|
</Typography.Title>
|
||||||
onClick={() => props.onUpdateTextProps(node.id, { color: c })}
|
|
||||||
style={{
|
<Typography.Text type="secondary">Source</Typography.Text>
|
||||||
width: 22,
|
<Input
|
||||||
height: 22,
|
value={node.props.src}
|
||||||
borderRadius: 6,
|
onChange={(e) => props.onUpdateIframeProps(node.id, { src: e.target.value })}
|
||||||
background: c,
|
placeholder="https://..."
|
||||||
border: '1px solid rgba(0,0,0,0.2)',
|
style={{ marginBottom: 12 }}
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title={c}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
<Typography.Text type="secondary">Border radius</Typography.Text>
|
||||||
</div>
|
<InputNumber
|
||||||
);
|
value={node.props.borderRadius ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateIframeProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
Video
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Source</Typography.Text>
|
||||||
|
<Input
|
||||||
|
value={node.props.src}
|
||||||
|
onChange={(e) => props.onUpdateVideoProps(node.id, { src: e.target.value })}
|
||||||
|
placeholder="https://..."
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Fit</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={node.props.fit ?? 'contain'}
|
||||||
|
onChange={(v) => props.onUpdateVideoProps(node.id, { fit: v })}
|
||||||
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'contain', label: 'contain' },
|
||||||
|
{ value: 'cover', label: 'cover' },
|
||||||
|
{ value: 'fill', label: 'fill' },
|
||||||
|
{ value: 'none', label: 'none' },
|
||||||
|
{ value: 'scale-down', label: 'scale-down' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Loop</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={String(node.props.loop ?? false)}
|
||||||
|
onChange={(v) => props.onUpdateVideoProps(node.id, { loop: v === 'true' })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: 'true' },
|
||||||
|
{ value: 'false', label: 'false' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Muted</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={String(node.props.muted ?? false)}
|
||||||
|
onChange={(v) => props.onUpdateVideoProps(node.id, { muted: v === 'true' })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: 'true' },
|
||||||
|
{ value: 'false', label: 'false' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Autoplay</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={String(node.props.autoplay ?? true)}
|
||||||
|
onChange={(v) => props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: 'true' },
|
||||||
|
{ value: 'false', label: 'false' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Border radius</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.borderRadius ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateVideoProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text': {
|
||||||
|
const fontWeight = node.props.fontWeight ?? 400;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
Text
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Content</Typography.Text>
|
||||||
|
<Input
|
||||||
|
value={node.props.text}
|
||||||
|
onChange={(e) => props.onUpdateTextProps(node.id, { text: e.target.value })}
|
||||||
|
placeholder="Text"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space style={{ width: '100%' }} size={12}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Font size</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.fontSize ?? 24}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Weight</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
value={String(fontWeight)}
|
||||||
|
onChange={(v) => {
|
||||||
|
const asNum = Number(v);
|
||||||
|
props.onUpdateTextProps(node.id, { fontWeight: Number.isFinite(asNum) ? asNum : v });
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: '300', label: 'Light (300)' },
|
||||||
|
{ value: '400', label: 'Regular (400)' },
|
||||||
|
{ value: '500', label: 'Medium (500)' },
|
||||||
|
{ value: '600', label: 'Semibold (600)' },
|
||||||
|
{ value: '700', label: 'Bold (700)' },
|
||||||
|
{ value: '800', label: 'Extrabold (800)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Color</Typography.Text>
|
||||||
|
<Input
|
||||||
|
value={node.props.color ?? '#ffffff'}
|
||||||
|
onChange={(e) => props.onUpdateTextProps(node.id, { color: e.target.value })}
|
||||||
|
placeholder="#ffffff"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space style={{ width: '100%' }} size={12}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Padding X</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.paddingX ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Padding Y</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.paddingY ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space style={{ width: '100%' }} size={12}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Letter spacing</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.letterSpacing ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary">Align</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
value={node.props.textAlign ?? 'center'}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { textAlign: v })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: 'left', label: 'Left' },
|
||||||
|
{ value: 'center', label: 'Center' },
|
||||||
|
{ value: 'right', label: 'Right' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Background</Typography.Text>
|
||||||
|
<Input
|
||||||
|
value={node.props.backgroundColor ?? 'transparent'}
|
||||||
|
onChange={(e) => props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })}
|
||||||
|
placeholder="transparent or #00000000"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Writing mode</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={node.props.writingMode ?? 'horizontal-tb'}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { writingMode: v })}
|
||||||
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'horizontal-tb', label: 'Horizontal' },
|
||||||
|
{ value: 'vertical-rl', label: 'Vertical' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Link</Typography.Text>
|
||||||
|
<Space style={{ width: '100%', marginBottom: 12 }} size={8}>
|
||||||
|
<Input
|
||||||
|
value={node.props.linkHead ?? 'http://'}
|
||||||
|
onChange={(e) => props.onUpdateTextProps(node.id, { linkHead: e.target.value })}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={node.props.link ?? ''}
|
||||||
|
onChange={(e) => props.onUpdateTextProps(node.id, { link: e.target.value })}
|
||||||
|
placeholder="example.com"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Border</Typography.Text>
|
||||||
|
<Space style={{ width: '100%' }} size={12}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.borderWidth ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonBefore="W"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 2 }}>
|
||||||
|
<Input
|
||||||
|
value={node.props.borderColor ?? '#ffffff'}
|
||||||
|
onChange={(e) => props.onUpdateTextProps(node.id, { borderColor: e.target.value })}
|
||||||
|
placeholder="#ffffff"
|
||||||
|
addonBefore="C"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 8, marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={node.props.borderRadius ?? 0}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => props.onUpdateTextProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonBefore="R"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary">Quick colors</Typography.Text>
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{['#ffffff', '#d1d5db', '#93c5fd', '#a7f3d0', '#fca5a5', '#fbbf24'].map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onUpdateTextProps(node.id, { color: c })}
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: c,
|
||||||
|
border: '1px solid rgba(0,0,0,0.2)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
title={c}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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