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";
7 export class Pointer extends Component {
9 protected showing: boolean = false;
10 protected isMakingSelection: boolean = false;
11 protected targetElement: HTMLElement|null = null;
12 protected targetSelectionRange: Range|null = null;
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;
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;
37 this.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));
45 // Select all contents on input click
46 DOM.onSelect([this.includeInput, this.linkInput], event => {
47 (event.target as HTMLInputElement).select();
48 event.stopPropagation();
51 // Prevent closing pointer when clicked or focused
52 DOM.onEvents(this.pointer, ['click', 'focus'], event => {
53 event.stopPropagation();
56 // Hide pointer when clicking away
57 DOM.onEvents(document.body, ['click', 'focus'], () => {
58 if (!this.showing || this.isMakingSelection) return;
62 // Hide pointer on escape press
63 DOM.onEscapePress(this.pointer, this.hidePointer.bind(this));
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);
76 // Start section selection mode on button press
77 DOM.onSelect(this.sectionModeButton, this.enterSectionSelectMode.bind(this));
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);
87 const otherToggle = this.modeToggles.find(b => b !== targetToggle);
88 otherToggle && otherToggle.focus();
91 if (this.commentButton) {
92 DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
97 this.pointer.style.removeProperty('display');
99 this.targetElement = null;
100 this.targetSelectionRange = null;
104 * Move and display the pointer at the given element, targeting the given screen x-position if possible.
106 showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) {
107 this.targetElement = element;
108 this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
109 this.updateDomForTarget(element);
111 this.pointer.style.display = 'block';
112 const targetBounds = element.getBoundingClientRect();
113 const pointerBounds = this.pointer.getBoundingClientRect();
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;
119 this.pointer.style.left = `${xOffset}px`;
120 this.pointer.style.top = `${yOffset}px`;
123 this.isMakingSelection = true;
126 this.isMakingSelection = false;
129 const scrollListener = () => {
131 window.removeEventListener('scroll', scrollListener);
134 element.parentElement?.insertBefore(this.pointer, element);
136 window.addEventListener('scroll', scrollListener, {passive: true});
141 * Update the pointer inputs/content for the given target element.
143 updateDomForTarget(element: HTMLElement) {
144 const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
145 const includeTag = `{{@${this.pageId}#${element.id}}}`;
147 this.linkInput.value = permaLink;
148 this.includeInput.value = includeTag;
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;
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)}`;
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');
170 DOM.onEnterPress(sections, event => {
171 this.showPointerAtTarget(event.target as HTMLElement, 0, true);
172 this.pointer.focus();
176 createCommentAtPointer() {
177 if (!this.targetElement) {
181 const refId = this.targetElement.id;
182 const hash = hashElement(this.targetElement);
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,
192 const end = normalizeNodeTextOffsetToParent(
193 this.targetSelectionRange.endContainer,
194 this.targetSelectionRange.endOffset,
197 range = `${start}-${end}`;
201 const reference = `${refId}:${hash}:${range}`;
202 const pageComments = window.$components.first('page-comments') as PageComments;
203 pageComments.startNewComment(reference);