chore: standardize project comments in English

This commit is contained in:
eezd committed 2026-10-26 22:52:12 +08:00
1 parent a874a39ea5
commit ccb16acbea
167 files changed
+820 -820

No files matched your search

+1 -1
View File
@@ -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>
+5 -5
View File
@@ -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',
+8 -8
View File
@@ -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',
+1 -1
View File
@@ -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'
+8 -8
View File
@@ -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)}`,
+13 -13
View File
@@ -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 -1
View File
@@ -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',
+6 -6
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
}
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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',
+2 -2
View File
@@ -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)}`,
+3 -3
View File
@@ -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)}`,
+4 -4
View File
@@ -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)}`,
+2 -2
View File
@@ -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',
+1 -1
View File
@@ -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) {
+18 -18
View File
@@ -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;
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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,
+28 -28
View File
@@ -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'])
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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 -1
View File
@@ -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'
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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 }))
+2 -2
View File
@@ -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(['---'])
})
})
+13 -13
View File
@@ -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: [
+5 -5
View File
@@ -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('安全模式', () => {
+22 -22
View File
@@ -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 === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;'))
}
// 属性值中保留实体引用(交给浏览器解码),只转义其余的特殊字符
// 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 === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;')
@@ -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)
+1 -1
View File
@@ -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 -1
View File
@@ -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) => {
+7 -7
View File
@@ -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')
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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 -1
View File
@@ -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>,
+2 -2
View File
@@ -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')
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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',
+3 -3
View File
@@ -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')
+3 -3
View File
@@ -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'))
}
+3 -3
View File
@@ -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]')!
+1 -1
View File
@@ -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 -1
View File
@@ -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[]> = {
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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' }))
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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')
+3 -3
View File
@@ -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()
+3 -3
View File
@@ -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 -8
View File
@@ -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)
})
+4 -4
View File
@@ -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('标题循环', () => {
+12 -12
View File
@@ -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)
+4 -4
View File
@@ -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) => {
+7 -7
View File
@@ -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')
+11 -11
View File
@@ -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) {
+10 -10
View File
@@ -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)
+20 -20
View File
@@ -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 -1
View File
@@ -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' },
+15 -15
View File
@@ -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
}
+8 -8
View File
@@ -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++) {
+5 -5
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>()
+1 -1
View File
@@ -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',
+3 -3
View File
@@ -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) => {
+82 -82
View File
@@ -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 -10
View File
@@ -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 }))
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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 -5
View File
@@ -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