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
4 changes: 2 additions & 2 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -939,8 +939,8 @@
"packages/session-ui/src/v2/components/editable-diff-view-core.ts": "f01a341c4c1cdb4a9522f9ccd3c856bd3b9b7310c01d87327b3ce777052c8c59",
"packages/session-ui/src/v2/components/editable-diff-view.test.ts": "ab206fa17779e6e9015d343ae37e3067dfc1821a93b6ba566b7a0dce0fc19acb",
"packages/session-ui/src/v2/components/editable-diff-view.tsx": "f682e398ece864b5b854ca36698581c7e1cc5964eed96e7b45cabfcb64748b24",
"packages/session-ui/src/v2/components/editor-core.test.ts": "c758e1fb20da30cae44a3311ddd27591a074f137020b28b79d9d879666ee37d6",
"packages/session-ui/src/v2/components/editor-core.ts": "efc2b5f6fe774f1be862184db381f9229f1e642cf93d1f4ca925ed39ceb211d0",
"packages/session-ui/src/v2/components/editor-core.test.ts": "b2fa5f3a2395862fe59692e346568299acf76ad639d0d1336b09fec9cdaf4694",
"packages/session-ui/src/v2/components/editor-core.ts": "1457c2e2e223f6bbe0a1a5c8c9fd45974ebd86cefc90442ce6df02ae79d5946c",
"packages/session-ui/src/v2/components/file-name-picker.test.tsx": "1ce9741e315b549165d390c74b5885a43580aa70e24de987f9ba8388cd521b41",
"packages/session-ui/src/v2/components/markdown-utils.test.ts": "10e1034293bf385cf50d860bf1667d8370abc61dcee0c5a93295f21f5f55876a",
"packages/session-ui/src/v2/components/markdown-utils.ts": "3bc47cfed5b99ab23f857d5324be3189f3a7e800d991103d72ef1c2f970b910b",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
keymapBindings,
} from "./editor-core"
import { EditorView, runScopeHandlers } from "@codemirror/view"
import { EditorState } from "@codemirror/state"
import { EditorSelection, EditorState } from "@codemirror/state"
import { foldable, foldedRanges, syntaxTree } from "@codemirror/language"
import { gotoLine } from "@codemirror/search"
import { addCursorAbove, addCursorBelow, cursorCharRight, deleteCharBackward } from "@codemirror/commands"

