diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3806efe..f7f0d64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,77 +21,36 @@ import { } from './downloadTextFile' import { randomId } from './randomId' import { webVfs } from './platform/webVfs' - -type Tab = { - id: string - filePath: string | null - content: string - savedContent: string -} - -const BG_KEY = 'myreader-bg-id' -const FONT_KEY = 'myreader-font-id' -const VIEW_KEY = 'myreader-view' -const LEGACY_READ_KEY = 'myreader-read' -const SESSION_KEY = 'myreader-session-v1' -const SIDEBAR_W_KEY = 'myreader-sidebar-w' -const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden' -const DESKTOP_DOWNLOAD_URL = - 'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip' -const SPLIT_KEY = 'myreader-split' -const PREVIEW_W_KEY = 'myreader-preview-max' -const PREVIEW_LINE_KEY = 'myreader-preview-line-height' - -function loadPreviewLineHeight(): string { - const raw = localStorage.getItem(PREVIEW_LINE_KEY) - if (!raw) return '1.5' - const n = Number.parseFloat(raw) - if (!Number.isFinite(n)) return '1.5' - return String(Math.min(3, Math.max(1, n))) -} +import type { Tab } from './types' +import { + clamp, + newTab, + tabLabel, + isDirty, + loadViewMode, + viewModeLabel, + loadPreviewLineHeight, + normalizePathKey +} from './utils' +import { + BG_KEY, + FONT_KEY, + VIEW_KEY, + SIDEBAR_W_KEY, + SIDEBAR_HIDDEN_KEY, + SPLIT_KEY, + PREVIEW_W_KEY, + PREVIEW_LINE_KEY, + SESSION_KEY, + AUTO_SAVE_KEY +} from './constants' +import { TabBar } from './TabBar' +import { StatsPanel } from './StatsPanel' +import { addFileHistory, loadFileHistory, removeFileHistory } from './fileHistory' type SessionTabRow = { path: string | null; content: string; saved: string } type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string } -function clamp(n: number, min: number, max: number): number { - return Math.min(max, Math.max(min, n)) -} - -function normalizePathKey(p: string): string { - return p.replace(/\\/g, '/').toLowerCase() -} - -function newTab(): Tab { - const id = randomId() - return { id, filePath: null, content: '', savedContent: '' } -} - -function tabLabel(t: Tab): string { - if (t.filePath) { - const parts = t.filePath.split(/[/\\]/) - return parts[parts.length - 1] || t.filePath - } - return '未命名' -} - -function isDirty(t: Tab): boolean { - return t.content !== t.savedContent -} - -function loadViewMode(): ViewMode { - const v = localStorage.getItem(VIEW_KEY) as ViewMode | null - if (v === 'edit' || v === 'read' || v === 'hybrid') return v - if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read' - if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit' - return 'read' -} - -function viewModeLabel(m: ViewMode): string { - if (m === 'edit') return '编辑' - if (m === 'read') return '阅读' - return '分栏' -} - export default function App(): React.ReactElement { const api = window.myreader const editorRef = React.useRef(null) @@ -146,6 +105,19 @@ export default function App(): React.ReactElement { const [colorHexInput, setColorHexInput] = React.useState('#dc2626') const [webNotesOpen, setWebNotesOpen] = React.useState(false) const [desktopDownloadOpen, setDesktopDownloadOpen] = React.useState(false) + const [histCtxMenu, setHistCtxMenu] = React.useState<{ + x: number + y: number + path: string + } | null>(null) + const [saveToast, setSaveToast] = React.useState(null) + const [autoSave, setAutoSave] = React.useState( + () => localStorage.getItem(AUTO_SAVE_KEY) === '1' + ) + const [statusText, setStatusText] = React.useState('') + const [searchVisible, setSearchVisible] = React.useState(false) + const [searchQuery, setSearchQuery] = React.useState('') + const [dragOver, setDragOver] = React.useState(false) const readPreviewOuterRef = React.useRef(null) const readPreviewArticleRef = React.useRef(null) const hybridPreviewRef = React.useRef(null) @@ -286,6 +258,30 @@ export default function App(): React.ReactElement { localStorage.setItem(PREVIEW_W_KEY, String(previewMaxWidth)) }, [previewMaxWidth]) + React.useEffect(() => { + localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0') + }, [autoSave]) + + React.useEffect(() => { + if (!activeDoc) { + setStatusText('') + return + } + setStatusText('') + }, [activeDoc, activeLineCount]) + + React.useEffect(() => { + const onDoc = (): void => { + setHistCtxMenu(null) + } + window.addEventListener('mousedown', onDoc) + window.addEventListener('scroll', onDoc, true) + return () => { + window.removeEventListener('mousedown', onDoc) + window.removeEventListener('scroll', onDoc, true) + } + }, []) + React.useEffect(() => { if (textPrompt) setTextPromptInput(textPrompt.defaultValue) }, [textPrompt]) @@ -305,6 +301,12 @@ export default function App(): React.ReactElement { if (viewMode !== 'hybrid') setHybridLinePulse(null) }, [viewMode]) + React.useEffect(() => { + if (!saveToast) return + const timer = window.setTimeout(() => setSaveToast(null), 2800) + return () => clearTimeout(timer) + }, [saveToast]) + const requestText = React.useCallback((title: string, defaultValue: string) => { return new Promise((resolve) => { setTextPrompt({ title, defaultValue, resolve }) @@ -452,17 +454,10 @@ export default function App(): React.ReactElement { [activeDoc, activeTab?.filePath] ) - const pickLocalImage = React.useCallback(async (): Promise => { - if (!api) return - const overlay = document.querySelector('.modal-overlay') - if (overlay) overlay.style.visibility = 'hidden' - try { - const r = await api.openImageDialog() - if (!r.canceled && r.url) setImageUrlInput(r.url) - } finally { - if (overlay) overlay.style.visibility = '' - } - }, [api]) + const recordFileOpen = React.useCallback((path: string): void => { + if (!/\.(md|markdown)$/i.test(path)) return + addFileHistory(loadFileHistory(), path) + }, []) const updateActive = React.useCallback( (fn: (t: Tab) => Tab): void => { @@ -540,6 +535,7 @@ export default function App(): React.ReactElement { ) if (existing) { setActiveId(existing.id) + recordFileOpen(r.path) return } setViewMode('read') @@ -551,15 +547,8 @@ export default function App(): React.ReactElement { } setTabs((prev) => [...prev, nt]) setActiveId(nt.id) - }, [api]) - - const [saveToast, setSaveToast] = React.useState(null) - - React.useEffect(() => { - if (!saveToast) return - const timer = window.setTimeout(() => setSaveToast(null), 2800) - return () => clearTimeout(timer) - }, [saveToast]) + recordFileOpen(r.path) + }, [api, recordFileOpen]) const markTabSaved = React.useCallback((tabId: string, filePath?: string | null): void => { setTabs((prev) => @@ -641,6 +630,18 @@ export default function App(): React.ReactElement { await saveTabById(activeId) }, [activeId, saveTabById]) + React.useEffect(() => { + if (!autoSave || !activeId) return + const t = tabsRef.current.find((x) => x.id === activeId) + if (!t || !t.filePath || !isDirty(t)) return + const timer = window.setTimeout(() => { + void saveTabById(activeId) + setStatusText('已自动保存') + window.setTimeout(() => setStatusText(''), 2000) + }, 3000) + return () => window.clearTimeout(timer) + }, [activeDoc, autoSave, activeId, saveTabById]) + const saveActiveAs = React.useCallback(async (): Promise => { if (!api) return const t = tabs.find((x) => x.id === activeId) @@ -701,6 +702,19 @@ export default function App(): React.ReactElement { [removeTabById] ) + const closeOtherTabs = React.useCallback( + (tabId: string): void => { + setTabs((prev) => prev.filter((t) => t.id === tabId)) + setActiveId(tabId) + }, + [] + ) + + const closeAllTabs = React.useCallback((): void => { + setTabs([]) + setActiveId('') + }, []) + const newTabAction = React.useCallback((): void => { setViewMode('edit') const nt = newTab() @@ -782,96 +796,129 @@ export default function App(): React.ReactElement { [previewMaxWidth] ) - const handlersRef = React.useRef({ - openFileDialog: async (): Promise => {}, - saveActive: async (): Promise => {}, - saveActiveAs: async (): Promise => {}, - newTabAction: (): void => {}, - closeTabAction: (): void => {}, - tabNext: (): void => {}, - tabPrev: (): void => {} - }) - handlersRef.current = { - openFileDialog, - saveActive, - saveActiveAs, - newTabAction, - closeTabAction, - tabNext, - tabPrev - } + const handleDrop = React.useCallback( + async (e: React.DragEvent): Promise => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + const files = e.dataTransfer.files + if (!files || files.length === 0) return + for (let i = 0; i < files.length; i++) { + const file = files[i] + if (!file) continue + if (/\.(md|markdown|mdown|mkd|txt)$/i.test(file.name)) { + try { + const content = await file.text() + const filePath: string | null = (file as { path?: string }).path ?? file.name + if (filePath) { + const key = normalizePathKey(filePath) + const existing = tabsRef.current.find( + (t) => t.filePath && normalizePathKey(t.filePath) === key + ) + if (existing) { + setActiveId(existing.id) + continue + } + } + const nt: Tab = { + id: randomId(), + filePath, + content, + savedContent: content + } + setTabs((prev) => [...prev, nt]) + setActiveId(nt.id) + setViewMode('read') + } catch { + /* ignore */ + } + } + } + }, + [] + ) + + const handleDragOver = React.useCallback((e: React.DragEvent): void => { + e.preventDefault() + e.stopPropagation() + setDragOver(true) + }, []) + + const handleDragLeave = React.useCallback((e: React.DragEvent): void => { + e.preventDefault() + const target = e.currentTarget as HTMLElement | null + const related = e.relatedTarget as HTMLElement | null + if (target && related && target.contains(related)) return + setDragOver(false) + }, []) + + const exportHtml = React.useCallback((): void => { + if (!activeDoc.trim()) return + const html = ` + + + + +${activeTab?.filePath ? tabLabel(activeTab) : '导出文档'} + + + +${previewHtml} + +` + const blob = new Blob([html], { type: 'text/html;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${activeTab?.filePath ? tabLabel(activeTab).replace(/\.\w+$/, '') : 'document'}.html` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + setStatusText('已导出 HTML') + window.setTimeout(() => setStatusText(''), 2000) + }, [activeDoc, activeTab, previewHtml]) React.useEffect(() => { - if (!api) return - const unsubClose = api.onRequestClose(() => { - try { - const anyDirty = tabsRef.current.some(isDirty) - if (!anyDirty) { - api.allowClose() - return - } - const ok = window.confirm('有未保存的标签,确定退出并丢弃更改?') - if (ok) api.allowClose() - } catch (e) { - console.error('[MyReader] request-close handler', e) - api.allowClose() + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 's' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + void saveActive() + return + } + if (e.key === 'f' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + setSearchVisible((v) => !v) + return + } + if (e.key === 'n' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + newTabAction() + return + } + if (e.key === 'w' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + closeTabAction() + return + } + if (e.key === 'Tab' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + if (e.shiftKey) tabPrev() + else tabNext() + return } - }) - const unsubMenu = api.onMenuAction((action) => { - void (async () => { - try { - const h = handlersRef.current - switch (action) { - case 'file-open': - await h.openFileDialog() - break - case 'file-save': - await h.saveActive() - break - case 'file-save-as': - await h.saveActiveAs() - break - case 'tab-new': - h.newTabAction() - break - case 'tab-close': - h.closeTabAction() - break - case 'tab-next': - h.tabNext() - break - case 'tab-prev': - h.tabPrev() - break - case 'toggle-read': - cycleViewMode() - break - case 'help-about': - window.alert( - " Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者:黄志军 \n 支持与协助(wx):liaofan199404" - ) - break - default: - break - } - } catch (e) { - console.error('[MyReader] menu action', action, e) - } - })() - }) - return () => { - unsubClose() - unsubMenu() } - }, [api, cycleViewMode]) - - if (!api) { - return ( -
-

