snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
{
"name": "@tiptap/extension-node-range",
"version": "3.30.3",
"description": "node range extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-node-range"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,27 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import type { Mappable } from '@tiptap/pm/transform'
import { NodeRangeSelection } from './NodeRangeSelection.js'
export class NodeRangeBookmark {
anchor!: number
head!: number
depth!: number
constructor(anchor: number, head: number, depth: number | undefined) {
this.anchor = anchor
this.head = head
this.depth = depth ?? 0
}
map(mapping: Mappable) {
return new NodeRangeBookmark(mapping.map(this.anchor), mapping.map(this.head), this.depth)
}
resolve(doc: PMNode) {
const $anchor = doc.resolve(this.anchor)
const $head = doc.resolve(this.head)
return new NodeRangeSelection($anchor, $head, this.depth)
}
}

View File

@@ -0,0 +1,93 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Selection } from '@tiptap/pm/state'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { isNodeRangeSelection } from './isNodeRangeSelection.js'
import { NodeRangeSelection } from './NodeRangeSelection.js'
describe('NodeRangeSelection serialization', () => {
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function createEditor(content: string): Editor {
editor = new Editor({
element: document.body,
extensions: [Document, Paragraph, Text],
content,
})
return editor
}
it('registers the "nodeRange" JSON id', () => {
const { state } = createEditor('<p>First</p><p>Second</p>')
// jsonID is set on the prototype at registration but not in the Selection type
const selection = NodeRangeSelection.create(state.doc, 1, 1) as NodeRangeSelection & {
jsonID?: string
}
expect(selection.jsonID).toBe('nodeRange')
})
it('round-trips through the generic Selection.fromJSON dispatcher', () => {
// This is the regression: without `Selection.jsonID('nodeRange', …)`,
// `Selection.fromJSON` cannot resolve the `nodeRange` type and throws.
const { state } = createEditor('<p>First</p><p>Second</p>')
const selection = NodeRangeSelection.create(state.doc, 1, 1)
const restored = Selection.fromJSON(state.doc, selection.toJSON())
expect(isNodeRangeSelection(restored)).toBe(true)
expect(restored.eq(selection)).toBe(true)
})
it('preserves depth across toJSON → fromJSON', () => {
const { state } = createEditor('<p>First</p><p>Second</p>')
const selection = NodeRangeSelection.create(state.doc, 1, 1, 0)
const json = selection.toJSON()
expect(json.depth).toBe(0)
const restored = NodeRangeSelection.fromJSON(state.doc, json)
expect(restored.depth).toBe(0)
})
it('round-trips a multi-block selection without losing its ranges', () => {
const { state } = createEditor('<p>First</p><p>Second</p><p>Third</p>')
const firstParagraphSize = state.doc.child(0).nodeSize
const secondParagraphSize = state.doc.child(1).nodeSize
// anchor inside the first paragraph, head at the boundary after the second
const selection = NodeRangeSelection.create(
state.doc,
1,
firstParagraphSize + secondParagraphSize,
)
expect(selection.ranges).toHaveLength(2)
const restored = Selection.fromJSON(state.doc, selection.toJSON())
expect(isNodeRangeSelection(restored)).toBe(true)
expect(restored.eq(selection)).toBe(true)
expect((restored as NodeRangeSelection).ranges).toHaveLength(2)
})
it('keeps depth when mapped through a transaction', () => {
const { state } = createEditor('<p>First</p><p>Second</p>')
const selection = NodeRangeSelection.create(state.doc, 1, 1, 0)
const tr = state.tr.insertText('x', 1)
const mapped = selection.map(tr.doc, tr.mapping)
expect(mapped.depth).toBe(0)
})
})

View File

