mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Added table column cut/copy/paste support
This commit is contained in:
parent
8a13a9df80
commit
1ebb0f8c93
@ -2,7 +2,7 @@
|
||||
|
||||
## In progress
|
||||
|
||||
- Table Cut/Copy/Paste column
|
||||
//
|
||||
|
||||
## Main Todo
|
||||
|
||||
|
@ -29,9 +29,15 @@ import {
|
||||
} from "../../../utils/tables";
|
||||
import {$isCustomTableRowNode} from "../../../nodes/custom-table-row";
|
||||
import {
|
||||
$copySelectedColumnsToClipboard,
|
||||
$copySelectedRowsToClipboard,
|
||||
$cutSelectedColumnsToClipboard,
|
||||
$cutSelectedRowsToClipboard,
|
||||
$pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty
|
||||
$pasteClipboardRowsBefore,
|
||||
$pasteClipboardRowsAfter,
|
||||
isColumnClipboardEmpty,
|
||||
isRowClipboardEmpty,
|
||||
$pasteClipboardColumnsBefore, $pasteClipboardColumnsAfter
|
||||
} from "../../../utils/table-copy-paste";
|
||||
|
||||
const neverActive = (): boolean => false;
|
||||
@ -180,7 +186,7 @@ export const cutRow: EditorButtonDefinition = {
|
||||
try {
|
||||
$cutSelectedRowsToClipboard();
|
||||
} catch (e: any) {
|
||||
context.error(e.toString());
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -196,7 +202,7 @@ export const copyRow: EditorButtonDefinition = {
|
||||
try {
|
||||
$copySelectedRowsToClipboard();
|
||||
} catch (e: any) {
|
||||
context.error(e.toString());
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -212,7 +218,7 @@ export const pasteRowBefore: EditorButtonDefinition = {
|
||||
try {
|
||||
$pasteClipboardRowsBefore(context.editor);
|
||||
} catch (e: any) {
|
||||
context.error(e.toString());
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -226,9 +232,9 @@ export const pasteRowAfter: EditorButtonDefinition = {
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => {
|
||||
try {
|
||||
$pasteRowsAfter(context.editor);
|
||||
$pasteClipboardRowsAfter(context.editor);
|
||||
} catch (e: any) {
|
||||
context.error(e.toString());
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -240,8 +246,12 @@ export const cutColumn: EditorButtonDefinition = {
|
||||
label: 'Cut column',
|
||||
format: 'long',
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.getEditorState().read(() => {
|
||||
// TODO
|
||||
context.editor.update(() => {
|
||||
try {
|
||||
$cutSelectedColumnsToClipboard();
|
||||
} catch (e: any) {
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
isActive: neverActive,
|
||||
@ -253,7 +263,11 @@ export const copyColumn: EditorButtonDefinition = {
|
||||
format: 'long',
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.getEditorState().read(() => {
|
||||
// TODO
|
||||
try {
|
||||
$copySelectedColumnsToClipboard();
|
||||
} catch (e: any) {
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
isActive: neverActive,
|
||||
@ -264,24 +278,32 @@ export const pasteColumnBefore: EditorButtonDefinition = {
|
||||
label: 'Paste column before',
|
||||
format: 'long',
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.getEditorState().read(() => {
|
||||
// TODO
|
||||
context.editor.update(() => {
|
||||
try {
|
||||
$pasteClipboardColumnsBefore(context.editor);
|
||||
} catch (e: any) {
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
isActive: neverActive,
|
||||
isDisabled: cellNotSelected,
|
||||
isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(),
|
||||
};
|
||||
|
||||
export const pasteColumnAfter: EditorButtonDefinition = {
|
||||
label: 'Paste column after',
|
||||
format: 'long',
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.getEditorState().read(() => {
|
||||
// TODO
|
||||
context.editor.update(() => {
|
||||
try {
|
||||
$pasteClipboardColumnsAfter(context.editor);
|
||||
} catch (e: any) {
|
||||
context.error(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
isActive: neverActive,
|
||||
isDisabled: cellNotSelected,
|
||||
isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(),
|
||||
};
|
||||
|
||||
export const insertColumnBefore: EditorButtonDefinition = {
|
||||
|
@ -14,7 +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
|
||||
error: (text: string|Error) => 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
|
||||
};
|
||||
|
@ -21,8 +21,9 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
|
||||
scrollDOM: scrollContainer,
|
||||
manager,
|
||||
translate: (text: string): string => text, // TODO - Implement
|
||||
error(error: string): void {
|
||||
window.$events.error(error); // TODO - Translate
|
||||
error(error: string|Error): void {
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
window.$events.error(message); // TODO - Translate
|
||||
},
|
||||
options,
|
||||
};
|
||||
|
@ -30,13 +30,8 @@ function unserializeNodeRecursive(editor: LexicalEditor, {node, children}: Seria
|
||||
}
|
||||
|
||||
export class NodeClipboard<T extends LexicalNode> {
|
||||
nodeClass: {importJSON: (s: SerializedLexicalNode) => T};
|
||||
protected store: SerializedLexicalNodeWithChildren[] = [];
|
||||
|
||||
constructor(nodeClass: {importJSON: (s: any) => T}) {
|
||||
this.nodeClass = nodeClass;
|
||||
}
|
||||
|
||||
set(...nodes: LexicalNode[]): void {
|
||||
this.store.splice(0, this.store.length);
|
||||
for (const node of nodes) {
|
||||
|
@ -1,12 +1,14 @@
|
||||
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 {$getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection} from "./tables";
|
||||
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
|
||||
import {$createCustomTableCellNode, $isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell";
|
||||
import {CustomTableNode} from "../nodes/custom-table";
|
||||
import {TableMap} from "./table-map";
|
||||
import {$isTableSelection} from "@lexical/table";
|
||||
import {$getNodeFromSelection} from "./selection";
|
||||
|
||||
const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>(CustomTableRowNode);
|
||||
const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>();
|
||||
|
||||
export function isRowClipboardEmpty(): boolean {
|
||||
return rowClipboard.size() === 0;
|
||||
@ -82,7 +84,7 @@ export function $pasteClipboardRowsBefore(editor: LexicalEditor): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function $pasteRowsAfter(editor: LexicalEditor): void {
|
||||
export function $pasteClipboardRowsAfter(editor: LexicalEditor): void {
|
||||
const selection = $getSelection();
|
||||
const rows = $getTableRowsFromSelection(selection);
|
||||
const table = $getTableFromSelection(selection);
|
||||
@ -94,4 +96,177 @@ export function $pasteRowsAfter(editor: LexicalEditor): void {
|
||||
lastRow.insertAfter(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const columnClipboard: NodeClipboard<CustomTableCellNode>[] = [];
|
||||
|
||||
function setColumnClipboard(columns: CustomTableCellNode[][]): void {
|
||||
const newClipboards = columns.map(cells => {
|
||||
const clipboard = new NodeClipboard<CustomTableCellNode>();
|
||||
clipboard.set(...cells);
|
||||
return clipboard;
|
||||
});
|
||||
|
||||
columnClipboard.splice(0, columnClipboard.length, ...newClipboards);
|
||||
}
|
||||
|
||||
type TableRange = {from: number, to: number};
|
||||
|
||||
export function isColumnClipboardEmpty(): boolean {
|
||||
return columnClipboard.length === 0;
|
||||
}
|
||||
|
||||
function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|null {
|
||||
if ($isTableSelection(selection)) {
|
||||
const shape = selection.getShape()
|
||||
return {from: shape.fromX, to: shape.toX};
|
||||
}
|
||||
|
||||
const cell = $getNodeFromSelection(selection, $isCustomTableCellNode);
|
||||
const table = $getTableFromSelection(selection);
|
||||
if (!$isCustomTableCellNode(cell) || !table) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const map = new TableMap(table);
|
||||
const range = map.getRangeForCell(cell);
|
||||
if (!range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {from: range.fromX, to: range.toX};
|
||||
}
|
||||
|
||||
function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTableNode): CustomTableCellNode[][] {
|
||||
const map = new TableMap(table);
|
||||
const columns = [];
|
||||
for (let x = range.from; x <= range.to; x++) {
|
||||
const cells = map.getCellsInColumn(x);
|
||||
columns.push(cells);
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function validateColumnsToCopy(columns: CustomTableCellNode[][]): void {
|
||||
let commonColSize: number|null = null;
|
||||
|
||||
for (const cells of columns) {
|
||||
let colSize = 0;
|
||||
for (const cell of cells) {
|
||||
colSize += cell.getRowSpan() || 1;
|
||||
if (cell.getColSpan() > 1) {
|
||||
throw Error('Cannot copy columns with merged cells');
|
||||
}
|
||||
}
|
||||
|
||||
if (commonColSize === null) {
|
||||
commonColSize = colSize;
|
||||
} else if (commonColSize !== colSize) {
|
||||
throw Error('Cannot copy columns with inconsistent sizes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $cutSelectedColumnsToClipboard(): void {
|
||||
const selection = $getSelection();
|
||||
const range = $getSelectionColumnRange(selection);
|
||||
const table = $getTableFromSelection(selection);
|
||||
if (!range || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colWidths = table.getColWidths();
|
||||
const columns = $getTableColumnCellsFromSelection(range, table);
|
||||
validateColumnsToCopy(columns);
|
||||
setColumnClipboard(columns);
|
||||
for (const cells of columns) {
|
||||
for (const cell of cells) {
|
||||
cell.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const newWidths = [...colWidths].splice(range.from, (range.to - range.from) + 1);
|
||||
table.setColWidths(newWidths);
|
||||
}
|
||||
|
||||
export function $copySelectedColumnsToClipboard(): void {
|
||||
const selection = $getSelection();
|
||||
const range = $getSelectionColumnRange(selection);
|
||||
const table = $getTableFromSelection(selection);
|
||||
if (!range || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = $getTableColumnCellsFromSelection(range, table);
|
||||
validateColumnsToCopy(columns);
|
||||
setColumnClipboard(columns);
|
||||
}
|
||||
|
||||
function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: CustomTableNode) {
|
||||
const tableRowCount = (new TableMap(targetTable)).rowCount;
|
||||
for (const cells of columns) {
|
||||
let colSize = 0;
|
||||
for (const cell of cells) {
|
||||
colSize += cell.getRowSpan() || 1;
|
||||
}
|
||||
|
||||
if (colSize > tableRowCount) {
|
||||
throw Error('Cannot paste columns that are taller than target table');
|
||||
}
|
||||
|
||||
while (colSize < tableRowCount) {
|
||||
cells.push($createCustomTableCellNode());
|
||||
colSize++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function $pasteClipboardColumns(editor: LexicalEditor, isBefore: boolean): void {
|
||||
const selection = $getSelection();
|
||||
const table = $getTableFromSelection(selection);
|
||||
const cells = $getTableCellsFromSelection(selection);
|
||||
const referenceCell = cells[isBefore ? 0 : cells.length - 1];
|
||||
if (!table || !referenceCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardCols = columnClipboard.map(cb => cb.get(editor));
|
||||
if (!isBefore) {
|
||||
clipboardCols.reverse();
|
||||
}
|
||||
|
||||
validateColumnsToPaste(clipboardCols, table);
|
||||
const map = new TableMap(table);
|
||||
const cellRange = map.getRangeForCell(referenceCell);
|
||||
if (!cellRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colIndex = isBefore ? cellRange.fromX : cellRange.toX;
|
||||
const colWidths = table.getColWidths();
|
||||
|
||||
for (let y = 0; y < map.rowCount; y++) {
|
||||
const relCell = map.getCellAtPosition(colIndex, y);
|
||||
for (const cells of clipboardCols) {
|
||||
const newCell = cells[y];
|
||||
if (isBefore) {
|
||||
relCell.insertBefore(newCell);
|
||||
} else {
|
||||
relCell.insertAfter(newCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refWidth = colWidths[colIndex];
|
||||
const addedWidths = clipboardCols.map(_ => refWidth);
|
||||
colWidths.splice(isBefore ? colIndex : colIndex + 1, 0, ...addedWidths);
|
||||
}
|
||||
|
||||
export function $pasteClipboardColumnsBefore(editor: LexicalEditor): void {
|
||||
$pasteClipboardColumns(editor, true);
|
||||
}
|
||||
|
||||
export function $pasteClipboardColumnsAfter(editor: LexicalEditor): void {
|
||||
$pasteClipboardColumns(editor, false);
|
||||
}
|
@ -2,6 +2,13 @@ import {CustomTableNode} from "../nodes/custom-table";
|
||||
import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell";
|
||||
import {$isTableRowNode} from "@lexical/table";
|
||||
|
||||
export type CellRange = {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}
|
||||
|
||||
export class TableMap {
|
||||
|
||||
rowCount: number = 0;
|
||||
@ -77,11 +84,11 @@ export class TableMap {
|
||||
return this.cells[position];
|
||||
}
|
||||
|
||||
public getCellsInRange(fromX: number, fromY: number, toX: number, toY: number): CustomTableCellNode[] {
|
||||
const minX = Math.max(Math.min(fromX, toX), 0);
|
||||
const maxX = Math.min(Math.max(fromX, toX), this.columnCount - 1);
|
||||
const minY = Math.max(Math.min(fromY, toY), 0);
|
||||
const maxY = Math.min(Math.max(fromY, toY), this.rowCount - 1);
|
||||
public getCellsInRange(range: CellRange): CustomTableCellNode[] {
|
||||
const minX = Math.max(Math.min(range.fromX, range.toX), 0);
|
||||
const maxX = Math.min(Math.max(range.fromX, range.toX), this.columnCount - 1);
|
||||
const minY = Math.max(Math.min(range.fromY, range.toY), 0);
|
||||
const maxY = Math.min(Math.max(range.fromY, range.toY), this.rowCount - 1);
|
||||
|
||||
const cells = new Set<CustomTableCellNode>();
|
||||
|
||||
@ -93,4 +100,37 @@ export class TableMap {
|
||||
|
||||
return [...cells.values()];
|
||||
}
|
||||
|
||||
public getCellsInColumn(columnIndex: number): CustomTableCellNode[] {
|
||||
return this.getCellsInRange({
|
||||
fromX: columnIndex,
|
||||
toX: columnIndex,
|
||||
fromY: 0,
|
||||
toY: this.rowCount - 1,
|
||||
});
|
||||
}
|
||||
|
||||
public getRangeForCell(cell: CustomTableCellNode): CellRange|null {
|
||||
let range: CellRange|null = null;
|
||||
const cellKey = cell.getKey();
|
||||
|
||||
for (let y = 0; y < this.rowCount; y++) {
|
||||
for (let x = 0; x < this.columnCount; x++) {
|
||||
const index = (y * this.columnCount) + x;
|
||||
const lCell = this.cells[index];
|
||||
if (lCell.getKey() === cellKey) {
|
||||
if (range === null) {
|
||||
range = {fromX: x, toX: x, fromY: y, toY: y};
|
||||
} else {
|
||||
range.fromX = Math.min(range.fromX, x);
|
||||
range.toX = Math.max(range.toX, x);
|
||||
range.fromY = Math.min(range.fromY, y);
|
||||
range.toY = Math.max(range.toY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
}
|
@ -168,12 +168,12 @@ export function $mergeTableCellsInSelection(selection: TableSelection): void {
|
||||
const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1);
|
||||
const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1);
|
||||
|
||||
const mergeCells = tableMap.getCellsInRange(
|
||||
selectionShape.fromX,
|
||||
selectionShape.fromY,
|
||||
fixedToX,
|
||||
fixedToY,
|
||||
);
|
||||
const mergeCells = tableMap.getCellsInRange({
|
||||
fromX: selectionShape.fromX,
|
||||
fromY: selectionShape.fromY,
|
||||
toX: fixedToX,
|
||||
toY: fixedToY,
|
||||
});
|
||||
|
||||
if (mergeCells.length === 0) {
|
||||
return;
|
||||
|
Loading…
Reference in New Issue
Block a user