浏览器文件接口未就绪,请刷新页面重试。

-
- ) - } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [saveActive, newTabAction, closeTabAction, tabNext, tabPrev]) const renderPreviewArticle = (articleRef?: React.Ref): React.ReactElement => ( api.resolveRelativePath(base, href)} + resolveRelativePath={async (base, href) => { + if (api) return api.resolveRelativePath(base, href) + try { + const url = new URL(href, base) + return url.href + } catch { + return href + } + }} onOpenMarkdown={(path, anchorId) => openPathInTab(path, anchorId)} - onOpenExternal={(url) => api.openExternal(url)} + onOpenExternal={(url) => { + if (api) api.openExternal(url) + else window.open(url, '_blank', 'noopener') + }} /> ) @@ -957,7 +1015,7 @@ export default function App(): React.ReactElement { onMouseDown={(e) => e.stopPropagation()} >

{textPrompt.title}

- {textPrompt.hint ?

{textPrompt.hint}

: null} + {textPrompt.hint ?

{textPrompt.hint}

: null} setTextPromptInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { - e.preventDefault() textPrompt.resolve(textPromptInput) setTextPrompt(null) } if (e.key === 'Escape') { - e.preventDefault() textPrompt.resolve(null) setTextPrompt(null) } }} autoFocus + spellCheck={false} />
-
@@ -1050,9 +1103,7 @@ export default function App(): React.ReactElement { type="button" className="primary" onClick={() => { - const url = imageUrlInput.trim() - if (!url) return - imageInsert.resolve({ url, alt: imageAltInput.trim() || '图片' }) + imageInsert.resolve({ url: imageUrlInput, alt: imageAltInput }) setImageInsert(null) }} > @@ -1152,149 +1203,40 @@ export default function App(): React.ReactElement {
) : null} - {webNotesOpen ? ( + {histCtxMenu ? (
setWebNotesOpen(false)} + className="ctx-menu" + style={{ + left: Math.min(histCtxMenu.x, window.innerWidth - 140), + top: Math.min(histCtxMenu.y, window.innerHeight - 100) + }} + onMouseDown={(e) => e.stopPropagation()} > -
e.stopPropagation()} + -
-
- - ) : null} - - {desktopDownloadOpen ? ( -
setDesktopDownloadOpen(false)} - > -
e.stopPropagation()} - > -

