✨ formatting hot keys
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
|
import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
|
||||||
import { EditorState, StateField, StateEffect, Annotation, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
|
import { EditorState, EditorSelection, StateField, StateEffect, Annotation, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll, indentLess } from '@codemirror/commands'
|
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll, indentLess } from '@codemirror/commands'
|
||||||
@@ -434,6 +434,110 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Markdown formatting helpers ---
|
||||||
|
|
||||||
|
function wrapOrUnwrap(view: EditorView, marker: string): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const changes = state.changeByRange(range => {
|
||||||
|
const selected = state.sliceDoc(range.from, range.to)
|
||||||
|
const m = marker.length
|
||||||
|
|
||||||
|
// Case 1: selection itself includes the markers (e.g. user selected "**bold**")
|
||||||
|
if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length >= m * 2 + 1) {
|
||||||
|
const inner = selected.slice(m, selected.length - m)
|
||||||
|
return {
|
||||||
|
changes: { from: range.from, to: range.to, insert: inner },
|
||||||
|
range: EditorSelection.range(range.from, range.from + inner.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: markers sit just outside the selection — happens after a wrap when
|
||||||
|
// the selection is left on the inner text (e.g. cursor on "bold" inside "**bold**")
|
||||||
|
const before = range.from >= m ? state.sliceDoc(range.from - m, range.from) : ''
|
||||||
|
const after = state.sliceDoc(range.to, range.to + m)
|
||||||
|
if (before === marker && after === marker) {
|
||||||
|
return {
|
||||||
|
changes: [
|
||||||
|
{ from: range.from - m, to: range.from, insert: '' },
|
||||||
|
{ from: range.to, to: range.to + m, insert: '' }
|
||||||
|
],
|
||||||
|
range: EditorSelection.range(range.from - m, range.to - m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: not wrapped — wrap it
|
||||||
|
const wrapped = marker + selected + marker
|
||||||
|
return {
|
||||||
|
changes: { from: range.from, to: range.to, insert: wrapped },
|
||||||
|
range: EditorSelection.range(range.from + m, range.from + m + selected.length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
view.dispatch(state.update(changes, { userEvent: 'input' }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLinePrefix(view: EditorView, prefix: string): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const changes = state.changeByRange(range => {
|
||||||
|
const lineFrom = state.doc.lineAt(range.from)
|
||||||
|
const lineTo = state.doc.lineAt(range.to)
|
||||||
|
const insertions: { from: number; to: number; insert: string }[] = []
|
||||||
|
let removing = true
|
||||||
|
for (let ln = lineFrom.number; ln <= lineTo.number; ln++) {
|
||||||
|
if (!state.doc.line(ln).text.startsWith(prefix)) { removing = false; break }
|
||||||
|
}
|
||||||
|
for (let ln = lineFrom.number; ln <= lineTo.number; ln++) {
|
||||||
|
const line = state.doc.line(ln)
|
||||||
|
if (removing) {
|
||||||
|
insertions.push({ from: line.from, to: line.from + prefix.length, insert: '' })
|
||||||
|
} else if (!line.text.startsWith(prefix)) {
|
||||||
|
insertions.push({ from: line.from, to: line.from, insert: prefix })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { changes: insertions, range }
|
||||||
|
})
|
||||||
|
view.dispatch(state.update(changes, { userEvent: 'input' }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeading(view: EditorView, level: number): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const prefix = '#'.repeat(level) + ' '
|
||||||
|
const changes = state.changeByRange(range => {
|
||||||
|
const lineFrom = state.doc.lineAt(range.from)
|
||||||
|
const lineTo = state.doc.lineAt(range.to)
|
||||||
|
const insertions: { from: number; to: number; insert: string }[] = []
|
||||||
|
for (let ln = lineFrom.number; ln <= lineTo.number; ln++) {
|
||||||
|
const line = state.doc.line(ln)
|
||||||
|
const existingMatch = line.text.match(/^(#{1,6} )/)
|
||||||
|
const existingLen = existingMatch ? existingMatch[0].length : 0
|
||||||
|
if (line.text.startsWith(prefix)) {
|
||||||
|
insertions.push({ from: line.from, to: line.from + prefix.length, insert: '' })
|
||||||
|
} else {
|
||||||
|
insertions.push({ from: line.from, to: line.from + existingLen, insert: prefix })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { changes: insertions, range }
|
||||||
|
})
|
||||||
|
view.dispatch(state.update(changes, { userEvent: 'input' }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLink(view: EditorView): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const changes = state.changeByRange(range => {
|
||||||
|
const selected = state.sliceDoc(range.from, range.to)
|
||||||
|
const insert = `[${selected}]()`
|
||||||
|
const cursorPos = range.from + selected.length + 3 // inside ()
|
||||||
|
return {
|
||||||
|
changes: { from: range.from, to: range.to, insert },
|
||||||
|
range: EditorSelection.cursor(cursorPos)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
view.dispatch(state.update(changes, { userEvent: 'input' }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkdownEditor(): JSX.Element {
|
export function MarkdownEditor(): JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const viewRef = useRef<EditorView | null>(null)
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
@@ -480,6 +584,15 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ key: 'Shift-Tab', run: indentLess },
|
{ key: 'Shift-Tab', run: indentLess },
|
||||||
|
{ key: 'Mod-b', run: (view) => wrapOrUnwrap(view, '**') },
|
||||||
|
{ key: 'Mod-i', run: (view) => wrapOrUnwrap(view, '*') },
|
||||||
|
{ key: 'Mod-Shift-s', run: (view) => wrapOrUnwrap(view, '~~') },
|
||||||
|
{ key: 'Mod-e', run: (view) => wrapOrUnwrap(view, '`') },
|
||||||
|
{ key: 'Mod-k', run: insertLink },
|
||||||
|
{ key: 'Mod-Shift-.', run: (view) => toggleLinePrefix(view, '> ') },
|
||||||
|
{ key: 'Mod-1', run: (view) => setHeading(view, 1) },
|
||||||
|
{ key: 'Mod-2', run: (view) => setHeading(view, 2) },
|
||||||
|
{ key: 'Mod-3', run: (view) => setHeading(view, 3) },
|
||||||
...searchKeymap,
|
...searchKeymap,
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
...historyKeymap
|
...historyKeymap
|
||||||
|
|||||||
Reference in New Issue
Block a user