diff --git a/packages/app-core/src/lib/cm-format.test.ts b/packages/app-core/src/lib/cm-format.test.ts index fcc2e447..c50aa600 100644 --- a/packages/app-core/src/lib/cm-format.test.ts +++ b/packages/app-core/src/lib/cm-format.test.ts @@ -51,6 +51,29 @@ describe('toggleWrap', () => { expect(view.state.selection.main.head).toBe(3) // between == and == }) + it('moves past the closing marker when toggling off an active empty-selection wrap', () => { + const view = mount('**bold** text', 6, 6) // cursor before closing ** + toggleWrap(view, '**') + expect(view.state.doc.toString()).toBe('**bold** text') + expect(view.state.selection.main.empty).toBe(true) + expect(view.state.selection.main.head).toBe(8) + }) + + it('removes an untouched empty pair when pressing the shortcut again', () => { + const view = mount('****', 2, 2) // between the opening and closing ** + toggleWrap(view, '**') + expect(view.state.doc.toString()).toBe('') + expect(view.state.selection.main.empty).toBe(true) + expect(view.state.selection.main.head).toBe(0) + }) + + it('does not treat a cursor before a later opening marker as active formatting', () => { + const view = mount('**done** **next**', 9, 9) // cursor before the second opening ** + toggleWrap(view, '**') + expect(view.state.doc.toString()).toBe('**done** ******next**') + expect(view.state.selection.main.head).toBe(11) + }) + it('works for the other markers', () => { for (const [marker, expected] of [ ['~~', 'a ~~b~~ c'], diff --git a/packages/app-core/src/lib/cm-format.ts b/packages/app-core/src/lib/cm-format.ts index 5b932313..36d347db 100644 --- a/packages/app-core/src/lib/cm-format.ts +++ b/packages/app-core/src/lib/cm-format.ts @@ -7,6 +7,23 @@ import { EditorSelection } from '@codemirror/state' import type { EditorView } from '@codemirror/view' +function isInsideUnclosedMarker(text: string, marker: string): boolean { + let count = 0 + let index = 0 + while (index < text.length) { + const found = text.indexOf(marker, index) + if (found === -1) break + if ( + marker !== '*' || + (text[found - 1] !== '*' && text[found + marker.length] !== '*') + ) { + count++ + } + index = found + marker.length + } + return count % 2 === 1 +} + /** * Toggle a symmetric inline marker around each selection range: wrap when it * isn't wrapped, unwrap when the markers already sit just outside (or just @@ -18,6 +35,31 @@ export function toggleWrap(view: EditorView, marker: string): boolean { view.state.changeByRange((range) => { const { from, to } = range if (from === to) { + const before = view.state.sliceDoc(Math.max(0, from - m.length), from) + const after = view.state.sliceDoc(from, Math.min(view.state.doc.length, from + m.length)) + + if (after === m) { + if (before === m) { + // Empty pair: pressing the shortcut again removes the markers. + return { + changes: { from: from - m.length, to: from + m.length, insert: '' }, + range: EditorSelection.cursor(from - m.length) + } + } + + const line = view.state.doc.lineAt(from) + const lineBefore = view.state.sliceDoc(line.from, from) + if (isInsideUnclosedMarker(lineBefore, m)) { + // Cursor is just before the closing marker from a previously inserted + // pair. Treat the shortcut as leaving/toggling off formatting instead + // of inserting another marker pair inside it. + return { + changes: [], + range: EditorSelection.cursor(from + m.length) + } + } + } + // No selection: insert the pair and drop the cursor between them. return { changes: { from, insert: m + m },