Compare commits

...

3 Commits

Author SHA1 Message Date
clawdbot
f0a6810c54 refactor: unwrap legacy components + improve context menu closing 2026-01-28 01:48:36 +08:00
clawdbot
176a1af2ed refactor: improve legacy media detection + clamp context menu 2026-01-28 01:48:03 +08:00
clawdbot
50bce304d0 sdk: infer media before text in legacy import
:wq
2026-01-28 01:47:25 +08:00
3 changed files with 69 additions and 26 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Screen, WidgetNode } from '@astralview/sdk'; import type { Screen, WidgetNode } from '@astralview/sdk';
import { assertNever } from '@astralview/sdk'; import { assertNever } from '@astralview/sdk';
import { Button, Space, Typography } from 'antd'; import { Button, Space, Typography } from 'antd';
@ -55,6 +55,8 @@ export function Canvas(props: CanvasProps) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null); const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
const [ctx, setCtx] = useState<ContextMenuState | null>(null); const [ctx, setCtx] = useState<ContextMenuState | null>(null);
const ctxMenuRef = useRef<HTMLDivElement | null>(null);
const [ctxMenuSize, setCtxMenuSize] = useState<{ w: number; h: number }>({ w: 220, h: 320 });
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]);
@ -66,22 +68,27 @@ export function Canvas(props: CanvasProps) {
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked); const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden); const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
useLayoutEffect(() => {
if (!ctx) return;
const el = ctxMenuRef.current;
if (!el) return;
// Measure after render so clamping matches the real menu size.
const rect = el.getBoundingClientRect();
if (rect.width && rect.height) setCtxMenuSize({ w: rect.width, h: rect.height });
}, [ctx]);
const ctxMenuPos = useMemo(() => { const ctxMenuPos = useMemo(() => {
if (!ctx) return null; 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 vw = typeof window === 'undefined' ? 10_000 : window.innerWidth;
const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight; const vh = typeof window === 'undefined' ? 10_000 : window.innerHeight;
return { return {
x: Math.max(8, Math.min(ctx.clientX, vw - w - 8)), x: Math.max(8, Math.min(ctx.clientX, vw - ctxMenuSize.w - 8)),
y: Math.max(8, Math.min(ctx.clientY, vh - h - 8)), y: Math.max(8, Math.min(ctx.clientY, vh - ctxMenuSize.h - 8)),
}; };
}, [ctx]); }, [ctx, ctxMenuSize.h, ctxMenuSize.w]);
const clientToCanvas = useCallback((clientX: number, clientY: number) => { const clientToCanvas = useCallback((clientX: number, clientY: number) => {
const el = ref.current; const el = ref.current;
@ -175,14 +182,26 @@ export function Canvas(props: CanvasProps) {
if (e.key === 'Escape') setCtx(null); if (e.key === 'Escape') setCtx(null);
}; };
const onAnyPointerDown = () => setCtx(null); const close = () => setCtx(null);
// Close on any outside interaction to keep UI parity with common editors.
const onAnyPointerDown = () => close();
const onAnyWheel = () => close();
const onScroll = () => close();
const onBlur = () => close();
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
window.addEventListener('pointerdown', onAnyPointerDown); window.addEventListener('pointerdown', onAnyPointerDown);
window.addEventListener('wheel', onAnyWheel, { passive: true });
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onBlur);
return () => { return () => {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('pointerdown', onAnyPointerDown); window.removeEventListener('pointerdown', onAnyPointerDown);
window.removeEventListener('wheel', onAnyWheel);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onBlur);
}; };
}, [ctx]); }, [ctx]);
@ -292,6 +311,7 @@ export function Canvas(props: CanvasProps) {
> >
{ctx && ctxMenuPos && ( {ctx && ctxMenuPos && (
<div <div
ref={ctxMenuRef}
style={{ style={{
position: 'fixed', position: 'fixed',
left: ctxMenuPos.x, left: ctxMenuPos.x,

View File

@ -63,17 +63,26 @@ export interface GoViewProjectLike {
} }
function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike { function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike {
// Prefer the nested component shape but keep outer fields as fallback. // Prefer nested component shapes but keep outer fields as fallback.
// This handles exports like: { id, attr, component: { chartConfig, option } } // Some exports wrap components multiple times like:
const inner = c.component; // { id, attr, component: { component: { chartConfig, option } } }
if (!inner) return c; // We unwrap iteratively to avoid recursion pitfalls.
return { let out: GoViewComponentLike = c;
let depth = 0;
while (out.component && depth < 6) {
const inner = out.component;
out = {
// Prefer outer for geometry/id, but prefer inner for identity/option when present. // Prefer outer for geometry/id, but prefer inner for identity/option when present.
...c, ...out,
...inner, ...inner,
// ensure the nested component doesn't get lost // keep unwrapping if there are more layers
component: inner.component, component: inner.component,
}; };
depth++;
}
return out;
} }
function keyOf(cIn: GoViewComponentLike): string { function keyOf(cIn: GoViewComponentLike): string {
@ -134,7 +143,11 @@ function isVideo(c: GoViewComponentLike): boolean {
k.includes('player') || k.includes('player') ||
k.includes('stream') || k.includes('stream') ||
k.includes('rtsp') || k.includes('rtsp') ||
k.includes('hls') k.includes('hls') ||
// common low-code names for live streams
k.includes('camera') ||
k.includes('cctv') ||
k.includes('monitor')
); );
} }
@ -341,15 +354,19 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
// We try to infer the widget kind early so we can pick better default sizes // We try to infer the widget kind early so we can pick better default sizes
// when exports omit sizing information. // when exports omit sizing information.
const inferredType: 'text' | 'image' | 'iframe' | 'video' | undefined = isTextCommon(c) // Important: run media/embed checks before text checks.
? 'text' // Some goView/fork widgets have misleading keys that contain "text" even though
: isImage(c) || looksLikeImageOption(option) // the option payload is clearly video/iframe.
const inferredType: 'text' | 'image' | 'iframe' | 'video' | undefined =
isImage(c) || looksLikeImageOption(option)
? 'image' ? 'image'
: // Important: run video checks before iframe checks; iframe URL detection is broader. : // Important: run video checks before iframe checks; iframe URL detection is broader.
isVideo(c) || looksLikeVideoOption(option) isVideo(c) || looksLikeVideoOption(option)
? 'video' ? 'video'
: isIframe(c) || looksLikeIframeOption(option) : isIframe(c) || looksLikeIframeOption(option)
? 'iframe' ? 'iframe'
: isTextCommon(c)
? 'text'
: undefined; : undefined;
const defaults = const defaults =

View File

@ -53,6 +53,9 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
'frameSrc', 'frameSrc',
'htmlUrl', 'htmlUrl',
'htmlSrc', 'htmlSrc',
// generic web-ish
'webUrl',
'webSrc',
// video-ish // video-ish
'videoUrl', 'videoUrl',
'videoSrc', 'videoSrc',
@ -70,6 +73,9 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
'rtspUrl', 'rtspUrl',
'rtmp', 'rtmp',
'rtmpUrl', 'rtmpUrl',
// camera-ish
'cameraUrl',
'cameraSrc',
]) { ]) {
const v = obj[key]; const v = obj[key];
if (typeof v === 'string' && v) return v; if (typeof v === 'string' && v) return v;