dsh-smooth-stream 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +1757 -282
- package/lib/client.js.map +1 -1
- package/lib/index.js +299 -3
- package/package.json +1 -1
- package/src/client/AgentRowEntrance.module.css +1 -4
- package/src/client/AnimatedDisclosure.tsx +3 -3
- package/src/client/DebugPanel.tsx +34 -15
- package/src/client/FrameCoordinator.ts +180 -0
- package/src/client/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.tsx +41 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +232 -104
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +153 -11
- package/src/client/locales.ts +90 -0
- package/src/client/smooth-stream-card-controller.ts +10 -3
- package/src/client/smooth-stream-settings-api.ts +7 -3
- package/src/client/teleprompterGlide.ts +499 -70
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +672 -0
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +99 -12
- package/src/settings-api.ts +4 -0
- package/src/settings-channel.ts +303 -0
- package/src/settings.ts +14 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import { FrameCoordinator } from './FrameCoordinator.ts'
|
|
3
|
+
import css from './LogarithmicFade.module.css'
|
|
4
|
+
|
|
5
|
+
export const FADE_DURATION_MS = 240
|
|
6
|
+
export const FADE_TAIL_SIZE = 24
|
|
7
|
+
export const FADE_MAX_TAIL_SIZE = 160
|
|
8
|
+
export const FADE_MIN_OPACITY = 0
|
|
9
|
+
const FADE_STEPS = 32
|
|
10
|
+
const PREFIX = 'dsh-smooth-stream-log-fade-'
|
|
11
|
+
const COLOR_PROPERTY = '--dsh-smooth-stream-fade-color'
|
|
12
|
+
// Lightning CSS scopes highlight identifiers as well as class names.
|
|
13
|
+
const highlightName = (index: number): string => css[`${PREFIX}${index}`] ?? `${PREFIX}${index}`
|
|
14
|
+
const EXCLUDED = 'pre,code,math,.katex,.katex-display,mjx-container,svg,script,style,textarea,input,button,select,[role="button"],[contenteditable],[hidden],[aria-hidden="true"],[aria-live]'
|
|
15
|
+
// Attributes that can start or stop an EXCLUDED match mid-stream (`hidden` and
|
|
16
|
+
// `aria-hidden` flip whole subtrees, `class` carries the math renderers,
|
|
17
|
+
// `role`/`contenteditable` match their own selectors). `style` is deliberately
|
|
18
|
+
// absent: every colour write below is a style property, so observing it would
|
|
19
|
+
// make the fade re-enter its own observer.
|
|
20
|
+
const EXCLUDED_ATTRIBUTES = ['hidden', 'aria-hidden', 'role', 'contenteditable', 'class']
|
|
21
|
+
// Attributes on <html>/<body> that can move the resolved `currentColor`, i.e.
|
|
22
|
+
// an app theme switch that is not delivered through the media query.
|
|
23
|
+
const APPEARANCE_ATTRIBUTES = ['class', 'style', 'data-theme', 'data-color-scheme', 'data-appearance']
|
|
24
|
+
|
|
25
|
+
export function logarithmicOpacity(progress: number): number {
|
|
26
|
+
const p = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 1
|
|
27
|
+
// Reverse the log easing: preserve translucency early, then settle to ink.
|
|
28
|
+
return FADE_MIN_OPACITY + (1 - FADE_MIN_OPACITY) * (1 - Math.log1p(5 * (1 - p)) / Math.log(6))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fadeTailSize(speedCps: number): number {
|
|
32
|
+
const speed = Number.isFinite(speedCps) ? Math.max(0, speedCps) : 0
|
|
33
|
+
return Math.min(FADE_MAX_TAIL_SIZE, Math.max(FADE_TAIL_SIZE, Math.ceil(speed * FADE_DURATION_MS / 1000)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface FadeCharacter {
|
|
37
|
+
start: number
|
|
38
|
+
end: number
|
|
39
|
+
born: number
|
|
40
|
+
range: Range
|
|
41
|
+
bucket: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One text node of the fade root with its span in the concatenated source text. */
|
|
45
|
+
interface TextNodeEntry {
|
|
46
|
+
node: Text
|
|
47
|
+
start: number
|
|
48
|
+
end: number
|
|
49
|
+
eligible: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PreservedColor {
|
|
53
|
+
value: string
|
|
54
|
+
priority: string
|
|
55
|
+
/** Reconcile pass that last wanted this element, so stale entries can be pruned. */
|
|
56
|
+
generation: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Scheduler {
|
|
60
|
+
highlights: Highlight[]
|
|
61
|
+
clients: Set<LogarithmicFadeController>
|
|
62
|
+
pending: Set<LogarithmicFadeController>
|
|
63
|
+
/** Task handle on the shared frame clock; non-null while a client is live. */
|
|
64
|
+
taskId: string | null
|
|
65
|
+
registry: HighlightRegistry
|
|
66
|
+
window: Window
|
|
67
|
+
coordinator: FrameCoordinator
|
|
68
|
+
/** Detaches the document-level appearance watcher when the last client goes. */
|
|
69
|
+
unwatchAppearance: () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const schedulers = new WeakMap<Document, Scheduler>()
|
|
73
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hot-path counters for the streaming benchmark. Increments only — this is the
|
|
77
|
+
* evidence that the per-frame pass count collapsed, not a control input.
|
|
78
|
+
*/
|
|
79
|
+
export const fadeHotPathStats = {
|
|
80
|
+
/** Observer batches that reached reconcile(). */
|
|
81
|
+
reconcileCalls: 0,
|
|
82
|
+
/** Reconciles that did real work (the rest are dirty-check no-ops). */
|
|
83
|
+
reconcilePasses: 0,
|
|
84
|
+
/** Full text-node table rebuilds (structural DOM change). */
|
|
85
|
+
nodeScans: 0,
|
|
86
|
+
/** Forced style resolutions for newly faded elements. */
|
|
87
|
+
styleReads: 0,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resetFadeHotPathStats(): void {
|
|
91
|
+
fadeHotPathStats.reconcileCalls = 0
|
|
92
|
+
fadeHotPathStats.reconcilePasses = 0
|
|
93
|
+
fadeHotPathStats.nodeScans = 0
|
|
94
|
+
fadeHotPathStats.styleReads = 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function schedule(scheduler: Scheduler): void {
|
|
98
|
+
if (scheduler.taskId !== null || scheduler.pending.size === 0) return
|
|
99
|
+
// One fade task per document on the shared clock, so the fade's bucket
|
|
100
|
+
// bookkeeping shares the reveal/follow frame instead of racing it.
|
|
101
|
+
scheduler.taskId = scheduler.coordinator.registerTask({
|
|
102
|
+
onSimulate: (_dtMs, now) => {
|
|
103
|
+
for (const client of scheduler.pending) {
|
|
104
|
+
if (!client.paint(now)) scheduler.pending.delete(client)
|
|
105
|
+
}
|
|
106
|
+
if (scheduler.pending.size === 0) {
|
|
107
|
+
scheduler.taskId = null
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
return true
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether an attribute record actually moved the attribute's value. jsdom (and
|
|
117
|
+
* some engines) report a write even when the serialized value is unchanged, and
|
|
118
|
+
* this controller writes the scope class on every enable/disable transition —
|
|
119
|
+
* counting a no-op record as a DOM change would re-enter the observer forever.
|
|
120
|
+
* Requires `attributeOldValue` on the observing call.
|
|
121
|
+
* @param record - attribute mutation record from either observer.
|
|
122
|
+
* @returns true only when the value differs from the recorded old value.
|
|
123
|
+
*/
|
|
124
|
+
function attributeChanged(record: MutationRecord): boolean {
|
|
125
|
+
const attribute = record.attributeName
|
|
126
|
+
if (attribute === null) return true
|
|
127
|
+
return record.oldValue !== (record.target as Element).getAttribute(attribute)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One appearance watcher per document. A theme switch arrives either as an OS
|
|
132
|
+
* media-query flip (the app's default `preference: system`) or as an attribute
|
|
133
|
+
* write on <html>/<body>, so both are observed; the fade only ever writes
|
|
134
|
+
* custom properties on its own root, never on those elements, so this watcher
|
|
135
|
+
* cannot observe the controller's own output.
|
|
136
|
+
* @param scheduler - document-scoped registry notified when the look changes.
|
|
137
|
+
* @returns teardown for the last client's dispose.
|
|
138
|
+
*/
|
|
139
|
+
function watchAppearance(scheduler: Scheduler): () => void {
|
|
140
|
+
// The scheduler already resolved the root's realm; re-state it so the DOM
|
|
141
|
+
// constructors resolve off that window rather than the ambient global.
|
|
142
|
+
const win = scheduler.window as Window & typeof globalThis
|
|
143
|
+
const doc = win.document
|
|
144
|
+
const invalidate = (): void => {
|
|
145
|
+
for (const client of scheduler.clients) client.invalidateColors()
|
|
146
|
+
}
|
|
147
|
+
const observer = new win.MutationObserver((records) => {
|
|
148
|
+
if (records.some(attributeChanged)) invalidate()
|
|
149
|
+
})
|
|
150
|
+
const watched = doc.body === null ? [doc.documentElement] : [doc.documentElement, doc.body]
|
|
151
|
+
for (const element of watched) {
|
|
152
|
+
observer.observe(element, {
|
|
153
|
+
attributes: true,
|
|
154
|
+
attributeFilter: APPEARANCE_ATTRIBUTES,
|
|
155
|
+
attributeOldValue: true,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const query = typeof win.matchMedia === 'function'
|
|
159
|
+
? win.matchMedia('(prefers-color-scheme: dark)')
|
|
160
|
+
: null
|
|
161
|
+
if (query !== null) query.addEventListener('change', invalidate)
|
|
162
|
+
return () => {
|
|
163
|
+
observer.disconnect()
|
|
164
|
+
query?.removeEventListener('change', invalidate)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function schedulerFor(root: HTMLElement): Scheduler | null {
|
|
169
|
+
const doc = root.ownerDocument
|
|
170
|
+
const win = doc.defaultView
|
|
171
|
+
if (win === null) return null
|
|
172
|
+
// Use the root's realm, including when a renderer lives in another document.
|
|
173
|
+
const realm = win as Window & typeof globalThis
|
|
174
|
+
if (typeof realm.Highlight !== 'function' || !realm.CSS?.highlights
|
|
175
|
+
|| !realm.CSS.supports('color', 'color-mix(in srgb, currentColor 15%, transparent)')) return null
|
|
176
|
+
let scheduler = schedulers.get(doc)
|
|
177
|
+
if (scheduler === undefined) {
|
|
178
|
+
const highlights = Array.from({ length: FADE_STEPS }, () => new realm.Highlight())
|
|
179
|
+
for (const [index, highlight] of highlights.entries()) realm.CSS.highlights.set(highlightName(index), highlight)
|
|
180
|
+
scheduler = {
|
|
181
|
+
highlights,
|
|
182
|
+
clients: new Set(),
|
|
183
|
+
pending: new Set(),
|
|
184
|
+
taskId: null,
|
|
185
|
+
registry: realm.CSS.highlights,
|
|
186
|
+
window: win,
|
|
187
|
+
coordinator: FrameCoordinator.forDocument(doc),
|
|
188
|
+
unwatchAppearance: () => {},
|
|
189
|
+
}
|
|
190
|
+
scheduler.unwatchAppearance = watchAppearance(scheduler)
|
|
191
|
+
schedulers.set(doc, scheduler)
|
|
192
|
+
}
|
|
193
|
+
return scheduler
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Owns ranges only: React retains ownership of every element and Text node.
|
|
198
|
+
*
|
|
199
|
+
* The controller used to re-derive its whole view of the subtree (textContent,
|
|
200
|
+
* prefix walk, full TreeWalker, an O(tail x #nodes) filter) on *every* commit
|
|
201
|
+
* AND on every observer batch for that same commit. It now keeps an
|
|
202
|
+
* incremental text-node table, reconciles at most once per observed DOM
|
|
203
|
+
* change, and preserves the per-element colour memo across passes.
|
|
204
|
+
*/
|
|
205
|
+
export class LogarithmicFadeController {
|
|
206
|
+
private previous = ''
|
|
207
|
+
private characters: FadeCharacter[] = []
|
|
208
|
+
private colors = new Map<HTMLElement, PreservedColor>()
|
|
209
|
+
private enabled = false
|
|
210
|
+
private active = false
|
|
211
|
+
private speedCps = 100
|
|
212
|
+
private pausedAt: number | null = null
|
|
213
|
+
private disposed = false
|
|
214
|
+
private readonly observer: MutationObserver
|
|
215
|
+
/** Text nodes of the root with their source offsets; survives data-only edits. */
|
|
216
|
+
private nodes: TextNodeEntry[] = []
|
|
217
|
+
/** Ineligible nodes in nodes[0..index): a span is fadeable when its bounds match. */
|
|
218
|
+
private ineligiblePrefix: number[] = [0]
|
|
219
|
+
/**
|
|
220
|
+
* `closest(EXCLUDED)` derived once per element instead of once per Text node:
|
|
221
|
+
* exclusion is inherited from the ancestor chain, so a memo stays usable only
|
|
222
|
+
* while the element keeps its own parent. Moving a subtree into `code` — or
|
|
223
|
+
* into anything else that matches — invalidates exactly the moved chain the
|
|
224
|
+
* next time it is read.
|
|
225
|
+
*/
|
|
226
|
+
private excludedElements = new WeakMap<Element, { parent: Element | null, excluded: boolean }>()
|
|
227
|
+
/** Own tag/attribute match per element; dropped when an exclusion attribute moves. */
|
|
228
|
+
private ownExcluded = new WeakMap<Element, boolean>()
|
|
229
|
+
/** Bumped by every observed mutation; reconcile() no-ops while it does not move. */
|
|
230
|
+
private domRevision = 0
|
|
231
|
+
/** Bumped only by structural (childList) mutations; the node table is rebuilt then. */
|
|
232
|
+
private structureRevision = 0
|
|
233
|
+
private scannedStructureRevision = -1
|
|
234
|
+
private reconciledRevision = -1
|
|
235
|
+
private reconciledEnabled = false
|
|
236
|
+
private reconciledActive = false
|
|
237
|
+
private reconciledPaused = false
|
|
238
|
+
private reconciledTailSize = 0
|
|
239
|
+
private colorGeneration = 0
|
|
240
|
+
|
|
241
|
+
private constructor(private readonly root: HTMLElement, private readonly scheduler: Scheduler) {
|
|
242
|
+
scheduler.clients.add(this)
|
|
243
|
+
const win = root.ownerDocument.defaultView as Window & typeof globalThis
|
|
244
|
+
// The observer is the single invalidation source. It watches structure,
|
|
245
|
+
// text and only the exclusion-relevant attributes; every colour write below
|
|
246
|
+
// is a `style` property, which stays unobserved, so it cannot re-enter.
|
|
247
|
+
this.observer = new win.MutationObserver((records) => { this.onDomMutation(records) })
|
|
248
|
+
this.observer.observe(root, {
|
|
249
|
+
subtree: true,
|
|
250
|
+
childList: true,
|
|
251
|
+
characterData: true,
|
|
252
|
+
attributes: true,
|
|
253
|
+
attributeFilter: EXCLUDED_ATTRIBUTES,
|
|
254
|
+
attributeOldValue: true,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
static create(root: HTMLElement): LogarithmicFadeController | null {
|
|
259
|
+
const scheduler = schedulerFor(root)
|
|
260
|
+
return scheduler === null ? null : new LogarithmicFadeController(root, scheduler)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
update(enabled: boolean, active: boolean, speedCps = 100, paused = false): void {
|
|
264
|
+
const now = this.scheduler.window.performance.now()
|
|
265
|
+
if (paused && this.pausedAt === null) this.pausedAt = now
|
|
266
|
+
if (!paused && this.pausedAt !== null) {
|
|
267
|
+
const pauseDuration = now - this.pausedAt
|
|
268
|
+
for (const character of this.characters) character.born += pauseDuration
|
|
269
|
+
this.pausedAt = null
|
|
270
|
+
}
|
|
271
|
+
this.enabled = enabled
|
|
272
|
+
this.active = active
|
|
273
|
+
this.speedCps = speedCps
|
|
274
|
+
this.reconcile()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Invalidate and reconcile from one observer batch (one batch per commit). */
|
|
278
|
+
private onDomMutation(records: MutationRecord[]): void {
|
|
279
|
+
let changed = false
|
|
280
|
+
let exclusionAttributesMoved = false
|
|
281
|
+
for (const record of records) {
|
|
282
|
+
if (record.type === 'childList') {
|
|
283
|
+
this.structureRevision += 1
|
|
284
|
+
changed = true
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (record.type === 'characterData') {
|
|
288
|
+
changed = true
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
// Only EXCLUDED_ATTRIBUTES reach this branch, and only value changes
|
|
292
|
+
// count: this controller writes its own scope class below, which must not
|
|
293
|
+
// be mistaken for a DOM change (it re-renders the same token list).
|
|
294
|
+
if (!attributeChanged(record)) continue
|
|
295
|
+
exclusionAttributesMoved = true
|
|
296
|
+
changed = true
|
|
297
|
+
}
|
|
298
|
+
// A batch made only of this controller's own no-op writes is not a DOM
|
|
299
|
+
// change. Reconciling on it would re-add the scope class, whose record is
|
|
300
|
+
// another such batch, and the observer would re-enter itself forever.
|
|
301
|
+
if (!changed) return
|
|
302
|
+
if (exclusionAttributesMoved) {
|
|
303
|
+
// `hidden`/`aria-hidden`/`class` on an ancestor decides the eligibility of
|
|
304
|
+
// every descendant Text node, so the whole memo is stale — not just the
|
|
305
|
+
// mutation target — and the next pass has to re-derive the table.
|
|
306
|
+
this.excludedElements = new WeakMap()
|
|
307
|
+
this.ownExcluded = new WeakMap()
|
|
308
|
+
this.structureRevision += 1
|
|
309
|
+
}
|
|
310
|
+
this.domRevision += 1
|
|
311
|
+
this.reconcile()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Whether this element matches a SKIP rule itself or inherits one from an
|
|
316
|
+
* ancestor — the memoised form of `element.closest(EXCLUDED) !== null`.
|
|
317
|
+
* Memoised per element and revalidated through the parent chain, so a node
|
|
318
|
+
* that moves into `code` — or whose ancestor does — is re-decided without
|
|
319
|
+
* paying the selector walk once per Text node per pass.
|
|
320
|
+
* @param element - element owning the Text node being classified.
|
|
321
|
+
* @returns true when text under this element must stay out of the fade.
|
|
322
|
+
*/
|
|
323
|
+
private elementExcluded(element: Element): boolean {
|
|
324
|
+
const cached = this.excludedElements.get(element)
|
|
325
|
+
if (cached !== undefined && cached.parent === element.parentElement) return cached.excluded
|
|
326
|
+
const parent = element.parentElement
|
|
327
|
+
const inherited = parent === null ? false : this.elementExcluded(parent)
|
|
328
|
+
let own = this.ownExcluded.get(element)
|
|
329
|
+
if (own === undefined) {
|
|
330
|
+
own = element.matches(EXCLUDED)
|
|
331
|
+
this.ownExcluded.set(element, own)
|
|
332
|
+
}
|
|
333
|
+
const excluded = inherited || own
|
|
334
|
+
this.excludedElements.set(element, { parent, excluded })
|
|
335
|
+
return excluded
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Rebuild the text-node table after a structural change. */
|
|
339
|
+
private scanNodes(): void {
|
|
340
|
+
const nodes: TextNodeEntry[] = []
|
|
341
|
+
const walker = this.root.ownerDocument.createTreeWalker(this.root, NodeFilter.SHOW_TEXT)
|
|
342
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
343
|
+
const text = node as Text
|
|
344
|
+
const parent = text.parentElement
|
|
345
|
+
// Derived through the parent element, never cached on Text identity: a
|
|
346
|
+
// node that was moved keeps its identity, so a Text-keyed memo would keep
|
|
347
|
+
// answering for the tree it used to live in.
|
|
348
|
+
const eligible = parent !== null && !this.elementExcluded(parent)
|
|
349
|
+
nodes.push({ node: text, start: 0, end: 0, eligible })
|
|
350
|
+
}
|
|
351
|
+
this.nodes = nodes
|
|
352
|
+
this.scannedStructureRevision = this.structureRevision
|
|
353
|
+
fadeHotPathStats.nodeScans += 1
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Re-derive the source text, the per-node spans and the eligibility prefix
|
|
358
|
+
* sums from the cached table. No DOM read, no selector match, no allocation
|
|
359
|
+
* beyond the string itself.
|
|
360
|
+
*/
|
|
361
|
+
private readText(): string {
|
|
362
|
+
if (this.scannedStructureRevision !== this.structureRevision) this.scanNodes()
|
|
363
|
+
// The commit-driven pass runs before the observer microtask for the same
|
|
364
|
+
// commit, so a replaced text node can still be in the table. A removed
|
|
365
|
+
// node keeps its data, which would silently hand a detached node to the
|
|
366
|
+
// Range and colour code, so re-scan on the first eviction.
|
|
367
|
+
for (const entry of this.nodes) {
|
|
368
|
+
if (entry.node.parentNode !== null) continue
|
|
369
|
+
this.scanNodes()
|
|
370
|
+
break
|
|
371
|
+
}
|
|
372
|
+
const nodes = this.nodes
|
|
373
|
+
const prefix = this.ineligiblePrefix
|
|
374
|
+
if (prefix.length < nodes.length + 1) prefix.length = nodes.length + 1
|
|
375
|
+
prefix[0] = 0
|
|
376
|
+
let text = ''
|
|
377
|
+
let offset = 0
|
|
378
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
379
|
+
const entry = nodes[index]!
|
|
380
|
+
const data = entry.node.data
|
|
381
|
+
entry.start = offset
|
|
382
|
+
offset += data.length
|
|
383
|
+
entry.end = offset
|
|
384
|
+
text += data
|
|
385
|
+
prefix[index + 1] = prefix[index]! + (entry.eligible ? 0 : 1)
|
|
386
|
+
}
|
|
387
|
+
prefix.length = nodes.length + 1
|
|
388
|
+
this.ineligiblePrefix = prefix
|
|
389
|
+
return text
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** First cached node whose span ends after `offset` (binary search). */
|
|
393
|
+
private firstNodeEndingAfter(offset: number): number {
|
|
394
|
+
const nodes = this.nodes
|
|
395
|
+
let low = 0
|
|
396
|
+
let high = nodes.length
|
|
397
|
+
while (low < high) {
|
|
398
|
+
const mid = (low + high) >> 1
|
|
399
|
+
if (nodes[mid]!.end > offset) high = mid
|
|
400
|
+
else low = mid + 1
|
|
401
|
+
}
|
|
402
|
+
return low
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Last cached node whose span starts before `offset` (binary search). */
|
|
406
|
+
private lastNodeStartingBefore(offset: number): number {
|
|
407
|
+
const nodes = this.nodes
|
|
408
|
+
let low = -1
|
|
409
|
+
let high = nodes.length - 1
|
|
410
|
+
while (low < high) {
|
|
411
|
+
const mid = (low + high + 1) >> 1
|
|
412
|
+
if (nodes[mid]!.start < offset) low = mid
|
|
413
|
+
else high = mid - 1
|
|
414
|
+
}
|
|
415
|
+
return low
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private clearRanges(): void {
|
|
419
|
+
for (const character of this.characters) {
|
|
420
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
421
|
+
}
|
|
422
|
+
this.characters = []
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private restoreColor(element: HTMLElement, preserved: PreservedColor): void {
|
|
426
|
+
if (preserved.value === '') element.style.removeProperty(COLOR_PROPERTY)
|
|
427
|
+
else element.style.setProperty(COLOR_PROPERTY, preserved.value, preserved.priority)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private rootColorSet = false
|
|
431
|
+
|
|
432
|
+
private ensureRootColor(): void {
|
|
433
|
+
if (this.rootColorSet) return
|
|
434
|
+
const win = this.scheduler.window
|
|
435
|
+
const color = win.getComputedStyle(this.root).color || 'currentColor'
|
|
436
|
+
this.root.style.setProperty(COLOR_PROPERTY, color)
|
|
437
|
+
this.rootColorSet = true
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private restoreColors(): void {
|
|
441
|
+
if (this.rootColorSet) {
|
|
442
|
+
this.root.style.removeProperty(COLOR_PROPERTY)
|
|
443
|
+
this.rootColorSet = false
|
|
444
|
+
}
|
|
445
|
+
for (const [element, preserved] of this.colors) this.restoreColor(element, preserved)
|
|
446
|
+
this.colors.clear()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The document's appearance moved (theme switch, restyle): the captured ink
|
|
451
|
+
* colour is stale, so drop it and let the next pass read the new one. Costs
|
|
452
|
+
* nothing while no colour is captured, which is the common case — the read
|
|
453
|
+
* still happens only when text is actually fading.
|
|
454
|
+
*/
|
|
455
|
+
invalidateColors(): void {
|
|
456
|
+
if (this.disposed || (!this.rootColorSet && this.colors.size === 0)) return
|
|
457
|
+
this.restoreColors()
|
|
458
|
+
// Force the next reconcile past its dirty check; it re-reads the colour and
|
|
459
|
+
// re-cuts the live tail, so the characters already fading recolour in place
|
|
460
|
+
// instead of finishing in the previous theme's ink.
|
|
461
|
+
this.domRevision += 1
|
|
462
|
+
this.reconcile()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Refresh the fade ranges. Two triggers used to run this 2-3x per frame with
|
|
468
|
+
* no dirty check: the commit-driven `update()` and the observer batch for the
|
|
469
|
+
* very same DOM write. Both converge here, and the pass is skipped unless the
|
|
470
|
+
* DOM revision, the gate, the fade window or the pause state actually moved.
|
|
471
|
+
*/
|
|
472
|
+
private reconcile(): void {
|
|
473
|
+
if (this.disposed) return
|
|
474
|
+
fadeHotPathStats.reconcileCalls += 1
|
|
475
|
+
const tailSize = fadeTailSize(this.speedCps)
|
|
476
|
+
const paused = this.pausedAt !== null
|
|
477
|
+
const domChanged = this.domRevision !== this.reconciledRevision
|
|
478
|
+
// The window only matters while characters are still fading: an idle
|
|
479
|
+
// controller has nothing to re-cut, and the next append reconciles anyway.
|
|
480
|
+
const paramsChanged = this.enabled !== this.reconciledEnabled
|
|
481
|
+
|| this.active !== this.reconciledActive
|
|
482
|
+
|| paused !== this.reconciledPaused
|
|
483
|
+
|| (tailSize !== this.reconciledTailSize && this.characters.length > 0)
|
|
484
|
+
if (!domChanged && !paramsChanged) return
|
|
485
|
+
fadeHotPathStats.reconcilePasses += 1
|
|
486
|
+
this.reconciledRevision = this.domRevision
|
|
487
|
+
this.reconciledEnabled = this.enabled
|
|
488
|
+
this.reconciledActive = this.active
|
|
489
|
+
this.reconciledPaused = paused
|
|
490
|
+
this.reconciledTailSize = tailSize
|
|
491
|
+
|
|
492
|
+
const text = this.readText()
|
|
493
|
+
const previous = this.previous
|
|
494
|
+
this.previous = text
|
|
495
|
+
const old = this.characters
|
|
496
|
+
this.clearRanges()
|
|
497
|
+
if (!this.enabled) {
|
|
498
|
+
this.root.classList.remove(css.scope!)
|
|
499
|
+
this.restoreColors()
|
|
500
|
+
this.scheduler.pending.delete(this)
|
|
501
|
+
this.stopIfIdle()
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
this.root.classList.add(css.scope!)
|
|
505
|
+
this.ensureRootColor()
|
|
506
|
+
const now = this.pausedAt ?? this.scheduler.window.performance.now()
|
|
507
|
+
const appended = text.startsWith(previous)
|
|
508
|
+
let prefix = appended ? previous.length : 0
|
|
509
|
+
if (!appended) {
|
|
510
|
+
while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
|
|
511
|
+
}
|
|
512
|
+
const nodes = this.nodes
|
|
513
|
+
const ineligible = this.ineligiblePrefix
|
|
514
|
+
// Keep existing characters alive when the engine resets its speed during
|
|
515
|
+
// completion. A shrinking window must not abruptly darken the old tail.
|
|
516
|
+
const oldestLiveStart = old.reduce((start, character) => (
|
|
517
|
+
now - character.born < FADE_DURATION_MS && character.end <= prefix
|
|
518
|
+
? Math.min(start, character.start)
|
|
519
|
+
: start
|
|
520
|
+
), Infinity)
|
|
521
|
+
const retainedBySpan = new Map<string, FadeCharacter>()
|
|
522
|
+
for (const character of old) retainedBySpan.set(`${String(character.start)}:${String(character.end)}`, character)
|
|
523
|
+
this.colorGeneration += 1
|
|
524
|
+
|
|
525
|
+
// Windowed segmentation: fade only affects the active tail. Windowing to the
|
|
526
|
+
// safe boundary keeps Intl.Segmenter at O(1) constant time (<0.1ms) even for
|
|
527
|
+
// long texts with tens of thousands of characters.
|
|
528
|
+
const windowStart = Math.max(
|
|
529
|
+
0,
|
|
530
|
+
Math.min(
|
|
531
|
+
text.length - FADE_MAX_TAIL_SIZE * 2,
|
|
532
|
+
oldestLiveStart < Infinity ? oldestLiveStart : text.length,
|
|
533
|
+
),
|
|
534
|
+
)
|
|
535
|
+
const tailText = windowStart > 0 ? text.slice(windowStart) : text
|
|
536
|
+
const segments = segmenter.segment(tailText)
|
|
537
|
+
|
|
538
|
+
let end = text.length
|
|
539
|
+
for (let count = 0; count < FADE_MAX_TAIL_SIZE && end > 0 && (count < tailSize || end > oldestLiveStart); count += 1) {
|
|
540
|
+
const localEnd = end - windowStart
|
|
541
|
+
if (localEnd <= 0) break
|
|
542
|
+
const segment = segments.containing(localEnd - 1)
|
|
543
|
+
if (segment === undefined) break
|
|
544
|
+
const start = segment.index + windowStart
|
|
545
|
+
const retained = end <= prefix
|
|
546
|
+
? retainedBySpan.get(`${String(start)}:${String(end)}`)
|
|
547
|
+
: undefined
|
|
548
|
+
const born = retained?.born ?? (this.active && appended && start >= previous.length ? now : null)
|
|
549
|
+
if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== '') {
|
|
550
|
+
// One binary-searched node span replaces the former O(tail x #nodes)
|
|
551
|
+
// filter; the prefix sums answer "is every part eligible" in O(1).
|
|
552
|
+
const first = this.firstNodeEndingAfter(start)
|
|
553
|
+
const last = this.lastNodeStartingBefore(end)
|
|
554
|
+
if (last >= first && first < nodes.length && nodes[last]!.end > start
|
|
555
|
+
&& ineligible[last + 1] === ineligible[first]) {
|
|
556
|
+
const firstPart = nodes[first]!
|
|
557
|
+
const lastPart = nodes[last]!
|
|
558
|
+
const range = this.root.ownerDocument.createRange()
|
|
559
|
+
range.setStart(firstPart.node, start - firstPart.start)
|
|
560
|
+
range.setEnd(lastPart.node, end - lastPart.start)
|
|
561
|
+
this.characters.push({ start, end, born, range, bucket: -1 })
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
end = start
|
|
565
|
+
}
|
|
566
|
+
if (this.paint(now)) {
|
|
567
|
+
if (this.pausedAt === null) {
|
|
568
|
+
this.scheduler.pending.add(this)
|
|
569
|
+
schedule(this.scheduler)
|
|
570
|
+
} else {
|
|
571
|
+
this.scheduler.pending.delete(this)
|
|
572
|
+
this.stopIfIdle()
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
this.scheduler.pending.delete(this)
|
|
576
|
+
this.stopIfIdle()
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
paint(now: number): boolean {
|
|
581
|
+
this.characters = this.characters.filter((character) => {
|
|
582
|
+
const progress = (now - character.born) / FADE_DURATION_MS
|
|
583
|
+
if (progress >= 1 || !this.root.isConnected || !this.root.contains(character.range.startContainer)) {
|
|
584
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
585
|
+
return false
|
|
586
|
+
}
|
|
587
|
+
const bucket = Math.min(FADE_STEPS - 1, Math.round((logarithmicOpacity(progress) - FADE_MIN_OPACITY) / (1 - FADE_MIN_OPACITY) * (FADE_STEPS - 1)))
|
|
588
|
+
if (bucket !== character.bucket) {
|
|
589
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
590
|
+
this.scheduler.highlights[bucket]!.add(character.range)
|
|
591
|
+
character.bucket = bucket
|
|
592
|
+
}
|
|
593
|
+
return true
|
|
594
|
+
})
|
|
595
|
+
if (this.characters.length === 0) this.restoreColors()
|
|
596
|
+
return this.characters.length > 0
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private stopIfIdle(): void {
|
|
600
|
+
if (this.scheduler.pending.size !== 0) return
|
|
601
|
+
if (this.scheduler.taskId === null) return
|
|
602
|
+
this.scheduler.coordinator.unregisterTask(this.scheduler.taskId)
|
|
603
|
+
this.scheduler.taskId = null
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
dispose(): void {
|
|
607
|
+
if (this.disposed) return
|
|
608
|
+
this.disposed = true
|
|
609
|
+
this.observer.disconnect()
|
|
610
|
+
this.clearRanges()
|
|
611
|
+
this.restoreColors()
|
|
612
|
+
this.nodes = []
|
|
613
|
+
this.scannedStructureRevision = -1
|
|
614
|
+
this.root.classList.remove(css.scope!)
|
|
615
|
+
this.scheduler.pending.delete(this)
|
|
616
|
+
this.scheduler.clients.delete(this)
|
|
617
|
+
this.stopIfIdle()
|
|
618
|
+
if (this.scheduler.clients.size === 0) {
|
|
619
|
+
for (const [index, highlight] of this.scheduler.highlights.entries()) {
|
|
620
|
+
const name = highlightName(index)
|
|
621
|
+
if (this.scheduler.registry.get(name) === highlight) this.scheduler.registry.delete(name)
|
|
622
|
+
}
|
|
623
|
+
// "Fade off" must cost nothing: no observer, no frame seat, no media
|
|
624
|
+
// listener left behind for a document that no longer fades.
|
|
625
|
+
this.scheduler.unwatchAppearance()
|
|
626
|
+
schedulers.delete(this.root.ownerDocument)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** active admits new characters; enabled=false also cancels completion linger. */
|
|
632
|
+
export function useLogarithmicFade(
|
|
633
|
+
rootRef: RefObject<HTMLElement | null>,
|
|
634
|
+
enabled: boolean,
|
|
635
|
+
active: boolean,
|
|
636
|
+
speedCpsRef?: { current: number },
|
|
637
|
+
paused = false,
|
|
638
|
+
): void {
|
|
639
|
+
const controller = useRef<LogarithmicFadeController | null>(null)
|
|
640
|
+
const committed = useRef(false)
|
|
641
|
+
useLayoutEffect(() => {
|
|
642
|
+
return () => {
|
|
643
|
+
controller.current?.dispose()
|
|
644
|
+
controller.current = null
|
|
645
|
+
}
|
|
646
|
+
}, [rootRef])
|
|
647
|
+
// Deliberately commit-driven, including Markdown updates with unchanged
|
|
648
|
+
// source: React may swap text nodes for identical text, and the controller's
|
|
649
|
+
// observer cannot report that before the commit's layout effects. The
|
|
650
|
+
// controller itself drops the redundant half of those passes now.
|
|
651
|
+
useLayoutEffect(() => {
|
|
652
|
+
const root = rootRef.current
|
|
653
|
+
if (!enabled) {
|
|
654
|
+
// "Fade off" must cost nothing: no observer, no reconcile, no rAF seat,
|
|
655
|
+
// no leftover scope class or colour property.
|
|
656
|
+
if (controller.current !== null) {
|
|
657
|
+
controller.current.dispose()
|
|
658
|
+
controller.current = null
|
|
659
|
+
}
|
|
660
|
+
committed.current = true
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
// Settled history allocates neither observers nor highlight buckets.
|
|
664
|
+
if (controller.current === null && root !== null && active) {
|
|
665
|
+
controller.current = LogarithmicFadeController.create(root)
|
|
666
|
+
// Enabling midway through a message must not replay readable text.
|
|
667
|
+
if (committed.current) controller.current?.update(false, false)
|
|
668
|
+
}
|
|
669
|
+
controller.current?.update(enabled, active, speedCpsRef?.current, paused)
|
|
670
|
+
committed.current = true
|
|
671
|
+
})
|
|
672
|
+
}
|