From 8c68e93e55f65b6a35afbf05005e734d94ccd6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 14 Mar 2023 17:22:23 +0100 Subject: [PATCH] Add draggable block plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/__tests__/me.test.ts | 4 +- .../actions/__tests__/notifications.test.ts | 2 +- app/soapbox/actions/domain-blocks.ts | 2 +- app/soapbox/actions/events.ts | 4 +- app/soapbox/components/account.tsx | 2 +- app/soapbox/components/status-list.tsx | 2 +- app/soapbox/components/ui/icon/svg-icon.tsx | 4 +- app/soapbox/components/upload.tsx | 2 +- .../chat-pane/__tests__/chat-pane.test.tsx | 2 +- app/soapbox/features/compose/editor/index.tsx | 4 +- .../editor/plugins/draggable-block-plugin.tsx | 369 ++++++++++++++++++ .../compose/editor/plugins/mention-plugin.tsx | 3 - .../editor/plugins/typeahead-menu-plugin.tsx | 38 +- .../compose/editor/utils/is-html-element.ts | 4 + .../features/compose/editor/utils/point.ts | 58 +++ .../features/compose/editor/utils/rect.ts | 164 ++++++++ .../event/components/event-header.tsx | 2 +- .../modals/report-modal/report-modal.tsx | 4 +- .../service-worker/web-push-notifications.ts | 2 +- 19 files changed, 632 insertions(+), 40 deletions(-) create mode 100644 app/soapbox/features/compose/editor/plugins/draggable-block-plugin.tsx create mode 100644 app/soapbox/features/compose/editor/utils/is-html-element.ts create mode 100644 app/soapbox/features/compose/editor/utils/point.ts create mode 100644 app/soapbox/features/compose/editor/utils/rect.ts diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts index d4dc1d31f5..d2b9be179d 100644 --- a/app/soapbox/actions/__tests__/me.test.ts +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -64,7 +64,7 @@ describe('fetchMe()', () => { }); it('dispatches the correct actions', async() => { - const expectedActions = [ + const expectedActions = [ { type: 'ME_FETCH_REQUEST' }, { type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl }, { type: 'ACCOUNTS_IMPORT', accounts: [] }, @@ -100,7 +100,7 @@ describe('patchMe()', () => { }); it('dispatches the correct actions', async() => { - const expectedActions = [ + const expectedActions = [ { type: 'ME_PATCH_REQUEST' }, { type: 'ACCOUNTS_IMPORT', accounts: [] }, { diff --git a/app/soapbox/actions/__tests__/notifications.test.ts b/app/soapbox/actions/__tests__/notifications.test.ts index 2d0dd93569..5bc282add0 100644 --- a/app/soapbox/actions/__tests__/notifications.test.ts +++ b/app/soapbox/actions/__tests__/notifications.test.ts @@ -21,7 +21,7 @@ describe('markReadNotifications()', () => { const store = mockStore(state); - const expectedActions = [{ + const expectedActions = [{ type: 'MARKER_SAVE_REQUEST', marker: { notifications: { diff --git a/app/soapbox/actions/domain-blocks.ts b/app/soapbox/actions/domain-blocks.ts index 4308edec71..0fbdf96e33 100644 --- a/app/soapbox/actions/domain-blocks.ts +++ b/app/soapbox/actions/domain-blocks.ts @@ -151,7 +151,7 @@ const expandDomainBlocksSuccess = (domains: string[], next: string | null) => ({ next, }); -const expandDomainBlocksFail = (error: AxiosError) => ({ +const expandDomainBlocksFail = (error: AxiosError) => ({ type: DOMAIN_BLOCKS_EXPAND_FAIL, error, }); diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index 44a9ae2079..3b1f400a9b 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -612,7 +612,7 @@ const fetchRecentEvents = () => next: next ? next.uri : null, }); }).catch(error => { - dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error }); + dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error }); }); }; @@ -633,7 +633,7 @@ const fetchJoinedEvents = () => next: next ? next.uri : null, }); }).catch(error => { - dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error }); + dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error }); }); }; diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 0a435f48f3..2421f4898b 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -72,7 +72,7 @@ export interface IAccount { actionAlignment?: 'center' | 'top' actionIcon?: string actionTitle?: string - /** Override other actions for specificity like mute/unmute. */ + /** Override other actions for specificity like mute/unmute. */ actionType?: 'muting' | 'blocking' | 'follow_request' avatarSize?: number hidden?: boolean diff --git a/app/soapbox/components/status-list.tsx b/app/soapbox/components/status-list.tsx index 937dac8de3..30ead15d7f 100644 --- a/app/soapbox/components/status-list.tsx +++ b/app/soapbox/components/status-list.tsx @@ -26,7 +26,7 @@ interface IStatusList extends Omit { scrollKey: string /** List of status IDs to display. */ statusIds: ImmutableOrderedSet - /** Last _unfiltered_ status ID (maxId) for pagination. */ + /** Last _unfiltered_ status ID (maxId) for pagination. */ lastStatusId?: string /** Pinned statuses to show at the top of the feed. */ featuredStatusIds?: ImmutableOrderedSet diff --git a/app/soapbox/components/ui/icon/svg-icon.tsx b/app/soapbox/components/ui/icon/svg-icon.tsx index eefe88d0d8..004177fd5e 100644 --- a/app/soapbox/components/ui/icon/svg-icon.tsx +++ b/app/soapbox/components/ui/icon/svg-icon.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports +import InlineSVG, { Props as InlineSVGProps } from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports -interface ISvgIcon { +interface ISvgIcon extends InlineSVGProps { /** Class name for the */ className?: string /** Tooltip text for the icon. */ diff --git a/app/soapbox/components/upload.tsx b/app/soapbox/components/upload.tsx index da452276d3..a7477585fc 100644 --- a/app/soapbox/components/upload.tsx +++ b/app/soapbox/components/upload.tsx @@ -152,7 +152,7 @@ const Upload: React.FC = ({ {({ scale }) => (
(({ composeId, condensed, onF contentEditable={
(({ composeId, condensed, onF {features.richText && } {features.richText && floatingAnchorElem && ( <> + diff --git a/app/soapbox/features/compose/editor/plugins/draggable-block-plugin.tsx b/app/soapbox/features/compose/editor/plugins/draggable-block-plugin.tsx new file mode 100644 index 0000000000..655cb3578b --- /dev/null +++ b/app/soapbox/features/compose/editor/plugins/draggable-block-plugin.tsx @@ -0,0 +1,369 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { eventFiles } from '@lexical/rich-text'; +import { mergeRegister } from '@lexical/utils'; +import clsx from 'clsx'; +import { + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + DRAGOVER_COMMAND, + DROP_COMMAND, + LexicalEditor, +} from 'lexical'; +import * as React from 'react'; +import { DragEvent as ReactDragEvent, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; + +import isHTMLElement from '../utils/is-html-element'; +import Point from '../utils/point'; +import Rect from '../utils/rect'; + +const SPACE = 4; +const TARGET_LINE_HALF_HEIGHT = 2; +const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'; +const TEXT_BOX_HORIZONTAL_PADDING = 28; + +const Downward = 1; +const Upward = -1; +const Indeterminate = 0; + +let prevIndex = Infinity; + +const getCurrentIndex = (keysLength: number): number => { + if (keysLength === 0) { + return Infinity; + } + if (prevIndex >= 0 && prevIndex < keysLength) { + return prevIndex; + } + + return Math.floor(keysLength / 2); +}; + +const getTopLevelNodeKeys = (editor: LexicalEditor): string[] => editor.getEditorState().read(() => $getRoot().getChildrenKeys()); + +const getBlockElement = ( + anchorElem: HTMLElement, + editor: LexicalEditor, + event: MouseEvent, +): HTMLElement | null => { + const anchorElementRect = anchorElem.getBoundingClientRect(); + const topLevelNodeKeys = getTopLevelNodeKeys(editor); + + let blockElem: HTMLElement | null = null; + + editor.getEditorState().read(() => { + let index = getCurrentIndex(topLevelNodeKeys.length); + let direction = Indeterminate; + + while (index >= 0 && index < topLevelNodeKeys.length) { + const key = topLevelNodeKeys[index]; + const elem = editor.getElementByKey(key); + if (elem === null) { + break; + } + const point = new Point(event.x, event.y); + const domRect = Rect.fromDOM(elem); + const { marginTop, marginBottom } = window.getComputedStyle(elem); + + const rect = domRect.generateNewRect({ + bottom: domRect.bottom + parseFloat(marginBottom), + left: anchorElementRect.left, + right: anchorElementRect.right, + top: domRect.top - parseFloat(marginTop), + }); + + const { + result, + reason: { isOnTopSide, isOnBottomSide }, + } = rect.contains(point); + + if (result) { + blockElem = elem; + prevIndex = index; + break; + } + + if (direction === Indeterminate) { + if (isOnTopSide) { + direction = Upward; + } else if (isOnBottomSide) { + direction = Downward; + } else { + // stop search block element + direction = Infinity; + } + } + + index += direction; + } + }); + + return blockElem; +}; + +const isOnMenu = (element: HTMLElement): boolean => !!element.closest('.draggable-block-menu'); + +const setMenuPosition = ( + targetElem: HTMLElement | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, +) => { + if (!targetElem) { + floatingElem.style.opacity = '0'; + floatingElem.style.transform = 'translate(-10000px, -10000px)'; + return; + } + + const targetRect = targetElem.getBoundingClientRect(); + const targetStyle = window.getComputedStyle(targetElem); + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + + const top = + targetRect.top + + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - + anchorElementRect.top; + + const left = SPACE; + + floatingElem.style.opacity = '1'; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; +}; + +const setDragImage = ( + dataTransfer: DataTransfer, + draggableBlockElem: HTMLElement, +) => { + const { transform } = draggableBlockElem.style; + + // Remove dragImage borders + draggableBlockElem.style.transform = 'translateZ(0)'; + dataTransfer.setDragImage(draggableBlockElem, 0, 0); + + setTimeout(() => { + draggableBlockElem.style.transform = transform; + }); +}; + +const setTargetLine = ( + targetLineElem: HTMLElement, + targetBlockElem: HTMLElement, + mouseY: number, + anchorElem: HTMLElement, +) => { + const targetStyle = window.getComputedStyle(targetBlockElem); + const { top: targetBlockElemTop, height: targetBlockElemHeight } = + targetBlockElem.getBoundingClientRect(); + const { top: anchorTop, width: anchorWidth } = + anchorElem.getBoundingClientRect(); + + let lineTop = targetBlockElemTop; + // At the bottom of the target + if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) { + lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom); + } else { + lineTop -= parseFloat(targetStyle.marginTop); + } + + const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; + const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE; + + targetLineElem.style.transform = `translate(${left}px, ${top}px)`; + targetLineElem.style.width = `${ + anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2 + }px`; + targetLineElem.style.opacity = '.4'; +}; + +const hideTargetLine = (targetLineElem: HTMLElement | null) => { + if (targetLineElem) { + targetLineElem.style.opacity = '0'; + targetLineElem.style.transform = 'translate(-10000px, -10000px)'; + } +}; + +const useDraggableBlockMenu = ( + editor: LexicalEditor, + anchorElem: HTMLElement, + isEditable: boolean, +): JSX.Element => { + const scrollerElem = anchorElem.parentElement; + + const menuRef = useRef(null); + const targetLineRef = useRef(null); + const [draggableBlockElem, setDraggableBlockElem] = + useState(null); + + useEffect(() => { + function onMouseMove(event: MouseEvent) { + const target = event.target; + if (!isHTMLElement(target)) { + setDraggableBlockElem(null); + return; + } + + if (isOnMenu(target)) { + return; + } + + const _draggableBlockElem = getBlockElement(anchorElem, editor, event); + + setDraggableBlockElem(_draggableBlockElem); + } + + function onMouseLeave() { + setDraggableBlockElem(null); + } + + scrollerElem?.addEventListener('mousemove', onMouseMove); + scrollerElem?.addEventListener('mouseleave', onMouseLeave); + + return () => { + scrollerElem?.removeEventListener('mousemove', onMouseMove); + scrollerElem?.removeEventListener('mouseleave', onMouseLeave); + }; + }, [scrollerElem, anchorElem, editor]); + + useEffect(() => { + if (menuRef.current) { + setMenuPosition(draggableBlockElem, menuRef.current, anchorElem); + } + }, [anchorElem, draggableBlockElem]); + + useEffect(() => { + function onDragover(event: DragEvent): boolean { + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const { pageY, target } = event; + if (!isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event); + const targetLineElem = targetLineRef.current; + if (targetBlockElem === null || targetLineElem === null) { + return false; + } + setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); + // Prevent default event to be able to trigger onDrop events + event.preventDefault(); + return true; + } + + function onDrop(event: DragEvent): boolean { + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const { target, dataTransfer, pageY } = event; + const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''; + const draggedNode = $getNodeByKey(dragData); + if (!draggedNode) { + return false; + } + if (!isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event); + if (!targetBlockElem) { + return false; + } + const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); + if (!targetNode) { + return false; + } + if (targetNode === draggedNode) { + return true; + } + const { top, height } = targetBlockElem.getBoundingClientRect(); + const shouldInsertAfter = pageY - top > height / 2; + if (shouldInsertAfter) { + targetNode.insertAfter(draggedNode); + } else { + targetNode.insertBefore(draggedNode); + } + setDraggableBlockElem(null); + + return true; + } + + return mergeRegister( + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + return onDragover(event); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + return onDrop(event); + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [anchorElem, editor]); + + function onDragStart(event: ReactDragEvent): void { + const dataTransfer = event.dataTransfer; + if (!dataTransfer || !draggableBlockElem) { + return; + } + setDragImage(dataTransfer, draggableBlockElem); + let nodeKey = ''; + editor.update(() => { + const node = $getNearestNodeFromDOMNode(draggableBlockElem); + if (node) { + nodeKey = node.getKey(); + } + }); + dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); + } + + function onDragEnd(): void { + hideTargetLine(targetLineRef.current); + } + + return createPortal( + <> +
+ +
+
+ , + anchorElem, + ); +}; + +export default function DraggableBlockPlugin({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + return useDraggableBlockMenu(editor, anchorElem, editor._editable); +} diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx index 8825ba88cd..c0fb666649 100644 --- a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx @@ -161,8 +161,6 @@ function checkForAtSignMentions( minMatchLength: number, ): QueryMatch | null { let match = AtSignMentionsRegex.exec(text); - console.log(text, match); - if (match === null) { match = AtSignMentionsRegexAliasRegex.exec(text); } @@ -278,7 +276,6 @@ export function MentionPlugin(): JSX.Element | null { const checkForMentionMatch = useCallback( (text: string) => { - console.log(text); const mentionMatch = getPossibleQueryMatch(text); const slashMatch = checkForSlashTriggerMatch(text, editor); return !slashMatch && mentionMatch ? mentionMatch : null; diff --git a/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx b/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx index 6e92c9dd37..601c8acdcb 100644 --- a/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx @@ -125,7 +125,7 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean { const startOffset = leadOffset; const endOffset = domSelection.anchorOffset; - if (anchorNode == null || endOffset == null) { + if (!anchorNode || !endOffset) { return false; } @@ -142,9 +142,7 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean { function getQueryTextForSearch(editor: LexicalEditor): string | null { let text = null; editor.getEditorState().read(() => { - console.log(editor.getEditorState().toJSON()); const selection = $getSelection(); - console.log(selection); if (!$isRangeSelection(selection)) { return; } @@ -289,10 +287,10 @@ export function useDynamicPositioning( ) { const [editor] = useLexicalComposerContext(); useEffect(() => { - if (targetElement != null && resolution != null) { + if (targetElement && resolution) { const rootElement = editor.getRootElement(); const rootScrollParent = - rootElement != null + rootElement ? getScrollParent(rootElement, false) : document.body; let ticking = false; @@ -314,7 +312,7 @@ export function useDynamicPositioning( ); if (isInView !== previousIsInView) { previousIsInView = isInView; - if (onVisibilityChange != null) { + if (onVisibilityChange) { onVisibilityChange(isInView); } } @@ -423,7 +421,7 @@ function LexicalPopoverMenu({ editor.registerCommand( SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, ({ option }) => { - if (option.ref && option.ref.current != null) { + if (option.ref && option.ref.current) { scrollIntoViewIfNeeded(option.ref.current); return true; } @@ -446,7 +444,7 @@ function LexicalPopoverMenu({ selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0; updateSelectedIndex(newSelectedIndex); const option = options[newSelectedIndex]; - if (option.ref != null && option.ref.current) { + if (option.ref && option.ref.current) { editor.dispatchCommand( SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, { @@ -471,7 +469,7 @@ function LexicalPopoverMenu({ selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1; updateSelectedIndex(newSelectedIndex); const option = options[newSelectedIndex]; - if (option.ref != null && option.ref.current) { + if (option.ref && option.ref.current) { scrollIntoViewIfNeeded(option.ref.current); } event.preventDefault(); @@ -499,7 +497,7 @@ function LexicalPopoverMenu({ if ( options === null || selectedIndex === null || - options[selectedIndex] == null + !options[selectedIndex] ) { return false; } @@ -516,7 +514,7 @@ function LexicalPopoverMenu({ if ( options === null || selectedIndex === null || - options[selectedIndex] == null + !options[selectedIndex] ) { return false; } @@ -612,7 +610,7 @@ function useMenuAnchorRef( containerDiv.style.width = `${width}px`; if (!containerDiv.isConnected) { - if (className != null) { + if (className) { containerDiv.className = className; } containerDiv.setAttribute('aria-label', 'Typeahead menu'); @@ -706,7 +704,7 @@ export function TypeaheadMenuPlugin({ const closeTypeahead = useCallback(() => { setResolution(null); - if (onClose != null && resolution !== null) { + if (onClose && resolution !== null) { onClose(); } }, [onClose, resolution]); @@ -714,7 +712,7 @@ export function TypeaheadMenuPlugin({ const openTypeahead = useCallback( (res: Resolution) => { setResolution(res); - if (onOpen != null && resolution === null) { + if (onOpen && resolution === null) { onOpen(res); } }, @@ -821,7 +819,7 @@ export function LexicalNodeMenuPlugin({ const closeNodeMenu = useCallback(() => { setResolution(null); - if (onClose != null && resolution !== null) { + if (onClose && resolution !== null) { onClose(); } }, [onClose, resolution]); @@ -829,7 +827,7 @@ export function LexicalNodeMenuPlugin({ const openNodeMenu = useCallback( (res: Resolution) => { setResolution(res); - if (onOpen != null && resolution === null) { + if (onOpen && resolution === null) { onOpen(res); } }, @@ -841,9 +839,9 @@ export function LexicalNodeMenuPlugin({ editor.update(() => { const node = $getNodeByKey(nodeKey); const domElement = editor.getElementByKey(nodeKey); - if (node != null && domElement != null) { + if (node && domElement) { const text = node.getTextContent(); - if (resolution == null || resolution.match.matchingString !== text) { + if (!resolution || resolution.match.matchingString !== text) { startTransition(() => openNodeMenu({ getRect: () => domElement.getBoundingClientRect(), @@ -857,7 +855,7 @@ export function LexicalNodeMenuPlugin({ } } }); - } else if (nodeKey == null && resolution != null) { + } else if (!nodeKey && resolution) { closeNodeMenu(); } }, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]); @@ -867,7 +865,7 @@ export function LexicalNodeMenuPlugin({ }, [positionOrCloseMenu, nodeKey]); useEffect(() => { - if (nodeKey != null) { + if (nodeKey) { return editor.registerUpdateListener(({ dirtyElements }) => { if (dirtyElements.get(nodeKey)) { positionOrCloseMenu(); diff --git a/app/soapbox/features/compose/editor/utils/is-html-element.ts b/app/soapbox/features/compose/editor/utils/is-html-element.ts new file mode 100644 index 0000000000..f7ff6f6394 --- /dev/null +++ b/app/soapbox/features/compose/editor/utils/is-html-element.ts @@ -0,0 +1,4 @@ +const isHTMLElement = (x: unknown): x is HTMLElement => x instanceof HTMLElement; + +export default isHTMLElement; +export { isHTMLElement }; diff --git a/app/soapbox/features/compose/editor/utils/point.ts b/app/soapbox/features/compose/editor/utils/point.ts new file mode 100644 index 0000000000..4cd951c825 --- /dev/null +++ b/app/soapbox/features/compose/editor/utils/point.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +class Point { + + private readonly _x: number; + private readonly _y: number; + + constructor(x: number, y: number) { + this._x = x; + this._y = y; + } + + get x(): number { + return this._x; + } + + get y(): number { + return this._y; + } + + public equals({ x, y }: Point): boolean { + return this.x === x && this.y === y; + } + + public calcDeltaXTo({ x }: Point): number { + return this.x - x; + } + + public calcDeltaYTo({ y }: Point): number { + return this.y - y; + } + + public calcHorizontalDistanceTo(point: Point): number { + return Math.abs(this.calcDeltaXTo(point)); + } + + public calcVerticalDistance(point: Point): number { + return Math.abs(this.calcDeltaYTo(point)); + } + + public calcDistanceTo(point: Point): number { + return Math.sqrt( + Math.pow(this.calcDeltaXTo(point), 2) + + Math.pow(this.calcDeltaYTo(point), 2), + ); + } + +} + +const isPoint = (x: unknown): x is Point => x instanceof Point; + +export default Point; +export { Point, isPoint }; diff --git a/app/soapbox/features/compose/editor/utils/rect.ts b/app/soapbox/features/compose/editor/utils/rect.ts new file mode 100644 index 0000000000..1a7f4b769a --- /dev/null +++ b/app/soapbox/features/compose/editor/utils/rect.ts @@ -0,0 +1,164 @@ +/* eslint-disable no-dupe-class-members */ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { isPoint, Point } from './point'; + +type ContainsPointReturn = { + result: boolean + reason: { + isOnTopSide: boolean + isOnBottomSide: boolean + isOnLeftSide: boolean + isOnRightSide: boolean + } +}; + +class Rect { + + private readonly _left: number; + private readonly _top: number; + private readonly _right: number; + private readonly _bottom: number; + + constructor(left: number, top: number, right: number, bottom: number) { + const [physicTop, physicBottom] = + top <= bottom ? [top, bottom] : [bottom, top]; + + const [physicLeft, physicRight] = + left <= right ? [left, right] : [right, left]; + + this._top = physicTop; + this._right = physicRight; + this._left = physicLeft; + this._bottom = physicBottom; + } + + get top(): number { + return this._top; + } + + get right(): number { + return this._right; + } + + get bottom(): number { + return this._bottom; + } + + get left(): number { + return this._left; + } + + get width(): number { + return Math.abs(this._left - this._right); + } + + get height(): number { + return Math.abs(this._bottom - this._top); + } + + public equals({ top, left, bottom, right }: Rect): boolean { + return ( + top === this._top && + bottom === this._bottom && + left === this._left && + right === this._right + ); + } + + public contains({ x, y }: Point): ContainsPointReturn; + public contains({ top, left, bottom, right }: Rect): boolean; + public contains(target: Point | Rect): boolean | ContainsPointReturn { + if (isPoint(target)) { + const { x, y } = target; + + const isOnTopSide = y < this._top; + const isOnBottomSide = y > this._bottom; + const isOnLeftSide = x < this._left; + const isOnRightSide = x > this._right; + + const result = + !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide; + + return { + reason: { + isOnBottomSide, + isOnLeftSide, + isOnRightSide, + isOnTopSide, + }, + result, + }; + } else { + const { top, left, bottom, right } = target; + + return ( + top >= this._top && + top <= this._bottom && + bottom >= this._top && + bottom <= this._bottom && + left >= this._left && + left <= this._right && + right >= this._left && + right <= this._right + ); + } + } + + public intersectsWith(rect: Rect): boolean { + const { left: x1, top: y1, width: w1, height: h1 } = rect; + const { left: x2, top: y2, width: w2, height: h2 } = this; + const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2; + const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2; + const minX = x1 <= x2 ? x1 : x2; + const minY = y1 <= y2 ? y1 : y2; + return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2; + } + + public generateNewRect({ + left = this.left, + top = this.top, + right = this.right, + bottom = this.bottom, + }): Rect { + return new Rect(left, top, right, bottom); + } + + static fromLTRB( + left: number, + top: number, + right: number, + bottom: number, + ): Rect { + return new Rect(left, top, right, bottom); + } + + static fromLWTH( + left: number, + width: number, + top: number, + height: number, + ): Rect { + return new Rect(left, top, left + width, top + height); + } + + static fromPoints(startPoint: Point, endPoint: Point): Rect { + const { y: top, x: left } = startPoint; + const { y: bottom, x: right } = endPoint; + return Rect.fromLTRB(left, top, right, bottom); + } + + static fromDOM(dom: HTMLElement): Rect { + const { top, width, left, height } = dom.getBoundingClientRect(); + return Rect.fromLWTH(left, width, top, height); + } + +} + +export default Rect; +export { Rect }; diff --git a/app/soapbox/features/event/components/event-header.tsx b/app/soapbox/features/event/components/event-header.tsx index e18d342fec..ee2ff2b440 100644 --- a/app/soapbox/features/event/components/event-header.tsx +++ b/app/soapbox/features/event/components/event-header.tsx @@ -109,7 +109,7 @@ const EventHeader: React.FC = ({ status }) => { }; const handleCopy = () => { - const { uri } = status; + const { uri } = status; copy(uri); }; diff --git a/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx b/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx index ed9c5c4115..8465d8b847 100644 --- a/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx +++ b/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx @@ -24,8 +24,8 @@ const messages = defineMessages({ submit: { id: 'report.submit', defaultMessage: 'Submit' }, reportContext: { id: 'report.chatMessage.context', defaultMessage: 'When reporting a user’s message, the five messages before and five messages after the one selected will be passed along to our moderation team for context.' }, reportMessage: { id: 'report.chatMessage.title', defaultMessage: 'Report message' }, - cancel: { id: 'common.cancel', defaultMessage: 'Cancel' }, - previous: { id: 'report.previous', defaultMessage: 'Previous' }, + cancel: { id: 'common.cancel', defaultMessage: 'Cancel' }, + previous: { id: 'report.previous', defaultMessage: 'Previous' }, }); enum Steps { diff --git a/app/soapbox/service-worker/web-push-notifications.ts b/app/soapbox/service-worker/web-push-notifications.ts index 46ba7aa38d..52c6ce9b03 100644 --- a/app/soapbox/service-worker/web-push-notifications.ts +++ b/app/soapbox/service-worker/web-push-notifications.ts @@ -212,7 +212,7 @@ const findBestClient = (clients: readonly WindowClient[]): WindowClient => { return focusedClient || visibleClient || clients[0]; }; -/** Update a notification with CW to display the full status. */ +/** Update a notification with CW to display the full status. */ const expandNotification = (notification: Notification) => { const newNotification = cloneNotification(notification);