From a5e82dd91d9f5ebea10fa0dce6f09117a825af07 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Wed, 30 Oct 2024 11:18:15 +0100 Subject: [PATCH] feat: Table row/column extension UI (#1172) * Added buttons to extend table rows/columns * Added proper extend button implementations * Fixed table handle menu buttons not preserving column widths * Implemented PR feedback * Implemented PR feedback * Cleaned up code * Updated test snapshots & fixes * Added unit tests for column widths * remaining todos * prosemirror-tables upgrade * show buttons when to right / bottom of table * fix lint * fix names * fix small bugs * fix drag handle * fix safari support (will require prosemirror-tables upgrade * add comment * fix safari --------- Co-authored-by: yousefed --- package-lock.json | 8 +- packages/ariakit/src/index.tsx | 2 + packages/ariakit/src/style.css | 187 ++--- .../ariakit/src/tableHandle/ExtendButton.tsx | 30 + packages/core/package.json | 2 +- .../__snapshots__/insertBlocks.test.ts.snap | 30 + .../__snapshots__/mergeBlocks.test.ts.snap | 25 + .../__snapshots__/moveBlock.test.ts.snap | 40 ++ .../__snapshots__/removeBlocks.test.ts.snap | 10 + .../__snapshots__/replaceBlocks.test.ts.snap | 40 ++ .../__snapshots__/splitBlock.test.ts.snap | 30 + .../__snapshots__/updateBlock.test.ts.snap | 87 +++ .../core/src/api/clipboard/clipboard.test.ts | 2 +- .../table/allColWidths/external.html | 1 + .../table/allColWidths/internal.html | 1 + .../__snapshots__/table/basic/external.html | 1 + .../__snapshots__/table/basic/internal.html | 1 + .../table/mixedColWidths/external.html | 1 + .../table/mixedColWidths/internal.html | 1 + .../table/allColWidths/markdown.md | 5 + .../__snapshots__/table/basic/markdown.md | 5 + .../table/mixedColWidths/markdown.md | 5 + .../nodeConversions.test.ts.snap | 642 ++++++++++++++++++ .../src/api/nodeConversions/blockToNode.ts | 15 +- .../src/api/nodeConversions/nodeToBlock.ts | 12 +- .../html/__snapshots__/parse-notion-html.json | 5 + .../src/api/testUtil/cases/defaultSchema.ts | 68 ++ .../src/api/testUtil/partialBlockTestUtil.ts | 27 +- .../TableBlockContent/TableBlockContent.ts | 80 ++- .../TableBlockContent/TableExtension.ts | 11 +- packages/core/src/editor/editor.css | 38 +- .../getDefaultEmojiPickerItems.ts | 3 +- .../TableHandles/TableHandlesPlugin.ts | 337 +++++---- packages/core/src/index.ts | 4 + packages/core/src/schema/blocks/types.ts | 2 + packages/mantine/src/index.tsx | 2 + packages/mantine/src/style.css | 23 +- .../mantine/src/tableHandle/ExtendButton.tsx | 27 + .../ExtendButton/ExtendButton.tsx | 304 +++++++++ .../ExtendButton/ExtendButtonProps.ts | 25 + .../components/TableHandles/TableHandle.tsx | 15 +- .../DefaultButtons/AddButton.tsx | 12 +- .../DefaultButtons/DeleteButton.tsx | 10 +- .../TableHandles/TableHandleProps.ts | 1 + .../TableHandles/TableHandlesController.tsx | 125 +++- .../hooks/useExtendButtonsPositioning.ts | 87 +++ .../hooks/useTableHandlesPositioning.ts | 1 - .../react/src/editor/ComponentsContext.tsx | 6 + packages/react/src/index.ts | 3 + packages/shadcn/src/index.tsx | 2 + packages/shadcn/src/style.css | 10 +- .../shadcn/src/tableHandle/ExtendButton.tsx | 40 ++ playground/src/main.tsx | 6 +- 53 files changed, 2186 insertions(+), 271 deletions(-) create mode 100644 packages/ariakit/src/tableHandle/ExtendButton.tsx create mode 100644 packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/table/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/table/basic/internal.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/internal.html create mode 100644 packages/core/src/api/exporters/markdown/__snapshots__/table/allColWidths/markdown.md create mode 100644 packages/core/src/api/exporters/markdown/__snapshots__/table/basic/markdown.md create mode 100644 packages/core/src/api/exporters/markdown/__snapshots__/table/mixedColWidths/markdown.md create mode 100644 packages/mantine/src/tableHandle/ExtendButton.tsx create mode 100644 packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx create mode 100644 packages/react/src/components/TableHandles/ExtendButton/ExtendButtonProps.ts create mode 100644 packages/react/src/components/TableHandles/hooks/useExtendButtonsPositioning.ts create mode 100644 packages/shadcn/src/tableHandle/ExtendButton.tsx diff --git a/package-lock.json b/package-lock.json index db74f1351..78c617d34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23294,9 +23294,9 @@ } }, "node_modules/prosemirror-tables": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.5.0.tgz", - "integrity": "sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.1.tgz", + "integrity": "sha512-p8WRJNA96jaNQjhJolmbxTzd6M4huRE5xQ8OxjvMhQUP0Nzpo4zz6TztEiwk6aoqGBhz9lxRWR1yRZLlpQN98w==", "dependencies": { "prosemirror-keymap": "^1.1.2", "prosemirror-model": "^1.8.1", @@ -28554,7 +28554,7 @@ "hast-util-from-dom": "^4.2.0", "prosemirror-model": "^1.21.0", "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.3.7", + "prosemirror-tables": "^1.6.1", "prosemirror-transform": "^1.9.0", "prosemirror-view": "^1.33.7", "rehype-format": "^5.0.0", diff --git a/packages/ariakit/src/index.tsx b/packages/ariakit/src/index.tsx index d651dde9a..cd7b217a6 100644 --- a/packages/ariakit/src/index.tsx +++ b/packages/ariakit/src/index.tsx @@ -39,6 +39,7 @@ import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel.js"; import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader.js"; import { TableHandle } from "./tableHandle/TableHandle.js"; +import { ExtendButton } from "./tableHandle/ExtendButton.js"; import { Toolbar } from "./toolbar/Toolbar.js"; import { ToolbarButton } from "./toolbar/ToolbarButton.js"; import { ToolbarSelect } from "./toolbar/ToolbarSelect.js"; @@ -81,6 +82,7 @@ export const components: Components = { }, TableHandle: { Root: TableHandle, + ExtendButton: ExtendButton, }, Generic: { Form: { diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 213f4d5cd..bf1dcd4ce 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -6,181 +6,206 @@ @import "./ariakitStyles.css"; .bn-ak-input-wrapper { - align-items: center; - display: flex; - gap: 0.5rem; + align-items: center; + display: flex; + gap: 0.5rem; } .bn-toolbar .bn-ak-button { - width: unset; + width: unset; } .bn-toolbar .bn-ak-button[data-selected] { - padding-top: 0.125rem; - box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--border); + padding-top: 0.125rem; + box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--border); } .bn-toolbar .bn-ak-button[data-selected]:where(.dark, .dark *) { - box-shadow: inset 0 0 0 1px var(--border), inset 0 1px 1px 1px var(--shadow); + box-shadow: inset 0 0 0 1px var(--border), inset 0 1px 1px 1px var(--shadow); } .bn-toolbar .bn-ak-popover { - gap: 0.5rem; + gap: 0.5rem; } .bn-ariakit .bn-tab-panel { - align-items: center; - display: flex; - flex-direction: column; - gap: 0.5rem; + align-items: center; + display: flex; + flex-direction: column; + gap: 0.5rem; } .bn-ariakit .bn-file-input { - max-width: 100%; + max-width: 100%; } .bn-ak-button { - outline-style: none; + outline-style: none; } .bn-ak-menu-item[aria-selected="true"], .bn-ak-menu-item:hover { - background-color: hsl(204 100% 40%); - color: hsl(204 20% 100%); + background-color: hsl(204 100% 40%); + color: hsl(204 20% 100%); } .bn-ak-menu-item { - display: flex; + display: flex; } .bn-ariakit .bn-dropdown { - overflow: visible; + overflow: visible; } .bn-ariakit .bn-suggestion-menu { - height: fit-content; - max-height: 100%; + height: fit-content; + max-height: 100%; } .bn-ariakit .bn-color-picker-dropdown { - overflow: scroll; + overflow: scroll; } .bn-ak-suggestion-menu-item-body { - flex: 1; + flex: 1; } .bn-ak-suggestion-menu-item-subtitle { - font-size: 0.7rem; + font-size: 0.7rem; } .bn-ak-suggestion-menu-item-section[data-position="left"] { - padding: 8px; + padding: 8px; } .bn-ak-suggestion-menu-item-section[data-position="right"] { - --border: rgb(0 0 0/13%); - --highlight: rgb(255 255 255/20%); - --shadow: rgb(0 0 0/10%); - box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--highlight), + --border: rgb(0 0 0/13%); + --highlight: rgb(255 255 255/20%); + --shadow: rgb(0 0 0/10%); + box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--highlight), inset 0 -1px 0 var(--shadow), 0 1px 1px var(--shadow); - font-size: 0.7rem; - border-radius: 4px; - padding-inline: 4px; + font-size: 0.7rem; + border-radius: 4px; + padding-inline: 4px; } .bn-ariakit .bn-grid-suggestion-menu { - background: var(--bn-colors-menu-background); - border-radius: var(--bn-border-radius-large); - box-shadow: var(--bn-shadow-medium); - display: grid; - gap: 7px; - height: fit-content; - justify-items: center; - max-height: min(500px, 100%); - overflow-y: auto; - padding: 20px; + background: var(--bn-colors-menu-background); + border-radius: var(--bn-border-radius-large); + box-shadow: var(--bn-shadow-medium); + display: grid; + gap: 7px; + height: fit-content; + justify-items: center; + max-height: min(500px, 100%); + overflow-y: auto; + padding: 20px; } .bn-ariakit .bn-grid-suggestion-menu-item { - align-items: center; - border-radius: var(--bn-border-radius-large); - cursor: pointer; - display: flex; - font-size: 24px; - height: 32px; - justify-content: center; - margin: 2px; - padding: 4px; - width: 32px; + align-items: center; + border-radius: var(--bn-border-radius-large); + cursor: pointer; + display: flex; + font-size: 24px; + height: 32px; + justify-content: center; + margin: 2px; + padding: 4px; + width: 32px; } .bn-ariakit .bn-grid-suggestion-menu-item[aria-selected="true"], .bn-ariakit .bn-grid-suggestion-menu-item:hover { - background-color: var(--bn-colors-hovered-background); + background-color: var(--bn-colors-hovered-background); } .bn-ariakit .bn-grid-suggestion-menu-empty-item, .bn-ariakit .bn-grid-suggestion-menu-loader { - align-items: center; - color: var(--bn-colors-menu-text); - display: flex; - font-size: 14px; - font-weight: 500; - height: 32px; - justify-content: center; + align-items: center; + color: var(--bn-colors-menu-text); + display: flex; + font-size: 14px; + font-weight: 500; + height: 32px; + justify-content: center; } .bn-ariakit .bn-grid-suggestion-menu-loader span { - background-color: var(--bn-colors-side-menu); + background-color: var(--bn-colors-side-menu); } .bn-ariakit .bn-side-menu { - align-items: center; - display: flex; - justify-content: center; + align-items: center; + display: flex; + justify-content: center; } .bn-side-menu .bn-ak-button { - height: fit-content; - padding: 0; - width: fit-content; + height: fit-content; + padding: 0; + width: fit-content; } .bn-ariakit .bn-panel-popover { - background-color: transparent; - border: none; - box-shadow: none; + background-color: transparent; + border: none; + box-shadow: none; } .bn-ariakit .bn-table-handle { - height: fit-content; - padding: 0; - width: fit-content; + height: fit-content; + padding: 0; + width: fit-content; } .bn-ariakit .bn-side-menu, -.bn-ariakit .bn-table-handle { - color: gray; +.bn-ariakit .bn-table-handle, +.bn-ariakit .bn-extend-button { + color: gray; +} + +.bn-ariakit .bn-extend-button-editing { + background-color: hsl(204 4% 0% / 0.05); +} + +.bn-ariakit .bn-extend-button-editing:where(.dark, .dark *) { + background-color: hsl(204 20% 100% / 0.05); +} + +.bn-ariakit .bn-extend-button-add-remove-columns { + height: 100%; + width: 18px; + padding: 0; + margin-left: 4px; + cursor: col-resize; +} + +.bn-ariakit .bn-extend-button-add-remove-rows { + height: 18px; + width: 100%; + padding: 0; + margin-top: 4px; + cursor: row-resize; } .bn-ak-button:where(.dark, .dark *) { - color: hsl(204 20% 100%); + color: hsl(204 20% 100%); } .bn-ak-tab, .bn-ariakit .bn-file-input { - background-color: transparent; - color: black; + background-color: transparent; + color: black; } .bn-ak-tab:where(.dark, .dark *), .bn-ariakit .bn-file-input:where(.dark, .dark *) { - color: white; + color: white; } .bn-ak-tooltip { - align-items: center; - display: flex; - flex-direction: column; + align-items: center; + display: flex; + flex-direction: column; } diff --git a/packages/ariakit/src/tableHandle/ExtendButton.tsx b/packages/ariakit/src/tableHandle/ExtendButton.tsx new file mode 100644 index 000000000..67b163905 --- /dev/null +++ b/packages/ariakit/src/tableHandle/ExtendButton.tsx @@ -0,0 +1,30 @@ +import { Button as AriakitButton } from "@ariakit/react"; + +import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const ExtendButton = forwardRef< + HTMLButtonElement, + ComponentProps["TableHandle"]["ExtendButton"] +>((props, ref) => { + const { children, className, onMouseDown, onClick, ...rest } = props; + + // false, because rest props can be added by mantine when button is used as a trigger + // assertEmpty in this case is only used at typescript level, not runtime level + assertEmpty(rest, false); + + return ( + + {children} + + ); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 62667072a..b116b4d6f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,7 +82,7 @@ "hast-util-from-dom": "^4.2.0", "prosemirror-model": "^1.21.0", "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.3.7", + "prosemirror-tables": "^1.6.1", "prosemirror-transform": "^1.9.0", "prosemirror-view": "^1.33.7", "rehype-format": "^5.0.0", diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap index 4bac28e44..34aebcb1b 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap @@ -309,6 +309,11 @@ exports[`Test insertBlocks > Insert multiple blocks after 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -831,6 +836,11 @@ exports[`Test insertBlocks > Insert multiple blocks before 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1313,6 +1323,11 @@ exports[`Test insertBlocks > Insert single basic block after 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1795,6 +1810,11 @@ exports[`Test insertBlocks > Insert single basic block before 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2334,6 +2354,11 @@ exports[`Test insertBlocks > Insert single complex block after 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2873,6 +2898,11 @@ exports[`Test insertBlocks > Insert single complex block before 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap index 6b2592770..233e8e85f 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap @@ -241,6 +241,11 @@ exports[`Test mergeBlocks > Basic 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -695,6 +700,11 @@ exports[`Test mergeBlocks > Blocks have different types 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1149,6 +1159,11 @@ exports[`Test mergeBlocks > First block has children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1602,6 +1617,11 @@ exports[`Test mergeBlocks > Second block has children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2073,6 +2093,11 @@ exports[`Test mergeBlocks > Second block is empty 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/blockManipulation/commands/moveBlock/__snapshots__/moveBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/moveBlock/__snapshots__/moveBlock.test.ts.snap index 558e863c7..64a53c658 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlock/__snapshots__/moveBlock.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/moveBlock/__snapshots__/moveBlock.test.ts.snap @@ -258,6 +258,11 @@ exports[`Test moveBlockDown > Basic 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -729,6 +734,11 @@ exports[`Test moveBlockDown > Into children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1200,6 +1210,11 @@ exports[`Test moveBlockDown > Last block 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1671,6 +1686,11 @@ exports[`Test moveBlockDown > Out of children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2141,6 +2161,11 @@ exports[`Test moveBlockUp > Basic 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2612,6 +2637,11 @@ exports[`Test moveBlockUp > First block 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -3083,6 +3113,11 @@ exports[`Test moveBlockUp > Into children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -3554,6 +3589,11 @@ exports[`Test moveBlockUp > Out of children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/blockManipulation/commands/removeBlocks/__snapshots__/removeBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/removeBlocks/__snapshots__/removeBlocks.test.ts.snap index 68c3cbfa1..27ed2158a 100644 --- a/packages/core/src/api/blockManipulation/commands/removeBlocks/__snapshots__/removeBlocks.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/removeBlocks/__snapshots__/removeBlocks.test.ts.snap @@ -171,6 +171,11 @@ exports[`Test removeBlocks > Remove multiple consecutive blocks 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -922,6 +927,11 @@ exports[`Test removeBlocks > Remove single block 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap index add46c028..200a384ea 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap @@ -171,6 +171,11 @@ exports[`Test replaceBlocks > Remove multiple consecutive blocks 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -922,6 +927,11 @@ exports[`Test replaceBlocks > Remove single block 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1357,6 +1367,11 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with multiple { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1752,6 +1767,11 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single ba { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2204,6 +2224,11 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single co { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -3730,6 +3755,11 @@ exports[`Test replaceBlocks > Replace single block with multiple 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -4195,6 +4225,11 @@ exports[`Test replaceBlocks > Replace single block with single basic 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -4717,6 +4752,11 @@ exports[`Test replaceBlocks > Replace single block with single complex 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap index d308e0046..492fe3330 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap @@ -275,6 +275,11 @@ exports[`Test splitBlocks > Basic 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -763,6 +768,11 @@ exports[`Test splitBlocks > Block has children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1251,6 +1261,11 @@ exports[`Test splitBlocks > Don't keep props 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1739,6 +1754,11 @@ exports[`Test splitBlocks > Don't keep type 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2221,6 +2241,11 @@ exports[`Test splitBlocks > End of content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2710,6 +2735,11 @@ exports[`Test splitBlocks > Keep type 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap index a069e613a..6138dbc1c 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap @@ -258,6 +258,11 @@ exports[`Test updateBlock > Revert all props 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -729,6 +734,11 @@ exports[`Test updateBlock > Revert single prop 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1200,6 +1210,11 @@ exports[`Test updateBlock > Update all props 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1671,6 +1686,11 @@ exports[`Test updateBlock > Update children 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -1889,6 +1909,7 @@ exports[`Test updateBlock > Update inline content to empty table content 1`] = ` { "children": [], "content": { + "columnWidths": [], "rows": [], "type": "tableContent", }, @@ -2138,6 +2159,11 @@ exports[`Test updateBlock > Update inline content to empty table content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2607,6 +2633,11 @@ exports[`Test updateBlock > Update inline content to no content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -2825,6 +2856,11 @@ exports[`Test updateBlock > Update inline content to table content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -3150,6 +3186,11 @@ exports[`Test updateBlock > Update inline content to table content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -3617,6 +3658,11 @@ exports[`Test updateBlock > Update no content to empty inline content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -4056,6 +4102,7 @@ exports[`Test updateBlock > Update no content to empty table content 1`] = ` { "children": [], "content": { + "columnWidths": [], "rows": [], "type": "tableContent", }, @@ -4086,6 +4133,11 @@ exports[`Test updateBlock > Update no content to empty table content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -4559,6 +4611,11 @@ exports[`Test updateBlock > Update no content to inline content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -4998,6 +5055,11 @@ exports[`Test updateBlock > Update no content to table content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -5104,6 +5166,11 @@ exports[`Test updateBlock > Update no content to table content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -5575,6 +5642,11 @@ exports[`Test updateBlock > Update single prop 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -7235,6 +7307,11 @@ exports[`Test updateBlock > Update type 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -7705,6 +7782,11 @@ exports[`Test updateBlock > Update with plain content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ @@ -8162,6 +8244,11 @@ exports[`Test updateBlock > Update with styled content 1`] = ` { "children": [], "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/clipboard/clipboard.test.ts b/packages/core/src/api/clipboard/clipboard.test.ts index a5b8f71db..416076aa0 100644 --- a/packages/core/src/api/clipboard/clipboard.test.ts +++ b/packages/core/src/api/clipboard/clipboard.test.ts @@ -173,7 +173,7 @@ describe("Test ProseMirror selection clipboard HTML", () => { ) ); - const { clipboardHTML, externalHTML } = await selectedFragmentToHTML( + const { clipboardHTML, externalHTML } = selectedFragmentToHTML( editor._tiptapEditor.view, editor ); diff --git a/packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/external.html b/packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/external.html new file mode 100644 index 000000000..fca273021 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/external.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/internal.html b/packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/internal.html new file mode 100644 index 000000000..7247411a1 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/table/allColWidths/internal.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/table/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/table/basic/external.html new file mode 100644 index 000000000..ce73c75aa --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/table/basic/external.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/table/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/table/basic/internal.html new file mode 100644 index 000000000..f00383b3d --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/table/basic/internal.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/external.html b/packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/external.html new file mode 100644 index 000000000..c7018c749 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/external.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/internal.html b/packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/internal.html new file mode 100644 index 000000000..b2b6765da --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/table/mixedColWidths/internal.html @@ -0,0 +1 @@ +

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

\ No newline at end of file diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/table/allColWidths/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/table/allColWidths/markdown.md new file mode 100644 index 000000000..3e52272fe --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/table/allColWidths/markdown.md @@ -0,0 +1,5 @@ +| | | | +| ---------- | ---------- | ---------- | +| Table Cell | Table Cell | Table Cell | +| Table Cell | Table Cell | Table Cell | +| Table Cell | Table Cell | Table Cell | diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/table/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/table/basic/markdown.md new file mode 100644 index 000000000..3e52272fe --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/table/basic/markdown.md @@ -0,0 +1,5 @@ +| | | | +| ---------- | ---------- | ---------- | +| Table Cell | Table Cell | Table Cell | +| Table Cell | Table Cell | Table Cell | +| Table Cell | Table Cell | Table Cell | diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/table/mixedColWidths/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/table/mixedColWidths/markdown.md new file mode 100644 index 000000000..3e52272fe --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/table/mixedColWidths/markdown.md @@ -0,0 +1,5 @@ +| | | | +| ---------- | ---------- | ---------- | +| Table Cell | Table Cell | Table Cell | +| Table Cell | Table Cell | Table Cell | +| Table Cell | Table Cell | Table Cell | diff --git a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap index fe98c2415..942a525b4 100644 --- a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +++ b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap @@ -1752,3 +1752,645 @@ exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert "type": "blockContainer", } `; + +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert table/allColWidths to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "content": [ + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": [ + 100, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 200, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 300, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": [ + 100, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 200, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 300, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": [ + 100, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 200, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 300, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + ], + "type": "table", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert table/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "content": [ + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + ], + "type": "table", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert table/mixedColWidths to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "content": [ + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": [ + 100, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 300, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": [ + 100, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 300, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + { + "content": [ + { + "attrs": { + "colspan": 1, + "colwidth": [ + 100, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": null, + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + { + "attrs": { + "colspan": 1, + "colwidth": [ + 300, + ], + "rowspan": 1, + }, + "content": [ + { + "content": [ + { + "text": "Table Cell", + "type": "text", + }, + ], + "type": "tableParagraph", + }, + ], + "type": "tableCell", + }, + ], + "type": "tableRow", + }, + ], + "type": "table", + }, + ], + "type": "blockContainer", +} +`; diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index f63ee0997..b4df390c1 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -161,7 +161,8 @@ export function tableContentToNodes< for (const row of tableContent.rows) { const columnNodes: Node[] = []; - for (const cell of row.cells) { + for (let i = 0; i < row.cells.length; i++) { + const cell = row.cells[i]; let pNode: Node; if (!cell) { pNode = schema.nodes["tableParagraph"].create({}); @@ -172,7 +173,17 @@ export function tableContentToNodes< pNode = schema.nodes["tableParagraph"].create({}, textNodes); } - const cellNode = schema.nodes["tableCell"].create({}, pNode); + const cellNode = schema.nodes["tableCell"].create( + { + // The colwidth array should have multiple values when the colspan of + // a cell is greater than 1. However, this is not yet implemented so + // we can always assume a length of 1. + colwidth: tableContent.columnWidths?.[i] + ? [tableContent.columnWidths[i]] + : null, + }, + pNode + ); columnNodes.push(cellNode); } const rowNode = schema.nodes["tableRow"].create({}, columnNodes); diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 55cfa045a..79889cb54 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -30,14 +30,24 @@ export function contentNodeToTableContent< >(contentNode: Node, inlineContentSchema: I, styleSchema: S) { const ret: TableContent = { type: "tableContent", + columnWidths: [], rows: [], }; - contentNode.content.forEach((rowNode) => { + contentNode.content.forEach((rowNode, _offset, index) => { const row: TableContent["rows"][0] = { cells: [], }; + if (index === 0) { + rowNode.content.forEach((cellNode) => { + // The colwidth array should have multiple values when the colspan of a + // cell is greater than 1. However, this is not yet implemented so we + // can always assume a length of 1. + ret.columnWidths.push(cellNode.attrs.colwidth?.[0] || undefined); + }); + } + rowNode.content.forEach((cellNode) => { row.cells.push( contentNodeToInlineContent( diff --git a/packages/core/src/api/parsers/html/__snapshots__/parse-notion-html.json b/packages/core/src/api/parsers/html/__snapshots__/parse-notion-html.json index d79fe0964..8b9a11c50 100644 --- a/packages/core/src/api/parsers/html/__snapshots__/parse-notion-html.json +++ b/packages/core/src/api/parsers/html/__snapshots__/parse-notion-html.json @@ -370,6 +370,11 @@ }, "content": { "type": "tableContent", + "columnWidths": [ + null, + null, + null + ], "rows": [ { "cells": [ diff --git a/packages/core/src/api/testUtil/cases/defaultSchema.ts b/packages/core/src/api/testUtil/cases/defaultSchema.ts index 66a84ab28..da8c790e8 100644 --- a/packages/core/src/api/testUtil/cases/defaultSchema.ts +++ b/packages/core/src/api/testUtil/cases/defaultSchema.ts @@ -332,6 +332,74 @@ export const defaultSchemaTestCases: EditorTestCases< }, ], }, + { + name: "table/basic", + blocks: [ + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + ], + }, + { + name: "table/allColWidths", + blocks: [ + { + type: "table", + content: { + type: "tableContent", + columnWidths: [100, 200, 300], + rows: [ + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + ], + }, + { + name: "table/mixedColWidths", + blocks: [ + { + type: "table", + content: { + type: "tableContent", + columnWidths: [100, undefined, 300], + rows: [ + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + ], + }, { name: "link/basic", blocks: [ diff --git a/packages/core/src/api/testUtil/partialBlockTestUtil.ts b/packages/core/src/api/testUtil/partialBlockTestUtil.ts index fa5eb8b77..efd685ab3 100644 --- a/packages/core/src/api/testUtil/partialBlockTestUtil.ts +++ b/packages/core/src/api/testUtil/partialBlockTestUtil.ts @@ -80,12 +80,19 @@ export function partialBlockToBlockForTesting< schema: BSchema, partialBlock: PartialBlock ): Block { + const contentType: "inline" | "table" | "none" = + schema[partialBlock.type!].content; + const withDefaults: Block = { id: "", type: partialBlock.type!, props: {} as any, content: - schema[partialBlock.type!].content === "inline" ? [] : (undefined as any), + contentType === "inline" + ? [] + : contentType === "table" + ? { type: "tableContent", columnWidths: [], rows: [] } + : (undefined as any), children: [] as any, ...partialBlock, }; @@ -98,6 +105,24 @@ export function partialBlockToBlockForTesting< } ); + if (contentType === "inline") { + const content = withDefaults.content as InlineContent[] | undefined; + withDefaults.content = partialContentToInlineContent(content) as any; + } else if (contentType === "table") { + const content = withDefaults.content as TableContent | undefined; + withDefaults.content = { + type: "tableContent", + columnWidths: + content?.columnWidths || + content?.rows[0]?.cells.map(() => undefined) || + [], + rows: + content?.rows.map((row) => ({ + cells: row.cells.map((cell) => partialContentToInlineContent(cell)), + })) || [], + } as any; + } + return { ...withDefaults, content: partialContentToInlineContent(withDefaults.content), diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts index ac3afa4d5..ca9190297 100644 --- a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts @@ -1,17 +1,22 @@ -import { mergeAttributes, Node } from "@tiptap/core"; +import { Node } from "@tiptap/core"; import { TableCell } from "@tiptap/extension-table-cell"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableRow } from "@tiptap/extension-table-row"; +import { Node as PMNode } from "prosemirror-model"; +import { TableView } from "prosemirror-tables"; + import { createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, } from "../../schema/index.js"; +import { mergeCSSClasses } from "../../util/browser.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; -import { TableExtension } from "./TableExtension.js"; +import { EMPTY_CELL_WIDTH, TableExtension } from "./TableExtension.js"; export const tablePropSchema = { - ...defaultProps, + backgroundColor: defaultProps.backgroundColor, + textColor: defaultProps.textColor, }; export const TableBlockContent = createStronglyTypedTiptapNode({ @@ -37,6 +42,69 @@ export const TableBlockContent = createStronglyTypedTiptapNode({ this.options.domAttributes?.inlineContent || {} ); }, + + // This node view is needed for the `columnResizing` plugin. By default, the + // plugin adds its own node view, which overrides how the node is rendered vs + // `renderHTML`. This means that the wrapping `blockContent` HTML element is + // no longer rendered. The `columnResizing` plugin uses the `TableView` as its + // default node view. `BlockNoteTableView` extends it by wrapping it in a + // `blockContent` element, so the DOM structure is consistent with other block + // types. + addNodeView() { + return ({ node, HTMLAttributes }) => { + class BlockNoteTableView extends TableView { + constructor( + public node: PMNode, + public cellMinWidth: number, + public blockContentHTMLAttributes: Record + ) { + super(node, cellMinWidth); + + const blockContent = document.createElement("div"); + blockContent.className = mergeCSSClasses( + "bn-block-content", + blockContentHTMLAttributes.class + ); + blockContent.setAttribute("data-content-type", "table"); + for (const [attribute, value] of Object.entries( + blockContentHTMLAttributes + )) { + if (attribute !== "class") { + blockContent.setAttribute(attribute, value); + } + } + + const tableWrapper = this.dom; + + const tableWrapperInner = document.createElement("div"); + tableWrapperInner.className = "tableWrapper-inner"; + tableWrapperInner.appendChild(tableWrapper.firstChild!); + + tableWrapper.appendChild(tableWrapperInner); + + blockContent.appendChild(tableWrapper); + const floatingContainer = document.createElement("div"); + floatingContainer.className = "table-widgets-container"; + floatingContainer.style.position = "relative"; + tableWrapper.appendChild(floatingContainer); + + this.dom = blockContent; + } + + ignoreMutation(record: MutationRecord): boolean { + return ( + !(record.target as HTMLElement).closest(".tableWrapper-inner") || + super.ignoreMutation(record) + ); + } + } + + return new BlockNoteTableView(node, EMPTY_CELL_WIDTH, { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }); + }; + }, }); const TableParagraph = Node.create({ @@ -70,11 +138,7 @@ const TableParagraph = Node.create({ }, renderHTML({ HTMLAttributes }) { - return [ - "p", - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), - 0, - ]; + return ["p", HTMLAttributes, 0]; }, }); diff --git a/packages/core/src/blocks/TableBlockContent/TableExtension.ts b/packages/core/src/blocks/TableBlockContent/TableExtension.ts index fb0147d7a..cd197a139 100644 --- a/packages/core/src/blocks/TableBlockContent/TableExtension.ts +++ b/packages/core/src/blocks/TableBlockContent/TableExtension.ts @@ -1,13 +1,22 @@ import { callOrReturn, Extension, getExtensionField } from "@tiptap/core"; import { columnResizing, tableEditing } from "prosemirror-tables"; +export const RESIZE_MIN_WIDTH = 35; +export const EMPTY_CELL_WIDTH = 120; +export const EMPTY_CELL_HEIGHT = 31; + export const TableExtension = Extension.create({ name: "BlockNoteTableExtension", addProseMirrorPlugins: () => { return [ columnResizing({ - cellMinWidth: 100, + cellMinWidth: RESIZE_MIN_WIDTH, + defaultCellMinWidth: EMPTY_CELL_WIDTH, + // We set this to null as we implement our own node view in the table + // block content. This node view is the same as what's used by default, + // but is wrapped in a `blockContent` HTML element. + View: null, }), tableEditing(), ]; diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index f2040b7db..9ee98f1dc 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -104,22 +104,50 @@ Tippy popups that are appended to document.body directly white-space: nowrap; } +/* .tableWrapper { + padding +} */ + +.ProseMirror .tableWrapper { + position: relative; + top: -16px; + left: -16px; + /* padding: 16px; */ + min-width: calc(100% + 16px); + padding-bottom: 16px; + overflow-y: hidden; +} + +.ProseMirror .tableWrapper-inner { + /* position: relative; */ + /* top: -16px; + left: -16px; */ + padding: 16px; +} + /* table related: */ .bn-editor table { width: auto !important; + word-break: break-word; } .bn-editor th, .bn-editor td { - min-width: 1em; border: 1px solid #ddd; padding: 3px 5px; } -.bn-editor .tableWrapper { - margin: 1em 0; -} - .bn-editor th { font-weight: bold; text-align: left; } + +/* tiptap uses colwidth instead of data-colwidth, se we need to adjust this style from prosemirror-tables */ +.ProseMirror td, +.ProseMirror th { + min-width: auto !important; +} +.ProseMirror td:not([colwidth]):not(.column-resize-dragging), +.ProseMirror th:not([colwidth]):not(.column-resize-dragging) { + /* if there's no explicit width set and the column is not being resized, set a default width */ + min-width: var(--default-cell-min-width) !important; +} diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts index 94fd95cf7..4b7519e72 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts @@ -32,7 +32,8 @@ export async function getDefaultEmojiPickerItems< if (!data) { // use a dynamic import to encourage bundle-splitting // and a smaller initial client bundle size - data = import("@emoji-mart/data", { assert: { type: "json" } }) as any; + + data = import("@emoji-mart/data") as any; // load dynamically because emoji-mart doesn't specify type: module and breaks in nodejs emojiMart = await import("emoji-mart"); diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index b72c76b93..af49098a8 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -1,8 +1,9 @@ import { Plugin, PluginKey, PluginView } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; +import { getNodeById } from "../../api/nodeUtil.js"; import { checkBlockIsDefaultType } from "../../blocks/defaultBlockTypeGuards.js"; -import { Block, DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; +import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockFromConfigNoChildren, @@ -20,12 +21,14 @@ export type TableHandlesState< S extends StyleSchema > = { show: boolean; - referencePosCell: DOMRect; + showAddOrRemoveRowsButton: boolean; + showAddOrRemoveColumnsButton: boolean; + referencePosCell: DOMRect | undefined; referencePosTable: DOMRect; block: BlockFromConfigNoChildren; - colIndex: number; - rowIndex: number; + colIndex: number | undefined; + rowIndex: number | undefined; draggingState: | { @@ -34,6 +37,8 @@ export type TableHandlesState< mousePos: number; } | undefined; + + widgetContainer: HTMLElement | undefined; }; function setHiddenDragImage(rootEl: Document | ShadowRoot) { @@ -70,28 +75,42 @@ function getChildIndex(node: Element) { // Finds the DOM element corresponding to the table cell that the target element // is currently in. If the target element is not in a table cell, returns null. -function domCellAround(target: Element | null): Element | null { - while (target && target.nodeName !== "TD" && target.nodeName !== "TH") { - target = - target.classList && target.classList.contains("ProseMirror") - ? null - : (target.parentNode as Element); +function domCellAround(target: Element) { + let currentTarget: Element | undefined = target; + while ( + currentTarget && + currentTarget.nodeName !== "TD" && + currentTarget.nodeName !== "TH" && + !currentTarget.classList.contains("tableWrapper") + ) { + currentTarget = + currentTarget.classList && currentTarget.classList.contains("ProseMirror") + ? undefined + : (currentTarget.parentNode as Element); + } + if (!currentTarget) { + return undefined; } - return target; + return currentTarget?.nodeName === "TD" || currentTarget?.nodeName === "TH" + ? { + type: "cell", + domNode: currentTarget, + tbodyNode: currentTarget.closest("tbody"), + } + : { + type: "wrapper", + domNode: currentTarget, + tbodyNode: currentTarget.querySelector("tbody"), + }; } // Hides elements in the DOMwith the provided class names. -function hideElementsWithClassNames( - classNames: string[], - rootEl: Document | ShadowRoot -) { - classNames.forEach((className) => { - const elementsToHide = rootEl.querySelectorAll(className); - - for (let i = 0; i < elementsToHide.length; i++) { - (elementsToHide[i] as HTMLElement).style.visibility = "hidden"; - } - }); +function hideElements(selector: string, rootEl: Document | ShadowRoot) { + const elementsToHide = rootEl.querySelectorAll(selector); + + for (let i = 0; i < elementsToHide.length; i++) { + (elementsToHide[i] as HTMLElement).style.visibility = "hidden"; + } } export class TableHandlesView< @@ -104,6 +123,7 @@ export class TableHandlesView< public tableId: string | undefined; public tablePos: number | undefined; + public tableElement: HTMLElement | undefined; public menuFrozen = false; @@ -130,25 +150,20 @@ export class TableHandlesView< pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); pmView.dom.addEventListener("mousedown", this.viewMousedownHandler); - pmView.dom.addEventListener("mouseup", this.viewMouseupHandler); + window.addEventListener("mouseup", this.mouseUpHandler); pmView.root.addEventListener( "dragover", this.dragOverHandler as EventListener ); pmView.root.addEventListener("drop", this.dropHandler as EventListener); - - // Setting capture=true ensures that any parent container of the editor that - // gets scrolled will trigger the scroll event. Scroll events do not bubble - // and so won't propagate to the document by default. - pmView.root.addEventListener("scroll", this.scrollHandler, true); } viewMousedownHandler = () => { this.mouseState = "down"; }; - viewMouseupHandler = (event: MouseEvent) => { + mouseUpHandler = (event: MouseEvent) => { this.mouseState = "up"; this.mouseMoveHandler(event); }; @@ -158,100 +173,147 @@ export class TableHandlesView< return; } - if (this.mouseState === "down") { + if (this.mouseState === "selecting") { + return; + } + + if (!(event.target instanceof Element)) { + return; + } + + const target = domCellAround(event.target); + + if ( + target?.type === "cell" && + this.mouseState === "down" && + !this.state?.draggingState + ) { + // hide draghandles when selecting text as they could be in the way of the user this.mouseState = "selecting"; if (this.state?.show) { this.state.show = false; + this.state.showAddOrRemoveRowsButton = false; + this.state.showAddOrRemoveColumnsButton = false; this.emitUpdate(); } - } - - if (this.mouseState === "selecting") { return; } - const target = domCellAround(event.target as HTMLElement); - if (!target || !this.editor.isEditable) { if (this.state?.show) { this.state.show = false; + this.state.showAddOrRemoveRowsButton = false; + this.state.showAddOrRemoveColumnsButton = false; this.emitUpdate(); } return; } - const colIndex = getChildIndex(target); - const rowIndex = getChildIndex(target.parentElement!); - const cellRect = target.getBoundingClientRect(); - const tableRect = - target.parentElement?.parentElement?.getBoundingClientRect(); - - if (!tableRect) { + if (!target.tbodyNode) { return; } - const blockEl = getDraggableBlockFromElement(target, this.pmView); + const tableRect = target.tbodyNode.getBoundingClientRect(); + + const blockEl = getDraggableBlockFromElement(target.domNode, this.pmView); if (!blockEl) { return; } + this.tableElement = blockEl.node; - let tableBlock: Block | undefined = undefined; + let tableBlock: + | BlockFromConfigNoChildren + | undefined; - // Copied from `getBlock`. We don't use `getBlock` since we also need the PM - // node for the table, so we would effectively be doing the same work twice. - this.editor._tiptapEditor.state.doc.descendants((node, pos) => { - if (typeof tableBlock !== "undefined") { - return false; - } - - if (node.type.name !== "blockContainer" || node.attrs.id !== blockEl.id) { - return true; - } - - const block = nodeToBlock( - node, - this.editor.schema.blockSchema, - this.editor.schema.inlineContentSchema, - this.editor.schema.styleSchema, - this.editor.blockCache - ); + const pmNodeInfo = getNodeById( + blockEl.id, + this.editor._tiptapEditor.state.doc + ); - if (checkBlockIsDefaultType("table", block, this.editor)) { - this.tablePos = pos + 1; - tableBlock = block; - } + const block = nodeToBlock( + pmNodeInfo.node, + this.editor.schema.blockSchema, + this.editor.schema.inlineContentSchema, + this.editor.schema.styleSchema, + this.editor.blockCache + ); - return false; - }); + if (checkBlockIsDefaultType("table", block, this.editor)) { + this.tablePos = pmNodeInfo.posBeforeNode + 1; + tableBlock = block; + } if (!tableBlock) { return; } this.tableId = blockEl.id; + const widgetContainer = target.domNode + .closest(".tableWrapper") + ?.querySelector(".table-widgets-container") as HTMLElement; + + if (target?.type === "wrapper") { + // if we're just to the right or below the table, show the extend buttons + // (this is a bit hacky. It would probably be cleaner to render the extend buttons in the Table NodeView instead) + const belowTable = + event.clientY >= tableRect.bottom - 1 && // -1 to account for fractions of pixels in "bottom" + event.clientY < tableRect.bottom + 20; + const toRightOfTable = + event.clientX >= tableRect.right - 1 && + event.clientX < tableRect.right + 20; + + // without this check, we'd also hide draghandles when hovering over them + const hideHandles = + event.clientX > tableRect.right || event.clientY > tableRect.bottom; + + this.state = { + ...this.state!, + show: true, + showAddOrRemoveRowsButton: belowTable, + showAddOrRemoveColumnsButton: toRightOfTable, + referencePosTable: tableRect, + block: tableBlock, + widgetContainer, + colIndex: hideHandles ? undefined : this.state!.colIndex, + rowIndex: hideHandles ? undefined : this.state!.rowIndex, + referencePosCell: hideHandles + ? undefined + : this.state!.referencePosCell, + }; + } else { + const colIndex = getChildIndex(target.domNode); + const rowIndex = getChildIndex(target.domNode.parentElement!); + const cellRect = target.domNode.getBoundingClientRect(); + + if ( + this.state !== undefined && + this.state.show && + this.tableId === blockEl.id && + this.state.rowIndex === rowIndex && + this.state.colIndex === colIndex + ) { + // no update needed + return; + } - if ( - this.state !== undefined && - this.state.show && - this.tableId === blockEl.id && - this.state.rowIndex === rowIndex && - this.state.colIndex === colIndex - ) { - return; + this.state = { + show: true, + showAddOrRemoveColumnsButton: + colIndex === tableBlock.content.rows[0].cells.length - 1, + showAddOrRemoveRowsButton: + rowIndex === tableBlock.content.rows.length - 1, + referencePosTable: tableRect, + + block: tableBlock, + draggingState: undefined, + referencePosCell: cellRect, + colIndex: colIndex, + rowIndex: rowIndex, + + widgetContainer, + }; } - - this.state = { - show: true, - referencePosCell: cellRect, - referencePosTable: tableRect, - - block: tableBlock, - colIndex: colIndex, - rowIndex: rowIndex, - - draggingState: undefined, - }; this.emitUpdate(); return false; @@ -265,12 +327,8 @@ export class TableHandlesView< event.preventDefault(); event.dataTransfer!.dropEffect = "move"; - hideElementsWithClassNames( - [ - "column-resize-handle", - "prosemirror-dropcursor-block", - "prosemirror-dropcursor-inline", - ], + hideElements( + ".prosemirror-dropcursor-block, .prosemirror-dropcursor-inline", this.pmView.root ); @@ -358,25 +416,37 @@ export class TableHandlesView< }; dropHandler = (event: DragEvent) => { + this.mouseState = "up"; if (this.state === undefined || this.state.draggingState === undefined) { return; } + if ( + this.state.rowIndex === undefined || + this.state.colIndex === undefined + ) { + throw new Error( + "Attempted to drop table row or column, but no table block was hovered prior." + ); + } + event.preventDefault(); + const { draggingState, colIndex, rowIndex } = this.state; + const rows = this.state.block.content.rows; - if (this.state.draggingState.draggedCellOrientation === "row") { - const rowToMove = rows[this.state.draggingState.originalIndex]; - rows.splice(this.state.draggingState.originalIndex, 1); - rows.splice(this.state.rowIndex, 0, rowToMove); + if (draggingState.draggedCellOrientation === "row") { + const rowToMove = rows[draggingState.originalIndex]; + rows.splice(draggingState.originalIndex, 1); + rows.splice(rowIndex, 0, rowToMove); } else { const cellsToMove = rows.map( - (row) => row.cells[this.state!.draggingState!.originalIndex] + (row) => row.cells[draggingState.originalIndex] ); rows.forEach((row, rowIndex) => { - row.cells.splice(this.state!.draggingState!.originalIndex, 1); - row.cells.splice(this.state!.colIndex, 0, cellsToMove[rowIndex]); + row.cells.splice(draggingState.originalIndex, 1); + row.cells.splice(colIndex, 0, cellsToMove[rowIndex]); }); } @@ -392,26 +462,45 @@ export class TableHandlesView< // the existing selection out of the block. this.editor.setTextCursorPosition(this.state.block.id); }; + // Updates drag handle positions on table content updates. + update() { + if (!this.state || !this.state.show) { + return; + } - scrollHandler = () => { - if (this.state?.show) { - const tableElement = this.pmView.root.querySelector( - `[data-node-type="blockContainer"][data-id="${this.tableId}"] table` - )!; - const cellElement = tableElement.querySelector( - `tr:nth-child(${this.state.rowIndex + 1}) > td:nth-child(${ - this.state.colIndex + 1 - })` - )!; - - this.state.referencePosTable = tableElement.getBoundingClientRect(); - this.state.referencePosCell = cellElement.getBoundingClientRect(); - this.emitUpdate(); + const tableBody = this.tableElement!.querySelector("tbody"); + if (!tableBody) { + return; + } + + if ( + this.state.rowIndex !== undefined && + this.state.colIndex !== undefined + ) { + // If rows or columns are deleted in the update, the hovered indices for + // those may now be out of bounds. If this is the case, they are moved to + // the new last row or column. + if (this.state.rowIndex >= tableBody.children.length) { + this.state.rowIndex = tableBody.children.length - 1; + } + if (this.state.colIndex >= tableBody.children[0].children.length) { + this.state.colIndex = tableBody.children[0].children.length - 1; + } + + const row = tableBody.children[this.state.rowIndex]; + const cell = row.children[this.state.colIndex]; + this.state.referencePosCell = cell.getBoundingClientRect(); } - }; + + this.state.block = this.editor.getBlock(this.state.block.id)!; + this.state.referencePosTable = tableBody.getBoundingClientRect(); + this.emitUpdate(); + } destroy() { this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler); + window.removeEventListener("mouseup", this.mouseUpHandler); + this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler); this.pmView.root.removeEventListener( "dragover", this.dragOverHandler as EventListener @@ -420,7 +509,6 @@ export class TableHandlesView< "drop", this.dropHandler as EventListener ); - this.pmView.root.removeEventListener("scroll", this.scrollHandler, true); } } @@ -467,6 +555,10 @@ export class TableHandlesProsemirrorPlugin< ? this.view.state.rowIndex : this.view.state.colIndex; + if (newIndex === undefined) { + return; + } + const decorations: Decoration[] = []; if (newIndex === this.view.state.draggingState.originalIndex) { @@ -547,6 +639,7 @@ export class TableHandlesProsemirrorPlugin< (newIndex > this.view.state.draggingState.originalIndex ? cellNode.nodeSize - 2 : 0); + decorations.push( // The widget is a small bar which spans the height of the cell. Decoration.widget(decorationPos, () => { @@ -592,7 +685,10 @@ export class TableHandlesProsemirrorPlugin< dataTransfer: DataTransfer | null; clientX: number; }) => { - if (this.view!.state === undefined) { + if ( + this.view!.state === undefined || + this.view!.state.colIndex === undefined + ) { throw new Error( "Attempted to drag table column, but no table block was hovered prior." ); @@ -628,7 +724,10 @@ export class TableHandlesProsemirrorPlugin< dataTransfer: DataTransfer | null; clientY: number; }) => { - if (this.view!.state === undefined) { + if ( + this.view!.state === undefined || + this.view!.state.rowIndex === undefined + ) { throw new Error( "Attempted to drag table row, but no table block was hovered prior." ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae98dff89..cae4aefe2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,10 @@ export * from "./blocks/FileBlockContent/FileBlockContent.js"; export * from "./blocks/FileBlockContent/fileBlockHelpers.js"; export * from "./blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.js"; export * from "./blocks/ImageBlockContent/ImageBlockContent.js"; +export { + EMPTY_CELL_WIDTH, + EMPTY_CELL_HEIGHT, +} from "./blocks/TableBlockContent/TableExtension.js"; export { parseImageElement } from "./blocks/ImageBlockContent/imageBlockHelpers.js"; export * from "./blocks/VideoBlockContent/VideoBlockContent.js"; export * from "./blocks/defaultBlockHelpers.js"; diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 7fc70ea88..a5e20ecdd 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -149,6 +149,7 @@ export type TableContent< S extends StyleSchema = StyleSchema > = { type: "tableContent"; + columnWidths: (number | undefined)[]; rows: { cells: InlineContent[][]; }[]; @@ -224,6 +225,7 @@ export type PartialTableContent< S extends StyleSchema = StyleSchema > = { type: "tableContent"; + columnWidths?: (number | undefined)[]; rows: { cells: PartialInlineContent[]; }[]; diff --git a/packages/mantine/src/index.tsx b/packages/mantine/src/index.tsx index 3a3799eae..3941d640d 100644 --- a/packages/mantine/src/index.tsx +++ b/packages/mantine/src/index.tsx @@ -47,6 +47,7 @@ import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel.js"; import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader.js"; import { TableHandle } from "./tableHandle/TableHandle.js"; +import { ExtendButton } from "./tableHandle/ExtendButton.js"; import { Toolbar } from "./toolbar/Toolbar.js"; import { ToolbarButton } from "./toolbar/ToolbarButton.js"; import { ToolbarSelect } from "./toolbar/ToolbarSelect.js"; @@ -90,6 +91,7 @@ export const components: Components = { }, TableHandle: { Root: TableHandle, + ExtendButton: ExtendButton, }, Generic: { Form: { diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css index ee8b2e60f..55b5301fb 100644 --- a/packages/mantine/src/style.css +++ b/packages/mantine/src/style.css @@ -396,7 +396,7 @@ } .bn-mantine .bn-grid-suggestion-menu-empty-item, -.bn-mantine .bn-grid-suggestion-menu-loader{ +.bn-mantine .bn-grid-suggestion-menu-loader { align-items: center; color: var(--bn-colors-menu-text); display: flex; @@ -488,7 +488,8 @@ } /* Table Handle styling */ -.bn-mantine .bn-table-handle { +.bn-mantine .bn-table-handle, +.bn-mantine .bn-extend-button { align-items: center; background-color: var(--bn-colors-menu-background); border: var(--bn-border); @@ -508,10 +509,26 @@ } .bn-mantine .bn-table-handle:hover, -.bn-mantine .bn-table-handle-dragging { +.bn-mantine .bn-table-handle-dragging, +.bn-mantine .bn-extend-button:hover, +.bn-mantine .bn-extend-button-editing { background-color: var(--bn-colors-hovered-background); } +.bn-mantine .bn-extend-button-add-remove-columns { + height: 100%; + width: 18px; + margin-left: 4px; + cursor: col-resize; +} + +.bn-mantine .bn-extend-button-add-remove-rows { + height: 18px; + width: 100%; + margin-top: 4px; + cursor: row-resize; +} + /* Drag Handle & Table Handle Menu styling */ .bn-mantine .bn-drag-handle-menu { overflow: visible; diff --git a/packages/mantine/src/tableHandle/ExtendButton.tsx b/packages/mantine/src/tableHandle/ExtendButton.tsx new file mode 100644 index 000000000..a145106ae --- /dev/null +++ b/packages/mantine/src/tableHandle/ExtendButton.tsx @@ -0,0 +1,27 @@ +import { Button as MantineButton } from "@mantine/core"; + +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const ExtendButton = forwardRef< + HTMLButtonElement, + ComponentProps["TableHandle"]["ExtendButton"] +>((props, ref) => { + const { children, className, onMouseDown, onClick, ...rest } = props; + + // false, because rest props can be added by mantine when button is used as a trigger + // assertEmpty in this case is only used at typescript level, not runtime level + assertEmpty(rest, false); + + return ( + + {children} + + ); +}); diff --git a/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx b/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx new file mode 100644 index 000000000..60aaaeabe --- /dev/null +++ b/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx @@ -0,0 +1,304 @@ +import { + DefaultInlineContentSchema, + DefaultStyleSchema, + EMPTY_CELL_HEIGHT, + EMPTY_CELL_WIDTH, + InlineContentSchema, + mergeCSSClasses, + PartialTableContent, + StyleSchema, +} from "@blocknote/core"; +import { + MouseEvent as ReactMouseEvent, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { RiAddFill } from "react-icons/ri"; + +import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { ExtendButtonProps } from "./ExtendButtonProps.js"; + +function cropEmptyRowsOrColumns< + I extends InlineContentSchema, + S extends StyleSchema +>( + content: PartialTableContent, + removeEmpty: "columns" | "rows" +): PartialTableContent { + let emptyColsOnRight = 0; + + if (removeEmpty === "columns") { + // strips empty columns to the right and empty rows at the bottom + for (let i = content.rows[0].cells.length - 1; i >= 0; i--) { + const isEmpty = content.rows.every((row) => row.cells[i].length === 0); + if (!isEmpty) { + break; + } + + emptyColsOnRight++; + } + } + + const rows: PartialTableContent["rows"] = []; + for (let i = content.rows.length - 1; i >= 0; i--) { + if (removeEmpty === "rows") { + if ( + rows.length === 0 && + content.rows[i].cells.every((cell) => cell.length === 0) + ) { + // empty row at bottom + continue; + } + } + + rows.unshift({ + cells: content.rows[i].cells.slice( + 0, + content.rows[0].cells.length - emptyColsOnRight + ), + }); + } + + return { + ...content, + rows, + }; +} +// Rounds a number up or down, depending on whether we're close (as defined by +// `margin`) to the next integer. +const marginRound = (num: number, margin = 0.3) => { + const lowerBound = Math.floor(num) + margin; + const upperBound = Math.ceil(num) - margin; + + if (num >= lowerBound && num <= upperBound) { + return Math.round(num); + } else if (num < lowerBound) { + return Math.floor(num); + } else { + return Math.ceil(num); + } +}; + +const getContentWithAddedRows = < + I extends InlineContentSchema, + S extends StyleSchema +>( + content: PartialTableContent, + rowsToAdd: number, + numCols: number +): PartialTableContent => { + const newRow: PartialTableContent["rows"][number] = { + cells: Array(numCols).fill([]), + }; + + const newRows: PartialTableContent["rows"] = []; + for (let i = 0; i < rowsToAdd; i++) { + newRows.push(newRow); + } + return { + type: "tableContent", + columnWidths: content.columnWidths, + rows: [...content.rows, ...newRows], + }; +}; + +const getContentWithAddedCols = < + I extends InlineContentSchema, + S extends StyleSchema +>( + content: PartialTableContent, + colsToAdd: number +): PartialTableContent => { + const newCell: PartialTableContent["rows"][number]["cells"][number] = + []; + const newCells: PartialTableContent["rows"][number]["cells"] = []; + for (let i = 0; i < colsToAdd; i++) { + newCells.push(newCell); + } + + return { + type: "tableContent", + columnWidths: content.columnWidths + ? [...content.columnWidths, ...newCells.map(() => undefined)] + : undefined, + rows: content.rows.map((row) => ({ + cells: [...row.cells, ...newCells], + })), + }; +}; + +export const ExtendButton = < + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>( + props: ExtendButtonProps & { children?: ReactNode } +) => { + const Components = useComponentsContext()!; + + // needs to be a ref because it's used immediately in the onClick handler + // (state would be async and only have effect after the next render + const movedMouse = useRef(false); + + const [editingState, setEditingState] = useState< + | { + originalContent: PartialTableContent; + originalCroppedContent: PartialTableContent; + startPos: number; + } + | undefined + >(); + + // Lets the user start extending columns/rows by moving the mouse. + const mouseDownHandler = useCallback( + (event: ReactMouseEvent) => { + props.onMouseDown(); + setEditingState({ + originalContent: props.block.content, + originalCroppedContent: cropEmptyRowsOrColumns( + props.block.content, + props.orientation === "addOrRemoveColumns" ? "columns" : "rows" + ), + startPos: + props.orientation === "addOrRemoveColumns" + ? event.clientX + : event.clientY, + }); + movedMouse.current = false; + + // preventdefault, otherwise text in the table might be selected + event.preventDefault(); + }, + [props] + ); + + const onClickHandler = useCallback(() => { + if (movedMouse.current) { + return; + } + props.editor.updateBlock(props.block, { + type: "table", + content: + props.orientation === "addOrRemoveColumns" + ? getContentWithAddedCols(props.block.content, 1) + : getContentWithAddedRows( + props.block.content, + 1, + props.block.content.rows[0].cells.length + ), + }); + }, [props.block, props.orientation, props.editor]); + + // Extends columns/rows on when moving the mouse. + useEffect(() => { + const callback = (event: MouseEvent) => { + // console.log("callback", event); + if (!editingState) { + throw new Error("editingState is undefined"); + } + + movedMouse.current = true; + + const diff = + (props.orientation === "addOrRemoveColumns" + ? event.clientX + : event.clientY) - editingState.startPos; + + const numCroppedCells = + props.orientation === "addOrRemoveColumns" + ? editingState.originalCroppedContent.rows[0]?.cells.length ?? 0 + : editingState.originalCroppedContent.rows.length; + + const numOriginalCells = + props.orientation === "addOrRemoveColumns" + ? editingState.originalContent.rows[0]?.cells.length ?? 0 + : editingState.originalContent.rows.length; + + const currentNumCells = + props.orientation === "addOrRemoveColumns" + ? props.block.content.rows[0].cells.length + : props.block.content.rows.length; + + const newNumCells = + numOriginalCells + + marginRound( + diff / + (props.orientation === "addOrRemoveColumns" + ? EMPTY_CELL_WIDTH + : EMPTY_CELL_HEIGHT), + 0.3 + ); + + if ( + newNumCells >= numCroppedCells && + newNumCells > 0 && + newNumCells !== currentNumCells + ) { + props.editor.updateBlock(props.block, { + type: "table", + content: + props.orientation === "addOrRemoveColumns" + ? getContentWithAddedCols( + editingState.originalCroppedContent, + newNumCells - numCroppedCells + ) + : getContentWithAddedRows( + editingState.originalCroppedContent, + newNumCells - numCroppedCells, + editingState.originalContent.rows[0].cells.length + ), + }); + + // Edge case for updating block content as `updateBlock` causes the + // selection to move into the next block, so we have to set it back. + if (props.block.content) { + props.editor.setTextCursorPosition(props.block); + } + } + }; + + if (editingState) { + window.addEventListener("mousemove", callback); + } + return () => { + window.removeEventListener("mousemove", callback); + }; + }, [editingState, props.block, props.editor, props.orientation]); + + // Stops mouse movements from extending columns/rows when the mouse is + // released. Also extends columns/rows by 1 if the mouse wasn't moved enough + // to add any, imitating a click. + useEffect(() => { + const onMouseUp = props.onMouseUp; + + const callback = () => { + setEditingState(undefined); + onMouseUp(); + }; + + if (editingState) { + window.addEventListener("mouseup", callback); + } + + return () => { + window.removeEventListener("mouseup", callback); + }; + }, [editingState, props.onMouseUp]); + + return ( + + {props.children || } + + ); +}; diff --git a/packages/react/src/components/TableHandles/ExtendButton/ExtendButtonProps.ts b/packages/react/src/components/TableHandles/ExtendButton/ExtendButtonProps.ts new file mode 100644 index 000000000..cc473c289 --- /dev/null +++ b/packages/react/src/components/TableHandles/ExtendButton/ExtendButtonProps.ts @@ -0,0 +1,25 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, + TableHandlesState, +} from "@blocknote/core"; + +export type ExtendButtonProps< + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = { + editor: BlockNoteEditor< + { + table: DefaultBlockSchema["table"]; + }, + I, + S + >; + onMouseDown: () => void; + onMouseUp: () => void; + orientation: "addOrRemoveRows" | "addOrRemoveColumns"; +} & Pick, "block">; diff --git a/packages/react/src/components/TableHandles/TableHandle.tsx b/packages/react/src/components/TableHandles/TableHandle.tsx index ef1599f83..451de4b84 100644 --- a/packages/react/src/components/TableHandles/TableHandle.tsx +++ b/packages/react/src/components/TableHandles/TableHandle.tsx @@ -7,6 +7,7 @@ import { } from "@blocknote/core"; import { ReactNode, useState } from "react"; +import { createPortal } from "react-dom"; import { MdDragIndicator } from "react-icons/md"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { TableHandleMenu } from "./TableHandleMenu/TableHandleMenu.js"; @@ -66,11 +67,15 @@ export const TableHandle = < )} - + {/* the menu can extend outside of the table, so we use a portal to prevent clipping */} + {createPortal( + , + props.menuContainer + )} ); }; diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx index 5f86a6839..e90289647 100644 --- a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -3,8 +3,8 @@ import { DefaultInlineContentSchema, DefaultStyleSchema, InlineContentSchema, + PartialTableContent, StyleSchema, - TableContent, } from "@blocknote/core"; import { useComponentsContext } from "../../../../editor/ComponentsContext.js"; @@ -42,6 +42,7 @@ export const AddRowButton = < type: "table", content: { type: "tableContent", + columnWidths: props.block.content.columnWidths, rows, }, }); @@ -73,8 +74,15 @@ export const AddColumnButton = < return ( { - const content: TableContent = { + const columnWidths = [...props.block.content.columnWidths]; + columnWidths.splice( + props.index + (props.side === "right" ? 1 : 0), + 0, + undefined + ); + const content: PartialTableContent = { type: "tableContent", + columnWidths, rows: props.block.content.rows.map((row) => { const cells = [...row.cells]; cells.splice(props.index + (props.side === "right" ? 1 : 0), 0, []); diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx index f5bd9ae7c..dc71823af 100644 --- a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -3,8 +3,8 @@ import { DefaultInlineContentSchema, DefaultStyleSchema, InlineContentSchema, + PartialTableContent, StyleSchema, - TableContent, } from "@blocknote/core"; import { useComponentsContext } from "../../../../editor/ComponentsContext.js"; @@ -29,8 +29,9 @@ export const DeleteRowButton = < return ( { - const content: TableContent = { + const content: PartialTableContent = { type: "tableContent", + columnWidths: props.block.content.columnWidths, rows: props.block.content.rows.filter( (_, index) => index !== props.index ), @@ -68,8 +69,11 @@ export const DeleteColumnButton = < return ( { - const content: TableContent = { + const content: PartialTableContent = { type: "tableContent", + columnWidths: props.block.content.columnWidths.filter( + (_, index) => index !== props.index + ), rows: props.block.content.rows.map((row) => ({ cells: row.cells.filter((_, index) => index !== props.index), })), diff --git a/packages/react/src/components/TableHandles/TableHandleProps.ts b/packages/react/src/components/TableHandles/TableHandleProps.ts index e961ddb53..c7c2272df 100644 --- a/packages/react/src/components/TableHandles/TableHandleProps.ts +++ b/packages/react/src/components/TableHandles/TableHandleProps.ts @@ -29,6 +29,7 @@ export type TableHandleProps< dragStart: (e: DragEvent) => void; showOtherSide: () => void; hideOtherSide: () => void; + menuContainer: HTMLDivElement; tableHandleMenu?: FC< DragHandleMenuProps< { diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index fac165485..31ccecf4d 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -5,12 +5,16 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { FC, useMemo, useState } from "react"; +import { FC, useCallback, useMemo, useState } from "react"; +import { FloatingPortal } from "@floating-ui/react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useUIPluginState } from "../../hooks/useUIPluginState.js"; +import { ExtendButton } from "./ExtendButton/ExtendButton.js"; +import { ExtendButtonProps } from "./ExtendButton/ExtendButtonProps.js"; import { TableHandle } from "./TableHandle.js"; import { TableHandleProps } from "./TableHandleProps.js"; +import { useExtendButtonsPositioning } from "./hooks/useExtendButtonsPositioning.js"; import { useTableHandlesPositioning } from "./hooks/useTableHandlesPositioning.js"; export const TableHandlesController = < @@ -18,9 +22,13 @@ export const TableHandlesController = < S extends StyleSchema = DefaultStyleSchema >(props: { tableHandle?: FC>; + extendButton?: FC>; }) => { const editor = useBlockNoteEditor(); + const [menuContainerRef, setMenuContainerRef] = + useState(null); + if (!editor.tableHandles) { throw new Error( "TableHandlesController can only be used when BlockNote editor schema contains table block" @@ -35,6 +43,20 @@ export const TableHandlesController = < unfreezeHandles: editor.tableHandles.unfreezeHandles, }; + const { freezeHandles, unfreezeHandles } = callbacks; + + const onStartExtend = useCallback(() => { + freezeHandles(); + setHideCol(true); + setHideRow(true); + }, [freezeHandles]); + + const onEndExtend = useCallback(() => { + unfreezeHandles(); + setHideCol(false); + setHideRow(false); + }, [unfreezeHandles]); + const state = useUIPluginState( editor.tableHandles.onUpdate.bind(editor.tableHandles) ); @@ -60,50 +82,97 @@ export const TableHandlesController = < draggingState ); + const { addOrRemoveColumnsButton, addOrRemoveRowsButton } = + useExtendButtonsPositioning( + state?.showAddOrRemoveColumnsButton || false, + state?.showAddOrRemoveRowsButton || false, + state?.referencePosTable || null + ); + const [hideRow, setHideRow] = useState(false); const [hideCol, setHideCol] = useState(false); - if (!rowHandle.isMounted || !colHandle.isMounted || !state) { + if (!state) { return null; } - const Component = props.tableHandle || TableHandle; + const TableHandleComponent = props.tableHandle || TableHandle; + const ExtendButtonComponent = props.extendButton || ExtendButton; return ( <> - {!hideRow && ( -
-
+ {/* we want to make sure the elements are clipped by the .tableWrapper element (so that we scroll the table, widgets also dissappear) + we do this by rendering in a portal into the table's widget container (defined in TableBlockContent.ts) + */} + + {!hideRow && + menuContainerRef && + rowHandle.isMounted && + state.rowIndex !== undefined && ( +
+ setHideCol(false)} + hideOtherSide={() => setHideCol(true)} + index={state.rowIndex} + block={state.block} + dragStart={callbacks.rowDragStart} + dragEnd={callbacks.dragEnd} + freezeHandles={callbacks.freezeHandles} + unfreezeHandles={callbacks.unfreezeHandles} + menuContainer={menuContainerRef} + /> +
+ )} + {!hideCol && + menuContainerRef && + colHandle.isMounted && + state.colIndex !== undefined && ( +
+ setHideRow(false)} + hideOtherSide={() => setHideRow(true)} + index={state.colIndex} + block={state.block} + dragStart={callbacks.colDragStart} + dragEnd={callbacks.dragEnd} + freezeHandles={callbacks.freezeHandles} + unfreezeHandles={callbacks.unfreezeHandles} + menuContainer={menuContainerRef} + /> +
+ )} + + {/* note that the extend buttons are always shown (we don't look at isMounted etc, + because otherwise the table slightly shifts when they unmount */} +
+ setHideCol(false)} - hideOtherSide={() => setHideCol(true)} - index={state.rowIndex} + orientation={"addOrRemoveRows"} block={state.block} - dragStart={callbacks.rowDragStart} - dragEnd={callbacks.dragEnd} - freezeHandles={callbacks.freezeHandles} - unfreezeHandles={callbacks.unfreezeHandles} + onMouseDown={onStartExtend} + onMouseUp={onEndExtend} />
- )} - {!hideCol && ( -
- + setHideRow(false)} - hideOtherSide={() => setHideRow(true)} - index={state.colIndex} + orientation={"addOrRemoveColumns"} block={state.block} - dragStart={callbacks.colDragStart} - dragEnd={callbacks.dragEnd} - freezeHandles={callbacks.freezeHandles} - unfreezeHandles={callbacks.unfreezeHandles} + onMouseDown={onStartExtend} + onMouseUp={onEndExtend} />
- )} +
); }; diff --git a/packages/react/src/components/TableHandles/hooks/useExtendButtonsPositioning.ts b/packages/react/src/components/TableHandles/hooks/useExtendButtonsPositioning.ts new file mode 100644 index 000000000..f102b6c64 --- /dev/null +++ b/packages/react/src/components/TableHandles/hooks/useExtendButtonsPositioning.ts @@ -0,0 +1,87 @@ +import { size, useFloating, useTransitionStyles } from "@floating-ui/react"; +import { useEffect, useMemo } from "react"; + +function useExtendButtonPosition( + orientation: "addOrRemoveRows" | "addOrRemoveColumns", + show: boolean, + referencePosTable: DOMRect | null +) { + const { refs, update, context, floatingStyles } = useFloating({ + open: show, + placement: orientation === "addOrRemoveColumns" ? "right" : "bottom", + middleware: [ + size({ + apply({ rects, elements }) { + Object.assign( + elements.floating.style, + orientation === "addOrRemoveColumns" + ? { + height: `${rects.reference.height}px`, + } + : { + width: `${rects.reference.width}px`, + } + ); + }, + }), + ], + }); + + const { isMounted, styles } = useTransitionStyles(context); + + useEffect(() => { + update(); + }, [referencePosTable, update]); + + useEffect(() => { + // Will be null on initial render when used in UI component controllers. + if (referencePosTable === null) { + return; + } + + refs.setReference({ + getBoundingClientRect: () => referencePosTable, + }); + }, [orientation, referencePosTable, refs]); + + return useMemo( + () => ({ + isMounted: isMounted, + ref: refs.setFloating, + style: { + display: "flex", + ...styles, + ...floatingStyles, + }, + }), + [floatingStyles, isMounted, refs.setFloating, styles] + ); +} + +export function useExtendButtonsPositioning( + showAddOrRemoveColumnsButton: boolean, + showAddOrRemoveRowsButton: boolean, + referencePosTable: DOMRect | null +): { + addOrRemoveRowsButton: ReturnType; + addOrRemoveColumnsButton: ReturnType; +} { + const addOrRemoveRowsButton = useExtendButtonPosition( + "addOrRemoveRows", + showAddOrRemoveRowsButton, + referencePosTable + ); + const addOrRemoveColumnsButton = useExtendButtonPosition( + "addOrRemoveColumns", + showAddOrRemoveColumnsButton, + referencePosTable + ); + + return useMemo( + () => ({ + addOrRemoveRowsButton, + addOrRemoveColumnsButton, + }), + [addOrRemoveColumnsButton, addOrRemoveRowsButton] + ); +} diff --git a/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts b/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts index 4bd7083fd..44cea9116 100644 --- a/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts +++ b/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts @@ -98,7 +98,6 @@ function useTableHandlePosition( display: "flex", ...styles, ...floatingStyles, - zIndex: 10000, }, }), [floatingStyles, isMounted, refs.setFloating, styles] diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 735d36f29..123264a19 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -183,6 +183,12 @@ export type ComponentProps = { | { children: ReactNode; label?: string } | { children?: undefined; label: string } ); + ExtendButton: { + className?: string; + onClick: (e: React.MouseEvent) => void; + onMouseDown: (e: React.MouseEvent) => void; + children: ReactNode; + }; }; // TODO: We should try to make everything as generic as we can Generic: { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9e343458f..713325abf 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -70,6 +70,9 @@ export * from "./components/FilePanel/FilePanelProps.js"; export * from "./components/TableHandles/TableHandle.js"; export * from "./components/TableHandles/TableHandleProps.js"; export * from "./components/TableHandles/TableHandlesController.js"; +export * from "./components/TableHandles/ExtendButton/ExtendButton.js"; +export * from "./components/TableHandles/ExtendButton/ExtendButtonProps.js"; +export * from "./components/TableHandles/hooks/useExtendButtonsPositioning.js"; export * from "./components/TableHandles/hooks/useTableHandlesPositioning.js"; export * from "./components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.js"; diff --git a/packages/shadcn/src/index.tsx b/packages/shadcn/src/index.tsx index a799d0c75..f5dc69eec 100644 --- a/packages/shadcn/src/index.tsx +++ b/packages/shadcn/src/index.tsx @@ -40,6 +40,7 @@ import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel.js"; import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader.js"; import { TableHandle } from "./tableHandle/TableHandle.js"; +import { ExtendButton } from "./tableHandle/ExtendButton.js"; import { Toolbar, ToolbarButton, ToolbarSelect } from "./toolbar/Toolbar.js"; import { PanelButton } from "./panel/PanelButton.js"; @@ -84,6 +85,7 @@ export const components: Components = { }, TableHandle: { Root: TableHandle, + ExtendButton: ExtendButton, }, Generic: { Form: { diff --git a/packages/shadcn/src/style.css b/packages/shadcn/src/style.css index bd507e628..23271f05f 100644 --- a/packages/shadcn/src/style.css +++ b/packages/shadcn/src/style.css @@ -147,7 +147,7 @@ } .bn-shadcn .bn-grid-suggestion-menu-empty-item, -.bn-shadcn .bn-grid-suggestion-menu-loader{ +.bn-shadcn .bn-grid-suggestion-menu-loader { align-items: center; color: var(--bn-colors-menu-text); display: flex; @@ -160,3 +160,11 @@ .bn-shadcn .bn-grid-suggestion-menu-loader span { background-color: hsl(var(--accent)); } + +.bn-shadcn .bn-extend-button-add-remove-columns { + cursor: col-resize; +} + +.bn-shadcn .bn-extend-button-add-remove-rows { + cursor: row-resize; +} diff --git a/packages/shadcn/src/tableHandle/ExtendButton.tsx b/packages/shadcn/src/tableHandle/ExtendButton.tsx new file mode 100644 index 000000000..76b9eca6a --- /dev/null +++ b/packages/shadcn/src/tableHandle/ExtendButton.tsx @@ -0,0 +1,40 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +import { cn } from "../lib/utils.js"; +import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; + +export const ExtendButton = forwardRef< + HTMLButtonElement, + ComponentProps["TableHandle"]["ExtendButton"] +>((props, ref) => { + const { className, children, onMouseDown, onClick, ...rest } = props; + + // false, because rest props can be added by shadcn when button is used as a trigger + // assertEmpty in this case is only used at typescript level, not runtime level + assertEmpty(rest, false); + + const ShadCNComponents = useShadCNComponentsContext()!; + + return ( + + {children} + + ); +}); diff --git a/playground/src/main.tsx b/playground/src/main.tsx index 653e7c868..14271f4d6 100644 --- a/playground/src/main.tsx +++ b/playground/src/main.tsx @@ -96,9 +96,9 @@ const App = (props: { project: (typeof examples.basic)["projects"][0] }) => { React.useEffect(() => { (async () => { // load app async - const c: any = await modules[ - "../../" + props.project.pathFromRoot + "/App.tsx" - ](); + const moduleName = "../../" + props.project.pathFromRoot + "/App.tsx"; + const module = modules[moduleName]; + const c: any = await module(); setExampleComponent(c); })(); }, [props.project.pathFromRoot]);