- 桌面版下载说明 -

-
-

- 为什么推荐桌面版? - Web 版在 HTTP 站点上无法直接写回您电脑上的原文件,保存时往往只能触发下载;文件夹浏览、稳定保存等能力也受浏览器限制。桌面版(Electron)在 - Windows 本地运行,可像普通编辑器一样打开、保存 Markdown,体验更接近完整版 MyReader。 -

-

- 如何安装? -

-
    -
  1. 点击下方链接下载 ZIP 压缩包;
  2. -
  3. 解压到任意目录(路径尽量不要含特殊字符);
  4. -
  5. 进入解压目录,运行其中的可执行程序(一般为 .exe);
  6. -
  7. 首次使用可通过菜单「打开」选择本地 .md 文件进行阅读与编辑。
  8. -
-

- 下载地址: -
- - {DESKTOP_DOWNLOAD_URL} - -

-

- 若链接无法打开,请检查网络或联系站点管理员。桌面版仅支持 Windows;Mac / Linux 请继续使用 Web 版。 -

-
-
- - 前往下载 - - -
-
+ 从历史中移除 +
) : null}
- {tabs.map((t) => ( -
- - -
- ))} +
- {saveToast ? ( -
- {saveToast} -
- ) : null} @@ -1313,6 +1255,22 @@ export default function App(): React.ReactElement { + + @@ -1327,31 +1285,31 @@ export default function App(): React.ReactElement { @@ -1359,9 +1317,9 @@ export default function App(): React.ReactElement { 背景 @@ -1370,8 +1328,11 @@ export default function App(): React.ReactElement {
{!sidebarHidden ? ( - <> -
) : (
- 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
📖
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
) ) : null} @@ -1431,7 +1467,13 @@ export default function App(): React.ReactElement { /> ) : (
- 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
✏️
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
) ) : null} @@ -1479,12 +1521,104 @@ export default function App(): React.ReactElement {
) : (
- 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
📝
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
) ) : null} + + {activeTab ? ( + {}} + /> + ) : null} + + {statusText ?
{statusText}
: null} + + {saveToast ? ( +
{saveToast}
+ ) : null} + + {dragOver ? ( +
+
+ 📂 + 释放以打开 Markdown 文件 +
+
+ ) : null} + + {webNotesOpen ? ( +
setWebNotesOpen(false)}> +
e.stopPropagation()}> +

