Add scroll locking feature with proportional sync by jmthomas · Pull Request #132 · ace-diff/ace-diff · GitHub
Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/tests.yaml
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Here are all the defaults. I'll explain each one in details below. Note: you onl
theme: null,
element: null,
diffGranularity: 'broad',
lockScrolling: true,
showDiffs: true,
showConnectors: true,
maxDiffs: 5000,
Expand Down Expand Up @@ -152,6 +153,7 @@ Here are all the defaults. I'll explain each one in details below. Note: you onl
- `mode` (string, optional). this is the mode for the Ace Editor, e.g. `"ace/mode/javascript"`. Check out the Ace docs for that. This setting will be applied to both editors. I figured 99.999999% of the time you're going to want the same mode for both of them so you can just set it once here. If you're a mad genius and want to have different modes for each side, (a) _whoah man, what's your use-case?_, and (b) you can override this setting in one of the settings below. Read on.
- `theme` (string, optional). This lets you set the theme for both editors.
- `diffGranularity` (string, optional, default: `broad`). this has two options (`specific`, and `broad`). Basically this determines how aggressively AceDiff combines diffs to simplify the interface. I found that often it's a judgement call as to whether multiple diffs on one side should be grouped. This setting provides a little control over it.
- `lockScrolling` (boolean, optional, default: `true`). Synchronizes scrolling between the left and right editors. When enabled, scrolling one editor will scroll the other proportionally. Set to `false` to allow independent scrolling.
- `showDiffs` (boolean, optional, default: `true`). Whether or not the diffs are enabled. This basically turns everything off.
- `showConnectors` (boolean, optional, default: `true`). Whether or not the gutter in the middle show show connectors visualizing where the left and right changes map to one another.
- `maxDiffs` (integer, optional, default: `5000`). This was added a safety precaution. For really massive files with vast numbers of diffs, it's possible the Ace instances or AceDiff will become too laggy. This simply disables the diffing altogether once you hit a certain number of diffs.
Expand Down
134 changes: 134 additions & 0 deletions cypress/e2e/scroll-unlock.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
describe('Scroll unlocking', () => {
context('with lockScrolling disabled (unlocked)', () => {
beforeEach(() => {
cy.visit('http://localhost:8081/scroll-unlock.html')
cy.get('.acediff__wrap').should('have.length', 1)
cy.wait(100)
})

it('does not sync scroll from left to right editor', () => {
cy.window().then((win) => {
// Verify lockScrolling is disabled
expect(win.aceDiffer.options.lockScrolling).to.equal(false)

const { left, right } = win.aceDiffer.getEditors()
const rightSession = right.getSession()
const initialRightScroll = rightSession.getScrollTop()

// Scroll the left editor
const leftSession = left.getSession()
leftSession.setScrollTop(500)

cy.wait(50).then(() => {
// Right editor should not have changed
expect(rightSession.getScrollTop()).to.equal(initialRightScroll)
})
})
})

it('does not sync scroll from right to left editor', () => {
cy.window().then((win) => {
const { left, right } = win.aceDiffer.getEditors()
const leftSession = left.getSession()
const initialLeftScroll = leftSession.getScrollTop()

// Scroll the right editor
const rightSession = right.getSession()
rightSession.setScrollTop(500)

cy.wait(50).then(() => {
// Left editor should not have changed
expect(leftSession.getScrollTop()).to.equal(initialLeftScroll)
})
})
})

it('allows independent scrolling of both editors', () => {
cy.window().then((win) => {
const { left, right } = win.aceDiffer.getEditors()

// Scroll left to 200, right to 500
left.getSession().setScrollTop(200)
right.getSession().setScrollTop(500)

cy.wait(50).then(() => {
// Both should maintain their independent positions
expect(left.getSession().getScrollTop()).to.equal(200)
expect(right.getSession().getScrollTop()).to.equal(500)
})
})
})
})

context('toggling lockScrolling via setOptions', () => {
beforeEach(() => {
cy.visit('http://localhost:8081/scroll-unlock.html')
cy.get('.acediff__wrap').should('have.length', 1)

// Wait for Ace editor to be fully rendered by checking for line numbers in the gutter
cy.get('.ace_gutter-cell').should('exist')

// In headless mode, Ace may not calculate lineHeight properly without a resize
// Force resize and wait for layout to complete
cy.window().then((win) => {
const { left, right } = win.aceDiffer.getEditors()
left.resize(true)
right.resize(true)
})

// Wait for resize to complete and lineHeight to be calculated
cy.wait(300)

// Update lineHeight if still 0 (defensive workaround for headless mode)
cy.window().then((win) => {
if (win.aceDiffer.lineHeight === 0) {
win.aceDiffer.lineHeight = win.aceDiffer.editors.left.ace.renderer.lineHeight
}
})
})

it('can enable lockScrolling after initialization', () => {
cy.window().then((win) => {
// Enable lock scrolling
win.aceDiffer.setOptions({ lockScrolling: true })
expect(win.aceDiffer.options.lockScrolling).to.equal(true)

const { left } = win.aceDiffer.getEditors()

// Scroll left editor - this triggers 'changeScrollTop' event
// which is throttled (16ms) and then syncs to right editor
left.getSession().setScrollTop(500)
})

// Wait for throttled scroll handler (16ms) plus buffer
cy.wait(100)

// Verify scroll synced
cy.window().then((win) => {
const { right } = win.aceDiffer.getEditors()
expect(right.getSession().getScrollTop()).to.be.greaterThan(0)
})
})

it('can disable lockScrolling after enabling it', () => {
cy.window().then((win) => {
// First enable, then disable
win.aceDiffer.setOptions({ lockScrolling: true })
win.aceDiffer.setOptions({ lockScrolling: false })
expect(win.aceDiffer.options.lockScrolling).to.equal(false)

const { left, right } = win.aceDiffer.getEditors()
const rightSession = right.getSession()
const initialRightScroll = rightSession.getScrollTop()

// Scroll left editor
left.getSession().setScrollTop(300)

cy.wait(50).then(() => {
// Right editor should not have changed
expect(rightSession.getScrollTop()).to.equal(initialRightScroll)
})
})
})
})
})
59 changes: 56 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default function AceDiff(options = {}) {
theme: null,
element: null,
diffGranularity: C.DIFF_GRANULARITY_BROAD,
lockScrolling: false, // not implemented yet
lockScrolling: true,
showDiffs: true,
showConnectors: true,
maxDiffs: 5000,
Expand Down Expand Up @@ -285,15 +285,62 @@ AceDiff.prototype = {
}

function addEventHandlers(acediff) {
// Flag to prevent infinite scroll loops when lockScrolling is enabled
let isSyncingScroll = false

// Helper to sync scroll between editors using proportional positioning
function syncScroll(sourceEditor, targetEditor) {
if (!acediff.options.lockScrolling || isSyncingScroll) {
return
}

const sourceSession = sourceEditor.ace.getSession()
const targetSession = targetEditor.ace.getSession()

const sourceScrollTop = sourceSession.getScrollTop()

// Calculate the maximum scroll position for source editor
// This is: (total content height) - (visible viewport height)
const sourceLineCount = sourceSession.getLength()
const sourceContentHeight = sourceLineCount * acediff.lineHeight
const sourceViewportHeight = sourceEditor.ace.renderer.$size.scrollerHeight
const sourceMaxScroll = Math.max(
0,
sourceContentHeight - sourceViewportHeight,
)

// Calculate the scroll ratio (0 to 1)
const scrollRatio =
sourceMaxScroll > 0 ? sourceScrollTop / sourceMaxScroll : 0

// Calculate the maximum scroll position for target editor
const targetLineCount = targetSession.getLength()
const targetContentHeight = targetLineCount * acediff.lineHeight
const targetViewportHeight = targetEditor.ace.renderer.$size.scrollerHeight
const targetMaxScroll = Math.max(
0,
targetContentHeight - targetViewportHeight,
)

// Apply the same scroll ratio to target editor
const targetScrollTop = scrollRatio * targetMaxScroll

isSyncingScroll = true
targetSession.setScrollTop(targetScrollTop)
isSyncingScroll = false
}

acediff.editors.left.ace.getSession().on(
'changeScrollTop',
throttle(() => {
syncScroll(acediff.editors.left, acediff.editors.right)
updateGap(acediff)
}, 16),
)
acediff.editors.right.ace.getSession().on(
'changeScrollTop',
throttle(() => {
syncScroll(acediff.editors.right, acediff.editors.left)
updateGap(acediff)
}, 16),
)
Expand Down Expand Up @@ -367,7 +414,10 @@ function copy(acediff, e, dir) {

// Get the content to insert using character offsets from the source
const sourceValue = sourceEditor.ace.getValue()
const contentToInsert = sourceValue.substring(sourceStartOffset, sourceEndOffset)
const contentToInsert = sourceValue.substring(
sourceStartOffset,
sourceEndOffset,
)

// Use Ace's built-in indexToPosition for precise character-to-position conversion
const targetDoc = targetEditor.ace.getSession().doc
Expand All @@ -380,7 +430,10 @@ function copy(acediff, e, dir) {
// Use character-precise range for the replacement
targetEditor.ace
.getSession()
.replace(new Range(startPos.row, startPos.column, endPos.row, endPos.column), contentToInsert)
.replace(
new Range(startPos.row, startPos.column, endPos.row, endPos.column),
contentToInsert,
)
targetEditor.ace.getSession().setScrollTop(parseInt(h, 10))

acediff.diff()
Expand Down
49 changes: 49 additions & 0 deletions test/fixtures/scroll-unlock.html