/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useRef, useEffect } from 'react'; import { FuriganaItem, WritingLine, SidebarNote } from '../types'; import { Trash2, Plus, ArrowUp, ArrowDown } from 'lucide-react'; import { FuriganaLayer, FuriganaLayerHandle } from './FuriganaLayer'; interface LineItemProps { line: WritingLine; lineNumber: number; showLineNumber: boolean; isActive: boolean; fontStyle: 'sans' | 'serif'; fontSize: number; lineSpacing: number; readOnly?: boolean; canEdit: boolean; onUpdate: (lineId: string, newRawText: string, newFuriganaItems?: FuriganaItem[]) => void; onDelete: (lineId: string) => void; onActivate: (lineId: string) => void; onDeactivate: () => void; onDoubleClick?: (lineId: string) => void; onInsertBelow: (lineId: string) => void; onInsertAbove: (lineId: string) => void; onTextSelect: (selectedText: string, lineId: string) => void; selectedFuriganaItemId?: string | null; onFuriganaSelect: (lineId: string, itemId: string) => void; onNoteClick: (note: SidebarNote) => void; moveFocusUp: (lineId: string, focusField: 'main' | 'furigana') => void; moveFocusDown: (lineId: string, focusField: 'main' | 'furigana') => void; } export const LineItem: React.FC = ({ line, lineNumber, showLineNumber, isActive, fontStyle, fontSize, lineSpacing, readOnly = false, canEdit, onUpdate, onDelete, onActivate, onDeactivate, onDoubleClick, onInsertBelow, onInsertAbove, onTextSelect, selectedFuriganaItemId, onFuriganaSelect, onNoteClick, moveFocusUp, moveFocusDown, }) => { const furiganaLayerRef = useRef(null); const mainInputRef = useRef(null); const displayRef = useRef(null); const mainTextRef = useRef(null); const mobileSelectionRef = useRef<{ pointerId: number; startX: number; startY: number; currentX: number; currentY: number; selecting: boolean; startPoint: TextHitPoint | null; } | null>(null); const furiganaItems = line.furiganaItems; type TextHitPoint = { node: Text; offset: number; globalOffset: number; }; // Expose focus methods dynamically useEffect(() => { const handleCustomFocus = (e: CustomEvent<{ field: 'main' | 'furigana' }>) => { if (e.detail.field === 'furigana') { furiganaLayerRef.current?.focusFirstItem(); } else { mainInputRef.current?.focus({ preventScroll: true }); } }; const el = document.getElementById(`line-${line.id}`); if (el) { el.addEventListener('focus-field' as any, handleCustomFocus as any); } return () => { if (el) { el.removeEventListener('focus-field' as any, handleCustomFocus as any); } }; }, [furiganaItems, line.id]); const handleKeyDown = (e: React.KeyboardEvent, isFurigana: boolean) => { if (e.key === 'Enter') { e.preventDefault(); if (isFurigana) { e.currentTarget.blur(); } else { onInsertBelow(line.id); } } else if (e.key === 'Backspace') { // If both inputs are completely empty, delete this line if (!line.rawText && furiganaItems.length === 0) { e.preventDefault(); onDelete(line.id); } } else if (e.key === 'ArrowUp') { e.preventDefault(); if (isFurigana) { // Go to previous line's main input moveFocusUp(line.id, 'main'); } else { // Go up to the furigana input of the same line furiganaLayerRef.current?.focusFirstItem(); } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (isFurigana) { // Go down to the main input of the same line mainInputRef.current?.focus({ preventScroll: true }); } else { // Go to next line's furigana input moveFocusDown(line.id, 'furigana'); } } }; const updateFuriganaItems = (nextItems: FuriganaItem[]) => { onUpdate(line.id, line.rawText, nextItems); }; const getCaretRangeFromPoint = (clientX: number, clientY: number) => { const doc = document as Document & { caretRangeFromPoint?: (x: number, y: number) => Range | null; caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null; }; if (doc.caretRangeFromPoint) { return doc.caretRangeFromPoint(clientX, clientY); } const position = doc.caretPositionFromPoint?.(clientX, clientY); if (!position) return null; const range = document.createRange(); range.setStart(position.offsetNode, position.offset); range.collapse(true); return range; }; const getTextHitPoint = (clientX: number, clientY: number): TextHitPoint | null => { const root = mainTextRef.current; const range = getCaretRangeFromPoint(clientX, clientY); if (!root || !range || !root.contains(range.startContainer)) return null; if (range.startContainer.nodeType !== Node.TEXT_NODE) return null; const targetNode = range.startContainer as Text; const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let globalOffset = 0; let current = treeWalker.nextNode() as Text | null; while (current) { if (current === targetNode) { return { node: targetNode, offset: Math.min(range.startOffset, targetNode.data.length), globalOffset: globalOffset + Math.min(range.startOffset, targetNode.data.length), }; } globalOffset += current.data.length; current = treeWalker.nextNode() as Text | null; } return null; }; const setVisualSelection = (startPoint: TextHitPoint, endPoint: TextHitPoint) => { const range = document.createRange(); if (startPoint.globalOffset <= endPoint.globalOffset) { range.setStart(startPoint.node, startPoint.offset); range.setEnd(endPoint.node, endPoint.offset); } else { range.setStart(endPoint.node, endPoint.offset); range.setEnd(startPoint.node, startPoint.offset); } const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); return selection?.toString().trim() || ''; }; const handleMobileSelectionStart = (e: React.PointerEvent) => { if (canEdit || e.pointerType !== 'touch' || !line.rawText) return; const target = e.target as HTMLElement; if (target.closest('[data-note-highlight="true"]') || target.closest('[data-furigana-layer="true"]')) return; const startPoint = getTextHitPoint(e.clientX, e.clientY); mobileSelectionRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, currentX: e.clientX, currentY: e.clientY, selecting: false, startPoint, }; }; const handleMobileSelectionMove = (e: React.PointerEvent) => { const gesture = mobileSelectionRef.current; if (!gesture || gesture.pointerId !== e.pointerId) return; gesture.currentX = e.clientX; gesture.currentY = e.clientY; const dx = Math.abs(e.clientX - gesture.startX); const dy = Math.abs(e.clientY - gesture.startY); if (!gesture.selecting && dx > 10 && dx > dy * 1.15) { gesture.selecting = true; } if (gesture.selecting) { e.preventDefault(); e.stopPropagation(); const endPoint = getTextHitPoint(e.clientX, e.clientY); if (gesture.startPoint && endPoint) { setVisualSelection(gesture.startPoint, endPoint); } } }; const handleMobileSelectionEnd = (e: React.PointerEvent) => { const gesture = mobileSelectionRef.current; if (!gesture || gesture.pointerId !== e.pointerId) return; mobileSelectionRef.current = null; if (!gesture.selecting) return; e.preventDefault(); e.stopPropagation(); const endPoint = getTextHitPoint(gesture.currentX, gesture.currentY); const selectedText = gesture.startPoint && endPoint ? setVisualSelection(gesture.startPoint, endPoint) : window.getSelection()?.toString().trim() || ''; if (selectedText) { onTextSelect(selectedText, line.id); } }; // Capture selection in reading mode to add annotations const handleSelection = () => { const selection = window.getSelection(); if (!selection || selection.isCollapsed) return; const isNodeInsideFurigana = (node: Node | null) => { const element = node instanceof Element ? node : node?.parentElement; return !!element?.closest('[data-furigana-layer="true"]'); }; const selectedText = selection.toString().trim(); if (selectedText.length > 0) { const lowerSelected = selectedText.toLowerCase(); const mainTextLower = (line.rawText || '').toLowerCase(); const isInside = displayRef.current && ( displayRef.current.contains(selection.anchorNode) || displayRef.current.contains(selection.focusNode) ); const isInsideFurigana = isNodeInsideFurigana(selection.anchorNode) || isNodeInsideFurigana(selection.focusNode); if (!isInsideFurigana && (isInside || mainTextLower.includes(lowerSelected))) { onTextSelect(selectedText, line.id); } } }; // Renders text highlighted with custom note overlays const renderTextWithNotes = (text: string, notes: SidebarNote[]) => { if (!text || notes.length === 0) return text; const activeNotes = notes.filter((n) => text.includes(n.text)); if (activeNotes.length === 0) return text; const splitText = (str: string, availableNotes: SidebarNote[]): React.ReactNode[] => { if (!str) return []; if (availableNotes.length === 0) return [str]; let earliestNote: SidebarNote | null = null; let earliestIndex = Infinity; for (const note of availableNotes) { const idx = str.indexOf(note.text); if (idx !== -1 && idx < earliestIndex) { earliestIndex = idx; earliestNote = note; } } if (!earliestNote || earliestIndex === Infinity) { return [str]; } const matchText = earliestNote.text; const before = str.slice(0, earliestIndex); const after = str.slice(earliestIndex + matchText.length); const parts: React.ReactNode[] = []; if (before) { parts.push(...splitText(before, availableNotes)); } let colorClass = 'bg-[#FAF9F6] border-[#8B7E74] text-[#2D2926] hover:bg-[#E8E4DE]/50'; if (earliestNote.color === 'rose') { colorClass = 'bg-rose-100/70 border-rose-300 text-rose-950 hover:bg-rose-200/80'; } else if (earliestNote.color === 'sky') { colorClass = 'bg-sky-100/70 border-sky-300 text-sky-950 hover:bg-sky-200/80'; } else if (earliestNote.color === 'emerald') { colorClass = 'bg-emerald-100/70 border-emerald-300 text-emerald-950 hover:bg-emerald-200/80'; } else if (earliestNote.color === 'violet') { colorClass = 'bg-purple-100/70 border-purple-300 text-purple-950 hover:bg-purple-200/80'; } parts.push( { if (e.pointerType !== 'touch') return; e.preventDefault(); e.stopPropagation(); onNoteClick(earliestNote!); }} onClick={(e) => { e.stopPropagation(); onNoteClick(earliestNote!); }} className={`${colorClass} px-1 rounded border-b-2 font-medium cursor-pointer transition-all duration-200 inline-block active:scale-95`} title={`点击查看注释: ${earliestNote.comment}`} > {matchText} ); if (after) { parts.push(...splitText(after, availableNotes)); } return parts; }; return splitText(text, activeNotes); }; const calculatedLineHeight = fontSize * lineSpacing; const isSerif = fontStyle === 'serif'; return (
{/* 1. Left gutter with number */} {showLineNumber && (
{lineNumber.toString().padStart(2, '0')}
)} {/* 2. Interactive sheet writing lines (Direct live editing) */}
{!readOnly ? ( /* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
{/* Top Row: Free-positioned Furigana / 假名 layer */} onFuriganaSelect(line.id, itemId)} onFocusMain={() => mainInputRef.current?.focus({ preventScroll: true })} onFocusPreviousLine={() => moveFocusUp(line.id, 'main')} /> {/* Bottom Row: Main Kanji / 正文 input line */} onUpdate(line.id, e.target.value)} onKeyDown={(e) => handleKeyDown(e, false)} onSelect={(e) => { const target = e.currentTarget; const start = target.selectionStart; const end = target.selectionEnd; if (start !== null && end !== null && start !== end) { const selectedText = target.value.substring(start, end).trim(); if (selectedText.length > 0) { onTextSelect(selectedText, line.id); } } }} placeholder="" className="w-full bg-transparent border-none outline-none focus:outline-none focus:ring-0 m-0 p-0 font-normal text-[#2D2926] placeholder-neutral-300 font-mono" style={{ fontSize: `${fontSize}px`, fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace', marginTop: '1px', height: `${fontSize * 1.2}px`, lineHeight: '1.2', }} />
) : ( /* ================= READ MODE: ALIGNED STATIC TEXT DISPLAY ================= */
{ mobileSelectionRef.current = null; }} onClick={(e) => { if ((e.target as HTMLElement).closest('[data-furigana-layer="true"]')) return; if (!canEdit) return; const selection = window.getSelection(); if (selection && !selection.isCollapsed) return; onActivate(line.id); }} onDoubleClick={(e) => { if (canEdit && onDoubleClick) { onDoubleClick(line.id); } }} className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 rounded px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${ canEdit ? 'cursor-text' : 'cursor-default' }`} > {/* Furigana line */} onFuriganaSelect(line.id, itemId) : undefined} /> {/* Main Kanji text line with word highlighter/comment select matches */}
{line.rawText ? ( renderTextWithNotes(line.rawText, line.notes) ) : ( '\u00A0' )}
)}
); };