maquina_stream 0.1.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.
- checksums.yaml +7 -0
- data/.rdoc_options +30 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
- data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
- data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
- data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
- data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
- data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
- data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
- data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
- data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
- data/app/controllers/maquina_stream/application_controller.rb +21 -0
- data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
- data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
- data/app/helpers/maquina_stream/components_helper.rb +80 -0
- data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
- data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
- data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
- data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
- data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
- data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
- data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
- data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
- data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
- data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
- data/app/javascript/maquina_stream/index.js +59 -0
- data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
- data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
- data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
- data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
- data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
- data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
- data/config/importmap.rb +10 -0
- data/config/locales/en.yml +79 -0
- data/config/locales/es.yml +82 -0
- data/config/routes.rb +11 -0
- data/docs/configuration.md +219 -0
- data/docs/deferred-renderers.md +184 -0
- data/docs/getting-started.md +356 -0
- data/docs/javascript.md +298 -0
- data/docs/registries.md +283 -0
- data/docs/repair.md +162 -0
- data/docs/security.md +247 -0
- data/docs/streaming.md +308 -0
- data/lib/generators/maquina_stream/install/USAGE +26 -0
- data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
- data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
- data/lib/generators/maquina_stream/streamable/USAGE +28 -0
- data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
- data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
- data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
- data/lib/maquina_stream/block.rb +99 -0
- data/lib/maquina_stream/broadcaster.rb +233 -0
- data/lib/maquina_stream/component_cache.rb +0 -0
- data/lib/maquina_stream/components/contract.rb +184 -0
- data/lib/maquina_stream/components.rb +135 -0
- data/lib/maquina_stream/configuration.rb +240 -0
- data/lib/maquina_stream/document.rb +296 -0
- data/lib/maquina_stream/engine.rb +46 -0
- data/lib/maquina_stream/errors.rb +17 -0
- data/lib/maquina_stream/export.rb +66 -0
- data/lib/maquina_stream/frame.rb +73 -0
- data/lib/maquina_stream/manifest.rb +137 -0
- data/lib/maquina_stream/registries.rb +116 -0
- data/lib/maquina_stream/renderer/fence.rb +115 -0
- data/lib/maquina_stream/renderer/post_pass.rb +363 -0
- data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
- data/lib/maquina_stream/renderer/view_context.rb +72 -0
- data/lib/maquina_stream/renderer.rb +128 -0
- data/lib/maquina_stream/sanitizer.rb +392 -0
- data/lib/maquina_stream/streamable.rb +281 -0
- data/lib/maquina_stream/text_direction.rb +56 -0
- data/lib/maquina_stream/themes.rb +84 -0
- data/lib/maquina_stream/version.rb +5 -0
- data/lib/maquina_stream.rb +175 -0
- metadata +204 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import MsDeferredController from "maquina_stream/controllers/ms_deferred_controller"
|
|
2
|
+
|
|
3
|
+
// `ms-math` — KaTeX, with trust disabled.
|
|
4
|
+
//
|
|
5
|
+
// `trust: false` refuses \htmlClass, \includegraphics and \href, all of which
|
|
6
|
+
// take attacker-controlled strings straight into the DOM. `throwOnError: false`
|
|
7
|
+
// keeps a malformed formula from taking the message down; ms-deferred's error
|
|
8
|
+
// path handles the rest.
|
|
9
|
+
export default class extends MsDeferredController {
|
|
10
|
+
static library = null
|
|
11
|
+
|
|
12
|
+
async library() {
|
|
13
|
+
if (!this.constructor.library) {
|
|
14
|
+
const katex = await import("katex")
|
|
15
|
+
this.constructor.library = katex.default ?? katex
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return this.constructor.library
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async draw(payload) {
|
|
22
|
+
const katex = await this.library()
|
|
23
|
+
|
|
24
|
+
return katex.renderToString(String(payload.source ?? ""), {
|
|
25
|
+
displayMode: payload.display !== false,
|
|
26
|
+
throwOnError: false,
|
|
27
|
+
trust: false,
|
|
28
|
+
strict: "ignore",
|
|
29
|
+
output: "html"
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import ApplicationController from "maquina_stream/controllers/application_controller"
|
|
2
|
+
|
|
3
|
+
// `ms-repair` — reconcile the message with the server.
|
|
4
|
+
//
|
|
5
|
+
// <div id="ms-msg-42"
|
|
6
|
+
// data-controller="ms-stream ms-repair"
|
|
7
|
+
// data-ms-repair-manifest-url-value="/maquina_stream/42/manifest"
|
|
8
|
+
// data-ms-repair-blocks-url-value="/maquina_stream/42/blocks"
|
|
9
|
+
// data-ms-repair-interval-value="4000">
|
|
10
|
+
//
|
|
11
|
+
// Action Cable gives no delivery guarantee, no ordering guarantee and no gap
|
|
12
|
+
// detection. Deltas are an optimization; this is where correctness lives.
|
|
13
|
+
//
|
|
14
|
+
// Four triggers, per docs/design.md:
|
|
15
|
+
//
|
|
16
|
+
// 1. final seal, always
|
|
17
|
+
// 2. a gap in the sequence
|
|
18
|
+
// 3. reconnect, or the tab becoming visible again
|
|
19
|
+
// 4. a periodic keyframe
|
|
20
|
+
//
|
|
21
|
+
// What it fetches is decided by digests, not by guesswork: the manifest is a
|
|
22
|
+
// few hundred bytes, the client compares it against its own DOM, and asks only
|
|
23
|
+
// for the blocks that differ. Repair therefore costs what has drifted, not what
|
|
24
|
+
// the message weighs.
|
|
25
|
+
export default class extends ApplicationController {
|
|
26
|
+
static values = {
|
|
27
|
+
manifestUrl: String,
|
|
28
|
+
blocksUrl: String,
|
|
29
|
+
interval: { type: Number, default: 4000 },
|
|
30
|
+
seq: { type: Number, default: 0 },
|
|
31
|
+
rollup: { type: String, default: "" }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
connect() {
|
|
35
|
+
this.onVisibility = () => { if (!document.hidden) this.repair("visible") }
|
|
36
|
+
this.onConnect = () => this.repair("reconnect")
|
|
37
|
+
this.onStreamFrame = (event) => this.observe(event)
|
|
38
|
+
|
|
39
|
+
document.addEventListener("visibilitychange", this.onVisibility)
|
|
40
|
+
document.addEventListener("turbo:before-stream-render", this.onStreamFrame)
|
|
41
|
+
window.addEventListener("online", this.onConnect)
|
|
42
|
+
|
|
43
|
+
this.start()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnect() {
|
|
47
|
+
this.stop()
|
|
48
|
+
document.removeEventListener("visibilitychange", this.onVisibility)
|
|
49
|
+
document.removeEventListener("turbo:before-stream-render", this.onStreamFrame)
|
|
50
|
+
window.removeEventListener("online", this.onConnect)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
start() {
|
|
54
|
+
this.stop()
|
|
55
|
+
if (this.intervalValue > 0) this.timer = setInterval(() => this.repair("keyframe"), this.intervalValue)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop() {
|
|
59
|
+
clearInterval(this.timer)
|
|
60
|
+
this.timer = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ------------------------------------------------------------------ triggers
|
|
64
|
+
|
|
65
|
+
// Every frame carries its sequence. A number that is not exactly one more
|
|
66
|
+
// than the last means a frame never arrived, and the DOM is now a guess.
|
|
67
|
+
observe(event) {
|
|
68
|
+
const stream = event.target
|
|
69
|
+
const seq = Number(stream.dataset.msSeq)
|
|
70
|
+
if (!Number.isFinite(seq) || seq === 0) return
|
|
71
|
+
|
|
72
|
+
const expected = this.seqValue + 1
|
|
73
|
+
this.seqValue = seq
|
|
74
|
+
|
|
75
|
+
if (this.seqValue > expected) this.repair("gap")
|
|
76
|
+
if (stream.dataset.msFrame === "final") this.repair("seal")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ------------------------------------------------------------------ repair
|
|
80
|
+
|
|
81
|
+
async repair(reason) {
|
|
82
|
+
if (this.running) return
|
|
83
|
+
this.running = true
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const manifest = await this.fetchManifest()
|
|
87
|
+
if (!manifest) return
|
|
88
|
+
|
|
89
|
+
// History behind the window disagrees, so the window is not enough to
|
|
90
|
+
// reconcile from. Rare, and the same work a cold page load does.
|
|
91
|
+
if (manifest.cutoff > 0 && manifest.rollup !== this.rollupValue) {
|
|
92
|
+
const full = await this.fetchManifest({ full: true })
|
|
93
|
+
if (full) await this.reconcile(full, reason)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await this.reconcile(manifest, reason)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A failed repair is not fatal: the next trigger tries again, and the
|
|
100
|
+
// final seal always fires.
|
|
101
|
+
this.notify("repair-failed", { reason, error: String(error) })
|
|
102
|
+
} finally {
|
|
103
|
+
this.running = false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async reconcile(manifest, reason) {
|
|
108
|
+
const stale = manifest.blocks.filter(([id, digest]) => this.digestOf(id) !== digest).map(([id]) => id)
|
|
109
|
+
this.rollupValue = manifest.rollup || ""
|
|
110
|
+
if (stale.length === 0) return
|
|
111
|
+
|
|
112
|
+
const html = await this.fetchBlocks(stale)
|
|
113
|
+
if (!html) return
|
|
114
|
+
|
|
115
|
+
// Silent by construction: suppress before the morph, resume after it. A
|
|
116
|
+
// repair that re-animates text already on screen is the strobe Phase 0
|
|
117
|
+
// exists to prevent.
|
|
118
|
+
this.suppressReveal()
|
|
119
|
+
try {
|
|
120
|
+
window.Turbo.renderStreamMessage(html)
|
|
121
|
+
await this.nextFrame()
|
|
122
|
+
} finally {
|
|
123
|
+
this.resumeReveal()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.notify("repaired", { reason, blocks: stale.length })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The digest the client can compute for what it currently holds. The server
|
|
130
|
+
// digests the block's rendered HTML, so the client digests the same bytes.
|
|
131
|
+
digestOf(id) {
|
|
132
|
+
const node = document.getElementById(id)
|
|
133
|
+
return node ? node.dataset.msBlockDigest : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async fetchManifest(params = {}) {
|
|
137
|
+
const url = new URL(this.manifestUrlValue, window.location.origin)
|
|
138
|
+
if (params.full) url.searchParams.set("full", "1")
|
|
139
|
+
|
|
140
|
+
const response = await fetch(url, { headers: { Accept: "application/json" } })
|
|
141
|
+
return response.ok ? response.json() : null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async fetchBlocks(ids) {
|
|
145
|
+
const url = new URL(this.blocksUrlValue, window.location.origin)
|
|
146
|
+
ids.forEach((id) => url.searchParams.append("ids[]", id))
|
|
147
|
+
|
|
148
|
+
const response = await fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" } })
|
|
149
|
+
return response.ok ? response.text() : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The suppression seam. Dispatched as events rather than called directly, so
|
|
153
|
+
// it works for whichever reveal strategy Phase 0 settles on: a controller
|
|
154
|
+
// that needs to stay quiet listens, and one that is structurally immune
|
|
155
|
+
// (strategy C) simply does not.
|
|
156
|
+
suppressReveal() {
|
|
157
|
+
this.element.dispatchEvent(new CustomEvent("ms:suppress", { bubbles: false }))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
resumeReveal() {
|
|
161
|
+
this.element.dispatchEvent(new CustomEvent("ms:resume", { bubbles: false }))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
nextFrame() {
|
|
165
|
+
return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import ApplicationController from "maquina_stream/controllers/application_controller"
|
|
2
|
+
|
|
3
|
+
// `ms-reveal` — the streaming reveal, mounted on the message element.
|
|
4
|
+
//
|
|
5
|
+
// <div id="ms-msg-42" data-controller="ms-stream ms-repair ms-reveal" data-ms-streaming>
|
|
6
|
+
// <div id="ms-42-b0" data-ms-block …>…</div>
|
|
7
|
+
// </div>
|
|
8
|
+
//
|
|
9
|
+
// What it does: when a block's text grows, the newly arrived tail is wrapped in
|
|
10
|
+
// one `<span data-ms-revealing>`, that span is animated in, and on
|
|
11
|
+
// `animationend` the span is unwrapped again. One live extra node per block —
|
|
12
|
+
// not one per word — and nothing left in the DOM once the animation ends.
|
|
13
|
+
//
|
|
14
|
+
// ## Why not the CSS mask
|
|
15
|
+
//
|
|
16
|
+
// Phase 0's strategy C masked the whole block with a horizontal gradient whose
|
|
17
|
+
// edge was placed at `revealed / total` **characters**. A character fraction and
|
|
18
|
+
// a horizontal position are the same number only while a block occupies one
|
|
19
|
+
// line. On a block wrapped over three lines each line ends at ~94% of the
|
|
20
|
+
// block's width, so a mask edge at 75% hid the last fifth of *every* line —
|
|
21
|
+
// including lines the reader finished seconds ago — and swept them back in on
|
|
22
|
+
// the next frame. That is the flash the review saw on the first few lines of a
|
|
23
|
+
// block, fading out as the fraction approached 100%. The geometry is wrong, not
|
|
24
|
+
// the easing: no gradient stop fixes a mapping from characters to pixels that
|
|
25
|
+
// does not exist.
|
|
26
|
+
//
|
|
27
|
+
// Wrapping the tail is reading-order correct *by construction*. There is no
|
|
28
|
+
// character-to-pixel mapping anywhere, because the thing that animates is the
|
|
29
|
+
// new text itself.
|
|
30
|
+
//
|
|
31
|
+
// ## Why the span is temporary
|
|
32
|
+
//
|
|
33
|
+
// docs/javascript.md: a block's digest covers what the
|
|
34
|
+
// block says, so any chrome the reveal leaves behind is drift the manifest diff
|
|
35
|
+
// cannot see and repair cannot correct — two tabs that lost different frames
|
|
36
|
+
// end up visibly different with agreeing digests. The span therefore exists
|
|
37
|
+
// only for the length of one animation, is removed before a repair morph
|
|
38
|
+
// (`ms:suppress`), and is removed on seal, on disconnect and before Turbo
|
|
39
|
+
// caches the page.
|
|
40
|
+
//
|
|
41
|
+
// Nothing here assigns a DOM-derived string as HTML. Per docs/security.md
|
|
42
|
+
// every value a controller reads from the DOM is untrusted; this one moves
|
|
43
|
+
// existing text nodes and never re-parses them.
|
|
44
|
+
export default class extends ApplicationController {
|
|
45
|
+
static values = {
|
|
46
|
+
// Milliseconds. The stylesheet reads `var(--ms-reveal-duration, 320ms)`;
|
|
47
|
+
// setting this writes the property rather than pinning the duration twice.
|
|
48
|
+
duration: { type: Number, default: 320 },
|
|
49
|
+
// A host that wants the markup but not the animation says so once.
|
|
50
|
+
disabled: Boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Both fixed by the DOM contract (docs/javascript.md), like `data-ms-block`
|
|
54
|
+
// itself: the stylesheet and the controller have to agree, and an attribute a
|
|
55
|
+
// host could rename is an attribute the shipped CSS would miss.
|
|
56
|
+
static blockSelector = "[data-ms-block]"
|
|
57
|
+
static markerAttribute = "data-ms-revealing"
|
|
58
|
+
|
|
59
|
+
connect() {
|
|
60
|
+
// Transient, and deliberately not a Value. Suppression spans exactly one
|
|
61
|
+
// repair morph; writing it into the DOM would put client state on the
|
|
62
|
+
// element the morph is about to rewrite.
|
|
63
|
+
this.suppressed = false
|
|
64
|
+
|
|
65
|
+
// Block id → characters already revealed. Keyed by id rather than by node,
|
|
66
|
+
// because a repair morph may replace the element while keeping its
|
|
67
|
+
// identity (ids are index-derived and stable).
|
|
68
|
+
this.revealed = new Map()
|
|
69
|
+
|
|
70
|
+
// Spans currently animating, each with the timer that force-unwraps it if
|
|
71
|
+
// `animationend` never arrives (a hidden or display:none block never fires
|
|
72
|
+
// one, and an orphaned span would then outlive its animation).
|
|
73
|
+
this.pending = new Map()
|
|
74
|
+
|
|
75
|
+
if (this.hasDurationValue) {
|
|
76
|
+
this.element.style.setProperty("--ms-reveal-duration", `${this.durationValue}ms`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `ms-repair` dispatches these on the message element, without bubbling.
|
|
80
|
+
// They are wired here rather than through `data-action` because the markup
|
|
81
|
+
// a host renders (docs/javascript.md) carries `data-controller` and no
|
|
82
|
+
// actions — a reveal that needed one more attribute would silently never
|
|
83
|
+
// suppress, which is the failure mode docs/javascript.md warns about.
|
|
84
|
+
this.onSuppress = () => this.suppress()
|
|
85
|
+
this.onResume = () => this.resume()
|
|
86
|
+
this.root.addEventListener("ms:suppress", this.onSuppress)
|
|
87
|
+
this.root.addEventListener("ms:resume", this.onResume)
|
|
88
|
+
|
|
89
|
+
// A tab that was hidden through half a message must not replay it on
|
|
90
|
+
// return; the baseline moves forward instead.
|
|
91
|
+
this.onVisibility = () => { if (!document.hidden) this.sync() }
|
|
92
|
+
document.addEventListener("visibilitychange", this.onVisibility)
|
|
93
|
+
|
|
94
|
+
this.onAnimationEnd = (event) => this.settle(event)
|
|
95
|
+
this.element.addEventListener("animationend", this.onAnimationEnd)
|
|
96
|
+
|
|
97
|
+
// Two observers, two jobs. Content growth drives the reveal; the message
|
|
98
|
+
// element's `data-ms-streaming` decides whether there is a reveal at all.
|
|
99
|
+
this.contentObserver = new MutationObserver((records) => this.observe(records))
|
|
100
|
+
this.contentObserver.observe(this.element, { childList: true, subtree: true, characterData: true })
|
|
101
|
+
|
|
102
|
+
this.stateObserver = new MutationObserver(() => this.arm())
|
|
103
|
+
this.stateObserver.observe(this.root, { attributes: true, attributeFilter: ["data-ms-streaming"] })
|
|
104
|
+
|
|
105
|
+
this.arm()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
disconnect() {
|
|
109
|
+
this.contentObserver?.disconnect()
|
|
110
|
+
this.stateObserver?.disconnect()
|
|
111
|
+
this.root.removeEventListener("ms:suppress", this.onSuppress)
|
|
112
|
+
this.root.removeEventListener("ms:resume", this.onResume)
|
|
113
|
+
document.removeEventListener("visibilitychange", this.onVisibility)
|
|
114
|
+
this.element.removeEventListener("animationend", this.onAnimationEnd)
|
|
115
|
+
this.unwrapAll()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Turbo caches the page as the reader left it. `index.js` calls this before
|
|
119
|
+
// the snapshot, so a cached message is never restored mid-reveal.
|
|
120
|
+
teardown() {
|
|
121
|
+
this.unwrapAll()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------- suppression
|
|
125
|
+
|
|
126
|
+
// The seam `ms-repair` drives. Both are idempotent, and both are safe on a
|
|
127
|
+
// sealed message — a repair can land on one.
|
|
128
|
+
suppress() {
|
|
129
|
+
this.suppressed = true
|
|
130
|
+
this.unwrapAll()
|
|
131
|
+
this.sync()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resume without re-revealing. The morph that just landed put text on screen
|
|
135
|
+
// the reader has already seen, so the baseline moves to the current length:
|
|
136
|
+
// the next delta reveals its tail, not the whole block.
|
|
137
|
+
resume() {
|
|
138
|
+
this.suppressed = false
|
|
139
|
+
this.sync()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ------------------------------------------------------------------- arming
|
|
143
|
+
|
|
144
|
+
// A sealed message does not animate. The host owns `data-ms-streaming` and
|
|
145
|
+
// stamps it from `maquina_stream_open?`; the seal removing it takes the
|
|
146
|
+
// reveal down live.
|
|
147
|
+
arm() {
|
|
148
|
+
if (this.active) {
|
|
149
|
+
this.sync()
|
|
150
|
+
} else {
|
|
151
|
+
this.unwrapAll()
|
|
152
|
+
this.sync()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get active() {
|
|
157
|
+
return this.streaming && !this.disabledValue && !this.reducedMotion
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Reduced motion is answered here and not only in CSS. With `animation: none`
|
|
161
|
+
// no `animationend` ever fires, so a span wrapped anyway would never be
|
|
162
|
+
// unwrapped — the honest reading of the preference is to not wrap at all.
|
|
163
|
+
get reducedMotion() {
|
|
164
|
+
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------- revealing
|
|
168
|
+
|
|
169
|
+
observe(records) {
|
|
170
|
+
// Wrapping and unwrapping are themselves mutations. They never change a
|
|
171
|
+
// block's text length, so they cannot start a reveal, but they are dropped
|
|
172
|
+
// here rather than walked.
|
|
173
|
+
if (this.mutating) return
|
|
174
|
+
|
|
175
|
+
if (!this.active || this.suppressed || document.hidden) {
|
|
176
|
+
this.sync()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const touched = new Set()
|
|
181
|
+
records.forEach((record) => {
|
|
182
|
+
const block = this.blockFor(record.target)
|
|
183
|
+
if (block) touched.add(block)
|
|
184
|
+
record.addedNodes?.forEach((node) => {
|
|
185
|
+
const added = this.blockFor(node)
|
|
186
|
+
if (added) touched.add(added)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
touched.forEach((block) => this.reveal(block))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
reveal(block) {
|
|
194
|
+
const id = this.identify(block)
|
|
195
|
+
const length = block.textContent.length
|
|
196
|
+
const seen = this.revealed.get(id) ?? 0
|
|
197
|
+
|
|
198
|
+
this.revealed.set(id, length)
|
|
199
|
+
if (length <= seen) return
|
|
200
|
+
|
|
201
|
+
this.surgery(() => this.wrapTail(block, seen).forEach((span) => this.animate(span)))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Walk the block's text in document order and wrap everything past `offset`.
|
|
205
|
+
//
|
|
206
|
+
// Text arriving at the end of one text node — the streaming case — yields
|
|
207
|
+
// exactly one span. A frame that also appends new elements (a new list item,
|
|
208
|
+
// a new paragraph inside the block) yields one span per newly written text
|
|
209
|
+
// node, which is bounded by what arrived rather than by what the block holds.
|
|
210
|
+
// A single span cannot span element boundaries without moving text out of the
|
|
211
|
+
// structure it belongs to, and restructuring a block to animate it is exactly
|
|
212
|
+
// the drift this controller exists to avoid.
|
|
213
|
+
wrapTail(block, offset) {
|
|
214
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
|
|
215
|
+
const tails = []
|
|
216
|
+
let consumed = 0
|
|
217
|
+
let node
|
|
218
|
+
|
|
219
|
+
while ((node = walker.nextNode())) {
|
|
220
|
+
const start = consumed
|
|
221
|
+
consumed += node.nodeValue.length
|
|
222
|
+
if (consumed <= offset) continue
|
|
223
|
+
if (node.parentElement?.hasAttribute(this.constructor.markerAttribute)) continue
|
|
224
|
+
|
|
225
|
+
tails.push([node, Math.max(0, offset - start)])
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return tails.map(([text, at]) => this.wrap(at > 0 ? text.splitText(at) : text)).filter(Boolean)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
wrap(text) {
|
|
232
|
+
if (!text.nodeValue.length || !text.parentNode) return null
|
|
233
|
+
|
|
234
|
+
const span = document.createElement("span")
|
|
235
|
+
span.setAttribute(this.constructor.markerAttribute, "")
|
|
236
|
+
text.parentNode.insertBefore(span, text)
|
|
237
|
+
span.appendChild(text)
|
|
238
|
+
return span
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
animate(span) {
|
|
242
|
+
// The fallback timer, not the animation, is what guarantees the span goes
|
|
243
|
+
// away. `animationend` unwraps first in every normal case.
|
|
244
|
+
const timer = setTimeout(() => this.unwrap(span), this.durationValue + 400)
|
|
245
|
+
this.pending.set(span, timer)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
settle(event) {
|
|
249
|
+
const span = event.target
|
|
250
|
+
if (span instanceof Element && span.hasAttribute(this.constructor.markerAttribute)) this.unwrap(span)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Replace the span with the text it holds and normalize, so the block is
|
|
254
|
+
// plain text again and consecutive text nodes do not accumulate over a long
|
|
255
|
+
// message.
|
|
256
|
+
unwrap(span) {
|
|
257
|
+
clearTimeout(this.pending.get(span))
|
|
258
|
+
this.pending.delete(span)
|
|
259
|
+
|
|
260
|
+
const parent = span.parentNode
|
|
261
|
+
if (!parent) return
|
|
262
|
+
|
|
263
|
+
this.surgery(() => {
|
|
264
|
+
while (span.firstChild) parent.insertBefore(span.firstChild, span)
|
|
265
|
+
span.remove()
|
|
266
|
+
parent.normalize()
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
unwrapAll() {
|
|
271
|
+
Array.from(this.element.querySelectorAll(`[${this.constructor.markerAttribute}]`))
|
|
272
|
+
.forEach((span) => this.unwrap(span))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Our own DOM writes, fenced off from the observer that watches for the
|
|
276
|
+
// server's.
|
|
277
|
+
surgery(work) {
|
|
278
|
+
const previous = this.mutating
|
|
279
|
+
this.mutating = true
|
|
280
|
+
try {
|
|
281
|
+
work()
|
|
282
|
+
} finally {
|
|
283
|
+
this.contentObserver?.takeRecords()
|
|
284
|
+
this.mutating = previous
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Re-baseline every block to what is currently on screen. Anything already
|
|
289
|
+
// rendered counts as seen, which is what keeps a reload, a background tab and
|
|
290
|
+
// a repair from replaying text the reader has read.
|
|
291
|
+
sync() {
|
|
292
|
+
this.blocks.forEach((block) => this.revealed.set(this.identify(block), block.textContent.length))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --------------------------------------------------------------------- DOM
|
|
296
|
+
|
|
297
|
+
// The element the host stamps `data-ms-streaming` on, and the element
|
|
298
|
+
// `ms-repair` dispatches suppression on. Normally `this.element` itself; the
|
|
299
|
+
// lookup only matters for a host that nests the controller.
|
|
300
|
+
get root() {
|
|
301
|
+
return this.messageElement || this.element
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
get blocks() {
|
|
305
|
+
return Array.from(this.element.querySelectorAll(this.constructor.blockSelector))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
blockFor(node) {
|
|
309
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
|
|
310
|
+
const block = element?.closest(this.constructor.blockSelector)
|
|
311
|
+
return block && this.element.contains(block) ? block : null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Block ids are index-derived and stable across a morph (docs/streaming.md).
|
|
315
|
+
// A block without one is still revealed; it just cannot be tracked across a
|
|
316
|
+
// repair, so it re-baselines instead of re-revealing.
|
|
317
|
+
identify(block) {
|
|
318
|
+
return block.id || `@${block.dataset.msBlockIndex ?? this.blocks.indexOf(block)}`
|
|
319
|
+
}
|
|
320
|
+
}
|