markstream-svelte 2.0.14 → 2.0.16

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.
@@ -137,6 +137,8 @@
137
137
  let lifecycleId = $state(0)
138
138
  let heightSyncRaf: number | null = $state(null)
139
139
  let heightSyncDisposables: Array<{ dispose?: () => void } | (() => void)> = $state([])
140
+ let editorSurfaceResizeObserver: ResizeObserver | null = $state(null)
141
+ let editorSurfaceResizeTarget: HTMLElement | null = $state(null)
140
142
  let lastLayoutWidth: number | null = $state(null)
141
143
  let lastLayoutHeight: number | null = $state(null)
142
144
  let lastThemeRequest = $state('')
@@ -706,6 +708,10 @@ ${configuredUnsafeCSS}`.trim(),
706
708
  function nextAnimationFrame() {
707
709
  if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function')
708
710
  return Promise.resolve()
711
+ // Animation frames never run in hidden tabs; fall back to a timer so the
712
+ // editor handoff loop cannot stall indefinitely while the page is hidden.
713
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden')
714
+ return new Promise<void>(resolve => setTimeout(() => resolve(), 0))
709
715
  return new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
710
716
  }
711
717
 
@@ -843,6 +849,39 @@ ${configuredUnsafeCSS}`.trim(),
843
849
  catch {}
844
850
  }
845
851
 
