更新内容

This commit is contained in:
TU
2026-07-06 20:46:31 +08:00
parent 1d1f7591db
commit 71bb89342d
83 changed files with 5961 additions and 427 deletions
+69
View File
@@ -0,0 +1,69 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { BookOpen, Layers3 } from 'lucide-react';
export type AppView = 'lyrics' | 'vocab';
interface AppNavProps {
activeView: AppView;
onChange: (view: AppView) => void;
}
const NAV_ITEMS = [
{ id: 'lyrics' as const, label: '歌词', icon: BookOpen },
{ id: 'vocab' as const, label: '单词卡片', icon: Layers3 },
];
export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
return (
<>
<nav className="hidden lg:flex w-28 shrink-0 flex-col gap-2 border-r border-[#E8E4DE] bg-[#FDFCFB] p-2 shadow-[8px_0_24px_-24px_rgba(45,41,38,0.45)]">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={`flex w-full flex-col items-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition ${
isActive
? 'bg-[#8B7E74] text-white'
: 'bg-white text-[#8B7E74] hover:bg-[#FAF9F6] hover:text-[#2D2926]'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
{item.label}
</button>
);
})}
</nav>
<nav className="order-last grid shrink-0 grid-cols-2 border-t border-[#E8E4DE] bg-[#FDFCFB] px-3 py-2 shadow-[0_-8px_24px_-18px_rgba(45,41,38,0.4)] lg:hidden">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={`mx-1 flex items-center justify-center gap-2 rounded-lg py-2 text-xs font-semibold transition ${
isActive
? 'bg-[#8B7E74] text-white'
: 'bg-white text-[#8B7E74] border border-[#E8E4DE]'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
{item.label}
</button>
);
})}
</nav>
</>
);
};
+180 -18
View File
@@ -17,6 +17,7 @@ interface LineItemProps {
fontSize: number;
lineSpacing: number;
readOnly?: boolean;
canEdit: boolean;
onUpdate: (lineId: string, newRawText: string, newFuriganaItems?: FuriganaItem[]) => void;
onDelete: (lineId: string) => void;
onActivate: (lineId: string) => void;
@@ -41,6 +42,7 @@ export const LineItem: React.FC<LineItemProps> = ({
fontSize,
lineSpacing,
readOnly = false,
canEdit,
onUpdate,
onDelete,
onActivate,
@@ -58,9 +60,25 @@ export const LineItem: React.FC<LineItemProps> = ({
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' }>) => {
@@ -121,23 +139,152 @@ export const LineItem: React.FC<LineItemProps> = ({
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 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)) {
const isInsideFurigana = isNodeInsideFurigana(selection.anchorNode) || isNodeInsideFurigana(selection.focusNode);
if (!isInsideFurigana && (isInside || mainTextLower.includes(lowerSelected))) {
onTextSelect(selectedText, line.id);
}
}
@@ -193,6 +340,12 @@ export const LineItem: React.FC<LineItemProps> = ({
<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!);
@@ -220,23 +373,23 @@ export const LineItem: React.FC<LineItemProps> = ({
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]`}
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 */}
<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 && (
{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>
</div>
)}
{/* 2. Interactive sheet writing lines (Direct live editing) */}
<div className="flex-1 py-1 flex flex-col justify-center">
<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 px-1 -mx-1 pr-1 font-mono">
<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}
@@ -247,7 +400,6 @@ export const LineItem: React.FC<LineItemProps> = ({
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')}
/>
@@ -286,40 +438,50 @@ export const LineItem: React.FC<LineItemProps> = ({
<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 (onDoubleClick) {
if (canEdit && 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"
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
editable={canEdit}
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)}
onItemSelect={canEdit ? (itemId) => onFuriganaSelect(line.id, itemId) : undefined}
/>
{/* Main Kanji text line with word highlighter/comment select matches */}
<div
className="text-[#2D2926] m-0 p-0"
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',
height: `${fontSize * 1.2}px`,
minHeight: `${fontSize * 1.2}px`,
lineHeight: '1.2',
touchAction: canEdit ? 'auto' : 'pan-y',
}}
>
{line.rawText ? (
+186 -167
View File
@@ -5,14 +5,14 @@
import React, { useState } from 'react';
import { SidebarNote, WritingLine } from '../types';
import { BookOpen, Tag, Trash2, Search, FileText, ChevronRight, HelpCircle, X, Check } from 'lucide-react';
import { BookOpen, Tag, Trash2, 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;
savedDocumentNotes: SavedDocumentNote[];
onFocusNote: (noteId: string | null) => void;
onAddNote: (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => void;
onDeleteNote: (lineId: string, noteId: string) => void;
@@ -23,6 +23,18 @@ interface SidebarProps {
onJumpToLine: (lineId: string) => void;
}
export interface SavedDocumentNote {
id: string;
text: string;
comment: string;
color: string;
lineId: string;
lineNumber: number;
lineText: string;
documentTitle: string;
fileName: string;
}
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' },
@@ -33,10 +45,10 @@ const HIGHLIGHT_COLORS = [
export const Sidebar: React.FC<SidebarProps> = ({
lines,
activeLineId,
currentSelection,
selectedFurigana,
focusedNoteId,
savedDocumentNotes,
onFocusNote,
onAddNote,
onDeleteNote,
@@ -48,8 +60,6 @@ export const Sidebar: React.FC<SidebarProps> = ({
}) => {
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 = !!(
@@ -81,6 +91,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
const selectedFuriganaMaxX = selectedFuriganaLine
? Math.max(4, [...selectedFuriganaLine.rawText].length + 4, (selectedFuriganaItem?.x || 0) + 2)
: 4;
const isFuriganaMode = !!(selectedFurigana && selectedFuriganaLine && selectedFuriganaItem);
// Automatically update the comment input field when the selected text/note changes
React.useEffect(() => {
@@ -127,41 +138,57 @@ export const Sidebar: React.FC<SidebarProps> = ({
}
};
// Compile all notes across the writing pad
// Compile all notes across the document
const allNotesWithLineContext = lines.flatMap((line, idx) => {
return line.notes.map((note) => ({
...note,
lineId: line.id,
lineNumber: idx + 1,
lineText: line.rawText,
}));
});
// Filter notes based on search query, current line focus, and focusedNoteId
const filteredNotes = allNotesWithLineContext.filter((note) => {
if (focusedNoteId) {
return note.id === focusedNoteId;
}
const selectedTextForMatch = currentSelection?.text.trim() || '';
const exactMatchedNotes = selectedTextForMatch
? [
...allNotesWithLineContext
.filter((note) => note.lineId !== currentSelection?.lineId)
.map((note) => ({
...note,
documentTitle: '当前歌词',
fileName: '',
source: 'current' as const,
})),
...savedDocumentNotes.map((note) => ({
...note,
source: 'saved' as const,
})),
].filter((note) => note.text.trim() === selectedTextForMatch)
: [];
const matchesSearch =
note.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.comment.toLowerCase().includes(searchQuery.toLowerCase());
if (filterMode === 'active') {
return matchesSearch && note.lineId === activeLineId;
}
return matchesSearch;
});
const filteredNotes = focusedNoteId
? allNotesWithLineContext.filter((note) => note.id === focusedNoteId)
: allNotesWithLineContext;
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>
{isFuriganaMode ? (
<>
<Tag className="w-4 h-4 text-[#8B7E74]" />
<span></span>
</>
) : (
<>
<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>
@@ -356,154 +383,146 @@ export const Sidebar: React.FC<SidebarProps> = ({
)}
</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}
{!isFuriganaMode && (
<>
{exactMatchedNotes.length > 0 && (
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE]">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 shadow-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm text-amber-900 font-bold font-sans tracking-wide">
</span>
<span className="text-xs text-amber-700 font-mono font-semibold">
{exactMatchedNotes.length}
</span>
</div>
{/* Jump-to Line indicator */}
<button
onClick={(e) => {
e.stopPropagation();
onJumpToLine(note.lineId);
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
{exactMatchedNotes.map((note) => (
<div
key={`${note.lineId}-${note.id}`}
className="rounded border border-amber-200 bg-white p-2.5 shadow-xs"
>
<div className="mb-1.5 flex items-center justify-between gap-2">
{note.source === 'current' ? (
<button
type="button"
onClick={() => onJumpToLine(note.lineId)}
className="text-xs text-amber-800 hover:text-[#2D2926] font-mono font-bold cursor-pointer"
>
· {note.lineNumber.toString().padStart(2, '0')}
</button>
) : (
<span className="text-xs text-amber-800 font-mono font-bold">
{note.documentTitle} · {note.lineNumber.toString().padStart(2, '0')}
</span>
)}
</div>
<div className="text-sm text-amber-700 font-serif truncate" title={note.lineText}>
{note.lineText}
</div>
<div className="mt-1 text-base leading-relaxed text-[#2D2926] break-words">
{note.comment}
</div>
</div>
))}
</div>
</div>
</div>
)}
{!focusedNoteId && (
/* 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="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="点击跳转并定位此行"
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.lineNumber.toString().padStart(2, '0')}
<ChevronRight className="w-2.5 h-2.5" />
</button>
</div>
{/* 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>
{/* Comment contents */}
<div className="text-[#2D2926] text-xs leading-relaxed break-words pr-2 font-sans select-text">
{note.comment}
</div>
{/* 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>
{/* 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>
{/* 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>
</div>
)}
</div>
)}
</>
)}
</div>
);
};
+35 -37
View File
@@ -17,10 +17,10 @@ interface ToolbarProps {
onViewSavedDocuments: () => void;
onSaveDocument: () => void;
onToggleBulkImport: () => void;
fileStatus: string;
showImportArea: boolean;
showSavedArea: boolean;
savedDocumentCount: number;
canEditDocument: boolean;
}
export const Toolbar: React.FC<ToolbarProps> = ({
@@ -28,33 +28,27 @@ export const Toolbar: React.FC<ToolbarProps> = ({
onViewSavedDocuments,
onSaveDocument,
onToggleBulkImport,
fileStatus,
showImportArea,
showSavedArea,
savedDocumentCount,
canEditDocument,
}) => {
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">
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] px-3 py-2 sm:px-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 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 className="flex items-center gap-2 min-w-0">
<div className="bg-[#8B7E74] text-white rounded-lg p-2 shadow-sm">
<BookOpen className="w-4 h-4" />
</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 className="font-serif font-bold text-[#2D2926] text-base sm:text-lg leading-none tracking-widest flex items-center gap-2">
</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">
<div className="flex flex-wrap items-center gap-1.5 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"
@@ -64,27 +58,31 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<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>
{canEditDocument && (
<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>
{canEditDocument && (
<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}
@@ -93,10 +91,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
}`}
title="查看项目 saves 目录里的保存文件"
title="打开已保存歌词"
>
<FolderOpen className={`w-3.5 h-3.5 ${showSavedArea ? 'text-white' : 'text-[#8B7E74]'}`} />
<span></span>
<span></span>
{savedDocumentCount > 0 && (
<span className={`font-mono text-[10px] ${showSavedArea ? 'text-white/80' : 'text-[#A69F92]'}`}>
{savedDocumentCount}
+167
View File
@@ -0,0 +1,167 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import { BookOpen, ChevronDown, Layers3 } from 'lucide-react';
import { apiUrl } from '../api';
interface VocabEntry {
comment: string;
color: string;
lineText: string;
lineNumber: number;
documentTitle: string;
fileName: string;
documentId: number;
createdAt: number;
}
interface VocabCard {
text: string;
normalizedText: string;
noteCount: number;
documentTitles: string[];
latestComment: string;
latestColor: string;
entries: VocabEntry[];
}
const colorClassFor = (color: string) => {
if (color === 'rose') return 'bg-rose-100 text-rose-950 border-rose-200';
if (color === 'sky') return 'bg-sky-100 text-sky-950 border-sky-200';
if (color === 'emerald') return 'bg-emerald-100 text-emerald-950 border-emerald-200';
if (color === 'violet') return 'bg-purple-100 text-purple-950 border-purple-200';
return 'bg-[#E8E4DE] text-[#2D2926] border-[#8B7E74]/30';
};
export const VocabCards: React.FC = () => {
const [cards, setCards] = useState<VocabCard[]>([]);
const [expandedTerm, setExpandedTerm] = useState<string | null>(null);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
useEffect(() => {
let cancelled = false;
const loadCards = async () => {
try {
setStatus('loading');
const response = await fetch(apiUrl('/api/vocab'));
if (!response.ok) throw new Error('Failed to fetch vocab cards');
const data = await response.json();
if (!cancelled) {
setCards(Array.isArray(data.cards) ? data.cards : []);
setStatus('ready');
}
} catch (error) {
console.error('Error loading vocab cards', error);
if (!cancelled) setStatus('error');
}
};
loadCards();
return () => {
cancelled = true;
};
}, []);
return (
<div className="flex-1 overflow-y-auto bg-[#FAF9F6] px-2 py-3 lg:px-6 lg:pb-6">
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] px-4 py-3 shadow-sm">
<div>
<div className="flex items-center gap-2 font-serif text-lg font-bold text-[#2D2926]">
<Layers3 className="h-5 w-5 text-[#8B7E74]" />
</div>
<div className="mt-1 text-xs text-[#A69F92]">
</div>
</div>
<div className="rounded-full bg-[#8B7E74]/10 px-3 py-1 text-xs font-semibold text-[#8B7E74]">
{cards.length}
</div>
</div>
{status === 'loading' && (
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
...
</div>
)}
{status === 'error' && (
<div className="rounded-xl border border-rose-200 bg-rose-50 py-10 text-center text-sm text-rose-700">
</div>
)}
{status === 'ready' && cards.length === 0 && (
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
</div>
)}
<div className="space-y-2">
{cards.map((card) => {
const isExpanded = expandedTerm === card.normalizedText;
return (
<article
key={card.normalizedText}
className="rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] p-3 shadow-sm"
>
<button
type="button"
onClick={() => setExpandedTerm(isExpanded ? null : card.normalizedText)}
className="flex w-full items-start justify-between gap-3 text-left"
>
<div className="min-w-0">
<div className="font-serif text-xl font-bold text-[#2D2926]">
{card.text}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[#A69F92]">
<span>{card.noteCount} </span>
<span>·</span>
<span className="truncate">{card.documentTitles.join(' / ')}</span>
</div>
</div>
<ChevronDown
className={`mt-1 h-4 w-4 shrink-0 text-[#8B7E74] transition ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
<div className={`mt-2 rounded-lg border p-2 text-sm leading-relaxed ${colorClassFor(card.latestColor)}`}>
{card.latestComment || '无释义内容'}
</div>
{isExpanded && (
<div className="mt-3 space-y-2 border-t border-[#E8E4DE]/60 pt-3">
{card.entries.map((entry, index) => (
<div
key={`${entry.fileName}-${entry.lineNumber}-${index}`}
className="rounded-lg border border-[#E8E4DE]/70 bg-white p-2.5"
>
<div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-[#8B7E74]">
<BookOpen className="h-3.5 w-3.5" />
{entry.documentTitle} · {entry.lineNumber}
</div>
<div className="text-xs leading-relaxed text-[#A69F92]">
{entry.lineText}
</div>
<div className={`mt-2 rounded border px-2 py-1.5 text-sm ${colorClassFor(entry.color)}`}>
{entry.comment}
</div>
</div>
))}
</div>
)}
</article>
);
})}
</div>
</div>
</div>
);
};