Add draggable block plugin
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
9ce079ce7e
commit
8c68e93e55
19 changed files with 632 additions and 40 deletions
|
@ -64,7 +64,7 @@ describe('fetchMe()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches the correct actions', async() => {
|
it('dispatches the correct actions', async() => {
|
||||||
const expectedActions = [
|
const expectedActions = [
|
||||||
{ type: 'ME_FETCH_REQUEST' },
|
{ type: 'ME_FETCH_REQUEST' },
|
||||||
{ type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl },
|
{ type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl },
|
||||||
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
||||||
|
@ -100,7 +100,7 @@ describe('patchMe()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches the correct actions', async() => {
|
it('dispatches the correct actions', async() => {
|
||||||
const expectedActions = [
|
const expectedActions = [
|
||||||
{ type: 'ME_PATCH_REQUEST' },
|
{ type: 'ME_PATCH_REQUEST' },
|
||||||
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
|
||||||
{
|
{
|
||||||
|
|
|
@ -21,7 +21,7 @@ describe('markReadNotifications()', () => {
|
||||||
|
|
||||||
const store = mockStore(state);
|
const store = mockStore(state);
|
||||||
|
|
||||||
const expectedActions = [{
|
const expectedActions = [{
|
||||||
type: 'MARKER_SAVE_REQUEST',
|
type: 'MARKER_SAVE_REQUEST',
|
||||||
marker: {
|
marker: {
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|
|
@ -151,7 +151,7 @@ const expandDomainBlocksSuccess = (domains: string[], next: string | null) => ({
|
||||||
next,
|
next,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expandDomainBlocksFail = (error: AxiosError) => ({
|
const expandDomainBlocksFail = (error: AxiosError) => ({
|
||||||
type: DOMAIN_BLOCKS_EXPAND_FAIL,
|
type: DOMAIN_BLOCKS_EXPAND_FAIL,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
|
@ -612,7 +612,7 @@ const fetchRecentEvents = () =>
|
||||||
next: next ? next.uri : null,
|
next: next ? next.uri : null,
|
||||||
});
|
});
|
||||||
}).catch(error => {
|
}).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,
|
next: next ? next.uri : null,
|
||||||
});
|
});
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
|
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -72,7 +72,7 @@ export interface IAccount {
|
||||||
actionAlignment?: 'center' | 'top'
|
actionAlignment?: 'center' | 'top'
|
||||||
actionIcon?: string
|
actionIcon?: string
|
||||||
actionTitle?: string
|
actionTitle?: string
|
||||||
/** Override other actions for specificity like mute/unmute. */
|
/** Override other actions for specificity like mute/unmute. */
|
||||||
actionType?: 'muting' | 'blocking' | 'follow_request'
|
actionType?: 'muting' | 'blocking' | 'follow_request'
|
||||||
avatarSize?: number
|
avatarSize?: number
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
|
|
|
@ -26,7 +26,7 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||||
scrollKey: string
|
scrollKey: string
|
||||||
/** List of status IDs to display. */
|
/** List of status IDs to display. */
|
||||||
statusIds: ImmutableOrderedSet<string>
|
statusIds: ImmutableOrderedSet<string>
|
||||||
/** Last _unfiltered_ status ID (maxId) for pagination. */
|
/** Last _unfiltered_ status ID (maxId) for pagination. */
|
||||||
lastStatusId?: string
|
lastStatusId?: string
|
||||||
/** Pinned statuses to show at the top of the feed. */
|
/** Pinned statuses to show at the top of the feed. */
|
||||||
featuredStatusIds?: ImmutableOrderedSet<string>
|
featuredStatusIds?: ImmutableOrderedSet<string>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
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 <svg> */
|
/** Class name for the <svg> */
|
||||||
className?: string
|
className?: string
|
||||||
/** Tooltip text for the icon. */
|
/** Tooltip text for the icon. */
|
||||||
|
|
|
@ -152,7 +152,7 @@ const Upload: React.FC<IUpload> = ({
|
||||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||||
{({ scale }) => (
|
{({ scale }) => (
|
||||||
<div
|
<div
|
||||||
className={clsx('compose-form__upload-thumbnail', mediaType)}
|
className={clsx('compose-form__upload-thumbnail', mediaType)}
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
|
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { __stub } from 'soapbox/api';
|
||||||
import { ChatContext } from 'soapbox/contexts/chat-context';
|
import { ChatContext } from 'soapbox/contexts/chat-context';
|
||||||
import { StatProvider } from 'soapbox/contexts/stat-context';
|
import { StatProvider } from 'soapbox/contexts/stat-context';
|
||||||
import chats from 'soapbox/jest/fixtures/chats.json';
|
import chats from 'soapbox/jest/fixtures/chats.json';
|
||||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||||
|
|
||||||
import ChatPane from '../chat-pane';
|
import ChatPane from '../chat-pane';
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { setEditorState } from 'soapbox/actions/compose';
|
||||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||||
|
|
||||||
import nodes from './nodes';
|
import nodes from './nodes';
|
||||||
|
import DraggableBlockPlugin from './plugins/draggable-block-plugin';
|
||||||
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
|
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
|
||||||
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
|
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
|
||||||
import { MentionPlugin } from './plugins/mention-plugin';
|
import { MentionPlugin } from './plugins/mention-plugin';
|
||||||
|
@ -109,7 +110,7 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
|
||||||
contentEditable={
|
contentEditable={
|
||||||
<div className='editor' ref={onRef} onFocus={onFocus}>
|
<div className='editor' ref={onRef} onFocus={onFocus}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
className={clsx('py-2 outline-none transition-[min-height] motion-reduce:transition-none', {
|
className={clsx('mr-4 py-2 outline-none transition-[min-height] motion-reduce:transition-none', {
|
||||||
'min-h-[40px]': condensed,
|
'min-h-[40px]': condensed,
|
||||||
'min-h-[100px]': !condensed,
|
'min-h-[100px]': !condensed,
|
||||||
})}
|
})}
|
||||||
|
@ -135,6 +136,7 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
|
||||||
{features.richText && <LinkPlugin />}
|
{features.richText && <LinkPlugin />}
|
||||||
{features.richText && floatingAnchorElem && (
|
{features.richText && floatingAnchorElem && (
|
||||||
<>
|
<>
|
||||||
|
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||||
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||||
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
|
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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<HTMLDivElement>(null);
|
||||||
|
const targetLineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draggableBlockElem, setDraggableBlockElem] =
|
||||||
|
useState<HTMLElement | null>(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<HTMLDivElement>): 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(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className='draggable-block-menu absolute top-0 right-0 cursor-grab rounded px-[1px] py-0.5 opacity-0 will-change-transform hover:bg-gray-100 active:cursor-grabbing hover:dark:bg-primary-700'
|
||||||
|
ref={menuRef}
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<SvgIcon
|
||||||
|
src={require('@tabler/icons/grip-vertical.svg')}
|
||||||
|
className={clsx(isEditable && 'pointer-events-none h-4 w-4 text-gray-700 dark:text-gray-600')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='pointer-events-none absolute left-0 top-0 h-1 bg-secondary-400 opacity-0 will-change-transform' ref={targetLineRef} />
|
||||||
|
</>,
|
||||||
|
anchorElem,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DraggableBlockPlugin({
|
||||||
|
anchorElem = document.body,
|
||||||
|
}: {
|
||||||
|
anchorElem?: HTMLElement
|
||||||
|
}): JSX.Element {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
return useDraggableBlockMenu(editor, anchorElem, editor._editable);
|
||||||
|
}
|
|
@ -161,8 +161,6 @@ function checkForAtSignMentions(
|
||||||
minMatchLength: number,
|
minMatchLength: number,
|
||||||
): QueryMatch | null {
|
): QueryMatch | null {
|
||||||
let match = AtSignMentionsRegex.exec(text);
|
let match = AtSignMentionsRegex.exec(text);
|
||||||
console.log(text, match);
|
|
||||||
|
|
||||||
if (match === null) {
|
if (match === null) {
|
||||||
match = AtSignMentionsRegexAliasRegex.exec(text);
|
match = AtSignMentionsRegexAliasRegex.exec(text);
|
||||||
}
|
}
|
||||||
|
@ -278,7 +276,6 @@ export function MentionPlugin(): JSX.Element | null {
|
||||||
|
|
||||||
const checkForMentionMatch = useCallback(
|
const checkForMentionMatch = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
console.log(text);
|
|
||||||
const mentionMatch = getPossibleQueryMatch(text);
|
const mentionMatch = getPossibleQueryMatch(text);
|
||||||
const slashMatch = checkForSlashTriggerMatch(text, editor);
|
const slashMatch = checkForSlashTriggerMatch(text, editor);
|
||||||
return !slashMatch && mentionMatch ? mentionMatch : null;
|
return !slashMatch && mentionMatch ? mentionMatch : null;
|
||||||
|
|
|
@ -125,7 +125,7 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean {
|
||||||
const startOffset = leadOffset;
|
const startOffset = leadOffset;
|
||||||
const endOffset = domSelection.anchorOffset;
|
const endOffset = domSelection.anchorOffset;
|
||||||
|
|
||||||
if (anchorNode == null || endOffset == null) {
|
if (!anchorNode || !endOffset) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,9 +142,7 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean {
|
||||||
function getQueryTextForSearch(editor: LexicalEditor): string | null {
|
function getQueryTextForSearch(editor: LexicalEditor): string | null {
|
||||||
let text = null;
|
let text = null;
|
||||||
editor.getEditorState().read(() => {
|
editor.getEditorState().read(() => {
|
||||||
console.log(editor.getEditorState().toJSON());
|
|
||||||
const selection = $getSelection();
|
const selection = $getSelection();
|
||||||
console.log(selection);
|
|
||||||
if (!$isRangeSelection(selection)) {
|
if (!$isRangeSelection(selection)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -289,10 +287,10 @@ export function useDynamicPositioning(
|
||||||
) {
|
) {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetElement != null && resolution != null) {
|
if (targetElement && resolution) {
|
||||||
const rootElement = editor.getRootElement();
|
const rootElement = editor.getRootElement();
|
||||||
const rootScrollParent =
|
const rootScrollParent =
|
||||||
rootElement != null
|
rootElement
|
||||||
? getScrollParent(rootElement, false)
|
? getScrollParent(rootElement, false)
|
||||||
: document.body;
|
: document.body;
|
||||||
let ticking = false;
|
let ticking = false;
|
||||||
|
@ -314,7 +312,7 @@ export function useDynamicPositioning(
|
||||||
);
|
);
|
||||||
if (isInView !== previousIsInView) {
|
if (isInView !== previousIsInView) {
|
||||||
previousIsInView = isInView;
|
previousIsInView = isInView;
|
||||||
if (onVisibilityChange != null) {
|
if (onVisibilityChange) {
|
||||||
onVisibilityChange(isInView);
|
onVisibilityChange(isInView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -423,7 +421,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
||||||
editor.registerCommand(
|
editor.registerCommand(
|
||||||
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
||||||
({ option }) => {
|
({ option }) => {
|
||||||
if (option.ref && option.ref.current != null) {
|
if (option.ref && option.ref.current) {
|
||||||
scrollIntoViewIfNeeded(option.ref.current);
|
scrollIntoViewIfNeeded(option.ref.current);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -446,7 +444,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
||||||
selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0;
|
selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0;
|
||||||
updateSelectedIndex(newSelectedIndex);
|
updateSelectedIndex(newSelectedIndex);
|
||||||
const option = options[newSelectedIndex];
|
const option = options[newSelectedIndex];
|
||||||
if (option.ref != null && option.ref.current) {
|
if (option.ref && option.ref.current) {
|
||||||
editor.dispatchCommand(
|
editor.dispatchCommand(
|
||||||
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
||||||
{
|
{
|
||||||
|
@ -471,7 +469,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
||||||
selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1;
|
selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1;
|
||||||
updateSelectedIndex(newSelectedIndex);
|
updateSelectedIndex(newSelectedIndex);
|
||||||
const option = options[newSelectedIndex];
|
const option = options[newSelectedIndex];
|
||||||
if (option.ref != null && option.ref.current) {
|
if (option.ref && option.ref.current) {
|
||||||
scrollIntoViewIfNeeded(option.ref.current);
|
scrollIntoViewIfNeeded(option.ref.current);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -499,7 +497,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
||||||
if (
|
if (
|
||||||
options === null ||
|
options === null ||
|
||||||
selectedIndex === null ||
|
selectedIndex === null ||
|
||||||
options[selectedIndex] == null
|
!options[selectedIndex]
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -516,7 +514,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
||||||
if (
|
if (
|
||||||
options === null ||
|
options === null ||
|
||||||
selectedIndex === null ||
|
selectedIndex === null ||
|
||||||
options[selectedIndex] == null
|
!options[selectedIndex]
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -612,7 +610,7 @@ function useMenuAnchorRef(
|
||||||
containerDiv.style.width = `${width}px`;
|
containerDiv.style.width = `${width}px`;
|
||||||
|
|
||||||
if (!containerDiv.isConnected) {
|
if (!containerDiv.isConnected) {
|
||||||
if (className != null) {
|
if (className) {
|
||||||
containerDiv.className = className;
|
containerDiv.className = className;
|
||||||
}
|
}
|
||||||
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
||||||
|
@ -706,7 +704,7 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
|
|
||||||
const closeTypeahead = useCallback(() => {
|
const closeTypeahead = useCallback(() => {
|
||||||
setResolution(null);
|
setResolution(null);
|
||||||
if (onClose != null && resolution !== null) {
|
if (onClose && resolution !== null) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}, [onClose, resolution]);
|
}, [onClose, resolution]);
|
||||||
|
@ -714,7 +712,7 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
const openTypeahead = useCallback(
|
const openTypeahead = useCallback(
|
||||||
(res: Resolution) => {
|
(res: Resolution) => {
|
||||||
setResolution(res);
|
setResolution(res);
|
||||||
if (onOpen != null && resolution === null) {
|
if (onOpen && resolution === null) {
|
||||||
onOpen(res);
|
onOpen(res);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -821,7 +819,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
|
|
||||||
const closeNodeMenu = useCallback(() => {
|
const closeNodeMenu = useCallback(() => {
|
||||||
setResolution(null);
|
setResolution(null);
|
||||||
if (onClose != null && resolution !== null) {
|
if (onClose && resolution !== null) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}, [onClose, resolution]);
|
}, [onClose, resolution]);
|
||||||
|
@ -829,7 +827,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
const openNodeMenu = useCallback(
|
const openNodeMenu = useCallback(
|
||||||
(res: Resolution) => {
|
(res: Resolution) => {
|
||||||
setResolution(res);
|
setResolution(res);
|
||||||
if (onOpen != null && resolution === null) {
|
if (onOpen && resolution === null) {
|
||||||
onOpen(res);
|
onOpen(res);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -841,9 +839,9 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
const node = $getNodeByKey(nodeKey);
|
const node = $getNodeByKey(nodeKey);
|
||||||
const domElement = editor.getElementByKey(nodeKey);
|
const domElement = editor.getElementByKey(nodeKey);
|
||||||
if (node != null && domElement != null) {
|
if (node && domElement) {
|
||||||
const text = node.getTextContent();
|
const text = node.getTextContent();
|
||||||
if (resolution == null || resolution.match.matchingString !== text) {
|
if (!resolution || resolution.match.matchingString !== text) {
|
||||||
startTransition(() =>
|
startTransition(() =>
|
||||||
openNodeMenu({
|
openNodeMenu({
|
||||||
getRect: () => domElement.getBoundingClientRect(),
|
getRect: () => domElement.getBoundingClientRect(),
|
||||||
|
@ -857,7 +855,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (nodeKey == null && resolution != null) {
|
} else if (!nodeKey && resolution) {
|
||||||
closeNodeMenu();
|
closeNodeMenu();
|
||||||
}
|
}
|
||||||
}, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
|
}, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
|
||||||
|
@ -867,7 +865,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
}, [positionOrCloseMenu, nodeKey]);
|
}, [positionOrCloseMenu, nodeKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nodeKey != null) {
|
if (nodeKey) {
|
||||||
return editor.registerUpdateListener(({ dirtyElements }) => {
|
return editor.registerUpdateListener(({ dirtyElements }) => {
|
||||||
if (dirtyElements.get(nodeKey)) {
|
if (dirtyElements.get(nodeKey)) {
|
||||||
positionOrCloseMenu();
|
positionOrCloseMenu();
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
const isHTMLElement = (x: unknown): x is HTMLElement => x instanceof HTMLElement;
|
||||||
|
|
||||||
|
export default isHTMLElement;
|
||||||
|
export { isHTMLElement };
|
58
app/soapbox/features/compose/editor/utils/point.ts
Normal file
58
app/soapbox/features/compose/editor/utils/point.ts
Normal file
|
@ -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 };
|
164
app/soapbox/features/compose/editor/utils/rect.ts
Normal file
164
app/soapbox/features/compose/editor/utils/rect.ts
Normal file
|
@ -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 };
|
|
@ -109,7 +109,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
const { uri } = status;
|
const { uri } = status;
|
||||||
|
|
||||||
copy(uri);
|
copy(uri);
|
||||||
};
|
};
|
||||||
|
|
|
@ -24,8 +24,8 @@ const messages = defineMessages({
|
||||||
submit: { id: 'report.submit', defaultMessage: 'Submit' },
|
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.' },
|
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' },
|
reportMessage: { id: 'report.chatMessage.title', defaultMessage: 'Report message' },
|
||||||
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
||||||
previous: { id: 'report.previous', defaultMessage: 'Previous' },
|
previous: { id: 'report.previous', defaultMessage: 'Previous' },
|
||||||
});
|
});
|
||||||
|
|
||||||
enum Steps {
|
enum Steps {
|
||||||
|
|
|
@ -212,7 +212,7 @@ const findBestClient = (clients: readonly WindowClient[]): WindowClient => {
|
||||||
return focusedClient || visibleClient || clients[0];
|
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 expandNotification = (notification: Notification) => {
|
||||||
const newNotification = cloneNotification(notification);
|
const newNotification = cloneNotification(notification);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue