Initial local project snapshot.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
saves/*.json
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/d4d25382-dcc2-43cb-97a3-541137bcfafe
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Japanese Writing Pad",
|
||||||
|
"description": "An interactive, spacious Japanese writing pad with line-by-line furigana tagging and visual text-selection annotations.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||||
|
}
|
||||||
Generated
+4277
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist server.js",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^2.4.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"motion": "^12.23.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
"@types/express": "^4.17.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { FuriganaItem } from '../types';
|
||||||
|
|
||||||
|
export interface FuriganaLayerHandle {
|
||||||
|
focusFirstItem: () => void;
|
||||||
|
focusArea: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FuriganaLayerProps {
|
||||||
|
items: FuriganaItem[];
|
||||||
|
editable: boolean;
|
||||||
|
selectedItemId?: string | null;
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
onChange?: (items: FuriganaItem[]) => void;
|
||||||
|
onItemSelect?: (itemId: string) => void;
|
||||||
|
onTextSelect?: (selectedText: string) => void;
|
||||||
|
onFocusMain?: () => void;
|
||||||
|
onFocusPreviousLine?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FURIGANA_ROW_Y = 12;
|
||||||
|
|
||||||
|
export const FuriganaLayer = forwardRef<FuriganaLayerHandle, FuriganaLayerProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
editable,
|
||||||
|
selectedItemId,
|
||||||
|
fontSize,
|
||||||
|
fontFamily,
|
||||||
|
onChange,
|
||||||
|
onItemSelect,
|
||||||
|
onTextSelect,
|
||||||
|
onFocusMain,
|
||||||
|
onFocusPreviousLine,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
itemsRef.current = items;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focusFirstItem: () => {
|
||||||
|
const firstItem = itemsRef.current[0];
|
||||||
|
if (firstItem) {
|
||||||
|
inputRefs.current[firstItem.id]?.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
areaRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focusArea: () => {
|
||||||
|
areaRef.current?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getXPosition = (clientX: number) => {
|
||||||
|
const area = areaRef.current;
|
||||||
|
if (!area) return 0;
|
||||||
|
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
return Math.max(0, (clientX - rect.left) / fontSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitItems = (nextItems: FuriganaItem[]) => {
|
||||||
|
onChange?.(nextItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanEmptyItems = (itemsToClean = itemsRef.current) => {
|
||||||
|
const cleanedItems = itemsToClean.filter((item) => item.text.trim().length > 0);
|
||||||
|
if (cleanedItems.length !== itemsToClean.length) {
|
||||||
|
commitItems(cleanedItems);
|
||||||
|
itemsRef.current = cleanedItems;
|
||||||
|
}
|
||||||
|
return cleanedItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editable) return;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanEmptyItems();
|
||||||
|
};
|
||||||
|
}, [editable]);
|
||||||
|
|
||||||
|
const createItem = (clientX: number) => {
|
||||||
|
if (!editable) return;
|
||||||
|
|
||||||
|
const cleanedItems = cleanEmptyItems();
|
||||||
|
const x = getXPosition(clientX);
|
||||||
|
const newItem: FuriganaItem = {
|
||||||
|
id: `furi-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
text: '',
|
||||||
|
x,
|
||||||
|
y: FURIGANA_ROW_Y,
|
||||||
|
};
|
||||||
|
|
||||||
|
commitItems([...cleanedItems, newItem]);
|
||||||
|
onItemSelect?.(newItem.id);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inputRefs.current[newItem.id]?.focus({ preventScroll: true });
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItemText = (itemId: string, text: string) => {
|
||||||
|
commitItems(itemsRef.current.map((item) => (item.id === itemId ? { ...item, text } : item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (itemId: string) => {
|
||||||
|
commitItems(itemsRef.current.filter((item) => item.id !== itemId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItemIfEmpty = (itemId: string) => {
|
||||||
|
const item = itemsRef.current.find((candidate) => candidate.id === itemId);
|
||||||
|
if (item && item.text.trim() === '') {
|
||||||
|
removeItem(itemId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectExistingItem = (itemId: string) => {
|
||||||
|
const nextItems = itemsRef.current.filter((item) => item.id === itemId || item.text.trim().length > 0);
|
||||||
|
if (nextItems.length !== itemsRef.current.length) {
|
||||||
|
commitItems(nextItems);
|
||||||
|
itemsRef.current = nextItems;
|
||||||
|
}
|
||||||
|
onItemSelect?.(itemId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderedItems = items.map((item) => ({ ...item, y: FURIGANA_ROW_Y }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={areaRef}
|
||||||
|
tabIndex={editable ? 0 : -1}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (editable && e.target === e.currentTarget) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
createItem(e.clientX);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (editable) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (editable) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!editable) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocusMain?.();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocusPreviousLine?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={editable ? '点击空白处添加假名;点击已有假名可查看和修改' : undefined}
|
||||||
|
data-furigana-layer="true"
|
||||||
|
className={`relative w-full outline-none ${editable ? 'cursor-text' : 'select-none'}`}
|
||||||
|
style={{
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontFamily,
|
||||||
|
height: `${fontSize * 1.3}px`,
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderedItems.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className="absolute inline-flex items-center"
|
||||||
|
style={{
|
||||||
|
left: `${item.x}em`,
|
||||||
|
top: `${item.y}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editable ? (
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
inputRefs.current[item.id] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
value={item.text}
|
||||||
|
onFocus={() => selectExistingItem(item.id)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectExistingItem(item.id);
|
||||||
|
}}
|
||||||
|
onChange={(e) => updateItemText(item.id, e.target.value)}
|
||||||
|
onBlur={() => removeItemIfEmpty(item.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocusMain?.();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocusMain?.();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocusPreviousLine?.();
|
||||||
|
} else if (e.key === 'Backspace' && item.text === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
removeItem(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelect={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const start = target.selectionStart;
|
||||||
|
const end = target.selectionEnd;
|
||||||
|
if (start !== null && end !== null && start !== end) {
|
||||||
|
const selectedText = target.value.substring(start, end).trim();
|
||||||
|
if (selectedText) onTextSelect?.(selectedText);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-transparent border-none outline-none focus:outline-none focus:ring-0 m-0 p-0 text-[#8B7E74] tracking-wide font-mono"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max([...item.text].length, 1)}em`,
|
||||||
|
fontSize: '0.6em',
|
||||||
|
fontFamily,
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-block text-[#8B7E74] tracking-wide"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6em',
|
||||||
|
fontFamily,
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FuriganaLayer.displayName = 'FuriganaLayer';
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { FuriganaItem, WritingLine, SidebarNote } from '../types';
|
||||||
|
import { Trash2, Plus, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
import { FuriganaLayer, FuriganaLayerHandle } from './FuriganaLayer';
|
||||||
|
|
||||||
|
interface LineItemProps {
|
||||||
|
line: WritingLine;
|
||||||
|
lineNumber: number;
|
||||||
|
showLineNumber: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
fontStyle: 'sans' | 'serif';
|
||||||
|
fontSize: number;
|
||||||
|
lineSpacing: number;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onUpdate: (lineId: string, newRawText: string, newFuriganaItems?: FuriganaItem[]) => void;
|
||||||
|
onDelete: (lineId: string) => void;
|
||||||
|
onActivate: (lineId: string) => void;
|
||||||
|
onDeactivate: () => void;
|
||||||
|
onDoubleClick?: (lineId: string) => void;
|
||||||
|
onInsertBelow: (lineId: string) => void;
|
||||||
|
onInsertAbove: (lineId: string) => void;
|
||||||
|
onTextSelect: (selectedText: string, lineId: string) => void;
|
||||||
|
selectedFuriganaItemId?: string | null;
|
||||||
|
onFuriganaSelect: (lineId: string, itemId: string) => void;
|
||||||
|
onNoteClick: (note: SidebarNote) => void;
|
||||||
|
moveFocusUp: (lineId: string, focusField: 'main' | 'furigana') => void;
|
||||||
|
moveFocusDown: (lineId: string, focusField: 'main' | 'furigana') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LineItem: React.FC<LineItemProps> = ({
|
||||||
|
line,
|
||||||
|
lineNumber,
|
||||||
|
showLineNumber,
|
||||||
|
isActive,
|
||||||
|
fontStyle,
|
||||||
|
fontSize,
|
||||||
|
lineSpacing,
|
||||||
|
readOnly = false,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onActivate,
|
||||||
|
onDeactivate,
|
||||||
|
onDoubleClick,
|
||||||
|
onInsertBelow,
|
||||||
|
onInsertAbove,
|
||||||
|
onTextSelect,
|
||||||
|
selectedFuriganaItemId,
|
||||||
|
onFuriganaSelect,
|
||||||
|
onNoteClick,
|
||||||
|
moveFocusUp,
|
||||||
|
moveFocusDown,
|
||||||
|
}) => {
|
||||||
|
const furiganaLayerRef = useRef<FuriganaLayerHandle>(null);
|
||||||
|
const mainInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const displayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const furiganaItems = line.furiganaItems;
|
||||||
|
|
||||||
|
// Expose focus methods dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCustomFocus = (e: CustomEvent<{ field: 'main' | 'furigana' }>) => {
|
||||||
|
if (e.detail.field === 'furigana') {
|
||||||
|
furiganaLayerRef.current?.focusFirstItem();
|
||||||
|
} else {
|
||||||
|
mainInputRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = document.getElementById(`line-${line.id}`);
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('focus-field' as any, handleCustomFocus as any);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (el) {
|
||||||
|
el.removeEventListener('focus-field' as any, handleCustomFocus as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [furiganaItems, line.id]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, isFurigana: boolean) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isFurigana) {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else {
|
||||||
|
onInsertBelow(line.id);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
// If both inputs are completely empty, delete this line
|
||||||
|
if (!line.rawText && furiganaItems.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onDelete(line.id);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isFurigana) {
|
||||||
|
// Go to previous line's main input
|
||||||
|
moveFocusUp(line.id, 'main');
|
||||||
|
} else {
|
||||||
|
// Go up to the furigana input of the same line
|
||||||
|
furiganaLayerRef.current?.focusFirstItem();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isFurigana) {
|
||||||
|
// Go down to the main input of the same line
|
||||||
|
mainInputRef.current?.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
// Go to next line's furigana input
|
||||||
|
moveFocusDown(line.id, 'furigana');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFuriganaItems = (nextItems: FuriganaItem[]) => {
|
||||||
|
onUpdate(line.id, line.rawText, nextItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture selection in reading mode to add annotations
|
||||||
|
const handleSelection = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed) return;
|
||||||
|
|
||||||
|
const selectedText = selection.toString().trim();
|
||||||
|
if (selectedText.length > 0) {
|
||||||
|
const lowerSelected = selectedText.toLowerCase();
|
||||||
|
const mainTextLower = (line.rawText || '').toLowerCase();
|
||||||
|
const furiTextLower = furiganaItems.map((item) => item.text).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
const isInside = displayRef.current && (
|
||||||
|
displayRef.current.contains(selection.anchorNode) ||
|
||||||
|
displayRef.current.contains(selection.focusNode)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isInside || mainTextLower.includes(lowerSelected) || furiTextLower.includes(lowerSelected)) {
|
||||||
|
onTextSelect(selectedText, line.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders text highlighted with custom note overlays
|
||||||
|
const renderTextWithNotes = (text: string, notes: SidebarNote[]) => {
|
||||||
|
if (!text || notes.length === 0) return text;
|
||||||
|
|
||||||
|
const activeNotes = notes.filter((n) => text.includes(n.text));
|
||||||
|
if (activeNotes.length === 0) return text;
|
||||||
|
|
||||||
|
const splitText = (str: string, availableNotes: SidebarNote[]): React.ReactNode[] => {
|
||||||
|
if (!str) return [];
|
||||||
|
if (availableNotes.length === 0) return [str];
|
||||||
|
|
||||||
|
let earliestNote: SidebarNote | null = null;
|
||||||
|
let earliestIndex = Infinity;
|
||||||
|
|
||||||
|
for (const note of availableNotes) {
|
||||||
|
const idx = str.indexOf(note.text);
|
||||||
|
if (idx !== -1 && idx < earliestIndex) {
|
||||||
|
earliestIndex = idx;
|
||||||
|
earliestNote = note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!earliestNote || earliestIndex === Infinity) {
|
||||||
|
return [str];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchText = earliestNote.text;
|
||||||
|
const before = str.slice(0, earliestIndex);
|
||||||
|
const after = str.slice(earliestIndex + matchText.length);
|
||||||
|
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
if (before) {
|
||||||
|
parts.push(...splitText(before, availableNotes));
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorClass = 'bg-[#FAF9F6] border-[#8B7E74] text-[#2D2926] hover:bg-[#E8E4DE]/50';
|
||||||
|
if (earliestNote.color === 'rose') {
|
||||||
|
colorClass = 'bg-rose-100/70 border-rose-300 text-rose-950 hover:bg-rose-200/80';
|
||||||
|
} else if (earliestNote.color === 'sky') {
|
||||||
|
colorClass = 'bg-sky-100/70 border-sky-300 text-sky-950 hover:bg-sky-200/80';
|
||||||
|
} else if (earliestNote.color === 'emerald') {
|
||||||
|
colorClass = 'bg-emerald-100/70 border-emerald-300 text-emerald-950 hover:bg-emerald-200/80';
|
||||||
|
} else if (earliestNote.color === 'violet') {
|
||||||
|
colorClass = 'bg-purple-100/70 border-purple-300 text-purple-950 hover:bg-purple-200/80';
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
<span
|
||||||
|
key={`highlight-${earliestNote.id}-${earliestIndex}`}
|
||||||
|
data-note-highlight="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNoteClick(earliestNote!);
|
||||||
|
}}
|
||||||
|
className={`${colorClass} px-1 rounded border-b-2 font-medium cursor-pointer transition-all duration-200 inline-block active:scale-95`}
|
||||||
|
title={`点击查看注释: ${earliestNote.comment}`}
|
||||||
|
>
|
||||||
|
{matchText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (after) {
|
||||||
|
parts.push(...splitText(after, availableNotes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
return splitText(text, activeNotes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatedLineHeight = fontSize * lineSpacing;
|
||||||
|
const isSerif = fontStyle === 'serif';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`line-${line.id}`}
|
||||||
|
className={`relative group flex gap-4 pr-3 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 min-w-[800px] md:min-w-[1200px] xl:min-w-[1600px]`}
|
||||||
|
style={{ minHeight: `${calculatedLineHeight}px` }}
|
||||||
|
>
|
||||||
|
{/* 1. Left gutter with number */}
|
||||||
|
<div className="relative flex flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 w-12 border-r border-[#E8E4DE]/50 font-mono">
|
||||||
|
{showLineNumber && (
|
||||||
|
<span className="text-[11px] font-bold select-none opacity-60">
|
||||||
|
{lineNumber.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Interactive sheet writing lines (Direct live editing) */}
|
||||||
|
<div className="flex-1 py-1 flex flex-col justify-center">
|
||||||
|
{!readOnly ? (
|
||||||
|
/* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
|
||||||
|
<div className="flex flex-col w-full px-1 -mx-1 pr-1 font-mono">
|
||||||
|
{/* Top Row: Free-positioned Furigana / 假名 layer */}
|
||||||
|
<FuriganaLayer
|
||||||
|
ref={furiganaLayerRef}
|
||||||
|
items={furiganaItems}
|
||||||
|
editable
|
||||||
|
selectedItemId={selectedFuriganaItemId}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
||||||
|
onChange={updateFuriganaItems}
|
||||||
|
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
||||||
|
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
|
||||||
|
onFocusMain={() => mainInputRef.current?.focus({ preventScroll: true })}
|
||||||
|
onFocusPreviousLine={() => moveFocusUp(line.id, 'main')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom Row: Main Kanji / 正文 input line */}
|
||||||
|
<input
|
||||||
|
ref={mainInputRef}
|
||||||
|
type="text"
|
||||||
|
value={line.rawText || ''}
|
||||||
|
onChange={(e) => onUpdate(line.id, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, false)}
|
||||||
|
onSelect={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const start = target.selectionStart;
|
||||||
|
const end = target.selectionEnd;
|
||||||
|
if (start !== null && end !== null && start !== end) {
|
||||||
|
const selectedText = target.value.substring(start, end).trim();
|
||||||
|
if (selectedText.length > 0) {
|
||||||
|
onTextSelect(selectedText, line.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder=""
|
||||||
|
className="w-full bg-transparent border-none outline-none focus:outline-none focus:ring-0 m-0 p-0 font-normal text-[#2D2926] placeholder-neutral-300 font-mono"
|
||||||
|
style={{
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
|
||||||
|
marginTop: '1px',
|
||||||
|
height: `${fontSize * 1.2}px`,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ================= READ MODE: ALIGNED STATIC TEXT DISPLAY ================= */
|
||||||
|
<div
|
||||||
|
ref={displayRef}
|
||||||
|
onMouseUp={handleSelection}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('[data-furigana-layer="true"]')) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && !selection.isCollapsed) return;
|
||||||
|
onActivate(line.id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (onDoubleClick) {
|
||||||
|
onDoubleClick(line.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex flex-col w-full cursor-text select-text pr-1 font-mono hover:bg-[#FAF9F6]/80 rounded px-1 -mx-1"
|
||||||
|
>
|
||||||
|
{/* Furigana line */}
|
||||||
|
<FuriganaLayer
|
||||||
|
items={furiganaItems}
|
||||||
|
editable
|
||||||
|
selectedItemId={selectedFuriganaItemId}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
||||||
|
onChange={updateFuriganaItems}
|
||||||
|
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
||||||
|
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Kanji text line with word highlighter/comment select matches */}
|
||||||
|
<div
|
||||||
|
className="text-[#2D2926] m-0 p-0"
|
||||||
|
style={{
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
|
||||||
|
marginTop: '1px',
|
||||||
|
height: `${fontSize * 1.2}px`,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.rawText ? (
|
||||||
|
renderTextWithNotes(line.rawText, line.notes)
|
||||||
|
) : (
|
||||||
|
'\u00A0'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { SidebarNote, WritingLine } from '../types';
|
||||||
|
import { BookOpen, Tag, Trash2, Search, FileText, ChevronRight, HelpCircle, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
lines: WritingLine[];
|
||||||
|
activeLineId: string | null;
|
||||||
|
currentSelection: { text: string; lineId: string } | null;
|
||||||
|
selectedFurigana: { lineId: string; itemId: string } | null;
|
||||||
|
focusedNoteId: string | null;
|
||||||
|
onFocusNote: (noteId: string | null) => void;
|
||||||
|
onAddNote: (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => void;
|
||||||
|
onDeleteNote: (lineId: string, noteId: string) => void;
|
||||||
|
onDeleteFurigana: (lineId: string, itemId: string) => void;
|
||||||
|
onUpdateFurigana: (lineId: string, itemId: string, text: string) => void;
|
||||||
|
onUpdateFuriganaX: (lineId: string, itemId: string, x: number) => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onJumpToLine: (lineId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLORS = [
|
||||||
|
{ value: 'yellow', label: '岩砂褐', bg: 'bg-[#E8E4DE]', border: 'border-[#8B7E74]', check: 'text-[#2D2926]' },
|
||||||
|
{ value: 'rose', label: '绯樱红', bg: 'bg-rose-100', border: 'border-rose-400', check: 'text-rose-700' },
|
||||||
|
{ value: 'sky', label: '琉璃蓝', bg: 'bg-sky-100', border: 'border-sky-400', check: 'text-sky-700' },
|
||||||
|
{ value: 'emerald', label: '薄荷绿', bg: 'bg-emerald-100', border: 'border-emerald-400', check: 'text-emerald-700' },
|
||||||
|
{ value: 'violet', label: '藤萝紫', bg: 'bg-purple-100', border: 'border-purple-400', check: 'text-purple-700' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
lines,
|
||||||
|
activeLineId,
|
||||||
|
currentSelection,
|
||||||
|
selectedFurigana,
|
||||||
|
focusedNoteId,
|
||||||
|
onFocusNote,
|
||||||
|
onAddNote,
|
||||||
|
onDeleteNote,
|
||||||
|
onDeleteFurigana,
|
||||||
|
onUpdateFurigana,
|
||||||
|
onUpdateFuriganaX,
|
||||||
|
onClearSelection,
|
||||||
|
onJumpToLine,
|
||||||
|
}) => {
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [selectedColor, setSelectedColor] = useState('yellow');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterMode, setFilterMode] = useState<'all' | 'active'>('all');
|
||||||
|
|
||||||
|
// Check if we are editing an existing note
|
||||||
|
const isEditing = !!(
|
||||||
|
focusedNoteId ||
|
||||||
|
(currentSelection &&
|
||||||
|
lines.find((l) => l.id === currentSelection.lineId)?.notes.some((n) => n.text === currentSelection.text))
|
||||||
|
);
|
||||||
|
|
||||||
|
let editingNoteId: string | undefined = focusedNoteId || undefined;
|
||||||
|
let editingLineId: string | undefined = currentSelection?.lineId || undefined;
|
||||||
|
|
||||||
|
if (!editingNoteId && currentSelection) {
|
||||||
|
const lineObj = lines.find((l) => l.id === currentSelection.lineId);
|
||||||
|
if (lineObj) {
|
||||||
|
const existing = lineObj.notes.find((n) => n.text === currentSelection.text);
|
||||||
|
if (existing) {
|
||||||
|
editingNoteId = existing.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedFuriganaLineIndex = selectedFurigana
|
||||||
|
? lines.findIndex((line) => line.id === selectedFurigana.lineId)
|
||||||
|
: -1;
|
||||||
|
const selectedFuriganaLine = selectedFuriganaLineIndex >= 0 ? lines[selectedFuriganaLineIndex] : undefined;
|
||||||
|
const selectedFuriganaItem = selectedFuriganaLine?.furiganaItems.find(
|
||||||
|
(item) => item.id === selectedFurigana?.itemId
|
||||||
|
);
|
||||||
|
const selectedFuriganaMaxX = selectedFuriganaLine
|
||||||
|
? Math.max(4, [...selectedFuriganaLine.rawText].length + 4, (selectedFuriganaItem?.x || 0) + 2)
|
||||||
|
: 4;
|
||||||
|
|
||||||
|
// Automatically update the comment input field when the selected text/note changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
let existingNote: SidebarNote | undefined;
|
||||||
|
if (focusedNoteId) {
|
||||||
|
for (const line of lines) {
|
||||||
|
const found = line.notes.find((n) => n.id === focusedNoteId);
|
||||||
|
if (found) {
|
||||||
|
existingNote = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!existingNote && currentSelection) {
|
||||||
|
const lineObj = lines.find((l) => l.id === currentSelection.lineId);
|
||||||
|
existingNote = lineObj?.notes.find((n) => n.text === currentSelection.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingNote) {
|
||||||
|
setCommentText(existingNote.comment);
|
||||||
|
setSelectedColor(existingNote.color);
|
||||||
|
} else if (currentSelection) {
|
||||||
|
setCommentText('');
|
||||||
|
setSelectedColor('yellow');
|
||||||
|
} else {
|
||||||
|
setCommentText('');
|
||||||
|
}
|
||||||
|
}, [currentSelection?.text, currentSelection?.lineId, focusedNoteId]);
|
||||||
|
|
||||||
|
const handleSaveNote = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentSelection || !commentText.trim()) return;
|
||||||
|
|
||||||
|
onAddNote(
|
||||||
|
currentSelection.lineId,
|
||||||
|
currentSelection.text,
|
||||||
|
commentText.trim(),
|
||||||
|
selectedColor,
|
||||||
|
editingNoteId
|
||||||
|
);
|
||||||
|
if (!editingNoteId) {
|
||||||
|
setCommentText('');
|
||||||
|
onClearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compile all notes across the writing pad
|
||||||
|
const allNotesWithLineContext = lines.flatMap((line, idx) => {
|
||||||
|
return line.notes.map((note) => ({
|
||||||
|
...note,
|
||||||
|
lineId: line.id,
|
||||||
|
lineNumber: idx + 1,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter notes based on search query, current line focus, and focusedNoteId
|
||||||
|
const filteredNotes = allNotesWithLineContext.filter((note) => {
|
||||||
|
if (focusedNoteId) {
|
||||||
|
return note.id === focusedNoteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
note.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
note.comment.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
if (filterMode === 'active') {
|
||||||
|
return matchesSearch && note.lineId === activeLineId;
|
||||||
|
}
|
||||||
|
return matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-[#FDFCFB] border-l border-[#E8E4DE]" id="annotations-sidebar">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-[#E8E4DE] bg-[#FAF9F6]/80 flex items-center justify-between">
|
||||||
|
<h2 className="font-serif font-bold text-[#2D2926] text-sm flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-[#8B7E74]" />
|
||||||
|
<span>释义与随堂笔录</span>
|
||||||
|
<span className="font-mono text-xs text-[#A69F92] font-normal">
|
||||||
|
({allNotesWithLineContext.length})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor or Helper Section for Selection */}
|
||||||
|
<div className="p-4 border-b border-[#E8E4DE] bg-[#FAF9F6]/30">
|
||||||
|
{selectedFurigana && selectedFuriganaLine && selectedFuriganaItem ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
|
||||||
|
<Tag className="w-3 h-3" /> 假名操作区
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
|
||||||
|
title="关闭假名操作区"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
|
||||||
|
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
|
||||||
|
当前假名:
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedFuriganaItem.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateFurigana(selectedFurigana.lineId, selectedFurigana.itemId, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="在此修改假名"
|
||||||
|
className="w-full bg-[#FAF9F6] border border-[#E8E4DE] rounded px-2 py-1.5 text-[#2D2926] text-base font-serif focus:outline-none focus:ring-1 focus:ring-[#8B7E74] placeholder-[#A69F92]/60"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[11px] text-[#A69F92] font-mono">
|
||||||
|
行 {(selectedFuriganaLineIndex + 1).toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] text-[#8B7E74] font-bold uppercase font-mono tracking-wider">
|
||||||
|
X 坐标
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[#2D2926] font-mono">
|
||||||
|
{selectedFuriganaItem.x.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={selectedFuriganaMaxX}
|
||||||
|
step={0.1}
|
||||||
|
value={selectedFuriganaItem.x}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateFuriganaX(
|
||||||
|
selectedFurigana.lineId,
|
||||||
|
selectedFurigana.itemId,
|
||||||
|
Number(e.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full accent-[#8B7E74] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-[10px] text-[#A69F92] font-mono">
|
||||||
|
<span>0</span>
|
||||||
|
<span>{selectedFuriganaMaxX.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJumpToLine(selectedFurigana.lineId)}
|
||||||
|
className="flex-1 border border-[#E8E4DE] bg-white hover:bg-[#FAF9F6] text-[#8B7E74] hover:text-[#2D2926] font-semibold text-xs py-2 rounded shadow-xs transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
定位该行
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDeleteFurigana(selectedFurigana.lineId, selectedFurigana.itemId)}
|
||||||
|
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
删除假名
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : currentSelection ? (
|
||||||
|
/* ACTIVE NEW NOTE FORM */
|
||||||
|
<form onSubmit={handleSaveNote} className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
|
||||||
|
<Tag className="w-3 h-3" /> {isEditing ? '修改注释' : '新建注释'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
|
||||||
|
title="取消选择"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Text Showcase */}
|
||||||
|
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
|
||||||
|
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
|
||||||
|
选中的字符:
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-[#2D2926] text-lg leading-relaxed tracking-wide font-serif break-all">
|
||||||
|
“{currentSelection.text}”
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color preset picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-[#2D2926] mb-1.5">
|
||||||
|
高亮底色标签:
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{HIGHLIGHT_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedColor(c.value)}
|
||||||
|
title={c.label}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 transition-all flex items-center justify-center ${c.bg} ${
|
||||||
|
selectedColor === c.value
|
||||||
|
? 'border-[#8B7E74] scale-110 shadow-xs'
|
||||||
|
: 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedColor === c.value && (
|
||||||
|
<Check className={`w-3.5 h-3.5 ${c.check} stroke-[3]`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment input textarea */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="comment-textarea" className="block text-xs font-semibold text-[#2D2926] mb-1.5">
|
||||||
|
写下你的释义或解析:
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="comment-textarea"
|
||||||
|
rows={6}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
placeholder="在此录入释义、语法要点、生词短语等注释..."
|
||||||
|
className="w-full text-sm md:text-base bg-white border border-[#E8E4DE] rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-[#8B7E74]/40 focus:border-[#8B7E74] placeholder-neutral-400 text-[#2D2926] leading-relaxed shadow-inner"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{editingNoteId && editingLineId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (editingNoteId && editingLineId) {
|
||||||
|
onDeleteNote(editingLineId, editingNoteId);
|
||||||
|
onClearSelection();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
|
||||||
|
title="删除此条注释"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
删除注释
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`${editingNoteId ? 'flex-[2]' : 'w-full'} bg-[#8B7E74] text-white font-semibold text-xs py-2 rounded shadow-xs hover:bg-[#786C63] active:bg-[#665B53] transition-all font-sans cursor-pointer text-center`}
|
||||||
|
>
|
||||||
|
{isEditing ? '保存修改' : '保存此条注释'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* EMPTY SELECTION WELCOME STATE */
|
||||||
|
<div className="text-xs text-[#A69F92] leading-relaxed py-2 flex flex-col items-center text-center gap-1.5">
|
||||||
|
<HelpCircle className="w-5 h-5 text-[#8B7E74]/40" />
|
||||||
|
<p>
|
||||||
|
想要添加右侧注释吗?
|
||||||
|
<br />
|
||||||
|
在写字板里<span className="font-bold text-[#2D2926]">用鼠标拖拽选中任意字词</span>,即可在此快速记录属于你自己的日语注释!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FILTER & SEARCH OVERVIEW */}
|
||||||
|
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE] flex flex-col gap-2">
|
||||||
|
{/* Toggle line vs document */}
|
||||||
|
<div className="flex bg-[#E8E4DE]/60 rounded p-0.5 text-xs font-sans">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterMode('all')}
|
||||||
|
className={`flex-1 py-1 rounded text-center transition-all cursor-pointer ${
|
||||||
|
filterMode === 'all'
|
||||||
|
? 'bg-white text-[#2D2926] font-semibold border border-[#E8E4DE]/40 shadow-xs'
|
||||||
|
: 'text-[#A69F92] hover:bg-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全篇注释
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterMode('active')}
|
||||||
|
className={`flex-1 py-1 rounded text-center transition-all cursor-pointer ${
|
||||||
|
filterMode === 'active'
|
||||||
|
? 'bg-white text-[#2D2926] font-semibold border border-[#E8E4DE]/40 shadow-xs'
|
||||||
|
: 'text-[#A69F92] hover:bg-white/30 font-medium'
|
||||||
|
}`}
|
||||||
|
disabled={!activeLineId}
|
||||||
|
title={!activeLineId ? '请先在写作板中双击或选中一行' : ''}
|
||||||
|
>
|
||||||
|
仅看当前行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative font-sans">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-[#A69F92]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索已有注释..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full text-xs bg-[#FAF9F6] border border-[#E8E4DE] text-[#2D2926] pl-8 pr-3 py-1.5 rounded focus:outline-none focus:bg-white focus:ring-1 focus:ring-[#8B7E74]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{focusedNoteId && (
|
||||||
|
<div className="mx-3 mt-3 px-3 py-2 bg-[#8B7E74]/10 rounded-md border border-[#8B7E74]/20 flex items-center justify-between text-xs text-[#2D2926] font-medium font-sans animate-fadeIn">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 bg-[#8B7E74] rounded-full animate-pulse"></span>
|
||||||
|
已聚焦单条注释
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onFocusNote(null)}
|
||||||
|
className="text-[#8B7E74] hover:text-[#2D2926] font-semibold hover:underline flex items-center gap-0.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
显示全部注释
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LIST OF PREVIOUS NOTES */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-3.5 select-none bg-[#FAF9F6]/20">
|
||||||
|
{filteredNotes.length > 0 ? (
|
||||||
|
filteredNotes.map((note) => {
|
||||||
|
let badgeColorClass = 'bg-[#FAF9F6] border-[#E8E4DE] text-[#2D2926]';
|
||||||
|
let dotColor = 'bg-[#8B7E74]';
|
||||||
|
if (note.color === 'rose') {
|
||||||
|
badgeColorClass = 'bg-rose-50 border-rose-200 text-rose-950';
|
||||||
|
dotColor = 'bg-rose-500';
|
||||||
|
} else if (note.color === 'sky') {
|
||||||
|
badgeColorClass = 'bg-sky-50 border-sky-200 text-sky-950';
|
||||||
|
dotColor = 'bg-sky-500';
|
||||||
|
} else if (note.color === 'emerald') {
|
||||||
|
badgeColorClass = 'bg-emerald-50 border-emerald-200 text-emerald-950';
|
||||||
|
dotColor = 'bg-emerald-500';
|
||||||
|
} else if (note.color === 'violet') {
|
||||||
|
badgeColorClass = 'bg-purple-50 border-purple-200 text-purple-950';
|
||||||
|
dotColor = 'bg-purple-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFocused = focusedNoteId === note.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (isFocused) {
|
||||||
|
onFocusNote(null);
|
||||||
|
} else {
|
||||||
|
onFocusNote(note.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`group border rounded-lg p-3 bg-white hover:shadow-xs transition-all flex flex-col gap-2 relative cursor-pointer ${
|
||||||
|
isFocused ? 'border-[#8B7E74] ring-1 ring-[#8B7E74]/30' : 'border-[#E8E4DE]/60 hover:border-[#8B7E74]/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Note metadata */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Highlight text indicator */}
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold px-2 py-0.5 rounded border ${badgeColorClass} font-serif max-w-[70%] truncate`}
|
||||||
|
title={note.text}
|
||||||
|
>
|
||||||
|
{note.text}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Jump-to Line indicator */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onJumpToLine(note.lineId);
|
||||||
|
}}
|
||||||
|
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 rounded font-mono text-[10px] flex items-center gap-0.5 transition-all cursor-pointer"
|
||||||
|
title="点击跳转并定位此行"
|
||||||
|
>
|
||||||
|
行 {note.lineNumber.toString().padStart(2, '0')}
|
||||||
|
<ChevronRight className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment contents */}
|
||||||
|
<div className="text-[#2D2926] text-xs leading-relaxed break-words pr-2 font-sans select-text">
|
||||||
|
{note.comment}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button (displays on card hover) */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteNote(note.lineId, note.id);
|
||||||
|
}}
|
||||||
|
title="删除此注释"
|
||||||
|
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 rounded transition-all md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-12 text-neutral-400 gap-2">
|
||||||
|
<FileText className="w-8 h-8 text-neutral-200" />
|
||||||
|
<div className="text-xs font-medium">无匹配的注释记录</div>
|
||||||
|
{searchQuery && (
|
||||||
|
<div className="text-[10px] text-neutral-400 px-4">
|
||||||
|
尝试缩减包含 “{searchQuery}” 的检索词
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
FolderOpen,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Upload,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ToolbarProps {
|
||||||
|
onNewDocument: () => void;
|
||||||
|
onViewSavedDocuments: () => void;
|
||||||
|
onSaveDocument: () => void;
|
||||||
|
onToggleBulkImport: () => void;
|
||||||
|
fileStatus: string;
|
||||||
|
showImportArea: boolean;
|
||||||
|
showSavedArea: boolean;
|
||||||
|
savedDocumentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
|
onNewDocument,
|
||||||
|
onViewSavedDocuments,
|
||||||
|
onSaveDocument,
|
||||||
|
onToggleBulkImport,
|
||||||
|
fileStatus,
|
||||||
|
showImportArea,
|
||||||
|
showSavedArea,
|
||||||
|
savedDocumentCount,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] p-4 flex flex-col xl:flex-row xl:items-center justify-between gap-4 select-none">
|
||||||
|
{/* Brand / Logo */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-[#8B7E74] text-white rounded-lg p-2.5 shadow-sm">
|
||||||
|
<BookOpen className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif font-bold text-[#2D2926] text-lg leading-none tracking-widest flex items-center gap-2">
|
||||||
|
言の葉 <span className="text-[#8B7E74]">Kotonoha</span> 写字板
|
||||||
|
<span className="text-[10px] bg-[#8B7E74]/10 text-[#8B7E74] border border-[#8B7E74]/20 px-2 py-0.5 rounded-full font-sans tracking-normal font-bold">
|
||||||
|
無AI纯净版
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-[#A69F92] font-sans mt-1.5 tracking-tight">
|
||||||
|
在阅览模式下划词记录释义 · {fileStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document actions */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs select-none font-sans">
|
||||||
|
<button
|
||||||
|
onClick={onSaveDocument}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium rounded-lg border border-[#8B7E74] shadow-2xs transition-all cursor-pointer"
|
||||||
|
title="按当前文档标题保存到项目 saves 目录"
|
||||||
|
>
|
||||||
|
<Save className="w-3.5 h-3.5 text-white" />
|
||||||
|
<span>保存</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onToggleBulkImport}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
|
||||||
|
showImportArea
|
||||||
|
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
|
||||||
|
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
|
||||||
|
}`}
|
||||||
|
title="粘贴整段文本并按行导入"
|
||||||
|
>
|
||||||
|
<Upload className={`w-3.5 h-3.5 ${showImportArea ? 'text-white' : 'text-[#8B7E74]'}`} />
|
||||||
|
<span>整段导入</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onNewDocument}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#FAF9F6] text-[#2D2926] font-medium rounded-lg border border-[#E8E4DE] shadow-2xs transition-all cursor-pointer"
|
||||||
|
title="新建空白文档"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 text-[#8B7E74]" />
|
||||||
|
<span>新建</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onViewSavedDocuments}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
|
||||||
|
showSavedArea
|
||||||
|
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
|
||||||
|
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
|
||||||
|
}`}
|
||||||
|
title="查看项目 saves 目录里的保存文件"
|
||||||
|
>
|
||||||
|
<FolderOpen className={`w-3.5 h-3.5 ${showSavedArea ? 'text-white' : 'text-[#8B7E74]'}`} />
|
||||||
|
<span>查看</span>
|
||||||
|
{savedDocumentCount > 0 && (
|
||||||
|
<span className={`font-mono text-[10px] ${showSavedArea ? 'text-white/80' : 'text-[#A69F92]'}`}>
|
||||||
|
{savedDocumentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@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');
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: "Noto Serif JP", Georgia, Cambria, serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--color-artistic-primary: #8B7E74;
|
||||||
|
--color-artistic-dark: #2D2926;
|
||||||
|
--color-artistic-muted: #A69F92;
|
||||||
|
--color-artistic-bg: #FAF9F6;
|
||||||
|
--color-artistic-border: #E8E4DE;
|
||||||
|
--color-artistic-paper: #FDFCFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base resets & scrollbar styles */
|
||||||
|
body {
|
||||||
|
background-color: #FAF9F6; /* Artistic light cream */
|
||||||
|
color: #2D2926; /* Deep artistic charcoal */
|
||||||
|
font-feature-settings: "palt"; /* Proportional Japanese spacing */
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Customized ledger and grid paper backgrounds */
|
||||||
|
.paper-lined {
|
||||||
|
background-image: linear-gradient(#E8E4DE 1px, transparent 1px);
|
||||||
|
background-size: 100% 4.5rem; /* Matches line-height height scaling */
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(232, 228, 222, 0.7) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(232, 228, 222, 0.7) 1px, transparent 1px);
|
||||||
|
background-size: 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom shadow effects for premium depth */
|
||||||
|
.premium-shadow {
|
||||||
|
box-shadow: 0 12px 40px -10px rgba(139, 126, 116, 0.12), 0 4px 16px -4px rgba(139, 126, 116, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth custom selection styles - beautiful warm brown tint matching paper character */
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(139, 126, 116, 0.25);
|
||||||
|
color: #2D2926;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ruby spacing and formatting adjustments */
|
||||||
|
ruby {
|
||||||
|
ruby-position: over;
|
||||||
|
ruby-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
rt {
|
||||||
|
font-size: 0.55em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #A69F92; /* Artistic Muted */
|
||||||
|
user-select: none; /* Make furigana non-selectable so copy-pasting is easy */
|
||||||
|
padding-bottom: 0.15em;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interactive focus transitions */
|
||||||
|
.writing-line:hover rt {
|
||||||
|
color: #8B7E74;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth custom scrollbars for subtle design */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #FAF9F6;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #E8E4DE;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #8B7E74;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LineSegment {
|
||||||
|
type: 'text' | 'ruby';
|
||||||
|
text: string;
|
||||||
|
rt?: string; // Furigana reading
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarNote {
|
||||||
|
id: string;
|
||||||
|
text: string; // The highlighted Japanese characters
|
||||||
|
comment: string; // The user's annotation
|
||||||
|
color: string; // Highlight color class or name
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuriganaItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
x: number; // Horizontal position in main-text em units from the line start
|
||||||
|
y: number; // Vertical position within the furigana area, as a percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingLine {
|
||||||
|
id: string;
|
||||||
|
rawText: string; // Main Japanese text
|
||||||
|
/** @deprecated Runtime furigana is stored in furiganaItems. Kept for legacy import/export compatibility. */
|
||||||
|
furiganaText: string;
|
||||||
|
furiganaItems: FuriganaItem[]; // Freely positioned furigana notes above the main text
|
||||||
|
/** @deprecated Parsed segments are transient import data, not runtime state. */
|
||||||
|
segments: LineSegment[];
|
||||||
|
notes: SidebarNote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingPadState {
|
||||||
|
lines: WritingLine[];
|
||||||
|
paperStyle: 'lined' | 'grid' | 'blank';
|
||||||
|
fontSize: number; // in pixels, e.g., 20
|
||||||
|
lineSpacing: number; // multiplier, e.g., 2.5
|
||||||
|
themeColor: string; // theme color preset
|
||||||
|
showLineNumbers: boolean;
|
||||||
|
}
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FuriganaItem, LineSegment, SidebarNote, WritingLine } from './types';
|
||||||
|
|
||||||
|
// Regex to check if a character is Kanji, including common iteration marks like 々, 〆, ヶ
|
||||||
|
export const KANJI_REGEX = /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF々〆ヶ]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses raw text containing custom ruby bindings into LineSegments.
|
||||||
|
* Supported syntaxes:
|
||||||
|
* 1. Bracket explicit syntax: [漢字]{かんじ} or [日本語]{にほんご}
|
||||||
|
* 2. Auto-Kanji binding sync: 漢字{かんじ} (automatically binds to consecutive kanji characters preceding the brace)
|
||||||
|
* 3. Fallback binding: 文字{もじ} (for non-kanji, binds to the sequence preceding `{`)
|
||||||
|
*/
|
||||||
|
export function parseRawTextToSegments(rawText: string): LineSegment[] {
|
||||||
|
const segments: LineSegment[] = [];
|
||||||
|
if (!rawText) return segments;
|
||||||
|
|
||||||
|
const appendPlainText = (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
const lastSegment = segments[segments.length - 1];
|
||||||
|
if (lastSegment?.type === 'text') {
|
||||||
|
lastSegment.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'text', text });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const len = rawText.length;
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
// 1. Explicit bracket syntax: [base]{rt}
|
||||||
|
if (rawText[i] === '[') {
|
||||||
|
const closeBracketIndex = rawText.indexOf(']', i);
|
||||||
|
if (closeBracketIndex !== -1 && rawText[closeBracketIndex + 1] === '{') {
|
||||||
|
const closeBraceIndex = rawText.indexOf('}', closeBracketIndex + 1);
|
||||||
|
if (closeBraceIndex !== -1) {
|
||||||
|
const baseText = rawText.slice(i + 1, closeBracketIndex);
|
||||||
|
const rtText = rawText.slice(closeBracketIndex + 2, closeBraceIndex);
|
||||||
|
|
||||||
|
segments.push({ type: 'ruby', text: baseText, rt: rtText });
|
||||||
|
i = closeBraceIndex + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPlainText(rawText[i]);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Simple brace syntax: TEXT{rt}
|
||||||
|
if (rawText[i] === '{') {
|
||||||
|
const closeBraceIndex = rawText.indexOf('}', i);
|
||||||
|
const baseStartProbe = i - 1;
|
||||||
|
|
||||||
|
if (closeBraceIndex !== -1 && baseStartProbe >= 0) {
|
||||||
|
const rtText = rawText.slice(i + 1, closeBraceIndex);
|
||||||
|
|
||||||
|
let baseStart = baseStartProbe;
|
||||||
|
const lastChar = rawText[baseStart];
|
||||||
|
if (KANJI_REGEX.test(lastChar)) {
|
||||||
|
while (baseStart > 0 && KANJI_REGEX.test(rawText[baseStart - 1])) {
|
||||||
|
baseStart--;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const stopChars = /[\s{}·・,。、!¥…\-[\]]/;
|
||||||
|
while (baseStart > 0 && !stopChars.test(rawText[baseStart - 1])) {
|
||||||
|
baseStart--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseText = rawText.slice(baseStart, i);
|
||||||
|
if (baseText) {
|
||||||
|
let lengthToRemove = baseText.length;
|
||||||
|
while (lengthToRemove > 0 && segments.length > 0) {
|
||||||
|
const lastSegment = segments[segments.length - 1];
|
||||||
|
if (lastSegment.type !== 'text') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSegment.text.length <= lengthToRemove) {
|
||||||
|
lengthToRemove -= lastSegment.text.length;
|
||||||
|
segments.pop();
|
||||||
|
} else {
|
||||||
|
lastSegment.text = lastSegment.text.slice(0, -lengthToRemove);
|
||||||
|
lengthToRemove = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({ type: 'ruby', text: baseText, rt: rtText });
|
||||||
|
i = closeBraceIndex + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPlainText(rawText[i]);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plainText = '';
|
||||||
|
while (i < len && rawText[i] !== '[' && rawText[i] !== '{') {
|
||||||
|
plainText += rawText[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPlainText(plainText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts line segments back into raw mark-up string.
|
||||||
|
*/
|
||||||
|
export function segmentsToRawText(segments: LineSegment[]): string {
|
||||||
|
return segments
|
||||||
|
.map((s) => {
|
||||||
|
if (s.type === 'ruby') {
|
||||||
|
// Use simpler auto-binding syntax if base is pure Kanji, otherwise use bracket syntax [base]{rt}
|
||||||
|
const isPureKanji = [...s.text].every((char) => KANJI_REGEX.test(char));
|
||||||
|
if (isPureKanji) {
|
||||||
|
return `${s.text}{${s.rt || ''}}`;
|
||||||
|
} else {
|
||||||
|
return `[${s.text}]{${s.rt || ''}}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.text;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if a string contains any Kanji.
|
||||||
|
*/
|
||||||
|
export function containsKanji(text: string): boolean {
|
||||||
|
return [...text].some((char) => KANJI_REGEX.test(char));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses legacy text with curly braces (e.g. 日本語{にほんご}或者吾輩{わがはい}は猫{ねこ}である)
|
||||||
|
* into clean main text plus freely positioned furigana items.
|
||||||
|
*/
|
||||||
|
export function convertLegacyText(rawText: string): { main: string; furigana: string; furiganaItems: FuriganaItem[] } {
|
||||||
|
const segments = parseRawTextToSegments(rawText);
|
||||||
|
let mainText = '';
|
||||||
|
const furiganaItems: FuriganaItem[] = [];
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg.type === 'ruby') {
|
||||||
|
const baseText = seg.text;
|
||||||
|
const rtText = seg.rt || '';
|
||||||
|
const startIndex = mainText.length;
|
||||||
|
|
||||||
|
mainText += baseText;
|
||||||
|
|
||||||
|
if (rtText) {
|
||||||
|
furiganaItems.push({
|
||||||
|
id: `ruby-${furiganaItems.length}-${startIndex}`,
|
||||||
|
text: rtText,
|
||||||
|
x: startIndex,
|
||||||
|
y: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainText += seg.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { main: mainText, furigana: '', furiganaItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
|
export function migrateLegacyFuriganaTextToItems(rawText: string, furiganaText: string): FuriganaItem[] {
|
||||||
|
const items: FuriganaItem[] = [];
|
||||||
|
const pattern = /[^\s ]+/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(furiganaText)) !== null) {
|
||||||
|
const text = match[0].trim();
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const startIndex = Math.min(match.index, Math.max(rawText.length, 1));
|
||||||
|
items.push({
|
||||||
|
id: `legacy-${items.length}-${startIndex}`,
|
||||||
|
text,
|
||||||
|
x: startIndex,
|
||||||
|
y: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFuriganaItems(value: unknown): FuriganaItem[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item, index) => {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const candidate = item as Partial<FuriganaItem>;
|
||||||
|
const text = typeof candidate.text === 'string' ? candidate.text : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof candidate.id === 'string' && candidate.id ? candidate.id : `furi-${index}`,
|
||||||
|
text,
|
||||||
|
x: typeof candidate.x === 'number' && Number.isFinite(candidate.x) ? Math.max(0, candidate.x) : 0,
|
||||||
|
y: typeof candidate.y === 'number' && Number.isFinite(candidate.y) ? clamp(candidate.y, 0, 80) : 12,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is FuriganaItem => !!item && item.text.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeNotes = (value: unknown): SidebarNote[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((note, index) => {
|
||||||
|
if (!note || typeof note !== 'object') return null;
|
||||||
|
const candidate = note as Partial<SidebarNote>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof candidate.id === 'string' && candidate.id ? candidate.id : `note-${Date.now()}-${index}`,
|
||||||
|
text: typeof candidate.text === 'string' ? candidate.text : '',
|
||||||
|
comment: typeof candidate.comment === 'string' ? candidate.comment : '',
|
||||||
|
color: typeof candidate.color === 'string' ? candidate.color : 'yellow',
|
||||||
|
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : Date.now(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((note): note is SidebarNote => !!note && note.text.trim().length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeLine(value: Partial<WritingLine> & { rawText?: unknown }, fallbackId: string): WritingLine {
|
||||||
|
const rawTextInput = typeof value.rawText === 'string' ? value.rawText : '';
|
||||||
|
const legacyFuriganaText = typeof value.furiganaText === 'string' ? value.furiganaText : '';
|
||||||
|
const parsed = convertLegacyText(rawTextInput);
|
||||||
|
const hasMarkup = parsed.furiganaItems.length > 0 || parsed.main !== rawTextInput;
|
||||||
|
const rawText = hasMarkup ? parsed.main : rawTextInput;
|
||||||
|
const providedItems = sanitizeFuriganaItems(value.furiganaItems);
|
||||||
|
const furiganaItems = hasMarkup
|
||||||
|
? providedItems.length > 0
|
||||||
|
? providedItems
|
||||||
|
: parsed.furiganaItems
|
||||||
|
: providedItems.length > 0
|
||||||
|
? providedItems
|
||||||
|
: migrateLegacyFuriganaTextToItems(rawText, legacyFuriganaText);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof value.id === 'string' && value.id ? value.id : fallbackId,
|
||||||
|
rawText,
|
||||||
|
furiganaText: '',
|
||||||
|
furiganaItems,
|
||||||
|
segments: [],
|
||||||
|
notes: sanitizeNotes(value.notes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLines(value: unknown, idPrefix = 'line'): WritingLine[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value.map((line, index) =>
|
||||||
|
normalizeLine((line && typeof line === 'object' ? line : {}) as Partial<WritingLine>, `${idPrefix}-${Date.now()}-${index}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustFuriganaItemsForTextChange(
|
||||||
|
previousText: string,
|
||||||
|
nextText: string,
|
||||||
|
items: FuriganaItem[]
|
||||||
|
): FuriganaItem[] {
|
||||||
|
if (previousText === nextText || items.length === 0) return items;
|
||||||
|
|
||||||
|
let prefixLength = 0;
|
||||||
|
while (
|
||||||
|
prefixLength < previousText.length &&
|
||||||
|
prefixLength < nextText.length &&
|
||||||
|
previousText[prefixLength] === nextText[prefixLength]
|
||||||
|
) {
|
||||||
|
prefixLength++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousSuffix = previousText.length - 1;
|
||||||
|
let nextSuffix = nextText.length - 1;
|
||||||
|
while (
|
||||||
|
previousSuffix >= prefixLength &&
|
||||||
|
nextSuffix >= prefixLength &&
|
||||||
|
previousText[previousSuffix] === nextText[nextSuffix]
|
||||||
|
) {
|
||||||
|
previousSuffix--;
|
||||||
|
nextSuffix--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedLength = Math.max(0, previousSuffix - prefixLength + 1);
|
||||||
|
const insertedLength = Math.max(0, nextSuffix - prefixLength + 1);
|
||||||
|
const delta = insertedLength - removedLength;
|
||||||
|
if (delta === 0) return items;
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
if (item.x < prefixLength) return item;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
x: Math.max(0, item.x + delta),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { defineConfig, Plugin } from 'vite';
|
||||||
|
|
||||||
|
const SAVES_DIR = path.resolve(__dirname, 'saves');
|
||||||
|
|
||||||
|
const ensureSavesDir = async () => {
|
||||||
|
await fs.mkdir(SAVES_DIR, { recursive: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeSaveName = (name: string) => {
|
||||||
|
const cleaned = name
|
||||||
|
.replace(/\.json$/i, '')
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||||
|
.trim();
|
||||||
|
return `${cleaned || `save-${Date.now()}`}.json`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readJsonBody = async (req: any) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendJson = (res: any, statusCode: number, payload: unknown) => {
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const localSavesPlugin = (): Plugin => ({
|
||||||
|
name: 'local-saves-api',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
if (!req.url?.startsWith('/api/saves')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSavesDir();
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const fileNameFromPath = decodeURIComponent(url.pathname.replace(/^\/api\/saves\/?/, ''));
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/saves') {
|
||||||
|
const entries = await fs.readdir(SAVES_DIR);
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.toLowerCase().endsWith('.json'))
|
||||||
|
.map(async (entry) => {
|
||||||
|
const filePath = path.join(SAVES_DIR, entry);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
let lineCount: number | null = null;
|
||||||
|
let title = entry.replace(/\.json$/i, '');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
||||||
|
lineCount = Array.isArray(parsed?.lines) ? parsed.lines.length : null;
|
||||||
|
title = typeof parsed?.title === 'string' && parsed.title.trim() ? parsed.title : title;
|
||||||
|
} catch {
|
||||||
|
lineCount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry,
|
||||||
|
title,
|
||||||
|
savedAt: stat.mtime.toISOString(),
|
||||||
|
size: stat.size,
|
||||||
|
lineCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
files.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime());
|
||||||
|
sendJson(res, 200, { files });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && fileNameFromPath) {
|
||||||
|
const fileName = sanitizeSaveName(fileNameFromPath);
|
||||||
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
sendJson(res, 404, { error: 'Save file not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
sendJson(res, 200, JSON.parse(content));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/saves') {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const fileName = sanitizeSaveName(String(body.name || 'current'));
|
||||||
|
const document = body.document;
|
||||||
|
if (!document || typeof document !== 'object') {
|
||||||
|
sendJson(res, 400, { error: 'Missing document payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(document, null, 2), 'utf-8');
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
sendJson(res, 200, {
|
||||||
|
file: {
|
||||||
|
name: fileName,
|
||||||
|
savedAt: stat.mtime.toISOString(),
|
||||||
|
size: stat.size,
|
||||||
|
lineCount: Array.isArray(document.lines) ? document.lines.length : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && fileNameFromPath) {
|
||||||
|
const fileName = sanitizeSaveName(fileNameFromPath);
|
||||||
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
sendJson(res, 404, { error: 'Save file not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 405, { error: 'Unsupported saves API request' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
sendJson(res, 500, { error: 'Local saves API failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig(() => {
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss(), localSavesPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||||
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user