Skip to content

Commit

Permalink
Rework export to HTML for clipboard to better support Tables and Lists (
Browse files Browse the repository at this point in the history
#1610)

* Rework export to HTML for clipboard to better support Tables and Lists

* Improve Selection Extract Logic
  • Loading branch information
tylerjbainbridge authored and acywatson committed Apr 9, 2022
1 parent fa1d329 commit cd08594
Show file tree
Hide file tree
Showing 24 changed files with 274 additions and 41 deletions.
125 changes: 114 additions & 11 deletions packages/lexical-clipboard/src/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import type {
DOMChildConversion,
DOMConversion,
DOMConversionFn,
GridSelection,
LexicalEditor,
LexicalNode,
NodeKey,
NodeSelection,
ParsedNodeMap,
RangeSelection,
} from 'lexical';
Expand All @@ -22,27 +24,128 @@ import {$cloneContents} from '@lexical/selection';
import {
$createNodeFromParse,
$createParagraphNode,
$getNodeByKey,
$getSelection,
$isElementNode,
$isGridSelection,
$isRangeSelection,
$isTextNode,
} from 'lexical';
import getDOMSelection from 'shared/getDOMSelection';

const IGNORE_TAGS = new Set(['STYLE']);

export function getHtmlContent(editor: LexicalEditor): string | null {
const domSelection = getDOMSelection();
// If we haven't selected a range, then don't copy anything
if (domSelection.isCollapsed) {
const selection = $getSelection();

if (selection == null) {
throw new Error('Expected valid LexicalSelection');
}

// If we haven't selected anything
if (
($isRangeSelection(selection) && selection.isCollapsed()) ||
selection.getNodes().length === 0
) {
return null;
}
const range = domSelection.getRangeAt(0);
if (range) {
const container = document.createElement('div');
const frag = range.cloneContents();
container.appendChild(frag);
return container.innerHTML;

const state = $cloneContents(selection);
return $convertSelectedLexicalContentToHtml(editor, selection, state);
}

export function $convertSelectedLexicalNodeToHTMLElement(
editor: LexicalEditor,
selection: RangeSelection | NodeSelection | GridSelection,
node: LexicalNode,
): ?HTMLElement {
let nodeToConvert = node;

if ($isRangeSelection(selection) || $isGridSelection(selection)) {
const anchor = selection.anchor.getNode();
const focus = selection.focus.getNode();
const isAnchor = node.is(anchor);
const isFocus = node.is(focus);

if ($isTextNode(node) && (isAnchor || isFocus)) {
const anchorOffset = selection.anchor.getCharacterOffset();
const focusOffset = selection.focus.getCharacterOffset();
const isBackward = selection.isBackward();

const isSame = anchor.is(focus);
const isFirst = node.is(isBackward ? focus : anchor);

const nodeText = node.getTextContent();
const nodeTextLength = nodeText.length;

if (isSame) {
const startOffset =
anchorOffset > focusOffset ? focusOffset : anchorOffset;
const endOffset =
anchorOffset > focusOffset ? anchorOffset : focusOffset;
const splitNodes = node.splitText(startOffset, endOffset);
nodeToConvert = startOffset === 0 ? splitNodes[0] : splitNodes[1];
} else {
let endOffset;

if (isFirst) {
endOffset = isBackward ? focusOffset : anchorOffset;
} else {
endOffset = isBackward ? anchorOffset : focusOffset;
}

if (!isBackward && endOffset === 0) {
return null;
} else if (endOffset !== nodeTextLength) {
nodeToConvert =
node.splitText(endOffset)[isFirst && endOffset !== 0 ? 1 : 0];
}
}
}
}

const {element, after} = nodeToConvert.exportDOM(editor);
if (!element) return null;
const children = $isElementNode(nodeToConvert)
? nodeToConvert.getChildren()
: [];
for (let i = 0; i < children.length; i++) {
const childNode = children[i];

if (childNode.isSelected()) {
const newElement = $convertSelectedLexicalNodeToHTMLElement(
editor,
selection,
childNode,
);
if (newElement) element.append(newElement);
}
}
return null;

return after ? after.call(nodeToConvert, element) : element;
}

export function $convertSelectedLexicalContentToHtml(
editor: LexicalEditor,
selection: RangeSelection | NodeSelection | GridSelection,
state: {
nodeMap: Array<[NodeKey, LexicalNode]>,
range: Array<NodeKey>,
},
): string {
const container = document.createElement('div');
for (let i = 0; i < state.range.length; i++) {
const nodeKey = state.range[i];
const node = $getNodeByKey(nodeKey);
if (node && node.isSelected()) {
const element = $convertSelectedLexicalNodeToHTMLElement(
editor,
selection,
node,
);
if (element) container.append(element);
}
}
return container.innerHTML;
}

export function $getLexicalContent(editor: LexicalEditor): string | null {
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-code/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export class CodeNode extends ElementNode {
return false;
}

static convertDOM(): DOMConversionMap | null {
static importDOM(): DOMConversionMap | null {
return {
div: (node: Node) => ({
conversion: convertDivElement,
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-link/LexicalLink.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export declare class LinkNode extends ElementNode {
dom: HTMLElement,
config: EditorConfig<EditorContext>,
): boolean;
static convertDOM(): DOMConversionMap | null;
static importDOM(): DOMConversionMap | null;
getURL(): string;
setURL(url: string): void;
insertNewAfter(selection: RangeSelection): null | ElementNode;
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-link/flow/LexicalLink.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ declare export class LinkNode extends ElementNode {
dom: HTMLElement,
config: EditorConfig<EditorContext>,
): boolean;
static convertDOM(): DOMConversionMap | null;
static importDOM(): DOMConversionMap | null;
getURL(): string;
setURL(url: string): void;
insertNewAfter(selection: RangeSelection): null | ElementNode;
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-link/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class LinkNode extends ElementNode {
return false;
}

static convertDOM(): DOMConversionMap | null {
static importDOM(): DOMConversionMap | null {
return {
a: (node: Node) => ({
conversion: convertAnchorElement,
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-list/src/LexicalListItemNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class ListItemNode extends ElementNode {
return false;
}

static convertDOM(): DOMConversionMap | null {
static importDOM(): DOMConversionMap | null {
return {
li: (node: Node) => ({
conversion: convertListItemElement,
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-list/src/LexicalListNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class ListNode extends ElementNode {
return false;
}

static convertDOM(): DOMConversionMap | null {
static importDOM(): DOMConversionMap | null {
return {
ol: (node: Node) => ({
conversion: convertListNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
.PlaygroundEditorTheme__tableCell {
border: 1px solid black;
padding: 8px;
height: 20px;
height: 40px;
min-width: 75px;
vertical-align: top;
text-align: start;
Expand Down
25 changes: 24 additions & 1 deletion packages/lexical-react/src/LexicalHorizontalRuleNode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
* @flow strict
*/

import type {LexicalCommand, LexicalNode} from 'lexical';
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
LexicalCommand,
LexicalNode,
} from 'lexical';

import {createCommand, DecoratorNode} from 'lexical';
import * as React from 'react';
Expand All @@ -28,6 +34,19 @@ export class HorizontalRuleNode extends DecoratorNode<React$Node> {
return new HorizontalRuleNode(node.__state, node.__key);
}

static importDOM(): DOMConversionMap | null {
return {
hr: (node: Node) => ({
conversion: convertHorizontalRuleElement,
priority: 0,
}),
};
}

exportDOM(): DOMExportOutput {
return {element: document.createElement('hr')};
}

createDOM(): HTMLElement {
const div = document.createElement('div');
div.style.display = 'contents';
Expand All @@ -51,6 +70,10 @@ export class HorizontalRuleNode extends DecoratorNode<React$Node> {
}
}

function convertHorizontalRuleElement(): DOMConversionOutput {
return {node: $createHorizontalRuleNode()};
}

export function $createHorizontalRuleNode(): HorizontalRuleNode {
return new HorizontalRuleNode();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-rich-text/LexicalRichText.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export declare class HeadingNode extends ElementNode {
getTag(): HeadingTagType;
createDOM<EditorContext>(config: EditorConfig<EditorContext>): HTMLElement;
updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean;
static convertDOM(): DOMConversionMap | null;
static importDOM(): DOMConversionMap | null;
insertNewAfter(): ParagraphNode;
collapseAtStart(): true;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-rich-text/flow/LexicalRichText.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ declare export class HeadingNode extends ElementNode {
getTag(): HeadingTagType;
createDOM<EditorContext>(config: EditorConfig<EditorContext>): HTMLElement;
updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean;
static convertDOM(): DOMConversionMap | null;
static importDOM(): DOMConversionMap | null;
insertNewAfter(): ParagraphNode;
collapseAtStart(): true;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-rich-text/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export class HeadingNode extends ElementNode {
return false;
}

static convertDOM(): DOMConversionMap | null {
static importDOM(): DOMConversionMap | null {
return {
h1: (node: Node) => ({
conversion: convertHeadingElement,
Expand Down
28 changes: 27 additions & 1 deletion packages/lexical-table/src/LexicalTableCellNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
} from 'lexical';
Expand Down Expand Up @@ -49,7 +51,7 @@ export class TableCellNode extends GridCellNode {
);
}

static convertDOM(): DOMConversionMap | null {
static importDOM(): DOMConversionMap | null {
return {
td: (node: Node) => ({
conversion: convertTableCellNodeElement,
Expand Down Expand Up @@ -89,6 +91,30 @@ export class TableCellNode extends GridCellNode {
return element;
}

exportDOM(editor: LexicalEditor): DOMExportOutput {
const {element} = super.exportDOM(editor);

if (element) {
const maxWidth = 700;
const colCount = this.getParentOrThrow().getChildrenSize();
element.style.border = '1px solid black';
element.style.width = `${
this.getWidth() || Math.max(90, maxWidth / colCount)
}px`;

element.style.verticalAlign = 'top';
element.style.textAlign = 'start';

if (this.hasHeader()) {
element.style.backgroundColor = '#f2f3f5';
}
}

return {
element,
};
}

getTag(): string {
return this.hasHeader() ? 'th' : 'td';
}
Expand Down
Loading

0 comments on commit cd08594

Please sign in to comment.