Lexical: Improved table row copy/paste

Added safeguarding/matching of source/target sizes to prevent broken
tables.
This commit is contained in:
Dan Brown 2024-08-22 10:08:08 +01:00
parent ddf5f2543c
commit 8a13a9df80
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 132 additions and 31 deletions

View File

@ -235,7 +235,7 @@ export function $convertTableCellNodeElement(
export function $createCustomTableCellNode(
headerState: TableCellHeaderState,
headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
colSpan = 1,
width?: number,
): CustomTableCellNode {

View File

@ -2,7 +2,7 @@
## In progress
//
- Table Cut/Copy/Paste column
## Main Todo
@ -10,7 +10,6 @@
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images)
- Table caption text support
- Table Cut/Copy/Paste column
- Mac: Shortcut support via command.
## Secondary Todo

View File

@ -27,8 +27,12 @@ import {
$getTableRowsFromSelection,
$mergeTableCellsInSelection
} from "../../../utils/tables";
import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row";
import {NodeClipboard} from "../../../services/node-clipboard";
import {$isCustomTableRowNode} from "../../../nodes/custom-table-row";
import {
$copySelectedRowsToClipboard,
$cutSelectedRowsToClipboard,
$pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty
} from "../../../utils/table-copy-paste";
const neverActive = (): boolean => false;
const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode);
@ -168,17 +172,15 @@ export const rowProperties: EditorButtonDefinition = {
isDisabled: cellNotSelected,
};
const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>(CustomTableRowNode);
export const cutRow: EditorButtonDefinition = {
label: 'Cut row',
format: 'long',
action(context: EditorUiContext) {
context.editor.update(() => {
const rows = $getTableRowsFromSelection($getSelection());
rowClipboard.set(...rows);
for (const row of rows) {
row.remove();
try {
$cutSelectedRowsToClipboard();
} catch (e: any) {
context.error(e.toString());
}
});
},
@ -191,8 +193,11 @@ export const copyRow: EditorButtonDefinition = {
format: 'long',
action(context: EditorUiContext) {
context.editor.getEditorState().read(() => {
const rows = $getTableRowsFromSelection($getSelection());
rowClipboard.set(...rows);
try {
$copySelectedRowsToClipboard();
} catch (e: any) {
context.error(e.toString());
}
});
},
isActive: neverActive,
@ -204,17 +209,15 @@ export const pasteRowBefore: EditorButtonDefinition = {
format: 'long',
action(context: EditorUiContext) {
context.editor.update(() => {
const rows = $getTableRowsFromSelection($getSelection());
const lastRow = rows[rows.length - 1];
if (lastRow) {
for (const row of rowClipboard.get(context.editor)) {
lastRow.insertBefore(row);
}
try {
$pasteClipboardRowsBefore(context.editor);
} catch (e: any) {
context.error(e.toString());
}
});
},
isActive: neverActive,
isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0,
isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
};
export const pasteRowAfter: EditorButtonDefinition = {
@ -222,17 +225,15 @@ export const pasteRowAfter: EditorButtonDefinition = {
format: 'long',
action(context: EditorUiContext) {
context.editor.update(() => {
const rows = $getTableRowsFromSelection($getSelection());
const lastRow = rows[rows.length - 1];
if (lastRow) {
for (const row of rowClipboard.get(context.editor).reverse()) {
lastRow.insertAfter(row);
}
try {
$pasteRowsAfter(context.editor);
} catch (e: any) {
context.error(e.toString());
}
});
},
isActive: neverActive,
isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0,
isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
};
export const cutColumn: EditorButtonDefinition = {

View File

@ -14,6 +14,7 @@ export type EditorUiContext = {
containerDOM: HTMLElement; // DOM element which contains all editor elements
scrollDOM: HTMLElement; // DOM element which is the main content scroll container
translate: (text: string) => string; // Translate function
error: (text: string) => void; // Error reporting function
manager: EditorUIManager; // UI Manager instance for this editor
options: Record<string, any>; // General user options which may be used by sub elements
};

View File

@ -20,7 +20,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
editorDOM: element,
scrollDOM: scrollContainer,
manager,
translate: (text: string): string => text,
translate: (text: string): string => text, // TODO - Implement
error(error: string): void {
window.$events.error(error); // TODO - Translate
},
options,
};
manager.setContext(context);

View File

@ -44,10 +44,10 @@ export class NodeClipboard<T extends LexicalNode> {
}
}
get(editor: LexicalEditor): LexicalNode[] {
get(editor: LexicalEditor): T[] {
return this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => {
return node !== null;
});
}) as T[];
}
size(): number {

View File

@ -0,0 +1,97 @@
import {NodeClipboard} from "./node-clipboard";
import {CustomTableRowNode} from "../nodes/custom-table-row";
import {$getTableFromSelection, $getTableRowsFromSelection} from "./tables";
import {$getSelection, LexicalEditor} from "lexical";
import {$createCustomTableCellNode, $isCustomTableCellNode} from "../nodes/custom-table-cell";
import {CustomTableNode} from "../nodes/custom-table";
import {TableMap} from "./table-map";
const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>(CustomTableRowNode);
export function isRowClipboardEmpty(): boolean {
return rowClipboard.size() === 0;
}
export function validateRowsToCopy(rows: CustomTableRowNode[]): void {
let commonRowSize: number|null = null;
for (const row of rows) {
const cells = row.getChildren().filter(n => $isCustomTableCellNode(n));
let rowSize = 0;
for (const cell of cells) {
rowSize += cell.getColSpan() || 1;
if (cell.getRowSpan() > 1) {
throw Error('Cannot copy rows with merged cells');
}
}
if (commonRowSize === null) {
commonRowSize = rowSize;
} else if (commonRowSize !== rowSize) {
throw Error('Cannot copy rows with inconsistent sizes');
}
}
}
export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: CustomTableNode): void {
const tableColCount = (new TableMap(targetTable)).columnCount;
for (const row of rows) {
const cells = row.getChildren().filter(n => $isCustomTableCellNode(n));
let rowSize = 0;
for (const cell of cells) {
rowSize += cell.getColSpan() || 1;
}
if (rowSize > tableColCount) {
throw Error('Cannot paste rows that are wider than target table');
}
while (rowSize < tableColCount) {
row.append($createCustomTableCellNode());
rowSize++;
}
}
}
export function $cutSelectedRowsToClipboard(): void {
const rows = $getTableRowsFromSelection($getSelection());
validateRowsToCopy(rows);
rowClipboard.set(...rows);
for (const row of rows) {
row.remove();
}
}
export function $copySelectedRowsToClipboard(): void {
const rows = $getTableRowsFromSelection($getSelection());
validateRowsToCopy(rows);
rowClipboard.set(...rows);
}
export function $pasteClipboardRowsBefore(editor: LexicalEditor): void {
const selection = $getSelection();
const rows = $getTableRowsFromSelection(selection);
const table = $getTableFromSelection(selection);
const lastRow = rows[rows.length - 1];
if (lastRow && table) {
const clipboardRows = rowClipboard.get(editor);
validateRowsToPaste(clipboardRows, table);
for (const row of clipboardRows) {
lastRow.insertBefore(row);
}
}
}
export function $pasteRowsAfter(editor: LexicalEditor): void {
const selection = $getSelection();
const rows = $getTableRowsFromSelection(selection);
const table = $getTableFromSelection(selection);
const lastRow = rows[rows.length - 1];
if (lastRow && table) {
const clipboardRows = rowClipboard.get(editor).reverse();
validateRowsToPaste(clipboardRows, table);
for (const row of clipboardRows) {
lastRow.insertAfter(row);
}
}
}