Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/table-unmapped-click-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@platejs/table": patch
---

Fix table-area clicks on unmapped editor regions losing selection and breaking typing or paste.
20 changes: 17 additions & 3 deletions .github/workflows/changeset-auto-release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Changeset Auto Release Checkbox

on:
pull_request:
types: [opened, reopened, synchronize, edited]
pull_request_target:
types: [opened, reopened, synchronize, edited]

Expand All @@ -12,13 +14,25 @@ jobs:
sync-auto-release-checkbox:
name: Sync auto-release checkbox
runs-on: ubuntu-latest
if: ${{ github.repository == 'udecode/plate' }}
if: >-
github.repository == 'udecode/plate' &&
(
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository)
)

steps:
- name: 📥 Checkout base
- name: 📥 Checkout workflow helper
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
ref: >-
${{
github.event_name == 'pull_request'
&& github.event.pull_request.head.sha
|| github.event.repository.default_branch
}}
persist-credentials: false

- name: 🔁 Sync auto-release checkbox
Expand Down
2 changes: 1 addition & 1 deletion apps/www/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep next-env route import stable across dev/build modes

This import was changed to ./.next/dev/types/routes.d.ts, which is a dev-mode output path. In build/typegen flows Next writes route types under ./.next/types/routes.d.ts, so this committed value becomes mode-dependent and gets rewritten in non-dev workflows, creating deterministic churn and potentially pointing at a missing route-types file when only build artifacts are present.

Useful? React with 👍 / 👎.


// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
51 changes: 51 additions & 0 deletions docs/plans/4956-table-unmapped-dom-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 4956 Table Unmapped DOM Selection

## Goal

Fix table/editor clicks on unmapped contentEditable regions so typing or paste cannot desync DOM from Slate or crash `toSlateRange`.

## Source

- GitHub issue: https://github.com/udecode/plate/issues/4956
- Discussion: https://github.com/udecode/plate/discussions/4952
- Type: bug
- Browser surface: yes, `/docs/table`

## Acceptance

- Clicking editor padding/unmapped regions with table content does not leave a stale invalid interaction path.
- Targeted regression coverage proves the selection recovery contract.
- Browser check proves `/docs/table` no longer throws in the reported flow.

## Plan

- [x] Fetch issue, discussion, comments, and prior local learnings.
- [x] Classify scope and release requirement.
- [x] Add failing regression coverage for unmapped table click handling.
- [x] Implement the highest-leverage fix.
- [x] Run package verification and lint.
- [x] Run browser verification on `/docs/table`.
- [x] Create changeset.
- [x] Run PR gate.
- [ ] Create PR and issue comment.

## Findings

- The issue is real table/browser behavior: root/padding/gap clicks can leave `editor.selection` stale, then typing or rich paste can hit an unmapped DOM point.
- Discussion says suppressing `toSlateRange` alone is insufficient because native text can still render outside the Slate value.
- Prior learning: DOM selection stack traces can hide ownership bugs; here the linked discussion and current table normalizer point to missing mapped targets around tables plus a root-click guard.
- `docs/solutions/patterns/critical-patterns.md` does not exist in this checkout.
- Published package code is likely changing under `packages/`; a changeset is required.
- Forcing paragraphs around tables is too broad: it shifts path-targeted table operations. The fix guards unmapped root clicks by selecting the closest editable child point.

## Progress