852
+ function bindEditorSurfaceResizeSync() {
853
+ if (typeof ResizeObserver === 'undefined' || !editorHost)
854
+ return
855
+ const surface = getVisualEditorSurface()
856
+ if (editorSurfaceResizeObserver && surface === editorSurfaceResizeTarget)
857
+ return
858
+ unbindEditorSurfaceResizeSync()
859
+ if (!surface)
860
+ return
861
+ // The diff surface re-renders at a different height when unchanged regions
862
+ // collapse/expand and when a settled stream replaces the partial layout.
863
+ // Those changes do not always surface through the editor events bound
864
+ // above, so observe the rendered surface and re-sync the host height when
865
+ // the rendered height no longer matches the host.
866
+ editorSurfaceResizeObserver = new ResizeObserver(() => {
867
+ if (!editorHost || collapsed)
868
+ return
869
+ const renderedHeight = measureRenderedDiffHeight(editorHost)
870
+ const hostHeight = Math.ceil(editorHost.getBoundingClientRect().height || 0)
871
+ if (renderedHeight == null || Math.abs(renderedHeight - hostHeight) <= 1)
872
+ return
873
+ scheduleEditorHeightSync()
874
+ })
875
+ editorSurfaceResizeObserver.observe(surface)
876
+ editorSurfaceResizeTarget = surface
877
+ }
878
+
879
+ function unbindEditorSurfaceResizeSync() {
880
+ editorSurfaceResizeObserver?.disconnect()
881
+ editorSurfaceResizeObserver = null
882
+ editorSurfaceResizeTarget = null
883
+ }
884
+
846
885
  function bindEditorHeightSync() {
847
886
  clearEditorHeightSyncBindings()
848
887
  const bind = (source: any, eventName: 'onDidContentSizeChange' | 'onDidLayoutChange') => {
@@ -871,6 +910,7 @@ ${configuredUnsafeCSS}`.trim(),
871
910
  bind(modifiedEditor, 'onDidContentSizeChange')
872
911
  bind(originalEditor, 'onDidLayoutChange')
873
912
  bind(modifiedEditor, 'onDidLayoutChange')
913
+ bindEditorSurfaceResizeSync()
874
914
  return
875
915
  }
876
916
 
@@ -880,6 +920,7 @@ ${configuredUnsafeCSS}`.trim(),
880
920
  }
881
921
 
882
922
  function clearEditorHeightSyncBindings() {
923
+ unbindEditorSurfaceResizeSync()
883
924
  for (const disposable of heightSyncDisposables) {
884
925
  try {
885
926
  if (typeof disposable === 'function')
@@ -911,22 +952,33 @@ ${configuredUnsafeCSS}`.trim(),
911
952
  editorHost.style.height = `${nextHeight}px`
912
953
  editorHost.style.minHeight = `${nextHeight}px`
913
954
  editorHost.style.maxHeight = expanded || !Number.isFinite(maxHeight) ? 'none' : `${Math.ceil(maxHeight)}px`
914
- editorHost.style.overflow = diff ? 'hidden' : (contentHeight > nextHeight ? 'auto' : 'hidden')
955
+ editorHost.style.overflow = contentHeight > nextHeight ? 'auto' : 'hidden'
915
956
  layoutEditor(nextHeight)
957
+ if (diff)
958
+ bindEditorSurfaceResizeSync()
916
959
  }
917
960
 
918
961
  function getEditorHostMinHeight() {
919
962
  if (!editorHost || typeof window === 'undefined')
920
963
  return 0
921
- const values = [
922
- window.getComputedStyle(editorHost.parentElement || editorHost).minHeight,
923
- window.getComputedStyle(editorHost).minHeight,
924
- ]
925
- for (const value of values) {
926
- const parsed = Number.parseFloat(value)
927
- if (Number.isFinite(parsed) && parsed > 0)
928
- return Math.ceil(parsed)
929
- }
964
+ // A consumer-configured min-height on the body element is a real floor.
965
+ const parentMinHeight = window.getComputedStyle(editorHost.parentElement || editorHost).minHeight
966
+ const parsedParentMinHeight = Number.parseFloat(parentMinHeight)
967
+ if (Number.isFinite(parsedParentMinHeight) && parsedParentMinHeight > 0)
968
+ return Math.ceil(parsedParentMinHeight)
969
+ // `syncEditorHostHeight` writes the host's inline min-height, so reading the
970
+ // computed value back would pin the host at its largest historical height
971
+ // and stop the container from shrinking when the diff surface collapses
972
+ // unchanged regions. Temporarily drop the inline value so the computed
973
+ // min-height reflects only a consumer/CSS-configured floor.
974
+ const inlineMinHeight = editorHost.style.minHeight
975
+ if (inlineMinHeight)
976
+ editorHost.style.minHeight = ''
977
+ const configuredMinHeight = Number.parseFloat(window.getComputedStyle(editorHost).minHeight)
978
+ if (inlineMinHeight)
979
+ editorHost.style.minHeight = inlineMinHeight
980
+ if (Number.isFinite(configuredMinHeight) && configuredMinHeight > 0)
981
+ return Math.ceil(configuredMinHeight)
930
982
  return 0
931
983
  }
932
984
 
@@ -1101,7 +1153,7 @@ ${configuredUnsafeCSS}`.trim(),
1101
1153
  {#if !shouldDelayEditor}
1102
1154
  <div bind:this={editorHost} class:is-hidden={!editorRevealed} class="code-editor-container"></div>
1103
1155
  {/if}
1104
- <div class:is-hidden={fallbackRetired} class="code-editor-fallback-surface">
1156
+ <div class:is-retired={fallbackRetired} class="code-editor-fallback-surface">
1105
1157
  <PreCodeNode
1106
1158
  class="code-pre-fallback"
1107
1159
  enhanceable={false}
@@ -5,6 +5,7 @@
5
5
  import { getD2 } from '../optional/d2'
6
6
  import { extractRenderedSvg, toSafeSvgMarkup } from '../sanitizeSvg'
7
7
  import { hideTooltip, showTooltipForAnchor } from '../tooltip/singletonTooltip'
8
+ import { createD2RenderSalt } from '../utils/d2RenderSalt'
8
9
  import { copyTextToClipboard, downloadSvgMarkup } from './shared/rich-block-helpers'
9
10
  import { getString } from './shared/node-helpers'
10
11
 
@@ -153,6 +154,7 @@
153
154
  }
154
155
  }
155
156
 
157
+ renderOptions.salt = renderOptions.salt ?? createD2RenderSalt()
156
158
  const rendered = await instance.render(diagram, renderOptions)
157
159
  if (!mounted || token !== renderToken)
158
160
  return
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { SvelteRenderableNode } from './shared/node-helpers'
3
3
  import { getString } from './shared/node-helpers'
4
+ import { findFootnoteElement } from '../utils/footnoteTarget'
4
5
 
5
6
  interface Props {
6
7
  node: SvelteRenderableNode
@@ -15,7 +16,7 @@
15
16
  event.preventDefault()
16
17
  if (typeof document === 'undefined' || !id)
17
18
  return
18
- const target = document.getElementById(`fnref-${id}`)
19
+ const target = findFootnoteElement(event.currentTarget, `fnref-${id}`)
19
20
  target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
20
21
  }
21
22
  </script>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { SvelteRenderableNode } from './shared/node-helpers'
3
3
  import { getString } from './shared/node-helpers'
4
+ import { findFootnoteElement } from '../utils/footnoteTarget'
4
5
 
5
6
  interface Props {
6
7
  node: SvelteRenderableNode
@@ -16,7 +17,7 @@
16
17
  event.preventDefault()
17
18
  if (typeof document === 'undefined' || !id)
18
19
  return
19
- const target = document.querySelector(href || '')
20
+ const target = findFootnoteElement(event.currentTarget, `fnref--${id}`)
20
21
  target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
21
22
  }
22
23
  </script>
@@ -7,6 +7,7 @@
7
7
  import { getMermaid } from '../optional/mermaid'
8
8
  import { hideTooltip, showTooltipForAnchor, type TooltipPlacement } from '../tooltip/singletonTooltip'
9
9
  import { getLanguageIcon } from '../utils/languageIcon'
10
+ import { createMermaidRenderId } from '../utils/mermaidRenderId'
10
11
  import { canParseOffthread, findPrefixOffthread } from '../workers/mermaidWorkerClient'
11
12
  import { clampPreviewHeight, estimateMermaidPreviewHeight, getMermaidDiagramKind, MERMAID_PREVIEW_MIN_HEIGHT, parsePositiveNumber, resolveDiagramMinPreviewHeight } from './shared/diagram-height'
12
13
  import { copyTextToClipboard, downloadSvgMarkup } from './shared/rich-block-helpers'
@@ -257,7 +258,7 @@
257
258
  }
258
259
 
259
260
  const rendered = await withTimeout(
260
- () => Promise.resolve(mermaid.render(`markstream-svelte-mermaid-${token}`, applyMermaidThemeTo(renderSource, theme))),
261
+ () => Promise.resolve(mermaid.render(createMermaidRenderId(), applyMermaidThemeTo(renderSource, theme))),
261
262
  fullRender ? fullRenderTimeoutMs : renderTimeoutMs,
262
263
  )
263
264
  if (!mounted || token !== renderToken)
@@ -315,7 +316,7 @@
315
316
  await withTimeout(() => Promise.resolve(mermaid.parse(themedSource)), parseTimeoutMs)
316
317
  return true
317
318
  }
318
- await withTimeout(() => Promise.resolve(mermaid.render(`markstream-svelte-mermaid-parse-${renderToken}`, themedSource)), parseTimeoutMs)
319
+ await withTimeout(() => Promise.resolve(mermaid.render(createMermaidRenderId('markstream-svelte-mermaid-parse'), themedSource)), parseTimeoutMs)
319
320
  return true
320
321
  }
