Skip to content
Merged
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
61 changes: 60 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<h1>开始书写</h1><p>在此输入草稿,或从段落切片管理器将内容转化为卡片节点。</p>';
const MIN_EDITOR_WIDTH = 320;
const DEFAULT_EDITOR_WIDTH = 480;
const OUTLINE_EDITOR_WIDTH = 732;
const MIN_CANVAS_WIDTH = 360;

interface HistoryAvailability {
canUndo: boolean;
Expand All @@ -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<AutoSaveStatus>('idle');
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="flex h-screen w-screen overflow-hidden bg-neutral-50 font-sans">
Expand Down Expand Up @@ -317,6 +364,18 @@ export default function App() {
shortcuts={shortcuts}
/>
</div>
{!isSidebarCollapsed && activeTab === 'split' && (
<div
className="absolute right-[-3px] top-0 z-20 hidden h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-neutral-300/60 md:block"
onPointerDown={(event) => {
event.preventDefault();
setIsEditorResizing(true);
}}
role="separator"
aria-orientation="vertical"
aria-label="调整脚本编辑区宽度"
/>
)}
</div>

{/* Sidebar expand collapse trigger button - placed OUTSIDE the column to transition beautifully */}
Expand Down
4 changes: 4 additions & 0 deletions src/features/canvas/CanvasViewport.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Background,
BackgroundVariant,
ConnectionMode,
MiniMap,
PanOnScrollMode,
ReactFlow,
Expand Down Expand Up @@ -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}
Expand All @@ -80,6 +82,8 @@ export default function CanvasViewport({
onPointerUp={viewportHandlers.onPointerUp}
onPointerLeave={viewportHandlers.onPointerLeave}
onPointerCancel={viewportHandlers.onPointerCancel}
onDragOver={viewportHandlers.onDragOver}
onDrop={viewportHandlers.onDrop}
>
<Background
variant={BackgroundVariant.Dots}
Expand Down
33 changes: 33 additions & 0 deletions src/features/canvas/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useCanvasPointerPan } from './hooks/useCanvasPointerPan';
import { useCanvasPresentation } from './hooks/useCanvasPresentation';
import { useCanvasShortcuts } from './hooks/useCanvasShortcuts';
import { useCanvasTemplates } from './hooks/useCanvasTemplates';
import { createImageNodeFromAsset, createMediaAsset } from './utils/mediaAssetUtils';
import type { FlowCanvasProps, ViewportHandlers, ViewportShellHandlers } from './types';

export default function FlowCanvas({
Expand Down Expand Up @@ -129,6 +130,25 @@ export default function FlowCanvas({
getCenteredNodePosition,
});

const addImageFilesToCanvas = useCallback((files: File[], clientX: number, clientY: number) => {
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,
Expand Down Expand Up @@ -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 = {
Expand Down
3 changes: 2 additions & 1 deletion src/features/canvas/components/CanvasPropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ function EdgePropertiesPanel({
onUpdateEdge: (edgeId: string, patch: Partial<Edge>) => 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 (
Expand Down
2 changes: 1 addition & 1 deletion src/features/canvas/components/MediaLibraryDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function MediaLibraryDrawer({
>
<Upload className="w-6 h-6 text-neutral-400 group-hover:text-neutral-700 transition-colors mb-2" />
<span className="text-[11px] font-semibold text-neutral-700">拖拽上传 / 批量选择</span>
<span className="text-[9px] text-neutral-400 mt-0.5">支持批量上传到文件夹,自动插到画布</span>
<span className="text-[9px] text-neutral-400 mt-0.5">支持批量上传到文件夹,点击图片后插入画布</span>
<input
ref={fileInputRef}
type="file"
Expand Down
8 changes: 1 addition & 7 deletions src/features/canvas/hooks/useCanvasMediaLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,10 @@ export function useCanvasMediaLibrary({
}
return [asset, ...currentAssets];
});

const newNode = createImageNodeFromAsset(asset, {
x: 100 + Math.random() * 250,
y: 100 + Math.random() * 250,
});
setNodes((currentNodes) => [...currentNodes, newNode]);
};
reader.readAsDataURL(file);
});
}, [setNodes]);
}, []);

const insertAsset = useCallback((asset: CanvasMediaAsset) => {
setNodes((currentNodes) => {
Expand Down
26 changes: 20 additions & 6 deletions src/features/canvas/hooks/useCanvasNodeCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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]);
Expand Down Expand Up @@ -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]);
Expand Down
9 changes: 6 additions & 3 deletions src/features/canvas/hooks/useCanvasPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 35 additions & 2 deletions src/features/canvas/nodes/IdeaNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -162,7 +195,7 @@ export const IdeaNode = memo(({ id, data, selected }: { id: string; data: IdeaCa
placeholder="写下你的灵感火花..."
/>
) : (
<p className="w-full overflow-visible break-words text-xs font-sans text-neutral-600 italic bg-white/40 p-2.5 rounded border border-neutral-100 [overflow-wrap:anywhere]">
<p className="h-full min-h-0 w-full overflow-y-auto break-words rounded border border-neutral-100 bg-white/40 p-2.5 font-sans text-xs italic text-neutral-600 [overflow-wrap:anywhere]">
{data.content || <span className="text-neutral-400 italic">空白灵感卡... 双击进行编辑</span>}
</p>
)}
Expand Down
Loading
Loading