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,9 @@
|
||||
.DS_Store
|
||||
.vite-ssg-dist
|
||||
.vite-ssg-temp
|
||||
*.local
|
||||
dist
|
||||
dist-ssr
|
||||
node_modules
|
||||
.idea/
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
shamefully-hoist=true
|
||||
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
||||
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
||||
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
||||
const useToString: typeof import('@vueuse/core')['useToString']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchDeep: typeof import('@vueuse/core')['watchDeep']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Counter: typeof import('./src/components/Counter.vue')['default']
|
||||
Footer: typeof import('./src/components/Footer.vue')['default']
|
||||
LabSelect: typeof import('./src/components/LabSelect.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ThinkingNode: typeof import('./src/components/ThinkingNode.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>markstream-vue Playground | Streaming Markdown, Mermaid, and large-document demos</title>
|
||||
<meta name="description" content="Explore markstream-vue with live playground demos for streaming Markdown, Mermaid, KaTeX, CDN peers, and large-document rendering." />
|
||||
<meta name="keywords" content="markstream-vue,vue markdown,streaming markdown,mermaid,markdown playground,ai chat ui,large documents" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<link rel="canonical" href="https://markstream-vue.simonhe.me/" />
|
||||
<meta property="og:site_name" content="markstream-vue Playground" />
|
||||
<meta property="og:title" content="markstream-vue Playground | Streaming Markdown, Mermaid, and large-document demos" />
|
||||
<meta property="og:description" content="Explore markstream-vue with live playground demos for streaming Markdown, Mermaid, KaTeX, CDN peers, and large-document rendering." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://markstream-vue.simonhe.me/" />
|
||||
<meta property="og:image" content="https://markstream-vue.simonhe.me/og-image.svg" />
|
||||
<meta property="og:image:alt" content="markstream-vue playground with streaming markdown and diagram demos" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="markstream-vue Playground | Streaming Markdown, Mermaid, and large-document demos" />
|
||||
<meta name="twitter:description" content="Explore markstream-vue with live playground demos for streaming Markdown, Mermaid, KaTeX, CDN peers, and large-document rendering." />
|
||||
<meta name="twitter:image" content="https://markstream-vue.simonhe.me/og-image.svg" />
|
||||
</head>
|
||||
<body class="font-sans">
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
;(function () {
|
||||
const prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const setting = localStorage.getItem('color-schema') || 'auto'
|
||||
if (setting === 'dark' || (prefersDark && setting !== 'light'))
|
||||
document.documentElement.classList.toggle('dark', true)
|
||||
})()
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
[build]
|
||||
publish = "dist"
|
||||
command = "npx pnpm i && npm run build"
|
||||
|
||||
[build.environment]
|
||||
NPM_FLAGS = "--version"
|
||||
NODE_VERSION = "22"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "playground",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --port 3333 --open",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/carbon": "^1.2.20",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@terrastruct/d2": ">=0.1.33",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"drauu": "^1.0.0",
|
||||
"katex": "^0.16.44",
|
||||
"markstream-vue": "workspace:*",
|
||||
"stream-markdown": "latest",
|
||||
"stream-markdown-parser": "workspace:*",
|
||||
"stream-monaco": "latest",
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.31",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.37",
|
||||
"@unocss/reset": "^0.61.9",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.39.4",
|
||||
"jsdom": "^24.1.3",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.4",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.5",
|
||||
"vite-plugin-monaco-editor-esm": "^2.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Cloudflare Pages 重定向规则
|
||||
# SPA 路由: 将所有请求重定向到 index.html
|
||||
# https://developers.cloudflare.com/pages/configuration/redirects/
|
||||
|
||||
/* /index.html 200
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<style>
|
||||
path { fill: #222; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #ffffff; }
|
||||
}
|
||||
</style>
|
||||
<path d="M27.562 26L17.17 8.928l2.366-3.888L17.828 4L16 7.005L14.17 4l-1.708 1.04l2.366 3.888L4.438 26H2v2h28v-2zM16 10.85L25.22 26H17v-8h-2v8H6.78z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,33 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="630" fill="#050B18"/>
|
||||
<rect x="44" y="44" width="1112" height="542" rx="34" fill="url(#bg)"/>
|
||||
<rect x="92" y="108" width="430" height="414" rx="30" fill="#020617" stroke="#1E293B" stroke-width="2"/>
|
||||
<rect x="128" y="148" width="132" height="36" rx="18" fill="#22C55E" fill-opacity="0.2" stroke="#22C55E"/>
|
||||
<text x="194" y="166" fill="#DCFCE7" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="700" text-anchor="middle" dominant-baseline="middle">Live demo</text>
|
||||
<rect x="128" y="214" width="320" height="18" rx="9" fill="#E2E8F0" fill-opacity="0.92"/>
|
||||
<rect x="128" y="246" width="270" height="18" rx="9" fill="#94A3B8" fill-opacity="0.8"/>
|
||||
<rect x="128" y="302" width="154" height="164" rx="26" fill="#111827"/>
|
||||
<rect x="304" y="302" width="178" height="164" rx="26" fill="#0F172A"/>
|
||||
<path d="M168 344H242" stroke="#38BDF8" stroke-width="20" stroke-linecap="round"/>
|
||||
<path d="M352 332C352 325.373 357.373 320 364 320H404C427.196 320 446 338.804 446 362C446 385.196 427.196 404 404 404H364C357.373 404 352 398.627 352 392V332Z" fill="#F59E0B"/>
|
||||
<path d="M352 434L430 434" stroke="#A78BFA" stroke-width="18" stroke-linecap="round"/>
|
||||
<rect x="550" y="118" width="558" height="394" rx="34" fill="#0B1120" stroke="#334155" stroke-width="2"/>
|
||||
<rect x="598" y="152" width="158" height="34" rx="17" fill="#38BDF8" fill-opacity="0.14" stroke="#38BDF8" stroke-opacity="0.7"/>
|
||||
<text x="677" y="169" fill="#BAE6FD" font-family="Arial, Helvetica, sans-serif" font-size="15" font-weight="700" text-anchor="middle" dominant-baseline="middle">PLAYGROUND</text>
|
||||
<text x="598" y="232" fill="#F8FAFC" font-family="Arial, Helvetica, sans-serif" font-size="48" font-weight="700">markstream-vue</text>
|
||||
<text x="598" y="288" fill="#C4B5FD" font-family="Arial, Helvetica, sans-serif" font-size="28" font-weight="700">Interactive demos</text>
|
||||
<text x="598" y="342" fill="#CBD5E1" font-family="Arial, Helvetica, sans-serif" font-size="20">Streaming Markdown for AI chat</text>
|
||||
<text x="598" y="378" fill="#CBD5E1" font-family="Arial, Helvetica, sans-serif" font-size="20">Mermaid, KaTeX, CDN peers</text>
|
||||
<rect x="598" y="416" width="110" height="36" rx="18" fill="#0F1D33" stroke="#334155"/>
|
||||
<text x="653" y="434" fill="#E2E8F0" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="700" text-anchor="middle" dominant-baseline="middle">AI chat</text>
|
||||
<rect x="720" y="416" width="120" height="36" rx="18" fill="#0F1D33" stroke="#334155"/>
|
||||
<text x="780" y="434" fill="#E2E8F0" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="700" text-anchor="middle" dominant-baseline="middle">Mermaid</text>
|
||||
<rect x="852" y="416" width="100" height="36" rx="18" fill="#0F1D33" stroke="#334155"/>
|
||||
<text x="902" y="434" fill="#E2E8F0" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="700" text-anchor="middle" dominant-baseline="middle">KaTeX</text>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="66" y1="62" x2="1124" y2="566" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#111827"/>
|
||||
<stop offset="1" stop-color="#312E81"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://markstream-vue.simonhe.me/sitemap.xml
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://markstream-vue.simonhe.me/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://markstream-vue.simonhe.me/markdown</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.85</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://markstream-vue.simonhe.me/test</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.75</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://markstream-vue.simonhe.me/cdn-peers</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://markstream-vue.simonhe.me/mermaid-export-demo</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.76</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Vue Markdown Renderer">
|
||||
<title>Vue Markdown Renderer</title>
|
||||
<defs>
|
||||
<linearGradient id="g-vue" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#41b883" />
|
||||
<stop offset="1" stop-color="#2c9f67" />
|
||||
</linearGradient>
|
||||
<linearGradient id="g-accent" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#3aa8ff" />
|
||||
<stop offset="1" stop-color="#5cc6ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- background card -->
|
||||
<rect x="6" y="6" width="116" height="116" rx="16" fill="#0b1220" />
|
||||
<rect x="12" y="12" width="104" height="104" rx="12" fill="#ffffff" />
|
||||
|
||||
<!-- folded corner -->
|
||||
<path d="M92 12 H108 V28" fill="#f6f7f8" opacity="0.9" />
|
||||
<path d="M92 12 L108 28" stroke="#e6e6e6" stroke-width="1.5" fill="none" />
|
||||
|
||||
<!-- document area -->
|
||||
<rect x="28" y="26" width="72" height="76" rx="6" fill="#ffffff" />
|
||||
|
||||
<!-- Vue chevron (simplified) -->
|
||||
<g transform="translate(34,30)">
|
||||
<path d="M10 0 L24 24 L0 24 Z" fill="url(#g-vue)" />
|
||||
<path d="M10 6 L24 30 L0 30 Z" fill="#063" opacity="0.95" />
|
||||
</g>
|
||||
|
||||
<!-- Markdown hash (#) drawn with strokes (reliable across renderers) -->
|
||||
<g transform="translate(64,46)" stroke="#0b1220" stroke-width="4" stroke-linecap="round">
|
||||
<line x1="-12" y1="-4" x2="12" y2="-4" />
|
||||
<line x1="-12" y1="8" x2="12" y2="8" />
|
||||
<line x1="-6" y1="-12" x2="-6" y2="20" />
|
||||
<line x1="6" y1="-12" x2="6" y2="20" />
|
||||
</g>
|
||||
|
||||
<!-- small code brackets to indicate renderer -->
|
||||
<g transform="translate(36,84)" stroke="#0b1220" stroke-width="3" fill="none" stroke-linecap="round">
|
||||
<path d="M0 0 L8 8 L0 16" />
|
||||
<path d="M56 0 L48 8 L56 16" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path
|
||||
d="M4.5 11c0 .828427.6715729 1.5 1.5 1.5.8284271 0 1.5-.671573 1.5-1.5V7.5M12.5 8.75C12.5 8.05964406 11.9627417 7.5 11.3 7.5L10.7 7.5C10.0372583 7.5 9.5 8.05964406 9.5 8.75 9.5 9.44035594 10.0372583 10 10.7 10L11.3 10C11.9627417 10 12.5 10.5596441 12.5 11.25 12.5 11.9403559 11.9627417 12.5 11.3 12.5L10.7 12.5C10.0372583 12.5 9.5 11.9403559 9.5 11.25" />
|
||||
<path
|
||||
d="m 4,1.5 h 8 c 1.385,0 2.5,1.115 2.5,2.5 v 8 c 0,1.385 -1.115,2.5 -2.5,2.5 H 4 C 2.615,14.5 1.5,13.385 1.5,12 V 4 C 1.5,2.615 2.615,1.5 4,1.5 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
initial: number
|
||||
}>()
|
||||
const { count, inc, dec } = useCounter(props.initial)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
{{ count }}
|
||||
<button class="inc" @click="inc()">
|
||||
+
|
||||
</button>
|
||||
<button class="dec" @click="dec()">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '@iconify/vue'
|
||||
import { isDark, toggleDark } from '~/composables'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav text-xl mt-6 inline-flex gap-2>
|
||||
<button class="icon-btn !outline-none" @click="toggleDark()">
|
||||
<Icon v-if="isDark" class="i-carbon-moon" />
|
||||
<Icon v-else class="i-carbon-sun" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="icon-btn"
|
||||
i-carbon-logo-github
|
||||
rel="noreferrer"
|
||||
href="https://github.com/Simon-He95/my-vitesse-app"
|
||||
target="_blank"
|
||||
title="GitHub"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside, useEventListener } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
type LabSelectValue = string | number
|
||||
|
||||
interface LabSelectOption {
|
||||
value: LabSelectValue
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: LabSelectValue
|
||||
label: string
|
||||
options: readonly LabSelectOption[]
|
||||
placeholder?: string
|
||||
}>(), {
|
||||
placeholder: '请选择',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: LabSelectValue]
|
||||
}>()
|
||||
|
||||
const uid = `lab-select-${Math.random().toString(36).slice(2, 9)}`
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
const placement = ref<'top' | 'bottom'>('bottom')
|
||||
const labelId = `${uid}-label`
|
||||
const listId = `${uid}-list`
|
||||
|
||||
const selectedIndex = computed(() => props.options.findIndex(option => option.value === props.modelValue))
|
||||
const selectedOption = computed(() => props.options[selectedIndex.value] ?? null)
|
||||
const activeDescendantId = computed(() => `${uid}-option-${activeIndex.value}`)
|
||||
|
||||
function syncActiveIndex() {
|
||||
activeIndex.value = selectedIndex.value >= 0 ? selectedIndex.value : 0
|
||||
}
|
||||
|
||||
function updatePlacement() {
|
||||
const element = rootRef.value
|
||||
if (!element)
|
||||
return
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
const estimatedHeight = Math.min(props.options.length * 50 + 18, 280)
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
placement.value = spaceBelow >= estimatedHeight || spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
||||
}
|
||||
|
||||
function focusListbox() {
|
||||
nextTick(() => {
|
||||
updatePlacement()
|
||||
listRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (isOpen.value || !props.options.length)
|
||||
return
|
||||
|
||||
syncActiveIndex()
|
||||
isOpen.value = true
|
||||
focusListbox()
|
||||
}
|
||||
|
||||
function closeMenu({ restoreFocus = false }: { restoreFocus?: boolean } = {}) {
|
||||
if (!isOpen.value)
|
||||
return
|
||||
|
||||
isOpen.value = false
|
||||
|
||||
if (restoreFocus) {
|
||||
nextTick(() => {
|
||||
triggerRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (isOpen.value)
|
||||
closeMenu()
|
||||
else
|
||||
openMenu()
|
||||
}
|
||||
|
||||
function moveActive(step: number) {
|
||||
if (!props.options.length)
|
||||
return
|
||||
|
||||
if (!isOpen.value) {
|
||||
openMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const next = activeIndex.value + step
|
||||
const lastIndex = props.options.length - 1
|
||||
|
||||
if (next < 0)
|
||||
activeIndex.value = lastIndex
|
||||
else if (next > lastIndex)
|
||||
activeIndex.value = 0
|
||||
else
|
||||
activeIndex.value = next
|
||||
}
|
||||
|
||||
function selectActiveOption() {
|
||||
const option = props.options[activeIndex.value]
|
||||
if (option)
|
||||
selectOption(option.value)
|
||||
}
|
||||
|
||||
function selectOption(value: LabSelectValue) {
|
||||
emit('update:modelValue', value)
|
||||
closeMenu({ restoreFocus: true })
|
||||
}
|
||||
|
||||
function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openMenu()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
syncActiveIndex()
|
||||
moveActive(-1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleListKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = Math.max(props.options.length - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
selectActiveOption()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeMenu({ restoreFocus: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Tab')
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (!isOpen.value)
|
||||
syncActiveIndex()
|
||||
})
|
||||
|
||||
watch(() => props.options, () => {
|
||||
syncActiveIndex()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
onClickOutside(rootRef, () => {
|
||||
closeMenu()
|
||||
})
|
||||
|
||||
useEventListener(window, 'resize', () => {
|
||||
if (isOpen.value)
|
||||
updatePlacement()
|
||||
})
|
||||
|
||||
useEventListener(window, 'scroll', () => {
|
||||
if (isOpen.value)
|
||||
updatePlacement()
|
||||
}, { capture: true, passive: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="lab-select select-control"
|
||||
:class="{
|
||||
'lab-select--open': isOpen,
|
||||
'lab-select--top': placement === 'top',
|
||||
}"
|
||||
>
|
||||
<span :id="labelId" class="lab-select__label">{{ label }}</span>
|
||||
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
class="lab-select__trigger"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-labelledby="labelId"
|
||||
@click="toggleMenu"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<span class="lab-select__value">
|
||||
{{ selectedOption?.label ?? placeholder }}
|
||||
</span>
|
||||
<span class="lab-select__chevron" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<transition name="lab-select-menu">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
:id="listId"
|
||||
ref="listRef"
|
||||
class="lab-select__menu"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
:aria-activedescendant="activeDescendantId"
|
||||
:aria-labelledby="labelId"
|
||||
@keydown="handleListKeydown"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:id="`${uid}-option-${index}`"
|
||||
:key="option.value"
|
||||
class="lab-select__option"
|
||||
:class="{
|
||||
'lab-select__option--active': index === activeIndex,
|
||||
'lab-select__option--selected': option.value === modelValue,
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="option.value === modelValue"
|
||||
@click="selectOption(option.value)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
<span class="lab-select__option-label">{{ option.label }}</span>
|
||||
<span
|
||||
v-if="option.value === modelValue"
|
||||
class="lab-select__option-indicator"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lab-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lab-select__label {
|
||||
color: var(--lab-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.lab-select__trigger {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 12px 14px 12px 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.92));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
||||
0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
color: var(--lab-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
background 0.18s ease;
|
||||
}
|
||||
|
||||
.lab-select__trigger:hover {
|
||||
border-color: rgba(15, 118, 110, 0.18);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.88),
|
||||
0 14px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.lab-select__trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--lab-accent);
|
||||
box-shadow:
|
||||
0 0 0 3px var(--lab-accent-soft),
|
||||
0 16px 32px rgba(15, 23, 42, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lab-select--open .lab-select__trigger {
|
||||
border-color: rgba(15, 118, 110, 0.24);
|
||||
box-shadow:
|
||||
0 0 0 3px var(--lab-accent-soft),
|
||||
0 18px 36px rgba(15, 23, 42, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lab-select__value {
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lab-select__chevron {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 4px;
|
||||
border-right: 1.6px solid currentColor;
|
||||
border-bottom: 1.6px solid currentColor;
|
||||
color: var(--lab-muted);
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.lab-select--open .lab-select__chevron {
|
||||
color: var(--lab-accent);
|
||||
transform: rotate(-135deg) translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.lab-select__menu {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: calc(100% - 2px);
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 24px 56px rgba(15, 23, 42, 0.16);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lab-select--top .lab-select__menu {
|
||||
top: auto;
|
||||
bottom: calc(100% - 2px);
|
||||
}
|
||||
|
||||
.lab-select__option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: var(--lab-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
color 0.14s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.lab-select__option--active {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lab-select__option--selected {
|
||||
background: var(--lab-accent-soft);
|
||||
color: var(--lab-accent);
|
||||
}
|
||||
|
||||
.lab-select__option-label {
|
||||
min-width: 0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.lab-select__option-indicator {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.lab-select-menu-enter-active,
|
||||
.lab-select-menu-leave-active {
|
||||
transition: opacity 0.14s ease, transform 0.14s ease;
|
||||
}
|
||||
|
||||
.lab-select-menu-enter-from,
|
||||
.lab-select-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.lab-select--top .lab-select-menu-enter-from,
|
||||
.lab-select--top .lab-select-menu-leave-to {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
:global(.test-lab--dark) .lab-select__trigger {
|
||||
border-color: rgba(148, 163, 184, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(15, 23, 42, 0.76));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 12px 28px rgba(2, 6, 23, 0.26);
|
||||
}
|
||||
|
||||
:global(.test-lab--dark) .lab-select__trigger:hover {
|
||||
border-color: rgba(34, 211, 238, 0.22);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 16px 32px rgba(2, 6, 23, 0.3);
|
||||
}
|
||||
|
||||
:global(.test-lab--dark) .lab-select--open .lab-select__trigger,
|
||||
:global(.test-lab--dark) .lab-select__trigger:focus-visible {
|
||||
border-color: rgba(34, 211, 238, 0.28);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(34, 211, 238, 0.16),
|
||||
0 18px 36px rgba(2, 6, 23, 0.34);
|
||||
}
|
||||
|
||||
:global(.test-lab--dark) .lab-select__menu {
|
||||
border-color: rgba(148, 163, 184, 0.12);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
box-shadow: 0 28px 64px rgba(2, 6, 23, 0.44);
|
||||
}
|
||||
|
||||
:global(.test-lab--dark) .lab-select__option--active {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
:global(.test-lab--dark) .lab-select__option--selected {
|
||||
background: rgba(34, 211, 238, 0.14);
|
||||
color: #67e8f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
node: {
|
||||
type: 'thinking'
|
||||
content: string
|
||||
children: any[]
|
||||
loading?: boolean
|
||||
}
|
||||
isDark?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="thinking-node">
|
||||
{{ node.content }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thinking-node {
|
||||
margin: 1em 0;
|
||||
padding: 0.75em 1em;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--ms-muted-foreground));
|
||||
border-left: 2px solid hsl(var(--ms-border));
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
export interface ChunkInfo {
|
||||
content: string
|
||||
delay: number
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface LocalStreamControl {
|
||||
shouldPause?: () => boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type StreamSliceMode = 'pure-random' | 'boundary-aware'
|
||||
|
||||
export interface CreateLocalTextStreamOptions {
|
||||
chunkSizeMin: number
|
||||
chunkSizeMax: number
|
||||
chunkDelayMin: number
|
||||
chunkDelayMax: number
|
||||
onChunk?: (chunk: ChunkInfo) => void
|
||||
control?: LocalStreamControl
|
||||
random?: () => number
|
||||
sliceMode?: StreamSliceMode
|
||||
}
|
||||
|
||||
const WORD_BOUNDARY = /[\s,.;:!?()[\]{}"'`<>/\-\\]/
|
||||
|
||||
function randomInt(min: number, max: number, random: () => number) {
|
||||
return Math.floor((random() * ((max - min) + 1))) + min
|
||||
}
|
||||
|
||||
function isBoundaryChar(char: string | undefined) {
|
||||
return char ? WORD_BOUNDARY.test(char) : false
|
||||
}
|
||||
|
||||
function snapChunkToBoundary(content: string, start: number, chunkSize: number) {
|
||||
const preferredEnd = Math.min(content.length, start + chunkSize)
|
||||
if (preferredEnd >= content.length)
|
||||
return content.length - start
|
||||
|
||||
if (isBoundaryChar(content[preferredEnd - 1]) || isBoundaryChar(content[preferredEnd]))
|
||||
return preferredEnd - start
|
||||
|
||||
const lookaheadEnd = Math.min(content.length, preferredEnd + 12)
|
||||
for (let index = preferredEnd; index < lookaheadEnd; index++) {
|
||||
if (isBoundaryChar(content[index]))
|
||||
return (index + 1) - start
|
||||
}
|
||||
|
||||
return preferredEnd - start
|
||||
}
|
||||
|
||||
export function createLocalTextStream(
|
||||
content: string,
|
||||
options: CreateLocalTextStreamOptions,
|
||||
) {
|
||||
const encoder = new TextEncoder()
|
||||
const random = options.random ?? Math.random
|
||||
const control = options.control ?? {}
|
||||
let currentPosition = 0
|
||||
let chunkIndex = 0
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const waitIfPaused = async () => {
|
||||
while (control.shouldPause?.()) {
|
||||
if (control.signal?.aborted)
|
||||
return
|
||||
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
while (currentPosition < content.length) {
|
||||
if (control.signal?.aborted)
|
||||
break
|
||||
|
||||
await waitIfPaused()
|
||||
if (control.signal?.aborted)
|
||||
break
|
||||
|
||||
const rawChunkSize = randomInt(options.chunkSizeMin, options.chunkSizeMax, random)
|
||||
const chunkSize = options.sliceMode === 'boundary-aware'
|
||||
? snapChunkToBoundary(content, currentPosition, rawChunkSize)
|
||||
: rawChunkSize
|
||||
const delay = randomInt(options.chunkDelayMin, options.chunkDelayMax, random)
|
||||
const chunkContent = content.slice(currentPosition, currentPosition + chunkSize)
|
||||
|
||||
await sleep(delay)
|
||||
if (control.signal?.aborted)
|
||||
break
|
||||
|
||||
await waitIfPaused()
|
||||
if (control.signal?.aborted)
|
||||
break
|
||||
|
||||
controller.enqueue(encoder.encode(chunkContent))
|
||||
options.onChunk?.({
|
||||
index: chunkIndex++,
|
||||
content: chunkContent,
|
||||
delay,
|
||||
})
|
||||
|
||||
currentPosition += chunkSize
|
||||
}
|
||||
|
||||
try {
|
||||
controller.close()
|
||||
}
|
||||
catch {
|
||||
// The consumer may have already canceled the stream.
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// No extra cleanup is required for the local simulation stream.
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isDark = useDark()
|
||||
export const toggleDark = useToggle(isDark)
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dark'
|
||||
@@ -0,0 +1,96 @@
|
||||
export const CUSTOM_STREAM_PRESET_ID = 'custom' as const
|
||||
|
||||
export type StreamPresetId
|
||||
= 'balanced'
|
||||
| 'sse'
|
||||
| 'websocket'
|
||||
| 'proxy-buffered'
|
||||
| 'weak-mobile'
|
||||
| typeof CUSTOM_STREAM_PRESET_ID
|
||||
|
||||
export interface StreamPresetValues {
|
||||
chunkDelayMin: number
|
||||
chunkDelayMax: number
|
||||
chunkSizeMin: number
|
||||
chunkSizeMax: number
|
||||
burstiness: number
|
||||
}
|
||||
|
||||
export interface StreamPreset extends StreamPresetValues {
|
||||
id: Exclude<StreamPresetId, typeof CUSTOM_STREAM_PRESET_ID>
|
||||
label: string
|
||||
description: string
|
||||
descriptionZh: string
|
||||
}
|
||||
|
||||
export const STREAM_PRESETS: StreamPreset[] = [
|
||||
{
|
||||
id: 'balanced',
|
||||
label: 'Balanced',
|
||||
description: 'Moderate jitter with short stalls and occasional bursts.',
|
||||
descriptionZh: '中等抖动,偶尔停顿和 burst,适合大多数 LLM 对话流。',
|
||||
chunkDelayMin: 14,
|
||||
chunkDelayMax: 34,
|
||||
chunkSizeMin: 2,
|
||||
chunkSizeMax: 7,
|
||||
burstiness: 35,
|
||||
},
|
||||
{
|
||||
id: 'sse',
|
||||
label: 'SSE',
|
||||
description: 'Smaller chunks, frequent flushes, and visible network wobble.',
|
||||
descriptionZh: '更小的 chunk、更频繁的 flush,能看到明显的网络抖动。',
|
||||
chunkDelayMin: 18,
|
||||
chunkDelayMax: 44,
|
||||
chunkSizeMin: 1,
|
||||
chunkSizeMax: 4,
|
||||
burstiness: 18,
|
||||
},
|
||||
{
|
||||
id: 'websocket',
|
||||
label: 'WebSocket',
|
||||
description: 'Smoother cadence with larger chunks and lower transport jitter.',
|
||||
descriptionZh: '节奏更顺、chunk 更大、传输抖动更小,像直连 WebSocket。',
|
||||
chunkDelayMin: 8,
|
||||
chunkDelayMax: 20,
|
||||
chunkSizeMin: 4,
|
||||
chunkSizeMax: 8,
|
||||
burstiness: 22,
|
||||
},
|
||||
{
|
||||
id: 'proxy-buffered',
|
||||
label: 'Proxy Buffered',
|
||||
description: 'Silent gaps followed by buffered batches from an intermediate proxy.',
|
||||
descriptionZh: '前面更安静,后面成批吐内容,模拟代理层缓冲后再转发。',
|
||||
chunkDelayMin: 36,
|
||||
chunkDelayMax: 160,
|
||||
chunkSizeMin: 6,
|
||||
chunkSizeMax: 16,
|
||||
burstiness: 88,
|
||||
},
|
||||
{
|
||||
id: 'weak-mobile',
|
||||
label: 'Weak Mobile',
|
||||
description: 'High latency, high jitter, and resume-after-stall behavior.',
|
||||
descriptionZh: '高延迟、高抖动,偶尔停住再继续,像弱网移动端。',
|
||||
chunkDelayMin: 42,
|
||||
chunkDelayMax: 180,
|
||||
chunkSizeMin: 1,
|
||||
chunkSizeMax: 6,
|
||||
burstiness: 68,
|
||||
},
|
||||
]
|
||||
|
||||
export function getStreamPreset(id: StreamPresetId | string | null | undefined) {
|
||||
return STREAM_PRESETS.find(preset => preset.id === id) ?? null
|
||||
}
|
||||
|
||||
export function findMatchingStreamPreset(values: StreamPresetValues) {
|
||||
return STREAM_PRESETS.find(preset =>
|
||||
preset.chunkDelayMin === values.chunkDelayMin
|
||||
&& preset.chunkDelayMax === values.chunkDelayMax
|
||||
&& preset.chunkSizeMin === values.chunkSizeMin
|
||||
&& preset.chunkSizeMax === values.chunkSizeMax
|
||||
&& preset.burstiness === values.burstiness,
|
||||
) ?? null
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { ChunkInfo, StreamSliceMode } from './createLocalTextStream'
|
||||
import { computed, onBeforeUnmount, ref, toValue } from 'vue'
|
||||
import { createLocalTextStream } from './createLocalTextStream'
|
||||
|
||||
export type StreamTransportMode = 'scheduler' | 'readable-stream'
|
||||
|
||||
export interface StreamSimulatorOptions {
|
||||
source: MaybeRefOrGetter<string>
|
||||
baseChunkSize?: MaybeRefOrGetter<number>
|
||||
baseDelayMs?: MaybeRefOrGetter<number>
|
||||
chunkSizeMin?: MaybeRefOrGetter<number>
|
||||
chunkSizeMax?: MaybeRefOrGetter<number>
|
||||
chunkDelayMin?: MaybeRefOrGetter<number>
|
||||
chunkDelayMax?: MaybeRefOrGetter<number>
|
||||
jitter?: MaybeRefOrGetter<number>
|
||||
burstiness: MaybeRefOrGetter<number>
|
||||
sliceMode?: MaybeRefOrGetter<StreamSliceMode>
|
||||
transportMode?: MaybeRefOrGetter<StreamTransportMode>
|
||||
random?: () => number
|
||||
}
|
||||
|
||||
const WORD_BOUNDARY = /[\s,.;:!?()[\]{}"'`<>/\-\\]/
|
||||
|
||||
interface BurstState {
|
||||
quickChunksRemaining: number
|
||||
fastCadenceChunkPending: boolean
|
||||
}
|
||||
|
||||
export function clampStreamControl(value: number, min: number, max: number, fallback: number) {
|
||||
const normalized = Number.isFinite(value) ? value : fallback
|
||||
return Math.min(max, Math.max(min, normalized))
|
||||
}
|
||||
|
||||
export function normalizeStreamRange(
|
||||
minValue: number,
|
||||
maxValue: number,
|
||||
lowerBound: number,
|
||||
upperBound: number,
|
||||
fallbackMin: number,
|
||||
fallbackMax: number,
|
||||
) {
|
||||
const normalizedMin = Math.round(clampStreamControl(minValue, lowerBound, upperBound, fallbackMin))
|
||||
const normalizedMax = Math.round(clampStreamControl(maxValue, lowerBound, upperBound, fallbackMax))
|
||||
|
||||
if (normalizedMin <= normalizedMax)
|
||||
return { min: normalizedMin, max: normalizedMax }
|
||||
|
||||
return { min: normalizedMax, max: normalizedMin }
|
||||
}
|
||||
|
||||
function normalizeRatio(value: number, fallback: number) {
|
||||
return clampStreamControl(value, 0, 1, fallback)
|
||||
}
|
||||
|
||||
function randomBetween(min: number, max: number, random: () => number) {
|
||||
return min + ((max - min) * random())
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number, random: () => number) {
|
||||
return Math.floor(randomBetween(min, max + 1, random))
|
||||
}
|
||||
|
||||
function hasExplicitRange(minRef: MaybeRefOrGetter<number> | undefined, maxRef: MaybeRefOrGetter<number> | undefined) {
|
||||
return minRef !== undefined || maxRef !== undefined
|
||||
}
|
||||
|
||||
function inferJitterFromRange(min: number, max: number) {
|
||||
if (max <= min)
|
||||
return 0
|
||||
|
||||
return normalizeRatio((max - min) / Math.max(1, max), 0)
|
||||
}
|
||||
|
||||
function resolveSliceMode(sliceMode: MaybeRefOrGetter<StreamSliceMode> | undefined) {
|
||||
return (sliceMode ? toValue(sliceMode) : 'boundary-aware') as StreamSliceMode
|
||||
}
|
||||
|
||||
function isBoundaryChar(char: string | undefined) {
|
||||
return char ? WORD_BOUNDARY.test(char) : false
|
||||
}
|
||||
|
||||
function snapChunkToBoundary(source: string, start: number, desiredChunk: number) {
|
||||
const preferredEnd = Math.min(source.length, start + desiredChunk)
|
||||
if (preferredEnd >= source.length)
|
||||
return source.length - start
|
||||
|
||||
if (isBoundaryChar(source[preferredEnd - 1]) || isBoundaryChar(source[preferredEnd]))
|
||||
return preferredEnd - start
|
||||
|
||||
const lookaheadEnd = Math.min(source.length, preferredEnd + 12)
|
||||
for (let index = preferredEnd; index < lookaheadEnd; index++) {
|
||||
if (isBoundaryChar(source[index]))
|
||||
return (index + 1) - start
|
||||
}
|
||||
|
||||
return preferredEnd - start
|
||||
}
|
||||
|
||||
function sampleChunkSize(
|
||||
source: string,
|
||||
start: number,
|
||||
baseChunkSize: number,
|
||||
jitter: number,
|
||||
burstiness: number,
|
||||
burstState: BurstState,
|
||||
sliceMode: StreamSliceMode,
|
||||
random: () => number,
|
||||
) {
|
||||
const remaining = source.length - start
|
||||
if (remaining <= 0)
|
||||
return 0
|
||||
|
||||
const useBurstChunkBias = burstState.fastCadenceChunkPending || burstState.quickChunksRemaining > 0
|
||||
const smallChunkBias = jitter > 0.08 && random() < (jitter * 0.4)
|
||||
const stableMin = Math.max(0.35, 1 - (jitter * 0.55))
|
||||
const stableMax = 1 + (jitter * 1.45)
|
||||
const sizeFactor = smallChunkBias
|
||||
? randomBetween(Math.max(0.18, 1 - (jitter * 1.35)), Math.max(0.32, 0.78 - (jitter * 0.08)), random)
|
||||
: randomBetween(stableMin, stableMax, random)
|
||||
|
||||
let chunkSize = Math.max(1, Math.round(baseChunkSize * sizeFactor))
|
||||
|
||||
if (useBurstChunkBias) {
|
||||
const burstExtra = Math.max(1, Math.round(baseChunkSize * randomBetween(0.75, 2.2 + burstiness, random)))
|
||||
chunkSize += burstExtra
|
||||
}
|
||||
else if (burstiness > 0.05 && random() < (burstiness * 0.22)) {
|
||||
const burstExtra = Math.max(1, Math.round(baseChunkSize * randomBetween(1.2, 3.2 + (burstiness * 2), random)))
|
||||
chunkSize += burstExtra
|
||||
}
|
||||
|
||||
chunkSize = Math.min(remaining, chunkSize)
|
||||
|
||||
if (sliceMode === 'boundary-aware' && remaining > 6 && random() < 0.72)
|
||||
chunkSize = Math.min(remaining, snapChunkToBoundary(source, start, chunkSize))
|
||||
|
||||
return Math.max(1, chunkSize)
|
||||
}
|
||||
|
||||
function sampleChunkSizeFromRange(
|
||||
source: string,
|
||||
start: number,
|
||||
chunkSizeMin: number,
|
||||
chunkSizeMax: number,
|
||||
burstState: BurstState,
|
||||
sliceMode: StreamSliceMode,
|
||||
random: () => number,
|
||||
) {
|
||||
const remaining = source.length - start
|
||||
if (remaining <= 0)
|
||||
return 0
|
||||
|
||||
const range = Math.max(0, chunkSizeMax - chunkSizeMin)
|
||||
if (range === 0 && sliceMode === 'pure-random')
|
||||
return Math.min(remaining, chunkSizeMin)
|
||||
|
||||
if (sliceMode === 'pure-random') {
|
||||
const chunkSize = range === 0
|
||||
? chunkSizeMin
|
||||
: randomInt(chunkSizeMin, chunkSizeMax, random)
|
||||
return Math.max(1, Math.min(remaining, chunkSize))
|
||||
}
|
||||
|
||||
if (range === 0)
|
||||
return Math.min(remaining, chunkSizeMin)
|
||||
|
||||
const useBurstChunkBias = burstState.fastCadenceChunkPending || burstState.quickChunksRemaining > 0
|
||||
const useLowWindow = !useBurstChunkBias && random() < 0.22
|
||||
let chunkSize = chunkSizeMax
|
||||
|
||||
if (useBurstChunkBias) {
|
||||
const burstMin = Math.max(chunkSizeMin, chunkSizeMax - Math.max(1, Math.floor(range * 0.35)))
|
||||
chunkSize = randomInt(burstMin, chunkSizeMax, random)
|
||||
}
|
||||
else if (useLowWindow) {
|
||||
const lowMax = Math.min(chunkSizeMax, chunkSizeMin + Math.max(1, Math.floor(range * 0.4)))
|
||||
chunkSize = randomInt(chunkSizeMin, lowMax, random)
|
||||
}
|
||||
else {
|
||||
chunkSize = randomInt(chunkSizeMin, chunkSizeMax, random)
|
||||
}
|
||||
|
||||
chunkSize = Math.min(remaining, chunkSize)
|
||||
if (sliceMode === 'boundary-aware' && remaining > 6 && random() < 0.72)
|
||||
chunkSize = Math.min(remaining, snapChunkToBoundary(source, start, chunkSize))
|
||||
|
||||
return Math.max(1, chunkSize)
|
||||
}
|
||||
|
||||
function sampleDelayMs(
|
||||
baseDelayMs: number,
|
||||
jitter: number,
|
||||
burstiness: number,
|
||||
burstState: BurstState,
|
||||
isFirstChunk: boolean,
|
||||
random: () => number,
|
||||
) {
|
||||
const fastCadence = burstState.quickChunksRemaining > 0
|
||||
let delayMs = baseDelayMs * randomBetween(
|
||||
fastCadence ? 0.16 : Math.max(0.55, 1 - (jitter * 0.42)),
|
||||
fastCadence ? 0.7 : 1 + (jitter * 2.05),
|
||||
random,
|
||||
)
|
||||
|
||||
if (isFirstChunk && (jitter > 0.05 || burstiness > 0.05))
|
||||
delayMs += baseDelayMs * randomBetween(0.35, 1.8 + (jitter * 4.6), random)
|
||||
|
||||
if (!fastCadence && burstiness > 0.05 && random() < (burstiness * 0.24))
|
||||
delayMs += baseDelayMs * randomBetween(2.5, 8 + (jitter * 6), random)
|
||||
|
||||
if (!fastCadence && burstiness > 0.08 && random() < (burstiness * 0.2))
|
||||
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
|
||||
|
||||
if (fastCadence)
|
||||
burstState.fastCadenceChunkPending = true
|
||||
|
||||
return Math.round(clampStreamControl(delayMs, 8, 1800, baseDelayMs))
|
||||
}
|
||||
|
||||
function sampleDelayMsFromRange(
|
||||
chunkDelayMin: number,
|
||||
chunkDelayMax: number,
|
||||
burstiness: number,
|
||||
burstState: BurstState,
|
||||
isFirstChunk: boolean,
|
||||
pureRandom: boolean,
|
||||
random: () => number,
|
||||
) {
|
||||
const range = Math.max(0, chunkDelayMax - chunkDelayMin)
|
||||
if (range === 0)
|
||||
return chunkDelayMin
|
||||
|
||||
if (pureRandom)
|
||||
return randomInt(chunkDelayMin, chunkDelayMax, random)
|
||||
|
||||
const fastCadence = burstState.quickChunksRemaining > 0
|
||||
if (fastCadence) {
|
||||
const quickMax = Math.min(chunkDelayMax, chunkDelayMin + Math.max(1, Math.floor(range * 0.3)))
|
||||
burstState.fastCadenceChunkPending = true
|
||||
return randomInt(chunkDelayMin, quickMax, random)
|
||||
}
|
||||
|
||||
const shouldStall = burstiness > 0.05 && random() < (burstiness * 0.24)
|
||||
const shouldBurst = burstiness > 0.08 && random() < (burstiness * 0.18)
|
||||
|
||||
let delayMs: number
|
||||
if (isFirstChunk) {
|
||||
const warmupMin = Math.min(chunkDelayMax, chunkDelayMin + Math.max(1, Math.floor(range * 0.45)))
|
||||
delayMs = randomInt(warmupMin, chunkDelayMax, random)
|
||||
}
|
||||
else if (shouldStall) {
|
||||
const stallMin = Math.max(chunkDelayMin, chunkDelayMax - Math.max(1, Math.floor(range * 0.35)))
|
||||
delayMs = randomInt(stallMin, chunkDelayMax, random)
|
||||
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
|
||||
}
|
||||
else {
|
||||
delayMs = randomInt(chunkDelayMin, chunkDelayMax, random)
|
||||
if (shouldBurst)
|
||||
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
|
||||
}
|
||||
|
||||
return delayMs
|
||||
}
|
||||
|
||||
export function useStreamSimulator(options: StreamSimulatorOptions) {
|
||||
const content = ref('')
|
||||
const isStreaming = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const lastDelayMs = ref(0)
|
||||
const lastChunkSize = ref(0)
|
||||
const chunkCount = ref(0)
|
||||
const chunks = ref<ChunkInfo[]>([])
|
||||
const burstState: BurstState = {
|
||||
quickChunksRemaining: 0,
|
||||
fastCadenceChunkPending: false,
|
||||
}
|
||||
|
||||
const progress = computed(() => {
|
||||
const source = toValue(options.source) || ''
|
||||
if (!source.length)
|
||||
return 0
|
||||
return Math.min(100, Math.round((content.value.length / source.length) * 100))
|
||||
})
|
||||
|
||||
const random = options.random ?? Math.random
|
||||
let abortController: AbortController | null = null
|
||||
let timer: ReturnType<typeof window.setTimeout> | null = null
|
||||
|
||||
function clearTimer() {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimer()
|
||||
abortController?.abort()
|
||||
abortController = null
|
||||
isStreaming.value = false
|
||||
isPaused.value = false
|
||||
}
|
||||
|
||||
function scheduleNext(isFirstChunk = false) {
|
||||
const source = toValue(options.source) || ''
|
||||
if (!isStreaming.value || isPaused.value)
|
||||
return
|
||||
|
||||
if (content.value.length >= source.length) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const burstiness = normalizeRatio(Number(toValue(options.burstiness)), 0.35)
|
||||
const hasExplicitDelayRange = hasExplicitRange(options.chunkDelayMin, options.chunkDelayMax)
|
||||
const pureRandom = resolveSliceMode(options.sliceMode) === 'pure-random'
|
||||
const delayMs = hasExplicitDelayRange
|
||||
? (() => {
|
||||
const { min, max } = normalizeStreamRange(
|
||||
Number(toValue(options.chunkDelayMin)),
|
||||
Number(toValue(options.chunkDelayMax)),
|
||||
8,
|
||||
1800,
|
||||
16,
|
||||
40,
|
||||
)
|
||||
return sampleDelayMsFromRange(min, max, burstiness, burstState, isFirstChunk, pureRandom, random)
|
||||
})()
|
||||
: (() => {
|
||||
const baseDelayMs = clampStreamControl(Number(toValue(options.baseDelayMs)), 8, 400, 24)
|
||||
const jitter = normalizeRatio(Number(toValue(options.jitter)), 0.45)
|
||||
return sampleDelayMs(baseDelayMs, jitter, burstiness, burstState, isFirstChunk, random)
|
||||
})()
|
||||
|
||||
lastDelayMs.value = delayMs
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null
|
||||
pushNextChunk()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
function pushNextChunk() {
|
||||
const source = toValue(options.source) || ''
|
||||
if (!isStreaming.value || isPaused.value)
|
||||
return
|
||||
|
||||
if (!source.length) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const start = content.value.length
|
||||
if (start >= source.length) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const burstiness = normalizeRatio(Number(toValue(options.burstiness)), 0.35)
|
||||
const hasExplicitChunkRange = hasExplicitRange(options.chunkSizeMin, options.chunkSizeMax)
|
||||
const sliceMode = resolveSliceMode(options.sliceMode)
|
||||
const chunkSize = hasExplicitChunkRange
|
||||
? (() => {
|
||||
const { min, max } = normalizeStreamRange(
|
||||
Number(toValue(options.chunkSizeMin)),
|
||||
Number(toValue(options.chunkSizeMax)),
|
||||
1,
|
||||
160,
|
||||
2,
|
||||
6,
|
||||
)
|
||||
return sampleChunkSizeFromRange(source, start, min, max, burstState, sliceMode, random)
|
||||
})()
|
||||
: (() => {
|
||||
const baseChunkSize = clampStreamControl(Number(toValue(options.baseChunkSize)), 1, 160, 4)
|
||||
const hasExplicitDelayRange = hasExplicitRange(options.chunkDelayMin, options.chunkDelayMax)
|
||||
const jitter = hasExplicitDelayRange
|
||||
? (() => {
|
||||
const { min, max } = normalizeStreamRange(
|
||||
Number(toValue(options.chunkDelayMin)),
|
||||
Number(toValue(options.chunkDelayMax)),
|
||||
8,
|
||||
1800,
|
||||
16,
|
||||
40,
|
||||
)
|
||||
return inferJitterFromRange(min, max)
|
||||
})()
|
||||
: normalizeRatio(Number(toValue(options.jitter)), 0.45)
|
||||
return sampleChunkSize(source, start, baseChunkSize, jitter, burstiness, burstState, sliceMode, random)
|
||||
})()
|
||||
|
||||
lastChunkSize.value = chunkSize
|
||||
chunkCount.value += 1
|
||||
const nextChunk = source.slice(start, start + chunkSize)
|
||||
content.value = source.slice(0, start + chunkSize)
|
||||
chunks.value.push({
|
||||
index: chunkCount.value - 1,
|
||||
content: nextChunk,
|
||||
delay: lastDelayMs.value,
|
||||
})
|
||||
if (burstState.fastCadenceChunkPending) {
|
||||
burstState.fastCadenceChunkPending = false
|
||||
burstState.quickChunksRemaining = Math.max(0, burstState.quickChunksRemaining - 1)
|
||||
}
|
||||
|
||||
if (content.value.length >= source.length) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
async function startReadableStream(reset = true) {
|
||||
clearTimer()
|
||||
abortController?.abort()
|
||||
const controller = new AbortController()
|
||||
abortController = controller
|
||||
burstState.quickChunksRemaining = 0
|
||||
burstState.fastCadenceChunkPending = false
|
||||
|
||||
if (reset)
|
||||
content.value = ''
|
||||
|
||||
chunks.value = []
|
||||
lastChunkSize.value = 0
|
||||
lastDelayMs.value = 0
|
||||
chunkCount.value = 0
|
||||
isPaused.value = false
|
||||
|
||||
const source = toValue(options.source) || ''
|
||||
if (!source.length) {
|
||||
abortController = null
|
||||
isStreaming.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const { min: chunkSizeMin, max: chunkSizeMax } = normalizeStreamRange(
|
||||
Number(toValue(options.chunkSizeMin)),
|
||||
Number(toValue(options.chunkSizeMax)),
|
||||
1,
|
||||
160,
|
||||
2,
|
||||
6,
|
||||
)
|
||||
const { min: chunkDelayMin, max: chunkDelayMax } = normalizeStreamRange(
|
||||
Number(toValue(options.chunkDelayMin)),
|
||||
Number(toValue(options.chunkDelayMax)),
|
||||
8,
|
||||
1800,
|
||||
16,
|
||||
40,
|
||||
)
|
||||
const sliceMode = (toValue(options.sliceMode) ?? 'boundary-aware') as StreamSliceMode
|
||||
|
||||
isStreaming.value = true
|
||||
|
||||
try {
|
||||
const stream = createLocalTextStream(source, {
|
||||
chunkDelayMax,
|
||||
chunkDelayMin,
|
||||
chunkSizeMax,
|
||||
chunkSizeMin,
|
||||
random,
|
||||
sliceMode,
|
||||
control: {
|
||||
shouldPause: () => isPaused.value,
|
||||
signal: controller.signal,
|
||||
},
|
||||
onChunk: (chunk) => {
|
||||
lastDelayMs.value = chunk.delay
|
||||
lastChunkSize.value = chunk.content.length
|
||||
chunkCount.value += 1
|
||||
chunks.value.push(chunk)
|
||||
},
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = reset ? '' : content.value
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
|
||||
accumulated += decoder.decode(value, { stream: true })
|
||||
content.value = accumulated
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (abortController === controller)
|
||||
abortController = null
|
||||
|
||||
if (abortController === null || abortController === controller) {
|
||||
isStreaming.value = false
|
||||
isPaused.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start(reset = true) {
|
||||
const transportMode = (toValue(options.transportMode) ?? 'scheduler') as StreamTransportMode
|
||||
if (transportMode === 'readable-stream') {
|
||||
void startReadableStream(reset)
|
||||
return
|
||||
}
|
||||
|
||||
const source = toValue(options.source) || ''
|
||||
if (!source.length) {
|
||||
lastChunkSize.value = 0
|
||||
lastDelayMs.value = 0
|
||||
isStreaming.value = false
|
||||
isPaused.value = false
|
||||
return
|
||||
}
|
||||
|
||||
clearTimer()
|
||||
abortController?.abort()
|
||||
abortController = null
|
||||
burstState.quickChunksRemaining = 0
|
||||
burstState.fastCadenceChunkPending = false
|
||||
if (reset)
|
||||
content.value = ''
|
||||
|
||||
chunks.value = []
|
||||
lastChunkSize.value = 0
|
||||
lastDelayMs.value = 0
|
||||
chunkCount.value = 0
|
||||
isStreaming.value = true
|
||||
isPaused.value = false
|
||||
scheduleNext(true)
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (!isStreaming.value)
|
||||
return
|
||||
|
||||
isPaused.value = true
|
||||
clearTimer()
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!isStreaming.value || !isPaused.value)
|
||||
return
|
||||
|
||||
isPaused.value = false
|
||||
const transportMode = (toValue(options.transportMode) ?? 'scheduler') as StreamTransportMode
|
||||
if (transportMode === 'scheduler')
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (isPaused.value)
|
||||
resume()
|
||||
else
|
||||
pause()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop()
|
||||
content.value = ''
|
||||
chunks.value = []
|
||||
chunkCount.value = 0
|
||||
lastChunkSize.value = 0
|
||||
lastDelayMs.value = 0
|
||||
burstState.quickChunksRemaining = 0
|
||||
burstState.fastCadenceChunkPending = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
return {
|
||||
chunkCount,
|
||||
content,
|
||||
chunks,
|
||||
isStreaming,
|
||||
isPaused,
|
||||
lastChunkSize,
|
||||
lastDelayMs,
|
||||
pause,
|
||||
progress,
|
||||
reset,
|
||||
resume,
|
||||
start,
|
||||
stop,
|
||||
togglePause,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
export const streamContent = `
|
||||
<thinking>我们被要求思考斐波那契数的实现过程。斐波那契数列的定义是:F(0)=0, F(1)=1, 对于n>=2,F(n)=F(n-1)+F(n-2)。有多种方法可以实现斐波那契数的计算,包括递归、迭代、矩阵快速幂、通项公式等。我们需要讨论这些方法的优缺点,可能还需要考虑时间复杂度和空间复杂度。
|
||||
|
||||
由于用户只说了“思考一下斐波拉契数的实现过程”,我们需要给出一个全面的回答,可能包括不同的实现方法及其分析。
|
||||
|
||||
我将用中文回答,因为用户的问题是中文的。
|
||||
|
||||
首先,斐波那契数列是一个经典的数列,在计算机科学中常被用来演示算法和优化。以下是一些常见的实现方法:
|
||||
|
||||
1. **递归实现**:直接根据定义递归地计算F(n)。这种方法简单直观,但效率极低,因为它会产生大量的重复计算。时间复杂度为O(2^n),空间复杂度为O(n)(递归栈深度)。
|
||||
|
||||
2. **记忆化递归(动态规划)**:使用一个数组或哈希表来存储已经计算过的斐波那契数,避免重复计算。时间复杂度为O(n),空间复杂度为O(n)。
|
||||
|
||||
3. **迭代实现(动态规划)**:使用循环从底向上计算,只保留前两个值。时间复杂度为O(n),空间复杂度为O(1)(只存储两个变量)。
|
||||
|
||||
4. **矩阵快速幂**:利用矩阵乘法可以将斐波那契数的计算转化为矩阵的幂运算。通过快速幂算法,可以在O(log n)时间内计算F(n)。空间复杂度为O(1)(存储矩阵)。
|
||||
|
||||
5. **通项公式(比内公式)**:斐波那契数有通项公式:F(n) = (φ^n - ψ^n)/√5,其中φ=(1+√5)/2,ψ=(1-√5)/2。由于浮点数精度问题,对于较大的n可能不准确。时间复杂度取决于幂运算,通常为O(log n)(使用快速幂),但可能有精度误差。
|
||||
|
||||
6. **其他方法**:如使用生成函数、递推关系等。
|
||||
|
||||
在编程中,根据n的大小和对精度、性能的要求选择合适的方法。对于小n,递归或迭代都可以;对于大n,矩阵快速幂或通项公式更高效。
|
||||
|
||||
此外,还需要注意整数溢出问题,当n很大时,斐波那契数可能超过整数范围,需要使用大整数运算。
|
||||
|
||||
现在,我可以给出一个详细的回答,涵盖这些方法,并讨论它们的优缺点。
|
||||
|
||||
由于用户没有指定n的范围或其他要求,我将提供一个全面的分析。
|
||||
|
||||
最后,我将用中文总结。
|
||||
</thinking>
|
||||
|
||||
>>>I'll create a simple Electron + Vue chat application demo. Here's the structure:
|
||||
|
||||
[Star on GitHub](https://github.com/Simon-He95/markstream-vue)
|
||||
|
||||
<a href="https://simonhe.me/">我是 a 元素标签</a>
|
||||
|
||||
https://github.com/Simon-He95/markstream-vue
|
||||
|
||||
[【Author: Simon】](https://simonhe.me/)
|
||||
|
||||
- **[Link (Test 1)](https://simonhe.me/)**
|
||||
|
||||
**[Link (Test 2)](https://simonhe.me/)**
|
||||
|
||||
**Markdown链接**:
|
||||
1. [GitHub官网](https://github.com)
|
||||
2. [知乎 - 有问题就会有答案](https://www.zhihu.com)
|
||||
3. **加粗链接**:[Google](https://www.google.com)
|
||||
4. 嵌套格式的链接:[*斜体链接*](https://example.com)
|
||||
|
||||
**普通链接**:
|
||||
1. https://www.wikipedia.org
|
||||
2. http://example.com/path?query=test
|
||||
3. 纯文本URL:https://markdown-guide.readthedocs.io
|
||||
|
||||

|
||||
*Figure: Vue Markdown Icon (served from /vue-markdown-icon.svg)*
|
||||
|
||||
这是 ~~已删除的文本~~,这是一个表情 :smile:。
|
||||
|
||||
- [ ] Star this repo
|
||||
- [x] Fork this repo
|
||||
- [ ] Create issues
|
||||
- [x] Submit PRs
|
||||
|
||||
## 表格
|
||||
|
||||
| 姓名 | 年龄 | 职业 |
|
||||
|------|------|------|
|
||||
| 张三 | 25 | 工程师 |
|
||||
| 李四 | 30 | 设计师 |
|
||||
| 王五 | 28 | 产品经理 |
|
||||
|
||||
### 对齐表格
|
||||
| 左对齐 | 居中对齐 | 右对齐 |
|
||||
|:-------|:--------:|-------:|
|
||||
| 内容1 | 内容2 | 内容3 |
|
||||
| 内容4 | 内容5 | 内容6 |
|
||||
|
||||
我将为您输出泰勒公式的一般形式及其常见展开式。
|
||||
|
||||
---
|
||||
|
||||
## 0. 薛定谔方程(量子力学)
|
||||
$$i\\hbar \\frac{\\partial}{\\partial t} \\Psi(\\mathbf{r},t) = \\left[ -\\frac{\\hbar^2}{2m} \\nabla^2 + V(\\mathbf{r},t) \\right] \\Psi(\\mathbf{r},t)$$
|
||||
|
||||
|
||||
## 1. 泰勒公式(Taylor's Formula)
|
||||
|
||||
### 一般形式(在点 \\(x = a\\) 处展开):
|
||||
\[
|
||||
f(x) = f(a) + f'(a)(x-a) + \frac{f''(a)}{2!}(x-a)^2 + \frac{f'''(a)}{3!}(x-a)^3 + \cdots + \frac{f^{(n)}(a)}{n!}(x-a)^n + R_n(x)
|
||||
\\]
|
||||
|
||||
其中:
|
||||
- \\(f^{(k)}(a)\\) 是 \\(f(x)\\) 在 \\(x=a\\) 处的 \\(k\\) 阶导数
|
||||
- \\(R_n(x)\\) 是余项,常见形式有拉格朗日余项:
|
||||
\[
|
||||
R_n(x) = \frac{f^{(n+1)}(xi)}{(n+1)!}(x-a)^{n+1}, \quad xi \text{ 在 } a \text{ 和 } x \text{ 之间}
|
||||
\\]
|
||||
|
||||
---
|
||||
|
||||
## 2. 麦克劳林公式(Maclaurin's Formula,即 \\(a=0\\) 时的泰勒公式):
|
||||
\[
|
||||
f(x) = f(0) + f'(0)x + \frac{f''(0)}{2!}x^2 + \frac{f'''(0)}{3!}x^3 + \cdots + \frac{f^{(n)}(0)}{n!}x^n + R_n(x)
|
||||
\\]
|
||||
|
||||
---
|
||||
|
||||
## 3. 常见函数的麦克劳林展开(前几项)
|
||||
|
||||
- **指数函数**:
|
||||
\\[
|
||||
e^x = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \cdots + \frac{x^n}{n!} + \cdots, \quad x \in \mathbb{R}
|
||||
\\]
|
||||
|
||||
- **正弦函数**:
|
||||
\\[
|
||||
\sin x = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots + (-1)^n \frac{x^{2n+1}}{(2n+1)!} + \cdots
|
||||
\\]
|
||||
|
||||
- **余弦函数**:
|
||||
\\[
|
||||
\cos x = 1 - \frac{x^2}{2!} + \frac{x^4}{4!} - \frac{x^6}{6!} + \cdots + (-1)^n \frac{x^{2n}}{(2n)!} + \cdots
|
||||
\\]
|
||||
|
||||
- **自然对数**(在 \\(x=0\\) 附近):
|
||||
\\[
|
||||
\ln(1+x) = x - \frac{x^2}{2} + \frac{x^3}{3} - \frac{x^4}{4} + \cdots + (-1)^{n-1} \frac{x^n}{n} + \cdots, \quad -1 < x \le 1
|
||||
\\]
|
||||
|
||||
- **二项式展开**(\\( (1+x)^m \\),\\(m\\) 为实数):
|
||||
\[
|
||||
(1+x)^m = 1 + mx + \frac{m(m-1)}{2!}x^2 + \frac{m(m-1)(m-2)}{3!}x^3 + \cdots, \quad |x| < 1
|
||||
\\]
|
||||
|
||||
- **矩阵**:
|
||||
\\[
|
||||
\begin{bmatrix}
|
||||
2x_2 - 8x_3 = 8 \\
|
||||
5x_1 - 5x_3 = 10
|
||||
\end{bmatrix}
|
||||
\\]
|
||||
|
||||
- **公式**
|
||||
|
||||
|
||||
- **代入数据**
|
||||
\\[
|
||||
\frac{363}{15,\!135} \times 100\% = 2.398\%
|
||||
\\]
|
||||
|
||||
- **计算工具验证**
|
||||
通过数学计算工具确认结果:
|
||||
\`363 ÷ 15,135 × 100 = 2.39841427...\`
|
||||
|
||||
- **差异说明**
|
||||
$$E=mc^2$$
|
||||
|
||||
---
|
||||
|
||||
如果您需要某个特定函数在特定点的泰勒展开,请告诉我,我可以为您详细写出。
|
||||
|
||||
::: warning
|
||||
这是一个警告块。
|
||||
:::
|
||||
|
||||
::: tip 提示标题
|
||||
这是带标题的提示。
|
||||
:::
|
||||
|
||||
::: error 错误块
|
||||
这是一个错误块。
|
||||
:::
|
||||
|
||||
مرحبا بكم في عالم اللغة العربية!
|
||||
|
||||
\`\`\`plaintext
|
||||
packages/
|
||||
\`\`\`
|
||||
|
||||
1. First, let's set up the project:
|
||||
|
||||
\`\`\`shellscript
|
||||
# Create Vue project
|
||||
npm create vue@latest electron-vue-chat
|
||||
|
||||
# Navigate to project
|
||||
cd electron-vue-chat
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
npm install electron electron-builder vue-router
|
||||
|
||||
# Install dev dependencies
|
||||
npm install -D electron-dev-server concurrently wait-on
|
||||
\`\`\`
|
||||
|
||||
2. Create the main Electron file:
|
||||
|
||||
\`\`\`javascript:electron/main.js
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
let mainWindow;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
}
|
||||
});
|
||||
|
||||
const url = isDev
|
||||
? 'http://localhost:5173'
|
||||
: \`file://\${path.join(__dirname, '../dist/index.html')}\`;
|
||||
|
||||
mainWindow.loadURL(url);
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.on('ready', createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
3. Update package.json:
|
||||
|
||||
\`\`\`diff json:package.json
|
||||
{
|
||||
"name": "markstream-vue",
|
||||
"type": "module",
|
||||
- "version": "0.0.49",
|
||||
+ "version": "0.0.54-beta.1",
|
||||
"packageManager": "pnpm@10.16.1",
|
||||
"description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",
|
||||
"author": "Simon He",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git + git@github.com:Simon-He95/markstream-vue.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Simon-He95/markstream-vue/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"vue",
|
||||
"vue3",
|
||||
"markdown",
|
||||
"markdown-to-html",
|
||||
"markdown-renderer",
|
||||
"vue-markdown",
|
||||
"vue-component",
|
||||
"html",
|
||||
"renderer",
|
||||
"custom-component"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/exports.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./index.css": "./dist/index.css",
|
||||
"./index.tailwind.css": "./dist/index.tailwind.css",
|
||||
"./tailwind": "./dist/tailwind.ts"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/types/exports.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
4. Create chat components \\(diversified languages\\):
|
||||
|
||||
\`\`\`python:src/server/app.py
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
class Message(BaseModel):
|
||||
sender: str
|
||||
text: str
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/echo")
|
||||
def echo(msg: Message):
|
||||
return {"reply": f"Echo: {msg.text}"}
|
||||
\`\`\`
|
||||
|
||||
5. Create a native module example \(C++\):
|
||||
|
||||
\`\`\`cpp:src/native/compute.cpp
|
||||
#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
int fibonacci(int n){
|
||||
if(n<=1) return n;
|
||||
int a=0,b=1;
|
||||
for(int i=2;i<=n;++i){ int c=a+b; a=b; b=c; }
|
||||
return b;
|
||||
}
|
||||
|
||||
int main(){
|
||||
ios::sync_with_stdio(false);
|
||||
cin.tie(nullptr);
|
||||
cout << "fib(10)=" << fibonacci(10) << "\n";
|
||||
return 0;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
6. Update the main App.vue:
|
||||
|
||||
\`\`\`vue:src/App.vue
|
||||
<template>
|
||||
<router-view />
|
||||
<\/template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
\`\`\`
|
||||
|
||||
7. Set up the router:
|
||||
|
||||
\`\`\`javascript:src/router/index.js
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import ChatView from '../views/ChatView.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'chat',
|
||||
component: ChatView
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
export default router;
|
||||
\`\`\`
|
||||
|
||||
8. Update main.js:
|
||||
|
||||
\`\`\`javascript:src/main.js
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
\`\`\`
|
||||
|
||||
9. Mermaid graphic:
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
Kira_Yamato[基拉·大和]
|
||||
Lacus_Clyne[拉克丝·克莱因]
|
||||
Athrun_Zala[阿斯兰·萨拉]
|
||||
Cagalli_Yula_Athha[卡嘉莉·尤拉·阿斯哈]
|
||||
Shinn_Asuka[真·飞鸟]
|
||||
Lunamaria_Hawke[露娜玛丽亚·霍克]
|
||||
COMPASS[世界和平监视组织COMPASS]
|
||||
Foundation[芬德申王国]
|
||||
Orphee_Lam_Tao[奥尔菲·拉姆·陶]
|
||||
%% 节点定义结束,开始定义边
|
||||
Kira_Yamato ---|恋人| Lacus_Clyne
|
||||
Kira_Yamato ---|挚友| Athrun_Zala
|
||||
Kira_Yamato -->|隶属| COMPASS
|
||||
Kira_Yamato -->|前辈| Shinn_Asuka
|
||||
Lacus_Clyne -->|初代总裁| COMPASS
|
||||
Athrun_Zala ---|恋人| Cagalli_Yula_Athha
|
||||
Athrun_Zala -.->|协力| COMPASS
|
||||
Shinn_Asuka ---|恋人| Lunamaria_Hawke
|
||||
Shinn_Asuka -->|隶属| COMPASS
|
||||
Lunamaria_Hawke -->|隶属| COMPASS
|
||||
COMPASS -->|对立| Foundation
|
||||
Orphee_Lam_Tao -->|隶属| Foundation
|
||||
Orphee_Lam_Tao -.->|追求| Lacus_Clyne
|
||||
\`\`\`
|
||||
|
||||
\`\`\`mermaid
|
||||
xychart
|
||||
title "销售收入"
|
||||
x-axis ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]
|
||||
y-axis "收入" 4000 --> 11000
|
||||
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
|
||||
\`\`\`
|
||||
|
||||
\`\`\`infographic
|
||||
infographic list-row-simple-horizontal-arrow
|
||||
data
|
||||
items
|
||||
- label 步骤 1
|
||||
desc 开始
|
||||
- label 步骤 2
|
||||
desc 进行中
|
||||
- label 步骤 3
|
||||
desc 完成
|
||||
\`\`\`
|
||||
|
||||
|
||||
---
|
||||
# 复杂数学公式
|
||||
|
||||
### 1. **理解 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 的含义**
|
||||
- \\(\boldsymbol{\alpha}\\) 和 \\(\boldsymbol{\beta}\\) 是三维列向量,因此 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta}\\) 表示它们的点积(内积)。
|
||||
- \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 意味着向量 \\(\boldsymbol{\alpha}\\) 和 \\(\boldsymbol{\beta}\\) 正交(即垂直),因为点积为零表示它们之间的夹角为 90 度。
|
||||
|
||||
### 2. **正交补空间的概念**
|
||||
- 在线性代数中,对于一个子空间 \\(W\\),它的正交补空间(记为 \\(W^\perp\\))定义为所有与 \\(W\\) 中每个向量正交的向量的集合。即:
|
||||
\[
|
||||
W^\perp = \{ \mathbf{v} \in \mathbb{R}^3 \mid \mathbf{v} \cdot \mathbf{w} = 0 \text{ 对于所有 } \mathbf{w} \in W \}
|
||||
\]
|
||||
- 例如,如果 \\(W\\) 是由一个向量 \\(\boldsymbol{\alpha}\\) 张成的一维子空间(即 \\(W = \operatorname{span}\{\boldsymbol{\alpha}\}\\)),那么 \\(W^\perp\\) 就是所有与 \\(\boldsymbol{\alpha}\\) 正交的向量构成的二维平面。
|
||||
### 3. **\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 与正交补空间的联系**
|
||||
- 当 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 时,这意味着:
|
||||
- \\(\boldsymbol{\beta}\\) 属于 \\(\operatorname{span}\{\boldsymbol{\alpha}\}\\) 的正交补空间,即 \\(\boldsymbol{\beta} \in (\operatorname{span}\{\boldsymbol{\alpha}\})^\perp\\)。
|
||||
- 同样,\\(\boldsymbol{\alpha}\\) 也属于 \\(\operatorname{span}\{\boldsymbol{\beta}\}\\) 的正交补空间,即 \\(\boldsymbol{\alpha} \in (\operatorname{span}\{\boldsymbol{\beta}\})^\perp\\)。
|
||||
- 换句话说,\\(\boldsymbol{\beta}\\) 与 \\(\boldsymbol{\alpha}\\) 张成的直线正交,因此 \\(\boldsymbol{\beta}\\) 位于该直线的垂直平面(即正交补空间)上。反之亦然。
|
||||
|
||||
### 4. **在三维空间中的几何意义**
|
||||
- 在三维空间中,如果 \\(\boldsymbol{\alpha}\\) 是一个非零向量,那么 \\(\operatorname{span}\{\boldsymbol{\alpha}\}\\) 是一条通过原点的直线,而它的正交补空间 \\((\operatorname{span}\{\boldsymbol{\alpha}\})^\perp\\) 是一个通过原点且与该直线垂直的平面。
|
||||
- \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 表示 \\(\boldsymbol{\beta}\\) 位于这个垂直平面上。同样,如果 \\(\boldsymbol{\beta}\\) 非零,那么 \\(\boldsymbol{\alpha}\\) 也位于与 \\(\boldsymbol{\beta}\\) 垂直的平面上。
|
||||
|
||||
### 5. **推广到更一般的情况**
|
||||
- 如果考虑多个向量,正交补空间的概念可以扩展。例如,如果有一组向量 \\(\{\boldsymbol{\alpha}_1, \boldsymbol{\alpha}_2, \ldots, \boldsymbol{\alpha}_k\}\\),那么它们的张成子空间 \\(W = \operatorname{span}\{\boldsymbol{\alpha}_1, \ldots, \boldsymbol{\alpha}_k\}\\) 的正交补空间 \\(W^\perp\\) 包含所有与这些向量正交的向量。
|
||||
- 在这种情况下,\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 可以看作 \\(\boldsymbol{\beta}\\) 与 \\(W\\) 正交的一个特例(当 \\(W\\) 只由 \\(\boldsymbol{\alpha}\\) 张成时)。
|
||||
总之,\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 直接体现了正交补空间的关系:它表明一个向量属于另一个向量张成子空间的正交补空间。如果你有更多向量或子空间,这种联系可以进一步深化。
|
||||
|
||||
**示例:** emm\`1-(5)\`、\`3-(3)\`、\`3-(4)\` complex test \`1-(4)\`”heiheihei”中,hello world。
|
||||
|
||||
---
|
||||
|
||||
## Blockquote
|
||||
|
||||
> This is a blockquote with **bold**, *italic*, and \`inline code\`.
|
||||
>
|
||||
> > Nested blockquotes work too.
|
||||
|
||||
## Heading Levels
|
||||
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
## Inline Elements
|
||||
|
||||
Text with ==highlighted text==, <sub>subscript</sub> and <sup>superscript</sup>, and <ins>inserted text</ins>.
|
||||
|
||||
Use \`npm install\` to install dependencies. The \`--save-dev\` flag marks it as a dev dependency.
|
||||
|
||||
## Definition List
|
||||
|
||||
Token System
|
||||
: A set of CSS custom properties that define colors, spacing, typography, and other visual attributes.
|
||||
|
||||
Design Token
|
||||
: An individual variable (e.g., \`--ms-foreground\`) that can be overridden to customize the theme.
|
||||
|
||||
## Footnotes
|
||||
|
||||
The design token system[^1] enables full theme customization.
|
||||
|
||||
[^1]: See \`design/architecture.md\` for the complete token specification.
|
||||
|
||||
::: note
|
||||
This is a note admonition for additional context.
|
||||
:::
|
||||
|
||||
::: danger
|
||||
This is a danger admonition for critical warnings.
|
||||
:::
|
||||
|
||||
## Image
|
||||
|
||||

|
||||
`
|
||||
@@ -0,0 +1,11 @@
|
||||
// import { getMarkdown, KATEX_COMMANDS } from 'stream-markdown-parser'
|
||||
|
||||
// // Example: create a MarkdownIt instance with custom math options
|
||||
// export const mdWithMathOpts = getMarkdown({
|
||||
// mathOptions: {
|
||||
// commands: [...KATEX_COMMANDS, 'infty', 'perp'],
|
||||
// escapeExclamation: false,
|
||||
// },
|
||||
// })
|
||||
|
||||
// export const rendered = mdWithMathOpts.render('Here is math: $infty$ and bang !')
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import routes from 'virtual:generated-pages'
|
||||
import { createApp } from 'vue'
|
||||
// import { setDefaultI18nMap } from '../../src/exports'
|
||||
// import { createI18n } from 'vue-i18n'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
// import { VueRendererMarkdown } from '../../src/exports'
|
||||
import App from './App.vue'
|
||||
import { installPlaygroundSeo } from './seo'
|
||||
// import JsLocalIcon from './assets/javascript.svg?raw'
|
||||
import 'monaco-editor/min/vs/editor/editor.main.css'
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
installPlaygroundSeo(router)
|
||||
app.use(router)
|
||||
|
||||
// Demo: override icons via plugin options (preferred)
|
||||
// const SHELL_ICON_URL = 'https://raw.githubusercontent.com/catppuccin/vscode-icons/refs/heads/main/icons/mocha/bash.svg'
|
||||
// app.use(VueRendererMarkdown, {
|
||||
// getLanguageIcon(lang: string) {
|
||||
// const l = (lang || '').toLowerCase()
|
||||
// if (l === 'shellscript' || l === 'sh' || l === 'bash')
|
||||
// return `<img src="${SHELL_ICON_URL}" alt="${l}" />`
|
||||
// if (l === 'javascript' || l === 'js')
|
||||
// return JsLocalIcon
|
||||
// return undefined
|
||||
// },
|
||||
// })
|
||||
|
||||
// Optional: if you don't use `vue-i18n`, replace built-in fallback translations
|
||||
// at app startup by calling `setDefaultI18nMap`. Keep this commented in the
|
||||
// playground by default — uncomment to try it out.
|
||||
|
||||
// setDefaultI18nMap({
|
||||
// 'common.copy': '复制',
|
||||
// 'common.copied': '已复制',
|
||||
// 'common.decrease': '减少',
|
||||
// 'common.reset': '重置',
|
||||
// 'common.increase': '增加',
|
||||
// 'common.expand': '展开',
|
||||
// 'common.collapse': '折叠',
|
||||
// 'common.preview': '预览',
|
||||
// 'common.source': '源代码',
|
||||
// 'common.export': '导出',
|
||||
// 'common.open': '打开',
|
||||
// 'common.zoomIn': '放大',
|
||||
// 'common.zoomOut': '缩小',
|
||||
// 'common.resetZoom': '重置缩放',
|
||||
// 'image.loadError': '图片加载失败',
|
||||
// 'image.loading': '正在加载图片...',
|
||||
// })
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,581 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import MarkdownRender, {
|
||||
clearKaTeXWorker,
|
||||
clearMermaidWorker,
|
||||
createKaTeXWorkerFromCDN,
|
||||
createMermaidWorkerFromCDN,
|
||||
enableKatex,
|
||||
enableMermaid,
|
||||
setKatexLoader,
|
||||
setKaTeXWorker,
|
||||
setMermaidLoader,
|
||||
setMermaidWorker,
|
||||
} from '../../../src/exports'
|
||||
|
||||
type Mode = 'cdn' | 'local'
|
||||
|
||||
const router = useRouter()
|
||||
const mode = useLocalStorage<Mode>('vmr-playground-peers-mode', 'cdn')
|
||||
const isDark = useDark()
|
||||
const toggleTheme = useToggle(isDark)
|
||||
|
||||
const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const statusDetail = ref<string>('')
|
||||
const renderKey = ref(0)
|
||||
|
||||
const hasWindowKatex = computed(() => !!(globalThis as any)?.katex)
|
||||
const hasWindowMermaid = computed(() => !!(globalThis as any)?.mermaid)
|
||||
|
||||
const content = ref<string>(`# CDN peers demo
|
||||
|
||||
This page demonstrates running **KaTeX + Mermaid** with CDN-loaded peers, including worker injection.
|
||||
|
||||
## Math
|
||||
|
||||
Inline: $E=mc^2$, $a^2+b^2=c^2$.
|
||||
|
||||
Block:
|
||||
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}
|
||||
$$
|
||||
|
||||
## Mermaid
|
||||
|
||||
\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant W as Worker
|
||||
participant M as Mermaid
|
||||
U->>W: offthread parse
|
||||
W-->>U: ok + prefix
|
||||
U->>M: render
|
||||
\`\`\`
|
||||
`)
|
||||
|
||||
let katexDispose: (() => void) | null = null
|
||||
let mermaidDispose: (() => void) | null = null
|
||||
|
||||
function loadCssOnce(href: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return Promise.resolve()
|
||||
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`) as HTMLLinkElement | null
|
||||
if (existing)
|
||||
return Promise.resolve()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.onload = () => resolve()
|
||||
link.onerror = () => reject(new Error(`Failed to load CSS: ${href}`))
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
const scriptPromises = new Map<string, Promise<void>>()
|
||||
function loadScriptOnce(src: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return Promise.resolve()
|
||||
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null
|
||||
if (existing)
|
||||
return Promise.resolve()
|
||||
const cached = scriptPromises.get(src)
|
||||
if (cached)
|
||||
return cached
|
||||
const p = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
script.async = true
|
||||
script.crossOrigin = 'anonymous'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
scriptPromises.set(src, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function setupLocal() {
|
||||
// Workers (bundled by Vite)
|
||||
const [{ default: KatexWorker }, { default: MermaidWorker }] = await Promise.all([
|
||||
import('../../../src/workers/katexRenderer.worker?worker&inline'),
|
||||
import('../../../src/workers/mermaidParser.worker?worker&inline'),
|
||||
])
|
||||
setKaTeXWorker(new (KatexWorker as any)())
|
||||
setMermaidWorker(new (MermaidWorker as any)())
|
||||
|
||||
// Default loaders (package-installed peers)
|
||||
enableKatex()
|
||||
enableMermaid()
|
||||
|
||||
// Use CDN CSS to keep this page independent from local node_modules imports.
|
||||
await loadCssOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css')
|
||||
}
|
||||
|
||||
async function setupCdn() {
|
||||
// Always reset workers & loaders on mode switch (important if imports already failed once)
|
||||
setKatexLoader(() => (globalThis as any)?.katex)
|
||||
setMermaidLoader(() => (globalThis as any)?.mermaid)
|
||||
|
||||
// Load main-thread peers via CDN globals
|
||||
await Promise.all([
|
||||
loadCssOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css'),
|
||||
loadScriptOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js')
|
||||
.then(() => loadScriptOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/mhchem.min.js')),
|
||||
loadScriptOnce('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'),
|
||||
])
|
||||
|
||||
// Ensure UMD globals are actually available (script load can succeed but global may be missing due to CSP/misconfigured CDN).
|
||||
if (!(globalThis as any)?.katex)
|
||||
throw new Error('KaTeX CDN loaded but window.katex is missing')
|
||||
if (!(globalThis as any)?.mermaid)
|
||||
throw new Error('Mermaid CDN loaded but window.mermaid is missing')
|
||||
|
||||
// Re-enable after scripts are available to guarantee caches are reset.
|
||||
enableKatex(() => (globalThis as any)?.katex)
|
||||
enableMermaid(() => (globalThis as any)?.mermaid)
|
||||
|
||||
// Inject CDN workers (no bundler required)
|
||||
const katex = createKaTeXWorkerFromCDN({
|
||||
mode: 'classic',
|
||||
katexUrl: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js',
|
||||
mhchemUrl: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/mhchem.min.js',
|
||||
})
|
||||
katexDispose = katex.dispose
|
||||
if (katex.worker)
|
||||
setKaTeXWorker(katex.worker)
|
||||
|
||||
const mermaid = createMermaidWorkerFromCDN({
|
||||
mode: 'module',
|
||||
mermaidUrl: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs',
|
||||
})
|
||||
mermaidDispose = mermaid.dispose
|
||||
if (mermaid.worker)
|
||||
setMermaidWorker(mermaid.worker)
|
||||
}
|
||||
|
||||
async function applyMode(next: Mode) {
|
||||
status.value = 'loading'
|
||||
statusDetail.value = ''
|
||||
|
||||
try {
|
||||
// Clean old workers
|
||||
clearKaTeXWorker()
|
||||
clearMermaidWorker()
|
||||
try {
|
||||
katexDispose?.()
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
mermaidDispose?.()
|
||||
}
|
||||
catch {}
|
||||
katexDispose = null
|
||||
mermaidDispose = null
|
||||
|
||||
if (next === 'cdn')
|
||||
await setupCdn()
|
||||
else
|
||||
await setupLocal()
|
||||
|
||||
status.value = 'ready'
|
||||
statusDetail.value = next === 'cdn'
|
||||
? 'CDN scripts + CDN workers injected.'
|
||||
: 'Local Vite workers injected.'
|
||||
renderKey.value++
|
||||
}
|
||||
catch (e: any) {
|
||||
status.value = 'error'
|
||||
statusDetail.value = e?.message ?? String(e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => mode.value,
|
||||
(m) => {
|
||||
applyMode(m)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
try {
|
||||
katexDispose?.()
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
mermaidDispose?.()
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
function goHome() {
|
||||
router.push('/').catch(() => {
|
||||
window.location.href = '/'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cdn-peers-page min-h-screen" :class="{ dark: isDark }">
|
||||
<div class="cdn-peers-bg absolute inset-0 -z-10" />
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-5 sm:px-6 sm:py-8 space-y-4 sm:space-y-5">
|
||||
<header class="cdn-peers-header sticky top-3 z-10">
|
||||
<div class="cdn-peers-header-inner flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="cdn-btn" @click="goHome">
|
||||
<Icon icon="carbon:arrow-left" class="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<h2 class="text-base sm:text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
CDN peers demo
|
||||
</h2>
|
||||
<div class="text-xs text-slate-500 dark:text-slate-400">
|
||||
KaTeX + Mermaid, CDN scripts + CDN workers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="cdn-btn" @click="toggleTheme()">
|
||||
<Icon :icon="isDark ? 'carbon:moon' : 'carbon:sun'" class="h-4 w-4" />
|
||||
Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="cdn-card">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">Mode</span>
|
||||
<div class="cdn-segment">
|
||||
<label class="cdn-segment-item">
|
||||
<input v-model="mode" type="radio" value="cdn">
|
||||
<span>CDN</span>
|
||||
</label>
|
||||
<label class="cdn-segment-item">
|
||||
<input v-model="mode" type="radio" value="local">
|
||||
<span>Local</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<button
|
||||
class="cdn-btn-primary"
|
||||
:disabled="status === 'loading'"
|
||||
@click="applyMode(mode)"
|
||||
>
|
||||
<Icon icon="carbon:reset" class="h-4 w-4" />
|
||||
Re-apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="cdn-mini">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">
|
||||
Status
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
class="cdn-badge"
|
||||
:class="status === 'ready'
|
||||
? 'cdn-badge--ok'
|
||||
: status === 'loading'
|
||||
? 'cdn-badge--loading'
|
||||
: status === 'error'
|
||||
? 'cdn-badge--err'
|
||||
: 'cdn-badge--idle'"
|
||||
>
|
||||
{{ status === 'loading' ? 'loading' : status }}
|
||||
</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-300 truncate">{{ statusDetail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cdn-mini">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">
|
||||
Globals
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="text-slate-500 dark:text-slate-400">window.katex</span>
|
||||
<span class="cdn-dot" :class="hasWindowKatex ? 'cdn-dot--ok' : 'cdn-dot--off'" />
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="text-slate-500 dark:text-slate-400">window.mermaid</span>
|
||||
<span class="cdn-dot" :class="hasWindowMermaid ? 'cdn-dot--ok' : 'cdn-dot--off'" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cdn-mini">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">
|
||||
Notes
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
CDN mode uses UMD globals for main-thread rendering and injects CDN-backed workers for KaTeX render + Mermaid parse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cdn-card">
|
||||
<div v-if="status !== 'ready'" class="p-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
Waiting for optional peers to be ready…
|
||||
</div>
|
||||
<MarkdownRender
|
||||
v-else
|
||||
:key="renderKey"
|
||||
:content="content"
|
||||
:is-dark="isDark"
|
||||
class="cdn-render p-1 sm:p-2"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cdn-peers-page {
|
||||
color: rgb(15 23 42);
|
||||
color-scheme: light;
|
||||
}
|
||||
.cdn-peers-bg {
|
||||
background:
|
||||
radial-gradient(800px 400px at 15% 0%, rgba(79, 70, 229, 0.18), transparent 55%),
|
||||
radial-gradient(700px 380px at 85% 10%, rgba(56, 189, 248, 0.16), transparent 55%),
|
||||
radial-gradient(700px 420px at 60% 85%, rgba(16, 185, 129, 0.12), transparent 55%),
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 1), rgba(241, 245, 249, 1));
|
||||
}
|
||||
.cdn-peers-page.dark {
|
||||
color: rgb(241 245 249);
|
||||
color-scheme: dark;
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-peers-bg {
|
||||
background:
|
||||
radial-gradient(900px 520px at 12% 0%, rgba(124, 58, 237, 0.22), transparent 60%),
|
||||
radial-gradient(820px 520px at 88% 10%, rgba(14, 165, 233, 0.14), transparent 60%),
|
||||
radial-gradient(760px 560px at 60% 86%, rgba(16, 185, 129, 0.10), transparent 60%),
|
||||
radial-gradient(1200px 600px at 50% 40%, rgba(255, 255, 255, 0.04), transparent 62%),
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 1), rgba(3, 7, 18, 1));
|
||||
}
|
||||
|
||||
.cdn-peers-header-inner {
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(15, 23, 42, 0.08),
|
||||
0 2px 10px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-peers-header-inner {
|
||||
background: rgba(3, 7, 18, 0.60);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
box-shadow:
|
||||
0 14px 46px rgba(0, 0, 0, 0.42),
|
||||
0 2px 16px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.cdn-card {
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(15, 23, 42, 0.07),
|
||||
0 2px 10px rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-card {
|
||||
background: rgba(3, 7, 18, 0.62);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
box-shadow:
|
||||
0 18px 54px rgba(0, 0, 0, 0.45),
|
||||
0 2px 18px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cdn-mini {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(248, 250, 252, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-mini {
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.cdn-btn,
|
||||
.cdn-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
.cdn-btn {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
.cdn-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-btn {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
color: rgb(226 232 240);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-btn:hover {
|
||||
background: rgba(30, 41, 59, 0.66);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.cdn-btn-primary {
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 1), rgba(59, 130, 246, 1));
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
box-shadow: 0 12px 22px rgba(79, 70, 229, 0.22);
|
||||
}
|
||||
.cdn-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 18px 28px rgba(79, 70, 229, 0.28);
|
||||
}
|
||||
.cdn-btn-primary:disabled {
|
||||
opacity: 0.65;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cdn-segment {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
background: rgba(241, 245, 249, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-segment {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.cdn-segment-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: rgb(51 65 85);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cdn-segment-item input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cdn-segment-item span {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.cdn-segment-item:has(input:checked) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: 0 10px 16px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-segment-item {
|
||||
color: rgb(226 232 240);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-segment-item:has(input:checked) {
|
||||
background: rgba(30, 41, 59, 0.78);
|
||||
color: rgb(241 245 249);
|
||||
box-shadow: 0 18px 26px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
.cdn-segment-item:has(input:checked) span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cdn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.cdn-badge--ok {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
color: rgb(5 150 105);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--ok {
|
||||
background: rgba(16, 185, 129, 0.18);
|
||||
color: rgb(110 231 183);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
.cdn-badge--loading {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: rgb(37 99 235);
|
||||
border-color: rgba(59, 130, 246, 0.24);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--loading {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: rgb(147 197 253);
|
||||
border-color: rgba(59, 130, 246, 0.24);
|
||||
}
|
||||
.cdn-badge--err {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: rgb(220 38 38);
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--err {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: rgb(252 165 165);
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
}
|
||||
.cdn-badge--idle {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: rgb(71 85 105);
|
||||
border-color: rgba(148, 163, 184, 0.24);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--idle {
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: rgb(226 232 240);
|
||||
border-color: rgba(148, 163, 184, 0.20);
|
||||
}
|
||||
|
||||
.cdn-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
.cdn-dot--ok {
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
background: rgba(16, 185, 129, 0.65);
|
||||
}
|
||||
.cdn-dot--off {
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
background: rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.cdn-render { color: inherit; }
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
|
||||
type Appearance = 'light' | 'dark'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
const themes = ['vitesse-dark', 'vitesse-light']
|
||||
const codeBlockMonacoOptions = computed(() => ({
|
||||
renderSideBySide: true,
|
||||
useInlineViewWhenSpaceIsLimited: false,
|
||||
maxComputationTime: 0,
|
||||
ignoreTrimWhitespace: false,
|
||||
renderIndicators: true,
|
||||
diffAlgorithm: 'legacy',
|
||||
diffLineStyle: 'background',
|
||||
diffUnchangedRegionStyle: 'line-info',
|
||||
diffHideUnchangedRegions: {
|
||||
enabled: true,
|
||||
contextLineCount: 2,
|
||||
minimumLineCount: 3,
|
||||
revealLineCount: 5,
|
||||
},
|
||||
}))
|
||||
|
||||
function buildDiffMarkdown() {
|
||||
const original = [
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "type": "module",',
|
||||
' "version": "0.0.49",',
|
||||
' "packageManager": "pnpm@10.16.1",',
|
||||
' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",',
|
||||
' "keywords": [',
|
||||
' "vue",',
|
||||
' "markdown",',
|
||||
' "streaming",',
|
||||
' "diff",',
|
||||
' "preview"',
|
||||
' ],',
|
||||
' "exports": {',
|
||||
' ".": "./dist/index.js"',
|
||||
' },',
|
||||
' "peerDependencies": {',
|
||||
' "vue": ">=3.0.0"',
|
||||
' },',
|
||||
' "license": "MIT"',
|
||||
'}',
|
||||
]
|
||||
|
||||
const modified = [...original]
|
||||
modified[3] = ' "version": "0.0.54-beta.1",'
|
||||
modified[5] = ' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components, streaming diffs, and advanced markdown features.",'
|
||||
|
||||
const max = Math.max(original.length, modified.length)
|
||||
const diffLines: string[] = []
|
||||
for (let i = 0; i < max; i++) {
|
||||
const left = original[i]
|
||||
const right = modified[i]
|
||||
if (left === right) {
|
||||
diffLines.push(left ?? '')
|
||||
continue
|
||||
}
|
||||
if (left != null)
|
||||
diffLines.push(`-${left}`)
|
||||
if (right != null)
|
||||
diffLines.push(`+${right}`)
|
||||
}
|
||||
|
||||
return [
|
||||
'# Diff Line-Info Regression',
|
||||
'',
|
||||
'```diff json:package.json',
|
||||
...diffLines,
|
||||
'```',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const markdown = buildDiffMarkdown()
|
||||
|
||||
function setAppearance(next: Appearance) {
|
||||
isDark.value = next === 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="theme-regression-page"
|
||||
:class="isDark ? 'theme-regression-page--dark' : 'theme-regression-page--light'"
|
||||
>
|
||||
<div class="theme-regression-shell">
|
||||
<header class="theme-regression-toolbar">
|
||||
<div>
|
||||
<h1>Diff Line-Info Regression</h1>
|
||||
<p>Validates CodeBlockNode light/dark switching with line-info collapsed regions.</p>
|
||||
</div>
|
||||
|
||||
<div class="theme-regression-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="light"
|
||||
:data-active="String(!isDark)"
|
||||
@click="setAppearance('light')"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="dark"
|
||||
:data-active="String(isDark)"
|
||||
@click="setAppearance('dark')"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="theme-regression-preview" data-testid="diff-line-info-preview">
|
||||
<MarkdownRender
|
||||
:content="markdown"
|
||||
:is-dark="isDark"
|
||||
:themes="themes"
|
||||
code-block-dark-theme="vitesse-dark"
|
||||
code-block-light-theme="vitesse-light"
|
||||
:code-block-monaco-options="codeBlockMonacoOptions"
|
||||
:code-block-stream="false"
|
||||
:viewport-priority="false"
|
||||
:batch-rendering="false"
|
||||
:typewriter="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-regression-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px 48px;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-regression-page--light {
|
||||
background: #f5f7fb;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.theme-regression-page--dark {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.theme-regression-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar,
|
||||
.theme-regression-preview {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.theme-regression-page--light .theme-regression-toolbar,
|
||||
.theme-regression-page--light .theme-regression-preview {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.theme-regression-page--dark .theme-regression-toolbar,
|
||||
.theme-regression-page--dark .theme-regression-preview {
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
}
|
||||
|
||||
.theme-regression-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar p {
|
||||
margin: 0;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-regression-actions {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-regression-actions button {
|
||||
appearance: none;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-regression-actions button[data-active="true"] {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.theme-regression-preview {
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
|
||||
type Appearance = 'light' | 'dark'
|
||||
|
||||
const isDark = ref(true)
|
||||
|
||||
const themes = ['vitesse-dark', 'vitesse-light']
|
||||
const codeBlockMonacoOptions = computed(() => ({
|
||||
renderSideBySide: true,
|
||||
useInlineViewWhenSpaceIsLimited: false,
|
||||
maxComputationTime: 0,
|
||||
ignoreTrimWhitespace: false,
|
||||
renderIndicators: true,
|
||||
diffAlgorithm: 'legacy',
|
||||
diffLineStyle: 'background',
|
||||
diffUnchangedRegionStyle: 'metadata',
|
||||
diffHideUnchangedRegions: {
|
||||
enabled: true,
|
||||
contextLineCount: 2,
|
||||
minimumLineCount: 3,
|
||||
revealLineCount: 5,
|
||||
},
|
||||
}))
|
||||
|
||||
function buildDiffMarkdown() {
|
||||
const original = [
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "type": "module",',
|
||||
' "version": "0.0.49",',
|
||||
' "packageManager": "pnpm@10.16.1",',
|
||||
' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",',
|
||||
' "keywords": [',
|
||||
' "vue",',
|
||||
' "markdown",',
|
||||
' "streaming",',
|
||||
' "diff",',
|
||||
' "preview"',
|
||||
' ],',
|
||||
' "exports": {',
|
||||
' ".": "./dist/index.js"',
|
||||
' },',
|
||||
' "peerDependencies": {',
|
||||
' "vue": ">=3.0.0"',
|
||||
' },',
|
||||
' "license": "MIT"',
|
||||
'}',
|
||||
]
|
||||
|
||||
const modified = [...original]
|
||||
modified[3] = ' "version": "0.0.54-beta.1",'
|
||||
modified[5] = ' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components, streaming diffs, and advanced markdown features.",'
|
||||
|
||||
const max = Math.max(original.length, modified.length)
|
||||
const diffLines: string[] = []
|
||||
for (let i = 0; i < max; i++) {
|
||||
const left = original[i]
|
||||
const right = modified[i]
|
||||
if (left === right) {
|
||||
diffLines.push(left ?? '')
|
||||
continue
|
||||
}
|
||||
if (left != null)
|
||||
diffLines.push(`-${left}`)
|
||||
if (right != null)
|
||||
diffLines.push(`+${right}`)
|
||||
}
|
||||
|
||||
return [
|
||||
'# Diff Theme Regression',
|
||||
'',
|
||||
'```diff json:package.json',
|
||||
...diffLines,
|
||||
'```',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const markdown = buildDiffMarkdown()
|
||||
|
||||
function setAppearance(next: Appearance) {
|
||||
isDark.value = next === 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="theme-regression-page"
|
||||
:class="isDark ? 'theme-regression-page--dark' : 'theme-regression-page--light'"
|
||||
>
|
||||
<div class="theme-regression-shell">
|
||||
<header class="theme-regression-toolbar">
|
||||
<div>
|
||||
<h1>Diff Theme Regression</h1>
|
||||
<p>Validates CodeBlockNode light/dark switching with metadata collapsed regions.</p>
|
||||
</div>
|
||||
|
||||
<div class="theme-regression-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="light"
|
||||
:data-active="String(!isDark)"
|
||||
@click="setAppearance('light')"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="dark"
|
||||
:data-active="String(isDark)"
|
||||
@click="setAppearance('dark')"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="theme-regression-preview" data-testid="diff-theme-preview">
|
||||
<MarkdownRender
|
||||
:content="markdown"
|
||||
:is-dark="isDark"
|
||||
:themes="themes"
|
||||
code-block-dark-theme="vitesse-dark"
|
||||
code-block-light-theme="vitesse-light"
|
||||
:code-block-monaco-options="codeBlockMonacoOptions"
|
||||
:code-block-stream="false"
|
||||
:viewport-priority="false"
|
||||
:batch-rendering="false"
|
||||
:typewriter="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-regression-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px 48px;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-regression-page--light {
|
||||
background: #f5f7fb;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.theme-regression-page--dark {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.theme-regression-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar,
|
||||
.theme-regression-preview {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.theme-regression-page--light .theme-regression-toolbar,
|
||||
.theme-regression-page--light .theme-regression-preview {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.theme-regression-page--dark .theme-regression-toolbar,
|
||||
.theme-regression-page--dark .theme-regression-preview {
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
}
|
||||
|
||||
.theme-regression-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar p {
|
||||
margin: 0;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-regression-actions {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-regression-actions button {
|
||||
appearance: none;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-regression-actions button[data-active="true"] {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.theme-regression-preview {
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,643 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { getUseMonaco } from '../../../src/components/CodeBlockNode/monaco'
|
||||
import MarkdownCodeBlockNode from '../../../src/components/MarkdownCodeBlockNode'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
import KatexWorker from '../../../src/workers/katexRenderer.worker?worker&inline'
|
||||
import { setKaTeXWorker } from '../../../src/workers/katexWorkerClient'
|
||||
import MermaidWorker from '../../../src/workers/mermaidParser.worker?worker&inline'
|
||||
import { setMermaidWorker } from '../../../src/workers/mermaidWorkerClient'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
// Workers
|
||||
setKaTeXWorker(() => new KatexWorker())
|
||||
setMermaidWorker(() => new MermaidWorker())
|
||||
getUseMonaco()
|
||||
|
||||
const isDark = ref(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
)
|
||||
// Sync initial state to DOM
|
||||
if (isDark.value)
|
||||
document.documentElement.classList.add('dark')
|
||||
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
}
|
||||
|
||||
const activeTheme = ref('')
|
||||
const themeList = [
|
||||
'',
|
||||
'airbnb',
|
||||
'airtable',
|
||||
'apple',
|
||||
'bmw',
|
||||
'cal',
|
||||
'claude',
|
||||
'clay',
|
||||
'clickhouse',
|
||||
'cohere',
|
||||
'coinbase',
|
||||
'composio',
|
||||
'cursor',
|
||||
'elevenlabs',
|
||||
'expo',
|
||||
'figma',
|
||||
'framer',
|
||||
'hashicorp',
|
||||
'ibm',
|
||||
'intercom',
|
||||
'kraken',
|
||||
'linear',
|
||||
'lovable',
|
||||
'minimax',
|
||||
'mintlify',
|
||||
'miro',
|
||||
'mistral',
|
||||
'mongodb',
|
||||
'notion',
|
||||
'nvidia',
|
||||
'ollama',
|
||||
'opencode-ai',
|
||||
'pinterest',
|
||||
'posthog',
|
||||
'raycast',
|
||||
'replicate',
|
||||
'resend',
|
||||
'revolut',
|
||||
'runwayml',
|
||||
'sanity',
|
||||
'sentry',
|
||||
'spacex',
|
||||
'spotify',
|
||||
'stripe',
|
||||
'supabase',
|
||||
'superhuman',
|
||||
'together-ai',
|
||||
'uber',
|
||||
'vercel',
|
||||
'voltagent',
|
||||
'warp',
|
||||
'webflow',
|
||||
'wise',
|
||||
'x-ai',
|
||||
'zapier',
|
||||
]
|
||||
|
||||
// ── Color palette data ──
|
||||
const palette = [
|
||||
{
|
||||
title: 'Neutral',
|
||||
tokens: [
|
||||
{ name: 'background', bg: '--ms-background', fg: '--ms-foreground' },
|
||||
{ name: 'muted', bg: '--ms-muted', fg: '--ms-muted-foreground' },
|
||||
{ name: 'secondary', bg: '--ms-secondary', fg: '--ms-secondary-foreground' },
|
||||
{ name: 'accent', bg: '--ms-accent', fg: '--ms-accent-foreground' },
|
||||
{ name: 'border', bg: '--ms-border', fg: '--ms-foreground' },
|
||||
{ name: 'muted-fg', bg: '--ms-muted-foreground', fg: '--ms-background' },
|
||||
{ name: 'foreground', bg: '--ms-foreground', fg: '--ms-background' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Signal',
|
||||
tokens: [
|
||||
{ name: 'info', bg: '--ms-info', fg: '--ms-info-foreground' },
|
||||
{ name: 'success', bg: '--ms-success', fg: '--ms-success-foreground' },
|
||||
{ name: 'warning', bg: '--ms-warning', fg: '--ms-warning-foreground' },
|
||||
{ name: 'destructive', bg: '--ms-destructive', fg: '--ms-destructive-foreground' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
tokens: [
|
||||
{ name: 'highlight', bg: '--ms-highlight', fg: '--ms-highlight-foreground' },
|
||||
{ name: 'diff-added', bg: '--ms-diff-added', fg: '--ms-background' },
|
||||
{ name: 'diff-removed', bg: '--ms-diff-removed', fg: '--ms-background' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Shiki code block node (lightweight, no Monaco)
|
||||
const shikiNode = {
|
||||
type: 'code_block' as const,
|
||||
language: 'typescript',
|
||||
code: `import { createApp } from 'vue'
|
||||
import { VueRendererMarkdown } from 'markstream-vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(VueRendererMarkdown, {
|
||||
iconTheme: 'material',
|
||||
})
|
||||
app.mount('#app')`,
|
||||
raw: '',
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ id: 'default', label: 'Default', desc: 'Polished Editorial — 16px, 1.75 line-height, warm reading rhythm' },
|
||||
{ id: 'compact', label: 'Compact', desc: 'AI chat / dashboard — 14px, 1.5 line-height, tight spacing' },
|
||||
] as const
|
||||
|
||||
const activePreset = ref<string>('default')
|
||||
|
||||
// Static markdown covering all component types
|
||||
const content = `# Heading Level 1
|
||||
|
||||
## Heading Level 2
|
||||
|
||||
### Heading Level 3
|
||||
|
||||
#### Heading Level 4
|
||||
|
||||
##### Heading Level 5
|
||||
|
||||
###### Heading Level 6
|
||||
|
||||
---
|
||||
|
||||
## Paragraphs & Inline Elements
|
||||
|
||||
This is a regular paragraph with **bold text**, *italic text*, ~~strikethrough~~, ==highlighted text==, \`inline code\`, and a [link to GitHub](https://github.com). Here is a footnote reference[^1].
|
||||
|
||||
Text with <sub>subscript</sub> and <sup>superscript</sup> and <ins>inserted text</ins>.
|
||||
|
||||
Another paragraph to show the vertical rhythm between blocks. Good typography creates a sense of order and hierarchy. The spacing between paragraphs should feel intentional — neither too tight nor too loose. Line height matters just as much as font size.
|
||||
|
||||
---
|
||||
|
||||
## Inline Code
|
||||
|
||||
Use \`npm install\` to install dependencies. The \`--save-dev\` flag marks it as a dev dependency.
|
||||
|
||||
In TypeScript, use \`Record<string, unknown>\` for generic object types. The \`Partial<T>\` utility makes all properties optional. You can combine them: \`Partial<Record<string, unknown>>\`.
|
||||
|
||||
The config file exports a \`defineConfig()\` function. Set \`mode: 'production'\` for optimized builds. Environment variables are accessed via \`import.meta.env.VITE_API_URL\`.
|
||||
|
||||
---
|
||||
|
||||
## Blockquote
|
||||
|
||||
> This is a blockquote. It can contain **bold**, *italic*, and \`code\`.
|
||||
>
|
||||
> > Nested blockquotes are also supported.
|
||||
|
||||
---
|
||||
|
||||
## Lists
|
||||
|
||||
### Unordered List
|
||||
|
||||
- First item
|
||||
- Second item with **bold**
|
||||
- Nested item A
|
||||
- Nested item B
|
||||
- Deep nested
|
||||
- Third item
|
||||
|
||||
### Ordered List
|
||||
|
||||
1. First step
|
||||
2. Second step
|
||||
1. Sub-step 2.1
|
||||
2. Sub-step 2.2
|
||||
3. Third step
|
||||
|
||||
### Task List
|
||||
|
||||
- [x] Completed task
|
||||
- [ ] Incomplete task
|
||||
- [x] Another done task
|
||||
|
||||
---
|
||||
|
||||
## Code Blocks
|
||||
|
||||
### JavaScript
|
||||
|
||||
\`\`\`javascript
|
||||
function fibonacci(n) {
|
||||
if (n <= 1) return n;
|
||||
let a = 0, b = 1;
|
||||
for (let i = 2; i <= n; i++) {
|
||||
[a, b] = [b, a + b];
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
console.log(fibonacci(10)); // 55
|
||||
\`\`\`
|
||||
|
||||
### TypeScript with Title
|
||||
|
||||
\`\`\`typescript title="utils/theme.ts"
|
||||
interface ThemeConfig {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
foreground: string;
|
||||
}
|
||||
|
||||
export function createTheme(config: Partial<ThemeConfig>): ThemeConfig {
|
||||
return {
|
||||
primary: '#0366d6',
|
||||
secondary: '#586069',
|
||||
background: '#ffffff',
|
||||
foreground: '#24292e',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Shell
|
||||
|
||||
\`\`\`bash
|
||||
npm install markstream-vue
|
||||
pnpm dev
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Table
|
||||
|
||||
### Feature Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Color tokens | Done | 25 components migrated |
|
||||
| Dark mode | Done | Bridge tokens + \`.dark\` class |
|
||||
| Spacing tokens | Done | Flow, inset, gap, size tokens |
|
||||
| Shell extraction | Done | \`CodeBlockShell.vue\` |
|
||||
| Visual redesign | Not started | Core **#343** deliverable |
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
| Metric | v1.0 | v2.0 | Change |
|
||||
|--------|-----:|-----:|-------:|
|
||||
| Bundle size (gzip) | 48.2 KB | 32.7 KB | −32% |
|
||||
| First render | 120 ms | 45 ms | −62% |
|
||||
| Re-render | 18 ms | 6 ms | −67% |
|
||||
| Memory usage | 12.4 MB | 8.1 MB | −35% |
|
||||
| Lighthouse score | 72 | 94 | +31% |
|
||||
| Test coverage | 68% | 91% | +34% |
|
||||
| Components | 18 | 25 | +39% |
|
||||
| CSS tokens | 0 | 82 | — |
|
||||
|
||||
### Compact Table
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| License | MIT |
|
||||
| Runtime | Vue 3 |
|
||||
|
||||
---
|
||||
|
||||
## Admonitions
|
||||
|
||||
::: note
|
||||
This is a note admonition. Useful for additional information.
|
||||
:::
|
||||
|
||||
::: tip
|
||||
This is a tip. Helpful advice for the reader.
|
||||
:::
|
||||
|
||||
::: warning
|
||||
This is a warning. Pay attention to this.
|
||||
:::
|
||||
|
||||
::: danger
|
||||
This is a danger notice. Something could go wrong.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Math
|
||||
|
||||
### Inline Math
|
||||
|
||||
The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ and Euler's identity: $e^{i\\pi} + 1 = 0$.
|
||||
|
||||
### Block Math
|
||||
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}
|
||||
$$
|
||||
|
||||
---
|
||||
|
||||
## Images
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Mermaid Diagram
|
||||
|
||||
\`\`\`mermaid
|
||||
graph LR
|
||||
A[Token System] --> B[Bridge Tokens]
|
||||
A --> C[Semantic Tokens]
|
||||
B --> D[shadcn compat]
|
||||
B --> E[Fallback defaults]
|
||||
C --> F[Component styles]
|
||||
F --> G[Light/Dark auto]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Infographic
|
||||
|
||||
\`\`\`infographic
|
||||
infographic list-row-simple-horizontal-arrow
|
||||
data
|
||||
items
|
||||
- label Components
|
||||
desc 25 migrated to tokens
|
||||
- label Tokens
|
||||
desc 82 semantic variables
|
||||
- label Icon Theme
|
||||
desc Material default
|
||||
- label Test Pass
|
||||
desc 1029 passing
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Definition List
|
||||
|
||||
Term 1
|
||||
: Definition for term 1. This explains what term 1 means.
|
||||
|
||||
Term 2
|
||||
: Definition for term 2. Can contain **formatting**.
|
||||
|
||||
---
|
||||
|
||||
## Footnotes
|
||||
|
||||
[^1]: This is the footnote content. It appears at the bottom.
|
||||
|
||||
---
|
||||
|
||||
*End of static example — compare typography presets using the buttons above.*
|
||||
`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="markstream-vue min-h-screen bg-[hsl(var(--ms-background))] text-[hsl(var(--ms-foreground))] transition-colors"
|
||||
:class="[activePreset, { dark: isDark }]"
|
||||
:data-theme="activeTheme || undefined"
|
||||
>
|
||||
<header class="sticky top-0 z-50 flex flex-wrap items-center gap-3 px-6 py-2.5 border-b border-[hsl(var(--ms-border))] bg-[hsl(var(--ms-background))] backdrop-blur-sm">
|
||||
<span class="text-sm font-semibold mr-2">Typography</span>
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.id"
|
||||
class="px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors"
|
||||
:class="activePreset === preset.id
|
||||
? 'bg-[hsl(var(--ms-primary))] text-[hsl(var(--ms-primary-foreground))] border-transparent'
|
||||
: 'bg-transparent text-inherit border-[hsl(var(--ms-border))] hover:bg-[hsl(var(--ms-accent))]'"
|
||||
:title="preset.desc"
|
||||
@click="activePreset = preset.id"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<select
|
||||
v-model="activeTheme"
|
||||
class="px-2 py-1 text-xs border border-[hsl(var(--ms-border))] rounded-md bg-[hsl(var(--ms-background))] text-inherit cursor-pointer"
|
||||
>
|
||||
<option value="">
|
||||
Default
|
||||
</option>
|
||||
<option v-for="t in themeList.filter(Boolean)" :key="t" :value="t">
|
||||
{{ t.charAt(0).toUpperCase() + t.slice(1).replace(/-/g, ' ') }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs border border-[hsl(var(--ms-border))] rounded-md bg-transparent text-inherit cursor-pointer hover:bg-[hsl(var(--ms-accent))] transition-colors"
|
||||
@click="toggleDark"
|
||||
>
|
||||
{{ isDark ? 'Light' : 'Dark' }}
|
||||
</button>
|
||||
<router-link to="/" class="text-xs text-[var(--link-color)] no-underline hover:underline">
|
||||
← Playground
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="example-content mx-auto px-6 py-8">
|
||||
<div class="mb-4 text-xs text-[hsl(var(--ms-muted-foreground))]">
|
||||
<strong>{{ presets.find(p => p.id === activePreset)?.label }}</strong> — {{ presets.find(p => p.id === activePreset)?.desc }}
|
||||
</div>
|
||||
|
||||
<!-- Color Palette -->
|
||||
<section class="palette-section">
|
||||
<h2 class="palette-title">
|
||||
Color Palette
|
||||
</h2>
|
||||
<div v-for="group in palette" :key="group.title" class="palette-group">
|
||||
<h3 class="palette-group-title">
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<div class="palette-row">
|
||||
<div v-for="token in group.tokens" :key="token.name" class="palette-item">
|
||||
<div
|
||||
class="palette-swatch"
|
||||
:style="{
|
||||
backgroundColor: `hsl(var(${token.bg}))`,
|
||||
color: `hsl(var(${token.fg}))`,
|
||||
}"
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
<span class="palette-label">{{ token.name }}</span>
|
||||
<span class="palette-var">{{ token.bg.replace('--ms-', '') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="palette-divider">
|
||||
|
||||
<MarkdownRender
|
||||
:content="content"
|
||||
:is-dark="isDark"
|
||||
:loading="false"
|
||||
:stream="false"
|
||||
:data-theme="activeTheme || undefined"
|
||||
:code-block-props="{
|
||||
showHeader: true,
|
||||
showCopyButton: true,
|
||||
showCollapseButton: true,
|
||||
showExpandButton: true,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Shiki-rendered code block (lightweight, no Monaco) -->
|
||||
<h2 class="text-xl font-semibold mt-8 mb-4">
|
||||
Shiki Code Block
|
||||
</h2>
|
||||
<MarkdownCodeBlockNode
|
||||
:node="shikiNode"
|
||||
:is-dark="isDark"
|
||||
:loading="false"
|
||||
:stream="false"
|
||||
index-key="shiki-demo"
|
||||
:show-header="true"
|
||||
:show-copy-button="true"
|
||||
:show-collapse-button="true"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* "default" uses the library's built-in token defaults (Polished Editorial).
|
||||
* "compact" overrides tokens for high-density AI chat / dashboard use.
|
||||
*/
|
||||
|
||||
/* ================================================================
|
||||
COMPACT — AI chat / dashboard / sidebar
|
||||
~60% of default spacing. Tighter typography and rhythm.
|
||||
================================================================ */
|
||||
.markstream-vue.compact,
|
||||
.compact .markstream-vue {
|
||||
/* Typography — smaller, tighter */
|
||||
--ms-text-body: 0.875rem;
|
||||
--ms-leading-body: 1.5;
|
||||
--ms-text-h1: 1.375rem;
|
||||
--ms-text-h2: 1.125rem;
|
||||
--ms-text-h3: 1rem;
|
||||
--ms-text-h4: 0.875rem;
|
||||
--ms-text-h5: 0.8125rem;
|
||||
--ms-text-h6: 0.8125rem;
|
||||
--ms-text-label: 0.6875rem;
|
||||
--ms-action-btn-padding: 0.25rem;
|
||||
--ms-action-btn-icon: 0.75rem;
|
||||
|
||||
/* Heading rhythm — compressed */
|
||||
--ms-flow-heading-1-mt: 0;
|
||||
--ms-flow-heading-1-mb: 0.3em;
|
||||
--ms-flow-heading-2-mt: 1.25em;
|
||||
--ms-flow-heading-2-mb: 0.25em;
|
||||
--ms-flow-heading-3-mt: 1em;
|
||||
--ms-flow-heading-3-mb: 0.2em;
|
||||
--ms-flow-heading-4-mt: 0.8em;
|
||||
--ms-flow-heading-4-mb: 0.15em;
|
||||
--ms-flow-heading-5-mt: 0.6em;
|
||||
--ms-flow-heading-5-mb: 0.1em;
|
||||
--ms-flow-heading-6-mt: 0.5em;
|
||||
--ms-flow-heading-6-mb: 0.1em;
|
||||
|
||||
/* Flow spacing — tighter */
|
||||
--ms-flow-paragraph-y: 0.625em;
|
||||
--ms-flow-list-y: 0.5em;
|
||||
--ms-flow-list-item-y: 0.125em;
|
||||
--ms-flow-list-indent: 1.25em;
|
||||
--ms-flow-table-y: 0.625em;
|
||||
--ms-flow-table-cell: 0.2em 0.4em;
|
||||
--ms-flow-blockquote-y: 0.625em;
|
||||
--ms-flow-blockquote-indent: 0.75em;
|
||||
--ms-flow-admonition-y: 0.5em;
|
||||
--ms-flow-footnote-y: 0.25em;
|
||||
--ms-flow-hr-y: 1.25em;
|
||||
--ms-flow-diagram-y: 0.625em;
|
||||
--ms-flow-codeblock-y: 0.625em;
|
||||
--ms-flow-definition-term-mt: 0.25em;
|
||||
--ms-flow-definition-desc-ml: 0.75em;
|
||||
--ms-flow-definition-desc-mb: 0.25em;
|
||||
|
||||
/* Panel insets — smaller */
|
||||
--ms-inset-panel-x: 0.5rem;
|
||||
--ms-inset-panel-y: 0.25rem;
|
||||
--ms-inset-panel-body-sm: 0.125rem;
|
||||
--ms-inset-panel-body: 0.5rem;
|
||||
--ms-inset-admonition-body-top: 0.25rem;
|
||||
--ms-inset-admonition-body-bottom: 0.375rem;
|
||||
|
||||
/* Gaps — tighter */
|
||||
--ms-gap-header: 0.5rem;
|
||||
--ms-gap-header-main: 0.375rem;
|
||||
--ms-gap-header-actions: 0.25rem;
|
||||
|
||||
/* Sizes — smaller */
|
||||
--ms-size-diagram-min-height: 280px;
|
||||
--ms-size-code-max-height: 420px;
|
||||
--ms-size-skeleton-min-height: 80px;
|
||||
|
||||
/* Animation — faster */
|
||||
--ms-duration-standard: 140ms;
|
||||
--ms-duration-overlay: 160ms;
|
||||
}
|
||||
|
||||
/* Compact content width: full width for demo */
|
||||
.compact .example-content { max-width: 100%; padding: 1rem; }
|
||||
|
||||
/* ── Color Palette ── */
|
||||
.palette-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.palette-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.25rem;
|
||||
color: hsl(var(--ms-foreground));
|
||||
}
|
||||
.palette-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.palette-group-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--ms-muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.palette-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.palette-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
.palette-swatch {
|
||||
width: 4.5rem;
|
||||
height: 3.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--ms-border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--ms-font-sans);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.palette-swatch:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.palette-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.375rem;
|
||||
color: hsl(var(--ms-foreground));
|
||||
}
|
||||
.palette-var {
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--ms-muted-foreground));
|
||||
font-family: var(--ms-font-mono);
|
||||
}
|
||||
.palette-divider {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--ms-border));
|
||||
margin: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
<script setup lang="ts">
|
||||
import type { StreamSliceMode } from '../composables/createLocalTextStream'
|
||||
import type { StreamPresetId } from '../composables/streamPresets'
|
||||
import type { StreamTransportMode } from '../composables/useStreamSimulator'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getUseMonaco } from '../../../src/components/CodeBlockNode/monaco'
|
||||
import MarkdownCodeBlockNode from '../../../src/components/MarkdownCodeBlockNode'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
import { setCustomComponents } from '../../../src/utils/nodeComponents'
|
||||
import KatexWorker from '../../../src/workers/katexRenderer.worker?worker&inline'
|
||||
import { setKaTeXWorker } from '../../../src/workers/katexWorkerClient'
|
||||
import MermaidWorker from '../../../src/workers/mermaidParser.worker?worker&inline'
|
||||
import { setMermaidWorker } from '../../../src/workers/mermaidWorkerClient'
|
||||
import ThinkingNode from '../components/ThinkingNode.vue'
|
||||
import { CUSTOM_STREAM_PRESET_ID, findMatchingStreamPreset, getStreamPreset, STREAM_PRESETS } from '../composables/streamPresets'
|
||||
import { clampStreamControl, normalizeStreamRange, useStreamSimulator } from '../composables/useStreamSimulator'
|
||||
import { streamContent } from '../const/markdown'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
const streamChunkDelayMin = useLocalStorage<number>('vmr-settings-stream-delay-min', 14)
|
||||
const streamChunkDelayMax = useLocalStorage<number>('vmr-settings-stream-delay-max', 34)
|
||||
const streamChunkSizeMin = useLocalStorage<number>('vmr-settings-stream-chunk-size-min', 2)
|
||||
const streamChunkSizeMax = useLocalStorage<number>('vmr-settings-stream-chunk-size-max', 7)
|
||||
const streamBurstiness = useLocalStorage<number>('vmr-settings-stream-burstiness', 35)
|
||||
const streamTransportMode = useLocalStorage<StreamTransportMode>('vmr-settings-stream-transport-mode', 'readable-stream')
|
||||
const streamSliceMode = useLocalStorage<StreamSliceMode>('vmr-settings-stream-slice-mode', 'pure-random')
|
||||
const normalizedChunkDelayRange = computed(() => normalizeStreamRange(
|
||||
Number(streamChunkDelayMin.value),
|
||||
Number(streamChunkDelayMax.value),
|
||||
8,
|
||||
240,
|
||||
14,
|
||||
34,
|
||||
))
|
||||
const normalizedChunkSizeRange = computed(() => normalizeStreamRange(
|
||||
Number(streamChunkSizeMin.value),
|
||||
Number(streamChunkSizeMax.value),
|
||||
1,
|
||||
24,
|
||||
2,
|
||||
7,
|
||||
))
|
||||
const normalizedBurstiness = computed(() => Math.round(clampStreamControl(Number(streamBurstiness.value), 0, 100, 35)))
|
||||
const activeStreamPreset = computed(() => findMatchingStreamPreset({
|
||||
chunkDelayMin: normalizedChunkDelayRange.value.min,
|
||||
chunkDelayMax: normalizedChunkDelayRange.value.max,
|
||||
chunkSizeMin: normalizedChunkSizeRange.value.min,
|
||||
chunkSizeMax: normalizedChunkSizeRange.value.max,
|
||||
burstiness: normalizedBurstiness.value,
|
||||
}))
|
||||
const selectedStreamPresetId = computed<StreamPresetId>({
|
||||
get: () => activeStreamPreset.value?.id ?? CUSTOM_STREAM_PRESET_ID,
|
||||
set: (presetId) => {
|
||||
if (presetId === CUSTOM_STREAM_PRESET_ID)
|
||||
return
|
||||
|
||||
const preset = getStreamPreset(presetId)
|
||||
if (!preset)
|
||||
return
|
||||
|
||||
streamChunkDelayMin.value = preset.chunkDelayMin
|
||||
streamChunkDelayMax.value = preset.chunkDelayMax
|
||||
streamChunkSizeMin.value = preset.chunkSizeMin
|
||||
streamChunkSizeMax.value = preset.chunkSizeMax
|
||||
streamBurstiness.value = preset.burstiness
|
||||
},
|
||||
})
|
||||
const streamPresetDescription = computed(() => activeStreamPreset.value?.description ?? 'Custom min/max window with your own burst profile.')
|
||||
const streamChunkRangeLabel = computed(() => `${normalizedChunkSizeRange.value.min}-${normalizedChunkSizeRange.value.max}`)
|
||||
const streamDelayRangeLabel = computed(() => `${normalizedChunkDelayRange.value.min}-${normalizedChunkDelayRange.value.max}ms`)
|
||||
const {
|
||||
content,
|
||||
isPaused,
|
||||
isStreaming,
|
||||
start: startStreamSimulation,
|
||||
stop: stopStreamSimulation,
|
||||
togglePause: toggleStreamPause,
|
||||
} = useStreamSimulator({
|
||||
source: streamContent,
|
||||
chunkSizeMin: computed(() => normalizedChunkSizeRange.value.min),
|
||||
chunkSizeMax: computed(() => normalizedChunkSizeRange.value.max),
|
||||
chunkDelayMin: computed(() => normalizedChunkDelayRange.value.min),
|
||||
chunkDelayMax: computed(() => normalizedChunkDelayRange.value.max),
|
||||
burstiness: computed(() => normalizedBurstiness.value / 100),
|
||||
sliceMode: streamSliceMode,
|
||||
transportMode: streamTransportMode,
|
||||
})
|
||||
|
||||
// 预加载 Monaco 编辑器
|
||||
getUseMonaco()
|
||||
setKaTeXWorker(new KatexWorker())
|
||||
setMermaidWorker(new MermaidWorker())
|
||||
const router = useRouter()
|
||||
|
||||
function goToTest() {
|
||||
// Prefer router navigation, fallback to full redirect if it fails.
|
||||
router.push('/test').catch(() => {
|
||||
window.location.href = '/test'
|
||||
})
|
||||
}
|
||||
|
||||
function goToCdnPeers() {
|
||||
router.push('/cdn-peers').catch(() => {
|
||||
window.location.href = '/cdn-peers'
|
||||
})
|
||||
}
|
||||
|
||||
// Keep persisted values within reasonable bounds on hydration.
|
||||
watchEffect(() => {
|
||||
if (streamChunkDelayMin.value !== normalizedChunkDelayRange.value.min)
|
||||
streamChunkDelayMin.value = normalizedChunkDelayRange.value.min
|
||||
if (streamChunkDelayMax.value !== normalizedChunkDelayRange.value.max)
|
||||
streamChunkDelayMax.value = normalizedChunkDelayRange.value.max
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (streamChunkSizeMin.value !== normalizedChunkSizeRange.value.min)
|
||||
streamChunkSizeMin.value = normalizedChunkSizeRange.value.min
|
||||
if (streamChunkSizeMax.value !== normalizedChunkSizeRange.value.max)
|
||||
streamChunkSizeMax.value = normalizedChunkSizeRange.value.max
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const parsedBurstiness = Number(streamBurstiness.value)
|
||||
const fallbackBurstiness = Number.isFinite(parsedBurstiness) ? parsedBurstiness : 35
|
||||
const boundedBurstiness = Math.round(clampStreamControl(fallbackBurstiness, 0, 100, 35))
|
||||
if (streamBurstiness.value !== boundedBurstiness)
|
||||
streamBurstiness.value = boundedBurstiness
|
||||
})
|
||||
|
||||
setCustomComponents('playground-demo', { thinking: ThinkingNode, code_block: MarkdownCodeBlockNode })
|
||||
const parseOptions = {
|
||||
preTransformTokens: (tokens: any[]) => {
|
||||
// Example: Log tokens during parsing
|
||||
// console.log('Pre-transform tokens:', tokens)
|
||||
return tokens.map((token) => {
|
||||
if (token.type === 'inline' && token.content.includes('<thinking')) {
|
||||
token.children = token.children.map((t: any) => {
|
||||
if (t.type === 'html_block' && t.tag === 'thinking') {
|
||||
const m = t.content.match(/<thinking([^>]*)>/)
|
||||
const attrs = []
|
||||
if (m) {
|
||||
const attrString = m[1]
|
||||
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g
|
||||
let match
|
||||
while ((match = attrRegex.exec(attrString)) !== null) {
|
||||
const attrName = match[1]
|
||||
const attrValue = match[2] || true
|
||||
attrs.push({ name: attrName, value: attrValue })
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||
const content = t.content.replace(/<thinking[^>]*>/, '').replace(/<\/*t*h*i*n*k*i*n*g*>*\n*$/, '')
|
||||
return {
|
||||
type: 'thinking',
|
||||
loading: t.loading,
|
||||
attrs,
|
||||
content,
|
||||
}
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
return token
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 主题切换
|
||||
const isDark = useDark()
|
||||
const toggleTheme = useToggle(isDark)
|
||||
// Code block theme selector (single dropdown)
|
||||
const themes = [
|
||||
'andromeeda',
|
||||
'aurora-x',
|
||||
'ayu-dark',
|
||||
'catppuccin-frappe',
|
||||
'catppuccin-latte',
|
||||
'catppuccin-macchiato',
|
||||
'catppuccin-mocha',
|
||||
'dark-plus',
|
||||
'dracula',
|
||||
'dracula-soft',
|
||||
'everforest-dark',
|
||||
'everforest-light',
|
||||
'github-dark',
|
||||
'github-dark-default',
|
||||
'github-dark-dimmed',
|
||||
'github-dark-high-contrast',
|
||||
'github-light',
|
||||
'github-light-default',
|
||||
'github-light-high-contrast',
|
||||
'gruvbox-dark-hard',
|
||||
'gruvbox-dark-medium',
|
||||
'gruvbox-dark-soft',
|
||||
'gruvbox-light-hard',
|
||||
'gruvbox-light-medium',
|
||||
'gruvbox-light-soft',
|
||||
'houston',
|
||||
'kanagawa-dragon',
|
||||
'kanagawa-lotus',
|
||||
'kanagawa-wave',
|
||||
'laserwave',
|
||||
'light-plus',
|
||||
'material-theme',
|
||||
'material-theme-darker',
|
||||
'material-theme-lighter',
|
||||
'material-theme-ocean',
|
||||
'material-theme-palenight',
|
||||
'min-dark',
|
||||
'min-light',
|
||||
'monokai',
|
||||
'night-owl',
|
||||
'nord',
|
||||
'one-dark-pro',
|
||||
'one-light',
|
||||
'plastic',
|
||||
'poimandres',
|
||||
'red',
|
||||
'rose-pine',
|
||||
'rose-pine-dawn',
|
||||
'rose-pine-moon',
|
||||
'slack-dark',
|
||||
'slack-ochin',
|
||||
'snazzy-light',
|
||||
'solarized-dark',
|
||||
'solarized-light',
|
||||
'synthwave-84',
|
||||
'tokyo-night',
|
||||
'vesper',
|
||||
'vitesse-black',
|
||||
'vitesse-dark',
|
||||
'vitesse-light',
|
||||
]
|
||||
const selectedTheme = useLocalStorage<string>('vmr-settings-selected-theme', 'vitesse-dark')
|
||||
|
||||
// 格式化主题名称显示
|
||||
function formatThemeName(themeName: string) {
|
||||
return themeName
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// 设置面板显示状态
|
||||
const showSettings = ref(false)
|
||||
|
||||
// Use reversed column layout and let the browser handle native scrolling.
|
||||
// Removed custom JS scroll management (observers, programmatic scroll, and
|
||||
// pointer/wheel/touch heuristics) to rely on CSS `flex-direction: column-reverse`.
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// 性能友好的监听:使用 ResizeObserver 监听容器和渲染内容变化,
|
||||
// 当内容高度超过容器可见高度时,在滚动容器上添加 `disable-min-height` 类以移除渲染器的 min-height。
|
||||
// 注意:不要直接对 `.markdown-renderer` 做 `classList.add()`,因为它的 class 由 Vue patch,
|
||||
// 在切换模式/主题等触发更新时会被覆盖,导致 `disable-min-height` 丢失。
|
||||
let __roContainer: ResizeObserver | null = null
|
||||
let __roContent: ResizeObserver | null = null
|
||||
let __mo: MutationObserver | null = null
|
||||
let __scheduled = false
|
||||
// Observers and scheduler
|
||||
|
||||
function scheduleCheckMinHeight() {
|
||||
if (__scheduled)
|
||||
return
|
||||
__scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
__scheduled = false
|
||||
const container = messagesContainer.value
|
||||
if (!container)
|
||||
return
|
||||
container.classList.add('disable-min-height')
|
||||
const containerDelta = container.scrollHeight - container.clientHeight
|
||||
const shouldRemove = containerDelta > 1
|
||||
if (shouldRemove) {
|
||||
// 内容已超出:不再需要继续监听,断开所有 observer 以节省开销
|
||||
try {
|
||||
__roContainer?.disconnect()
|
||||
__roContent?.disconnect()
|
||||
__mo?.disconnect()
|
||||
}
|
||||
finally {
|
||||
__roContainer = null
|
||||
__roContent = null
|
||||
__mo = null
|
||||
}
|
||||
}
|
||||
else {
|
||||
container.classList.remove('disable-min-height')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startStreamSimulation()
|
||||
// 初始检查和观察
|
||||
const container = messagesContainer.value
|
||||
if (!container)
|
||||
return
|
||||
// 初次判断(确保组件渲染完)
|
||||
requestAnimationFrame(scheduleCheckMinHeight)
|
||||
|
||||
// 观察容器尺寸变化(窗口大小、面板大小)
|
||||
__roContainer = new ResizeObserver(scheduleCheckMinHeight)
|
||||
__roContainer.observe(container)
|
||||
|
||||
// 观察渲染内容尺寸变化(markdown 内容动态变化)
|
||||
const tryObserveContent = () => {
|
||||
const el = container.querySelector('.markdown-renderer') as HTMLElement | null
|
||||
if (el) {
|
||||
if (__roContent)
|
||||
__roContent.disconnect()
|
||||
__roContent = new ResizeObserver(scheduleCheckMinHeight)
|
||||
__roContent.observe(el)
|
||||
}
|
||||
}
|
||||
tryObserveContent()
|
||||
|
||||
// 如果 MarkdownRender 在后续替换了子节点,使用 MutationObserver 重新 attach
|
||||
__mo = new MutationObserver(() => {
|
||||
tryObserveContent()
|
||||
scheduleCheckMinHeight()
|
||||
})
|
||||
__mo.observe(container, { childList: true, subtree: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopStreamSimulation()
|
||||
__roContainer?.disconnect()
|
||||
__roContent?.disconnect()
|
||||
__mo?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center p-4 app-container h-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- 设置按钮和面板 -->
|
||||
<div class="fixed top-4 right-4 z-10">
|
||||
<!-- 设置按钮 -->
|
||||
<button
|
||||
class="
|
||||
settings-toggle w-10 h-10 rounded-full
|
||||
bg-white/95 dark:bg-gray-800/95
|
||||
backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700/50
|
||||
shadow-lg dark:shadow-gray-900/20
|
||||
transition-all duration-200 flex items-center justify-center
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||
"
|
||||
:class="{ 'ring-2 ring-blue-500/50': showSettings }"
|
||||
@click="showSettings = !showSettings"
|
||||
>
|
||||
<Icon
|
||||
icon="carbon:settings"
|
||||
class="w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': showSettings }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 设置面板 -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="showSettings"
|
||||
class="
|
||||
absolute top-12 right-0 mt-2
|
||||
bg-white/95 dark:bg-gray-800/95
|
||||
backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50
|
||||
rounded-xl shadow-xl dark:shadow-gray-900/30
|
||||
p-4 space-y-4 min-w-[220px]
|
||||
origin-top-right
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 主题选择器 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Code Theme
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="selectedTheme"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
aria-label="Code block theme"
|
||||
@click.stop
|
||||
@change.stop
|
||||
>
|
||||
<option v-for="t in themes" :key="t" :value="t">
|
||||
{{ formatThemeName(t) }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流式速度控制 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Stream Profile
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="selectedStreamPresetId"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
>
|
||||
<option v-for="preset in STREAM_PRESETS" :key="preset.id" :value="preset.id">
|
||||
{{ preset.label }}
|
||||
</option>
|
||||
<option :value="CUSTOM_STREAM_PRESET_ID">
|
||||
Custom
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ streamPresetDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Transport
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="streamTransportMode"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
>
|
||||
<option value="readable-stream">
|
||||
ReadableStream
|
||||
</option>
|
||||
<option value="scheduler">
|
||||
Scheduler
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Slice Mode
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="streamSliceMode"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
>
|
||||
<option value="pure-random">
|
||||
Pure Random
|
||||
</option>
|
||||
<option value="boundary-aware">
|
||||
Boundary Aware
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkDelayMin
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkDelayMin"
|
||||
type="range"
|
||||
min="8"
|
||||
max="240"
|
||||
step="4"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkDelayRange.min }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkDelayMax
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkDelayMax"
|
||||
type="range"
|
||||
min="8"
|
||||
max="240"
|
||||
step="4"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkDelayRange.max }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkSizeMin
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkSizeMin"
|
||||
type="range"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkSizeRange.min }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkSizeMax
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkSizeMax"
|
||||
type="range"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkSizeRange.max }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Burstiness
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamBurstiness"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
|
||||
{{ normalizedBurstiness }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
|
||||
Active window: {{ streamChunkRangeLabel }} chars and {{ streamDelayRangeLabel }}. When min=max, the cadence becomes fixed.
|
||||
</p>
|
||||
|
||||
<p class="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
|
||||
`Pure Random` uses raw random `slice`; `Boundary Aware` snaps toward word and punctuation boundaries. `ReadableStream` is closest to the real reader path.
|
||||
</p>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Dark Mode
|
||||
</label>
|
||||
<button
|
||||
class="
|
||||
relative w-12 h-6 rounded-full
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||
hover:shadow-lg active:scale-95
|
||||
transition-all duration-200 ease-out
|
||||
"
|
||||
:style="{
|
||||
backgroundColor: isDark ? '#3b82f6' : '#e5e7eb',
|
||||
transition: 'background-color 0.35s ease-out, box-shadow 0.2s ease, transform 0.1s ease',
|
||||
}"
|
||||
@click.stop="toggleTheme()"
|
||||
>
|
||||
<!-- 滑动圆点 -->
|
||||
<div
|
||||
class="
|
||||
absolute top-0.5 w-5 h-5 bg-white rounded-full
|
||||
flex items-center justify-center
|
||||
shadow-md hover:shadow-lg
|
||||
"
|
||||
:style="{
|
||||
left: isDark ? '26px' : '2px',
|
||||
transform: `scale(${isDark ? 1.02 : 1})`,
|
||||
transition: 'left 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.2s ease-out, box-shadow 0.2s ease',
|
||||
}"
|
||||
>
|
||||
<!-- 图标根据状态显示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
enter-from-class="opacity-0 scale-0 rotate-90"
|
||||
enter-to-class="opacity-100 scale-100 rotate-0"
|
||||
leave-from-class="opacity-100 scale-100 rotate-0"
|
||||
leave-to-class="opacity-0 scale-0 rotate-90"
|
||||
mode="out-in"
|
||||
>
|
||||
<Icon
|
||||
v-if="isDark"
|
||||
key="moon"
|
||||
icon="carbon:moon"
|
||||
class="w-3 h-3 text-blue-600 drop-shadow-sm"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
key="sun"
|
||||
icon="carbon:sun"
|
||||
class="w-3 h-3 text-yellow-500 drop-shadow-sm"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Chatbot-style container -->
|
||||
<div class="chatbot-container max-w-5xl w-full bg-white dark:bg-gray-800 rounded-2xl shadow-2xl dark:shadow-gray-900/50 flex flex-col overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<!-- Header -->
|
||||
<div class="chatbot-header px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<Icon icon="carbon:chat" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
|
||||
markstream-vue
|
||||
</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Streaming markdown demo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<!-- GitHub Star Button -->
|
||||
<a
|
||||
href="https://github.com/Simon-He95/markstream-vue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="
|
||||
github-star-btn flex items-center gap-2 px-3 py-1.5
|
||||
bg-gray-800 dark:bg-gray-700 hover:bg-gray-700 dark:hover:bg-gray-600
|
||||
text-white text-sm font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
shadow-md hover:shadow-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||
"
|
||||
>
|
||||
<Icon icon="carbon:star" class="w-4 h-4" />
|
||||
<span>Star</span>
|
||||
</a>
|
||||
|
||||
<!-- Test Page Button -->
|
||||
<button
|
||||
class="ml-2 flex items-center gap-2 px-3 py-1.5 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-200 dark:disabled:bg-gray-700 text-white disabled:text-gray-500 dark:disabled:text-gray-400 text-sm font-medium rounded-lg transition-all duration-200 shadow-md disabled:shadow-none focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed"
|
||||
:disabled="!isStreaming"
|
||||
:title="isPaused ? 'Resume streaming' : 'Pause streaming'"
|
||||
@click="toggleStreamPause"
|
||||
>
|
||||
<Icon :icon="isPaused ? 'carbon:play-filled-alt' : 'carbon:pause-filled'" class="w-4 h-4" />
|
||||
<span>{{ isPaused ? 'Resume' : 'Pause' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ml-2 test-page-btn flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-all duration-200 shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
title="Go to Test page"
|
||||
@click="goToTest"
|
||||
>
|
||||
<Icon icon="carbon:rocket" class="w-4 h-4" />
|
||||
<span>Test</span>
|
||||
</button>
|
||||
|
||||
<!-- CDN peers demo -->
|
||||
<button
|
||||
class="ml-2 flex items-center gap-2 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all duration-200 shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
title="Go to CDN peers demo"
|
||||
@click="goToCdnPeers"
|
||||
>
|
||||
<Icon icon="carbon:cloud" class="w-4 h-4" />
|
||||
<span>CDN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages area with scroll (use column-reverse on the scroll container) -->
|
||||
<main ref="messagesContainer" class="chatbot-messages flex-1 overflow-y-auto mr-[1px] mb-4 flex flex-col-reverse">
|
||||
<MarkdownRender
|
||||
:content="content"
|
||||
:code-block-dark-theme="selectedTheme || undefined"
|
||||
:code-block-light-theme="selectedTheme || undefined"
|
||||
:themes="themes"
|
||||
:custom-html-tags="['thinking']"
|
||||
:is-dark="isDark"
|
||||
:parse-options="parseOptions"
|
||||
custom-id="playground-demo"
|
||||
class="p-6"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
transition: background-color 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatbot-container {
|
||||
transition: all 0.3s ease;
|
||||
overscroll-behavior: contain;
|
||||
height: calc(var(--app-viewport-vh, 1vh) * 100 - 2rem);
|
||||
max-height: calc(var(--app-viewport-vh, 1vh) * 100 - 2rem);
|
||||
}
|
||||
|
||||
.github-star-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.chatbot-messages {
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.chatbot-messages > .markdown-renderer {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 当真实内容高度超出容器时,移除默认 min-height(由 JS 切换类名) */
|
||||
.chatbot-messages.disable-min-height > .markdown-renderer {
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .chatbot-messages::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .chatbot-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.settings-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 主题选择器自定义样式 */
|
||||
.theme-selector select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.theme-selector select option {
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .theme-selector select option {
|
||||
background-color: #1f2937;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 设置面板动画 */
|
||||
.settings-panel {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
/* 代码块加载时的流光闪烁效果 */
|
||||
:deep(.code-block-container.is-rendering) {
|
||||
position: relative;
|
||||
animation: renderingGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes renderingGlow {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 10px rgba(59, 130, 246, 0.4),
|
||||
0 0 20px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
25% {
|
||||
box-shadow:
|
||||
0 0 15px rgba(139, 92, 246, 0.5),
|
||||
0 0 30px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 20px rgba(236, 72, 153, 0.5),
|
||||
0 0 40px rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
75% {
|
||||
box-shadow:
|
||||
0 0 15px rgba(16, 185, 129, 0.5),
|
||||
0 0 30px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 10px rgba(59, 130, 246, 0.4),
|
||||
0 0 20px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mermaid 块加载时的流光闪烁效果 */
|
||||
:deep(.is-rendering) {
|
||||
position: relative;
|
||||
animation: renderingGlow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { h, ref } from 'vue'
|
||||
import MermaidBlockNode from '../../../src/components/MermaidBlockNode'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
import { setCustomComponents } from '../../../src/utils/nodeComponents'
|
||||
import MermaidWorker from '../../../src/workers/mermaidParser.worker?worker&inline'
|
||||
import { setMermaidWorker } from '../../../src/workers/mermaidWorkerClient'
|
||||
|
||||
// Ensure mermaid worker is registered (playground environment)
|
||||
setMermaidWorker(new MermaidWorker())
|
||||
|
||||
const uploaded = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function fakeUpload(_svg: string) {
|
||||
uploading.value = true
|
||||
// Simulate an async upload and return a fake URL
|
||||
return new Promise<string>((resolve) => {
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
resolve(`https://example.com/uploads/mermaid-${Date.now()}.svg`)
|
||||
}, 700)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleExport(ev: any) {
|
||||
// Prevent default export (component would otherwise download)
|
||||
ev.preventDefault()
|
||||
const svgString = ev.svgString ?? (ev.svgElement ? new XMLSerializer().serializeToString(ev.svgElement) : null)
|
||||
if (!svgString) {
|
||||
console.warn('No svg payload available on export event')
|
||||
return
|
||||
}
|
||||
const url = await fakeUpload(svgString)
|
||||
uploaded.value = url
|
||||
}
|
||||
|
||||
// Register a scoped override so MarkdownRender will use our custom onExport handler
|
||||
setCustomComponents('playground-demo-export', {
|
||||
mermaid: (props: any) => h(MermaidBlockNode, {
|
||||
...props,
|
||||
onExport: handleExport,
|
||||
}),
|
||||
})
|
||||
|
||||
// small mermaid block content (note: use a normal JS string here)
|
||||
const md = ref('```mermaid\ngraph LR\n A[User] --> B[Server]\n B --> C[Storage]\n```')
|
||||
|
||||
// reference MarkdownRender to satisfy linters that may require script usage
|
||||
void MarkdownRender
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
Mermaid export override demo
|
||||
</h2>
|
||||
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
This demo overrides the internal Mermaid renderer inside MarkdownRender and intercepts the <code>export</code> event. The example uses <code>ev.svgString</code> (provided by the component) and simulates uploading it to a server.
|
||||
</p>
|
||||
|
||||
<div class="mb-4 border rounded p-4 bg-white">
|
||||
<MarkdownRender custom-id="playground-demo-export" :content="md" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="uploading" class="text-blue-700">
|
||||
Uploading SVG…
|
||||
</div>
|
||||
<div v-else-if="uploaded">
|
||||
Uploaded → <a :href="uploaded" target="_blank" class="text-indigo-600 hover:underline">{{ uploaded }}</a>
|
||||
</div>
|
||||
<div v-else class="text-gray-500">
|
||||
Try the 'Export' button in the block's header to upload (intercepted).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
pre { white-space: pre-wrap }
|
||||
</style>
|
||||
@@ -0,0 +1,543 @@
|
||||
<script setup lang="ts">
|
||||
import type { SandboxSelection } from '../../../playground-shared/versionSandbox'
|
||||
import { computed, createApp as createVue3RuntimeApp, onBeforeUnmount, onMounted, ref, h as vue3H } from 'vue'
|
||||
import { decodeMarkdownHash } from '../../../playground-shared/testPageState'
|
||||
import { parseSandboxSelection } from '../../../playground-shared/versionSandbox'
|
||||
import {
|
||||
TEST_SANDBOX_KATEX_VERSION,
|
||||
TEST_SANDBOX_MERMAID_VERSION,
|
||||
testSandboxFrameworks,
|
||||
} from '../testSandboxConfig'
|
||||
import '../../../packages/markstream-angular/src/index.css'
|
||||
|
||||
type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
const canvasRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<SandboxStatus>('idle')
|
||||
const selection = ref<SandboxSelection>(parseSandboxSelection(window.location.search, testSandboxFrameworks))
|
||||
const markdown = ref(decodeMarkdownHash(window.location.hash || '') ?? '')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (status.value === 'loading')
|
||||
return '加载中'
|
||||
if (status.value === 'error')
|
||||
return '加载失败'
|
||||
if (status.value === 'ready')
|
||||
return 'Ready'
|
||||
return '待命'
|
||||
})
|
||||
|
||||
const selectionSummary = computed(() => {
|
||||
const current = selection.value
|
||||
return `${current.framework.label} / ${current.source === 'workspace' ? 'workspace' : 'npm'} / ${current.version}`
|
||||
})
|
||||
|
||||
let cleanupMountedRuntime: (() => void) | null = null
|
||||
let renderToken = 0
|
||||
|
||||
function setHeadLink(id: string, href: string | null) {
|
||||
const existing = document.head.querySelector<HTMLLinkElement>(`link[data-sandbox-style="${id}"]`)
|
||||
|
||||
if (!href) {
|
||||
existing?.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing.href !== href)
|
||||
existing.href = href
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.dataset.sandboxStyle = id
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
function encodeNpmVersion(version: string) {
|
||||
return encodeURIComponent(version.trim() || 'latest')
|
||||
}
|
||||
|
||||
function createEsmPackageUrl(packageName: string, version: string, deps: string[] = []) {
|
||||
const query = new URLSearchParams()
|
||||
if (deps.length)
|
||||
query.set('deps', deps.join(','))
|
||||
const suffix = query.toString()
|
||||
return `https://esm.sh/${packageName}@${encodeNpmVersion(version)}${suffix ? `?${suffix}` : ''}`
|
||||
}
|
||||
|
||||
function createEsmPackageSubpathUrl(
|
||||
packageName: string,
|
||||
version: string,
|
||||
subpath: string,
|
||||
deps: string[] = [],
|
||||
) {
|
||||
const base = createEsmPackageUrl(packageName, version, deps)
|
||||
const [prefix, query] = base.split('?')
|
||||
return `${prefix}/${subpath}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
function createJsDelivrCssUrl(packageName: string, version: string) {
|
||||
return `https://cdn.jsdelivr.net/npm/${packageName}@${encodeNpmVersion(version)}/dist/index.css`
|
||||
}
|
||||
|
||||
function applySandboxStyles(current: SandboxSelection) {
|
||||
setHeadLink(
|
||||
'renderer',
|
||||
current.source === 'npm'
|
||||
? createJsDelivrCssUrl(current.framework.packageName, current.version)
|
||||
: null,
|
||||
)
|
||||
setHeadLink(
|
||||
'katex',
|
||||
`https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.css`,
|
||||
)
|
||||
}
|
||||
|
||||
async function importRemote<T = any>(url: string): Promise<T> {
|
||||
return await import(/* @vite-ignore */ url) as T
|
||||
}
|
||||
|
||||
function cleanupRuntime() {
|
||||
cleanupMountedRuntime?.()
|
||||
cleanupMountedRuntime = null
|
||||
if (canvasRef.value)
|
||||
canvasRef.value.innerHTML = ''
|
||||
}
|
||||
|
||||
function setupCommonRendererRuntime(rendererModule: Record<string, any>) {
|
||||
rendererModule.enableKatex?.()
|
||||
rendererModule.enableMermaid?.()
|
||||
|
||||
const katexHandle = rendererModule.createKaTeXWorkerFromCDN?.({
|
||||
mode: 'classic',
|
||||
katexUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.js`,
|
||||
mhchemUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/contrib/mhchem.min.js`,
|
||||
})
|
||||
if (katexHandle?.worker)
|
||||
rendererModule.setKaTeXWorker?.(katexHandle.worker)
|
||||
|
||||
const mermaidHandle = rendererModule.createMermaidWorkerFromCDN?.({
|
||||
mode: 'module',
|
||||
workerOptions: { type: 'module' },
|
||||
mermaidUrl: `https://cdn.jsdelivr.net/npm/mermaid@${TEST_SANDBOX_MERMAID_VERSION}/dist/mermaid.esm.min.mjs`,
|
||||
})
|
||||
if (mermaidHandle?.worker)
|
||||
rendererModule.setMermaidWorker?.(mermaidHandle.worker)
|
||||
|
||||
return () => {
|
||||
rendererModule.clearKaTeXWorker?.()
|
||||
rendererModule.clearMermaidWorker?.()
|
||||
katexHandle?.dispose?.()
|
||||
mermaidHandle?.dispose?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountVue3Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const rendererModule = current.source === 'workspace'
|
||||
? await import('markstream-vue')
|
||||
: await importRemote(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`vue@${current.framework.runtimeVersion}`],
|
||||
))
|
||||
const vueModule = current.source === 'workspace'
|
||||
? null
|
||||
: await importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion))
|
||||
|
||||
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
|
||||
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
|
||||
const createApp = current.source === 'workspace'
|
||||
? createVue3RuntimeApp
|
||||
: (vueModule?.createApp || vueModule?.default?.createApp)
|
||||
const h = current.source === 'workspace'
|
||||
? vue3H
|
||||
: (vueModule?.h || vueModule?.default?.h)
|
||||
|
||||
if (!MarkdownRender || !createApp || !h)
|
||||
throw new Error('无法找到 Vue 3 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const app = createApp({
|
||||
render() {
|
||||
return h(MarkdownRender, {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.mount(host)
|
||||
|
||||
return () => {
|
||||
app.unmount()
|
||||
teardownRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountVue2Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const [vueModule, rendererModule] = await Promise.all([
|
||||
importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion)),
|
||||
importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[
|
||||
`vue@${current.framework.runtimeVersion}`,
|
||||
'@vue/composition-api@1.7.2',
|
||||
],
|
||||
)),
|
||||
])
|
||||
|
||||
const Vue2 = vueModule.default || vueModule
|
||||
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
|
||||
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
|
||||
|
||||
if (!Vue2 || !MarkdownRender)
|
||||
throw new Error('无法找到 Vue 2 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const app = new Vue2({
|
||||
render(createElement: (component: any, data?: any) => any) {
|
||||
return createElement(MarkdownRender, {
|
||||
props: {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.$mount(host)
|
||||
|
||||
return () => {
|
||||
app.$destroy()
|
||||
teardownRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountReactSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const runtimeVersion = current.framework.runtimeVersion
|
||||
const [reactModule, reactDomModule, rendererModule] = await Promise.all([
|
||||
importRemote<any>(createEsmPackageUrl('react', runtimeVersion)),
|
||||
importRemote<any>(createEsmPackageSubpathUrl(
|
||||
'react-dom',
|
||||
runtimeVersion,
|
||||
'client',
|
||||
[`react@${runtimeVersion}`],
|
||||
)),
|
||||
importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`react@${runtimeVersion}`, `react-dom@${runtimeVersion}`],
|
||||
)),
|
||||
])
|
||||
|
||||
const React = reactModule.default || reactModule
|
||||
const ReactDOMClient = reactDomModule.default || reactDomModule
|
||||
const MarkdownRender = rendererModule.default || rendererModule.NodeRenderer
|
||||
|
||||
if (!React?.createElement || !ReactDOMClient?.createRoot || !MarkdownRender)
|
||||
throw new Error('无法找到 React 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const root = ReactDOMClient.createRoot(host)
|
||||
root.render(
|
||||
React.createElement(MarkdownRender, {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
}),
|
||||
)
|
||||
|
||||
return () => {
|
||||
root.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountAngularSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const runtimeVersion = current.framework.runtimeVersion
|
||||
const rendererModule = current.source === 'workspace'
|
||||
? await import('markstream-angular')
|
||||
: await importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`@angular/common@${runtimeVersion}`, `@angular/core@${runtimeVersion}`],
|
||||
))
|
||||
|
||||
const renderMarkdownToHtml = rendererModule.renderMarkdownToHtml
|
||||
const enhanceRenderedHtml = rendererModule.enhanceRenderedHtml
|
||||
if (typeof renderMarkdownToHtml !== 'function')
|
||||
throw new Error('无法找到 Angular 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
|
||||
const shell = document.createElement('div')
|
||||
shell.className = 'markstream-angular markdown-renderer'
|
||||
shell.innerHTML = renderMarkdownToHtml({
|
||||
content,
|
||||
final: true,
|
||||
allowHtml: true,
|
||||
})
|
||||
const enhancementHandle = await enhanceRenderedHtml?.(shell, {
|
||||
final: true,
|
||||
})
|
||||
|
||||
host.replaceChildren(shell)
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
return () => {
|
||||
enhancementHandle?.dispose?.()
|
||||
mountPoint.replaceChildren()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
if (current.frameworkId === 'vue3')
|
||||
return await mountVue3Sandbox(current, content, mountPoint)
|
||||
|
||||
if (current.frameworkId === 'vue2')
|
||||
return await mountVue2Sandbox(current, content, mountPoint)
|
||||
|
||||
if (current.frameworkId === 'angular')
|
||||
return await mountAngularSandbox(current, content, mountPoint)
|
||||
|
||||
return await mountReactSandbox(current, content, mountPoint)
|
||||
}
|
||||
|
||||
async function renderSandbox() {
|
||||
const mountPoint = canvasRef.value
|
||||
if (!mountPoint)
|
||||
return
|
||||
|
||||
renderToken += 1
|
||||
const activeToken = renderToken
|
||||
|
||||
cleanupRuntime()
|
||||
selection.value = parseSandboxSelection(window.location.search, testSandboxFrameworks)
|
||||
markdown.value = decodeMarkdownHash(window.location.hash || '') ?? ''
|
||||
errorMessage.value = ''
|
||||
status.value = 'loading'
|
||||
applySandboxStyles(selection.value)
|
||||
|
||||
try {
|
||||
const teardown = await mountSandbox(selection.value, markdown.value, mountPoint)
|
||||
if (activeToken !== renderToken) {
|
||||
teardown()
|
||||
return
|
||||
}
|
||||
cleanupMountedRuntime = teardown
|
||||
status.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[test-sandbox] failed to render sandbox', error)
|
||||
cleanupRuntime()
|
||||
status.value = 'error'
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleHashChange() {
|
||||
void renderSandbox()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void renderSandbox()
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', handleHashChange)
|
||||
cleanupRuntime()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sandbox-page">
|
||||
<header class="sandbox-hero">
|
||||
<div>
|
||||
<span class="sandbox-eyebrow">Version sandbox</span>
|
||||
<h1>Isolated Renderer</h1>
|
||||
<p>当前 iframe 正在独立加载对应 framework 与包版本,用于和左侧 workspace 预览做对照。</p>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-summary">
|
||||
<div class="sandbox-summary__item">
|
||||
<span>选择</span>
|
||||
<strong>{{ selectionSummary }}</strong>
|
||||
</div>
|
||||
<div class="sandbox-summary__item">
|
||||
<span>状态</span>
|
||||
<strong>{{ statusLabel }}</strong>
|
||||
</div>
|
||||
<div class="sandbox-summary__item">
|
||||
<span>字符数</span>
|
||||
<strong>{{ markdown.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="status === 'error'" class="sandbox-banner sandbox-banner--error">
|
||||
<strong>沙箱加载失败。</strong>
|
||||
<span>{{ errorMessage || '请检查版本号是否存在,或稍后重试。' }}</span>
|
||||
</div>
|
||||
<div v-else-if="selection.source === 'workspace' && !selection.framework.supportsWorkspace" class="sandbox-banner sandbox-banner--warning">
|
||||
当前框架不支持在这个 iframe 中直接跑 workspace 源码,已自动回退到 npm 包模式。
|
||||
</div>
|
||||
<div v-else-if="status === 'loading'" class="sandbox-banner sandbox-banner--info">
|
||||
正在加载隔离渲染器与对应版本资源...
|
||||
</div>
|
||||
|
||||
<section class="sandbox-canvas">
|
||||
<div ref="canvasRef" class="sandbox-canvas__mount" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sandbox-page {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(14, 165, 233, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
color: #10203a;
|
||||
}
|
||||
|
||||
.sandbox-hero,
|
||||
.sandbox-canvas,
|
||||
.sandbox-banner {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.sandbox-hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.sandbox-eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(29, 78, 216, 0.1);
|
||||
color: #1d4ed8;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sandbox-hero h1 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.sandbox-hero p {
|
||||
margin: 0;
|
||||
color: #5d7390;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sandbox-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sandbox-summary__item {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.sandbox-summary__item span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #5d7390;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sandbox-summary__item strong {
|
||||
display: block;
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.sandbox-banner {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sandbox-banner--info {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.sandbox-banner--warning {
|
||||
color: #b45309;
|
||||
background: rgba(255, 251, 235, 0.92);
|
||||
}
|
||||
|
||||
.sandbox-banner--error {
|
||||
color: #b91c1c;
|
||||
background: rgba(254, 242, 242, 0.94);
|
||||
}
|
||||
|
||||
.sandbox-canvas {
|
||||
margin-top: 14px;
|
||||
min-height: calc(100vh - 220px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sandbox-canvas__mount {
|
||||
min-height: inherit;
|
||||
padding: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 248, 252, 0.92));
|
||||
}
|
||||
|
||||
.sandbox-canvas__mount :deep(.markstream-vue),
|
||||
.sandbox-canvas__mount :deep(.markstream-react) {
|
||||
min-height: calc(100vh - 320px);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sandbox-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sandbox-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sandbox-canvas__mount {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
|
||||
const PLAYGROUND_SITE_URL = 'https://markstream-vue.simonhe.me'
|
||||
const PLAYGROUND_OG_IMAGE_URL = `${PLAYGROUND_SITE_URL}/og-image.svg`
|
||||
const GITHUB_REPO_URL = 'https://github.com/Simon-He95/markstream-vue'
|
||||
const DEFAULT_TITLE = 'markstream-vue Playground | Streaming Markdown, Mermaid, and large-document demos'
|
||||
const DEFAULT_DESCRIPTION = 'Explore markstream-vue with live playground demos for streaming Markdown, Mermaid, KaTeX, CDN peers, and large-document rendering.'
|
||||
const DEFAULT_ROBOTS = 'noindex,nofollow'
|
||||
|
||||
interface RouteSeoConfig {
|
||||
title: string
|
||||
description: string
|
||||
robots?: string
|
||||
}
|
||||
|
||||
const routeSeoMap: Record<string, RouteSeoConfig> = {
|
||||
'/': {
|
||||
title: DEFAULT_TITLE,
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
robots: 'index,follow',
|
||||
},
|
||||
'/markdown': {
|
||||
title: 'Markdown Block Playground | markstream-vue',
|
||||
description: 'Focus on MarkdownCodeBlockNode behavior with streaming content, KaTeX, Mermaid, custom tags, and code block theming.',
|
||||
robots: 'index,follow',
|
||||
},
|
||||
'/test': {
|
||||
title: 'Markdown Preview & Test Lab | markstream-vue Playground',
|
||||
description: 'Paste markdown for live preview, then compare framework and version behavior with streaming, Monaco, Mermaid, and KaTeX in one place.',
|
||||
robots: 'index,follow',
|
||||
},
|
||||
'/cdn-peers': {
|
||||
title: 'CDN Peer Loading Demo | markstream-vue Playground',
|
||||
description: 'See how to run markstream-vue with KaTeX and Mermaid loaded from CDNs, including worker setup and runtime fallback handling.',
|
||||
robots: 'index,follow',
|
||||
},
|
||||
'/mermaid-export-demo': {
|
||||
title: 'Mermaid Export Override Demo | markstream-vue Playground',
|
||||
description: 'Learn how to intercept Mermaid export events, replace the built-in renderer, and upload generated SVG output.',
|
||||
robots: 'index,follow',
|
||||
},
|
||||
'/diff-theme-regression': {
|
||||
title: 'Diff Theme Regression Check | markstream-vue Playground',
|
||||
description: 'Internal regression page for validating diff theme rendering.',
|
||||
robots: 'noindex,nofollow',
|
||||
},
|
||||
'/diff-line-info-regression': {
|
||||
title: 'Diff Line-Info Regression Check | markstream-vue Playground',
|
||||
description: 'Internal regression page for validating diff metadata rendering.',
|
||||
robots: 'noindex,nofollow',
|
||||
},
|
||||
'/test-sandbox': {
|
||||
title: 'Sandbox Runtime Check | markstream-vue Playground',
|
||||
description: 'Internal sandbox for version and framework compatibility checks.',
|
||||
robots: 'noindex,nofollow',
|
||||
},
|
||||
}
|
||||
|
||||
function normalizeRoutePath(path: string) {
|
||||
if (!path)
|
||||
return '/'
|
||||
|
||||
const normalized = path.replace(/\/+$/, '')
|
||||
return normalized || '/'
|
||||
}
|
||||
|
||||
function getRouteSeo(path: string) {
|
||||
const normalized = normalizeRoutePath(path)
|
||||
return routeSeoMap[normalized] ?? {
|
||||
title: DEFAULT_TITLE,
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
robots: DEFAULT_ROBOTS,
|
||||
}
|
||||
}
|
||||
|
||||
function upsertMeta(selector: string, attributes: Record<string, string>) {
|
||||
let element = document.head.querySelector<HTMLMetaElement>(selector)
|
||||
if (!element) {
|
||||
element = document.createElement('meta')
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element!.setAttribute(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
function upsertLink(selector: string, attributes: Record<string, string>) {
|
||||
let element = document.head.querySelector<HTMLLinkElement>(selector)
|
||||
if (!element) {
|
||||
element = document.createElement('link')
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element!.setAttribute(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
function upsertJsonLd(selector: string, payload: string) {
|
||||
let element = document.head.querySelector<HTMLScriptElement>(selector)
|
||||
if (!element) {
|
||||
element = document.createElement('script')
|
||||
element.type = 'application/ld+json'
|
||||
element.dataset.seoJsonld = 'playground'
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
|
||||
element.textContent = payload
|
||||
}
|
||||
|
||||
function removeJsonLd(selector: string) {
|
||||
document.head.querySelector(selector)?.remove()
|
||||
}
|
||||
|
||||
function createStructuredData(path: string, canonicalUrl: string, seo: RouteSeoConfig) {
|
||||
const pageName = seo.title.replace(/\s+\|\s+markstream-vue.*$/, '')
|
||||
const graph: Record<string, any>[] = [
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': `${PLAYGROUND_SITE_URL}/#website`,
|
||||
'name': 'markstream-vue Playground',
|
||||
'url': `${PLAYGROUND_SITE_URL}/`,
|
||||
'description': DEFAULT_DESCRIPTION,
|
||||
'publisher': {
|
||||
'@type': 'Organization',
|
||||
'name': 'markstream-vue',
|
||||
'url': GITHUB_REPO_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'itemListElement': [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
'position': 1,
|
||||
'name': 'Playground',
|
||||
'item': `${PLAYGROUND_SITE_URL}/`,
|
||||
},
|
||||
...(path === '/'
|
||||
? []
|
||||
: [{
|
||||
'@type': 'ListItem',
|
||||
'position': 2,
|
||||
'name': pageName,
|
||||
'item': canonicalUrl,
|
||||
}]),
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'SoftwareApplication',
|
||||
'name': pageName,
|
||||
'applicationCategory': 'DeveloperApplication',
|
||||
'operatingSystem': 'Web',
|
||||
'url': canonicalUrl,
|
||||
'description': seo.description,
|
||||
'image': PLAYGROUND_OG_IMAGE_URL,
|
||||
'isAccessibleForFree': true,
|
||||
'offers': {
|
||||
'@type': 'Offer',
|
||||
'price': '0',
|
||||
'priceCurrency': 'USD',
|
||||
},
|
||||
'sameAs': [
|
||||
GITHUB_REPO_URL,
|
||||
`${PLAYGROUND_SITE_URL}/`,
|
||||
],
|
||||
'isPartOf': {
|
||||
'@id': `${PLAYGROUND_SITE_URL}/#website`,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': graph,
|
||||
})
|
||||
}
|
||||
|
||||
function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
|
||||
const path = normalizeRoutePath(route.path)
|
||||
const seo = getRouteSeo(path)
|
||||
const canonicalUrl = path === '/' ? `${PLAYGROUND_SITE_URL}/` : `${PLAYGROUND_SITE_URL}${path}`
|
||||
|
||||
document.title = seo.title
|
||||
|
||||
upsertLink('link[rel="canonical"]', { rel: 'canonical', href: canonicalUrl })
|
||||
upsertMeta('meta[name="description"]', { name: 'description', content: seo.description })
|
||||
upsertMeta('meta[name="robots"]', { name: 'robots', content: seo.robots || DEFAULT_ROBOTS })
|
||||
upsertMeta('meta[property="og:type"]', { property: 'og:type', content: 'website' })
|
||||
upsertMeta('meta[property="og:site_name"]', { property: 'og:site_name', content: 'markstream-vue Playground' })
|
||||
upsertMeta('meta[property="og:title"]', { property: 'og:title', content: seo.title })
|
||||
upsertMeta('meta[property="og:description"]', { property: 'og:description', content: seo.description })
|
||||
upsertMeta('meta[property="og:url"]', { property: 'og:url', content: canonicalUrl })
|
||||
upsertMeta('meta[property="og:image"]', { property: 'og:image', content: PLAYGROUND_OG_IMAGE_URL })
|
||||
upsertMeta('meta[property="og:image:alt"]', { property: 'og:image:alt', content: 'markstream-vue playground with live streaming markdown demos' })
|
||||
upsertMeta('meta[name="twitter:card"]', { name: 'twitter:card', content: 'summary_large_image' })
|
||||
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title', content: seo.title })
|
||||
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description', content: seo.description })
|
||||
upsertMeta('meta[name="twitter:image"]', { name: 'twitter:image', content: PLAYGROUND_OG_IMAGE_URL })
|
||||
|
||||
if ((seo.robots || DEFAULT_ROBOTS).startsWith('noindex')) {
|
||||
removeJsonLd('script[data-seo-jsonld="playground"]')
|
||||
return
|
||||
}
|
||||
|
||||
upsertJsonLd('script[data-seo-jsonld="playground"]', createStructuredData(path, canonicalUrl, seo))
|
||||
}
|
||||
|
||||
export function installPlaygroundSeo(router: Router) {
|
||||
router.afterEach((to) => {
|
||||
applyRouteSeo(to)
|
||||
})
|
||||
|
||||
void router.isReady().then(() => {
|
||||
applyRouteSeo(router.currentRoute.value)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@import '../../../themes/index.css';
|
||||
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer components {
|
||||
@import 'markstream-vue/index.css';
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Provide defaults for Tailwind gradient position variables so var() calls resolve */
|
||||
:root {
|
||||
--tw-gradient-from-position: 0%;
|
||||
--tw-gradient-to-position: 100%;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background: #121212;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
html{
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { SandboxFrameworkTarget } from '../../playground-shared/versionSandbox'
|
||||
|
||||
export const TEST_SANDBOX_KATEX_VERSION = '0.16.22'
|
||||
export const TEST_SANDBOX_MERMAID_VERSION = '11'
|
||||
|
||||
export const testSandboxFrameworks: ReadonlyArray<SandboxFrameworkTarget> = [
|
||||
{
|
||||
id: 'vue3',
|
||||
label: 'Vue 3',
|
||||
packageName: 'markstream-vue',
|
||||
defaultVersion: '0.0.9-beta.0',
|
||||
runtimeVersion: '3.5.29',
|
||||
supportsWorkspace: true,
|
||||
},
|
||||
{
|
||||
id: 'vue2',
|
||||
label: 'Vue 2',
|
||||
packageName: 'markstream-vue2',
|
||||
defaultVersion: '0.0.20',
|
||||
runtimeVersion: '2.7.16',
|
||||
supportsWorkspace: false,
|
||||
},
|
||||
{
|
||||
id: 'react18',
|
||||
label: 'React 18',
|
||||
packageName: 'markstream-react',
|
||||
defaultVersion: '0.0.25',
|
||||
runtimeVersion: '18.3.1',
|
||||
supportsWorkspace: false,
|
||||
},
|
||||
{
|
||||
id: 'react19',
|
||||
label: 'React 19',
|
||||
packageName: 'markstream-react',
|
||||
defaultVersion: '0.0.25',
|
||||
runtimeVersion: '19.2.4',
|
||||
supportsWorkspace: false,
|
||||
},
|
||||
{
|
||||
id: 'angular',
|
||||
label: 'Angular',
|
||||
packageName: 'markstream-angular',
|
||||
defaultVersion: '0.0.1-alpha.0',
|
||||
runtimeVersion: '20.3.18',
|
||||
supportsWorkspace: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function getTestSandboxFramework(id: string | null | undefined) {
|
||||
return testSandboxFrameworks.find(item => item.id === id) ?? testSandboxFrameworks[0]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Provide a lightweight module declaration so Playground imports of the
|
||||
// local `src/components/NodeRenderer` (used for dev) get proper types.
|
||||
// This maps the source import to the package's exported types (which are
|
||||
// available at `dist/index.d.ts`). It helps the editor and `vue-tsc` when
|
||||
// the playground imports the component by relative path.
|
||||
declare module '../../../src/components/NodeRenderer' {
|
||||
// Use the package's exported default type as the component type.
|
||||
const comp: import('markstream-vue').default
|
||||
export default comp
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import typography from '@tailwindcss/typography'
|
||||
import markstreamPreset from '../tailwind.preset.js'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
presets: [markstreamPreset],
|
||||
darkMode: ['selector'],
|
||||
safelist: [
|
||||
'border-gray-400/5',
|
||||
'hover:bg-[var(--vscode-editor-selectionBackground)]',
|
||||
],
|
||||
content: [
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'../src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'./node_modules/markstream-vue/dist/tailwind.ts',
|
||||
// 在 dev 环境为了不需要安装依赖,从 上层 dist 目录引入
|
||||
'../dist/tailwind.ts',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
},
|
||||
},
|
||||
plugins: [typography],
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"target": "es2016",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"~/*": ["src/*"],
|
||||
"stream-markdown-parser": ["../packages/markdown-parser/src/index.ts"],
|
||||
"stream-markdown-parser/*": ["../packages/markdown-parser/src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client", "vite-plugin-pages/client"],
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/// <reference types="vitest" />
|
||||
|
||||
import type { PluginOption } from 'vite'
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'
|
||||
import Pages from 'vite-plugin-pages'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const localStreamMonacoRoot = path.resolve(
|
||||
__dirname,
|
||||
'../../stream-monaco',
|
||||
)
|
||||
const localStreamMonacoSource = path.resolve(
|
||||
localStreamMonacoRoot,
|
||||
'src/index.ts',
|
||||
)
|
||||
const localStreamMonacoDist = path.resolve(
|
||||
localStreamMonacoRoot,
|
||||
'dist/index.js',
|
||||
)
|
||||
const preferredVueI18nEntry = path.resolve(
|
||||
path.dirname(require.resolve('vue-i18n/package.json', { paths: [__dirname] })),
|
||||
'dist/vue-i18n.mjs',
|
||||
)
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
const streamMonacoAlias = command === 'serve'
|
||||
? (fs.existsSync(localStreamMonacoSource) ? localStreamMonacoSource : null)
|
||||
: (
|
||||
fs.existsSync(localStreamMonacoDist)
|
||||
? localStreamMonacoDist
|
||||
: (fs.existsSync(localStreamMonacoSource) ? localStreamMonacoSource : null)
|
||||
)
|
||||
|
||||
return {
|
||||
base: './',
|
||||
server: {
|
||||
fs: {
|
||||
allow: [
|
||||
path.resolve(__dirname, '..'),
|
||||
localStreamMonacoRoot,
|
||||
],
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
// Avoid IIFE/UMD for workers; use ESM which supports code-splitting
|
||||
format: 'es',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~/': `${path.resolve(__dirname, 'src')}/`,
|
||||
'markstream-vue/index.css': path.resolve(__dirname, '../src/index.css'),
|
||||
'markstream-vue': path.resolve(__dirname, '../src/exports.ts'),
|
||||
'markstream-angular': path.resolve(__dirname, '../packages/markstream-angular/src/index.ts'),
|
||||
'stream-markdown-parser': path.resolve(__dirname, '../packages/markdown-parser/src/index.ts'),
|
||||
'vue-i18n': preferredVueI18nEntry,
|
||||
...(streamMonacoAlias
|
||||
? { 'stream-monaco': streamMonacoAlias }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Keep workspace-linked parser/runtime packages on source files in dev so
|
||||
// fixes in the monorepo are reflected immediately instead of getting
|
||||
// stuck behind Vite's optimized-deps cache.
|
||||
exclude: [
|
||||
'stream-markdown-parser',
|
||||
...(streamMonacoAlias === localStreamMonacoSource ? ['stream-monaco'] : []),
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
Vue({}),
|
||||
|
||||
// https://github.com/hannoeru/vite-plugin-pages
|
||||
Pages(),
|
||||
|
||||
// https://github.com/antfu/unplugin-auto-import
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router', '@vueuse/core'],
|
||||
dts: true,
|
||||
}),
|
||||
|
||||
// https://github.com/antfu/vite-plugin-components
|
||||
Components({
|
||||
dts: true,
|
||||
}),
|
||||
|
||||
monacoEditorPlugin({
|
||||
languageWorkers: [
|
||||
'editorWorkerService',
|
||||
'typescript',
|
||||
'css',
|
||||
'html',
|
||||
'json',
|
||||
],
|
||||
customDistPath(root, buildOutDir) {
|
||||
return path.resolve(buildOutDir, 'monacoeditorwork')
|
||||
},
|
||||
}) as unknown as PluginOption,
|
||||
],
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user