Compare commits
3 Commits
f0a6810c54
...
4c96177982
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c96177982 | ||
|
|
2161b3cc71 | ||
|
|
41ba9d0512 |
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
// Prefer outer for geometry/id, but prefer inner for identity/option when present.
|
let depth = 0;
|
||||||
...c,
|
|
||||||
...inner,
|
while (out.component && depth < 6) {
|
||||||
// ensure the nested component doesn't get lost
|
const inner = out.component;
|
||||||
component: inner.component,
|
out = {
|
||||||
};
|
// Prefer outer for geometry/id, but prefer inner for identity/option when present.
|
||||||
|
...out,
|
||||||
|
...inner,
|
||||||
|
// keep unwrapping if there are more layers
|
||||||
|
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,16 +354,20 @@ 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'
|
||||||
: undefined;
|
: isTextCommon(c)
|
||||||
|
? 'text'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const defaults =
|
const defaults =
|
||||||
inferredType === 'text'
|
inferredType === 'text'
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user