mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 06:59:17 +08:00
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:
@ -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++) {
|
||||
|
@ -8,15 +8,21 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
$generateJSONFromSelectedNodes,
|
||||
$generateNodesFromSerializedNodes,
|
||||
$getHtmlContent,
|
||||
$getLexicalContent,
|
||||
$insertDataTransferForPlainText,
|
||||
$insertDataTransferForRichText,
|
||||
$insertGeneratedNodes,
|
||||
} from './clipboard';
|
||||
|
||||
export {
|
||||
$generateJSONFromSelectedNodes,
|
||||
$generateNodesFromSerializedNodes,
|
||||
$getHtmlContent,
|
||||
$getLexicalContent,
|
||||
$insertDataTransferForPlainText,
|
||||
$insertDataTransferForRichText,
|
||||
$insertGeneratedNodes,
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -182,6 +182,7 @@ export default function ExcalidrawComponent({
|
||||
editor={editor}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeEnd={onResizeEnd}
|
||||
captionsEnabled={true}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
46
packages/lexical-playground/src/nodes/TableCellNodes.ts
Normal file
46
packages/lexical-playground/src/nodes/TableCellNodes.ts
Normal 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;
|
1773
packages/lexical-playground/src/nodes/TableComponent.tsx
Normal file
1773
packages/lexical-playground/src/nodes/TableComponent.tsx
Normal file
File diff suppressed because it is too large
Load Diff
424
packages/lexical-playground/src/nodes/TableNode.tsx
Normal file
424
packages/lexical-playground/src/nodes/TableNode.tsx
Normal 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);
|
||||
}
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
155
packages/lexical-playground/src/plugins/TablePlugin.tsx
Normal file
155
packages/lexical-playground/src/plugins/TablePlugin.tsx
Normal 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;
|
||||
}
|
@ -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) => (
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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();
|
||||
|
@ -52,7 +52,7 @@ export class TableNode extends GridNode {
|
||||
return {
|
||||
table: (_node: Node) => ({
|
||||
conversion: convertTableElement,
|
||||
priority: 0,
|
||||
priority: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -123,6 +123,7 @@ export {
|
||||
} from './LexicalSelection';
|
||||
export {$parseSerializedNode} from './LexicalUpdates';
|
||||
export {
|
||||
$addUpdateTag,
|
||||
$getDecoratorNode,
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeByKey,
|
||||
|
Reference in New Issue
Block a user