Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(comparison): scroll both document panes together
Mirror the scroll offset between the two panes, which line up block
by block since the previous commit. Jumping to a change scrolls one
pane and lets the other follow. Smooth scrolling is dropped because
mirroring an animated scroll feeds intermediate positions back.

Signed-off-by: Jonas <jonas@freesources.org>
Assisted-by: ClaudeCode:claude-opus-5.5
  • Loading branch information
mejo- committed Sep 29, 2026
commit 6cbf664a8c5a7b93af5d98b6bd4b8d44fa9a7534
45 changes: 32 additions & 13 deletions src/components/MarkdownContentComparison.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const editors = {} as Record<Side, ReturnType<typeof createComparisonEditor>>
const decorationKeys = {} as Record<Side, ComparisonDecorationKey>
const spacerKeys = {} as Record<Side, ComparisonDecorationKey>
const scrollers: Record<Side, HTMLElement | null> = { before: null, after: null }
const mirrored: Record<Side, number | null> = { before: null, after: null }
let changes: Change[] = []
let blockPairs: BlockPair[] = []
let spacers: ComparisonSpacers = { before: new Map(), after: new Map() }
Expand Down Expand Up @@ -216,15 +217,18 @@ onMounted(() => {
return
}
const panes = sides.map((side) => scrollers[side]).filter((pane) => pane !== null)
for (const pane of panes) {
pane.addEventListener('scroll', syncScroll, { passive: true })
// Images and other embeds change block heights once they have loaded
pane.addEventListener('load', alignDocuments, true)
}
if (typeof ResizeObserver === 'undefined') {
nextTick(alignDocuments)
return
}
observer = new ResizeObserver(alignDocuments)
for (const pane of panes) {
observer.observe(pane)
// Images and other embeds change block heights once they have loaded
pane.addEventListener('load', alignDocuments, true)
}
})
onBeforeUnmount(() => {
Expand Down Expand Up @@ -272,21 +276,43 @@ function blockTops(editor: ReturnType<typeof createComparisonEditor>) {
return tops
}

/** Scroll one pane to the current change; the other pane follows through syncScroll. */
function scrollToCurrent() {
if (view.value !== 'documents' || currentId.value === null) {
return
}
for (const side of sides) {
const scroller = scrollers[side]
const>
if (!scroller || !target) {
continue
if (scroller && target) {
const offset = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top
scroller.scrollTop += offset - (scroller.clientHeight - target.offsetHeight) / 2
return
}
const offset = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top
scroller.scrollTop += offset - (scroller.clientHeight - target.offsetHeight) / 2
}
}

/**
* Mirror the scroll position of one pane onto the other.
* A pane that was just positioned by the mirror reports that position in its own scroll event;
* that echo must not be mirrored back, as assigning scrollTop would cancel a running scroll animation.
*
* @param event Scroll event of one of the panes.
*/
function syncScroll(event: Event) {
const sourceSide: Side = event.currentTarget === scrollers.before ? 'before' : 'after'
const targetSide: Side = sourceSide === 'before' ? 'after' : 'before'
const source = scrollers[sourceSide]!
const>
const echo = mirrored[sourceSide] === source.scrollTop
mirrored[sourceSide] = null
if (echo || !target || target.scrollTop === source.scrollTop) {
return
}
target.scrollTop = source.scrollTop
mirrored[targetSide] = target.scrollTop
}

function setScroller(side: Side, element: Element | ComponentPublicInstance | null) {
scrollers[side] = element instanceof HTMLElement ? element : null
}
Expand Down Expand Up @@ -437,7 +463,6 @@ function destroyEditors() {
min-block-size: 0;
padding-inline: calc(4 * $g);
overflow: auto;
scroll-behavior: smooth;

.ProseMirror {
inline-size: auto;
Expand Down Expand Up @@ -489,10 +514,4 @@ function destroyEditors() {
}
}
}

@media (prefers-reduced-motion: reduce) {
.text-comparison__document-scroller {
scroll-behavior: auto;
}
}
</style>
28 changes: 28 additions & 0 deletions src/tests/comparison/MarkdownContentComparison.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ describe('MarkdownContentComparison', () => {
expect(el.childElementCount).toBe(0)
})

it('keeps both document panes at the same scroll position', async () => {
const { el, instance } = await create('One\n\nTwo', 'One\n\nThree')
const scrollers = el.querySelectorAll<HTMLElement>('.text-comparison__document-scroller')
// jsdom has no layout, so give the panes a scroll position to mirror
for (const scroller of scrollers) {
let top = 0
Object.defineProperty(scroller, 'scrollTop', {
get: () => top,
set: (value: number) => {
top = value
},
})
}

scrollers[0]!.scrollTop = 120
scrollers[0]!.dispatchEvent(new Event('scroll'))
expect(scrollers[1]!.scrollTop).toBe(120)
// The mirrored pane reports its new position after the source moved on: no push back
scrollers[0]!.scrollTop = 150
scrollers[1]!.dispatchEvent(new Event('scroll'))
expect(scrollers[0]!.scrollTop).toBe(150)
scrollers[1]!.scrollTop = 40
scrollers[1]!.dispatchEvent(new Event('scroll'))
expect(scrollers[0]!.scrollTop).toBe(40)

instance.destroy()
})

it('hides formatting-only changes on request', async () => {
const { el, instance } = await create('Plain text\n\nOld', '**Plain** text\n\nNew')

Expand Down