499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
/**
|
|
* @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<LineItemProps> = ({
|
|
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<FuriganaLayerHandle>(null);
|
|
const mainInputRef = useRef<HTMLInputElement>(null);
|
|
const displayRef = useRef<HTMLDivElement>(null);
|
|
const mainTextRef = useRef<HTMLDivElement>(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<HTMLInputElement>, 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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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(
|
|
<span
|
|
key={`highlight-${earliestNote.id}-${earliestIndex}`}
|
|
data-note-highlight="true"
|
|
onPointerUp={(e) => {
|
|
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}
|
|
</span>
|
|
);
|
|
|
|
if (after) {
|
|
parts.push(...splitText(after, availableNotes));
|
|
}
|
|
|
|
return parts;
|
|
};
|
|
|
|
return splitText(text, activeNotes);
|
|
};
|
|
|
|
const calculatedLineHeight = fontSize * lineSpacing;
|
|
const isSerif = fontStyle === 'serif';
|
|
|
|
return (
|
|
<div
|
|
id={`line-${line.id}`}
|
|
className={`relative group flex w-full min-w-0 gap-1 pr-1 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 md:min-w-[900px] md:gap-4 md:pr-3 xl:min-w-[1200px]`}
|
|
style={{ minHeight: `${calculatedLineHeight}px` }}
|
|
>
|
|
{/* 1. Left gutter with number */}
|
|
{showLineNumber && (
|
|
<div className="relative flex w-8 shrink-0 flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 border-r border-[#E8E4DE]/50 font-mono sm:w-10 md:w-12">
|
|
<span className="text-[11px] font-bold select-none opacity-60">
|
|
{lineNumber.toString().padStart(2, '0')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 2. Interactive sheet writing lines (Direct live editing) */}
|
|
<div className="min-w-0 flex-1 py-1 flex flex-col justify-center">
|
|
{!readOnly ? (
|
|
/* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
|
|
<div className="flex flex-col w-full min-w-0 overflow-hidden px-0.5 pr-0.5 font-mono md:-mx-1 md:px-1 md:pr-1 md:overflow-visible">
|
|
{/* Top Row: Free-positioned Furigana / 假名 layer */}
|
|
<FuriganaLayer
|
|
ref={furiganaLayerRef}
|
|
items={furiganaItems}
|
|
editable
|
|
selectedItemId={selectedFuriganaItemId}
|
|
fontSize={fontSize}
|
|
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
|
onChange={updateFuriganaItems}
|
|
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
|
onFocusMain={() => mainInputRef.current?.focus({ preventScroll: true })}
|
|
onFocusPreviousLine={() => moveFocusUp(line.id, 'main')}
|
|
/>
|
|
|
|
{/* Bottom Row: Main Kanji / 正文 input line */}
|
|
<input
|
|
ref={mainInputRef}
|
|
type="text"
|
|
value={line.rawText || ''}
|
|
onChange={(e) => 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',
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
/* ================= READ MODE: ALIGNED STATIC TEXT DISPLAY ================= */
|
|
<div
|
|
ref={displayRef}
|
|
onMouseUp={handleSelection}
|
|
onPointerDown={handleMobileSelectionStart}
|
|
onPointerMove={handleMobileSelectionMove}
|
|
onPointerUp={handleMobileSelectionEnd}
|
|
onPointerCancel={() => {
|
|
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 */}
|
|
<FuriganaLayer
|
|
items={furiganaItems}
|
|
editable={canEdit}
|
|
selectedItemId={selectedFuriganaItemId}
|
|
fontSize={fontSize}
|
|
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
|
onChange={updateFuriganaItems}
|
|
onItemSelect={canEdit ? (itemId) => onFuriganaSelect(line.id, itemId) : undefined}
|
|
/>
|
|
|
|
{/* Main Kanji text line with word highlighter/comment select matches */}
|
|
<div
|
|
ref={mainTextRef}
|
|
className="text-[#2D2926] m-0 p-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
|
style={{
|
|
fontSize: `${fontSize}px`,
|
|
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
|
|
marginTop: '1px',
|
|
minHeight: `${fontSize * 1.2}px`,
|
|
lineHeight: '1.2',
|
|
touchAction: canEdit ? 'auto' : 'pan-y',
|
|
}}
|
|
>
|
|
{line.rawText ? (
|
|
renderTextWithNotes(line.rawText, line.notes)
|
|
) : (
|
|
'\u00A0'
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|