@tiptap/react
Advanced tools
| 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() | ||
| }) | ||
| }) |
| 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. | ||
| } | ||
| } | ||
| } |
+27
-0
@@ -902,2 +902,27 @@ "use strict"; | ||
| var import_react9 = require("react"); | ||
| // src/captureDOMSelection.ts | ||
| function captureDOMSelection(element) { | ||
| var _a, _b; | ||
| const root = element.getRootNode(); | ||
| const selection = typeof root.getSelection === "function" ? root.getSelection() : (_b = (_a = element.ownerDocument) == null ? void 0 : _a.defaultView) == null ? void 0 : _b.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 { | ||
| } | ||
| }; | ||
| } | ||
| // src/ReactNodeViewRenderer.tsx | ||
| var import_jsx_runtime7 = require("react/jsx-runtime"); | ||
@@ -1000,3 +1025,5 @@ var ReactNodeView = class extends import_core3.NodeView { | ||
| } | ||
| const restoreSelection = captureDOMSelection(this.contentDOMElement); | ||
| element.appendChild(this.contentDOMElement); | ||
| restoreSelection == null ? void 0 : restoreSelection(); | ||
| } | ||
@@ -1003,0 +1030,0 @@ }; |
+27
-0
@@ -840,2 +840,27 @@ "use client"; | ||
| import { createElement as createElement2, createRef, memo } from "react"; | ||
| // src/captureDOMSelection.ts | ||
| function captureDOMSelection(element) { | ||
| var _a, _b; | ||
| const root = element.getRootNode(); | ||
| const selection = typeof root.getSelection === "function" ? root.getSelection() : (_b = (_a = element.ownerDocument) == null ? void 0 : _a.defaultView) == null ? void 0 : _b.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 { | ||
| } | ||
| }; | ||
| } | ||
| // src/ReactNodeViewRenderer.tsx | ||
| import { jsx as jsx7 } from "react/jsx-runtime"; | ||
@@ -938,3 +963,5 @@ var ReactNodeView = class extends NodeView { | ||
| } | ||
| const restoreSelection = captureDOMSelection(this.contentDOMElement); | ||
| element.appendChild(this.contentDOMElement); | ||
| restoreSelection == null ? void 0 : restoreSelection(); | ||
| } | ||
@@ -941,0 +968,0 @@ }; |
+7
-7
| { | ||
| "name": "@tiptap/react", | ||
| "version": "3.29.1", | ||
| "version": "3.29.2", | ||
| "description": "React components for tiptap", | ||
@@ -60,4 +60,4 @@ "keywords": [ | ||
| "react-dom": "^19.0.0", | ||
| "@tiptap/core": "^3.29.1", | ||
| "@tiptap/pm": "^3.29.1" | ||
| "@tiptap/core": "^3.29.2", | ||
| "@tiptap/pm": "^3.29.2" | ||
| }, | ||
@@ -69,8 +69,8 @@ "peerDependencies": { | ||
| "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||
| "@tiptap/core": "3.29.1", | ||
| "@tiptap/pm": "3.29.1" | ||
| "@tiptap/core": "3.29.2", | ||
| "@tiptap/pm": "3.29.2" | ||
| }, | ||
| "optionalDependencies": { | ||
| "@tiptap/extension-bubble-menu": "^3.29.1", | ||
| "@tiptap/extension-floating-menu": "^3.29.1" | ||
| "@tiptap/extension-bubble-menu": "^3.29.2", | ||
| "@tiptap/extension-floating-menu": "^3.29.2" | ||
| }, | ||
@@ -77,0 +77,0 @@ "scripts": { |
@@ -14,2 +14,3 @@ import type { | ||
| import { captureDOMSelection } from './captureDOMSelection.js' | ||
| import type { EditorWithContentComponent } from './Editor.js' | ||
@@ -197,3 +198,9 @@ import { ReactRenderer } from './ReactRenderer.js' | ||
| } | ||
| // The caret can already be inside here. Moving would lose it. | ||
| const restoreSelection = captureDOMSelection(this.contentDOMElement) | ||
| element.appendChild(this.contentDOMElement) | ||
| restoreSelection?.() | ||
| } | ||
@@ -200,0 +207,0 @@ } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
504689
1.78%42
5%7249
1.9%