Skip to content

Commit

Permalink
Add basic markdown commands
Browse files Browse the repository at this point in the history
  • Loading branch information
aaron7 committed Jun 5, 2024
1 parent 8f4d7aa commit f78c7aa
Show file tree
Hide file tree
Showing 4 changed files with 211 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/components/MarkdownEditor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import { Prec } from '@codemirror/state';
import { EditorView, ViewUpdate } from '@codemirror/view';
import CodeMirror, { EditorView as RCEditorView } from '@uiw/react-codemirror';
import { WebrtcProvider } from 'y-webrtc';
Expand All @@ -9,6 +10,7 @@ import { useTheme } from '@/components/ThemeProvider/ThemeProvider';

import createCollabPlugin from './extensions/collab/collab';
import fenchedCodePlugin from './extensions/fenced-code/decoration';
import markdownCommands from './extensions/markdown/commands';
import markdownHeadings from './extensions/markdown/headings';
import { getTheme } from './extensions/theme/theme';

Expand Down Expand Up @@ -48,6 +50,7 @@ const Editor = ({ onChange, value, webrtcProvider }: EditorProps) => {
getTheme(theme === 'system' ? systemTheme : theme),
markdown({ base: markdownLanguage, codeLanguages: languages }),
markdownHeadings,
Prec.high(markdownCommands),
RCEditorView.lineWrapping,
...(collabPlugin ? [collabPlugin] : []),
fenchedCodePlugin,
Expand Down
84 changes: 84 additions & 0 deletions src/components/MarkdownEditor/extensions/markdown/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { EditorState, StateCommand } from '@codemirror/state';
import { describe, expect, it } from 'vitest';

import { mkState, stateStr } from '@/test-utils/state';

import { makeBold, makeItalic, makeStrikethrough } from './commands';

const cmd = (state: EditorState, command: StateCommand) => {
command({
dispatch(tr) {
state = tr.state;
},
state,
});
return state;
};

const testMakeBold = (from: string, to: string) => {
expect(stateStr(cmd(mkState(from), makeBold))).toBe(to);
};

const testMakeItalic = (from: string, to: string) => {
expect(stateStr(cmd(mkState(from), makeItalic))).toBe(to);
};

const testMakeStrikethrough = (from: string, to: string) => {
expect(stateStr(cmd(mkState(from), makeStrikethrough))).toBe(to);
};

describe('commands', () => {
describe.each([
{ command: 'makeBold', test: testMakeBold, wrap: '**' },
{ command: 'makeItalic', test: testMakeItalic, wrap: '*' },
{
command: 'makeStrikethrough',
test: testMakeStrikethrough,
wrap: '~~',
},
])('$command', ({ test, wrap }) => {
describe('wraps', () => {
it('word from end', () => {
test(' hello| ', ` ${wrap}hello|${wrap} `);
});

it('word from middle', () => {
test(' h|ello ', ` ${wrap}h|ello${wrap} `);
});

it('word from start', () => {
test(' |hello ', ` ${wrap}|hello${wrap} `);
});

it('selected word', () => {
test(' <hello> ', ` ${wrap}<hello>${wrap} `);
});

it('partial word', () => {
test('he<llo worl>d', `he${wrap}<llo worl>${wrap}d`);
});
});

describe('unwraps', () => {
it('word from end', () => {
test(` ${wrap}hello|${wrap} `, ' hello| ');
});

it('word from middle', () => {
test(` ${wrap}h|ello${wrap} `, ' h|ello ');
});

it('word from start', () => {
test(` ${wrap}|hello${wrap} `, ' |hello ');
});

it('selected word', () => {
test(` ${wrap}<hello>${wrap} `, ' <hello> ');
});

it('partial word', () => {
test(`he${wrap}<llo worl>${wrap}d`, 'he<llo worl>d');
});
});
});
});
81 changes: 81 additions & 0 deletions src/components/MarkdownEditor/extensions/markdown/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
EditorSelection,
EditorState,
StateCommand,
Transaction,
} from '@codemirror/state';
import { keymap } from '@codemirror/view';

export function wrapTextWith(
state: EditorState,
dispatch: (tr: Transaction) => void,
openString: string,
closeString: string = openString,
) {
dispatch(
state.update(
state.changeByRange((range) => {
const getTargetRange = (from: number, to: number) => {
if (from === to) {
// If no selection, use the word at the cursor
const word = state.wordAt(from);
if (!word) {
return { from, to };
}
return { from: word.from, to: word.to };
}
return { from, to };
};

const { from, to } = getTargetRange(range.from, range.to);

const existingOpen = state.sliceDoc(from - openString.length, from);
const existingClose = state.sliceDoc(to, to + closeString.length);

if (existingOpen === openString && existingClose === closeString) {
// Undo the wrapping if the selection is already wrapped
return {
changes: [
{ from: from - openString.length, insert: '', to: from },
{ from: to, insert: '', to: to + closeString.length },
],
range: EditorSelection.range(
range.from - openString.length,
range.to - openString.length,
),
};
}

return {
changes: [
{ from, insert: openString },
{ from: to, insert: closeString },
],
range: EditorSelection.range(
range.from + openString.length,
range.to + openString.length,
),
};
}),
),
);

return true;
}

export const makeBold: StateCommand = ({ dispatch, state }) =>
wrapTextWith(state, dispatch, '**');

export const makeItalic: StateCommand = ({ dispatch, state }) =>
wrapTextWith(state, dispatch, '*');

export const makeStrikethrough: StateCommand = ({ dispatch, state }) =>
wrapTextWith(state, dispatch, '~~');

const markdownKeymap = keymap.of([
{ key: 'Mod-b', run: makeBold },
{ key: 'Mod-i', run: makeItalic },
{ key: 'Mod-shift-s', run: makeStrikethrough },
]);

export default markdownKeymap;
43 changes: 43 additions & 0 deletions src/test-utils/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Useful test helpers from https://github.com/codemirror/commands/blob/6.6.0/test/state.ts

import { EditorSelection, EditorState, Extension } from '@codemirror/state';

export function mkState(doc: string, extensions: Extension = []) {
const range = /\||<([^]*?)>/g;
let m;
const ranges = [];
while ((m = range.exec(doc))) {
if (m[1]) {
ranges.push(EditorSelection.range(m.index, m.index + m[1].length));
doc =
doc.slice(0, m.index) +
doc.slice(m.index + 1, m.index + 1 + m[1].length) +
doc.slice(m.index + m[0].length);
range.lastIndex -= 2;
} else {
ranges.push(EditorSelection.cursor(m.index));
doc = doc.slice(0, m.index) + doc.slice(m.index + 1);
range.lastIndex--;
}
}
return EditorState.create({
doc,
extensions: [extensions, EditorState.allowMultipleSelections.of(true)],
selection: ranges.length ? EditorSelection.create(ranges) : undefined,
});
}

export function stateStr(state: EditorState) {
let doc = state.doc.toString();
for (let i = state.selection.ranges.length - 1; i >= 0; i--) {
const range = state.selection.ranges[i];
doc = range.empty
? doc.slice(0, range.from) + '|' + doc.slice(range.from)
: doc.slice(0, range.from) +
'<' +
doc.slice(range.from, range.to) +
'>' +
doc.slice(range.to);
}
return doc;
}

0 comments on commit f78c7aa

Please sign in to comment.