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
+256
View File
@@ -0,0 +1,256 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { FuriganaItem } from '../types';
export interface FuriganaLayerHandle {
focusFirstItem: () => void;
focusArea: () => void;
}
interface FuriganaLayerProps {
items: FuriganaItem[];
editable: boolean;
selectedItemId?: string | null;
fontSize: number;
fontFamily: string;
onChange?: (items: FuriganaItem[]) => void;
onItemSelect?: (itemId: string) => void;
onTextSelect?: (selectedText: string) => void;
onFocusMain?: () => void;
onFocusPreviousLine?: () => void;
}
const FURIGANA_ROW_Y = 12;
export const FuriganaLayer = forwardRef<FuriganaLayerHandle, FuriganaLayerProps>(
(
{
items,
editable,
selectedItemId,
fontSize,
fontFamily,
onChange,
onItemSelect,
onTextSelect,
onFocusMain,
onFocusPreviousLine,
},
ref
) => {
const areaRef = useRef<HTMLDivElement>(null);
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const itemsRef = useRef(items);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useImperativeHandle(ref, () => ({
focusFirstItem: () => {
const firstItem = itemsRef.current[0];
if (firstItem) {
inputRefs.current[firstItem.id]?.focus({ preventScroll: true });
} else {
areaRef.current?.focus({ preventScroll: true });
}
},
focusArea: () => {
areaRef.current?.focus({ preventScroll: true });
},
}));
const getXPosition = (clientX: number) => {
const area = areaRef.current;
if (!area) return 0;
const rect = area.getBoundingClientRect();
return Math.max(0, (clientX - rect.left) / fontSize);
};
const commitItems = (nextItems: FuriganaItem[]) => {
onChange?.(nextItems);
};
const cleanEmptyItems = (itemsToClean = itemsRef.current) => {
const cleanedItems = itemsToClean.filter((item) => item.text.trim().length > 0);
if (cleanedItems.length !== itemsToClean.length) {
commitItems(cleanedItems);
itemsRef.current = cleanedItems;
}
return cleanedItems;
};
useEffect(() => {
if (!editable) return;
return () => {
cleanEmptyItems();
};
}, [editable]);
const createItem = (clientX: number) => {
if (!editable) return;
const cleanedItems = cleanEmptyItems();
const x = getXPosition(clientX);
const newItem: FuriganaItem = {
id: `furi-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
text: '',
x,
y: FURIGANA_ROW_Y,
};
commitItems([...cleanedItems, newItem]);
onItemSelect?.(newItem.id);
window.setTimeout(() => {
inputRefs.current[newItem.id]?.focus({ preventScroll: true });
}, 0);
};
const updateItemText = (itemId: string, text: string) => {
commitItems(itemsRef.current.map((item) => (item.id === itemId ? { ...item, text } : item)));
};
const removeItem = (itemId: string) => {
commitItems(itemsRef.current.filter((item) => item.id !== itemId));
};
const removeItemIfEmpty = (itemId: string) => {
const item = itemsRef.current.find((candidate) => candidate.id === itemId);
if (item && item.text.trim() === '') {
removeItem(itemId);
}
};
const selectExistingItem = (itemId: string) => {
const nextItems = itemsRef.current.filter((item) => item.id === itemId || item.text.trim().length > 0);
if (nextItems.length !== itemsRef.current.length) {
commitItems(nextItems);
itemsRef.current = nextItems;
}
onItemSelect?.(itemId);
};
const renderedItems = items.map((item) => ({ ...item, y: FURIGANA_ROW_Y }));
return (
<div
ref={areaRef}
tabIndex={editable ? 0 : -1}
onPointerDown={(e) => {
if (editable && e.target === e.currentTarget) {
e.preventDefault();
e.stopPropagation();
createItem(e.clientX);
}
}}
onClick={(e) => {
if (editable) {
e.stopPropagation();
}
}}
onDoubleClick={(e) => {
if (editable) {
e.stopPropagation();
}
}}
onKeyDown={(e) => {
if (!editable) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
onFocusMain?.();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
onFocusPreviousLine?.();
}
}}
title={editable ? '点击空白处添加假名;点击已有假名可查看和修改' : undefined}
data-furigana-layer="true"
className={`relative w-full outline-none ${editable ? 'cursor-text' : 'select-none'}`}
style={{
fontSize: `${fontSize}px`,
fontFamily,
height: `${fontSize * 1.3}px`,
lineHeight: '1',
}}
>
{renderedItems.map((item) => (
<span
key={item.id}
className="absolute inline-flex items-center"
style={{
left: `${item.x}em`,
top: `${item.y}%`,
}}
>
{editable ? (
<input
ref={(el) => {
inputRefs.current[item.id] = el;
}}
type="text"
value={item.text}
onFocus={() => selectExistingItem(item.id)}
onPointerDown={(e) => {
e.stopPropagation();
selectExistingItem(item.id);
}}
onChange={(e) => updateItemText(item.id, e.target.value)}
onBlur={() => removeItemIfEmpty(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onFocusMain?.();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onFocusMain?.();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
onFocusPreviousLine?.();
} else if (e.key === 'Backspace' && item.text === '') {
e.preventDefault();
removeItem(item.id);
}
}}
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) onTextSelect?.(selectedText);
}
}}
className="bg-transparent border-none outline-none focus:outline-none focus:ring-0 m-0 p-0 text-[#8B7E74] tracking-wide font-mono"
style={{
width: `${Math.max([...item.text].length, 1)}em`,
fontSize: '0.6em',
fontFamily,
lineHeight: '1',
}}
/>
) : (
<span
className="inline-block text-[#8B7E74] tracking-wide"
style={{
fontSize: '0.6em',
fontFamily,
lineHeight: '1',
}}
>
{item.text}
</span>
)}
</span>
))}
</div>
);
}
);
FuriganaLayer.displayName = 'FuriganaLayer';
+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>
);
};
+509
View File
@@ -0,0 +1,509 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { SidebarNote, WritingLine } from '../types';
import { BookOpen, Tag, Trash2, Search, FileText, ChevronRight, HelpCircle, X, Check } from 'lucide-react';
interface SidebarProps {
lines: WritingLine[];
activeLineId: string | null;
currentSelection: { text: string; lineId: string } | null;
selectedFurigana: { lineId: string; itemId: string } | null;
focusedNoteId: string | null;
onFocusNote: (noteId: string | null) => void;
onAddNote: (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => void;
onDeleteNote: (lineId: string, noteId: string) => void;
onDeleteFurigana: (lineId: string, itemId: string) => void;
onUpdateFurigana: (lineId: string, itemId: string, text: string) => void;
onUpdateFuriganaX: (lineId: string, itemId: string, x: number) => void;
onClearSelection: () => void;
onJumpToLine: (lineId: string) => void;
}
const HIGHLIGHT_COLORS = [
{ value: 'yellow', label: '岩砂褐', bg: 'bg-[#E8E4DE]', border: 'border-[#8B7E74]', check: 'text-[#2D2926]' },
{ value: 'rose', label: '绯樱红', bg: 'bg-rose-100', border: 'border-rose-400', check: 'text-rose-700' },
{ value: 'sky', label: '琉璃蓝', bg: 'bg-sky-100', border: 'border-sky-400', check: 'text-sky-700' },
{ value: 'emerald', label: '薄荷绿', bg: 'bg-emerald-100', border: 'border-emerald-400', check: 'text-emerald-700' },
{ value: 'violet', label: '藤萝紫', bg: 'bg-purple-100', border: 'border-purple-400', check: 'text-purple-700' },
];
export const Sidebar: React.FC<SidebarProps> = ({
lines,
activeLineId,
currentSelection,
selectedFurigana,
focusedNoteId,
onFocusNote,
onAddNote,
onDeleteNote,
onDeleteFurigana,
onUpdateFurigana,
onUpdateFuriganaX,
onClearSelection,
onJumpToLine,
}) => {
const [commentText, setCommentText] = useState('');
const [selectedColor, setSelectedColor] = useState('yellow');
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'active'>('all');
// Check if we are editing an existing note
const isEditing = !!(
focusedNoteId ||
(currentSelection &&
lines.find((l) => l.id === currentSelection.lineId)?.notes.some((n) => n.text === currentSelection.text))
);
let editingNoteId: string | undefined = focusedNoteId || undefined;
let editingLineId: string | undefined = currentSelection?.lineId || undefined;
if (!editingNoteId && currentSelection) {
const lineObj = lines.find((l) => l.id === currentSelection.lineId);
if (lineObj) {
const existing = lineObj.notes.find((n) => n.text === currentSelection.text);
if (existing) {
editingNoteId = existing.id;
}
}
}
const selectedFuriganaLineIndex = selectedFurigana
? lines.findIndex((line) => line.id === selectedFurigana.lineId)
: -1;
const selectedFuriganaLine = selectedFuriganaLineIndex >= 0 ? lines[selectedFuriganaLineIndex] : undefined;
const selectedFuriganaItem = selectedFuriganaLine?.furiganaItems.find(
(item) => item.id === selectedFurigana?.itemId
);
const selectedFuriganaMaxX = selectedFuriganaLine
? Math.max(4, [...selectedFuriganaLine.rawText].length + 4, (selectedFuriganaItem?.x || 0) + 2)
: 4;
// Automatically update the comment input field when the selected text/note changes
React.useEffect(() => {
let existingNote: SidebarNote | undefined;
if (focusedNoteId) {
for (const line of lines) {
const found = line.notes.find((n) => n.id === focusedNoteId);
if (found) {
existingNote = found;
break;
}
}
}
if (!existingNote && currentSelection) {
const lineObj = lines.find((l) => l.id === currentSelection.lineId);
existingNote = lineObj?.notes.find((n) => n.text === currentSelection.text);
}
if (existingNote) {
setCommentText(existingNote.comment);
setSelectedColor(existingNote.color);
} else if (currentSelection) {
setCommentText('');
setSelectedColor('yellow');
} else {
setCommentText('');
}
}, [currentSelection?.text, currentSelection?.lineId, focusedNoteId]);
const handleSaveNote = (e: React.FormEvent) => {
e.preventDefault();
if (!currentSelection || !commentText.trim()) return;
onAddNote(
currentSelection.lineId,
currentSelection.text,
commentText.trim(),
selectedColor,
editingNoteId
);
if (!editingNoteId) {
setCommentText('');
onClearSelection();
}
};
// Compile all notes across the writing pad
const allNotesWithLineContext = lines.flatMap((line, idx) => {
return line.notes.map((note) => ({
...note,
lineId: line.id,
lineNumber: idx + 1,
}));
});
// Filter notes based on search query, current line focus, and focusedNoteId
const filteredNotes = allNotesWithLineContext.filter((note) => {
if (focusedNoteId) {
return note.id === focusedNoteId;
}
const matchesSearch =
note.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.comment.toLowerCase().includes(searchQuery.toLowerCase());
if (filterMode === 'active') {
return matchesSearch && note.lineId === activeLineId;
}
return matchesSearch;
});
return (
<div className="flex flex-col h-full bg-[#FDFCFB] border-l border-[#E8E4DE]" id="annotations-sidebar">
{/* Header */}
<div className="p-4 border-b border-[#E8E4DE] bg-[#FAF9F6]/80 flex items-center justify-between">
<h2 className="font-serif font-bold text-[#2D2926] text-sm flex items-center gap-2">
<BookOpen className="w-4 h-4 text-[#8B7E74]" />
<span></span>
<span className="font-mono text-xs text-[#A69F92] font-normal">
({allNotesWithLineContext.length})
</span>
</h2>
</div>
{/* Editor or Helper Section for Selection */}
<div className="p-4 border-b border-[#E8E4DE] bg-[#FAF9F6]/30">
{selectedFurigana && selectedFuriganaLine && selectedFuriganaItem ? (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
<Tag className="w-3 h-3" />
</span>
<button
type="button"
onClick={onClearSelection}
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
title="关闭假名操作区"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
:
</div>
<input
type="text"
value={selectedFuriganaItem.text}
onChange={(e) =>
onUpdateFurigana(selectedFurigana.lineId, selectedFurigana.itemId, e.target.value)
}
placeholder="在此修改假名"
className="w-full bg-[#FAF9F6] border border-[#E8E4DE] rounded px-2 py-1.5 text-[#2D2926] text-base font-serif focus:outline-none focus:ring-1 focus:ring-[#8B7E74] placeholder-[#A69F92]/60"
/>
<div className="mt-1 text-[11px] text-[#A69F92] font-mono">
{(selectedFuriganaLineIndex + 1).toString().padStart(2, '0')}
</div>
</div>
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] text-[#8B7E74] font-bold uppercase font-mono tracking-wider">
X
</span>
<span className="text-[11px] text-[#2D2926] font-mono">
{selectedFuriganaItem.x.toFixed(1)}
</span>
</div>
<input
type="range"
min={0}
max={selectedFuriganaMaxX}
step={0.1}
value={selectedFuriganaItem.x}
onChange={(e) =>
onUpdateFuriganaX(
selectedFurigana.lineId,
selectedFurigana.itemId,
Number(e.target.value)
)
}
className="w-full accent-[#8B7E74] cursor-pointer"
/>
<div className="mt-1 flex justify-between text-[10px] text-[#A69F92] font-mono">
<span>0</span>
<span>{selectedFuriganaMaxX.toFixed(1)}</span>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onJumpToLine(selectedFurigana.lineId)}
className="flex-1 border border-[#E8E4DE] bg-white hover:bg-[#FAF9F6] text-[#8B7E74] hover:text-[#2D2926] font-semibold text-xs py-2 rounded shadow-xs transition-all cursor-pointer"
>
</button>
<button
type="button"
onClick={() => onDeleteFurigana(selectedFurigana.lineId, selectedFurigana.itemId)}
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
) : currentSelection ? (
/* ACTIVE NEW NOTE FORM */
<form onSubmit={handleSaveNote} className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
<Tag className="w-3 h-3" /> {isEditing ? '修改注释' : '新建注释'}
</span>
<button
type="button"
onClick={onClearSelection}
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
title="取消选择"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Selected Text Showcase */}
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
:
</div>
<div className="font-semibold text-[#2D2926] text-lg leading-relaxed tracking-wide font-serif break-all">
{currentSelection.text}
</div>
</div>
{/* Color preset picker */}
<div>
<label className="block text-xs font-semibold text-[#2D2926] mb-1.5">
</label>
<div className="flex gap-2">
{HIGHLIGHT_COLORS.map((c) => (
<button
key={c.value}
type="button"
onClick={() => setSelectedColor(c.value)}
title={c.label}
className={`w-6 h-6 rounded-full border-2 transition-all flex items-center justify-center ${c.bg} ${
selectedColor === c.value
? 'border-[#8B7E74] scale-110 shadow-xs'
: 'border-transparent hover:scale-105'
}`}
>
{selectedColor === c.value && (
<Check className={`w-3.5 h-3.5 ${c.check} stroke-[3]`} />
)}
</button>
))}
</div>
</div>
{/* Comment input textarea */}
<div>
<label htmlFor="comment-textarea" className="block text-xs font-semibold text-[#2D2926] mb-1.5">
</label>
<textarea
id="comment-textarea"
rows={6}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="在此录入释义、语法要点、生词短语等注释..."
className="w-full text-sm md:text-base bg-white border border-[#E8E4DE] rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-[#8B7E74]/40 focus:border-[#8B7E74] placeholder-neutral-400 text-[#2D2926] leading-relaxed shadow-inner"
required
/>
</div>
<div className="flex gap-2">
{editingNoteId && editingLineId && (
<button
type="button"
onClick={() => {
if (editingNoteId && editingLineId) {
onDeleteNote(editingLineId, editingNoteId);
onClearSelection();
}
}}
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
title="删除此条注释"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
type="submit"
className={`${editingNoteId ? 'flex-[2]' : 'w-full'} bg-[#8B7E74] text-white font-semibold text-xs py-2 rounded shadow-xs hover:bg-[#786C63] active:bg-[#665B53] transition-all font-sans cursor-pointer text-center`}
>
{isEditing ? '保存修改' : '保存此条注释'}
</button>
</div>
</form>
) : (
/* EMPTY SELECTION WELCOME STATE */
<div className="text-xs text-[#A69F92] leading-relaxed py-2 flex flex-col items-center text-center gap-1.5">
<HelpCircle className="w-5 h-5 text-[#8B7E74]/40" />
<p>
<br />
<span className="font-bold text-[#2D2926]"></span>
</p>
</div>
)}
</div>
{/* FILTER & SEARCH OVERVIEW */}
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE] flex flex-col gap-2">
{/* Toggle line vs document */}
<div className="flex bg-[#E8E4DE]/60 rounded p-0.5 text-xs font-sans">
<button
onClick={() => setFilterMode('all')}
className={`flex-1 py-1 rounded text-center transition-all cursor-pointer ${
filterMode === 'all'
? 'bg-white text-[#2D2926] font-semibold border border-[#E8E4DE]/40 shadow-xs'
: 'text-[#A69F92] hover:bg-white/30'
}`}
>
</button>
<button
onClick={() => setFilterMode('active')}
className={`flex-1 py-1 rounded text-center transition-all cursor-pointer ${
filterMode === 'active'
? 'bg-white text-[#2D2926] font-semibold border border-[#E8E4DE]/40 shadow-xs'
: 'text-[#A69F92] hover:bg-white/30 font-medium'
}`}
disabled={!activeLineId}
title={!activeLineId ? '请先在写作板中双击或选中一行' : ''}
>
</button>
</div>
{/* Search bar */}
<div className="relative font-sans">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-[#A69F92]" />
<input
type="text"
placeholder="搜索已有注释..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full text-xs bg-[#FAF9F6] border border-[#E8E4DE] text-[#2D2926] pl-8 pr-3 py-1.5 rounded focus:outline-none focus:bg-white focus:ring-1 focus:ring-[#8B7E74]"
/>
</div>
</div>
{focusedNoteId && (
<div className="mx-3 mt-3 px-3 py-2 bg-[#8B7E74]/10 rounded-md border border-[#8B7E74]/20 flex items-center justify-between text-xs text-[#2D2926] font-medium font-sans animate-fadeIn">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-[#8B7E74] rounded-full animate-pulse"></span>
</span>
<button
onClick={() => onFocusNote(null)}
className="text-[#8B7E74] hover:text-[#2D2926] font-semibold hover:underline flex items-center gap-0.5 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* LIST OF PREVIOUS NOTES */}
<div className="flex-1 overflow-y-auto p-3 space-y-3.5 select-none bg-[#FAF9F6]/20">
{filteredNotes.length > 0 ? (
filteredNotes.map((note) => {
let badgeColorClass = 'bg-[#FAF9F6] border-[#E8E4DE] text-[#2D2926]';
let dotColor = 'bg-[#8B7E74]';
if (note.color === 'rose') {
badgeColorClass = 'bg-rose-50 border-rose-200 text-rose-950';
dotColor = 'bg-rose-500';
} else if (note.color === 'sky') {
badgeColorClass = 'bg-sky-50 border-sky-200 text-sky-950';
dotColor = 'bg-sky-500';
} else if (note.color === 'emerald') {
badgeColorClass = 'bg-emerald-50 border-emerald-200 text-emerald-950';
dotColor = 'bg-emerald-500';
} else if (note.color === 'violet') {
badgeColorClass = 'bg-purple-50 border-purple-200 text-purple-950';
dotColor = 'bg-purple-500';
}
const isFocused = focusedNoteId === note.id;
return (
<div
key={note.id}
onClick={() => {
if (isFocused) {
onFocusNote(null);
} else {
onFocusNote(note.id);
}
}}
className={`group border rounded-lg p-3 bg-white hover:shadow-xs transition-all flex flex-col gap-2 relative cursor-pointer ${
isFocused ? 'border-[#8B7E74] ring-1 ring-[#8B7E74]/30' : 'border-[#E8E4DE]/60 hover:border-[#8B7E74]/60'
}`}
>
{/* Note metadata */}
<div className="flex items-center justify-between">
{/* Highlight text indicator */}
<span
className={`text-xs font-semibold px-2 py-0.5 rounded border ${badgeColorClass} font-serif max-w-[70%] truncate`}
title={note.text}
>
{note.text}
</span>
{/* Jump-to Line indicator */}
<button
onClick={(e) => {
e.stopPropagation();
onJumpToLine(note.lineId);
}}
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 rounded font-mono text-[10px] flex items-center gap-0.5 transition-all cursor-pointer"
title="点击跳转并定位此行"
>
{note.lineNumber.toString().padStart(2, '0')}
<ChevronRight className="w-2.5 h-2.5" />
</button>
</div>
{/* Comment contents */}
<div className="text-[#2D2926] text-xs leading-relaxed break-words pr-2 font-sans select-text">
{note.comment}
</div>
{/* Delete button (displays on card hover) */}
<button
onClick={(e) => {
e.stopPropagation();
onDeleteNote(note.lineId, note.id);
}}
title="删除此注释"
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 rounded transition-all md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})
) : (
<div className="flex flex-col items-center justify-center text-center py-12 text-neutral-400 gap-2">
<FileText className="w-8 h-8 text-neutral-200" />
<div className="text-xs font-medium"></div>
{searchQuery && (
<div className="text-[10px] text-neutral-400 px-4">
{searchQuery}
</div>
)}
</div>
)}
</div>
</div>
);
};
+109
View File
@@ -0,0 +1,109 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import {
BookOpen,
FolderOpen,
Plus,
Save,
Upload,
} from 'lucide-react';
interface ToolbarProps {
onNewDocument: () => void;
onViewSavedDocuments: () => void;
onSaveDocument: () => void;
onToggleBulkImport: () => void;
fileStatus: string;
showImportArea: boolean;
showSavedArea: boolean;
savedDocumentCount: number;
}
export const Toolbar: React.FC<ToolbarProps> = ({
onNewDocument,
onViewSavedDocuments,
onSaveDocument,
onToggleBulkImport,
fileStatus,
showImportArea,
showSavedArea,
savedDocumentCount,
}) => {
return (
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] p-4 flex flex-col xl:flex-row xl:items-center justify-between gap-4 select-none">
{/* Brand / Logo */}
<div className="flex items-center gap-3">
<div className="bg-[#8B7E74] text-white rounded-lg p-2.5 shadow-sm">
<BookOpen className="w-5 h-5" />
</div>
<div>
<h1 className="font-serif font-bold text-[#2D2926] text-lg leading-none tracking-widest flex items-center gap-2">
<span className="text-[#8B7E74]">Kotonoha</span>
<span className="text-[10px] bg-[#8B7E74]/10 text-[#8B7E74] border border-[#8B7E74]/20 px-2 py-0.5 rounded-full font-sans tracking-normal font-bold">
AI纯净版
</span>
</h1>
<p className="text-[11px] text-[#A69F92] font-sans mt-1.5 tracking-tight">
· {fileStatus}
</p>
</div>
</div>
{/* Document actions */}
<div className="flex flex-wrap items-center gap-2 text-xs select-none font-sans">
<button
onClick={onSaveDocument}
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium rounded-lg border border-[#8B7E74] shadow-2xs transition-all cursor-pointer"
title="按当前文档标题保存到项目 saves 目录"
>
<Save className="w-3.5 h-3.5 text-white" />
<span></span>
</button>
<button
onClick={onToggleBulkImport}
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
showImportArea
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
}`}
title="粘贴整段文本并按行导入"
>
<Upload className={`w-3.5 h-3.5 ${showImportArea ? 'text-white' : 'text-[#8B7E74]'}`} />
<span></span>
</button>
<button
onClick={onNewDocument}
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#FAF9F6] text-[#2D2926] font-medium rounded-lg border border-[#E8E4DE] shadow-2xs transition-all cursor-pointer"
title="新建空白文档"
>
<Plus className="w-3.5 h-3.5 text-[#8B7E74]" />
<span></span>
</button>
<button
onClick={onViewSavedDocuments}
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
showSavedArea
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
}`}
title="查看项目 saves 目录里的保存文件"
>
<FolderOpen className={`w-3.5 h-3.5 ${showSavedArea ? 'text-white' : 'text-[#8B7E74]'}`} />
<span></span>
{savedDocumentCount > 0 && (
<span className={`font-mono text-[10px] ${showSavedArea ? 'text-white/80' : 'text-[#A69F92]'}`}>
{savedDocumentCount}
</span>
)}
</button>
</div>
</div>
);
};