@@ -0,0 +1,145 @@
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import { Selection } from '@tiptap/pm/state'
import type { Mapping } from '@tiptap/pm/transform'
import { getSelectionRanges } from './getSelectionRanges.js'
import { NodeRangeBookmark } from './NodeRangeBookmark.js'
export class NodeRangeSelection extends Selection {
depth: number | undefined
constructor($anchor: ResolvedPos, $head: ResolvedPos, depth?: number, bias = 1) {
// if there is only a cursor we can’t calculate a direction of the selection
// that’s why we adjust the head position by 1 in the desired direction
const { doc } = $anchor
const isCursor = $anchor === $head
const isCursorAtEnd = $anchor.pos === doc.content.size && $head.pos === doc.content.size
const $correctedHead =
isCursor && !isCursorAtEnd ? doc.resolve($head.pos + (bias > 0 ? 1 : -1)) : $head
const $correctedAnchor =
isCursor && isCursorAtEnd ? doc.resolve($anchor.pos - (bias > 0 ? 1 : -1)) : $anchor
const ranges = getSelectionRanges(
$correctedAnchor.min($correctedHead),
$correctedAnchor.max($correctedHead),
depth,
)
// get the smallest range start position
// this will become the $anchor
const $rangeFrom =
$correctedHead.pos >= $anchor.pos ? ranges[0].$from : ranges[ranges.length - 1].$to
// get the biggest range end position
// this will become the $head
const $rangeTo =
$correctedHead.pos >= $anchor.pos ? ranges[ranges.length - 1].$to : ranges[0].$from
super($rangeFrom, $rangeTo, ranges)
this.depth = depth
}
// we can safely ignore this TypeScript error: https://github.com/Microsoft/TypeScript/issues/338
// @ts-ignore
get $to() {
return this.ranges[this.ranges.length - 1].$to
}
eq(other: Selection): boolean {
return (
other instanceof NodeRangeSelection &&
other.$from.pos === this.$from.pos &&
other.$to.pos === this.$to.pos
)
}
map(doc: PMNode, mapping: Mapping): NodeRangeSelection {
const $anchor = doc.resolve(mapping.map(this.anchor))
const $head = doc.resolve(mapping.map(this.head))
return new NodeRangeSelection($anchor, $head, this.depth)
}
toJSON() {
return {
type: 'nodeRange',
anchor: this.anchor,
head: this.head,
depth: this.depth,
}
}
get isForwards(): boolean {
return this.head >= this.anchor
}
get isBackwards(): boolean {
return !this.isForwards
}
extendBackwards(): NodeRangeSelection {
const { doc } = this.$from
if (this.isForwards && this.ranges.length > 1) {
const ranges = this.ranges.slice(0, -1)
const $from = ranges[0].$from
const $to = ranges[ranges.length - 1].$to
return new NodeRangeSelection($from, $to, this.depth)
}
const firstRange = this.ranges[0]
const $from = doc.resolve(Math.max(0, firstRange.$from.pos - 1))
return new NodeRangeSelection(this.$anchor, $from, this.depth)
}
extendForwards(): NodeRangeSelection {
const { doc } = this.$from
if (this.isBackwards && this.ranges.length > 1) {
const ranges = this.ranges.slice(1)
const $from = ranges[0].$from
const $to = ranges[ranges.length - 1].$to
return new NodeRangeSelection($to, $from, this.depth)
}
const lastRange = this.ranges[this.ranges.length - 1]
const $to = doc.resolve(Math.min(doc.content.size, lastRange.$to.pos + 1))
return new NodeRangeSelection(this.$anchor, $to, this.depth)
}
static fromJSON(
doc: PMNode,
json: { anchor: number; head: number; depth?: number },
): NodeRangeSelection {
return new NodeRangeSelection(doc.resolve(json.anchor), doc.resolve(json.head), json.depth)
}
static create(
doc: PMNode,
anchor: number,
head: number,
depth?: number,
bias = 1,
): NodeRangeSelection {
return new this(doc.resolve(anchor), doc.resolve(head), depth, bias)
}
getBookmark(): NodeRangeBookmark {
return new NodeRangeBookmark(this.anchor, this.head, this.depth)
}
}
NodeRangeSelection.prototype.visible = false
// register the JSON id so collaboration can restore the selection on remote updates
// guarded because Selection.jsonID throws if the id is already registered (dual ESM/CJS builds)
try {
Selection.jsonID('nodeRange', NodeRangeSelection)
} catch {
// already registered — safe to ignore
}

View File

