// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type Quill from 'quill'; import type { KeyboardContext } from 'quill'; import type Op from 'quill-delta/dist/Op'; import React from 'react'; import classNames from 'classnames'; import { Popper } from 'react-popper'; import { createPortal } from 'react-dom'; import type { VirtualElement } from '@popperjs/core'; import { isString } from 'lodash'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; import type { LocalizerType } from '../../types/Util'; import { handleOutsideClick } from '../../util/handleOutsideClick'; const MENU_FADE_OUT_MS = 200; const POPUP_GUIDE_FADE_MS = 120; const BUTTON_HOVER_TIMEOUT_MS = 900; const MENU_TEXT_BUFFER = 12; // pixels // Note: Keyboard shortcuts are defined in the constructor below, and when using // below. They're also referenced in ShortcutGuide.tsx. const BOLD_CHAR = 'B'; const ITALIC_CHAR = 'I'; const MONOSPACE_CHAR = 'E'; const SPOILER_CHAR = 'B'; const STRIKETHROUGH_CHAR = 'X'; type FormattingPickerOptions = { i18n: LocalizerType; isMenuEnabled: boolean; isMouseDown?: boolean; isEnabled: boolean; isSpoilersEnabled: boolean; platform: string; setFormattingChooserElement: (element: JSX.Element | null) => void; }; export enum QuillFormattingStyle { bold = 'bold', italic = 'italic', monospace = 'monospace', strike = 'strike', spoiler = 'spoiler', } function getMetaKey(platform: string, i18n: LocalizerType) { const isMacOS = platform === 'darwin'; if (isMacOS) { return '⌘'; } return i18n('icu:Keyboard--Key--ctrl'); } function isAllNewlines(ops: Array): boolean { return ops.every(isNewlineOnlyOp); } export function isNewlineOnlyOp(op: Op): boolean { return isString(op.insert) && /^\n+$/g.test(op.insert); } export class FormattingMenu { // Cache the results of our virtual elements's last rect calculation lastRect: DOMRect | undefined; // Keep a references to our originally passed (or updated) options options: FormattingPickerOptions; // Used to dismiss our menu if we click outside it outsideClickDestructor?: () => void; // Maintaining a direct reference to quill quill: Quill; // The element we hand to Popper to position the menu referenceElement: VirtualElement | undefined; // The host for our portal root: HTMLDivElement; // Timer to track an animated fade-out, then DOM removal fadingOutTimeout?: NodeJS.Timeout; constructor(quill: Quill, options: FormattingPickerOptions) { this.quill = quill; this.options = options; this.root = document.body.appendChild(document.createElement('div')); this.quill.on('editor-change', this.onEditorChange.bind(this)); // We override these keybindings, which means that we need to move their priority // above the built-in shortcuts, which don't exactly do what we want. const boldCharCode = BOLD_CHAR.charCodeAt(0); this.quill.keyboard.addBinding( { key: BOLD_CHAR, shortKey: true }, (_range, context) => this.toggleForStyle(QuillFormattingStyle.bold, context) ); quill.keyboard.bindings[boldCharCode].unshift( quill.keyboard.bindings[boldCharCode].pop() ); const italicCharCode = ITALIC_CHAR.charCodeAt(0); this.quill.keyboard.addBinding( { key: ITALIC_CHAR, shortKey: true }, (_range, context) => this.toggleForStyle(QuillFormattingStyle.italic, context) ); quill.keyboard.bindings[italicCharCode].unshift( quill.keyboard.bindings[italicCharCode].pop() ); // No need for changing priority for these new keybindings this.quill.keyboard.addBinding( { key: MONOSPACE_CHAR, shortKey: true }, (_range, context) => this.toggleForStyle(QuillFormattingStyle.monospace, context) ); this.quill.keyboard.addBinding( { key: STRIKETHROUGH_CHAR, shortKey: true, shiftKey: true }, (_range, context) => this.toggleForStyle(QuillFormattingStyle.strike, context) ); this.quill.keyboard.addBinding( { key: SPOILER_CHAR, shortKey: true, shiftKey: true }, (_range, context) => this.toggleForStyle(QuillFormattingStyle.spoiler, context) ); } destroy(): void { this.root.remove(); } updateOptions(options: Partial): void { this.options = { ...this.options, ...options }; this.onEditorChange(); } scheduleRemoval(): void { // Nothing to do if (!this.referenceElement) { return; } // Already scheduled if (this.fadingOutTimeout) { return; } this.fadingOutTimeout = setTimeout(() => { this.referenceElement = undefined; this.lastRect = undefined; this.fadingOutTimeout = undefined; this.render(); }, MENU_FADE_OUT_MS); this.render(); } cancelRemoval(): void { if (this.fadingOutTimeout) { clearTimeout(this.fadingOutTimeout); this.fadingOutTimeout = undefined; } } onEditorChange(): void { if (!this.options.isMenuEnabled || !this.options.isEnabled) { this.scheduleRemoval(); return; } const isFocused = this.quill.hasFocus(); if (!isFocused) { this.scheduleRemoval(); return; } const quillSelection = this.quill.getSelection(); if (!quillSelection || quillSelection.length === 0) { this.scheduleRemoval(); return; } // Note: we special-case all-newline ops because Quill doesn't apply styles to them const contents = this.quill.getContents( quillSelection.index, quillSelection.length ); if (isAllNewlines(contents.ops)) { this.scheduleRemoval(); return; } // a virtual reference to the text we are trying to format this.cancelRemoval(); this.referenceElement = { getBoundingClientRect: () => { const selection = window.getSelection(); // there's a selection and at least one range if (selection != null && selection.rangeCount !== 0) { // grab the first range, the one the user is actually on right now const range = selection.getRangeAt(0); const { activeElement } = document; const editorElement = activeElement?.closest( '.module-composition-input__input' ); const editorRect = editorElement?.getClientRects()[0]; if (!editorRect) { // Note: this will happen when a user dismisses a panel; and if we don't // cache here, the formatting menu will show in the very top-left if (this.lastRect) { return this.lastRect; } log.warn('No editor rect when showing formatting menu'); return new DOMRect(); } const rect = range.getBoundingClientRect(); if (!rect) { if (this.lastRect) { return this.lastRect; } log.warn('No maximum rect when showing formatting menu'); return new DOMRect(); } // If we've scrolled down and the top of the composer text is invisible, above // where the editor ends, we fix the popover so it stays connected to the // visible editor. Important for the 'Cmd-A' scenario when scrolled down. const updatedY = Math.max( (editorRect.y || 0) - MENU_TEXT_BUFFER, (rect.y || 0) - MENU_TEXT_BUFFER ); const updatedHeight = rect.height + (rect.y - updatedY); this.lastRect = DOMRect.fromRect({ x: rect.x, y: updatedY, height: updatedHeight, width: rect.width, }); return this.lastRect; } log.warn('No selection range when showing formatting menu'); return new DOMRect(); }, }; this.render(); } isStyleEnabledInSelection(style: QuillFormattingStyle): boolean | undefined { const selection = this.quill.getSelection(); if (!selection || !selection.length) { return; } const contents = this.quill.getContents(selection.index, selection.length); // Note: we special-case single \n ops because Quill doesn't apply formatting to them if (isAllNewlines(contents.ops)) { return false; } return contents.ops.every( op => op.attributes?.[style] || isNewlineOnlyOp(op) ); } toggleForStyle(style: QuillFormattingStyle, context?: KeyboardContext): void { if (!this.options.isEnabled) { return; } if ( !this.options.isSpoilersEnabled && style === QuillFormattingStyle.spoiler ) { return; } try { const isEnabled = context ? Boolean(context.format[style]) : this.isStyleEnabledInSelection(style); if (isEnabled === undefined) { return; } this.quill.format(style, !isEnabled); } catch (error) { log.error('toggleForStyle error:', Errors.toLogFormat(error)); } } render(): void { if (!this.referenceElement) { this.outsideClickDestructor?.(); this.outsideClickDestructor = undefined; this.options.setFormattingChooserElement(null); return; } const { i18n, isSpoilersEnabled, platform } = this.options; const metaKey = getMetaKey(platform, i18n); const shiftKey = i18n('icu:Keyboard--Key--shift'); // showing the popup format menu const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this); const toggleForStyle = this.toggleForStyle.bind(this); const element = createPortal( {({ ref, style }) => { const opacity = style.transform && !this.options.isMouseDown && !this.fadingOutTimeout ? 1 : 0; const [hasLongHovered, setHasLongHovered] = React.useState(false); const onLongHover = React.useCallback( (value: boolean) => { setHasLongHovered(value); }, [setHasLongHovered] ); return (
setHasLongHovered(false)} > {isSpoilersEnabled ? ( ) : null}
); }}
, this.root ); // Just to make sure that we don't propagate outside clicks until this is closed. this.outsideClickDestructor?.(); this.outsideClickDestructor = handleOutsideClick( () => { return true; }, { name: 'quill.emoji.completion', containerElements: [this.root], } ); this.options.setFormattingChooserElement(element); } } function FormattingButton({ hasLongHovered, isActive, label, onLongHover, popupGuideText, popupGuideShortcut, style, toggleForStyle, }: { hasLongHovered: boolean; isActive: boolean | undefined; label: string; onLongHover: (value: boolean) => unknown; popupGuideText: string; popupGuideShortcut: string; style: QuillFormattingStyle; toggleForStyle: (style: QuillFormattingStyle) => unknown; }): JSX.Element { const buttonRef = React.useRef(null); const [isHovered, setIsHovered] = React.useState(false); const hoverTimerRef = React.useRef(); const [isFadingOut, setIsFadingOut] = React.useState(false); const fadeOutTimerRef = React.useRef(); React.useEffect(() => { return () => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = undefined; } if (fadeOutTimerRef.current) { clearTimeout(fadeOutTimerRef.current); fadeOutTimerRef.current = undefined; } }; }, []); return ( <> {(isFadingOut || (hasLongHovered && isHovered)) && buttonRef.current ? ( {({ ref, style: popperStyles }) => { const opacity = !popperStyles.transform || isFadingOut ? 0 : 1; return (
{popupGuideText}
{popupGuideShortcut}
); }}
) : null} ); }