Compare commits
14 Commits
ca2636f489
...
e0d39b1a8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d39b1a8c | ||
| b859d1e3b6 | |||
| f96f77ad9c | |||
| 5d6aae77ef | |||
| a5ec482bd5 | |||
| 3e353c6322 | |||
| 5b708faf7b | |||
| de4243ca10 | |||
| b6f69b8d6a | |||
| 299326e060 | |||
| 98b47749e9 | |||
| 576ad74370 | |||
| 003f23ee78 | |||
| f416a393d8 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -22,3 +22,9 @@ Thumbs.db
|
|||||||
# docs build
|
# docs build
|
||||||
packages/docs/docs/.vitepress/cache
|
packages/docs/docs/.vitepress/cache
|
||||||
packages/docs/docs/.vitepress/dist
|
packages/docs/docs/.vitepress/dist
|
||||||
|
|
||||||
|
# TypeScript build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# local traces
|
||||||
|
trace.txt
|
||||||
|
|||||||
@ -7,7 +7,8 @@
|
|||||||
"lint": "pnpm -r lint",
|
"lint": "pnpm -r lint",
|
||||||
"test": "pnpm -r test",
|
"test": "pnpm -r test",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
"typecheck": "pnpm -r typecheck"
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"check": "pnpm lint && pnpm typecheck && pnpm build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.20.0",
|
"@eslint/js": "^9.20.0",
|
||||||
|
|||||||
@ -64,15 +64,15 @@ Represents one dashboard/screen.
|
|||||||
- Clipboard (copy/paste)
|
- Clipboard (copy/paste)
|
||||||
- History (command pattern)
|
- History (command pattern)
|
||||||
|
|
||||||
## Migration Plan (from go-view)
|
## Migration Plan (from legacy)
|
||||||
|
|
||||||
We will *not* transplant Vue UI. We will extract **behavior + data contracts** into `sdk`, then rebuild UI in `editor`.
|
We will *not* transplant Vue UI. We will extract **behavior + data contracts** into `sdk`, then rebuild UI in `editor`.
|
||||||
|
|
||||||
High-signal sources in go-view:
|
High-signal sources in the legacy codebase:
|
||||||
- `src/views/chart/ContentEdit/*` (drag, selection, guides, tools)
|
- `src/views/chart/ContentEdit/*` (drag, selection, guides, tools)
|
||||||
- `src/store/modules/chartEditStore` (editor state)
|
- `src/store/modules/chartEditStore` (editor state)
|
||||||
- `src/hooks/useChartDataFetch.hook.ts` (data fetch)
|
- `src/hooks/useChartDataFetch.hook.ts` (data fetch)
|
||||||
- `src/views/preview/*` (preview scaling + rendering)
|
- `src/views/preview/*` (preview scaling + rendering)
|
||||||
- `src/packages/*` (widget catalog + configs)
|
- `src/packages/*` (widget catalog + configs)
|
||||||
|
|
||||||
Next step: write a detailed mapping table (go-view module → sdk/editor target) and implement the first vertical slice: **canvas + one widget + import/export**.
|
Next step: write a detailed mapping table (legacy module → sdk/editor target) and implement the first vertical slice: **canvas + one widget + import/export**.
|
||||||
|
|||||||
@ -3,4 +3,4 @@
|
|||||||
- [Architecture](./architecture)
|
- [Architecture](./architecture)
|
||||||
- [Migration Plan](./migration)
|
- [Migration Plan](./migration)
|
||||||
|
|
||||||
This site documents the AstralView monorepo and the ongoing refactor from go-view.
|
This site documents the AstralView monorepo and the ongoing refactor from a legacy third-party editor codebase.
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# Migration Plan (go-view → AstralView)
|
# Migration Plan (Legacy → AstralView)
|
||||||
|
|
||||||
## Strategy
|
## Strategy
|
||||||
|
|
||||||
- Keep go-view as reference under `third_party/go-view`.
|
- Keep the legacy codebase as reference under `third_party/`.
|
||||||
- Extract domain and behaviors into `@astralview/sdk`.
|
- Extract domain and behaviors into `@astralview/sdk`.
|
||||||
- Rebuild UI in `@astralview/editor` (React + Ant Design).
|
- Rebuild UI in `@astralview/editor` (React + Ant Design).
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export interface CanvasProps {
|
|||||||
onBeginMove(e: React.PointerEvent): void;
|
onBeginMove(e: React.PointerEvent): void;
|
||||||
onUpdateMove(e: PointerEvent): void;
|
onUpdateMove(e: PointerEvent): void;
|
||||||
onEndMove(): void;
|
onEndMove(): void;
|
||||||
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number): void;
|
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number, additive: boolean): void;
|
||||||
onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void;
|
onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void;
|
||||||
onEndBoxSelect(): void;
|
onEndBoxSelect(): void;
|
||||||
onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): void;
|
onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): void;
|
||||||
@ -31,10 +31,14 @@ export interface CanvasProps {
|
|||||||
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
|
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
|
||||||
onDeleteSelected?(): void;
|
onDeleteSelected?(): void;
|
||||||
onDuplicateSelected?(): void;
|
onDuplicateSelected?(): void;
|
||||||
|
onToggleLockSelected?(): void;
|
||||||
|
onToggleHideSelected?(): void;
|
||||||
|
onBringToFrontSelected?(): void;
|
||||||
|
onSendToBackSelected?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
|
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
|
||||||
// align with goView: ignore middle click in box select
|
// Ignore middle click in box select.
|
||||||
return (e as PointerEvent).buttons === 1 || (e as PointerEvent).button === 0;
|
return (e as PointerEvent).buttons === 1 || (e as PointerEvent).button === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +57,31 @@ export function Canvas(props: CanvasProps) {
|
|||||||
|
|
||||||
const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]);
|
const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]);
|
||||||
|
|
||||||
|
const selection = useMemo(() => {
|
||||||
|
const ids = new Set(props.selectionIds);
|
||||||
|
return props.screen.nodes.filter((n) => ids.has(n.id));
|
||||||
|
}, [props.screen.nodes, props.selectionIds]);
|
||||||
|
|
||||||
|
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
|
||||||
|
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
|
||||||
|
|
||||||
|
const ctxMenuPos = useMemo(() => {
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
// Rough clamp so the menu stays inside the viewport.
|
||||||
|
// (We don't measure actual size to keep this simple + stable.)
|
||||||
|
const w = 220;
|
||||||
|
const h = 320;
|
||||||
|
|
||||||
|
const vw = typeof window === 'undefined' ? 10_000 : window.innerWidth;
|
||||||
|
const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(8, Math.min(ctx.clientX, vw - w - 8)),
|
||||||
|
y: Math.max(8, Math.min(ctx.clientY, vh - h - 8)),
|
||||||
|
};
|
||||||
|
}, [ctx]);
|
||||||
|
|
||||||
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
|
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
@ -163,10 +192,22 @@ export function Canvas(props: CanvasProps) {
|
|||||||
const p = clientToWorld(e.clientX, e.clientY);
|
const p = clientToWorld(e.clientX, e.clientY);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
|
|
||||||
if (targetId && !props.selectionIds.includes(targetId)) {
|
const additive = (e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey;
|
||||||
|
|
||||||
|
if (targetId) {
|
||||||
|
if (!props.selectionIds.includes(targetId)) {
|
||||||
// goView-ish: right click selects the item.
|
// goView-ish: right click selects the item.
|
||||||
|
// Ctrl/Cmd keeps multi-select parity (add to selection instead of replacing).
|
||||||
|
if (additive) {
|
||||||
|
props.onToggleSelect(targetId);
|
||||||
|
} else {
|
||||||
props.onSelectSingle(targetId);
|
props.onSelectSingle(targetId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Editor parity: right-click on empty space clears selection (unless additive).
|
||||||
|
if (!additive) props.onSelectSingle(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
|
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
|
||||||
};
|
};
|
||||||
@ -190,8 +231,9 @@ export function Canvas(props: CanvasProps) {
|
|||||||
const p = clientToWorld(e.clientX, e.clientY);
|
const p = clientToWorld(e.clientX, e.clientY);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
|
|
||||||
props.onSelectSingle(undefined);
|
const additive = e.ctrlKey || e.metaKey;
|
||||||
props.onBeginBoxSelect(e, p.x, p.y);
|
if (!additive) props.onSelectSingle(undefined);
|
||||||
|
props.onBeginBoxSelect(e, p.x, p.y, additive);
|
||||||
setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y }));
|
setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -246,12 +288,12 @@ export function Canvas(props: CanvasProps) {
|
|||||||
cursor: props.keyboard.space ? 'grab' : 'default',
|
cursor: props.keyboard.space ? 'grab' : 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ctx && (
|
{ctx && ctxMenuPos && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: ctx.clientX,
|
left: ctxMenuPos.x,
|
||||||
top: ctx.clientY,
|
top: ctxMenuPos.y,
|
||||||
zIndex: 10_000,
|
zIndex: 10_000,
|
||||||
background: '#111827',
|
background: '#111827',
|
||||||
border: '1px solid rgba(255,255,255,0.12)',
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
@ -283,6 +325,43 @@ export function Canvas(props: CanvasProps) {
|
|||||||
setCtx(null);
|
setCtx(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
label={selectionAllLocked ? 'Unlock' : 'Lock'}
|
||||||
|
disabled={!props.selectionIds.length || !props.onToggleLockSelected}
|
||||||
|
onClick={() => {
|
||||||
|
props.onToggleLockSelected?.();
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label={selectionAllHidden ? 'Show' : 'Hide'}
|
||||||
|
disabled={!props.selectionIds.length || !props.onToggleHideSelected}
|
||||||
|
onClick={() => {
|
||||||
|
props.onToggleHideSelected?.();
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
label="Bring To Front"
|
||||||
|
disabled={!props.selectionIds.length || !props.onBringToFrontSelected}
|
||||||
|
onClick={() => {
|
||||||
|
props.onBringToFrontSelected?.();
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Send To Back"
|
||||||
|
disabled={!props.selectionIds.length || !props.onSendToBackSelected}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSendToBackSelected?.();
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Delete"
|
label="Delete"
|
||||||
danger
|
danger
|
||||||
@ -348,6 +427,10 @@ export function Canvas(props: CanvasProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (node.locked) return;
|
if (node.locked) return;
|
||||||
|
|
||||||
|
// Right-click selection is handled by the contextmenu handler.
|
||||||
|
// Important: don't collapse a multi-selection on pointerdown.
|
||||||
|
if (e.button === 2) return;
|
||||||
|
|
||||||
// ctrl click: multi-select toggle
|
// ctrl click: multi-select toggle
|
||||||
if (props.keyboard.ctrl) {
|
if (props.keyboard.ctrl) {
|
||||||
props.onToggleSelect(node.id);
|
props.onToggleSelect(node.id);
|
||||||
@ -355,9 +438,6 @@ export function Canvas(props: CanvasProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
props.onSelectSingle(node.id);
|
props.onSelectSingle(node.id);
|
||||||
// right click should not start move
|
|
||||||
if (e.button === 2) return;
|
|
||||||
|
|
||||||
props.onBeginMove(e);
|
props.onBeginMove(e);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => openContextMenu(e, node.id)}
|
onContextMenu={(e) => openContextMenu(e, node.id)}
|
||||||
@ -495,9 +575,78 @@ function NodeView(props: {
|
|||||||
{node.props.text}
|
{node.props.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : node.type === 'image' ? (
|
||||||
<div>{node.type}</div>
|
<div
|
||||||
)}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: `${node.props.borderRadius ?? 0}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={node.props.src}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: node.props.fit ?? 'contain',
|
||||||
|
display: 'block',
|
||||||
|
// Editor parity: allow selecting/dragging the widget even when clicking the media.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : node.type === 'iframe' ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: `${node.props.borderRadius ?? 0}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src={node.props.src}
|
||||||
|
width={rect.w}
|
||||||
|
height={rect.h}
|
||||||
|
style={{
|
||||||
|
border: 0,
|
||||||
|
display: 'block',
|
||||||
|
// Editor parity: iframes steal pointer events; disable so selection/context menu works.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
title={node.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : node.type === 'video' ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: `${node.props.borderRadius ?? 0}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={node.props.src}
|
||||||
|
width={rect.w}
|
||||||
|
height={rect.h}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
loop={node.props.loop ?? false}
|
||||||
|
muted={node.props.muted ?? false}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: node.props.fit ?? 'contain',
|
||||||
|
// Editor parity: allow selecting/dragging even when clicking the video surface.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
|
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,22 +1,56 @@
|
|||||||
import { Button, Divider, Input, Layout, Space, Typography } from 'antd';
|
import {
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Layout,
|
||||||
|
Segmented,
|
||||||
|
Space,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||||
import { createInitialState, editorReducer, exportScreenJSON } from './store';
|
|
||||||
import { bindEditorHotkeys } from './hotkeys';
|
import { bindEditorHotkeys } from './hotkeys';
|
||||||
import { Canvas } from './Canvas';
|
import { Canvas } from './Canvas';
|
||||||
import { Inspector } from './Inspector';
|
import { Inspector } from './Inspector';
|
||||||
|
import { createInitialState, editorReducer, exportScreenJSON } from './store';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
type RightPanelTabKey = 'custom' | 'animation' | 'data' | 'event';
|
||||||
|
|
||||||
|
type LeftCategoryKey =
|
||||||
|
| 'components'
|
||||||
|
| 'charts'
|
||||||
|
| 'info'
|
||||||
|
| 'list'
|
||||||
|
| 'widgets'
|
||||||
|
| 'image'
|
||||||
|
| 'icon';
|
||||||
|
|
||||||
|
function PanelTitle(props: { title: string; extra?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12, letterSpacing: 0.6 }}>{props.title}</Typography.Text>
|
||||||
|
{props.extra}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EditorApp() {
|
export function EditorApp() {
|
||||||
const [state, dispatch] = useReducer(editorReducer, undefined, createInitialState);
|
const [state, dispatch] = useReducer(editorReducer, undefined, createInitialState);
|
||||||
const [importText, setImportText] = useState('');
|
const [importText, setImportText] = useState('');
|
||||||
|
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
|
||||||
|
const [rightTab, setRightTab] = useState<RightPanelTabKey>('custom');
|
||||||
|
|
||||||
|
const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
|
||||||
|
const hasSelection = state.selection.ids.length > 0;
|
||||||
|
|
||||||
const bounds = useMemo(
|
const bounds = useMemo(
|
||||||
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
|
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
|
||||||
[state.doc.screen.width, state.doc.screen.height],
|
[state.doc.screen.width, state.doc.screen.height],
|
||||||
);
|
);
|
||||||
|
|
||||||
// keyboard tracking (ctrl/space) — goView uses window.$KeyboardActive
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space });
|
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space });
|
||||||
@ -33,9 +67,6 @@ export function EditorApp() {
|
|||||||
};
|
};
|
||||||
}, [state.keyboard.space]);
|
}, [state.keyboard.space]);
|
||||||
|
|
||||||
// Canvas handles its own context menu.
|
|
||||||
|
|
||||||
// Hotkeys (goView-like)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return bindEditorHotkeys(() => false, dispatch);
|
return bindEditorHotkeys(() => false, dispatch);
|
||||||
}, []);
|
}, []);
|
||||||
@ -45,72 +76,205 @@ export function EditorApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh', background: '#0b1220' }}>
|
||||||
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Header
|
||||||
<Typography.Title level={4} style={{ margin: 0, color: '#fff' }}>
|
style={{
|
||||||
AstralView Editor
|
height: 44,
|
||||||
</Typography.Title>
|
padding: '0 12px',
|
||||||
<Space>
|
display: 'flex',
|
||||||
<Button onClick={() => dispatch({ type: 'undo' })} disabled={!state.history.past.length}>
|
alignItems: 'center',
|
||||||
Undo
|
justifyContent: 'space-between',
|
||||||
|
background: '#0f172a',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={10}>
|
||||||
|
<Button size="small" type="text" style={{ color: '#cbd5e1' }}>
|
||||||
|
Home
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => dispatch({ type: 'redo' })} disabled={!state.history.future.length}>
|
<Space size={6}>
|
||||||
Redo
|
<Button size="small" type="text" style={{ color: '#94a3b8' }}>
|
||||||
|
◀
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="text" style={{ color: '#94a3b8' }}>
|
||||||
|
▶
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space size={8}>
|
||||||
|
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}>Workspace</Typography.Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 22,
|
||||||
|
padding: '0 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" style={{ background: 'rgba(255,255,255,0.06)', color: '#e2e8f0', border: 'none' }}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary">
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
{/* Left icon rail */}
|
||||||
|
<Sider
|
||||||
|
width={56}
|
||||||
|
theme="dark"
|
||||||
|
style={{
|
||||||
|
background: '#111827',
|
||||||
|
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
paddingTop: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '0 8px' }}>
|
||||||
|
{([
|
||||||
|
['components', '组件'],
|
||||||
|
['charts', '图表'],
|
||||||
|
['info', '信息'],
|
||||||
|
['list', '列表'],
|
||||||
|
['widgets', '小组件'],
|
||||||
|
['image', '图片'],
|
||||||
|
['icon', '图标'],
|
||||||
|
] as Array<[LeftCategoryKey, string]>).map(([key, label]) => {
|
||||||
|
const active = leftCategory === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLeftCategory(key)}
|
||||||
|
style={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
background: active ? 'rgba(16,185,129,0.12)' : 'transparent',
|
||||||
|
color: active ? '#a7f3d0' : '#cbd5e1',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label.slice(0, 1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
{/* Left library panel */}
|
||||||
|
<Sider
|
||||||
|
width={300}
|
||||||
|
theme="dark"
|
||||||
|
style={{
|
||||||
|
background: '#111827',
|
||||||
|
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
padding: 12,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PanelTitle title="组件" />
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="搜索组件"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.10)', color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Segmented
|
||||||
|
size="small"
|
||||||
|
value="all"
|
||||||
|
options={[
|
||||||
|
{ label: '所有', value: 'all' },
|
||||||
|
{ label: '常用', value: 'common' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder: we will replace with real registry-based catalog */}
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ title: '柱状图', subtitle: 'Chart' },
|
||||||
|
{ title: '折线图', subtitle: 'Chart' },
|
||||||
|
{ title: '图片', subtitle: 'Media' },
|
||||||
|
{ title: '视频', subtitle: 'Media' },
|
||||||
|
{ title: '网页', subtitle: 'Embed' },
|
||||||
|
{ title: '文本', subtitle: 'Text' },
|
||||||
|
].map((it) => (
|
||||||
|
<div
|
||||||
|
key={it.title}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
padding: 10,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ color: '#e2e8f0', fontSize: 13 }}>{it.title}</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: 12 }}>{it.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
<PanelTitle title="导入 / 导出" />
|
||||||
|
<Input.TextArea
|
||||||
|
value={importText}
|
||||||
|
onChange={(e) => setImportText(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder="粘贴 JSON"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.10)', color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<Space style={{ marginTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => dispatch({ type: 'importJSON', json: importText })}
|
||||||
|
disabled={!importText.trim()}
|
||||||
|
>
|
||||||
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const json = api.exportJSON();
|
const json = api.exportJSON();
|
||||||
setImportText(json);
|
setImportText(json);
|
||||||
void navigator.clipboard?.writeText(json);
|
void navigator.clipboard?.writeText(json);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Export JSON (copy)
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<Sider width={360} theme="light" style={{ padding: 16, overflow: 'auto' }}>
|
|
||||||
<Typography.Title level={5}>Inspector</Typography.Title>
|
|
||||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
|
||||||
Selected: {state.selection.ids.length ? state.selection.ids.join(', ') : 'None'}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
|
|
||||||
<Inspector
|
|
||||||
selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])}
|
|
||||||
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography.Title level={5}>Import/Export</Typography.Title>
|
|
||||||
<Input.TextArea
|
|
||||||
value={importText}
|
|
||||||
onChange={(e) => setImportText(e.target.value)}
|
|
||||||
rows={10}
|
|
||||||
placeholder="Paste screen JSON here"
|
|
||||||
/>
|
|
||||||
<Space style={{ marginTop: 8 }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={() => {
|
|
||||||
dispatch({ type: 'importJSON', json: importText });
|
|
||||||
}}
|
|
||||||
disabled={!importText.trim()}
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setImportText(api.exportJSON())}>Load current</Button>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<Typography.Title level={5}>Notes</Typography.Title>
|
|
||||||
<Typography.Paragraph style={{ color: '#666' }}>
|
|
||||||
Interactions target goView semantics: Ctrl multi-select, box-select full containment, scale-aware movement.
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
<Content style={{ padding: 16, overflow: 'auto' }}>
|
{/* Center */}
|
||||||
|
<Content style={{ padding: 12, overflow: 'hidden', background: '#0b1220' }}>
|
||||||
<Canvas
|
<Canvas
|
||||||
screen={state.doc.screen}
|
screen={state.doc.screen}
|
||||||
selectionIds={state.selection.ids}
|
selectionIds={state.selection.ids}
|
||||||
@ -123,42 +287,128 @@ export function EditorApp() {
|
|||||||
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
|
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
|
||||||
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
||||||
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
||||||
onBeginPan={(e) => {
|
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
|
||||||
dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } });
|
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
|
||||||
}}
|
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
|
||||||
onUpdatePan={(e: PointerEvent) => {
|
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
|
||||||
dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } });
|
onBeginPan={(e) => dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } })}
|
||||||
}}
|
onUpdatePan={(e: PointerEvent) => dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } })}
|
||||||
onEndPan={() => dispatch({ type: 'endPan' })}
|
onEndPan={() => dispatch({ type: 'endPan' })}
|
||||||
onBeginMove={(e) => {
|
onBeginMove={(e) => dispatch({ type: 'beginMove', start: { screenX: e.screenX, screenY: e.screenY }, bounds })}
|
||||||
dispatch({ type: 'beginMove', start: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
onUpdateMove={(e: PointerEvent) => dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds })}
|
||||||
}}
|
|
||||||
onUpdateMove={(e: PointerEvent) => {
|
|
||||||
dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
|
||||||
}}
|
|
||||||
onEndMove={() => dispatch({ type: 'endMove' })}
|
onEndMove={() => dispatch({ type: 'endMove' })}
|
||||||
onBeginBoxSelect={(e, offsetX, offsetY) => {
|
onBeginBoxSelect={(e, offsetX, offsetY, additive) =>
|
||||||
dispatch({ type: 'beginBoxSelect', start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });
|
dispatch({
|
||||||
}}
|
type: 'beginBoxSelect',
|
||||||
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => {
|
additive,
|
||||||
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });
|
start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY },
|
||||||
}}
|
})
|
||||||
|
}
|
||||||
|
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) =>
|
||||||
|
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } })
|
||||||
|
}
|
||||||
onEndBoxSelect={() => dispatch({ type: 'endBoxSelect' })}
|
onEndBoxSelect={() => dispatch({ type: 'endBoxSelect' })}
|
||||||
onBeginResize={(e, id, handle) => {
|
onBeginResize={(e, id, handle) =>
|
||||||
dispatch({ type: 'beginResize', id, handle, start: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
dispatch({ type: 'beginResize', id, handle, start: { screenX: e.screenX, screenY: e.screenY }, bounds })
|
||||||
}}
|
}
|
||||||
onUpdateResize={(e: PointerEvent) => {
|
onUpdateResize={(e: PointerEvent) => dispatch({ type: 'updateResize', current: { screenX: e.screenX, screenY: e.screenY }, bounds })}
|
||||||
dispatch({ type: 'updateResize', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
|
||||||
}}
|
|
||||||
onEndResize={() => dispatch({ type: 'endResize' })}
|
onEndResize={() => dispatch({ type: 'endResize' })}
|
||||||
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
||||||
onWheelPan={(dx, dy) => dispatch({ type: 'panBy', dx, dy })}
|
onWheelPan={(dx, dy) => dispatch({ type: 'panBy', dx, dy })}
|
||||||
onZoomAt={(scale, anchorX, anchorY) => {
|
onZoomAt={(scale, anchorX, anchorY) => dispatch({ type: 'zoomAt', scale: Math.max(0.1, Math.min(4, scale)), anchor: { x: anchorX, y: anchorY } })}
|
||||||
const next = Math.max(0.1, Math.min(4, scale));
|
|
||||||
dispatch({ type: 'zoomAt', scale: next, anchor: { x: anchorX, y: anchorY } });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
|
{/* Right panel */}
|
||||||
|
<Sider
|
||||||
|
width={360}
|
||||||
|
theme="dark"
|
||||||
|
style={{
|
||||||
|
background: '#111827',
|
||||||
|
borderLeft: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
padding: 12,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasSelection ? (
|
||||||
|
<>
|
||||||
|
<Tabs
|
||||||
|
activeKey={rightTab}
|
||||||
|
onChange={(k) => setRightTab(k as RightPanelTabKey)}
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{ key: 'custom', label: '定制', children: null },
|
||||||
|
{ key: 'animation', label: '动画', children: null },
|
||||||
|
{ key: 'data', label: '数据', children: null },
|
||||||
|
{ key: 'event', label: '事件', children: null },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rightTab === 'custom' ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<PanelTitle
|
||||||
|
title="组件配置"
|
||||||
|
extra={
|
||||||
|
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>
|
||||||
|
{state.selection.ids.length}/12
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '64px 1fr', gap: 10, alignItems: 'center' }}>
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>名称</Typography.Text>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={selected?.type ?? ''}
|
||||||
|
disabled
|
||||||
|
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.10)', color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>尺寸</Typography.Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<InputNumber size="small" value={selected?.rect.w ?? 0} controls={false} readOnly style={{ width: 120 }} />
|
||||||
|
<InputNumber size="small" value={selected?.rect.h ?? 0} controls={false} readOnly style={{ width: 120 }} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>位置</Typography.Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<InputNumber size="small" value={selected?.rect.x ?? 0} controls={false} readOnly style={{ width: 120 }} />
|
||||||
|
<InputNumber size="small" value={selected?.rect.y ?? 0} controls={false} readOnly style={{ width: 120 }} />
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
|
||||||
|
<Inspector
|
||||||
|
selected={selected}
|
||||||
|
onUpdateTextProps={(id, props) => 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 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography.Paragraph style={{ color: '#64748b' }}>占位:该面板将在后续补齐。</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PanelTitle title="页面配置" />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '64px 1fr', gap: 10, alignItems: 'center' }}>
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>宽度</Typography.Text>
|
||||||
|
<InputNumber size="small" value={state.doc.screen.width} controls={false} readOnly style={{ width: 160 }} />
|
||||||
|
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>高度</Typography.Text>
|
||||||
|
<InputNumber size="small" value={state.doc.screen.height} controls={false} readOnly style={{ width: 160 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
<Typography.Paragraph style={{ color: '#64748b' }}>占位:页面背景 / 主题 / 配色将在后续补齐。</Typography.Paragraph>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sider>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
import { Input, InputNumber, Select, Space, Typography } from 'antd';
|
import { Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||||
import type { TextWidgetNode, WidgetNode } from '@astralview/sdk';
|
import type { WidgetNode, TextWidgetNode } from '@astralview/sdk';
|
||||||
|
|
||||||
|
type ImageWidgetNode = Extract<WidgetNode, { type: 'image' }>;
|
||||||
|
type IframeWidgetNode = Extract<WidgetNode, { type: 'iframe' }>;
|
||||||
|
type VideoWidgetNode = Extract<WidgetNode, { type: 'video' }>;
|
||||||
|
|
||||||
export function Inspector(props: {
|
export function Inspector(props: {
|
||||||
selected?: WidgetNode;
|
selected?: WidgetNode;
|
||||||
onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void;
|
onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void;
|
||||||
|
onUpdateImageProps: (id: string, patch: Partial<ImageWidgetNode['props']>) => void;
|
||||||
|
onUpdateIframeProps: (id: string, patch: Partial<IframeWidgetNode['props']>) => void;
|
||||||
|
onUpdateVideoProps: (id: string, patch: Partial<VideoWidgetNode['props']>) => void;
|
||||||
}) {
|
}) {
|
||||||
const node = props.selected;
|
const node = props.selected;
|
||||||
|
|
||||||
@ -11,8 +18,143 @@ 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') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
Image
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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') {
|
if (node.type !== 'text') {
|
||||||
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type: {node.type}</Typography.Paragraph>;
|
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type.</Typography.Paragraph>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontWeight = node.props.fontWeight ?? 400;
|
const fontWeight = node.props.fontWeight ?? 400;
|
||||||
@ -144,7 +286,7 @@ export function Inspector(props: {
|
|||||||
style={{ width: '100%', marginBottom: 12 }}
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'horizontal-tb', label: 'Horizontal' },
|
{ value: 'horizontal-tb', label: 'Horizontal' },
|
||||||
{ value: 'vertical-rl', label: 'Vertical' }
|
{ value: 'vertical-rl', label: 'Vertical' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,9 @@ 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,
|
||||||
@ -25,7 +28,11 @@ export type EditorAction =
|
|||||||
| { type: 'endPan' }
|
| { type: 'endPan' }
|
||||||
| { type: 'selectSingle'; id?: string }
|
| { type: 'selectSingle'; id?: string }
|
||||||
| { type: 'toggleSelect'; id: string }
|
| { type: 'toggleSelect'; id: string }
|
||||||
| { type: 'beginBoxSelect'; start: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
|
| {
|
||||||
|
type: 'beginBoxSelect';
|
||||||
|
additive: boolean;
|
||||||
|
start: { offsetX: number; offsetY: number; screenX: number; screenY: number };
|
||||||
|
}
|
||||||
| { type: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
|
| { type: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
|
||||||
| { type: 'endBoxSelect' }
|
| { type: 'endBoxSelect' }
|
||||||
| { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
|
| { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
|
||||||
@ -39,7 +46,14 @@ export type EditorAction =
|
|||||||
| { type: 'deleteSelected' }
|
| { type: 'deleteSelected' }
|
||||||
| { type: 'nudgeSelected'; dx: number; dy: number }
|
| { type: 'nudgeSelected'; dx: number; dy: number }
|
||||||
| { type: 'duplicateSelected' }
|
| { type: 'duplicateSelected' }
|
||||||
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> };
|
| { type: 'toggleLockSelected' }
|
||||||
|
| { type: 'toggleHideSelected' }
|
||||||
|
| { type: 'bringToFrontSelected' }
|
||||||
|
| { type: 'sendToBackSelected' }
|
||||||
|
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
|
||||||
|
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> }
|
||||||
|
| { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> }
|
||||||
|
| { type: 'updateVideoProps'; id: string; props: Partial<VideoWidgetNode['props']> };
|
||||||
|
|
||||||
interface DragSession {
|
interface DragSession {
|
||||||
kind: 'move' | 'resize';
|
kind: 'move' | 'resize';
|
||||||
@ -198,6 +212,57 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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': {
|
||||||
if (!state.selection.ids.length) return state;
|
if (!state.selection.ids.length) return state;
|
||||||
const ids = new Set(state.selection.ids);
|
const ids = new Set(state.selection.ids);
|
||||||
@ -259,6 +324,101 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'toggleLockSelected': {
|
||||||
|
if (!state.selection.ids.length) return state;
|
||||||
|
const ids = new Set(state.selection.ids);
|
||||||
|
const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id));
|
||||||
|
if (!selected.length) return state;
|
||||||
|
|
||||||
|
const shouldLock = selected.some((n) => !n.locked);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...historyPush(state),
|
||||||
|
doc: {
|
||||||
|
screen: {
|
||||||
|
...state.doc.screen,
|
||||||
|
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, locked: shouldLock } : n)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toggleHideSelected': {
|
||||||
|
if (!state.selection.ids.length) return state;
|
||||||
|
const ids = new Set(state.selection.ids);
|
||||||
|
const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id));
|
||||||
|
if (!selected.length) return state;
|
||||||
|
|
||||||
|
const shouldHide = selected.some((n) => !n.hidden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...historyPush(state),
|
||||||
|
doc: {
|
||||||
|
screen: {
|
||||||
|
...state.doc.screen,
|
||||||
|
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selection: shouldHide ? { ids: [] } : state.selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bringToFrontSelected': {
|
||||||
|
if (!state.selection.ids.length) return state;
|
||||||
|
const ids = new Set(state.selection.ids);
|
||||||
|
const nodes = state.doc.screen.nodes;
|
||||||
|
|
||||||
|
let maxZ = 0;
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const n = nodes[i]!;
|
||||||
|
const z = n.zIndex ?? i;
|
||||||
|
if (z > maxZ) maxZ = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bump = 0;
|
||||||
|
return {
|
||||||
|
...historyPush(state),
|
||||||
|
doc: {
|
||||||
|
screen: {
|
||||||
|
...state.doc.screen,
|
||||||
|
nodes: nodes.map((n) => {
|
||||||
|
if (!ids.has(n.id)) return n;
|
||||||
|
bump += 1;
|
||||||
|
return { ...n, zIndex: maxZ + bump };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sendToBackSelected': {
|
||||||
|
if (!state.selection.ids.length) return state;
|
||||||
|
const ids = new Set(state.selection.ids);
|
||||||
|
const nodes = state.doc.screen.nodes;
|
||||||
|
|
||||||
|
let minZ = 0;
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const n = nodes[i]!;
|
||||||
|
const z = n.zIndex ?? i;
|
||||||
|
if (z < minZ) minZ = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bump = 0;
|
||||||
|
return {
|
||||||
|
...historyPush(state),
|
||||||
|
doc: {
|
||||||
|
screen: {
|
||||||
|
...state.doc.screen,
|
||||||
|
nodes: nodes.map((n) => {
|
||||||
|
if (!ids.has(n.id)) return n;
|
||||||
|
bump += 1;
|
||||||
|
return { ...n, zIndex: minZ - bump };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'beginPan': {
|
case 'beginPan': {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -319,6 +479,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
|
|
||||||
case 'beginBoxSelect': {
|
case 'beginBoxSelect': {
|
||||||
if (action.start.screenX === 0 && action.start.screenY === 0) return state;
|
if (action.start.screenX === 0 && action.start.screenY === 0) return state;
|
||||||
|
|
||||||
|
const baseIds = action.additive ? state.selection.ids : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
canvas: {
|
canvas: {
|
||||||
@ -336,8 +499,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
screenY: action.start.screenY,
|
screenY: action.start.screenY,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
selection: { ids: [] },
|
selection: { ids: baseIds },
|
||||||
};
|
__boxSelect: { additive: action.additive, baseIds },
|
||||||
|
} as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'updateBoxSelect': {
|
case 'updateBoxSelect': {
|
||||||
@ -358,9 +522,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
if (rectContains(box, node.rect)) selected.push(node.id);
|
if (rectContains(box, node.rect)) selected.push(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boxState = (state as EditorState & { __boxSelect?: { additive: boolean; baseIds: string[] } }).__boxSelect;
|
||||||
|
const ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selection: { ids: selected },
|
selection: { ids },
|
||||||
canvas: {
|
canvas: {
|
||||||
...state.canvas,
|
...state.canvas,
|
||||||
mouse: {
|
mouse: {
|
||||||
@ -376,8 +543,10 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
|
|
||||||
case 'endBoxSelect': {
|
case 'endBoxSelect': {
|
||||||
if (!state.canvas.isBoxSelecting) return state;
|
if (!state.canvas.isBoxSelecting) return state;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { __boxSelect: _box, ...rest } = state as EditorState & { __boxSelect?: unknown };
|
||||||
return {
|
return {
|
||||||
...state,
|
...rest,
|
||||||
canvas: {
|
canvas: {
|
||||||
...state.canvas,
|
...state.canvas,
|
||||||
isBoxSelecting: false,
|
isBoxSelecting: false,
|
||||||
|
|||||||
@ -4,4 +4,19 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// Keep bundle sizes manageable (and make caching better) by splitting common deps.
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) return
|
||||||
|
|
||||||
|
// Keep this match tight: many packages contain "react" in their name.
|
||||||
|
if (/node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) return 'react'
|
||||||
|
|
||||||
|
return 'vendor'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import type { Screen } from './schema';
|
import type { Screen } from './schema';
|
||||||
|
import { convertGoViewProjectToScreen } from './goview/convert';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* goView JSON converter (stub).
|
* goView JSON converter.
|
||||||
*
|
*
|
||||||
* The legacy goView format isn't implemented yet; this is a placeholder so we can
|
* We keep this permissive because goView exports / storage snapshots vary across
|
||||||
* start wiring UI + migration flows without committing to the full mapping.
|
* versions and forks. The heavy lifting lives in `goview/convert.ts`.
|
||||||
*/
|
*/
|
||||||
export function convertGoViewJSONToScreen(input: unknown): Screen {
|
export function convertGoViewJSONToScreen(input: unknown): Screen {
|
||||||
// keep reference to avoid unused-vars lint until implemented
|
if (!input || typeof input !== 'object') {
|
||||||
void input;
|
throw new Error('convertGoViewJSONToScreen: expected object');
|
||||||
throw new Error('convertGoViewJSONToScreen: not implemented yet');
|
}
|
||||||
|
|
||||||
|
return convertGoViewProjectToScreen(input as unknown as object);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,52 +1,304 @@
|
|||||||
import { ASTRALVIEW_SCHEMA_VERSION, createEmptyScreen, type Screen, type TextWidgetNode } from '../schema';
|
import {
|
||||||
|
ASTRALVIEW_SCHEMA_VERSION,
|
||||||
|
createEmptyScreen,
|
||||||
|
type ImageWidgetNode,
|
||||||
|
type IframeWidgetNode,
|
||||||
|
type Screen,
|
||||||
|
type TextWidgetNode,
|
||||||
|
type VideoWidgetNode,
|
||||||
|
} from '../schema';
|
||||||
|
import { convertGoViewImageOptionToNodeProps, type GoViewImageOption } from '../widgets/image';
|
||||||
|
import { convertGoViewIframeOptionToNodeProps, type GoViewIframeOption } from '../widgets/iframe';
|
||||||
|
import { convertGoViewVideoOptionToNodeProps, type GoViewVideoOption } from '../widgets/video';
|
||||||
import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text';
|
import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text';
|
||||||
|
import { pickUrlLike } from '../widgets/urlLike';
|
||||||
|
|
||||||
export interface GoViewComponentLike {
|
export interface GoViewComponentLike {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
||||||
|
// some exports wrap the actual component under a nested field
|
||||||
|
component?: GoViewComponentLike;
|
||||||
|
|
||||||
|
// component identity
|
||||||
|
key?: string; // e.g. "TextCommon" (sometimes)
|
||||||
|
componentKey?: string;
|
||||||
|
chartConfig?: {
|
||||||
key?: string;
|
key?: string;
|
||||||
|
chartKey?: string;
|
||||||
|
type?: string;
|
||||||
|
option?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// geometry
|
||||||
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
|
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
|
||||||
|
|
||||||
|
// state
|
||||||
status?: { lock?: boolean; hide?: boolean };
|
status?: { lock?: boolean; hide?: boolean };
|
||||||
|
|
||||||
|
// widget-specific config
|
||||||
option?: unknown;
|
option?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoViewEditCanvasConfigLike {
|
||||||
|
projectName?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
background?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoViewStorageLike {
|
||||||
|
editCanvasConfig?: GoViewEditCanvasConfigLike;
|
||||||
|
componentList?: GoViewComponentLike[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GoViewProjectLike {
|
export interface GoViewProjectLike {
|
||||||
// very loose input shape; goView has different versions/branches.
|
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
canvas?: { width?: number; height?: number };
|
canvas?: { width?: number; height?: number };
|
||||||
componentList?: GoViewComponentLike[];
|
componentList?: GoViewComponentLike[];
|
||||||
|
|
||||||
|
// persisted store shape (some variants)
|
||||||
|
editCanvasConfig?: GoViewEditCanvasConfigLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertGoViewProjectToScreen(input: GoViewProjectLike): Screen {
|
function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike {
|
||||||
const width = input.canvas?.width ?? input.width ?? 1920;
|
// Prefer the nested component shape but keep outer fields as fallback.
|
||||||
const height = input.canvas?.height ?? input.height ?? 1080;
|
// This handles exports like: { id, attr, component: { chartConfig, option } }
|
||||||
|
const inner = c.component;
|
||||||
|
if (!inner) return c;
|
||||||
|
return {
|
||||||
|
// Prefer outer for geometry/id, but prefer inner for identity/option when present.
|
||||||
|
...c,
|
||||||
|
...inner,
|
||||||
|
// ensure the nested component doesn't get lost
|
||||||
|
component: inner.component,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(cIn: GoViewComponentLike): string {
|
||||||
|
const c = unwrapComponent(cIn);
|
||||||
|
return (
|
||||||
|
c.chartConfig?.key ??
|
||||||
|
c.chartConfig?.chartKey ??
|
||||||
|
c.chartConfig?.type ??
|
||||||
|
c.componentKey ??
|
||||||
|
c.key ??
|
||||||
|
''
|
||||||
|
).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextCommon(c: GoViewComponentLike): boolean {
|
||||||
|
const k = keyOf(c);
|
||||||
|
if (k === 'textcommon') return true;
|
||||||
|
return k.includes('text');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(c: GoViewComponentLike): boolean {
|
||||||
|
const k = keyOf(c);
|
||||||
|
// goView variants: "Image", "image", sometimes with suffixes.
|
||||||
|
return k === 'image' || k.includes('image') || k.includes('picture');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIframe(c: GoViewComponentLike): boolean {
|
||||||
|
const k = keyOf(c);
|
||||||
|
// goView variants: "Iframe", "IframeCommon", etc.
|
||||||
|
if (k === 'iframe' || k.includes('iframe')) return true;
|
||||||
|
|
||||||
|
// Other names seen in low-code editors for embedded web content.
|
||||||
|
return k.includes('embed') || k.includes('web') || k.includes('webview') || k.includes('html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideo(c: GoViewComponentLike): boolean {
|
||||||
|
const k = keyOf(c);
|
||||||
|
// goView variants: "Video", "VideoCommon", etc.
|
||||||
|
if (k === 'video' || k.includes('video')) return true;
|
||||||
|
|
||||||
|
// Other names seen in the wild.
|
||||||
|
return k.includes('mp4') || k.includes('media') || k.includes('player') || k.includes('stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeIframeOption(option: unknown): boolean {
|
||||||
|
if (!option || typeof option !== 'object') return false;
|
||||||
|
const o = option as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Prefer explicit iframe-ish keys.
|
||||||
|
if ('iframeUrl' in o || 'iframeSrc' in o || 'embedUrl' in o) return true;
|
||||||
|
|
||||||
|
const url = pickUrlLike(option);
|
||||||
|
if (!url) return false;
|
||||||
|
|
||||||
|
// If it isn't an obvious media URL, it's often an iframe/embed.
|
||||||
|
// (We deliberately keep this conservative; image/video are handled earlier.)
|
||||||
|
return /^https?:\/\//i.test(url) && !/\.(mp4|m3u8|flv)(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeVideoOption(option: unknown): boolean {
|
||||||
|
if (!option || typeof option !== 'object') return false;
|
||||||
|
const o = option as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Prefer explicit video-ish keys.
|
||||||
|
if ('videoUrl' in o || 'videoSrc' in o || 'mp4' in o || 'm3u8' in o || 'flv' in o || 'hls' in o || 'rtsp' in o) return true;
|
||||||
|
|
||||||
|
const url = pickUrlLike(option);
|
||||||
|
if (!url) return false;
|
||||||
|
|
||||||
|
// Common direct URL patterns.
|
||||||
|
if (/\.(mp4|m3u8|flv)(\?|#|$)/i.test(url)) return true;
|
||||||
|
if (/\bm3u8\b/i.test(url)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(...values: Array<T | undefined | null>): T | undefined {
|
||||||
|
for (const v of values) {
|
||||||
|
if (v !== undefined && v !== null) return v as T;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionOf(cIn: GoViewComponentLike): unknown {
|
||||||
|
const c = unwrapComponent(cIn);
|
||||||
|
const chartData = c.chartConfig?.data as Record<string, unknown> | undefined;
|
||||||
|
return (
|
||||||
|
c.option ??
|
||||||
|
c.chartConfig?.option ??
|
||||||
|
chartData?.option ??
|
||||||
|
chartData?.options ??
|
||||||
|
chartData?.config ??
|
||||||
|
chartData ??
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(v: unknown, fallback: number): number {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen {
|
||||||
|
// goView exports vary a lot; attempt a few common nesting shapes.
|
||||||
|
const root = input as unknown as Record<string, unknown>;
|
||||||
|
const data = (root.data as Record<string, unknown> | undefined) ?? undefined;
|
||||||
|
const state = (root.state as Record<string, unknown> | undefined) ?? undefined;
|
||||||
|
const project = (root.project as Record<string, unknown> | undefined) ?? undefined;
|
||||||
|
|
||||||
|
const editCanvasConfig = pick<GoViewEditCanvasConfigLike>(
|
||||||
|
(input as GoViewStorageLike).editCanvasConfig,
|
||||||
|
data?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
|
||||||
|
state?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
|
||||||
|
project?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const width =
|
||||||
|
editCanvasConfig?.width ??
|
||||||
|
(input as GoViewProjectLike).canvas?.width ??
|
||||||
|
(input as GoViewProjectLike).width ??
|
||||||
|
(data?.width as number | undefined) ??
|
||||||
|
1920;
|
||||||
|
|
||||||
|
const height =
|
||||||
|
editCanvasConfig?.height ??
|
||||||
|
(input as GoViewProjectLike).canvas?.height ??
|
||||||
|
(input as GoViewProjectLike).height ??
|
||||||
|
(data?.height as number | undefined) ??
|
||||||
|
1080;
|
||||||
|
|
||||||
|
const name = editCanvasConfig?.projectName ?? 'Imported Project';
|
||||||
|
const background = editCanvasConfig?.background;
|
||||||
|
|
||||||
const screen = createEmptyScreen({
|
const screen = createEmptyScreen({
|
||||||
version: ASTRALVIEW_SCHEMA_VERSION,
|
version: ASTRALVIEW_SCHEMA_VERSION,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
name: 'Imported from goView',
|
name,
|
||||||
|
background: background ? { color: background } : undefined,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodes: TextWidgetNode[] = [];
|
const componentList =
|
||||||
for (const c of input.componentList ?? []) {
|
(input as GoViewStorageLike).componentList ??
|
||||||
// Only first: TextCommon-like
|
(input as GoViewProjectLike).componentList ??
|
||||||
const key = c.key ?? '';
|
(data?.componentList as GoViewComponentLike[] | undefined) ??
|
||||||
if (!/text/i.test(key)) continue;
|
(state?.componentList as GoViewComponentLike[] | undefined) ??
|
||||||
|
(project?.componentList as GoViewComponentLike[] | undefined) ??
|
||||||
|
[];
|
||||||
|
|
||||||
const rect = c.attr ? { x: c.attr.x, y: c.attr.y, w: c.attr.w, h: c.attr.h } : { x: 0, y: 0, w: 320, h: 60 };
|
const nodes: Array<TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode> = [];
|
||||||
const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) as GoViewTextOption);
|
|
||||||
|
|
||||||
|
for (const raw of componentList) {
|
||||||
|
const c = unwrapComponent(raw);
|
||||||
|
|
||||||
|
const rect = c.attr
|
||||||
|
? {
|
||||||
|
x: toNumber((c.attr as unknown as Record<string, unknown>).x, 0),
|
||||||
|
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 0),
|
||||||
|
w: toNumber((c.attr as unknown as Record<string, unknown>).w, 320),
|
||||||
|
h: toNumber((c.attr as unknown as Record<string, unknown>).h, 60),
|
||||||
|
}
|
||||||
|
: { x: 0, y: 0, w: 320, h: 60 };
|
||||||
|
|
||||||
|
if (isTextCommon(c)) {
|
||||||
|
const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption);
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: c.id ?? `goview_text_${Math.random().toString(16).slice(2)}`,
|
id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
rect,
|
rect,
|
||||||
zIndex: c.attr?.zIndex,
|
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||||
locked: c.status?.lock ?? false,
|
locked: c.status?.lock ?? false,
|
||||||
hidden: c.status?.hide ?? false,
|
hidden: c.status?.hide ?? false,
|
||||||
props,
|
props,
|
||||||
});
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImage(c)) {
|
||||||
|
const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption);
|
||||||
|
nodes.push({
|
||||||
|
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
|
||||||
|
type: 'image',
|
||||||
|
rect,
|
||||||
|
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||||
|
locked: c.status?.lock ?? false,
|
||||||
|
hidden: c.status?.hide ?? false,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = optionOf(c);
|
||||||
|
|
||||||
|
if (isIframe(c) || looksLikeIframeOption(option)) {
|
||||||
|
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
|
||||||
|
nodes.push({
|
||||||
|
id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
|
||||||
|
type: 'iframe',
|
||||||
|
rect,
|
||||||
|
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||||
|
locked: c.status?.lock ?? false,
|
||||||
|
hidden: c.status?.hide ?? false,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideo(c) || looksLikeVideoOption(option)) {
|
||||||
|
const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption);
|
||||||
|
nodes.push({
|
||||||
|
id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`,
|
||||||
|
type: 'video',
|
||||||
|
rect,
|
||||||
|
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
|
||||||
|
locked: c.status?.lock ?? false,
|
||||||
|
hidden: c.status?.hide ?? false,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export interface TextWidgetNode extends WidgetNodeBase {
|
|||||||
color?: string;
|
color?: string;
|
||||||
fontWeight?: number | string;
|
fontWeight?: number | string;
|
||||||
|
|
||||||
// goView parity (TextCommon)
|
// legacy parity (TextCommon)
|
||||||
paddingX?: number;
|
paddingX?: number;
|
||||||
paddingY?: number;
|
paddingY?: number;
|
||||||
letterSpacing?: number;
|
letterSpacing?: number;
|
||||||
@ -51,7 +51,35 @@ export interface TextWidgetNode extends WidgetNodeBase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WidgetNode = TextWidgetNode;
|
export interface ImageWidgetNode extends WidgetNodeBase {
|
||||||
|
type: 'image';
|
||||||
|
props: {
|
||||||
|
src: string;
|
||||||
|
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||||
|
borderRadius?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IframeWidgetNode extends WidgetNodeBase {
|
||||||
|
type: 'iframe';
|
||||||
|
props: {
|
||||||
|
src: string;
|
||||||
|
borderRadius?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoWidgetNode extends WidgetNodeBase {
|
||||||
|
type: 'video';
|
||||||
|
props: {
|
||||||
|
src: string;
|
||||||
|
loop?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||||
|
borderRadius?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidgetNode = TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode;
|
||||||
|
|
||||||
export interface Screen {
|
export interface Screen {
|
||||||
version: SchemaVersion;
|
version: SchemaVersion;
|
||||||
|
|||||||
35
packages/sdk/src/core/widgets/iframe.ts
Normal file
35
packages/sdk/src/core/widgets/iframe.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { IframeWidgetNode } from '../schema';
|
||||||
|
import { pickUrlLike } from './urlLike';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goView iframe option shape varies across versions.
|
||||||
|
* Keep it permissive and normalize the common fields.
|
||||||
|
*/
|
||||||
|
export interface GoViewIframeOption {
|
||||||
|
dataset?: unknown;
|
||||||
|
src?: unknown;
|
||||||
|
url?: unknown;
|
||||||
|
borderRadius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-compat alias (older code used "LegacyIframeOption").
|
||||||
|
*/
|
||||||
|
export type LegacyIframeOption = GoViewIframeOption;
|
||||||
|
|
||||||
|
function pickSrc(option: GoViewIframeOption): string {
|
||||||
|
// Prefer the whole option first (covers iframeUrl/embedUrl variants directly on the object).
|
||||||
|
return pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {
|
||||||
|
return {
|
||||||
|
src: pickSrc(option),
|
||||||
|
borderRadius: option.borderRadius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-compat export.
|
||||||
|
*/
|
||||||
|
export const convertLegacyIframeOptionToNodeProps = convertGoViewIframeOptionToNodeProps;
|
||||||
56
packages/sdk/src/core/widgets/image.ts
Normal file
56
packages/sdk/src/core/widgets/image.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { ImageWidgetNode } from '../schema';
|
||||||
|
import { pickUrlLike } from './urlLike';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goView Image option shape varies across versions. We keep this intentionally
|
||||||
|
* permissive and normalize the common fields.
|
||||||
|
*/
|
||||||
|
export interface GoViewImageOption {
|
||||||
|
/**
|
||||||
|
* Common in existing legacy widgets (same as iframe/video).
|
||||||
|
*/
|
||||||
|
dataset?: unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other variants seen in the wild.
|
||||||
|
*/
|
||||||
|
src?: unknown;
|
||||||
|
url?: unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styling.
|
||||||
|
*/
|
||||||
|
fit?: unknown;
|
||||||
|
objectFit?: unknown;
|
||||||
|
borderRadius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSrc(option: GoViewImageOption): string {
|
||||||
|
return pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(v: unknown): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
if (!v) return '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFit(option: GoViewImageOption): ImageWidgetNode['props']['fit'] | undefined {
|
||||||
|
const raw = asString(option.fit) || asString(option.objectFit);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
const v = raw.toLowerCase();
|
||||||
|
if (v === 'contain' || v === 'cover' || v === 'fill' || v === 'none' || v === 'scale-down') {
|
||||||
|
return v as ImageWidgetNode['props']['fit'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertGoViewImageOptionToNodeProps(option: GoViewImageOption): ImageWidgetNode['props'] {
|
||||||
|
return {
|
||||||
|
src: pickSrc(option),
|
||||||
|
fit: pickFit(option),
|
||||||
|
borderRadius: option.borderRadius,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
packages/sdk/src/core/widgets/urlLike.ts
Normal file
75
packages/sdk/src/core/widgets/urlLike.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Small helper for permissive imports (goView / low-code exports).
|
||||||
|
*
|
||||||
|
* Many widgets store their URL-ish source in slightly different shapes:
|
||||||
|
* - string
|
||||||
|
* - { value: string }
|
||||||
|
* - { url: string }
|
||||||
|
* - { src: string }
|
||||||
|
* - { dataset: string | { value/url/src } }
|
||||||
|
* - nested objects under `data` / `config` / `options`
|
||||||
|
*/
|
||||||
|
export function pickUrlLike(input: unknown, maxDepth = 3): string {
|
||||||
|
return pickUrlLikeInner(input, maxDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUrlLikeInner(input: unknown, depth: number): string {
|
||||||
|
if (typeof input === 'string') return input;
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
for (const item of input) {
|
||||||
|
const v = pickUrlLikeInner(item, depth - 1);
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input !== 'object') return '';
|
||||||
|
|
||||||
|
const obj = input as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Common direct keys.
|
||||||
|
// Keep this list generous; imports come from many low-code editors.
|
||||||
|
for (const key of [
|
||||||
|
'value',
|
||||||
|
'url',
|
||||||
|
'src',
|
||||||
|
'href',
|
||||||
|
'link',
|
||||||
|
'path',
|
||||||
|
'source',
|
||||||
|
'address',
|
||||||
|
// iframe-ish
|
||||||
|
'iframeUrl',
|
||||||
|
'iframeSrc',
|
||||||
|
'embedUrl',
|
||||||
|
// video-ish
|
||||||
|
'videoUrl',
|
||||||
|
'videoSrc',
|
||||||
|
'mp4',
|
||||||
|
'm3u8',
|
||||||
|
'flv',
|
||||||
|
// other streaming-ish keys
|
||||||
|
'hls',
|
||||||
|
'hlsUrl',
|
||||||
|
'stream',
|
||||||
|
'streamUrl',
|
||||||
|
'rtsp',
|
||||||
|
'rtspUrl',
|
||||||
|
]) {
|
||||||
|
const v = obj[key];
|
||||||
|
if (typeof v === 'string' && v) return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth <= 0) return '';
|
||||||
|
|
||||||
|
// Common nesting keys.
|
||||||
|
for (const key of ['dataset', 'data', 'config', 'option', 'options', 'props', 'source', 'media']) {
|
||||||
|
const v = obj[key];
|
||||||
|
const nested = pickUrlLikeInner(v, depth - 1);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
64
packages/sdk/src/core/widgets/video.ts
Normal file
64
packages/sdk/src/core/widgets/video.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { VideoWidgetNode } from '../schema';
|
||||||
|
import { pickUrlLike } from './urlLike';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goView video option shape varies across versions.
|
||||||
|
* Keep it permissive and normalize the common fields.
|
||||||
|
*/
|
||||||
|
export interface GoViewVideoOption {
|
||||||
|
dataset?: unknown;
|
||||||
|
src?: unknown;
|
||||||
|
url?: unknown;
|
||||||
|
|
||||||
|
loop?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
|
||||||
|
fit?: unknown;
|
||||||
|
objectFit?: unknown;
|
||||||
|
|
||||||
|
borderRadius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-compat alias (older code used "LegacyVideoOption").
|
||||||
|
*/
|
||||||
|
export type LegacyVideoOption = GoViewVideoOption;
|
||||||
|
|
||||||
|
function pickSrc(option: GoViewVideoOption): string {
|
||||||
|
// Prefer the whole option first (covers videoUrl/mp4/m3u8/flv/etc directly on the object).
|
||||||
|
return pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(v: unknown): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
if (!v) return '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | undefined {
|
||||||
|
const raw = asString(option.fit) || asString(option.objectFit);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
// normalize common variants
|
||||||
|
const v = raw.toLowerCase();
|
||||||
|
if (v === 'contain' || v === 'cover' || v === 'fill' || v === 'none' || v === 'scale-down') {
|
||||||
|
return v as VideoWidgetNode['props']['fit'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
|
||||||
|
return {
|
||||||
|
src: pickSrc(option),
|
||||||
|
loop: option.loop,
|
||||||
|
muted: option.muted,
|
||||||
|
fit: pickFit(option),
|
||||||
|
borderRadius: option.borderRadius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-compat export.
|
||||||
|
*/
|
||||||
|
export const convertLegacyVideoOptionToNodeProps = convertGoViewVideoOptionToNodeProps;
|
||||||
@ -14,6 +14,9 @@ export type {
|
|||||||
Screen,
|
Screen,
|
||||||
WidgetNode,
|
WidgetNode,
|
||||||
TextWidgetNode,
|
TextWidgetNode,
|
||||||
|
ImageWidgetNode,
|
||||||
|
IframeWidgetNode,
|
||||||
|
VideoWidgetNode,
|
||||||
} from './core/schema';
|
} from './core/schema';
|
||||||
|
|
||||||
export { migrateScreen } from './core/migrate';
|
export { migrateScreen } from './core/migrate';
|
||||||
@ -21,6 +24,15 @@ export { migrateScreen } from './core/migrate';
|
|||||||
export type { GoViewTextOption } from './core/widgets/text';
|
export type { GoViewTextOption } from './core/widgets/text';
|
||||||
export { convertGoViewTextOptionToNodeProps } from './core/widgets/text';
|
export { convertGoViewTextOptionToNodeProps } from './core/widgets/text';
|
||||||
|
|
||||||
|
export type { GoViewImageOption } from './core/widgets/image';
|
||||||
|
export { convertGoViewImageOptionToNodeProps } from './core/widgets/image';
|
||||||
|
|
||||||
|
export type { GoViewIframeOption, LegacyIframeOption } from './core/widgets/iframe';
|
||||||
|
export { convertGoViewIframeOptionToNodeProps, convertLegacyIframeOptionToNodeProps } from './core/widgets/iframe';
|
||||||
|
|
||||||
|
export type { GoViewVideoOption, LegacyVideoOption } from './core/widgets/video';
|
||||||
|
export { convertGoViewVideoOptionToNodeProps, convertLegacyVideoOptionToNodeProps } from './core/widgets/video';
|
||||||
|
|
||||||
export type { GoViewProjectLike, GoViewComponentLike } from './core/goview/convert';
|
export type { GoViewProjectLike, GoViewComponentLike } from './core/goview/convert';
|
||||||
export { convertGoViewProjectToScreen } from './core/goview/convert';
|
export { convertGoViewProjectToScreen } from './core/goview/convert';
|
||||||
export { convertGoViewJSONToScreen } from './core/goview';
|
export { convertGoViewJSONToScreen } from './core/goview';
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user