Repository: nhn/tui.editor
Pull request #2572 (2022-06-22 06:50:21)

fix: convert by using HTML tag when it is not between spaces
<!-- EDIT TITLE PLEASE -->
<!-- It should be one of them
  <ISSUE TYPE>: Short Description (<CLOSING TYPE> #<ISSUE NUMBERS>)
  ex)
  feat: add new feature (close #111)
  fix: wrong behavior (fix #111)
  chore: change build tool (ref #111)
-->

<!-- SPECIFY A ISSUE TYPE AT HEAD
  feat: A new feature
  fix: A bug fix
  docs: Documentation only changes
  style: Changes that do not affect the meaning of the code (white-space, formatting etc)
  refactor: A code change that neither fixes a bug or adds a feature
  perf: A code change that improves performance
  test: Adding missing tests
  chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->

<!-- ADD CLOSING TYPE AND ISSUE NUMBER AT TAIL
  (<CLOSING TYPE> #<ISSUE NUMBERS>)
  close: resolve not a bug(feature, docs, etc) completely
  fix: resolve a bug completely
  ref: not fully resolved or related to
-->

### Please check if the PR fulfills these requirements
- [x] It's the right issue type on the title
- [ ] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
- [x] The commit message follows our guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [ ] Docs have been added/updated (for bug fixes/features)
- [ ] It does not introduce a breaking change or has a description of the breaking change

### Description

* Fix the converter to match the spec difference between WYSIWYG and Markdown.
  * Following [markdown spec (Rule 5)](https://spec.commonmark.org/0.30/#Rule_5), the markdown like `**"text"**a` is not parsed.
  * But in WYSIWIG, It is possible that typing content like <strong>"test"</strong>a.
  * So, to match both, when typing it in WYSIWIG and converting to markdown, It will be converted by using an HTML tag, not a markdown delimiter.

**Typing content in WYSIWYG**
<img width="710" src="https://user-images.githubusercontent.com/41339744/174962482-3943a4b6-b224-491c-848a-85d579131bbc.png">


**As-Is**
<img width="710" src="https://user-images.githubusercontent.com/41339744/174962517-0cfdc8dc-793a-4348-923b-f92d1116ea48.png">

**To-Be**
<img width="710" src="https://user-images.githubusercontent.com/41339744/174962549-d3abab42-f28f-4507-b755-26c8c00ff8c0.png">


---
Thank you for your contribution to TOAST UI product. 🎉 😘 ✨



Patch:
diff --git a/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts b/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts
index 544a186f..99991a34 100644
--- a/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts
+++ b/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts
@@ -1,6 +1,6 @@
 import { Node, Mark } from 'prosemirror-model';
 
-import { includes, escape, last } from '@/utils/common';
+import { includes, escape, last, isEndWithSpace, isStartWithSpace } from '@/utils/common';
 
 import { WwNodeType, WwMarkType } from '@t/wysiwyg';
 import {
@@ -10,6 +10,7 @@ import {
   FirstDelimFn,
   InfoForPosSync,
 } from '@t/convertor';
+import { DEFAULT_TEXT_NOT_START_OR_END_WITH_SPACE } from '@/utils/constants';
 
 export default class ToMdConvertorState {
   private readonly nodeTypeConvertors: ToMdNodeTypeConvertorMap;
@@ -49,11 +50,27 @@ export default class ToMdConvertorState {
     return /(^|\n)$/.test(this.result);
   }
 
+  private isBetweenSpaces(parent: Node, index: number) {
+    const { content } = parent;
+
+    const isFrontNodeEndWithSpace =
+      index === 0 ||
+      isEndWithSpace(content.child(index - 1).text ?? DEFAULT_TEXT_NOT_START_OR_END_WITH_SPACE);
+
+    const isRearNodeStartWithSpace =
+      index >= content.childCount - 1 ||
+      isStartWithSpace(content.child(index + 1).text ?? DEFAULT_TEXT_NOT_START_OR_END_WITH_SPACE);
+
+    return isFrontNodeEndWithSpace && isRearNodeStartWithSpace;
+  }
+
   private markText(mark: Mark, entering: boolean, parent: Node, index: number) {
     const convertor = this.getMarkConvertor(mark);
 
     if (convertor) {
-      const { delim, rawHTML } = convertor({ node: mark, parent, index }, entering);
+      const betweenSpace = this.isBetweenSpaces(parent, entering ? index : index - 1);
+
+      const { delim, rawHTML } = convertor({ node: mark, parent, index }, entering, betweenSpace);
 
       return (rawHTML as string) || (delim as string);
     }
diff --git a/apps/editor/src/convertors/toMarkdown/toMdConvertors.ts b/apps/editor/src/convertors/toMarkdown/toMdConvertors.ts
index 71066e7b..8f1a1904 100644
--- a/apps/editor/src/convertors/toMarkdown/toMdConvertors.ts
+++ b/apps/editor/src/convertors/toMarkdown/toMdConvertors.ts
@@ -208,29 +208,44 @@ export const toMdConvertors: ToMdConvertorMap = {
     };
   },
 
-  strong({ node }, { entering }) {
+  strong({ node }, { entering }, betweenSpace) {
     const { rawHTML } = node.attrs;
+    let delim = '**';
+
+    if (!betweenSpace) {
+      delim = entering ? '<strong>' : '</strong>';
+    }
 
     return {
-      delim: '**',
+      delim,
       rawHTML: entering ? getOpenRawHTML(rawHTML) : getCloseRawHTML(rawHTML),
     };
   },
 
-  emph({ node }, { entering }) {
+  emph({ node }, { entering }, betweenSpace) {
     const { rawHTML } = node.attrs;
+    let delim = '*';
+
+    if (!betweenSpace) {
+      delim = entering ? '<em>' : '</em>';
+    }
 
     return {
-      delim: '*',
+      delim,
       rawHTML: entering ? getOpenRawHTML(rawHTML) : getCloseRawHTML(rawHTML),
     };
   },
 
-  strike({ node }, { entering }) {
+  strike({ node }, { entering }, betweenSpace) {
     const { rawHTML } = node.attrs;
+    let delim = '~~';
+
+    if (!betweenSpace) {
+      delim = entering ? '<del>' : '</del>';
+    }
 
     return {
-      delim: '~~',
+      delim,
       rawHTML: entering ? getOpenRawHTML(rawHTML) : getCloseRawHTML(rawHTML),
     };
   },
@@ -353,7 +368,7 @@ function createMarkTypeConvertors(convertors: ToMdConvertorMap) {
   const markTypes = Object.keys(markTypeOptions) as WwMarkType[];
 
   markTypes.forEach((type) => {
-    markTypeConvertors[type] = (nodeInfo, entering) => {
+    markTypeConvertors[type] = (nodeInfo, entering, betweenSpace) => {
       const markOption = markTypeOptions[type];
       const convertor = convertors[type];
 
@@ -362,7 +377,9 @@ function createMarkTypeConvertors(convertors: ToMdConvertorMap) {
       // When calling the converter without using `delim` and `rawHTML` values,
       // the converter is called without parameters.
       const runConvertor = convertor && nodeInfo && !isUndefined(entering);
-      const params = runConvertor ? convertor!(nodeInfo as MarkInfo, { entering }) : {};
+      const params = runConvertor
+        ? convertor!(nodeInfo as MarkInfo, { entering }, betweenSpace)
+        : {};
 
       return { ...params, ...markOption };
     };
diff --git a/apps/editor/src/utils/common.ts b/apps/editor/src/utils/common.ts
index 343eecc8..cd38e930 100644
--- a/apps/editor/src/utils/common.ts
+++ b/apps/editor/src/utils/common.ts
@@ -257,3 +257,15 @@ export function assign(targetObj: Record<string, any>, obj: Record<string, any>
 export function getSortedNumPair(valueA: number, valueB: number) {
   return valueA > valueB ? [valueB, valueA] : [valueA, valueB];
 }
+
+export function isStartWithSpace(text: string) {
+  const reStartWithSpace = /^\s(\S*)/g;
+
+  return reStartWithSpace.test(text);
+}
+
+export function isEndWithSpace(text: string) {
+  const reEndWithSpace = /(\S*)\s$/g;
+
+  return reEndWithSpace.test(text);
+}
diff --git a/apps/editor/src/utils/constants.ts b/apps/editor/src/utils/constants.ts
index 2d6cd549..78d5adf3 100644
--- a/apps/editor/src/utils/constants.ts
+++ b/apps/editor/src/utils/constants.ts
@@ -20,3 +20,5 @@ export const reBR = /<br\s*\/*>/i;
 export const reHTMLComment = /<! ---->|<!--(?:-?[^>-])(?:-?[^-])*-->/;
 
 export const ALTERNATIVE_TAG_FOR_BR = '</p><p>';
+
+export const DEFAULT_TEXT_NOT_START_OR_END_WITH_SPACE = 'a';
diff --git a/apps/editor/types/convertor.d.ts b/apps/editor/types/convertor.d.ts
index e8ef7d5f..d44db7d0 100644
--- a/apps/editor/types/convertor.d.ts
+++ b/apps/editor/types/convertor.d.ts
@@ -111,7 +111,8 @@ export type ToMdNodeTypeConvertorMap = Partial<Record<WwNodeType, ToMdNodeTypeCo
 
 type ToMdMarkTypeConvertor = (
   nodeInfo?: MarkInfo,
-  entering?: boolean
+  entering?: boolean,
+  betweenSpace?: boolean
 ) => ToMdConvertorReturnValues & ToMdMarkTypeOption;
 
 export type ToMdMarkTypeConvertorMap = Partial<Record<WwMarkType, ToMdMarkTypeConvertor>>;
@@ -124,7 +125,8 @@ interface ToMdConvertorContext {
 
 type ToMdConvertor = (
   nodeInfo: NodeInfo | MarkInfo,
-  context: ToMdConvertorContext
+  context: ToMdConvertorContext,
+  betweenSpace?: boolean
 ) => ToMdConvertorReturnValues;
 
 export type ToMdConvertorMap = Partial<Record<WwNodeType | MdNodeType, ToMdConvertor>>;


Test patch:
diff --git a/apps/editor/src/__test__/unit/convertor.spec.ts b/apps/editor/src/__test__/unit/convertor.spec.ts
index 52702f26..bef09ec5 100644
--- a/apps/editor/src/__test__/unit/convertor.spec.ts
+++ b/apps/editor/src/__test__/unit/convertor.spec.ts
@@ -2,7 +2,7 @@ import { source, oneLineTrim } from 'common-tags';
 
 import { Context, MdNode, Parser, HTMLConvertorMap } from '@toast-ui/toastmark';
 
-import { Schema } from 'prosemirror-model';
+import { Node, Schema } from 'prosemirror-model';
 import { createSpecs } from '@/wysiwyg/specCreator';
 
 import Convertor from '@/convertors/convertor';
@@ -595,7 +595,20 @@ describe('Convertor', () => {
         | ![altText](imgUrl) **mixed**<ul><li>[linkText](linkUrl) mixed</li></ul> |
       `;
 
-      assertConverting(markdown, `${markdown}\n`);
+      const expected = source`
+        | thead |
+        | ----- |
+        | <ul><li>bullet</li></ul> |
+        | <ol><li>ordered</li></ol> |
+        | <ul><li>nested<ul><li>nested</li></ul></li></ul> |
+        | <ul><li>nested<ul><li>nested</li><li>nested</li></ul></li></ul> |
+        | <ol><li>mix<strong>ed</strong><ul><li><strong>mix</strong>ed</li></ul></li></ol> |
+        | <ol><li>mix<i>ed</i><ul><li><strong>mix</strong>ed</li></ul></li></ol> |
+        | foo<ul><li>bar</li></ul>baz |
+        | ![altText](imgUrl) **mixed**<ul><li>[linkText](linkUrl) mixed</li></ul> |
+      `;
+
+      assertConverting(markdown, `${expected}\n`);
     });
 
     it('table with unmatched html list', () => {
@@ -1061,4 +1074,28 @@ describe('Convertor', () => {
       assertConverting(markdown, markdown);
     });
   });
+
+  it('should convert by using HTML tag when delimiter is not preceded an alphanumeric', () => {
+    const wwNodeJson = {
+      type: 'doc',
+      content: [
+        {
+          type: 'paragraph',
+          content: [
+            {
+              type: 'text',
+              marks: [{ type: 'strong' }],
+              text: '"test"',
+            },
+            { type: 'text', text: 'a' },
+          ],
+        },
+      ],
+    };
+    const wwNode = Node.fromJSON(schema, wwNodeJson);
+
+    const result = convertor.toMarkdownText(wwNode);
+
+    expect(result).toBe(`<strong>"test"</strong>a`);
+  });
 });