@teipublisher/pb-components 1.43.5 → 1.44.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.
package/src/pb-view.js CHANGED
@@ -1,1366 +1,1366 @@
1
- import { LitElement, html, css } from 'lit-element';
2
- import anime from 'animejs';
3
- import { pbMixin, waitOnce } from "./pb-mixin.js";
4
- import { translate } from "./pb-i18n.js";
5
- import { typesetMath } from "./pb-formula.js";
6
- import { loadStylesheets, themableMixin } from "./theming.js";
7
- import '@polymer/iron-ajax';
8
- import '@polymer/paper-dialog';
9
- import '@polymer/paper-dialog-scrollable';
10
-
11
- /**
12
- * This is the main component for viewing text which has been transformed via an ODD.
13
- * The document to be viewed is determined by the `pb-document` element the property
14
- * `src` points to. If not overwritten, `pb-view` will use the settings defined by
15
- * the connected document, like view type, ODD etc.
16
- *
17
- * `pb-view` can display an entire document or just a fragment of it
18
- * as defined by the properties `xpath`, `xmlId` or `nodeId`. The most common use case
19
- * is to set `xpath` to point to a specific part of a document.
20
- *
21
- * Navigating to the next or previous fragment would usually be triggered by a separate
22
- * `pb-navigation` element, which sends a `pb-navigate` event to the `pb-view`. However,
23
- * `pb-view` also implements automatic loading of next/previous fragments if the user
24
- * scrolls the page beyond the current viewport boudaries. To enable this, set property
25
- * `infinite-scroll`.
26
- *
27
- * You may also define optional parameters to be passed to the ODD in nested `pb-param`
28
- * tags. These parameters can be accessed within the ODD via the `$parameters` map. For
29
- * example, the following snippet is being used to output breadcrumbs above the main text
30
- * in the documentation view:
31
- *
32
- * ```xml
33
- * <section class="breadcrumbs">
34
- * <pb-view id="title-view1" src="document1" subscribe="transcription">
35
- * <pb-param name="mode" value="breadcrumbs"/>
36
- * </pb-view>
37
- * </section>
38
- * ```
39
- *
40
- * @cssprop [--pb-view-column-gap=10px] - The gap between columns in two-column mode
41
- * @cssprop --pb-view-loader-font - Font used in the message shown during loading in infinite scroll mode
42
- * @cssprop [--pb-view-loader-color=black] - Text color in the message shown during loading in infinite scroll mode
43
- * @cssprop [--pb-view-loader-background-padding=10px 20px] - Background padding for the message shown during loading in infinite scroll mode
44
- * @cssprop [--pb-view-loader-background-image=linear-gradient(to bottom, #f6a62440, #f6a524)] - Background image the message shown during loading in infinite scroll mode
45
- * @cssprop --pb-footnote-color - Text color of footnote marker
46
- * @cssprop --pb-footnote-padding - Padding around a footnote marker
47
- * @cssprop --pb-footnote-font-size - Font size for the footnote marker
48
- * @cssprop --pb-footnote-font-family - Font family for the footnote marker
49
- * @cssprop --pb-view-scroll-margin-top - Applied to any element with an id
50
- * @csspart content - The root div around the displayed content
51
- * @csspart footnotes - div containing the footnotes
52
-
53
- * @fires pb-start-update - Fired before the element updates its content
54
- * @fires pb-update - Fired when the component received content from the server
55
- * @fires pb-end-update - Fired after the element has finished updating its content
56
- * @fires pb-navigate - When received, navigate forward or backward in the document
57
- * @fires pb-zoom - When received, zoom in or out by changing font size of the content
58
- * @fires pb-refresh - When received, refresh the content based on the parameters passed in the event
59
- * @fires pb-toggle - When received, toggle content properties
60
- */
61
- export class PbView extends themableMixin(pbMixin(LitElement)) {
62
-
63
- static get properties() {
64
- return {
65
- /**
66
- * The id of a `pb-document` element this view should display.
67
- * Settings like `odd` or `view` will be taken from the `pb-document`
68
- * unless overwritten by properties in this component.
69
- *
70
- * This property is **required** and **must** point to an existing `pb-document` with
71
- * the given id.
72
- *
73
- * Setting the property after initialization will clear the properties xmlId, nodeId and odd.
74
- */
75
- src: {
76
- type: String
77
- },
78
- /**
79
- * The ODD to use for rendering the document. Overwrites an ODD defined on
80
- * `pb-document`. The odd should be specified by its name without path
81
- * or the `.odd` suffix.
82
- */
83
- odd: {
84
- type: String,
85
- reflect: true
86
- },
87
- /**
88
- * The view type to use for paginating the document. Either `page`, `div` or `single`.
89
- * Overwrites the same property specified on `pb-document`. Values have the following meaning:
90
- *
91
- * Value | Displayed content
92
- * ------|------------------
93
- * `page` | content is displayed page by page as determined by tei:pb
94
- * `div` | content is displayed by divisions
95
- * `single` | do not paginate but display entire content at once
96
- */
97
- view: {
98
- type: String,
99
- reflect: true
100
- },
101
- /**
102
- * An eXist nodeId. If specified, selects the root of the fragment of the document
103
- * which should be displayed. Normally this property is set automatically by pagination.
104
- */
105
- nodeId: {
106
- type: String,
107
- reflect: true,
108
- attribute: 'node-id'
109
- },
110
- /**
111
- * An xml:id to be displayed. If specified, this determines the root of the fragment to be
112
- * displayed. Use to directly navigate to a specific section.
113
- */
114
- xmlId: {
115
- type: Array,
116
- reflect: true,
117
- attribute: 'xml-id'
118
- },
119
- /**
120
- * An optional XPath expression: the root of the fragment to be processed is determined
121
- * by evaluating the given XPath expression. The XPath expression should be absolute.
122
- * The namespace of the document is declared as default namespace, so no prefixes should
123
- * be used.
124
- *
125
- * If the `map` property is used, it may change scope for the displayed fragment.
126
- */
127
- xpath: {
128
- type: String,
129
- reflect: true
130
- },
131
- /**
132
- * If defined denotes the local name of an XQuery function in `modules/map.xql`, which will be called
133
- * with the current root node and should return the node of a mapped fragment. This is helpful if one
134
- * wants, for example, to show a translation fragment aligned with the part of the transcription currently
135
- * shown. In this case, the properties of the `pb-view` would still point to the transcription, but the function
136
- * identified by map would return the corresponding fragment from the translation to be processed.
137
- *
138
- * Navigation in the document is still determined by the current root as defined through the `root`, `xpath`
139
- * and `xmlId` properties.
140
- */
141
- map: {
142
- type: String
143
- },
144
- /**
145
- * If set to true, the component will not load automatically. Instead it will wait until it receives a `pb-update`
146
- * event. Use this to make one `pb-view` component dependent on another one. Default is 'false'.
147
- */
148
- onUpdate: {
149
- type: Boolean,
150
- attribute: 'on-update'
151
- },
152
- /**
153
- * Message to display if no content was returned by the server.
154
- * Set to empty string to show nothing.
155
- */
156
- notFound: {
157
- type: String,
158
- attribute: 'not-found'
159
- },
160
- /**
161
- * The relative URL to the script on the server which will be called for loading content.
162
- */
163
- url: {
164
- type: String
165
- },
166
- /**
167
- * If set, rewrite URLs to load pages as static HTML files,
168
- * so no TEI Publisher instance is required. Use this in combination with
169
- * [tei-publisher-static](https://github.com/eeditiones/tei-publisher-static).
170
- * The value should point to the HTTP root path under which the static version
171
- * will be hosted. This is used to resolve CSS stylesheets.
172
- */
173
- static: {
174
- type: String
175
- },
176
- /**
177
- * The server returns footnotes separately. Set this property
178
- * if you wish to append them to the main text.
179
- */
180
- appendFootnotes: {
181
- type: Boolean,
182
- attribute: 'append-footnotes'
183
- },
184
- /**
185
- * Should matches be highlighted if a search has been executed?
186
- */
187
- suppressHighlight: {
188
- type: Boolean,
189
- attribute: 'suppress-highlight',
190
- reflect: true
191
- },
192
- /**
193
- * CSS selector to find column breaks in the content returned
194
- * from the server. If this property is set and column breaks
195
- * are found, the component will display two columns side by side.
196
- */
197
- columnSeparator: {
198
- type: String,
199
- attribute: 'column-separator'
200
- },
201
- /**
202
- * The reading direction, i.e. 'ltr' or 'rtl'.
203
- *
204
- * @type {"ltr"|"rtl"}
205
- */
206
- direction: {
207
- type: String
208
- },
209
- /**
210
- * If set, points to an external stylesheet which should be applied to
211
- * the text *after* the ODD-generated styles.
212
- */
213
- loadCss: {
214
- type: String,
215
- attribute: 'load-css'
216
- },
217
- /**
218
- * If set, relative links (img, a) will be made absolute.
219
- */
220
- fixLinks: {
221
- type: Boolean,
222
- attribute: 'fix-links'
223
- },
224
- /**
225
- * If set, a refresh will be triggered if a `pb-i18n-update` event is received,
226
- * e.g. due to the user selecting a different interface language.
227
- *
228
- * Also requires `requireLanguage` to be set on the surrounding `pb-page`.
229
- * See there for more information.
230
- */
231
- useLanguage: {
232
- type: Boolean,
233
- attribute: 'use-language'
234
- },
235
- /**
236
- * wether to animate the view when new page is loaded. Defaults to 'false' meaning that no
237
- * animation takes place. If 'true' will apply a translateX transistion in forward/backward direction.
238
- */
239
- animation: {
240
- type: Boolean
241
- },
242
- /**
243
- * Experimental: if enabled, the view will incrementally load new document fragments if the user tries to scroll
244
- * beyond the start or end of the visible text. The feature inserts a small blank section at the top
245
- * and bottom. If this section becomes visible, a load operation will be triggered.
246
- *
247
- * Note: only browsers implementing the `IntersectionObserver` API are supported. Also the feature
248
- * does not work in two-column mode or with animations.
249
- */
250
- infiniteScroll: {
251
- type: Boolean,
252
- attribute: 'infinite-scroll'
253
- },
254
- /**
255
- * Maximum number of fragments to keep in memory if `infinite-scroll`
256
- * is enabled. If the user is scrolling beyond the maximum, fragements
257
- * will be removed from the DOM before or after the current reading position.
258
- * Default is 10. Set to zero to allow loading the entire document.
259
- */
260
- infiniteScrollMax: {
261
- type: Number,
262
- attribute: 'infinite-scroll-max'
263
- },
264
- /**
265
- * A selector pointing to other components this component depends on.
266
- * When method `wait` is called, it will wait until all referenced
267
- * components signal with a `pb-ready` event that they are ready and listening
268
- * to events.
269
- *
270
- * `pb-view` by default sets this property to select `pb-toggle-feature` and `pb-select-feature`
271
- * elements.
272
- */
273
- waitFor: {
274
- type: String,
275
- attribute: 'wait-for'
276
- },
277
- /**
278
- * By default, navigating to next/previous page will update browser parameters,
279
- * so reloading the page will load the correct position within the document.
280
- *
281
- * Set this property to disable location tracking for the component altogether.
282
- */
283
- disableHistory: {
284
- type: Boolean,
285
- attribute: 'disable-history'
286
- },
287
- /**
288
- * If set to the name of an event, the content of the pb-view will not be replaced
289
- * immediately upon updates. Instead, an event is emitted, which contains the new content
290
- * in property `root`. An event handler intercepting the event can thus modify the content.
291
- * Once it is done, it should pass the modified content to the callback function provided
292
- * in the event detail under the name `render`. See the demo for an example.
293
- */
294
- beforeUpdate: {
295
- type: String,
296
- attribute: 'before-update-event'
297
- },
298
- /**
299
- * If set, do not scroll the view to target node (e.g. given in URL hash)
300
- * after content was loaded.
301
- */
302
- noScroll: {
303
- type: Boolean,
304
- attribute: 'no-scroll'
305
- },
306
- _features: {
307
- type: Object
308
- },
309
- _content: {
310
- type: Node,
311
- attribute: false
312
- },
313
- _column1: {
314
- type: Node,
315
- attribute: false
316
- },
317
- _column2: {
318
- type: Node,
319
- attribute: false
320
- },
321
- _footnotes: {
322
- type: Node,
323
- attribute: false
324
- },
325
- _style: {
326
- type: Node,
327
- attribute: false
328
- },
329
- ...super.properties
330
- };
331
- }
332
-
333
- constructor() {
334
- super();
335
- this.src = null;
336
- this.url = null;
337
- this.onUpdate = false;
338
- this.appendFootnotes = false;
339
- this.notFound = "the server did not return any content";
340
- this.animation = false;
341
- this.direction = 'ltr';
342
- this.suppressHighlight = false;
343
- this.highlight = false;
344
- this.infiniteScrollMax = 10;
345
- this.disableHistory = false;
346
- this.beforeUpdate = null;
347
- this.noScroll = false;
348
- this._features = {};
349
- this._selector = new Map();
350
- this._chunks = [];
351
- this._scrollTarget = null;
352
- this.static = null;
353
- }
354
-
355
- attributeChangedCallback(name, oldVal, newVal) {
356
- super.attributeChangedCallback(name, oldVal, newVal);
357
- switch (name) {
358
- case 'src':
359
- this._updateSource(newVal, oldVal);
360
- break;
361
- }
362
- }
363
-
364
- connectedCallback() {
365
- super.connectedCallback();
366
-
367
- if (this.loadCss) {
368
- waitOnce('pb-page-ready', () => {
369
- loadStylesheets([this.toAbsoluteURL(this.loadCss)])
370
- .then((theme) => {
371
- this.shadowRoot.adoptedStyleSheets = [...this.shadowRoot.adoptedStyleSheets, theme];
372
- });
373
- });
374
- }
375
-
376
- if (this.infiniteScroll) {
377
- this.columnSeparator = null;
378
- this.animation = false;
379
- this._content = document.createElement('div');
380
- this._content.className = 'infinite-content';
381
- }
382
-
383
- if (!this.disableHistory) {
384
- const id = this.getParameter('id');
385
- if (id && !this.xmlId) {
386
- this.xmlId = id;
387
- }
388
-
389
- const action = this.getParameter('action');
390
- if (action && action === 'search') {
391
- this.highlight = true;
392
- }
393
-
394
- const nodeId = this.getParameter('root');
395
- if (this.view === 'single') {
396
- this.nodeId = null;
397
- } else if (nodeId && !this.nodeId) {
398
- this.nodeId = nodeId;
399
- }
400
- }
401
- if (!this.waitFor) {
402
- this.waitFor = 'pb-toggle-feature,pb-select-feature,pb-navigation';
403
- }
404
-
405
- this.subscribeTo('pb-navigate', ev => {
406
- if (ev.detail.source && ev.detail.source === this) {
407
- return;
408
- }
409
- this.navigate(ev.detail.direction);
410
- });
411
- this.subscribeTo('pb-refresh', this._refresh.bind(this));
412
- this.subscribeTo('pb-toggle', ev => {
413
- this.toggleFeature(ev);
414
- });
415
- this.subscribeTo('pb-zoom', ev => {
416
- this.zoom(ev.detail.direction);
417
- });
418
- this.subscribeTo('pb-i18n-update', ev => {
419
- const needsRefresh = this._features.language && this._features.language !== ev.detail.language;
420
- this._features.language = ev.detail.language;
421
- if (this.useLanguage && needsRefresh) {
422
- this._refresh();
423
- }
424
- }, []);
425
-
426
- this.signalReady();
427
-
428
- if (this.onUpdate) {
429
- this.subscribeTo('pb-update', this._refresh.bind(this));
430
- }
431
- }
432
-
433
- disconnectedCallback() {
434
- super.disconnectedCallback();
435
- if (this._scrollObserver) {
436
- this._scrollObserver.disconnect();
437
- }
438
- }
439
-
440
- firstUpdated() {
441
- super.firstUpdated();
442
- this.enableScrollbar(true);
443
- if (this.infiniteScroll) {
444
- this._topObserver = this.shadowRoot.getElementById('top-observer');
445
- this._bottomObserver = this.shadowRoot.getElementById('bottom-observer');
446
- this._bottomObserver.style.display = 'none';
447
- this._topObserver.style.display = 'none';
448
- this._scrollObserver = new IntersectionObserver((entries) => {
449
- if (!this._content) {
450
- return;
451
- }
452
- entries.forEach((entry) => {
453
- if (entry.isIntersecting) {
454
- if (entry.target.id === 'bottom-observer') {
455
- const lastChild = this._content.lastElementChild;
456
- if (lastChild) {
457
- const next = lastChild.getAttribute('data-next');
458
- if (next && !this._content.querySelector(`[data-root="${next}"]`)) {
459
- console.log('<pb-view> Loading next page: %s', next);
460
- this._checkChunks('forward');
461
- this._load(next, 'forward');
462
- }
463
- }
464
- } else {
465
- const firstChild = this._content.firstElementChild;
466
- if (firstChild) {
467
- const previous = firstChild.getAttribute('data-previous');
468
- if (previous && !this._content.querySelector(`[data-root="${previous}"]`)) {
469
- this._checkChunks('backward');
470
- this._load(previous, 'backward');
471
- }
472
- }
473
- }
474
- }
475
- });
476
- });
477
- }
478
- if (!this.onUpdate) {
479
- PbView.waitOnce('pb-page-ready', (data) => {
480
- if (data && data.language) {
481
- this._features.language = data.language;
482
- }
483
- this.wait(() => this._refresh());
484
- });
485
- }
486
- }
487
-
488
- /**
489
- * Returns the ODD used to render content.
490
- *
491
- * @returns the ODD being used
492
- */
493
- getOdd() {
494
- return this.odd || this.getDocument().odd || "teipublisher";
495
- }
496
-
497
- getView() {
498
- return this.view || this.getDocument().view || "single";
499
- }
500
-
501
- /**
502
- * Trigger an update of this element's content
503
- */
504
- forceUpdate() {
505
- this._load(this.nodeId);
506
-
507
- }
508
-
509
- animate() {
510
- // animate new element if 'animation' property is 'true'
511
- if (this.animation) {
512
- if (this.lastDirection === 'forward') {
513
- anime({
514
- targets: this.shadowRoot.getElementById('view'),
515
- opacity: [0, 1],
516
- translateX: [1000, 0],
517
- duration: 300,
518
- easing: 'linear'
519
- });
520
- } else {
521
- anime({
522
- targets: this.shadowRoot.getElementById('view'),
523
- opacity: [0, 1],
524
- translateX: [-1000, 0],
525
- duration: 300,
526
- easing: 'linear'
527
- });
528
- }
529
- }
530
- }
531
-
532
- enableScrollbar(enable) {
533
- if (enable) {
534
- this.classList.add('noscroll');
535
- } else {
536
- this.classList.remove('noscroll');
537
- }
538
- }
539
-
540
- _refresh(ev) {
541
- if (ev && ev.detail) {
542
- if (ev.detail.hash && !this.noScroll && !(ev.detail.id || ev.detail.path || ev.detail.odd || ev.detail.view || ev.detail.position)) {
543
- // if only the scroll target has changed: scroll to the element without reloading
544
- this._scrollTarget = ev.detail.hash;
545
- const target = this.shadowRoot.getElementById(this._scrollTarget);
546
- if (target) {
547
- setTimeout(() => target.scrollIntoView({block: 'nearest'}));
548
- }
549
- return;
550
- }
551
- if (ev.detail.path) {
552
- const doc = this.getDocument();
553
- doc.path = ev.detail.path;
554
- }
555
- if (ev.detail.id) {
556
- this.xmlId = ev.detail.id;
557
- }
558
- this.odd = ev.detail.odd || this.odd;
559
- if (ev.detail.columnSeparator !== undefined) {
560
- this.columnSeparator = ev.detail.columnSeparator;
561
- }
562
- this.view = ev.detail.view || this.view;
563
- if (ev.detail.xpath) {
564
- this.xpath = ev.detail.xpath;
565
- this.nodeId = null;
566
- }
567
- // clear nodeId if set to null
568
- if (ev.detail.position === null) {
569
- this.nodeId = null;
570
- } else {
571
- this.nodeId = ev.detail.position || this.nodeId;
572
- }
573
- if (!this.noScroll) {
574
- this._scrollTarget = ev.detail.hash;
575
- }
576
- }
577
- this._updateStyles();
578
- if (this.infiniteScroll) {
579
- this._clear();
580
- }
581
- this._load(this.nodeId);
582
- }
583
-
584
- _load(pos, direction) {
585
- const doc = this.getDocument();
586
-
587
- if (!doc.path) {
588
- console.log("No path");
589
- return;
590
- }
591
-
592
- if (this._loading) {
593
- return;
594
- }
595
- this._loading = true;
596
- const params = this.getParameters(pos);
597
- if (direction) {
598
- params._dir = direction;
599
- }
600
- // this.$.view.style.opacity=0;
601
-
602
- this._doLoad(params);
603
- }
604
-
605
- _doLoad(params) {
606
- this.emitTo('pb-start-update', params);
607
-
608
- console.log("<pb-view> Loading view with params %o", params);
609
- if (!this.infiniteScroll) {
610
- this._clear();
611
- }
612
-
613
- if (this._scrollObserver) {
614
- if (this._bottomObserver) {
615
- this._scrollObserver.unobserve(this._bottomObserver);
616
- }
617
- if (this._topObserver) {
618
- this._scrollObserver.unobserve(this._topObserver);
619
- }
620
- }
621
-
622
- const loadContent = this.shadowRoot.getElementById('loadContent');
623
-
624
- if (this.static !== null) {
625
- this._staticUrl(params).then((url) => {
626
- loadContent.url = url;
627
- loadContent.generateRequest();
628
- });
629
- } else {
630
- if (!this.url) {
631
- if (this.minApiVersion('1.0.0')) {
632
- this.url = "api/parts";
633
- } else {
634
- this.url = "modules/lib/components.xql";
635
- }
636
- }
637
- if (this.minApiVersion('1.0.0')) {
638
- loadContent.url = `${this.getEndpoint()}/${this.url}/${encodeURIComponent(this.getDocument().path)}/json`;
639
- } else {
640
- loadContent.url = `${this.getEndpoint()}/${this.url}`;
641
- }
642
- loadContent.params = params;
643
- loadContent.generateRequest();
644
- }
645
- }
646
-
647
- /**
648
- * Use a static URL to load pre-generated content.
649
- */
650
- async _staticUrl(params) {
651
- function createKey(paramNames) {
652
- const urlComponents = [];
653
- paramNames.sort().forEach(key => {
654
- if (params.hasOwnProperty(key)) {
655
- urlComponents.push(`${key}=${params[key]}`);
656
- }
657
- });
658
- return urlComponents.join('&');
659
- }
660
-
661
- const index = await fetch(`index.json`)
662
- .then((response) => response.json());
663
- const paramNames = ['odd', 'view', 'xpath', 'map'];
664
- this.querySelectorAll('pb-param').forEach((param) => paramNames.push(`user.${param.getAttribute('name')}`));
665
- let url = params.id ? createKey([...paramNames, 'id']) : createKey([...paramNames, 'root']);
666
- let file = index[url];
667
- if (!file) {
668
- url = createKey(paramNames);
669
- file = index[url];
670
- }
671
-
672
- console.log('<pb-view> Static lookup %s: %s', url, file);
673
- return `${file}`;
674
- }
675
-
676
- _clear() {
677
- if (this.infiniteScroll) {
678
- this._content = document.createElement('div');
679
- this._content.className = 'infinite-content';
680
- } else {
681
- this._content = null;
682
- }
683
- this._column1 = null;
684
- this._column2 = null;
685
- this._footnotes = null;
686
- this._chunks = [];
687
- }
688
-
689
- _handleError() {
690
- this._clear();
691
- const loader = this.shadowRoot.getElementById('loadContent');
692
- let message;
693
- const { response } = loader.lastError;
694
-
695
- if (response) {
696
- message = response.description;
697
- } else {
698
- message = '<pb-i18n key="dialogs.serverError"></pb-i18n>';
699
- }
700
-
701
- const content = `
702
- <p>${this.notFound}</p>
703
- <p><pb-i18n key="dialogs.serverError"></pb-i18n>: ${message} </p>
704
- `;
705
-
706
- this._replaceContent({ content });
707
- this.emitTo('pb-end-update');
708
-
709
- }
710
-
711
- _handleContent() {
712
- const loader = this.shadowRoot.getElementById('loadContent');
713
- const resp = loader.lastResponse;
714
-
715
- if (!resp) {
716
- console.error('<pb-view> No response received');
717
- return;
718
- }
719
- if (resp.error) {
720
- if (this.notFound) {
721
- this._content = this.notFound;
722
- }
723
- this.emitTo('pb-end-update', null);
724
- return;
725
- }
726
-
727
- this._replaceContent(resp, loader.params._dir);
728
-
729
- this.animate();
730
-
731
- if (this._scrollTarget) {
732
- this.updateComplete.then(() => {
733
- const target = this.shadowRoot.getElementById(this._scrollTarget) ||
734
- this.shadowRoot.querySelector(`[node-id="${this._scrollTarget}"]`);
735
- if (target) {
736
- window.requestAnimationFrame(() =>
737
- setTimeout(() => {
738
- target.scrollIntoView({block: 'nearest'});
739
- }, 400)
740
- );
741
- }
742
- this._scrollTarget = null;
743
- });
744
- }
745
-
746
- this.next = resp.next;
747
- this.nextId = resp.nextId;
748
- this.previous = resp.previous;
749
- this.previousId = resp.previousId;
750
- this.nodeId = resp.root;
751
- this.switchView = resp.switchView;
752
- if (!this.disableHistory && this.xmlId && !this.map) {
753
- //this.setParameter('root', this.nodeId);
754
- this.setParameter('id', this.xmlId);
755
- this.pushHistory('Navigate to xml:id');
756
- }
757
- this.xmlId = null;
758
-
759
- this.updateComplete.then(() => {
760
- const view = this.shadowRoot.getElementById('view');
761
- this._applyToggles(view);
762
- this._fixLinks(view);
763
- typesetMath(view);
764
-
765
- const eventOptions = {
766
- data: resp,
767
- root: view,
768
- params: loader.params,
769
- id: this.xmlId,
770
- position: this.nodeId
771
- };
772
- this.emitTo('pb-update', eventOptions);
773
- this._scroll();
774
- });
775
-
776
- this.emitTo('pb-end-update', null);
777
- }
778
-
779
- _replaceContent(resp, direction) {
780
- const fragment = document.createDocumentFragment();
781
- const elem = document.createElement('div');
782
- // elem.style.opacity = 0; //hide it - animation has to make sure to blend it in
783
- fragment.appendChild(elem);
784
- elem.innerHTML = resp.content;
785
-
786
- // if before-update-event is set, we do not replace the content immediately,
787
- // but emit an event
788
- if (this.beforeUpdate) {
789
- this.emitTo(this.beforeUpdate, {
790
- data: resp,
791
- root: elem,
792
- render: (content) => {
793
- this._doReplaceContent(content, resp, direction);
794
- }
795
- });
796
- } else {
797
- this._doReplaceContent(elem, resp, direction);
798
- }
799
- }
800
-
801
- _doReplaceContent(elem, resp, direction) {
802
- if (this.columnSeparator) {
803
- this._replaceColumns(elem);
804
- this._loading = false;
805
- } else if (this.infiniteScroll) {
806
- elem.className = 'scroll-fragment';
807
- elem.setAttribute('data-root', resp.root);
808
- if (resp.next) {
809
- elem.setAttribute('data-next', resp.next);
810
- }
811
- if (resp.previous) {
812
- elem.setAttribute('data-previous', resp.previous);
813
- }
814
- let refNode;
815
- switch (direction) {
816
- case 'backward':
817
- refNode = this._content.firstElementChild;
818
- this._chunks.unshift(elem);
819
- this.updateComplete.then(() => {
820
- refNode.scrollIntoView(true);
821
- this._loading = false;
822
- this._checkVisibility();
823
- this._scrollObserver.observe(this._bottomObserver);
824
- this._scrollObserver.observe(this._topObserver);
825
- });
826
- this._content.insertBefore(elem, refNode);
827
- break;
828
- default:
829
- this.updateComplete.then(() => {
830
- this._loading = false;
831
- this._checkVisibility();
832
- this._scrollObserver.observe(this._bottomObserver);
833
- this._scrollObserver.observe(this._topObserver);
834
- });
835
- this._chunks.push(elem);
836
- this._content.appendChild(elem);
837
- break;
838
- }
839
- } else {
840
- this._content = elem;
841
- this._loading = false;
842
- }
843
-
844
- if (this.appendFootnotes) {
845
- const footnotes = document.createElement('div');
846
- if (resp.footnotes) {
847
- footnotes.innerHTML = resp.footnotes;
848
- }
849
- this._footnotes = footnotes;
850
- }
851
-
852
- this._initFootnotes(this._footnotes);
853
-
854
- return elem;
855
- }
856
-
857
- _checkVisibility() {
858
- const bottomActive = this._chunks[this._chunks.length - 1].hasAttribute('data-next');
859
- this._bottomObserver.style.display = bottomActive ? '' : 'none';
860
-
861
- const topActive = this._chunks[0].hasAttribute('data-previous');
862
- this._topObserver.style.display = topActive ? '' : 'none';
863
- }
864
-
865
- _replaceColumns(elem) {
866
- let cb;
867
- if (this.columnSeparator) {
868
- const cbs = elem.querySelectorAll(this.columnSeparator);
869
- // use last separator only
870
- if (cbs.length > 1) {
871
- cb = cbs[cbs.length - 1];
872
- }
873
- }
874
-
875
- if (!cb) {
876
- this._content = elem;
877
- } else {
878
- const fragmentBefore = this._getFragmentBefore(elem, cb);
879
- const fragmentAfter = this._getFragmentAfter(elem, cb);
880
- if (this.direction === 'ltr') {
881
- this._column1 = fragmentBefore;
882
- this._column2 = fragmentAfter;
883
- } else {
884
- this._column2 = fragmentBefore;
885
- this._column1 = fragmentAfter;
886
- }
887
- }
888
- }
889
-
890
- _scroll() {
891
- if (this.noScroll) {
892
- return;
893
- }
894
- const { hash } = this.getUrl();
895
- if (hash) {
896
- const target = this.shadowRoot.getElementById(hash.substring(1));
897
- console.log('hash target: %o', target);
898
- if (target) {
899
- window.requestAnimationFrame(() =>
900
- setTimeout(() => {
901
- target.scrollIntoView({block: 'nearest'});
902
- }, 400)
903
- );
904
- }
905
- }
906
- }
907
-
908
- _scrollToElement(ev, link) {
909
- const target = this.shadowRoot.getElementById(link.hash.substring(1));
910
- if (target) {
911
- ev.preventDefault();
912
- console.log('<pb-view> Scrolling to element %s', target.id);
913
- target.scrollIntoView({ block: "center", inline: "nearest" });
914
- }
915
- }
916
-
917
- _updateStyles() {
918
- let link = document.createElement('link');
919
- link.setAttribute('rel', 'stylesheet');
920
- link.setAttribute('type', 'text/css');
921
- if (this.static !== null) {
922
- link.setAttribute('href', `${this.static}/css/${this.getOdd()}.css`);
923
- } else {
924
- link.setAttribute('href', `${this.getEndpoint()}/transform/${this.getOdd()}.css`);
925
- }
926
- this._style = link;
927
- }
928
-
929
- _fixLinks(content) {
930
- if (this.fixLinks) {
931
- const doc = this.getDocument();
932
- const base = this.toAbsoluteURL(doc.path);
933
- content.querySelectorAll('img').forEach((image) => {
934
- const oldSrc = image.getAttribute('src');
935
- const src = new URL(oldSrc, base);
936
- image.src = src;
937
- });
938
- content.querySelectorAll('a').forEach((link) => {
939
- const oldHref = link.getAttribute('href');
940
- if (oldHref === link.hash) {
941
- link.addEventListener('click', (ev) => this._scrollToElement(ev, link));
942
- } else {
943
- const href = new URL(oldHref, base);
944
- link.href = href;
945
- }
946
- });
947
- } else {
948
- content.querySelectorAll('a').forEach((link) => {
949
- const oldHref = link.getAttribute('href');
950
- if (oldHref === link.hash) {
951
- link.addEventListener('click', (ev) => this._scrollToElement(ev, link));
952
- }
953
- });
954
- }
955
- }
956
-
957
- _initFootnotes(content) {
958
- if (content) {
959
- content.querySelectorAll('.note, .fn-back').forEach(elem => {
960
- elem.addEventListener('click', (ev) => {
961
- ev.preventDefault();
962
- const fn = this.shadowRoot.getElementById('content').querySelector(elem.hash);
963
- if (fn) {
964
- fn.scrollIntoView();
965
- }
966
- });
967
- });
968
- }
969
- }
970
-
971
- _getParameters() {
972
- const params = [];
973
- this.querySelectorAll('pb-param').forEach(function (param) {
974
- params['user.' + param.getAttribute('name')] = param.getAttribute('value');
975
- });
976
- // add parameters for features set with pb-toggle-feature
977
- for (const [key, value] of Object.entries(this._features)) {
978
- params['user.' + key] = value;
979
- }
980
- return params;
981
- }
982
-
983
- /**
984
- * Return the parameter object which would be passed to the server by this component
985
- */
986
- getParameters(pos) {
987
- pos = pos || this.nodeId;
988
- const doc = this.getDocument();
989
- const params = this._getParameters();
990
- if (!this.minApiVersion('1.0.0')) {
991
- params.doc = doc.path;
992
- }
993
- params.odd = this.getOdd() + '.odd';
994
- params.view = this.getView();
995
- if (pos) {
996
- params['root'] = pos;
997
- }
998
- if (this.xpath) {
999
- params.xpath = this.xpath;
1000
- }
1001
- if (this.xmlId) {
1002
- params.id = this.xmlId;
1003
- }
1004
- if (!this.suppressHighlight && this.highlight) {
1005
- params.highlight = "yes";
1006
- }
1007
- if (this.map) {
1008
- params.map = this.map;
1009
- }
1010
-
1011
- return params;
1012
- }
1013
-
1014
- _applyToggles(elem) {
1015
- if (this._selector.size === 0) {
1016
- return;
1017
- }
1018
- this._selector.forEach((setting, selector) => {
1019
- elem.querySelectorAll(selector).forEach(node => {
1020
- const command = setting.command || 'toggle';
1021
- if (node.command) {
1022
- node.command(command, setting.state);
1023
- }
1024
- if (setting.state) {
1025
- node.classList.add(command);
1026
- } else {
1027
- node.classList.remove(command);
1028
- }
1029
- });
1030
- });
1031
- }
1032
-
1033
- /**
1034
- * Load a part of the document identified by the given eXist nodeId
1035
- *
1036
- * @param {String} nodeId The eXist nodeId of the root element to load
1037
- */
1038
- goto(nodeId) {
1039
- this._load(nodeId);
1040
- }
1041
-
1042
- /**
1043
- * Load a part of the document identified by the given xml:id
1044
- *
1045
- * @param {String} xmlId The xml:id to be loaded
1046
- */
1047
- gotoId(xmlId) {
1048
- this.xmlId = xmlId;
1049
- this._load();
1050
- }
1051
-
1052
- /**
1053
- * Navigate the document either forward or backward and refresh the view.
1054
- * The navigation method is determined by property `view`.
1055
- *
1056
- * @param {string} direction either `backward` or `forward`
1057
- */
1058
- navigate(direction) {
1059
- this.lastDirection = direction;
1060
-
1061
- if (direction === 'backward') {
1062
- if (this.previous) {
1063
- if (!this.disableHistory && !this.map) {
1064
- if (this.previousId) {
1065
- this.setParameter('id', this.previousId);
1066
- } else {
1067
- this.setParameter('root', this.previous);
1068
- }
1069
- this.pushHistory('Navigate backward');
1070
- }
1071
- this._load(this.previous, direction);
1072
- }
1073
- } else if (this.next) {
1074
- if (!this.disableHistory && !this.map) {
1075
- if (this.nextId) {
1076
- this.setParameter('id', this.nextId);
1077
- } else {
1078
- this.setParameter('root', this.next);
1079
- }
1080
- this.pushHistory('Navigate forward');
1081
- }
1082
- this._load(this.next, direction);
1083
- }
1084
- }
1085
-
1086
- /**
1087
- * Check the number of fragments which were already loaded in infinite
1088
- * scroll mode. If they exceed `infiniteScrollMax`, remove either the
1089
- * first or last fragment from the DOM, depending on the scroll direction.
1090
- *
1091
- * @param {string} direction either 'forward' or 'backward'
1092
- */
1093
- _checkChunks(direction) {
1094
- if (!this.infiniteScroll || this.infiniteScrollMax === 0) {
1095
- return;
1096
- }
1097
-
1098
- if (this._chunks.length === this.infiniteScrollMax) {
1099
- switch (direction) {
1100
- case 'forward':
1101
- this._content.removeChild(this._chunks.shift());
1102
- break;
1103
- default:
1104
- this._content.removeChild(this._chunks.pop());
1105
- break;
1106
- }
1107
- }
1108
- this.emitTo('pb-navigate', {
1109
- direction,
1110
- source: this
1111
- });
1112
- }
1113
-
1114
- /**
1115
- * Zoom the displayed content by increasing or decreasing font size.
1116
- *
1117
- * @param {string} direction either `in` or `out`
1118
- */
1119
- zoom(direction) {
1120
- const view = this.shadowRoot.getElementById('view');
1121
- const fontSize = window.getComputedStyle(view).getPropertyValue('font-size');
1122
- const size = parseInt(fontSize.replace(/^(\d+)px/, "$1"));
1123
-
1124
- if (direction === 'in') {
1125
- view.style.fontSize = (size + 1) + 'px';
1126
- } else {
1127
- view.style.fontSize = (size - 1) + 'px';
1128
- }
1129
- }
1130
-
1131
- toggleFeature(ev) {
1132
- const applyToggles = () => {
1133
- const view = this.shadowRoot.getElementById('view');
1134
- this._applyToggles(view);
1135
- }
1136
-
1137
- const properties = ev.detail.properties;
1138
- for (const [key, value] of Object.entries(properties)) {
1139
- switch (key) {
1140
- case 'odd':
1141
- case 'view':
1142
- case 'columnSeparator':
1143
- case 'xpath':
1144
- case 'nodeId':
1145
- break;
1146
- default:
1147
- this._features[key] = value;
1148
- break;
1149
- }
1150
- }
1151
- if (properties) {
1152
- if (properties.odd) {
1153
- this.odd = properties.odd;
1154
- }
1155
- if (properties.view) {
1156
- this.view = properties.view;
1157
- if (this.view === 'single') {
1158
- // when switching to single view, clear current node id
1159
- this.nodeId = null;
1160
- } else {
1161
- // otherwise use value for alternate view returned from server
1162
- this.nodeId = this.switchView;
1163
- }
1164
- }
1165
- if (properties.xpath) {
1166
- this.xpath = properties.xpath;
1167
- }
1168
- if (properties.hasOwnProperty('columnSeparator')) {
1169
- this.columnSeparator = properties.columnSeparator;
1170
- }
1171
- }
1172
- if (ev.detail.selectors) {
1173
- ev.detail.selectors.forEach(sc => {
1174
- this._selector.set(sc.selector, {
1175
- state: sc.state,
1176
- command: sc.command || 'toggle'
1177
- });
1178
- });
1179
- }
1180
- if (ev.detail.action === 'refresh') {
1181
- if (Object.keys(properties).length > 0) {
1182
- this._updateStyles();
1183
- this._load();
1184
- } else {
1185
- applyToggles();
1186
- }
1187
- }
1188
- }
1189
-
1190
- _getFragmentBefore(node, ms) {
1191
- const range = document.createRange();
1192
- range.setStartBefore(node);
1193
- range.setEndBefore(ms);
1194
-
1195
- return range.cloneContents();
1196
- }
1197
-
1198
- _getFragmentAfter(node, ms) {
1199
- const range = document.createRange();
1200
- range.setStartBefore(ms);
1201
- range.setEndAfter(node);
1202
-
1203
- return range.cloneContents();
1204
- }
1205
-
1206
- _updateSource(newVal, oldVal) {
1207
- if (typeof oldVal !== 'undefined' && newVal !== oldVal) {
1208
- this.xpath = null;
1209
- this.odd = null;
1210
- this.xmlId = null;
1211
- this.nodeId = null;
1212
- }
1213
- }
1214
-
1215
- static get styles() {
1216
- return css`
1217
- :host {
1218
- display: block;
1219
- background: transparent;
1220
- }
1221
-
1222
- :host(.noscroll) {
1223
- scrollbar-width: none; /* Firefox 64 */
1224
- -ms-overflow-style: none;
1225
- }
1226
-
1227
- :host(.noscroll)::-webkit-scrollbar {
1228
- width: 0 !important;
1229
- display: none;
1230
- }
1231
-
1232
- [id] {
1233
- scroll-margin-top: var(--pb-view-scroll-margin-top);
1234
- }
1235
-
1236
- #view {
1237
- position: relative;
1238
- }
1239
-
1240
- .columns {
1241
- display: grid;
1242
- grid-template-columns: calc(50% - var(--pb-view-column-gap, 10px) / 2) calc(50% - var(--pb-view-column-gap, 10px) / 2);
1243
- grid-column-gap: var(--pb-view-column-gap, 10px);
1244
- }
1245
-
1246
- .margin-note {
1247
- display: none;
1248
- }
1249
-
1250
- @media (min-width: 769px) {
1251
- .content.margin-right {
1252
- margin-right: 200px;
1253
- }
1254
-
1255
- .margin-note {
1256
- background: rgba(153, 153, 153, 0.2);
1257
- display: block;
1258
- font-size: small;
1259
- margin-right: -200px;
1260
- margin-bottom: 5px;
1261
- padding: 5px 0;
1262
- float: right;
1263
- clear: both;
1264
- width: 180px;
1265
- }
1266
-
1267
- .margin-note .n {
1268
- color: #777777;
1269
- }
1270
- }
1271
-
1272
- a[rel=footnote] {
1273
- font-size: var(--pb-footnote-font-size, var(--pb-content-font-size, 75%));
1274
- font-family: var(--pb-footnote-font-family, --pb-content-font-family);
1275
- vertical-align: super;
1276
- color: var(--pb-footnote-color, var(--pb-color-primary, #333333));
1277
- text-decoration: none;
1278
- padding: var(--pb-footnote-padding, 0 0 0 .25em);
1279
- }
1280
-
1281
- .list dt {
1282
- float: left;
1283
- }
1284
-
1285
- .footnote .fn-number {
1286
- float: left;
1287
- font-size: var(--pb-footnote-font-size, var(--pb-content-font-size, 75%));
1288
- }
1289
-
1290
- .observer {
1291
- display: block;
1292
- width: 100%;
1293
- height: var(--pb-view-loader-height, 16px);
1294
- font-family: var(--pb-view-loader-font, --pb-base-font);
1295
- color: var(--pb-view-loader-color, black);
1296
- background: var(--pb-view-loader-background, #909090);
1297
- background-image: var(--pb-view-loader-background-image, repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,.5) 35px, rgba(255,255,255,.5) 70px));
1298
- animation-name: loader;
1299
- animation-timing-function: linear;
1300
- animation-duration: 2s;
1301
- animation-fill-mode: forwards;
1302
- animation-iteration-count: infinite;
1303
- }
1304
-
1305
- @keyframes loader {
1306
- 0% {
1307
- background-position: 3rem 0;
1308
- }
1309
-
1310
- 100% {
1311
- background-position: 0 0;
1312
- }
1313
- }
1314
-
1315
- .scroll-fragment {
1316
- animation: fadeIn ease 500ms;
1317
- }
1318
-
1319
- @keyframes fadeIn {
1320
- 0% {opacity:0;}
1321
- 100% {opacity:1;}
1322
- }
1323
- `;
1324
- }
1325
-
1326
- render() {
1327
- return [
1328
- html`
1329
- <div id="view" part="content">
1330
- ${this._style}
1331
- ${this.infiniteScroll ? html`<div id="top-observer" class="observer"></div>` : null}
1332
- <div class="columns">
1333
- <div id="column1">${this._column1}</div>
1334
- <div id="column2">${this._column2}</div>
1335
- </div>
1336
- <div id="content">${this._content}</div>
1337
- ${
1338
- this.infiniteScroll ?
1339
- html`<div id="bottom-observer" class="observer"></div>` :
1340
- null
1341
- }
1342
- <div id="footnotes" part="footnotes">${this._footnotes}</div>
1343
- </div>
1344
- <paper-dialog id="errorDialog">
1345
- <h2>${translate('dialogs.error')}</h2>
1346
- <paper-dialog-scrollable></paper-dialog-scrollable>
1347
- <div class="buttons">
1348
- <paper-button dialog-confirm="dialog-confirm" autofocus="autofocus">
1349
- ${translate('dialogs.close')}
1350
- </paper-button>
1351
- </div>
1352
- </paper-dialog>
1353
- <iron-ajax
1354
- id="loadContent"
1355
- verbose
1356
- handle-as="json"
1357
- method="get"
1358
- with-credentials
1359
- @response="${this._handleContent}"
1360
- @error="${this._handleError}"></iron-ajax>
1361
- `
1362
- ]
1363
- }
1364
- }
1365
-
1366
- customElements.define('pb-view', PbView);
1
+ import { LitElement, html, css } from 'lit-element';
2
+ import anime from 'animejs';
3
+ import { pbMixin, waitOnce } from "./pb-mixin.js";
4
+ import { translate } from "./pb-i18n.js";
5
+ import { typesetMath } from "./pb-formula.js";
6
+ import { loadStylesheets, themableMixin } from "./theming.js";
7
+ import '@polymer/iron-ajax';
8
+ import '@polymer/paper-dialog';
9
+ import '@polymer/paper-dialog-scrollable';
10
+
11
+ /**
12
+ * This is the main component for viewing text which has been transformed via an ODD.
13
+ * The document to be viewed is determined by the `pb-document` element the property
14
+ * `src` points to. If not overwritten, `pb-view` will use the settings defined by
15
+ * the connected document, like view type, ODD etc.
16
+ *
17
+ * `pb-view` can display an entire document or just a fragment of it
18
+ * as defined by the properties `xpath`, `xmlId` or `nodeId`. The most common use case
19
+ * is to set `xpath` to point to a specific part of a document.
20
+ *
21
+ * Navigating to the next or previous fragment would usually be triggered by a separate
22
+ * `pb-navigation` element, which sends a `pb-navigate` event to the `pb-view`. However,
23
+ * `pb-view` also implements automatic loading of next/previous fragments if the user
24
+ * scrolls the page beyond the current viewport boudaries. To enable this, set property
25
+ * `infinite-scroll`.
26
+ *
27
+ * You may also define optional parameters to be passed to the ODD in nested `pb-param`
28
+ * tags. These parameters can be accessed within the ODD via the `$parameters` map. For
29
+ * example, the following snippet is being used to output breadcrumbs above the main text
30
+ * in the documentation view:
31
+ *
32
+ * ```xml
33
+ * <section class="breadcrumbs">
34
+ * <pb-view id="title-view1" src="document1" subscribe="transcription">
35
+ * <pb-param name="mode" value="breadcrumbs"/>
36
+ * </pb-view>
37
+ * </section>
38
+ * ```
39
+ *
40
+ * @cssprop [--pb-view-column-gap=10px] - The gap between columns in two-column mode
41
+ * @cssprop --pb-view-loader-font - Font used in the message shown during loading in infinite scroll mode
42
+ * @cssprop [--pb-view-loader-color=black] - Text color in the message shown during loading in infinite scroll mode
43
+ * @cssprop [--pb-view-loader-background-padding=10px 20px] - Background padding for the message shown during loading in infinite scroll mode
44
+ * @cssprop [--pb-view-loader-background-image=linear-gradient(to bottom, #f6a62440, #f6a524)] - Background image the message shown during loading in infinite scroll mode
45
+ * @cssprop --pb-footnote-color - Text color of footnote marker
46
+ * @cssprop --pb-footnote-padding - Padding around a footnote marker
47
+ * @cssprop --pb-footnote-font-size - Font size for the footnote marker
48
+ * @cssprop --pb-footnote-font-family - Font family for the footnote marker
49
+ * @cssprop --pb-view-scroll-margin-top - Applied to any element with an id
50
+ * @csspart content - The root div around the displayed content
51
+ * @csspart footnotes - div containing the footnotes
52
+
53
+ * @fires pb-start-update - Fired before the element updates its content
54
+ * @fires pb-update - Fired when the component received content from the server
55
+ * @fires pb-end-update - Fired after the element has finished updating its content
56
+ * @fires pb-navigate - When received, navigate forward or backward in the document
57
+ * @fires pb-zoom - When received, zoom in or out by changing font size of the content
58
+ * @fires pb-refresh - When received, refresh the content based on the parameters passed in the event
59
+ * @fires pb-toggle - When received, toggle content properties
60
+ */
61
+ export class PbView extends themableMixin(pbMixin(LitElement)) {
62
+
63
+ static get properties() {
64
+ return {
65
+ /**
66
+ * The id of a `pb-document` element this view should display.
67
+ * Settings like `odd` or `view` will be taken from the `pb-document`
68
+ * unless overwritten by properties in this component.
69
+ *
70
+ * This property is **required** and **must** point to an existing `pb-document` with
71
+ * the given id.
72
+ *
73
+ * Setting the property after initialization will clear the properties xmlId, nodeId and odd.
74
+ */
75
+ src: {
76
+ type: String
77
+ },
78
+ /**
79
+ * The ODD to use for rendering the document. Overwrites an ODD defined on
80
+ * `pb-document`. The odd should be specified by its name without path
81
+ * or the `.odd` suffix.
82
+ */
83
+ odd: {
84
+ type: String,
85
+ reflect: true
86
+ },
87
+ /**
88
+ * The view type to use for paginating the document. Either `page`, `div` or `single`.
89
+ * Overwrites the same property specified on `pb-document`. Values have the following meaning:
90
+ *
91
+ * Value | Displayed content
92
+ * ------|------------------
93
+ * `page` | content is displayed page by page as determined by tei:pb
94
+ * `div` | content is displayed by divisions
95
+ * `single` | do not paginate but display entire content at once
96
+ */
97
+ view: {
98
+ type: String,
99
+ reflect: true
100
+ },
101
+ /**
102
+ * An eXist nodeId. If specified, selects the root of the fragment of the document
103
+ * which should be displayed. Normally this property is set automatically by pagination.
104
+ */
105
+ nodeId: {
106
+ type: String,
107
+ reflect: true,
108
+ attribute: 'node-id'
109
+ },
110
+ /**
111
+ * An xml:id to be displayed. If specified, this determines the root of the fragment to be
112
+ * displayed. Use to directly navigate to a specific section.
113
+ */
114
+ xmlId: {
115
+ type: Array,
116
+ reflect: true,
117
+ attribute: 'xml-id'
118
+ },
119
+ /**
120
+ * An optional XPath expression: the root of the fragment to be processed is determined
121
+ * by evaluating the given XPath expression. The XPath expression should be absolute.
122
+ * The namespace of the document is declared as default namespace, so no prefixes should
123
+ * be used.
124
+ *
125
+ * If the `map` property is used, it may change scope for the displayed fragment.
126
+ */
127
+ xpath: {
128
+ type: String,
129
+ reflect: true
130
+ },
131
+ /**
132
+ * If defined denotes the local name of an XQuery function in `modules/map.xql`, which will be called
133
+ * with the current root node and should return the node of a mapped fragment. This is helpful if one
134
+ * wants, for example, to show a translation fragment aligned with the part of the transcription currently
135
+ * shown. In this case, the properties of the `pb-view` would still point to the transcription, but the function
136
+ * identified by map would return the corresponding fragment from the translation to be processed.
137
+ *
138
+ * Navigation in the document is still determined by the current root as defined through the `root`, `xpath`
139
+ * and `xmlId` properties.
140
+ */
141
+ map: {
142
+ type: String
143
+ },
144
+ /**
145
+ * If set to true, the component will not load automatically. Instead it will wait until it receives a `pb-update`
146
+ * event. Use this to make one `pb-view` component dependent on another one. Default is 'false'.
147
+ */
148
+ onUpdate: {
149
+ type: Boolean,
150
+ attribute: 'on-update'
151
+ },
152
+ /**
153
+ * Message to display if no content was returned by the server.
154
+ * Set to empty string to show nothing.
155
+ */
156
+ notFound: {
157
+ type: String,
158
+ attribute: 'not-found'
159
+ },
160
+ /**
161
+ * The relative URL to the script on the server which will be called for loading content.
162
+ */
163
+ url: {
164
+ type: String
165
+ },
166
+ /**
167
+ * If set, rewrite URLs to load pages as static HTML files,
168
+ * so no TEI Publisher instance is required. Use this in combination with
169
+ * [tei-publisher-static](https://github.com/eeditiones/tei-publisher-static).
170
+ * The value should point to the HTTP root path under which the static version
171
+ * will be hosted. This is used to resolve CSS stylesheets.
172
+ */
173
+ static: {
174
+ type: String
175
+ },
176
+ /**
177
+ * The server returns footnotes separately. Set this property
178
+ * if you wish to append them to the main text.
179
+ */
180
+ appendFootnotes: {
181
+ type: Boolean,
182
+ attribute: 'append-footnotes'
183
+ },
184
+ /**
185
+ * Should matches be highlighted if a search has been executed?
186
+ */
187
+ suppressHighlight: {
188
+ type: Boolean,
189
+ attribute: 'suppress-highlight',
190
+ reflect: true
191
+ },
192
+ /**
193
+ * CSS selector to find column breaks in the content returned
194
+ * from the server. If this property is set and column breaks
195
+ * are found, the component will display two columns side by side.
196
+ */
197
+ columnSeparator: {
198
+ type: String,
199
+ attribute: 'column-separator'
200
+ },
201
+ /**
202
+ * The reading direction, i.e. 'ltr' or 'rtl'.
203
+ *
204
+ * @type {"ltr"|"rtl"}
205
+ */
206
+ direction: {
207
+ type: String
208
+ },
209
+ /**
210
+ * If set, points to an external stylesheet which should be applied to
211
+ * the text *after* the ODD-generated styles.
212
+ */
213
+ loadCss: {
214
+ type: String,
215
+ attribute: 'load-css'
216
+ },
217
+ /**
218
+ * If set, relative links (img, a) will be made absolute.
219
+ */
220
+ fixLinks: {
221
+ type: Boolean,
222
+ attribute: 'fix-links'
223
+ },
224
+ /**
225
+ * If set, a refresh will be triggered if a `pb-i18n-update` event is received,
226
+ * e.g. due to the user selecting a different interface language.
227
+ *
228
+ * Also requires `requireLanguage` to be set on the surrounding `pb-page`.
229
+ * See there for more information.
230
+ */
231
+ useLanguage: {
232
+ type: Boolean,
233
+ attribute: 'use-language'
234
+ },
235
+ /**
236
+ * wether to animate the view when new page is loaded. Defaults to 'false' meaning that no
237
+ * animation takes place. If 'true' will apply a translateX transistion in forward/backward direction.
238
+ */
239
+ animation: {
240
+ type: Boolean
241
+ },
242
+ /**
243
+ * Experimental: if enabled, the view will incrementally load new document fragments if the user tries to scroll
244
+ * beyond the start or end of the visible text. The feature inserts a small blank section at the top
245
+ * and bottom. If this section becomes visible, a load operation will be triggered.
246
+ *
247
+ * Note: only browsers implementing the `IntersectionObserver` API are supported. Also the feature
248
+ * does not work in two-column mode or with animations.
249
+ */
250
+ infiniteScroll: {
251
+ type: Boolean,
252
+ attribute: 'infinite-scroll'
253
+ },
254
+ /**
255
+ * Maximum number of fragments to keep in memory if `infinite-scroll`
256
+ * is enabled. If the user is scrolling beyond the maximum, fragements
257
+ * will be removed from the DOM before or after the current reading position.
258
+ * Default is 10. Set to zero to allow loading the entire document.
259
+ */
260
+ infiniteScrollMax: {
261
+ type: Number,
262
+ attribute: 'infinite-scroll-max'
263
+ },
264
+ /**
265
+ * A selector pointing to other components this component depends on.
266
+ * When method `wait` is called, it will wait until all referenced
267
+ * components signal with a `pb-ready` event that they are ready and listening
268
+ * to events.
269
+ *
270
+ * `pb-view` by default sets this property to select `pb-toggle-feature` and `pb-select-feature`
271
+ * elements.
272
+ */
273
+ waitFor: {
274
+ type: String,
275
+ attribute: 'wait-for'
276
+ },
277
+ /**
278
+ * By default, navigating to next/previous page will update browser parameters,
279
+ * so reloading the page will load the correct position within the document.
280
+ *
281
+ * Set this property to disable location tracking for the component altogether.
282
+ */
283
+ disableHistory: {
284
+ type: Boolean,
285
+ attribute: 'disable-history'
286
+ },
287
+ /**
288
+ * If set to the name of an event, the content of the pb-view will not be replaced
289
+ * immediately upon updates. Instead, an event is emitted, which contains the new content
290
+ * in property `root`. An event handler intercepting the event can thus modify the content.
291
+ * Once it is done, it should pass the modified content to the callback function provided
292
+ * in the event detail under the name `render`. See the demo for an example.
293
+ */
294
+ beforeUpdate: {
295
+ type: String,
296
+ attribute: 'before-update-event'
297
+ },
298
+ /**
299
+ * If set, do not scroll the view to target node (e.g. given in URL hash)
300
+ * after content was loaded.
301
+ */
302
+ noScroll: {
303
+ type: Boolean,
304
+ attribute: 'no-scroll'
305
+ },
306
+ _features: {
307
+ type: Object
308
+ },
309
+ _content: {
310
+ type: Node,
311
+ attribute: false
312
+ },
313
+ _column1: {
314
+ type: Node,
315
+ attribute: false
316
+ },
317
+ _column2: {
318
+ type: Node,
319
+ attribute: false
320
+ },
321
+ _footnotes: {
322
+ type: Node,
323
+ attribute: false
324
+ },
325
+ _style: {
326
+ type: Node,
327
+ attribute: false
328
+ },
329
+ ...super.properties
330
+ };
331
+ }
332
+
333
+ constructor() {
334
+ super();
335
+ this.src = null;
336
+ this.url = null;
337
+ this.onUpdate = false;
338
+ this.appendFootnotes = false;
339
+ this.notFound = "the server did not return any content";
340
+ this.animation = false;
341
+ this.direction = 'ltr';
342
+ this.suppressHighlight = false;
343
+ this.highlight = false;
344
+ this.infiniteScrollMax = 10;
345
+ this.disableHistory = false;
346
+ this.beforeUpdate = null;
347
+ this.noScroll = false;
348
+ this._features = {};
349
+ this._selector = new Map();
350
+ this._chunks = [];
351
+ this._scrollTarget = null;
352
+ this.static = null;
353
+ }
354
+
355
+ attributeChangedCallback(name, oldVal, newVal) {
356
+ super.attributeChangedCallback(name, oldVal, newVal);
357
+ switch (name) {
358
+ case 'src':
359
+ this._updateSource(newVal, oldVal);
360
+ break;
361
+ }
362
+ }
363
+
364
+ connectedCallback() {
365
+ super.connectedCallback();
366
+
367
+ if (this.loadCss) {
368
+ waitOnce('pb-page-ready', () => {
369
+ loadStylesheets([this.toAbsoluteURL(this.loadCss)])
370
+ .then((theme) => {
371
+ this.shadowRoot.adoptedStyleSheets = [...this.shadowRoot.adoptedStyleSheets, theme];
372
+ });
373
+ });
374
+ }
375
+
376
+ if (this.infiniteScroll) {
377
+ this.columnSeparator = null;
378
+ this.animation = false;
379
+ this._content = document.createElement('div');
380
+ this._content.className = 'infinite-content';
381
+ }
382
+
383
+ if (!this.disableHistory) {
384
+ const id = this.getParameter('id');
385
+ if (id && !this.xmlId) {
386
+ this.xmlId = id;
387
+ }
388
+
389
+ const action = this.getParameter('action');
390
+ if (action && action === 'search') {
391
+ this.highlight = true;
392
+ }
393
+
394
+ const nodeId = this.getParameter('root');
395
+ if (this.view === 'single') {
396
+ this.nodeId = null;
397
+ } else if (nodeId && !this.nodeId) {
398
+ this.nodeId = nodeId;
399
+ }
400
+ }
401
+ if (!this.waitFor) {
402
+ this.waitFor = 'pb-toggle-feature,pb-select-feature,pb-navigation';
403
+ }
404
+
405
+ this.subscribeTo('pb-navigate', ev => {
406
+ if (ev.detail.source && ev.detail.source === this) {
407
+ return;
408
+ }
409
+ this.navigate(ev.detail.direction);
410
+ });
411
+ this.subscribeTo('pb-refresh', this._refresh.bind(this));
412
+ this.subscribeTo('pb-toggle', ev => {
413
+ this.toggleFeature(ev);
414
+ });
415
+ this.subscribeTo('pb-zoom', ev => {
416
+ this.zoom(ev.detail.direction);
417
+ });
418
+ this.subscribeTo('pb-i18n-update', ev => {
419
+ const needsRefresh = this._features.language && this._features.language !== ev.detail.language;
420
+ this._features.language = ev.detail.language;
421
+ if (this.useLanguage && needsRefresh) {
422
+ this._refresh();
423
+ }
424
+ }, []);
425
+
426
+ this.signalReady();
427
+
428
+ if (this.onUpdate) {
429
+ this.subscribeTo('pb-update', this._refresh.bind(this));
430
+ }
431
+ }
432
+
433
+ disconnectedCallback() {
434
+ super.disconnectedCallback();
435
+ if (this._scrollObserver) {
436
+ this._scrollObserver.disconnect();
437
+ }
438
+ }
439
+
440
+ firstUpdated() {
441
+ super.firstUpdated();
442
+ this.enableScrollbar(true);
443
+ if (this.infiniteScroll) {
444
+ this._topObserver = this.shadowRoot.getElementById('top-observer');
445
+ this._bottomObserver = this.shadowRoot.getElementById('bottom-observer');
446
+ this._bottomObserver.style.display = 'none';
447
+ this._topObserver.style.display = 'none';
448
+ this._scrollObserver = new IntersectionObserver((entries) => {
449
+ if (!this._content) {
450
+ return;
451
+ }
452
+ entries.forEach((entry) => {
453
+ if (entry.isIntersecting) {
454
+ if (entry.target.id === 'bottom-observer') {
455
+ const lastChild = this._content.lastElementChild;
456
+ if (lastChild) {
457
+ const next = lastChild.getAttribute('data-next');
458
+ if (next && !this._content.querySelector(`[data-root="${next}"]`)) {
459
+ console.log('<pb-view> Loading next page: %s', next);
460
+ this._checkChunks('forward');
461
+ this._load(next, 'forward');
462
+ }
463
+ }
464
+ } else {
465
+ const firstChild = this._content.firstElementChild;
466
+ if (firstChild) {
467
+ const previous = firstChild.getAttribute('data-previous');
468
+ if (previous && !this._content.querySelector(`[data-root="${previous}"]`)) {
469
+ this._checkChunks('backward');
470
+ this._load(previous, 'backward');
471
+ }
472
+ }
473
+ }
474
+ }
475
+ });
476
+ });
477
+ }
478
+ if (!this.onUpdate) {
479
+ PbView.waitOnce('pb-page-ready', (data) => {
480
+ if (data && data.language) {
481
+ this._features.language = data.language;
482
+ }
483
+ this.wait(() => this._refresh());
484
+ });
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Returns the ODD used to render content.
490
+ *
491
+ * @returns the ODD being used
492
+ */
493
+ getOdd() {
494
+ return this.odd || this.getDocument().odd || "teipublisher";
495
+ }
496
+
497
+ getView() {
498
+ return this.view || this.getDocument().view || "single";
499
+ }
500
+
501
+ /**
502
+ * Trigger an update of this element's content
503
+ */
504
+ forceUpdate() {
505
+ this._load(this.nodeId);
506
+
507
+ }
508
+
509
+ animate() {
510
+ // animate new element if 'animation' property is 'true'
511
+ if (this.animation) {
512
+ if (this.lastDirection === 'forward') {
513
+ anime({
514
+ targets: this.shadowRoot.getElementById('view'),
515
+ opacity: [0, 1],
516
+ translateX: [1000, 0],
517
+ duration: 300,
518
+ easing: 'linear'
519
+ });
520
+ } else {
521
+ anime({
522
+ targets: this.shadowRoot.getElementById('view'),
523
+ opacity: [0, 1],
524
+ translateX: [-1000, 0],
525
+ duration: 300,
526
+ easing: 'linear'
527
+ });
528
+ }
529
+ }
530
+ }
531
+
532
+ enableScrollbar(enable) {
533
+ if (enable) {
534
+ this.classList.add('noscroll');
535
+ } else {
536
+ this.classList.remove('noscroll');
537
+ }
538
+ }
539
+
540
+ _refresh(ev) {
541
+ if (ev && ev.detail) {
542
+ if (ev.detail.hash && !this.noScroll && !(ev.detail.id || ev.detail.path || ev.detail.odd || ev.detail.view || ev.detail.position)) {
543
+ // if only the scroll target has changed: scroll to the element without reloading
544
+ this._scrollTarget = ev.detail.hash;
545
+ const target = this.shadowRoot.getElementById(this._scrollTarget);
546
+ if (target) {
547
+ setTimeout(() => target.scrollIntoView({block: 'nearest'}));
548
+ }
549
+ return;
550
+ }
551
+ if (ev.detail.path) {
552
+ const doc = this.getDocument();
553
+ doc.path = ev.detail.path;
554
+ }
555
+ if (ev.detail.id) {
556
+ this.xmlId = ev.detail.id;
557
+ }
558
+ this.odd = ev.detail.odd || this.odd;
559
+ if (ev.detail.columnSeparator !== undefined) {
560
+ this.columnSeparator = ev.detail.columnSeparator;
561
+ }
562
+ this.view = ev.detail.view || this.view;
563
+ if (ev.detail.xpath) {
564
+ this.xpath = ev.detail.xpath;
565
+ this.nodeId = null;
566
+ }
567
+ // clear nodeId if set to null
568
+ if (ev.detail.position === null) {
569
+ this.nodeId = null;
570
+ } else {
571
+ this.nodeId = ev.detail.position || this.nodeId;
572
+ }
573
+ if (!this.noScroll) {
574
+ this._scrollTarget = ev.detail.hash;
575
+ }
576
+ }
577
+ this._updateStyles();
578
+ if (this.infiniteScroll) {
579
+ this._clear();
580
+ }
581
+ this._load(this.nodeId);
582
+ }
583
+
584
+ _load(pos, direction) {
585
+ const doc = this.getDocument();
586
+
587
+ if (!doc.path) {
588
+ console.log("No path");
589
+ return;
590
+ }
591
+
592
+ if (this._loading) {
593
+ return;
594
+ }
595
+ this._loading = true;
596
+ const params = this.getParameters(pos);
597
+ if (direction) {
598
+ params._dir = direction;
599
+ }
600
+ // this.$.view.style.opacity=0;
601
+
602
+ this._doLoad(params);
603
+ }
604
+
605
+ _doLoad(params) {
606
+ this.emitTo('pb-start-update', params);
607
+
608
+ console.log("<pb-view> Loading view with params %o", params);
609
+ if (!this.infiniteScroll) {
610
+ this._clear();
611
+ }
612
+
613
+ if (this._scrollObserver) {
614
+ if (this._bottomObserver) {
615
+ this._scrollObserver.unobserve(this._bottomObserver);
616
+ }
617
+ if (this._topObserver) {
618
+ this._scrollObserver.unobserve(this._topObserver);
619
+ }
620
+ }
621
+
622
+ const loadContent = this.shadowRoot.getElementById('loadContent');
623
+
624
+ if (this.static !== null) {
625
+ this._staticUrl(params).then((url) => {
626
+ loadContent.url = url;
627
+ loadContent.generateRequest();
628
+ });
629
+ } else {
630
+ if (!this.url) {
631
+ if (this.minApiVersion('1.0.0')) {
632
+ this.url = "api/parts";
633
+ } else {
634
+ this.url = "modules/lib/components.xql";
635
+ }
636
+ }
637
+ if (this.minApiVersion('1.0.0')) {
638
+ loadContent.url = `${this.getEndpoint()}/${this.url}/${encodeURIComponent(this.getDocument().path)}/json`;
639
+ } else {
640
+ loadContent.url = `${this.getEndpoint()}/${this.url}`;
641
+ }
642
+ loadContent.params = params;
643
+ loadContent.generateRequest();
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Use a static URL to load pre-generated content.
649
+ */
650
+ async _staticUrl(params) {
651
+ function createKey(paramNames) {
652
+ const urlComponents = [];
653
+ paramNames.sort().forEach(key => {
654
+ if (params.hasOwnProperty(key)) {
655
+ urlComponents.push(`${key}=${params[key]}`);
656
+ }
657
+ });
658
+ return urlComponents.join('&');
659
+ }
660
+
661
+ const index = await fetch(`index.json`)
662
+ .then((response) => response.json());
663
+ const paramNames = ['odd', 'view', 'xpath', 'map'];
664
+ this.querySelectorAll('pb-param').forEach((param) => paramNames.push(`user.${param.getAttribute('name')}`));
665
+ let url = params.id ? createKey([...paramNames, 'id']) : createKey([...paramNames, 'root']);
666
+ let file = index[url];
667
+ if (!file) {
668
+ url = createKey(paramNames);
669
+ file = index[url];
670
+ }
671
+
672
+ console.log('<pb-view> Static lookup %s: %s', url, file);
673
+ return `${file}`;
674
+ }
675
+
676
+ _clear() {
677
+ if (this.infiniteScroll) {
678
+ this._content = document.createElement('div');
679
+ this._content.className = 'infinite-content';
680
+ } else {
681
+ this._content = null;
682
+ }
683
+ this._column1 = null;
684
+ this._column2 = null;
685
+ this._footnotes = null;
686
+ this._chunks = [];
687
+ }
688
+
689
+ _handleError() {
690
+ this._clear();
691
+ const loader = this.shadowRoot.getElementById('loadContent');
692
+ let message;
693
+ const { response } = loader.lastError;
694
+
695
+ if (response) {
696
+ message = response.description;
697
+ } else {
698
+ message = '<pb-i18n key="dialogs.serverError"></pb-i18n>';
699
+ }
700
+
701
+ const content = `
702
+ <p>${this.notFound}</p>
703
+ <p><pb-i18n key="dialogs.serverError"></pb-i18n>: ${message} </p>
704
+ `;
705
+
706
+ this._replaceContent({ content });
707
+ this.emitTo('pb-end-update');
708
+
709
+ }
710
+
711
+ _handleContent() {
712
+ const loader = this.shadowRoot.getElementById('loadContent');
713
+ const resp = loader.lastResponse;
714
+
715
+ if (!resp) {
716
+ console.error('<pb-view> No response received');
717
+ return;
718
+ }
719
+ if (resp.error) {
720
+ if (this.notFound) {
721
+ this._content = this.notFound;
722
+ }
723
+ this.emitTo('pb-end-update', null);
724
+ return;
725
+ }
726
+
727
+ this._replaceContent(resp, loader.params._dir);
728
+
729
+ this.animate();
730
+
731
+ if (this._scrollTarget) {
732
+ this.updateComplete.then(() => {
733
+ const target = this.shadowRoot.getElementById(this._scrollTarget) ||
734
+ this.shadowRoot.querySelector(`[node-id="${this._scrollTarget}"]`);
735
+ if (target) {
736
+ window.requestAnimationFrame(() =>
737
+ setTimeout(() => {
738
+ target.scrollIntoView({block: 'nearest'});
739
+ }, 400)
740
+ );
741
+ }
742
+ this._scrollTarget = null;
743
+ });
744
+ }
745
+
746
+ this.next = resp.next;
747
+ this.nextId = resp.nextId;
748
+ this.previous = resp.previous;
749
+ this.previousId = resp.previousId;
750
+ this.nodeId = resp.root;
751
+ this.switchView = resp.switchView;
752
+ if (!this.disableHistory && this.xmlId && !this.map) {
753
+ //this.setParameter('root', this.nodeId);
754
+ this.setParameter('id', this.xmlId);
755
+ this.pushHistory('Navigate to xml:id');
756
+ }
757
+ this.xmlId = null;
758
+
759
+ this.updateComplete.then(() => {
760
+ const view = this.shadowRoot.getElementById('view');
761
+ this._applyToggles(view);
762
+ this._fixLinks(view);
763
+ typesetMath(view);
764
+
765
+ const eventOptions = {
766
+ data: resp,
767
+ root: view,
768
+ params: loader.params,
769
+ id: this.xmlId,
770
+ position: this.nodeId
771
+ };
772
+ this.emitTo('pb-update', eventOptions);
773
+ this._scroll();
774
+ });
775
+
776
+ this.emitTo('pb-end-update', null);
777
+ }
778
+
779
+ _replaceContent(resp, direction) {
780
+ const fragment = document.createDocumentFragment();
781
+ const elem = document.createElement('div');
782
+ // elem.style.opacity = 0; //hide it - animation has to make sure to blend it in
783
+ fragment.appendChild(elem);
784
+ elem.innerHTML = resp.content;
785
+
786
+ // if before-update-event is set, we do not replace the content immediately,
787
+ // but emit an event
788
+ if (this.beforeUpdate) {
789
+ this.emitTo(this.beforeUpdate, {
790
+ data: resp,
791
+ root: elem,
792
+ render: (content) => {
793
+ this._doReplaceContent(content, resp, direction);
794
+ }
795
+ });
796
+ } else {
797
+ this._doReplaceContent(elem, resp, direction);
798
+ }
799
+ }
800
+
801
+ _doReplaceContent(elem, resp, direction) {
802
+ if (this.columnSeparator) {
803
+ this._replaceColumns(elem);
804
+ this._loading = false;
805
+ } else if (this.infiniteScroll) {
806
+ elem.className = 'scroll-fragment';
807
+ elem.setAttribute('data-root', resp.root);
808
+ if (resp.next) {
809
+ elem.setAttribute('data-next', resp.next);
810
+ }
811
+ if (resp.previous) {
812
+ elem.setAttribute('data-previous', resp.previous);
813
+ }
814
+ let refNode;
815
+ switch (direction) {
816
+ case 'backward':
817
+ refNode = this._content.firstElementChild;
818
+ this._chunks.unshift(elem);
819
+ this.updateComplete.then(() => {
820
+ refNode.scrollIntoView(true);
821
+ this._loading = false;
822
+ this._checkVisibility();
823
+ this._scrollObserver.observe(this._bottomObserver);
824
+ this._scrollObserver.observe(this._topObserver);
825
+ });
826
+ this._content.insertBefore(elem, refNode);
827
+ break;
828
+ default:
829
+ this.updateComplete.then(() => {
830
+ this._loading = false;
831
+ this._checkVisibility();
832
+ this._scrollObserver.observe(this._bottomObserver);
833
+ this._scrollObserver.observe(this._topObserver);
834
+ });
835
+ this._chunks.push(elem);
836
+ this._content.appendChild(elem);
837
+ break;
838
+ }
839
+ } else {
840
+ this._content = elem;
841
+ this._loading = false;
842
+ }
843
+
844
+ if (this.appendFootnotes) {
845
+ const footnotes = document.createElement('div');
846
+ if (resp.footnotes) {
847
+ footnotes.innerHTML = resp.footnotes;
848
+ }
849
+ this._footnotes = footnotes;
850
+ }
851
+
852
+ this._initFootnotes(this._footnotes);
853
+
854
+ return elem;
855
+ }
856
+
857
+ _checkVisibility() {
858
+ const bottomActive = this._chunks[this._chunks.length - 1].hasAttribute('data-next');
859
+ this._bottomObserver.style.display = bottomActive ? '' : 'none';
860
+
861
+ const topActive = this._chunks[0].hasAttribute('data-previous');
862
+ this._topObserver.style.display = topActive ? '' : 'none';
863
+ }
864
+
865
+ _replaceColumns(elem) {
866
+ let cb;
867
+ if (this.columnSeparator) {
868
+ const cbs = elem.querySelectorAll(this.columnSeparator);
869
+ // use last separator only
870
+ if (cbs.length > 1) {
871
+ cb = cbs[cbs.length - 1];
872
+ }
873
+ }
874
+
875
+ if (!cb) {
876
+ this._content = elem;
877
+ } else {
878
+ const fragmentBefore = this._getFragmentBefore(elem, cb);
879
+ const fragmentAfter = this._getFragmentAfter(elem, cb);
880
+ if (this.direction === 'ltr') {
881
+ this._column1 = fragmentBefore;
882
+ this._column2 = fragmentAfter;
883
+ } else {
884
+ this._column2 = fragmentBefore;
885
+ this._column1 = fragmentAfter;
886
+ }
887
+ }
888
+ }
889
+
890
+ _scroll() {
891
+ if (this.noScroll) {
892
+ return;
893
+ }
894
+ const { hash } = this.getUrl();
895
+ if (hash) {
896
+ const target = this.shadowRoot.getElementById(hash.substring(1));
897
+ console.log('hash target: %o', target);
898
+ if (target) {
899
+ window.requestAnimationFrame(() =>
900
+ setTimeout(() => {
901
+ target.scrollIntoView({block: 'nearest'});
902
+ }, 400)
903
+ );
904
+ }
905
+ }
906
+ }
907
+
908
+ _scrollToElement(ev, link) {
909
+ const target = this.shadowRoot.getElementById(link.hash.substring(1));
910
+ if (target) {
911
+ ev.preventDefault();
912
+ console.log('<pb-view> Scrolling to element %s', target.id);
913
+ target.scrollIntoView({ block: "center", inline: "nearest" });
914
+ }
915
+ }
916
+
917
+ _updateStyles() {
918
+ let link = document.createElement('link');
919
+ link.setAttribute('rel', 'stylesheet');
920
+ link.setAttribute('type', 'text/css');
921
+ if (this.static !== null) {
922
+ link.setAttribute('href', `${this.static}/css/${this.getOdd()}.css`);
923
+ } else {
924
+ link.setAttribute('href', `${this.getEndpoint()}/transform/${this.getOdd()}.css`);
925
+ }
926
+ this._style = link;
927
+ }
928
+
929
+ _fixLinks(content) {
930
+ if (this.fixLinks) {
931
+ const doc = this.getDocument();
932
+ const base = this.toAbsoluteURL(doc.path);
933
+ content.querySelectorAll('img').forEach((image) => {
934
+ const oldSrc = image.getAttribute('src');
935
+ const src = new URL(oldSrc, base);
936
+ image.src = src;
937
+ });
938
+ content.querySelectorAll('a').forEach((link) => {
939
+ const oldHref = link.getAttribute('href');
940
+ if (oldHref === link.hash) {
941
+ link.addEventListener('click', (ev) => this._scrollToElement(ev, link));
942
+ } else {
943
+ const href = new URL(oldHref, base);
944
+ link.href = href;
945
+ }
946
+ });
947
+ } else {
948
+ content.querySelectorAll('a').forEach((link) => {
949
+ const oldHref = link.getAttribute('href');
950
+ if (oldHref === link.hash) {
951
+ link.addEventListener('click', (ev) => this._scrollToElement(ev, link));
952
+ }
953
+ });
954
+ }
955
+ }
956
+
957
+ _initFootnotes(content) {
958
+ if (content) {
959
+ content.querySelectorAll('.note, .fn-back').forEach(elem => {
960
+ elem.addEventListener('click', (ev) => {
961
+ ev.preventDefault();
962
+ const fn = this.shadowRoot.getElementById('content').querySelector(elem.hash);
963
+ if (fn) {
964
+ fn.scrollIntoView();
965
+ }
966
+ });
967
+ });
968
+ }
969
+ }
970
+
971
+ _getParameters() {
972
+ const params = [];
973
+ this.querySelectorAll('pb-param').forEach(function (param) {
974
+ params['user.' + param.getAttribute('name')] = param.getAttribute('value');
975
+ });
976
+ // add parameters for features set with pb-toggle-feature
977
+ for (const [key, value] of Object.entries(this._features)) {
978
+ params['user.' + key] = value;
979
+ }
980
+ return params;
981
+ }
982
+
983
+ /**
984
+ * Return the parameter object which would be passed to the server by this component
985
+ */
986
+ getParameters(pos) {
987
+ pos = pos || this.nodeId;
988
+ const doc = this.getDocument();
989
+ const params = this._getParameters();
990
+ if (!this.minApiVersion('1.0.0')) {
991
+ params.doc = doc.path;
992
+ }
993
+ params.odd = this.getOdd() + '.odd';
994
+ params.view = this.getView();
995
+ if (pos) {
996
+ params['root'] = pos;
997
+ }
998
+ if (this.xpath) {
999
+ params.xpath = this.xpath;
1000
+ }
1001
+ if (this.xmlId) {
1002
+ params.id = this.xmlId;
1003
+ }
1004
+ if (!this.suppressHighlight && this.highlight) {
1005
+ params.highlight = "yes";
1006
+ }
1007
+ if (this.map) {
1008
+ params.map = this.map;
1009
+ }
1010
+
1011
+ return params;
1012
+ }
1013
+
1014
+ _applyToggles(elem) {
1015
+ if (this._selector.size === 0) {
1016
+ return;
1017
+ }
1018
+ this._selector.forEach((setting, selector) => {
1019
+ elem.querySelectorAll(selector).forEach(node => {
1020
+ const command = setting.command || 'toggle';
1021
+ if (node.command) {
1022
+ node.command(command, setting.state);
1023
+ }
1024
+ if (setting.state) {
1025
+ node.classList.add(command);
1026
+ } else {
1027
+ node.classList.remove(command);
1028
+ }
1029
+ });
1030
+ });
1031
+ }
1032
+
1033
+ /**
1034
+ * Load a part of the document identified by the given eXist nodeId
1035
+ *
1036
+ * @param {String} nodeId The eXist nodeId of the root element to load
1037
+ */
1038
+ goto(nodeId) {
1039
+ this._load(nodeId);
1040
+ }
1041
+
1042
+ /**
1043
+ * Load a part of the document identified by the given xml:id
1044
+ *
1045
+ * @param {String} xmlId The xml:id to be loaded
1046
+ */
1047
+ gotoId(xmlId) {
1048
+ this.xmlId = xmlId;
1049
+ this._load();
1050
+ }
1051
+
1052
+ /**
1053
+ * Navigate the document either forward or backward and refresh the view.
1054
+ * The navigation method is determined by property `view`.
1055
+ *
1056
+ * @param {string} direction either `backward` or `forward`
1057
+ */
1058
+ navigate(direction) {
1059
+ this.lastDirection = direction;
1060
+
1061
+ if (direction === 'backward') {
1062
+ if (this.previous) {
1063
+ if (!this.disableHistory && !this.map) {
1064
+ if (this.previousId) {
1065
+ this.setParameter('id', this.previousId);
1066
+ } else {
1067
+ this.setParameter('root', this.previous);
1068
+ }
1069
+ this.pushHistory('Navigate backward');
1070
+ }
1071
+ this._load(this.previous, direction);
1072
+ }
1073
+ } else if (this.next) {
1074
+ if (!this.disableHistory && !this.map) {
1075
+ if (this.nextId) {
1076
+ this.setParameter('id', this.nextId);
1077
+ } else {
1078
+ this.setParameter('root', this.next);
1079
+ }
1080
+ this.pushHistory('Navigate forward');
1081
+ }
1082
+ this._load(this.next, direction);
1083
+ }
1084
+ }
1085
+
1086
+ /**
1087
+ * Check the number of fragments which were already loaded in infinite
1088
+ * scroll mode. If they exceed `infiniteScrollMax`, remove either the
1089
+ * first or last fragment from the DOM, depending on the scroll direction.
1090
+ *
1091
+ * @param {string} direction either 'forward' or 'backward'
1092
+ */
1093
+ _checkChunks(direction) {
1094
+ if (!this.infiniteScroll || this.infiniteScrollMax === 0) {
1095
+ return;
1096
+ }
1097
+
1098
+ if (this._chunks.length === this.infiniteScrollMax) {
1099
+ switch (direction) {
1100
+ case 'forward':
1101
+ this._content.removeChild(this._chunks.shift());
1102
+ break;
1103
+ default:
1104
+ this._content.removeChild(this._chunks.pop());
1105
+ break;
1106
+ }
1107
+ }
1108
+ this.emitTo('pb-navigate', {
1109
+ direction,
1110
+ source: this
1111
+ });
1112
+ }
1113
+
1114
+ /**
1115
+ * Zoom the displayed content by increasing or decreasing font size.
1116
+ *
1117
+ * @param {string} direction either `in` or `out`
1118
+ */
1119
+ zoom(direction) {
1120
+ const view = this.shadowRoot.getElementById('view');
1121
+ const fontSize = window.getComputedStyle(view).getPropertyValue('font-size');
1122
+ const size = parseInt(fontSize.replace(/^(\d+)px/, "$1"));
1123
+
1124
+ if (direction === 'in') {
1125
+ view.style.fontSize = (size + 1) + 'px';
1126
+ } else {
1127
+ view.style.fontSize = (size - 1) + 'px';
1128
+ }
1129
+ }
1130
+
1131
+ toggleFeature(ev) {
1132
+ const applyToggles = () => {
1133
+ const view = this.shadowRoot.getElementById('view');
1134
+ this._applyToggles(view);
1135
+ }
1136
+
1137
+ const properties = ev.detail.properties;
1138
+ for (const [key, value] of Object.entries(properties)) {
1139
+ switch (key) {
1140
+ case 'odd':
1141
+ case 'view':
1142
+ case 'columnSeparator':
1143
+ case 'xpath':
1144
+ case 'nodeId':
1145
+ break;
1146
+ default:
1147
+ this._features[key] = value;
1148
+ break;
1149
+ }
1150
+ }
1151
+ if (properties) {
1152
+ if (properties.odd) {
1153
+ this.odd = properties.odd;
1154
+ }
1155
+ if (properties.view) {
1156
+ this.view = properties.view;
1157
+ if (this.view === 'single') {
1158
+ // when switching to single view, clear current node id
1159
+ this.nodeId = null;
1160
+ } else {
1161
+ // otherwise use value for alternate view returned from server
1162
+ this.nodeId = this.switchView;
1163
+ }
1164
+ }
1165
+ if (properties.xpath) {
1166
+ this.xpath = properties.xpath;
1167
+ }
1168
+ if (properties.hasOwnProperty('columnSeparator')) {
1169
+ this.columnSeparator = properties.columnSeparator;
1170
+ }
1171
+ }
1172
+ if (ev.detail.selectors) {
1173
+ ev.detail.selectors.forEach(sc => {
1174
+ this._selector.set(sc.selector, {
1175
+ state: sc.state,
1176
+ command: sc.command || 'toggle'
1177
+ });
1178
+ });
1179
+ }
1180
+ if (ev.detail.action === 'refresh') {
1181
+ if (Object.keys(properties).length > 0) {
1182
+ this._updateStyles();
1183
+ this._load();
1184
+ } else {
1185
+ applyToggles();
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ _getFragmentBefore(node, ms) {
1191
+ const range = document.createRange();
1192
+ range.setStartBefore(node);
1193
+ range.setEndBefore(ms);
1194
+
1195
+ return range.cloneContents();
1196
+ }
1197
+
1198
+ _getFragmentAfter(node, ms) {
1199
+ const range = document.createRange();
1200
+ range.setStartBefore(ms);
1201
+ range.setEndAfter(node);
1202
+
1203
+ return range.cloneContents();
1204
+ }
1205
+
1206
+ _updateSource(newVal, oldVal) {
1207
+ if (typeof oldVal !== 'undefined' && newVal !== oldVal) {
1208
+ this.xpath = null;
1209
+ this.odd = null;
1210
+ this.xmlId = null;
1211
+ this.nodeId = null;
1212
+ }
1213
+ }
1214
+
1215
+ static get styles() {
1216
+ return css`
1217
+ :host {
1218
+ display: block;
1219
+ background: transparent;
1220
+ }
1221
+
1222
+ :host(.noscroll) {
1223
+ scrollbar-width: none; /* Firefox 64 */
1224
+ -ms-overflow-style: none;
1225
+ }
1226
+
1227
+ :host(.noscroll)::-webkit-scrollbar {
1228
+ width: 0 !important;
1229
+ display: none;
1230
+ }
1231
+
1232
+ [id] {
1233
+ scroll-margin-top: var(--pb-view-scroll-margin-top);
1234
+ }
1235
+
1236
+ #view {
1237
+ position: relative;
1238
+ }
1239
+
1240
+ .columns {
1241
+ display: grid;
1242
+ grid-template-columns: calc(50% - var(--pb-view-column-gap, 10px) / 2) calc(50% - var(--pb-view-column-gap, 10px) / 2);
1243
+ grid-column-gap: var(--pb-view-column-gap, 10px);
1244
+ }
1245
+
1246
+ .margin-note {
1247
+ display: none;
1248
+ }
1249
+
1250
+ @media (min-width: 769px) {
1251
+ .content.margin-right {
1252
+ margin-right: 200px;
1253
+ }
1254
+
1255
+ .margin-note {
1256
+ background: rgba(153, 153, 153, 0.2);
1257
+ display: block;
1258
+ font-size: small;
1259
+ margin-right: -200px;
1260
+ margin-bottom: 5px;
1261
+ padding: 5px 0;
1262
+ float: right;
1263
+ clear: both;
1264
+ width: 180px;
1265
+ }
1266
+
1267
+ .margin-note .n {
1268
+ color: #777777;
1269
+ }
1270
+ }
1271
+
1272
+ a[rel=footnote] {
1273
+ font-size: var(--pb-footnote-font-size, var(--pb-content-font-size, 75%));
1274
+ font-family: var(--pb-footnote-font-family, --pb-content-font-family);
1275
+ vertical-align: super;
1276
+ color: var(--pb-footnote-color, var(--pb-color-primary, #333333));
1277
+ text-decoration: none;
1278
+ padding: var(--pb-footnote-padding, 0 0 0 .25em);
1279
+ }
1280
+
1281
+ .list dt {
1282
+ float: left;
1283
+ }
1284
+
1285
+ .footnote .fn-number {
1286
+ float: left;
1287
+ font-size: var(--pb-footnote-font-size, var(--pb-content-font-size, 75%));
1288
+ }
1289
+
1290
+ .observer {
1291
+ display: block;
1292
+ width: 100%;
1293
+ height: var(--pb-view-loader-height, 16px);
1294
+ font-family: var(--pb-view-loader-font, --pb-base-font);
1295
+ color: var(--pb-view-loader-color, black);
1296
+ background: var(--pb-view-loader-background, #909090);
1297
+ background-image: var(--pb-view-loader-background-image, repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,.5) 35px, rgba(255,255,255,.5) 70px));
1298
+ animation-name: loader;
1299
+ animation-timing-function: linear;
1300
+ animation-duration: 2s;
1301
+ animation-fill-mode: forwards;
1302
+ animation-iteration-count: infinite;
1303
+ }
1304
+
1305
+ @keyframes loader {
1306
+ 0% {
1307
+ background-position: 3rem 0;
1308
+ }
1309
+
1310
+ 100% {
1311
+ background-position: 0 0;
1312
+ }
1313
+ }
1314
+
1315
+ .scroll-fragment {
1316
+ animation: fadeIn ease 500ms;
1317
+ }
1318
+
1319
+ @keyframes fadeIn {
1320
+ 0% {opacity:0;}
1321
+ 100% {opacity:1;}
1322
+ }
1323
+ `;
1324
+ }
1325
+
1326
+ render() {
1327
+ return [
1328
+ html`
1329
+ <div id="view" part="content">
1330
+ ${this._style}
1331
+ ${this.infiniteScroll ? html`<div id="top-observer" class="observer"></div>` : null}
1332
+ <div class="columns">
1333
+ <div id="column1">${this._column1}</div>
1334
+ <div id="column2">${this._column2}</div>
1335
+ </div>
1336
+ <div id="content">${this._content}</div>
1337
+ ${
1338
+ this.infiniteScroll ?
1339
+ html`<div id="bottom-observer" class="observer"></div>` :
1340
+ null
1341
+ }
1342
+ <div id="footnotes" part="footnotes">${this._footnotes}</div>
1343
+ </div>
1344
+ <paper-dialog id="errorDialog">
1345
+ <h2>${translate('dialogs.error')}</h2>
1346
+ <paper-dialog-scrollable></paper-dialog-scrollable>
1347
+ <div class="buttons">
1348
+ <paper-button dialog-confirm="dialog-confirm" autofocus="autofocus">
1349
+ ${translate('dialogs.close')}
1350
+ </paper-button>
1351
+ </div>
1352
+ </paper-dialog>
1353
+ <iron-ajax
1354
+ id="loadContent"
1355
+ verbose
1356
+ handle-as="json"
1357
+ method="get"
1358
+ with-credentials
1359
+ @response="${this._handleContent}"
1360
+ @error="${this._handleError}"></iron-ajax>
1361
+ `
1362
+ ]
1363
+ }
1364
+ }
1365
+
1366
+ customElements.define('pb-view', PbView);