Skip to content

Commit

Permalink
feat: table support
Browse files Browse the repository at this point in the history
  • Loading branch information
umaranis committed Oct 11, 2024
1 parent 4c95cbe commit c2ad92e
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script lang="ts">
export let dataTestId: string | undefined = undefined;
export let label: string;
export let placeholder = '';
export let value: string;
export let id = '';
export let onChange: ((value: string) => void) | undefined = undefined;
export let width: string | undefined = undefined;
</script>

<div class="Input__wrapper">
<label class="Input__label" for={id}>{label}</label>
<input
type="number"
class="Input__input"
style="width: {width};"
{placeholder}
bind:value
data-test-id={dataTestId}
{id}
on:change={(e) => {
if (onChange) {
/* @ts-ignore TS not supported in Svelte Html */
onChange(e.target.value);
}
}} />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
import DropDownItem from '../../generic/dropdown/DropDownItem.svelte';
</script>

<DropDownItem on:click class="item">
<i class="icon table" />
<span class="text">Table</span>
</DropDownItem>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script lang="ts">
import {getActiveEditor} from '$lib/core/composerContext.js';
import {INSERT_TABLE_COMMAND} from '@lexical/table';
import ModalDialog from '../../generic/dialog/ModalDialog.svelte';
import {getCommands} from '$lib/core/commands.js';
import NumberInput from '$lib/components/generic/input/NumberInput.svelte';
let rows = '5';
let columns = '5';
let isDisabled = true;
$: {
const row = Number(rows);
const column = Number(columns);
if (row && row > 0 && row <= 500 && column && column > 0 && column <= 50) {
isDisabled = false;
} else {
isDisabled = true;
}
}
const activeEditor = getActiveEditor();
export let showModal = false;
export function open() {
showModal = true;
}
function close() {
showModal = false;
getCommands().FocusEditor.execute($activeEditor);
}
const onClick = () => {
$activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {
columns,
rows,
});
close();
};
</script>

