]> BookStack Code Mirror - bookstack/blob - resources/js/components/pointer.ts
Comments: Added inline comment marker/highlight logic
[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 {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom.ts";
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 && 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      * @param {Element} element
106      * @param {Number} xPosition
107      * @param {Boolean} keyboardMode
108      */
109     showPointerAtTarget(element, xPosition, keyboardMode) {
110         this.targetElement = element;
111         this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
112         this.updateDomForTarget(element);
113
114         this.pointer.style.display = 'block';
115         const targetBounds = element.getBoundingClientRect();
116         const pointerBounds = this.pointer.getBoundingClientRect();
117
118         const xTarget = Math.min(Math.max(xPosition, targetBounds.left), targetBounds.right);
119         const xOffset = xTarget - (pointerBounds.width / 2);
120         const yOffset = (targetBounds.top - pointerBounds.height) - 16;
121
122         this.pointer.style.left = `${xOffset}px`;
123         this.pointer.style.top = `${yOffset}px`;
124
125         this.showing = true;
126         this.isMakingSelection = true;
127
128         setTimeout(() => {
129             this.isMakingSelection = false;
130         }, 100);
131
132         const scrollListener = () => {
133             this.hidePointer();
134             window.removeEventListener('scroll', scrollListener);
135         };
136
137         element.parentElement.insertBefore(this.pointer, element);
138         if (!keyboardMode) {
139             window.addEventListener('scroll', scrollListener, {passive: true});
140         }
141     }
142
143     /**
144      * Update the pointer inputs/content for the given target element.
145      * @param {?Element} element
146      */
147     updateDomForTarget(element) {
148         const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
149         const includeTag = `{{@${this.pageId}#${element.id}}}`;
150
151         this.linkInput.value = permaLink;
152         this.includeInput.value = includeTag;
153
154         // Update anchor if present
155         const editAnchor = this.pointer.querySelector('#pointer-edit');
156         if (editAnchor instanceof HTMLAnchorElement && element) {
157             const {editHref} = editAnchor.dataset;
158             const elementId = element.id;
159
160             // Get the first 50 characters.
161             const queryContent = element.textContent && element.textContent.substring(0, 50);
162             editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
163         }
164     }
165
166     enterSectionSelectMode() {
167         const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
168         for (const section of sections) {
169             section.setAttribute('tabindex', '0');
170         }
171
172         sections[0].focus();
173
174         DOM.onEnterPress(sections, event => {
175             this.showPointerAtTarget(event.target, 0, true);
176             this.pointer.focus();
177         });
178     }
179
180     createCommentAtPointer(event) {
181         if (!this.targetElement) {
182             return;
183         }
184
185         const refId = this.targetElement.id;
186         const hash = hashElement(this.targetElement);
187         let range = '';
188         if (this.targetSelectionRange) {
189             const commonContainer = this.targetSelectionRange.commonAncestorContainer;
190             if (this.targetElement.contains(commonContainer)) {
191                 const start = normalizeNodeTextOffsetToParent(
192                     this.targetSelectionRange.startContainer,
193                     this.targetSelectionRange.startOffset,
194                     this.targetElement
195                 );
196                 const end = normalizeNodeTextOffsetToParent(
197                     this.targetSelectionRange.endContainer,
198                     this.targetSelectionRange.endOffset,
199                     this.targetElement
200                 );
201                 range = `${start}-${end}`;
202             }
203         }
204
205         const reference = `${refId}:${hash}:${range}`;
206         const pageComments = window.$components.first('page-comments') as PageComments;
207         pageComments.startNewComment(reference);
208     }
209
210 }