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