Web 版使用说明

+
+ {`MyReader Web 版说明 + +由于浏览器环境的限制,Web 版与桌面版有以下差异: + +1. 文件操作 + - 需要通过「打开」按钮或拖放 .md 文件来导入文档 + - 保存时会触发文件下载(浏览器无法直接写回原文件) + - 支持 File System Access API 的浏览器可获得更好的保存体验 + +2. 功能限制 + - 不支持文件系统浏览(侧栏仅显示大纲) + - 不支持文件重命名/删除 + - 不支持文件夹操作 + +3. 快捷键 + - Ctrl+S / Cmd+S:保存 + - Ctrl+F / Cmd+F:搜索 + - Ctrl+N / Cmd+N:新建标签 + - Ctrl+W / Cmd+W:关闭标签 + - Ctrl+Tab / Cmd+Tab:切换标签 + +4. 数据存储 + - 所有配置保存在浏览器 localStorage 中 + - 清除浏览器数据会导致配置丢失`} +
+
+ +
+
+
+ ) : null} + + {desktopDownloadOpen ? ( +
setDesktopDownloadOpen(false)}> +
e.stopPropagation()}> +

桌面版下载

+
+

桌面版提供完整的文件系统访问能力,推荐使用。

+

下载地址:

+ + {DESKTOP_DOWNLOAD_URL} + +
+
+ +
+
+
+ ) : null} ) } + +const DESKTOP_DOWNLOAD_URL = + 'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip' \ No newline at end of file diff --git a/src/EditorPane.tsx b/src/EditorPane.tsx index f369391..c8a9ff2 100644 --- a/src/EditorPane.tsx +++ b/src/EditorPane.tsx @@ -13,6 +13,8 @@ import { export type MarkdownEditorHandle = { applyAction: (id: string) => void | Promise + searchText: (query: string) => boolean + getView: () => EditorView | null } type Props = { @@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef( requestImageDetails: requestImageDetailsRef.current, requestColor: requestColorRef.current }) - } + }, + searchText: (query: string): boolean => { + const view = viewRef.current + if (!view || !query.trim()) return false + const doc = view.state.doc.toString() + const idx = doc.toLowerCase().indexOf(query.toLowerCase()) + if (idx >= 0) { + view.dispatch({ + selection: { anchor: idx, head: idx + query.length }, + scrollIntoView: true + }) + return true + } + return false + }, + getView: (): EditorView | null => viewRef.current }), [] ) @@ -347,9 +364,10 @@ export const EditorPane = React.forwardRef( if (!view) return const cur = view.state.doc.toString() if (cur !== value) { + const main = view.state.selection.main view.dispatch({ changes: { from: 0, to: cur.length, insert: value }, - selection: { anchor: 0 }, + selection: main.empty ? { anchor: main.from } : { anchor: main.from, head: main.to }, scrollIntoView: true }) } diff --git a/src/StatsPanel.tsx b/src/StatsPanel.tsx new file mode 100644 index 0000000..1eea7bf --- /dev/null +++ b/src/StatsPanel.tsx @@ -0,0 +1,183 @@ +import * as React from 'react' + +type Props = { + doc: string + width: number + onResizeStart: (e: React.MouseEvent) => void +} + +function wordCount(s: string): number { + return s.replace(/[\u4e00-\u9fff]/g, ' $& ').split(/[\s]+/).filter(Boolean).length +} + +function codeBlockCount(s: string): number { + return Math.floor(((s.match(/```/g) || []).length) / 2) +} + +function imageCount(s: string): number { + return (s.match(/!\[.*?\]\(.*?\)/g) || []).length +} + +function linkCount(s: string): number { + return (s.match(/\[.*?\]\(.*?\)/g) || []).length +} + +function tableCount(s: string): number { + return (s.match(/^\|.+\|.+\|/gm) || []).length +} + +function blockquoteCount(s: string): number { + return (s.match(/^>\s/gm) || []).length +} + +function listItemCount(s: string): number { + return (s.match(/^(?:[-*+]|\d+\.)\s/gm) || []).length +} + +function mermaidCount(s: string): number { + return (s.match(/```mermaid\n?/gi) || []).length +} + +function headingCount(s: string): number { + return (s.match(/^#{1,6}\s/gm) || []).length +} + +function boldCount(s: string): number { + return (s.match(/\*\*[^*]+\*\*/g) || []).length +} + +function italicCount(s: string): number { + return (s.match(/(? t.includes('[x]')).length + return { done, total: tasks.length } +} + +function docSize(s: string): string { + const sizeKB = new Blob([s]).size / 1024 + return sizeKB < 1 ? '<1 KB' : `${Math.round(sizeKB)} KB` +} + +export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactElement { + return ( + <> +
+ + + ) +} \ No newline at end of file diff --git a/src/TabBar.tsx b/src/TabBar.tsx new file mode 100644 index 0000000..b9de1bf --- /dev/null +++ b/src/TabBar.tsx @@ -0,0 +1,115 @@ +import * as React from 'react' +import type { Tab } from './types' +import { tabLabel, isDirty } from './utils' + +type Props = { + tabs: Tab[] + activeId: string + onSelect: (id: string) => void + onClose: (id: string) => void + onCloseOthers: (id: string) => void + onCloseAll: () => void +} + +export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll }: Props): React.ReactElement { + const barRef = React.useRef(null) + + React.useEffect(() => { + const el = barRef.current + if (!el) return + const active = el.querySelector('.tab.active') as HTMLElement | null + if (active) { + active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + } + }, [activeId]) + + return ( +
+ {tabs.map((t) => ( + onSelect(t.id)} + onClose={() => onClose(t.id)} + onCloseOthers={() => onCloseOthers(t.id)} + onCloseAll={onCloseAll} + /> + ))} +
+ ) +} + +type TabItemProps = { + tab: Tab + active: boolean + onSelect: () => void + onClose: () => void + onCloseOthers: () => void + onCloseAll: () => void +} + +function TabItem({ tab, active, onSelect, onClose, onCloseOthers, onCloseAll }: TabItemProps): React.ReactElement { + const [menuOpen, setMenuOpen] = React.useState(false) + const [menuPos, setMenuPos] = React.useState({ x: 0, y: 0 }) + const menuRef = React.useRef(null) + + React.useEffect(() => { + if (!menuOpen) return + const close = (e: MouseEvent): void => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + const closeOnScroll = (): void => setMenuOpen(false) + document.addEventListener('mousedown', close) + window.addEventListener('scroll', closeOnScroll, true) + return () => { + document.removeEventListener('mousedown', close) + window.removeEventListener('scroll', closeOnScroll, true) + } + }, [menuOpen]) + + const handleContextMenu = (e: React.MouseEvent): void => { + e.preventDefault() + e.stopPropagation() + setMenuPos({ + x: Math.min(e.clientX, window.innerWidth - 140), + y: Math.min(e.clientY, window.innerHeight - 140) + }) + setMenuOpen(true) + } + + return ( +
+ + + {menuOpen ? ( +
+ + + +
+ ) : null} +
+ ) +} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..525b7c5 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,11 @@ +export const BG_KEY = 'myreader-bg-id' +export const FONT_KEY = 'myreader-font-id' +export const VIEW_KEY = 'myreader-view' +export const LEGACY_READ_KEY = 'myreader-read' +export const SIDEBAR_W_KEY = 'myreader-sidebar-w' +export const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden' +export const SPLIT_KEY = 'myreader-split' +export const PREVIEW_W_KEY = 'myreader-preview-max' +export const SESSION_KEY = 'myreader-session-v1' +export const PREVIEW_LINE_KEY = 'myreader-preview-line-height' +export const AUTO_SAVE_KEY = 'myreader-auto-save' \ No newline at end of file diff --git a/src/fileHistory.ts b/src/fileHistory.ts new file mode 100644 index 0000000..953b02f --- /dev/null +++ b/src/fileHistory.ts @@ -0,0 +1,35 @@ +const HISTORY_KEY = 'myreader-file-history-v1' +const MAX_ITEMS = 80 + +export function loadFileHistory(): string[] { + try { + const raw = localStorage.getItem(HISTORY_KEY) + if (!raw) return [] + const data = JSON.parse(raw) as { paths?: unknown } + if (!Array.isArray(data.paths)) return [] + return data.paths.filter((p): p is string => typeof p === 'string' && p.length > 0) + } catch { + return [] + } +} + +export function saveFileHistory(paths: string[]): void { + localStorage.setItem(HISTORY_KEY, JSON.stringify({ v: 1, paths })) +} + +export function addFileHistory(paths: string[], filePath: string): string[] { + const next = [filePath, ...paths.filter((p) => p !== filePath)].slice(0, MAX_ITEMS) + saveFileHistory(next) + return next +} + +export function removeFileHistory(paths: string[], filePath: string): string[] { + const next = paths.filter((p) => p !== filePath) + saveFileHistory(next) + return next +} + +export function historyFileName(path: string): string { + const parts = path.split(/[/\\]/) + return parts[parts.length - 1] || path +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 0a54d31..1f1123d 100644 --- a/src/index.css +++ b/src/index.css @@ -153,18 +153,6 @@ html[data-bg='22'] .app-root { isolation: isolate; } -.save-toast { - flex: 1 1 100%; - margin: 0 0 4px; - padding: 8px 10px; - border-radius: 6px; - border: 1px solid var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--panel-bg)); - color: var(--text); - font-size: 13px; - line-height: 1.45; -} - .toolbar button, .sidebar-tabs button, .path-row button, @@ -891,18 +879,6 @@ html[data-bg='22'] .app-root { overflow: hidden; } -.content-empty { - flex: 1; - min-height: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 32px; - color: var(--muted); - font-size: 15px; - text-align: center; -} - .editor-host { flex: 1; min-height: 0; @@ -1254,6 +1230,262 @@ html[data-bg='22'] .app-root { } } +/* ── Search Bar ── */ +.search-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + background: var(--panel-bg); + z-index: 20; +} + +.search-input { + flex: 1; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + font: inherit; + font-size: 13px; +} + +.search-input:focus { + outline: none; + border-color: var(--accent); +} + +.search-close-btn { + flex: 0 0 auto; + padding: 4px 8px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; +} + +.search-close-btn:hover { + color: var(--text); +} + +/* ── Status Bar ── */ +.status-bar { + flex: 0 0 auto; + padding: 4px 12px; + font-size: 12px; + color: var(--muted); + border-top: 1px solid var(--border); + background: var(--panel-bg); + text-align: center; +} + +/* ── Save Toast ── */ +.save-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 2000; + padding: 10px 20px; + border-radius: 8px; + border: 1px solid var(--accent); + background: var(--panel-bg); + color: var(--text); + font-size: 14px; + box-shadow: var(--preview-shadow); + animation: save-toast-in 0.25s ease-out; + max-width: min(90vw, 480px); + text-align: center; +} + +@keyframes save-toast-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(12px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* ── Content Empty State ── */ +.content-empty { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px; + color: var(--muted); + font-size: 15px; + text-align: center; +} + +.content-empty-icon { + font-size: 48px; + line-height: 1; +} + +.content-empty-hint { + font-size: 13px; + color: color-mix(in srgb, var(--muted) 80%, transparent); +} + +.content-drop-zone { + margin-top: 16px; + padding: 24px 32px; + border: 2px dashed var(--border); + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 14px; +} + +.content-drop-zone-icon { + font-size: 32px; + line-height: 1; +} + +/* ── Drag Over State ── */ +.content-drag-over { + outline: 3px dashed var(--accent); + outline-offset: -3px; + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +/* ── Global Drop Overlay ── */ +.global-drop-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.global-drop-overlay-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 48px; + border: 3px dashed var(--accent); + border-radius: 16px; + background: var(--panel-bg); + box-shadow: var(--preview-shadow); +} + +.global-drop-overlay-icon { + font-size: 56px; + line-height: 1; +} + +.global-drop-overlay-text { + font-size: 18px; + color: var(--text); + font-weight: 600; +} + +/* ── Stats Panel ── */ +.stats-resize { + width: 5px; + cursor: col-resize; + flex: 0 0 5px; + background: transparent; +} + +.stats-resize:hover { + background: var(--accent); + opacity: 0.35; +} + +.stats-panel { + flex: 0 0 auto; + align-self: stretch; + border-left: 1px solid var(--border); + background: var(--panel-bg); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.stats-panel-header { + padding: 10px 12px; + font-size: 13px; + font-weight: 600; + color: var(--text); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 6px; +} + +.stats-panel-header-icon { + font-size: 16px; + line-height: 1; +} + +.stats-panel-body { + padding: 8px 10px; + flex: 1; +} + +.stats-panel-section { + margin-bottom: 14px; +} + +.stats-panel-section-title { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; + padding-bottom: 4px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); +} + +.stats-panel-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.stats-panel-item { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 8px; + border-radius: 6px; + background: var(--btn-bg); +} + +.stats-panel-value { + font-size: 15px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.stats-panel-label { + font-size: 10px; + color: var(--muted); + font-weight: 500; +} + .modal-overlay { position: fixed; inset: 0; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6408863 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,9 @@ +export type Tab = { + id: string + filePath: string | null + content: string + savedContent: string +} + +export type SessionTabRow = { path: string | null; content: string; saved: string } +export type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string } \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..548b805 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,51 @@ +import type { Tab } from './types' +import { PREVIEW_LINE_KEY, LEGACY_READ_KEY, VIEW_KEY } from './constants' +import type { ViewMode } from './appearance' +import { randomId } from './randomId' + +export function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)) +} + +export function normalizePathKey(p: string): string { + return p.replace(/\\/g, '/').toLowerCase() +} + +export function newTab(): Tab { + const id = randomId() + return { id, filePath: null, content: '', savedContent: '' } +} + +export function tabLabel(t: Tab): string { + if (t.filePath) { + const parts = t.filePath.split(/[/\\]/) + return parts[parts.length - 1] || t.filePath + } + return '未命名' +} + +export function isDirty(t: Tab): boolean { + return t.content !== t.savedContent +} + +export function loadViewMode(): ViewMode { + const v = localStorage.getItem(VIEW_KEY) as ViewMode | null + if (v === 'edit' || v === 'read' || v === 'hybrid') return v + if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read' + if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit' + return 'read' +} + +export function viewModeLabel(m: ViewMode): string { + if (m === 'edit') return '编辑' + if (m === 'read') return '阅读' + return '分栏' +} + +export function loadPreviewLineHeight(): string { + const raw = localStorage.getItem(PREVIEW_LINE_KEY) + if (!raw) return '1.5' + const n = Number.parseFloat(raw) + if (!Number.isFinite(n)) return '1.5' + return String(Math.min(3, Math.max(1, n))) +} \ No newline at end of file