Add UI components to Lexical Playground (#1211)

* Add UI components to Lexical Playground

* Add autofocus plugin

* Fix test
This commit is contained in:
Dominic Gannaway
2022-02-02 14:21:56 +00:00
committed by acywatson
parent 1b6e5d254e
commit fa107c93fb
10 changed files with 369 additions and 5 deletions

View File

@ -17,6 +17,10 @@ describe('Selection', () => {
return document.activeElement === editorElement;
});
const {page} = e2e;
await evaluate(page, () => {
const editorElement = document.querySelector('div[contenteditable="true"]');
return editorElement.blur();
});
expect(await editorHasFocus()).toEqual(false);
await sleep(500);
expect(await editorHasFocus()).toEqual(false);

View File

@ -39,6 +39,7 @@ import ContentEditable from './ui/ContentEditable';
import AutoLinkPlugin from './plugins/AutoLinkPlugin';
import PollPlugin from './plugins/PollPlugin';
import {useSettings} from './context/SettingsContext';
import AutoFocusPlugin from './plugins/AutoFocusPlugin';
const skipCollaborationInit =
window.parent != null && window.parent.frames.right === window;
@ -69,6 +70,7 @@ export default function Editor(): React$Node {
className={`editor-container ${showTreeView ? 'tree-view' : ''} ${
!isRichText ? 'plain-text' : ''
}`}>
<AutoFocusPlugin />
<StickyPlugin />
<MentionsPlugin />
<EmojisPlugin />

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
import Modal from '../ui/Modal';
import {useCallback, useMemo, useState} from 'react';
import * as React from 'react';
export default function useModal(): [
React$Node,
(string, (() => void) => React$Node) => void,
] {
const [modalContent, setModalContent] = useState<null | {
content: React$Node,
title: string,
}>(null);
const onClose = useCallback(() => {
setModalContent(null);
}, []);
const modal = useMemo(() => {
if (modalContent === null) {
return null;
}
const {title, content} = modalContent;
return (
<Modal onClose={onClose} title={title}>
{content}
</Modal>
);
}, [modalContent, onClose]);
const showModal = useCallback(
(title, getContent: (() => void) => React$Node) => {
setModalContent({title, content: getContent(onClose)});
},
[onClose],
);
return [modal, showModal];
}

View File

@ -35,7 +35,7 @@ header h1 {
.editor-shell {
margin: 20px auto 20px auto;
border-radius: 2px;
width: 940px;
width: 960px;
color: #000;
position: relative;
line-height: 20px;
@ -62,7 +62,7 @@ header h1 {
.test-recorder-output {
margin: 20px auto 20px auto;
width: 940px;
width: 960px;
}
pre {

View File

@ -0,0 +1,22 @@
/**
* 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.
*
* @flow strict
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useEffect} from 'react';
export default function AutoFocusPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.focus();
}, [editor]);
return null;
}

View File

@ -40,6 +40,19 @@ import {getCodeLanguages, getDefaultCodeLanguage} from './CodeHighlightPlugin';
import {$getNearestNodeOfType} from '@lexical/helpers/nodes';
// $FlowFixMe
import {createPortal} from 'react-dom';
import useModal from '../hooks/useModal';
import Input from '../ui/Input';
import Button from '../ui/Button';
import stylex from 'stylex';
const styles = stylex.create({
dialogActions: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'right',
marginTop: 20,
},
});
const LowPriority: CommandListenerLowPriority = 1;
@ -221,6 +234,32 @@ function FloatingLinkEditor({editor}: {editor: LexicalEditor}): React$Node {
);
}
function InsertTableDialog({
activeEditor,
onClose,
}: {
activeEditor: LexicalEditor,
onClose: () => void,
}): React$Node {
const [rows, setRows] = useState('3');
const [columns, setColumns] = useState('3');
const onClick = () => {
activeEditor.execCommand('insertTable', {rows, columns});
onClose();
};
return (
<>
<Input label="No of rows" onChange={setRows} value={rows} />
<Input label="No of columns" onChange={setColumns} value={columns} />
<div className={stylex(styles.dialogActions)}>
<Button onClick={onClick}>Confirm</Button>
</div>
</>
);
}
function BlockOptionsDropdownList({
editor,
blockType,
@ -438,6 +477,7 @@ export default function ToolbarPlugin(): React$Node {
const [isCode, setIsCode] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [modal, showModal] = useModal();
const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] =
useState(false);
const [codeLanguage, setCodeLanguage] = useState<string>('');
@ -740,7 +780,12 @@ export default function ToolbarPlugin(): React$Node {
</button>
<button
onClick={() => {
activeEditor.execCommand('insertTable');
showModal('Insert Table', (onClose) => (
<InsertTableDialog
activeEditor={activeEditor}
onClose={onClose}
/>
));
}}
className="toolbar-item"
aria-label="Insert Table">
@ -807,6 +852,7 @@ export default function ToolbarPlugin(): React$Node {
aria-label="Indent">
<i className="format indent" />
</button>
{modal}
</div>
);
}

View File

@ -0,0 +1,41 @@
/**
* 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.
*
* @flow strict
*/
import * as React from 'react';
import stylex from 'stylex';
const styles = stylex.create({
root: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 15,
paddingRight: 15,
border: 0,
backgroundColor: '#eee',
borderRadius: 5,
cursor: 'pointer',
':hover': {
backgroundColor: '#ddd',
},
},
});
export default function Button({
children,
onClick,
}: {
children: React$Node,
onClick: () => void,
}): React$Node {
return (
<button className={stylex(styles.root)} onClick={onClick}>
{children}
</button>
);
}

View File

@ -0,0 +1,60 @@
/**
* 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.
*
* @flow strict
*/
import * as React from 'react';
import stylex from 'stylex';
const styles = stylex.create({
wrapper: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
label: {
display: 'flex',
flex: 1,
color: '#666',
},
input: {
display: 'flex',
flex: 2,
border: '1px solid #999',
paddingTop: 7,
paddingBottom: 7,
paddingLeft: 10,
paddingRight: 10,
fontSize: 16,
borderRadius: 5,
},
});
export default function Input({
label,
value,
onChange,
}: {
label: string,
onChange: (string) => void,
value: string,
}): React$Node {
return (
<div className={stylex(styles.wrapper)}>
<label className={stylex(styles.label)}>{label}</label>
<input
className={stylex(styles.input)}
value={value}
onChange={(e) => {
onChange(e.target.value);
}}
/>
</div>
);
}

View File

@ -0,0 +1,140 @@
/**
* 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.
*
* @flow strict
*/
// $FlowFixMe
import {createPortal} from 'react-dom';
import * as React from 'react';
import {useEffect, useRef} from 'react';
import stylex from 'stylex';
const styles = stylex.create({
overlay: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'fixed',
flexDirection: 'column',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(40, 40, 40, 0.6)',
flexGrow: 0,
flexShrink: 1,
zIndex: 100,
},
modal: {
padding: 20,
minHeight: 100,
minWidth: 400,
display: 'flex',
flexGrow: 0,
backgroundColor: '#fff',
maxHeight: 300,
flexDirection: 'column',
position: 'relative',
boxShadow: '0 0 20px 0 #444',
borderRadius: 10,
},
title: {
color: '#444',
margin: 0,
paddingBottom: 10,
borderBottom: '1px solid #ccc',
},
closeButton: {
border: 0,
position: 'absolute',
right: 20,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
display: 'flex',
width: 30,
height: 30,
textAlign: 'center',
cursor: 'pointer',
backgroundColor: '#eee',
':hover': {
backgroundColor: '#ddd',
},
},
content: {
paddingTop: 20,
paddingBottom: 20,
},
});
function PortalImpl({
onClose,
children,
title,
}: {
onClose: () => void,
children: React$Node,
title: string,
}) {
const modalRef = useRef(null);
useEffect(() => {
if (modalRef.current !== null) {
modalRef.current.focus();
}
}, []);
useEffect(() => {
const handler = (event) => {
if (event.keyCode === 27) {
onClose();
}
};
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('keydown', handler);
};
}, [onClose]);
return (
<div className={stylex(styles.overlay)} onClick={() => onClose()}>
<div
className={stylex(styles.modal)}
tabIndex={-1}
ref={modalRef}
onClick={(e) => e.stopPropagation()}>
<h2 className={stylex(styles.title)}>{title}</h2>
<button
className={stylex(styles.closeButton)}
aria-label="Close modal"
onClick={() => onClose()}>
X
</button>
<div className={stylex(styles.content)}>{children}</div>
</div>
</div>
);
}
export default function Modal({
onClose,
children,
title,
}: {
onClose: () => void,
children: React$Node,
title: string,
}): React$Node {
return createPortal(
<PortalImpl onClose={onClose} title={title}>
{children}
</PortalImpl>,
document.body,
);
}

View File

@ -26,8 +26,9 @@ export default function TablePlugin(): React$Node {
useEffect(() => {
const removeCommandListener = editor.addListener(
'command',
(type) => {
(type, payload) => {
if (type === 'insertTable') {
const {columns, rows} = payload;
$log('handleAddTable');
const selection = $getSelection();
if (selection === null) {
@ -37,7 +38,7 @@ export default function TablePlugin(): React$Node {
if (focusNode !== null) {
const topLevelNode = focusNode.getTopLevelElementOrThrow();
const tableNode = $createTableNodeWithDimensions(3, 3);
const tableNode = $createTableNodeWithDimensions(rows, columns);
topLevelNode.insertAfter(tableNode);
tableNode.insertAfter($createParagraphNode());
const firstCell = tableNode