- Loaded `task`, `planning-with-files`, `tdd`, `learnings-researcher`, and `dev-browser`.
- Created branch `codex/4956-table-unmapped-dom-selection` from current `main`.
- Red: `bun test packages/table/src/lib/withNormalizeTable.spec.tsx packages/table/src/react/onMouseDownTable.spec.tsx` failed on missing `onMouseDownTable`.
- Full table suite exposed adjacent-table normalization as path-breaking; removed that approach.
- Green: `bun test packages/table/src` passed.
- Package gate passed: `pnpm install`, `pnpm turbo build --filter=./packages/table`, `pnpm turbo typecheck --filter=./packages/table`, `pnpm lint:fix`.
- Browser gate passed on `http://localhost:3000/docs/table`: root click target was `data-slate-node="value"`, selection moved to a Slate text node, typed text stayed in the table, and no console/page errors fired. Screenshot: `/Users/zbeyens/.dev-browser/tmp/plate-4956-table-unmapped-click-fixed.png`.
- PR gate passed: `pnpm check`.
- Compounded the reusable learning in `docs/solutions/ui-bugs/2026-04-25-table-root-clicks-must-recover-selection.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: Table root clicks must recover selection
date: 2026-04-25
category: ui-bugs
module: packages/table
problem_type: ui_bug
component: documentation
symptoms:
- Clicking an unmapped editor root region near a table can leave browser selection outside Slate text.
- Typing or pasting after the click can throw `Cannot resolve a Slate point from DOM point`.
- Text can render outside the Plate value when the native browser edit is not intercepted.
root_cause: logic_error
resolution_type: code_fix
severity: high
tags:
- plate
- slate
- table
- selection
- dom-selection
- root-click
- toslaterange
---

# Table root clicks must recover selection

## Problem

On `/docs/table`, clicking editor padding or another unmapped root region near a table could move the browser selection to the contentEditable root instead of a Slate text node. The next typed character or paste could then run through Slate React with a DOM point that has no Slate point.

## Symptoms

- Browser console error: `Cannot resolve a Slate point from DOM point`.
- The clicked target can be the editor root with `data-slate-node="value"`, not a mapped descendant.
- Typed text can appear outside the editor value if the native browser edit is allowed to proceed.

## What Didn't Work

- Adding paragraph normalization around adjacent tables was too broad. It shifted path-targeted table transforms, including insert and margin operations that intentionally address a specific top-level table path.
- Treating any ancestor with `data-slate-node` as mapped was wrong. `data-slate-node="value"` identifies the editor root, not a selectable Slate descendant.

## Solution

Handle table editor `mousedown` events on unmapped root regions. If the click is inside the editable, not on a mapped Slate descendant, and the editor contains a table, prevent the native edit path and select the closest valid Slate point.

```ts
const hasMappedSlateTarget = (target: EventTarget | null) => {
const slateNode = getTargetElement(target)?.closest('[data-slate-node]');

return !!slateNode && slateNode.getAttribute('data-slate-node') !== 'value';
};
```

The handler then chooses the nearest top-level child by vertical click position and selects either that child start or end point.

## Why This Works

Slate React can only translate DOM points that belong to mapped Slate nodes. Root-level contentEditable clicks can still produce a DOM target inside the editor, but the root is not enough information to resolve a text point. Intercepting those clicks before the browser edits the DOM keeps selection inside Slate and makes typing or paste use the editor transforms.

## Prevention

- Do not count `data-slate-node="value"` as a mapped Slate target in DOM event guards.
- Prefer explicit selection recovery for root/unmapped clicks over structural normalization that changes document paths.
- Add browser proof for DOM selection bugs. Unit tests catch the guard, but only the browser proves native typing stays inside the editor value.

## Related Issues

- GitHub issue: https://github.com/udecode/plate/issues/4956
- Related learning: `docs/solutions/ui-bugs/2026-03-30-docs-demos-must-clone-reusable-values-per-editor.md`
2 changes: 2 additions & 0 deletions packages/table/src/react/TablePlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BaseTableRowPlugin,
} from '../lib/BaseTablePlugin';
import { onKeyDownTable } from './onKeyDownTable';
import { onMouseDownTable } from './onMouseDownTable';

export const TableRowPlugin = toPlatePlugin(BaseTableRowPlugin);

Expand All @@ -18,6 +19,7 @@ export const TableCellHeaderPlugin = toPlatePlugin(BaseTableCellHeaderPlugin);
export const TablePlugin = toPlatePlugin(BaseTablePlugin, {
handlers: {
onKeyDown: onKeyDownTable,
onMouseDown: onMouseDownTable,
},
plugins: [TableRowPlugin, TableCellPlugin, TableCellHeaderPlugin],
});
127 changes: 127 additions & 0 deletions packages/table/src/react/onMouseDownTable.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/** @jsx jsxt */

import { type SlateEditor, createSlateEditor } from 'platejs';

import { jsxt } from '@platejs/test-utils';

import { getTestTablePlugins } from '../lib/__tests__/getTestTablePlugins';
import { onMouseDownTable } from './onMouseDownTable';

jsxt;

const createTableEditor = (input: SlateEditor) =>
createSlateEditor({
nodeId: true,
plugins: getTestTablePlugins(),
selection: input.selection,
value: input.children,
});

const setRect = (element: HTMLElement, rect: Partial<DOMRect>) => {
element.getBoundingClientRect = () =>
({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
toJSON: () => rect,
...rect,
}) as DOMRect;
};

describe('onMouseDownTable', () => {
it('selects a Slate point when a click lands on the editable root beside a table', () => {
const input = (
<editor>
<htable>
<htr>
<htd>
<hp>11</hp>
</htd>
</htr>
</htable>
<hp>after</hp>
</editor>
) as any as SlateEditor;

const editor = createTableEditor(input);
const editable = document.createElement('div');
const tableDom = document.createElement('table');
const paragraphDom = document.createElement('p');
const preventDefault = mock();
const stopPropagation = mock();

editable.setAttribute('data-slate-editor', 'true');
editable.setAttribute('data-slate-node', 'value');
editable.append(tableDom, paragraphDom);
setRect(tableDom, { bottom: 40, top: 0 });
setRect(paragraphDom, { bottom: 80, top: 41 });

spyOn(editor.api, 'toDOMNode').mockImplementation((node) =>
node === editor.children[0] ? tableDom : paragraphDom
);

onMouseDownTable({
editor,
event: {
button: 0,
clientY: 30,
currentTarget: editable,
defaultPrevented: false,
preventDefault,
stopPropagation,
target: editable,
},
} as any);

expect(preventDefault).toHaveBeenCalledTimes(1);
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(editor.selection).toEqual({
anchor: { offset: 2, path: [0, 0, 0, 0, 0] },
focus: { offset: 2, path: [0, 0, 0, 0, 0] },
});
});

it('ignores clicks that already target a mapped Slate node', () => {
const input = (
<editor>
<htable>
<htr>
<htd>
<hp>11</hp>
</htd>
</htr>
</htable>
</editor>
) as any as SlateEditor;

const editor = createTableEditor(input);
const editable = document.createElement('div');
const mappedNode = document.createElement('div');
const preventDefault = mock();

editable.setAttribute('data-slate-editor', 'true');
mappedNode.setAttribute('data-slate-node', 'element');
editable.append(mappedNode);

onMouseDownTable({
editor,
event: {
button: 0,
clientY: 20,
currentTarget: editable,
defaultPrevented: false,
preventDefault,
stopPropagation: mock(),
target: mappedNode,
},
} as any);

expect(preventDefault).not.toHaveBeenCalled();
expect(editor.selection).toEqual(input.selection);
});
});
Loading
Loading