@@ -0,0 +1,28 @@
import type { SelectionRange } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export function getNodeRangeDecorations(ranges: SelectionRange[]): DecorationSet {
if (!ranges.length) {
return DecorationSet.empty
}
const decorations: Decoration[] = []
const doc = ranges[0].$from.node(0)
ranges.forEach(range => {
const pos = range.$from.pos
const node = range.$from.nodeAfter
if (!node) {
return
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: 'ProseMirror-selectednoderange',
}),
)
})
return DecorationSet.create(doc, decorations)
}

View File

@@ -0,0 +1,118 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { getSelectionRanges } from './getSelectionRanges.js'
function getNodeContentStart(nodeStart: number): number {
return nodeStart + 1
}
function getNodeContentEnd(nodeStart: number, nodeSize: number): number {
return nodeStart + nodeSize - 1
}
describe('getSelectionRanges', () => {
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
it('extends the trailing range by default when the selection ends at the next node start', () => {
editor = new Editor({
element: document.body,
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p>',
})
const firstParagraphStart = 0
const firstParagraphSize = editor.state.doc.child(0).nodeSize
const secondParagraphStart = firstParagraphSize
const $from = editor.state.doc.resolve(getNodeContentStart(firstParagraphStart))
const $to = editor.state.doc.resolve(getNodeContentStart(secondParagraphStart))
const ranges = getSelectionRanges($from, $to, 0)
expect(ranges).toHaveLength(2)
expect(ranges[0].$from.pos).toBe(firstParagraphStart)
expect(ranges[1].$from.pos).toBe(secondParagraphStart)
})
it('does not extend the trailing range when boundary overlap is disabled', () => {
editor = new Editor({
element: document.body,
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p>',
})
const firstParagraphStart = 0
const firstParagraphSize = editor.state.doc.child(0).nodeSize
const secondParagraphStart = firstParagraphSize
const $from = editor.state.doc.resolve(getNodeContentStart(firstParagraphStart))
const $to = editor.state.doc.resolve(getNodeContentStart(secondParagraphStart))
const ranges = getSelectionRanges($from, $to, 0, {
extendOnBoundaryOverlap: false,
})
expect(ranges).toHaveLength(1)
expect(ranges[0].$from.pos).toBe(firstParagraphStart)
expect(ranges[0].$to.pos).toBe(secondParagraphStart)
})
it('extends the leading range by default when the selection starts at the previous node end', () => {
editor = new Editor({
element: document.body,
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p>',
})
const firstParagraphStart = 0
const firstParagraphSize = editor.state.doc.child(0).nodeSize
const secondParagraphStart = firstParagraphSize
const secondParagraphSize = editor.state.doc.child(1).nodeSize
const $from = editor.state.doc.resolve(
getNodeContentEnd(firstParagraphStart, firstParagraphSize),
)
const $to = editor.state.doc.resolve(
getNodeContentEnd(secondParagraphStart, secondParagraphSize),
)
const ranges = getSelectionRanges($from, $to, 0)
expect(ranges).toHaveLength(2)
expect(ranges[0].$from.pos).toBe(firstParagraphStart)
expect(ranges[1].$from.pos).toBe(secondParagraphStart)
})
it('does not extend the leading range when boundary overlap is disabled', () => {
editor = new Editor({
element: document.body,
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p>',
})
const firstParagraphStart = 0
const firstParagraphSize = editor.state.doc.child(0).nodeSize
const secondParagraphStart = firstParagraphSize
const secondParagraphSize = editor.state.doc.child(1).nodeSize
const $from = editor.state.doc.resolve(
getNodeContentEnd(firstParagraphStart, firstParagraphSize),
)
const $to = editor.state.doc.resolve(
getNodeContentEnd(secondParagraphStart, secondParagraphSize),
)
const ranges = getSelectionRanges($from, $to, 0, {
extendOnBoundaryOverlap: false,
})
expect(ranges).toHaveLength(1)
expect(ranges[0].$from.pos).toBe(secondParagraphStart)
expect(ranges[0].$to.pos).toBe(secondParagraphStart + secondParagraphSize)
})
})

View File

