- 写作配置 Q&A 改为「先流式生成大纲并多轮确认 → 再确认意图」,放宽轮数上限 - 意图识别出列表外的新文章类型时带上类型名+简介(仅本次写作临时使用) - 成稿支持参考文献:正文 [n] 角标 + 文末按出现顺序的「参考文献」列表(表单开关,默认开) - 流式接取未闭合 <think> 思考内容,首 token 前加「正在整理…」占位 - 修复右侧编辑器输入空格只出一次、光标跳字(避免自激回灌 setContent 重置光标) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import { useState } from 'react';
|
||
import { ListTree, Plus, GripVertical } from 'lucide-react';
|
||
import {
|
||
DndContext,
|
||
DragOverlay,
|
||
PointerSensor,
|
||
pointerWithin,
|
||
rectIntersection,
|
||
useSensor,
|
||
useSensors,
|
||
type CollisionDetection,
|
||
type DragEndEvent,
|
||
type DragStartEvent,
|
||
} from '@dnd-kit/core';
|
||
import { Button } from '@/components/ui/button';
|
||
import { cn } from '@/lib/utils';
|
||
import type { AIOutline, KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||
import { normalizeKeyPoint } from '../../types/ai-writing';
|
||
import { OutlineSection } from './OutlineSection';
|
||
import { CollapseTransition } from './CollapseTransition';
|
||
|
||
interface OutlineCardProps {
|
||
outline: AIOutline;
|
||
onChange?: (updated: AIOutline) => void;
|
||
readOnly?: boolean;
|
||
}
|
||
|
||
// 节点路径(章节/小节/子要点)
|
||
export type DragPath =
|
||
| { type: 'section'; s: number }
|
||
| { type: 'keypoint'; s: number; k: number }
|
||
| { type: 'subpoint'; s: number; k: number; p: number };
|
||
|
||
// 节点 id:s{S} / s{S}-k{K} / s{S}-k{K}-p{P}
|
||
export function parseItemId(id: string): DragPath | null {
|
||
const m = id.match(/^s(\d+)(?:-k(\d+))?(?:-p(\d+))?$/);
|
||
if (!m) return null;
|
||
const s = parseInt(m[1], 10);
|
||
const k = m[2] !== undefined ? parseInt(m[2], 10) : undefined;
|
||
const p = m[3] !== undefined ? parseInt(m[3], 10) : undefined;
|
||
if (p !== undefined && k !== undefined) return { type: 'subpoint', s, k, p };
|
||
if (k !== undefined) return { type: 'keypoint', s, k };
|
||
return { type: 'section', s };
|
||
}
|
||
|
||
// 区域 id:before:<节点id> / after:<节点id> / into:<节点id>
|
||
type Zone = 'before' | 'after' | 'into';
|
||
function parseZoneId(zoneId: string): { zone: Zone; targetPath: DragPath; targetItemId: string } | null {
|
||
const m = zoneId.match(/^(before|after|into):(.+)$/);
|
||
if (!m) return null;
|
||
const targetItemId = m[2];
|
||
const targetPath = parseItemId(targetItemId);
|
||
if (!targetPath) return null;
|
||
return { zone: m[1] as Zone, targetPath, targetItemId };
|
||
}
|
||
|
||
function cloneOutline(outline: AIOutline): AIOutline {
|
||
return {
|
||
...outline,
|
||
sections: outline.sections.map((s) => ({
|
||
...s,
|
||
keyPoints: (s.keyPoints ?? []).map((kp) => {
|
||
const n = normalizeKeyPoint(kp);
|
||
return { ...n, subPoints: [...n.subPoints] } as KeyPoint;
|
||
}),
|
||
materialIds: [...(s.materialIds ?? [])],
|
||
})),
|
||
};
|
||
}
|
||
|
||
type SourceValue = OutlineSectionType | KeyPoint | string;
|
||
|
||
// 拖到自身子树内非法(章节扔进自己的小节/子要点 / 小节扔进自己的子要点)
|
||
function isDescendantTarget(from: DragPath, to: DragPath): boolean {
|
||
if (from.type === 'section') return to.s === from.s;
|
||
if (from.type === 'keypoint') return to.type !== 'section' && to.s === from.s && to.k === from.k;
|
||
return false;
|
||
}
|
||
|
||
function readAt(outline: AIOutline, p: DragPath): SourceValue {
|
||
if (p.type === 'section') return outline.sections[p.s];
|
||
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
|
||
if (p.type === 'keypoint') return kps[p.k];
|
||
return kps[p.k].subPoints[p.p];
|
||
}
|
||
|
||
function removeAt(outline: AIOutline, p: DragPath): void {
|
||
if (p.type === 'section') { outline.sections.splice(p.s, 1); return; }
|
||
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
|
||
if (p.type === 'keypoint') { kps.splice(p.k, 1); return; }
|
||
kps[p.k].subPoints.splice(p.p, 1);
|
||
}
|
||
|
||
function insertAt(outline: AIOutline, p: DragPath, value: SourceValue): void {
|
||
if (p.type === 'section') { outline.sections.splice(p.s, 0, value as OutlineSectionType); return; }
|
||
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
|
||
if (p.type === 'keypoint') { kps.splice(p.k, 0, value as KeyPoint); return; }
|
||
kps[p.k].subPoints.splice(p.p, 0, value as string);
|
||
}
|
||
|
||
// 删源后,同一父列表里 target index 会前移一位
|
||
function adjustTargetAfterRemove(to: DragPath, from: DragPath): DragPath {
|
||
if (from.type === 'section') {
|
||
if (to.s > from.s) return { ...to, s: to.s - 1 } as DragPath;
|
||
return to;
|
||
}
|
||
if (from.type === 'keypoint') {
|
||
if (to.type !== 'section' && to.s === from.s && to.k > from.k) {
|
||
return { ...to, k: to.k - 1 } as DragPath;
|
||
}
|
||
return to;
|
||
}
|
||
if (to.type === 'subpoint' && to.s === from.s && to.k === from.k && to.p > from.p) {
|
||
return { ...to, p: to.p - 1 };
|
||
}
|
||
return to;
|
||
}
|
||
|
||
type Level = DragPath['type'];
|
||
|
||
function convertToLevel(value: SourceValue, fromLevel: Level, toLevel: Level): SourceValue {
|
||
if (fromLevel === toLevel) return value;
|
||
if (toLevel === 'section') {
|
||
if (fromLevel === 'keypoint') {
|
||
const kp = value as KeyPoint;
|
||
return {
|
||
sectionTitle: kp.pointTitle,
|
||
keyPoints: kp.subPoints.map((sp) => ({ pointTitle: sp, subPoints: [] })),
|
||
materialIds: [],
|
||
};
|
||
}
|
||
return { sectionTitle: value as string, keyPoints: [], materialIds: [] };
|
||
}
|
||
if (toLevel === 'keypoint') {
|
||
if (fromLevel === 'section') {
|
||
const sec = value as OutlineSectionType;
|
||
const sub = (sec.keyPoints ?? [])
|
||
.map((kp) => normalizeKeyPoint(kp).pointTitle)
|
||
.filter((s) => !!s);
|
||
return { pointTitle: sec.sectionTitle, subPoints: sub };
|
||
}
|
||
return { pointTitle: value as string, subPoints: [] };
|
||
}
|
||
if (fromLevel === 'section') return (value as OutlineSectionType).sectionTitle;
|
||
return (value as KeyPoint).pointTitle;
|
||
}
|
||
|
||
// into 区域:作为 target 节点的「末尾子项」插入
|
||
function childPathOf(outline: AIOutline, target: DragPath): DragPath | null {
|
||
if (target.type === 'section') {
|
||
const kps = outline.sections[target.s]?.keyPoints ?? [];
|
||
return { type: 'keypoint', s: target.s, k: kps.length };
|
||
}
|
||
if (target.type === 'keypoint') {
|
||
const kp = normalizeKeyPoint((outline.sections[target.s]?.keyPoints ?? [])[target.k]);
|
||
return { type: 'subpoint', s: target.s, k: target.k, p: kp.subPoints.length };
|
||
}
|
||
return null; // 子要点没有子节点
|
||
}
|
||
|
||
// before/after:作为 target 节点的兄弟插入
|
||
function siblingPathOf(target: DragPath, side: 'before' | 'after'): DragPath {
|
||
const delta = side === 'after' ? 1 : 0;
|
||
if (target.type === 'section') return { type: 'section', s: target.s + delta };
|
||
if (target.type === 'keypoint') return { type: 'keypoint', s: target.s, k: target.k + delta };
|
||
return { type: 'subpoint', s: target.s, k: target.k, p: target.p + delta };
|
||
}
|
||
|
||
// 自定义碰撞:pointerWithin 命中所有包含光标的 droppable,取 rect 最小的(最内层)
|
||
// —— 这是嵌套区域 (section > keypoint > subpoint,每层又分 before/into/after) 能
|
||
// 正确选中「最深的那个」的关键。
|
||
function getRectArea(rect: { width: number; height: number } | null | undefined): number {
|
||
if (!rect) return Infinity;
|
||
return rect.width * rect.height;
|
||
}
|
||
|
||
const innermostCollision: CollisionDetection = (args) => {
|
||
const pointer = pointerWithin(args);
|
||
if (pointer.length === 0) return rectIntersection(args);
|
||
let best = pointer[0];
|
||
let bestArea = getRectArea(args.droppableRects.get(best.id));
|
||
for (const c of pointer.slice(1)) {
|
||
const a = getRectArea(args.droppableRects.get(c.id));
|
||
if (a < bestArea) { best = c; bestArea = a; }
|
||
}
|
||
return [best];
|
||
};
|
||
|
||
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [editingTitle, setEditingTitle] = useState(false);
|
||
const [titleDraft, setTitleDraft] = useState(outline.title);
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
|
||
const editable = !readOnly && !!onChange;
|
||
|
||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||
|
||
function commitTitle() {
|
||
setEditingTitle(false);
|
||
const trimmed = titleDraft.trim();
|
||
if (trimmed && trimmed !== outline.title) {
|
||
onChange?.({ ...outline, title: trimmed });
|
||
} else {
|
||
setTitleDraft(outline.title);
|
||
}
|
||
}
|
||
|
||
function updateSection(i: number, updated: OutlineSectionType) {
|
||
const sections = [...outline.sections];
|
||
sections[i] = updated;
|
||
onChange?.({ ...outline, sections });
|
||
}
|
||
|
||
function removeSection(i: number) {
|
||
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
|
||
}
|
||
|
||
function addSection() {
|
||
onChange?.({
|
||
...outline,
|
||
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
|
||
});
|
||
}
|
||
|
||
function handleDragStart(event: DragStartEvent) {
|
||
setActiveId(String(event.active.id));
|
||
}
|
||
function handleDragCancel() {
|
||
setActiveId(null);
|
||
}
|
||
|
||
function handleDragEnd(event: DragEndEvent) {
|
||
setActiveId(null);
|
||
const { active, over } = event;
|
||
if (!over || !editable) return;
|
||
|
||
const fromId = String(active.id);
|
||
const overId = String(over.id);
|
||
|
||
const from = parseItemId(fromId);
|
||
const zoneInfo = parseZoneId(overId);
|
||
if (!from || !zoneInfo) return;
|
||
const { zone, targetPath, targetItemId } = zoneInfo;
|
||
|
||
// 落在源自身的任何区域 → 无操作
|
||
if (targetItemId === fromId) return;
|
||
|
||
// 计算插入路径:into = 末尾子项;before/after = 同级兄弟
|
||
const insertPath = zone === 'into'
|
||
? childPathOf(outline, targetPath)
|
||
: siblingPathOf(targetPath, zone);
|
||
if (!insertPath) return;
|
||
|
||
// 拖入源自身子树 → 非法
|
||
if (isDescendantTarget(from, insertPath)) return;
|
||
|
||
const next = cloneOutline(outline);
|
||
const sourceValue = readAt(next, from);
|
||
removeAt(next, from);
|
||
const adjusted = adjustTargetAfterRemove(insertPath, from);
|
||
const converted = convertToLevel(sourceValue, from.type, adjusted.type);
|
||
insertAt(next, adjusted, converted);
|
||
|
||
onChange?.(next);
|
||
}
|
||
|
||
function renderDragOverlay() {
|
||
if (!activeId) return null;
|
||
const path = parseItemId(activeId);
|
||
if (!path) return null;
|
||
const section = outline.sections[path.s];
|
||
if (!section) return null;
|
||
if (path.type === 'section') {
|
||
return <OverlayCard level="section" label={section.sectionTitle} />;
|
||
}
|
||
const kp = normalizeKeyPoint((section.keyPoints ?? [])[path.k]);
|
||
if (path.type === 'keypoint') {
|
||
return <OverlayCard level="keypoint" label={kp.pointTitle} />;
|
||
}
|
||
return <OverlayCard level="subpoint" label={kp.subPoints?.[path.p] ?? ''} />;
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||
<span className="text-xs font-medium flex-1">文章大纲</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCollapsed((v) => !v)}
|
||
className="text-xs text-muted-foreground hover:text-foreground"
|
||
>
|
||
{collapsed ? '展开' : '收起'}
|
||
</button>
|
||
</div>
|
||
|
||
<CollapseTransition open={!collapsed}>
|
||
<div className="px-3 py-2.5 space-y-2">
|
||
{/* 文章标题 */}
|
||
<div className="flex items-center gap-1.5 text-xs">
|
||
<span className="text-muted-foreground shrink-0">标题:</span>
|
||
{editingTitle && editable ? (
|
||
<input
|
||
autoFocus
|
||
value={titleDraft}
|
||
onChange={(e) => setTitleDraft(e.target.value)}
|
||
onBlur={commitTitle}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') commitTitle();
|
||
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
|
||
}}
|
||
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
|
||
/>
|
||
) : (
|
||
<span
|
||
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
|
||
onClick={() => editable && setEditingTitle(true)}
|
||
>
|
||
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 章节列表 —— 三区域拖放:顶/底边重排,中间嵌入为子项 */}
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={innermostCollision}
|
||
onDragStart={handleDragStart}
|
||
onDragEnd={handleDragEnd}
|
||
onDragCancel={handleDragCancel}
|
||
>
|
||
<div className="space-y-1.5">
|
||
{outline.sections.map((section, i) => (
|
||
<OutlineSection
|
||
key={i}
|
||
section={section}
|
||
index={i}
|
||
sectionIndex={i}
|
||
onChange={(updated) => updateSection(i, updated)}
|
||
onRemove={editable ? () => removeSection(i) : undefined}
|
||
readOnly={!editable}
|
||
/>
|
||
))}
|
||
</div>
|
||
<DragOverlay dropAnimation={null}>{renderDragOverlay()}</DragOverlay>
|
||
</DndContext>
|
||
|
||
{editable && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
|
||
onClick={addSection}
|
||
>
|
||
<Plus className="size-3" />
|
||
添加章节
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CollapseTransition>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 拖动时跟随光标的浮动预览。DragOverlay 把这个节点 portal 到 body,
|
||
// 不受任何祖先 overflow:hidden 影响,所以拖出卡片也始终可见。
|
||
function OverlayCard({ level, label }: { level: Level; label: string }) {
|
||
const styleByLevel: Record<Level, string> = {
|
||
section: 'rounded border-2 border-blue-400 bg-background text-xs font-medium px-2.5 py-2 shadow-lg',
|
||
keypoint: 'rounded border-2 border-blue-400 bg-muted/40 text-xs px-2 py-1.5 shadow-lg',
|
||
subpoint: 'rounded border-2 border-blue-400 bg-background text-xs px-2 py-1 shadow-lg',
|
||
};
|
||
return (
|
||
<div className={cn('flex items-center gap-1.5 cursor-grabbing min-w-[180px] max-w-[420px]', styleByLevel[level])}>
|
||
<GripVertical className="size-3 text-muted-foreground/50 shrink-0" />
|
||
{level === 'keypoint' && <span className="text-purple-500/70 shrink-0">###</span>}
|
||
{level === 'subpoint' && <span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>}
|
||
<span className="truncate">
|
||
{label || <span className="italic text-muted-foreground">(未命名)</span>}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|