From de61130efe19dda9d706a41f1b7dc5be794376c3 Mon Sep 17 00:00:00 2001 From: YoLin02 Date: Thu, 18 Jun 2026 12:01:27 +0800 Subject: [PATCH] Fix canvas node interactions and editor layout --- src/app/App.tsx | 61 ++++++++++- src/features/canvas/CanvasViewport.tsx | 4 + src/features/canvas/FlowCanvas.tsx | 33 ++++++ .../components/CanvasPropertiesPanel.tsx | 3 +- .../canvas/components/MediaLibraryDrawer.tsx | 2 +- .../canvas/hooks/useCanvasMediaLibrary.ts | 8 +- .../canvas/hooks/useCanvasNodeCommands.ts | 26 +++-- .../canvas/hooks/useCanvasPresentation.ts | 9 +- src/features/canvas/nodes/IdeaNode.tsx | 37 ++++++- src/features/canvas/nodes/ImageNode.tsx | 102 +++++++++++++----- src/features/canvas/nodes/StandardHandles.tsx | 54 ++++------ src/features/canvas/nodes/TextNode.tsx | 34 +++++- src/features/canvas/types.ts | 3 + src/features/canvas/utils/createCanvasNode.ts | 2 + .../canvas/utils/normalizeEdgeHandles.ts | 72 +++++++++---- .../canvas/utils/presentationUtils.ts | 11 +- 16 files changed, 354 insertions(+), 107 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 359a162..97b8e52 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -17,6 +17,10 @@ import { dbGet, dbSet, removeLegacyLocalStorageKeys } from '../shared/storage/db const STORAGE_KEY = 'visual_text_flow_state'; const MAX_HISTORY_ENTRIES = 50; const EMPTY_DOCUMENT_HTML = '

开始书写

在此输入草稿,或从段落切片管理器将内容转化为卡片节点。

