Skip to content

Commit

Permalink
feat: smart task marker in markdown (#1080)
Browse files Browse the repository at this point in the history
* wip: apply smart checkbox when entering key

* wip: change text to task state when using shortcut

* wip: remove unused function and change shortcut

* wip: add unit test and change function name

* refactor: change file name and terms

* wip: change task marker when pasting

* feat: apply code review

* fix: remove logic to set value

* chore: edit unit test of spec

* feat: apply code review
  • Loading branch information
seonim-ryu authored Jul 14, 2020
1 parent 337e860 commit d0b7501
Show file tree
Hide file tree
Showing 2 changed files with 198 additions and 4 deletions.
78 changes: 74 additions & 4 deletions apps/editor/src/js/markdownEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
getMdStartLine,
getMdEndLine,
getMdStartCh,
getMdEndCh
getMdEndCh,
addChPos,
findClosestNode
} from './utils/markdown';
import { getMarkInfo } from './markTextHelper';

Expand Down Expand Up @@ -103,6 +105,8 @@ function isToolbarStateChanged(previousState, currentState) {
}

const ATTR_NAME_MARK = 'data-tui-mark';
const TASK_MARKER_RX = /^\[(\s*)(x?)(\s*)\](?:\s+)/i;
const TASK_MARKER_KEY_RX = /x|backspace/i;

/**
* Class MarkdownEditor
Expand All @@ -118,7 +122,8 @@ class MarkdownEditor extends CodeMirrorExt {
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
Tab: 'indentOrderedList',
'Shift-Tab': 'indentLessOrderedList'
'Shift-Tab': 'indentLessOrderedList',
'Shift-Ctrl-X': () => this._toggleTaskStates()
},
viewportMargin: options && options.height === 'auto' ? Infinity : 10
});
Expand Down Expand Up @@ -207,6 +212,12 @@ class MarkdownEditor extends CodeMirrorExt {
source: 'markdown',
data: keyboardEvent
});

const { key } = keyboardEvent;

if (TASK_MARKER_KEY_RX.test(key)) {
this._changeTextToTaskMarker(keyboardEvent);
}
});

this.cm.on('copy', (cm, ev) => {
Expand Down Expand Up @@ -286,6 +297,38 @@ class MarkdownEditor extends CodeMirrorExt {
}
}

_changeTaskState(mdNode, line) {
const { listData, sourcepos } = mdNode;
const { task, checked, padding } = listData;

if (task) {
const stateChar = checked ? ' ' : 'x';
const [[, startCh]] = sourcepos;
const startPos = { line, ch: startCh + padding };

this.cm.replaceRange(stateChar, startPos, addChPos(startPos, 1));
}
}

_toggleTaskStates() {
const ranges = this.cm.listSelections();

ranges.forEach(range => {
const { anchor, head } = range;
const startLine = Math.min(anchor.line, head.line);
const endLine = Math.max(anchor.line, head.line);
let mdNode;

for (let index = startLine, len = endLine; index <= len; index += 1) {
mdNode = this.toastMark.findFirstNodeAtLine(index + 1);

if (mdNode.type === 'list' || mdNode.type === 'item') {
this._changeTaskState(mdNode, index);
}
}
});
}

_refreshCodeMirrorMarks(e) {
const { from, to, text } = e;
const changed = this.toastMark.editMarkdown(
Expand Down Expand Up @@ -323,21 +366,48 @@ class MarkdownEditor extends CodeMirrorExt {
}
}

/* eslint-disable max-depth */
for (const parent of nodes) {
const walker = parent.walker();
let event = walker.next();

while (event) {
const { node, entering } = event;

// eslint-disable-next-line max-depth
if (entering) {
this._markNode(node);
}
event = walker.next();
}
}
/* eslint-enable max-depth */
}
}

_changeTextToTaskMarker() {
const { line, ch } = this.cm.getCursor();
const mdCh = this.cm.getLine(line).length === ch ? ch : ch + 1;
const mdNode = this.toastMark.findNodeAtPosition([line + 1, mdCh]);
const paraNode = findClosestNode(
mdNode,
node => node.type === 'paragraph' && node.parent && node.parent.type === 'item'
);

if (paraNode && paraNode.firstChild) {
const { literal, sourcepos } = paraNode.firstChild;
const [[startLine, startCh]] = sourcepos;
const matched = literal.match(TASK_MARKER_RX);

if (matched) {
const [, startSpaces, stateChar, lastSpaces] = matched;
const spaces = startSpaces.length + lastSpaces.length;
const startPos = { line: startLine - 1, ch: startCh };

if (stateChar) {
this.cm.replaceRange(stateChar, startPos, addChPos(startPos, spaces ? spaces + 1 : 0));
} else if (!spaces) {
this.cm.replaceRange(' ', startPos, startPos);
}
}
}
}

