Skip to content
Merged
6 changes: 4 additions & 2 deletions src/renderer/components/notes/NotesEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { indentUnit } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { EditorState, type Extension } from '@codemirror/state'
import { EditorState, type Extension, Prec } from '@codemirror/state'
import {
EditorView,
type KeyBinding,
Expand All @@ -30,6 +30,7 @@ import {
import { createImageInsert } from './cm-extensions/imageInsert'
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 { createMermaidBlocks } from './cm-extensions/mermaidBlocks'
import { moveSelectionToAdjacentMermaidSource } from './cm-extensions/mermaidNavigation'
Expand Down Expand Up @@ -174,9 +175,9 @@ function createEditorState(doc: string): EditorState {
: createNotesEditTheme(raw, notesSettings),
EditorView.lineWrapping,
history(),
Prec.highest(keymap.of(editable && !raw ? listIndent : [])),
keymap.of([
...(editable && !raw ? navigationKeymap : []),
...(editable && !raw ? listIndent : []),
...defaultKeymap,
...historyKeymap,
]),
Expand Down Expand Up @@ -214,6 +215,7 @@ function createEditorState(doc: string): EditorState {
calloutTitleMode: preview ? 'replace' : 'smart',
}),
createHideMarkup({ alwaysHide: preview }),
createListLineIndent({ interactiveTaskMarkers: editable }),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { parseListPrefix } from '../listLineIndent'

describe('parseListPrefix', () => {
it('matches unordered list markers', () => {
expect(parseListPrefix('- item')?.[1]).toBe('- ')
expect(parseListPrefix('* item')?.[1]).toBe('* ')
expect(parseListPrefix('+ item')?.[1]).toBe('+ ')
})

it('matches nested unordered list markers', () => {
expect(parseListPrefix(' - item')?.[1]).toBe(' - ')
expect(parseListPrefix(' - item')?.[1]).toBe(' - ')
})

it('matches ordered list markers', () => {
expect(parseListPrefix('1. item')?.[1]).toBe('1. ')
expect(parseListPrefix('10. item')?.[1]).toBe('10. ')
expect(parseListPrefix('123. item')?.[1]).toBe('123. ')
})

it('matches nested ordered list markers', () => {
expect(parseListPrefix(' 1. item')?.[1]).toBe(' 1. ')
})

it('matches task markers', () => {
const match = parseListPrefix('- [ ] task')
expect(match?.[1]).toBe('- ')
expect(match?.[2]).toBe('[ ]')
})

it('matches checked task markers', () => {
const matchLower = parseListPrefix('- [x] done')
expect(matchLower?.[2]).toBe('[x]')

const matchUpper = parseListPrefix('- [X] done')
expect(matchUpper?.[2]).toBe('[X]')
})

it('matches nested task markers', () => {
const match = parseListPrefix(' - [ ] nested task')
expect(match?.[1]).toBe(' - ')
expect(match?.[2]).toBe('[ ]')
})

it('captures full prefix for task markers', () => {
const match = parseListPrefix('- [ ] task')
expect(match?.[0]).toBe('- [ ] ')
})

it('returns null for non-list lines', () => {
expect(parseListPrefix('regular text')).toBeNull()
expect(parseListPrefix('# heading')).toBeNull()
expect(parseListPrefix('> quote')).toBeNull()
expect(parseListPrefix('')).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class InternalLinkWidget extends WidgetType {
toDOM(): HTMLElement {
const root = document.createElement('span')
root.className = `cm-internal-link is-${this.status}`
root.style.textIndent = '0'
root.dataset.internalLink = 'true'
root.dataset.internalLinkBroken = String(this.status === 'broken')
root.dataset.internalLinkFrom = String(this.link.from)
Expand Down
117 changes: 117 additions & 0 deletions src/renderer/components/notes/cm-extensions/listIndent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,121 @@ export const listIndent: KeyBinding[] = [
return true
},
},
{
key: 'Enter',
run(view) {
const { state } = view

if (state.selection.ranges.length !== 1)
return false

const range = state.selection.main
if (!range.empty)
return false

const currentLine = state.doc.lineAt(range.head)

let firstLine: { from: number, text: string, number: number } | null
= null

if (listRe.test(currentLine.text)) {
firstLine = currentLine
}
else if (/^\s/.test(currentLine.text)) {
for (let ln = currentLine.number - 1; ln >= 1; ln--) {
const prevLine = state.doc.line(ln)
if (listRe.test(prevLine.text)) {
firstLine = prevLine
break
}
if (!/^\s/.test(prevLine.text) && prevLine.text !== '')
break
if (prevLine.text === '')
break
}
}

if (!firstLine)
return false

const match = firstLine.text.match(listRe)!

if (
currentLine.number === firstLine.number
&& range.head < firstLine.from + match[0].length
) {
return false
}

const [full, indent, marker] = match
const afterMarker = firstLine.text.slice(full.length)
const taskMatch = afterMarker.match(/^\[[ x]\]\s/i)
const hasTask = !!taskMatch
const taskPrefixLen = hasTask ? taskMatch![0].length : 0

const contentAfter = afterMarker.slice(taskPrefixLen).trim()
const onFirstLine = currentLine.number === firstLine.number

if (onFirstLine && contentAfter === '') {
view.dispatch({
changes: {
from: firstLine.from,
to: firstLine.from + full.length + taskPrefixLen,
insert: '',
},
selection: { anchor: firstLine.from },
scrollIntoView: true,
userEvent: 'input',
})
return true
}

const orderedMatch = marker.match(/^(\d+)\.$/)
const nextMarker = orderedMatch
? `${Number(orderedMatch[1]) + 1}.`
: marker
const taskSuffix = hasTask ? '[ ] ' : ''
const nextPrefix = `${indent}${nextMarker} ${taskSuffix}`
const insert = `\n${nextPrefix}`

view.dispatch({
changes: { from: range.from, to: range.to, insert },
selection: { anchor: range.from + insert.length },
scrollIntoView: true,
userEvent: 'input',
})
return true
},
},
{
key: 'Shift-Enter',
run(view) {
const { state } = view

if (state.selection.ranges.length !== 1)
return false

const range = state.selection.main
if (!range.empty)
return false

const line = state.doc.lineAt(range.head)
const match = line.text.match(listRe)
if (!match)
return false

if (range.head < line.from + match[0].length)
return false

const insert = `\n${' '.repeat(match[0].length)}`

view.dispatch({
changes: { from: range.from, to: range.to, insert },
selection: { anchor: range.from + insert.length },
scrollIntoView: true,
userEvent: 'input',
})
return true
},
},
]
Loading