feat: improve goview media import and editor selection
This commit is contained in:
parent
d21806c836
commit
52f5cce272
@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Screen, WidgetNode } from '@astralview/sdk';
|
||||
import { assertNever } from '@astralview/sdk';
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
import { Button, Space } from 'antd';
|
||||
import type { ResizeHandle } from './types';
|
||||
import { rectFromPoints } from './geometry';
|
||||
import type { ContextMenuState } from './ContextMenu';
|
||||
|
||||
export interface CanvasProps {
|
||||
screen: Screen;
|
||||
@ -30,12 +31,8 @@ export interface CanvasProps {
|
||||
onAddTextAt(x: number, y: number): void;
|
||||
onWheelPan(dx: number, dy: number): void;
|
||||
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
|
||||
onDeleteSelected?(): void;
|
||||
onDuplicateSelected?(): void;
|
||||
onToggleLockSelected?(): void;
|
||||
onToggleHideSelected?(): void;
|
||||
onBringToFrontSelected?(): void;
|
||||
onSendToBackSelected?(): void;
|
||||
onOpenContextMenu(state: ContextMenuState): void;
|
||||
onCloseContextMenu(): void;
|
||||
}
|
||||
|
||||
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
|
||||
@ -43,53 +40,12 @@ function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
|
||||
return (e as PointerEvent).buttons === 1 || (e as PointerEvent).button === 0;
|
||||
}
|
||||
|
||||
type ContextMenuState = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
export function Canvas(props: CanvasProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | 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 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);
|
||||
|
||||
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(() => {
|
||||
if (!ctx) return null;
|
||||
|
||||
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 - ctxMenuSize.w - 8)),
|
||||
y: Math.max(8, Math.min(ctx.clientY, vh - ctxMenuSize.h - 8)),
|
||||
};
|
||||
}, [ctx, ctxMenuSize.h, ctxMenuSize.w]);
|
||||
|
||||
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
|
||||
const el = ref.current;
|
||||
if (!el) return null;
|
||||
@ -175,36 +131,6 @@ export function Canvas(props: CanvasProps) {
|
||||
onUpdateResize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') 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('pointerdown', onAnyPointerDown);
|
||||
window.addEventListener('wheel', onAnyWheel, { passive: true });
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('blur', onBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('pointerdown', onAnyPointerDown);
|
||||
window.removeEventListener('wheel', onAnyWheel);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
}, [ctx]);
|
||||
|
||||
const openContextMenu = (e: React.MouseEvent | React.PointerEvent, targetId?: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -230,12 +156,12 @@ export function Canvas(props: CanvasProps) {
|
||||
if (!additive) props.onSelectSingle(undefined);
|
||||
}
|
||||
|
||||
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
|
||||
props.onOpenContextMenu({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
|
||||
};
|
||||
|
||||
const onBackgroundPointerDown = (e: React.PointerEvent) => {
|
||||
// close any open context menu on normal interactions
|
||||
if (ctx && e.button !== 2) setCtx(null);
|
||||
if (e.button !== 2) props.onCloseContextMenu();
|
||||
|
||||
// middle click: do nothing (goView returns)
|
||||
if (e.button === 1) return;
|
||||
@ -309,97 +235,6 @@ export function Canvas(props: CanvasProps) {
|
||||
cursor: props.keyboard.space ? 'grab' : 'default',
|
||||
}}
|
||||
>
|
||||
{ctx && ctxMenuPos && (
|
||||
<div
|
||||
ref={ctxMenuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: ctxMenuPos.x,
|
||||
top: ctxMenuPos.y,
|
||||
zIndex: 10_000,
|
||||
background: '#111827',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 8,
|
||||
minWidth: 180,
|
||||
padding: 6,
|
||||
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// keep it open when clicking inside
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
label="Add Text Here"
|
||||
onClick={() => {
|
||||
props.onAddTextAt(ctx.worldX, ctx.worldY);
|
||||
setCtx(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<MenuItem
|
||||
label="Duplicate"
|
||||
disabled={!props.selectionIds.length || !props.onDuplicateSelected}
|
||||
onClick={() => {
|
||||
props.onDuplicateSelected?.();
|
||||
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
|
||||
label="Delete"
|
||||
danger
|
||||
disabled={!props.selectionIds.length || !props.onDeleteSelected}
|
||||
onClick={() => {
|
||||
props.onDeleteSelected?.();
|
||||
setCtx(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography.Text style={{ display: 'block', marginTop: 6, color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>
|
||||
({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${props.panX}px, ${props.panY}px) scale(${props.scale})`,
|
||||
@ -510,42 +345,6 @@ export function Canvas(props: CanvasProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
props.onClick();
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
cursor: props.disabled ? 'not-allowed' : 'pointer',
|
||||
color: props.disabled
|
||||
? 'rgba(255,255,255,0.35)'
|
||||
: props.danger
|
||||
? '#ff7875'
|
||||
: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (props.disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'rgba(255,255,255,0.06)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeView(props: {
|
||||
node: WidgetNode;
|
||||
selected: boolean;
|
||||
@ -578,7 +377,16 @@ function NodeView(props: {
|
||||
>
|
||||
{(() => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
case 'text': {
|
||||
const writingModeRaw = node.props.writingMode;
|
||||
const writingModeStyle =
|
||||
writingModeRaw === 'horizontal-tb' ||
|
||||
writingModeRaw === 'vertical-rl' ||
|
||||
writingModeRaw === 'vertical-lr' ||
|
||||
writingModeRaw === 'sideways-rl' ||
|
||||
writingModeRaw === 'sideways-lr'
|
||||
? writingModeRaw
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -600,18 +408,18 @@ function NodeView(props: {
|
||||
boxSizing: 'border-box',
|
||||
padding: `${node.props.paddingY ?? 0}px ${node.props.paddingX ?? 0}px`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: node.props.fontSize ?? 24,
|
||||
color: node.props.color ?? '#fff',
|
||||
fontWeight: node.props.fontWeight ?? 400,
|
||||
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
|
||||
writingMode: node.props.writingMode as unknown as React.CSSProperties['writingMode'],
|
||||
cursor: node.props.link ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: node.props.fontSize ?? 24,
|
||||
color: node.props.color ?? '#fff',
|
||||
fontWeight: node.props.fontWeight ?? 400,
|
||||
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
|
||||
writingMode: writingModeStyle,
|
||||
cursor: node.props.link ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!node.props.link) return;
|
||||
const head = node.props.linkHead ?? 'http://';
|
||||
@ -622,6 +430,7 @@ function NodeView(props: {
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'image':
|
||||
return (
|
||||
@ -687,7 +496,7 @@ function NodeView(props: {
|
||||
src={node.props.src}
|
||||
width={rect.w}
|
||||
height={rect.h}
|
||||
autoPlay
|
||||
autoPlay={node.props.autoplay ?? true}
|
||||
playsInline
|
||||
loop={node.props.loop ?? false}
|
||||
muted={node.props.muted ?? false}
|
||||
|
||||
@ -1,44 +1,207 @@
|
||||
import { Button, Card, Space } from 'antd';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
export type ContextMenuAction = 'delete' | 'duplicate';
|
||||
export type ContextMenuState = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
export function ContextMenu(props: {
|
||||
x: number;
|
||||
y: number;
|
||||
onAction: (a: ContextMenuAction) => void;
|
||||
state: ContextMenuState | null;
|
||||
selectionIds: string[];
|
||||
selectionAllLocked: boolean;
|
||||
selectionAllHidden: boolean;
|
||||
onClose: () => void;
|
||||
onAddTextAt: (x: number, y: number) => void;
|
||||
onDuplicateSelected?: () => void;
|
||||
onToggleLockSelected?: () => void;
|
||||
onToggleHideSelected?: () => void;
|
||||
onBringToFrontSelected?: () => void;
|
||||
onSendToBackSelected?: () => void;
|
||||
onDeleteSelected?: () => void;
|
||||
}) {
|
||||
const { onClose } = props;
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuSize, setMenuSize] = useState<{ w: number; h: number }>({ w: 220, h: 320 });
|
||||
const ctx = props.state;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ctx) return;
|
||||
const el = menuRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width && rect.height) setMenuSize({ w: rect.width, h: rect.height });
|
||||
}, [ctx]);
|
||||
|
||||
const position = useMemo(() => {
|
||||
if (!ctx) return null;
|
||||
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 - menuSize.w - 8)),
|
||||
y: Math.max(8, Math.min(ctx.clientY, vh - menuSize.h - 8)),
|
||||
};
|
||||
}, [ctx, menuSize.h, menuSize.w]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
const close = () => onClose();
|
||||
const onAnyPointerDown = () => close();
|
||||
const onAnyWheel = () => close();
|
||||
const onScroll = () => close();
|
||||
const onBlur = () => close();
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('pointerdown', onAnyPointerDown);
|
||||
window.addEventListener('wheel', onAnyWheel, { passive: true });
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('blur', onBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('pointerdown', onAnyPointerDown);
|
||||
window.removeEventListener('wheel', onAnyWheel);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
}, [ctx, onClose]);
|
||||
|
||||
if (!ctx || !position) return null;
|
||||
|
||||
const hasSelection = props.selectionIds.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* click-away backdrop */}
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
props.onClose();
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 10_000,
|
||||
background: '#111827',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 8,
|
||||
minWidth: 180,
|
||||
padding: 6,
|
||||
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// keep it open when clicking inside
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
label="Add Text Here"
|
||||
onClick={() => {
|
||||
props.onAddTextAt(ctx.worldX, ctx.worldY);
|
||||
onClose();
|
||||
}}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
|
||||
/>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: props.x,
|
||||
top: props.y,
|
||||
zIndex: 1000,
|
||||
width: 160,
|
||||
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<MenuItem
|
||||
label="Duplicate"
|
||||
disabled={!hasSelection || !props.onDuplicateSelected}
|
||||
onClick={() => {
|
||||
props.onDuplicateSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
styles={{ body: { padding: 8 } }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button block onClick={() => props.onAction('duplicate')}>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button danger block onClick={() => props.onAction('delete')}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
label={props.selectionAllLocked ? 'Unlock' : 'Lock'}
|
||||
disabled={!hasSelection || !props.onToggleLockSelected}
|
||||
onClick={() => {
|
||||
props.onToggleLockSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label={props.selectionAllHidden ? 'Show' : 'Hide'}
|
||||
disabled={!hasSelection || !props.onToggleHideSelected}
|
||||
onClick={() => {
|
||||
props.onToggleHideSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<MenuItem
|
||||
label="Bring To Front"
|
||||
disabled={!hasSelection || !props.onBringToFrontSelected}
|
||||
onClick={() => {
|
||||
props.onBringToFrontSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Send To Back"
|
||||
disabled={!hasSelection || !props.onSendToBackSelected}
|
||||
onClick={() => {
|
||||
props.onSendToBackSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
label="Delete"
|
||||
danger
|
||||
disabled={!hasSelection || !props.onDeleteSelected}
|
||||
onClick={() => {
|
||||
props.onDeleteSelected?.();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography.Text style={{ display: 'block', marginTop: 6, color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>
|
||||
({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
props.onClick();
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
cursor: props.disabled ? 'not-allowed' : 'pointer',
|
||||
color: props.disabled
|
||||
? 'rgba(255,255,255,0.35)'
|
||||
: props.danger
|
||||
? '#ff7875'
|
||||
: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (props.disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'rgba(255,255,255,0.06)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,9 +9,10 @@ import {
|
||||
Tabs,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { bindEditorHotkeys } from './hotkeys';
|
||||
import { Canvas } from './Canvas';
|
||||
import { ContextMenu, type ContextMenuState } from './ContextMenu';
|
||||
import { Inspector } from './Inspector';
|
||||
import { createInitialState, editorReducer, exportScreenJSON } from './store';
|
||||
|
||||
@ -42,15 +43,36 @@ export function EditorApp() {
|
||||
const [importText, setImportText] = useState('');
|
||||
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
|
||||
const [rightTab, setRightTab] = useState<RightPanelTabKey>('custom');
|
||||
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
|
||||
|
||||
const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
|
||||
const hasSelection = state.selection.ids.length > 0;
|
||||
|
||||
const selection = useMemo(() => {
|
||||
const ids = new Set(state.selection.ids);
|
||||
return state.doc.screen.nodes.filter((n) => ids.has(n.id));
|
||||
}, [state.doc.screen.nodes, state.selection.ids]);
|
||||
|
||||
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
|
||||
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
|
||||
|
||||
const bounds = useMemo(
|
||||
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
|
||||
[state.doc.screen.width, state.doc.screen.height],
|
||||
);
|
||||
|
||||
const orderedNodes = useMemo(() => {
|
||||
return state.doc.screen.nodes
|
||||
.map((node, index) => ({ node, index, z: node.zIndex ?? index }))
|
||||
.sort((a, b) => {
|
||||
if (a.z !== b.z) return b.z - a.z;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.node);
|
||||
}, [state.doc.screen.nodes]);
|
||||
|
||||
const closeContextMenu = useCallback(() => setCtxMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space });
|
||||
@ -285,12 +307,8 @@ export function EditorApp() {
|
||||
guides={state.canvas.guides}
|
||||
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
||||
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
|
||||
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
||||
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
||||
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
|
||||
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
|
||||
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
|
||||
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
|
||||
onOpenContextMenu={(next) => setCtxMenu(next)}
|
||||
onCloseContextMenu={closeContextMenu}
|
||||
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' })}
|
||||
@ -408,8 +426,91 @@ export function EditorApp() {
|
||||
<Typography.Paragraph style={{ color: '#64748b' }}>占位:页面背景 / 主题 / 配色将在后续补齐。</Typography.Paragraph>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
<PanelTitle
|
||||
title="Layers"
|
||||
extra={<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>{state.doc.screen.nodes.length}</Typography.Text>}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{orderedNodes.length === 0 ? (
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>No layers</Typography.Text>
|
||||
) : (
|
||||
orderedNodes.map((node) => {
|
||||
const isSelected = state.selection.ids.includes(node.id);
|
||||
const status = `${node.locked ? 'L' : ''}${node.hidden ? 'H' : ''}`;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 2) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (additive) {
|
||||
dispatch({ type: 'toggleSelect', id: node.id });
|
||||
} else {
|
||||
dispatch({ type: 'selectSingle', id: node.id });
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (!state.selection.ids.includes(node.id)) {
|
||||
if (additive) {
|
||||
dispatch({ type: 'toggleSelect', id: node.id });
|
||||
} else {
|
||||
dispatch({ type: 'selectSingle', id: node.id });
|
||||
}
|
||||
}
|
||||
setCtxMenu({
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
worldX: node.rect.x + node.rect.w / 2,
|
||||
worldY: node.rect.y + node.rect.h / 2,
|
||||
targetId: node.id,
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
border: isSelected ? '1px solid rgba(24,144,255,0.7)' : '1px solid rgba(255,255,255,0.08)',
|
||||
background: isSelected ? 'rgba(24,144,255,0.12)' : 'rgba(255,255,255,0.02)',
|
||||
color: isSelected ? '#e2e8f0' : '#cbd5e1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: 12 }}>{node.type}</span>
|
||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }}>{node.id}</span>
|
||||
</div>
|
||||
{status ? <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{status}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Sider>
|
||||
</Layout>
|
||||
|
||||
<ContextMenu
|
||||
state={ctxMenu}
|
||||
selectionIds={state.selection.ids}
|
||||
selectionAllLocked={selectionAllLocked}
|
||||
selectionAllHidden={selectionAllHidden}
|
||||
onClose={closeContextMenu}
|
||||
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
||||
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
||||
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
|
||||
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
|
||||
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
|
||||
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
|
||||
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -140,6 +140,21 @@ export function Inspector(props: {
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} size={12}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Autoplay</Typography.Text>
|
||||
<Select
|
||||
value={String(node.props.autoplay ?? true)}
|
||||
onChange={(v) => props.onUpdateVideoProps(node.id, { autoplay: v === 'true' })}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'true', label: 'true' },
|
||||
{ value: 'false', label: 'false' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Typography.Text type="secondary">Border radius</Typography.Text>
|
||||
<InputNumber
|
||||
value={node.props.borderRadius ?? 0}
|
||||
|
||||
@ -332,7 +332,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
||||
...n,
|
||||
id,
|
||||
rect: { ...n.rect, x: n.rect.x + 10, y: n.rect.y + 10 },
|
||||
} as WidgetNode);
|
||||
});
|
||||
}
|
||||
|
||||
if (!clones.length) return state;
|
||||
|
||||
@ -167,6 +167,11 @@ function looksLikeImageOption(option: unknown): boolean {
|
||||
function looksLikeIframeOption(option: unknown): boolean {
|
||||
if (!option) return false;
|
||||
|
||||
if (typeof option === 'string') {
|
||||
const trimmed = option.trim();
|
||||
if (trimmed.startsWith('<') && trimmed.includes('>')) return true;
|
||||
}
|
||||
|
||||
// Prefer explicit iframe-ish keys when option is an object.
|
||||
if (typeof option === 'object') {
|
||||
const o = option as Record<string, unknown>;
|
||||
@ -213,7 +218,12 @@ function looksLikeVideoOption(option: unknown): boolean {
|
||||
// list-ish shapes
|
||||
'sources' in o ||
|
||||
'sourceList' in o ||
|
||||
'urlList' in o
|
||||
'urlList' in o ||
|
||||
'autoplay' in o ||
|
||||
'autoPlay' in o ||
|
||||
'isAutoPlay' in o ||
|
||||
'poster' in o ||
|
||||
'posterUrl' in o
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ export interface VideoWidgetNode extends WidgetNodeBase {
|
||||
type: 'video';
|
||||
props: {
|
||||
src: string;
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
|
||||
@ -24,10 +24,15 @@ function toDataHtmlUrl(html: string): string {
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||
}
|
||||
|
||||
function looksLikeHtml(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return trimmed.startsWith('<') && trimmed.includes('>');
|
||||
}
|
||||
|
||||
function pickSrc(option: GoViewIframeOption): string {
|
||||
// Prefer the whole option first (covers iframeUrl/embedUrl variants directly on the object).
|
||||
const url = pickUrlLike(option) || pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
|
||||
if (url) return url;
|
||||
if (url) return looksLikeHtml(url) ? toDataHtmlUrl(url) : url;
|
||||
|
||||
// Some goView / low-code exports store raw HTML instead of a URL.
|
||||
const html = pickFromNested(
|
||||
|
||||
@ -10,10 +10,12 @@ export interface GoViewVideoOption {
|
||||
src?: unknown;
|
||||
url?: unknown;
|
||||
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
autoplay?: boolean;
|
||||
autoPlay?: boolean;
|
||||
isAutoPlay?: boolean;
|
||||
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
|
||||
fit?: unknown;
|
||||
objectFit?: unknown;
|
||||
@ -94,6 +96,10 @@ function pickBooleanLike(option: GoViewVideoOption, keys: string[]): boolean | u
|
||||
);
|
||||
}
|
||||
|
||||
function pickAutoplay(option: GoViewVideoOption): boolean | undefined {
|
||||
return pickBooleanLike(option, ['autoplay', 'autoPlay', 'auto_play', 'isAutoPlay']);
|
||||
}
|
||||
|
||||
function pickFitFromNested(option: GoViewVideoOption): string {
|
||||
const direct = asString(option.fit) || asString(option.objectFit);
|
||||
if (direct) return direct;
|
||||
@ -118,6 +124,7 @@ function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | u
|
||||
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
|
||||
return {
|
||||
src: pickSrc(option),
|
||||
autoplay: pickAutoplay(option),
|
||||
loop: pickBooleanLike(option, ['loop', 'isLoop']),
|
||||
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
|
||||
fit: pickFit(option),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user