427 lines
16 KiB
TypeScript
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',
|
||
)}
|
||
/>
|
||
);
|
||
}
|