初始化web版本MyReader
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
import { randomId } from '../randomId'
|
||||
import type { DirEntry, ListDirResult } from '../types/ipc-types'
|
||||
import {
|
||||
dirnamePath,
|
||||
fileUrlToPath,
|
||||
isAbsDiskPath,
|
||||
joinAbsPath,
|
||||
normalizeAbsPath,
|
||||
pathBasename,
|
||||
pathsEqual,
|
||||
resolvePathFromBaseFile
|
||||
} from '../pathLite'
|
||||
|
||||
const MD_EXT = new Set(['.md', '.markdown', '.mdown', '.mkd'])
|
||||
const IMAGE_EXT = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.bmp',
|
||||
'.ico',
|
||||
'.avif'
|
||||
])
|
||||
|
||||
function isMarkdownFile(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
const dot = lower.lastIndexOf('.')
|
||||
if (dot < 0) return false
|
||||
return MD_EXT.has(lower.slice(dot))
|
||||
}
|
||||
|
||||
function isImageFile(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
const dot = lower.lastIndexOf('.')
|
||||
if (dot < 0) return false
|
||||
return IMAGE_EXT.has(lower.slice(dot))
|
||||
}
|
||||
|
||||
function parentPath(p: string): string {
|
||||
const n = normalizeAbsPath(p).replace(/\/+$/, '')
|
||||
const i = n.lastIndexOf('/')
|
||||
if (i <= 0) return '/'
|
||||
const d = n.slice(0, i)
|
||||
if (/^[A-Za-z]:$/.test(d)) return `${d}/`
|
||||
return d || '/'
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
const n = p.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const i = n.lastIndexOf('/')
|
||||
return i >= 0 ? n.slice(i + 1) : n
|
||||
}
|
||||
|
||||
type DirEntries = FileSystemDirectoryHandle & {
|
||||
entries: () => AsyncIterableIterator<[string, FileSystemHandle]>
|
||||
}
|
||||
|
||||
async function* iterateDir(
|
||||
dir: FileSystemDirectoryHandle
|
||||
): AsyncGenerator<[string, FileSystemHandle]> {
|
||||
const d = dir as DirEntries
|
||||
for await (const entry of d.entries()) {
|
||||
yield entry
|
||||
}
|
||||
}
|
||||
|
||||
/** 浏览器端:File System Access 句柄 + 本机绝对路径(用户确认)映射。 */
|
||||
class WebVfs {
|
||||
private dirHandles = new Map<string, FileSystemDirectoryHandle>()
|
||||
private fileHandles = new Map<string, FileSystemFileHandle>()
|
||||
private memoryFiles = new Map<string, File>()
|
||||
private blobUrlByPath = new Map<string, string>()
|
||||
private defaultDir = ''
|
||||
|
||||
getDefaultDirectory(): string {
|
||||
return this.defaultDir
|
||||
}
|
||||
|
||||
setDefaultDirectory(p: string): void {
|
||||
this.defaultDir = normalizeAbsPath(p)
|
||||
}
|
||||
|
||||
hasPath(path: string): boolean {
|
||||
const n = normalizeAbsPath(path)
|
||||
return this.fileHandles.has(n) || this.memoryFiles.has(n) || this.dirHandles.has(n)
|
||||
}
|
||||
|
||||
/** 仅通过 <input type="file"> 打开、无 FileHandle,无法写回用户磁盘原文件。 */
|
||||
isMemoryOnlyPath(path: string): boolean {
|
||||
const n = normalizeAbsPath(path)
|
||||
if (this.fileHandles.has(n)) return false
|
||||
return this.memoryFiles.has(n)
|
||||
}
|
||||
|
||||
getCachedBlobUrlPublic(absPath: string): string | undefined {
|
||||
return this.getCachedBlobUrl(absPath)
|
||||
}
|
||||
|
||||
listKnownImagePaths(): string[] {
|
||||
return Array.from(this.blobUrlByPath.keys())
|
||||
}
|
||||
|
||||
async registerDirectory(root: FileSystemDirectoryHandle, absRoot: string): Promise<string> {
|
||||
const rootPath = normalizeAbsPath(absRoot)
|
||||
this.dirHandles.set(rootPath, root)
|
||||
this.defaultDir = rootPath
|
||||
await this.indexDirectory(root, rootPath, true)
|
||||
return rootPath
|
||||
}
|
||||
|
||||
private async indexDirectory(
|
||||
dir: FileSystemDirectoryHandle,
|
||||
absPath: string,
|
||||
deep: boolean
|
||||
): Promise<void> {
|
||||
for await (const [name, handle] of iterateDir(dir)) {
|
||||
if (name.startsWith('.')) continue
|
||||
const full = joinAbsPath(absPath, name)
|
||||
if (handle.kind === 'directory') {
|
||||
const sub = handle as FileSystemDirectoryHandle
|
||||
this.dirHandles.set(full, sub)
|
||||
if (deep) await this.indexDirectory(sub, full, true)
|
||||
} else if (handle.kind === 'file') {
|
||||
const fh = handle as FileSystemFileHandle
|
||||
if (isMarkdownFile(name)) {
|
||||
this.fileHandles.set(full, fh)
|
||||
}
|
||||
if (isImageFile(name)) {
|
||||
void this.cacheBlobUrl(full, fh)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheBlobUrl(absPath: string, handle: FileSystemFileHandle): Promise<string> {
|
||||
const key = normalizeAbsPath(absPath)
|
||||
const prev = this.blobUrlByPath.get(key)
|
||||
if (prev) return prev
|
||||
const file = await handle.getFile()
|
||||
const url = URL.createObjectURL(file)
|
||||
this.blobUrlByPath.set(key, url)
|
||||
return url
|
||||
}
|
||||
|
||||
/** 将图片 src(含 file://)解析为可显示的 blob URL。 */
|
||||
async resolveImageToBlobUrl(src: string, baseFilePath: string | null): Promise<string | null> {
|
||||
const s = src.trim()
|
||||
if (!s) return null
|
||||
if (/^(blob:|data:|https?:)/i.test(s)) return s
|
||||
|
||||
const base = baseFilePath?.trim() ? normalizeAbsPath(baseFilePath) : null
|
||||
const absPath = this.resolveImageAbsPath(s, base)
|
||||
if (!absPath) return null
|
||||
|
||||
const hit = await this.ensureBlobUrl(absPath)
|
||||
if (hit) return hit
|
||||
|
||||
if (base) {
|
||||
const baseDir = dirnamePath(base)
|
||||
const imgDir = dirnamePath(absPath)
|
||||
if (pathsEqual(baseDir, imgDir)) {
|
||||
const sibling = joinAbsPath(baseDir, pathBasename(absPath))
|
||||
if (!pathsEqual(sibling, absPath)) {
|
||||
const alt = await this.ensureBlobUrl(sibling)
|
||||
if (alt) return alt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.ensureBlobUrlByBasename(pathBasename(absPath))
|
||||
}
|
||||
|
||||
resolveImageAbsPath(src: string, baseFilePath: string | null): string | null {
|
||||
const s = src.trim()
|
||||
if (/^file:/i.test(s)) return normalizeAbsPath(fileUrlToPath(s))
|
||||
if (isAbsDiskPath(s)) return normalizeAbsPath(s)
|
||||
if (baseFilePath) return normalizeAbsPath(resolvePathFromBaseFile(baseFilePath, s))
|
||||
return null
|
||||
}
|
||||
|
||||
private lookupFileHandle(
|
||||
absPath: string
|
||||
): { key: string; handle: FileSystemFileHandle } | null {
|
||||
const key = normalizeAbsPath(absPath)
|
||||
const direct = this.fileHandles.get(key)
|
||||
if (direct) return { key, handle: direct }
|
||||
|
||||
for (const [k, h] of this.fileHandles) {
|
||||
if (pathsEqual(k, key)) return { key: k, handle: h }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private lookupImageByBasename(fileName: string): { key: string; handle: FileSystemFileHandle } | null {
|
||||
const want = fileName.toLowerCase()
|
||||
let found: { key: string; handle: FileSystemFileHandle } | null = null
|
||||
for (const [k, h] of this.fileHandles) {
|
||||
if (!isImageFile(pathBasename(k))) continue
|
||||
if (pathBasename(k).toLowerCase() !== want) continue
|
||||
if (!found) found = { key: k, handle: h }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private async ensureBlobUrl(absPath: string): Promise<string | null> {
|
||||
const key = normalizeAbsPath(absPath)
|
||||
const cached = this.getCachedBlobUrl(key)
|
||||
if (cached) return cached
|
||||
|
||||
const located = this.lookupFileHandle(key)
|
||||
let handle = located?.handle
|
||||
let storeKey = located?.key ?? key
|
||||
|
||||
if (!handle) {
|
||||
handle = (await this.ensureFileHandle(key)) ?? undefined
|
||||
storeKey = key
|
||||
}
|
||||
if (!handle) return null
|
||||
return this.cacheBlobUrl(storeKey, handle)
|
||||
}
|
||||
|
||||
private async ensureBlobUrlByBasename(fileName: string): Promise<string | null> {
|
||||
const located = this.lookupImageByBasename(fileName)
|
||||
if (!located) return null
|
||||
return this.ensureBlobUrl(located.key)
|
||||
}
|
||||
|
||||
private getCachedBlobUrl(absPath: string): string | undefined {
|
||||
const key = normalizeAbsPath(absPath)
|
||||
const direct = this.blobUrlByPath.get(key)
|
||||
if (direct) return direct
|
||||
for (const [k, url] of this.blobUrlByPath) {
|
||||
if (pathsEqual(k, key)) return url
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async registerOpenFile(handle: FileSystemFileHandle, absPath: string): Promise<string> {
|
||||
const normalized = normalizeAbsPath(absPath)
|
||||
this.fileHandles.set(normalized, handle)
|
||||
this.memoryFiles.delete(normalized)
|
||||
this.defaultDir = dirnamePath(normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** 为「仅打开文件」生成稳定虚拟路径(无需用户填写磁盘绝对路径)。 */
|
||||
virtualPathForPickedFile(fileName: string): string {
|
||||
const id = randomId()
|
||||
const safe =
|
||||
fileName.replace(/[/\\<>|:*?"\u0000-\u001f]/g, '_').trim() || 'note.md'
|
||||
return normalizeAbsPath(`/__web__/open/${id}/${safe}`)
|
||||
}
|
||||
|
||||
registerMemoryFile(absPath: string, file: File): string {
|
||||
const normalized = normalizeAbsPath(absPath)
|
||||
this.memoryFiles.set(normalized, file)
|
||||
return normalized
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const normalized = normalizeAbsPath(path)
|
||||
const mem = this.memoryFiles.get(normalized)
|
||||
if (mem) return mem.text()
|
||||
let h = this.fileHandles.get(normalized)
|
||||
if (!h) {
|
||||
h = (await this.ensureFileHandle(normalized)) ?? undefined
|
||||
}
|
||||
if (!h) throw new Error(`文件不存在或未授权:${normalized}`)
|
||||
const file = await h.getFile()
|
||||
return file.text()
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
const normalized = normalizeAbsPath(path)
|
||||
const mem = this.memoryFiles.get(normalized)
|
||||
if (mem) {
|
||||
this.memoryFiles.set(normalized, new File([content], mem.name, { type: 'text/markdown' }))
|
||||
return
|
||||
}
|
||||
let h = this.fileHandles.get(normalized)
|
||||
if (!h) {
|
||||
h = await this.createFileAtParent(normalized)
|
||||
}
|
||||
const w = await h.createWritable()
|
||||
await w.write(content)
|
||||
await w.close()
|
||||
}
|
||||
|
||||
private async createFileAtParent(path: string): Promise<FileSystemFileHandle> {
|
||||
const parent = parentPath(path)
|
||||
const name = baseName(path)
|
||||
const dir = this.dirHandles.get(parent)
|
||||
if (!dir) throw new Error(`无法写入:父目录未打开 (${parent})`)
|
||||
const h = await dir.getFileHandle(name, { create: true })
|
||||
this.fileHandles.set(path, h)
|
||||
return h
|
||||
}
|
||||
|
||||
async listDirectory(dirPath: string): Promise<ListDirResult> {
|
||||
const normalized = normalizeAbsPath(dirPath)
|
||||
const dir = this.dirHandles.get(normalized)
|
||||
if (!dir) {
|
||||
return { entries: [], error: '未授权访问该目录(Web 版请使用工具栏「打开」选择文件)。' }
|
||||
}
|
||||
try {
|
||||
const entries: DirEntry[] = []
|
||||
for await (const [name, handle] of iterateDir(dir)) {
|
||||
if (name.startsWith('.')) continue
|
||||
const full = joinAbsPath(normalized, name)
|
||||
if (handle.kind === 'directory') {
|
||||
this.dirHandles.set(full, handle as FileSystemDirectoryHandle)
|
||||
entries.push({ name, path: full, kind: 'dir' })
|
||||
} else if (handle.kind === 'file' && isMarkdownFile(name)) {
|
||||
this.fileHandles.set(full, handle as FileSystemFileHandle)
|
||||
entries.push({ name, path: full, kind: 'file' })
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||
})
|
||||
return { entries }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return { entries: [], error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
parentDirectory(dirPath: string): string {
|
||||
const n = normalizeAbsPath(dirPath)
|
||||
if (!n) return n
|
||||
if (isAbsDiskPath(n)) {
|
||||
const p = parentPath(n)
|
||||
return p
|
||||
}
|
||||
return parentPath(n)
|
||||
}
|
||||
|
||||
private findWorkspaceRoot(absPath: string): { rootPath: string; dir: FileSystemDirectoryHandle } | null {
|
||||
const normalized = normalizeAbsPath(absPath)
|
||||
let best = ''
|
||||
let bestDir: FileSystemDirectoryHandle | null = null
|
||||
for (const [rp, dir] of this.dirHandles) {
|
||||
const root = normalizeAbsPath(rp)
|
||||
if (normalized === root || normalized.startsWith(`${root}/`)) {
|
||||
if (root.length > best.length) {
|
||||
best = root
|
||||
bestDir = dir
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestDir ? { rootPath: best, dir: bestDir } : null
|
||||
}
|
||||
|
||||
async ensureFileHandle(absPath: string): Promise<FileSystemFileHandle | null> {
|
||||
const normalized = normalizeAbsPath(absPath)
|
||||
const direct = this.fileHandles.get(normalized)
|
||||
if (direct) return direct
|
||||
|
||||
const ws = this.findWorkspaceRoot(normalized)
|
||||
if (!ws) return null
|
||||
|
||||
const relParts = normalized.slice(ws.rootPath.length).split('/').filter(Boolean)
|
||||
if (relParts.length === 0) return null
|
||||
|
||||
try {
|
||||
let cur = ws.dir
|
||||
let curPath = ws.rootPath
|
||||
for (let i = 0; i < relParts.length - 1; i++) {
|
||||
const part = relParts[i]!
|
||||
cur = await cur.getDirectoryHandle(part)
|
||||
curPath = joinAbsPath(curPath, part)
|
||||
this.dirHandles.set(curPath, cur)
|
||||
}
|
||||
const fileName = relParts[relParts.length - 1]!
|
||||
const fh = await cur.getFileHandle(fileName)
|
||||
this.fileHandles.set(normalized, fh)
|
||||
if (isImageFile(fileName)) void this.cacheBlobUrl(normalized, fh)
|
||||
return fh
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
const normalized = normalizeAbsPath(path)
|
||||
const parent = parentPath(normalized)
|
||||
const name = baseName(normalized)
|
||||
const dir = this.dirHandles.get(parent)
|
||||
if (!dir) throw new Error('无法删除:父目录未找到')
|
||||
await dir.removeEntry(name)
|
||||
this.fileHandles.delete(normalized)
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newName: string): Promise<{ newPath: string }> {
|
||||
const oldNorm = normalizeAbsPath(oldPath)
|
||||
const parent = parentPath(oldNorm)
|
||||
const dir = this.dirHandles.get(parent)
|
||||
if (!dir) throw new Error('无法重命名:父目录未找到')
|
||||
const newPath = joinAbsPath(parent, newName.trim())
|
||||
const handle = this.fileHandles.get(oldNorm) ?? this.dirHandles.get(oldNorm)
|
||||
if (!handle) throw new Error('无法重命名:条目未找到')
|
||||
const mover = dir as FileSystemDirectoryHandle & {
|
||||
move?: (item: FileSystemHandle, name: string) => Promise<void>
|
||||
}
|
||||
if (typeof mover.move !== 'function') {
|
||||
throw new Error('当前浏览器不支持重命名,请在资源管理器中手动操作')
|
||||
}
|
||||
await mover.move(handle, newName.trim())
|
||||
if (this.fileHandles.has(oldNorm)) {
|
||||
this.fileHandles.delete(oldNorm)
|
||||
if (handle.kind === 'file') this.fileHandles.set(newPath, handle as FileSystemFileHandle)
|
||||
} else if (this.dirHandles.has(oldNorm)) {
|
||||
this.dirHandles.delete(oldNorm)
|
||||
if (handle.kind === 'directory') this.dirHandles.set(newPath, handle as FileSystemDirectoryHandle)
|
||||
}
|
||||
return { newPath }
|
||||
}
|
||||
|
||||
async pickSaveHandle(
|
||||
suggestedName?: string
|
||||
): Promise<FileSystemFileHandle | null> {
|
||||
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) {
|
||||
return null
|
||||
}
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
return await window.showSaveFilePicker!({
|
||||
suggestedName: suggestedName || '未命名.md',
|
||||
types: [
|
||||
{
|
||||
description: 'Markdown',
|
||||
accept: { 'text/markdown': ['.md', '.markdown'] }
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const webVfs = new WebVfs()
|
||||
|
||||
export function supportsFileSystemAccess(): boolean {
|
||||
if (typeof window.showOpenFilePicker !== 'function') return false
|
||||
// Chromium 上 API 可能「存在」但在非安全上下文调用会抛 SecurityError,必须走 <input type="file"> 降级。
|
||||
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function getCachedImageBlobUrl(absPath: string): string | undefined {
|
||||
return webVfs.getCachedBlobUrlPublic(normalizeAbsPath(absPath))
|
||||
}
|
||||
Reference in New Issue
Block a user