diff --git a/CHANGELOG.md b/CHANGELOG.md index 87257e1..c4f642f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 6.3.2 (2023-11-28) + +### Bug fixes + +Fix a regression that caused `deleteCharBackward` to sometimes delete a large chunk of text. + +## 6.3.1 (2023-11-27) + +### Bug fixes + +When undoing, store the selection after the undone change with the redo event, so that redoing restores it. + +`deleteCharBackward` will no longer delete variant selector characters as separate characters. + +## 6.3.0 (2023-09-29) + +### Bug fixes + +Make it possible for `selectParentSyntax` to jump out of or into a syntax tree overlay. + +Make Cmd-Backspace and Cmd-Delete on macOS delete to the next line wrap point, not the start/end of the line. + +### New features + +The new `deleteLineBoundaryForward` and `deleteLineBoundaryBackward` commands delete to the start/end of the line or the next line wrapping point. + ## 6.2.5 (2023-08-26) ### Bug fixes diff --git a/package.json b/package.json index 1c24d0c..0eb1812 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codemirror/commands", - "version": "6.2.5", + "version": "6.3.2", "description": "Collection of editing commands for the CodeMirror code editor", "scripts": { "test": "cm-runtests", @@ -29,7 +29,7 @@ "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.2.0", "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.1.0" }, "devDependencies": { "@codemirror/buildhelper": "^1.0.0", diff --git a/src/README.md b/src/README.md index 5e922cf..bc0ec3a 100644 --- a/src/README.md +++ b/src/README.md @@ -147,6 +147,10 @@ with key bindings for a lot of them. @deleteToLineEnd +@deleteLineBoundaryBackward + +@deleteLineBoundaryForward + @deleteTrailingWhitespace ### Line manipulation diff --git a/src/commands.ts b/src/commands.ts index 453c4ec..2dc0e38 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -401,12 +401,15 @@ export const selectLine: StateCommand = ({state, dispatch}) => { /// syntax tree. export const selectParentSyntax: StateCommand = ({state, dispatch}) => { let selection = updateSel(state.selection, range => { - let context = syntaxTree(state).resolveInner(range.head, 1) - while (!((context.from < range.from && context.to >= range.to) || - (context.to > range.to && context.from <= range.from) || - !context.parent?.parent)) - context = context.parent - return EditorSelection.range(context.to, context.from) + let stack = syntaxTree(state).resolveStack(range.from, 1) + for (let cur: typeof stack | null = stack; cur; cur = cur.next) { + let {node} = cur + if (((node.from < range.from && node.to >= range.to) || + (node.to > range.to && node.from <= range.from)) && + node.parent?.parent) + return EditorSelection.range(node.to, node.from) + } + return range }) dispatch(setSel(state, selection)) return true @@ -424,13 +427,13 @@ export const simplifySelection: StateCommand = ({state, dispatch}) => { return true } -function deleteBy(target: CommandTarget, by: (start: number) => number) { +function deleteBy(target: CommandTarget, by: (start: SelectionRange) => number) { if (target.state.readOnly) return false let event = "delete.selection", {state} = target let changes = state.changeByRange(range => { let {from, to} = range if (from == to) { - let towards = by(from) + let towards = by(range) if (towards < from) { event = "delete.backward" towards = skipAtomic(target, towards, false) @@ -444,7 +447,7 @@ function deleteBy(target: CommandTarget, by: (start: number) => number) { from = skipAtomic(target, from, false) to = skipAtomic(target, to, true) } - return from == to ? {range} : {changes: {from, to}, range: EditorSelection.cursor(from)} + return from == to ? {range} : {changes: {from, to}, range: EditorSelection.cursor(from, from < range.head ? -1 : 1)} }) if (changes.changes.empty) return false target.dispatch(state.update(changes, { @@ -463,8 +466,8 @@ function skipAtomic(target: CommandTarget, pos: number, forward: boolean) { return pos } -const deleteByChar = (target: CommandTarget, forward: boolean) => deleteBy(target, pos => { - let {state} = target, line = state.doc.lineAt(pos), before, targetPos: number +const deleteByChar = (target: CommandTarget, forward: boolean) => deleteBy(target, range => { + let pos = range.from, {state} = target, line = state.doc.lineAt(pos), before, targetPos: number if (!forward && pos > line.from && pos < line.from + 200 && !/[^ \t]/.test(before = line.text.slice(0, pos - line.from))) { if (before[before.length - 1] == "\t") return pos - 1 @@ -475,6 +478,8 @@ const deleteByChar = (target: CommandTarget, forward: boolean) => deleteBy(targe targetPos = findClusterBreak(line.text, pos - line.from, forward, forward) + line.from if (targetPos == pos && line.number != (forward ? state.doc.lines : 1)) targetPos += forward ? 1 : -1 + else if (!forward && /[\ufe00-\ufe0f]/.test(line.text.slice(targetPos - line.from, pos - line.from))) + targetPos = findClusterBreak(line.text, targetPos - line.from, false, false) + line.from } return targetPos }) @@ -485,12 +490,12 @@ export const deleteCharBackward: Command = view => deleteByChar(view, false) /// Delete the selection or the character after the cursor. export const deleteCharForward: Command = view => deleteByChar(view, true) -const deleteByGroup = (target: CommandTarget, forward: boolean) => deleteBy(target, start => { - let pos = start, {state} = target, line = state.doc.lineAt(pos) +const deleteByGroup = (target: CommandTarget, forward: boolean) => deleteBy(target, range => { + let pos = range.head, {state} = target, line = state.doc.lineAt(pos) let categorize = state.charCategorizer(pos) for (let cat: CharCategory | null = null;;) { if (pos == (forward ? line.to : line.from)) { - if (pos == start && line.number != (forward ? state.doc.lines : 1)) + if (pos == range.head && line.number != (forward ? state.doc.lines : 1)) pos += forward ? 1 : -1 break } @@ -498,7 +503,7 @@ const deleteByGroup = (target: CommandTarget, forward: boolean) => deleteBy(targ let nextChar = line.text.slice(Math.min(pos, next) - line.from, Math.max(pos, next) - line.from) let nextCat = categorize(nextChar) if (cat != null && nextCat != cat) break - if (nextChar != " " || pos != start) cat = nextCat + if (nextChar != " " || pos != range.head) cat = nextCat pos = next } return pos @@ -514,17 +519,31 @@ export const deleteGroupForward: StateCommand = target => deleteByGroup(target, /// Delete the selection, or, if it is a cursor selection, delete to /// the end of the line. If the cursor is directly at the end of the /// line, delete the line break after it. -export const deleteToLineEnd: Command = view => deleteBy(view, pos => { - let lineEnd = view.lineBlockAt(pos).to - return pos < lineEnd ? lineEnd : Math.min(view.state.doc.length, pos + 1) +export const deleteToLineEnd: Command = view => deleteBy(view, range => { + let lineEnd = view.lineBlockAt(range.head).to + return range.head < lineEnd ? lineEnd : Math.min(view.state.doc.length, range.head + 1) }) /// Delete the selection, or, if it is a cursor selection, delete to /// the start of the line. If the cursor is directly at the start of the /// line, delete the line break before it. -export const deleteToLineStart: Command = view => deleteBy(view, pos => { - let lineStart = view.lineBlockAt(pos).from - return pos > lineStart ? lineStart : Math.max(0, pos - 1) +export const deleteToLineStart: Command = view => deleteBy(view, range => { + let lineStart = view.lineBlockAt(range.head).from + return range.head > lineStart ? lineStart : Math.max(0, range.head - 1) +}) + +/// Delete the selection, or, if it is a cursor selection, delete to +/// the start of the line or the next line wrap before the cursor. +export const deleteLineBoundaryBackward: Command = view => deleteBy(view, range => { + let lineStart = view.moveToLineBoundary(range, false).head + return range.head > lineStart ? lineStart : Math.max(0, range.head - 1) +}) + +/// Delete the selection, or, if it is a cursor selection, delete to +/// the end of the line or the next line wrap after the cursor. +export const deleteLineBoundaryForward: Command = view => deleteBy(view, range => { + let lineStart = view.moveToLineBoundary(range, true).head + return range.head < lineStart ? lineStart : Math.min(view.state.doc.length, range.head + 1) }) /// Delete all whitespace directly before a line end from the @@ -856,8 +875,8 @@ export const emacsStyleKeymap: readonly KeyBinding[] = [ /// - Delete: [`deleteCharForward`](#commands.deleteCharForward) /// - Ctrl-Backspace (Alt-Backspace on macOS): [`deleteGroupBackward`](#commands.deleteGroupBackward) /// - Ctrl-Delete (Alt-Delete on macOS): [`deleteGroupForward`](#commands.deleteGroupForward) -/// - Cmd-Backspace (macOS): [`deleteToLineStart`](#commands.deleteToLineStart). -/// - Cmd-Delete (macOS): [`deleteToLineEnd`](#commands.deleteToLineEnd). +/// - Cmd-Backspace (macOS): [`deleteLineBoundaryBackward`](#commands.deleteLineBoundaryBackward). +/// - Cmd-Delete (macOS): [`deleteLineBoundaryForward`](#commands.deleteLineBoundaryForward). export const standardKeymap: readonly KeyBinding[] = ([ {key: "ArrowLeft", run: cursorCharLeft, shift: selectCharLeft, preventDefault: true}, {key: "Mod-ArrowLeft", mac: "Alt-ArrowLeft", run: cursorGroupLeft, shift: selectGroupLeft, preventDefault: true}, @@ -892,8 +911,8 @@ export const standardKeymap: readonly KeyBinding[] = ([ {key: "Delete", run: deleteCharForward}, {key: "Mod-Backspace", mac: "Alt-Backspace", run: deleteGroupBackward}, {key: "Mod-Delete", mac: "Alt-Delete", run: deleteGroupForward}, - {mac: "Mod-Backspace", run: deleteToLineStart}, - {mac: "Mod-Delete", run: deleteToLineEnd} + {mac: "Mod-Backspace", run: deleteLineBoundaryBackward}, + {mac: "Mod-Delete", run: deleteLineBoundaryForward} ] as KeyBinding[]).concat(emacsStyleKeymap.map(b => ({mac: b.key, run: b.run, shift: b.shift}))) /// The default keymap. Includes all bindings from diff --git a/src/history.ts b/src/history.ts index aff438a..0d17c59 100644 --- a/src/history.ts +++ b/src/history.ts @@ -4,7 +4,7 @@ import {KeyBinding, EditorView} from "@codemirror/view" const enum BranchName { Done, Undone } -const fromHistory = Annotation.define<{side: BranchName, rest: Branch}>() +const fromHistory = Annotation.define<{side: BranchName, rest: Branch, selection: EditorSelection}>() /// Transaction annotation that will prevent that transaction from /// being combined with other transactions in the undo history. Given @@ -47,12 +47,6 @@ const historyConfig = Facet.define>({ } }) -function changeEnd(changes: ChangeDesc) { - let end = 0 - changes.iterChangedRanges((_, to) => end = to) - return end -} - const historyField_ = StateField.define({ create() { return HistoryState.empty @@ -63,8 +57,7 @@ const historyField_ = StateField.define({ let fromHist = tr.annotation(fromHistory) if (fromHist) { - let selection = tr.docChanged ? EditorSelection.single(changeEnd(tr.changes)) : undefined - let item = HistEvent.fromTransaction(tr, selection), from = fromHist.side + let item = HistEvent.fromTransaction(tr, fromHist.selection), from = fromHist.side let other = from == BranchName.Done ? state.undone : state.done if (item) other = updateBranch(other, other.length, config.minDepth, item) else other = addSelection(other, tr.startState.selection) @@ -358,14 +351,14 @@ class HistoryState { this.prevTime, this.prevUserEvent) } - pop(side: BranchName, state: EditorState, selection: boolean): Transaction | null { + pop(side: BranchName, state: EditorState, onlySelection: boolean): Transaction | null { let branch = side == BranchName.Done ? this.done : this.undone if (branch.length == 0) return null - let event = branch[branch.length - 1] - if (selection && event.selectionsAfter.length) { + let event = branch[branch.length - 1], selection = event.selectionsAfter[0] || state.selection + if (onlySelection && event.selectionsAfter.length) { return state.update({ selection: event.selectionsAfter[event.selectionsAfter.length - 1], - annotations: fromHistory.of({side, rest: popSelection(branch)}), + annotations: fromHistory.of({side, rest: popSelection(branch), selection}), userEvent: side == BranchName.Done ? "select.undo" : "select.redo", scrollIntoView: true }) @@ -378,7 +371,7 @@ class HistoryState { changes: event.changes, selection: event.startSelection, effects: event.effects, - annotations: fromHistory.of({side, rest}), + annotations: fromHistory.of({side, rest, selection}), filter: false, userEvent: side == BranchName.Done ? "undo" : "redo", scrollIntoView: true diff --git a/test/test-history.ts b/test/test-history.ts index 5e0d646..5ec5289 100644 --- a/test/test-history.ts +++ b/test/test-history.ts @@ -63,7 +63,7 @@ describe("history", () => { it("puts the cursor after the change on redo", () => { let state = mkState({}, "one\n\ntwo") - state = state.update({changes: {from: 3, insert: "!"}}).state + state = state.update({changes: {from: 3, insert: "!"}, selection: {anchor: 4}}).state state = state.update({selection: {anchor: state.doc.length}}).state state = command(state, undo) state = command(state, redo) @@ -359,6 +359,17 @@ describe("history", () => { ist(state.selection.ranges.map(r => r.from).join(","), "0,2,3") }) + it("restores selection on redo", () => { + let state = mkState({}, "a\nb\nc\n") + state = state.update({selection: EditorSelection.create([1, 3, 5].map(n => EditorSelection.cursor(n)))}).state + state = state.update(state.replaceSelection("-")).state + state = state.update({selection: {anchor: 0}}).state + state = command(state, undo) + state = state.update({selection: {anchor: 0}}).state + state = command(state, redo) + ist(state.selection.ranges.map(r => r.head).join(","), "2,5,8") + }) + describe("undoSelection", () => { it("allows to undo a change", () => { let state = mkState()