feat: ability to drag super list items; secure password generation blocks (#2039)

* feat: ability to drag list item nodes

* fix: issue where editor focus would scroll to bottom

* fix: improve drag icon and prevent from interfering with selection

* fix(super): add 'current' as keyword for bringing up date block options

* fix(super): issue with autocomplete menu width on large screens

* feat(super): ability to generate secure random passwords
This commit is contained in:
Mo
2022-11-19 08:53:30 -06:00
committed by GitHub
parent 7f28876047
commit c39c72da7a
19 changed files with 238 additions and 62 deletions

View File

@@ -98,7 +98,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<div className="editor" ref={onRef}>
<ContentEditable
id={SuperEditorContentId}
className={`ContentEditable__root ${className}`}
className={`ContentEditable__root overflow-y-auto ${className}`}
spellCheck={spellcheck}
/>
</div>

View File

@@ -1,18 +1,21 @@
.draggable-block-menu {
border-radius: 4px;
padding: 2px 1px;
padding: 3px 1px;
cursor: grab;
opacity: 0;
position: absolute;
left: 0;
top: 0;
will-change: transform;
transition: opacity 0.3s;
}
.draggable-block-menu .icon {
width: 1rem;
height: 1rem;
opacity: 0.4;
width: 0.8rem;
height: 1.1rem;
opacity: 0.2;
padding-left: 4.75px;
padding-top: 2px;
}
.draggable-block-menu:active {
@@ -21,7 +24,6 @@
.draggable-block-menu:hover {
background-color: var(--sn-stylekit-contrast-background-color);
padding: 3px;
}
.draggable-block-target-line {
@@ -32,5 +34,6 @@
left: 0;
top: 0;
opacity: 0;
will-change: transform;
will-change: transform, opacity;
transition: opacity 0.15s;
}

View File

@@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*
*/
import {$createListNode, $isListNode} from '@lexical/list';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {eventFiles} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
@@ -19,17 +20,17 @@ import {
} from 'lexical';
import {DragEvent as ReactDragEvent, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {LexicalDraggableBlockMenu} from '@standardnotes/icons';
import {BlockIcon} from '@standardnotes/icons';
import {isHTMLElement} from '../../Utils/guard';
import {Point} from '../../Utils/point';
import {Rect} from '../../Utils/rect';
import {ContainsPointReturn, Rect} from '../../Utils/rect';
const SPACE = 4;
const TARGET_LINE_HALF_HEIGHT = 2;
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu';
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
const TEXT_BOX_HORIZONTAL_PADDING = 28;
const TEXT_BOX_HORIZONTAL_PADDING = 24;
const Downward = 1;
const Upward = -1;
@@ -53,12 +54,56 @@ function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
return root ? root.__children : [];
}
function elementContainingEventLocation(
anchorElem: HTMLElement,
element: HTMLElement,
event: MouseEvent,
): {contains: ContainsPointReturn; element: HTMLElement} {
const anchorElementRect = anchorElem.getBoundingClientRect();
const eventLocation = new Point(event.x, event.y);
const elementDomRect = Rect.fromDOM(element);
const {marginTop, marginBottom} = window.getComputedStyle(element);
const rect = elementDomRect.generateNewRect({
bottom: elementDomRect.bottom + parseFloat(marginBottom),
left: anchorElementRect.left,
right: anchorElementRect.right,
top: elementDomRect.top - parseFloat(marginTop),
});
const children = Array.from(element.children);
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(
element.tagName,
);
if (shouldRecurseIntoChildren) {
for (const child of children) {
const isLeaf = child.children.length === 0;
if (isLeaf) {
continue;
}
const childResult = elementContainingEventLocation(
anchorElem,
child as HTMLElement,
event,
);
if (childResult.contains.result) {
return childResult;
}
}
}
return {contains: rect.contains(eventLocation), element: element};
}
function getBlockElement(
anchorElem: HTMLElement,
editor: LexicalEditor,
event: MouseEvent,
): HTMLElement | null {
const anchorElementRect = anchorElem.getBoundingClientRect();
const topLevelNodeKeys = getTopLevelNodeKeys(editor);
let blockElem: HTMLElement | null = null;
@@ -73,32 +118,22 @@ function getBlockElement(
if (elem === null) {
break;
}
const point = new Point(event.x, event.y);
const domRect = Rect.fromDOM(elem);
const {marginTop, marginBottom} = window.getComputedStyle(elem);
const {contains, element} = elementContainingEventLocation(
anchorElem,
elem,
event,
);
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;
if (contains.result) {
blockElem = element;
prevIndex = index;
break;
}
if (direction === Indeterminate) {
if (isOnTopSide) {
if (contains.reason.isOnTopSide) {
direction = Upward;
} else if (isOnBottomSide) {
} else if (contains.reason.isOnBottomSide) {
direction = Downward;
} else {
// stop search block element
@@ -124,7 +159,6 @@ function setMenuPosition(
) {
if (!targetElem) {
floatingElem.style.opacity = '0';
floatingElem.style.transform = 'translate(-10000px, -10000px)';
return;
}
@@ -186,13 +220,12 @@ function setTargetLine(
targetLineElem.style.width = `${
anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2
}px`;
targetLineElem.style.opacity = '.4';
targetLineElem.style.opacity = '.6';
}
function hideTargetLine(targetLineElem: HTMLElement | null) {
if (targetLineElem) {
targetLineElem.style.opacity = '0';
targetLineElem.style.transform = 'translate(-10000px, -10000px)';
}
}
@@ -284,18 +317,30 @@ function useDraggableBlockMenu(
return false;
}
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
if (!targetNode) {
return false;
}
if (targetNode === draggedNode) {
return true;
}
let nodeToInsert = draggedNode;
const targetParent = targetNode.getParent();
const sourceParent = draggedNode.getParent();
if ($isListNode(sourceParent) && !$isListNode(targetParent)) {
const newList = $createListNode(sourceParent.getListType());
newList.append(draggedNode);
nodeToInsert = newList;
}
const {top, height} = targetBlockElem.getBoundingClientRect();
const shouldInsertAfter = pageY - top > height / 2;
if (shouldInsertAfter) {
targetNode.insertAfter(draggedNode);
targetNode.insertAfter(nodeToInsert);
} else {
targetNode.insertBefore(draggedNode);
targetNode.insertBefore(nodeToInsert);
}
setDraggableBlockElem(null);
@@ -349,7 +394,7 @@ function useDraggableBlockMenu(
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
<div className={isEditable ? 'icon' : ''}>
<LexicalDraggableBlockMenu className="text-text pointer-events-none" />
<BlockIcon className="text-text pointer-events-none" />
</div>
</div>
<div className="draggable-block-target-line" ref={targetLineRef} />

View File

@@ -110,8 +110,8 @@ export function InsertTableDialog({
return (
<>
<TextInput label="No of rows" onChange={setRows} value={rows} />
<TextInput label="No of columns" onChange={setColumns} value={columns} />
<TextInput label="Number of rows" onChange={setRows} value={rows} />
<TextInput label="Number of columns" onChange={setColumns} value={columns} />
<DialogActions data-test-id="table-model-confirm-insert">
<Button onClick={onClick}>Confirm</Button>
</DialogActions>

View File

@@ -13,13 +13,13 @@
padding-left: 15px;
padding-right: 15px;
border: 0px;
background-color: #eee;
border-radius: 5px;
background-color: var(--sn-stylekit-contrast-background-color);
cursor: pointer;
font-size: 14px;
}
.Button__root:hover {
background-color: #ddd;
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.Button__small {
padding-top: 5px;
@@ -32,5 +32,5 @@
cursor: not-allowed;
}
.Button__disabled:hover {
background-color: #eee;
background-color: var(--sn-stylekit-secondary-background-color);
}

View File

@@ -17,16 +17,17 @@
display: flex;
flex: 1;
color: #666;
margin-right: 20px;
}
.Input__input {
display: flex;
flex: 2;
border: 1px solid #999;
border: 1px solid var(--sn-stylekit-contrast-border-color);
background-color: var(--sn-stylekit-contrast-background-color);
padding-top: 7px;
padding-bottom: 7px;
padding-left: 10px;
padding-right: 10px;
font-size: 16px;
border-radius: 5px;
min-width: 0;
}

View File

@@ -17,7 +17,7 @@
bottom: 0px;
left: 0px;
right: 0px;
background-color: rgba(40, 40, 40, 0.6);
background-color: rgba(0, 0, 0, 0.7);
flex-grow: 0px;
flex-shrink: 1px;
z-index: 100;
@@ -28,22 +28,23 @@
min-width: 300px;
display: flex;
flex-grow: 0px;
background-color: #fff;
background-color: var(--sn-stylekit-background-color);
flex-direction: column;
position: relative;
box-shadow: 0 0 20px 0 #444;
border-radius: 10px;
box-shadow: 0 0px 0 var(--sn-stylekit-shadow-color);
border-radius: 0px;
}
.Modal__title {
color: #444;
color:var(--sn-stylekit-foreground-color);
margin: 0px;
padding-bottom: 10px;
border-bottom: 1px solid #ccc;
padding-bottom: 15px;
border-bottom: 1px solid var(--sn-stylekit-border-color);
}
.Modal__closeButton {
border: 0px;
position: absolute;
right: 20px;
top: 15px;
border-radius: 20px;
justify-content: center;
align-items: center;
@@ -52,10 +53,11 @@
height: 30px;
text-align: center;
cursor: pointer;
background-color: #eee;
background-color: var(--sn-stylekit-contrast-background-color);
}
.Modal__closeButton:hover {
background-color: #ddd;
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.Modal__content {
padding-top: 20px;

View File

@@ -73,7 +73,7 @@ function PortalImpl({
aria-label="Close modal"
type="button"
onClick={onClose}>
X
</button>
<div className="Modal__content">{children}</div>
</div>

View File

@@ -0,0 +1,7 @@
<svg width="9px" height="14px" viewBox="0 0 9 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-2" fill="currentColor" fill-rule="nonzero">
<path d="M1.6666,3.33333 C1.2246,3.33333 0.8007,3.15774 0.4881,2.84518 C0.1755,2.53262 0,2.1087 0,1.66667 C0,1.22464 0.1755,0.80072 0.4881,0.48816 C0.8007,0.1756 1.2246,0 1.6666,0 C2.1086,0 2.5326,0.1756 2.8451,0.48816 C3.1577,0.80072 3.3333,1.22464 3.3333,1.66667 C3.3333,2.1087 3.1577,2.53262 2.8451,2.84518 C2.5326,3.15774 2.1086,3.33333 1.6666,3.33333 Z M1.6666,8.33333 C1.2246,8.33333 0.8007,8.15773 0.4881,7.84513 C0.1755,7.53263 0,7.10873 0,6.66663 C0,6.22463 0.1755,5.80073 0.4881,5.48813 C0.8007,5.17563 1.2246,5.00003 1.6666,5.00003 C2.1086,5.00003 2.5326,5.17563 2.8451,5.48813 C3.1577,5.80073 3.3333,6.22463 3.3333,6.66663 C3.3333,7.10873 3.1577,7.53263 2.8451,7.84513 C2.5326,8.15773 2.1086,8.33333 1.6666,8.33333 Z M1.6666,13.33333 C1.2246,13.33333 0.8007,13.15773 0.4881,12.84513 C0.1755,12.53263 0,12.10873 0,11.66663 C0,11.22463 0.1755,10.80073 0.4881,10.48813 C0.8007,10.17563 1.2246,10.00003 1.6666,10.00003 C2.1086,10.00003 2.5326,10.17563 2.8451,10.48813 C3.1577,10.80073 3.3333,11.22463 3.3333,11.66663 C3.3333,12.10873 3.1577,12.53263 2.8451,12.84513 C2.5326,13.15773 2.1086,13.33333 1.6666,13.33333 L1.6666,13.33333 Z M6.6666,3.33333 C6.2246,3.33333 5.8007,3.15774 5.4881,2.84518 C5.1755,2.53262 5,2.1087 5,1.66667 C5,1.22464 5.1755,0.80072 5.4881,0.48816 C5.8007,0.1756 6.2246,0 6.6666,0 C7.1086,0 7.5326,0.1756 7.8451,0.48816 C8.1577,0.80072 8.3333,1.22464 8.3333,1.66667 C8.3333,2.1087 8.1577,2.53262 7.8451,2.84518 C7.5326,3.15774 7.1086,3.33333 6.6666,3.33333 Z M6.6666,8.33333 C6.2246,8.33333 5.8007,8.15773 5.4881,7.84513 C5.1755,7.53263 5,7.10873 5,6.66663 C5,6.22463 5.1755,5.80073 5.4881,5.48813 C5.8007,5.17563 6.2246,5.00003 6.6666,5.00003 C7.1086,5.00003 7.5326,5.17563 7.8451,5.48813 C8.1577,5.80073 8.3333,6.22463 8.3333,6.66663 C8.3333,7.10873 8.1577,7.53263 7.8451,7.84513 C7.5326,8.15773 7.1086,8.33333 6.6666,8.33333 Z M6.6666,13.33333 C6.2246,13.33333 5.8007,13.15773 5.4881,12.84513 C5.1755,12.53263 5,12.10873 5,11.66663 C5,11.22463 5.1755,10.80073 5.4881,10.48813 C5.8007,10.17563 6.2246,10.00003 6.6666,10.00003 C7.1086,10.00003 7.5326,10.17563 7.8451,10.48813 C8.1577,10.80073 8.3333,11.22463 8.3333,11.66663 C8.3333,12.10873 8.1577,12.53263 7.8451,12.84513 C7.5326,13.15773 7.1086,13.33333 6.6666,13.33333 L6.6666,13.33333 Z" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -21,6 +21,7 @@ import AttachmentFileIcon from './ic-attachment-file.svg'
import AuthenticatorIcon from './ic-authenticator.svg'
import AuthenticatorVariantIcon from './ic-authenticator-variant.svg'
import BackIosIcon from './ic-back-ios.svg'
import BlockIcon from './ic-block.svg'
import BlueDotIcon from './blue-dot.svg'
import BoldIcon from './ic-bold.svg'
import BoxFilledIcon from './ic-box-filled.svg'
@@ -224,6 +225,7 @@ export {
AuthenticatorIcon,
AuthenticatorVariantIcon,
BackIosIcon,
BlockIcon,
BlueDotIcon,
BoldIcon,
BoxFilledIcon,

View File

@@ -11,6 +11,7 @@ import { GetBulletedListBlock } from './Blocks/BulletedList'
import { GetChecklistBlock } from './Blocks/Checklist'
import { GetDividerBlock } from './Blocks/Divider'
import { GetCollapsibleBlock } from './Blocks/Collapsible'
import { GetDynamicPasswordBlocks, GetPasswordBlocks } from './Blocks/Password'
import { GetParagraphBlock } from './Blocks/Paragraph'
import { GetHeadingsBlocks } from './Blocks/Headings'
import { GetQuoteBlock } from './Blocks/Quote'
@@ -49,11 +50,15 @@ export default function BlockPickerMenuPlugin(): JSX.Element {
GetDividerBlock(editor),
...GetDatetimeBlocks(editor),
...GetAlignmentBlocks(editor),
...GetPasswordBlocks(editor),
GetCollapsibleBlock(editor),
...GetEmbedsBlocks(editor),
]
const dynamicOptions = GetDynamicTableBlocks(editor, queryString || '')
const dynamicOptions = [
...GetDynamicTableBlocks(editor, queryString || ''),
...GetDynamicPasswordBlocks(editor, queryString || ''),
]
return queryString
? [

View File

@@ -6,17 +6,17 @@ export function GetDatetimeBlocks(editor: LexicalEditor) {
return [
new BlockPickerOption('Current date and time', {
iconName: 'authenticator',
keywords: ['date'],
keywords: ['date', 'current'],
onSelect: () => editor.dispatchCommand(INSERT_DATETIME_COMMAND, 'datetime'),
}),
new BlockPickerOption('Current time', {
iconName: 'authenticator',
keywords: ['time'],
keywords: ['time', 'current'],
onSelect: () => editor.dispatchCommand(INSERT_TIME_COMMAND, 'datetime'),
}),
new BlockPickerOption('Current date', {
iconName: 'authenticator',
keywords: ['date'],
keywords: ['date', 'current'],
onSelect: () => editor.dispatchCommand(INSERT_DATE_COMMAND, 'datetime'),
}),
]

View File

@@ -0,0 +1,42 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_PASSWORD_COMMAND } from '../../Commands'
const DEFAULT_PASSWORD_LENGTH = 16
const MIN_PASSWORD_LENGTH = 8
export function GetPasswordBlocks(editor: LexicalEditor) {
return [
new BlockPickerOption('Generate cryptographically secure password', {
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, String(DEFAULT_PASSWORD_LENGTH)),
}),
]
}
export function GetDynamicPasswordBlocks(editor: LexicalEditor, queryString: string) {
if (queryString == null) {
return []
}
const lengthRegex = /^\d+$/
const match = lengthRegex.exec(queryString)
if (!match) {
return []
}
const length = parseInt(match[0], 10)
if (length < MIN_PASSWORD_LENGTH) {
return []
}
return [
new BlockPickerOption(`Generate ${length}-character cryptographically secure password`, {
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, length.toString()),
}),
]
}

View File

@@ -1,8 +1,8 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
export const PopoverClassNames = classNames(
'z-dropdown-menu w-full min-w-80',
'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto md:max-w-xs h-auto overflow-y-scroll',
'z-dropdown-menu w-full',
'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto h-auto overflow-y-scroll',
)
export const PopoverItemClassNames = classNames(

View File

@@ -5,3 +5,4 @@ export const INSERT_BUBBLE_COMMAND: LexicalCommand<string> = createCommand('INSE
export const INSERT_TIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_TIME_COMMAND')
export const INSERT_DATE_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATE_COMMAND')
export const INSERT_DATETIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATETIME_COMMAND')
export const INSERT_PASSWORD_COMMAND: LexicalCommand<string> = createCommand('INSERT_PASSWORD_COMMAND')

View File

@@ -0,0 +1,26 @@
const LOWER_CASE_LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('')
const UPPER_CASE_LETTERS = LOWER_CASE_LETTERS.map((l) => l.toUpperCase())
const SPECIAL_SYMBOLS = '!£$%^&*()@~:;,./?{}=-_'.split('')
const CHARACTER_SET = [...LOWER_CASE_LETTERS, ...UPPER_CASE_LETTERS, ...SPECIAL_SYMBOLS]
const CHARACTER_SET_LENGTH = CHARACTER_SET.length
function isValidPassword(password: string) {
const containsSymbols = SPECIAL_SYMBOLS.some((symbol) => password.includes(symbol))
const containsUpperCase = UPPER_CASE_LETTERS.some((upperLetter) => password.includes(upperLetter))
const containsLowerCase = LOWER_CASE_LETTERS.some((lowerLetter) => password.includes(lowerLetter))
return containsLowerCase && containsUpperCase && containsSymbols
}
export function generatePassword(length: number): string {
const buffer = new Uint8Array(length)
let generatedPassword = ''
do {
window.crypto.getRandomValues(buffer)
generatedPassword = [...buffer].map((x) => CHARACTER_SET[x % CHARACTER_SET_LENGTH]).join('')
} while (!isValidPassword(generatedPassword))
return generatedPassword
}

View File

@@ -0,0 +1,40 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
COMMAND_PRIORITY_EDITOR,
$createTextNode,
$getSelection,
$isRangeSelection,
$createParagraphNode,
} from 'lexical'
import { useEffect } from 'react'
import { INSERT_PASSWORD_COMMAND } from '../Commands'
import { mergeRegister } from '@lexical/utils'
import { generatePassword } from './Generator'
export default function PasswordPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(
editor.registerCommand<string>(
INSERT_PASSWORD_COMMAND,
(lengthString) => {
const length = Number(lengthString)
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const paragraph = $createParagraphNode()
const password = generatePassword(length)
paragraph.append($createTextNode(password))
selection.insertNodes([paragraph])
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return null
}

View File

@@ -21,6 +21,7 @@ import {
ChangeContentCallbackPlugin,
ChangeEditorFunction,
} from './Plugins/ChangeContentCallback/ChangeContentCallback'
import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
const NotePreviewCharLimit = 160
@@ -102,7 +103,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<BlocksEditor
onChange={handleChange}
ignoreFirstChange={true}
className="relative h-full resize-none px-5 py-4 text-base focus:shadow-none focus:outline-none"
className="relative h-full resize-none px-6 py-4 text-base focus:shadow-none focus:outline-none"
previewLength={NotePreviewCharLimit}
spellcheck={spellcheck}
>
@@ -111,6 +112,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<ItemBubblePlugin />
<BlockPickerMenuPlugin />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />
<ChangeContentCallbackPlugin
providerCallback={(callback) => (changeEditorFunction.current = callback)}

View File

@@ -93,7 +93,7 @@ const Popover = ({
anchorElement={anchorElement}
anchorPoint={anchorPoint}
childPopovers={childPopovers}
className={className}
className={`popover-content-container ${className ?? ''}`}
id={popoverId.current}
overrideZIndex={overrideZIndex}
side={side}