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";
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 && 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.
105 * @param {Element} element
106 * @param {Number} xPosition
107 * @param {Boolean} keyboardMode
109 showPointerAtTarget(element, xPosition, keyboardMode) {
110 this.targetElement = element;
111 this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
112 this.updateDomForTarget(element);
114 this.pointer.style.display = 'block';
115 const targetBounds = element.getBoundingClientRect();
116 const pointerBounds = this.pointer.getBoundingClientRect();
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;
122 this.pointer.style.left = `${xOffset}px`;
123 this.pointer.style.top = `${yOffset}px`;
126 this.isMakingSelection = true;
129 this.isMakingSelection = false;
132 const scrollListener = () => {
134 window.removeEventListener('scroll', scrollListener);
137 element.parentElement.insertBefore(this.pointer, element);
139 window.addEventListener('scroll', scrollListener, {passive: true});
144 * Update the pointer inputs/content for the given target element.
145 * @param {?Element} element
147 updateDomForTarget(element) {
148 const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
149 const includeTag = `{{@${this.pageId}#${element.id}}}`;
151 this.linkInput.value = permaLink;
152 this.includeInput.value = includeTag;
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;
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)}`;
166 enterSectionSelectMode() {
167 const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
168 for (const section of sections) {
169 section.setAttribute('tabindex', '0');
174 DOM.onEnterPress(sections, event => {
175 this.showPointerAtTarget(event.target, 0, true);
176 this.pointer.focus();
180 createCommentAtPointer(event) {
181 if (!this.targetElement) {
185 const refId = this.targetElement.id;
186 const hash = hashElement(this.targetElement);
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,
196 const end = normalizeNodeTextOffsetToParent(
197 this.targetSelectionRange.endContainer,
198 this.targetSelectionRange.endOffset,
201 range = `${start}-${end}`;
205 const reference = `${refId}:${hash}:${range}`;
206 const pageComments = window.$components.first('page-comments') as PageComments;
207 pageComments.startNewComment(reference);