更新内容
This commit is contained in:
+407
-137
@@ -7,9 +7,12 @@ import React, { useState, useEffect } from 'react';
|
||||
import { FuriganaItem, WritingLine, SidebarNote } from './types';
|
||||
import { Toolbar } from './components/Toolbar';
|
||||
import { LineItem } from './components/LineItem';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { Sidebar, SavedDocumentNote } from './components/Sidebar';
|
||||
import { AppNav, AppView } from './components/AppNav';
|
||||
import { VocabCards } from './components/VocabCards';
|
||||
import { adjustFuriganaItemsForTextChange, normalizeLine, normalizeLines } from './utils';
|
||||
import { BookOpen, Plus, X } from 'lucide-react';
|
||||
import { apiUrl } from './api';
|
||||
import { Check, Copy, Plus, X } from 'lucide-react';
|
||||
|
||||
// Default Demo Content Definitions
|
||||
const DEMOS = {
|
||||
@@ -91,8 +94,16 @@ const STORAGE_KEY = 'jp_writing_pad_document_v5';
|
||||
const LEGACY_LINES_KEY = 'jp_writing_pad_lines_v4';
|
||||
const CONFIG_KEY = 'jp_writing_pad_config_v4';
|
||||
const DEFAULT_DOCUMENT_TITLE = '未命名文档';
|
||||
const MOBILE_HIGHLIGHT_COLORS = [
|
||||
{ value: 'yellow', label: '岩砂褐', bg: 'bg-[#E8E4DE]', check: 'text-[#2D2926]' },
|
||||
{ value: 'rose', label: '绯樱红', bg: 'bg-rose-100', check: 'text-rose-700' },
|
||||
{ value: 'sky', label: '琉璃蓝', bg: 'bg-sky-100', check: 'text-sky-700' },
|
||||
{ value: 'emerald', label: '薄荷绿', bg: 'bg-emerald-100', check: 'text-emerald-700' },
|
||||
{ value: 'violet', label: '藤萝紫', bg: 'bg-purple-100', check: 'text-purple-700' },
|
||||
];
|
||||
|
||||
interface ServerSaveFile {
|
||||
id?: number;
|
||||
name: string;
|
||||
title: string;
|
||||
savedAt: string;
|
||||
@@ -115,6 +126,57 @@ const removeEmptyFuriganaItems = (line: WritingLine): WritingLine => ({
|
||||
furiganaItems: line.furiganaItems.filter((item) => item.text.trim().length > 0),
|
||||
});
|
||||
|
||||
const applyNoteToLines = (
|
||||
sourceLines: WritingLine[],
|
||||
lineId: string,
|
||||
noteText: string,
|
||||
comment: string,
|
||||
color: string,
|
||||
noteId?: string
|
||||
) =>
|
||||
sourceLines.map((line) => {
|
||||
if (line.id !== lineId) return line;
|
||||
|
||||
if (noteId) {
|
||||
return {
|
||||
...line,
|
||||
notes: line.notes.map((note) =>
|
||||
note.id === noteId ? { ...note, text: noteText, comment, color } : note
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const existingIndex = line.notes.findIndex((note) => note.text === noteText);
|
||||
if (existingIndex !== -1) {
|
||||
const updatedNotes = [...line.notes];
|
||||
updatedNotes[existingIndex] = { ...updatedNotes[existingIndex], comment, color };
|
||||
return {
|
||||
...line,
|
||||
notes: updatedNotes,
|
||||
};
|
||||
}
|
||||
|
||||
const newNote: SidebarNote = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
|
||||
text: noteText,
|
||||
comment,
|
||||
color,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
return {
|
||||
...line,
|
||||
notes: [...line.notes, newNote],
|
||||
};
|
||||
});
|
||||
|
||||
const getIsMobileViewport = () => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
const isMobileUserAgent = /Android|iPhone|iPad|iPod|Mobile/i.test(window.navigator.userAgent);
|
||||
const isSmallTouchViewport = window.matchMedia('(max-width: 767px) and (pointer: coarse)').matches;
|
||||
return isMobileUserAgent || isSmallTouchViewport;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [lines, setLines] = useState<WritingLine[]>([]);
|
||||
const paperStyle = 'blank';
|
||||
@@ -122,6 +184,9 @@ export default function App() {
|
||||
const [fontSize, setFontSize] = useState<number>(20);
|
||||
const lineSpacing = 4;
|
||||
const showLineNumbers = false;
|
||||
const [isMobileViewport, setIsMobileViewport] = useState<boolean>(getIsMobileViewport);
|
||||
const canEditDocument = !isMobileViewport;
|
||||
const [activeView, setActiveView] = useState<AppView>('lyrics');
|
||||
|
||||
// Focus and Selection States
|
||||
const [activeLineId, setActiveLineId] = useState<string | null>(null);
|
||||
@@ -142,8 +207,12 @@ export default function App() {
|
||||
const [boundFileName, setBoundFileName] = useState<string | null>(null);
|
||||
const [fileStatus, setFileStatus] = useState<string>('未选择项目保存文件');
|
||||
const [serverSaveFiles, setServerSaveFiles] = useState<ServerSaveFile[]>([]);
|
||||
const [savedDocumentNotes, setSavedDocumentNotes] = useState<SavedDocumentNote[]>([]);
|
||||
const [saveNotice, setSaveNotice] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [mobileDrawerInteractive, setMobileDrawerInteractive] = useState<boolean>(false);
|
||||
const [mobileSelectedColor, setMobileSelectedColor] = useState<string>('yellow');
|
||||
const saveNoticeTimerRef = React.useRef<number | null>(null);
|
||||
const mobileDrawerTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
// INITIAL LOAD
|
||||
useEffect(() => {
|
||||
@@ -211,14 +280,58 @@ export default function App() {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
}, [fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 767px) and (pointer: coarse)');
|
||||
const syncViewportMode = () => setIsMobileViewport(mediaQuery.matches);
|
||||
|
||||
syncViewportMode();
|
||||
mediaQuery.addEventListener('change', syncViewportMode);
|
||||
window.addEventListener('resize', syncViewportMode);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', syncViewportMode);
|
||||
window.removeEventListener('resize', syncViewportMode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditDocument) {
|
||||
setEditingLineId(null);
|
||||
setActiveLineId(null);
|
||||
setSelectedFurigana(null);
|
||||
setShowImportArea(false);
|
||||
}
|
||||
}, [canEditDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveNoticeTimerRef.current) {
|
||||
window.clearTimeout(saveNoticeTimerRef.current);
|
||||
}
|
||||
if (mobileDrawerTimerRef.current) {
|
||||
window.clearTimeout(mobileDrawerTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileDrawerTimerRef.current) {
|
||||
window.clearTimeout(mobileDrawerTimerRef.current);
|
||||
mobileDrawerTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!currentSelection) {
|
||||
setMobileDrawerInteractive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileDrawerInteractive(false);
|
||||
mobileDrawerTimerRef.current = window.setTimeout(() => {
|
||||
setMobileDrawerInteractive(true);
|
||||
mobileDrawerTimerRef.current = null;
|
||||
}, 350);
|
||||
}, [currentSelection?.text, currentSelection?.lineId, focusedNoteId]);
|
||||
|
||||
// DEACTIVATE EDIT MODE AND UNFOCUS NOTES ON CLICK-AWAY
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
@@ -282,6 +395,8 @@ export default function App() {
|
||||
newRawText: string,
|
||||
newFuriganaItems?: FuriganaItem[]
|
||||
) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id === lineId) {
|
||||
@@ -323,6 +438,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleInsertBelow = (referenceLineId: string) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
const newId = `line-${Date.now()}`;
|
||||
setLines((prev) => {
|
||||
const idx = prev.findIndex((l) => l.id === referenceLineId);
|
||||
@@ -345,6 +462,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleInsertAbove = (referenceLineId: string) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
const newId = `line-${Date.now()}`;
|
||||
setLines((prev) => {
|
||||
const idx = prev.findIndex((l) => l.id === referenceLineId);
|
||||
@@ -367,6 +486,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleAddLineAtEnd = () => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
const newId = `line-${Date.now()}`;
|
||||
const newLine = createEmptyLine(newId);
|
||||
setLines((prev) => [...prev, newLine]);
|
||||
@@ -381,6 +502,8 @@ export default function App() {
|
||||
|
||||
// SEAMLESS KEYBOARD NAVIGATION BETWEEN FIELDS
|
||||
const moveFocusUp = (currentLineId: string, focusField: 'main' | 'furigana') => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
const idx = lines.findIndex((l) => l.id === currentLineId);
|
||||
if (idx > 0) {
|
||||
const prevLine = lines[idx - 1];
|
||||
@@ -397,6 +520,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const moveFocusDown = (currentLineId: string, focusField: 'main' | 'furigana') => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
const idx = lines.findIndex((l) => l.id === currentLineId);
|
||||
if (idx !== -1 && idx < lines.length - 1) {
|
||||
const nextLine = lines[idx + 1];
|
||||
@@ -456,44 +581,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleAddNote = (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id === lineId) {
|
||||
if (noteId) {
|
||||
return {
|
||||
...l,
|
||||
notes: l.notes.map((n) =>
|
||||
n.id === noteId ? { ...n, text: noteText, comment, color } : n
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, if there is already a note with the exact same text, update it
|
||||
const idx = l.notes.findIndex((n) => n.text === noteText);
|
||||
if (idx !== -1) {
|
||||
const updated = [...l.notes];
|
||||
updated[idx] = { ...updated[idx], comment, color };
|
||||
return {
|
||||
...l,
|
||||
notes: updated,
|
||||
};
|
||||
}
|
||||
|
||||
const newNote: SidebarNote = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
|
||||
text: noteText,
|
||||
comment,
|
||||
color,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...l,
|
||||
notes: [...l.notes, newNote],
|
||||
};
|
||||
}
|
||||
return l;
|
||||
})
|
||||
);
|
||||
setLines((prev) => applyNoteToLines(prev, lineId, noteText, comment, color, noteId));
|
||||
};
|
||||
|
||||
const handleDeleteNote = (lineId: string, noteId: string) => {
|
||||
@@ -511,6 +599,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleFuriganaSelect = (lineId: string, itemId: string) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
lastSelectionTimeRef.current = Date.now();
|
||||
setFocusedNoteId(null);
|
||||
setCurrentSelection(null);
|
||||
@@ -519,6 +609,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteFurigana = (lineId: string, itemId: string) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
setLines((prev) =>
|
||||
prev.map((line) => {
|
||||
if (line.id !== lineId) return line;
|
||||
@@ -533,6 +625,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleUpdateFurigana = (lineId: string, itemId: string, text: string) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
setLines((prev) =>
|
||||
prev.map((line) => {
|
||||
if (line.id !== lineId) return line;
|
||||
@@ -548,6 +642,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleUpdateFuriganaX = (lineId: string, itemId: string, x: number) => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
setLines((prev) =>
|
||||
prev.map((line) => {
|
||||
if (line.id !== lineId) return line;
|
||||
@@ -571,6 +667,8 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleNewDocument = () => {
|
||||
if (!canEditDocument) return;
|
||||
|
||||
if (!window.confirm('新建文档会清空当前纸面内容。请确认当前内容已经保存。')) return;
|
||||
|
||||
const firstLine = createEmptyLine();
|
||||
@@ -616,6 +714,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleBulkImport = () => {
|
||||
if (!canEditDocument) return;
|
||||
if (!importText.trim()) return;
|
||||
|
||||
const importedLines = importText
|
||||
@@ -637,13 +736,13 @@ export default function App() {
|
||||
resetDocumentFocus();
|
||||
};
|
||||
|
||||
const createDocumentBackup = () => {
|
||||
const createDocumentBackup = (linesOverride = lines) => {
|
||||
return {
|
||||
version: 'japanese-writing-pad-v5',
|
||||
title: documentTitle.trim() || DEFAULT_DOCUMENT_TITLE,
|
||||
exportedAt: new Date().toISOString(),
|
||||
config: { fontSize },
|
||||
lines: lines.map(removeEmptyFuriganaItems),
|
||||
lines: linesOverride.map(removeEmptyFuriganaItems),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -672,13 +771,54 @@ export default function App() {
|
||||
|
||||
const refreshServerSaveFiles = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/saves');
|
||||
const response = await fetch(apiUrl('/api/documents'));
|
||||
if (!response.ok) throw new Error('Failed to list server saves');
|
||||
const data = await response.json();
|
||||
setServerSaveFiles(Array.isArray(data.files) ? data.files : []);
|
||||
const files: ServerSaveFile[] = Array.isArray(data.files) ? data.files : [];
|
||||
setServerSaveFiles(files);
|
||||
|
||||
const savedNotes = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const documentResponse = await fetch(
|
||||
file.id
|
||||
? apiUrl(`/api/documents/${encodeURIComponent(String(file.id))}`)
|
||||
: apiUrl(`/api/saves/${encodeURIComponent(file.name)}`)
|
||||
);
|
||||
if (!documentResponse.ok) return [];
|
||||
|
||||
const documentData = await documentResponse.json();
|
||||
const documentLines = normalizeLines(documentData?.lines, `saved-index-${file.name}`);
|
||||
const title =
|
||||
typeof documentData?.title === 'string' && documentData.title.trim()
|
||||
? documentData.title
|
||||
: file.title || file.name.replace(/\.json$/i, '');
|
||||
|
||||
return documentLines.flatMap((line, lineIndex) =>
|
||||
line.notes.map((note): SavedDocumentNote => ({
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
comment: note.comment,
|
||||
color: note.color,
|
||||
lineId: line.id,
|
||||
lineNumber: lineIndex + 1,
|
||||
lineText: line.rawText,
|
||||
documentTitle: title,
|
||||
fileName: file.name,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error indexing saved document notes: ${file.name}`, error);
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setSavedDocumentNotes(savedNotes.flat());
|
||||
} catch (e) {
|
||||
console.error('Error refreshing server saves', e);
|
||||
setFileStatus('无法读取项目 saves 目录');
|
||||
setSavedDocumentNotes([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -696,14 +836,14 @@ export default function App() {
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
const writeDocumentToServerFile = async () => {
|
||||
const writeDocumentToServerFile = async (linesOverride = lines) => {
|
||||
const name = getCurrentDocumentSaveName();
|
||||
const response = await fetch('/api/saves', {
|
||||
const response = await fetch(apiUrl('/api/documents'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
document: createDocumentBackup(),
|
||||
document: createDocumentBackup(linesOverride),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -718,6 +858,25 @@ export default function App() {
|
||||
await refreshServerSaveFiles();
|
||||
};
|
||||
|
||||
const getLinesWithPendingMobileNote = () => {
|
||||
if (!currentSelection) return lines;
|
||||
|
||||
const textarea = document.getElementById(
|
||||
mobileSelectedNote ? 'comment-textarea-mobile-existing' : 'comment-textarea-mobile'
|
||||
) as HTMLTextAreaElement | null;
|
||||
const comment = textarea?.value?.trim();
|
||||
if (!comment) return lines;
|
||||
|
||||
return applyNoteToLines(
|
||||
lines,
|
||||
mobileSelectedNote?.lineId || currentSelection.lineId,
|
||||
mobileSelectedNote?.text || currentSelection.text,
|
||||
comment,
|
||||
mobileSelectedColor,
|
||||
mobileSelectedNote?.id
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveBoundLocalFile = async () => {
|
||||
if (!documentTitle.trim()) {
|
||||
showTransientSaveNotice('保存失败', 'error');
|
||||
@@ -725,7 +884,11 @@ export default function App() {
|
||||
}
|
||||
|
||||
try {
|
||||
await writeDocumentToServerFile();
|
||||
const nextLines = getLinesWithPendingMobileNote();
|
||||
if (nextLines !== lines) {
|
||||
setLines(nextLines);
|
||||
}
|
||||
await writeDocumentToServerFile(nextLines);
|
||||
showTransientSaveNotice('已保存', 'success');
|
||||
} catch (e) {
|
||||
console.error('Error saving project file', e);
|
||||
@@ -738,11 +901,15 @@ export default function App() {
|
||||
setShowSavedArea((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleLoadServerSave = async (fileName: string) => {
|
||||
const handleLoadServerSave = async (file: ServerSaveFile) => {
|
||||
try {
|
||||
if (!window.confirm(`加载 saves/${fileName}?当前未保存内容会被覆盖。`)) return;
|
||||
if (!window.confirm(`打开「${file.title}」?当前未保存内容会被覆盖。`)) return;
|
||||
|
||||
const response = await fetch(`/api/saves/${encodeURIComponent(fileName)}`);
|
||||
const response = await fetch(
|
||||
file.id
|
||||
? apiUrl(`/api/documents/${encodeURIComponent(String(file.id))}`)
|
||||
: apiUrl(`/api/saves/${encodeURIComponent(file.name)}`)
|
||||
);
|
||||
if (!response.ok) {
|
||||
alert('读取项目保存文件失败。');
|
||||
return;
|
||||
@@ -754,13 +921,13 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setBoundFileName(fileName);
|
||||
setBoundFileName(file.name);
|
||||
setDocumentTitle(
|
||||
typeof data.title === 'string' && data.title.trim()
|
||||
? data.title
|
||||
: fileName.replace(/\.json$/i, '')
|
||||
: file.name.replace(/\.json$/i, '')
|
||||
);
|
||||
setFileStatus(`已打开:${typeof data.title === 'string' && data.title.trim() ? data.title : fileName}`);
|
||||
setFileStatus(`已打开:${typeof data.title === 'string' && data.title.trim() ? data.title : file.name}`);
|
||||
setShowSavedArea(false);
|
||||
} catch (e) {
|
||||
console.error('Error loading project save', e);
|
||||
@@ -772,7 +939,7 @@ export default function App() {
|
||||
if (!window.confirm(`删除项目保存文件 saves/${fileName}?该操作不可撤销。`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/saves/${encodeURIComponent(fileName)}`, {
|
||||
const response = await fetch(apiUrl(`/api/saves/${encodeURIComponent(fileName)}`), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete project save');
|
||||
@@ -794,6 +961,20 @@ export default function App() {
|
||||
backgroundSize: 'none',
|
||||
};
|
||||
|
||||
const mobileSelectedNote = currentSelection
|
||||
? lines
|
||||
.flatMap((line) => line.notes.map((note) => ({ ...note, lineId: line.id })))
|
||||
.find((note) =>
|
||||
focusedNoteId
|
||||
? note.id === focusedNoteId
|
||||
: note.lineId === currentSelection.lineId && note.text === currentSelection.text
|
||||
)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setMobileSelectedColor(mobileSelectedNote?.color || 'yellow');
|
||||
}, [currentSelection?.text, currentSelection?.lineId, focusedNoteId, mobileSelectedNote?.id, mobileSelectedNote?.color]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-[#FAF9F6] text-[#2D2926] overflow-hidden font-sans">
|
||||
{saveNotice && (
|
||||
@@ -816,76 +997,50 @@ export default function App() {
|
||||
onViewSavedDocuments={handleOpenLocalFile}
|
||||
onSaveDocument={handleSaveBoundLocalFile}
|
||||
onToggleBulkImport={() => setShowImportArea((prev) => !prev)}
|
||||
fileStatus={fileStatus}
|
||||
showImportArea={showImportArea}
|
||||
showSavedArea={showSavedArea}
|
||||
savedDocumentCount={serverSaveFiles.length}
|
||||
canEditDocument={canEditDocument}
|
||||
/>
|
||||
|
||||
{showSavedArea && (
|
||||
<div className="fixed left-1/2 top-24 z-40 w-[calc(100vw-2rem)] max-w-3xl -translate-x-1/2 text-xs select-none">
|
||||
<div className="bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-4 shadow-xl animate-in fade-in duration-200">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#E8E4DE]/50 pb-3 mb-3">
|
||||
<div>
|
||||
<div className="font-bold text-[#2D2926]">已保存文档</div>
|
||||
<div className="mt-1 text-[11px] text-[#A69F92]">
|
||||
文档保存在项目根目录的 <span className="font-mono">saves/</span> 目录中。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col lg:flex-row">
|
||||
<AppNav activeView={activeView} onChange={setActiveView} />
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeView === 'lyrics' && showSavedArea && (
|
||||
<div className="fixed left-1/2 top-20 z-40 w-[calc(100vw-1rem)] max-w-2xl -translate-x-1/2 text-xs select-none">
|
||||
<div className="bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-2 shadow-xl animate-in fade-in duration-200">
|
||||
{serverSaveFiles.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[min(60vh,420px)] overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5 max-h-[min(62vh,420px)] overflow-y-auto">
|
||||
{serverSaveFiles.map((file) => (
|
||||
<div
|
||||
<button
|
||||
key={file.name}
|
||||
className="flex flex-wrap items-center justify-between gap-3 bg-white border border-[#E8E4DE]/70 rounded-lg px-3 py-2.5"
|
||||
type="button"
|
||||
onClick={() => handleLoadServerSave(file)}
|
||||
className="w-full text-left bg-white hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/30 border border-[#E8E4DE]/70 hover:border-[#8B7E74]/40 rounded-lg px-3 py-2.5 transition cursor-pointer"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[#2D2926] truncate">{file.title}</div>
|
||||
<div className="mt-1 text-[10px] text-[#A69F92] font-mono">
|
||||
{file.name} · {new Date(file.savedAt).toLocaleString()} · {file.lineCount ?? '?'} 行
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleLoadServerSave(file.name)}
|
||||
className="px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-semibold rounded-lg transition cursor-pointer"
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteServerSave(file.name)}
|
||||
className="px-3 py-1.5 bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 font-semibold rounded-lg transition cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE] rounded-lg">
|
||||
还没有已保存文档。填写标题后点击顶部“保存”即可创建。
|
||||
<div className="py-6 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE] rounded-lg">
|
||||
还没有保存的歌词
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔮 Outer Workspace Area */}
|
||||
{activeView === 'lyrics' ? (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* LEFT COMPARTMENT: The writing sheet ledger */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-4 md:px-4 flex flex-col items-center bg-[#FAF9F6]">
|
||||
{/* LEFT COMPARTMENT: The writing sheet */}
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1 md:px-4 md:py-4 flex flex-col items-center bg-[#FAF9F6]">
|
||||
<div
|
||||
className="w-full bg-[#FDFCFB] premium-shadow min-h-[85vh] h-auto flex-shrink-0 rounded-xl border border-[#E8E4DE] p-4 sm:p-6 md:p-8 relative flex flex-col animate-fadeIn overflow-x-auto"
|
||||
className="w-full bg-[#FDFCFB] premium-shadow min-h-[85vh] h-auto flex-shrink-0 rounded-xl border border-[#E8E4DE] px-1 py-2 sm:p-5 md:p-8 relative flex flex-col animate-fadeIn overflow-x-hidden md:overflow-x-auto"
|
||||
>
|
||||
{/* Elegant watermark indicators (Minimal traditional look) */}
|
||||
<div className="absolute top-4 right-6 text-[10px] text-[#A69F92]/70 pointer-events-none select-none font-mono flex items-center gap-1 z-10">
|
||||
<BookOpen className="w-2.5 h-2.5 text-[#8B7E74]" /> JAPANESE WRITING LEDGER · 原稿用紙
|
||||
</div>
|
||||
|
||||
<div className="mt-6 mb-4 flex justify-center">
|
||||
<div className="mt-1 mb-3 md:mt-2 md:mb-4 flex justify-center">
|
||||
<input
|
||||
value={documentTitle}
|
||||
onChange={(e) => setDocumentTitle(e.target.value)}
|
||||
@@ -894,7 +1049,10 @@ export default function App() {
|
||||
setEditingLineId(null);
|
||||
}}
|
||||
placeholder="请输入文档标题"
|
||||
className="w-full max-w-xl bg-transparent text-center font-serif text-2xl font-bold tracking-widest text-[#2D2926] placeholder-[#A69F92]/50 border-b border-[#E8E4DE] focus:border-[#8B7E74] focus:outline-none py-2"
|
||||
disabled={!canEditDocument}
|
||||
className={`w-full max-w-xl bg-transparent text-center font-serif text-xl sm:text-2xl font-bold tracking-widest text-[#2D2926] placeholder-[#A69F92]/50 border-b border-[#E8E4DE] focus:border-[#8B7E74] focus:outline-none py-1.5 sm:py-2 ${
|
||||
canEditDocument ? '' : 'cursor-default border-transparent'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -961,7 +1119,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{/* 📝 Integrated Book Page Layout (Double-click or click line to type/edit; select text or click highlights to view annotation) */}
|
||||
<div className="flex-1 flex flex-col pt-2 select-text" onClick={(e) => {
|
||||
<div className="flex-1 flex flex-col pt-1 md:pt-2 select-text" onClick={(e) => {
|
||||
// If clicked the background paper itself, deactivate active editing line
|
||||
if (e.target === e.currentTarget) {
|
||||
setActiveLineId(null);
|
||||
@@ -969,7 +1127,10 @@ export default function App() {
|
||||
setFocusedNoteId(null);
|
||||
}
|
||||
}}>
|
||||
<div className="flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn" style={paperBackgroundStyle}>
|
||||
<div
|
||||
className="mx-auto w-full max-w-none flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn rounded-xl border-x border-[#8B7E74]/10 bg-[#FDFCFB]/70 shadow-[inset_4px_0_10px_-10px_rgba(139,126,116,0.45),inset_-4px_0_10px_-10px_rgba(139,126,116,0.45)] md:border-x-0 md:bg-transparent md:shadow-none"
|
||||
style={paperBackgroundStyle}
|
||||
>
|
||||
{lines.map((line, idx) => (
|
||||
<LineItem
|
||||
key={line.id}
|
||||
@@ -977,13 +1138,15 @@ export default function App() {
|
||||
lineNumber={idx + 1}
|
||||
showLineNumber={showLineNumbers}
|
||||
isActive={activeLineId === line.id}
|
||||
readOnly={editingLineId !== line.id}
|
||||
readOnly={!canEditDocument || editingLineId !== line.id}
|
||||
canEdit={canEditDocument}
|
||||
fontStyle={fontStyle}
|
||||
fontSize={fontSize}
|
||||
lineSpacing={lineSpacing}
|
||||
onUpdate={handleUpdateLine}
|
||||
onDelete={handleDeleteLine}
|
||||
onActivate={(lineId) => {
|
||||
if (!canEditDocument) return;
|
||||
setActiveLineId(lineId);
|
||||
setFocusedNoteId(null);
|
||||
}}
|
||||
@@ -994,6 +1157,7 @@ export default function App() {
|
||||
setSelectedFurigana(null);
|
||||
}}
|
||||
onDoubleClick={(lineId) => {
|
||||
if (!canEditDocument) return;
|
||||
setActiveLineId(lineId);
|
||||
setEditingLineId(lineId);
|
||||
setFocusedNoteId(null);
|
||||
@@ -1033,10 +1197,10 @@ export default function App() {
|
||||
<div className="w-[300px] sm:w-[350px] md:w-[380px] h-full flex-shrink-0 hidden lg:block border-l border-[#E8E4DE]/60">
|
||||
<Sidebar
|
||||
lines={lines}
|
||||
activeLineId={activeLineId}
|
||||
currentSelection={currentSelection}
|
||||
selectedFurigana={selectedFurigana}
|
||||
focusedNoteId={focusedNoteId}
|
||||
savedDocumentNotes={savedDocumentNotes.filter((note) => note.fileName !== boundFileName)}
|
||||
onFocusNote={handleFocusNote}
|
||||
onAddNote={handleAddNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
@@ -1052,44 +1216,150 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VocabCards />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Floating Sticky Drawer for Mobile / Tablet screens which normally hides the Sidebar */}
|
||||
{currentSelection && (
|
||||
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] shadow-2xl z-50 flex flex-col p-4 overflow-y-auto animate-in slide-in-from-bottom">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-xs font-bold text-[#2D2926] bg-[#8B7E74]/10 px-2.5 py-1 rounded-md border border-[#8B7E74]/20">
|
||||
划词注释 (移动端视图)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentSelection(null)}
|
||||
className="text-[#A69F92] hover:text-[#2D2926] p-1 rounded-full hover:bg-[#FAF9F6] cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-[#A69F92] mb-2 font-sans">
|
||||
选中:“<span className="font-serif font-bold text-[#2D2926] text-sm">{currentSelection.text}</span>”
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={3}
|
||||
id="comment-textarea-mobile"
|
||||
placeholder="在此为选中字词记录您的日语随笔、语法释义..."
|
||||
className="w-full text-xs bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-3 text-[#2D2926]"
|
||||
/>
|
||||
{activeView === 'lyrics' && currentSelection && (
|
||||
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] shadow-2xl z-50 flex flex-col px-3 pt-2 pb-3 overflow-y-auto animate-in slide-in-from-bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById('comment-textarea-mobile') as HTMLTextAreaElement;
|
||||
const val = el?.value?.trim() || '';
|
||||
if (val) {
|
||||
handleAddNote(currentSelection.lineId, currentSelection.text, val, 'yellow');
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setCurrentSelection(null);
|
||||
setFocusedNoteId(null);
|
||||
}}
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
className="absolute right-2 top-1.5 text-[#A69F92] hover:text-[#2D2926] p-1 rounded-full hover:bg-[#FAF9F6] cursor-pointer"
|
||||
>
|
||||
快速保存
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="mb-1.5 pr-8 font-sans flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 text-xs text-[#A69F92]">
|
||||
选中:“<span className="font-serif font-bold text-[#2D2926] text-sm">{currentSelection.text}</span>”
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentSelection.text);
|
||||
showTransientSaveNotice('已复制', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error copying selected text', error);
|
||||
showTransientSaveNotice('复制失败', 'error');
|
||||
}
|
||||
}}
|
||||
className="shrink-0 flex items-center gap-1 rounded-md border border-[#E8E4DE] bg-white px-2 py-1 text-[11px] font-semibold text-[#8B7E74] hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/40"
|
||||
title="复制选中文本"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={mobileDrawerInteractive ? '' : 'pointer-events-none'}>
|
||||
{mobileSelectedNote ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-bold text-[#8B7E74]">已有注释,可直接修改</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#A69F92]">颜色</span>
|
||||
<div className="flex gap-1.5">
|
||||
{MOBILE_HIGHLIGHT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setMobileSelectedColor(color.value)}
|
||||
title={color.label}
|
||||
className={`w-6 h-6 rounded-full border-2 ${color.bg} flex items-center justify-center transition ${
|
||||
mobileSelectedColor === color.value
|
||||
? 'border-[#8B7E74] scale-110'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
{mobileSelectedColor === color.value && (
|
||||
<Check className={`w-3.5 h-3.5 ${color.check} stroke-[3]`} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
rows={4}
|
||||
id="comment-textarea-mobile-existing"
|
||||
defaultValue={mobileSelectedNote.comment}
|
||||
className="w-full text-sm bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] leading-relaxed"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById('comment-textarea-mobile-existing') as HTMLTextAreaElement;
|
||||
const val = el?.value?.trim() || '';
|
||||
if (val) {
|
||||
handleAddNote(
|
||||
mobileSelectedNote.lineId,
|
||||
mobileSelectedNote.text,
|
||||
val,
|
||||
mobileSelectedColor,
|
||||
mobileSelectedNote.id
|
||||
);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setCurrentSelection(null);
|
||||
setFocusedNoteId(null);
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
>
|
||||
保存修改
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#A69F92]">颜色</span>
|
||||
<div className="flex gap-1.5">
|
||||
{MOBILE_HIGHLIGHT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setMobileSelectedColor(color.value)}
|
||||
title={color.label}
|
||||
className={`w-6 h-6 rounded-full border-2 ${color.bg} flex items-center justify-center transition ${
|
||||
mobileSelectedColor === color.value
|
||||
? 'border-[#8B7E74] scale-110'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
{mobileSelectedColor === color.value && (
|
||||
<Check className={`w-3.5 h-3.5 ${color.check} stroke-[3]`} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
rows={3}
|
||||
id="comment-textarea-mobile"
|
||||
placeholder="在此为选中字词记录您的日语随笔、语法释义..."
|
||||
className="w-full text-xs bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-2 text-[#2D2926]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById('comment-textarea-mobile') as HTMLTextAreaElement;
|
||||
const val = el?.value?.trim() || '';
|
||||
if (val) {
|
||||
handleAddNote(currentSelection.lineId, currentSelection.text, val, mobileSelectedColor);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
>
|
||||
快速保存
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.replace(/\/+$/, '') || '';
|
||||
|
||||
export const apiUrl = (path: string) => {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&family=Noto+Serif+JP:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Noto Serif JP", Georgia, Cambria, serif;
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user