我正在尝试构建一个允许用户对法律判决发表评论的应用程序。我的应用程序中有判断和注释。对判决案文作出注释。目前,当用户选择判断文本的某个区域时,添加注释表单会收集注释并放置从所选文本的开头到结尾的跨度。
对于添加的第一个注释来说,这工作正常。之后跨度就错位了。我看不出我做错了什么。谁能建议一种更好的方法来计算如何放置我的笔记的跨度?
这是我的判断显示组件:
'use client';
import React, { useRef, useEffect, useState } from 'react';
import { Judgment, Note, Selection } from '../../../types';
interface JudgmentDisplayProps {
judgment: Judgment;
notes: Note[];
onTextSelect: (selection: Selection) => void;
onNoteClick: (note: Note) => void;
}
export default function JudgmentDisplay({ judgment, notes, onTextSelect, onNoteClick }: JudgmentDisplayProps) {
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const handleMouseUp = (event: React.MouseEvent, fieldName: string) => {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const contentElement = contentRefs.current[fieldName];
if (contentElement) {
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(contentElement);
preCaretRange.setEnd(range.startContainer, range.startOffset);
const start = preCaretRange.toString().length;
const end = start + selection.toString().length;
console.log(`Selected in ${fieldName}: "${selection.toString()}", start: ${start}, end: ${end}`);
onTextSelect({
text: selection.toString(),
start,
end,
fieldName,
});
}
}
};
const renderField = (fieldName: string, content: string) => {
const fieldNotes = notes.filter(note => note.field_name === fieldName);
const segments: React.ReactNode[] = [];
let lastIndex = 0;
fieldNotes.sort((a, b) => a.start_index - b.start_index).forEach(note => {
if (note.start_index > lastIndex) {
segments.push(content.slice(lastIndex, note.start_index));
}
segments.push(
<React.Fragment key={note.id}>
<span
className="cursor-pointer text-blue-500"
onClick={() => onNoteClick(note)}
>
*
</span>
<span className="bg-yellow-200">
{content.slice(note.start_index, note.end_index)}
</span>
</React.Fragment>
);
lastIndex = note.end_index;
});
if (lastIndex < content.length) {
segments.push(content.slice(lastIndex));
}
return (
<div
key={fieldName}
ref={(el) => {
if (el) {
contentRefs.current[fieldName] = el;
}
}}
className="mb-4"
>
<h2 className="text-2xl font-bold mb-2">{fieldName}</h2>
<div
className="whitespace-pre-wrap"
onMouseUp={(e) => handleMouseUp(e, fieldName)}
>
{segments}
</div>
</div>
);
};
return (
<div>
{Object.entries(judgment).map(([fieldName, content]) =>
typeof content === 'string' ? renderField(fieldName, content) : null
)}
</div>
);
}
这是我的表单处理程序:
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useUser } from '@clerk/nextjs';
import JudgmentSelector from './_components/judgments/judgmentSelector';
import JudgmentDisplay from './_components/judgments/judgmentDisplay';
import NoteForm from './_components/notes/noteForm';
import NotePanel from './_components/notes/notePanel';
import { Judgment, Note, Selection } from '../types';
import { fetchJudgments, fetchNotes, addNote, editNote, deleteNote } from './actions';
export default function Home() {
const { user } = useUser();
const [judgments, setJudgments] = useState<Judgment[]>([]);
const [selectedJudgment, setSelectedJudgment] = useState<Judgment | null>(null);
const [notes, setNotes] = useState<Note[]>([]);
const [selection, setSelection] = useState<Selection | null>(null);
const [isNoteFormOpen, setIsNoteFormOpen] = useState(false);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
useEffect(() => {
if (user) {
fetchJudgments().then(setJudgments);
}
}, [user]);
useEffect(() => {
if (selectedJudgment) {
fetchNotes(selectedJudgment.id).then(setNotes);
} else {
setNotes([]);
}
}, [selectedJudgment]);
const handleJudgmentSelect = (judgment: Judgment) => {
setSelectedJudgment(judgment);
setSelection(null);
setIsNoteFormOpen(false);
setSelectedNote(null);
};
const handleTextSelect = (newSelection: Selection) => {
console.log(`Selected text: "${newSelection.text}", start: ${newSelection.start}, end: ${newSelection.end}, field: ${newSelection.fieldName}`);
setSelection(newSelection);
setIsNoteFormOpen(true);
};
const handleNoteSubmit = async (comment: string) => {
if (selection && selectedJudgment) {
try {
console.log(`Creating note in ${selection.fieldName}: "${selection.text}" at ${selection.start}-${selection.end}`);
const newNote = await addNote(comment, selection.start, selection.end, selectedJudgment.id, selection.fieldName);
console.log('New note created:', newNote);
setNotes(prevNotes => [...prevNotes, newNote]);
setIsNoteFormOpen(false);
setSelection(null);
} catch (error) {
console.error("Error adding note:", error);
}
}
};
const handleNoteClick = useCallback((note: Note) => {
console.log('Note clicked:', note);
setSelectedNote(note);
}, []);
const handleNoteUpdate = async (updatedNote: Note) => {
try {
console.log('Updating note:', updatedNote);
const result = await editNote(updatedNote.id, updatedNote.comment, updatedNote.judgment_id);
console.log('Note updated:', result);
setNotes(prevNotes => prevNotes.map(n => n.id === result.id ? result : n));
} catch (error) {
console.error("Error updating note:", error);
}
};
const handleNoteDelete = async (noteId: number) => {
if (selectedJudgment) {
try {
console.log('Deleting note:', noteId);
await deleteNote(noteId, selectedJudgment.id);
console.log('Note deleted:', noteId);
setNotes(prevNotes => prevNotes.filter(n => n.id !== noteId));
setSelectedNote(null);
} catch (error) {
console.error("Error deleting note:", error);
}
}
};
if (!user) {
return <div>Please sign in to access this page.</div>;
}
return (
<div className="container mx-auto p-4">
<JudgmentSelector
judgments={judgments}
selectedJudgment={selectedJudgment}
onSelect={handleJudgmentSelect}
/>
{selectedJudgment && (
<JudgmentDisplay
judgment={selectedJudgment}
notes={notes}
onTextSelect={handleTextSelect}
onNoteClick={handleNoteClick}
/>
)}
{isNoteFormOpen && selection && (
<NoteForm
selection={selection}
onSubmit={handleNoteSubmit}
onClose={() => {
setIsNoteFormOpen(false);
setSelection(null);
}}
/>
)}
<NotePanel
note={selectedNote}
onClose={() => setSelectedNote(null)}
onNoteUpdate={handleNoteUpdate}
onNoteDelete={handleNoteDelete}
/>
</div>
);
}
计算和跟踪文本索引以及在文本内渲染注释时可能会出现错误。以下是一些可能的改进和建议,可帮助解决该问题:
直接使用range.startContainer和range.endContainer
const handleMouseUp = (event: React.MouseEvent, fieldName: string) => {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const contentElement = contentRefs.current[fieldName];
if (contentElement) {
const startOffset = range.startOffset;
const endOffset = range.endOffset;
const startIndex = calculateOffset(contentElement, range.startContainer, startOffset);
const endIndex = calculateOffset(contentElement, range.endContainer, endOffset);
console.log(`Selected in ${fieldName}: "${selection.toString()}", start: ${startIndex}, end: ${endIndex}`);
onTextSelect({
text: selection.toString(),
start: startIndex,
end: endIndex,
fieldName,
});
}
}
};
const calculateOffset = (parent: Node, node: Node, offset: number) => {
let charCount = 0;
const traverseNodes = (current: Node) => {
if (current === node) {
charCount += offset;
return;
}
if (current.nodeType === Node.TEXT_NODE) {
charCount += current.textContent?.length || 0;
} else {
for (let i = 0; i < current.childNodes.length; i++) {
traverseNodes(current.childNodes[i]);
}
}
};
traverseNodes(parent);
return charCount;
};
使用 document.createRange() 和 Range.getBoundingClientRect() 这有助于可视化和理解所选文本位置:
const visualizeSelection = (range) => {
const rects = range.getClientRects();
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
const highlight = document.createElement('div');
highlight.style.position = 'absolute';
highlight.style.backgroundColor = 'rgba(255, 255, 0, 0.4)';
highlight.style.left = `${rect.left + window.scrollX}px`;
highlight.style.top = `${rect.top + window.scrollY}px`;
highlight.style.width = `${rect.width}px`;
highlight.style.height = `${rect.height}px`;
document.body.appendChild(highlight);
}
};
const handleMouseUp = (event: React.MouseEvent, fieldName: string) => {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
visualizeSelection(range);
// ... code for handling selection
}
};
每次更新后标准化跨度 确保每当添加新注释时都会重新计算跨度并正确排序。
const normalizeSpans = (content, notes) => {
notes.sort((a, b) => a.start_index - b.start_index);
const segments = [];
let lastIndex = 0;
notes.forEach(note => {
if (note.start_index > lastIndex) {
segments.push(content.slice(lastIndex, note.start_index));
}
segments.push(
<React.Fragment key={note.id}>
<span className="cursor-pointer text-blue-500" onClick={() => onNoteClick(note)}>*</span>
<span className="bg-yellow-200">{content.slice(note.start_index, note.end_index)}</span>
</React.Fragment>
);
lastIndex = note.end_index;
});
if (lastIndex < content.length) {
segments.push(content.slice(lastIndex));
}
return segments;
};
const renderField = (fieldName: string, content: string) => {
const fieldNotes = notes.filter(note => note.field_name === fieldName);
const segments = normalizeSpans(content, fieldNotes);
return (
<div
key={fieldName}
ref={(el) => {
if (el) {
contentRefs.current[fieldName] = el;
}
}}
className="mb-4"
>
<h2 className="text-2xl font-bold mb-2">{fieldName}</h2>
<div
className="whitespace-pre-wrap"
onMouseUp={(e) => handleMouseUp(e, fieldName)}
>
{segments}
</div>
</div>
);
};
用于管理笔记的基于文本的元数据 使用元数据来管理注释并动态渲染它们,而不是依赖于跨度和直接 DOM 操作。
const handleTextSelect = (newSelection: Selection) => {
const updatedNotes = [
...notes,
{
id: new Date().getTime(), // or any unique ID generator
text: newSelection.text,
start_index: newSelection.start,
end_index: newSelection.end,
field_name: newSelection.fieldName,
},
];
setNotes(updatedNotes);
setIsNoteFormOpen(true);
};
// Render notes using metadata
const renderField = (fieldName: string, content: string) => {
const fieldNotes = notes.filter(note => note.field_name === fieldName);
const segments = normalizeSpans(content, fieldNotes);
return (
<div
key={fieldName}
ref={(el) => {
if (el) {
contentRefs.current[fieldName] = el;
}
}}
className="mb-4"
>
<h2 className="text-2xl font-bold mb-2">{fieldName}</h2>
<div
className="whitespace-pre-wrap"
onMouseUp={(e) => handleMouseUp(e, fieldName)}
>
{segments}
</div>
</div>
);
};
最终组件
'use client';
import React, { useRef } from 'react';
import { Judgment, Note, Selection } from '../../../types';
interface JudgmentDisplayProps {
judgment: Judgment;
notes: Note[];
onTextSelect: (selection: Selection) => void;
onNoteClick: (note: Note) => void;
}
export default function JudgmentDisplay({ judgment, notes, onTextSelect, onNoteClick }: JudgmentDisplayProps) {
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const handleMouseUp = (event: React.MouseEvent, fieldName: string) => {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const contentElement = contentRefs.current[fieldName];
if (contentElement) {
const startOffset = range.startOffset;
const endOffset = range.endOffset;
const startIndex = calculateOffset(contentElement, range.startContainer, startOffset);
const endIndex = calculateOffset(contentElement, range.endContainer, endOffset);
console.log(`Selected in ${fieldName}: "${selection.toString()}", start: ${startIndex}, end: ${endIndex}`);
onTextSelect({
text: selection.toString(),
start: startIndex,
end: endIndex,
fieldName,
});
}
}
};
const calculateOffset = (parent: Node, node: Node, offset: number) => {
let charCount = 0;
const traverseNodes = (current: Node) => {
if (current === node) {
charCount += offset;
return;
}
if (current.nodeType === Node.TEXT_NODE) {
charCount += current.textContent?.length || 0;
} else {
for (let i = 0; i < current.childNodes.length; i++) {
traverseNodes(current.childNodes[i]);
}
}
};
traverseNodes(parent);
return charCount;
};
const normalizeSpans = (content: string, notes: Note[]) => {
notes.sort((a, b) => a.start_index - b.start_index);
const segments: React.ReactNode[] = [];
let lastIndex = 0;
notes.forEach(note => {
if (note.start_index > lastIndex) {
segments.push(content.slice(lastIndex, note.start_index));
}
segments.push(
<React.Fragment key={note.id}>
<span
className="cursor-pointer text-blue-500"
onClick={() => onNoteClick(note)}
>
*
</span>
<span className="bg-yellow-200">
{content.slice(note.start_index, note.end_index)}
</span>
</React.Fragment>
);
lastIndex = note.end_index;
});
if (lastIndex < content.length) {
segments.push(content.slice(lastIndex));
}
return segments;
};
const renderField = (fieldName: string, content: string) => {
const fieldNotes = notes.filter(note => note.field_name === fieldName);
const segments = normalizeSpans(content, fieldNotes);
return (
<div
key={fieldName}
ref={(el) => {
if (el) {
contentRefs.current[fieldName] = el;
}
}}
className="mb-4"
>
<h2 className="text-2xl font-bold mb-2">{fieldName}</h2>
<div
className="whitespace-pre-wrap"
onMouseUp={(e) => handleMouseUp(e, fieldName)}
>
{segments}
</div>
</div>
);
};
return (
<div>
{Object.entries(judgment).map(([fieldName, content]) =>
typeof content === 'string' ? renderField(fieldName, content) : null
)}
</div>
);
}