UNPKG

@teipublisher/pb-components

Version:
1,397 lines (1,281 loc) 50.7 kB
import { LitElement, html, css } from 'lit-element'; import anime from 'animejs'; import { pbMixin, waitOnce } from "./pb-mixin.js"; import { registry } from "./urls.js"; import { translate } from "./pb-i18n.js"; import { typesetMath } from "./pb-formula.js"; import { loadStylesheets, themableMixin } from "./theming.js"; import '@polymer/iron-ajax'; import '@polymer/paper-dialog'; import '@polymer/paper-dialog-scrollable'; /** * This is the main component for viewing text which has been transformed via an ODD. * The document to be viewed is determined by the `pb-document` element the property * `src` points to. If not overwritten, `pb-view` will use the settings defined by * the connected document, like view type, ODD etc. * * `pb-view` can display an entire document or just a fragment of it * as defined by the properties `xpath`, `xmlId` or `nodeId`. The most common use case * is to set `xpath` to point to a specific part of a document. * * Navigating to the next or previous fragment would usually be triggered by a separate * `pb-navigation` element, which sends a `pb-navigate` event to the `pb-view`. However, * `pb-view` also implements automatic loading of next/previous fragments if the user * scrolls the page beyond the current viewport boudaries. To enable this, set property * `infinite-scroll`. * * You may also define optional parameters to be passed to the ODD in nested `pb-param` * tags. These parameters can be accessed within the ODD via the `$parameters` map. For * example, the following snippet is being used to output breadcrumbs above the main text * in the documentation view: * * ```xml * <section class="breadcrumbs"> * <pb-view id="title-view1" src="document1" subscribe="transcription"> * <pb-param name="mode" value="breadcrumbs"/> * </pb-view> * </section> * ``` * * @cssprop [--pb-view-column-gap=10px] - The gap between columns in two-column mode * @cssprop --pb-view-loader-font - Font used in the message shown during loading in infinite scroll mode * @cssprop [--pb-view-loader-color=black] - Text color in the message shown during loading in infinite scroll mode * @cssprop [--pb-view-loader-background-padding=10px 20px] - Background padding for the message shown during loading in infinite scroll mode * @cssprop [--pb-view-loader-background-image=linear-gradient(to bottom, #f6a62440, #f6a524)] - Background image the message shown during loading in infinite scroll mode * @cssprop --pb-footnote-color - Text color of footnote marker * @cssprop --pb-footnote-padding - Padding around a footnote marker * @cssprop --pb-footnote-font-size - Font size for the footnote marker * @cssprop --pb-footnote-font-family - Font family for the footnote marker * @cssprop --pb-view-scroll-margin-top - Applied to any element with an id * @csspart content - The root div around the displayed content * @csspart footnotes - div containing the footnotes * @fires pb-start-update - Fired before the element updates its content * @fires pb-update - Fired when the component received content from the server * @fires pb-end-update - Fired after the element has finished updating its content * @fires pb-navigate - When received, navigate forward or backward in the document * @fires pb-zoom - When received, zoom in or out by changing font size of the content * @fires pb-refresh - When received, refresh the content based on the parameters passed in the event * @fires pb-toggle - When received, toggle content properties */ export class PbView extends themableMixin(pbMixin(LitElement)) { static get properties() { return { /** * The id of a `pb-document` element this view should display. * Settings like `odd` or `view` will be taken from the `pb-document` * unless overwritten by properties in this component. * * This property is **required** and **must** point to an existing `pb-document` with * the given id. * * Setting the property after initialization will clear the properties xmlId, nodeId and odd. */ src: { type: String }, /** * The ODD to use for rendering the document. Overwrites an ODD defined on * `pb-document`. The odd should be specified by its name without path * or the `.odd` suffix. */ odd: { type: String }, /** * The view type to use for paginating the document. Either `page`, `div` or `single`. * Overwrites the same property specified on `pb-document`. Values have the following meaning: * * Value | Displayed content * ------|------------------ * `page` | content is displayed page by page as determined by tei:pb * `div` | content is displayed by divisions * `single` | do not paginate but display entire content at once */ view: { type: String }, /** * An eXist nodeId. If specified, selects the root of the fragment of the document * which should be displayed. Normally this property is set automatically by pagination. */ nodeId: { type: String, attribute: 'node-id' }, /** * An xml:id to be displayed. If specified, this determines the root of the fragment to be * displayed. Use to directly navigate to a specific section. */ xmlId: { type: Array, attribute: 'xml-id' }, /** * An optional XPath expression: the root of the fragment to be processed is determined * by evaluating the given XPath expression. The XPath expression should be absolute. * The namespace of the document is declared as default namespace, so no prefixes should * be used. * * If the `map` property is used, it may change scope for the displayed fragment. */ xpath: { type: String }, /** * If defined denotes the local name of an XQuery function in `modules/map.xql`, which will be called * with the current root node and should return the node of a mapped fragment. This is helpful if one * wants, for example, to show a translation fragment aligned with the part of the transcription currently * shown. In this case, the properties of the `pb-view` would still point to the transcription, but the function * identified by map would return the corresponding fragment from the translation to be processed. * * Navigation in the document is still determined by the current root as defined through the `root`, `xpath` * and `xmlId` properties. */ map: { type: String }, /** * If set to true, the component will not load automatically. Instead it will wait until it receives a `pb-update` * event. Use this to make one `pb-view` component dependent on another one. Default is 'false'. */ onUpdate: { type: Boolean, attribute: 'on-update' }, /** * Message to display if no content was returned by the server. * Set to empty string to show nothing. */ notFound: { type: String, attribute: 'not-found' }, /** * The relative URL to the script on the server which will be called for loading content. */ url: { type: String }, /** * If set, rewrite URLs to load pages as static HTML files, * so no TEI Publisher instance is required. Use this in combination with * [tei-publisher-static](https://github.com/eeditiones/tei-publisher-static). * The value should point to the HTTP root path under which the static version * will be hosted. This is used to resolve CSS stylesheets. */ static: { type: String }, /** * The server returns footnotes separately. Set this property * if you wish to append them to the main text. */ appendFootnotes: { type: Boolean, attribute: 'append-footnotes' }, /** * Should matches be highlighted if a search has been executed? */ suppressHighlight: { type: Boolean, attribute: 'suppress-highlight' }, /** * CSS selector to find column breaks in the content returned * from the server. If this property is set and column breaks * are found, the component will display two columns side by side. */ columnSeparator: { type: String, attribute: 'column-separator' }, /** * The reading direction, i.e. 'ltr' or 'rtl'. * * @type {"ltr"|"rtl"} */ direction: { type: String }, /** * If set, points to an external stylesheet which should be applied to * the text *after* the ODD-generated styles. */ loadCss: { type: String, attribute: 'load-css' }, /** * If set, relative links (img, a) will be made absolute. */ fixLinks: { type: Boolean, attribute: 'fix-links' }, /** * If set, a refresh will be triggered if a `pb-i18n-update` event is received, * e.g. due to the user selecting a different interface language. * * Also requires `requireLanguage` to be set on the surrounding `pb-page`. * See there for more information. */ useLanguage: { type: Boolean, attribute: 'use-language' }, /** * wether to animate the view when new page is loaded. Defaults to 'false' meaning that no * animation takes place. If 'true' will apply a translateX transistion in forward/backward direction. */ animation: { type: Boolean }, /** * Experimental: if enabled, the view will incrementally load new document fragments if the user tries to scroll * beyond the start or end of the visible text. The feature inserts a small blank section at the top * and bottom. If this section becomes visible, a load operation will be triggered. * * Note: only browsers implementing the `IntersectionObserver` API are supported. Also the feature * does not work in two-column mode or with animations. */ infiniteScroll: { type: Boolean, attribute: 'infinite-scroll' }, /** * Maximum number of fragments to keep in memory if `infinite-scroll` * is enabled. If the user is scrolling beyond the maximum, fragements * will be removed from the DOM before or after the current reading position. * Default is 10. Set to zero to allow loading the entire document. */ infiniteScrollMax: { type: Number, attribute: 'infinite-scroll-max' }, /** * A selector pointing to other components this component depends on. * When method `wait` is called, it will wait until all referenced * components signal with a `pb-ready` event that they are ready and listening * to events. * * `pb-view` by default sets this property to select `pb-toggle-feature` and `pb-select-feature` * elements. */ waitFor: { type: String, attribute: 'wait-for' }, /** * By default, navigating to next/previous page will update browser parameters, * so reloading the page will load the correct position within the document. * * Set this property to disable location tracking for the component altogether. */ disableHistory: { type: Boolean, attribute: 'disable-history' }, /** * If set to the name of an event, the content of the pb-view will not be replaced * immediately upon updates. Instead, an event is emitted, which contains the new content * in property `root`. An event handler intercepting the event can thus modify the content. * Once it is done, it should pass the modified content to the callback function provided * in the event detail under the name `render`. See the demo for an example. */ beforeUpdate: { type: String, attribute: 'before-update-event' }, /** * If set, do not scroll the view to target node (e.g. given in URL hash) * after content was loaded. */ noScroll: { type: Boolean, attribute: 'no-scroll' }, _features: { type: Object }, _content: { type: Node, attribute: false }, _column1: { type: Node, attribute: false }, _column2: { type: Node, attribute: false }, _footnotes: { type: Node, attribute: false }, _style: { type: Node, attribute: false }, _additionalParams: { type: Object }, ...super.properties }; } constructor() { super(); this.src = null; this.url = null; this.onUpdate = false; this.appendFootnotes = false; this.notFound = null; this.animation = false; this.direction = 'ltr'; this.suppressHighlight = false; this.highlight = false; this.infiniteScrollMax = 10; this.disableHistory = false; this.beforeUpdate = null; this.noScroll = false; this._features = {}; this._additionalParams = {}; this._selector = {}; this._chunks = []; this._scrollTarget = null; this.static = null; } attributeChangedCallback(name, oldVal, newVal) { super.attributeChangedCallback(name, oldVal, newVal); switch (name) { case 'src': this._updateSource(newVal, oldVal); break; } } connectedCallback() { super.connectedCallback(); if (this.loadCss) { waitOnce('pb-page-ready', () => { loadStylesheets([this.toAbsoluteURL(this.loadCss)]) .then((theme) => { this.shadowRoot.adoptedStyleSheets = [...this.shadowRoot.adoptedStyleSheets, theme]; }); }); } if (this.infiniteScroll) { this.columnSeparator = null; this.animation = false; this._content = document.createElement('div'); this._content.className = 'infinite-content'; } if (!this.disableHistory) { if (registry.state.id && !this.xmlId) { this.xmlId = registry.state.id; } if (registry.state.action && registry.state.action === 'search') { this.highlight = true; } if (this.view === 'single') { this.nodeId = null; } else if (registry.state.root && !this.nodeId) { this.nodeId = registry.state.root; } const newState = { id: this.xmlId, view: this.getView(), odd: this.getOdd(), path: this.getDocument().path }; if (this.view !== 'single') { newState.root = this.nodeId; } console.log('id: %s; state: %o', this.id, newState); registry.replace(this, newState); registry.subscribe(this, (state) => { this._setState(state); this._refresh(); }); } if (!this.waitFor) { this.waitFor = 'pb-toggle-feature,pb-select-feature,pb-navigation'; } this.subscribeTo('pb-navigate', ev => { if (ev.detail.source && ev.detail.source === this) { return; } this.navigate(ev.detail.direction); }); this.subscribeTo('pb-refresh', this._refresh.bind(this)); this.subscribeTo('pb-toggle', ev => { this.toggleFeature(ev); }); this.subscribeTo('pb-zoom', ev => { this.zoom(ev.detail.direction); }); this.subscribeTo('pb-i18n-update', ev => { const needsRefresh = this._features.language && this._features.language !== ev.detail.language; this._features.language = ev.detail.language; if (this.useLanguage && needsRefresh) { this._setState(registry.getState(this)); this._refresh(); } }, []); this.signalReady(); if (this.onUpdate) { this.subscribeTo('pb-update', (ev) => { this._refresh(ev); }); } } disconnectedCallback() { super.disconnectedCallback(); if (this._scrollObserver) { this._scrollObserver.disconnect(); } } firstUpdated() { super.firstUpdated(); this.enableScrollbar(true); if (this.infiniteScroll) { this._topObserver = this.shadowRoot.getElementById('top-observer'); this._bottomObserver = this.shadowRoot.getElementById('bottom-observer'); this._bottomObserver.style.display = 'none'; this._topObserver.style.display = 'none'; this._scrollObserver = new IntersectionObserver((entries) => { if (!this._content) { return; } entries.forEach((entry) => { if (entry.isIntersecting) { if (entry.target.id === 'bottom-observer') { const lastChild = this._content.lastElementChild; if (lastChild) { const next = lastChild.getAttribute('data-next'); if (next && !this._content.querySelector(`[data-root="${next}"]`)) { console.log('<pb-view> Loading next page: %s', next); this._checkChunks('forward'); this._load(next, 'forward'); } } } else { const firstChild = this._content.firstElementChild; if (firstChild) { const previous = firstChild.getAttribute('data-previous'); if (previous && !this._content.querySelector(`[data-root="${previous}"]`)) { this._checkChunks('backward'); this._load(previous, 'backward'); } } } } }); }); } if (!this.onUpdate) { waitOnce('pb-page-ready', (data) => { if (data && data.language) { this._features.language = data.language; } this.wait(() => { if (!this.disableHistory) { this._setState(registry.state); } this._refresh(); }); }); } } /** * Returns the ODD used to render content. * * @returns the ODD being used */ getOdd() { return this.odd || this.getDocument().odd || "teipublisher"; } getView() { return this.view || this.getDocument().view || "single"; } /** * Trigger an update of this element's content */ forceUpdate() { this._load(this.nodeId); } animate() { // animate new element if 'animation' property is 'true' if (this.animation) { if (this.lastDirection === 'forward') { anime({ targets: this.shadowRoot.getElementById('view'), opacity: [0, 1], translateX: [1000, 0], duration: 300, easing: 'linear' }); } else { anime({ targets: this.shadowRoot.getElementById('view'), opacity: [0, 1], translateX: [-1000, 0], duration: 300, easing: 'linear' }); } } } enableScrollbar(enable) { if (enable) { this.classList.add('noscroll'); } else { this.classList.remove('noscroll'); } } _refresh(ev) { if (ev && ev.detail) { if (ev.detail.hash && !this.noScroll && !(ev.detail.id || ev.detail.path || ev.detail.odd || ev.detail.view || ev.detail.position)) { // if only the scroll target has changed: scroll to the element without reloading this._scrollTarget = ev.detail.hash; const target = this.shadowRoot.getElementById(this._scrollTarget); if (target) { setTimeout(() => target.scrollIntoView({block: 'nearest'})); } return; } if (ev.detail.path) { const doc = this.getDocument(); doc.path = ev.detail.path; } if (ev.detail.id) { this.xmlId = ev.detail.id; } else if (ev.detail.id == null) { this.xmlId = null; } this.odd = ev.detail.odd || this.odd; if (ev.detail.columnSeparator !== undefined) { this.columnSeparator = ev.detail.columnSeparator; } this.view = ev.detail.view || this.getView(); if (ev.detail.xpath) { this.xpath = ev.detail.xpath; this.nodeId = null; } // clear nodeId if set to null if (ev.detail.root === null) { this.nodeId = null; } else { this.nodeId = (ev.detail.position !== undefined ? ev.detail.position : ev.detail.root) || this.nodeId; } // check if the URL template needs any other parameters // and set them on this._additionalParams registry.pathParams.forEach((key) => { this._additionalParams[key] = ev.detail[key]; }); if (!this.noScroll) { this._scrollTarget = ev.detail.hash; } } this._updateStyles(); if (this.infiniteScroll) { this._clear(); } this._load(this.nodeId); } _load(pos, direction) { const doc = this.getDocument(); if (!doc.path) { console.log("No path"); return; } if (this._loading) { return; } this._loading = true; const params = this.getParameters(pos); if (direction) { params._dir = direction; } // this.$.view.style.opacity=0; this._doLoad(params); } _doLoad(params) { this.emitTo('pb-start-update', params); console.log("<pb-view> Loading view with params %o", params); if (!this.infiniteScroll) { this._clear(); } if (this._scrollObserver) { if (this._bottomObserver) { this._scrollObserver.unobserve(this._bottomObserver); } if (this._topObserver) { this._scrollObserver.unobserve(this._topObserver); } } const loadContent = this.shadowRoot.getElementById('loadContent'); if (this.static !== null) { this._staticUrl(params).then((url) => { loadContent.url = url; loadContent.generateRequest(); }); return; } if (!this.url) { if (this.minApiVersion('1.0.0')) { this.url = "api/parts"; } else { this.url = "modules/lib/components.xql"; } } let url = `${this.getEndpoint()}/${this.url}`; if (this.minApiVersion('1.0.0')) { url += `/${encodeURIComponent(this.getDocument().path)}/json`; } loadContent.url = url; loadContent.params = params; loadContent.generateRequest(); } /** * Use a static URL to load pre-generated content. */ async _staticUrl(params) { function createKey(paramNames) { const urlComponents = []; paramNames.sort().forEach(key => { if (params.hasOwnProperty(key)) { urlComponents.push(`${key}=${params[key]}`); } }); return urlComponents.join('&'); } const index = await fetch(`index.json`) .then((response) => response.json()); const paramNames = ['odd', 'view', 'xpath', 'map']; this.querySelectorAll('pb-param').forEach((param) => paramNames.push(`user.${param.getAttribute('name')}`)); let url = params.id ? createKey([...paramNames, 'id']) : createKey([...paramNames, 'root']); let file = index[url]; if (!file) { url = createKey(paramNames); file = index[url]; } console.log('<pb-view> Static lookup %s: %s', url, file); return `${file}`; } _clear() { if (this.infiniteScroll) { this._content = document.createElement('div'); this._content.className = 'infinite-content'; } else { this._content = null; } this._column1 = null; this._column2 = null; this._footnotes = null; this._chunks = []; } _handleError() { this._clear(); const loader = this.shadowRoot.getElementById('loadContent'); let message; const { response } = loader.lastError; if (response) { message = response.description; } else { message = '<pb-i18n key="dialogs.serverError"></pb-i18n>'; } let content; if (this.notFound != null) { content = `<p>${this.notFound}</p>`; } else { content = `<p><pb-i18n key="dialogs.serverError"></pb-i18n>: ${message} </p>`; } this._replaceContent({ content }); this.emitTo('pb-end-update'); } _handleContent() { const loader = this.shadowRoot.getElementById('loadContent'); const resp = loader.lastResponse; if (!resp) { console.error('<pb-view> No response received'); return; } if (resp.error) { if (this.notFound != null) { this._content = this.notFound; } this.emitTo('pb-end-update', null); return; } this._replaceContent(resp, loader.params._dir); this.animate(); if (this._scrollTarget) { this.updateComplete.then(() => { const target = this.shadowRoot.getElementById(this._scrollTarget) || this.shadowRoot.querySelector(`[node-id="${this._scrollTarget}"]`); if (target) { window.requestAnimationFrame(() => setTimeout(() => { target.scrollIntoView({block: 'nearest'}); }, 400) ); } this._scrollTarget = null; }); } this.next = resp.next; this.nextId = resp.nextId; this.previous = resp.previous; this.previousId = resp.previousId; this.nodeId = resp.root; this.switchView = resp.switchView; this.updateComplete.then(() => { const view = this.shadowRoot.getElementById('view'); this._applyToggles(view); this._fixLinks(view); typesetMath(view); const eventOptions = { data: resp, root: view, params: loader.params, id: this.xmlId, position: this.nodeId }; this.emitTo('pb-update', eventOptions); this._scroll(); }); this.emitTo('pb-end-update', null); } _replaceContent(resp, direction) { const fragment = document.createDocumentFragment(); const elem = document.createElement('div'); // elem.style.opacity = 0; //hide it - animation has to make sure to blend it in fragment.appendChild(elem); elem.innerHTML = resp.content; // if before-update-event is set, we do not replace the content immediately, // but emit an event if (this.beforeUpdate) { this.emitTo(this.beforeUpdate, { data: resp, root: elem, render: (content) => { this._doReplaceContent(content, resp, direction); } }); } else { this._doReplaceContent(elem, resp, direction); } } _doReplaceContent(elem, resp, direction) { if (this.columnSeparator) { this._replaceColumns(elem); this._loading = false; } else if (this.infiniteScroll) { elem.className = 'scroll-fragment'; elem.setAttribute('data-root', resp.root); if (resp.next) { elem.setAttribute('data-next', resp.next); } if (resp.previous) { elem.setAttribute('data-previous', resp.previous); } let refNode; switch (direction) { case 'backward': refNode = this._content.firstElementChild; this._chunks.unshift(elem); this.updateComplete.then(() => { refNode.scrollIntoView(true); this._loading = false; this._checkVisibility(); this._scrollObserver.observe(this._bottomObserver); this._scrollObserver.observe(this._topObserver); }); this._content.insertBefore(elem, refNode); break; default: this.updateComplete.then(() => { this._loading = false; this._checkVisibility(); this._scrollObserver.observe(this._bottomObserver); this._scrollObserver.observe(this._topObserver); }); this._chunks.push(elem); this._content.appendChild(elem); break; } } else { this._content = elem; this._loading = false; } if (this.appendFootnotes) { const footnotes = document.createElement('div'); if (resp.footnotes) { footnotes.innerHTML = resp.footnotes; } this._footnotes = footnotes; } this._initFootnotes(this._footnotes); return elem; } _checkVisibility() { const bottomActive = this._chunks[this._chunks.length - 1].hasAttribute('data-next'); this._bottomObserver.style.display = bottomActive ? '' : 'none'; const topActive = this._chunks[0].hasAttribute('data-previous'); this._topObserver.style.display = topActive ? '' : 'none'; } _replaceColumns(elem) { let cb; if (this.columnSeparator) { const cbs = elem.querySelectorAll(this.columnSeparator); // use last separator only if (cbs.length > 1) { cb = cbs[cbs.length - 1]; } } if (!cb) { this._content = elem; } else { const fragmentBefore = this._getFragmentBefore(elem, cb); const fragmentAfter = this._getFragmentAfter(elem, cb); if (this.direction === 'ltr') { this._column1 = fragmentBefore; this._column2 = fragmentAfter; } else { this._column2 = fragmentBefore; this._column1 = fragmentAfter; } } } _scroll() { if (this.noScroll) { return; } if (registry.hash) { const target = this.shadowRoot.getElementById(registry.hash.substring(1)); console.log('hash target: %o', target); if (target) { window.requestAnimationFrame(() => setTimeout(() => { target.scrollIntoView({ block: "center", inline: "nearest" }); }, 400) ); } } } _scrollToElement(ev, link) { const target = this.shadowRoot.getElementById(link.hash.substring(1)); if (target) { ev.preventDefault(); console.log('<pb-view> Scrolling to element %s', target.id); target.scrollIntoView({ block: "center", inline: "nearest" }); } } _updateStyles() { let link = document.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); if (this.static !== null) { link.setAttribute('href', `${this.static}/css/${this.getOdd()}.css`); } else { link.setAttribute('href', `${this.getEndpoint()}/transform/${this.getOdd()}.css`); } this._style = link; } _fixLinks(content) { if (this.fixLinks) { const doc = this.getDocument(); const base = this.toAbsoluteURL(doc.path); content.querySelectorAll('img').forEach((image) => { const oldSrc = image.getAttribute('src'); const src = new URL(oldSrc, base); image.src = src; }); content.querySelectorAll('a').forEach((link) => { const oldHref = link.getAttribute('href'); if (oldHref === link.hash) { link.addEventListener('click', (ev) => this._scrollToElement(ev, link)); } else { const href = new URL(oldHref, base); link.href = href; } }); } else { content.querySelectorAll('a').forEach((link) => { const oldHref = link.getAttribute('href'); if (oldHref === link.hash) { link.addEventListener('click', (ev) => this._scrollToElement(ev, link)); } }); } } _initFootnotes(content) { if (content) { content.querySelectorAll('.note, .fn-back').forEach(elem => { elem.addEventListener('click', (ev) => { ev.preventDefault(); const fn = this.shadowRoot.getElementById('content').querySelector(elem.hash); if (fn) { fn.scrollIntoView(); } }); }); } } _getParameters() { const params = []; this.querySelectorAll('pb-param').forEach(function (param) { params['user.' + param.getAttribute('name')] = param.getAttribute('value'); }); // add parameters for features set with pb-toggle-feature for (const [key, value] of Object.entries(this._features)) { params['user.' + key] = value; } // add parameters for user-defined parameters supplied via pb-link if (this._additionalParams) { for (const [key, value] of Object.entries(this._additionalParams)) { params[key] = value; } } return params; } /** * Return the parameter object which would be passed to the server by this component */ getParameters(pos) { pos = pos || this.nodeId; const doc = this.getDocument(); const params = this._getParameters(); if (!this.minApiVersion('1.0.0')) { params.doc = doc.path; } params.odd = this.getOdd() + '.odd'; params.view = this.getView(); if (pos) { params['root'] = pos; } if (this.xpath) { params.xpath = this.xpath; } if (this.xmlId) { params.id = this.xmlId; } if (!this.suppressHighlight && this.highlight) { params.highlight = "yes"; } if (this.map) { params.map = this.map; } return params; } _applyToggles(elem) { for (const [selector, setting] of Object.entries(this._selector)) { elem.querySelectorAll(selector).forEach(node => { const command = setting.command || 'toggle'; if (node.command) { node.command(command, setting.state); } if (setting.state) { node.classList.add(command); } else { node.classList.remove(command); } }); } } /** * Load a part of the document identified by the given eXist nodeId * * @param {String} nodeId The eXist nodeId of the root element to load */ goto(nodeId) { this._load(nodeId); } /** * Load a part of the document identified by the given xml:id * * @param {String} xmlId The xml:id to be loaded */ gotoId(xmlId) { this.xmlId = xmlId; this._load(); } /** * Navigate the document either forward or backward and refresh the view. * The navigation method is determined by property `view`. * * @param {string} direction either `backward` or `forward` */ navigate(direction) { // in single view mode there should be no navigation if (this.getView() === 'single') { return; } this.lastDirection = direction; if (direction === 'backward') { if (this.previous) { if (!this.disableHistory && !this.map) { registry.commit(this, { id: this.previousId || null, root: this.previousId ? null : this.previous }); } this.xmlId = this.previousId; this._load(this.xmlId ? null : this.previous, direction); } } else if (this.next) { if (!this.disableHistory && !this.map) { registry.commit(this, { id: this.nextId || null, root: this.nextId ? null : this.next }); } this.xmlId = this.nextId; this._load(this.xmlId ? null : this.next, direction); } } /** * Check the number of fragments which were already loaded in infinite * scroll mode. If they exceed `infiniteScrollMax`, remove either the * first or last fragment from the DOM, depending on the scroll direction. * * @param {string} direction either 'forward' or 'backward' */ _checkChunks(direction) { if (!this.infiniteScroll || this.infiniteScrollMax === 0) { return; } if (this._chunks.length === this.infiniteScrollMax) { switch (direction) { case 'forward': this._content.removeChild(this._chunks.shift()); break; default: this._content.removeChild(this._chunks.pop()); break; } } this.emitTo('pb-navigate', { direction, source: this }); } /** * Zoom the displayed content by increasing or decreasing font size. * * @param {string} direction either `in` or `out` */ zoom(direction) { const view = this.shadowRoot.getElementById('view'); const fontSize = window.getComputedStyle(view).getPropertyValue('font-size'); const size = parseInt(fontSize.replace(/^(\d+)px/, "$1")); if (direction === 'in') { view.style.fontSize = (size + 1) + 'px'; } else { view.style.fontSize = (size - 1) + 'px'; } } toggleFeature(ev) { const properties = registry.getState(this); if (properties) { this._setState(properties); } if (ev.detail.refresh) { this._updateStyles(); this._load(); } else { const view = this.shadowRoot.getElementById('view'); this._applyToggles(view); } registry.commit(this, properties); } _setState(properties) { for (const [key, value] of Object.entries(properties)) { // check if URL template needs the parameter and if // yes, add it to the additional parameter list if (registry.pathParams.has(key)) { this._additionalParams[key] = value; } else { switch (key) { case 'odd': case 'view': case 'columnSeparator': case 'xpath': case 'nodeId': case 'path': case 'root': break; default: this._features[key] = value; break; } } } if (properties.odd && !this.getAttribute('odd')) { this.odd = properties.odd; } if (properties.view && !this.getAttribute('view')) { this.view = properties.view; if (this.view === 'single') { // when switching to single view, clear current node id this.nodeId = null; } else { // otherwise use value for alternate view returned from server this.nodeId = this.switchView; } } if (properties.xpath && !this.getAttribute('xpath')) { this.xpath = properties.xpath; } if (properties.hasOwnProperty('columnSeparator')) { this.columnSeparator = properties.columnSeparator; } this.xmlId = (!this.getAttribute('xml-id') && properties.id) || this.xmlId; this.nodeId = (!this.getAttribute('xml-id') && properties.root) || null; if (properties.path) { this.getDocument().path = properties.path; } if (properties.selectors) { properties.selectors.forEach(sc => { this._selector[sc.selector] = { state: sc.state, command: sc.command || 'toggle' }; }); } } _getFragmentBefore(node, ms) { const range = document.createRange(); range.setStartBefore(node); range.setEndBefore(ms); return range.cloneContents(); } _getFragmentAfter(node, ms) { const range = document.createRange(); range.setStartBefore(ms); range.setEndAfter(node); return range.cloneContents(); } _updateSource(newVal, oldVal) { if (typeof oldVal !== 'undefined' && newVal !== oldVal) { this.xpath = null; this.odd = null; this.xmlId = null; this.nodeId = null; } } static get styles() { return css` :host { display: block; background: transparent; } :host(.noscroll) { scrollbar-width: none; /* Firefox 64 */ -ms-overflow-style: none; } :host(.noscroll)::-webkit-scrollbar { width: 0 !important; display: none; } [id] { scroll-margin-top: var(--pb-view-scroll-margin-top); } #view { position: relative; } .columns { display: grid; grid-template-columns: calc(50% - var(--pb-view-column-gap, 10px) / 2) calc(50% - var(--pb-view-column-gap, 10px) / 2); grid-column-gap: var(--pb-view-column-gap, 10px); } .margin-note { display: none; } @media (min-width: 769px) { .content.margin-right { margin-right: 200px; } .margin-note { background: rgba(153, 153, 153, 0.2); display: block; font-size: small; margin-right: -200px; margin-bottom: 5px; padding: 5px 0; float: right; clear: both; width: 180px; } .margin-note .n { color: #777777; } } a[rel=footnote] { font-size: var(--pb-footnote-font-size, var(--pb-content-font-size, 75%)); font-family: var(--pb-footnote-font-family, --pb-content-font-family); vertical-align: super; color: var(--pb-footnote-color, var(--pb-color-primary, #333333)); text-decoration: none; padding: var(--pb-footnote-padding, 0 0 0 .25em); } .list dt { float: left; } .footnote .fn-number { float: left; font-size: var(--pb-footnote-font-size, var(--pb-content-font-size, 75%)); } .observer { display: block; width: 100%; height: var(--pb-view-loader-height, 16px); font-family: var(--pb-view-loader-font, --pb-base-font); color: var(--pb-view-loader-color, black); background: var(--pb-view-loader-background, #909090); 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)); animation-name: loader; animation-timing-function: linear; animation-duration: 2s; animation-fill-mode: forwards; animation-iteration-count: infinite; } @keyframes loader { 0% { background-position: 3rem 0; } 100% { background-position: 0 0; } } .scroll-fragment { animation: fadeIn ease 500ms; } @keyframes fadeIn { 0% {opacity:0;} 100% {opacity:1;} } `; } render() { return [ html` <div id="view" part="content"> ${this._style} ${this.infiniteScroll ? html`<div id="top-observer" class="observer"></div>` : null} <div class="columns"> <div id="column1">${this._column1}</div> <div id="column2">${this._column2}</div> </div> <div id="content">${this._content}</div> ${ this.infiniteScroll ? html`<div id="bottom-observer" class="observer"></div>` : null } <div id="footnotes" part="footnotes">${this._footnotes}</div> </div> <paper-dialog id="errorDialog"> <h2>${translate('dialogs.error')}</h2> <paper-dialog-scrollable></pap