From d650b2b9f9a5e92fca560ba677c4ebb91fb83d27 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 04:20:12 +0000 Subject: [PATCH 1/2] fix(text-editor): reveal the change after undo, redo and paste In collaborative documents undo/redo is handled by the Yjs undo manager. y-prosemirror restores the selection captured before the Yjs transaction (the selection-restoring 'stack-item-popped' event fires only after the document was updated), so the cursor stays where it was and a change made outside the viewport is never shown. The custom markdown and table paste handlers also dispatched their transactions without scrollIntoView, unlike the default ProseMirror paste. Wrap the collaboration undo/redo commands so that, after a successful undo/redo, the cursor moves to the changed range and it is scrolled into view (selection-only transaction, not added to history). Add scrollIntoView to the smart paste and table paste transactions. Add unit tests for the changed-range helper and the paste scroll. Fixes #7814 Signed-off-by: Michael Uray --- .../extension/__tests__/collaboration.test.ts | 91 +++++++++++++++++++ .../src/components/extension/collaboration.ts | 77 ++++++++++++++++ .../shortcuts/__tests__/smartPaste.test.ts | 1 + .../extension/shortcuts/smartPaste.ts | 2 +- .../extension/shortcuts/tablePaste.ts | 2 +- .../src/kits/editor-kit.ts | 4 +- 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 plugins/text-editor-resources/src/components/extension/__tests__/collaboration.test.ts create mode 100644 plugins/text-editor-resources/src/components/extension/collaboration.ts diff --git a/plugins/text-editor-resources/src/components/extension/__tests__/collaboration.test.ts b/plugins/text-editor-resources/src/components/extension/__tests__/collaboration.test.ts new file mode 100644 index 00000000000..14e1ea89287 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/__tests__/collaboration.test.ts @@ -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() + }) +}) diff --git a/plugins/text-editor-resources/src/components/extension/collaboration.ts b/plugins/text-editor-resources/src/components/extension/collaboration.ts new file mode 100644 index 00000000000..81f04fa3850 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/collaboration.ts @@ -0,0 +1,77 @@ +// +// 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 {} + + return { + ...parent, + undo: () => withRevealChange(parent.undo), + redo: () => withRevealChange(parent.redo) + } + } +}) diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts index 2505f94b7ee..8d777ee0a4c 100644 --- a/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts @@ -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', () => { diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts index 598eb4aa863..287487c6aee 100644 --- a/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts @@ -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 } diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts index 8ff8db53fcc..e09632f9165 100644 --- a/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts @@ -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 } diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index 82669655cb0..a8a5b6562a8 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -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' @@ -180,7 +180,7 @@ const subKits = { 'collaboration-kit', (e) => ({ - collaboration: e(Collaboration), + collaboration: e(CollaborationExtension), collaborationCursor: e(CollaborationCursor), inlineComments: e(InlineCommentCollaborationExtension) }) as const From d4d7aa0325203de7fa89e5a064bfad09ec97d8b1 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 19 Oct 2026 06:39:55 +0000 Subject: [PATCH 2/2] fix(text-editor): guard missing parent undo/redo commands in collaboration extension Signed-off-by: Michael Uray --- .../src/components/extension/collaboration.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/text-editor-resources/src/components/extension/collaboration.ts b/plugins/text-editor-resources/src/components/extension/collaboration.ts index 81f04fa3850..3375d5f3bf8 100644 --- a/plugins/text-editor-resources/src/components/extension/collaboration.ts +++ b/plugins/text-editor-resources/src/components/extension/collaboration.ts @@ -68,10 +68,13 @@ export const CollaborationExtension = Collaboration.extend({ const parent = this.parent?.() if (parent === undefined) return {} + const { undo, redo } = parent + if (undo === undefined || redo === undefined) return parent + return { ...parent, - undo: () => withRevealChange(parent.undo), - redo: () => withRevealChange(parent.redo) + undo: () => withRevealChange(undo), + redo: () => withRevealChange(redo) } } })