<ModalDialog bind:showModal>
<NumberInput
placeholder={'# of rows (1-500)'}
label="Rows"
value={rows}
dataTestId="table-modal-rows" />
<NumberInput
placeholder={'# of columns (1-50)'}
label="Columns"
value={columns}
dataTestId="table-modal-columns" />
<div class="DialogActions" data-test-id="table-model-confirm-insert">
<button
disabled={isDisabled}
class="Button__root"
class:Button__disabled={isDisabled}
on:click={onClick}>
Confirm
</button>
</div>
</ModalDialog>
251 changes: 251 additions & 0 deletions packages/svelte-lexical/src/lib/core/plugins/Table/TablePlugin.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<script lang="ts">
import type {
HTMLTableElementWithWithTableSelectionState,
InsertTableCommandPayload,
TableObserver,
} from '@lexical/table';
import type {LexicalEditor, NodeKey} from 'lexical';
import {
$computeTableMap as computeTableMap,
$computeTableMapSkipCellCheck as computeTableMapSkipCellCheck,
$createTableCellNode as createTableCellNode,
$createTableNodeWithDimensions as createTableNodeWithDimensions,
$getNodeTriplet as getNodeTriplet,
$isTableCellNode as isTableCellNode,
$isTableNode as isTableNode,
$isTableRowNode as isTableRowNode,
applyTableHandlers,
INSERT_TABLE_COMMAND,
TableCellNode,
TableNode,
TableRowNode,
} from '@lexical/table';
import {
$insertFirst as insertFirst,
$insertNodeToNearestRoot as insertNodeToNearestRoot,
mergeRegister,
} from '@lexical/utils';
import {
$createParagraphNode as createParagraphNode,
$getNodeByKey as getNodeByKey,
$isTextNode as isTextNode,
COMMAND_PRIORITY_EDITOR,
} from 'lexical';
import {onMount} from 'svelte';
import {getEditor} from '../../composerContext.js';
export let hasCellMerge = true;
export let hasCellBackgroundColor = true;
export let hasTabHandler = true;
const editor: LexicalEditor = getEditor();
onMount(() => {
if (!editor.hasNodes([TableNode, TableCellNode, TableRowNode])) {
throw new Error(
'TablePlugin: TableNode, TableCellNode or TableRowNode not registered on editor',
);
}
const unregisterTableInsertCmd =
editor.registerCommand<InsertTableCommandPayload>(
INSERT_TABLE_COMMAND,
({columns, rows, includeHeaders}) => {
const tableNode = createTableNodeWithDimensions(
Number(rows),
Number(columns),
includeHeaders,
);
insertNodeToNearestRoot(tableNode);
const firstDescendant = tableNode.getFirstDescendant();
if (isTextNode(firstDescendant)) {
firstDescendant.select();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
);
const unregisterMutationListener = editor.registerNodeTransform(
TableNode,
(node) => {
const [gridMap] = computeTableMapSkipCellCheck(node, null, null);
const maxRowLength = gridMap.reduce((curLength, row) => {
return Math.max(curLength, row.length);
}, 0);
const rowNodes = node.getChildren();
for (let i = 0; i < gridMap.length; ++i) {
const rowNode = rowNodes[i];
if (!rowNode) {
continue;
}
const rowLength = gridMap[i].reduce(
(acc, cell) => (cell ? 1 + acc : acc),
0,
);
if (rowLength === maxRowLength) {
continue;
}
for (let j = rowLength; j < maxRowLength; ++j) {
// TODO: inherit header state from another header or body
const newCell = createTableCellNode(0);
newCell.append(createParagraphNode());
(rowNode as TableRowNode).append(newCell);
}
}
},
);
const unregisterUnmergeCellsNodeTransform = unmergeCellsNodeTransform();
const unregisterRemoveCellColorNodeTransform =
removeCellColorNodeTransform();
const unregisterTableSelections = setupTableSelections();
return mergeRegister(
unregisterTableInsertCmd,
unregisterMutationListener,
unregisterTableSelections,
unregisterUnmergeCellsNodeTransform,
unregisterRemoveCellColorNodeTransform,
);
});
function setupTableSelections() {
const tableSelections = new Map<
NodeKey,
[TableObserver, HTMLTableElementWithWithTableSelectionState]
>();
const initializeTableNode = (
tableNode: TableNode,
nodeKey: NodeKey,
dom: HTMLElement,
) => {
const tableElement = dom as HTMLTableElementWithWithTableSelectionState;
const tableSelection = applyTableHandlers(
tableNode,
tableElement,
editor,
hasTabHandler,
);
tableSelections.set(nodeKey, [tableSelection, tableElement]);
};
const unregisterMutationListener = editor.registerMutationListener(
TableNode,
(nodeMutations) => {
for (const [nodeKey, mutation] of nodeMutations) {
if (mutation === 'created' || mutation === 'updated') {
const tableSelection = tableSelections.get(nodeKey);
const dom = editor.getElementByKey(nodeKey);
if (!(tableSelection && dom === tableSelection[1])) {
// The update created a new DOM node, destroy the existing TableObserver
if (tableSelection) {
tableSelection[0].removeListeners();
tableSelections.delete(nodeKey);
}
if (dom !== null) {
// Create a new TableObserver
editor.getEditorState().read(() => {
const tableNode = getNodeByKey<TableNode>(nodeKey);
if (isTableNode(tableNode)) {
initializeTableNode(tableNode, nodeKey, dom);
}
});
}
}
} else if (mutation === 'destroyed') {
const tableSelection = tableSelections.get(nodeKey);
if (tableSelection !== undefined) {
tableSelection[0].removeListeners();
tableSelections.delete(nodeKey);
}
}
}
},
{skipInitialization: false},
);
return () => {
unregisterMutationListener();
// Hook might be called multiple times so cleaning up tables listeners as well,
// as it'll be reinitialized during recurring call
for (const [, [tableSelection]] of tableSelections) {
tableSelection.removeListeners();
}
};
}
// Unmerge cells when the feature isn't enabled
function unmergeCellsNodeTransform() {
if (hasCellMerge) {
return () => {
// intentionally empty
};
}
return editor.registerNodeTransform(TableCellNode, (node) => {
if (node.getColSpan() > 1 || node.getRowSpan() > 1) {
// When we have rowSpan we have to map the entire Table to understand where the new Cells
// fit best; let's analyze all Cells at once to save us from further transform iterations
const [, , gridNode] = getNodeTriplet(node);
const [gridMap] = computeTableMap(gridNode, node, node);
// TODO this function expects Tables to be normalized. Look into this once it exists
const rowsCount = gridMap.length;
const columnsCount = gridMap[0].length;
let row = gridNode.getFirstChild();
if (!isTableRowNode(row)) {
throw new Error('Expected TableNode first child to be a RowNode');
}
const unmerged = [];
for (let i = 0; i < rowsCount; i++) {
if (i !== 0) {
row = row!.getNextSibling();
if (!isTableRowNode(row)) {
throw new Error('Expected TableNode first child to be a RowNode');
}
}
let lastRowCell: null | TableCellNode = null;
for (let j = 0; j < columnsCount; j++) {
const cellMap = gridMap[i][j];
const cell = cellMap.cell;
if (cellMap.startRow === i && cellMap.startColumn === j) {
lastRowCell = cell;
unmerged.push(cell);
} else if (cell.getColSpan() > 1 || cell.getRowSpan() > 1) {
if (!isTableCellNode(cell)) {
throw new Error(
'Expected TableNode cell to be a TableCellNode',
);
}
const newCell = createTableCellNode(cell.__headerState);
if (lastRowCell !== null) {
lastRowCell.insertAfter(newCell);
} else {
insertFirst(row, newCell);
}
}
}
}
for (const cell of unmerged) {
cell.setColSpan(1);
cell.setRowSpan(1);
}
}
});
}
// Remove cell background color when feature is disabled
function removeCellColorNodeTransform() {
if (hasCellBackgroundColor) {
return () => {
// intentionally empty
};
}
return editor.registerNodeTransform(TableCellNode, (node) => {
if (node.getBackgroundColor() !== null) {
node.setBackgroundColor(null);
}
});
}
</script>
1 change: 1 addition & 0 deletions packages/svelte-lexical/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export {default as InsertImageDialog} from './components/toolbar/dialogs/InsertI
export {default as InsertImageUploadedDialogBody} from './components/toolbar/dialogs/InsertImageUploadedDialogBody.svelte';
export {default as InsertImageUriDialogBody} from './components/toolbar/dialogs/InsertImageUriDialogBody.svelte';
export {default as InsertColumnsDialog} from './components/toolbar/dialogs/InsertColumnsDialog.svelte';
export {default as InsertTableDialog} from './components/toolbar/dialogs/InsertTableDialog.svelte';

export {getCommands} from './core/commands.js';
export type {ImagePayload} from './core/plugins/Image/ImageNode.js';
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte-lexical/src/routes/RichTextComposer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
} from '$lib/index.js';
import RichTextToolbar from './RichTextToolbar.svelte';
import {onMount} from 'svelte';
import TablePlugin from '$lib/core/plugins/Table/TablePlugin.svelte';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
let isSmallWidthViewport = true;
let editorDiv;
Expand All @@ -67,6 +69,9 @@
CodeHighlightNode,
LayoutContainerNode,
LayoutItemNode,
TableNode,
TableCellNode,
TableRowNode,
],
onError: (error: Error) => {
throw error;
Expand Down Expand Up @@ -140,6 +145,7 @@
LINK,
]} />
<ColumnLayoutPlugin />
<TablePlugin />
<ActionBar />
</div>
</div>
Expand Down
Loading

0 comments on commit c2ad92e

Please sign in to comment.