]> BookStack Code Mirror - bookstack/blob - resources/js/components/pointer.ts
Comments & Pointer: Converted components to typescript
[bookstack] / resources / js / components / pointer.ts
1 import * as DOM from '../services/dom.ts';
2 import {Component} from './component';
3 import {copyTextToClipboard} from '../services/clipboard.ts';
4 import {cyrb53} from "../services/util";
5 import {normalizeNodeTextOffsetToParent} from "../services/dom.ts";
6 import {PageComments} from "./page-comments";
7
8 export class Pointer extends Component {
9
10     protected showing: boolean = false;
11     protected isMakingSelection: boolean = false;
12     protected targetElement: HTMLElement|null = null;
13     protected targetSelectionRange: Range|null = null;
14
15     protected pointer: HTMLElement;
16     protected linkInput: HTMLInputElement;
17     protected linkButton: HTMLElement;
18     protected includeInput: HTMLInputElement;
19     protected includeButton: HTMLElement;
20     protected sectionModeButton: HTMLElement;
21     protected commentButton: HTMLElement;
22     protected modeToggles: HTMLElement[];
23     protected modeSections: HTMLElement[];
24     protected pageId: string;
25
26     setup() {
27         this.pointer = this.$refs.pointer;
28         this.linkInput = this.$refs.linkInput as HTMLInputElement;
29         this.linkButton = this.$refs.linkButton;
30         this.includeInput = this.$refs.includeInput as HTMLInputElement;
31         this.includeButton = this.$refs.includeButton;
32         this.sectionModeButton = this.$refs.sectionModeButton;
33         this.commentButton = this.$refs.commentButton;
34         this.modeToggles = this.$manyRefs.modeToggle;
35         this.modeSections = this.$manyRefs.modeSection;
36         this.pageId = this.$opts.pageId;
37
38         this.setupListeners();
39     }
40
41     setupListeners() {
42         // Copy on copy button click
43         this.includeButton.addEventListener('click', () => copyTextToClipboard(this.includeInput.value));
44         this.linkButton.addEventListener('click', () => copyTextToClipboard(this.linkInput.value));
45
46         // Select all contents on input click
47         DOM.onSelect([this.includeInput, this.linkInput], event => {
48             (event.target as HTMLInputElement).select();
49             event.stopPropagation();
50         });
51
52         // Prevent closing pointer when clicked or focused
53         DOM.onEvents(this.pointer, ['click', 'focus'], event => {
54             event.stopPropagation();
55         });
56
57         // Hide pointer when clicking away
58         DOM.onEvents(document.body, ['click', 'focus'], () => {
59             if (!this.showing || this.isMakingSelection) return;
60             this.hidePointer();
61         });
62
63         // Hide pointer on escape press
64         DOM.onEscapePress(this.pointer, this.hidePointer.bind(this));
65
66         // Show pointer when selecting a single block of tagged content
67         const pageContent = document.querySelector('.page-content');
68         DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
69             event.stopPropagation();
70             const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]');
71             if (targetEl && window.getSelection().toString().length > 0) {
72                 const xPos = (event instanceof MouseEvent) ? event.pageX : 0;
73                 this.showPointerAtTarget(targetEl, xPos, false);
74             }
75         });
76
77         // Start section selection mode on button press
78         DOM.onSelect(this.sectionModeButton, this.enterSectionSelectMode.bind(this));
79
80         // Toggle between pointer modes
81         DOM.onSelect(this.modeToggles, event => {
82             const targetToggle = (event.target as HTMLElement);
83             for (const section of this.modeSections) {
84                 const show = !section.contains(targetToggle);
85                 section.toggleAttribute('hidden', !show);
86             }
87
88             const otherToggle = this.modeToggles.find(b => b !== targetToggle);
89             otherToggle && otherToggle.focus();
90         });
91
92         if (this.commentButton) {
93             DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
94         }
95     }
96
97     hidePointer() {
98         this.pointer.style.removeProperty('display');
99         this.showing = false;
100         this.targetElement = null;
101         this.targetSelectionRange = null;
102     }
103
104     /**
105      * Move and display the pointer at the given element, targeting the given screen x-position if possible.
106      * @param {Element} element
107      * @param {Number} xPosition
108      * @param {Boolean} keyboardMode
109      */
110     showPointerAtTarget(element, xPosition, keyboardMode) {
111         this.targetElement = element;
112         this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
113         this.updateDomForTarget(element);
114
115         this.pointer.style.display = 'block';
116         const targetBounds = element.getBoundingClientRect();
117         const pointerBounds = this.pointer.getBoundingClientRect();
118
119         const xTarget = Math.min(Math.max(xPosition, targetBounds.left), targetBounds.right);
120         const xOffset = xTarget - (pointerBounds.width / 2);
121         const yOffset = (targetBounds.top - pointerBounds.height) - 16;
122
123         this.pointer.style.left = `${xOffset}px`;
124         this.pointer.style.top = `${yOffset}px`;
125
126         this.showing = true;
127         this.isMakingSelection = true;
128
129         setTimeout(() => {
130             this.isMakingSelection = false;
131         }, 100);
132
133         const scrollListener = () => {
134             this.hidePointer();
135             window.removeEventListener('scroll', scrollListener);
136         };
137
138         element.parentElement.insertBefore(this.pointer, element);
139         if (!keyboardMode) {
140             window.addEventListener('scroll', scrollListener, {passive: true});
141         }
142     }
143
144     /**
145      * Update the pointer inputs/content for the given target element.
146      * @param {?Element} element
147      */
148     updateDomForTarget(element) {
149         const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
150         const includeTag = `{{@${this.pageId}#${element.id}}}`;
151
152         this.linkInput.value = permaLink;
153         this.includeInput.value = includeTag;
154
155         // Update anchor if present
156         const editAnchor = this.pointer.querySelector('#pointer-edit');
157         if (editAnchor instanceof HTMLAnchorElement && element) {
158             const {editHref} = editAnchor.dataset;
159             const elementId = element.id;
160
161             // Get the first 50 characters.
162             const queryContent = element.textContent && element.textContent.substring(0, 50);
163             editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
164         }
165     }
166
167     enterSectionSelectMode() {
168         const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
169         for (const section of sections) {
170             section.setAttribute('tabindex', '0');
171         }
172
173         sections[0].focus();
174
175         DOM.onEnterPress(sections, event => {
176             this.showPointerAtTarget(event.target, 0, true);
177             this.pointer.focus();
178         });
179     }
180
181     createCommentAtPointer(event) {
182         if (!this.targetElement) {
183             return;
184         }
185
186         const normalisedElemHtml = this.targetElement.outerHTML.replace(/\s{2,}/g, '');
187         const refId = this.targetElement.id;
188         const hash = cyrb53(normalisedElemHtml);
189         let range = '';
190         if (this.targetSelectionRange) {
191             const commonContainer = this.targetSelectionRange.commonAncestorContainer;
192             if (this.targetElement.contains(commonContainer)) {
193                 const start = normalizeNodeTextOffsetToParent(
194                     this.targetSelectionRange.startContainer,
195                     this.targetSelectionRange.startOffset,
196                     this.targetElement
197                 );
198                 const end = normalizeNodeTextOffsetToParent(
199                     this.targetSelectionRange.endContainer,
200                     this.targetSelectionRange.endOffset,
201                     this.targetElement
202                 );
203                 range = `${start}-${end}`;
204             }
205         }
206
207         const reference = `${refId}:${hash}:${range}`;
208         const pageComments = window.$components.first('page-comments') as PageComments;
209         pageComments.startNewComment(reference);
210     }
211
212 }