Repository: nhn/tui.editor
Pull request #2301 (2022-02-10 23:57:07)

fix: `getHTML()` API result includes placeholder html
<!-- 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
* related issue(#2096)


---
Thank you for your contribution to TOAST UI product. 🎉 😘 ✨



Patch:
diff --git a/.github/workflows/publish-doc.yml b/.github/workflows/publish-doc.yml
index 5ac368a4..837829bd 100644
--- a/.github/workflows/publish-doc.yml
+++ b/.github/workflows/publish-doc.yml
@@ -118,8 +118,8 @@ jobs:
           ref: gh-pages
       - name: Commit files
         run: |
-          git config --local user.email 'js87zz.lee@nhn.com'
-          git config --local user.name 'js87zz'
+          git config --local user.email 'jw.lee@nhn.com'
+          git config --local user.name 'jwlee1108'
           rm -rf ${{ steps.check.outputs.committed-version }}
           rm -rf latest
           git add .
@@ -130,4 +130,4 @@ jobs:
         uses: ad-m/github-push-action@master
         with:
           github_token: ${{ secrets.GITHUB_TOKEN }}
-          branch: gh-pages
\ No newline at end of file
+          branch: gh-pages
diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
index 28d4aa50..54659b44 100644
--- a/.github/workflows/publish-npm.yml
+++ b/.github/workflows/publish-npm.yml
@@ -115,8 +115,8 @@ jobs:
           npm run build editor
       - name: Create Tag
         run: |
-          git config --local user.email 'js87zz.lee@nhn.com'
-          git config --local user.name 'js87zz'
+          git config --local user.email 'jw.lee@nhn.com'
+          git config --local user.name 'jwlee1108'
           git tag editor@${{ steps.check.outputs.committed-version }}
       - name: Push Tag
         run: |
diff --git a/apps/editor/src/editorCore.ts b/apps/editor/src/editorCore.ts
index 15d0094c..85e5ad1e 100644
--- a/apps/editor/src/editorCore.ts
+++ b/apps/editor/src/editorCore.ts
@@ -112,6 +112,8 @@ class ToastUIEditorCore {
 
   private scrollSync: ScrollSync;
 
+  private placeholder?: string;
+
   eventEmitter: Emitter;
 
   protected options: Required<EditorOptions>;
@@ -501,8 +503,18 @@ class ToastUIEditorCore {
         this.wwEditor.setModel(wwNode!);
       }
     });
+    const html = this.wwEditor.view.dom.innerHTML;
+
+    if (this.placeholder) {
+      const rePlaceholder = new RegExp(
+        `<span class="placeholder[^>]+>${this.placeholder}</span>`,
+        'i'
+      );
+
+      return html.replace(rePlaceholder, '');
+    }
 
-    return this.wwEditor.view.dom.innerHTML;
+    return html;
   }
 
   /**
@@ -793,6 +805,7 @@ class ToastUIEditorCore {
    * @param {string} placeholder - placeholder to set
    */
   setPlaceholder(placeholder: string) {
+    this.placeholder = placeholder;
     this.mdEditor.setPlaceholder(placeholder);
     this.wwEditor.setPlaceholder(placeholder);
   }
diff --git a/package-lock.json b/package-lock.json
index 3092d879..cd8a9a58 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2438,7 +2438,6 @@
         "jest-resolve": "^26.6.2",
         "jest-util": "^26.6.2",
         "jest-worker": "^26.6.2",
-        "node-notifier": "^8.0.0",
         "slash": "^3.0.0",
         "source-map": "^0.6.0",
         "string-length": "^4.0.1",
@@ -3463,9 +3462,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -3541,9 +3537,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -3760,9 +3753,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -4011,9 +4001,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -4198,9 +4185,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -4263,9 +4247,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -4432,9 +4413,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -4487,9 +4465,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -4704,9 +4679,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -5085,9 +5057,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -5168,9 +5137,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -5312,9 +5278,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -5374,9 +5337,6 @@
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
       "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
       "dev": true,
-      "dependencies": {
-        "graceful-fs": "^4.1.6"
-      },
       "optionalDependencies": {
         "graceful-fs": "^4.1.6"
       }
@@ -6654,12 +6614,8 @@
         "@vue/component-compiler-utils": "^3.0.0",
         "clean-css": "^4.1.11",
         "hash-sum": "^1.0.2",
-        "less": "^3.9.0",
         "postcss-modules-sync": "^1.0.0",
-        "pug": "^3.0.1",
-        "sass": "^1.18.0",
-        "source-map": "0.6.*",
-        "stylus": "^0.54.5"
+        "source-map": "0.6.*"
       },
       "optionalDependencies": {
         "less": "^3.9.0",
@@ -6684,7 +6640,6 @@
         "merge-source-map": "^1.1.0",
         "postcss": "^7.0.14",
         "postcss-selector-parser": "^6.0.2",
-        "prettier": "^1.18.2",
         "source-map": "~0.6.1",
         "vue-template-es2015-compiler": "^1.9.0"
       },
@@ -8417,7 +8372,6 @@
         "anymatch": "^2.0.0",
         "async-each": "^1.0.1",
         "braces": "^2.3.2",
-        "fsevents": "^1.2.7",
         "glob-parent": "^3.1.0",
         "inherits": "^2.0.3",
         "is-binary-path": "^1.0.0",
@@ -11743,8 +11697,7 @@
         "esprima": "^4.0.1",
         "estraverse": "^5.2.0",
         "esutils": "^2.0.2",
