Skip to content
Merged

Dev #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/features/canvas/CanvasOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface CanvasOverlaysProps {
onUpdateNodes: (nodeIds: string[], patch: Partial<CanvasNodeData>) => void;
onResizeNodes: (nodeIds: string[], width?: number, height?: number) => void;
onUpdateEdge: (edgeId: string, patch: Partial<Edge>) => void;
onSaveSelectionAsTemplate?: () => void;
};
contextMenu: {
state: CanvasContextMenuState | null;
Expand Down Expand Up @@ -134,6 +135,7 @@ export default function CanvasOverlays({
onUpdateNodes={properties.onUpdateNodes}
onResizeNodes={properties.onResizeNodes}
onUpdateEdge={properties.onUpdateEdge}
onSaveSelectionAsTemplate={properties.onSaveSelectionAsTemplate}
/>
)}

Expand Down
69 changes: 67 additions & 2 deletions src/features/canvas/FlowCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,10 +47,11 @@ export default function FlowCanvas({
const [editingId, setEditingId] = useState<string | null>(null);
const [showClearConfirmModal, setShowClearConfirmModal] = useState(false);
const [isTemplatePanelOpen, setIsTemplatePanelOpen] = useState(false);
const [timelineFocusDisabledIds, setTimelineFocusDisabledIds] = useState<Set<string>>(() => 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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -385,6 +449,7 @@ export default function FlowCanvas({
onUpdateNodes: nodeCommands.updateNodesFromPanel,
onResizeNodes: nodeCommands.resizeNodesFromPanel,
onUpdateEdge: edgeCommands.updateEdgeFromPanel,
onSaveSelectionAsTemplate: templates.saveSelectionAsTemplate,
}}
contextMenu={{
state: contextMenu.contextMenu,
Expand Down
34 changes: 33 additions & 1 deletion src/features/canvas/components/CanvasPropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +11,7 @@ interface CanvasPropertiesPanelProps {
onUpdateNodes: (nodeIds: string[], patch: Partial<WorkspaceNode['data']>) => void;
onResizeNodes: (nodeIds: string[], width?: number, height?: number) => void;
onUpdateEdge: (edgeId: string, patch: Partial<Edge>) => void;
onSaveSelectionAsTemplate?: () => void;
}

const STATUS_OPTIONS = ['', '草稿', '待补充', '已确认', '重点', '废弃'];
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function CanvasPropertiesPanel({
onUpdateNodes,
onResizeNodes,
onUpdateEdge,
onSaveSelectionAsTemplate,
}: CanvasPropertiesPanelProps) {
if (selectedNodes.length === 0 && !selectedEdge) return null;

Expand Down Expand Up @@ -71,6 +73,13 @@ export default function CanvasPropertiesPanel({
onUpdateNodes={onUpdateNodes}
/>

{!isSingle && onSaveSelectionAsTemplate && (
<BatchTemplateAction
selectedCount={selectedNodes.length}
onSaveSelectionAsTemplate={onSaveSelectionAsTemplate}
/>
)}

{isSingle && firstNode.type !== 'timeline' && (
<SpecificFieldsShell key={firstNode.type || firstNode.data.type}>
<NodeSpecificFields node={firstNode} nodeIds={nodeIds} onUpdateNodes={onUpdateNodes} />
Expand All @@ -80,6 +89,29 @@ export default function CanvasPropertiesPanel({
);
}

function BatchTemplateAction({
selectedCount,
onSaveSelectionAsTemplate,
}: {
selectedCount: number;
onSaveSelectionAsTemplate: () => void;
}) {
return (
<section className="border-t border-neutral-100 pt-2">
<button
type="button"
onClick={onSaveSelectionAsTemplate}
className="flex h-9 w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white text-xs font-semibold text-neutral-800 shadow-xs transition-colors hover:border-neutral-300 hover:bg-neutral-50"
data-tooltip={`保存当前 ${selectedCount} 个节点为模板`}
data-tooltip-placement="bottom"
>
<Boxes className="h-3.5 w-3.5 text-neutral-500" />
保存为节点模板
</button>
</section>
);
}

function EdgePropertiesPanel({
selectedEdge,
onUpdateEdge,
Expand Down
25 changes: 19 additions & 6 deletions src/features/canvas/hooks/useCanvasPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
) {
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(
Expand Down
3 changes: 3 additions & 0 deletions src/features/canvas/nodes/NodeActionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
editingId?: string | null;
setEditingId?: (id: string | null) => void;
selectedNodeCount?: number;
shortcuts?: ShortcutMap;
}

Expand Down
70 changes: 53 additions & 17 deletions src/features/canvas/nodes/TimelineNode.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<TimelineTrackDataValue>(() => 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);
Expand Down Expand Up @@ -302,14 +310,16 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti
</span>
<span>轨道管理器</span>
</span>
<button
onClick={onDelete}
className="h-8 cursor-pointer px-1 text-[12px] font-bold text-red-600 transition-colors hover:text-red-700"
data-tooltip="删除轨道"
data-tooltip-placement="bottom"
>
删除
</button>
<div className="flex items-center gap-1.5">
<button
onClick={onDelete}
className="h-8 cursor-pointer px-1 text-[12px] font-bold text-red-600 transition-colors hover:text-red-700"
data-tooltip="删除轨道"
data-tooltip-placement="bottom"
>
删除
</button>
</div>
</div>

<div className="rounded-xl border border-neutral-100 bg-neutral-50/60 p-1.5">
Expand Down Expand Up @@ -337,10 +347,8 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti
</div>

<div
className="space-y-1 overflow-y-auto overflow-x-hidden pr-0.5"
style={{
maxHeight: `${Math.min(state.ticks.length, 6) * 36 + Math.max(0, Math.min(state.ticks.length, 6) - 1) * 4}px`,
}}
className={`timeline-manager-tick-list space-y-1 overflow-x-hidden pr-0.5 ${state.ticks.length > 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);
Expand Down Expand Up @@ -421,14 +429,42 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti
})}
</div>

<div className="flex items-center justify-between gap-2 border-t border-neutral-100 px-1 pt-2">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-neutral-100 px-1 pt-2">
<button
onClick={addTickInLargestGap}
className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-neutral-950 px-3 text-[11px] font-bold text-white shadow-xs transition-all hover:bg-neutral-800"
>
<Plus className="h-3.5 w-3.5" />
<span>添加时刻</span>
</button>
{selectedNodeCount > 1 && (
<div className="flex items-center gap-1.5">
<button
onClick={(event) => {
event.stopPropagation();
onExitTimelineFocus?.(id, true);
}}
className="inline-flex h-8 cursor-pointer items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 text-[11px] font-bold text-neutral-600 shadow-xs transition-colors hover:bg-neutral-50 hover:text-neutral-950"
data-tooltip="保留轨道"
data-tooltip-placement="top"
>
<EyeOff className="h-3.5 w-3.5" />
退出聚焦
</button>
<button
onClick={(event) => {
event.stopPropagation();
onExitTimelineFocus?.(id, false);
}}
className="inline-flex h-8 cursor-pointer items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 text-[11px] font-bold text-neutral-600 shadow-xs transition-colors hover:bg-neutral-50 hover:text-neutral-950"
data-tooltip="移出轨道"
data-tooltip-placement="top"
>
<MousePointer2 className="h-3.5 w-3.5" />
移出批量
</button>
</div>
)}
</div>
</>
);
Expand Down Expand Up @@ -528,12 +564,12 @@ export const TimelineNode = memo(({ id, data, selected }: { id: string; data: Ti
</div>
</div>

{selected && createPortal(
{selected && !isFocusDisabled && createPortal(
<div
onMouseDown={(e) => 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}
</div>,
Expand Down
3 changes: 2 additions & 1 deletion src/features/canvas/utils/presentationUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
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) {
Expand Down
8 changes: 8 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading