You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js 14(App Router) React文本标注span错位问题求解

法律判决书标注位置错位问题求解

我正在开发一款基于React Next.js 14(App Router)的应用,支持用户为法律判决书添加评论标注。当前实现逻辑是:用户选中判决书文本区域后,通过标注表单收集内容,然后在选中文本的起止位置插入span标签做高亮。

但现在遇到一个问题:添加第一个标注时一切正常,但后续添加的span标签位置会出现错位,我无法定位问题所在,恳请提供更优的span标签位置计算方案。


JudgmentDisplay组件代码

'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>
  );
}

内容的提问来源于stack exchange,提问作者Mel

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 19:39:53