BookStackApp/BookStack

View on GitHub
resources/js/components/pointer.js

Summary

Maintainability
A
1 hr
Test Coverage
import * as DOM from '../services/dom';
import {Component} from './component';
import {copyTextToClipboard} from '../services/clipboard.ts';

export class Pointer extends Component {

    setup() {
        this.container = this.$el;
        this.pointer = this.$refs.pointer;
        this.linkInput = this.$refs.linkInput;
        this.linkButton = this.$refs.linkButton;
        this.includeInput = this.$refs.includeInput;
        this.includeButton = this.$refs.includeButton;
        this.sectionModeButton = this.$refs.sectionModeButton;
        this.modeToggles = this.$manyRefs.modeToggle;
        this.modeSections = this.$manyRefs.modeSection;
        this.pageId = this.$opts.pageId;

        // Instance variables
        this.showing = false;
        this.isSelection = false;

        this.setupListeners();
    }

    setupListeners() {
        // Copy on copy button click
        this.includeButton.addEventListener('click', () => copyTextToClipboard(this.includeInput.value));
        this.linkButton.addEventListener('click', () => copyTextToClipboard(this.linkInput.value));

        // Select all contents on input click
        DOM.onSelect([this.includeInput, this.linkInput], event => {
            event.target.select();
            event.stopPropagation();
        });

        // Prevent closing pointer when clicked or focused
        DOM.onEvents(this.pointer, ['click', 'focus'], event => {
            event.stopPropagation();
        });

        // Hide pointer when clicking away
        DOM.onEvents(document.body, ['click', 'focus'], () => {
            if (!this.showing || this.isSelection) return;
            this.hidePointer();
        });

        // Hide pointer on escape press
        DOM.onEscapePress(this.pointer, this.hidePointer.bind(this));

        // Show pointer when selecting a single block of tagged content
        const pageContent = document.querySelector('.page-content');
        DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
            event.stopPropagation();
            const targetEl = event.target.closest('[id^="bkmrk"]');
            if (targetEl && window.getSelection().toString().length > 0) {
                this.showPointerAtTarget(targetEl, event.pageX, false);
            }
        });

        // Start section selection mode on button press
        DOM.onSelect(this.sectionModeButton, this.enterSectionSelectMode.bind(this));

        // Toggle between pointer modes
        DOM.onSelect(this.modeToggles, event => {
            for (const section of this.modeSections) {
                const show = !section.contains(event.target);
                section.toggleAttribute('hidden', !show);
            }

            this.modeToggles.find(b => b !== event.target).focus();
        });
    }

    hidePointer() {
        this.pointer.style.display = null;
        this.showing = false;
    }

    /**
     * Move and display the pointer at the given element, targeting the given screen x-position if possible.
     * @param {Element} element
     * @param {Number} xPosition
     * @param {Boolean} keyboardMode
     */
    showPointerAtTarget(element, xPosition, keyboardMode) {
        this.updateForTarget(element);

        this.pointer.style.display = 'block';
        const targetBounds = element.getBoundingClientRect();
        const pointerBounds = this.pointer.getBoundingClientRect();

        const xTarget = Math.min(Math.max(xPosition, targetBounds.left), targetBounds.right);
        const xOffset = xTarget - (pointerBounds.width / 2);
        const yOffset = (targetBounds.top - pointerBounds.height) - 16;

        this.pointer.style.left = `${xOffset}px`;
        this.pointer.style.top = `${yOffset}px`;

        this.showing = true;
        this.isSelection = true;

        setTimeout(() => {
            this.isSelection = false;
        }, 100);

        const scrollListener = () => {
            this.hidePointer();
            window.removeEventListener('scroll', scrollListener, {passive: true});
        };

        element.parentElement.insertBefore(this.pointer, element);
        if (!keyboardMode) {
            window.addEventListener('scroll', scrollListener, {passive: true});
        }
    }

    /**
     * Update the pointer inputs/content for the given target element.
     * @param {?Element} element
     */
    updateForTarget(element) {
        const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
        const includeTag = `{{@${this.pageId}#${element.id}}}`;

        this.linkInput.value = permaLink;
        this.includeInput.value = includeTag;

        // Update anchor if present
        const editAnchor = this.pointer.querySelector('#pointer-edit');
        if (editAnchor && element) {
            const {editHref} = editAnchor.dataset;
            const elementId = element.id;

            // Get the first 50 characters.
            const queryContent = element.textContent && element.textContent.substring(0, 50);
            editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
        }
    }

    enterSectionSelectMode() {
        const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
        for (const section of sections) {
            section.setAttribute('tabindex', '0');
        }

        sections[0].focus();

        DOM.onEnterPress(sections, event => {
            this.showPointerAtTarget(event.target, 0, true);
            this.pointer.focus();
        });
    }

}