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
100 changes: 50 additions & 50 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

614 changes: 307 additions & 307 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/hiddenSyntax/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { inlineSyntaxDecorations } from './inline';
import { linkSyntax, referenceDestinationResolver } from './link';
import { hiddenSyntaxTheme } from './theme';
import { unorderedListSyntax } from './unorderedList';
import { stablePointerSelection } from './selection';
import { correctedLineUp, stablePointerSelection } from './selection';
import { inlineImages } from '../support/settings';

const hiddenSyntax = Decoration.mark({ class: 'cm-md-syntaxHiddenSource' });
Expand All @@ -31,6 +31,7 @@ const hiddenSyntaxBaseExtension = [
EditorView.editorAttributes.of({
class: 'cm-md-syntaxHiddenMode',
}),
correctedLineUp,
stablePointerSelection,
ViewPlugin.fromClass(
class {
Expand Down
91 changes: 89 additions & 2 deletions src/hiddenSyntax/selection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { EditorSelection, type EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { EditorSelection, Prec, type EditorState, type SelectionRange } from '@codemirror/state';
import { type Command, EditorView, keymap } from '@codemirror/view';
import type { SyntaxNode } from '@lezer/common';

export const cursorLineUp = moveLineUp(false);
export const selectLineUp = moveLineUp(true);

export const correctedLineUp = Prec.high(keymap.of([
{
key: 'ArrowUp',
run: cursorLineUp,
shift: selectLineUp,
},
]));

export const stablePointerSelection = EditorView.mouseSelectionStyle.of((view, startEvent) => {
if (startEvent.button !== 0
Expand Down Expand Up @@ -54,3 +67,77 @@ export const stablePointerSelection = EditorView.mouseSelectionStyle.of((view, s
export function selectionReveals(state: EditorState, from: number, to: number) {
return state.selection.ranges.some(range => range.from <= to && range.to >= from);
}

function moveLineUp(extend: boolean): Command {
return view => {
const selection = EditorSelection.create(view.state.selection.ranges.map(originalRange => {
let range = originalRange;
if (extend && range.undirectional && range.head >= range.anchor) {
range = EditorSelection.range(range.head, range.anchor);
}

let moved = extend || range.empty
? moveToPreviousHeading(view, range)
: EditorSelection.cursor(range.from);
if (!extend && range.empty && moved.head === range.head) {
moved = view.moveToLineBoundary(range, false);
}

return extend
? EditorSelection.range(range.anchor, moved.head, moved.goalColumn, moved.bidiLevel ?? undefined, moved.assoc)
: moved;
}), view.state.selection.mainIndex);

if (selection.eq(view.state.selection, true)) {
return false;
}

view.dispatch({ selection, scrollIntoView: true, userEvent: 'select' });
return true;
};
}

function moveToPreviousHeading(view: Parameters<Command>[0], range: SelectionRange) {
const moved = view.moveVertically(range, false);
const startLine = view.state.doc.lineAt(range.head);
const movedLine = view.state.doc.lineAt(moved.head);
if (startLine.number - movedLine.number <= 1) {
return moved;
}

const targetLine = view.state.doc.line(startLine.number - 1);
if (!isAtxHeading(view, targetLine.from)) {
return moved;
}

const block = view.lineBlockAt(targetLine.from);
const goal = moved.goalColumn;
const startCoords = view.coordsAtPos(range.head, range.assoc || 1);
const left = goal === undefined
Comment thread
cyanzhong marked this conversation as resolved.
? startCoords?.left
: view.contentDOM.getBoundingClientRect().left + goal;
if (left === undefined) {
return moved;
}

const hit = view.posAndSideAtCoords({
x: left,
y: view.documentTop + block.top + block.height / 2,
});

if (hit === null || hit.pos < targetLine.from || hit.pos > targetLine.to) {
return moved;
}

return EditorSelection.cursor(hit.pos, hit.assoc, undefined, goal);
}

function isAtxHeading(view: Parameters<Command>[0], pos: number) {
for (let node: SyntaxNode | null = syntaxTree(view.state).resolve(pos, 1); node !== null; node = node.parent) {
if (node.name.startsWith('ATXHeading')) {
return true;
}
}

return false;
}
123 changes: 121 additions & 2 deletions tests/hiddenSyntax.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { EditorSelection, EditorState } from '@codemirror/state';
import { EditorSelection, EditorState, Prec } from '@codemirror/state';
import { history, redo, undo } from '@codemirror/commands';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Decoration, EditorView, lineNumbers, type ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, keymap, lineNumbers, runScopeHandlers, type ViewUpdate } from '@codemirror/view';
import { classHighlighter, tags } from '@lezer/highlight';
import { createHiddenSyntaxExtension, hiddenSyntaxExtension } from '../src/hiddenSyntax';
import { blockquoteBarDescriptors, blockquoteBarMarkers } from '../src/hiddenSyntax/components/bar';
Expand All @@ -14,6 +14,7 @@ import { MermaidWidget } from '../src/hiddenSyntax/components/mermaid';
import { renderMermaidSVG } from '../src/render';
import { hiddenSyntaxModeExtension, setHiddenSyntaxMode } from '../src/hiddenSyntax/mode';
import { followLinkAnchor } from '../src/hiddenSyntax/navigation';
import { cursorLineUp, selectLineUp } from '../src/hiddenSyntax/selection';
import * as editor from './support/editor';

const mermaidMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -53,6 +54,124 @@ function editorText() {
.join('\n');
}

describe('Vertical motion', () => {
test('corrects Arrow Up when CodeMirror skips an ATX heading', () => {
const source = '\n## Heading\n';
editor.setUp(source, hiddenSyntaxExtension);
window.editor.dispatch({ selection: { anchor: source.length } });
vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(0, 0, undefined, 24));
const hitTest = vi.spyOn(window.editor, 'posAndSideAtCoords').mockReturnValue({ pos: 4, assoc: 1 });

expect(cursorLineUp(window.editor)).toBe(true);
expect(window.editor.state.selection.main).toEqual(EditorSelection.cursor(4, 1, undefined, 24));
expect(hitTest).toHaveBeenCalledOnce();
});

test('keeps CodeMirror movement when the skipped line is not a heading', () => {
const source = '\nOrdinary\n';
editor.setUp(source, hiddenSyntaxExtension);
window.editor.dispatch({ selection: { anchor: source.length } });
vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(0, 0, undefined, 24));
const hitTest = vi.spyOn(window.editor, 'posAndSideAtCoords');

expect(cursorLineUp(window.editor)).toBe(true);
expect(window.editor.state.selection.main.head).toBe(0);
expect(hitTest).not.toHaveBeenCalled();
});

test('keeps CodeMirror movement when it reaches the previous line', () => {
const source = '\n## Heading\n';
editor.setUp(source, hiddenSyntaxExtension);
window.editor.dispatch({ selection: { anchor: source.length } });
vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(4, 1, undefined, 24));
const hitTest = vi.spyOn(window.editor, 'posAndSideAtCoords');

expect(cursorLineUp(window.editor)).toBe(true);
expect(window.editor.state.selection.main).toEqual(EditorSelection.cursor(4, 1, undefined, 24));
expect(hitTest).not.toHaveBeenCalled();
});

test('extends Shift-ArrowUp to the corrected heading position', () => {
const source = '\n## Heading\n';
editor.setUp(source, hiddenSyntaxExtension);
window.editor.dispatch({ selection: { anchor: source.length } });
vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(0, 0, undefined, 24));
vi.spyOn(window.editor, 'posAndSideAtCoords').mockReturnValue({ pos: 4, assoc: 1 });

expect(selectLineUp(window.editor)).toBe(true);
expect(window.editor.state.selection.main).toEqual(EditorSelection.range(source.length, 4, 24, undefined, 1));
});

test('extends an existing selection from its active head', () => {
const source = 'First\nSecond\nThird';
editor.setUp(source, hiddenSyntaxExtension);
const range = EditorSelection.range(source.length, 10);
window.editor.dispatch({ selection: EditorSelection.create([range]) });
const move = vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(4));

expect(selectLineUp(window.editor)).toBe(true);
expect(move).toHaveBeenCalledWith(range, false);
expect(window.editor.state.selection.main).toEqual(EditorSelection.range(source.length, 4));
});

test('preserves the anchor of a forward selection', () => {
const source = 'First\nSecond\nThird';
editor.setUp(source, hiddenSyntaxExtension);
const range = EditorSelection.range(1, source.length);
window.editor.dispatch({ selection: range });
const move = vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(0));

expect(selectLineUp(window.editor)).toBe(true);
expect(move).toHaveBeenCalledWith(range, false);
expect(window.editor.state.selection.main).toEqual(EditorSelection.range(1, 0));
});

test('falls back to the line boundary for a stationary cursor while another moves', () => {
editor.setUp('First\nSecond', [hiddenSyntaxExtension, EditorState.allowMultipleSelections.of(true)]);
const stationary = EditorSelection.cursor(3);
window.editor.dispatch({
selection: EditorSelection.create([stationary, EditorSelection.cursor(9)], 1),
});

vi.spyOn(window.editor, 'moveVertically').mockImplementation(range =>
EditorSelection.cursor(range.head === 3 ? 3 : 2, 0, undefined, 24));

const boundary = vi.spyOn(window.editor, 'moveToLineBoundary').mockReturnValue(EditorSelection.cursor(0));
expect(cursorLineUp(window.editor)).toBe(true);
expect(boundary).toHaveBeenCalledExactlyOnceWith(stationary, false);
expect(window.editor.state.selection).toEqual(EditorSelection.create([
EditorSelection.cursor(0),
EditorSelection.cursor(2, 0, undefined, 24),
], 1));
});

test('does not fall back to the line boundary for Shift-ArrowUp', () => {
editor.setUp('First', hiddenSyntaxExtension);
const range = EditorSelection.range(1, 3);
window.editor.dispatch({ selection: range });
vi.spyOn(window.editor, 'moveVertically').mockReturnValue(EditorSelection.cursor(3));
const boundary = vi.spyOn(window.editor, 'moveToLineBoundary');

expect(selectLineUp(window.editor)).toBe(false);
expect(boundary).not.toHaveBeenCalled();
expect(window.editor.state.selection.main).toEqual(range);
});

test('allows highest-priority Arrow Up bindings to take precedence', () => {
const source = 'First\nSecond';
const override = vi.fn(() => true);
editor.setUp(source, [
hiddenSyntaxExtension,
Prec.highest(keymap.of([{ key: 'ArrowUp', run: override }])),
]);
window.editor.dispatch({ selection: { anchor: source.length } });

expect(runScopeHandlers(window.editor, new KeyboardEvent('keydown', { key: 'ArrowUp' }), 'editor')).toBe(true);
expect(override).toHaveBeenCalledOnce();
expect(window.editor.state.selection.main.head).toBe(source.length);
});
});

describe('Pointer selection', () => {
test('keeps click jitter collapsed at its original position', () => {
const source = '# Hello\n\nBody';
Expand Down