🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@tiptap/core

Package Overview
Dependencies
Maintainers
6
Versions
497
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tiptap/core - npm Package Compare versions

Comparing version
3.28.0
to
3.29.0
+111
src/__tests__/inputRuleInlineAtoms.test.ts
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'

@@ -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)

@@ -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({

@@ -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