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,48 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { CustomComponentDisplayMode, CustomComponentMap, MarkstreamCustomComponent } from '../customComponents'
|
||||
import { getHtmlTagFromContent } from 'stream-markdown-parser'
|
||||
import { getCustomComponentDisplay } from '../customComponents'
|
||||
|
||||
export interface ResolvedCustomHtmlTag {
|
||||
tag: string
|
||||
isWhitelisted: boolean
|
||||
component: MarkstreamCustomComponent<any> | null
|
||||
display: CustomComponentDisplayMode | undefined
|
||||
}
|
||||
|
||||
export function resolveCustomHtmlTag(
|
||||
node: Pick<ParsedNode, 'type'> & { tag?: string | null, content?: unknown },
|
||||
customComponents: CustomComponentMap,
|
||||
customHtmlTags?: readonly string[],
|
||||
): ResolvedCustomHtmlTag | null {
|
||||
const normalizedType = String(node.type ?? '').trim().toLowerCase()
|
||||
const normalizedTags = (customHtmlTags ?? []).map(tag => tag.toLowerCase())
|
||||
const taggedNode = normalizedType === 'html_inline' || normalizedType === 'html_block'
|
||||
|
||||
if (!taggedNode && !normalizedTags.includes(normalizedType))
|
||||
return null
|
||||
|
||||
const tag = taggedNode
|
||||
? (String(node.tag ?? '').trim().toLowerCase() || getHtmlTagFromContent(node.content))
|
||||
: (String(node.tag ?? '').trim().toLowerCase() || normalizedType)
|
||||
if (!tag)
|
||||
return null
|
||||
|
||||
const isWhitelisted = normalizedTags.includes(tag)
|
||||
const component = isWhitelisted ? (customComponents[tag] ?? customComponents[normalizedType] ?? null) : null
|
||||
|
||||
return {
|
||||
tag,
|
||||
isWhitelisted,
|
||||
component,
|
||||
display: getCustomComponentDisplay(component),
|
||||
}
|
||||
}
|
||||
|
||||
export function isParagraphBreakingCustomHtmlNode(
|
||||
node: Pick<ParsedNode, 'type'> & { tag?: string | null, content?: unknown },
|
||||
customComponents: CustomComponentMap,
|
||||
customHtmlTags?: readonly string[],
|
||||
) {
|
||||
return resolveCustomHtmlTag(node, customComponents, customHtmlTags)?.display === 'block'
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
BLOCKED_HTML_TAGS as BLOCKED_TAGS,
|
||||
hasCustomHtmlComponents as hasCustomHtmlComponentsBase,
|
||||
isCustomHtmlComponentTag,
|
||||
sanitizeHtmlAttrs as sanitizeHtmlAttrsBase,
|
||||
tokenizeHtml as tokenizeHtmlBase,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
export type { HtmlToken } from 'stream-markdown-parser'
|
||||
|
||||
function normalizeCssPropName(prop: string) {
|
||||
if (prop.startsWith('--'))
|
||||
return prop
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function parseInlineStyle(style: string): Record<string, string> | undefined {
|
||||
const input = style.trim()
|
||||
if (!input)
|
||||
return undefined
|
||||
|
||||
const out: Record<string, string> = {}
|
||||
for (const part of input.split(';')) {
|
||||
const chunk = part.trim()
|
||||
if (!chunk)
|
||||
continue
|
||||
const idx = chunk.indexOf(':')
|
||||
if (idx === -1)
|
||||
continue
|
||||
const key = normalizeCssPropName(chunk.slice(0, idx).trim())
|
||||
const value = chunk.slice(idx + 1).trim()
|
||||
if (key)
|
||||
out[key] = value
|
||||
}
|
||||
|
||||
return Object.keys(out).length ? out : undefined
|
||||
}
|
||||
|
||||
export function normalizeDomAttrs(attrs: Record<string, string>) {
|
||||
const next: Record<string, any> = { ...attrs }
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'class')) {
|
||||
next.className = next.class
|
||||
delete next.class
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'for')) {
|
||||
next.htmlFor = next.for
|
||||
delete next.for
|
||||
}
|
||||
if (typeof next.style === 'string') {
|
||||
const parsed = parseInlineStyle(next.style)
|
||||
if (parsed)
|
||||
next.style = parsed
|
||||
else
|
||||
delete next.style
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export const tokenizeHtml = tokenizeHtmlBase
|
||||
export const sanitizeHtmlAttrs = sanitizeHtmlAttrsBase
|
||||
|
||||
export function isCustomHtmlComponent(
|
||||
tagName: string,
|
||||
customComponents: Record<string, ComponentType<any>>,
|
||||
) {
|
||||
return isCustomHtmlComponentTag(tagName, customComponents as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function hasCustomHtmlComponents(
|
||||
content: string,
|
||||
customComponents: Record<string, ComponentType<any>>,
|
||||
) {
|
||||
return hasCustomHtmlComponentsBase(content, customComponents as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function parseHtmlToReactNodes(
|
||||
content: string,
|
||||
customComponents: Record<string, ComponentType<any>>,
|
||||
): ReactNode[] | null {
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
try {
|
||||
const tokens = tokenizeHtml(content)
|
||||
let autoKeySeed = 0
|
||||
const stack: Array<{ tagName: string, children: ReactNode[], attrs?: Record<string, string>, blocked?: boolean }> = []
|
||||
const rootNodes: ReactNode[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'text') {
|
||||
if (stack.length > 0 && stack[stack.length - 1].blocked)
|
||||
continue
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
target.push(token.content ?? '')
|
||||
continue
|
||||
}
|
||||
|
||||
if (token.type === 'self_closing') {
|
||||
if (BLOCKED_TAGS.has(token.tagName.toLowerCase()))
|
||||
continue
|
||||
const attrs = sanitizeHtmlAttrs(token.attrs || {})
|
||||
const explicitKey = (attrs as any).key
|
||||
const elementKey = explicitKey != null && explicitKey !== '' ? explicitKey : `ms-html-${autoKeySeed++}`
|
||||
const Comp = isCustomHtmlComponent(token.tagName, customComponents)
|
||||
? (customComponents[token.tagName] || customComponents[token.tagName.toLowerCase()])
|
||||
: undefined
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
if (Comp) {
|
||||
target.push(React.createElement(Comp, { ...attrs, key: elementKey }))
|
||||
}
|
||||
else {
|
||||
target.push(React.createElement(token.tagName, {
|
||||
...normalizeDomAttrs(attrs),
|
||||
key: elementKey,
|
||||
suppressHydrationWarning: true,
|
||||
}))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (token.type === 'tag_open') {
|
||||
stack.push({
|
||||
tagName: token.tagName,
|
||||
children: [],
|
||||
attrs: token.attrs,
|
||||
blocked: BLOCKED_TAGS.has(token.tagName.toLowerCase()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const opening = stack.pop()
|
||||
if (!opening || opening.blocked)
|
||||
continue
|
||||
|
||||
const attrs = sanitizeHtmlAttrs(opening.attrs || {})
|
||||
const explicitKey = (attrs as any).key
|
||||
const elementKey = explicitKey != null && explicitKey !== '' ? explicitKey : `ms-html-${autoKeySeed++}`
|
||||
const Comp = isCustomHtmlComponent(opening.tagName, customComponents)
|
||||
? (customComponents[opening.tagName] || customComponents[opening.tagName.toLowerCase()])
|
||||
: undefined
|
||||
const element = Comp
|
||||
? React.createElement(Comp, { ...attrs, key: elementKey }, ...opening.children)
|
||||
: React.createElement(opening.tagName, {
|
||||
...normalizeDomAttrs(attrs),
|
||||
key: elementKey,
|
||||
suppressHydrationWarning: true,
|
||||
}, ...opening.children)
|
||||
|
||||
if (stack.length > 0)
|
||||
stack[stack.length - 1].children.push(element)
|
||||
else
|
||||
rootNodes.push(element)
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const unclosed = stack.pop()
|
||||
if (!unclosed || unclosed.blocked)
|
||||
continue
|
||||
const attrs = sanitizeHtmlAttrs(unclosed.attrs || {})
|
||||
const explicitKey = (attrs as any).key
|
||||
const elementKey = explicitKey != null && explicitKey !== '' ? explicitKey : `ms-html-${autoKeySeed++}`
|
||||
const Comp = isCustomHtmlComponent(unclosed.tagName, customComponents)
|
||||
? (customComponents[unclosed.tagName] || customComponents[unclosed.tagName.toLowerCase()])
|
||||
: undefined
|
||||
const element = Comp
|
||||
? React.createElement(Comp, { ...attrs, key: elementKey }, ...unclosed.children)
|
||||
: React.createElement(unclosed.tagName, {
|
||||
...normalizeDomAttrs(attrs),
|
||||
key: elementKey,
|
||||
suppressHydrationWarning: true,
|
||||
}, ...unclosed.children)
|
||||
rootNodes.push(element)
|
||||
}
|
||||
|
||||
return rootNodes
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import CssIcon from '../icon/css.svg?raw'
|
||||
import HtmlIcon from '../icon/html.svg?raw'
|
||||
import JsxReactIcon from '../icon/javascript-react.svg?raw'
|
||||
import JsIcon from '../icon/javascript.svg?raw'
|
||||
import JsonIcon from '../icon/json.svg?raw'
|
||||
import MarkdownIcon from '../icon/markdown.svg?raw'
|
||||
import MermaidIcon from '../icon/mermaid.svg?raw'
|
||||
import PythonIcon from '../icon/python.svg?raw'
|
||||
import ShellIcon from '../icon/shell.svg?raw'
|
||||
import SquareCodeIcon from '../icon/square-code.svg?raw'
|
||||
import TextIcon from '../icon/text.svg?raw'
|
||||
import TsReactIcon from '../icon/typescript-react.svg?raw'
|
||||
import TsIcon from '../icon/typescript.svg?raw'
|
||||
|
||||
export type LanguageIconResolver = (lang: string) => string | undefined | null
|
||||
|
||||
type LanguageIconMap = Record<string, string>
|
||||
|
||||
let userLanguageIconResolver: LanguageIconResolver | null = null
|
||||
let extendedLanguageIconMap: LanguageIconMap | null = null
|
||||
let extendedLanguageIconPromise: Promise<LanguageIconMap | null> | null = null
|
||||
const revisionListeners = new Set<() => void>()
|
||||
|
||||
const DEFAULT_LANGUAGE_ICON = SquareCodeIcon
|
||||
|
||||
export function setLanguageIconResolver(resolver?: LanguageIconResolver | null) {
|
||||
userLanguageIconResolver = resolver ?? null
|
||||
}
|
||||
|
||||
const LANGUAGE_ALIAS_MAP: Record<string, string> = {
|
||||
'': '',
|
||||
'javascript': 'javascript',
|
||||
'js': 'javascript',
|
||||
'mjs': 'javascript',
|
||||
'cjs': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'ts': 'typescript',
|
||||
'jsx': 'jsx',
|
||||
'tsx': 'tsx',
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'sh': 'shell',
|
||||
'bash': 'shell',
|
||||
'zsh': 'shell',
|
||||
'shellscript': 'shell',
|
||||
'd2lang': 'd2',
|
||||
'plaintext': 'plain',
|
||||
'text': 'plain',
|
||||
'c++': 'cpp',
|
||||
'md': 'markdown',
|
||||
}
|
||||
|
||||
const CORE_LANGUAGE_ICON_MAP: LanguageIconMap = {
|
||||
'': TextIcon,
|
||||
'plain': TextIcon,
|
||||
'text': TextIcon,
|
||||
'javascript': JsIcon,
|
||||
'typescript': TsIcon,
|
||||
'jsx': JsxReactIcon,
|
||||
'tsx': TsReactIcon,
|
||||
'html': HtmlIcon,
|
||||
'css': CssIcon,
|
||||
'json': JsonIcon,
|
||||
'python': PythonIcon,
|
||||
'shell': ShellIcon,
|
||||
'markdown': MarkdownIcon,
|
||||
'mermaid': MermaidIcon,
|
||||
}
|
||||
|
||||
function extractLanguageToken(lang?: string | null): string {
|
||||
if (!lang)
|
||||
return ''
|
||||
const trimmed = lang.trim()
|
||||
if (!trimmed)
|
||||
return ''
|
||||
const [firstToken] = trimmed.split(/\s+/)
|
||||
const [base] = firstToken.split(':')
|
||||
return base.toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizeLanguageIdentifier(lang?: string | null): string {
|
||||
const token = extractLanguageToken(lang)
|
||||
return LANGUAGE_ALIAS_MAP[token] ?? token
|
||||
}
|
||||
|
||||
export function resolveMonacoLanguageId(lang?: string | null): string {
|
||||
const canonical = normalizeLanguageIdentifier(lang)
|
||||
if (!canonical)
|
||||
return 'plaintext'
|
||||
switch (canonical) {
|
||||
case 'plain':
|
||||
return 'plaintext'
|
||||
case 'jsx':
|
||||
return 'javascript'
|
||||
case 'tsx':
|
||||
return 'typescript'
|
||||
default:
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
function emitLanguageIconsRevision() {
|
||||
for (const listener of revisionListeners) {
|
||||
try {
|
||||
listener()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExtendedLanguageIconMap(): Promise<LanguageIconMap | null> {
|
||||
if (extendedLanguageIconMap)
|
||||
return extendedLanguageIconMap
|
||||
|
||||
if (!extendedLanguageIconPromise) {
|
||||
extendedLanguageIconPromise = import('./languageIconExtended')
|
||||
.then((mod) => {
|
||||
extendedLanguageIconMap = mod.EXTENDED_LANGUAGE_ICON_MAP
|
||||
emitLanguageIconsRevision()
|
||||
return extendedLanguageIconMap
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
return extendedLanguageIconPromise
|
||||
}
|
||||
|
||||
export async function preloadExtendedLanguageIcons() {
|
||||
await loadExtendedLanguageIconMap()
|
||||
}
|
||||
|
||||
export function subscribeLanguageIconsRevision(listener: () => void): () => void {
|
||||
revisionListeners.add(listener)
|
||||
return () => {
|
||||
revisionListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function getLanguageIcon(lang: string): string {
|
||||
if (userLanguageIconResolver) {
|
||||
const hit = userLanguageIconResolver(lang)
|
||||
if (hit != null && hit !== '')
|
||||
return hit
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageIdentifier(lang)
|
||||
const coreIcon = CORE_LANGUAGE_ICON_MAP[normalized]
|
||||
if (coreIcon)
|
||||
return coreIcon
|
||||
|
||||
const extendedIcon = extendedLanguageIconMap?.[normalized]
|
||||
if (extendedIcon)
|
||||
return extendedIcon
|
||||
|
||||
void loadExtendedLanguageIconMap()
|
||||
return DEFAULT_LANGUAGE_ICON
|
||||
}
|
||||
|
||||
export const languageMap: Record<string, string> = {
|
||||
javascript: 'JavaScript',
|
||||
typescript: 'TypeScript',
|
||||
jsx: 'JSX',
|
||||
tsx: 'TSX',
|
||||
shell: 'Shell',
|
||||
plain: 'Text',
|
||||
text: 'Text',
|
||||
markdown: 'Markdown',
|
||||
json: 'JSON',
|
||||
python: 'Python',
|
||||
cpp: 'C++',
|
||||
vue: 'Vue',
|
||||
html: 'HTML',
|
||||
css: 'CSS',
|
||||
svg: 'SVG',
|
||||
mermaid: 'Mermaid',
|
||||
d2: 'D2',
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import CppIcon from '../icon/cpp.svg?raw'
|
||||
import SvgIcon from '../icon/svg.svg?raw'
|
||||
import VueIcon from '../icon/vue.svg?raw'
|
||||
|
||||
export const EXTENDED_LANGUAGE_ICON_MAP: Record<string, string> = {
|
||||
cpp: CppIcon,
|
||||
svg: SvgIcon,
|
||||
vue: VueIcon,
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export function normalizeKaTeXRenderInput(content: string) {
|
||||
if (!content)
|
||||
return ''
|
||||
|
||||
return content
|
||||
.replace(/·/g, '⋅')
|
||||
.replace(/℃/g, '°C')
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Lightweight performance monitoring helper for KaTeX rendering.
|
||||
* Only used during development to understand Worker/cache benefits.
|
||||
*/
|
||||
interface RenderMetrics {
|
||||
type: 'worker' | 'direct' | 'cache-hit'
|
||||
duration: number
|
||||
formulaLength: number
|
||||
timestamp: number
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
class PerformanceMonitor {
|
||||
private metrics: RenderMetrics[] = []
|
||||
private enabled = false
|
||||
private maxMetrics = 1000
|
||||
|
||||
enable() {
|
||||
this.enabled = true
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false
|
||||
}
|
||||
|
||||
recordRender(metrics: RenderMetrics) {
|
||||
if (!this.enabled)
|
||||
return
|
||||
this.metrics.push(metrics)
|
||||
if (this.metrics.length > this.maxMetrics)
|
||||
this.metrics.shift()
|
||||
}
|
||||
|
||||
private averages(kind: RenderMetrics['type']) {
|
||||
const rows = this.metrics.filter(m => m.type === kind && m.success)
|
||||
if (!rows.length)
|
||||
return 0
|
||||
return rows.reduce((sum, cur) => sum + cur.duration, 0) / rows.length
|
||||
}
|
||||
|
||||
getStats() {
|
||||
if (!this.metrics.length) {
|
||||
return {
|
||||
totalRenders: 0,
|
||||
cacheHits: 0,
|
||||
cacheHitRate: '0.0',
|
||||
workerCalls: 0,
|
||||
directCalls: 0,
|
||||
averageWorkerTime: '0.00',
|
||||
averageDirectTime: '0.00',
|
||||
averageCacheHitTime: '0.000',
|
||||
workerSavings: '0.00',
|
||||
recommendation: 'Insufficient data',
|
||||
}
|
||||
}
|
||||
const workerAvg = this.averages('worker')
|
||||
const directAvg = this.averages('direct')
|
||||
const cacheHits = this.metrics.filter(m => m.type === 'cache-hit')
|
||||
const cacheAvg = cacheHits.length
|
||||
? cacheHits.reduce((sum, cur) => sum + cur.duration, 0) / cacheHits.length
|
||||
: 0
|
||||
const totalRenders = this.metrics.length
|
||||
const estimatedDirect = totalRenders * directAvg
|
||||
const actualTime = (
|
||||
this.metrics.reduce((sum, cur) => sum + cur.duration, 0)
|
||||
)
|
||||
const workerSavings = estimatedDirect - actualTime
|
||||
const cacheHitRate = cacheHits.length / totalRenders * 100
|
||||
|
||||
let recommendation = '✅ Worker prevents main thread blocking'
|
||||
if (cacheHitRate > 70 && workerAvg < directAvg * 2)
|
||||
recommendation = '✅ Worker + cache is highly beneficial'
|
||||
else if (cacheHitRate > 50)
|
||||
recommendation = '✅ Worker + cache is beneficial'
|
||||
else if (workerAvg > directAvg * 3 && directAvg > 0)
|
||||
recommendation = '⚠️ Worker overhead too high; consider direct rendering'
|
||||
else if (directAvg > 0 && directAvg < 5)
|
||||
recommendation = '⚠️ Formulas are trivial; worker may be unnecessary'
|
||||
|
||||
return {
|
||||
totalRenders,
|
||||
cacheHits: cacheHits.length,
|
||||
cacheHitRate: cacheHitRate.toFixed(1),
|
||||
workerCalls: this.metrics.filter(m => m.type === 'worker').length,
|
||||
directCalls: this.metrics.filter(m => m.type === 'direct').length,
|
||||
averageWorkerTime: workerAvg.toFixed(2),
|
||||
averageDirectTime: directAvg.toFixed(2),
|
||||
averageCacheHitTime: cacheAvg.toFixed(3),
|
||||
workerSavings: workerSavings.toFixed(2),
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
printReport() {
|
||||
const stats = this.getStats()
|
||||
console.group('KaTeX Performance Report')
|
||||
console.log(`Total renders: ${stats.totalRenders}`)
|
||||
console.log(`Cache hits: ${stats.cacheHits} (${stats.cacheHitRate}%)`)
|
||||
console.log(`Worker average: ${stats.averageWorkerTime}ms`)
|
||||
console.log(`Direct average: ${stats.averageDirectTime}ms`)
|
||||
console.log(`Cache hit average: ${stats.averageCacheHitTime}ms`)
|
||||
console.log(`Estimated savings: ${stats.workerSavings}ms`)
|
||||
console.log(`Recommendation: ${stats.recommendation}`)
|
||||
console.groupEnd()
|
||||
return stats
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metrics = []
|
||||
}
|
||||
|
||||
exportMetrics() {
|
||||
return {
|
||||
metrics: [...this.metrics],
|
||||
stats: this.getStats(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const perfMonitor = new PerformanceMonitor()
|
||||
|
||||
export function enablePerfMonitoring() {
|
||||
perfMonitor.enable()
|
||||
}
|
||||
|
||||
export function disablePerfMonitoring() {
|
||||
perfMonitor.disable()
|
||||
}
|
||||
|
||||
export function getPerfReport() {
|
||||
return perfMonitor.printReport()
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__katexPerfMonitor = perfMonitor
|
||||
;(window as any).__katexPerfReport = getPerfReport
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function safeRaf(cb: FrameRequestCallback) {
|
||||
try {
|
||||
if (typeof globalThis !== 'undefined' && typeof (globalThis as any).requestAnimationFrame === 'function')
|
||||
return (globalThis as any).requestAnimationFrame(cb)
|
||||
}
|
||||
catch {}
|
||||
return (globalThis as any).setTimeout(cb as any, 0) as unknown as number
|
||||
}
|
||||
|
||||
export function safeCancelRaf(id: number | null | undefined) {
|
||||
if (id == null)
|
||||
return
|
||||
try {
|
||||
if (typeof globalThis !== 'undefined' && typeof (globalThis as any).cancelAnimationFrame === 'function') {
|
||||
(globalThis as any).cancelAnimationFrame(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
;(globalThis as any).clearTimeout(id)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
export interface ResolveStreamingTextStateOptions {
|
||||
nextContent: string
|
||||
previousContent: string
|
||||
typewriterEnabled: boolean
|
||||
}
|
||||
|
||||
export interface StreamingTextStateResult {
|
||||
settledContent: string
|
||||
streamedDelta: string
|
||||
appended: boolean
|
||||
}
|
||||
|
||||
export interface StreamingRenderState {
|
||||
settledContent: string
|
||||
streamedDelta: string
|
||||
}
|
||||
|
||||
export interface ResolveStreamingTextUpdateOptions {
|
||||
nextContent: string
|
||||
persistedContent?: string
|
||||
currentState: StreamingRenderState
|
||||
typewriterEnabled: boolean
|
||||
streamRenderVersionChanged?: boolean
|
||||
}
|
||||
|
||||
export function resolveStreamingTextState({
|
||||
nextContent,
|
||||
previousContent,
|
||||
typewriterEnabled,
|
||||
}: ResolveStreamingTextStateOptions): StreamingTextStateResult {
|
||||
if (!typewriterEnabled) {
|
||||
return {
|
||||
settledContent: nextContent,
|
||||
streamedDelta: '',
|
||||
appended: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (nextContent === previousContent) {
|
||||
return {
|
||||
settledContent: nextContent,
|
||||
streamedDelta: '',
|
||||
appended: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (previousContent && nextContent.startsWith(previousContent) && nextContent.length > previousContent.length) {
|
||||
return {
|
||||
settledContent: previousContent,
|
||||
streamedDelta: nextContent.slice(previousContent.length),
|
||||
appended: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settledContent: nextContent,
|
||||
streamedDelta: '',
|
||||
appended: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStreamingTextUpdate({
|
||||
nextContent,
|
||||
persistedContent,
|
||||
currentState,
|
||||
typewriterEnabled,
|
||||
streamRenderVersionChanged = false,
|
||||
}: ResolveStreamingTextUpdateOptions): StreamingTextStateResult {
|
||||
const renderedContent = `${currentState.settledContent}${currentState.streamedDelta}`
|
||||
|
||||
if (!typewriterEnabled) {
|
||||
return {
|
||||
settledContent: nextContent,
|
||||
streamedDelta: '',
|
||||
appended: false,
|
||||
}
|
||||
}
|
||||
|
||||
// React StrictMode may replay effects with the same props while the delta
|
||||
// animation is still active. Preserve the current delta instead of settling
|
||||
// it immediately so the fade remains visible in dev and playgrounds.
|
||||
if (currentState.streamedDelta && renderedContent === nextContent) {
|
||||
if (streamRenderVersionChanged) {
|
||||
return {
|
||||
settledContent: renderedContent,
|
||||
streamedDelta: '',
|
||||
appended: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
settledContent: currentState.settledContent,
|
||||
streamedDelta: currentState.streamedDelta,
|
||||
appended: false,
|
||||
}
|
||||
}
|
||||
|
||||
return resolveStreamingTextState({
|
||||
nextContent,
|
||||
previousContent: persistedContent ?? renderedContent,
|
||||
typewriterEnabled,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user