'; +const MIN_EDITOR_WIDTH = 320; +const DEFAULT_EDITOR_WIDTH = 480; +const OUTLINE_EDITOR_WIDTH = 732; +const MIN_CANVAS_WIDTH = 360; interface HistoryAvailability { canUndo: boolean; @@ -38,6 +42,8 @@ export default function App() { const [activeTab, setActiveTab] = useState<'editor' | 'canvas' | 'split'>('split'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isEditorOutlineOpen, setIsEditorOutlineOpen] = useState(false); + const [editorWidth, setEditorWidth] = useState(DEFAULT_EDITOR_WIDTH); + const [isEditorResizing, setIsEditorResizing] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const [saveStatus, setSaveStatus] = useState('idle'); const [lastSavedAt, setLastSavedAt] = useState(null); @@ -61,6 +67,12 @@ export default function App() { const didSeedHistoryRef = useRef(false); const isRestoringHistoryRef = useRef(false); + const clampEditorWidth = useCallback((width: number) => { + if (typeof window === 'undefined') return Math.max(MIN_EDITOR_WIDTH, width); + const maxWidth = Math.max(MIN_EDITOR_WIDTH, Math.min(window.innerWidth * 0.72, window.innerWidth - MIN_CANVAS_WIDTH)); + return Math.round(Math.min(Math.max(width, MIN_EDITOR_WIDTH), maxWidth)); + }, []); + const createWorkspaceSnapshot = useCallback((): WorkspaceSaveState => { const serializedNodes = nodes.filter(n => n && n.data).map((n) => ({ id: n.id, @@ -160,6 +172,41 @@ export default function App() { localStorage.setItem(SHORTCUT_STORAGE_KEY, JSON.stringify(shortcuts)); }, [shortcuts]); + useEffect(() => { + if (!isEditorOutlineOpen) return; + setEditorWidth((currentWidth) => clampEditorWidth(Math.max(currentWidth, OUTLINE_EDITOR_WIDTH))); + }, [clampEditorWidth, isEditorOutlineOpen]); + + useEffect(() => { + const handleResize = () => setEditorWidth((currentWidth) => clampEditorWidth(currentWidth)); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [clampEditorWidth]); + + useEffect(() => { + if (!isEditorResizing) return; + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const handlePointerMove = (event: PointerEvent) => { + event.preventDefault(); + setEditorWidth(clampEditorWidth(event.clientX)); + }; + const handlePointerUp = () => setIsEditorResizing(false); + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + }, [clampEditorWidth, isEditorResizing]); + // Local Database Save Sync Effect with Debounce to prevent lag during dragging or typing useEffect(() => { if (!isLoaded) return; @@ -286,7 +333,7 @@ export default function App() { setMainDocHtml(newHtml); }, []); - const splitEditorWidth = isEditorOutlineOpen ? 'min(732px, 72vw)' : 'min(480px, 42vw)'; + const splitEditorWidth = `${clampEditorWidth(editorWidth)}px`; return (
@@ -317,6 +364,18 @@ export default function App() { shortcuts={shortcuts} />
+ {!isSidebarCollapsed && activeTab === 'split' && ( +
{ + event.preventDefault(); + setIsEditorResizing(true); + }} + role="separator" + aria-orientation="vertical" + aria-label="调整脚本编辑区宽度" + /> + )}
{/* Sidebar expand collapse trigger button - placed OUTSIDE the column to transition beautifully */} diff --git a/src/features/canvas/CanvasViewport.tsx b/src/features/canvas/CanvasViewport.tsx index 9e27231..5ee4598 100644 --- a/src/features/canvas/CanvasViewport.tsx +++ b/src/features/canvas/CanvasViewport.tsx @@ -1,6 +1,7 @@ import { Background, BackgroundVariant, + ConnectionMode, MiniMap, PanOnScrollMode, ReactFlow, @@ -59,6 +60,7 @@ export default function CanvasViewport({ onEdgesChange={onEdgesChange} onConnect={viewportHandlers.onConnect} nodeTypes={nodeTypes} + connectionMode={ConnectionMode.Loose} onNodeDragStop={viewportHandlers.onNodeDragStop} onNodeClick={viewportHandlers.onNodeClick} onEdgeClick={viewportHandlers.onEdgeClick} @@ -80,6 +82,8 @@ export default function CanvasViewport({ onPointerUp={viewportHandlers.onPointerUp} onPointerLeave={viewportHandlers.onPointerLeave} onPointerCancel={viewportHandlers.onPointerCancel} + onDragOver={viewportHandlers.onDragOver} + onDrop={viewportHandlers.onDrop} > { + const imageFiles = files.filter((file) => file.type.startsWith('image/')); + if (imageFiles.length === 0) return; + + imageFiles.forEach((file, index) => { + const reader = new FileReader(); + reader.onloadend = () => { + const asset = createMediaAsset(file.name, String(reader.result || '')); + const basePosition = screenToFlowPosition({ + x: clientX + index * 24, + y: clientY + index * 24, + }); + const newNode = createImageNodeFromAsset(asset, basePosition); + setNodes((currentNodes) => [...currentNodes, newNode]); + }; + reader.readAsDataURL(file); + }); + }, [screenToFlowPosition, setNodes]); + useCanvasShortcuts({ shortcuts, setNodes, @@ -263,6 +283,19 @@ export default function FlowCanvas({ onPointerUp: pointerPan.endPointerPan, onPointerLeave: pointerPan.endPointerPan, onPointerCancel: pointerPan.resetPointerPan, + onDragOver: (event) => { + const hasImage = Array.from(event.dataTransfer.items || []).some((item) => item.kind === 'file' && item.type.startsWith('image/')); + if (!hasImage) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }, + onDrop: (event) => { + const files = Array.from(event.dataTransfer.files || []).filter((file) => file.type.startsWith('image/')); + if (files.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + addImageFilesToCanvas(files, event.clientX, event.clientY); + }, }; const viewportShellHandlers: ViewportShellHandlers = { diff --git a/src/features/canvas/components/CanvasPropertiesPanel.tsx b/src/features/canvas/components/CanvasPropertiesPanel.tsx index 819ce2c..516be7a 100644 --- a/src/features/canvas/components/CanvasPropertiesPanel.tsx +++ b/src/features/canvas/components/CanvasPropertiesPanel.tsx @@ -88,7 +88,8 @@ function EdgePropertiesPanel({ onUpdateEdge: (edgeId: string, patch: Partial) => void; }) { const edgeColor = typeof selectedEdge.style?.stroke === 'string' ? selectedEdge.style.stroke : '#737373'; - const isDashed = typeof selectedEdge.style?.strokeDasharray === 'string' && selectedEdge.style.strokeDasharray.length > 0; + const isDashed = selectedEdge.animated === true + || (typeof selectedEdge.style?.strokeDasharray === 'string' && selectedEdge.style.strokeDasharray.length > 0); const edgeRouteType = selectedEdge.type === 'step' ? 'step' : 'default'; return ( diff --git a/src/features/canvas/components/MediaLibraryDrawer.tsx b/src/features/canvas/components/MediaLibraryDrawer.tsx index 24a4d7e..f52436e 100644 --- a/src/features/canvas/components/MediaLibraryDrawer.tsx +++ b/src/features/canvas/components/MediaLibraryDrawer.tsx @@ -61,7 +61,7 @@ export default function MediaLibraryDrawer({ > 拖拽上传 / 批量选择 - 支持批量上传到文件夹,自动插到画布 + 支持批量上传到文件夹,点击图片后插入画布 [...currentNodes, newNode]); }; reader.readAsDataURL(file); }); - }, [setNodes]); + }, []); const insertAsset = useCallback((asset: CanvasMediaAsset) => { setNodes((currentNodes) => { diff --git a/src/features/canvas/hooks/useCanvasNodeCommands.ts b/src/features/canvas/hooks/useCanvasNodeCommands.ts index 65dbd7b..e0ecd6c 100644 --- a/src/features/canvas/hooks/useCanvasNodeCommands.ts +++ b/src/features/canvas/hooks/useCanvasNodeCommands.ts @@ -17,6 +17,23 @@ interface UseCanvasNodeCommandsOptions { onAfterSelectionMutation?: () => void; } +function isLegacyCustomHandleMatch(edgeHandleId: string | null | undefined, handleId: string) { + return edgeHandleId === handleId + || edgeHandleId === `${handleId}-src` + || edgeHandleId === `${handleId}-tgt`; +} + +function isEdgeUsingCustomHandle(edge: Edge, nodeId: string, handleId: string) { + return (edge.source === nodeId && isLegacyCustomHandleMatch(edge.sourceHandle, handleId)) + || (edge.target === nodeId && isLegacyCustomHandleMatch(edge.targetHandle, handleId)); +} + +function isEdgeUsingInvalidTimelineTick(edge: Edge, nodeId: string, validTickIds: Set) { + if (edge.source === nodeId && edge.sourceHandle && !validTickIds.has(edge.sourceHandle)) return true; + if (edge.target === nodeId && edge.targetHandle && !validTickIds.has(edge.targetHandle)) return true; + return false; +} + export function useCanvasNodeCommands({ selectedNodes, setNodes, @@ -74,7 +91,7 @@ export function useCanvasNodeCommands({ if (timelineData?.ticks) { const validTickIds = new Set(timelineData.ticks.map((tick) => tick.id)); setEdges((currentEdges) => - currentEdges.filter((edge) => edge.source !== id || !edge.sourceHandle || validTickIds.has(edge.sourceHandle)), + currentEdges.filter((edge) => !isEdgeUsingInvalidTimelineTick(edge, id, validTickIds)), ); return; } @@ -85,7 +102,7 @@ export function useCanvasNodeCommands({ if (!Array.isArray(parsed.ticks)) return; const validTickIds = new Set(parsed.ticks.map((tick) => tick.id)); setEdges((currentEdges) => - currentEdges.filter((edge) => edge.source !== id || !edge.sourceHandle || validTickIds.has(edge.sourceHandle)), + currentEdges.filter((edge) => !isEdgeUsingInvalidTimelineTick(edge, id, validTickIds)), ); } catch {} }, [setEdges, setNodes]); @@ -173,10 +190,7 @@ export function useCanvasNodeCommands({ setEdges((currentEdges) => currentEdges.filter((edge) => - !( - (edge.source === nodeId && edge.sourceHandle?.startsWith(`${handleId}-`)) || - (edge.target === nodeId && edge.targetHandle?.startsWith(`${handleId}-`)) - ), + !isEdgeUsingCustomHandle(edge, nodeId, handleId), ), ); }, [setEdges, setNodes]); diff --git a/src/features/canvas/hooks/useCanvasPresentation.ts b/src/features/canvas/hooks/useCanvasPresentation.ts index 0daf4cb..133b123 100644 --- a/src/features/canvas/hooks/useCanvasPresentation.ts +++ b/src/features/canvas/hooks/useCanvasPresentation.ts @@ -46,10 +46,13 @@ export function useCanvasPresentation(nodes: WorkspaceNode[], edges: Edge[]) { return edges.map((edge) => { const isSourceConnected = connectedNodeIds.has(edge.source); const isTargetConnected = connectedNodeIds.has(edge.target); + const isActiveTickEdge = !!activeTickDetails + && ( + (edge.source === activeTickDetails.nodeId && edge.sourceHandle === activeTickDetails.tickId) + || (edge.target === activeTickDetails.nodeId && edge.targetHandle === activeTickDetails.tickId) + ); const isLinkActive = activeTickDetails - ? edge.source === activeTickDetails.nodeId - ? edge.sourceHandle === activeTickDetails.tickId - : isSourceConnected && isTargetConnected + ? isActiveTickEdge || (isSourceConnected && isTargetConnected) : isSourceConnected && isTargetConnected; return { diff --git a/src/features/canvas/nodes/IdeaNode.tsx b/src/features/canvas/nodes/IdeaNode.tsx index cf06bec..75f68cd 100644 --- a/src/features/canvas/nodes/IdeaNode.tsx +++ b/src/features/canvas/nodes/IdeaNode.tsx @@ -9,6 +9,8 @@ import type { IdeaCanvasNodeData } from '../../../types'; const DEFAULT_IDEA_NODE_WIDTH = 260; const IDEA_NODE_MIN_HEIGHT = 100; const IDEA_NODE_VERTICAL_CHROME = 68; +const IDEA_NODE_BODY_HORIZONTAL_PADDING = 24; +const IDEA_NODE_DISPLAY_VERTICAL_CHROME = 82; export const IdeaNode = memo(({ id, data, selected }: { id: string; data: IdeaCanvasNodeData; selected?: boolean }) => { const { onDeleteNode, onUpdateContent, editingId, setEditingId } = useContext(NodeActionContext); @@ -32,8 +34,39 @@ export const IdeaNode = memo(({ id, data, selected }: { id: string; data: IdeaCa return Math.max(IDEA_NODE_MIN_HEIGHT, textarea.scrollHeight + IDEA_NODE_VERTICAL_CHROME); }; + const measureDisplayHeight = (text: string) => { + if (typeof document === 'undefined') return measureDraftHeight(); + const nodeWidth = nodeRef.current?.getBoundingClientRect().width || data.width || DEFAULT_IDEA_NODE_WIDTH; + const measureEl = document.createElement('div'); + measureEl.textContent = text || '空白灵感卡... 双击进行编辑'; + Object.assign(measureEl.style, { + position: 'fixed', + left: '-9999px', + top: '0', + width: `${Math.max(80, nodeWidth - IDEA_NODE_BODY_HORIZONTAL_PADDING - 20)}px`, + visibility: 'hidden', + whiteSpace: 'normal', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + fontSize: '12px', + fontFamily: 'sans-serif', + fontStyle: 'italic', + lineHeight: 'normal', + padding: '10px', + border: '1px solid transparent', + }); + document.body.appendChild(measureEl); + const measuredHeight = measureEl.scrollHeight; + measureEl.remove(); + return Math.max(IDEA_NODE_MIN_HEIGHT, measuredHeight + IDEA_NODE_DISPLAY_VERTICAL_CHROME); + }; + const persistContent = () => { - const nextHeight = Math.max(draftHeight || measureDraftHeight(), data.height || IDEA_NODE_MIN_HEIGHT); + const nextHeight = Math.max( + draftHeight || measureDraftHeight(), + measureDisplayHeight(editorVal), + data.height || IDEA_NODE_MIN_HEIGHT, + ); onUpdateContent?.(id, editorVal, undefined, undefined, undefined, { width: data.width || DEFAULT_IDEA_NODE_WIDTH, height: nextHeight, @@ -162,7 +195,7 @@ export const IdeaNode = memo(({ id, data, selected }: { id: string; data: IdeaCa placeholder="写下你的灵感火花..." /> ) : ( -

+

{data.content || 空白灵感卡... 双击进行编辑}

)} diff --git a/src/features/canvas/nodes/ImageNode.tsx b/src/features/canvas/nodes/ImageNode.tsx index 59d237d..4495fb1 100644 --- a/src/features/canvas/nodes/ImageNode.tsx +++ b/src/features/canvas/nodes/ImageNode.tsx @@ -29,7 +29,7 @@ export const ImageNode = memo(({ id, data, selected }: { id: string; data: Image : imageDisplayMode === 'original' ? 'max-h-full max-w-full object-contain' : 'max-h-full max-w-full object-contain'; - const isPureImageMode = imageNodeDisplayMode === 'image-only' && !!imageUrl; + const isPureImageMode = imageNodeDisplayMode === 'image-only'; // Sync state with outer modifications useEffect(() => { @@ -45,20 +45,41 @@ export const ImageNode = memo(({ id, data, selected }: { id: string; data: Image onUpdateContent?.(id, data.content, titleVal, imageUrl, caption); }; + const applyImageFile = (file: File) => { + const nameWithoutExtension = file.name.substring(0, file.name.lastIndexOf('.')) || file.name; + const reader = new FileReader(); + reader.onloadend = () => { + const base64String = reader.result as string; + setImageUrl(base64String); + setTitleVal(nameWithoutExtension); + setCaption(nameWithoutExtension); + onUpdateContent?.(id, data.content, nameWithoutExtension, base64String, nameWithoutExtension); + }; + reader.readAsDataURL(file); + }; + const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - const nameWithoutExtension = file.name.substring(0, file.name.lastIndexOf('.')) || file.name; - const reader = new FileReader(); - reader.onloadend = () => { - const base64String = reader.result as string; - setImageUrl(base64String); - setTitleVal(nameWithoutExtension); - setCaption(nameWithoutExtension); - onUpdateContent?.(id, data.content, nameWithoutExtension, base64String, nameWithoutExtension); - }; - reader.readAsDataURL(file); + applyImageFile(file); } + e.target.value = ''; + }; + + const handleImageDragOver = (e: React.DragEvent) => { + const hasImage = Array.from(e.dataTransfer.items || []).some((item) => item.kind === 'file' && item.type.startsWith('image/')); + if (!hasImage) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + }; + + const handleImageDrop = (e: React.DragEvent) => { + const file = Array.from(e.dataTransfer.files || []).find((item) => item.type.startsWith('image/')); + if (!file) return; + e.preventDefault(); + e.stopPropagation(); + applyImageFile(file); }; const handleUrlSubmit = (e: React.FormEvent) => { @@ -118,37 +139,58 @@ export const ImageNode = memo(({ id, data, selected }: { id: string; data: Image }} onDoubleClick={() => setIsEditing(true)} onClickCapture={handleDynamicHandleClick} + onDragOver={handleImageDragOver} + onDrop={handleImageDrop} > -
- {caption -
+ + + {imageUrl ? ( +
+ {caption +
+ ) : ( +
+ + 拖放或上传图片 + +
+ )}
@@ -239,7 +281,11 @@ export const ImageNode = memo(({ id, data, selected }: { id: string; data: Image
) : ( -
+
{isUrlInput ? (
- - - - - - - - + + + + {customHandles.map((handle) => { const position = POSITION_BY_SIDE[handle.side]; const style = getCustomHandleStyle(handle); return ( - - { - if (!event.altKey) return; - event.preventDefault(); - event.stopPropagation(); - onDeleteCustomHandle?.(nodeId, handle.id); - }} - /> - { - if (!event.altKey) return; - event.preventDefault(); - event.stopPropagation(); - onDeleteCustomHandle?.(nodeId, handle.id); - }} - /> - + { + if (!event.altKey) return; + event.preventDefault(); + event.stopPropagation(); + onDeleteCustomHandle?.(nodeId, handle.id); + }} + /> ); })} diff --git a/src/features/canvas/nodes/TextNode.tsx b/src/features/canvas/nodes/TextNode.tsx index 6c084d2..c3358d7 100644 --- a/src/features/canvas/nodes/TextNode.tsx +++ b/src/features/canvas/nodes/TextNode.tsx @@ -9,6 +9,8 @@ import type { TextCanvasNodeData } from '../../../types'; const DEFAULT_TEXT_NODE_WIDTH = 280; const TEXT_NODE_MIN_HEIGHT = 120; const TEXT_NODE_VERTICAL_CHROME = 106; +const TEXT_NODE_BODY_HORIZONTAL_PADDING = 32; +const TEXT_NODE_DISPLAY_VERTICAL_CHROME = 104; export const TextNode = memo(({ id, data, selected }: { id: string; data: TextCanvasNodeData; selected?: boolean }) => { const { onDeleteNode, onUpdateContent, editingId, setEditingId } = useContext(NodeActionContext); @@ -33,8 +35,36 @@ export const TextNode = memo(({ id, data, selected }: { id: string; data: TextCa return Math.max(TEXT_NODE_MIN_HEIGHT, textarea.scrollHeight + TEXT_NODE_VERTICAL_CHROME); }; + const measureDisplayHeight = (text: string) => { + if (typeof document === 'undefined') return measureDraftHeight(); + const nodeWidth = nodeRef.current?.getBoundingClientRect().width || data.width || DEFAULT_TEXT_NODE_WIDTH; + const measureEl = document.createElement('div'); + measureEl.textContent = text || '空白文本卡片... 双击进行编辑'; + Object.assign(measureEl.style, { + position: 'fixed', + left: '-9999px', + top: '0', + width: `${Math.max(80, nodeWidth - TEXT_NODE_BODY_HORIZONTAL_PADDING)}px`, + visibility: 'hidden', + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + fontSize: '12px', + fontFamily: 'sans-serif', + lineHeight: '1.625', + }); + document.body.appendChild(measureEl); + const measuredHeight = measureEl.scrollHeight; + measureEl.remove(); + return Math.max(TEXT_NODE_MIN_HEIGHT, measuredHeight + TEXT_NODE_DISPLAY_VERTICAL_CHROME); + }; + const persistContent = () => { - const nextHeight = Math.max(draftHeight || measureDraftHeight(), data.height || TEXT_NODE_MIN_HEIGHT); + const nextHeight = Math.max( + draftHeight || measureDraftHeight(), + measureDisplayHeight(editorVal), + data.height || TEXT_NODE_MIN_HEIGHT, + ); onUpdateContent?.(id, editorVal, titleVal, undefined, undefined, { width: data.width || DEFAULT_TEXT_NODE_WIDTH, height: nextHeight, @@ -176,7 +206,7 @@ export const TextNode = memo(({ id, data, selected }: { id: string; data: TextCa onClick={(e) => e.stopPropagation()} /> ) : ( -

+

{data.content || 空白文本卡片... 双击进行编辑}

)} diff --git a/src/features/canvas/types.ts b/src/features/canvas/types.ts index bc275f9..bef5d73 100644 --- a/src/features/canvas/types.ts +++ b/src/features/canvas/types.ts @@ -1,6 +1,7 @@ import type { Connection, Edge, EdgeChange, NodeChange, OnEdgesChange, OnNodesChange } from '@xyflow/react'; import type { Dispatch, + DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, MutableRefObject, PointerEvent as ReactPointerEvent, @@ -71,6 +72,8 @@ export interface ViewportHandlers { onPointerUp: () => void; onPointerLeave: () => void; onPointerCancel: () => void; + onDragOver: (event: ReactDragEvent) => void; + onDrop: (event: ReactDragEvent) => void; } export interface ViewportShellHandlers { diff --git a/src/features/canvas/utils/createCanvasNode.ts b/src/features/canvas/utils/createCanvasNode.ts index 8a07826..09bcf72 100644 --- a/src/features/canvas/utils/createCanvasNode.ts +++ b/src/features/canvas/utils/createCanvasNode.ts @@ -14,6 +14,8 @@ export function createCanvasNode(type: NodeType, position: { x: number; y: numbe extraData = { imageNodeDisplayMode, imageDisplayMode: imageNodeDisplayMode === 'image-only' ? 'cover' : 'contain', + imageUrl: '', + imageCaption: '', width: 280, height: 180, } as Partial; diff --git a/src/features/canvas/utils/normalizeEdgeHandles.ts b/src/features/canvas/utils/normalizeEdgeHandles.ts index 527f313..974b11e 100644 --- a/src/features/canvas/utils/normalizeEdgeHandles.ts +++ b/src/features/canvas/utils/normalizeEdgeHandles.ts @@ -1,23 +1,55 @@ import type { Edge } from '@xyflow/react'; import type { CanvasNodeHandleData, WorkspaceNode } from '../../../types'; -const DEFAULT_SOURCE_HANDLE = 'r-src'; -const DEFAULT_TARGET_HANDLE = 'l-tgt'; -const DEFAULT_SOURCE_HANDLES = new Set(['t-src', DEFAULT_SOURCE_HANDLE, 'b-src', 'l-src']); -const DEFAULT_TARGET_HANDLES = new Set(['t-tgt', 'r-tgt', 'b-tgt', DEFAULT_TARGET_HANDLE]); +const DEFAULT_SOURCE_HANDLE = 'r'; +const DEFAULT_TARGET_HANDLE = 'l'; +const DEFAULT_NODE_HANDLES = new Set(['t', 'r', 'b', 'l']); +const LEGACY_HANDLE_MAP: Record = { + 't-src': 't', + 't-tgt': 't', + 'r-src': 'r', + 'r-tgt': 'r', + 'b-src': 'b', + 'b-tgt': 'b', + 'l-src': 'l', + 'l-tgt': 'l', +}; -function getCustomHandleIds(customHandles: CanvasNodeHandleData[] | undefined, suffix: 'src' | 'tgt') { - return (customHandles || []).map((handle) => `${handle.id}-${suffix}`); +function getTimelineTickIds(node: WorkspaceNode | undefined) { + if (!node || node.data.type !== 'timeline') return new Set(); + const structuredTickIds = (node.data.timelineData?.ticks || []).map((tick) => tick.id); + if (structuredTickIds.length > 0) return new Set(structuredTickIds); + + try { + const parsed = JSON.parse(node.data.content || '{}') as { ticks?: Array<{ id?: string }> }; + if (!Array.isArray(parsed.ticks)) return new Set(); + return new Set(parsed.ticks.map((tick) => tick.id).filter((id): id is string => !!id)); + } catch { + return new Set(); + } } -function isValidSourceHandle(node: WorkspaceNode | undefined, handleId: string | null | undefined) { - if (!node || !handleId) return false; - return DEFAULT_SOURCE_HANDLES.has(handleId) || getCustomHandleIds(node.data.customHandles, 'src').includes(handleId); +function normalizeLegacyHandleId(handleId: string | null | undefined) { + if (!handleId) return undefined; + if (LEGACY_HANDLE_MAP[handleId]) return LEGACY_HANDLE_MAP[handleId]; + if (handleId.endsWith('-src') || handleId.endsWith('-tgt')) { + return handleId.replace(/-(src|tgt)$/, ''); + } + return handleId; } -function isValidTargetHandle(node: WorkspaceNode | undefined, handleId: string | null | undefined) { +function isValidHandle(node: WorkspaceNode | undefined, handleId: string | null | undefined) { if (!node || !handleId) return false; - return DEFAULT_TARGET_HANDLES.has(handleId) || getCustomHandleIds(node.data.customHandles, 'tgt').includes(handleId); + if (node.data.type === 'timeline') return getTimelineTickIds(node).has(handleId); + return DEFAULT_NODE_HANDLES.has(handleId) + || (node.data.customHandles || []).some((handle: CanvasNodeHandleData) => handle.id === handleId); +} + +function getFallbackHandle(node: WorkspaceNode | undefined, kind: 'source' | 'target') { + if (node?.data.type === 'timeline') { + return Array.from(getTimelineTickIds(node))[0]; + } + return kind === 'source' ? DEFAULT_SOURCE_HANDLE : DEFAULT_TARGET_HANDLE; } export function normalizeEdgeHandles(nodes: WorkspaceNode[], edges: Edge[]) { @@ -26,18 +58,20 @@ export function normalizeEdgeHandles(nodes: WorkspaceNode[], edges: Edge[]) { return edges.map((edge) => { const sourceNode = nodesById.get(edge.source); const targetNode = nodesById.get(edge.target); - const sourceHandle = isValidSourceHandle(sourceNode, edge.sourceHandle) - ? edge.sourceHandle - : DEFAULT_SOURCE_HANDLE; - const targetHandle = isValidTargetHandle(targetNode, edge.targetHandle) - ? edge.targetHandle - : DEFAULT_TARGET_HANDLE; + const normalizedSourceHandle = normalizeLegacyHandleId(edge.sourceHandle); + const normalizedTargetHandle = normalizeLegacyHandleId(edge.targetHandle); + const sourceHandle = isValidHandle(sourceNode, normalizedSourceHandle) + ? normalizedSourceHandle + : getFallbackHandle(sourceNode, 'source'); + const targetHandle = isValidHandle(targetNode, normalizedTargetHandle) + ? normalizedTargetHandle + : getFallbackHandle(targetNode, 'target'); if (edge.sourceHandle === sourceHandle && edge.targetHandle === targetHandle) return edge; return { ...edge, - sourceHandle, - targetHandle, + sourceHandle: sourceHandle || edge.sourceHandle, + targetHandle: targetHandle || edge.targetHandle, }; }); } diff --git a/src/features/canvas/utils/presentationUtils.ts b/src/features/canvas/utils/presentationUtils.ts index c19b8e6..9a42be2 100644 --- a/src/features/canvas/utils/presentationUtils.ts +++ b/src/features/canvas/utils/presentationUtils.ts @@ -36,10 +36,19 @@ export function getConnectedNodeIds( const queue: string[] = []; edges.forEach((edge) => { - if (edge.source === activeTickDetails.nodeId && edge.sourceHandle === activeTickDetails.tickId) { + const startsFromActiveTick = + edge.source === activeTickDetails.nodeId && edge.sourceHandle === activeTickDetails.tickId; + const endsAtActiveTick = + edge.target === activeTickDetails.nodeId && edge.targetHandle === activeTickDetails.tickId; + + if (startsFromActiveTick) { visited.add(edge.target); queue.push(edge.target); } + if (endsAtActiveTick) { + visited.add(edge.source); + queue.push(edge.source); + } }); while (queue.length > 0) {