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
44 changes: 44 additions & 0 deletions packages/super-editor/src/core/commands/backspaceAcrossRuns.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Selection } from 'prosemirror-state';
import { findPreviousTextDeleteRange } from './findPreviousTextDeleteRange.js';

/**
* Fallback backspace for fragmented run structures.
*
* Prevents the browser from handling backspace natively inside run-based
* paragraphs, where the hidden ProseMirror DOM is too fragmented for
* reliable contenteditable editing. Scans backward across all runs in the
* containing paragraph to find and delete one character via a PM transaction.
*
* Placed after the specialized handlers (backspaceSkipEmptyRun,
* backspaceNextToRun) in the keymap chain so it only fires when they bail.
*
* @returns {import('@core/commands/types').Command}
*/
export const backspaceAcrossRuns =
() =>
({ state, tr, dispatch }) => {
const sel = state.selection;
if (!sel.empty) return false;

const $pos = sel.$from;
const runType = state.schema.nodes.run;

const insideRun = $pos.parent.type === runType;
const betweenRuns = $pos.nodeBefore?.type === runType && !insideRun;

if (!insideRun && !betweenRuns) return false;

// Determine the containing paragraph so we can scan its full width.
const paraDepth = insideRun ? $pos.depth - 1 : $pos.depth;
const paraStart = $pos.start(paraDepth);

const deleteRange = findPreviousTextDeleteRange(state.doc, $pos.pos, paraStart);
if (!deleteRange) return false;

if (dispatch) {
tr.delete(deleteRange.from, deleteRange.to);
tr.setSelection(Selection.near(tr.doc.resolve(deleteRange.from)));
dispatch(tr.scrollIntoView());
}
return true;
};
172 changes: 172 additions & 0 deletions packages/super-editor/src/core/commands/backspaceAcrossRuns.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, vi } from 'vitest';
import { Schema } from 'prosemirror-model';
import { EditorState, TextSelection } from 'prosemirror-state';
import { backspaceAcrossRuns } from './backspaceAcrossRuns.js';

const makeSchema = () =>
new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
run: { inline: true, group: 'inline', content: 'inline*' },
text: { group: 'inline' },
},
marks: {},
});

const posInsideRun = (doc, runText, offset) => {
let target = null;
doc.descendants((node, pos) => {
if (node.type.name === 'run' && node.textContent === runText) {
target = pos + 1 + offset; // +1 for run open, +offset into text
return false;
}
return true;
});
return target;
};

const posBetweenRuns = (doc, firstRunText) => {
let boundary = null;
doc.descendants((node, pos) => {
if (node.type.name === 'run' && node.textContent === firstRunText) {
boundary = pos + node.nodeSize;
return false;
}
return true;
});
return boundary;
};

describe('backspaceAcrossRuns', () => {
it('deletes the character before the cursor when mid-text in a run', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.node('run', null, schema.text('ABC'))]),
]);

const cursorPos = posInsideRun(doc, 'ABC', 2); // after "B"
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) });

let dispatched;
const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) });

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(dispatched.doc.textContent).toBe('AC');
});

it('deletes across an empty sibling run when cursor is at run start', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.node('run', null, schema.text('A')),
schema.node('run'), // empty run
schema.node('run', null, schema.text('B')),
]),
]);

const cursorPos = posInsideRun(doc, 'B', 0); // start of third run
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) });

let dispatched;
const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) });

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(dispatched.doc.textContent).toBe('B');
});

it('deletes when cursor is between runs at the paragraph level', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.node('run', null, schema.text('A')),
schema.node('run', null, schema.text('B')),
]),
]);

const cursorPos = posBetweenRuns(doc, 'A');
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) });

let dispatched;
const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) });

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(dispatched.doc.textContent).toBe('B');
});

it('returns false when no text exists before the cursor in the paragraph', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.node('run'), // empty
schema.node('run', null, schema.text('B')),
]),
]);

const cursorPos = posInsideRun(doc, 'B', 0);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) });
const dispatch = vi.fn();

const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('returns false when cursor is not inside or adjacent to a run', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('AB'))]);

const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 2) });
const dispatch = vi.fn();

const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('returns false when selection is not empty', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.node('run', null, schema.text('ABC'))]),
]);

const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 3, 5), // "BC" selected
});
const dispatch = vi.fn();

const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('does not delete text from a previous paragraph', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.node('run', null, schema.text('First'))]),
schema.node('paragraph', null, [
schema.node('run'), // empty — first run of second paragraph
schema.node('run', null, schema.text('Second')),
]),
]);

// Cursor at start of second paragraph's second run
const cursorPos = posInsideRun(doc, 'Second', 0);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) });
const dispatch = vi.fn();

const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
expect(state.doc.textContent).toBe('FirstSecond');
});
});
11 changes: 1 addition & 10 deletions packages/super-editor/src/core/commands/backspaceNextToRun.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
import { Selection } from 'prosemirror-state';

