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
+4
-3
@@ -3,10 +3,11 @@
|
|||||||
# ---- 前端 ----
|
# ---- 前端 ----
|
||||||
FROM node:24-alpine AS web
|
FROM node:24-alpine AS web
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
COPY web/package.json web/package-lock.json ./
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
RUN npm ci
|
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/.npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN pnpm build
|
||||||
|
|
||||||
# ---- 服务端(前端产物嵌入二进制) ----
|
# ---- 服务端(前端产物嵌入二进制) ----
|
||||||
FROM golang:1-alpine AS server
|
FROM golang:1-alpine AS server
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func uploadTestFont(t *testing.T, f *fixture, client *http.Client, name string,
|
|||||||
|
|
||||||
func testFontData(t *testing.T) []byte {
|
func testFontData(t *testing.T) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := os.ReadFile("../../web/src/themes/fonts/maple/regular-00.woff2")
|
data, err := os.ReadFile("../../web/src/common/appearance/fonts/maple/regular-00.woff2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -42,7 +42,7 @@ fail() {
|
|||||||
|
|
||||||
command -v go >/dev/null || fail "未找到 go,请安装 Go 1.27 或更高版本"
|
command -v go >/dev/null || fail "未找到 go,请安装 Go 1.27 或更高版本"
|
||||||
command -v node >/dev/null || fail "未找到 node,请安装 Node.js 24 或更高版本"
|
command -v node >/dev/null || fail "未找到 node,请安装 Node.js 24 或更高版本"
|
||||||
command -v npm >/dev/null || fail "未找到 npm"
|
command -v pnpm >/dev/null || fail "未找到 pnpm,请启用 pnpm 12.11.2"
|
||||||
|
|
||||||
port_in_use() {
|
port_in_use() {
|
||||||
if command -v ss >/dev/null; then
|
if command -v ss >/dev/null; then
|
||||||
@@ -54,11 +54,9 @@ port_in_use() {
|
|||||||
port_in_use "$API_PORT" && fail "端口 $API_PORT 已被占用(后端)"
|
port_in_use "$API_PORT" && fail "端口 $API_PORT 已被占用(后端)"
|
||||||
port_in_use "$WEB_PORT" && fail "端口 $WEB_PORT 已被占用(前端)"
|
port_in_use "$WEB_PORT" && fail "端口 $WEB_PORT 已被占用(前端)"
|
||||||
|
|
||||||
# 依赖缺失或 package-lock.json 有更新时才安装
|
# 由 pnpm 核对锁定依赖;已有依赖时复用本地缓存。
|
||||||
if [[ ! -d web/node_modules || web/package-lock.json -nt web/node_modules/.package-lock.json ]]; then
|
echo "==> 核对前端依赖"
|
||||||
echo "==> 安装前端依赖"
|
pnpm -C web install --frozen-lockfile
|
||||||
(cd web && npm install --no-fund --no-audit)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 退出时结束本脚本启动的所有子进程(包括 go run 编译出的服务进程)
|
# 退出时结束本脚本启动的所有子进程(包括 go run 编译出的服务进程)
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -83,7 +81,7 @@ done
|
|||||||
curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 || fail "后端 60 秒内未就绪,请查看上方 [server] 日志"
|
curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 || fail "后端 60 秒内未就绪,请查看上方 [server] 日志"
|
||||||
|
|
||||||
echo "==> 启动前端 http://localhost:$WEB_PORT"
|
echo "==> 启动前端 http://localhost:$WEB_PORT"
|
||||||
(cd web && exec npx vite "${VITE_ARGS[@]}") 2>&1 | prefix web &
|
(cd web && exec pnpm exec vite "${VITE_ARGS[@]}") 2>&1 | prefix web &
|
||||||
|
|
||||||
# 任一进程退出(如后端崩溃)即整体停止
|
# 任一进程退出(如后端崩溃)即整体停止
|
||||||
wait -n
|
wait -n
|
||||||
@@ -11,7 +11,7 @@ import zipfile
|
|||||||
from fontTools import subset
|
from fontTools import subset
|
||||||
from fontTools.ttLib import TTFont
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[1] / 'web/src/themes'
|
root = Path(__file__).resolve().parents[1] / 'web/src/common/appearance'
|
||||||
output = root / 'fonts/maple'
|
output = root / 'fonts/maple'
|
||||||
output.mkdir(exist_ok=True)
|
output.mkdir(exist_ok=True)
|
||||||
family = 'Maple Mono Normal NL NF CN'
|
family = 'Maple Mono Normal NL NF CN'
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 配置项文档:https://editorconfig.org(修改配置后重启编辑器)
|
||||||
|
|
||||||
|
## 告知 EditorConfig 插件,当前即是根文件
|
||||||
|
root = true
|
||||||
|
|
||||||
|
## 适用全部文件
|
||||||
|
[*]
|
||||||
|
### 设置字符集
|
||||||
|
charset = utf-8
|
||||||
|
### 缩进风格 space | tab,建议 space
|
||||||
|
indent_style = space
|
||||||
|
### 缩进的空格数
|
||||||
|
indent_size = 2
|
||||||
|
### 换行符类型 lf | cr | crlf,一般都是设置为 lf
|
||||||
|
end_of_line = lf
|
||||||
|
### 是否在文件末尾插入空白行
|
||||||
|
insert_final_newline = true
|
||||||
|
### 是否删除一行中的前后空格
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
## 适用 .md 文件
|
||||||
|
[*.md]
|
||||||
|
insert_final_newline = false
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Common
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.eslintcache
|
||||||
|
vite.config.*.timestamp*
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
*.local
|
||||||
|
*.local.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Use the pnpm
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Agent data
|
||||||
|
.omc
|
||||||
|
/CONTEXT.md
|
||||||
|
.agents/skills/*
|
||||||
|
!.agents/skills/v3-*/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# China mirror of npm
|
||||||
|
registry = https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 安装依赖时锁定版本号
|
||||||
|
save-exact = true
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"vue.volar",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"antfu.unocss",
|
||||||
|
"vitest.explorer"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+56
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
// Use workspace TypeScript version
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
|
||||||
|
// Show inlay hints for destructured props
|
||||||
|
"vue.inlayHints.destructuredProps": true,
|
||||||
|
|
||||||
|
// Disable the default formatter, use eslint instead
|
||||||
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
|
||||||
|
// Auto fix
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||||
|
"eslint.rules.customizations": [
|
||||||
|
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||||
|
{ "rule": "*semi", "severity": "off", "fixable": true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Enable eslint for all supported languages
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"yaml",
|
||||||
|
"toml",
|
||||||
|
"xml",
|
||||||
|
"gql",
|
||||||
|
"graphql",
|
||||||
|
"astro",
|
||||||
|
"svelte",
|
||||||
|
"css",
|
||||||
|
"less",
|
||||||
|
"scss",
|
||||||
|
"pcss",
|
||||||
|
"postcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022-present pany <https://github.com/pany-ang>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/demo/main.ts"></script>
|
<script type="module" src="/src/pages/workspace/demo/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
//go:build !dev
|
//go:build !dev
|
||||||
|
|
||||||
// Package web 把前端构建产物(web/dist,由 npm run build 生成)嵌入 Go 二进制。
|
// Package web 把前端构建产物(web/dist,由 pnpm build 生成)嵌入 Go 二进制。
|
||||||
// 开发时使用 -tags dev,前端由 Vite 开发服务器提供,见 embed_dev.go。
|
// 开发时使用 -tags dev,前端由 Vite 开发服务器提供,见 embed_dev.go。
|
||||||
package web
|
package web
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ func BuildVersion() (string, error) { return "dev", nil }
|
|||||||
|
|
||||||
// FontStorage 按 Vite 构建使用的 fonts.css 统计可选字体,不包含 Maple 与 KaTeX。
|
// FontStorage 按 Vite 构建使用的 fonts.css 统计可选字体,不包含 Maple 与 KaTeX。
|
||||||
func FontStorage() (int64, error) {
|
func FontStorage() (int64, error) {
|
||||||
root := filepath.Join("web", "src", "themes")
|
root := filepath.Join("web", "src", "common", "appearance")
|
||||||
css, err := os.ReadFile(filepath.Join(root, "fonts.css"))
|
css, err := os.ReadFile(filepath.Join(root, "fonts.css"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func TestDevelopmentMetadataDoesNotRequireBuild(t *testing.T) {
|
func TestDevelopmentMetadataDoesNotRequireBuild(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
t.Chdir(root)
|
t.Chdir(root)
|
||||||
themes := filepath.Join("web", "src", "themes")
|
themes := filepath.Join("web", "src", "common", "appearance")
|
||||||
if err := os.MkdirAll(filepath.Join(themes, "fonts"), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Join(themes, "fonts"), 0700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
|
// 更多自定义配置可查阅仓库:https://github.com/antfu/eslint-config
|
||||||
|
export default antfu(
|
||||||
|
{
|
||||||
|
// 使用外部格式化程序格式化 css、html、markdown 等文件
|
||||||
|
formatters: true,
|
||||||
|
// 启用样式规则
|
||||||
|
stylistic: {
|
||||||
|
// 缩进级别
|
||||||
|
indent: 2,
|
||||||
|
// 引号风格 'single' | 'double'
|
||||||
|
quotes: 'single',
|
||||||
|
// 是否启用分号
|
||||||
|
semi: false,
|
||||||
|
},
|
||||||
|
// 忽略文件
|
||||||
|
ignores: ['dist/**', 'types/auto/**', 'public/**', 'src/common/appearance/fonts/**', 'src/common/appearance/maple-font.css', 'LICENSE'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 对所有文件都生效的规则
|
||||||
|
rules: {
|
||||||
|
// 保留项目现有紧凑分支及浏览器原生危险操作确认。
|
||||||
|
'style/max-statements-per-line': 'off',
|
||||||
|
'no-alert': 'off',
|
||||||
|
// vue
|
||||||
|
'vue/block-order': ['error', { order: ['script', 'template', 'style'] }],
|
||||||
|
'vue/attributes-order': 'off',
|
||||||
|
// ts
|
||||||
|
'ts/no-use-before-define': 'off',
|
||||||
|
// node
|
||||||
|
'node/prefer-global/process': 'off',
|
||||||
|
// style
|
||||||
|
'style/comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'style/brace-style': ['error', '1tbs'],
|
||||||
|
// regexp
|
||||||
|
'regexp/no-unused-capturing-group': 'off',
|
||||||
|
// other
|
||||||
|
'no-console': 'off',
|
||||||
|
'no-debugger': 'off',
|
||||||
|
'symbol-description': 'off',
|
||||||
|
'antfu/if-newline': 'off',
|
||||||
|
'unicorn/no-instanceof-builtins': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ files: ['src/service-worker.js'], languageOptions: { globals: { __PRECACHE__: 'readonly' } } },
|
||||||
|
)
|
||||||
Generated
-2979
File diff suppressed because it is too large.
Load diff
+43
-26
@@ -1,37 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "rawmark-web",
|
"name": "rawmark-web",
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "[email protected]",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"lint": "eslint --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint:check": "eslint .",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.11.1",
|
"@codemirror/commands": "6.11.1",
|
||||||
"@codemirror/lang-markdown": "^6.5.2",
|
"@codemirror/lang-markdown": "6.5.2",
|
||||||
"@codemirror/language": "^6.13.1",
|
"@codemirror/language": "6.13.1",
|
||||||
"@codemirror/state": "^6.7.6",
|
"@codemirror/state": "6.7.6",
|
||||||
"@codemirror/view": "^6.43.14",
|
"@codemirror/view": "6.43.14",
|
||||||
"@lezer/common": "^1.5.3",
|
"@lezer/common": "1.5.3",
|
||||||
"@lezer/highlight": "^1.2.5",
|
"@lezer/highlight": "1.2.5",
|
||||||
"@lezer/markdown": "^1.8.0",
|
"@lezer/markdown": "1.8.0",
|
||||||
"gemoji": "^8.1.0",
|
"axios": "1.18.1",
|
||||||
"highlight.js": "^11.12.0",
|
"element-plus": "2.14.2",
|
||||||
"katex": "^0.18.11",
|
"gemoji": "8.1.0",
|
||||||
"mermaid": "^12.2.0",
|
"highlight.js": "11.12.0",
|
||||||
"turndown": "^7.2.4",
|
"katex": "0.18.11",
|
||||||
"vue": "^3.5.44"
|
"mermaid": "12.2.0",
|
||||||
|
"pinia": "3.0.4",
|
||||||
|
"turndown": "7.2.4",
|
||||||
|
"vue": "3.5.44",
|
||||||
|
"vue-router": "5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/turndown": "^5.0.6",
|
"@antfu/eslint-config": "9.1.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@types/node": "26.1.1",
|
||||||
"commonmark-spec": "^0.31.2",
|
"@types/turndown": "5.0.6",
|
||||||
"happy-dom": "^21.0.3",
|
"@vitejs/plugin-vue": "6.0.10",
|
||||||
"typescript": "^6.0.3",
|
"@vue/test-utils": "2.4.11",
|
||||||
"vite": "^8.3.4",
|
"commonmark-spec": "0.31.2",
|
||||||
"vitest": "^5.0.5",
|
"eslint": "10.6.0",
|
||||||
"vue-tsc": "^3.3.13"
|
"eslint-plugin-format": "2.0.1",
|
||||||
|
"happy-dom": "21.0.3",
|
||||||
|
"sass-embedded": "1.78.0",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"unocss": "66.7.5",
|
||||||
|
"unplugin-auto-import": "21.0.0",
|
||||||
|
"unplugin-vue-components": "32.1.0",
|
||||||
|
"vite": "8.3.4",
|
||||||
|
"vitest": "5.0.5",
|
||||||
|
"vue-tsc": "3.3.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+7236
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
|||||||
|
shellEmulator: true
|
||||||
|
trustPolicy: no-downgrade
|
||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
esbuild: true
|
||||||
|
|
||||||
|
# CodeMirror/Lezer 类型和扩展必须共享同一实例,与迁移前版本保持一致。
|
||||||
|
overrides:
|
||||||
|
'@codemirror/commands': 6.11.1
|
||||||
|
'@codemirror/lang-markdown': 6.5.2
|
||||||
|
'@codemirror/language': 6.13.1
|
||||||
|
'@codemirror/state': 6.7.6
|
||||||
|
'@codemirror/view': 6.43.14
|
||||||
|
'@lezer/common': 1.5.3
|
||||||
|
'@lezer/highlight': 1.2.5
|
||||||
|
'@lezer/markdown': 1.8.0
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Toasts from '@@/components/Toasts.vue'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { useUserStore } from '@/pinia/stores/user'
|
||||||
|
import { navigationError, router } from '@/router'
|
||||||
|
|
||||||
|
const user = useUserStore()
|
||||||
|
function retry() {
|
||||||
|
const { path, query, hash } = router.resolve(navigationError.value!.path)
|
||||||
|
void router.replace({ path, query, hash, force: true }).catch(() => { /* 错误由路由错误界面展示。 */ })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<div v-if="navigationError" class="rm-splash" role="alert">
|
||||||
|
<p>{{ navigationError.message }}</p><el-button @click="retry">
|
||||||
|
重试
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<router-view v-else v-slot="{ Component, route }">
|
||||||
|
<component :is="Component" :key="route.meta.anonymous ? route.name : user.sessionVersion" />
|
||||||
|
</router-view>
|
||||||
|
<Toasts />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { api, setUnauthorizedHandler } from './api'
|
|
||||||
import AuthView from './AuthView.vue'
|
|
||||||
import AppStatus from './AppStatus.vue'
|
|
||||||
import { online, registerPwa } from './pwa'
|
|
||||||
import Toasts from './Toasts.vue'
|
|
||||||
import Workspace from './Workspace.vue'
|
|
||||||
import { stopWorkspace } from './workspace'
|
|
||||||
|
|
||||||
const phase = ref<'loading' | 'setup' | 'login' | 'ready' | 'error'>('loading')
|
|
||||||
|
|
||||||
async function check() {
|
|
||||||
try {
|
|
||||||
const s = await api.authState()
|
|
||||||
// 请求成功比浏览器的网络状态提示更可靠(局域网和网络切换时可能不同步)。
|
|
||||||
online.value = true
|
|
||||||
phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login'
|
|
||||||
} catch {
|
|
||||||
phase.value = 'error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setUnauthorizedHandler(() => {
|
|
||||||
stopWorkspace()
|
|
||||||
phase.value = 'login'
|
|
||||||
})
|
|
||||||
onMounted(() => {
|
|
||||||
void check()
|
|
||||||
void registerPwa()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppStatus />
|
|
||||||
<AuthView v-if="phase === 'setup' || phase === 'login'" :mode="phase" @done="phase = 'ready'" />
|
|
||||||
<Workspace v-else-if="phase === 'ready'" @logout="phase = 'login'" />
|
|
||||||
<div v-else class="rm-splash">
|
|
||||||
<template v-if="phase === 'error'">
|
|
||||||
<p>{{ online ? '无法连接到服务器' : '离线,无法加载笔记' }}</p>
|
|
||||||
<button class="rm-btn" @click="check">重试</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<Toasts />
|
|
||||||
</template>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { api, ApiError, type Theme } from './api'
|
|
||||||
import { DISPLAY_DEFAULTS } from '../themes/typography'
|
|
||||||
import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance'
|
|
||||||
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
|
|
||||||
import SettingRow from './SettingRow.vue'
|
|
||||||
import { toast } from './workspace'
|
|
||||||
|
|
||||||
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
|
|
||||||
const error = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
const editing = ref<string | null>(null)
|
|
||||||
const draft = ref({ name: '', css: '' })
|
|
||||||
const missingTheme = computed(() => !themes.value.some((theme) => theme.id === settings.theme))
|
|
||||||
const fixedMode = computed(() => fixedThemeMode(settings.theme))
|
|
||||||
|
|
||||||
async function refreshThemes() {
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
themes.value = await api.themes()
|
|
||||||
await reloadTheme()
|
|
||||||
}
|
|
||||||
catch (err) { error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络' }
|
|
||||||
}
|
|
||||||
onMounted(refreshThemes)
|
|
||||||
|
|
||||||
async function change(patch: Partial<Settings>): Promise<boolean> {
|
|
||||||
busy.value = true
|
|
||||||
try {
|
|
||||||
await updateSettings(patch)
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof SettingsAppearanceError) { toast(err.message); return true }
|
|
||||||
toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
|
|
||||||
void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误')))
|
|
||||||
return false
|
|
||||||
} finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit(snippet?: CssSnippet) {
|
|
||||||
editing.value = snippet?.id ?? ''
|
|
||||||
draft.value = { name: snippet?.name ?? '', css: snippet?.css ?? '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSnippet() {
|
|
||||||
if (!draft.value.name.trim()) return
|
|
||||||
const snippets = settings.cssSnippets.map((s) => ({ ...s }))
|
|
||||||
const existing = snippets.find((s) => s.id === editing.value)
|
|
||||||
if (existing) Object.assign(existing, draft.value, { name: draft.value.name.trim() })
|
|
||||||
else {
|
|
||||||
// getRandomValues 在局域网 HTTP 开发页面也可用,randomUUID 需要安全上下文。
|
|
||||||
const id = [...crypto.getRandomValues(new Uint32Array(4))].map((n) => n.toString(16).padStart(8, '0')).join('')
|
|
||||||
snippets.push({ ...draft.value, name: draft.value.name.trim(), id, enabled: true })
|
|
||||||
}
|
|
||||||
if (await change({ cssSnippets: snippets })) editing.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggle(snippet: CssSnippet) {
|
|
||||||
await change({ cssSnippets: settings.cssSnippets.map((s) => s.id === snippet.id ? { ...s, enabled: !s.enabled } : { ...s }) })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(snippet: CssSnippet) {
|
|
||||||
if (!window.confirm(`删除 CSS 片段“${snippet.name}”?`)) return
|
|
||||||
if (await change({ cssSnippets: settings.cssSnippets.filter((s) => s.id !== snippet.id) })) {
|
|
||||||
if (editing.value === snippet.id) editing.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reset() {
|
|
||||||
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS,
|
|
||||||
cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return
|
|
||||||
toast('已恢复默认外观与排版,CSS 片段已停用并保留')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
|
|
||||||
<SettingRow id="theme-select" control="rm-theme-select">
|
|
||||||
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
|
|
||||||
<option v-for="theme in themes" :key="theme.id" :value="theme.id">{{ theme.name }}</option>
|
|
||||||
<option v-if="missingTheme" :value="settings.theme">{{ settings.theme }}(不可用)</option>
|
|
||||||
</select>
|
|
||||||
<button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow id="color-mode">
|
|
||||||
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
|
|
||||||
<button v-for="mode in [{ id: 'light', title: '浅色' }, { id: 'dark', title: '深色' }, { id: 'system', title: '跟随系统' }]" :key="mode.id" role="radio" :aria-checked="(fixedMode ?? settings.colorMode) === mode.id" :class="{ on: (fixedMode ?? settings.colorMode) === mode.id }" :disabled="busy || !!fixedMode" @click="change({ colorMode: mode.id as Settings['colorMode'] })">{{ mode.title }}</button>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">{{ error }}</p>
|
|
||||||
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。</p>
|
|
||||||
<details class="rm-settings-details rm-appearance-advanced"><summary>自定义 CSS 与恢复</summary>
|
|
||||||
<SettingRow id="css-snippets"><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></SettingRow>
|
|
||||||
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
|
|
||||||
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
|
|
||||||
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
|
|
||||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
|
|
||||||
</div>
|
|
||||||
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
|
|
||||||
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
|
|
||||||
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
|
|
||||||
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
|
|
||||||
</form>
|
|
||||||
<SettingRow id="reset-appearance"><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观与排版</button></SettingRow>
|
|
||||||
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
|
|
||||||
</details>
|
|
||||||
</template>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { api, ApiError } from './api'
|
|
||||||
|
|
||||||
const props = defineProps<{ mode: 'setup' | 'login' }>()
|
|
||||||
const emit = defineEmits<{ done: [] }>()
|
|
||||||
|
|
||||||
const password = ref('')
|
|
||||||
const confirm = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
const isSetup = computed(() => props.mode === 'setup')
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
if (isSetup.value && password.value !== confirm.value) {
|
|
||||||
error.value = '两次输入的密码不一致'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
busy.value = true
|
|
||||||
try {
|
|
||||||
if (isSetup.value) await api.setup(password.value)
|
|
||||||
else await api.login(password.value)
|
|
||||||
emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-auth">
|
|
||||||
<form class="rm-auth-card" @submit.prevent="submit">
|
|
||||||
<h1>RawMark</h1>
|
|
||||||
<p v-if="isSetup" class="rm-auth-hint">
|
|
||||||
首次使用,请设置登录密码。
|
|
||||||
</p>
|
|
||||||
<label>
|
|
||||||
<span>密码</span>
|
|
||||||
<input
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
:autocomplete="isSetup ? 'new-password' : 'current-password'"
|
|
||||||
:minlength="isSetup ? 8 : undefined"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label v-if="isSetup">
|
|
||||||
<span>确认密码</span>
|
|
||||||
<input v-model="confirm" type="password" autocomplete="new-password" required />
|
|
||||||
</label>
|
|
||||||
<p v-if="error" class="rm-auth-error">{{ error }}</p>
|
|
||||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">{{ isSetup ? '完成设置' : '登录' }}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { api } from './api'
|
|
||||||
import { customFonts, loadFonts } from './fonts'
|
|
||||||
import { FONT_PRESETS } from '../themes/typography'
|
|
||||||
import { loadSettings, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
|
|
||||||
import { toast } from './workspace'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ id: string; setting: 'uiFont' | 'textFont' | 'codeFont' }>()
|
|
||||||
const expanded = ref(false)
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const fileInput = ref<HTMLInputElement>()
|
|
||||||
const fonts = computed(() => [
|
|
||||||
...Object.entries(FONT_PRESETS).filter(([id]) => !settings.hiddenFonts.includes(id)).map(([id, font]) => ({ id, name: font.label })),
|
|
||||||
...customFonts.value,
|
|
||||||
])
|
|
||||||
const selected = computed(() => fonts.value.find(font => font.id === settings[props.setting]) ?? { id: 'maple', name: FONT_PRESETS.maple.label })
|
|
||||||
|
|
||||||
async function choose(id: string) {
|
|
||||||
error.value = ''
|
|
||||||
busy.value = true
|
|
||||||
try { if (await save({ [props.setting]: id })) expanded.value = false }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(patch: Partial<Settings>) {
|
|
||||||
try { await updateSettings(patch); return true }
|
|
||||||
catch (cause) {
|
|
||||||
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return true }
|
|
||||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
|
||||||
try { await loadSettings() }
|
|
||||||
catch (reload) { error.value += ';重新读取设置失败:' + String(reload) }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importFont(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
input.value = ''
|
|
||||||
if (!file) return
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
if (file.size > 30 * 1024 * 1024) throw new Error('字体文件超过 30 MB')
|
|
||||||
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('请选择 TTF、OTF、WOFF 或 WOFF2 字体')
|
|
||||||
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
|
|
||||||
const data = await file.arrayBuffer()
|
|
||||||
try { await new FontFace('rawmark-import-check', data).load() }
|
|
||||||
catch (cause) { throw new Error('无法解析字体文件,请选择有效的字体。', { cause }) }
|
|
||||||
const font = await api.importFont(file)
|
|
||||||
await loadFonts()
|
|
||||||
if (!await save({ [props.setting]: font.id })) return
|
|
||||||
expanded.value = false
|
|
||||||
toast('字体已导入')
|
|
||||||
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(id: string) {
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
if (Object.hasOwn(FONT_PRESETS, id)) {
|
|
||||||
const patch: Partial<typeof settings> = { hiddenFonts: [...settings.hiddenFonts, id] }
|
|
||||||
for (const key of ['uiFont', 'textFont', 'codeFont'] as const) if (settings[key] === id) patch[key] = 'maple'
|
|
||||||
if (!await save(patch)) return
|
|
||||||
} else {
|
|
||||||
await api.deleteFont(id)
|
|
||||||
await loadSettings()
|
|
||||||
}
|
|
||||||
toast('字体已删除,使用它的位置已恢复 Maple')
|
|
||||||
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape' && expanded.value) { event.stopPropagation(); expanded.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-font-picker" @keydown="onKey">
|
|
||||||
<button :id="id" class="rm-btn rm-font-trigger" :aria-expanded="expanded" :aria-controls="id + '-options'" :disabled="busy" @click="expanded = !expanded"><span>{{ selected.name }}</span><Icon name="chevron" /></button>
|
|
||||||
<div v-if="expanded" :id="id + '-options'" class="rm-font-options">
|
|
||||||
<div v-for="font in fonts" :key="font.id" class="rm-font-option">
|
|
||||||
<button class="rm-font-choice" :class="{ on: font.id === settings[setting] }" :aria-pressed="font.id === settings[setting]" :disabled="busy" @click="choose(font.id)"><span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small></button>
|
|
||||||
<button v-if="font.id !== 'maple'" class="rm-icon-btn" :aria-label="'删除字体 ' + font.name" title="删除字体" :disabled="busy" @click="remove(font.id)"><Icon name="trash" /></button>
|
|
||||||
</div>
|
|
||||||
<button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()"><Icon name="import" />{{ busy ? '处理中…' : '导入字体' }}</button>
|
|
||||||
<p class="rm-share-note">支持 TTF、OTF、WOFF、WOFF2,单个文件不超过 30 MB。</p>
|
|
||||||
</div>
|
|
||||||
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="'导入字体 ' + id" @change="importFont" />
|
|
||||||
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rm-font-picker { position: relative; width: 260px; max-width: 100%; min-width: 0; }
|
|
||||||
.rm-font-trigger { width: 100%; justify-content: space-between; text-align: left; }
|
|
||||||
.rm-font-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-font-options { position: absolute; top: calc(100% + 4px); right: 0; z-index: 10; width: 100%; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 8px; padding: var(--rm-space-1); max-height: min(320px, 45dvh); overflow: auto; background: var(--rm-bg); box-shadow: var(--rm-shadow); }
|
|
||||||
.rm-font-option { display: flex; align-items: center; gap: var(--rm-space-1); }
|
|
||||||
.rm-font-choice { display: flex; align-items: center; flex: 1; min-width: 0; padding: 6px 8px; text-align: left; overflow-wrap: anywhere; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
|
||||||
.rm-font-choice span { flex: 1; min-width: 0; }
|
|
||||||
.rm-font-choice:hover, .rm-font-choice.on { background: var(--rm-hover); }
|
|
||||||
.rm-font-choice small { flex: none; margin-left: 8px; color: var(--rm-text-muted); white-space: nowrap; }
|
|
||||||
.rm-font-import { width: 100%; margin-top: var(--rm-space-1); }
|
|
||||||
.rm-font-options p { margin: var(--rm-space-2); }
|
|
||||||
@media (pointer: coarse) { .rm-font-choice { min-height: 44px; } }
|
|
||||||
</style>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import { ApiError } from './api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { basename, dirname, isImage, joinPath } from './paths'
|
|
||||||
import { imageUrl, move, remove, replaceImage, ws } from './workspace'
|
|
||||||
|
|
||||||
const props = defineProps<{ path: string }>()
|
|
||||||
const dialog = ref<HTMLElement>()
|
|
||||||
const picker = ref<HTMLInputElement>()
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const loadError = ref(false)
|
|
||||||
const dimensions = ref('')
|
|
||||||
const name = ref('')
|
|
||||||
const path = computed(() => props.path)
|
|
||||||
const entry = computed(() => ws.entries.find((e) => e.path === path.value))
|
|
||||||
const extension = computed(() => basename(path.value).slice(basename(path.value).lastIndexOf('.')))
|
|
||||||
const src = computed(() => imageUrl(path.value))
|
|
||||||
const siblings = computed(() => ws.entries.filter((e) => !e.dir && isImage(e.path) && dirname(e.path) === dirname(path.value)))
|
|
||||||
const index = computed(() => siblings.value.findIndex((e) => e.path === path.value))
|
|
||||||
const size = computed(() => entry.value ? (entry.value.size / 1024).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) + ' KB' : '')
|
|
||||||
|
|
||||||
watch(path, (p) => { name.value = basename(p).slice(0, -extension.value.length); error.value = '' }, { immediate: true })
|
|
||||||
watch(src, () => { loadError.value = false; dimensions.value = '' })
|
|
||||||
onMounted(() => dialog.value?.focus())
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (!busy.value) ws.image = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function select(offset: number) {
|
|
||||||
const target = siblings.value[index.value + offset]
|
|
||||||
if (target && !busy.value) ws.image = target.path
|
|
||||||
}
|
|
||||||
|
|
||||||
function loaded(event: Event) {
|
|
||||||
const image = event.target as HTMLImageElement
|
|
||||||
dimensions.value = `${image.naturalWidth} × ${image.naturalHeight}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rename() {
|
|
||||||
const base = name.value.trim()
|
|
||||||
if (!base || base.startsWith('.') || /[/\\]/.test(base)) { error.value = '图片名称不能为空、以点开头或包含斜杠'; return }
|
|
||||||
const to = joinPath(dirname(path.value), base + extension.value)
|
|
||||||
if (to === path.value || busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try { await move(path.value, to) }
|
|
||||||
catch (err) { error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录' }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function replace(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
input.value = ''
|
|
||||||
if (!file || busy.value) return
|
|
||||||
if (file.size > 50 * 1024 * 1024) { error.value = '图片不能超过 50 MB'; return }
|
|
||||||
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) { error.value = '请选择与原图片扩展名相同的文件'; return }
|
|
||||||
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try { await replaceImage(path.value, file) }
|
|
||||||
catch (err) { error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试' }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function trash() {
|
|
||||||
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
|
|
||||||
busy.value = true
|
|
||||||
try { await remove(path.value) }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
|
|
||||||
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy">
|
|
||||||
<div class="rm-dialog-head">
|
|
||||||
<h2>图片管理</h2>
|
|
||||||
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="rm-image-body">
|
|
||||||
<p class="rm-image-path">{{ path }}</p>
|
|
||||||
<div class="rm-image-preview">
|
|
||||||
<p v-if="!entry">图片已不存在,请刷新目录</p>
|
|
||||||
<p v-else-if="loadError" role="alert">图片无法预览,文件可能损坏或浏览器不支持此格式。</p>
|
|
||||||
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true" />
|
|
||||||
</div>
|
|
||||||
<div class="rm-image-navigation">
|
|
||||||
<button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">上一张</button>
|
|
||||||
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
|
|
||||||
<button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">下一张</button>
|
|
||||||
</div>
|
|
||||||
<form class="rm-image-rename" @submit.prevent="rename">
|
|
||||||
<label for="rm-image-name">图片名称</label>
|
|
||||||
<div>
|
|
||||||
<input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
|
|
||||||
<span>{{ extension }}</span>
|
|
||||||
<button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">重命名</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<p class="rm-image-hint">改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。</p>
|
|
||||||
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
|
||||||
<div class="rm-image-actions">
|
|
||||||
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace" />
|
|
||||||
<button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">移动到…</button>
|
|
||||||
<button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">移入回收站</button>
|
|
||||||
<button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">{{ busy ? '处理中…' : '替换图片' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rm-image-dialog { max-width: 860px; width: min(860px, calc(100vw - 24px)); }
|
|
||||||
.rm-image-body { display: flex; flex-direction: column; gap: 14px; padding: 0 20px 20px; overflow-y: auto; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-image-body p { margin: 0; }
|
|
||||||
.rm-image-path { overflow-wrap: anywhere; color: var(--rm-text-muted); }
|
|
||||||
.rm-image-preview { display: flex; align-items: center; justify-content: center; min-height: 140px; background: var(--rm-bg); border: 1px solid var(--rm-divider); border-radius: 6px; padding: 12px; }
|
|
||||||
.rm-image-preview img { display: block; max-width: 100%; max-height: 44dvh; object-fit: contain; }
|
|
||||||
.rm-image-navigation, .rm-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
||||||
.rm-image-actions { flex-wrap: wrap; }
|
|
||||||
.rm-image-navigation span { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); text-align: center; }
|
|
||||||
.rm-image-navigation button, .rm-image-rename button { white-space: nowrap; }
|
|
||||||
.rm-image-rename { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.rm-image-rename > div { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.rm-image-rename input { min-width: 0; flex: 1; width: 0; border: 1px solid var(--rm-divider); background: var(--rm-bg); color: var(--rm-text); padding: 8px; border-radius: 6px; font: inherit; }
|
|
||||||
.rm-image-hint { color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: 1.6; }
|
|
||||||
@media(max-width:767px), (pointer:coarse) { .rm-image-rename input { font-size: max(16px, var(--rm-ui-font-size)); } }
|
|
||||||
</style>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { type Entry } from './api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { metadata, refreshMetadata } from './metadata'
|
|
||||||
import { basename, hiddenAssetPath, imageInFolder, isImage } from './paths'
|
|
||||||
import { cleanupImages, imageUrl, openReference, toast, uploadFolderImages, ws } from './workspace'
|
|
||||||
|
|
||||||
const props = defineProps<{ dir: string }>()
|
|
||||||
const query = ref('')
|
|
||||||
const format = ref('')
|
|
||||||
const usage = ref('all')
|
|
||||||
const sort = ref('modified')
|
|
||||||
const list = ref(false)
|
|
||||||
const selected = ref('')
|
|
||||||
const dimensions = ref('')
|
|
||||||
const broken = ref(false)
|
|
||||||
const picker = ref<HTMLInputElement>()
|
|
||||||
const busy = ref(false)
|
|
||||||
const report = ref<string[]>([])
|
|
||||||
const cleanupCandidates = ref<Entry[] | null>(null)
|
|
||||||
const cleanupSelected = ref<string[]>([])
|
|
||||||
const uploadDir = ref(props.dir)
|
|
||||||
const directories = computed(() => ws.entries.filter(entry => entry.dir && !hiddenAssetPath(entry.path)))
|
|
||||||
const allImages = computed(() => ws.entries.filter(entry => !entry.dir && isImage(entry.path)))
|
|
||||||
const scopeImages = computed(() => allImages.value.filter(entry => ws.imagesAll || imageInFolder(entry.path, props.dir)))
|
|
||||||
const references = computed(() => {
|
|
||||||
const result = new Map<string, { path: string; title: string; line: number }[]>()
|
|
||||||
for (const note of metadata.notes) for (const asset of note.assets) {
|
|
||||||
const refs = result.get(asset.target) ?? []
|
|
||||||
if (!refs.some(ref => ref.path === note.path)) refs.push({ path: note.path, title: note.title, line: asset.line })
|
|
||||||
result.set(asset.target, refs)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length))
|
|
||||||
const images = computed(() => scopeImages.value.filter(entry => {
|
|
||||||
const count = references.value.get(entry.path)?.length ?? 0
|
|
||||||
return entry.path.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()) && (!format.value || entry.path.toLowerCase().endsWith('.' + format.value)) && (usage.value === 'all' || certain.value && (usage.value === 'used' ? count > 0 : count === 0))
|
|
||||||
}).sort((a,b) => sort.value === 'name' ? a.path.localeCompare(b.path, 'zh-CN', { numeric: true }) : sort.value === 'size' ? b.size - a.size : b.mtime - a.mtime))
|
|
||||||
const types = computed(() => [...new Set(scopeImages.value.map(entry => entry.path.slice(entry.path.lastIndexOf('.')+1).toLowerCase()))].sort())
|
|
||||||
const totalSize = computed(() => scopeImages.value.reduce((sum, entry) => sum + entry.size, 0))
|
|
||||||
const current = computed(() => scopeImages.value.find(entry => entry.path === selected.value))
|
|
||||||
const currentRefs = computed(() => references.value.get(selected.value) ?? [])
|
|
||||||
const orphans = computed(() => certain.value ? scopeImages.value.filter(entry => !references.value.has(entry.path)) : [])
|
|
||||||
function size(bytes: number) { return bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB' }
|
|
||||||
function select(path: string) { selected.value = path; dimensions.value = ''; broken.value = false }
|
|
||||||
function loaded(event: Event) { const image = event.target as HTMLImageElement; dimensions.value = image.naturalWidth + ' × ' + image.naturalHeight }
|
|
||||||
onMounted(() => { void refreshMetadata() })
|
|
||||||
async function upload(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const files = [...(input.files ?? [])]; input.value = ''
|
|
||||||
if (!files.length) return
|
|
||||||
busy.value = true; report.value = []
|
|
||||||
const valid = files.filter(file => {
|
|
||||||
if (!isImage(file.name) || file.size === 0 || file.size > 50 * 1024 * 1024) { report.value.push(file.name + ':请选择非空图片,单张最大 50 MB'); return false }; return true
|
|
||||||
})
|
|
||||||
try { const result = await uploadFolderImages(uploadDir.value, valid); report.value.push(`已处理 ${result.uploaded} 张图片,相同内容自动去重`, ...result.failures) }
|
|
||||||
catch (error) { report.value.push('上传后刷新失败,请重新检查目录:' + (error instanceof Error ? error.message : '网络错误')) }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
async function proposeCleanup() {
|
|
||||||
await refreshMetadata()
|
|
||||||
if (!certain.value) { toast('引用状态尚未确认,不能列出清理候选'); return }
|
|
||||||
cleanupCandidates.value = [...orphans.value]
|
|
||||||
cleanupSelected.value = orphans.value.map(entry => entry.path)
|
|
||||||
}
|
|
||||||
async function clean() {
|
|
||||||
busy.value = true; report.value = []
|
|
||||||
try {
|
|
||||||
const result = await cleanupImages(cleanupSelected.value)
|
|
||||||
report.value.push(`已将 ${result.deleted.length} 张图片移入回收站`, ...result.skipped.map(item => item.path + ':' + item.reason))
|
|
||||||
cleanupCandidates.value = null
|
|
||||||
} catch (error) { report.value.push('清理失败:' + (error instanceof Error ? error.message : '网络错误')) }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)">
|
|
||||||
<header class="rm-media-header"><div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : (dir || '根目录') + ' · 当前目录及直属附件' }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">返回笔记</button></header>
|
|
||||||
<div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">清理孤立图片</button></div>
|
|
||||||
<div class="rm-media-workspace">
|
|
||||||
<aside class="rm-media-filters"><span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false"><Icon name="folder" />{{ dir || '根目录' }}</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true"><Icon name="image" />整个知识库</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">{{ item.label }}</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain" @click="proposeCleanup"><Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small></button><p>清理前核对全库引用,图片移入回收站,可恢复。</p></aside>
|
|
||||||
<div class="rm-media-content">
|
|
||||||
<div class="rm-media-toolbar"><label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片" /></label><select v-model="format" aria-label="图片格式"><option value="">所有格式</option><option v-for="type in types" :key="type" :value="type">{{ type.toUpperCase() }}</option></select><select v-model="sort" aria-label="图片排序"><option value="modified">最近修改</option><option value="name">名称</option><option value="size">文件大小</option></select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list"><Icon :name="list ? 'grid' : 'outline'" /></button></div>
|
|
||||||
<div class="rm-media-upload"><label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload" /><button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()"><Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}</button></div>
|
|
||||||
<p v-if="metadata.error || !certain" class="rm-media-warning" role="status">{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <button class="rm-btn" @click="refreshMetadata()">刷新状态</button></p>
|
|
||||||
<div v-if="report.length" class="rm-media-report" role="status"><p v-for="(line,i) in report" :key="i">{{ line }}</p></div>
|
|
||||||
<div class="rm-media-grid" :class="{ list }">
|
|
||||||
<button v-for="image in images" :key="image.path" class="rm-media-card" :class="{ selected: selected === image.path }" :title="image.path" @click="select(image.path)" @dblclick="ws.image = image.path" @contextmenu.prevent="ws.image = image.path"><span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy" /></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? `${references.get(image.path)?.length ?? 0} 篇引用` : '引用未确认' }}</small></button>
|
|
||||||
</div>
|
|
||||||
<p v-if="!images.length" class="rm-tree-empty">没有符合条件的图片</p>
|
|
||||||
</div>
|
|
||||||
<aside v-if="current" class="rm-media-inspector"><button class="rm-icon-btn rm-media-deselect" title="关闭详情" @click="selected = ''"><Icon name="close" /></button><div class="rm-media-detail-image"><img v-if="!broken" :src="imageUrl(current.path)" :alt="basename(current.path)" @load="loaded" @error="broken = true" /><p v-else>无法预览此图片</p></div><h2>{{ basename(current.path) }}</h2><p class="rm-media-path">{{ current.path }}</p><dl><dt>尺寸</dt><dd>{{ dimensions || (broken ? '无法读取' : '加载中…') }}</dd><dt>大小</dt><dd>{{ size(current.size) }}</dd><dt>修改时间</dt><dd>{{ new Date(current.mtime).toLocaleDateString('zh-CN') }}</dd><dt>引用</dt><dd>{{ certain ? currentRefs.length + ' 篇笔记' : '尚未确认' }}</dd></dl><button class="rm-btn rm-btn-primary" @click="ws.image = current.path"><Icon name="edit" />预览与编辑</button><h3>引用此图片的笔记</h3><button v-for="ref in currentRefs" :key="ref.path" class="rm-media-reference" @click="ws.images = null; openReference(ref.path, ref.line)"><Icon name="note" /><span>{{ ref.title }}<small>{{ ref.path }}</small></span></button><p v-if="certain && !currentRefs.length">没有笔记引用</p></aside>
|
|
||||||
</div>
|
|
||||||
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"><section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"><div class="rm-dialog-head"><h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"><Icon name="close" /></button></div><p class="rm-share-note">服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。</p><ul><li v-for="image in cleanupCandidates" :key="image.path"><label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy" />{{ image.path }} <small>{{ size(image.size) }}</small></label></li></ul><p v-if="!cleanupCandidates.length" class="rm-share-note">当前范围没有可清理的孤立图片。</p><div class="rm-cleanup-actions"><button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">取消</button><button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">移入回收站({{ cleanupSelected.length }})</button></div></section></div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
<style scoped>
|
|
||||||
.rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: var(--rm-space-6); overflow: hidden; }
|
|
||||||
.rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title)/var(--rm-ui-line-height) var(--rm-font-text); margin: var(--rm-space-2) 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
|
|
||||||
.rm-media-workspace { display: flex; flex: 1; min-height: 0; border: 1px solid var(--rm-divider); border-radius: 16px; overflow: hidden; background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); }
|
|
||||||
.rm-media-filters { flex: none; width: 190px; padding: var(--rm-space-4) var(--rm-space-2); border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: var(--rm-space-2); min-height: var(--rm-control-size); margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; }
|
|
||||||
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: var(--rm-space-4); } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); }
|
|
||||||
.rm-media-upload { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--rm-ui-font-size); } .rm-media-upload label select { min-height: var(--rm-control-size); max-width: 100%; min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { max-width: 100%; font-size: var(--rm-ui-font-size); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; }
|
|
||||||
.rm-media-inspector { flex: none; width: 230px; padding: var(--rm-space-4); border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; }
|
|
||||||
.rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); } .rm-media-warning .rm-btn { font-size: var(--rm-ui-font-size); margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; }
|
|
||||||
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: var(--rm-space-4); }
|
|
||||||
.rm-media-mobile-filters { display: none; }
|
|
||||||
@media(max-width:1199px) { .rm-media-library { padding: var(--rm-space-4); } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: var(--rm-space-4); } }
|
|
||||||
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: var(--rm-space-4); } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } }
|
|
||||||
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
|
|
||||||
</style>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { HOLLOW, INLINE_FONTS, SHADOW, type InlineStyles } from '../markdown/inlineStyles'
|
|
||||||
import { fixedThemeMode } from '../themes/appearance'
|
|
||||||
import { settings } from './settings'
|
|
||||||
|
|
||||||
const props = defineProps<{ styles: InlineStyles; baseSize: number }>()
|
|
||||||
const emit = defineEmits<{ change: [patch: InlineStyles]; clear: [] }>()
|
|
||||||
const system = matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
const systemDark = ref(system.matches)
|
|
||||||
const onSystem = () => { systemDark.value = system.matches }
|
|
||||||
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')
|
|
||||||
// 思源色板顺序;只影响选中文字,不修改主题背景。
|
|
||||||
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'])
|
|
||||||
const background = computed(() => dark.value
|
|
||||||
? ['#442724','#554636','#28405c','#425347','#3a3f42','#031840','#593905','#3a0c09','#4d1b40','#1a5459','#305415','#4a4712','#dadada']
|
|
||||||
: ['#f5d1cf','#ffe8c8','#d6eaf9','#d7eed8','#e2e3e4','#acd0fc','#fddaab','#ffb0a9','#fdbfff','#b1ffff','#affad1','#fff88f','#222222'])
|
|
||||||
const relative = ref(false)
|
|
||||||
const size = ref(props.baseSize)
|
|
||||||
watch(() => props.styles['font-size'], value => {
|
|
||||||
relative.value = value?.endsWith('em') ?? false
|
|
||||||
size.value = value ? Math.round(parseFloat(value) * (relative.value ? 100 : 1)) : props.baseSize
|
|
||||||
}, { immediate: true })
|
|
||||||
function setSize() {
|
|
||||||
if (!Number.isFinite(size.value) || size.value < (relative.value ? 56 : 9) || size.value > (relative.value ? 450 : 72)) return
|
|
||||||
emit('change', { 'font-size': relative.value ? `${size.value / 100}em` : `${size.value}px` })
|
|
||||||
}
|
|
||||||
function switchSize() {
|
|
||||||
size.value = Math.max(relative.value ? 56 : 9, Math.min(relative.value ? 450 : 72, Math.round(relative.value ? size.value / props.baseSize * 100 : size.value / 100 * props.baseSize)))
|
|
||||||
setSize()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-inline-appearance" role="region" aria-label="文字外观">
|
|
||||||
<fieldset><legend>配色</legend><div class="rm-inline-swatches">
|
|
||||||
<button type="button" title="默认配色" aria-label="默认配色" @mousedown.prevent @click="emit('change', { color: undefined, 'background-color': undefined })">A</button>
|
|
||||||
<button v-for="(color, index) in foreground" :key="index" type="button" :title="'配色 ' + (index + 1)" :aria-label="'配色 ' + (index + 1)" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">A</button>
|
|
||||||
</div></fieldset>
|
|
||||||
<fieldset v-for="group in [{ key: 'color', title: '字体颜色', colors: foreground }, { key: 'background-color', title: '背景色', colors: background }]" :key="group.key"><legend>{{ group.title }}</legend><div class="rm-inline-swatches">
|
|
||||||
<button type="button" :title="'默认' + group.title" :aria-label="'默认' + group.title" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">↺</button>
|
|
||||||
<button v-for="(color, index) in group.colors" :key="color" type="button" :title="group.title + ' ' + (index + 1)" :aria-label="group.title + ' ' + (index + 1)" :aria-pressed="styles[group.key as keyof InlineStyles] === color" :style="group.key === 'color' ? { color } : { backgroundColor: color, color: foreground[index] }" @mousedown.prevent @click="emit('change', { [group.key]: color })">A</button>
|
|
||||||
<label class="rm-inline-custom" :title="'自定义' + group.title"><span>自定义</span><input type="color" :aria-label="'自定义' + group.title" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })" /></label>
|
|
||||||
</div></fieldset>
|
|
||||||
<fieldset><legend>字体效果</legend><div class="rm-inline-effects">
|
|
||||||
<button type="button" title="空心" aria-label="空心" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">A</button>
|
|
||||||
<button type="button" title="投影" aria-label="投影" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">A</button>
|
|
||||||
</div></fieldset>
|
|
||||||
<label class="rm-inline-control">字体<select aria-label="局部字体" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">继承正文</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
|
|
||||||
<div class="rm-inline-control"><span>字号</span><label><input v-model="relative" type="checkbox" aria-label="相对字号" @change="switchSize" /> 相对字号</label><button type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">默认</button></div>
|
|
||||||
<form class="rm-inline-size" @submit.prevent="setSize"><input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号滑块" @change="setSize" /><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号" @change="setSize" /><span>{{ relative ? '%' : 'px' }}</span></form>
|
|
||||||
<button type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')">清除样式</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { metadata, refreshMetadata } from './metadata'
|
|
||||||
import { openNote, openReference, ws } from './workspace'
|
|
||||||
const query = ref('')
|
|
||||||
const tag = ref('')
|
|
||||||
const local = ref(false)
|
|
||||||
const camera = ref({ x: -420, y: -290, width: 840 })
|
|
||||||
const tags = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const note of metadata.notes) for (const name of note.tags) counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
||||||
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-CN'))
|
|
||||||
})
|
|
||||||
const visibleNotes = computed(() => {
|
|
||||||
const nearby = new Set([ws.active])
|
|
||||||
for (const note of metadata.notes) for (const link of note.links) if (note.path === ws.active || link.target === ws.active) { nearby.add(note.path); nearby.add(link.target) }
|
|
||||||
return metadata.notes.filter(note => (!local.value || nearby.has(note.path)) && (!tag.value || note.tags.includes(tag.value)) && `${note.path} ${note.title}`.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()))
|
|
||||||
})
|
|
||||||
const graph = computed(() => {
|
|
||||||
const notes = [...visibleNotes.value].sort((a,b) => a.path.localeCompare(b.path))
|
|
||||||
const paths = new Set(notes.map(note => note.path))
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const edges = notes.flatMap(note => note.links.filter(link => {
|
|
||||||
const key = note.path + '\0' + link.target
|
|
||||||
if (seen.has(key) || !paths.has(link.target)) return false
|
|
||||||
seen.add(key); return true
|
|
||||||
}).map(link => ({ from: note.path, to: link.target })))
|
|
||||||
const nodes = notes.map((note, i) => {
|
|
||||||
const center = local.value && note.path === ws.active
|
|
||||||
const angle = i * Math.PI * 2 / Math.min(notes.length, 28)
|
|
||||||
const radius = notes.length === 1 || center ? 0 : 145 + Math.floor(i / 28) * 90
|
|
||||||
return { ...note, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, degree: edges.filter(edge => edge.from === note.path || edge.to === note.path).length }
|
|
||||||
})
|
|
||||||
const byPath = new Map(nodes.map(node => [node.path, node]))
|
|
||||||
return { nodes, edges: edges.map(edge => ({ from: byPath.get(edge.from)!, to: byPath.get(edge.to)! })) }
|
|
||||||
})
|
|
||||||
const missing = computed(() => {
|
|
||||||
const paths = new Set(metadata.notes.map(note => note.path))
|
|
||||||
return visibleNotes.value.flatMap(note => note.links.filter(link => !paths.has(link.target)).map(link => ({ ...link, path: note.path })))
|
|
||||||
})
|
|
||||||
function zoom(factor: number) { const old = camera.value; const width = Math.max(180, Math.min(5000, old.width * factor)); camera.value = { x: old.x + (old.width - width) / 2, y: old.y + (old.width - width) * .345, width } }
|
|
||||||
let drag: { x: number; y: number; left: number; top: number } | undefined
|
|
||||||
function start(event: PointerEvent) {
|
|
||||||
if (event.target instanceof Element && event.target.closest('[data-note]')) return
|
|
||||||
const svg = event.currentTarget as SVGSVGElement
|
|
||||||
svg.setPointerCapture(event.pointerId)
|
|
||||||
drag = { x: event.clientX, y: event.clientY, left: camera.value.x, top: camera.value.y }
|
|
||||||
}
|
|
||||||
function pan(event: PointerEvent) { if (!drag) return; const scale = camera.value.width / (event.currentTarget as SVGSVGElement).getBoundingClientRect().width; camera.value.x = drag.left - (event.clientX - drag.x) * scale; camera.value.y = drag.top - (event.clientY - drag.y) * scale }
|
|
||||||
watch(() => ws.view, () => { query.value = ''; tag.value = '' })
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<section class="rm-knowledge-panel">
|
|
||||||
<header class="rm-knowledge-heading"><div><span class="rm-eyebrow">KNOWLEDGE CONNECTIONS</span><h1>{{ ws.view === 'graph' ? '让知识自然连接' : '标签索引' }}</h1><p>{{ metadata.notes.length }} 篇笔记,以标准 Markdown 链接与标签连接你的想法。</p></div><button class="rm-btn" @click="ws.view = 'notes'">返回笔记</button></header>
|
|
||||||
<div class="rm-knowledge-tools"><label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记" /></label><select v-model="tag" aria-label="筛选标签"><option value="">全部标签</option><option v-for="[name, count] in tags" :key="name" :value="name">#{{ name }} · {{ count }}</option></select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active" />仅当前笔记关联</label></div>
|
|
||||||
<p v-if="metadata.error" role="alert">{{ metadata.error }} <button class="rm-btn" @click="refreshMetadata">重试</button></p>
|
|
||||||
<p v-else-if="!metadata.ready" class="rm-tree-empty">关系索引准备中…</p>
|
|
||||||
<template v-else-if="ws.view === 'graph'">
|
|
||||||
<div class="rm-graph-canvas">
|
|
||||||
<svg :viewBox="`${camera.x} ${camera.y} ${camera.width} ${camera.width * .69}`" role="img" aria-label="笔记关系图谱,可点击节点打开笔记" @pointerdown="start" @pointermove="pan" @pointerup="drag = undefined" @pointercancel="drag = undefined" @wheel.prevent="zoom($event.deltaY > 0 ? 1.12 : .89)">
|
|
||||||
<line v-for="edge in graph.edges" :key="edge.from.path + '\0' + edge.to.path" :x1="edge.from.x" :y1="edge.from.y" :x2="edge.to.x" :y2="edge.to.y" />
|
|
||||||
<g v-for="node in graph.nodes" :key="node.path" :transform="`translate(${node.x} ${node.y})`" data-note tabindex="0" role="button" :aria-label="node.title" :class="{ current: node.path === ws.active }" @click="openNote(node.path)" @keydown.enter.prevent="openNote(node.path)" @keydown.space.prevent="openNote(node.path)"><title>{{ node.path }}</title><circle :r="Math.min(13, 5 + node.degree * 1.5)" /><text y="27" text-anchor="middle">{{ node.title.length > 16 ? node.title.slice(0,16) + '…' : node.title }}</text></g>
|
|
||||||
</svg>
|
|
||||||
<p v-if="!graph.nodes.length" class="rm-graph-empty">没有符合条件的笔记</p>
|
|
||||||
<div class="rm-graph-controls"><span>{{ graph.nodes.length }} 节点 · {{ graph.edges.length }} 关系</span><button class="rm-icon-btn" title="放大" @click="zoom(.8)">+</button><button class="rm-icon-btn" title="缩小" @click="zoom(1.25)">−</button><button class="rm-icon-btn" title="重置视图" @click="camera = { x: -420, y: -290, width: 840 }"><Icon name="expand" /></button></div>
|
|
||||||
</div>
|
|
||||||
<details v-if="missing.length" class="rm-missing-links"><summary>{{ missing.length }} 个链接目标尚不存在</summary><button v-for="link in missing" :key="link.path + link.line + link.target" @click="openReference(link.path, link.line)">{{ link.path }} → {{ link.target }} · 第 {{ link.line }} 行</button></details>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="rm-tags-cloud"><button :class="{ on: !tag }" @click="tag = ''">全部</button><button v-for="[name, count] in tags" :key="name" :class="{ on: tag === name }" @click="tag = name">#{{ name }} <small>{{ count }}</small></button></div>
|
|
||||||
<div class="rm-tag-results"><button v-for="note in visibleNotes" :key="note.path" @click="openNote(note.path)"><Icon name="note" /><div><strong>{{ note.title }}</strong><small>{{ note.path }}</small></div><span>{{ note.tags.map(tag => '#' + tag).join(' ') }}</span></button><p v-if="!visibleNotes.length">没有符合条件的笔记</p></div>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
<style scoped>
|
|
||||||
.rm-knowledge-panel { flex: 1; overflow: auto; padding: var(--rm-space-6); }
|
|
||||||
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--rm-space-4); } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-text); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
|
|
||||||
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: var(--rm-space-6) 0 var(--rm-space-4); } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: var(--rm-ui-font-caption); } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
|
|
||||||
.rm-missing-links { margin-top: 20px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
|
|
||||||
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: var(--rm-space-3); border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
|
|
||||||
@media(max-width:767px) { .rm-knowledge-panel { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
|
|
||||||
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
|
|
||||||
</style>
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { api, type BackupKind, type MaintenanceStatus } from './api'
|
|
||||||
import { flushAll, readyToReload, ws } from './workspace'
|
|
||||||
import { flushSettings, loadSettings, reloadTheme } from './settings'
|
|
||||||
import { refreshPlugins } from '../plugins/host'
|
|
||||||
import SettingRow from './SettingRow.vue'
|
|
||||||
|
|
||||||
const emit = defineEmits<{ changed: [] }>()
|
|
||||||
const busy = ref(false)
|
|
||||||
const progress = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const message = ref('')
|
|
||||||
const last = ref<MaintenanceStatus | null>(null)
|
|
||||||
const fullInput = ref<HTMLInputElement>()
|
|
||||||
const settingsInput = ref<HTMLInputElement>()
|
|
||||||
let mounted = true
|
|
||||||
onBeforeUnmount(() => { mounted = false })
|
|
||||||
onMounted(async () => {
|
|
||||||
try { const status = await api.maintenanceStatus(); if (mounted) last.value = status }
|
|
||||||
catch (cause) { if (mounted) error.value = '读取维护状态失败:' + String(cause) }
|
|
||||||
})
|
|
||||||
async function prepare() {
|
|
||||||
await Promise.all([flushAll(), flushSettings()])
|
|
||||||
if (!readyToReload()) throw new Error('仍有未保存内容或正在上传、操作文件,请完成后再试')
|
|
||||||
}
|
|
||||||
async function perform(label: string, action: () => Promise<void>) {
|
|
||||||
if (busy.value) return
|
|
||||||
busy.value = true; progress.value = label; error.value = ''; message.value = ''
|
|
||||||
try {
|
|
||||||
await prepare()
|
|
||||||
await action()
|
|
||||||
if (mounted) { emit('changed'); last.value = await api.maintenanceStatus() }
|
|
||||||
} catch (cause) { if (mounted) error.value = String(cause) }
|
|
||||||
finally { if (mounted) { busy.value = false; progress.value = '' } }
|
|
||||||
}
|
|
||||||
async function download(response: Response, fallback: string) {
|
|
||||||
const blob = await response.blob()
|
|
||||||
const header = response.headers.get('Content-Disposition') ?? ''
|
|
||||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
|
|
||||||
const plain = /filename="([^"]+)"/i.exec(header)?.[1]
|
|
||||||
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = Object.assign(document.createElement('a'), { href: url, download: name })
|
|
||||||
link.click();setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
||||||
message.value = '导出文件已生成'
|
|
||||||
}
|
|
||||||
async function exportPackage(kind: BackupKind) { await perform('正在导出…', async () => download(await api.exportBackup(kind), `rawmark-${kind}.zip`)) }
|
|
||||||
async function exportMarkdown() { await perform('正在导出笔记…', async () => download(await api.exportNotes(), 'rawmark-notes.zip')) }
|
|
||||||
async function exportSystemLogs() { await perform('正在导出日志…', async () => download(await api.exportLogs(), 'rawmark-system-logs.zip')) }
|
|
||||||
async function syncPreferences() { await loadSettings(); await reloadTheme(); await refreshPlugins() }
|
|
||||||
async function reset() {
|
|
||||||
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
|
|
||||||
await perform('正在重置设置…', async () => { await api.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份' })
|
|
||||||
}
|
|
||||||
async function restore(kind: BackupKind, event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]; input.value = ''
|
|
||||||
if (!file) return
|
|
||||||
await perform('正在上传…', async () => {
|
|
||||||
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
|
|
||||||
const { id } = await api.startBackupUpload(kind, file.size)
|
|
||||||
let submitted = false
|
|
||||||
try {
|
|
||||||
let offset = 0
|
|
||||||
while (offset < file.size) {
|
|
||||||
if (!mounted) throw new Error('已取消上传')
|
|
||||||
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
|
|
||||||
const result = await api.uploadBackupChunk(id, offset, chunk)
|
|
||||||
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
|
|
||||||
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
|
|
||||||
}
|
|
||||||
progress.value = '正在校验备份…'
|
|
||||||
const summary = await api.validateBackup(id)
|
|
||||||
if (!mounted) throw new Error('已取消导入')
|
|
||||||
const description = kind === 'full'
|
|
||||||
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
|
|
||||||
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
|
|
||||||
if (!confirm(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
|
|
||||||
await prepare()
|
|
||||||
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
|
||||||
if (kind === 'settings') {
|
|
||||||
await api.importSettings(id);submitted = true
|
|
||||||
await syncPreferences();message.value = '设置与资源已导入,原设置已备份'
|
|
||||||
} else {
|
|
||||||
await api.restoreBackup(id);submitted = true
|
|
||||||
for (let attempt = 0; attempt < 30; attempt++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
||||||
try { if (!(await api.authState()).authenticated) { location.reload(); return } }
|
|
||||||
catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
|
|
||||||
}
|
|
||||||
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!submitted) {
|
|
||||||
try { await api.cancelBackupUpload(id) }
|
|
||||||
catch (cause) { if (mounted) error.value = '清理上传暂存失败:' + String(cause) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async function index(operation: 'optimize' | 'rebuild') {
|
|
||||||
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
|
|
||||||
await api.maintainIndex(operation);message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async function clearTemp() {
|
|
||||||
await perform('正在清理临时文件…', async () => {
|
|
||||||
const result = await api.clearTemp()
|
|
||||||
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
|
|
||||||
if (result.failed.length) error.value = '部分清理失败:\n' + result.failed.join('\n')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function importNotes() { if (!busy.value) { ws.settings = null; ws.importing = '' } }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-maintenance" :aria-busy="busy">
|
|
||||||
<h3 class="rm-settings-group-title">备份与迁移</h3>
|
|
||||||
<SettingRow id="backup-full"><button class="rm-btn" :disabled="busy" @click="exportPackage('full')">导出</button><button class="rm-btn" :disabled="busy" @click="fullInput!.click()">恢复</button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)" /></SettingRow>
|
|
||||||
<SettingRow id="backup-notes"><button class="rm-btn" :disabled="busy" @click="exportMarkdown">导出</button><button class="rm-btn" :disabled="busy" @click="importNotes">导入</button></SettingRow>
|
|
||||||
<SettingRow id="backup-settings"><button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">导出</button><button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">导入</button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)" /></SettingRow>
|
|
||||||
<h3 class="rm-settings-group-title">维护工具</h3>
|
|
||||||
<SettingRow id="index-optimize"><button class="rm-btn" :disabled="busy" @click="index('optimize')">优化</button></SettingRow>
|
|
||||||
<SettingRow id="index-rebuild"><button class="rm-btn" :disabled="busy" @click="index('rebuild')">重建</button></SettingRow>
|
|
||||||
<SettingRow id="temp-clear"><button class="rm-btn" :disabled="busy" @click="clearTemp">清理</button></SettingRow>
|
|
||||||
<SettingRow id="settings-reset"><button class="rm-btn" :disabled="busy" @click="reset">重置</button></SettingRow>
|
|
||||||
<SettingRow id="logs-export"><button class="rm-btn" :disabled="busy" @click="exportSystemLogs">导出</button></SettingRow>
|
|
||||||
<p v-if="busy" role="status" aria-live="polite">{{ progress }}</p>
|
|
||||||
<p v-if="message" role="status">{{ message }}</p>
|
|
||||||
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
|
||||||
<p v-if="last?.error" class="rm-auth-error" role="alert">上次维护失败:{{ last.error }}</p>
|
|
||||||
<p v-if="last?.backup" class="rm-share-note">操作前备份:backups/{{ last.backup }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rm-maintenance [role="alert"] { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
||||||
</style>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { api, ApiError, type InstalledPlugin } from './api'
|
|
||||||
import { pluginState, refreshPlugins, retryPlugin } from '../plugins/host'
|
|
||||||
import { pluginsPaused, settingTabs } from '../plugins/registry'
|
|
||||||
import PluginMount from './PluginMount.vue'
|
|
||||||
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const fileInput = ref<HTMLInputElement>()
|
|
||||||
const selected = ref('')
|
|
||||||
const currentTab = computed(() => settingTabs.get(selected.value))
|
|
||||||
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
|
||||||
|
|
||||||
async function perform(operation: () => Promise<unknown>) {
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try { await operation(); await refreshPlugins() }
|
|
||||||
catch (err) { error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试' }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function install(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
input.value = ''
|
|
||||||
if (!file) return
|
|
||||||
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
|
|
||||||
await perform(() => api.installPlugin(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(plugin: InstalledPlugin) {
|
|
||||||
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
|
|
||||||
await perform(() => api.uninstallPlugin(plugin.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(plugin: InstalledPlugin) {
|
|
||||||
void perform(() => api.togglePlugin(plugin.id, !plugin.enabled))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<p v-if="pluginsPaused" class="rm-appearance-note">已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。</p>
|
|
||||||
<div class="rm-setting">
|
|
||||||
<span class="rm-setting-label">已安装插件</span>
|
|
||||||
<button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">刷新列表</button>
|
|
||||||
<button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button>
|
|
||||||
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" />
|
|
||||||
</div>
|
|
||||||
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。</p>
|
|
||||||
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">{{ error || pluginState.error }}</p>
|
|
||||||
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">尚未安装插件。</p>
|
|
||||||
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
|
|
||||||
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)" />
|
|
||||||
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span>
|
|
||||||
</label>
|
|
||||||
<button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">重试</button>
|
|
||||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">卸载</button>
|
|
||||||
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">{{ pluginState.status[plugin.id].error }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="rm-appearance-note">若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。</p>
|
|
||||||
<template v-if="settingTabs.size">
|
|
||||||
<div class="rm-setting"><label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
|
|
||||||
<select id="rm-plugin-tab" v-model="selected"><option value="">选择插件设置页</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">{{ tab.title }}</option></select>
|
|
||||||
</div>
|
|
||||||
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { SETTING_ITEMS, type SettingId } from './settingsCatalog'
|
|
||||||
const props = defineProps<{ id: SettingId; control?: string; resettable?: boolean }>()
|
|
||||||
defineEmits<{ reset: [] }>()
|
|
||||||
const item = computed(() => SETTING_ITEMS[props.id])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :id="'rm-setting-' + id" class="rm-setting" tabindex="-1">
|
|
||||||
<div class="rm-setting-info">
|
|
||||||
<component :is="control ? 'label' : 'span'" :for="control" class="rm-setting-label">{{ item.title }}</component>
|
|
||||||
<p>{{ item.description }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rm-setting-control">
|
|
||||||
<slot />
|
|
||||||
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="'重置' + item.title" :aria-label="'重置' + item.title" @click="$emit('reset')"><Icon name="restore" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { api, ApiError } from './api'
|
|
||||||
import { runCommand } from './commands'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import AppearanceSettings from './AppearanceSettings.vue'
|
|
||||||
import { UI_FONT_SIZE } from '../themes/typography'
|
|
||||||
import FontPicker from './FontPicker.vue'
|
|
||||||
import PluginsPanel from './PluginsPanel.vue'
|
|
||||||
import { installApp, installPrompt } from './pwa'
|
|
||||||
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
|
|
||||||
import SettingRow from './SettingRow.vue'
|
|
||||||
import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './settingsCatalog'
|
|
||||||
import ShortcutSettings from './ShortcutSettings.vue'
|
|
||||||
import TokensPanel from './TokensPanel.vue'
|
|
||||||
import TrashPanel from './TrashPanel.vue'
|
|
||||||
import StoragePanel from './StoragePanel.vue'
|
|
||||||
import { toast, ws, type SettingsSection } from './workspace'
|
|
||||||
|
|
||||||
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
|
||||||
if (!buildVersion) throw new Error('缺少应用构建版本信息')
|
|
||||||
|
|
||||||
const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.settings!))
|
|
||||||
// 手机上一次只显示分组列表或分组内容
|
|
||||||
const showList = ref(ws.settings === 'index')
|
|
||||||
const current = computed(() => SETTINGS_SECTIONS.find((s) => s.id === section.value)!)
|
|
||||||
const query = ref('')
|
|
||||||
const searching = computed(() => !!query.value.trim())
|
|
||||||
const results = computed(() => searchSettings(query.value))
|
|
||||||
const dialog = ref<HTMLElement>()
|
|
||||||
const content = ref<HTMLElement>()
|
|
||||||
const heading = ref<HTMLElement>()
|
|
||||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
||||||
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
|
|
||||||
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
|
|
||||||
|
|
||||||
async function open(id: SettingsSection) {
|
|
||||||
query.value = ''
|
|
||||||
ws.settings = id
|
|
||||||
showList.value = false
|
|
||||||
await nextTick()
|
|
||||||
content.value!.scrollTop = 0
|
|
||||||
heading.value!.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function locate(result: SettingSearchResult) {
|
|
||||||
await open(result.section)
|
|
||||||
if (!result.id) return
|
|
||||||
const target = document.getElementById('rm-setting-' + result.id)!
|
|
||||||
// 设置可能位于 CSS 或排版预览的折叠区内。
|
|
||||||
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
|
|
||||||
if (parent instanceof HTMLDetailsElement) parent.open = true
|
|
||||||
}
|
|
||||||
if (target instanceof HTMLDetailsElement) target.open = true
|
|
||||||
await nextTick()
|
|
||||||
target.focus({ preventScroll: true })
|
|
||||||
target.scrollIntoView({ block: 'center' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() {
|
|
||||||
query.value = ''
|
|
||||||
showList.value = true
|
|
||||||
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
ws.settings = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.isComposing) return
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close() }
|
|
||||||
if (e.key !== 'Tab') return
|
|
||||||
const controls = [...dialog.value!.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], summary, [tabindex="0"]')].filter(el => el.getClientRects().length > 0 && !el.closest('[hidden]'))
|
|
||||||
const first = controls[0], last = controls.at(-1)
|
|
||||||
if (e.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
|
|
||||||
e.preventDefault(); last?.focus()
|
|
||||||
} else if (!e.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) {
|
|
||||||
e.preventDefault(); first?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMounted(() => dialog.value!.focus({ preventScroll: true }))
|
|
||||||
onBeforeUnmount(() => { if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }) })
|
|
||||||
|
|
||||||
async function change(patch: Partial<Settings>) {
|
|
||||||
try {
|
|
||||||
await updateSettings(patch)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof SettingsAppearanceError) { toast(err.message); return }
|
|
||||||
toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
|
|
||||||
void loadSettings().catch(error => toast('重新读取设置失败:' + (error instanceof Error ? error.message : '网络错误')))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeFont(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const value = input.valueAsNumber
|
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
|
||||||
if (event.type === 'input') return
|
|
||||||
toast('字号必须为正数')
|
|
||||||
input.value = String(settings.fontSize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (value !== settings.fontSize) void change({ fontSize: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeUIFont(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const value = input.valueAsNumber
|
|
||||||
if (!Number.isInteger(value) || value < UI_FONT_SIZE.min || value > UI_FONT_SIZE.max) {
|
|
||||||
toast(`界面字号须为 ${UI_FONT_SIZE.min}–${UI_FONT_SIZE.max} 之间的整数`)
|
|
||||||
input.value = String(settings.uiFontSize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (value !== settings.uiFontSize) void change({ uiFontSize: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 修改登录密码 ----
|
|
||||||
const pw = ref({ current: '', next: '', confirm: '' })
|
|
||||||
const pwError = ref('')
|
|
||||||
const pwBusy = ref(false)
|
|
||||||
|
|
||||||
async function changePassword() {
|
|
||||||
pwError.value = ''
|
|
||||||
if (pw.value.next.length < 8) return void (pwError.value = '新密码至少 8 位')
|
|
||||||
if (pw.value.next !== pw.value.confirm) return void (pwError.value = '两次输入的新密码不一致')
|
|
||||||
pwBusy.value = true
|
|
||||||
try {
|
|
||||||
await api.changePassword(pw.value.current, pw.value.next)
|
|
||||||
pw.value = { current: '', next: '', confirm: '' }
|
|
||||||
toast('登录密码已修改,其他设备需要重新登录')
|
|
||||||
} catch (err) {
|
|
||||||
pwError.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
|
||||||
} finally {
|
|
||||||
pwBusy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close">
|
|
||||||
<div ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey">
|
|
||||||
<nav class="rm-settings-nav" aria-label="设置分类">
|
|
||||||
<div class="rm-dialog-head">
|
|
||||||
<h2>设置</h2>
|
|
||||||
<button class="rm-icon-btn rm-settings-close-list" title="关闭设置" @click="close"><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<label class="rm-settings-search"><Icon name="search" /><input v-model="query" type="search" aria-label="搜索设置" placeholder="搜索设置" /><button v-if="query" class="rm-icon-btn" aria-label="清空设置搜索" @click="query = ''"><Icon name="close" /></button></label>
|
|
||||||
<div class="rm-settings-categories">
|
|
||||||
<button v-for="s in SETTINGS_SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section && !searching }" :aria-current="s.id === section && !searching ? 'page' : undefined" @click="open(s.id)"><Icon :name="s.icon" />{{ s.title }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="rm-settings-nav-foot"><span>RawMark</span><small>{{ buildVersion }}</small></div>
|
|
||||||
</nav>
|
|
||||||
<section class="rm-settings-body">
|
|
||||||
<div class="rm-dialog-head">
|
|
||||||
<button class="rm-icon-btn rm-settings-back" title="返回设置分类" @click="back"><Icon name="chevron" class="flip" /></button>
|
|
||||||
<h2 ref="heading" tabindex="-1">{{ searching ? '搜索结果' : current.title }}</h2>
|
|
||||||
<span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || '偏好设置保存在服务器上,所有设备共用'" role="status" aria-live="polite">{{ saveLabel }}</span>
|
|
||||||
<button class="rm-icon-btn" title="关闭设置" @click="close"><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<div ref="content" class="rm-settings-content">
|
|
||||||
<div v-if="searching" class="rm-settings-results">
|
|
||||||
<p class="rm-settings-result-count" role="status">{{ results.length ? `找到 ${results.length} 项设置` : '没有匹配的设置,试试其他关键词。' }}</p>
|
|
||||||
<button v-for="result in results" :key="result.id || result.section" class="rm-settings-result" @click="locate(result)"><span><strong>{{ result.title }}</strong><small>{{ result.description }}</small></span><small>{{ SETTINGS_SECTIONS.find(s => s.id === result.section)!.title }}</small><Icon name="chevron" /></button>
|
|
||||||
</div>
|
|
||||||
<div v-show="!searching" class="rm-settings-page">
|
|
||||||
<PluginsPanel v-if="section === 'plugins'" />
|
|
||||||
<template v-else-if="section === 'appearance'">
|
|
||||||
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })"><FontPicker id="rm-ui-font" setting="uiFont" /></SettingRow>
|
|
||||||
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
|
|
||||||
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })"><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output></SettingRow>
|
|
||||||
<AppearanceSettings />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="section === 'editor'">
|
|
||||||
<h3 class="rm-settings-group-title">正文与代码</h3>
|
|
||||||
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><FontPicker id="rm-text-font" setting="textFont" /></SettingRow>
|
|
||||||
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><FontPicker id="rm-code-font" setting="codeFont" /></SettingRow>
|
|
||||||
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })"><label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @input="changeFont" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label></SettingRow>
|
|
||||||
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"><div class="rm-segmented" role="radiogroup" aria-label="正文宽度"><button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">{{ w.label }}</button></div></SettingRow>
|
|
||||||
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output></SettingRow>
|
|
||||||
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1"><summary>排版预览</summary><article class="rm-typography-preview"><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article></details>
|
|
||||||
<h3 class="rm-settings-group-title">编辑行为</h3>
|
|
||||||
<SettingRow id="selection-toolbar" control="rm-selection-toolbar"><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></SettingRow>
|
|
||||||
<SettingRow id="spellcheck" control="rm-spellcheck"><input id="rm-spellcheck" type="checkbox" :checked="settings.spellcheck" @change="change({ spellcheck: ($event.target as HTMLInputElement).checked })" /></SettingRow>
|
|
||||||
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })"><select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })"><option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option></select></SettingRow>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="section === 'account'">
|
|
||||||
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
|
|
||||||
<h3>修改登录密码</h3>
|
|
||||||
<p class="rm-share-note">修改后其他设备需要重新登录。</p>
|
|
||||||
<label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label>
|
|
||||||
<label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label>
|
|
||||||
<label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label>
|
|
||||||
<p v-if="pwError" class="rm-auth-error" role="alert">{{ pwError }}</p>
|
|
||||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
|
|
||||||
</form>
|
|
||||||
<section id="rm-setting-tokens" tabindex="-1"><h3 class="rm-settings-group-title">API 令牌</h3><TokensPanel /></section>
|
|
||||||
<SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
|
||||||
|
|
||||||
<TrashPanel v-else-if="section === 'trash'" />
|
|
||||||
<StoragePanel v-else-if="section === 'storage'" />
|
|
||||||
<template v-else-if="section === 'about'">
|
|
||||||
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
|
|
||||||
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
|
|
||||||
<SettingRow id="install"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装 RawMark</button><span v-else class="rm-settings-unavailable">当前浏览器暂未提供安装入口</span></SettingRow>
|
|
||||||
<SettingRow id="openapi"><a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a></SettingRow>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
|
||||||
import { allCommands, commandKeys, formatKeys, shortcutConflict, type Command } from './commands'
|
|
||||||
import { recordedKeys } from '../keys'
|
|
||||||
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
|
|
||||||
import { toast } from './workspace'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
|
|
||||||
const commands = computed(allCommands)
|
|
||||||
const query = ref('')
|
|
||||||
const filteredCommands = computed(() => {
|
|
||||||
const terms = query.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
|
||||||
return commands.value.filter(command => {
|
|
||||||
const text = [command.title, command.id, ...commandKeys(command).map(formatKeys)].join(' ').toLocaleLowerCase()
|
|
||||||
return terms.every(term => text.includes(term))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const recording = ref<string | null>(null)
|
|
||||||
const recorder = ref<HTMLInputElement>()
|
|
||||||
const error = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
watch(query, () => { recording.value = null; error.value = '' })
|
|
||||||
|
|
||||||
async function save(shortcuts: Record<string, string[]>) {
|
|
||||||
busy.value = true
|
|
||||||
try { await updateSettings({ shortcuts }) }
|
|
||||||
catch (cause) {
|
|
||||||
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return }
|
|
||||||
toast('保存快捷键失败:' + (cause instanceof Error ? cause.message : String(cause)))
|
|
||||||
await loadSettings().catch((cause) => toast('重新读取设置失败:' + String(cause)))
|
|
||||||
} finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function record(id: string) {
|
|
||||||
error.value = ''
|
|
||||||
recording.value = id
|
|
||||||
await nextTick()
|
|
||||||
recorder.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function capture(event: KeyboardEvent, command: Command) {
|
|
||||||
if (event.key === 'Escape') { recording.value = null; return }
|
|
||||||
const key = recordedKeys(event)
|
|
||||||
if (!key) return
|
|
||||||
const conflict = shortcutConflict(command.id, [key])
|
|
||||||
if (conflict) { error.value = `与“${conflict.title}”冲突,请换一个快捷键`; return }
|
|
||||||
const keys = [...new Set([...commandKeys(command), key])]
|
|
||||||
recording.value = null
|
|
||||||
await save({ ...settings.shortcuts, [command.id]: keys })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reset(command: Command) {
|
|
||||||
const conflict = shortcutConflict(command.id, command.keys ?? [])
|
|
||||||
if (conflict) { error.value = `默认快捷键与“${conflict.title}”冲突,请先调整该命令`; return }
|
|
||||||
const next = { ...settings.shortcuts }
|
|
||||||
delete next[command.id]
|
|
||||||
error.value = ''
|
|
||||||
await save(next)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-shortcut-settings">
|
|
||||||
<div class="rm-shortcut-toolbar"><input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button></div>
|
|
||||||
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。</p>
|
|
||||||
<p class="rm-share-note">浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。</p>
|
|
||||||
<p class="rm-settings-result-count" role="status">{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}</p>
|
|
||||||
<p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p>
|
|
||||||
<div v-for="command in filteredCommands" :key="command.id" class="rm-shortcut-row">
|
|
||||||
<span class="rm-shortcut-title">{{ command.title }}<small>{{ command.id }}</small></span>
|
|
||||||
<div class="rm-shortcut-bindings">
|
|
||||||
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
|
|
||||||
<kbd>{{ formatKeys(key) }}</kbd>
|
|
||||||
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button>
|
|
||||||
</span>
|
|
||||||
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly aria-label="按下快捷键" placeholder="按下快捷键…" @keydown.prevent.stop="capture($event, command)" @blur="recording = null" />
|
|
||||||
<button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">添加</button>
|
|
||||||
<button class="rm-btn" :disabled="busy" @click="reset(command)">重置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { api, type StorageUsage } from './api'
|
|
||||||
import MaintenancePanel from './MaintenancePanel.vue'
|
|
||||||
import SettingRow from './SettingRow.vue'
|
|
||||||
|
|
||||||
const usage = ref<StorageUsage | null>(null)
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
let mounted = true
|
|
||||||
onBeforeUnmount(() => { mounted = false })
|
|
||||||
|
|
||||||
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' }
|
|
||||||
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
|
|
||||||
const segments = computed(() => {
|
|
||||||
let offset = 0
|
|
||||||
return (usage.value?.categories ?? []).map((category, index) => {
|
|
||||||
const percent = usage.value!.totalSize ? category.size / usage.value!.totalSize * 100 : 0
|
|
||||||
const segment = { ...category, percent, offset, color: colors[index] }
|
|
||||||
offset += percent
|
|
||||||
return segment
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function size(value: number): string {
|
|
||||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
||||||
const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0
|
|
||||||
return `${(value / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
|
|
||||||
}
|
|
||||||
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
|
|
||||||
async function scan() {
|
|
||||||
if (busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const next = await api.storage()
|
|
||||||
if (mounted) usage.value = next
|
|
||||||
} catch (cause) {
|
|
||||||
if (mounted) error.value = '统计失败:' + String(cause)
|
|
||||||
} finally { if (mounted) busy.value = false }
|
|
||||||
}
|
|
||||||
onMounted(scan)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h3 class="rm-settings-group-title">空间占用</h3>
|
|
||||||
<SettingRow id="storage"><button class="rm-btn" :disabled="busy" @click="scan">{{ busy ? '统计中…' : '刷新' }}</button></SettingRow>
|
|
||||||
<p class="rm-share-note">统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。</p>
|
|
||||||
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
|
||||||
<div v-if="usage" class="rm-storage" :aria-busy="busy">
|
|
||||||
<div class="rm-storage-summary">
|
|
||||||
<svg viewBox="0 0 120 120" role="img" aria-label="各类存储空间占比">
|
|
||||||
<circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" />
|
|
||||||
<circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" />
|
|
||||||
</svg>
|
|
||||||
<div><span>总占用</span><strong :title="`${usage.totalSize} 字节`">{{ size(usage.totalSize) }}</strong><small>统计时间:{{ updated }}</small></div>
|
|
||||||
</div>
|
|
||||||
<dl class="rm-storage-list">
|
|
||||||
<template v-for="segment in segments" :key="segment.id">
|
|
||||||
<div><dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small></dd></div>
|
|
||||||
<template v-if="segment.id === 'fonts'">
|
|
||||||
<div class="rm-storage-sub"><dt>内置可选字体</dt><dd>{{ size(usage.builtinFontsSize) }}</dd></div>
|
|
||||||
<div class="rm-storage-sub"><dt>导入字体</dt><dd>{{ size(usage.customFontsSize) }}</dd></div>
|
|
||||||
</template>
|
|
||||||
<template v-if="segment.id === 'vault'">
|
|
||||||
<div class="rm-storage-sub"><dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
|
|
||||||
<div class="rm-storage-sub"><dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<MaintenancePanel @changed="scan" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rm-storage-summary { display: flex; align-items: center; gap: 20px; margin: 20px 0; }
|
|
||||||
.rm-storage-summary svg { width: 128px; height: 128px; flex-shrink: 0; }
|
|
||||||
.rm-storage-summary strong { display: block; font-size: var(--rm-ui-font-heading); margin: 6px 0; }
|
|
||||||
.rm-storage-summary small { display: block; color: var(--rm-text-muted); }
|
|
||||||
.rm-storage-list { margin: 0; }
|
|
||||||
.rm-storage-list > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
|
||||||
.rm-storage-list dt { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.rm-storage-list i { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
||||||
.rm-storage-list dd { margin: 0; text-align: right; }
|
|
||||||
.rm-storage-list small { display: inline-block; min-width: 52px; color: var(--rm-text-muted); }
|
|
||||||
.rm-storage-list .rm-storage-sub { padding-left: 18px; color: var(--rm-text-muted); }
|
|
||||||
@media (max-width: 480px) { .rm-storage-summary { gap: 12px; flex-wrap: wrap; } .rm-storage-summary svg { width: 100px; height: 100px; } }
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { api, type TrashItem } from './api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import SettingRow from './SettingRow.vue'
|
|
||||||
import { isImage } from './paths'
|
|
||||||
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
|
|
||||||
import { restore, toast } from './workspace'
|
|
||||||
|
|
||||||
const items = ref<TrashItem[] | null>(null)
|
|
||||||
const error = ref('')
|
|
||||||
const failures = ref<{ id: string; error: string }[]>([])
|
|
||||||
const busy = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
items.value = await api.trash()
|
|
||||||
error.value = ''
|
|
||||||
} catch (cause) {
|
|
||||||
error.value = '读取回收站失败:' + String(cause)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retention(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const days = input.valueAsNumber
|
|
||||||
if (!Number.isSafeInteger(days) || days < 0) {
|
|
||||||
input.value = String(settings.trashRetentionDays)
|
|
||||||
toast('请输入非负整数天数,0 表示永不自动删除')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (days === settings.trashRetentionDays) return
|
|
||||||
saving.value = true
|
|
||||||
try { await updateSettings({ trashRetentionDays: days }) }
|
|
||||||
catch (cause) {
|
|
||||||
toast('保存自动删除期限失败:' + String(cause))
|
|
||||||
if (!(cause instanceof SettingsAppearanceError)) await loadSettings().catch(cause => toast('重新读取设置失败:' + String(cause)))
|
|
||||||
} finally { saving.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doRestore(item: TrashItem) {
|
|
||||||
if (busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
try { await restore(item.id); await load() }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function purge(item?: TrashItem) {
|
|
||||||
if (busy.value) return
|
|
||||||
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
|
|
||||||
busy.value = true
|
|
||||||
failures.value = []
|
|
||||||
try {
|
|
||||||
if (item) await api.purge(item.id)
|
|
||||||
else {
|
|
||||||
const result = await api.clearTrash()
|
|
||||||
failures.value = result.failed
|
|
||||||
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
toast('删除失败:' + String(cause))
|
|
||||||
} finally {
|
|
||||||
await load()
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<SettingRow id="trash-retention" control="rm-trash-retention">
|
|
||||||
<label class="rm-font-input"><input id="rm-trash-retention" type="number" min="0" step="1" :placeholder="String(DEFAULTS.trashRetentionDays)" :value="settings.trashRetentionDays" :disabled="saving" @change="retention" /> 天</label>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow id="trash-clear"><button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">清空全部</button></SettingRow>
|
|
||||||
<p v-if="error" role="alert" class="rm-auth-error">{{ error }} <button class="rm-btn" :disabled="busy" @click="load">重试</button></p>
|
|
||||||
<ul v-if="failures.length" role="alert"><li v-for="failure in failures" :key="failure.id">{{ items?.find(item => item.id === failure.id)?.path || failure.id }}:{{ failure.error }}</li></ul>
|
|
||||||
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
|
|
||||||
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">读取中…</p>
|
|
||||||
<ul v-else class="rm-trash-list" :aria-busy="busy">
|
|
||||||
<li v-for="item in items ?? []" :key="item.id">
|
|
||||||
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
|
|
||||||
<div class="rm-trash-info">
|
|
||||||
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
|
||||||
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="rm-btn" :disabled="busy" @click="doRestore(item)">恢复</button>
|
|
||||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">彻底删除</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import type { Gemoji } from 'gemoji'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { iconPaths, type IconName } from './icons'
|
|
||||||
import type { TreeIcon } from './api'
|
|
||||||
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from './treeIconCatalog'
|
|
||||||
import { basename } from './paths'
|
|
||||||
import { treeIconTarget } from './tree'
|
|
||||||
import { saveTreeIcon } from './workspace'
|
|
||||||
|
|
||||||
const props = defineProps<{ target: { path: string; anchor: HTMLElement } }>()
|
|
||||||
const panel = ref<HTMLElement>()
|
|
||||||
const search = ref<HTMLInputElement>()
|
|
||||||
const position = ref({ left: 8, top: 8 })
|
|
||||||
const tab = ref<'emoji' | 'builtin'>('emoji')
|
|
||||||
const category = ref('Objects')
|
|
||||||
const query = ref('')
|
|
||||||
const emojis = ref<Gemoji[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const loadError = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
const recent = ref<TreeIcon[]>([])
|
|
||||||
let resizeObserver: ResizeObserver | undefined
|
|
||||||
const RECENT_KEY = 'rawmark.recent-tree-icons'
|
|
||||||
const iconNames = Object.keys(iconPaths) as IconName[]
|
|
||||||
const filtered = computed(() => {
|
|
||||||
const q = query.value.trim().toLowerCase()
|
|
||||||
return emojis.value.filter(e => q ? matchesEmoji(e, q) : e.category === category.value)
|
|
||||||
})
|
|
||||||
const linear = computed(() => {
|
|
||||||
const q = query.value.trim().toLowerCase()
|
|
||||||
return iconNames.filter(name => (name + ' ' + iconLabels[name]).toLowerCase().includes(q))
|
|
||||||
})
|
|
||||||
const visibleRecent = computed(() => recent.value.filter(icon => icon.kind === tab.value))
|
|
||||||
const chosen = (icon: TreeIcon) => `${icon.kind}:${icon.value}`
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (busy.value) return
|
|
||||||
treeIconTarget.value = null
|
|
||||||
props.target.anchor.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function place() {
|
|
||||||
if (!panel.value) return
|
|
||||||
const anchor = props.target.anchor.getBoundingClientRect()
|
|
||||||
const { width, height } = panel.value.getBoundingClientRect()
|
|
||||||
position.value = {
|
|
||||||
left: Math.max(8, Math.min(anchor.left, innerWidth - width - 8)),
|
|
||||||
top: Math.max(8, Math.min(anchor.bottom + 4, innerHeight - height - 8)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() }
|
|
||||||
if (event.key !== 'Tab' || !panel.value) return
|
|
||||||
const buttons = [...panel.value.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')]
|
|
||||||
const index = buttons.indexOf(document.activeElement as HTMLElement)
|
|
||||||
if (event.shiftKey && index <= 0) { event.preventDefault(); buttons.at(-1)?.focus() }
|
|
||||||
else if (!event.shiftKey && index === buttons.length - 1) { event.preventDefault(); buttons[0]?.focus() }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEmojis() {
|
|
||||||
loading.value = true
|
|
||||||
loadError.value = ''
|
|
||||||
try { emojis.value = (await import('gemoji')).gemoji }
|
|
||||||
catch (err) { loadError.value = '表情加载失败:' + (err instanceof Error ? err.message : String(err)) }
|
|
||||||
finally { loading.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(icon: TreeIcon | null) {
|
|
||||||
if (busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await saveTreeIcon(props.target.path, icon)
|
|
||||||
if (icon) {
|
|
||||||
recent.value = [icon, ...recent.value.filter(item => chosen(item) !== chosen(icon))].slice(0, 24)
|
|
||||||
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value)) }
|
|
||||||
catch (err) { console.warn('保存最近图标失败', err) }
|
|
||||||
}
|
|
||||||
busy.value = false
|
|
||||||
close()
|
|
||||||
} catch (err) {
|
|
||||||
error.value = '图标保存失败:' + (err instanceof Error ? err.message : String(err))
|
|
||||||
} finally { busy.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
try {
|
|
||||||
const data: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
|
|
||||||
if (Array.isArray(data)) recent.value = data.filter((item): item is TreeIcon => item && typeof item === 'object' && typeof item.value === 'string' && (item.kind === 'emoji' || item.kind === 'builtin' && iconNames.includes(item.value))).slice(0, 24)
|
|
||||||
} catch (err) { console.warn('读取最近图标失败', err) }
|
|
||||||
place()
|
|
||||||
search.value?.focus({ preventScroll: true })
|
|
||||||
resizeObserver = new ResizeObserver(place)
|
|
||||||
resizeObserver.observe(panel.value!)
|
|
||||||
window.addEventListener('resize', place)
|
|
||||||
document.addEventListener('keydown', onKeydown, true)
|
|
||||||
void loadEmojis()
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
resizeObserver?.disconnect()
|
|
||||||
window.removeEventListener('resize', place)
|
|
||||||
document.removeEventListener('keydown', onKeydown, true)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="rm-menu-backdrop rm-tree-icon-backdrop" @click="close" @contextmenu.prevent="close" />
|
|
||||||
<section ref="panel" class="rm-tree-icon-picker" role="dialog" aria-modal="true" aria-label="修改图标" :aria-busy="busy" :style="{ left: position.left + 'px', top: position.top + 'px' }">
|
|
||||||
<header><span class="rm-tree-icon-title" :title="target.path">{{ basename(target.path) }}</span><button class="rm-icon-btn" title="关闭图标选择" aria-label="关闭图标选择" :disabled="busy" @click="close"><Icon name="close" /></button></header>
|
|
||||||
<div class="rm-tree-icon-tabs" role="tablist" aria-label="图标类型">
|
|
||||||
<button role="tab" :aria-selected="tab === 'emoji'" :disabled="busy" @click="tab = 'emoji'">表情</button>
|
|
||||||
<button role="tab" :aria-selected="tab === 'builtin'" :disabled="busy" @click="tab = 'builtin'">线性图标</button>
|
|
||||||
</div>
|
|
||||||
<input ref="search" v-model="query" class="rm-input" placeholder="搜索图标(中文 / English)" aria-label="搜索图标" :disabled="busy" />
|
|
||||||
<nav v-if="tab === 'emoji'" class="rm-tree-icon-categories" aria-label="表情分类">
|
|
||||||
<button v-for="item in emojiCategories" :key="item.id" class="rm-emoji" :title="item.label" :aria-label="item.label" :aria-pressed="category === item.id && !query" :disabled="busy" @click="category = item.id; query = ''">{{ item.icon }}</button>
|
|
||||||
</nav>
|
|
||||||
<div class="rm-tree-icon-results">
|
|
||||||
<template v-if="!query && visibleRecent.length">
|
|
||||||
<p>最近使用</p>
|
|
||||||
<div class="rm-tree-icon-grid">
|
|
||||||
<button v-for="icon in visibleRecent" :key="chosen(icon)" :class="{ 'rm-emoji': icon.kind === 'emoji' }" :title="icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value" :aria-label="'最近:' + (icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value)" :disabled="busy" @click="save(icon)">
|
|
||||||
<Icon v-if="icon.kind === 'builtin'" :name="icon.value" :size="20" /><template v-else>{{ icon.value }}</template>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-if="tab === 'emoji'">
|
|
||||||
<p v-if="loading">正在加载表情…</p>
|
|
||||||
<div v-else-if="loadError" role="alert">{{ loadError }} <button class="rm-btn" @click="loadEmojis">重试</button></div>
|
|
||||||
<template v-else>
|
|
||||||
<p>{{ query ? '搜索结果' : emojiCategories.find(c => c.id === category)?.label }}</p>
|
|
||||||
<div class="rm-tree-icon-grid">
|
|
||||||
<button v-for="emoji in filtered" :key="emoji.emoji" class="rm-emoji" :title="emojiLabel(emoji)" :aria-label="emojiLabel(emoji)" :disabled="busy" @click="save({ kind: 'emoji', value: emoji.emoji })">{{ emoji.emoji }}</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="!filtered.length">没有找到图标,试试其他关键词</p>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p>线性图标</p>
|
|
||||||
<div class="rm-tree-icon-grid">
|
|
||||||
<button v-for="name in linear" :key="name" :title="iconLabels[name]" :aria-label="iconLabels[name]" :disabled="busy" @click="save({ kind: 'builtin', value: name })"><Icon :name="name" :size="20" /></button>
|
|
||||||
</div>
|
|
||||||
<p v-if="!linear.length">没有找到图标,试试其他关键词</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<p v-if="error" class="rm-tree-icon-error" role="alert">{{ error }}</p>
|
|
||||||
<footer><button class="rm-btn" :disabled="busy" @click="save(null)">{{ busy ? '正在保存…' : '恢复默认图标' }}</button></footer>
|
|
||||||
</section>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rm-tree-icon-backdrop { z-index: 46; }
|
|
||||||
.rm-tree-icon-picker { position: fixed; z-index: 47; box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rm-space-2); width: 360px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); padding: var(--rm-space-3); border: 1px solid var(--rm-divider); border-radius: 14px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); backdrop-filter: blur(24px); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-size); }
|
|
||||||
header { flex: none; display: flex; align-items: center; gap: var(--rm-space-2); }
|
|
||||||
.rm-tree-icon-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-tree-icon-tabs { display: flex; border-bottom: 1px solid var(--rm-divider); }
|
|
||||||
.rm-tree-icon-tabs button { flex: 1; min-height: var(--rm-control-size); border: 0; border-bottom: 2px solid transparent; background: none; color: var(--rm-text-muted); cursor: pointer; }
|
|
||||||
.rm-tree-icon-tabs [aria-selected="true"] { color: var(--rm-text); border-bottom-color: var(--rm-accent); }
|
|
||||||
.rm-tree-icon-categories { display: flex; flex-wrap: wrap; gap: 2px; }
|
|
||||||
.rm-tree-icon-categories button { flex: 1; min-width: 28px; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: none; font-size: 18px; cursor: pointer; }
|
|
||||||
.rm-tree-icon-results { flex: 1 1 auto; min-height: 0; max-height: 300px; overflow: auto; overscroll-behavior: contain; }
|
|
||||||
.rm-tree-icon-results p { margin: var(--rm-space-2) 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-tree-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 3px; }
|
|
||||||
.rm-tree-icon-grid button { display: flex; align-items: center; justify-content: center; min-width: 0; height: 36px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--rm-text); font-size: 22px; cursor: pointer; }
|
|
||||||
.rm-tree-icon-grid button:hover, .rm-tree-icon-categories button:hover, .rm-tree-icon-categories [aria-pressed="true"] { background: var(--rm-hover); }
|
|
||||||
.rm-tree-icon-error { margin: 0; max-height: 80px; overflow: auto; color: var(--rm-danger); overflow-wrap: anywhere; }
|
|
||||||
footer { flex: none; display: flex; justify-content: flex-end; }
|
|
||||||
@media (pointer: coarse) { .rm-tree-icon-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); } .rm-tree-icon-grid button, .rm-tree-icon-categories button, .rm-tree-icon-tabs button { min-height: 44px; } }
|
|
||||||
</style>
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { api, ApiError } from './api'
|
|
||||||
import { registerCommand } from './commands'
|
|
||||||
import EditorPane from './EditorPane.vue'
|
|
||||||
import KnowledgePanel from './KnowledgePanel.vue'
|
|
||||||
import EditorChrome from './EditorChrome.vue'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import SelectionToolbar from './SelectionToolbar.vue'
|
|
||||||
import MoveDialog from './MoveDialog.vue'
|
|
||||||
import ImportDialog from './ImportDialog.vue'
|
|
||||||
import ImageGallery from './ImageGallery.vue'
|
|
||||||
import ImageDialog from './ImageDialog.vue'
|
|
||||||
import NoteBanner from './NoteBanner.vue'
|
|
||||||
import OutlinePanel from './OutlinePanel.vue'
|
|
||||||
import QuickSwitcher from './QuickSwitcher.vue'
|
|
||||||
import SettingsDialog from './SettingsDialog.vue'
|
|
||||||
import { noteTitle } from './paths'
|
|
||||||
import { SIDEBAR_WIDTH, settings, SettingsAppearanceError, updateSettings } from './settings'
|
|
||||||
import Sidebar from './Sidebar.vue'
|
|
||||||
import TabBar from './TabBar.vue'
|
|
||||||
import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace'
|
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
|
||||||
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
|
|
||||||
const resizingSidebar = ref(false)
|
|
||||||
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
|
|
||||||
const initializing = ref(true)
|
|
||||||
|
|
||||||
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
|
|
||||||
function startResize(e: PointerEvent) {
|
|
||||||
const handle = e.currentTarget as HTMLElement
|
|
||||||
handle.setPointerCapture(e.pointerId)
|
|
||||||
resizingSidebar.value = true
|
|
||||||
const startX = e.clientX
|
|
||||||
const startWidth = settings.sidebarWidth
|
|
||||||
let width = startWidth
|
|
||||||
const onMove = (ev: PointerEvent) => {
|
|
||||||
width = Math.round(Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min, startWidth + ev.clientX - startX)))
|
|
||||||
document.documentElement.style.setProperty('--rm-sidebar-width', width + 'px')
|
|
||||||
}
|
|
||||||
const onUp = () => {
|
|
||||||
handle.removeEventListener('pointermove', onMove)
|
|
||||||
handle.removeEventListener('pointerup', onUp)
|
|
||||||
handle.removeEventListener('lostpointercapture', onUp)
|
|
||||||
resizingSidebar.value = false
|
|
||||||
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(error => toast(error instanceof SettingsAppearanceError ? error.message : '保存侧栏宽度失败'))
|
|
||||||
}
|
|
||||||
handle.addEventListener('pointermove', onMove)
|
|
||||||
handle.addEventListener('pointerup', onUp)
|
|
||||||
handle.addEventListener('lostpointercapture', onUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await flushAll()
|
|
||||||
if (!readyToReload()) {
|
|
||||||
toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api.logout()
|
|
||||||
} catch {
|
|
||||||
toast('退出失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stopWorkspace()
|
|
||||||
emit('logout')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移动端:从屏幕左边缘右滑打开侧栏
|
|
||||||
let touchX: number | null = null
|
|
||||||
// 侧栏打开时向左滑关闭(以水平方向为主,避免与列表上下滚动冲突)
|
|
||||||
let touchStart: { x: number; y: number } | null = null
|
|
||||||
function onTouchStart(e: TouchEvent) {
|
|
||||||
const t = e.touches[0]
|
|
||||||
touchX = t.clientX < 24 ? t.clientX : null
|
|
||||||
touchStart = { x: t.clientX, y: t.clientY }
|
|
||||||
}
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
|
||||||
const t = e.touches[0]
|
|
||||||
if (touchX !== null && t.clientX - touchX > 60) {
|
|
||||||
ws.sidebarOpen = true
|
|
||||||
touchX = null
|
|
||||||
}
|
|
||||||
if (ws.sidebarOpen && touchStart) {
|
|
||||||
const dx = t.clientX - touchStart.x
|
|
||||||
if (dx < -60 && Math.abs(t.clientY - touchStart.y) < Math.abs(dx) / 2) {
|
|
||||||
ws.sidebarOpen = false
|
|
||||||
touchStart = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try { await startWorkspace() }
|
|
||||||
catch (err) { toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误')) }
|
|
||||||
finally { initializing.value = false }
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
unregisterLogout()
|
|
||||||
stopWorkspace()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="rm-shell"
|
|
||||||
:style="initializing ? { opacity: 0, pointerEvents: 'none' } : undefined"
|
|
||||||
:inert="initializing"
|
|
||||||
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
|
|
||||||
@touchstart.passive="onTouchStart"
|
|
||||||
@touchmove.passive="onTouchMove"
|
|
||||||
>
|
|
||||||
<div class="rm-workspace">
|
|
||||||
<aside class="rm-sidebar">
|
|
||||||
<Sidebar @logout="logout" />
|
|
||||||
<div class="rm-sidebar-resize" title="拖动调整宽度" @pointerdown.prevent="startResize" />
|
|
||||||
</aside>
|
|
||||||
<div class="rm-backdrop" @click="ws.sidebarOpen = false" />
|
|
||||||
<main class="rm-main">
|
|
||||||
<header class="rm-mobile-bar">
|
|
||||||
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" /></button>
|
|
||||||
<button class="rm-mobile-title" title="切换笔记" @click="ws.switcher = ''">{{ title }}</button>
|
|
||||||
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" /></button>
|
|
||||||
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" /></button>
|
|
||||||
</header>
|
|
||||||
<div v-show="ws.view === 'notes' && ws.images === null" class="rm-notes-view">
|
|
||||||
<TabBar />
|
|
||||||
<EditorChrome />
|
|
||||||
<NoteBanner />
|
|
||||||
<EditorPane />
|
|
||||||
<SelectionToolbar />
|
|
||||||
</div>
|
|
||||||
<KnowledgePanel v-if="ws.view !== 'notes' && ws.images === null" />
|
|
||||||
<ImageGallery v-if="ws.images !== null" :dir="ws.images" />
|
|
||||||
</main>
|
|
||||||
<Transition name="rm-outline-panel">
|
|
||||||
<OutlinePanel v-if="ws.outlineOpen && !ws.focusMode && ws.active && ws.view === 'notes' && ws.images === null" />
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
|
||||||
<SettingsDialog v-if="ws.settings" />
|
|
||||||
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
|
|
||||||
<ImageDialog v-if="ws.image" :path="ws.image" />
|
|
||||||
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import type { IconName } from './icons'
|
|
||||||
|
|
||||||
// REST API 客户端,与服务端 internal/api 对应
|
|
||||||
|
|
||||||
export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName }
|
|
||||||
|
|
||||||
export interface Entry {
|
|
||||||
path: string
|
|
||||||
dir: boolean
|
|
||||||
size: number
|
|
||||||
mtime: number
|
|
||||||
icon?: TreeIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
paths: string[]
|
|
||||||
notes: number
|
|
||||||
assets: number
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteMetadata {
|
|
||||||
path: string
|
|
||||||
rev: string
|
|
||||||
title: string
|
|
||||||
tags: string[]
|
|
||||||
links: { target: string; line: number }[]
|
|
||||||
assets: { target: string; line: number }[]
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Theme {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomFont {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
format: 'ttf' | 'otf' | 'woff' | 'woff2'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstalledPlugin {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
apiVersion: 1
|
|
||||||
revision: string
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrashItem {
|
|
||||||
id: string
|
|
||||||
path: string
|
|
||||||
dir: boolean
|
|
||||||
deletedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageUsage {
|
|
||||||
totalSize: number
|
|
||||||
categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[]
|
|
||||||
builtinFontsSize: number
|
|
||||||
customFontsSize: number
|
|
||||||
assetsSize: number
|
|
||||||
trashSize: number
|
|
||||||
calculatedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MaintenanceStatus { busy: boolean; operation: string; error: string; finishedAt: number; backup?: string }
|
|
||||||
export interface BackupSummary { kind: 'full' | 'settings'; createdAt: number; entries: number; size: number }
|
|
||||||
export type BackupKind = 'full' | 'settings'
|
|
||||||
|
|
||||||
export interface TrashPurgeResult {
|
|
||||||
deleted: string[]
|
|
||||||
failed: { id: string; error: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
path: string
|
|
||||||
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
|
|
||||||
line: number
|
|
||||||
snippet: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiToken {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
scope: 'read' | 'write'
|
|
||||||
createdAt: number
|
|
||||||
// 0 为从未使用
|
|
||||||
lastUsedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly status: number,
|
|
||||||
message: string,
|
|
||||||
readonly data: Record<string, unknown> = {},
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 401 时通知应用回到登录页
|
|
||||||
let onUnauthorized: () => void = () => {}
|
|
||||||
export function setUnauthorizedHandler(fn: () => void) {
|
|
||||||
onUnauthorized = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodePath(path: string): string {
|
|
||||||
return path.split('/').map(encodeURIComponent).join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(
|
|
||||||
method: string,
|
|
||||||
url: string,
|
|
||||||
init: { json?: unknown; body?: BodyInit; headers?: Record<string, string>; keepalive?: boolean } = {},
|
|
||||||
) {
|
|
||||||
const headers: Record<string, string> = { ...init.headers }
|
|
||||||
let body = init.body
|
|
||||||
if (init.json !== undefined) {
|
|
||||||
headers['Content-Type'] = 'application/json'
|
|
||||||
body = JSON.stringify(init.json)
|
|
||||||
}
|
|
||||||
const res = await fetch(url, { method, headers, body, credentials: 'same-origin', cache: 'no-store', keepalive: init.keepalive })
|
|
||||||
if (!res.ok) {
|
|
||||||
let data: Record<string, unknown> = {}
|
|
||||||
try {
|
|
||||||
data = await res.json()
|
|
||||||
} catch {
|
|
||||||
// 非 JSON 错误体(如反向代理返回的页面)
|
|
||||||
}
|
|
||||||
if (res.status === 401 && !url.startsWith('/api/v1/auth/')) onUnauthorized()
|
|
||||||
throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `请求失败(${res.status})`, data)
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = async <T>(p: Promise<Response>): Promise<T> => (await p).json()
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')),
|
|
||||||
setup: (password: string) => request('POST', '/api/v1/auth/setup', { json: { password } }),
|
|
||||||
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
|
|
||||||
logout: () => request('POST', '/api/v1/auth/logout'),
|
|
||||||
|
|
||||||
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
|
|
||||||
|
|
||||||
maintenanceStatus: () => json<MaintenanceStatus>(request('GET', '/api/v1/maintenance/status')),
|
|
||||||
exportBackup: (kind: BackupKind) => request('POST', '/api/v1/maintenance/export/' + kind),
|
|
||||||
exportNotes: () => request('POST', '/api/v1/maintenance/notes/export'),
|
|
||||||
startBackupUpload: (kind: BackupKind, size: number) => json<{ id: string }>(request('POST', '/api/v1/maintenance/uploads', { json: { kind, size } })),
|
|
||||||
uploadBackupChunk: (id: string, offset: number, body: Blob) => json<{ offset: number }>(request('PUT', '/api/v1/maintenance/uploads/' + id, { body, headers: { 'Upload-Offset': String(offset) } })),
|
|
||||||
validateBackup: (id: string) => json<BackupSummary>(request('POST', '/api/v1/maintenance/uploads/' + id + '/validate')),
|
|
||||||
cancelBackupUpload: (id: string) => request('DELETE', '/api/v1/maintenance/uploads/' + id),
|
|
||||||
importSettings: (id: string) => request('POST', '/api/v1/maintenance/settings/import', { json: { id, confirmed: true } }),
|
|
||||||
restoreBackup: (id: string) => request('POST', '/api/v1/maintenance/restore', { json: { id, confirmed: true } }),
|
|
||||||
resetSettings: () => request('POST', '/api/v1/maintenance/settings/reset', { json: { confirmed: true } }),
|
|
||||||
maintainIndex: (operation: 'optimize' | 'rebuild') => request('POST', '/api/v1/maintenance/index/' + operation),
|
|
||||||
clearTemp: () => json<{ files: number; bytes: number; failed: string[] }>(request('POST', '/api/v1/maintenance/temp/clear')),
|
|
||||||
exportLogs: () => request('POST', '/api/v1/maintenance/logs/export'),
|
|
||||||
|
|
||||||
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
|
||||||
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
|
|
||||||
importFont: (file: File) => {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('file', file)
|
|
||||||
return json<CustomFont>(request('POST', '/api/v1/fonts', { body }))
|
|
||||||
},
|
|
||||||
deleteFont: (id: string) => request('DELETE', '/api/v1/fonts/' + encodeURIComponent(id)),
|
|
||||||
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
|
|
||||||
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
|
|
||||||
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
|
|
||||||
installPlugin: (file: File) => {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('file', file)
|
|
||||||
return json<InstalledPlugin>(request('POST', '/api/v1/plugins', { body }))
|
|
||||||
},
|
|
||||||
togglePlugin: (id: string, enabled: boolean) => json<InstalledPlugin>(request('PATCH', '/api/v1/plugins/' + encodeURIComponent(id), { json: { enabled } })),
|
|
||||||
uninstallPlugin: (id: string) => request('DELETE', '/api/v1/plugins/' + encodeURIComponent(id)),
|
|
||||||
pluginData: (id: string) => json<{ data: unknown }>(request('GET', '/api/v1/plugins/' + encodeURIComponent(id) + '/data')),
|
|
||||||
savePluginData: (id: string, data: unknown) => request('PUT', '/api/v1/plugins/' + encodeURIComponent(id) + '/data', { json: { data } }),
|
|
||||||
// 按顶层键合并,值为 null 的键被删除
|
|
||||||
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
|
||||||
|
|
||||||
importNotes: (dir: string, folder: string, files: File[]) => {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('dir', dir)
|
|
||||||
body.append('folder', folder)
|
|
||||||
for (const file of files) body.append('files', file)
|
|
||||||
return json<ImportResult>(request('POST', '/api/v1/import', { body }))
|
|
||||||
},
|
|
||||||
|
|
||||||
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
|
|
||||||
|
|
||||||
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
|
|
||||||
setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }),
|
|
||||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
|
||||||
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
|
||||||
|
|
||||||
async readFile(path: string): Promise<{ content: string; rev: string }> {
|
|
||||||
const res = await request('GET', '/api/v1/files/' + encodePath(path))
|
|
||||||
return { content: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
|
|
||||||
},
|
|
||||||
|
|
||||||
// ifMatch 为空字符串表示新建(文件必须不存在),undefined 表示无条件覆盖
|
|
||||||
// keepalive 让请求在页面关闭后仍能发出(浏览器限制请求体不超过 64 KB,超出时退回普通请求)
|
|
||||||
async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise<string> {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
|
|
||||||
if (ifMatch === '') headers['If-None-Match'] = '*'
|
|
||||||
else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"`
|
|
||||||
const res = request('PUT', '/api/v1/files/' + encodePath(path), {
|
|
||||||
body: content,
|
|
||||||
headers,
|
|
||||||
keepalive: keepalive && new Blob([content]).size < 60_000,
|
|
||||||
})
|
|
||||||
return (await json<{ rev: string }>(res)).rev
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))),
|
|
||||||
move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }),
|
|
||||||
mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }),
|
|
||||||
|
|
||||||
async upload(note: string, file: File): Promise<string> {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', file, file.name || 'image')
|
|
||||||
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
|
|
||||||
},
|
|
||||||
|
|
||||||
cleanupImages: (paths: string[]) => json<{ deleted: TrashItem[]; skipped: { path: string; reason: string }[] }>(request('POST', '/api/v1/assets/cleanup', { json: { paths } })),
|
|
||||||
uploadFolderImage: async (folder: string, file: File) => {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('file', file)
|
|
||||||
return json<{ link: string }>(request('POST', '/api/v1/assets?folder=' + encodeURIComponent(folder), { body }))
|
|
||||||
},
|
|
||||||
replaceImage: (path: string, file: File) => {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('file', file)
|
|
||||||
return request('PUT', '/api/v1/assets/' + encodePath(path), { body })
|
|
||||||
},
|
|
||||||
|
|
||||||
importImage: async (note: string, url: string): Promise<string> =>
|
|
||||||
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
|
|
||||||
|
|
||||||
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
|
|
||||||
// 返回的 token 明文只有这一次
|
|
||||||
createToken: (name: string, scope: ApiToken['scope']) =>
|
|
||||||
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
|
|
||||||
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
|
|
||||||
|
|
||||||
storage: () => json<StorageUsage>(request('GET', '/api/v1/storage')),
|
|
||||||
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
|
|
||||||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
|
||||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
|
||||||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
|
||||||
}
|
|
||||||
@@ -1,727 +0,0 @@
|
|||||||
/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
|
|
||||||
#app {
|
|
||||||
height: var(--rm-app-height, 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.rm-app-status { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 12px; padding-top: max(6px, env(safe-area-inset-top)); background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); flex: none; }
|
|
||||||
#app > .rm-shell, #app > .rm-splash, #app > .rm-auth { flex: 1; min-height: 0; height: auto; }
|
|
||||||
@media (pointer: coarse) { .rm-app-status .rm-btn { min-height: var(--rm-control-size); } }
|
|
||||||
body {
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
input, select, textarea { font-size: var(--rm-ui-font-size); }
|
|
||||||
input:not([type=checkbox],[type=radio],[type=range],[type=file]), select { box-sizing: border-box; min-height: var(--rm-control-size); }
|
|
||||||
button {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 通用控件 ---- */
|
|
||||||
.rm-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--rm-divider);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.rm-btn:hover { border-color: var(--rm-accent); color: var(--rm-accent-strong); }
|
|
||||||
.rm-btn:disabled { opacity: 0.6; cursor: default; }
|
|
||||||
.rm-btn-primary { background: var(--rm-accent); border-color: var(--rm-accent); color: #fff; }
|
|
||||||
.rm-btn-primary:hover { background: var(--rm-accent-strong); color: #fff; }
|
|
||||||
.rm-btn-danger:hover { border-color: var(--rm-danger); color: var(--rm-danger); }
|
|
||||||
.rm-icon-btn {
|
|
||||||
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;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.rm-icon-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
|
|
||||||
.rm-icon-btn[aria-pressed="true"] { background: var(--rm-active); color: var(--rm-accent-strong); }
|
|
||||||
|
|
||||||
.rm-splash,
|
|
||||||
.rm-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 登录 ---- */
|
|
||||||
.rm-auth { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 24px; box-sizing: border-box; }
|
|
||||||
.rm-auth-card { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
.rm-auth-card h1 { margin: 0 0 8px; font-size: var(--rm-ui-font-title); color: var(--rm-accent-strong); }
|
|
||||||
.rm-auth-hint { margin: 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
|
|
||||||
.rm-auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
|
|
||||||
.rm-auth-card input {
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--rm-divider);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
color: var(--rm-text);
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rm-auth-card input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
|
|
||||||
.rm-auth-card .rm-btn { height: var(--rm-control-size); justify-content: center; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: var(--rm-ui-font-small); }
|
|
||||||
|
|
||||||
/* ---- 布局 ---- */
|
|
||||||
.rm-shell { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.rm-workspace { display: flex; flex: 1; min-height: 0; }
|
|
||||||
.rm-sidebar {
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: var(--rm-sidebar-width);
|
|
||||||
flex: none;
|
|
||||||
overflow: clip;
|
|
||||||
background: color-mix(in srgb, var(--rm-sidebar-bg) 78%, transparent);
|
|
||||||
backdrop-filter: blur(18px) saturate(150%);
|
|
||||||
border-right: 1px solid var(--rm-divider);
|
|
||||||
transition: width var(--rm-panel-duration) var(--rm-panel-easing), border-width var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s;
|
|
||||||
}
|
|
||||||
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
||||||
.rm-sidebar-resize { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; }
|
|
||||||
.rm-sidebar-resize:hover,
|
|
||||||
.rm-sidebar-resize:active { background: var(--rm-accent); opacity: 0.4; }
|
|
||||||
.sidebar-collapsed .rm-sidebar,
|
|
||||||
.focus-mode .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); }
|
|
||||||
.sidebar-resizing .rm-sidebar { transition: none; }
|
|
||||||
|
|
||||||
/* ---- 大纲 ---- */
|
|
||||||
.rm-outline-backdrop { display: flex; flex: none; width: 256px; overflow: clip; }
|
|
||||||
.rm-outline { box-sizing: border-box; flex: none; width: 256px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); color: var(--rm-text-muted); }
|
|
||||||
.rm-outline-panel-enter-active,
|
|
||||||
.rm-outline-panel-leave-active { transition: width var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
|
|
||||||
.rm-outline-panel-enter-from,
|
|
||||||
.rm-outline-panel-leave-to { width: 0; opacity: 0; }
|
|
||||||
.rm-outline-panel-leave-active { pointer-events: none; }
|
|
||||||
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-1) var(--rm-space-3); font-size: var(--rm-ui-font-size); font-weight: 500; color: var(--rm-text-muted); }
|
|
||||||
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: var(--rm-space-1) var(--rm-space-2); }
|
|
||||||
.rm-outline-list li { box-sizing: border-box; height: var(--rm-control-compact); padding: 0 var(--rm-space-2); border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: var(--rm-control-compact); cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-outline-list li:hover { background: var(--rm-hover); }
|
|
||||||
.rm-outline-list li.on { color: var(--rm-text); background: var(--rm-active); font-weight: 500; }
|
|
||||||
.rm-backdrop,
|
|
||||||
.rm-mobile-bar { display: none; }
|
|
||||||
|
|
||||||
/* ---- 侧栏 ---- */
|
|
||||||
.rm-sidebar-inner { box-sizing: border-box; display: flex; flex-direction: column; width: calc(var(--rm-sidebar-width) - 1px); height: 100%; overflow-y: auto; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing); }
|
|
||||||
.sidebar-collapsed .rm-sidebar-inner,
|
|
||||||
.focus-mode .rm-sidebar-inner { opacity: 0; transform: translateX(-12px); }
|
|
||||||
.rm-brand { flex: 1; font-weight: 600; font-size: var(--rm-ui-font-size); color: var(--rm-accent-strong); }
|
|
||||||
.rm-tree { flex: 1; min-height: 100px; overflow: auto; padding: 0 6px 12px; }
|
|
||||||
.rm-tree.drop { background: var(--rm-active); }
|
|
||||||
.rm-tree-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.rm-tree-row {
|
|
||||||
--rm-tree-row-overlay: transparent;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
padding-right: 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.rm-tree-row:hover { --rm-tree-row-overlay: var(--rm-hover); background: var(--rm-hover); }
|
|
||||||
/* 长按由应用处理,不弹出 iOS 的系统菜单 */
|
|
||||||
.rm-tree-row { -webkit-touch-callout: none; }
|
|
||||||
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
|
|
||||||
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
|
|
||||||
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
|
|
||||||
.rm-tree-chevron.open { transform: rotate(90deg); }
|
|
||||||
.rm-tree-chevron.hidden { visibility: hidden; }
|
|
||||||
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-tree-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: var(--rm-control-compact);
|
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid var(--rm-accent);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
color: var(--rm-text);
|
|
||||||
font: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rm-tree-more { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); z-index: 1; width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; pointer-events: none; }
|
|
||||||
/* 按钮覆盖名称末尾,不为隐藏按钮留位;不透明底色遮住下方文字。 */
|
|
||||||
.rm-tree-row .rm-tree-more { background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg); }
|
|
||||||
.rm-tree-row:hover .rm-tree-more,
|
|
||||||
.rm-tree-row:focus-within .rm-tree-more,
|
|
||||||
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; pointer-events: auto; }
|
|
||||||
@media (pointer: coarse), (hover: none) {
|
|
||||||
.rm-tree-more { position: static; transform: none; opacity: 1; pointer-events: auto; width: max(44px, var(--rm-control-compact)); height: max(44px, var(--rm-control-size)); }
|
|
||||||
.rm-tree-row .rm-tree-more { background: none; }
|
|
||||||
}
|
|
||||||
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); text-align: center; }
|
|
||||||
/* ---- 搜索 ---- */
|
|
||||||
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
|
||||||
.rm-search-box {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 0 10px 8px;
|
|
||||||
padding: 0 4px 0 8px;
|
|
||||||
border: 1px solid var(--rm-divider);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
}
|
|
||||||
.rm-search-box:focus-within { border-color: var(--rm-accent); }
|
|
||||||
.rm-search-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--rm-text);
|
|
||||||
font: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rm-search-input::-webkit-search-cancel-button { display: none; }
|
|
||||||
.rm-search-results { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 12px; }
|
|
||||||
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
|
|
||||||
.rm-search-item.active { background: var(--rm-hover); }
|
|
||||||
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
|
|
||||||
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: inherit; overflow: hidden; white-space: nowrap; }
|
|
||||||
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-search-snippet {
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
font-size: var(--rm-ui-font-small);
|
|
||||||
line-height: 1.5;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.rm-search-hit { background: rgba(66, 184, 131, 0.28); color: var(--rm-text); border-radius: 2px; }
|
|
||||||
/* ---- 快速切换 ---- */
|
|
||||||
.rm-switcher-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: rgba(0, 0, 0, 0.18); }
|
|
||||||
.rm-switcher {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: min(560px, calc(100vw - 24px));
|
|
||||||
max-height: 60vh;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
box-shadow: var(--rm-shadow);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.rm-switcher-list { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 6px; }
|
|
||||||
.rm-switcher-item { display: flex; align-items: center; gap: 8px; height: var(--rm-control-size); padding: 0 10px; border-radius: 6px; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); cursor: pointer; }
|
|
||||||
.rm-switcher-item.active { background: var(--rm-active); }
|
|
||||||
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.rm-switcher-dir { flex: 1; min-width: 0; font-size: var(--rm-ui-font-small); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
|
||||||
.rm-switcher-keys span { padding: 1px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; font-family: inherit; }
|
|
||||||
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
|
|
||||||
.rm-foot-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.rm-foot-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
|
|
||||||
|
|
||||||
/* ---- 菜单 ---- */
|
|
||||||
.rm-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 4px;
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 150px;
|
|
||||||
padding: 4px;
|
|
||||||
border: 1px solid var(--rm-divider);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
box-shadow: var(--rm-shadow);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.rm-menu button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.rm-menu button:hover { background: var(--rm-hover); }
|
|
||||||
.rm-menu button.danger:hover { color: var(--rm-danger); background: rgba(229, 72, 77, 0.1); }
|
|
||||||
.rm-menu-backdrop { position: fixed; inset: 0; z-index: 19; cursor: default; }
|
|
||||||
|
|
||||||
/* ---- 标签页 ---- */
|
|
||||||
.rm-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
height: 38px;
|
|
||||||
padding: 4px 8px 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
border-bottom: 1px solid var(--rm-divider);
|
|
||||||
scrollbar-width: none;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.rm-tabs.empty { display: none; }
|
|
||||||
.rm-tabs-side,
|
|
||||||
.rm-tabs-actions { flex: none; align-self: center; margin-bottom: 4px; }
|
|
||||||
.rm-tabs-actions { display: flex; margin-left: auto; position: sticky; right: 0; background: var(--rm-bg); }
|
|
||||||
.rm-tabs-outline.on { color: var(--rm-accent-strong); }
|
|
||||||
.rm-tab.dragging { opacity: 0.5; }
|
|
||||||
.rm-tab.drop-before { box-shadow: inset 2px 0 0 var(--rm-accent); }
|
|
||||||
.rm-tab.drop-after { box-shadow: inset -2px 0 0 var(--rm-accent); }
|
|
||||||
.rm-tab-menu { position: fixed; z-index: 20; min-width: 160px; }
|
|
||||||
.rm-tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
max-width: 200px;
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
padding: 0 4px 0 12px;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.rm-tab:hover { background: var(--rm-hover); }
|
|
||||||
.rm-tab.active { background: var(--rm-active); color: var(--rm-text); }
|
|
||||||
.rm-tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-tab-pin { display: inline-flex; flex: none; color: var(--rm-accent-strong); }
|
|
||||||
.rm-tab-close {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: var(--rm-control-compact);
|
|
||||||
height: var(--rm-control-compact);
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--rm-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.rm-tab-close:hover { background: var(--rm-hover); }
|
|
||||||
.rm-tab-close svg { opacity: 0; }
|
|
||||||
.rm-tab:hover .rm-tab-close svg,
|
|
||||||
.rm-tab.active .rm-tab-close svg { opacity: 1; }
|
|
||||||
.rm-dirty-dot { display: none; position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--rm-accent); }
|
|
||||||
/* 有未保存修改时显示圆点,悬停时换回关闭图标 */
|
|
||||||
.rm-tab-close.dirty .rm-dirty-dot { display: block; }
|
|
||||||
.rm-tab-close.dirty svg { opacity: 0 !important; }
|
|
||||||
.rm-tab-close.dirty:hover .rm-dirty-dot { display: none; }
|
|
||||||
.rm-tab-close.dirty:hover svg { opacity: 1 !important; }
|
|
||||||
|
|
||||||
/* ---- 提示条 ---- */
|
|
||||||
.rm-banner {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: var(--rm-ui-font-small);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.rm-banner span { flex: 1; min-width: 200px; }
|
|
||||||
.rm-banner.warn,
|
|
||||||
.rm-banner.error { background: var(--rm-warn-bg); color: var(--rm-warn-text); }
|
|
||||||
|
|
||||||
/* ---- 编辑区 ---- */
|
|
||||||
.rm-editor-pane { flex: 1; min-height: 0; position: relative; }
|
|
||||||
.rm-editor-host { height: 100%; }
|
|
||||||
.rm-editor-host .cm-editor { height: 100%; }
|
|
||||||
|
|
||||||
/* ---- 对话框 ---- */
|
|
||||||
.rm-dialog-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); padding: 16px; }
|
|
||||||
.rm-dialog {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
box-shadow: var(--rm-shadow);
|
|
||||||
}
|
|
||||||
.rm-dialog-head { display: flex; align-items: center; padding: var(--rm-space-3) var(--rm-space-4) var(--rm-space-2); }
|
|
||||||
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; }
|
|
||||||
.rm-share-form input,
|
|
||||||
.rm-share-form select {
|
|
||||||
height: var(--rm-control-size);
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid var(--rm-divider);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--rm-bg);
|
|
||||||
color: var(--rm-text);
|
|
||||||
font-size: var(--rm-ui-font-size);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rm-share-form input { flex: 1; min-width: 0; }
|
|
||||||
.rm-share-form input:focus,
|
|
||||||
.rm-share-form select:focus { border-color: var(--rm-accent); }
|
|
||||||
.rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
|
|
||||||
.rm-share-note a { color: var(--rm-accent-strong); }
|
|
||||||
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-token-created p { margin: 0 0 6px; }
|
|
||||||
.rm-token-value { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); user-select: all; }
|
|
||||||
/* ---- 设置 ---- */
|
|
||||||
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; }
|
|
||||||
.rm-setting-label { font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-setting select { min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; line-height: 1.4; }
|
|
||||||
.rm-segmented { flex: none; display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 6px; overflow: hidden; }
|
|
||||||
.rm-segmented button { flex: 1 0 auto; white-space: nowrap; min-height: var(--rm-control-size); padding: 4px 10px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; cursor: pointer; }
|
|
||||||
.rm-segmented button:first-child { border-left: 0; }
|
|
||||||
.rm-segmented button.on { background: var(--rm-active); color: var(--rm-accent-strong); }
|
|
||||||
.flip { transform: rotate(180deg); }
|
|
||||||
|
|
||||||
.rm-dialog-backdrop.rm-settings-backdrop { padding: 24px; background: color-mix(in srgb, var(--rm-bg) 30%, transparent); backdrop-filter: blur(4px); }
|
|
||||||
.rm-dialog.rm-settings { box-sizing: border-box; flex-direction: row; width: 100%; max-width: 960px; height: min(720px, 100%); max-height: 100%; overflow: hidden; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(20px); outline: none; }
|
|
||||||
.rm-settings-nav { box-sizing: border-box; flex: none; width: 192px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 70%, transparent); }
|
|
||||||
.rm-settings .rm-dialog-head { flex: none; box-sizing: border-box; min-height: 52px; gap: 8px; padding: 8px 16px; }
|
|
||||||
.rm-settings .rm-dialog-head h2 { font-size: var(--rm-ui-font-size); font-weight: 600; min-width: 0; outline: none; }
|
|
||||||
.rm-settings-close-list, .rm-settings-back { display: none; }
|
|
||||||
.rm-settings-search { display: flex; align-items: center; gap: 4px; min-width: 0; min-height: var(--rm-control-size); margin: 0 8px 8px; padding: 0 6px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); }
|
|
||||||
.rm-settings-search > .rm-icon { flex: none; width: 16px; height: 16px; color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-search input { box-sizing: border-box; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 4px 0; border: 0; outline: 0; background: none; color: var(--rm-text); font: inherit; }
|
|
||||||
.rm-settings-search input::-webkit-search-cancel-button { display: none; }
|
|
||||||
.rm-settings-search:focus-within { border-color: var(--rm-accent); }
|
|
||||||
.rm-settings-categories { min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
|
|
||||||
.rm-settings-tab { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; min-height: var(--rm-control-size); margin: 2px 0; padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); font: inherit; text-align: left; cursor: pointer; }
|
|
||||||
.rm-settings-tab .rm-icon { flex: none; }
|
|
||||||
.rm-settings-tab:hover { background: var(--rm-hover); }
|
|
||||||
.rm-settings-tab.on { background: var(--rm-active); color: var(--rm-accent-strong); }
|
|
||||||
.rm-settings-nav-foot { margin-top: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-nav-foot small { font-size: inherit; overflow-wrap: anywhere; }
|
|
||||||
.rm-settings-body { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
||||||
.rm-settings-body > .rm-dialog-head { border-bottom: 1px solid var(--rm-divider); }
|
|
||||||
.rm-settings-save { flex: none; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-save.error { color: var(--rm-danger); }
|
|
||||||
.rm-settings-content { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; padding: 0 24px 16px; }
|
|
||||||
.rm-settings-page { min-width: 0; }
|
|
||||||
.rm-settings .rm-setting { flex-wrap: wrap; gap: 12px 16px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-setting-info { flex: 1 1 180px; min-width: 0; }
|
|
||||||
.rm-setting-info p { margin: 4px 0 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: var(--rm-ui-line-height); overflow-wrap: anywhere; }
|
|
||||||
.rm-setting-control { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; max-width: 100%; min-width: 0; margin-left: auto; }
|
|
||||||
.rm-settings .rm-setting input:not([type=checkbox],[type=range]), .rm-settings .rm-setting select { box-sizing: border-box; max-width: 260px; min-width: 0; min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
|
||||||
.rm-settings .rm-setting-control > input:not([type=checkbox],[type=range]) { width: 240px; max-width: 100%; }
|
|
||||||
.rm-settings .rm-setting select { max-width: min(260px, 100%); }
|
|
||||||
.rm-settings .rm-setting input[type=range] { width: 120px; min-width: 0; max-width: 100%; margin: 0; accent-color: var(--rm-accent); }
|
|
||||||
.rm-settings input[type=checkbox] { accent-color: var(--rm-accent); }
|
|
||||||
.rm-setting-control output { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.rm-setting-control code { overflow-wrap: anywhere; }
|
|
||||||
.rm-setting-reset { flex: none; color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-group-title { margin: 20px 0 0; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-group-title:first-child { margin-top: 16px; }
|
|
||||||
.rm-settings-details { margin: 12px 0; }
|
|
||||||
.rm-settings-details > summary { padding: 8px 0; min-height: var(--rm-control-size); box-sizing: border-box; cursor: pointer; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
|
|
||||||
.rm-settings :is(.rm-setting, .rm-settings-details, .rm-settings-form):focus { outline: 2px solid var(--rm-accent); outline-offset: -2px; border-radius: 6px; }
|
|
||||||
.rm-typography-preview { margin: 8px 0 0; padding: 16px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-sidebar-bg); font: var(--rm-font-size)/var(--rm-line-height) var(--rm-font-editor, var(--rm-font-text)); overflow-wrap: anywhere; }
|
|
||||||
.rm-typography-preview h2 { margin-top: 0; font-family: var(--rm-font-heading); font-size: 1.5em; }
|
|
||||||
.rm-typography-preview p { margin: 0; }
|
|
||||||
.rm-typography-preview p + p { margin-top: 1lh; }
|
|
||||||
.rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .9em; color: var(--rm-inline-code-text); }
|
|
||||||
.rm-settings .rm-share-note, .rm-settings .rm-appearance-note { margin: 12px 0; overflow-wrap: anywhere; }
|
|
||||||
.rm-settings .rm-share-form { flex-wrap: wrap; padding: 16px 0 0; }
|
|
||||||
.rm-settings .rm-share-form input { flex-basis: 180px; }
|
|
||||||
.rm-settings .rm-trash-list { padding: 0; }
|
|
||||||
.rm-settings .rm-trash-list li { flex-wrap: wrap; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); border-radius: 0; }
|
|
||||||
.rm-settings .rm-trash-info { flex-basis: 180px; }
|
|
||||||
.rm-settings .rm-token-created { margin: 12px 0; }
|
|
||||||
.rm-settings :is(.rm-snippet-row, .rm-plugin-row) { padding: 12px 0; flex-wrap: wrap; }
|
|
||||||
.rm-settings .rm-snippet-form { padding: 12px 0; }
|
|
||||||
.rm-settings .rm-plugin-view { padding: 12px 0; }
|
|
||||||
.rm-settings .rm-settings-form { box-sizing: border-box; max-width: 400px; padding: 16px 0; border: 0; border-radius: 0; background: none; box-shadow: none; }
|
|
||||||
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-settings-form .rm-share-note { margin: 0; }
|
|
||||||
.rm-settings-unavailable { max-width: 240px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-result-count { margin: 12px 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
|
|
||||||
.rm-settings-result { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; text-align: left; cursor: pointer; }
|
|
||||||
.rm-settings-result:hover { background: var(--rm-hover); }
|
|
||||||
.rm-settings-result > span { flex: 1; min-width: 0; }
|
|
||||||
.rm-settings-result strong { font-weight: 500; }
|
|
||||||
.rm-settings-result small { display: block; margin-top: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
|
|
||||||
.rm-settings-result > .rm-icon { flex: none; color: var(--rm-text-muted); }
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.rm-dialog-backdrop.rm-settings-backdrop { padding: 0; }
|
|
||||||
.rm-dialog.rm-settings { height: 100%; max-width: none; border: 0; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--rm-bg); }
|
|
||||||
.rm-settings-nav { display: none; width: 100%; border-right: 0; }
|
|
||||||
.rm-settings.show-list .rm-settings-nav { display: flex; }
|
|
||||||
.rm-settings.show-list .rm-settings-body { display: none; }
|
|
||||||
.rm-settings.show-list.searching { flex-direction: column; }
|
|
||||||
.rm-settings.show-list.searching .rm-settings-body { display: flex; }
|
|
||||||
.rm-settings.show-list.searching :is(.rm-settings-categories, .rm-settings-nav-foot, .rm-settings-body > .rm-dialog-head) { display: none; }
|
|
||||||
.rm-settings.show-list:not(.searching) .rm-settings-nav { flex: 1; }
|
|
||||||
.rm-settings .rm-dialog-head { padding: 8px 12px; }
|
|
||||||
.rm-settings-close-list, .rm-settings-back { display: inline-flex; }
|
|
||||||
.rm-settings-content { padding: 0 16px 16px; }
|
|
||||||
.rm-settings-search { min-height: 44px; }
|
|
||||||
.rm-settings-tab { min-height: 44px; }
|
|
||||||
.rm-settings-save { max-width: 6em; }
|
|
||||||
.rm-settings .rm-setting-info { flex-basis: 100%; }
|
|
||||||
.rm-settings .rm-setting-control { width: 100%; max-width: 100%; }
|
|
||||||
.rm-setting-control > select { flex: 1 1 0; }
|
|
||||||
.rm-settings input:not([type=checkbox],[type=radio],[type=range]), .rm-settings select, .rm-settings textarea { font-size: max(16px, var(--rm-ui-font-size)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
|
|
||||||
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
|
|
||||||
.rm-trash-list li:hover { background: var(--rm-hover); }
|
|
||||||
.rm-trash-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
||||||
.rm-trash-path { color: var(--rm-text); font-size: var(--rm-ui-font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-trash-time { font-size: var(--rm-ui-font-small); }
|
|
||||||
|
|
||||||
/* ---- 通知 ---- */
|
|
||||||
.rm-toasts { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
|
|
||||||
.rm-toast {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #1f2328;
|
|
||||||
color: #fff;
|
|
||||||
font-size: var(--rm-ui-font-small);
|
|
||||||
box-shadow: var(--rm-shadow);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.rm-toast-action { border: 0; background: none; color: #42d392; font-weight: 600; cursor: pointer; padding: 0; }
|
|
||||||
|
|
||||||
/* ---- 移动端 ---- */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.rm-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
z-index: 40;
|
|
||||||
width: min(84vw, 320px);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s var(--rm-panel-duration);
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
}
|
|
||||||
.sidebar-collapsed .rm-sidebar,
|
|
||||||
.focus-mode .rm-sidebar { width: min(84vw, 320px); border-right-width: 1px; transition-delay: 0s, var(--rm-panel-duration); }
|
|
||||||
.rm-sidebar-inner { width: 100%; }
|
|
||||||
.sidebar-collapsed .rm-sidebar-inner { opacity: 1; transform: none; }
|
|
||||||
.sidebar-open:not(.focus-mode) .rm-sidebar { transform: none; visibility: visible; pointer-events: auto; transition-delay: 0s; box-shadow: var(--rm-shadow); }
|
|
||||||
.rm-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.3); opacity: 0; pointer-events: none; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing); display: block; }
|
|
||||||
.sidebar-open:not(.focus-mode) .rm-backdrop { opacity: 1; pointer-events: auto; }
|
|
||||||
.rm-tabs { display: none; }
|
|
||||||
.rm-mobile-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 48px;
|
|
||||||
padding: env(safe-area-inset-top) 8px 0;
|
|
||||||
border-bottom: 1px solid var(--rm-divider);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.rm-mobile-bar .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
|
|
||||||
.rm-mobile-title { flex: 1; min-width: 0; height: var(--rm-control-size); padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rm-sidebar-resize { display: none; }
|
|
||||||
/* 大纲在手机上为从右侧滑入的浮层 */
|
|
||||||
.rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(0, 0, 0, 0.3); }
|
|
||||||
.rm-outline { position: absolute; top: 0; right: 0; bottom: 0; width: min(80vw, 300px); padding-top: env(safe-area-inset-top); box-shadow: var(--rm-shadow); }
|
|
||||||
/* 触控热区不小于 44px */
|
|
||||||
.rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
|
|
||||||
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
|
|
||||||
.rm-search-input { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-auth-card input,
|
|
||||||
.rm-share-form input,
|
|
||||||
.rm-share-form select,
|
|
||||||
.rm-setting select { font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-share-form { flex-wrap: wrap; }
|
|
||||||
.rm-search-item { padding: 10px 8px; }
|
|
||||||
/* 手机上从顶部弹出,给软键盘留出空间 */
|
|
||||||
.rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
|
|
||||||
.rm-switcher { max-height: 50vh; }
|
|
||||||
.rm-switcher-item { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-trash-list li { flex-wrap: wrap; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主题与 CSS 编辑 */
|
|
||||||
.rm-appearance-note { margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4); font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
|
|
||||||
.rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; }
|
|
||||||
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-snippet-row input { accent-color: var(--rm-accent); flex: none; }
|
|
||||||
.rm-snippet-form { display: flex; flex-direction: column; gap: 12px; padding: 12px 20px; }
|
|
||||||
.rm-snippet-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
|
||||||
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
|
|
||||||
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: var(--rm-control-size); } .rm-snippet-form input, .rm-snippet-form textarea { font-size: var(--rm-ui-font-size); } }
|
|
||||||
|
|
||||||
.rm-plugin-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; border-bottom: 1px solid var(--rm-divider); }
|
|
||||||
.rm-plugin-row label { display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px; overflow-wrap: anywhere; }
|
|
||||||
.rm-plugin-row small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-plugin-row p { width: 100%; font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-plugin-row input { accent-color: var(--rm-accent); flex: none; }
|
|
||||||
.rm-plugin-view { padding: 16px; min-width: 0; overflow: auto; overflow-wrap: anywhere; }
|
|
||||||
.rm-sidebar-inner > .rm-plugin-view { flex: 1; }
|
|
||||||
.rm-plugin-tabs { display: flex; flex: none; gap: 4px; padding: 8px; overflow-x: auto; }
|
|
||||||
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
|
|
||||||
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } }
|
|
||||||
|
|
||||||
.rm-font-input { display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.rm-font-input input { min-height: var(--rm-control-size); box-sizing: border-box; font: inherit; width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
|
|
||||||
.rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); }
|
|
||||||
|
|
||||||
.rm-shortcut-settings { padding: 16px 0 0; }
|
|
||||||
.rm-shortcut-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
||||||
.rm-shortcut-toolbar input { box-sizing: border-box; flex: 1 1 180px; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 6px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; color: var(--rm-text); background: var(--rm-bg); }
|
|
||||||
.rm-shortcut-title { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
|
|
||||||
.rm-shortcut-title small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); margin-top: 4px; }
|
|
||||||
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: var(--rm-ui-font-size); }
|
|
||||||
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; max-width: 100%; min-width: 0; margin-left: auto; }
|
|
||||||
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
|
|
||||||
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
|
|
||||||
|
|
||||||
.rm-context-menu { position: fixed; right: auto; z-index: 45; width: max-content; min-width: 0; max-width: calc(100vw - 8px); max-height: calc(100dvh - 8px); box-sizing: border-box; overflow: auto; }
|
|
||||||
.rm-context-menu button { flex: none; white-space: nowrap; }
|
|
||||||
.rm-context-menu-backdrop { z-index: 44; }
|
|
||||||
|
|
||||||
.rm-tree-row.selected { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); }
|
|
||||||
|
|
||||||
/* 轻玻璃工作台 */
|
|
||||||
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
|
|
||||||
.rm-tree-label { margin-left: auto; flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: right; cursor: pointer; }
|
|
||||||
.rm-tree-label:hover { background: var(--rm-hover); }
|
|
||||||
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
|
|
||||||
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
|
|
||||||
.rm-sidebar-foot { border: 0; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-2); }
|
|
||||||
.rm-foot-btn { height: var(--rm-control-size); font-size: inherit; }
|
|
||||||
.rm-tabs { height: auto; min-height: 31px; padding: 2px 6px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
|
|
||||||
.rm-tab { box-sizing: border-box; min-width: 84px; height: max(31px, calc(var(--rm-ui-font-size) + 15px)); max-width: 196px; padding: 0 3px 0 8px; font-size: var(--rm-ui-font-size); font-weight: 500; border: 1px solid transparent; border-radius: 7px 7px 0 0; }
|
|
||||||
.rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); }
|
|
||||||
.rm-tab-add { margin: 0 3px; align-self: center; }
|
|
||||||
.rm-tab-close { width: max(24px, calc(var(--rm-ui-font-size) + 10px)); height: max(24px, calc(var(--rm-ui-font-size) + 10px)); }
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
.rm-tab { height: max(48px, calc(var(--rm-ui-font-size) + 26px)); }
|
|
||||||
.rm-tab-close { width: var(--rm-control-compact); height: var(--rm-control-compact); }
|
|
||||||
}
|
|
||||||
.rm-editor-chrome { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 2px; padding: 2px 6px; border-bottom: 1px solid var(--rm-divider); }
|
|
||||||
.rm-editor-chrome .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-compact); }
|
|
||||||
.rm-btn { border-radius: 8px; background: color-mix(in srgb, var(--rm-bg) 80%, transparent); }
|
|
||||||
.rm-btn-primary { background: var(--rm-accent); box-shadow: 0 2px 5px var(--rm-selection); }
|
|
||||||
.rm-auth-card { box-sizing: border-box; gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); }
|
|
||||||
.rm-dialog-backdrop { background: rgba(19,27,46,.15); backdrop-filter: blur(8px); }
|
|
||||||
.rm-dialog { border-radius: 20px; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) 96%, transparent); }
|
|
||||||
.rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); border-radius: 14px; padding: 7px; }
|
|
||||||
.rm-menu button { min-height: var(--rm-control-size); }
|
|
||||||
@media (min-width: 768px) and (max-width: 1199px) {
|
|
||||||
.rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(19,27,46,.12); }
|
|
||||||
.rm-outline { position: absolute; right: 0; top: 0; bottom: 0; box-shadow: var(--rm-shadow); }
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
|
|
||||||
.rm-outline { width: min(84vw, 320px); }
|
|
||||||
.rm-auth-card { padding: 28px 24px; }
|
|
||||||
}
|
|
||||||
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
|
|
||||||
/* 选区工具栏与局部外观面板 */
|
|
||||||
.rm-selection-popover { position: fixed; display: flex; flex-direction: column; box-sizing: border-box; max-width: calc(100vw - 16px); padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); color: var(--rm-text); font: var(--rm-ui-font-size)/var(--rm-ui-line-height) var(--rm-font-text); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
|
|
||||||
.rm-selection-toolbar { display: flex; flex: none; align-items: center; max-width: 100%; overflow-x: auto; }
|
|
||||||
.rm-selection-toolbar > button { display: inline-flex; align-items: center; justify-content: center; width: var(--rm-control-size); height: var(--rm-control-size); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; color: inherit; cursor: pointer; font: inherit; touch-action: manipulation; }
|
|
||||||
.rm-selection-toolbar > button:hover, .rm-selection-toolbar > button[aria-pressed=true], .rm-selection-toolbar > button[aria-expanded=true] { background: var(--rm-selection); color: var(--rm-accent-strong); }
|
|
||||||
.rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; } .rm-selection-toolbar .underline { text-decoration: underline; } .rm-selection-toolbar .highlight { text-decoration: underline; text-decoration-color: var(--rm-accent); text-decoration-thickness: 3px; }
|
|
||||||
.rm-inline-appearance { box-sizing: border-box; width: 328px; max-width: 100%; min-height: 0; padding: 4px 8px 8px; overflow-y: auto; overscroll-behavior: contain; border-top: 1px solid var(--rm-divider); }
|
|
||||||
.rm-inline-appearance fieldset { min-width: 0; margin: 8px 0; padding: 0 0 10px; border: 0; border-bottom: 1px solid var(--rm-divider); }
|
|
||||||
.rm-inline-appearance legend { padding: 0 0 6px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-inline-swatches { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
|
|
||||||
.rm-inline-swatches button, .rm-inline-effects button { box-sizing: border-box; min-height: 28px; padding: 2px; border: 1px solid var(--rm-divider); border-radius: 5px; background: var(--rm-bg); color: var(--rm-text); font: inherit; cursor: pointer; }
|
|
||||||
.rm-inline-swatches button:hover, .rm-inline-effects button:hover, .rm-inline-appearance button[aria-pressed=true] { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
|
|
||||||
.rm-inline-custom { grid-column: span 3; display: flex; align-items: center; justify-content: space-between; gap: 4px; font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-inline-custom input { box-sizing: border-box; width: 32px; height: 28px; padding: 2px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-bg); cursor: pointer; }
|
|
||||||
.rm-inline-effects { display: flex; gap: 8px; } .rm-inline-effects button { width: 36px; font-size: 20px; }
|
|
||||||
.rm-inline-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0; font-size: var(--rm-ui-font-small); }
|
|
||||||
.rm-inline-control select { width: min(190px, 100%); min-width: 0; }
|
|
||||||
.rm-inline-control select, .rm-inline-size input[type=number] { box-sizing: border-box; min-height: var(--rm-control-compact); padding: 2px 4px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-bg); color: inherit; font: inherit; }
|
|
||||||
.rm-inline-size { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .rm-inline-size input[type=range] { min-width: 0; width: 100%; flex: 1; accent-color: var(--rm-accent); } .rm-inline-size input[type=number] { width: 64px; } .rm-inline-size > span { min-width: 18px; }
|
|
||||||
.rm-inline-clear { width: 100%; }
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
.rm-selection-toolbar > button { width: 44px; height: 44px; }
|
|
||||||
.rm-inline-swatches { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
||||||
.rm-inline-effects button, .rm-inline-custom input { min-width: 44px; }
|
|
||||||
.rm-inline-swatches button, .rm-inline-effects button, .rm-inline-appearance select, .rm-inline-appearance input, .rm-inline-appearance .rm-btn { min-height: 44px; }
|
|
||||||
.rm-inline-appearance select, .rm-inline-size input[type=number] { font-size: max(16px, var(--rm-ui-font-size)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.rm-shell { position: relative; }
|
|
||||||
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); } }
|
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
|
||||||
.rm-outline-panel-enter-active,
|
|
||||||
.rm-outline-panel-leave-active { transition-property: opacity; }
|
|
||||||
.rm-outline { transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
|
|
||||||
.rm-outline-panel-enter-from .rm-outline,
|
|
||||||
.rm-outline-panel-leave-to .rm-outline { transform: translateX(100%); }
|
|
||||||
.rm-outline-panel-enter-from,
|
|
||||||
.rm-outline-panel-leave-to { width: auto; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
:root { --rm-panel-duration: 0ms; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px), (pointer: coarse) {
|
|
||||||
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */
|
|
||||||
.rm-tree-children { display: grid; grid-template-rows: 1fr; }
|
|
||||||
.rm-tree-children > .rm-tree-list { min-height: 0; overflow: hidden; }
|
|
||||||
.rm-tree-expand-enter-active, .rm-tree-expand-leave-active { transition: grid-template-rows var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
|
|
||||||
.rm-tree-expand-enter-from, .rm-tree-expand-leave-to { grid-template-rows: 0fr; opacity: 0; }
|
|
||||||
.rm-tree-expand-leave-active { pointer-events: none; }
|
|
||||||
.rm-emoji { font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; font-weight: normal; font-style: normal; }
|
|
||||||
.rm-tree-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }
|
|
||||||
.rm-tree-icon:hover { background: var(--rm-hover); }
|
|
||||||
@media (pointer: coarse) { .rm-tree-icon { width: 28px; height: 44px; } }
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { api } from './api'
|
|
||||||
import { basename, joinPath } from './paths'
|
|
||||||
import { minimalPaths } from './selection'
|
|
||||||
import { flush, move, refreshTree, remove, toast, ws } from './workspace'
|
|
||||||
|
|
||||||
export async function batchFiles(action: 'move' | 'delete', paths: string[], destination = '') {
|
|
||||||
const subjects = minimalPaths(paths)
|
|
||||||
if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
|
|
||||||
const failures: string[] = []
|
|
||||||
let succeeded = 0
|
|
||||||
for (const path of subjects) {
|
|
||||||
try {
|
|
||||||
const result = action === 'move' ? await move(path, joinPath(destination, basename(path))) : await remove(path)
|
|
||||||
if (result.ok) succeeded++
|
|
||||||
else failures.push(`${path}:${result.error}`)
|
|
||||||
} catch (cause) { failures.push(`${path}:${cause instanceof Error ? cause.message : String(cause)}`) }
|
|
||||||
}
|
|
||||||
await refreshTree().catch((cause) => toast('刷新目录失败:' + String(cause)))
|
|
||||||
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? ';' + failures.join(';') : ''}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportFiles(paths: string[]) {
|
|
||||||
const subjects = minimalPaths(paths)
|
|
||||||
const tabs = ws.tabs.filter((tab) => subjects.some((path) => tab.path === path || tab.path.startsWith(path + '/')))
|
|
||||||
try {
|
|
||||||
await Promise.all(tabs.map((tab) => flush(tab.path)))
|
|
||||||
if (tabs.some((tab) => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
|
|
||||||
const response = await api.exportFiles(subjects)
|
|
||||||
const blob = await response.blob()
|
|
||||||
const disposition = response.headers.get('Content-Disposition') ?? ''
|
|
||||||
const encoded = /filename\*=utf-8''([^;]+)/i.exec(disposition)?.[1]
|
|
||||||
const plain = /filename="([^"]+)"|filename=([^;]+)/i.exec(disposition)
|
|
||||||
const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? '导出.zip'
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = Object.assign(document.createElement('a'), { href: url, download: filename })
|
|
||||||
document.body.append(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
||||||
} catch (cause) { toast('导出失败:' + (cause instanceof Error ? cause.message : String(cause))) }
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步
|
|
||||||
|
|
||||||
export interface VaultEvent {
|
|
||||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
|
|
||||||
path?: string
|
|
||||||
from?: string
|
|
||||||
rev?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void; onResync: () => void }): () => void {
|
|
||||||
let ws: WebSocket | undefined
|
|
||||||
let delay = 1000
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let stopped = false
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
ws = new WebSocket(`${proto}//${location.host}/api/v1/events`)
|
|
||||||
ws.onopen = () => {
|
|
||||||
delay = 1000
|
|
||||||
// 初始化读取到首次订阅之间也存在空窗,每次建立连接都补同步。
|
|
||||||
handlers.onResync()
|
|
||||||
}
|
|
||||||
ws.onmessage = (e) => handlers.onEvents(JSON.parse(e.data))
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (stopped) return
|
|
||||||
timer = setTimeout(connect, delay)
|
|
||||||
delay = Math.min(delay * 2, 30000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connect()
|
|
||||||
// 页面从后台恢复时立即重连,不等退避计时
|
|
||||||
const onVisible = () => {
|
|
||||||
if (document.visibilityState === 'visible' && ws?.readyState === WebSocket.CLOSED) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
connect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', onVisible)
|
|
||||||
return () => {
|
|
||||||
stopped = true
|
|
||||||
clearTimeout(timer)
|
|
||||||
document.removeEventListener('visibilitychange', onVisible)
|
|
||||||
ws?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { describe, expect, it } 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([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { Gemoji } from 'gemoji'
|
|
||||||
import type { IconName } from './icons'
|
|
||||||
|
|
||||||
export const iconLabels: Record<IconName, string> = {
|
|
||||||
plus: '加号', eye: '查看', expand: '展开', graph: '关系', calendar: '日历', tag: '标签', copy: '复制',
|
|
||||||
task: '任务', quote: '引用', undo: '撤销', redo: '重做', print: '打印', grid: '网格', menu: '菜单',
|
|
||||||
noteAdd: '新建笔记', import: '导入', folderAdd: '新建文件夹', folder: '文件夹', image: '图片', note: '文档',
|
|
||||||
chevron: '箭头', more: '更多', close: '关闭', pin: '固定', trash: '回收站', logout: '退出', restore: '恢复',
|
|
||||||
edit: '编辑', search: '搜索', link: '链接', key: '钥匙', settings: '设置', palette: '调色板', user: '用户',
|
|
||||||
outline: '大纲', source: '代码', command: '命令', info: '信息',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const emojiCategories = [
|
|
||||||
{ id: 'Smileys & Emotion', label: '表情', icon: '😀' },
|
|
||||||
{ id: 'People & Body', label: '人物', icon: '👋' },
|
|
||||||
{ id: 'Animals & Nature', label: '自然', icon: '🌿' },
|
|
||||||
{ id: 'Food & Drink', label: '食物', icon: '🍎' },
|
|
||||||
{ id: 'Travel & Places', label: '旅行', icon: '🚀' },
|
|
||||||
{ id: 'Activities', label: '活动', icon: '⚽' },
|
|
||||||
{ id: 'Objects', label: '物品', icon: '💡' },
|
|
||||||
{ id: 'Symbols', label: '符号', icon: '❤️' },
|
|
||||||
{ id: 'Flags', label: '旗帜', icon: '🏳️' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。
|
|
||||||
const keywords: Record<string, string> = {
|
|
||||||
'📂': '文件夹 目录 打开', '📁': '文件夹 目录', '📄': '文档 笔记 文章', '📝': '笔记 编辑 记录',
|
|
||||||
'📃': '文档 文章', '📋': '清单 剪贴板', '📚': '书籍 知识 学习', '📖': '阅读 书籍', '📕': '书籍 红书',
|
|
||||||
'📗': '书籍 绿书', '📘': '书籍 蓝书', '📙': '书籍 橙书', '📓': '笔记本', '📒': '账本 笔记本',
|
|
||||||
'📔': '笔记本 日记', '📅': '日历 日期', '🗓️': '日历 计划', '✅': '完成 任务 对勾', '☑️': '勾选 任务',
|
|
||||||
'⭐': '星星 收藏', '🌟': '星星 重要', '❤️': '爱心 喜欢', '🔥': '火 热门', '💡': '灯泡 灵感 想法',
|
|
||||||
'📌': '固定 图钉', '🔗': '链接', '🔒': '锁 安全', '🔑': '钥匙 密钥', '⚙️': '设置 齿轮',
|
|
||||||
'🛠️': '工具 开发', '💻': '电脑 编程 代码', '🖥️': '电脑 显示器', '📱': '手机', '🖼️': '图片 相册',
|
|
||||||
'🎨': '艺术 设计 调色板', '🎵': '音乐', '🎬': '电影 视频', '📷': '相机 摄影', '🎮': '游戏',
|
|
||||||
'🏠': '房子 主页 家', '🏡': '家 房子', '🚀': '火箭 项目 发布', '💼': '工作 公文包', '🎯': '目标',
|
|
||||||
'🌍': '地球 世界', '🌏': '地球 亚洲', '🌱': '幼苗 成长', '🌿': '植物 叶子 自然', '🌸': '花 樱花',
|
|
||||||
'☀️': '太阳 晴天', '🌙': '月亮 夜晚', '🐱': '猫 动物', '🐶': '狗 动物', '🍎': '苹果 食物',
|
|
||||||
'☕': '咖啡 饮料', '😀': '笑脸 开心 表情', '😊': '微笑 开心 表情', '🤔': '思考 想法', '👍': '赞 好',
|
|
||||||
'⚠️': '警告 注意', '❗': '感叹号 重要', '❓': '问号 问题', '💰': '钱 财务', '🎓': '毕业 学习',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emojiLabel(emoji: Gemoji): string {
|
|
||||||
return keywords[emoji.emoji] ?? emoji.description
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchesEmoji(emoji: Gemoji, query: string): boolean {
|
|
||||||
return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, keywords[emoji.emoji] ?? '', emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query)
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { EditorView } from '@codemirror/view'
|
|
||||||
import { insertImageFiles } from '../editor/paste'
|
|
||||||
import { api, ApiError } from './api'
|
|
||||||
import { drafts, type Draft } from './drafts'
|
|
||||||
import { connectEvents } from './events'
|
|
||||||
import { pluginEvents } from '../plugins/registry'
|
|
||||||
import { attachView, closeTab, createNoteAt, editorStateOf, flush, keepLocalVersion, importNotes, move, remove, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
|
|
||||||
|
|
||||||
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
|
|
||||||
|
|
||||||
let view: EditorView | undefined
|
|
||||||
beforeEach(() => { vi.spyOn(drafts, 'move').mockResolvedValue(undefined) })
|
|
||||||
afterEach(() => {
|
|
||||||
stopWorkspace()
|
|
||||||
attachView(null)
|
|
||||||
view?.destroy()
|
|
||||||
view = undefined
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('事件同步', () => {
|
|
||||||
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
|
|
||||||
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
|
|
||||||
vi.spyOn(api, 'settings').mockResolvedValue({})
|
|
||||||
vi.spyOn(api, 'fonts').mockResolvedValue([])
|
|
||||||
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
|
||||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
|
||||||
const saved = new Map<string, string>()
|
|
||||||
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { saved.set(path, content); return 'r2' })
|
|
||||||
await startWorkspace()
|
|
||||||
const v = await edit('from/note.md')
|
|
||||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '尚未保存的输入' } })
|
|
||||||
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
|
|
||||||
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
|
|
||||||
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
|
|
||||||
expect(drafts.move).toHaveBeenCalledWith('from', 'to')
|
|
||||||
expect(editorStateOf('from/note.md')).toBeUndefined()
|
|
||||||
expect(editorStateOf('to/note.md')!.doc.toString()).toBe('尚未保存的输入')
|
|
||||||
await flush('to/note.md')
|
|
||||||
expect(saved.get('to/note.md')).toBe('尚未保存的输入')
|
|
||||||
expect(saved.has('from/note.md')).toBe(false)
|
|
||||||
await closeTab('to/note.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
|
|
||||||
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue(entries)
|
|
||||||
vi.spyOn(api, 'settings').mockResolvedValue({})
|
|
||||||
vi.spyOn(api, 'fonts').mockResolvedValue([])
|
|
||||||
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
|
||||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
|
||||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
|
||||||
const read = vi.spyOn(api, 'readFile').mockResolvedValue({ content: '旧正文', rev: 'r1' })
|
|
||||||
await startWorkspace()
|
|
||||||
await openNote('sync.md')
|
|
||||||
const handlers = vi.mocked(connectEvents).mock.lastCall![0]
|
|
||||||
const observed: string[] = []
|
|
||||||
let resyncs = 0
|
|
||||||
const changed = () => observed.push(editorStateOf('sync.md')!.doc.toString())
|
|
||||||
const resync = () => { resyncs++ }
|
|
||||||
pluginEvents.addEventListener('changed', changed)
|
|
||||||
pluginEvents.addEventListener('resync', resync)
|
|
||||||
try {
|
|
||||||
let finish!: (value: { content: string; rev: string }) => void
|
|
||||||
read.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
|
|
||||||
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r2' }])
|
|
||||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
|
||||||
expect(observed).toEqual([])
|
|
||||||
finish({ content: '新正文', rev: 'r2' })
|
|
||||||
await vi.waitFor(() => expect(observed).toEqual(['新正文']))
|
|
||||||
read.mockRejectedValueOnce(new ApiError(500, '正文读取失败'))
|
|
||||||
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r3' }])
|
|
||||||
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('正文读取失败'))).toBe(true))
|
|
||||||
expect(observed).toEqual(['新正文'])
|
|
||||||
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
|
|
||||||
handlers.onResync()
|
|
||||||
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('目录读取失败'))).toBe(true))
|
|
||||||
expect(resyncs).toBe(0)
|
|
||||||
read.mockResolvedValue({ content: '恢复后的正文', rev: 'r3' })
|
|
||||||
handlers.onResync()
|
|
||||||
await vi.waitFor(() => expect(resyncs).toBe(1))
|
|
||||||
expect(editorStateOf('sync.md')!.doc.toString()).toBe('恢复后的正文')
|
|
||||||
} finally {
|
|
||||||
pluginEvents.removeEventListener('changed', changed)
|
|
||||||
pluginEvents.removeEventListener('resync', resync)
|
|
||||||
await closeTab('sync.md')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function edit(path: string) {
|
|
||||||
vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' })
|
|
||||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
|
||||||
ws.entries.push({ path, dir: false, size: 0, mtime: 0 })
|
|
||||||
await openNote(path)
|
|
||||||
view = new EditorView({ parent: document.body, state: editorStateOf(path)! })
|
|
||||||
attachView(view)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('笔记打开与创建', () => {
|
|
||||||
it('从图片库新建或打开已有笔记时显示笔记视图', async () => {
|
|
||||||
vi.spyOn(api, 'writeFile').mockResolvedValue('r1')
|
|
||||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
|
||||||
ws.images = ''
|
|
||||||
ws.view = 'graph'
|
|
||||||
expect(await createNoteAt('gallery-create.md', '正文')).toBe(true)
|
|
||||||
expect(ws.images).toBeNull()
|
|
||||||
expect(ws.view).toBe('notes')
|
|
||||||
expect(ws.active).toBe('gallery-create.md')
|
|
||||||
ws.images = ''
|
|
||||||
await openNote('gallery-create.md')
|
|
||||||
expect(ws.images).toBeNull()
|
|
||||||
expect(editorStateOf('gallery-create.md')?.doc.toString()).toBe('正文')
|
|
||||||
await closeTab('gallery-create.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([true, false])('创建期间打开同一路径,创建成功=%s,不产生重复或错误标签', async (succeeds) => {
|
|
||||||
const path = `pending-create-${succeeds}.md`
|
|
||||||
let finish!: (rev: string) => void
|
|
||||||
let fail!: (error: Error) => void
|
|
||||||
vi.spyOn(api, 'writeFile').mockImplementation(() => new Promise((resolve, reject) => { finish = resolve; fail = reject }))
|
|
||||||
vi.spyOn(api, 'readFile').mockRejectedValue(new ApiError(404, '文件不存在'))
|
|
||||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
|
||||||
const creating = createNoteAt(path, '创建内容')
|
|
||||||
const opening = openNote(path)
|
|
||||||
if (succeeds) finish('r1')
|
|
||||||
else fail(new ApiError(500, '创建写入失败'))
|
|
||||||
expect(await creating).toBe(succeeds)
|
|
||||||
await opening
|
|
||||||
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(succeeds ? 1 : 0)
|
|
||||||
if (succeeds) {
|
|
||||||
expect(ws.tabs.find(tab => tab.path === path)?.status).toBe('ready')
|
|
||||||
expect(editorStateOf(path)?.doc.toString()).toBe('创建内容')
|
|
||||||
await closeTab(path)
|
|
||||||
} else {
|
|
||||||
expect(ws.toasts.some(toast => toast.text.includes('创建写入失败'))).toBe(true)
|
|
||||||
}
|
|
||||||
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('保存与关闭', () => {
|
|
||||||
it('关闭冲突标签前持久化冲突之后的新输入,草稿失败时保留标签', async () => {
|
|
||||||
const v = await edit('conflict.md')
|
|
||||||
vi.spyOn(api, 'writeFile').mockRejectedValue(new ApiError(412, '版本冲突', { rev: 'r2' }))
|
|
||||||
const saved = new Map<string, Draft>()
|
|
||||||
const put = vi.spyOn(drafts, 'put').mockImplementation(async (draft) => { saved.set(draft.path, draft); return draft.path })
|
|
||||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '冲突前' } })
|
|
||||||
await flush('conflict.md')
|
|
||||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '冲突后新增' } })
|
|
||||||
put.mockRejectedValueOnce(new Error('存储失败'))
|
|
||||||
expect(await closeTab('conflict.md')).toBe(false)
|
|
||||||
expect(ws.tabs.some((tab) => tab.path === 'conflict.md')).toBe(true)
|
|
||||||
expect(await closeTab('conflict.md')).toBe(true)
|
|
||||||
expect(saved.get('conflict.md')?.content).toBe('冲突后新增')
|
|
||||||
expect(editorStateOf('conflict.md')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('冲突保存期间的新输入继续保存,不能标成已保存', async () => {
|
|
||||||
const v = await edit('race.md')
|
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
|
||||||
const contents: string[] = []
|
|
||||||
let finish!: (rev: string) => void
|
|
||||||
vi.spyOn(api, 'writeFile').mockImplementation(async (_path, content) => {
|
|
||||||
contents.push(content)
|
|
||||||
return contents.length === 1 ? new Promise<string>((resolve) => { finish = resolve }) : 'r3'
|
|
||||||
})
|
|
||||||
const tab = ws.tabs.find((tab) => tab.path === 'race.md')!
|
|
||||||
Object.assign(tab, { status: 'conflict', conflictRev: 'r2' })
|
|
||||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '请求发出时' } })
|
|
||||||
const saving = keepLocalVersion('race.md')
|
|
||||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '等待期间又输入' } })
|
|
||||||
finish('r2-new')
|
|
||||||
await saving
|
|
||||||
await flush('race.md')
|
|
||||||
expect(contents).toEqual(['请求发出时', '等待期间又输入'])
|
|
||||||
expect(tab.dirty).toBe(false)
|
|
||||||
await closeTab('race.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('网页图片导入期间切换标签,转换内容仍保存到原笔记', async () => {
|
|
||||||
const v = await edit('网页.md')
|
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
|
||||||
const contents = new Map<string, string>()
|
|
||||||
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' })
|
|
||||||
let finish!: (link: string) => void
|
|
||||||
vi.spyOn(api, 'importImage').mockImplementation(() => new Promise<string>((resolve) => { finish = resolve }))
|
|
||||||
const data = new DataTransfer()
|
|
||||||
data.setData('text/plain', '网页标题')
|
|
||||||
data.setData('text/html', '<h1>网页标题</h1><img src="https://example.com/a.png">')
|
|
||||||
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
|
||||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
|
||||||
expect(await closeTab('网页.md')).toBe(false)
|
|
||||||
await openNote('other.md')
|
|
||||||
storeViewState('网页.md', v.state)
|
|
||||||
v.setState(editorStateOf('other.md')!)
|
|
||||||
finish('网页.assets/hash.png')
|
|
||||||
await vi.waitFor(() => expect(editorStateOf('网页.md')!.doc.toString()).toContain('网页.assets/hash.png'))
|
|
||||||
await flush('网页.md')
|
|
||||||
expect(contents.get('网页.md')).toContain('# 网页标题')
|
|
||||||
expect(contents.get('网页.md')).toContain('网页.assets/hash.png')
|
|
||||||
expect(v.state.doc.toString()).toBe('原文')
|
|
||||||
await closeTab('网页.md')
|
|
||||||
await closeTab('other.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('切换标签后上传结果仍写回原笔记,上传期间不能关闭', async () => {
|
|
||||||
const v = await edit('image.md')
|
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
|
||||||
const contents = new Map<string, string>()
|
|
||||||
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' })
|
|
||||||
let finish!: (link: string) => void
|
|
||||||
vi.spyOn(api, 'upload').mockImplementation(() => new Promise<string>((resolve) => { finish = resolve }))
|
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image')
|
|
||||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
|
||||||
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
|
|
||||||
expect(await closeTab('image.md')).toBe(false)
|
|
||||||
await openNote('other.md')
|
|
||||||
v.setState(editorStateOf('other.md')!)
|
|
||||||
finish('image.assets/ok.png')
|
|
||||||
await vi.waitFor(() => expect(editorStateOf('image.md')!.doc.toString()).toContain('image.assets/ok.png'))
|
|
||||||
await flush('image.md')
|
|
||||||
expect(contents.get('image.md')).toBe('原文')
|
|
||||||
expect(v.state.doc.toString()).toBe('原文')
|
|
||||||
await closeTab('image.md')
|
|
||||||
await closeTab('other.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('图片上传期间不能移动笔记或其父目录', async () => {
|
|
||||||
const v = await edit('upload/note.md')
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'upload/note.md', dir: false, size: 0, mtime: 0 }])
|
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
|
||||||
vi.spyOn(api, 'writeFile').mockResolvedValue('r2')
|
|
||||||
const moving = vi.spyOn(api, 'move').mockResolvedValue(new Response())
|
|
||||||
let finish!: (link: string) => void
|
|
||||||
vi.spyOn(api, 'upload').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
|
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pending-move')
|
|
||||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
|
||||||
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
|
|
||||||
await move('upload/note.md', 'renamed.md')
|
|
||||||
await move('upload', 'renamed-folder')
|
|
||||||
expect(moving).not.toHaveBeenCalled()
|
|
||||||
expect(ws.active).toBe('upload/note.md')
|
|
||||||
await refreshTree()
|
|
||||||
finish('note.assets/hash.png')
|
|
||||||
await vi.waitFor(() => expect(v.state.doc.toString()).toContain('note.assets/hash.png'))
|
|
||||||
await closeTab('upload/note.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('移动期间拒绝新的图片和网页粘贴,保留原选区内容', async () => {
|
|
||||||
const v = await edit('moving.md')
|
|
||||||
v.dispatch({ selection: { anchor: 0, head: v.state.doc.length } })
|
|
||||||
let finish!: (response: Response) => void
|
|
||||||
vi.spyOn(api, 'move').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'moved.md', dir: false, size: 0, mtime: 0 }])
|
|
||||||
const upload = vi.spyOn(api, 'upload')
|
|
||||||
const importing = vi.spyOn(api, 'importImage')
|
|
||||||
const moving = move('moving.md', 'moved.md')
|
|
||||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
|
||||||
expect(insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])).toBe(true)
|
|
||||||
const data = new DataTransfer()
|
|
||||||
data.setData('text/html', '<h1>不应插入</h1>')
|
|
||||||
data.setData('text/plain', '不应插入')
|
|
||||||
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
|
||||||
expect(v.state.doc.toString()).toBe('原文')
|
|
||||||
expect(v.state.selection.main.from).toBe(0)
|
|
||||||
expect(v.state.selection.main.to).toBe(2)
|
|
||||||
expect(upload).not.toHaveBeenCalled()
|
|
||||||
expect(importing).not.toHaveBeenCalled()
|
|
||||||
finish(new Response())
|
|
||||||
await moving
|
|
||||||
await refreshTree()
|
|
||||||
await closeTab('moved.md')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('草稿迁移失败时不移动文件,服务端失败时恢复草稿路径', async () => {
|
|
||||||
await edit('draft-move.md')
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'draft-move.md', dir: false, size: 0, mtime: 0 }])
|
|
||||||
const moving = vi.spyOn(api, 'move').mockRejectedValue(new ApiError(500, '移动失败'))
|
|
||||||
vi.mocked(drafts.move).mockRejectedValueOnce(new Error('草稿不可写'))
|
|
||||||
await move('draft-move.md', 'draft-moved.md')
|
|
||||||
expect(moving).not.toHaveBeenCalled()
|
|
||||||
expect(ws.active).toBe('draft-move.md')
|
|
||||||
await move('draft-move.md', 'draft-moved.md')
|
|
||||||
expect(ws.active).toBe('draft-move.md')
|
|
||||||
expect(drafts.move).toHaveBeenLastCalledWith('draft-moved.md', 'draft-move.md')
|
|
||||||
await refreshTree()
|
|
||||||
await closeTab('draft-move.md')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('导入', () => {
|
|
||||||
it('上传期间阻止刷新,成功后刷新目录,并保留真实的成功结果', async () => {
|
|
||||||
ws.tabs = []
|
|
||||||
const result = { paths: ['导入'], notes: 1, assets: 0, warnings: [] }
|
|
||||||
let finish!: (value: typeof result) => void
|
|
||||||
vi.spyOn(api, 'importNotes').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: '导入', dir: true, size: 0, mtime: 0 }])
|
|
||||||
const importing = importNotes('', '导入', [new File(['正文'], 'test.md')])
|
|
||||||
expect(readyToReload()).toBe(false)
|
|
||||||
finish(result)
|
|
||||||
expect(await importing).toEqual(result)
|
|
||||||
expect(ws.entries.some((e) => e.path === '导入')).toBe(true)
|
|
||||||
expect(readyToReload()).toBe(true)
|
|
||||||
vi.mocked(api.importNotes).mockResolvedValueOnce(result)
|
|
||||||
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
|
|
||||||
expect(await importNotes('', '导入', [])).toEqual(result)
|
|
||||||
expect(ws.toasts.some((t) => t.text.includes('导入已完成,目录刷新失败'))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('失败保留错误并释放操作占用', async () => {
|
|
||||||
ws.tabs = []
|
|
||||||
vi.spyOn(api, 'importNotes').mockRejectedValue(new ApiError(409, '目标已存在'))
|
|
||||||
await expect(importNotes('', '导入', [])).rejects.toMatchObject({ status: 409, message: '目标已存在' })
|
|
||||||
expect(readyToReload()).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
describe('图片管理', () => {
|
|
||||||
it('替换期间阻止刷新,完成后更新列表;刷新失败不伪装成替换失败', async () => {
|
|
||||||
ws.tabs = []
|
|
||||||
let finish!: (value: Response) => void
|
|
||||||
vi.spyOn(api, 'replaceImage').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
|
|
||||||
const replacing = replaceImage('assets/a.png', new File(['image'], 'a.png'))
|
|
||||||
expect(readyToReload()).toBe(false)
|
|
||||||
finish(new Response())
|
|
||||||
await replacing
|
|
||||||
expect(ws.entries.find((e) => e.path === 'assets/a.png')?.mtime).toBe(2)
|
|
||||||
expect(readyToReload()).toBe(true)
|
|
||||||
vi.mocked(api.replaceImage).mockResolvedValueOnce(new Response())
|
|
||||||
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '读取失败'))
|
|
||||||
await replaceImage('assets/a.png', new File(['image'], 'a.png'))
|
|
||||||
expect(ws.toasts.some((t) => t.text.includes('图片已替换,但刷新目录失败'))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
|
|
||||||
ws.tabs = []
|
|
||||||
ws.active = null
|
|
||||||
vi.spyOn(api, 'restore').mockResolvedValue({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 })
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
|
|
||||||
await restore('trash-image')
|
|
||||||
expect(ws.image).toBe('assets/a.png')
|
|
||||||
expect(ws.tabs).toEqual([])
|
|
||||||
expect(ws.active).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('文件操作的保存前提', () => {
|
|
||||||
it.each(['move', 'delete'] as const)('%s 保存失败时返回失败并保留原文档', async (action) => {
|
|
||||||
const path = `保存失败-${action}.md`
|
|
||||||
const v = await edit(path)
|
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
|
||||||
const saving = vi.spyOn(api, 'writeFile').mockRejectedValue(new ApiError(403, '禁止写入'))
|
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path, dir: false, size: 0, mtime: 0 }])
|
|
||||||
v.dispatch({ changes: { from: 0, insert: '未保存' } })
|
|
||||||
const result = action === 'move' ? await move(path, '目标.md') : await remove(path)
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
expect(ws.active).toBe(path)
|
|
||||||
expect(editorStateOf(path)?.doc.toString()).toBe('未保存原文')
|
|
||||||
saving.mockResolvedValue('r2')
|
|
||||||
await flush(path)
|
|
||||||
await refreshTree()
|
|
||||||
await closeTab(path)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -22,10 +22,12 @@ export function sanitizeAppearance(raw: Record<string, unknown>): Appearance {
|
|||||||
return {
|
return {
|
||||||
colorMode: raw.colorMode === 'light' || raw.colorMode === 'dark' ? raw.colorMode : 'system',
|
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',
|
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 =>
|
cssSnippets: Array.isArray(raw.cssSnippets)
|
||||||
s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.name === 'string' &&
|
? raw.cssSnippets.filter((s): s is CssSnippet =>
|
||||||
typeof s.css === 'string' && typeof s.enabled === 'boolean',
|
s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.name === 'string'
|
||||||
).map((s) => ({ id: s.id, name: s.name, css: s.css, enabled: s.enabled })) : [],
|
&& typeof s.css === 'string' && typeof s.enabled === 'boolean',
|
||||||
|
).map(s => ({ id: s.id, name: s.name, css: s.css, enabled: s.enabled }))
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +57,15 @@ function render() {
|
|||||||
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
|
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
|
||||||
style('rm-theme', appearancePaused ? '' : themeCSS)
|
style('rm-theme', appearancePaused ? '' : themeCSS)
|
||||||
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。
|
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。
|
||||||
document.querySelectorAll('style[data-rm-snippet]').forEach((el) => el.remove())
|
document.querySelectorAll('style[data-rm-snippet]').forEach(el => el.remove())
|
||||||
if (!appearancePaused) for (const snippet of current.cssSnippets) {
|
if (!appearancePaused) {
|
||||||
if (!snippet.enabled) continue
|
for (const snippet of current.cssSnippets) {
|
||||||
const el = document.createElement('style')
|
if (!snippet.enabled) continue
|
||||||
el.dataset.rmSnippet = snippet.id
|
const el = document.createElement('style')
|
||||||
el.textContent = snippet.css
|
el.dataset.rmSnippet = snippet.id
|
||||||
document.head.append(el)
|
el.textContent = snippet.css
|
||||||
|
document.head.append(el)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const bg = getComputedStyle(root).getPropertyValue('--rm-bg').trim()
|
const bg = getComputedStyle(root).getPropertyValue('--rm-bg').trim()
|
||||||
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
|
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+21
-6
@@ -1,16 +1,29 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { watchFontLoading } from './fontLoading'
|
import { watchFontLoading } from './font-loading'
|
||||||
|
|
||||||
class Fonts extends EventTarget {
|
class Fonts extends EventTarget {
|
||||||
status = 'loading'
|
status = 'loading'
|
||||||
faces: { status: string }[] = []
|
faces: { status: string }[] = []
|
||||||
resolve!: () => void
|
resolve!: () => void
|
||||||
ready = this.pending()
|
ready = this.pending()
|
||||||
pending() { return new Promise<void>(resolve => { this.resolve = resolve }) }
|
pending() {
|
||||||
[Symbol.iterator]() { return this.faces[Symbol.iterator]() }
|
return new Promise<void>((resolve) => {
|
||||||
finish() { this.status = 'loaded'; this.resolve() }
|
this.resolve = resolve
|
||||||
load() { this.status = 'loading'; this.ready = this.pending(); this.dispatchEvent(new Event('loading')) }
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 fonts: Fonts
|
||||||
let frame: FrameRequestCallback
|
let frame: FrameRequestCallback
|
||||||
@@ -19,7 +32,9 @@ beforeEach(() => {
|
|||||||
document.body.innerHTML = '<div id="app">正文</div>'
|
document.body.innerHTML = '<div id="app">正文</div>'
|
||||||
fonts = new Fonts()
|
fonts = new Fonts()
|
||||||
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
|
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frame = callback; return 1 })
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||||
|
frame = callback; return 1
|
||||||
|
})
|
||||||
watchFontLoading()
|
watchFontLoading()
|
||||||
})
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
File renamed without changes.
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`,
|
||||||
|
}))
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import type { CustomFont } from '@@/apis/preferences/type'
|
||||||
|
import { fontResourceRoot, prepareResources } from '@@/apis/auth/resources'
|
||||||
|
import { preferencesApi } from '@@/apis/preferences'
|
||||||
import { shallowRef } from 'vue'
|
import { shallowRef } from 'vue'
|
||||||
import { api, type CustomFont } from './api'
|
|
||||||
|
|
||||||
export const customFonts = shallowRef<CustomFont[]>([])
|
export const customFonts = shallowRef<CustomFont[]>([])
|
||||||
|
|
||||||
export function applyFontCatalog(fonts: CustomFont[]) {
|
export function applyFontCatalog(fonts: CustomFont[]) {
|
||||||
customFonts.value = fonts
|
customFonts.value = fonts
|
||||||
let style = document.getElementById('rm-custom-fonts')
|
let style = document.getElementById('rm-custom-fonts')
|
||||||
if (!style) { style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style) }
|
if (!style) {
|
||||||
|
style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style)
|
||||||
|
}
|
||||||
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
|
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
|
||||||
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('/api/v1/fonts/${font.id}/file'); font-display: block; }`).join('\n')
|
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
|
if (style.textContent !== css) style.textContent = css
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,5 +22,6 @@ export function parseFontCatalog(value: unknown): CustomFont[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFonts() {
|
export async function loadFonts() {
|
||||||
applyFontCatalog(parseFontCatalog(await api.fonts()))
|
await prepareResources()
|
||||||
|
applyFontCatalog(parseFontCatalog(await preferencesApi.fonts()))
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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