chore: standardize project comments in English
This commit is contained in:
1 parent
a874a39ea5
commit
ccb16acbea
167 files changed
+820
-820
No files matched your search
+1
-1
@@ -10,7 +10,7 @@ import { navigationError, router } from '@/router'
|
||||
const user = useUserStore()
|
||||
function retry() {
|
||||
const { path, query, hash } = router.resolve(navigationError.value!.path)
|
||||
void router.replace({ path, query, hash, force: true }).catch(() => { /* 错误由路由错误界面展示。 */ })
|
||||
void router.replace({ path, query, hash, force: true }).catch(() => { /* The router error interface displays failures. */ })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type * as Assets from './type'
|
||||
import { encodePath } from '@@/utils/paths'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 上传笔记图片。 */
|
||||
/** Upload an image for a note. */
|
||||
export function uploadImageApi(data: Assets.UploadImageRequestData) {
|
||||
const body = new FormData()
|
||||
body.append('file', data.file, data.file.name || 'image')
|
||||
@@ -15,7 +15,7 @@ export function uploadImageApi(data: Assets.UploadImageRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传目录图片。 */
|
||||
/** Upload an image for a folder. */
|
||||
export function uploadFolderImageApi(data: Assets.FolderImageRequestData) {
|
||||
const body = new FormData()
|
||||
body.append('file', data.file)
|
||||
@@ -28,7 +28,7 @@ export function uploadFolderImageApi(data: Assets.FolderImageRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 替换原路径图片。 */
|
||||
/** Replace an image at its existing path. */
|
||||
export function replaceImageApi(data: Assets.ReplaceImageRequestData) {
|
||||
const body = new FormData()
|
||||
body.append('file', data.file)
|
||||
@@ -40,7 +40,7 @@ export function replaceImageApi(data: Assets.ReplaceImageRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 下载并导入远程图片。 */
|
||||
/** Download and import a remote image. */
|
||||
export function importImageApi(data: Assets.ImportImageRequestData) {
|
||||
return request<Assets.ImageResponseData>({
|
||||
url: 'assets/import',
|
||||
@@ -51,7 +51,7 @@ export function importImageApi(data: Assets.ImportImageRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 核对引用后清理图片。 */
|
||||
/** Clean up images after checking references. */
|
||||
export function cleanupImagesApi(data: Assets.CleanupImagesRequestData) {
|
||||
return request<Assets.CleanupImagesResponseData>({
|
||||
url: 'assets/cleanup',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Auth from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 查询初始化状态。 */
|
||||
/** Query initialization status. */
|
||||
export function getAuthStateApi() {
|
||||
return request<Auth.StateResponseData>({
|
||||
url: 'auth/state',
|
||||
@@ -11,7 +11,7 @@ export function getAuthStateApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 签发或刷新登录验证码。 */
|
||||
/** Issue or refresh a sign-in captcha. */
|
||||
export function getCaptchaApi(data: Auth.CaptchaRequestData) {
|
||||
return request<Auth.CaptchaResponseData>({
|
||||
url: 'auth/captcha',
|
||||
@@ -22,7 +22,7 @@ export function getCaptchaApi(data: Auth.CaptchaRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化登录密码。 */
|
||||
/** Initialize the login password. */
|
||||
export function setupApi(data: Auth.SetupRequestData) {
|
||||
return request<Auth.LoginResponseData>({
|
||||
url: 'auth/setup',
|
||||
@@ -33,7 +33,7 @@ export function setupApi(data: Auth.SetupRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 使用密码和验证码登录。 */
|
||||
/** Sign in with a password and captcha. */
|
||||
export function loginApi(data: Auth.LoginRequestData) {
|
||||
return request<Auth.LoginResponseData>({
|
||||
url: 'auth/login',
|
||||
@@ -44,7 +44,7 @@ export function loginApi(data: Auth.LoginRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 恢复当前身份与权限。 */
|
||||
/** Restore the current identity and permissions. */
|
||||
export function getCurrentUserApi() {
|
||||
return request<Auth.IdentityResponseData>({
|
||||
url: 'auth/me',
|
||||
@@ -53,7 +53,7 @@ export function getCurrentUserApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 撤销当前服务端会话。 */
|
||||
/** Revoke the current server session. */
|
||||
export function logoutApi() {
|
||||
return request<void>({
|
||||
url: 'auth/logout',
|
||||
@@ -61,7 +61,7 @@ export function logoutApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 签发受限资源票据。 */
|
||||
/** Issue restricted resource tickets. */
|
||||
export function createResourceTicketApi(data: Auth.TicketRequestData) {
|
||||
return request<Auth.TicketResponseData>({
|
||||
url: 'auth/tickets',
|
||||
@@ -71,7 +71,7 @@ export function createResourceTicketApi(data: Auth.TicketRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改登录密码。 */
|
||||
/** Change the login password. */
|
||||
export function changePasswordApi(data: Auth.PasswordRequestData) {
|
||||
return request<Auth.PasswordResponseData>({
|
||||
url: 'auth/password',
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EventHandlers } from './type'
|
||||
|
||||
import { createResourceTicketApi } from '@@/apis/auth'
|
||||
|
||||
// 订阅仓库事件;每次连接领取一次性票据,重连后补全量同步。
|
||||
// Subscribe to vault events; obtain a one-use ticket for each connection and fully sync after reconnecting.
|
||||
import { isCancel } from 'axios'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
@@ -2,7 +2,7 @@ import type * as Files from './type'
|
||||
import { encodePath } from '@@/utils/paths'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取 Markdown 原文与版本。 */
|
||||
/** Read original Markdown and its revision. */
|
||||
export function getFileApi(path: string) {
|
||||
return request<string>({
|
||||
url: `files/${encodePath(path)}`,
|
||||
@@ -13,7 +13,7 @@ export function getFileApi(path: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 按版本写入 Markdown。 */
|
||||
/** Write Markdown with a revision precondition. */
|
||||
export function writeFileApi(data: Files.WriteFileRequestData) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
|
||||
if (data.ifMatch === '') headers['If-None-Match'] = '*'
|
||||
@@ -28,7 +28,7 @@ export function writeFileApi(data: Files.WriteFileRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 移入回收站。 */
|
||||
/** Move an item to the recycle bin. */
|
||||
export function deleteFileApi(path: string) {
|
||||
return request<Files.DeleteFileResponseData>({
|
||||
url: `files/${encodePath(path)}`,
|
||||
@@ -37,7 +37,7 @@ export function deleteFileApi(path: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建文件夹。 */
|
||||
/** Create a folder. */
|
||||
export function createFolderApi(data: Files.FileRequestData) {
|
||||
return request<Files.FileOperationResponseData>({
|
||||
url: 'folders',
|
||||
@@ -47,7 +47,7 @@ export function createFolderApi(data: Files.FileRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 移动文件或目录。 */
|
||||
/** Move a file or folder. */
|
||||
export function moveFileApi(data: Files.MoveRequestData) {
|
||||
return request<Files.FileOperationResponseData>({
|
||||
url: 'move',
|
||||
@@ -57,7 +57,7 @@ export function moveFileApi(data: Files.MoveRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 导入 Markdown 或 ZIP。 */
|
||||
/** Import Markdown or a ZIP archive. */
|
||||
export function importNotesApi(data: Files.ImportRequestData) {
|
||||
const body = new FormData()
|
||||
body.append('dir', data.dir)
|
||||
@@ -71,7 +71,7 @@ export function importNotesApi(data: Files.ImportRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出选中内容为 Markdown ZIP。 */
|
||||
/** Export selected contents as a Markdown ZIP. */
|
||||
export function exportFilesApi(data: Files.ExportRequestData) {
|
||||
return request<Blob>({
|
||||
url: 'export',
|
||||
@@ -83,7 +83,7 @@ export function exportFilesApi(data: Files.ExportRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 下载原始文件。 */
|
||||
/** Download a raw file. */
|
||||
export function getRawFileApi(path: string) {
|
||||
return request<Blob>({
|
||||
url: `raw/${encodePath(path)}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Maintenance from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取维护状态。 */
|
||||
/** Read maintenance status. */
|
||||
export function getMaintenanceStatusApi() {
|
||||
return request<Maintenance.MaintenanceResponseData>({
|
||||
url: 'maintenance/status',
|
||||
@@ -10,7 +10,7 @@ export function getMaintenanceStatusApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出实例或设置备份。 */
|
||||
/** Export an instance or settings backup. */
|
||||
export function exportBackupApi(kind: Maintenance.BackupKind) {
|
||||
return request<Blob>({
|
||||
url: `maintenance/export/${kind}`,
|
||||
@@ -21,7 +21,7 @@ export function exportBackupApi(kind: Maintenance.BackupKind) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出全部 Markdown 笔记。 */
|
||||
/** Export all Markdown notes. */
|
||||
export function exportNotesApi() {
|
||||
return request<Blob>({
|
||||
url: 'maintenance/notes/export',
|
||||
@@ -32,7 +32,7 @@ export function exportNotesApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出系统日志。 */
|
||||
/** Export system logs. */
|
||||
export function exportLogsApi() {
|
||||
return request<Blob>({
|
||||
url: 'maintenance/logs/export',
|
||||
@@ -43,7 +43,7 @@ export function exportLogsApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建备份上传。 */
|
||||
/** Create a backup upload. */
|
||||
export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) {
|
||||
return request<Maintenance.BackupUploadResponseData>({
|
||||
url: 'maintenance/uploads',
|
||||
@@ -53,7 +53,7 @@ export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData)
|
||||
})
|
||||
}
|
||||
|
||||
/** 顺序上传备份分块。 */
|
||||
/** Upload backup chunks sequentially. */
|
||||
export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
|
||||
return request<Maintenance.BackupChunkResponseData>({
|
||||
url: `maintenance/uploads/${encodeURIComponent(data.id)}`,
|
||||
@@ -64,7 +64,7 @@ export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 校验完整备份。 */
|
||||
/** Validate the complete backup. */
|
||||
export function validateBackupApi(id: string) {
|
||||
return request<Maintenance.BackupSummaryResponseData>({
|
||||
url: `maintenance/uploads/${encodeURIComponent(id)}/validate`,
|
||||
@@ -73,7 +73,7 @@ export function validateBackupApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消备份上传并清理暂存。 */
|
||||
/** Cancel a backup upload and remove staging files. */
|
||||
export function cancelBackupUploadApi(id: string) {
|
||||
return request<Maintenance.MaintenanceOperationResponseData>({
|
||||
url: `maintenance/uploads/${encodeURIComponent(id)}`,
|
||||
@@ -82,7 +82,7 @@ export function cancelBackupUploadApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 导入已确认的设置备份。 */
|
||||
/** Import a confirmed settings backup. */
|
||||
export function importSettingsApi(id: string) {
|
||||
return request<Maintenance.MaintenanceOperationResponseData>({
|
||||
url: 'maintenance/settings/import',
|
||||
@@ -92,7 +92,7 @@ export function importSettingsApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 恢复已确认的实例备份。 */
|
||||
/** Restore a confirmed instance backup. */
|
||||
export function restoreBackupApi(id: string) {
|
||||
return request<Maintenance.RestoreBackupResponseData>({
|
||||
url: 'maintenance/restore',
|
||||
@@ -102,7 +102,7 @@ export function restoreBackupApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 备份后重置设置。 */
|
||||
/** Back up and reset settings. */
|
||||
export function resetSettingsApi() {
|
||||
return request<Maintenance.MaintenanceOperationResponseData>({
|
||||
url: 'maintenance/settings/reset',
|
||||
@@ -112,7 +112,7 @@ export function resetSettingsApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 优化或重建派生索引。 */
|
||||
/** Optimize or rebuild the derived index. */
|
||||
export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
|
||||
return request<Maintenance.MaintenanceOperationResponseData>({
|
||||
url: `maintenance/index/${operation}`,
|
||||
@@ -121,7 +121,7 @@ export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
|
||||
})
|
||||
}
|
||||
|
||||
/** 清理非活动临时文件。 */
|
||||
/** Clean inactive temporary files. */
|
||||
export function clearTempApi() {
|
||||
return request<Maintenance.ClearTempResponseData>({
|
||||
url: 'maintenance/temp/clear',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Metadata from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取笔记元数据索引。 */
|
||||
/** Read indexed note metadata. */
|
||||
export function getMetadataApi() {
|
||||
return request<Metadata.MetadataResponseData>({
|
||||
url: 'metadata',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Plugins from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取已安装插件。 */
|
||||
/** List installed plugins. */
|
||||
export function getPluginsApi() {
|
||||
return request<Plugins.PluginsResponseData>({
|
||||
url: 'plugins',
|
||||
@@ -10,7 +10,7 @@ export function getPluginsApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 安装插件 ZIP。 */
|
||||
/** Install a plugin ZIP. */
|
||||
export function installPluginApi(file: File) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
@@ -22,7 +22,7 @@ export function installPluginApi(file: File) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 启用或停用插件。 */
|
||||
/** Enable or disable a plugin. */
|
||||
export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
|
||||
return request<Plugins.PluginResponseData>({
|
||||
url: `plugins/${encodeURIComponent(data.id)}`,
|
||||
@@ -32,7 +32,7 @@ export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 卸载插件并保留插件数据。 */
|
||||
/** Uninstall a plugin while retaining its data. */
|
||||
export function uninstallPluginApi(id: string) {
|
||||
return request<Plugins.PluginOperationResponseData>({
|
||||
url: `plugins/${encodeURIComponent(id)}`,
|
||||
@@ -41,7 +41,7 @@ export function uninstallPluginApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取插件存储。 */
|
||||
/** Read plugin storage. */
|
||||
export function getPluginDataApi(id: string) {
|
||||
return request<Plugins.PluginDataResponseData>({
|
||||
url: `plugins/${encodeURIComponent(id)}/data`,
|
||||
@@ -50,7 +50,7 @@ export function getPluginDataApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存插件存储。 */
|
||||
/** Save plugin storage. */
|
||||
export function savePluginDataApi(data: Plugins.PluginDataRequestData) {
|
||||
return request<Plugins.PluginDataResponseData>({
|
||||
url: `plugins/${encodeURIComponent(data.id)}/data`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Search from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 搜索笔记。 */
|
||||
/** Search notes. */
|
||||
export function searchApi(params: Search.SearchRequestData) {
|
||||
return request<Search.SearchResponseData>({
|
||||
url: 'search',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface SearchRequestData { q: string }
|
||||
export interface SearchResult {
|
||||
path: string
|
||||
/** 第一处匹配行,从1开始;只有路径匹配时为0。 */
|
||||
/** First matching line, starting at 1; 0 when only the path matches. */
|
||||
line: number
|
||||
snippet: string
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Settings from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取设置。 */
|
||||
/** Read settings. */
|
||||
export function getSettingsApi() {
|
||||
return request<Settings.SettingsResponseData>({
|
||||
url: 'settings',
|
||||
@@ -10,7 +10,7 @@ export function getSettingsApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新设置。 */
|
||||
/** Update settings. */
|
||||
export function patchSettingsApi(data: Settings.SettingsData) {
|
||||
return request<Settings.SettingsResponseData>({
|
||||
url: 'settings',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/** 保留插件写入的未知设置,应用端按已知字段校验。 */
|
||||
/** Preserve unknown plugin settings; the application validates known fields. */
|
||||
export type SettingsData = Record<string, unknown>
|
||||
export type SettingsResponseData = ApiResponseData<SettingsData>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Storage from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 统计实际存储占用。 */
|
||||
/** Measure actual storage usage. */
|
||||
export function getStorageApi() {
|
||||
return request<Storage.StorageResponseData>({
|
||||
url: 'storage',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Themes from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取可选主题。 */
|
||||
/** List available themes. */
|
||||
export function getThemesApi() {
|
||||
return request<Themes.ThemesResponseData>({
|
||||
url: 'themes',
|
||||
@@ -10,7 +10,7 @@ export function getThemesApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取主题与样式。 */
|
||||
/** Read a theme and its styles. */
|
||||
export function getThemeApi(id: string) {
|
||||
return request<Themes.ThemeResponseData>({
|
||||
url: `themes/${encodeURIComponent(id)}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Tokens from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取 API 令牌列表。 */
|
||||
/** List API tokens. */
|
||||
export function getTokensApi() {
|
||||
return request<Tokens.TokensResponseData>({
|
||||
url: 'tokens',
|
||||
@@ -10,7 +10,7 @@ export function getTokensApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建 API 令牌。 */
|
||||
/** Create an API token. */
|
||||
export function createTokenApi(data: Tokens.CreateTokenRequestData) {
|
||||
return request<Tokens.CreateTokenResponseData>({
|
||||
url: 'tokens',
|
||||
@@ -20,7 +20,7 @@ export function createTokenApi(data: Tokens.CreateTokenRequestData) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 撤销 API 令牌。 */
|
||||
/** Revoke an API token. */
|
||||
export function deleteTokenApi(id: string) {
|
||||
return request<Tokens.DeleteTokenResponseData>({
|
||||
url: `tokens/${encodeURIComponent(id)}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Trash from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取回收站。 */
|
||||
/** Read the recycle bin. */
|
||||
export function getTrashApi() {
|
||||
return request<Trash.TrashResponseData>({
|
||||
url: 'trash',
|
||||
@@ -10,7 +10,7 @@ export function getTrashApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 恢复回收站条目。 */
|
||||
/** Restore a recycle-bin entry. */
|
||||
export function restoreTrashApi(id: string) {
|
||||
return request<Trash.RestoreTrashResponseData>({
|
||||
url: `trash/${encodeURIComponent(id)}/restore`,
|
||||
@@ -19,7 +19,7 @@ export function restoreTrashApi(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 清空回收站并返回逐项结果。 */
|
||||
/** Empty the recycle bin and return per-entry results. */
|
||||
export function clearTrashApi() {
|
||||
return request<Trash.ClearTrashResponseData>({
|
||||
url: 'trash',
|
||||
@@ -28,7 +28,7 @@ export function clearTrashApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 永久删除回收站条目。 */
|
||||
/** Permanently delete a recycle-bin entry. */
|
||||
export function purgeTrashApi(id: string) {
|
||||
return request<Trash.PurgeTrashResponseData>({
|
||||
url: `trash/${encodeURIComponent(id)}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Tree from './type'
|
||||
import { request } from '@/http/axios'
|
||||
|
||||
/** 读取目录树。 */
|
||||
/** Read the directory tree. */
|
||||
export function getTreeApi() {
|
||||
return request<Tree.TreeResponseData>({
|
||||
url: 'tree',
|
||||
@@ -10,7 +10,7 @@ export function getTreeApi() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 设置或重置目录项图标。 */
|
||||
/** Set or reset a tree entry's icon. */
|
||||
export function setTreeIconApi(data: Tree.TreeIconRequestData) {
|
||||
return request<Tree.TreeIconResponseData>({
|
||||
url: 'tree-icon',
|
||||
|
||||
@@ -56,7 +56,7 @@ function render() {
|
||||
root.dataset.rmColorMode = dark ? 'dark' : 'light'
|
||||
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
|
||||
style('rm-theme', appearancePaused ? '' : themeCSS)
|
||||
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。
|
||||
// Separate style elements prevent unclosed syntax in one snippet from swallowing others.
|
||||
document.querySelectorAll('style[data-rm-snippet]').forEach(el => el.remove())
|
||||
if (!appearancePaused) {
|
||||
for (const snippet of current.cssSnippets) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@import 'highlight.js/styles/atom-one-dark.css';
|
||||
@import './maple-font.css';
|
||||
|
||||
/* 基础主题。--rm-* 变量继续作为主题接口,字体在本地加载。 */
|
||||
/* Base theme. --rm-* variables remain the theme interface; fonts load locally. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--rm-sidebar-width: 288px;
|
||||
@@ -10,7 +10,7 @@
|
||||
--rm-font-heading: var(--rm-font-editor, var(--rm-font-text));
|
||||
--rm-font-mono: 'Maple Mono Normal NL NF CN', monospace;
|
||||
--rm-font-size: 16px;
|
||||
/* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */
|
||||
/* Interface typography is independent of body text; secondary text scales with the interface but stays at least 13px. */
|
||||
--rm-ui-font-size: 14px;
|
||||
--rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
|
||||
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
|
||||
@@ -159,7 +159,7 @@ textarea {
|
||||
background: var(--rm-selection) !important;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
/* Headings. */
|
||||
.cm-line:is(.cm-rm-h1, .cm-rm-h2, .cm-rm-h3, .cm-rm-h4, .cm-rm-h5, .cm-rm-h6) {
|
||||
line-height: 1.4;
|
||||
padding-top: 1rem;
|
||||
@@ -186,7 +186,7 @@ textarea {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Markdown 标记(激活行显示源码时) */
|
||||
/* Markdown markers shown as source on active lines. */
|
||||
.cm-rm-markup,
|
||||
.cm-rm-url {
|
||||
color: var(--rm-text-muted);
|
||||
@@ -198,7 +198,7 @@ textarea {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* 行内代码 */
|
||||
/* Inline code. */
|
||||
.cm-rm-inline-code {
|
||||
font-family: var(--rm-font-mono);
|
||||
font-size: 0.875em;
|
||||
@@ -208,8 +208,8 @@ textarea {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 代码块
|
||||
* 背景画在 ::before 上并置于选区图层之下:若直接给 .cm-line 设背景,会盖住 CodeMirror 绘制的选区 */
|
||||
/* Code blocks. Paint backgrounds on ::before below the selection
|
||||
* layer; a background on .cm-line would cover CodeMirror's selection. */
|
||||
.cm-line.cm-rm-code {
|
||||
position: relative;
|
||||
font-family: var(--rm-font-mono);
|
||||
@@ -246,7 +246,7 @@ textarea {
|
||||
.cm-line.cm-rm-code-begin.cm-rm-code-end::before {
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* 围栏展开前后保持同一行高,语言标签与复制按钮不参与行高计算,避免代码内容跳动。 */
|
||||
/* Keep fence line heights unchanged when expanded; language labels and copy buttons must not affect line height or shift code. */
|
||||
.cm-rm-code-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -291,14 +291,14 @@ textarea {
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
/* Blockquotes. */
|
||||
.cm-line.cm-rm-quote {
|
||||
border-left: 3px solid var(--rm-accent);
|
||||
padding: 5px 15px;
|
||||
background: color-mix(in srgb, var(--rm-accent) 4%, transparent);
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
/* Lists. */
|
||||
.cm-rm-bullet {
|
||||
color: var(--rm-text-muted);
|
||||
display: inline-block;
|
||||
@@ -311,7 +311,7 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
/* Thematic breaks. */
|
||||
.cm-rm-hr {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
@@ -319,7 +319,7 @@ textarea {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 图片 */
|
||||
/* Images. */
|
||||
.cm-rm-image {
|
||||
display: block;
|
||||
}
|
||||
@@ -332,7 +332,7 @@ textarea {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */
|
||||
/* Tables show monospaced source while the cursor is inside, otherwise an editable rendered table. */
|
||||
.cm-line.cm-rm-table {
|
||||
font-family: var(--rm-font-mono);
|
||||
font-size: 0.9em;
|
||||
@@ -379,7 +379,7 @@ textarea {
|
||||
.cm-rm-table-widget a {
|
||||
color: var(--rm-accent-strong);
|
||||
}
|
||||
/* 工具栏占据表格上方的固定空间,只切换可见性,不遮挡表格也不引起跳动 */
|
||||
/* Reserve fixed space above tables for the toolbar; toggle visibility without covering the table or shifting its position. */
|
||||
.cm-rm-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -452,7 +452,7 @@ textarea {
|
||||
.cm-editor .cm-content {
|
||||
padding: 16px 16px 40vh;
|
||||
}
|
||||
/* 触屏没有悬停,工具栏常显,按钮加大触控热区 */
|
||||
/* Touchscreens lack hover: keep the toolbar visible and enlarge button touch targets. */
|
||||
.cm-rm-table-toolbar {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
@@ -463,7 +463,7 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Typora 扩展语法 ---- */
|
||||
/* ---- Typora syntax extensions ---- */
|
||||
.cm-rm-highlight,
|
||||
.cm-rm-table-widget mark {
|
||||
background: var(--rm-highlight-bg);
|
||||
@@ -505,7 +505,7 @@ textarea {
|
||||
font-size: 0.9em;
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
/* 编辑时显示在源码下方的预览 */
|
||||
/* Preview displayed below source while editing. */
|
||||
.cm-rm-preview {
|
||||
margin: 4px 0 8px;
|
||||
padding: 8px 12px;
|
||||
@@ -628,7 +628,7 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* opacity 保留排版和字体请求;等待期间不显示备用字体,也覆盖 Teleport 到 body 的菜单。 */
|
||||
/* opacity preserves layout and font requests; hide fallback fonts while waiting, including menus teleported to body. */
|
||||
:root:is([data-rm-fonts='loading'], [data-rm-fonts='error']) body > :not(.rm-font-error) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
// 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。
|
||||
// Use the font set requested by actual browser layout, covering initial render, note switches, and later glyphs.
|
||||
export function watchFontLoading() {
|
||||
const root = document.documentElement
|
||||
const fonts = document.fonts
|
||||
@@ -22,7 +22,7 @@ export function watchFontLoading() {
|
||||
|
||||
function ready() {
|
||||
if (root.dataset.rmFonts === 'error') return
|
||||
// fonts.ready 在下载失败时也会完成,不能将失败当作字体已就绪。
|
||||
// fonts.ready also resolves after download failure; failure must not count as fonts being ready.
|
||||
if ([...fonts].some(font => font.status === 'error')) failed()
|
||||
else if (fonts.status === 'loaded') root.dataset.rmFonts = 'ready'
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function watchFontLoading() {
|
||||
})
|
||||
fonts.addEventListener('loadingerror', failed)
|
||||
|
||||
// 挂载完成后触发一次排版,确保等待的是当前文字用到的字体。
|
||||
// Trigger layout after mounting so the wait covers fonts used by the current text.
|
||||
requestAnimationFrame(() => {
|
||||
document.body.getBoundingClientRect()
|
||||
void fonts.ready.then(ready)
|
||||
|
||||
@@ -13,7 +13,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
|
||||
if (!style) {
|
||||
style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style)
|
||||
}
|
||||
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
|
||||
// Names are display-only; CSS identifiers and resource paths use only server-generated content hashes.
|
||||
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('${fontResourceRoot()}${font.id}/file'); font-display: block; }`).join('\n')
|
||||
if (style.textContent !== css) style.textContent = css
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Maple Mono v7.9;由 scripts/prepare-maple-font.py 生成,授权见 fonts/maple/LICENSE.txt。 */
|
||||
/* Maple Mono v7.9; generated by scripts/prepare-maple-font.py. See fonts/maple/LICENSE.txt for licensing. */
|
||||
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-00.woff2') format('woff2'); unicode-range: U+0-FFF; }
|
||||
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-01.woff2') format('woff2'); unicode-range: U+1000-1FFF; }
|
||||
@font-face { font-family: 'Maple Mono Normal NL NF CN'; font-style: normal; font-weight: 400; font-display: block; src: url('./fonts/maple/regular-02.woff2') format('woff2'); unicode-range: U+2000-2FFF; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
// 编辑器排版设置;账户、快捷键等设置不进入此模块。
|
||||
// Editor typography settings only; account, shortcuts, and other settings do not belong here.
|
||||
const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' }
|
||||
export const TEXT_FONTS = {
|
||||
maple,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */
|
||||
/* Application shell styles; colors and fonts use --rm-* variables from common/appearance/base.css. */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
|
||||
/* Use the visible viewport height when the software keyboard opens; see viewport.ts. */
|
||||
#app {
|
||||
height: var(--rm-app-height, 100%);
|
||||
display: flex;
|
||||
@@ -52,7 +52,7 @@ button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- 通用控件 ---- */
|
||||
/* ---- Shared controls ---- */
|
||||
.rm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -121,7 +121,7 @@ button {
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
|
||||
/* ---- 登录 ---- */
|
||||
/* ---- Sign-in ---- */
|
||||
.rm-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -159,7 +159,7 @@ button {
|
||||
font-size: var(--rm-ui-font-small);
|
||||
}
|
||||
|
||||
/* ---- 布局 ---- */
|
||||
/* ---- Layout ---- */
|
||||
.rm-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -220,7 +220,7 @@ button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* ---- 大纲 ---- */
|
||||
/* ---- Outline ---- */
|
||||
.rm-outline-backdrop {
|
||||
display: flex;
|
||||
flex: none;
|
||||
@@ -292,7 +292,7 @@ button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- 侧栏 ---- */
|
||||
/* ---- Sidebar ---- */
|
||||
.rm-sidebar-inner {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@@ -346,7 +346,7 @@ button {
|
||||
--rm-tree-row-overlay: var(--rm-hover);
|
||||
background: var(--rm-hover);
|
||||
}
|
||||
/* 长按由应用处理,不弹出 iOS 的系统菜单 */
|
||||
/* The application handles long presses instead of showing the iOS system menu. */
|
||||
.rm-tree-row {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
@@ -403,7 +403,7 @@ button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 按钮覆盖名称末尾,不为隐藏按钮留位;不透明底色遮住下方文字。 */
|
||||
/* Buttons overlay the end of the name without reserving hidden space; an opaque background covers underlying text. */
|
||||
.rm-tree-row .rm-tree-more {
|
||||
background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg);
|
||||
}
|
||||
@@ -432,7 +432,7 @@ button {
|
||||
font-size: var(--rm-ui-font-small);
|
||||
text-align: center;
|
||||
}
|
||||
/* ---- 搜索 ---- */
|
||||
/* ---- Search ---- */
|
||||
.rm-search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -515,7 +515,7 @@ button {
|
||||
color: var(--rm-text);
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* ---- 快速切换 ---- */
|
||||
/* ---- Quick switcher ---- */
|
||||
.rm-switcher-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@@ -583,7 +583,7 @@ button {
|
||||
color: var(--rm-text);
|
||||
}
|
||||
|
||||
/* ---- 菜单 ---- */
|
||||
/* ---- Menus ---- */
|
||||
.rm-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -626,7 +626,7 @@ button {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 标签页 ---- */
|
||||
/* ---- Tabs ---- */
|
||||
.rm-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -733,7 +733,7 @@ button {
|
||||
border-radius: 50%;
|
||||
background: var(--rm-accent);
|
||||
}
|
||||
/* 有未保存修改时显示圆点,悬停时换回关闭图标 */
|
||||
/* Show a dot for unsaved changes; replace it with the close icon on hover. */
|
||||
.rm-tab-close.dirty .rm-dirty-dot {
|
||||
display: block;
|
||||
}
|
||||
@@ -747,7 +747,7 @@ button {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* ---- 提示条 ---- */
|
||||
/* ---- Status bars ---- */
|
||||
.rm-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -767,7 +767,7 @@ button {
|
||||
color: var(--rm-warn-text);
|
||||
}
|
||||
|
||||
/* ---- 编辑区 ---- */
|
||||
/* ---- Editor area ---- */
|
||||
.rm-editor-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -780,7 +780,7 @@ button {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---- 对话框 ---- */
|
||||
/* ---- Dialogs ---- */
|
||||
.rm-dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -823,7 +823,7 @@ button {
|
||||
font-size: var(--rm-ui-font-small);
|
||||
user-select: all;
|
||||
}
|
||||
/* ---- 设置 ---- */
|
||||
/* ---- Settings ---- */
|
||||
.rm-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -849,7 +849,7 @@ button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ---- 通知 ---- */
|
||||
/* ---- Notifications ---- */
|
||||
.rm-toasts {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
@@ -883,7 +883,7 @@ button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- 移动端 ---- */
|
||||
/* ---- Mobile ---- */
|
||||
@media (max-width: 767px) {
|
||||
.rm-sidebar {
|
||||
position: fixed;
|
||||
@@ -966,7 +966,7 @@ button {
|
||||
.rm-sidebar-resize {
|
||||
display: none;
|
||||
}
|
||||
/* 大纲在手机上为从右侧滑入的浮层 */
|
||||
/* On phones, the outline is an overlay sliding in from the right. */
|
||||
.rm-outline-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -984,12 +984,12 @@ button {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
box-shadow: var(--rm-shadow);
|
||||
}
|
||||
/* 触控热区不小于 44px */
|
||||
/* Touch targets must be at least 44px. */
|
||||
.rm-tree-row {
|
||||
height: var(--rm-control-size);
|
||||
font-size: inherit;
|
||||
}
|
||||
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
|
||||
/* iOS Safari zooms focused inputs when their font size is below 16px. */
|
||||
.rm-search-input {
|
||||
height: var(--rm-control-size);
|
||||
font-size: var(--rm-ui-font-size);
|
||||
@@ -1001,14 +1001,14 @@ button {
|
||||
.rm-search-item {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
/* 手机上从顶部弹出,给软键盘留出空间 */
|
||||
/* Open from the top on phones to leave room for the software keyboard. */
|
||||
.rm-switcher-item {
|
||||
height: var(--rm-control-size);
|
||||
font-size: var(--rm-ui-font-size);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题与 CSS 编辑 */
|
||||
/* Theme and CSS editing. */
|
||||
.rm-appearance-note {
|
||||
margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4);
|
||||
font-size: var(--rm-ui-font-small);
|
||||
@@ -1195,7 +1195,7 @@ button {
|
||||
background: var(--rm-active);
|
||||
}
|
||||
|
||||
/* 轻玻璃工作台 */
|
||||
/* Glass workspace. */
|
||||
.rm-vault-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1348,7 +1348,7 @@ button {
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
/* 选区工具栏与局部外观面板 */
|
||||
/* Selection toolbar and local appearance panel. */
|
||||
.rm-selection-popover {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
@@ -1597,7 +1597,7 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */
|
||||
/* Tree children expand to their actual content height; exiting items must not remain clickable or focusable. */
|
||||
.rm-tree-children {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
gap: inherit;
|
||||
}
|
||||
|
||||
/* 复用 RawMark 主题,弹出层与页面使用相同的字体、色彩和密度。 */
|
||||
/* Reuse the RawMark theme so popovers and pages share fonts, colors, and density. */
|
||||
:root {
|
||||
--el-font-size-small: var(--rm-ui-font-small);
|
||||
--el-font-size-extra-small: var(--rm-ui-font-small);
|
||||
|
||||
@@ -20,7 +20,7 @@ async function update() {
|
||||
return
|
||||
}
|
||||
if (!activated.value && worker) {
|
||||
// 等待新版激活后,仅刷新主动点击更新的页面。
|
||||
// After the new version activates, refresh only the page that requested the update.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = () => {
|
||||
if (worker.state !== 'activated' && worker.state !== 'redundant') return
|
||||
@@ -39,7 +39,7 @@ async function update() {
|
||||
})
|
||||
activated.value = true
|
||||
}
|
||||
// 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。
|
||||
// Typing may continue during activation; confirm again before refreshing and keep the button for retrying after save.
|
||||
if (await props.beforeReload()) location.reload()
|
||||
else toast(translate('common.some_changes_or_operations_are_a5fda9'))
|
||||
} catch {
|
||||
|
||||
@@ -67,7 +67,7 @@ async function importFont(event: Event) {
|
||||
try {
|
||||
if (file.size > 30 * 1024 * 1024) throw new Error(translate('common.font_files_must_not_exceed_cc001c'))
|
||||
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error(translate('common.choose_a_ttf_otf_woff_eb6ab7'))
|
||||
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
|
||||
// Let the browser validate the actual font; invalid fonts are neither uploaded nor added to the page's loading set.
|
||||
const data = await file.arrayBuffer()
|
||||
try {
|
||||
await new FontFace('rawmark-import-check', data).load()
|
||||
|
||||
@@ -22,7 +22,7 @@ function onSystem() {
|
||||
system.addEventListener('change', onSystem)
|
||||
onBeforeUnmount(() => system.removeEventListener('change', onSystem))
|
||||
const dark = computed(() => (fixedThemeMode(settings.theme) ?? (settings.colorMode === 'system' ? (systemDark.value ? 'dark' : 'light') : settings.colorMode)) === 'dark')
|
||||
// 思源色板顺序;只影响选中文字,不修改主题背景。
|
||||
// SiYuan palette order; affect only selected text, not the theme background.
|
||||
const foreground = computed(() => dark.value
|
||||
? ['#f39993', '#ffd599', '#a6d5fa', '#b7dfb9', '#9aa0a6', '#3575f0', '#f3a92f', '#d23f31', '#f47c9c', '#4dd0e1', '#10df54', '#ecd424', '#1e1e1e']
|
||||
: ['#790600', '#8a4f00', '#005599', '#006b04', '#5f6368', '#3575f0', '#ff9200', '#d23f31', '#a40074', '#007080', '#006e2a', '#6b5a00', '#ffffff'])
|
||||
|
||||
@@ -8,7 +8,7 @@ const CACHE_LIMIT = 500
|
||||
let worker: Worker | undefined
|
||||
let nextId = 1
|
||||
const pending = new Map<number, { resolve: (tokens: TokenRun | null) => void, reject: (reason: unknown) => void }>()
|
||||
// 以"语言 + 代码"为键缓存结果;Map 保持插入顺序,超限时淘汰最早的条目
|
||||
// Cache by "language + code"; Map preserves insertion order, so evict the oldest entry when the limit is exceeded.
|
||||
const cache = new Map<string, TokenRun | null>()
|
||||
|
||||
function getWorker(): Worker {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 主线程与高亮 Worker 之间的消息格式
|
||||
// Message format between the main thread and highlight Worker.
|
||||
|
||||
export interface HighlightRequest {
|
||||
id: number
|
||||
@@ -6,8 +6,8 @@ export interface HighlightRequest {
|
||||
code: string
|
||||
}
|
||||
|
||||
// 保留 highlight.js 的嵌套 token 与原文,编辑器使用官方 token 类样式。
|
||||
// Preserve highlight.js nested tokens and source text; the editor uses official token-class styles.
|
||||
export type TokenRun = (string | { classes: string, content: TokenRun })[]
|
||||
|
||||
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
|
||||
// tokens is null for unsupported languages; actual failures must remain distinct from plain-text results.
|
||||
export type HighlightResponse = { id: number, tokens: TokenRun | null } | { id: number, error: Error }
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-template-curly-in-string -- 测试样例需要保留源码中的模板插值。 */
|
||||
/* eslint-disable no-template-curly-in-string -- Test fixtures must retain source template interpolation. */
|
||||
import type { HighlightResponse, TokenRun } from './protocol'
|
||||
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
// 只转换 highlight.js 生成的 span 和转义文本,不向编辑器插入 HTML。
|
||||
// Convert only highlight.js-generated spans and escaped text; never insert HTML into the editor.
|
||||
function serialize(html: string): TokenRun {
|
||||
const root: TokenRun = []
|
||||
const stack = [root]
|
||||
@@ -23,7 +23,7 @@ function serialize(html: string): TokenRun {
|
||||
function highlight(req: HighlightRequest): TokenRun | null {
|
||||
if (!hljs.getLanguage(req.lang)) return null
|
||||
const result = hljs.highlight(req.code, { language: req.lang, ignoreIllegals: true })
|
||||
// 库的安全模式会随纯文本结果返回异常,仍需传递真实失败原因。
|
||||
// The library's safe mode may return an exception alongside plain text; propagate the actual failure.
|
||||
if (result.errorRaised) throw result.errorRaised
|
||||
return serialize(result.value)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function applyLocale(value: Locale) {
|
||||
if (typeof document !== 'undefined') document.documentElement.lang = value
|
||||
}
|
||||
|
||||
/** 登录页仅缓存显示语言;登录后由服务器账户设置覆盖。 */
|
||||
/** The sign-in page caches display language only; server account settings override it after authentication. */
|
||||
export function chooseAnonymousLocale(value: Locale) {
|
||||
const cached = JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
|
||||
localStorage.setItem('rawmark.settings', JSON.stringify({ ...cached, locale: value }))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { typoraExtensions } from './extensions'
|
||||
|
||||
const parser = base.configure([GFM, ...typoraExtensions])
|
||||
|
||||
/** 列出指定类型节点覆盖的原文 */
|
||||
/** List original text covered by nodes of the specified type. */
|
||||
function nodes(src: string, name: string): string[] {
|
||||
const out: string[] = []
|
||||
parser.parse(src).iterate({
|
||||
@@ -57,7 +57,7 @@ describe('typora 扩展语法', () => {
|
||||
expect(nodes('---\ntitle: a\n...\n正文', 'FrontMatter')).toEqual(['---\ntitle: a\n...'])
|
||||
expect(nodes('正文\n\n---\na: b\n---', 'FrontMatter')).toEqual([])
|
||||
expect(nodes('正文\n\n---\n', 'HorizontalRule')).toEqual(['---'])
|
||||
// 闭合后的 --- 照常解析为分隔线
|
||||
// After front matter closes, --- is parsed as a thematic break again.
|
||||
expect(nodes('---\na: 1\n---\n\n---\n', 'HorizontalRule')).toEqual(['---'])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BlockContext, LeafBlockParser, Line, MarkdownConfig } from '@lezer/markdown'
|
||||
// Typora 扩展语法:==高亮==、$行内公式$、$$公式块$$、脚注、[TOC]、YAML front matter。
|
||||
// 上标 ^x^、下标 ~x~、:emoji: 由 @lezer/markdown 自带的扩展解析
|
||||
// Typora extensions: ==highlight==, $inline math$, $$math blocks$$, footnotes, [TOC], and YAML front matter.
|
||||
// Superscript ^x^, subscript ~x~, and :emoji: use built-in @lezer/markdown extensions.
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { inlineStyleSyntax } from './inline-styles'
|
||||
|
||||
@@ -8,7 +8,7 @@ const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\xA1\u2010-\u2027]/
|
||||
|
||||
const highlightDelim = { resolve: 'Highlight', mark: 'HighlightMark' }
|
||||
|
||||
/** ==高亮==,定界规则与 GFM 删除线相同 */
|
||||
/** ==highlight== uses the same delimiter rules as GFM strikethrough. */
|
||||
const Highlight: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Highlight', style: { 'Highlight/...': tags.special(tags.content) } },
|
||||
@@ -39,8 +39,8 @@ const Highlight: MarkdownConfig = {
|
||||
}
|
||||
|
||||
/**
|
||||
* $行内公式$(Pandoc 规则):开头 $ 后不能是空白,结尾 $ 前不能是空白、后面不能紧跟数字,
|
||||
* 因此"$5 和 $10"这类金额不会被当成公式;\$ 为转义
|
||||
* $inline math$ follows Pandoc rules: no whitespace after the opening $ or before the closing $, and
|
||||
* no digit immediately after the closing $. Thus "$5 and $10" is not math; \$ is escaped.
|
||||
*/
|
||||
const InlineMath: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
@@ -70,8 +70,8 @@ const InlineMath: MarkdownConfig = {
|
||||
const isMathFence = (line: Line) => /^\$\$\s*$/.test(line.text.slice(line.pos))
|
||||
|
||||
/**
|
||||
* 独占一行的 $$ 之间为公式块,未闭合时延续到文档末尾。只在顶层识别:解析器的公开接口无法判断
|
||||
* 下一行是否仍属于引用、列表等容器,与表格一样,容器内的公式块以源码显示
|
||||
* Standalone $$ lines delimit math blocks; unclosed blocks extend to the document end. Recognize only top-level blocks: the public parser
|
||||
* API cannot tell whether the next line remains in a quote/list container, so nested blocks show source, like tables.
|
||||
*/
|
||||
const BlockMath: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
@@ -103,7 +103,7 @@ const BlockMath: MarkdownConfig = {
|
||||
|
||||
const FOOTNOTE_LABEL = /^\[\^([^\]\s]+)\]/
|
||||
|
||||
/** 脚注引用 [^label] 与脚注定义 [^label]: 内容(定义可跨行,直到空行) */
|
||||
/** Footnote references [^label] and definitions [^label]: content; definitions may span lines until a blank line. */
|
||||
const Footnote: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'FootnoteReference', style: tags.labelName },
|
||||
@@ -128,8 +128,8 @@ const Footnote: MarkdownConfig = {
|
||||
const m = /^\[\^[^\]\s]+\]:/.exec(leaf.content)
|
||||
if (!m) return null
|
||||
const self: LeafBlockParser = {
|
||||
// "[^1]: 文字"同时也是合法的链接引用定义;本解析器排在前面,续行时去掉其他候选,整块归为脚注定义。
|
||||
// 须原地修改:调用方正在遍历这个数组
|
||||
// "[^1]: text" is also a valid link reference definition; run this parser first and remove other continuation candidates to keep the entire block a footnote.
|
||||
// Mutate in place because the caller is iterating this array.
|
||||
nextLine(_cx, _line, leaf) {
|
||||
leaf.parsers.splice(0, leaf.parsers.length, self)
|
||||
return false
|
||||
@@ -148,7 +148,7 @@ const Footnote: MarkdownConfig = {
|
||||
],
|
||||
}
|
||||
|
||||
/** 独占一行的 [TOC](不区分大小写)显示为目录 */
|
||||
/** A standalone [TOC], case-insensitively, displays a table of contents. */
|
||||
const TableOfContents: MarkdownConfig = {
|
||||
defineNodes: [{ name: 'TableOfContents', block: true, style: tags.meta }],
|
||||
parseBlock: [
|
||||
@@ -168,8 +168,8 @@ const TableOfContents: MarkdownConfig = {
|
||||
const isFrontMatterEnd = (line: Line) => /^(---|\.\.\.)\s*$/.test(line.text)
|
||||
|
||||
/**
|
||||
* 文档首行为 --- 时,到下一个 --- 或 ... 为 YAML front matter。解析器无法向后预读整个文档,
|
||||
* 未闭合时延续到文末——与 Typora(首行输入 --- 即创建 front matter)及 GitHub、Hugo 的识别方式一致
|
||||
* When the first line is ---, YAML front matter runs through the next --- or ... . The parser cannot look ahead
|
||||
* through the whole document, so unclosed front matter extends to the end, matching Typora's immediate creation after --- and GitHub/Hugo recognition.
|
||||
*/
|
||||
const FrontMatter: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
|
||||
@@ -6,7 +6,7 @@ import { commonmarkParser, renderMarkdown } from './html'
|
||||
const window = new Window()
|
||||
const document = window.document
|
||||
|
||||
// 规范化 HTML:解析为 DOM 后重新序列化,消除实体写法、标签间空白和 URL 编码差异
|
||||
// Normalize HTML by parsing and reserializing the DOM to remove entity, inter-tag whitespace, and URL-encoding differences.
|
||||
function normalize(html: string): string {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = html
|
||||
@@ -17,7 +17,7 @@ function normalize(html: string): string {
|
||||
try {
|
||||
el.setAttribute(attr, decodeURI(v))
|
||||
} catch {
|
||||
// 非法的百分号序列保持原样比较
|
||||
// Compare invalid percent-escape sequences without changing them.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,9 @@ const examples = specTests.map(t => ({
|
||||
html: t.html.replace(/→/g, '\t'),
|
||||
}))
|
||||
|
||||
// @lezer/markdown 1.8.0 解析结果本身偏离规范的用例(已逐个核对语法树,非渲染器问题):
|
||||
// 制表符 ATX 标题、空列表项后的段落、<textarea> HTML 块、方括号嵌套与链接优先级、非法 HTML 标签/注释。
|
||||
// 用 it.fails 标记:上游修复后这些用例会转为通过并报错,提醒移出此列表。
|
||||
// Cases where @lezer/markdown 1.8.0 itself differs from the specification (syntax trees checked individually; not renderer defects):
|
||||
// Tab-separated ATX headings, paragraphs after empty list items, <textarea> HTML blocks, bracket nesting/link precedence, and invalid HTML tags/comments.
|
||||
// Use it.fails so an upstream fix makes these cases pass and fail the test run, prompting removal from this list.
|
||||
const PARSER_DEVIATIONS = new Set([10, 171, 280, 493, 512, 523, 528, 569, 571, 621, 625, 626])
|
||||
|
||||
describe('安全模式', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parser as commonmarkParser, Emoji, GFM, Subscript, Superscript } from '
|
||||
import { typoraExtensions } from './extensions'
|
||||
import { inlineStyleText, stylesFromTag } from './inline-styles'
|
||||
|
||||
// 与编辑器一致的语法集(lang-markdown 的 markdownLanguage 加 Typora 扩展)
|
||||
// Match the editor's syntax set: lang-markdown's markdownLanguage plus Typora extensions.
|
||||
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji, ...typoraExtensions])
|
||||
|
||||
export { commonmarkParser }
|
||||
@@ -27,7 +27,7 @@ export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"]/g, c => (c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"'))
|
||||
}
|
||||
|
||||
// 属性值中保留实体引用(交给浏览器解码),只转义其余的特殊字符
|
||||
// Preserve entity references in attributes for browser decoding; escape only other special characters.
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&(?!(?:#\d{1,7}|#x[\da-f]{1,6}|[A-Z][A-Z\d]{1,31});)|[<>"]/gi, c =>
|
||||
c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"')
|
||||
@@ -37,7 +37,7 @@ function unescapeBackslashes(s: string): string {
|
||||
return s.replace(/\\([\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E])/g, '$1')
|
||||
}
|
||||
|
||||
// 对非法 URL 字符做百分号编码,已有的 %XX 保留
|
||||
// Percent-encode invalid URL characters while preserving existing %XX escapes.
|
||||
function normalizeUrl(s: string): string {
|
||||
return s.replace(/%(?![\da-f]{2})|[^\w\-.~:/?#[\]@!$&'()*+,;=%]/gi, c => encodeURIComponent(c))
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function tabWidth(col: number): number {
|
||||
|
||||
export interface RenderOptions {
|
||||
parser?: MarkdownParser
|
||||
// 为 true 时原样输出原始 HTML、不过滤危险链接协议;仅用于可信内容(如规范测试)
|
||||
// When true, preserve raw HTML and unsafe link schemes; use only with trusted content such as specification tests.
|
||||
unsafe?: boolean
|
||||
}
|
||||
|
||||
@@ -60,12 +60,12 @@ const UNSAFE_PROTOCOL = /^\s*(javascript|vbscript|file|data):/i
|
||||
const SAFE_DATA_IMAGE = /^\s*data:image\/(png|gif|jpeg|webp);/i
|
||||
|
||||
function safeHref(href: string): string {
|
||||
// 协议由 ASCII 组成;属性实体会被浏览器解码,URL 解析还会忽略制表符和换行。
|
||||
// Schemes are ASCII; browsers decode attribute entities, and URL parsing also ignores tabs and newlines.
|
||||
const protocol = href.replace(/&#(?:(\d{1,7})|[xX]([\da-fA-F]{1,6}));|&(colon|Tab|NewLine);/g, (entity, decimal, hex, named) => {
|
||||
if (named) return named === 'colon' ? ':' : named === 'Tab' ? '\t' : '\n'
|
||||
const code = decimal ? Number(decimal) : Number.parseInt(hex, 16)
|
||||
return code <= 127 ? String.fromCharCode(code) : entity
|
||||
// eslint-disable-next-line no-control-regex -- URL 安全检查必须移除 ASCII 控制字符。
|
||||
// eslint-disable-next-line no-control-regex -- URL safety checks must remove ASCII control characters.
|
||||
}).replace(/[\t\r\n]/g, '').replace(/^[\x00-\x20]+/, '')
|
||||
return UNSAFE_PROTOCOL.test(protocol) && !SAFE_DATA_IMAGE.test(protocol) ? '#' : href
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return node ? unescapeBackslashes(text(node.from + 1, node.to - 1)) : null
|
||||
}
|
||||
|
||||
// ---- 容器前缀剥离:代码块、HTML 块需要按 CommonMark 的列规则去掉引用符与列表缩进 ----
|
||||
// ---- Strip container prefixes: code and HTML blocks must remove quote markers and list indentation using CommonMark column rules ----
|
||||
|
||||
function containers(node: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = []
|
||||
@@ -125,7 +125,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return col
|
||||
}
|
||||
|
||||
// 列表项内容的起始列(绝对列号)
|
||||
// Absolute starting column of list-item content.
|
||||
function itemContentColumn(item: SyntaxNode): number {
|
||||
const mark = item.getChild('ListMark')!
|
||||
const markEnd = columnAt(mark.to)
|
||||
@@ -137,7 +137,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return col
|
||||
}
|
||||
|
||||
// 去掉一行开头属于容器的部分,返回剩余文本(被拆开的制表符以空格补齐)
|
||||
// Remove the container prefix from a line and return the remaining text, padding split tabs with spaces.
|
||||
function stripContainers(lineFrom: number, cs: SyntaxNode[]): { rest: string, col: number } {
|
||||
const end = lineEnd(lineFrom)
|
||||
let i = lineFrom
|
||||
@@ -177,7 +177,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
} else {
|
||||
const target = itemContentColumn(c)
|
||||
if (lineStart(c.from) === lineFrom) {
|
||||
// 列表项首行:跳过列表标记本身
|
||||
// First list-item line: skip the list marker itself.
|
||||
const mark = c.getChild('ListMark')!
|
||||
col = columnAt(mark.to)
|
||||
i = mark.to
|
||||
@@ -191,11 +191,11 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
eatColumns(target)
|
||||
}
|
||||
}
|
||||
// pad 个空格(被拆开的制表符剩余部分)从第 col 列开始
|
||||
// pad spaces (the remainder of a split tab) start at column col.
|
||||
return { rest: ' '.repeat(pad) + src.slice(i, end), col }
|
||||
}
|
||||
|
||||
// 去掉至多 n 列的前导空白;startCol 为该行文本起始的绝对列,用于计算制表符宽度
|
||||
// Remove up to n columns of leading whitespace; startCol is the line's absolute starting column for tab-width calculation.
|
||||
function stripIndent({ rest: line, col: startCol }: Line, n: number): string {
|
||||
let col = 0
|
||||
let i = 0
|
||||
@@ -212,7 +212,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
const cs = containers(node)
|
||||
const lines: Line[] = []
|
||||
let pos = lineStart(node.from)
|
||||
// 未闭合的块会延伸到文末换行之后,那一行不属于内容
|
||||
// Unclosed blocks extend past the final newline; that trailing line is not content.
|
||||
const last = lineStart(node.to > node.from && src[node.to - 1] === '\n' ? node.to - 1 : node.to)
|
||||
for (let first = true; pos <= last; first = false) {
|
||||
const isLast = pos === last
|
||||
@@ -223,12 +223,12 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return lines
|
||||
}
|
||||
|
||||
// ---- 行内 ----
|
||||
// ---- Inline ----
|
||||
|
||||
function inline(parent: SyntaxNode, from: number, to: number): string {
|
||||
let out = ''
|
||||
let pos = from
|
||||
// 引用符和硬换行之后的行首空白不属于内容
|
||||
// Leading whitespace after quote markers or hard breaks is not content.
|
||||
let afterQuote = false
|
||||
for (let ch = parent.firstChild; ch; ch = ch.nextSibling) {
|
||||
if (ch.to <= from || ch.from >= to) continue
|
||||
@@ -289,7 +289,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
const styles = stylesFromTag(text(open.from, open.to))!
|
||||
return `<span style="${escapeAttr(inlineStyleText(styles))}">${innerOf(node, 'InlineStyleMark')}</span>`
|
||||
}
|
||||
// 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充
|
||||
// Math and emoji emit placeholders; render.ts hydrate loads dependencies and fills them as needed.
|
||||
case 'InlineMath': {
|
||||
const tex = text(node.from + 1, node.to - 1)
|
||||
return `<span data-math="${escapeHtml(tex)}">${escapeHtml(tex)}</span>`
|
||||
@@ -320,7 +320,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return `<a href="${escapeAttr(linkHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
|
||||
}
|
||||
case 'URL': {
|
||||
// GFM 裸链接
|
||||
// GFM autolinks.
|
||||
const raw = text(node.from, node.to)
|
||||
const href = raw.startsWith('www.') ? `http://${raw}` : raw.includes('@') && !raw.includes(':') ? `mailto:${raw}` : raw
|
||||
return `<a href="${escapeAttr(linkHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
|
||||
@@ -349,7 +349,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
const key = label && text(label.from, label.to) !== '[]' ? text(label.from, label.to) : text(labelFrom - 1, closeBracket.to)
|
||||
def = defs.get(normalizeLabel(key))
|
||||
if (!def) {
|
||||
// 引用未定义:按字面文本输出
|
||||
// Undefined reference: output literal text.
|
||||
const tail = label ? escapeHtml(unescapeBackslashes(text(label.from, label.to))) : ''
|
||||
return `${isImage ? '!' : ''}[${content}]${tail}`
|
||||
}
|
||||
@@ -359,7 +359,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return `<a href="${escapeAttr(linkHref(def.href))}"${title}>${content}</a>`
|
||||
}
|
||||
|
||||
// ---- 块级 ----
|
||||
// ---- Blocks ----
|
||||
|
||||
function isBlank(line: string): boolean {
|
||||
return /^[ \t>]*$/.test(line)
|
||||
@@ -477,10 +477,10 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
const name = text(label.from + 2, label.to - 2)
|
||||
return `<div class="footnote" id="${escapeAttr(footnoteId(name))}"><sup>${escapeHtml(name)}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
|
||||
}
|
||||
// 目录占位,由调用方根据标题填充
|
||||
// Table-of-contents placeholder; the caller fills it from headings.
|
||||
case 'TableOfContents':
|
||||
return '<nav class="toc"></nav>\n'
|
||||
// front matter 是元数据,不输出
|
||||
// Front matter is metadata and is not rendered.
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
@@ -531,7 +531,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
return blocks(tree.topNode)
|
||||
}
|
||||
|
||||
// 渲染单行行内 Markdown(表格单元格等),不产生块级结构
|
||||
// Render single-line inline Markdown, such as table cells, without creating block structures.
|
||||
export function renderInlineMarkdown(src: string, options?: RenderOptions): string {
|
||||
const html = renderMarkdown(src, options)
|
||||
const m = /^<p>([\s\S]*)<\/p>\n$/.exec(html)
|
||||
|
||||
@@ -6,7 +6,7 @@ export const HOLLOW = '0.2px currentColor'
|
||||
export const SHADOW = '1px 1px 2px currentColor'
|
||||
export type InlineStyles = Partial<Record<'color' | 'background-color' | 'font-family' | 'font-size' | '-webkit-text-stroke' | '-webkit-text-fill-color' | 'text-shadow', string>>
|
||||
|
||||
/** 笔记是外部输入;仅允许工具栏实际生成的样式,不开放通用 HTML/CSS。 */
|
||||
/** Notes are external input; allow only styles actually produced by the toolbar, not general HTML/CSS. */
|
||||
export function parseInlineStyles(value: string): InlineStyles | null {
|
||||
const result: InlineStyles = {}
|
||||
for (const declaration of value.split(';')) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 浏览器在呈现链接属性时会解码实体;源码操作也须按相同规则解析目标。
|
||||
// Browsers decode entities in link attributes; source operations must resolve destinations using the same rules.
|
||||
export function decodeLinkDestination(raw: string): string {
|
||||
const field = document.createElement('textarea')
|
||||
return raw.replace(/\\([\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E])/g, '$1').replace(/&(?:#\d{1,7}|#x[\da-f]{1,6}|[A-Z][A-Z\d]{1,31});/gi, (entity) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 数学公式、Mermaid 图表、emoji 的渲染。依赖体积较大,首次用到时才加载(各自打成独立的包)
|
||||
// Render math, Mermaid, and emoji. Load these large dependencies on first use as separate chunks.
|
||||
import type { MermaidConfig } from 'mermaid'
|
||||
|
||||
let katexLoad: Promise<typeof import('katex').default> | undefined
|
||||
@@ -7,7 +7,7 @@ function loadKatex() {
|
||||
return katexLoad
|
||||
}
|
||||
|
||||
/** 渲染公式;语法错误以红色显示错误信息而不是抛出 */
|
||||
/** Render math; display syntax errors in red instead of throwing. */
|
||||
export async function renderMath(el: HTMLElement, tex: string, displayMode: boolean): Promise<void> {
|
||||
const katex = await loadKatex()
|
||||
katex.render(tex, el, { displayMode, throwOnError: false, output: 'htmlAndMathml' })
|
||||
@@ -20,19 +20,19 @@ function loadMermaid() {
|
||||
return mermaidLoad
|
||||
}
|
||||
|
||||
/** 渲染 Mermaid 图表,返回 SVG 标记;语法错误时抛出带说明的 Error */
|
||||
/** Render Mermaid to SVG markup; throw a descriptive Error for invalid syntax. */
|
||||
async function renderDiagram(code: string): Promise<string> {
|
||||
const mermaid = await loadMermaid()
|
||||
const mode = document.documentElement.dataset.rmColorMode
|
||||
const dark = mode === 'dark' || (mode === undefined && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// 配置与渲染串行进行,避免切换模式时并发图表读到另一个模式的全局配置。
|
||||
// Serialize configuration and rendering so concurrent diagrams cannot read another mode's global configuration during a mode switch.
|
||||
const config: MermaidConfig = { startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' }
|
||||
mermaid.initialize(config)
|
||||
const id = `rm-mermaid-${++mermaidSeq}`
|
||||
try {
|
||||
return (await mermaid.render(id, code)).svg
|
||||
} catch (err) {
|
||||
// 出错时 mermaid 会把临时节点留在 body 中
|
||||
// Mermaid leaves temporary nodes in body after errors.
|
||||
document.getElementById(`d${id}`)?.remove()
|
||||
throw err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function renderMermaid(code: string): Promise<string> {
|
||||
|
||||
let emojiLoad: Promise<Map<string, string>> | undefined
|
||||
|
||||
/** 按 GitHub/Typora 的名称查找 emoji,如 smile → 😄;未知名称返回 undefined */
|
||||
/** Look up emoji by GitHub/Typora names, such as smile -> smiling emoji; return undefined for unknown names. */
|
||||
export async function emojiFor(name: string): Promise<string | undefined> {
|
||||
emojiLoad ??= import('gemoji').then(({ gemoji }) => {
|
||||
const map = new Map<string, string>()
|
||||
@@ -59,7 +59,7 @@ export async function emojiFor(name: string): Promise<string | undefined> {
|
||||
return (await emojiLoad).get(name)
|
||||
}
|
||||
|
||||
/** 为渲染器输出的占位元素(表格单元格等处)填充公式与 emoji */
|
||||
/** Fill renderer placeholders, including table cells, with math and emoji. */
|
||||
export function hydrate(root: HTMLElement) {
|
||||
for (const el of root.querySelectorAll<HTMLElement>('[data-math]')) {
|
||||
void renderMath(el, el.dataset.math!, el.tagName === 'DIV')
|
||||
|
||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ export interface HostModules {
|
||||
export interface PluginCommand {
|
||||
id: string
|
||||
title: string
|
||||
/** 保留声明兼容;默认不绑定,请用户在设置中添加快捷键。 */
|
||||
/** Retain declaration compatibility; do not bind by default. Users can add shortcuts in settings. */
|
||||
keys?: string[]
|
||||
when?: () => boolean
|
||||
run: () => Awaitable<void>
|
||||
|
||||
@@ -241,7 +241,7 @@ async function pump() {
|
||||
loading = true
|
||||
const currentSession = session
|
||||
try {
|
||||
// eslint-disable-next-line no-unmodified-loop-condition -- await 期间可由停用或刷新变更会话。
|
||||
// eslint-disable-next-line no-unmodified-loop-condition -- Disabling or refreshing may change the session during await.
|
||||
while (running && currentSession === session && !pluginState.error) {
|
||||
const next = pluginState.installed.find(p => p.enabled && !instances.has(p.id) && faults.get(p.id) !== p.revision)
|
||||
if (!next) break
|
||||
@@ -316,7 +316,7 @@ export function retryPlugin(id: string) {
|
||||
export function pluginEditorFailure(error: unknown) {
|
||||
const owners = new Set([...editorExtensions.values(), ...markdownSyntax.values()].map(entry => entry.owner))
|
||||
if (!owners.size) throw error
|
||||
// CodeMirror 的事务/解析错误无法可靠定位任意第三方扩展;先保留文档并撤回外部扩展。
|
||||
// CodeMirror transaction/parser errors cannot reliably identify arbitrary third-party extensions; preserve the document and remove external extensions first.
|
||||
queueMicrotask(() => {
|
||||
for (const owner of owners) {
|
||||
const instance = instances.get(owner)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AsyncCleanup, Awaitable, MountContext } from '@@/plugin-api'
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
|
||||
// 每次挂载使用独立容器;超时或停用后的异步渲染只能修改已脱离页面的容器。
|
||||
// Each mount gets its own container; asynchronous rendering after timeout or disable can only modify a detached container.
|
||||
export function mountContent(
|
||||
parent: HTMLElement,
|
||||
mount: (context: MountContext) => Awaitable<void | AsyncCleanup>,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand
|
||||
// Write to the clipboard; Clipboard API requires HTTPS/localhost, so LAN HTTP falls back to execCommand.
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 权限被拒时继续尝试旧方法
|
||||
// Try the legacy method if permission is denied.
|
||||
}
|
||||
}
|
||||
const ta = document.createElement('textarea')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
|
||||
// Command registry: command-palette actions and global shortcuts originate here; plugins also add commands through registerCommand.
|
||||
import { shallowReactive } from 'vue'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
@@ -12,16 +12,16 @@ export { formatKeys, matchKeys } from './keys'
|
||||
export interface Command {
|
||||
id: string
|
||||
title: string
|
||||
/** 快捷键,如 'Mod+Shift+F'、'F1';Mod 在 Mac 上为 Cmd,其他平台为 Ctrl。第一个用于显示 */
|
||||
/** Shortcuts such as 'Mod+Shift+F' or 'F1'; Mod is Cmd on Mac and Ctrl elsewhere. The first binding is displayed. */
|
||||
keys?: string[]
|
||||
/** 返回 false 时命令不可用:不在面板中列出,快捷键也不触发 */
|
||||
/** Returning false disables the command in both the palette and shortcut dispatch. */
|
||||
when?: () => boolean
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const commands = shallowReactive(new Map<string, Command>())
|
||||
|
||||
/** 注册命令,返回注销函数;id 重复时后注册的覆盖先注册的 */
|
||||
/** Register a command and return its disposer; later registrations replace earlier ones with the same id. */
|
||||
export function registerCommand(cmd: Command): () => void {
|
||||
commands.set(cmd.id, cmd)
|
||||
return () => {
|
||||
@@ -29,11 +29,11 @@ export function registerCommand(cmd: Command): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部已注册的命令(含当前不可用的),用于快捷键一览 */
|
||||
/** All registered commands, including unavailable ones, for the shortcut overview. */
|
||||
export function allCommands(): Command[] {
|
||||
const all = [...commands.values()]
|
||||
const plugins = all.filter(command => command.id.startsWith('plugin:'))
|
||||
// 动态启停不能改变快捷键优先级;同一插件内仍保留注册顺序。
|
||||
// Dynamic enable/disable must not change shortcut priority; retain registration order within each plugin.
|
||||
plugins.sort((a, b) => {
|
||||
const left = a.id.split(':')[1]; const right = b.id.split(':')[1]
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
@@ -65,14 +65,14 @@ export function shortcutConflict(id: string, keys: string[]): Command | undefine
|
||||
return allCommands().find(command => command.id !== id && commandKeys(command).some(existing => keys.some(key => physicalKeys(existing) === physicalKeys(key))))
|
||||
}
|
||||
|
||||
/** 全局快捷键分发;编辑器等已处理的按键(defaultPrevented)和输入法组字中的按键不处理 */
|
||||
/** Dispatch global shortcuts, ignoring defaultPrevented keys already handled by editors and keys during IME composition. */
|
||||
export function handleShortcut(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || e.isComposing || e.getModifierState('AltGraph') || (e.target instanceof Element && e.target.closest('[data-shortcut-recorder]'))) return
|
||||
for (const cmd of availableCommands()) {
|
||||
if (cmd.id.startsWith('editor.') && !(e.target instanceof Element && e.target.closest('.cm-content'))) continue
|
||||
if (commandKeys(cmd).some(k => matchKeys(e, k))) {
|
||||
e.preventDefault()
|
||||
// 捕获阶段已执行命令,不再让编辑器或弹窗重复处理同一次按键。
|
||||
// The capture phase already ran the command; prevent editors or dialogs from handling the same key again.
|
||||
e.stopPropagation()
|
||||
cmd.run()
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ElMessageBox } from 'element-plus'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
/** 确认等待期间换会话时,旧操作视为取消。 */
|
||||
/** Treat the old operation as canceled if the session changes while awaiting confirmation. */
|
||||
export async function confirmAction(message: string, title = translate('common.confirm_action_f8347f')): Promise<boolean> {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 线性图标(24×24 视框,描边绘制)
|
||||
// Outline icons (24x24 viewBox, stroke-based drawing).
|
||||
export const iconPaths = {
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
// 应用、编辑器和快捷键录入共用同一套按键表示。
|
||||
// The application, editor, and shortcut recorder share one key representation.
|
||||
export const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
const punctuation: Record<string, string> = { BracketLeft: '[', BracketRight: ']', Backquote: '`' }
|
||||
const modifiers = ['Mod', 'Ctrl', 'Meta', 'Alt', 'Shift']
|
||||
|
||||
function physicalCharacter(event: KeyboardEvent): string | undefined {
|
||||
if (/^Digit\d$/.test(event.code)) return event.code.slice(5)
|
||||
// macOS Option 会把 Q/O/U 等变成其他字符,快捷键仍按原物理字母匹配。
|
||||
// macOS Option transforms letters such as Q/O/U; shortcuts still match the original physical letter.
|
||||
if (isMac && event.altKey && /^Key[A-Z]$/.test(event.code)) return event.code.slice(3)
|
||||
return punctuation[event.code]
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function matchKeys(event: KeyboardEvent, value: string): boolean {
|
||||
export function recordedKeys(event: KeyboardEvent): string | null {
|
||||
if (['Control', 'Meta', 'Alt', 'Shift', 'AltGraph', 'Dead', 'Process', 'Unidentified'].includes(event.key) || event.isComposing) return null
|
||||
const parts: string[] = []
|
||||
// 同时按下两种修饰键时保留物理组合,避免同步后 Mod 与另一键重合。
|
||||
// Preserve physical combinations when both modifiers are held so synchronized Mod bindings do not collapse onto the other key.
|
||||
if (event.ctrlKey) parts.push(isMac || event.metaKey ? 'Ctrl' : 'Mod')
|
||||
if (event.metaKey) parts.push(!isMac || event.ctrlKey ? 'Meta' : 'Mod')
|
||||
if (event.altKey) parts.push('Alt')
|
||||
|
||||
@@ -29,7 +29,7 @@ export function noteTitle(path: string): string {
|
||||
return basename(path).replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
// 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw
|
||||
// Resolve note image sources to loadable URLs: preserve absolute URLs and resolve relative paths against the note folder through the raw-resource endpoint.
|
||||
export function resolveAssetUrl(notePath: string, src: string): string {
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return src
|
||||
const parts = dirname(notePath) ? dirname(notePath).split('/') : []
|
||||
@@ -42,7 +42,7 @@ export function resolveAssetUrl(notePath: string, src: string): string {
|
||||
try {
|
||||
decoded = decodeURIComponent(seg)
|
||||
} catch {
|
||||
// 非法的百分号序列按原样使用
|
||||
// Preserve invalid percent-escape sequences as written.
|
||||
}
|
||||
parts.push(decoded)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function resolveAssetUrl(notePath: string, src: string): string {
|
||||
return `/api/v1/raw/${encodePath(parts.join('/'))}`
|
||||
}
|
||||
|
||||
// 导航隐藏普通共享附件与笔记附件,不改变这些目录的读写契约。
|
||||
// Hide shared and note-specific attachment folders from navigation without changing their read/write contracts.
|
||||
export function hiddenAssetPath(path: string): boolean {
|
||||
return path.split('/').some(part => part === 'assets' || part.endsWith('.assets'))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function installApp() {
|
||||
}
|
||||
|
||||
export async function registerPwa() {
|
||||
// 开发服务器不注册,避免外壳缓存干扰热更新。
|
||||
// Do not register on the development server, where shell caching would interfere with hot updates.
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator) || !window.isSecureContext) return
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
||||
@@ -40,7 +40,7 @@ export async function registerPwa() {
|
||||
trackInstalling()
|
||||
registration.addEventListener('updatefound', trackInstalling)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
// 其他标签页激活新版时,本页也保留手动更新入口。
|
||||
// Keep the manual update entry point when another tab activates the new version.
|
||||
waitingWorker.value = navigator.serviceWorker.controller
|
||||
})
|
||||
const checkUpdate = () => {
|
||||
@@ -50,7 +50,7 @@ export async function registerPwa() {
|
||||
}
|
||||
document.addEventListener('visibilitychange', checkUpdate)
|
||||
window.addEventListener('online', checkUpdate)
|
||||
// 新旧页面报告入口哈希,确认所有页面均已更新后才回收旧资源。
|
||||
// Old and new pages report entry hashes; reclaim old resources only after every page updates.
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if (event.data === 'SHELL_ENTRY') {
|
||||
const script = document.querySelector<HTMLScriptElement>('script[type="module"][src]')!
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function prepareResources() {
|
||||
pending = request
|
||||
void request.finally(() => {
|
||||
if (pending === request) pending = undefined
|
||||
}).catch(() => { /* 由等待初始化的调用方处理失败。 */ })
|
||||
}).catch(() => { /* The caller awaiting initialization handles failures. */ })
|
||||
}
|
||||
await pending
|
||||
user.assertSession(current)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isMac } from './keys'
|
||||
|
||||
// https://support.typora.io/Shortcut-Keys/;仅为已有功能提供默认值。
|
||||
// https://support.typora.io/Shortcut-Keys/; provide defaults only for existing actions.
|
||||
export function typoraKeys(id: string, mac = isMac): string[] {
|
||||
if (/^editor\.heading[1-6]$/.test(id)) return [`Mod+${id.at(-1)}`]
|
||||
const common: Record<string, string[]> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 软键盘弹出时,iOS Safari 与新版 Android Chrome 只缩小可视区域、页面高度不变,
|
||||
// 编辑器底部(光标、键盘上方的工具条)会被键盘挡住。把应用高度设为可视区域的高度。
|
||||
// 双指缩放也会改变可视区域,缩放时不调整
|
||||
// When software keyboards open, iOS Safari and recent Android Chrome shrink the visual viewport without changing page height,
|
||||
// covering the editor bottom, cursor, and toolbar. Set application height to the visual viewport height.
|
||||
// Pinch zoom also changes the visual viewport; do not resize while zooming.
|
||||
export function trackVisualViewport(onShrink: () => void) {
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
@@ -8,7 +8,7 @@ export function trackVisualViewport(onShrink: () => void) {
|
||||
const update = () => {
|
||||
const height = vv.scale > 1.01 ? null : vv.height
|
||||
document.documentElement.style.setProperty('--rm-app-height', height === null ? '100%' : `${height}px`)
|
||||
// iOS 会把页面整体上推以露出输入位置,应用高度已经适配,推回原位
|
||||
// iOS shifts the page to reveal the input; application height already accounts for the keyboard, so restore its position.
|
||||
if (height !== null && vv.offsetTop > 0) window.scrollTo(0, 0)
|
||||
if (height !== null && height < last) requestAnimationFrame(onShrink)
|
||||
last = vv.height
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ApiError extends Error {
|
||||
interface RequestConfig extends Omit<AxiosRequestConfig<unknown>, 'auth'> {
|
||||
anonymous?: boolean
|
||||
errorOwner?: 'request' | 'caller'
|
||||
/** 原文和下载需要 ETag、Content-Disposition 等响应头。 */
|
||||
/** Raw files and downloads need response headers such as ETag and Content-Disposition. */
|
||||
returnResponse?: boolean
|
||||
}
|
||||
const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCredentials: false })
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ watch(() => user.sessionVersion, () => {
|
||||
if (!isNavigationFailure(error)) console.error('认证导航失败', error)
|
||||
})
|
||||
})
|
||||
// 即使身份恢复暂时失败,也挂载可重试的错误界面。
|
||||
// Mount a retryable error interface even if identity restoration temporarily fails.
|
||||
void router.isReady().catch(() => {}).finally(() => {
|
||||
app.mount('#app'); void registerPwa()
|
||||
})
|
||||
@@ -77,7 +77,7 @@ async function saveSnippet() {
|
||||
if (existing) {
|
||||
Object.assign(existing, draft.value, { name: draft.value.name.trim() })
|
||||
} else {
|
||||
// getRandomValues 在局域网 HTTP 开发页面也可用,randomUUID 需要安全上下文。
|
||||
// getRandomValues works on LAN HTTP development pages; randomUUID requires a secure context.
|
||||
const id = [...crypto.getRandomValues(new Uint32Array(4))].map(n => n.toString(16).padStart(8, '0')).join('')
|
||||
snippets.push({ ...draft.value, name: draft.value.name.trim(), id, enabled: true })
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ async function restore(kind: BackupKind, event: Event) {
|
||||
if (((await getAuthStateApi()).data).setUp) {
|
||||
location.reload(); return
|
||||
}
|
||||
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
|
||||
} catch { /* Keep waiting while the service stops and rebinds; report a final timeout explicitly. */ }
|
||||
}
|
||||
throw new Error(translate('settings.restore_request_submitted_the_service_b1d2c2'))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: t
|
||||
}, trigger: 'blur' }] }
|
||||
const user = useUserStore()
|
||||
const busy = ref(false)
|
||||
// 刚创建的令牌明文,只在本次对话框中显示
|
||||
// Plaintext of the newly created token, shown only in this dialog session.
|
||||
const created = ref<string | null>(null)
|
||||
const fmt = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'short' }))
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ export interface SettingSearchResult {
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 只搜索设置说明,不读取设置值、笔记或管理列表。 */
|
||||
/** Search setting descriptions only, without reading values, notes, or management lists. */
|
||||
export function searchSettings(query: string): SettingSearchResult[] {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return []
|
||||
|
||||
@@ -11,9 +11,9 @@ import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeVi
|
||||
const host = ref<HTMLElement>()
|
||||
const tab = computed(activeTab)
|
||||
let view: EditorView
|
||||
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置。
|
||||
// 优先用滚动快照(锚定文档位置):重设状态后视口外的行高会重新估算,单靠 scrollTop 会偏移几行;
|
||||
// 快照只对同一文档有效,笔记在后台被外部修改时退回 scrollTop
|
||||
// The displayed note (identified by an identity object that survives renaming) and each note's scroll position.
|
||||
// Prefer document-anchored scroll snapshots: resetting state reestimates offscreen line heights, so scrollTop alone may drift by several lines.
|
||||
// Snapshots apply only to the same document; fall back to scrollTop when external changes update a background note.
|
||||
interface ScrollPosition {
|
||||
doc: Text
|
||||
snapshot: StateEffect<unknown>
|
||||
@@ -34,11 +34,11 @@ function show() {
|
||||
shown = undefined
|
||||
return
|
||||
}
|
||||
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
|
||||
// Reset only on note switches or when another operation replaces the entire state, such as draft restoration.
|
||||
if (shown !== ref || state.doc !== view.state.doc) {
|
||||
view.setState(state)
|
||||
const pos = scrollPositions.get(ref)
|
||||
// 等一帧:v-show 在此回调之后才取消 display:none,隐藏状态下无法滚动和聚焦
|
||||
// Wait one frame: v-show removes display:none after this callback, and hidden elements cannot scroll or focus.
|
||||
requestAnimationFrame(() => {
|
||||
if (shown !== ref || revealPending(view, ref.path)) return
|
||||
if (pos?.doc === view.state.doc) view.dispatch({ effects: pos.snapshot })
|
||||
|
||||
@@ -46,13 +46,13 @@ async function submit() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// 支持修改普通 Markdown 的目标文件名,以便处理同名冲突。
|
||||
// Allow changing plain Markdown destination filenames to resolve name conflicts.
|
||||
const uploads = zipped.value ? files.value : files.value.map((file, i) => new File([file], names.value[i], { type: file.type }))
|
||||
result.value = await importNotes(props.dir, zipped.value ? folder.value.trim() : '', uploads)
|
||||
for (const p of result.value.paths) {
|
||||
for (let d = zipped.value ? p : dirname(p); d; d = dirname(d)) expanded.add(d)
|
||||
}
|
||||
// 导入文件成功后释放浏览器持有的文件,结果仍保留在弹窗中。
|
||||
// Release browser-held files after a successful import while retaining results in the dialog.
|
||||
files.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : translate('workspace.network_error_import_status_is_fb8726')
|
||||
|
||||
@@ -11,7 +11,7 @@ import { fuzzyMatch, segments } from '../fuzzy'
|
||||
import { minimalPaths } from '../selection'
|
||||
import { move, ws } from '../workspace'
|
||||
|
||||
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
|
||||
// "Move to..." selects a destination folder, supporting phones without drag-and-drop and faster desktop moves.
|
||||
const props = defineProps<{ path: string | string[] }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EditorView } from '@codemirror/view'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
|
||||
|
||||
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
|
||||
// Outline: list current note headings, jump on click, and highlight the section containing the cursor.
|
||||
interface Heading {
|
||||
level: number
|
||||
text: string
|
||||
@@ -19,7 +19,7 @@ const headings = ref<Heading[]>([])
|
||||
const current = ref(-1)
|
||||
|
||||
function collect(state: EditorState): Heading[] {
|
||||
// 长文档的语法树可能尚未解析完,最多等 50ms,之后随编辑更新
|
||||
// Long documents may not be fully parsed yet; wait up to 50ms and update on subsequent edits.
|
||||
const tree = ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state)
|
||||
const out: Heading[] = []
|
||||
tree.iterate({
|
||||
@@ -44,7 +44,7 @@ function refresh() {
|
||||
lastDoc = undefined
|
||||
return
|
||||
}
|
||||
// 只移动光标时不重新收集标题
|
||||
// Do not recollect headings for cursor-only changes.
|
||||
if (v.state.doc !== lastDoc) {
|
||||
headings.value = collect(v.state)
|
||||
lastDoc = v.state.doc
|
||||
@@ -70,7 +70,7 @@ function jump(h: Heading) {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
onMounted(() => {
|
||||
unsubscribe = onEditorUpdate(scheduleRefresh)
|
||||
// 切换标签页时编辑器整体替换状态,不经过更新监听;等编辑器面板换好状态后再读
|
||||
// Tab switches replace editor state without an update notification; read after the editor pane installs the new state.
|
||||
requestAnimationFrame(refresh)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -17,9 +17,9 @@ interface Item {
|
||||
key: string
|
||||
icon: 'note' | 'noteAdd' | 'command'
|
||||
title: string
|
||||
// 笔记为所在目录,命令为快捷键
|
||||
// Parent folder for notes; shortcut for commands.
|
||||
detail: string
|
||||
// 命中字符在标题、目录中的下标
|
||||
// Indices of matched characters in the title and folder.
|
||||
titleHits: Set<number>
|
||||
detailHits: Set<number>
|
||||
run: () => void
|
||||
@@ -29,10 +29,10 @@ const input = ref<InputInstance>()
|
||||
const list = ref<HTMLElement>()
|
||||
const query = ref(ws.switcher ?? '')
|
||||
const selected = ref(0)
|
||||
// 以 > 开头时列出命令(与 VS Code 一致),删掉 > 回到找笔记
|
||||
// A leading > lists commands, matching VS Code; remove it to search notes again.
|
||||
const commandMode = computed(() => query.value.startsWith('>'))
|
||||
|
||||
// 取消时把焦点还给打开前的元素(通常是编辑器)
|
||||
// On cancellation, return focus to the element that opened the switcher, usually the editor.
|
||||
const previousFocus = document.activeElement as HTMLElement | null
|
||||
function close() {
|
||||
ws.switcher = null
|
||||
@@ -59,7 +59,7 @@ function noteItems(q: string): Item[] {
|
||||
}
|
||||
}
|
||||
if (!q) {
|
||||
// 最近打开的在前(当前笔记除外,回车即回到上一篇),其余按修改时间
|
||||
// Recently opened notes first, excluding the current note so Enter returns to the previous one; sort others by modification time.
|
||||
const rank = (p: string) => recentRank.get(p) ?? Infinity
|
||||
const mtime = new Map(notes.map(e => [e.path, e.mtime]))
|
||||
return notes
|
||||
@@ -79,7 +79,7 @@ function noteItems(q: string): Item[] {
|
||||
(a, b) => b.score - a.score || (recentRank.get(a.path) ?? Infinity) - (recentRank.get(b.path) ?? Infinity) || a.path.length - b.path.length,
|
||||
)
|
||||
const items = scored.slice(0, MAX_ITEMS).map(s => toItem(s.path, s.positions))
|
||||
// 没有同名笔记时提供"新建";输入中的 / 表示目录
|
||||
// Offer "Create" when no note has the same name; / in the input denotes folders.
|
||||
const name = q.replace(/\.md$/i, '')
|
||||
const path = name + NOTE_EXT
|
||||
if (
|
||||
@@ -115,7 +115,7 @@ function commandItems(q: string): Item[] {
|
||||
.filter(c => c.id !== 'app.commandPalette')
|
||||
.map((c, order) => ({ c, order, m: q ? fuzzyMatch(q, c.title, 0) : { score: 0, positions: [] } }))
|
||||
.filter(x => x.m)
|
||||
// 无输入时最近用过的在前,其余保持注册顺序
|
||||
// With no query, show recently used commands first and preserve registration order for the rest.
|
||||
scored.sort((a, b) => b.m!.score - a.m!.score || rank(a.c.id) - rank(b.c.id) || a.order - b.order)
|
||||
return scored.map(({ c, m }) => ({
|
||||
key: c.id,
|
||||
|
||||
@@ -21,7 +21,7 @@ const results = ref<SearchResult[] | null>(null)
|
||||
const selected = ref(0)
|
||||
const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
|
||||
|
||||
// 只采用最后一次请求的结果,避免慢请求覆盖新结果
|
||||
// Use only the latest request's results so slow requests cannot overwrite newer results.
|
||||
let seq = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(query, (q) => {
|
||||
@@ -50,7 +50,7 @@ async function run(q: string, id: number, version: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 把文本按搜索词(不区分大小写)切分,命中部分标记为高亮 */
|
||||
/** Split text by case-insensitive search terms and mark matching fragments for highlighting. */
|
||||
function segments(text: string): { text: string, hit: boolean }[] {
|
||||
const lower = text.toLowerCase()
|
||||
const words = terms.value.map(t => t.toLowerCase())
|
||||
|
||||
@@ -60,7 +60,7 @@ function change(patch: InlineStyles) {
|
||||
run(applyInlineStyles(patch), true)
|
||||
}
|
||||
|
||||
// 保留 CM 选区,允许面板输入框获得焦点;不用 pointerdown.prevent,以免 WebKit 丢 click。
|
||||
// Preserve the CM selection while allowing panel inputs to focus; avoid pointerdown.prevent, which loses clicks in WebKit.
|
||||
function sync() {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { noteTitle } from '@@/utils/paths'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { closeTab, createNote, ws } from '../workspace'
|
||||
|
||||
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
|
||||
// Drag sorting: dropping on a tab's left half inserts before it; its right half inserts after it.
|
||||
const TAB_TYPE = 'application/x-rawmark-tab'
|
||||
const dragging = ref<string | null>(null)
|
||||
const dropAt = ref<{ path: string, after: boolean } | null>(null)
|
||||
@@ -44,7 +44,7 @@ function onDragEnd() {
|
||||
dragging.value = dropAt.value = null
|
||||
}
|
||||
|
||||
// ---- 右键菜单 ----
|
||||
// ---- Context menu ----
|
||||
const menu = ref<{ tab: Tab, x: number, y: number } | null>(null)
|
||||
|
||||
const menuElement = ref<HTMLElement>()
|
||||
|
||||
@@ -23,7 +23,7 @@ const menuPosition = ref({ left: 0, top: 0 })
|
||||
const input = ref<HTMLInputElement>()
|
||||
const draft = ref('')
|
||||
|
||||
// 长按打开菜单:Android 会触发 contextmenu,iOS Safari 不会,统一用触摸计时
|
||||
// Use a touch timer for long-press menus consistently: Android fires contextmenu, but iOS Safari does not.
|
||||
const LONG_PRESS_MS = 500
|
||||
let pressTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pressStart: { x: number, y: number } | null = null
|
||||
@@ -48,7 +48,7 @@ function cancelPress() {
|
||||
}
|
||||
|
||||
function activate(event: MouseEvent) {
|
||||
// 长按后松手产生的点击不打开笔记
|
||||
// Do not open a note on the click generated when releasing a long press.
|
||||
if (longPressed) {
|
||||
longPressed = false
|
||||
return
|
||||
@@ -138,7 +138,7 @@ function cancelRename() {
|
||||
ws.renaming = null
|
||||
}
|
||||
|
||||
// ---- 拖放移动(桌面端) ----
|
||||
// ---- Drag-and-drop moves (desktop) ----
|
||||
function onDragStart(e: DragEvent) {
|
||||
e.dataTransfer?.setData('application/x-rawmark-path', props.node.path)
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { watchFontLoading } from '@@/appearance/font-loading'
|
||||
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
|
||||
// Unauthenticated editor demo for automated performance and interaction tests.
|
||||
import { createEditorState, createEditorView } from '../editor'
|
||||
import sample from './sample.md?raw'
|
||||
import '@/i18n'
|
||||
import '@@/appearance/base.css'
|
||||
|
||||
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
|
||||
// ?repeat=N repeats the sample N times for large-document performance testing.
|
||||
const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1)
|
||||
const doc = Array.from({ length: Math.max(1, repeat) }).fill(sample).join('\n')
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
// 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复
|
||||
// Keep content not yet saved to the server in IndexedDB for restoration on the next startup.
|
||||
|
||||
export interface Draft {
|
||||
path: string
|
||||
content: string
|
||||
// 本地修改所基于的服务器版本;新建文件时为空字符串
|
||||
// Server revision underlying local changes; an empty string for new files.
|
||||
baseRev: string
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export const drafts = {
|
||||
request.onsuccess = () => {
|
||||
const moving = request.result.filter(draft => draft.path === from || draft.path.startsWith(`${from}/`))
|
||||
for (const draft of moving) store.delete(draft.path)
|
||||
// add 遇到目标路径已有草稿时中止整个事务,不能覆盖另一份未保存内容。
|
||||
// add aborts the whole transaction if a draft already exists at the destination, preserving other unsaved contents.
|
||||
for (const draft of moving) store.add({ ...draft, path: to + draft.path.slice(from.length) })
|
||||
}
|
||||
transaction.oncomplete = () => resolve()
|
||||
|
||||
@@ -10,10 +10,10 @@ import { editorFocused, setEditorFocus } from './focus'
|
||||
import { editorLocale } from './locale'
|
||||
import { CodeRendererWidget, MathWidget } from './widgets'
|
||||
|
||||
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
|
||||
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
|
||||
// Math and Mermaid blocks render as replacements while inactive; show source and a live preview below when the cursor enters the block.
|
||||
// Multiline block replacements must come from StateField; handle only top-level blocks, as with tables.
|
||||
|
||||
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
|
||||
/** Return block source and its offset from the block start so clicking rendered output places the cursor at the content start. */
|
||||
function blockContent(state: EditorState, node: SyntaxNode): { text: string, offset: number, language?: string } | null {
|
||||
if (node.name === 'BlockMath') {
|
||||
const marks = node.getChildren('BlockMathMark')
|
||||
|
||||
@@ -8,7 +8,7 @@ import { StateEffect, StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
|
||||
interface CodeBlock {
|
||||
// 代码内容(不含围栏行)在文档中的范围
|
||||
// Document range of code contents, excluding fence lines.
|
||||
from: number
|
||||
to: number
|
||||
lang: string
|
||||
@@ -17,7 +17,7 @@ interface CodeBlock {
|
||||
|
||||
const setBlockTokens = StateEffect.define<{ from: number, to: number, decorations: Range<Decoration>[] }>()
|
||||
|
||||
// 高亮结果作为 StateField 保存并随编辑映射位置:Worker 返回新结果前沿用旧颜色,避免闪烁
|
||||
// Keep highlighting in a StateField and map positions through edits; retain old colors until the Worker responds to avoid flashing.
|
||||
const codeTokenField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(value, tr) {
|
||||
@@ -72,7 +72,7 @@ export function fenceLanguage(state: EditorState, infoFrom: number, infoTo: numb
|
||||
return state.sliceDoc(infoFrom, infoTo).trim().split(/\s/)[0].toLowerCase()
|
||||
}
|
||||
|
||||
// 围栏代码块的内容范围(不含围栏行);有闭合围栏时到闭合行之前为止,否则到块尾
|
||||
// Fenced-code content range, excluding fence lines; stop before the closing line if present, otherwise at the block end.
|
||||
export function codeContentRange(state: EditorState, node: SyntaxNode): { from: number, to: number } {
|
||||
const open = state.doc.lineAt(node.from)
|
||||
const marks = node.getChildren('CodeMark')
|
||||
@@ -87,7 +87,7 @@ export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null
|
||||
return null
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选
|
||||
// Ctrl/Cmd+A first selects the current code block's contents; when already selected, defer to normal select-all.
|
||||
export function selectCodeBlock(view: EditorView): boolean {
|
||||
const { state } = view
|
||||
const main = state.selection.main
|
||||
@@ -124,7 +124,7 @@ function visibleCodeBlocks(view: EditorView): CodeBlock[] {
|
||||
|
||||
const codeHighlighter = ViewPlugin.fromClass(
|
||||
class {
|
||||
// 已应用到各代码块的结果键,按块起点记录
|
||||
// Result keys applied to code blocks, indexed by block start.
|
||||
applied = new Map<number, string>()
|
||||
destroyed = false
|
||||
|
||||
@@ -141,7 +141,7 @@ const codeHighlighter = ViewPlugin.fromClass(
|
||||
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) this.schedule()
|
||||
}
|
||||
|
||||
// 不能在 update 中同步 dispatch,推迟到微任务
|
||||
// Do not dispatch synchronously inside update; defer to a microtask.
|
||||
schedule() {
|
||||
queueMicrotask(() => this.sync())
|
||||
}
|
||||
@@ -161,9 +161,9 @@ const codeHighlighter = ViewPlugin.fromClass(
|
||||
this.applied.set(block.from, key)
|
||||
effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
|
||||
} else {
|
||||
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算
|
||||
// Run sync again when results arrive to hit the cache; changed document keys naturally trigger recalculation.
|
||||
requestTokens(block.lang, block.code).then(() => this.schedule()).catch((error) => {
|
||||
// 本次内容失败后不随每次光标移动重试,修改代码或语言后重新请求。
|
||||
// Do not retry failed content on every cursor movement; request again after code or language changes.
|
||||
if (!this.destroyed) this.applied.set(block.from, key)
|
||||
console.error('代码高亮失败', error)
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { EditorView, ViewUpdate } from '@codemirror/view'
|
||||
import { StateEffect, StateField } from '@codemirror/state'
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
|
||||
// 编辑器焦点状态。块级渲染组件(表格、公式块、Mermaid)由 StateField 提供,拿不到视图的焦点,
|
||||
// 由视图插件把焦点变化同步进状态:获得焦点且光标在块内时显示源码,否则渲染
|
||||
// (打开笔记时光标默认在文首,不应因此展开开头的块)
|
||||
// Editor focus state. StateField-based block widgets (tables, math, Mermaid) cannot access view focus directly,
|
||||
// so a view plugin syncs focus into state: show source only when focused with the cursor inside the block; otherwise render.
|
||||
// Opening a note places the cursor at the start and must not expand its first block by itself.
|
||||
export const setEditorFocus = StateEffect.define<boolean>()
|
||||
|
||||
export const editorFocused = StateField.define<boolean>({
|
||||
@@ -27,7 +27,7 @@ const focusTracker = ViewPlugin.fromClass(
|
||||
if (u.focusChanged) this.sync()
|
||||
}
|
||||
|
||||
// 不能在 update 中同步 dispatch,推迟到微任务
|
||||
// Do not dispatch synchronously inside update; defer to a microtask.
|
||||
sync() {
|
||||
queueMicrotask(() => {
|
||||
const focused = this.view.hasFocus
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EditorSelection, EditorState } from '@codemirror/state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic } from './format'
|
||||
|
||||
// 用 | 标记光标、[ ] 标记选区,执行命令后以同样的形式返回结果
|
||||
// Mark the cursor with | and selection with [ ]; return the result in the same notation after executing the command.
|
||||
function run(cmd: FormatCommand, doc: string): string {
|
||||
const ranges: { anchor: number, head: number }[] = []
|
||||
let text = ''
|
||||
@@ -59,7 +59,7 @@ describe('行首标记', () => {
|
||||
it('多行:有序列表按行编号,保留缩进', () => {
|
||||
expect(run(toggleBlock('ordered'), '[a\n b\nc]')).toBe('[1. a\n 2. b\n3. c]')
|
||||
expect(run(toggleBlock('ordered'), '[1. a\n2. b]')).toBe('[a\nb]')
|
||||
// 部分行已是该类型时统一加上
|
||||
// Apply the type consistently when only some lines already have it.
|
||||
expect(run(toggleBlock('bullet'), '[- a\nb]')).toBe('[- a\n- b]')
|
||||
})
|
||||
it('标题循环', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ChangeSpec, EditorState, TransactionSpec } from '@codemirror/state'
|
||||
// Markdown 格式命令:快捷键与选区工具栏共用
|
||||
// Markdown formatting commands shared by shortcuts and the selection toolbar.
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
|
||||
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
|
||||
/** Depend only on state and dispatch to allow testing without a DOM. */
|
||||
export interface FormatTarget {
|
||||
readonly state: EditorState
|
||||
dispatch: (spec: TransactionSpec) => void
|
||||
@@ -10,13 +10,13 @@ export interface FormatTarget {
|
||||
export type FormatCommand = (target: FormatTarget) => boolean
|
||||
|
||||
/**
|
||||
* 切换行内标记(粗体 **、斜体 *、删除线 ~~、行内代码 `):选区两侧或选区首尾已有该标记时去掉,否则加上;
|
||||
* 没有选中文字时插入一对标记,光标放在中间
|
||||
* Toggle inline markers (bold **, italic *, strikethrough ~~, inline code `): remove matching surrounding or edge
|
||||
* markers, otherwise add them. With no selection, insert a pair and place the cursor between them.
|
||||
*/
|
||||
export function toggleInline(mark: string): FormatCommand {
|
||||
const n = mark.length
|
||||
const ch = mark[0]
|
||||
// 选区两侧连续的标记字符数;* 同时用于斜体与粗体:*** 两者都有,单数个才有斜体
|
||||
// Count adjacent marker characters around the selection; * serves italic and bold: *** means both, and only odd counts imply italic.
|
||||
const runLength = (state: EditorState, pos: number, dir: 1 | -1) => {
|
||||
let k = 0
|
||||
while (state.sliceDoc(dir > 0 ? pos + k : pos - k - 1, dir > 0 ? pos + k + 1 : pos - k) === ch) k++
|
||||
@@ -54,7 +54,7 @@ export function toggleInline(mark: string): FormatCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// 行首的块标记:缩进 + 标题 / 任务 / 无序列表 / 有序列表 / 引用(每行只识别一个)
|
||||
// Leading block marker: indentation plus heading/task/bullet/ordered-list/quote (recognize one per line).
|
||||
const LINE_PREFIX = /^(\s*)(#{1,6} |[-*+] \[[ x]\] |[-*+] |\d+[.)] |> )?/i
|
||||
|
||||
type PrefixKind = 'heading' | 'task' | 'bullet' | 'ordered' | 'quote'
|
||||
@@ -67,7 +67,7 @@ function kindOf(prefix: string): PrefixKind | null {
|
||||
return prefix.includes('[') ? 'task' : 'bullet'
|
||||
}
|
||||
|
||||
/** 改写选区涉及的每一行的行首标记;next 返回新的标记 */
|
||||
/** Rewrite leading markers on every selected line; next returns the new marker. */
|
||||
function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKind | null)[], index: number) => string): boolean {
|
||||
const lineNumbers = new Set<number>()
|
||||
for (const r of state.selection.ranges) {
|
||||
@@ -83,7 +83,7 @@ function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKi
|
||||
return { from, to: from + current.length, insert: next(kinds, i) }
|
||||
})
|
||||
const changes = state.changes(specs)
|
||||
// 光标在行首时随新插入的标记后移;选区则向两端扩展,把新标记包含在内
|
||||
// Move a line-start cursor after the inserted marker; expand selections outward to include new markers.
|
||||
const selection = EditorSelection.create(
|
||||
state.selection.ranges.map((r) => {
|
||||
if (r.empty) return EditorSelection.cursor(changes.mapPos(r.head, 1))
|
||||
@@ -96,7 +96,7 @@ function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKi
|
||||
return true
|
||||
}
|
||||
|
||||
/** 切换列表或引用:所选各行都已是该类型时去掉,否则全部改为该类型(替换原有的其他块标记) */
|
||||
/** Toggle a list or quote: remove the type if all selected lines have it; otherwise apply it to all, replacing other block markers. */
|
||||
export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand {
|
||||
return target =>
|
||||
editPrefixes(target, (kinds, i) => {
|
||||
@@ -105,7 +105,7 @@ export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand
|
||||
})
|
||||
}
|
||||
|
||||
/** 标题级别循环:正文 → 一级 → 二级 → 三级 → 正文;以第一行为准 */
|
||||
/** Cycle heading levels based on the first line: body -> level 1 -> level 2 -> level 3 -> body. */
|
||||
export const cycleHeading: FormatCommand = (target) => {
|
||||
const first = LINE_PREFIX.exec(target.state.doc.lineAt(target.state.selection.main.from).text)![2] ?? ''
|
||||
const level = kindOf(first) === 'heading' ? first.length - 1 : 0
|
||||
@@ -113,7 +113,7 @@ export const cycleHeading: FormatCommand = (target) => {
|
||||
return editPrefixes(target, () => prefix)
|
||||
}
|
||||
|
||||
/** 全部选中行已是指定标题时恢复正文,否则设置为指定级别。 */
|
||||
/** Return to body text if every selected line already has the requested heading level; otherwise apply that level. */
|
||||
export function toggleHeading(level: number): FormatCommand {
|
||||
return (target) => {
|
||||
const prefix = `${'#'.repeat(level)} `
|
||||
@@ -128,7 +128,7 @@ export function toggleHeading(level: number): FormatCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/** 插入链接:选中文字作为链接文字,光标放到地址处;没有选中时光标放在文字处 */
|
||||
/** Insert a link using selected text as its label and focus the destination; without a selection, focus the label. */
|
||||
export const insertLink: FormatCommand = ({ state, dispatch }) => {
|
||||
const tr = state.changeByRange((r) => {
|
||||
const text = state.sliceDoc(r.from, r.to)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t as translate } from '@@/i18n/runtime'
|
||||
/* eslint-disable no-control-regex -- 粘贴链接必须识别并移除协议中的控制字符。 */
|
||||
/* eslint-disable no-control-regex -- Pasted links must detect and remove control characters in schemes. */
|
||||
import TurndownService from 'turndown'
|
||||
|
||||
export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise<string>, onError: (error: unknown) => void): Promise<string> {
|
||||
// template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。
|
||||
// Keep external resources and scripts inert inside template; clipboard HTML must not enter the active page.
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
const fragment = template.content
|
||||
@@ -32,7 +32,7 @@ export async function htmlToMarkdown(html: string, importImage: (url: string) =>
|
||||
images.set(src, group)
|
||||
}
|
||||
const sources = [...images]
|
||||
// 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。
|
||||
// Web pages often contain many images; bound concurrency so one paste cannot exhaust browser connections or server download memory.
|
||||
for (let offset = 0; offset < sources.length; offset += 3) {
|
||||
await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => {
|
||||
try {
|
||||
@@ -61,7 +61,7 @@ export async function htmlToMarkdown(html: string, importImage: (url: string) =>
|
||||
return `\n\n${[line(rows[0]), line(Array.from<string>({ length: columns }).fill('---')), ...rows.slice(1).map(line)].join('\n')}\n\n`
|
||||
},
|
||||
})
|
||||
// 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。
|
||||
// Wrap destinations in angle brackets so URL parentheses do not truncate Markdown; discard executable schemes.
|
||||
service.addRule('link', {
|
||||
filter: 'a',
|
||||
replacement: (content, node) => {
|
||||
|
||||
@@ -20,8 +20,8 @@ import { markdownTheme } from './theme'
|
||||
|
||||
export { insertImageFiles, pasteImages } from './paste'
|
||||
|
||||
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
||||
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
||||
// Add Typora extensions to lang-markdown's syntax set (CommonMark, GFM, superscript/subscript, and emoji).
|
||||
// Reuse its language data so markdownKeymap commands recognize Markdown through isActiveAt.
|
||||
export const pluginCompartment = new Compartment()
|
||||
export const languageCompartment = new Compartment()
|
||||
export const preferencesCompartment = new Compartment()
|
||||
@@ -49,20 +49,20 @@ export interface EditorStateOptions {
|
||||
shortcuts?: KeyBinding[]
|
||||
resolveImage?: (src: string) => string
|
||||
openLink?: (href: string) => void
|
||||
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
||||
// Notify document changes only for the state mounted in the view; callers update inactive-tab states directly.
|
||||
onChange?: (update: ViewUpdate) => void
|
||||
extensions?: Extension[]
|
||||
}
|
||||
|
||||
// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换
|
||||
// Each open note owns an EditorState with undo history; replace the entire state on tab switches.
|
||||
export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
const extensions: Extension[] = [
|
||||
history(),
|
||||
localeCompartment.of(editorLocale.of(locale.value)),
|
||||
drawSelection(),
|
||||
EditorView.lineWrapping,
|
||||
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
||||
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
||||
// Avoid lang-markdown's markdown(): its static lang-html/css/javascript imports enable nested HTML highlighting,
|
||||
// increasing the initial gzip bundle from roughly 108 KB to 180 KB, while Live Preview does not need HTML highlighting.
|
||||
languageCompartment.of(pluginLanguage()),
|
||||
pluginCompartment.of(pluginEditorExtensions()),
|
||||
EditorView.exceptionSink.of(reportEditorFailure),
|
||||
@@ -72,7 +72,7 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
codeBlockHighlight,
|
||||
editorFocus,
|
||||
previewCompartment.of(previewExtensions(opts.sourceMode ?? false)),
|
||||
// 可配置操作不再通过 CM 的默认绑定重复注册。
|
||||
// Do not register configurable actions again through CodeMirror's default bindings.
|
||||
keymap.of(defaultKeymap.filter(binding => binding.run !== selectAll && binding.run !== indentLess && binding.run !== indentMore && binding.run !== selectParentSyntax && binding.run !== toggleComment)),
|
||||
preferencesCompartment.of([
|
||||
EditorState.tabSize.of(opts.tabSize ?? 4),
|
||||
|
||||
@@ -24,16 +24,16 @@ const MARK_NODES: Record<InlineMark, string> = { StrongEmphasis: 'EmphasisMark',
|
||||
|
||||
function formattingTree(state: EditorState) {
|
||||
const to = state.selection.ranges.at(-1)!.to
|
||||
// 后台标签页或长文末尾的选区可能尚未完成增量解析;操作不能悄悄漏掉文字。
|
||||
// Selections in background tabs or near long-document ends may not be fully parsed; operations must not silently skip text.
|
||||
return ensureSyntaxTree(state, to, 50) ?? state.facet(language)!.parser.parse(state.doc.toString())
|
||||
}
|
||||
|
||||
/** 从当前语法树取文字片段,保留 Markdown 的标记、链接地址和块级结构。 */
|
||||
/** Extract text fragments from the current syntax tree, preserving Markdown markers, link destinations, and block structure. */
|
||||
function segments(state: EditorState): Segment[] {
|
||||
const result: Segment[] = []
|
||||
function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) {
|
||||
if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink', 'Escape', 'Entity'].includes(node.name)) {
|
||||
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
|
||||
// Do not split atomic syntax; when changing sibling text under the same outer tag, preserve styles on these unselected fragments.
|
||||
result.push({ from: node.from, to: node.to, atomic: true, styles, underline, formats, wrappers })
|
||||
return
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function segments(state: EditorState): Segment[] {
|
||||
for (const match of state.sliceDoc(from, to).matchAll(/[^\n]+/g)) {
|
||||
const start = from + match.index; const end = start + match[0].length
|
||||
const line = state.doc.lineAt(start)
|
||||
// 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。
|
||||
// Continuation lines can still have list indentation or quote prefixes; insert tags after the prefix.
|
||||
const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length
|
||||
const contentFrom = Math.max(start, prefixEnd)
|
||||
if (contentFrom < end) result.push({ from: contentFrom, to: end, atomic: false, styles, underline, formats, wrappers })
|
||||
@@ -116,7 +116,7 @@ function wrap(text: string, styles: InlineStyles, underline: boolean, formats: s
|
||||
return { prefix, leading: leading.length, trailing: trailing.length, marks: open.length, text: prefix + (content ? leading + open + content + close + trailing : text) + (underline ? '</u>' : '') + (css ? '</span>' : '') }
|
||||
}
|
||||
|
||||
/** 只重写选中文字及包围它的外观标签;被拆开的其余文字保留原样式。 */
|
||||
/** Rewrite only selected text and surrounding appearance tags; retain styles on remaining split fragments. */
|
||||
function editAppearance(update: (styles: InlineStyles, underline: boolean, formats: string[]) => { styles: InlineStyles, underline: boolean, formats: string[] }): FormatCommand {
|
||||
return ({ state, dispatch }) => {
|
||||
if (!canFormatSelection(state)) return false
|
||||
@@ -130,14 +130,14 @@ function editAppearance(update: (styles: InlineStyles, underline: boolean, forma
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (!selected(state, run) && !run.wrappers.some(wrapper => wrappers.has(wrapper))) continue
|
||||
// 同一外层标签内的兄弟标签也要展开,重新写回合并后的有效样式。
|
||||
// Expand sibling tags within the same outer tag too, then write back merged effective styles.
|
||||
for (const wrapper of run.wrappers) {
|
||||
if (!wrappers.has(wrapper)) {
|
||||
wrappers.add(wrapper)
|
||||
for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
|
||||
}
|
||||
}
|
||||
// 部分选中转义字符等语法单元时,格式应用于整个单元,不能把标签插进语法内部。
|
||||
// Partial selections of atomic syntax, such as escapes, format the whole unit; never insert tags inside the syntax.
|
||||
const cuts = run.atomic ? [run.from, run.to] : [...new Set([run.from, run.to, ...state.selection.ranges.flatMap(range => [Math.max(run.from, Math.min(run.to, range.from)), Math.max(run.from, Math.min(run.to, range.to))])])].sort((a, b) => a - b)
|
||||
let insert = ''
|
||||
for (let i = 0; i < cuts.length - 1; i++) {
|
||||
@@ -184,7 +184,7 @@ export const toggleUnderline: FormatCommand = (target) => {
|
||||
return editAppearance((styles, _underline, formats) => ({ styles, underline: !remove, formats }))(target)
|
||||
}
|
||||
|
||||
/** 跨段落格式化按语法片段处理,不把列表标记或链接地址包进 Markdown 定界符。 */
|
||||
/** Format across paragraphs by syntax fragment, without wrapping list markers or link destinations in Markdown delimiters. */
|
||||
export function toggleSelectionMark(mark: InlineMark): FormatCommand {
|
||||
return (target) => {
|
||||
const runs = segments(target.state).filter(run => selected(target.state, run))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('快捷键', () => {
|
||||
it('windows 上 Ctrl+Y 为重做', async () => {
|
||||
// CodeMirror 在模块加载时读取平台,需先设置再导入
|
||||
// CodeMirror reads the platform at module load; configure it before importing.
|
||||
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
|
||||
const { undo } = await import('@codemirror/commands')
|
||||
const { EditorView, runScopeHandlers } = await import('@codemirror/view')
|
||||
|
||||
@@ -21,14 +21,14 @@ import {
|
||||
TocWidget,
|
||||
} from './widgets'
|
||||
|
||||
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
|
||||
// Resolve note image paths to browser-loadable URLs, using the note's folder for relative paths.
|
||||
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
|
||||
combine: values => values[0] ?? (src => src),
|
||||
})
|
||||
|
||||
export const openLink = Facet.define<(href: string) => void, ((href: string) => void) | undefined>({ combine: values => values[0] })
|
||||
|
||||
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
|
||||
// Rebuild previews after in-place image replacement without changing text, selection, or undo history.
|
||||
export const refreshImages = StateEffect.define<null>()
|
||||
|
||||
const hidden = Decoration.replace({})
|
||||
@@ -46,8 +46,8 @@ function lineDeco(cls: string): Decoration {
|
||||
return deco
|
||||
}
|
||||
|
||||
// 块级标记(标题、引用、列表、围栏)按行展开源码,行内语法只展开光标所在的元素,
|
||||
// 与 Typora/Obsidian 一致,避免长段落中光标一落整行跳动;编辑器失焦时整篇按渲染效果显示
|
||||
// Expand block markers (headings, quotes, lists, fences) by line; expand inline syntax only around the cursor,
|
||||
// matching Typora/Obsidian and avoiding whole-line jumps in long paragraphs. Render the entire document when the editor loses focus.
|
||||
function activeLines(view: EditorView): Set<number> {
|
||||
const lines = new Set<number>()
|
||||
if (!view.hasFocus) return lines
|
||||
@@ -74,7 +74,7 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
}
|
||||
const ranges = view.hasFocus ? state.selection.ranges : []
|
||||
const touches = (node: SyntaxNode) => ranges.some(r => r.from <= node.to && r.to >= node.from)
|
||||
// 视图插件提供的替换装饰不能跨越换行
|
||||
// Replacement decorations from view plugins cannot cross newlines.
|
||||
const hide = (from: number, to: number) => {
|
||||
if (from < to && lineOf(from) === lineOf(to)) out.push(hidden.range(from, to))
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
return end
|
||||
}
|
||||
|
||||
// 脚注定义(标签 → 内容)与目录条目,只在可见区域出现相应语法时才计算
|
||||
// Compute footnote definitions (label -> content) and table-of-contents entries only when the relevant syntax is visible.
|
||||
let footnoteMap: Map<string, string> | undefined
|
||||
function footnotes() {
|
||||
if (!footnoteMap) {
|
||||
@@ -195,7 +195,7 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
n === first ? 'cm-rm-frontmatter cm-rm-frontmatter-begin' : n === last ? 'cm-rm-frontmatter cm-rm-frontmatter-end' : 'cm-rm-frontmatter')
|
||||
return false
|
||||
case 'BlockMath':
|
||||
// 未编辑时由 blocks.ts 整块替换为渲染结果,这里只负责编辑时的源码样式
|
||||
// blocks.ts replaces inactive blocks with rendered output; this code handles source styling only while editing.
|
||||
classLines(node.from, node.to, () => 'cm-rm-math-source')
|
||||
return false
|
||||
case 'InlineCode':
|
||||
@@ -244,7 +244,7 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
classLines(node.from, node.to, () => 'cm-rm-code')
|
||||
return false
|
||||
case 'Table':
|
||||
// 光标在表格内时由此以等宽源码显示(其余情况由 table.ts 渲染为可编辑表格),内部语法不做隐藏以免列对不齐
|
||||
// Show monospaced source while the cursor is in a table (otherwise table.ts renders an editable table); preserve internal syntax to keep columns aligned.
|
||||
classLines(node.from, node.to, () => 'cm-rm-table')
|
||||
return false
|
||||
case 'HTMLBlock':
|
||||
@@ -290,7 +290,7 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
const openLine = doc.lineAt(node.from)
|
||||
const lang = info ? fenceLanguage(state, info.from, info.to) : ''
|
||||
if (isActive(node.from, node.to)) {
|
||||
// 编辑中显示围栏源码,复制按钮浮在行尾
|
||||
// Show fence source while editing, with the copy button floating at line end.
|
||||
out.push(Decoration.widget({ widget: new CodeHeaderWidget(lang, false, codeAt), side: 1 }).range(openLine.to))
|
||||
return
|
||||
}
|
||||
@@ -319,7 +319,7 @@ function trailingSpaceStart(state: EditorState, pos: number): number {
|
||||
return start
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + 点击打开链接
|
||||
// Ctrl/Cmd-click opens links.
|
||||
function linkAt(state: EditorState, pos: number): string | null {
|
||||
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
|
||||
if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
|
||||
@@ -348,7 +348,7 @@ function linkAt(state: EditorState, pos: number): string | null {
|
||||
const livePreviewPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
// 输入法组字期间只映射位置不重建,组字结束后再补一次重建
|
||||
// During IME composition, map positions without rebuilding; rebuild once composition ends.
|
||||
stale = false
|
||||
|
||||
constructor(view: EditorView) {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { Facet, StateEffect, StateField } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export interface PasteImagesOptions {
|
||||
// 文件移动期间不能开始异步粘贴或上传,避免响应中的资源链接属于旧路径。
|
||||
// Do not start asynchronous paste or uploads during file moves; returned resource links could target the old path.
|
||||
canPaste: () => boolean
|
||||
// 上传文件,返回写入 Markdown 的链接
|
||||
// Upload a file and return its Markdown link.
|
||||
upload: (file: File) => Promise<string>
|
||||
importImage: (url: string) => Promise<string>
|
||||
// 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
|
||||
// Update by note identity; the note may be in the background when asynchronous paste or upload completes.
|
||||
update: (change: (state: EditorState) => TransactionSpec | undefined) => void
|
||||
onError: (err: unknown) => void
|
||||
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
|
||||
// Notify upload start/end so the save queue can pause and avoid writing temporary blob: URLs to files.
|
||||
onPending: (delta: 1 | -1) => void
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ const pendingPastes = StateField.define<Map<object, { from: number, to: number }
|
||||
transaction.changes.iterChangedRanges((from, to) => {
|
||||
if ((from < range.to && to > range.from) || (from === to && from > range.from && from < range.to)) edited = true
|
||||
})
|
||||
// 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。
|
||||
// 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。
|
||||
// Edits or undo while waiting must not be overwritten by a late conversion result.
|
||||
// Locate successive pastes at the same position in initiation order, independent of image-download completion order.
|
||||
const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1
|
||||
if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) })
|
||||
}
|
||||
@@ -85,8 +85,8 @@ function insertHTML(view: EditorView, html: string, text: string, opts: PasteIma
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
|
||||
* 返回是否已处理图片插入(包括因操作冲突被拒绝);未配置上传时不处理
|
||||
* Insert image files from paste, drop, or photo selection: immediately show blob: placeholders, then replace them with
|
||||
* uploaded links. Return whether insertion was handled, including operation-conflict rejection; do nothing if upload is unconfigured.
|
||||
*/
|
||||
export function insertImageFiles(view: EditorView, files: File[], pos?: number): boolean {
|
||||
const opts = view.state.facet(uploadOptions)
|
||||
@@ -120,7 +120,7 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
|
||||
return true
|
||||
}
|
||||
|
||||
// 粘贴或拖入图片
|
||||
// Paste or drop images.
|
||||
export function pasteImages(opts: PasteImagesOptions): Extension {
|
||||
let plain = false
|
||||
return [
|
||||
@@ -144,7 +144,7 @@ export function pasteImages(opts: PasteImagesOptions): Extension {
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
// 个别浏览器/来源只在 items 中提供图片
|
||||
// Some browsers/sources expose images only through items.
|
||||
let files = [...(e.clipboardData?.files ?? [])]
|
||||
if (!files.length) {
|
||||
files = [...(e.clipboardData?.items ?? [])].filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(f => f !== null)
|
||||
|
||||
@@ -23,13 +23,13 @@ interface TableModel {
|
||||
from: number
|
||||
to: number
|
||||
aligns: Align[]
|
||||
// rows[0] 为表头,其余为表体;分隔行单独由 aligns 表示
|
||||
// rows[0] is the header; remaining rows form the body. aligns separately represents the delimiter row.
|
||||
rows: RowLine[]
|
||||
}
|
||||
|
||||
// ---- 解析与序列化 ----
|
||||
// ---- Parsing and serialization ----
|
||||
|
||||
// 按未转义的 | 切分一行,去掉首尾可选的竖线
|
||||
// Split a line on unescaped | characters, removing optional leading/trailing pipes.
|
||||
function splitRow(line: string): string[] {
|
||||
const cells: string[] = []
|
||||
let cur = ''
|
||||
@@ -100,7 +100,7 @@ function findTable(state: EditorState, pos: number): SyntaxNode | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 渲染组件 ----
|
||||
// ---- Rendered widget ----
|
||||
|
||||
function renderCell(cell: HTMLElement, raw: string) {
|
||||
cell.innerHTML = renderInlineMarkdown(raw)
|
||||
@@ -109,9 +109,9 @@ function renderCell(cell: HTMLElement, raw: string) {
|
||||
|
||||
interface WidgetState {
|
||||
model: TableModel
|
||||
// 最近编辑的单元格,工具栏操作以它为准
|
||||
// The most recently edited cell, used as the target for toolbar actions.
|
||||
current: { r: number, c: number }
|
||||
// 结构变更后需要聚焦的单元格(DOM 重建后生效)
|
||||
// Cell to focus after a structural change, once the DOM is rebuilt.
|
||||
pendingFocus: { r: number, c: number } | null
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class TableWidget extends WidgetType {
|
||||
wrap.className = 'cm-rm-table-widget'
|
||||
wrap.lang = this.locale
|
||||
widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null })
|
||||
// 横向滚动放在内层,避免绝对定位的工具栏被裁剪
|
||||
// Keep horizontal scrolling inside so the absolutely positioned toolbar is not clipped.
|
||||
const scroller = document.createElement('div')
|
||||
scroller.className = 'cm-rm-table-scroll'
|
||||
scroller.append(this.buildTable(view, wrap))
|
||||
@@ -159,7 +159,7 @@ class TableWidget extends WidgetType {
|
||||
const sameShape
|
||||
= old.rows.length === this.model.rows.length && old.aligns.join() === this.model.aligns.join()
|
||||
if (!sameShape || st.pendingFocus) {
|
||||
// 重建前记下正在编辑的单元格(如撤销改变了行数),重建后恢复焦点
|
||||
// Remember the active cell before rebuilding, such as after undo changes row count, then restore focus.
|
||||
const editing = dom.querySelector<HTMLElement>('[contenteditable]')
|
||||
const focus
|
||||
= st.pendingFocus ?? (editing ? { r: Number(editing.dataset.r), c: Number(editing.dataset.c) } : null)
|
||||
@@ -173,7 +173,7 @@ class TableWidget extends WidgetType {
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 形状不变:只刷新内容变化的单元格;正在编辑的单元格若与文档一致则不动,以免打断输入
|
||||
// With the same shape, refresh only changed cells; leave the active cell untouched when it matches the document to preserve input.
|
||||
dom.querySelectorAll<HTMLElement>('[data-r]').forEach((cell) => {
|
||||
const r = Number(cell.dataset.r)
|
||||
const c = Number(cell.dataset.c)
|
||||
@@ -226,7 +226,7 @@ class TableWidget extends WidgetType {
|
||||
if (danger) b.className = 'cm-rm-danger'
|
||||
b.innerHTML = svg(icon)
|
||||
b.append(label)
|
||||
// mousedown 阻止默认行为,保持当前单元格的焦点与光标
|
||||
// Prevent default on mousedown to preserve the active cell's focus and cursor.
|
||||
b.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
run()
|
||||
@@ -266,7 +266,7 @@ function svg(body: string): string {
|
||||
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
|
||||
}
|
||||
|
||||
// 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格
|
||||
// For structural changes, regenerate the table's source from the latest document and return the cell to focus afterward.
|
||||
function mutate(
|
||||
view: EditorView,
|
||||
wrap: HTMLElement,
|
||||
@@ -278,14 +278,14 @@ function mutate(
|
||||
const rows = model.rows.map(r => [...r.cells])
|
||||
const aligns = [...model.aligns]
|
||||
st.pendingFocus = f(rows, aligns, st.current)
|
||||
// 结构变更单独成为一步撤销,不与前后的单元格输入合并
|
||||
// Keep structural changes as a separate undo step, not merged with surrounding cell edits.
|
||||
view.dispatch({
|
||||
changes: { from: model.from, to: model.to, insert: serializeTable(aligns, rows) },
|
||||
annotations: isolateHistory.of('full'),
|
||||
})
|
||||
}
|
||||
|
||||
// focusColumn 未指定时聚焦新行的同一列
|
||||
// Without focusColumn, focus the same column in the new row.
|
||||
function insertRow(view: EditorView, wrap: HTMLElement, focusColumn?: number) {
|
||||
return mutate(view, wrap, (rows, aligns, cur) => {
|
||||
rows.splice(cur.r + 1, 0, aligns.map(() => ''))
|
||||
@@ -304,7 +304,7 @@ function insertColumn(view: EditorView, wrap: HTMLElement) {
|
||||
|
||||
function deleteRow(view: EditorView, wrap: HTMLElement) {
|
||||
return mutate(view, wrap, (rows, _, cur) => {
|
||||
// 表头行不能删除
|
||||
// The header row cannot be deleted.
|
||||
if (cur.r === 0) return cur
|
||||
rows.splice(cur.r, 1)
|
||||
return { r: Math.min(cur.r, rows.length - 1), c: cur.c }
|
||||
@@ -339,7 +339,7 @@ function placeCaretAtEnd(el: HTMLElement) {
|
||||
sel.addRange(range)
|
||||
}
|
||||
|
||||
// 单元格编辑时显示该格的 Markdown 源码,失焦后恢复渲染
|
||||
// Show a cell's Markdown source while editing; restore rendered output on blur.
|
||||
function startEdit(cell: HTMLElement, st: WidgetState) {
|
||||
const r = Number(cell.dataset.r)
|
||||
const c = Number(cell.dataset.c)
|
||||
@@ -416,7 +416,7 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
|
||||
e.preventDefault()
|
||||
moveTo(r() + 1, c())
|
||||
} else if (e.key === 'Escape') {
|
||||
// 退出表格,光标放到表格之后
|
||||
// Exit the table and place the cursor after it.
|
||||
e.preventDefault()
|
||||
const model = currentModel(view, wrap)
|
||||
if (model) view.dispatch({ selection: { anchor: Math.min(model.to + 1, view.state.doc.length) } })
|
||||
@@ -425,14 +425,14 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 装饰 ----
|
||||
// ---- Decorations ----
|
||||
|
||||
// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。
|
||||
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(见 focus.ts)
|
||||
// Multiline block replacements must come from StateField, not ViewPlugin.
|
||||
// Match other syntax: show source only when the editor is focused and the cursor is inside the table (see focus.ts).
|
||||
function buildTables(state: EditorState): DecorationSet {
|
||||
const out: Range<Decoration>[] = []
|
||||
const sel = state.field(editorFocused) ? state.selection.ranges : []
|
||||
// 只渲染顶层表格;引用、列表内的表格以源码显示
|
||||
// Render only top-level tables; keep tables inside quotes or lists as source.
|
||||
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
|
||||
if (node.name !== 'Table') continue
|
||||
if (sel.some(r => r.from <= node.to && r.to >= node.from)) continue
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags as t } from '@lezer/highlight'
|
||||
|
||||
// 编辑器排版样式在 themes/base.css 中以 CSS 变量定义,这里只做语法元素到类名的映射
|
||||
// Editor typography uses CSS variables in common/appearance/base.css; this module maps syntax elements to class names only.
|
||||
const markdownHighlight = HighlightStyle.define([
|
||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||
{ tag: t.strong, fontWeight: '600' },
|
||||
|
||||
@@ -32,7 +32,7 @@ export class CheckboxWidget extends WidgetType {
|
||||
el.type = 'checkbox'
|
||||
el.className = 'cm-rm-task'
|
||||
el.checked = this.checked
|
||||
// 用 mousedown 并阻止默认行为:否则编辑器先移动光标,行被展开为源码,点击落空
|
||||
// Use mousedown with preventDefault; otherwise the editor moves the cursor, expands the line to source, and loses the click.
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
const pos = view.posAtDOM(el)
|
||||
@@ -65,13 +65,13 @@ const COPY_ICON
|
||||
const CHECK_ICON
|
||||
= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
|
||||
|
||||
// 代码块右上角:语言标签(未激活时)+ 复制按钮
|
||||
// Code-block top right: language label when inactive, plus a copy button.
|
||||
export class CodeHeaderWidget extends WidgetType {
|
||||
readonly locale = locale.value
|
||||
constructor(
|
||||
readonly lang: string,
|
||||
readonly showLang: boolean,
|
||||
// 根据组件所在位置取出代码块内容,由调用方结合语法树实现
|
||||
// Read code contents at the widget position; the caller implements this using the syntax tree.
|
||||
readonly codeAt: (view: EditorView, pos: number) => string | null,
|
||||
) {
|
||||
super()
|
||||
@@ -131,12 +131,12 @@ export class ImageWidget extends WidgetType {
|
||||
img.alt = this.alt
|
||||
img.loading = 'lazy'
|
||||
img.decoding = 'async'
|
||||
// 图片加载完成后高度变化,需通知编辑器重新测量,否则光标定位会错位
|
||||
// Image loading changes height; request editor remeasurement to keep cursor positioning correct.
|
||||
img.addEventListener('load', () => view.requestMeasure())
|
||||
// 组件内的事件默认被编辑器忽略,需手动把光标移到图片语法处以展开源码
|
||||
// The editor ignores widget events by default; move the cursor to image syntax manually to expand source.
|
||||
wrap.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
// 先设光标再聚焦:focus() 会把编辑器选区写回 DOM,反之会读到残留在别处(如表格单元格)的 DOM 选区
|
||||
// Set the cursor before focusing: focus() writes editor selection to the DOM; reversing this reads a stale DOM selection elsewhere, such as a table cell.
|
||||
view.dispatch({ selection: { anchor: view.posAtDOM(wrap) } })
|
||||
view.focus()
|
||||
})
|
||||
@@ -153,9 +153,9 @@ export class ImageWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 扩展语法(公式、Mermaid、emoji、脚注、目录) ----
|
||||
// ---- Extended syntax (math, Mermaid, emoji, footnotes, table of contents) ----
|
||||
|
||||
// 点击渲染结果时把光标移到源码中(offset 为相对组件起点的位置)以展开编辑,做法同图片
|
||||
// Clicking rendered output moves the cursor into source at offset relative to the widget start, expanding editing as with images.
|
||||
function editOnMousedown(el: HTMLElement, view: EditorView, offset: number) {
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return
|
||||
@@ -173,8 +173,8 @@ function showRenderError(el: HTMLElement, err: unknown) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 公式。editOffset 为 null 时是编辑中显示在源码下方的预览(不响应点击,内容变化时原地更新),
|
||||
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
|
||||
* Math. A null editOffset means a preview below source during editing: no click handling, update in place
|
||||
* on content changes. Otherwise it replaces source; clicking moves the cursor to editOffset in the source.
|
||||
*/
|
||||
export class MathWidget extends WidgetType {
|
||||
readonly locale = locale.value
|
||||
@@ -199,7 +199,7 @@ export class MathWidget extends WidgetType {
|
||||
return el
|
||||
}
|
||||
|
||||
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
|
||||
// Reuse preview elements only: switching rendered output to editing may update an old element that still has click-to-edit listeners.
|
||||
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
||||
this.render(dom, view)
|
||||
@@ -213,7 +213,7 @@ export class MathWidget extends WidgetType {
|
||||
return
|
||||
}
|
||||
el.classList.remove('cm-rm-placeholder')
|
||||
// 加载 KaTeX 前先显示源码
|
||||
// Show source until KaTeX loads.
|
||||
if (!el.firstChild) el.textContent = this.tex
|
||||
void renderMath(el, this.tex, this.display).then(() => view.requestMeasure())
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export class MathWidget extends WidgetType {
|
||||
|
||||
const rendererMounts = new WeakMap<HTMLElement, () => void>()
|
||||
|
||||
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
|
||||
/** Generic code-block preview; editOffset follows the math widget's click-to-edit contract. */
|
||||
export class CodeRendererWidget extends WidgetType {
|
||||
readonly locale = locale.value
|
||||
constructor(
|
||||
@@ -306,7 +306,7 @@ export class EmojiWidget extends WidgetType {
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('span')
|
||||
el.className = 'cm-rm-emoji'
|
||||
// 名称表加载前或名称未知时显示原文
|
||||
// Show original text until the name table loads or when the name is unknown.
|
||||
el.textContent = `:${this.name}:`
|
||||
void emojiFor(this.name).then((e) => {
|
||||
if (e) el.textContent = e
|
||||
@@ -376,7 +376,7 @@ export class TocWidget extends WidgetType {
|
||||
})
|
||||
el.append(item)
|
||||
}
|
||||
// 点击目录空白处展开 [TOC] 源码
|
||||
// Clicking empty table-of-contents space expands [TOC] source.
|
||||
editOnMousedown(el, view, 0)
|
||||
return el
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 快速切换的模糊匹配。目标形如"目录/标题",空白分隔的每个词都要命中;
|
||||
// 连续命中标题 > 连续命中目录 > 按顺序零散命中(如用首字母找笔记)
|
||||
// Quick-switcher fuzzy matching targets "folder/title" and requires every whitespace-separated term to match.
|
||||
// Rank contiguous title matches above contiguous folder matches, then ordered scattered matches such as initials.
|
||||
|
||||
export interface FuzzyMatch {
|
||||
score: number
|
||||
// 命中字符在目标中的下标,用于高亮
|
||||
// Matched character indices in the target, for highlighting.
|
||||
positions: number[]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function isBoundary(target: string, i: number): boolean {
|
||||
}
|
||||
|
||||
function matchTerm(term: string, target: string, lower: string, titleStart: number): FuzzyMatch | null {
|
||||
// 取最后一处连续命中:标题在末尾,优先落在标题上
|
||||
// Use the last contiguous match: the title is at the end and takes priority.
|
||||
const at = lower.lastIndexOf(term)
|
||||
if (at >= 0) {
|
||||
let score = at >= titleStart ? 100 : 50
|
||||
@@ -30,13 +30,13 @@ function matchTerm(term: string, target: string, lower: string, titleStart: numb
|
||||
positions.push(i)
|
||||
from = i + 1
|
||||
}
|
||||
// 越分散得分越低,命中词首的字符加分
|
||||
// Scattered matches score lower; word-initial matches receive a bonus.
|
||||
const spread = positions[positions.length - 1] - positions[0] + 1 - positions.length
|
||||
const boundaries = positions.filter(i => isBoundary(target, i)).length
|
||||
return { score: Math.max(1, 20 - spread + boundaries * 3), positions }
|
||||
}
|
||||
|
||||
/** titleStart 为标题在 target 中的起始下标;不匹配时返回 null */
|
||||
/** titleStart is the title's starting index in target; return null when unmatched. */
|
||||
export function fuzzyMatch(query: string, target: string, titleStart: number): FuzzyMatch | null {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return null
|
||||
@@ -49,12 +49,12 @@ export function fuzzyMatch(query: string, target: string, titleStart: number): F
|
||||
score += m.score
|
||||
m.positions.forEach(p => positions.add(p))
|
||||
}
|
||||
// 标题与输入完全一致时排在最前
|
||||
// Exact title/query matches rank first.
|
||||
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
|
||||
return { score, positions: [...positions].sort((a, b) => a - b) }
|
||||
}
|
||||
|
||||
/** 按命中与否把文本切成连续片段,用于高亮 */
|
||||
/** Split text into contiguous matched/unmatched fragments for highlighting. */
|
||||
export function segments(text: string, hits: Set<number>) {
|
||||
const out: { text: string, hit: boolean }[] = []
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
|
||||
@@ -43,10 +43,10 @@ async function prepareMaintenance() {
|
||||
}
|
||||
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
|
||||
const resizingSidebar = ref(false)
|
||||
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
|
||||
// Custom fonts require authentication; preserve layout during initialization without briefly showing the default font.
|
||||
const initializing = ref(true)
|
||||
|
||||
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
|
||||
// Drag the sidebar's right edge to resize: update only CSS variables while dragging, then save settings on release.
|
||||
function startResize(e: PointerEvent) {
|
||||
const version = user.getSessionVersion()
|
||||
const handle = e.currentTarget as HTMLElement
|
||||
@@ -88,16 +88,16 @@ async function logout() {
|
||||
await user.logout()
|
||||
} catch (error) {
|
||||
if (isCancel(error)) return
|
||||
// 注销错误已由请求层提示。
|
||||
// The request layer already displays logout errors.
|
||||
return
|
||||
}
|
||||
stopWorkspace()
|
||||
await router.replace('/login')
|
||||
}
|
||||
|
||||
// 移动端:从屏幕左边缘右滑打开侧栏
|
||||
// Mobile: swipe right from the screen's left edge to open the sidebar.
|
||||
let touchX: number | null = null
|
||||
// 侧栏打开时向左滑关闭(以水平方向为主,避免与列表上下滚动冲突)
|
||||
// Swipe left to close the open sidebar; require primarily horizontal movement to avoid conflicts with list scrolling.
|
||||
let touchStart: { x: number, y: number } | null = null
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 中文按字、其他文字按连续词计数;界面统计不改变 Markdown 原文。
|
||||
// Count Chinese characters individually and other scripts as contiguous words; statistics do not change the original Markdown.
|
||||
export function readingStats(text: string) {
|
||||
const han = text.match(/\p{Script=Han}/gu)?.length ?? 0
|
||||
const words = han + (text.replace(/\p{Script=Han}/gu, ' ').match(/[\p{L}\p{N}]+/gu)?.length ?? 0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 父目录已经选中时不重复操作其内部项。
|
||||
// Do not operate on descendants when their parent folder is already selected.
|
||||
export function minimalPaths(paths: Iterable<string>): string[] {
|
||||
const out: string[] = []
|
||||
const roots = new Set<string>()
|
||||
|
||||
@@ -149,7 +149,7 @@ export const emojiCategories = [
|
||||
}, icon: '🏳️' },
|
||||
]
|
||||
|
||||
// 常见图标的显示标签在使用时翻译,避免缓存语言切换前的文案。
|
||||
// Translate common icon labels at use time so locale changes do not retain cached text.
|
||||
const keywordKeys: Record<string, string> = {
|
||||
'📂': 'workspace.folder_directory_open_3744fe',
|
||||
'📁': 'workspace.folder_directory_796213',
|
||||
|
||||
@@ -18,7 +18,7 @@ const collator = computed(() => new Intl.Collator(locale.value, { numeric: true,
|
||||
function build(entries: Entry[]): TreeNode[] {
|
||||
const root: TreeNode[] = []
|
||||
const dirs = new Map<string, TreeNode>()
|
||||
// 先按路径排序,保证父目录先于子项出现
|
||||
// Sort paths first so parents precede their children.
|
||||
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
|
||||
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] }
|
||||
if (e.dir) dirs.set(e.path, node)
|
||||
@@ -36,14 +36,14 @@ export const tree = computed(() => build(ws.entries.filter(entry => !hiddenAsset
|
||||
|
||||
export const treeIconTarget = shallowRef<{ path: string, anchor: HTMLElement } | null>(null)
|
||||
|
||||
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
|
||||
// null means no drop target; an empty string means the root. File rows target their direct parent folder.
|
||||
export const dropDirectory = ref<string | null>(null)
|
||||
|
||||
const EXPANDED_KEY = 'rawmark.expanded'
|
||||
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
|
||||
watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])))
|
||||
|
||||
// 打开或新建笔记时展开其所在的各级目录
|
||||
// Expand ancestor folders when opening or creating a note.
|
||||
watch(
|
||||
() => [ws.active, ws.renaming, ws.image],
|
||||
(paths) => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import { restoreTrashApi } from '@@/apis/trash'
|
||||
import { getTreeApi, setTreeIconApi } from '@@/apis/tree'
|
||||
import { appearancePaused } from '@@/appearance/appearance'
|
||||
import { locale, t as translate } from '@@/i18n/runtime'
|
||||
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
|
||||
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
|
||||
// Workspace state and operations: tree, tabs, save queue, conflict handling, and live synchronization.
|
||||
// File operations update the interface optimistically before requesting the server, rolling back on failure for immediate feedback.
|
||||
import { decodeLinkDestination } from '@@/markdown/links'
|
||||
|
||||
import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '@@/plugins/host'
|
||||
@@ -45,18 +45,18 @@ export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||
export interface Tab {
|
||||
path: string
|
||||
pinned?: boolean
|
||||
// 最近一次与服务器一致的版本
|
||||
// Last revision synchronized with the server.
|
||||
rev: string
|
||||
status: TabStatus
|
||||
// 有尚未保存成功的修改
|
||||
// Changes not yet saved successfully.
|
||||
dirty: boolean
|
||||
saveError?: string
|
||||
// 冲突时服务器上的版本;为空字符串表示服务器上已不存在
|
||||
// Server revision during conflict; an empty string means the file no longer exists there.
|
||||
conflictRev?: string
|
||||
}
|
||||
|
||||
const RECENT_KEY = 'rawmark.recent'
|
||||
// 本机的界面状态(不属于设置):侧栏折叠、大纲面板
|
||||
// Local interface state outside settings: sidebar collapse and outline panel.
|
||||
const LAYOUT_KEY = 'rawmark.layout'
|
||||
const layout = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') as { sidebarCollapsed?: boolean, outlineOpen?: boolean }
|
||||
const RECENT_MAX = 30
|
||||
@@ -65,32 +65,32 @@ export const ws = reactive({
|
||||
entries: [] as Entry[],
|
||||
tabs: [] as Tab[],
|
||||
active: null as string | null,
|
||||
// 目录树中正在行内重命名的路径
|
||||
// Path currently being renamed inline in the tree.
|
||||
renaming: null as string | null,
|
||||
// 移动端侧栏抽屉
|
||||
// Mobile sidebar drawer.
|
||||
sidebarOpen: false,
|
||||
// 侧栏显示搜索而非目录树
|
||||
// Show search instead of the tree in the sidebar.
|
||||
searchOpen: false,
|
||||
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||
// Initial quick-switcher/command-palette query; null closes it, and leading > lists commands.
|
||||
switcher: null as string | null,
|
||||
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
||||
// Open settings category; null closes it. index is the general entry, showing categories on phones and the first category on desktop.
|
||||
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
||||
// 「移动到…」对话框(笔记或文件夹路径)
|
||||
// "Move to..." dialog paths for notes or folders.
|
||||
moving: null as string | string[] | null,
|
||||
// 导入目标目录,空字符串为根目录
|
||||
// Import destination folder; an empty string denotes the root.
|
||||
importing: null as string | null,
|
||||
// 图片预览与管理,独立于笔记标签和草稿
|
||||
// Image preview and management, independent of note tabs and drafts.
|
||||
image: null as string | null,
|
||||
images: null as string | null,
|
||||
imagesAll: false,
|
||||
// 电脑上侧栏折叠
|
||||
// Desktop sidebar collapse state.
|
||||
sidebarCollapsed: layout.sidebarCollapsed ?? false,
|
||||
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
|
||||
// Outline panel: persistent on the right on desktop, an overlay on phones.
|
||||
outlineOpen: layout.outlineOpen ?? false,
|
||||
sourceMode: false,
|
||||
focusMode: false,
|
||||
view: 'notes' as 'notes' | 'graph' | 'tags',
|
||||
// 最近打开的笔记,最新的在前
|
||||
// Recently opened notes, newest first.
|
||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||
})
|
||||
|
||||
@@ -98,15 +98,15 @@ const SAVE_DELAY = 500
|
||||
const RETRY_DELAY = 5000
|
||||
const MAX_RETRIES = 12
|
||||
|
||||
// 以下状态不放进 reactive:EditorState 体积大且不可变,交给 Vue 代理没有意义
|
||||
// Keep the following state outside reactive: large immutable EditorState objects gain nothing from Vue proxies.
|
||||
const editorStates = new Map<string, EditorState>()
|
||||
// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新)
|
||||
// Each open note has a mutable reference through which editor extensions read its current path, updated on rename.
|
||||
const noteRefs = new Map<string, { path: string }>()
|
||||
|
||||
interface SaveSlot {
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
inflight?: Promise<void>
|
||||
// 修改序号与已保存序号,二者相等即没有待保存的修改
|
||||
// Change and saved sequence numbers; equality means no pending changes.
|
||||
seq: number
|
||||
savedSeq: number
|
||||
uploads: number
|
||||
@@ -121,12 +121,12 @@ let workspaceEpoch = 0
|
||||
let draftPreserved: Promise<void> = Promise.resolve()
|
||||
|
||||
let view: EditorView | null = null
|
||||
// 进行中的本地文件操作数;期间收到的目录事件不用于判断标签页是否被删除
|
||||
// In-flight local file operations; tree events during these operations must not determine whether tabs were deleted.
|
||||
let opsInFlight = 0
|
||||
|
||||
const externalChange = Annotation.define<boolean>()
|
||||
|
||||
// 编辑器内容或光标变化的订阅者(大纲面板);不经过 Vue 响应式,避免每次按键都触发组件更新
|
||||
// Editor content/cursor subscribers, such as the outline, bypass Vue reactivity to avoid component updates on every keystroke.
|
||||
const editorListeners = new Set<() => void>()
|
||||
export function onEditorUpdate(fn: () => void): () => void {
|
||||
editorListeners.add(fn)
|
||||
@@ -145,13 +145,13 @@ function errorText(err: unknown): string {
|
||||
return err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064')
|
||||
}
|
||||
|
||||
// ---- 编辑器状态 ----
|
||||
// ---- Editor state ----
|
||||
|
||||
export function attachView(v: EditorView | null) {
|
||||
view = v
|
||||
}
|
||||
|
||||
/** 当前显示的编辑器视图(手机工具条等需要直接操作编辑器) */
|
||||
/** Current editor view, accessed directly by controls such as the mobile toolbar. */
|
||||
export function editorView(): EditorView | null {
|
||||
return view
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export function toggleSourceMode() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从相册/文件中选择图片插入到光标处 */
|
||||
/** Choose an image from photos/files and insert it at the cursor. */
|
||||
export function insertImagesFromPicker() {
|
||||
const v = view
|
||||
const tab = activeTab()
|
||||
@@ -188,12 +188,12 @@ export function editorStateOf(path: string): EditorState | undefined {
|
||||
return editorStates.get(path)
|
||||
}
|
||||
|
||||
// 笔记的身份对象:重命名/移动后保持不变,用于判断编辑器中显示的是否仍是同一篇笔记
|
||||
// Note identity survives rename/move and determines whether the editor still displays the same note.
|
||||
export function noteRefOf(path: string): { path: string } | undefined {
|
||||
return noteRefs.get(path)
|
||||
}
|
||||
|
||||
// 切换标签页前由编辑器面板调用,保存当前视图状态(含光标、滚动无关的选区与撤销历史)
|
||||
// Called by the editor pane before tab switches to save view state, including cursor, non-scroll selection state, and undo history.
|
||||
export function storeViewState(path: string, state: EditorState) {
|
||||
if (editorStates.has(path)) editorStates.set(path, state)
|
||||
}
|
||||
@@ -271,7 +271,7 @@ function buildState(path: string, doc: string): EditorState {
|
||||
|
||||
function reconfigurePluginStates() {
|
||||
const effects = [languageCompartment.reconfigure(pluginLanguage()), pluginCompartment.reconfigure(pluginEditorExtensions()), pluginRefresh.of(null)]
|
||||
// 先计算全部新状态,失败时让注册方撤回;不让后台标签保留已停用扩展。
|
||||
// Compute every new state first so the registering party can roll back on failure; background tabs must not retain disabled extensions.
|
||||
const updates = [...editorStates].map(([path, state]) => {
|
||||
const current = path === ws.active && view ? view.state : state
|
||||
return { path, transaction: current.update({ effects }) }
|
||||
@@ -290,7 +290,7 @@ const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options)
|
||||
const initialRevision = tab?.rev
|
||||
const initialSequence = saved?.seq ?? 0
|
||||
const rev = (await writeFileApi({ path, content, ifMatch: 'create' in options ? '' : options.ifMatch })).data.rev
|
||||
// 用户可能已完成下一次保存,或已收到更新版本;迟到响应不能退回正文与 rev。
|
||||
// A later save or newer revision may already exist; late responses must not revert contents or rev.
|
||||
if (tab && tabOf(path) === tab && tab.rev === initialRevision && (slots.get(path)?.seq ?? 0) === initialSequence && !tab.dirty && tab.status === 'ready' && !slots.get(path)?.inflight) {
|
||||
replaceDoc(path, content); tab.rev = rev
|
||||
}
|
||||
@@ -298,7 +298,7 @@ const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options)
|
||||
return { rev }
|
||||
}
|
||||
|
||||
// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史
|
||||
// Replace document contents with server text by changing only the difference between shared prefix/suffix, preserving the cursor where possible and bypassing undo history.
|
||||
function replaceDoc(path: string, content: string) {
|
||||
const state = path === ws.active && view ? view.state : editorStates.get(path)
|
||||
if (!state) return
|
||||
@@ -325,7 +325,7 @@ function docOf(path: string): string | undefined {
|
||||
return editorStates.get(path)?.doc.toString()
|
||||
}
|
||||
|
||||
// ---- 保存队列 ----
|
||||
// ---- Save queue ----
|
||||
|
||||
function slot(path: string): SaveSlot {
|
||||
let s = slots.get(path)
|
||||
@@ -342,7 +342,7 @@ function scheduleSave(path: string, delay = SAVE_DELAY) {
|
||||
s.timer = setTimeout(() => void flush(path), delay)
|
||||
}
|
||||
|
||||
// 立即保存一篇笔记的待保存修改;keepalive 用于页面隐藏/关闭时尽力发出请求
|
||||
// Immediately save pending changes for one note; keepalive attempts delivery when the page hides or closes.
|
||||
export async function flush(path: string, keepalive = false): Promise<void> {
|
||||
const version = user.getSessionVersion()
|
||||
const s = slots.get(path)
|
||||
@@ -353,7 +353,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
|
||||
const tab = tabOf(path)
|
||||
if (!tab || tab.status !== 'ready' || s.seq === s.savedSeq) return
|
||||
if (s.paused || s.uploads) {
|
||||
// 图片上传中或文件正在移动,稍后再存,避免写入临时 blob: 地址或写到旧路径
|
||||
// Defer saves during image uploads or file moves to avoid temporary blob: URLs or writes to old paths.
|
||||
s.timer = setTimeout(() => void flush(path), SAVE_DELAY)
|
||||
return
|
||||
}
|
||||
@@ -362,8 +362,8 @@ export async function flush(path: string, keepalive = false): Promise<void> {
|
||||
const seq = s.seq
|
||||
const baseRev = tab.rev
|
||||
s.inflight = (async () => {
|
||||
// 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出
|
||||
// 草稿只是兜底,写入失败不影响保存本身
|
||||
// Run draft persistence and the request in parallel; waiting for IndexedDB first often prevents the request from starting during page close.
|
||||
// Drafts are a recovery fallback; draft-write failures do not block saving itself.
|
||||
const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch(err => console.warn('写入本地草稿失败', err))
|
||||
try {
|
||||
const rev = (await writeFileApi({ path, content, ifMatch: baseRev, keepalive })).data.rev
|
||||
@@ -384,7 +384,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
|
||||
tab.status = serverRev ? 'conflict' : 'deleted'
|
||||
tab.conflictRev = serverRev
|
||||
} else if (!(err instanceof ApiError) || err.status >= 500) {
|
||||
// 网络错误与服务器错误可重试,草稿已在本地,不会丢
|
||||
// Network/server failures can be retried; the local draft preserves contents.
|
||||
tab.saveError = s.retries < MAX_RETRIES ? translate('workspace.save_failed_retrying_d62793') : translate('workspace.save_failed_check_your_connection_c0913f')
|
||||
if (s.retries++ < MAX_RETRIES) s.timer = setTimeout(() => void flush(path), RETRY_DELAY)
|
||||
} else {
|
||||
@@ -407,15 +407,15 @@ export async function flushAll(keepalive = false) {
|
||||
await Promise.all([...slots.keys()].map(p => flush(p, keepalive)))
|
||||
}
|
||||
|
||||
// 保存失败会留在标签页状态中,flushAll 完成并不意味着可以安全刷新。
|
||||
// Save failures remain in tab state; completion of flushAll does not guarantee a safe refresh.
|
||||
export function readyToReload(): boolean {
|
||||
return opsInFlight === 0 && !ws.tabs.some(t => t.dirty || t.status === 'loading')
|
||||
&& [...slots.values()].every(s => !s.uploads && !s.paused && !s.inflight)
|
||||
}
|
||||
|
||||
// ---- 冲突处理 ----
|
||||
// ---- Conflict handling ----
|
||||
|
||||
// 放弃本地修改,载入服务器版本
|
||||
// Discard local changes and load the server version.
|
||||
export async function useServerVersion(path: string) {
|
||||
const tab = tabOf(path)
|
||||
if (!tab) return
|
||||
@@ -432,7 +432,7 @@ export async function useServerVersion(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 保留本地修改,覆盖服务器版本(服务器上已删除时重新创建)
|
||||
// Keep local changes and overwrite the server version, recreating the file if deleted there.
|
||||
export async function keepLocalVersion(path: string) {
|
||||
const version = user.getSessionVersion()
|
||||
const tab = tabOf(path)
|
||||
@@ -471,17 +471,17 @@ export async function keepLocalVersion(path: string) {
|
||||
if (tab.dirty && tab.status === 'ready') void flush(path)
|
||||
}
|
||||
|
||||
// ---- 打开并聚焦(快速切换、搜索结果跳转) ----
|
||||
// ---- Open and focus (quick switcher and search results) ----
|
||||
|
||||
interface RevealTarget {
|
||||
path: string
|
||||
line: number
|
||||
terms: string[]
|
||||
}
|
||||
// 笔记可能尚未载入或编辑器尚未显示,由编辑器面板在显示该笔记后执行
|
||||
// The note may not be loaded or its editor visible yet; the editor pane performs this after displaying the note.
|
||||
let pendingReveal: RevealTarget | null = null
|
||||
|
||||
/** 打开笔记并聚焦编辑器;line > 0 时定位到该行(从 1 开始)并选中该行第一个搜索词 */
|
||||
/** Open a note and focus the editor; line > 0 targets that 1-based line and selects its first search-term match. */
|
||||
export async function openAt(path: string, line = 0, terms: string[] = []) {
|
||||
pendingReveal = { path, line, terms }
|
||||
if (path === ws.active && view && editorStates.has(path)) {
|
||||
@@ -492,12 +492,12 @@ export async function openAt(path: string, line = 0, terms: string[] = []) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑器面板显示笔记后调用;返回 true 表示已定位到指定行(不应再恢复原来的滚动位置) */
|
||||
/** Called after the editor pane displays the note; true means the requested line was reached, so do not restore the old scroll position. */
|
||||
export function revealPending(v: EditorView, path: string): boolean {
|
||||
const r = pendingReveal
|
||||
if (!r || r.path !== path) return false
|
||||
pendingReveal = null
|
||||
// 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
|
||||
// Focus opens software keyboards on touch devices and covers content; focus only devices with a mouse.
|
||||
if (!matchMedia('(pointer: coarse)').matches) v.focus()
|
||||
if (r.line === 0) return false
|
||||
const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines))
|
||||
@@ -513,14 +513,14 @@ export function revealPending(v: EditorView, path: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- 标签页 ----
|
||||
// ---- Tabs ----
|
||||
|
||||
export async function openNote(path: string) {
|
||||
const version = user.getSessionVersion()
|
||||
ws.view = 'notes'
|
||||
ws.images = null
|
||||
ws.sidebarOpen = false
|
||||
// 目录中的新笔记先乐观显示,读取前须等它真正创建成功。
|
||||
// New tree notes appear optimistically; wait for actual creation before reading them.
|
||||
const pending = creating.get(path)
|
||||
if (pending && !(await pending)) return
|
||||
user.assertSession(version)
|
||||
@@ -591,7 +591,7 @@ function forget(path: string) {
|
||||
slots.delete(path)
|
||||
}
|
||||
|
||||
// 路径前缀替换(移动文件夹时其下所有路径一起变化)
|
||||
// Replace path prefixes; moving a folder changes every descendant path together.
|
||||
function remapPath(p: string, from: string, to: string): string | null {
|
||||
if (p === from) return to
|
||||
if (p.startsWith(`${from}/`)) return to + p.slice(from.length)
|
||||
@@ -625,11 +625,11 @@ function affected(path: string): string[] {
|
||||
return ws.tabs.map(t => t.path).filter(p => p === path || p.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
// ---- 目录树 ----
|
||||
// ---- Directory tree ----
|
||||
|
||||
// 目录刷新:同一时间只有一个请求;请求期间又有刷新请求或本地操作时,结果可能已过时,丢弃后重新获取。
|
||||
// 本地操作会先乐观修改目录,过时的结果会抹掉刚加入的条目(例如新建笔记的重命名框随之消失)。
|
||||
// 等待中的调用者在下一次被采用的结果(或请求失败)后统一完成
|
||||
// Tree refresh permits one request at a time; discard and refetch if another refresh or local operation makes its result stale.
|
||||
// Local operations update the tree optimistically; stale results would erase new entries, such as a new note's rename input.
|
||||
// Waiting callers settle together after the next accepted result or request failure.
|
||||
let treeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let treeFetching = false
|
||||
let treeAgain = false
|
||||
@@ -669,7 +669,7 @@ async function fetchTree() {
|
||||
}
|
||||
treeFetching = false
|
||||
if (treeAgain || edits !== localTreeEdits || opsInFlight > 0) {
|
||||
// 有操作进行中时由 withOp 在全部结束后重新调度
|
||||
// During active operations, withOp reschedules once all operations finish.
|
||||
if (opsInFlight === 0) scheduleTreeRefresh()
|
||||
return
|
||||
}
|
||||
@@ -730,10 +730,10 @@ export async function createNote(dir: string) {
|
||||
await createNoteAt(path)
|
||||
}
|
||||
|
||||
// 进行中的新建:新建后立即出现重命名输入框,重命名须等文件真正创建后再执行
|
||||
// In-flight creation: immediately show the rename input, but wait for actual file creation before renaming.
|
||||
const creating = new Map<string, Promise<boolean>>()
|
||||
|
||||
/** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */
|
||||
/** Create and open an empty note at the requested path and report success; the server creates missing parent folders. */
|
||||
export async function createNoteAt(path: string, content = ''): Promise<boolean> {
|
||||
ws.view = 'notes'
|
||||
ws.images = null
|
||||
@@ -769,7 +769,7 @@ async function createNoteFile(path: string, content: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function importNotes(dir: string, folder: string, files: File[]) {
|
||||
// refreshTree 必须等 withOp 结束,否则会等待自身释放操作计数。
|
||||
// refreshTree must wait until withOp ends, or it would wait for its own operation count to be released.
|
||||
const result = await withOp(() => importNotesApi({ dir, folder, files }).then(({ data }) => data))
|
||||
ws.searchOpen = false
|
||||
try {
|
||||
@@ -820,16 +820,16 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
|
||||
return operationFailed(translate('workspace.already_exists_6af434', { p0: basename(to) }), errorOwner)
|
||||
}
|
||||
const result = await withOp<FileResult>(async () => {
|
||||
// 移动会更新仓库中的相对链接,先保存全部已打开笔记,沿用版本冲突保护。
|
||||
// Moves update relative vault links; save all open notes first, retaining revision-conflict protection.
|
||||
const impacted = () => ws.tabs.map(t => t.path)
|
||||
const busy = () => impacted().some(p => slots.get(p)?.uploads || slots.get(p)?.paused)
|
||||
if (busy()) {
|
||||
return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner)
|
||||
}
|
||||
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
|
||||
// Save pending edits first so the latest contents move; pause saving during the move to avoid writing to old paths.
|
||||
await Promise.all(impacted().map(p => flush(p)))
|
||||
user.assertSession(version)
|
||||
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
|
||||
// Pasting may start while awaiting saves; check again before acquiring the move lock.
|
||||
if (busy()) {
|
||||
return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner)
|
||||
}
|
||||
@@ -844,7 +844,7 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
|
||||
let draftsMoved = false
|
||||
let remapped = false
|
||||
try {
|
||||
// 先确认持久草稿能够迁移;失败时服务器与界面均保留原路径。
|
||||
// Verify persistent drafts can migrate first; on failure, keep original paths on both the server and interface.
|
||||
await drafts.move(from, to)
|
||||
user.assertSession(version)
|
||||
draftsMoved = true
|
||||
@@ -875,12 +875,12 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
|
||||
} finally {
|
||||
paused.forEach(s => s.paused--)
|
||||
}
|
||||
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
|
||||
// The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization.
|
||||
await Promise.all(ws.tabs.map(t => t.path).map(async (p) => {
|
||||
const tab = tabOf(p)
|
||||
if (!tab) return
|
||||
if (tab.dirty) {
|
||||
// 移动期间又有输入:本地内容仍含旧链接,交给用户选择
|
||||
// Input during the move still contains old links; let the user resolve it.
|
||||
const { rev } = await readNoteFile(p)
|
||||
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
|
||||
} else {
|
||||
@@ -912,7 +912,7 @@ export async function remove(path: string, errorOwner: 'operation' | 'caller' =
|
||||
return operationFailed(translate('workspace.save_your_notes_and_resolve_5981a5'), errorOwner)
|
||||
}
|
||||
const snapshot = ws.entries
|
||||
ws.entries = ws.entries.filter(e => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目
|
||||
ws.entries = ws.entries.filter(e => remapPath(e.path, path, path) === null) // Remove the item and all descendants.
|
||||
for (const p of paths) {
|
||||
if (!(await closeTab(p))) {
|
||||
ws.entries = snapshot; return operationFailed(translate('workspace.could_not_close_the_note_cd2a80'), errorOwner)
|
||||
@@ -949,7 +949,7 @@ export async function restore(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 版本只用于刷新浏览器图片,不改变 Markdown 中保存的相对路径。
|
||||
// The version refreshes browser images only; relative Markdown paths remain unchanged.
|
||||
export function imageUrl(path: string): string {
|
||||
const entry = ws.entries.find(e => e.path === path)
|
||||
return imageResourceRoot() + encodePath(path) + (entry ? `?v=${entry.mtime}-${entry.size}` : '')
|
||||
@@ -965,7 +965,7 @@ export async function replaceImage(path: string, file: File) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 实时同步 ----
|
||||
// ---- Live synchronization ----
|
||||
|
||||
async function syncOpenTab(tab: Tab) {
|
||||
const s = slots.get(tab.path)
|
||||
@@ -1002,7 +1002,7 @@ async function handleEvents(evs: VaultEvent[]) {
|
||||
await refreshPlugins()
|
||||
} else if (ev.type === 'changed') {
|
||||
const tab = tabOf(ev.path!)
|
||||
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
||||
// Own-save events match the local rev; do not overwrite local edits, and let the next save detect conflicts.
|
||||
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
|
||||
} else if (ev.type === 'moved') {
|
||||
if (ws.image) ws.image = remapPath(ws.image, ev.from!, ev.path!) ?? ws.image
|
||||
@@ -1010,14 +1010,14 @@ async function handleEvents(evs: VaultEvent[]) {
|
||||
const paused = affected(ev.from!).map(slot)
|
||||
paused.forEach(s => s.paused++)
|
||||
try {
|
||||
// 已发出的保存仍可能写入旧键,先等其草稿事务结束。
|
||||
// In-flight saves may still write the old key; wait for their draft transactions first.
|
||||
await Promise.all(paused.map(s => s.inflight))
|
||||
user.assertSession(version)
|
||||
if (epoch !== workspaceEpoch) return
|
||||
try {
|
||||
await drafts.move(ev.from!, ev.path!)
|
||||
} catch (cause) {
|
||||
// 外部文件已移动,草稿失败不能阻止编辑器跟随;保留原记录并明确提示。
|
||||
// The external file already moved, so draft failures must not prevent editor tracking; retain the original record and report failure explicitly.
|
||||
toast(translate('workspace.could_not_migrate_the_local_4c513a', { p0: cause instanceof Error ? cause.message : errorText(cause) }))
|
||||
}
|
||||
user.assertSession(version)
|
||||
@@ -1056,11 +1056,11 @@ async function resync() {
|
||||
await refreshPlugins()
|
||||
}
|
||||
|
||||
// ---- 启动 ----
|
||||
// ---- Startup ----
|
||||
|
||||
const TABS_KEY = 'rawmark.tabs'
|
||||
|
||||
// 恢复未保存成功的草稿:服务器版本未变则继续保存,已变则进入冲突状态由用户选择
|
||||
// Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution.
|
||||
async function recoverDrafts() {
|
||||
const version = user.getSessionVersion()
|
||||
for (const d of await drafts.all()) {
|
||||
@@ -1100,7 +1100,7 @@ export async function startWorkspace() {
|
||||
workspaceRunning = false
|
||||
await prepareResources()
|
||||
user.assertSession(version)
|
||||
// 设置读取失败不影响使用,沿用本地缓存的值
|
||||
// Settings-read failures do not prevent use; retain locally cached values.
|
||||
const settingsLoaded = loadSettings().catch((err) => {
|
||||
if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) }))
|
||||
})
|
||||
@@ -1123,7 +1123,7 @@ export async function startWorkspace() {
|
||||
let eventQueue = Promise.resolve()
|
||||
let stopped = false
|
||||
const enqueue = (work: () => Promise<void>) => {
|
||||
// 同步读取是异步的,后续批次不能越过前一批次的移动和正文更新。
|
||||
// Synchronization reads are asynchronous; later batches must not overtake earlier moves or content updates.
|
||||
eventQueue = eventQueue.then(() => {
|
||||
if (!stopped) return work()
|
||||
}).catch((err) => {
|
||||
@@ -1147,7 +1147,7 @@ export async function startWorkspace() {
|
||||
return { path: tab.path, content: state.doc.toString(), rev: tab.rev, dirty: tab.dirty }
|
||||
},
|
||||
})
|
||||
// 重新登录后工作区会再次启动,以下全局监听只注册一次
|
||||
// The workspace starts again after signing in; register the following global listeners only once.
|
||||
if (listenersInstalled) return
|
||||
listenersInstalled = true
|
||||
watch(
|
||||
@@ -1155,7 +1155,7 @@ export async function startWorkspace() {
|
||||
(tabs) => {
|
||||
if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs))
|
||||
},
|
||||
// 启动期间(如等待目录刷新时)打开的标签页也要立即记录
|
||||
// Record tabs opened during startup immediately, including while waiting for tree refresh.
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
@@ -1173,7 +1173,7 @@ export async function startWorkspace() {
|
||||
() => ({ sidebarCollapsed: ws.sidebarCollapsed, outlineOpen: ws.outlineOpen, focusMode: ws.focusMode }),
|
||||
(l) => {
|
||||
localStorage.setItem(LAYOUT_KEY, JSON.stringify(l))
|
||||
// 编辑区宽度变化,需要重新测量
|
||||
// Editor width changed; request remeasurement.
|
||||
requestAnimationFrame(() => view?.requestMeasure())
|
||||
},
|
||||
)
|
||||
@@ -1188,7 +1188,7 @@ export async function startWorkspace() {
|
||||
}
|
||||
}
|
||||
})
|
||||
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
|
||||
// Font-size and text-width changes affect line heights; request editor remeasurement.
|
||||
watch(
|
||||
() => [settings.uiFont, settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight],
|
||||
() => view?.requestMeasure(),
|
||||
@@ -1212,7 +1212,7 @@ export async function startWorkspace() {
|
||||
)
|
||||
window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure()))
|
||||
window.addEventListener('rawmark-plugin-notice', event => toast((event as CustomEvent<string>).detail))
|
||||
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||
// Attempt to send pending saves before page hide/close; some browsers fire only pagehide on tab close.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||
})
|
||||
@@ -1223,13 +1223,13 @@ export async function startWorkspace() {
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (workspaceRunning) handleShortcut(event)
|
||||
}, true)
|
||||
// 软键盘弹出、编辑区变矮后,让光标保持可见
|
||||
// Keep the cursor visible when the software keyboard reduces editor height.
|
||||
trackVisualViewport(() => {
|
||||
if (view?.hasFocus) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest', yMargin: 24 }) })
|
||||
})
|
||||
}
|
||||
|
||||
// 内置命令不依赖工作区状态,模块载入时注册,排在组件注册的命令(如退出登录)之前
|
||||
// Built-in commands do not depend on workspace state; register at module load before component commands such as sign-out.
|
||||
registerBuiltinCommands()
|
||||
|
||||
function registerBuiltinCommands() {
|
||||
@@ -1313,13 +1313,13 @@ function registerBuiltinCommands() {
|
||||
}
|
||||
}
|
||||
|
||||
// 手机上侧栏是抽屉,电脑上折叠/展开
|
||||
// The sidebar is a drawer on phones and collapsible on desktop.
|
||||
function toggleSidebar() {
|
||||
if (matchMedia('(max-width: 768px)').matches) ws.sidebarOpen = !ws.sidebarOpen
|
||||
else ws.sidebarCollapsed = !ws.sidebarCollapsed
|
||||
}
|
||||
|
||||
// 需要在目录树中操作的命令:切回目录树,移动端打开侧栏
|
||||
// For tree operations, switch back to the tree and open the sidebar on mobile.
|
||||
function withSidebar(fn: () => unknown) {
|
||||
ws.searchOpen = false
|
||||
ws.sidebarOpen = true
|
||||
@@ -1331,7 +1331,7 @@ export function openSearch() {
|
||||
ws.searchOpen = true
|
||||
ws.sidebarOpen = true
|
||||
ws.sidebarCollapsed = false
|
||||
// 已打开时重新聚焦并全选,便于直接输入新的搜索词
|
||||
// If already open, refocus and select all so users can immediately enter a new query.
|
||||
void nextTick(() => {
|
||||
const input = document.querySelector<HTMLInputElement>('.rm-search-input')
|
||||
input?.focus()
|
||||
|
||||
@@ -10,14 +10,14 @@ import { applyLocale, DEFAULT_LOCALE, isLocale, t as translate } from '@@/i18n/r
|
||||
import { normalizeKeys } from '@@/utils/keys'
|
||||
import { isCancel } from 'axios'
|
||||
import { defineStore } from 'pinia'
|
||||
// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。
|
||||
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
|
||||
// Application settings are one server JSON object shared across devices; changes save immediately and sync through events.
|
||||
// Apply the last local cache at startup to avoid flashing default values before saved settings appear.
|
||||
import { reactive } from 'vue'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from './user'
|
||||
|
||||
export const TAB_SIZES = [2, 4, 8]
|
||||
// 0 为全宽
|
||||
// 0 means full width.
|
||||
export const CONTENT_WIDTHS = [
|
||||
{ get label() {
|
||||
return translate('common.narrow_a614db')
|
||||
@@ -48,7 +48,7 @@ export class SettingsAppearanceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
|
||||
// Server settings may come from older versions, APIs, or plugins; invalid values fall back to defaults.
|
||||
function sanitize(raw: Record<string, unknown>): Settings {
|
||||
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
|
||||
const hiddenFonts = Array.isArray(raw.hiddenFonts) ? raw.hiddenFonts.filter((id): id is string => typeof id === 'string' && id !== 'maple' && Object.hasOwn(FONT_PRESETS, id)) : []
|
||||
@@ -113,7 +113,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
|
||||
applied({ ...settings })
|
||||
// 缓存只提前应用明暗模式;主题和片段等服务器确认后再应用。
|
||||
// Apply only cached light/dark mode early; wait for server confirmation before themes and snippets.
|
||||
applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
|
||||
|
||||
let settingsRequest = 0
|
||||
@@ -122,14 +122,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const version = user.getSessionVersion()
|
||||
const request = ++settingsRequest
|
||||
if (settingsSave.pending) {
|
||||
// 推送可能先于 PATCH 响应到达;先等待现有写入,避免读取未保存的旧值。
|
||||
// Events may precede PATCH responses; wait for existing writes before reading to avoid loading unsaved old values.
|
||||
await saveQueue
|
||||
user.assertSession(version)
|
||||
if (request !== settingsRequest) return
|
||||
}
|
||||
const [raw, fonts] = await Promise.all([getSettingsApi().then(({ data }) => data), getFontsApi().then(({ data }) => data.fonts)])
|
||||
user.assertSession(version)
|
||||
// 目录与设置作为一批应用,迟到旧响应不能恢复已删除字体或覆盖新设置。
|
||||
// Apply the catalog and settings together; late old responses must not restore deleted fonts or overwrite newer settings.
|
||||
if (request !== settingsRequest) return
|
||||
applyFontCatalog(parseFontCatalog(fonts))
|
||||
applied(sanitize(raw))
|
||||
@@ -141,11 +141,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
await applySettingsAppearance()
|
||||
}
|
||||
|
||||
/** 修改设置:先在本地生效再保存,保存失败时由调用方提示并重新载入 */
|
||||
/** Apply setting changes locally before saving; on failure the caller reports the error and reloads. */
|
||||
async function updateSettings(patch: Partial<Settings>) {
|
||||
++settingsRequest
|
||||
applied({ ...settings, ...patch })
|
||||
// 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。
|
||||
// Serialize setting requests so rapid switches or snippet edits cannot let old requests overwrite newer values.
|
||||
await saveSettings(patch)
|
||||
try {
|
||||
await applySettingsAppearance()
|
||||
@@ -180,7 +180,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return pending
|
||||
}
|
||||
|
||||
/** 维护前等待现有设置保存;失败时不得继续替换配置。 */
|
||||
/** Wait for pending settings saves before maintenance; do not replace configuration if saving fails. */
|
||||
async function flushSettings() {
|
||||
await saveQueue
|
||||
if (settingsSave.result === 'error') throw new Error(translate('common.could_not_save_settings_1cdd7f', { p0: settingsSave.error }))
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
infoRequest = pending
|
||||
void pending.finally(() => {
|
||||
if (infoRequest === pending) infoRequest = undefined
|
||||
}).catch(() => { /* 失败由原请求调用方处理。 */ })
|
||||
}).catch(() => { /* The original request caller handles failures. */ })
|
||||
return pending
|
||||
}
|
||||
async function logout() {
|
||||
|
||||
@@ -31,7 +31,7 @@ it('匿名深链保留完整回跳地址,登录后恢复身份并注册路由'
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/?note=a#heading')
|
||||
user.setToken('rms_valid')
|
||||
// 登录组件已恢复身份时,守卫依然必须安装动态路由。
|
||||
// The guard must install dynamic routes even when the sign-in component has already restored identity.
|
||||
await user.getInfo()
|
||||
await router.replace({ path: '/login', query: router.currentRoute.value.query, force: true })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?note=a#heading')
|
||||
|
||||
@@ -5,7 +5,7 @@ const paths = new Set(FILES)
|
||||
globalThis.addEventListener('install', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
// 逐批下载,避免同时请求数百个语法包和字体。
|
||||
// Download in batches instead of requesting hundreds of syntax packages and fonts concurrently.
|
||||
for (let i = 0; i < FILES.length; i += 16) {
|
||||
await cache.addAll(FILES.slice(i, i + 16).map(url => new Request(url, { cache: 'reload' })))
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function clientUsesCurrentShell(client) {
|
||||
}
|
||||
|
||||
async function cleanCaches() {
|
||||
// 安装中的新版本也使用独立缓存,旧 worker 不能把它当作过期资源删除。
|
||||
// Installing versions use independent caches too; old workers must not delete them as obsolete resources.
|
||||
if (globalThis.registration.installing || globalThis.registration.waiting) return
|
||||
const clients = await globalThis.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
if (!(await Promise.all(clients.map(clientUsesCurrentShell))).every(Boolean)) return
|
||||
@@ -42,7 +42,7 @@ globalThis.addEventListener('activate', (event) => {
|
||||
event.waitUntil(cleanCaches())
|
||||
})
|
||||
|
||||
// 激活会更换所有页面的控制器,但只由点击更新的页面主动刷新。
|
||||
// Activation changes every page's controller, but only the page requesting the update refreshes itself.
|
||||
globalThis.addEventListener('message', (event) => {
|
||||
if (event.data === 'ACTIVATE') event.waitUntil(globalThis.skipWaiting())
|
||||
if (event.data === 'CLEANUP') event.waitUntil(cleanCaches())
|
||||
@@ -52,14 +52,14 @@ globalThis.addEventListener('fetch', (event) => {
|
||||
const { request } = event
|
||||
const url = new URL(request.url)
|
||||
if (request.method !== 'GET' || url.origin !== globalThis.location.origin) return
|
||||
// 只匹配构建产物与应用入口,不缓存 API、笔记图片或插件。
|
||||
// Match only build assets and application entry points; never cache APIs, note images, or plugins.
|
||||
const entry = request.mode === 'navigate' && (url.pathname === '/' || url.pathname === '/index.html')
|
||||
if (!entry && !paths.has(url.pathname) && !url.pathname.startsWith('/assets/')) return
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
const cached = await cache.match(entry ? '/index.html' : url.pathname)
|
||||
if (cached) return cached
|
||||
// 旧标签页尚未刷新时,按原来的哈希加载旧语法包与字体。
|
||||
// Unrefreshed old tabs load old syntax packages and fonts by their original hashes.
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
for (const name of await caches.keys()) {
|
||||
if (!name.startsWith('rawmark-shell-')) continue
|
||||
|
||||
Reference in new issue
Block a user