refactor(web): adopt modular Vue architecture and isolate bearer sessions

This commit is contained in:
eezd committed 2026-10-25 20:47:32 +08:00
1 parent 1b2b2b12ff
commit 3cd641a380
379 files changed
+19736 -8712

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
import { requestJSON } from '@/http/axios'
export interface LoginResult { token: string, expiresAt: string }
export interface Identity { permissions: string[], expiresAt: string }
export interface Captcha { captchaId: string, imageDataUrl: string, expiresAt: string }
export interface ResourceTicket { url: string, expiresAt: string }
export type TicketRequest = { kind: 'images' | 'fonts' | 'events' } | { kind: 'plugin', pluginId: string, revision: string }
export const authApi = {
state: () => requestJSON<{ setUp: boolean }>('GET', '/auth/state', { auth: false, errorOwner: 'caller' }),
captcha: (replaceId?: string) => requestJSON<Captcha>('POST', '/auth/captcha', { auth: false, json: { replaceId }, errorOwner: 'caller' }),
setup: (password: string) => requestJSON<LoginResult>('POST', '/auth/setup', { auth: false, json: { password }, errorOwner: 'caller' }),
login: (password: string, captchaId: string, captchaCode: string) => requestJSON<LoginResult>('POST', '/auth/login', { auth: false, json: { password, captchaId, captchaCode }, errorOwner: 'caller' }),
me: () => requestJSON<Identity>('GET', '/auth/me', { errorOwner: 'caller' }),
logout: () => requestJSON<null>('POST', '/auth/logout'),
ticket: (data: TicketRequest) => requestJSON<ResourceTicket>('POST', '/auth/tickets', { json: data }),
}
+40
View File
@@ -0,0 +1,40 @@
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { authApi } from './index'
let version = -1
let imageRoot = ''
let fontRoot = ''
let pending: Promise<void> | undefined
export async function prepareResources() {
const user = useUserStore(pinia)
const current = user.getSessionVersion()
if (version !== current) {
version = current; imageRoot = ''; fontRoot = ''; pending = undefined
}
if (imageRoot && fontRoot) return
if (!pending) {
const request = Promise.all([authApi.ticket({ kind: 'images' }), authApi.ticket({ kind: 'fonts' })]).then(([images, fonts]) => {
user.assertSession(current)
imageRoot = images.url
fontRoot = fonts.url
})
pending = request
void request.finally(() => {
if (pending === request) pending = undefined
}).catch(() => { /* 由等待初始化的调用方处理失败。 */ })
}
await pending
user.assertSession(current)
}
export function imageResourceRoot(): string {
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('图片资源认证尚未就绪')
return imageRoot
}
export function fontResourceRoot(): string {
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('字体资源认证尚未就绪')
return fontRoot
}
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {
return `${(await authApi.ticket({ kind: 'plugin', pluginId, revision })).url}main.js`
}
+15
View File
@@ -0,0 +1,15 @@
import type { InstalledPlugin } from './type'
import { requestJSON } from '@/http/axios'
export const pluginsApi = {
plugins: async () => (await requestJSON<{ plugins: InstalledPlugin[] }>('GET', '/plugins', { errorOwner: 'caller' })).plugins,
installPlugin: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<InstalledPlugin>('POST', '/plugins', { errorOwner: 'caller', body })
},
togglePlugin: (id: string, enabled: boolean) => requestJSON<InstalledPlugin>('PATCH', `/plugins/${encodeURIComponent(id)}`, { errorOwner: 'caller', json: { enabled } }),
uninstallPlugin: (id: string) => requestJSON('DELETE', `/plugins/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
pluginData: (id: string) => requestJSON<{ data: unknown }>('GET', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller' }),
savePluginData: (id: string, data: unknown) => requestJSON('PUT', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller', json: { data } }),
}
+8
View File
@@ -0,0 +1,8 @@
export interface InstalledPlugin {
id: string
name: string
version: string
apiVersion: 1
revision: string
enabled: boolean
}
+16
View File
@@ -0,0 +1,16 @@
import type { CustomFont, Theme } from './type'
import { requestJSON } from '@/http/axios'
export const preferencesApi = {
settings: () => requestJSON<Record<string, unknown>>('GET', '/settings', { errorOwner: 'caller' }),
fonts: async () => (await requestJSON<{ fonts: CustomFont[] }>('GET', '/fonts', { errorOwner: 'caller' })).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<CustomFont>('POST', '/fonts', { errorOwner: 'caller', body })
},
deleteFont: (id: string) => requestJSON('DELETE', `/fonts/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
themes: async () => (await requestJSON<{ themes: Theme[] }>('GET', '/themes', { errorOwner: 'caller' })).themes,
theme: (id: string) => requestJSON<Theme & { css: string }>('GET', `/themes/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
patchSettings: (patch: Record<string, unknown>) => requestJSON<Record<string, unknown>>('PATCH', '/settings', { errorOwner: 'caller', json: patch }),
}
+11
View File
@@ -0,0 +1,11 @@
export interface Theme {
id: string
name: string
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
+9
View File
@@ -0,0 +1,9 @@
import type { TrashItem, TrashPurgeResult } from './type'
import { requestJSON } from '@/http/axios'
export const trashApi = {
clearTrash: () => requestJSON<TrashPurgeResult>('DELETE', '/trash', { errorOwner: 'caller' }),
trash: async () => (await requestJSON<{ items: TrashItem[] }>('GET', '/trash', { errorOwner: 'caller' })).items,
restore: (id: string) => requestJSON<TrashItem>('POST', `/trash/${id}/restore`, { errorOwner: 'caller' }),
purge: (id: string) => requestJSON('DELETE', `/trash/${id}`, { errorOwner: 'caller' }),
}
+11
View File
@@ -0,0 +1,11 @@
export interface TrashItem {
id: string
path: string
dir: boolean
deletedAt: number
}
export interface TrashPurgeResult {
deleted: string[]
failed: { id: string, error: string }[]
}
+16
View File
@@ -0,0 +1,16 @@
import type { Entry } from './type'
import { encodePath, requestJSON, requestRaw } from '@/http/axios'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
export const vaultApi = {
tree: async () => (await requestJSON<{ entries: Entry[] }>('GET', '/tree', { errorOwner: 'caller' })).entries,
async readFile(path: string): Promise<{ content: string, rev: string }> {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
const res = await requestRaw('GET', `/files/${encodePath(path)}`, { errorOwner: 'caller' })
const content = await res.text()
user.assertSession(version)
return { content, rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
},
}
+11
View File
@@ -0,0 +1,11 @@
import type { IconName } from '@@/utils/icons'
export type TreeIcon = { kind: 'emoji', value: string } | { kind: 'builtin', value: IconName }
export interface Entry {
path: string
dir: boolean
size: number
mtime: number
icon?: TreeIcon
}
+84
View File
@@ -0,0 +1,84 @@
export interface CssSnippet {
id: string
name: string
css: string
enabled: boolean
}
export interface Appearance {
colorMode: 'system' | 'light' | 'dark'
theme: string
cssSnippets: CssSnippet[]
}
export const appearancePaused = new URLSearchParams(location.search).has('reset-appearance')
export function fixedThemeMode(theme: string): 'light' | 'dark' | undefined {
if (theme === 'white') return 'light'
if (theme === 'black') return 'dark'
}
export function sanitizeAppearance(raw: Record<string, unknown>): Appearance {
return {
colorMode: raw.colorMode === 'light' || raw.colorMode === 'dark' ? raw.colorMode : 'system',
theme: typeof raw.theme === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(raw.theme) ? raw.theme : 'default',
cssSnippets: Array.isArray(raw.cssSnippets)
? raw.cssSnippets.filter((s): s is CssSnippet =>
s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.name === 'string'
&& typeof s.css === 'string' && typeof s.enabled === 'boolean',
).map(s => ({ id: s.id, name: s.name, css: s.css, enabled: s.enabled }))
: [],
}
}
let current: Appearance = { colorMode: 'system', theme: 'default', cssSnippets: [] }
let themeCSS = ''
const darkMode = matchMedia('(prefers-color-scheme: dark)')
let rendered = ''
function style(id: string, css: string) {
let el = document.getElementById(id) as HTMLStyleElement | null
if (!el) {
el = document.createElement('style')
el.id = id
document.head.append(el)
}
if (el.textContent !== css) el.textContent = css
}
function render() {
const mode = appearancePaused ? 'system' : fixedThemeMode(current.theme) ?? current.colorMode
const dark = mode === 'system' ? darkMode.matches : mode === 'dark'
const signature = JSON.stringify([dark, appearancePaused ? '' : [current.theme, themeCSS, current.cssSnippets]])
if (signature === rendered) return
rendered = signature
const root = document.documentElement
root.dataset.rmColorMode = dark ? 'dark' : 'light'
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
style('rm-theme', appearancePaused ? '' : themeCSS)
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。
document.querySelectorAll('style[data-rm-snippet]').forEach(el => el.remove())
if (!appearancePaused) {
for (const snippet of current.cssSnippets) {
if (!snippet.enabled) continue
const el = document.createElement('style')
el.dataset.rmSnippet = snippet.id
el.textContent = snippet.css
document.head.append(el)
}
}
const bg = getComputedStyle(root).getPropertyValue('--rm-bg').trim()
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
meta.removeAttribute('media')
meta.content = bg
}
window.dispatchEvent(new Event('rawmark-appearance'))
}
darkMode.addEventListener('change', render)
export function applyAppearance(next: Appearance, css: string) {
current = next
themeCSS = css
render()
}
+656
View File
@@ -0,0 +1,656 @@
@import './fonts.css';
@import 'highlight.js/styles/atom-one-dark.css';
@import './maple-font.css';
/* 基础主题。--rm-* 变量继续作为主题接口,字体在本地加载。 */
:root {
color-scheme: light;
--rm-sidebar-width: 288px;
--rm-font-text: 'Maple Mono Normal NL NF CN', monospace;
--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。 */
--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));
--rm-ui-font-heading: calc(var(--rm-ui-font-size) + 4px);
--rm-ui-font-title: calc(var(--rm-ui-font-size) + 8px);
--rm-ui-line-height: 1.5;
--rm-icon-size: max(16px, calc(var(--rm-ui-font-size) + 2px));
--rm-control-size: max(32px, calc(var(--rm-ui-font-size) + 18px));
--rm-control-compact: max(28px, calc(var(--rm-ui-font-size) + 14px));
--rm-space-1: 4px;
--rm-space-2: 8px;
--rm-space-3: 12px;
--rm-space-4: 16px;
--rm-space-6: 24px;
--rm-panel-duration: 220ms;
--rm-panel-easing: cubic-bezier(0.2, 0, 0, 1);
--rm-header-height: calc(var(--rm-ui-font-size) + 48px);
--rm-line-height: 1.75;
--rm-content-width: 760px;
--rm-glass-opacity: 0.75;
--rm-bg: #faf8ff;
--rm-sidebar-bg: #f2f3ff;
--rm-text: #131b2e;
--rm-text-muted: #777587;
--rm-divider: rgba(119, 117, 135, 0.14);
--rm-accent: #4f46e5;
--rm-accent-strong: #3525cd;
--rm-selection: rgba(79, 70, 229, 0.16);
--rm-hover: rgba(199, 196, 216, 0.14);
--rm-active: rgba(255, 255, 255, 0.85);
--rm-danger: #ba1a1a;
--rm-warn-bg: #fff8e6;
--rm-warn-text: #8a5a00;
--rm-shadow: 0 12px 40px -12px rgba(19, 27, 46, 0.18), 0 2px 8px rgba(19, 27, 46, 0.03);
--rm-inline-code-bg: #eaedff;
--rm-inline-code-text: #3525cd;
--rm-code-bg: #282c34;
--rm-code-text: #abb2bf;
--rm-code-font-size: 14px;
--rm-highlight-bg: rgba(255, 213, 0, 0.35);
}
:root:where([data-rm-color-mode='dark']) {
color-scheme: dark;
--rm-bg: #1e2022;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
--rm-divider: rgba(84, 84, 84, 0.48);
--rm-accent: #42d392;
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
--rm-hover: rgba(255, 255, 255, 0.06);
--rm-active: rgba(66, 211, 146, 0.16);
--rm-warn-bg: #2f2716;
--rm-warn-text: #f0c060;
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-rm-color-mode])) {
color-scheme: dark;
--rm-bg: #1e2022;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
--rm-divider: rgba(84, 84, 84, 0.48);
--rm-accent: #42d392;
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
--rm-hover: rgba(255, 255, 255, 0.06);
--rm-active: rgba(66, 211, 146, 0.16);
--rm-warn-bg: #2f2716;
--rm-warn-text: #f0c060;
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
}
html,
body {
margin: 0;
background: var(--rm-bg);
color: var(--rm-text);
font-family: var(--rm-font-text);
}
body {
font-size: var(--rm-ui-font-size);
line-height: var(--rm-ui-line-height);
}
button,
input,
select,
textarea {
font: inherit;
}
.rm-icon {
flex: none;
width: var(--rm-icon-size);
height: var(--rm-icon-size);
}
@media (pointer: coarse), (max-width: 767px) {
:root {
--rm-control-size: max(44px, calc(var(--rm-ui-font-size) + 24px));
--rm-control-compact: max(44px, calc(var(--rm-ui-font-size) + 16px));
}
input:not([type='checkbox'], [type='radio'], [type='range']),
select,
textarea {
font-size: max(16px, var(--rm-ui-font-size));
}
}
.cm-editor {
font-size: var(--rm-font-size);
background: var(--rm-bg);
color: var(--rm-text);
}
.cm-editor.cm-focused {
outline: none;
}
.cm-editor .cm-scroller {
font-family: var(--rm-font-editor, var(--rm-font-text));
line-height: var(--rm-line-height);
}
.cm-editor .cm-content {
max-width: var(--rm-content-width);
margin: 0 auto;
padding: 32px 24px 40vh;
caret-color: var(--rm-accent);
}
.cm-editor .cm-content.cm-rm-source {
font-family: var(--rm-font-mono);
}
.cm-editor .cm-cursor {
border-left: 2px solid var(--rm-accent);
}
.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground {
background: var(--rm-selection) !important;
}
/* 标题 */
.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;
padding-bottom: 0.5rem;
}
.cm-line.cm-rm-h1 {
font-size: 2.2em;
line-height: 1.3;
}
.cm-line.cm-rm-h2 {
font-size: 1.75em;
line-height: 1.225;
border-bottom: 1px solid var(--rm-divider);
}
.cm-line.cm-rm-h3 {
font-size: 1.4em;
line-height: 1.43;
}
.cm-line.cm-rm-h4 {
font-size: 1.2em;
}
.cm-line.cm-rm-h5,
.cm-line.cm-rm-h6 {
font-size: 1em;
}
/* Markdown 标记(激活行显示源码时) */
.cm-rm-markup,
.cm-rm-url {
color: var(--rm-text-muted);
}
.cm-rm-link,
.cm-rm-link .cm-rm-url {
color: var(--rm-accent-strong);
text-decoration: none;
cursor: text;
}
/* 行内代码 */
.cm-rm-inline-code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.15em 0.4em;
border-radius: 4px;
}
/* 代码块
* 背景画在 ::before 上并置于选区图层之下:若直接给 .cm-line 设背景,会盖住 CodeMirror 绘制的选区 */
.cm-line.cm-rm-code {
position: relative;
font-family: var(--rm-font-mono);
font-size: var(--rm-code-font-size);
line-height: 1.5;
color: var(--rm-code-text);
padding: 0 24px;
tab-size: 4;
}
.cm-line.cm-rm-code::before {
content: '';
position: absolute;
inset: 0;
background: var(--rm-code-bg);
z-index: -10;
pointer-events: none;
}
.cm-line.cm-rm-code .cm-rm-mono {
font-family: inherit;
}
.cm-line.cm-rm-code-begin {
padding-top: 10px;
min-height: var(--rm-control-compact);
}
.cm-line.cm-rm-code-end {
padding-bottom: 10px;
}
.cm-line.cm-rm-code-begin::before {
border-radius: 8px 8px 0 0;
}
.cm-line.cm-rm-code-end::before {
border-radius: 0 0 8px 8px;
}
.cm-line.cm-rm-code-begin.cm-rm-code-end::before {
border-radius: 8px;
}
/* 围栏展开前后保持同一行高,语言标签与复制按钮不参与行高计算,避免代码内容跳动。 */
.cm-rm-code-header {
display: inline-flex;
align-items: center;
gap: 6px;
vertical-align: middle;
user-select: none;
}
.cm-line.cm-rm-code-begin .cm-rm-code-header {
position: absolute;
top: 6px;
right: 12px;
}
.cm-rm-code-lang {
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-small);
line-height: 1;
}
.cm-rm-code-copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
}
.cm-rm-code-copy:hover {
color: var(--rm-accent-strong);
background: var(--rm-hover);
}
.cm-rm-code-copy.cm-rm-copied {
color: #42d392;
}
.cm-rm-code .cm-rm-markup {
color: var(--rm-text-muted);
}
/* 引用 */
.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);
}
/* 列表 */
.cm-rm-bullet {
color: var(--rm-text-muted);
display: inline-block;
width: 1ch;
}
.cm-rm-task {
margin: 0 0.4em 0 0;
vertical-align: -0.1em;
accent-color: var(--rm-accent);
cursor: pointer;
}
/* 分隔线 */
.cm-rm-hr {
display: inline-block;
width: 100%;
border-top: 1px solid var(--rm-divider);
vertical-align: middle;
}
/* 图片 */
.cm-rm-image {
display: block;
}
.cm-rm-image img {
max-width: 100%;
border-radius: 4px;
}
.cm-rm-image-error {
color: var(--rm-text-muted);
font-style: italic;
}
/* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */
.cm-line.cm-rm-table {
font-family: var(--rm-font-mono);
font-size: 0.9em;
}
.cm-rm-table-widget {
padding: 2px 0 4px;
width: fit-content;
max-width: 100%;
}
.cm-rm-table-scroll {
overflow-x: auto;
}
.cm-rm-table-widget table {
border-collapse: collapse;
font-size: 0.95em;
line-height: 1.5;
}
.cm-rm-table-widget th,
.cm-rm-table-widget td {
border: 1px solid var(--rm-divider);
padding: 6px 12px;
min-width: 3em;
cursor: text;
outline: none;
}
.cm-rm-table-widget th {
font-weight: 600;
text-align: left;
background: var(--rm-inline-code-bg);
}
.cm-rm-table-widget [contenteditable] {
box-shadow: inset 0 0 0 2px var(--rm-accent);
font-family: var(--rm-font-mono);
font-size: 0.9em;
}
.cm-rm-table-widget code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.1em 0.35em;
border-radius: 4px;
}
.cm-rm-table-widget a {
color: var(--rm-accent-strong);
}
/* 工具栏占据表格上方的固定空间,只切换可见性,不遮挡表格也不引起跳动 */
.cm-rm-table-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s;
}
.cm-rm-table-widget:hover .cm-rm-table-toolbar,
.cm-rm-table-widget:focus-within .cm-rm-table-toolbar {
visibility: visible;
opacity: 1;
}
.cm-rm-table-tools {
max-width: 100%;
flex-wrap: wrap;
display: inline-flex;
align-items: center;
padding: 2px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.cm-rm-table-tools button {
min-width: var(--rm-control-compact);
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--rm-control-compact);
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
font: var(--rm-ui-font-small)/1 var(--rm-font-text);
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
}
.cm-rm-table-tools button:hover {
color: var(--rm-accent-strong);
background: var(--rm-selection);
}
.cm-rm-table-tools button.cm-rm-danger:hover {
color: #e5484d;
background: rgba(229, 72, 77, 0.1);
}
.cm-rm-table-tools svg {
flex: none;
width: var(--rm-ui-font-size);
height: var(--rm-ui-font-size);
}
.cm-rm-code-copy svg {
width: var(--rm-icon-size);
height: var(--rm-icon-size);
}
.cm-rm-table-tools .cm-rm-sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--rm-divider);
}
.cm-rm-image {
cursor: pointer;
}
@media (max-width: 640px) {
.cm-editor .cm-content {
padding: 16px 16px 40vh;
}
/* 触屏没有悬停,工具栏常显,按钮加大触控热区 */
.cm-rm-table-toolbar {
visibility: visible;
opacity: 1;
}
.cm-line.cm-rm-code {
padding-left: 12px;
padding-right: 12px;
}
}
/* ---- Typora 扩展语法 ---- */
.cm-rm-highlight,
.cm-rm-table-widget mark {
background: var(--rm-highlight-bg);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.cm-rm-sup {
vertical-align: super;
font-size: 0.75em;
line-height: 0;
}
.cm-rm-sub {
vertical-align: sub;
font-size: 0.75em;
line-height: 0;
}
.cm-rm-placeholder {
color: var(--rm-text-muted);
font-style: italic;
}
.cm-rm-math {
cursor: pointer;
}
.cm-rm-math-block {
display: block;
padding: 8px 0;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
cursor: pointer;
}
.cm-rm-math-block .katex-display {
margin: 0;
}
.cm-line.cm-rm-math-source {
font-family: var(--rm-font-mono);
font-size: 0.9em;
color: var(--rm-text-muted);
}
/* 编辑时显示在源码下方的预览 */
.cm-rm-preview {
margin: 4px 0 8px;
padding: 8px 12px;
border: 1px dashed var(--rm-divider);
border-radius: 6px;
cursor: default;
}
.cm-rm-code-render {
display: block;
padding: 8px 0;
overflow-x: auto;
cursor: pointer;
}
.cm-rm-mermaid {
display: block;
padding: 8px 0;
text-align: center;
overflow-x: auto;
cursor: pointer;
color: var(--rm-text-muted);
}
.cm-rm-mermaid svg {
max-width: 100%;
height: auto;
}
.cm-rm-render-error {
margin: 0;
padding: 8px 12px;
border-radius: 6px;
background: rgba(229, 72, 77, 0.08);
color: #e5484d;
font-family: var(--rm-font-mono);
font-size: var(--rm-ui-font-small);
text-align: left;
white-space: pre-wrap;
}
.cm-rm-emoji {
cursor: text;
}
.cm-rm-footnote-ref {
color: var(--rm-accent-strong);
font-size: 0.75em;
cursor: pointer;
padding: 0 1px;
}
.cm-line.cm-rm-footnote {
font-size: 0.9em;
color: var(--rm-text-muted);
}
.cm-rm-footnote-label {
color: var(--rm-accent-strong);
}
.cm-rm-toc {
display: block;
padding: 8px 12px;
border-left: 3px solid var(--rm-divider);
cursor: pointer;
}
.cm-rm-toc-item {
display: block;
color: var(--rm-accent-strong);
line-height: 1.8;
cursor: pointer;
text-decoration: none;
}
.cm-rm-toc-item:hover {
text-decoration: underline;
}
.cm-line.cm-rm-frontmatter {
font-family: var(--rm-font-mono);
font-size: 0.85em;
color: var(--rm-text-muted);
background: var(--rm-inline-code-bg);
padding: 0 12px;
}
.cm-line.cm-rm-frontmatter-begin {
border-radius: 6px 6px 0 0;
padding-top: 4px;
}
.cm-line.cm-rm-frontmatter-end {
border-radius: 0 0 6px 6px;
padding-bottom: 4px;
}
.cm-line:is(.cm-rm-h1, .cm-rm-h2, .cm-rm-h3, .cm-rm-h4, .cm-rm-h5, .cm-rm-h6) {
font-family: var(--rm-font-heading);
font-weight: 600;
}
.cm-line.cm-rm-h1 {
font-size: 2.125em;
line-height: 1.4;
}
.cm-line.cm-rm-h2 {
font-size: 1.5em;
line-height: 1.5;
}
.cm-rm-underline {
text-decoration: underline;
}
.cm-editor .cm-content {
padding-top: 16px;
}
.cm-rm-code-lang {
font-family: var(--rm-font-mono);
}
.cm-line.cm-rm-code-begin::before {
border-top: 1px solid var(--rm-divider);
}
.cm-line.cm-rm-code-end::before {
border-bottom: 1px solid var(--rm-divider);
}
@media (max-width: 767px) {
.cm-line.cm-rm-h1 {
font-size: 1.75em;
}
}
/* opacity 保留排版和字体请求;等待期间不显示备用字体,也覆盖 Teleport 到 body 的菜单。 */
:root:is([data-rm-fonts='loading'], [data-rm-fonts='error']) body > :not(.rm-font-error) {
opacity: 0;
pointer-events: none;
}
.rm-font-error {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--rm-text);
font:
16px/1.5 system-ui,
sans-serif;
}
.rm-font-error button {
padding: 8px 16px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
color: inherit;
cursor: pointer;
}
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { watchFontLoading } from './font-loading'
class Fonts extends EventTarget {
status = 'loading'
faces: { status: string }[] = []
resolve!: () => void
ready = this.pending()
pending() {
return new Promise<void>((resolve) => {
this.resolve = resolve
})
}
[Symbol.iterator]() {
return this.faces[Symbol.iterator]()
}
finish() {
this.status = 'loaded'; this.resolve()
}
load() {
this.status = 'loading'; this.ready = this.pending(); this.dispatchEvent(new Event('loading'))
}
}
let fonts: Fonts
let frame: FrameRequestCallback
const descriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
beforeEach(() => {
document.body.innerHTML = '<div id="app">正文</div>'
fonts = new Fonts()
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frame = callback; return 1
})
watchFontLoading()
})
afterEach(() => {
vi.unstubAllGlobals()
delete document.documentElement.dataset.rmFonts
document.body.innerHTML = ''
if (descriptor) Object.defineProperty(document, 'fonts', descriptor)
else Reflect.deleteProperty(document, 'fonts')
})
it('挂载后的字体就绪前保持隐藏,等待结束才显示', async () => {
expect(document.documentElement.dataset.rmFonts).toBe('loading')
frame(0)
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('loading')
fonts.finish()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('ready')
})
it('字体已在缓存中时直接显示,不依赖再次触发 loading 事件', async () => {
fonts.finish()
frame(0)
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('ready')
})
it('切换笔记或字形需要新字体时重新等待,不用旧一轮完成信号提前显示', async () => {
frame(0)
fonts.finish()
fonts.load()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('loading')
fonts.finish()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('ready')
})
it('字体失败显示可刷新提示,之后 ready 完成和其他字体加载不能显示备用字体', async () => {
frame(0)
fonts.dispatchEvent(new Event('loadingerror'))
fonts.dispatchEvent(new Event('loadingerror'))
fonts.finish()
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(document.querySelector('[role="alert"]')!.textContent).toContain('字体加载失败')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('刷新页面')
fonts.load()
expect(document.documentElement.dataset.rmFonts).toBe('error')
})
it('开始监听前已有字体失败,也不能被 resolved ready 当作成功', async () => {
fonts.faces = [{ status: 'error' }]
fonts.finish()
frame(0)
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelector('[role="alert"]')).not.toBeNull()
})
+41
View File
@@ -0,0 +1,41 @@
// 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。
export function watchFontLoading() {
const root = document.documentElement
const fonts = document.fonts
root.dataset.rmFonts = 'loading'
function failed() {
if (root.dataset.rmFonts === 'error') return
root.dataset.rmFonts = 'error'
const panel = document.createElement('div')
panel.className = 'rm-font-error'
panel.setAttribute('role', 'alert')
const message = document.createElement('p')
message.textContent = '字体加载失败,请检查网络后刷新页面。'
const retry = document.createElement('button')
retry.textContent = '刷新页面'
retry.addEventListener('click', () => location.reload())
panel.append(message, retry)
document.body.append(panel)
}
function ready() {
if (root.dataset.rmFonts === 'error') return
// fonts.ready 在下载失败时也会完成,不能将失败当作字体已就绪。
if ([...fonts].some(font => font.status === 'error')) failed()
else if (fonts.status === 'loaded') root.dataset.rmFonts = 'ready'
}
fonts.addEventListener('loading', () => {
if (root.dataset.rmFonts === 'error') return
root.dataset.rmFonts = 'loading'
void fonts.ready.then(ready)
})
fonts.addEventListener('loadingerror', failed)
// 挂载完成后触发一次排版,确保等待的是当前文字用到的字体。
requestAnimationFrame(() => {
document.body.getBoundingClientRect()
void fonts.ready.then(ready)
})
}
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { applyFontCatalog, parseFontCatalog } from './fonts'
describe('导入字体资源边界', () => {
it('拒绝可插入CSS的标识和不支持的格式', () => {
for (const value of [null, {}, [{}], [{ id: 'custom-\');}body{color:red}', name: 'bad', format: 'woff2' }], [{ id: `custom-${'a'.repeat(64)}`, name: 'bad', format: 'exe' }]]) {
expect(() => parseFontCatalog(value)).toThrow('字体列表格式无效')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
const fonts = parseFontCatalog([{ id: `custom-${'a'.repeat(64)}`, name: 'bad\');}body{color:red}', format: 'woff2' }])
applyFontCatalog(fonts)
const style = document.querySelector('#rm-custom-fonts')!
expect(style.textContent).not.toContain('body{color:red}')
const text = style.firstChild
applyFontCatalog(fonts)
expect(style.firstChild).toBe(text)
applyFontCatalog([])
})
})
vi.mock('@@/apis/auth/resources', () => ({
prepareResources: async () => {},
imageResourceRoot: () => '/api/v1/resources/test-images/images/',
fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/',
pluginResourceURL: async (id: string, revision: string) => `/api/v1/resources/test-plugin/plugin/plugins/${id}/assets/${revision}/main.js`,
}))
+27
View File
@@ -0,0 +1,27 @@
import type { CustomFont } from '@@/apis/preferences/type'
import { fontResourceRoot, prepareResources } from '@@/apis/auth/resources'
import { preferencesApi } from '@@/apis/preferences'
import { shallowRef } from 'vue'
export const customFonts = shallowRef<CustomFont[]>([])
export function applyFontCatalog(fonts: CustomFont[]) {
customFonts.value = fonts
let style = document.getElementById('rm-custom-fonts')
if (!style) {
style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style)
}
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
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
}
export function parseFontCatalog(value: unknown): CustomFont[] {
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error('字体列表格式无效')
return value
}
export async function loadFonts() {
await prepareResources()
applyFontCatalog(parseFontCatalog(await preferencesApi.fonts()))
}
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff. Show more