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