Add managed plugins with lifecycle cleanup and editor extensions
This commit is contained in:
1 parent
9fd7e2a948
commit
8ce4a11b33
25 files changed
+1731
-73
No files matched your search
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { mountContent } from '../plugins/mount'
|
||||
import type { OwnedView } from '../plugins/registry'
|
||||
|
||||
const props = defineProps<{ view: OwnedView }>()
|
||||
const container = ref<HTMLElement>()
|
||||
let stop = () => {}
|
||||
watch(() => [container.value, props.view] as const, ([el, view]) => {
|
||||
stop()
|
||||
if (el) stop = mountContent(el, view.mount, view.fail)
|
||||
}, { flush: 'post', immediate: true })
|
||||
onBeforeUnmount(() => stop())
|
||||
</script>
|
||||
|
||||
<template><div ref="container" class="rm-plugin-view" /></template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { api, ApiError, type InstalledPlugin } from './api'
|
||||
import { pluginState, refreshPlugins, retryPlugin } from '../plugins/host'
|
||||
import { pluginsPaused, settingTabs } from '../plugins/registry'
|
||||
import PluginMount from './PluginMount.vue'
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const selected = ref('')
|
||||
const currentTab = computed(() => settingTabs.get(selected.value))
|
||||
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
||||
|
||||
async function perform(operation: () => Promise<unknown>) {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try { await operation(); await refreshPlugins() }
|
||||
catch (err) { error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function install(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
|
||||
await perform(() => api.installPlugin(file))
|
||||
}
|
||||
|
||||
async function remove(plugin: InstalledPlugin) {
|
||||
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
|
||||
await perform(() => api.uninstallPlugin(plugin.id))
|
||||
}
|
||||
|
||||
function toggle(plugin: InstalledPlugin) {
|
||||
void perform(() => api.togglePlugin(plugin.id, !plugin.enabled))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="pluginsPaused" class="rm-appearance-note">已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。</p>
|
||||
<div class="rm-setting">
|
||||
<span class="rm-setting-label">已安装插件</span>
|
||||
<button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">刷新列表</button>
|
||||
<button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button>
|
||||
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" />
|
||||
</div>
|
||||
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。分享页不加载外部插件。</p>
|
||||
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">{{ error || pluginState.error }}</p>
|
||||
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">尚未安装插件。</p>
|
||||
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
|
||||
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)" />
|
||||
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span>
|
||||
</label>
|
||||
<button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">重试</button>
|
||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">卸载</button>
|
||||
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">{{ pluginState.status[plugin.id].error }}</p>
|
||||
</div>
|
||||
<p class="rm-appearance-note">若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。</p>
|
||||
<template v-if="settingTabs.size">
|
||||
<div class="rm-setting"><label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
|
||||
<select id="rm-plugin-tab" v-model="selected"><option value="">选择插件设置页</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">{{ tab.title }}</option></select>
|
||||
</div>
|
||||
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
|
||||
</template>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { api, ApiError } from './api'
|
||||
import { allCommands, formatKeys, runCommand } from './commands'
|
||||
import Icon, { type IconName } from './Icon.vue'
|
||||
import AppearanceSettings from './AppearanceSettings.vue'
|
||||
import PluginsPanel from './PluginsPanel.vue'
|
||||
import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
|
||||
import SharePanel from './SharePanel.vue'
|
||||
import TokensPanel from './TokensPanel.vue'
|
||||
@@ -12,6 +13,7 @@ import { toast, ws, type SettingsSection } from './workspace'
|
||||
|
||||
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
||||
{ id: 'appearance', title: '外观', icon: 'palette' },
|
||||
{ id: 'plugins', title: '插件', icon: 'command' },
|
||||
{ id: 'account', title: '账户', icon: 'user' },
|
||||
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
|
||||
{ id: 'shares', title: '分享链接', icon: 'link' },
|
||||
@@ -106,7 +108,8 @@ async function changePassword() {
|
||||
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button>
|
||||
</div>
|
||||
<div class="rm-settings-content">
|
||||
<template v-if="section === 'appearance'">
|
||||
<PluginsPanel v-if="section === 'plugins'" />
|
||||
<template v-else-if="section === 'appearance'">
|
||||
<AppearanceSettings />
|
||||
<div class="rm-setting">
|
||||
<span class="rm-setting-label">字号</span>
|
||||
|
||||
+12
-2
@@ -1,14 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { basename } from './paths'
|
||||
import { tree } from './tree'
|
||||
import TreeNode from './TreeNode.vue'
|
||||
import SearchPanel from './SearchPanel.vue'
|
||||
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
||||
import { sidebarViews } from '../plugins/registry'
|
||||
import PluginMount from './PluginMount.vue'
|
||||
|
||||
const emit = defineEmits<{ logout: [] }>()
|
||||
const rootDrop = ref(false)
|
||||
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
|
||||
@@ -35,7 +40,12 @@ function onRootDrop(e: DragEvent) {
|
||||
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
||||
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
||||
</div>
|
||||
<SearchPanel v-if="ws.searchOpen" />
|
||||
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
|
||||
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
|
||||
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
|
||||
</nav>
|
||||
<PluginMount v-if="currentView" :key="selectedView" :view="currentView" />
|
||||
<SearchPanel v-else-if="ws.searchOpen" />
|
||||
<div v-else class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
||||
<ul class="rm-tree-list">
|
||||
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
||||
|
||||
@@ -13,6 +13,15 @@ export interface Theme {
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
apiVersion: 1
|
||||
revision: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface TrashItem {
|
||||
id: string
|
||||
path: string
|
||||
@@ -105,6 +114,16 @@ export const api = {
|
||||
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
||||
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
|
||||
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
|
||||
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
|
||||
installPlugin: (file: File) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return json<InstalledPlugin>(request('POST', '/api/v1/plugins', { body }))
|
||||
},
|
||||
togglePlugin: (id: string, enabled: boolean) => json<InstalledPlugin>(request('PATCH', '/api/v1/plugins/' + encodeURIComponent(id), { json: { enabled } })),
|
||||
uninstallPlugin: (id: string) => request('DELETE', '/api/v1/plugins/' + encodeURIComponent(id)),
|
||||
pluginData: (id: string) => json<{ data: unknown }>(request('GET', '/api/v1/plugins/' + encodeURIComponent(id) + '/data')),
|
||||
savePluginData: (id: string, data: unknown) => request('PUT', '/api/v1/plugins/' + encodeURIComponent(id) + '/data', { json: { data } }),
|
||||
// 按顶层键合并,值为 null 的键被删除
|
||||
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
||||
|
||||
|
||||
@@ -524,3 +524,14 @@ button {
|
||||
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
||||
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
|
||||
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: 44px; } .rm-snippet-form input, .rm-snippet-form textarea { font-size: 16px; } }
|
||||
|
||||
.rm-plugin-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; border-bottom: 1px solid var(--rm-divider); }
|
||||
.rm-plugin-row label { display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px; overflow-wrap: anywhere; }
|
||||
.rm-plugin-row small { display: block; color: var(--rm-text-muted); font-size: 12px; }
|
||||
.rm-plugin-row p { width: 100%; font-size: 13px; }
|
||||
.rm-plugin-row input { accent-color: var(--rm-accent); flex: none; }
|
||||
.rm-plugin-view { padding: 16px; min-width: 0; overflow: auto; overflow-wrap: anywhere; }
|
||||
.rm-sidebar-inner > .rm-plugin-view { flex: 1; }
|
||||
.rm-plugin-tabs { display: flex; flex: none; gap: 4px; padding: 8px; overflow-x: auto; }
|
||||
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
|
||||
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: 44px; } }
|
||||
@@ -23,11 +23,18 @@ export function registerCommand(cmd: Command): () => void {
|
||||
|
||||
/** 全部已注册的命令(含当前不可用的),用于快捷键一览 */
|
||||
export function allCommands(): Command[] {
|
||||
return [...commands.values()]
|
||||
const all = [...commands.values()]
|
||||
const plugins = all.filter((command) => command.id.startsWith('plugin:'))
|
||||
// 动态启停不能改变快捷键优先级;同一插件内仍保留注册顺序。
|
||||
plugins.sort((a, b) => {
|
||||
const left = a.id.split(':')[1], right = b.id.split(':')[1]
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
})
|
||||
return [...all.filter((command) => !command.id.startsWith('plugin:')), ...plugins]
|
||||
}
|
||||
|
||||
export function availableCommands(): Command[] {
|
||||
return [...commands.values()].filter((c) => c.when?.() ?? true)
|
||||
return allCommands().filter((c) => c.when?.() ?? true)
|
||||
}
|
||||
|
||||
export function runCommand(id: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
|
||||
|
||||
export interface VaultEvent {
|
||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings'
|
||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins'
|
||||
path?: string
|
||||
from?: string
|
||||
rev?: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { nextTick, reactive, watch } from 'vue'
|
||||
import { createEditorState, insertImageFiles, pasteImages } from '../editor'
|
||||
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage } from '../editor'
|
||||
import { handleShortcut, registerCommand } from './commands'
|
||||
import { api, ApiError, type Entry } from './api'
|
||||
import { drafts } from './drafts'
|
||||
@@ -12,6 +12,9 @@ import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||
import { loadSettings, settings } from './settings'
|
||||
import { trackVisualViewport } from './viewport'
|
||||
import { appearancePaused } from '../themes/appearance'
|
||||
import { notePath, pluginEvents, pluginRefresh, pluginsPaused, setPluginEditorHandlers } from '../plugins/registry'
|
||||
import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '../plugins/host'
|
||||
import type { PluginAPI } from '../plugin-api'
|
||||
|
||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||
|
||||
@@ -27,7 +30,7 @@ export interface Tab {
|
||||
conflictRev?: string
|
||||
}
|
||||
|
||||
export type SettingsSection = 'appearance' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash'
|
||||
export type SettingsSection = 'appearance' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash' | 'plugins'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
@@ -54,7 +57,7 @@ export const ws = reactive({
|
||||
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||
switcher: null as string | null,
|
||||
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
||||
settings: (appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
||||
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
||||
// 分享当前笔记的对话框(笔记路径)
|
||||
sharing: null as string | null,
|
||||
// 「移动到…」对话框(笔记或文件夹路径)
|
||||
@@ -179,6 +182,7 @@ function buildState(path: string, doc: string): EditorState {
|
||||
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
|
||||
},
|
||||
extensions: [
|
||||
notePath.of(ref),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged || u.selectionSet) editorListeners.forEach((fn) => fn())
|
||||
}),
|
||||
@@ -193,6 +197,33 @@ function buildState(path: string, doc: string): EditorState {
|
||||
})
|
||||
}
|
||||
|
||||
function reconfigurePluginStates() {
|
||||
const effects = [languageCompartment.reconfigure(pluginLanguage()), pluginCompartment.reconfigure(pluginEditorExtensions()), pluginRefresh.of(null)]
|
||||
// 先计算全部新状态,失败时让注册方撤回;不让后台标签保留已停用扩展。
|
||||
const updates = [...editorStates].map(([path, state]) => {
|
||||
const current = path === ws.active && view ? view.state : state
|
||||
return { path, transaction: current.update({ effects }) }
|
||||
})
|
||||
for (const { path, transaction } of updates) {
|
||||
if (path === ws.active && view) view.dispatch(transaction)
|
||||
editorStates.set(path, transaction.state)
|
||||
}
|
||||
}
|
||||
|
||||
const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options) => {
|
||||
const tab = tabOf(path)
|
||||
const saved = slots.get(path)
|
||||
if ((tab && (tab.dirty || tab.status !== 'ready')) || saved?.inflight || saved?.uploads || saved?.paused || opsInFlight) throw new ApiError(409, '笔记有未保存修改或正在操作,请稍后重试')
|
||||
if (!options || (!('create' in options && options.create === true) && !('ifMatch' in options && typeof options.ifMatch === 'string' && options.ifMatch.length > 0))) throw new ApiError(400, '写入必须指定非空版本号或明确新建')
|
||||
const initialRevision = tab?.rev
|
||||
const initialSequence = saved?.seq ?? 0
|
||||
const rev = await api.writeFile(path, content, 'create' in options ? '' : options.ifMatch)
|
||||
// 用户可能已完成下一次保存,或已收到更新版本;迟到响应不能退回正文与 rev。
|
||||
if (tab && tabOf(path) === tab && tab.rev === initialRevision && (slots.get(path)?.seq ?? 0) === initialSequence && !tab.dirty && tab.status === 'ready' && !slots.get(path)?.inflight) { replaceDoc(path, content); tab.rev = rev }
|
||||
await refreshTree()
|
||||
return { rev }
|
||||
}
|
||||
|
||||
// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史
|
||||
function replaceDoc(path: string, content: string) {
|
||||
const state = path === ws.active && view ? view.state : editorStates.get(path)
|
||||
@@ -449,6 +480,7 @@ function remapOpenNotes(from: string, to: string) {
|
||||
const ref = noteRefs.get(next)
|
||||
if (ref) ref.path = next
|
||||
}
|
||||
reconfigurePluginStates()
|
||||
}
|
||||
|
||||
function remapRecent(from: string, to: string) {
|
||||
@@ -696,7 +728,11 @@ async function syncOpenTab(tab: Tab) {
|
||||
function handleEvents(evs: VaultEvent[]) {
|
||||
for (const ev of evs) {
|
||||
if (ev.type === 'tree') void refreshTree()
|
||||
else if (ev.type === 'settings') void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
||||
else if (ev.type === 'settings') {
|
||||
void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
||||
void refreshPlugins()
|
||||
}
|
||||
else if (ev.type === 'plugins') void refreshPlugins()
|
||||
else if (ev.type === 'changed') {
|
||||
const tab = tabOf(ev.path!)
|
||||
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
||||
@@ -711,12 +747,16 @@ function handleEvents(evs: VaultEvent[]) {
|
||||
tab.conflictRev = ''
|
||||
}
|
||||
}
|
||||
if (ev.type === 'changed' || ev.type === 'moved' || ev.type === 'deleted') pluginEvents.dispatchEvent(new CustomEvent(ev.type, { detail: ev.type === 'moved' ? { from: ev.from!, path: ev.path! } : ev.type === 'changed' ? { path: ev.path!, rev: ev.rev! } : { path: ev.path! } }))
|
||||
}
|
||||
if (evs.some((event) => event.type === 'tree')) void resync().catch((err) => toast('插件同步失败:' + errorText(err)))
|
||||
}
|
||||
|
||||
async function resync() {
|
||||
await refreshTree()
|
||||
await Promise.all(ws.tabs.map(syncOpenTab))
|
||||
pluginEvents.dispatchEvent(new CustomEvent('resync'))
|
||||
await refreshPlugins()
|
||||
}
|
||||
|
||||
// ---- 启动 ----
|
||||
@@ -766,6 +806,19 @@ export async function startWorkspace() {
|
||||
}
|
||||
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
|
||||
disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() })
|
||||
setPluginEditorHandlers(reconfigurePluginStates, pluginEditorFailure)
|
||||
startPlugins({
|
||||
notice: toast,
|
||||
openNote,
|
||||
writeNote: pluginWrite,
|
||||
getActiveNote: () => {
|
||||
const tab = activeTab()
|
||||
if (!tab) return null
|
||||
const state = view && ws.active === tab.path ? view.state : editorStates.get(tab.path)
|
||||
if (!state) return null
|
||||
return { path: tab.path, content: state.doc.toString(), rev: tab.rev, dirty: tab.dirty }
|
||||
},
|
||||
})
|
||||
// 重新登录后工作区会再次启动,以下全局监听只注册一次
|
||||
if (listenersInstalled) return
|
||||
listenersInstalled = true
|
||||
@@ -800,6 +853,7 @@ export async function startWorkspace() {
|
||||
() => view?.requestMeasure(),
|
||||
)
|
||||
window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure()))
|
||||
window.addEventListener('rawmark-plugin-notice', (event) => toast((event as CustomEvent<string>).detail))
|
||||
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||
@@ -873,6 +927,7 @@ export function openSearch() {
|
||||
}
|
||||
|
||||
export function stopWorkspace() {
|
||||
stopPlugins()
|
||||
disconnect?.()
|
||||
disconnect = undefined
|
||||
}
|
||||
Reference in new issue
Block a user