/**
* Tests for editor-core — the extracted general-purpose CM6 setup functions
Expand Down Expand Up @@ -242,6 +243,117 @@ describe("baseExtensions go-to-line shortcuts (#1515)", () => {
})
})

// ---------------------------------------------------------------------------
// Multiple cursors (#1516) — Mod-Alt-Up/Down, Mod-d, Escape, in-sync edits.
// CM6's defaultKeymap already binds Mod-Alt-ArrowUp/Down (addCursorAbove /
// addCursorBelow) and searchKeymap binds Mod-d (selectNextOccurrence). The
// facet below is what actually lets those commands keep extra ranges.
//
// Vertical cursor placement (Mod-Alt-Up/Down) reads real layout geometry, which
// happy-dom does not provide, so those cases assert the binding wiring here and
// are verified by hand in a live webview. Editing and movement across several
// cursors are exercised on explicitly built multi-range selections.
// ---------------------------------------------------------------------------

describe("multiple-cursor keybindings (#1516)", () => {
const SRC = "alpha foo\nbeta foo\ngamma foo\n"

function makeView(anchor: number, head?: number): EditorView {
const parent = document.createElement("div")
document.body.appendChild(parent)
return new EditorView({
parent,
state: EditorState.create({
doc: SRC,
selection: { anchor, head },
extensions: baseExtensions({ theme: buildThemeExtension("dark") }),
}),
})
}

function withCursors(view: EditorView, positions: number[]): void {
view.dispatch({
selection: EditorSelection.create(positions.map((p) => EditorSelection.cursor(p))),
})
}

const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform)
const mod = isMac ? { metaKey: true } : { ctrlKey: true }

function keyEvent(init: KeyboardEventInit): KeyboardEvent {
return new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init })
}

test("baseExtensions enables multiple selections (CM6 default is off)", () => {
const view = makeView(0)
expect(view.state.facet(EditorState.allowMultipleSelections)).toBe(true)
view.destroy()
})

test("Mod-Alt-ArrowDown is wired to addCursorBelow and handled by the editor", () => {
expect(keymapBindings().find((b) => b.key === "Mod-Alt-ArrowDown")?.run).toBe(addCursorBelow)
const view = makeView(0)
const ev = keyEvent({ key: "ArrowDown", code: "ArrowDown", keyCode: 40, altKey: true, ...mod })
expect(runScopeHandlers(view, ev, "editor")).toBe(true)
view.destroy()
})

test("Mod-Alt-ArrowUp is wired to addCursorAbove and handled by the editor", () => {
expect(keymapBindings().find((b) => b.key === "Mod-Alt-ArrowUp")?.run).toBe(addCursorAbove)
const view = makeView(SRC.indexOf("gamma"))
const ev = keyEvent({ key: "ArrowUp", code: "ArrowUp", keyCode: 38, altKey: true, ...mod })
expect(runScopeHandlers(view, ev, "editor")).toBe(true)
view.destroy()
})

test("Mod-d selects the next occurrence and adds a cursor", () => {
const view = makeView(6, 9) // "foo" on line 1
const ev = keyEvent({ key: "d", code: "KeyD", keyCode: 68, ...mod })
expect(runScopeHandlers(view, ev, "editor")).toBe(true)
const texts = view.state.selection.ranges.map((r) => view.state.sliceDoc(r.from, r.to))
expect(texts).toEqual(["foo", "foo"])
view.destroy()
})

test("Escape collapses multiple cursors to the primary cursor", () => {
const view = makeView(0)
withCursors(view, [0, SRC.indexOf("beta")])
expect(view.state.selection.ranges).toHaveLength(2)
const primaryHead = view.state.selection.main.head
const esc = keyEvent({ key: "Escape", code: "Escape", keyCode: 27 })
expect(runScopeHandlers(view, esc, "editor")).toBe(true)
expect(view.state.selection.ranges).toHaveLength(1)
expect(view.state.selection.main.head).toBe(primaryHead)
view.destroy()
})

test("cursor movement applies to every cursor", () => {
const view = makeView(0)
withCursors(view, [0, SRC.indexOf("beta")])
expect(cursorCharRight(view)).toBe(true)
const columns = view.state.selection.ranges.map((r) => r.head - view.state.doc.lineAt(r.head).from)
expect(columns).toEqual([1, 1])
view.destroy()
})

test("deletion applies to every cursor", () => {
// Cursors after "alpha" and after "beta " (column 5 on lines 1 and 2).
const view = makeView(5)
withCursors(view, [5, SRC.indexOf("beta") + 5])
expect(deleteCharBackward(view)).toBe(true)
expect(view.state.doc.toString()).toBe("alph foo\nbetafoo\ngamma foo\n")
view.destroy()
})

test("typing inserts text at every cursor", () => {
const view = makeView(0)
withCursors(view, [0, SRC.indexOf("beta")])
view.dispatch(view.state.replaceSelection("X"))
expect(view.state.doc.toString()).toBe("Xalpha foo\nXbeta foo\ngamma foo\n")
view.destroy()
})
})

// ---------------------------------------------------------------------------
// editableExtensions — readOnly/onChange contract preserved
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,11 @@ export function baseExtensions(opts: {
lang?: string
}): Extension[] {
return [
// Multiple cursors (#1516): CM6 collapses to a single selection unless this
// facet is on, which silently disables Mod-Alt-Up/Down, Mod-d and
// Cmd/Ctrl+Click adds. The keys themselves come from defaultKeymap and
// searchKeymap via keymapBindings().
EditorState.allowMultipleSelections.of(true),
lineNumbers(),
// Syntax-aware code folding (#1517): chevrons in the gutter next to the
// line numbers; fold regions come from each language's lezer grammar.
Expand Down
Loading