-        "optionator": "^0.8.1",
-        "source-map": "~0.6.1"
+        "optionator": "^0.8.1"
       },
       "bin": {
         "escodegen": "bin/escodegen.js",
@@ -14427,7 +14380,6 @@
         "minimist": "^1.2.5",
         "neo-async": "^2.6.0",
         "source-map": "^0.6.1",
-        "uglify-js": "^3.1.4",
         "wordwrap": "^1.0.0"
       },
       "bin": {
@@ -16572,7 +16524,6 @@
         "@types/node": "*",
         "anymatch": "^3.0.3",
         "fb-watchman": "^2.0.0",
-        "fsevents": "^2.1.2",
         "graceful-fs": "^4.2.4",
         "jest-regex-util": "^26.0.0",
         "jest-serializer": "^26.6.2",
@@ -18104,7 +18055,6 @@
       "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
       "dev": true,
       "dependencies": {
-        "graceful-fs": "^4.1.6",
         "universalify": "^2.0.0"
       },
       "optionalDependencies": {
@@ -18385,13 +18335,6 @@
       "optional": true,
       "dependencies": {
         "copy-anything": "^2.0.1",
-        "errno": "^0.1.1",
-        "graceful-fs": "^4.1.2",
-        "image-size": "~0.5.0",
-        "make-dir": "^2.1.0",
-        "mime": "^1.4.1",
-        "native-request": "^1.0.5",
-        "source-map": "~0.6.0",
         "tslib": "^1.10.0"
       },
       "bin": {
@@ -21890,9 +21833,6 @@
       "version": "1.23.0",
       "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz",
       "integrity": "sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==",
-      "dependencies": {
-        "clipboard": "^2.0.0"
-      },
       "optionalDependencies": {
         "clipboard": "^2.0.0"
       }
@@ -23270,9 +23210,6 @@
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.67.0.tgz",
       "integrity": "sha512-W83AaERwvDiHwHEF/dfAfS3z1Be5wf7n+pO3ZAO5IQadCT2lBTr7WQ2MwZZe+nodbD+n3HtC4OCOAdsOPPcKZQ==",
       "dev": true,
-      "dependencies": {
-        "fsevents": "~2.3.2"
-      },
       "bin": {
         "rollup": "dist/bin/rollup"
       },
@@ -23762,7 +23699,6 @@
       "dependencies": {
         "anymatch": "~3.1.1",
         "braces": "~3.0.2",
-        "fsevents": "~2.3.1",
         "glob-parent": "~5.1.0",
         "is-binary-path": "~2.1.0",
         "is-glob": "~4.0.1",
@@ -24441,7 +24377,6 @@
         "default-browser-id": "^2.0.0",
         "esbuild": "^0.9.3",
         "fdir": "^5.0.0",
-        "fsevents": "^2.2.0",
         "open": "^7.0.4",
         "picomatch": "^2.2.2",
         "resolve": "^1.20.0",
diff --git a/package.json b/package.json
index 65c47441..c30b6a4e 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "@rollup/plugin-json": "^4.1.0",
     "@rollup/plugin-node-resolve": "^13.0.0",
     "@rollup/plugin-typescript": "^8.2.1",
+    "@testing-library/dom": "^8.11.3",
     "@testing-library/jest-dom": "^5.11.9",
     "@types/common-tags": "^1.8.0",
     "@types/jest": "^27.4.0",


Test patch:
diff --git a/apps/editor/src/__test__/integration/ui/layout.spec.ts b/apps/editor/src/__test__/integration/ui/layout.spec.ts
index 615f1564..dda16ae7 100644
--- a/apps/editor/src/__test__/integration/ui/layout.spec.ts
+++ b/apps/editor/src/__test__/integration/ui/layout.spec.ts
@@ -1,10 +1,8 @@
-import { Layout } from '@/ui/components/layout';
-import { render } from '@/ui/vdom/renderer';
-import { VNode } from '@/ui/vdom/vnode';
-import html from '@/ui/vdom/template';
-import EventEmitter from '@/event/eventEmitter';
 import { cls } from '@/utils/dom';
 import '@/i18n/en-us';
+import { Editor } from '@/index';
+import { Emitter } from '@t/event';
+import { screen } from '@testing-library/dom';
 
 const EDITOR_CLASS = 'toastui-editor';
 
@@ -33,11 +31,11 @@ function getWwEditor() {
 }
 
 function getMdSwitch() {
-  return getElements(`.${cls('mode-switch')} .tab-item`)![0];
+  return screen.getByText('Markdown')!;
 }
 
 function getWwSwitch() {
-  return getElements(`.${cls('mode-switch')} .tab-item`)![1];
+  return screen.getByText('WYSIWYG')!;
 }
 
 function clickMdSwitch() {
@@ -75,45 +73,25 @@ function assertToContainElement(el: HTMLElement) {
 }
 
 describe('layout component', () => {
-  let em: EventEmitter, container: HTMLElement, destroy: () => void;
+  let container: HTMLElement;
+  let editor: Editor;
+  let em: Emitter;
 
   beforeEach(() => {
-    const mdEditor = document.createElement('div');
-    const mdPreview = document.createElement('div');
-    const wwEditor = document.createElement('div');
-
-    mdEditor.className = EDITOR_CLASS;
-    mdPreview.className = cls('md-preview');
-    wwEditor.className = EDITOR_CLASS;
-
-    const dummySlot = {
-      mdEditor,
-      mdPreview,
-      wwEditor,
-    };
-
     container = document.createElement('div');
+    editor = new Editor({
+      el: container,
+      previewStyle: 'vertical',
+      height: '400px',
+      initialEditType: 'markdown',
+    });
+    em = editor.eventEmitter;
     document.body.appendChild(container);
-
-    em = new EventEmitter();
-
-    destroy = render(
-      container,
-      html`
-        <${Layout}
-          eventEmitter=${em}
-          slots=${dummySlot}
-          hideModeSwitch=${false}
-          toolbarItems=${[['bold', 'italic', 'strike'], ['scrollSync']]}
-          previewStyle="tab"
-          editorType="markdown"
-        />
-      ` as VNode
-    );
   });
 
   afterEach(() => {
-    destroy();
+    editor.destroy();
+    document.body.removeChild(container);
   });
 
   it('render default ui properly', () => {
@@ -128,12 +106,10 @@ describe('layout component', () => {
   it('show/hide editor', () => {
     const layout = getElement(`.${cls('defaultUI')}`);
 
-    em.emit('hide');
-
+    editor.hide();
     expect(layout).toHaveClass('hidden');
 
-    em.emit('show');
-
+    editor.show();
     expect(layout).not.toHaveClass('hidden');
   });
 
@@ -144,11 +120,9 @@ describe('layout component', () => {
       em.listen('needChangeMode', spy);
 
       clickWwSwitch();
-
       expect(spy).toHaveBeenCalledWith('wysiwyg');
 
       clickMdSwitch();
-
       expect(spy).toHaveBeenCalledWith('markdown');
     });
 
@@ -170,7 +144,28 @@ describe('layout component', () => {
       expect(wwSwitch).not.toHaveClass('active');
     });
 
+    it('should change layout when clicking the switch button', () => {
+      const editorArea = getEditorMain();
+      const mdSwitch = getMdSwitch();
+      const wwSwitch = getWwSwitch();
+
+      clickWwSwitch();
+      expect(editorArea).toHaveClass(cls('ww-mode'));
+      expect(wwSwitch).toHaveClass('active');
+      expect(mdSwitch).not.toHaveClass('active');
+
+      clickMdSwitch();
+      expect(editorArea).toHaveClass(cls('md-mode'));
+      expect(mdSwitch).toHaveClass('active');
+      expect(wwSwitch).not.toHaveClass('active');
+    });
+
     it('should not render scrollSync when previewStyle is tab regardless of changing editor mode', () => {
+      editor = new Editor({
+        el: container,
+        previewStyle: 'tab',
+      });
+
       const scrollSyncWrapper = getScrollSyncWrapper();
 
       expect(scrollSyncWrapper).toBeNull();
@@ -180,27 +175,25 @@ describe('layout component', () => {
       expect(scrollSyncWrapper).toBeNull();
     });
 
+    // @todo It needs to break test by each event (changePreviewStyle, changeMode)
     it('should show scrollSync when previewStyle is vertical on only markdown mode', () => {
-      em.emit('changePreviewStyle', 'vertical');
       const scrollSyncWrapper = getScrollSyncWrapper();
 
+      em.emit('changePreviewStyle', 'vertical');
       expect(scrollSyncWrapper).toHaveStyle({ display: 'inline-block' });
 
       em.emit('changeMode', 'wysiwyg');
-
       expect(getElement('.scroll-sync')).toBeNull();
     });
 
     it('should show scrollSync when previewStyle is changed on only markdown mode', () => {
+      const scrollSyncWrapper = getScrollSyncWrapper();
+
       em.emit('changeMode', 'wysiwyg');
       em.emit('changePreviewStyle', 'vertical');
-
       expect(getElement('.scroll-sync')).toBeNull();
 
       em.emit('changeMode', 'markdown');
-
-      const scrollSyncWrapper = getScrollSyncWrapper();
-
       expect(scrollSyncWrapper).toHaveStyle({ display: 'inline-block' });
     });
   });
@@ -209,20 +202,25 @@ describe('layout component', () => {
     it('should hide markdown tab when changePreviewStyle is triggered', () => {
       const tabSection = getElement(`.${cls('md-tab-container')}`)!;
 
-      expect(tabSection).toHaveStyle({ display: 'block' });
-
-      em.emit('changePreviewStyle', 'vertical');
-
       expect(tabSection).toHaveStyle({ display: 'none' });
+
+      em.emit('changePreviewStyle', 'tab');
+      expect(tabSection).toHaveStyle({ display: 'block' });
     });
 
     it('should hide markdown tab when changeMode is triggered', () => {
+      editor = new Editor({
+        el: container,
+        previewStyle: 'tab',
+        initialEditType: 'markdown',
+      });
+      em = editor.eventEmitter;
+
       const tabSection = getElement(`.${cls('md-tab-container')}`)!;
 
       expect(tabSection).toHaveStyle({ display: 'block' });
 
       em.emit('changeMode', 'wysiwyg');
-
       expect(tabSection).toHaveStyle({ display: 'none' });
     });
 
@@ -244,15 +242,18 @@ describe('layout component', () => {
       em.listen('changePreviewTabPreview', spy2);
 
       clickMdPreviewTab();
-
       expect(spy2).toHaveBeenCalledTimes(1);
 
       clickMdWriteTab();
-
       expect(spy1).toHaveBeenCalledTimes(1);
     });
 
     it('should enable/disable the toolbar items by clicking markdown tab', () => {
+      editor = new Editor({
+        el: container,
+        previewStyle: 'tab',
+        initialEditType: 'markdown',
+      });
       const scrollSyncWrapper = getScrollSyncWrapper();
 
       clickMdPreviewTab();
@@ -267,23 +268,26 @@ describe('layout component', () => {
     });
 
     it('should enable the toolbar items when changeMode is triggered', () => {
+      editor = new Editor({
+        el: container,
+        previewStyle: 'tab',
+        initialEditType: 'markdown',
+      });
+      em = editor.eventEmitter;
+
       clickMdPreviewTab();
 
       em.emit('changeMode', 'wysiwyg');
-
       expect(getElement(`.${cls('toolbar-icons')}`)).not.toBeDisabled();
 
       em.emit('changeMode', 'markdown');
-
       expect(getElement(`.${cls('toolbar-icons')}`)).not.toBeDisabled();
       expect(getMdWriteTab()).toHaveClass('active');
     });
 
     it('should enable the toolbar items when changePreviewStyle is triggered', () => {
       clickMdPreviewTab();
-
       em.emit('changePreviewStyle', 'vertical');
-
       expect(getElement(`.${cls('toolbar-icons')}`)).not.toBeDisabled();
     });
   });
@@ -293,9 +297,7 @@ describe('layout component', () => {
       const contextMenu = getElement(`.${cls('context-menu')}`);
 
       expect(contextMenu).toHaveStyle({ display: 'none' });
-
       em.emit('contextmenu', { pos: { left: 10, top: 10 }, menuGroups: [[{ label: 'test' }]] });
-
       expect(contextMenu).toHaveStyle({ display: 'block' });
     });
   });
diff --git a/apps/editor/src/__test__/integration/ui/toolbar.spec.ts b/apps/editor/src/__test__/integration/ui/toolbar.spec.ts
index 9a9aaddf..5d1952ac 100644
--- a/apps/editor/src/__test__/integration/ui/toolbar.spec.ts
+++ b/apps/editor/src/__test__/integration/ui/toolbar.spec.ts
@@ -1,298 +1,243 @@
-import isString from 'tui-code-snippet/type/isString';
-import { VNode } from '@t/ui';
-import EventEmitter from '@/event/eventEmitter';
-import html from '@/ui/vdom/template';
-import { render } from '@/ui/vdom/renderer';
-import { Toolbar } from '@/ui/components/toolbar/toolbar';
 import { cls } from '@/utils/dom';
+import { fireEvent, getByLabelText, getByText, screen } from '@testing-library/dom';
+import { Editor } from '@/index';
 import '@/i18n/en-us';
 
 function getElement(selector: string) {
   return document.querySelector<HTMLElement>(selector)!;
 }
 
-function assertToContainElement(el: HTMLElement | string) {
-  if (isString(el)) {
-    el = getElement(el)!;
-  }
-  expect(document.body).toContainElement(el);
-}
-
-function assertToNotContainElement(el: HTMLElement | string) {
-  if (isString(el)) {
-    el = getElement(el)!;
-  }
-  expect(document.body).not.toContainElement(el);
-}
-
-function dispatchClick(selector: string) {
-  const el = getElement(selector)!;
-
-  el.click();
+function getPopUpElement() {
+  return getElement(`.${cls('popup')}`);
 }
 
-function dispatchSelectChange(selector: string, value: string) {
-  const event = new Event('change', {
+function fireMousemoveEvent(el: HTMLElement, x: number, y: number) {
+  const event = new MouseEvent('mousemove', {
     bubbles: true,
     cancelable: true,
   });
-  const el = getElement(selector)! as HTMLSelectElement;
 
-  el.value = value;
+  // @ts-ignore
+  event.pageX = x;
+  // @ts-ignore
+  event.pageY = y;
 
-  el.dispatchEvent(event);
+  fireEvent(el, event);
 }
 
-function dispatchMouseover(selector: string) {
-  const event = new MouseEvent('mouseover', {
+function fireMouseoverEvent(el: HTMLElement) {
+  const mouseover = new MouseEvent('mouseover', {
     bubbles: true,
     cancelable: true,
   });
-  const el = getElement(selector)!;
 
-  el.dispatchEvent(event);
+  fireEvent(el, mouseover);
 }
 
-function dispatchMousemove(selector: string, x: number, y: number) {
-  const event = new MouseEvent('mousemove', {
-    bubbles: true,
-    cancelable: true,
-  });
-
-  // @ts-ignore
-  event.pageX = x;
-  // @ts-ignore
-  event.pageY = y;
+describe('Default toolbar', () => {
+  let el: HTMLDivElement, editor: Editor;
 
-  const el = getElement(selector)!;
+  beforeEach(() => {
+    el = document.createElement('div');
 
-  el.dispatchEvent(event);
-}
-let em: EventEmitter, container: HTMLElement, destroy: () => void;
+    editor = new Editor({
+      el,
+      previewStyle: 'vertical',
+      height: '400px',
+      initialEditType: 'markdown',
+    });
 
-describe('default toolbar', () => {
-  beforeEach(() => {
-    const toolbarItems = [
-      ['heading', 'bold', 'italic', 'strike'],
-      ['hr', 'quote'],
-      ['ul', 'ol', 'task', 'indent', 'outdent'],
-      ['table', 'image', 'link'],
-      ['code', 'codeblock'],
-      ['scrollSync'],
-    ];
-
-    container = document.createElement('div');
-    document.body.appendChild(container);
-
-    em = new EventEmitter();
-
-    destroy = render(
-      container,
-      html`
-        <${Toolbar}
-          eventEmitter=${em}
-          previewStyle="vertical"
-          toolbarItems=${toolbarItems}
-          editorType="markdown"
-        />
-      ` as VNode
-    );
+    document.body.appendChild(el);
   });
 
   afterEach(() => {
-    destroy();
+    editor.destroy();
+    document.body.removeChild(el);
   });
 
   it('should be rendered properly', () => {
-    const toolbarButtonSelectors = [
-      '.heading',
-      '.bold',
-      '.italic',
-      '.strike',
-      '.hrline',
-      '.quote',
-      '.bullet-list',
-      '.ordered-list',
-      '.task-list',
-      '.indent',
-      '.outdent',
-      '.table',
-      '.image',
-      '.link',
-      '.code',
-      '.codeblock',
-      '.scroll-sync',
-    ];
-
-    toolbarButtonSelectors.forEach((selector) => {
-      assertToContainElement(selector);
+    [
+      'Headings',
+      'Bold',
+      'Italic',
+      'Italic',
+      'Line',
+      'Blockquote',
+      'Unordered list',
+      'Ordered list',
+      'Task',
+      'Indent',
+      'Outdent',
+      'Insert table',
+      'Insert image',
+      'Insert link',
+      'Inline code',
+      'Insert codeBlock',
+    ].forEach((label) => {
+      expect(screen.queryByLabelText(label)).not.toBeNull();
     });
-  });
-
-  it('should show tooltip when mouseover on toolbar button', () => {
-    dispatchMouseover('.bold');
-
-    const tooltip = getElement(`.${cls('tooltip')}`);
 
-    expect(tooltip).toHaveStyle({ display: 'block' });
+    expect(document.body).toContainElement(getElement('.scroll-sync'));
   });
 
   it('should trigger command event when clicking toolbar button', () => {
     const spy = jest.fn();
 
-    em.listen('command', spy);
-
-    dispatchClick('.bold');
+    editor.eventEmitter.listen('command', spy);
+    screen.getByLabelText('Bold').click();
 
     // eslint-disable-next-line no-undefined
     expect(spy).toHaveBeenCalledWith('bold', undefined);
   });
 
-  it('should hide the popup when clicking X button on popup', () => {
-    dispatchClick('.link');
-
-    const linkPopup = getElement(`.${cls('popup-add-link')}`);
-    const closeBtn = getElement(`.${cls('popup-add-link')} .${cls('close-button')}`);
+  it('should show tooltip when mouseover on toolbar button', () => {
+    fireMouseoverEvent(screen.getByLabelText('Headings'));
 
-    closeBtn.click();
+    const tooltip = screen.getByText('Headings').parentElement;
 
-    expect(linkPopup).toHaveStyle({ display: 'none' });
+    expect(tooltip).toHaveStyle({ display: 'block' });
+    expect(tooltip).toHaveClass(cls('tooltip'));
   });
 
   describe('scroll sync button', () => {
-    it('should toggle state when clicking scroll sync button', () => {
-      const scrollSyncSwitch = getElement('.scroll-sync input');
+    it('should toggle active state when clicking scroll sync button', () => {
+      const scrollSyncSwitch = getElement('.scroll-sync');
 
-      expect(scrollSyncSwitch).toHaveProperty('checked', true);
+      expect(scrollSyncSwitch).toHaveClass('active');
 
-      dispatchClick('.scroll-sync');
+      scrollSyncSwitch.click();
 
-      expect(scrollSyncSwitch).toHaveProperty('checked', false);
+      expect(scrollSyncSwitch).not.toHaveClass('active');
     });
 
     it('should trigger command event with state', () => {
       const spy = jest.fn();
 
-      em.listen('command', spy);
+      editor.eventEmitter.listen('command', spy);
 
-      dispatchClick('.scroll-sync');
+      getElement('.scroll-sync').click();
 
       expect(spy).toHaveBeenCalledWith('toggleScrollSync', { active: false });
 
-      dispatchClick('.scroll-sync');
+      getElement('.scroll-sync').click();
 
       expect(spy).toHaveBeenCalledWith('toggleScrollSync', { active: true });
     });
   });
 
-  describe('heading button', () => {
-    it('should show the popup when clicking heading button', () => {
-      dispatchClick('.heading');
+  describe('Headings button', () => {
+    let headingPopup: HTMLElement;
+    let hedingButton: HTMLElement;
 
-      const headingPopup = getElement(`.${cls('popup-add-heading')}`);
+    beforeEach(() => {
+      headingPopup = getPopUpElement();
+      hedingButton = screen.getByLabelText('Headings');
 
+      hedingButton.click();
+    });
+
+    it('should show the popup when clicking Headings button', () => {
+      expect(headingPopup).toHaveClass(cls('popup-add-heading'));
       expect(headingPopup).toHaveStyle({ display: 'block' });
     });
 
-    it('should trigger command event when clicking heading popup button', () => {
-      const spy = jest.fn();
+    ['1', '2', '3', '4', '5', '6'].forEach((level) => {
+      const mdHeadingOfLevel = '#'.repeat(parseInt(level, 10));
 
-      em.listen('command', spy);
+      it(`should active heading button when click heading level ${level}`, () => {
+        getByText(headingPopup, `Heading ${level}`).click();
 
-      dispatchClick('.heading');
-      dispatchClick(`.${cls('popup-add-heading [data-level="2"]')}`);
+        expect(hedingButton).toHaveClass('active');
+      });
+
+      it(`should add heading to document when click heading level ${level}`, () => {
+        getByText(headingPopup, `Heading ${level}`).click();
 
-      expect(spy).toHaveBeenCalledWith('heading', { level: 2 });
+        expect(editor.getMarkdown()).toBe(`${mdHeadingOfLevel} `);
+      });
     });
   });
 
   describe('link button', () => {
-    it('should show the popup when clicking link button', () => {
-      dispatchClick('.link');
+    let linkPopup: HTMLElement;
+    let linkButton: HTMLElement;
+
+    beforeEach(() => {
+      linkPopup = getPopUpElement();
+      linkButton = screen.getByLabelText('Insert link');
 
-      const linkPopup = getElement(`.${cls('popup-add-link')}`);
+      linkButton.click();
+    });
 
+    it('should show the popup when clicking link button', () => {
+      expect(linkPopup).toHaveClass(cls('popup-add-link'));
       expect(linkPopup).toHaveStyle({ display: 'block' });
     });
 
     it('should hide popup when clicking Cancel button', () => {
-      dispatchClick('.link');
-      dispatchClick(`.${cls('popup-add-link')} .${cls('close-button')}`);
+      const closeBtn = getByText(linkPopup, 'Cancel');
 
-      const linkPopup = getElement(`.${cls('popup-add-link')}`);
+      closeBtn.click();
 
       expect(linkPopup).toHaveStyle({ display: 'none' });
     });
 
-    it('should trigger command event when clicking OK button', () => {
-      const spy = jest.fn();
-
-      em.listen('command', spy);
-
-      dispatchClick('.link');
-
-      const urlText = getElement(
-        `.${cls('popup-add-link #toastuiLinkUrlInput')}`
-      ) as HTMLInputElement;
-      const linkText = getElement(
-        `.${cls('popup-add-link #toastuiLinkTextInput')}`
-      ) as HTMLInputElement;
+    it('should add link to document when clicking OK button', () => {
+      const urlText = getByText(linkPopup, 'URL').nextElementSibling as HTMLInputElement;
+      const linkText = getByText(linkPopup, 'Link text').nextElementSibling as HTMLInputElement;
+      const OkBtn = getByText(linkPopup, 'OK');
 
       urlText.value = 'https://ui.toast.com';
       linkText.value = 'toastui';
 
-      dispatchClick(`.${cls('popup-add-link')} .${cls('ok-button')}`);
+      OkBtn.click();
 
-      expect(spy).toHaveBeenCalledWith('addLink', {
-        linkText: 'toastui',
-        linkUrl: 'https://ui.toast.com',
-      });
+      expect(editor.getMarkdown()).toBe('[toastui](https://ui.toast.com)');
     });
 
     it('should add wrong class when url or text are not filled out', () => {
-      dispatchClick('.link');
+      const urlText = getByText(linkPopup, 'URL').nextElementSibling as HTMLInputElement;
+      const linkText = getByText(linkPopup, 'Link text').nextElementSibling as HTMLInputElement;
+      const OkBtn = getByText(linkPopup, 'OK');
 
-      const urlText = getElement(
-        `.${cls('popup-add-link #toastuiLinkUrlInput')}`
-      ) as HTMLInputElement;
-      const linkText = getElement(
-        `.${cls('popup-add-link #toastuiLinkTextInput')}`
-      ) as HTMLInputElement;
-
-      dispatchClick(`.${cls('popup-add-link')} .${cls('ok-button')}`);
+      OkBtn.click();
 
       expect(urlText).toHaveClass('wrong');
 
       urlText.value = 'https://ui.toast.com';
-      dispatchClick(`.${cls('popup-add-link')} .${cls('ok-button')}`);
+      OkBtn.click();
 
       expect(linkText).toHaveClass('wrong');
     });
   });
 
   describe('image button', () => {
-    it('should show the popup when clicking image button', () => {
-      dispatchClick('.image');
+    let imagePopup: HTMLElement;
+    let imageButton: HTMLElement;
 
-      const imagePopup = getElement(`.${cls('popup-add-image')}`);
+    beforeEach(() => {
+      imagePopup = getPopUpElement();
+      imageButton = screen.getByLabelText('Insert image');
 
+      imageButton.click();
+    });
+
+    it('should show the popup when clicking image button', () => {
+      expect(imagePopup).toHaveClass(cls('popup-add-image'));
       expect(imagePopup).toHaveStyle({ display: 'block' });
     });
 
     it('should hide popup when clicking Cancel button', () => {
-      dispatchClick('.image');
-      dispatchClick(`.${cls('popup-add-image')} .${cls('close-button')}`);
+      const closeBtn = getByText(imagePopup, 'Cancel');
 
-      const imagePopup = getElement(`.${cls('popup-add-image')}`);
+      closeBtn.click();
 
       expect(imagePopup).toHaveStyle({ display: 'none' });
     });
 
     it('should toggle tab when clicking the file or url tab', () => {
-      dispatchClick('.image');
-
-      const fileTabBtn = getElement(`.${cls('popup-add-image .active')}`);
-      const urlTabBtn = fileTabBtn.nextSibling as HTMLButtonElement;
+      const fileTabBtn = getByLabelText(imagePopup, 'File');
+      const urlTabBtn = getByLabelText(imagePopup, 'URL');
 
       urlTabBtn.click();
 
@@ -305,75 +250,109 @@ describe('default toolbar', () => {
       expect(urlTabBtn).not.toHaveClass('active');
     });
 
-    it('should trigger command event when clicking OK button', () => {
-      const spy = jest.fn();
-
-      em.listen('command', spy);
-
-      dispatchClick('.image');
-
-      const fileTabBtn = getElement(`.${cls('popup-add-image .active')}`);
-      const urlTabBtn = fileTabBtn.nextSibling as HTMLButtonElement;
-      const urlText = getElement(
-        `.${cls('popup-add-image #toastuiImageUrlInput')}`
-      ) as HTMLInputElement;
-      const descriptionText = getElement(
-        `.${cls('popup-add-image #toastuiAltTextInput')}`
-      ) as HTMLInputElement;
+    it('should add image to document when clicking OK button', () => {
+      getByLabelText(imagePopup, 'URL').click();
 
-      urlTabBtn.click();
+      const urlText = getByText(imagePopup, 'Image URL').nextElementSibling as HTMLInputElement;
+      const descriptionText = getByText(imagePopup, 'Description')
+        .nextElementSibling as HTMLInputElement;
+      const OkBtn = getByText(imagePopup, 'OK');
 
       urlText.value = 'myImageUrl';
       descriptionText.value = 'image';
 
-      dispatchClick(`.${cls('popup-add-image')} .${cls('ok-button')}`);
+      OkBtn.click();
 
-      expect(spy).toHaveBeenCalledWith('addImage', { altText: 'image', imageUrl: 'myImageUrl' });
+      expect(editor.getMarkdown()).toBe('![image](myImageUrl)');
     });
 
     it('should add wrong class when url or text are not filled out', () => {
-      dispatchClick('.image');
+      const fileText = getByText(imagePopup, 'Select image file')
+        .nextElementSibling as HTMLInputElement;
+      const urlText = getByText(imagePopup, 'Image URL').nextElementSibling as HTMLInputElement;
+      const OkBtn = getByText(imagePopup, 'OK');
 
-      const fileTabBtn = getElement('.toastui-editor-popup-add-image .active');
-      const urlTabBtn = fileTabBtn.nextSibling as HTMLButtonElement;
+      OkBtn.click();
 
-      urlTabBtn.click();
+      expect(fileText).toHaveClass('wrong');
 
-      const urlText = getElement(
-        '.toastui-editor-popup-add-image #toastuiImageUrlInput'
-      ) as HTMLInputElement;
+      getByLabelText(imagePopup, 'URL').click();
 
-      dispatchClick('.toastui-editor-popup-add-image .toastui-editor-ok-button');
+      OkBtn.click();
 
       expect(urlText).toHaveClass('wrong');
     });
   });
 
   describe('table button', () => {
-    it('should show the popup when clicking table button', () => {
-      dispatchClick('.table');
+    let tablePopup: HTMLElement;
+    let tableButton: HTMLElement;
 
-      const tablePopup = getElement(`.${cls('popup-add-table')}`);
+    beforeEach(() => {
+      tablePopup = getPopUpElement();
+      tableButton = screen.getByLabelText('Insert table');
 
+      tableButton.click();
+    });
+
+    it('should show the popup when clicking table button', () => {
+      expect(tablePopup).toHaveClass(cls('popup-add-table'));
       expect(tablePopup).toHaveStyle({ display: 'block' });
     });
 
-    it('should trigger command event when selecting the area and clicking it', () => {
-      const spy = jest.fn();
+    it('should add table to document when selecting the area and clicking it', () => {
+      const tableSelection = tablePopup.querySelector(`.${cls('table-selection')}`)! as HTMLElement;
+
+      fireMousemoveEvent(tableSelection, 100, 60);
+      tableSelection.click();
+
+      expect(editor.getMarkdown()).toBe(
+        '\n|  |  |  |  |  |  |\n| --- | --- | --- | --- | --- | --- |\n|  |  |  |  |  |  |\n|  |  |  |  |  |  |\n|  |  |  |  |  |  |'
+      );
+    });
+  });
 
-      em.listen('command', spy);
+  it('should active indent/outdent button when only ordered or bullet list actived', () => {
+    const bulletListBtn = screen.getByLabelText('Unordered list');
+    const orderedListBtn = screen.getByLabelText('Ordered list');
+    const indentBtn = screen.getByLabelText('Indent');
+    const outdentBtn = screen.getByLabelText('Outdent');
 
-      dispatchClick('.table');
+    bulletListBtn.click();
 
-      dispatchMousemove(`.${cls('table-selection')}`, 100, 60);
-      dispatchClick(`.${cls('table-selection')}`);
+    expect(indentBtn).not.toBeDisabled();
+    expect(outdentBtn).not.toBeDisabled();
 
-      expect(spy).toHaveBeenCalledWith('addTable', { columnCount: 6, rowCount: 4 });
-    });
+    orderedListBtn.click();
+
+    expect(indentBtn).not.toBeDisabled();
+    expect(outdentBtn).not.toBeDisabled();
+
+    editor.reset();
+    expect(indentBtn).toBeDisabled();
+    expect(outdentBtn).toBeDisabled();
+  });
+
+  it('should change tab mode when changing markdown tab mode', () => {
+    editor.changePreviewStyle('tab');
+    const writeTab = screen.getByLabelText('Write');
+    const previewTab = screen.getByLabelText('Preview');
+
+    previewTab.click();
+
+    expect(writeTab).not.toHaveClass('active');
+    expect(previewTab).toHaveClass('active');
+
+    writeTab.click();
+
+    expect(writeTab).toHaveClass('active');
+    expect(previewTab).not.toHaveClass('active');
   });
 });
 
-describe('custom button toolbar', () => {
+describe('Custom toobar button', () => {
+  let el: HTMLDivElement, editor: Editor;
+
   function createCustomButtonWithPopup() {
     const body = document.createElement('select');
 
@@ -385,9 +364,12 @@ describe('custom button toolbar', () => {
       <option value="5">5</option>
       <option value="6">6</option>
     `;
+
     body.addEventListener('change', (ev) => {
-      em.emit('command', 'heading', { level: Number((ev.target as HTMLSelectElement).value) });
-      em.emit('closePopup');
+      editor.eventEmitter.emit('command', 'heading', {
+        level: Number((ev.target as HTMLSelectElement).value),
+      });
+      editor.eventEmitter.emit('closePopup');
       (ev.target as HTMLSelectElement).value = '1';
     });
 
@@ -416,33 +398,27 @@ describe('custom button toolbar', () => {
   const customButtonWithPopup = createCustomButtonWithPopup();
 
   beforeEach(() => {
-    const toolbarItems = [[customButton, customButtonWithPopup]];
-
-    container = document.createElement('div');
-    document.body.appendChild(container);
-
-    em = new EventEmitter();
-
-    destroy = render(
-      container,
-      html`
-        <${Toolbar}
-          eventEmitter=${em}
-          previewStyle="vertical"
-          toolbarItems=${toolbarItems}
-          editorType="markdown"
-        />
-      ` as VNode
-    );
+    el = document.createElement('div');
+
+    editor = new Editor({
+      el,
+      previewStyle: 'vertical',
+      height: '400px',
+      initialEditType: 'markdown',
+      toolbarItems: [[customButton, customButtonWithPopup]],
+    });
+
+    document.body.appendChild(el);
   });
 
   afterEach(() => {
-    destroy();
+    editor.destroy();
+    document.body.removeChild(el);
   });
 
   it('should be rendered properly', () => {
-    const customToolbar1 = getElement('.my-toolbar');
-    const customToolbar2 = getElement('.my-toolbar-with-popup');
+    const customToolbar1 = screen.getByLabelText('B!');
+    const customToolbar2 = screen.getByLabelText('L!');
 
     expect(customToolbar1).toHaveTextContent('B!');
     expect(customToolbar1).toHaveStyle({ color: '#222', width: '40px' });
@@ -452,26 +428,22 @@ describe('custom button toolbar', () => {
   });
 
   it('should show tooltip when mouseover on toolbar button', () => {
-    dispatchMouseover('.my-toolbar');
+    fireMouseoverEvent(screen.getByLabelText('B!'));
 
     const tooltip = getElement(`.${cls('tooltip')}`);
 
     expect(tooltip).toHaveStyle({ display: 'block' });
+    expect(tooltip).toHaveTextContent('B!');
   });
 
-  it('should trigger command event when clicking button', () => {
-    const spy = jest.fn();
-
-    em.listen('command', spy);
-
-    dispatchClick('.my-toolbar');
+  it('should add text that matched command to document event when clicking button', () => {
+    screen.getByLabelText('B!').click();
 
-    // eslint-disable-next-line no-undefined
-    expect(spy).toHaveBeenCalledWith('bold', undefined);
+    expect(editor.getMarkdown()).toBe('****');
   });
 
   it('should show the popup when clicking button with popup option', () => {
-    dispatchClick('.my-toolbar-with-popup');
+    screen.getByLabelText('L!').click();
 
     const customPopup = getElement('.my-popup');
 
@@ -480,209 +452,48 @@ describe('custom button toolbar', () => {
   });
 
   it('should operate properly when event is triggered in popup', () => {
-    const spy = jest.fn();
-
-    em.listen('command', spy);
-
-    dispatchClick('.my-toolbar-with-popup');
-    dispatchClick('select');
-    dispatchSelectChange('select', '3');
-
-    expect(spy).toHaveBeenCalledWith('heading', { level: 3 });
-  });
-});
-
-describe('custom toolbar element', () => {
-  const onUpdatedSpy = jest.fn();
-
-  function createCustomItem() {
-    const el = document.createElement('div');
-
-    el.className = 'my-toolbar';
-    el.textContent = 'custom1';
-
-    return {
-      el,
-      name: 'myToolbar',
-      tooltip: 'custom1!',
-      state: 'strong',
-      onUpdated: onUpdatedSpy,
-    };
-  }
-
-  function createCustomItemWithPopup() {
-    const el = document.createElement('div');
-
-    el.textContent = 'custom2';
-
-    const body = document.createElement('select');
-
-    body.innerHTML = `
-      <option value="1">1</option>
-      <option value="2">2</option>
-      <option value="3">3</option>
-      <option value="4">4</option>
-      <option value="5">5</option>
-      <option value="6">6</option>
-    `;
-    body.addEventListener('change', (ev) => {
-      em.emit('command', 'heading', { level: Number((ev.target as HTMLSelectElement).value) });
-      em.emit('closePopup');
-      (ev.target as HTMLSelectElement).value = '1';
-    });
-
-    el.className = 'my-toolbar-with-popup';
-
-    return {
-      el,
-      name: 'myToolbarWithPopup',
-      tooltip: 'custom2!',
-      popup: {
-        body,
-        className: 'my-popup',
-        style: { width: 'auto' },
-      },
-    };
-  }
-
-  function clickMarkdownWriteTab() {
-    document.querySelectorAll<HTMLElement>('.tab-item')![0].click();
-  }
-
-  function clickMarkdownPreviewTab() {
-    document.querySelectorAll<HTMLElement>('.tab-item')![1].click();
-  }
-
-  const customItem = createCustomItem();
-  const customItemWithPopup = createCustomItemWithPopup();
-
-  beforeEach(() => {
-    const toolbarItems = [[customItem, customItemWithPopup]];
-
-    container = document.createElement('div');
-    document.body.appendChild(container);
-
-    onUpdatedSpy.mockReset();
-    em = new EventEmitter();
-
-    destroy = render(
-      container,
-      html`
-        <${Toolbar}
-          eventEmitter=${em}
-          previewStyle="tab"
-          toolbarItems=${toolbarItems}
-          editorType="markdown"
-        />
-      ` as VNode
-    );
-  });
-
-  afterEach(() => {
-    destroy();
-  });
-
-  it('should be rendered properly', () => {
-    const customToolbar1 = getElement('.my-toolbar');
-    const customToolbar2 = getElement('.my-toolbar-with-popup');
-
-    expect(customToolbar1).toHaveTextContent('custom1');
-    expect(customToolbar2).toHaveTextContent('custom2');
-  });
-
-  it('should show tooltip when mouseover on toolbar button', () => {
-    dispatchMouseover('.my-toolbar');
-
-    const tooltip = getElement(`.${cls('tooltip')}`);
-
-    expect(tooltip).toHaveStyle({ display: 'block' });
-  });
-
-  it('should show the popup when clicking custom toolbar item with popup option', () => {
-    dispatchClick('.my-toolbar-with-popup');
+    screen.getByLabelText('L!').click();
 
     const customPopup = getElement('.my-popup');
+    const select = customPopup.querySelector('select')!;
 
-    expect(customPopup).toHaveStyle({ display: 'block', width: 'auto' });
-    expect(customPopup).toHaveClass('my-popup');
-  });
-
-  it('should operate properly when event is triggered in popup', () => {
-    const spy = jest.fn();
-
-    em.listen('command', spy);
+    select.value = '3';
+    fireEvent(select, new Event('change'));
 
-    dispatchClick('.my-toolbar-with-popup');
-    dispatchClick('select');
-    dispatchSelectChange('select', '3');
-
-    expect(spy).toHaveBeenCalledWith('heading', { level: 3 });
-  });
-
-  it('should toggle active state properly when toolbar state is changed', () => {
-    em.emit('changeToolbarState', { toolbarState: { strong: { active: true } } });
-
-    expect(onUpdatedSpy).toHaveBeenCalledWith(expect.objectContaining({ active: true }));
-
-    em.emit('changeToolbarState', { toolbarState: { strong: { active: false } } });
-
-    expect(onUpdatedSpy).toHaveBeenCalledWith(expect.objectContaining({ active: false }));
-  });
-
-  it('should toggle disabled state when changing markdown tab mode', () => {
-    em.emit('changePreviewStyle', 'tab');
-
-    // change markdown tab mode to preview tab
-    clickMarkdownPreviewTab();
-
-    expect(onUpdatedSpy).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }));
-
-    // change markdown tab mode to write tab
-    clickMarkdownWriteTab();
-
-    expect(onUpdatedSpy).toHaveBeenCalledWith(expect.objectContaining({ disabled: false }));
+    expect(editor.getMarkdown()).toBe('### ');
   });
 });
 
 describe('API', () => {
-  let ref: Toolbar | null;
-
   function getToolbarItems() {
     return getElement(`.${cls('defaultUI-toolbar')}`).querySelectorAll('button:not(.more)');
   }
 
+  let el: HTMLDivElement, editor: Editor;
+
   beforeEach(() => {
     const toolbarItems = [['heading', 'bold', 'italic', 'strike']];
 
-    container = document.createElement('div');
-    document.body.appendChild(container);
-
-    em = new EventEmitter();
-
-    destroy = render(
-      container,
-      html`
-        <${Toolbar}
-          ref=${(toolbar: Toolbar) => (ref = toolbar)}
-          eventEmitter=${em}
-          previewStyle="vertical"
-          toolbarItems=${toolbarItems}
-          editorType="markdown"
-        />
-      ` as VNode
-    );
-    jest
-      .spyOn(getElement(`.${cls('defaultUI-toolbar')}`), 'clientWidth', 'get')
-      .mockImplementation(() => 500);
+    el = document.createElement('div');
+
+    editor = new Editor({
+      el,
+      previewStyle: 'vertical',
+      height: '400px',
+      initialEditType: 'markdown',
+      toolbarItems,
+    });
+
+    document.body.appendChild(el);
   });
 
   afterEach(() => {
-    ref = null;
-    destroy();
+    editor.destroy();
+    document.body.removeChild(el);
   });
 
   it('should insert item on calling insertToolbarItem', () => {
-    ref!.insertToolbarItem({ groupIndex: 0, itemIndex: 1 }, 'ol');
+    editor.insertToolbarItem({ groupIndex: 0, itemIndex: 1 }, 'ol');
 
     const toolbarItems = getToolbarItems();
 
@@ -696,7 +507,7 @@ describe('API', () => {
   });
 
   it('should add item on calling insertToolbarItem', () => {
-    ref!.insertToolbarItem({ groupIndex: 1, itemIndex: 1 }, 'ol');
+    editor.insertToolbarItem({ groupIndex: 1, itemIndex: 1 }, 'ol');
 
     const toolbarItems = getToolbarItems();
 
@@ -719,7 +530,7 @@ describe('API', () => {
       style: { color: '#222', width: 40 },
     };
 
-    ref!.insertToolbarItem({ groupIndex: 0, itemIndex: 1 }, customButton);
+    editor.insertToolbarItem({ groupIndex: 0, itemIndex: 1 }, customButton);
 
     const toolbarItems = getToolbarItems();
 
@@ -735,47 +546,42 @@ describe('API', () => {
   });
 
   it('should remove item on calling removeToolbarItem', () => {
-    ref!.removeToolbarItem('bold');
+    editor.removeToolbarItem('bold');
 
-    assertToNotContainElement('.bold');
+    expect(screen.queryByLabelText('Bold')).toBeNull();
   });
 });
 
-describe('event', () => {
+describe('Event', () => {
+  let el: HTMLDivElement, editor: Editor;
+
   beforeEach(() => {
-    const toolbarItems = [['image', 'link']];
-
-    container = document.createElement('div');
-    document.body.appendChild(container);
-
-    em = new EventEmitter();
-
-    destroy = render(
-      container,
-      html`
-        <${Toolbar}
-          eventEmitter=${em}
-          previewStyle="vertical"
-          toolbarItems=${toolbarItems}
-          editorType="wysiwyg"
-        />
-      ` as VNode
-    );
+    el = document.createElement('div');
+
+    editor = new Editor({
+      el,
+      previewStyle: 'vertical',
+      height: '400px',
+      initialEditType: 'markdown',
+    });
+
+    document.body.appendChild(el);
   });
 
   afterEach(() => {
-    destroy();
+    editor.destroy();
+    document.body.removeChild(el);
   });
 
   describe('openPopup, closePopup', () => {
     it('should open and close popup corresponding to name', () => {
-      em.emit('openPopup', 'image');
+      editor.eventEmitter.emit('openPopup', 'image');
 
       const imagePopup = getElement(`.${cls('popup-add-image')}`);
 
       expect(imagePopup).toHaveStyle({ display: 'block' });
 
-      em.emit('closePopup');
+      editor.eventEmitter.emit('closePopup');
 
       expect(imagePopup).toHaveStyle({ display: 'none' });
     });
@@ -783,14 +589,10 @@ describe('event', () => {
     it('should render popup with initial values', () => {
       const initialValues = { linkUrl: 'http://test.com', linkText: 'foo' };
 
-      em.emit('openPopup', 'link', initialValues);
+      editor.eventEmitter.emit('openPopup', 'link', initialValues);
 
-      const urlText = getElement(
-        `.${cls('popup-add-link #toastuiLinkUrlInput')}`
-      ) as HTMLInputElement;
-      const linkText = getElement(
-        `.${cls('popup-add-link #toastuiLinkTextInput')}`
-      ) as HTMLInputElement;
+      const urlText = screen.getByText('URL').nextElementSibling as HTMLInputElement;
+      const linkText = screen.getByText('Link text').nextElementSibling as HTMLInputElement;
 
       expect(urlText).toHaveValue('http://test.com');
       expect(linkText).toHaveValue('foo');
diff --git a/apps/editor/src/__test__/unit/editor.spec.ts b/apps/editor/src/__test__/unit/editor.spec.ts
index 490a1cce..a33a9d1f 100644
--- a/apps/editor/src/__test__/unit/editor.spec.ts
+++ b/apps/editor/src/__test__/unit/editor.spec.ts
@@ -117,6 +117,17 @@ describe('editor', () => {
 
         expect(editor.getHTML()).toBe(expected);
       });
+
+      it('placeholder should be removed', () => {
+        editor.changeMode('wysiwyg');
+        editor.setPlaceholder('placeholder');
+
+        const result = oneLineTrim`
+          <p><br></p>
+        `;
+
+        expect(editor.getHTML()).toBe(result);
+      });
     });
 
     it('changeMode()', () => {