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

+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