Expand Down
124 changes: 124 additions & 0 deletions apps/editor/test/unit/markdownEditor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,128 @@ describe('MarkdownEditor', () => {
mde.focus();
mde.blur();
});

describe('task', () => {
let setValue, setCursor, setSelection, getValue;
let changeTextToTaskMarker, toggleTaskStates;

function init() {
const doc = mde.getEditor().getDoc();

setValue = val => mde.setValue(val);
setCursor = pos => doc.setCursor(pos);
setSelection = (from, to) => doc.setSelection(from, to);
getValue = () => mde.getValue();
toggleTaskStates = () => mde._toggleTaskStates();
changeTextToTaskMarker = () => mde._changeTextToTaskMarker();
}

beforeEach(() => {
init();
});

describe('changeTextToTaskMarker()', () => {
it('spaces before state character in marker are removed', () => {
setValue('* [ x] list');
setCursor({ line: 0, ch: 3 });
changeTextToTaskMarker();

expect(getValue()).toBe('* [x] list');
});

it('spaces after state character in marker are removed', () => {
setValue('* [x ] list');
setCursor({ line: 0, ch: 5 });
changeTextToTaskMarker();

expect(getValue()).toBe('* [x] list');
});

it('all spaces in marker are removed', () => {
setValue('* [ x ] list');
setCursor({ line: 0, ch: 3 });
changeTextToTaskMarker();

expect(getValue()).toBe('* [x] list');
});

it('space is added if marker has no spaces', () => {
setValue('* [] list');
setCursor({ line: 0, ch: 3 });
changeTextToTaskMarker();

expect(getValue()).toBe('* [ ] list');
});
});

it('toggle task state according to cursor position', () => {
setValue('1. [ ] list1\n\t* [x] list2');

setCursor({ line: 1, ch: 0 }); // in list node
toggleTaskStates();

expect(getValue()).toBe('1. [ ] list1\n\t* [ ] list2');

setCursor({ line: 0, ch: 3 }); // in item node
toggleTaskStates();

expect(getValue()).toBe('1. [x] list1\n\t* [ ] list2');

setCursor({ line: 1, ch: 5 }); // in marker syntax
toggleTaskStates();

expect(getValue()).toBe('1. [x] list1\n\t* [x] list2');

setCursor({ line: 0, ch: 10 }); // in text node
toggleTaskStates();

expect(getValue()).toBe('1. [ ] list1\n\t* [x] list2');

setValue('1. [x] li **st** 1'); // in text node with inline style
setCursor({ line: 0, ch: 13 });
toggleTaskStates();

expect(getValue()).toBe('1. [ ] li **st** 1');
});

describe('toggle task state according to selection', () => {
let list;

beforeEach(() => {
list = '1. [ ] list1\n\t* [ ] list2\n\t\t* [x] list3';
});

it('when all from start line to last line is selected', () => {
setValue(list);
setSelection({ line: 0, ch: 0 }, { line: 2, ch: 19 });
toggleTaskStates();

expect(getValue()).toBe('1. [x] list1\n\t* [x] list2\n\t\t* [ ] list3');
});

it('when text of start line to text of last line is selected', () => {
setValue(list);
setSelection({ line: 1, ch: 11 }, { line: 2, ch: 15 });
toggleTaskStates();

expect(getValue()).toBe('1. [ ] list1\n\t* [x] list2\n\t\t* [ ] list3');
});

it('when marker of start line to list of last line is selected', () => {
setValue(list);
setSelection({ line: 0, ch: 4 }, { line: 1, ch: 0 });
toggleTaskStates();

expect(getValue()).toBe('1. [x] list1\n\t* [x] list2\n\t\t* [x] list3');
});

it('when selecting from bottom cursor to top cursor', () => {
setValue(list);
setSelection({ line: 1, ch: 0 }, { line: 0, ch: 4 });
toggleTaskStates();

expect(getValue()).toBe('1. [x] list1\n\t* [x] list2\n\t\t* [x] list3');
});
});
});
});

0 comments on commit d0b7501

Please sign in to comment.