diff --git a/.changeset/table-unmapped-click-selection.md b/.changeset/table-unmapped-click-selection.md new file mode 100644 index 0000000000..90837897e6 --- /dev/null +++ b/.changeset/table-unmapped-click-selection.md @@ -0,0 +1,5 @@ +--- +"@platejs/table": patch +--- + +Fix table-area clicks on unmapped editor regions losing selection and breaking typing or paste. diff --git a/.github/workflows/changeset-auto-release.yml b/.github/workflows/changeset-auto-release.yml index 3db29555f1..61fe888f48 100644 --- a/.github/workflows/changeset-auto-release.yml +++ b/.github/workflows/changeset-auto-release.yml @@ -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] @@ -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 diff --git a/apps/www/next-env.d.ts b/apps/www/next-env.d.ts index 2d5420ebae..0c7fad710c 100644 --- a/apps/www/next-env.d.ts +++ b/apps/www/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/docs/plans/4956-table-unmapped-dom-selection.md b/docs/plans/4956-table-unmapped-dom-selection.md new file mode 100644 index 0000000000..4c880e6031 --- /dev/null +++ b/docs/plans/4956-table-unmapped-dom-selection.md @@ -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`. diff --git a/docs/solutions/ui-bugs/2026-04-25-table-root-clicks-must-recover-selection.md b/docs/solutions/ui-bugs/2026-04-25-table-root-clicks-must-recover-selection.md new file mode 100644 index 0000000000..2d7657dbfc --- /dev/null +++ b/docs/solutions/ui-bugs/2026-04-25-table-root-clicks-must-recover-selection.md @@ -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` diff --git a/packages/table/src/react/TablePlugin.tsx b/packages/table/src/react/TablePlugin.tsx index f82c42658c..b30dc6feec 100644 --- a/packages/table/src/react/TablePlugin.tsx +++ b/packages/table/src/react/TablePlugin.tsx @@ -7,6 +7,7 @@ import { BaseTableRowPlugin, } from '../lib/BaseTablePlugin'; import { onKeyDownTable } from './onKeyDownTable'; +import { onMouseDownTable } from './onMouseDownTable'; export const TableRowPlugin = toPlatePlugin(BaseTableRowPlugin); @@ -18,6 +19,7 @@ export const TableCellHeaderPlugin = toPlatePlugin(BaseTableCellHeaderPlugin); export const TablePlugin = toPlatePlugin(BaseTablePlugin, { handlers: { onKeyDown: onKeyDownTable, + onMouseDown: onMouseDownTable, }, plugins: [TableRowPlugin, TableCellPlugin, TableCellHeaderPlugin], }); diff --git a/packages/table/src/react/onMouseDownTable.spec.tsx b/packages/table/src/react/onMouseDownTable.spec.tsx new file mode 100644 index 0000000000..7b60b79ddf --- /dev/null +++ b/packages/table/src/react/onMouseDownTable.spec.tsx @@ -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) => { + 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 = ( + + + + + 11 + + + + after + + ) 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 = ( + + + + + 11 + + + + + ) 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); + }); +}); diff --git a/packages/table/src/react/onMouseDownTable.ts b/packages/table/src/react/onMouseDownTable.ts new file mode 100644 index 0000000000..6b5ef4a318 --- /dev/null +++ b/packages/table/src/react/onMouseDownTable.ts @@ -0,0 +1,93 @@ +import type React from 'react'; + +import type { DOMHandler } from 'platejs/react'; + +import { KEYS } from 'platejs'; + +import type { TableConfig } from '../lib'; + +const DATA_SLATE_NODE_SELECTOR = '[data-slate-node]'; + +const getTargetElement = (target: EventTarget | null) => { + if (target instanceof Element) return target; + if (target instanceof Node) return target.parentElement; +}; + +const hasMappedSlateTarget = (target: EventTarget | null) => { + const slateNode = getTargetElement(target)?.closest(DATA_SLATE_NODE_SELECTOR); + + return !!slateNode && slateNode.getAttribute('data-slate-node') !== 'value'; +}; + +const getClosestEditableChildPath = ( + editor: Parameters>[0]['editor'], + clientY: number +) => { + let previousPath: number[] | undefined; + + for (const [index, node] of editor.children.entries()) { + const path = [index]; + const domNode = editor.api.toDOMNode(node); + + if (!(domNode instanceof Element)) continue; + + const rect = domNode.getBoundingClientRect(); + + if (clientY < rect.top) { + return previousPath + ? { edge: 'end' as const, path: previousPath } + : { edge: 'start' as const, path }; + } + + if (clientY <= rect.bottom) { + const height = rect.height || rect.bottom - rect.top; + + return { + edge: clientY <= rect.top + height / 2 ? 'start' : 'end', + path, + } as const; + } + + previousPath = path; + } + + if (previousPath) { + return { edge: 'end' as const, path: previousPath }; + } +}; + +export const onMouseDownTable: DOMHandler = ({ + editor, + event, +}) => { + if (event.defaultPrevented || event.button !== 0) return; + if (hasMappedSlateTarget(event.target)) return; + + const editable = event.currentTarget; + const target = event.target; + + if (!(editable instanceof HTMLElement)) return; + if (!(target instanceof Node) || !editable.contains(target)) return; + if ( + !editor.api.some({ at: [], match: { type: editor.getType(KEYS.table) } }) + ) { + return; + } + + const closestPath = getClosestEditableChildPath(editor, event.clientY); + + if (!closestPath) return; + + const point = + closestPath.edge === 'start' + ? editor.api.start(closestPath.path) + : editor.api.end(closestPath.path); + + if (!point) return; + + event.preventDefault(); + event.stopPropagation(); + editor.tf.select(point); + + return true; +};