Add an experimental Table component in React (#2929)

* Add an experimental Table component in React

Add an experimental Table component in React

Add an experimental Table component in React

WIP

Fix

Fix

Fix

Fix

Fix

Ensure editor states are cloned if read only

Fix

Fix

Fix

Fix

Fix HMR

Add table controls

More fixes

Many fixes

WIP

Fixes

Fix bugs + prettier

* Fix TS issue

* Ensure table priority is higher

* Revert image selection changes

* Add sorting

* Fix sort algo
This commit is contained in:
Dominic Gannaway
2022-09-06 17:46:31 +01:00
committed by GitHub
parent 3084d52561
commit 343a006706
31 changed files with 2765 additions and 77 deletions

View File

@ -142,7 +142,7 @@ export function $insertDataTransferForRichText(
}
}
function $insertGeneratedNodes(
export function $insertGeneratedNodes(
editor: LexicalEditor,
nodes: Array<LexicalNode>,
selection: RangeSelection | GridSelection,
@ -343,7 +343,7 @@ function $mergeGridNodesStrategy(
}
}
interface BaseSerializedNode {
export interface BaseSerializedNode {
children?: Array<BaseSerializedNode>;
type: string;
version: number;
@ -387,14 +387,19 @@ function $appendNodesToJSON(
let shouldInclude = selection != null ? currentNode.isSelected() : true;
const shouldExclude =
$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
let clone = $cloneWithProperties<LexicalNode>(currentNode);
clone =
$isTextNode(clone) && selection != null
? $sliceSelectedTextNodeContent(selection, clone)
: clone;
const children = $isElementNode(clone) ? clone.getChildren() : [];
let target = currentNode;
const serializedNode = exportNodeToJSON(clone);
if (selection !== null) {
let clone = $cloneWithProperties<LexicalNode>(currentNode);
clone =
$isTextNode(clone) && selection != null
? $sliceSelectedTextNodeContent(selection, clone)
: clone;
target = clone;
}
const children = $isElementNode(target) ? target.getChildren() : [];
const serializedNode = exportNodeToJSON(target);
// TODO: TextNode calls getTextContent() (NOT node.__text) within it's exportJSON method
// which uses getLatest() to get the text from the original node with the same key.
@ -402,8 +407,8 @@ function $appendNodesToJSON(
// same node as far as the LexicalEditor is concerned since it shares a key.
// We need a way to create a clone of a Node in memory with it's own key, but
// until then this hack will work for the selected text extract use case.
if ($isTextNode(clone)) {
(serializedNode as SerializedTextNode).text = clone.__text;
if ($isTextNode(target)) {
(serializedNode as SerializedTextNode).text = target.__text;
}
for (let i = 0; i < children.length; i++) {

View File

@ -8,15 +8,21 @@
*/
import {
$generateJSONFromSelectedNodes,
$generateNodesFromSerializedNodes,
$getHtmlContent,
$getLexicalContent,
$insertDataTransferForPlainText,
$insertDataTransferForRichText,
$insertGeneratedNodes,
} from './clipboard';
export {
$generateJSONFromSelectedNodes,
$generateNodesFromSerializedNodes,
$getHtmlContent,
$getLexicalContent,
$insertDataTransferForPlainText,
$insertDataTransferForRichText,
$insertGeneratedNodes,
};

View File

@ -85,13 +85,18 @@ function $appendNodesToHTML(
let shouldInclude = selection != null ? currentNode.isSelected() : true;
const shouldExclude =
$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
let clone = $cloneWithProperties<LexicalNode>(currentNode);
clone =
$isTextNode(clone) && selection != null
? $sliceSelectedTextNodeContent(selection, clone)
: clone;
const children = $isElementNode(clone) ? clone.getChildren() : [];
const {element, after} = clone.exportDOM(editor);
let target = currentNode;
if (selection !== null) {
let clone = $cloneWithProperties<LexicalNode>(currentNode);
clone =
$isTextNode(clone) && selection != null
? $sliceSelectedTextNodeContent(selection, clone)
: clone;
target = clone;
}
const children = $isElementNode(target) ? target.getChildren() : [];
const {element, after} = target.exportDOM(editor);
if (!element) {
return false;
@ -123,7 +128,7 @@ function $appendNodesToHTML(
parentElement.append(element);
if (after) {
const newElement = after.call(clone, element);
const newElement = after.call(target, element);
if (newElement) element.replaceWith(newElement);
}
} else {

View File

@ -95,7 +95,9 @@ function onPasteForPlainText(
() => {
const selection = $getSelection();
const clipboardData =
event instanceof InputEvent ? null : event.clipboardData;
event instanceof InputEvent || event instanceof KeyboardEvent
? null
: event.clipboardData;
if (clipboardData != null && $isRangeSelection(selection)) {
$insertDataTransferForPlainText(clipboardData, selection);

View File

@ -21,6 +21,7 @@ import Editor from './Editor';
import logo from './images/logo.svg';
import PlaygroundNodes from './nodes/PlaygroundNodes';
import PasteLogPlugin from './plugins/PasteLogPlugin';
import {TableContext} from './plugins/TablePlugin';
import TestRecorderPlugin from './plugins/TestRecorderPlugin';
import TypingPerfPlugin from './plugins/TypingPerfPlugin';
import Settings from './Settings';
@ -132,20 +133,22 @@ function App(): JSX.Element {
return (
<LexicalComposer initialConfig={initialConfig}>
<SharedHistoryContext>
<SharedAutocompleteContext>
<header>
<a href="https://lexical.dev" target="_blank" rel="noopener">
<img src={logo} alt="Lexical Logo" />
</a>
</header>
<div className="editor-shell">
<Editor />
</div>
<Settings />
{isDevPlayground ? <PasteLogPlugin /> : null}
{isDevPlayground ? <TestRecorderPlugin /> : null}
{measureTypingPerf ? <TypingPerfPlugin /> : null}
</SharedAutocompleteContext>
<TableContext>
<SharedAutocompleteContext>
<header>
<a href="https://lexical.dev" target="_blank" rel="noopener">
<img src={logo} alt="Lexical Logo" />
</a>
</header>
<div className="editor-shell">
<Editor />
</div>
<Settings />
{isDevPlayground ? <PasteLogPlugin /> : null}
{isDevPlayground ? <TestRecorderPlugin /> : null}
{measureTypingPerf ? <TypingPerfPlugin /> : null}
</SharedAutocompleteContext>
</TableContext>
</SharedHistoryContext>
</LexicalComposer>
);

View File

@ -25,6 +25,7 @@ import {useRef, useState} from 'react';
import {createWebsocketProvider} from './collaboration';
import {useSettings} from './context/SettingsContext';
import {useSharedHistoryContext} from './context/SharedHistoryContext';
import TableCellNodes from './nodes/TableCellNodes';
import ActionsPlugin from './plugins/ActionsPlugin';
import AutocompletePlugin from './plugins/AutocompletePlugin';
import AutoEmbedPlugin from './plugins/AutoEmbedPlugin';
@ -50,13 +51,13 @@ import MentionsPlugin from './plugins/MentionsPlugin';
import PollPlugin from './plugins/PollPlugin';
import SpeechToTextPlugin from './plugins/SpeechToTextPlugin';
import TabFocusPlugin from './plugins/TabFocusPlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
import TableCellResizer from './plugins/TableCellResizer';
import TableOfContentsPlugin from './plugins/TableOfContentsPlugin';
import {TablePlugin as NewTablePlugin} from './plugins/TablePlugin';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import TreeViewPlugin from './plugins/TreeViewPlugin';
import TwitterPlugin from './plugins/TwitterPlugin';
import YouTubePlugin from './plugins/YouTubePlugin';
import PlaygroundEditorTheme from './themes/PlaygroundEditorTheme';
import ContentEditable from './ui/ContentEditable';
import Placeholder from './ui/Placeholder';
@ -94,6 +95,15 @@ export default function Editor(): JSX.Element {
}
};
const cellEditorConfig = {
namespace: 'Playground',
nodes: [...TableCellNodes],
onError: (error: Error) => {
throw error;
},
theme: PlaygroundEditorTheme,
};
return (
<>
{isRichText && <ToolbarPlugin />}
@ -146,7 +156,21 @@ export default function Editor(): JSX.Element {
<CheckListPlugin />
<ListMaxIndentLevelPlugin maxDepth={7} />
<TablePlugin />
<TableCellResizer />
<NewTablePlugin cellEditorConfig={cellEditorConfig}>
<AutoFocusPlugin />
<RichTextPlugin
contentEditable={
<ContentEditable className="TableNode__contentEditable" />
}
placeholder={''}
/>
<MentionsPlugin />
<HistoryPlugin />
<ImagesPlugin captionsEnabled={false} />
<LinkPlugin />
<ClickableLinkPlugin />
<FloatingTextFormatToolbarPlugin />
</NewTablePlugin>
<ImagesPlugin />
<LinkPlugin />
<PollPlugin />
@ -161,7 +185,6 @@ export default function Editor(): JSX.Element {
{floatingAnchorElem && (
<>
<CodeActionMenuPlugin anchorElem={floatingAnchorElem} />
<TableCellActionMenuPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
<FloatingTextFormatToolbarPlugin
anchorElem={floatingAnchorElem}

View File

@ -1555,3 +1555,20 @@ hr.selected {
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}
.TableNode__contentEditable {
min-height: 20px;
border: 0px;
resize: none;
cursor: text;
display: block;
position: relative;
tab-size: 1;
outline: 0px;
padding: 0;
user-select: text;
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
z-index: 3;
}

View File

@ -182,6 +182,7 @@ export default function ExcalidrawComponent({
editor={editor}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
captionsEnabled={true}
/>
)}
</button>

View File

@ -25,11 +25,9 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection';
import {mergeRegister} from '@lexical/utils';
import {
$createNodeSelection,
$getNodeByKey,
$getSelection,
$isNodeSelection,
@ -49,10 +47,8 @@ import {createWebsocketProvider} from '../collaboration';
import {useSettings} from '../context/SettingsContext';
import {useSharedHistoryContext} from '../context/SharedHistoryContext';
import EmojisPlugin from '../plugins/EmojisPlugin';
import ImagesPlugin from '../plugins/ImagesPlugin';
import KeywordsPlugin from '../plugins/KeywordsPlugin';
import MentionsPlugin from '../plugins/MentionsPlugin';
import TableCellActionMenuPlugin from '../plugins/TableActionMenuPlugin';
import TreeViewPlugin from '../plugins/TreeViewPlugin';
import ContentEditable from '../ui/ContentEditable';
import ImageResizer from '../ui/ImageResizer';
@ -118,6 +114,7 @@ export default function ImageComponent({
resizable,
showCaption,
caption,
captionsEnabled,
}: {
altText: string;
caption: LexicalEditor;
@ -128,8 +125,9 @@ export default function ImageComponent({
showCaption: boolean;
src: string;
width: 'inherit' | number;
captionsEnabled: boolean;
}): JSX.Element {
const imageRef = useRef(null);
const imageRef = useRef<null | HTMLImageElement>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
@ -194,19 +192,17 @@ export default function ImageComponent({
) {
$setSelection(null);
editor.update(() => {
const nodeSelection = $createNodeSelection();
nodeSelection.add(nodeKey);
setSelected(true);
const parentRootElement = editor.getRootElement();
if (parentRootElement !== null) {
parentRootElement.focus();
}
$setSelection(nodeSelection);
});
return true;
}
return false;
},
[caption, editor, nodeKey],
[caption, editor, setSelected],
);
useEffect(() => {
@ -231,10 +227,12 @@ export default function ImageComponent({
return true;
}
if (event.target === imageRef.current) {
if (!event.shiftKey) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
setSelected(!isSelected);
return true;
}
@ -332,9 +330,6 @@ export default function ImageComponent({
<LexicalNestedComposer initialEditor={caption}>
<AutoFocusPlugin />
<MentionsPlugin />
<TablePlugin />
<TableCellActionMenuPlugin />
<ImagesPlugin />
<LinkPlugin />
<EmojisPlugin />
<HashtagPlugin />
@ -374,6 +369,7 @@ export default function ImageComponent({
maxWidth={maxWidth}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
captionsEnabled={captionsEnabled}
/>
)}
</>

View File

@ -37,6 +37,7 @@ export interface ImagePayload {
showCaption?: boolean;
src: string;
width?: number;
captionsEnabled?: boolean;
}
function convertImageElement(domNode: Node): null | DOMConversionOutput {
@ -71,6 +72,8 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
__maxWidth: number;
__showCaption: boolean;
__caption: LexicalEditor;
// Captions cannot yet be used within editor cells
__captionsEnabled: boolean;
static getType(): string {
return 'image';
@ -85,6 +88,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
node.__height,
node.__showCaption,
node.__caption,
node.__captionsEnabled,
node.__key,
);
}
@ -132,6 +136,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
height?: 'inherit' | number,
showCaption?: boolean,
caption?: LexicalEditor,
captionsEnabled?: boolean,
key?: NodeKey,
) {
super(key);
@ -142,6 +147,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
this.__height = height || 'inherit';
this.__showCaption = showCaption || false;
this.__caption = caption || createEditor();
this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined;
}
exportJSON(): SerializedImageNode {
@ -208,6 +214,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
nodeKey={this.getKey()}
showCaption={this.__showCaption}
caption={this.__caption}
captionsEnabled={this.__captionsEnabled}
resizable={true}
/>
</Suspense>
@ -219,6 +226,7 @@ export function $createImageNode({
altText,
height,
maxWidth = 500,
captionsEnabled,
src,
width,
showCaption,
@ -233,6 +241,7 @@ export function $createImageNode({
height,
showCaption,
caption,
captionsEnabled,
key,
);
}

View File

@ -28,6 +28,7 @@ import {KeywordNode} from './KeywordNode';
import {MentionNode} from './MentionNode';
import {PollNode} from './PollNode';
import {StickyNode} from './StickyNode';
import {TableNode as NewTableNode} from './TableNode';
import {TweetNode} from './TweetNode';
import {TypeaheadNode} from './TypeaheadNode';
import {YouTubeNode} from './YouTubeNode';
@ -38,6 +39,7 @@ const PlaygroundNodes: Array<Klass<LexicalNode>> = [
ListItemNode,
QuoteNode,
CodeNode,
NewTableNode,
TableNode,
TableCellNode,
TableRowNode,

View File

@ -0,0 +1,46 @@
/**
* 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 type {Klass, LexicalNode} from 'lexical';
import {CodeHighlightNode, CodeNode} from '@lexical/code';
import {HashtagNode} from '@lexical/hashtag';
import {AutoLinkNode, LinkNode} from '@lexical/link';
import {ListItemNode, ListNode} from '@lexical/list';
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
import {AutocompleteNode} from './AutocompleteNode';
import {EmojiNode} from './EmojiNode';
import {EquationNode} from './EquationNode';
import {ExcalidrawNode} from './ExcalidrawNode';
import {ImageNode} from './ImageNode';
import {KeywordNode} from './KeywordNode';
import {MentionNode} from './MentionNode';
import {TypeaheadNode} from './TypeaheadNode';
const PlaygroundNodes: Array<Klass<LexicalNode>> = [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
HashtagNode,
CodeHighlightNode,
AutoLinkNode,
LinkNode,
ImageNode,
MentionNode,
EmojiNode,
ExcalidrawNode,
EquationNode,
AutocompleteNode,
TypeaheadNode,
KeywordNode,
];
export default PlaygroundNodes;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,424 @@
/**
* 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 type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import {DecoratorNode} from 'lexical';
import * as React from 'react';
import {Suspense} from 'react';
export type Cell = {
colSpan: number;
json: string;
type: 'normal' | 'header';
id: string;
width: number | null;
};
export type Row = {
cells: Array<Cell>;
height: null | number;
id: string;
};
export type Rows = Array<Row>;
export const cellHTMLCache: Map<string, string> = new Map();
export const cellTextContentCache: Map<string, string> = new Map();
const emptyEditorJSON =
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
const plainTextEditorJSON = (text: string) =>
text === ''
? emptyEditorJSON
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`;
const TableComponent = React.lazy(
// @ts-ignore
() => import('./TableComponent'),
);
export function createUID(): string {
return Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(0, 5);
}
function createCell(type: 'normal' | 'header'): Cell {
return {
colSpan: 1,
id: createUID(),
json: emptyEditorJSON,
type,
width: null,
};
}
export function createRow(): Row {
return {
cells: [],
height: null,
id: createUID(),
};
}
export type SerializedTableNode = Spread<
{
rows: Rows;
type: 'tablesheet';
version: 1;
},
SerializedLexicalNode
>;
export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows {
const rowElems = tableElem.querySelectorAll('tr');
const rows: Rows = [];
for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y];
const cellElems = rowElem.querySelectorAll('td,th');
if (!cellElems || cellElems.length === 0) {
continue;
}
const cells: Array<Cell> = [];
for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement;
const isHeader = cellElem.nodeName === 'TH';
const cell = createCell(isHeader ? 'header' : 'normal');
cell.json = plainTextEditorJSON(
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
);
cells.push(cell);
}
const row = createRow();
row.cells = cells;
rows.push(row);
}
return rows;
}
function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
const rowElems = domNode.querySelectorAll('tr');
if (!rowElems || rowElems.length === 0) {
return null;
}
const rows: Rows = [];
for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y];
const cellElems = rowElem.querySelectorAll('td,th');
if (!cellElems || cellElems.length === 0) {
continue;
}
const cells: Array<Cell> = [];
for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement;
const isHeader = cellElem.nodeName === 'TH';
const cell = createCell(isHeader ? 'header' : 'normal');
cell.json = plainTextEditorJSON(
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
);
cells.push(cell);
}
const row = createRow();
row.cells = cells;
rows.push(row);
}
return {node: $createTableNode(rows)};
}
export function exportTableCellsToHTML(
rows: Rows,
rect?: {startX: number; endX: number; startY: number; endY: number},
): HTMLElement {
const table = document.createElement('table');
const colGroup = document.createElement('colgroup');
const tBody = document.createElement('tbody');
const firstRow = rows[0];
for (
let x = rect != null ? rect.startX : 0;
x < (rect != null ? rect.endX + 1 : firstRow.cells.length);
x++
) {
const col = document.createElement('col');
colGroup.append(col);
}
for (
let y = rect != null ? rect.startY : 0;
y < (rect != null ? rect.endY + 1 : rows.length);
y++
) {
const row = rows[y];
const cells = row.cells;
const rowElem = document.createElement('tr');
for (
let x = rect != null ? rect.startX : 0;
x < (rect != null ? rect.endX + 1 : cells.length);
x++
) {
const cell = cells[x];
const cellElem = document.createElement(
cell.type === 'header' ? 'th' : 'td',
);
cellElem.innerHTML = cellHTMLCache.get(cell.json) || '';
rowElem.appendChild(cellElem);
}
tBody.appendChild(rowElem);
}
table.appendChild(colGroup);
table.appendChild(tBody);
return table;
}
export class TableNode extends DecoratorNode<JSX.Element> {
__rows: Rows;
static getType(): string {
return 'tablesheet';
}
static clone(node: TableNode): TableNode {
return new TableNode(Array.from(node.__rows), node.__key);
}
static importJSON(serializedNode: SerializedTableNode): TableNode {
return $createTableNode(serializedNode.rows);
}
exportJSON(): SerializedTableNode {
return {
rows: this.__rows,
type: 'tablesheet',
version: 1,
};
}
static importDOM(): DOMConversionMap | null {
return {
table: (_node: Node) => ({
conversion: convertTableElement,
priority: 0,
}),
};
}
exportDOM(): DOMExportOutput {
return {element: exportTableCellsToHTML(this.__rows)};
}
constructor(rows?: Rows, key?: NodeKey) {
super(key);
this.__rows = rows || [];
}
createDOM(): HTMLElement {
const div = document.createElement('div');
div.style.display = 'contents';
return div;
}
updateDOM(): false {
return false;
}
mergeRows(startX: number, startY: number, mergeRows: Rows): void {
const self = this.getWritable();
const rows = self.__rows;
const endY = Math.min(rows.length, startY + mergeRows.length);
for (let y = startY; y < endY; y++) {
const row = rows[y];
const mergeRow = mergeRows[y - startY];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
const mergeCells = mergeRow.cells;
const endX = Math.min(cells.length, startX + mergeCells.length);
for (let x = startX; x < endX; x++) {
const cell = cells[x];
const mergeCell = mergeCells[x - startX];
const cellClone = {...cell, json: mergeCell.json, type: mergeCell.type};
cellsClone[x] = cellClone;
}
rows[y] = rowClone;
}
}
updateCellJSON(x: number, y: number, json: string): void {
const self = this.getWritable();
const rows = self.__rows;
const row = rows[y];
const cells = row.cells;
const cell = cells[x];
const cellsClone = Array.from(cells);
const cellClone = {...cell, json};
const rowClone = {...row, cells: cellsClone};
cellsClone[x] = cellClone;
rows[y] = rowClone;
}
updateCellType(x: number, y: number, type: 'header' | 'normal'): void {
const self = this.getWritable();
const rows = self.__rows;
const row = rows[y];
const cells = row.cells;
const cell = cells[x];
const cellsClone = Array.from(cells);
const cellClone = {...cell, type};
const rowClone = {...row, cells: cellsClone};
cellsClone[x] = cellClone;
rows[y] = rowClone;
}
insertColumnAt(x: number): void {
const self = this.getWritable();
const rows = self.__rows;
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
const type = (cells[x] || cells[x - 1]).type;
cellsClone.splice(x, 0, createCell(type));
rows[y] = rowClone;
}
}
deleteColumnAt(x: number): void {
const self = this.getWritable();
const rows = self.__rows;
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
cellsClone.splice(x, 1);
rows[y] = rowClone;
}
}
addColumns(count: number): void {
const self = this.getWritable();
const rows = self.__rows;
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
const type = cells[cells.length - 1].type;
for (let x = 0; x < count; x++) {
cellsClone.push(createCell(type));
}
rows[y] = rowClone;
}
}
insertRowAt(y: number): void {
const self = this.getWritable();
const rows = self.__rows;
const prevRow = rows[y] || rows[y - 1];
const cellCount = prevRow.cells.length;
const row = createRow();
for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type);
row.cells.push(cell);
}
rows.splice(y, 0, row);
}
deleteRowAt(y: number): void {
const self = this.getWritable();
const rows = self.__rows;
rows.splice(y, 1);
}
addRows(count: number): void {
const self = this.getWritable();
const rows = self.__rows;
const prevRow = rows[rows.length - 1];
const cellCount = prevRow.cells.length;
for (let y = 0; y < count; y++) {
const row = createRow();
for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type);
row.cells.push(cell);
}
rows.push(row);
}
}
updateColumnWidth(x: number, width: number): void {
const self = this.getWritable();
const rows = self.__rows;
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
cellsClone[x].width = width;
rows[y] = rowClone;
}
}
decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
return (
<Suspense>
<TableComponent
nodeKey={this.__key}
theme={config.theme}
rows={this.__rows}
/>
</Suspense>
);
}
}
export function $isTableNode(
node: LexicalNode | null | undefined,
): node is TableNode {
return node instanceof TableNode;
}
export function $createTableNode(rows: Rows): TableNode {
return new TableNode(rows);
}
export function $createTableNodeWithDimensions(
rowCount: number,
columnCount: number,
includeHeaders = true,
): TableNode {
const rows: Rows = [];
for (let y = 0; y < columnCount; y++) {
const row: Row = createRow();
rows.push(row);
for (let x = 0; x < rowCount; x++) {
row.cells.push(
createCell(
includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal',
),
);
}
}
return new TableNode(rows);
}

View File

@ -22,7 +22,6 @@ import {
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
import {$wrapLeafNodesInElements} from '@lexical/selection';
import {INSERT_TABLE_COMMAND} from '@lexical/table';
import {
$createParagraphNode,
$getSelection,
@ -39,6 +38,7 @@ import catTypingGif from '../../images/cat-typing.gif';
import {EmbedConfigs} from '../AutoEmbedPlugin';
import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawPlugin';
import {INSERT_IMAGE_COMMAND} from '../ImagesPlugin';
import {INSERT_TABLE_COMMAND} from '../TablePlugin';
import {
InsertEquationDialog,
InsertImageDialog,

View File

@ -291,9 +291,16 @@ function useFloatingTextFormatToolbar(
}, [updatePopup]);
useEffect(() => {
return editor.registerUpdateListener(() => {
updatePopup();
});
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
}
}),
);
}, [editor, updatePopup]);
if (!isText || isLink) {
@ -320,7 +327,7 @@ function useFloatingTextFormatToolbar(
export default function FloatingTextFormatToolbarPlugin({
anchorElem = document.body,
}: {
anchorElem: HTMLElement;
anchorElem?: HTMLElement;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem);

View File

@ -38,7 +38,11 @@ export type InsertImagePayload = Readonly<ImagePayload>;
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
createCommand();
export default function ImagesPlugin(): JSX.Element | null {
export default function ImagesPlugin({
captionsEnabled,
}: {
captionsEnabled?: boolean;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
@ -55,7 +59,7 @@ export default function ImagesPlugin(): JSX.Element | null {
if ($isRootNode(selection.anchor.getNode())) {
selection.insertParagraph();
}
const imageNode = $createImageNode(payload);
const imageNode = $createImageNode({captionsEnabled, ...payload});
selection.insertNodes([imageNode]);
}
return true;
@ -84,7 +88,7 @@ export default function ImagesPlugin(): JSX.Element | null {
COMMAND_PRIORITY_HIGH,
),
);
}, [editor]);
}, [captionsEnabled, editor]);
return null;
}

View File

@ -138,6 +138,7 @@ export const TWEET: ElementTransformer = {
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
export const TABLE: ElementTransformer = {
// TODO: refactor transformer for new TableNode
dependencies: [TableNode, TableRowNode, TableCellNode],
export: (
node: LexicalNode,

View File

@ -0,0 +1,155 @@
/**
* 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 {
$createNodeSelection,
$createParagraphNode,
$getSelection,
$isRangeSelection,
$isRootNode,
$setSelection,
COMMAND_PRIORITY_EDITOR,
createCommand,
EditorThemeClasses,
Klass,
LexicalCommand,
LexicalEditor,
LexicalNode,
} from 'lexical';
import {createContext, useContext, useEffect, useMemo, useState} from 'react';
import * as React from 'react';
import invariant from 'shared/invariant';
import {$createTableNodeWithDimensions, TableNode} from '../nodes/TableNode';
export type InsertTableCommandPayload = Readonly<{
columns: string;
rows: string;
includeHeaders?: boolean;
}>;
export type CellContextShape = {
cellEditorConfig: null | CellEditorConfig;
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
set: (
cellEditorConfig: null | CellEditorConfig,
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>,
) => void;
};
export type CellEditorConfig = Readonly<{
namespace: string;
nodes?: ReadonlyArray<Klass<LexicalNode>>;
onError: (error: Error, editor: LexicalEditor) => void;
readOnly?: boolean;
theme?: EditorThemeClasses;
}>;
export const INSERT_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
createCommand();
// @ts-ignore: not sure why TS doesn't like using null as the value?
export const CellContext: React.Context<CellContextShape> = createContext({
cellEditorConfig: null,
cellEditorPlugins: null,
set: () => {
// Empty
},
});
export function TableContext({children}: {children: JSX.Element}) {
const [contextValue, setContextValue] = useState<{
cellEditorConfig: null | CellEditorConfig;
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
}>({
cellEditorConfig: null,
cellEditorPlugins: null,
});
return (
<CellContext.Provider
value={useMemo(
() => ({
cellEditorConfig: contextValue.cellEditorConfig,
cellEditorPlugins: contextValue.cellEditorPlugins,
set: (cellEditorConfig, cellEditorPlugins) => {
setContextValue({cellEditorConfig, cellEditorPlugins});
},
}),
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
)}>
{children}
</CellContext.Provider>
);
}
export function TablePlugin({
cellEditorConfig,
children,
}: {
cellEditorConfig: CellEditorConfig;
children: JSX.Element | Array<JSX.Element>;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const cellContext = useContext(CellContext);
useEffect(() => {
if (!editor.hasNodes([TableNode])) {
invariant(false, 'TablePlugin: TableNode is not registered on editor');
}
cellContext.set(cellEditorConfig, children);
return editor.registerCommand<InsertTableCommandPayload>(
INSERT_TABLE_COMMAND,
({columns, rows, includeHeaders}) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return true;
}
const focus = selection.focus;
const focusNode = focus.getNode();
if (focusNode !== null) {
const tableNode = $createTableNodeWithDimensions(
Number(rows),
Number(columns),
includeHeaders,
);
if ($isRootNode(focusNode)) {
const target = focusNode.getChildAtIndex(focus.offset);
if (target !== null) {
target.insertBefore(tableNode);
} else {
focusNode.append(tableNode);
}
tableNode.insertBefore($createParagraphNode());
} else {
const topLevelNode = focusNode.getTopLevelElementOrThrow();
topLevelNode.insertAfter(tableNode);
}
tableNode.insertAfter($createParagraphNode());
const nodeSelection = $createNodeSelection();
nodeSelection.add(tableNode.getKey());
$setSelection(nodeSelection);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
);
}, [cellContext, cellEditorConfig, children, editor]);
return null;
}

View File

@ -90,6 +90,7 @@ import {INSERT_EQUATION_COMMAND} from '../EquationsPlugin';
import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawPlugin';
import {INSERT_IMAGE_COMMAND} from '../ImagesPlugin';
import {INSERT_POLL_COMMAND} from '../PollPlugin';
import {INSERT_TABLE_COMMAND as INSERT_NEW_TABLE_COMMAND} from '../TablePlugin';
const blockTypeToBlockName = {
bullet: 'Bulleted List',
@ -305,6 +306,34 @@ export function InsertTableDialog({
);
}
export function InsertNewTableDialog({
activeEditor,
onClose,
}: {
activeEditor: LexicalEditor;
onClose: () => void;
}): JSX.Element {
const [rows, setRows] = useState('5');
const [columns, setColumns] = useState('5');
const onClick = () => {
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, {columns, rows});
onClose();
};
return (
<>
<TextInput label="No of rows" onChange={setRows} value={rows} />
<TextInput label="No of columns" onChange={setColumns} value={columns} />
<div
className="ToolbarPlugin__dialogActions"
data-test-id="table-model-confirm-insert">
<Button onClick={onClick}>Confirm</Button>
</div>
</>
);
}
export function InsertPollDialog({
activeEditor,
onClose,
@ -1012,6 +1041,19 @@ export default function ToolbarPlugin(): JSX.Element {
<i className="icon table" />
<span className="text">Table</span>
</DropDownItem>
<DropDownItem
onClick={() => {
showModal('Insert Table', (onClose) => (
<InsertNewTableDialog
activeEditor={activeEditor}
onClose={onClose}
/>
));
}}
className="item">
<i className="icon table" />
<span className="text">Table (Experimental)</span>
</DropDownItem>
<DropDownItem
onClick={() => {
showModal('Insert Poll', (onClose) => (

View File

@ -129,19 +129,156 @@
max-width: 100%;
overflow-y: scroll;
table-layout: fixed;
width: 100%;
width: calc(100% - 25px);
margin: 30px 0;
}
.PlaygroundEditorTheme__tableSelected {
outline: 2px solid rgb(60, 132, 244);
}
.PlaygroundEditorTheme__tableCell {
border: 1px solid black;
padding: 6px 8px;
border: 1px solid #bbb;
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
cursor: default;
outline: none;
}
.PlaygroundEditorTheme__tableCellSortedIndicator {
display: block;
opacity: 0.5;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #999;
}
.PlaygroundEditorTheme__tableCellResizer {
position: absolute;
right: -4px;
height: 100%;
width: 8px;
cursor: ew-resize;
z-index: 10;
top: 0;
}
.PlaygroundEditorTheme__tableCellHeader {
background-color: #f2f3f5;
text-align: start;
}
.PlaygroundEditorTheme__tableCellSelected {
background-color: #c9dbf0;
}
.PlaygroundEditorTheme__tableCellPrimarySelected {
border: 2px solid rgb(60, 132, 244);
display: block;
height: calc(100% - 2px);
position: absolute;
width: calc(100% - 2px);
left: -1px;
top: -1px;
z-index: 2;
}
.PlaygroundEditorTheme__tableCellEditing {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
border-radius: 3px;
}
.PlaygroundEditorTheme__tableAddColumns {
position: absolute;
top: 0;
width: 20px;
background-color: #eee;
height: 100%;
right: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
.PlaygroundEditorTheme__tableAddColumns:after {
background-image: url(../images/icons/plus.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
display: block;
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.4;
}
.PlaygroundEditorTheme__tableAddColumns:hover {
background-color: #c9dbf0;
}
.PlaygroundEditorTheme__tableAddRows {
position: absolute;
bottom: -25px;
width: calc(100% - 25px);
background-color: #eee;
height: 20px;
left: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
.PlaygroundEditorTheme__tableAddRows:after {
background-image: url(../images/icons/plus.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
display: block;
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.4;
}
.PlaygroundEditorTheme__tableAddRows:hover {
background-color: #c9dbf0;
}
@keyframes table-controls {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.PlaygroundEditorTheme__tableCellResizeRuler {
display: block;
position: absolute;
width: 1px;
background-color: rgb(60, 132, 244);
height: 100%;
top: 0;
}
.PlaygroundEditorTheme__tableCellActionButtonContainer {
display: block;
right: 5px;
top: 6px;
position: absolute;
z-index: 4;
width: 20px;
height: 20px;
}
.PlaygroundEditorTheme__tableCellActionButton {
background-color: #eee;
display: block;
border: 0;
border-radius: 20px;
width: 20px;
height: 20px;
color: #222;
cursor: pointer;
}
.PlaygroundEditorTheme__tableCellActionButton:hover {
background-color: #ddd;
}
.PlaygroundEditorTheme__characterLimit {
display: inline;
background-color: #ffbbbb !important;

View File

@ -83,8 +83,20 @@ const theme: EditorThemeClasses = {
quote: 'PlaygroundEditorTheme__quote',
rtl: 'PlaygroundEditorTheme__rtl',
table: 'PlaygroundEditorTheme__table',
tableAddColumns: 'PlaygroundEditorTheme__tableAddColumns',
tableAddRows: 'PlaygroundEditorTheme__tableAddRows',
tableCell: 'PlaygroundEditorTheme__tableCell',
tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton',
tableCellActionButtonContainer:
'PlaygroundEditorTheme__tableCellActionButtonContainer',
tableCellEditing: 'PlaygroundEditorTheme__tableCellEditing',
tableCellHeader: 'PlaygroundEditorTheme__tableCellHeader',
tableCellPrimarySelected: 'PlaygroundEditorTheme__tableCellPrimarySelected',
tableCellResizer: 'PlaygroundEditorTheme__tableCellResizer',
tableCellSelected: 'PlaygroundEditorTheme__tableCellSelected',
tableCellSortedIndicator: 'PlaygroundEditorTheme__tableCellSortedIndicator',
tableResizeRuler: 'PlaygroundEditorTheme__tableCellResizeRuler',
tableSelected: 'PlaygroundEditorTheme__tableSelected',
text: {
bold: 'PlaygroundEditorTheme__textBold',
code: 'PlaygroundEditorTheme__textCode',

View File

@ -31,6 +31,7 @@ export default function ImageResizer({
editor,
showCaption,
setShowCaption,
captionsEnabled,
}: {
editor: LexicalEditor;
buttonRef: {current: null | HTMLButtonElement};
@ -40,6 +41,7 @@ export default function ImageResizer({
onResizeStart: () => void;
setShowCaption: (show: boolean) => void;
showCaption: boolean;
captionsEnabled: boolean;
}): JSX.Element {
const controlWrapperRef = useRef<HTMLDivElement>(null);
const userSelect = useRef({
@ -243,7 +245,7 @@ export default function ImageResizer({
};
return (
<div ref={controlWrapperRef}>
{!showCaption && (
{!showCaption && captionsEnabled && (
<button
className="image-caption-button"
ref={buttonRef}

View File

@ -28,11 +28,13 @@ export function LexicalNestedComposer({
children,
initialNodes,
initialTheme,
skipCollabChecks,
}: {
children: ReactNode;
initialEditor: LexicalEditor;
initialTheme?: EditorThemeClasses;
initialNodes?: ReadonlyArray<Klass<LexicalNode>>;
skipCollabChecks?: true;
}): JSX.Element {
const wasCollabPreviouslyReadyRef = useRef(false);
const parentContext = useContext(LexicalComposerContext);
@ -91,6 +93,7 @@ export function LexicalNestedComposer({
// until the collaboration subdocument is ready.
const {isCollabActive, yjsDocMap} = useCollaborationContext();
const isCollabReady =
skipCollabChecks ||
wasCollabPreviouslyReadyRef.current ||
yjsDocMap.has(initialEditor.getKey());

View File

@ -420,7 +420,9 @@ function onPasteForRichText(
() => {
const selection = $getSelection();
const clipboardData =
event instanceof InputEvent ? null : event.clipboardData;
event instanceof InputEvent || event instanceof KeyboardEvent
? null
: event.clipboardData;
if (
clipboardData != null &&
($isRangeSelection(selection) || $isGridSelection(selection))
@ -732,10 +734,7 @@ export function registerRichText(
KEY_ARROW_DOWN_COMMAND,
(event) => {
const selection = $getSelection();
if (
$isNodeSelection(selection) &&
!isTargetWithinDecorator(event.target as HTMLElement)
) {
if ($isNodeSelection(selection)) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
const nodes = selection.getNodes();
@ -752,10 +751,7 @@ export function registerRichText(
KEY_ARROW_LEFT_COMMAND,
(event) => {
const selection = $getSelection();
if (
$isNodeSelection(selection) &&
!isTargetWithinDecorator(event.target as HTMLElement)
) {
if ($isNodeSelection(selection)) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
const nodes = selection.getNodes();

View File

@ -52,7 +52,7 @@ export class TableNode extends GridNode {
return {
table: (_node: Node) => ({
conversion: convertTableElement,
priority: 0,
priority: 1,
}),
};
}

View File

@ -22,8 +22,9 @@ export const INSERT_PARAGRAPH_COMMAND: LexicalCommand<void> = createCommand();
export const CONTROLLED_TEXT_INSERTION_COMMAND: LexicalCommand<
InputEvent | string
> = createCommand();
export const PASTE_COMMAND: LexicalCommand<ClipboardEvent | InputEvent> =
createCommand();
export const PASTE_COMMAND: LexicalCommand<
ClipboardEvent | InputEvent | KeyboardEvent
> = createCommand();
export const REMOVE_TEXT_COMMAND: LexicalCommand<void> = createCommand();
export const DELETE_WORD_COMMAND: LexicalCommand<boolean> = createCommand();
export const DELETE_LINE_COMMAND: LexicalCommand<boolean> = createCommand();

View File

@ -106,9 +106,20 @@ export type EditorThemeClasses = {
root?: EditorThemeClassName;
rtl?: EditorThemeClassName;
table?: EditorThemeClassName;
tableAddColumns?: EditorThemeClassName;
tableAddRows?: EditorThemeClassName;
tableCellActionButton?: EditorThemeClassName;
tableCellActionButtonContainer?: EditorThemeClassName;
tableCellPrimarySelected?: EditorThemeClassName;
tableCellSelected?: EditorThemeClassName;
tableCell?: EditorThemeClassName;
tableCellEditing?: EditorThemeClassName;
tableCellHeader?: EditorThemeClassName;
tableCellResizer?: EditorThemeClassName;
tableCellSortedIndicator?: EditorThemeClassName;
tableResizeRuler?: EditorThemeClassName;
tableRow?: EditorThemeClassName;
tableSelected?: EditorThemeClassName;
text?: TextNodeThemeClasses;
embedBlock?: {
base?: EditorThemeClassName;

View File

@ -797,7 +797,7 @@ function errorOnTypeKlassMismatch(
if (registeredNode === undefined) {
invariant(
false,
'Create node: Attempted to create node %s that was not previously registered on the editor. You can use register your custom nodes.',
'Create node: Attempted to create node %s that was not configured to be used on the editor.',
klass.name,
);
}

View File

@ -62,6 +62,7 @@ import {
errorOnReadOnly,
getActiveEditor,
getActiveEditorState,
isCurrentlyReadOnlyMode,
triggerCommandListeners,
updateEditor,
} from './LexicalUpdates';
@ -326,6 +327,9 @@ export function $setCompositionKey(compositionKey: null | NodeKey): void {
}
export function $getCompositionKey(): null | NodeKey {
if (isCurrentlyReadOnlyMode()) {
return null;
}
const editor = getActiveEditor();
return editor._compositionKey;
}

View File

@ -123,6 +123,7 @@ export {
} from './LexicalSelection';
export {$parseSerializedNode} from './LexicalUpdates';
export {
$addUpdateTag,
$getDecoratorNode,
$getNearestNodeFromDOMNode,
$getNodeByKey,