Repository: Thinkmill/keystatic
Pull request #156 (2023-03-09 05:56:17)

Tables
This adds tables to the document field:
- Markdoc supports both GFM-style tables and a different Markdoc specific syntax, both the GFM syntax and Markdoc syntax will be parsed into tables but when serialized, they'll always be written in the Markdoc specific syntax because it's imo a better syntax and it can support colspan/rowspan (though those are currently not supported in the editor)
- Tables can have a header row, not a header column though
- There's a dropdown in whatever cell the focus of the selection is in which lets you add more columns, rows, delete column and rows and enable/disable the header row
- The cells can be selected like you'd expect and they can be copied, cut, pasted, deleted as expected

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 15013c306f1572f9dfce0b6fdfdc24ad4f35f71d

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 6 packages</summary>

  | Name                           | Type  |
| ------------------------------ | ----- |
| @keystatic/core                | Patch |
| @keystatic/test-next-app       | Patch |
| @keystatic/test-astro          | Patch |
| localization                   | Patch |
| @keystatic/test-next-pages-dir | Patch |
| @keystatic/remix-test-app      | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Thinkmill/keystatic/new/tables?filename=.changeset/tough-plants-double.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0ATables%0A)


vercel[bot]: [vc]: #5QW/HC808Dd7Wykndc4qzoxyevPoHoF0tXi/w9yPBTU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5Ijoia2V5c3RhdGljIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC9rZXlzdGF0aWMvOG82M2VUazJjUlE5dDgxbUFuem1wd2pRbmdCayIsInByZXZpZXdVcmwiOiJrZXlzdGF0aWMtZ2l0LXRhYmxlcy10aGlua21pbGwudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6InZvdXNzb2lyIiwicm9vdERpcmVjdG9yeSI6ImRlc2lnbi1zeXN0ZW0vZG9jcyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90aGlua21pbGwvdm91c3NvaXIvMlZETXhnYndaUjFScDlIcXk5OGVNcU1rQVkzSCIsInByZXZpZXdVcmwiOiJ2b3Vzc29pci1naXQtdGFibGVzLXRoaW5rbWlsbC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated |
| :--- | :----- | :------ | :------ |
| **keystatic** | ✅ Ready ([Inspect](https://vercel.com/thinkmill/keystatic/8o63eTk2cRQ9t81mAnzmpwjQngBk)) | [Visit Preview](https://keystatic-git-tables-thinkmill.vercel.app) | Mar 9, 2023 at 5:56AM (UTC) |
| **voussoir** | ✅ Ready ([Inspect](https://vercel.com/thinkmill/voussoir/2VDMxgbwZR1Rp9Hqy98eMqMkAY3H)) | [Visit Preview](https://voussoir-git-tables-thinkmill.vercel.app) | Mar 9, 2023 at 5:56AM (UTC) |




Patch:
diff --git a/.changeset/tough-pots-raise.md b/.changeset/tough-pots-raise.md
new file mode 100644
index 00000000..e93b07d3
--- /dev/null
+++ b/.changeset/tough-pots-raise.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Add `table` block
diff --git a/keystatic/local-config.tsx b/keystatic/local-config.tsx
index ef42a314..6f93a3a7 100644
--- a/keystatic/local-config.tsx
+++ b/keystatic/local-config.tsx
@@ -50,6 +50,7 @@ export default config({
           formatting: true,
           dividers: true,
           links: true,
+          tables: true,
           images: {
             directory: 'public/images/posts',
             publicPath: '/images/posts/',
diff --git a/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts b/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts
index 4ba04bd2..0af3c241 100644
--- a/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts
+++ b/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts
@@ -4,6 +4,7 @@ import { ComponentBlock } from './component-blocks/api';
 import { insertDivider } from './divider';
 import { DocumentFeaturesForNormalization } from './document-features-normalization';
 import { getAncestorComponentChildFieldDocumentFeatures } from './toolbar-state';
+import { isBlock } from '.';
 
 export function withBlockMarkdownShortcuts(
   documentFeatures: DocumentFeatures,
@@ -59,7 +60,7 @@ export function withBlockMarkdownShortcuts(
       Transforms.wrapNodes(
         editor,
         { type: 'ordered-list', children: [] },
-        { match: n => Editor.isBlock(editor, n) }
+        { match: isBlock }
       );
     },
     features => features.formatting.listTypes.ordered
@@ -71,7 +72,7 @@ export function withBlockMarkdownShortcuts(
       Transforms.wrapNodes(
         editor,
         { type: 'unordered-list', children: [] },
-        { match: n => Editor.isBlock(editor, n) }
+        { match: isBlock }
       );
     },
     features => features.formatting.listTypes.unordered
@@ -82,7 +83,7 @@ export function withBlockMarkdownShortcuts(
       Transforms.wrapNodes(
         editor,
         { type: 'unordered-list', children: [] },
-        { match: n => Editor.isBlock(editor, n) }
+        { match: isBlock }
       );
     },
     features => features.formatting.listTypes.unordered
@@ -147,7 +148,7 @@ export function withBlockMarkdownShortcuts(
     ) {
       const { anchor } = editor.selection;
       const block = Editor.above(editor, {
-        match: node => Editor.isBlock(editor, node),
+        match: isBlock,
       });
       if (
         !block ||
@@ -182,8 +183,12 @@ export function withBlockMarkdownShortcuts(
       ) {
         return;
       }
+
       // so that this starts a new undo group
-      editor.history.undos.push([]);
+      editor.history.undos.push({
+        operations: [],
+        selectionBefore: editor.selection,
+      });
       Transforms.select(editor, range);
       Transforms.delete(editor);
       shortcut.insert();
diff --git a/packages/keystatic/DocumentEditor/code-block.tsx b/packages/keystatic/DocumentEditor/code-block.tsx
index 08b37ff7..e88af3f3 100644
--- a/packages/keystatic/DocumentEditor/code-block.tsx
+++ b/packages/keystatic/DocumentEditor/code-block.tsx
@@ -27,13 +27,14 @@ import { Kbd, Text } from '@voussoir/typography';
 
 import Prism from './prism';
 import { useToolbarState } from './toolbar-state';
+import { isBlock } from '.';
 
 export function withCodeBlock(editor: Editor): Editor {
   const { insertBreak, normalizeNode } = editor;
 
   editor.insertBreak = () => {
     const [node, path] = Editor.above(editor, {
-      match: n => Editor.isBlock(editor, n),
+      match: isBlock,
     }) || [editor, []];
     if (node.type === 'code' && SlateText.isText(node.children[0])) {
       const text = node.children[0].text;
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/child.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/child.tsx
index 0648378e..7f35fc1b 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/child.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/child.tsx
@@ -30,6 +30,7 @@ export type ChildField = {
         dividers?: 'inherit';
         links?: 'inherit';
         images?: 'inherit';
+        tables?: 'inherit';
       }
     | {
         kind: 'inline';
@@ -50,6 +51,8 @@ export function child(
         formatting?: BlockFormattingConfig | 'inherit';
         dividers?: 'inherit';
         links?: 'inherit';
+        images?: 'inherit';
+        tables?: 'inherit';
       }
     | {
         kind: 'inline';
@@ -83,6 +86,8 @@ export function child(
                   }
                 : options.formatting,
             links: options.links,
+            images: options.images,
+            tables: options.tables,
           }
         : {
             kind: 'inline',
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx
index feaf643f..9dfffc48 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx
@@ -65,6 +65,7 @@ type DocumentFeaturesConfig = {
   dividers?: true;
   images?: true | { directory?: string; publicPath?: string };
   layouts?: readonly (readonly [number, ...number[]])[];
+  tables?: true;
 };
 
 function normaliseDocumentFeatures(config: DocumentFeaturesConfig) {
@@ -139,6 +140,7 @@ function normaliseDocumentFeatures(config: DocumentFeaturesConfig) {
     ].map(x => JSON.parse(x)),
     dividers: !!config.dividers,
     images: config.images === undefined ? false : config.images,
+    tables: !!config.tables,
   };
   return documentFeatures;
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/utils.ts b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
index 7f4ad4d9..a0e28d4f 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/utils.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
@@ -167,6 +167,7 @@ export function getDocumentFeaturesForChildField(
       },
       links: options.links === 'inherit',
       images: options.images === 'inherit',
+      tables: options.tables === 'inherit',
     },
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/with-component-blocks.tsx b/packages/keystatic/DocumentEditor/component-blocks/with-component-blocks.tsx
index 26d5bc47..167e30f7 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/with-component-blocks.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/with-component-blocks.tsx
@@ -36,11 +36,12 @@ import {
   getNewArrayElementKey,
   setKeysForArrayValue,
 } from './preview-props';
+import { isBlock } from '..';
 
 function getAncestorComponentBlock(editor: Editor) {
   if (editor.selection) {
     const ancestorEntry = Editor.above(editor, {
-      match: node => Editor.isBlock(editor, node) && node.type !== 'paragraph',
+      match: node => isBlock(node) && node.type !== 'paragraph',
     });
     if (
       ancestorEntry &&
diff --git a/packages/keystatic/DocumentEditor/document-features-normalization.ts b/packages/keystatic/DocumentEditor/document-features-normalization.ts
index de575fae..c13e256d 100644
--- a/packages/keystatic/DocumentEditor/document-features-normalization.ts
+++ b/packages/keystatic/DocumentEditor/document-features-normalization.ts
@@ -73,6 +73,7 @@ export function normalizeElementBasedOnDocumentFeatures(
     layouts,
     links,
     images,
+    tables,
   }: DocumentFeaturesForNormalization
 ): boolean {
   if (
@@ -84,6 +85,7 @@ export function normalizeElementBasedOnDocumentFeatures(
     (node.type === 'code' && !formatting.blockTypes.code) ||
     (node.type === 'blockquote' && !formatting.blockTypes.blockquote) ||
     (node.type === 'image' && !images) ||
+    (node.type === 'table' && !tables) ||
     (node.type === 'layout' &&
       (layouts.length === 0 ||
         !layouts.some(layout => areArraysEqual(layout, node.layout))))
diff --git a/packages/keystatic/DocumentEditor/document-features.ts b/packages/keystatic/DocumentEditor/document-features.ts
index 3db96ac1..fec9fb7d 100644
--- a/packages/keystatic/DocumentEditor/document-features.ts
+++ b/packages/keystatic/DocumentEditor/document-features.ts
@@ -29,4 +29,5 @@ export type DocumentFeatures = {
   images: boolean | { directory?: string; publicPath?: string };
   dividers: boolean;
   layouts: [number, ...number[]][];
+  tables: boolean;
 };
diff --git a/packages/keystatic/DocumentEditor/index.tsx b/packages/keystatic/DocumentEditor/index.tsx
index 21da7ef2..1ef8a8fe 100644
--- a/packages/keystatic/DocumentEditor/index.tsx
+++ b/packages/keystatic/DocumentEditor/index.tsx
@@ -29,7 +29,7 @@ import { ComponentBlock } from './component-blocks/api';
 import { withParagraphs } from './paragraphs';
 import { withLink, wrapLink } from './link';
 import { withLayouts } from './layouts';
-import { clearFormatting, Mark } from './utils';
+import { clearFormatting, Mark, nodeTypeMatcher } from './utils';
 import { Toolbar } from './Toolbar';
 import { renderElement } from './render-element';
 import { withHeading } from './heading';
@@ -51,6 +51,12 @@ import { withPasting } from './pasting';
 import { classNames, css, tokenSchema } from '@voussoir/style';
 import { Box } from '@voussoir/layout';
 import { withImages } from './image';
+import {
+  getCellPathInDirection,
+  getSelectedTableArea,
+  TableSelectionProvider,
+  withTable,
+} from './table';
 // the docs site needs access to Editor and importing slate would use the version from the content field
 // so we're exporting it from here (note that this is not at all visible in the published version)
 export { Editor } from 'slate';
@@ -75,6 +81,13 @@ function isMarkActive(editor: Editor, mark: Mark) {
   return false;
 }
 
+const arrowKeyToDirection = new Map([
+  ['ArrowUp', 'up' as const],
+  ['ArrowDown', 'down' as const],
+  ['ArrowLeft', 'left' as const],
+  ['ArrowRight', 'right' as const],
+]);
+
 const getKeyDownHandler = (editor: Editor) => (event: KeyboardEvent) => {
   if (event.defaultPrevented) return;
   for (const hotkey in HOTKEYS) {
@@ -106,9 +119,10 @@ const getKeyDownHandler = (editor: Editor) => (event: KeyboardEvent) => {
       return;
     }
   }
+
   if (event.key === 'Tab' && editor.selection) {
     const layoutArea = Editor.above(editor, {
-      match: node => node.type === 'layout-area',
+      match: node => node.type === 'layout-area' || node.type === 'table-cell',
     });
     if (layoutArea) {
       const layoutAreaToEnter = event.shiftKey
@@ -121,6 +135,92 @@ const getKeyDownHandler = (editor: Editor) => (event: KeyboardEvent) => {
       event.preventDefault();
     }
   }
+  if (isHotkey('mod+a', event)) {
+    const parentTable = Editor.above(editor, {
+      match: nodeTypeMatcher('table'),
+    });
+    if (parentTable) {
+      Transforms.select(editor, parentTable[1]);
+      event.preventDefault();
+      return;
+    }
+  }
+  const direction = arrowKeyToDirection.get(event.key);
+  const { selection } = editor;
+  if (direction && selection) {
+    const selectedTableArea = getSelectedTableArea(editor);
+    if (selectedTableArea) {
+      const focusCellPath = Editor.above(editor, {
+        match: nodeTypeMatcher('table-cell'),
+        at: selection.focus.path,
+      })?.[1];
+      const anchorCellPath = Editor.above(editor, {
+        match: nodeTypeMatcher('table-cell'),
+        at: selection.anchor.path,
+      })?.[1];
+
+      if (!focusCellPath || !anchorCellPath) return;
+      const newCellPath = getCellPathInDirection(
+        editor,
+        focusCellPath,
+        direction
+      );
+      if (newCellPath) {
+        if (selectedTableArea.singleCell === 'not-selected') {
+          if (direction !== 'up' && direction !== 'down') return;
+          const [node, offset] = ReactEditor.toDOMPoint(
+            editor,
+            selection.focus
+          );
+          const blockElement = Editor.above(editor, {
+            match: isBlock,
+            at: selection.focus.path,
+          })?.[0];
+          if (!blockElement) return;
+          const domNodeForBlockElement = ReactEditor.toDOMNode(
+            editor,
+            blockElement
+          );
+          const rangeOfWholeBlock = document.createRange();
+          rangeOfWholeBlock.selectNodeContents(domNodeForBlockElement);
+          const rectsOfRangeOfWholeBlock = Array.from(
+            rangeOfWholeBlock.getClientRects()
+          );
+          const newRange = document.createRange();
+          newRange.setStart(node, offset);
+          newRange.setEnd(node, offset);
+          const rangeRects = Array.from(newRange.getClientRects());
+          const lastRangeRect = rangeRects[rangeRects.length - 1];
+          const key = direction === 'up' ? 'top' : 'bottom';
+          const expected =
+            key === 'top'
+              ? Math.min(...rectsOfRangeOfWholeBlock.map(x => x.top))
+              : Math.max(...rectsOfRangeOfWholeBlock.map(x => x.bottom));
+          if (lastRangeRect[key] === expected) {
+            const focus = Editor.start(editor, newCellPath);
+            Transforms.select(editor, {
+              focus,
+              anchor: event.shiftKey ? selection.anchor : focus,
+            });
+            event.preventDefault();
+          }
+
+          return;
+        }
+        if (!event.shiftKey) return;
+
+        if (Path.equals(newCellPath, anchorCellPath)) {
+          Transforms.select(editor, newCellPath);
+        } else {
+          Transforms.select(editor, {
+            anchor: selection.anchor,
+            focus: Editor.start(editor, newCellPath),
+          });
+        }
+        event.preventDefault();
+      }
+    }
+  }
 };
 
 export function createDocumentEditorWithoutReact(
@@ -143,29 +243,31 @@ function _createDocumentEditor(
             documentFeatures,
             componentBlocks,
             withList(
-              withHeading(
-                withInsertMenu(
-                  withComponentBlocks(
-                    componentBlocks,
-                    documentFeatures,
-                    withParagraphs(
-                      withShortcuts(
-                        withDivider(
-                          withLayouts(
-                            withMarks(
-                              documentFeatures,
-                              componentBlocks,
-                              withCodeBlock(
-                                withBlockMarkdownShortcuts(
-                                  documentFeatures,
-                                  componentBlocks,
-                                  withBlockquote(
-                                    withDocumentFeaturesNormalization(
-                                      documentFeatures,
-                                      withHistory(
-                                        includeReact
-                                          ? withReact(createEditor())
-                                          : createEditor()
+              withTable(
+                withHeading(
+                  withInsertMenu(
+                    withComponentBlocks(
+                      componentBlocks,
+                      documentFeatures,
+                      withParagraphs(
+                        withShortcuts(
+                          withDivider(
+                            withLayouts(
+                              withMarks(
+                                documentFeatures,
+                                componentBlocks,
+                                withCodeBlock(
+                                  withBlockMarkdownShortcuts(
+                                    documentFeatures,
+                                    componentBlocks,
+                                    withBlockquote(
+                                      withDocumentFeaturesNormalization(
+                                        documentFeatures,
+                                        withHistory(
+                                          includeReact
+                                            ? withReact(createEditor())
+                                            : createEditor()
+                                        )
                                       )
                                     )
                                   )
@@ -307,12 +409,14 @@ export function DocumentEditorProvider({
           }
         }}
       >
-        <ToolbarStateProvider
-          componentBlocks={componentBlocks}
-          editorDocumentFeatures={documentFeatures}
-        >
-          {children}
-        </ToolbarStateProvider>
+        <TableSelectionProvider>
+          <ToolbarStateProvider
+            componentBlocks={componentBlocks}
+            editorDocumentFeatures={documentFeatures}
+          >
+            {children}
+          </ToolbarStateProvider>
+        </TableSelectionProvider>
       </Slate>
     </IsInEditorContext.Provider>
   );
@@ -501,7 +605,7 @@ type TypesWhichHaveNoExtraRequiredProps = {
     : never;
 }[Block['type']];
 
-const blockquoteChildren = [
+const tableCellChildren = [
   'paragraph',
   'code',
   'heading',
@@ -511,6 +615,8 @@ const blockquoteChildren = [
   'image',
 ] as const;
 
+const blockquoteChildren = [...tableCellChildren, 'table'] as const;
+
 const paragraphLike = [...blockquoteChildren, 'blockquote'] as const;
 
 const insideOfLayouts = [...paragraphLike, 'component-block'] as const;
@@ -596,6 +702,26 @@ export const editorSchema = satisfies<
   }),
   'list-item-content': inlineContainer({ invalidPositionHandleMode: 'unwrap' }),
   image: inlineContainer({ invalidPositionHandleMode: 'move' }),
+  table: blockContainer({
+    invalidPositionHandleMode: 'move',
+    allowedChildren: ['table-head', 'table-body'],
+  }),
+  'table-body': blockContainer({
+    invalidPositionHandleMode: 'move',
+    allowedChildren: ['table-row'],
+  }),
+  'table-row': blockContainer({
+    invalidPositionHandleMode: 'move',
+    allowedChildren: ['table-cell'],
+  }),
+  'table-cell': blockContainer({
+    invalidPositionHandleMode: 'move',
+    allowedChildren: tableCellChildren,
+  }),
+  'table-head': blockContainer({
+    invalidPositionHandleMode: 'move',
+    allowedChildren: ['table-row'],
+  }),
 });
 
 type InlineContainingType = {
@@ -621,7 +747,7 @@ const blockTypes: Set<string | undefined> = new Set(
   Object.keys(editorSchema).filter(x => x !== 'editor')
 );
 
-export function isBlock(node: Descendant): node is Block {
+export function isBlock(node: Node): node is Block {
   return blockTypes.has(node.type);
 }
 
@@ -642,12 +768,12 @@ function withBlocksSchema(editor: Editor): Editor {
       if (
         info.kind === 'blocks' &&
         node.children.length !== 0 &&
-        node.children.every(child => !Editor.isBlock(editor, child))
+        node.children.every(child => !isBlock(child))
       ) {
         Transforms.wrapNodes(
           editor,
           { type: info.blockToWrapInlinesIn, children: [] },
-          { at: path, match: node => !Editor.isBlock(editor, node) }
+          { at: path, match: node => !isBlock(node) }
         );
         return;
       }
@@ -655,17 +781,12 @@ function withBlocksSchema(editor: Editor): Editor {
       for (const [index, childNode] of node.children.entries()) {
         const childPath = [...path, index];
         if (info.kind === 'inlines') {
-          if (!Text.isText(childNode) && !Editor.isInline(editor, childNode)) {
+          if (!Text.isText(childNode) && isBlock(childNode)) {
             handleNodeInInvalidPosition(editor, [childNode, childPath], path);
             return;
           }
         } else {
-          if (
-            !Editor.isBlock(editor, childNode) ||
-            // these checks are implicit in Editor.isBlock
-            // but that isn't encoded in types so these will make TS happy
-            childNode.type === 'link'
-          ) {
+          if (!isBlock(childNode)) {
             Transforms.wrapNodes(
               editor,
               { type: info.blockToWrapInlinesIn, children: [] },
diff --git a/packages/keystatic/DocumentEditor/insert-menu.tsx b/packages/keystatic/DocumentEditor/insert-menu.tsx
index c210fc1d..3634b22a 100644
--- a/packages/keystatic/DocumentEditor/insert-menu.tsx
+++ b/packages/keystatic/DocumentEditor/insert-menu.tsx
@@ -20,6 +20,8 @@ import { insertLayout } from './layouts';
 import { ToolbarState, useToolbarState } from './toolbar-state';
 import { insertNodesButReplaceIfSelectionIsAtEmptyParagraphOrHeading } from './utils';
 import { getUploadedImage } from './component-blocks/fields/image';
+import { isBlock } from '.';
+import { insertTable } from './table';
 
 type Option = {
   label: string;
@@ -87,6 +89,10 @@ function getOptions(
         }
       },
     },
+    !!toolbarState.editorDocumentFeatures.tables && {
+      label: 'Table',
+      insert: insertTable,
+    },
     !toolbarState.dividers.isDisabled &&
       toolbarState.editorDocumentFeatures.dividers && {
         label: 'Divider',
@@ -385,7 +391,7 @@ export function withInsertMenu(editor: Editor): Editor {
       const startOfBlock = Editor.start(
         editor,
         Editor.above(editor, {
-          match: node => Editor.isBlock(editor, node),
+          match: isBlock,
         })![1]
       );
       const before = Editor.before(editor, editor.selection.anchor, {
diff --git a/packages/keystatic/DocumentEditor/link.tsx b/packages/keystatic/DocumentEditor/link.tsx
index d7e6bd60..4ee470fc 100644
--- a/packages/keystatic/DocumentEditor/link.tsx
+++ b/packages/keystatic/DocumentEditor/link.tsx
@@ -16,7 +16,7 @@ import { TooltipTrigger, Tooltip } from '@voussoir/tooltip';
 import { Heading, Text } from '@voussoir/typography';
 
 import l10nMessages from '../app/l10n/index.json';
-import { isInlineContainer } from '.';
+import { isBlock, isInlineContainer } from '.';
 import { BlockPopoverTrigger } from './BlockPopoverTrigger';
 import { DocumentFeatures } from './document-features';
 import { ComponentBlock } from './component-blocks/api';
@@ -265,7 +265,7 @@ export function withLink(
       const startOfBlock = Editor.start(
         editor,
         Editor.above(editor, {
-          match: node => Editor.isBlock(editor, node),
+          match: isBlock,
         })![1]
       );
 
@@ -291,7 +291,10 @@ export function withLink(
       // by doing this, the insertText(')') above will happen in a different undo than the link replacement
       // so that means that when someone does an undo after this
       // it will undo to the state of "[content](link)" rather than "[content](link" (note the missing closing bracket)
-      editor.history.undos.push([]);
+      editor.history.undos.push({
+        operations: [],
+        selectionBefore: editor.selection,
+      });
       const startOfShortcut =
         match.index === 0
           ? startOfBlock
@@ -308,9 +311,7 @@ export function withLink(
       const endOfLinkText = EditorAfterButIgnoringingPointsWithNoContent(
         editor,
         startOfLinkText,
-        {
-          distance: linkText.length,
-        }
+        { distance: linkText.length }
       )!;
 
       Transforms.delete(editor, {
diff --git a/packages/keystatic/DocumentEditor/lists.tsx b/packages/keystatic/DocumentEditor/lists.tsx
index 1e2292e9..a84b36b1 100644
--- a/packages/keystatic/DocumentEditor/lists.tsx
+++ b/packages/keystatic/DocumentEditor/lists.tsx
@@ -18,6 +18,7 @@ import {
 import { Flex } from '@voussoir/layout';
 import { css, tokenSchema } from '@voussoir/style';
 import { Icon } from '@voussoir/icon';
+import { isBlock } from '.';
 
 export const isListType = (type: string | undefined) =>
   type === 'ordered-list' || type === 'unordered-list';
@@ -46,8 +47,7 @@ export const toggleList = (
         editor,
         { type: format, children: [] },
         {
-          match: x =>
-            x.type !== 'list-item-content' && Editor.isBlock(editor, x),
+          match: x => x.type !== 'list-item-content' && isBlock(x),
         }
       );
     }
@@ -148,7 +148,7 @@ export function withList(editor: Editor): Editor {
           node.type === 'list-item' &&
           childNode.type !== 'list-item-content' &&
           index === 0 &&
-          Editor.isBlock(editor, childNode)
+          isBlock(childNode)
         ) {
           if (path[path.length - 1] !== 0) {
             const previousChild = Node.get(editor, Path.previous(path));
@@ -227,9 +227,7 @@ export function ListButtons(props: {
 }
 
 export function nestList(editor: Editor) {
-  const block = Editor.above(editor, {
-    match: n => Editor.isBlock(editor, n),
-  });
+  const block = Editor.above(editor, { match: isBlock });
 
   if (!block || block[0].type !== 'list-item-content') {
     return false;
@@ -275,7 +273,7 @@ export function nestList(editor: Editor) {
 
 export function unnestList(editor: Editor) {
   const block = Editor.above(editor, {
-    match: n => Editor.isBlock(editor, n),
+    match: isBlock,
   });
 
   if (block && block[0].type === 'list-item-content') {
diff --git a/packages/keystatic/DocumentEditor/marks.tsx b/packages/keystatic/DocumentEditor/marks.tsx
index eebe9fe6..2375c7ef 100644
--- a/packages/keystatic/DocumentEditor/marks.tsx
+++ b/packages/keystatic/DocumentEditor/marks.tsx
@@ -3,6 +3,7 @@ import { DocumentFeatures } from './document-features';
 import { ComponentBlock } from './component-blocks/api';
 import { getAncestorComponentChildFieldDocumentFeatures } from './toolbar-state';
 import { EditorAfterButIgnoringingPointsWithNoContent, Mark } from './utils';
+import { isBlock } from '.';
 
 export const allMarkdownShortcuts = {
   bold: ['**', '__'],
@@ -18,7 +19,10 @@ function applyMark(
   startOfStartPoint: Point
 ) {
   // so that this starts a new undo group
-  editor.history.undos.push([]);
+  editor.history.undos.push({
+    operations: [],
+    selectionBefore: editor.selection,
+  });
   const startPointRef = Editor.pointRef(editor, startOfStartPoint);
 
   Transforms.delete(editor, {
@@ -57,7 +61,7 @@ export function withMarks(
     const marksAfterInsertBreak = Editor.marks(editor);
     if (!marksAfterInsertBreak || !editor.selection) return;
     const parentBlock = Editor.above(editor, {
-      match: node => Editor.isBlock(editor, node),
+      match: isBlock,
     });
     if (!parentBlock) return;
     const point = EditorAfterButIgnoringingPointsWithNoContent(
@@ -231,8 +235,5 @@ export function withMarks(
 }
 
 function getStartOfBlock(editor: Editor) {
-  return Editor.start(
-    editor,
-    Editor.above(editor, { match: node => Editor.isBlock(editor, node) })![1]
-  );
+  return Editor.start(editor, Editor.above(editor, { match: isBlock })![1]);
 }
diff --git a/packages/keystatic/DocumentEditor/pasting/index.ts b/packages/keystatic/DocumentEditor/pasting/index.ts
index 9996a5c1..d7ff496e 100644
--- a/packages/keystatic/DocumentEditor/pasting/index.ts
+++ b/packages/keystatic/DocumentEditor/pasting/index.ts
@@ -5,11 +5,12 @@ import { insertNodesButReplaceIfSelectionIsAtEmptyParagraphOrHeading } from '../
 import { deserializeHTML } from './html';
 import { deserializeMarkdown } from './markdown';
 import { fromByteArray, toByteArray } from 'base64-js';
+import { isBlock } from '..';
 
 const urlPattern = /https?:\/\//;
 
 function insertFragmentButDifferent(editor: Editor, nodes: Descendant[]) {
-  if (Editor.isBlock(editor, nodes[0])) {
+  if (isBlock(nodes[0])) {
     insertNodesButReplaceIfSelectionIsAtEmptyParagraphOrHeading(editor, nodes);
   } else {
     Transforms.insertFragment(editor, nodes);
@@ -193,7 +194,7 @@ export function withPasting(editor: Editor): Editor {
   };
   editor.insertTextData = data => {
     const blockAbove = Editor.above(editor, {
-      match: node => Editor.isBlock(editor, node),
+      match: isBlock,
     });
     if (blockAbove?.[0].type === 'code') {
       const plain = data.getData('text/plain');
@@ -228,14 +229,12 @@ export function withPasting(editor: Editor): Editor {
       !Range.isCollapsed(editor.selection) &&
       // we only want to turn the selected text into a link if the selection is within the same block
       Editor.above(editor, {
-        match: node =>
-          Editor.isBlock(editor, node) &&
-          !Editor.isBlock(editor, node.children[0]),
+        match: node => isBlock(node) && !isBlock(node.children[0]),
       }) &&
       // and there is only text(potentially with marks) in the selection
       // no other links
       Editor.nodes(editor, {
-        match: node => Editor.isInline(editor, node),
+        match: node => node.type === 'link',
       }).next().done
     ) {
       Transforms.wrapNodes(
diff --git a/packages/keystatic/DocumentEditor/render-element.tsx b/packages/keystatic/DocumentEditor/render-element.tsx
index eb15257e..69f04294 100644
--- a/packages/keystatic/DocumentEditor/render-element.tsx
+++ b/packages/keystatic/DocumentEditor/render-element.tsx
@@ -11,6 +11,13 @@ import { LinkElement } from './link';
 import { CodeElement } from './code-block';
 import { DividerElement } from './divider';
 import { ImageElement } from './image';
+import {
+  TableBodyElement,
+  TableCellElement,
+  TableElement,
+  TableHeadElement,
+  TableRowElement,
+} from './table';
 
 // some of the renderers read properties of the element
 // and TS doesn't understand the type narrowing when doing a spread for some reason
@@ -83,6 +90,47 @@ export const renderElement = (props: RenderElementProps) => {
           element={props.element}
         />
       );
+    case 'table':
+      return (
+        <TableElement
+          attributes={props.attributes}
+          children={props.children}
+          element={props.element}
+        />
+      );
+    case 'table-head':
+      return (
+        <TableHeadElement
+          attributes={props.attributes}
+          children={props.children}
+          element={props.element}
+        />
+      );
+    case 'table-body':
+      return (
+        <TableBodyElement
+          attributes={props.attributes}
+          children={props.children}
+          element={props.element}
+        />
+      );
+    case 'table-row':
+      return (
+        <TableRowElement
+          attributes={props.attributes}
+          children={props.children}
+          element={props.element}
+        />
+      );
+    case 'table-cell':
+      return (
+        <TableCellElement
+          attributes={props.attributes}
+          children={props.children}
+          element={props.element}
+        />
+      );
+
     default:
       let { textAlign } = props.element;
       return (
diff --git a/packages/keystatic/DocumentEditor/shortcuts.ts b/packages/keystatic/DocumentEditor/shortcuts.ts
index f799613e..f719e25c 100644
--- a/packages/keystatic/DocumentEditor/shortcuts.ts
+++ b/packages/keystatic/DocumentEditor/shortcuts.ts
@@ -1,4 +1,5 @@
 import { Range, Editor, Transforms, Path } from 'slate';
+import { isBlock } from '.';
 
 export const shortcuts: Record<string, string> = {
   '...': '…',
@@ -20,7 +21,7 @@ export function withShortcuts(editor: Editor): Editor {
     ) {
       const selectionPoint = editor.selection.anchor;
       const ancestorBlock = Editor.above(editor, {
-        match: node => Editor.isBlock(editor, node),
+        match: isBlock,
       });
       if (ancestorBlock) {
         Object.keys(shortcuts).forEach(shortcut => {
@@ -35,7 +36,10 @@ export function withShortcuts(editor: Editor): Editor {
             const range = { anchor: selectionPoint, focus: pointBefore };
             const str = Editor.string(editor, range);
             if (str.slice(0, shortcut.length) === shortcut) {
-              editor.history.undos.push([]);
+              editor.history.undos.push({
+                operations: [],
+                selectionBefore: editor.selection,
+              });
               Transforms.select(editor, range);
               editor.insertText(shortcuts[shortcut] + ' ');
             }
diff --git a/packages/keystatic/DocumentEditor/slate-types.ts b/packages/keystatic/DocumentEditor/slate-types.ts
index 771f3bf7..2d3d153e 100644
--- a/packages/keystatic/DocumentEditor/slate-types.ts
+++ b/packages/keystatic/DocumentEditor/slate-types.ts
@@ -31,7 +31,18 @@ type OnlyChildrenElements = {
     | 'list-item'
     | 'list-item-content'
     | 'ordered-list'
-    | 'unordered-list';
+    | 'unordered-list'
+    | 'table'
+    | 'table-body'
+    | 'table-head'
+    | 'table-row';
+};
+
+type TableCell = {
+  type: 'table-cell';
+  header?: true;
+  rowSpan?: number;
+  colSpan?: number;
 };
 
 type CodeBlock = {
@@ -78,6 +89,7 @@ type Element = (
   | Link
   | CodeBlock
   | Image
+  | TableCell
 ) &
   BaseElement;
 declare module 'slate' {
diff --git a/packages/keystatic/DocumentEditor/table.tsx b/packages/keystatic/DocumentEditor/table.tsx
new file mode 100644
index 00000000..0ead991f
--- /dev/null
+++ b/packages/keystatic/DocumentEditor/table.tsx
@@ -0,0 +1,1204 @@
+import { ContextType, createContext, useContext, useMemo } from 'react';
+import {
+  Descendant,
+  Editor,
+  Element,
+  Node,
+  Path,
+  Transforms,
+  Range,
+  Text as SlateText,
+  Point,
+} from 'slate';
+import { ReactEditor, RenderElementProps, useSlate } from 'slate-react';
+
+import { ActionButton } from '@voussoir/button';
+import { tableIcon } from '@voussoir/icon/icons/tableIcon';
+import { Icon } from '@voussoir/icon';
+import { TooltipTrigger, Tooltip } from '@voussoir/tooltip';
+import { Text } from '@voussoir/typography';
+
+import { useToolbarState } from './toolbar-state';
+import { css, tokenSchema } from '@voussoir/style';
+import { moveChildren, nodeTypeMatcher, useStaticEditor } from './utils';
+import { chevronDownIcon } from '@voussoir/icon/icons/chevronDownIcon';
+import { Item, Menu, MenuTrigger } from '@voussoir/menu';
+
+const cell = (header: boolean) => ({
+  type: 'table-cell' as const,
+  ...(header ? { header: true as const } : {}),
+  children: [{ type: 'paragraph' as const, children: [{ text: '' }] }],
+});
+
+export const insertTable = (editor: Editor) => {
+  Transforms.insertNodes(editor, {
+    type: 'table',
+    children: [
+      {
+        type: 'table-head',
+        children: [
+          { type: 'table-row', children: [cell(true), cell(true), cell(true)] },
+        ],
+      },
+      {
+        type: 'table-body',
+        children: [
+          {
+            type: 'table-row',
+            children: [cell(false), cell(false), cell(false)],
+          },
+          {
+            type: 'table-row',
+            children: [cell(false), cell(false), cell(false)],
+          },
+        ],
+      },
+    ],
+  });
+};
+
+function cloneDescendant(node: Descendant): Descendant {
+  if (SlateText.isText(node)) return { ...node };
+  return {
+    ...node,
+    children: node.children.map(cloneDescendant),
+  };
+}
+
+function getRelativeRowPath(hasHead: boolean, rowIndex: number): Path {
+  return hasHead
+    ? rowIndex === 0
+      ? [0, 0]
+      : [1, rowIndex - 1]
+    : [0, rowIndex];
+}
+
+export function withTable(editor: Editor): Editor {
+  const {
+    deleteFragment,
+    normalizeNode,
+    getFragment,
+    insertFragment,
+    deleteBackward,
+  } = editor;
+  editor.insertFragment = fragment => {
+    const selectedTableArea = getSelectedTableArea(editor);
+    if (
+      !selectedTableArea ||
+      fragment.length !== 1 ||
+      fragment[0].type !== 'table'
+    ) {
+      insertFragment(fragment);
+      return;
+    }
+    const newRows = fragment[0].children.flatMap(child =>
+      child.type === 'table-head' || child.type === 'table-body'
+        ? child.children
+        : []
+    );
+    if (!newRows.every(nodeTypeMatcher('table-row'))) {
+      insertFragment(fragment);
+      return;
+    }
+
+    let { row, column, tablePath, table } = selectedTableArea;
+    const existingBody =
+      selectedTableArea.table.children[
+        selectedTableArea.table.children.length === 1 ? 0 : 1
+      ];
+    if (
+      newRows[0].type !== 'table-row' ||
+      existingBody.type !== 'table-body' ||
+      existingBody.children[0].type !== 'table-row'
+    ) {
+      insertFragment(fragment);
+      return;
+    }
+    const hasHead = table.children[0].type === 'table-head';
+    if (selectedTableArea.singleCell !== 'many') {
+      row = {
+        start: row.start,
+        end: Math.min(
+          row.start + newRows.length - 1,
+          existingBody.children.length - 1 + (hasHead ? 1 : 0)
+        ),
+      };
+      column = {
+        start: column.start,
+        end: Math.min(
+          column.start + newRows[0].children.length - 1,
+          existingBody.children[0].children.length - 1
+        ),
+      };
+    }
+    Editor.withoutNormalizing(editor, () => {
+      for (let rowIndex = row.start; rowIndex <= row.end; rowIndex++) {
+        const newRow = newRows[(rowIndex - row.start) % newRows.length];
+        for (
+          let cellIndex = column.start;
+          cellIndex <= column.end;
+          cellIndex++
+        ) {
+          const relativeCellPath = [
+            ...getRelativeRowPath(hasHead, rowIndex),
+            cellIndex,
+          ];
+          const cell = Node.get(table, relativeCellPath);
+          const newCell =
+            newRow.children[
+              (cellIndex - column.start) % newRow.children.length
+            ];
+          if (cell.type !== 'table-cell' || newCell.type !== 'table-cell') {
+            continue;
+          }
+          const cellPath = [...tablePath, ...relativeCellPath];
+          for (const childIdx of [...cell.children.keys()].reverse()) {
+            Transforms.removeNodes(editor, {
+              at: [...cellPath, childIdx],
+            });
+          }
+          Transforms.insertNodes(
+            editor,
+            newCell.children.map(cloneDescendant),
+            { at: [...cellPath, 0] }
+          );
+        }
+      }
+      Transforms.setSelection(editor, {
+        anchor: Editor.start(editor, [
+          ...tablePath,
+          ...getRelativeRowPath(hasHead, row.start),
+          column.start,
+        ]),
+        focus: Editor.end(editor, [
+          ...tablePath,
+          ...getRelativeRowPath(hasHead, row.end),
+          column.end,
+        ]),
+      });
+    });
+  };
+  editor.deleteBackward = unit => {
+    if (
+      editor.selection &&
+      Range.isCollapsed(editor.selection) &&
+      editor.selection.anchor.offset === 0
+    ) {
+      const tableCell = Editor.above(editor, {
+        match: nodeTypeMatcher('table-cell'),
+      });
+      if (
+        tableCell &&
+        tableCell[0].children[0].type === 'paragraph' &&
+        tableCell[0].children[0].children[0].type === undefined &&
+        Path.equals(editor.selection.anchor.path, [...tableCell[1], 0, 0])
+      ) {
+        return;
+      }
+    }
+    deleteBackward(unit);
+  };
+  editor.getFragment = () => {
+    const selectedTableArea = getSelectedTableArea(editor);
+    if (selectedTableArea && selectedTableArea.singleCell !== 'not-selected') {
+      const { table } = selectedTableArea;
+      const first =
+        table.children[0].type === 'table-head' ||
+        table.children[0].type === 'table-body'
+          ? table.children[0]
+          : undefined;
+      if (!first) return getFragment();
+      const second =
+        table.children[1]?.type === 'table-body'
+          ? table.children[1]
+          : undefined;
+      const body = second || first;
+      const hasHead = first.type === 'table-head';
+      const isSelectionInHead = selectedTableArea.row.start === 0 && !!second;
+      const columnLength =
+        selectedTableArea.column.end - selectedTableArea.column.start + 1;
+      return [
+        {
+          type: 'table',
+          children: [
+            ...(isSelectionInHead
+              ? [
+                  {
+                    type: 'table-head' as const,
+                    children: [
+                      {
+                        type: 'table-row' as const,
+                        children: Array.from({ length: columnLength }).map(
+                          (_, columnIndex) =>
+                            (first.children[0] as Element).children[
+                              columnIndex + selectedTableArea.column.start
+                            ]
+                        ),
+                      },
+                    ],
+                  },
+                ]
+              : []),
+            {
+              type: 'table-body',
+              children: Array.from({
+                length:
+                  selectedTableArea.row.end -
+                  selectedTableArea.row.start +
+                  (isSelectionInHead ? 0 : 1),
+              }).map((_, rowIndex) => ({
+                type: 'table-row',
+                children: Array.from({ length: columnLength }).map(
+                  (_, columnIndex) =>
+                    (
+                      body.children[
+                        rowIndex +
+                          selectedTableArea.row.start -
+                          (hasHead && !isSelectionInHead ? 1 : 0)
+                      ] as Element
+                    ).children[columnIndex + selectedTableArea.column.start]
+                ),
+              })),
+            },
+          ],
+        },
+      ];
+    }
+    return getFragment();
+  };
+  editor.deleteFragment = direction => {
+    if (!editor.selection || Range.isCollapsed(editor.selection)) {
+      deleteFragment(direction);
+      return;
+    }
+    const selectedTableArea = getSelectedTableArea(editor);
+    if (!selectedTableArea || selectedTableArea.singleCell === 'not-selected') {
+      deleteFragment(direction);
+      return;
+    }
+    const headOrBody = selectedTableArea.table.children[0];
+    if (
+      !Element.isElement(headOrBody) ||
+      !Element.isElement(headOrBody.children[0])
+    ) {
+      deleteFragment(direction);
+      return;
+    }
+    const maxRowIdx =
+      selectedTableArea.table.children.reduce(
+        (sum, headOrBody) =>
+          sum +
+          (headOrBody.type === 'table-head' || headOrBody.type === 'table-body'
+            ? headOrBody.children.length
+            : 0),
+        0
+      ) - 1;
+    const { row, column, tablePath } = selectedTableArea;
+    // note the fact that hasWholeColumnSelected uses row and hasWholeRowSelected uses column
+    // is not a mistake. if a whole column has been selected, then the starting row is 0 and the end is the last row
+    const hasWholeColumnSelected = row.start === 0 && row.end === maxRowIdx;
+    const hasWholeRowSelected =
+      column.start === 0 &&
+      column.end === headOrBody.children[0].children.length - 1;
+    if (hasWholeColumnSelected && hasWholeRowSelected) {
+      Transforms.removeNodes(editor, { at: tablePath });
+      return;
+    }
+    const hasHead = headOrBody.type === 'table-head';
+
+    if (hasWholeRowSelected) {
+      Editor.withoutNormalizing(editor, () => {
+        for (let i = row.end; i >= row.start; i--) {
+          if (hasHead) {
+            if (i === 0) {
+              Transforms.removeNodes(editor, {
+                at: [...tablePath, 0],
+              });
+              continue;
+            }
+            Transforms.removeNodes(editor, {
+              at: [...tablePath, 1, i],
+            });
+            continue;
+          }
+          Transforms.removeNodes(editor, {
+            at: [...tablePath, 0, i],
+          });
+        }
+      });
+      return;
+    }
+
+    if (hasWholeColumnSelected) {
+      Editor.withoutNormalizing(editor, () => {
+        for (let i = column.end; i >= column.start; i--) {
+          for (let rowIdx = 0; rowIdx <= maxRowIdx; rowIdx++) {
+            Transforms.removeNodes(editor, {
+              at: [...tablePath, ...getRelativeRowPath(hasHead, rowIdx), i],
+            });
+          }
+        }
+        const selectionPath = [...tablePath, 0, 0, column.start];
+        const point = Editor.start(
+          editor,
+          column.start === 0 ? selectionPath : Path.previous(selectionPath)
+        );
+        Transforms.select(editor, point);
+      });
+      return;
+    }
+    const selectionStart = Editor.start(editor, editor.selection).path;
+    Editor.withoutNormalizing(editor, () => {
+      for (let rowIndex = row.start; rowIndex <= row.end; rowIndex++) {
+        for (
+          let cellIndex = column.start;
+          cellIndex <= column.end;
+          cellIndex++
+        ) {
+          const relativeCellPath = [
+            ...getRelativeRowPath(hasHead, rowIndex),
+            cellIndex,
+          ];
+          const cell = Node.get(selectedTableArea.table, relativeCellPath);
+          if (!Element.isElement(cell)) continue;
+
+          const cellPath = [...tablePath, ...relativeCellPath];
+
+          Transforms.insertNodes(
+            editor,
+            { type: 'paragraph', children: [{ text: '' }] },
+            { at: [...cellPath, 0] }
+          );
+          for (const childIdx of [...cell.children.keys()].reverse()) {
+            Transforms.removeNodes(editor, {
+              at: [...cellPath, childIdx + 1],
+            });
+          }
+        }
+      }
+      Transforms.select(editor, selectionStart);
+    });
+  };
+  editor.normalizeNode = entry => {
+    const [node, path] = entry;
+    if (node.type === 'table-head' && node.children.length > 1) {
+      moveChildren(editor, path, Path.next(path), (_, i) => i !== 0);
+      return;
+    }
+    let didUpdateThings = false;
+    for (const parent of ['table-body', 'table-head'] as const) {
+      if (node.type === parent) {
+        for (const [rowIdx, row] of node.children.entries()) {
+          if (row.type === 'table-row') {
+            for (const [cellIdx, cell] of row.children.entries()) {
+              if (cell.type === 'table-cell') {
+                const at = [...path, rowIdx, cellIdx];
+                if (cell.header && parent === 'table-body') {
+                  Transforms.unsetNodes(editor, 'header', { at });
+                  didUpdateThings = true;
+                }
+                if (!cell.header && parent === 'table-head') {
+                  Transforms.setNodes(editor, { header: true }, { at });
+                  didUpdateThings = true;
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    if (didUpdateThings) {
+      return;
+    }
+    if (node.type === 'table') {
+      const maxRowCount = node.children.reduce(
+        (max, node) =>
+          node.type === 'table-head' || node.type === 'table-body'
+            ? node.children.reduce(
+                (max, node) =>
+                  node.type === 'table-row'
+                    ? Math.max(max, node.children.length)
+                    : max,
+                max
+              )
+            : max,
+        0
+      );
+      let didInsert = false;
+      for (const [idx, child] of node.children.entries()) {
+        if (child.type === 'table-body' || child.type === 'table-head') {
+          for (const [rowIdx, row] of child.children.entries()) {
+            if (
+              row.type === 'table-row' &&
+              row.children.length !== maxRowCount
+            ) {
+              Transforms.insertNodes(
+                editor,
+                Array.from({ length: maxRowCount - row.children.length }, () =>
+                  cell(child.type === 'table-head')
+                ),
+                { at: [...path, idx, rowIdx, row.children.length] }
+              );
+              didInsert = true;
+            }
+          }
+        }
+      }
+      if (didInsert) {
+        return;
+      }
+      if (
+        node.children.length === 1 &&
+        node.children[0].type === 'table-head'
+      ) {
+        Transforms.insertNodes(
+          editor,
+          {
+            type: 'table-body',
+            children: Array.from(
+              { length: node.children[0].children.length },
+              () => cell(false)
+            ),
+          },
+          { at: [...path, 1] }
+        );
+        return;
+      }
+      if (
+        node.children.length === 2 &&
+        node.children[1].type === 'table-head'
+      ) {
+        Transforms.moveNodes(editor, {
+          at: [...path, 1],
+          to: [...path, 0],
+        });
+        return;
+      }
+      if (node.children.length > 2) {
+        moveChildren(
+          editor,
+          path,
+          Path.next(path),
+          (_, i) => i !== 0 && i !== 1
+        );
+        return;
+      }
+    }
+    normalizeNode(entry);
+  };
+  return editor;
+}
+
+const SelectedCellsContext = createContext<
+  | {
+      table: Element & { type: 'table' };
+      focus: Descendant;
+      cells: Set<Descendant>;
+    }
+  | undefined
+>(undefined);
+
+function order(a: number, b: number) {
+  return { start: Math.min(a, b), end: Math.max(a, b) };
+}
+
+function getSelectedCells(
+  table: Element & { type: 'table' },
+  row: { start: number; end: number },
+  column: { start: number; end: number }
+) {
+  const selectedCells = new Set<Descendant>();
+  const first =
+    table.children[0].type === 'table-head' ||
+    table.children[0].type === 'table-body'
+      ? table.children[0]
+      : undefined;
+  if (!first) return selectedCells;
+  const second =
+    table.children[1]?.type === 'table-body' ? table.children[1] : undefined;
+  for (let rowIndex = row.start; rowIndex <= row.end; rowIndex++) {
+    const row = second
+      ? rowIndex === 0
+        ? first.children[0]
+        : second.children[rowIndex - 1]
+      : first.children[rowIndex];
+    if (!Element.isElement(row)) continue;
+    for (let cellIndex = column.start; cellIndex <= column.end; cellIndex++) {
+      selectedCells.add(row.children[cellIndex]);
+    }
+  }
+  return selectedCells;
+}
+
+export function getSelectedTableArea(editor: Editor) {
+  const anchor = Editor.above(editor, {
+    match: nodeTypeMatcher('table-cell'),
+    at: editor.selection?.anchor.path,
+  })?.[1];
+  const focus = Editor.above(editor, {
+    match: nodeTypeMatcher('table-cell'),
+    at: editor.selection?.focus.path,
+  })?.[1];
+  const table = Editor.above(editor, {
+    match: nodeTypeMatcher('table'),
+  });
+  if (
+    editor.selection &&
+    table &&
+    Element.isElement(table[0].children[0]) &&
+    anchor &&
+    focus &&
+    Path.equals(anchor.slice(0, -3), focus.slice(0, -3))
+  ) {
+    const [start, end] = Editor.edges(editor, editor.selection);
+    return {
+      tablePath: table[1],
+      table: table[0],
+      singleCell: Path.equals(anchor, focus)
+        ? Point.equals(Editor.start(editor, anchor), start) &&
+          Point.equals(Editor.end(editor, anchor), end) &&
+          !Point.equals(start, end)
+          ? ('selected' as const)
+          : ('not-selected' as const)
+        : ('many' as const),
+      row: order(
+        anchor[anchor.length - 2] + anchor[anchor.length - 3],
+        focus[focus.length - 2] + focus[anchor.length - 3]
+      ),
+      column: order(anchor[anchor.length - 1], focus[focus.length - 1]),
+    };
+  }
+}
+
+export function TableSelectionProvider(props: { children: React.ReactNode }) {
+  const editor = useSlate();
+  const selectedTableArea = getSelectedTableArea(editor);
+  if (selectedTableArea) {
+    return (
+      <SelectedCellsContext.Provider
+        value={{
+          cells:
+            selectedTableArea.singleCell === 'not-selected'
+              ? new Set()
+              : getSelectedCells(
+                  selectedTableArea.table,
+                  selectedTableArea.row,
+                  selectedTableArea.column
+                ),
+          table: selectedTableArea.table,
+          focus: Editor.above(editor, {
+            match: nodeTypeMatcher('table-cell'),
+            at: editor.selection?.focus.path,
+          })?.[0]!,
+        }}
+      >
+        {props.children}
+      </SelectedCellsContext.Provider>
+    );
+  }
+
+  return (
+    <SelectedCellsContext.Provider value={undefined}>
+      {props.children}
+    </SelectedCellsContext.Provider>
+  );
+}
+
+const StartElementsContext = createContext<{
+  top: Map<Element, boolean>;
+  left: Map<Element, boolean>;
+}>({ top: new Map(), left: new Map() });
+
+export const TableElement = ({
+  attributes,
+  children,
+  element,
+}: RenderElementProps) => {
+  const selectedCellsContext = useContext(SelectedCellsContext);
+  const selectedCells =
+    selectedCellsContext?.table === element ? selectedCellsContext : undefined;
+  const startElements = useMemo((): ContextType<
+    typeof StartElementsContext
+  > => {
+    const firstTableChild = element.children[0];
+    if (
+      !Element.isElement(firstTableChild) ||
+      !Element.isElement(firstTableChild.children[0])
+    ) {
+      return { top: new Map(), left: new Map() };
+    }
+    const top = new Map<Element, boolean>();
+    const left = new Map<Element, boolean>();
+    for (const [idx, cell] of firstTableChild.children[0].children.entries()) {
+      if (cell.type !== 'table-cell') continue;
+      top.set(
+        cell,
+        element.children.every(headOrBody =>
+          Element.isElement(headOrBody)
+            ? headOrBody.children.every(
+                row =>
+                  Element.isElement(row) &&
+                  selectedCells?.cells.has(row.children[idx])
+              )
+            : false
+        )
+      );
+    }
+    for (const headOrBody of element.children) {
+      if (
+        headOrBody.type !== 'table-head' &&
+        headOrBody.type !== 'table-body'
+      ) {
+        continue;
+      }
+      for (const row of headOrBody.children) {
+        if (row.type !== 'table-row' || row.children[0].type !== 'table-cell') {
+          continue;
+        }
+        left.set(
+          row.children[0],
+          row.children.every(element => selectedCells?.cells.has(element))
+        );
+      }
+    }
+    return { top, left };
+  }, [element, selectedCells]);
+  return (
+    <StartElementsContext.Provider value={startElements}>
+      <SelectedCellsContext.Provider value={selectedCells}>
+        <table
+          className={css({
+            width: '100%',
+            tableLayout: 'fixed',
+            position: 'relative',
+            borderSpacing: 0,
+            marginTop: 10,
+            marginLeft: 10,
+            '& *::selection': selectedCells?.cells.size
+              ? { backgroundColor: 'transparent' }
+              : undefined,
+          })}
+          {...attributes}
+        >
+          {children}
+        </table>
+      </SelectedCellsContext.Provider>
+    </StartElementsContext.Provider>
+  );
+};
+
+export const TableBodyElement = ({
+  attributes,
+  children,
+}: RenderElementProps) => {
+  return <tbody {...attributes}>{children}</tbody>;
+};
+
+export const TableHeadElement = ({
+  attributes,
+  children,
+}: RenderElementProps) => {
+  return <thead {...attributes}>{children}</thead>;
+};
+
+export const TableRowElement = ({
+  attributes,
+  children,
+  element,
+}: RenderElementProps) => {
+  const table = useContext(SelectedCellsContext)?.table;
+  return (
+    <RowIndexContext.Provider value={table?.children.indexOf(element) ?? -1}>
+      <tr {...attributes}>{children}</tr>
+    </RowIndexContext.Provider>
+  );
+};
+
+const RowIndexContext = createContext<number>(-1);
+
+export function TableCellElement({
+  attributes,
+  children,
+  element,
+}: RenderElementProps & { element: { type: 'table-cell' } }) {
+  const editor = useStaticEditor();
+  const selectedCellsContext = useContext(SelectedCellsContext);
+  const startElements = useContext(StartElementsContext);
+  const isSelected = selectedCellsContext?.cells.has(element);
+  const size = `calc(100% + 2px)`;
+  const ElementType = element.header ? 'th' : 'td';
+  return (
+    <ElementType
+      className={css({
+        borderLeft: `1px solid ${tokenSchema.color.alias.borderIdle}`,
+        borderBottom: `1px solid ${tokenSchema.color.alias.borderIdle}`,
+        borderTop: startElements.top.has(element)
+          ? `1px solid ${tokenSchema.color.alias.borderIdle}`
+          : undefined,
+        backgroundColor: selectedCellsContext?.cells.has(element)
+          ? tokenSchema.color.alias.backgroundSelected
+          : element.header
+          ? tokenSchema.color.scale.slate4
+          : undefined,
+        position: 'relative',
+        margin: 0,
+        padding: 0,
+        fontWeight: 'inherit',
+        textAlign: 'start',
+      })}
+      {...attributes}
+    >
+      {isSelected && (
+        <>
+          <div
+            contentEditable={false}
+            className={css({
+              position: 'absolute',
+              top: -1,
+              left: -1,
+              background: tokenSchema.color.alias.borderSelected,
+              height: size,
+              width: 1,
+            })}
+          />
+          <div
+            contentEditable={false}
+            className={css({
+              position: 'absolute',
+              top: -1,
+              right: -1,
+              background: tokenSchema.color.alias.borderSelected,
+              height: size,
+              width: 1,
+            })}
+          />
+          <div
+            contentEditable={false}
+            className={css({
+              position: 'absolute',
+              top: -1,
+              left: -1,
+              background: tokenSchema.color.alias.borderSelected,
+              height: 1,
+              width: size,
+            })}
+          />
+          <div
+            contentEditable={false}
+            className={css({
+              position: 'absolute',
+              bottom: -1,
+              left: -1,
+              background: tokenSchema.color.alias.borderSelected,
+              height: 1,
+              width: size,
+            })}
+          />
+        </>
+      )}
+      {startElements.top.has(element) && (
+        <CellSelection
+          location="top"
+          selected={!!startElements.top.get(element)}
+          label="Select Column"
+          onClick={() => {
+            const path = ReactEditor.findPath(editor, element);
+            const table = Editor.above(editor, {
+              match: nodeTypeMatcher('table'),
+              at: path,
+            });
+            if (!table) return;
+            const lastTableIndex = table[0].children.length - 1;
+            const tableBody = table[0].children[lastTableIndex];
+            if (tableBody.type !== 'table-body') return;
+            const cellIndex = path[path.length - 1];
+            const endPath = [
+              ...table[1],
+              table[0].children.length - 1,
+              tableBody.children.length - 1,
+              cellIndex,
+            ];
+            Transforms.select(editor, {
+              anchor: Editor.start(editor, path),
+              focus: Editor.end(editor, endPath),
+            });
+          }}
+        />
+      )}
+      {startElements.left.has(element) && (
+        <CellSelection
+          location="left"
+          selected={!!startElements.left.get(element)}
+          label="Select Row"
+          onClick={() => {
+            const path = ReactEditor.findPath(editor, element);
+            Transforms.select(editor, {
+              anchor: Editor.start(editor, Path.parent(path)),
+              focus: Editor.end(editor, Path.parent(path)),
+            });
+          }}
+        />
+      )}
+      {startElements.left.has(element) && startElements.top.has(element) && (
+        <CellSelection
+          location="top-left"
+          selected={
+            !!(
+              startElements.top.get(element) && startElements.left.get(element)
+            )
+          }
+          label="Select Table"
+          onClick={() => {
+            const path = ReactEditor.findPath(editor, element);
+            const table = Editor.above(editor, {
+              match: nodeTypeMatcher('table'),
+              at: path,
+            });
+            if (!table) return;
+            Transforms.select(editor, {
+              anchor: Editor.start(editor, table[1]),
+              focus: Editor.end(editor, table[1]),
+            });
+          }}
+        />
+      )}
+      {selectedCellsContext?.focus === element && (
+        <CellMenu cell={element} table={selectedCellsContext.table} />
+      )}
+      {children}
+    </ElementType>
+  );
+}
+
+const styles = {
+  top: {
+    top: -9,
+    left: -1,
+    width: 'calc(100% + 2px)',
+    height: 8,
+  },
+  left: {
+    top: -1,
+    left: -9,
+    width: 8,
+    height: 'calc(100% + 2px)',
+  },
+  'top-left': {
+    top: -9,
+    left: -9,
+    width: 8,
+    height: 8,
+  },
+};
+
+function CellSelection(props: {
+  location: 'top' | 'left' | 'top-left';
+  selected: boolean;
+  onClick: () => void;
+  label: string;
+}) {
+  const editor = useStaticEditor();
+  return (
+    <div contentEditable={false}>
+      <button
+        tabIndex={-1}
+        type="button"
+        className={css(
+          {
+            position: 'absolute',
+            margin: 0,
+            padding: 0,
+            background: props.selected
+              ? tokenSchema.color.scale.indigo8
+              : tokenSchema.color.scale.slate3,
+            border: `1px solid ${
+              props.selected
+                ? tokenSchema.color.alias.borderSelected
+                : tokenSchema.color.alias.borderIdle
+            }`,
+            borderBottom: props.location === 'left' ? undefined : 'none',
+            borderRight: props.location === 'top' ? undefined : 'none',
+          },
+          styles[props.location]
+        )}
+        aria-label={props.label}
+        onClick={() => {
+          ReactEditor.focus(editor);
+          props.onClick();
+        }}
+      />
+      {props.selected &&
+        (props.location === 'top' ? (
+          <div
+            className={css({
+              position: 'absolute',
+              top: -9,
+              right: -1,
+              background: tokenSchema.color.alias.borderSelected,
+              height: 8,
+              width: 1,
+              zIndex: 2,
+            })}
+          />
+        ) : (
+          <div
+            className={css({
+              position: 'absolute',
+              bottom: -1,
+              left: -9,
+              background: tokenSchema.color.alias.borderSelected,
+              height: 1,
+              width: 8,
+              zIndex: 2,
+            })}
+          />
+        ))}
+    </div>
+  );
+}
+
+export const cellActions = {
+  deleteRow: {
+    label: 'Delete row',
+    action: (editor, cellPath) => {
+      const tablePath = cellPath.slice(0, -3);
+      const table = Node.get(editor, tablePath);
+      if (table.type !== 'table') return;
+      const hasHead = table.children[0].type === 'table-head';
+      const rowPath = Path.parent(cellPath);
+      Transforms.removeNodes(editor, {
+        at:
+          hasHead && rowPath[cellPath.length - 3] === 0
+            ? Path.parent(rowPath)
+            : rowPath,
+      });
+    },
+  },
+  deleteColumn: {
+    label: 'Delete column',
+    action: (editor, path) => {
+      const cellIndex = path[path.length - 1];
+      const tablePath = path.slice(0, -3);
+      const table = Node.get(editor, tablePath);
+      if (table.type !== 'table') return;
+      Editor.withoutNormalizing(editor, () => {
+        for (const [headOrBodyIdx, headOrBody] of table.children.entries()) {
+          if (
+            headOrBody.type !== 'table-head' &&
+            headOrBody.type !== 'table-body'
+          ) {
+            continue;
+          }
+          for (const idx of headOrBody.children.keys()) {
+            Transforms.removeNodes(editor, {
+              at: [...tablePath, headOrBodyIdx, idx, cellIndex],
+            });
+          }
+        }
+      });
+    },
+  },
+  insertRowBelow: {
+    label: 'Insert row below',
+    action: (editor, path) => {
+      const tableRow = Node.get(editor, Path.parent(path));
+      const tablePath = path.slice(0, -3);
+      const table = Node.get(editor, tablePath);
+      if (tableRow.type !== 'table-row' || table.type !== 'table') {
+        return;
+      }
+      const hasHead = table.children[0].type === 'table-head';
+      const newRowPath = [
+        ...tablePath,
+        hasHead ? 1 : 0,
+        hasHead && path[path.length - 3] === 0 ? 0 : path[path.length - 2] + 1,
+      ];
+      Editor.withoutNormalizing(editor, () => {
+        Transforms.insertNodes(
+          editor,
+          {
+            type: 'table-row',
+            children: tableRow.children.map(() => cell(false)),
+          },
+          { at: newRowPath }
+        );
+        Transforms.select(editor, [...newRowPath, path[path.length - 1]]);
+      });
+    },
+  },
+  insertColumnRight: {
+    label: 'Insert column right',
+    action: (editor, path) => {
+      const newCellIndex = path[path.length - 1] + 1;
+      const tablePath = path.slice(0, -3);
+      const table = Node.get(editor, tablePath);
+      if (table.type !== 'table') return;
+      Editor.withoutNormalizing(editor, () => {
+        for (const [headOrBodyIdx, headOrBody] of table.children.entries()) {
+          if (
+            headOrBody.type !== 'table-head' &&
+            headOrBody.type !== 'table-body'
+          ) {
+            continue;
+          }
+          for (const rowIdx of headOrBody.children.keys()) {
+            Transforms.insertNodes(
+              editor,
+              cell(headOrBody.type === 'table-head'),
+              {
+                at: [...tablePath, headOrBodyIdx, rowIdx, newCellIndex],
+              }
+            );
+          }
+        }
+        Transforms.select(editor, Editor.start(editor, Path.next(path)));
+      });
+    },
+  },
+  headerRow: {
+    label: 'Header row',
+    action: (editor, path) => {
+      const tablePath = path.slice(0, -3);
+      const table = Node.get(editor, tablePath);
+      if (table.type !== 'table') return;
+      Editor.withoutNormalizing(editor, () => {
+        if (table.children[0].type === 'table-head') {
+          Transforms.moveNodes(editor, {
+            at: [...tablePath, 0, 0],
+            to: [...tablePath, 1, 0],
+          });
+          Transforms.removeNodes(editor, { at: [...tablePath, 0] });
+          return;
+        }
+        Transforms.insertNodes(
+          editor,
+          { type: 'table-head', children: [] },
+          { at: [...tablePath, 0] }
+        );
+        Transforms.moveNodes(editor, {
+          at: [...tablePath, 1, 0],
+          to: [...tablePath, 0, 0],
+        });
+      });
+    },
+  },
+} satisfies Record<
+  string,
+  { label: string; action: (editor: Editor, cellPath: Path) => void }
+>;
+
+const _cellActions: Record<
+  string,
+  { label: string; action: (editor: Editor, cellPath: Path) => void }
+> = cellActions;
+
+function CellMenu(props: {
+  cell: Element;
+  table: Element & { type: 'table' };
+}) {
+  const editor = useStaticEditor();
+  return (
+    <div contentEditable={false}>
+      <TooltipTrigger>
+        <MenuTrigger>
+          <ActionButton
+            prominence="low"
+            UNSAFE_className={css({
+              top: 4,
+              right: 4,
+              position: 'absolute !important' as any,
+            })}
+          >
+            <Icon src={chevronDownIcon} />
+          </ActionButton>
+          <Menu
+            onAction={key => {
+              if (key in _cellActions) {
+                _cellActions[key].action(
+                  editor,
+                  ReactEditor.findPath(editor, props.cell)
+                );
+              }
+            }}
+            items={Object.entries(_cellActions).map(([key, item]) => ({
+              ...item,
+              key,
+            }))}
+            selectionMode="single"
+            selectedKeys={
+              props.table.children[0].type === 'table-head' ? ['headerRow'] : []
+            }
+          >
+            {item => <Item key={item.key}>{item.label}</Item>}
+          </Menu>
+        </MenuTrigger>
+        <Tooltip>Options</Tooltip>
+      </TooltipTrigger>
+    </div>
+  );
+}
+
+const TableButton = () => {
+  const {
+    editor,
+    blockquote: { isDisabled, isSelected },
+  } = useToolbarState();
+  return useMemo(
+    () => (
+      <ActionButton
+        prominence="low"
+        isSelected={isSelected}
+        isDisabled={isDisabled}
+        onPress={() => {
+          insertTable(editor);
+          ReactEditor.focus(editor);
+        }}
+      >
+        <Icon src={tableIcon} />
+      </ActionButton>
+    ),
+    [editor, isDisabled, isSelected]
+  );
+};
+export const tableButton = (
+  <TooltipTrigger>
+    <TableButton />
+    <Tooltip>
+      <Text>Table</Text>
+    </Tooltip>
+  </TooltipTrigger>
+);
+
+export function getCellPathInDirection(
+  editor: Editor,
+  path: Path,
+  direction: 'up' | 'down' | 'left' | 'right'
+): Path | undefined {
+  if (direction === 'left' || direction === 'right') {
+    const row = Editor.above(editor, {
+      match: nodeTypeMatcher('table-row'),
+      at: path,
+    });
+    if (!row) return;
+    const currentCellIdx = path[path.length - 1];
+    const diff = direction === 'left' ? -1 : 1;
+    const nextCellIdx = currentCellIdx + diff;
+    const nextCell = row[0].children[nextCellIdx];
+    if (!nextCell) return;
+    return [...row[1], nextCellIdx];
+  }
+  const table = Editor.above(editor, {
+    match: nodeTypeMatcher('table'),
+    at: path,
+  });
+  if (!table) return;
+  const diff = direction === 'up' ? -1 : 1;
+  const rowIndex = path[path.length - 3] + path[path.length - 2];
+  const nextRowIndex = rowIndex + diff;
+  const relativeRowPath = getRelativeRowPath(
+    table[0].children[0].type === 'table-head',
+    nextRowIndex
+  );
+  if (!Node.has(table[0], relativeRowPath)) return;
+  return [...table[1], ...relativeRowPath, path[path.length - 1]];
+}
diff --git a/packages/keystatic/DocumentEditor/toolbar-state.tsx b/packages/keystatic/DocumentEditor/toolbar-state.tsx
index 51a1233d..18dc8c61 100644
--- a/packages/keystatic/DocumentEditor/toolbar-state.tsx
+++ b/packages/keystatic/DocumentEditor/toolbar-state.tsx
@@ -13,6 +13,7 @@ import { LayoutOptionsProvider } from './layouts';
 import { isListNode } from './lists';
 
 import { allMarks, isElementActive, Mark, nodeTypeMatcher } from './utils';
+import { isBlock } from '.';
 
 type BasicToolbarItem = { isSelected: boolean; isDisabled: boolean };
 
@@ -115,12 +116,13 @@ export const createToolbarState = (
         layouts: editorDocumentFeatures.layouts,
         links: true,
         images: true,
+        tables: true,
       },
       softBreaks: true,
     };
 
   let [maybeCodeBlockEntry] = Editor.nodes(editor, {
-    match: node => node.type !== 'code' && Editor.isBlock(editor, node),
+    match: node => node.type !== 'code' && isBlock(node),
   });
   const editorMarks = Editor.marks(editor) || {};
   const marks = Object.fromEntries(
diff --git a/packages/keystatic/DocumentEditor/utils.ts b/packages/keystatic/DocumentEditor/utils.ts
index b291f362..d014441b 100644
--- a/packages/keystatic/DocumentEditor/utils.ts
+++ b/packages/keystatic/DocumentEditor/utils.ts
@@ -19,6 +19,7 @@ import {
   Point,
 } from 'slate';
 import { ReactEditor, useFocused, useSelected } from 'slate-react';
+import { isBlock } from '.';
 
 export { useSlateStatic as useStaticEditor } from 'slate-react';
 
@@ -68,16 +69,16 @@ export function moveChildren(
   editor: Editor,
   parent: NodeEntry | Path,
   to: Path,
-  shouldMoveNode: (node: Node) => boolean = () => true
+  shouldMoveNode: (node: Node, index: number) => boolean = () => true
 ) {
   const parentPath = Path.isPath(parent) ? parent : parent[1];
   const parentNode = Path.isPath(parent)
     ? Node.get(editor, parentPath)
     : parent[0];
-  if (!Editor.isBlock(editor, parentNode)) return;
+  if (!isBlock(parentNode)) return;
 
   for (let i = parentNode.children.length - 1; i >= 0; i--) {
-    if (shouldMoveNode(parentNode.children[i])) {
+    if (shouldMoveNode(parentNode.children[i], i)) {
       const childPath = [...parentPath, i];
       Transforms.moveNodes(editor, { at: childPath, to });
     }
diff --git a/packages/keystatic/markdoc/from-markdoc.ts b/packages/keystatic/markdoc/from-markdoc.ts
index baa3449f..0d344d0b 100644
--- a/packages/keystatic/markdoc/from-markdoc.ts
+++ b/packages/keystatic/markdoc/from-markdoc.ts
@@ -207,7 +207,48 @@ function fromMarkdocNode(
   if (node.type === 'hr') {
     return { type: 'divider', children: [{ text: '' }] };
   }
+  if (node.type === 'table') {
+    return {
+      type: 'table',
+      children: node.children.flatMap(x => fromMarkdocNode(x, componentBlocks)),
+    };
+  }
+  if (node.type === 'tbody') {
+    return {
+      type: 'table-body',
+      children: node.children.flatMap(x => fromMarkdocNode(x, componentBlocks)),
+    };
+  }
+  if (node.type === 'thead') {
+    if (!node.children.length) return [];
+    return {
+      type: 'table-head',
+      children: node.children.flatMap(x => fromMarkdocNode(x, componentBlocks)),
+    };
+  }
+  if (node.type === 'tr') {
+    return {
+      type: 'table-row',
+      children: node.children.flatMap(x => fromMarkdocNode(x, componentBlocks)),
+    };
+  }
+  if (node.type === 'td') {
+    return {
+      type: 'table-cell',
+      children: node.children.flatMap(x => fromMarkdocNode(x, componentBlocks)),
+    };
+  }
+  if (node.type === 'th') {
+    return {
+      type: 'table-cell',
+      header: true,
+      children: node.children.flatMap(x => fromMarkdocNode(x, componentBlocks)),
+    };
+  }
   if (node.type === 'tag') {
+    if (node.tag === 'table') {
+      return fromMarkdocNode(node.children[0], componentBlocks);
+    }
     if (node.tag === 'layout') {
       return {
         type: 'layout',
diff --git a/packages/keystatic/markdoc/to-markdoc.tsx b/packages/keystatic/markdoc/to-markdoc.tsx
index 02127783..9d59267f 100644
--- a/packages/keystatic/markdoc/to-markdoc.tsx
+++ b/packages/keystatic/markdoc/to-markdoc.tsx
@@ -223,6 +223,35 @@ function toMarkdoc(
   if (node.type === 'divider') {
     return new Ast.Node('hr');
   }
+  if (node.type === 'table') {
+    const head = node.children.find(
+      (x): x is typeof x & { type: 'table-head' } => x.type === 'table-head'
+    );
+    return new Ast.Node(
+      'tag',
+      {},
+      [
+        new Ast.Node('table', {}, [
+          new Ast.Node('thead', {}, head ? head.children.map(_toMarkdoc) : []),
+          _toMarkdoc(node.children.find(x => x.type === 'table-body')!),
+        ]),
+      ],
+      'table'
+    );
+  }
+  if (node.type === 'table-body') {
+    return new Ast.Node('tbody', {}, node.children.map(_toMarkdoc));
+  }
+  if (node.type === 'table-row') {
+    return new Ast.Node('tr', {}, node.children.map(_toMarkdoc));
+  }
+  if (node.type === 'table-cell') {
+    return new Ast.Node(
+      node.header ? 'th' : 'td',
+      {},
+      node.children.map(_toMarkdoc)
+    );
+  }
   if (node.type === 'heading') {
     const markdocNode = new Ast.Node(
       'heading',
diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json
index 0d50845b..65342aef 100644
--- a/packages/keystatic/package.json
+++ b/packages/keystatic/package.json
@@ -118,10 +118,10 @@
     "pretty-format": "^29.0.1",
     "prismjs": "^1.29.0",
     "scroll-into-view-if-needed": "^3.0.3",
-    "slate": "^0.81.1",
-    "slate-history": "^0.66.0",
-    "slate-hyperscript": "^0.67.0",
-    "slate-react": "^0.81.0",
+    "slate": "^0.91.4",
+    "slate-history": "^0.86.0",
+    "slate-hyperscript": "^0.77.0",
+    "slate-react": "^0.91.9",
     "urql": "^3.0.3",
     "zod": "^3.20.2"
   },
diff --git a/packages/keystatic/src/renderer.tsx b/packages/keystatic/src/renderer.tsx
index 04e7402d..2a7e5b19 100644
--- a/packages/keystatic/src/renderer.tsx
+++ b/packages/keystatic/src/renderer.tsx
@@ -63,6 +63,10 @@ interface Renderers {
           title?: string;
         }>
       | 'img';
+    table: Component<{
+      head?: { children: ReactNode; colSpan?: number; rowSpan?: number }[];
+      body: { children: ReactNode; colSpan?: number; rowSpan?: number }[][];
+    }>;
   };
 }
 
@@ -115,6 +119,34 @@ export const defaultRenderers: Renderers = {
         </div>
       );
     },
+    table: ({ head, body }) => {
+      return (
+        <table>
+          {head && (
+            <thead>
+              <tr>
+                {head.map((x, i) => (
+                  <th key={i} colSpan={x.colSpan} rowSpan={x.rowSpan}>
+                    {x.children}
+                  </th>
+                ))}
+              </tr>
+            </thead>
+          )}
+          <tbody>
+            {body.map((row, i) => (
+              <tr key={i}>
+                {row.map((x, j) => (
+                  <td key={j} colSpan={x.colSpan} rowSpan={x.rowSpan}>
+                    {x.children}
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    },
   },
 };
 
@@ -231,6 +263,42 @@ function DocumentNode({
         />
       );
     }
+    case 'table': {
+      const first = node.children[0];
+      const second = node.children[1];
+      const body = second || first;
+      const head = second ? first : undefined;
+      return (
+        <renderers.block.table
+          head={
+            head
+              ? (head.children as Element[])[0].children.map(cell => ({
+                  children: (cell as Element).children.map((x, i) => (
+                    <DocumentNode
+                      node={x}
+                      componentBlocks={componentBlocks}
+                      renderers={renderers}
+                      key={i}
+                    />
+                  )),
+                }))
+              : undefined
+          }
+          body={(body.children as Element[]).map(row =>
+            (row.children as Element[]).map(cell => ({
+              children: (cell as Element).children.map((x, i) => (
+                <DocumentNode
+                  node={x}
+                  componentBlocks={componentBlocks}
+                  renderers={renderers}
+                  key={i}
+                />
+              )),
+            }))
+          )}
+        />
+      );
+    }
   }
   return <Fragment>{children}</Fragment>;
 }
diff --git a/packages/keystatic/structure-validation.ts b/packages/keystatic/structure-validation.ts
index 1081c8b2..63b6638a 100644
--- a/packages/keystatic/structure-validation.ts
+++ b/packages/keystatic/structure-validation.ts
@@ -86,6 +86,10 @@ const onlyChildrenElements: t.Type<OnlyChildrenElements> = t.recursion(
           t.literal('list-item-content'),
           t.literal('ordered-list'),
           t.literal('unordered-list'),
+          t.literal('table'),
+          t.literal('table-head'),
+          t.literal('table-body'),
+          t.literal('table-row'),
         ]),
         children,
       })
@@ -100,7 +104,27 @@ type OnlyChildrenElements = {
     | 'list-item'
     | 'list-item-content'
     | 'ordered-list'
-    | 'unordered-list';
+    | 'unordered-list'
+    | 'table'
+    | 'table-head'
+    | 'table-body'
+    | 'table-row';
+  children: Children;
+};
+
+const tableCell: t.Type<TableCell> = t.recursion('TableCell', () =>
+  excess(
+    t.type({
+      type: t.literal('table-cell'),
+      header: t.union([t.undefined, t.literal(true)]),
+      children,
+    })
+  )
+);
+
+type TableCell = {
+  type: 'table-cell';
+  header: true | undefined;
   children: Children;
 };
 
@@ -247,7 +271,8 @@ export type BlockFromValidation =
   | ComponentProp
   | Paragraph
   | CodeBlock
-  | Image;
+  | Image
+  | TableCell;
 
 const block: t.Type<BlockFromValidation> = t.recursion('Element', () =>
   t.union([
@@ -259,6 +284,7 @@ const block: t.Type<BlockFromValidation> = t.recursion('Element', () =>
     paragraph,
     codeBlock,
     image,
+    tableCell,
   ])
 );
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 56acd53c..a338557a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2019,10 +2019,10 @@ importers:
       react: ^18.2.0
       react-dom: ^18.2.0
       scroll-into-view-if-needed: ^3.0.3
-      slate: ^0.81.1
-      slate-history: ^0.66.0
-      slate-hyperscript: ^0.67.0
-      slate-react: ^0.81.0
+      slate: ^0.91.4
+      slate-history: ^0.86.0
+      slate-hyperscript: ^0.77.0
+      slate-react: ^0.91.9
       tsx: ^3.8.0
       typescript: ^4.7.4
       urql: ^3.0.3
@@ -2105,10 +2105,10 @@ importers:
       pretty-format: 29.4.3
       prismjs: 1.29.0
       scroll-into-view-if-needed: 3.0.6
-      slate: 0.81.3
-      slate-history: 0.66.0_slate@0.81.3
-      slate-hyperscript: 0.67.0_slate@0.81.3
-      slate-react: 0.81.0_njwfekudbehx2lwpmvgkrdxmhi
+      slate: 0.91.4
+      slate-history: 0.86.0_slate@0.91.4
+      slate-hyperscript: 0.77.0_slate@0.91.4
+      slate-react: 0.91.9_6tgy34rvmll7duwkm4ydcekf3u
       urql: 3.0.3_onqnqwb3ubg5opvemcqf7c2qhy
       zod: 3.20.6
     devDependencies:
@@ -5175,6 +5175,10 @@ packages:
       '@jridgewell/resolve-uri': 3.1.0
       '@jridgewell/sourcemap-codec': 1.4.14
 
+  /@juggle/resize-observer/3.4.0:
+    resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
+    dev: false
+
   /@leichtgewicht/ip-codec/2.0.4:
     resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==}
     dev: true
@@ -20775,31 +20779,32 @@ packages:
     resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==}
     engines: {node: '>=12'}
 
-  /slate-history/0.66.0_slate@0.81.3:
-    resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
+  /slate-history/0.86.0_slate@0.91.4:
+    resolution: {integrity: sha512-OxObL9tbhgwvSlnKSCpGIh7wnuaqvOj5jRExGjEyCU2Ke8ctf22HjT+jw7GEi9ttLzNTUmTEU3YIzqKGeqN+og==}
     peerDependencies:
       slate: '>=0.65.3'
     dependencies:
       is-plain-object: 5.0.0
-      slate: 0.81.3
+      slate: 0.91.4
     dev: false
 
-  /slate-hyperscript/0.67.0_slate@0.81.3:
-    resolution: {integrity: sha512-3c+d2ePTUk5J7wMjs2CZIJPhb1/VnTltel+qnQarOWlXtJng7oRc8BNaYbNfkl2ecT9W5bpxpsy1r6x5ICXucQ==}
+  /slate-hyperscript/0.77.0_slate@0.91.4:
+    resolution: {integrity: sha512-M6uRpttwKnosniQORNPYQABHQ9XWC7qaSr/127LWWPjTOR5MSSwrHGrghN81BhZVqpICHrI7jkPA2813cWdHNA==}
     peerDependencies:
       slate: '>=0.65.3'
     dependencies:
       is-plain-object: 5.0.0
-      slate: 0.81.3
+      slate: 0.91.4
     dev: false
 
-  /slate-react/0.81.0_njwfekudbehx2lwpmvgkrdxmhi:
-    resolution: {integrity: sha512-bwryad4EvOmc7EFKb8aGg9DWNDh3KvToaggGieIgGTTbHJYHc9ADFC3A87Ittlpd5XUVopR0MpChQ3g3ODyvqw==}
+  /slate-react/0.91.9_6tgy34rvmll7duwkm4ydcekf3u:
+    resolution: {integrity: sha512-e3DvmZuUEKKrIB8Kb4RmJ+qzIL2IVJjN2NM/IHRi8W5MmjAtd8yhv46Ewz1sCf0F7uRODS5xvrQsJJAftQhH4Q==}
     peerDependencies:
       react: '>=16.8.0'
       react-dom: '>=16.8.0'
       slate: '>=0.65.3'
     dependencies:
+      '@juggle/resize-observer': 3.4.0
       '@types/is-hotkey': 0.1.7
       '@types/lodash': 4.14.191
       direction: 1.0.4
@@ -20809,12 +20814,12 @@ packages:
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
       scroll-into-view-if-needed: 2.2.31
-      slate: 0.81.3
+      slate: 0.91.4
       tiny-invariant: 1.0.6
     dev: false
 
-  /slate/0.81.3:
-    resolution: {integrity: sha512-9rME3rSOsR76BsTGezNp6kiwjJL4yVJLwVQawj9xUf8wEZqoPU8d38hyQ3Jeg1SG6me2aS43m6H+n163/BhGWw==}
+  /slate/0.91.4:
+    resolution: {integrity: sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==}
     dependencies:
       immer: 9.0.19
       is-plain-object: 5.0.0


Test patch:
diff --git a/packages/keystatic/DocumentEditor/table.test.tsx b/packages/keystatic/DocumentEditor/table.test.tsx
new file mode 100644
index 00000000..df7a9f6e
--- /dev/null
+++ b/packages/keystatic/DocumentEditor/table.test.tsx
@@ -0,0 +1,652 @@
+/** @jest-environment jsdom */
+/** @jsxRuntime classic */
+/** @jsx jsx */
+import { Editor, Node, Transforms } from 'slate';
+import { cellActions, insertTable } from './table';
+import { jsx, makeEditor } from './tests/utils';
+import { nodeTypeMatcher } from './utils';
+
+const table = (
+  options: Parameters<typeof makeEditor>[1] | Node[],
+  ..._rows: Node[][]
+) => {
+  const rows = [...(Array.isArray(options) ? [options] : []), ..._rows];
+  let head: undefined | Node;
+  if (rows[0][0].type === 'table-cell' && rows[0][0].header) {
+    const row = rows.shift()!;
+    head = (
+      <table-head>
+        <table-row>{row}</table-row>
+      </table-head>
+    );
+  }
+  let body = (
+    <table-body>
+      {rows.map(row => (
+        <table-row>{row}</table-row>
+      ))}
+    </table-body>
+  );
+  return makeEditor(
+    <editor>
+      {head ? (
+        <table>
+          {head}
+          {body}
+        </table>
+      ) : (
+        <table>{body}</table>
+      )}
+      <paragraph>
+        <text />
+      </paragraph>
+    </editor>,
+    Array.isArray(options) ? undefined : options
+  );
+};
+
+const cell = (content: string | Node, header?: true) => (
+  <table-cell header={header}>
+    <paragraph>
+      {typeof content === 'string' ? <text>{content}</text> : content}
+    </paragraph>
+  </table-cell>
+);
+
+const cellWithCursor = (content: string) =>
+  cell(
+    <text>
+      <cursor />
+      {content}
+    </text>
+  );
+
+const cellWithAnchor = (content: string) =>
+  cell(
+    <text>
+      <anchor />
+      {content}
+    </text>
+  );
+
+const cellWithFocus = (content: string) =>
+  cell(
+    <text>
+      <focus />
+      {content}
+    </text>
+  );
+
+const header = (content: string | Node) => cell(content, true);
+
+const headerWithCursor = (content: string) =>
+  header(
+    <text>
+      <cursor />
+      {content}
+    </text>
+  );
+
+const headerWithAnchor = (content: string) =>
+  header(
+    <text>
+      <anchor />
+      {content}
+    </text>
+  );
+
+const headerWithFocus = (content: string) =>
+  header(
+    <text>
+      <focus />
+      {content}
+    </text>
+  );
+
+test('delete at start does nothing', () => {
+  let editor = table([cellWithCursor('some content')]);
+  const initialEditor = makeEditor(editor);
+  editor.deleteBackward('character');
+  expect(editor).toEqualEditor(initialEditor);
+});
+
+const cellPathAtCursor = (editor: Editor) =>
+  Editor.above(editor, {
+    match: nodeTypeMatcher('table-cell'),
+  })![1];
+
+test('delete in second cell does nothing', () => {
+  let editor = table([cell('1a'), cellWithCursor('1b')]);
+  const initialEditor = makeEditor(editor);
+  editor.deleteBackward('character');
+  expect(editor).toEqualEditor(initialEditor);
+});
+
+test('insert row below', () => {
+  let editor = table([cell('1a'), cellWithCursor('1b')]);
+  cellActions.insertRowBelow.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table([cell('1a'), cell('1b')], [cell(''), cellWithCursor('')])
+  );
+});
+
+test('insert row below header', () => {
+  let editor = table(
+    [header('1a'), headerWithCursor('1b')],
+    [cell('2a'), cell('2b')]
+  );
+  cellActions.insertRowBelow.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [header('1a'), header('1b')],
+      [cell(''), cellWithCursor('')],
+      [cell('2a'), cell('2b')]
+    )
+  );
+});
+
+test('insert column with header', () => {
+  let editor = table(
+    [header('1a'), headerWithCursor('1b')],
+    [cell('2a'), cell('2b')]
+  );
+  cellActions.insertColumnRight.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [header('1a'), header('1b'), headerWithCursor('')],
+      [cell('2a'), cell('2b'), cell('')]
+    )
+  );
+});
+
+test('insert column right', () => {
+  let editor = table([cell('1a'), cellWithCursor('1b')]);
+  cellActions.insertColumnRight.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table([cell('1a'), cell('1b'), cellWithCursor('')])
+  );
+});
+test('insert column and row', () => {
+  let editor = table([cell('1a'), cellWithCursor('1b')]);
+  cellActions.insertRowBelow.action(editor, cellPathAtCursor(editor));
+  cellActions.insertColumnRight.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [cell('1a'), cell('1b'), cell('')],
+      [cell(''), cell(''), cellWithCursor('')]
+    )
+  );
+});
+
+test('delete row', () => {
+  const editor = table(
+    [cell('1a'), cellWithCursor('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  cellActions.deleteRow.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithCursor('2a'), cell('2b'), cell('2c')],
+      [cell('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete column', () => {
+  const editor = table(
+    [cell('1a'), cellWithCursor('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  cellActions.deleteColumn.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [cell('1a'), cellWithCursor('1c')],
+      [cell('2a'), cell('2c')],
+      [cell('3a'), cell('3c')]
+    )
+  );
+});
+
+test('delete header row', () => {
+  const editor = table(
+    [header('1a'), headerWithCursor('1b'), header('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  cellActions.deleteRow.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithCursor('2a'), cell('2b'), cell('2c')],
+      [cell('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete non-header row in table with header row', () => {
+  const editor = table(
+    [header('1a'), header('1b'), header('1c')],
+    [cell('2a'), cellWithCursor('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  cellActions.deleteRow.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [header('1a'), header('1b'), header('1c')],
+      [cellWithCursor('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete column with header row', () => {
+  const editor = table(
+    [header('1a'), headerWithCursor('1b'), header('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  cellActions.deleteColumn.action(editor, cellPathAtCursor(editor));
+  expect(editor).toEqualEditor(
+    table(
+      [header('1a'), headerWithCursor('1c')],
+      [cell('2a'), cell('2c')],
+      [cell('3a'), cell('3c')]
+    )
+  );
+});
+
+test('normalize to the same number of cells in a row', () => {
+  const editor = table(
+    { normalization: 'normalize' },
+    [cell('1a'), cell('1b'), cell('1c')],
+    [cell('2a'), cell('2b')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  expect(editor).toEqualEditor(
+    table(
+      [cell('1a'), cell('1b'), cell('1c')],
+      [cell('2a'), cell('2b'), cell('')],
+      [cell('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete whole table', () => {
+  const editor = table(
+    [cellWithAnchor('1a'), cell('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    makeEditor(
+      <editor>
+        <paragraph>
+          <text>
+            <cursor />
+          </text>
+        </paragraph>
+      </editor>
+    )
+  );
+});
+
+test('delete rows', () => {
+  const editor = table(
+    [cell('1a'), cell('1b'), cell('1c')],
+    [cellWithAnchor('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table([
+      cell('1a'),
+      cell('1b'),
+      cell(
+        <text>
+          1c
+          <cursor />
+        </text>
+      ),
+    ])
+  );
+});
+
+test('delete header row with deleteFragment', () => {
+  const editor = table(
+    [headerWithAnchor('1a'), header('1b'), headerWithFocus('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithCursor('2a'), cell('2b'), cell('2c')],
+      [cell('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete rows including header row', () => {
+  const editor = table(
+    [cellWithAnchor('1a'), cell('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cellWithFocus('2c')],
+    [cell('3a'), cell('3b'), cell('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table([cellWithCursor('3a'), cell('3b'), cell('3c')])
+  );
+});
+
+test('delete middle column', () => {
+  const editor = table(
+    [cell('1a'), cellWithAnchor('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cellWithFocus('3b'), cell('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithCursor('1a'), cell('1c')],
+      [cell('2a'), cell('2c')],
+      [cell('3a'), cell('3c')]
+    )
+  );
+});
+
+test('delete first column', () => {
+  const editor = table(
+    [cellWithAnchor('1a'), cell('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cellWithFocus('3a'), cell('3b'), cell('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithCursor('1b'), cell('1c')],
+      [cell('2b'), cell('2c')],
+      [cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete last column', () => {
+  const editor = table(
+    [cell('1a'), cell('1b'), cellWithAnchor('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [cell('1a'), cellWithCursor('1b')],
+      [cell('2a'), cell('2b')],
+      [cell('3a'), cell('3b')]
+    )
+  );
+});
+
+test('delete two columns', () => {
+  const editor = table(
+    [cell('1a'), cellWithAnchor('1b'), cell('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table([cellWithCursor('1a')], [cell('2a')], [cell('3a')])
+  );
+});
+
+test('delete middle column with head', () => {
+  const editor = table(
+    [header('1a'), headerWithAnchor('1b'), header('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cellWithFocus('3b'), cell('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [headerWithCursor('1a'), header('1c')],
+      [cell('2a'), cell('2c')],
+      [cell('3a'), cell('3c')]
+    )
+  );
+});
+
+test('delete first column with head', () => {
+  const editor = table(
+    [headerWithAnchor('1a'), header('1b'), header('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cellWithFocus('3a'), cell('3b'), cell('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [headerWithCursor('1b'), header('1c')],
+      [cell('2b'), cell('2c')],
+      [cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('delete last column with head', () => {
+  const editor = table(
+    [header('1a'), header('1b'), headerWithAnchor('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [header('1a'), headerWithCursor('1b')],
+      [cell('2a'), cell('2b')],
+      [cell('3a'), cell('3b')]
+    )
+  );
+});
+
+test('delete two columns with head', () => {
+  const editor = table(
+    [header('1a'), headerWithAnchor('1b'), header('1c')],
+    [cell('2a'), cell('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table([headerWithCursor('1a')], [cell('2a')], [cell('3a')])
+  );
+});
+
+test('clear cells', () => {
+  const editor = table(
+    [cell('1a'), cell('1b'), cell('1c')],
+    [cell('2a'), cellWithAnchor('2b'), cell('2c')],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [cell('1a'), cell('1b'), cell('1c')],
+      [cell('2a'), cellWithCursor(''), cell('')],
+      [cell('3a'), cell(''), cell('')]
+    )
+  );
+});
+
+test('clear cells with multiple children in cell', () => {
+  const editor = table(
+    [cell('1a'), cell('1b'), cell('1c')],
+    [
+      cell('2a'),
+      cellWithAnchor('2b'),
+      <table-cell>
+        <paragraph>
+          <text>a</text>
+        </paragraph>
+        <paragraph>
+          <text>b</text>
+        </paragraph>
+      </table-cell>,
+    ],
+    [cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  editor.deleteFragment();
+  expect(editor).toEqualEditor(
+    table(
+      [cell('1a'), cell('1b'), cell('1c')],
+      [cell('2a'), cellWithCursor(''), cell('')],
+      [cell('3a'), cell(''), cell('')]
+    )
+  );
+});
+
+test('insert table', () => {
+  const editor = makeEditor(
+    <editor>
+      <paragraph>
+        <text>
+          <cursor />
+        </text>
+      </paragraph>
+    </editor>
+  );
+  insertTable(editor);
+  expect(editor).toMatchInlineSnapshot(`
+    <editor>
+      <paragraph>
+        <text />
+      </paragraph>
+      <table>
+        <table-head>
+          <table-row>
+            <table-cell
+              header={true}
+            >
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+            <table-cell
+              header={true}
+            >
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+            <table-cell
+              header={true}
+            >
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+          </table-row>
+        </table-head>
+        <table-body>
+          <table-row>
+            <table-cell>
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+            <table-cell>
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+            <table-cell>
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+          </table-row>
+          <table-row>
+            <table-cell>
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+            <table-cell>
+              <paragraph>
+                <text />
+              </paragraph>
+            </table-cell>
+            <table-cell>
+              <paragraph>
+                <text>
+                  <cursor />
+                </text>
+              </paragraph>
+            </table-cell>
+          </table-row>
+        </table-body>
+      </table>
+      <paragraph>
+        <text />
+      </paragraph>
+    </editor>
+  `);
+});
+
+test('copy paste with expanded selection', () => {
+  const editor = table(
+    [cell(''), cell(''), cell('1a'), cell('1b'), cell('1c')],
+    [cell(''), cell(''), cell('2a'), cellWithAnchor('2b'), cell('2c')],
+    [cell(''), cell(''), cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  const fragment = editor.getFragment();
+  Transforms.setSelection(editor, {
+    anchor: { path: [0, 0, 0, 0, 0, 0], offset: 0 },
+    focus: { path: [0, 0, 1, 1, 0, 0], offset: 0 },
+  });
+  editor.insertFragment(fragment);
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithAnchor('2b'), cell('2c'), cell('1a'), cell('1b'), cell('1c')],
+      [
+        cell('3b'),
+        cell(
+          <text>
+            3c
+            <focus />
+          </text>
+        ),
+        cell('2a'),
+        cell('2b'),
+        cell('2c'),
+      ],
+      [cell(''), cell(''), cell('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
+
+test('copy paste with collapsed selection', () => {
+  const editor = table(
+    [cell(''), cell(''), cell('1a'), cell('1b'), cell('1c')],
+    [cell(''), cell(''), cell('2a'), cellWithAnchor('2b'), cell('2c')],
+    [cell(''), cell(''), cell('3a'), cell('3b'), cellWithFocus('3c')]
+  );
+  const fragment = editor.getFragment();
+  Transforms.select(editor, [0, 0, 0, 0, 0, 0]);
+  editor.insertFragment(fragment);
+  expect(editor).toEqualEditor(
+    table(
+      [cellWithAnchor('2b'), cell('2c'), cell('1a'), cell('1b'), cell('1c')],
+      [
+        cell('3b'),
+        cell(
+          <text>
+            3c
+            <focus />
+          </text>
+        ),
+        cell('2a'),
+        cell('2b'),
+        cell('2c'),
+      ],
+      [cell(''), cell(''), cell('3a'), cell('3b'), cell('3c')]
+    )
+  );
+});
diff --git a/packages/keystatic/DocumentEditor/tests/jsx/impl.ts b/packages/keystatic/DocumentEditor/tests/jsx/impl.ts
index f9c2e7ad..29736f98 100644
--- a/packages/keystatic/DocumentEditor/tests/jsx/impl.ts
+++ b/packages/keystatic/DocumentEditor/tests/jsx/impl.ts
@@ -1,5 +1,4 @@
 // slate-hyperscript depends on Array.prototype.flat
-import 'array.prototype.flat/auto';
 import { createHyperscript } from 'slate-hyperscript';
 
 import { editorSchema } from '../../index';
diff --git a/packages/keystatic/DocumentEditor/tests/jsx/namespace.d.ts b/packages/keystatic/DocumentEditor/tests/jsx/namespace.d.ts
index d0db2ba3..ed7cda2f 100644
--- a/packages/keystatic/DocumentEditor/tests/jsx/namespace.d.ts
+++ b/packages/keystatic/DocumentEditor/tests/jsx/namespace.d.ts
@@ -1,17 +1,19 @@
-import { Node } from 'slate';
+import { Element, Node } from 'slate';
 import { Mark } from '../../utils';
-import { ReadonlyPropPath } from '../../component-blocks/utils';
 
 export const __jsx: any;
 
 type Children = Node | string | (Node | string)[];
 
-type OnlyChildren = { children: Children };
-
-type ComponentProp = { children: Children; propPath?: ReadonlyPropPath };
+type Elements = {
+  [Key in Element['type']]: Omit<
+    Element & { type: Key },
+    'type' | 'children'
+  > & { children: Children };
+};
 declare namespace __jsx {
   namespace JSX {
-    interface IntrinsicElements {
+    type IntrinsicElements = Elements & {
       editor: {
         children: Children;
         marks?: { [Key in Mark | 'insertMenu']?: true };
@@ -21,36 +23,7 @@ declare namespace __jsx {
       cursor: { [key: string]: never };
       anchor: { [key: string]: never };
       focus: { [key: string]: never };
-      layout: { layout: [number, ...number[]]; children: Children };
-      'layout-area': OnlyChildren;
-      blockquote: OnlyChildren;
-      paragraph: {
-        textAlign?: 'center' | 'end';
-        children: Children;
-      };
-      code: OnlyChildren;
-      divider: OnlyChildren;
-      heading: {
-        level: 1 | 2 | 3 | 4 | 5 | 6;
-        children: Children;
-        textAlign?: 'center' | 'end';
-      };
-      'component-block': {
-        component: string;
-        props: Record<string, any>;
-        children: Children;
-      };
-      'component-inline-prop': ComponentProp;
-      'component-block-prop': ComponentProp;
-      'ordered-list': OnlyChildren;
-      'unordered-list': OnlyChildren;
-      'list-item': OnlyChildren;
-      'list-item-content': OnlyChildren;
-      link: {
-        href: string;
-        children: Children;
-      };
-    }
+    };
     type Element = Node;
     interface ElementAttributesProperty {
       props: {};
diff --git a/packages/keystatic/DocumentEditor/tests/utils.tsx b/packages/keystatic/DocumentEditor/tests/utils.tsx
index f1ca2d0e..f00e5a94 100644
--- a/packages/keystatic/DocumentEditor/tests/utils.tsx
+++ b/packages/keystatic/DocumentEditor/tests/utils.tsx
@@ -158,6 +158,7 @@ export const defaultDocumentFeatures: DocumentFeatures = {
   links: true,
   layouts: [[1], [1, 1], [1, 1, 1], [1, 2, 1]],
   images: true,
+  tables: true,
 };
 
 injectVoussoirStyles();
diff --git a/packages/keystatic/markdoc/find-children.test.ts b/packages/keystatic/markdoc/find-children.test.ts
index 57d7a933..18dd0bf8 100644
--- a/packages/keystatic/markdoc/find-children.test.ts
+++ b/packages/keystatic/markdoc/find-children.test.ts
@@ -13,9 +13,11 @@ test('one child field', () => {
       "options": {
         "dividers": undefined,
         "formatting": undefined,
+        "images": undefined,
         "kind": "block",
         "links": undefined,
         "placeholder": "a",
+        "tables": undefined,
       },
       "relativePath": [
         "a",
@@ -36,9 +38,11 @@ test('nested one child field', () => {
       "options": {
         "dividers": undefined,
         "formatting": undefined,
+        "images": undefined,
         "kind": "block",
         "links": undefined,
         "placeholder": "a",
+        "tables": undefined,
       },
       "relativePath": [
         "a",