feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type { CodeBlockMonacoOptions } from '../types/component-props'
|
||||
import type { NodeRendererCodeBlockProps } from '../types/node-renderer-props'
|
||||
import { inject, provide } from 'vue'
|
||||
|
||||
export interface MarkstreamRendererContext {
|
||||
customId: ComputedRef<string | undefined>
|
||||
isDark: ComputedRef<boolean | undefined>
|
||||
themes: ComputedRef<string[] | undefined>
|
||||
renderCodeBlocksAsPre: ComputedRef<boolean | undefined>
|
||||
codeBlockStream: ComputedRef<boolean | undefined>
|
||||
codeBlockDarkTheme: ComputedRef<any | undefined>
|
||||
codeBlockLightTheme: ComputedRef<any | undefined>
|
||||
codeBlockMonacoOptions: ComputedRef<CodeBlockMonacoOptions | undefined>
|
||||
codeBlockMinWidth: ComputedRef<string | number | undefined>
|
||||
codeBlockMaxWidth: ComputedRef<string | number | undefined>
|
||||
codeBlockProps: ComputedRef<NodeRendererCodeBlockProps | undefined>
|
||||
}
|
||||
|
||||
export const MARKSTREAM_RENDERER_CONTEXT = Symbol('MarkstreamRendererContext')
|
||||
|
||||
export function provideMarkstreamRendererContext(ctx: MarkstreamRendererContext) {
|
||||
provide(MARKSTREAM_RENDERER_CONTEXT, ctx)
|
||||
}
|
||||
|
||||
export function useMarkstreamRendererContext() {
|
||||
return inject<MarkstreamRendererContext | null>(MARKSTREAM_RENDERER_CONTEXT, null)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readonly, shallowRef } from 'vue'
|
||||
import { getKatex } from '../components/MathInlineNode/katex'
|
||||
|
||||
const katexReadyState = shallowRef(false)
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export function useKatexReady() {
|
||||
if (!initPromise) {
|
||||
initPromise = getKatex()
|
||||
.then((k) => {
|
||||
katexReadyState.value = !!k
|
||||
})
|
||||
.catch(() => {
|
||||
katexReadyState.value = false
|
||||
})
|
||||
}
|
||||
return readonly(katexReadyState)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
function humanizeKey(key: string) {
|
||||
const s = key.split('.').pop() || key
|
||||
return s
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
const defaultMap: Record<string, string> = {
|
||||
'common.copy': 'Copy',
|
||||
'common.copied': 'Copied',
|
||||
'common.decrease': 'Decrease',
|
||||
'common.reset': 'Reset',
|
||||
'common.increase': 'Increase',
|
||||
'common.expand': 'Expand',
|
||||
'common.collapse': 'Collapse',
|
||||
'common.preview': 'Preview',
|
||||
'common.source': 'Source',
|
||||
'common.export': 'Export',
|
||||
'common.open': 'Open',
|
||||
'common.minimize': 'Minimize',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.resetZoom': 'Reset zoom',
|
||||
'image.loadError': 'Image failed to load',
|
||||
'image.loading': 'Loading image...',
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace default fallback translations.
|
||||
* Consumers can call this to provide their own fallback translations (e.g. Chinese).
|
||||
*/
|
||||
export function setDefaultI18nMap(map: Partial<Record<keyof typeof defaultMap, string>>) {
|
||||
Object.assign(defaultMap, map)
|
||||
}
|
||||
|
||||
interface Translator {
|
||||
t: (key: string) => string
|
||||
te?: (key: string) => boolean
|
||||
}
|
||||
|
||||
function resolveComponentTranslator() {
|
||||
try {
|
||||
const instance = getCurrentInstance()
|
||||
const proxy = instance?.proxy as unknown as Record<string, unknown> | null | undefined
|
||||
const proxyT = proxy?.$t
|
||||
if (typeof proxyT === 'function') {
|
||||
const proxyTe = proxy?.$te
|
||||
return {
|
||||
t: proxyT.bind(proxy),
|
||||
te: typeof proxyTe === 'function' ? proxyTe.bind(proxy) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const globalProperties = instance?.appContext?.config?.globalProperties as Record<string, unknown> | undefined
|
||||
const appT = globalProperties?.$t
|
||||
if (typeof appT === 'function') {
|
||||
const appTe = globalProperties?.$te
|
||||
return {
|
||||
t: appT.bind(globalProperties),
|
||||
te: typeof appTe === 'function' ? appTe.bind(globalProperties) : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
const fallbackT = (key: string) => defaultMap[key] ?? humanizeKey(key)
|
||||
|
||||
function withFallback(translator: Translator) {
|
||||
return {
|
||||
t(key: string) {
|
||||
if (translator.te && defaultMap[key] && !translator.te(key))
|
||||
return fallbackT(key)
|
||||
|
||||
const translated = translator.t(key)
|
||||
return translated === key && defaultMap[key] ? fallbackT(key) : translated
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useSafeI18n() {
|
||||
const translator = resolveComponentTranslator()
|
||||
if (translator)
|
||||
return withFallback(translator)
|
||||
|
||||
// Synchronous fallback in case `vue-i18n` is not installed.
|
||||
try {
|
||||
// Try to use global installed vue-i18n if available synchronously via composition API
|
||||
// This will work when the consumer has set up vue-i18n and the bundler left
|
||||
// the runtime entry available. We access it via (global) require-ish path.
|
||||
// Keep this non-throwing.
|
||||
const possible = (globalThis as any).$vueI18nUse || null
|
||||
if (possible && typeof possible === 'function') {
|
||||
try {
|
||||
const i18n = possible()
|
||||
if (i18n && typeof i18n.t === 'function') {
|
||||
return withFallback({
|
||||
t: (i18n.t as any).bind(i18n),
|
||||
te: typeof i18n.te === 'function' ? (i18n.te as any).bind(i18n) : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
return { t: fallbackT }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import Tooltip from '../components/Tooltip/Tooltip.vue'
|
||||
|
||||
const visible = ref(false)
|
||||
const content = ref('')
|
||||
const placement = ref<'top' | 'bottom' | 'left' | 'right'>('top')
|
||||
const anchorEl = ref<HTMLElement | null>(null)
|
||||
const tooltipId = ref<string | null>(null)
|
||||
const originX = ref<number | null>(null)
|
||||
const originY = ref<number | null>(null)
|
||||
const tooltipIsDark = ref<boolean | null>(null)
|
||||
|
||||
let showTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Mount singleton Tooltip once
|
||||
let mounted = false
|
||||
function ensureMounted() {
|
||||
if (mounted)
|
||||
return
|
||||
if (typeof document === 'undefined')
|
||||
return
|
||||
|
||||
try {
|
||||
mounted = true
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-singleton-tooltip', '1')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const App = {
|
||||
setup() {
|
||||
return () => h(Tooltip as any, {
|
||||
'visible': visible.value,
|
||||
'anchor-el': anchorEl.value,
|
||||
'content': content.value,
|
||||
'placement': placement.value,
|
||||
'id': tooltipId.value,
|
||||
'originX': originX.value,
|
||||
'originY': originY.value,
|
||||
'isDark': tooltipIsDark.value ?? undefined,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
createApp(App).mount(container)
|
||||
}
|
||||
catch (err) {
|
||||
mounted = false
|
||||
console.warn('[markstream-vue] Failed to mount Tooltip component. Tooltips will be disabled.', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function showTooltipForAnchor(
|
||||
el: HTMLElement | null,
|
||||
text: string,
|
||||
place: typeof placement.value = 'top',
|
||||
immediate = false,
|
||||
origin?: { x: number, y: number } | undefined,
|
||||
isDark?: boolean | null,
|
||||
) {
|
||||
if (!el)
|
||||
return
|
||||
ensureMounted()
|
||||
clearTimers()
|
||||
const doShow = () => {
|
||||
tooltipId.value = `tooltip-${Date.now()}-${Math.floor(Math.random() * 1000)}`
|
||||
anchorEl.value = el
|
||||
content.value = text
|
||||
placement.value = place
|
||||
// expose origin coordinates for initial animation
|
||||
originX.value = origin?.x ?? null
|
||||
originY.value = origin?.y ?? null
|
||||
// allow caller to hint dark mode; if omitted, remain null (tooltip will detect global)
|
||||
tooltipIsDark.value = typeof isDark === 'boolean' ? isDark : null
|
||||
visible.value = true
|
||||
try {
|
||||
el.setAttribute('aria-describedby', tooltipId.value!)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
if (immediate)
|
||||
doShow()
|
||||
else showTimer = setTimeout(doShow, 80)
|
||||
}
|
||||
|
||||
export function hideTooltip(immediate = false) {
|
||||
clearTimers()
|
||||
const doHide = () => {
|
||||
if (anchorEl.value && tooltipId.value) {
|
||||
try {
|
||||
anchorEl.value.removeAttribute('aria-describedby')
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
visible.value = false
|
||||
anchorEl.value = null
|
||||
tooltipId.value = null
|
||||
originX.value = null
|
||||
originY.value = null
|
||||
}
|
||||
if (immediate)
|
||||
doHide()
|
||||
else hideTimer = setTimeout(doHide, 120)
|
||||
}
|
||||
|
||||
export default {
|
||||
showTooltipForAnchor,
|
||||
hideTooltip,
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { inject, provide, ref } from 'vue'
|
||||
|
||||
// Injection key for viewport-priority registration
|
||||
const ViewportPriorityKey = Symbol('ViewportPriority') as unknown as InjectionKey<RegisterFn>
|
||||
|
||||
export interface VisibilityHandle {
|
||||
isVisible: Ref<boolean>
|
||||
whenVisible: Promise<void>
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export type RegisterFn = (el: HTMLElement, opts?: { rootMargin?: string, threshold?: number }) => VisibilityHandle
|
||||
|
||||
type InjectionKey<T> = symbol & { __type?: T }
|
||||
interface IdleDeadlineLike {
|
||||
didTimeout: boolean
|
||||
timeRemaining: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a shared viewport-priority registrar.
|
||||
* Targets resolve immediately when they enter the viewport and otherwise
|
||||
* trickle through during browser idle so heavy nodes can prerender offscreen.
|
||||
*/
|
||||
export function provideViewportPriority(
|
||||
getRootEl: (target?: HTMLElement | null) => HTMLElement | null | undefined,
|
||||
enabled: Ref<boolean> | boolean,
|
||||
): RegisterFn {
|
||||
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
const enabledRef = typeof enabled === 'boolean' ? ref(enabled) : enabled
|
||||
const requestIdle = isBrowser
|
||||
? ((window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined)
|
||||
?? ((cb: (deadline: IdleDeadlineLike) => void) => window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 16))
|
||||
: null
|
||||
const cancelIdle = isBrowser
|
||||
? ((window as any).cancelIdleCallback as ((id: number) => void) | undefined)
|
||||
?? ((id: number) => window.clearTimeout(id))
|
||||
: null
|
||||
|
||||
interface ObserverConfig {
|
||||
root: HTMLElement | null
|
||||
rootMargin: string
|
||||
threshold: number
|
||||
}
|
||||
|
||||
// Lazily created IO bound to the provided root element
|
||||
let io: IntersectionObserver | null = null
|
||||
let currentConfig: ObserverConfig | null = null
|
||||
const targets = new Map<Element, { resolve: () => void, visible: Ref<boolean> }>()
|
||||
const idleQueue = new Set<Element>()
|
||||
let idleJob: number | null = null
|
||||
|
||||
function normalizeConfig(target?: HTMLElement, opts?: { rootMargin?: string, threshold?: number }): ObserverConfig {
|
||||
return {
|
||||
root: getRootEl?.(target ?? null) ?? null,
|
||||
rootMargin: opts?.rootMargin ?? '300px',
|
||||
threshold: opts?.threshold ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function sameConfig(a: ObserverConfig | null, b: ObserverConfig) {
|
||||
return !!a
|
||||
&& a.root === b.root
|
||||
&& a.rootMargin === b.rootMargin
|
||||
&& a.threshold === b.threshold
|
||||
}
|
||||
|
||||
function clearIdleJob() {
|
||||
if (idleJob == null)
|
||||
return
|
||||
try {
|
||||
cancelIdle?.(idleJob)
|
||||
}
|
||||
catch {}
|
||||
idleJob = null
|
||||
}
|
||||
|
||||
function cleanupObserver() {
|
||||
if (targets.size)
|
||||
return
|
||||
try {
|
||||
io?.disconnect()
|
||||
}
|
||||
catch {}
|
||||
io = null
|
||||
currentConfig = null
|
||||
if (!idleQueue.size)
|
||||
clearIdleJob()
|
||||
}
|
||||
|
||||
function settleTarget(target: Element) {
|
||||
const data = targets.get(target)
|
||||
if (!data)
|
||||
return
|
||||
if (!data.visible.value) {
|
||||
data.visible.value = true
|
||||
try {
|
||||
data.resolve()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
try {
|
||||
io?.unobserve(target)
|
||||
}
|
||||
catch {}
|
||||
targets.delete(target)
|
||||
idleQueue.delete(target)
|
||||
cleanupObserver()
|
||||
}
|
||||
|
||||
function scheduleIdleDrain() {
|
||||
if (!requestIdle || idleJob != null || !idleQueue.size)
|
||||
return
|
||||
idleJob = requestIdle(() => {
|
||||
idleJob = null
|
||||
const next = idleQueue.values().next().value as Element | undefined
|
||||
if (!next)
|
||||
return
|
||||
idleQueue.delete(next)
|
||||
settleTarget(next)
|
||||
if (idleQueue.size)
|
||||
scheduleIdleDrain()
|
||||
}, { timeout: 1200 })
|
||||
}
|
||||
|
||||
function ensureObserver(target?: HTMLElement, opts?: { rootMargin?: string, threshold?: number }) {
|
||||
if (!isBrowser)
|
||||
return io
|
||||
// Guard: some browser-like environments (e.g., jsdom) don't provide IO
|
||||
if (typeof IntersectionObserver === 'undefined')
|
||||
return null
|
||||
|
||||
const nextConfig = normalizeConfig(target, opts)
|
||||
if (io && sameConfig(currentConfig, nextConfig))
|
||||
return io
|
||||
|
||||
if (io) {
|
||||
try {
|
||||
io.disconnect()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
|
||||
if (isVisible)
|
||||
settleTarget(entry.target)
|
||||
}
|
||||
}, {
|
||||
root: nextConfig.root,
|
||||
rootMargin: nextConfig.rootMargin, // prefetch slightly before entering viewport
|
||||
threshold: nextConfig.threshold,
|
||||
})
|
||||
|
||||
currentConfig = nextConfig
|
||||
for (const el of targets.keys())
|
||||
io.observe(el)
|
||||
|
||||
return io
|
||||
}
|
||||
|
||||
const register: RegisterFn = (el, opts) => {
|
||||
const visible = ref(false)
|
||||
let settled = false
|
||||
let resolve!: () => void
|
||||
const whenVisible = new Promise<void>((res) => {
|
||||
resolve = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
res()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
io?.unobserve(el)
|
||||
}
|
||||
catch {}
|
||||
targets.delete(el)
|
||||
idleQueue.delete(el)
|
||||
cleanupObserver()
|
||||
}
|
||||
|
||||
if (!isBrowser || !enabledRef.value) {
|
||||
visible.value = true
|
||||
resolve()
|
||||
return { isVisible: visible, whenVisible, destroy: cleanup }
|
||||
}
|
||||
|
||||
const obs = ensureObserver(el, opts)
|
||||
if (!obs) {
|
||||
visible.value = true
|
||||
resolve()
|
||||
return { isVisible: visible, whenVisible, destroy: cleanup }
|
||||
}
|
||||
|
||||
targets.set(el, { resolve, visible })
|
||||
obs.observe(el)
|
||||
idleQueue.add(el)
|
||||
scheduleIdleDrain()
|
||||
return { isVisible: visible, whenVisible, destroy: cleanup }
|
||||
}
|
||||
|
||||
provide(ViewportPriorityKey, register)
|
||||
return register
|
||||
}
|
||||
|
||||
/**
|
||||
* Child components call this to register an element and await priority.
|
||||
* If provider is missing, returns a no-op registrar that resolves immediately.
|
||||
*/
|
||||
export function useViewportPriority() {
|
||||
const injected = inject<RegisterFn | undefined>(ViewportPriorityKey, undefined)
|
||||
if (injected)
|
||||
return injected
|
||||
|
||||
const localTargets = new WeakMap<Element, { resolve: () => void, visible: Ref<boolean> }>()
|
||||
let localIo: IntersectionObserver | null = null
|
||||
const localIdleQueue = new Set<Element>()
|
||||
let localIdleJob: number | null = null
|
||||
const requestIdle = typeof window !== 'undefined'
|
||||
? ((window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined)
|
||||
?? ((cb: (deadline: IdleDeadlineLike) => void) => window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 16))
|
||||
: null
|
||||
const cancelIdle = typeof window !== 'undefined'
|
||||
? ((window as any).cancelIdleCallback as ((id: number) => void) | undefined)
|
||||
?? ((id: number) => window.clearTimeout(id))
|
||||
: null
|
||||
|
||||
const clearLocalIdleJob = () => {
|
||||
if (localIdleJob == null)
|
||||
return
|
||||
try {
|
||||
cancelIdle?.(localIdleJob)
|
||||
}
|
||||
catch {}
|
||||
localIdleJob = null
|
||||
}
|
||||
|
||||
const settleLocalTarget = (target: Element) => {
|
||||
const data = localTargets.get(target)
|
||||
if (!data)
|
||||
return
|
||||
if (!data.visible.value) {
|
||||
data.visible.value = true
|
||||
try {
|
||||
data.resolve()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
try {
|
||||
localIo?.unobserve(target)
|
||||
}
|
||||
catch {}
|
||||
localTargets.delete(target)
|
||||
localIdleQueue.delete(target)
|
||||
if (!localIdleQueue.size)
|
||||
clearLocalIdleJob()
|
||||
}
|
||||
|
||||
const scheduleLocalIdleDrain = () => {
|
||||
if (!requestIdle || localIdleJob != null || !localIdleQueue.size)
|
||||
return
|
||||
localIdleJob = requestIdle(() => {
|
||||
localIdleJob = null
|
||||
const next = localIdleQueue.values().next().value as Element | undefined
|
||||
if (!next)
|
||||
return
|
||||
localIdleQueue.delete(next)
|
||||
settleLocalTarget(next)
|
||||
if (localIdleQueue.size)
|
||||
scheduleLocalIdleDrain()
|
||||
}, { timeout: 1200 })
|
||||
}
|
||||
|
||||
const ensureLocal = () => {
|
||||
if (localIo)
|
||||
return localIo
|
||||
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined')
|
||||
return null
|
||||
localIo = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const vis = e.isIntersecting || e.intersectionRatio > 0
|
||||
if (vis)
|
||||
settleLocalTarget(e.target)
|
||||
}
|
||||
}, { root: null, rootMargin: '300px', threshold: 0 })
|
||||
return localIo
|
||||
}
|
||||
|
||||
const register: RegisterFn = (el) => {
|
||||
const isVisible = ref(false)
|
||||
let settled = false
|
||||
let resolve!: () => void
|
||||
const whenVisible = new Promise<void>((res) => {
|
||||
resolve = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
res()
|
||||
}
|
||||
}
|
||||
})
|
||||
const cleanup = () => {
|
||||
try {
|
||||
localIo?.unobserve(el)
|
||||
}
|
||||
catch {}
|
||||
localTargets.delete(el)
|
||||
localIdleQueue.delete(el)
|
||||
if (!localIdleQueue.size)
|
||||
clearLocalIdleJob()
|
||||
}
|
||||
const obs = ensureLocal()
|
||||
if (!obs) {
|
||||
isVisible.value = true
|
||||
resolve()
|
||||
return { isVisible, whenVisible, destroy: cleanup }
|
||||
}
|
||||
localTargets.set(el, { resolve, visible: isVisible })
|
||||
obs.observe(el)
|
||||
localIdleQueue.add(el)
|
||||
scheduleLocalIdleDrain()
|
||||
return { isVisible, whenVisible, destroy: cleanup }
|
||||
}
|
||||
|
||||
return register
|
||||
}
|
||||
Reference in New Issue
Block a user