Initial local project snapshot.

This commit is contained in:
yiulix
2026-06-15 11:42:58 +08:00
commit 1d1f7591db
19 changed files with 7312 additions and 0 deletions
+336
View File
@@ -0,0 +1,336 @@
/**
* @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;
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,
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 furiganaItems = line.furiganaItems;
// 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);
};
// Capture selection in reading mode to add annotations
const handleSelection = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
const lowerSelected = selectedText.toLowerCase();
const mainTextLower = (line.rawText || '').toLowerCase();
const furiTextLower = furiganaItems.map((item) => item.text).join(' ').toLowerCase();
const isInside = displayRef.current && (
displayRef.current.contains(selection.anchorNode) ||
displayRef.current.contains(selection.focusNode)
);
if (isInside || mainTextLower.includes(lowerSelected) || furiTextLower.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"
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 gap-4 pr-3 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 min-w-[800px] md:min-w-[1200px] xl:min-w-[1600px]`}
style={{ minHeight: `${calculatedLineHeight}px` }}
>
{/* 1. Left gutter with number */}
<div className="relative flex flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 w-12 border-r border-[#E8E4DE]/50 font-mono">
{showLineNumber && (
<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="flex-1 py-1 flex flex-col justify-center">
{!readOnly ? (
/* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
<div className="flex flex-col w-full px-1 -mx-1 pr-1 font-mono">
{/* 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)}
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
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}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[data-furigana-layer="true"]')) return;
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
onActivate(line.id);
}}
onDoubleClick={(e) => {
if (onDoubleClick) {
onDoubleClick(line.id);
}
}}
className="flex flex-col w-full cursor-text select-text pr-1 font-mono hover:bg-[#FAF9F6]/80 rounded px-1 -mx-1"
>
{/* Furigana line */}
<FuriganaLayer
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)}
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
/>
{/* Main Kanji text line with word highlighter/comment select matches */}
<div
className="text-[#2D2926] m-0 p-0"
style={{
fontSize: `${fontSize}px`,
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
marginTop: '1px',
height: `${fontSize * 1.2}px`,
lineHeight: '1.2',
}}
>
{line.rawText ? (
renderTextWithNotes(line.rawText, line.notes)
) : (
'\u00A0'
)}
</div>
</div>
)}
</div>
</div>
);
};