如何在 React nextjs 14(应用程序路由器)应用程序中向文本注释添加文本注释

问题描述 投票:0回答:1

我正在尝试构建一个允许用户对法律判决发表评论的应用程序。我的应用程序中有判断和注释。对判决案文作出注释。目前,当用户选择判断文本的某个区域时,添加注释表单会收集注释并放置从所选文本的开头到结尾的跨度。

对于添加的第一个注释来说,这工作正常。之后跨度就错位了。我看不出我做错了什么。谁能建议一种更好的方法来计算如何放置我的笔记的跨度?

这是我的判断显示组件:

    '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>
  );
}
javascript reactjs next.js annotations nextjs14
1个回答
0
投票

计算和跟踪文本索引以及在文本内渲染注释时可能会出现错误。以下是一些可能的改进和建议,可帮助解决该问题:

直接使用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>
  );
}
© www.soinside.com 2019 - 2024. All rights reserved.