From e91460547b308cc9643cfd3502da724966d9da4c Mon Sep 17 00:00:00 2001 From: Dominik Biedebach <6538827+bdbch@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:02:01 +0200 Subject: [PATCH 1/3] fix(react): capture and restore DOM selection when moving contentDOM in ReactNodeViews (#8135) --- ...x-react-nodeview-caret-content-dom-move.md | 5 ++ packages/react/src/ReactNodeViewRenderer.tsx | 7 ++ .../react/src/captureDOMSelection.spec.ts | 76 +++++++++++++++++++ packages/react/src/captureDOMSelection.ts | 35 +++++++++ 4 files changed, 123 insertions(+) create mode 100644 .changeset/fix-react-nodeview-caret-content-dom-move.md create mode 100644 packages/react/src/captureDOMSelection.spec.ts create mode 100644 packages/react/src/captureDOMSelection.ts diff --git a/.changeset/fix-react-nodeview-caret-content-dom-move.md b/.changeset/fix-react-nodeview-caret-content-dom-move.md new file mode 100644 index 0000000000..b7ca18ae80 --- /dev/null +++ b/.changeset/fix-react-nodeview-caret-content-dom-move.md @@ -0,0 +1,5 @@ +--- +'@tiptap/react': patch +--- + +Fixed the caret jumping back to the previous block when pressing Enter inside a React node view. diff --git a/packages/react/src/ReactNodeViewRenderer.tsx b/packages/react/src/ReactNodeViewRenderer.tsx index be88879f3a..8f89f1b15a 100644 --- a/packages/react/src/ReactNodeViewRenderer.tsx +++ b/packages/react/src/ReactNodeViewRenderer.tsx @@ -11,6 +11,7 @@ import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } fr import type { ComponentType, NamedExoticComponent } from 'react' import { createElement, createRef, memo } from 'react' +import { captureDOMSelection } from './captureDOMSelection.js' import type { EditorWithContentComponent } from './Editor.js' import { ReactRenderer } from './ReactRenderer.js' import type { ReactNodeViewProps } from './types.js' @@ -193,7 +194,13 @@ export class ReactNodeView< if (element.hasAttribute('data-node-view-wrapper')) { element.removeAttribute('data-node-view-wrapper') } + + // The caret can already be inside here. Moving would lose it. + const restoreSelection = captureDOMSelection(this.contentDOMElement) + element.appendChild(this.contentDOMElement) + + restoreSelection?.() } } const context = { onDragStart, nodeViewContentRef } diff --git a/packages/react/src/captureDOMSelection.spec.ts b/packages/react/src/captureDOMSelection.spec.ts new file mode 100644 index 0000000000..29a10b0883 --- /dev/null +++ b/packages/react/src/captureDOMSelection.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { captureDOMSelection } from './captureDOMSelection.js' + +const createContent = () => { + const oldParent = document.createElement('div') + const newParent = document.createElement('div') + const content = document.createElement('div') + const text = document.createTextNode('Alpha') + + content.appendChild(text) + oldParent.appendChild(content) + document.body.append(oldParent, newParent) + + return { content, newParent, text } +} + +const selectInside = (node: Node, offset: number) => { + const selection = document.getSelection()! + + selection.removeAllRanges() + + const range = document.createRange() + + range.setStart(node, offset) + range.collapse(true) + selection.addRange(range) +} + +afterEach(() => { + document.getSelection()?.removeAllRanges() + document.body.innerHTML = '' +}) + +describe('captureDOMSelection', () => { + it('restores a selection that lives inside the element', () => { + const { content, newParent, text } = createContent() + + selectInside(text, 3) + + const restore = captureDOMSelection(content) + + newParent.appendChild(content) + + // jsdom keeps the selection on a move, browsers drop it. Fake that here. + document.getSelection()?.removeAllRanges() + + restore?.() + + const selection = document.getSelection()! + + expect(restore).toBeTypeOf('function') + expect(selection.anchorNode).toBe(text) + expect(selection.anchorOffset).toBe(3) + }) + + it('returns null when there is no selection', () => { + const { content } = createContent() + + document.getSelection()?.removeAllRanges() + + expect(captureDOMSelection(content)).toBeNull() + }) + + it('returns null when the selection is outside of the element', () => { + const { content } = createContent() + const outside = document.createElement('div') + + outside.appendChild(document.createTextNode('Beta')) + document.body.appendChild(outside) + + selectInside(outside.firstChild!, 2) + + expect(captureDOMSelection(content)).toBeNull() + }) +}) diff --git a/packages/react/src/captureDOMSelection.ts b/packages/react/src/captureDOMSelection.ts new file mode 100644 index 0000000000..c74ec5c315 --- /dev/null +++ b/packages/react/src/captureDOMSelection.ts @@ -0,0 +1,35 @@ +type SelectionRoot = Node & { getSelection?: () => Selection | null } + +/** + * Saves the DOM selection inside `element`, returns a function to put it back. + * Browsers drop the selection when an element moves, so restore it after the move. + */ +export function captureDOMSelection(element: HTMLElement): (() => void) | null { + const root = element.getRootNode() as SelectionRoot + const selection = + typeof root.getSelection === 'function' + ? root.getSelection() + : element.ownerDocument?.defaultView?.getSelection() + + if (!selection || selection.rangeCount === 0) { + return null + } + + const { anchorNode, anchorOffset, focusNode, focusOffset } = selection + + if (!anchorNode || !focusNode) { + return null + } + + if (!element.contains(anchorNode) || !element.contains(focusNode)) { + return null + } + + return () => { + try { + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) + } catch { + // The saved nodes can be gone by now. + } + } +} From 295e220fc5a2d6c2793ae576305ed5c6e1ae538a Mon Sep 17 00:00:00 2001 From: bdbch Date: Tue, 28 Jul 2026 13:36:29 +0200 Subject: [PATCH 2/3] docs: consolidate AI agent documentation into AGENTS.md and remove redundant guides --- AGENTS.md | 139 ++++++++++++++++++------------------ CONTRIBUTING.md | 13 +++- agents/CODING.md | 57 --------------- agents/DEMOS.md | 42 ----------- agents/DOCUMENTATION.md | 31 -------- agents/REPOSITORY_LAYOUT.md | 23 ------ agents/SCRIPTS.md | 42 ----------- agents/STYLECHECK.md | 12 ---- agents/TESTING.md | 27 ------- agents/VERSIONING.md | 73 ------------------- 10 files changed, 80 insertions(+), 379 deletions(-) delete mode 100644 agents/CODING.md delete mode 100644 agents/DEMOS.md delete mode 100644 agents/DOCUMENTATION.md delete mode 100644 agents/REPOSITORY_LAYOUT.md delete mode 100644 agents/SCRIPTS.md delete mode 100644 agents/STYLECHECK.md delete mode 100644 agents/TESTING.md delete mode 100644 agents/VERSIONING.md diff --git a/AGENTS.md b/AGENTS.md index a902eaa1dd..aa99f710a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,103 +1,100 @@ # Tiptap -How to work on the Tiptap monorepo. Written for humans and AI coding assistants. +Headless rich text editor toolkit built on ProseMirror. A monorepo of small packages: a framework-agnostic core and extensions, plus React and Vue bindings. -**IMPORTANT for AI agents**: Read the [Resources](#resources) sub-pages before contributing. They cover repo layout, scripts, coding standards, tests, demos, docs, and versioning. Come back to them when unsure. +Published packages live in `packages/*`. `demos/` is a Vite app used as playground and as the target for e2e tests. All scripts are in the root `package.json`. ---- +## Rules -## What is Tiptap +- Small, single-purpose diffs. Ask the user to review. Never autocommit. +- Add a changeset for user-facing changes. Public API breaks need a major bump and migration notes. +- Add or update a demo and tests for user-visible behavior. Prefer unit tests over e2e when deterministic. +- Fix fallow findings your change introduced. Don't suppress them. -- Headless rich text editor toolkit built on ProseMirror. Small Core + opt-in Extensions for React, Vue, or vanilla. -- A collection of focused packages. Many are framework-agnostic with separate bindings for React and Vue. -- Favor small pure utilities, deterministic code, explicit side effects. -- Keep packages modular. Breaking changes need a major bump and a migration path. -- Add or update demos and tests when introducing a feature. +## Before opening a PR ---- +```bash +pnpm lint +pnpm build +pnpm test:unit +pnpm test:e2e +pnpm fallow:audit # verdict must be pass or warn, never fail +``` -## AI contributor rules +Single package failing types: `pnpm -w -F @tiptap/core build`. +Dependency or lockfile errors: `pnpm reset`, then rebuild. -- Work in small, iterative steps. If a task is too broad, say so and propose smaller steps. -- After changes, ask the user to review them. -- Keep PR descriptions short, clear, and easy to read. Use simple English and explain why the change is needed. -- Make single-purpose, small diffs. No sweeping changes in one PR. -- Never autocommit. Ask before committing or opening PRs. -- Run the [validation checklist](#validation-run-before-opening-a-pr) after edits. -- Add a Changeset for user-facing changes. No public API changes without a major bump and migration notes. -- Add or update a demo and tests for user-visible behavior. Prefer unit tests over e2e when deterministic. +## Code style ---- +oxlint lints, oxfmt formats. Husky and lint-staged run both on commit. -## Key scripts +Prefer simple, readable code over clever code. Use early returns. Avoid deep nesting, nested ternaries, and abstractions you don't need yet. Keep functions focused. Apply DRY and SOLID pragmatically, not blindly. -Run from the repo root with `pnpm