structured-render 0.0.5 → 0.0.7

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();
@@ -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';
@@ -34,15 +34,19 @@ export const VirStructuredRender = defineElement()({
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';
@@ -207,6 +208,17 @@ async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
207
208
  * written to the iframe and know the first child is always an element.
208
209
  */
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);
210
222
  return await importHtml2Canvas()(contentElement, {
211
223
  ...html2PdfOptions.html2canvas,
212
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,7 +365,7 @@ 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;
@@ -375,7 +375,7 @@ function structuredRenderToHtmlArray(data, options, keyChain) {
375
375
  <div
376
376
  class=${classMap({
377
377
  'section-wrapper': true,
378
- 'top-section-wrapper': keyChain.length === 0,
378
+ 'top-section-wrapper': isTopSection,
379
379
  [structuredRenderSectionHtmlNames[data.type]]: true,
380
380
  })}
381
381
  ${testId(structuredRenderSectionHtmlTestId)}
@@ -384,36 +384,31 @@ function structuredRenderToHtmlArray(data, options, keyChain) {
384
384
  ${createSourceWrapper(sectionTemplate, options, keyChain, sources)}
385
385
  </div>
386
386
  `;
387
- if (sectionTitle) {
388
- const sectionKeyChain = [
389
- ...keyChain,
390
- 'section-collapsible',
391
- ];
392
- const sectionKey = makeChainKey(sectionKeyChain);
393
- const isSectionExpanded = !!options.currentlyExpanded[sectionKey];
387
+ if (isTopSection) {
394
388
  return [
395
389
  html `
396
- <${ViraCollapsibleWrapper.assign({
397
- expanded: isSectionExpanded,
398
- })}
399
- class="section-collapsible-wrapper"
400
- ${listen(ViraCollapsibleWrapper.events.expandChange, (event) => {
401
- const eventTarget = extractEventTarget(event, HTMLElement);
402
- eventTarget.dispatchEvent(new SourceExpansionEvent({
403
- expanded: event.detail,
404
- key: sectionKey,
405
- }));
406
- })}
407
- >
408
- <h3 slot=${ViraCollapsibleWrapper.slotNames.header}>${sectionTitle}</h3>
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>
409
397
  ${sectionContent}
410
- </${ViraCollapsibleWrapper}>
398
+ </${ViraCollapsibleCard}>
411
399
  `,
412
400
  ];
413
401
  }
414
- return [
415
- sectionContent,
416
- ];
402
+ else {
403
+ return [
404
+ sectionTitle
405
+ ? html `
406
+ <h3>${sectionTitle}</h3>
407
+ `
408
+ : undefined,
409
+ sectionContent,
410
+ ];
411
+ }
417
412
  }
418
413
  else if ('sections' in data) {
419
414
  return [
@@ -425,7 +420,7 @@ function structuredRenderToHtmlArray(data, options, keyChain) {
425
420
  ...structuredRenderToHtmlArray(data.sections, options, [
426
421
  ...keyChain,
427
422
  'sections',
428
- ]),
423
+ ], true),
429
424
  ];
430
425
  }
431
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,46 +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
- }, {
110
- once: true,
111
- });
112
- globalThis.window.addEventListener('focus', focusFallback, {
113
- once: true,
114
- });
115
- contentWindow.print();
116
- };
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();
117
143
  });
118
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.5",
3
+ "version": "0.0.7",
4
4
  "description": "A library for safely rendering arbitrary data generated from any source.",
5
5
  "keywords": [
6
6
  "render",
@@ -44,7 +44,7 @@
44
44
  "@augment-vir/web": "^31.67.0",
45
45
  "@electrovir/color": "^1.7.8",
46
46
  "dompurify": "^3.3.1",
47
- "element-vir": "^26.14.4",
47
+ "element-vir": "^26.14.5",
48
48
  "html2canvas": "^1.4.1",
49
49
  "html2pdf.js": "^0.14.0",
50
50
  "lit-css-vars": "^3.5.0",
@@ -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.8.0"
55
+ "vira": "^30.1.1"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@augment-vir/test": "^31.67.0",
@@ -1 +0,0 @@
1
- export declare const VirApp: import("element-vir").DeclarativeElementDefinition<"vir-app", {}, {}, {}, "vir-app-", "vir-app-", readonly [], readonly []>;
@@ -1,9 +0,0 @@
1
- import { defineElement, html } from 'element-vir';
2
- export const VirApp = defineElement()({
3
- tagName: 'vir-app',
4
- render() {
5
- return html `
6
- Vir App goes here!
7
- `;
8
- },
9
- });