Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { type Node, Schema } from '@tiptap/pm/model'
import { EditorState, TextSelection, type Transaction } from '@tiptap/pm/state'

import { getChangedRange, revealChange } from '../collaboration'

const schema = new Schema({
nodes: {
doc: { content: 'block+' },
text: { group: 'inline' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] }
}
})

function makeDoc (...paragraphs: string[]): Node {
return schema.node(
'doc',
undefined,
paragraphs.map((text) => schema.node('paragraph', undefined, text.length > 0 ? schema.text(text) : []))
)
}

describe('getChangedRange', () => {
it('returns null for equal documents', () => {
expect(getChangedRange(makeDoc('one', 'two'), makeDoc('one', 'two'))).toBeNull()
})

it('returns a collapsed range where text was removed', () => {
// "two!" -> "two": the removed "!" was at position 9 (after "two" in the second paragraph)
expect(getChangedRange(makeDoc('one', 'two!'), makeDoc('one', 'two'))).toEqual({ from: 9, to: 9 })
})

it('returns the inserted range in the new document', () => {
expect(getChangedRange(makeDoc('one', 'two'), makeDoc('one', 'two', 'three'))).toEqual({ from: 10, to: 17 })
})

it('handles changes next to identical content', () => {
expect(getChangedRange(makeDoc('aa'), makeDoc('a'))).toEqual({ from: 2, to: 2 })
expect(getChangedRange(makeDoc('a'), makeDoc('aa'))).toEqual({ from: 2, to: 3 })
})
})

describe('revealChange', () => {
function makeView (state: EditorState): { state: EditorState, dispatch: jest.Mock } {
const view = {
state,
dispatch: jest.fn((tr: Transaction) => {
view.state = view.state.apply(tr)
})
}
return view
}

it('moves the cursor to the changed place and scrolls it into view', () => {
const before = makeDoc('top', 'bottom!')
const doc = makeDoc('top', 'bottom')
const view = makeView(EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) }))

revealChange(view as any, before)

expect(view.dispatch).toHaveBeenCalledTimes(1)
const tr: Transaction = view.dispatch.mock.calls[0][0]
expect(tr.scrolledIntoView).toBe(true)
expect(tr.getMeta('addToHistory')).toBe(false)
expect(view.state.selection.from).toBe(12)
expect(view.state.selection.empty).toBe(true)
})

it('does nothing when the document did not change', () => {
const doc = makeDoc('top', 'bottom')
const view = makeView(EditorState.create({ schema, doc }))

revealChange(view as any, doc)

expect(view.dispatch).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { type CommandProps } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import { type Node } from '@tiptap/pm/model'
import { Selection } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'

/**
* Returns the range in `after` that differs from `before`, or null when both documents are equal.
*/
export function getChangedRange (before: Node, after: Node): { from: number, to: number } | null {
const from = before.content.findDiffStart(after.content)
if (from === null) return null

const end = before.content.findDiffEnd(after.content)
if (end === null) return { from, to: from }

// Both ends may overlap when the change is next to identical content (e.g. "aa" -> "a")
const overlap = from - Math.min(end.a, end.b)
const to = overlap > 0 ? end.b + overlap : end.b
return { from, to: Math.max(from, to) }
}

/**
* Moves the cursor to the place changed between `before` and the current document and scrolls it into view.
*/
export function revealChange (view: EditorView, before: Node): void {
const doc = view.state.doc
const range = getChangedRange(before, doc)
if (range === null) return

const selection = Selection.near(doc.resolve(range.to), -1)
view.dispatch(view.state.tr.setSelection(selection).scrollIntoView().setMeta('addToHistory', false))
}

// y-prosemirror restores the selection captured before the undo/redo, so a change made
// outside the viewport stays invisible. Move the cursor to the changed place instead.
function withRevealChange (command: () => (props: CommandProps) => boolean): (props: CommandProps) => boolean {
return (props) => {
const { view, dispatch } = props
if (dispatch === undefined) return command()(props)

const before = view.state.doc
const result = command()(props)
if (result) {
revealChange(view, before)
}
return result
}
}

export const CollaborationExtension = Collaboration.extend({
addCommands () {
const parent = this.parent?.()
if (parent === undefined) return {}

const { undo, redo } = parent
if (undo === undefined || redo === undefined) return parent

return {
...parent,
undo: () => withRevealChange(undo),
redo: () => withRevealChange(redo)
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ describe('SmartPaste handlePaste transform scenarios', () => {

expect(handled).toBe(true)
expect(dispatch).toHaveBeenCalledTimes(1)
expect(dispatch.mock.calls[0][0].scrolledIntoView).toBe(true)
})

it('transforms when explicit markdown is present even with rich clipboard types', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function PasteTextAsMarkdownPlugin (): Plugin {
const content = Node.fromJSON(view.state.schema, markupNode)
content.check()

const transaction = view.state.tr.replaceSelectionWith(content)
const transaction = view.state.tr.replaceSelectionWith(content).scrollIntoView()
view.dispatch(transaction)
return true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function TableMetadataPastePlugin (): Plugin {
}

const modifiedContent = rebuildNode(content)
const transaction = view.state.tr.replaceSelectionWith(modifiedContent)
const transaction = view.state.tr.replaceSelectionWith(modifiedContent).scrollIntoView()
view.dispatch(transaction)
return true // Handled
}
Expand Down
4 changes: 2 additions & 2 deletions plugins/text-editor-resources/src/kits/editor-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import TableHeader from '@tiptap/extension-table-header'
import 'prosemirror-codemark/dist/codemark.css'
import 'katex/dist/katex.min.css'

import Collaboration from '@tiptap/extension-collaboration'
import { CollaborationExtension } from '../components/extension/collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import { CodeBlockHighlighExtension, codeBlockHighlightOptions } from '../components/extension/codeSnippets/codeblock'
import { MermaidExtension, mermaidOptions } from '../components/extension/codeSnippets/mermaid'
Expand Down Expand Up @@ -180,7 +180,7 @@ const subKits = {
'collaboration-kit',
(e) =>
({
collaboration: e(Collaboration),
collaboration: e(CollaborationExtension),
collaborationCursor: e(CollaborationCursor),
inlineComments: e(InlineCommentCollaborationExtension)
}) as const
Expand Down