@@ -0,0 +1,99 @@
import { type ResolvedPos, NodeRange } from '@tiptap/pm/model'
import { SelectionRange } from '@tiptap/pm/state'
export interface GetSelectionRangesOptions {
/**
* Whether nodes should be included when the selection only overlaps their
* start or end content boundary.
* @default true
*/
extendOnBoundaryOverlap?: boolean
}
function getNodeContentBounds(
nodeStart: number,
nodeSize: number,
node: { isText: boolean; isAtom: boolean },
) {
const contentOffset = node.isText || node.isAtom ? 0 : 1
return {
start: nodeStart + contentOffset,
end: nodeStart + nodeSize - contentOffset,
}
}
/**
* Calculates node-aligned selection ranges between two resolved positions.
*
* The helper derives a suitable depth when none is provided and returns a
* `SelectionRange` for each matching child node in the computed `NodeRange`.
* Each returned range exposes `$from` as the resolved start position of the
* node selection and `$to` as the resolved end position.
*
* @param $from The resolved anchor position where the selection starts.
* @param $to The resolved head position where the selection ends.
* @param depth An optional depth to force when creating the ProseMirror `NodeRange`.
* When omitted, the depth is inferred from the shared depth of `$from` and `$to`.
* @param options Optional behavior flags for how boundary nodes are handled.
* @param options.extendOnBoundaryOverlap Whether touching only a node's start
* or end content boundary should still include that node in the returned ranges.
* @returns An array of `SelectionRange` objects for the nodes covered at the
* computed depth.
* @example
* ```ts
* const { $from, $to } = editor.state.selection
* const ranges = getSelectionRanges($from, $to, undefined, {
* extendOnBoundaryOverlap: false,
* })
*
* ranges.forEach(range => {
* console.log(range.$from.pos, range.$to.pos)
* })
* ```
*/
export function getSelectionRanges(
$from: ResolvedPos,
$to: ResolvedPos,
depth?: number,
options: GetSelectionRangesOptions = {},
): SelectionRange[] {
const ranges: SelectionRange[] = []
const doc = $from.node(0)
const { extendOnBoundaryOverlap = true } = options
// Determine the appropriate depth
if (typeof depth === 'number' && depth >= 0) {
// Use the provided depth
} else if ($from.sameParent($to)) {
depth = Math.max(0, $from.sharedDepth($to.pos) - 1)
} else {
depth = $from.sharedDepth($to.pos)
}
const nodeRange = new NodeRange($from, $to, depth)
const offset = nodeRange.depth === 0 ? 0 : doc.resolve(nodeRange.start).posAtIndex(0)
nodeRange.parent.forEach((node, pos) => {
const from = offset + pos
const to = from + node.nodeSize
const contentBounds = getNodeContentBounds(from, node.nodeSize, node)
const overlapsNodeContent = extendOnBoundaryOverlap
? $to.pos >= contentBounds.start && $from.pos <= contentBounds.end
: $to.pos > contentBounds.start && $from.pos < contentBounds.end
if (from < nodeRange.start || from >= nodeRange.end) {
return
}
if (!overlapsNodeContent) {
return
}
const selectionRange = new SelectionRange(doc.resolve(from), doc.resolve(to))
ranges.push(selectionRange)
})
return ranges
}

View File

@@ -0,0 +1,5 @@
import { NodeRangeSelection } from './NodeRangeSelection.js'
export function isNodeRangeSelection(value: unknown): value is NodeRangeSelection {
return value instanceof NodeRangeSelection
}

View File

@@ -0,0 +1,9 @@
import { NodeRange } from './node-range.js'
export * from './helpers/getNodeRangeDecorations.js'
export * from './helpers/getSelectionRanges.js'
export * from './helpers/isNodeRangeSelection.js'
export * from './helpers/NodeRangeSelection.js'
export * from './node-range.js'
export default NodeRange

View File

