dsh-smooth-stream 0.6.1 → 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/lib/client.js +1508 -362
- package/lib/client.js.map +1 -1
- package/lib/index.js +102 -8
- 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/SmoothStreamCard.tsx +28 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +216 -102
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +151 -11
- package/src/client/locales.ts +85 -0
- package/src/client/smooth-stream-card-controller.ts +7 -3
- package/src/client/smooth-stream-settings-api.ts +4 -2
- package/src/client/teleprompterGlide.ts +491 -52
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +439 -60
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +83 -8
- package/src/settings-api.ts +2 -0
- package/src/settings-channel.ts +34 -8
- package/src/settings.ts +11 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import { FrameCoordinator } from './FrameCoordinator.ts'
|
|
2
3
|
import css from './LogarithmicFade.module.css'
|
|
3
4
|
|
|
4
5
|
export const FADE_DURATION_MS = 240
|
|
@@ -11,6 +12,15 @@ const COLOR_PROPERTY = '--dsh-smooth-stream-fade-color'
|
|
|
11
12
|
// Lightning CSS scopes highlight identifiers as well as class names.
|
|
12
13
|
const highlightName = (index: number): string => css[`${PREFIX}${index}`] ?? `${PREFIX}${index}`
|
|
13
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']
|
|
14
24
|
|
|
15
25
|
export function logarithmicOpacity(progress: number): number {
|
|
16
26
|
const p = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 1
|
|
@@ -31,29 +41,130 @@ interface FadeCharacter {
|
|
|
31
41
|
bucket: number
|
|
32
42
|
}
|
|
33
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
|
+
|
|
34
59
|
interface Scheduler {
|
|
35
60
|
highlights: Highlight[]
|
|
36
61
|
clients: Set<LogarithmicFadeController>
|
|
37
62
|
pending: Set<LogarithmicFadeController>
|
|
38
|
-
frame
|
|
63
|
+
/** Task handle on the shared frame clock; non-null while a client is live. */
|
|
64
|
+
taskId: string | null
|
|
39
65
|
registry: HighlightRegistry
|
|
40
66
|
window: Window
|
|
67
|
+
coordinator: FrameCoordinator
|
|
68
|
+
/** Detaches the document-level appearance watcher when the last client goes. */
|
|
69
|
+
unwatchAppearance: () => void
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
const schedulers = new WeakMap<Document, Scheduler>()
|
|
44
73
|
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
45
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
|
+
|
|
46
97
|
function schedule(scheduler: Scheduler): void {
|
|
47
|
-
if (scheduler.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
},
|
|
54
112
|
})
|
|
55
113
|
}
|
|
56
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
|
+
|
|
57
168
|
function schedulerFor(root: HTMLElement): Scheduler | null {
|
|
58
169
|
const doc = root.ownerDocument
|
|
59
170
|
const win = doc.defaultView
|
|
@@ -70,32 +181,78 @@ function schedulerFor(root: HTMLElement): Scheduler | null {
|
|
|
70
181
|
highlights,
|
|
71
182
|
clients: new Set(),
|
|
72
183
|
pending: new Set(),
|
|
73
|
-
|
|
184
|
+
taskId: null,
|
|
74
185
|
registry: realm.CSS.highlights,
|
|
75
186
|
window: win,
|
|
187
|
+
coordinator: FrameCoordinator.forDocument(doc),
|
|
188
|
+
unwatchAppearance: () => {},
|
|
76
189
|
}
|
|
190
|
+
scheduler.unwatchAppearance = watchAppearance(scheduler)
|
|
77
191
|
schedulers.set(doc, scheduler)
|
|
78
192
|
}
|
|
79
193
|
return scheduler
|
|
80
194
|
}
|
|
81
195
|
|
|
82
|
-
/**
|
|
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
|
+
*/
|
|
83
205
|
export class LogarithmicFadeController {
|
|
84
206
|
private previous = ''
|
|
85
207
|
private characters: FadeCharacter[] = []
|
|
86
|
-
private colors = new Map<HTMLElement,
|
|
208
|
+
private colors = new Map<HTMLElement, PreservedColor>()
|
|
87
209
|
private enabled = false
|
|
88
210
|
private active = false
|
|
89
211
|
private speedCps = 100
|
|
90
212
|
private pausedAt: number | null = null
|
|
91
213
|
private disposed = false
|
|
92
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
|
|
93
240
|
|
|
94
241
|
private constructor(private readonly root: HTMLElement, private readonly scheduler: Scheduler) {
|
|
95
242
|
scheduler.clients.add(this)
|
|
96
243
|
const win = root.ownerDocument.defaultView as Window & typeof globalThis
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
})
|
|
99
256
|
}
|
|
100
257
|
|
|
101
258
|
static create(root: HTMLElement): LogarithmicFadeController | null {
|
|
@@ -117,90 +274,292 @@ export class LogarithmicFadeController {
|
|
|
117
274
|
this.reconcile()
|
|
118
275
|
}
|
|
119
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
|
+
|
|
120
418
|
private clearRanges(): void {
|
|
121
419
|
for (const character of this.characters) {
|
|
122
420
|
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
123
421
|
}
|
|
124
422
|
this.characters = []
|
|
125
|
-
|
|
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
|
|
126
438
|
}
|
|
127
439
|
|
|
128
440
|
private restoreColors(): void {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
441
|
+
if (this.rootColorSet) {
|
|
442
|
+
this.root.style.removeProperty(COLOR_PROPERTY)
|
|
443
|
+
this.rootColorSet = false
|
|
132
444
|
}
|
|
445
|
+
for (const [element, preserved] of this.colors) this.restoreColor(element, preserved)
|
|
133
446
|
this.colors.clear()
|
|
134
447
|
}
|
|
135
448
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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()
|
|
146
463
|
}
|
|
147
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
|
+
*/
|
|
148
472
|
private reconcile(): void {
|
|
149
473
|
if (this.disposed) return
|
|
150
|
-
|
|
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()
|
|
151
493
|
const previous = this.previous
|
|
152
494
|
this.previous = text
|
|
153
495
|
const old = this.characters
|
|
154
496
|
this.clearRanges()
|
|
155
497
|
if (!this.enabled) {
|
|
156
498
|
this.root.classList.remove(css.scope!)
|
|
499
|
+
this.restoreColors()
|
|
157
500
|
this.scheduler.pending.delete(this)
|
|
158
501
|
this.stopIfIdle()
|
|
159
502
|
return
|
|
160
503
|
}
|
|
161
504
|
this.root.classList.add(css.scope!)
|
|
505
|
+
this.ensureRootColor()
|
|
162
506
|
const now = this.pausedAt ?? this.scheduler.window.performance.now()
|
|
163
|
-
// A parser rewrite must not replay already readable content. Retain only
|
|
164
|
-
// the unchanged prefix; future appends resume the effect normally.
|
|
165
|
-
let prefix = 0
|
|
166
|
-
while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
|
|
167
507
|
const appended = text.startsWith(previous)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
172
|
-
const end = offset + (node.textContent?.length ?? 0)
|
|
173
|
-
nodes.push({ node: node as Text, start: offset, end, eligible: node.parentElement?.closest(EXCLUDED) === null })
|
|
174
|
-
offset = end
|
|
508
|
+
let prefix = appended ? previous.length : 0
|
|
509
|
+
if (!appended) {
|
|
510
|
+
while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
|
|
175
511
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const segments = segmenter.segment(text)
|
|
512
|
+
const nodes = this.nodes
|
|
513
|
+
const ineligible = this.ineligiblePrefix
|
|
179
514
|
// Keep existing characters alive when the engine resets its speed during
|
|
180
515
|
// completion. A shrinking window must not abruptly darken the old tail.
|
|
181
|
-
const tailSize = fadeTailSize(this.speedCps)
|
|
182
516
|
const oldestLiveStart = old.reduce((start, character) => (
|
|
183
517
|
now - character.born < FADE_DURATION_MS && character.end <= prefix
|
|
184
518
|
? Math.min(start, character.start)
|
|
185
519
|
: start
|
|
186
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
|
+
|
|
187
538
|
let end = text.length
|
|
188
539
|
for (let count = 0; count < FADE_MAX_TAIL_SIZE && end > 0 && (count < tailSize || end > oldestLiveStart); count += 1) {
|
|
189
|
-
const
|
|
540
|
+
const localEnd = end - windowStart
|
|
541
|
+
if (localEnd <= 0) break
|
|
542
|
+
const segment = segments.containing(localEnd - 1)
|
|
190
543
|
if (segment === undefined) break
|
|
191
|
-
const start = segment.index
|
|
192
|
-
const
|
|
193
|
-
|
|
544
|
+
const start = segment.index + windowStart
|
|
545
|
+
const retained = end <= prefix
|
|
546
|
+
? retainedBySpan.get(`${String(start)}:${String(end)}`)
|
|
547
|
+
: undefined
|
|
194
548
|
const born = retained?.born ?? (this.active && appended && start >= previous.length ? now : null)
|
|
195
|
-
if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== ''
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
204
563
|
}
|
|
205
564
|
end = start
|
|
206
565
|
}
|
|
@@ -239,8 +598,9 @@ export class LogarithmicFadeController {
|
|
|
239
598
|
|
|
240
599
|
private stopIfIdle(): void {
|
|
241
600
|
if (this.scheduler.pending.size !== 0) return
|
|
242
|
-
|
|
243
|
-
this.scheduler.
|
|
601
|
+
if (this.scheduler.taskId === null) return
|
|
602
|
+
this.scheduler.coordinator.unregisterTask(this.scheduler.taskId)
|
|
603
|
+
this.scheduler.taskId = null
|
|
244
604
|
}
|
|
245
605
|
|
|
246
606
|
dispose(): void {
|
|
@@ -248,6 +608,9 @@ export class LogarithmicFadeController {
|
|
|
248
608
|
this.disposed = true
|
|
249
609
|
this.observer.disconnect()
|
|
250
610
|
this.clearRanges()
|
|
611
|
+
this.restoreColors()
|
|
612
|
+
this.nodes = []
|
|
613
|
+
this.scannedStructureRevision = -1
|
|
251
614
|
this.root.classList.remove(css.scope!)
|
|
252
615
|
this.scheduler.pending.delete(this)
|
|
253
616
|
this.scheduler.clients.delete(this)
|
|
@@ -257,6 +620,9 @@ export class LogarithmicFadeController {
|
|
|
257
620
|
const name = highlightName(index)
|
|
258
621
|
if (this.scheduler.registry.get(name) === highlight) this.scheduler.registry.delete(name)
|
|
259
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()
|
|
260
626
|
schedulers.delete(this.root.ownerDocument)
|
|
261
627
|
}
|
|
262
628
|
}
|
|
@@ -278,11 +644,24 @@ export function useLogarithmicFade(
|
|
|
278
644
|
controller.current = null
|
|
279
645
|
}
|
|
280
646
|
}, [rootRef])
|
|
281
|
-
// Deliberately commit-driven, including Markdown updates with unchanged
|
|
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.
|
|
282
651
|
useLayoutEffect(() => {
|
|
283
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
|
+
}
|
|
284
663
|
// Settled history allocates neither observers nor highlight buckets.
|
|
285
|
-
if (controller.current === null && root !== null &&
|
|
664
|
+
if (controller.current === null && root !== null && active) {
|
|
286
665
|
controller.current = LogarithmicFadeController.create(root)
|
|
287
666
|
// Enabling midway through a message must not replay readable text.
|
|
288
667
|
if (committed.current) controller.current?.update(false, false)
|