refactor(web): adopt modular Vue architecture and isolate bearer sessions
This commit is contained in:
1 parent
1b2b2b12ff
commit
3cd641a380
379 files changed
+19736
-8712
No files matched your search
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import Layout from './components/Layout.vue'
|
||||
import Svg403 from './images/403.svg'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<img :src="Svg403" alt="403">
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import Layout from './components/Layout.vue'
|
||||
import Svg404 from './images/404.svg'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<img :src="Svg404" alt="404">
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="error">
|
||||
<div class="error-svg">
|
||||
<slot />
|
||||
</div>
|
||||
<router-link to="/">
|
||||
<el-button type="primary">
|
||||
回到首页
|
||||
</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
&-svg {
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g id="freepik--background-simple--inject-57"><path d="M55.48,273.73s2.32,72,62.43,120,143.41,51.43,210.84,56,119.23-33.62,127-91.32-43.72-74.64-71.68-140.33S358.64,130.8,299.49,90.4,147.8,74.81,99.29,144,55.48,273.73,55.48,273.73Z" style="fill:#409EFF"></path><path d="M55.48,273.73s2.32,72,62.43,120,143.41,51.43,210.84,56,119.23-33.62,127-91.32-43.72-74.64-71.68-140.33S358.64,130.8,299.49,90.4,147.8,74.81,99.29,144,55.48,273.73,55.48,273.73Z" style="fill:#fff;opacity:0.7000000000000001"></path></g><g id="freepik--Padlock--inject-57"><path d="M83.61,179.69V153.92c0-18.24,15.16-33.08,33.79-33.08s33.79,14.84,33.79,33.08v25.77h13.47V153.92c0-25.51-21.2-46.27-47.26-46.27s-47.26,20.76-47.26,46.27v25.77Z" style="fill:none;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><rect x="65.14" y="179.87" width="103.18" height="85.35" style="fill:none;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></rect><path d="M127.46,215.32a11.24,11.24,0,0,0-22.47,0,11,11,0,0,0,5.9,9.68L109,244.38h14.45L121.56,225A11,11,0,0,0,127.46,215.32Z" style="fill:none;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path></g><g id="freepik--Character--inject-57"><polygon points="232.64 267.99 206.95 266.3 206.95 265.88 203.79 266.09 200.64 265.88 200.64 266.3 174.95 267.99 156 423.79 174.95 423.79 203.16 299.57 204.43 299.57 232.64 423.79 251.59 423.79 232.64 267.99" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><path d="M156,423.78l-4.63,7.16-14.32,6.32a4.88,4.88,0,0,0-2.52,4.21v5.47h40.84a54.21,54.21,0,0,0,0-8.84c-.42-4.21-.42-14.32-.42-14.32Z" style="fill:#4c4c4c;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><path d="M134.53,442.31v4.63h40.84s.19-2,.19-4.63Z" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><path d="M251.51,423.78l4.63,7.16,14.32,6.32a4.88,4.88,0,0,1,2.52,4.21v5.47H232.14a54.21,54.21,0,0,1,0-8.84c.42-4.21.42-14.32.42-14.32Z" style="fill:#4c4c4c;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><path d="M273,442.31v4.63H232.14s-.18-2-.19-4.63Z" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><path d="M164.07,195.51s-30,42.16-30,45.25,4.77,26.95,12.35,29.19,11.79-1.68,13.19-9.54-3.37-19.09-3.37-19.09l14-12.63Z" style="fill:#409EFF;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><path d="M158.25,259.29s8.7-1.41,10.95-.85,3.93,5.9,3.93,5.9.84,7.86-.57,9.54-8.14,3.65-10.94,4.21-8.71-5.89-11-9.82S152.35,257.6,158.25,259.29Z" style="fill:#ccc;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><polygon points="170.38 193.6 164.07 195.51 174.01 261.86 233.09 261.86 242.08 195.89 234.05 192.83 204.41 186.33 170.38 193.6" style="fill:#409EFF;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><polygon points="236.34 215.78 235.01 225.34 224.68 227.82 214.16 223.62 214.93 215.01 236.34 215.78" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><circle cx="225.35" cy="223.52" r="1.82" style="fill:#4c4c4c;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></circle><polygon points="198.1 198.57 203.65 205.07 196.57 250.19 204.03 262.05 212.83 249.62 205.37 205.26 211.68 199.14 204.99 190.54 198.1 198.57" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><polygon points="192.75 187.29 167.32 193.6 168.09 196.66 192.94 189.77 192.75 187.29" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><polygon points="213.59 186.52 239.02 192.83 238.26 195.89 213.4 189.01 213.59 186.52" style="fill:#263238;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><path d="M239,192.83s9.62,2.17,15.51,5.54,7.3,10.95,3.93,19.37-13.75,7.3-18.52,5-6.18-10.66-5.34-14.31" style="fill:#409EFF;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></path><polygon points="193.9 184.04 191.03 185.95 191.79 205.26 204.22 196.47 217.22 205.45 218.37 186.33 214.93 183.46 193.9 184.04" style="fill:#409EFF;stroke:#263238;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.166343341900884px"></polygon><path d="M219,161.07s7.06-1.72,8.39-8.78S216,136.64,207.18,135.12s-21.75,7.63-23.85,13.93,6.1,12.59,14.88,13.93S219,161.07,219,161.07Z" style="fill:#263238;stroke:#263238;strokeLine truncated
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g id="freepik--background-complete--inject-49"><path d="M373.23,244a21.78,21.78,0,0,1,2.12-5.37l-2.92-4.47,6.15-6.36,4.57,2.78a21.6,21.6,0,0,1,5.3-2.3l1.09-5.23,8.85-.15,1.27,5.2a21.78,21.78,0,0,1,5.37,2.12l4.47-2.93,6.36,6.16L413.08,238a21.53,21.53,0,0,1,2.3,5.29l5.23,1.1.15,8.85-5.2,1.27a21.87,21.87,0,0,1-2.12,5.36l2.93,4.48-6.16,6.36L405.64,268a21.91,21.91,0,0,1-5.29,2.3l-1.1,5.23-8.85.14-1.27-5.19a21.87,21.87,0,0,1-5.36-2.12l-4.48,2.92-6.36-6.15,2.78-4.57a22,22,0,0,1-2.3-5.3l-5.23-1.09-.14-8.85,5.19-1.27Zm11.67,5a9.49,9.49,0,1,0,9.34-9.65A9.48,9.48,0,0,0,384.9,249Z" style="fill:none"></path><path d="M384.9,249a7.24,7.24,0,0,1,.16-2.35,9.57,9.57,0,0,1,7.38-7.32,9.8,9.8,0,0,1,5.12.31,9.78,9.78,0,0,1,4.59,3.31,9.7,9.7,0,0,1,.19,11.52,9.05,9.05,0,0,1-2,2.1,9.19,9.19,0,0,1-2.44,1.36,9.75,9.75,0,0,1-5.11.48,9.6,9.6,0,0,1-7.62-7.08,7.32,7.32,0,0,1-.23-2.33,21.12,21.12,0,0,0,.43,2.28,9.5,9.5,0,0,0,3.55,5.05,9.28,9.28,0,0,0,8.8,1.15,8.88,8.88,0,0,0,2.32-1.3,8.7,8.7,0,0,0,1.92-2,9.22,9.22,0,0,0-.18-10.92,8.88,8.88,0,0,0-2-1.94,8.69,8.69,0,0,0-2.36-1.22,9.29,9.29,0,0,0-4.87-.34,9.12,9.12,0,0,0-3.89,1.77,9.45,9.45,0,0,0-3.38,5.17A19.37,19.37,0,0,0,384.9,249Z" style="fill:#e0e0e0"></path><path d="M373.23,244a1.31,1.31,0,0,1-.32.1l-1,.26-3.87,1,.06-.08c.05,2.26.11,5.24.19,8.84l-.1-.12,5.24,1.07.09,0,0,.09a21.81,21.81,0,0,0,2.3,5.25l.05.09-.05.08-2.77,4.58,0-.23,6.37,6.15-.25,0,4.47-2.93.11-.08.11.07a22.18,22.18,0,0,0,5.32,2.09l.13,0,0,.14c.4,1.61.83,3.38,1.28,5.19l-.23-.18,8.85-.15-.24.19c.34-1.63.72-3.42,1.09-5.24l0-.14.15,0a21.48,21.48,0,0,0,5.23-2.28l.13-.08.13.08,4.57,2.78-.31,0,6.15-6.36,0,.32-2.93-4.48-.08-.13.07-.14a21.33,21.33,0,0,0,2.1-5.29h0l0-.15.15,0,5.2-1.26-.2.25c-.05-3-.1-5.94-.14-8.85l.2.25-5.23-1.1-.15,0-.05-.15a21.38,21.38,0,0,0-2.26-5.23l-.08-.13.08-.13,2.78-4.57,0,.31-6.35-6.16.29,0-4.48,2.93-.12.07-.12-.07a21.53,21.53,0,0,0-5.3-2.1l-.14,0,0-.13-1.26-5.2.22.17-8.85.13.19-.16c-.39,1.84-.76,3.56-1.11,5.23l0,.11-.11,0a21.68,21.68,0,0,0-5.26,2.26l-.09.06-.09-.06L378.5,228l.19,0-6.18,6.34,0-.15,2.89,4.5,0,0,0,0a22.2,22.2,0,0,0-1.77,4c-.14.44-.24.78-.32,1s-.11.34-.11.34,0-.12.07-.35.14-.58.28-1a20.87,20.87,0,0,1,1.7-4v.09c-.8-1.22-1.77-2.68-2.95-4.46l-.05-.08.06-.06,6.13-6.39.09-.09.1.07,4.58,2.76h-.17a21.58,21.58,0,0,1,5.33-2.33l-.14.14c.35-1.66.71-3.39,1.09-5.23l0-.16h.16l8.85-.16h.17l0,.17c.42,1.69.84,3.41,1.28,5.19l-.17-.16a21.76,21.76,0,0,1,5.43,2.14h-.24l4.47-2.94.16-.1.14.13,6.37,6.15.14.14-.11.17-2.77,4.57v-.26a21.86,21.86,0,0,1,2.33,5.35l-.2-.18,5.24,1.1.2,0v.21q.08,4.37.15,8.84v.21l-.2.05-5.2,1.27.2-.19h0a22.28,22.28,0,0,1-2.14,5.43v-.27l2.92,4.48.12.17-.15.15-6.16,6.36-.14.14-.17-.1-4.57-2.79h.26a21.55,21.55,0,0,1-5.36,2.32l.18-.18c-.38,1.81-.76,3.6-1.1,5.23l0,.19h-.2l-8.85.14h-.18l0-.18-1.26-5.2.16.17a22.31,22.31,0,0,1-5.42-2.16h.23l-4.49,2.91-.13.09-.12-.11-6.35-6.17-.1-.1.07-.13,2.79-4.56v.17a21.94,21.94,0,0,1-2.29-5.34l.11.11-5.23-1.11-.1,0v-.1c0-3.61-.07-6.59-.1-8.85v-.07H368l3.86-.9,1-.22Z" style="fill:#e0e0e0"></path><path d="M114.41,219.61a24.61,24.61,0,0,1,2.37-6l-3.27-5,6.88-7.11,5.12,3.11a24.46,24.46,0,0,1,5.92-2.57l1.22-5.85,9.9-.16,1.42,5.81a24.21,24.21,0,0,1,6,2.37l5-3.27,7.11,6.88L159,212.93a24.29,24.29,0,0,1,2.57,5.92l5.85,1.22.16,9.9-5.81,1.42a24.21,24.21,0,0,1-2.37,6l3.27,5-6.88,7.11-5.12-3.11a24.29,24.29,0,0,1-5.92,2.57l-1.22,5.85-9.9.16-1.42-5.81a24.21,24.21,0,0,1-6-2.37l-5,3.27-7.11-6.88,3.11-5.12a24.46,24.46,0,0,1-2.57-5.92l-5.85-1.22-.16-9.9,5.81-1.42Zm13,5.61a10.62,10.62,0,1,0,10.44-10.79A10.62,10.62,0,0,0,127.46,225.22Z" style="fill:#e0e0e0;fill-rule:evenodd"></path><path d="M342.47,194a7,7,0,0,1,.68-1.73l-.94-1.45,2-2.05,1.47.9a7.28,7.28,0,0,1,1.71-.75l.35-1.69,2.86,0,.41,1.67a7.27,7.27,0,0,1,1.73.69l1.45-.94,2.05,2-.89,1.48a7.25,7.25,0,0,1,.74,1.71l1.69.35,0,2.86-1.67.41a7.44,7.44,0,0,1-.69,1.73l.95,1.45-2,2-1.48-.9a6.86,6.86,0,0,1-1.71.74l-.35,1.69-2.86.05-.41-1.68a7,7,0,0,1-1.73-.68l-1.45.94-2.05-2,.9-1.48a6.86,6.86,0,0,1-.74-1.71l-1.69-.35,0-2.86,1.68-.41Zm3.76,1.62a3.07,3.07,0,1,0,3-3.12A3.07,3.07,0,0,0,346.23,195.66Z" style="fill:#e0e0e0;fill-rule:evenodd"></path><path d="M48.58,223.62a9.94,9.94,0,0,1,1-2.39l-1.31-2L51,216.38l2,1.24a9.36,9.36,0,0,1,2.36-1l.49-2.34,4-.07.57,2.32a10.37,10.37,0,0,1,2.39,1l2-1.3,2.84,2.75-1.24,2a9.81,9.81,0,0,1,1,2.37l2.34.49.07,3.95-2.33.57a10,10,0,0,1-.94,2.4l1.3,2-2.75,2.84-2-1.24a9.43,9.43,0,0,1-2.37,1l-.49,2.34-4,.07-.57-2.32a10,10,0,0,1-2.4-.95l-2,1.31L48.45,233l1.24-2a9.28,9.28,0,0,1-1-2.37l-2.34-.49-.07-3.95,2.32-.57Zm5.22,2.24A4.24,4.24,0,1,0,58,221.55,4.24,4.24,0,0,0,53.8,225.86Z" style="fill:#ebebeb;fill-rule:evenodd"></path><path d="M408.34,159.92a22.21,22.21,0,0,1,2.17-5.49l-3-4.58,6.3-6.51,4.67,2.84a22.38,22.38,0,0,1,5.42-2.34l1.12-5.36,9.05-.15,1.3,5.32a22.37,22.37,0,0,1,5.49,2.17l4.58-3,6.5,6.29-2.84,4.68a22,22,0,0,1,2.35,5.41l5.36,1.13.14,9-5.31,1.29a22.06,22.06,0,0,1-2.17,5.49l3,4.58-6.3,6.51-4.67-2.84a22.4,22.4,0,0,1-5.42,2.35L435,192.12l-9,.15-1.3-5Line truncated
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import type { Captcha } from '@@/apis/auth'
|
||||
import { authApi } from '@@/apis/auth'
|
||||
import AppStatus from '@@/components/AppStatus.vue'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { loginRedirect } from '@/router/guard'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const isSetup = computed(() => route.name === 'Setup')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captcha = ref<Captcha>()
|
||||
const captchaError = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const refreshing = ref(false)
|
||||
let refreshID = 0
|
||||
async function refresh() {
|
||||
const id = ++refreshID
|
||||
refreshing.value = true
|
||||
captchaError.value = ''
|
||||
captchaCode.value = ''
|
||||
try {
|
||||
const result = await authApi.captcha(captcha.value?.captchaId)
|
||||
if (id === refreshID) captcha.value = result
|
||||
} catch (cause) {
|
||||
if (id === refreshID && !isCancel(cause)) {
|
||||
captcha.value = undefined; captchaError.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
} finally {
|
||||
if (id === refreshID) refreshing.value = false
|
||||
}
|
||||
}
|
||||
watch(isSetup, (setup) => {
|
||||
++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh()
|
||||
}, { immediate: true })
|
||||
async function submit() {
|
||||
if (busy.value || (!isSetup.value && (!captcha.value || refreshing.value))) return
|
||||
error.value = ''
|
||||
if (isSetup.value && password.value !== confirm.value) {
|
||||
error.value = '两次输入的密码不一致'; return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = isSetup.value ? await authApi.setup(password.value) : await authApi.login(password.value, captcha.value!.captchaId, captchaCode.value)
|
||||
if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('登录响应格式错误')
|
||||
user.setToken(result.token)
|
||||
const version = user.getSessionVersion()
|
||||
await user.getInfo()
|
||||
user.assertSession(version)
|
||||
await router.replace(loginRedirect(route.query.redirect))
|
||||
} catch (cause) {
|
||||
if (!isCancel(cause)) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
if (!isSetup.value && !(cause instanceof ApiError && cause.status === 429)) await refresh()
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppStatus />
|
||||
<div class="rm-auth">
|
||||
<form class="rm-auth-card" @submit.prevent="submit">
|
||||
<h1>RawMark</h1>
|
||||
<p class="rm-auth-hint">
|
||||
{{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }}
|
||||
</p>
|
||||
<label><span>密码</span><el-input v-model="password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" required autofocus show-password /></label>
|
||||
<label v-if="isSetup"><span>确认密码</span><el-input v-model="confirm" type="password" autocomplete="new-password" required show-password /></label>
|
||||
<template v-else>
|
||||
<label><span>验证码</span><el-input v-model="captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" required placeholder="输入图片中的六位数字" /></label>
|
||||
<div class="rm-captcha">
|
||||
<el-button :disabled="refreshing || busy" class="rm-captcha-image" aria-label="刷新验证码" @click="refresh">
|
||||
<img v-if="captcha" :src="captcha.imageDataUrl" alt="登录验证码"><span v-else>{{ refreshing ? '正在加载验证码…' : '点击重新加载验证码' }}</span>
|
||||
</el-button>
|
||||
<el-button text :disabled="refreshing || busy" @click="refresh">
|
||||
换一张
|
||||
</el-button>
|
||||
</div>
|
||||
<p v-if="captchaError" class="rm-auth-error" role="alert">
|
||||
{{ captchaError }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-if="error" class="rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)">
|
||||
{{ isSetup ? '完成设置' : '登录' }}
|
||||
</el-button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-auth {
|
||||
font-family: 'Maple Mono Normal NL NF CN', monospace;
|
||||
}
|
||||
.rm-captcha {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.rm-captcha-image {
|
||||
height: 80px;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rm-captcha-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ApiToken, BackupKind, BackupSummary, MaintenanceStatus, StorageUsage } from './type'
|
||||
import { requestJSON, requestRaw } from '@/http/axios'
|
||||
|
||||
export const settingsApi = {
|
||||
changePassword: (current: string, next: string) => requestJSON('POST', '/auth/password', { errorOwner: 'caller', json: { current, next } }),
|
||||
maintenanceStatus: () => requestJSON<MaintenanceStatus>('GET', '/maintenance/status', { errorOwner: 'caller' }),
|
||||
exportBackup: (kind: BackupKind) => requestRaw('POST', `/maintenance/export/${kind}`, { errorOwner: 'caller' }),
|
||||
exportNotes: () => requestRaw('POST', '/maintenance/notes/export', { errorOwner: 'caller' }),
|
||||
startBackupUpload: (kind: BackupKind, size: number) => requestJSON<{ id: string }>('POST', '/maintenance/uploads', { errorOwner: 'caller', json: { kind, size } }),
|
||||
uploadBackupChunk: (id: string, offset: number, body: Blob) => requestJSON<{ offset: number }>('PUT', `/maintenance/uploads/${id}`, { errorOwner: 'caller', body, headers: { 'Upload-Offset': String(offset) } }),
|
||||
validateBackup: (id: string) => requestJSON<BackupSummary>('POST', `/maintenance/uploads/${id}/validate`, { errorOwner: 'caller' }),
|
||||
cancelBackupUpload: (id: string) => requestJSON('DELETE', `/maintenance/uploads/${id}`, { errorOwner: 'caller' }),
|
||||
importSettings: (id: string) => requestJSON('POST', '/maintenance/settings/import', { errorOwner: 'caller', json: { id, confirmed: true } }),
|
||||
restoreBackup: (id: string) => requestJSON('POST', '/maintenance/restore', { errorOwner: 'caller', json: { id, confirmed: true } }),
|
||||
resetSettings: () => requestJSON('POST', '/maintenance/settings/reset', { errorOwner: 'caller', json: { confirmed: true } }),
|
||||
maintainIndex: (operation: 'optimize' | 'rebuild') => requestJSON('POST', `/maintenance/index/${operation}`, { errorOwner: 'caller' }),
|
||||
clearTemp: () => requestJSON<{ files: number, bytes: number, failed: string[] }>('POST', '/maintenance/temp/clear', { errorOwner: 'caller' }),
|
||||
exportLogs: () => requestRaw('POST', '/maintenance/logs/export', { errorOwner: 'caller' }),
|
||||
tokens: async () => (await requestJSON<{ tokens: ApiToken[] }>('GET', '/tokens', { errorOwner: 'caller' })).tokens,
|
||||
createToken: (name: string, scope: ApiToken['scope']) =>
|
||||
requestJSON<{ token: string, info: ApiToken }>('POST', '/tokens', { errorOwner: 'caller', json: { name, scope } }),
|
||||
deleteToken: (id: string) => requestJSON('DELETE', `/tokens/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
|
||||
storage: () => requestJSON<StorageUsage>('GET', '/storage', { errorOwner: 'caller' }),
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 ApiToken {
|
||||
id: string
|
||||
name: string
|
||||
scope: 'read' | 'write'
|
||||
createdAt: number
|
||||
// 0 为从未使用
|
||||
lastUsedAt: number
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { Theme } from '@@/apis/preferences/type'
|
||||
import type { CssSnippet } from '../../../common/appearance/appearance'
|
||||
import type { Settings } from '../../../pinia/stores/settings'
|
||||
import { preferencesApi } from '@@/apis/preferences'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { appearancePaused, fixedThemeMode } from '../../../common/appearance/appearance'
|
||||
import { DISPLAY_DEFAULTS } from '../../../common/appearance/typography'
|
||||
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
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 preferencesApi.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 (isCancel(err)) return false
|
||||
if (err instanceof SettingsAppearanceError) {
|
||||
toast(err.message); return true
|
||||
}
|
||||
toast(`保存外观失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`)
|
||||
void loadSettings().catch((err) => {
|
||||
if (!isCancel(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>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="refreshThemes">
|
||||
刷新列表
|
||||
</el-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">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="edit()">
|
||||
新建片段
|
||||
</el-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>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="edit(snippet)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">
|
||||
删除
|
||||
</el-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>
|
||||
<el-button class="rm-btn" :disabled="busy" native-type="submit">
|
||||
保存并应用
|
||||
</el-button> <el-button class="rm-btn" native-type="button" @click="editing = null">
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
<SettingRow id="reset-appearance">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset">
|
||||
恢复默认外观与排版
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<p class="rm-appearance-note">
|
||||
若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。
|
||||
</p>
|
||||
</details>
|
||||
</template>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupKind, MaintenanceStatus } from '@/pages/settings/apis/type'
|
||||
import { authApi } from '@@/apis/auth'
|
||||
import { isCancel } from 'axios'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { refreshPlugins } from '../../../common/plugins/host'
|
||||
import { flushSettings, loadSettings, reloadTheme } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void> }>()
|
||||
|
||||
const emit = defineEmits<{ changed: [], importNotes: [] }>()
|
||||
|
||||
const user = useUserStore()
|
||||
|
||||
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 settingsApi.maintenanceStatus(); if (mounted) last.value = status
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `读取维护状态失败:${String(cause)}`
|
||||
}
|
||||
})
|
||||
async function prepare() {
|
||||
await Promise.all([props.prepareMaintenance(), flushSettings()])
|
||||
}
|
||||
async function perform(label: string, action: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
const version = user.getSessionVersion()
|
||||
busy.value = true; progress.value = label; error.value = ''; message.value = ''
|
||||
try {
|
||||
await prepare()
|
||||
user.assertSession(version)
|
||||
await action()
|
||||
if (mounted) {
|
||||
emit('changed'); last.value = await settingsApi.maintenanceStatus()
|
||||
}
|
||||
} catch (cause) {
|
||||
if (mounted && !isCancel(cause)) error.value = String(cause)
|
||||
} finally {
|
||||
if (mounted) {
|
||||
busy.value = false; progress.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
async function download(response: Response, fallback: string) {
|
||||
const version = user.getSessionVersion()
|
||||
const blob = await response.blob()
|
||||
user.assertSession(version)
|
||||
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 settingsApi.exportBackup(kind), `rawmark-${kind}.zip`))
|
||||
}
|
||||
async function exportMarkdown() {
|
||||
await perform('正在导出笔记…', async () => download(await settingsApi.exportNotes(), 'rawmark-notes.zip'))
|
||||
}
|
||||
async function exportSystemLogs() {
|
||||
await perform('正在导出日志…', async () => download(await settingsApi.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 settingsApi.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 version = user.getSessionVersion()
|
||||
const { id } = await settingsApi.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 settingsApi.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 settingsApi.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()
|
||||
user.assertSession(version)
|
||||
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
||||
if (kind === 'settings') {
|
||||
await settingsApi.importSettings(id); submitted = true
|
||||
await syncPreferences(); message.value = '设置与资源已导入,原设置已备份'
|
||||
} else {
|
||||
await settingsApi.restoreBackup(id); submitted = true
|
||||
user.invalidate(version)
|
||||
const recoveryVersion = user.getSessionVersion()
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
if (recoveryVersion !== user.getSessionVersion()) return
|
||||
try {
|
||||
if ((await authApi.state()).setUp) {
|
||||
location.reload(); return
|
||||
}
|
||||
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
|
||||
}
|
||||
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
|
||||
}
|
||||
} finally {
|
||||
if (!submitted && version === user.getSessionVersion()) {
|
||||
try {
|
||||
await settingsApi.cancelBackupUpload(id)
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `清理上传暂存失败:${String(cause)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
async function index(operation: 'optimize' | 'rebuild') {
|
||||
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
|
||||
await settingsApi.maintainIndex(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
|
||||
})
|
||||
}
|
||||
async function clearTemp() {
|
||||
await perform('正在清理临时文件…', async () => {
|
||||
const result = await settingsApi.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) emit('importNotes')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-maintenance" :aria-busy="busy">
|
||||
<h3 class="rm-settings-group-title">
|
||||
备份与迁移
|
||||
</h3>
|
||||
<SettingRow id="backup-full">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('full')">
|
||||
导出
|
||||
</el-button><el-button class="rm-btn" :disabled="busy" @click="fullInput!.click()">
|
||||
恢复
|
||||
</el-button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)">
|
||||
</SettingRow>
|
||||
<SettingRow id="backup-notes">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportMarkdown">
|
||||
导出
|
||||
</el-button><el-button class="rm-btn" :disabled="busy" @click="importNotes">
|
||||
导入
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="backup-settings">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">
|
||||
导出
|
||||
</el-button><el-button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">
|
||||
导入
|
||||
</el-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">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="index('optimize')">
|
||||
优化
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="index-rebuild">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="index('rebuild')">
|
||||
重建
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="temp-clear">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="clearTemp">
|
||||
清理
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="settings-reset">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset">
|
||||
重置
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="logs-export">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportSystemLogs">
|
||||
导出
|
||||
</el-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>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { InstalledPlugin } from '@@/apis/plugins/type'
|
||||
import { pluginsApi } from '@@/apis/plugins'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import PluginMount from '../../../common/components/PluginMount.vue'
|
||||
import { pluginState, refreshPlugins, retryPlugin } from '../../../common/plugins/host'
|
||||
import { pluginsPaused, settingTabs } from '../../../common/plugins/registry'
|
||||
|
||||
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(() => pluginsApi.installPlugin(file))
|
||||
}
|
||||
|
||||
async function remove(plugin: InstalledPlugin) {
|
||||
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
|
||||
await perform(() => pluginsApi.uninstallPlugin(plugin.id))
|
||||
}
|
||||
|
||||
function toggle(plugin: InstalledPlugin) {
|
||||
void perform(() => pluginsApi.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>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">
|
||||
刷新列表
|
||||
</el-button>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()">
|
||||
安装 ZIP
|
||||
</el-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>
|
||||
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">
|
||||
重试
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">
|
||||
卸载
|
||||
</el-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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingId } from '../settings-catalog'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { SETTING_ITEMS } from '../settings-catalog'
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { Command } from '../../../common/utils/commands'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '../../../common/utils/commands'
|
||||
import { recordedKeys } from '../../../common/utils/keys'
|
||||
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
|
||||
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="搜索命令或快捷键"><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
|
||||
重置全部快捷键
|
||||
</el-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">
|
||||
<el-button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">
|
||||
添加
|
||||
</el-button>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset(command)">
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageUsage } from '@/pages/settings/apis/type'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import MaintenancePanel from './MaintenancePanel.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void> }>()
|
||||
const emit = defineEmits<{ importNotes: [] }>()
|
||||
|
||||
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 settingsApi.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">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="scan">
|
||||
{{ busy ? '统计中…' : '刷新' }}
|
||||
</el-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 :prepare-maintenance="props.prepareMaintenance" @changed="scan" @import-notes="emit('importNotes')" />
|
||||
</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>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiToken } from '@/pages/settings/apis/type'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { copyText } from '../../../common/utils/clipboard'
|
||||
|
||||
const tokens = ref<ApiToken[] | null>(null)
|
||||
const failed = ref(false)
|
||||
const name = ref('')
|
||||
const scope = ref<ApiToken['scope']>('read')
|
||||
const busy = ref(false)
|
||||
// 刚创建的令牌明文,只在本次对话框中显示
|
||||
const created = ref<string | null>(null)
|
||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||
|
||||
function errorText(err: unknown) {
|
||||
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
tokens.value = await settingsApi.tokens()
|
||||
} catch {
|
||||
failed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await settingsApi.createToken(name.value.trim(), scope.value)
|
||||
tokens.value = [res.info, ...(tokens.value ?? [])]
|
||||
created.value = res.token
|
||||
name.value = ''
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
toast(`创建失败:${errorText(err)}`)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制')
|
||||
}
|
||||
|
||||
async function revoke(t: ApiToken) {
|
||||
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
|
||||
try {
|
||||
await settingsApi.deleteToken(t.id)
|
||||
tokens.value = tokens.value!.filter(x => x.id !== t.id)
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
toast(`吊销失败:${errorText(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form class="rm-share-form" @submit.prevent="create">
|
||||
<el-input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
|
||||
<el-select v-model="scope" aria-label="权限">
|
||||
<el-option value="read" label="只读" /><el-option value="write" label="读写" />
|
||||
</el-select>
|
||||
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
|
||||
创建
|
||||
</el-button>
|
||||
</form>
|
||||
<div v-if="created" class="rm-token-created">
|
||||
<p>令牌只显示这一次,请立即保存:</p>
|
||||
<div class="rm-token-value">
|
||||
<code>{{ created }}</code>
|
||||
<el-button class="rm-btn" @click="copy">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rm-share-note">
|
||||
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
|
||||
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
|
||||
</p>
|
||||
<p v-if="failed" class="rm-tree-empty">
|
||||
读取 API 令牌失败
|
||||
</p>
|
||||
<p v-else-if="tokens && !tokens.length" class="rm-tree-empty">
|
||||
还没有 API 令牌
|
||||
</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<li v-for="t in tokens ?? []" :key="t.id">
|
||||
<Icon name="key" />
|
||||
<div class="rm-trash-info">
|
||||
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span>
|
||||
<span class="rm-trash-time">
|
||||
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? `${fmt.format(t.lastUsedAt)} 最近使用` : '从未使用' }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button class="rm-btn rm-btn-danger" @click="revoke(t)">
|
||||
吊销
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import type { TrashItem } from '@@/apis/trash/type'
|
||||
import { trashApi } from '@@/apis/trash'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { isImage } from '../../../common/utils/paths'
|
||||
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ restoreNote: (id: string) => Promise<void> }>()
|
||||
const user = useUserStore()
|
||||
|
||||
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 trashApi.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) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`保存自动删除期限失败:${String(cause)}`)
|
||||
if (!(cause instanceof SettingsAppearanceError)) {
|
||||
await loadSettings().catch((cause) => {
|
||||
if (!isCancel(cause)) toast(`重新读取设置失败:${String(cause)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doRestore(item: TrashItem) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await props.restoreNote(item.id); await load()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function purge(item?: TrashItem) {
|
||||
if (busy.value) return
|
||||
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
|
||||
const version = user.getSessionVersion()
|
||||
busy.value = true
|
||||
failures.value = []
|
||||
try {
|
||||
if (item) {
|
||||
await trashApi.purge(item.id)
|
||||
} else {
|
||||
const result = await trashApi.clearTrash()
|
||||
failures.value = result.failed
|
||||
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`删除失败:${String(cause)}`)
|
||||
} finally {
|
||||
if (version === user.getSessionVersion()) 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">
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">
|
||||
清空全部
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">
|
||||
{{ error }} <el-button class="rm-btn" :disabled="busy" @click="load">
|
||||
重试
|
||||
</el-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>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="doRestore(item)">
|
||||
恢复
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">
|
||||
彻底删除
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import type { Settings } from '../../pinia/stores/settings'
|
||||
|
||||
import type { SettingSearchResult } from './settings-catalog'
|
||||
import type { SettingsSection } from '@/pinia/stores/settings'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import { UI_FONT_SIZE } from '../../common/appearance/typography'
|
||||
import FontPicker from '../../common/components/FontPicker.vue'
|
||||
import Icon from '../../common/components/Icon.vue'
|
||||
import { runCommand } from '../../common/utils/commands'
|
||||
import { installApp, installPrompt } from '../../common/utils/pwa'
|
||||
import { CONTENT_WIDTHS, DEFAULTS, loadSettings, settings, SettingsAppearanceError, settingsSave, TAB_SIZES, updateSettings } from '../../pinia/stores/settings'
|
||||
import AppearanceSettings from './components/AppearanceSettings.vue'
|
||||
import PluginsPanel from './components/PluginsPanel.vue'
|
||||
import SettingRow from './components/SettingRow.vue'
|
||||
import ShortcutSettings from './components/ShortcutSettings.vue'
|
||||
import StoragePanel from './components/StoragePanel.vue'
|
||||
import TokensPanel from './components/TokensPanel.vue'
|
||||
import TrashPanel from './components/TrashPanel.vue'
|
||||
import { searchSettings, SETTINGS_SECTIONS } from './settings-catalog'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>()
|
||||
const emit = defineEmits<{ importNotes: [] }>()
|
||||
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
||||
if (!buildVersion) throw new Error('缺少应用构建版本信息')
|
||||
|
||||
const model = defineModel<SettingsSection | 'index' | null>({ required: true })
|
||||
const section = computed(() => (model.value === 'index' || model.value === null ? 'appearance' : model.value))
|
||||
// 手机上一次只显示分组列表或分组内容
|
||||
const showList = ref(model.value === '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 = ''
|
||||
model.value = 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() {
|
||||
model.value = 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]; const 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 (isCancel(err)) return
|
||||
if (err instanceof SettingsAppearanceError) {
|
||||
toast(err.message); return
|
||||
}
|
||||
toast(`保存设置失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`)
|
||||
void loadSettings().catch((error) => {
|
||||
if (!isCancel(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 settingsApi.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">
|
||||
<el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="spellcheck" control="rm-spellcheck">
|
||||
<el-switch id="rm-spellcheck" :model-value="settings.spellcheck" @change="change({ spellcheck: Boolean($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })">
|
||||
<el-select id="rm-tab-size" :model-value="settings.tabSize" @change="change({ tabSize: $event })">
|
||||
<el-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="`${n} 列`" />
|
||||
</el-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><el-input v-model="pw.current" type="password" autocomplete="current-password" required show-password /></label>
|
||||
<label><span>新密码</span><el-input v-model="pw.next" type="password" autocomplete="new-password" required show-password /></label>
|
||||
<label><span>确认新密码</span><el-input v-model="pw.confirm" type="password" autocomplete="new-password" required show-password /></label>
|
||||
<p v-if="pwError" class="rm-auth-error" role="alert">
|
||||
{{ pwError }}
|
||||
</p>
|
||||
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="pwBusy">
|
||||
修改密码
|
||||
</el-button>
|
||||
</form>
|
||||
<section id="rm-setting-tokens" tabindex="-1">
|
||||
<h3 class="rm-settings-group-title">
|
||||
API 令牌
|
||||
</h3><TokensPanel />
|
||||
</section>
|
||||
<SettingRow id="logout">
|
||||
<el-button class="rm-btn" @click="close(), runCommand('app.logout')">
|
||||
<Icon name="logout" />退出
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
||||
|
||||
<TrashPanel v-else-if="section === 'trash'" :restore-note="props.restoreNote" />
|
||||
<StoragePanel v-else-if="section === 'storage'" :prepare-maintenance="props.prepareMaintenance" @import-notes="emit('importNotes')" />
|
||||
<template v-else-if="section === 'about'">
|
||||
<p class="rm-share-note">
|
||||
RawMark · 纯 Markdown 笔记编辑器,文件即真源。
|
||||
</p>
|
||||
<SettingRow id="version">
|
||||
<code>{{ buildVersion }}</code>
|
||||
</SettingRow>
|
||||
<SettingRow id="install">
|
||||
<el-button v-if="installPrompt" class="rm-btn" @click="installApp">
|
||||
安装 RawMark
|
||||
</el-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>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { IconName } from '../../common/components/Icon.vue'
|
||||
import type { SettingsSection } from '@/pinia/stores/settings'
|
||||
|
||||
export const SETTINGS_SECTIONS: { id: SettingsSection, title: string, icon: IconName, description: string }[] = [
|
||||
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
|
||||
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
|
||||
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
|
||||
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
|
||||
{ id: 'storage', title: '存储空间', icon: 'grid', description: '空间占用、备份迁移及维护工具' },
|
||||
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
|
||||
{ id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' },
|
||||
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
|
||||
]
|
||||
|
||||
export const SETTING_ITEMS = {
|
||||
'storage': { section: 'storage', title: '空间占用', description: '自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。', keywords: '容量 占用 大小 附件 图片 回收站' },
|
||||
'backup-full': { section: 'storage', title: '完整服务备份', description: '包含笔记、回收站、登录信息、设置和资源,用于恢复实例;恢复前备份当前数据,完成后重新登录。请妥善保管导出包。', keywords: 'Data 导入 导出 备份 恢复 迁移' },
|
||||
'backup-notes': { section: 'storage', title: '笔记数据', description: '导出普通 Markdown 与附件 ZIP,保留目录和相对链接,缺失附件另附清单;导入到新文件夹,不覆盖现有笔记。', keywords: 'Data MD Markdown 导入 导出 迁移' },
|
||||
'backup-settings': { section: 'storage', title: '设置与资源', description: '备份偏好、快捷键、插件数据及字体、主题、插件;不包含笔记或登录信息。导入前备份,同 ID 资源替换。', keywords: '设置 导入 导出 字体 主题 插件' },
|
||||
'index-optimize': { section: 'storage', title: '优化索引', description: '整理全文索引并回收索引数据库空闲空间,不修改笔记。', keywords: '搜索 数据库 整理 VACUUM' },
|
||||
'index-rebuild': { section: 'storage', title: '重建索引', description: '从当前 Markdown 笔记重新生成全文搜索和元数据索引。', keywords: '搜索 数据库 重建 修复' },
|
||||
'temp-clear': { section: 'storage', title: '清理临时文件', description: '清理本程序不再使用的临时文件;保留活动上传、恢复备份与用户文件。', keywords: '临时 缓存 空间 清理' },
|
||||
'settings-reset': { section: 'storage', title: '重置设置', description: '恢复全部 RawMark 偏好并停用插件,保留笔记、账户、已安装资源、插件存储和本机标签页/布局。', keywords: '重置 默认 恢复' },
|
||||
'logs-export': { section: 'storage', title: '导出系统日志', description: '导出有限轮转的应用日志和运行环境摘要,不包含笔记正文及认证信息。历史未保存的日志无法补回。', keywords: '日志 系统 错误 排查' },
|
||||
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
|
||||
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
|
||||
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
|
||||
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
|
||||
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
|
||||
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
|
||||
'glass-opacity': { section: 'appearance', title: '磨砂玻璃通透度', description: '调整玻璃面板的透明程度,保留当前主题配色。', keywords: '透明度 背景' },
|
||||
'css-snippets': { section: 'appearance', title: 'CSS 片段', description: '管理自定义主题和 CSS 片段。', keywords: '样式 自定义 高级' },
|
||||
'reset-appearance': { section: 'appearance', title: '恢复默认外观与排版', description: '重置主题、界面与正文排版;停用并保留 CSS 片段。', keywords: '重置 默认 恢复' },
|
||||
'text-font': { section: 'editor', title: '正文主要字体', description: '用于笔记正文,不改变界面字体。', keywords: '无衬线 宋体 系统' },
|
||||
'code-font': { section: 'editor', title: '代码字体', description: '用于代码块和行内代码。', keywords: '等宽 JetBrains Mono' },
|
||||
'font-size': { section: 'editor', title: '编辑器字号', description: '调整正文与代码的基础字号,不影响界面字号。', keywords: '正文 大小' },
|
||||
'content-width': { section: 'editor', title: '正文宽度', description: '限制正文最大宽度,或使用可用空间的全宽。', keywords: '窄 标准 宽 全宽' },
|
||||
'line-height': { section: 'editor', title: '正文行高', description: '调整正文各行之间的距离。', keywords: '行距 排版' },
|
||||
'selection-toolbar': { section: 'editor', title: '选中文本浮动工具栏', description: '选中文字后显示常用格式操作。', keywords: '格式 编辑' },
|
||||
'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' },
|
||||
'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' },
|
||||
'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' },
|
||||
'tokens': { section: 'account', title: 'API 令牌', description: '创建或撤销 REST API 访问令牌。', keywords: 'API REST 接口 自动化 安全' },
|
||||
'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' },
|
||||
'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' },
|
||||
'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' },
|
||||
'version': { section: 'about', title: '构建版本', description: '当前页面加载的应用版本。', keywords: 'RawMark 关于' },
|
||||
'openapi': { section: 'about', title: 'API 文档', description: '在「账户与安全 → API 令牌」创建令牌后,通过 REST API 读取或写入笔记。', keywords: 'OpenAPI 接口 自动化' },
|
||||
} satisfies Record<string, { section: SettingsSection, title: string, description: string, keywords: string }>
|
||||
|
||||
export type SettingId = keyof typeof SETTING_ITEMS
|
||||
export interface SettingSearchResult {
|
||||
section: SettingsSection
|
||||
id?: SettingId
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 只搜索设置说明,不读取设置值、笔记或管理列表。 */
|
||||
export function searchSettings(query: string): SettingSearchResult[] {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return []
|
||||
const matches = (text: string) => terms.every(term => text.toLocaleLowerCase().includes(term))
|
||||
return [
|
||||
...SETTINGS_SECTIONS.filter(s => matches(`${s.title} ${s.description}`)).map(s => ({ section: s.id, title: s.title, description: s.description })),
|
||||
...(Object.entries(SETTING_ITEMS) as [SettingId, typeof SETTING_ITEMS[SettingId]][])
|
||||
.filter(([, item]) => matches(`${item.title} ${item.description} ${item.keywords}`))
|
||||
.map(([id, item]) => ({ id, section: item.section, title: item.title, description: item.description })),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TrashItem } from '@@/apis/trash/type'
|
||||
import type { TreeIcon } from '@@/apis/vault/type'
|
||||
import type { ImportResult, NoteMetadata, SearchResult } from './type'
|
||||
import { encodePath, requestJSON, requestRaw } from '@/http/axios'
|
||||
|
||||
export const workspaceApi = {
|
||||
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 requestJSON<ImportResult>('POST', '/import', { errorOwner: 'caller', body })
|
||||
},
|
||||
exportFiles: (paths: string[]) => requestRaw('POST', '/export', { errorOwner: 'caller', json: { paths } }),
|
||||
metadata: () => requestJSON<{ notes: NoteMetadata[], ready: boolean }>('GET', '/metadata', { errorOwner: 'caller' }),
|
||||
setTreeIcon: (path: string, icon: TreeIcon | null) => requestJSON('PUT', '/tree-icon', { errorOwner: 'caller', json: { path, icon } }),
|
||||
search: async (q: string) => (await requestJSON<{ results: SearchResult[] }>('GET', `/search?q=${encodeURIComponent(q)}`, { errorOwner: 'caller' })).results,
|
||||
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 = await requestJSON<{ rev: string }>('PUT', `/files/${encodePath(path)}`, { errorOwner: 'caller', body: content, headers, keepalive: keepalive && new Blob([content]).size < 60_000 })
|
||||
return res.rev
|
||||
},
|
||||
deleteFile: (path: string) => requestJSON<TrashItem>('DELETE', `/files/${encodePath(path)}`, { errorOwner: 'caller' }),
|
||||
move: (from: string, to: string) => requestJSON('POST', '/move', { errorOwner: 'caller', json: { from, to } }),
|
||||
mkdir: (path: string) => requestJSON('POST', '/folders', { errorOwner: 'caller', json: { path } }),
|
||||
async upload(note: string, file: File): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name || 'image')
|
||||
return (await requestJSON<{ link: string }>('POST', `/assets?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', body: form })).link
|
||||
},
|
||||
cleanupImages: (paths: string[]) => requestJSON<{ deleted: TrashItem[], skipped: { path: string, reason: string }[] }>('POST', '/assets/cleanup', { errorOwner: 'caller', json: { paths } }),
|
||||
uploadFolderImage: async (folder: string, file: File) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return requestJSON<{ link: string }>('POST', `/assets?folder=${encodeURIComponent(folder)}`, { errorOwner: 'caller', body })
|
||||
},
|
||||
replaceImage: (path: string, file: File) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return requestJSON('PUT', `/assets/${encodePath(path)}`, { errorOwner: 'caller', body })
|
||||
},
|
||||
importImage: async (note: string, url: string): Promise<string> =>
|
||||
(await requestJSON<{ link: string }>('POST', `/assets/import?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', json: { url } })).link,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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 SearchResult {
|
||||
path: string
|
||||
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
|
||||
line: number
|
||||
snippet: string
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { toasts } from '@@/utils/notifications'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { workspaceApi } from './apis'
|
||||
import { batchFiles, exportFiles } from './batch'
|
||||
import { flush, remove } from './workspace'
|
||||
|
||||
vi.mock('./workspace', () => ({ move: vi.fn(), remove: vi.fn(), refreshTree: vi.fn().mockResolvedValue(undefined), flush: vi.fn(), ws: { tabs: [{ path: 'a.md', dirty: false, status: 'ready' }] } }))
|
||||
const user = useUserStore(pinia)
|
||||
beforeEach(() => {
|
||||
user.setToken('rms_old'); toasts.splice(0)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks(); vi.unstubAllGlobals(); user.setToken('')
|
||||
})
|
||||
it('切换会话后批量操作停止,不以新身份继续下一项', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
let finish!: (value: { ok: true }) => void
|
||||
vi.mocked(remove).mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const pending = batchFiles('delete', ['a.md', 'b.md'])
|
||||
user.setToken('rms_new')
|
||||
finish({ ok: true })
|
||||
await pending
|
||||
expect(remove).toHaveBeenCalledTimes(1)
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
it('等待笔记保存时换会话,不再使用新身份导出旧选择', async () => {
|
||||
let finish!: () => void
|
||||
vi.mocked(flush).mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const request = vi.spyOn(workspaceApi, 'exportFiles')
|
||||
const pending = exportFiles(['a.md'])
|
||||
user.setToken('rms_new')
|
||||
finish()
|
||||
await pending
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { workspaceApi } from '@/pages/workspace/apis'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { basename, joinPath } from '../../common/utils/paths'
|
||||
import { minimalPaths } from './selection'
|
||||
import { flush, move, refreshTree, remove, ws } from './workspace'
|
||||
|
||||
export async function batchFiles(action: 'move' | 'delete', paths: string[], destination = '') {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
const subjects = minimalPaths(paths)
|
||||
if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
|
||||
const failures: string[] = []
|
||||
let succeeded = 0
|
||||
for (const path of subjects) {
|
||||
try {
|
||||
user.assertSession(version)
|
||||
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) {
|
||||
if (isCancel(cause)) return
|
||||
failures.push(`${path}:${cause instanceof Error ? cause.message : String(cause)}`)
|
||||
}
|
||||
}
|
||||
if (version !== user.getSessionVersion()) return
|
||||
try {
|
||||
await refreshTree()
|
||||
user.assertSession(version)
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`刷新目录失败:${String(cause)}`)
|
||||
}
|
||||
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? `;${failures.join(';')}` : ''}`)
|
||||
}
|
||||
|
||||
export async function exportFiles(paths: string[]) {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
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)))
|
||||
user.assertSession(version)
|
||||
if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
|
||||
const response = await workspaceApi.exportFiles(subjects)
|
||||
const blob = await response.blob()
|
||||
user.assertSession(version)
|
||||
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) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`导出失败:${cause instanceof Error ? cause.message : String(cause)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { runCommand } from '../../../common/utils/commands'
|
||||
import { toggleSourceMode, ws } from '../workspace'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="ws.active" class="rm-editor-chrome" aria-label="笔记操作">
|
||||
<button class="rm-icon-btn" title="即时预览" aria-label="即时预览" :aria-pressed="!ws.sourceMode" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()">
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
<button class="rm-icon-btn" title="源码模式" aria-label="源码模式" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()">
|
||||
<Icon name="source" />
|
||||
</button>
|
||||
<button class="rm-icon-btn" title="显示 / 隐藏大纲" aria-label="显示 / 隐藏大纲" :aria-pressed="ws.outlineOpen" @click="ws.outlineOpen = !ws.outlineOpen">
|
||||
<Icon name="outline" />
|
||||
</button>
|
||||
<button class="rm-icon-btn" title="专注写作" aria-label="专注写作" :aria-pressed="ws.focusMode" @click="runCommand('view.focus')">
|
||||
<Icon name="expand" />
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import type { StateEffect, Text } from '@codemirror/state'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { createEditorView } from '../editor'
|
||||
import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, ws } from '../workspace'
|
||||
|
||||
const host = ref<HTMLElement>()
|
||||
const tab = computed(activeTab)
|
||||
let view: EditorView
|
||||
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置。
|
||||
// 优先用滚动快照(锚定文档位置):重设状态后视口外的行高会重新估算,单靠 scrollTop 会偏移几行;
|
||||
// 快照只对同一文档有效,笔记在后台被外部修改时退回 scrollTop
|
||||
interface ScrollPosition {
|
||||
doc: Text
|
||||
snapshot: StateEffect<unknown>
|
||||
top: number
|
||||
}
|
||||
let shown: { path: string } | undefined
|
||||
const scrollPositions = new WeakMap<object, ScrollPosition>()
|
||||
|
||||
function show() {
|
||||
const path = ws.active
|
||||
const ref = path ? noteRefOf(path) : undefined
|
||||
const state = path ? editorStateOf(path) : undefined
|
||||
if (shown && shown !== ref) {
|
||||
storeViewState(shown.path, view.state)
|
||||
scrollPositions.set(shown, { doc: view.state.doc, snapshot: view.scrollSnapshot(), top: view.scrollDOM.scrollTop })
|
||||
}
|
||||
if (!ref || !state) {
|
||||
shown = undefined
|
||||
return
|
||||
}
|
||||
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
|
||||
if (shown !== ref || state.doc !== view.state.doc) {
|
||||
view.setState(state)
|
||||
const pos = scrollPositions.get(ref)
|
||||
// 等一帧:v-show 在此回调之后才取消 display:none,隐藏状态下无法滚动和聚焦
|
||||
requestAnimationFrame(() => {
|
||||
if (shown !== ref || revealPending(view, ref.path)) return
|
||||
if (pos?.doc === view.state.doc) view.dispatch({ effects: pos.snapshot })
|
||||
else view.scrollDOM.scrollTop = pos?.top ?? 0
|
||||
})
|
||||
}
|
||||
shown = ref
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
view = createEditorView(host.value!, EditorState.create())
|
||||
attachView(view)
|
||||
document.fonts.addEventListener('loadingdone', measure)
|
||||
show()
|
||||
})
|
||||
function measure() {
|
||||
view.requestMeasure()
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
document.fonts.removeEventListener('loadingdone', measure)
|
||||
if (shown) storeViewState(shown.path, view.state)
|
||||
attachView(null)
|
||||
view.destroy()
|
||||
})
|
||||
watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => {
|
||||
show(); requestAnimationFrame(() => view.requestMeasure())
|
||||
}, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-editor-pane">
|
||||
<div v-show="tab && tab.status !== 'loading' && tab.status !== 'error'" ref="host" class="rm-editor-host" />
|
||||
<div v-if="!tab" class="rm-empty">
|
||||
<p>选择或新建一篇笔记</p>
|
||||
</div>
|
||||
<div v-else-if="tab.status === 'error'" class="rm-empty">
|
||||
<p>笔记打开失败</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { basename, dirname, isImage, joinPath } from '../../../common/utils/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">
|
||||
<el-button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">
|
||||
上一张
|
||||
</el-button>
|
||||
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
|
||||
<el-button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">
|
||||
下一张
|
||||
</el-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>
|
||||
<el-button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
|
||||
重命名
|
||||
</el-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">
|
||||
<el-button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">
|
||||
移动到…
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">
|
||||
移入回收站
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">
|
||||
{{ busy ? '处理中…' : '替换图片' }}
|
||||
</el-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>
|
||||
@@ -0,0 +1,645 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entry } from '@@/apis/vault/type'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { basename, hiddenAssetPath, imageInFolder, isImage } from '../../../common/utils/paths'
|
||||
import { metadata, refreshMetadata } from '../metadata'
|
||||
import { cleanupImages, imageUrl, openReference, 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><el-button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">
|
||||
返回笔记
|
||||
</el-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><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
|
||||
清理孤立图片
|
||||
</el-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"><el-button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()">
|
||||
<Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p v-if="metadata.error || !certain" class="rm-media-warning" role="status">
|
||||
{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <el-button class="rm-btn" @click="refreshMetadata()">
|
||||
刷新状态
|
||||
</el-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><el-button class="rm-btn rm-btn-primary" @click="ws.image = current.path">
|
||||
<Icon name="edit" />预览与编辑
|
||||
</el-button><h3>引用此图片的笔记</h3><button v-for="reference in currentRefs" :key="reference.path" class="rm-media-reference" @click="ws.images = null; openReference(reference.path, reference.line)">
|
||||
<Icon name="note" /><span>{{ reference.title }}<small>{{ reference.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">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
|
||||
取消
|
||||
</el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">
|
||||
移入回收站({{ cleanupSelected.length }})
|
||||
</el-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: 0.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: 0.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>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImportResult } from '@/pages/workspace/apis/type'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { dirname } from '../../../common/utils/paths'
|
||||
import { expanded } from '../tree'
|
||||
import { importNotes } from '../workspace'
|
||||
|
||||
const props = defineProps<{ dir: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const files = ref<File[]>([])
|
||||
const folder = ref('')
|
||||
const names = ref<string[]>([])
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<ImportResult | null>(null)
|
||||
const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name))
|
||||
|
||||
const dialog = ref<HTMLElement>()
|
||||
const validation = computed(() => {
|
||||
if (!zipped.value && files.value.some(f => !f.name.endsWith('.md'))) return '请选择 .md 文件,或单独选择一个 ZIP'
|
||||
if (files.value.reduce((sum, file) => sum + file.size, 0) > 100_000_000) return '导入文件总量不能超过 100 MB'
|
||||
return ''
|
||||
})
|
||||
onMounted(() => dialog.value?.focus())
|
||||
|
||||
function choose(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
files.value = [...(input.files ?? [])]
|
||||
names.value = files.value.map(f => f.name)
|
||||
error.value = ''
|
||||
result.value = null
|
||||
if (zipped.value) folder.value = files.value[0].name.replace(/(?:\.md)?\.zip$/i, '')
|
||||
else folder.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!busy.value) emit('close')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (busy.value || !files.value.length || validation.value || result.value) return
|
||||
if (zipped.value && !folder.value.trim()) {
|
||||
error.value = '请输入导入文件夹名称'; return
|
||||
}
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// 支持修改普通 Markdown 的目标文件名,以便处理同名冲突。
|
||||
const uploads = zipped.value ? files.value : files.value.map((file, i) => new File([file], names.value[i], { type: file.type }))
|
||||
result.value = await importNotes(props.dir, zipped.value ? folder.value.trim() : '', uploads)
|
||||
for (const p of result.value.paths) {
|
||||
for (let d = zipped.value ? p : dirname(p); d; d = dirname(d)) expanded.add(d)
|
||||
}
|
||||
// 导入文件成功后释放浏览器持有的文件,结果仍保留在弹窗中。
|
||||
files.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '网络错误,导入结果尚未确认,请检查目标目录后再重试'
|
||||
} 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" tabindex="-1" role="dialog" aria-modal="true" aria-label="导入 Markdown" :aria-busy="busy">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>导入 Markdown</h2>
|
||||
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form class="rm-import-body" @submit.prevent="submit">
|
||||
<p class="rm-import-target">
|
||||
导入到:{{ dir || '根目录' }}
|
||||
</p>
|
||||
<template v-if="!result">
|
||||
<label>选择 Markdown 文件或一个 ZIP
|
||||
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose">
|
||||
</label>
|
||||
<label v-if="zipped">新建文件夹名称
|
||||
<input v-model="folder" :disabled="busy" required @input="error = ''">
|
||||
</label>
|
||||
<div v-else-if="files.length" class="rm-import-files">
|
||||
<label v-for="(file, i) in files" :key="i">{{ file.name }}
|
||||
<input v-model="names[i]" :disabled="busy" required aria-label="导入文件名称" @input="error = ''">
|
||||
</label>
|
||||
</div>
|
||||
<p class="rm-import-hint">
|
||||
总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。
|
||||
</p>
|
||||
</template>
|
||||
<div v-if="result" role="status">
|
||||
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
|
||||
<details v-if="result.warnings.length" class="rm-import-warnings">
|
||||
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
|
||||
<ul>
|
||||
<li v-for="warning in result.warnings" :key="warning">
|
||||
{{ warning }}
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<p v-if="error || validation" class="rm-auth-error" role="alert">
|
||||
{{ error || validation }}
|
||||
</p>
|
||||
<p v-if="busy" role="status">
|
||||
正在上传并整理,请勿关闭页面…
|
||||
</p>
|
||||
<div class="rm-import-actions">
|
||||
<el-button native-type="button" class="rm-btn" :disabled="busy" @click="close">
|
||||
{{ result ? '完成' : '取消' }}
|
||||
</el-button>
|
||||
<el-button v-if="!result" native-type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">
|
||||
{{ busy ? '导入中…' : '导入' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-import-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 8px 20px 20px;
|
||||
overflow-y: auto;
|
||||
font-size: var(--rm-ui-font-size);
|
||||
}
|
||||
.rm-import-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.rm-import-body label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rm-import-body input {
|
||||
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-import-target,
|
||||
.rm-import-hint {
|
||||
color: var(--rm-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rm-import-hint {
|
||||
font-size: var(--rm-ui-font-small);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rm-import-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.rm-import-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.rm-import-warnings {
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
.rm-import-body input {
|
||||
font-size: max(16px, var(--rm-ui-font-size));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/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) * 0.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><el-button class="rm-btn" @click="ws.view = 'notes'">
|
||||
返回笔记
|
||||
</el-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 }} <el-button class="rm-btn" @click="refreshMetadata">
|
||||
重试
|
||||
</el-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: 0.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: 0.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>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '../../../common/utils/paths'
|
||||
import { batchFiles } from '../batch'
|
||||
import { fuzzyMatch, segments } from '../fuzzy'
|
||||
import { minimalPaths } from '../selection'
|
||||
import { move, ws } from '../workspace'
|
||||
|
||||
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
|
||||
const props = defineProps<{ path: string | string[] }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
const list = ref<HTMLElement>()
|
||||
const query = ref('')
|
||||
const selected = ref(0)
|
||||
const paths = computed(() => minimalPaths(typeof props.path === 'string' ? [props.path] : props.path))
|
||||
const title = computed(() => paths.value.length > 1 ? `${paths.value.length} 项` : noteTitle(paths.value[0]))
|
||||
|
||||
const targets = computed(() => {
|
||||
const inside = (dir: string) => paths.value.some(path => ws.entries.some(entry => entry.path === path && entry.dir) && (dir === path || dir.startsWith(`${path}/`)))
|
||||
return ['', ...ws.entries.filter(entry => entry.dir && !inAssetDir(entry.path)).map(entry => entry.path)]
|
||||
.filter(dir => !inside(dir) && !paths.value.every(path => dirname(path) === dir))
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
const q = query.value.trim()
|
||||
if (!q) return [...targets.value].sort((a, b) => a.localeCompare(b, 'zh-CN')).map(path => ({ path, hits: new Set<number>() }))
|
||||
return targets.value
|
||||
.filter(p => p !== '')
|
||||
.map(path => ({ path, m: fuzzyMatch(q, path, path.lastIndexOf('/') + 1) }))
|
||||
.filter(x => x.m)
|
||||
.sort((a, b) => b.m!.score - a.m!.score)
|
||||
.map(x => ({ path: x.path, hits: new Set(x.m!.positions) }))
|
||||
})
|
||||
|
||||
watch(query, () => (selected.value = 0))
|
||||
watch(selected, i => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
|
||||
|
||||
function choose(i: number) {
|
||||
const target = items.value[i]
|
||||
if (!target) return
|
||||
emit('close')
|
||||
if (paths.value.length === 1) void move(paths.value[0], joinPath(target.path, basename(paths.value[0])))
|
||||
else void batchFiles('move', paths.value, target.path)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const n = items.value.length
|
||||
if (e.key === 'Escape') emit('close')
|
||||
else if (e.key === 'ArrowDown' && n) selected.value = (selected.value + 1) % n
|
||||
else if (e.key === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
|
||||
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 触屏设备上不自动弹出键盘,文件夹不多时直接点选更方便
|
||||
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-switcher-backdrop" @mousedown.self="emit('close')">
|
||||
<div class="rm-switcher" role="dialog" :aria-label="`移动“${title}”`">
|
||||
<div class="rm-search-box">
|
||||
<Icon name="folder" />
|
||||
<input ref="input" v-model="query" class="rm-search-input" :placeholder="`把“${title}”移动到…`" enterkeyhint="go" @keydown="onKeydown">
|
||||
<button class="rm-icon-btn" title="取消" @click="emit('close')">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="items.length" ref="list" class="rm-switcher-list">
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
:key="item.path"
|
||||
class="rm-switcher-item"
|
||||
:class="{ active: i === selected }"
|
||||
@click="choose(i)"
|
||||
@mousemove="selected = i"
|
||||
>
|
||||
<Icon name="folder" />
|
||||
<span class="rm-switcher-title">
|
||||
<template v-if="item.path === ''">根目录</template>
|
||||
<span v-for="(s, j) in segments(item.path, item.hits)" v-else :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="rm-tree-empty">
|
||||
没有可移动到的文件夹
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { activeTab, keepLocalVersion, retrySave, useServerVersion } from '../workspace'
|
||||
|
||||
const tab = computed(activeTab)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="tab?.status === 'conflict'" class="rm-banner warn">
|
||||
<span>这篇笔记已在其他地方被修改,与你的修改冲突。</span>
|
||||
<el-button class="rm-btn" @click="useServerVersion(tab.path)">
|
||||
载入服务器版本
|
||||
</el-button>
|
||||
<el-button class="rm-btn" @click="keepLocalVersion(tab.path)">
|
||||
保留我的修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else-if="tab?.status === 'deleted'" class="rm-banner warn">
|
||||
<span>这篇笔记已在其他地方被删除或移动。</span>
|
||||
<el-button class="rm-btn" @click="keepLocalVersion(tab.path)">
|
||||
重新保存
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else-if="tab?.saveError" class="rm-banner error">
|
||||
<span>{{ tab.saveError }}</span>
|
||||
<el-button class="rm-btn" @click="retrySave(tab.path)">
|
||||
立即重试
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
|
||||
|
||||
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
|
||||
interface Heading {
|
||||
level: number
|
||||
text: string
|
||||
from: number
|
||||
}
|
||||
|
||||
const headings = ref<Heading[]>([])
|
||||
const current = ref(-1)
|
||||
|
||||
function collect(state: EditorState): Heading[] {
|
||||
// 长文档的语法树可能尚未解析完,最多等 50ms,之后随编辑更新
|
||||
const tree = ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state)
|
||||
const out: Heading[] = []
|
||||
tree.iterate({
|
||||
enter(n) {
|
||||
const m = /^(ATX|Setext)Heading(\d)$/.exec(n.name)
|
||||
if (!m) return
|
||||
const line = state.doc.lineAt(n.from)
|
||||
const text = m[1] === 'ATX' ? line.text.replace(/^\s*#{1,6}\s*/, '').replace(/\s+#+\s*$/, '') : line.text.trim()
|
||||
out.push({ level: Number(m[2]), text: text || '(无标题)', from: n.from })
|
||||
return false
|
||||
},
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastDoc: unknown
|
||||
function refresh() {
|
||||
const v = editorView()
|
||||
if (!v || activeTab()?.status !== 'ready') {
|
||||
headings.value = []
|
||||
lastDoc = undefined
|
||||
return
|
||||
}
|
||||
// 只移动光标时不重新收集标题
|
||||
if (v.state.doc !== lastDoc) {
|
||||
headings.value = collect(v.state)
|
||||
lastDoc = v.state.doc
|
||||
}
|
||||
const head = v.state.selection.main.head
|
||||
let i = headings.value.length - 1
|
||||
while (i >= 0 && headings.value[i].from > head) i--
|
||||
current.value = i
|
||||
}
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(refresh, 150)
|
||||
}
|
||||
|
||||
function jump(h: Heading) {
|
||||
const v = editorView()
|
||||
if (!v) return
|
||||
v.dispatch({ selection: { anchor: h.from }, effects: EditorView.scrollIntoView(h.from, { y: 'start', yMargin: 16 }) })
|
||||
if (matchMedia('(max-width: 768px)').matches) ws.outlineOpen = false
|
||||
else v.focus()
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined
|
||||
onMounted(() => {
|
||||
unsubscribe = onEditorUpdate(scheduleRefresh)
|
||||
// 切换标签页时编辑器整体替换状态,不经过更新监听;等编辑器面板换好状态后再读
|
||||
requestAnimationFrame(refresh)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe?.()
|
||||
clearTimeout(timer)
|
||||
})
|
||||
watch(
|
||||
() => [ws.active, activeTab()?.status],
|
||||
() => requestAnimationFrame(refresh),
|
||||
)
|
||||
const minLevel = () => Math.min(...headings.value.map(h => h.level))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-outline-backdrop" @click.self="ws.outlineOpen = false">
|
||||
<aside class="rm-outline" aria-label="大纲">
|
||||
<div class="rm-outline-head">
|
||||
<span>大纲</span>
|
||||
<button class="rm-icon-btn" title="关闭大纲" @click="ws.outlineOpen = false">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="headings.length" class="rm-outline-list">
|
||||
<li
|
||||
v-for="(h, i) in headings"
|
||||
:key="h.from"
|
||||
:class="{ on: i === current }"
|
||||
:style="{ paddingLeft: `${(h.level - minLevel()) * 14 + 12}px` }"
|
||||
@mousedown.prevent
|
||||
@click="jump(h)"
|
||||
>
|
||||
{{ h.text }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="rm-tree-empty">
|
||||
没有标题
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { availableCommands, commandKeys, formatKeys } from '../../../common/utils/commands'
|
||||
import { dirname, NOTE_EXT, noteTitle } from '../../../common/utils/paths'
|
||||
import { fuzzyMatch, segments } from '../fuzzy'
|
||||
import { createNoteAt, openAt, ws } from '../workspace'
|
||||
|
||||
const MAX_ITEMS = 50
|
||||
const RECENT_COMMANDS_KEY = 'rawmark.recentCommands'
|
||||
const RECENT_COMMANDS_MAX = 10
|
||||
|
||||
interface Item {
|
||||
key: string
|
||||
icon: 'note' | 'noteAdd' | 'command'
|
||||
title: string
|
||||
// 笔记为所在目录,命令为快捷键
|
||||
detail: string
|
||||
// 命中字符在标题、目录中的下标
|
||||
titleHits: Set<number>
|
||||
detailHits: Set<number>
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
const list = ref<HTMLElement>()
|
||||
const query = ref(ws.switcher ?? '')
|
||||
const selected = ref(0)
|
||||
// 以 > 开头时列出命令(与 VS Code 一致),删掉 > 回到找笔记
|
||||
const commandMode = computed(() => query.value.startsWith('>'))
|
||||
|
||||
// 取消时把焦点还给打开前的元素(通常是编辑器)
|
||||
const previousFocus = document.activeElement as HTMLElement | null
|
||||
function close() {
|
||||
ws.switcher = null
|
||||
}
|
||||
function cancel() {
|
||||
close()
|
||||
previousFocus?.focus()
|
||||
}
|
||||
|
||||
function noteItems(q: string): Item[] {
|
||||
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
|
||||
const notes = ws.entries.filter(e => !e.dir && e.path.endsWith(NOTE_EXT))
|
||||
const toItem = (path: string, positions: number[] = []): Item => {
|
||||
const dir = dirname(path)
|
||||
const offset = dir ? dir.length + 1 : 0
|
||||
return {
|
||||
key: path,
|
||||
icon: 'note',
|
||||
title: noteTitle(path),
|
||||
detail: dir,
|
||||
titleHits: new Set(positions.filter(p => p >= offset).map(p => p - offset)),
|
||||
detailHits: new Set(positions.filter(p => p < offset)),
|
||||
run: () => void openAt(path),
|
||||
}
|
||||
}
|
||||
if (!q) {
|
||||
// 最近打开的在前(当前笔记除外,回车即回到上一篇),其余按修改时间
|
||||
const rank = (p: string) => recentRank.get(p) ?? Infinity
|
||||
const mtime = new Map(notes.map(e => [e.path, e.mtime]))
|
||||
return notes
|
||||
.map(e => e.path)
|
||||
.filter(p => p !== ws.active)
|
||||
.sort((a, b) => rank(a) - rank(b) || mtime.get(b)! - mtime.get(a)!)
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map(p => toItem(p))
|
||||
}
|
||||
const scored: { path: string, score: number, positions: number[] }[] = []
|
||||
for (const e of notes) {
|
||||
const target = e.path.slice(0, -NOTE_EXT.length)
|
||||
const m = fuzzyMatch(q, target, target.lastIndexOf('/') + 1)
|
||||
if (m) scored.push({ path: e.path, ...m })
|
||||
}
|
||||
scored.sort(
|
||||
(a, b) => b.score - a.score || (recentRank.get(a.path) ?? Infinity) - (recentRank.get(b.path) ?? Infinity) || a.path.length - b.path.length,
|
||||
)
|
||||
const items = scored.slice(0, MAX_ITEMS).map(s => toItem(s.path, s.positions))
|
||||
// 没有同名笔记时提供"新建";输入中的 / 表示目录
|
||||
const name = q.replace(/\.md$/i, '')
|
||||
const path = name + NOTE_EXT
|
||||
if (
|
||||
name.split('/').every(seg => seg.trim() && !seg.startsWith('.'))
|
||||
&& !ws.entries.some(e => e.path.toLowerCase() === path.toLowerCase())
|
||||
) {
|
||||
items.push({
|
||||
key: '\0create',
|
||||
icon: 'noteAdd',
|
||||
title: `新建笔记“${name}”`,
|
||||
detail: '',
|
||||
titleHits: new Set(),
|
||||
detailHits: new Set(),
|
||||
run: () =>
|
||||
void createNoteAt(path).then((ok) => {
|
||||
if (ok) void openAt(path)
|
||||
}),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const recentCommands = ref<string[]>(JSON.parse(localStorage.getItem(RECENT_COMMANDS_KEY) ?? '[]'))
|
||||
|
||||
function commandItems(q: string): Item[] {
|
||||
const rank = (id: string) => {
|
||||
const i = recentCommands.value.indexOf(id)
|
||||
return i < 0 ? Infinity : i
|
||||
}
|
||||
const scored = availableCommands()
|
||||
.filter(c => c.id !== 'app.commandPalette')
|
||||
.map((c, order) => ({ c, order, m: q ? fuzzyMatch(q, c.title, 0) : { score: 0, positions: [] } }))
|
||||
.filter(x => x.m)
|
||||
// 无输入时最近用过的在前,其余保持注册顺序
|
||||
scored.sort((a, b) => b.m!.score - a.m!.score || rank(a.c.id) - rank(b.c.id) || a.order - b.order)
|
||||
return scored.map(({ c, m }) => ({
|
||||
key: c.id,
|
||||
icon: 'command',
|
||||
title: c.title,
|
||||
detail: commandKeys(c).length ? formatKeys(commandKeys(c)[0]) : '',
|
||||
titleHits: new Set(m!.positions),
|
||||
detailHits: new Set(),
|
||||
run: () => {
|
||||
recentCommands.value = [c.id, ...recentCommands.value.filter(id => id !== c.id)].slice(0, RECENT_COMMANDS_MAX)
|
||||
localStorage.setItem(RECENT_COMMANDS_KEY, JSON.stringify(recentCommands.value))
|
||||
c.run()
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const items = computed(() => (commandMode.value ? commandItems(query.value.slice(1).trim()) : noteItems(query.value.trim())))
|
||||
|
||||
watch(query, () => (selected.value = 0))
|
||||
watch(selected, i => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
|
||||
|
||||
function choose(i: number) {
|
||||
const item = items.value[i]
|
||||
close()
|
||||
item?.run()
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const n = items.value.length
|
||||
if (e.key === 'Escape') cancel()
|
||||
else if (e.key === 'ArrowDown' && n) selected.value = (selected.value + 1) % n
|
||||
else if (e.key === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
|
||||
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => input.value?.focus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-switcher-backdrop" @mousedown.self="cancel">
|
||||
<div class="rm-switcher" role="dialog" :aria-label="commandMode ? '命令面板' : '快速打开'">
|
||||
<div class="rm-search-box">
|
||||
<Icon :name="commandMode ? 'command' : 'search'" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
class="rm-search-input"
|
||||
:placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'"
|
||||
enterkeyhint="go"
|
||||
@keydown="onKeydown"
|
||||
@blur="close"
|
||||
>
|
||||
</div>
|
||||
<ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent>
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
:key="item.key"
|
||||
class="rm-switcher-item"
|
||||
:class="{ active: i === selected }"
|
||||
@click="choose(i)"
|
||||
@mousemove="selected = i"
|
||||
>
|
||||
<Icon :name="item.icon" />
|
||||
<span class="rm-switcher-title">
|
||||
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
</span>
|
||||
<span v-if="item.detail" class="rm-switcher-dir" :class="{ 'rm-switcher-keys': item.icon === 'command' }">
|
||||
<span v-for="(s, j) in segments(item.detail, item.detailHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="rm-tree-empty">
|
||||
{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { SearchResult } from '@/pages/workspace/apis/type'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { workspaceApi } from '@/pages/workspace/apis'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { dirname, noteTitle } from '../../../common/utils/paths'
|
||||
import { openAt, ws } from '../workspace'
|
||||
|
||||
const SEARCH_DELAY = 150
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
const query = ref('')
|
||||
const results = ref<SearchResult[] | null>(null)
|
||||
const selected = ref(0)
|
||||
const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
|
||||
|
||||
// 只采用最后一次请求的结果,避免慢请求覆盖新结果
|
||||
let seq = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(query, (q) => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => void run(q), SEARCH_DELAY)
|
||||
})
|
||||
|
||||
async function run(q: string) {
|
||||
const id = ++seq
|
||||
if (!q.trim()) {
|
||||
results.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await workspaceApi.search(q)
|
||||
if (id !== seq) return
|
||||
results.value = r
|
||||
selected.value = 0
|
||||
} catch {
|
||||
if (id === seq) toast('搜索失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 把文本按搜索词(不区分大小写)切分,命中部分标记为高亮 */
|
||||
function segments(text: string): { text: string, hit: boolean }[] {
|
||||
const lower = text.toLowerCase()
|
||||
const words = terms.value.map(t => t.toLowerCase())
|
||||
const out: { text: string, hit: boolean }[] = []
|
||||
let plain = ''
|
||||
for (let i = 0; i < text.length;) {
|
||||
const len = Math.max(0, ...words.filter(w => lower.startsWith(w, i)).map(w => w.length))
|
||||
if (len) {
|
||||
if (plain) out.push({ text: plain, hit: false })
|
||||
out.push({ text: text.slice(i, i + len), hit: true })
|
||||
plain = ''
|
||||
i += len
|
||||
} else {
|
||||
plain += text[i++]
|
||||
}
|
||||
}
|
||||
if (plain) out.push({ text: plain, hit: false })
|
||||
return out
|
||||
}
|
||||
|
||||
function open(r: SearchResult) {
|
||||
void openAt(r.path, r.line, terms.value)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const list = results.value ?? []
|
||||
if (e.key === 'Escape') ws.searchOpen = false
|
||||
else if (e.key === 'ArrowDown' && list.length) selected.value = (selected.value + 1) % list.length
|
||||
else if (e.key === 'ArrowUp' && list.length) selected.value = (selected.value - 1 + list.length) % list.length
|
||||
else if (e.key === 'Enter' && list[selected.value]) open(list[selected.value])
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => input.value?.focus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-search">
|
||||
<div class="rm-search-box">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
class="rm-search-input"
|
||||
type="search"
|
||||
placeholder="搜索笔记"
|
||||
enterkeyhint="search"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button class="rm-icon-btn" title="关闭搜索" @click="ws.searchOpen = false">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="results?.length" class="rm-search-results">
|
||||
<li
|
||||
v-for="(r, i) in results"
|
||||
:key="r.path"
|
||||
class="rm-search-item"
|
||||
:class="{ active: i === selected, current: r.path === ws.active }"
|
||||
@click="open(r)"
|
||||
@mousemove="selected = i"
|
||||
>
|
||||
<div class="rm-search-title">
|
||||
<span v-for="(s, j) in segments(noteTitle(r.path))" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
<span v-if="dirname(r.path)" class="rm-search-dir">{{ dirname(r.path) }}</span>
|
||||
</div>
|
||||
<div v-if="r.snippet" class="rm-search-snippet">
|
||||
<span v-for="(s, j) in segments(r.snippet)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="results" class="rm-tree-empty">
|
||||
没有找到匹配的笔记
|
||||
</p>
|
||||
<p v-else class="rm-tree-empty">
|
||||
输入关键词,多个词用空格分隔
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorSelection } from '@codemirror/state'
|
||||
import type { InlineStyles } from '../../../common/markdown/inline-styles'
|
||||
import type { FormatCommand } from '../editor/format'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import InlineAppearance from '../../../common/components/InlineAppearance.vue'
|
||||
import { settings } from '../../../pinia/stores/settings'
|
||||
import { insertLink } from '../editor/format'
|
||||
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from '../editor/inline-styles'
|
||||
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
|
||||
|
||||
const root = ref<HTMLElement>()
|
||||
const floating = ref<{ left: number, top: number, maxHeight: number, maxWidth: number } | null>(null)
|
||||
const appearanceOpen = ref(false)
|
||||
const baseSize = ref(settings.fontSize)
|
||||
const appearance = ref<ReturnType<typeof selectionAppearance>>({ styles: {}, underline: false, bold: false, italic: false, strike: false, highlight: false, link: false })
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let frame = 0
|
||||
let dismissed: EditorSelection | undefined
|
||||
const buttons = [
|
||||
{ title: '加粗', label: 'B', cls: 'bold', key: 'bold', run: toggleSelectionMark('StrongEmphasis') },
|
||||
{ title: '斜体', label: 'I', cls: 'italic', key: 'italic', run: toggleSelectionMark('Emphasis') },
|
||||
{ title: '下划线', label: 'U', cls: 'underline', key: 'underline', run: toggleUnderline },
|
||||
{ title: '删除线', label: 'S', cls: 'strike', key: 'strike', run: toggleSelectionMark('Strikethrough') },
|
||||
{ title: '高亮', label: 'M', cls: 'highlight', key: 'highlight', run: toggleSelectionMark('Highlight') },
|
||||
] as const
|
||||
|
||||
function close(restoreFocus = false) {
|
||||
dismissed = editorView()?.state.selection
|
||||
floating.value = null
|
||||
appearanceOpen.value = false
|
||||
if (restoreFocus) editorView()?.focus()
|
||||
}
|
||||
function run(command: FormatCommand, keepPanel = false) {
|
||||
const view = editorView()
|
||||
if (!view || !canFormatSelection(view.state)) return
|
||||
command(view)
|
||||
if (!keepPanel) {
|
||||
appearanceOpen.value = false; view.focus()
|
||||
}
|
||||
sync()
|
||||
}
|
||||
function change(patch: InlineStyles) {
|
||||
run(applyInlineStyles(patch), true)
|
||||
}
|
||||
|
||||
// 保留 CM 选区,允许面板输入框获得焦点;不用 pointerdown.prevent,以免 WebKit 丢 click。
|
||||
function sync() {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(async () => {
|
||||
const view = editorView()
|
||||
if (view && dismissed && !dismissed.eq(view.state.selection)) dismissed = undefined
|
||||
const inToolbar = !!root.value?.contains(document.activeElement)
|
||||
if (!view || !settings.selectionToolbar || !ws.active || activeTab()?.status !== 'ready' || ws.view !== 'notes' || ws.settings || ws.images !== null || (!view.hasFocus && !inToolbar) || !canFormatSelection(view.state) || dismissed?.eq(view.state.selection)) {
|
||||
floating.value = null
|
||||
appearanceOpen.value = false
|
||||
return
|
||||
}
|
||||
dismissed = undefined
|
||||
const coords = view.coordsAtPos(view.state.selection.main.head)
|
||||
const rect = view.scrollDOM.getBoundingClientRect()
|
||||
if (!coords || coords.bottom < rect.top || coords.top > rect.bottom) {
|
||||
floating.value = null; appearanceOpen.value = false; return
|
||||
}
|
||||
appearance.value = selectionAppearance(view.state)
|
||||
const position = view.domAtPos(view.state.selection.main.from).node
|
||||
const line = (position instanceof Element ? position : position.parentElement)!.closest('.cm-line')!
|
||||
baseSize.value = parseFloat(getComputedStyle(line).fontSize)
|
||||
const viewport = window.visualViewport
|
||||
const left = viewport?.offsetLeft ?? 0; const top = viewport?.offsetTop ?? 0
|
||||
const width = viewport?.width ?? innerWidth; const height = viewport?.height ?? innerHeight
|
||||
floating.value ??= { left, top, maxHeight: height - 16, maxWidth: width - 16 }
|
||||
floating.value.maxHeight = Math.max(44, height - 16)
|
||||
floating.value.maxWidth = width - 16
|
||||
await nextTick()
|
||||
if (!root.value || !floating.value) return
|
||||
const box = root.value.getBoundingClientRect()
|
||||
const above = coords.top - box.height - 8
|
||||
floating.value = {
|
||||
left: Math.max(left + 8, Math.min(left + width - box.width - 8, coords.left - box.width / 2)),
|
||||
top: Math.max(top + 8, Math.min(top + height - box.height - 8, above >= Math.max(top, rect.top) ? above : coords.bottom + 8)),
|
||||
maxHeight: Math.max(44, height - 16),
|
||||
maxWidth: width - 16,
|
||||
}
|
||||
})
|
||||
}
|
||||
function outside(event: PointerEvent) {
|
||||
if (event.target instanceof Node && root.value?.contains(event.target)) return
|
||||
if (floating.value) close()
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && floating.value) {
|
||||
event.preventDefault(); event.stopPropagation(); close(true)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
unsubscribe = onEditorUpdate(sync)
|
||||
document.addEventListener('scroll', sync, true)
|
||||
document.addEventListener('selectionchange', sync)
|
||||
document.addEventListener('focusin', sync)
|
||||
document.addEventListener('focusout', sync)
|
||||
document.addEventListener('pointerdown', outside, true)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
window.addEventListener('resize', sync)
|
||||
window.visualViewport?.addEventListener('resize', sync)
|
||||
window.visualViewport?.addEventListener('scroll', sync)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe?.()
|
||||
cancelAnimationFrame(frame)
|
||||
document.removeEventListener('scroll', sync, true)
|
||||
document.removeEventListener('selectionchange', sync)
|
||||
document.removeEventListener('focusin', sync)
|
||||
document.removeEventListener('focusout', sync)
|
||||
document.removeEventListener('pointerdown', outside, true)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
window.removeEventListener('resize', sync)
|
||||
window.visualViewport?.removeEventListener('resize', sync)
|
||||
window.visualViewport?.removeEventListener('scroll', sync)
|
||||
})
|
||||
watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, settings.uiFontSize, ws.settings, ws.images, ws.view], () => {
|
||||
dismissed = undefined; sync()
|
||||
})
|
||||
watch(appearanceOpen, sync)
|
||||
watch(() => ws.active, () => {
|
||||
floating.value = null; appearanceOpen.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="floating" ref="root" class="rm-selection-popover" :style="{ left: `${floating.left}px`, top: `${floating.top}px`, maxHeight: `${floating.maxHeight}px`, maxWidth: `${floating.maxWidth}px` }">
|
||||
<div class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式">
|
||||
<button type="button" title="外观" aria-label="外观" :aria-expanded="appearanceOpen" aria-controls="rm-inline-appearance" @mousedown.prevent @click="appearanceOpen = !appearanceOpen">
|
||||
<Icon name="palette" />
|
||||
</button>
|
||||
<button v-for="button in buttons" :key="button.key" type="button" :title="button.title" :aria-label="button.title" :aria-pressed="appearance[button.key]" :class="button.cls" @mousedown.prevent @click="run(button.run)">
|
||||
{{ button.label }}
|
||||
</button>
|
||||
<button type="button" title="链接" aria-label="链接" :aria-pressed="appearance.link" @mousedown.prevent @click="run(insertLink)">
|
||||
<Icon name="link" />
|
||||
</button>
|
||||
</div>
|
||||
<InlineAppearance v-if="appearanceOpen" id="rm-inline-appearance" :styles="appearance.styles" :base-size="baseSize" @change="change" @clear="run(clearInlineStyles, true)" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import PluginMount from '../../../common/components/PluginMount.vue'
|
||||
import { sidebarViews } from '../../../common/plugins/registry'
|
||||
import { commandTitle } from '../../../common/utils/commands'
|
||||
import { basename } from '../../../common/utils/paths'
|
||||
import { dropDirectory, tree, treeIconTarget } from '../tree'
|
||||
import { createFolder, createNote, move, openSearch, ws } from '../workspace'
|
||||
import SearchPanel from './SearchPanel.vue'
|
||||
import TreeIconPicker from './TreeIconPicker.vue'
|
||||
import TreeNode from './TreeNode.vue'
|
||||
|
||||
const emit = defineEmits<{ logout: [] }>()
|
||||
const rootMenu = ref<{ x: number, y: number } | null>(null)
|
||||
const menuElement = ref<HTMLElement>()
|
||||
async function rootContext(event: MouseEvent) {
|
||||
if (event.target instanceof Element && event.target.closest('.rm-tree-row')) return
|
||||
event.preventDefault()
|
||||
const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
rootMenu.value = event.type === 'click' ? { x: anchor.left, y: anchor.bottom } : { x: event.clientX, y: event.clientY }
|
||||
await nextTick()
|
||||
if (!rootMenu.value || !menuElement.value) return
|
||||
const { width, height } = menuElement.value.getBoundingClientRect()
|
||||
rootMenu.value.x = Math.max(4, Math.min(rootMenu.value.x, innerWidth - width - 4))
|
||||
rootMenu.value.y = Math.max(4, Math.min(rootMenu.value.y, innerHeight - height - 4))
|
||||
}
|
||||
watch(rootMenu, (menu, _previous, onCleanup) => {
|
||||
if (!menu) return
|
||||
const close = () => {
|
||||
rootMenu.value = null
|
||||
}
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') close()
|
||||
}
|
||||
window.addEventListener('resize', close)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('resize', close)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
})
|
||||
|
||||
function runRootMenu(action: 'search' | 'tags' | 'note' | 'folder' | 'import' | 'images') {
|
||||
rootMenu.value = null
|
||||
if (action === 'search') {
|
||||
openSearch()
|
||||
} else if (action === 'tags') {
|
||||
ws.view = 'tags'; ws.images = null; ws.sidebarOpen = false
|
||||
} else if (action === 'note' || action === 'folder') {
|
||||
selectedView.value = ''
|
||||
ws.searchOpen = false
|
||||
void (action === 'note' ? createNote('') : createFolder(''))
|
||||
} else if (action === 'import') {
|
||||
ws.importing = ''
|
||||
} else {
|
||||
ws.imagesAll = false; ws.images = ''
|
||||
}
|
||||
}
|
||||
const selectedView = ref('')
|
||||
const currentView = computed(() => sidebarViews.get(selectedView.value))
|
||||
watch(() => ws.searchOpen, () => {
|
||||
selectedView.value = ''
|
||||
})
|
||||
|
||||
function onRootDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||
if (e.target instanceof Element && e.target.closest('.rm-tree-item')) {
|
||||
dropDirectory.value = null
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
dropDirectory.value = ''
|
||||
}
|
||||
|
||||
function onRootDrop(e: DragEvent) {
|
||||
dropDirectory.value = null
|
||||
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||
if (e.target instanceof Element && e.target.closest('.rm-tree-item')) return
|
||||
e.preventDefault()
|
||||
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
||||
if (!src || !src.includes('/')) return
|
||||
void move(src, basename(src))
|
||||
}
|
||||
|
||||
function clearDrop() {
|
||||
dropDirectory.value = null
|
||||
}
|
||||
function onTreeDragLeave(e: DragEvent) {
|
||||
if (e.currentTarget instanceof Element && e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return
|
||||
clearDrop()
|
||||
}
|
||||
function onDragKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') clearDrop()
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('dragend', clearDrop)
|
||||
window.addEventListener('drop', clearDrop)
|
||||
window.addEventListener('blur', clearDrop)
|
||||
window.addEventListener('keydown', onDragKeydown)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('dragend', clearDrop)
|
||||
window.removeEventListener('drop', clearDrop)
|
||||
window.removeEventListener('blur', clearDrop)
|
||||
window.removeEventListener('keydown', onDragKeydown)
|
||||
clearDrop()
|
||||
treeIconTarget.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-sidebar-inner">
|
||||
<div class="rm-vault-heading">
|
||||
<button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">
|
||||
文件目录
|
||||
</button>
|
||||
</div>
|
||||
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
|
||||
<el-button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">
|
||||
文件
|
||||
</el-button>
|
||||
<el-button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">
|
||||
{{ pluginView.title }}
|
||||
</el-button>
|
||||
</nav>
|
||||
<PluginMount v-if="currentView" :key="selectedView" :view="currentView" />
|
||||
<SearchPanel v-else-if="ws.searchOpen" />
|
||||
<div v-else class="rm-tree" @contextmenu="rootContext" :class="{ drop: dropDirectory === '' }" @dragover="onRootDragOver" @dragleave="onTreeDragLeave" @drop="onRootDrop">
|
||||
<ul class="rm-tree-list">
|
||||
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
||||
</ul>
|
||||
<p v-if="!tree.length" class="rm-tree-empty">
|
||||
还没有笔记
|
||||
</p>
|
||||
</div>
|
||||
<Teleport v-if="rootMenu" to="body">
|
||||
<div class="rm-menu-backdrop rm-context-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" />
|
||||
<div ref="menuElement" class="rm-menu rm-context-menu" :style="{ left: `${rootMenu.x}px`, top: `${rootMenu.y}px` }">
|
||||
<button :title="commandTitle('app.search')" @click="runRootMenu('search')">
|
||||
<Icon name="search" />搜索
|
||||
</button>
|
||||
<button @click="runRootMenu('tags')">
|
||||
<Icon name="tag" />标签索引
|
||||
</button>
|
||||
<button @click="runRootMenu('note')">
|
||||
<Icon name="noteAdd" />新建笔记
|
||||
</button>
|
||||
<button @click="runRootMenu('folder')">
|
||||
<Icon name="folderAdd" />新建目录
|
||||
</button>
|
||||
<button @click="runRootMenu('import')">
|
||||
<Icon name="import" />导入文档
|
||||
</button>
|
||||
<button @click="runRootMenu('images')">
|
||||
<Icon name="image" />图片管理
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
<TreeIconPicker v-if="treeIconTarget" :key="treeIconTarget.path" :target="treeIconTarget" />
|
||||
<div class="rm-sidebar-foot">
|
||||
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'">
|
||||
<Icon name="settings" />设置
|
||||
</button>
|
||||
<button class="rm-foot-btn" @click="emit('logout')">
|
||||
<Icon name="logout" />退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import type { Tab } from '../workspace'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { commandTitle } from '../../../common/utils/commands'
|
||||
import { noteTitle } from '../../../common/utils/paths'
|
||||
import { closeTab, createNote, ws } from '../workspace'
|
||||
|
||||
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
|
||||
const TAB_TYPE = 'application/x-rawmark-tab'
|
||||
const dragging = ref<string | null>(null)
|
||||
const dropAt = ref<{ path: string, after: boolean } | null>(null)
|
||||
|
||||
function onDragStart(e: DragEvent, path: string) {
|
||||
dragging.value = path
|
||||
e.dataTransfer!.setData(TAB_TYPE, path)
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, path: string) {
|
||||
if (!e.dataTransfer?.types.includes(TAB_TYPE)) return
|
||||
e.preventDefault()
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
dropAt.value = { path, after: e.clientX > r.left + r.width / 2 }
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
const from = dragging.value
|
||||
const target = dropAt.value
|
||||
dragging.value = dropAt.value = null
|
||||
if (!from || !target || from === target.path) return
|
||||
e.preventDefault()
|
||||
const tab = ws.tabs.splice(
|
||||
ws.tabs.findIndex(t => t.path === from),
|
||||
1,
|
||||
)[0]
|
||||
const i = ws.tabs.findIndex(t => t.path === target.path) + (target.after ? 1 : 0)
|
||||
ws.tabs.splice(i, 0, tab)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragging.value = dropAt.value = null
|
||||
}
|
||||
|
||||
// ---- 右键菜单 ----
|
||||
const menu = ref<{ tab: Tab, x: number, y: number } | null>(null)
|
||||
|
||||
const menuElement = ref<HTMLElement>()
|
||||
async function openMenu(e: MouseEvent, tab: Tab) {
|
||||
menu.value = { tab, x: e.clientX, y: e.clientY }
|
||||
await nextTick()
|
||||
if (menu.value?.tab !== tab || !menuElement.value) return
|
||||
const { width, height } = menuElement.value.getBoundingClientRect()
|
||||
menu.value.x = Math.max(4, Math.min(e.clientX, innerWidth - width - 4))
|
||||
menu.value.y = Math.max(4, Math.min(e.clientY, innerHeight - height - 4))
|
||||
}
|
||||
|
||||
async function closeMany(keep: (index: number, path: string) => boolean) {
|
||||
const paths = ws.tabs.filter((t, i) => !t.pinned && !keep(i, t.path)).map(t => t.path)
|
||||
menu.value = null
|
||||
for (const p of paths) await closeTab(p)
|
||||
}
|
||||
|
||||
function closeOthers(path: string) {
|
||||
void closeMany((_, p) => p === path)
|
||||
}
|
||||
|
||||
function closeRight(path: string) {
|
||||
const at = ws.tabs.findIndex(t => t.path === path)
|
||||
void closeMany(i => i <= at)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="rm-tabs" :class="{ empty: !ws.tabs.length && !ws.sidebarCollapsed }" role="tablist">
|
||||
<button v-if="ws.sidebarCollapsed" class="rm-icon-btn rm-tabs-side" :title="commandTitle('view.sidebar')" @click="ws.sidebarCollapsed = false">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div
|
||||
v-for="tab in ws.tabs"
|
||||
:key="tab.path"
|
||||
class="rm-tab"
|
||||
:class="{
|
||||
'active': ws.active === tab.path,
|
||||
'dragging': dragging === tab.path,
|
||||
'drop-before': dropAt?.path === tab.path && !dropAt.after,
|
||||
'drop-after': dropAt?.path === tab.path && dropAt.after,
|
||||
}"
|
||||
role="tab"
|
||||
:aria-selected="ws.active === tab.path"
|
||||
:title="tab.path"
|
||||
draggable="true"
|
||||
@click="ws.active = tab.path"
|
||||
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
|
||||
@contextmenu.prevent="openMenu($event, tab)"
|
||||
@dragstart="onDragStart($event, tab.path)"
|
||||
@dragover="onDragOver($event, tab.path)"
|
||||
@drop="onDrop"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span v-if="tab.pinned" class="rm-tab-pin" title="已固定"><Icon name="pin" :size="16" /></span>
|
||||
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
|
||||
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
|
||||
<span class="rm-dirty-dot" />
|
||||
<Icon name="close" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="rm-icon-btn rm-tab-add" title="新建笔记" @click="createNote('')">
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
<template v-if="menu">
|
||||
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
|
||||
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: `${menu.x}px`, top: `${menu.y}px` }">
|
||||
<button @click="menu.tab.pinned = !menu.tab.pinned; menu = null">
|
||||
{{ menu.tab.pinned ? '取消固定' : '固定' }}
|
||||
</button>
|
||||
<button @click="closeTab(menu.tab.path); menu = null">
|
||||
关闭
|
||||
</button>
|
||||
<button @click="closeOthers(menu.tab.path)">
|
||||
关闭其他标签页
|
||||
</button>
|
||||
<button @click="closeRight(menu.tab.path)">
|
||||
关闭右侧标签页
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeIcon } from '@@/apis/vault/type'
|
||||
import type { Gemoji } from 'gemoji'
|
||||
import type { IconName } from '../../../common/utils/icons'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { iconPaths } from '../../../common/utils/icons'
|
||||
import { basename } from '../../../common/utils/paths'
|
||||
import { treeIconTarget } from '../tree'
|
||||
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from '../tree-icon-catalog'
|
||||
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 }} <el-button class="rm-btn" @click="loadEmojis">
|
||||
重试
|
||||
</el-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>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="save(null)">
|
||||
{{ busy ? '正在保存…' : '恢复默认图标' }}
|
||||
</el-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>
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeNode } from '../tree'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { basename, dirname, inAssetDir, isImage, joinPath } from '../../../common/utils/paths'
|
||||
import { batchFiles, exportFiles } from '../batch'
|
||||
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget } from '../tree'
|
||||
import { createFolder, createNote, move, openNote, renameTarget, ws } from '../workspace'
|
||||
|
||||
const props = defineProps<{ node: TreeNode, depth: number }>()
|
||||
|
||||
const image = computed(() => !props.node.dir && isImage(props.node.path))
|
||||
const assetDir = computed(() => props.node.dir && inAssetDir(props.node.path))
|
||||
const isOpen = computed(() => expanded.has(props.node.path))
|
||||
const isActive = computed(() => (image.value ? ws.image : ws.active) === props.node.path)
|
||||
const isRenaming = computed(() => ws.renaming === props.node.path)
|
||||
const menuOpen = ref(false)
|
||||
const row = ref<HTMLElement>()
|
||||
const menuElement = ref<HTMLElement>()
|
||||
const menuPosition = ref({ left: 0, top: 0 })
|
||||
const input = ref<HTMLInputElement>()
|
||||
const draft = ref('')
|
||||
|
||||
// 长按打开菜单:Android 会触发 contextmenu,iOS Safari 不会,统一用触摸计时
|
||||
const LONG_PRESS_MS = 500
|
||||
let pressTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pressStart: { x: number, y: number } | null = null
|
||||
let longPressed = false
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
longPressed = false
|
||||
pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
pressTimer = setTimeout(() => {
|
||||
longPressed = true
|
||||
showMenu()
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (pressStart && Math.hypot(e.touches[0].clientX - pressStart.x, e.touches[0].clientY - pressStart.y) > 10) cancelPress()
|
||||
}
|
||||
|
||||
function cancelPress() {
|
||||
clearTimeout(pressTimer)
|
||||
pressStart = null
|
||||
}
|
||||
|
||||
function activate(event: MouseEvent) {
|
||||
// 长按后松手产生的点击不打开笔记
|
||||
if (longPressed) {
|
||||
longPressed = false
|
||||
return
|
||||
}
|
||||
selectPath(props.node.path, event)
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (props.node.dir) {
|
||||
if (isOpen.value) expanded.delete(props.node.path)
|
||||
else expanded.add(props.node.path)
|
||||
} else if (image.value) {
|
||||
ws.image = props.node.path
|
||||
} else {
|
||||
void openNote(props.node.path)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
isRenaming,
|
||||
async (on) => {
|
||||
if (!on) return
|
||||
draft.value = props.node.dir ? props.node.name : props.node.name.replace(/\.md$/, '')
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function commitRename() {
|
||||
if (!isRenaming.value) return
|
||||
ws.renaming = null
|
||||
const target = renameTarget(props.node.path, draft.value)
|
||||
if (target && target !== props.node.path) void move(props.node.path, target)
|
||||
}
|
||||
|
||||
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'icon' | 'rename' | 'move' | 'delete' | 'export') {
|
||||
menuOpen.value = false
|
||||
const path = props.node.path
|
||||
if (action === 'note' || action === 'folder') {
|
||||
expanded.add(path)
|
||||
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||
} else if (action === 'import') {
|
||||
ws.importing = path
|
||||
} else if (action === 'images') {
|
||||
ws.imagesAll = false; ws.images = path
|
||||
} else if (action === 'icon') {
|
||||
editIcon()
|
||||
} else if (action === 'rename') {
|
||||
ws.renaming = path
|
||||
} else if (action === 'move') {
|
||||
ws.moving = [...selectedPaths]
|
||||
} else if (action === 'export') {
|
||||
void exportFiles([...selectedPaths])
|
||||
} else {
|
||||
void batchFiles('delete', [...selectedPaths])
|
||||
}
|
||||
}
|
||||
|
||||
function editIcon(event?: MouseEvent) {
|
||||
const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget : row.value?.querySelector<HTMLElement>('.rm-tree-icon')
|
||||
if (anchor) treeIconTarget.value = { path: props.node.path, anchor }
|
||||
}
|
||||
|
||||
function showMenu() {
|
||||
contextSelection(props.node.path)
|
||||
menuOpen.value = true
|
||||
}
|
||||
|
||||
watch(menuOpen, async (open, _previous, onCleanup) => {
|
||||
if (!open) return
|
||||
const close = () => {
|
||||
menuOpen.value = false
|
||||
}
|
||||
window.addEventListener('resize', close)
|
||||
onCleanup(() => window.removeEventListener('resize', close))
|
||||
await nextTick()
|
||||
if (!menuOpen.value || !row.value || !menuElement.value) return
|
||||
const anchor = row.value.getBoundingClientRect()
|
||||
const { width, height } = menuElement.value.getBoundingClientRect()
|
||||
menuPosition.value = {
|
||||
left: Math.max(4, Math.min(anchor.right - width, innerWidth - width - 4)),
|
||||
top: Math.max(4, Math.min(anchor.bottom, innerHeight - height - 4)),
|
||||
}
|
||||
})
|
||||
|
||||
function cancelRename() {
|
||||
ws.renaming = null
|
||||
}
|
||||
|
||||
// ---- 拖放移动(桌面端) ----
|
||||
function onDragStart(e: DragEvent) {
|
||||
e.dataTransfer?.setData('application/x-rawmark-path', props.node.path)
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dropDirectory.value = assetDir.value ? null : (props.node.dir ? props.node.path : dirname(props.node.path))
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dropDirectory.value = null
|
||||
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const src = e.dataTransfer.getData('application/x-rawmark-path')
|
||||
if (assetDir.value || !src) return
|
||||
const dir = props.node.dir ? props.node.path : dirname(props.node.path)
|
||||
const to = joinPath(dir, basename(src))
|
||||
if (src !== dir && !dir.startsWith(`${src}/`) && to !== src) void move(src, to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="rm-tree-item">
|
||||
<div
|
||||
ref="row"
|
||||
class="rm-tree-row"
|
||||
:class="{ 'active': isActive, 'selected': selectedPaths.has(node.path), 'drop': node.dir && dropDirectory === node.path, 'menu-open': menuOpen }"
|
||||
:style="{ paddingLeft: `${depth * 14 + 8}px` }"
|
||||
:draggable="!isRenaming && !assetDir"
|
||||
role="treeitem"
|
||||
:aria-selected="selectedPaths.has(node.path)"
|
||||
:aria-expanded="node.dir ? isOpen : undefined"
|
||||
@click="activate"
|
||||
@contextmenu.prevent.stop="showMenu"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
@touchend="cancelPress"
|
||||
@touchcancel="cancelPress"
|
||||
@dragstart="onDragStart"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
|
||||
<button v-if="!isRenaming" type="button" class="rm-tree-icon" :class="{ 'rm-emoji': node.icon?.kind === 'emoji' || (!node.icon && !image) }" :aria-label="`修改图标:${node.name}`" title="修改图标" @click.stop="editIcon" @touchstart.stop @contextmenu.prevent.stop="editIcon">
|
||||
<Icon v-if="node.icon?.kind === 'builtin'" :name="node.icon.value" :size="18" />
|
||||
<template v-else-if="node.icon?.kind === 'emoji'">
|
||||
{{ node.icon.value }}
|
||||
</template>
|
||||
<Icon v-else-if="image" name="image" :size="18" />
|
||||
<template v-else>
|
||||
{{ node.dir ? '📂' : '📄' }}
|
||||
</template>
|
||||
</button>
|
||||
<input
|
||||
v-if="isRenaming"
|
||||
ref="input"
|
||||
v-model="draft"
|
||||
class="rm-tree-input"
|
||||
@click.stop
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.prevent="cancelRename"
|
||||
@blur="commitRename"
|
||||
>
|
||||
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
||||
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="menuOpen" class="rm-menu-backdrop rm-tree-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||
<div v-if="menuOpen" ref="menuElement" class="rm-menu rm-tree-menu" :style="{ left: `${menuPosition.left}px`, top: `${menuPosition.top}px` }" @click.stop>
|
||||
<template v-if="node.dir && !assetDir && selectedPaths.size === 1">
|
||||
<button @click="runMenu('note')">
|
||||
<Icon name="noteAdd" />新建笔记
|
||||
</button>
|
||||
<button @click="runMenu('folder')">
|
||||
<Icon name="folderAdd" />新建文件夹
|
||||
</button>
|
||||
<button @click="runMenu('images')">
|
||||
<Icon name="image" />图片管理
|
||||
</button>
|
||||
<button @click="runMenu('import')">
|
||||
<Icon name="import" />导入
|
||||
</button>
|
||||
</template>
|
||||
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path">
|
||||
<Icon name="image" />预览 / 替换图片
|
||||
</button>
|
||||
<button v-if="selectedPaths.size === 1" @click="runMenu('icon')">
|
||||
<Icon name="palette" />修改图标
|
||||
</button>
|
||||
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')">
|
||||
<Icon name="edit" />重命名
|
||||
</button>
|
||||
<button v-if="!assetDir" @click="runMenu('move')">
|
||||
<Icon name="folder" />移动到…
|
||||
</button>
|
||||
<button @click="runMenu('export')">
|
||||
<Icon name="note" />导出 Markdown
|
||||
</button>
|
||||
<button v-if="!assetDir" class="danger" @click="runMenu('delete')">
|
||||
<Icon name="trash" />删除
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Transition name="rm-tree-expand" @before-leave="($el: Element) => $el.setAttribute('inert', '')" @leave-cancelled="($el: Element) => $el.removeAttribute('inert')">
|
||||
<div v-if="node.dir && isOpen" class="rm-tree-children">
|
||||
<ul class="rm-tree-list">
|
||||
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-tree-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-tree-menu button {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rm-tree-menu-backdrop {
|
||||
z-index: 44;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { watchFontLoading } from '../../../common/appearance/font-loading'
|
||||
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
|
||||
import { createEditorState, createEditorView } from '../editor'
|
||||
import sample from './sample.md?raw'
|
||||
import '@@/appearance/base.css'
|
||||
|
||||
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
|
||||
const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1)
|
||||
const doc = Array.from({ length: Math.max(1, repeat) }).fill(sample).join('\n')
|
||||
|
||||
watchFontLoading()
|
||||
const view = createEditorView(document.getElementById('app')!, createEditorState({ doc }))
|
||||
;(window as unknown as { rawmarkView: typeof view }).rawmarkView = view
|
||||
@@ -0,0 +1,62 @@
|
||||
# RawMark 原型
|
||||
|
||||
这是 **M0 原型**,用来验证 _Live Preview_ 的手感。光标所在的行显示 Markdown 源码,其余行显示渲染效果。
|
||||
|
||||
## 行内语法
|
||||
|
||||
支持 **粗体**、_斜体_、~~删除线~~、`行内代码`、[链接](https://vuejs.org/guide/introduction.html) 和自动链接 <https://example.com>。
|
||||
按住 Ctrl/Cmd 点击链接可以打开。转义字符 \*不会变成斜体\*。
|
||||
|
||||
## 列表
|
||||
|
||||
- 无序列表第一项
|
||||
- 第二项,包含 `code`
|
||||
- 嵌套项
|
||||
|
||||
1. 有序列表
|
||||
2. 第二项
|
||||
|
||||
- [ ] 待办事项(点击复选框切换)
|
||||
- [x] 已完成事项
|
||||
|
||||
> 引用块:纯粹的 Markdown,不做格式转换。
|
||||
> 第二行引用
|
||||
|
||||
---
|
||||
|
||||
## 代码块
|
||||
|
||||
```ts
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
// 计数器
|
||||
const count = ref(0)
|
||||
const double = computed(() => count.value * 2)
|
||||
|
||||
export function increment(step: number = 1): void {
|
||||
count.value += step
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
def fib(n: int) -> int:
|
||||
"""返回第 n 个斐波那契数"""
|
||||
return n if n < 2 else fib(n - 1) + fib(n - 2)
|
||||
```
|
||||
|
||||
```go
|
||||
func main() {
|
||||
fmt.Println("hello, rawmark")
|
||||
}
|
||||
```
|
||||
|
||||
## 图片
|
||||
|
||||

|
||||
|
||||
## 表格(点击单元格直接编辑,Tab 跳格,悬停显示工具栏)
|
||||
|
||||
| 功能 | 状态 |
|
||||
| ------------ | ---- |
|
||||
| Live Preview | 原型 |
|
||||
| 表格 | M2 |
|
||||
@@ -0,0 +1,52 @@
|
||||
// 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复
|
||||
|
||||
export interface Draft {
|
||||
path: string
|
||||
content: string
|
||||
// 本地修改所基于的服务器版本;新建文件时为空字符串
|
||||
baseRev: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | undefined
|
||||
|
||||
function db(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('rawmark', 1)
|
||||
req.onupgradeneeded = () => req.result.createObjectStore('drafts', { keyPath: 'path' })
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
async function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
const transaction = (await db()).transaction('drafts', mode)
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = fn(transaction.objectStore('drafts'))
|
||||
transaction.oncomplete = () => resolve(req.result)
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('本地草稿事务已中止'))
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
export const drafts = {
|
||||
put: (d: Draft) => tx('readwrite', s => s.put(d)),
|
||||
delete: (path: string) => tx('readwrite', s => s.delete(path)),
|
||||
all: () => tx<Draft[]>('readonly', s => s.getAll()),
|
||||
async move(from: string, to: string): Promise<void> {
|
||||
const transaction = (await db()).transaction('drafts', 'readwrite')
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = transaction.objectStore('drafts')
|
||||
const request: IDBRequest<Draft[]> = store.getAll()
|
||||
request.onsuccess = () => {
|
||||
const moving = request.result.filter(draft => draft.path === from || draft.path.startsWith(`${from}/`))
|
||||
for (const draft of moving) store.delete(draft.path)
|
||||
// add 遇到目标路径已有草稿时中止整个事务,不能覆盖另一份未保存内容。
|
||||
for (const draft of moving) store.add({ ...draft, path: to + draft.path.slice(from.length) })
|
||||
}
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onabort = () => reject(new Error('迁移本地草稿事务失败', { cause: transaction.error }))
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView } from '@codemirror/view'
|
||||
import { codeRenderers, notePath, pluginRefresh } from '../../../common/plugins/registry'
|
||||
import { codeContentRange, fenceLanguage } from './codeblocks'
|
||||
import { editorFocused, setEditorFocus } from './focus'
|
||||
import { CodeRendererWidget, MathWidget } from './widgets'
|
||||
|
||||
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
|
||||
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
|
||||
|
||||
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
|
||||
function blockContent(state: EditorState, node: SyntaxNode): { text: string, offset: number, language?: string } | null {
|
||||
if (node.name === 'BlockMath') {
|
||||
const marks = node.getChildren('BlockMathMark')
|
||||
const from = state.doc.lineAt(node.from).to + 1
|
||||
const to = marks.length > 1 ? state.doc.lineAt(marks[marks.length - 1].from).from - 1 : node.to
|
||||
return { text: from <= to ? state.sliceDoc(from, to) : '', offset: Math.min(from, node.to) - node.from }
|
||||
}
|
||||
if (node.name === 'FencedCode') {
|
||||
const info = node.getChild('CodeInfo')
|
||||
if (!info) return null
|
||||
const language = fenceLanguage(state, info.from, info.to).toLowerCase()
|
||||
if (!codeRenderers.has(language)) return null
|
||||
const { from, to } = codeContentRange(state, node)
|
||||
return { text: state.sliceDoc(from, to), offset: from - node.from, language }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildBlocks(state: EditorState): DecorationSet {
|
||||
const out: Range<Decoration>[] = []
|
||||
const sel = state.field(editorFocused) ? state.selection.ranges : []
|
||||
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
|
||||
const content = blockContent(state, node)
|
||||
if (!content) continue
|
||||
const editing = sel.some(r => r.from <= node.to && r.to >= node.from)
|
||||
const widget = (editOffset: number | null) =>
|
||||
node.name === 'BlockMath' ? new MathWidget(content.text, true, editOffset) : new CodeRendererWidget(content.text, editOffset, content.language!, state.facet(notePath).path, codeRenderers.get(content.language!)!)
|
||||
if (editing) out.push(Decoration.widget({ widget: widget(null), block: true, side: 1 }).range(node.to))
|
||||
else out.push(Decoration.replace({ widget: widget(content.offset), block: true }).range(node.from, node.to))
|
||||
}
|
||||
return Decoration.set(out)
|
||||
}
|
||||
|
||||
export const renderedBlocks = StateField.define<DecorationSet>({
|
||||
create: buildBlocks,
|
||||
update(value, tr) {
|
||||
if (
|
||||
tr.docChanged
|
||||
|| tr.selection
|
||||
|| tr.effects.some(e => e.is(setEditorFocus) || e.is(pluginRefresh))
|
||||
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
||||
) {
|
||||
return buildBlocks(tr.state)
|
||||
}
|
||||
return value
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import type { TokenRun } from '../../../common/highlight/client'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { StateEffect, StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import { cachedTokens, requestTokens } from '../../../common/highlight/client'
|
||||
|
||||
interface CodeBlock {
|
||||
// 代码内容(不含围栏行)在文档中的范围
|
||||
from: number
|
||||
to: number
|
||||
lang: string
|
||||
code: string
|
||||
}
|
||||
|
||||
const setBlockTokens = StateEffect.define<{ from: number, to: number, decorations: Range<Decoration>[] }>()
|
||||
|
||||
// 高亮结果作为 StateField 保存并随编辑映射位置:Worker 返回新结果前沿用旧颜色,避免闪烁
|
||||
const codeTokenField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(value, tr) {
|
||||
value = value.map(tr.changes)
|
||||
for (const e of tr.effects) {
|
||||
if (!e.is(setBlockTokens)) continue
|
||||
value = value.update({
|
||||
filterFrom: e.value.from,
|
||||
filterTo: e.value.to,
|
||||
filter: () => false,
|
||||
add: e.value.decorations,
|
||||
sort: true,
|
||||
})
|
||||
}
|
||||
return value
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
})
|
||||
|
||||
const markCache = new Map<string, Decoration>()
|
||||
|
||||
function tokenMark(classes: string): Decoration {
|
||||
let deco = markCache.get(classes)
|
||||
if (!deco) {
|
||||
deco = Decoration.mark({ class: classes })
|
||||
markCache.set(classes, deco)
|
||||
}
|
||||
return deco
|
||||
}
|
||||
|
||||
function tokensToDecorations(block: CodeBlock, run: TokenRun | null): Range<Decoration>[] {
|
||||
const out: Range<Decoration>[] = []
|
||||
if (!run) return out
|
||||
let at = block.from
|
||||
function visit(tokens: TokenRun) {
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
at += token.length
|
||||
} else {
|
||||
const from = at
|
||||
const index = out.length
|
||||
visit(token.content)
|
||||
if (at > from) out.splice(index, 0, tokenMark(token.classes).range(from, at))
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(run)
|
||||
return out
|
||||
}
|
||||
|
||||
export function fenceLanguage(state: EditorState, infoFrom: number, infoTo: number): string {
|
||||
return state.sliceDoc(infoFrom, infoTo).trim().split(/\s/)[0].toLowerCase()
|
||||
}
|
||||
|
||||
// 围栏代码块的内容范围(不含围栏行);有闭合围栏时到闭合行之前为止,否则到块尾
|
||||
export function codeContentRange(state: EditorState, node: SyntaxNode): { from: number, to: number } {
|
||||
const open = state.doc.lineAt(node.from)
|
||||
const marks = node.getChildren('CodeMark')
|
||||
const to = marks.length > 1 ? state.doc.lineAt(node.to).from - 1 : node.to
|
||||
return { from: Math.min(open.to + 1, to), to }
|
||||
}
|
||||
|
||||
export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null {
|
||||
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
|
||||
if (node.name === 'FencedCode') return node
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选
|
||||
export function selectCodeBlock(view: EditorView): boolean {
|
||||
const { state } = view
|
||||
const main = state.selection.main
|
||||
const node = fencedCodeAt(state, main.head)
|
||||
if (!node) return false
|
||||
const { from, to } = codeContentRange(state, node)
|
||||
if (to <= from || main.head < from || main.head > to || (main.from === from && main.to === to)) return false
|
||||
view.dispatch({ selection: { anchor: from, head: to }, userEvent: 'select' })
|
||||
return true
|
||||
}
|
||||
|
||||
function visibleCodeBlocks(view: EditorView): CodeBlock[] {
|
||||
const { state } = view
|
||||
const blocks: CodeBlock[] = []
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter(node) {
|
||||
if (node.name !== 'FencedCode') return
|
||||
const info = node.node.getChild('CodeInfo')
|
||||
if (info) {
|
||||
const { from: start, to: end } = codeContentRange(state, node.node)
|
||||
if (end > start) {
|
||||
blocks.push({ from: start, to: end, lang: fenceLanguage(state, info.from, info.to), code: state.sliceDoc(start, end) })
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
const codeHighlighter = ViewPlugin.fromClass(
|
||||
class {
|
||||
// 已应用到各代码块的结果键,按块起点记录
|
||||
applied = new Map<number, string>()
|
||||
destroyed = false
|
||||
|
||||
constructor(readonly view: EditorView) {
|
||||
this.schedule()
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged) {
|
||||
const mapped = new Map<number, string>()
|
||||
for (const [pos, key] of this.applied) mapped.set(u.changes.mapPos(pos), key)
|
||||
this.applied = mapped
|
||||
}
|
||||
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) this.schedule()
|
||||
}
|
||||
|
||||
// 不能在 update 中同步 dispatch,推迟到微任务
|
||||
schedule() {
|
||||
queueMicrotask(() => this.sync())
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
sync() {
|
||||
if (this.destroyed) return
|
||||
const effects: StateEffect<unknown>[] = []
|
||||
for (const block of visibleCodeBlocks(this.view)) {
|
||||
const key = `${block.lang}\0${block.code}`
|
||||
if (this.applied.get(block.from) === key) continue
|
||||
const cached = cachedTokens(block.lang, block.code)
|
||||
if (cached !== undefined) {
|
||||
this.applied.set(block.from, key)
|
||||
effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
|
||||
} else {
|
||||
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算
|
||||
requestTokens(block.lang, block.code).then(() => this.schedule()).catch((error) => {
|
||||
// 本次内容失败后不随每次光标移动重试,修改代码或语言后重新请求。
|
||||
if (!this.destroyed) this.applied.set(block.from, key)
|
||||
console.error('代码高亮失败', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (effects.length) this.view.dispatch({ effects })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const codeBlockHighlight = [
|
||||
codeTokenField,
|
||||
codeHighlighter,
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { EditorView, ViewUpdate } from '@codemirror/view'
|
||||
import { StateEffect, StateField } from '@codemirror/state'
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
|
||||
// 编辑器焦点状态。块级渲染组件(表格、公式块、Mermaid)由 StateField 提供,拿不到视图的焦点,
|
||||
// 由视图插件把焦点变化同步进状态:获得焦点且光标在块内时显示源码,否则渲染
|
||||
// (打开笔记时光标默认在文首,不应因此展开开头的块)
|
||||
export const setEditorFocus = StateEffect.define<boolean>()
|
||||
|
||||
export const editorFocused = StateField.define<boolean>({
|
||||
create: () => false,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(setEditorFocus)) value = e.value
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
const focusTracker = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(readonly view: EditorView) {
|
||||
this.sync()
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (u.focusChanged) this.sync()
|
||||
}
|
||||
|
||||
// 不能在 update 中同步 dispatch,推迟到微任务
|
||||
sync() {
|
||||
queueMicrotask(() => {
|
||||
const focused = this.view.hasFocus
|
||||
if (this.view.state.field(editorFocused, false) !== undefined && this.view.state.field(editorFocused) !== focused) {
|
||||
this.view.dispatch({ effects: setEditorFocus.of(focused) })
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const editorFocus = [editorFocused, focusTracker]
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { FormatCommand } from './format'
|
||||
import { EditorSelection, EditorState } from '@codemirror/state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic } from './format'
|
||||
|
||||
// 用 | 标记光标、[ ] 标记选区,执行命令后以同样的形式返回结果
|
||||
function run(cmd: FormatCommand, doc: string): string {
|
||||
const ranges: { anchor: number, head: number }[] = []
|
||||
let text = ''
|
||||
let start = -1
|
||||
for (const ch of doc) {
|
||||
if (ch === '|') {
|
||||
ranges.push({ anchor: text.length, head: text.length })
|
||||
} else if (ch === '[' && start < 0 && doc.includes(']')) {
|
||||
start = text.length
|
||||
} else if (ch === ']' && start >= 0) {
|
||||
ranges.push({ anchor: start, head: text.length })
|
||||
start = -2
|
||||
} else {
|
||||
text += ch
|
||||
}
|
||||
}
|
||||
let state = EditorState.create({
|
||||
doc: text,
|
||||
selection: EditorSelection.create(ranges.map(r => EditorSelection.range(r.anchor, r.head))),
|
||||
extensions: EditorState.allowMultipleSelections.of(true),
|
||||
})
|
||||
cmd({ state, dispatch: spec => (state = state.update(spec).state) })
|
||||
let out = state.doc.toString()
|
||||
for (const r of [...state.selection.ranges].reverse()) {
|
||||
out = r.empty ? `${out.slice(0, r.from)}|${out.slice(r.from)}` : `${out.slice(0, r.from)}[${out.slice(r.from, r.to)}]${out.slice(r.to)}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('行内标记', () => {
|
||||
it('加上与去掉', () => {
|
||||
expect(run(toggleBold, 'a [bc] d')).toBe('a **[bc]** d')
|
||||
expect(run(toggleBold, 'a **[bc]** d')).toBe('a [bc] d')
|
||||
expect(run(toggleBold, 'a [**bc**] d')).toBe('a [bc] d')
|
||||
expect(run(toggleCode, 'x|')).toBe('x`|`')
|
||||
})
|
||||
it('粗体与斜体叠加', () => {
|
||||
expect(run(toggleItalic, '**[x]**')).toBe('***[x]***')
|
||||
expect(run(toggleItalic, '***[x]***')).toBe('**[x]**')
|
||||
expect(run(toggleBold, '***[x]***')).toBe('*[x]*')
|
||||
expect(run(toggleBold, '*[x]*')).toBe('***[x]***')
|
||||
})
|
||||
})
|
||||
|
||||
describe('行首标记', () => {
|
||||
it('列表、任务、引用:同类再按去掉,异类替换', () => {
|
||||
expect(run(toggleBlock('bullet'), 'a|')).toBe('- a|')
|
||||
expect(run(toggleBlock('bullet'), '- a|')).toBe('a|')
|
||||
expect(run(toggleBlock('task'), '- a|')).toBe('- [ ] a|')
|
||||
expect(run(toggleBlock('quote'), '## 标题|')).toBe('> 标题|')
|
||||
expect(run(toggleBlock('bullet'), '|')).toBe('- |')
|
||||
})
|
||||
it('多行:有序列表按行编号,保留缩进', () => {
|
||||
expect(run(toggleBlock('ordered'), '[a\n b\nc]')).toBe('[1. a\n 2. b\n3. c]')
|
||||
expect(run(toggleBlock('ordered'), '[1. a\n2. b]')).toBe('[a\nb]')
|
||||
// 部分行已是该类型时统一加上
|
||||
expect(run(toggleBlock('bullet'), '[- a\nb]')).toBe('[- a\n- b]')
|
||||
})
|
||||
it('标题循环', () => {
|
||||
expect(run(cycleHeading, '标题|')).toBe('# 标题|')
|
||||
expect(run(cycleHeading, '# 标题|')).toBe('## 标题|')
|
||||
expect(run(cycleHeading, '### 标题|')).toBe('标题|')
|
||||
expect(run(cycleHeading, '- 项|')).toBe('# 项|')
|
||||
})
|
||||
})
|
||||
|
||||
it('链接', () => {
|
||||
expect(run(insertLink, '看 [文档] 吧')).toBe('看 [文档](|) 吧')
|
||||
expect(run(insertLink, '看|')).toBe('看[|]()')
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ChangeSpec, EditorState, TransactionSpec } from '@codemirror/state'
|
||||
// Markdown 格式命令:快捷键与选区工具栏共用
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
|
||||
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
|
||||
export interface FormatTarget {
|
||||
readonly state: EditorState
|
||||
dispatch: (spec: TransactionSpec) => void
|
||||
}
|
||||
export type FormatCommand = (target: FormatTarget) => boolean
|
||||
|
||||
/**
|
||||
* 切换行内标记(粗体 **、斜体 *、删除线 ~~、行内代码 `):选区两侧或选区首尾已有该标记时去掉,否则加上;
|
||||
* 没有选中文字时插入一对标记,光标放在中间
|
||||
*/
|
||||
export function toggleInline(mark: string): FormatCommand {
|
||||
const n = mark.length
|
||||
const ch = mark[0]
|
||||
// 选区两侧连续的标记字符数;* 同时用于斜体与粗体:*** 两者都有,单数个才有斜体
|
||||
const runLength = (state: EditorState, pos: number, dir: 1 | -1) => {
|
||||
let k = 0
|
||||
while (state.sliceDoc(dir > 0 ? pos + k : pos - k - 1, dir > 0 ? pos + k + 1 : pos - k) === ch) k++
|
||||
return k
|
||||
}
|
||||
const wrapped = (state: EditorState, from: number, to: number) => {
|
||||
const k = Math.min(runLength(state, from, -1), runLength(state, to, 1))
|
||||
return mark === '*' ? k % 2 === 1 : k >= n
|
||||
}
|
||||
return ({ state, dispatch }) => {
|
||||
const tr = state.changeByRange((r) => {
|
||||
if (wrapped(state, r.from, r.to)) {
|
||||
return {
|
||||
changes: [
|
||||
{ from: r.from - n, to: r.from },
|
||||
{ from: r.to, to: r.to + n },
|
||||
],
|
||||
range: EditorSelection.range(r.from - n, r.to - n),
|
||||
}
|
||||
}
|
||||
const text = state.sliceDoc(r.from, r.to)
|
||||
if (text.length >= 2 * n && text.startsWith(mark) && text.endsWith(mark)) {
|
||||
return { changes: { from: r.from, to: r.to, insert: text.slice(n, -n) }, range: EditorSelection.range(r.from, r.to - 2 * n) }
|
||||
}
|
||||
return {
|
||||
changes: [
|
||||
{ from: r.from, insert: mark },
|
||||
{ from: r.to, insert: mark },
|
||||
],
|
||||
range: EditorSelection.range(r.from + n, r.to + n),
|
||||
}
|
||||
})
|
||||
dispatch({ ...tr, scrollIntoView: true, userEvent: 'input.format' })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 行首的块标记:缩进 + 标题 / 任务 / 无序列表 / 有序列表 / 引用(每行只识别一个)
|
||||
const LINE_PREFIX = /^(\s*)(#{1,6} |[-*+] \[[ x]\] |[-*+] |\d+[.)] |> )?/i
|
||||
|
||||
type PrefixKind = 'heading' | 'task' | 'bullet' | 'ordered' | 'quote'
|
||||
|
||||
function kindOf(prefix: string): PrefixKind | null {
|
||||
if (!prefix) return null
|
||||
if (prefix.startsWith('#')) return 'heading'
|
||||
if (prefix.startsWith('>')) return 'quote'
|
||||
if (/^\d/.test(prefix)) return 'ordered'
|
||||
return prefix.includes('[') ? 'task' : 'bullet'
|
||||
}
|
||||
|
||||
/** 改写选区涉及的每一行的行首标记;next 返回新的标记 */
|
||||
function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKind | null)[], index: number) => string): boolean {
|
||||
const lineNumbers = new Set<number>()
|
||||
for (const r of state.selection.ranges) {
|
||||
const to = r.to > r.from && state.doc.lineAt(r.to).from === r.to ? r.to - 1 : r.to
|
||||
for (let l = state.doc.lineAt(r.from).number, end = state.doc.lineAt(to).number; l <= end; l++) lineNumbers.add(l)
|
||||
}
|
||||
const lines = [...lineNumbers].sort((a, b) => a - b).map(l => state.doc.line(l))
|
||||
const matches = lines.map(line => LINE_PREFIX.exec(line.text)!)
|
||||
const kinds = matches.map(m => kindOf(m[2] ?? ''))
|
||||
const specs: ChangeSpec[] = lines.map((line, i) => {
|
||||
const from = line.from + matches[i][1].length
|
||||
const current = matches[i][2] ?? ''
|
||||
return { from, to: from + current.length, insert: next(kinds, i) }
|
||||
})
|
||||
const changes = state.changes(specs)
|
||||
// 光标在行首时随新插入的标记后移;选区则向两端扩展,把新标记包含在内
|
||||
const selection = EditorSelection.create(
|
||||
state.selection.ranges.map((r) => {
|
||||
if (r.empty) return EditorSelection.cursor(changes.mapPos(r.head, 1))
|
||||
const [lo, hi] = [changes.mapPos(r.from, -1), changes.mapPos(r.to, 1)]
|
||||
return r.anchor <= r.head ? EditorSelection.range(lo, hi) : EditorSelection.range(hi, lo)
|
||||
}),
|
||||
state.selection.mainIndex,
|
||||
)
|
||||
dispatch({ changes, selection, scrollIntoView: true, userEvent: 'input.format' })
|
||||
return true
|
||||
}
|
||||
|
||||
/** 切换列表或引用:所选各行都已是该类型时去掉,否则全部改为该类型(替换原有的其他块标记) */
|
||||
export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand {
|
||||
return target =>
|
||||
editPrefixes(target, (kinds, i) => {
|
||||
if (kinds.every(k => k === kind)) return ''
|
||||
return { task: '- [ ] ', bullet: '- ', ordered: `${i + 1}. `, quote: '> ' }[kind]
|
||||
})
|
||||
}
|
||||
|
||||
/** 标题级别循环:正文 → 一级 → 二级 → 三级 → 正文;以第一行为准 */
|
||||
export const cycleHeading: FormatCommand = (target) => {
|
||||
const first = LINE_PREFIX.exec(target.state.doc.lineAt(target.state.selection.main.from).text)![2] ?? ''
|
||||
const level = kindOf(first) === 'heading' ? first.length - 1 : 0
|
||||
const prefix = level >= 3 ? '' : `${'#'.repeat(level + 1)} `
|
||||
return editPrefixes(target, () => prefix)
|
||||
}
|
||||
|
||||
/** 全部选中行已是指定标题时恢复正文,否则设置为指定级别。 */
|
||||
export function toggleHeading(level: number): FormatCommand {
|
||||
return (target) => {
|
||||
const prefix = `${'#'.repeat(level)} `
|
||||
const ranges = target.state.selection.ranges
|
||||
const lines = new Set<number>()
|
||||
for (const range of ranges) {
|
||||
const end = range.to > range.from && target.state.doc.lineAt(range.to).from === range.to ? range.to - 1 : range.to
|
||||
for (let n = target.state.doc.lineAt(range.from).number; n <= target.state.doc.lineAt(end).number; n++) lines.add(n)
|
||||
}
|
||||
const remove = [...lines].every(n => LINE_PREFIX.exec(target.state.doc.line(n).text)![2] === prefix)
|
||||
return editPrefixes(target, () => remove ? '' : prefix)
|
||||
}
|
||||
}
|
||||
|
||||
/** 插入链接:选中文字作为链接文字,光标放到地址处;没有选中时光标放在文字处 */
|
||||
export const insertLink: FormatCommand = ({ state, dispatch }) => {
|
||||
const tr = state.changeByRange((r) => {
|
||||
const text = state.sliceDoc(r.from, r.to)
|
||||
const insert = `[${text}]()`
|
||||
const cursor = text ? r.from + insert.length - 1 : r.from + 1
|
||||
return { changes: { from: r.from, to: r.to, insert }, range: EditorSelection.cursor(cursor) }
|
||||
})
|
||||
dispatch({ ...tr, scrollIntoView: true, userEvent: 'input.format' })
|
||||
return true
|
||||
}
|
||||
|
||||
export const toggleBold = toggleInline('**')
|
||||
export const toggleItalic = toggleInline('*')
|
||||
export const toggleStrike = toggleInline('~~')
|
||||
export const toggleCode = toggleInline('`')
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TransactionSpec } from '@codemirror/state'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { expect, it } from 'vitest'
|
||||
import { toggleHeading } from './format'
|
||||
|
||||
it('标题快捷键设置六级标题,同级再次执行恢复正文,选区末端不修改下一行', () => {
|
||||
let state = EditorState.create({ doc: '第一行\n第二行', selection: { anchor: 0, head: 4 } })
|
||||
const target = { get state() {
|
||||
return state
|
||||
}, dispatch: (spec: TransactionSpec) => {
|
||||
state = state.update(spec).state
|
||||
} }
|
||||
toggleHeading(6)(target)
|
||||
expect(state.doc.toString()).toBe('###### 第一行\n第二行')
|
||||
toggleHeading(6)(target)
|
||||
expect(state.doc.toString()).toBe('第一行\n第二行')
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable no-control-regex -- 粘贴链接必须识别并移除协议中的控制字符。 */
|
||||
import TurndownService from 'turndown'
|
||||
|
||||
export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise<string>, onError: (error: unknown) => void): Promise<string> {
|
||||
// template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
const fragment = template.content
|
||||
const base = fragment.querySelector('base[href]')?.getAttribute('href')
|
||||
fragment.querySelectorAll('script,style,iframe,object,embed,template,meta,link,base').forEach(node => node.remove())
|
||||
if (base) {
|
||||
for (const node of fragment.querySelectorAll('a[href],img[src]')) {
|
||||
const attribute = node.tagName === 'A' ? 'href' : 'src'
|
||||
try {
|
||||
node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href)
|
||||
} catch (error) {
|
||||
onError(new Error('来源基址无效,已保留原链接', { cause: error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images = new Map<string, HTMLImageElement[]>()
|
||||
for (const image of fragment.querySelectorAll('img')) {
|
||||
let src = image.getAttribute('src') ?? ''
|
||||
if (src.startsWith('//')) {
|
||||
src = `https:${src}`; image.setAttribute('src', src)
|
||||
}
|
||||
if (!/^https?:\/\//i.test(src)) continue
|
||||
const group = images.get(src) ?? []
|
||||
group.push(image)
|
||||
images.set(src, group)
|
||||
}
|
||||
const sources = [...images]
|
||||
// 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。
|
||||
for (let offset = 0; offset < sources.length; offset += 3) {
|
||||
await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => {
|
||||
try {
|
||||
const link = await importImage(src)
|
||||
for (const node of nodes) node.setAttribute('src', link)
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const service = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' })
|
||||
service.addRule('strikethrough', { filter: node => ['DEL', 'S', 'STRIKE'].includes(node.tagName), replacement: content => `~~${content}~~` })
|
||||
service.addRule('table', {
|
||||
filter: 'table',
|
||||
replacement: (_content, node) => {
|
||||
const rows = [...node.querySelectorAll('tr')].filter(row => row.closest('table') === node).map(row =>
|
||||
[...row.cells].map(cell =>
|
||||
service.turndown(cell).replace(/\|/g, '\\|').replace(/\n+/g, ' '),
|
||||
),
|
||||
)
|
||||
if (!rows.length) return ''
|
||||
const columns = Math.max(...rows.map(row => row.length))
|
||||
if (!columns) return ''
|
||||
const line = (cells: string[]) => `| ${Array.from({ length: columns }, (_, index) => cells[index] ?? '').join(' | ')} |`
|
||||
return `\n\n${[line(rows[0]), line(Array.from<string>({ length: columns }).fill('---')), ...rows.slice(1).map(line)].join('\n')}\n\n`
|
||||
},
|
||||
})
|
||||
// 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。
|
||||
service.addRule('link', {
|
||||
filter: 'a',
|
||||
replacement: (content, node) => {
|
||||
const href = node.getAttribute('href') ?? ''
|
||||
return href && !/^[\u0000-\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?:javascript|vbscript|data|file):/i.test(href.replace(/[\u0000-\u0020]/g, ''))
|
||||
? `[${content}](<${href.replace(/</g, '%3C').replace(/>/g, '%3E')}>)`
|
||||
: content
|
||||
},
|
||||
})
|
||||
service.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: (_content, node) => {
|
||||
const src = node.getAttribute('src') ?? ''
|
||||
const alt = (node.getAttribute('alt') ?? '').replace(/[[\]\\]/g, '\\$&').replace(/\n/g, ' ')
|
||||
return src && !/^(?:javascript|vbscript|file):/i.test(src.replace(/[\u0000-\u0020]/g, ''))
|
||||
? `.replace(/>/g, '%3E')}>)`
|
||||
: alt
|
||||
},
|
||||
})
|
||||
return service.turndown(fragment)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import type { KeyBinding, ViewUpdate } from '@codemirror/view'
|
||||
import type { MarkdownParser } from '@lezer/markdown'
|
||||
import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
|
||||
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { Language } from '@codemirror/language'
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state'
|
||||
import { drawSelection, EditorView, keymap } from '@codemirror/view'
|
||||
import { typoraExtensions } from '../../../common/markdown/extensions'
|
||||
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../../../common/plugins/registry'
|
||||
import { renderedBlocks } from './blocks'
|
||||
import { codeBlockHighlight } from './codeblocks'
|
||||
import { editorFocus } from './focus'
|
||||
import { livePreview, openLink, resolveImageUrl } from './livepreview'
|
||||
import { defaultEditorKeys } from './shortcuts'
|
||||
import { tableEditing } from './table'
|
||||
import { markdownTheme } from './theme'
|
||||
|
||||
export { insertImageFiles, pasteImages } from './paste'
|
||||
|
||||
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
||||
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
||||
export const pluginCompartment = new Compartment()
|
||||
export const languageCompartment = new Compartment()
|
||||
export const preferencesCompartment = new Compartment()
|
||||
export const previewCompartment = new Compartment()
|
||||
export function previewExtensions(sourceMode: boolean): Extension {
|
||||
return sourceMode ? EditorView.contentAttributes.of({ class: 'cm-rm-source' }) : [livePreview, tableEditing, renderedBlocks]
|
||||
}
|
||||
export function pluginLanguage() {
|
||||
return new Language(
|
||||
markdownLanguage.data,
|
||||
(markdownLanguage.parser as MarkdownParser).configure([typoraExtensions, ...[...markdownSyntax.values()].map(entry => entry.value)]),
|
||||
[],
|
||||
'markdown',
|
||||
)
|
||||
}
|
||||
export function pluginEditorExtensions(): Extension[] {
|
||||
return [...editorExtensions.values()].map(entry => entry.value)
|
||||
}
|
||||
|
||||
export interface EditorStateOptions {
|
||||
doc: string
|
||||
spellcheck?: boolean
|
||||
tabSize?: number
|
||||
sourceMode?: boolean
|
||||
shortcuts?: KeyBinding[]
|
||||
resolveImage?: (src: string) => string
|
||||
openLink?: (href: string) => void
|
||||
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
||||
onChange?: (update: ViewUpdate) => void
|
||||
extensions?: Extension[]
|
||||
}
|
||||
|
||||
// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换
|
||||
export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
const extensions: Extension[] = [
|
||||
history(),
|
||||
drawSelection(),
|
||||
EditorView.lineWrapping,
|
||||
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
||||
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
||||
languageCompartment.of(pluginLanguage()),
|
||||
pluginCompartment.of(pluginEditorExtensions()),
|
||||
EditorView.exceptionSink.of(reportEditorFailure),
|
||||
Prec.high(keymap.of(markdownKeymap)),
|
||||
Prec.high(keymap.of(opts.shortcuts ?? defaultEditorKeys)),
|
||||
markdownTheme,
|
||||
codeBlockHighlight,
|
||||
editorFocus,
|
||||
previewCompartment.of(previewExtensions(opts.sourceMode ?? false)),
|
||||
// 可配置操作不再通过 CM 的默认绑定重复注册。
|
||||
keymap.of(defaultKeymap.filter(binding => binding.run !== selectAll && binding.run !== indentLess && binding.run !== indentMore && binding.run !== selectParentSyntax && binding.run !== toggleComment)),
|
||||
preferencesCompartment.of([
|
||||
EditorState.tabSize.of(opts.tabSize ?? 4),
|
||||
EditorView.contentAttributes.of({ spellcheck: String(opts.spellcheck ?? false) }),
|
||||
]),
|
||||
EditorView.contentAttributes.of({ autocapitalize: 'sentences' }),
|
||||
]
|
||||
if (opts.openLink) extensions.push(openLink.of(opts.openLink))
|
||||
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
||||
if (opts.onChange) {
|
||||
const onChange = opts.onChange
|
||||
extensions.push(EditorView.updateListener.of(u => u.docChanged && onChange(u)))
|
||||
}
|
||||
if (opts.extensions) extensions.push(...opts.extensions)
|
||||
return EditorState.create({ doc: opts.doc, extensions })
|
||||
}
|
||||
|
||||
export function createEditorView(parent: HTMLElement, state: EditorState): EditorView {
|
||||
const view = new EditorView({ parent, state })
|
||||
const dispatch = view.dispatch.bind(view)
|
||||
view.dispatch = (...args: unknown[]) => {
|
||||
try {
|
||||
Reflect.apply(dispatch, view, args)
|
||||
} catch (error) {
|
||||
reportEditorFailure(error)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { TransactionSpec } from '@codemirror/state'
|
||||
import type { FormatCommand } from './format'
|
||||
import { redo, undo } from '@codemirror/commands'
|
||||
import { EditorSelection, EditorState, Transaction } from '@codemirror/state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMarkdown } from '../../../common/markdown/html'
|
||||
import { toggleBold } from './format'
|
||||
import { createEditorState } from './index'
|
||||
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inline-styles'
|
||||
|
||||
function editor(doc: string, text: string = doc) {
|
||||
let state = createEditorState({ doc, extensions: [EditorState.allowMultipleSelections.of(true)] })
|
||||
const select = (text: string) => {
|
||||
const from = state.doc.toString().indexOf(text)
|
||||
if (from < 0) throw new Error('找不到测试选区')
|
||||
state = state.update({ selection: EditorSelection.range(from, from + text.length) }).state
|
||||
}
|
||||
select(text)
|
||||
const target = { get state() {
|
||||
return state
|
||||
}, dispatch(spec: TransactionSpec | Transaction) {
|
||||
state = spec instanceof Transaction ? spec.state : state.update(spec).state
|
||||
} }
|
||||
return { target, select, run(command: FormatCommand) {
|
||||
return command(target)
|
||||
}, get doc() {
|
||||
return state.doc.toString()
|
||||
} }
|
||||
}
|
||||
|
||||
describe('局部外观', () => {
|
||||
it.each([
|
||||
['a\\*b', '*', '\\*'],
|
||||
['a&b', 'amp', '&'],
|
||||
['a*b', '42', '*'],
|
||||
['a*b', 'x2a', '*'],
|
||||
['a:smile:b', 'smile', ':smile:'],
|
||||
['a[^note]b\n\n[^note]: footnote', 'note', '[^note]'],
|
||||
['a<https://example.com>b', 'example', '<https://example.com>'],
|
||||
])('局部选择语法单元时整体设置外观:%s', (doc, selection, atom) => {
|
||||
const e = editor(doc, selection)
|
||||
expect(e.run(applyInlineStyles({ color: '#0000ff' }))).toBe(true)
|
||||
expect(e.doc).toBe(doc.replace(atom, `<span style="color: #0000ff">${atom}</span>`))
|
||||
expect(renderMarkdown(e.doc).replace('<span style="color: #0000ff">', '').replace('</span>', '')).toBe(renderMarkdown(doc))
|
||||
expect(e.target.state.sliceDoc(e.target.state.selection.main.from, e.target.state.selection.main.to)).toBe(selection)
|
||||
e.run(clearInlineStyles)
|
||||
expect(e.doc).toBe(doc)
|
||||
})
|
||||
it('局部选择转义字符后切换下划线,不改变正文含义,且可撤销', () => {
|
||||
const e = editor('a\\*b', '*')
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('a<u>\\*</u>b')
|
||||
expect(renderMarkdown(e.doc)).toBe('<p>a<u>*</u>b</p>\n')
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('a\\*b')
|
||||
expect(undo(e.target)).toBe(true)
|
||||
expect(e.doc).toBe('a<u>\\*</u>b')
|
||||
})
|
||||
it('同一字符实体内多个选区只格式化一次并保留反向选区', () => {
|
||||
const e = editor('&')
|
||||
e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(2, 1), EditorSelection.range(3, 4)]) })
|
||||
e.run(toggleSelectionMark('StrongEmphasis'))
|
||||
expect(e.doc).toBe('**&**')
|
||||
expect(renderMarkdown(e.doc)).toBe('<p><strong>&</strong></p>\n')
|
||||
expect(e.target.state.selection.ranges.map(range => e.target.state.sliceDoc(range.from, range.to))).toEqual(['a', 'p'])
|
||||
expect(e.target.state.selection.main.anchor).toBeGreaterThan(e.target.state.selection.main.head)
|
||||
})
|
||||
it('下划线可切换,并能和 Markdown 粗体叠加', () => {
|
||||
const e = editor('hello')
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('<u>hello</u>')
|
||||
expect(selectionAppearance(e.target.state).underline).toBe(true)
|
||||
e.run(toggleBold)
|
||||
expect(renderMarkdown(e.doc)).toBe('<p><u><strong>hello</strong></u></p>\n')
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('**hello**')
|
||||
})
|
||||
it('重复修改同一选区会更新标签,撤销和重做保留选区', () => {
|
||||
const e = editor('hello')
|
||||
e.run(applyInlineStyles({ color: '#ff0000' }))
|
||||
e.run(applyInlineStyles({ color: '#0000ff' }))
|
||||
e.run(applyInlineStyles({ 'font-size': '1.5em' }))
|
||||
expect(e.doc).toBe('<span style="color: #0000ff; font-size: 1.5em">hello</span>')
|
||||
expect(e.target.state.sliceDoc(e.target.state.selection.main.from, e.target.state.selection.main.to)).toBe('hello')
|
||||
expect(undo(e.target)).toBe(true)
|
||||
expect(redo(e.target)).toBe(true)
|
||||
expect(e.doc).toContain('font-size: 1.5em')
|
||||
})
|
||||
it('修改部分文字,不影响同一标签内其余内容', () => {
|
||||
const e = editor('<span style="color: #ff0000">hello world</span>', 'world')
|
||||
e.run(applyInlineStyles({ color: '#0000ff' }))
|
||||
expect(e.doc).toBe('<span style="color: #ff0000">hello </span><span style="color: #0000ff">world</span>')
|
||||
e.run(clearInlineStyles)
|
||||
expect(e.doc).toBe('<span style="color: #ff0000">hello </span>world')
|
||||
})
|
||||
it('跨列表、引用和链接操作保留 Markdown 结构与地址', () => {
|
||||
const e = editor('- **one**\n- [two](https://example.com)\n\n> three')
|
||||
e.run(applyInlineStyles({ color: '#ff0000' }))
|
||||
expect(e.doc).toBe('- <span style="color: #ff0000">**one**</span>\n- [<span style="color: #ff0000">two</span>](https://example.com)\n\n> <span style="color: #ff0000">three</span>')
|
||||
e.run(clearInlineStyles)
|
||||
expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three')
|
||||
})
|
||||
it('清除嵌套外观时保留下划线、高亮和链接', () => {
|
||||
const e = editor('<span style="color: #ff0000"><u>==one== <span style="font-size: 20px">[two](https://example.com)</span></u></span>')
|
||||
e.run(clearInlineStyles)
|
||||
expect(renderMarkdown(e.doc)).toBe('<p><u><mark>one</mark></u><u> </u><a href="https://example.com"><u>two</u></a></p>\n')
|
||||
})
|
||||
it('代码、公式及 YAML 选区不允许套用样式', () => {
|
||||
for (const doc of ['```js\nvalue\n```', '`value`', '$value$', '---\nvalue: true\n---']) {
|
||||
const e = editor(doc, 'value')
|
||||
expect(canFormatSelection(e.target.state)).toBe(false)
|
||||
expect(e.run(toggleUnderline)).toBe(false)
|
||||
expect(e.doc).toBe(doc)
|
||||
}
|
||||
})
|
||||
it('反向和多选区保留方向与相互独立的样式', () => {
|
||||
const e = editor('one two three')
|
||||
e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(3, 0), EditorSelection.range(8, 13)]) })
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('<u>one</u> two <u>three</u>')
|
||||
expect(e.target.state.selection.main.anchor).toBeGreaterThan(e.target.state.selection.main.head)
|
||||
})
|
||||
it('未渲染的长文末尾也能格式化;默认外观不制造无效编辑', () => {
|
||||
const doc = `${'paragraph\n\n'.repeat(3000)}lastword`
|
||||
const e = editor(doc, 'lastword')
|
||||
expect(e.run(clearInlineStyles)).toBe(false)
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe(`${doc.slice(0, -8)}<u>lastword</u>`)
|
||||
})
|
||||
it('跨段落的 Markdown 格式逐项切换,保留列表和链接', () => {
|
||||
for (const mark of ['StrongEmphasis', 'Emphasis', 'Strikethrough', 'Highlight'] as const) {
|
||||
const e = editor('- one\n- [two](https://example.com)\n\n> three')
|
||||
e.run(toggleSelectionMark(mark))
|
||||
const html = renderMarkdown(e.doc)
|
||||
const tag = { StrongEmphasis: 'strong', Emphasis: 'em', Strikethrough: 'del', Highlight: 'mark' }[mark]
|
||||
expect(html).toContain(`<${tag}>one</${tag}>`)
|
||||
expect(html).toContain(`<a href="https://example.com"><${tag}>two</${tag}></a>`)
|
||||
e.run(toggleSelectionMark(mark))
|
||||
expect(e.doc).toBe('- one\n- [two](https://example.com)\n\n> three')
|
||||
}
|
||||
})
|
||||
it('混合格式选区统一加粗,局部取消不影响剩余文字', () => {
|
||||
const e = editor('**one** two')
|
||||
e.run(toggleSelectionMark('StrongEmphasis'))
|
||||
expect(renderMarkdown(e.doc)).toBe('<p><strong>one</strong> <strong>two</strong></p>\n')
|
||||
e.select('two')
|
||||
e.run(toggleSelectionMark('StrongEmphasis'))
|
||||
expect(renderMarkdown(e.doc)).toBe('<p><strong>one</strong> two</p>\n')
|
||||
const whitespace = editor(' alpha ')
|
||||
whitespace.run(toggleSelectionMark('StrongEmphasis'))
|
||||
expect(whitespace.doc).toBe(' **alpha** ')
|
||||
expect(whitespace.target.state.sliceDoc(whitespace.target.state.selection.main.from, whitespace.target.state.selection.main.to)).toBe(' **alpha** ')
|
||||
})
|
||||
it('多行列表缩进、引用前缀与硬换行保持不变', () => {
|
||||
const e = editor('- first \n continued\n\n> quote\n> next')
|
||||
e.run(applyInlineStyles({ color: '#ff0000' }))
|
||||
expect(e.doc).toBe('- <span style="color: #ff0000">first</span> \n <span style="color: #ff0000">continued</span>\n\n> <span style="color: #ff0000">quote</span>\n> <span style="color: #ff0000">next</span>')
|
||||
const html = renderMarkdown(e.doc)
|
||||
expect(html).toContain('<br />')
|
||||
expect((html.match(/<li>/g) ?? []).length).toBe(1)
|
||||
expect((html.match(/<blockquote>/g) ?? []).length).toBe(1)
|
||||
})
|
||||
it('局部修改保留同一外层格式内未选代码的样式', () => {
|
||||
const bold = editor('**first `code` last**', 'first')
|
||||
bold.run(applyInlineStyles({ color: '#0000ff' }))
|
||||
expect(renderMarkdown(bold.doc)).toContain('<strong><code>code</code></strong>')
|
||||
const color = editor('<span style="color: #ff0000">first `code` last</span>', 'first')
|
||||
color.run(applyInlineStyles({ color: '#0000ff' }))
|
||||
expect(renderMarkdown(color.doc)).toContain('<span style="color: #ff0000"><code>code</code></span>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import type { InlineStyles } from '../../../common/markdown/inline-styles'
|
||||
import type { FormatCommand } from './format'
|
||||
import { ensureSyntaxTree, language } from '@codemirror/language'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
|
||||
|
||||
interface Wrapper { marks: { from: number, to: number }[] }
|
||||
interface Segment {
|
||||
from: number
|
||||
to: number
|
||||
atomic: boolean
|
||||
styles: InlineStyles
|
||||
underline: boolean
|
||||
formats: string[]
|
||||
wrappers: Wrapper[]
|
||||
}
|
||||
const TEXT_CONTAINERS = new Set(['Paragraph', 'TableCell', 'Emphasis', 'StrongEmphasis', 'Strikethrough', 'Highlight', 'Superscript', 'Subscript', 'Underline', 'InlineStyle'])
|
||||
const PROTECTED = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'InlineMath', 'BlockMath', 'FrontMatter', 'HTMLBlock', 'Image'])
|
||||
type InlineMark = 'StrongEmphasis' | 'Emphasis' | 'Strikethrough' | 'Highlight'
|
||||
const MARKS: Record<InlineMark, string> = { StrongEmphasis: '**', Emphasis: '*', Strikethrough: '~~', Highlight: '==' }
|
||||
const MARK_NODES: Record<InlineMark, string> = { StrongEmphasis: 'EmphasisMark', Emphasis: 'EmphasisMark', Strikethrough: 'StrikethroughMark', Highlight: 'HighlightMark' }
|
||||
|
||||
function formattingTree(state: EditorState) {
|
||||
const to = state.selection.ranges.at(-1)!.to
|
||||
// 后台标签页或长文末尾的选区可能尚未完成增量解析;操作不能悄悄漏掉文字。
|
||||
return ensureSyntaxTree(state, to, 50) ?? state.facet(language)!.parser.parse(state.doc.toString())
|
||||
}
|
||||
|
||||
/** 从当前语法树取文字片段,保留 Markdown 的标记、链接地址和块级结构。 */
|
||||
function segments(state: EditorState): Segment[] {
|
||||
const result: Segment[] = []
|
||||
function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) {
|
||||
if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink', 'Escape', 'Entity'].includes(node.name)) {
|
||||
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
|
||||
result.push({ from: node.from, to: node.to, atomic: true, styles, underline, formats, wrappers })
|
||||
return
|
||||
}
|
||||
if (PROTECTED.has(node.name)) return
|
||||
if (node.name === 'InlineStyle' || node.name === 'Underline') {
|
||||
const marks = node.getChildren(`${node.name}Mark`)
|
||||
if (node.name === 'InlineStyle') styles = { ...styles, ...stylesFromTag(state.sliceDoc(marks[0].from, marks[0].to)) }
|
||||
else underline = true
|
||||
wrappers = [...wrappers, { marks }]
|
||||
}
|
||||
if (node.name in MARKS) {
|
||||
formats = [...formats, node.name]
|
||||
wrappers = [...wrappers, { marks: node.getChildren(MARK_NODES[node.name as InlineMark]) }]
|
||||
}
|
||||
if (node.name === 'Link') formats = [...formats, 'Link']
|
||||
const textContainer = TEXT_CONTAINERS.has(node.name) || /^(ATX|Setext)Heading\d$/.test(node.name) || node.name === 'Link'
|
||||
const add = (from: number, to: number) => {
|
||||
for (const match of state.sliceDoc(from, to).matchAll(/[^\n]+/g)) {
|
||||
const start = from + match.index; const end = start + match[0].length
|
||||
const line = state.doc.lineAt(start)
|
||||
// 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。
|
||||
const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length
|
||||
const contentFrom = Math.max(start, prefixEnd)
|
||||
if (contentFrom < end) result.push({ from: contentFrom, to: end, atomic: false, styles, underline, formats, wrappers })
|
||||
}
|
||||
}
|
||||
let pos = node.from
|
||||
let end = node.to
|
||||
if (node.name === 'Link') {
|
||||
const marks = node.getChildren('LinkMark')
|
||||
pos = marks[0].to
|
||||
end = marks.find(mark => state.sliceDoc(mark.from, mark.to) === ']')!.from
|
||||
}
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.to <= pos || child.from >= end) continue
|
||||
if (textContainer && child.from > pos) add(pos, child.from)
|
||||
walk(child, styles, underline, formats, wrappers)
|
||||
pos = child.to
|
||||
}
|
||||
if (textContainer && pos < end) add(pos, end)
|
||||
}
|
||||
walk(formattingTree(state).topNode)
|
||||
return result
|
||||
}
|
||||
|
||||
export function canFormatSelection(state: EditorState): boolean {
|
||||
if (state.selection.main.empty) return false
|
||||
let allowed = true
|
||||
for (const range of state.selection.ranges) {
|
||||
formattingTree(state).iterate({ from: range.from, to: range.to, enter(node) {
|
||||
if (range.from < node.to && range.to > node.from && PROTECTED.has(node.name)) {
|
||||
allowed = false; return false
|
||||
}
|
||||
} })
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
function selected(state: EditorState, segment: Segment) {
|
||||
return state.selection.ranges.some(range => range.from < segment.to && range.to > segment.from)
|
||||
}
|
||||
|
||||
export function selectionAppearance(state: EditorState) {
|
||||
const runs = segments(state).filter(segment => selected(state, segment))
|
||||
const styles: InlineStyles = { ...runs[0]?.styles }
|
||||
for (const key of Object.keys(styles) as (keyof InlineStyles)[]) {
|
||||
if (runs.some(run => run.styles[key] !== styles[key])) delete styles[key]
|
||||
}
|
||||
const all = (format: string) => runs.length > 0 && runs.every(run => run.formats.includes(format))
|
||||
return { styles, underline: runs.length > 0 && runs.every(run => run.underline), bold: all('StrongEmphasis'), italic: all('Emphasis'), strike: all('Strikethrough'), highlight: all('Highlight'), link: all('Link') }
|
||||
}
|
||||
|
||||
function wrap(text: string, styles: InlineStyles, underline: boolean, formats: string[]) {
|
||||
const css = inlineStyleText(styles)
|
||||
const leading = text.match(/^\s*/u)![0]; const trailing = text.match(/\s*$/u)![0]
|
||||
const content = text.slice(leading.length, text.length - trailing.length)
|
||||
const marks = content ? (['Strikethrough', 'Highlight', 'StrongEmphasis', 'Emphasis'] as const).filter(mark => formats.includes(mark)).map(mark => MARKS[mark]) : []
|
||||
const open = marks.join(''); const close = marks.reverse().join('')
|
||||
const prefix = (css ? `<span style="${css}">` : '') + (underline ? '<u>' : '')
|
||||
return { prefix, leading: leading.length, trailing: trailing.length, marks: open.length, text: prefix + (content ? leading + open + content + close + trailing : text) + (underline ? '</u>' : '') + (css ? '</span>' : '') }
|
||||
}
|
||||
|
||||
/** 只重写选中文字及包围它的外观标签;被拆开的其余文字保留原样式。 */
|
||||
function editAppearance(update: (styles: InlineStyles, underline: boolean, formats: string[]) => { styles: InlineStyles, underline: boolean, formats: string[] }): FormatCommand {
|
||||
return ({ state, dispatch }) => {
|
||||
if (!canFormatSelection(state)) return false
|
||||
const runs = segments(state)
|
||||
const affected = runs.filter(run => selected(state, run))
|
||||
const wrappers = new Set(affected.flatMap(run => run.wrappers))
|
||||
const changes: { from: number, to: number, insert: string }[] = []
|
||||
const offsets: { from: number, to: number, start: number, leading: number, trailing: number, marks: number, changeFrom: number }[] = []
|
||||
for (const wrapper of wrappers) {
|
||||
for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (!selected(state, run) && !run.wrappers.some(wrapper => wrappers.has(wrapper))) continue
|
||||
// 同一外层标签内的兄弟标签也要展开,重新写回合并后的有效样式。
|
||||
for (const wrapper of run.wrappers) {
|
||||
if (!wrappers.has(wrapper)) {
|
||||
wrappers.add(wrapper)
|
||||
for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
|
||||
}
|
||||
}
|
||||
// 部分选中转义字符等语法单元时,格式应用于整个单元,不能把标签插进语法内部。
|
||||
const cuts = run.atomic ? [run.from, run.to] : [...new Set([run.from, run.to, ...state.selection.ranges.flatMap(range => [Math.max(run.from, Math.min(run.to, range.from)), Math.max(run.from, Math.min(run.to, range.to))])])].sort((a, b) => a - b)
|
||||
let insert = ''
|
||||
for (let i = 0; i < cuts.length - 1; i++) {
|
||||
const from = cuts[i]; const to = cuts[i + 1]
|
||||
const next = selected(state, { ...run, from, to }) ? update({ ...run.styles }, run.underline, run.formats) : run
|
||||
const part = wrap(state.sliceDoc(from, to), next.styles, next.underline, next.formats)
|
||||
offsets.push({ from, to, start: insert.length + part.prefix.length, leading: part.leading, trailing: part.trailing, marks: part.marks, changeFrom: run.from })
|
||||
insert += part.text
|
||||
}
|
||||
if (insert !== state.sliceDoc(run.from, run.to)) changes.push({ from: run.from, to: run.to, insert })
|
||||
}
|
||||
if (!changes.length) return false
|
||||
const changeSet = state.changes(changes.sort((a, b) => a.from - b.from))
|
||||
const mapped = (pos: number, end: boolean) => {
|
||||
const piece = offsets.find(piece => end ? piece.from < pos && pos <= piece.to : piece.from <= pos && pos < piece.to)
|
||||
if (!piece) return changeSet.mapPos(pos, end ? -1 : 1)
|
||||
const relative = pos - piece.from; const bodyEnd = piece.to - piece.from - piece.trailing
|
||||
const marks = relative < piece.leading || (end && relative === piece.leading)
|
||||
? 0
|
||||
: relative > bodyEnd || (!end && relative === bodyEnd) ? piece.marks * 2 : piece.marks
|
||||
return changeSet.mapPos(piece.changeFrom, -1) + piece.start + relative + marks
|
||||
}
|
||||
const selection = EditorSelection.create(state.selection.ranges.map((range) => {
|
||||
const from = mapped(range.from, false); const to = mapped(range.to, true)
|
||||
return range.anchor <= range.head ? EditorSelection.range(from, to) : EditorSelection.range(to, from)
|
||||
}), state.selection.mainIndex)
|
||||
dispatch({ changes: changeSet, selection, scrollIntoView: true, userEvent: 'input.format' })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function applyInlineStyles(patch: InlineStyles): FormatCommand {
|
||||
return editAppearance((styles, underline, formats) => {
|
||||
for (const [key, value] of Object.entries(patch) as [keyof InlineStyles, string | undefined][]) {
|
||||
if (value) styles[key] = value
|
||||
else delete styles[key]
|
||||
}
|
||||
return { styles, underline, formats }
|
||||
})
|
||||
}
|
||||
export const clearInlineStyles = editAppearance((_styles, underline, formats) => ({ styles: {}, underline, formats }))
|
||||
export const toggleUnderline: FormatCommand = (target) => {
|
||||
const remove = selectionAppearance(target.state).underline
|
||||
return editAppearance((styles, _underline, formats) => ({ styles, underline: !remove, formats }))(target)
|
||||
}
|
||||
|
||||
/** 跨段落格式化按语法片段处理,不把列表标记或链接地址包进 Markdown 定界符。 */
|
||||
export function toggleSelectionMark(mark: InlineMark): FormatCommand {
|
||||
return (target) => {
|
||||
const runs = segments(target.state).filter(run => selected(target.state, run))
|
||||
const remove = runs.length > 0 && runs.every(run => run.formats.includes(mark))
|
||||
return editAppearance((styles, underline, formats) => ({ styles, underline, formats: remove ? formats.filter(value => value !== mark) : [...new Set([...formats, mark])] }))(target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('快捷键', () => {
|
||||
it('windows 上 Ctrl+Y 为重做', async () => {
|
||||
// CodeMirror 在模块加载时读取平台,需先设置再导入
|
||||
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
|
||||
const { undo } = await import('@codemirror/commands')
|
||||
const { EditorView, runScopeHandlers } = await import('@codemirror/view')
|
||||
const { createEditorState } = await import('./index')
|
||||
const view = new EditorView({ parent: document.body, state: createEditorState({ doc: '' }) })
|
||||
view.dispatch({ changes: { from: 0, insert: 'abc' } })
|
||||
undo(view)
|
||||
expect(view.state.doc.toString()).toBe('')
|
||||
const event = new KeyboardEvent('keydown', { key: 'y', code: 'KeyY', keyCode: 89, ctrlKey: true })
|
||||
expect(runScopeHandlers(view, event, 'editor')).toBe(true)
|
||||
expect(view.state.doc.toString()).toBe('abc')
|
||||
view.destroy()
|
||||
})
|
||||
it('工作区自定义快捷键清空后,不回落到 CM 的缩进或其他冲突绑定', async () => {
|
||||
const { EditorView, runScopeHandlers } = await import('@codemirror/view')
|
||||
const { createEditorState } = await import('./index')
|
||||
const view = new EditorView({ parent: document.body, state: createEditorState({ doc: 'text', shortcuts: [] }) })
|
||||
try {
|
||||
for (const [key, code] of [['[', 'BracketLeft'], [']', 'BracketRight'], ['i', 'KeyI'], ['/', 'Slash']]) {
|
||||
expect(runScopeHandlers(view, new KeyboardEvent('keydown', { key, code, ctrlKey: true }), 'editor')).toBe(false)
|
||||
}
|
||||
expect(view.state.doc.toString()).toBe('text')
|
||||
} finally {
|
||||
view.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import type { TocEntry } from './widgets'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Facet, StateEffect } from '@codemirror/state'
|
||||
import { Decoration, ViewPlugin } from '@codemirror/view'
|
||||
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
|
||||
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
|
||||
import {
|
||||
BulletWidget,
|
||||
CheckboxWidget,
|
||||
CodeHeaderWidget,
|
||||
EmojiWidget,
|
||||
FootnoteRefWidget,
|
||||
HorizontalRuleWidget,
|
||||
ImageWidget,
|
||||
MathWidget,
|
||||
|
||||
TocWidget,
|
||||
} from './widgets'
|
||||
|
||||
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
|
||||
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
|
||||
combine: values => values[0] ?? (src => src),
|
||||
})
|
||||
|
||||
export const openLink = Facet.define<(href: string) => void, ((href: string) => void) | undefined>({ combine: values => values[0] })
|
||||
|
||||
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
|
||||
export const refreshImages = StateEffect.define<null>()
|
||||
|
||||
const hidden = Decoration.replace({})
|
||||
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
|
||||
const linkText = Decoration.mark({ class: 'cm-rm-link' })
|
||||
const highlightText = Decoration.mark({ class: 'cm-rm-highlight' })
|
||||
const superscriptText = Decoration.mark({ class: 'cm-rm-sup' })
|
||||
const subscriptText = Decoration.mark({ class: 'cm-rm-sub' })
|
||||
const footnoteLabel = Decoration.mark({ class: 'cm-rm-footnote-label' })
|
||||
const lineDecos = new Map<string, Decoration>()
|
||||
|
||||
function lineDeco(cls: string): Decoration {
|
||||
let deco = lineDecos.get(cls)
|
||||
if (!deco) lineDecos.set(cls, (deco = Decoration.line({ class: cls })))
|
||||
return deco
|
||||
}
|
||||
|
||||
// 块级标记(标题、引用、列表、围栏)按行展开源码,行内语法只展开光标所在的元素,
|
||||
// 与 Typora/Obsidian 一致,避免长段落中光标一落整行跳动;编辑器失焦时整篇按渲染效果显示
|
||||
function activeLines(view: EditorView): Set<number> {
|
||||
const lines = new Set<number>()
|
||||
if (!view.hasFocus) return lines
|
||||
const { doc } = view.state
|
||||
for (const r of view.state.selection.ranges) {
|
||||
for (let n = doc.lineAt(r.from).number, end = doc.lineAt(r.to).number; n <= end; n++) lines.add(n)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const { state } = view
|
||||
const { doc } = state
|
||||
const active = activeLines(view)
|
||||
const resolveUrl = state.facet(resolveImageUrl)
|
||||
const out: Range<Decoration>[] = []
|
||||
|
||||
const lineOf = (pos: number) => doc.lineAt(pos).number
|
||||
const isActive = (from: number, to = from) => {
|
||||
for (let n = lineOf(from), end = lineOf(to); n <= end; n++) {
|
||||
if (active.has(n)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const ranges = view.hasFocus ? state.selection.ranges : []
|
||||
const touches = (node: SyntaxNode) => ranges.some(r => r.from <= node.to && r.to >= node.from)
|
||||
// 视图插件提供的替换装饰不能跨越换行
|
||||
const hide = (from: number, to: number) => {
|
||||
if (from < to && lineOf(from) === lineOf(to)) out.push(hidden.range(from, to))
|
||||
}
|
||||
const classLines = (from: number, to: number, cls: (line: number, first: number, last: number) => string) => {
|
||||
const first = lineOf(from)
|
||||
const last = lineOf(to)
|
||||
for (const vr of view.visibleRanges) {
|
||||
for (let n = Math.max(first, lineOf(vr.from)), end = Math.min(last, lineOf(vr.to)); n <= end; n++) {
|
||||
out.push(lineDeco(cls(n, first, last)).range(doc.line(n).from))
|
||||
}
|
||||
}
|
||||
}
|
||||
const spacesAfter = (pos: number) => {
|
||||
let end = pos
|
||||
while (end < doc.length && state.sliceDoc(end, end + 1) === ' ') end++
|
||||
return end
|
||||
}
|
||||
|
||||
// 脚注定义(标签 → 内容)与目录条目,只在可见区域出现相应语法时才计算
|
||||
let footnoteMap: Map<string, string> | undefined
|
||||
function footnotes() {
|
||||
if (!footnoteMap) {
|
||||
footnoteMap = new Map()
|
||||
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
|
||||
if (n.name !== 'FootnoteDefinition') continue
|
||||
const label = n.getChild('FootnoteLabel')!
|
||||
footnoteMap.set(state.sliceDoc(label.from + 2, label.to - 2), state.sliceDoc(label.to, n.to).trim())
|
||||
}
|
||||
}
|
||||
return footnoteMap
|
||||
}
|
||||
|
||||
function tocEntries(): TocEntry[] {
|
||||
const entries: TocEntry[] = []
|
||||
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
|
||||
const m = /^(ATX|Setext)Heading(\d)$/.exec(n.name)
|
||||
if (!m) continue
|
||||
const raw = m[1] === 'ATX' ? state.sliceDoc(n.from, n.to).replace(/^#+\s*/, '').replace(/\s+#+\s*$/, '') : doc.lineAt(n.from).text
|
||||
const text = raw.replace(/\*\*|__|==|~~|`/g, '').trim()
|
||||
if (text) entries.push({ level: Number(m[2]), text, pos: n.from })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter(ref) {
|
||||
const node = ref.node
|
||||
const name = node.name
|
||||
if (name.startsWith('ATXHeading') || name.startsWith('SetextHeading')) {
|
||||
out.push(lineDeco(`cm-rm-h${name.slice(-1)}`).range(doc.lineAt(node.from).from))
|
||||
return
|
||||
}
|
||||
switch (name) {
|
||||
case 'HeaderMark': {
|
||||
if (isActive(node.from)) return
|
||||
const parent = node.parent!
|
||||
if (parent.name.startsWith('SetextHeading')) hide(node.from, node.to)
|
||||
else if (node.from === parent.from) hide(node.from, spacesAfter(node.to))
|
||||
else hide(trailingSpaceStart(state, node.from), node.to)
|
||||
return
|
||||
}
|
||||
case 'EmphasisMark':
|
||||
case 'StrikethroughMark':
|
||||
case 'SuperscriptMark':
|
||||
case 'SubscriptMark':
|
||||
case 'HighlightMark':
|
||||
case 'UnderlineMark':
|
||||
case 'InlineStyleMark':
|
||||
if (!touches(node.parent!)) hide(node.from, node.to)
|
||||
return
|
||||
case 'Underline':
|
||||
out.push(Decoration.mark({ class: 'cm-rm-underline' }).range(node.from, node.to))
|
||||
return
|
||||
case 'InlineStyle': {
|
||||
const mark = node.getChild('InlineStyleMark')!
|
||||
const styles = stylesFromTag(state.sliceDoc(mark.from, mark.to))!
|
||||
out.push(Decoration.mark({ attributes: { style: inlineStyleText(styles) } }).range(mark.to, node.getChildren('InlineStyleMark').at(-1)!.from))
|
||||
return
|
||||
}
|
||||
case 'Highlight':
|
||||
out.push(highlightText.range(node.from, node.to))
|
||||
return
|
||||
case 'Superscript':
|
||||
out.push(superscriptText.range(node.from, node.to))
|
||||
return
|
||||
case 'Subscript':
|
||||
out.push(subscriptText.range(node.from, node.to))
|
||||
return
|
||||
case 'InlineMath':
|
||||
if (!touches(node) && lineOf(node.from) === lineOf(node.to)) {
|
||||
const widget = new MathWidget(state.sliceDoc(node.from + 1, node.to - 1), false, 1)
|
||||
out.push(Decoration.replace({ widget }).range(node.from, node.to))
|
||||
}
|
||||
return false
|
||||
case 'Emoji':
|
||||
if (!touches(node)) out.push(Decoration.replace({ widget: new EmojiWidget(state.sliceDoc(node.from + 1, node.to - 1)) }).range(node.from, node.to))
|
||||
return
|
||||
case 'FootnoteReference':
|
||||
if (!touches(node)) {
|
||||
const label = state.sliceDoc(node.from + 2, node.to - 1)
|
||||
out.push(Decoration.replace({ widget: new FootnoteRefWidget(label, footnotes().get(label)) }).range(node.from, node.to))
|
||||
}
|
||||
return
|
||||
case 'FootnoteDefinition':
|
||||
classLines(node.from, node.to, () => 'cm-rm-footnote')
|
||||
return
|
||||
case 'FootnoteLabel':
|
||||
if (!isActive(node.from)) out.push(footnoteLabel.range(node.from, node.to))
|
||||
return
|
||||
case 'TableOfContents':
|
||||
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new TocWidget(tocEntries()) }).range(node.from, node.to))
|
||||
return
|
||||
case 'FrontMatter':
|
||||
classLines(node.from, node.to, (n, first, last) =>
|
||||
n === first ? 'cm-rm-frontmatter cm-rm-frontmatter-begin' : n === last ? 'cm-rm-frontmatter cm-rm-frontmatter-end' : 'cm-rm-frontmatter')
|
||||
return false
|
||||
case 'BlockMath':
|
||||
// 未编辑时由 blocks.ts 整块替换为渲染结果,这里只负责编辑时的源码样式
|
||||
classLines(node.from, node.to, () => 'cm-rm-math-source')
|
||||
return false
|
||||
case 'InlineCode':
|
||||
out.push(inlineCode.range(node.from, node.to))
|
||||
return
|
||||
case 'CodeMark':
|
||||
if (node.parent?.name === 'InlineCode' && !touches(node.parent)) hide(node.from, node.to)
|
||||
return
|
||||
case 'Link':
|
||||
decorateLink(node)
|
||||
return
|
||||
case 'Autolink':
|
||||
if (!touches(node)) {
|
||||
hide(node.from, node.from + 1)
|
||||
hide(node.to - 1, node.to)
|
||||
}
|
||||
return
|
||||
case 'Image':
|
||||
decorateImage(node)
|
||||
return false
|
||||
case 'Escape':
|
||||
if (!touches(node)) hide(node.from, node.from + 1)
|
||||
return
|
||||
case 'HorizontalRule':
|
||||
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new HorizontalRuleWidget() }).range(node.from, node.to))
|
||||
return
|
||||
case 'Blockquote':
|
||||
classLines(node.from, node.to, () => 'cm-rm-quote')
|
||||
return
|
||||
case 'QuoteMark':
|
||||
if (!isActive(node.from)) hide(node.from, Math.min(node.to + 1, spacesAfter(node.to)))
|
||||
return
|
||||
case 'ListMark':
|
||||
decorateListMark(node)
|
||||
return
|
||||
case 'TaskMarker':
|
||||
if (!isActive(node.from)) {
|
||||
const checked = state.sliceDoc(node.from + 1, node.from + 2) !== ' '
|
||||
out.push(Decoration.replace({ widget: new CheckboxWidget(checked) }).range(node.from, node.to))
|
||||
}
|
||||
return
|
||||
case 'FencedCode':
|
||||
decorateFencedCode(node)
|
||||
return false
|
||||
case 'CodeBlock':
|
||||
classLines(node.from, node.to, () => 'cm-rm-code')
|
||||
return false
|
||||
case 'Table':
|
||||
// 光标在表格内时由此以等宽源码显示(其余情况由 table.ts 渲染为可编辑表格),内部语法不做隐藏以免列对不齐
|
||||
classLines(node.from, node.to, () => 'cm-rm-table')
|
||||
return false
|
||||
case 'HTMLBlock':
|
||||
case 'CommentBlock':
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function decorateLink(node: SyntaxNode) {
|
||||
const marks = node.getChildren('LinkMark')
|
||||
if (marks.length < 2) return
|
||||
out.push(linkText.range(marks[0].to, marks[1].from))
|
||||
if (touches(node)) return
|
||||
hide(marks[0].from, marks[0].to)
|
||||
hide(marks[1].from, node.to)
|
||||
}
|
||||
|
||||
function decorateImage(node: SyntaxNode) {
|
||||
const url = node.getChild('URL')
|
||||
const marks = node.getChildren('LinkMark')
|
||||
if (!url || marks.length < 2) return
|
||||
const src = resolveUrl(state.sliceDoc(url.from, url.to).replace(/^<|>$/g, ''))
|
||||
const widget = new ImageWidget(src, state.sliceDoc(marks[0].to, marks[1].from))
|
||||
if (touches(node)) out.push(Decoration.widget({ widget, side: 1 }).range(node.to))
|
||||
else if (lineOf(node.from) === lineOf(node.to)) out.push(Decoration.replace({ widget }).range(node.from, node.to))
|
||||
}
|
||||
|
||||
function decorateListMark(node: SyntaxNode) {
|
||||
if (isActive(node.from)) return
|
||||
const item = node.parent!
|
||||
if (item.getChild('Task')) hide(node.from, spacesAfter(node.to))
|
||||
else if (item.parent?.name === 'BulletList') out.push(Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to))
|
||||
}
|
||||
|
||||
function decorateFencedCode(node: SyntaxNode) {
|
||||
const marks = node.getChildren('CodeMark')
|
||||
const closed = marks.length > 1
|
||||
classLines(node.from, node.to, (n, first, last) =>
|
||||
n === first ? 'cm-rm-code cm-rm-code-begin' : n === last && closed ? 'cm-rm-code cm-rm-code-end' : 'cm-rm-code')
|
||||
const info = node.getChild('CodeInfo')
|
||||
const openLine = doc.lineAt(node.from)
|
||||
const lang = info ? fenceLanguage(state, info.from, info.to) : ''
|
||||
if (isActive(node.from, node.to)) {
|
||||
// 编辑中显示围栏源码,复制按钮浮在行尾
|
||||
out.push(Decoration.widget({ widget: new CodeHeaderWidget(lang, false, codeAt), side: 1 }).range(openLine.to))
|
||||
return
|
||||
}
|
||||
out.push(lineDeco('cm-rm-code-fence').range(openLine.from))
|
||||
out.push(Decoration.replace({ widget: new CodeHeaderWidget(lang, true, codeAt) }).range(marks[0].from, openLine.to))
|
||||
if (closed) {
|
||||
const closeMark = marks[marks.length - 1]
|
||||
out.push(lineDeco('cm-rm-code-fence').range(doc.lineAt(closeMark.from).from))
|
||||
hide(closeMark.from, closeMark.to)
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(out, true)
|
||||
}
|
||||
|
||||
function codeAt(view: EditorView, pos: number): string | null {
|
||||
const node = fencedCodeAt(view.state, pos)
|
||||
if (!node) return null
|
||||
const { from, to } = codeContentRange(view.state, node)
|
||||
return view.state.sliceDoc(from, to)
|
||||
}
|
||||
|
||||
function trailingSpaceStart(state: EditorState, pos: number): number {
|
||||
let start = pos
|
||||
while (start > 0 && state.sliceDoc(start - 1, start) === ' ') start--
|
||||
return start
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + 点击打开链接
|
||||
function linkAt(state: EditorState, pos: number): string | null {
|
||||
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
|
||||
if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
|
||||
if (node.name === 'Link' || node.name === 'Autolink') {
|
||||
const url = node.getChild('URL')
|
||||
if (url) return state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '')
|
||||
const text = (n: SyntaxNode) => state.sliceDoc(n.from, n.to)
|
||||
const label = node.getChild('LinkLabel')
|
||||
const close = node.getChildren('LinkMark').find(mark => text(mark) === ']')
|
||||
if (!close) return null
|
||||
const normalize = (value: string) => value.replace(/^\[|\]$/g, '').trim().replace(/\s+/g, ' ').toUpperCase()
|
||||
const key = normalize(label && text(label) !== '[]' ? text(label) : state.sliceDoc(node.from, close.to))
|
||||
let destination: string | null = null
|
||||
syntaxTree(state).iterate({ enter(ref) {
|
||||
if (ref.name !== 'LinkReference') return
|
||||
const refLabel = ref.node.getChild('LinkLabel'); const refUrl = ref.node.getChild('URL')
|
||||
if (destination === null && refLabel && refUrl && normalize(text(refLabel)) === key) destination = text(refUrl).replace(/^<|>$/g, '')
|
||||
return false
|
||||
} })
|
||||
return destination
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const livePreviewPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
// 输入法组字期间只映射位置不重建,组字结束后再补一次重建
|
||||
stale = false
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view)
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (u.view.composing) {
|
||||
this.decorations = this.decorations.map(u.changes)
|
||||
this.stale = true
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.stale
|
||||
|| u.transactions.some(tr => tr.effects.some(effect => effect.is(refreshImages)))
|
||||
|| u.docChanged
|
||||
|| u.viewportChanged
|
||||
|| u.selectionSet
|
||||
|| u.focusChanged
|
||||
|| syntaxTree(u.startState) !== syntaxTree(u.state)
|
||||
) {
|
||||
this.stale = false
|
||||
this.decorations = buildDecorations(u.view)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: v => v.decorations,
|
||||
eventHandlers: {
|
||||
mousedown(e, view) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return false
|
||||
const pos = view.posAtCoords(e)
|
||||
const href = pos === null ? null : linkAt(view.state, pos)
|
||||
if (!href) return false
|
||||
e.preventDefault()
|
||||
const handler = view.state.facet(openLink)
|
||||
if (handler) handler(href)
|
||||
else if (/^(https?:|mailto:|tel:)/i.test(href)) window.open(href, '_blank', 'noopener')
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export const livePreview = [livePreviewPlugin]
|
||||
@@ -0,0 +1,125 @@
|
||||
import { history, undo } from '@codemirror/commands'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { htmlToMarkdown } from './html-paste'
|
||||
import { pasteImages } from './paste'
|
||||
|
||||
let view: EditorView | undefined
|
||||
afterEach(() => {
|
||||
view?.destroy(); view = undefined; vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function paste(html: string, text: string) {
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/html', html)
|
||||
data.setData('text/plain', text)
|
||||
view!.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
function editor(importImage: (url: string) => Promise<string> = async () => {
|
||||
throw new Error('此用例不下载图片')
|
||||
}) {
|
||||
let pending = 0
|
||||
const errors: unknown[] = []
|
||||
view = new EditorView({ parent: document.body, state: EditorState.create({ doc: '', extensions: [history(), pasteImages({
|
||||
canPaste: () => true,
|
||||
upload: async () => {
|
||||
throw new Error('此用例不上传文件')
|
||||
},
|
||||
importImage,
|
||||
update: (change) => {
|
||||
const spec = change(view!.state); if (spec) view!.dispatch(spec)
|
||||
},
|
||||
onError: error => errors.push(error),
|
||||
onPending: (delta) => {
|
||||
pending += delta
|
||||
},
|
||||
})] }) })
|
||||
return { pending: () => pending, errors }
|
||||
}
|
||||
|
||||
describe('网页粘贴', () => {
|
||||
it('保留标题、列表、链接、代码块、删除线和表格,并移除活动内容', async () => {
|
||||
const result = await htmlToMarkdown('<h2>标题</h2><ul><li>一</li><li><del>二</del></li></ul><a href="https://example.com/a(b)">链接</a><pre><code class="language-js">const x = 1\n</code></pre><table><tr><th>名称</th><th>值</th></tr><tr><td><strong>甲</strong></td><td>a|b</td></tr></table><script>alert(1)</script><style>body{}</style><a href="java	script:alert(1)">危险链接</a>', async () => '', () => {})
|
||||
expect(result).toContain('## 标题')
|
||||
expect(result).toMatch(/-\s+一/)
|
||||
expect(result).toContain('~~二~~')
|
||||
expect(result).toContain('[链接](<https://example.com/a(b)>)')
|
||||
expect(result).toContain('```js\nconst x = 1\n```')
|
||||
expect(result).toContain('| 名称 | 值 |\n| --- | --- |\n| **甲** | a\\|b |')
|
||||
expect(result).not.toContain('alert(1)')
|
||||
expect(result).not.toContain('body{}')
|
||||
expect(result).toContain('危险链接')
|
||||
})
|
||||
|
||||
it('相同来源只下载一次,失败保留原图链接并报告原始原因', async () => {
|
||||
const failure = new Error('来源拒绝下载')
|
||||
const errors: unknown[] = []
|
||||
const urls: string[] = []
|
||||
const result = await htmlToMarkdown('<base href="https://example.com/docs/"><img alt="图" src="../a.png"><img src="//example.com/a.png"><img src="bad.png">', async (url) => {
|
||||
urls.push(url)
|
||||
if (url.endsWith('bad.png')) throw failure
|
||||
return '笔记.assets/hash.png'
|
||||
}, error => errors.push(error))
|
||||
expect(urls.sort()).toEqual(['https://example.com/a.png', 'https://example.com/docs/bad.png'])
|
||||
expect(result.match(/笔记.assets\/hash.png/g)).toHaveLength(2)
|
||||
expect(result).toContain('https://example.com/docs/bad.png')
|
||||
expect(errors).toEqual([failure])
|
||||
})
|
||||
|
||||
it('转换期间的新输入保持原样,撤销一次移除转换后的整次粘贴', async () => {
|
||||
const state = editor()
|
||||
paste('<h1>标题</h1>', '标题')
|
||||
view!.dispatch({ changes: { from: 0, insert: '之后输入' }, userEvent: 'input.type' })
|
||||
await vi.waitFor(() => expect(state.pending()).toBe(0))
|
||||
expect(view!.state.doc.toString()).toBe('# 标题之后输入')
|
||||
undo(view!)
|
||||
expect(view!.state.doc.toString()).toBe('之后输入')
|
||||
undo(view!)
|
||||
expect(view!.state.doc.toString()).toBe('')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('用户已修改粘贴区域时不覆盖新输入,Shift 粘贴保持纯文本', async () => {
|
||||
const state = editor()
|
||||
view!.dispatch({ changes: { from: 0, insert: '原选区' }, selection: { anchor: 0, head: 3 } })
|
||||
paste('<h1>标题</h1>', '标题')
|
||||
view!.dispatch({ changes: { from: 1, to: 3, insert: '新输入' } })
|
||||
await vi.waitFor(() => expect(state.pending()).toBe(0))
|
||||
expect(view!.state.doc.toString()).toBe('原新输入')
|
||||
view!.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'V', shiftKey: true, ctrlKey: true, bubbles: true }))
|
||||
paste('<h1>纯文本</h1>', '纯文本')
|
||||
expect(view!.state.doc.toString()).not.toContain('#')
|
||||
expect(view!.state.doc.toString()).toContain('纯文本')
|
||||
expect(state.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('同一位置连续发起的异步粘贴保持发起顺序', async () => {
|
||||
const state = editor()
|
||||
paste('<b>第一段</b>', '第一段')
|
||||
paste('<b>第二段</b>', '第二段')
|
||||
await vi.waitFor(() => expect(state.pending()).toBe(0))
|
||||
expect(view!.state.doc.toString()).toBe('**第一段****第二段**')
|
||||
})
|
||||
|
||||
it('后发粘贴的图片先完成时仍保持顺序,撤销会取消尚未插入的粘贴', async () => {
|
||||
const downloads = new Map<string, (link: string) => void>()
|
||||
const state = editor(url => new Promise(resolve => downloads.set(url, resolve)))
|
||||
paste('<b>第一段</b><img src="https://example.com/1.png">', '第一段')
|
||||
paste('<b>第二段</b><img src="https://example.com/2.png">', '第二段')
|
||||
await vi.waitFor(() => expect(downloads.size).toBe(2))
|
||||
downloads.get('https://example.com/2.png')!('n.assets/2.png')
|
||||
await vi.waitFor(() => expect(view!.state.doc.toString()).toContain('第二段'))
|
||||
downloads.get('https://example.com/1.png')!('n.assets/1.png')
|
||||
await vi.waitFor(() => expect(state.pending()).toBe(0))
|
||||
expect(view!.state.doc.toString().indexOf('第一段')).toBeLessThan(view!.state.doc.toString().indexOf('第二段'))
|
||||
paste('<b>取消的粘贴</b><img src="https://example.com/3.png">', '取消的粘贴')
|
||||
await vi.waitFor(() => expect(downloads.size).toBe(3))
|
||||
undo(view!)
|
||||
downloads.get('https://example.com/3.png')!('n.assets/3.png')
|
||||
await vi.waitFor(() => expect(state.pending()).toBe(0))
|
||||
expect(view!.state.doc.toString()).not.toContain('取消的粘贴')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { EditorState, Extension, TransactionSpec } from '@codemirror/state'
|
||||
import { isolateHistory } from '@codemirror/commands'
|
||||
import { Facet, StateEffect, StateField } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export interface PasteImagesOptions {
|
||||
// 文件移动期间不能开始异步粘贴或上传,避免响应中的资源链接属于旧路径。
|
||||
canPaste: () => boolean
|
||||
// 上传文件,返回写入 Markdown 的链接
|
||||
upload: (file: File) => Promise<string>
|
||||
importImage: (url: string) => Promise<string>
|
||||
// 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
|
||||
update: (change: (state: EditorState) => TransactionSpec | undefined) => void
|
||||
onError: (err: unknown) => void
|
||||
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
|
||||
onPending: (delta: 1 | -1) => void
|
||||
}
|
||||
|
||||
const uploadOptions = Facet.define<PasteImagesOptions, PasteImagesOptions | undefined>({ combine: v => v[0] })
|
||||
|
||||
const pasteRange = StateEffect.define<{ id: object, from: number, to: number } | { id: object }>()
|
||||
const pendingPastes = StateField.define<Map<object, { from: number, to: number }>>({
|
||||
create: () => new Map(),
|
||||
update(value, transaction) {
|
||||
const next = new Map<object, { from: number, to: number }>()
|
||||
let completed: object | undefined
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(pasteRange) && !('from' in effect.value)) completed = effect.value.id
|
||||
}
|
||||
let afterCompleted = false
|
||||
for (const [id, range] of value) {
|
||||
if (id === completed) {
|
||||
afterCompleted = true; continue
|
||||
}
|
||||
let edited = transaction.isUserEvent('undo')
|
||||
transaction.changes.iterChangedRanges((from, to) => {
|
||||
if ((from < range.to && to > range.from) || (from === to && from > range.from && from < range.to)) edited = true
|
||||
})
|
||||
// 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。
|
||||
// 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。
|
||||
const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1
|
||||
if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) })
|
||||
}
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(pasteRange)) {
|
||||
if ('from' in effect.value) next.set(effect.value.id, { from: effect.value.from, to: effect.value.to })
|
||||
else next.delete(effect.value.id)
|
||||
}
|
||||
}
|
||||
return next
|
||||
},
|
||||
})
|
||||
|
||||
function replaceText(opts: PasteImagesOptions, text: string, replacement: string) {
|
||||
opts.update((state) => {
|
||||
const at = state.doc.toString().indexOf(text)
|
||||
if (at >= 0) return { changes: { from: at, to: at + text.length, insert: replacement } }
|
||||
})
|
||||
}
|
||||
|
||||
function insertHTML(view: EditorView, html: string, text: string, opts: PasteImagesOptions) {
|
||||
const id = {}
|
||||
const range = view.state.selection.main
|
||||
opts.onPending(1)
|
||||
view.dispatch({ effects: pasteRange.of({ id, from: range.from, to: range.to }) })
|
||||
const insert = (markdown: string) => {
|
||||
opts.update((state) => {
|
||||
const pending = state.field(pendingPastes).get(id)
|
||||
if (pending) {
|
||||
return {
|
||||
changes: { from: pending.from, to: pending.to, insert: markdown },
|
||||
selection: state.selection.main.from === pending.from && state.selection.main.to === pending.to ? { anchor: pending.from + markdown.length } : undefined,
|
||||
effects: pasteRange.of({ id }),
|
||||
userEvent: 'input.paste',
|
||||
annotations: isolateHistory.of('full'),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => {
|
||||
opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error }))
|
||||
return text
|
||||
}).then(insert).catch(opts.onError).finally(() => opts.onPending(-1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
|
||||
* 返回是否已处理图片插入(包括因操作冲突被拒绝);未配置上传时不处理
|
||||
*/
|
||||
export function insertImageFiles(view: EditorView, files: File[], pos?: number): boolean {
|
||||
const opts = view.state.facet(uploadOptions)
|
||||
const images = files.filter(f => f.type.startsWith('image/'))
|
||||
if (!opts || !images.length) return false
|
||||
if (!opts.canPaste()) return true
|
||||
const urls = images.map(f => URL.createObjectURL(f))
|
||||
const text = urls.map(u => ``).join('\n')
|
||||
const range = view.state.selection.main
|
||||
const from = pos ?? range.from
|
||||
const to = pos ?? range.to
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: from + text.length },
|
||||
userEvent: 'input.paste',
|
||||
})
|
||||
images.forEach((file, i) => {
|
||||
opts.onPending(1)
|
||||
opts
|
||||
.upload(file)
|
||||
.then(link => replaceText(opts, urls[i], link))
|
||||
.catch((err) => {
|
||||
replaceText(opts, ``, '')
|
||||
opts.onError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
opts.onPending(-1)
|
||||
URL.revokeObjectURL(urls[i])
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// 粘贴或拖入图片
|
||||
export function pasteImages(opts: PasteImagesOptions): Extension {
|
||||
let plain = false
|
||||
return [
|
||||
uploadOptions.of(opts),
|
||||
pendingPastes,
|
||||
EditorView.domEventHandlers({
|
||||
keydown(e) {
|
||||
plain = e.shiftKey
|
||||
},
|
||||
keyup(e) {
|
||||
plain = e.shiftKey
|
||||
},
|
||||
blur() {
|
||||
plain = false
|
||||
},
|
||||
paste(e, view) {
|
||||
if (view.state.readOnly || plain) return false
|
||||
const html = e.clipboardData?.getData('text/html')
|
||||
if (html) {
|
||||
if (opts.canPaste()) insertHTML(view, html, e.clipboardData!.getData('text/plain'), opts)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
// 个别浏览器/来源只在 items 中提供图片
|
||||
let files = [...(e.clipboardData?.files ?? [])]
|
||||
if (!files.length) {
|
||||
files = [...(e.clipboardData?.items ?? [])].filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(f => f !== null)
|
||||
}
|
||||
if (!insertImageFiles(view, files)) return false
|
||||
e.preventDefault()
|
||||
return true
|
||||
},
|
||||
drop(e, view) {
|
||||
if (view.state.readOnly) return false
|
||||
const files = [...(e.dataTransfer?.files ?? [])]
|
||||
const pos = view.posAtCoords(e)
|
||||
if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false
|
||||
e.preventDefault()
|
||||
return true
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Command, KeyBinding } from '@codemirror/view'
|
||||
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
|
||||
import { typoraKeys } from '../../../common/utils/typora-keys'
|
||||
import { selectCodeBlock } from './codeblocks'
|
||||
import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format'
|
||||
import { toggleSelectionMark, toggleUnderline } from './inline-styles'
|
||||
|
||||
export const editorCommands: { id: string, title: string, keys: string[], run: Command }[] = [
|
||||
{ id: 'editor.bold', title: '粗体', keys: typoraKeys('editor.bold'), run: toggleBold },
|
||||
{ id: 'editor.italic', title: '斜体', keys: typoraKeys('editor.italic'), run: toggleItalic },
|
||||
{ id: 'editor.underline', title: '下划线', keys: typoraKeys('editor.underline'), run: toggleUnderline },
|
||||
{ id: 'editor.strike', title: '删除线', keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') },
|
||||
{ id: 'editor.highlight', title: '高亮', keys: [], run: toggleSelectionMark('Highlight') },
|
||||
{ id: 'editor.code', title: '行内代码', keys: typoraKeys('editor.code'), run: toggleCode },
|
||||
...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, title: { task: '任务列表', bullet: '无序列表', ordered: '有序列表', quote: '引用' }[kind], keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })),
|
||||
{ id: 'editor.link', title: '插入链接', keys: typoraKeys('editor.link'), run: insertLink },
|
||||
{ id: 'editor.undo', title: '撤销', keys: typoraKeys('editor.undo'), run: undo },
|
||||
{ id: 'editor.redo', title: '重做', keys: typoraKeys('editor.redo'), run: redo },
|
||||
{ id: 'editor.indent', title: '增加缩进', keys: typoraKeys('editor.indent'), run: indentMore },
|
||||
{ id: 'editor.outdent', title: '减少缩进', keys: typoraKeys('editor.outdent'), run: indentLess },
|
||||
{ id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: typoraKeys('editor.selectAll'), run: view => selectCodeBlock(view) || selectAll(view) },
|
||||
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: typoraKeys(`editor.heading${i + 1}`), run: toggleHeading(i + 1) })),
|
||||
]
|
||||
|
||||
export const defaultEditorKeys: KeyBinding[] = editorCommands.flatMap(command => command.keys.map(key => ({ key: key.replaceAll('+', '-').replace(/-[A-Z]$/, letter => letter.toLowerCase()), run: command.run, preventDefault: true })))
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setEditorFocus } from './focus'
|
||||
import { createEditorState } from './index'
|
||||
import { tableField } from './table'
|
||||
|
||||
const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n正文'
|
||||
|
||||
describe('表格渲染', () => {
|
||||
it('光标在文首但编辑器未聚焦时,开头的表格仍渲染', () => {
|
||||
const state = createEditorState({ doc })
|
||||
expect(state.selection.main.head).toBe(0)
|
||||
expect(state.field(tableField).size).toBe(1)
|
||||
})
|
||||
it('编辑器聚焦且光标在表格内时显示源码,移出后恢复渲染', () => {
|
||||
let state = createEditorState({ doc }).update({ effects: setEditorFocus.of(true) }).state
|
||||
expect(state.field(tableField).size).toBe(0)
|
||||
state = state.update({ selection: { anchor: doc.length } }).state
|
||||
expect(state.field(tableField).size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import { isolateHistory, redo, undo } from '@codemirror/commands'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
|
||||
import { renderInlineMarkdown } from '../../../common/markdown/html'
|
||||
import { hydrate } from '../../../common/markdown/render'
|
||||
import { editorFocused, setEditorFocus } from './focus'
|
||||
|
||||
type Align = '' | 'left' | 'center' | 'right'
|
||||
|
||||
interface RowLine {
|
||||
from: number
|
||||
to: number
|
||||
cells: string[]
|
||||
}
|
||||
|
||||
interface TableModel {
|
||||
from: number
|
||||
to: number
|
||||
aligns: Align[]
|
||||
// rows[0] 为表头,其余为表体;分隔行单独由 aligns 表示
|
||||
rows: RowLine[]
|
||||
}
|
||||
|
||||
// ---- 解析与序列化 ----
|
||||
|
||||
// 按未转义的 | 切分一行,去掉首尾可选的竖线
|
||||
function splitRow(line: string): string[] {
|
||||
const cells: string[] = []
|
||||
let cur = ''
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]
|
||||
if (ch === '\\' && i + 1 < line.length) {
|
||||
cur += ch + line[++i]
|
||||
} else if (ch === '|') {
|
||||
cells.push(cur)
|
||||
cur = ''
|
||||
} else {
|
||||
cur += ch
|
||||
}
|
||||
}
|
||||
cells.push(cur)
|
||||
if (/^\s*$/.test(cells[0]) && line.trimStart().startsWith('|')) cells.shift()
|
||||
if (cells.length && /^\s*$/.test(cells[cells.length - 1]) && line.trimEnd().endsWith('|')) cells.pop()
|
||||
return cells.map(c => c.trim())
|
||||
}
|
||||
|
||||
function parseAlign(cell: string): Align {
|
||||
const left = cell.startsWith(':')
|
||||
const right = cell.endsWith(':')
|
||||
return left && right ? 'center' : right ? 'right' : left ? 'left' : ''
|
||||
}
|
||||
|
||||
function escapeCell(text: string): string {
|
||||
return text.replace(/\n/g, ' ').replace(/(^|[^\\])\|/g, '$1\\|')
|
||||
}
|
||||
|
||||
function serializeRow(cells: string[]): string {
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
function serializeDelimiter(aligns: Align[]): string {
|
||||
const marks = { '': '---', 'left': ':---', 'center': ':---:', 'right': '---:' }
|
||||
return `| ${aligns.map(a => marks[a]).join(' | ')} |`
|
||||
}
|
||||
|
||||
function serializeTable(aligns: Align[], rows: string[][]): string {
|
||||
const [header, ...body] = rows
|
||||
return [serializeRow(header), serializeDelimiter(aligns), ...body.map(serializeRow)].join('\n')
|
||||
}
|
||||
|
||||
function readTable(state: EditorState, node: SyntaxNode): TableModel {
|
||||
const { doc } = state
|
||||
const rows: RowLine[] = []
|
||||
let aligns: Align[] = []
|
||||
for (let n = doc.lineAt(node.from).number, end = doc.lineAt(node.to).number; n <= end; n++) {
|
||||
const line = doc.line(n)
|
||||
const cells = splitRow(line.text)
|
||||
if (rows.length === 1 && aligns.length === 0) aligns = cells.map(parseAlign)
|
||||
else rows.push({ from: line.from, to: line.to, cells })
|
||||
}
|
||||
const width = aligns.length
|
||||
for (const r of rows) {
|
||||
r.cells = r.cells.slice(0, width)
|
||||
while (r.cells.length < width) r.cells.push('')
|
||||
}
|
||||
return { from: node.from, to: node.to, aligns, rows }
|
||||
}
|
||||
|
||||
function findTable(state: EditorState, pos: number): SyntaxNode | null {
|
||||
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
|
||||
if (node.name === 'Table' && node.from === pos) return node
|
||||
if (node.from > pos) break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 渲染组件 ----
|
||||
|
||||
function renderCell(cell: HTMLElement, raw: string) {
|
||||
cell.innerHTML = renderInlineMarkdown(raw)
|
||||
hydrate(cell)
|
||||
}
|
||||
|
||||
interface WidgetState {
|
||||
model: TableModel
|
||||
// 最近编辑的单元格,工具栏操作以它为准
|
||||
current: { r: number, c: number }
|
||||
// 结构变更后需要聚焦的单元格(DOM 重建后生效)
|
||||
pendingFocus: { r: number, c: number } | null
|
||||
}
|
||||
|
||||
const widgetStates = new WeakMap<HTMLElement, WidgetState>()
|
||||
|
||||
class TableWidget extends WidgetType {
|
||||
readonly key: string
|
||||
|
||||
constructor(readonly model: TableModel) {
|
||||
super()
|
||||
this.key = JSON.stringify([model.aligns, model.rows.map(r => r.cells)])
|
||||
}
|
||||
|
||||
eq(other: TableWidget) {
|
||||
return other.key === this.key
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return this.model.rows.length * 34 + 44
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'cm-rm-table-widget'
|
||||
widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null })
|
||||
// 横向滚动放在内层,避免绝对定位的工具栏被裁剪
|
||||
const scroller = document.createElement('div')
|
||||
scroller.className = 'cm-rm-table-scroll'
|
||||
scroller.append(this.buildTable(view, wrap))
|
||||
wrap.append(this.toolbar(view, wrap), scroller)
|
||||
return wrap
|
||||
}
|
||||
|
||||
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||
const st = widgetStates.get(dom)
|
||||
if (!st) return false
|
||||
const old = st.model
|
||||
st.model = this.model
|
||||
const sameShape
|
||||
= old.rows.length === this.model.rows.length && old.aligns.join() === this.model.aligns.join()
|
||||
if (!sameShape || st.pendingFocus) {
|
||||
// 重建前记下正在编辑的单元格(如撤销改变了行数),重建后恢复焦点
|
||||
const editing = dom.querySelector<HTMLElement>('[contenteditable]')
|
||||
const focus
|
||||
= st.pendingFocus ?? (editing ? { r: Number(editing.dataset.r), c: Number(editing.dataset.c) } : null)
|
||||
st.pendingFocus = null
|
||||
dom.querySelector('table')!.replaceWith(this.buildTable(view, dom))
|
||||
if (focus) {
|
||||
const rows = this.model.rows.length
|
||||
const cols = this.model.aligns.length
|
||||
const cell = cellAt(dom, Math.min(focus.r, rows - 1), Math.min(focus.c, cols - 1))
|
||||
if (cell) startEdit(cell, st)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 形状不变:只刷新内容变化的单元格;正在编辑的单元格若与文档一致则不动,以免打断输入
|
||||
dom.querySelectorAll<HTMLElement>('[data-r]').forEach((cell) => {
|
||||
const r = Number(cell.dataset.r)
|
||||
const c = Number(cell.dataset.c)
|
||||
const raw = this.model.rows[r].cells[c]
|
||||
if (cell.isContentEditable) {
|
||||
if (escapeCell(cell.textContent ?? '') !== raw) {
|
||||
cell.textContent = raw
|
||||
placeCaretAtEnd(cell)
|
||||
}
|
||||
} else if (old.rows[r].cells[c] !== raw) {
|
||||
renderCell(cell, raw)
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return true
|
||||
}
|
||||
|
||||
private buildTable(view: EditorView, wrap: HTMLElement): HTMLTableElement {
|
||||
const table = document.createElement('table')
|
||||
const thead = table.createTHead()
|
||||
const tbody = table.createTBody()
|
||||
this.model.rows.forEach((row, r) => {
|
||||
const tr = (r === 0 ? thead : tbody).insertRow()
|
||||
row.cells.forEach((raw, c) => {
|
||||
const cell = document.createElement(r === 0 ? 'th' : 'td')
|
||||
cell.dataset.r = String(r)
|
||||
cell.dataset.c = String(c)
|
||||
if (this.model.aligns[c]) cell.style.textAlign = this.model.aligns[c]
|
||||
renderCell(cell, raw)
|
||||
bindCell(view, wrap, cell)
|
||||
tr.append(cell)
|
||||
})
|
||||
})
|
||||
return table
|
||||
}
|
||||
|
||||
private toolbar(view: EditorView, wrap: HTMLElement): HTMLElement {
|
||||
const bar = document.createElement('div')
|
||||
bar.className = 'cm-rm-table-toolbar'
|
||||
const tools = document.createElement('div')
|
||||
tools.className = 'cm-rm-table-tools'
|
||||
bar.append(tools)
|
||||
const button = (icon: string, label: string, title: string, run: () => void, danger = false) => {
|
||||
const b = document.createElement('button')
|
||||
b.type = 'button'
|
||||
b.title = title
|
||||
if (danger) b.className = 'cm-rm-danger'
|
||||
b.innerHTML = svg(icon)
|
||||
b.append(label)
|
||||
// mousedown 阻止默认行为,保持当前单元格的焦点与光标
|
||||
b.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
run()
|
||||
})
|
||||
tools.append(b)
|
||||
}
|
||||
const separator = () => {
|
||||
const sep = document.createElement('span')
|
||||
sep.className = 'cm-rm-sep'
|
||||
tools.append(sep)
|
||||
}
|
||||
button(ICONS.addRow, '行', '在下方插入行', () => insertRow(view, wrap))
|
||||
button(ICONS.addColumn, '列', '在右侧插入列', () => insertColumn(view, wrap))
|
||||
separator()
|
||||
button(ICONS.deleteRow, '行', '删除当前行', () => deleteRow(view, wrap), true)
|
||||
button(ICONS.deleteColumn, '列', '删除当前列', () => deleteColumn(view, wrap), true)
|
||||
separator()
|
||||
button(ICONS.source, '源码', '以 Markdown 源码编辑', () => {
|
||||
const model = currentModel(view, wrap)
|
||||
if (!model) return
|
||||
view.dispatch({ selection: { anchor: model.from } })
|
||||
view.focus()
|
||||
})
|
||||
return bar
|
||||
}
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
addRow: '<rect x="3" y="3" width="18" height="8" rx="1.5"/><path d="M12 14v7M8.5 17.5h7"/>',
|
||||
addColumn: '<rect x="3" y="3" width="8" height="18" rx="1.5"/><path d="M14 12h7M17.5 8.5v7"/>',
|
||||
deleteRow: '<rect x="3" y="3" width="18" height="8" rx="1.5"/><path d="M8.5 17.5h7"/>',
|
||||
deleteColumn: '<rect x="3" y="3" width="8" height="18" rx="1.5"/><path d="M14 12h7"/>',
|
||||
source: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
|
||||
}
|
||||
|
||||
function svg(body: string): string {
|
||||
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
|
||||
}
|
||||
|
||||
// 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格
|
||||
function mutate(
|
||||
view: EditorView,
|
||||
wrap: HTMLElement,
|
||||
f: (rows: string[][], aligns: Align[], cur: { r: number, c: number }) => { r: number, c: number },
|
||||
) {
|
||||
const st = widgetStates.get(wrap)!
|
||||
const model = currentModel(view, wrap)
|
||||
if (!model) return
|
||||
const rows = model.rows.map(r => [...r.cells])
|
||||
const aligns = [...model.aligns]
|
||||
st.pendingFocus = f(rows, aligns, st.current)
|
||||
// 结构变更单独成为一步撤销,不与前后的单元格输入合并
|
||||
view.dispatch({
|
||||
changes: { from: model.from, to: model.to, insert: serializeTable(aligns, rows) },
|
||||
annotations: isolateHistory.of('full'),
|
||||
})
|
||||
}
|
||||
|
||||
// focusColumn 未指定时聚焦新行的同一列
|
||||
function insertRow(view: EditorView, wrap: HTMLElement, focusColumn?: number) {
|
||||
return mutate(view, wrap, (rows, aligns, cur) => {
|
||||
rows.splice(cur.r + 1, 0, aligns.map(() => ''))
|
||||
return { r: cur.r + 1, c: focusColumn ?? cur.c }
|
||||
})
|
||||
}
|
||||
|
||||
function insertColumn(view: EditorView, wrap: HTMLElement) {
|
||||
return mutate(view, wrap, (rows, aligns, cur) => {
|
||||
const c = cur.c + 1
|
||||
aligns.splice(c, 0, '')
|
||||
rows.forEach(row => row.splice(c, 0, ''))
|
||||
return { r: cur.r, c }
|
||||
})
|
||||
}
|
||||
|
||||
function deleteRow(view: EditorView, wrap: HTMLElement) {
|
||||
return mutate(view, wrap, (rows, _, cur) => {
|
||||
// 表头行不能删除
|
||||
if (cur.r === 0) return cur
|
||||
rows.splice(cur.r, 1)
|
||||
return { r: Math.min(cur.r, rows.length - 1), c: cur.c }
|
||||
})
|
||||
}
|
||||
|
||||
function deleteColumn(view: EditorView, wrap: HTMLElement) {
|
||||
return mutate(view, wrap, (rows, aligns, cur) => {
|
||||
if (aligns.length <= 1) return cur
|
||||
aligns.splice(cur.c, 1)
|
||||
rows.forEach(row => row.splice(cur.c, 1))
|
||||
return { r: cur.r, c: Math.min(cur.c, aligns.length - 1) }
|
||||
})
|
||||
}
|
||||
|
||||
function currentModel(view: EditorView, wrap: HTMLElement): TableModel | null {
|
||||
const node = findTable(view.state, view.posAtDOM(wrap))
|
||||
return node ? readTable(view.state, node) : null
|
||||
}
|
||||
|
||||
function cellAt(wrap: HTMLElement, r: number, c: number): HTMLElement | null {
|
||||
return wrap.querySelector<HTMLElement>(`[data-r="${r}"][data-c="${c}"]`)
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(el: HTMLElement) {
|
||||
const sel = el.ownerDocument.getSelection()
|
||||
if (!sel) return
|
||||
const range = el.ownerDocument.createRange()
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
}
|
||||
|
||||
// 单元格编辑时显示该格的 Markdown 源码,失焦后恢复渲染
|
||||
function startEdit(cell: HTMLElement, st: WidgetState) {
|
||||
const r = Number(cell.dataset.r)
|
||||
const c = Number(cell.dataset.c)
|
||||
st.current = { r, c }
|
||||
if (!cell.isContentEditable) {
|
||||
cell.contentEditable = 'plaintext-only'
|
||||
cell.textContent = st.model.rows[r].cells[c]
|
||||
}
|
||||
cell.focus()
|
||||
placeCaretAtEnd(cell)
|
||||
}
|
||||
|
||||
function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
|
||||
const st = () => widgetStates.get(wrap)!
|
||||
const r = () => Number(cell.dataset.r)
|
||||
const c = () => Number(cell.dataset.c)
|
||||
|
||||
cell.addEventListener('mousedown', (e) => {
|
||||
if (cell.isContentEditable) return
|
||||
e.preventDefault()
|
||||
startEdit(cell, st())
|
||||
})
|
||||
|
||||
const commit = () => {
|
||||
const model = currentModel(view, wrap)
|
||||
if (!model) return
|
||||
const row = model.rows[r()]
|
||||
const cells = [...row.cells]
|
||||
const next = escapeCell(cell.textContent ?? '')
|
||||
if (cells[c()] === next) return
|
||||
cells[c()] = next
|
||||
view.dispatch({ changes: { from: row.from, to: row.to, insert: serializeRow(cells) }, userEvent: 'input.type' })
|
||||
}
|
||||
|
||||
cell.addEventListener('input', (e) => {
|
||||
if (!(e as InputEvent).isComposing) commit()
|
||||
})
|
||||
cell.addEventListener('compositionend', commit)
|
||||
|
||||
cell.addEventListener('blur', () => {
|
||||
cell.removeAttribute('contenteditable')
|
||||
renderCell(cell, st().model.rows[r()]?.cells[c()] ?? '')
|
||||
})
|
||||
|
||||
const moveTo = (nr: number, nc: number) => {
|
||||
const target = cellAt(wrap, nr, nc)
|
||||
if (target) startEdit(target, st())
|
||||
}
|
||||
|
||||
cell.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return
|
||||
const { rows, aligns } = st().model
|
||||
const cols = aligns.length
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
if (mod && e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? redo : undo)(view)
|
||||
} else if (mod && e.key.toLowerCase() === 'y') {
|
||||
e.preventDefault()
|
||||
redo(view)
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const idx = r() * cols + c() + (e.shiftKey ? -1 : 1)
|
||||
if (idx >= 0 && idx < rows.length * cols) moveTo(Math.floor(idx / cols), idx % cols)
|
||||
else if (idx >= rows.length * cols) insertRow(view, wrap, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (r() + 1 < rows.length) moveTo(r() + 1, c())
|
||||
else insertRow(view, wrap)
|
||||
} else if (e.key === 'ArrowUp' && r() > 0) {
|
||||
e.preventDefault()
|
||||
moveTo(r() - 1, c())
|
||||
} else if (e.key === 'ArrowDown' && r() + 1 < rows.length) {
|
||||
e.preventDefault()
|
||||
moveTo(r() + 1, c())
|
||||
} else if (e.key === 'Escape') {
|
||||
// 退出表格,光标放到表格之后
|
||||
e.preventDefault()
|
||||
const model = currentModel(view, wrap)
|
||||
if (model) view.dispatch({ selection: { anchor: Math.min(model.to + 1, view.state.doc.length) } })
|
||||
view.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 装饰 ----
|
||||
|
||||
// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。
|
||||
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(见 focus.ts)
|
||||
function buildTables(state: EditorState): DecorationSet {
|
||||
const out: Range<Decoration>[] = []
|
||||
const sel = state.field(editorFocused) ? state.selection.ranges : []
|
||||
// 只渲染顶层表格;引用、列表内的表格以源码显示
|
||||
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
|
||||
if (node.name !== 'Table') continue
|
||||
if (sel.some(r => r.from <= node.to && r.to >= node.from)) continue
|
||||
const model = readTable(state, node)
|
||||
if (model.rows.length === 0 || model.aligns.length === 0) continue
|
||||
out.push(Decoration.replace({ widget: new TableWidget(model), block: true }).range(node.from, node.to))
|
||||
}
|
||||
return Decoration.set(out)
|
||||
}
|
||||
|
||||
export const tableField = StateField.define<DecorationSet>({
|
||||
create: buildTables,
|
||||
update(value, tr) {
|
||||
if (
|
||||
tr.docChanged
|
||||
|| tr.selection
|
||||
|| tr.effects.some(e => e.is(setEditorFocus))
|
||||
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
||||
) {
|
||||
return buildTables(tr.state)
|
||||
}
|
||||
return value
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
})
|
||||
|
||||
export const tableEditing = tableField
|
||||
@@ -0,0 +1,16 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags as t } from '@lezer/highlight'
|
||||
|
||||
// 编辑器排版样式在 themes/base.css 中以 CSS 变量定义,这里只做语法元素到类名的映射
|
||||
const markdownHighlight = HighlightStyle.define([
|
||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||
{ tag: t.strong, fontWeight: '600' },
|
||||
{ tag: t.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], fontWeight: '600' },
|
||||
{ tag: t.processingInstruction, class: 'cm-rm-markup' },
|
||||
{ tag: [t.url, t.link], class: 'cm-rm-url' },
|
||||
{ tag: t.quote, class: 'cm-rm-quote-text' },
|
||||
{ tag: t.monospace, class: 'cm-rm-mono' },
|
||||
])
|
||||
|
||||
export const markdownTheme = syntaxHighlighting(markdownHighlight)
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { OwnedRenderer } from '../../../common/plugins/registry'
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { emojiFor, renderMath } from '../../../common/markdown/render'
|
||||
import { mountContent } from '../../../common/plugins/mount'
|
||||
import { copyText } from '../../../common/utils/clipboard'
|
||||
|
||||
export class BulletWidget extends WidgetType {
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const el = document.createElement('span')
|
||||
el.className = 'cm-rm-bullet'
|
||||
el.textContent = '•'
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
export class CheckboxWidget extends WidgetType {
|
||||
constructor(readonly checked: boolean) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: CheckboxWidget) {
|
||||
return other.checked === this.checked
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('input')
|
||||
el.type = 'checkbox'
|
||||
el.className = 'cm-rm-task'
|
||||
el.checked = this.checked
|
||||
// 用 mousedown 并阻止默认行为:否则编辑器先移动光标,行被展开为源码,点击落空
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
const pos = view.posAtDOM(el)
|
||||
const marker = view.state.sliceDoc(pos, pos + 3)
|
||||
if (!/^\[[ x]\]$/i.test(marker)) return
|
||||
view.dispatch({ changes: { from: pos + 1, to: pos + 2, insert: this.checked ? ' ' : 'x' } })
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export class HorizontalRuleWidget extends WidgetType {
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const el = document.createElement('span')
|
||||
el.className = 'cm-rm-hr'
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
const COPY_ICON
|
||||
= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
|
||||
const CHECK_ICON
|
||||
= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
|
||||
|
||||
// 代码块右上角:语言标签(未激活时)+ 复制按钮
|
||||
export class CodeHeaderWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly lang: string,
|
||||
readonly showLang: boolean,
|
||||
// 根据组件所在位置取出代码块内容,由调用方结合语法树实现
|
||||
readonly codeAt: (view: EditorView, pos: number) => string | null,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: CodeHeaderWidget) {
|
||||
return other.lang === this.lang && other.showLang === this.showLang
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('span')
|
||||
el.className = 'cm-rm-code-header'
|
||||
if (this.showLang && this.lang) {
|
||||
const label = document.createElement('span')
|
||||
label.className = 'cm-rm-code-lang'
|
||||
label.textContent = this.lang
|
||||
el.append(label)
|
||||
}
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'cm-rm-code-copy'
|
||||
btn.title = '复制代码'
|
||||
btn.innerHTML = COPY_ICON
|
||||
btn.addEventListener('mousedown', e => e.preventDefault())
|
||||
btn.addEventListener('click', async () => {
|
||||
const code = this.codeAt(view, view.posAtDOM(el))
|
||||
if (code === null || !(await copyText(code))) return
|
||||
btn.innerHTML = CHECK_ICON
|
||||
btn.classList.add('cm-rm-copied')
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = COPY_ICON
|
||||
btn.classList.remove('cm-rm-copied')
|
||||
}, 1500)
|
||||
})
|
||||
el.append(btn)
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly src: string,
|
||||
readonly alt: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: ImageWidget) {
|
||||
return other.src === this.src && other.alt === this.alt
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const wrap = document.createElement('span')
|
||||
wrap.className = 'cm-rm-image'
|
||||
const img = document.createElement('img')
|
||||
img.src = this.src
|
||||
img.alt = this.alt
|
||||
img.loading = 'lazy'
|
||||
img.decoding = 'async'
|
||||
// 图片加载完成后高度变化,需通知编辑器重新测量,否则光标定位会错位
|
||||
img.addEventListener('load', () => view.requestMeasure())
|
||||
// 组件内的事件默认被编辑器忽略,需手动把光标移到图片语法处以展开源码
|
||||
wrap.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
// 先设光标再聚焦:focus() 会把编辑器选区写回 DOM,反之会读到残留在别处(如表格单元格)的 DOM 选区
|
||||
view.dispatch({ selection: { anchor: view.posAtDOM(wrap) } })
|
||||
view.focus()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
wrap.classList.add('cm-rm-image-error')
|
||||
wrap.textContent = this.alt || this.src
|
||||
})
|
||||
wrap.append(img)
|
||||
return wrap
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return 200
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 扩展语法(公式、Mermaid、emoji、脚注、目录) ----
|
||||
|
||||
// 点击渲染结果时把光标移到源码中(offset 为相对组件起点的位置)以展开编辑,做法同图片
|
||||
function editOnMousedown(el: HTMLElement, view: EditorView, offset: number) {
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
view.dispatch({ selection: { anchor: Math.min(view.posAtDOM(el) + offset, view.state.doc.length) } })
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function showRenderError(el: HTMLElement, err: unknown) {
|
||||
const pre = document.createElement('pre')
|
||||
pre.className = 'cm-rm-render-error'
|
||||
pre.textContent = err instanceof Error ? err.message : String(err)
|
||||
el.replaceChildren(pre)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公式。editOffset 为 null 时是编辑中显示在源码下方的预览(不响应点击,内容变化时原地更新),
|
||||
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
|
||||
*/
|
||||
export class MathWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly tex: string,
|
||||
readonly display: boolean,
|
||||
readonly editOffset: number | null,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: MathWidget) {
|
||||
return other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement(this.display ? 'div' : 'span')
|
||||
el.className = this.display ? 'cm-rm-math-block' : 'cm-rm-math'
|
||||
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
||||
else editOnMousedown(el, view, this.editOffset)
|
||||
this.render(el, view)
|
||||
return el
|
||||
}
|
||||
|
||||
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
|
||||
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
||||
this.render(dom, view)
|
||||
return true
|
||||
}
|
||||
|
||||
private render(el: HTMLElement, view: EditorView) {
|
||||
if (!this.tex.trim()) {
|
||||
el.textContent = '空公式'
|
||||
el.classList.add('cm-rm-placeholder')
|
||||
return
|
||||
}
|
||||
el.classList.remove('cm-rm-placeholder')
|
||||
// 加载 KaTeX 前先显示源码
|
||||
if (!el.firstChild) el.textContent = this.tex
|
||||
void renderMath(el, this.tex, this.display).then(() => view.requestMeasure())
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return this.display ? 60 : -1
|
||||
}
|
||||
}
|
||||
|
||||
const rendererMounts = new WeakMap<HTMLElement, () => void>()
|
||||
|
||||
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
|
||||
export class CodeRendererWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
readonly editOffset: number | null,
|
||||
readonly language: string,
|
||||
readonly path: string,
|
||||
readonly renderer: OwnedRenderer,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: CodeRendererWidget) {
|
||||
return other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('div')
|
||||
el.className = this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render'
|
||||
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
||||
else editOnMousedown(el, view, this.editOffset)
|
||||
this.mount(el, view)
|
||||
return el
|
||||
}
|
||||
|
||||
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
||||
dom.className = `${this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render'} cm-rm-preview`
|
||||
this.destroy(dom)
|
||||
this.mount(dom, view)
|
||||
return true
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
rendererMounts.get(dom)?.()
|
||||
rendererMounts.delete(dom)
|
||||
}
|
||||
|
||||
private mount(el: HTMLElement, view: EditorView) {
|
||||
let stop = () => {}
|
||||
const render = () => {
|
||||
stop()
|
||||
stop = mountContent(el, context => Promise.resolve(this.renderer.render({
|
||||
...context,
|
||||
source: this.code,
|
||||
language: this.language,
|
||||
path: this.path,
|
||||
colorMode: document.documentElement.dataset.rmColorMode === 'dark' ? 'dark' : 'light',
|
||||
})).then((cleanup) => {
|
||||
requestAnimationFrame(() => view.requestMeasure()); return cleanup
|
||||
}), (error) => {
|
||||
showRenderError(el, error)
|
||||
this.renderer.fail(error)
|
||||
view.requestMeasure()
|
||||
})
|
||||
}
|
||||
render()
|
||||
window.addEventListener('rawmark-appearance', render)
|
||||
rendererMounts.set(el, () => {
|
||||
window.removeEventListener('rawmark-appearance', render); stop()
|
||||
})
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return 240
|
||||
}
|
||||
}
|
||||
|
||||
export class EmojiWidget extends WidgetType {
|
||||
constructor(readonly name: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: EmojiWidget) {
|
||||
return other.name === this.name
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('span')
|
||||
el.className = 'cm-rm-emoji'
|
||||
// 名称表加载前或名称未知时显示原文
|
||||
el.textContent = `:${this.name}:`
|
||||
void emojiFor(this.name).then((e) => {
|
||||
if (e) el.textContent = e
|
||||
})
|
||||
editOnMousedown(el, view, 1)
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
export class FootnoteRefWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly label: string,
|
||||
readonly definition: string | undefined,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: FootnoteRefWidget) {
|
||||
return other.label === this.label && other.definition === this.definition
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('sup')
|
||||
el.className = 'cm-rm-footnote-ref'
|
||||
el.textContent = this.label
|
||||
el.title = this.definition ?? '未找到脚注定义'
|
||||
editOnMousedown(el, view, 2)
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
export interface TocEntry {
|
||||
level: number
|
||||
text: string
|
||||
pos: number
|
||||
}
|
||||
|
||||
export class TocWidget extends WidgetType {
|
||||
constructor(readonly entries: TocEntry[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: TocWidget) {
|
||||
return JSON.stringify(other.entries) === JSON.stringify(this.entries)
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'cm-rm-toc'
|
||||
if (!this.entries.length) {
|
||||
el.textContent = '目录(还没有标题)'
|
||||
el.classList.add('cm-rm-placeholder')
|
||||
}
|
||||
const minLevel = Math.min(...this.entries.map(e => e.level))
|
||||
for (const entry of this.entries) {
|
||||
const item = document.createElement('a')
|
||||
item.className = 'cm-rm-toc-item'
|
||||
item.style.paddingLeft = `${(entry.level - minLevel) * 1.2}em`
|
||||
item.textContent = entry.text
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
view.dispatch({ selection: { anchor: entry.pos }, effects: EditorView.scrollIntoView(entry.pos, { y: 'start', yMargin: 24 }) })
|
||||
view.focus()
|
||||
})
|
||||
el.append(item)
|
||||
}
|
||||
// 点击目录空白处展开 [TOC] 源码
|
||||
editOnMousedown(el, view, 0)
|
||||
return el
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { authApi } from '@@/apis/auth'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { connectEvents } from './events'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('首次连接和重连均补同步,停止后不再连接', async () => {
|
||||
vi.spyOn(authApi, 'ticket').mockResolvedValue({ url: '/api/v1/resources/once/events/', expiresAt: '2030-01-01T00:00:00Z' })
|
||||
vi.useFakeTimers()
|
||||
class Socket {
|
||||
static CLOSED = 3
|
||||
static instances: Socket[] = []
|
||||
readyState = 0
|
||||
onopen?: () => void
|
||||
onclose?: () => void
|
||||
close() {
|
||||
this.readyState = Socket.CLOSED
|
||||
}
|
||||
|
||||
constructor() {
|
||||
Socket.instances.push(this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', Socket)
|
||||
let synchronized = 0
|
||||
const stop = connectEvents({ onEvents: () => {}, onResync: () => {
|
||||
synchronized++
|
||||
} })
|
||||
await vi.waitFor(() => expect(Socket.instances).toHaveLength(1))
|
||||
Socket.instances[0].onopen!()
|
||||
expect(synchronized).toBe(1)
|
||||
Socket.instances[0].onclose!()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
Socket.instances[1].onopen!()
|
||||
expect(synchronized).toBe(2)
|
||||
stop()
|
||||
Socket.instances[1].onclose!()
|
||||
vi.advanceTimersByTime(30000)
|
||||
expect(Socket.instances).toHaveLength(2)
|
||||
expect(authApi.ticket).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { authApi } from '@@/apis/auth'
|
||||
// 订阅仓库事件;每次连接领取一次性票据,重连后补全量同步。
|
||||
import { isCancel } from 'axios'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
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 {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
let socket: WebSocket | undefined
|
||||
let delay = 1000
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let stopped = false
|
||||
let connecting = false
|
||||
|
||||
function reconnect() {
|
||||
if (stopped || version !== user.getSessionVersion()) return
|
||||
timer = setTimeout(() => void connect(), delay)
|
||||
delay = Math.min(delay * 2, 30000)
|
||||
}
|
||||
async function connect() {
|
||||
if (stopped || connecting) return
|
||||
connecting = true
|
||||
try {
|
||||
const ticket = await authApi.ticket({ kind: 'events' })
|
||||
user.assertSession(version)
|
||||
if (stopped) return
|
||||
const url = new URL(ticket.url, location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(url)
|
||||
socket = ws
|
||||
ws.onopen = () => {
|
||||
delay = 1000
|
||||
handlers.onResync()
|
||||
}
|
||||
ws.onmessage = event => handlers.onEvents(JSON.parse(event.data))
|
||||
ws.onclose = reconnect
|
||||
} catch (error) {
|
||||
if (!isCancel(error)) reconnect()
|
||||
} finally {
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible' && (!socket || socket.readyState === WebSocket.CLOSED)) {
|
||||
clearTimeout(timer)
|
||||
void connect()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
socket?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fuzzyMatch } from './fuzzy'
|
||||
|
||||
const match = (q: string, path: string) => fuzzyMatch(q, path, path.lastIndexOf('/') + 1)
|
||||
function rank(q: string, paths: string[]) {
|
||||
return paths
|
||||
.map(p => ({ p, m: match(q, p) }))
|
||||
.filter(x => x.m)
|
||||
.sort((a, b) => b.m!.score - a.m!.score)
|
||||
.map(x => x.p)
|
||||
}
|
||||
|
||||
describe('fuzzyMatch', () => {
|
||||
it('不区分大小写,记录命中位置', () => {
|
||||
expect(match('READ', 'docs/readme')?.positions).toEqual([5, 6, 7, 8])
|
||||
})
|
||||
|
||||
it('按顺序零散命中,顺序不对则不匹配', () => {
|
||||
expect(match('dpl', 'deploy')?.positions).toEqual([0, 2, 3])
|
||||
expect(match('lpd', 'deploy')).toBeNull()
|
||||
})
|
||||
|
||||
it('多个词都要命中,可分别落在目录和标题', () => {
|
||||
expect(match('项目 部署', '项目/部署笔记')).not.toBeNull()
|
||||
expect(match('项目 发布', '项目/部署笔记')).toBeNull()
|
||||
})
|
||||
|
||||
it('标题完全一致 > 标题开头 > 标题中间 > 目录 > 零散命中', () => {
|
||||
expect(rank('部署', ['部署/其他', '笔记/安装部署', '部署说明', '部 x 署', '部署'])).toEqual(['部署', '部署说明', '笔记/安装部署', '部署/其他', '部 x 署'])
|
||||
})
|
||||
|
||||
it('零散命中时紧凑、命中词首的排前', () => {
|
||||
expect(rank('rm', ['rawmark', 'readme', 'r-m'])[0]).toBe('r-m')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// 快速切换的模糊匹配。目标形如"目录/标题",空白分隔的每个词都要命中;
|
||||
// 连续命中标题 > 连续命中目录 > 按顺序零散命中(如用首字母找笔记)
|
||||
|
||||
export interface FuzzyMatch {
|
||||
score: number
|
||||
// 命中字符在目标中的下标,用于高亮
|
||||
positions: number[]
|
||||
}
|
||||
|
||||
const BOUNDARY = new Set(['/', ' ', '-', '_', '.'])
|
||||
|
||||
function isBoundary(target: string, i: number): boolean {
|
||||
return i === 0 || BOUNDARY.has(target[i - 1])
|
||||
}
|
||||
|
||||
function matchTerm(term: string, target: string, lower: string, titleStart: number): FuzzyMatch | null {
|
||||
// 取最后一处连续命中:标题在末尾,优先落在标题上
|
||||
const at = lower.lastIndexOf(term)
|
||||
if (at >= 0) {
|
||||
let score = at >= titleStart ? 100 : 50
|
||||
if (at === titleStart) score += 30
|
||||
else if (isBoundary(target, at)) score += 10
|
||||
return { score, positions: Array.from(term, (_, k) => at + k) }
|
||||
}
|
||||
const positions: number[] = []
|
||||
let from = 0
|
||||
for (const ch of term) {
|
||||
const i = lower.indexOf(ch, from)
|
||||
if (i < 0) return null
|
||||
positions.push(i)
|
||||
from = i + 1
|
||||
}
|
||||
// 越分散得分越低,命中词首的字符加分
|
||||
const spread = positions[positions.length - 1] - positions[0] + 1 - positions.length
|
||||
const boundaries = positions.filter(i => isBoundary(target, i)).length
|
||||
return { score: Math.max(1, 20 - spread + boundaries * 3), positions }
|
||||
}
|
||||
|
||||
/** titleStart 为标题在 target 中的起始下标;不匹配时返回 null */
|
||||
export function fuzzyMatch(query: string, target: string, titleStart: number): FuzzyMatch | null {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return null
|
||||
const lower = target.toLowerCase()
|
||||
let score = 0
|
||||
const positions = new Set<number>()
|
||||
for (const term of terms) {
|
||||
const m = matchTerm(term, target, lower, titleStart)
|
||||
if (!m) return null
|
||||
score += m.score
|
||||
m.positions.forEach(p => positions.add(p))
|
||||
}
|
||||
// 标题与输入完全一致时排在最前
|
||||
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
|
||||
return { score, positions: [...positions].sort((a, b) => a - b) }
|
||||
}
|
||||
|
||||
/** 按命中与否把文本切成连续片段,用于高亮 */
|
||||
export function segments(text: string, hits: Set<number>) {
|
||||
const out: { text: string, hit: boolean }[] = []
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const hit = hits.has(i)
|
||||
const last = out[out.length - 1]
|
||||
if (last?.hit === hit) last.text += text[i]
|
||||
else out.push({ text: text[i], hit })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { toasts } from '@@/utils/notifications'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { CanceledError } from 'axios'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { pinia } from '@/pinia'
|
||||
import * as preferences from '@/pinia/stores/settings'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import Workspace from './index.vue'
|
||||
import { flushAll, readyToReload } from './workspace'
|
||||
|
||||
vi.mock('./workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./workspace')>()
|
||||
return { ...actual, flushAll: vi.fn(), readyToReload: vi.fn(), startWorkspace: vi.fn(), stopWorkspace: vi.fn() }
|
||||
})
|
||||
|
||||
const user = useUserStore(pinia)
|
||||
let wrapper: VueWrapper | undefined
|
||||
beforeEach(() => {
|
||||
user.setToken('rms_before')
|
||||
toasts.splice(0)
|
||||
vi.mocked(flushAll).mockResolvedValue(undefined)
|
||||
vi.mocked(readyToReload).mockReturnValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
vi.restoreAllMocks()
|
||||
user.setToken('')
|
||||
})
|
||||
async function mountWorkspace() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }, { path: '/login', component: { template: '<div />' } }] })
|
||||
await router.push('/')
|
||||
wrapper = shallowMount(Workspace, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('正常退出先等待保存,再注销服务端会话', async () => {
|
||||
const logout = vi.spyOn(user, 'logout').mockResolvedValue(undefined)
|
||||
let finish!: () => void
|
||||
vi.mocked(flushAll).mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const component = await mountWorkspace()
|
||||
component.findComponent(Sidebar).vm.$emit('logout')
|
||||
expect(logout).not.toHaveBeenCalled()
|
||||
finish()
|
||||
await flushPromises()
|
||||
expect(logout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('等待保存期间切换会话,旧退出操作不能注销新身份', async () => {
|
||||
const logout = vi.spyOn(user, 'logout').mockResolvedValue(undefined)
|
||||
let finish!: () => void
|
||||
vi.mocked(flushAll).mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const component = await mountWorkspace()
|
||||
component.findComponent(Sidebar).vm.$emit('logout')
|
||||
user.setToken('rms_after')
|
||||
finish()
|
||||
await flushPromises()
|
||||
expect(logout).not.toHaveBeenCalled()
|
||||
expect(user.token).toBe('rms_after')
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('侧栏拖动期间切换会话,旧手势不能保存新身份的设置', async () => {
|
||||
const save = vi.spyOn(preferences, 'updateSettings').mockResolvedValue(undefined)
|
||||
const component = await mountWorkspace()
|
||||
const handle = component.get('.rm-sidebar-resize')
|
||||
Object.assign(handle.element, { setPointerCapture: vi.fn() })
|
||||
await handle.trigger('pointerdown', { clientX: 100, pointerId: 1 })
|
||||
await handle.trigger('pointermove', { clientX: 140, pointerId: 1 })
|
||||
user.setToken('rms_after')
|
||||
await handle.trigger('pointerup', { pointerId: 1 })
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('同一会话的侧栏拖动正常保存宽度', async () => {
|
||||
const save = vi.spyOn(preferences, 'updateSettings').mockResolvedValue(undefined)
|
||||
const component = await mountWorkspace()
|
||||
const handle = component.get('.rm-sidebar-resize')
|
||||
Object.assign(handle.element, { setPointerCapture: vi.fn() })
|
||||
await handle.trigger('pointerdown', { clientX: 100, pointerId: 1 })
|
||||
await handle.trigger('pointermove', { clientX: 140, pointerId: 1 })
|
||||
await handle.trigger('pointerup', { pointerId: 1 })
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({ sidebarWidth: preferences.settings.sidebarWidth + 40 })
|
||||
})
|
||||
|
||||
it('侧栏设置请求因会话切换取消时不提示保存失败', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
vi.spyOn(preferences, 'updateSettings').mockImplementationOnce(() => new Promise<void>((_, fail) => {
|
||||
reject = fail
|
||||
}))
|
||||
const component = await mountWorkspace()
|
||||
const handle = component.get('.rm-sidebar-resize')
|
||||
Object.assign(handle.element, { setPointerCapture: vi.fn() })
|
||||
await handle.trigger('pointerdown', { clientX: 100, pointerId: 1 })
|
||||
await handle.trigger('pointermove', { clientX: 140, pointerId: 1 })
|
||||
await handle.trigger('pointerup', { pointerId: 1 })
|
||||
user.setToken('rms_after')
|
||||
reject(new CanceledError('会话已变化'))
|
||||
await flushPromises()
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import AppStatus from '@@/components/AppStatus.vue'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Icon from '../../common/components/Icon.vue'
|
||||
import { registerCommand } from '../../common/utils/commands'
|
||||
import { noteTitle } from '../../common/utils/paths'
|
||||
import { settings, SettingsAppearanceError, SIDEBAR_WIDTH, updateSettings } from '../../pinia/stores/settings'
|
||||
import SettingsDialog from '../settings/index.vue'
|
||||
import EditorChrome from './components/EditorChrome.vue'
|
||||
import EditorPane from './components/EditorPane.vue'
|
||||
import ImageDialog from './components/ImageDialog.vue'
|
||||
import ImageGallery from './components/ImageGallery.vue'
|
||||
|
||||
import ImportDialog from './components/ImportDialog.vue'
|
||||
import KnowledgePanel from './components/KnowledgePanel.vue'
|
||||
import MoveDialog from './components/MoveDialog.vue'
|
||||
import NoteBanner from './components/NoteBanner.vue'
|
||||
import OutlinePanel from './components/OutlinePanel.vue'
|
||||
import QuickSwitcher from './components/QuickSwitcher.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import TabBar from './components/TabBar.vue'
|
||||
import { flushAll, readyToReload, restore, startWorkspace, stopWorkspace, ws } from './workspace'
|
||||
|
||||
async function prepareReload() {
|
||||
await flushAll(); return readyToReload()
|
||||
}
|
||||
|
||||
const user = useUserStore()
|
||||
const router = useRouter()
|
||||
async function prepareMaintenance() {
|
||||
await flushAll(); if (!readyToReload()) throw new Error('仍有未保存内容或正在上传、操作文件,请完成后再试')
|
||||
}
|
||||
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
|
||||
const resizingSidebar = ref(false)
|
||||
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
|
||||
const initializing = ref(true)
|
||||
|
||||
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
|
||||
function startResize(e: PointerEvent) {
|
||||
const version = user.getSessionVersion()
|
||||
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 (version !== user.getSessionVersion()) return
|
||||
if (width !== startWidth) {
|
||||
updateSettings({ sidebarWidth: width }).catch((error) => {
|
||||
if (!isCancel(error)) toast(error instanceof SettingsAppearanceError ? error.message : '保存侧栏宽度失败')
|
||||
})
|
||||
}
|
||||
}
|
||||
handle.addEventListener('pointermove', onMove)
|
||||
handle.addEventListener('pointerup', onUp)
|
||||
handle.addEventListener('lostpointercapture', onUp)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const version = user.getSessionVersion()
|
||||
try {
|
||||
await flushAll()
|
||||
user.assertSession(version)
|
||||
if (!readyToReload()) {
|
||||
toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出')
|
||||
return
|
||||
}
|
||||
await user.logout()
|
||||
} catch (error) {
|
||||
if (isCancel(error)) return
|
||||
// 注销错误已由请求层提示。
|
||||
return
|
||||
}
|
||||
stopWorkspace()
|
||||
await router.replace('/login')
|
||||
}
|
||||
|
||||
// 移动端:从屏幕左边缘右滑打开侧栏
|
||||
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) {
|
||||
if (isCancel(err)) return; toast(`工作区初始化失败,请重新加载:${err instanceof ApiError ? err.message : '网络或本地存储错误'}`)
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unregisterLogout()
|
||||
stopWorkspace()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppStatus :before-reload="prepareReload" />
|
||||
<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" v-model="ws.settings" :prepare-maintenance="prepareMaintenance" :restore-note="restore" @import-notes="ws.settings = null; ws.importing = ''" />
|
||||
<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>
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NoteMetadata } from '@/pages/workspace/apis/type'
|
||||
import { isCancel } from 'axios'
|
||||
import { reactive } from 'vue'
|
||||
import { workspaceApi } from '@/pages/workspace/apis'
|
||||
|
||||
export const metadata = reactive({ notes: [] as NoteMetadata[], ready: false, error: '' })
|
||||
let request = 0
|
||||
export async function refreshMetadata() {
|
||||
const current = ++request
|
||||
try {
|
||||
const result = await workspaceApi.metadata()
|
||||
if (current !== request) return
|
||||
metadata.notes = result.notes
|
||||
metadata.ready = result.ready
|
||||
metadata.error = ''
|
||||
} catch (error) {
|
||||
if (isCancel(error)) return
|
||||
if (current === request) metadata.error = error instanceof Error ? error.message : '关系索引读取失败'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 中文按字、其他文字按连续词计数;界面统计不改变 Markdown 原文。
|
||||
export function readingStats(text: string) {
|
||||
const han = text.match(/\p{Script=Han}/gu)?.length ?? 0
|
||||
const words = han + (text.replace(/\p{Script=Han}/gu, ' ').match(/[\p{L}\p{N}]+/gu)?.length ?? 0)
|
||||
return { words, characters: Array.from(text.replace(/\s/gu, '')).length, minutes: Math.ceil(words / 300) }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { minimalPaths, rangePaths } from './selection'
|
||||
|
||||
it('消除重叠主体,不受相似目录排序干扰', () => {
|
||||
expect(minimalPaths(['a/b.md', 'a-b.md', 'a', 'a', 'z.md'])).toEqual(['a', 'a-b.md', 'z.md'])
|
||||
})
|
||||
it('shift 范围只使用可见项,允许向上选择和丢失锚点', () => {
|
||||
expect(rangePaths(['a', 'a/x.md', 'b'], 'b', 'a')).toEqual(['a', 'a/x.md', 'b'])
|
||||
expect(rangePaths(['a', 'b'], 'removed', 'b')).toEqual(['b'])
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// 父目录已经选中时不重复操作其内部项。
|
||||
export function minimalPaths(paths: Iterable<string>): string[] {
|
||||
const out: string[] = []
|
||||
const roots = new Set<string>()
|
||||
for (const path of [...new Set(paths)].sort()) {
|
||||
let parent = path
|
||||
let covered = false
|
||||
while (parent.includes('/')) {
|
||||
parent = parent.slice(0, parent.lastIndexOf('/'))
|
||||
if (roots.has(parent)) {
|
||||
covered = true; break
|
||||
}
|
||||
}
|
||||
if (!covered) {
|
||||
out.push(path); roots.add(path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function rangePaths(visible: string[], anchor: string | null, target: string): string[] {
|
||||
const from = anchor === null ? -1 : visible.indexOf(anchor)
|
||||
const to = visible.indexOf(target)
|
||||
return from < 0 || to < 0 ? [target] : visible.slice(Math.min(from, to), Math.max(from, to) + 1)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { App } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import { expanded } from './tree'
|
||||
import * as workspace from './workspace'
|
||||
|
||||
let app: App
|
||||
let host: HTMLElement
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
|
||||
Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [
|
||||
{ path: 'source', dir: true },
|
||||
{ path: 'source/source-note.md', dir: false },
|
||||
{ path: 'target', dir: true },
|
||||
{ path: 'target/existing-note.md', dir: false },
|
||||
{ path: 'target/nested', dir: true },
|
||||
{ path: 'target/nested/child-note.md', dir: false },
|
||||
{ path: 'root-note.md', dir: false },
|
||||
].map(entry => ({ ...entry, size: 0, mtime: 0 })) })
|
||||
expanded.clear()
|
||||
for (const dir of ['source', 'target', 'target/nested']) expanded.add(dir)
|
||||
host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
app = createApp(Sidebar)
|
||||
app.mount(host)
|
||||
await nextTick()
|
||||
})
|
||||
afterEach(() => {
|
||||
app.unmount()
|
||||
host.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function row(name: string): HTMLElement {
|
||||
const node = [...host.querySelectorAll<HTMLElement>('.rm-tree-row')].find(row => row.querySelector('.rm-tree-name')?.textContent === name)
|
||||
if (!node) throw new Error(`找不到目录项:${name}`)
|
||||
return node
|
||||
}
|
||||
|
||||
function drag(target: Element | Window, type: string, source = 'source/source-note.md', relatedTarget: EventTarget | null = null, internal = true) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
dataTransfer: { value: { types: [internal ? 'application/x-rawmark-path' : 'text/plain'], getData: () => source } },
|
||||
relatedTarget: { value: relatedTarget },
|
||||
})
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
describe('目录树拖放目标', () => {
|
||||
it.each(['', '.rm-tree-name', '.rm-tree-more'])('文件行落点 %s 接收至所在文件夹,不能冒泡到根目录', (selector) => {
|
||||
const target = row('existing-note')
|
||||
drag(selector ? target.querySelector(selector)! : target, 'drop')
|
||||
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'target/source-note.md')
|
||||
})
|
||||
|
||||
it.each(['', '.rm-tree-name', 'svg'])('文件夹行落点 %s 接收至该文件夹', (selector) => {
|
||||
const target = row('target')
|
||||
drag(selector ? target.querySelector(selector)! : target, 'drop')
|
||||
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'target/source-note.md')
|
||||
})
|
||||
|
||||
it('深层文件使用直接父目录', () => {
|
||||
drag(row('child-note'), 'drop')
|
||||
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'target/nested/source-note.md')
|
||||
})
|
||||
|
||||
it.each(['root-note', 'blank'])('根目标 %s 只移动一次', (target) => {
|
||||
drag(target === 'blank' ? host.querySelector('.rm-tree')! : row(target), 'drop')
|
||||
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'source-note.md')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['existing-note', 'target/existing-note.md'],
|
||||
['target', 'target/existing-note.md'],
|
||||
['target', 'target'],
|
||||
['nested', 'target'],
|
||||
['child-note', 'target'],
|
||||
])('无效移动 %s / %s 不得冒泡后改成移动到根目录', (target, source) => {
|
||||
const event = drag(row(target), 'drop', source)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(workspace.move).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('属于节点的列表留白不能交给根目录接收', () => {
|
||||
drag(row('target').parentElement!.querySelector('ul')!, 'drop')
|
||||
expect(workspace.move).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('悬停文件只高亮直接父目录,进入根空白后切换为根区域提示', async () => {
|
||||
drag(row('child-note').querySelector('.rm-tree-name')!, 'dragover')
|
||||
await nextTick()
|
||||
expect(row('nested').classList.contains('drop')).toBe(true)
|
||||
expect(row('child-note').classList.contains('drop')).toBe(false)
|
||||
expect(host.querySelector('.rm-tree')!.classList.contains('drop')).toBe(false)
|
||||
drag(host.querySelector('.rm-tree')!, 'dragover')
|
||||
await nextTick()
|
||||
expect(host.querySelectorAll('.rm-tree-row.drop')).toHaveLength(0)
|
||||
expect(host.querySelector('.rm-tree')!.classList.contains('drop')).toBe(true)
|
||||
})
|
||||
|
||||
it('离开树之前在行内切换子元素不会清除目标', async () => {
|
||||
const target = row('existing-note')
|
||||
drag(target, 'dragover')
|
||||
drag(target.querySelector('.rm-tree-name')!, 'dragleave', undefined, target.querySelector('.rm-tree-more'))
|
||||
await nextTick()
|
||||
expect(row('target').classList.contains('drop')).toBe(true)
|
||||
drag(target, 'dragleave', undefined, document.body)
|
||||
await nextTick()
|
||||
expect(host.querySelectorAll('.drop')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['dragend', 'blur', 'Escape', 'drop'])('结束或取消 %s 后清除所有高亮', async (type) => {
|
||||
drag(row('existing-note'), 'dragover')
|
||||
await nextTick()
|
||||
expect(host.querySelector('.drop')).not.toBeNull()
|
||||
if (type === 'Escape') window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
else if (type === 'drop') drag(row('existing-note'), 'drop')
|
||||
else window.dispatchEvent(new Event(type))
|
||||
await nextTick()
|
||||
expect(host.querySelectorAll('.drop')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('不接管外部文本拖放,空的内部载荷也不得移动文件', () => {
|
||||
expect(drag(row('existing-note'), 'dragover', '', null, false).defaultPrevented).toBe(false)
|
||||
drag(row('existing-note'), 'drop', '', null, false)
|
||||
drag(row('existing-note'), 'drop', '')
|
||||
expect(workspace.move).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Gemoji } from 'gemoji'
|
||||
import type { IconName } from '../../common/utils/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)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { App } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import { workspaceApi } from '@/pages/workspace/apis'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import { expanded, selectedPaths, treeIconTarget } from './tree'
|
||||
import * as workspace from './workspace'
|
||||
|
||||
let app: App
|
||||
let host: HTMLElement
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(workspace, 'saveTreeIcon').mockResolvedValue()
|
||||
vi.spyOn(workspace, 'openNote').mockResolvedValue()
|
||||
vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
|
||||
localStorage.removeItem('rawmark.recent-tree-icons')
|
||||
Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [
|
||||
{ path: 'folder', dir: true },
|
||||
{ path: 'folder/a.md', dir: false },
|
||||
{ path: 'folder/b.md', dir: false },
|
||||
{ path: 'folder/c.md', dir: false },
|
||||
].map(e => ({ ...e, size: 0, mtime: 0 })) })
|
||||
expanded.clear()
|
||||
selectedPaths.clear()
|
||||
treeIconTarget.value = null
|
||||
host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
app = createApp(Sidebar)
|
||||
app.mount(host)
|
||||
await nextTick()
|
||||
})
|
||||
afterEach(() => {
|
||||
app.unmount(); host.remove(); vi.restoreAllMocks()
|
||||
})
|
||||
const row = (name: string) => [...host.querySelectorAll<HTMLElement>('.rm-tree-row')].find(e => e.querySelector('.rm-tree-name')?.textContent?.trim() === name)!
|
||||
async function click(element: Element, init: MouseEventInit = {}) {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init })); await nextTick()
|
||||
}
|
||||
const button = (name: string) => document.querySelector<HTMLElement>(`[aria-label="${name}"]`)!
|
||||
async function picker() {
|
||||
await click(row('folder').querySelector('.rm-tree-icon')!)
|
||||
await vi.waitFor(() => expect(button('搜索图标')).not.toBeNull())
|
||||
}
|
||||
|
||||
describe('文件树图标操作', () => {
|
||||
it('保留右侧目录菜单,点击默认图标不折叠目录或改变多选', async () => {
|
||||
expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent?.trim()).toBe('文件目录')
|
||||
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📂')
|
||||
await click(row('folder'))
|
||||
expect(row('a').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📄')
|
||||
await click(row('a'), { ctrlKey: true })
|
||||
const selected = [...selectedPaths]
|
||||
await picker()
|
||||
expect(expanded.has('folder')).toBe(true)
|
||||
expect([...selectedPaths]).toEqual(selected)
|
||||
expect(workspace.openNote).not.toHaveBeenCalled()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await nextTick()
|
||||
expect(treeIconTarget.value).toBeNull()
|
||||
expect(document.activeElement).toBe(row('folder').querySelector('.rm-tree-icon'))
|
||||
})
|
||||
|
||||
it('中文搜索表情并保存到当前节点,最近使用只记成功操作', async () => {
|
||||
await picker()
|
||||
const input = button('搜索图标') as HTMLInputElement
|
||||
input.value = '文件夹'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await vi.waitFor(() => expect(button('文件夹 目录 打开')).not.toBeNull())
|
||||
await click(button('文件夹 目录 打开'))
|
||||
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
|
||||
expect(workspace.saveTreeIcon).toHaveBeenCalledExactlyOnceWith('folder', { kind: 'emoji', value: '📂' })
|
||||
expect(JSON.parse(localStorage.getItem('rawmark.recent-tree-icons')!)).toEqual([{ kind: 'emoji', value: '📂' }])
|
||||
})
|
||||
|
||||
it('保存失败保留弹窗与明确错误,恢复默认成功后关闭', async () => {
|
||||
vi.mocked(workspace.saveTreeIcon).mockRejectedValueOnce(new Error('磁盘只读'))
|
||||
await picker()
|
||||
await click([...document.querySelectorAll('button')].find(e => e.textContent?.trim() === '恢复默认图标')!)
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain('磁盘只读'))
|
||||
expect(treeIconTarget.value?.path).toBe('folder')
|
||||
expect(localStorage.getItem('rawmark.recent-tree-icons')).toBeNull()
|
||||
await click([...document.querySelectorAll('button')].find(e => e.textContent?.trim() === '恢复默认图标')!)
|
||||
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
|
||||
expect(workspace.saveTreeIcon).toHaveBeenLastCalledWith('folder', null)
|
||||
})
|
||||
|
||||
it('线性图标可搜索,右键入口选择同一节点', async () => {
|
||||
row('folder').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent?.trim() === '修改图标')!)
|
||||
await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent?.trim() === '线性图标')!)
|
||||
const input = button('搜索图标') as HTMLInputElement
|
||||
input.value = '任务'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick()
|
||||
expect(document.querySelectorAll('.rm-tree-icon-grid button')).toHaveLength(1)
|
||||
await click(button('任务'))
|
||||
expect(workspace.saveTreeIcon).toHaveBeenCalledWith('folder', { kind: 'builtin', value: 'task' })
|
||||
})
|
||||
|
||||
it('ctrl 和 Shift 仍按可见行选择,快速展开/收起保留最新状态', async () => {
|
||||
await click(row('folder'))
|
||||
await click(row('a'))
|
||||
await click(row('c'), { shiftKey: true })
|
||||
expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/b.md', 'folder/c.md'])
|
||||
await click(row('b'), { ctrlKey: true })
|
||||
expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/c.md'])
|
||||
await click(row('folder'))
|
||||
await click(row('folder'))
|
||||
await vi.waitFor(() => expect(row('c')).toBeDefined())
|
||||
expect(expanded.has('folder')).toBe(true)
|
||||
await click(row('folder'))
|
||||
await vi.waitFor(() => expect(row('a')).toBeUndefined())
|
||||
expect(selectedPaths.has('folder/a.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('从图标按住拖动仍使用整行的拖放契约,不能误开选择框', async () => {
|
||||
const setData = vi.fn()
|
||||
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { setData, effectAllowed: '' } })
|
||||
row('folder').querySelector('.rm-tree-icon')!.dispatchEvent(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(setData).toHaveBeenCalledExactlyOnceWith('application/x-rawmark-path', 'folder')
|
||||
expect(treeIconTarget.value).toBeNull()
|
||||
})
|
||||
|
||||
it('持久属性直接来自目录 API,不维护第二份图标映射', async () => {
|
||||
workspace.ws.entries[0].icon = { kind: 'builtin', value: 'task' }
|
||||
await nextTick()
|
||||
expect(row('folder').querySelector('.rm-tree-icon svg')).not.toBeNull()
|
||||
workspace.ws.entries[0].icon = { kind: 'emoji', value: '📚' }
|
||||
await nextTick()
|
||||
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📚')
|
||||
})
|
||||
})
|
||||
|
||||
it('图标请求沿用认证、禁缓存和 null 重置契约', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"code":0,"data":{},"message":"成功"}'))
|
||||
await workspaceApi.setTreeIcon('folder/a.md', null)
|
||||
const request = fetch.mock.calls[0]![0] as Request
|
||||
expect(request.url).toBe('http://localhost:3000/api/v1/tree-icon')
|
||||
expect(request.method).toBe('PUT')
|
||||
expect(request.credentials).toBe('omit')
|
||||
expect(fetch.mock.calls[0]![1]).toEqual(expect.objectContaining({ cache: 'no-store' }))
|
||||
expect(await request.text()).toBe('{"path":"folder/a.md","icon":null}')
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Entry, TreeIcon } from '@@/apis/vault/type'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { basename, dirname, hiddenAssetPath } from '../../common/utils/paths'
|
||||
import { rangePaths } from './selection'
|
||||
import { ws } from './workspace'
|
||||
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
path: string
|
||||
dir: boolean
|
||||
icon?: TreeIcon
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' })
|
||||
|
||||
function build(entries: Entry[]): TreeNode[] {
|
||||
const root: TreeNode[] = []
|
||||
const dirs = new Map<string, TreeNode>()
|
||||
// 先按路径排序,保证父目录先于子项出现
|
||||
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
|
||||
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] }
|
||||
if (e.dir) dirs.set(e.path, node)
|
||||
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
|
||||
}
|
||||
const sort = (nodes: TreeNode[]) => {
|
||||
nodes.sort((a, b) => (a.dir !== b.dir ? (a.dir ? -1 : 1) : collator.compare(a.name, b.name)))
|
||||
nodes.forEach(n => sort(n.children))
|
||||
}
|
||||
sort(root)
|
||||
return root
|
||||
}
|
||||
|
||||
export const tree = computed(() => build(ws.entries.filter(entry => !hiddenAssetPath(entry.path))))
|
||||
|
||||
export const treeIconTarget = shallowRef<{ path: string, anchor: HTMLElement } | null>(null)
|
||||
|
||||
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
|
||||
export const dropDirectory = ref<string | null>(null)
|
||||
|
||||
const EXPANDED_KEY = 'rawmark.expanded'
|
||||
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
|
||||
watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])))
|
||||
|
||||
// 打开或新建笔记时展开其所在的各级目录
|
||||
watch(
|
||||
() => [ws.active, ws.renaming, ws.image],
|
||||
(paths) => {
|
||||
for (const p of paths) {
|
||||
for (let d = p ? dirname(p) : ''; d; d = dirname(d)) expanded.add(d)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const selectedPaths = reactive(new Set<string>())
|
||||
let anchor: string | null = null
|
||||
export const visiblePaths = computed(() => {
|
||||
const paths: string[] = []
|
||||
const visit = (nodes: TreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
paths.push(node.path); if (expanded.has(node.path)) visit(node.children)
|
||||
}
|
||||
}
|
||||
visit(tree.value)
|
||||
return paths
|
||||
})
|
||||
watch(visiblePaths, (paths) => {
|
||||
const visible = new Set(paths)
|
||||
for (const path of selectedPaths) {
|
||||
if (!visible.has(path)) selectedPaths.delete(path)
|
||||
}
|
||||
if (anchor && !visible.has(anchor)) anchor = null
|
||||
})
|
||||
|
||||
export function selectPath(path: string, event?: Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>) {
|
||||
const additive = event?.ctrlKey || event?.metaKey
|
||||
if (event?.shiftKey) {
|
||||
const range = rangePaths(visiblePaths.value, anchor, path)
|
||||
if (!additive) selectedPaths.clear()
|
||||
range.forEach(item => selectedPaths.add(item))
|
||||
return
|
||||
}
|
||||
if (!additive) selectedPaths.clear()
|
||||
if (additive && selectedPaths.has(path)) selectedPaths.delete(path)
|
||||
else selectedPaths.add(path)
|
||||
anchor = path
|
||||
}
|
||||
|
||||
export function contextSelection(path: string) {
|
||||
if (!selectedPaths.has(path)) selectPath(path)
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import type { Draft } from './drafts'
|
||||
import { pluginsApi } from '@@/apis/plugins'
|
||||
import { preferencesApi } from '@@/apis/preferences'
|
||||
import { trashApi } from '@@/apis/trash'
|
||||
import { vaultApi } from '@@/apis/vault'
|
||||
// @vitest-environment happy-dom
|
||||
import { toasts } from '@@/utils/notifications'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { CanceledError, isCancel } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { workspaceApi } from '@/pages/workspace/apis'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { pluginEvents } from '../../common/plugins/registry'
|
||||
import { drafts } from './drafts'
|
||||
import { insertImageFiles } from './editor/paste'
|
||||
import { connectEvents } from './events'
|
||||
import { attachView, closeTab, createNoteAt, editorStateOf, flush, importNotes, keepLocalVersion, move, openNote, readyToReload, refreshTree, remove, 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(vaultApi, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
|
||||
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
|
||||
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
|
||||
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
|
||||
const saved = new Map<string, string>()
|
||||
vi.spyOn(workspaceApi, '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(vaultApi, 'tree').mockResolvedValue(entries)
|
||||
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
|
||||
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
|
||||
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
||||
const read = vi.spyOn(vaultApi, '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(toasts.some(toast => toast.text.includes('正文读取失败'))).toBe(true))
|
||||
expect(observed).toEqual(['新正文'])
|
||||
vi.mocked(vaultApi.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
|
||||
handlers.onResync()
|
||||
await vi.waitFor(() => expect(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(vaultApi, '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(workspaceApi, '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(workspaceApi, 'writeFile').mockImplementation(() => new Promise((resolve, reject) => {
|
||||
finish = resolve; fail = reject
|
||||
}))
|
||||
vi.spyOn(vaultApi, '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(toasts.some(toast => toast.text.includes('创建写入失败'))).toBe(true)
|
||||
}
|
||||
expect(toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('保存与关闭', () => {
|
||||
it('关闭冲突标签前持久化冲突之后的新输入,草稿失败时保留标签', async () => {
|
||||
const v = await edit('conflict.md')
|
||||
vi.spyOn(workspaceApi, '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(workspaceApi, '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(workspaceApi, 'writeFile').mockImplementation(async (path, content) => {
|
||||
contents.set(path, content); return 'r2'
|
||||
})
|
||||
let finish!: (link: string) => void
|
||||
vi.spyOn(workspaceApi, '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(workspaceApi, 'writeFile').mockImplementation(async (path, content) => {
|
||||
contents.set(path, content); return 'r2'
|
||||
})
|
||||
let finish!: (link: string) => void
|
||||
vi.spyOn(workspaceApi, '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(vaultApi, 'tree').mockResolvedValue([{ path: 'upload/note.md', dir: false, size: 0, mtime: 0 }])
|
||||
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
|
||||
vi.spyOn(workspaceApi, 'writeFile').mockResolvedValue('r2')
|
||||
const moving = vi.spyOn(workspaceApi, 'move').mockResolvedValue(new Response())
|
||||
let finish!: (link: string) => void
|
||||
vi.spyOn(workspaceApi, '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(workspaceApi, 'move').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'moved.md', dir: false, size: 0, mtime: 0 }])
|
||||
const upload = vi.spyOn(workspaceApi, 'upload')
|
||||
const importing = vi.spyOn(workspaceApi, '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(vaultApi, 'tree').mockResolvedValue([{ path: 'draft-move.md', dir: false, size: 0, mtime: 0 }])
|
||||
const moving = vi.spyOn(workspaceApi, '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(workspaceApi, 'importNotes').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
vi.spyOn(vaultApi, '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(workspaceApi.importNotes).mockResolvedValueOnce(result)
|
||||
vi.mocked(vaultApi.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
|
||||
expect(await importNotes('', '导入', [])).toEqual(result)
|
||||
expect(toasts.some(t => t.text.includes('导入已完成,目录刷新失败'))).toBe(true)
|
||||
})
|
||||
|
||||
it('失败保留错误并释放操作占用', async () => {
|
||||
ws.tabs = []
|
||||
vi.spyOn(workspaceApi, '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(workspaceApi, 'replaceImage').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
vi.spyOn(vaultApi, '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(workspaceApi.replaceImage).mockResolvedValueOnce(new Response())
|
||||
vi.mocked(vaultApi.tree).mockRejectedValueOnce(new ApiError(500, '读取失败'))
|
||||
await replaceImage('assets/a.png', new File(['image'], 'a.png'))
|
||||
expect(toasts.some(t => t.text.includes('图片已替换,但刷新目录失败'))).toBe(true)
|
||||
})
|
||||
|
||||
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
|
||||
ws.tabs = []
|
||||
ws.active = null
|
||||
vi.spyOn(trashApi, 'restore').mockResolvedValue({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 })
|
||||
vi.spyOn(vaultApi, '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(workspaceApi, 'writeFile').mockRejectedValue(new ApiError(403, '禁止写入'))
|
||||
vi.spyOn(vaultApi, '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)
|
||||
})
|
||||
})
|
||||
|
||||
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`,
|
||||
}))
|
||||
|
||||
it('失效会话的保存结果不覆盖重新登录后的标签页,也不安排旧内容重试', async () => {
|
||||
const { pinia } = await import('@/pinia')
|
||||
const { useUserStore } = await import('@/pinia/stores/user')
|
||||
const user = useUserStore(pinia)
|
||||
vi.spyOn(drafts, 'put').mockResolvedValue('session.md')
|
||||
const v = await edit('session.md')
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '旧会话输入' } })
|
||||
let finish!: (rev: string) => void
|
||||
const write = vi.spyOn(workspaceApi, 'writeFile').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const saved = flush('session.md')
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
user.setToken('rms_new')
|
||||
stopWorkspace()
|
||||
view?.destroy(); view = undefined
|
||||
vi.mocked(vaultApi.readFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
|
||||
await openNote('session.md')
|
||||
finish('old-rev')
|
||||
await saved
|
||||
expect(ws.tabs.find(tab => tab.path === 'session.md')?.rev).toBe('new-rev')
|
||||
expect(editorStateOf('session.md')?.doc.toString()).toBe('新会话正文')
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
await closeTab('session.md')
|
||||
user.setToken('')
|
||||
})
|
||||
|
||||
it('切换会话后新目录读取不等待已取消的旧目录请求', async () => {
|
||||
const user = useUserStore(pinia)
|
||||
let finish!: (entries: []) => void
|
||||
const tree = vi.spyOn(vaultApi, 'tree').mockImplementationOnce(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})).mockResolvedValue([{ path: 'new.md', dir: false, size: 0, mtime: 0 }])
|
||||
const previous = refreshTree().catch(error => error)
|
||||
await vi.waitFor(() => expect(tree).toHaveBeenCalledTimes(1))
|
||||
user.setToken('rms_new')
|
||||
stopWorkspace()
|
||||
const current = refreshTree().catch(error => error)
|
||||
try {
|
||||
expect(isCancel(await previous)).toBe(true)
|
||||
finish([])
|
||||
await vi.waitFor(() => expect(tree).toHaveBeenCalledTimes(2))
|
||||
expect(await current).toBeUndefined()
|
||||
expect(ws.entries.map(entry => entry.path)).toEqual(['new.md'])
|
||||
} finally {
|
||||
stopWorkspace(); await current; user.setToken('')
|
||||
}
|
||||
})
|
||||
|
||||
it('新工作区不继承旧会话未完成的文件操作计数', async () => {
|
||||
const user = useUserStore(pinia)
|
||||
let cancel!: (error: Error) => void
|
||||
vi.spyOn(workspaceApi, 'writeFile').mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||
cancel = reject
|
||||
}))
|
||||
const creating = createNoteAt('old.md')
|
||||
user.setToken('rms_new')
|
||||
stopWorkspace()
|
||||
const tree = vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'new.md', dir: false, size: 0, mtime: 0 }])
|
||||
const current = refreshTree().catch(error => error)
|
||||
try {
|
||||
await vi.waitFor(() => expect(tree).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(ws.entries.map(entry => entry.path)).toEqual(['new.md']))
|
||||
} finally {
|
||||
cancel(new CanceledError('旧会话已关闭'))
|
||||
await creating
|
||||
stopWorkspace(); await current; user.setToken('')
|
||||
}
|
||||
})
|
||||
|
||||
it('外部移动事件等待草稿后换会话,不再映射新工作区的同名笔记', async () => {
|
||||
const user = useUserStore(pinia)
|
||||
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
|
||||
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
|
||||
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
|
||||
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||
await startWorkspace()
|
||||
await edit('from/note.md')
|
||||
let finish!: () => void
|
||||
let completed = false
|
||||
vi.mocked(drafts.move).mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
completed = true
|
||||
})
|
||||
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }])
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
user.setToken('rms_new')
|
||||
stopWorkspace()
|
||||
view?.destroy(); view = undefined
|
||||
vi.mocked(vaultApi.readFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
|
||||
await openNote('from/note.md')
|
||||
finish()
|
||||
await vi.waitFor(() => expect(completed).toBe(true))
|
||||
expect(ws.active).toBe('from/note.md')
|
||||
expect(editorStateOf('from/note.md')?.doc.toString()).toBe('新会话正文')
|
||||
expect(editorStateOf('to/note.md')).toBeUndefined()
|
||||
user.setToken('')
|
||||
})
|
||||
|
||||
it('旧网页粘贴在换会话后不继续下载下一批图片或修改同名笔记', async () => {
|
||||
const user = useUserStore(pinia)
|
||||
const v = await edit('paste-session.md')
|
||||
const finish: ((link: string) => void)[] = []
|
||||
const importing = vi.spyOn(workspaceApi, 'importImage').mockImplementation(() => new Promise((resolve) => {
|
||||
finish.push(resolve)
|
||||
}))
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/html', Array.from({ length: 4 }, (_, i) => `<img src="https://example.com/${i}.png">`).join(''))
|
||||
data.setData('text/plain', '剪贴板内容')
|
||||
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
||||
await vi.waitFor(() => expect(importing).toHaveBeenCalledTimes(3))
|
||||
user.setToken('rms_new')
|
||||
stopWorkspace()
|
||||
view?.destroy(); view = undefined
|
||||
vi.mocked(vaultApi.readFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
|
||||
await openNote('paste-session.md')
|
||||
toasts.splice(0)
|
||||
finish.forEach(resolve => resolve('./assets/old.png'))
|
||||
await flushPromises()
|
||||
expect(importing).toHaveBeenCalledTimes(3)
|
||||
expect(editorStateOf('paste-session.md')?.doc.toString()).toBe('新会话正文')
|
||||
expect(toasts).toHaveLength(0)
|
||||
expect(readyToReload()).toBe(true)
|
||||
user.setToken('')
|
||||
})
|
||||
|
||||
it('工作区关闭后不响应其全局新建快捷键', async () => {
|
||||
vi.spyOn(vaultApi, 'tree').mockResolvedValue([])
|
||||
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
|
||||
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
|
||||
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||
const write = vi.spyOn(workspaceApi, 'writeFile').mockResolvedValue('unused')
|
||||
await startWorkspace()
|
||||
stopWorkspace()
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', code: 'KeyN', ctrlKey: true, bubbles: true, cancelable: true }))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(ws.tabs).toEqual([])
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user