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";
8 export class Pointer extends Component {
10 protected showing: boolean = false;
11 protected isMakingSelection: boolean = false;
12 protected targetElement: HTMLElement|null = null;
13 protected targetSelectionRange: Range|null = null;
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;
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;
38 this.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));
46 // Select all contents on input click
47 DOM.onSelect([this.includeInput, this.linkInput], event => {
48 (event.target as HTMLInputElement).select();
49 event.stopPropagation();
52 // Prevent closing pointer when clicked or focused
53 DOM.onEvents(this.pointer, ['click', 'focus'], event => {
54 event.stopPropagation();
57 // Hide pointer when clicking away
58 DOM.onEvents(document.body, ['click', 'focus'], () => {
59 if (!this.showing || this.isMakingSelection) return;
63 // Hide pointer on escape press
64 DOM.onEscapePress(this.pointer, this.hidePointer.bind(this));
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);
77 // Start section selection mode on button press
78 DOM.onSelect(this.sectionModeButton, this.enterSectionSelectMode.bind(this));
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);
88 const otherToggle = this.modeToggles.find(b => b !== targetToggle);
89 otherToggle && otherToggle.focus();
92 if (this.commentButton) {
93 DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
98 this.pointer.style.removeProperty('display');
100 this.targetElement = null;
101 this.targetSelectionRange = null;
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
110 showPointerAtTarget(element, xPosition, keyboardMode) {
111 this.targetElement = element;
112 this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
113 this.updateDomForTarget(element);
115 this.pointer.style.display = 'block';
116 const targetBounds = element.getBoundingClientRect();
117 const pointerBounds = this.pointer.getBoundingClientRect();
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;
123 this.pointer.style.left = `${xOffset}px`;
124 this.pointer.style.top = `${yOffset}px`;
127 this.isMakingSelection = true;
130 this.isMakingSelection = false;
133 const scrollListener = () => {
135 window.removeEventListener('scroll', scrollListener);
138 element.parentElement.insertBefore(this.pointer, element);
140 window.addEventListener('scroll', scrollListener, {passive: true});
145 * Update the pointer inputs/content for the given target element.
146 * @param {?Element} element
148 updateDomForTarget(element) {
149 const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
150 const includeTag = `{{@${this.pageId}#${element.id}}}`;
152 this.linkInput.value = permaLink;
153 this.includeInput.value = includeTag;
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;
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)}`;
167 enterSectionSelectMode() {
168 const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
169 for (const section of sections) {
170 section.setAttribute('tabindex', '0');
175 DOM.onEnterPress(sections, event => {
176 this.showPointerAtTarget(event.target, 0, true);
177 this.pointer.focus();
181 createCommentAtPointer(event) {
182 if (!this.targetElement) {
186 const normalisedElemHtml = this.targetElement.outerHTML.replace(/\s{2,}/g, '');
187 const refId = this.targetElement.id;
188 const hash = cyrb53(normalisedElemHtml);
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,
198 const end = normalizeNodeTextOffsetToParent(
199 this.targetSelectionRange.endContainer,
200 this.targetSelectionRange.endOffset,
203 range = `${start}-${end}`;
207 const reference = `${refId}:${hash}:${range}`;
208 const pageComments = window.$components.first('page-comments') as PageComments;
209 pageComments.startNewComment(reference);