import { useState, useMemo } from 'react'; import { ChevronRight, Plus, X, GripVertical } from 'lucide-react'; import { useDraggable, useDroppable } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing'; import { normalizeKeyPoint } from '../../types/ai-writing'; import { CollapseTransition } from './CollapseTransition'; interface OutlineSectionProps { section: OutlineSectionType; index: number; /** 章节在 outline.sections 中的下标,用于生成稳定 dnd id */ sectionIndex: number; onChange: (updated: OutlineSectionType) => void; onRemove?: () => void; readOnly?: boolean; } // 各级 grip 标题提示文案 const DRAG_HINT = '按住拖动:项的顶/底边 = 同级重排;中间区域 = 嵌入为子项'; const DRAG_HINT_LEAF = '按住拖动重排(顶半区 = 上插入,底半区 = 下插入)'; // ─── 章节 (Level 1) ─────────────────────────────────────────────────────────── export function OutlineSection({ section, index, sectionIndex, onChange, onRemove, readOnly, }: OutlineSectionProps) { const [expanded, setExpanded] = useState(true); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(section.sectionTitle); const id = `s${sectionIndex}`; const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ id, disabled: readOnly, }); // 三个 drop 区:before / into / after const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly }); const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); const keyPoints: KeyPoint[] = useMemo( () => (section.keyPoints ?? []).map(normalizeKeyPoint), [section.keyPoints], ); function commitTitle() { setEditingTitle(false); const trimmed = titleDraft.trim(); if (trimmed && trimmed !== section.sectionTitle) { onChange({ ...section, sectionTitle: trimmed }); } else { setTitleDraft(section.sectionTitle); } } function updateKeyPoint(i: number, updated: KeyPoint) { const next = [...keyPoints]; next[i] = updated; onChange({ ...section, keyPoints: next }); } function removeKeyPoint(i: number) { onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) }); } function addKeyPoint() { onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] }); } const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0); return (
{/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */}
{beforeDrop.isOver && } {/* into:包住头部 + 折叠体;本身就是 drop 容器 */}
{/* 章节头 */}
{!readOnly && ( )} {index + 1}. {editingTitle && !readOnly ? ( setTitleDraft(e.target.value)} onBlur={commitTitle} onKeyDown={(e) => { if (e.key === 'Enter') commitTitle(); if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); } }} className="flex-1 bg-transparent border-b border-primary outline-none font-medium" /> ) : ( !readOnly && setEditingTitle(true)} > {section.sectionTitle || (未命名)} )} {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} {!readOnly && onRemove && ( )}
{/* 小节列表 (Level 2) */}
{keyPoints.map((kp, i) => ( updateKeyPoint(i, updated)} onRemove={readOnly ? undefined : () => removeKeyPoint(i)} readOnly={readOnly} /> ))} {!readOnly && ( )}
{/* after:底部 12px 命中区 */}
{afterDrop.isOver && }
); } // ─── 小节 (Level 2) ─────────────────────────────────────────────────────────── interface KeyPointRowProps { keyPoint: KeyPoint; sectionIndex: number; keyPointIndex: number; onChange: (updated: KeyPoint) => void; onRemove?: () => void; readOnly?: boolean; } function KeyPointRow({ keyPoint, sectionIndex, keyPointIndex, onChange, onRemove, readOnly }: KeyPointRowProps) { const [expanded, setExpanded] = useState(true); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle); const id = `s${sectionIndex}-k${keyPointIndex}`; const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ id, disabled: readOnly, }); const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly }); const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); function commitTitle() { setEditingTitle(false); const trimmed = titleDraft.trim(); if (trimmed !== keyPoint.pointTitle) { onChange({ ...keyPoint, pointTitle: trimmed }); } } function updateSubPoint(i: number, value: string) { const next = [...keyPoint.subPoints]; next[i] = value; onChange({ ...keyPoint, subPoints: next }); } function removeSubPoint(i: number) { onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) }); } function addSubPoint() { onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] }); setExpanded(true); } return (
{beforeDrop.isOver && }
{/* 小节头 */}
{!readOnly && ( )} ### {editingTitle && !readOnly ? ( setTitleDraft(e.target.value)} onBlur={commitTitle} onKeyDown={(e) => { if (e.key === 'Enter') commitTitle(); if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } }} className="flex-1 bg-transparent border-b border-primary outline-none" placeholder="小节标题" /> ) : ( !readOnly && setEditingTitle(true)} > {keyPoint.pointTitle || (未命名小节)} )} {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} {!readOnly && onRemove && ( )}
{/* 子要点 (Level 3) */}
{keyPoint.subPoints.map((sp, i) => ( updateSubPoint(i, value)} onRemove={readOnly ? undefined : () => removeSubPoint(i)} readOnly={readOnly} /> ))} {!readOnly && ( )}
{afterDrop.isOver && }
); } // ─── 子要点 (Level 3,叶节点,无 into) ──────────────────────────────────────── interface SubPointRowProps { value: string; sectionIndex: number; keyPointIndex: number; subPointIndex: number; onChange: (value: string) => void; onRemove?: () => void; readOnly?: boolean; } function SubPointRow({ value, sectionIndex, keyPointIndex, subPointIndex, onChange, onRemove, readOnly, }: SubPointRowProps) { const id = `s${sectionIndex}-k${keyPointIndex}-p${subPointIndex}`; const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ id, disabled: readOnly, }); // 叶节点:没有 into;顶半区 = before、底半区 = after,把整行高度铺满 const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); return (
{beforeDrop.isOver && } {!readOnly && ( )} #### {readOnly ? ( {value} ) : ( onChange(e.target.value)} placeholder="子要点标题(会展开成一段正文)" className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" /> )} {!readOnly && onRemove && ( )}
{afterDrop.isOver && }
); } // ─── 蓝色插入指示线 ─────────────────────────────────────────────────────────── function DropLine({ position, thin }: { position: 'top' | 'bottom'; thin?: boolean }) { return (
); }