11<script lang =" ts" >
2- import { Keymap , MarkdownRenderer , loadMathJax } from " obsidian" ;
2+ import { Component , Keymap , MarkdownRenderer , loadMathJax } from " obsidian" ;
33import { onDestroy } from " svelte" ;
44import { getPlugin } from " ../../stores/state.svelte" ;
5+ import { VIEW_READY_EVENT } from " ../../genview/viewFrame" ;
56import { findSealableEnd } from " ../../utils/streamingMarkdown" ;
67import { openTagSearch } from " ../../utils/tagSearch" ;
78import { VIEW_TYPE_CHAT } from " ../../views/chat/Chat" ;
@@ -155,9 +156,27 @@ function normalizeLinks(containerEl: HTMLElement) {
155156// untouched, and a live tail — the block still being written — that is the only
156157// part re-parsed each frame. Without this the whole accumulated reply was torn
157158// down and re-parsed every frame, O(length) per frame, which pinned the main
158- // thread for the duration of a long reply (#482). Outside streaming, and when the
159- // reply settles, the content is rendered as one document, so link-reference and
160- // footnote definitions resolve exactly as before.
159+ // thread for the duration of a long reply (#482). The tail's staging element
160+ // carries `s2b-md-tail` while it renders, so a block processor can tell "still
161+ // being written" (show a placeholder) from "sealed" (render for real) — see
162+ // `genview/registerViewBlocks.ts`.
163+ //
164+ // Outside streaming — a settled reply's first render, and the moment a streamed
165+ // reply settles — the content is rendered as one document, so every construct
166+ // resolves exactly as a whole-document parse does (reference definitions, HTML
167+ // blocks and loose lists that a seam between segments would cut). That render is
168+ // double-buffered: it happens in a hidden wrapper while the previous DOM stays on
169+ // screen, and swaps in once the wrapper's view frames report ready (capped), so
170+ // settling never blanks a frame that was already showing. The wrapper is then
171+ // unwrapped so the container stays flat (call sites style with direct-child
172+ // selectors such as `[&>p]`) — unless it holds an iframe, which a DOM move would
173+ // reload; only then does the wrapper stay.
174+
175+ const TAIL_CLASS = " s2b-md-tail" ;
176+ const DOC_CLASS = " s2b-md-doc" ;
177+ const DOC_PENDING_CLASS = " s2b-md-doc-pending" ;
178+ /** Upper bound on waiting for a settled document's view frames before swapping it in. */
179+ const VIEW_READY_GRACE_MS = 2000 ;
161180
162181let latest = { content: " " , sourcePath: " " , enableMath: true , streaming: false };
163182let frame: number | null = null ;
@@ -168,6 +187,23 @@ let destroyed = false;
168187let sealedText = " " ;
169188/** Nodes belonging to the live tail; replaced on every render while streaming. */
170189let tailNodes: ChildNode [] = [];
190+ /** The last render was a whole document (so a resumed stream must start over). */
191+ let settled = false ;
192+ /**
193+ * Owners of the render children Obsidian's processors attach to rendered blocks
194+ * (Dataview tables, `s2b-view` frames, embeds). Handing the renderer the plugin
195+ * itself kept every such child alive until plugin unload; these are unloaded when
196+ * the DOM they belong to goes away — the tail's on every tail render, the
197+ * document's on reset, swap and destroy.
198+ */
199+ let docComponent = loadedComponent ();
200+ let tailComponent: Component | null = null ;
201+
202+ function loadedComponent(): Component {
203+ const component = new Component ();
204+ component .load ();
205+ return component ;
206+ }
171207
172208$effect (() => {
173209 // Read every reactive dep here so the effect re-runs when any of them change;
@@ -179,6 +215,8 @@ $effect(() => {
179215onDestroy (() => {
180216 destroyed = true ;
181217 if (frame !== null ) cancelAnimationFrame (frame );
218+ tailComponent ?.unload ();
219+ docComponent .unload ();
182220});
183221
184222function schedule() {
@@ -213,35 +251,83 @@ async function renderLatest() {
213251 if (destroyed || ! container ) return ;
214252
215253 if (! live ) {
216- resetDom ();
217- await appendSegment (text , path );
254+ await renderDocument (text , path );
218255 return ;
219256 }
220257
221258 // Streaming: content normally extends what is already sealed. Anything else
222- // (a reset at a tool-call boundary, an edit) starts over.
223- if (! text .startsWith (sealedText )) resetDom ();
259+ // (a settled document on screen, a reset at a tool-call boundary, an edit)
260+ // starts over.
261+ if (settled || ! text .startsWith (sealedText )) resetDom ();
224262 const remainder = text .slice (sealedText .length );
225263 const sealEnd = findSealableEnd (remainder );
226264 removeTail ();
227265 if (sealEnd > 0 ) {
228266 const segment = remainder .slice (0 , sealEnd );
229- await appendSegment (segment , path );
267+ await appendSegment (segment , path , docComponent );
230268 if (destroyed || ! container ) return ;
231269 sealedText += segment ;
232270 }
233- tailNodes = await appendSegment (text .slice (sealedText .length ), path );
271+ tailComponent = loadedComponent ();
272+ tailNodes = await appendSegment (text .slice (sealedText .length ), path , tailComponent , true );
273+ }
274+
275+ /** Whole-document render, double-buffered behind whatever is currently showing. */
276+ async function renderDocument(text : string , path : string ) {
277+ if (! container ) return ;
278+ const next = container .createDiv ({ cls: ` ${DOC_CLASS } ${DOC_PENDING_CLASS } ` });
279+ const nextComponent = loadedComponent ();
280+ await renderInto (next , text , path , nextComponent );
281+ if (! destroyed && container ) await waitForViews (next );
282+ if (destroyed || ! container ) {
283+ nextComponent .unload ();
284+ next .remove ();
285+ return ;
286+ }
287+ for (const node of [... container .childNodes ]) if (node !== next ) node .remove ();
288+ next .classList .remove (DOC_PENDING_CLASS );
289+ if (! next .querySelector (" iframe" )) next .replaceWith (... next .childNodes );
290+ docComponent .unload ();
291+ tailComponent ?.unload ();
292+ tailComponent = null ;
293+ tailNodes = [];
294+ sealedText = " " ;
295+ docComponent = nextComponent ;
296+ settled = true ;
297+ }
298+
299+ /** Resolve once every view frame under `root` has reported ready, or after the grace period. */
300+ function waitForViews(root : HTMLElement ): Promise <void > {
301+ let pending = root .querySelectorAll (" .s2b-view-frame:not([data-s2b-view-ready])" ).length ;
302+ if (pending === 0 ) return Promise .resolve ();
303+ return new Promise ((resolve ) => {
304+ const finish = () => {
305+ root .removeEventListener (VIEW_READY_EVENT , onReady );
306+ window .clearTimeout (timer );
307+ resolve ();
308+ };
309+ const onReady = () => {
310+ if (-- pending <= 0 ) finish ();
311+ };
312+ root .addEventListener (VIEW_READY_EVENT , onReady );
313+ const timer = window .setTimeout (finish , VIEW_READY_GRACE_MS );
314+ });
234315}
235316
236317function resetDom() {
318+ removeTail ();
237319 container ?.empty ();
238320 sealedText = " " ;
239- tailNodes = [];
321+ settled = false ;
322+ docComponent .unload ();
323+ docComponent = loadedComponent ();
240324}
241325
242326function removeTail() {
243327 for (const node of tailNodes ) node .remove ();
244328 tailNodes = [];
329+ tailComponent ?.unload ();
330+ tailComponent = null ;
245331}
246332
247333/**
@@ -251,21 +337,30 @@ function removeTail() {
251337 * the container stays flat: no wrapper element, so `:first-child`/`:last-child`
252338 * styling on the container keeps working across segment seams.
253339 */
254- async function appendSegment(markdown : string , path : string ): Promise <ChildNode []> {
340+ async function appendSegment(markdown : string , path : string , component : Component , tail = false ): Promise <ChildNode []> {
255341 if (! markdown || ! container ) return [];
256- const staging = container .createDiv ({ attr: { style: " display: contents" } });
257- await MarkdownRenderer . render ( plugin . app , markdown , staging , path , plugin );
342+ const staging = container .createDiv ({ cls: tail ? TAIL_CLASS : " " , attr: { style: " display: contents" } });
343+ await renderInto ( staging , markdown , path , component );
258344 if (destroyed || ! container ) {
259345 staging .remove ();
260346 return [];
261347 }
262- normalizeLinks (staging );
263- // The renderer may tag the target element (e.g. `markdown-rendered`); carry that over.
264- for (const cls of staging .classList ) container .classList .add (cls );
265348 const nodes = [... staging .childNodes ];
266349 staging .replaceWith (... nodes );
267350 return nodes ;
268351}
352+
353+ /** Render `markdown` into `target` (attached) and normalise the result. */
354+ async function renderInto(target : HTMLElement , markdown : string , path : string , component : Component ) {
355+ if (! markdown ) return ;
356+ await MarkdownRenderer .render (plugin .app , markdown , target , path , component );
357+ if (destroyed || ! container ) return ;
358+ normalizeLinks (target );
359+ // The renderer may tag the target element (e.g. `markdown-rendered`); carry that over.
360+ for (const cls of target .classList ) {
361+ if (! cls .startsWith (" s2b-md-" )) container .classList .add (cls );
362+ }
363+ }
269364 </script >
270365
271366<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -278,3 +373,20 @@ async function appendSegment(markdown: string, path: string): Promise<ChildNode[
278373 onmouseover ={handleMouseOver }
279374 onmouseout ={handleMouseOut }
280375></div >
376+
377+ <style >
378+ /* Anchor for the hidden double-buffer wrapper below. */
379+ div {
380+ position : relative ;
381+ }
382+ /* A settling document renders here, off-flow and invisible but with the real
383+ width (post-processors measure layout), then swaps in. */
384+ :global(.s2b-md-doc-pending ) {
385+ position : absolute ;
386+ top : 0 ;
387+ left : 0 ;
388+ width : 100% ;
389+ visibility : hidden ;
390+ pointer-events : none ;
391+ }
392+ </style >
0 commit comments