Files
2026-09-07 18:24:55 +08:00

427 lines
16 KiB
TypeScript

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 (
<div
ref={setDragRef}
{...attributes}
className={cn(
'relative rounded border text-xs transition-colors duration-150',
isDragging ? 'opacity-40 border-blue-300' : 'border-border/60',
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-background',
)}
>
{/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */}
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-3 z-20 pointer-events-none" />
{beforeDrop.isOver && <DropLine position="top" />}
{/* into:包住头部 + 折叠体;本身就是 drop 容器 */}
<div ref={intoDrop.setNodeRef}>
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && (
<button
type="button"
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/40 hover:text-muted-foreground"
aria-label="拖动排序章节"
title={DRAG_HINT}
>
<GripVertical className="size-3" />
</button>
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
<ChevronRight
className={cn(
'size-3.5 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => 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"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
{/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{keyPoints.map((kp, i) => (
<KeyPointRow
key={i}
keyPoint={kp}
sectionIndex={sectionIndex}
keyPointIndex={i}
onChange={(updated) => updateKeyPoint(i, updated)}
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加小节
</button>
)}
</div>
</CollapseTransition>
</div>
{/* after:底部 12px 命中区 */}
<div ref={afterDrop.setNodeRef} className="absolute bottom-0 inset-x-0 h-3 z-20 pointer-events-none" />
{afterDrop.isOver && <DropLine position="bottom" />}
</div>
);
}
// ─── 小节 (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 (
<div
ref={setDragRef}
{...attributes}
className={cn(
'relative rounded border border-border/40 transition-colors duration-150',
isDragging && 'opacity-40 border-blue-300',
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-muted/10',
)}
>
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-2 z-20 pointer-events-none" />
{beforeDrop.isOver && <DropLine position="top" />}
<div ref={intoDrop.setNodeRef}>
{/* 小节头 */}
<div className="flex items-center gap-1.5 px-2 py-1.5">
{!readOnly && (
<button
type="button"
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/30 hover:text-muted-foreground"
aria-label="拖动排序小节"
title={DRAG_HINT}
>
<GripVertical className="size-3" />
</button>
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
aria-label="展开/收起子要点"
>
<ChevronRight
className={cn(
'size-3 text-muted-foreground/60 transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-purple-500/70 shrink-0">###</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => 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="小节标题"
/>
) : (
<span
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
</span>
)}
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
</span>
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
aria-label="删除小节"
>
<X className="size-3" />
</button>
)}
</div>
{/* 子要点 (Level 3) */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
{keyPoint.subPoints.map((sp, i) => (
<SubPointRow
key={i}
value={sp}
sectionIndex={sectionIndex}
keyPointIndex={keyPointIndex}
subPointIndex={i}
onChange={(value) => updateSubPoint(i, value)}
onRemove={readOnly ? undefined : () => removeSubPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
>
<Plus className="size-3" />
添加子要点
</button>
)}
</div>
</CollapseTransition>
</div>
<div ref={afterDrop.setNodeRef} className="absolute bottom-0 inset-x-0 h-2 z-20 pointer-events-none" />
{afterDrop.isOver && <DropLine position="bottom" />}
</div>
);
}
// ─── 子要点 (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 (
<div
ref={setDragRef}
{...attributes}
className={cn(
'relative flex items-center gap-1.5 pt-1 rounded transition-colors duration-150',
isDragging && 'opacity-40',
)}
>
<div
ref={beforeDrop.setNodeRef}
className="absolute left-0 right-0 top-0 bottom-1/2 z-20 pointer-events-none"
/>
{beforeDrop.isOver && <DropLine position="top" thin />}
{!readOnly && (
<button
type="button"
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/30 hover:text-muted-foreground"
aria-label="拖动排序子要点"
title={DRAG_HINT_LEAF}
>
<GripVertical className="size-2.5" />
</button>
)}
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{value}</span>
) : (
<input
value={value}
onChange={(e) => 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 && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
aria-label="删除子要点"
>
<X className="size-3" />
</button>
)}
<div
ref={afterDrop.setNodeRef}
className="absolute left-0 right-0 top-1/2 bottom-0 z-20 pointer-events-none"
/>
{afterDrop.isOver && <DropLine position="bottom" thin />}
</div>
);
}
// ─── 蓝色插入指示线 ───────────────────────────────────────────────────────────
function DropLine({ position, thin }: { position: 'top' | 'bottom'; thin?: boolean }) {
return (
<div
className={cn(
'absolute inset-x-1 bg-blue-500 rounded-full z-30 pointer-events-none',
thin ? 'h-[2px]' : 'h-[3px]',
position === 'top' ? '-top-px' : '-bottom-px',
)}
/>
);
}