ngx-print 21.2.0 → 22.1.0

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.
Files changed (43) hide show
  1. package/.editorconfig +13 -0
  2. package/.eslintrc.json +50 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  5. package/.github/dependabot.yml +17 -0
  6. package/.github/workflows/PullRequest.yml +32 -0
  7. package/.github/workflows/stale.yml +25 -0
  8. package/.prettierignore +42 -0
  9. package/.prettierrc.json +14 -0
  10. package/CHANGELOG.md +126 -0
  11. package/README.md +90 -52
  12. package/_config.yml +1 -0
  13. package/angular.json +132 -0
  14. package/eslint.config.js +32 -0
  15. package/ng-package.json +7 -0
  16. package/package.json +60 -43
  17. package/projects/demo/public/favicon.ico +0 -0
  18. package/projects/demo/src/app/app.config.ts +11 -0
  19. package/projects/demo/src/app/app.routes.ts +3 -0
  20. package/projects/demo/src/app/demo.component.html +83 -0
  21. package/projects/demo/src/app/demo.component.scss +54 -0
  22. package/projects/demo/src/app/demo.component.ts +73 -0
  23. package/projects/demo/src/index.html +13 -0
  24. package/projects/demo/src/main.ts +5 -0
  25. package/projects/demo/src/styles.scss +1 -0
  26. package/projects/demo/tsconfig.app.json +19 -0
  27. package/projects/demo/tsconfig.spec.json +15 -0
  28. package/src/lib/ngx-print.base.ts +381 -0
  29. package/src/lib/ngx-print.directive.spec.ts +232 -0
  30. package/src/lib/ngx-print.directive.ts +72 -0
  31. package/src/lib/ngx-print.module.ts +8 -0
  32. package/src/lib/ngx-print.service.spec.ts +276 -0
  33. package/src/lib/ngx-print.service.ts +50 -0
  34. package/src/lib/print-options.ts +16 -0
  35. package/src/public_api.ts +8 -0
  36. package/tsconfig.json +38 -0
  37. package/tsconfig.lib.json +19 -0
  38. package/tsconfig.spec.json +10 -0
  39. package/tslint.json +17 -0
  40. package/fesm2022/ngx-print.mjs +0 -543
  41. package/fesm2022/ngx-print.mjs.map +0 -1
  42. package/types/ngx-print.d.ts +0 -228
  43. package/types/ngx-print.d.ts.map +0 -1
