diff --git a/packages/editor/src/editor/Canvas.tsx b/packages/editor/src/editor/Canvas.tsx index aaf1b08..dd563b4 100644 --- a/packages/editor/src/editor/Canvas.tsx +++ b/packages/editor/src/editor/Canvas.tsx @@ -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(null); const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null); - const [ctx, setCtx] = useState(null); - const ctxMenuRef = useRef(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 && ( -
{ - // keep it open when clicking inside - e.stopPropagation(); - }} - > - { - props.onAddTextAt(ctx.worldX, ctx.worldY); - setCtx(null); - }} - /> - -
- - { - props.onDuplicateSelected?.(); - setCtx(null); - }} - /> - - { - props.onToggleLockSelected?.(); - setCtx(null); - }} - /> - { - props.onToggleHideSelected?.(); - setCtx(null); - }} - /> - -
- - { - props.onBringToFrontSelected?.(); - setCtx(null); - }} - /> - { - props.onSendToBackSelected?.(); - setCtx(null); - }} - /> - - { - props.onDeleteSelected?.(); - setCtx(null); - }} - /> - - - ({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)}) - -
- )} -
void; - disabled?: boolean; - danger?: boolean; -}) { - return ( -
{ - 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} -
- ); -} - 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 (
- + { if (!node.props.link) return; const head = node.props.linkHead ?? 'http://'; @@ -622,6 +430,7 @@ function NodeView(props: {
); + } 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} diff --git a/packages/editor/src/editor/ContextMenu.tsx b/packages/editor/src/editor/ContextMenu.tsx index e10bb70..ee8e973 100644 --- a/packages/editor/src/editor/ContextMenu.tsx +++ b/packages/editor/src/editor/ContextMenu.tsx @@ -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(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 */} -
{ - e.preventDefault(); - props.onClose(); +
{ + // keep it open when clicking inside + e.stopPropagation(); + }} + > + { + props.onAddTextAt(ctx.worldX, ctx.worldY); + onClose(); }} - style={{ position: 'fixed', inset: 0, zIndex: 999 }} /> - + + { + props.onDuplicateSelected?.(); + onClose(); }} - styles={{ body: { padding: 8 } }} - > - - - - - - + /> + + { + props.onToggleLockSelected?.(); + onClose(); + }} + /> + { + props.onToggleHideSelected?.(); + onClose(); + }} + /> + +
+ + { + props.onBringToFrontSelected?.(); + onClose(); + }} + /> + { + props.onSendToBackSelected?.(); + onClose(); + }} + /> + + { + props.onDeleteSelected?.(); + onClose(); + }} + /> + + + ({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)}) + +
+ ); +} + +function MenuItem(props: { + label: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; +}) { + return ( +
{ + 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} +
); } diff --git a/packages/editor/src/editor/EditorApp.tsx b/packages/editor/src/editor/EditorApp.tsx index 93fca77..e9fa216 100644 --- a/packages/editor/src/editor/EditorApp.tsx +++ b/packages/editor/src/editor/EditorApp.tsx @@ -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('charts'); const [rightTab, setRightTab] = useState('custom'); + const [ctxMenu, setCtxMenu] = useState(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() { 占位:页面背景 / 主题 / 配色将在后续补齐。 )} + + + {state.doc.screen.nodes.length}} + /> +
+ {orderedNodes.length === 0 ? ( + No layers + ) : ( + orderedNodes.map((node) => { + const isSelected = state.selection.ids.includes(node.id); + const status = `${node.locked ? 'L' : ''}${node.hidden ? 'H' : ''}`; + return ( +
{ + 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', + }} + > +
+ {node.type} + {node.id} +
+ {status ? {status} : null} +
+ ); + }) + )} +
+ + 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' })} + /> ); } diff --git a/packages/editor/src/editor/Inspector.tsx b/packages/editor/src/editor/Inspector.tsx index 82726f8..7d23a27 100644 --- a/packages/editor/src/editor/Inspector.tsx +++ b/packages/editor/src/editor/Inspector.tsx @@ -140,6 +140,21 @@ export function Inspector(props: {
+ +
+ Autoplay +