mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-29 23:22:34 +01:00
Lexical: Improved table row copy/paste
Added safeguarding/matching of source/target sizes to prevent broken tables.
This commit is contained in:
parent
ddf5f2543c
commit
8a13a9df80
@ -235,7 +235,7 @@ export function $convertTableCellNodeElement(
|
|||||||
|
|
||||||
|
|
||||||
export function $createCustomTableCellNode(
|
export function $createCustomTableCellNode(
|
||||||
headerState: TableCellHeaderState,
|
headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
|
||||||
colSpan = 1,
|
colSpan = 1,
|
||||||
width?: number,
|
width?: number,
|
||||||
): CustomTableCellNode {
|
): CustomTableCellNode {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## In progress
|
## In progress
|
||||||
|
|
||||||
//
|
- Table Cut/Copy/Paste column
|
||||||
|
|
||||||
## Main Todo
|
## 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)
|
- 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)
|
- Media resize support (like images)
|
||||||
- Table caption text support
|
- Table caption text support
|
||||||
- Table Cut/Copy/Paste column
|
|
||||||
- Mac: Shortcut support via command.
|
- Mac: Shortcut support via command.
|
||||||
|
|
||||||
## Secondary Todo
|
## Secondary Todo
|
||||||
|
@ -27,8 +27,12 @@ import {
|
|||||||
$getTableRowsFromSelection,
|
$getTableRowsFromSelection,
|
||||||
$mergeTableCellsInSelection
|
$mergeTableCellsInSelection
|
||||||
} from "../../../utils/tables";
|
} from "../../../utils/tables";
|
||||||
import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row";
|
import {$isCustomTableRowNode} from "../../../nodes/custom-table-row";
|
||||||
import {NodeClipboard} from "../../../services/node-clipboard";
|
import {
|
||||||
|
$copySelectedRowsToClipboard,
|
||||||
|
$cutSelectedRowsToClipboard,
|
||||||
|
$pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty
|
||||||
|
} from "../../../utils/table-copy-paste";
|
||||||
|
|
||||||
const neverActive = (): boolean => false;
|
const neverActive = (): boolean => false;
|
||||||
const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode);
|
const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode);
|
||||||
@ -168,17 +172,15 @@ export const rowProperties: EditorButtonDefinition = {
|
|||||||
isDisabled: cellNotSelected,
|
isDisabled: cellNotSelected,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>(CustomTableRowNode);
|
|
||||||
|
|
||||||
export const cutRow: EditorButtonDefinition = {
|
export const cutRow: EditorButtonDefinition = {
|
||||||
label: 'Cut row',
|
label: 'Cut row',
|
||||||
format: 'long',
|
format: 'long',
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => {
|
context.editor.update(() => {
|
||||||
const rows = $getTableRowsFromSelection($getSelection());
|
try {
|
||||||
rowClipboard.set(...rows);
|
$cutSelectedRowsToClipboard();
|
||||||
for (const row of rows) {
|
} catch (e: any) {
|
||||||
row.remove();
|
context.error(e.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -191,8 +193,11 @@ export const copyRow: EditorButtonDefinition = {
|
|||||||
format: 'long',
|
format: 'long',
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.getEditorState().read(() => {
|
context.editor.getEditorState().read(() => {
|
||||||
const rows = $getTableRowsFromSelection($getSelection());
|
try {
|
||||||
rowClipboard.set(...rows);
|
$copySelectedRowsToClipboard();
|
||||||
|
} catch (e: any) {
|
||||||
|
context.error(e.toString());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isActive: neverActive,
|
isActive: neverActive,
|
||||||
@ -204,17 +209,15 @@ export const pasteRowBefore: EditorButtonDefinition = {
|
|||||||
format: 'long',
|
format: 'long',
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => {
|
context.editor.update(() => {
|
||||||
const rows = $getTableRowsFromSelection($getSelection());
|
try {
|
||||||
const lastRow = rows[rows.length - 1];
|
$pasteClipboardRowsBefore(context.editor);
|
||||||
if (lastRow) {
|
} catch (e: any) {
|
||||||
for (const row of rowClipboard.get(context.editor)) {
|
context.error(e.toString());
|
||||||
lastRow.insertBefore(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isActive: neverActive,
|
isActive: neverActive,
|
||||||
isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0,
|
isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pasteRowAfter: EditorButtonDefinition = {
|
export const pasteRowAfter: EditorButtonDefinition = {
|
||||||
@ -222,17 +225,15 @@ export const pasteRowAfter: EditorButtonDefinition = {
|
|||||||
format: 'long',
|
format: 'long',
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => {
|
context.editor.update(() => {
|
||||||
const rows = $getTableRowsFromSelection($getSelection());
|
try {
|
||||||
const lastRow = rows[rows.length - 1];
|
$pasteRowsAfter(context.editor);
|
||||||
if (lastRow) {
|
} catch (e: any) {
|
||||||
for (const row of rowClipboard.get(context.editor).reverse()) {
|
context.error(e.toString());
|
||||||
lastRow.insertAfter(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isActive: neverActive,
|
isActive: neverActive,
|
||||||
isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0,
|
isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cutColumn: EditorButtonDefinition = {
|
export const cutColumn: EditorButtonDefinition = {
|
||||||
|
@ -14,6 +14,7 @@ export type EditorUiContext = {
|
|||||||
containerDOM: HTMLElement; // DOM element which contains all editor elements
|
containerDOM: HTMLElement; // DOM element which contains all editor elements
|
||||||
scrollDOM: HTMLElement; // DOM element which is the main content scroll container
|
scrollDOM: HTMLElement; // DOM element which is the main content scroll container
|
||||||
translate: (text: string) => string; // Translate function
|
translate: (text: string) => string; // Translate function
|
||||||
|
error: (text: string) => void; // Error reporting function
|
||||||
manager: EditorUIManager; // UI Manager instance for this editor
|
manager: EditorUIManager; // UI Manager instance for this editor
|
||||||
options: Record<string, any>; // General user options which may be used by sub elements
|
options: Record<string, any>; // General user options which may be used by sub elements
|
||||||
};
|
};
|
||||||
|
@ -20,7 +20,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
|
|||||||
editorDOM: element,
|
editorDOM: element,
|
||||||
scrollDOM: scrollContainer,
|
scrollDOM: scrollContainer,
|
||||||
manager,
|
manager,
|
||||||
translate: (text: string): string => text,
|
translate: (text: string): string => text, // TODO - Implement
|
||||||
|
error(error: string): void {
|
||||||
|
window.$events.error(error); // TODO - Translate
|
||||||
|
},
|
||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
manager.setContext(context);
|
manager.setContext(context);
|
||||||
|
@ -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 this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => {
|
||||||
return node !== null;
|
return node !== null;
|
||||||
});
|
}) as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
size(): number {
|
size(): number {
|
97
resources/js/wysiwyg/utils/table-copy-paste.ts
Normal file
97
resources/js/wysiwyg/utils/table-copy-paste.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -93,4 +93,4 @@ export class TableMap {
|
|||||||
|
|
||||||
return [...cells.values()];
|
return [...cells.values()];
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user