]> BookStack Code Mirror - bookstack/blob - resources/assets/js/components/page-display.js
Merge branch 'master' into 2019-design
[bookstack] / resources / assets / js / components / page-display.js
1 import Clipboard from "clipboard/dist/clipboard.min";
2 import Code from "../services/code";
3
4 class PageDisplay {
5
6     constructor(elem) {
7         this.elem = elem;
8         this.pageId = elem.getAttribute('page-display');
9
10         Code.highlight();
11         this.setupPointer();
12         this.setupStickySidebar();
13         this.setupNavHighlighting();
14
15         // Check the hash on load
16         if (window.location.hash) {
17             let text = window.location.hash.replace(/\%20/g, ' ').substr(1);
18             this.goToText(text);
19         }
20
21         // Sidebar page nav click event
22         $('.sidebar-page-nav').on('click', 'a', event => {
23             this.goToText(event.target.getAttribute('href').substr(1));
24         });
25     }
26
27     goToText(text) {
28         let idElem = document.getElementById(text);
29         $('.page-content [data-highlighted]').attr('data-highlighted', '').css('background-color', '');
30         if (idElem !== null) {
31             window.scrollAndHighlight(idElem);
32         } else {
33             $('.page-content').find(':contains("' + text + '")').smoothScrollTo();
34         }
35     }
36
37     setupPointer() {
38         if (document.getElementById('pointer') === null) return;
39         // Set up pointer
40         let $pointer = $('#pointer').detach();
41         let pointerShowing = false;
42         let $pointerInner = $pointer.children('div.pointer').first();
43         let isSelection = false;
44         let pointerModeLink = true;
45         let pointerSectionId = '';
46
47         // Select all contents on input click
48         $pointer.on('click', 'input', event => {
49             $(this).select();
50             event.stopPropagation();
51         });
52
53         $pointer.on('click focus', event => {
54             event.stopPropagation();
55         });
56
57         // Pointer mode toggle
58         $pointer.on('click', 'span.icon', event => {
59             event.stopPropagation();
60             let $icon = $(event.currentTarget);
61             pointerModeLink = !pointerModeLink;
62             $icon.find('[data-icon="include"]').toggle(!pointerModeLink);
63             $icon.find('[data-icon="link"]').toggle(pointerModeLink);
64             updatePointerContent();
65         });
66
67         // Set up clipboard
68         let clipboard = new Clipboard($pointer[0].querySelector('button'));
69
70         // Hide pointer when clicking away
71         $(document.body).find('*').on('click focus', event => {
72             if (!pointerShowing || isSelection) return;
73             $pointer.detach();
74             pointerShowing = false;
75         });
76
77         let updatePointerContent = ($elem) => {
78             let inputText = pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${pointerSectionId}`) : `{{@${this.pageId}#${pointerSectionId}}}`;
79             if (pointerModeLink && inputText.indexOf('http') !== 0) inputText = window.location.protocol + "//" + window.location.host + inputText;
80
81             $pointer.find('input').val(inputText);
82
83             // update anchor if present
84             const $editAnchor = $pointer.find('#pointer-edit');
85             if ($editAnchor.length !== 0 && $elem) {
86                 const editHref = $editAnchor.data('editHref');
87                 const element = $elem[0];
88                 const elementId = element.id;
89
90                 // get the first 50 characters.
91                 let queryContent = element.textContent && element.textContent.substring(0, 50);
92                 $editAnchor[0].href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
93             }
94         };
95
96         // Show pointer when selecting a single block of tagged content
97         $('.page-content [id^="bkmrk"]').on('mouseup keyup', function (e) {
98             e.stopPropagation();
99             let selection = window.getSelection();
100             if (selection.toString().length === 0) return;
101
102             // Show pointer and set link
103             let $elem = $(this);
104             pointerSectionId = $elem.attr('id');
105             updatePointerContent($elem);
106
107             $elem.before($pointer);
108             $pointer.show();
109             pointerShowing = true;
110
111             // Set pointer to sit near mouse-up position
112             let pointerLeftOffset = (e.pageX - $elem.offset().left - ($pointerInner.width() / 2));
113             if (pointerLeftOffset < 0) pointerLeftOffset = 0;
114             let pointerLeftOffsetPercent = (pointerLeftOffset / $elem.width()) * 100;
115             $pointerInner.css('left', pointerLeftOffsetPercent + '%');
116
117             isSelection = true;
118             setTimeout(() => {
119                 isSelection = false;
120             }, 100);
121         });
122     }
123
124     setupStickySidebar() {
125         // Make the sidebar stick in view on scroll
126         let $window = $(window);
127         let $sidebar = $("#sidebar .scroll-body");
128         let $bookTreeParent = $sidebar.parent();
129
130         // Check the page is scrollable and the content is taller than the tree
131         let pageScrollable = ($(document).height() > ($window.height() + 40)) && ($sidebar.height() < $('.page-content').height());
132
133         // Get current tree's width and header height
134         let headerHeight = $("#header").height() + $(".toolbar").height();
135         let isFixed = $window.scrollTop() > headerHeight;
136
137         // Fix the tree as a sidebar
138         function stickTree() {
139             $sidebar.width($bookTreeParent.width() - 32);
140             $sidebar.addClass("fixed");
141             isFixed = true;
142         }
143
144         // Un-fix the tree back into position
145         function unstickTree() {
146             $sidebar.css('width', 'auto');
147             $sidebar.removeClass("fixed");
148             isFixed = false;
149         }
150
151         // Checks if the tree stickiness state should change
152         function checkTreeStickiness(skipCheck) {
153             let shouldBeFixed = $window.scrollTop() > headerHeight;
154             if (shouldBeFixed && (!isFixed || skipCheck)) {
155                 stickTree();
156             } else if (!shouldBeFixed && (isFixed || skipCheck)) {
157                 unstickTree();
158             }
159         }
160         // The event ran when the window scrolls
161         function windowScrollEvent() {
162             checkTreeStickiness(false);
163         }
164
165         // If the page is scrollable and the window is wide enough listen to scroll events
166         // and evaluate tree stickiness.
167         if (pageScrollable && $window.width() > 1000) {
168             $window.on('scroll', windowScrollEvent);
169             checkTreeStickiness(true);
170         }
171
172         // Handle window resizing and switch between desktop/mobile views
173         $window.on('resize', event => {
174             if (pageScrollable && $window.width() > 1000) {
175                 $window.on('scroll', windowScrollEvent);
176                 checkTreeStickiness(true);
177             } else {
178                 $window.off('scroll', windowScrollEvent);
179                 unstickTree();
180             }
181         });
182     }
183
184     setupNavHighlighting() {
185         // Check if support is present for IntersectionObserver
186         if (!'IntersectionObserver' in window ||
187             !'IntersectionObserverEntry' in window ||
188             !'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
189             return;
190         }
191
192         let pageNav = document.querySelector('.sidebar-page-nav');
193
194         // fetch all the headings.
195         let headings = document.querySelector('.page-content').querySelectorAll('h1, h2, h3, h4, h5, h6');
196         // if headings are present, add observers.
197         if (headings.length > 0 && pageNav !== null) {
198             addNavObserver(headings);
199         }
200
201         function addNavObserver(headings) {
202             // Setup the intersection observer.
203             let intersectOpts = {
204                 rootMargin: '0px 0px 0px 0px',
205                 threshold: 1.0
206             };
207             let pageNavObserver = new IntersectionObserver(headingVisibilityChange, intersectOpts);
208
209             // observe each heading
210             for (let i = 0; i !== headings.length; ++i) {
211                 pageNavObserver.observe(headings[i]);
212             }
213         }
214
215         function headingVisibilityChange(entries, observer) {
216             for (let entry of entries) {
217                 let isVisible = (entry.intersectionRatio === 1);
218                 toggleAnchorHighlighting(entry.target.id, isVisible);
219             }
220         }
221
222         function toggleAnchorHighlighting(elementId, shouldHighlight) {
223             let anchorsToHighlight = pageNav.querySelectorAll('a[href="#' + elementId + '"]');
224             for (let i = 0; i < anchorsToHighlight.length; i++) {
225                 // Change below to use classList.toggle when IE support is dropped.
226                 if (shouldHighlight) {
227                     anchorsToHighlight[i].classList.add('current-heading');
228                 } else {
229                     anchorsToHighlight[i].classList.remove('current-heading');
230                 }
231             }
232         }
233     }
234 }
235
236 export default PageDisplay;