@tiptap/core
Advanced tools
| import { Editor, Node } from '@tiptap/core' | ||
| import Code from '@tiptap/extension-code' | ||
| import Document from '@tiptap/extension-document' | ||
| import Paragraph from '@tiptap/extension-paragraph' | ||
| import Text from '@tiptap/extension-text' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
| // minimal stand-in for a mention-like node. inline atoms expand to %leaf% (6 chars) | ||
| // in getTextContentFromNodes but only take up 1 position in the doc, | ||
| // which used to break the input rule range math | ||
| const InlineAtom = Node.create({ | ||
| name: 'inlineAtom', | ||
| group: 'inline', | ||
| inline: true, | ||
| atom: true, | ||
| parseHTML: () => [{ tag: 'span[data-atom]' }], | ||
| renderHTML: () => ['span', { 'data-atom': '' }], | ||
| }) | ||
| // same as above, but with a custom text representation instead of the %leaf% fallback | ||
| const InlineAtomWithText = Node.create({ | ||
| name: 'inlineAtomWithText', | ||
| group: 'inline', | ||
| inline: true, | ||
| atom: true, | ||
| parseHTML: () => [{ tag: 'span[data-atom-text]' }], | ||
| renderHTML: () => ['span', { 'data-atom-text': '' }], | ||
| renderText: () => '@mention', | ||
| }) | ||
| const createEditor = (content: string) => | ||
| new Editor({ | ||
| extensions: [Document, Paragraph, Text, InlineAtom, InlineAtomWithText, Code], | ||
| content, | ||
| }) | ||
| const typeAtEndOfParagraph = (editor: Editor, char: string) => { | ||
| const pos = editor.state.doc.content.size - 1 | ||
| editor.commands.setTextSelection(pos) | ||
| return editor.view.someProp('handleTextInput', f => f(editor.view, pos, pos, char)) | ||
| } | ||
| describe('input rules with inline atom nodes', () => { | ||
| let editor: Editor | ||
| afterEach(() => { | ||
| editor.destroy() | ||
| }) | ||
| it('does not crash when the match spans an inline atom (#7933)', () => { | ||
| editor = createEditor('<p>`a<span data-atom></span></p>') | ||
| expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow() | ||
| }) | ||
| it('does not apply the rule when the match spans an inline atom', () => { | ||
| editor = createEditor('<p>`a<span data-atom></span></p>') | ||
| const handled = typeAtEndOfParagraph(editor, '`') | ||
| // someProp returns undefined when the handler declines the input | ||
| expect(handled).toBeFalsy() | ||
| expect(editor.getHTML()).not.toContain('<code>') | ||
| }) | ||
| it('does not crash with multiple atoms inside the match', () => { | ||
| editor = createEditor( | ||
| '<p>`a<span data-atom></span><span data-atom></span><span data-atom></span></p>', | ||
| ) | ||
| expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow() | ||
| }) | ||
| it('does not corrupt preceding text when the match spans an atom (#7833)', () => { | ||
| // with enough text before the backtick the miscalculated range stays positive, | ||
| // so instead of crashing it used to silently mark/replace the wrong content | ||
| editor = createEditor('<p>hello world `a<span data-atom></span></p>') | ||
| expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow() | ||
| expect(editor.getHTML()).toBe('<p>hello world `a<span data-atom=""></span></p>') | ||
| }) | ||
| it('does not apply the rule when the match spans an atom with a custom text representation', () => { | ||
| editor = createEditor('<p>hello `a<span data-atom-text></span></p>') | ||
| const handled = typeAtEndOfParagraph(editor, '`') | ||
| expect(handled).toBeFalsy() | ||
| expect(editor.getHTML()).toBe('<p>hello `a<span data-atom-text=""></span></p>') | ||
| }) | ||
| it('still applies input rules to plain text', () => { | ||
| editor = createEditor('<p>some `code</p>') | ||
| const handled = typeAtEndOfParagraph(editor, '`') | ||
| expect(handled).toBe(true) | ||
| expect(editor.getHTML()).toContain('<code>code</code>') | ||
| }) | ||
| it('still applies input rules when an atom precedes the match', () => { | ||
| editor = createEditor('<p><span data-atom></span>x `code</p>') | ||
| const handled = typeAtEndOfParagraph(editor, '`') | ||
| expect(handled).toBe(true) | ||
| expect(editor.getHTML()).toContain('<code>code</code>') | ||
| }) | ||
| }) |
| import { afterEach, describe, expect, it, vi } from 'vitest' | ||
| import { isAndroid } from '../utilities/isAndroid.js' | ||
| function mockNavigator(platform: string, userAgent: string) { | ||
| vi.stubGlobal('navigator', { platform, userAgent }) | ||
| } | ||
| describe('isAndroid', () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals() | ||
| }) | ||
| it('detects Android via userAgent', () => { | ||
| mockNavigator('Linux armv8l', 'Mozilla/5.0 (Linux; Android 13; Pixel 7)') | ||
| expect(isAndroid()).toBe(true) | ||
| }) | ||
| it('detects Android via platform', () => { | ||
| mockNavigator('Android', 'Mozilla/5.0 (X11; Linux x86_64)') | ||
| expect(isAndroid()).toBe(true) | ||
| }) | ||
| it('returns false for non-Android platforms', () => { | ||
| mockNavigator('MacIntel', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') | ||
| expect(isAndroid()).toBe(false) | ||
| }) | ||
| }) |
| import type { Node as ProseMirrorNode, Fragment, ResolvedPos } from '@tiptap/pm/model' | ||
| import { createNodeFromContent } from '../helpers/createNodeFromContent.js' | ||
| import { defaultBlockAt } from '../helpers/defaultBlockAt.js' | ||
| import { selectionToInsertionEnd } from '../helpers/selectionToInsertionEnd.js' | ||
| import type { Content, RawCommands } from '../types.js' | ||
| export interface InsertDefaultBlockOptions { | ||
| /** | ||
| * The position to insert the block at. | ||
| * Accepts a number or a resolved ProseMirror position. | ||
| * Defaults to the current caret position. | ||
| */ | ||
| pos?: number | ResolvedPos | ||
| /** | ||
| * Attributes to apply to the inserted node. | ||
| * Only attributes that match the node's schema will be applied. | ||
| */ | ||
| attrs?: Record<string, any> | ||
| /** | ||
| * Content to insert into the block. | ||
| * Accepts string (plain text or HTML), ProseMirror node, or Fragment. | ||
| */ | ||
| content?: Content | ProseMirrorNode | Fragment | ||
| /** | ||
| * Whether to update the selection after inserting the content. | ||
| * @default true | ||
| */ | ||
| updateSelection?: boolean | ||
| } | ||
| declare module '@tiptap/core' { | ||
| interface Commands<ReturnType> { | ||
| insertDefaultBlock: { | ||
| /** | ||
| * Insert a default content block at the given position. | ||
| * Returns false when the default block can't be inserted at that position. | ||
| * @example editor.commands.insertDefaultBlock() | ||
| * @example editor.commands.insertDefaultBlock({ pos: 5, content: 'Hello' }) | ||
| */ | ||
| insertDefaultBlock: (options?: InsertDefaultBlockOptions) => ReturnType | ||
| } | ||
| } | ||
| } | ||
| export const insertDefaultBlock: RawCommands['insertDefaultBlock'] = | ||
| (options = {}) => | ||
| ({ tr, dispatch, editor }) => { | ||
| const { pos, attrs, content, updateSelection = true } = options | ||
| let $pos: ResolvedPos | ||
| if (typeof pos === 'number') { | ||
| $pos = tr.doc.resolve(pos) | ||
| } else if (pos) { | ||
| $pos = pos | ||
| } else { | ||
| $pos = tr.selection.$from | ||
| } | ||
| const defaultType = defaultBlockAt($pos.parent.contentMatchAt($pos.index())) | ||
| if (!defaultType) { | ||
| return false | ||
| } | ||
| const validAttrKeys = Object.keys(defaultType.spec.attrs || {}) | ||
| const filteredAttrs = attrs | ||
| ? Object.fromEntries(Object.entries(attrs).filter(([key]) => validAttrKeys.includes(key))) | ||
| : {} | ||
| let node: ProseMirrorNode | null | undefined | ||
| if (content) { | ||
| const parsed = createNodeFromContent(content, editor.schema) | ||
| node = defaultType.createAndFill(filteredAttrs, parsed) | ||
| } else { | ||
| node = defaultType.createAndFill(filteredAttrs) | ||
| } | ||
| if (!node) { | ||
| return false | ||
| } | ||
| if (dispatch) { | ||
| tr.insert($pos.pos, node) | ||
| if (updateSelection) { | ||
| selectionToInsertionEnd(tr, tr.steps.length - 1, -1) | ||
| } | ||
| } | ||
| return true | ||
| } |
+3
-3
| { | ||
| "name": "@tiptap/core", | ||
| "version": "3.28.0", | ||
| "version": "3.29.0", | ||
| "description": "headless rich text editor", | ||
@@ -64,6 +64,6 @@ "keywords": [ | ||
| "devDependencies": { | ||
| "@tiptap/pm": "^3.28.0" | ||
| "@tiptap/pm": "^3.29.0" | ||
| }, | ||
| "peerDependencies": { | ||
| "@tiptap/pm": "3.28.0" | ||
| "@tiptap/pm": "3.29.0" | ||
| }, | ||
@@ -70,0 +70,0 @@ "scripts": { |
| import type { ResolvedPos, Schema } from '@tiptap/pm/model' | ||
| import { TextSelection } from '@tiptap/pm/state' | ||
@@ -96,2 +97,9 @@ import type { RawCommands } from '../types.js' | ||
| // after deleting the selection we restore a text selection | ||
| // near the from position to avoid having confusing selections | ||
| // after the deletion (for example after deleting via Ctrl/Cmd+A) | ||
| if (!tr.selection.empty) { | ||
| tr.setSelection(TextSelection.near(tr.doc.resolve(tr.selection.from))) | ||
| } | ||
| tr.scrollIntoView() | ||
@@ -98,0 +106,0 @@ dispatch(tr) |
@@ -19,2 +19,3 @@ export * from './blur.js' | ||
| export * from './insertContentAt.js' | ||
| export * from './insertDefaultBlock.js' | ||
| export * from './join.js' | ||
@@ -21,0 +22,0 @@ export * from './joinItemBackward.js' |
+36
-18
@@ -146,11 +146,14 @@ /* oslint-disableno-empty-object-type */ | ||
| const initialDoc = this.createDoc() | ||
| const selection = resolveFocusPosition(initialDoc, this.options.autofocus) | ||
| // Set editor state immediately, so that it's available independently from the view | ||
| this.editorState = EditorState.create({ | ||
| doc: initialDoc, | ||
| schema: this.schema, | ||
| selection: selection || undefined, | ||
| }) | ||
| // createDoc() already seeds editorState from the fallback doc on a content error | ||
| if (!this.editorState) { | ||
| const selection = resolveFocusPosition(initialDoc, this.options.autofocus) | ||
| this.editorState = EditorState.create({ | ||
| doc: initialDoc, | ||
| schema: this.schema, | ||
| selection: selection || undefined, | ||
| }) | ||
| } | ||
| if (this.options.element) { | ||
@@ -501,2 +504,20 @@ this.mount(this.options.element) | ||
| } | ||
| // Content is invalid, but attempt to create it anyway, stripping out the invalid parts | ||
| const fallbackDoc = createDocument( | ||
| this.options.content, | ||
| this.schema, | ||
| this.options.parseOptions, | ||
| { | ||
| errorOnInvalidContent: false, | ||
| }, | ||
| ) | ||
| // Seed editorState with the fallback doc so a handler can safely use `editor.commands` | ||
| this.editorState = EditorState.create({ | ||
| doc: fallbackDoc, | ||
| schema: this.schema, | ||
| selection: resolveFocusPosition(fallbackDoc, this.options.autofocus) || undefined, | ||
| }) | ||
| this.emit('contentError', { | ||
@@ -523,6 +544,3 @@ editor: this, | ||
| // Content is invalid, but attempt to create it anyway, stripping out the invalid parts | ||
| doc = createDocument(this.options.content, this.schema, this.options.parseOptions, { | ||
| errorOnInvalidContent: false, | ||
| }) | ||
| return this.editorState.doc | ||
| } | ||
@@ -822,9 +840,9 @@ return doc | ||
| const $pos = this.state.doc.resolve(pos) | ||
| // When the position sits directly before a non-text node (e.g. an image or | ||
| // other atom), nodeAfter is that node but resolvedPos.node() would return | ||
| // the parent (often the doc at depth 0). Pass nodeAfter as the explicit node | ||
| // so NodePos.node returns the expected node instead of the parent. | ||
| // Keep $pos(0) returning the doc node; for other positions, prefer nodeAfter | ||
| // when it points at a non-text node. | ||
| const node = pos > 0 && $pos.nodeAfter && !$pos.nodeAfter.isText ? $pos.nodeAfter : null | ||
| // For positions directly before a non-text atom, resolvedPos.node() returns | ||
| // the parent, so pass the atom explicitly. Container nodes and $pos(0) keep | ||
| // resolving to the parent. | ||
| const node = | ||
| pos > 0 && $pos.nodeAfter && !$pos.nodeAfter.isText && $pos.nodeAfter.isAtom | ||
| ? $pos.nodeAfter | ||
| : null | ||
@@ -831,0 +849,0 @@ return new NodePos($pos, this, false, node) |
+16
-0
@@ -136,2 +136,18 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model' | ||
| // Leaf nodes have a different length in text and the document. | ||
| // Skip matches across them to avoid wrong document positions. | ||
| const matchedDocLength = match[0].length - text.length | ||
| if (matchedDocLength > 0) { | ||
| const matchStartOffset = $from.parentOffset - matchedDocLength | ||
| if ( | ||
| matchStartOffset < 0 || | ||
| $from.parent.textBetween(matchStartOffset, $from.parentOffset) !== | ||
| match[0].slice(0, matchedDocLength) | ||
| ) { | ||
| return | ||
| } | ||
| } | ||
| const tr = view.state.tr | ||
@@ -138,0 +154,0 @@ const state = createChainableState({ |
+8
-1
@@ -54,3 +54,10 @@ import { NodeSelection } from '@tiptap/pm/state' | ||
| this.HTMLAttributes = props.HTMLAttributes | ||
| this.getPos = props.getPos | ||
| this.getPos = () => { | ||
| // ProseMirror throws while this node view is not attached to its parent yet. | ||
| try { | ||
| return props.getPos() | ||
| } catch { | ||
| return undefined | ||
| } | ||
| } | ||
| this.mount() | ||
@@ -57,0 +64,0 @@ } |
| export function isAndroid(): boolean { | ||
| return navigator.platform === 'Android' || /android/i.test(navigator.userAgent) | ||
| return ['Android'].includes(navigator.platform) || /android/i.test(navigator.userAgent) | ||
| } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
2451587
1.15%221
1.38%33572
1.21%