diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index 4911a713..e391a971 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -51,6 +51,8 @@ import { isBareHttpUrl, isInsideMarkdownLink, wrapSelectionWithUrl, + continueMarkupKeymap, + editorLineKeymap, } from '@dripnex/commands'; import { createWikilinkHighlighter, @@ -382,6 +384,7 @@ export const MarkdownEditor = forwardRef' } }, + }), // Syntax highlighting syntaxHighlighting(markdownHighlighting), diff --git a/apps/desktop/src/renderer/hooks/useCommandRegistry.ts b/apps/desktop/src/renderer/hooks/useCommandRegistry.ts index a9d6f9ab..d3b77dd8 100644 --- a/apps/desktop/src/renderer/hooks/useCommandRegistry.ts +++ b/apps/desktop/src/renderer/hooks/useCommandRegistry.ts @@ -7,8 +7,31 @@ import { type KeyBinding, } from '@dripnex/command-registry'; import { editorCommands } from '@dripnex/command-registry/definitions'; -import { openSearchPanel, findNext, findPrevious } from '@codemirror/search'; +import { + openSearchPanel, + findNext, + findPrevious, + selectNextOccurrence, + selectSelectionMatches, +} from '@codemirror/search'; import { toggleFold, foldAll, unfoldAll } from '@codemirror/language'; +import { + copyLineUp, + copyLineDown, + moveLineUp, + moveLineDown, + insertBlankLine, + selectLine, + cursorMatchingBracket, + selectMatchingBracket, + selectParentSyntax, + cursorSubwordBackward, + cursorSubwordForward, + selectSubwordBackward, + selectSubwordForward, + toggleComment, + toggleLineComment, +} from '@codemirror/commands'; import { toggleBold, toggleItalic, @@ -24,6 +47,21 @@ import { insertHorizontalRule, undoChange, redoChange, + joinLines, + sortLines, + reverseSortLines, + sortLinesInsensitive, + reverseSortLinesInsensitive, + insertLineBefore, + upcaseAtCursor, + downcaseAtCursor, + findUnder, + findUnderPrevious, + skipAndSelectNextOccurrence, + splitSelectionByLine, + scrollLineUp, + scrollLineDown, + showInCenter, } from '@dripnex/commands'; import { followWikilinkAtCursor } from '../utils/followWikilinkAtCursor'; import { acceptNes, dismissNes, triggerNes } from '../editor/nes/extension'; @@ -86,6 +124,38 @@ const editorExecutors: Record boolean | void> = { 'editor:trigger-nes': triggerNes, 'editor:accept-nes': acceptNes, 'editor:dismiss-nes': dismissNes, + 'editor:copy-line-up': copyLineUp, + 'editor:copy-line-down': copyLineDown, + 'editor:move-line-up': moveLineUp, + 'editor:move-line-down': moveLineDown, + 'editor:insert-blank-line': insertBlankLine, + 'editor:insert-line-before': insertLineBefore, + 'editor:select-line': selectLine, + 'editor:go-matching-bracket': cursorMatchingBracket, + 'editor:select-matching-bracket': selectMatchingBracket, + 'editor:select-parent-syntax': selectParentSyntax, + 'editor:go-subword-left': cursorSubwordBackward, + 'editor:go-subword-right': cursorSubwordForward, + 'editor:select-subword-left': selectSubwordBackward, + 'editor:select-subword-right': selectSubwordForward, + 'editor:select-next-occurrence': selectNextOccurrence, + 'editor:select-selection-matches': selectSelectionMatches, + 'editor:toggle-comment': toggleComment, + 'editor:toggle-line-comment': toggleLineComment, + 'editor:sort-lines-insensitive': sortLinesInsensitive, + 'editor:reverse-sort-lines-insensitive': reverseSortLinesInsensitive, + 'editor:sort-lines': sortLines, + 'editor:reverse-sort-lines': reverseSortLines, + 'editor:join-lines': joinLines, + 'editor:upcase-at-cursor': upcaseAtCursor, + 'editor:downcase-at-cursor': downcaseAtCursor, + 'editor:find-under': findUnder, + 'editor:find-under-previous': findUnderPrevious, + 'editor:skip-and-select-next-occurrence': skipAndSelectNextOccurrence, + 'editor:split-selection-by-line': splitSelectionByLine, + 'editor:scroll-line-up': scrollLineUp, + 'editor:scroll-line-down': scrollLineDown, + 'editor:show-in-center': showInCenter, }; for (const def of editorCommands) { diff --git a/packages/command-registry/src/definitions/editor.ts b/packages/command-registry/src/definitions/editor.ts index 6bc6bdf6..2f66e6aa 100644 --- a/packages/command-registry/src/definitions/editor.ts +++ b/packages/command-registry/src/definitions/editor.ts @@ -209,4 +209,237 @@ export const editorCommands: CommandDefinition[] = [ context: 'editor', showInPalette: false, }, + { + id: 'editor:copy-line-up', + name: 'Copy Line Up', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'arrowup', modifiers: ['Shift', 'Alt'] }, + showInPalette: true, + }, + { + id: 'editor:copy-line-down', + name: 'Copy Line Down', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'arrowdown', modifiers: ['Shift', 'Alt'] }, + showInPalette: true, + }, + { + id: 'editor:move-line-up', + name: 'Move Line Up', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'arrowup', modifiers: ['Alt'] }, + showInPalette: true, + }, + { + id: 'editor:move-line-down', + name: 'Move Line Down', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'arrowdown', modifiers: ['Alt'] }, + showInPalette: true, + }, + { + id: 'editor:insert-blank-line', + name: 'Insert Blank Line Below', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:insert-line-before', + name: 'Insert Blank Line Above', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-line', + name: 'Select Line', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'l', modifiers: ['Mod'] }, + showInPalette: true, + }, + { + id: 'editor:go-matching-bracket', + name: 'Go to Matching Bracket', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-matching-bracket', + name: 'Select Matching Bracket', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-parent-syntax', + name: 'Select Parent Syntax', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:go-subword-left', + name: 'Go Subword Left', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:go-subword-right', + name: 'Go Subword Right', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-subword-left', + name: 'Select Subword Left', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-subword-right', + name: 'Select Subword Right', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-next-occurrence', + name: 'Select Next Occurrence', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:select-selection-matches', + name: 'Select All Occurrences', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'f3', modifiers: ['Alt'] }, + showInPalette: true, + }, + { + id: 'editor:toggle-comment', + name: 'Toggle Comment', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: '/', modifiers: ['Mod'] }, + showInPalette: true, + }, + { + id: 'editor:toggle-line-comment', + name: 'Toggle Line Comment', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:sort-lines-insensitive', + name: 'Sort Lines', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:reverse-sort-lines-insensitive', + name: 'Sort Lines Reverse', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:sort-lines', + name: 'Sort Lines (Case Sensitive)', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:reverse-sort-lines', + name: 'Sort Lines Reverse (Case Sensitive)', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:join-lines', + name: 'Join Lines', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:upcase-at-cursor', + name: 'Uppercase', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:downcase-at-cursor', + name: 'Lowercase', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:find-under', + name: 'Find Under', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'f3', modifiers: ['Mod'] }, + showInPalette: true, + }, + { + id: 'editor:find-under-previous', + name: 'Find Under Previous', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'f3', modifiers: ['Mod', 'Shift'] }, + showInPalette: true, + }, + { + id: 'editor:skip-and-select-next-occurrence', + name: 'Skip and Select Next Occurrence', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:split-selection-by-line', + name: 'Split Selection by Line', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:scroll-line-up', + name: 'Scroll Line Up', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:scroll-line-down', + name: 'Scroll Line Down', + category: 'editor', + context: 'editor', + showInPalette: true, + }, + { + id: 'editor:show-in-center', + name: 'Scroll Cursor to Center', + category: 'editor', + context: 'editor', + showInPalette: true, + }, ]; diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index d83fa953..174aace3 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -25,6 +25,26 @@ export { } from './markdown/commands.js'; export type { GithubAlertKind } from './markdown/commands.js'; +export { continueMarkup, continueMarkupKeymap } from './markdown/continueMarkup.js'; +export { + joinLines, + sortLines, + reverseSortLines, + sortLinesInsensitive, + reverseSortLinesInsensitive, + insertLineBefore, + upcaseAtCursor, + downcaseAtCursor, + findUnder, + findUnderPrevious, + skipAndSelectNextOccurrence, + splitSelectionByLine, + scrollLineUp, + scrollLineDown, + showInCenter, + editorLineKeymap, +} from './markdown/lineCommands.js'; + export { SLASH_ITEMS, FENCE_LANGUAGES, diff --git a/packages/commands/src/markdown/continueMarkup.test.ts b/packages/commands/src/markdown/continueMarkup.test.ts new file mode 100644 index 00000000..7a39b9a7 --- /dev/null +++ b/packages/commands/src/markdown/continueMarkup.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EditorState, EditorSelection } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { continueMarkup, nextMarkupPrefix, outdentMarkupLine } from './continueMarkup.js'; + +function fakeView(initialDoc: string, selection: { from: number; to: number }) { + let state = EditorState.create({ + doc: initialDoc, + selection: EditorSelection.range(selection.from, selection.to), + }); + const dispatch = vi.fn((spec: Parameters[0]) => { + state = state.update(spec).state; + }); + const view = { + get state() { + return state; + }, + dispatch, + }; + return { + view: view as unknown as EditorView, + doc: () => state.doc.toString(), + cursor: () => state.selection.main.head, + }; +} + +describe('nextMarkupPrefix', () => { + it('continues a bullet', () => { + expect(nextMarkupPrefix('- item')).toEqual({ prefix: '- ', empty: false }); + }); + + it('continues an ordered item and bumps the number', () => { + expect(nextMarkupPrefix('1. item')).toEqual({ prefix: '2. ', empty: false }); + }); + + it('continues a task unchecked', () => { + expect(nextMarkupPrefix('- [x] done')).toEqual({ prefix: '- [ ] ', empty: false }); + }); + + it('continues a quote', () => { + expect(nextMarkupPrefix('> hello')).toEqual({ prefix: '> ', empty: false }); + }); + + it('marks empty items', () => { + expect(nextMarkupPrefix('- ')?.empty).toBe(true); + expect(nextMarkupPrefix('> ')?.empty).toBe(true); + }); +}); + +describe('outdentMarkupLine', () => { + it('clears a top-level empty bullet', () => { + expect(outdentMarkupLine('- ')).toBe(''); + }); + + it('outdents a nested bullet by two spaces', () => { + expect(outdentMarkupLine(' - ')).toBe('- '); + }); + + it('drops a list inside a quote and keeps the quote', () => { + expect(outdentMarkupLine('> - ')).toBe('> '); + }); +}); + +describe('continueMarkup', () => { + it('inserts a sibling bullet and splits the line', () => { + const t = fakeView('- hello', { from: 5, to: 5 }); + expect(continueMarkup(t.view)).toBe(true); + expect(t.doc()).toBe('- hel\n- lo'); + }); + + it('continues an ordered list and renumbers what follows', () => { + const t = fakeView('1. a\n2. b', { from: 4, to: 4 }); + expect(continueMarkup(t.view)).toBe(true); + expect(t.doc()).toBe('1. a\n2. \n3. b'); + }); + + it('exits an empty list item', () => { + const t = fakeView('- ', { from: 2, to: 2 }); + expect(continueMarkup(t.view)).toBe(true); + expect(t.doc()).toBe(''); + }); + + it('continues a blockquote', () => { + const t = fakeView('> hi', { from: 4, to: 4 }); + expect(continueMarkup(t.view)).toBe(true); + expect(t.doc()).toBe('> hi\n> '); + }); + + it('does not steal Enter inside a fence', () => { + const doc = '```\n- not a list\n```'; + const t = fakeView(doc, { from: 6, to: 6 }); + expect(continueMarkup(t.view)).toBe(false); + expect(t.doc()).toBe(doc); + }); + + it('falls through when the line is not markup', () => { + const t = fakeView('plain', { from: 5, to: 5 }); + expect(continueMarkup(t.view)).toBe(false); + }); + + it('falls through when there is a selection', () => { + const t = fakeView('- hello', { from: 2, to: 7 }); + expect(continueMarkup(t.view)).toBe(false); + }); +}); diff --git a/packages/commands/src/markdown/continueMarkup.ts b/packages/commands/src/markdown/continueMarkup.ts new file mode 100644 index 00000000..deee91c9 --- /dev/null +++ b/packages/commands/src/markdown/continueMarkup.ts @@ -0,0 +1,180 @@ +/** + * Enter continues the current Markdown list, task, or blockquote. + * An empty item drops the marker (outdent / leave the block). + * Fenced code falls through to the default newline. + */ + +import { EditorSelection } from '@codemirror/state'; +import { EditorView, type KeyBinding } from '@codemirror/view'; + +const LIST_RE = /^([ \t]*)([-*+]|\d+[.)])( \[[ xX]\])? (.*)$/; +const QUOTE_RE = /^(?: *>)+ ?/; + +interface Fence { + ch: '`' | '~'; + len: number; +} + +function fenceAfterLine(line: string, fence: Fence | null): Fence | null { + const m = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + if (!m) return fence; + const marker = m[2] ?? ''; + const ch = marker[0] as '`' | '~'; + const len = marker.length; + const info = m[3] ?? ''; + if (!fence) { + if (ch === '`' && info.includes('`')) return fence; + return { ch, len }; + } + if (fence.ch === ch && len >= fence.len && info.trim() === '') return null; + return fence; +} + +function lineIsInFence(doc: string, pos: number): boolean { + const lines = doc.split('\n'); + const index = doc.slice(0, pos).split('\n').length - 1; + let fence: Fence | null = null; + for (let i = 0; i < lines.length; i++) { + const inside = fence !== null; + if (i === index) return inside; + fence = fenceAfterLine(lines[i] ?? '', fence); + } + return false; +} + +function nextOrderedMarker(marker: string): string { + const n = parseInt(marker, 10); + const delim = marker.slice(-1); + return `${Number.isFinite(n) ? n + 1 : 1}${delim}`; +} + +/** + * Marker for a new sibling item. Tasks always continue unchecked. + * `empty` means the current item has no text — Enter should outdent / leave. + */ +export function nextMarkupPrefix(line: string): { prefix: string; empty: boolean } | null { + const quote = line.match(QUOTE_RE)?.[0] ?? ''; + const content = line.slice(quote.length); + const list = content.match(LIST_RE); + if (list) { + const indent = list[1] ?? ''; + const marker = list[2] ?? ''; + const task = list[3]; + const rest = list[4] ?? ''; + const empty = rest.trim() === ''; + const ordered = /^\d+[.)]$/.test(marker); + const next = ordered ? nextOrderedMarker(marker) : marker; + const taskBit = task ? ' [ ]' : ''; + return { prefix: `${quote}${indent}${next}${taskBit} `, empty }; + } + if (quote) { + return { prefix: quote.endsWith(' ') ? quote : `${quote} `, empty: content.trim() === '' }; + } + return null; +} + +/** Empty item: nested list outdents two spaces; otherwise drop the marker. */ +export function outdentMarkupLine(line: string): string { + const quote = line.match(QUOTE_RE)?.[0] ?? ''; + const content = line.slice(quote.length); + const list = content.match(LIST_RE); + if (list) { + const indent = list[1] ?? ''; + if (indent.length >= 2) { + const marker = list[2] ?? ''; + const taskBit = list[3] ? ' [ ]' : ''; + return `${quote}${indent.slice(0, indent.length - 2)}${marker}${taskBit} `; + } + return quote; + } + return ''; +} + +function renumberFollowing( + doc: string, + fromLineNumber: number +): { from: number; to: number; insert: string }[] { + const lines = doc.split('\n'); + const start = lines[fromLineNumber - 1]; + if (start == null) return []; + const quote = start.match(QUOTE_RE)?.[0] ?? ''; + const content = start.slice(quote.length); + const list = content.match(LIST_RE); + if (!list || !/^\d+[.)]$/.test(list[2] ?? '')) return []; + const indent = list[1] ?? ''; + const delim = (list[2] ?? '').slice(-1); + let n = parseInt(list[2] ?? '1', 10); + if (!Number.isFinite(n)) return []; + const changes: { from: number; to: number; insert: string }[] = []; + let offset = 0; + for (let i = 0; i < fromLineNumber; i++) offset += (lines[i]?.length ?? 0) + 1; + for (let i = fromLineNumber; i < lines.length; i++) { + const line = lines[i] ?? ''; + const q = line.match(QUOTE_RE)?.[0] ?? ''; + if (q !== quote) break; + const c = line.slice(q.length); + const m = c.match(LIST_RE); + if (!m) { + if (line.trim() === '') continue; + break; + } + if ((m[1] ?? '') !== indent) { + if ((m[1] ?? '').length > indent.length) continue; + break; + } + if (!/^\d+[.)]$/.test(m[2] ?? '')) break; + n += 1; + const next = `${n}${delim}`; + if (next !== m[2]) { + const markerFrom = offset + q.length + (m[1]?.length ?? 0); + changes.push({ from: markerFrom, to: markerFrom + (m[2]?.length ?? 0), insert: next }); + } + offset += line.length + 1; + } + return changes; +} + +/** Continue the list/quote on Enter. Returns false so a plain newline can run. */ +export function continueMarkup(view: EditorView): boolean { + const { state } = view; + if (state.selection.ranges.length !== 1 || state.selection.ranges.some(range => !range.empty)) { + return false; + } + const pos = state.selection.main.head; + const doc = state.doc.toString(); + if (lineIsInFence(doc, pos)) return false; + + const line = state.doc.lineAt(pos); + const parsed = nextMarkupPrefix(line.text); + if (!parsed) return false; + + if (parsed.empty) { + const kept = outdentMarkupLine(line.text); + view.dispatch({ + changes: { from: line.from, to: line.to, insert: kept }, + selection: EditorSelection.cursor(line.from + kept.length), + userEvent: 'input', + }); + return true; + } + + const after = state.sliceDoc(pos, line.to); + const insert = `\n${parsed.prefix}${after}`; + const cursor = pos + 1 + parsed.prefix.length; + let next = doc.slice(0, pos) + insert + doc.slice(line.to); + const extras = renumberFollowing(next, line.number + 1); + for (const change of [...extras].sort((a, b) => b.from - a.from)) { + next = next.slice(0, change.from) + change.insert + next.slice(change.to); + } + view.dispatch({ + changes: { from: 0, to: doc.length, insert: next }, + selection: EditorSelection.cursor(cursor), + userEvent: 'input', + }); + return true; +} + +export const continueMarkupKeymap: KeyBinding = { + key: 'Enter', + run: continueMarkup, +}; diff --git a/packages/commands/src/markdown/lineCommands.test.ts b/packages/commands/src/markdown/lineCommands.test.ts new file mode 100644 index 00000000..6343dc2f --- /dev/null +++ b/packages/commands/src/markdown/lineCommands.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EditorState, EditorSelection } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { + joinLines, + sortLines, + sortLinesInsensitive, + reverseSortLines, + insertLineBefore, + upcaseAtCursor, + downcaseAtCursor, + findUnder, + findUnderPrevious, + skipAndSelectNextOccurrence, + splitSelectionByLine, + wordRangeAt, +} from './lineCommands.js'; + +function fakeView(initialDoc: string, selection: { from: number; to: number }) { + let state = EditorState.create({ + doc: initialDoc, + selection: EditorSelection.range(selection.from, selection.to), + extensions: [EditorState.allowMultipleSelections.of(true)], + }); + const dispatch = vi.fn((spec: Parameters[0]) => { + state = state.update(spec).state; + }); + const view = { + get state() { + return state; + }, + dispatch, + }; + return { + view: view as unknown as EditorView, + doc: () => state.doc.toString(), + selection: () => state.selection.main, + }; +} + +describe('wordRangeAt', () => { + it('expands around the cursor', () => { + const state = EditorState.create({ doc: 'hello world' }); + expect(wordRangeAt(state.doc, 2)).toEqual({ from: 0, to: 5 }); + }); +}); + +describe('joinLines', () => { + it('joins the next line when nothing is selected', () => { + const t = fakeView('hello\n world', { from: 0, to: 0 }); + expect(joinLines(t.view)).toBe(true); + expect(t.doc()).toBe('hello world'); + }); + + it('joins a multi-line selection and drops absorbed indent', () => { + const t = fakeView('a\n b\n c', { from: 0, to: 9 }); + expect(joinLines(t.view)).toBe(true); + expect(t.doc()).toBe('a b c'); + }); + + it('does nothing on the last line with an empty selection', () => { + const t = fakeView('only', { from: 4, to: 4 }); + expect(joinLines(t.view)).toBe(false); + }); +}); + +describe('sortLines', () => { + it('does not rewrite a single-line selection', () => { + const t = fakeView('b\na', { from: 0, to: 1 }); + expect(sortLines(t.view)).toBe(false); + }); + + it('sorts the selected lines', () => { + const t = fakeView('b\na\nc', { from: 0, to: 5 }); + expect(sortLines(t.view)).toBe(true); + expect(t.doc()).toBe('a\nb\nc'); + }); + + it('sorts case-insensitively and reverse', () => { + const t = fakeView('B\na\nC', { from: 0, to: 5 }); + expect(sortLinesInsensitive(t.view)).toBe(true); + expect(t.doc()).toBe('a\nB\nC'); + const r = fakeView('a\nb\nc', { from: 0, to: 5 }); + expect(reverseSortLines(r.view)).toBe(true); + expect(r.doc()).toBe('c\nb\na'); + }); +}); + +describe('insertLineBefore', () => { + it('opens an indented blank line above', () => { + const t = fakeView(' hello', { from: 4, to: 4 }); + expect(insertLineBefore(t.view)).toBe(true); + expect(t.doc()).toBe(' \n hello'); + expect(t.selection().head).toBe(2); + }); +}); + +describe('case at cursor', () => { + it('uppercases the word at the cursor', () => { + const t = fakeView('hello world', { from: 1, to: 1 }); + expect(upcaseAtCursor(t.view)).toBe(true); + expect(t.doc()).toBe('HELLO world'); + }); + + it('lowercases a selection', () => { + const t = fakeView('HELLO', { from: 0, to: 5 }); + expect(downcaseAtCursor(t.view)).toBe(true); + expect(t.doc()).toBe('hello'); + }); +}); + +describe('findUnder', () => { + it('jumps to the next occurrence of the word', () => { + const t = fakeView('foo bar foo', { from: 1, to: 1 }); + expect(findUnder(t.view)).toBe(true); + expect(t.selection().from).toBe(8); + expect(t.selection().to).toBe(11); + }); + + it('wraps around the note', () => { + const t = fakeView('foo bar foo', { from: 8, to: 11 }); + expect(findUnder(t.view)).toBe(true); + expect(t.selection().from).toBe(0); + }); + + it('wraps backward from the first occurrence to the last', () => { + const t = fakeView('foo bar foo', { from: 0, to: 3 }); + expect(findUnderPrevious(t.view)).toBe(true); + expect(t.selection().from).toBe(8); + expect(t.selection().to).toBe(11); + }); +}); + +describe('skipAndSelectNextOccurrence', () => { + it('expands an empty selection to the word first', () => { + const t = fakeView('foo bar foo', { from: 1, to: 1 }); + expect(skipAndSelectNextOccurrence(t.view)).toBe(true); + expect(t.selection().from).toBe(0); + expect(t.selection().to).toBe(3); + }); +}); + +describe('splitSelectionByLine', () => { + it('puts a range on each covered line', () => { + const t = fakeView('a\nb\nc', { from: 0, to: 5 }); + expect(splitSelectionByLine(t.view)).toBe(true); + expect(t.view.state.selection.ranges).toHaveLength(3); + }); + + it('does not add a caret on a line that is only the selection end', () => { + const t = fakeView('a\nb\nc', { from: 0, to: 4 }); + expect(splitSelectionByLine(t.view)).toBe(true); + expect(t.view.state.selection.ranges).toHaveLength(2); + }); +}); diff --git a/packages/commands/src/markdown/lineCommands.ts b/packages/commands/src/markdown/lineCommands.ts new file mode 100644 index 00000000..9d85465c --- /dev/null +++ b/packages/commands/src/markdown/lineCommands.ts @@ -0,0 +1,291 @@ +/** + * Line/selection commands matching Inkdrop v6.1 `editor:*` names. + * CodeMirror already ships copy/move/select-line; this file is the rest. + */ + +import { EditorSelection, type ChangeSpec } from '@codemirror/state'; +import { EditorView, type KeyBinding } from '@codemirror/view'; +import { + cursorSubwordBackward, + cursorSubwordForward, + selectSubwordBackward, + selectSubwordForward, +} from '@codemirror/commands'; + +const WORD = /[A-Za-z0-9_]/; + +export function wordRangeAt( + doc: { lineAt: (pos: number) => { from: number; text: string } }, + pos: number +): { from: number; to: number } { + const line = doc.lineAt(pos); + const offset = pos - line.from; + let start = offset; + let end = offset; + while (start > 0 && WORD.test(line.text[start - 1] ?? '')) start -= 1; + while (end < line.text.length && WORD.test(line.text[end] ?? '')) end += 1; + return { from: line.from + start, to: line.from + end }; +} + +function queryAt(view: EditorView): { from: number; to: number; text: string } | null { + const { state } = view; + const range = state.selection.main; + if (!range.empty) { + return { from: range.from, to: range.to, text: state.sliceDoc(range.from, range.to) }; + } + const word = wordRangeAt(state.doc, range.head); + if (word.from === word.to) return null; + return { from: word.from, to: word.to, text: state.sliceDoc(word.from, word.to) }; +} + +function indexOfFrom(haystack: string, needle: string, from: number, reverse: boolean): number { + if (!needle) return -1; + if (!reverse) { + const hit = haystack.indexOf(needle, from); + if (hit !== -1) return hit; + return haystack.indexOf(needle); + } + const before = from > 0 ? haystack.lastIndexOf(needle, from - 1) : -1; + if (before !== -1) return before; + return haystack.lastIndexOf(needle); +} + +/** Select the next occurrence of the selection (or the word at the cursor). */ +export function findUnder(view: EditorView): boolean { + return findUnderDir(view, false); +} + +export function findUnderPrevious(view: EditorView): boolean { + return findUnderDir(view, true); +} + +function findUnderDir(view: EditorView, reverse: boolean): boolean { + const q = queryAt(view); + if (!q || !q.text) return false; + const range = view.state.selection.main; + const doc = view.state.doc.toString(); + const hit = indexOfFrom(doc, q.text, reverse ? q.from : q.to, reverse); + if (hit === -1) return false; + if (hit === q.from && !range.empty) return false; + view.dispatch({ + selection: EditorSelection.range(hit, hit + q.text.length), + scrollIntoView: true, + }); + return true; +} + +/** + * Empty selection expands to the word. Otherwise jump to the next hit + * and drop the current range. + */ +export function skipAndSelectNextOccurrence(view: EditorView): boolean { + const { state } = view; + const range = state.selection.main; + if (range.empty) { + const word = wordRangeAt(state.doc, range.head); + if (word.from === word.to) return false; + view.dispatch({ + selection: EditorSelection.range(word.from, word.to), + scrollIntoView: true, + }); + return true; + } + return findUnder(view); +} + +export function splitSelectionByLine(view: EditorView): boolean { + const { state } = view; + const ranges = []; + for (const range of state.selection.ranges) { + const fromLine = state.doc.lineAt(range.from); + let toLine = state.doc.lineAt(range.to); + if (!range.empty && toLine.from === range.to) { + toLine = state.doc.line(toLine.number - 1); + } + for (let n = fromLine.number; n <= toLine.number; n++) { + const line = state.doc.line(n); + const from = n === fromLine.number ? range.from : line.from; + const to = n === toLine.number ? range.to : line.to; + ranges.push(EditorSelection.range(from, to)); + } + } + if (ranges.length === state.selection.ranges.length && ranges.length === 1) return false; + view.dispatch({ selection: EditorSelection.create(ranges) }); + return true; +} + +export function joinLines(view: EditorView): boolean { + const { state } = view; + const changes: ChangeSpec[] = []; + const cursors: { from: number }[] = []; + let shift = 0; + for (const range of state.selection.ranges) { + const startLine = state.doc.lineAt(range.from); + let endLine = range.empty ? startLine : state.doc.lineAt(range.to); + if (range.empty || endLine.number === startLine.number) { + if (startLine.number === state.doc.lines) continue; + endLine = state.doc.line(startLine.number + 1); + } else if (endLine.from === range.to && range.to > range.from) { + endLine = state.doc.line(endLine.number - 1); + } + if (endLine.number <= startLine.number) continue; + const parts: string[] = []; + for (let n = startLine.number; n <= endLine.number; n++) { + let text = state.doc.line(n).text; + if (n > startLine.number) text = text.replace(/^[ \t]+/, ''); + parts.push(text); + } + const joined = parts.join(' '); + changes.push({ from: startLine.from, to: endLine.to, insert: joined }); + cursors.push({ from: startLine.from + shift + joined.length }); + shift += joined.length - (endLine.to - startLine.from); + } + if (changes.length === 0) return false; + view.dispatch({ + changes, + selection: EditorSelection.create(cursors.map(c => EditorSelection.cursor(c.from))), + userEvent: 'join', + }); + return true; +} + +function sortRanges( + view: EditorView, + compare: (a: string, b: string) => number, + reverse: boolean +): boolean { + const { state } = view; + const changes: ChangeSpec[] = []; + for (const range of state.selection.ranges) { + if (range.empty) continue; + const fromLine = state.doc.lineAt(range.from); + let toLine = state.doc.lineAt(range.to); + if (toLine.from === range.to && range.to > range.from) { + toLine = state.doc.line(toLine.number - 1); + } + if (toLine.number - fromLine.number < 1) continue; + const lines: string[] = []; + for (let n = fromLine.number; n <= toLine.number; n++) { + lines.push(state.doc.line(n).text); + } + const sorted = [...lines].sort(compare); + if (reverse) sorted.reverse(); + if (sorted.every((line, i) => line === lines[i])) continue; + changes.push({ from: fromLine.from, to: toLine.to, insert: sorted.join('\n') }); + } + if (changes.length === 0) return false; + view.dispatch({ changes, userEvent: 'sort' }); + return true; +} + +const cmpSensitive = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); +const cmpInsensitive = (a: string, b: string) => { + const c = a.toLowerCase().localeCompare(b.toLowerCase()); + return c !== 0 ? c : cmpSensitive(a, b); +}; + +export function sortLines(view: EditorView): boolean { + return sortRanges(view, cmpSensitive, false); +} +export function reverseSortLines(view: EditorView): boolean { + return sortRanges(view, cmpSensitive, true); +} +export function sortLinesInsensitive(view: EditorView): boolean { + return sortRanges(view, cmpInsensitive, false); +} +export function reverseSortLinesInsensitive(view: EditorView): boolean { + return sortRanges(view, cmpInsensitive, true); +} + +export function insertLineBefore(view: EditorView): boolean { + const { state } = view; + const changes: ChangeSpec[] = []; + const cursors: number[] = []; + let shift = 0; + const seen = new Set(); + for (const range of state.selection.ranges) { + const line = state.doc.lineAt(range.head); + if (seen.has(line.from)) continue; + seen.add(line.from); + const indent = line.text.match(/^[ \t]*/)?.[0] ?? ''; + const insert = `${indent}\n`; + changes.push({ from: line.from, insert }); + cursors.push(line.from + shift + indent.length); + shift += insert.length; + } + if (changes.length === 0) return false; + view.dispatch({ + changes, + selection: EditorSelection.create(cursors.map(pos => EditorSelection.cursor(pos))), + userEvent: 'input', + }); + return true; +} + +function changeCase(view: EditorView, fn: (s: string) => string): boolean { + const { state } = view; + const changes: ChangeSpec[] = []; + for (const range of state.selection.ranges) { + let from = range.from; + let to = range.to; + if (from === to) { + const word = wordRangeAt(state.doc, from); + from = word.from; + to = word.to; + } + if (from === to) continue; + const next = fn(state.sliceDoc(from, to)); + if (next === state.sliceDoc(from, to)) continue; + changes.push({ from, to, insert: next }); + } + if (changes.length === 0) return false; + view.dispatch({ changes, userEvent: 'input' }); + return true; +} + +export function upcaseAtCursor(view: EditorView): boolean { + return changeCase(view, s => s.toUpperCase()); +} +export function downcaseAtCursor(view: EditorView): boolean { + return changeCase(view, s => s.toLowerCase()); +} + +export function scrollLineUp(view: EditorView): boolean { + const line = view.defaultLineHeight || 16; + view.scrollDOM.scrollTop = Math.max(0, view.scrollDOM.scrollTop - line); + return true; +} + +export function scrollLineDown(view: EditorView): boolean { + const line = view.defaultLineHeight || 16; + view.scrollDOM.scrollTop += line; + return true; +} + +export function showInCenter(view: EditorView): boolean { + view.dispatch({ + effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'center' }), + }); + return true; +} + +export const editorLineKeymap: KeyBinding[] = [ + { key: 'F5', run: sortLinesInsensitive }, + { key: 'Shift-F5', run: reverseSortLinesInsensitive }, + { key: 'F9', run: sortLinesInsensitive }, + { key: 'Shift-F9', run: reverseSortLinesInsensitive }, + { key: 'Ctrl-F5', run: sortLines }, + { key: 'Shift-Ctrl-F5', run: reverseSortLines }, + { key: 'Ctrl-F9', run: sortLines }, + { key: 'Shift-Ctrl-F9', run: reverseSortLines }, + { key: 'Ctrl-j', run: joinLines }, + { key: 'Ctrl-Shift-j', run: joinLines }, + { key: 'Ctrl-Alt-ArrowLeft', run: cursorSubwordBackward }, + { key: 'Ctrl-Alt-ArrowRight', run: cursorSubwordForward }, + { key: 'Shift-Ctrl-Alt-ArrowLeft', run: selectSubwordBackward }, + { key: 'Shift-Ctrl-Alt-ArrowRight', run: selectSubwordForward }, + { key: 'Ctrl-PageUp', run: scrollLineUp }, + { key: 'Ctrl-PageDown', run: scrollLineDown }, + { win: 'Ctrl-ArrowUp', linux: 'Ctrl-ArrowUp', run: scrollLineUp }, + { win: 'Ctrl-ArrowDown', linux: 'Ctrl-ArrowDown', run: scrollLineDown }, +];