@@ -0,0 +1,215 @@
import { Extension } from '@tiptap/core'
import type { SelectionRange } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { getNodeRangeDecorations } from './helpers/getNodeRangeDecorations.js'
import { getSelectionRanges } from './helpers/getSelectionRanges.js'
import { isNodeRangeSelection } from './helpers/isNodeRangeSelection.js'
import { NodeRangeSelection } from './helpers/NodeRangeSelection.js'
export interface NodeRangeOptions {
depth: number | undefined
key: 'Shift' | 'Control' | 'Alt' | 'Meta' | 'Mod' | null | undefined
}
export const NodeRange = Extension.create<NodeRangeOptions>({
name: 'nodeRange',
addOptions() {
return {
depth: undefined,
key: 'Mod',
}
},
addKeyboardShortcuts() {
return {
// extend NodeRangeSelection upwards
'Shift-ArrowUp': ({ editor }) => {
const { depth } = this.options
const { view, state } = editor
const { doc, selection, tr } = state
const { anchor, head } = selection
if (!isNodeRangeSelection(selection)) {
const nodeRangeSelection = NodeRangeSelection.create(doc, anchor, head, depth, -1)
tr.setSelection(nodeRangeSelection)
view.dispatch(tr)
return true
}
const nodeRangeSelection = selection.extendBackwards()
tr.setSelection(nodeRangeSelection)
view.dispatch(tr)
return true
},
// extend NodeRangeSelection downwards
'Shift-ArrowDown': ({ editor }) => {
const { depth } = this.options
const { view, state } = editor
const { doc, selection, tr } = state
const { anchor, head } = selection
if (!isNodeRangeSelection(selection)) {
const nodeRangeSelection = NodeRangeSelection.create(doc, anchor, head, depth)
tr.setSelection(nodeRangeSelection)
view.dispatch(tr)
return true
}
const nodeRangeSelection = selection.extendForwards()
tr.setSelection(nodeRangeSelection)
view.dispatch(tr)
return true
},
// add `NodeRangeSelection` to all nodes
'Mod-a': ({ editor }) => {
const { depth } = this.options
const { view, state } = editor
const { doc, tr } = state
const nodeRangeSelection = NodeRangeSelection.create(doc, 0, doc.content.size, depth)
tr.setSelection(nodeRangeSelection)
view.dispatch(tr)
return true
},
}
},
onSelectionUpdate() {
const { selection } = this.editor.state
if (isNodeRangeSelection(selection)) {
this.editor.view.dom.classList.add('ProseMirror-noderangeselection')
}
},
addProseMirrorPlugins() {
let hideTextSelection = false
let activeMouseSelection = false
return [
new Plugin({
key: new PluginKey('nodeRange'),
props: {
attributes: () => {
if (hideTextSelection) {
return {
class: 'ProseMirror-noderangeselection',
}
}
return { class: '' }
},
handleDOMEvents: {
mousedown: (view, event) => {
const { key } = this.options
const isMac = /Mac/.test(navigator.platform)
const isShift = !!event.shiftKey
const isControl = !!event.ctrlKey
const isAlt = !!event.altKey
const isMeta = !!event.metaKey
const isMod = isMac ? isMeta : isControl
if (
key === null ||
key === undefined ||
(key === 'Shift' && isShift) ||
(key === 'Control' && isControl) ||
(key === 'Alt' && isAlt) ||
(key === 'Meta' && isMeta) ||
(key === 'Mod' && isMod)
) {
activeMouseSelection = true
}
if (!activeMouseSelection) {
return false
}
document.addEventListener(
'mouseup',
() => {
activeMouseSelection = false
const { state } = view
const { doc, selection, tr } = state
const { $anchor, $head } = selection
if ($anchor.sameParent($head)) {
return
}
const nodeRangeSelection = NodeRangeSelection.create(
doc,
$anchor.pos,
$head.pos,
this.options.depth,
)
tr.setSelection(nodeRangeSelection)
view.dispatch(tr)
},
{ once: true },
)
return false
},
},
// when selecting some text we want to render some decorations
// to preview a `NodeRangeSelection`
decorations: state => {
const { selection } = state
const isNodeRange = isNodeRangeSelection(selection)
hideTextSelection = false
if (!activeMouseSelection) {
if (!isNodeRange) {
return null
}
hideTextSelection = true
return getNodeRangeDecorations(selection.ranges as SelectionRange[])
}
const { $from, $to } = selection
// selection is probably in the same node like a paragraph
// so we don’t render decorations and show
// a simple text selection instead
if (!isNodeRange && $from.sameParent($to)) {
return null
}
// try to calculate some node ranges
const nodeRanges = getSelectionRanges($from, $to, this.options.depth)
if (!nodeRanges.length) {
return null
}
hideTextSelection = true
return getNodeRangeDecorations(nodeRanges)
},
},
}),
]
},
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})