Formatting: A few more changes

This commit is contained in:
Scott Nonnenberg 2023-05-09 18:23:56 -07:00 committed by GitHub
parent b4caf67bf9
commit 2177a79080
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 393 additions and 159 deletions

View file

@ -8,7 +8,6 @@ import classNames from 'classnames';
import { Popper } from 'react-popper';
import { createPortal } from 'react-dom';
import type { VirtualElement } from '@popperjs/core';
import { pick } from 'lodash';
import * as log from '../../logging/log';
import * as Errors from '../../types/errors';
@ -16,7 +15,9 @@ import type { LocalizerType } from '../../types/Util';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import { SECOND } from '../../util/durations/constants';
const FADE_OUT_MS = 200;
const BUTTON_HOVER_TIMEOUT = 2 * SECOND;
const MENU_TEXT_BUFFER = 12; // pixels
// Note: Keyboard shortcuts are defined in the constructor below, and when using
// <FormattingButton /> below. They're also referenced in ShortcutGuide.tsx.
@ -29,6 +30,7 @@ const STRIKETHROUGH_CHAR = 'X';
type FormattingPickerOptions = {
i18n: LocalizerType;
isMenuEnabled: boolean;
isMouseDown?: boolean;
isEnabled: boolean;
isSpoilersEnabled: boolean;
platform: string;
@ -43,40 +45,6 @@ export enum QuillFormattingStyle {
spoiler = 'spoiler',
}
function findMaximumRect(rects: DOMRectList):
| {
x: number;
y: number;
height: number;
width: number;
}
| undefined {
const first = rects[0];
if (!first) {
return undefined;
}
let result = pick(first, ['top', 'left', 'right', 'bottom']);
for (let i = 1, max = rects.length; i < max; i += 1) {
const rect = rects[i];
result = {
top: Math.min(rect.top, result.top),
left: Math.min(rect.left, result.left),
bottom: Math.max(rect.bottom, result.bottom),
right: Math.max(rect.right, result.right),
};
}
return {
x: result.left,
y: result.top,
height: result.bottom - result.top,
width: result.right - result.left,
};
}
function getMetaKey(platform: string, i18n: LocalizerType) {
const isMacOS = platform === 'darwin';
@ -87,16 +55,27 @@ function getMetaKey(platform: string, i18n: LocalizerType) {
}
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;
@ -155,75 +134,105 @@ export class FormattingMenu {
this.onEditorChange();
}
scheduleRemoval(): void {
if (this.fadingOutTimeout) {
return;
}
this.fadingOutTimeout = setTimeout(() => {
this.referenceElement = undefined;
this.lastRect = undefined;
this.fadingOutTimeout = undefined;
this.render();
}, FADE_OUT_MS);
this.render();
}
cancelRemoval(): void {
if (this.fadingOutTimeout) {
clearTimeout(this.fadingOutTimeout);
this.fadingOutTimeout = undefined;
}
}
onEditorChange(): void {
if (!this.options.isMenuEnabled) {
this.referenceElement = undefined;
this.render();
this.scheduleRemoval();
return;
}
const isFocused = this.quill.hasFocus();
if (!isFocused) {
this.referenceElement = undefined;
this.render();
this.scheduleRemoval();
return;
}
const quillSelection = this.quill.getSelection();
if (!quillSelection || quillSelection.length === 0) {
this.referenceElement = undefined;
} else {
// a virtual reference to the text we are trying to format
this.referenceElement = {
getBoundingClientRect() {
const selection = window.getSelection();
this.scheduleRemoval();
return;
}
// 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);
// a virtual reference to the text we are trying to format
this.cancelRemoval();
this.referenceElement = {
getBoundingClientRect: () => {
const selection = window.getSelection();
const { activeElement } = document;
const editorElement = activeElement?.closest(
'.module-composition-input__input'
);
const editorRect = editorElement?.getClientRects()[0];
if (!editorRect) {
log.warn('No editor rect when showing formatting menu');
return new DOMRect();
// 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;
}
const rect = findMaximumRect(range.getClientRects());
if (!rect) {
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) - 10,
(rect.y || 0) - 10
);
const updatedHeight = rect.height + (rect.y - updatedY);
return DOMRect.fromRect({
x: rect.x,
y: updatedY,
height: updatedHeight,
width: rect.width,
});
log.warn('No editor rect when showing formatting menu');
return new DOMRect();
}
log.warn('No selection range 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();
}
@ -279,22 +288,15 @@ export class FormattingMenu {
const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this);
const toggleForStyle = this.toggleForStyle.bind(this);
const element = createPortal(
<Popper
placement="top"
referenceElement={this.referenceElement}
modifiers={[
{
name: 'fadeIn',
enabled: true,
phase: 'write',
fn({ state }) {
// eslint-disable-next-line no-param-reassign
state.elements.popper.style.opacity = '1';
},
},
]}
>
<Popper placement="top" referenceElement={this.referenceElement}>
{({ ref, style }) => {
const opacity =
style.transform &&
!this.options.isMouseDown &&
!this.fadingOutTimeout
? 1
: 0;
const [hasLongHovered, setHasLongHovered] =
React.useState<boolean>(false);
const onLongHover = React.useCallback(
@ -308,14 +310,14 @@ export class FormattingMenu {
<div
ref={ref}
className="module-composition-input__format-menu"
style={style}
style={{ ...style, opacity }}
role="menu"
tabIndex={0}
onMouseLeave={() => setHasLongHovered(false)}
>
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
isActive={isStyleEnabledInSelection(QuillFormattingStyle.bold)}
label={i18n('icu:Keyboard--composer--bold')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${BOLD_CHAR}`}
@ -325,7 +327,9 @@ export class FormattingMenu {
/>
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
isActive={isStyleEnabledInSelection(
QuillFormattingStyle.italic
)}
label={i18n('icu:Keyboard--composer--italic')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${ITALIC_CHAR}`}
@ -335,7 +339,9 @@ export class FormattingMenu {
/>
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
isActive={isStyleEnabledInSelection(
QuillFormattingStyle.strike
)}
label={i18n('icu:Keyboard--composer--strikethrough')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${STRIKETHROUGH_CHAR}`}
@ -345,7 +351,9 @@ export class FormattingMenu {
/>
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
isActive={isStyleEnabledInSelection(
QuillFormattingStyle.monospace
)}
label={i18n('icu:Keyboard--composer--monospace')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${MONOSPACE_CHAR}`}
@ -356,7 +364,9 @@ export class FormattingMenu {
{isSpoilersEnabled ? (
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
isActive={isStyleEnabledInSelection(
QuillFormattingStyle.spoiler
)}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${SPOILER_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--spoiler')}
@ -390,7 +400,7 @@ export class FormattingMenu {
function FormattingButton({
hasLongHovered,
isStyleEnabledInSelection,
isActive,
label,
onLongHover,
popupGuideText,
@ -399,9 +409,7 @@ function FormattingButton({
toggleForStyle,
}: {
hasLongHovered: boolean;
isStyleEnabledInSelection: (
style: QuillFormattingStyle
) => boolean | undefined;
isActive: boolean | undefined;
label: string;
onLongHover: (value: boolean) => unknown;
popupGuideText: string;
@ -413,6 +421,15 @@ function FormattingButton({
const timerRef = React.useRef<NodeJS.Timeout | undefined>();
const [isHovered, setIsHovered] = React.useState<boolean>(false);
React.useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
};
}, []);
return (
<>
{hasLongHovered && isHovered && buttonRef.current ? (
@ -434,7 +451,12 @@ function FormattingButton({
<button
ref={buttonRef}
type="button"
className="module-composition-input__format-menu__item"
className={classNames(
'module-composition-input__format-menu__item',
isActive
? 'module-composition-input__format-menu__item--active'
: null
)}
aria-label={label}
onClick={event => {
event.preventDefault();
@ -467,7 +489,7 @@ function FormattingButton({
className={classNames(
'module-composition-input__format-menu__item__icon',
`module-composition-input__format-menu__item__icon--${style}`,
isStyleEnabledInSelection(style)
isActive
? 'module-composition-input__format-menu__item__icon--active'
: null
)}