@embedpdf/angular 3.0.0-next.14 → 3.0.0-next.15
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 +309 -0
- package/dist/README.md +309 -0
- package/dist/actions/index.d.ts +34 -16
- package/dist/anchored/index.d.ts +67 -0
- package/dist/annotation/index.d.ts +599 -0
- package/dist/commands/index.d.ts +109 -0
- package/dist/fesm2022/embedpdf-angular-actions.mjs +117 -36
- package/dist/fesm2022/embedpdf-angular-actions.mjs.map +1 -1
- package/dist/fesm2022/embedpdf-angular-anchored.mjs +148 -0
- package/dist/fesm2022/embedpdf-angular-anchored.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-annotation.mjs +1960 -0
- package/dist/fesm2022/embedpdf-angular-annotation.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-commands.mjs +235 -0
- package/dist/fesm2022/embedpdf-angular-commands.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-form.mjs +1074 -0
- package/dist/fesm2022/embedpdf-angular-form.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-history.mjs +54 -0
- package/dist/fesm2022/embedpdf-angular-history.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-i18n.mjs +100 -0
- package/dist/fesm2022/embedpdf-angular-i18n.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-interaction.mjs +157 -0
- package/dist/fesm2022/embedpdf-angular-interaction.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-link.mjs +306 -0
- package/dist/fesm2022/embedpdf-angular-link.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-measurement.mjs +106 -0
- package/dist/fesm2022/embedpdf-angular-measurement.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-metadata.mjs +77 -0
- package/dist/fesm2022/embedpdf-angular-metadata.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-page-edit.mjs +65 -0
- package/dist/fesm2022/embedpdf-angular-page-edit.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-page-view.mjs +297 -0
- package/dist/fesm2022/embedpdf-angular-page-view.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-redaction.mjs +93 -0
- package/dist/fesm2022/embedpdf-angular-redaction.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-render.mjs +331 -69
- package/dist/fesm2022/embedpdf-angular-render.mjs.map +1 -1
- package/dist/fesm2022/embedpdf-angular-runtime.mjs +1147 -390
- package/dist/fesm2022/embedpdf-angular-runtime.mjs.map +1 -1
- package/dist/fesm2022/embedpdf-angular-search.mjs +251 -0
- package/dist/fesm2022/embedpdf-angular-search.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-selection.mjs +477 -0
- package/dist/fesm2022/embedpdf-angular-selection.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-shell.mjs +177 -0
- package/dist/fesm2022/embedpdf-angular-shell.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-signature.mjs +120 -0
- package/dist/fesm2022/embedpdf-angular-signature.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-stage.mjs +688 -288
- package/dist/fesm2022/embedpdf-angular-stage.mjs.map +1 -1
- package/dist/fesm2022/embedpdf-angular-stamp.mjs +169 -0
- package/dist/fesm2022/embedpdf-angular-stamp.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-testing.mjs +89 -0
- package/dist/fesm2022/embedpdf-angular-testing.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-toolbar.mjs +1123 -0
- package/dist/fesm2022/embedpdf-angular-toolbar.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular-view-manager.mjs +69 -0
- package/dist/fesm2022/embedpdf-angular-view-manager.mjs.map +1 -0
- package/dist/fesm2022/embedpdf-angular.mjs +23 -3
- package/dist/fesm2022/embedpdf-angular.mjs.map +1 -1
- package/dist/form/index.d.ts +79 -0
- package/dist/history/index.d.ts +28 -0
- package/dist/i18n/index.d.ts +52 -0
- package/dist/index.d.ts +20 -0
- package/dist/interaction/index.d.ts +58 -0
- package/dist/link/index.d.ts +77 -0
- package/dist/measurement/index.d.ts +46 -0
- package/dist/metadata/index.d.ts +51 -0
- package/dist/page-edit/index.d.ts +22 -0
- package/dist/page-view/index.d.ts +94 -0
- package/dist/redaction/index.d.ts +38 -0
- package/dist/render/index.d.ts +45 -6
- package/dist/runtime/index.d.ts +579 -199
- package/dist/search/index.d.ts +71 -0
- package/dist/selection/index.d.ts +141 -0
- package/dist/shell/index.d.ts +88 -0
- package/dist/signature/index.d.ts +45 -0
- package/dist/stage/index.d.ts +242 -141
- package/dist/stamp/index.d.ts +58 -0
- package/dist/testing/index.d.ts +53 -0
- package/dist/toolbar/index.d.ts +257 -0
- package/dist/view-manager/index.d.ts +23 -0
- package/package.json +135 -14
package/README.md
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# @embedpdf/angular
|
|
2
|
+
|
|
3
|
+
The Angular adapter for EmbedPDF's headless viewer: providers to set it up, one service per
|
|
4
|
+
plugin, and standalone components for the pages and the layers on them. Zoneless and `OnPush`
|
|
5
|
+
throughout, built on signals, server-rendering safe (the engine only starts in the browser).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'app-contract',
|
|
10
|
+
imports: [
|
|
11
|
+
EpdfDocumentGate,
|
|
12
|
+
EpdfStage,
|
|
13
|
+
EpdfPageTemplate,
|
|
14
|
+
EpdfRenderLayer,
|
|
15
|
+
PercentPipe,
|
|
16
|
+
],
|
|
17
|
+
providers: [
|
|
18
|
+
provideEmbedPdf(
|
|
19
|
+
{
|
|
20
|
+
engine: () => localEngine(),
|
|
21
|
+
initialDocuments: [{ source: { kind: 'url', url: '/c.pdf' } }],
|
|
22
|
+
},
|
|
23
|
+
withStage(),
|
|
24
|
+
withRender(),
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
template: `
|
|
28
|
+
<section *epdfDocumentGate="let document; fallback: opening">
|
|
29
|
+
<button (click)="stage.zoomIn()">+</button>
|
|
30
|
+
{{ stage.zoomLevel() | percent }}
|
|
31
|
+
<epdf-stage #stage="epdfStage" style="height: 600px">
|
|
32
|
+
<ng-template epdfPage><epdf-render-layer /></ng-template>
|
|
33
|
+
</epdf-stage>
|
|
34
|
+
</section>
|
|
35
|
+
<ng-template #opening>Opening…</ng-template>
|
|
36
|
+
`,
|
|
37
|
+
})
|
|
38
|
+
export class Contract {
|
|
39
|
+
private readonly documents = inject(EpdfDocuments); // the component that provides can inject
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The docs: [embedpdf.com/docs/headless/angular](https://www.embedpdf.com/docs/headless/angular).
|
|
44
|
+
|
|
45
|
+
## Entry points
|
|
46
|
+
|
|
47
|
+
One secondary entry point per feature, so a bundle carries only the features it imports.
|
|
48
|
+
|
|
49
|
+
| Entry point | What it has |
|
|
50
|
+
| :------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `@embedpdf/angular/runtime` | `provideEmbedPdf()`, `EpdfViewer`, `EpdfDocuments`, `EpdfDocument`, `[epdfDocumentScope]`, `*epdfDocumentGate`, the numbered errors, and what features build on: `pluginService()`, `CapabilityBinding`, `injectCapability()`, `EPDF_PAGE` / `injectPage()`, `paintsPagePart()` / `injectPaintedParts()`, `epdfTheme()` |
|
|
52
|
+
| `@embedpdf/angular/stage` | `withStage()`, `<epdf-stage>`, `<ng-template epdfPage>` / `epdfPageChrome`, `<epdf-scrollbar>` |
|
|
53
|
+
| `@embedpdf/angular/render` | `withRender()`, `EpdfRender`, `<epdf-render-layer>` |
|
|
54
|
+
| `@embedpdf/angular/interaction` | `withInteraction()`, `withFeedback()`, `EpdfInteraction` (with `overrideCursor()`), `<epdf-page-pointer-source>` |
|
|
55
|
+
| `@embedpdf/angular/page-view` | `<epdf-page-view [page] [width]>`, one page without a Stage, with `epdfFallback` / `epdfPageChrome` templates |
|
|
56
|
+
| `@embedpdf/angular/selection` | `withSelection()`, `EpdfSelection`, `<epdf-selection-layer>`, `<epdf-selection-menu>`, `<epdf-selection-handles>`, `<epdf-selection-clipboard>`, `copySelection()` |
|
|
57
|
+
| `@embedpdf/angular/search` | `withSearch()`, `EpdfSearch` (with `hits()` and `hitsOn(page)`), `<epdf-search-layer (hitClick)>` |
|
|
58
|
+
| `@embedpdf/angular/link` | `withLink()`, `EpdfLink`, `<epdf-link-layer>` with `<ng-template epdfLink let-link let-native="native">` |
|
|
59
|
+
| `@embedpdf/angular/testing` | `EpdfStageHarness`, `EpdfSearchLayerHarness`: test harnesses on `@angular/cdk/testing` |
|
|
60
|
+
| `@embedpdf/angular/anchored` | `<epdf-anchored>`, and `EPDF_PROJECTOR`, the binding a page surface provides for it |
|
|
61
|
+
| `@embedpdf/angular/actions` | `withActions()`, `withActionsUi(handlers)`, `EpdfActions` |
|
|
62
|
+
| `@embedpdf/angular/metadata` | `withMetadata()`, `EpdfMetadata` (`fields()`, and your own fields as `custom.fields()`) |
|
|
63
|
+
| `@embedpdf/angular/page-edit` | `withPageEdit()`, `EpdfPageEdit` |
|
|
64
|
+
| `@embedpdf/angular/view-manager` | `withViewManager()`, `EpdfViewManager` (`panes()`, `focusedPaneId()`) |
|
|
65
|
+
| `@embedpdf/angular/commands` | `withCommands()`, `withCommandShortcuts()`, `standardCommands`, `EpdfCommands` (with `commandOf(id)`), `[epdfCommand]` on your own buttons, `[epdfCommandShortcuts]` for the keys inside one element |
|
|
66
|
+
| `@embedpdf/angular/toolbar` | `<epdf-toolbar [bar]>`, its parts as templates (`epdfToolbarCommand`, `epdfToolbarCustom`, `epdfToolbarCollapsed`, `epdfToolbarGroupTrigger`, `epdfToolbarSeparator`, `epdfToolbarOverflowTrigger`, `epdfToolbarOverflowMenu`), and the bar's vocabulary (`group()`, `item()`, `custom()`, …) |
|
|
67
|
+
| `@embedpdf/angular/shell` | `withShell()`, `EpdfShell` (with `surface(id)`: `isOpen()`, `props()`, `open()`, `close()`, `toggle()`), `epdfPanelToggle` with `epdfPanelToggleExclusive` |
|
|
68
|
+
| `@embedpdf/angular/i18n` | `withI18n()`, `EpdfI18n` (with `translator()`), the `epdfT` pipe (`EpdfTPipe`) |
|
|
69
|
+
| `@embedpdf/angular/annotation` | `withAnnotation()`, `withFilePicker()`, `EpdfAnnotation` (with `watch(filter)`, `anchorOf(ref)`, `selection.properties()`, `tools.defaultsOf(id)`), `EpdfComments` (`threads()`, `threadOf(ref)`), `<epdf-annotation-layer>` with `<ng-template [epdfAnnotation]>` / `epdfHandle` / `epdfRotationHandle`, `[epdfRichTextEditor]`, `<epdf-annotation-menu>`, `<epdf-annotation-draft-menu>`, `<epdf-annotation-rotation-badge>` |
|
|
70
|
+
| `@embedpdf/angular/stamp` | `withStamp()`, `EpdfStamp` (with `libraries()`, `assetsOf(filter)`, `previewUrlOf(id)`), `persistStampLibraries()` / `restoreStampLibraries()` over the service, `indexedDbByteStore()` |
|
|
71
|
+
| `@embedpdf/angular/measurement` | `withMeasurement()`, `EpdfMeasurement` (with `scaleOf(page)` and `readoutOf(ref)`) |
|
|
72
|
+
| `@embedpdf/angular/redaction` | `withRedaction()`, `EpdfRedaction` (with `pending()` and `pendingOn(page)`) |
|
|
73
|
+
| `@embedpdf/angular/form` | `withForm()`, `EpdfForm` (with `valueOf(ref)`, and `controlOf(ref)`: a field as a Reactive Forms `FormControl`, which needs `@angular/forms`), `<epdf-form-layer>` |
|
|
74
|
+
| `@embedpdf/angular/signature` | `withSignature()`, `EpdfSignature` (with `signerRows()`), and the signers (`webCryptoSigner`, `remoteSigner`, `personalSigner`, `createTestSigner`) |
|
|
75
|
+
|
|
76
|
+
`scripts/check-parity.mjs` (part of `test`) lists the React entry points this package doesn't
|
|
77
|
+
have yet.
|
|
78
|
+
|
|
79
|
+
## How it fits together
|
|
80
|
+
|
|
81
|
+
- **`provideEmbedPdf(config, ...features)`** returns providers. Each injector that gets them (a
|
|
82
|
+
component, a route, the app) has one viewer: `EpdfKernelHost` creates the kernel in the browser
|
|
83
|
+
once the engine is there (a factory may return a promise), starts it, opens
|
|
84
|
+
`initialDocuments`, and destroys it with the injector.
|
|
85
|
+
- **Signals.** The kernel has one change stream; the host turns each change into a signal write
|
|
86
|
+
(`revision`), and every value the adapter shows is a `computed()` over it that keeps its value
|
|
87
|
+
while nothing it reads changed (`host.read()`, `binding.select()`).
|
|
88
|
+
- **Services.** A plugin's service (`inject(EpdfSearch)`) has the plugin's methods, its State
|
|
89
|
+
table as signals, its events as RxJS streams (`completed$`), and its settings. `pluginService()`
|
|
90
|
+
builds that from the plugin's declarations. A service acts on the document of the injector
|
|
91
|
+
that created it: `[epdfDocumentScope]` (and a Stage with a `[document]`) provide every service
|
|
92
|
+
again, bound to their document; elsewhere a service follows the active document.
|
|
93
|
+
- **The read rule, the same in every adapter.** A method named `get*`, `list*`, `is*`, `has*` or
|
|
94
|
+
`can*` (the capability vocabulary's reads, `docs/conventions/naming.md`) is a read: called in a
|
|
95
|
+
template, a `computed()` or an `effect()`, it is read again after each change of the kernel and
|
|
96
|
+
wakes its reader only when its answer changes (`[disabled]="!metadata.canUpdate()"`). Every
|
|
97
|
+
other method tracks nothing. Without a document a method refuses with `not-ready`: one that
|
|
98
|
+
returns a promise (`form.setValue`, `signature.placeMark`) rejects, so `.catch()` sees it, and
|
|
99
|
+
the others throw. Which methods return a promise comes from the plugin's token (`promises`).
|
|
100
|
+
- **Components** have signal inputs and outputs; the Stage is its own API, reached from around it
|
|
101
|
+
with `#stage="epdfStage"` or `viewChild(EpdfStage)`, and from inside it with
|
|
102
|
+
`inject(EpdfStageRef)`, which anywhere else drives the main view of the document in scope.
|
|
103
|
+
**Templates** replace React's render functions: `<ng-template epdfPage let-page>`.
|
|
104
|
+
- **Errors** a developer can make while setting up have a number and the fix:
|
|
105
|
+
`EPDF-101` (no viewer), `EPDF-102` (a plugin's service without its feature), `EPDF-103` (page UI
|
|
106
|
+
outside a page), `EPDF-104` (Stage UI outside a Stage), `EPDF-105` (an engine call on the
|
|
107
|
+
server). They link to `/docs/headless/angular/errors`.
|
|
108
|
+
|
|
109
|
+
## Adding a plugin binding
|
|
110
|
+
|
|
111
|
+
The recipe for a plugin's Angular side, with search as the example. React's binding
|
|
112
|
+
(`packages/framework/react/src/<plugin>.tsx`) is the reference for behavior; the plugin's
|
|
113
|
+
`contract.ts` and `state.ts` are the API; the page's Angular snippets
|
|
114
|
+
(`docs/content/snippets/<area>/<name>.angular.ts`) are the spec for the names.
|
|
115
|
+
|
|
116
|
+
### 1. The entry point
|
|
117
|
+
|
|
118
|
+
`search/ng-package.json` (copy `render/ng-package.json`), `search/src/public_api.ts`, an
|
|
119
|
+
`./search` entry in `package.json`'s `exports` (the same shape as the others), a line in
|
|
120
|
+
`src/public_api.ts`, and remove `search` from `PENDING` in `scripts/check-parity.mjs`. The entry
|
|
121
|
+
re-exports the plugin, so app code has one import for the feature:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
/** @embedpdf/angular/search: … one line per thing it has. */
|
|
125
|
+
export * from '@embedpdf/plugin-search';
|
|
126
|
+
export * from './search';
|
|
127
|
+
export * from './search-layer';
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 2. The service and the feature
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
@Injectable({ providedIn: 'root' })
|
|
134
|
+
export class EpdfSearch extends pluginService({
|
|
135
|
+
name: 'EpdfSearch', // as errors show it
|
|
136
|
+
feature: 'withSearch()', // as errors show it
|
|
137
|
+
token: SearchToken,
|
|
138
|
+
state: searchState, // every field becomes a signal: search.hitCount()
|
|
139
|
+
methods: ['search', 'clear', 'next', 'previous', 'goToHit'], // the page's Methods table
|
|
140
|
+
events: ['onCompleted', 'onActiveHitChanged'], // completed$, activeHitChanged$
|
|
141
|
+
}) {
|
|
142
|
+
// What the generic part can't build, written by hand with the protected `binding`:
|
|
143
|
+
/** A value with arguments is a method that returns a signal. */
|
|
144
|
+
hitsOn(page: PageRef | (() => PageRef)): Signal<readonly SearchHit[]> {
|
|
145
|
+
const pageOf = typeof page === 'function' ? page : () => page;
|
|
146
|
+
return this.binding.select(
|
|
147
|
+
(search) => search.listHitsOn(pageOf()),
|
|
148
|
+
NO_HITS,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function withSearch(options?: SearchConfig): EmbedPdfFeature {
|
|
154
|
+
return { plugins: [searchPlugin(options)], services: [EpdfSearch] };
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- `settings()`, `getSettings()`, `updateSettings()`, `resetSettings()` and `settingsChanged$`
|
|
159
|
+
come by themselves when the plugin declares settings; they work before any document opens.
|
|
160
|
+
Don't list `getSettings` in `methods`.
|
|
161
|
+
- Methods are looked up when called, so a method kept in a field acts on the document current
|
|
162
|
+
then; without a document a document plugin's method refuses with `not-ready`.
|
|
163
|
+
- A check (`can…()`, `is…()`, `has…()`) is state: read in a template, a `computed()` or an
|
|
164
|
+
`effect()`, it is read again after each change of the kernel and wakes its reader only when
|
|
165
|
+
its answer changes, so `[disabled]="!metadata.canUpdate()"` follows the document. Other methods
|
|
166
|
+
track nothing.
|
|
167
|
+
- `providedIn: 'root'` is only the fallback that turns a missing feature into EPDF-102; the
|
|
168
|
+
feature's `services` provides it next to the viewer and under every document scope.
|
|
169
|
+
- A namespace (`metadata.custom`, `annotation.selection`) is an object of its own on the service,
|
|
170
|
+
built the same way (`this.binding.namespaceMethod('custom', 'update')`, `this.binding.select(…)`,
|
|
171
|
+
`this.binding.stream(…)`; `metadata.ts` has one). A namespace is
|
|
172
|
+
never itself a signal, and a State value never shares a name with the service or a namespace
|
|
173
|
+
(`EpdfMetadata` has `fields()` and `custom.fields()`).
|
|
174
|
+
- A helper that needs the caller's lifetime (`interaction.overrideCursor(() => spec)`) creates
|
|
175
|
+
its `effect()` in the caller's injection context; say so in its doc comment.
|
|
176
|
+
- App-wide setup that React does with a hook (`useCommandShortcuts()`) is a feature:
|
|
177
|
+
`withCommandShortcuts()` returns `{ plugins: [], setup: () => { … } }`; `setup` runs once in the
|
|
178
|
+
providers' injection context, after the viewer is created.
|
|
179
|
+
|
|
180
|
+
### 3. A component, with inputs and outputs
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
@Component({
|
|
184
|
+
selector: 'epdf-search-layer',
|
|
185
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
186
|
+
template: `@for (hit of hits(); track hit.index) { … (click)="hitClick.emit(hit)" }`,
|
|
187
|
+
})
|
|
188
|
+
export class EpdfSearchLayer {
|
|
189
|
+
readonly hitClick = output<SearchHit>(); // (hitClick)
|
|
190
|
+
…
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
React's props become `input()`s (`<RenderLayer tiles>` → `[tiles]`), `onX` callbacks `output()`s
|
|
195
|
+
named `x` (`onHitClick` → `(hitClick)`), and a controlled value (`zoom` + `onZoomChange`) an input and an output named
|
|
196
|
+
`zoom` / `zoomChange`, so `[(zoom)]` works with a signal. Every component is standalone and
|
|
197
|
+
`OnPush`, and touches the DOM only in the browser (`isPlatformBrowser(inject(PLATFORM_ID))`).
|
|
198
|
+
|
|
199
|
+
### 4. A layer drawn on each page
|
|
200
|
+
|
|
201
|
+
A layer goes inside `<ng-template epdfPage>` and reads its page with `injectPage()`. It resolves
|
|
202
|
+
its plugin for the page's own document:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
export class EpdfSearchLayer {
|
|
206
|
+
private readonly page = injectPage('<epdf-search-layer>'); // EPDF-103 outside a page
|
|
207
|
+
private readonly search = new CapabilityBinding(
|
|
208
|
+
injectKernelHost('<epdf-search-layer>'),
|
|
209
|
+
() => SearchToken,
|
|
210
|
+
() => this.page.documentId,
|
|
211
|
+
);
|
|
212
|
+
protected readonly hits = this.search.select(
|
|
213
|
+
(search) => search.listHitsOn(this.page.ref),
|
|
214
|
+
NO_HITS,
|
|
215
|
+
);
|
|
216
|
+
// page.transform() places page coordinates on screen: page.transform().pageToViewRect(rect)
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`page.transform()`, `page.frame()` and `page.pageIndex()` are signals that move with the
|
|
221
|
+
camera; the context itself never changes. A layer that paints a part of the page itself (the
|
|
222
|
+
annotations, the form fields) says so with `paintsPagePart()`, and the render layer leaves that
|
|
223
|
+
part out of the page's picture.
|
|
224
|
+
|
|
225
|
+
### 5. Custom drawing with a template
|
|
226
|
+
|
|
227
|
+
React's render prop (`renderLink={(link) => …}`) becomes a marker directive on an
|
|
228
|
+
`<ng-template>`, typed with a context guard, that the component reads with `contentChild()`:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
export interface EpdfLinkTemplateContext {
|
|
232
|
+
$implicit: LinkInfo;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@Directive({ selector: 'ng-template[epdfLink]' })
|
|
236
|
+
export class EpdfLinkTemplate {
|
|
237
|
+
readonly template = inject<TemplateRef<EpdfLinkTemplateContext>>(TemplateRef);
|
|
238
|
+
static ngTemplateContextGuard(_: EpdfLinkTemplate, context: unknown): context is EpdfLinkTemplateContext {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// In the layer: the app's template when there is one, the built-in look otherwise.
|
|
244
|
+
private readonly custom = contentChild(EpdfLinkTemplate);
|
|
245
|
+
// <ng-container [ngTemplateOutlet]="custom()?.template ?? builtIn" [ngTemplateOutletContext]="{ $implicit: link }" />
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The app writes `<epdf-link-layer><ng-template epdfLink let-link>…</ng-template></epdf-link-layer>`.
|
|
249
|
+
A name that would clash gets a prefix (the toolbar's command template is `epdfToolbarCommand`,
|
|
250
|
+
because `epdfCommand` is the directive on your own buttons). Floating UI next to something on a
|
|
251
|
+
page uses `<epdf-anchored [anchor]>` inside the Stage.
|
|
252
|
+
|
|
253
|
+
A template can't draw "nothing, use the default" the way a React render prop returning
|
|
254
|
+
`undefined` can: a template you give draws every part of its kind. The toolbar's items of your own
|
|
255
|
+
are templates by name (`epdfToolbarCustom="page-number"`), and an item with no template of its name
|
|
256
|
+
is a native `<slot name>` socket with its command's button inside.
|
|
257
|
+
|
|
258
|
+
Two rules for templates inside a page template (`<ng-template epdfPage>`), which Angular creates
|
|
259
|
+
once per page: an entry the template registers (an annotation renderer) is interned, so the same
|
|
260
|
+
rule gives the same entry on every page and its behaviors register once; and a marker directive's
|
|
261
|
+
inputs are never `input.required`, because a template inside `@if` enters `contentChildren` before
|
|
262
|
+
its inputs are set.
|
|
263
|
+
|
|
264
|
+
### 6. A test
|
|
265
|
+
|
|
266
|
+
Tests run in vitest with happy-dom, compiling components just in time with `TestBed`, zoneless
|
|
267
|
+
(`vitest.config.mts` runs Angular's JIT transform on this package's files, so signal inputs and
|
|
268
|
+
queries work). `test/fixtures.ts` has a fake engine, a small document plugin, and `viewerHost()`
|
|
269
|
+
/ `mount()`:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
it('reads the hit count, and empty without a document', async () => {
|
|
273
|
+
const fixture = await mount(
|
|
274
|
+
viewerHost({ template: '<epdf-search-layer />', imports: [EpdfSearchLayer], features: [withSearch()] }),
|
|
275
|
+
);
|
|
276
|
+
const search = fixture.debugElement.injector.get(EpdfSearch);
|
|
277
|
+
expect(search.hitCount()).toBe(0);
|
|
278
|
+
await kernelOf(fixture).documents.open(bytesInput('a'));
|
|
279
|
+
…
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Port the React tests that test the binding (not the plugin): empty state without a document,
|
|
284
|
+
signals that change only when their value does, the document scope, streams that follow the
|
|
285
|
+
document, outputs, the setup errors.
|
|
286
|
+
|
|
287
|
+
### 7. Checks
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
pnpm --filter @embedpdf/angular test # parity + vitest
|
|
291
|
+
pnpm --filter @embedpdf/angular typecheck # ngc strictTemplates + the tests with tsc
|
|
292
|
+
pnpm --filter @embedpdf/angular build # ng-packagr, then the built declarations as an app reads them
|
|
293
|
+
pnpm --filter @embedpdf/docs-content snippets # your pages, per framework
|
|
294
|
+
pnpm --filter @embedpdf/docs-content samples <area> # your examples
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The build ends with `scripts/check-declarations.mjs`: it imports every entry point from `dist`
|
|
298
|
+
with `skipLibCheck: false` and fails on any error there. The usual one is an import that doesn't
|
|
299
|
+
resolve, which an app (`skipLibCheck: true`) never reports: the service whose type needed it
|
|
300
|
+
quietly loses its members. It happens when a declaration needs a type its source file doesn't
|
|
301
|
+
import, and the compiler names it through another module: the entry point's own public name, or
|
|
302
|
+
the package that declares it, which may not be a dependency of this one. Import that type into
|
|
303
|
+
the file (`services.ts` imports `PluginServiceClass`), or give the member an explicit type.
|
|
304
|
+
|
|
305
|
+
An Angular example (`docs/content/samples/<area>/<name>.angular.ts`) exports its root as `App`
|
|
306
|
+
with `selector: 'demo-root'`, uses the React example's stylesheet with
|
|
307
|
+
`styleUrl: './<name>.css'` and `encapsulation: ViewEncapsulation.None`, and exports every
|
|
308
|
+
component in the file: `ngc` stops reporting template errors anywhere once a file has an
|
|
309
|
+
unexported component.
|
package/dist/README.md
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# @embedpdf/angular
|
|
2
|
+
|
|
3
|
+
The Angular adapter for EmbedPDF's headless viewer: providers to set it up, one service per
|
|
4
|
+
plugin, and standalone components for the pages and the layers on them. Zoneless and `OnPush`
|
|
5
|
+
throughout, built on signals, server-rendering safe (the engine only starts in the browser).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'app-contract',
|
|
10
|
+
imports: [
|
|
11
|
+
EpdfDocumentGate,
|
|
12
|
+
EpdfStage,
|
|
13
|
+
EpdfPageTemplate,
|
|
14
|
+
EpdfRenderLayer,
|
|
15
|
+
PercentPipe,
|
|
16
|
+
],
|
|
17
|
+
providers: [
|
|
18
|
+
provideEmbedPdf(
|
|
19
|
+
{
|
|
20
|
+
engine: () => localEngine(),
|
|
21
|
+
initialDocuments: [{ source: { kind: 'url', url: '/c.pdf' } }],
|
|
22
|
+
},
|
|
23
|
+
withStage(),
|
|
24
|
+
withRender(),
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
template: `
|
|
28
|
+
<section *epdfDocumentGate="let document; fallback: opening">
|
|
29
|
+
<button (click)="stage.zoomIn()">+</button>
|
|
30
|
+
{{ stage.zoomLevel() | percent }}
|
|
31
|
+
<epdf-stage #stage="epdfStage" style="height: 600px">
|
|
32
|
+
<ng-template epdfPage><epdf-render-layer /></ng-template>
|
|
33
|
+
</epdf-stage>
|
|
34
|
+
</section>
|
|
35
|
+
<ng-template #opening>Opening…</ng-template>
|
|
36
|
+
`,
|
|
37
|
+
})
|
|
38
|
+
export class Contract {
|
|
39
|
+
private readonly documents = inject(EpdfDocuments); // the component that provides can inject
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The docs: [embedpdf.com/docs/headless/angular](https://www.embedpdf.com/docs/headless/angular).
|
|
44
|
+
|
|
45
|
+
## Entry points
|
|
46
|
+
|
|
47
|
+
One secondary entry point per feature, so a bundle carries only the features it imports.
|
|
48
|
+
|
|
49
|
+
| Entry point | What it has |
|
|
50
|
+
| :------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `@embedpdf/angular/runtime` | `provideEmbedPdf()`, `EpdfViewer`, `EpdfDocuments`, `EpdfDocument`, `[epdfDocumentScope]`, `*epdfDocumentGate`, the numbered errors, and what features build on: `pluginService()`, `CapabilityBinding`, `injectCapability()`, `EPDF_PAGE` / `injectPage()`, `paintsPagePart()` / `injectPaintedParts()`, `epdfTheme()` |
|
|
52
|
+
| `@embedpdf/angular/stage` | `withStage()`, `<epdf-stage>`, `<ng-template epdfPage>` / `epdfPageChrome`, `<epdf-scrollbar>` |
|
|
53
|
+
| `@embedpdf/angular/render` | `withRender()`, `EpdfRender`, `<epdf-render-layer>` |
|
|
54
|
+
| `@embedpdf/angular/interaction` | `withInteraction()`, `withFeedback()`, `EpdfInteraction` (with `overrideCursor()`), `<epdf-page-pointer-source>` |
|
|
55
|
+
| `@embedpdf/angular/page-view` | `<epdf-page-view [page] [width]>`, one page without a Stage, with `epdfFallback` / `epdfPageChrome` templates |
|
|
56
|
+
| `@embedpdf/angular/selection` | `withSelection()`, `EpdfSelection`, `<epdf-selection-layer>`, `<epdf-selection-menu>`, `<epdf-selection-handles>`, `<epdf-selection-clipboard>`, `copySelection()` |
|
|
57
|
+
| `@embedpdf/angular/search` | `withSearch()`, `EpdfSearch` (with `hits()` and `hitsOn(page)`), `<epdf-search-layer (hitClick)>` |
|
|
58
|
+
| `@embedpdf/angular/link` | `withLink()`, `EpdfLink`, `<epdf-link-layer>` with `<ng-template epdfLink let-link let-native="native">` |
|
|
59
|
+
| `@embedpdf/angular/testing` | `EpdfStageHarness`, `EpdfSearchLayerHarness`: test harnesses on `@angular/cdk/testing` |
|
|
60
|
+
| `@embedpdf/angular/anchored` | `<epdf-anchored>`, and `EPDF_PROJECTOR`, the binding a page surface provides for it |
|
|
61
|
+
| `@embedpdf/angular/actions` | `withActions()`, `withActionsUi(handlers)`, `EpdfActions` |
|
|
62
|
+
| `@embedpdf/angular/metadata` | `withMetadata()`, `EpdfMetadata` (`fields()`, and your own fields as `custom.fields()`) |
|
|
63
|
+
| `@embedpdf/angular/page-edit` | `withPageEdit()`, `EpdfPageEdit` |
|
|
64
|
+
| `@embedpdf/angular/view-manager` | `withViewManager()`, `EpdfViewManager` (`panes()`, `focusedPaneId()`) |
|
|
65
|
+
| `@embedpdf/angular/commands` | `withCommands()`, `withCommandShortcuts()`, `standardCommands`, `EpdfCommands` (with `commandOf(id)`), `[epdfCommand]` on your own buttons, `[epdfCommandShortcuts]` for the keys inside one element |
|
|
66
|
+
| `@embedpdf/angular/toolbar` | `<epdf-toolbar [bar]>`, its parts as templates (`epdfToolbarCommand`, `epdfToolbarCustom`, `epdfToolbarCollapsed`, `epdfToolbarGroupTrigger`, `epdfToolbarSeparator`, `epdfToolbarOverflowTrigger`, `epdfToolbarOverflowMenu`), and the bar's vocabulary (`group()`, `item()`, `custom()`, …) |
|
|
67
|
+
| `@embedpdf/angular/shell` | `withShell()`, `EpdfShell` (with `surface(id)`: `isOpen()`, `props()`, `open()`, `close()`, `toggle()`), `epdfPanelToggle` with `epdfPanelToggleExclusive` |
|
|
68
|
+
| `@embedpdf/angular/i18n` | `withI18n()`, `EpdfI18n` (with `translator()`), the `epdfT` pipe (`EpdfTPipe`) |
|
|
69
|
+
| `@embedpdf/angular/annotation` | `withAnnotation()`, `withFilePicker()`, `EpdfAnnotation` (with `watch(filter)`, `anchorOf(ref)`, `selection.properties()`, `tools.defaultsOf(id)`), `EpdfComments` (`threads()`, `threadOf(ref)`), `<epdf-annotation-layer>` with `<ng-template [epdfAnnotation]>` / `epdfHandle` / `epdfRotationHandle`, `[epdfRichTextEditor]`, `<epdf-annotation-menu>`, `<epdf-annotation-draft-menu>`, `<epdf-annotation-rotation-badge>` |
|
|
70
|
+
| `@embedpdf/angular/stamp` | `withStamp()`, `EpdfStamp` (with `libraries()`, `assetsOf(filter)`, `previewUrlOf(id)`), `persistStampLibraries()` / `restoreStampLibraries()` over the service, `indexedDbByteStore()` |
|
|
71
|
+
| `@embedpdf/angular/measurement` | `withMeasurement()`, `EpdfMeasurement` (with `scaleOf(page)` and `readoutOf(ref)`) |
|
|
72
|
+
| `@embedpdf/angular/redaction` | `withRedaction()`, `EpdfRedaction` (with `pending()` and `pendingOn(page)`) |
|
|
73
|
+
| `@embedpdf/angular/form` | `withForm()`, `EpdfForm` (with `valueOf(ref)`, and `controlOf(ref)`: a field as a Reactive Forms `FormControl`, which needs `@angular/forms`), `<epdf-form-layer>` |
|
|
74
|
+
| `@embedpdf/angular/signature` | `withSignature()`, `EpdfSignature` (with `signerRows()`), and the signers (`webCryptoSigner`, `remoteSigner`, `personalSigner`, `createTestSigner`) |
|
|
75
|
+
|
|
76
|
+
`scripts/check-parity.mjs` (part of `test`) lists the React entry points this package doesn't
|
|
77
|
+
have yet.
|
|
78
|
+
|
|
79
|
+
## How it fits together
|
|
80
|
+
|
|
81
|
+
- **`provideEmbedPdf(config, ...features)`** returns providers. Each injector that gets them (a
|
|
82
|
+
component, a route, the app) has one viewer: `EpdfKernelHost` creates the kernel in the browser
|
|
83
|
+
once the engine is there (a factory may return a promise), starts it, opens
|
|
84
|
+
`initialDocuments`, and destroys it with the injector.
|
|
85
|
+
- **Signals.** The kernel has one change stream; the host turns each change into a signal write
|
|
86
|
+
(`revision`), and every value the adapter shows is a `computed()` over it that keeps its value
|
|
87
|
+
while nothing it reads changed (`host.read()`, `binding.select()`).
|
|
88
|
+
- **Services.** A plugin's service (`inject(EpdfSearch)`) has the plugin's methods, its State
|
|
89
|
+
table as signals, its events as RxJS streams (`completed$`), and its settings. `pluginService()`
|
|
90
|
+
builds that from the plugin's declarations. A service acts on the document of the injector
|
|
91
|
+
that created it: `[epdfDocumentScope]` (and a Stage with a `[document]`) provide every service
|
|
92
|
+
again, bound to their document; elsewhere a service follows the active document.
|
|
93
|
+
- **The read rule, the same in every adapter.** A method named `get*`, `list*`, `is*`, `has*` or
|
|
94
|
+
`can*` (the capability vocabulary's reads, `docs/conventions/naming.md`) is a read: called in a
|
|
95
|
+
template, a `computed()` or an `effect()`, it is read again after each change of the kernel and
|
|
96
|
+
wakes its reader only when its answer changes (`[disabled]="!metadata.canUpdate()"`). Every
|
|
97
|
+
other method tracks nothing. Without a document a method refuses with `not-ready`: one that
|
|
98
|
+
returns a promise (`form.setValue`, `signature.placeMark`) rejects, so `.catch()` sees it, and
|
|
99
|
+
the others throw. Which methods return a promise comes from the plugin's token (`promises`).
|
|
100
|
+
- **Components** have signal inputs and outputs; the Stage is its own API, reached from around it
|
|
101
|
+
with `#stage="epdfStage"` or `viewChild(EpdfStage)`, and from inside it with
|
|
102
|
+
`inject(EpdfStageRef)`, which anywhere else drives the main view of the document in scope.
|
|
103
|
+
**Templates** replace React's render functions: `<ng-template epdfPage let-page>`.
|
|
104
|
+
- **Errors** a developer can make while setting up have a number and the fix:
|
|
105
|
+
`EPDF-101` (no viewer), `EPDF-102` (a plugin's service without its feature), `EPDF-103` (page UI
|
|
106
|
+
outside a page), `EPDF-104` (Stage UI outside a Stage), `EPDF-105` (an engine call on the
|
|
107
|
+
server). They link to `/docs/headless/angular/errors`.
|
|
108
|
+
|
|
109
|
+
## Adding a plugin binding
|
|
110
|
+
|
|
111
|
+
The recipe for a plugin's Angular side, with search as the example. React's binding
|
|
112
|
+
(`packages/framework/react/src/<plugin>.tsx`) is the reference for behavior; the plugin's
|
|
113
|
+
`contract.ts` and `state.ts` are the API; the page's Angular snippets
|
|
114
|
+
(`docs/content/snippets/<area>/<name>.angular.ts`) are the spec for the names.
|
|
115
|
+
|
|
116
|
+
### 1. The entry point
|
|
117
|
+
|
|
118
|
+
`search/ng-package.json` (copy `render/ng-package.json`), `search/src/public_api.ts`, an
|
|
119
|
+
`./search` entry in `package.json`'s `exports` (the same shape as the others), a line in
|
|
120
|
+
`src/public_api.ts`, and remove `search` from `PENDING` in `scripts/check-parity.mjs`. The entry
|
|
121
|
+
re-exports the plugin, so app code has one import for the feature:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
/** @embedpdf/angular/search: … one line per thing it has. */
|
|
125
|
+
export * from '@embedpdf/plugin-search';
|
|
126
|
+
export * from './search';
|
|
127
|
+
export * from './search-layer';
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 2. The service and the feature
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
@Injectable({ providedIn: 'root' })
|
|
134
|
+
export class EpdfSearch extends pluginService({
|
|
135
|
+
name: 'EpdfSearch', // as errors show it
|
|
136
|
+
feature: 'withSearch()', // as errors show it
|
|
137
|
+
token: SearchToken,
|
|
138
|
+
state: searchState, // every field becomes a signal: search.hitCount()
|
|
139
|
+
methods: ['search', 'clear', 'next', 'previous', 'goToHit'], // the page's Methods table
|
|
140
|
+
events: ['onCompleted', 'onActiveHitChanged'], // completed$, activeHitChanged$
|
|
141
|
+
}) {
|
|
142
|
+
// What the generic part can't build, written by hand with the protected `binding`:
|
|
143
|
+
/** A value with arguments is a method that returns a signal. */
|
|
144
|
+
hitsOn(page: PageRef | (() => PageRef)): Signal<readonly SearchHit[]> {
|
|
145
|
+
const pageOf = typeof page === 'function' ? page : () => page;
|
|
146
|
+
return this.binding.select(
|
|
147
|
+
(search) => search.listHitsOn(pageOf()),
|
|
148
|
+
NO_HITS,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function withSearch(options?: SearchConfig): EmbedPdfFeature {
|
|
154
|
+
return { plugins: [searchPlugin(options)], services: [EpdfSearch] };
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- `settings()`, `getSettings()`, `updateSettings()`, `resetSettings()` and `settingsChanged$`
|
|
159
|
+
come by themselves when the plugin declares settings; they work before any document opens.
|
|
160
|
+
Don't list `getSettings` in `methods`.
|
|
161
|
+
- Methods are looked up when called, so a method kept in a field acts on the document current
|
|
162
|
+
then; without a document a document plugin's method refuses with `not-ready`.
|
|
163
|
+
- A check (`can…()`, `is…()`, `has…()`) is state: read in a template, a `computed()` or an
|
|
164
|
+
`effect()`, it is read again after each change of the kernel and wakes its reader only when
|
|
165
|
+
its answer changes, so `[disabled]="!metadata.canUpdate()"` follows the document. Other methods
|
|
166
|
+
track nothing.
|
|
167
|
+
- `providedIn: 'root'` is only the fallback that turns a missing feature into EPDF-102; the
|
|
168
|
+
feature's `services` provides it next to the viewer and under every document scope.
|
|
169
|
+
- A namespace (`metadata.custom`, `annotation.selection`) is an object of its own on the service,
|
|
170
|
+
built the same way (`this.binding.namespaceMethod('custom', 'update')`, `this.binding.select(…)`,
|
|
171
|
+
`this.binding.stream(…)`; `metadata.ts` has one). A namespace is
|
|
172
|
+
never itself a signal, and a State value never shares a name with the service or a namespace
|
|
173
|
+
(`EpdfMetadata` has `fields()` and `custom.fields()`).
|
|
174
|
+
- A helper that needs the caller's lifetime (`interaction.overrideCursor(() => spec)`) creates
|
|
175
|
+
its `effect()` in the caller's injection context; say so in its doc comment.
|
|
176
|
+
- App-wide setup that React does with a hook (`useCommandShortcuts()`) is a feature:
|
|
177
|
+
`withCommandShortcuts()` returns `{ plugins: [], setup: () => { … } }`; `setup` runs once in the
|
|
178
|
+
providers' injection context, after the viewer is created.
|
|
179
|
+
|
|
180
|
+
### 3. A component, with inputs and outputs
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
@Component({
|
|
184
|
+
selector: 'epdf-search-layer',
|
|
185
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
186
|
+
template: `@for (hit of hits(); track hit.index) { … (click)="hitClick.emit(hit)" }`,
|
|
187
|
+
})
|
|
188
|
+
export class EpdfSearchLayer {
|
|
189
|
+
readonly hitClick = output<SearchHit>(); // (hitClick)
|
|
190
|
+
…
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
React's props become `input()`s (`<RenderLayer tiles>` → `[tiles]`), `onX` callbacks `output()`s
|
|
195
|
+
named `x` (`onHitClick` → `(hitClick)`), and a controlled value (`zoom` + `onZoomChange`) an input and an output named
|
|
196
|
+
`zoom` / `zoomChange`, so `[(zoom)]` works with a signal. Every component is standalone and
|
|
197
|
+
`OnPush`, and touches the DOM only in the browser (`isPlatformBrowser(inject(PLATFORM_ID))`).
|
|
198
|
+
|
|
199
|
+
### 4. A layer drawn on each page
|
|
200
|
+
|
|
201
|
+
A layer goes inside `<ng-template epdfPage>` and reads its page with `injectPage()`. It resolves
|
|
202
|
+
its plugin for the page's own document:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
export class EpdfSearchLayer {
|
|
206
|
+
private readonly page = injectPage('<epdf-search-layer>'); // EPDF-103 outside a page
|
|
207
|
+
private readonly search = new CapabilityBinding(
|
|
208
|
+
injectKernelHost('<epdf-search-layer>'),
|
|
209
|
+
() => SearchToken,
|
|
210
|
+
() => this.page.documentId,
|
|
211
|
+
);
|
|
212
|
+
protected readonly hits = this.search.select(
|
|
213
|
+
(search) => search.listHitsOn(this.page.ref),
|
|
214
|
+
NO_HITS,
|
|
215
|
+
);
|
|
216
|
+
// page.transform() places page coordinates on screen: page.transform().pageToViewRect(rect)
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`page.transform()`, `page.frame()` and `page.pageIndex()` are signals that move with the
|
|
221
|
+
camera; the context itself never changes. A layer that paints a part of the page itself (the
|
|
222
|
+
annotations, the form fields) says so with `paintsPagePart()`, and the render layer leaves that
|
|
223
|
+
part out of the page's picture.
|
|
224
|
+
|
|
225
|
+
### 5. Custom drawing with a template
|
|
226
|
+
|
|
227
|
+
React's render prop (`renderLink={(link) => …}`) becomes a marker directive on an
|
|
228
|
+
`<ng-template>`, typed with a context guard, that the component reads with `contentChild()`:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
export interface EpdfLinkTemplateContext {
|
|
232
|
+
$implicit: LinkInfo;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@Directive({ selector: 'ng-template[epdfLink]' })
|
|
236
|
+
export class EpdfLinkTemplate {
|
|
237
|
+
readonly template = inject<TemplateRef<EpdfLinkTemplateContext>>(TemplateRef);
|
|
238
|
+
static ngTemplateContextGuard(_: EpdfLinkTemplate, context: unknown): context is EpdfLinkTemplateContext {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// In the layer: the app's template when there is one, the built-in look otherwise.
|
|
244
|
+
private readonly custom = contentChild(EpdfLinkTemplate);
|
|
245
|
+
// <ng-container [ngTemplateOutlet]="custom()?.template ?? builtIn" [ngTemplateOutletContext]="{ $implicit: link }" />
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The app writes `<epdf-link-layer><ng-template epdfLink let-link>…</ng-template></epdf-link-layer>`.
|
|
249
|
+
A name that would clash gets a prefix (the toolbar's command template is `epdfToolbarCommand`,
|
|
250
|
+
because `epdfCommand` is the directive on your own buttons). Floating UI next to something on a
|
|
251
|
+
page uses `<epdf-anchored [anchor]>` inside the Stage.
|
|
252
|
+
|
|
253
|
+
A template can't draw "nothing, use the default" the way a React render prop returning
|
|
254
|
+
`undefined` can: a template you give draws every part of its kind. The toolbar's items of your own
|
|
255
|
+
are templates by name (`epdfToolbarCustom="page-number"`), and an item with no template of its name
|
|
256
|
+
is a native `<slot name>` socket with its command's button inside.
|
|
257
|
+
|
|
258
|
+
Two rules for templates inside a page template (`<ng-template epdfPage>`), which Angular creates
|
|
259
|
+
once per page: an entry the template registers (an annotation renderer) is interned, so the same
|
|
260
|
+
rule gives the same entry on every page and its behaviors register once; and a marker directive's
|
|
261
|
+
inputs are never `input.required`, because a template inside `@if` enters `contentChildren` before
|
|
262
|
+
its inputs are set.
|
|
263
|
+
|
|
264
|
+
### 6. A test
|
|
265
|
+
|
|
266
|
+
Tests run in vitest with happy-dom, compiling components just in time with `TestBed`, zoneless
|
|
267
|
+
(`vitest.config.mts` runs Angular's JIT transform on this package's files, so signal inputs and
|
|
268
|
+
queries work). `test/fixtures.ts` has a fake engine, a small document plugin, and `viewerHost()`
|
|
269
|
+
/ `mount()`:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
it('reads the hit count, and empty without a document', async () => {
|
|
273
|
+
const fixture = await mount(
|
|
274
|
+
viewerHost({ template: '<epdf-search-layer />', imports: [EpdfSearchLayer], features: [withSearch()] }),
|
|
275
|
+
);
|
|
276
|
+
const search = fixture.debugElement.injector.get(EpdfSearch);
|
|
277
|
+
expect(search.hitCount()).toBe(0);
|
|
278
|
+
await kernelOf(fixture).documents.open(bytesInput('a'));
|
|
279
|
+
…
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Port the React tests that test the binding (not the plugin): empty state without a document,
|
|
284
|
+
signals that change only when their value does, the document scope, streams that follow the
|
|
285
|
+
document, outputs, the setup errors.
|
|
286
|
+
|
|
287
|
+
### 7. Checks
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
pnpm --filter @embedpdf/angular test # parity + vitest
|
|
291
|
+
pnpm --filter @embedpdf/angular typecheck # ngc strictTemplates + the tests with tsc
|
|
292
|
+
pnpm --filter @embedpdf/angular build # ng-packagr, then the built declarations as an app reads them
|
|
293
|
+
pnpm --filter @embedpdf/docs-content snippets # your pages, per framework
|
|
294
|
+
pnpm --filter @embedpdf/docs-content samples <area> # your examples
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The build ends with `scripts/check-declarations.mjs`: it imports every entry point from `dist`
|
|
298
|
+
with `skipLibCheck: false` and fails on any error there. The usual one is an import that doesn't
|
|
299
|
+
resolve, which an app (`skipLibCheck: true`) never reports: the service whose type needed it
|
|
300
|
+
quietly loses its members. It happens when a declaration needs a type its source file doesn't
|
|
301
|
+
import, and the compiler names it through another module: the entry point's own public name, or
|
|
302
|
+
the package that declares it, which may not be a dependency of this one. Import that type into
|
|
303
|
+
the file (`services.ts` imports `PluginServiceClass`), or give the member an explicit type.
|
|
304
|
+
|
|
305
|
+
An Angular example (`docs/content/samples/<area>/<name>.angular.ts`) exports its root as `App`
|
|
306
|
+
with `selector: 'demo-root'`, uses the React example's stylesheet with
|
|
307
|
+
`styleUrl: './<name>.css'` and `encapsulation: ViewEncapsulation.None`, and exports every
|
|
308
|
+
component in the file: `ngc` stops reporting template errors anywhere once a file has an
|
|
309
|
+
unexported component.
|