Skip to content

fix: cursor drift during vertical arrow navigation (SD-1689)#1918

Open
luccas-harbour wants to merge 19 commits intomainfrom
luccas/sd-1689-fix-cursor-drift-during-vertical-arrow-navigation
Open

fix: cursor drift during vertical arrow navigation (SD-1689)#1918
luccas-harbour wants to merge 19 commits intomainfrom
luccas/sd-1689-fix-cursor-drift-during-vertical-arrow-navigation

Conversation

@luccas-harbour
Copy link
Contributor

@luccas-harbour luccas-harbour commented Feb 3, 2026

  • Adds a new plugin that handles vertical navigation using arrow keys
  • The basic logic is:
    1. On first vertical move, record goal X from current caret position (in layout space coordinates).
    2. Find adjacent line element in the desired direction.
    3. Perform hit test to find target ProseMirror position.
    4. Move selection to target position, extending if Shift is held.

@linear
Copy link

linear bot commented Feb 3, 2026

@luccas-harbour luccas-harbour force-pushed the luccas/sd-1689-fix-cursor-drift-during-vertical-arrow-navigation branch from af96315 to 09421f5 Compare February 3, 2026 12:38
@luccas-harbour luccas-harbour marked this pull request as ready for review February 3, 2026 13:44
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09421f5954

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@luccas-harbour luccas-harbour force-pushed the luccas/sd-1689-fix-cursor-drift-during-vertical-arrow-navigation branch from 3ded6e3 to 94c3fcf Compare February 3, 2026 16:32
@luccas-harbour luccas-harbour requested a review from tupizz February 3, 2026 18:43
@harbournick harbournick requested a review from afn February 3, 2026 21:42
Copy link
Contributor

@afn afn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to the best of my understanding! Had a couple of minor suggestions but nothing blocking.

Comment on lines 85 to 86
const pageEl = options.painterHost.querySelector(
`.superdoc-page[data-page-index="${options.pageIndex}"]`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making this a helper function (and replace several other places in the codebase that are querying the DOM for data-page-index) to encapsulate the implementation details

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out there was already a helper for this here so I am referencing it instead.

* @param options - Configuration object containing DOM elements and page information.
* @returns The Y offset in layout-space units, or null if calculation fails.
*/
export function getPageOffsetY(options: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to duplicate the contents of getPageOffsets(). Why not use that method (or wrap it like getPageOffsetX) instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! I should have double checked this one

Comment on lines 245 to 257
// Upper-bound search for pmStart <= pos
let lo = 0;
let hi = entries.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (entries[mid].pmStart <= pos) {
lo = mid + 1;
} else {
hi = mid;
}
}

const idx = lo - 1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Binary search is notoriously tricky to get right :) Consider using something like lodash's sortedIndexBy (lodash isn't currently a direct dependency of superdoc-editor, but it's pulled in via naive-ui) to simplify the code and avoid potential pitfalls.

Suggested change
// Upper-bound search for pmStart <= pos
let lo = 0;
let hi = entries.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (entries[mid].pmStart <= pos) {
lo = mid + 1;
} else {
hi = mid;
}
}
const idx = lo - 1;
const idx = sortedIndexBy(entries, { pmStart: pos }, 'pmStart') - 1;

Copy link
Contributor Author

@luccas-harbour luccas-harbour Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I changed it

* @param {boolean} extend
* @returns {import('prosemirror-state').Selection | null}
*/
function buildSelection(state, pos, extend) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving this function to a place where it can be used by other extensions if needed, perhaps src/core/presentation-editor/selection/SelectionHelpers.ts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a look at this one and it looks quite simple and specific to what is being done in this plugin so I think I'd rather keep it as a local function. I could not find anywhere else in the codebase that would benefit from this helper.

I don't think it would really fit in src/core/presentation-editor/selection/SelectionHelpers.ts as it modifies the selection from the ProseMirror side of things, whereas the code in the SelectionHelpers file deals with rendering concerns from the layout-engine's side.

@luccas-harbour luccas-harbour force-pushed the luccas/sd-1689-fix-cursor-drift-during-vertical-arrow-navigation branch from 4d9c694 to e710bcc Compare February 4, 2026 18:35
@luccas-harbour luccas-harbour requested a review from afn February 4, 2026 18:40
Copy link
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some comments

const presentationEditor = editor.presentationEditor;
const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head);
if (!layoutSpaceCoords) return null;
const clientCoords = presentationEditor.denormalizeClientPoint(layoutSpaceCoords.x, layoutSpaceCoords.y, layoutSpaceCoords.pageIndex, layoutSpaceCoords.height);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

denormalizeClientPoint can return null but we're accessing .x without a guard

this could throw if hosts are missing or coords invalid

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That function will only return null if the values of x and y are null. Those values come from the call to presentationEditor.computeCaretLayoutRect(selection.head) which will never return null for these values.

const pageEl = adjacentLine.closest?.(`.${DOM_CLASS_NAMES.PAGE}`);
const pageIndex = pageEl ? Number(pageEl.dataset.pageIndex ?? 'NaN') : null;
const rect = adjacentLine.getBoundingClientRect();
const clientY = rect.top + rect.height / 2;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clientY is computed here then recomputed in the return below

maybe use a variable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, absolutely
fixed

const clamped = Math.max(0, Math.min(pos, doc.content.size));
if (extend) {
const anchor = selection.anchor ?? selection.from;
return TextSelection.create(doc, clamped, anchor);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args appear reversed - TextSelection.create(doc, anchor, head) expects anchor first.

ref: https://prosemirror.net/docs/ref/#state.TextSelection.constructor
image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great catch! fixed

*/
function getHitFromLayoutCoords(editor, goalX, clientY, coords, pageIndex) {
const presentationEditor = editor.presentationEditor;
const clientPoint = presentationEditor.denormalizeClientPoint(goalX, coords.y, pageIndex);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does crossing page boundaries affects Y and the X calculation here?

Copy link
Contributor Author

@luccas-harbour luccas-harbour Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does and that's taken into account via the pageIndex parameter (right here)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants