diff --git a/src/features/canvas/CanvasOverlays.tsx b/src/features/canvas/CanvasOverlays.tsx index 9f239bd..7a6f031 100644 --- a/src/features/canvas/CanvasOverlays.tsx +++ b/src/features/canvas/CanvasOverlays.tsx @@ -44,6 +44,7 @@ interface CanvasOverlaysProps { onUpdateNodes: (nodeIds: string[], patch: Partial) => void; onResizeNodes: (nodeIds: string[], width?: number, height?: number) => void; onUpdateEdge: (edgeId: string, patch: Partial) => void; + onSaveSelectionAsTemplate?: () => void; }; contextMenu: { state: CanvasContextMenuState | null; @@ -134,6 +135,7 @@ export default function CanvasOverlays({ onUpdateNodes={properties.onUpdateNodes} onResizeNodes={properties.onResizeNodes} onUpdateEdge={properties.onUpdateEdge} + onSaveSelectionAsTemplate={properties.onSaveSelectionAsTemplate} /> )} diff --git a/src/features/canvas/FlowCanvas.tsx b/src/features/canvas/FlowCanvas.tsx index db49035..4ff8195 100644 --- a/src/features/canvas/FlowCanvas.tsx +++ b/src/features/canvas/FlowCanvas.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useReactFlow } from '@xyflow/react'; import type { Edge } from '@xyflow/react'; import { NodeActionContext } from './nodes'; @@ -47,10 +47,11 @@ export default function FlowCanvas({ const [editingId, setEditingId] = useState(null); const [showClearConfirmModal, setShowClearConfirmModal] = useState(false); const [isTemplatePanelOpen, setIsTemplatePanelOpen] = useState(false); + const [timelineFocusDisabledIds, setTimelineFocusDisabledIds] = useState>(() => new Set()); const pointerPan = useCanvasPointerPan(); const edgeCommands = useCanvasEdgeCommands({ setEdges }); - const presentation = useCanvasPresentation(nodes, edges); + const presentation = useCanvasPresentation(nodes, edges, timelineFocusDisabledIds); const contextMenu = useCanvasContextMenu({ screenToFlowPosition, setEditingId, @@ -202,21 +203,84 @@ export default function FlowCanvas({ onCloseTransientUi: closeTransientUi, }); + useEffect(() => { + setTimelineFocusDisabledIds((currentIds) => { + if (currentIds.size === 0) return currentIds; + + const selectedTimelineIds = new Set( + nodes + .filter((node) => node.type === 'timeline' && node.selected) + .map((node) => node.id), + ); + const nextIds = new Set([...currentIds].filter((id) => selectedTimelineIds.has(id))); + return nextIds.size === currentIds.size ? currentIds : nextIds; + }); + }, [nodes]); + + const exitTimelineFocus = useCallback((nodeId: string, keepSelected = true) => { + setEditingId(null); + setTimelineFocusDisabledIds((currentIds) => { + const nextIds = new Set(currentIds); + if (keepSelected) { + nextIds.add(nodeId); + } else { + nextIds.delete(nodeId); + } + return nextIds; + }); + setNodes((currentNodes) => + currentNodes.map((node) => { + if (node.id !== nodeId || node.data.type !== 'timeline') return node; + let nextContent = node.data.content; + if (!node.data.timelineData && node.data.content?.trim().startsWith('{')) { + try { + nextContent = JSON.stringify({ + ...JSON.parse(node.data.content), + activeTickId: null, + }); + } catch { + nextContent = node.data.content; + } + } + return { + ...node, + selected: keepSelected ? node.selected : false, + data: { + ...node.data, + content: nextContent, + timelineData: node.data.timelineData + ? { + ...node.data.timelineData, + activeTickId: null, + } + : node.data.timelineData, + }, + }; + }), + ); + }, [setNodes]); + const nodeActionContextValue = useMemo(() => ({ onDeleteNode: nodeCommands.deleteNode, onUpdateContent: nodeCommands.updateContent, onAddCustomHandle: nodeCommands.addCustomHandle, onDeleteCustomHandle: nodeCommands.deleteCustomHandle, + onExitTimelineFocus: exitTimelineFocus, + timelineFocusDisabledIds, editingId, setEditingId, + selectedNodeCount: presentation.selectedNodes.length, shortcuts, }), [ editingId, + exitTimelineFocus, nodeCommands.addCustomHandle, nodeCommands.deleteCustomHandle, nodeCommands.deleteNode, nodeCommands.updateContent, + presentation.selectedNodes.length, shortcuts, + timelineFocusDisabledIds, ]); const viewportHandlers: ViewportHandlers = { @@ -385,6 +449,7 @@ export default function FlowCanvas({ onUpdateNodes: nodeCommands.updateNodesFromPanel, onResizeNodes: nodeCommands.resizeNodesFromPanel, onUpdateEdge: edgeCommands.updateEdgeFromPanel, + onSaveSelectionAsTemplate: templates.saveSelectionAsTemplate, }} contextMenu={{ state: contextMenu.contextMenu, diff --git a/src/features/canvas/components/CanvasPropertiesPanel.tsx b/src/features/canvas/components/CanvasPropertiesPanel.tsx index 516be7a..5b03cb3 100644 --- a/src/features/canvas/components/CanvasPropertiesPanel.tsx +++ b/src/features/canvas/components/CanvasPropertiesPanel.tsx @@ -1,5 +1,5 @@ import { Edge, MarkerType } from '@xyflow/react'; -import { AlignCenter, AlignLeft, AlignRight, CornerDownRight, Link2, MousePointer2, Route, SquareDashedMousePointer, Upload } from 'lucide-react'; +import { AlignCenter, AlignLeft, AlignRight, Boxes, CornerDownRight, Link2, MousePointer2, Route, SquareDashedMousePointer, Upload } from 'lucide-react'; import { useRef, useState } from 'react'; import type { ChangeEvent, ReactNode } from 'react'; import { TableCellSelection, TableNodeDataValue, TableTextAlign, WorkspaceNode } from '../../../types'; @@ -11,6 +11,7 @@ interface CanvasPropertiesPanelProps { onUpdateNodes: (nodeIds: string[], patch: Partial) => void; onResizeNodes: (nodeIds: string[], width?: number, height?: number) => void; onUpdateEdge: (edgeId: string, patch: Partial) => void; + onSaveSelectionAsTemplate?: () => void; } const STATUS_OPTIONS = ['', '草稿', '待补充', '已确认', '重点', '废弃']; @@ -41,6 +42,7 @@ export default function CanvasPropertiesPanel({ onUpdateNodes, onResizeNodes, onUpdateEdge, + onSaveSelectionAsTemplate, }: CanvasPropertiesPanelProps) { if (selectedNodes.length === 0 && !selectedEdge) return null; @@ -71,6 +73,13 @@ export default function CanvasPropertiesPanel({ onUpdateNodes={onUpdateNodes} /> + {!isSingle && onSaveSelectionAsTemplate && ( + + )} + {isSingle && firstNode.type !== 'timeline' && ( @@ -80,6 +89,29 @@ export default function CanvasPropertiesPanel({ ); } +function BatchTemplateAction({ + selectedCount, + onSaveSelectionAsTemplate, +}: { + selectedCount: number; + onSaveSelectionAsTemplate: () => void; +}) { + return ( +
+ +
+ ); +} + function EdgePropertiesPanel({ selectedEdge, onUpdateEdge, diff --git a/src/features/canvas/hooks/useCanvasPresentation.ts b/src/features/canvas/hooks/useCanvasPresentation.ts index 133b123..5c57a80 100644 --- a/src/features/canvas/hooks/useCanvasPresentation.ts +++ b/src/features/canvas/hooks/useCanvasPresentation.ts @@ -3,19 +3,32 @@ import type { Edge } from '@xyflow/react'; import type { WorkspaceNode } from '../../../types'; import { getActiveTickDetails, getConnectedNodeIds } from '../utils/presentationUtils'; -export function useCanvasPresentation(nodes: WorkspaceNode[], edges: Edge[]) { +export function useCanvasPresentation( + nodes: WorkspaceNode[], + edges: Edge[], + timelineFocusDisabledIds: Set, +) { const activeTimelineNode = useMemo( - () => nodes.find((node) => node.type === 'timeline' && node.selected), - [nodes], + () => nodes.find((node) => ( + node.type === 'timeline' + && node.selected + && !timelineFocusDisabledIds.has(node.id) + )), + [nodes, timelineFocusDisabledIds], ); const selectedNodes = useMemo(() => nodes.filter((node) => node.selected), [nodes]); const selectedNodesForProperties = useMemo( - () => selectedNodes.some((node) => node.type === 'timeline') ? [] : selectedNodes, - [selectedNodes], + () => selectedNodes.some((node) => node.type === 'timeline' && !timelineFocusDisabledIds.has(node.id)) + ? [] + : selectedNodes, + [selectedNodes, timelineFocusDisabledIds], ); - const activeTickDetails = useMemo(() => getActiveTickDetails(nodes), [nodes]); + const activeTickDetails = useMemo( + () => getActiveTickDetails(nodes, timelineFocusDisabledIds), + [nodes, timelineFocusDisabledIds], + ); const isFilterActive = !!(activeTimelineNode || activeTickDetails); const connectedNodeIds = useMemo( diff --git a/src/features/canvas/nodes/NodeActionContext.tsx b/src/features/canvas/nodes/NodeActionContext.tsx index e2b6045..16d2719 100644 --- a/src/features/canvas/nodes/NodeActionContext.tsx +++ b/src/features/canvas/nodes/NodeActionContext.tsx @@ -14,8 +14,11 @@ export interface NodeActionContextProps { ) => void; onAddCustomHandle?: (nodeId: string, handle: CanvasNodeHandleData) => void; onDeleteCustomHandle?: (nodeId: string, handleId: string) => void; + onExitTimelineFocus?: (nodeId: string, keepSelected?: boolean) => void; + timelineFocusDisabledIds?: Set; editingId?: string | null; setEditingId?: (id: string | null) => void; + selectedNodeCount?: number; shortcuts?: ShortcutMap; } diff --git a/src/features/canvas/nodes/TimelineNode.tsx b/src/features/canvas/nodes/TimelineNode.tsx index 6682a98..e2630b5 100644 --- a/src/features/canvas/nodes/TimelineNode.tsx +++ b/src/features/canvas/nodes/TimelineNode.tsx @@ -1,7 +1,7 @@ import React, { memo, useContext, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Handle, Position, useUpdateNodeInternals } from '@xyflow/react'; -import { Clock, Plus, X } from 'lucide-react'; +import { Clock, EyeOff, MousePointer2, Plus, X } from 'lucide-react'; import { NodeActionContext } from './NodeActionContext'; import type { TimelineCanvasNodeData, TimelineTrackDataValue } from '../../../types'; import { eventToShortcut, isShortcutEvent, normalizeShortcut } from '../../shortcuts'; @@ -100,10 +100,18 @@ function getTimelineDataFromNode(data: TimelineCanvasNodeData): TimelineTrackDat } export const TimelineNode = memo(({ id, data, selected }: { id: string; data: TimelineCanvasNodeData; selected?: boolean }) => { - const { onDeleteNode, onUpdateContent, shortcuts } = useContext(NodeActionContext); + const { + onDeleteNode, + onUpdateContent, + onExitTimelineFocus, + timelineFocusDisabledIds, + selectedNodeCount = 0, + shortcuts, + } = useContext(NodeActionContext); const updateNodeInternals = useUpdateNodeInternals(); const [state, setState] = useState(() => getTimelineDataFromNode(data)); const [isTimelineAddMode, setIsTimelineAddMode] = useState(false); + const isFocusDisabled = timelineFocusDisabledIds?.has(id) ?? false; // Keep a ref of the current state so drag-and-resize events can access it safely without stale closures const stateRef = useRef(state); @@ -302,14 +310,16 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti 轨道管理器 - +
+ +
@@ -337,10 +347,8 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti
6 ? 'overflow-y-auto' : 'overflow-y-visible'}`} + style={state.ticks.length > 6 ? { maxHeight: '272px' } : undefined} > {state.ticks.map((tick, index) => { const hours = Math.floor(tick.seconds / 3600); @@ -421,7 +429,7 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti })}
-
+
+ {selectedNodeCount > 1 && ( +
+ + +
+ )}
); @@ -528,12 +564,12 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti
- {selected && createPortal( + {selected && !isFocusDisabled && createPortal(
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} - className="fixed right-4 top-24 z-[9000] flex w-[320px] max-w-[calc(100vw-2rem)] flex-col gap-2.5 overflow-x-hidden rounded-2xl border border-neutral-200/80 bg-white/95 p-2.5 text-left shadow-2xl shadow-neutral-900/10 backdrop-blur-md animate-in fade-in slide-in-from-right-2 duration-150" + className="fixed right-4 top-24 z-[9000] flex w-[320px] max-w-[calc(100vw-2rem)] flex-col gap-2.5 rounded-2xl border border-neutral-200/80 bg-white/95 p-2.5 text-left shadow-2xl shadow-neutral-900/10 backdrop-blur-md animate-in fade-in slide-in-from-right-2 duration-150" > {managerContent}
, diff --git a/src/features/canvas/utils/presentationUtils.ts b/src/features/canvas/utils/presentationUtils.ts index 9a42be2..67c9d4a 100644 --- a/src/features/canvas/utils/presentationUtils.ts +++ b/src/features/canvas/utils/presentationUtils.ts @@ -1,9 +1,10 @@ import type { Edge } from '@xyflow/react'; import type { TimelineTrackDataValue, WorkspaceNode } from '../../../types'; -export function getActiveTickDetails(nodes: WorkspaceNode[]) { +export function getActiveTickDetails(nodes: WorkspaceNode[], timelineFocusDisabledIds: Set) { for (const node of nodes) { if (node.type !== 'timeline') continue; + if (timelineFocusDisabledIds.has(node.id)) continue; const timelineData = (node.data as { timelineData?: TimelineTrackDataValue }).timelineData; if (timelineData?.activeTickId) { diff --git a/src/index.css b/src/index.css index 5beb41c..367d704 100644 --- a/src/index.css +++ b/src/index.css @@ -32,6 +32,14 @@ background: #b5b5b5; } +.timeline-manager-tick-list { + scrollbar-width: none; +} + +.timeline-manager-tick-list::-webkit-scrollbar { + display: none; +} + /* TipTap Editor Styles */ .ProseMirror { outline: none;