const findPreviousTextDeleteRange = (doc, cursorPos, minPos) => {
for (let pos = cursorPos - 1; pos >= minPos; pos -= 1) {
const $probe = doc.resolve(pos);
const nodeBefore = $probe.nodeBefore;
if (!nodeBefore?.isText || !nodeBefore.text?.length) continue;
return { from: pos - 1, to: pos };
}
return null;
};
import { findPreviousTextDeleteRange } from './findPreviousTextDeleteRange.js';

/**
* Backspaces a single character when the cursor sits adjacent to a run boundary.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Scans backward from `cursorPos` to `minPos` for the nearest text character
* and returns the range needed to delete it.
*
* @param {import('prosemirror-model').Node} doc
* @param {number} cursorPos - Position to start scanning backward from.
* @param {number} minPos - Earliest position to consider (e.g. paragraph start).
* @returns {{ from: number, to: number } | null}
*/
export const findPreviousTextDeleteRange = (doc, cursorPos, minPos) => {
for (let pos = cursorPos; pos >= minPos; pos -= 1) {
const $probe = doc.resolve(pos);
const nodeBefore = $probe.nodeBefore;
if (!nodeBefore?.isText || !nodeBefore.text?.length) continue;
return { from: pos - 1, to: pos };
}
return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import { Schema } from 'prosemirror-model';
import { findPreviousTextDeleteRange } from './findPreviousTextDeleteRange.js';

const makeSchema = () =>
new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
run: { inline: true, group: 'inline', content: 'inline*' },
bookmarkEnd: { inline: true, group: 'inline', atom: true },
text: { group: 'inline' },
},
marks: {},
});

describe('findPreviousTextDeleteRange', () => {
it('finds the character immediately before the cursor in plain text', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.node('run', null, schema.text('ABC'))]),
]);

// Cursor after "C" — should target "C"
const cursorPos = 5; // doc(0) > para(1) > run(2) > A(3) B(4) C(5)
const paraStart = 2; // start of run content inside paragraph

const range = findPreviousTextDeleteRange(doc, cursorPos, paraStart);

expect(range).toEqual({ from: 4, to: 5 });
});

it('finds text across an empty sibling run', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.node('run', null, schema.text('A')),
schema.node('run'), // empty run
schema.node('run', null, schema.text('B')),
]),
]);

// Cursor at start of third run's content — scan should skip empty run and find "A"
let thirdRunContentStart = null;
doc.descendants((node, pos) => {
if (node.type.name === 'run' && node.textContent === 'B') {
thirdRunContentStart = pos + 1; // content start inside the run
return false;
}
return true;
});

const paraStart = 2;
const range = findPreviousTextDeleteRange(doc, thirdRunContentStart, paraStart);

expect(range).not.toBeNull();
expect(doc.textBetween(range.from, range.to)).toBe('A');
});

it('skips non-text inline nodes', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.node('run', null, [schema.text('A'), schema.node('bookmarkEnd')])]),
]);

// Cursor after the bookmarkEnd — should skip it and find "A"
let runEnd = null;
doc.descendants((node, pos) => {
if (node.type.name === 'run') {
runEnd = pos + node.nodeSize - 1;
return false;
}
return true;
});

const paraStart = 2;
const range = findPreviousTextDeleteRange(doc, runEnd, paraStart);

expect(range).not.toBeNull();
expect(doc.textBetween(range.from, range.to)).toBe('A');
});

it('returns null when no text exists in the scan range', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.node('run'), schema.node('run', null, schema.text('B'))]),
]);

// Scan only within the empty run
let emptyRunEnd = null;
doc.descendants((node, pos) => {
if (node.type.name === 'run' && node.content.size === 0) {
emptyRunEnd = pos + node.nodeSize - 1;
return false;
}
return true;
});

const emptyRunStart = 2;
const range = findPreviousTextDeleteRange(doc, emptyRunEnd, emptyRunStart);

expect(range).toBeNull();
});

it('respects minPos and does not scan past it', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.node('run', null, schema.text('A')),
schema.node('run', null, schema.text('B')),
]),
]);

// Set minPos to the start of the second run so "A" is out of range
let secondRunStart = null;
doc.descendants((node, pos) => {
if (node.type.name === 'run' && node.textContent === 'B') {
secondRunStart = pos + 1;
return false;
}
return true;
});

// Cursor at start of second run's content — scanning only within that run
const range = findPreviousTextDeleteRange(doc, secondRunStart, secondRunStart);

expect(range).toBeNull();
});
});
1 change: 1 addition & 0 deletions packages/super-editor/src/core/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export * from './lineHeight.js';
export * from './backspaceEmptyRunParagraph.js';
export * from './backspaceSkipEmptyRun.js';
export * from './backspaceNextToRun.js';
export * from './backspaceAcrossRuns.js';
export * from './deleteSkipEmptyRun.js';
export * from './deleteNextToRun.js';
export * from './skipTab.js';
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/src/core/extensions/keymap.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const handleBackspace = (editor) => {
() => commands.backspaceEmptyRunParagraph(),
() => commands.backspaceSkipEmptyRun(),
() => commands.backspaceNextToRun(),
() => commands.backspaceAcrossRuns(),
() => commands.deleteSelection(),
() => commands.removeNumberingProperties(),
() => commands.joinBackward(),
Expand Down
Loading