diff --git a/docs/website/documentation/notes/index.md b/docs/website/documentation/notes/index.md index b4e22aa3..fd7402c0 100644 --- a/docs/website/documentation/notes/index.md +++ b/docs/website/documentation/notes/index.md @@ -56,12 +56,23 @@ The editor is built on CodeMirror 6 and includes: - Syntax highlighting for fenced code blocks - Smart list indentation with automatic ordered list renumbering - Tab / Shift-Tab indentation +- Inline markdown formatting shortcuts in editable modes - Table navigation between cells - Internal links to notes and snippets - Mermaid diagram support - Image embedding - Callout blocks +## Formatting Shortcuts + +The following shortcuts work in **Editor** and **Live Preview** modes: + +- Cmd+B / Ctrl+B for **bold** +- Cmd+I / Ctrl+I for *italic* +- Cmd+Shift+S / Ctrl+Shift+S for ~~strikethrough~~ + +Press the same shortcut again to remove the markdown markers from the current selection. + For visual diagrams in notes, see [Mermaid](/documentation/notes/mermaid). For wiki-style links between notes and snippets, see [Internal Links](/documentation/notes/internal-links). diff --git a/src/renderer/components/notes/NotesEditor.vue b/src/renderer/components/notes/NotesEditor.vue index 306c3d7c..86c017a1 100644 --- a/src/renderer/components/notes/NotesEditor.vue +++ b/src/renderer/components/notes/NotesEditor.vue @@ -32,6 +32,7 @@ import { createInternalLinks } from './cm-extensions/internalLinks' import { listIndent } from './cm-extensions/listIndent' import { createListLineIndent } from './cm-extensions/listLineIndent' import { createMarkdownDecorations } from './cm-extensions/markdownDecorations' +import { markdownShortcuts } from './cm-extensions/markdownShortcuts' import { createMermaidBlocks } from './cm-extensions/mermaidBlocks' import { moveSelectionToAdjacentMermaidSource } from './cm-extensions/mermaidNavigation' import { notesEditorScrollbarTheme } from './cm-extensions/scrollbarTheme' @@ -177,6 +178,7 @@ function createEditorState(doc: string): EditorState { history(), Prec.highest(keymap.of(editable && !raw ? listIndent : [])), keymap.of([ + ...(editable ? markdownShortcuts : []), ...(editable && !raw ? navigationKeymap : []), ...defaultKeymap, ...historyKeymap, diff --git a/src/renderer/components/notes/cm-extensions/__tests__/markdownShortcuts.test.ts b/src/renderer/components/notes/cm-extensions/__tests__/markdownShortcuts.test.ts new file mode 100644 index 00000000..635527f0 --- /dev/null +++ b/src/renderer/components/notes/cm-extensions/__tests__/markdownShortcuts.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { markdownShortcuts, toggleInlineMarkdown } from '../markdownShortcuts' + +describe('toggleInlineMarkdown', () => { + it('wraps selected text with bold markers', () => { + expect(toggleInlineMarkdown('hello world', 0, 5, '**')).toEqual({ + selection: { + from: 2, + to: 7, + }, + text: '**hello** world', + }) + }) + + it('unwraps selected text when bold markers are adjacent to the selection', () => { + expect(toggleInlineMarkdown('**hello** world', 2, 7, '**')).toEqual({ + selection: { + from: 0, + to: 5, + }, + text: 'hello world', + }) + }) + + it('unwraps selected text when the selection already includes the markers', () => { + expect(toggleInlineMarkdown('**hello** world', 0, 9, '**')).toEqual({ + selection: { + from: 0, + to: 5, + }, + text: 'hello world', + }) + }) + + it('inserts italic markers around an empty selection and places cursor between them', () => { + expect(toggleInlineMarkdown('hello', 5, 5, '*')).toEqual({ + selection: { + from: 6, + to: 6, + }, + text: 'hello**', + }) + }) + + it('wraps selected text with strikethrough markers', () => { + expect(toggleInlineMarkdown('hello world', 6, 11, '~~')).toEqual({ + selection: { + from: 8, + to: 13, + }, + text: 'hello ~~world~~', + }) + }) +}) + +describe('markdownShortcuts', () => { + it('registers bold, italic, and strikethrough shortcuts', () => { + expect(markdownShortcuts.map(binding => binding.key)).toEqual([ + 'Mod-b', + 'Mod-i', + 'Mod-Shift-s', + ]) + }) +}) diff --git a/src/renderer/components/notes/cm-extensions/markdownShortcuts.ts b/src/renderer/components/notes/cm-extensions/markdownShortcuts.ts new file mode 100644 index 00000000..bc329b59 --- /dev/null +++ b/src/renderer/components/notes/cm-extensions/markdownShortcuts.ts @@ -0,0 +1,154 @@ +import type { KeyBinding } from '@codemirror/view' +import { EditorSelection } from '@codemirror/state' + +interface SelectionRange { + from: number + to: number +} + +interface InlineMarkdownEdit { + change: { + from: number + insert: string + to: number + } + selection: SelectionRange +} + +function createInlineMarkdownEdit( + text: string, + from: number, + to: number, + marker: string, +): InlineMarkdownEdit { + const selection = text.slice(from, to) + const markerLength = marker.length + + if (from === to) { + return { + change: { + from, + insert: `${marker}${marker}`, + to, + }, + selection: { + from: from + markerLength, + to: from + markerLength, + }, + } + } + + if ( + selection.length >= markerLength * 2 + && selection.startsWith(marker) + && selection.endsWith(marker) + ) { + const unwrapped = selection.slice( + markerLength, + selection.length - markerLength, + ) + + return { + change: { + from, + insert: unwrapped, + to, + }, + selection: { + from, + to: from + unwrapped.length, + }, + } + } + + const hasAdjacentMarkers + = from >= markerLength + && text.slice(from - markerLength, from) === marker + && text.slice(to, to + markerLength) === marker + + if (hasAdjacentMarkers) { + return { + change: { + from: from - markerLength, + insert: selection, + to: to + markerLength, + }, + selection: { + from: from - markerLength, + to: from - markerLength + selection.length, + }, + } + } + + return { + change: { + from, + insert: `${marker}${selection}${marker}`, + to, + }, + selection: { + from: from + markerLength, + to: to + markerLength, + }, + } +} + +export function toggleInlineMarkdown( + text: string, + from: number, + to: number, + marker: string, +) { + const edit = createInlineMarkdownEdit(text, from, to, marker) + + return { + selection: edit.selection, + text: + text.slice(0, edit.change.from) + + edit.change.insert + + text.slice(edit.change.to), + } +} + +function createInlineMarkdownCommand(marker: string) { + return (view: Parameters>[0]) => { + const docText = view.state.doc.toString() + const change = view.state.changeByRange((range) => { + const edit = createInlineMarkdownEdit( + docText, + range.from, + range.to, + marker, + ) + + return { + changes: edit.change, + range: EditorSelection.range(edit.selection.from, edit.selection.to), + } + }) + + view.dispatch( + view.state.update(change, { + scrollIntoView: true, + userEvent: 'input', + }), + ) + + return true + } +} + +export const markdownShortcuts: KeyBinding[] = [ + { + key: 'Mod-b', + run: createInlineMarkdownCommand('**'), + }, + { + key: 'Mod-i', + run: createInlineMarkdownCommand('*'), + }, + { + key: 'Mod-Shift-s', + run: createInlineMarkdownCommand('~~'), + }, +]