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
11 changes: 11 additions & 0 deletions docs/website/documentation/notes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

- <kbd>Cmd+B</kbd> / <kbd>Ctrl+B</kbd> for **bold**
- <kbd>Cmd+I</kbd> / <kbd>Ctrl+I</kbd> for *italic*
- <kbd>Cmd+Shift+S</kbd> / <kbd>Ctrl+Shift+S</kbd> 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).

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/components/notes/NotesEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
])
})
})
154 changes: 154 additions & 0 deletions src/renderer/components/notes/cm-extensions/markdownShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<KeyBinding['run']>>[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('~~'),
},
]