mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-22 07:12:36 +00:00
22d078b47f
Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies.
1820 lines
50 KiB
TypeScript
1820 lines
50 KiB
TypeScript
/**
|
|
* 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 {TableCellNode} from './LexicalTableCellNode';
|
|
import type {TableNode} from './LexicalTableNode';
|
|
import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver';
|
|
import type {
|
|
TableMapType,
|
|
TableMapValueType,
|
|
TableSelection,
|
|
} from './LexicalTableSelection';
|
|
import type {
|
|
BaseSelection,
|
|
ElementFormatType,
|
|
LexicalCommand,
|
|
LexicalEditor,
|
|
LexicalNode,
|
|
RangeSelection,
|
|
TextFormatType,
|
|
} from 'lexical';
|
|
|
|
import {
|
|
$getClipboardDataFromSelection,
|
|
copyToClipboard,
|
|
} from '@lexical/clipboard';
|
|
import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
|
|
import {
|
|
$createParagraphNode,
|
|
$createRangeSelectionFromDom,
|
|
$createTextNode,
|
|
$getNearestNodeFromDOMNode,
|
|
$getPreviousSelection,
|
|
$getSelection,
|
|
$isDecoratorNode,
|
|
$isElementNode,
|
|
$isRangeSelection,
|
|
$isRootOrShadowRoot,
|
|
$isTextNode,
|
|
$setSelection,
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
COMMAND_PRIORITY_HIGH,
|
|
CONTROLLED_TEXT_INSERTION_COMMAND,
|
|
CUT_COMMAND,
|
|
DELETE_CHARACTER_COMMAND,
|
|
DELETE_LINE_COMMAND,
|
|
DELETE_WORD_COMMAND,
|
|
FOCUS_COMMAND,
|
|
FORMAT_ELEMENT_COMMAND,
|
|
FORMAT_TEXT_COMMAND,
|
|
INSERT_PARAGRAPH_COMMAND,
|
|
KEY_ARROW_DOWN_COMMAND,
|
|
KEY_ARROW_LEFT_COMMAND,
|
|
KEY_ARROW_RIGHT_COMMAND,
|
|
KEY_ARROW_UP_COMMAND,
|
|
KEY_BACKSPACE_COMMAND,
|
|
KEY_DELETE_COMMAND,
|
|
KEY_ESCAPE_COMMAND,
|
|
KEY_TAB_COMMAND,
|
|
SELECTION_CHANGE_COMMAND,
|
|
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
|
|
} from 'lexical';
|
|
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
|
|
import invariant from 'lexical/shared/invariant';
|
|
|
|
import {$isTableCellNode} from './LexicalTableCellNode';
|
|
import {$isTableNode} from './LexicalTableNode';
|
|
import {TableDOMTable, TableObserver} from './LexicalTableObserver';
|
|
import {$isTableRowNode} from './LexicalTableRowNode';
|
|
import {$isTableSelection} from './LexicalTableSelection';
|
|
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
|
|
|
|
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
|
|
|
|
export const getDOMSelection = (
|
|
targetWindow: Window | null,
|
|
): Selection | null =>
|
|
CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
|
|
|
|
const isMouseDownOnEvent = (event: MouseEvent) => {
|
|
return (event.buttons & 1) === 1;
|
|
};
|
|
|
|
export function applyTableHandlers(
|
|
tableNode: TableNode,
|
|
tableElement: HTMLTableElementWithWithTableSelectionState,
|
|
editor: LexicalEditor,
|
|
hasTabHandler: boolean,
|
|
): TableObserver {
|
|
const rootElement = editor.getRootElement();
|
|
|
|
if (rootElement === null) {
|
|
throw new Error('No root element.');
|
|
}
|
|
|
|
const tableObserver = new TableObserver(editor, tableNode.getKey());
|
|
const editorWindow = editor._window || window;
|
|
|
|
attachTableObserverToTableElement(tableElement, tableObserver);
|
|
|
|
const createMouseHandlers = () => {
|
|
const onMouseUp = () => {
|
|
tableObserver.isSelecting = false;
|
|
editorWindow.removeEventListener('mouseup', onMouseUp);
|
|
editorWindow.removeEventListener('mousemove', onMouseMove);
|
|
};
|
|
|
|
const onMouseMove = (moveEvent: MouseEvent) => {
|
|
// delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
|
|
setTimeout(() => {
|
|
if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
|
|
tableObserver.isSelecting = false;
|
|
editorWindow.removeEventListener('mouseup', onMouseUp);
|
|
editorWindow.removeEventListener('mousemove', onMouseMove);
|
|
return;
|
|
}
|
|
const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
|
|
if (
|
|
focusCell !== null &&
|
|
(tableObserver.anchorX !== focusCell.x ||
|
|
tableObserver.anchorY !== focusCell.y)
|
|
) {
|
|
moveEvent.preventDefault();
|
|
tableObserver.setFocusCellForSelection(focusCell);
|
|
}
|
|
}, 0);
|
|
};
|
|
return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
|
|
};
|
|
|
|
tableElement.addEventListener('mousedown', (event: MouseEvent) => {
|
|
setTimeout(() => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
if (!editorWindow) {
|
|
return;
|
|
}
|
|
|
|
const anchorCell = getDOMCellFromTarget(event.target as Node);
|
|
if (anchorCell !== null) {
|
|
stopEvent(event);
|
|
tableObserver.setAnchorCellForSelection(anchorCell);
|
|
}
|
|
|
|
const {onMouseUp, onMouseMove} = createMouseHandlers();
|
|
tableObserver.isSelecting = true;
|
|
editorWindow.addEventListener('mouseup', onMouseUp);
|
|
editorWindow.addEventListener('mousemove', onMouseMove);
|
|
}, 0);
|
|
});
|
|
|
|
// Clear selection when clicking outside of dom.
|
|
const mouseDownCallback = (event: MouseEvent) => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
editor.update(() => {
|
|
const selection = $getSelection();
|
|
const target = event.target as Node;
|
|
if (
|
|
$isTableSelection(selection) &&
|
|
selection.tableKey === tableObserver.tableNodeKey &&
|
|
rootElement.contains(target)
|
|
) {
|
|
tableObserver.clearHighlight();
|
|
}
|
|
});
|
|
};
|
|
|
|
editorWindow.addEventListener('mousedown', mouseDownCallback);
|
|
|
|
tableObserver.listenersToRemove.add(() =>
|
|
editorWindow.removeEventListener('mousedown', mouseDownCallback),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_DOWN_COMMAND,
|
|
(event) =>
|
|
$handleArrowKey(editor, event, 'down', tableNode, tableObserver),
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_UP_COMMAND,
|
|
(event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver),
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_LEFT_COMMAND,
|
|
(event) =>
|
|
$handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_RIGHT_COMMAND,
|
|
(event) =>
|
|
$handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ESCAPE_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if ($isTableSelection(selection)) {
|
|
const focusCellNode = $findMatchingParent(
|
|
selection.focus.getNode(),
|
|
$isTableCellNode,
|
|
);
|
|
if ($isTableCellNode(focusCellNode)) {
|
|
stopEvent(event);
|
|
focusCellNode.selectEnd();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
|
|
const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
|
|
const selection = $getSelection();
|
|
|
|
if (!$isSelectionInTable(selection, tableNode)) {
|
|
return false;
|
|
}
|
|
|
|
if ($isTableSelection(selection)) {
|
|
tableObserver.clearText();
|
|
|
|
return true;
|
|
} else if ($isRangeSelection(selection)) {
|
|
const tableCellNode = $findMatchingParent(
|
|
selection.anchor.getNode(),
|
|
(n) => $isTableCellNode(n),
|
|
);
|
|
|
|
if (!$isTableCellNode(tableCellNode)) {
|
|
return false;
|
|
}
|
|
|
|
const anchorNode = selection.anchor.getNode();
|
|
const focusNode = selection.focus.getNode();
|
|
const isAnchorInside = tableNode.isParentOf(anchorNode);
|
|
const isFocusInside = tableNode.isParentOf(focusNode);
|
|
|
|
const selectionContainsPartialTable =
|
|
(isAnchorInside && !isFocusInside) ||
|
|
(isFocusInside && !isAnchorInside);
|
|
|
|
if (selectionContainsPartialTable) {
|
|
tableObserver.clearText();
|
|
return true;
|
|
}
|
|
|
|
const nearestElementNode = $findMatchingParent(
|
|
selection.anchor.getNode(),
|
|
(n) => $isElementNode(n),
|
|
);
|
|
|
|
const topLevelCellElementNode =
|
|
nearestElementNode &&
|
|
$findMatchingParent(
|
|
nearestElementNode,
|
|
(n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
|
|
);
|
|
|
|
if (
|
|
!$isElementNode(topLevelCellElementNode) ||
|
|
!$isElementNode(nearestElementNode)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
command === DELETE_LINE_COMMAND &&
|
|
topLevelCellElementNode.getPreviousSibling() === null
|
|
) {
|
|
// TODO: Fix Delete Line in Table Cells.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
[DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
|
|
(command) => {
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand(
|
|
command,
|
|
deleteTextHandler(command),
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
const $deleteCellHandler = (
|
|
event: KeyboardEvent | ClipboardEvent | null,
|
|
): boolean => {
|
|
const selection = $getSelection();
|
|
|
|
if (!$isSelectionInTable(selection, tableNode)) {
|
|
const nodes = selection ? selection.getNodes() : null;
|
|
if (nodes) {
|
|
const table = nodes.find(
|
|
(node) =>
|
|
$isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
|
|
);
|
|
if ($isTableNode(table)) {
|
|
const parentNode = table.getParent();
|
|
if (!parentNode) {
|
|
return false;
|
|
}
|
|
table.remove();
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if ($isTableSelection(selection)) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
tableObserver.clearText();
|
|
|
|
return true;
|
|
} else if ($isRangeSelection(selection)) {
|
|
const tableCellNode = $findMatchingParent(
|
|
selection.anchor.getNode(),
|
|
(n) => $isTableCellNode(n),
|
|
);
|
|
|
|
if (!$isTableCellNode(tableCellNode)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_BACKSPACE_COMMAND,
|
|
$deleteCellHandler,
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_DELETE_COMMAND,
|
|
$deleteCellHandler,
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
|
|
CUT_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if (selection) {
|
|
if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
|
|
return false;
|
|
}
|
|
// Copying to the clipboard is async so we must capture the data
|
|
// before we delete it
|
|
void copyToClipboard(
|
|
editor,
|
|
objectKlassEquals(event, ClipboardEvent)
|
|
? (event as ClipboardEvent)
|
|
: null,
|
|
$getClipboardDataFromSelection(selection),
|
|
);
|
|
const intercepted = $deleteCellHandler(event);
|
|
if ($isRangeSelection(selection)) {
|
|
selection.removeText();
|
|
}
|
|
return intercepted;
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<TextFormatType>(
|
|
FORMAT_TEXT_COMMAND,
|
|
(payload) => {
|
|
const selection = $getSelection();
|
|
|
|
if (!$isSelectionInTable(selection, tableNode)) {
|
|
return false;
|
|
}
|
|
|
|
if ($isTableSelection(selection)) {
|
|
tableObserver.formatCells(payload);
|
|
|
|
return true;
|
|
} else if ($isRangeSelection(selection)) {
|
|
const tableCellNode = $findMatchingParent(
|
|
selection.anchor.getNode(),
|
|
(n) => $isTableCellNode(n),
|
|
);
|
|
|
|
if (!$isTableCellNode(tableCellNode)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<ElementFormatType>(
|
|
FORMAT_ELEMENT_COMMAND,
|
|
(formatType) => {
|
|
const selection = $getSelection();
|
|
if (
|
|
!$isTableSelection(selection) ||
|
|
!$isSelectionInTable(selection, tableNode)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const anchorNode = selection.anchor.getNode();
|
|
const focusNode = selection.focus.getNode();
|
|
if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) {
|
|
return false;
|
|
}
|
|
|
|
const [tableMap, anchorCell, focusCell] = $computeTableMap(
|
|
tableNode,
|
|
anchorNode,
|
|
focusNode,
|
|
);
|
|
const maxRow = Math.max(anchorCell.startRow, focusCell.startRow);
|
|
const maxColumn = Math.max(
|
|
anchorCell.startColumn,
|
|
focusCell.startColumn,
|
|
);
|
|
const minRow = Math.min(anchorCell.startRow, focusCell.startRow);
|
|
const minColumn = Math.min(
|
|
anchorCell.startColumn,
|
|
focusCell.startColumn,
|
|
);
|
|
for (let i = minRow; i <= maxRow; i++) {
|
|
for (let j = minColumn; j <= maxColumn; j++) {
|
|
const cell = tableMap[i][j].cell;
|
|
cell.setFormat(formatType);
|
|
|
|
const cellChildren = cell.getChildren();
|
|
for (let k = 0; k < cellChildren.length; k++) {
|
|
const child = cellChildren[k];
|
|
if ($isElementNode(child) && !child.isInline()) {
|
|
child.setFormat(formatType);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand(
|
|
CONTROLLED_TEXT_INSERTION_COMMAND,
|
|
(payload) => {
|
|
const selection = $getSelection();
|
|
|
|
if (!$isSelectionInTable(selection, tableNode)) {
|
|
return false;
|
|
}
|
|
|
|
if ($isTableSelection(selection)) {
|
|
tableObserver.clearHighlight();
|
|
|
|
return false;
|
|
} else if ($isRangeSelection(selection)) {
|
|
const tableCellNode = $findMatchingParent(
|
|
selection.anchor.getNode(),
|
|
(n) => $isTableCellNode(n),
|
|
);
|
|
|
|
if (!$isTableCellNode(tableCellNode)) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof payload === 'string') {
|
|
const edgePosition = $getTableEdgeCursorPosition(
|
|
editor,
|
|
selection,
|
|
tableNode,
|
|
);
|
|
if (edgePosition) {
|
|
$insertParagraphAtTableEdge(edgePosition, tableNode, [
|
|
$createTextNode(payload),
|
|
]);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
if (hasTabHandler) {
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_TAB_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if (
|
|
!$isRangeSelection(selection) ||
|
|
!selection.isCollapsed() ||
|
|
!$isSelectionInTable(selection, tableNode)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const tableCellNode = $findCellNode(selection.anchor.getNode());
|
|
if (tableCellNode === null) {
|
|
return false;
|
|
}
|
|
|
|
stopEvent(event);
|
|
|
|
const currentCords = tableNode.getCordsFromCellNode(
|
|
tableCellNode,
|
|
tableObserver.table,
|
|
);
|
|
|
|
selectTableNodeInDirection(
|
|
tableObserver,
|
|
tableNode,
|
|
currentCords.x,
|
|
currentCords.y,
|
|
!event.shiftKey ? 'forward' : 'backward',
|
|
);
|
|
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
}
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand(
|
|
FOCUS_COMMAND,
|
|
(payload) => {
|
|
return tableNode.isSelected();
|
|
},
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
|
|
function getObserverCellFromCellNode(
|
|
tableCellNode: TableCellNode,
|
|
): TableDOMCell {
|
|
const currentCords = tableNode.getCordsFromCellNode(
|
|
tableCellNode,
|
|
tableObserver.table,
|
|
);
|
|
return tableNode.getDOMCellFromCordsOrThrow(
|
|
currentCords.x,
|
|
currentCords.y,
|
|
tableObserver.table,
|
|
);
|
|
}
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand(
|
|
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
|
|
(selectionPayload) => {
|
|
const {nodes, selection} = selectionPayload;
|
|
const anchorAndFocus = selection.getStartEndPoints();
|
|
const isTableSelection = $isTableSelection(selection);
|
|
const isRangeSelection = $isRangeSelection(selection);
|
|
const isSelectionInsideOfGrid =
|
|
(isRangeSelection &&
|
|
$findMatchingParent(selection.anchor.getNode(), (n) =>
|
|
$isTableCellNode(n),
|
|
) !== null &&
|
|
$findMatchingParent(selection.focus.getNode(), (n) =>
|
|
$isTableCellNode(n),
|
|
) !== null) ||
|
|
isTableSelection;
|
|
|
|
if (
|
|
nodes.length !== 1 ||
|
|
!$isTableNode(nodes[0]) ||
|
|
!isSelectionInsideOfGrid ||
|
|
anchorAndFocus === null
|
|
) {
|
|
return false;
|
|
}
|
|
const [anchor] = anchorAndFocus;
|
|
|
|
const newGrid = nodes[0];
|
|
const newGridRows = newGrid.getChildren();
|
|
const newColumnCount = newGrid
|
|
.getFirstChildOrThrow<TableNode>()
|
|
.getChildrenSize();
|
|
const newRowCount = newGrid.getChildrenSize();
|
|
const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
|
|
$isTableCellNode(n),
|
|
);
|
|
const gridRowNode =
|
|
gridCellNode &&
|
|
$findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
|
|
const gridNode =
|
|
gridRowNode &&
|
|
$findMatchingParent(gridRowNode, (n) => $isTableNode(n));
|
|
|
|
if (
|
|
!$isTableCellNode(gridCellNode) ||
|
|
!$isTableRowNode(gridRowNode) ||
|
|
!$isTableNode(gridNode)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const startY = gridRowNode.getIndexWithinParent();
|
|
const stopY = Math.min(
|
|
gridNode.getChildrenSize() - 1,
|
|
startY + newRowCount - 1,
|
|
);
|
|
const startX = gridCellNode.getIndexWithinParent();
|
|
const stopX = Math.min(
|
|
gridRowNode.getChildrenSize() - 1,
|
|
startX + newColumnCount - 1,
|
|
);
|
|
const fromX = Math.min(startX, stopX);
|
|
const fromY = Math.min(startY, stopY);
|
|
const toX = Math.max(startX, stopX);
|
|
const toY = Math.max(startY, stopY);
|
|
const gridRowNodes = gridNode.getChildren();
|
|
let newRowIdx = 0;
|
|
|
|
for (let r = fromY; r <= toY; r++) {
|
|
const currentGridRowNode = gridRowNodes[r];
|
|
|
|
if (!$isTableRowNode(currentGridRowNode)) {
|
|
return false;
|
|
}
|
|
|
|
const newGridRowNode = newGridRows[newRowIdx];
|
|
|
|
if (!$isTableRowNode(newGridRowNode)) {
|
|
return false;
|
|
}
|
|
|
|
const gridCellNodes = currentGridRowNode.getChildren();
|
|
const newGridCellNodes = newGridRowNode.getChildren();
|
|
let newColumnIdx = 0;
|
|
|
|
for (let c = fromX; c <= toX; c++) {
|
|
const currentGridCellNode = gridCellNodes[c];
|
|
|
|
if (!$isTableCellNode(currentGridCellNode)) {
|
|
return false;
|
|
}
|
|
|
|
const newGridCellNode = newGridCellNodes[newColumnIdx];
|
|
|
|
if (!$isTableCellNode(newGridCellNode)) {
|
|
return false;
|
|
}
|
|
|
|
const originalChildren = currentGridCellNode.getChildren();
|
|
newGridCellNode.getChildren().forEach((child) => {
|
|
if ($isTextNode(child)) {
|
|
const paragraphNode = $createParagraphNode();
|
|
paragraphNode.append(child);
|
|
currentGridCellNode.append(child);
|
|
} else {
|
|
currentGridCellNode.append(child);
|
|
}
|
|
});
|
|
originalChildren.forEach((n) => n.remove());
|
|
newColumnIdx++;
|
|
}
|
|
|
|
newRowIdx++;
|
|
}
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand(
|
|
SELECTION_CHANGE_COMMAND,
|
|
() => {
|
|
const selection = $getSelection();
|
|
const prevSelection = $getPreviousSelection();
|
|
|
|
if ($isRangeSelection(selection)) {
|
|
const {anchor, focus} = selection;
|
|
const anchorNode = anchor.getNode();
|
|
const focusNode = focus.getNode();
|
|
// Using explicit comparison with table node to ensure it's not a nested table
|
|
// as in that case we'll leave selection resolving to that table
|
|
const anchorCellNode = $findCellNode(anchorNode);
|
|
const focusCellNode = $findCellNode(focusNode);
|
|
const isAnchorInside = !!(
|
|
anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
|
|
);
|
|
const isFocusInside = !!(
|
|
focusCellNode && tableNode.is($findTableNode(focusCellNode))
|
|
);
|
|
const isPartialyWithinTable = isAnchorInside !== isFocusInside;
|
|
const isWithinTable = isAnchorInside && isFocusInside;
|
|
const isBackward = selection.isBackward();
|
|
|
|
if (isPartialyWithinTable) {
|
|
const newSelection = selection.clone();
|
|
if (isFocusInside) {
|
|
const [tableMap] = $computeTableMap(
|
|
tableNode,
|
|
focusCellNode,
|
|
focusCellNode,
|
|
);
|
|
const firstCell = tableMap[0][0].cell;
|
|
const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
|
|
newSelection.focus.set(
|
|
isBackward ? firstCell.getKey() : lastCell.getKey(),
|
|
isBackward
|
|
? firstCell.getChildrenSize()
|
|
: lastCell.getChildrenSize(),
|
|
'element',
|
|
);
|
|
}
|
|
$setSelection(newSelection);
|
|
$addHighlightStyleToTable(editor, tableObserver);
|
|
} else if (isWithinTable) {
|
|
// Handle case when selection spans across multiple cells but still
|
|
// has range selection, then we convert it into grid selection
|
|
if (!anchorCellNode.is(focusCellNode)) {
|
|
tableObserver.setAnchorCellForSelection(
|
|
getObserverCellFromCellNode(anchorCellNode),
|
|
);
|
|
tableObserver.setFocusCellForSelection(
|
|
getObserverCellFromCellNode(focusCellNode),
|
|
true,
|
|
);
|
|
if (!tableObserver.isSelecting) {
|
|
setTimeout(() => {
|
|
const {onMouseUp, onMouseMove} = createMouseHandlers();
|
|
tableObserver.isSelecting = true;
|
|
editorWindow.addEventListener('mouseup', onMouseUp);
|
|
editorWindow.addEventListener('mousemove', onMouseMove);
|
|
}, 0);
|
|
}
|
|
}
|
|
}
|
|
} else if (
|
|
selection &&
|
|
$isTableSelection(selection) &&
|
|
selection.is(prevSelection) &&
|
|
selection.tableKey === tableNode.getKey()
|
|
) {
|
|
// if selection goes outside of the table we need to change it to Range selection
|
|
const domSelection = getDOMSelection(editor._window);
|
|
if (
|
|
domSelection &&
|
|
domSelection.anchorNode &&
|
|
domSelection.focusNode
|
|
) {
|
|
const focusNode = $getNearestNodeFromDOMNode(
|
|
domSelection.focusNode,
|
|
);
|
|
const isFocusOutside =
|
|
focusNode && !tableNode.is($findTableNode(focusNode));
|
|
|
|
const anchorNode = $getNearestNodeFromDOMNode(
|
|
domSelection.anchorNode,
|
|
);
|
|
const isAnchorInside =
|
|
anchorNode && tableNode.is($findTableNode(anchorNode));
|
|
|
|
if (
|
|
isFocusOutside &&
|
|
isAnchorInside &&
|
|
domSelection.rangeCount > 0
|
|
) {
|
|
const newSelection = $createRangeSelectionFromDom(
|
|
domSelection,
|
|
editor,
|
|
);
|
|
if (newSelection) {
|
|
newSelection.anchor.set(
|
|
tableNode.getKey(),
|
|
selection.isBackward() ? tableNode.getChildrenSize() : 0,
|
|
'element',
|
|
);
|
|
domSelection.removeAllRanges();
|
|
$setSelection(newSelection);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
selection &&
|
|
!selection.is(prevSelection) &&
|
|
($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
|
|
tableObserver.tableSelection &&
|
|
!tableObserver.tableSelection.is(prevSelection)
|
|
) {
|
|
if (
|
|
$isTableSelection(selection) &&
|
|
selection.tableKey === tableObserver.tableNodeKey
|
|
) {
|
|
tableObserver.updateTableTableSelection(selection);
|
|
} else if (
|
|
!$isTableSelection(selection) &&
|
|
$isTableSelection(prevSelection) &&
|
|
prevSelection.tableKey === tableObserver.tableNodeKey
|
|
) {
|
|
tableObserver.updateTableTableSelection(null);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
tableObserver.hasHijackedSelectionStyles &&
|
|
!tableNode.isSelected()
|
|
) {
|
|
$removeHighlightStyleToTable(editor, tableObserver);
|
|
} else if (
|
|
!tableObserver.hasHijackedSelectionStyles &&
|
|
tableNode.isSelected()
|
|
) {
|
|
$addHighlightStyleToTable(editor, tableObserver);
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
tableObserver.listenersToRemove.add(
|
|
editor.registerCommand(
|
|
INSERT_PARAGRAPH_COMMAND,
|
|
() => {
|
|
const selection = $getSelection();
|
|
if (
|
|
!$isRangeSelection(selection) ||
|
|
!selection.isCollapsed() ||
|
|
!$isSelectionInTable(selection, tableNode)
|
|
) {
|
|
return false;
|
|
}
|
|
const edgePosition = $getTableEdgeCursorPosition(
|
|
editor,
|
|
selection,
|
|
tableNode,
|
|
);
|
|
if (edgePosition) {
|
|
$insertParagraphAtTableEdge(edgePosition, tableNode);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_CRITICAL,
|
|
),
|
|
);
|
|
|
|
return tableObserver;
|
|
}
|
|
|
|
export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
|
|
Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
|
|
|
|
export function attachTableObserverToTableElement(
|
|
tableElement: HTMLTableElementWithWithTableSelectionState,
|
|
tableObserver: TableObserver,
|
|
) {
|
|
tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
|
|
}
|
|
|
|
export function getTableObserverFromTableElement(
|
|
tableElement: HTMLTableElementWithWithTableSelectionState,
|
|
): TableObserver | null {
|
|
return tableElement[LEXICAL_ELEMENT_KEY];
|
|
}
|
|
|
|
export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
|
|
let currentNode: ParentNode | Node | null = node;
|
|
|
|
while (currentNode != null) {
|
|
const nodeName = currentNode.nodeName;
|
|
|
|
if (nodeName === 'TD' || nodeName === 'TH') {
|
|
// @ts-expect-error: internal field
|
|
const cell = currentNode._cell;
|
|
|
|
if (cell === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return cell;
|
|
}
|
|
|
|
currentNode = currentNode.parentNode;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function doesTargetContainText(node: Node): boolean {
|
|
const currentNode: ParentNode | Node | null = node;
|
|
|
|
if (currentNode !== null) {
|
|
const nodeName = currentNode.nodeName;
|
|
|
|
if (nodeName === 'SPAN') {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function getTable(tableElement: HTMLElement): TableDOMTable {
|
|
const domRows: TableDOMRows = [];
|
|
const grid = {
|
|
columns: 0,
|
|
domRows,
|
|
rows: 0,
|
|
};
|
|
let currentNode = tableElement.firstChild;
|
|
let x = 0;
|
|
let y = 0;
|
|
domRows.length = 0;
|
|
|
|
while (currentNode != null) {
|
|
const nodeMame = currentNode.nodeName;
|
|
|
|
if (nodeMame === 'TD' || nodeMame === 'TH') {
|
|
const elem = currentNode as HTMLElement;
|
|
const cell = {
|
|
elem,
|
|
hasBackgroundColor: elem.style.backgroundColor !== '',
|
|
highlighted: false,
|
|
x,
|
|
y,
|
|
};
|
|
|
|
// @ts-expect-error: internal field
|
|
currentNode._cell = cell;
|
|
|
|
let row = domRows[y];
|
|
if (row === undefined) {
|
|
row = domRows[y] = [];
|
|
}
|
|
|
|
row[x] = cell;
|
|
} else {
|
|
const child = currentNode.firstChild;
|
|
|
|
if (child != null) {
|
|
currentNode = child;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const sibling = currentNode.nextSibling;
|
|
|
|
if (sibling != null) {
|
|
x++;
|
|
currentNode = sibling;
|
|
continue;
|
|
}
|
|
|
|
const parent = currentNode.parentNode;
|
|
|
|
if (parent != null) {
|
|
const parentSibling = parent.nextSibling;
|
|
|
|
if (parentSibling == null) {
|
|
break;
|
|
}
|
|
|
|
y++;
|
|
x = 0;
|
|
currentNode = parentSibling;
|
|
}
|
|
}
|
|
|
|
grid.columns = x + 1;
|
|
grid.rows = y + 1;
|
|
|
|
return grid;
|
|
}
|
|
|
|
export function $updateDOMForSelection(
|
|
editor: LexicalEditor,
|
|
table: TableDOMTable,
|
|
selection: TableSelection | RangeSelection | null,
|
|
) {
|
|
const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
|
|
$forEachTableCell(table, (cell, lexicalNode) => {
|
|
const elem = cell.elem;
|
|
|
|
if (selectedCellNodes.has(lexicalNode)) {
|
|
cell.highlighted = true;
|
|
$addHighlightToDOM(editor, cell);
|
|
} else {
|
|
cell.highlighted = false;
|
|
$removeHighlightFromDOM(editor, cell);
|
|
if (!elem.getAttribute('style')) {
|
|
elem.removeAttribute('style');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function $forEachTableCell(
|
|
grid: TableDOMTable,
|
|
cb: (
|
|
cell: TableDOMCell,
|
|
lexicalNode: LexicalNode,
|
|
cords: {
|
|
x: number;
|
|
y: number;
|
|
},
|
|
) => void,
|
|
) {
|
|
const {domRows} = grid;
|
|
|
|
for (let y = 0; y < domRows.length; y++) {
|
|
const row = domRows[y];
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
|
|
for (let x = 0; x < row.length; x++) {
|
|
const cell = row[x];
|
|
if (!cell) {
|
|
continue;
|
|
}
|
|
const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
|
|
|
|
if (lexicalNode !== null) {
|
|
cb(cell, lexicalNode, {
|
|
x,
|
|
y,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function $addHighlightStyleToTable(
|
|
editor: LexicalEditor,
|
|
tableSelection: TableObserver,
|
|
) {
|
|
tableSelection.disableHighlightStyle();
|
|
$forEachTableCell(tableSelection.table, (cell) => {
|
|
cell.highlighted = true;
|
|
$addHighlightToDOM(editor, cell);
|
|
});
|
|
}
|
|
|
|
export function $removeHighlightStyleToTable(
|
|
editor: LexicalEditor,
|
|
tableObserver: TableObserver,
|
|
) {
|
|
tableObserver.enableHighlightStyle();
|
|
$forEachTableCell(tableObserver.table, (cell) => {
|
|
const elem = cell.elem;
|
|
cell.highlighted = false;
|
|
$removeHighlightFromDOM(editor, cell);
|
|
|
|
if (!elem.getAttribute('style')) {
|
|
elem.removeAttribute('style');
|
|
}
|
|
});
|
|
}
|
|
|
|
type Direction = 'backward' | 'forward' | 'up' | 'down';
|
|
|
|
const selectTableNodeInDirection = (
|
|
tableObserver: TableObserver,
|
|
tableNode: TableNode,
|
|
x: number,
|
|
y: number,
|
|
direction: Direction,
|
|
): boolean => {
|
|
const isForward = direction === 'forward';
|
|
|
|
switch (direction) {
|
|
case 'backward':
|
|
case 'forward':
|
|
if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
|
|
selectTableCellNode(
|
|
tableNode.getCellNodeFromCordsOrThrow(
|
|
x + (isForward ? 1 : -1),
|
|
y,
|
|
tableObserver.table,
|
|
),
|
|
isForward,
|
|
);
|
|
} else {
|
|
if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
|
|
selectTableCellNode(
|
|
tableNode.getCellNodeFromCordsOrThrow(
|
|
isForward ? 0 : tableObserver.table.columns - 1,
|
|
y + (isForward ? 1 : -1),
|
|
tableObserver.table,
|
|
),
|
|
isForward,
|
|
);
|
|
} else if (!isForward) {
|
|
tableNode.selectPrevious();
|
|
} else {
|
|
tableNode.selectNext();
|
|
}
|
|
}
|
|
|
|
return true;
|
|
|
|
case 'up':
|
|
if (y !== 0) {
|
|
selectTableCellNode(
|
|
tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
|
|
false,
|
|
);
|
|
} else {
|
|
tableNode.selectPrevious();
|
|
}
|
|
|
|
return true;
|
|
|
|
case 'down':
|
|
if (y !== tableObserver.table.rows - 1) {
|
|
selectTableCellNode(
|
|
tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
|
|
true,
|
|
);
|
|
} else {
|
|
tableNode.selectNext();
|
|
}
|
|
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const adjustFocusNodeInDirection = (
|
|
tableObserver: TableObserver,
|
|
tableNode: TableNode,
|
|
x: number,
|
|
y: number,
|
|
direction: Direction,
|
|
): boolean => {
|
|
const isForward = direction === 'forward';
|
|
|
|
switch (direction) {
|
|
case 'backward':
|
|
case 'forward':
|
|
if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
|
|
tableObserver.setFocusCellForSelection(
|
|
tableNode.getDOMCellFromCordsOrThrow(
|
|
x + (isForward ? 1 : -1),
|
|
y,
|
|
tableObserver.table,
|
|
),
|
|
);
|
|
}
|
|
|
|
return true;
|
|
case 'up':
|
|
if (y !== 0) {
|
|
tableObserver.setFocusCellForSelection(
|
|
tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
|
|
);
|
|
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
case 'down':
|
|
if (y !== tableObserver.table.rows - 1) {
|
|
tableObserver.setFocusCellForSelection(
|
|
tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
|
|
);
|
|
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
function $isSelectionInTable(
|
|
selection: null | BaseSelection,
|
|
tableNode: TableNode,
|
|
): boolean {
|
|
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
|
|
const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
|
|
const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
|
|
|
|
return isAnchorInside && isFocusInside;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
|
|
if (fromStart) {
|
|
tableCell.selectStart();
|
|
} else {
|
|
tableCell.selectEnd();
|
|
}
|
|
}
|
|
|
|
const BROWSER_BLUE_RGB = '172,206,247';
|
|
function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
|
|
const element = cell.elem;
|
|
const node = $getNearestNodeFromDOMNode(element);
|
|
invariant(
|
|
$isTableCellNode(node),
|
|
'Expected to find LexicalNode from Table Cell DOMNode',
|
|
);
|
|
const backgroundColor = node.getBackgroundColor();
|
|
if (backgroundColor === null) {
|
|
element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
|
|
} else {
|
|
element.style.setProperty(
|
|
'background-image',
|
|
`linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
|
|
);
|
|
}
|
|
element.style.setProperty('caret-color', 'transparent');
|
|
}
|
|
|
|
function $removeHighlightFromDOM(
|
|
editor: LexicalEditor,
|
|
cell: TableDOMCell,
|
|
): void {
|
|
const element = cell.elem;
|
|
const node = $getNearestNodeFromDOMNode(element);
|
|
invariant(
|
|
$isTableCellNode(node),
|
|
'Expected to find LexicalNode from Table Cell DOMNode',
|
|
);
|
|
const backgroundColor = node.getBackgroundColor();
|
|
if (backgroundColor === null) {
|
|
element.style.removeProperty('background-color');
|
|
}
|
|
element.style.removeProperty('background-image');
|
|
element.style.removeProperty('caret-color');
|
|
}
|
|
|
|
export function $findCellNode(node: LexicalNode): null | TableCellNode {
|
|
const cellNode = $findMatchingParent(node, $isTableCellNode);
|
|
return $isTableCellNode(cellNode) ? cellNode : null;
|
|
}
|
|
|
|
export function $findTableNode(node: LexicalNode): null | TableNode {
|
|
const tableNode = $findMatchingParent(node, $isTableNode);
|
|
return $isTableNode(tableNode) ? tableNode : null;
|
|
}
|
|
|
|
function $handleArrowKey(
|
|
editor: LexicalEditor,
|
|
event: KeyboardEvent,
|
|
direction: Direction,
|
|
tableNode: TableNode,
|
|
tableObserver: TableObserver,
|
|
): boolean {
|
|
if (
|
|
(direction === 'up' || direction === 'down') &&
|
|
isTypeaheadMenuInView(editor)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const selection = $getSelection();
|
|
|
|
if (!$isSelectionInTable(selection, tableNode)) {
|
|
if ($isRangeSelection(selection)) {
|
|
if (selection.isCollapsed() && direction === 'backward') {
|
|
const anchorType = selection.anchor.type;
|
|
const anchorOffset = selection.anchor.offset;
|
|
if (
|
|
anchorType !== 'element' &&
|
|
!(anchorType === 'text' && anchorOffset === 0)
|
|
) {
|
|
return false;
|
|
}
|
|
const anchorNode = selection.anchor.getNode();
|
|
if (!anchorNode) {
|
|
return false;
|
|
}
|
|
const parentNode = $findMatchingParent(
|
|
anchorNode,
|
|
(n) => $isElementNode(n) && !n.isInline(),
|
|
);
|
|
if (!parentNode) {
|
|
return false;
|
|
}
|
|
const siblingNode = parentNode.getPreviousSibling();
|
|
if (!siblingNode || !$isTableNode(siblingNode)) {
|
|
return false;
|
|
}
|
|
stopEvent(event);
|
|
siblingNode.selectEnd();
|
|
return true;
|
|
} else if (
|
|
event.shiftKey &&
|
|
(direction === 'up' || direction === 'down')
|
|
) {
|
|
const focusNode = selection.focus.getNode();
|
|
if ($isRootOrShadowRoot(focusNode)) {
|
|
const selectedNode = selection.getNodes()[0];
|
|
if (selectedNode) {
|
|
const tableCellNode = $findMatchingParent(
|
|
selectedNode,
|
|
$isTableCellNode,
|
|
);
|
|
if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
|
|
const firstDescendant = tableNode.getFirstDescendant();
|
|
const lastDescendant = tableNode.getLastDescendant();
|
|
if (!firstDescendant || !lastDescendant) {
|
|
return false;
|
|
}
|
|
const [firstCellNode] = $getNodeTriplet(firstDescendant);
|
|
const [lastCellNode] = $getNodeTriplet(lastDescendant);
|
|
const firstCellCoords = tableNode.getCordsFromCellNode(
|
|
firstCellNode,
|
|
tableObserver.table,
|
|
);
|
|
const lastCellCoords = tableNode.getCordsFromCellNode(
|
|
lastCellNode,
|
|
tableObserver.table,
|
|
);
|
|
const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
|
|
firstCellCoords.x,
|
|
firstCellCoords.y,
|
|
tableObserver.table,
|
|
);
|
|
const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
|
|
lastCellCoords.x,
|
|
lastCellCoords.y,
|
|
tableObserver.table,
|
|
);
|
|
tableObserver.setAnchorCellForSelection(firstCellDOM);
|
|
tableObserver.setFocusCellForSelection(lastCellDOM, true);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} else {
|
|
const focusParentNode = $findMatchingParent(
|
|
focusNode,
|
|
(n) => $isElementNode(n) && !n.isInline(),
|
|
);
|
|
if (!focusParentNode) {
|
|
return false;
|
|
}
|
|
const sibling =
|
|
direction === 'down'
|
|
? focusParentNode.getNextSibling()
|
|
: focusParentNode.getPreviousSibling();
|
|
if (
|
|
$isTableNode(sibling) &&
|
|
tableObserver.tableNodeKey === sibling.getKey()
|
|
) {
|
|
const firstDescendant = sibling.getFirstDescendant();
|
|
const lastDescendant = sibling.getLastDescendant();
|
|
if (!firstDescendant || !lastDescendant) {
|
|
return false;
|
|
}
|
|
const [firstCellNode] = $getNodeTriplet(firstDescendant);
|
|
const [lastCellNode] = $getNodeTriplet(lastDescendant);
|
|
const newSelection = selection.clone();
|
|
newSelection.focus.set(
|
|
(direction === 'up' ? firstCellNode : lastCellNode).getKey(),
|
|
direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
|
|
'element',
|
|
);
|
|
$setSelection(newSelection);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
|
const {anchor, focus} = selection;
|
|
const anchorCellNode = $findMatchingParent(
|
|
anchor.getNode(),
|
|
$isTableCellNode,
|
|
);
|
|
const focusCellNode = $findMatchingParent(
|
|
focus.getNode(),
|
|
$isTableCellNode,
|
|
);
|
|
if (
|
|
!$isTableCellNode(anchorCellNode) ||
|
|
!anchorCellNode.is(focusCellNode)
|
|
) {
|
|
return false;
|
|
}
|
|
const anchorCellTable = $findTableNode(anchorCellNode);
|
|
if (anchorCellTable !== tableNode && anchorCellTable != null) {
|
|
const anchorCellTableElement = editor.getElementByKey(
|
|
anchorCellTable.getKey(),
|
|
);
|
|
if (anchorCellTableElement != null) {
|
|
tableObserver.table = getTable(anchorCellTableElement);
|
|
return $handleArrowKey(
|
|
editor,
|
|
event,
|
|
direction,
|
|
anchorCellTable,
|
|
tableObserver,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (direction === 'backward' || direction === 'forward') {
|
|
const anchorType = anchor.type;
|
|
const anchorOffset = anchor.offset;
|
|
const anchorNode = anchor.getNode();
|
|
if (!anchorNode) {
|
|
return false;
|
|
}
|
|
|
|
const selectedNodes = selection.getNodes();
|
|
if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
|
|
) {
|
|
return $handleTableExit(event, anchorNode, tableNode, direction);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
|
|
const anchorDOM = editor.getElementByKey(anchor.key);
|
|
if (anchorDOM == null || anchorCellDom == null) {
|
|
return false;
|
|
}
|
|
|
|
let edgeSelectionRect;
|
|
if (anchor.type === 'element') {
|
|
edgeSelectionRect = anchorDOM.getBoundingClientRect();
|
|
} else {
|
|
const domSelection = window.getSelection();
|
|
if (domSelection === null || domSelection.rangeCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
const range = domSelection.getRangeAt(0);
|
|
edgeSelectionRect = range.getBoundingClientRect();
|
|
}
|
|
|
|
const edgeChild =
|
|
direction === 'up'
|
|
? anchorCellNode.getFirstChild()
|
|
: anchorCellNode.getLastChild();
|
|
if (edgeChild == null) {
|
|
return false;
|
|
}
|
|
|
|
const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
|
|
|
|
if (edgeChildDOM == null) {
|
|
return false;
|
|
}
|
|
|
|
const edgeRect = edgeChildDOM.getBoundingClientRect();
|
|
const isExiting =
|
|
direction === 'up'
|
|
? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
|
|
: edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
|
|
|
|
if (isExiting) {
|
|
stopEvent(event);
|
|
|
|
const cords = tableNode.getCordsFromCellNode(
|
|
anchorCellNode,
|
|
tableObserver.table,
|
|
);
|
|
|
|
if (event.shiftKey) {
|
|
const cell = tableNode.getDOMCellFromCordsOrThrow(
|
|
cords.x,
|
|
cords.y,
|
|
tableObserver.table,
|
|
);
|
|
tableObserver.setAnchorCellForSelection(cell);
|
|
tableObserver.setFocusCellForSelection(cell, true);
|
|
} else {
|
|
return selectTableNodeInDirection(
|
|
tableObserver,
|
|
tableNode,
|
|
cords.x,
|
|
cords.y,
|
|
direction,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
} else if ($isTableSelection(selection)) {
|
|
const {anchor, focus} = selection;
|
|
const anchorCellNode = $findMatchingParent(
|
|
anchor.getNode(),
|
|
$isTableCellNode,
|
|
);
|
|
const focusCellNode = $findMatchingParent(
|
|
focus.getNode(),
|
|
$isTableCellNode,
|
|
);
|
|
|
|
const [tableNodeFromSelection] = selection.getNodes();
|
|
const tableElement = editor.getElementByKey(
|
|
tableNodeFromSelection.getKey(),
|
|
);
|
|
if (
|
|
!$isTableCellNode(anchorCellNode) ||
|
|
!$isTableCellNode(focusCellNode) ||
|
|
!$isTableNode(tableNodeFromSelection) ||
|
|
tableElement == null
|
|
) {
|
|
return false;
|
|
}
|
|
tableObserver.updateTableTableSelection(selection);
|
|
|
|
const grid = getTable(tableElement);
|
|
const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
|
|
const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
|
|
cordsAnchor.x,
|
|
cordsAnchor.y,
|
|
grid,
|
|
);
|
|
tableObserver.setAnchorCellForSelection(anchorCell);
|
|
|
|
stopEvent(event);
|
|
|
|
if (event.shiftKey) {
|
|
const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
|
|
return adjustFocusNodeInDirection(
|
|
tableObserver,
|
|
tableNodeFromSelection,
|
|
cords.x,
|
|
cords.y,
|
|
direction,
|
|
);
|
|
} else {
|
|
focusCellNode.selectEnd();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function stopEvent(event: Event) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function isTypeaheadMenuInView(editor: LexicalEditor) {
|
|
// There is no inbuilt way to check if the component picker is in view
|
|
// but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
|
|
const root = editor.getRootElement();
|
|
if (!root) {
|
|
return false;
|
|
}
|
|
return (
|
|
root.hasAttribute('aria-controls') &&
|
|
root.getAttribute('aria-controls') === 'typeahead-menu'
|
|
);
|
|
}
|
|
|
|
function isExitingTableAnchor(
|
|
type: string,
|
|
offset: number,
|
|
anchorNode: LexicalNode,
|
|
direction: 'backward' | 'forward',
|
|
) {
|
|
return (
|
|
isExitingTableElementAnchor(type, anchorNode, direction) ||
|
|
$isExitingTableTextAnchor(type, offset, anchorNode, direction)
|
|
);
|
|
}
|
|
|
|
function isExitingTableElementAnchor(
|
|
type: string,
|
|
anchorNode: LexicalNode,
|
|
direction: 'backward' | 'forward',
|
|
) {
|
|
return (
|
|
type === 'element' &&
|
|
(direction === 'backward'
|
|
? anchorNode.getPreviousSibling() === null
|
|
: anchorNode.getNextSibling() === null)
|
|
);
|
|
}
|
|
|
|
function $isExitingTableTextAnchor(
|
|
type: string,
|
|
offset: number,
|
|
anchorNode: LexicalNode,
|
|
direction: 'backward' | 'forward',
|
|
) {
|
|
const parentNode = $findMatchingParent(
|
|
anchorNode,
|
|
(n) => $isElementNode(n) && !n.isInline(),
|
|
);
|
|
if (!parentNode) {
|
|
return false;
|
|
}
|
|
const hasValidOffset =
|
|
direction === 'backward'
|
|
? offset === 0
|
|
: offset === anchorNode.getTextContentSize();
|
|
return (
|
|
type === 'text' &&
|
|
hasValidOffset &&
|
|
(direction === 'backward'
|
|
? parentNode.getPreviousSibling() === null
|
|
: parentNode.getNextSibling() === null)
|
|
);
|
|
}
|
|
|
|
function $handleTableExit(
|
|
event: KeyboardEvent,
|
|
anchorNode: LexicalNode,
|
|
tableNode: TableNode,
|
|
direction: 'backward' | 'forward',
|
|
) {
|
|
const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
|
|
if (!$isTableCellNode(anchorCellNode)) {
|
|
return false;
|
|
}
|
|
const [tableMap, cellValue] = $computeTableMap(
|
|
tableNode,
|
|
anchorCellNode,
|
|
anchorCellNode,
|
|
);
|
|
if (!isExitingCell(tableMap, cellValue, direction)) {
|
|
return false;
|
|
}
|
|
|
|
const toNode = $getExitingToNode(anchorNode, direction, tableNode);
|
|
if (!toNode || $isTableNode(toNode)) {
|
|
return false;
|
|
}
|
|
|
|
stopEvent(event);
|
|
if (direction === 'backward') {
|
|
toNode.selectEnd();
|
|
} else {
|
|
toNode.selectStart();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isExitingCell(
|
|
tableMap: TableMapType,
|
|
cellValue: TableMapValueType,
|
|
direction: 'backward' | 'forward',
|
|
) {
|
|
const firstCell = tableMap[0][0];
|
|
const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
|
|
const {startColumn, startRow} = cellValue;
|
|
return direction === 'backward'
|
|
? startColumn === firstCell.startColumn && startRow === firstCell.startRow
|
|
: startColumn === lastCell.startColumn && startRow === lastCell.startRow;
|
|
}
|
|
|
|
function $getExitingToNode(
|
|
anchorNode: LexicalNode,
|
|
direction: 'backward' | 'forward',
|
|
tableNode: TableNode,
|
|
) {
|
|
const parentNode = $findMatchingParent(
|
|
anchorNode,
|
|
(n) => $isElementNode(n) && !n.isInline(),
|
|
);
|
|
if (!parentNode) {
|
|
return undefined;
|
|
}
|
|
const anchorSibling =
|
|
direction === 'backward'
|
|
? parentNode.getPreviousSibling()
|
|
: parentNode.getNextSibling();
|
|
return anchorSibling && $isTableNode(anchorSibling)
|
|
? anchorSibling
|
|
: direction === 'backward'
|
|
? tableNode.getPreviousSibling()
|
|
: tableNode.getNextSibling();
|
|
}
|
|
|
|
function $insertParagraphAtTableEdge(
|
|
edgePosition: 'first' | 'last',
|
|
tableNode: TableNode,
|
|
children?: LexicalNode[],
|
|
) {
|
|
const paragraphNode = $createParagraphNode();
|
|
if (edgePosition === 'first') {
|
|
tableNode.insertBefore(paragraphNode);
|
|
} else {
|
|
tableNode.insertAfter(paragraphNode);
|
|
}
|
|
paragraphNode.append(...(children || []));
|
|
paragraphNode.selectEnd();
|
|
}
|
|
|
|
function $getTableEdgeCursorPosition(
|
|
editor: LexicalEditor,
|
|
selection: RangeSelection,
|
|
tableNode: TableNode,
|
|
) {
|
|
const tableNodeParent = tableNode.getParent();
|
|
if (!tableNodeParent) {
|
|
return undefined;
|
|
}
|
|
|
|
const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
|
|
if (!tableNodeParentDOM) {
|
|
return undefined;
|
|
}
|
|
|
|
// TODO: Add support for nested tables
|
|
const domSelection = window.getSelection();
|
|
if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
|
|
return undefined;
|
|
}
|
|
|
|
const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
|
|
$isTableCellNode(n),
|
|
) as TableCellNode | null;
|
|
if (!anchorCellNode) {
|
|
return undefined;
|
|
}
|
|
|
|
const parentTable = $findMatchingParent(anchorCellNode, (n) =>
|
|
$isTableNode(n),
|
|
);
|
|
if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
|
|
return undefined;
|
|
}
|
|
|
|
const [tableMap, cellValue] = $computeTableMap(
|
|
tableNode,
|
|
anchorCellNode,
|
|
anchorCellNode,
|
|
);
|
|
const firstCell = tableMap[0][0];
|
|
const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
|
|
const {startRow, startColumn} = cellValue;
|
|
|
|
const isAtFirstCell =
|
|
startRow === firstCell.startRow && startColumn === firstCell.startColumn;
|
|
const isAtLastCell =
|
|
startRow === lastCell.startRow && startColumn === lastCell.startColumn;
|
|
|
|
if (isAtFirstCell) {
|
|
return 'first';
|
|
} else if (isAtLastCell) {
|
|
return 'last';
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|