javascriptreactjsnext.jsannotationsnextjs14

how to add text annotations to text notes in a react nextjs 14 (app router) app


I am trying to build an app that allows users to comment on legal judgments. i have judgments and notes in my app. notes are made on the text of judgments. At the moment, when a user selects an area of the judgment text, an add note form collects the note and puts a span from the start to the end of the selected text.

this is working fine, for the first note added. after that the spans are misplaced. I can't see what im doing wrong. Can anyone suggest a better way to calculate how to place the spans for my notes?

This is my judgmentDisplay component:

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

this is my form handler:

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

Solution

  • It could be an error in calculating and keeping track of the text indices and rendering the notes inside the text. Here are a few possible improvements and suggestions to help resolve the issue:

    Use range.startContainer and range.endContainer Directly

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

    Use document.createRange() and Range.getBoundingClientRect() This helps to visualize and understand the selected text position:

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

    Normalize Spans After Each Update Ensure that spans are recalculated and properly ordered whenever new notes are added.

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

    Text-based Metadata for Managing Notes Instead of relying on spans and direct DOM manipulations, use metadata to manage notes and render them dynamically.

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

    Final component

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