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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrameCoordinator: unified per-document frame clock and phased layout scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Replaces competing, uncoordinated rAF loops across text reveal, follow
|
|
5
|
+
* physics, fade painting, and diagnostic sampling. One document owns exactly
|
|
6
|
+
* one live animation frame while any task is active.
|
|
7
|
+
*
|
|
8
|
+
* Strict phase order in every frame:
|
|
9
|
+
* 1. Read Phase: layout reads ONLY when some task declared them dirty.
|
|
10
|
+
* 2. Simulate Phase: pure math / integration (pacing, spring, buckets).
|
|
11
|
+
* A task returns `true` to stay armed for the next frame.
|
|
12
|
+
* 3. Write Phase: batched DOM mutations (text nodes, transforms, CSS vars,
|
|
13
|
+
* at most one scrollTop write per owner).
|
|
14
|
+
*
|
|
15
|
+
* Tasks registered later never overtake earlier ones inside a phase, so a
|
|
16
|
+
* task's relative order is its registration order.
|
|
17
|
+
*
|
|
18
|
+
* `requestRead` is the bridge to the previous frame: a task that just wrote
|
|
19
|
+
* layout-affecting DOM (pacing revealed text, mutate() batch) declares that
|
|
20
|
+
* geometry must be re-read at the top of the NEXT frame, before the follow
|
|
21
|
+
* engine simulates. That keeps the "no layout read after a layout write in
|
|
22
|
+
* the same frame" invariant without giving up a same-reveal measurement.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface FrameTask {
|
|
26
|
+
readonly id: string
|
|
27
|
+
/** Called if any task marked layout as dirty during the frame cycle. */
|
|
28
|
+
onRead?: ((now: number) => void) | undefined
|
|
29
|
+
/** Physics, trajectory, and character pace calculation. Returns true if active. */
|
|
30
|
+
onSimulate?: ((dtMs: number, now: number) => boolean) | undefined
|
|
31
|
+
/** DOM and visual style mutation phase (transform, text, scrollTop). */
|
|
32
|
+
onWrite?: ((now: number) => void) | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class FrameCoordinator {
|
|
36
|
+
private static coordinators = new WeakMap<Document, FrameCoordinator>()
|
|
37
|
+
|
|
38
|
+
private tasks = new Map<string, FrameTask>()
|
|
39
|
+
private rafId: number | null = null
|
|
40
|
+
private lastTs: number | null = null
|
|
41
|
+
/**
|
|
42
|
+
* Owed layout read. Starts false: a fresh coordinator has no unread writes,
|
|
43
|
+
* and starting dirty would keep the clock armed forever after the first
|
|
44
|
+
* frame. Consumers declare reads via {@link markLayoutDirty}.
|
|
45
|
+
*/
|
|
46
|
+
private layoutDirty = false
|
|
47
|
+
/** One id per consumer, so an unregister cannot evict a re-registered task. */
|
|
48
|
+
private nextTaskId = 0
|
|
49
|
+
private doc: Document
|
|
50
|
+
|
|
51
|
+
private constructor(doc: Document) {
|
|
52
|
+
this.doc = doc
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static forDocument(doc: Document = document): FrameCoordinator {
|
|
56
|
+
let coordinator = FrameCoordinator.coordinators.get(doc)
|
|
57
|
+
if (coordinator === undefined) {
|
|
58
|
+
coordinator = new FrameCoordinator(doc)
|
|
59
|
+
FrameCoordinator.coordinators.set(doc, coordinator)
|
|
60
|
+
}
|
|
61
|
+
return coordinator
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Mark that a DOM or geometry change requires a layout read next frame.
|
|
66
|
+
*/
|
|
67
|
+
markLayoutDirty(): void {
|
|
68
|
+
this.layoutDirty = true
|
|
69
|
+
this.ensureLoop()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Declare that geometry written since the last frame must be re-read at the
|
|
74
|
+
* top of the next one, and keep the clock running for it.
|
|
75
|
+
*/
|
|
76
|
+
requestRead(): void {
|
|
77
|
+
this.markLayoutDirty()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register or replace an animation/render task. Returns the task id to hand
|
|
82
|
+
* back to {@link unregisterTask}.
|
|
83
|
+
*/
|
|
84
|
+
registerTask(task: Omit<FrameTask, 'id'> & { id?: string }): string {
|
|
85
|
+
const id = task.id ?? `frame-task-${this.nextTaskId++}`
|
|
86
|
+
this.tasks.set(id, { ...task, id })
|
|
87
|
+
this.ensureLoop()
|
|
88
|
+
return id
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Unregister a task when its stream completes or the element unmounts.
|
|
93
|
+
*/
|
|
94
|
+
unregisterTask(id: string | null | undefined): void {
|
|
95
|
+
if (id === null || id === undefined) return
|
|
96
|
+
this.tasks.delete(id)
|
|
97
|
+
if (this.tasks.size === 0 && !this.layoutDirty) this.stopLoop()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private ensureLoop(): void {
|
|
101
|
+
if (this.rafId !== null) return
|
|
102
|
+
const win = this.doc.defaultView ?? (typeof window !== 'undefined' ? window : null)
|
|
103
|
+
if (win === null) return
|
|
104
|
+
|
|
105
|
+
const tick = (now: number) => {
|
|
106
|
+
this.rafId = null
|
|
107
|
+
|
|
108
|
+
if (this.lastTs === null) {
|
|
109
|
+
this.lastTs = now
|
|
110
|
+
// First frame after an idle gap only establishes the time base.
|
|
111
|
+
this.ensureLoop()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const frameIntervalMs = Math.max(0, now - this.lastTs)
|
|
116
|
+
// Clamp frame delta between 1ms and 100ms to avoid physics explosions
|
|
117
|
+
// during main-thread hiccups.
|
|
118
|
+
const dtMs = Math.max(1, Math.min(frameIntervalMs, 100))
|
|
119
|
+
this.lastTs = now
|
|
120
|
+
|
|
121
|
+
// 1. Read phase (only when some task declared layout dirty).
|
|
122
|
+
if (this.layoutDirty) {
|
|
123
|
+
this.layoutDirty = false
|
|
124
|
+
for (const task of this.tasks.values()) task.onRead?.(now)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2. Simulate phase (pacing step, spring, lag, bucket math).
|
|
128
|
+
let anyActive = false
|
|
129
|
+
for (const task of this.tasks.values()) {
|
|
130
|
+
if (task.onSimulate?.(dtMs, now) === true) anyActive = true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3. Write phase (transforms, text nodes, CSS variables, scrollTop).
|
|
134
|
+
for (const task of this.tasks.values()) task.onWrite?.(now)
|
|
135
|
+
|
|
136
|
+
// Stay armed while a task is active, while a pending read is owed, or
|
|
137
|
+
// while callers keep the clock explicitly running.
|
|
138
|
+
if ((anyActive || this.layoutDirty) && this.rafId === null) {
|
|
139
|
+
this.ensureLoop()
|
|
140
|
+
} else if (!anyActive && !this.layoutDirty) {
|
|
141
|
+
this.lastTs = null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.rafId = win.requestAnimationFrame(tick)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private stopLoop(): void {
|
|
149
|
+
if (this.rafId !== null) {
|
|
150
|
+
const win = this.doc.defaultView ?? (typeof window !== 'undefined' ? window : null)
|
|
151
|
+
win?.cancelAnimationFrame(this.rafId)
|
|
152
|
+
this.rafId = null
|
|
153
|
+
}
|
|
154
|
+
this.lastTs = null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether this document currently owns a live, still-needed animation
|
|
159
|
+
* frame. A frame that is merely draining an owed read with no registered
|
|
160
|
+
* task left is not considered active work.
|
|
161
|
+
*/
|
|
162
|
+
get active(): boolean {
|
|
163
|
+
return this.rafId !== null && (this.tasks.size > 0 || this.layoutDirty)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Number of registered tasks; used by tests to pin the single-clock contract. */
|
|
167
|
+
get taskCount(): number {
|
|
168
|
+
return this.tasks.size
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Release every task, drop any owed read, and stop the clock. Used by tests
|
|
173
|
+
* for isolation; production consumers unregister their own task.
|
|
174
|
+
*/
|
|
175
|
+
shutdown(): void {
|
|
176
|
+
this.tasks.clear()
|
|
177
|
+
this.layoutDirty = false
|
|
178
|
+
this.stopLoop()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* Shared buckets change paint only; original DOM, layout and colors survive. */
|
|
2
|
+
.scope::highlight(dsh-smooth-stream-log-fade-0),
|
|
3
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-0) {
|
|
4
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 0.00000%, transparent);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.scope::highlight(dsh-smooth-stream-log-fade-1),
|
|
8
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-1) {
|
|
9
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 3.22581%, transparent);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.scope::highlight(dsh-smooth-stream-log-fade-2),
|
|
13
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-2) {
|
|
14
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 6.45161%, transparent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.scope::highlight(dsh-smooth-stream-log-fade-3),
|
|
18
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-3) {
|
|
19
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 9.67742%, transparent);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.scope::highlight(dsh-smooth-stream-log-fade-4),
|
|
23
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-4) {
|
|
24
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 12.90323%, transparent);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.scope::highlight(dsh-smooth-stream-log-fade-5),
|
|
28
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-5) {
|
|
29
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 16.12903%, transparent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.scope::highlight(dsh-smooth-stream-log-fade-6),
|
|
33
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-6) {
|
|
34
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 19.35484%, transparent);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.scope::highlight(dsh-smooth-stream-log-fade-7),
|
|
38
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-7) {
|
|
39
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 22.58065%, transparent);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.scope::highlight(dsh-smooth-stream-log-fade-8),
|
|
43
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-8) {
|
|
44
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 25.80645%, transparent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.scope::highlight(dsh-smooth-stream-log-fade-9),
|
|
48
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-9) {
|
|
49
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 29.03226%, transparent);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.scope::highlight(dsh-smooth-stream-log-fade-10),
|
|
53
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-10) {
|
|
54
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 32.25806%, transparent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.scope::highlight(dsh-smooth-stream-log-fade-11),
|
|
58
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-11) {
|
|
59
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 35.48387%, transparent);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.scope::highlight(dsh-smooth-stream-log-fade-12),
|
|
63
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-12) {
|
|
64
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 38.70968%, transparent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.scope::highlight(dsh-smooth-stream-log-fade-13),
|
|
68
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-13) {
|
|
69
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 41.93548%, transparent);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.scope::highlight(dsh-smooth-stream-log-fade-14),
|
|
73
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-14) {
|
|
74
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 45.16129%, transparent);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.scope::highlight(dsh-smooth-stream-log-fade-15),
|
|
78
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-15) {
|
|
79
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 48.38710%, transparent);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.scope::highlight(dsh-smooth-stream-log-fade-16),
|
|
83
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-16) {
|
|
84
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 51.61290%, transparent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.scope::highlight(dsh-smooth-stream-log-fade-17),
|
|
88
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-17) {
|
|
89
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 54.83871%, transparent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.scope::highlight(dsh-smooth-stream-log-fade-18),
|
|
93
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-18) {
|
|
94
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 58.06452%, transparent);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.scope::highlight(dsh-smooth-stream-log-fade-19),
|
|
98
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-19) {
|
|
99
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 61.29032%, transparent);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.scope::highlight(dsh-smooth-stream-log-fade-20),
|
|
103
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-20) {
|
|
104
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 64.51613%, transparent);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.scope::highlight(dsh-smooth-stream-log-fade-21),
|
|
108
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-21) {
|
|
109
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 67.74194%, transparent);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.scope::highlight(dsh-smooth-stream-log-fade-22),
|
|
113
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-22) {
|
|
114
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 70.96774%, transparent);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.scope::highlight(dsh-smooth-stream-log-fade-23),
|
|
118
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-23) {
|
|
119
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 74.19355%, transparent);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.scope::highlight(dsh-smooth-stream-log-fade-24),
|
|
123
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-24) {
|
|
124
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 77.41935%, transparent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.scope::highlight(dsh-smooth-stream-log-fade-25),
|
|
128
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-25) {
|
|
129
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 80.64516%, transparent);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.scope::highlight(dsh-smooth-stream-log-fade-26),
|
|
133
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-26) {
|
|
134
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 83.87097%, transparent);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.scope::highlight(dsh-smooth-stream-log-fade-27),
|
|
138
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-27) {
|
|
139
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 87.09677%, transparent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.scope::highlight(dsh-smooth-stream-log-fade-28),
|
|
143
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-28) {
|
|
144
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 90.32258%, transparent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.scope::highlight(dsh-smooth-stream-log-fade-29),
|
|
148
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-29) {
|
|
149
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 93.54839%, transparent);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.scope::highlight(dsh-smooth-stream-log-fade-30),
|
|
153
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-30) {
|
|
154
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 96.77419%, transparent);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.scope::highlight(dsh-smooth-stream-log-fade-31),
|
|
158
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-31) {
|
|
159
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 100.00000%, transparent);
|
|
160
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { useState } from 'react'
|
|
9
9
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
10
10
|
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
11
|
-
import {
|
|
11
|
+
import { IconChevronDown, IconRefresh } from './harnessIcons.ts'
|
|
12
12
|
import type { SmoothStreamCardFace } from './smooth-stream-card-controller.ts'
|
|
13
13
|
import css from './SmoothStreamCard.module.css'
|
|
14
14
|
|
|
@@ -43,7 +43,7 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
43
43
|
</span>
|
|
44
44
|
{versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
|
|
45
45
|
{state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
|
|
46
|
-
<
|
|
46
|
+
<IconChevronDown className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
|
|
47
47
|
</button>
|
|
48
48
|
{open
|
|
49
49
|
? (
|
|
@@ -84,6 +84,19 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
84
84
|
</span>
|
|
85
85
|
<span className={css.hint}>{t('controlScrollHint')}</span>
|
|
86
86
|
</label>
|
|
87
|
+
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
88
|
+
<span className={css.fieldHead}>
|
|
89
|
+
<span className={css.label}>{t('logarithmicFade')}</span>
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
className={css.toggle}
|
|
93
|
+
checked={state.logarithmicFade}
|
|
94
|
+
disabled={!state.writable || state.saving || !state.enabled}
|
|
95
|
+
onChange={(event) => { props.edit({ logarithmicFade: event.target.checked }) }}
|
|
96
|
+
/>
|
|
97
|
+
</span>
|
|
98
|
+
<span className={css.hint}>{t('logarithmicFadeHint')}</span>
|
|
99
|
+
</label>
|
|
87
100
|
{/* Motion preference is a radio group, so this row is a div:
|
|
88
101
|
nesting the choice labels inside a field <label> would be
|
|
89
102
|
illegal HTML (label within label) and browsers route the
|
|
@@ -113,6 +126,31 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
113
126
|
))}
|
|
114
127
|
</span>
|
|
115
128
|
</div>
|
|
129
|
+
<div className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
130
|
+
<span className={css.fieldHead}>
|
|
131
|
+
<span className={css.label}>{t('preset')}</span>
|
|
132
|
+
</span>
|
|
133
|
+
<span className={css.hint}>{t('presetHint')}</span>
|
|
134
|
+
<span className={css.choiceRow} role="radiogroup" aria-label={t('preset')}>
|
|
135
|
+
{([
|
|
136
|
+
['realtime', 'presetRealtime', 'presetRealtimeHint'],
|
|
137
|
+
['balanced', 'presetBalanced', 'presetBalancedHint'],
|
|
138
|
+
['silky', 'presetSilky', 'presetSilkyHint'],
|
|
139
|
+
] as const).map(([value, label, hint]) => (
|
|
140
|
+
<label key={value} className={css.choice} title={t(hint)}>
|
|
141
|
+
<input
|
|
142
|
+
type="radio"
|
|
143
|
+
className={css.choiceInput}
|
|
144
|
+
name="smooth-stream-preset"
|
|
145
|
+
checked={state.preset === value}
|
|
146
|
+
disabled={!state.writable || state.saving || !state.enabled}
|
|
147
|
+
onChange={() => { props.edit({ preset: value }) }}
|
|
148
|
+
/>
|
|
149
|
+
{t(label)}
|
|
150
|
+
</label>
|
|
151
|
+
))}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
116
154
|
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
117
155
|
<span className={css.fieldHead}>
|
|
118
156
|
<span className={css.label}>{t('thinkAutoExpand')}</span>
|
|
@@ -156,7 +194,7 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
156
194
|
title={state.canUpgrade ? undefined : t('updateUnavailable')}
|
|
157
195
|
onClick={props.upgrade}
|
|
158
196
|
>
|
|
159
|
-
<span aria-hidden="true"><
|
|
197
|
+
<span aria-hidden="true"><IconRefresh /></span>
|
|
160
198
|
{t(state.upgrading ? 'updating' : 'update')}
|
|
161
199
|
</button>
|
|
162
200
|
</div>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-performance append-only stream buffer with code-point cursor addressing.
|
|
3
|
+
*
|
|
4
|
+
* Avoids per-frame `[...content].slice().join('')` or repeated full-string allocations.
|
|
5
|
+
* Chunks are stored in an indexed rope array, and code-point to UTF-16 offset maps
|
|
6
|
+
* are updated incrementally per chunk.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class StreamBuffer {
|
|
10
|
+
private chunks: string[] = []
|
|
11
|
+
private chunkCodePoints: number[] = []
|
|
12
|
+
private chunkCharLengths: number[] = []
|
|
13
|
+
private totalCodePoints = 0
|
|
14
|
+
private fullStringCache: string | null = null
|
|
15
|
+
|
|
16
|
+
constructor(initialText = '') {
|
|
17
|
+
if (initialText.length > 0) {
|
|
18
|
+
this.append(initialText)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Count Unicode code points (perceived characters, UTF-16 surrogate pairs counted as 1).
|
|
24
|
+
*/
|
|
25
|
+
static countCodePoints(str: string): number {
|
|
26
|
+
let count = 0
|
|
27
|
+
for (let i = 0; i < str.length; i++) {
|
|
28
|
+
const code = str.charCodeAt(i)
|
|
29
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
|
|
30
|
+
const next = str.charCodeAt(i + 1)
|
|
31
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
32
|
+
i++
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
count++
|
|
36
|
+
}
|
|
37
|
+
return count
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert code-point index into UTF-16 unit offset within a single chunk.
|
|
42
|
+
*/
|
|
43
|
+
private static codePointToOffset(str: string, targetCp: number): number {
|
|
44
|
+
let cp = 0
|
|
45
|
+
let offset = 0
|
|
46
|
+
while (offset < str.length && cp < targetCp) {
|
|
47
|
+
const code = str.charCodeAt(offset)
|
|
48
|
+
if (code >= 0xd800 && code <= 0xdbff && offset + 1 < str.length) {
|
|
49
|
+
const next = str.charCodeAt(offset + 1)
|
|
50
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
51
|
+
offset++
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
offset++
|
|
55
|
+
cp++
|
|
56
|
+
}
|
|
57
|
+
return offset
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append a new chunk to the buffer.
|
|
62
|
+
*/
|
|
63
|
+
append(chunk: string): void {
|
|
64
|
+
if (chunk.length === 0) return
|
|
65
|
+
const cps = StreamBuffer.countCodePoints(chunk)
|
|
66
|
+
this.chunks.push(chunk)
|
|
67
|
+
this.chunkCodePoints.push(cps)
|
|
68
|
+
this.chunkCharLengths.push(chunk.length)
|
|
69
|
+
this.totalCodePoints += cps
|
|
70
|
+
this.fullStringCache = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Total perceived characters (code points) in the buffer.
|
|
75
|
+
*/
|
|
76
|
+
get length(): number {
|
|
77
|
+
return this.totalCodePoints
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reset the buffer to empty or to a new baseline string.
|
|
82
|
+
*/
|
|
83
|
+
reset(text = ''): void {
|
|
84
|
+
this.chunks = []
|
|
85
|
+
this.chunkCodePoints = []
|
|
86
|
+
this.chunkCharLengths = []
|
|
87
|
+
this.totalCodePoints = 0
|
|
88
|
+
this.fullStringCache = null
|
|
89
|
+
if (text.length > 0) {
|
|
90
|
+
this.append(text)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract a slice of text between `fromCp` and `toCp` (both in code points).
|
|
96
|
+
*/
|
|
97
|
+
slice(fromCp: number, toCp: number): string {
|
|
98
|
+
const start = Math.max(0, fromCp)
|
|
99
|
+
const end = Math.min(this.totalCodePoints, toCp)
|
|
100
|
+
if (start >= end) return ''
|
|
101
|
+
|
|
102
|
+
// Fast path: if requesting the full string
|
|
103
|
+
if (start === 0 && end === this.totalCodePoints) {
|
|
104
|
+
return this.toString()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let result = ''
|
|
108
|
+
let accumulatedCp = 0
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
111
|
+
const chunkCps = this.chunkCodePoints[i]!
|
|
112
|
+
const chunkEndCp = accumulatedCp + chunkCps
|
|
113
|
+
|
|
114
|
+
if (chunkEndCp <= start) {
|
|
115
|
+
accumulatedCp = chunkEndCp
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (accumulatedCp >= end) {
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const chunk = this.chunks[i]!
|
|
123
|
+
const localStartCp = Math.max(0, start - accumulatedCp)
|
|
124
|
+
const localEndCp = Math.min(chunkCps, end - accumulatedCp)
|
|
125
|
+
|
|
126
|
+
const startOffset = localStartCp === 0 ? 0 : StreamBuffer.codePointToOffset(chunk, localStartCp)
|
|
127
|
+
const endOffset = localEndCp === chunkCps ? chunk.length : StreamBuffer.codePointToOffset(chunk, localEndCp)
|
|
128
|
+
|
|
129
|
+
result += chunk.slice(startOffset, endOffset)
|
|
130
|
+
accumulatedCp = chunkEndCp
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get the full concatenated string (cached until next append/reset).
|
|
138
|
+
*/
|
|
139
|
+
toString(): string {
|
|
140
|
+
if (this.fullStringCache === null) {
|
|
141
|
+
this.fullStringCache = this.chunks.join('')
|
|
142
|
+
}
|
|
143
|
+
return this.fullStringCache
|
|
144
|
+
}
|
|
145
|
+
}
|