refactor(web): adopt modular Vue architecture and isolate bearer sessions
This commit is contained in:
1 parent
1b2b2b12ff
commit
3cd641a380
379 files changed
+19736
-8712
No files matched your search
@@ -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 }),
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 } }),
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface InstalledPlugin {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
apiVersion: 1
|
||||
revision: string
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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 }[]
|
||||
}
|
||||
@@ -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, '') }
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
@@ -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`,
|
||||
}))
|
||||
@@ -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()))
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user