structured-render 0.0.4 → 0.0.6

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.
@@ -1,4 +1,9 @@
1
- /** @returns Whether the new styles were set or not. */
1
+ /**
2
+ * Inserts a new stylesheet into a ShadowRoot.
3
+ *
4
+ * @category Internal
5
+ * @returns Whether the new styles were set or not.
6
+ */
2
7
  export declare function insertStyleSheet({ newStyles, oldStyles, shadowRoot, maintainFirstStylesheet, }: {
3
8
  newStyles: string;
4
9
  oldStyles: string;
@@ -1,5 +1,10 @@
1
1
  import { assertWrap, check } from '@augment-vir/assert';
2
- /** @returns Whether the new styles were set or not. */
2
+ /**
3
+ * Inserts a new stylesheet into a ShadowRoot.
4
+ *
5
+ * @category Internal
6
+ * @returns Whether the new styles were set or not.
7
+ */
3
8
  export function insertStyleSheet({ newStyles, oldStyles, shadowRoot, maintainFirstStylesheet, }) {
4
9
  if (newStyles !== oldStyles) {
5
10
  const sheet = new CSSStyleSheet();
@@ -10,8 +10,24 @@ import { type RenderHtmlOptions, type RenderInput } from '../render/render-types
10
10
  export declare const VirStructuredRender: import("element-vir").DeclarativeElementDefinition<"vir-structured-render", {
11
11
  data: Readonly<RenderInput>;
12
12
  options?: Readonly<PartialWithUndefined<RenderHtmlOptions & {
13
- isTableSize: boolean;
13
+ /**
14
+ * If `true`, smaller, tablet-compatible styles are used.
15
+ *
16
+ * @default false
17
+ */
18
+ isTabletSize: boolean;
19
+ /**
20
+ * If `true`, smaller, phone-compatible styles are used.
21
+ *
22
+ * @default false
23
+ */
14
24
  isPhoneSize: boolean;
25
+ /**
26
+ * If `true`, all sections will automatically be expanded at first.
27
+ *
28
+ * @default false
29
+ */
30
+ expandAllSections: boolean;
15
31
  }>> | undefined;
16
32
  }, {
17
33
  currentlyExpanded: Record<string, boolean>;
@@ -1,7 +1,7 @@
1
1
  import { colorCss } from '@electrovir/color';
2
2
  import { css, defineElement, html, listen, unsafeCSS } from 'element-vir';
3
3
  import { themeDefaultKey } from 'theme-vir';
4
- import { noNativeFormStyles, ViraIcon, ViraTag, viraTheme } from 'vira';
4
+ import { noNativeFormStyles, viraFormCssVars, ViraIcon, ViraTag, viraTheme } from 'vira';
5
5
  import { insertStyleSheet } from '../augments/shadow-styles.js';
6
6
  import { renderStructuredHtml, SourceExpansionEvent } from '../render/render-html.js';
7
7
  import { contentDivClass, defaultMarkdownRenderStyles } from '../render/render-markdown-styles.js';
@@ -29,20 +29,24 @@ export const VirStructuredRender = defineElement()({
29
29
  },
30
30
  hostClasses: {
31
31
  'vir-structured-render-phone-size': ({ inputs }) => !!inputs.options?.isPhoneSize,
32
- 'vir-structured-render-tablet-size': ({ inputs }) => !!inputs.options?.isTableSize,
32
+ 'vir-structured-render-tablet-size': ({ inputs }) => !!inputs.options?.isTabletSize,
33
33
  },
34
34
  styles: ({ cssVars, hostClasses }) => css `
35
35
  :host {
36
36
  ${colorCss(viraTheme.colors[themeDefaultKey])}
37
- display: flex;
38
- flex-direction: column;
39
- align-items: stretch;
40
37
  }
41
38
 
42
39
  ${ViraIcon} {
43
40
  flex-shrink: 0;
44
41
  }
45
42
 
43
+ :host,
44
+ .${unsafeCSS(contentDivClass)}.${unsafeCSS(contentDivClass)}.${unsafeCSS(contentDivClass)} {
45
+ display: flex;
46
+ flex-direction: column;
47
+ align-items: stretch;
48
+ }
49
+
46
50
  .view-header {
47
51
  display: flex;
48
52
  flex-grow: 1;
@@ -218,6 +222,7 @@ export const VirStructuredRender = defineElement()({
218
222
  margin-left: auto;
219
223
  width: 32px;
220
224
  justify-content: flex-end;
225
+ align-items: center;
221
226
  display: flex;
222
227
  flex-shrink: 0;
223
228
  align-self: top;
@@ -226,7 +231,25 @@ export const VirStructuredRender = defineElement()({
226
231
  & .source-icon-button {
227
232
  ${noNativeFormStyles};
228
233
  cursor: pointer;
229
- color: ${viraTheme.colors['vira-grey-foreground-non-body'].foreground.value};
234
+ color: ${viraTheme.colors['vira-grey-foreground-header'].foreground.value};
235
+ padding: 2px;
236
+ border-radius: 4px;
237
+
238
+ & ${ViraIcon} {
239
+ display: flex;
240
+ }
241
+
242
+ &:hover {
243
+ background-color: ${viraTheme.colors['vira-grey-behind-fg-small-body']
244
+ .background.value};
245
+ color: ${viraFormCssVars['vira-form-accent-primary-color'].value};
246
+ }
247
+
248
+ &:active {
249
+ background-color: ${viraTheme.colors['vira-grey-behind-fg-body'].background
250
+ .value};
251
+ color: ${viraFormCssVars['vira-form-accent-primary-color'].value};
252
+ }
230
253
  }
231
254
 
232
255
  & ul {
@@ -1,5 +1,6 @@
1
1
  import { assert, assertWrap } from '@augment-vir/assert';
2
2
  import { isRuntimeEnv, mergeDefinedProperties, RuntimeEnv, stringify, } from '@augment-vir/common';
3
+ import { waitForAnimationFrame } from '@augment-vir/web';
3
4
  import DOMPurify from 'dompurify';
4
5
  import { convertTemplateToString, html } from 'element-vir';
5
6
  import { marked } from 'marked';
@@ -73,44 +74,6 @@ export var OutputImageType;
73
74
  OutputImageType["Download"] = "download";
74
75
  })(OutputImageType || (OutputImageType = {}));
75
76
  // cspell:enable
76
- /**
77
- * Triggers a file download from a Blob without opening the content in the browser.
78
- *
79
- * Firefox's content handler auto-opens downloaded PDFs in a new tab when it detects the `.pdf`
80
- * extension on a blob URL download. To prevent this, Firefox uses a `data:` URI with an
81
- * `application/octet-stream` MIME type. Data URIs don't carry a URL path, so Firefox can't perform
82
- * extension-based content-type sniffing and won't match the download against its PDF handler.
83
- *
84
- * Other browsers use a standard blob URL + anchor download approach.
85
- */
86
- async function triggerBlobDownload(blob, downloadFileName) {
87
- const anchor = globalThis.document.createElement('a');
88
- anchor.download = downloadFileName;
89
- anchor.style.display = 'none';
90
- if (navigator.userAgent.toLowerCase().includes('firefox')) {
91
- const dataUrl = await new Promise((resolve, reject) => {
92
- const reader = new FileReader();
93
- reader.onload = () => {
94
- resolve(String(reader.result).replace(/^data:[^;]*;/, 'data:application/octet-stream;'));
95
- };
96
- reader.onerror = () => {
97
- reject(new Error('Failed to read blob for download.'));
98
- };
99
- reader.readAsDataURL(blob);
100
- });
101
- anchor.href = dataUrl;
102
- }
103
- else {
104
- const downloadBlob = new Blob([blob], { type: 'application/octet-stream' });
105
- anchor.href = URL.createObjectURL(downloadBlob);
106
- globalThis.setTimeout(() => {
107
- URL.revokeObjectURL(anchor.href);
108
- }, 40_000);
109
- }
110
- globalThis.document.body.append(anchor);
111
- anchor.click();
112
- anchor.remove();
113
- }
114
77
  function createHtml2PdfOptions(fileName) {
115
78
  return {
116
79
  margin: [
@@ -161,44 +124,35 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
161
124
  const htmlString = convertTemplateToString(html `
162
125
  <div class=${contentDivClass}>${DOMPurify.sanitize(dirtyHtml)}</div>
163
126
  `);
127
+ /**
128
+ * Render in a clean iframe context so html2canvas only clones the minimal iframe document
129
+ * instead of the entire host page DOM (which is extremely slow on complex pages).
130
+ */
164
131
  const html2PdfOptions = createHtml2PdfOptions(fileName);
132
+ const canvas = await renderHtmlToCanvas(htmlString, String(options.styles), html2PdfOptions);
133
+ const instance = htmlToPdf().set(html2PdfOptions).from(canvas);
165
134
  if (outputType.pdf) {
166
- /**
167
- * For PDFs, create a hidden container in the main document (not an iframe) so html2pdf can
168
- * inspect the DOM for page break positions while also having access to the styles. Passing
169
- * a canvas loses page break info (tables get sliced). Passing an iframe element loses
170
- * styles (html2pdf clones elements into the main document, dropping iframe styles).
171
- */
172
- const { wrapper, contentElement } = createIsolatedContentDiv(htmlString, String(options.styles), html2PdfOptions);
173
- try {
174
- const instance = htmlToPdf().set(html2PdfOptions).from(contentElement);
175
- if (outputType.pdf === OutputPdfType.Download) {
176
- const pdfBlob = (await instance.outputPdf(OutputPdfType.Blob));
177
- const pdfFileName = fileName.endsWith('.pdf')
178
- ? fileName
179
- : `${fileName}.pdf`;
180
- await triggerBlobDownload(pdfBlob, pdfFileName);
181
- return;
182
- }
183
- else {
184
- return await instance.outputPdf(outputType.pdf, {
185
- filename: fileName,
186
- });
187
- }
135
+ if (outputType.pdf === OutputPdfType.Download) {
136
+ const pdfBlob = (await instance.outputPdf(OutputPdfType.Blob));
137
+ const blobUrl = URL.createObjectURL(pdfBlob);
138
+ const anchor = globalThis.document.createElement('a');
139
+ anchor.href = blobUrl;
140
+ anchor.download = fileName.endsWith('.pdf') ? fileName : `${fileName}.pdf`;
141
+ anchor.style.display = 'none';
142
+ globalThis.document.body.append(anchor);
143
+ anchor.click();
144
+ anchor.remove();
145
+ URL.revokeObjectURL(blobUrl);
146
+ return;
188
147
  }
189
- finally {
190
- wrapper.remove();
148
+ else {
149
+ return await instance.outputPdf(outputType.pdf, {
150
+ filename: fileName,
151
+ });
191
152
  }
192
153
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
193
154
  }
194
155
  else if (outputType.image) {
195
- /**
196
- * For images, render to a canvas in a clean iframe context so html2canvas only clones the
197
- * minimal iframe document instead of the entire host page DOM (which is extremely slow on
198
- * complex pages). No page break logic is needed for images.
199
- */
200
- const canvas = await renderHtmlToCanvas(htmlString, String(options.styles), html2PdfOptions);
201
- const instance = htmlToPdf().set(html2PdfOptions).from(canvas);
202
156
  if (outputType.image === OutputImageType.Download) {
203
157
  return await instance.toImg().save(fileName);
204
158
  }
@@ -212,45 +166,11 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
212
166
  }
213
167
  }
214
168
  /**
215
- * Create a hidden container div in the main document with styles and content. Unlike an iframe, the
216
- * styles live in the same document context so html2pdf.js can see them when it clones the element.
217
- * This allows html2pdf to both render styles correctly AND inspect the DOM for page break positions.
218
- *
219
- * The caller is responsible for removing the returned wrapper when done.
220
- */
221
- function createIsolatedContentDiv(htmlString, stylesString, html2PdfOptions) {
222
- const marginArray = normalizeMargin(html2PdfOptions.margin);
223
- /** A4 page width in mm. */
224
- const pageWidthMm = 210;
225
- const innerWidthMm = pageWidthMm - marginArray[1] - marginArray[3];
226
- const wrapper = globalThis.document.createElement('div');
227
- wrapper.style.position = 'fixed';
228
- wrapper.style.left = '-10000px';
229
- wrapper.style.top = '0';
230
- wrapper.style.width = `${innerWidthMm}mm`;
231
- wrapper.style.overflow = 'hidden';
232
- wrapper.setAttribute('aria-hidden', 'true');
233
- const styleElement = globalThis.document.createElement('style');
234
- styleElement.textContent = [
235
- baseContentResetStyles,
236
- stylesString,
237
- ].join('\n');
238
- wrapper.append(styleElement);
239
- const contentContainer = globalThis.document.createElement('div');
240
- contentContainer.innerHTML = htmlString;
241
- wrapper.append(contentContainer);
242
- globalThis.document.body.append(wrapper);
243
- const contentElement = assertWrap.isDefined(contentContainer.firstElementChild);
244
- return { wrapper, contentElement };
245
- }
246
- /**
247
- * Create an isolated iframe containing the rendered HTML content and styles. The iframe provides a
248
- * minimal document context so that html2canvas / html2pdf only need to process a small DOM tree
249
- * instead of the entire host page.
250
- *
251
- * The caller is responsible for removing the iframe when done.
169
+ * Render HTML content to a canvas using a clean, minimal iframe. This avoids the massive
170
+ * performance penalty from html2canvas cloning the entire host page document (including all custom
171
+ * elements, shadow DOM, and styles) just to render a small content div.
252
172
  */
253
- async function createIsolatedContentIframe(htmlString, stylesString, html2PdfOptions) {
173
+ async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
254
174
  const marginArray = normalizeMargin(html2PdfOptions.margin);
255
175
  /** A4 page width in mm. */
256
176
  const pageWidthMm = 210;
@@ -272,31 +192,33 @@ async function createIsolatedContentIframe(htmlString, stylesString, html2PdfOpt
272
192
  '</body></html>',
273
193
  ].join('');
274
194
  globalThis.document.body.append(iframe);
275
- await new Promise((resolve) => {
276
- iframe.addEventListener('load', () => {
277
- resolve();
278
- }, {
279
- once: true,
280
- });
281
- });
282
- const iframeDoc = assertWrap.isDefined(iframe.contentDocument);
283
- await iframeDoc.fonts.ready;
284
- /**
285
- * Elements from the iframe exist in a different window context, so `instanceof HTMLElement`
286
- * (from the parent window) fails. Use assertWrap.isDefined since we control the HTML written to
287
- * the iframe and know the first child is always an element.
288
- */
289
- const contentElement = assertWrap.isDefined(iframeDoc.body.firstElementChild);
290
- return { iframe, contentElement };
291
- }
292
- /**
293
- * Render HTML content to a canvas using a clean, minimal iframe. This avoids the massive
294
- * performance penalty from html2canvas cloning the entire host page document (including all custom
295
- * elements, shadow DOM, and styles) just to render a small content div.
296
- */
297
- async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
298
- const { iframe, contentElement } = await createIsolatedContentIframe(htmlString, stylesString, html2PdfOptions);
299
195
  try {
196
+ await new Promise((resolve) => {
197
+ iframe.addEventListener('load', () => {
198
+ resolve();
199
+ }, {
200
+ once: true,
201
+ });
202
+ });
203
+ const iframeDoc = assertWrap.isDefined(iframe.contentDocument);
204
+ await iframeDoc.fonts.ready;
205
+ /**
206
+ * Elements from the iframe exist in a different window context, so `instanceof HTMLElement`
207
+ * (from the parent window) fails. Use assertWrap.isDefined since we control the HTML
208
+ * written to the iframe and know the first child is always an element.
209
+ */
210
+ const contentElement = assertWrap.isDefined(iframeDoc.body.firstElementChild);
211
+ /**
212
+ * Resize the iframe to match the content's natural height so the viewport is not clipped at
213
+ * 0px. Without this, html2canvas may read 0-height dimensions for the element.
214
+ */
215
+ iframe.style.height = `${iframeDoc.body.scrollHeight}px`;
216
+ /**
217
+ * Wait for the browser to finish layout before html2canvas reads element dimensions and
218
+ * computed styles. Uses the parent window's requestAnimationFrame because the iframe is
219
+ * positioned offscreen, and browsers skip animation frames for invisible iframes.
220
+ */
221
+ await waitForAnimationFrame(3);
300
222
  return await importHtml2Canvas()(contentElement, {
301
223
  ...html2PdfOptions.html2canvas,
302
224
  });
@@ -2,7 +2,7 @@ import { assert, check } from '@augment-vir/assert';
2
2
  import { ensureArray, filterMap, mapEnumToObject, mapObjectValues, mergeDefinedProperties, stringify, } from '@augment-vir/common';
3
3
  import { extractEventTarget } from '@augment-vir/web';
4
4
  import { classMap, css, defineTypedEvent, html, ifDefined, join, listen, nothing, testId, unsafeCSS, } from 'element-vir';
5
- import { defineTable, ViraCollapsibleWrapper, ViraColorVariant, ViraEmphasis, ViraIcon, ViraSize, ViraTableOrientation, ViraTag, } from 'vira';
5
+ import { defineTable, ViraCollapsibleCard, ViraCollapsibleWrapper, ViraColorVariant, ViraEmphasis, ViraIcon, ViraSize, ViraTableOrientation, ViraTag, } from 'vira';
6
6
  import { VirMarkdown } from '../elements/vir-markdown.element.js';
7
7
  import { VirSource } from '../elements/vir-source.element.js';
8
8
  import { createStructuredRenderIcon } from '../structured-render-data/sections/icon.section.js';
@@ -338,7 +338,7 @@ const htmlRenderers = {
338
338
  },
339
339
  };
340
340
  function renderInternalStructuredHtml(data, options, keyChain) {
341
- return structuredRenderToHtmlArray(data, options, keyChain).filter(check.isTruthy);
341
+ return structuredRenderToHtmlArray(data, options, keyChain, false).filter(check.isTruthy);
342
342
  }
343
343
  /**
344
344
  * Used for both HTML class names and test ids.
@@ -357,7 +357,7 @@ export const structuredRenderSectionHtmlNames = mapEnumToObject(StructuredRender
357
357
  * @category Internal
358
358
  */
359
359
  export const structuredRenderSectionHtmlTestId = 'structured-render-section';
360
- function structuredRenderToHtmlArray(data, options, keyChain) {
360
+ function structuredRenderToHtmlArray(data, options, keyChain, isTopSection) {
361
361
  if (!data) {
362
362
  return [];
363
363
  }
@@ -365,32 +365,50 @@ function structuredRenderToHtmlArray(data, options, keyChain) {
365
365
  return data.flatMap((entry, index) => structuredRenderToHtmlArray(entry, options, [
366
366
  ...keyChain,
367
367
  index,
368
- ]));
368
+ ], isTopSection));
369
369
  }
370
370
  else if ('type' in data) {
371
371
  const sectionTitle = ('sectionTitle' in data && keyChain.length > 0 && data.sectionTitle) || undefined;
372
372
  const sectionTemplate = htmlRenderers[data.type](data, options, keyChain);
373
373
  const sources = ('sources' in data && data.sources) || undefined;
374
- return [
375
- sectionTitle
376
- ? html `
377
- <h3>${sectionTitle}</h3>
378
- `
379
- : undefined,
380
- html `
381
- <div
382
- class=${classMap({
383
- 'section-wrapper': true,
384
- 'top-section-wrapper': keyChain.length === 0,
385
- [structuredRenderSectionHtmlNames[data.type]]: true,
386
- })}
387
- ${testId(structuredRenderSectionHtmlTestId)}
388
- ${testId(structuredRenderSectionHtmlNames[data.type])}
389
- >
390
- ${createSourceWrapper(sectionTemplate, options, keyChain, sources)}
391
- </div>
392
- `,
393
- ];
374
+ const sectionContent = html `
375
+ <div
376
+ class=${classMap({
377
+ 'section-wrapper': true,
378
+ 'top-section-wrapper': isTopSection,
379
+ [structuredRenderSectionHtmlNames[data.type]]: true,
380
+ })}
381
+ ${testId(structuredRenderSectionHtmlTestId)}
382
+ ${testId(structuredRenderSectionHtmlNames[data.type])}
383
+ >
384
+ ${createSourceWrapper(sectionTemplate, options, keyChain, sources)}
385
+ </div>
386
+ `;
387
+ if (isTopSection) {
388
+ return [
389
+ html `
390
+ <${ViraCollapsibleCard.assign({
391
+ expandOnPrint: true,
392
+ blockExpansion: options.blockSectionExpansion,
393
+ hideHeader: !sectionTitle,
394
+ startExpanded: options.expandAllSections || keyChain.at(-1) === 0,
395
+ })}>
396
+ <h3 slot=${ViraCollapsibleCard.slotNames.header}>${sectionTitle}</h3>
397
+ ${sectionContent}
398
+ </${ViraCollapsibleCard}>
399
+ `,
400
+ ];
401
+ }
402
+ else {
403
+ return [
404
+ sectionTitle
405
+ ? html `
406
+ <h3>${sectionTitle}</h3>
407
+ `
408
+ : undefined,
409
+ sectionContent,
410
+ ];
411
+ }
394
412
  }
395
413
  else if ('sections' in data) {
396
414
  return [
@@ -402,7 +420,7 @@ function structuredRenderToHtmlArray(data, options, keyChain) {
402
420
  ...structuredRenderToHtmlArray(data.sections, options, [
403
421
  ...keyChain,
404
422
  'sections',
405
- ]),
423
+ ], true),
406
424
  ];
407
425
  }
408
426
  else {
@@ -1,4 +1,5 @@
1
- import { isRuntimeEnv, RuntimeEnv } from '@augment-vir/common';
1
+ import { isRuntimeEnv, RuntimeEnv, wait } from '@augment-vir/common';
2
+ import { waitForAnimationFrame } from '@augment-vir/web';
2
3
  import { OutputPdfType, renderInBrowser, renderInNode } from './browser-rendering.js';
3
4
  /**
4
5
  * Render to a PDF. Only works in Node.js, not a browser.
@@ -50,12 +51,21 @@ export async function printPdf(renderInput, params) {
50
51
  ...params,
51
52
  }));
52
53
  const blobUrl = URL.createObjectURL(pdfBlob);
54
+ const userAgent = navigator.userAgent.toLowerCase();
53
55
  /**
54
56
  * Firefox uses PDF.js to render PDFs in iframes, which doesn't support printing via
55
- * `contentWindow.print()` on blob URLs. Open a new window instead so Firefox's native PDF
56
- * viewer handles it.
57
+ * `contentWindow.print()` on blob URLs.
58
+ *
59
+ * IOS Safari renders PDFs in iframes constrained to the iframe's viewport, so a hidden 1x1
60
+ * iframe only prints the first page. iPadOS Safari reports as "Macintosh" in its user agent, so
61
+ * we also check `maxTouchPoints` to detect it.
62
+ *
63
+ * In both cases, open a new window so the platform's native PDF viewer handles all pages.
57
64
  */
58
- if (navigator.userAgent.toLowerCase().includes('firefox')) {
65
+ const isFirefox = userAgent.includes('firefox');
66
+ const isIos = /iphone|ipad|ipod/.test(userAgent) ||
67
+ (userAgent.includes('macintosh') && navigator.maxTouchPoints > 1);
68
+ if (isFirefox || isIos) {
59
69
  const printWindow = globalThis.window.open(blobUrl);
60
70
  if (!printWindow) {
61
71
  URL.revokeObjectURL(blobUrl);
@@ -73,42 +83,62 @@ export async function printPdf(renderInput, params) {
73
83
  printFrame.style.opacity = '0';
74
84
  printFrame.src = blobUrl;
75
85
  globalThis.document.body.append(printFrame);
86
+ await new Promise((resolve) => {
87
+ printFrame.addEventListener('load', () => {
88
+ resolve();
89
+ }, {
90
+ once: true,
91
+ });
92
+ });
93
+ const contentWindow = printFrame.contentWindow;
94
+ if (!contentWindow) {
95
+ URL.revokeObjectURL(blobUrl);
96
+ printFrame.remove();
97
+ return;
98
+ }
99
+ /**
100
+ * The iframe's `load` event fires when the blob data is fetched, but the browser's built-in PDF
101
+ * viewer still needs time to parse and paint the PDF. Without this wait, `print()` can capture
102
+ * a blank/grey frame roughly 50% of the time. Animation frames alone are insufficient because
103
+ * the PDF viewer renders asynchronously outside the normal DOM paint cycle, so an additional
104
+ * delay is needed to let it finish.
105
+ */
106
+ await waitForAnimationFrame(3);
107
+ await wait({
108
+ milliseconds: 250,
109
+ });
110
+ await waitForAnimationFrame(3);
76
111
  return new Promise((resolve) => {
77
- printFrame.onload = () => {
78
- const contentWindow = printFrame.contentWindow;
79
- if (!contentWindow) {
80
- URL.revokeObjectURL(blobUrl);
81
- printFrame.remove();
82
- resolve();
112
+ function cleanup() {
113
+ URL.revokeObjectURL(blobUrl);
114
+ printFrame.remove();
115
+ resolve();
116
+ }
117
+ let resolved = false;
118
+ function resolveOnce() {
119
+ if (resolved) {
83
120
  return;
84
121
  }
85
- function cleanup() {
86
- URL.revokeObjectURL(blobUrl);
87
- printFrame.remove();
88
- resolve();
89
- }
90
- let resolved = false;
91
- function resolveOnce() {
92
- if (resolved) {
93
- return;
94
- }
95
- resolved = true;
96
- globalThis.window.removeEventListener('focus', focusFallback);
97
- cleanup();
98
- }
99
- /**
100
- * Fallback: when the print dialog closes (print or cancel), focus returns to the main
101
- * window. Some browsers don't fire `afterprint` on the iframe's contentWindow when the
102
- * user cancels.
103
- */
104
- function focusFallback() {
105
- resolveOnce();
106
- }
107
- contentWindow.addEventListener('afterprint', () => {
108
- resolveOnce();
109
- }, { once: true });
110
- globalThis.window.addEventListener('focus', focusFallback, { once: true });
111
- contentWindow.print();
112
- };
122
+ resolved = true;
123
+ globalThis.window.removeEventListener('focus', focusFallback);
124
+ cleanup();
125
+ }
126
+ /**
127
+ * Fallback: when the print dialog closes (print or cancel), focus returns to the main
128
+ * window. Some browsers don't fire `afterprint` on the iframe's contentWindow when the user
129
+ * cancels.
130
+ */
131
+ function focusFallback() {
132
+ resolveOnce();
133
+ }
134
+ contentWindow.addEventListener('afterprint', () => {
135
+ resolveOnce();
136
+ }, {
137
+ once: true,
138
+ });
139
+ globalThis.window.addEventListener('focus', focusFallback, {
140
+ once: true,
141
+ });
142
+ contentWindow.print();
113
143
  });
114
144
  }
@@ -119,12 +119,20 @@ export type RenderHtmlOptions = RenderOptions & {
119
119
  * This will only be used if `hideViewOnPageButtons` is not set to `true`.
120
120
  */
121
121
  createViewOnPageString(pageNumber: number): string;
122
+ /** CSS styles for rendering internal Markdown. */
123
+ markdownStyles: string | CSSResult;
122
124
  /**
123
- * CSS styles for rendering internal Markdown.
125
+ * If set to `true`, all sections will start out expanded.
124
126
  *
125
- * @default
127
+ * @default false
126
128
  */
127
- markdownStyles: string | CSSResult;
129
+ expandAllSections: boolean;
130
+ /**
131
+ * If set to `true`, all sections will be expanded and expansion toggling will be disabled.
132
+ *
133
+ * @default false
134
+ */
135
+ blockSectionExpansion: boolean;
128
136
  };
129
137
  /**
130
138
  * Default option values for Structured Render rendering to HTML.
@@ -38,4 +38,6 @@ export const defaultRenderHtmlOptions = {
38
38
  createViewOnPageString(pageNumber) {
39
39
  return `View on page ${pageNumber}`;
40
40
  },
41
+ expandAllSections: false,
42
+ blockSectionExpansion: false,
41
43
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-render",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "A library for safely rendering arbitrary data generated from any source.",
5
5
  "keywords": [
6
6
  "render",
@@ -52,7 +52,7 @@
52
52
  "object-shape-tester": "^6.11.0",
53
53
  "theme-vir": "^28.22.0",
54
54
  "type-fest": "^5.4.4",
55
- "vira": "^29.7.2"
55
+ "vira": "^29.8.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@augment-vir/test": "^31.67.0",