321
322
 
@@ -340,12 +340,17 @@
340
340
  }
341
341
 
342
342
  const requestIdle = typeof window !== 'undefined' ? (window as any).requestIdleCallback as ((callback: (deadline: { timeRemaining?: () => number }) => void, options?: { timeout?: number }) => number) | undefined : undefined
343
- if (requestIdle) {
343
+ // Idle callbacks and animation frames never run in hidden tabs, which
344
+ // would freeze batch rendering until the page becomes visible again. Fall
345
+ // back to a timer so background rendering keeps progressing (the browser
346
+ // clamps hidden-tab timers, trading pace for progress).
347
+ const documentHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'
348
+ if (requestIdle && !documentHidden) {
344
349
  renderBatchIdle = requestIdle(run, { timeout: Math.max(0, Number(renderBatchIdleTimeoutMs) || 120) })
345
350
  return
346
351
  }
347
352
 
348
- if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
353
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' && !documentHidden) {
349
354
  renderBatchFrame = window.requestAnimationFrame(() => {
350
355
  renderBatchFrame = null
351
356
  if (delay > 0)
@@ -6,10 +6,11 @@ import { getMermaid } from './optional/mermaid';
6
6
  import { getStreamDiffsRuntime } from './optional/streamDiffs';
7
7
  import { extractRenderedSvg, toSafeSvgMarkup } from './sanitizeSvg';
8
8
  import { hideTooltip, showTooltipForAnchor } from './tooltip/singletonTooltip';
9
+ import { createD2RenderSalt } from './utils/d2RenderSalt';
10
+ import { createMermaidRenderId } from './utils/mermaidRenderId';
9
11
  import { normalizeKaTeXRenderInput } from './utils/normalizeKaTeXRenderInput';
10
12
  import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from './workers/katexWorkerClient';
11
13
  import { canParseOffthread, findPrefixOffthread } from './workers/mermaidWorkerClient';
12
- let mermaidRenderId = 0;
13
14
  const rootHandles = new WeakMap();
14
15
  const DARK_THEME_OVERRIDES = {
15
16
  N1: '#E5E7EB',
@@ -262,7 +263,7 @@ async function renderMermaid(root, cleanupFns, options, isActive) {
262
263
  sourceToRender = prefix;
263
264
  }
264
265
  }
265
- const renderId = `markstream-svelte-mermaid-${++mermaidRenderId}`;
266
+ const renderId = createMermaidRenderId();
266
267
  const rendered = await withTimeout(() => Promise.resolve(mermaid.render(renderId, applyMermaidThemeTo(sourceToRender, theme))), options.final === false
267
268
  ? Number((_d = (_c = options.mermaidProps) === null || _c === void 0 ? void 0 : _c.renderTimeoutMs) !== null && _d !== void 0 ? _d : 2500)
268
269
  : Number((_f = (_e = options.mermaidProps) === null || _e === void 0 ? void 0 : _e.fullRenderTimeoutMs) !== null && _f !== void 0 ? _f : 4000));
@@ -318,7 +319,7 @@ async function canParseMermaidWithFallback(source, theme, mermaid, options) {
318
319
  await withTimeout(() => Promise.resolve(mermaid.parse(themedSource)), parseTimeout);
319
320
  return true;
320
321
  }
321
- const renderId = `markstream-svelte-mermaid-parse-${++mermaidRenderId}`;
322
+ const renderId = createMermaidRenderId('markstream-svelte-mermaid-parse');
322
323
  await withTimeout(() => Promise.resolve(mermaid.render(renderId, themedSource)), parseTimeout);
323
324
  return true;
324
325
  }
@@ -436,7 +437,7 @@ async function renderInfographic(root, cleanupFns, options, isActive) {
436
437
  }
437
438
  }
438
439
  async function renderD2(root, cleanupFns, options, isActive) {
439
- var _a, _b, _c, _d, _e, _f;
440
+ var _a, _b, _c, _d, _e, _f, _g;
440
441
  const D2Ctor = await getD2();
441
442
  if (!D2Ctor || !isActive())
442
443
  return;
@@ -477,6 +478,7 @@ async function renderD2(root, cleanupFns, options, isActive) {
477
478
  ...(baseOverrides || {}),
478
479
  };
479
480
  }
481
+ renderOptions.salt = (_g = renderOptions.salt) !== null && _g !== void 0 ? _g : createD2RenderSalt();
480
482
  const renderResult = await instance.render(diagram, renderOptions);
481
483
  if (!isActive())
482
484
  return;
@@ -721,6 +723,12 @@ function normalizeVisibleCodeText(value) {
721
723
  async function waitForRenderFrame() {
722
724
  if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function')
723
725
  return;
726
+ // Animation frames never run in hidden tabs; resolve on a timer so block
727
+ // enhancement keeps progressing while the page is in the background.
728
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
729
+ await new Promise(resolve => setTimeout(resolve, 0));
730
+ return;
731
+ }
724
732
  await new Promise(resolve => window.requestAnimationFrame(() => resolve()));
725
733
  }
726
734
  function decodeDataPayload(value) {
package/dist/index.css CHANGED
@@ -1665,9 +1665,11 @@ body > div[id^="dmermaid-"] {
1665
1665
  background: var(--markstream-code-fallback-bg, var(--code-bg));
1666
1666
  }
1667
1667
 
1668
- .markstream-svelte .code-editor-fallback-surface.is-hidden {
1669
- visibility: hidden;
1670
- pointer-events: none;
1668
+ /* Once the enhanced editor owns the grid row, the retired fallback must leave
1669
+ layout entirely: visibility-only hiding keeps the row at the fallback's
1670
+ (uncollapsed) height, so collapsed diffs reserve space they no longer use. */
1671
+ .markstream-svelte .code-editor-fallback-surface.is-retired {
1672
+ display: none;
1671
1673
  }
1672
1674
 
1673
1675
  .code-block-body--expanded {
package/dist/index.px.css CHANGED
@@ -1665,9 +1665,11 @@ body > div[id^="dmermaid-"] {
1665
1665
  background: var(--markstream-code-fallback-bg, var(--code-bg));
1666
1666
  }
1667
1667
 
1668
- .markstream-svelte .code-editor-fallback-surface.is-hidden {
1669
- visibility: hidden;
1670
- pointer-events: none;
1668
+ /* Once the enhanced editor owns the grid row, the retired fallback must leave
1669
+ layout entirely: visibility-only hiding keeps the row at the fallback's
1670
+ (uncollapsed) height, so collapsed diffs reserve space they no longer use. */
1671
+ .markstream-svelte .code-editor-fallback-surface.is-retired {
1672
+ display: none;
1671
1673
  }
1672
1674
 
1673
1675
  .code-block-body--expanded {
@@ -1665,9 +1665,11 @@ body > div[id^="dmermaid-"] {
1665
1665
  background: var(--markstream-code-fallback-bg, var(--code-bg));
1666
1666
  }
1667
1667
 
1668
- .markstream-svelte .code-editor-fallback-surface.is-hidden {
1669
- visibility: hidden;
1670
- pointer-events: none;
1668
+ /* Once the enhanced editor owns the grid row, the retired fallback must leave
1669
+ layout entirely: visibility-only hiding keeps the row at the fallback's
1670
+ (uncollapsed) height, so collapsed diffs reserve space they no longer use. */
1671
+ .markstream-svelte .code-editor-fallback-surface.is-retired {
1672
+ display: none;
1671
1673
  }
1672
1674
 
1673
1675
  .code-block-body--expanded {
@@ -0,0 +1 @@
1
+ export declare function createD2RenderSalt(prefix?: string): string;
@@ -0,0 +1,13 @@
1
+ // D2 derives the ids and style-scope classes of its SVG output from a hash of the
2
+ // diagram source, so two identical diagrams would otherwise emit duplicate
3
+ // `id`/`url(#…)` targets and two document-level `.d2-<hash>` style scopes — one
4
+ // of them then wins and the diagrams get each other's colours. D2 exposes
5
+ // `RenderOptions.salt` for exactly this case ("useful when generating multiple
6
+ // identical diagrams to be included in the same HTML doc"). Every render takes a
7
+ // fresh salt from this module-scoped counter, which the block component and
8
+ // `enhanceRenderedHtml` share, so both render paths stay unique on one page.
9
+ let d2RenderSaltSequence = 0;
10
+ export function createD2RenderSalt(prefix = 'markstream-svelte-d2') {
11
+ d2RenderSaltSequence += 1;
12
+ return `${prefix}-${d2RenderSaltSequence}`;
13
+ }
@@ -0,0 +1 @@
1
+ export declare function findFootnoteElement(source: EventTarget | null | undefined, id: string): Element | null;
@@ -0,0 +1,21 @@
1
+ // Footnote ids are derived from the markdown label (`fnref-<label>` / `fnref--<label>`), so the
2
+ // same id can exist in several renderer trees on one page. Resolve the target inside the tree the
3
+ // clicked element belongs to, and only fall back to the document when that tree has no match.
4
+ const RENDERER_ROOT_SELECTOR = '.markdown-renderer';
5
+ function toIdSelector(id) {
6
+ return `[id="${id.replace(/["\\]/g, '\\$&')}"]`;
7
+ }
8
+ export function findFootnoteElement(source, id) {
9
+ var _a, _b;
10
+ if (typeof document === 'undefined' || typeof Element === 'undefined')
11
+ return null;
12
+ const selector = toIdSelector(id);
13
+ let scope = source instanceof Element ? source.closest(RENDERER_ROOT_SELECTOR) : null;
14
+ while (scope) {
15
+ const found = scope.querySelector(selector);
16
+ if (found)
17
+ return found;
18
+ scope = (_b = (_a = scope.parentElement) === null || _a === void 0 ? void 0 : _a.closest(RENDERER_ROOT_SELECTOR)) !== null && _b !== void 0 ? _b : null;
19
+ }
20
+ return document.querySelector(selector);
21
+ }
@@ -0,0 +1 @@
1
+ export declare function createMermaidRenderId(prefix?: string): string;
@@ -0,0 +1,13 @@
1
+ // Mermaid keys both its temporary render container (`#d<id>`) and the produced
2
+ // `<svg id>` by the id handed to `mermaid.render`, then resolves that id again
3
+ // when it binds interactions. The id therefore has to be unique across every
4
+ // diagram on the page — not just within one block instance: an instance-local
5
+ // counter made two blocks render as `markstream-svelte-mermaid-1`, which left
6
+ // one of the previews blank (issue #775). This module-scoped counter is shared
7
+ // by the block component and `enhanceRenderedHtml`, so both render paths stay
8
+ // collision-free on the same page.
9
+ let mermaidRenderSequence = 0;
10
+ export function createMermaidRenderId(prefix = 'markstream-svelte-mermaid') {
11
+ mermaidRenderSequence += 1;
12
+ return `${prefix}-${mermaidRenderSequence}`;
13
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "markstream-svelte",
3
3
  "type": "module",
4
- "version": "2.0.14",
4
+ "version": "2.0.16",
5
5
  "description": "Svelte 5 and SvelteKit streaming Markdown renderer for AI chat, LLM token streams, SSE/WebSocket output, incomplete Markdown, Mermaid, KaTeX, stream-diffs powered code blocks, and custom Svelte components.",
6
6
  "author": "Simon He",
7
7
  "license": "MIT",
@@ -100,8 +100,8 @@
100
100
  },
101
101
  "dependencies": {
102
102
  "@floating-ui/dom": "^1.8.0",
103
- "markstream-core": "2.0.14",
104
- "stream-markdown-parser": "1.2.17"
103
+ "markstream-core": "2.0.16",
104
+ "stream-markdown-parser": "1.2.18"
105
105
  },
106
106
  "devDependencies": {
107
107
  "@sveltejs/package": "^2.5.8",
@@ -120,6 +120,7 @@
120
120
  "dev": "vite dev",
121
121
  "preview": "vite preview",
122
122
  "typecheck": "svelte-check --tsconfig ./tsconfig.json",
123
+ "test": "vitest run --config vitest.config.ts",
123
124
  "check:core-published": "node ../../scripts/check-core-published.mjs --package-json package.json --core-package-json ../markstream-core/package.json",
124
125
  "check:workspace-deps-published": "node ../../scripts/check-workspace-deps-published.mjs --package-json package.json",
125
126
  "release": "pnpm run check:workspace-deps-published && bumpp --all --commit --no-tag --no-push --execute 'pnpm --workspace-root run docs:llms:generate' && DIST_TAG=\"$(node ../../scripts/dist-tag.mjs)\" && pnpm publish --access public --tag \"${DIST_TAG}\" && node ../../scripts/tag-package.mjs --package-json package.json --push"