@@ -0,0 +1,381 @@
1
+ import { CSP_NONCE, DOCUMENT, inject, Service } from '@angular/core';
2
+ import { Subject } from 'rxjs';
3
+ import { PrintOptions } from './print-options';
4
+
5
+ /** For example:
6
+ * {
7
+ * 'h2': { 'border': 'solid 1px' },
8
+ * 'h1': { 'color': 'red', 'border': '1px solid' },
9
+ * */
10
+ export type PrintStyle = Record<string, Record<string, string>>;
11
+
12
+ /**
13
+ * Either a {@link PrintStyle} object, or a raw CSS string (e.g. `'h1 { color: red; }'`)
14
+ * to be injected into the print document's <style> tag as-is.
15
+ */
16
+ export type PrintStyleInput = PrintStyle | string;
17
+
18
+ @Service()
19
+ export class PrintBase {
20
+ private document = inject(DOCUMENT);
21
+ private nonce = inject(CSP_NONCE, { optional: true });
22
+
23
+ private _iframeElement: HTMLIFrameElement | undefined;
24
+ private _printStyle: string[] = [];
25
+ private _styleSheetFile = '';
26
+ protected printComplete = new Subject<void>();
27
+
28
+ //#region Getters and Setters
29
+ /**
30
+ * Sets the print styles based on the provided values.
31
+ *
32
+ * @param values - Either a key-value pairs object representing print styles, or a raw CSS string.
33
+ * @protected
34
+ */
35
+ protected setPrintStyle(values: PrintStyleInput) {
36
+ if (typeof values === 'string') {
37
+ this._printStyle = values ? [values] : [];
38
+ return;
39
+ }
40
+
41
+ this._printStyle = [];
42
+ for (const [selector, declarations] of Object.entries(values)) {
43
+ // Built declaration-by-declaration (rather than via JSON.stringify + a quote-stripping
44
+ // regex) so that quotes and commas that are part of a CSS value (e.g. quoted font names,
45
+ // comma-separated font-family fallback lists) survive instead of being stripped/mangled.
46
+ const body = Object.entries(declarations)
47
+ .map(([property, value]) => `${property}:${value}`)
48
+ .join(';');
49
+ this._printStyle.push(`${selector}{${body}}`);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @returns the string that create the stylesheet which will be injected
55
+ * later within <style></style> tag.
56
+ */
57
+ public returnStyleValues(): string {
58
+ const styleNonce = this.nonce ? ` nonce="${this.nonce}"` : '';
59
+ return `<style${styleNonce}> ${this._printStyle.join(' ')} </style>`;
60
+ }
61
+
62
+ /**
63
+ * @returns string which contains the link tags containing the css which will
64
+ * be injected later within <head></head> tag.
65
+ *
66
+ */
67
+ private returnStyleSheetLinkTags(): string {
68
+ return this._styleSheetFile;
69
+ }
70
+
71
+ /**
72
+ * Sets the style sheet file based on the provided CSS list.
73
+ *
74
+ * @param {string} cssList - CSS file or list of CSS files.
75
+ * @protected
76
+ */
77
+ // prettier-ignore
78
+ protected setStyleSheetFile(cssList: string): void {
79
+ if (!cssList) {
80
+ this._styleSheetFile = '';
81
+ return;
82
+ }
83
+ const files = cssList.split(',').map(f => f.trim());
84
+ const nonceAttr = this.nonce ? ` nonce="${this.nonce}"` : '';
85
+ this._styleSheetFile = files
86
+ .map(url => `<link${nonceAttr} rel="stylesheet" type="text/css" href="${url}">`)
87
+ .join('');
88
+ }
89
+
90
+ //#endregion
91
+
92
+ //#region Private methods used by PrintBase
93
+
94
+ private syncFormValues(source: HTMLElement, clone: HTMLElement): void {
95
+ // Select all form elements
96
+ const selector = 'input, select, textarea';
97
+ const sourceEls = source.querySelectorAll(selector);
98
+ const cloneEls = clone.querySelectorAll(selector);
99
+
100
+ for (let i = 0; i < sourceEls.length; i++) {
101
+ const srcNode = sourceEls[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
102
+ const cloneNode = cloneEls[i] as typeof srcNode;
103
+
104
+ if (srcNode instanceof HTMLInputElement) {
105
+ if (srcNode.type === 'checkbox' || srcNode.type === 'radio') {
106
+ if (srcNode.checked) cloneNode.setAttribute('checked', '');
107
+ else cloneNode.removeAttribute('checked'); // Remove if unchecked
108
+ } else if (srcNode.type === 'file') {
109
+ // File inputs can't be set programmatically for security
110
+ continue;
111
+ } else {
112
+ cloneNode.setAttribute('value', srcNode.value);
113
+ }
114
+ } else if (srcNode instanceof HTMLTextAreaElement) {
115
+ cloneNode.textContent = srcNode.value; // Use textContent, not innerHTML
116
+ } else if (srcNode instanceof HTMLSelectElement) {
117
+ Array.from((cloneNode as HTMLSelectElement).options).forEach((opt, idx) => {
118
+ if (idx === srcNode.selectedIndex) {
119
+ opt.setAttribute('selected', '');
120
+ } else {
121
+ opt.removeAttribute('selected'); // Remove from non-selected
122
+ }
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Converts a canvas element to an image and returns its HTML string.
130
+ *
131
+ * @param {HTMLCanvasElement} canvasElm - The canvas element to convert.
132
+ * @returns {HTMLImageElement | null} - HTML Element of the image.
133
+ * @private
134
+ */
135
+ private canvasToImageHtml(canvasElm: HTMLCanvasElement): HTMLImageElement | null {
136
+ try {
137
+ const dataUrl = canvasElm.toDataURL(); // may throw if canvas is tainted
138
+ const img = this.document.createElement('img');
139
+ img.src = dataUrl;
140
+ img.style.maxWidth = '100%';
141
+
142
+ // Preserve displayed size (not just bitmap size)
143
+ const rect = canvasElm.getBoundingClientRect();
144
+ if (rect.width) img.style.width = `${rect.width}px`;
145
+ if (rect.height) img.style.height = `${rect.height}px`;
146
+
147
+ return img;
148
+ } catch (err) {
149
+ console.warn(`Canvas conversion failed for ${canvasElm}. Likely the canvas is tainted:`, err);
150
+ // If toDataURL() fails (e.g., tainted canvas), keep canvas as-is in print output
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Includes canvas contents in the print section via img tags.
157
+ *
158
+ * @private
159
+ * @param source
160
+ * @param clone
161
+ */
162
+ private updateCanvasToImage(source: HTMLElement, clone: HTMLElement): void {
163
+ const sourceCanvases = source.querySelectorAll('canvas');
164
+ const cloneCanvases = clone.querySelectorAll('canvas');
165
+
166
+ for (let i = 0; i < sourceCanvases.length; i++) {
167
+ const srcCanvas = sourceCanvases[i];
168
+ const cloneCanvas = cloneCanvases[i];
169
+ const img = this.canvasToImageHtml(srcCanvas);
170
+ if (img) {
171
+ cloneCanvas.replaceWith(img);
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Retrieves the HTML content of a specified printing section.
178
+ *
179
+ * @param {string} printSectionId - Id of the printing section.
180
+ * @returns {string | null} - HTML content of the printing section, or null if not found.
181
+ * @private
182
+ */
183
+ private getHtmlContents(printSectionId: string): string | null {
184
+ const sourceElm = this.document.getElementById(printSectionId);
185
+ if (!sourceElm) return null;
186
+
187
+ const cloneElm = sourceElm.cloneNode(true) as HTMLElement; // cloneNode(true) deep clones subtree
188
+
189
+ this.syncFormValues(sourceElm, cloneElm);
190
+ this.updateCanvasToImage(sourceElm, cloneElm);
191
+
192
+ return cloneElm.outerHTML;
193
+ }
194
+
195
+ /**
196
+ * Retrieves the HTML content of elements with the specified tag.
197
+ *
198
+ * @param {keyof HTMLElementTagNameMap} tag - HTML tag name.
199
+ * @returns {string} - Concatenated outerHTML of elements with the specified tag.
200
+ * @private
201
+ */
202
+ private getElementTag(tag: keyof HTMLElementTagNameMap): string {
203
+ const html: string[] = [];
204
+ const elements = this.document.getElementsByTagName(tag);
205
+ for (const el of Array.from(elements)) {
206
+ html.push((el as Element).outerHTML);
207
+ }
208
+ return html.join('\r\n');
209
+ }
210
+
211
+ //#endregion
212
+
213
+ protected notifyPrintComplete() {
214
+ this.printComplete.next();
215
+ }
216
+
217
+ /**
218
+ * Prints the specified content using the provided print options.
219
+ *
220
+ * @public
221
+ * @param printOptionInput - Options for printing.
222
+ */
223
+ protected print(printOptionInput?: Partial<PrintOptions>): void {
224
+ const printOptions = new PrintOptions(printOptionInput);
225
+ if (printOptions.printMethod === 'iframe') {
226
+ this.printWithIframe(printOptions);
227
+ } else {
228
+ this.printWithWindow(printOptions);
229
+ }
230
+ }
231
+
232
+ protected printWithWindow(printOptions: PrintOptions) {
233
+ // If the openNewTab option is set to true, then set the popOut option to an empty string
234
+ // This will cause the print dialog to open in a new tab.
235
+ const popOut = printOptions.printMethod === 'tab' ? '' : 'top=0,left=0,height=auto,width=auto';
236
+
237
+ const popupWin = window.open('', '_blank', popOut);
238
+
239
+ if (!popupWin) {
240
+ // the popup window could not be opened.
241
+ console.error('Could not open print window.');
242
+ return;
243
+ }
244
+
245
+ popupWin.document.open();
246
+ // Create the HTML structure
247
+ this.buildPrintDocument(popupWin.document, printOptions);
248
+
249
+ popupWin.document.close();
250
+
251
+ // Listen for the window closing
252
+ const checkClosedInterval = setInterval(() => {
253
+ if (popupWin.closed) {
254
+ clearInterval(checkClosedInterval);
255
+ this.notifyPrintComplete();
256
+ }
257
+ }, 500);
258
+
259
+ popupWin.addEventListener('load', () => {
260
+ if (!printOptions.previewOnly) {
261
+ setTimeout(() => {
262
+ popupWin.print();
263
+ if (printOptions.closeWindow) popupWin.close();
264
+ }, printOptions.printDelay || 0);
265
+ }
266
+ });
267
+ }
268
+
269
+ private printWithIframe(printOptions: PrintOptions): void {
270
+ if (this._iframeElement) {
271
+ this._iframeElement.remove();
272
+ }
273
+ this._iframeElement = this.document.createElement('iframe');
274
+ const iframe = this._iframeElement;
275
+ iframe.id = 'print-iframe-' + new Date().getTime();
276
+ iframe.style.position = 'absolute';
277
+ iframe.style.left = '-9999px';
278
+ iframe.style.top = '-9999px';
279
+ iframe.style.width = '0px';
280
+ iframe.style.height = '0px';
281
+ iframe.ariaHidden = 'true';
282
+
283
+ this.document.body.appendChild(iframe);
284
+
285
+ const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
286
+ if (!iframeDoc) {
287
+ console.error('Could not access iframe document.');
288
+ this.document.body.removeChild(iframe);
289
+ return;
290
+ }
291
+
292
+ iframeDoc.open();
293
+ const success = this.buildPrintDocument(iframeDoc, printOptions);
294
+ if (!success) {
295
+ iframeDoc.close();
296
+ this.document.body.removeChild(iframe);
297
+ return;
298
+ }
299
+ iframeDoc.close();
300
+
301
+ iframe.onload = () => {
302
+ const printWindow = iframe.contentWindow;
303
+ if (!printWindow) {
304
+ console.error('Could not access iframe window.');
305
+ this.document.body.removeChild(iframe);
306
+ return;
307
+ }
308
+
309
+ setTimeout(() => {
310
+ if (printOptions.previewOnly) {
311
+ return;
312
+ }
313
+ printWindow.focus();
314
+ printWindow.print();
315
+
316
+ const mediaQueryList = printWindow.matchMedia('print');
317
+ const listener = (mql: MediaQueryListEvent) => {
318
+ if (!mql.matches) {
319
+ this.notifyPrintComplete();
320
+ mediaQueryList.removeEventListener('change', listener);
321
+ }
322
+ };
323
+
324
+ mediaQueryList.addEventListener('change', listener);
325
+ }, printOptions.printDelay || 0);
326
+ };
327
+ }
328
+
329
+ private prepareDocumentComponents(printOptions: PrintOptions) {
330
+ let styles = '';
331
+ let links = '';
332
+ const baseTag = this.getElementTag('base');
333
+
334
+ if (printOptions.useExistingCss) {
335
+ styles = this.getElementTag('style');
336
+ links = this.getElementTag('link');
337
+ }
338
+
339
+ const printContents = this.getHtmlContents(printOptions.printSectionId);
340
+
341
+ return { styles, links, baseTag, printContents };
342
+ }
343
+
344
+ private buildPrintDocument(doc: Document, printOptions: PrintOptions): boolean {
345
+ const components = this.prepareDocumentComponents(printOptions);
346
+
347
+ if (!components.printContents) {
348
+ console.error(`Print section with id "${printOptions.printSectionId}" not found.`);
349
+ return false;
350
+ }
351
+
352
+ const html = doc.createElement('html');
353
+ const head = doc.createElement('head');
354
+ const body = doc.createElement('body');
355
+
356
+ // Set title
357
+ const title = doc.createElement('title');
358
+ title.textContent = printOptions.printTitle || '';
359
+ head.appendChild(title);
360
+
361
+ // Add all head content
362
+ if (components.baseTag) {
363
+ head.innerHTML += components.baseTag;
364
+ }
365
+ head.innerHTML += this.returnStyleValues();
366
+ head.innerHTML += this.returnStyleSheetLinkTags();
367
+ head.innerHTML += components.styles;
368
+ head.innerHTML += components.links;
369
+
370
+ // Set body class and content
371
+ if (printOptions.bodyClass) body.className = printOptions.bodyClass;
372
+ body.innerHTML += components.printContents;
373
+
374
+ // Assemble document
375
+ html.appendChild(head);
376
+ html.appendChild(body);
377
+ doc.appendChild(html);
378
+
379
+ return true;
380
+ }
381
+ }
@@ -0,0 +1,232 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Component, DebugElement, provideZonelessChangeDetection, signal } from '@angular/core';
3
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
4
+ import { By } from '@angular/platform-browser';
5
+ import { NgxPrintDirective } from './ngx-print.directive';
6
+ import { PrintStyle, PrintStyleInput } from './ngx-print.base';
7
+
8
+ @Component({
9
+ template: `
10
+ <div id="print-section" style="border: 2px solid red;">
11
+ <h1>Welcome to ngx-print</h1>
12
+ <img
13
+ width="300"
14
+ alt="Angular Logo"
15
+ src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==" />
16
+ <h2>Here are some links to help you start:</h2>
17
+ <ul>
18
+ <li>
19
+ <h2><a target="_blank" rel="noopener" href="https://angular.io/tutorial">Tour of Heroes</a></h2>
20
+ </li>
21
+ <li>
22
+ <h2><a target="_blank" rel="noopener" href="https://github.com/angular/angular-cli/wiki">CLI Documentation</a></h2>
23
+ </li>
24
+ <li>
25
+ <h2><a target="_blank" rel="noopener" href="https://blog.angular.io/">Angular blog</a></h2>
26
+ </li>
27
+ </ul>
28
+ <table border="1">
29
+ <tr>
30
+ <td>Row 1, Column 1</td>
31
+ <td>Row 1, Column 2</td>
32
+ </tr>
33
+ <tr>
34
+ <td>Row 2, Column 1</td>
35
+ <td>Row 2, Column 2</td>
36
+ </tr>
37
+ </table>
38
+ </div>
39
+ <button [printStyle]="printStyle()" printSectionId="print-section" ngxPrint bodyClass="theme-dark">Print</button>
40
+ `,
41
+ imports: [NgxPrintDirective],
42
+ })
43
+ class TestNgxPrintComponent {
44
+ readonly printStyle = signal<PrintStyleInput>({});
45
+ }
46
+
47
+ describe('NgxPrintDirective', () => {
48
+ let buttonEl: DebugElement;
49
+ let component: TestNgxPrintComponent;
50
+ let fixture: ComponentFixture<TestNgxPrintComponent>;
51
+
52
+ // To change this later, so it'll depend on TestNgxPrintComponent
53
+ const styleSheet: PrintStyle = {
54
+ 'h2': { 'border': 'solid 1px' },
55
+ 'h1': { 'color': 'red', 'border': '1px solid' },
56
+ };
57
+
58
+ beforeEach(() => {
59
+ // Configure a NgModule-like decorator metadata
60
+ TestBed.configureTestingModule({
61
+ providers: [provideZonelessChangeDetection()],
62
+ imports: [TestNgxPrintComponent],
63
+ });
64
+
65
+ // Create a fixture object (that is going to allows us to create an instance of that component)
66
+ fixture = TestBed.createComponent(TestNgxPrintComponent);
67
+
68
+ // Create a component instance ( ~ new Component)
69
+ component = fixture.componentInstance;
70
+
71
+ // Get the button element (on which we tag the directive) to simulate clicks on it
72
+ buttonEl = fixture.debugElement.query(By.directive(NgxPrintDirective));
73
+
74
+ fixture.detectChanges();
75
+ });
76
+
77
+ it('should create an instance', () => {
78
+ const directive = buttonEl.injector.get(NgxPrintDirective);
79
+ expect(directive).toBeTruthy();
80
+ expect(component).toBeTruthy();
81
+ });
82
+
83
+ it('should test the @Input printStyle', () => {
84
+ const directive = buttonEl.injector.get(NgxPrintDirective);
85
+
86
+ // Create a spy on the instance's method
87
+ vi.spyOn(directive, 'returnStyleValues');
88
+
89
+ // Call the function before checking if it has been called
90
+ directive.returnStyleValues();
91
+
92
+ // Check if returnStyleValues has been called
93
+ expect(directive.returnStyleValues).toHaveBeenCalled();
94
+ });
95
+
96
+ it('should returns a string from array of objects', async () => {
97
+ vi.spyOn(window, 'open');
98
+ component.printStyle.set(styleSheet);
99
+ await fixture.whenStable();
100
+
101
+ const directive = buttonEl.injector.get(NgxPrintDirective);
102
+ buttonEl.triggerEventHandler('click', {});
103
+
104
+ // Ensure the print styles are correctly formatted in the document
105
+ expect(directive.returnStyleValues()).toEqual('<style> h2{border:solid 1px} h1{color:red;border:1px solid} </style>');
106
+ });
107
+
108
+ it('should preserve quotes and commas within style values', async () => {
109
+ vi.spyOn(window, 'open');
110
+ component.printStyle.set({
111
+ 'li::before': { content: '"→"' },
112
+ 'p': { 'font-family': '"Helvetica Neue", Arial, sans-serif', color: 'red' },
113
+ });
114
+ await fixture.whenStable();
115
+
116
+ const directive = buttonEl.injector.get(NgxPrintDirective);
117
+ buttonEl.triggerEventHandler('click', {});
118
+
119
+ expect(directive.returnStyleValues()).toEqual(
120
+ '<style> li::before{content:"→"} p{font-family:"Helvetica Neue", Arial, sans-serif;color:red} </style>',
121
+ );
122
+ });
123
+
124
+ it('should accept a raw CSS string for printStyle', async () => {
125
+ vi.spyOn(window, 'open');
126
+ component.printStyle.set('h1, h2 { color: red; }');
127
+ await fixture.whenStable();
128
+
129
+ const directive = buttonEl.injector.get(NgxPrintDirective);
130
+ buttonEl.triggerEventHandler('click', {});
131
+
132
+ expect(directive.returnStyleValues()).toEqual('<style> h1, h2 { color: red; } </style>');
133
+ });
134
+
135
+ it(`should popup a new window`, () => {
136
+ vi.spyOn(window, 'open');
137
+ // simulate click
138
+ buttonEl.triggerEventHandler('click', {});
139
+ expect(window.open).toHaveBeenCalled();
140
+ });
141
+
142
+ it('should apply class list to body element in new window', () => {
143
+ const body = {
144
+ className: '',
145
+ classList: { contains: (c: string) => body.className.split(' ').includes(c) },
146
+ innerHTML: '',
147
+ appendChild: vi.fn(),
148
+ };
149
+ const mockDocument = {
150
+ body,
151
+ open: vi.fn(),
152
+ close: vi.fn(),
153
+ head: { appendChild: vi.fn(), innerHTML: '' },
154
+ appendChild: vi.fn(),
155
+ createElement: (tag: string) => (tag === 'body' ? body : { innerHTML: '', appendChild: vi.fn(), textContent: '' }),
156
+ };
157
+ const mockWindow = {
158
+ document: mockDocument,
159
+ closed: true,
160
+ addEventListener: vi.fn(),
161
+ };
162
+ vi.spyOn(window, 'open').mockReturnValue(mockWindow as unknown as Window);
163
+
164
+ buttonEl.triggerEventHandler('click', {});
165
+
166
+ expect(mockDocument.body.classList.contains('theme-dark')).toBe(true);
167
+ });
168
+
169
+ it('should preserve the print section root element attributes (e.g. style)', () => {
170
+ const body = {
171
+ className: '',
172
+ classList: { contains: () => false },
173
+ innerHTML: '',
174
+ appendChild: vi.fn(),
175
+ };
176
+ const mockDocument = {
177
+ body,
178
+ open: vi.fn(),
179
+ close: vi.fn(),
180
+ head: { appendChild: vi.fn(), innerHTML: '' },
181
+ appendChild: vi.fn(),
182
+ createElement: (tag: string) => (tag === 'body' ? body : { innerHTML: '', appendChild: vi.fn(), textContent: '' }),
183
+ };
184
+ const mockWindow = {
185
+ document: mockDocument,
186
+ closed: true,
187
+ addEventListener: vi.fn(),
188
+ };
189
+ vi.spyOn(window, 'open').mockReturnValue(mockWindow as unknown as Window);
190
+
191
+ buttonEl.triggerEventHandler('click', {});
192
+
193
+ // The print section's own id and inline style must survive (not just its children's).
194
+ expect(mockDocument.body.innerHTML).toContain('id="print-section"');
195
+ expect(mockDocument.body.innerHTML).toContain('border: 2px solid red');
196
+ });
197
+
198
+ it('should emit printComplete when printing finishes', () => {
199
+ vi.useFakeTimers();
200
+ const body = {
201
+ className: '',
202
+ classList: { contains: () => false },
203
+ innerHTML: '',
204
+ appendChild: vi.fn(),
205
+ };
206
+ const mockDocument = {
207
+ body,
208
+ open: vi.fn(),
209
+ close: vi.fn(),
210
+ head: { appendChild: vi.fn(), innerHTML: '' },
211
+ appendChild: vi.fn(),
212
+ createElement: (tag: string) => (tag === 'body' ? body : { innerHTML: '', appendChild: vi.fn(), textContent: '' }),
213
+ };
214
+ const mockWindow = {
215
+ document: mockDocument,
216
+ closed: true,
217
+ addEventListener: vi.fn(),
218
+ };
219
+ vi.spyOn(window, 'open').mockReturnValue(mockWindow as unknown as Window);
220
+
221
+ return new Promise<void>(resolve => {
222
+ const directive = buttonEl.injector.get(NgxPrintDirective);
223
+ directive.printCompleted.subscribe(() => {
224
+ expect(true).toBe(true);
225
+ vi.useRealTimers();
226
+ resolve();
227
+ });
228
+ buttonEl.triggerEventHandler('click', null);
229
+ vi.advanceTimersByTime(600);
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,72 @@
1
+ import { Directive, input, output } from '@angular/core';
2
+ import { PrintBase, PrintStyleInput } from './ngx-print.base';
3
+ import { PrintOptions } from './print-options';
4
+ import { take } from 'rxjs';
5
+
6
+ @Directive({
7
+ selector: '[ngxPrint]',
8
+ standalone: true,
9
+ host: {
10
+ '(click)': 'print()',
11
+ },
12
+ })
13
+ export class NgxPrintDirective extends PrintBase {
14
+ /**
15
+ * Prevents the print dialog from opening on the window
16
+ */
17
+ readonly previewOnly = input(false);
18
+
19
+ readonly printSectionId = input('');
20
+
21
+ readonly printTitle = input('');
22
+
23
+ readonly useExistingCss = input(false);
24
+
25
+ /**
26
+ * A delay in milliseconds to force the print dialog to wait before opened. Default: 0
27
+ */
28
+ readonly printDelay = input(0);
29
+
30
+ /**
31
+ * Whether to close the window after print() returns.
32
+ */
33
+ readonly closeWindow = input(true);
34
+
35
+ /**
36
+ * Class attribute to apply to the body element.
37
+ */
38
+ readonly bodyClass = input('');
39
+
40
+ /**
41
+ * Which PrintMethod (iframe/window/tab) to use.
42
+ */
43
+ readonly printMethod = input<PrintOptions['printMethod']>('window');
44
+
45
+ readonly printStyle = input<PrintStyleInput>({});
46
+
47
+ readonly styleSheetFile = input('');
48
+
49
+ readonly printCompleted = output<void>();
50
+
51
+ public override print(): void {
52
+ // Inputs carry side effects on PrintBase's internal style state, so they're applied
53
+ // synchronously here rather than via effect() (effects shouldn't propagate state).
54
+ super.setPrintStyle(this.printStyle());
55
+ super.setStyleSheetFile(this.styleSheetFile());
56
+
57
+ super.print({
58
+ printSectionId: this.printSectionId(),
59
+ printTitle: this.printTitle(),
60
+ useExistingCss: this.useExistingCss(),
61
+ bodyClass: this.bodyClass(),
62
+ printMethod: this.printMethod(),
63
+ previewOnly: this.previewOnly(),
64
+ closeWindow: this.closeWindow(),
65
+ printDelay: this.printDelay(),
66
+ });
67
+
68
+ this.printComplete.pipe(take(1)).subscribe(() => {
69
+ this.printCompleted.emit();
70
+ });
71
+ }
72
+ }
@@ -0,0 +1,8 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { NgxPrintDirective } from './ngx-print.directive';
3
+
4
+ @NgModule({
5
+ imports: [NgxPrintDirective],
6
+ exports: [NgxPrintDirective],
7
+ })
8
+ export class NgxPrintModule {}