angular-my-report 0.1.0 → 0.1.1
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/README.md +124 -40
- package/fesm2022/angular-my-report.mjs +7931 -0
- package/fesm2022/angular-my-report.mjs.map +1 -0
- package/index.d.ts +5 -0
- package/lib/engine/barcode.d.ts +17 -0
- package/lib/engine/chart.d.ts +26 -0
- package/lib/engine/chartLayout.d.ts +45 -0
- package/lib/engine/crosstab.d.ts +22 -0
- package/lib/engine/dataSources.d.ts +35 -0
- package/lib/engine/expressions.d.ts +46 -0
- package/lib/engine/measure.d.ts +7 -0
- package/lib/engine/parameters.d.ts +15 -0
- package/lib/engine/qr.d.ts +8 -0
- package/lib/engine/renderer.d.ts +43 -0
- package/lib/engine/table.d.ts +22 -0
- package/lib/model/defaults.d.ts +31 -0
- package/lib/model/factories.d.ts +31 -0
- package/lib/model/types.d.ts +244 -0
- package/lib/ui/css.d.ts +2 -0
- package/lib/ui/designer/banded-table-dialog.component.d.ts +27 -0
- package/lib/ui/designer/canvas.component.d.ts +41 -0
- package/lib/ui/designer/data-source-dialog.component.d.ts +37 -0
- package/lib/ui/designer/element-box.component.d.ts +32 -0
- package/lib/ui/designer/graphics.components.d.ts +36 -0
- package/lib/ui/designer/left-panel.component.d.ts +37 -0
- package/lib/ui/designer/safe-image.pipe.d.ts +13 -0
- package/lib/ui/designer/table-view.component.d.ts +19 -0
- package/lib/ui/designer.component.d.ts +59 -0
- package/lib/ui/designer.store.d.ts +82 -0
- package/lib/ui/inputs.d.ts +8 -0
- package/lib/ui/properties/band-props.component.d.ts +15 -0
- package/lib/ui/properties/element-props.component.d.ts +46 -0
- package/lib/ui/properties/expr-input.component.d.ts +43 -0
- package/lib/ui/properties/graphics-props.component.d.ts +52 -0
- package/lib/ui/properties/image-picker.component.d.ts +15 -0
- package/lib/ui/properties/properties-panel.component.d.ts +13 -0
- package/lib/ui/properties/report-extras.component.d.ts +40 -0
- package/lib/ui/properties/report-props.component.d.ts +34 -0
- package/lib/ui/properties/table-props.component.d.ts +31 -0
- package/lib/ui/viewer/page-view.component.d.ts +17 -0
- package/lib/ui/viewer/render.d.ts +43 -0
- package/lib/ui/viewer/viewer.component.d.ts +99 -0
- package/lib/ui/viewer.component.d.ts +40 -0
- package/package.json +16 -5
- package/{src/public-api.ts → public-api.d.ts} +0 -5
- package/ng-package.json +0 -7
- package/src/lib/engine/barcode.ts +0 -118
- package/src/lib/engine/chart.ts +0 -85
- package/src/lib/engine/chartLayout.ts +0 -265
- package/src/lib/engine/crosstab.ts +0 -96
- package/src/lib/engine/dataSources.ts +0 -204
- package/src/lib/engine/expressions.ts +0 -734
- package/src/lib/engine/measure.ts +0 -62
- package/src/lib/engine/parameters.ts +0 -96
- package/src/lib/engine/qr.ts +0 -26
- package/src/lib/engine/renderer.ts +0 -324
- package/src/lib/engine/table.ts +0 -58
- package/src/lib/model/defaults.ts +0 -249
- package/src/lib/model/factories.ts +0 -324
- package/src/lib/model/types.ts +0 -281
- package/src/lib/ui/css.ts +0 -12
- package/src/lib/ui/designer/banded-table-dialog.component.ts +0 -127
- package/src/lib/ui/designer/canvas.component.ts +0 -281
- package/src/lib/ui/designer/data-source-dialog.component.ts +0 -201
- package/src/lib/ui/designer/element-box.component.ts +0 -138
- package/src/lib/ui/designer/graphics.components.ts +0 -140
- package/src/lib/ui/designer/left-panel.component.ts +0 -189
- package/src/lib/ui/designer/safe-image.pipe.ts +0 -16
- package/src/lib/ui/designer/table-view.component.ts +0 -120
- package/src/lib/ui/designer.component.ts +0 -296
- package/src/lib/ui/designer.store.ts +0 -230
- package/src/lib/ui/inputs.ts +0 -34
- package/src/lib/ui/myreport.css +0 -267
- package/src/lib/ui/properties/band-props.component.ts +0 -86
- package/src/lib/ui/properties/element-props.component.ts +0 -326
- package/src/lib/ui/properties/expr-input.component.ts +0 -191
- package/src/lib/ui/properties/graphics-props.component.ts +0 -260
- package/src/lib/ui/properties/image-picker.component.ts +0 -62
- package/src/lib/ui/properties/properties-panel.component.ts +0 -37
- package/src/lib/ui/properties/report-extras.component.ts +0 -198
- package/src/lib/ui/properties/report-props.component.ts +0 -211
- package/src/lib/ui/properties/table-props.component.ts +0 -197
- package/src/lib/ui/viewer/page-view.component.ts +0 -73
- package/src/lib/ui/viewer/render.ts +0 -161
- package/src/lib/ui/viewer/viewer.component.ts +0 -555
- package/src/lib/ui/viewer.component.ts +0 -107
- package/tsconfig.lib.json +0 -12
- package/tsconfig.lib.prod.json +0 -5
|
@@ -1,555 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ChangeDetectionStrategy,
|
|
3
|
-
Component,
|
|
4
|
-
DestroyRef,
|
|
5
|
-
ElementRef,
|
|
6
|
-
HostListener,
|
|
7
|
-
afterNextRender,
|
|
8
|
-
computed,
|
|
9
|
-
inject,
|
|
10
|
-
input,
|
|
11
|
-
output,
|
|
12
|
-
signal,
|
|
13
|
-
viewChild,
|
|
14
|
-
} from '@angular/core';
|
|
15
|
-
import { FormsModule } from '@angular/forms';
|
|
16
|
-
import { NgTemplateOutlet } from '@angular/common';
|
|
17
|
-
import type { ReportParameter } from '../../model/types';
|
|
18
|
-
import { defaultBackground, defaultWatermark } from '../../model/defaults';
|
|
19
|
-
import { filterRows, paramChoices } from '../../engine/parameters';
|
|
20
|
-
import { DesignerStore } from '../designer.store';
|
|
21
|
-
import { buildDocument, type Bookmark } from './render';
|
|
22
|
-
import { PageViewComponent } from './page-view.component';
|
|
23
|
-
|
|
24
|
-
type ViewMode = 'continuous' | 'single' | 'two';
|
|
25
|
-
type ZoomMode = 'width' | 'page' | number;
|
|
26
|
-
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Report viewer: paged view with navigation, zoom, view modes, find, bookmarks, thumbnails,
|
|
30
|
-
* parameters, print and export. Used standalone (/viewer) and as the designer's preview.
|
|
31
|
-
*/
|
|
32
|
-
@Component({
|
|
33
|
-
selector: 'rpt-viewer',
|
|
34
|
-
standalone: true,
|
|
35
|
-
imports: [FormsModule, NgTemplateOutlet, PageViewComponent],
|
|
36
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
37
|
-
template: `
|
|
38
|
-
<div class="viewer" [class.embedded]="embedded()" #root>
|
|
39
|
-
<!-- Toolbar -->
|
|
40
|
-
<div class="viewer-toolbar">
|
|
41
|
-
<button class="vt" [class.on]="sidebar()" title="Bookmarks & pages" (click)="sidebar.set(!sidebar())">☰</button>
|
|
42
|
-
<button class="vt" title="Reload data" (click)="store.reloadData()">⟳</button>
|
|
43
|
-
<span class="vsep"></span>
|
|
44
|
-
<button class="vt" title="Print (Ctrl+P)" (click)="print()">🖨 Print</button>
|
|
45
|
-
<select class="vt-select" title="Save / export" [ngModel]="''" (ngModelChange)="save($event)">
|
|
46
|
-
<option value="" disabled>💾 Save as…</option>
|
|
47
|
-
<option value="pdf">PDF (print dialog → Save as PDF)</option>
|
|
48
|
-
<option value="html">HTML document</option>
|
|
49
|
-
<option value="csv">Data — CSV (Excel)</option>
|
|
50
|
-
<option value="json">Report file (.report.json)</option>
|
|
51
|
-
</select>
|
|
52
|
-
<span class="vsep"></span>
|
|
53
|
-
<button class="vt" title="First page (Home)" [disabled]="current() === 0" (click)="goTo(0)">⏮</button>
|
|
54
|
-
<button class="vt" title="Previous page (PgUp)" [disabled]="current() === 0" (click)="goTo(current() - 1)">◀</button>
|
|
55
|
-
<input class="vt-page" type="number" min="1" [max]="pageCount()" [ngModel]="current() + 1" (change)="goTo(+$any($event.target).value - 1)" />
|
|
56
|
-
<span class="muted">/ {{ pageCount() }}</span>
|
|
57
|
-
<button class="vt" title="Next page (PgDn)" [disabled]="current() >= pageCount() - 1" (click)="goTo(current() + 1)">▶</button>
|
|
58
|
-
<button class="vt" title="Last page (End)" [disabled]="current() >= pageCount() - 1" (click)="goTo(pageCount() - 1)">⏭</button>
|
|
59
|
-
<span class="vsep"></span>
|
|
60
|
-
<button class="vt" title="Zoom out" (click)="zoomStep(-1)">−</button>
|
|
61
|
-
<select class="vt-select" title="Zoom" [ngModel]="zoomKey()" (ngModelChange)="setZoom($event)">
|
|
62
|
-
<option value="width">Page width</option>
|
|
63
|
-
<option value="page">Whole page</option>
|
|
64
|
-
@for (z of zoomSteps; track z) {
|
|
65
|
-
<option [value]="'' + z">{{ z * 100 }}%</option>
|
|
66
|
-
}
|
|
67
|
-
@if (zoomKey() === 'custom') {
|
|
68
|
-
<option value="custom">{{ (zoom() * 100).toFixed(0) }}%</option>
|
|
69
|
-
}
|
|
70
|
-
</select>
|
|
71
|
-
<button class="vt" title="Zoom in" (click)="zoomStep(1)">+</button>
|
|
72
|
-
<select class="vt-select" title="View mode" [ngModel]="mode()" (ngModelChange)="setMode($event)">
|
|
73
|
-
<option value="continuous">Continuous</option>
|
|
74
|
-
<option value="single">Single page</option>
|
|
75
|
-
<option value="two">Two pages</option>
|
|
76
|
-
</select>
|
|
77
|
-
<span class="vsep"></span>
|
|
78
|
-
<button class="vt" [class.on]="findOpen()" title="Find (Ctrl+F)" (click)="toggleFind()">🔍</button>
|
|
79
|
-
@if (parameters().length) {
|
|
80
|
-
<button class="vt" [class.on]="paramsOpen()" title="Parameters" (click)="paramsOpen.set(!paramsOpen())">⚙ Parameters</button>
|
|
81
|
-
}
|
|
82
|
-
<button class="vt" title="Full screen" (click)="fullscreen()">⛶</button>
|
|
83
|
-
<span class="spacer"></span>
|
|
84
|
-
<strong class="viewer-title">{{ store.report().name }}</strong>
|
|
85
|
-
<span class="spacer"></span>
|
|
86
|
-
@if (embedded()) {
|
|
87
|
-
<button class="primary" (click)="closed.emit()">Close</button>
|
|
88
|
-
} @else {
|
|
89
|
-
@if (allowOpen()) {
|
|
90
|
-
<button class="vt" title="Open a .report.json file" (click)="file.click()">📂 Open</button>
|
|
91
|
-
<input #file type="file" accept=".json,application/json" hidden (change)="openFile($event)" />
|
|
92
|
-
}
|
|
93
|
-
@if (designerUrl()) {
|
|
94
|
-
<a class="vt" [href]="designerUrl()" title="Back to the designer">✎ Designer</a>
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
</div>
|
|
98
|
-
|
|
99
|
-
<!-- Parameters -->
|
|
100
|
-
@if (paramsOpen() && parameters().length) {
|
|
101
|
-
<form class="param-bar" (submit)="$event.preventDefault(); applyParams()">
|
|
102
|
-
@for (p of parameters(); track p.name) {
|
|
103
|
-
<label>
|
|
104
|
-
<span>{{ p.label || p.name }}</span>
|
|
105
|
-
@if (choices(p).length) {
|
|
106
|
-
<select [name]="p.name" [ngModel]="draft()[p.name]" (ngModelChange)="setDraft(p.name, $event)">
|
|
107
|
-
@for (c of choices(p); track c) {
|
|
108
|
-
<option [value]="c">{{ c }}</option>
|
|
109
|
-
}
|
|
110
|
-
</select>
|
|
111
|
-
} @else if (p.type === 'boolean') {
|
|
112
|
-
<input type="checkbox" [name]="p.name" [ngModel]="draft()[p.name] === 'true'" (ngModelChange)="setDraft(p.name, $event ? 'true' : 'false')" />
|
|
113
|
-
} @else {
|
|
114
|
-
<input [type]="p.type === 'number' ? 'number' : p.type === 'date' ? 'date' : 'text'" [name]="p.name" [ngModel]="draft()[p.name]" (ngModelChange)="setDraft(p.name, $event)" />
|
|
115
|
-
}
|
|
116
|
-
</label>
|
|
117
|
-
}
|
|
118
|
-
<button class="primary" type="submit">Apply</button>
|
|
119
|
-
</form>
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
<!-- Find -->
|
|
123
|
-
@if (findOpen()) {
|
|
124
|
-
<div class="find-bar">
|
|
125
|
-
<input #findBox placeholder="Find in report…" [ngModel]="query()" (ngModelChange)="setQuery($event)" (keydown.enter)="findStep($any($event).shiftKey ? -1 : 1)" (keydown.escape)="toggleFind()" />
|
|
126
|
-
<button class="vt" title="Previous (Shift+Enter)" [disabled]="!matches().length" (click)="findStep(-1)">▲</button>
|
|
127
|
-
<button class="vt" title="Next (Enter)" [disabled]="!matches().length" (click)="findStep(1)">▼</button>
|
|
128
|
-
<span class="muted">
|
|
129
|
-
@if (query().trim()) {
|
|
130
|
-
{{ matches().length ? matchIndex() + 1 + ' of ' + matches().length : 'No matches' }}
|
|
131
|
-
}
|
|
132
|
-
</span>
|
|
133
|
-
<button class="link" (click)="toggleFind()">✕</button>
|
|
134
|
-
</div>
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
<div class="viewer-body">
|
|
138
|
-
<!-- Sidebar -->
|
|
139
|
-
@if (sidebar()) {
|
|
140
|
-
<aside class="viewer-sidebar">
|
|
141
|
-
<div class="tabs">
|
|
142
|
-
<button [class.on]="sideTab() === 'bookmarks'" (click)="sideTab.set('bookmarks')">Bookmarks</button>
|
|
143
|
-
<button [class.on]="sideTab() === 'pages'" (click)="sideTab.set('pages')">Pages</button>
|
|
144
|
-
</div>
|
|
145
|
-
@if (sideTab() === 'bookmarks') {
|
|
146
|
-
@if (doc().bookmarks.length) {
|
|
147
|
-
<ul class="bm-tree">
|
|
148
|
-
<ng-container *ngTemplateOutlet="bmList; context: { $implicit: doc().bookmarks }" />
|
|
149
|
-
</ul>
|
|
150
|
-
} @else {
|
|
151
|
-
<p class="hint">Bookmarks are made from groups. Add a group to the report to get a clickable outline here.</p>
|
|
152
|
-
}
|
|
153
|
-
} @else {
|
|
154
|
-
<div class="thumbs">
|
|
155
|
-
@for (pg of doc().pages; track pg.index) {
|
|
156
|
-
<button class="thumb" [class.on]="pg.index === current()" (click)="goTo(pg.index)">
|
|
157
|
-
<div class="thumb-page" [style.zoom]="thumbScale()">
|
|
158
|
-
<rpt-page-view [page]="pg" [settings]="settings()" [watermark]="watermark()" [background]="background()" [watermarkText]="doc().watermarkText" />
|
|
159
|
-
</div>
|
|
160
|
-
<span>{{ pg.index + 1 }}</span>
|
|
161
|
-
</button>
|
|
162
|
-
}
|
|
163
|
-
</div>
|
|
164
|
-
}
|
|
165
|
-
</aside>
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
<!-- Pages -->
|
|
169
|
-
<div class="viewer-scroll" #scroll (scroll)="onScroll()">
|
|
170
|
-
@if (store.data().status === 'loading') {
|
|
171
|
-
<div class="viewer-msg">Loading data…</div>
|
|
172
|
-
}
|
|
173
|
-
@if (store.data().status === 'error') {
|
|
174
|
-
<div class="viewer-msg error">{{ store.data().error }}</div>
|
|
175
|
-
}
|
|
176
|
-
<div class="viewer-pages mode-{{ mode() }}" [style.zoom]="zoom()">
|
|
177
|
-
@for (pg of shownPages(); track pg.index) {
|
|
178
|
-
<rpt-page-view
|
|
179
|
-
[attr.data-page]="pg.index"
|
|
180
|
-
[page]="pg"
|
|
181
|
-
[settings]="settings()"
|
|
182
|
-
[watermark]="watermark()"
|
|
183
|
-
[background]="background()"
|
|
184
|
-
[watermarkText]="doc().watermarkText"
|
|
185
|
-
[highlights]="highlightSet()"
|
|
186
|
-
[active]="activeKey()"
|
|
187
|
-
/>
|
|
188
|
-
}
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
</div>
|
|
192
|
-
|
|
193
|
-
<!-- Status bar -->
|
|
194
|
-
<div class="viewer-status">
|
|
195
|
-
<span>Page {{ current() + 1 }} of {{ pageCount() }}</span>
|
|
196
|
-
<span>{{ doc().rowCount }} rows</span>
|
|
197
|
-
<span>{{ (zoom() * 100).toFixed(0) }}%</span>
|
|
198
|
-
@if (store.data().status === 'loading') {
|
|
199
|
-
<span>Loading…</span>
|
|
200
|
-
}
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
<ng-template #bmList let-items>
|
|
205
|
-
@for (b of asBookmarks(items); track $index) {
|
|
206
|
-
<li>
|
|
207
|
-
<button class="bm" (click)="goToBookmark(b)">{{ b.label }} <span class="muted">p{{ b.page + 1 }}</span></button>
|
|
208
|
-
@if (b.children.length) {
|
|
209
|
-
<ul>
|
|
210
|
-
<ng-container *ngTemplateOutlet="bmList; context: { $implicit: b.children }" />
|
|
211
|
-
</ul>
|
|
212
|
-
}
|
|
213
|
-
</li>
|
|
214
|
-
}
|
|
215
|
-
</ng-template>
|
|
216
|
-
`,
|
|
217
|
-
})
|
|
218
|
-
export class ViewerComponent {
|
|
219
|
-
protected readonly store = inject(DesignerStore);
|
|
220
|
-
/** Inside the designer (shows Close) vs. the standalone /viewer page. */
|
|
221
|
-
readonly embedded = input(false);
|
|
222
|
-
/** Standalone viewer: shows a "Designer" link to this URL. */
|
|
223
|
-
readonly designerUrl = input<string | null | undefined>(null);
|
|
224
|
-
/** Standalone viewer: shows the Open button for local .report.json files. */
|
|
225
|
-
readonly allowOpen = input(true);
|
|
226
|
-
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
227
|
-
readonly closed = output<void>();
|
|
228
|
-
|
|
229
|
-
private readonly root = viewChild.required<ElementRef<HTMLElement>>('root');
|
|
230
|
-
private readonly scroll = viewChild.required<ElementRef<HTMLElement>>('scroll');
|
|
231
|
-
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
|
232
|
-
|
|
233
|
-
protected readonly zoomSteps = ZOOM_STEPS;
|
|
234
|
-
protected readonly mode = signal<ViewMode>('continuous');
|
|
235
|
-
protected readonly zoomMode = signal<ZoomMode>(1);
|
|
236
|
-
protected readonly current = signal(0);
|
|
237
|
-
protected readonly sidebar = signal(false);
|
|
238
|
-
protected readonly sideTab = signal<'bookmarks' | 'pages'>('bookmarks');
|
|
239
|
-
protected readonly findOpen = signal(false);
|
|
240
|
-
protected readonly query = signal('');
|
|
241
|
-
protected readonly matchIndex = signal(0);
|
|
242
|
-
protected readonly paramsOpen = signal(true);
|
|
243
|
-
private readonly viewport = signal({ w: 1000, h: 800 });
|
|
244
|
-
|
|
245
|
-
protected readonly settings = computed(() => this.store.report().page);
|
|
246
|
-
protected readonly watermark = computed(() => ({ ...defaultWatermark(), ...this.store.report().watermark }));
|
|
247
|
-
protected readonly background = computed(() => ({ ...defaultBackground(), ...this.store.report().background }));
|
|
248
|
-
protected readonly parameters = computed(() => (this.store.report().parameters ?? []).filter((p) => p.name));
|
|
249
|
-
|
|
250
|
-
protected readonly doc = computed(() => buildDocument(this.store.report(), this.store.data().rows, this.store.params(), this.store.data().root));
|
|
251
|
-
protected readonly pageCount = computed(() => this.doc().pages.length);
|
|
252
|
-
protected readonly shownPages = computed(() => {
|
|
253
|
-
const pages = this.doc().pages;
|
|
254
|
-
return this.mode() === 'single' ? pages.slice(this.current(), this.current() + 1) : pages;
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
/** Effective zoom factor (fit modes depend on the viewport and the page size). */
|
|
258
|
-
protected readonly zoom = computed(() => {
|
|
259
|
-
const z = this.zoomMode();
|
|
260
|
-
if (typeof z === 'number') return z;
|
|
261
|
-
const { w, h } = this.viewport();
|
|
262
|
-
const s = this.settings();
|
|
263
|
-
const pageW = this.mode() === 'two' ? s.width * 2 + 24 : s.width;
|
|
264
|
-
const fitW = (w - 48) / pageW;
|
|
265
|
-
if (z === 'width') return Math.max(0.1, fitW);
|
|
266
|
-
const pageH = this.doc().pages[this.current()]?.height ?? s.height;
|
|
267
|
-
return Math.max(0.1, Math.min(fitW, (h - 48) / pageH));
|
|
268
|
-
});
|
|
269
|
-
protected readonly zoomKey = computed(() => {
|
|
270
|
-
const z = this.zoomMode();
|
|
271
|
-
return typeof z === 'number' ? (ZOOM_STEPS.includes(z) ? String(z) : 'custom') : z;
|
|
272
|
-
});
|
|
273
|
-
protected readonly thumbScale = computed(() => 150 / this.settings().width);
|
|
274
|
-
|
|
275
|
-
// Find: every item whose text contains the query.
|
|
276
|
-
protected readonly matches = computed(() => {
|
|
277
|
-
const q = this.query().trim().toLowerCase();
|
|
278
|
-
if (!q) return [] as { key: string; page: number }[];
|
|
279
|
-
return this.doc().pages.flatMap((p) => p.bands.flatMap((b) => b.items.filter((it) => it.text.toLowerCase().includes(q)).map((it) => ({ key: it.key, page: p.index }))));
|
|
280
|
-
});
|
|
281
|
-
protected readonly highlightSet = computed<ReadonlySet<string>>(() => new Set(this.matches().map((m) => m.key)));
|
|
282
|
-
protected readonly activeKey = computed(() => this.matches()[this.matchIndex()]?.key ?? null);
|
|
283
|
-
|
|
284
|
-
// Parameters: edit a draft, apply to re-run the report.
|
|
285
|
-
protected readonly draft = signal<Record<string, string>>(
|
|
286
|
-
Object.fromEntries(Object.entries(this.store.params()).map(([k, v]) => [k, v == null ? '' : String(v)])),
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
constructor() {
|
|
290
|
-
const destroyRef = inject(DestroyRef);
|
|
291
|
-
afterNextRender(() => {
|
|
292
|
-
const el = this.scroll().nativeElement;
|
|
293
|
-
const ro = new ResizeObserver(() => this.viewport.set({ w: el.clientWidth, h: el.clientHeight }));
|
|
294
|
-
ro.observe(el);
|
|
295
|
-
destroyRef.onDestroy(() => ro.disconnect());
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
protected choices(p: ReportParameter): string[] {
|
|
300
|
-
return paramChoices(p);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
protected setDraft(name: string, value: unknown): void {
|
|
304
|
-
this.draft.update((d) => ({ ...d, [name]: value == null ? '' : String(value) }));
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
protected applyParams(): void {
|
|
308
|
-
this.store.paramInput.set({ ...this.draft() });
|
|
309
|
-
this.current.set(0);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
protected asBookmarks(v: unknown): Bookmark[] {
|
|
313
|
-
return v as Bookmark[];
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// --- Navigation -----------------------------------------------------------------------------
|
|
317
|
-
|
|
318
|
-
protected goTo(index: number, top = 0): void {
|
|
319
|
-
const i = Math.max(0, Math.min(this.pageCount() - 1, Math.round(index) || 0));
|
|
320
|
-
this.current.set(i);
|
|
321
|
-
if (this.mode() === 'single') {
|
|
322
|
-
this.scroll().nativeElement.scrollTop = top * this.zoom();
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
const el = this.scroll().nativeElement.querySelector<HTMLElement>(`[data-page="${i}"]`);
|
|
326
|
-
if (!el) return;
|
|
327
|
-
const box = this.scroll().nativeElement;
|
|
328
|
-
box.scrollTop += el.getBoundingClientRect().top - box.getBoundingClientRect().top - 12 + top * this.zoom();
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
protected goToBookmark(b: Bookmark): void {
|
|
332
|
-
this.goTo(b.page, b.top);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Continuous modes: the current page is the one at the top third of the viewport. */
|
|
336
|
-
protected onScroll(): void {
|
|
337
|
-
if (this.mode() === 'single') return;
|
|
338
|
-
const box = this.scroll().nativeElement;
|
|
339
|
-
const limit = box.getBoundingClientRect().top + box.clientHeight / 3;
|
|
340
|
-
let cur = 0;
|
|
341
|
-
box.querySelectorAll<HTMLElement>('[data-page]').forEach((el) => {
|
|
342
|
-
if (el.getBoundingClientRect().top <= limit) cur = Number(el.dataset['page']);
|
|
343
|
-
});
|
|
344
|
-
if (cur !== this.current()) this.current.set(cur);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
protected setMode(m: ViewMode): void {
|
|
348
|
-
const page = this.current();
|
|
349
|
-
this.mode.set(m);
|
|
350
|
-
setTimeout(() => this.goTo(page));
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// --- Zoom -----------------------------------------------------------------------------------
|
|
354
|
-
|
|
355
|
-
protected setZoom(v: string): void {
|
|
356
|
-
if (v === 'custom') return;
|
|
357
|
-
this.zoomMode.set(v === 'width' || v === 'page' ? v : Number(v));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
protected zoomStep(dir: 1 | -1): void {
|
|
361
|
-
const z = this.zoom();
|
|
362
|
-
const next = dir > 0 ? ZOOM_STEPS.find((s) => s > z + 0.001) : [...ZOOM_STEPS].reverse().find((s) => s < z - 0.001);
|
|
363
|
-
if (next) this.zoomMode.set(next);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// --- Find -----------------------------------------------------------------------------------
|
|
367
|
-
|
|
368
|
-
protected toggleFind(): void {
|
|
369
|
-
this.findOpen.set(!this.findOpen());
|
|
370
|
-
if (this.findOpen()) setTimeout(() => this.findBox()?.nativeElement.select());
|
|
371
|
-
else this.query.set('');
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
protected setQuery(q: string): void {
|
|
375
|
-
this.query.set(q);
|
|
376
|
-
this.matchIndex.set(0);
|
|
377
|
-
if (this.matches().length) this.revealMatch();
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
protected findStep(dir: 1 | -1): void {
|
|
381
|
-
const n = this.matches().length;
|
|
382
|
-
if (!n) return;
|
|
383
|
-
this.matchIndex.set((this.matchIndex() + dir + n) % n);
|
|
384
|
-
this.revealMatch();
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
private revealMatch(): void {
|
|
388
|
-
const m = this.matches()[this.matchIndex()];
|
|
389
|
-
if (!m) return;
|
|
390
|
-
if (this.mode() === 'single') this.current.set(m.page);
|
|
391
|
-
setTimeout(() => {
|
|
392
|
-
const el = this.scroll().nativeElement.querySelector<HTMLElement>(`[data-key="${m.key}"]`);
|
|
393
|
-
el?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
394
|
-
this.onScroll();
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// --- Print & export -------------------------------------------------------------------------
|
|
399
|
-
|
|
400
|
-
/** Runs `fn` with every page rendered at 100% (single-page mode would otherwise print one page). */
|
|
401
|
-
private withAllPages(fn: () => void): void {
|
|
402
|
-
const mode = this.mode();
|
|
403
|
-
const zoom = this.zoomMode();
|
|
404
|
-
this.mode.set('continuous');
|
|
405
|
-
this.zoomMode.set(1);
|
|
406
|
-
setTimeout(() => {
|
|
407
|
-
try {
|
|
408
|
-
fn();
|
|
409
|
-
} finally {
|
|
410
|
-
this.mode.set(mode);
|
|
411
|
-
this.zoomMode.set(zoom);
|
|
412
|
-
}
|
|
413
|
-
}, 60);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
protected print(): void {
|
|
417
|
-
const s = this.settings();
|
|
418
|
-
const height = s.continuous ? this.doc().pages[0]?.height ?? s.height : s.height;
|
|
419
|
-
this.withAllPages(() => {
|
|
420
|
-
// Print a copy of the pages placed directly under <body>, so the host page's layout
|
|
421
|
-
// (scroll containers, overflow, positioning) can't clip or shift them.
|
|
422
|
-
const root = document.createElement('div');
|
|
423
|
-
root.className = 'myreport myreport-print-root';
|
|
424
|
-
const pages = document.createElement('div');
|
|
425
|
-
pages.className = 'viewer-pages';
|
|
426
|
-
pages.innerHTML = this.scroll().nativeElement.querySelector('.viewer-pages')!.innerHTML;
|
|
427
|
-
root.appendChild(pages);
|
|
428
|
-
document.body.appendChild(root);
|
|
429
|
-
const style = document.createElement('style');
|
|
430
|
-
style.textContent = `@page { size: ${s.width}px ${height}px; margin: 0; }`;
|
|
431
|
-
document.head.appendChild(style);
|
|
432
|
-
document.documentElement.classList.add('myreport-printing');
|
|
433
|
-
const cleanup = () => {
|
|
434
|
-
document.documentElement.classList.remove('myreport-printing');
|
|
435
|
-
style.remove();
|
|
436
|
-
root.remove();
|
|
437
|
-
window.removeEventListener('afterprint', cleanup);
|
|
438
|
-
};
|
|
439
|
-
window.addEventListener('afterprint', cleanup);
|
|
440
|
-
// Cleanup runs on afterprint (print() may return before the dialog closes); leftovers are invisible on screen.
|
|
441
|
-
window.print();
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
protected save(kind: string): void {
|
|
446
|
-
const name = this.store.report().name.replace(/[^\w\- ]+/g, '').trim() || 'report';
|
|
447
|
-
if (kind === 'pdf') return this.print();
|
|
448
|
-
if (kind === 'json') return download(`${name}.report.json`, JSON.stringify(this.store.report(), null, 2), 'application/json');
|
|
449
|
-
if (kind === 'csv') return download(`${name}.csv`, '' + this.csv(), 'text/csv;charset=utf-8');
|
|
450
|
-
if (kind === 'html') {
|
|
451
|
-
this.withAllPages(() => {
|
|
452
|
-
const pages = this.scroll().nativeElement.querySelector('.viewer-pages')!.innerHTML;
|
|
453
|
-
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(
|
|
454
|
-
this.store.report().name,
|
|
455
|
-
)}</title><style>${EXPORT_CSS}</style></head><body><div class="viewer-pages">${pages}</div></body></html>`;
|
|
456
|
-
download(`${name}.html`, html, 'text/html;charset=utf-8');
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/** The report's data rows (after the report filter) as CSV. */
|
|
462
|
-
private csv(): string {
|
|
463
|
-
const rows = filterRows(this.store.report(), this.store.data().rows, this.store.params());
|
|
464
|
-
const fields = this.store.data().fields.map((f) => f.name);
|
|
465
|
-
const cell = (v: unknown) => {
|
|
466
|
-
const s = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
467
|
-
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
468
|
-
};
|
|
469
|
-
const get = (r: Record<string, unknown>, path: string) => path.split('.').reduce<unknown>((o, k) => (o == null ? o : (o as Record<string, unknown>)[k]), r);
|
|
470
|
-
return [fields.map(cell).join(','), ...rows.map((r) => fields.map((f) => cell(get(r, f))).join(','))].join('\r\n');
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
protected fullscreen(): void {
|
|
474
|
-
const el = this.root().nativeElement;
|
|
475
|
-
if (document.fullscreenElement) document.exitFullscreen();
|
|
476
|
-
else el.requestFullscreen?.();
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
protected async openFile(e: Event): Promise<void> {
|
|
480
|
-
const inputEl = e.target as HTMLInputElement;
|
|
481
|
-
const f = inputEl.files?.[0];
|
|
482
|
-
inputEl.value = '';
|
|
483
|
-
if (!f) return;
|
|
484
|
-
try {
|
|
485
|
-
const r = JSON.parse(await f.text());
|
|
486
|
-
if (!r || r.version !== 1 || !Array.isArray(r.bands)) throw new Error('Not a report definition file');
|
|
487
|
-
this.store.load(r);
|
|
488
|
-
this.current.set(0);
|
|
489
|
-
} catch (err) {
|
|
490
|
-
alert('Could not open file: ' + (err as Error).message);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// --- Keyboard -------------------------------------------------------------------------------
|
|
495
|
-
|
|
496
|
-
@HostListener('window:keydown', ['$event'])
|
|
497
|
-
protected onKey(e: KeyboardEvent): void {
|
|
498
|
-
// Standalone viewers react only when focus is inside them (or nowhere), so a host page keeps its own shortcuts.
|
|
499
|
-
const t = e.target as HTMLElement | null;
|
|
500
|
-
if (!this.embedded() && t && t !== document.body && t !== document.documentElement && !this.host.nativeElement.contains(t)) return;
|
|
501
|
-
const typing = t?.closest?.('input, textarea, select');
|
|
502
|
-
const ctrl = e.ctrlKey || e.metaKey;
|
|
503
|
-
if (ctrl && e.key.toLowerCase() === 'f') {
|
|
504
|
-
e.preventDefault();
|
|
505
|
-
if (!this.findOpen()) this.toggleFind();
|
|
506
|
-
else this.findBox()?.nativeElement.select();
|
|
507
|
-
} else if (ctrl && e.key.toLowerCase() === 'p') {
|
|
508
|
-
e.preventDefault();
|
|
509
|
-
this.print();
|
|
510
|
-
} else if (typing) {
|
|
511
|
-
return;
|
|
512
|
-
} else if (e.key === 'PageDown') {
|
|
513
|
-
e.preventDefault();
|
|
514
|
-
this.goTo(this.current() + 1);
|
|
515
|
-
} else if (e.key === 'PageUp') {
|
|
516
|
-
e.preventDefault();
|
|
517
|
-
this.goTo(this.current() - 1);
|
|
518
|
-
} else if (e.key === 'Home' && ctrl) {
|
|
519
|
-
this.goTo(0);
|
|
520
|
-
} else if (e.key === 'End' && ctrl) {
|
|
521
|
-
this.goTo(this.pageCount() - 1);
|
|
522
|
-
} else if ((e.key === '+' || e.key === '=') && ctrl) {
|
|
523
|
-
e.preventDefault();
|
|
524
|
-
this.zoomStep(1);
|
|
525
|
-
} else if (e.key === '-' && ctrl) {
|
|
526
|
-
e.preventDefault();
|
|
527
|
-
this.zoomStep(-1);
|
|
528
|
-
} else if (e.key === 'Escape') {
|
|
529
|
-
if (this.findOpen()) this.toggleFind();
|
|
530
|
-
else if (this.embedded()) this.closed.emit();
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function download(filename: string, content: string, type: string): void {
|
|
536
|
-
const a = document.createElement('a');
|
|
537
|
-
a.href = URL.createObjectURL(new Blob([content], { type }));
|
|
538
|
-
a.download = filename;
|
|
539
|
-
a.click();
|
|
540
|
-
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const escapeHtml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
544
|
-
|
|
545
|
-
/** Styles a saved HTML report needs (elements carry their own inline styles). */
|
|
546
|
-
const EXPORT_CSS = `
|
|
547
|
-
*{box-sizing:border-box}body{margin:0;background:#e6e8eb;font-family:'Segoe UI',Arial,sans-serif}
|
|
548
|
-
.viewer-pages{display:flex;flex-direction:column;align-items:center;gap:20px;padding:24px}
|
|
549
|
-
.rpt-page{position:relative;background:#fff;box-shadow:0 2px 10px rgba(0,0,0,.25);overflow:hidden;flex-shrink:0;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
550
|
-
.rpt-bg{position:absolute;inset:0;width:100%;height:100%;object-position:center}
|
|
551
|
-
.rpt-watermark{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;overflow:hidden;z-index:5}
|
|
552
|
-
.rpt-watermark span{white-space:nowrap;line-height:1}
|
|
553
|
-
rpt-page-view{display:block}
|
|
554
|
-
@media print{body{background:#fff}.viewer-pages{padding:0;gap:0;display:block}.rpt-page{box-shadow:none;break-after:page}.rpt-page:last-child{break-after:auto}}
|
|
555
|
-
`;
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, ViewEncapsulation, booleanAttribute, effect, inject, input, output, signal, untracked } from '@angular/core';
|
|
2
|
-
import type { DataSourceType, ReportDefinition } from '../model/types';
|
|
3
|
-
import { newReport } from '../model/defaults';
|
|
4
|
-
import { DesignerStore, fetchReport, type DataState } from './designer.store';
|
|
5
|
-
import { ViewerComponent } from './viewer/viewer.component';
|
|
6
|
-
import { parseParams, parseReport } from './inputs';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The report viewer.
|
|
10
|
-
*
|
|
11
|
-
* ```html
|
|
12
|
-
* <myreport-viewer path="/reports/sales.report.json" datasource="/api/orders" type="json" />
|
|
13
|
-
* <myreport-viewer path="/reports/orders.report.json" datasource="https://host/odata/Orders" type="odata" />
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
@Component({
|
|
17
|
-
selector: 'myreport-viewer',
|
|
18
|
-
standalone: true,
|
|
19
|
-
imports: [ViewerComponent],
|
|
20
|
-
providers: [DesignerStore],
|
|
21
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
22
|
-
encapsulation: ViewEncapsulation.None,
|
|
23
|
-
styleUrl: './myreport.css',
|
|
24
|
-
// A class binding (not a static host class) so classes the host page set on the element are kept.
|
|
25
|
-
host: { '[class.myreport]': 'true' },
|
|
26
|
-
template: `
|
|
27
|
-
@if (error()) {
|
|
28
|
-
<div class="viewer-msg error">{{ error() }}</div>
|
|
29
|
-
}
|
|
30
|
-
<rpt-viewer [designerUrl]="designerUrl()" [allowOpen]="allowOpen()" />
|
|
31
|
-
`,
|
|
32
|
-
})
|
|
33
|
-
export class MyReportViewerComponent {
|
|
34
|
-
private readonly store = inject(DesignerStore);
|
|
35
|
-
|
|
36
|
-
/** URL of the .report.json to show. */
|
|
37
|
-
readonly path = input<string | null | undefined>(null);
|
|
38
|
-
/** A report definition (object or JSON string); used when `path` is empty. */
|
|
39
|
-
readonly report = input<ReportDefinition | string | null | undefined>(null);
|
|
40
|
-
/**
|
|
41
|
-
* The report's data, replacing its own data source: a URL (JSON file/API, or OData service / entity-set URL),
|
|
42
|
-
* a JSON string, or (as a property) an array/object. Empty = use the report's data source.
|
|
43
|
-
*/
|
|
44
|
-
readonly datasource = input<unknown>(null);
|
|
45
|
-
/** How `datasource` is read: `json` (default) or `odata`. */
|
|
46
|
-
readonly type = input<DataSourceType | null | undefined>('json');
|
|
47
|
-
/** Parameter values: object, JSON string, or query string (`Country=Egypt&Year=2024`). */
|
|
48
|
-
readonly params = input<Record<string, unknown> | string | null | undefined>(null);
|
|
49
|
-
/** Shows a "Designer" link in the toolbar pointing at this URL. */
|
|
50
|
-
readonly designerUrl = input<string | null | undefined>(null);
|
|
51
|
-
/** Shows the toolbar's Open button for local .report.json files (default true). */
|
|
52
|
-
readonly allowOpen = input(true, { transform: booleanAttribute });
|
|
53
|
-
|
|
54
|
-
/** Fires with the report once it is loaded. */
|
|
55
|
-
readonly loaded = output<ReportDefinition>();
|
|
56
|
-
/** Fires with a message when the report or its data could not be loaded. */
|
|
57
|
-
readonly loadError = output<string>();
|
|
58
|
-
/** Fires with the rows once the data is loaded. */
|
|
59
|
-
readonly dataLoaded = output<DataState['rows']>();
|
|
60
|
-
|
|
61
|
-
protected readonly error = signal<string | null>(null);
|
|
62
|
-
|
|
63
|
-
constructor() {
|
|
64
|
-
effect((onCleanup) => {
|
|
65
|
-
const path = this.path()?.trim();
|
|
66
|
-
const report = this.report();
|
|
67
|
-
untracked(() => {
|
|
68
|
-
this.error.set(null);
|
|
69
|
-
if (path) {
|
|
70
|
-
const ac = new AbortController();
|
|
71
|
-
onCleanup(() => ac.abort());
|
|
72
|
-
fetchReport(path, ac.signal)
|
|
73
|
-
.then((r) => {
|
|
74
|
-
this.store.load(r);
|
|
75
|
-
this.loaded.emit(r);
|
|
76
|
-
})
|
|
77
|
-
.catch((e) => {
|
|
78
|
-
if (ac.signal.aborted) return;
|
|
79
|
-
const msg = `Could not load ${path}: ${(e as Error).message}`;
|
|
80
|
-
this.error.set(msg);
|
|
81
|
-
this.loadError.emit(msg);
|
|
82
|
-
});
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
const def = parseReport(report) ?? newReport();
|
|
86
|
-
this.store.load(def);
|
|
87
|
-
if (report) this.loaded.emit(def);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
effect(() => {
|
|
92
|
-
const src = this.datasource();
|
|
93
|
-
const type = this.type() === 'odata' ? 'odata' : 'json';
|
|
94
|
-
this.store.dataOverride.set(src == null || src === '' ? null : { source: src, type });
|
|
95
|
-
});
|
|
96
|
-
effect(() => this.store.externalParams.set(parseParams(this.params())));
|
|
97
|
-
|
|
98
|
-
// Data status → (dataLoaded) / (loadError)
|
|
99
|
-
effect(() => {
|
|
100
|
-
const d = this.store.data();
|
|
101
|
-
untracked(() => {
|
|
102
|
-
if (d.status === 'ready') this.dataLoaded.emit(d.rows);
|
|
103
|
-
else if (d.status === 'error' && d.error) this.loadError.emit(d.error);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
}
|
package/tsconfig.lib.json
DELETED