From d0b75017912bcf1dd09a0fe94d492c89d64544d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A5=98=EC=84=A0=EC=9E=84?= Date: Tue, 14 Jul 2020 21:24:51 +0900 Subject: [PATCH] feat: smart task marker in markdown (#1080) * 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 --- apps/editor/src/js/markdownEditor.js | 78 +++++++++++- apps/editor/test/unit/markdownEditor.spec.js | 124 +++++++++++++++++++ 2 files changed, 198 insertions(+), 4 deletions(-) diff --git a/apps/editor/src/js/markdownEditor.js b/apps/editor/src/js/markdownEditor.js index 88b00d375b..24f991f52e 100644 --- a/apps/editor/src/js/markdownEditor.js +++ b/apps/editor/src/js/markdownEditor.js @@ -15,7 +15,9 @@ import { getMdStartLine, getMdEndLine, getMdStartCh, - getMdEndCh + getMdEndCh, + addChPos, + findClosestNode } from './utils/markdown'; import { getMarkInfo } from './markTextHelper'; @@ -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 @@ -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 }); @@ -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) => { @@ -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( @@ -323,7 +366,6 @@ class MarkdownEditor extends CodeMirrorExt { } } - /* eslint-disable max-depth */ for (const parent of nodes) { const walker = parent.walker(); let event = walker.next(); @@ -331,13 +373,41 @@ class MarkdownEditor extends CodeMirrorExt { 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); + } + } } } diff --git a/apps/editor/test/unit/markdownEditor.spec.js b/apps/editor/test/unit/markdownEditor.spec.js index ee8ff1cd64..0e88130c13 100644 --- a/apps/editor/test/unit/markdownEditor.spec.js +++ b/apps/editor/test/unit/markdownEditor.spec.js @@ -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'); + }); + }); + }); });