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 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 } from 'antd';
|
||||||
import type { ResizeHandle } from './types';
|
import type { ResizeHandle } from './types';
|
||||||
import { rectFromPoints } from './geometry';
|
import { rectFromPoints } from './geometry';
|
||||||
|
import type { ContextMenuState } from './ContextMenu';
|
||||||
|
|
||||||
export interface CanvasProps {
|
export interface CanvasProps {
|
||||||
screen: Screen;
|
screen: Screen;
|
||||||
@ -30,12 +31,8 @@ export interface CanvasProps {
|
|||||||
onAddTextAt(x: number, y: number): void;
|
onAddTextAt(x: number, y: number): void;
|
||||||
onWheelPan(dx: number, dy: number): void;
|
onWheelPan(dx: number, dy: number): void;
|
||||||
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
|
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
|
||||||
onDeleteSelected?(): void;
|
onOpenContextMenu(state: ContextMenuState): void;
|
||||||
onDuplicateSelected?(): void;
|
onCloseContextMenu(): void;
|
||||||
onToggleLockSelected?(): void;
|
|
||||||
onToggleHideSelected?(): void;
|
|
||||||
onBringToFrontSelected?(): void;
|
|
||||||
onSendToBackSelected?(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
|
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;
|
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) {
|
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 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]);
|
||||||
|
|
||||||
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 clientToCanvas = useCallback((clientX: number, clientY: number) => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
@ -175,36 +131,6 @@ export function Canvas(props: CanvasProps) {
|
|||||||
onUpdateResize,
|
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) => {
|
const openContextMenu = (e: React.MouseEvent | React.PointerEvent, targetId?: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -230,12 +156,12 @@ export function Canvas(props: CanvasProps) {
|
|||||||
if (!additive) props.onSelectSingle(undefined);
|
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) => {
|
const onBackgroundPointerDown = (e: React.PointerEvent) => {
|
||||||
// close any open context menu on normal interactions
|
// 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)
|
// middle click: do nothing (goView returns)
|
||||||
if (e.button === 1) return;
|
if (e.button === 1) return;
|
||||||
@ -309,97 +235,6 @@ export function Canvas(props: CanvasProps) {
|
|||||||
cursor: props.keyboard.space ? 'grab' : 'default',
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${props.panX}px, ${props.panY}px) scale(${props.scale})`,
|
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: {
|
function NodeView(props: {
|
||||||
node: WidgetNode;
|
node: WidgetNode;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@ -578,7 +377,16 @@ function NodeView(props: {
|
|||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (node.type) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -609,7 +417,7 @@ function NodeView(props: {
|
|||||||
color: node.props.color ?? '#fff',
|
color: node.props.color ?? '#fff',
|
||||||
fontWeight: node.props.fontWeight ?? 400,
|
fontWeight: node.props.fontWeight ?? 400,
|
||||||
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
|
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
|
||||||
writingMode: node.props.writingMode as unknown as React.CSSProperties['writingMode'],
|
writingMode: writingModeStyle,
|
||||||
cursor: node.props.link ? 'pointer' : 'default',
|
cursor: node.props.link ? 'pointer' : 'default',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -622,6 +430,7 @@ function NodeView(props: {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'image':
|
case 'image':
|
||||||
return (
|
return (
|
||||||
@ -687,7 +496,7 @@ function NodeView(props: {
|
|||||||
src={node.props.src}
|
src={node.props.src}
|
||||||
width={rect.w}
|
width={rect.w}
|
||||||
height={rect.h}
|
height={rect.h}
|
||||||
autoPlay
|
autoPlay={node.props.autoplay ?? true}
|
||||||
playsInline
|
playsInline
|
||||||
loop={node.props.loop ?? false}
|
loop={node.props.loop ?? false}
|
||||||
muted={node.props.muted ?? 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: {
|
export function ContextMenu(props: {
|
||||||
x: number;
|
state: ContextMenuState | null;
|
||||||
y: number;
|
selectionIds: string[];
|
||||||
onAction: (a: ContextMenuAction) => void;
|
selectionAllLocked: boolean;
|
||||||
|
selectionAllHidden: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onAddTextAt: (x: number, y: number) => void;
|
||||||
|
onDuplicateSelected?: () => void;
|
||||||
|
onToggleLockSelected?: () => void;
|
||||||
|
onToggleHideSelected?: () => void;
|
||||||
|
onBringToFrontSelected?: () => void;
|
||||||
|
onSendToBackSelected?: () => void;
|
||||||
|
onDeleteSelected?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const { onClose } = props;
|
||||||
<>
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
{/* click-away backdrop */}
|
const [menuSize, setMenuSize] = useState<{ w: number; h: number }>({ w: 220, h: 320 });
|
||||||
<div
|
const ctx = props.state;
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
props.onClose();
|
|
||||||
}}
|
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card
|
useLayoutEffect(() => {
|
||||||
size="small"
|
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 (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: props.x,
|
left: position.x,
|
||||||
top: props.y,
|
top: position.y,
|
||||||
zIndex: 1000,
|
zIndex: 10_000,
|
||||||
width: 160,
|
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();
|
||||||
}}
|
}}
|
||||||
styles={{ body: { padding: 8 } }}
|
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<MenuItem
|
||||||
<Button block onClick={() => props.onAction('duplicate')}>
|
label="Add Text Here"
|
||||||
Duplicate
|
onClick={() => {
|
||||||
</Button>
|
props.onAddTextAt(ctx.worldX, ctx.worldY);
|
||||||
<Button danger block onClick={() => props.onAction('delete')}>
|
onClose();
|
||||||
Delete
|
}}
|
||||||
</Button>
|
/>
|
||||||
</Space>
|
|
||||||
</Card>
|
<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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,
|
Tabs,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||||
import { bindEditorHotkeys } from './hotkeys';
|
import { bindEditorHotkeys } from './hotkeys';
|
||||||
import { Canvas } from './Canvas';
|
import { Canvas } from './Canvas';
|
||||||
|
import { ContextMenu, type ContextMenuState } from './ContextMenu';
|
||||||
import { Inspector } from './Inspector';
|
import { Inspector } from './Inspector';
|
||||||
import { createInitialState, editorReducer, exportScreenJSON } from './store';
|
import { createInitialState, editorReducer, exportScreenJSON } from './store';
|
||||||
|
|
||||||
@ -42,15 +43,36 @@ export function EditorApp() {
|
|||||||
const [importText, setImportText] = useState('');
|
const [importText, setImportText] = useState('');
|
||||||
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
|
const [leftCategory, setLeftCategory] = useState<LeftCategoryKey>('charts');
|
||||||
const [rightTab, setRightTab] = useState<RightPanelTabKey>('custom');
|
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 selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
|
||||||
const hasSelection = state.selection.ids.length > 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(
|
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(() => {
|
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 });
|
||||||
@ -285,12 +307,8 @@ export function EditorApp() {
|
|||||||
guides={state.canvas.guides}
|
guides={state.canvas.guides}
|
||||||
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
||||||
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
|
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
|
||||||
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
onOpenContextMenu={(next) => setCtxMenu(next)}
|
||||||
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
onCloseContextMenu={closeContextMenu}
|
||||||
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
|
|
||||||
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
|
|
||||||
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
|
|
||||||
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
|
|
||||||
onBeginPan={(e) => dispatch({ type: 'beginPan', start: { 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 } })}
|
onUpdatePan={(e: PointerEvent) => dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } })}
|
||||||
onEndPan={() => dispatch({ type: 'endPan' })}
|
onEndPan={() => dispatch({ type: 'endPan' })}
|
||||||
@ -408,8 +426,91 @@ export function EditorApp() {
|
|||||||
<Typography.Paragraph style={{ color: '#64748b' }}>占位:页面背景 / 主题 / 配色将在后续补齐。</Typography.Paragraph>
|
<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>
|
</Sider>
|
||||||
</Layout>
|
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,6 +140,21 @@ export function Inspector(props: {
|
|||||||
</div>
|
</div>
|
||||||
</Space>
|
</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>
|
<Typography.Text type="secondary">Border radius</Typography.Text>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
value={node.props.borderRadius ?? 0}
|
value={node.props.borderRadius ?? 0}
|
||||||
|
|||||||
@ -332,7 +332,7 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||||||
...n,
|
...n,
|
||||||
id,
|
id,
|
||||||
rect: { ...n.rect, x: n.rect.x + 10, y: n.rect.y + 10 },
|
rect: { ...n.rect, x: n.rect.x + 10, y: n.rect.y + 10 },
|
||||||
} as WidgetNode);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clones.length) return state;
|
if (!clones.length) return state;
|
||||||
|
|||||||
@ -167,6 +167,11 @@ function looksLikeImageOption(option: unknown): boolean {
|
|||||||
function looksLikeIframeOption(option: unknown): boolean {
|
function looksLikeIframeOption(option: unknown): boolean {
|
||||||
if (!option) return false;
|
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.
|
// Prefer explicit iframe-ish keys when option is an object.
|
||||||
if (typeof option === 'object') {
|
if (typeof option === 'object') {
|
||||||
const o = option as Record<string, unknown>;
|
const o = option as Record<string, unknown>;
|
||||||
@ -213,7 +218,12 @@ function looksLikeVideoOption(option: unknown): boolean {
|
|||||||
// list-ish shapes
|
// list-ish shapes
|
||||||
'sources' in o ||
|
'sources' in o ||
|
||||||
'sourceList' 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,7 @@ export interface VideoWidgetNode extends WidgetNodeBase {
|
|||||||
type: 'video';
|
type: 'video';
|
||||||
props: {
|
props: {
|
||||||
src: string;
|
src: string;
|
||||||
|
autoplay?: boolean;
|
||||||
loop?: boolean;
|
loop?: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
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)}`;
|
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 {
|
function pickSrc(option: GoViewIframeOption): string {
|
||||||
// Prefer the whole option first (covers iframeUrl/embedUrl variants directly on the object).
|
// 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);
|
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.
|
// Some goView / low-code exports store raw HTML instead of a URL.
|
||||||
const html = pickFromNested(
|
const html = pickFromNested(
|
||||||
|
|||||||
@ -10,10 +10,12 @@ export interface GoViewVideoOption {
|
|||||||
src?: unknown;
|
src?: unknown;
|
||||||
url?: unknown;
|
url?: unknown;
|
||||||
|
|
||||||
loop?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
|
isAutoPlay?: boolean;
|
||||||
|
|
||||||
|
loop?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
|
||||||
fit?: unknown;
|
fit?: unknown;
|
||||||
objectFit?: 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 {
|
function pickFitFromNested(option: GoViewVideoOption): string {
|
||||||
const direct = asString(option.fit) || asString(option.objectFit);
|
const direct = asString(option.fit) || asString(option.objectFit);
|
||||||
if (direct) return direct;
|
if (direct) return direct;
|
||||||
@ -118,6 +124,7 @@ function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | u
|
|||||||
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
|
export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption): VideoWidgetNode['props'] {
|
||||||
return {
|
return {
|
||||||
src: pickSrc(option),
|
src: pickSrc(option),
|
||||||
|
autoplay: pickAutoplay(option),
|
||||||
loop: pickBooleanLike(option, ['loop', 'isLoop']),
|
loop: pickBooleanLike(option, ['loop', 'isLoop']),
|
||||||
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
|
muted: pickBooleanLike(option, ['muted', 'isMuted', 'mute']),
|
||||||
fit: pickFit(option),
|
fit: pickFit(option),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user