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
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:
1049
packages/extension-node-range/CHANGELOG.md
Normal file
1049
packages/extension-node-range/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
52
packages/extension-node-range/package.json
Normal file
52
packages/extension-node-range/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
145
packages/extension-node-range/src/helpers/NodeRangeSelection.ts
Normal file
145
packages/extension-node-range/src/helpers/NodeRangeSelection.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NodeRangeSelection } from './NodeRangeSelection.js'
|
||||
|
||||
export function isNodeRangeSelection(value: unknown): value is NodeRangeSelection {
|
||||
return value instanceof NodeRangeSelection
|
||||
}
|
||||
9
packages/extension-node-range/src/index.ts
Normal file
9
packages/extension-node-range/src/index.ts
Normal 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
|
||||
215
packages/extension-node-range/src/node-range.ts
Normal file
215
packages/extension-node-range/src/node-range.ts
Normal 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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
9
packages/extension-node-range/vite.config.ts
Normal file
9
packages/extension-node-range/vite.config.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user