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.
- package/dist/augments/shadow-styles.d.ts +6 -1
- package/dist/augments/shadow-styles.js +6 -1
- package/dist/elements/vir-structured-render.element.d.ts +17 -1
- package/dist/elements/vir-structured-render.element.js +29 -6
- package/dist/render/browser-rendering.js +53 -131
- package/dist/render/render-html.js +43 -25
- package/dist/render/render-pdf.js +68 -38
- package/dist/render/render-types.d.ts +11 -3
- package/dist/render/render-types.js +2 -0
- package/package.json +2 -2
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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?.
|
|
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-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
56
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
*
|
|
125
|
+
* If set to `true`, all sections will start out expanded.
|
|
124
126
|
*
|
|
125
|
-
* @default
|
|
127
|
+
* @default false
|
|
126
128
|
*/
|
|
127
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-render",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
55
|
+
"vira": "^29.8.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@augment-vir/test": "^31.67.0",
|