Repository: nhn/tui.editor Branch: master Commit: 0c5c11bac0b9 Files: 498 Total size: 1.8 MB Directory structure: gitextract_h8buw_ig/ ├── .eslintrc.js ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── question.md │ ├── dependabot.yml │ ├── stale.yml │ └── workflows/ │ ├── check-types.yml │ ├── examplePageTest.yml │ ├── linter.yml │ ├── plugin-test.yml │ ├── publish-cdn.yml │ ├── publish-doc.yml │ ├── publish-npm-wrapper.yml │ ├── publish-npm.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__/ │ └── cssMock.js ├── apps/ │ ├── editor/ │ │ ├── README.md │ │ ├── demo/ │ │ │ └── esm/ │ │ │ └── index.html │ │ ├── examples/ │ │ │ ├── css/ │ │ │ │ └── tuidoc-example-style.css │ │ │ ├── data/ │ │ │ │ ├── md-default.js │ │ │ │ └── md-plugins.js │ │ │ ├── example01-editor-basic.html │ │ │ ├── example02-editor-with-horizontal-preview.html │ │ │ ├── example03-editor-with-wysiwyg-mode.html │ │ │ ├── example04-viewer.html │ │ │ ├── example05-viewer-using-editor-factory.html │ │ │ ├── example06-dark-theme.html │ │ │ ├── example07-editor-with-chart-plugin.html │ │ │ ├── example08-editor-with-code-syntax-highlight-plugin.html │ │ │ ├── example09-editor-with-color-syntax-plugin.html │ │ │ ├── example10-editor-with-table-merged-cell-plugin.html │ │ │ ├── example11-editor-with-uml-plugin.html │ │ │ ├── example12-editor-with-all-plugins.html │ │ │ ├── example13-creating-plugin.html │ │ │ ├── example14-using-command.html │ │ │ ├── example15-customizing-toolbar-buttons.html │ │ │ ├── example16-i18n.html │ │ │ └── example17-placeholder.html │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── scripts/ │ │ │ ├── createConfigVariable.js │ │ │ ├── createIndexPage.js │ │ │ └── webpack.config.i18n.js │ │ ├── snowpack.config.js │ │ ├── src/ │ │ │ ├── __test__/ │ │ │ │ ├── integration/ │ │ │ │ │ ├── ui/ │ │ │ │ │ │ ├── layout.spec.ts │ │ │ │ │ │ └── toolbar.spec.ts │ │ │ │ │ ├── vdom/ │ │ │ │ │ │ └── render.spec.ts │ │ │ │ │ └── widget/ │ │ │ │ │ └── widgetNode.spec.ts │ │ │ │ └── unit/ │ │ │ │ ├── convertor.spec.ts │ │ │ │ ├── dom.spec.ts │ │ │ │ ├── editor.spec.ts │ │ │ │ ├── eventEmitter.spec.ts │ │ │ │ ├── helper/ │ │ │ │ │ ├── common.spec.ts │ │ │ │ │ └── image.spec.ts │ │ │ │ ├── markdown/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── syntaxHighlight.spec.ts.snap │ │ │ │ │ ├── keymap.spec.ts │ │ │ │ │ ├── mdCommand.spec.ts │ │ │ │ │ ├── mdEditor.spec.ts │ │ │ │ │ ├── mdPreview.spec.ts │ │ │ │ │ ├── smartTask.spec.ts │ │ │ │ │ ├── syntaxHighlight.spec.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── sanitizer.spec.ts │ │ │ │ ├── vdom/ │ │ │ │ │ └── template.spec.ts │ │ │ │ ├── viewer.spec.ts │ │ │ │ └── wysiwyg/ │ │ │ │ ├── customBlock.spec.ts │ │ │ │ ├── helper/ │ │ │ │ │ └── pasteMsoList.spec.ts │ │ │ │ ├── keymap.spec.ts │ │ │ │ ├── wwCommand.spec.ts │ │ │ │ ├── wwEditor.spec.ts │ │ │ │ ├── wwTableCommand.spec.ts │ │ │ │ └── wwToDOMAdaptor.spec.ts │ │ │ ├── base.ts │ │ │ ├── commands/ │ │ │ │ ├── commandManager.ts │ │ │ │ ├── defaultCommands.ts │ │ │ │ └── wwCommands.ts │ │ │ ├── convertors/ │ │ │ │ ├── convertor.ts │ │ │ │ ├── toMarkdown/ │ │ │ │ │ ├── toMdConvertorState.ts │ │ │ │ │ ├── toMdConvertors.ts │ │ │ │ │ └── toMdNodeTypeWriters.ts │ │ │ │ └── toWysiwyg/ │ │ │ │ ├── htmlToWwConvertors.ts │ │ │ │ ├── toWwConvertorState.ts │ │ │ │ └── toWwConvertors.ts │ │ │ ├── css/ │ │ │ │ ├── contents.css │ │ │ │ ├── editor.css │ │ │ │ ├── md-syntax-highlighting.css │ │ │ │ ├── preview-highlighting.css │ │ │ │ └── theme/ │ │ │ │ └── dark.css │ │ │ ├── editor.ts │ │ │ ├── editorCore.ts │ │ │ ├── esm/ │ │ │ │ ├── index.ts │ │ │ │ └── indexViewer.ts │ │ │ ├── event/ │ │ │ │ └── eventEmitter.ts │ │ │ ├── helper/ │ │ │ │ ├── image.ts │ │ │ │ ├── manipulation.ts │ │ │ │ └── plugin.ts │ │ │ ├── i18n/ │ │ │ │ ├── ar.ts │ │ │ │ ├── cs-cz.ts │ │ │ │ ├── de-de.ts │ │ │ │ ├── en-us.ts │ │ │ │ ├── es-es.ts │ │ │ │ ├── fi-fi.ts │ │ │ │ ├── fr-fr.ts │ │ │ │ ├── gl-es.ts │ │ │ │ ├── hr-hr.ts │ │ │ │ ├── i18n.ts │ │ │ │ ├── it-it.ts │ │ │ │ ├── ja-jp.ts │ │ │ │ ├── ko-kr.ts │ │ │ │ ├── nb-no.ts │ │ │ │ ├── nl-nl.ts │ │ │ │ ├── pl-pl.ts │ │ │ │ ├── pt-br.ts │ │ │ │ ├── ru-ru.ts │ │ │ │ ├── sv-se.ts │ │ │ │ ├── tr-tr.ts │ │ │ │ ├── uk-ua.ts │ │ │ │ ├── zh-cn.ts │ │ │ │ └── zh-tw.ts │ │ │ ├── index.ts │ │ │ ├── indexEditorOnlyStyle.ts │ │ │ ├── indexViewer.ts │ │ │ ├── markdown/ │ │ │ │ ├── helper/ │ │ │ │ │ ├── list.ts │ │ │ │ │ ├── mdCommand.ts │ │ │ │ │ ├── pos.ts │ │ │ │ │ └── query.ts │ │ │ │ ├── htmlRenderConvertors.ts │ │ │ │ ├── marks/ │ │ │ │ │ ├── blockQuote.ts │ │ │ │ │ ├── code.ts │ │ │ │ │ ├── codeBlock.ts │ │ │ │ │ ├── customBlock.ts │ │ │ │ │ ├── emph.ts │ │ │ │ │ ├── heading.ts │ │ │ │ │ ├── html.ts │ │ │ │ │ ├── link.ts │ │ │ │ │ ├── listItem.ts │ │ │ │ │ ├── simpleMark.ts │ │ │ │ │ ├── strike.ts │ │ │ │ │ ├── strong.ts │ │ │ │ │ ├── table.ts │ │ │ │ │ └── thematicBreak.ts │ │ │ │ ├── mdEditor.ts │ │ │ │ ├── mdPreview.ts │ │ │ │ ├── nodes/ │ │ │ │ │ ├── doc.ts │ │ │ │ │ ├── paragraph.ts │ │ │ │ │ └── text.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── helper/ │ │ │ │ │ │ └── markInfo.ts │ │ │ │ │ ├── previewHighlight.ts │ │ │ │ │ ├── smartTask.ts │ │ │ │ │ └── syntaxHighlight.ts │ │ │ │ └── scroll/ │ │ │ │ ├── animation.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── offset.ts │ │ │ │ └── scrollSync.ts │ │ │ ├── plugins/ │ │ │ │ ├── dropImage.ts │ │ │ │ ├── placeholder.ts │ │ │ │ └── popupWidget.ts │ │ │ ├── queries/ │ │ │ │ └── queryManager.ts │ │ │ ├── sanitizer/ │ │ │ │ └── htmlSanitizer.ts │ │ │ ├── spec/ │ │ │ │ ├── mark.ts │ │ │ │ ├── node.ts │ │ │ │ └── specManager.ts │ │ │ ├── ui/ │ │ │ │ ├── components/ │ │ │ │ │ ├── contextMenu.ts │ │ │ │ │ ├── layout.ts │ │ │ │ │ ├── popup.ts │ │ │ │ │ ├── switch.ts │ │ │ │ │ ├── tabs.ts │ │ │ │ │ └── toolbar/ │ │ │ │ │ ├── buttonHoc.ts │ │ │ │ │ ├── customPopupBody.ts │ │ │ │ │ ├── customToolbarItem.ts │ │ │ │ │ ├── dropdownToolbarButton.ts │ │ │ │ │ ├── headingPopupBody.ts │ │ │ │ │ ├── imagePopupBody.ts │ │ │ │ │ ├── linkPopupBody.ts │ │ │ │ │ ├── tablePopupBody.ts │ │ │ │ │ ├── toolbar.ts │ │ │ │ │ ├── toolbarButton.ts │ │ │ │ │ └── toolbarGroup.ts │ │ │ │ ├── toolbarItemFactory.ts │ │ │ │ └── vdom/ │ │ │ │ ├── commit.ts │ │ │ │ ├── component.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── htm.js │ │ │ │ ├── render.ts │ │ │ │ ├── renderer.ts │ │ │ │ ├── template.ts │ │ │ │ └── vnode.ts │ │ │ ├── utils/ │ │ │ │ ├── common.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── map.ts │ │ │ │ └── markdown.ts │ │ │ ├── viewer.ts │ │ │ ├── widget/ │ │ │ │ ├── rules.ts │ │ │ │ └── widgetNode.ts │ │ │ └── wysiwyg/ │ │ │ ├── adaptor/ │ │ │ │ ├── mdLikeNode.ts │ │ │ │ └── wwToDOMAdaptor.ts │ │ │ ├── clipboard/ │ │ │ │ ├── paste.ts │ │ │ │ ├── pasteMsoList.ts │ │ │ │ └── pasteToTable.ts │ │ │ ├── command/ │ │ │ │ ├── list.ts │ │ │ │ └── table.ts │ │ │ ├── helper/ │ │ │ │ ├── node.ts │ │ │ │ ├── table.ts │ │ │ │ └── tableOffsetMap.ts │ │ │ ├── marks/ │ │ │ │ ├── code.ts │ │ │ │ ├── emph.ts │ │ │ │ ├── link.ts │ │ │ │ ├── strike.ts │ │ │ │ └── strong.ts │ │ │ ├── nodes/ │ │ │ │ ├── blockQuote.ts │ │ │ │ ├── bulletList.ts │ │ │ │ ├── codeBlock.ts │ │ │ │ ├── customBlock.ts │ │ │ │ ├── doc.ts │ │ │ │ ├── frontMatter.ts │ │ │ │ ├── heading.ts │ │ │ │ ├── html.ts │ │ │ │ ├── htmlComment.ts │ │ │ │ ├── image.ts │ │ │ │ ├── listItem.ts │ │ │ │ ├── orderedList.ts │ │ │ │ ├── paragraph.ts │ │ │ │ ├── table.ts │ │ │ │ ├── tableBody.ts │ │ │ │ ├── tableBodyCell.ts │ │ │ │ ├── tableHead.ts │ │ │ │ ├── tableHeadCell.ts │ │ │ │ ├── tableRow.ts │ │ │ │ ├── text.ts │ │ │ │ └── thematicBreak.ts │ │ │ ├── nodeview/ │ │ │ │ ├── codeBlockView.ts │ │ │ │ ├── customBlockView.ts │ │ │ │ └── imageView.ts │ │ │ ├── plugins/ │ │ │ │ ├── selection/ │ │ │ │ │ ├── cellSelection.ts │ │ │ │ │ ├── tableSelection.ts │ │ │ │ │ └── tableSelectionView.ts │ │ │ │ ├── tableContextMenu.ts │ │ │ │ ├── task.ts │ │ │ │ └── toolbarState.ts │ │ │ ├── specCreator.ts │ │ │ └── wwEditor.ts │ │ ├── tsBannerGenerator.js │ │ ├── tsconfig.json │ │ ├── tuidoc.config.json │ │ ├── types/ │ │ │ ├── convertor.d.ts │ │ │ ├── editor.d.ts │ │ │ ├── event.d.ts │ │ │ ├── index.d.ts │ │ │ ├── map.d.ts │ │ │ ├── markdown.d.ts │ │ │ ├── plugin.d.ts │ │ │ ├── prosemirror-commands.d.ts │ │ │ ├── prosemirror-model.d.ts │ │ │ ├── prosemirror-transform.d.ts │ │ │ ├── spec.d.ts │ │ │ ├── toastmark.d.ts │ │ │ ├── toastui-editor-viewer.d.ts │ │ │ ├── ui.d.ts │ │ │ └── wysiwyg.d.ts │ │ └── webpack.config.js │ ├── react-editor/ │ │ ├── .eslintrc.js │ │ ├── README.md │ │ ├── demo/ │ │ │ └── esm/ │ │ │ ├── index.html │ │ │ └── index.jsx │ │ ├── index.d.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── snowpack.config.js │ │ ├── src/ │ │ │ ├── editor.tsx │ │ │ ├── index.ts │ │ │ └── viewer.tsx │ │ ├── tsconfig.json │ │ └── webpack.config.js │ └── vue-editor/ │ ├── .eslintrc.js │ ├── README.md │ ├── demo/ │ │ └── esm/ │ │ ├── index.html │ │ └── index.js │ ├── index.d.ts │ ├── package.json │ ├── rollup.config.js │ ├── snowpack.config.js │ ├── src/ │ │ ├── Editor.vue │ │ ├── Viewer.vue │ │ ├── index.js │ │ └── mixin/ │ │ └── option.js │ └── webpack.config.js ├── docs/ │ ├── COMMIT_MESSAGE_CONVENTION.md │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── README.md │ ├── en/ │ │ ├── custom-block.md │ │ ├── custom-html-renderer.md │ │ ├── extended-autolinks.md │ │ ├── getting-started.md │ │ ├── i18n.md │ │ ├── plugin.md │ │ ├── toolbar.md │ │ ├── viewer.md │ │ └── widget.md │ ├── ko/ │ │ ├── README.md │ │ ├── custom-block.md │ │ ├── custom-html-renderer.md │ │ ├── extended-autolinks.md │ │ ├── getting-started.md │ │ ├── i18n.md │ │ ├── plugin.md │ │ ├── toolbar.md │ │ ├── viewer.md │ │ └── widget.md │ ├── v3.0-migration-guide-ko.md │ └── v3.0-migration-guide.md ├── jest-setup.js ├── jest.base.config.js ├── jest.config.js ├── lerna.json ├── libs/ │ └── toastmark/ │ ├── .eslintrc.js │ ├── LICENSE │ ├── README.md │ ├── demo/ │ │ └── index.html │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── snowpack.config.js │ ├── src/ │ │ ├── __sample__/ │ │ │ ├── index.css │ │ │ └── index.ts │ │ ├── __test__/ │ │ │ └── toastmark.spec.ts │ │ ├── commonmark/ │ │ │ ├── __test__/ │ │ │ │ ├── base-examples.json │ │ │ │ ├── base-examples.spec.ts │ │ │ │ ├── helper.spec.ts │ │ │ │ ├── options.spec.ts │ │ │ │ ├── sourcepos.spec.ts │ │ │ │ └── syntax-info.spec.ts │ │ │ ├── blockHandlers.ts │ │ │ ├── blockHelper.ts │ │ │ ├── blockStarts.ts │ │ │ ├── blocks.ts │ │ │ ├── common.ts │ │ │ ├── custom/ │ │ │ │ ├── __test__/ │ │ │ │ │ ├── customBlock.spec.ts │ │ │ │ │ └── customInline.spec.ts │ │ │ │ ├── customBlockHandler.ts │ │ │ │ └── customBlockStart.ts │ │ │ ├── from-code-point.ts │ │ │ ├── frontMatter/ │ │ │ │ ├── __test__/ │ │ │ │ │ └── frontMatter.spec.ts │ │ │ │ ├── frontMatterHandler.ts │ │ │ │ └── frontMatterStart.ts │ │ │ ├── gfm/ │ │ │ │ ├── __test__/ │ │ │ │ │ ├── autolinks.spec.ts │ │ │ │ │ ├── strikethrough.spec.ts │ │ │ │ │ ├── table.spec.ts │ │ │ │ │ ├── tagfilter.spec.ts │ │ │ │ │ └── taskListItem.spec.ts │ │ │ │ ├── autoLinks.ts │ │ │ │ ├── tableBlockHandler.ts │ │ │ │ ├── tableBlockStart.ts │ │ │ │ └── taskListItem.ts │ │ │ ├── inlines.ts │ │ │ ├── node.ts │ │ │ ├── nodeWalker.ts │ │ │ └── rawHtml.ts │ │ ├── helper.ts │ │ ├── html/ │ │ │ ├── __test__/ │ │ │ │ └── render.spec.ts │ │ │ ├── baseConvertors.ts │ │ │ ├── gfmConvertors.ts │ │ │ ├── renderer.ts │ │ │ └── tagFilter.ts │ │ ├── index.ts │ │ ├── nodeHelper.ts │ │ └── toastmark.ts │ ├── tsconfig.json │ ├── types/ │ │ ├── index.d.ts │ │ ├── node.d.ts │ │ ├── parser.d.ts │ │ ├── renderer.d.ts │ │ └── toastMark.d.ts │ └── webpack.config.js ├── package.json ├── plugins/ │ ├── chart/ │ │ ├── README.md │ │ ├── demo/ │ │ │ ├── editor.html │ │ │ ├── esm/ │ │ │ │ └── index.html │ │ │ └── viewer.html │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── snowpack.config.js │ │ ├── src/ │ │ │ ├── __test__/ │ │ │ │ └── unit/ │ │ │ │ └── chartPlugin.spec.ts │ │ │ ├── csv.js │ │ │ ├── index.ts │ │ │ └── util.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ └── index.d.ts │ │ └── webpack.config.js │ ├── code-syntax-highlight/ │ │ ├── README.md │ │ ├── demo/ │ │ │ ├── editor-all-langs.html │ │ │ ├── editor.html │ │ │ ├── esm/ │ │ │ │ └── index.html │ │ │ └── viewer.html │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── snowpack.config.js │ │ ├── src/ │ │ │ ├── __test__/ │ │ │ │ ├── integration/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ ├── codeHighlightPlugin.spec.ts.snap │ │ │ │ │ │ └── codeHighlightPluginWithAllLangs.spec.ts.snap │ │ │ │ │ ├── codeHighlightPlugin.spec.ts │ │ │ │ │ └── codeHighlightPluginWithAllLangs.spec.ts │ │ │ │ └── unit/ │ │ │ │ └── languageSelectBox.spec.ts │ │ │ ├── css/ │ │ │ │ └── plugin.css │ │ │ ├── index.ts │ │ │ ├── indexAll.ts │ │ │ ├── nodeViews/ │ │ │ │ ├── codeSyntaxHighlightView.ts │ │ │ │ └── languageSelectBox.ts │ │ │ ├── plugin.ts │ │ │ ├── plugins/ │ │ │ │ └── codeSyntaxHighlighting.ts │ │ │ ├── prismjs-langs.ts │ │ │ ├── renderers/ │ │ │ │ └── toHTMLRenderers.ts │ │ │ └── utils/ │ │ │ ├── common.ts │ │ │ └── dom.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── index.d.ts │ │ │ └── prosemirror-transform.d.ts │ │ └── webpack.config.js │ ├── color-syntax/ │ │ ├── README.md │ │ ├── demo/ │ │ │ ├── editor.html │ │ │ └── esm/ │ │ │ └── index.html │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── snowpack.config.js │ │ ├── src/ │ │ │ ├── __test__/ │ │ │ │ └── integration/ │ │ │ │ └── colorSyntaxPlugin.spec.ts │ │ │ ├── css/ │ │ │ │ └── plugin.css │ │ │ ├── i18n/ │ │ │ │ └── langs.ts │ │ │ ├── index.ts │ │ │ └── utils/ │ │ │ └── dom.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── index.d.ts │ │ │ ├── prosemirror-model.d.ts │ │ │ └── tui-color-picker.d.ts │ │ └── webpack.config.js │ ├── table-merged-cell/ │ │ ├── README.md │ │ ├── demo/ │ │ │ ├── data.js │ │ │ ├── editor.html │ │ │ ├── esm/ │ │ │ │ └── index.html │ │ │ └── viewer.html │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── snowpack.config.js │ │ ├── src/ │ │ │ ├── __test__/ │ │ │ │ └── integration/ │ │ │ │ ├── convertor.spec.ts │ │ │ │ ├── markdown/ │ │ │ │ │ └── mergedTablePreview.spec.ts │ │ │ │ └── wysiwyg/ │ │ │ │ ├── addColumn.spec.ts │ │ │ │ ├── addRow.spec.ts │ │ │ │ ├── helper/ │ │ │ │ │ ├── cellSelection.ts │ │ │ │ │ ├── tableOffsetMap.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mergeCells.spec.ts │ │ │ │ ├── removeColumn.spec.ts │ │ │ │ ├── removeRow.spec.ts │ │ │ │ └── splitCells.spec.ts │ │ │ ├── css/ │ │ │ │ └── plugin.css │ │ │ ├── i18n/ │ │ │ │ └── langs.ts │ │ │ ├── index.ts │ │ │ ├── markdown/ │ │ │ │ ├── parser.ts │ │ │ │ └── renderer.ts │ │ │ └── wysiwyg/ │ │ │ ├── command/ │ │ │ │ ├── addColumn.ts │ │ │ │ ├── addRow.ts │ │ │ │ ├── direction.ts │ │ │ │ ├── mergeCells.ts │ │ │ │ ├── removeColumn.ts │ │ │ │ ├── removeRow.ts │ │ │ │ └── splitCells.ts │ │ │ ├── commandFactory.ts │ │ │ ├── contextMenu.ts │ │ │ ├── renderer.ts │ │ │ ├── tableOffsetMapMixin.ts │ │ │ └── util.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── index.d.ts │ │ │ └── prosemirror-transform.d.ts │ │ └── webpack.config.js │ └── uml/ │ ├── README.md │ ├── demo/ │ │ ├── editor.html │ │ ├── esm/ │ │ │ └── index.html │ │ └── viewer.html │ ├── index.d.ts │ ├── jest.config.js │ ├── package.json │ ├── snowpack.config.js │ ├── src/ │ │ ├── __test__/ │ │ │ └── integration/ │ │ │ └── umlPlugin.spec.ts │ │ └── index.ts │ ├── tsconfig.json │ └── webpack.config.js ├── scripts/ │ ├── pkg-script.js │ └── publish-cdn.js ├── tsconfig.json └── types/ └── tui-code-snippet.d.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.js ================================================ module.exports = { root: true, plugins: ['prettier', '@typescript-eslint'], extends: ['tui/es6', 'plugin:prettier/recommended', 'plugin:@typescript-eslint/recommended'], parser: '@typescript-eslint/parser', parserOptions: { parser: 'typescript-eslint-parser', }, env: { browser: true, node: true, jest: true, }, globals: { jest: true, }, ignorePatterns: ['node_modules/*', 'dist'], rules: { '@typescript-eslint/no-non-null-assertion': 0, '@typescript-eslint/explicit-function-return-type': 0, '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/ban-types': 0, '@typescript-eslint/ban-ts-comment': 0, '@typescript-eslint/no-useless-constructor': 2, 'lines-around-directive': 0, 'newline-before-return': 0, 'no-use-before-define': 0, 'no-useless-constructor': 0, 'padding-line-between-statements': [ 2, { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, ], 'no-useless-rename': 'error', 'no-duplicate-imports': ['error', { includeExports: true }], 'dot-notation': ['error', { allowKeywords: true }], 'prefer-destructuring': [ 'error', { VariableDeclarator: { array: true, object: true, }, AssignmentExpression: { array: false, object: false, }, }, { enforceForRenamedProperties: false, }, ], 'arrow-body-style': ['error', 'as-needed', { requireReturnForObjectLiteral: true }], 'object-property-newline': ['error', { allowMultiplePropertiesPerLine: true }], 'no-sync': 0, complexity: 0, 'max-nested-callbacks': ['error', 4], 'no-cond-assign': 0, 'max-depth': ['error', 4], 'no-return-assign': 0, }, }; ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: Bug assignees: '' --- ## Describe the bug A clear and concise description of what the bug is. ## To Reproduce Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior A clear and concise description of what you expected to happen. ## Screenshots If applicable, add screenshots to help explain your problem. ## Desktop (please complete the following information): - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] ## Smartphone (please complete the following information): - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] ## Additional context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: Enhancement, Need Discussion assignees: '' --- ## Version Write the version that you are currently using. ## Development Environment Write the browser type, OS and so on. ## Current Behavior Write a description of the current operation. You can add sample code, 'CodePen' or 'jsfiddle' links. ```js // Write example code ``` ## Expected Behavior Write a description of the future action. ================================================ FILE: .github/ISSUE_TEMPLATE/question.md ================================================ --- name: Question about: Create a question about the Editor title: '' labels: Question assignees: '' --- ## Summary A clear and concise description of what the question is. ## Screenshots If applicable, add screenshots to help explain your question. ## Version Write the version of the Editor you are currently using. ## Additional context Add any other context about the problem here. ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly - package-ecosystem: npm open-pull-requests-limit: 30 directory: / schedule: interval: weekly ================================================ FILE: .github/stale.yml ================================================ # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale daysUntilStale: 30 # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. daysUntilClose: 7 # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: - Feature - Enhancement - Bug - NHN Cloud # Label to use when marking as stale staleLabel: inactive # Comment to post when marking as stale. Set to `false` to disable markComment: > This issue has been automatically marked as inactive because there hasn’t been much going on it lately. It is going to be closed after 7 days. Thanks! # Comment to post when closing a stale Issue or Pull Request. closeComment: > This issue will be closed due to inactivity. Thanks for your contribution! ================================================ FILE: .github/workflows/check-types.yml ================================================ name: Editor Check Types on: pull_request jobs: check-types: name: Check Types runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: check types run: | npm run test:types:all ================================================ FILE: .github/workflows/examplePageTest.yml ================================================ name: detect runtime error on: schedule: - cron: '0 22 * * *' jobs: makeUrl: runs-on: ubuntu-latest env: WORKING_DIRECTORY: ./apps/editor steps: - name: checkout repository uses: actions/checkout@v2 - name: create config variable working-directory: ${{ env.WORKING_DIRECTORY }} run: | node scripts/createConfigVariable.js - name: set global error variable working-directory: ${{ env.WORKING_DIRECTORY }} run: | echo ::set-env name=ERROR_VARIABLE::$(head -n 1 ./errorVariable.txt) - name: set url working-directory: ${{ env.WORKING_DIRECTORY }} run: | echo ::set-env name=URLS::$(head -n 1 ./url.txt) - name: detect runtime error uses: nhn/toast-ui.detect-runtime-error-actions@master with: global-error-log-variable: ${{ env.ERROR_VARIABLE }} urls: ${{ env.URLS }} browserlist: ie11, safari, edge, firefox, chrome env: BROWSERSTACK_USERNAME: ${{secrets.BROWSERSTACK_USERNAME}} BROWSERSTACK_ACCESS_KEY: ${{secrets.BROWSERSTACK_ACCESS_KEY}} ================================================ FILE: .github/workflows/linter.yml ================================================ name: Editor Lint Code Base on: pull_request jobs: lint: name: Lint Code Base runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: eslint run: | npm run lint:all ================================================ FILE: .github/workflows/plugin-test.yml ================================================ name: Plugin Unit, Integration Test on: pull_request jobs: plugin-test: name: Unit, Integration Test runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm install - name: build toastmark run: | npm run build toastmark - name: build editor run: | npm run build editor - name: chart plugin unit, integration test run: | npm run test:ci chart - name: color syntax plugin unit, integration test run: | npm run test:ci color - name: code syntax highlighting plugin unit, integration test run: | npm run test:ci code - name: table merged cell plugin unit, integration test run: | npm run test:ci table - name: uml plugin unit, integration test run: | npm run test:ci uml ================================================ FILE: .github/workflows/publish-cdn.yml ================================================ name: Cdn Publish on: [workflow_dispatch] jobs: pre-check: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Eslint run: | npm run lint:all - name: Check types run: | npm run test:types:all test: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark - name: Toastmark unit, integration test run: | npm run test:ci toastmark - name: Editor unit, integration test run: | npm run test:ci editor plugin-test: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor - name: chart plugin unit, integration test run: | npm run test:ci chart - name: color syntax plugin unit, integration test run: | npm run test:ci color - name: code syntax highlighting plugin unit, integration test run: | npm run test:ci code - name: table merged cell plugin unit, integration test run: | npm run test:ci table - name: uml plugin unit, integration test run: | npm run test:ci uml publish-cdn: runs-on: ubuntu-latest needs: [pre-check, test, plugin-test] steps: - uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor - name: Publish CDN run: | npm run publish:cdn env: TOAST_CLOUD_TENENTID: ${{ secrets.TOAST_CLOUD_TENENTID }} TOAST_CLOUD_STORAGEID: ${{ secrets.TOAST_CLOUD_STORAGEID }} TOAST_CLOUD_USERNAME: ${{ secrets.TOAST_CLOUD_USERNAME }} TOAST_CLOUD_PASSWORD: ${{ secrets.TOAST_CLOUD_PASSWORD }} ================================================ FILE: .github/workflows/publish-doc.yml ================================================ name: Doc Publish on: [workflow_dispatch] jobs: pre-check: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Eslint run: | npm run lint:all - name: Check types run: | npm run test:types:all test: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark - name: Toastmark unit, integration test run: | npm run test:ci toastmark - name: Editor unit, integration test run: | npm run test:ci editor plugin-test: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor - name: chart plugin unit, integration test run: | npm run test:ci chart - name: color syntax plugin unit, integration test run: | npm run test:ci color - name: code syntax highlighting plugin unit, integration test run: | npm run test:ci code - name: table merged cell plugin unit, integration test run: | npm run test:ci table - name: uml plugin unit, integration test run: | npm run test:ci uml doc: runs-on: ubuntu-latest needs: [pre-check, test, plugin-test] steps: - uses: actions/checkout@v2 - name: Check the package version id: check uses: PostHog/check-package-version@v2 with: path: ./apps/editor/ - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm install - name: Build run: | npm run build toastmark npm run build editor - name: Use Node.js 10.x uses: actions/setup-node@v2.5.1 with: node-version: '10.x' - name: Install @toast-ui/doc run: | npm i -g @toast-ui/doc - name: Run doc run: | npm run doc mv apps/editor/_${{ steps.check.outputs.committed-version }} ${{ steps.check.outputs.committed-version }} mv apps/editor/_latest latest rm -rf apps/editor/tmpdoc git checkout -- apps/editor/types/index.d.ts package-lock.json git add ${{ steps.check.outputs.committed-version }}/dist -f git add latest/dist -f git stash --include-untracked - name: Checkout gh-pages uses: actions/checkout@v2 with: ref: gh-pages - name: Commit files run: | 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 . git stash pop git add . git commit -m '${{ steps.check.outputs.committed-version }}' - name: Push changes uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages ================================================ FILE: .github/workflows/publish-npm-wrapper.yml ================================================ name: Wrapper Npm Publish on: [workflow_dispatch] jobs: pre-check: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Eslint run: | npm run lint:all - name: Check types run: | npm run test:types:all test: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark - name: Toastmark unit, integration test run: | npm run test:ci toastmark - name: Editor unit, integration test run: | npm run test:ci editor plugin-test: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor - name: chart plugin unit, integration test run: | npm run test:ci chart - name: color syntax plugin unit, integration test run: | npm run test:ci color - name: code syntax highlighting plugin unit, integration test run: | npm run test:ci code - name: table merged cell plugin unit, integration test run: | npm run test:ci table - name: uml plugin unit, integration test run: | npm run test:ci uml publish: runs-on: ubuntu-latest needs: [pre-check, test, plugin-test] steps: - uses: actions/checkout@v2 - name: Check the package version id: check uses: PostHog/check-package-version@v2 with: path: ./apps/editor/ - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' registry-url: https://registry.npmjs.org/ - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor npm run build react npm run build vue - name: Npm Publish(react) working-directory: ./apps/react-editor run: | npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} - name: Npm Publish(vue) working-directory: ./apps/vue-editor run: | npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} ================================================ FILE: .github/workflows/publish-npm.yml ================================================ name: Npm Publish on: [workflow_dispatch] jobs: checkVersion: name: Check package version runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Check package version id: check uses: PostHog/check-package-version@v2 with: path: ./apps/editor/ - name: Cancel when unchanged uses: andymckay/cancel-action@0.2 if: steps.check.outputs.is-new-version == 'false' pre-check: needs: [checkVersion] runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Eslint run: | npm run lint:all - name: Check types run: | npm run test:types:all test: needs: [checkVersion] runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark - name: Toastmark unit, integration test run: | npm run test:ci toastmark - name: Editor unit, integration test run: | npm run test:ci editor plugin-test: needs: [checkVersion] runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor - name: chart plugin unit, integration test run: | npm run test:ci chart - name: color syntax plugin unit, integration test run: | npm run test:ci color - name: code syntax highlighting plugin unit, integration test run: | npm run test:ci code - name: table merged cell plugin unit, integration test run: | npm run test:ci table - name: uml plugin unit, integration test run: | npm run test:ci uml publish: runs-on: ubuntu-latest needs: [pre-check, test, plugin-test] steps: - uses: actions/checkout@v2 - name: Check the package version id: check uses: PostHog/check-package-version@v2 with: path: ./apps/editor/ - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' registry-url: https://registry.npmjs.org/ - name: Install run: | npm ci - name: Build run: | npm run build toastmark npm run build editor - name: Create Tag run: | 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: | git push origin editor@${{ steps.check.outputs.committed-version }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Npm Publish(editor) working-directory: ./apps/editor run: | npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} ================================================ FILE: .github/workflows/test.yml ================================================ name: Editor Unit, Integration Test on: pull_request jobs: test: name: Unit, Integration Test runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v2 - name: Use Node.js 15.x uses: actions/setup-node@v2.5.1 with: node-version: '15.x' - name: Install run: | npm install - name: build toastmark run: | npm run build toastmark - name: toastmark unit, integration test run: | npm run test:ci toastmark - name: editor unit, integration test run: | npm run test:ci editor ================================================ FILE: .gitignore ================================================ # Logs logs *.log # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage screenshots # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directory node_modules # Bower Components bower_components lib # IDEA .idea *.iml # Window Thumbs.db Desktop.ini # MAC .DS_Store # SVN .svn # eclipse .project .metadata # build build # etc *.swp etc temp api doc report karma.conf.local.js .tern-project .tern-port *.vim .\#* .vscode/ dist/ ================================================ FILE: .prettierignore ================================================ *.md *.html ================================================ FILE: .prettierrc.js ================================================ module.exports = { printWidth: 100, singleQuote: true }; ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at dl_javascript@nhn.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to TOAST UI First off, thanks for taking the time to contribute! 🎉 😘 ✨ The following is a set of guidelines for contributing to TOAST UI. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. ## Reporting Bugs Bugs are tracked as GitHub issues. Search the list and try reproduce on [demo][demo] before you create an issue. When you create an issue, please provide the following information by filling in the template. Explain the problem and include additional details to help maintainers reproduce the problem: * **Use a clear and descriptive title** for the issue to identify the problem. * **Describe the exact steps which reproduce the problem** in as many details as possible. Don't just say what you did, but explain how you did it. For example, if you moved the cursor to the end of a line, explain if you used a mouse or a keyboard. * **Provide specific examples to demonstrate the steps.** Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets on the issue, use Markdown code blocks. * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. * **Explain which behavior you expected to see instead and why.** * **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. ## Suggesting Enhancements In case you want to suggest for TOAST UI Editor, please follow this guideline to help maintainers and the community understand your suggestion. Before creating suggestions, please check [issue list](https://github.com/nhn/tui.editor/labels/feature) if there's already a request. Create an issue and provide the following information: * **Use a clear and descriptive title** for the issue to identify the suggestion. * **Provide a step-by-step description of the suggested enhancement** in as many details as possible. * **Provide specific examples to demonstrate the steps.** Include copy/pasteable snippets which you use in those examples, as Markdown code blocks. * **Include screenshots and animated GIFs** which helps demonstrate the steps or point out the part of TOAST UI Editor which the suggestion is related to. * **Explain why this enhancement would be useful** to most TOAST UI users. * **List some other text editors or applications where this enhancement exists.** ## First Code Contribution Unsure where to begin contributing to TOAST UI? You can start by looking through these `document`, `good first issue` and `help wanted` issues: * **document issues**: issues which should be reviewed or improved. * **good first issues**: issues which should only require a few lines of code, and a test or two. * **help wanted issues**: issues which should be a bit more involved than beginner issues. ## Pull Requests ### Development WorkFlow - Set up your development environment - Make change from a right branch - Be sure the code passes `npm run lint:all`, `npm run test:types:all`, `npm run test:all` - Make a pull request ### Development environment - Prepare your machine node and it's packages installed. - Checkout our repository - Install dependencies by `npm install` - Build toastmark by `npm run build toastmark` - Start snowpack-dev-server by `npm run serve` ### Make changes #### Checkout a branch - **master**: PR Base branch. - **production**: lastest release branch with distribution files. never make a PR on this - **gh-pages**: API docs, examples and demo #### Check Code Style Run `npm run eslint` and make sure all the tests pass. #### Test Run `npm run test:all` and verify all the tests pass. If you are adding new commands or features, they must include tests. If you are changing functionality, update the tests if you need to. #### Commit Follow our [commit message conventions](./docs/COMMIT_MESSAGE_CONVENTION.md). ### Yes! Pull request Make your pull request, then describe your changes. #### Title Follow other PR title format on below. ``` : Short Description (fix #111) : Short Description (fix #123, #111, #122) : Short Description (ref #111) ``` * capitalize first letter of Type * use present tense: 'change' not 'changed' or 'changes' #### Description If it has related to issues, add links to the issues(like `#123`) in the description. Fill in the [Pull Request Template](./docs/PULL_REQUEST_TEMPLATE.md) by check your case. ## Code of Conduct This project and everyone participating in it is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to dl_javascript@nhn.com. > This Guide is base on [atom contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md), [CocoaPods](http://guides.cocoapods.org/contributing/contribute-to-cocoapods.html) and [ESLint](http://eslint.org/docs/developer-guide/contributing/pull-requests) [demo]:https://nhn.github.io/tui.editor/ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 NHN Cloud Corp. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ![TOAST UI Editor](https://uicdn.toast.com/toastui/img/tui-editor-bi.png) > GFM Markdown and WYSIWYG Editor - Productive and Extensible [![github release version](https://img.shields.io/github/v/release/nhn/tui.editor.svg?include_prereleases)](https://github.com/nhn/tui.editor/releases/latest) [![npm version](https://img.shields.io/npm/v/@toast-ui/editor.svg)](https://www.npmjs.com/package/@toast-ui/editor) [![license](https://img.shields.io/github/license/nhn/tui.editor.svg)](https://github.com/nhn/tui.editor/blob/master/LICENSE) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg)](https://github.com/nhn/tui.editor/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) [![code with hearth by NHN Cloud](https://img.shields.io/badge/%3C%2F%3E%20with%20%E2%99%A5%20by-NHN_Cloud-ff1414.svg)](https://github.com/nhn) ## 🚩 Table of Contents - [Packages](#-packages) - [Why TOAST UI Editor?](#-why-toast-ui-editor) - [Features](#-features) - [Examples](#-examples) - [Browser Support](#-browser-support) - [Pull Request Steps](#-pull-request-steps) - [Contributing](#-contributing) - [TOAST UI Family](#-toast-ui-family) - [Used By](#-used-by) - [License](#-license) ## 📦 Packages ### TOAST UI Editor | Name | Description | | --- | --- | | [`@toast-ui/editor`](https://github.com/nhn/tui.editor/tree/master/apps/editor) | Plain JavaScript component | ### TOAST UI Editor's Wrappers | Name | Description | | --- | --- | | [`@toast-ui/react-editor`](https://github.com/nhn/tui.editor/tree/master/apps/react-editor) | [React](https://reactjs.org/) wrapper component | | [`@toast-ui/vue-editor`](https://github.com/nhn/tui.editor/tree/master/apps/vue-editor) | [Vue](https://vuejs.org/) wrapper component | ### TOAST UI Editor's Plugins | Name | Description | | --- | --- | | [`@toast-ui/editor-plugin-chart`](https://github.com/nhn/tui.editor/tree/master/plugins/chart) | Plugin to render chart | | [`@toast-ui/editor-plugin-code-syntax-highlight`](https://github.com/nhn/tui.editor/tree/master/plugins/code-syntax-highlight) | Plugin to highlight code syntax | | [`@toast-ui/editor-plugin-color-syntax`](https://github.com/nhn/tui.editor/tree/master/plugins/color-syntax) | Plugin to color editing text | | [`@toast-ui/editor-plugin-table-merged-cell`](https://github.com/nhn/tui.editor/tree/master/plugins/table-merged-cell) | Plugin to merge table columns | | [`@toast-ui/editor-plugin-uml`](https://github.com/nhn/tui.editor/tree/master/plugins/uml) | Plugin to render UML | ## 🤖 Why TOAST UI Editor? TOAST UI Editor provides **Markdown mode** and **WYSIWYG mode**. Depending on the type of use you want like production of *Markdown* or maybe to just edit the *Markdown*. The TOAST UI Editor can be helpful for both the usage. It offers **Markdown mode** and **WYSIWYG mode**, which can be switched any point in time. ### Productive Markdown Mode ![markdown](https://user-images.githubusercontent.com/37766175/121464762-71e2fc80-c9ef-11eb-9a0a-7b06e08d3ccb.png) **CommonMark + GFM Specifications** Today *CommonMark* is the de-facto *Markdown* standard. *GFM (GitHub Flavored Markdown)* is another popular specification based on *CommonMark* - maintained by *GitHub*, which is the *Markdown* mostly used. TOAST UI Editor follows both [*CommonMark*](http://commonmark.org/) and [*GFM*](https://github.github.com/gfm/) specifications. Write documents with ease using productive tools provided by TOAST UI Editor and you can easily open the produced document wherever the specifications are supported. * **Live Preview** : Edit Markdown while keeping an eye on the rendered HTML. Your edits will be applied immediately. * **Scroll Sync** : Synchronous scrolling between Markdown and Preview. You don't need to scroll through each one separately. * **Syntax Highlight** : You can check broken Markdown syntax immediately. ### Easy WYSIWYG Mode ![wysiwyg](https://user-images.githubusercontent.com/37766175/121808381-251f5000-cc93-11eb-8c47-4f5a809de2b3.png) * **Table** : Through the context menu of the table, you can add or delete columns or rows of the table, and you can also arrange text in cells. * **Custom Block Editor** : The custom block area can be edited through the internal editor. * **Copy and Paste** : Paste anything from browser, screenshot, excel, powerpoint, etc. ### UI * **Toolbar** : Through the toolbar, you can style or add elements to the document you are editing. ![UI](https://user-images.githubusercontent.com/37766175/121808231-767b0f80-cc92-11eb-82a0-433123746982.png) * **Dark Theme** : You can use the dark theme. ![UI](https://user-images.githubusercontent.com/37766175/121808649-8136a400-cc94-11eb-8674-812e170ccab5.png) ### Use of Various Extended Functions - Plugins ![plugin](https://user-images.githubusercontent.com/37766175/121808323-d8d41000-cc92-11eb-9117-b92a435c9b43.png) CommonMark and GFM are great, but we often need more abstraction. The TOAST UI Editor comes with powerful **Plugins** in compliance with the Markdown syntax. **Five basic plugins** are provided as follows, and can be downloaded and used with npm. * [**`chart`**](https://github.com/nhn/tui.editor/tree/master/plugins/chart) : A code block marked as a 'chart' will render [TOAST UI Chart](https://github.com/nhn/tui.chart). * [**`code-syntax-highlight`**](https://github.com/nhn/tui.editor/tree/master/plugins/code-syntax-highlight) : Highlight the code block area corresponding to the language provided by [Prism.js](https://prismjs.com/). * [**`color-syntax`**](https://github.com/nhn/tui.editor/tree/master/plugins/color-syntax) : Using [TOAST UI ColorPicker](https://github.com/nhn/tui.color-picker), you can change the color of the editing text with the GUI. * [**`table-merged-cell`**](https://github.com/nhn/tui.editor/tree/master/plugins/table-merged-cell) : You can merge columns of the table header and body area. * [**`uml`**](https://github.com/nhn/tui.editor/tree/master/plugins/uml) : A code block marked as an 'uml' will render [UML diagrams](http://plantuml.com/screenshot). ## 🎨 Features * [Viewer](https://github.com/nhn/tui.editor/tree/master/docs/en/viewer.md) : Supports a mode to display only markdown data without an editing area. * [Internationalization (i18n)](https://github.com/nhn/tui.editor/tree/master/docs/en/i18n.md) : Supports English, Dutch, Korean, Japanese, Chinese, Spanish, German, Russian, French, Ukrainian, Turkish, Finnish, Czech, Arabic, Polish, Galician, Swedish, Italian, Norwegian, Croatian + language and you can extend. * [Widget](https://github.com/nhn/tui.editor/tree/master/docs/en/widget.md) : This feature allows you to configure the rules that replaces the string matching to a specific `RegExp` with the widget node. * [Custom Block](https://github.com/nhn/tui.editor/tree/master/docs/en/custom-block.md) : Nodes not supported by Markdown can be defined through custom block. You can display the node what you want through writing the parsing logic with custom block. ## 🐾 Examples * [Basic](https://nhn.github.io/tui.editor/latest/tutorial-example01-editor-basic) * [Viewer](https://nhn.github.io/tui.editor/latest/tutorial-example04-viewer) * [Using All Plugins](https://nhn.github.io/tui.editor/latest/tutorial-example12-editor-with-all-plugins) * [Creating the User's Plugin](https://nhn.github.io/tui.editor/latest/tutorial-example13-creating-plugin) * [Customizing the Toobar Buttons](https://nhn.github.io/tui.editor/latest/tutorial-example15-customizing-toolbar-buttons) * [Internationalization (i18n)](https://nhn.github.io/tui.editor/latest/tutorial-example16-i18n) Here are more [examples](https://nhn.github.io/tui.editor/latest/tutorial-example01-editor-basic) and play with TOAST UI Editor! ## 🌏 Browser Support | Chrome Chrome | IE Internet Explorer | Edge Edge | Safari Safari | Firefox Firefox | | :---------: | :---------: | :---------: | :---------: | :---------: | | Yes | 11+ | Yes | Yes | Yes | ## 🔧 Pull Request Steps TOAST UI products are open source, so you can create a pull request(PR) after you fix issues. Run npm scripts and develop yourself with the following process. ### Setup Fork `main` branch into your personal repository. Clone it to local computer. Install node modules. Before starting development, you should check if there are any errors. ```sh $ git clone https://github.com/{your-personal-repo}/tui.editor.git $ npm install $ npm run build toastmark $ npm run test editor ``` > TOAST UI Editor uses [npm workspace](https://docs.npmjs.com/cli/v7/using-npm/workspaces/), so you need to set the environment based on [npm7](https://github.blog/2021-02-02-npm-7-is-now-generally-available/). If subversion is used, dependencies must be installed by moving direct paths per package. ### Develop You can see your code reflected as soon as you save the code by running a server. Don't miss adding test cases and then make green rights. #### Run snowpack-dev-server [snowpack](https://www.snowpack.dev/) allows you to run a development server without bundling. ``` sh $ npm run serve editor ``` #### Run webpack-dev-server If testing of legacy browsers is required, the development server can still be run using a [webpack](https://webpack.js.org/). ``` sh $ npm run serve:ie editor ``` #### Run test ``` sh $ npm test editor ``` ### Pull Request Before uploading your PR, run test one last time to check if there are any errors. If it has no errors, commit and then push it! For more information on PR's steps, please see links in the Contributing section. ## 💬 Contributing * [Code of Conduct](https://github.com/nhn/tui.editor/blob/master/CODE_OF_CONDUCT.md) * [Contributing Guideline](https://github.com/nhn/tui.editor/blob/master/CONTRIBUTING.md) * [Commit Convention](https://github.com/nhn/tui.editor/blob/master/docs/COMMIT_MESSAGE_CONVENTION.md) * [Issue Guidelines](https://github.com/nhn/tui.editor/tree/master/.github/ISSUE_TEMPLATE) ## 🍞 TOAST UI Family - [TOAST UI Calendar](https://github.com/nhn/tui.calendar) - [TOAST UI Chart](https://github.com/nhn/tui.chart) - [TOAST UI Grid](https://github.com/nhn/tui.grid) - [TOAST UI Image Editor](https://github.com/nhn/tui.image-editor) - [TOAST UI Components](https://github.com/nhn) ## 🚀 Used By * [NHN Dooray! - Collaboration Service (Project, Messenger, Mail, Calendar, Drive, Wiki, Contacts)](https://dooray.com) * [UNOTES - Visual Studio Code Extension](https://marketplace.visualstudio.com/items?itemName=ryanmcalister.Unotes) ## 📜 License This software is licensed under the [MIT](https://github.com/nhn/tui.editor/blob/master/LICENSE) © [NHN Cloud](https://github.com/nhn). ================================================ FILE: __mocks__/cssMock.js ================================================ module.exports = { process() { return 'module.exports = {};'; }, }; ================================================ FILE: apps/editor/README.md ================================================ # ![TOAST UI Editor](https://uicdn.toast.com/toastui/img/tui-editor-bi.png) [![npm](https://img.shields.io/npm/v/@toast-ui/editor.svg)](https://www.npmjs.com/package/@toast-ui/editor) ## 🚩 Table of Contents - [Collect Statistics on the Use of Open Source](#Collect-statistics-on-the-use-of-open-source) - [Documents](#-documents) - [Install](#-install) - [Usage](#-usage) - [Tutorials](#-tutorials) ## Collect Statistics on the Use of Open Source TOAST UI products apply Google Analytics (GA) to collect statistics on the use of open source, in order to identify how widely TOAST UI Editor is used throughout the world. It also serves as important index to determine the future course of projects. `location.hostname` (e.g. ui.toast.com) is to be collected and the sole purpose is nothing but to measure statistics on the usage. To disable GA, use the following `usageStatistics` option when creating the instance. ```js const options = { // ... usageStatistics: false }; const editor = new Editor(options); ``` ## 📙 Documents - [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) - [APIs](https://nhn.github.io/tui.editor/latest/) - v3.0 Migration Guide - [English](https://github.com/nhn/tui.editor/blob/master/docs/v3.0-migration-guide.md) - [한국어](https://github.com/nhn/tui.editor/blob/master/docs/v3.0-migration-guide-ko.md) You can also see the older versions of API page on the [releases page](https://github.com/nhn/tui.editor/releases). ## 💾 Install TOAST UI products can be used by using the package manager or downloading the source directly. However, we highly recommend using the package manager. ### Via Package Manager TOAST UI products are registered in two package managers, [npm](https://www.npmjs.com/). You can conveniently install it using the commands provided by the package manager. When using npm, be sure to use it in the environment [Node.js](https://nodejs.org/en/) is installed. #### npm ```sh $ npm install --save @toast-ui/editor # Latest Version $ npm install --save @toast-ui/editor@ # Specific Version ``` ### Via Contents Delivery Network (CDN) TOAST UI products are available over the CDN powered by [NHN Cloud](https://www.toast.com). You can use the CDN as below. ```html ... ... ... ``` If you want to use a specific version, use the tag name instead of `latest` in the url's path. The CDN directory has the following structure: ``` - uicdn.toast.com/ ├─ editor/ │ ├─ latest/ │ │ ├─ toastui-editor-all.js │ │ ├─ toastui-editor-all.min.js │ │ ├─ toastui-editor-viewer.js │ │ ├─ toastui-editor-viewer.min.js │ │ ├─ toastui-editor.css │ │ ├─ toastui-editor.min.css │ │ ├─ toastui-editor-viewer.css │ │ ├─ toastui-editor-viewer.min.css │ │ ├─ toastui-editor-only.css │ │ ├─ toastui-editor-only.min.css │ │ └─ theme/ │ │ ├─ toastui-editor-dark.css │ │ └─ toastui-editor-dark.min.css │ │ └─ i18n/ │ │ └─ ... │ ├─ 2.0.0/ │ │ └─ ... ``` ## 🔨 Usage First, you need to add the container element where TOAST UI Editor (henceforth referred to as 'Editor') will be created. ```html ...
... ``` The editor can be used by creating an instance with the constructor function. To get the constructor function, you should import the module using one of the following ways depending on your environment. ### Using Module Format in Node Environment - ES6 Modules ```javascript import Editor from '@toast-ui/editor'; ``` - CommonJS ```javascript const Editor = require('@toast-ui/editor'); ``` ### Using Namespace in Browser Environment ```javascript const Editor = toastui.Editor; ``` Then, you need to add the CSS files needed for the Editor. Import CSS files in node environment, and add it to html file when using CDN. ### Using in Node Environment ```javascript import '@toast-ui/editor/dist/toastui-editor.css'; // Editor's Style ``` ### Using in Browser Environment by CDN ```html ... ... ... ``` Finally you can create an instance with options and call various API after creating an instance. ```javascript const editor = new Editor({ el: document.querySelector('#editor'), height: '500px', initialEditType: 'markdown', previewStyle: 'vertical' }); editor.getMarkdown(); ``` ### Default Options - `height`: Height in string or auto ex) `300px` | `auto` - `initialEditType`: Initial type to show `markdown` | `wysiwyg` - `initialValue`: Initial value. Set Markdown string - `previewStyle`: Preview style of Markdown mode `tab` | `vertical` - `usageStatistics`: Let us know the _hostname_. We want to learn from you how you are using the Editor. You are free to disable it. `true` | `false` Find out more options [here](https://nhn.github.io/tui.editor/latest/ToastUIEditor). ## 🦄 Tutorials - [Viewer](https://github.com/nhn/tui.editor/blob/master/docs/en/viewer.md) - [Plugins](https://github.com/nhn/tui.editor/blob/master/docs/en/plugin.md) - [Internationalization (i18n)](https://github.com/nhn/tui.editor/blob/master/docs/en/i18n.md) ================================================ FILE: apps/editor/demo/esm/index.html ================================================ Demo
================================================ FILE: apps/editor/examples/css/tuidoc-example-style.css ================================================ body { margin: 0; padding: 0; } .tui-doc-description { padding: 22px 52px; background-color: rgba(81, 92, 230, 0.1); line-height: 1.4em; } .tui-doc-description, .tui-doc-description a { font-family: Arial; font-size: 14px; color: #515ce6; } .tui-doc-contents { padding: 20px 52px; } .tui-doc-contents .btn { display: inline-block; margin-bottom: 10px; padding: 0 14px 0 15px; height: 28px; font-size: 12px; font-weight: bold; color: #fff; border: 0; vertical-align: top; line-height: 22px; background: #777; cursor: pointer; border-radius: 5px; outline: 0; } ================================================ FILE: apps/editor/examples/data/md-default.js ================================================ /* eslint-disable no-unused-vars */ /* eslint-disable no-var */ var content = [ '![image](https://uicdn.toast.com/toastui/img/tui-editor-bi.png)', '', '# Awesome Editor!', '', 'It has been _released as opensource in 2018_ and has ~~continually~~ evolved to **receive 10k GitHub ⭐️ Stars**.', '', '## Create Instance', '', 'You can create an instance with the following code and use `getHtml()` and `getMarkdown()` of the [Editor](https://github.com/nhn/tui.editor).', '', '```js', 'const editor = new Editor(options);', '```', '', '> See the table below for default options', '> > More API information can be found in the document', '', '| name | type | description |', '| --- | --- | --- |', '| el | `HTMLElement` | container element |', '', '## Features', '', '* CommonMark + GFM Specifications', ' * Live Preview', ' * Scroll Sync', ' * Auto Indent', ' * Syntax Highlight', ' 1. Markdown', ' 2. Preview', '', '## Support Wrappers', '', '> * Wrappers', '> 1. [x] React', '> 2. [x] Vue', '> 3. [ ] Ember', ].join('\n'); ================================================ FILE: apps/editor/examples/data/md-plugins.js ================================================ /* eslint-disable no-unused-vars */ /* eslint-disable no-var */ var chartContent = [ '$$chart', ',category1,category2', 'Jan,21,23', 'Feb,31,17', '', 'type: column', 'title: Monthly Revenue', 'x.title: Amount', 'y.title: Month', 'y.min: 1', 'y.max: 40', 'y.suffix: $', '$$', ].join('\n'); var codeContent = [ '```js', "console.log('foo')", '```', '```javascript', "console.log('bar')", '```', '```html', '
baz
', '```', '```wrong', '[1 2 3]', '```', '```clojure', '[1 2 3]', '```', ].join('\n'); var tableContent = ['| @cols=2:merged |', '| --- | --- |', '| table | table2 |'].join('\n'); var umlContent = [ '$$uml', 'partition Conductor {', ' (*) --> "Climbs on Platform"', ' --> === S1 ===', ' --> Bows', '}', '', 'partition Audience #LightSkyBlue {', ' === S1 === --> Applauds', '}', '', 'partition Conductor {', ' Bows --> === S2 ===', ' --> WavesArmes', ' Applauds --> === S2 ===', '}', '', 'partition Orchestra #CCCCEE {', ' WavesArmes --> Introduction', ' --> "Play music"', '}', '$$', ].join('\n'); var allPluginsContent = [chartContent, codeContent, tableContent, umlContent].join('\n'); ================================================ FILE: apps/editor/examples/example01-editor-basic.html ================================================ 1. Editor
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here
================================================ FILE: apps/editor/examples/example02-editor-with-horizontal-preview.html ================================================ 2. Editor With Horizontal Preview
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here
================================================ FILE: apps/editor/examples/example03-editor-with-wysiwyg-mode.html ================================================ 3. Editor With WYSIWYG Mode
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.
================================================ FILE: apps/editor/examples/example04-viewer.html ================================================ 4. Viewer
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
================================================ FILE: apps/editor/examples/example05-viewer-using-editor-factory.html ================================================ 5. Viewer Using Editor's Factory
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
================================================ FILE: apps/editor/examples/example06-dark-theme.html ================================================ 6. Editor with Dark Theme
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.

Editor

Viewer

================================================ FILE: apps/editor/examples/example07-editor-with-chart-plugin.html ================================================ 7. Editor with Chart Plugin
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.

Editor

Viewer

================================================ FILE: apps/editor/examples/example08-editor-with-code-syntax-highlight-plugin.html ================================================ 8. Editor with Code Syntax Highlight Plugin
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.

Editor

Viewer

================================================ FILE: apps/editor/examples/example09-editor-with-color-syntax-plugin.html ================================================ 9. Editor with Color Syntax Plugin
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.
================================================ FILE: apps/editor/examples/example10-editor-with-table-merged-cell-plugin.html ================================================ 10. Editor with Table Merged Cell Plugin
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.

Editor

Viewer

================================================ FILE: apps/editor/examples/example11-editor-with-uml-plugin.html ================================================ 11. Editor with UML Plugin
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.

Editor

Viewer

================================================ FILE: apps/editor/examples/example12-editor-with-all-plugins.html ================================================ 12. Editor with All Plugins
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.

Editor

Viewer

================================================ FILE: apps/editor/examples/example13-creating-plugin.html ================================================ 13. Creating Plugin
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here and here.

Note: LaText doesn't support the ie11. Please check this example in Chrome.

================================================ FILE: apps/editor/examples/example14-using-command.html ================================================ 14. Using Command
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
================================================ FILE: apps/editor/examples/example15-customizing-toolbar-buttons.html ================================================ 15. Customizing Toolbar Buttons
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
================================================ FILE: apps/editor/examples/example16-i18n.html ================================================ 16. Internationalization (i18n)
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
You can see the tutorial here.
================================================ FILE: apps/editor/examples/example17-placeholder.html ================================================ 17. Placeholder
The example code can be slower than your environment because the code is transpiled by babel-standalone in runtime.
================================================ FILE: apps/editor/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; ================================================ FILE: apps/editor/package.json ================================================ { "name": "@toast-ui/editor", "version": "3.2.2", "description": "GFM Markdown Wysiwyg Editor - Productive and Extensible", "keywords": [ "nhn", "nhn cloud", "toast", "toastui", "toast-ui", "markdown", "wysiwyg", "editor", "preview", "gfm" ], "main": "dist/toastui-editor.js", "module": "dist/esm/", "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/toastui-editor.js" }, "./viewer": { "import": "./dist/esm/indexViewer.js", "require": "./dist/toastui-editor-viewer.js" }, "./dist/i18n/*": { "import": "./dist/esm/i18n/*.js", "require": "./dist/i18n/*.js" }, "./dist/toastui-editor-viewer": "./dist/toastui-editor-viewer.js", "./dist/toastui-editor.css": "./dist/toastui-editor.css", "./dist/toastui-editor-viewer.css": "./dist/toastui-editor-viewer.css", "./dist/toastui-editor-only.css": "./dist/toastui-editor-only.css", "./dist/theme/toastui-editor-dark.css": "./dist/theme/toastui-editor-dark.css", "./toastui-editor.css": "./dist/toastui-editor.css", "./toastui-editor-viewer.css": "./dist/toastui-editor-viewer.css", "./toastui-editor-only.css": "./dist/toastui-editor-only.css", "./toastui-editor-dark.css": "./dist/theme/toastui-editor-dark.css" }, "types": "types/index.d.ts", "files": [ "dist/*.js", "dist/*.css", "dist/theme", "dist/esm", "dist/i18n", "types" ], "author": "NHN Cloud FE Development Lab ", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "apps/editor" }, "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "homepage": "https://ui.toast.com", "browserslist": "last 2 versions, not ie <= 10", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "build:i18n": "cross-env webpack --config scripts/webpack.config.i18n.js && webpack --config scripts/webpack.config.i18n.js --env minify", "build:prod": "cross-env webpack build && webpack build --env minify && node tsBannerGenerator.js", "build": "npm run build:esm && npm run build:i18n && npm run build:prod", "build:esm": "rollup -c", "note": "tui-note --tag=$(git describe --tags)", "ts2js": "tsc --outDir tmpdoc --sourceMap false --target ES2015 --noEmit false", "doc:dev": "npm run ts2js && tuidoc --serv", "doc": "npm run ts2js && tuidoc" }, "devDependencies": { "@toast-ui/release-notes": "^2.0.1", "@types/dompurify": "2.3.3", "cross-env": "^6.0.3" }, "dependencies": { "dompurify": "^2.3.3", "prosemirror-commands": "^1.1.9", "prosemirror-history": "^1.1.3", "prosemirror-inputrules": "^1.1.3", "prosemirror-keymap": "^1.1.4", "prosemirror-model": "^1.14.1", "prosemirror-state": "^1.3.4", "prosemirror-view": "^1.18.7" } } ================================================ FILE: apps/editor/rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import fs from 'fs'; import banner from 'rollup-plugin-banner'; import { version, author, license } from './package.json'; function i18nEditorImportPath() { return { name: 'i18nEditorImportPath', transform(code) { return code.replace('../editorCore', '@toast-ui/editor'); }, }; } const fileNames = fs.readdirSync('./src/i18n'); function createBannerPlugin(type) { return banner( [ `@toast-ui/editor${type ? ` : ${type}` : ''}`, `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n') ); } export default [ // editor { input: 'src/esm/index.ts', output: { dir: 'dist/esm', format: 'es', sourcemap: false, }, plugins: [typescript(), commonjs(), nodeResolve(), createBannerPlugin()], external: [/^prosemirror/], }, // viewer { input: 'src/esm/indexViewer.ts', output: { dir: 'dist/esm', format: 'es', sourcemap: false, }, plugins: [typescript(), commonjs(), nodeResolve(), createBannerPlugin('viewer')], external: [/^prosemirror/], }, // i18n { input: fileNames.map((fileName) => `src/i18n/${fileName}`), output: { dir: 'dist/esm/i18n', format: 'es', sourcemap: false, }, external: ['@toast-ui/editor'], plugins: [ typescript(), commonjs(), nodeResolve(), i18nEditorImportPath(), createBannerPlugin('i18n'), ], }, ]; ================================================ FILE: apps/editor/scripts/createConfigVariable.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const fs = require('fs'); const path = require('path'); const config = require(path.resolve(__dirname, '../tuidoc.config.json')); const examples = config.examples || {}; const { filePath, globalErrorLogVariable } = examples; /** * Get Examples Url */ function getTestUrls() { if (!filePath) { throw Error('not exist examples path at tuidoc.config.json'); } const urlPrefix = 'http://nhn.github.io/tui.editor/latest'; const testUrls = fs.readdirSync(filePath).reduce((urls, fileName) => { if (/html$/.test(fileName)) { urls.push(`${urlPrefix}/${filePath}/${fileName}`); } return urls; }, []); fs.writeFileSync('url.txt', testUrls.join(', ')); } function getGlobalVariable() { if (!globalErrorLogVariable) { throw Error('not exist examples path at tuidoc.config.json'); } fs.writeFileSync('errorVariable.txt', String(globalErrorLogVariable)); } getTestUrls(); getGlobalVariable(); ================================================ FILE: apps/editor/scripts/createIndexPage.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const fs = require('fs'); const path = require('path'); const directory = path.resolve(__dirname, '../examples'); const fileName = 'index.html'; const style = ` `; function writeData(dir) { let data = ` ${style}

Examples

`; data += '
    '; const files = fs.readdirSync(dir); files.forEach((item) => { const p = `${directory}/${item}`; if (fs.statSync(p).isFile()) { data += `
  • ${item}
  • `; } }); data += '
'; return data; } const data = writeData(directory); fs.writeFile(`snowpack/examples/${fileName}`, data, 'utf8', (err) => { if (err) { console.error(err); } else { console.log('index.html is created successfully'); } }); ================================================ FILE: apps/editor/scripts/webpack.config.i18n.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const entry = require('webpack-glob-entry'); const pkg = require('../package.json'); const TerserPlugin = require('terser-webpack-plugin'); const FileManagerPlugin = require('filemanager-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); function getOptimizationConfig(minify) { const minimizer = []; if (minify) { minimizer.push( new TerserPlugin({ parallel: true, extractComments: false, }) ); } return { minimizer }; } function getEntries() { const entries = entry('./src/i18n/*.ts'); delete entries['en-us']; delete entries.i18n; return entries; } module.exports = (env) => { const { minify = false } = env; return { mode: 'production', entry: getEntries(), output: { library: { type: 'umd', }, path: path.resolve(__dirname, minify ? '../dist/cdn/i18n' : '../dist/i18n'), filename: `[name]${minify ? '.min' : ''}.js`, }, externals: [ { '../editorCore': { commonjs: '@toast-ui/editor', commonjs2: '@toast-ui/editor', amd: '@toast-ui/editor', root: ['toastui', 'Editor'], }, }, ], module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, ], }, plugins: [ new webpack.BannerPlugin( [ 'TOAST UI Editor : i18n', `@version ${pkg.version}`, `@author ${pkg.author}`, `@license ${pkg.license}`, ].join('\n') ), new FileManagerPlugin({ events: { onEnd: { copy: [{ source: './dist/i18n/*.js', destination: './dist/cdn/i18n' }], }, }, }), new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: true, }), ], optimization: getOptimizationConfig(minify), }; }; ================================================ FILE: apps/editor/snowpack.config.js ================================================ /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', 'src/img': '/img', src: '/dist', }, devOptions: { port: 8080, }, alias: { '@': './src', '@t': './types', }, workspaceRoot: '../../', }; ================================================ FILE: apps/editor/src/__test__/integration/ui/layout.spec.ts ================================================ 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'; function getElement(selector: string) { return document.querySelector(selector)!; } function getElements(selector: string) { return document.querySelectorAll(selector)!; } function getEditorMain() { return getElement(`.${cls('main')}`)!; } function getMdEditor() { return getElement(`.${cls('md-container')} .${EDITOR_CLASS}`)!; } function getMdPreview() { return getElement(`.${cls('md-container')} .${cls('md-preview')}`)!; } function getWwEditor() { return getElement(`.${cls('ww-container')} .${EDITOR_CLASS}`)!; } function getMdSwitch() { return screen.getByText('Markdown')!; } function getWwSwitch() { return screen.getByText('WYSIWYG')!; } function clickMdSwitch() { return getMdSwitch().click(); } function clickWwSwitch() { return getWwSwitch().click(); } function getMdWriteTab() { return getElement(`.${cls('md-tab-container')} .tab-item`)!; } function getMdPreviewTab() { return document.querySelectorAll(`.${cls('md-tab-container')} .tab-item`)[1]; } function getScrollSyncWrapper() { const scrollSync = getElement('.scroll-sync'); return scrollSync ? scrollSync.parentElement : null; } function clickMdWriteTab() { return getMdWriteTab().click(); } function clickMdPreviewTab() { return getMdPreviewTab().click(); } function assertToContainElement(el: HTMLElement) { expect(document.body).toContainElement(el); } describe('layout component', () => { let container: HTMLElement; let editor: Editor; let em: Emitter; beforeEach(() => { container = document.createElement('div'); editor = new Editor({ el: container, previewStyle: 'vertical', height: '400px', initialEditType: 'markdown', }); em = editor.eventEmitter; document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); it('render default ui properly', () => { assertToContainElement(getEditorMain()); assertToContainElement(getMdEditor()); assertToContainElement(getMdPreview()); assertToContainElement(getWwEditor()); assertToContainElement(getMdSwitch()); assertToContainElement(getWwSwitch()); }); it('show/hide editor', () => { const layout = getElement(`.${cls('defaultUI')}`); editor.hide(); expect(layout).toHaveClass('hidden'); editor.show(); expect(layout).not.toHaveClass('hidden'); }); describe('changing editor mode', () => { it('should trigger needChangeMode when clicking the switch button', () => { const spy = jest.fn(); em.listen('needChangeMode', spy); clickWwSwitch(); expect(spy).toHaveBeenCalledWith('wysiwyg'); clickMdSwitch(); expect(spy).toHaveBeenCalledWith('markdown'); }); it('should switch the editor in layout when changeMode is triggered', () => { const editorArea = getEditorMain(); const mdSwitch = getMdSwitch(); const wwSwitch = getWwSwitch(); em.emit('changeMode', 'wysiwyg'); expect(editorArea).toHaveClass(cls('ww-mode')); expect(wwSwitch).toHaveClass('active'); expect(mdSwitch).not.toHaveClass('active'); em.emit('changeMode', 'markdown'); expect(editorArea).toHaveClass(cls('md-mode')); expect(mdSwitch).toHaveClass('active'); 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(); em.emit('changeMode', 'wysiwyg'); 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', () => { 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'); expect(scrollSyncWrapper).toHaveStyle({ display: 'inline-block' }); }); }); describe('changing preview style', () => { it('should hide markdown tab when changePreviewStyle is triggered', () => { const tabSection = getElement(`.${cls('md-tab-container')}`)!; 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' }); }); it('should display the markdown editor or preview by clicking markdown tab', () => { expect(getMdWriteTab()).toHaveClass('active'); expect(getMdPreviewTab()).not.toHaveClass('active'); clickMdPreviewTab(); expect(getMdWriteTab()).not.toHaveClass('active'); expect(getMdPreviewTab()).toHaveClass('active'); }); it('should emit changePreviewTabWrite, changePreviewTabPreview events by clicking markdown tab', () => { const spy1 = jest.fn(); const spy2 = jest.fn(); em.listen('changePreviewTabWrite', spy1); 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(); expect(scrollSyncWrapper).toBeNull(); expect(getElement(`.${cls('toolbar-icons')}`)).toBeDisabled(); clickMdWriteTab(); expect(scrollSyncWrapper).toBeNull(); expect(getElement(`.${cls('toolbar-icons')}`)).not.toBeDisabled(); }); 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(); }); }); describe('context menu', () => { it('should be displayed when contextmenu event is triggered', () => { 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' }); }); }); }); ================================================ FILE: apps/editor/src/__test__/integration/ui/toolbar.spec.ts ================================================ 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(selector)!; } function getPopUpElement() { return getElement(`.${cls('popup')}`); } function fireMousemoveEvent(el: HTMLElement, x: number, y: number) { const event = new MouseEvent('mousemove', { bubbles: true, cancelable: true, }); // @ts-ignore event.pageX = x; // @ts-ignore event.pageY = y; fireEvent(el, event); } function fireMouseoverEvent(el: HTMLElement) { const mouseover = new MouseEvent('mouseover', { bubbles: true, cancelable: true, }); fireEvent(el, mouseover); } describe('Default toolbar', () => { let el: HTMLDivElement, editor: Editor; beforeEach(() => { el = document.createElement('div'); editor = new Editor({ el, previewStyle: 'vertical', height: '400px', initialEditType: 'markdown', }); document.body.appendChild(el); }); afterEach(() => { editor.destroy(); document.body.removeChild(el); }); it('should be rendered properly', () => { [ '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(); }); expect(document.body).toContainElement(getElement('.scroll-sync')); }); it('should trigger command event when clicking toolbar button', () => { const spy = jest.fn(); editor.eventEmitter.listen('command', spy); screen.getByLabelText('Bold').click(); // eslint-disable-next-line no-undefined expect(spy).toHaveBeenCalledWith('bold', undefined); }); it('should show tooltip when mouseover on toolbar button', () => { fireMouseoverEvent(screen.getByLabelText('Headings')); const tooltip = screen.getByText('Headings').parentElement; expect(tooltip).toHaveStyle({ display: 'block' }); expect(tooltip).toHaveClass(cls('tooltip')); }); describe('scroll sync button', () => { it('should toggle active state when clicking scroll sync button', () => { const scrollSyncSwitch = getElement('.scroll-sync'); expect(scrollSyncSwitch).toHaveClass('active'); scrollSyncSwitch.click(); expect(scrollSyncSwitch).not.toHaveClass('active'); }); it('should trigger command event with state', () => { const spy = jest.fn(); editor.eventEmitter.listen('command', spy); getElement('.scroll-sync').click(); expect(spy).toHaveBeenCalledWith('toggleScrollSync', { active: false }); getElement('.scroll-sync').click(); expect(spy).toHaveBeenCalledWith('toggleScrollSync', { active: true }); }); }); describe('Headings button', () => { let headingPopup: HTMLElement; let hedingButton: HTMLElement; 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' }); }); ['1', '2', '3', '4', '5', '6'].forEach((level) => { const mdHeadingOfLevel = '#'.repeat(parseInt(level, 10)); it(`should active heading button when click heading level ${level}`, () => { getByText(headingPopup, `Heading ${level}`).click(); expect(hedingButton).toHaveClass('active'); }); it(`should add heading to document when click heading level ${level}`, () => { getByText(headingPopup, `Heading ${level}`).click(); expect(editor.getMarkdown()).toBe(`${mdHeadingOfLevel} `); }); }); }); describe('link button', () => { let linkPopup: HTMLElement; let linkButton: HTMLElement; beforeEach(() => { linkPopup = getPopUpElement(); linkButton = screen.getByLabelText('Insert 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', () => { const closeBtn = getByText(linkPopup, 'Cancel'); closeBtn.click(); expect(linkPopup).toHaveStyle({ display: 'none' }); }); 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'; OkBtn.click(); expect(editor.getMarkdown()).toBe('[toastui](https://ui.toast.com)'); }); it('should add wrong class when url or text are not filled out', () => { const urlText = getByText(linkPopup, 'URL').nextElementSibling as HTMLInputElement; const linkText = getByText(linkPopup, 'Link text').nextElementSibling as HTMLInputElement; const OkBtn = getByText(linkPopup, 'OK'); OkBtn.click(); expect(urlText).toHaveClass('wrong'); urlText.value = 'https://ui.toast.com'; OkBtn.click(); expect(linkText).toHaveClass('wrong'); }); }); describe('image button', () => { let imagePopup: HTMLElement; let imageButton: HTMLElement; 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', () => { const closeBtn = getByText(imagePopup, 'Cancel'); closeBtn.click(); expect(imagePopup).toHaveStyle({ display: 'none' }); }); it('should toggle tab when clicking the file or url tab', () => { const fileTabBtn = getByLabelText(imagePopup, 'File'); const urlTabBtn = getByLabelText(imagePopup, 'URL'); urlTabBtn.click(); expect(fileTabBtn).not.toHaveClass('active'); expect(urlTabBtn).toHaveClass('active'); fileTabBtn.click(); expect(fileTabBtn).toHaveClass('active'); expect(urlTabBtn).not.toHaveClass('active'); }); it('should add image to document when clicking OK button', () => { getByLabelText(imagePopup, 'URL').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'; OkBtn.click(); expect(editor.getMarkdown()).toBe('![image](myImageUrl)'); }); it('should add wrong class when url or text are not filled out', () => { const fileText = getByText(imagePopup, 'Select image file') .nextElementSibling as HTMLInputElement; const urlText = getByText(imagePopup, 'Image URL').nextElementSibling as HTMLInputElement; const OkBtn = getByText(imagePopup, 'OK'); OkBtn.click(); expect(fileText).toHaveClass('wrong'); getByLabelText(imagePopup, 'URL').click(); OkBtn.click(); expect(urlText).toHaveClass('wrong'); }); }); describe('table button', () => { let tablePopup: HTMLElement; let tableButton: HTMLElement; 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 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| | | | | | |' ); }); }); 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'); bulletListBtn.click(); expect(indentBtn).not.toBeDisabled(); expect(outdentBtn).not.toBeDisabled(); 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 toobar button', () => { let el: HTMLDivElement, editor: Editor; function createCustomButtonWithPopup() { const body = document.createElement('select'); body.innerHTML = ` `; body.addEventListener('change', (ev) => { editor.eventEmitter.emit('command', 'heading', { level: Number((ev.target as HTMLSelectElement).value), }); editor.eventEmitter.emit('closePopup'); (ev.target as HTMLSelectElement).value = '1'; }); return { name: 'myToolbarWithPopup', tooltip: 'L!', className: 'my-toolbar-with-popup', text: 'L!', style: { color: '#fff', width: 30 }, popup: { body, className: 'my-popup', style: { width: 'auto' }, }, }; } const customButton = { name: 'myToolbar', tooltip: 'B!', className: 'my-toolbar', command: 'bold', text: 'B!', style: { color: '#222', width: 40 }, }; const customButtonWithPopup = createCustomButtonWithPopup(); beforeEach(() => { el = document.createElement('div'); editor = new Editor({ el, previewStyle: 'vertical', height: '400px', initialEditType: 'markdown', toolbarItems: [[customButton, customButtonWithPopup]], }); document.body.appendChild(el); }); afterEach(() => { editor.destroy(); document.body.removeChild(el); }); it('should be rendered properly', () => { const customToolbar1 = screen.getByLabelText('B!'); const customToolbar2 = screen.getByLabelText('L!'); expect(customToolbar1).toHaveTextContent('B!'); expect(customToolbar1).toHaveStyle({ color: '#222', width: '40px' }); expect(customToolbar2).toHaveTextContent('L!'); expect(customToolbar2).toHaveStyle({ color: '#fff', width: '30px' }); }); it('should show tooltip when mouseover on toolbar button', () => { fireMouseoverEvent(screen.getByLabelText('B!')); const tooltip = getElement(`.${cls('tooltip')}`); expect(tooltip).toHaveStyle({ display: 'block' }); expect(tooltip).toHaveTextContent('B!'); }); it('should add text that matched command to document event when clicking button', () => { screen.getByLabelText('B!').click(); expect(editor.getMarkdown()).toBe('****'); }); it('should show the popup when clicking button with popup option', () => { screen.getByLabelText('L!').click(); const customPopup = getElement('.my-popup'); expect(customPopup).toHaveStyle({ display: 'block', width: 'auto' }); expect(customPopup).toHaveClass('my-popup'); }); it('should operate properly when event is triggered in popup', () => { screen.getByLabelText('L!').click(); const customPopup = getElement('.my-popup'); const select = customPopup.querySelector('select')!; select.value = '3'; fireEvent(select, new Event('change')); expect(editor.getMarkdown()).toBe('### '); }); }); describe('API', () => { function getToolbarItems() { return getElement(`.${cls('defaultUI-toolbar')}`).querySelectorAll('button:not(.more)'); } let el: HTMLDivElement, editor: Editor; beforeEach(() => { const toolbarItems = [['heading', 'bold', 'italic', 'strike']]; el = document.createElement('div'); editor = new Editor({ el, previewStyle: 'vertical', height: '400px', initialEditType: 'markdown', toolbarItems, }); document.body.appendChild(el); }); afterEach(() => { editor.destroy(); document.body.removeChild(el); }); it('should insert item on calling insertToolbarItem', () => { editor.insertToolbarItem({ groupIndex: 0, itemIndex: 1 }, 'ol'); const toolbarItems = getToolbarItems(); expect(toolbarItems[0]).toHaveClass('heading'); expect(toolbarItems[1]).toHaveClass('ordered-list'); expect(toolbarItems[2]).toHaveClass('bold'); expect(toolbarItems[3]).toHaveClass('italic'); expect(toolbarItems[4]).toHaveClass('strike'); // should have same parent because the toolbar is added to same group expect(toolbarItems[1].parentElement).toEqual(toolbarItems[2].parentElement); }); it('should add item on calling insertToolbarItem', () => { editor.insertToolbarItem({ groupIndex: 1, itemIndex: 1 }, 'ol'); const toolbarItems = getToolbarItems(); expect(toolbarItems[0]).toHaveClass('heading'); expect(toolbarItems[1]).toHaveClass('bold'); expect(toolbarItems[2]).toHaveClass('italic'); expect(toolbarItems[3]).toHaveClass('strike'); expect(toolbarItems[4]).toHaveClass('ordered-list'); // should have different parent because the toolbar is added to another group expect(toolbarItems[3].parentElement).not.toEqual(toolbarItems[4].parentElement); }); it('should insert custom toolbar item on calling insertToolbarItem', () => { const customButton = { name: 'myToolbar', tooltip: 'B!', className: 'my-toolbar', command: 'bold', text: 'B!', style: { color: '#222', width: 40 }, }; editor.insertToolbarItem({ groupIndex: 0, itemIndex: 1 }, customButton); const toolbarItems = getToolbarItems(); expect(toolbarItems[0]).toHaveClass('heading'); expect(toolbarItems[1]).toHaveClass('my-toolbar'); expect(toolbarItems[1]).toHaveTextContent('B!'); expect(toolbarItems[1]).toHaveStyle({ color: '#222', width: '40px' }); expect(toolbarItems[2]).toHaveClass('bold'); expect(toolbarItems[3]).toHaveClass('italic'); expect(toolbarItems[4]).toHaveClass('strike'); }); it('should remove item on calling removeToolbarItem', () => { editor.removeToolbarItem('bold'); expect(screen.queryByLabelText('Bold')).toBeNull(); }); }); describe('Event', () => { let el: HTMLDivElement, editor: Editor; beforeEach(() => { el = document.createElement('div'); editor = new Editor({ el, previewStyle: 'vertical', height: '400px', initialEditType: 'markdown', }); document.body.appendChild(el); }); afterEach(() => { editor.destroy(); document.body.removeChild(el); }); describe('openPopup, closePopup', () => { it('should open and close popup corresponding to name', () => { editor.eventEmitter.emit('openPopup', 'image'); const imagePopup = getElement(`.${cls('popup-add-image')}`); expect(imagePopup).toHaveStyle({ display: 'block' }); editor.eventEmitter.emit('closePopup'); expect(imagePopup).toHaveStyle({ display: 'none' }); }); it('should render popup with initial values', () => { const initialValues = { linkUrl: 'http://test.com', linkText: 'foo' }; editor.eventEmitter.emit('openPopup', 'link', initialValues); 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'); }); }); }); ================================================ FILE: apps/editor/src/__test__/integration/vdom/render.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import { render } from '@/ui/vdom/renderer'; import { Component } from '@/ui/vdom/component'; import { VNode } from '@/ui/vdom/vnode'; import html from '@/ui/vdom/template'; interface Props { mounted?: jest.Mock; updated?: jest.Mock; beforeDestroy?: jest.Mock; refDOM?: jest.Mock; } interface State { hide: boolean; conditional: boolean; } class TestComponent extends Component { constructor(props: Props) { super(props); this.state = { hide: false, conditional: true, }; } show() { this.setState({ hide: false }); } hide() { this.setState({ hide: true }); } conditionalRender() { this.setState({ conditional: false }); } mounted() { if (this.props.mounted) { this.props.mounted(); } } updated() { if (this.props.updated) { this.props.updated(); } } beforeDestroy() { if (this.props.beforeDestroy) { this.props.beforeDestroy(); } } render() { const style = { display: this.state.hide ? 'none' : 'block', }; return html`
{ if (this.props.refDOM) { this.props.refDOM(el); } }} >
child
${this.state.conditional ? [1, 2, 3].map((num) => html`${num}`) : null}
${this.state.conditional ? [1, 2, 3].map((num) => html`${num}`) : null}
`; } } let container: HTMLElement, destroy: () => void; describe('html', () => { it('should be rendered properly', () => { const wrapper = document.createElement('div'); render(wrapper, html`
test
` as VNode); expect(wrapper).toContainHTML('
test
'); }); it('list children should be rendered properly', () => { const wrapper = document.createElement('div'); const expected = oneLineTrim`
1 2 3
`; render( wrapper, html`
${[1, 2, 3].map((text) => html`${text}`)}
` as VNode ); expect(wrapper).toContainHTML(expected); }); it('nested vnode should be rendered properly', () => { const wrapper = document.createElement('div'); const expected = oneLineTrim`
`; render( wrapper, html`
` as VNode ); expect(wrapper).toContainHTML(expected); }); it('should be rendered with style object properly', () => { const wrapper = document.createElement('div'); const style = { display: 'inline-block', backgroundColor: '#ccc' }; const expected = oneLineTrim`
test
`; render(wrapper, html`
test
` as VNode); expect(wrapper).toContainHTML(expected); }); it('should be rendered with pixel added automatically', () => { const wrapper = document.createElement('div'); const style = { position: 'absolute', top: 10, left: 10 }; const expected = oneLineTrim`
test
`; render(wrapper, html`
test
` as VNode); expect(wrapper).toContainHTML(expected); }); }); describe('Class Component', () => { function clickShowBtn() { container.querySelector('button')!.click(); } function clickHideBtn() { container.querySelectorAll('button')[1].click(); } function clickConditionalBtn() { container.querySelectorAll('button')[2].click(); } function renderComponent(spies?: Record) { container = document.createElement('div'); destroy = render(container, html`<${TestComponent} ...${spies} />` as VNode); } it('should be rendered properly', () => { renderComponent(); const expected = oneLineTrim`
child
1 2 3
1 2 3
`; expect(container).toContainHTML(expected); }); it('should be updated by event', () => { renderComponent(); clickHideBtn(); let expected = oneLineTrim`
child
1 2 3
1 2 3
`; expect(container).toContainHTML(expected); clickShowBtn(); expected = oneLineTrim`
child
1 2 3
1 2 3
`; expect(container).toContainHTML(expected); }); it('should call ref function with DOM after rendering the component', () => { const spy = jest.fn(); renderComponent({ refDOM: spy }); expect(spy).toHaveBeenCalledWith(container.querySelector('.my-comp')); }); it('should call ref function with component after rendering the component', () => { const spy = jest.fn(); renderComponent({ ref: spy }); expect(spy).toHaveBeenCalledTimes(1); }); it('should call mounted life cycle method ', () => { const spy = jest.fn(); renderComponent({ mounted: spy }); expect(spy).toHaveBeenCalledTimes(1); }); it('should call mounted life cycle method once', () => { const spy = jest.fn(); renderComponent({ mounted: spy }); clickHideBtn(); expect(spy).toHaveBeenCalledTimes(1); }); it('should call updated life cycle method after component is updated', () => { const spy = jest.fn(); renderComponent({ updated: spy }); clickHideBtn(); expect(spy).toHaveBeenCalledTimes(1); }); it('should call beforeDestroy life cycle method after component is destroyed', () => { const spy = jest.fn(); renderComponent({ beforeDestroy: spy }); destroy(); expect(spy).toHaveBeenCalledTimes(1); expect(container).toContainHTML(''); }); it('should render conditional children components', () => { renderComponent(); let expected = oneLineTrim`
child
1 2 3
1 2 3
`; expect(container).toContainHTML(expected); clickConditionalBtn(); expected = oneLineTrim`
child
`; expect(container).toContainHTML(expected); }); }); ================================================ FILE: apps/editor/src/__test__/integration/widget/widgetNode.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@/editorCore'; import { cls } from '@/utils/dom'; import { removeDataAttr } from '@/__test__/unit/markdown/util'; describe('widgetNode', () => { let container: HTMLElement, mdEditor: HTMLElement, mdPreview: HTMLElement, wwEditor: HTMLElement, editor: Editor; function getPreviewHTML() { return removeDataAttr(mdPreview.querySelector(`.${cls('contents')}`)!.innerHTML); } beforeEach(() => { container = document.createElement('div'); editor = new Editor({ el: container, widgetRules: [ { rule: /@\S+/, toDOM(text) { const span = document.createElement('span'); span.innerHTML = `${text}`; return span; }, }, { rule: /\[(#\S+)\]\((\S+)\)/, toDOM: (text) => { const rule = /\[(#\S+)\]\((\S+)\)/; const matched = text.match(rule)!; const span = document.createElement('span'); span.innerHTML = `${matched[1]}`; return span; }, }, ], previewStyle: 'vertical', }); const elements = editor.getEditorElements(); mdEditor = elements.mdEditor; mdPreview = elements.mdPreview!; wwEditor = elements.wwEditor!; container.append(mdEditor); container.append(mdPreview!); container.append(wwEditor!); document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('in markdown', () => { it('should render widget node in the editor and preview using replaceWithWidget API', () => { editor.setMarkdown('abc'); editor.replaceWithWidget([1, 1], [1, 3], '@test'); const expectedEditor = oneLineTrim`
@test c
`; const expectedPreview = oneLineTrim`

@test c

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should render widget node in the editor and preview using setMarkdown API', () => { editor.setMarkdown('@test1 @test2'); const expectedEditor = oneLineTrim` @test1 @test2 `; const expectedPreview = oneLineTrim`

@test1 @test2

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should render widget node in the editor and preview using insertText API', () => { editor.insertText('@test1 @test2'); const expectedEditor = oneLineTrim` @test1 @test2 `; const expectedPreview = oneLineTrim`

@test1 @test2

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should render widget node with markdown text', () => { editor.replaceWithWidget([1, 1], [1, 1], '[#toast](ui.toast.com)'); const expectedEditor = oneLineTrim` #toast `; const expectedPreview = oneLineTrim`

#toast

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should render widget node using all widget rules', () => { editor.insertText('@test1 [#toast](ui.toast.com) @test2'); const expectedEditor = oneLineTrim` @test1 #toast @test2 `; const expectedPreview = oneLineTrim`

@test1 #toast @test2

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should convert to wysiwyg properly', () => { editor.setMarkdown('@test1 @test2'); editor.changeMode('wysiwyg'); const expectedEditor = oneLineTrim` @test1 @test2 `; expect(wwEditor).toContainHTML(expectedEditor); }); it('should keep "$" character in case of plain text other than widget node', () => { editor.setMarkdown('@test1 $$myText @test2'); const expectedEditor = oneLineTrim` @test1 $$myText @test2 `; const expectedPreview = oneLineTrim`

@test1 $$myText @test2

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should render widget node in the editor and preview using replaceSelection API', () => { editor.setMarkdown('widgetNode: '); editor.replaceSelection('@test1 @test2', [1, 1], [1, 13]); const expectedEditor = oneLineTrim` @test1 @test2 `; const expectedPreview = oneLineTrim`

@test1 @test2

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should return the markdown text without widget syntax through calling getMarkdown() API', () => { const markdownText = oneLineTrim` Brand site: [#toast](https://ui.toast.com), editor: [#toastui-editor](https://github.com/nhn/tui.editor)\n The Toastui-editor... `; editor.setMarkdown(markdownText); expect(editor.getMarkdown()).toBe(markdownText); }); }); describe('in wysiwyg', () => { it('should render widget node in the editor using replaceWithWidget API', () => { editor.changeMode('wysiwyg'); editor.replaceWithWidget(1, 1, '@test'); const expectedEditor = oneLineTrim` @test `; expect(wwEditor).toContainHTML(expectedEditor); }); it('should render widget node with markdown text', () => { editor.changeMode('wysiwyg'); editor.replaceWithWidget(1, 1, '[#toast](ui.toast.com)'); const expectedEditor = oneLineTrim` #toast `; expect(wwEditor).toContainHTML(expectedEditor); }); it('should convert to markdown properly', () => { editor.changeMode('wysiwyg'); editor.replaceWithWidget(1, 1, '@test1 @test2'); editor.changeMode('markdown'); const expectedEditor = oneLineTrim` @test1 @test2 `; const expectedPreview = oneLineTrim`

@test1 @test2

`; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('should render widget node in the editor using replaceSelection API', () => { editor.setMarkdown('widgetNode:'); editor.changeMode('wysiwyg'); editor.replaceSelection('@test1 @test2', 1, 12); const expectedEditor = oneLineTrim` @test1 @test2 `; expect(wwEditor).toContainHTML(expectedEditor); }); it('should render widget node in the editor using insertText API', () => { editor.changeMode('wysiwyg'); editor.insertText('@test1 @test2'); const expectedEditor = oneLineTrim` @test1 @test2 `; expect(wwEditor).toContainHTML(expectedEditor); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/convertor.spec.ts ================================================ import { source, oneLineTrim } from 'common-tags'; import { Context, MdNode, Parser, HTMLConvertorMap } from '@toast-ui/toastmark'; import { Node, Schema } from 'prosemirror-model'; import { createSpecs } from '@/wysiwyg/specCreator'; import Convertor from '@/convertors/convertor'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import EventEmitter from '@/event/eventEmitter'; import { ToMdConvertorMap, ToMdConvertorContext, NodeInfo, MarkInfo } from '@t/convertor'; import { createHTMLSchemaMap } from '@/wysiwyg/nodes/html'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import { createHTMLrenderer } from './markdown/util'; function createSchema() { const specs = createSpecs({}); return new Schema({ nodes: specs.nodes, marks: specs.marks, }); } describe('Convertor', () => { let convertor: Convertor; let schema: Schema; const parser = new Parser({ disallowedHtmlBlockTags: ['br', 'img'], }); function assertConverting(markdown: string, expected: string) { const mdNode = parser.parse(markdown); const wwNode = convertor.toWysiwygModel(mdNode); const result = convertor.toMarkdownText(wwNode!); expect(result).toBe(expected); } beforeEach(() => { schema = createSchema(); convertor = new Convertor(schema, {}, {}, new EventEmitter()); }); describe('should convert between markdown and wysiwyg node to', () => { it('empty content', () => { assertConverting('', ''); }); it('paragraph', () => { const markdown = 'foo'; assertConverting(markdown, markdown); }); it('headings', () => { const markdown = source` # heading1 ## heading2 ### heading3 #### heading4 ##### heading5 ###### heading6 `; const expected = source` # heading1 ## heading2 ### heading3 #### heading4 ##### heading5 ###### heading6 `; assertConverting(markdown, expected); }); it('codeBlock', () => { const markdown = source` \`\`\` foo \`\`\` `; assertConverting(markdown, markdown); }); it('bullet list', () => { const markdown = source` * foo * bar * qux * baz `; assertConverting(markdown, markdown); }); it('ordered list', () => { const markdown = source` 1. foo 2. bar 3. baz `; assertConverting(markdown, markdown); }); it('blockQuote', () => { const markdown = source` > foo > bar >> baz > > qux > >> quxx `; const expected = source` > foo > bar > > baz > > qux > > > quxx `; assertConverting(markdown, expected); }); it('thematicBreak', () => { const markdown = source` --- *** - - - * * * * `; const expected = source` *** *** *** *** `; assertConverting(markdown, expected); }); it('image', () => { const markdown = source` ![](imgUrl) ![altText](imgUrl) ![altText](img*Url) ![altText](url?key=abc&attribute=abc) `; const expected = source` ![](imgUrl) ![altText](imgUrl) ![altText](img*Url) ![altText](url?key=abc&attribute=abc) `; assertConverting(markdown, expected); }); it('link', () => { const markdown = source` [](url)foo [text](url) [text](ur*l) [Editor](https://github.com/nhn_test/tui.editor) [this.is_a_test_link.com](this.is_a_test_link.com) [text](url?key=abc&attribute=abc) `; const expected = source` foo [text](url) [text](ur*l) [Editor](https://github.com/nhn_test/tui.editor) [this.is_a_test_link.com](this.is_a_test_link.com) [text](url?key=abc&attribute=abc) `; assertConverting(markdown, expected); }); it('code', () => { const markdown = '`foo bar baz`'; assertConverting(markdown, markdown); }); it('emphasis (strong, italic) syntax', () => { const markdown = source` **foo** __bar__ *baz* _qux_ `; const expected = source` **foo** **bar** *baz* *qux* `; assertConverting(markdown, expected); }); it('strike', () => { const markdown = '~~strike~~'; assertConverting(markdown, markdown); }); it('table', () => { const markdown = source` | thead | thead | | --- | --- | | tbody | tbody | | thead |thead | | -- | ----- | | tbody|tbody| | tbody|tbody| ||| |-|-| ||| `; const expected = source` | thead | thead | | ----- | ----- | | tbody | tbody | | thead | thead | | ----- | ----- | | tbody | tbody | | tbody | tbody | | | | | --- | --- | | | | `; assertConverting(markdown, `${expected}\n`); }); it('table with column align syntax', () => { const markdown = source` | default | left | right | center | | --- | :--- | ---: | :---: | | tbody | tbody | tbody | tbody | | | | | | | --- | :--- | ---: | :---: | | default | left | right | center | `; const expected = source` | default | left | right | center | | ------- | :--- | ----: | :----: | | tbody | tbody | tbody | tbody | | | | | | | --- | :--- | ---: | :---: | | default | left | right | center | `; assertConverting(markdown, `${expected}\n`); }); it('table with inline syntax', () => { const markdown = source` | ![altText](imgUrl) | foo ![altText](imgUrl) baz | | ---- | ---- | | [linkText](linkUrl) | foo [linkText](linkUrl) baz | | **foo** _bar_ ~~baz~~ | **foo** *bar* ~~baz~~ [linkText](linkUrl) | `; const expected = source` | ![altText](imgUrl) | foo ![altText](imgUrl) baz | | --- | -------- | | [linkText](linkUrl) | foo [linkText](linkUrl) baz | | **foo** *bar* ~~baz~~ | **foo** *bar* ~~baz~~ [linkText](linkUrl) | `; assertConverting(markdown, `${expected}\n`); }); // @TODO: should normalize table cell // it('should normalize wrong table syntax when converting', () => { // const markdown = source` // | col1 | col2 | col3 | // | --- | --- | // | cell1 | cell2 | cell3 | // `; // const expected = source` // | col1 | col2 | col3 | // | ---- | ---- | ---- | // | cell1 | cell2 | | // `; // assertConverting(markdown, `${expected}\n`); // }); it('task', () => { const markdown = source` * [ ] foo * [x] baz * [x] bar 1. [x] foo 2. [ ] bar `; assertConverting(markdown, markdown); }); it('list in blockQuote', () => { const markdown = source` > * foo > * baz > * bar >> 1. qux > > 2. quxx `; const expected = source` > * foo > * baz > * bar > > 1. qux > > 2. quxx `; assertConverting(markdown, expected); }); it('block nodes in list', () => { const markdown = source` 1. foo \`\`\` bar \`\`\` > bam `; const expected = source` 1. foo \`\`\` bar \`\`\` > bam `; assertConverting(markdown, expected); }); it('soft break', () => { const markdown = source` foo bar baz qux `; const expected = source` foo bar baz qux `; assertConverting(markdown, expected); }); it('
', () => { const markdown = source` foo
bar

baz


qux `; const expected = source` foo bar
baz

qux `; assertConverting(markdown, expected); }); it('
with soft break', () => { const markdown = source` foo
bar

baz
qux
quux
quuz `; const expected = source` foo
bar

baz
qux
quux

quuz `; assertConverting(markdown, expected); }); it('
with html inline node', () => { const markdown = source` foo bar Para Word
`; const expected = source` foo bar Para Word `; assertConverting(markdown, expected); }); it('
with following
', () => { const markdown = source` text1
text2

text3 `; const expected = source` text1 text2 text3 `; assertConverting(markdown, expected); }); it('
in the middle of the paragraph', () => { const markdown = source` text1
te
xt2

text3 `; const expected = source` text1 te xt2 text3 `; assertConverting(markdown, expected); }); it('should convert html comment', () => { const markdown = source` `; assertConverting(markdown, markdown); }); }); describe('convert inline html', () => { it('emphasis type', () => { const markdown = source` foo foo foo foo foo foo foo `; assertConverting(markdown, markdown); }); it('link type', () => { const markdown = source` foo test `; assertConverting(markdown, markdown); }); it('table with
', () => { const markdown = source` | thead
thead | thead | | ----- | ----- | | tbody
tbody | tbody | | tbody | tbody
tbody
tbody | | tbody | **tbody**
_tbody_
~~tbody~~
\`tbody\` | | tbody | ![img](imgUrl)
[link](linkUrl) | `; const expected = source` | thead
thead | thead | | ---------- | ----- | | tbody
tbody | tbody | | tbody | tbody
tbody
tbody | | tbody | **tbody**
*tbody*
~~tbody~~
\`tbody\` | | tbody | ![img](imgUrl)
[link](linkUrl) | `; assertConverting(markdown, `${expected}\n`); }); it('table with list', () => { const markdown = source` | thead | | ----- | |
  • bullet
| |
  1. ordered
| |
  • nested
    • nested
| |
  • nested
    • nested
    • nested
| |
  1. mix**ed**
    • **mix**ed
| |
  1. mixed
    • mixed
| | foo
  • bar
baz | | ![altText](imgUrl) **mixed**
  • [linkText](linkUrl) mixed
| `; assertConverting(markdown, `${markdown}\n`); }); it('table with unmatched html list', () => { const markdown = source` | thead | | ----- | |
  • bullet
    • | |
      1. ordered
        1. | |
          • nested
            • nested
                • | `; const expected = source` | thead | | ----- | |
                  • bullet
                  | |
                  1. ordered
                  | |
                  • nested
                    • nested
                  | `; assertConverting(markdown, `${expected}\n`); }); }); describe('convert block html', () => { it('paragraph and division are not converted to html block', () => { const markdown = source`

                  paragraph

                  division
                  `; const expected = source` paragraph division `; assertConverting(markdown, expected); }); it('heading', () => { const markdown = source`

                  heading1

                  heading2

                  heading3

                  heading4

                  heading4
                  heading4
                  `; const expected = oneLineTrim`

                  heading1

                  heading2

                  heading3

                  heading4

                  heading4
                  heading4
                  `; assertConverting(markdown, expected); }); it('pre', () => { const markdown = source`
                  code
                  `; assertConverting(markdown, markdown); }); it('blockquote', () => { const markdown = source`
                  foo
                  foo
                  foo
                  `; const expected = oneLineTrim`
                  foo
                  foo
                  foo
                  `; assertConverting(markdown, expected); }); it('bullet list', () => { const markdown = source`
                  • foo
                  • foo
                    • foo
                  `; const expected = oneLineTrim`
                  • foo
                  • foo
                    • foo
                  `; assertConverting(markdown, expected); }); it('ordered list', () => { const markdown = source`
                  1. foo
                  1. foo
                    1. foo
                  `; const expected = oneLineTrim`
                  1. foo
                  1. foo
                    1. foo
                  `; assertConverting(markdown, expected); }); it('task', () => { const markdown = source`
                  • bullet task
                  • ordered task
                  `; const expected = oneLineTrim`
                  • bullet task
                  • ordered task
                  `; assertConverting(markdown, expected); }); it('table', () => { const markdown = source`
                  foo
                  bar
                  `; assertConverting(markdown, markdown); }); it('with html inline', () => { const markdown = source`

                  foo

                  • foo bar
                  foo bar
                  `; const expected = oneLineTrim`

                  foo

                  • foo bar
                  foo bar
                  `; assertConverting(markdown, expected); }); }); describe('convert custom inline', () => { it('with info only', () => { const markdown = source`$$custom$$`; const expected = oneLineTrim`$$custom$$`; assertConverting(markdown, expected); }); it('with info and text', () => { const markdown = source`$$custom inline$$`; const expected = oneLineTrim`$$custom inline$$`; assertConverting(markdown, expected); }); }); describe('sanitize when using html', () => { it('href attribute with link', () => { const markdown = source` xss xss xss xss xss xss 123xss `; const expected = source` xss xss xss xss xss xss 123xss `; assertConverting(markdown, expected); }); it('src attribute with image', () => { const markdown = source` `; const expected = source` `; assertConverting(markdown, expected); }); }); describe('should custom convertor when converting from wysiwyg to markdown', () => { function createCustomConvertor(customConvertor: ToMdConvertorMap) { schema = createSchema(); convertor = new Convertor(schema, customConvertor, {}, new EventEmitter()); } it('should change delimeter', () => { const toMdCustomConvertor = { thematicBreak() { return { delim: '- - -', }; }, }; createCustomConvertor(toMdCustomConvertor); assertConverting('***', '- - -'); }); it('should change raw html', () => { const toMdCustomConvertor = { thematicBreak() { return { rawHTML: '
                  ', }; }, }; createCustomConvertor(toMdCustomConvertor); assertConverting('***', '
                  '); }); it('should not convert raw html when returning only delimiter', () => { const toMdCustomConvertor = { thematicBreak() { return { delim: '***', }; }, }; createCustomConvertor(toMdCustomConvertor); assertConverting('
                  ', '***'); }); it('should convert to original value', () => { const toMdCustomConvertor = { thematicBreak(_: NodeInfo | MarkInfo, { origin }: ToMdConvertorContext) { return origin!(); }, }; createCustomConvertor(toMdCustomConvertor); assertConverting('***', '***'); }); it('should convert by mixing return values', () => { const toMdCustomConvertor = { heading({ node }: NodeInfo | MarkInfo, { origin }: ToMdConvertorContext) { const { level, headingType } = node.attrs; if (headingType === 'setext') { const delim = level === 1 ? '========' : '------'; return { delim }; } return origin!(); }, }; createCustomConvertor(toMdCustomConvertor); const markdown = source` heading1 === heading2 --- # heading1 `; const expected = source` heading1 ======== heading2 ------ # heading1 `; assertConverting(markdown, expected); }); }); describe('with front matter parser option', () => { function assertFrontMatterConverting(markdown: string, expected: string) { const useFrontMatterParser = new Parser({ disallowedHtmlBlockTags: ['br', 'img'], frontMatter: true, }); const mdNode = useFrontMatterParser.parse(markdown); const wwNode = convertor.toWysiwygModel(mdNode); const result = convertor.toMarkdownText(wwNode!); expect(result).toBe(expected); } it('should convert front matter', () => { const markdown = source` --- title: foo desc: bar --- `; assertFrontMatterConverting(markdown, markdown); }); }); describe('should convert html block node which is not supported as default', () => { function createConvertorWithHTMLRenderer() { const customHTMLRenderer = createHTMLrenderer(); const adaptor = new WwToDOMAdaptor({}, customHTMLRenderer); const htmlSchemaMap = createHTMLSchemaMap(customHTMLRenderer, sanitizeHTML, adaptor); const specs = createSpecs({}); schema = new Schema({ nodes: { ...specs.nodes, ...htmlSchemaMap.nodes }, marks: { ...specs.marks, ...htmlSchemaMap.marks }, }); convertor = new Convertor(schema, {}, {}, new EventEmitter()); } beforeEach(() => { createConvertorWithHTMLRenderer(); }); it('should convert html block node to wysiwyg ignoring sanitizer tag', () => { const markdown = ''; const expected = ''; assertConverting(markdown, expected); }); it('should convert html block element which has "=" character as the attribute value', () => { const markdown = ''; const expected = ''; assertConverting(markdown, expected); }); it('should convert html block node as the block node through inserting the blank line', () => { const markdown = source` para1 para2 `; const expected = source` para1 para2 `; assertConverting(markdown, expected); }); it('should convert html inline node', () => { const markdown = 'inline content'; assertConverting(markdown, markdown); }); }); describe('with custom convertor when converting from markdown to wysiwyg', () => { function createCustomConvertor(customConvertor: HTMLConvertorMap) { schema = createSchema(); convertor = new Convertor(schema, {}, customConvertor, new EventEmitter()); } it('should convert markdown to wysiwyg', () => { const toHTMLConvertor: HTMLConvertorMap = { paragraph(_: MdNode, { entering, origin, options }: Context) { if (options.nodeId) { return { type: entering ? 'openTag' : 'closeTag', outerNewLine: true, tagName: 'p', }; } return origin!(); }, }; createCustomConvertor(toHTMLConvertor); const markdown = source` > * Wrappers > 1. [x] React > 2. [x] Vue > 3. [ ] Ember `; assertConverting(markdown, markdown); }); }); describe('should escape markdown text in wysiwyg', () => { it('with markdown text', () => { const markdown = source` \\# heading \\> blockquote \\*test\\* \\* list `; assertConverting(markdown, markdown); }); it('with html text', () => { const markdown = source` \\
                  block\\
                  \\bold\\ `; assertConverting(markdown, markdown); }); }); it('should convert empty line between lists of wysiwig to
                  ', () => { const wwNodeJson = { type: 'doc', content: [ { type: 'bulletList', content: [ { type: 'listItem', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'test_1' }] }, { type: 'paragraph', content: [] }, ], }, { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'test_2' }] }], }, ], }, ], }; const wwNode = Node.fromJSON(schema, wwNodeJson); const result = convertor.toMarkdownText(wwNode); expect(result).toBe(`* test\\_1\n
                  \n* test\\_2`); }); it('should escape the backslash, which is a plain chracter in the middle of a sentence', () => { const markdown = source` backslash \\in the middle of a sentence `; const expected = source` backslash \\\\in the middle of a sentence `; assertConverting(markdown, expected); }); }); ================================================ FILE: apps/editor/src/__test__/unit/dom.spec.ts ================================================ import toArray from 'tui-code-snippet/collection/toArray'; import { isPositionInBox, isElemNode, findNodes, appendNodes, insertBeforeNode, removeNode, unwrapNode, toggleClass, createElementWith, closest, empty, appendNode, prependNode, } from '@/utils/dom'; describe('dom utils', () => { let container: HTMLElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { container.parentNode!.removeChild(container); }); it('isPositionInBox() returns state whether position is contained within box size', () => { container.innerHTML = '
                  foo
                  '; const el = document.querySelector('.test') as HTMLElement; const { style } = el; style.left = '0'; style.top = '0'; style.width = '10px'; style.height = '10px'; style.paddingLeft = '0'; style.paddingRight = '0'; style.paddingTop = '0'; style.paddingBottom = '0'; expect(isPositionInBox(style, 5, 5)).toBe(true); expect(isPositionInBox(style, 15, 15)).toBe(false); }); describe('isElemNode', () => { it('returns true if passed node is ELEMENT_NODE', () => { container.innerHTML = '

                  hi

                  '; const target = container.querySelector('p') as HTMLElement; const result = isElemNode(target); expect(result).toBe(true); }); it('returns false if passed node is not ELEMENT_NODE', () => { container.innerHTML = 'text'; const result = isElemNode(container.firstChild!); expect(result).toBe(false); }); }); it('appendNodes() appends last child to parent using dom element', () => { container.innerHTML = '
                  foo
                  '; const el = document.createElement('p'); el.innerHTML = 'bar'; const target = container.querySelector('div') as HTMLElement; appendNodes(target, el); expect(container.innerHTML).toBe('
                  foo

                  bar

                  '); }); it('insertBeforeNode() inserts node in front of target node', () => { container.innerHTML = '
                  foo
                  '; const el = document.createElement('p'); el.innerHTML = 'bar'; const target = container.querySelector('div') as HTMLElement; insertBeforeNode(el, target); expect(container.innerHTML).toBe('

                  bar

                  foo
                  '); }); it('removeNode() removes target node', () => { container.innerHTML = '

                  foo

                  bar

                  '; const target = container.querySelector('p') as HTMLElement; removeNode(target); expect(container.innerHTML).toBe('

                  bar

                  '); }); it('unwrapNode() removes given element and insert children at the same position', () => { const childrenHTML = 'emph1 text emph2'; container.innerHTML = `

                  ${childrenHTML}

                  `; const target = container.querySelector('b') as HTMLElement; unwrapNode(target); expect(container.innerHTML).toBe(`

                  ${childrenHTML}

                  `); }); describe('findNodes() returns nodes matching by selector', () => { beforeEach(() => { container.innerHTML = '
                  foo
                  bar
                  '; }); it('to array when found', () => { const result = findNodes(container, 'div'); expect(result.length).toBe(2); expect(result[0].textContent).toBe('foo'); expect(result[1].textContent).toBe('bar'); }); it('to empty array when not found', () => { const result = findNodes(container, '.test'); expect(result.length).toBe(0); }); }); describe('toggleClass() adds or removes specific class name of element', () => { beforeEach(() => { container.innerHTML = '
                  foo
                  '; }); it('only toggle class', () => { const target = container.querySelector('div')!; toggleClass(target, 'active'); expect(target.className).toBe('test active'); toggleClass(target, 'active'); expect(target.className).toBe('test'); }); it('add or remove class by condition', () => { const target = container.querySelector('div')!; toggleClass(target, 'active', true); expect(target.className).toBe('test active'); toggleClass(target, 'active1', false); expect(target.className).toBe('test active'); toggleClass(target, 'active', false); expect(target.className).toBe('test'); }); }); describe('createElementWith() returns created new element using', () => { it('html string', () => { const result = createElementWith('

                  foo

                  ')!; expect(result.textContent).toBe('foo'); }); it('dom element', () => { const element = document.createElement('p'); element.innerHTML = 'foo'; const result = createElementWith(element)!; expect(result.textContent).toBe('foo'); }); it('if there is target element, new element is appended to target', () => { container.innerHTML = '
                  '; const target = container.querySelector('div')!; const result = createElementWith('

                  foo

                  ', target)!; expect(result.parentNode).toBe(target); }); }); describe('closest() finds node with', () => { beforeEach(() => { container.innerHTML = '
                  • foo
                  • bar
                  '; }); it('type selector from text node', () => { const selector = 'li'; const [target] = toArray(container.querySelectorAll('li')); const foundNode = closest(target.firstChild!, selector); const result = container.querySelector(selector); expect(foundNode).toBe(result); }); it('attribute selector from text node', () => { const selector = '.test'; const [, target] = toArray(container.querySelectorAll('li')); const foundNode = closest(target.firstChild!, selector); const result = container.querySelector(selector); expect(foundNode).toBe(result); }); it('type selector from element node', () => { const selector = 'UL'; const target = container.querySelector('li')!; const foundNode = closest(target, selector); const result = container.querySelector(selector); expect(foundNode).toBe(result); }); it('wrong selector', () => { const selector = 'wrong selector'; const target = container.querySelector('li')!; const foundNode = closest(target, selector); expect(foundNode).toBeNull(); }); it('dom element', () => { const target = container.querySelector('li')!; const selector = container.querySelector('ul')!; const foundNode = closest(target, selector)!; expect(foundNode).toEqual(selector); }); }); it('empty() removes all children from target node', () => { container.innerHTML = '

                  foo

                  bar

                  '; const target = container.querySelector('div')!; empty(target); expect(container.innerHTML).toBe('
                  '); }); describe('appendNode() appends last child to parent using', () => { beforeEach(() => { container.innerHTML = '
                  foo
                  '; }); it('html string', () => { appendNode(container.querySelector('div')!, '

                  bar

                  '); expect(container.innerHTML).toBe('
                  foo

                  bar

                  '); }); it('dom element', () => { const child = document.createElement('p'); child.innerHTML = 'bar'; appendNode(container.querySelector('div')!, child); expect(container.innerHTML).toBe('
                  foo

                  bar

                  '); }); }); describe('prependNode() appends first child to parent using', () => { beforeEach(() => { container.innerHTML = '
                  foo
                  '; }); it('html string', () => { prependNode(container.querySelector('div')!, '

                  bar

                  '); expect(container.innerHTML).toBe('

                  bar

                  foo
                  '); }); it('dom element', () => { const child = document.createElement('p'); child.innerHTML = 'bar'; prependNode(container.querySelector('div')!, child); expect(container.innerHTML).toBe('

                  bar

                  foo
                  '); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/editor.spec.ts ================================================ import '@/i18n/en-us'; import { oneLineTrim, stripIndents, source } from 'common-tags'; import { Emitter } from '@t/event'; import { EditorOptions } from '@t/editor'; import type { OpenTagToken } from '@toast-ui/toastmark'; import i18n from '@/i18n/i18n'; import Editor from '@/editor'; import Viewer from '@/viewer'; import * as commonUtil from '@/utils/common'; import { createHTMLrenderer } from './markdown/util'; import { cls } from '@/utils/dom'; import * as imageHelper from '@/helper/image'; const HEADING_CLS = `${cls('md-heading')} ${cls('md-heading1')}`; const DELIM_CLS = cls('md-delimiter'); describe('editor', () => { let container: HTMLElement, mdEditor: HTMLElement, mdPreview: HTMLElement, wwEditor: HTMLElement, editor: Editor; function getPreviewHTML() { return mdPreview .querySelector(`.${cls('contents')}`)! .innerHTML.replace(/\sdata-nodeid="\d+"|\n/g, '') .trim(); } describe('instance API', () => { beforeEach(() => { container = document.createElement('div'); editor = new Editor({ el: container, previewHighlight: false, widgetRules: [ { rule: /@\S+/, toDOM(text) { const span = document.createElement('span'); span.innerHTML = `${text}`; return span; }, }, ], }); const elements = editor.getEditorElements(); mdEditor = elements.mdEditor; mdPreview = elements.mdPreview!; wwEditor = elements.wwEditor!; document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('convertPosToMatchEditorMode', () => { const mdPos: [number, number] = [2, 1]; const wwPos = 14; it('should convert position to match editor mode', () => { editor.setMarkdown('Hello World\nwelcome to the world'); editor.changeMode('wysiwyg'); expect(editor.convertPosToMatchEditorMode(mdPos)).toEqual([wwPos, wwPos]); editor.changeMode('markdown'); expect(editor.convertPosToMatchEditorMode(wwPos)).toEqual([mdPos, mdPos]); }); it('should occurs error when types of parameters is not matched', () => { expect(() => { editor.convertPosToMatchEditorMode(mdPos, wwPos); }).toThrowError(); }); }); it('setPlaceholder()', () => { editor.setPlaceholder('Please input text'); const expected = 'Please input text'; expect(mdEditor).toContainHTML(expected); expect(wwEditor).toContainHTML(expected); }); describe('getHTML()', () => { it('basic', () => { editor.setMarkdown('# heading\n* bullet'); const result = oneLineTrim`

                  heading

                  • bullet

                  `; expect(editor.getHTML()).toBe(result); }); it('should not trigger change event when the mode is wysiwyg', () => { const spy = jest.fn(); editor.changeMode('wysiwyg'); editor.on('change', spy); editor.getHTML(); expect(spy).not.toHaveBeenCalled(); }); it('should be the same as wysiwyg contents', () => { const input = source`

                  first line

                  second line


                  \nthird line


                  \n
                  \nfourth line

                  `; const expected = oneLineTrim`

                  first line

                  second line


                  third line



                  fourth line

                  `; editor.setHTML(input); expect(editor.getHTML()).toBe(expected); }); it('placeholder should be removed', () => { editor.changeMode('wysiwyg'); editor.setPlaceholder('placeholder'); const result = oneLineTrim`


                  `; expect(editor.getHTML()).toBe(result); }); }); it('changeMode()', () => { const spy = jest.fn(); expect(editor.isMarkdownMode()).toBe(true); expect(editor.isWysiwygMode()).toBe(false); editor.on('changeMode', spy); editor.changeMode('wysiwyg'); expect(spy).toHaveBeenCalledWith('wysiwyg'); expect(editor.isMarkdownMode()).toBe(false); expect(editor.isWysiwygMode()).toBe(true); }); it('changePreviewStyle()', () => { const spy = jest.fn(); expect(editor.getCurrentPreviewStyle()).toBe('tab'); editor.on('changePreviewStyle', spy); editor.changePreviewStyle('vertical'); expect(spy).toHaveBeenCalledWith('vertical'); expect(editor.getCurrentPreviewStyle()).toBe('vertical'); }); describe('setMarkdown()', () => { it('basic', () => { editor.setMarkdown('# heading'); expect(mdEditor).toContainHTML( `
                  # heading
                  ` ); expect(getPreviewHTML()).toBe('

                  heading

                  '); }); it('should parse the CRLF properly in markdown', () => { editor.setMarkdown('# heading\r\nCRLF'); expect(mdEditor).toContainHTML( `
                  # heading
                  CRLF
                  ` ); expect(getPreviewHTML()).toBe('

                  heading

                  CRLF

                  '); }); }); describe('setHTML()', () => { it('basic', () => { editor.setHTML('

                  heading

                  '); expect(mdEditor).toContainHTML( `
                  # heading
                  ` ); expect(getPreviewHTML()).toBe('

                  heading

                  '); }); it('should parse the br tag as the empty block to separate between blocks', () => { editor.setHTML('

                  a
                  b

                  '); expect(mdEditor).toContainHTML('
                  a
                  b
                  '); expect(getPreviewHTML()).toBe('

                  a
                  b

                  '); }); it('should parse the br tag with the paragraph block to separate between blocks in wysiwyg', () => { editor.setHTML( '

                  test title

                  test bold
                  test italic
                  normal text

                  ' ); editor.changeMode('wysiwyg'); const expected = oneLineTrim`

                  test title

                  test bold

                  test italic

                  normal text

                  `; expect(wwEditor).toContainHTML(expected); }); it('should parse the br tag with the paragraph block to separate between blocks', () => { const input = source`

                  first line

                  second line


                  \nthird line


                  \n
                  \nfourth line

                  `; const expected = oneLineTrim`

                  first line
                  second line

                  third line


                  fourth line

                  `; editor.setHTML(input); expect(getPreviewHTML()).toBe(expected); }); it('should be parsed with the same content when calling setHTML() with getHTML() API result', () => { const input = source`

                  first line

                  second line


                  \nthird line


                  \n
                  \nfourth line

                  `; editor.setHTML(input); const mdEditorHTML = mdEditor.innerHTML; const mdPreviewHTML = getPreviewHTML(); editor.setHTML(editor.getHTML()); expect(mdEditor).toContainHTML(mdEditorHTML); expect(getPreviewHTML()).toBe(mdPreviewHTML); }); }); it('reset()', () => { editor.setMarkdown('# heading'); editor.reset(); expect(mdEditor).not.toContainHTML( `
                  # heading
                  ` ); expect(getPreviewHTML()).toBe(''); }); describe('setMinHeight()', () => { it('should set height with pixel option', () => { editor.setMinHeight('200px'); expect(mdEditor).toHaveStyle({ minHeight: '200px' }); expect(mdPreview).toHaveStyle({ minHeight: '200px' }); expect(wwEditor).toHaveStyle({ minHeight: '200px' }); }); it('should be less than the editor height', () => { editor.setMinHeight('400px'); expect(mdEditor).toHaveStyle({ minHeight: '225px' }); expect(mdPreview).toHaveStyle({ minHeight: '225px' }); expect(wwEditor).toHaveStyle({ minHeight: '225px' }); }); }); describe('setHeight()', () => { it('should set height with pixel option', () => { editor.setHeight('300px'); expect(container).not.toHaveClass('auto-height'); expect(container).toHaveStyle({ height: '300px' }); expect(mdEditor).toHaveStyle({ minHeight: '200px' }); expect(mdPreview).toHaveStyle({ minHeight: '200px' }); expect(wwEditor).toHaveStyle({ minHeight: '200px' }); }); it('should set height with auto option', () => { editor.setHeight('auto'); expect(container).toHaveClass('auto-height'); expect(container).toHaveStyle({ height: 'auto' }); expect(mdEditor).toHaveStyle({ minHeight: '200px' }); expect(mdPreview).toHaveStyle({ minHeight: '200px' }); expect(wwEditor).toHaveStyle({ minHeight: '200px' }); }); }); it('addWidget()', () => { const node = document.createElement('div'); node.innerHTML = 'widget'; editor.addWidget(node, 'top'); expect(document.body).toContainElement(node); editor.changeMode('wysiwyg'); expect(document.body).not.toContainElement(node); }); describe('replaceWithWidget()', () => { it('in markdown', () => { editor.replaceWithWidget([1, 1], [1, 1], '@test'); const expectedEditor = oneLineTrim` @test `; const expectedPreview = oneLineTrim`

                  @test

                  `; expect(mdEditor).toContainHTML(expectedEditor); expect(getPreviewHTML()).toBe(expectedPreview); }); it('in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.replaceWithWidget(1, 1, '@test'); const expected = oneLineTrim` @test `; expect(wwEditor).toContainHTML(expected); }); }); it('exec()', () => { // @ts-ignore jest.spyOn(editor.commandManager, 'exec'); editor.exec('bold'); // @ts-ignore // eslint-disable-next-line no-undefined expect(editor.commandManager.exec).toHaveBeenCalledWith('bold', undefined); }); it('addCommand()', () => { const spy = jest.fn(); // @ts-ignore const { view } = editor.mdEditor; const { state, dispatch } = view; editor.addCommand('markdown', 'custom', spy); editor.exec('custom', { prop: 'prop' }); expect(spy).toHaveBeenCalledWith({ prop: 'prop' }, state, dispatch, view); expect(spy).toHaveBeenCalled(); }); it('should be triggered only once when the event registered by addHook()', () => { const spy = jest.fn(); const { eventEmitter } = editor; eventEmitter.addEventType('custom'); editor.addHook('custom', spy); editor.addHook('custom', spy); eventEmitter.emit('custom'); expect(spy).toHaveBeenCalledTimes(1); }); describe('insertText()', () => { it('in markdown', () => { editor.insertText('test'); expect(mdEditor).toContainHTML('
                  test
                  '); expect(getPreviewHTML()).toBe('

                  test

                  '); }); it('in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.insertText('test'); expect(wwEditor).toContainHTML('

                  test

                  '); }); }); describe('setSelection(), getSelection()', () => { it('in markdown', () => { expect(editor.getSelection()).toEqual([ [1, 1], [1, 1], ]); editor.setMarkdown('line1\nline2'); editor.setSelection([1, 2], [2, 4]); expect(editor.getSelection()).toEqual([ [1, 2], [2, 4], ]); }); it('in wysiwyg', () => { editor.changeMode('wysiwyg'); expect(editor.getSelection()).toEqual([1, 1]); editor.setMarkdown('line1\nline2'); editor.setSelection(2, 8); expect(editor.getSelection()).toEqual([2, 8]); }); }); describe('getSelectedText()', () => { beforeEach(() => { editor.setMarkdown('line1\nline2'); editor.setSelection([1, 2], [2, 4]); }); it('in markdown', () => { expect(editor.getSelectedText()).toEqual('ine1\nlin'); expect(editor.getSelectedText([1, 2], [2, 6])).toEqual('ine1\nline2'); }); it('in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.setSelection(2, 11); expect(editor.getSelectedText()).toEqual('ine1\nlin'); expect(editor.getSelectedText(2, 13)).toEqual('ine1\nline2'); }); }); describe('replaceSelection()', () => { beforeEach(() => { editor.setMarkdown('line1\nline2'); editor.setSelection([1, 2], [2, 4]); }); it('should replace current selection in markdown', () => { editor.replaceSelection('Replaced'); expect(mdEditor).toContainHTML('
                  lReplacede2
                  '); expect(getPreviewHTML()).toBe('

                  lReplacede2

                  '); }); it('should replace current selection in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.setSelection(2, 11); editor.replaceSelection('Replaced'); expect(wwEditor).toContainHTML('

                  lReplacede2

                  '); }); it('should replace given selection in markdown', () => { editor.replaceSelection('Replaced', [1, 1], [2, 1]); expect(mdEditor).toContainHTML('
                  Replacedline2
                  '); expect(getPreviewHTML()).toBe('

                  Replacedline2

                  '); }); it('should replace given selection in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.replaceSelection('Replaced', 1, 7); expect(wwEditor).toContainHTML('

                  Replaced

                  line2

                  '); }); it('should parse the CRLF properly in markdown', () => { editor.replaceSelection('text\r\nCRLF'); expect(mdEditor).toContainHTML('
                  ltext
                  CRLFe2
                  '); expect(getPreviewHTML()).toBe('

                  ltext
                  CRLFe2

                  '); }); }); describe('deleteSelection()', () => { beforeEach(() => { editor.setMarkdown('line1\nline2'); editor.setSelection([1, 2], [2, 4]); }); it('should delete current selection in markdown', () => { editor.deleteSelection(); expect(mdEditor).toContainHTML('
                  le2
                  '); expect(getPreviewHTML()).toBe('

                  le2

                  '); }); it('should delete current selection in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.setSelection(2, 11); editor.deleteSelection(); expect(wwEditor).toContainHTML('

                  le2

                  '); }); it('should delete given selection in markdown', () => { editor.deleteSelection([1, 1], [2, 1]); expect(mdEditor).toContainHTML('
                  line2
                  '); expect(getPreviewHTML()).toBe('

                  line2

                  '); }); it('should delete given selection in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.deleteSelection(1, 7); expect(wwEditor).toContainHTML('

                  line2

                  '); }); }); describe('getRangeOfNode()', () => { beforeEach(() => { editor.setMarkdown('line1\nline2 **strong**'); editor.setSelection([2, 10], [2, 12]); }); it('should get the range of the current selected node in markdown', () => { const rangeInfo = editor.getRangeInfoOfNode(); const [start, end] = rangeInfo.range; expect(rangeInfo).toEqual({ range: [ [2, 7], [2, 17], ], type: 'strong', }); editor.replaceSelection('Replaced', start, end); expect(getPreviewHTML()).toBe('

                  line1
                  line2 Replaced

                  '); }); it('should get the range of the current selected node in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.setSelection(15, 15); const rangeInfo = editor.getRangeInfoOfNode(); const [start, end] = rangeInfo.range; expect(rangeInfo).toEqual({ range: [14, 20], type: 'strong' }); editor.replaceSelection('Replaced', start, end); expect(wwEditor).toContainHTML('

                  line1

                  line2 Replaced

                  '); }); it('should get the range of selection with given position in markdown', () => { const rangeInfo = editor.getRangeInfoOfNode([2, 2]); const [start, end] = rangeInfo.range; expect(rangeInfo).toEqual({ range: [ [2, 1], [2, 7], ], type: 'text', }); editor.replaceSelection('Replaced', start, end); expect(getPreviewHTML()).toBe('

                  line1
                  Replacedstrong

                  '); }); it('should get the range of selection with given position in wysiwyg', () => { editor.changeMode('wysiwyg'); const rangeInfo = editor.getRangeInfoOfNode(10); const [start, end] = rangeInfo.range; expect(rangeInfo).toEqual({ range: [8, 14], type: 'text' }); editor.replaceSelection('Replaced', start, end); expect(wwEditor).toContainHTML('

                  line1

                  Replacedstrong

                  '); }); }); }); describe('static API', () => { it('factory()', () => { const editorInst = Editor.factory({ el: document.createElement('div'), viewer: false }); const viewerInst = Editor.factory({ el: document.createElement('div'), viewer: true }); expect(editorInst).toBeInstanceOf(Editor); expect(viewerInst).toBeInstanceOf(Viewer); }); it('setLanguage()', () => { const data = {}; jest.spyOn(i18n, 'setLanguage'); Editor.setLanguage('ko', data); expect(i18n.setLanguage).toHaveBeenCalledWith('ko', data); }); }); describe('options', () => { beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); function createEditor(options: EditorOptions) { editor = new Editor(options); const elements = editor.getEditorElements(); mdEditor = elements.mdEditor; mdPreview = elements.mdPreview!; wwEditor = elements.wwEditor!; } describe('plugins', () => { it('should invoke plugin functions', () => { const fooPlugin = jest.fn().mockReturnValue({}); const barPlugin = jest.fn().mockReturnValue({}); createEditor({ el: container, plugins: [fooPlugin, barPlugin] }); // @ts-ignore const { eventEmitter } = editor; expect(fooPlugin).toHaveBeenCalledWith(expect.objectContaining({ eventEmitter })); expect(barPlugin).toHaveBeenCalledWith(expect.objectContaining({ eventEmitter })); }); it('should invoke plugin function with options of plugin', () => { const plugin = jest.fn().mockReturnValue({}); const options = {}; createEditor({ el: container, plugins: [[plugin, options]] }); // @ts-ignore const { eventEmitter } = editor; expect(plugin).toHaveBeenCalledWith( expect.objectContaining({ eventEmitter }), expect.objectContaining(options) ); }); it(`should add command to command manager when plugin return 'markdownCommands' value`, () => { const spy = jest.fn(); const plugin = () => { return { markdownCommands: { foo: () => { spy(); return true; }, }, }; }; createEditor({ el: container, plugins: [plugin] }); editor.exec('foo'); expect(spy).toHaveBeenCalled(); }); it(`should add command to command manager when plugin return 'wysiwygCommands' value`, () => { const spy = jest.fn(); const plugin = () => { return { wysiwygCommands: { foo: () => { spy(); return true; }, }, }; }; createEditor({ el: container, plugins: [plugin] }); editor.changeMode('wysiwyg'); editor.exec('foo'); expect(spy).toHaveBeenCalled(); }); it(`should add toolbar item when plugin return 'toolbarItems' value`, () => { const toolbarItem = { name: 'color', tooltip: 'Text color', className: 'toastui-editor-toolbar-icons color', }; const plugin = () => { return { toolbarItems: [{ groupIndex: 1, itemIndex: 2, item: toolbarItem }], }; }; createEditor({ el: container, plugins: [plugin] }); const toolbar = document.querySelector(`.${cls('toolbar-icons.color')}`); expect(toolbar).toBeInTheDocument(); }); }); describe('usageStatistics', () => { it('should send request hostname in payload by default', () => { spyOn(commonUtil, 'sendHostName'); createEditor({ el: container }); expect(commonUtil.sendHostName).toHaveBeenCalled(); }); it('should not send request if the option is set to false', () => { spyOn(commonUtil, 'sendHostName'); createEditor({ el: container, usageStatistics: false }); expect(commonUtil.sendHostName).not.toHaveBeenCalled(); }); }); describe('hideModeSwitch', () => { it('should hide mode switch if the option value is true', () => { createEditor({ el: container, hideModeSwitch: true }); const modeSwitch = document.querySelector(`.${cls('mode-switch')}`); expect(modeSwitch).not.toBeInTheDocument(); }); }); describe('extendedAutolinks option', () => { it('should convert url-like strings to anchor tags', () => { createEditor({ el: container, initialValue: 'http://nhn.com', extendedAutolinks: true, previewHighlight: false, }); expect(getPreviewHTML()).toBe('

                  http://nhn.com

                  '); }); }); describe('disallowDeepHeading internal parsing option', () => { it('should disallow the nested seTextHeading in list', () => { createEditor({ el: container, initialValue: '- item1\n\t-', previewHighlight: false, }); const result = oneLineTrim`
                  • item1
                    -

                  `; expect(getPreviewHTML()).toBe(result); }); it('should disallow the nested atxHeading in list', () => { createEditor({ el: container, initialValue: '- # item1', previewHighlight: false, }); const result = oneLineTrim`
                  • # item1

                  `; expect(getPreviewHTML()).toBe(result); }); it('should disallow the nested seTextHeading in blockquote', () => { createEditor({ el: container, initialValue: '> item1\n> -', previewHighlight: false, }); const result = oneLineTrim`

                  item1
                  -

                  `; expect(getPreviewHTML()).toBe(result); }); it('should disallow the nested atxHeading in blockquote', () => { createEditor({ el: container, initialValue: '> # item1', previewHighlight: false, }); const result = oneLineTrim`

                  # item1

                  `; expect(getPreviewHTML()).toBe(result); }); }); describe('frontMatter option', () => { it('should parse the front matter as the paragraph in WYSIWYG', () => { createEditor({ el: container, frontMatter: true, initialValue: '---\ntitle: front matter\n---', initialEditType: 'wysiwyg', }); const result = stripIndents`
                  --- title: front matter ---
                  `; expect(wwEditor).toContainHTML(result); }); it('should keep the front matter after changing the mode', () => { createEditor({ el: container, frontMatter: true, initialEditType: 'wysiwyg', initialValue: '---\ntitle: front matter\n---', }); editor.changeMode('markdown'); expect(editor.getMarkdown()).toBe('---\ntitle: front matter\n---'); }); }); describe('customHTMLSanitizer option', () => { it('should replace default sanitizer with custom sanitizer', () => { const customHTMLSanitizer = jest.fn(); createEditor({ el: container, customHTMLSanitizer }); editor.changeMode('wysiwyg'); expect(customHTMLSanitizer).toHaveBeenCalled(); }); }); describe('customHTMLRenderer', () => { it('should pass customHTMLRender option for creating convertor instance', () => { createEditor({ el: container, initialValue: 'Hello World', previewHighlight: false, customHTMLRenderer: { paragraph(_, { entering, origin }) { const result = origin!() as OpenTagToken; if (entering) { result.classNames = ['my-class']; } return result; }, }, }); expect(getPreviewHTML()).toBe('

                  Hello World

                  '); }); it('linkAttributes option should be applied to original renderer', () => { createEditor({ el: container, initialValue: '[Hello](nhn.com)', linkAttributes: { target: '_blank' }, previewHighlight: false, customHTMLRenderer: { link(_, { origin }) { return origin!(); }, }, }); expect(getPreviewHTML()).toBe('

                  Hello

                  '); }); it('should render html block node regardless of the sanitizer', () => { createEditor({ el: container, initialValue: '\n\ntest', previewHighlight: false, // add iframe html block renderer customHTMLRenderer: createHTMLrenderer(), }); const result = oneLineTrim`

                  test

                  `; expect(getPreviewHTML()).toBe(result); }); it('should keep the html block node after changing the mode', () => { createEditor({ el: container, initialValue: '\n\ntest', previewHighlight: false, // add iframe html block renderer customHTMLRenderer: createHTMLrenderer(), }); editor.changeMode('wysiwyg'); const result = oneLineTrim`

                  test

                  `; expect(wwEditor.innerHTML).toContain(result); }); it('should keep the html attributes with an empty string after changing the mode', () => { createEditor({ el: container, initialValue: '', previewHighlight: false, // add iframe html block renderer customHTMLRenderer: createHTMLrenderer(), }); editor.changeMode('wysiwyg'); const result = oneLineTrim` `; expect(wwEditor.innerHTML).toContain(result); }); }); describe('hooks option', () => { const defaultImageBlobHookSpy = jest.fn(); function mockDefaultImageBlobHook() { defaultImageBlobHookSpy.mockReset(); jest .spyOn(imageHelper, 'addDefaultImageBlobHook') .mockImplementation((emitter: Emitter) => { emitter.listen('addImageBlobHook', defaultImageBlobHookSpy); }); } it('should remove default `addImageBlobHook` event handler after registering hook', () => { const spy = jest.fn(); mockDefaultImageBlobHook(); createEditor({ el: container, hooks: { addImageBlobHook: spy, }, }); editor.eventEmitter.emit('addImageBlobHook'); expect(spy).toHaveBeenCalled(); expect(defaultImageBlobHookSpy).not.toHaveBeenCalled(); }); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/eventEmitter.spec.ts ================================================ import EventEmitter from '@/event/eventEmitter'; /* eslint-disable @typescript-eslint/no-empty-function */ describe('eventEmitter', () => { let emitter: EventEmitter; beforeEach(() => { emitter = new EventEmitter(); }); describe('Event registration', () => { it('should throw exception when it use not registered event type', () => { const throwableListen = () => { emitter.listen('testNoEvent', () => {}); }; expect(throwableListen).toThrow(new Error('There is no event type testNoEvent')); }); it('should throw exception when it register event type that already have', () => { emitter.addEventType('testAlreadyHaveEvent'); const throwableListen = () => { emitter.addEventType('testAlreadyHaveEvent'); }; expect(throwableListen).toThrow( new Error('There is already have event type testAlreadyHaveEvent') ); }); }); describe('emit()', () => { beforeEach(() => { emitter.addEventType('testEvent'); emitter.addEventType('testEventHook'); }); it('should emit and listen event', () => { const spy = jest.fn(); emitter.listen('testEvent', spy); emitter.emit('testEvent'); expect(spy).toHaveBeenCalled(); }); it('should return value that returned by listener', () => { let count = 0; emitter.listen('testEventHook', () => count); emitter.listen('testEventHook', () => { count += 1; return count; }); const result = emitter.emit('testEventHook'); expect(result).toEqual([0, 1]); }); it('should return the empty array if listener have not return value', () => { emitter.listen('testEvent', jest.fn()); const result = emitter.emit('testEvent'); expect(result).toEqual([]); }); it('should trigger the event handler added with namespace', () => { const spy = jest.fn(); emitter.listen('testEvent.ns', spy); emitter.emit('testEvent'); expect(spy).toHaveBeenCalled(); }); }); describe('emitReduce()', () => { beforeEach(() => { emitter.addEventType('reduceTest'); }); it('reduce the return value', () => { emitter.listen('reduceTest', (data) => { data += 1; return data; }); emitter.listen('reduceTest', (data) => { data += 2; return data; }); expect(emitter.emitReduce('reduceTest', 1)).toBe(4); }); it('can have additional parameter', () => { emitter.listen('reduceTest', (data, addition) => { data += addition; return data; }); emitter.listen('reduceTest', (data, addition) => { data += addition + 1; return data; }); expect(emitter.emitReduce('reduceTest', 1, 2)).toBe(6); }); it('skip the return value if the value is falsy', () => { emitter.listen('reduceTest', () => {}); emitter.listen('reduceTest', (data, addition) => { data += addition + 1; return data; }); expect(emitter.emitReduce('reduceTest', 1, 2)).toBe(4); }); }); describe('remove handler', () => { let handlerBeRemoved: jest.Mock, handlerBeRemained: jest.Mock; beforeEach(() => { handlerBeRemoved = jest.fn(); handlerBeRemained = jest.fn(); emitter.addEventType('myEvent'); emitter.addEventType('myEvent2'); }); it('remove all event handler by event', () => { emitter.listen('myEvent', handlerBeRemoved); emitter.listen('myEvent.ns', handlerBeRemoved); emitter.removeEventHandler('myEvent'); emitter.emit('myEvent'); expect(handlerBeRemoved).not.toHaveBeenCalled(); }); it('remove all event handler by namespace', () => { emitter.listen('myEvent.ns', handlerBeRemoved); emitter.listen('myEvent2.ns', handlerBeRemoved); emitter.removeEventHandler('.ns'); emitter.emit('myEvent'); emitter.emit('myEvent2'); expect(handlerBeRemoved).not.toHaveBeenCalled(); }); it('should remain the non-namespace handler when removing namespace', () => { emitter.listen('myEvent', handlerBeRemained); emitter.removeEventHandler('.ns'); emitter.emit('myEvent'); expect(handlerBeRemained).toHaveBeenCalled(); }); it('remove specific event handler using namespace and type', () => { emitter.listen('myEvent.ns', handlerBeRemoved); emitter.removeEventHandler('myEvent.ns'); emitter.emit('myEvent'); expect(handlerBeRemoved).not.toHaveBeenCalled(); }); it('should remain the non-related handler when removing specific namespace and type', () => { emitter.listen('myEvent2.ns', handlerBeRemained); emitter.removeEventHandler('myEvent.ns'); emitter.emit('myEvent2'); expect(handlerBeRemained).toHaveBeenCalled(); }); it('remove specific event handler using name and handler', () => { emitter.listen('myEvent', handlerBeRemoved); emitter.removeEventHandler('myEvent', handlerBeRemoved); emitter.emit('myEvent'); expect(handlerBeRemoved).not.toHaveBeenCalled(); }); }); describe('hold event', () => { let handler: jest.Mock; function triggerEvent(apiName: 'emit' | 'emitReduce') { if (apiName === 'emit') { emitter.emit('myEvent'); } else { emitter.emitReduce('myEvent', 0); } } beforeEach(() => { handler = jest.fn(); emitter.addEventType('myEvent'); emitter.listen('myEvent', handler); }); (['emit', 'emitReduce'] as const).forEach((apiName) => { it(`should not call the holding event with ${apiName} API`, () => { emitter.holdEventInvoke(() => triggerEvent(apiName)); expect(handler).not.toHaveBeenCalled(); }); it(`should call the event after holding the event with ${apiName} API`, () => { emitter.holdEventInvoke(() => triggerEvent(apiName)); triggerEvent(apiName); expect(handler).toHaveBeenCalledTimes(1); }); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/helper/common.spec.ts ================================================ import { deepCopy, deepCopyArray, deepMergedCopy, includes } from '@/utils/common'; it('"deepCopy" should copy the object deeply', () => { const obj = { foo: 1, bar: { baz: 1 } }; expect(deepCopy(obj)).toEqual(obj); }); it('"deepCopyArray" should copy the array deeply', () => { const arr = [1, 2, ['a', 'b', ['c']], 3, 4]; expect(deepCopyArray(arr)).toEqual(arr); }); it('"deepMergedCopy" should merge the objects and copy them deeply', () => { const obj1 = { a: 1, b: { c: 1, d: 'a', e: 'c', f: { g: 'd' } } }; const obj2 = { a: 1, b: { c: 1, d: 'b', h: 'e' } }; expect(deepMergedCopy(obj1, obj2)).toEqual({ a: 1, b: { c: 1, d: 'b', e: 'c', f: { g: 'd' }, h: 'e' }, }); }); it('"includes" should check whether the specific element is inlcuded in array', () => { expect(includes([1, 2, 3], 1)).toBe(true); }); ================================================ FILE: apps/editor/src/__test__/unit/helper/image.spec.ts ================================================ import EventEmitter from '@/event/eventEmitter'; import { addDefaultImageBlobHook, emitImageBlobHook } from '@/helper/image'; describe('image processor', () => { let em: EventEmitter; beforeEach(() => { em = new EventEmitter(); }); function mockReadAsDataURL() { jest .spyOn(FileReader.prototype, 'readAsDataURL') .mockImplementation(function (this: FileReader) { const ev = { target: { result: '/file.jpg' } } as ProgressEvent; this.onload!(ev); }); } it('should call addImageBlobHook hook on calling emitImageBlobHook function', () => { const spy = jest.fn(); const file = new File([new ArrayBuffer(1)], 'file.jpg'); em.listen('addImageBlobHook', spy); emitImageBlobHook(em, file, 'drop'); expect(spy).toHaveBeenCalledWith(file, expect.any(Function), 'drop'); }); it('should execute addImage command through hook callback function in default addImageBlobHook hook', () => { addDefaultImageBlobHook(em); mockReadAsDataURL(); const spy = jest.fn(); const file = new File([new ArrayBuffer(1)], 'file.jpg'); em.listen('command', spy); emitImageBlobHook(em, file, 'drop'); expect(spy).toHaveBeenCalledWith('addImage', { altText: 'file.jpg', imageUrl: '/file.jpg' }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/__snapshots__/syntaxHighlight.spec.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`markdown editor syntax highlight atx heading 1`] = `
                  # heading
                  `; exports[`markdown editor syntax highlight blockQuote basic 1`] = `
                  > block quote
                  `; exports[`markdown editor syntax highlight blockQuote with list 1`] = `
                  > * [ ] block quote
                  `; exports[`markdown editor syntax highlight bulletList 1`] = `
                  * bullet list
                  `; exports[`markdown editor syntax highlight code block 1`] = `
                  \`\`\` js
                  console.log("editor")
                  \`\`\`
                  `; exports[`markdown editor syntax highlight code block within list 1`] = `
                  * list
                  \`\`\` js
                  console.log("editor")
                  \`\`\`
                  `; exports[`markdown editor syntax highlight custom block 1`] = `
                  $$ custom
                  my custom element
                  $$
                  `; exports[`markdown editor syntax highlight emph 1`] = `
                  * emph *
                  `; exports[`markdown editor syntax highlight image 1`] = `
                  ! [ Logo ]( https://picsum.photos/200 )
                  `; exports[`markdown editor syntax highlight inline code 1`] = `
                  \` inline code \`
                  `; exports[`markdown editor syntax highlight link 1`] = `
                  [ TOAST UI ]( https://ui.toast.com )
                  `; exports[`markdown editor syntax highlight orderedList 1`] = `
                  1. ordered list
                  `; exports[`markdown editor syntax highlight seText heading 1`] = `
                  heading
                  ---
                  `; exports[`markdown editor syntax highlight strike 1`] = `
                  ~~ strike ~~
                  `; exports[`markdown editor syntax highlight strong 1`] = `
                  ** strong **
                  `; exports[`markdown editor syntax highlight table basic 1`] = `
                  | col2 | col2
                  | --- | ---
                  | data1 | data2 |
                  `; exports[`markdown editor syntax highlight table with mark 1`] = `
                  | col2 | col2
                  | --- | ---
                  | data1 | ** data2 ** |
                  `; exports[`markdown editor syntax highlight tastkList 1`] = `
                  * [ x ] task list
                  `; exports[`markdown editor syntax highlight thematicBreak 1`] = `
                  ---
                  `; ================================================ FILE: apps/editor/src/__test__/unit/markdown/keymap.spec.ts ================================================ import { oneLineTrim, source, stripIndent } from 'common-tags'; import { redo, undo } from 'prosemirror-history'; import { chainCommands, deleteSelection, joinBackward, selectNodeBackward, } from 'prosemirror-commands'; import * as keymaps from 'prosemirror-keymap'; import { Sourcepos, ToastMark } from '@toast-ui/toastmark'; import MarkdownEditor from '@/markdown/mdEditor'; import MarkdownPreview from '@/markdown/mdPreview'; import EventEmitter from '@/event/eventEmitter'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import { getTextContent, removeDataAttr, TestEditorWithNoneDelayHistory } from './util'; // @TODO: all tests should move to e2e test function forceKeymapFn(type: string, methodName: string, args: any[] = []) { const { specs, view } = mde; // @ts-ignore const [keymapFn] = specs.specs.filter((spec) => spec.name === type); // @ts-ignore keymapFn[methodName](...args)(view.state, view.dispatch); } function forceBackspaceKeymap() { const { state, dispatch } = mde.view; chainCommands(deleteSelection, joinBackward, selectNodeBackward)(state, dispatch, mde.view); } let mde: MarkdownEditor, em: EventEmitter, preview: MarkdownPreview; function getPreviewHTML() { return oneLineTrim`${removeDataAttr(preview.getHTML())}`; } function assertSelection(mdPos: Sourcepos) { expect(mde.getSelection()).toEqual(mdPos); } function execUndo() { const { state, dispatch } = mde.view; undo(state, dispatch); } beforeEach(() => { em = new EventEmitter(); mde = new TestEditorWithNoneDelayHistory(em, { toastMark: new ToastMark() }); const options = { linkAttributes: null, customHTMLRenderer: {}, isViewer: false, highlight: false, sanitizer: sanitizeHTML, }; preview = new MarkdownPreview(em, options); }); // @TODO: should add test case after developing the markdown editor API // describe('move table cell keymap', () => { // }); describe('extend table keymap', () => { it('should extend the table', () => { const input = source` | head1 | head2 | | --- | --- | | row1 | row1 | | row2 | row2 | `; const result = source` | head1 | head2 | | --- | --- | | row1 | row1 | | | | | row2 | row2 | `; mde.setMarkdown(input); mde.setSelection([3, 2], [3, 2]); forceKeymapFn('table', 'extendTable'); expect(getTextContent(mde)).toBe(result); }); it('should delete the row in case of empty table content', () => { const input = source` | head1 | head2 | | --- | --- | | row1 | row1 | | | | `; const result = source` | head1 | head2 | | --- | --- | | row1 | row1 | `; mde.setMarkdown(input); mde.setSelection([4, 2], [4, 2]); forceKeymapFn('table', 'extendTable'); expect(getTextContent(mde)).toBe(`${result}\n\n`); }); it('should not extend table list on multi line selection', () => { const input = source` | head1 | head2 | | --- | --- | | row1 | row1 | | row2 | row2 | `; mde.setMarkdown(input); mde.setSelection([2, 14], [4, 5]); forceKeymapFn('table', 'extendTable'); expect(getTextContent(mde)).toBe(input); }); it('should not extend the table out of table range', () => { const input = source` | head1 | head2 | | --- | --- | | row1 | row1 | | row2 | row2 | `; const result = source` | head1 | head2 | | --- | --- | | row1 | row1 | | row2 | row2 | `; mde.setMarkdown(input); mde.setSelection([4, 15], [4, 15]); forceKeymapFn('table', 'extendTable'); expect(getTextContent(mde)).toBe(result); }); it('should undo extend the table properly', () => { const input = source` | head1 | head2 | | --- | --- | | row1 | row1 | | row2 | row2 | text `; const result = oneLineTrim`
                  head1 head2
                  row1 row1
                  row2 row2

                  text

                  `; mde.setMarkdown(input); mde.setSelection([4, 2], [4, 2]); forceKeymapFn('table', 'extendTable'); execUndo(); expect(getPreviewHTML()).toBe(result); }); }); describe('extend block quote keymap', () => { it('should extend the block quote', () => { mde.setMarkdown('> block'); mde.setSelection([1, 8], [1, 8]); forceKeymapFn('blockQuote', 'extendBlockQuote'); expect(getTextContent(mde)).toBe('> block\n> '); assertSelection([ [2, 3], [2, 3], ]); }); it('should extend the block quote with sliced text', () => { mde.setMarkdown('> block'); mde.setSelection([1, 6], [1, 6]); forceKeymapFn('blockQuote', 'extendBlockQuote'); expect(getTextContent(mde)).toBe('> blo\n> ck'); assertSelection([ [2, 3], [2, 3], ]); }); it('should not extend the block quote on multi line selection', () => { const input = '> block1\n> block2'; mde.setMarkdown(input); mde.setSelection([1, 2], [2, 4]); forceKeymapFn('blockQuote', 'extendBlockQuote'); expect(getTextContent(mde)).toBe(input); }); it('should delete the row in case of empty block quote content', () => { mde.setMarkdown('> block\n> '); mde.setSelection([2, 2], [2, 2]); forceKeymapFn('blockQuote', 'extendBlockQuote'); expect(getTextContent(mde)).toBe('> block\n\n'); }); it('should delete the row in case of empty block quote content with next content', () => { mde.setMarkdown('> block\n>\nparagraph'); mde.setSelection([2, 2], [2, 2]); forceKeymapFn('blockQuote', 'extendBlockQuote'); expect(getTextContent(mde)).toBe('> block\n\n\nparagraph'); }); it('should not extend block quote when position is start offset', () => { mde.setMarkdown('> block'); mde.setSelection([1, 1], [1, 1]); forceKeymapFn('blockQuote', 'extendBlockQuote'); expect(getTextContent(mde)).toBe('> block'); }); it('should undo extend the block quote properly', () => { const input = '> block\nparagraph'; const result = '

                  block
                  paragraph

                  '; mde.setMarkdown(input); mde.setSelection([1, 6], [1, 6]); forceKeymapFn('blockQuote', 'extendBlockQuote'); execUndo(); expect(getPreviewHTML()).toBe(result); }); }); describe('extend list keymap', () => { describe('bullet list', () => { it('should extend the bullet list', () => { const input = source` * bullet `; const result = `${source` * bullet * `} `; mde.setMarkdown(input); mde.setSelection([1, 9], [1, 9]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); assertSelection([ [2, 3], [2, 3], ]); }); it('should extend the bullet list with sliced text', () => { const input = source` * bullet `; const result = source` * bull * et `; mde.setMarkdown(input); mde.setSelection([1, 7], [1, 7]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); assertSelection([ [2, 3], [2, 3], ]); }); it('should extend the nested bullet list', () => { const input = stripIndent` * bullet * sub `; const result = `${source` * bullet * sub * `} `; mde.setMarkdown(input); mde.setSelection([2, 8], [2, 8]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should extend the bullet list excluding blank line', () => { const input = `${source` * bullet1 * bullet2 `}\n\n`; const result = `${source` * bullet1 * bullet2 * `} \n\n`; mde.setMarkdown(input); mde.setSelection([2, 10], [2, 10]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should extend the bullet list with task', () => { const input = source` * [ ] bullet `; const result = `${source` * [ ] bullet * [ ] `} `; mde.setMarkdown(input); mde.setSelection([1, 13], [1, 13]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); assertSelection([ [2, 7], [2, 7], ]); }); it('should not extend the bullet list on multi line selection', () => { const input = source` * bullet1 * bullet2 `; mde.setMarkdown(input); mde.setSelection([1, 2], [2, 4]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(input); }); it('should delete the row in case of empty bullet list content', () => { const input = `${stripIndent` * bullet1 * `} `; const result = `${source` * bullet1 `}\n\n`; mde.setMarkdown(input); mde.setSelection([2, 2], [2, 2]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should delete the row in case of empty bullet task list content', () => { const input = `${stripIndent` * [ ] bullet1 * [ ] `} `; const result = `${source` * [ ] bullet1 `}\n\n`; mde.setMarkdown(input); mde.setSelection([2, 5], [2, 5]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should not extend list when paragraph includes `* `', () => { const input = source` just paragraph* bullet `; mde.setMarkdown(input); mde.setSelection([1, 9], [1, 9]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(input); }); it('should delete the row in case of empty bullet list content with next content', () => { mde.setMarkdown('* bullet1\n* \nparagraph'); mde.setSelection([2, 3], [2, 3]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe('* bullet1\n\n\nparagraph'); }); it('should undo extend the bullet list properly', () => { const input = source` * bullet paragraph `; const result = oneLineTrim`
                  • bullet
                    paragraph

                  `; mde.setMarkdown(input); mde.setSelection([1, 9], [1, 9]); forceKeymapFn('listItem', 'extendList'); execUndo(); expect(getPreviewHTML()).toBe(result); }); }); describe('ordered list', () => { it('should extend the ordered list', () => { const input = source` 1. ordered `; const result = `${source` 1. ordered 2. `} `; mde.setMarkdown(input); mde.setSelection([1, 11], [1, 11]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); assertSelection([ [2, 4], [2, 4], ]); }); it('should extend the ordered list with sliced text', () => { const input = source` 1. ordered `; const result = source` 1. ord 2. ered `; mde.setMarkdown(input); mde.setSelection([1, 7], [1, 7]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); assertSelection([ [2, 4], [2, 4], ]); }); it('should reorder the list list in the middle of ordered list', () => { const input = source` 1. ordered1 2. ordered2 3. ordered3 `; const result = source` 1. ordered1 2. 3. ordered2 4. ordered3 `; mde.setMarkdown(input); mde.setSelection([1, 12], [1, 12]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should extend the nested ordered list', () => { const input = stripIndent` 1. ordered1 1. sub1 2. sub2 2. ordered2 3. ordered3 `; const result = stripIndent` 1. ordered1 1. sub1 2. 3. sub2 2. ordered2 3. ordered3 `; mde.setMarkdown(input); mde.setSelection([2, 12], [2, 12]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should extend the ordered list on ordered paragraph(not ordered list)', () => { const input = stripIndent` 1. ordered1 2. sub1 3. sub2 2. ordered2 3. ordered3 `; const result = stripIndent` 1. ordered1 2. sub1 3. 4. sub2 2. ordered2 3. ordered3 `; mde.setMarkdown(input); mde.setSelection([2, 12], [2, 12]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should extend the ordered list with task', () => { const input = source` 1. [ ] ordered `; const result = `${source` 1. [ ] ordered 2. [ ] `} `; mde.setMarkdown(input); mde.setSelection([1, 15], [1, 15]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); assertSelection([ [2, 8], [2, 8], ]); }); it('should not extend the ordered list on multi line selection', () => { const input = source` 1. ordered1 2. ordered2 `; mde.setMarkdown(input); mde.setSelection([1, 2], [2, 5]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(input); }); it('should extend the ordered list excluding blank line', () => { const input = `${source` 1. ordered1 2. ordered2 `}\n\n`; const result = `${source` 1. ordered1 2. ordered2 3. `} \n\n`; mde.setMarkdown(input); mde.setSelection([2, 12], [2, 12]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should delete the row in case of empty ordered list content', () => { const input = `${source` 1. ordered1 2. `} `; const result = `${source` 1. ordered1 `}\n\n`; mde.setMarkdown(input); mde.setSelection([2, 2], [2, 2]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should delete the row in case of empty ordered task list content', () => { const input = `${stripIndent` 1. [ ] ordered1 2. [ ] `} `; const result = `${source` 1. [ ] ordered1 `}\n\n`; mde.setMarkdown(input); mde.setSelection([2, 6], [2, 6]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); it('should extend the ordered list with below bullet list', () => { const input = source` 1. ordered1 2. ordered2 * bullet1 * bullet2 `; const result = source` 1. ordered1 2. ordered2 3. * bullet1 * bullet2 `; mde.setMarkdown(input); mde.setSelection([2, 12], [2, 12]); forceKeymapFn('listItem', 'extendList'); expect(getTextContent(mde)).toBe(result); }); }); }); describe('toggle task list keymap', () => { it('should toggle single bullet task list state', () => { const input = source` * [ ] task1 `; const result = source` * [x] task1 `; mde.setMarkdown(input); mde.setSelection([1, 6], [1, 6]); forceKeymapFn('listItem', 'toggleTask'); expect(getTextContent(mde)).toBe(result); }); it('should toggle multi bullet task list state', () => { const input = source` * [ ] task1 * [x] task2 `; const result = source` * [x] task1 * [ ] task2 `; mde.setMarkdown(input); mde.setSelection([1, 6], [2, 2]); forceKeymapFn('listItem', 'toggleTask'); expect(getTextContent(mde)).toBe(result); }); it('should toggle single ordered task list state', () => { const input = source` 1. [ ] task1 `; const result = source` 1. [x] task1 `; mde.setMarkdown(input); mde.setSelection([1, 6], [1, 6]); forceKeymapFn('listItem', 'toggleTask'); expect(getTextContent(mde)).toBe(result); }); it('should toggle multi ordered task list state', () => { const input = source` 1. [ ] task1 2. [x] task2 `; const result = source` 1. [x] task1 2. [ ] task2 `; mde.setMarkdown(input); mde.setSelection([1, 6], [2, 2]); forceKeymapFn('listItem', 'toggleTask'); expect(getTextContent(mde)).toBe(result); }); it('should toggle nested task list state', () => { const input = stripIndent` 1. [x] task1 2. [ ] task2 * [x] sub-task1 * [x] sub-task2 1. [ ] sub-task3 `; const result = stripIndent` 1. [ ] task1 2. [x] task2 * [ ] sub-task1 * [ ] sub-task2 1. [x] sub-task3 `; mde.setMarkdown(input); mde.setSelection([1, 6], [5, 6]); forceKeymapFn('listItem', 'toggleTask'); expect(getTextContent(mde)).toBe(result); }); it('should remain unchanged on non task list', () => { const input = source` 1. task1 * sub-task1 `; mde.setMarkdown(input); mde.setSelection([1, 6], [2, 2]); forceKeymapFn('listItem', 'toggleTask'); expect(getTextContent(mde)).toBe(input); }); }); describe('delete lines keymap', () => { it('should delete the single line', () => { const input = stripIndent` aaaa bbbb `; const result = '\nbbbb'; mde.setMarkdown(input); mde.setSelection([1, 1], [1, 1]); forceKeymapFn('paragraph', 'deleteLines'); expect(getTextContent(mde)).toBe(result); }); it('should delete the multi lines', () => { const input = stripIndent` aaaa bbbb cccc `; const result = '\ncccc'; mde.setMarkdown(input); mde.setSelection([1, 1], [2, 1]); forceKeymapFn('paragraph', 'deleteLines'); expect(getTextContent(mde)).toBe(result); }); }); describe('move lines keymap', () => { it('should move down the single line', () => { const input = stripIndent` aaaa bbbb cccc `; const result = stripIndent` bbbb aaaa cccc `; mde.setMarkdown(input); mde.setSelection([1, 1], [1, 1]); forceKeymapFn('paragraph', 'moveDown'); expect(getTextContent(mde)).toBe(result); }); it('should move down the multi lines', () => { const input = stripIndent` aaaa bbbb cccc `; const result = stripIndent` cccc aaaa bbbb `; mde.setMarkdown(input); mde.setSelection([1, 1], [2, 1]); forceKeymapFn('paragraph', 'moveDown'); expect(getTextContent(mde)).toBe(result); }); it('should not move lines when the selection includes last line', () => { const input = stripIndent` aaaa bbbb cccc `; const result = stripIndent` aaaa bbbb cccc `; mde.setMarkdown(input); mde.setSelection([2, 1], [3, 1]); forceKeymapFn('paragraph', 'moveDown'); expect(getTextContent(mde)).toBe(result); }); it('should move up the single line', () => { const input = stripIndent` aaaa bbbb cccc `; const result = stripIndent` bbbb aaaa cccc `; mde.setMarkdown(input); mde.setSelection([2, 1], [2, 1]); forceKeymapFn('paragraph', 'moveUp'); expect(getTextContent(mde)).toBe(result); }); it('should move up the multi lines', () => { const input = stripIndent` aaaa bbbb cccc `; const result = stripIndent` bbbb cccc aaaa `; mde.setMarkdown(input); mde.setSelection([2, 1], [3, 1]); forceKeymapFn('paragraph', 'moveUp'); expect(getTextContent(mde)).toBe(result); }); it('should not move lines when the selection includes first line', () => { const input = stripIndent` aaaa bbbb cccc `; const result = stripIndent` aaaa bbbb cccc `; mde.setMarkdown(input); mde.setSelection([1, 1], [2, 1]); forceKeymapFn('paragraph', 'moveUp'); expect(getTextContent(mde)).toBe(result); }); }); /* eslint-disable no-irregular-whitespace */ describe('keep indentation in code block', () => { it('should keep indentation in next new line', () => { const input = stripIndent` \`\`\`js console.log('line1'); console.log('line2'); \`\`\` `; const result = stripIndent` \`\`\`js console.log('line1'); console.log('line2'); \`\`\` `; mde.setMarkdown(input); mde.setSelection([3, 26], [3, 26]); forceKeymapFn('codeBlock', 'keepIndentation'); expect(getTextContent(mde)).toBe(result); }); it('should keep indentation with sliced text', () => { const input = stripIndent` \`\`\`js console.log('line1'); console.log('line2'); \`\`\` `; const result = stripIndent` \`\`\`js console.log('line1'); console.log('li ne2'); \`\`\` `; mde.setMarkdown(input); mde.setSelection([3, 20], [3, 20]); forceKeymapFn('codeBlock', 'keepIndentation'); expect(getTextContent(mde)).toBe(result); }); it('should remain unchanged on multi selection', () => { const input = stripIndent` \`\`\`js console.log('line1'); console.log('line2'); \`\`\` `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 10]); forceKeymapFn('codeBlock', 'keepIndentation'); expect(getTextContent(mde)).toBe(input); }); it('should undo extend the code block properly', () => { const input = stripIndent` \`\`\`js console.log('line1'); console.log('line2'); \`\`\` `; const result = oneLineTrim`
                          
                            console.log('line1');
                                console.log('line2');
                          
                        
                  `; mde.setMarkdown(input); mde.setSelection([3, 20], [3, 20]); forceKeymapFn('codeBlock', 'keepIndentation'); execUndo(); expect(getPreviewHTML()).toBe(result); }); }); /* eslint-enable no-irregular-whitespace */ // @TODO: should move key event test case to e2e test describe('default keymap', () => { it('should delete the blank line properly when pressing the backspace key', () => { mde.setMarkdown('# myText\n\ntest'); mde.setSelection([3, 1], [3, 1]); forceBackspaceKeymap(); expect(getPreviewHTML()).toBe('

                  myText

                  test

                  '); }); }); describe('useCommandShortcut option', () => { it('should not make keymaps with history command when the value is false', () => { const spy = jest.spyOn(keymaps, 'keymap'); const useCommandShortcut = false; const history = { 'Mod-z': undo, 'Shift-Mod-z': redo, }; mde.createKeymaps(useCommandShortcut); expect(spy).not.toHaveBeenCalledWith(history); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/mdCommand.spec.ts ================================================ import { oneLineTrim, source, stripIndent } from 'common-tags'; import { undo } from 'prosemirror-history'; import { ToastMark } from '@toast-ui/toastmark'; import MarkdownEditor from '@/markdown/mdEditor'; import MarkdownPreview from '@/markdown/mdPreview'; import EventEmitter from '@/event/eventEmitter'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import CommandManager from '@/commands/commandManager'; import { getTextContent, TestEditorWithNoneDelayHistory, removeDataAttr } from './util'; let mde: MarkdownEditor, em: EventEmitter, cmd: CommandManager, preview: MarkdownPreview; function execUndo() { const { state, dispatch } = mde.view; undo(state, dispatch); } function getPreviewHTML() { return oneLineTrim`${removeDataAttr(preview.getHTML())}`; } beforeEach(() => { em = new EventEmitter(); mde = new TestEditorWithNoneDelayHistory(em, { toastMark: new ToastMark() }); cmd = new CommandManager(em, mde.commands, {}, () => 'markdown'); const options = { linkAttributes: null, customHTMLRenderer: {}, isViewer: false, highlight: false, sanitizer: sanitizeHTML, }; preview = new MarkdownPreview(em, options); }); afterEach(() => { mde.destroy(); preview.destroy(); }); describe('bold command', () => { it('should add bold syntax', () => { mde.setMarkdown('bold'); cmd.exec('selectAll'); cmd.exec('bold'); expect(getTextContent(mde)).toBe('**bold**'); }); it('should remove bold syntax', () => { mde.setMarkdown('**bold**'); mde.setSelection([1, 3], [1, 7]); cmd.exec('bold'); expect(getTextContent(mde)).toBe('bold'); }); it('should remove bold syntax with empty text', () => { mde.setMarkdown('****'); mde.setSelection([1, 3], [1, 3]); cmd.exec('bold'); expect(getTextContent(mde)).toBe(''); }); }); describe('italic command', () => { it('should add italic syntax', () => { mde.setMarkdown('italic'); cmd.exec('selectAll'); cmd.exec('italic'); expect(getTextContent(mde)).toBe('*italic*'); }); it('should remove italic syntax', () => { mde.setMarkdown('ab*italic*cd'); mde.setSelection([1, 4], [1, 10]); cmd.exec('italic'); expect(getTextContent(mde)).toBe('abitaliccd'); }); it('should remove italic syntax with empty text', () => { mde.setMarkdown('**'); mde.setSelection([1, 2], [1, 2]); cmd.exec('italic'); expect(getTextContent(mde)).toBe(''); }); }); describe('strike command', () => { it('should add strike syntax', () => { mde.setMarkdown('strike'); cmd.exec('selectAll'); cmd.exec('strike'); expect(getTextContent(mde)).toBe('~~strike~~'); }); it('should remove strike syntax', () => { mde.setMarkdown('~~strike~~'); mde.setSelection([1, 3], [1, 9]); cmd.exec('strike'); expect(getTextContent(mde)).toBe('strike'); }); it('should remove strike syntax with empty text', () => { mde.setMarkdown('~~~~'); mde.setSelection([1, 3], [1, 3]); cmd.exec('strike'); expect(getTextContent(mde)).toBe(''); }); }); describe('code command', () => { it('should add code syntax', () => { mde.setMarkdown('code'); cmd.exec('selectAll'); cmd.exec('code'); expect(getTextContent(mde)).toBe('`code`'); }); it('should remove code syntax', () => { mde.setMarkdown('`code`'); mde.setSelection([1, 2], [1, 6]); cmd.exec('code'); expect(getTextContent(mde)).toBe('code'); }); it('should remove code syntax with empty text', () => { mde.setMarkdown('``'); mde.setSelection([1, 2], [1, 2]); cmd.exec('code'); expect(getTextContent(mde)).toBe(''); }); }); describe('blockQuote command', () => { it('should add blockQuote syntax', () => { mde.setMarkdown('blockQuote'); cmd.exec('selectAll'); cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('> blockQuote'); }); it('should add blockQuote syntax on empty node', () => { cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('> '); }); it('should remove blockQuote syntax', () => { mde.setMarkdown('> blockQuote'); cmd.exec('selectAll'); cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('blockQuote'); }); it('should add blockQuote syntax on multi line', () => { mde.setMarkdown('blockQuote\ntext'); cmd.exec('selectAll'); cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('> blockQuote\n> text'); }); it('should remove unnecessary space when adding the blockQuote syntax', () => { mde.setMarkdown(' blockQuote'); cmd.exec('selectAll'); cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('> blockQuote'); }); it('should remove unnecessary space when removing the blockQuote syntax', () => { mde.setMarkdown('> blockQuote'); cmd.exec('selectAll'); cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('blockQuote'); }); it('should select last position of the line when adding the blockQuote syntax', () => { mde.setMarkdown('\ntest'); mde.setSelection([1, 1], [1, 1]); cmd.exec('blockQuote'); expect(getTextContent(mde)).toBe('> \ntest'); expect(mde.getSelection()).toEqual([ [1, 3], [1, 3], ]); }); it('should undo blockQuote command properly', () => { const input = 'test\nparagraph'; const result = '

                  test
                  paragraph

                  '; mde.setMarkdown(input); mde.setSelection([1, 1], [1, 1]); cmd.exec('blockQuote'); execUndo(); expect(getPreviewHTML()).toBe(result); }); }); describe('hr command', () => { it('should add thematicBreak(hr) syntax', () => { cmd.exec('hr'); expect(getTextContent(mde)).toBe('\n***\n'); }); it('should split the paragraph when adding thematicBreak(hr) syntax', () => { mde.setMarkdown('paragraph'); mde.setSelection([1, 2], [1, 4]); cmd.exec('hr'); expect(getTextContent(mde)).toBe('p\n***\nagraph'); }); it('should undo hr command properly', () => { const input = 'test\nparagraph'; const result = '

                  test
                  paragraph

                  '; mde.setMarkdown(input); mde.setSelection([1, 5], [1, 5]); cmd.exec('hr'); execUndo(); expect(getPreviewHTML()).toBe(result); }); }); describe('addImage command', () => { it('should add image syntax', () => { cmd.exec('addImage', { altText: 'image', imageUrl: 'https://picsum.photos/200' }); expect(getTextContent(mde)).toBe('![image](https://picsum.photos/200)'); }); it('should escape image altText', () => { cmd.exec('addImage', { altText: 'mytext ()[]<>', imageUrl: 'https://picsum.photos/200', }); expect(getTextContent(mde)).toBe('![mytext ()\\[\\]<>](https://picsum.photos/200)'); }); it('should encode image url', () => { cmd.exec('addImage', { altText: 'image', imageUrl: 'myurl ()[]<>', }); expect(getTextContent(mde)).toBe('![image](myurl ()[]<>)'); }); it('should not decode url which is already encoded', () => { cmd.exec('addImage', { altText: 'image', imageUrl: 'https://firebasestorage.googleapis.com/images%2Fimage.png?alt=media', }); expect(getTextContent(mde)).toBe( '![image](https://firebasestorage.googleapis.com/images%2Fimage.png?alt=media)' ); }); }); describe('addLink command', () => { it('should add link syntax', () => { cmd.exec('addLink', { linkText: 'TOAST UI', linkUrl: 'https://ui.toast.com' }); expect(getTextContent(mde)).toBe('[TOAST UI](https://ui.toast.com)'); }); it('should escape link Text', () => { cmd.exec('addLink', { linkText: 'mytext ()[]<>', linkUrl: 'https://ui.toast.com', }); expect(getTextContent(mde)).toBe('[mytext ()\\[\\]<>](https://ui.toast.com)'); }); it('should not decode url which is already encoded', () => { cmd.exec('addLink', { linkText: 'TOAST UI', linkUrl: 'https://firebasestorage.googleapis.com/links%2Fimage.png?alt=media', }); expect(getTextContent(mde)).toBe( '[TOAST UI](https://firebasestorage.googleapis.com/links%2Fimage.png?alt=media)' ); }); }); describe('heading command', () => { it('should add heading syntax', () => { mde.setMarkdown('heading'); cmd.exec('heading', { level: 1 }); expect(getTextContent(mde)).toBe('# heading'); }); it('should add heading syntax on empty node', () => { cmd.exec('heading', { level: 1 }); expect(getTextContent(mde)).toBe('# '); }); it('should maintain the heading syntax on same heading level', () => { mde.setMarkdown('## heading2'); cmd.exec('selectAll'); cmd.exec('heading', { level: 2 }); expect(getTextContent(mde)).toBe('## heading2'); }); it('should change the heading syntax on different heading level', () => { mde.setMarkdown('## heading2'); cmd.exec('selectAll'); cmd.exec('heading', { level: 1 }); expect(getTextContent(mde)).toBe('# heading2'); }); it('should add heading syntax on multi line', () => { mde.setMarkdown('heading1\n# heading2'); cmd.exec('selectAll'); cmd.exec('heading', { level: 2 }); expect(getTextContent(mde)).toBe('## heading1\n## heading2'); }); it('should select last position of the line when adding the heading syntax', () => { mde.setMarkdown('\ntest'); mde.setSelection([1, 1], [1, 1]); cmd.exec('heading', { level: 1 }); expect(getTextContent(mde)).toBe('# \ntest'); expect(mde.getSelection()).toEqual([ [1, 3], [1, 3], ]); }); }); describe('codeBlock command', () => { it('should add code block syntax', () => { const result = source` \`\`\` \`\`\` `; cmd.exec('codeBlock'); expect(getTextContent(mde)).toBe(result); }); it('should wrap the selection with code block syntax', () => { const result = source` \`\`\` console.log('codeBlock'); \`\`\` `; mde.setMarkdown(`console.log('codeBlock');`); cmd.exec('selectAll'); cmd.exec('codeBlock'); expect(getTextContent(mde)).toBe(result); }); }); describe('bulletList command', () => { it('should add bullet list syntax', () => { cmd.exec('bulletList'); expect(getTextContent(mde)).toBe('* '); }); it('should add bullet list syntax to empty line', () => { mde.setMarkdown('\n'); mde.setSelection([2, 1], [2, 1]); cmd.exec('bulletList'); expect(getTextContent(mde)).toBe('\n* '); }); it('should add bullet list syntax on multi line', () => { const input = source` bullet1 bullet2 `; const result = source` * bullet1 * bullet2 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('bulletList'); expect(getTextContent(mde)).toBe(result); }); it('should change ordered list to bullet list', () => { const input = source` 1. ordered1 2. ordered2 3. ordered3 `; const result = source` * ordered1 * ordered2 * ordered3 `; mde.setMarkdown(input); mde.setSelection([2, 1], [2, 1]); cmd.exec('bulletList'); expect(getTextContent(mde)).toBe(result); }); it('should change ordered list to bullet list with depth', () => { const input = source` 1. ordered1 2. ordered2 3. ordered3 1. sub1 2. sub2 `; const result = source` * ordered1 * ordered2 * ordered3 * sub1 * sub2 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('bulletList'); expect(getTextContent(mde)).toBe(result); }); it('should undo bullet list command properly', () => { const input = source` 1. ordered1 2. ordered2 3. ordered3 1. sub1 2. sub2 `; const result = oneLineTrim`
                  1. ordered1

                  2. ordered2

                  3. ordered3

                    1. sub1

                    2. sub2

                  `; mde.setMarkdown(input); mde.setSelection([1, 2], [1, 2]); cmd.exec('bulletList'); execUndo(); expect(getPreviewHTML()).toBe(result); }); it('should add bullet list syntax to empty line after heading node', () => { mde.setMarkdown('# heading\n'); mde.setSelection([2, 1], [2, 1]); cmd.exec('bulletList'); expect(getTextContent(mde)).toBe('# heading\n* '); }); }); describe('orderedList command', () => { it('should add ordered list syntax', () => { cmd.exec('orderedList'); expect(getTextContent(mde)).toBe('1. '); }); it('should add ordered list syntax to empty line', () => { mde.setMarkdown('\n'); mde.setSelection([2, 1], [2, 1]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe('\n1. '); }); it('should add ordered list syntax on multi line', () => { const input = source` ordered1 ordered2 `; const result = source` 1. ordered1 2. ordered2 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(result); }); it('should change bullet list to ordered list', () => { const input = source` * bullet1 * bullet2 * bullet3 `; const result = source` 1. bullet1 2. bullet2 3. bullet3 `; mde.setMarkdown(input); mde.setSelection([2, 1], [2, 1]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(result); }); it('should change bullet list to ordered list with depth', () => { const input = source` * bullet1 * sub1 * sub2 * bullet2 * bullet3 `; const result = source` 1. bullet1 1. sub1 2. sub2 2. bullet2 3. bullet3 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(result); }); it('should change paragraph to ordered list with prev bullet list', () => { const input = source` * bullet1 ordered1 ordered2 `; const result = source` * bullet1 1. ordered1 2. ordered2 `; mde.setMarkdown(input); mde.setSelection([3, 2], [4, 2]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(result); }); it('should change bullet list to ordered list partially', () => { const input = source` * bullet1 * bullet2 * bullet3 * bullet4 * bullet5 `; const firstResult = source` 1. bullet1 2. bullet2 3. bullet3 * bullet4 * bullet5 `; const secondResult = source` 1. bullet1 2. bullet2 3. bullet3 1. bullet4 2. bullet5 `; mde.setMarkdown(input); mde.setSelection([1, 2], [1, 2]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(firstResult); mde.setSelection([4, 2], [4, 2]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(secondResult); }); it('should change bullet list to ordered list with extended ranges', () => { const input = source` * bullet1 * bullet2 * bullet3 * bullet4 * bullet5 * bullet6 `; const result = source` 1. bullet1 2. bullet2 3. bullet3 * bullet4 * bullet5 4. bullet6 `; mde.setMarkdown(input); mde.setSelection([1, 2], [1, 2]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe(result); }); it('should undo ordered list command properly', () => { const input = source` * bullet1 * bullet2 * bullet3 * bullet4 * bullet5 * bullet6 `; const result = oneLineTrim`
                  • bullet1

                  • bullet2

                  • bullet3

                    • bullet4

                    • bullet5

                  • bullet6

                  `; mde.setMarkdown(input); mde.setSelection([1, 2], [1, 2]); cmd.exec('orderedList'); execUndo(); expect(getPreviewHTML()).toBe(result); }); it('should add ordered list syntax to empty line after heading node', () => { mde.setMarkdown('# heading\n'); mde.setSelection([2, 1], [2, 1]); cmd.exec('orderedList'); expect(getTextContent(mde)).toBe('# heading\n1. '); }); }); describe('taskList command', () => { it('should add task list syntax', () => { cmd.exec('taskList'); expect(getTextContent(mde)).toBe('* [ ] '); }); it('should add task list syntax on multi line', () => { const input = source` task1 task2 `; const result = source` * [ ] task1 * [ ] task2 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('taskList'); expect(getTextContent(mde)).toBe(result); }); it('should add task syntax to ordered list', () => { const input = source` 1. ordered1 2. ordered2 3. ordered3 `; const result = source` 1. [ ] ordered1 2. [ ] ordered2 3. [ ] ordered3 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('taskList'); expect(getTextContent(mde)).toBe(result); }); it('should add task syntax to bullet list', () => { const input = source` * bullet1 * bullet2 * bullet3 `; const result = source` * [ ] bullet1 * [ ] bullet2 * [ ] bullet3 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('taskList'); expect(getTextContent(mde)).toBe(result); }); it('should remove task syntax on ordered task list', () => { const input = source` 1. [ ] ordered1 2. [ ] ordered2 3. [ ] ordered3 `; const result = source` 1. ordered1 2. ordered2 3. ordered3 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('taskList'); expect(getTextContent(mde)).toBe(result); }); it('should remove task syntax on bullet task list', () => { const input = source` * [ ] bullet1 * [ ] bullet2 * [ ] bullet3 `; const result = source` * bullet1 * bullet2 * bullet3 `; mde.setMarkdown(input); cmd.exec('selectAll'); cmd.exec('taskList'); expect(getTextContent(mde)).toBe(result); }); }); describe('addTable command', () => { it('should add table syntax', () => { const result = `\n${source` | | | | --- | --- | | | | | | | `}`; cmd.exec('addTable', { columnCount: 2, rowCount: 3 }); expect(getTextContent(mde)).toBe(result); }); it('should add table syntax to next line', () => { const result = source` text | | | | --- | --- | | | | | | | `; mde.setMarkdown('text'); cmd.exec('selectAll'); cmd.exec('addTable', { columnCount: 2, rowCount: 3 }); expect(getTextContent(mde)).toBe(result); }); it('should undo table command properly', () => { mde.setMarkdown('text'); cmd.exec('selectAll'); cmd.exec('addTable', { columnCount: 2, rowCount: 3 }); execUndo(); expect(getPreviewHTML()).toBe('

                  text

                  '); }); }); describe('indent command', () => { it('should not operate if not a list', () => { mde.setMarkdown('text'); mde.setSelection([1, 3], [1, 3]); cmd.exec('indent'); expect(getTextContent(mde)).toBe('text'); }); it('should add soft-tab indentation to first offset on multi line selection', () => { const input = source` * line1 * line2 * line3 * line4 `; const result = stripIndent` * line1 * line2 * line3 * line4 `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 2]); cmd.exec('indent'); expect(getTextContent(mde)).toBe(result); }); it('should undo indent command properly', () => { const input = source` * line1 * line2 * line3 * line4 `; const result = oneLineTrim`
                  • line1

                  • line2

                  • line3

                  • line4

                  `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 2]); cmd.exec('indent'); execUndo(); expect(getPreviewHTML()).toBe(result); }); describe('ordered list', () => { it('should reorder ordered list after adding soft-tab indentation based on caret position', () => { const input = source` 1. line1 2. line2 3. line3 4. line4 `; const result = stripIndent` 1. line1 1. line2 2. line3 3. line4 `; mde.setMarkdown(input); mde.setSelection([2, 1], [2, 1]); cmd.exec('indent'); expect(getTextContent(mde)).toBe(result); }); it('should reorder ordered list after adding soft-tab indentation based on multi line selection', () => { const input = source` 1. line1 2. line2 3. line3 4. line4 `; const result = stripIndent` 1. line1 1. line2 2. line3 2. line4 `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 2]); cmd.exec('indent'); expect(getTextContent(mde)).toBe(result); }); it('should reorder ordered list with empty list item', () => { const input = source` 1. line1 2. line2 3. 4. line4 `; const result = stripIndent` 1. line1 2. line2 1. 3. line4 `; mde.setMarkdown(input); mde.setSelection([3, 2], [3, 3]); cmd.exec('indent'); expect(getTextContent(mde)).toBe(result); }); it('should change ordered list to paragraph properly', () => { const input = stripIndent` 1. ordered1 2. ordered2 * sub1 * sub2 1. sub-ordered1 2. sub-ordered1 3. sub-ordered1 `; const result = stripIndent` 1. ordered1 2. ordered2 * sub1 * sub2 1. sub-ordered1 2. sub-ordered1 3. sub-ordered1 `; mde.setMarkdown(input); mde.setSelection([5, 10], [5, 10]); cmd.exec('indent'); expect(getTextContent(mde)).toBe(result); }); }); }); describe('outdent command', () => { it('should not operate if not a list', () => { mde.setMarkdown(' text'); mde.setSelection([1, 5], [1, 5]); cmd.exec('outdent'); expect(getTextContent(mde)).toBe(' text'); }); it('should remove soft-tab indentation from first offset on multi line selection', () => { const input = stripIndent` * line1 * line2 * line3 * line4 `; const result = source` * line1 * line2 * line3 * line4 `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 2]); cmd.exec('outdent'); expect(getTextContent(mde)).toBe(result); }); it('should undo outdent command properly', () => { const input = stripIndent` * line1 * line2 * line3 * line4 `; const result = oneLineTrim`
                  • line1

                    • line2

                    • line3

                  • line4

                  `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 2]); cmd.exec('outdent'); execUndo(); expect(getPreviewHTML()).toBe(result); }); describe('ordered list', () => { it('should reorder ordered list after removing soft-tab indentation based on caret position', () => { const input = stripIndent` 1. line1 1. line2 2. line3 3. line4 `; const result = source` 1. line1 2. line2 3. line3 4. line4 `; mde.setMarkdown(input); mde.setSelection([2, 1], [2, 1]); cmd.exec('outdent'); expect(getTextContent(mde)).toBe(result); }); it('should reorder ordered list after removing soft-tab indentation based on multi line selection', () => { const input = stripIndent` 1. line1 1. line2 2. line3 2. line4 `; const result = source` 1. line1 2. line2 3. line3 4. line4 `; mde.setMarkdown(input); mde.setSelection([2, 3], [3, 2]); cmd.exec('outdent'); expect(getTextContent(mde)).toBe(result); }); it('should reorder ordered list with empty list item', () => { const input = stripIndent` 1. line1 2. line2 1. 3. line4 `; const result = source` 1. line1 2. line2 3. 4. line4 `; mde.setMarkdown(input); mde.setSelection([3, 2], [3, 3]); cmd.exec('outdent'); expect(getTextContent(mde)).toBe(result); }); it('should not throw error on line which has no indentation', () => { const result = stripIndent` 1. line1 2. line2 3. line3 4. line4 `; mde.setMarkdown(result); mde.setSelection([1, 2], [3, 3]); cmd.exec('outdent'); expect(getTextContent(mde)).toBe(result); }); }); }); describe('customBlock command', () => { it('should add custom block syntax', () => { const result = source` $$myCustom $$ `; cmd.exec('customBlock', { info: 'myCustom' }); expect(getTextContent(mde)).toBe(result); }); it('should wrap the selection with custom block syntax', () => { const result = source` $$myCustom console.log('customBlock'); $$ `; mde.setMarkdown(`console.log('customBlock');`); cmd.exec('selectAll'); cmd.exec('customBlock', { info: 'myCustom' }); expect(getTextContent(mde)).toBe(result); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/mdEditor.spec.ts ================================================ import { ToastMark } from '@toast-ui/toastmark'; import MarkdownEditor from '@/markdown/mdEditor'; import EventEmitter from '@/event/eventEmitter'; import { getTextContent } from './util'; function getSelectedText() { return document.getSelection()!.toString(); } function getEditorHTML(editor: MarkdownEditor) { return editor.view.dom.innerHTML; } jest.useFakeTimers(); describe('MarkdownEditor', () => { let mde: MarkdownEditor, em: EventEmitter, el: HTMLElement; beforeEach(() => { em = new EventEmitter(); mde = new MarkdownEditor(em, { toastMark: new ToastMark() }); el = mde.el; document.body.appendChild(el); }); afterEach(() => { jest.clearAllTimers(); mde.destroy(); document.body.removeChild(el); }); it('should emit updatePreview event when editing the content', () => { const spy = jest.fn(); em.listen('updatePreview', spy); mde.setMarkdown('# myText'); expect(spy).toHaveBeenCalled(); }); it('setMarkdown API', () => { mde.setMarkdown('# myText'); expect(getTextContent(mde)).toBe('# myText'); }); it('getMarkdown API', () => { mde.setMarkdown('# myText'); const markdown = mde.getMarkdown(); expect(markdown).toBe('# myText'); }); it('setSelection API', () => { mde.setMarkdown('# myText'); mde.setSelection([1, 1], [1, 2]); // run setTimeout function when focusing the editor jest.runAllTimers(); expect(getSelectedText()).toBe('#'); }); it('getSelection API', () => { mde.setMarkdown('# myText'); mde.setSelection([1, 1], [1, 2]); const selection = mde.getSelection(); expect(selection).toEqual([ [1, 1], [1, 2], ]); }); it('setPlaceholder API', () => { mde.setPlaceholder('Write something'); expect(getEditorHTML(mde)).toContain( 'Write something' ); }); it('replaceSelection API', () => { mde.setMarkdown('# myText'); mde.setSelection([1, 1], [1, 2]); mde.replaceSelection('# newText\n#newLine'); expect(getTextContent(mde)).toBe('# newText\n#newLine myText'); }); it('focus API', () => { mde.focus(); // run setTimeout function when focusing the editor jest.runAllTimers(); expect(document.activeElement).toEqual(mde.view.dom); }); it('blur API', () => { mde.focus(); mde.blur(); expect(document.activeElement).not.toEqual(mde.view.dom); }); it('setHeight API', () => { mde.setHeight(100); const { height } = mde.el.style; expect(height).toBe('100px'); }); it('setMinHeight API', () => { mde.setMinHeight(100); const { minHeight } = mde.el.style; expect(minHeight).toBe('100px'); }); it('addWidget API', () => { const ul = document.createElement('ul'); ul.innerHTML = `
                • Ryu
                • Lee
                • `; mde.addWidget(ul, 'top'); expect(document.body).toContainElement(ul); mde.blur(); expect(document.body).not.toContainElement(ul); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/mdPreview.spec.ts ================================================ import { MdPos, ToastMark } from '@toast-ui/toastmark'; import MarkdownPreview, { CLASS_HIGHLIGHT } from '@/markdown/mdPreview'; import MarkdownEditor from '@/markdown/mdEditor'; import EventEmitter from '@/event/eventEmitter'; import * as sanitizer from '@/sanitizer/htmlSanitizer'; import { createHTMLrenderer, removeDataAttr } from './util'; function getHTML(preview: MarkdownPreview) { return removeDataAttr(preview.getHTML()); } jest.useFakeTimers(); describe('Preview', () => { let eventEmitter: EventEmitter, preview: MarkdownPreview; beforeEach(() => { jest.spyOn(sanitizer, 'sanitizeHTML'); const options = { linkAttributes: null, customHTMLRenderer: {}, isViewer: false, highlight: true, sanitizer: sanitizer.sanitizeHTML, }; eventEmitter = new EventEmitter(); preview = new MarkdownPreview(eventEmitter, options); }); afterEach(() => { jest.restoreAllMocks(); preview.destroy(); }); it('listen to updatePreview and update the preview', () => { const doc = new ToastMark(); const editResult = doc.editMarkdown([1, 7], [1, 7], 'changed'); eventEmitter.emit('updatePreview', editResult); expect(getHTML(preview)).toBe('

                  changed

                  '); }); it('should call sanitizeHTML', () => { const doc = new ToastMark(); const editResult = doc.editMarkdown( [1, 1], [1, 1], `` ); eventEmitter.emit('updatePreview', editResult); expect(sanitizer.sanitizeHTML).toHaveBeenCalledTimes(1); }); }); describe('preview highlight', () => { let eventEmitter: EventEmitter, preview: MarkdownPreview, editor: MarkdownEditor, editorEl: HTMLElement; function init(highlight: boolean) { const options = { linkAttributes: null, customHTMLRenderer: {}, isViewer: false, highlight, sanitizer: sanitizer.sanitizeHTML, }; eventEmitter = new EventEmitter(); editor = new MarkdownEditor(eventEmitter, { toastMark: new ToastMark() }); preview = new MarkdownPreview(eventEmitter, options); editorEl = editor.getElement(); document.body.appendChild(editorEl); document.body.appendChild(preview.getElement()!); } function setMarkdown(markdown: string) { editor.setMarkdown(markdown); } function setCursor(caret: MdPos) { editor.setSelection(caret, caret); } function blur() { editor.blur(); } function getHighlightedCount() { return preview.el!.querySelectorAll(`.${CLASS_HIGHLIGHT}`).length; } function assertHighlighted(tagName: string, html: string) { const el = preview.el!.querySelector(`.${CLASS_HIGHLIGHT}`)!; expect(el.tagName).toBe(tagName); expect(el.innerHTML).toBe(html); } afterEach(() => { jest.clearAllTimers(); document.body.removeChild(editorEl); editor.destroy(); preview.destroy(); }); it('highlighted element should be one', () => { init(true); setMarkdown('# Hello\n\nWorld'); setCursor([1, 1]); expect(getHighlightedCount()).toBe(1); assertHighlighted('H1', 'Hello'); setCursor([3, 1]); expect(getHighlightedCount()).toBe(1); assertHighlighted('P', 'World'); }); it('highlighted element is not displayed when highlight option is false', () => { init(false); setMarkdown('# Hello\n\nWorld'); setCursor([1, 1]); expect(getHighlightedCount()).toBe(0); setCursor([3, 1]); expect(getHighlightedCount()).toBe(0); }); it('paragraph inside tight list item should not be removed', () => { init(true); setMarkdown('- Item1\n- Item2'); setCursor([1, 4]); expect(assertHighlighted('P', 'Item1')); setCursor([2, 4]); expect(assertHighlighted('P', 'Item2')); }); describe('table cell', () => { beforeEach(() => { init(true); setMarkdown('| a | b |\n| - | - |\n| c | d |\n\n'); }); it('whitespace and delimiter should be considered as a table cell', () => { setCursor([1, 2]); assertHighlighted('TH', 'a'); setCursor([1, 5]); assertHighlighted('TH', 'a'); setCursor([1, 6]); assertHighlighted('TH', 'b'); setCursor([1, 8]); assertHighlighted('TH', 'b'); setCursor([3, 1]); assertHighlighted('TD', 'c'); setCursor([3, 5]); assertHighlighted('TD', 'c'); setCursor([3, 6]); assertHighlighted('TD', 'd'); setCursor([3, 8]); assertHighlighted('TD', 'd'); }); it('delimiter row should not highlight any element', () => { setCursor([2, 2]); expect(getHighlightedCount()).toBe(0); setCursor([2, 4]); expect(getHighlightedCount()).toBe(0); setCursor([2, 6]); expect(getHighlightedCount()).toBe(0); }); it('empty line next to table should not highlight any element ', () => { setCursor([4, 1]); expect(getHighlightedCount()).toBe(0); }); }); it('the highlighted element disappears when blur event is triggered', () => { init(true); setMarkdown('# Heading'); setCursor([1, 1]); // run setTimeout function when focusing the editor jest.runAllTimers(); expect(getHighlightedCount()).toBe(1); blur(); expect(getHighlightedCount()).toBe(0); }); }); describe('Preview with html renderer', () => { let eventEmitter: EventEmitter, preview: MarkdownPreview; function createPreviewWithHTMLRenderer() { const options = { linkAttributes: null, customHTMLRenderer: createHTMLrenderer(), isViewer: false, highlight: true, sanitizer: sanitizer.sanitizeHTML, }; sanitizer.registerTagWhitelistIfPossible('iframe'); eventEmitter = new EventEmitter(); preview = new MarkdownPreview(eventEmitter, options); } beforeEach(() => { createPreviewWithHTMLRenderer(); }); it('should render iframe node to preview ignoring sanitizer tag', () => { const doc = new ToastMark(); const editResult = doc.editMarkdown( [1, 1], [1, 1], '' ); eventEmitter.emit('updatePreview', editResult); expect(getHTML(preview)).toBe( '' ); }); it('should render html inline node', () => { const doc = new ToastMark(); const editResult = doc.editMarkdown([1, 1], [1, 1], 'content'); eventEmitter.emit('updatePreview', editResult); expect(getHTML(preview)).toBe('

                  content

                  '); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/smartTask.spec.ts ================================================ import { ToastMark } from '@toast-ui/toastmark'; import MarkdownEditor from '@/markdown/mdEditor'; import EventEmitter from '@/event/eventEmitter'; import { getTextContent } from './util'; let mde: MarkdownEditor, em: EventEmitter; function dispatchKeyup() { const event = new KeyboardEvent('keyup', { key: 'backspace', bubbles: true, cancelable: true, }); mde.view.dom.dispatchEvent(event); } beforeEach(() => { em = new EventEmitter(); mde = new MarkdownEditor(em, { toastMark: new ToastMark() }); }); afterEach(() => { mde.destroy(); }); describe('smart task', () => { it('should add space between task brackets when collapsed', () => { mde.setMarkdown('* [] aaa'); mde.setSelection([1, 4], [1, 4]); dispatchKeyup(); expect(getTextContent(mde)).toBe('* [ ] aaa'); }); it('should remove spaces between task brackets when unnecessary spaces are included', () => { mde.setMarkdown('* [ x ] aaa'); mde.setSelection([1, 4], [1, 4]); dispatchKeyup(); expect(getTextContent(mde)).toBe('* [x] aaa'); }); it('should not emit script error and apply smart task when cursor position is not in the task list', () => { mde.setMarkdown('* *aaa*'); mde.setSelection([1, 4], [1, 4]); dispatchKeyup(); expect(getTextContent(mde)).toBe('* *aaa*'); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/syntaxHighlight.spec.ts ================================================ import { ToastMark } from '@toast-ui/toastmark'; import MarkdownEditor from '@/markdown/mdEditor'; import EventEmitter from '@/event/eventEmitter'; import { source } from 'common-tags'; function getEditorHTML(editor: MarkdownEditor) { return editor.view.dom.innerHTML; } let mde: MarkdownEditor, em: EventEmitter; beforeEach(() => { em = new EventEmitter(); mde = new MarkdownEditor(em, { toastMark: new ToastMark() }); }); afterEach(() => { mde.destroy(); }); describe('markdown editor syntax highlight', () => { it('atx heading', () => { mde.setMarkdown('# heading'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('seText heading', () => { mde.setMarkdown('heading\n---'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); describe('blockQuote', () => { it('basic', () => { mde.setMarkdown('> block quote'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('with list', () => { mde.setMarkdown('> * [ ] block quote'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); }); it('bulletList', () => { mde.setMarkdown('* bullet list'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('orderedList', () => { mde.setMarkdown('1. ordered list'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('tastkList', () => { mde.setMarkdown('* [x] task list'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); describe('table', () => { it('basic', () => { const input = source` | col2 | col2 | --- | --- | data1 | data2 | `; mde.setMarkdown(input); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('with mark', () => { const input = source` | col2 | col2 | --- | --- | data1 | **data2** | `; mde.setMarkdown(input); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); }); it('thematicBreak', () => { mde.setMarkdown('---'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('emph', () => { mde.setMarkdown('*emph*'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('strong', () => { mde.setMarkdown('**strong**'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('strike', () => { mde.setMarkdown('~~strike~~'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('inline code', () => { mde.setMarkdown('`inline code`'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('link', () => { mde.setMarkdown('[TOAST UI](https://ui.toast.com)'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('image', () => { mde.setMarkdown('![Logo](https://picsum.photos/200)'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('code block', () => { mde.setMarkdown('```js\nconsole.log("editor")\n```'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('code block within list', () => { mde.setMarkdown('* list\n ```js\n console.log("editor")\n ```'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); it('custom block', () => { mde.setMarkdown('$$custom\nmy custom element\n$$'); const html = getEditorHTML(mde); expect(html).toMatchSnapshot(); }); }); ================================================ FILE: apps/editor/src/__test__/unit/markdown/util.ts ================================================ import { HTMLConvertorMap } from '@toast-ui/toastmark'; import { history } from 'prosemirror-history'; import MarkdownEditor from '@/markdown/mdEditor'; export function getTextContent(editor: MarkdownEditor) { const { doc } = editor.view.state; const docSize = doc.content.size; let text = ''; doc.nodesBetween(0, docSize, (node, pos) => { if (node.isText) { text += node.text!.slice(Math.max(0, pos) - pos, docSize - pos); } else if (node.isBlock && pos > 0) { text += '\n'; } }); return text; } export function removeDataAttr(html: string) { return html.replace(/\sdata-nodeid="\d{1,}"/g, '').trim(); } export function createHTMLrenderer() { const customHTMLRenderer: HTMLConvertorMap = { htmlBlock: { // @ts-ignore iframe(node: MdLikeNode) { return [ { type: 'openTag', tagName: 'iframe', outerNewLine: true, attributes: node.attrs }, { type: 'html', content: node.childrenHTML }, { type: 'closeTag', tagName: 'iframe', outerNewLine: true }, ]; }, }, htmlInline: { // @ts-ignore big(node: MdLikeNode, { entering }: Context) { return entering ? { type: 'openTag', tagName: 'big', attributes: node.attrs } : { type: 'closeTag', tagName: 'big' }; }, }, }; return customHTMLRenderer; } export class TestEditorWithNoneDelayHistory extends MarkdownEditor { get defaultPlugins() { return [...this.keymaps, history({ newGroupDelay: -1 })]; } } ================================================ FILE: apps/editor/src/__test__/unit/sanitizer.spec.ts ================================================ import { registerTagWhitelistIfPossible, sanitizeHTML } from '@/sanitizer/htmlSanitizer'; describe('sanitizeHTML', () => { it('removes unnecessary tags', () => { expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('child die')).toBe('child die'); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); }); describe('attributes', () => { describe('removes attributes with invalid value including xss script', () => { it('table', () => { expect(sanitizeHTML(`
                  `)).toBe( '
                  ' ); expect(sanitizeHTML(``)).toBe( '
                  ' ); }); it('href attribute with a tag', () => { expect(sanitizeHTML('xss')).toBe('xss'); expect(sanitizeHTML('xss')).toBe('xss'); expect(sanitizeHTML('xss')).toBe('xss'); expect(sanitizeHTML('xss')).toBe('xss'); expect(sanitizeHTML('xss')).toBe('xss'); expect(sanitizeHTML('xss')).toBe('xss'); expect(sanitizeHTML(`123xss`)).toBe('123xss'); expect(sanitizeHTML(`xss`)).toBe('xss'); }); it('src attribute with img tag', () => { expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); }); it('src and onerror attribute with img tag', () => { expect( sanitizeHTML('') ).toBe(''); expect(sanitizeHTML('">')).toBe('">'); expect(sanitizeHTML('0')).toBe( '0' ); }); it('should remove onload attribute in svg', () => { expect(sanitizeHTML(' ')).toBe( ' ' ); expect(sanitizeHTML(' ')).toBe( ' ' ); expect(sanitizeHTML(' ')).toBe( ' ' ); expect(sanitizeHTML(` `)).toBe( ' ' ); expect(sanitizeHTML('')).toBe( '' ); expect(sanitizeHTML('')).toBe( '' ); expect(sanitizeHTML('

                  ')).toBe( '

                  ' ); }); it('should remove tag and href attribute in svg', () => { expect( sanitizeHTML( '' ) ).toBe(''); expect( sanitizeHTML( `` ) ).toBe(''); }); it('should remove ontoggle attribute in details', () => { expect(sanitizeHTML('
                  ')).toBe( '
                  ' ); }); }); describe('registerTagWhitelistIfPossible', () => { it('if possible, should keep the tags when registered in the white tag list', () => { registerTagWhitelistIfPossible('embed'); registerTagWhitelistIfPossible('iframe'); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe( '' ); }); it('should remove the tags in case that the tag name cannot be white list', () => { registerTagWhitelistIfPossible('sript'); registerTagWhitelistIfPossible('input'); expect(sanitizeHTML('')).toBe(''); expect(sanitizeHTML('')).toBe(''); }); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/vdom/template.spec.ts ================================================ import { VNode } from '@t/ui'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; class TestComponent extends Component { render() { return html`
                  test
                  `; } } describe('lit-html syntax', () => { it('should be converted as vnode', () => { const style = { position: 'absolute', top: 10, marginLeft: 10 }; const expected = { type: 'div', props: { class: 'my-class', style: { position: 'absolute', top: 10, marginLeft: 10 }, }, children: [ { type: 'TEXT_NODE', props: { nodeValue: 'test' }, children: [], }, ], }; const vnode = html`
                  test
                  ` as VNode; expect(vnode).toMatchObject(expected); }); it('should be converted with children array as vnode', () => { const expected = { type: 'div', props: { class: 'my-class', }, children: [ { type: 'span', props: {}, children: [ { type: 'TEXT_NODE', props: { nodeValue: '1' }, children: [], }, ], }, { type: 'span', props: {}, children: [ { type: 'TEXT_NODE', props: { nodeValue: '2' }, children: [], }, ], }, { type: 'span', props: {}, children: [ { type: 'TEXT_NODE', props: { nodeValue: '3' }, children: [], }, ], }, ], }; const vnode = html`
                  ${[1, 2, 3].map((num) => html`${num}`)}
                  `; expect(vnode).toMatchObject(expected); }); it('should be not converted with null, undefined, false value', () => { const expected = { type: 'div', props: { class: 'my-class', }, children: [ { type: 'TEXT_NODE', props: { nodeValue: 'test' }, children: [], }, ], }; const vnode = html`
                  ${null && html`123`} ${ // eslint-disable-next-line no-undefined undefined && html`123` } ${false && html`123`}test
                  ` as VNode; expect(vnode).toMatchObject(expected); }); it('should be converted with Component as vnode', () => { const expected = { type: TestComponent, props: { class: 'my-comp', 'data-id': 'my-comp', }, children: [], }; const vnode = html`<${TestComponent} class="my-comp" data-id="my-comp" />` as VNode; expect(vnode).toMatchObject(expected); }); }); ================================================ FILE: apps/editor/src/__test__/unit/viewer.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Viewer from '@/viewer'; import { createHTMLrenderer, removeDataAttr } from './markdown/util'; describe('Viewer', () => { let viewer: Viewer, container: HTMLElement; function getViewerHTML() { return oneLineTrim`${removeDataAttr( container.querySelector('.toastui-editor-contents')!.innerHTML )}`; } beforeEach(() => { container = document.createElement('div'); viewer = new Viewer({ el: container, extendedAutolinks: true, frontMatter: true, initialValue: '# test\n* list1\n* list2', customHTMLRenderer: createHTMLrenderer(), }); document.body.appendChild(container); }); afterEach(() => { viewer.destroy(); document.body.removeChild(container); }); it('should render properly', () => { const expected = oneLineTrim`

                  test

                  • list1

                  • list2

                  `; expect(getViewerHTML()).toBe(expected); }); it('should update preview by setMarkdown API', () => { viewer.setMarkdown('> block quote\n# heading *emph*'); const expected = oneLineTrim`

                  block quote

                  heading emph

                  `; expect(getViewerHTML()).toBe(expected); }); it('should render htmlBlock properly', () => { viewer.setMarkdown( '' ); const expected = ''; expect(getViewerHTML()).toBe(expected); }); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/customBlock.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import { HTMLConvertorMap } from '@toast-ui/toastmark'; import { ToDOMAdaptor } from '@t/convertor'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import WysiwygEditor from '@/wysiwyg/wwEditor'; import EventEmitter from '@/event/eventEmitter'; let wwe: WysiwygEditor, em: EventEmitter, toDOMAdaptor: ToDOMAdaptor; function createCustomBlockNode() { const customBlock = wwe.schema.nodes.customBlock.create( { info: 'myCustom' }, wwe.schema.text('myCustom Node!!') ); const doc = wwe.schema.nodes.doc.create(null, customBlock); return doc; } beforeEach(() => { const convertors: HTMLConvertorMap = { myCustom(node) { const span = document.createElement('span'); span.innerHTML = node.literal!; return [ { type: 'openTag', tagName: 'div', attributes: { 'data-custom': 'myCustom' } }, { type: 'html', content: span.outerHTML }, { type: 'closeTag', tagName: 'div' }, ]; }, }; toDOMAdaptor = new WwToDOMAdaptor({}, convertors); em = new EventEmitter(); wwe = new WysiwygEditor(em, { toDOMAdaptor }); wwe.setModel(createCustomBlockNode()); }); afterEach(() => { wwe.destroy(); }); it('custom block node should be rendered in wysiwyg editor properly', () => { const expected = oneLineTrim`
                  myCustom Node!!
                  `; expect(wwe.getHTML()).toContain(expected); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/helper/pasteMsoList.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import { convertMsoParagraphsToList } from '@/wysiwyg/clipboard/pasteMsoList'; describe('pasteMsoList helper', () => { let container: HTMLElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { container.parentNode!.removeChild(container); }); describe('convertMsoParagraphsToList() convert paragraphs copied from ms office ', () => { it('bullet list', () => { const inputHTML = oneLineTrim`

                  l   foo

                  l   bar

                  `; const result = convertMsoParagraphsToList(inputHTML); const expected = oneLineTrim`

                  • foo
                  • bar
                  `; expect(result).toBe(expected); }); it('ordered list', () => { const inputHTML = oneLineTrim`

                  1.      

                  2.      

                  `; const result = convertMsoParagraphsToList(inputHTML); const expected = oneLineTrim`

                  `; expect(result).toBe(expected); }); it('nested list', () => { const inputHTML = oneLineTrim`

                  l   foo

                  1.       가나다

                  `; const result = convertMsoParagraphsToList(inputHTML); const expected = oneLineTrim`

                  • foo
                    1. 가나다
                  `; expect(result).toBe(expected); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/keymap.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import { DOMParser } from 'prosemirror-model'; import { chainCommands, deleteSelection, joinBackward, selectNodeBackward, } from 'prosemirror-commands'; import WysiwygEditor from '@/wysiwyg/wwEditor'; import EventEmitter from '@/event/eventEmitter'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import CellSelection from '@/wysiwyg/plugins/selection/cellSelection'; import { cls } from '@/utils/dom'; const CELL_SELECTION_CLS = cls('cell-selected'); const CODE_BLOCK_CLS = cls('ww-code-block'); describe('keymap', () => { let wwe: WysiwygEditor, em: EventEmitter; let html; function setContent(content: string) { const wrapper = document.createElement('div'); wrapper.innerHTML = content; const nodes = DOMParser.fromSchema(wwe.schema).parse(wrapper); wwe.setModel(nodes); } function forceKeymapFn(type: string, methodName: string, args: any[] = []) { const { specs, view } = wwe; // @ts-ignore const [keymapFn] = specs.specs.filter((spec) => spec.name === type); // @ts-ignore keymapFn[methodName](...args)(view.state, view.dispatch); } function selectCells(from: number, to: number) { const { state, dispatch } = wwe.view; const { doc, tr } = state; const startCellPos = doc.resolve(from); const endCellPos = doc.resolve(to); const selection = new CellSelection(startCellPos, endCellPos); dispatch!(tr.setSelection(selection)); } beforeEach(() => { const toDOMAdaptor = new WwToDOMAdaptor({}, {}); em = new EventEmitter(); wwe = new WysiwygEditor(em, { toDOMAdaptor }); }); afterEach(() => { wwe.destroy(); }); describe('table', () => { beforeEach(() => { html = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  `; setContent(html); }); describe('moveToCell keymap with right (tab key)', () => { it('should move to start of right cell', () => { wwe.setSelection(7, 7); // in 'foo' cell forceKeymapFn('table', 'moveToCell', ['right']); expect(wwe.getSelection()).toEqual([12, 12]); }); it('should move to first cell of next line', () => { wwe.setSelection(13, 13); // in 'bar' cell forceKeymapFn('table', 'moveToCell', ['right']); expect(wwe.getSelection()).toEqual([23, 23]); }); }); describe('moveToCell keymap with left (shift + tab key)', () => { it('should move to end of left cell', () => { wwe.setSelection(13, 13); // in 'bar' cell forceKeymapFn('table', 'moveToCell', ['left']); expect(wwe.getSelection()).toEqual([8, 8]); }); it('should move to last cell of previous line', () => { wwe.setSelection(24, 24); // in 'baz' cell forceKeymapFn('table', 'moveToCell', ['left']); expect(wwe.getSelection()).toEqual([15, 15]); }); }); describe('moveInCell keymap with up', () => { it('should move to end of up cell', () => { wwe.setSelection(26, 26); // in 'baz' cell forceKeymapFn('table', 'moveInCell', ['up']); expect(wwe.getSelection()).toEqual([8, 8]); }); it('should add paragraph when there is no content before table and cursor is in first row', () => { wwe.setSelection(13, 13); // in 'bar' cell forceKeymapFn('table', 'moveInCell', ['up']); const expected = oneLineTrim`


                  foo

                  bar

                  baz

                  qux

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should move to before table content when cursor is in first row', () => { html = oneLineTrim`

                  before

                  foo

                  bar

                  baz

                  qux

                  `; setContent(html); wwe.setSelection(15, 15); // in 'foo' cell forceKeymapFn('table', 'moveInCell', ['up']); expect(wwe.getSelection()).toEqual([7, 7]); // 'before' paragraph }); }); describe('moveInCell keymap with down', () => { it('should move to start of down cell', () => { wwe.setSelection(7, 7); // in 'foo' cell forceKeymapFn('table', 'moveInCell', ['down']); expect(wwe.getSelection()).toEqual([23, 23]); }); it('should add paragraph when there is no content after table and cursor is in last row', () => { wwe.setSelection(26, 26); // in 'baz' cell forceKeymapFn('table', 'moveInCell', ['down']); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should move to after table content when cursor is in last row', () => { html = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  after

                  `; setContent(html); wwe.setSelection(32, 32); // in 'qux' cell forceKeymapFn('table', 'moveInCell', ['down']); expect(wwe.getSelection()).toEqual([39, 39]); // 'after' paragraph }); }); describe('moveInCell keymap with left and right', () => { let expected: string; beforeEach(() => { expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  `; }); it('should select table when cursor is in start of first cell', () => { wwe.setSelection(5, 5); // in 'foo' cell forceKeymapFn('table', 'moveInCell', ['left']); expect(wwe.getHTML()).toBe(expected); }); it('should select table when cursor is in end of last cell', () => { wwe.setSelection(33, 33); // in 'qux' cell forceKeymapFn('table', 'moveInCell', ['right']); expect(wwe.getHTML()).toBe(expected); }); }); it('deleteCells keymap should delete cells in selection', () => { selectCells(3, 28); forceKeymapFn('table', 'deleteCells'); const expected = oneLineTrim`





                  `; expect(wwe.getHTML()).toBe(expected); }); describe('exitTable keymap', () => { it('should exit the table node and add paragraph', () => { wwe.setSelection(5, 5); // in 'foo' cell forceKeymapFn('table', 'exitTable'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux


                  `; expect(wwe.getHTML()).toBe(expected); expect(wwe.getSelection()).toEqual([39, 39]); // in added paragraph }); }); }); describe('table with list and multiple lines', () => { beforeEach(() => { html = oneLineTrim`

                  foo

                  bar

                  • baz

                  • qux

                  • quux

                    • quuz

                  corge

                  `; setContent(html); }); describe('moveInCell keymap with up', () => { it('should move from first paragraph to end list item of up cell', () => { wwe.setSelection(65, 65); // in 'corge' cell forceKeymapFn('table', 'moveInCell', ['up']); expect(wwe.getSelection()).toEqual([55, 55]); // in 'quux' }); it('should move from first list item to end list item of up cell', () => { wwe.setSelection(44, 44); // in 'quux' cell forceKeymapFn('table', 'moveInCell', ['up']); expect(wwe.getSelection()).toEqual([33, 33]); // in 'qux' }); }); describe('moveInCell keymap with down', () => { it('should move from last paragraph to start list item of down cell', () => { wwe.setSelection(10, 10); // in 'bar' forceKeymapFn('table', 'moveInCell', ['down']); expect(wwe.getSelection()).toEqual([23, 23]); // in 'baz' }); it('should move from last list item to start list item of down cell', () => { wwe.setSelection(30, 30); // in 'qux' forceKeymapFn('table', 'moveInCell', ['down']); expect(wwe.getSelection()).toEqual([43, 43]); // in 'quux' }); }); }); describe('code block', () => { beforeEach(() => { html = oneLineTrim`
                              foo\nbar\nbaz
                            
                  `; setContent(html); }); describe('moveCursor keymap with up', () => { it('should add paragraph when there is no content before code block and cursor is in first line', () => { wwe.setSelection(4, 4); // in 'foo' text forceKeymapFn('codeBlock', 'moveCursor', ['up']); const expected = oneLineTrim`


                                foo\nbar\nbaz
                              
                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('moveCursor keymap with down', () => { it('should add paragraph when there is no content after code block and cursor is in last line', () => { wwe.setSelection(10, 10); // in 'baz' text forceKeymapFn('codeBlock', 'moveCursor', ['down']); const expected = oneLineTrim`
                                foo\nbar\nbaz
                              


                  `; expect(wwe.getHTML()).toBe(expected); }); }); }); describe('list item', () => { function forceBackspaceKeymap() { const { view } = wwe; const { state, dispatch } = view; chainCommands(deleteSelection, joinBackward, selectNodeBackward)(state, dispatch, view); } it('should remove list item and lift up to previous list item by backspace keymap ', () => { html = oneLineTrim`
                  • item1
                  `; setContent(html); wwe.setSelection(9, 10); // in second list item forceBackspaceKeymap(); forceKeymapFn('listItem', 'liftToPrevListItem'); const expected = oneLineTrim`
                  • item1

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should remove list item and lift up to parent list item by backspace keymap ', () => { html = oneLineTrim`
                  • item1
                  • item2
                  `; setContent(html); wwe.setSelection(19, 20); // in nested last child list item forceBackspaceKeymap(); forceKeymapFn('listItem', 'liftToPrevListItem'); const expected = oneLineTrim`
                  • item1

                  • item2

                  `; expect(wwe.getHTML()).toBe(expected); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/wwCommand.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import { DOMParser } from 'prosemirror-model'; import WysiwygEditor from '@/wysiwyg/wwEditor'; import EventEmitter from '@/event/eventEmitter'; import CommandManager from '@/commands/commandManager'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import { cls } from '@/utils/dom'; import type { HTMLConvertorMap } from '@toast-ui/toastmark'; const CODE_BLOCK_CLS = cls('ww-code-block'); describe('wysiwyg commands', () => { let wwe: WysiwygEditor, em: EventEmitter, cmd: CommandManager; function setTextToEditor(text: string) { const { state, dispatch } = wwe.view; const { tr, doc } = state; const lines = text.split('\n'); const node = lines.map((lineText) => wwe.schema.nodes.paragraph.create(null, wwe.schema.text(lineText)) ); dispatch(tr.replaceWith(0, doc.content.size, node)); } function setContent(content: string) { const wrapper = document.createElement('div'); wrapper.innerHTML = content; const nodes = DOMParser.fromSchema(wwe.schema).parse(wrapper); wwe.setModel(nodes); } beforeEach(() => { const customHTMLRenderer: HTMLConvertorMap = { myCustom(node) { const span = document.createElement('span'); span.innerHTML = node.literal!; return [ { type: 'openTag', tagName: 'div', attributes: { 'data-custom': 'myCustom' } }, { type: 'html', content: span.outerHTML }, { type: 'closeTag', tagName: 'div' }, ]; }, }; const toDOMAdaptor = new WwToDOMAdaptor({}, customHTMLRenderer); em = new EventEmitter(); wwe = new WysiwygEditor(em, { toDOMAdaptor }); cmd = new CommandManager(em, {}, wwe.commands, () => 'wysiwyg'); }); afterEach(() => { wwe.destroy(); }); describe('heading command', () => { it('should add empty heading element', () => { cmd.exec('heading', { level: 1 }); expect(wwe.getHTML()).toBe('


                  '); }); it('should add heading element to selection', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('heading', { level: 2 }); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should change heading element by level', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('heading', { level: 3 }); expect(wwe.getHTML()).toBe('

                  foo

                  '); cmd.exec('selectAll'); cmd.exec('heading', { level: 4 }); expect(wwe.getHTML()).toBe('

                  foo

                  '); cmd.exec('selectAll'); cmd.exec('heading', { level: 5 }); expect(wwe.getHTML()).toBe('
                  foo
                  '); cmd.exec('selectAll'); cmd.exec('heading', { level: 6 }); expect(wwe.getHTML()).toBe('
                  foo
                  '); }); it('should change heading element to paragraph with level 0', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('heading', { level: 0 }); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('hr command', () => { it('should add hr element with empty paragraphs in empty document', () => { cmd.exec('hr'); expect(wwe.getHTML()).toBe(oneLineTrim`




                  `); }); it('should add hr element with after empty paragraph', () => { setTextToEditor('foo'); wwe.setSelection(2, 2); cmd.exec('hr'); expect(wwe.getHTML()).toBe(oneLineTrim`

                  foo



                  `); }); it('should add only hr element', () => { setTextToEditor('foo\nbar'); wwe.setSelection(2, 2); cmd.exec('hr'); expect(wwe.getHTML()).toBe(oneLineTrim`

                  foo


                  bar

                  `); }); it('should not add hr element when there is selection', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('hr'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('blockQuote command', () => { it('should add blockquote element including empty paragraph', () => { cmd.exec('blockQuote'); expect(wwe.getHTML()).toBe('


                  '); }); it('should change blockquote element to selection', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('blockQuote'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should wrap with blockquote element', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('blockQuote'); cmd.exec('blockQuote'); const expected = oneLineTrim`

                  foo

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('codeBlock command', () => { it('should add pre element including code element', () => { cmd.exec('codeBlock'); expect(wwe.getHTML()).toBe(oneLineTrim`
                              
                  `); }); it('should change pre element to selection', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('codeBlock'); expect(wwe.getHTML()).toBe(oneLineTrim`
                              foo
                            
                  `); }); }); describe('bulletList command', () => { it('should add ul element having empty list item', () => { cmd.exec('bulletList'); const expected = oneLineTrim`

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should change to bullet list item in selection', () => { setTextToEditor('foo\nbar\nbaz'); cmd.exec('selectAll'); cmd.exec('bulletList'); const expected = oneLineTrim`
                  • foo

                  • bar

                  • baz

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('orderedList command', () => { it('should add ol element having empty list item', () => { cmd.exec('orderedList'); const expected = oneLineTrim`

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should change to ordered list item in selection', () => { setTextToEditor('foo\nbar\nbaz'); cmd.exec('selectAll'); cmd.exec('orderedList'); const expected = oneLineTrim`
                  1. foo

                  2. bar

                  3. baz

                  `; expect(wwe.getHTML()).toBe(expected); }); }); it('bulletList and orderedList command should change parent list to other list when in list item', () => { setTextToEditor('foo\nbar\nbaz'); cmd.exec('selectAll'); cmd.exec('bulletList'); wwe.setSelection(3, 3); // in 'foo' cmd.exec('orderedList'); let expected = oneLineTrim`
                  1. foo

                  2. bar

                  3. baz

                  `; expect(wwe.getHTML()).toBe(expected); wwe.setSelection(11, 11); // in 'bar' cmd.exec('bulletList'); expected = oneLineTrim`
                  • foo

                  • bar

                  • baz

                  `; expect(wwe.getHTML()).toBe(expected); }); describe('taskList command', () => { it('should add task to ul element ', () => { cmd.exec('taskList'); const expected = oneLineTrim`

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should change to task item in selection', () => { setTextToEditor('foo\nbar\nbaz'); cmd.exec('selectAll'); cmd.exec('taskList'); const expected = oneLineTrim`
                  • foo

                  • bar

                  • baz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should toggle task list item', () => { setTextToEditor('foo\nbar\nbaz'); cmd.exec('selectAll'); cmd.exec('taskList'); wwe.setSelection(3, 3); // from 'foo' cmd.exec('bulletList'); let expected = oneLineTrim`
                  • foo

                  • bar

                  • baz

                  `; expect(wwe.getHTML()).toBe(expected); wwe.setSelection(3, 12); // from 'foo' to 'bar' cmd.exec('taskList'); expected = oneLineTrim`
                  • foo

                  • bar

                  • baz

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('bold command', () => { beforeEach(() => setTextToEditor('foo')); it('should add strong element to selection', () => { cmd.exec('selectAll'); cmd.exec('bold'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should toggle and remove strong element', () => { cmd.exec('selectAll'); cmd.exec('bold'); cmd.exec('selectAll'); cmd.exec('bold'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('italic command', () => { beforeEach(() => setTextToEditor('foo')); it('should add emphasis element to selection', () => { cmd.exec('selectAll'); cmd.exec('italic'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should toggle and remove emphasis element', () => { cmd.exec('selectAll'); cmd.exec('bold'); cmd.exec('selectAll'); cmd.exec('bold'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('strike command', () => { beforeEach(() => setTextToEditor('foo')); it('should add del element to selection', () => { cmd.exec('selectAll'); cmd.exec('strike'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should toggle and remove del element', () => { cmd.exec('selectAll'); cmd.exec('strike'); cmd.exec('selectAll'); cmd.exec('strike'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('code command', () => { beforeEach(() => setTextToEditor('foo')); it('should add code element to selection', () => { cmd.exec('selectAll'); cmd.exec('code'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should toggle and remove code element', () => { cmd.exec('selectAll'); cmd.exec('code'); cmd.exec('selectAll'); cmd.exec('code'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('addImage command', () => { it('should add image element', () => { cmd.exec('addImage', { imageUrl: '#', }); expect(wwe.getHTML()).toBe('


                  '); }); it('should add image element with enabled attirbute', () => { cmd.exec('addImage', { imageUrl: '#', altText: 'foo', foo: 'test', }); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should not add image element when not having imageUrl attribute', () => { cmd.exec('addImage', { altText: 'foo', }); expect(wwe.getHTML()).toBe('


                  '); }); it('should not decode url which is already encoded', () => { cmd.exec('addImage', { imageUrl: 'https://firebasestorage.googleapis.com/images%2Fimage.png?alt=media', altText: 'foo', }); expect(wwe.getHTML()).toBe( '

                  foo

                  ' ); }); }); describe('addLink command', () => { it('should add link element', () => { cmd.exec('addLink', { linkUrl: '#', linkText: 'foo', }); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should not add link element when no selection and attributes are missing', () => { cmd.exec('addLink', { linkText: 'foo', }); expect(wwe.getHTML()).toBe('


                  '); cmd.exec('addLink', { linkUrl: '#', }); expect(wwe.getHTML()).toBe('


                  '); }); it('should change link url in selection', () => { cmd.exec('addLink', { linkUrl: '#', linkText: 'foo bar baz', }); wwe.setSelection(5, 8); cmd.exec('addLink', { linkUrl: 'http://test.com', linkText: 'bar', }); const expected = oneLineTrim`

                  foo bar baz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should not decode url which is already encoded', () => { cmd.exec('addLink', { linkUrl: 'https://firebasestorage.googleapis.com/links%2Fimage.png?alt=media', linkText: 'foo', }); expect(wwe.getHTML()).toBe( '

                  foo

                  ' ); }); }); describe(`addLink command with 'linkAttributes' option`, () => { beforeEach(() => { const linkAttributes = { target: '_blank', rel: 'noopener noreferrer', }; const toDOMAdaptor = new WwToDOMAdaptor({}, {}); em = new EventEmitter(); wwe = new WysiwygEditor(em, { toDOMAdaptor, linkAttributes }); cmd = new CommandManager(em, {}, wwe.commands, () => 'wysiwyg'); }); it('should add link element with link attributes', () => { cmd.exec('addLink', { linkUrl: '#', linkText: 'foo', }); expect(wwe.getHTML()).toBe( '

                  foo

                  ' ); }); }); describe('toggleLink command', () => { beforeEach(() => setTextToEditor('foo')); it('should add link element to selection', () => { cmd.exec('selectAll'); cmd.exec('toggleLink', { linkUrl: 'linkUrl', }); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('should toggle link element to selection', () => { cmd.exec('selectAll'); cmd.exec('toggleLink', { linkUrl: 'linkUrl', }); cmd.exec('selectAll'); cmd.exec('toggleLink'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('history command', () => { beforeEach(() => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('bold'); cmd.exec('italic'); }); it('undo go back to before previous action', () => { cmd.exec('undo'); expect(wwe.getHTML()).toBe('

                  foo

                  '); cmd.exec('undo'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); it('redo cancel undo action', () => { cmd.exec('undo'); cmd.exec('undo'); cmd.exec('redo'); expect(wwe.getHTML()).toBe('

                  foo

                  '); }); }); describe('indent command', () => { let html; beforeEach(() => { html = oneLineTrim`
                  • foo

                    1. bar

                    2. baz

                    3. qux

                  `; setContent(html); }); // @TODO move to 'tab' key event test // it('should add spaces for tab when it is not in list', () => { // setContent('

                  foo

                  '); // wwe.setSelection(1, 1); // cmd.exec( 'indent'); // expect(wwe.getHTML()).toBe('

                  foo

                  '); // wwe.setSelection(1, 8); // cmd.exec( 'indent'); // expect(wwe.getHTML()).toBe('

                  '); // }); it('should indent to list items at cursor position', () => { wwe.setSelection(18, 18); cmd.exec('indent'); const expected = oneLineTrim`
                  • foo

                    1. bar

                      1. baz

                    2. qux

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should indent to list items as selection', () => { wwe.setSelection(18, 26); cmd.exec('indent'); const expected = oneLineTrim`
                  • foo

                    1. bar

                      1. baz

                      2. qux

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('outdent command', () => { let html; beforeEach(() => { html = oneLineTrim`
                  • foo

                    1. bar

                      • baz

                  `; setContent(html); }); // @TODO move to 'shift + tab' key event test // it('should remove spaces for tab when it is not in list', () => { // setContent('

                     foo

                  '); // wwe.setSelection(4, 4); // cmd.exec( 'outdent'); // expect(wwe.getHTML()).toBe('

                  foo

                  '); // setContent('

                  foo    bar

                  '); // wwe.setSelection(6, 6); // cmd.exec( 'outdent'); // expect(wwe.getHTML()).toBe('

                  foo  bar

                  '); // wwe.setSelection(6, 8); // cmd.exec( 'outdent'); // expect(wwe.getHTML()).toBe('

                  foobar

                  '); // }); it('should outdent to list items at cursor position', () => { wwe.setSelection(19, 19); cmd.exec('outdent'); const expected = oneLineTrim`
                  • foo

                    1. bar

                    2. baz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should outdent to list items as selection', () => { wwe.setSelection(10, 20); cmd.exec('outdent'); const expected = oneLineTrim`
                  • foo

                  • bar

                    • baz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should change list item of 1 depth into paragraph ', () => { wwe.setSelection(3, 5); cmd.exec('outdent'); const expected = oneLineTrim`

                  foo

                  1. bar

                    • baz

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('customBlock command', () => { it('should add customBlock element', () => { cmd.exec('customBlock', { info: 'myCustom' }); expect(wwe.getHTML()).toBe(oneLineTrim`
                  myCustom
                  `); }); it('should change customBlock element to selection', () => { setTextToEditor('foo'); cmd.exec('selectAll'); cmd.exec('customBlock', { info: 'myCustom' }); expect(wwe.getHTML()).toBe(oneLineTrim`
                  foo
                  myCustom
                  `); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/wwEditor.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import { DOMParser } from 'prosemirror-model'; import WysiwygEditor from '@/wysiwyg/wwEditor'; import EventEmitter from '@/event/eventEmitter'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import { createHTMLSchemaMap } from '@/wysiwyg/nodes/html'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import { createHTMLrenderer } from '../markdown/util'; jest.useFakeTimers(); describe('WysiwygEditor', () => { let wwe: WysiwygEditor, em: EventEmitter, el: HTMLElement; function assertToContainHTML(html: string) { expect(wwe.view.dom.innerHTML).toContain(html); } function setContent(content: string) { const wrapper = document.createElement('div'); wrapper.innerHTML = content; const nodes = DOMParser.fromSchema(wwe.schema).parse(wrapper); wwe.setModel(nodes); } beforeEach(() => { const htmlRenderer = createHTMLrenderer(); const toDOMAdaptor = new WwToDOMAdaptor({}, htmlRenderer); const htmlSchemaMap = createHTMLSchemaMap(htmlRenderer, sanitizeHTML, toDOMAdaptor); em = new EventEmitter(); wwe = new WysiwygEditor(em, { toDOMAdaptor, htmlSchemaMap }); el = wwe.el; document.body.appendChild(el); }); afterEach(() => { jest.clearAllTimers(); if (Object.keys(wwe).length) { wwe.destroy(); } document.body.removeChild(el); }); describe('API', () => { it('destroy() initialize instance object', () => { wwe.destroy(); expect(wwe).toEqual({}); }); it(`focus() enable editor's dom selection state`, () => { wwe.focus(); // run setTimeout function when focusing the editor jest.runAllTimers(); expect(document.activeElement).toEqual(wwe.view.dom); }); it(`blur() disable editor's dom selection state`, () => { wwe.focus(); wwe.blur(); expect(document.activeElement).not.toEqual(wwe.view.dom); }); it('setHeight() change height of editor', () => { wwe.setHeight(50); expect(wwe.el.style.height).toBe('50px'); }); it('setMinHeight() change minimum height of editor', () => { wwe.setMinHeight(50); expect(wwe.el.style.minHeight).toBe('50px'); }); it('setPlaceholder() attach placeholder element', () => { wwe.setPlaceholder('placeholder text'); assertToContainHTML(oneLineTrim` placeholder text `); }); it('scrollTo() move scroll position', () => { setContent(oneLineTrim`

                  foo








                  `); wwe.setHeight(50); wwe.setScrollTop(30); expect(wwe.getScrollTop()).toBe(30); }); it('getSelection() return selection range as array', () => { setContent(oneLineTrim`

                  foo

                  bar

                  baz

                  `); wwe.setSelection(13, 2); expect(wwe.getSelection()).toEqual([2, 13]); }); it('replaceSelection() change text of selection range', () => { setContent(oneLineTrim`

                  foo

                  bar

                  `); wwe.setSelection(3, 7); wwe.replaceSelection('new foo\nnew bar'); assertToContainHTML(oneLineTrim`

                  fonew foo

                  new barar

                  `); }); it('addWidget API', () => { const ul = document.createElement('ul'); ul.innerHTML = `
                • Ryu
                • Lee
                • `; wwe.addWidget(ul, 'top'); expect(document.body).toContainElement(ul); wwe.blur(); expect(document.body).not.toContainElement(ul); }); }); it(`should emit 'changeToolbarState' event when changing cursor`, () => { setContent(oneLineTrim`

                  foo

                  bar

                  `); const spy = jest.fn(); em.listen('changeToolbarState', spy); wwe.setSelection(3, 3); expect(spy).toHaveBeenCalled(); }); it('should display html block element properly', () => { setContent( '' ); assertToContainHTML( '' ); }); it('should display html inline element properly', () => { setContent('text'); assertToContainHTML('

                  text

                  '); }); it('should sanitize html element', () => { setContent(''); assertToContainHTML( '' ); }); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/wwTableCommand.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import WysiwygEditor from '@/wysiwyg/wwEditor'; import EventEmitter from '@/event/eventEmitter'; import CommandManager from '@/commands/commandManager'; import CellSelection from '@/wysiwyg/plugins/selection/cellSelection'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import { TableOffsetMap } from '@/wysiwyg/helper/tableOffsetMap'; import { cls } from '@/utils/dom'; const CELL_SELECTION_CLS = cls('cell-selected'); describe('wysiwyg table commands', () => { let wwe: WysiwygEditor, em: EventEmitter, cmd: CommandManager; function selectCells(from: number, to: number) { const { state, dispatch } = wwe.view; const { doc, tr } = state; const startCellPos = doc.resolve(from); const endCellPos = doc.resolve(to); const selection = new CellSelection(startCellPos, endCellPos); dispatch!(tr.setSelection(selection)); } function setCellSelection( [startRowIdx, startColIdx]: number[], [endRowIdx, endColIdx]: number[], cellSelection = true ) { const doc = wwe.getModel(); const map = TableOffsetMap.create(doc.resolve(1))!; const startCellOffset = map.getCellInfo(startRowIdx, startColIdx).offset; const endCellOffset = map.getCellInfo(endRowIdx, endColIdx).offset; if (startCellOffset === endCellOffset && !cellSelection) { const from = startCellOffset + 1; wwe.setSelection(from, from); } else { selectCells(startCellOffset, endCellOffset); } } beforeEach(() => { const toDOMAdaptor = new WwToDOMAdaptor({}, {}); em = new EventEmitter(); wwe = new WysiwygEditor(em, { toDOMAdaptor }); cmd = new CommandManager(em, {}, wwe.commands, () => 'wysiwyg'); }); afterEach(() => { wwe.destroy(); }); describe('addTable command', () => { it('should create one by one table', () => { cmd.exec('addTable'); const expected = oneLineTrim`



                  `; expect(wwe.getHTML()).toBe(expected); }); it('should create table with column and row count', () => { cmd.exec('addTable', { rowCount: 4, columnCount: 2 }); const expected = oneLineTrim`









                  `; expect(wwe.getHTML()).toBe(expected); }); it('should create table with data', () => { cmd.exec('addTable', { rowCount: 2, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux'], }); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('removeTable command', () => { beforeEach(() => { cmd.exec('addTable'); }); it('should remove table when cursor is in table hedaer', () => { setCellSelection([0, 0], [0, 0], false); cmd.exec('removeTable'); expect(wwe.getHTML()).toBe('


                  '); }); it('should remove table when cursor is in table body', () => { setCellSelection([1, 0], [1, 0], false); cmd.exec('removeTable'); expect(wwe.getHTML()).toBe('


                  '); }); it('should remove table when selected cells', () => { setCellSelection([0, 0], [1, 0]); cmd.exec('removeTable'); expect(wwe.getHTML()).toBe('


                  '); }); }); describe('addRowToDown command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 3, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz'], }); }); it('should add a row to next row of current cursor cell', () => { setCellSelection([1, 1], [1, 1], false); // select 'baz' cell cmd.exec('addRowToDown'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux



                  quux

                  quuz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should add rows as selected row count after selection', () => { setCellSelection([0, 0], [1, 1]); // select from 'foo' to 'qux' cells cmd.exec('addRowToDown'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux





                  quux

                  quuz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should not add a row when selection is only at table head', () => { setCellSelection([0, 0], [0, 1]); // select from 'foo' to 'bar' cells cmd.exec('addRowToDown'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux

                  quuz

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('addRowToUp command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 3, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz'], }); }); it('should add a row to previous row of current cursor cell', () => { setCellSelection([1, 1], [1, 1], false); // select 'baz' cell cmd.exec('addRowToUp'); const expected = oneLineTrim`

                  foo

                  bar



                  baz

                  qux

                  quux

                  quuz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should add rows as selected row count before selection', () => { setCellSelection([1, 1], [2, 1]); // select from 'qux' to 'quuz' cells cmd.exec('addRowToUp'); const expected = oneLineTrim`

                  foo

                  bar





                  baz

                  qux

                  quux

                  quuz

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should not add a row when selection include table head', () => { setCellSelection([0, 0], [1, 0]); // select from 'foo' to 'baz' cells cmd.exec('addRowToUp'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux

                  quuz

                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('removeRow command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 4, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz', 'corge', ''], }); }); it('should remove a row where current cursor cell is located', () => { setCellSelection([1, 1], [1, 1], false); // select from 'qux' cell cmd.exec('removeRow'); const expected = oneLineTrim`

                  foo

                  bar

                  quux

                  quuz

                  corge


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should remove columns as selected column count in selection', () => { setCellSelection([3, 1], [2, 1]); // select from last to 'quuz' cells cmd.exec('removeRow'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should not remove rows when selection include table head', () => { setCellSelection([0, 1], [2, 1]); // select from 'bar' to 'qux' cells cmd.exec('removeRow'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux

                  quuz

                  corge


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should not remove rows when all rows of table body are selected', () => { setCellSelection([1, 0], [3, 0]); // select from 'baz' to 'corge' cells cmd.exec('removeRow'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux

                  quuz

                  corge


                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('addColumnToRight command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 3, columnCount: 3, data: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz', 'corge', 'grault', ''], }); }); it('should add a column to next column of current cursor cell', () => { setCellSelection([1, 1], [1, 1], false); // select 'quux' cell cmd.exec('addColumnToRight'); const expected = oneLineTrim`

                  foo

                  bar


                  baz

                  qux

                  quux


                  quuz

                  corge

                  grault



                  `; expect(wwe.getHTML()).toBe(expected); }); it('should add columns as selected column count to right of selection', () => { setCellSelection([0, 0], [1, 1]); // select from 'foo' to 'quux' cells cmd.exec('addColumnToRight'); const expected = oneLineTrim`

                  foo

                  bar



                  baz

                  qux

                  quux



                  quuz

                  corge

                  grault




                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('addColumnToLeft command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 3, columnCount: 3, data: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz', 'corge', 'grault', ''], }); }); it('should add a column to previous column of current cursor cell', () => { setCellSelection([1, 1], [1, 1], false); // select 'quux' cell cmd.exec('addColumnToLeft'); const expected = oneLineTrim`

                  foo


                  bar

                  baz

                  qux


                  quux

                  quuz

                  corge


                  grault


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should add columns as selected column count to right of selection', () => { setCellSelection([0, 1], [2, 2]); // select from 'bar' to last cells cmd.exec('addColumnToLeft'); const expected = oneLineTrim`

                  foo



                  bar

                  baz

                  qux



                  quux

                  quuz

                  corge



                  grault


                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('removeColumn command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 3, columnCount: 3, data: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz', 'corge', 'grault', ''], }); }); it('should remove a column where current cursor cell is located', () => { setCellSelection([1, 1], [1, 1], false); // select 'quux' cell cmd.exec('removeColumn'); const expected = oneLineTrim`

                  foo

                  baz

                  qux

                  quuz

                  corge


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should remove columns as selected column count in selection', () => { setCellSelection([0, 1], [2, 2]); // select from 'bar' to last cells cmd.exec('removeColumn'); const expected = oneLineTrim`

                  foo

                  qux

                  corge

                  `; expect(wwe.getHTML()).toBe(expected); }); it('should not remove columns when all columns are selected', () => { setCellSelection([0, 0], [1, 2]); // select from 'foo' to 'quuz' cells cmd.exec('removeRow'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux

                  quuz

                  corge

                  grault


                  `; expect(wwe.getHTML()).toBe(expected); }); }); describe('alignColumn command', () => { beforeEach(() => { cmd.exec('addTable', { rowCount: 3, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux', 'quux', ''], }); }); it('should add center align attribute to columns by no option', () => { setCellSelection([1, 0], [1, 0], false); // select 'baz' cell cmd.exec('alignColumn'); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should change align attribute to columns by option', () => { setCellSelection([2, 1], [2, 1], false); // select last cell cmd.exec('alignColumn', { align: 'left' }); let expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux


                  `; expect(wwe.getHTML()).toBe(expected); cmd.exec('alignColumn', { align: 'right' }); expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should add align attribute to columns with cursor in table hedaer', () => { setCellSelection([0, 0], [0, 0], false); // select 'foo' cell cmd.exec('alignColumn', { align: 'left' }); const expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux


                  `; expect(wwe.getHTML()).toBe(expected); }); it('should add align attribute to selected columns in selection', () => { setCellSelection([1, 0], [1, 1]); // select from 'baz' to 'qux' cell cmd.exec('alignColumn', { align: 'left' }); let expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux


                  `; expect(wwe.getHTML()).toBe(expected); setCellSelection([0, 1], [0, 0]); // select from 'bar' to 'foo' cell cmd.exec('alignColumn', { align: 'right' }); expected = oneLineTrim`

                  foo

                  bar

                  baz

                  qux

                  quux


                  `; expect(wwe.getHTML()).toBe(expected); }); }); }); ================================================ FILE: apps/editor/src/__test__/unit/wysiwyg/wwToDOMAdaptor.spec.ts ================================================ import { Fragment, ProsemirrorNode } from 'prosemirror-model'; import { oneLineTrim } from 'common-tags'; import { HeadingMdNode, CodeBlockMdNode, HTMLConvertorMap } from '@toast-ui/toastmark'; import { ToDOMAdaptor } from '@t/convertor'; import { WwToDOMAdaptor } from '@/wysiwyg/adaptor/wwToDOMAdaptor'; import EventEmitter from '@/event/eventEmitter'; import WysiwygEditor from '@/wysiwyg/wwEditor'; import { createMdLikeNode } from '@/wysiwyg/adaptor/mdLikeNode'; import { createHTMLSchemaMap } from '@/wysiwyg/nodes/html'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; let wwe: WysiwygEditor, em: EventEmitter, toDOMAdaptor: ToDOMAdaptor; function createText(text: string) { return wwe.schema.text(text); } function createNode( type: string, attrs?: { [key: string]: any } | null, content?: Fragment | ProsemirrorNode | Array ) { return wwe.schema.nodes[type].create(attrs, content); } function createMark(type: string, attrs?: { [key: string]: any } | null) { return wwe.schema.marks[type].create(attrs!); } beforeEach(() => { const convertors: HTMLConvertorMap = { code() { return [ { type: 'openTag', tagName: 'code' }, { type: 'html', content: '123' }, { type: 'closeTag', tagName: 'code' }, ]; }, heading(node, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: `h${(node as HeadingMdNode).level}`, attributes: { 'data-custom': 'customAttr' }, classNames: ['custom-heading'], }; }, codeBlock(node) { return [ { type: 'openTag', tagName: 'pre', attributes: { 'data-custom': (node as CodeBlockMdNode).info || '' }, classNames: ['custom-pre'], }, { type: 'openTag', tagName: 'code', classNames: ['custom-code'] }, { type: 'openTag', tagName: 'span' }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'span' }, { type: 'closeTag', tagName: 'code' }, { type: 'closeTag', tagName: 'pre' }, ]; }, emph(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: `em`, attributes: { 'data-custom': 'customAttr' }, classNames: ['custom-emph'], }; }, htmlBlock: { // @ts-ignore nav(node) { return [ { type: 'openTag', tagName: 'nav', outerNewLine: true, attributes: node.attrs }, { type: 'html', content: node.childrenHTML }, { type: 'closeTag', tagName: 'nav', outerNewLine: true }, ]; }, }, htmlInline: { // @ts-ignore big(node: MdLikeNode, { entering }: Context) { return entering ? { type: 'openTag', tagName: 'big', attributes: { class: node.attrs.class } } : { type: 'closeTag', tagName: 'big' }; }, }, }; toDOMAdaptor = new WwToDOMAdaptor({}, convertors); em = new EventEmitter(); const htmlSchemaMap = createHTMLSchemaMap(convertors, sanitizeHTML, toDOMAdaptor); wwe = new WysiwygEditor(em, { toDOMAdaptor, htmlSchemaMap }); }); afterEach(() => { wwe.destroy(); }); describe('mdLikeNode', () => { it('heading node should be changed to markdown-like-node', () => { const headingNode = createMdLikeNode( createNode('heading', { level: 2 }, createText('myHeading')) ); expect(headingNode).toEqual({ type: 'heading', literal: null, wysiwygNode: true, level: 2 }); }); it('image node should be changed to markdown-like-node', () => { const imageNode = createMdLikeNode(createNode('image', { imageUrl: 'myImageUrl' })); expect(imageNode).toEqual({ type: 'image', literal: null, wysiwygNode: true, destination: 'myImageUrl', }); }); it('codeBlock node should be changed to markdown-like-node', () => { const codeBlockNode = createMdLikeNode( createNode('codeBlock', { language: 'myLang' }, createText('myCode')) ); expect(codeBlockNode).toEqual({ type: 'codeBlock', literal: 'myCode', wysiwygNode: true, info: 'myLang', }); }); it('bulletList node should be changed to markdown-like-node', () => { const bulletListNode = createMdLikeNode(createNode('bulletList')); expect(bulletListNode).toEqual({ type: 'list', literal: null, wysiwygNode: true, listData: { type: 'bullet' }, }); }); it('orderedList node should be changed to markdown-like-node', () => { const orderedListNode = createMdLikeNode(createNode('orderedList')); expect(orderedListNode).toEqual({ type: 'list', literal: null, wysiwygNode: true, listData: { start: 1, type: 'ordered' }, }); }); it('listItem node should be changed to markdown-like-node', () => { const listItemNode = createMdLikeNode(createNode('listItem', { task: true })); expect(listItemNode).toEqual({ type: 'item', literal: null, wysiwygNode: true, listData: { task: true, checked: false }, }); }); it('tableHeadCell node should be changed to markdown-like-node', () => { const tableHeadCellNode = createMdLikeNode(createNode('tableHeadCell', { align: 'left' })); expect(tableHeadCellNode).toEqual({ type: 'tableCell', cellType: 'head', align: 'left', literal: null, wysiwygNode: true, }); }); it('tableBodyCell node should be changed to markdown-like-node', () => { const tableBodyCellNode = createMdLikeNode(createNode('tableBodyCell', { align: 'left' })); expect(tableBodyCellNode).toEqual({ type: 'tableCell', cellType: 'body', align: 'left', literal: null, wysiwygNode: true, }); }); it('customBlock node should be changed to markdown-like-node', () => { const customBlockNode = createMdLikeNode( createNode('customBlock', { info: 'myCustom' }, createText('myCustom')) ); expect(customBlockNode).toEqual({ type: 'customBlock', info: 'myCustom', literal: 'myCustom', wysiwygNode: true, }); }); it('link mark should be changed to markdown-like-node', () => { const linkNode = createMdLikeNode( createMark('link', { linkText: 'myLinkText', linkUrl: 'myLinkUrl' }) ); expect(linkNode).toEqual({ type: 'link', literal: null, wysiwygNode: true, destination: 'myLinkUrl', title: null, }); }); it('html block should be changed to markdown-like-node', () => { const navNode = createMdLikeNode( createNode('nav', { htmlAttrs: { class: 'my-nav', 'data-my-nav': 'my-nav' }, childrenHTML: 'text', }) ); expect(navNode).toEqual({ type: 'nav', literal: '', wysiwygNode: true, attrs: { class: 'my-nav', 'data-my-nav': 'my-nav' }, childrenHTML: 'text', }); }); it('html inline should be changed to markdown-like-node', () => { const bigNode = createMdLikeNode(createMark('big', { htmlAttrs: { class: 'my-big' } })); expect(bigNode).toEqual({ type: 'big', wysiwygNode: true, literal: null, attrs: { class: 'my-big' }, }); }); }); describe('wysiwyg adaptor toDOMNode using custom renderer', () => { function getHTML(node: Node) { return (node as HTMLElement).outerHTML; } it('toDOMNode should be parsed with renderer tokens for wysiwyg node schema', () => { const toDOMNode = toDOMAdaptor.getToDOMNode('heading')!; const headingNode = createNode('heading', { level: 2 }, createText('myHeading')); const expected = oneLineTrim`

                  myHeading

                  `; expect(getHTML(toDOMNode(headingNode))).toBe(expected); }); it('toDOMNode should be parsed with the nested renderer tokens', () => { const toDOMNode = toDOMAdaptor.getToDOMNode('codeBlock')!; const codeBlockNode = createNode('codeBlock', { language: 'myLan' }, createText('codeBlock')); const expected = oneLineTrim`
                            
                              codeBlock
                            
                          
                  `; expect(getHTML(toDOMNode(codeBlockNode))).toBe(expected); }); it('html token should be parsed in DOMNode', () => { const toDOMNode = toDOMAdaptor.getToDOMNode('code')!; const codeNode = createMark('code'); const expected = oneLineTrim` 123 `; expect(getHTML(toDOMNode(codeNode))).toBe(expected); }); it('should get toDOM for only registered renderer', () => { const toDOMNode = toDOMAdaptor.getToDOMNode('blockQuote'); expect(toDOMNode).toBe(null); }); it('toDOMNode should be parsed with the html block renderer tokens', () => { const toDOMNode = toDOMAdaptor.getToDOMNode('nav')!; const navNode = createNode('nav', { htmlAttrs: { class: 'my-nav' }, childrenHTML: 'text', }); const expected = oneLineTrim` `; expect(getHTML(toDOMNode(navNode))).toBe(expected); }); it('toDOMNode should be parsed with the html inline renderer tokens', () => { const toDOMNode = toDOMAdaptor.getToDOMNode('big')!; const bigNode = createMark('big', { htmlAttrs: { class: 'my-big', 'data-my-attr': 'my-attr' }, }); const expected = oneLineTrim` `; expect(getHTML(toDOMNode(bigNode))).toBe(expected); }); }); ================================================ FILE: apps/editor/src/base.ts ================================================ import { Schema } from 'prosemirror-model'; import { EditorState, Plugin, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { keymap } from 'prosemirror-keymap'; import { baseKeymap } from 'prosemirror-commands'; import { InputRule, inputRules } from 'prosemirror-inputrules'; import { history } from 'prosemirror-history'; import { Sourcepos } from '@toast-ui/toastmark'; import css from 'tui-code-snippet/domUtil/css'; import { WidgetStyle, EditorType, EditorPos, Base, NodeRangeInfo } from '@t/editor'; import { Emitter } from '@t/event'; import { Context, EditorAllCommandMap } from '@t/spec'; import SpecManager from './spec/specManager'; import { createTextSelection } from './helper/manipulation'; import { createNodesWithWidget, getWidgetRules } from './widget/rules'; import { getDefaultCommands } from './commands/defaultCommands'; import { placeholder } from './plugins/placeholder'; import { addWidget } from './plugins/popupWidget'; import { dropImage } from './plugins/dropImage'; import { isWidgetNode } from './widget/widgetNode'; import { last } from './utils/common'; import { PluginProp } from '@t/plugin'; export default abstract class EditorBase implements Base { el: HTMLElement; editorType!: EditorType; eventEmitter: Emitter; context!: Context; schema!: Schema; keymaps!: Plugin[]; view!: EditorView; commands!: EditorAllCommandMap; specs!: SpecManager; placeholder: { text: string }; extraPlugins!: PluginProp[]; timer: NodeJS.Timeout | null = null; constructor(eventEmitter: Emitter) { this.el = document.createElement('div'); this.el.className = 'toastui-editor'; this.eventEmitter = eventEmitter; this.placeholder = { text: '' }; } abstract createSpecs(): SpecManager; abstract createContext(): Context; abstract createView(): EditorView; createState() { return EditorState.create({ schema: this.schema, plugins: this.createPlugins(), }); } protected initEvent() { const { eventEmitter, view, editorType } = this; view.dom.addEventListener('focus', () => eventEmitter.emit('focus', editorType)); view.dom.addEventListener('blur', () => eventEmitter.emit('blur', editorType)); } protected emitChangeEvent(tr: Transaction) { this.eventEmitter.emit('caretChange', this.editorType); if (tr.docChanged) { this.eventEmitter.emit('change', this.editorType); } } get defaultPlugins() { const rules = this.createInputRules(); const plugins = [ ...this.keymaps, keymap({ 'Shift-Enter': baseKeymap.Enter, ...baseKeymap, }), history(), placeholder(this.placeholder), addWidget(this.eventEmitter), dropImage(this.context), ]; return rules ? plugins.concat(rules) : plugins; } private createInputRules() { const widgetRules = getWidgetRules(); const rules = widgetRules.map( ({ rule }) => new InputRule(rule, (state, match: RegExpMatchArray, start, end) => { const { schema, tr, doc } = state; const allMatched = match.input!.match(new RegExp(rule, 'g'))!; const pos = doc.resolve(start); let { parent } = pos; let count = 0; if (isWidgetNode(parent)) { parent = pos.node(pos.depth - 1); } parent.forEach((child) => isWidgetNode(child) && (count += 1)); // replace the content only if the count of matched rules in whole text is greater than current widget node count if (allMatched.length > count) { const content = last(allMatched); const nodes = createNodesWithWidget(content, schema); // adjust start position based on widget content return tr.replaceWith(end - content.length + 1, end, nodes); } return null; }) ); return rules.length ? inputRules({ rules }) : null; } private clearTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } createSchema() { return new Schema({ nodes: this.specs.nodes, marks: this.specs.marks, }); } createKeymaps(useCommandShortcut: boolean) { const { undo, redo } = getDefaultCommands(); const allKeymaps = this.specs.keymaps(useCommandShortcut); const historyKeymap = { 'Mod-z': undo(), 'Shift-Mod-z': redo(), }; return useCommandShortcut ? allKeymaps.concat(keymap(historyKeymap)) : allKeymaps; } createCommands() { return this.specs.commands(this.view); } createPluginProps() { return this.extraPlugins.map((plugin) => plugin(this.eventEmitter)); } focus() { this.clearTimer(); // prevent the error for IE11 this.timer = setTimeout(() => { this.view.focus(); this.view.dispatch(this.view.state.tr.scrollIntoView()); }); } blur() { (this.view.dom as HTMLElement).blur(); } destroy() { this.clearTimer(); this.view.destroy(); Object.keys(this).forEach((prop) => { delete this[prop as keyof this]; }); } moveCursorToStart(focus: boolean) { const { tr } = this.view.state; this.view.dispatch(tr.setSelection(createTextSelection(tr, 1)).scrollIntoView()); if (focus) { this.focus(); } } moveCursorToEnd(focus: boolean) { const { tr } = this.view.state; this.view.dispatch( tr.setSelection(createTextSelection(tr, tr.doc.content.size - 1)).scrollIntoView() ); if (focus) { this.focus(); } } setScrollTop(top: number) { this.view.dom.scrollTop = top; } getScrollTop() { return this.view.dom.scrollTop; } setPlaceholder(text: string) { this.placeholder.text = text; this.view.dispatch(this.view.state.tr.scrollIntoView()); } setHeight(height: number) { css(this.el, { height: `${height}px` }); } setMinHeight(minHeight: number) { css(this.el, { minHeight: `${minHeight}px` }); } getElement() { return this.el; } abstract createPlugins(): Plugin[]; abstract replaceWithWidget(start: EditorPos, end: EditorPos, text: string): void; abstract addWidget(node: Node, style: WidgetStyle, pos?: EditorPos): void; abstract setSelection(start?: EditorPos, end?: EditorPos): void; abstract replaceSelection(text: string, start?: EditorPos, end?: EditorPos): void; abstract deleteSelection(start?: EditorPos, end?: EditorPos): void; abstract getSelectedText(start?: EditorPos, end?: EditorPos): string; abstract getSelection(): Sourcepos | [number, number]; abstract getRangeInfoOfNode(pos?: EditorPos): NodeRangeInfo; } ================================================ FILE: apps/editor/src/commands/commandManager.ts ================================================ import { EditorType } from '@t/editor'; import { EditorAllCommandMap, EditorCommandFn } from '@t/spec'; import { Emitter } from '@t/event'; type GetEditorType = () => EditorType; export default class CommandManager { private eventEmitter: Emitter; private mdCommands: EditorAllCommandMap; private wwCommands: EditorAllCommandMap; private getEditorType: GetEditorType; constructor( eventEmitter: Emitter, mdCommands: EditorAllCommandMap, wwCommands: EditorAllCommandMap, getEditorType: GetEditorType ) { this.eventEmitter = eventEmitter; this.mdCommands = mdCommands; this.wwCommands = wwCommands; this.getEditorType = getEditorType; this.initEvent(); } private initEvent() { this.eventEmitter.listen('command', (command, payload) => { this.exec(command, payload); }); } addCommand(type: EditorType, name: string, command: EditorCommandFn) { if (type === 'markdown') { this.mdCommands[name] = command; } else { this.wwCommands[name] = command; } } deleteCommand(type: EditorType, name: string) { if (type === 'markdown') { delete this.mdCommands[name]; } else { delete this.wwCommands[name]; } } exec(name: string, payload?: Record) { const type = this.getEditorType(); if (type === 'markdown') { this.mdCommands[name](payload); } else { this.wwCommands[name](payload); } } } ================================================ FILE: apps/editor/src/commands/defaultCommands.ts ================================================ import { deleteSelection, selectAll } from 'prosemirror-commands'; import { undo, redo } from 'prosemirror-history'; import { EditorCommand } from '@t/spec'; export function getDefaultCommands(): Record { return { deleteSelection: () => deleteSelection, selectAll: () => selectAll, undo: () => undo, redo: () => redo, }; } ================================================ FILE: apps/editor/src/commands/wwCommands.ts ================================================ import { isInListNode } from '@/wysiwyg/helper/node'; import { sinkListItem, liftListItem } from '@/wysiwyg/command/list'; import { EditorCommand } from '@t/spec'; function indent(): EditorCommand { return () => (state, dispatch) => { const { selection, schema } = state; const { $from, $to } = selection; const range = $from.blockRange($to); if (range && isInListNode($from)) { return sinkListItem(schema.nodes.listItem)(state, dispatch); } return false; }; } function outdent(): EditorCommand { return () => (state, dispatch) => { const { selection, schema } = state; const { $from, $to } = selection; const range = $from.blockRange($to); if (range && isInListNode($from)) { return liftListItem(schema.nodes.listItem)(state, dispatch); } return false; }; } export function getWwCommands(): Record { return { indent: indent(), outdent: outdent(), }; } ================================================ FILE: apps/editor/src/convertors/convertor.ts ================================================ import { Node as ProsemirrorNode, Schema } from 'prosemirror-model'; import { HTMLConvertorMap, MdNode, MdPos } from '@toast-ui/toastmark'; import { ToWwConvertorMap, ToMdConvertors, ToMdConvertorMap } from '@t/convertor'; import { Emitter } from '@t/event'; import { createWwConvertors } from './toWysiwyg/toWwConvertors'; import ToWwConvertorState from './toWysiwyg/toWwConvertorState'; import { createMdConvertors } from './toMarkdown/toMdConvertors'; import ToMdConvertorState from './toMarkdown/toMdConvertorState'; export default class Convertor { private readonly schema: Schema; private readonly toWwConvertors: ToWwConvertorMap; private readonly toMdConvertors: ToMdConvertors; private readonly eventEmitter: Emitter; private focusedNode: ProsemirrorNode | MdNode | null; private mappedPosWhenConverting: number | MdPos | null; constructor( schema: Schema, toMdConvertors: ToMdConvertorMap, toHTMLConvertors: HTMLConvertorMap, eventEmitter: Emitter ) { this.schema = schema; this.eventEmitter = eventEmitter; this.focusedNode = null; this.mappedPosWhenConverting = null; this.toWwConvertors = createWwConvertors(toHTMLConvertors); this.toMdConvertors = createMdConvertors(toMdConvertors || {}); this.eventEmitter.listen( 'setFocusedNode', (node: ProsemirrorNode | MdNode) => (this.focusedNode = node) ); } getMappedPos() { return this.mappedPosWhenConverting; } setMappedPos = (pos: number | MdPos) => { this.mappedPosWhenConverting = pos; }; private getInfoForPosSync() { return { node: this.focusedNode, setMappedPos: this.setMappedPos }; } toWysiwygModel(mdNode: MdNode) { const state = new ToWwConvertorState(this.schema, this.toWwConvertors); return state.convertNode(mdNode, this.getInfoForPosSync()); } toMarkdownText(wwNode: ProsemirrorNode) { const state = new ToMdConvertorState(this.toMdConvertors); let markdownText = state.convertNode(wwNode, this.getInfoForPosSync()); markdownText = this.eventEmitter.emitReduce('beforeConvertWysiwygToMarkdown', markdownText); return markdownText; } } ================================================ FILE: apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts ================================================ import { Node, Mark } from 'prosemirror-model'; import { includes, escape, last } from '@/utils/common'; import { WwNodeType, WwMarkType } from '@t/wysiwyg'; import { ToMdConvertors, ToMdNodeTypeConvertorMap, ToMdMarkTypeConvertorMap, FirstDelimFn, InfoForPosSync, } from '@t/convertor'; export default class ToMdConvertorState { private readonly nodeTypeConvertors: ToMdNodeTypeConvertorMap; private readonly markTypeConvertors: ToMdMarkTypeConvertorMap; private delim: string; private result: string; private closed: boolean | Node; private tightList: boolean; public stopNewline: boolean; public inTable: boolean; constructor({ nodeTypeConvertors, markTypeConvertors }: ToMdConvertors) { this.nodeTypeConvertors = nodeTypeConvertors; this.markTypeConvertors = markTypeConvertors; this.delim = ''; this.result = ''; this.closed = false; this.tightList = false; this.stopNewline = false; this.inTable = false; } private getMarkConvertor(mark: Mark) { const type = mark.attrs.htmlInline ? 'html' : (mark.type.name as WwMarkType); return this.markTypeConvertors[type]; } private isInBlank() { return /(^|\n)$/.test(this.result); } 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); return (rawHTML as string) || (delim as string); } return ''; } setDelim(delim: string) { this.delim = delim; } getDelim() { return this.delim; } flushClose(size?: number) { if (!this.stopNewline && this.closed) { if (!this.isInBlank()) { this.result += '\n'; } if (!size) { size = 2; } if (size > 1) { let delimMin = this.delim; const trim = /\s+$/.exec(delimMin); if (trim) { delimMin = delimMin.slice(0, delimMin.length - trim[0].length); } for (let i = 1; i < size; i += 1) { this.result += `${delimMin}\n`; } } this.closed = false; } } wrapBlock(delim: string, firstDelim: string | null, node: Node, fn: () => void) { const old = this.getDelim(); this.write(firstDelim || delim); this.setDelim(this.getDelim() + delim); fn(); this.setDelim(old); this.closeBlock(node); } ensureNewLine() { if (!this.isInBlank()) { this.result += '\n'; } } write(content = '') { this.flushClose(); if (this.delim && this.isInBlank()) { this.result += this.delim; } if (content) { this.result += content; } } closeBlock(node: Node) { this.closed = node; } text(text: string, escaped = true) { const lines = text.split('\n'); for (let i = 0; i < lines.length; i += 1) { this.write(); this.result += escaped ? escape(lines[i]) : lines[i]; if (i !== lines.length - 1) { this.result += '\n'; } } } convertBlock(node: Node, parent: Node, index: number) { const type = node.type.name as WwNodeType; const convertor = this.nodeTypeConvertors[type]; const nodeInfo = { node, parent, index }; if (node.attrs.htmlBlock) { this.nodeTypeConvertors.html!(this, nodeInfo); } else if (convertor) { convertor(this, nodeInfo); } } convertInline(parent: Node) { const active: Mark[] = []; let trailing = ''; const progress = (node: Node | null, _: number | null, index: number) => { let marks = node ? (node.marks as Mark[]) : []; let leading = trailing; trailing = ''; // If whitespace has to be expelled from the node, adjust // leading and trailing accordingly. const removedWhitespace = node && node.isText && marks.some((mark: Mark) => { const markConvertor = this.getMarkConvertor(mark); const info = markConvertor && markConvertor(); return info && info.removedEnclosingWhitespace; }); if (removedWhitespace && node && node.text) { const [, lead, mark, trail] = /^(\s*)(.*?)(\s*)$/m.exec(node.text)!; leading += lead; trailing = trail; if (lead || trail) { // @ts-ignore // type is not defined for "withText" in prosemirror-model node = mark ? node.withText(mark) : null; if (!node) { marks = active; } } } const lastMark = marks.length && last(marks); const markConvertor = lastMark && this.getMarkConvertor(lastMark); const markType = markConvertor && markConvertor(); const noEscape = markType && markType.escape === false; const len = marks.length - (noEscape ? 1 : 0); // Try to reorder 'mixable' marks, such as em and strong, which // in Markdown may be opened and closed in different order, so // that order of the marks for the token matches the order in // active. for (let i = 0; i < len; i += 1) { const mark = marks[i]; if (markType && !markType.mixable) { break; } for (let j = 0; j < active.length; j += 1) { const other = active[j]; if (markType && !markType.mixable) { break; } if (mark.eq(other)) { // eslint-disable-next-line max-depth if (i > j) { marks = marks .slice(0, j) .concat(mark) .concat(marks.slice(j, i)) .concat(marks.slice(i + 1, len)); } else if (j > i) { marks = marks .slice(0, i) .concat(marks.slice(i + 1, j)) .concat(mark) .concat(marks.slice(j, len)); } break; } } } // Find the prefix of the mark set that didn't change let keep = 0; while (keep < Math.min(active.length, len) && marks[keep].eq(active[keep])) { keep += 1; } // Close the marks that need to be closed while (keep < active.length) { const activedMark = active.pop(); if (activedMark) { this.text(this.markText(activedMark, false, parent, index), false); } } // Output any previously expelled trailing whitespace outside the marks if (leading) { this.text(leading); } // Open the marks that need to be opened if (node) { while (active.length < len) { const mark = marks[active.length]; active.push(mark); this.text(this.markText(mark, true, parent, index), false); } // Render the node. Special case code marks, since their content // may not be escaped. if (noEscape && node.isText) { this.text( this.markText(lastMark as Mark, true, parent, index) + node.text + this.markText(lastMark as Mark, false, parent, index + 1), false ); } else { this.convertBlock(node, parent, index); } } }; parent.forEach(progress); progress(null, null, parent.childCount); } // Render a node's content as a list. `delim` should be the extra // indentation added to all lines except the first in an item, // `firstDelimFn` is a function going from an item index to a // delimiter for the first line of the item. convertList(node: Node, delim: string, firstDelimFn: FirstDelimFn) { if (this.closed && (this.closed as Node).type === node.type) { this.flushClose(3); } else if (this.tightList) { this.flushClose(1); } const tight = node.attrs.tight ?? true; const prevTight = this.tightList; this.tightList = tight; node.forEach((child, _, index) => { if (index && tight) { this.flushClose(1); } this.wrapBlock(delim, firstDelimFn(index), node, () => this.convertBlock(child, node, index)); }); this.tightList = prevTight; } convertTableCell(node: Node) { this.stopNewline = true; this.inTable = true; node.forEach((child, _, index) => { if (includes(['bulletList', 'orderedList'], child.type.name)) { this.convertBlock(child, node, index); this.closed = false; } else { this.convertInline(child); if (index < node.childCount - 1) { const nextChild = node.child(index + 1); if (nextChild.type.name === 'paragraph') { this.write('
                  '); } } } }); this.stopNewline = false; this.inTable = false; } convertNode(parent: Node, infoForPosSync?: InfoForPosSync | null) { parent.forEach((node, _, index) => { this.convertBlock(node, parent, index); if (infoForPosSync?.node === node) { const lineTexts = this.result.split('\n'); infoForPosSync.setMappedPos([lineTexts.length, last(lineTexts).length + 1]); } }); return this.result; } } ================================================ FILE: apps/editor/src/convertors/toMarkdown/toMdConvertors.ts ================================================ import { ProsemirrorNode } from 'prosemirror-model'; import isUndefined from 'tui-code-snippet/type/isUndefined'; import { nodeTypeWriters, write } from './toMdNodeTypeWriters'; import { repeat, quote, escapeXml, escapeTextForLink } from '@/utils/common'; import { ToMdConvertorMap, ToMdNodeTypeConvertorMap, ToMdMarkTypeConvertorMap, ToMdMarkTypeOptions, NodeInfo, MarkInfo, } from '@t/convertor'; import { WwNodeType, WwMarkType } from '@t/wysiwyg'; function addBackticks(node: ProsemirrorNode, side: number) { const { text } = node; const ticks = /`+/g; let len = 0; if (node.isText && text) { let matched = ticks.exec(text); while (matched) { len = Math.max(len, matched[0].length); matched = ticks.exec(text); } } let result = len > 0 && side > 0 ? ' `' : '`'; for (let i = 0; i < len; i += 1) { result += '`'; } if (len > 0 && side < 0) { result += ' '; } return result; } function getPairRawHTML(rawHTML?: string[]) { return rawHTML ? [`<${rawHTML}>`, ``] : null; } function getOpenRawHTML(rawHTML?: string) { return rawHTML ? `<${rawHTML}>` : null; } function getCloseRawHTML(rawHTML?: string) { return rawHTML ? `` : null; } export const toMdConvertors: ToMdConvertorMap = { heading({ node }) { const { attrs } = node; const { level } = attrs; let delim = repeat('#', level); if (attrs.headingType === 'setext') { delim = level === 1 ? '===' : '---'; } return { delim, rawHTML: getPairRawHTML(attrs.rawHTML), }; }, codeBlock({ node }) { const { attrs, textContent } = node as ProsemirrorNode; return { delim: [`\`\`\`${attrs.language || ''}`, '```'], rawHTML: getPairRawHTML(attrs.rawHTML), text: textContent, }; }, blockQuote({ node }) { return { delim: '> ', rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, bulletList({ node }, { inTable }) { let { rawHTML } = node.attrs; if (inTable) { rawHTML = rawHTML || 'ul'; } return { delim: '*', rawHTML: getPairRawHTML(rawHTML), }; }, orderedList({ node }, { inTable }) { let { rawHTML } = node.attrs; if (inTable) { rawHTML = rawHTML || 'ol'; } return { rawHTML: getPairRawHTML(rawHTML), }; }, listItem({ node }, { inTable }) { const { task, checked } = node.attrs; let { rawHTML } = node.attrs; if (inTable) { rawHTML = rawHTML || 'li'; } const className = task ? ` class="task-list-item${checked ? ' checked' : ''}"` : ''; const dataset = task ? ` data-task${checked ? ` data-task-checked` : ''}` : ''; return { rawHTML: rawHTML ? [`<${rawHTML}${className}${dataset}>`, ``] : null, }; }, table({ node }) { return { rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, tableHead({ node }) { return { rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, tableBody({ node }) { return { rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, tableRow({ node }) { return { rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, tableHeadCell({ node }) { return { rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, tableBodyCell({ node }) { return { rawHTML: getPairRawHTML(node.attrs.rawHTML), }; }, image({ node }) { const { attrs } = node; const { rawHTML, altText } = attrs; const imageUrl = attrs.imageUrl.replace(/&/g, '&'); const altAttr = altText ? ` alt="${escapeXml(altText)}"` : ''; return { rawHTML: rawHTML ? `<${rawHTML} src="${escapeXml(imageUrl)}"${altAttr}>` : null, attrs: { altText: escapeTextForLink(altText || ''), imageUrl, }, }; }, thematicBreak({ node }) { return { delim: '***', rawHTML: getOpenRawHTML(node.attrs.rawHTML), }; }, customBlock({ node }) { const { attrs, textContent } = node as ProsemirrorNode; return { delim: [`$$${attrs.info}`, '$$'], text: textContent, }; }, frontMatter({ node }) { return { text: (node as ProsemirrorNode).textContent, }; }, widget({ node }) { return { text: (node as ProsemirrorNode).textContent, }; }, strong({ node }, { entering }) { const { rawHTML } = node.attrs; return { delim: '**', rawHTML: entering ? getOpenRawHTML(rawHTML) : getCloseRawHTML(rawHTML), }; }, emph({ node }, { entering }) { const { rawHTML } = node.attrs; return { delim: '*', rawHTML: entering ? getOpenRawHTML(rawHTML) : getCloseRawHTML(rawHTML), }; }, strike({ node }, { entering }) { const { rawHTML } = node.attrs; return { delim: '~~', rawHTML: entering ? getOpenRawHTML(rawHTML) : getCloseRawHTML(rawHTML), }; }, link({ node }, { entering }) { const { attrs } = node; const { title, rawHTML } = attrs; const linkUrl = attrs.linkUrl.replace(/&/g, '&'); const titleAttr = title ? ` title="${escapeXml(title)}"` : ''; if (entering) { return { delim: '[', rawHTML: rawHTML ? `<${rawHTML} href="${escapeXml(linkUrl)}"${titleAttr}>` : null, }; } return { delim: `](${linkUrl}${title ? ` ${quote(escapeTextForLink(title))}` : ''})`, rawHTML: getCloseRawHTML(rawHTML), }; }, code({ node, parent, index = 0 }, { entering }) { const delim = entering ? addBackticks(parent!.child(index), -1) : addBackticks(parent!.child(index - 1), 1); const rawHTML = entering ? getOpenRawHTML(node.attrs.rawHTML) : getCloseRawHTML(node.attrs.rawHTML); return { delim, rawHTML, }; }, htmlComment({ node }) { return { text: (node as ProsemirrorNode).textContent, }; }, // html inline node, html block node html({ node }, { entering }) { const tagName = node.type.name; const attrs = node.attrs.htmlAttrs; let openTag = `<${tagName}`; const closeTag = ``; Object.keys(attrs).forEach((attrName) => { // To prevent broken converting when attributes has double quote string openTag += ` ${attrName}="${attrs[attrName].replace(/"/g, "'")}"`; }); openTag += '>'; if (node.attrs.htmlInline) { return { rawHTML: entering ? openTag : closeTag, }; } return { text: `${openTag}${node.attrs.childrenHTML}${closeTag}`, }; }, }; const markTypeOptions: ToMdMarkTypeOptions = { strong: { mixable: true, removedEnclosingWhitespace: true, }, emph: { mixable: true, removedEnclosingWhitespace: true, }, strike: { mixable: true, removedEnclosingWhitespace: true, }, code: { escape: false, }, link: null, html: null, }; function createNodeTypeConvertors(convertors: ToMdConvertorMap) { const nodeTypeConvertors: ToMdNodeTypeConvertorMap = {}; const nodeTypes = Object.keys(nodeTypeWriters) as WwNodeType[]; nodeTypes.forEach((type) => { nodeTypeConvertors[type] = (state, nodeInfo) => { const writer = nodeTypeWriters[type]; if (writer) { const convertor = convertors[type]; const params = convertor ? convertor(nodeInfo as NodeInfo, { inTable: state.inTable, }) : {}; write(type, { state, nodeInfo, params }); } }; }); return nodeTypeConvertors; } function createMarkTypeConvertors(convertors: ToMdConvertorMap) { const markTypeConvertors: ToMdMarkTypeConvertorMap = {}; const markTypes = Object.keys(markTypeOptions) as WwMarkType[]; markTypes.forEach((type) => { markTypeConvertors[type] = (nodeInfo, entering) => { const markOption = markTypeOptions[type]; const convertor = convertors[type]; // There are two ways to call the mark type converter // in the `toMdConvertorState` module. // 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 }) : {}; return { ...params, ...markOption }; }; }); return markTypeConvertors; } // Step 1: Create the converter by overriding the custom converter // to the original converter defined in the `toMdConvertors` module. // If the node type is defined in the original converter, // the `origin()` function is exported to the paramter of the converter. // Step 2: Create a converter for the node type of ProseMirror by combining the converter // created in Step 1 with the writers defined in the`toMdNodeTypeWriters` module. // Each writer converts the ProseMirror's node to a string with the value returned // by the converter, and then stores the state in the`toMdConverterState` class. // Step 3: Create a converter for the mark type of ProseMirror by combining the converter // created in Step 1 with `markTypeOptions`. // Step 4: The created node type converter and mark type converter are injected // when creating an instance of the`toMdConverterState` class. export function createMdConvertors(customConvertors: ToMdConvertorMap) { const customConvertorTypes = Object.keys(customConvertors) as (WwNodeType | WwMarkType)[]; customConvertorTypes.forEach((type) => { const baseConvertor = toMdConvertors[type]; const customConvertor = customConvertors[type]!; if (baseConvertor) { toMdConvertors[type] = (nodeInfo, context) => { context.origin = () => baseConvertor(nodeInfo, context); return customConvertor(nodeInfo, context); }; } else { toMdConvertors[type] = customConvertor; } delete customConvertors[type]; }); const nodeTypeConvertors = createNodeTypeConvertors(toMdConvertors); const markTypeConvertors = createMarkTypeConvertors(toMdConvertors); return { nodeTypeConvertors, markTypeConvertors, }; } ================================================ FILE: apps/editor/src/convertors/toMarkdown/toMdNodeTypeWriters.ts ================================================ import { ProsemirrorNode } from 'prosemirror-model'; import inArray from 'tui-code-snippet/array/inArray'; import { escapeTextForLink, repeat } from '@/utils/common'; import { ToMdNodeTypeWriterMap, ToMdConvertorState, NodeInfo, ToMdConvertorReturnValues, } from '@t/convertor'; import { WwNodeType, ColumnAlign } from '@t/wysiwyg'; function convertToRawHTMLHavingInlines( state: ToMdConvertorState, node: ProsemirrorNode, [openTag, closeTag]: string[] ) { state.write(openTag); state.convertInline(node); state.write(closeTag); } function convertToRawHTMLHavingBlocks( state: ToMdConvertorState, { node, parent }: NodeInfo, [openTag, closeTag]: string[] ) { state.stopNewline = true; state.write(openTag); state.convertNode(node); state.write(closeTag); if (parent?.type.name === 'doc') { state.closeBlock(node); state.stopNewline = false; } } function createTableHeadDelim(textContent: string, columnAlign: ColumnAlign) { let textLen = textContent.length; let leftDelim = ''; let rightDelim = ''; if (columnAlign === 'left') { leftDelim = ':'; textLen -= 1; } else if (columnAlign === 'right') { rightDelim = ':'; textLen -= 1; } else if (columnAlign === 'center') { leftDelim = ':'; rightDelim = ':'; textLen -= 2; } return `${leftDelim}${repeat('-', Math.max(textLen, 3))}${rightDelim}`; } export const nodeTypeWriters: ToMdNodeTypeWriterMap = { text(state, { node }) { const text = node.text ?? ''; if ((node.marks || []).some((mark) => mark.type.name === 'link')) { state.text(escapeTextForLink(text), false); } else { state.text(text); } }, paragraph(state, { node, parent, index = 0 }) { if (state.stopNewline) { state.convertInline(node); } else { const firstChildNode = index === 0; const prevNode = !firstChildNode && parent!.child(index - 1); const prevEmptyNode = prevNode && prevNode.childCount === 0; const nextNode = index < parent!.childCount - 1 && parent!.child(index + 1); const nextParaNode = nextNode && nextNode.type.name === 'paragraph'; const emptyNode = node.childCount === 0; if (emptyNode && prevEmptyNode) { state.write('
                  \n'); } else if (emptyNode && !prevEmptyNode && !firstChildNode) { if (parent?.type.name === 'listItem') { const prevDelim = state.getDelim(); state.setDelim(''); state.write('
                  '); state.setDelim(prevDelim); } state.write('\n'); } else { state.convertInline(node); if (nextParaNode) { state.write('\n'); } else { state.closeBlock(node); } } } }, heading(state, { node }, { delim }) { const { headingType } = node.attrs; if (headingType === 'atx') { state.write(`${delim} `); state.convertInline(node); state.closeBlock(node); } else { state.convertInline(node); state.ensureNewLine(); state.write(delim as string); state.closeBlock(node); } }, codeBlock(state, { node }, { delim, text }) { const [openDelim, closeDelim] = delim as string[]; state.write(openDelim); state.ensureNewLine(); state.text(text!, false); state.ensureNewLine(); state.write(closeDelim); state.closeBlock(node); }, blockQuote(state, { node, parent }, { delim }) { if (parent?.type.name === node.type.name) { state.flushClose(1); } state.wrapBlock(delim as string, null, node, () => state.convertNode(node)); }, bulletList(state, { node }, { delim }) { // soft-tab(4) state.convertList(node, repeat(' ', 4), () => `${delim} `); }, orderedList(state, { node }) { const start = node.attrs.order || 1; // soft-tab(4) state.convertList(node, repeat(' ', 4), (index: number) => { const orderedNum = String(start + index); return `${orderedNum}. `; }); }, listItem(state, { node }) { const { task, checked } = node.attrs; if (task) { state.write(`[${checked ? 'x' : ' '}] `); } state.convertNode(node); }, image(state, _, { attrs }) { state.write(`![${attrs?.altText}](${attrs?.imageUrl})`); }, thematicBreak(state, { node }, { delim }) { state.write(delim as string); state.closeBlock(node); }, table(state, { node }) { state.convertNode(node); state.closeBlock(node); }, tableHead(state, { node }, { delim }) { const row = node.firstChild; state.convertNode(node); let result = delim ?? ''; if (!delim && row) { row.forEach(({ textContent, attrs }) => { const headDelim = createTableHeadDelim(textContent, attrs.align); result += `| ${headDelim} `; }); } state.write(`${result}|`); state.ensureNewLine(); }, tableBody(state, { node }) { state.convertNode(node); }, tableRow(state, { node }) { state.convertNode(node); state.write('|'); state.ensureNewLine(); }, tableHeadCell(state, { node }, { delim = '| ' }) { state.write(delim as string); state.convertTableCell(node); state.write(' '); }, tableBodyCell(state, { node }, { delim = '| ' }) { state.write(delim as string); state.convertTableCell(node); state.write(' '); }, customBlock(state, { node }, { delim, text }) { const [openDelim, closeDelim] = delim as string[]; state.write(openDelim); state.ensureNewLine(); state.text(text!, false); state.ensureNewLine(); state.write(closeDelim); state.closeBlock(node); }, frontMatter(state, { node }, { text }) { state.text(text!, false); state.closeBlock(node); }, widget(state, _, { text }) { state.write(text); }, html(state, { node }, { text }) { state.write(text); if (node.attrs.htmlBlock) { state.closeBlock(node); } }, htmlComment(state, { node }, { text }) { state.write(text); state.closeBlock(node); }, }; export function write( type: WwNodeType, { state, nodeInfo, params, }: { state: ToMdConvertorState; nodeInfo: NodeInfo; params: ToMdConvertorReturnValues; } ) { const { rawHTML } = params; if (rawHTML) { if (inArray(type, ['heading', 'codeBlock']) > -1) { convertToRawHTMLHavingInlines(state, nodeInfo.node, rawHTML as string[]); } else if (inArray(type, ['image', 'thematicBreak']) > -1) { state.write(rawHTML as string); } else { convertToRawHTMLHavingBlocks(state, nodeInfo, rawHTML as string[]); } } else { nodeTypeWriters[type]!(state, nodeInfo, params); } } ================================================ FILE: apps/editor/src/convertors/toWysiwyg/htmlToWwConvertors.ts ================================================ import { MdNode } from '@toast-ui/toastmark'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import { HTMLToWwConvertorMap, FlattenHTMLToWwConvertorMap, ToWwConvertorState, } from '@t/convertor'; import { includes } from '@/utils/common'; import { reHTMLTag } from '@/utils/constants'; export function getTextWithoutTrailingNewline(text: string) { return text[text.length - 1] === '\n' ? text.slice(0, text.length - 1) : text; } export function isCustomHTMLInlineNode({ schema }: ToWwConvertorState, node: MdNode) { const html = node.literal!; const matched = html.match(reHTMLTag); if (matched) { const [, openTagName, , closeTagName] = matched; const typeName = (openTagName || closeTagName).toLowerCase(); return node.type === 'htmlInline' && !!(schema.marks[typeName] || schema.nodes[typeName]); } return false; } export function isInlineNode({ type }: MdNode) { return includes(['text', 'strong', 'emph', 'strike', 'image', 'link', 'code'], type); } function isSoftbreak(mdNode: MdNode | null) { return mdNode?.type === 'softbreak'; } function isListNode({ type, literal }: MdNode) { const matched = type === 'htmlInline' && literal!.match(reHTMLTag); if (matched) { const [, openTagName, , closeTagName] = matched; const tagName = openTagName || closeTagName; if (tagName) { return includes(['ul', 'ol', 'li'], tagName.toLowerCase()); } } return false; } function getListItemAttrs({ literal }: MdNode) { const task = /data-task/.test(literal!); const checked = /data-task-checked/.test(literal!); return { task, checked }; } function getMatchedAttributeValue(rawHTML: string, ...attrNames: string[]) { const wrapper = document.createElement('div'); wrapper.innerHTML = sanitizeHTML(rawHTML); const el = wrapper.firstChild as HTMLElement; return attrNames.map((attrName) => el.getAttribute(attrName) || ''); } function createConvertors(convertors: HTMLToWwConvertorMap) { const convertorMap: FlattenHTMLToWwConvertorMap = {}; Object.keys(convertors).forEach((key) => { const tagNames = key.split(', '); tagNames.forEach((tagName) => { const name = tagName.toLowerCase(); convertorMap[name] = convertors[key]!; }); }); return convertorMap; } const convertors: HTMLToWwConvertorMap = { 'b, strong': (state, _, openTagName) => { const { strong } = state.schema.marks; if (openTagName) { state.openMark(strong.create({ rawHTML: openTagName })); } else { state.closeMark(strong); } }, 'i, em': (state, _, openTagName) => { const { emph } = state.schema.marks; if (openTagName) { state.openMark(emph.create({ rawHTML: openTagName })); } else { state.closeMark(emph); } }, 's, del': (state, _, openTagName) => { const { strike } = state.schema.marks; if (openTagName) { state.openMark(strike.create({ rawHTML: openTagName })); } else { state.closeMark(strike); } }, code: (state, _, openTagName) => { const { code } = state.schema.marks; if (openTagName) { state.openMark(code.create({ rawHTML: openTagName })); } else { state.closeMark(code); } }, a: (state, node, openTagName) => { const tag = node.literal!; const { link } = state.schema.marks; if (openTagName) { const [linkUrl] = getMatchedAttributeValue(tag, 'href'); state.openMark( link.create({ linkUrl, rawHTML: openTagName, }) ); } else { state.closeMark(link); } }, img: (state, node, openTagName) => { const tag = node.literal!; if (openTagName) { const [imageUrl, altText] = getMatchedAttributeValue(tag, 'src', 'alt'); const { image } = state.schema.nodes; state.addNode(image, { rawHTML: openTagName, imageUrl, ...(altText && { altText }), }); } }, hr: (state, _, openTagName) => { state.addNode(state.schema.nodes.thematicBreak, { rawHTML: openTagName }); }, br: (state, node) => { const { paragraph } = state.schema.nodes; const { parent, prev, next } = node; if (parent?.type === 'paragraph') { // should open a paragraph node when line text has only
                  tag // ex) first line\n\n
                  \nfourth line if (isSoftbreak(prev)) { state.openNode(paragraph); } // should close a paragraph node when line text has only
                  tag // ex) first line\n\n
                  \nfourth line if (isSoftbreak(next)) { state.closeNode(); // should close a paragraph node and open a paragraph node to separate between blocks // when
                  tag is in the middle of the paragraph // ex) first
                  line\nthird line } else if (next) { state.closeNode(); state.openNode(paragraph); } } else if (parent?.type === 'tableCell') { if (prev && (isInlineNode(prev) || isCustomHTMLInlineNode(state, prev))) { state.closeNode(); } if (next && (isInlineNode(next) || isCustomHTMLInlineNode(state, next))) { state.openNode(paragraph); } } }, pre: (state, node, openTagName) => { const container = document.createElement('div'); container.innerHTML = node.literal!; const literal = container.firstChild?.firstChild?.textContent; state.openNode(state.schema.nodes.codeBlock, { rawHTML: openTagName }); state.addText(getTextWithoutTrailingNewline(literal!)); state.closeNode(); }, 'ul, ol': (state, node, openTagName) => { // in the table cell, '
                    ', '
                      ' is parsed as 'htmlInline' node if (node.parent!.type === 'tableCell') { const { bulletList, orderedList, paragraph } = state.schema.nodes; const list = openTagName === 'ul' ? bulletList : orderedList; if (openTagName) { if (node.prev && !isListNode(node.prev)) { state.closeNode(); } state.openNode(list, { rawHTML: openTagName }); } else { state.closeNode(); if (node.next && !isListNode(node.next)) { state.openNode(paragraph); } } } }, li: (state, node, openTagName) => { // in the table cell, '
                    1. ' is parsed as 'htmlInline' node if (node.parent?.type === 'tableCell') { const { listItem, paragraph } = state.schema.nodes; if (openTagName) { const attrs = getListItemAttrs(node); if (node.prev && !isListNode(node.prev)) { state.closeNode(); } state.openNode(listItem, { rawHTML: openTagName, ...attrs }); if (node.next && !isListNode(node.next)) { state.openNode(paragraph); } } else { if (node.prev && !isListNode(node.prev)) { state.closeNode(); } state.closeNode(); } } }, }; export const htmlToWwConvertors = createConvertors(convertors); ================================================ FILE: apps/editor/src/convertors/toWysiwyg/toWwConvertorState.ts ================================================ import { Schema, Node, NodeType, Mark, MarkType, DOMParser } from 'prosemirror-model'; import { MdNode } from '@toast-ui/toastmark'; import { ToWwConvertorMap, StackItem, Attrs, InfoForPosSync } from '@t/convertor'; import { last } from '@/utils/common'; import { isContainer, getChildrenText } from '@/utils/markdown'; export function mergeMarkText(a: Node, b: Node) { if (a.isText && b.isText && Mark.sameSet(a.marks, b.marks)) { // @ts-ignore // type is not defined for "withText" in prosemirror-model return a.withText(a.text! + b.text); } return false; } export default class ToWwConvertorState { public readonly schema: Schema; private readonly convertors: ToWwConvertorMap; private stack: StackItem[]; private marks: Mark[]; constructor(schema: Schema, convertors: ToWwConvertorMap) { this.schema = schema; this.convertors = convertors; this.stack = [{ type: this.schema.topNodeType, attrs: null, content: [] }]; this.marks = Mark.none as Mark[]; } top() { return last(this.stack); } push(node: Node) { if (this.stack.length) { this.top().content.push(node); } } addText(text: string) { if (text) { const nodes = this.top().content; const lastNode = last(nodes); const node = this.schema.text(text, this.marks); const merged = lastNode && mergeMarkText(lastNode, node); if (merged) { nodes[nodes.length - 1] = merged; } else { nodes.push(node); } } } openMark(mark: Mark) { this.marks = mark.addToSet(this.marks) as Mark[]; } closeMark(mark: MarkType) { this.marks = mark.removeFromSet(this.marks) as Mark[]; } addNode(type: NodeType, attrs: Attrs, content: Node[]) { const node = type.createAndFill(attrs, content, this.marks); if (node) { this.push(node); return node; } return null; } openNode(type: NodeType, attrs: Attrs) { this.stack.push({ type, attrs, content: [] }); } closeNode() { if (this.marks.length) { this.marks = Mark.none as Mark[]; } const { type, attrs, content } = this.stack.pop() as StackItem; return this.addNode(type, attrs, content); } convertByDOMParser(root: HTMLElement) { const doc = DOMParser.fromSchema(this.schema).parse(root); doc.content.forEach((node) => this.push(node)); } private closeUnmatchedHTMLInline(node: MdNode, entering: boolean) { if (!entering && node.type !== 'htmlInline') { const length = this.stack.length - 1; for (let i = length; i >= 0; i -= 1) { const nodeInfo = this.stack[i]; if (nodeInfo.attrs?.rawHTML) { if (nodeInfo.content.length) { this.closeNode(); } else { // just pop useless unmatched html inline node this.stack.pop(); } } else { break; } } } } private convert(mdNode: MdNode, infoForPosSync?: InfoForPosSync) { const walker = mdNode.walker(); let event = walker.next(); while (event) { const { node, entering } = event; const convertor = this.convertors[node.type]; let skipped = false; if (convertor) { const context = { entering, leaf: !isContainer(node), getChildrenText, options: { gfm: true, nodeId: false, tagFilter: false, softbreak: '\n' }, skipChildren: () => { skipped = true; }, }; this.closeUnmatchedHTMLInline(node, entering); convertor(this, node, context); if (infoForPosSync?.node === node) { const pos = this.stack.reduce( (nodeSize, stackItem) => nodeSize + stackItem.content.reduce((contentSize, pmNode) => contentSize + pmNode.nodeSize, 0), 0 ) + 1; infoForPosSync.setMappedPos(pos); } } if (skipped) { walker.resumeAt(node, false); walker.next(); } event = walker.next(); } } convertNode(mdNode: MdNode, infoForPosSync?: InfoForPosSync) { this.convert(mdNode, infoForPosSync); if (this.stack.length) { return this.closeNode(); } return null; } } ================================================ FILE: apps/editor/src/convertors/toWysiwyg/toWwConvertors.ts ================================================ import { MdNode, HeadingMdNode, CodeBlockMdNode, ListItemMdNode, LinkMdNode, TableCellMdNode, CustomBlockMdNode, CustomInlineMdNode, TableMdNode, HTMLConvertorMap, OpenTagToken, Renderer, } from '@toast-ui/toastmark'; import toArray from 'tui-code-snippet/collection/toArray'; import { isElemNode } from '@/utils/dom'; import { htmlToWwConvertors, getTextWithoutTrailingNewline, isInlineNode, isCustomHTMLInlineNode, } from './htmlToWwConvertors'; import { ToWwConvertorMap } from '@t/convertor'; import { createWidgetContent, getWidgetContent } from '@/widget/rules'; import { getChildrenHTML, getHTMLAttrsByHTMLString } from '@/wysiwyg/nodes/html'; import { includes } from '@/utils/common'; import { reBR, reHTMLTag, reHTMLComment } from '@/utils/constants'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; function isBRTag(node: MdNode) { return node.type === 'htmlInline' && reBR.test(node.literal!); } function addRawHTMLAttributeToDOM(parent: Node) { toArray(parent.childNodes).forEach((child) => { if (isElemNode(child)) { const openTagName = child.nodeName.toLowerCase(); (child as HTMLElement).setAttribute('data-raw-html', openTagName); if (child.childNodes) { addRawHTMLAttributeToDOM(child); } } }); } const toWwConvertors: ToWwConvertorMap = { text(state, node) { state.addText(node.literal || ''); }, paragraph(state, node, { entering }, customAttrs) { if (entering) { const { paragraph } = state.schema.nodes; // The `\n\n` entered in markdown separates the paragraph. // When changing to wysiwyg, a newline is added between the two paragraphs. if (node.prev?.type === 'paragraph') { state.openNode(paragraph, customAttrs); state.closeNode(); } state.openNode(paragraph, customAttrs); } else { state.closeNode(); } }, heading(state, node, { entering }, customAttrs) { if (entering) { const { level, headingType } = node as HeadingMdNode; state.openNode(state.schema.nodes.heading, { level, headingType, ...customAttrs }); } else { state.closeNode(); } }, codeBlock(state, node, customAttrs) { const { codeBlock } = state.schema.nodes; const { info, literal } = node as CodeBlockMdNode; state.openNode(codeBlock, { language: info, ...customAttrs }); state.addText(getTextWithoutTrailingNewline(literal || '')); state.closeNode(); }, list(state, node, { entering }, customAttrs) { if (entering) { const { bulletList, orderedList } = state.schema.nodes; const { type, start } = (node as ListItemMdNode).listData; if (type === 'bullet') { state.openNode(bulletList, customAttrs); } else { state.openNode(orderedList, { order: start, ...customAttrs }); } } else { state.closeNode(); } }, item(state, node, { entering }, customAttrs) { const { listItem } = state.schema.nodes; const { task, checked } = (node as ListItemMdNode).listData; if (entering) { const attrs = { ...(task && { task }), ...(checked && { checked }), ...customAttrs, }; state.openNode(listItem, attrs); } else { state.closeNode(); } }, blockQuote(state, _, { entering }, customAttrs) { if (entering) { state.openNode(state.schema.nodes.blockQuote, customAttrs); } else { state.closeNode(); } }, image(state, node, { entering, skipChildren }, customAttrs) { const { image } = state.schema.nodes; const { destination, firstChild } = node as LinkMdNode; if (entering && skipChildren) { skipChildren(); } state.addNode(image, { imageUrl: destination, ...(firstChild && { altText: firstChild.literal }), ...customAttrs, }); }, thematicBreak(state, node, _, customAttrs) { state.addNode(state.schema.nodes.thematicBreak, customAttrs); }, strong(state, _, { entering }, customAttrs) { const { strong } = state.schema.marks; if (entering) { state.openMark(strong.create(customAttrs)); } else { state.closeMark(strong); } }, emph(state, _, { entering }, customAttrs) { const { emph } = state.schema.marks; if (entering) { state.openMark(emph.create(customAttrs)); } else { state.closeMark(emph); } }, link(state, node, { entering }, customAttrs) { const { link } = state.schema.marks; const { destination, title } = node as LinkMdNode; if (entering) { const attrs = { linkUrl: destination, title, ...customAttrs, }; state.openMark(link.create(attrs)); } else { state.closeMark(link); } }, softbreak(state, node) { if (node.parent!.type === 'paragraph') { const { prev, next } = node; if (prev && !isBRTag(prev)) { state.closeNode(); } if (next && !isBRTag(next)) { state.openNode(state.schema.nodes.paragraph); } } }, // GFM specifications node table(state, _, { entering }, customAttrs) { if (entering) { state.openNode(state.schema.nodes.table, customAttrs); } else { state.closeNode(); } }, tableHead(state, _, { entering }, customAttrs) { if (entering) { state.openNode(state.schema.nodes.tableHead, customAttrs); } else { state.closeNode(); } }, tableBody(state, _, { entering }, customAttrs) { if (entering) { state.openNode(state.schema.nodes.tableBody, customAttrs); } else { state.closeNode(); } }, tableRow(state, _, { entering }, customAttrs) { if (entering) { state.openNode(state.schema.nodes.tableRow, customAttrs); } else { state.closeNode(); } }, tableCell(state, node, { entering }) { if (!(node as TableCellMdNode).ignored) { const hasParaNode = (childNode: MdNode | null) => childNode && (isInlineNode(childNode) || isCustomHTMLInlineNode(state, childNode)); if (entering) { const { tableHeadCell, tableBodyCell, paragraph } = state.schema.nodes; const tablePart = node.parent!.parent!; const cell = tablePart.type === 'tableHead' ? tableHeadCell : tableBodyCell; const table = tablePart.parent as TableMdNode; const { align } = table.columns[(node as TableCellMdNode).startIdx] || {}; const attrs: Record = { ...(node as TableCellMdNode).attrs }; if (align) { attrs.align = align; } state.openNode(cell, attrs); if (hasParaNode(node.firstChild)) { state.openNode(paragraph); } } else { if (hasParaNode(node.lastChild)) { state.closeNode(); } state.closeNode(); } } }, strike(state, _, { entering }, customAttrs) { const { strike } = state.schema.marks; if (entering) { state.openMark(strike.create(customAttrs)); } else { state.closeMark(strike); } }, code(state, node, _, customAttrs) { const { code } = state.schema.marks; state.openMark(code.create(customAttrs)); state.addText(getTextWithoutTrailingNewline(node.literal || '')); state.closeMark(code); }, customBlock(state, node) { const { customBlock, paragraph } = state.schema.nodes; const { info, literal } = node as CustomBlockMdNode; state.openNode(customBlock, { info }); state.addText(getTextWithoutTrailingNewline(literal || '')); state.closeNode(); // add empty line to edit the content in next line if (!node.next) { state.openNode(paragraph); state.closeNode(); } }, frontMatter(state, node) { state.openNode(state.schema.nodes.frontMatter); state.addText(node.literal!); state.closeNode(); }, htmlInline(state, node) { const html = node.literal!; const matched = html.match(reHTMLTag)!; const [, openTagName, , closeTagName] = matched; const typeName = (openTagName || closeTagName).toLowerCase(); const markType = state.schema.marks[typeName]; const sanitizedHTML = sanitizeHTML(html); // for user defined html schema if (markType?.spec.attrs!.htmlInline) { if (openTagName) { const htmlAttrs = getHTMLAttrsByHTMLString(sanitizedHTML); state.openMark(markType.create({ htmlAttrs })); } else { state.closeMark(markType); } } else { const htmlToWwConvertor = htmlToWwConvertors[typeName]; if (htmlToWwConvertor) { htmlToWwConvertor(state, node, openTagName); } } }, htmlBlock(state, node) { const html = node.literal!; const container = document.createElement('div'); const isHTMLComment = reHTMLComment.test(html); if (isHTMLComment) { state.openNode(state.schema.nodes.htmlComment); state.addText(node.literal!); state.closeNode(); } else { const matched = html.match(reHTMLTag)!; const [, openTagName, , closeTagName] = matched; const typeName = (openTagName || closeTagName).toLowerCase(); const nodeType = state.schema.nodes[typeName]; const sanitizedHTML = sanitizeHTML(html); // for user defined html schema if (nodeType?.spec.attrs!.htmlBlock) { const htmlAttrs = getHTMLAttrsByHTMLString(sanitizedHTML); const childrenHTML = getChildrenHTML(node, typeName); state.addNode(nodeType, { htmlAttrs, childrenHTML }); } else { container.innerHTML = sanitizedHTML; addRawHTMLAttributeToDOM(container); state.convertByDOMParser(container as HTMLElement); } } }, customInline(state, node, { entering, skipChildren }) { const { info, firstChild } = node as CustomInlineMdNode; const { schema } = state; if (info.indexOf('widget') !== -1 && entering) { const content = getWidgetContent(node as CustomInlineMdNode); skipChildren(); state.addNode(schema.nodes.widget, { info }, [ schema.text(createWidgetContent(info, content)), ]); } else { let text = '$$'; if (entering) { text += firstChild ? `${info} ` : info; } state.addText(text); } }, }; export function createWwConvertors(customConvertors: HTMLConvertorMap) { const customConvertorTypes = Object.keys(customConvertors); const convertors = { ...toWwConvertors }; const renderer = new Renderer({ gfm: true, nodeId: true, convertors: customConvertors, }); const orgConvertors = renderer.getConvertors(); customConvertorTypes.forEach((type) => { const wwConvertor = toWwConvertors[type]; if (wwConvertor && !includes(['htmlBlock', 'htmlInline'], type)) { convertors[type] = (state, node, context) => { context.origin = () => orgConvertors[type]!(node, context, orgConvertors); const tokens = customConvertors[type]!(node, context) as OpenTagToken; let attrs; if (tokens) { const { attributes: htmlAttrs, classNames } = Array.isArray(tokens) ? tokens[0] : tokens; attrs = { htmlAttrs, classNames }; } wwConvertor(state, node, context, attrs); }; } }); return convertors; } ================================================ FILE: apps/editor/src/css/contents.css ================================================ /* z-index basis -1: pseudo element 20 - preview, wysiwyg 30 - wysiwyg code block language editor, popup, context menu 40 - tooltip */ .ProseMirror { font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', 'Arial', '나눔바른고딕', 'Nanum Barun Gothic', '맑은고딕', 'Malgun Gothic', sans-serif; color: #222; font-size: 13px; overflow-y: auto; overflow-X: hidden; height: calc(100% - 36px); } .ProseMirror .placeholder { color: #999; } .ProseMirror:focus { outline: none; } .ProseMirror-selectednode { outline: none; } table.ProseMirror-selectednode { border-radius: 2px; outline: 2px solid #00a9ff; } .html-block.ProseMirror-selectednode { border-radius: 2px; outline: 2px solid #00a9ff; } .toastui-editor-contents { margin: 0; padding: 0; font-size: 13px; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', 'Arial', '나눔바른고딕', 'Nanum Barun Gothic', '맑은고딕', 'Malgun Gothic', sans-serif; z-index: 20; } .toastui-editor-contents *:not(table) { line-height: 160%; box-sizing: content-box; } .toastui-editor-contents i, .toastui-editor-contents cite, .toastui-editor-contents em, .toastui-editor-contents var, .toastui-editor-contents address, .toastui-editor-contents dfn { font-style: italic; } .toastui-editor-contents strong { font-weight: bold; } .toastui-editor-contents p { margin: 10px 0; color: #222; } .toastui-editor-contents > h1:first-of-type, .toastui-editor-contents > div > div:first-of-type h1 { margin-top: 14px; } .toastui-editor-contents h1, .toastui-editor-contents h2, .toastui-editor-contents h3, .toastui-editor-contents h4, .toastui-editor-contents h5, .toastui-editor-contents h6 { font-weight: bold; color: #222; } .toastui-editor-contents h1 { font-size: 24px; line-height: 28px; border-bottom: 3px double #999; margin: 52px 0 15px 0; padding-bottom: 7px; } .toastui-editor-contents h2 { font-size: 22px; line-height: 23px; border-bottom: 1px solid #dbdbdb; margin: 20px 0 13px 0; padding-bottom: 7px; } .toastui-editor-contents h3 { font-size: 20px; margin: 18px 0 2px; } .toastui-editor-contents h4 { font-size: 18px; margin: 10px 0 2px; } .toastui-editor-contents h3, .toastui-editor-contents h4 { line-height: 18px; } .toastui-editor-contents h5 { font-size: 16px; } .toastui-editor-contents h6 { font-size: 14px; } .toastui-editor-contents h5, .toastui-editor-contents h6 { line-height: 17px; margin: 9px 0 -4px; } .toastui-editor-contents del { color: #999; } .toastui-editor-contents blockquote { margin: 14px 0; border-left: 4px solid #e5e5e5; padding: 0 16px; color: #999; } .toastui-editor-contents blockquote p, .toastui-editor-contents blockquote ul, .toastui-editor-contents blockquote ol { color: #999; } .toastui-editor-contents blockquote > :first-child { margin-top: 0; } .toastui-editor-contents blockquote > :last-child { margin-bottom: 0; } .toastui-editor-contents pre, .toastui-editor-contents code { font-family: Consolas, Courier, 'Apple SD 산돌고딕 Neo', -apple-system, 'Lucida Grande', 'Apple SD Gothic Neo', '맑은 고딕', 'Malgun Gothic', 'Segoe UI', '돋움', dotum, sans-serif; border: 0; border-radius: 0; } .toastui-editor-contents pre { margin: 2px 0 8px; padding: 18px; background-color: #f4f7f8; } .toastui-editor-contents code { color: #c1798b; background-color: #f9f2f4; padding: 2px 3px; letter-spacing: -0.3px; border-radius: 2px; } .toastui-editor-contents pre code { padding: 0; color: inherit; white-space: pre-wrap; background-color: transparent; } .toastui-editor-contents img { margin: 4px 0 10px; box-sizing: border-box; vertical-align: top; max-width: 100%; } .toastui-editor-contents table { border: 1px solid rgba(0, 0, 0, 0.1); margin: 12px 0 14px; color: #222; width: auto; border-collapse: collapse; box-sizing: border-box; } .toastui-editor-contents table th, .toastui-editor-contents table td { border: 1px solid rgba(0, 0, 0, 0.1); padding: 5px 14px 5px 12px; height: 32px; } .toastui-editor-contents table th { background-color: #555; font-weight: 300; color: #fff; padding-top: 6px; } .toastui-editor-contents th p { margin: 0; color: #fff; } .toastui-editor-contents td p { margin: 0; padding: 0 2px; } .toastui-editor-contents td.toastui-editor-cell-selected { background-color: #d8dfec; } .toastui-editor-contents th.toastui-editor-cell-selected { background-color: #908f8f; } .toastui-editor-contents ul, .toastui-editor-contents menu, .toastui-editor-contents ol, .toastui-editor-contents dir { display: block; list-style-type: none; padding-left: 24px; margin: 6px 0 10px; color: #222; } .toastui-editor-contents ol { list-style-type: none; counter-reset: li; } .toastui-editor-contents ol > li { counter-increment: li; } .toastui-editor-contents ul > li::before, .toastui-editor-contents ol > li::before { display: inline-block; position: absolute; } .toastui-editor-contents ul > li::before { content: ''; margin-top: 6px; margin-left: -17px; width: 5px; height: 5px; border-radius: 50%; background-color: #ccc; } .toastui-editor-contents ol > li::before { content: '.' counter(li); margin-left: -28px; width: 24px; text-align: right; direction: rtl; color: #aaa; } .toastui-editor-contents ul ul, .toastui-editor-contents ul ol, .toastui-editor-contents ol ol, .toastui-editor-contents ol ul { margin-top: 0 !important; margin-bottom: 0 !important; } .toastui-editor-contents ul li, .toastui-editor-contents ol li { position: relative; } .toastui-editor-contents ul p, .toastui-editor-contents ol p { margin: 0; } .toastui-editor-contents hr { border-top: 1px solid #eee; margin: 16px 0; } .toastui-editor-contents a { text-decoration: underline; color: #4b96e6; } .toastui-editor-contents a:hover { color: #1f70de; } .toastui-editor-contents .image-link { position: relative; } .toastui-editor-contents .image-link:hover::before { content: ''; position: absolute; width: 30px; height: 30px; right: 0px; border-radius: 50%; border: 1px solid #c9ccd5; background: #fff url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDIwIDIwIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgICAgICA8ZyBzdHJva2U9IiM1NTUiIHN0cm9rZS13aWR0aD0iMS41Ij4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8Zz4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy42NjUgMTUuMDdsLTEuODE5LS4wMDJjLTEuNDg2IDAtMi42OTItMS4yMjgtMi42OTItMi43NDR2LS4xOTJjMC0xLjUxNSAxLjIwNi0yLjc0NCAyLjY5Mi0yLjc0NGgzLjg0NmMxLjQ4NyAwIDIuNjkyIDEuMjI5IDIuNjkyIDIuNzQ0di4xOTIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMDAwIC00NTgxKSB0cmFuc2xhdGUoOTk1IDQ1NzYpIHRyYW5zbGF0ZSg1IDUpIHNjYWxlKDEgLTEpIHJvdGF0ZSg0NSAzNy4yOTMgMCkiLz4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuMzI2IDQuOTM0bDEuODIyLjAwMmMxLjQ4NyAwIDIuNjkzIDEuMjI4IDIuNjkzIDIuNzQ0di4xOTJjMCAxLjUxNS0xLjIwNiAyLjc0NC0yLjY5MyAyLjc0NGgtMy44NDVjLTEuNDg3IDAtMi42OTItMS4yMjktMi42OTItMi43NDRWNy42OCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwMDAgLTQ1ODEpIHRyYW5zbGF0ZSg5OTUgNDU3NikgdHJhbnNsYXRlKDUgNSkgc2NhbGUoMSAtMSkgcm90YXRlKDQ1IDMwLjk5NiAwKSIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K') no-repeat; background-position: center; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); cursor: pointer; } .toastui-editor-contents .task-list-item { border: 0; list-style: none; padding-left: 24px; margin-left: -24px; } .toastui-editor-contents .task-list-item::before { background-repeat: no-repeat; background-size: 18px 18px; background-position: center; content: ''; margin-left: 0; margin-top: 0; border-radius: 2px; height: 18px; width: 18px; position: absolute; left: 0; top: 1px; cursor: pointer; background: transparent url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbD0iI0ZGRiIgc3Ryb2tlPSIjQ0NDIj4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTAzMCAtMjk2KSB0cmFuc2xhdGUoNzg4IDE5MikgdHJhbnNsYXRlKDI0MiAxMDQpIj4KICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iMTciIGhlaWdodD0iMTciIHg9Ii41IiB5PSIuNSIgcng9IjIiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg=='); } .toastui-editor-contents .task-list-item.checked::before { background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbD0iIzRCOTZFNiI+CiAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2IDBjMS4xMDUgMCAyIC44OTUgMiAydjE0YzAgMS4xMDUtLjg5NSAyLTIgMkgyYy0xLjEwNSAwLTItLjg5NS0yLTJWMkMwIC44OTUuODk1IDAgMiAwaDE0em0tMS43OTMgNS4yOTNjLS4zOS0uMzktMS4wMjQtLjM5LTEuNDE0IDBMNy41IDEwLjU4NSA1LjIwNyA4LjI5M2wtLjA5NC0uMDgzYy0uMzkyLS4zMDUtLjk2LS4yNzgtMS4zMi4wODMtLjM5LjM5LS4zOSAxLjAyNCAwIDEuNDE0bDMgMyAuMDk0LjA4M2MuMzkyLjMwNS45Ni4yNzggMS4zMi0uMDgzbDYtNiAuMDgzLS4wOTRjLjMwNS0uMzkyLjI3OC0uOTYtLjA4My0xLjMyeiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwNTAgLTI5NikgdHJhbnNsYXRlKDc4OCAxOTIpIHRyYW5zbGF0ZSgyNjIgMTA0KSIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K'); } .toastui-editor-custom-block .toastui-editor-custom-block-editor { background: #f9f7fd; color: #452d6b; border: solid 1px #dbd4ea; } .toastui-editor-custom-block .toastui-editor-custom-block-view { position: relative; padding: 9px 13px 8px 12px; } .toastui-editor-custom-block.ProseMirror-selectednode .toastui-editor-custom-block-view { border: solid 1px #dbd4ea; border-radius: 2px; } .toastui-editor-custom-block .toastui-editor-custom-block-view .tool { position: absolute; right: 10px; top: 7px; display: none; } .toastui-editor-custom-block.ProseMirror-selectednode .toastui-editor-custom-block-view .tool { display: block; } .toastui-editor-custom-block-view button { vertical-align: middle; width: 15px; height: 15px; margin-left: 8px; padding: 3px; border: solid 1px #cccccc; background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzU1NTU1NTt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==') no-repeat; background-position: center; background-size: 30px 30px; } .toastui-editor-custom-block-view .info { font-size: 13px; font-weight: bold; color: #5200d0; vertical-align: middle; } .toastui-editor-contents .toastui-editor-ww-code-block { position: relative; } .toastui-editor-contents .toastui-editor-ww-code-block:after { content: attr(data-language); position: absolute; display: inline-block; top: 10px; right: 10px; height: 24px; padding: 3px 35px 0 10px; font-weight: bold; font-size: 13px; color: #333; background: #e5e9ea url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzU1NTU1NTt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==') no-repeat; background-position: right; border-radius: 2px; background-size: 30px 30px; cursor: pointer; } .toastui-editor-ww-code-block-language { position: fixed; display: inline-block; width: 100px; height: 27px; right: 35px; border: 1px solid #ccc; border-radius: 2px; background-color: #fff; z-index: 30; } .toastui-editor-ww-code-block-language input { box-sizing: border-box; margin: 0; padding: 0 10px; height: 100%; width: 100%; background-color: transparent; border: none; outline: none; } .toastui-editor-contents-placeholder::before { content: attr(data-placeholder); color: grey; line-height: 160%; position: absolute; } .toastui-editor-md-preview .toastui-editor-contents h1 { min-height: 28px; } .toastui-editor-md-preview .toastui-editor-contents h2 { min-height: 23px; } .toastui-editor-md-preview .toastui-editor-contents blockquote { min-height: 20px; } .toastui-editor-md-preview .toastui-editor-contents li { min-height: 22px; } .toastui-editor-pseudo-clipboard { position: fixed; opacity: 0; width: 0; height: 0; left: -1000px; top: -1000px; z-index: -1; } ================================================ FILE: apps/editor/src/css/editor.css ================================================ /* height */ .auto-height, .auto-height .toastui-editor-defaultUI { height: auto; } .auto-height .toastui-editor-md-container { position: relative; } :not(.auto-height) > .toastui-editor-defaultUI, :not(.auto-height) > .toastui-editor-defaultUI > .toastui-editor-main { display: -ms-flexbox; display: flex; -ms-flex-direction: column; flex-direction: column; } :not(.auto-height) > .toastui-editor-defaultUI > .toastui-editor-main { -ms-flex: 1; flex: 1; } /* toastui editor */ .toastui-editor-md-container::after, .toastui-editor-defaultUI-toolbar::after { content: ''; display: block; height: 0; clear: both; } .toastui-editor-main { min-height: 0px; position: relative; height: inherit; box-sizing: border-box; } .toastui-editor-md-container { display: none; overflow: hidden; height: 100%; } .toastui-editor-md-container .toastui-editor { line-height: 1.5; position: relative; } .toastui-editor-md-container .toastui-editor, .toastui-editor-md-container .toastui-editor-md-preview { box-sizing: border-box; padding: 0; height: inherit; } .toastui-editor-md-container .toastui-editor-md-preview { overflow: auto; padding: 0 25px; height: 100%; } .toastui-editor-md-container .toastui-editor-md-preview > p:first-child { margin-top: 0 !important; } .toastui-editor-md-container .toastui-editor-md-preview .toastui-editor-contents { padding-top: 8px; } .toastui-editor-main .toastui-editor-md-tab-style > .toastui-editor, .toastui-editor-main .toastui-editor-md-tab-style > .toastui-editor-md-preview { width: 100%; display: none; } .toastui-editor-main .toastui-editor-md-tab-style > .active { display: block; } .toastui-editor-main .toastui-editor-md-vertical-style > .toastui-editor-tabs { display: none; } .toastui-editor-main .toastui-editor-md-tab-style > .toastui-editor-tabs { display: block; } .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor { width: 50%; } .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor-md-preview { width: 50%; } .toastui-editor-main .toastui-editor-md-splitter { display: none; height: 100%; width: 1px; background-color: #ebedf2; position: absolute; left: 50%; } .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor-md-splitter { display: block; } .toastui-editor-ww-container { display: none; overflow: hidden; height: inherit; background-color: #fff; } .auto-height .toastui-editor-main-container { position: relative; } .toastui-editor-main-container { position: absolute; line-height: 1; color: #222; width: 100%; height: inherit; } .toastui-editor-ww-container > .toastui-editor { height: inherit; position: relative; width: 100%; } .toastui-editor-ww-container .toastui-editor-contents { overflow: auto; box-sizing: border-box; margin: 0px; padding: 16px 25px 0px 25px; height: inherit; } .toastui-editor-ww-container .toastui-editor-contents p { margin: 0; } .toastui-editor-md-mode .toastui-editor-md-container, .toastui-editor-ww-mode .toastui-editor-ww-container { display: block; z-index: 20; } .toastui-editor-md-mode .toastui-editor-md-vertical-style { display: -ms-flexbox; display: flex; } .toastui-editor-main.hidden, .toastui-editor-defaultUI.hidden { display: none; } /* default UI Styles */ .toastui-editor-defaultUI .ProseMirror { padding: 18px 25px; } .toastui-editor-defaultUI { position: relative; border: 1px solid #dadde6; height: 100%; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', 'Arial', '나눔바른고딕', 'Nanum Barun Gothic', '맑은고딕', 'Malgun Gothic', sans-serif; border-radius: 4px; } .toastui-editor-defaultUI button { color: #333; height: 28px; font-size: 13px; cursor: pointer; border: none; border-radius: 2px; } .toastui-editor-defaultUI .toastui-editor-ok-button { min-width: 63px; height: 32px; background-color: #00a9ff; color: #fff; outline-color: #009bf2; } .toastui-editor-defaultUI .toastui-editor-ok-button:hover { background-color: #009bf2; } .toastui-editor-defaultUI .toastui-editor-close-button { min-width: 63px; height: 32px; background-color: #f7f9fc; border: 1px solid #dadde6; margin-right: 5px; outline-color: #cbcfdb; } .toastui-editor-defaultUI .toastui-editor-close-button:hover { border-color: #cbcfdb; } /* mode switch tab */ .toastui-editor-mode-switch { background-color: #fff; border-top: 1px solid #dadde6; font-size: 12px; text-align: right; height: 28px; padding-right: 10px; border-radius: 0 0 3px 3px; } .toastui-editor-mode-switch .tab-item { display: inline-block; width: 96px; height: 24px; line-height: 24px; text-align: center; background: #f7f9fc; color: #969aa5; margin-top: -1px; margin-right: -1px; cursor: pointer; border: 1px solid #dadde6; border-radius: 0 0 4px 4px; font-weight: 500; box-sizing: border-box; } .toastui-editor-mode-switch .tab-item.active { border-top: 1px solid #fff; background-color: #fff; color: #555; } /* markdown tab */ .toastui-editor-defaultUI .toastui-editor-md-tab-container { float: left; height: 45px; font-size: 13px; background: #f7f9fc; border-bottom: 1px solid #ebedf2; border-top-left-radius: 3px; } .toastui-editor-md-tab-container .toastui-editor-tabs { margin-left: 15px; height: 100%; } .toastui-editor-md-tab-container .tab-item { display: inline-block; width: 70px; height: 33px; line-height: 33px; font-size: 12px; font-weight: 500; text-align: center; background: #eaedf1; color: #969aa5; cursor: pointer; border: 1px solid #dadde6; border-radius: 4px 4px 0 0; box-sizing: border-box; margin-top: 13px; } .toastui-editor-md-tab-container .tab-item.active { border-bottom: 1px solid #fff; background-color: #fff; color: #555; } .toastui-editor-md-tab-container .tab-item:last-child { margin-left: -1px; } /* toolbar */ .toastui-editor-defaultUI-toolbar { display: -ms-flexbox; display: flex; padding: 0 25px; height: 45px; background-color: #f7f9fc; border-bottom: 1px solid #ebedf2; border-radius: 3px 3px 0 0; } .toastui-editor-toolbar { height: 46px; box-sizing: border-box; } .toastui-editor-toolbar-divider { display: inline-block; width: 1px; height: 18px; background-color: #e1e3e9; margin: 14px 12px; } .toastui-editor-toolbar-group { display: -ms-flexbox; display: flex; } .toastui-editor-defaultUI-toolbar button { box-sizing: border-box; cursor: pointer; width: 32px; height: 32px; padding: 0; border-radius: 3px; margin: 7px 5px; border: 1px solid #f7f9fc; } .toastui-editor-defaultUI-toolbar button:not(:disabled):hover { border: 1px solid #e4e7ee; background-color: #fff; } .toastui-editor-defaultUI-toolbar .scroll-sync { display: inline-block; position: relative; width: 70px; height: 10px; text-align: center; line-height: 10px; color: #81858f; cursor: pointer; } .toastui-editor-defaultUI-toolbar .scroll-sync::before { content: 'Scroll'; position: absolute; left: 0; font-size: 14px; } .toastui-editor-defaultUI-toolbar .scroll-sync.active::before { color: #00a9ff; } .toastui-editor-defaultUI-toolbar .scroll-sync input { opacity: 0; width: 0; height: 0; } .toastui-editor-defaultUI-toolbar .switch { position: absolute; top: 0; left: 45px; right: 0; bottom: 0; background-color: #d6d8de; -webkit-transition: .4s; transition: .4s; border-radius: 50px; } .toastui-editor-defaultUI-toolbar input:checked + .switch { background-color: #acddfa; } .toastui-editor-defaultUI-toolbar .switch::before { position: absolute; content: ''; height: 14px; width: 14px; left: 0px; bottom: -2px; background-color: #94979f; -webkit-transition: .4s; transition: .4s; border-radius: 50%; } .toastui-editor-defaultUI-toolbar input:checked + .switch::before { background-color: #00a9ff; -webkit-transform: translateX(12px); -moz-transform: translateX(12px); -ms-transform: translateX(12px); transform: translateX(12px); } .toastui-editor-dropdown-toolbar .scroll-sync { margin: 0 5px; } .toastui-editor-dropdown-toolbar { position: absolute; height: 46px; z-index: 30; border-radius: 2px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); border: 1px solid #dadde6; background-color: #f7f9fc; display: -ms-flexbox; display: flex; } .toastui-editor-toolbar-item-wrapper { margin: 7px 5px; height: 32px; line-height: 32px; } /* toolbar popup */ .toastui-editor-popup { width: 400px; margin-right: auto; background: #fff; z-index: 30; position: absolute; border-radius: 2px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); border: 1px solid #dadde6; } .toastui-editor-popup-body { padding: 15px; font-size: 12px; } .toastui-editor-popup-body label { font-weight: 600; color: #555; display: block; margin: 20px 0 5px; } .toastui-editor-popup-body .toastui-editor-button-container { text-align: right; margin-top: 20px; } .toastui-editor-popup-body input[type='text'] { width: calc(100% - 26px); height: 30px; padding: 0 12px; border-radius: 2px; border: 1px solid #e1e3e9; color: #333; } .toastui-editor-popup-body input[type='text']:focus { outline: 1px solid #00a9ff; border-color: transparent; } .toastui-editor-popup-body input[type='text'].disabled { background-color: #f7f9fc; border-color: #e1e3e9; color: #969aa5; } .toastui-editor-popup-body input[type='file'] { opacity: 0; border: none; width: 1px; height: 1px; position: absolute; top: 0; left: 0; } .toastui-editor-popup-body input.wrong, .toastui-editor-popup-body span.wrong { border-color: #fa2828; } .toastui-editor-popup-add-link .toastui-editor-popup-body, .toastui-editor-popup-add-image .toastui-editor-popup-body { padding: 0 20px 20px; } .toastui-editor-popup-add-image .toastui-editor-tabs { margin: 5px 0 10px; } .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item { display: inline-block; width: 60px; height: 40px; line-height: 40px; border-bottom: 1px solid #dadde6; color: #333; font-size: 13px; font-weight: 600; text-align: center; cursor: pointer; box-sizing: border-box; } .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item:hover { border-bottom: 1px solid #cbcfdb; } .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item.active { color: #00a9ff; border-bottom: 2px solid #00a9ff; } .toastui-editor-popup-add-image .toastui-editor-file-name { width: 58%; display: inline-block; border-radius: 2px; border: 1px solid #e1e3e9; color: #dadde6; height: 30px; line-height: 30px; padding: 0 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } .toastui-editor-popup-add-image .toastui-editor-file-name.has-file { color: #333; } .toastui-editor-popup-add-image .toastui-editor-file-select-button { width: 33%; margin-left: 5px; height: 32px; border-radius: 2px; border: 1px solid #dadde6; background-color: #f7f9fc; vertical-align: top; } .toastui-editor-popup-add-image .toastui-editor-file-select-button:hover { border-color: #cbcfdb; } .toastui-editor-popup-add-table { width: auto; } .toastui-editor-popup-add-table .toastui-editor-table-selection { position: relative; } .toastui-editor-popup-add-table .toastui-editor-table-cell { display: table-cell; width: 20px; height: 20px; border: 1px solid #e1e3e9; background: #fff; box-sizing: border-box; } .toastui-editor-popup-add-table .toastui-editor-table-cell.header { background: #f7f9fc; } .toastui-editor-popup-add-table .toastui-editor-table-row { display: table-row; } .toastui-editor-popup-add-table .toastui-editor-table { display: table; border-collapse: collapse; } .toastui-editor-popup-add-table .toastui-editor-table-selection-layer { position: absolute; top: 0; left: 0; border: 1px solid #00a9ff; background: rgba(0, 169, 255, 0.1); z-index: 30; } .toastui-editor-popup-add-table .toastui-editor-table-description { margin: 5px 0 0; text-align: center; color: #333 } .toastui-editor-popup-add-heading { width: auto; } .toastui-editor-popup-add-heading .toastui-editor-popup-body { padding: 0; } .toastui-editor-popup-add-heading h1, .toastui-editor-popup-add-heading h2, .toastui-editor-popup-add-heading h3, .toastui-editor-popup-add-heading h4, .toastui-editor-popup-add-heading h5, .toastui-editor-popup-add-heading h6, .toastui-editor-popup-add-heading ul, .toastui-editor-popup-add-heading p { padding: 0; margin: 0; } .toastui-editor-popup-add-heading ul { padding: 5px 0; list-style: none; } .toastui-editor-popup-add-heading ul li { padding: 4px 12px; cursor: pointer; } .toastui-editor-popup-add-heading ul li:hover { background-color: #dff4ff; } .toastui-editor-popup-add-heading h1 { font-size: 24px; } .toastui-editor-popup-add-heading h2 { font-size: 22px; } .toastui-editor-popup-add-heading h3 { font-size: 20px; } .toastui-editor-popup-add-heading h4 { font-size: 18px; } .toastui-editor-popup-add-heading h5 { font-size: 16px; } .toastui-editor-popup-add-heading h6 { font-size: 14px; } /* table context menu */ .toastui-editor-context-menu { position: absolute; width: auto; min-width: 197px; color: #333; border-radius: 2px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); border: 1px solid #dadde6; z-index: 30; padding: 5px 0; background-color: #fff; } .toastui-editor-context-menu .menu-group { list-style: none; border-bottom: 1px solid #ebedf2; padding: 0; margin: 0; font-size: 13px; } .toastui-editor-context-menu .menu-group:last-child { border-bottom: none !important; } .toastui-editor-context-menu .menu-item { height: 32px; line-height: 32px; padding: 0 14px; cursor: pointer; } .toastui-editor-context-menu span { display: inline-block; } .toastui-editor-context-menu span::before { background: url('../img/toastui-editor.png') no-repeat; background-size: 466px 146px; content: ''; width: 20px; height: 20px; display: inline-block; vertical-align: middle; margin-right: 10px; } .toastui-editor-context-menu .add-row-up::before { background-position: 3px -104px; } .toastui-editor-context-menu .add-row-down::before { background-position: -19px -104px; } .toastui-editor-context-menu .remove-row::before { background-position: -41px -104px; } .toastui-editor-context-menu .add-column-left::before { background-position: -63px -104px; } .toastui-editor-context-menu .add-column-right::before { background-position: -85px -104px; } .toastui-editor-context-menu .remove-column::before { background-position: -111px -104px; } .toastui-editor-context-menu .align-column-left::before { background-position: -129px -104px; } .toastui-editor-context-menu .align-column-center::before { background-position: -151px -104px; } .toastui-editor-context-menu .align-column-right::before { background-position: -173px -104px; } .toastui-editor-context-menu .remove-table::before { background-position: -197px -104px; } .toastui-editor-context-menu .disabled span::before { opacity: 0.3; } .toastui-editor-context-menu li:not(.disabled):hover { background-color: #dff4ff; } .toastui-editor-context-menu li.disabled { color: #c9ccd5; } .toastui-editor-tooltip { position: absolute; background-color: #444; z-index: 40; padding: 4px 7px; font-size: 12px; border-radius: 3px; color: #fff; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', 'Arial', '나눔바른고딕', 'Nanum Barun Gothic', '맑은고딕', 'Malgun Gothic', sans-serif; } .toastui-editor-tooltip .arrow { content: ''; display: inline-block; width: 10px; height: 10px; background-color: #444; -webkit-transform: rotate(45deg); -moz-transform: rotate(45deg); -ms-transform: rotate(45deg); -o-transform: rotate(45deg); transform: rotate(45deg); position: absolute; top: -3px; left: 6px; z-index: -1; } .toastui-editor-toolbar-icons { background: url('../img/toastui-editor.png') no-repeat; background-size: 466px 146px; } @media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2/1), only screen and (min-device-pixel-ratio: 2), only screen and (min-resolution: 192dpi), only screen and (min-resolution: 2dppx) { .toastui-editor-toolbar-icons, .toastui-editor-context-menu span::before { background: url('../img/toastui-editor-2x.png') no-repeat; background-size: 466px 146px; } } .toastui-editor-toolbar-icons { background-position-y: 3px; } .toastui-editor-toolbar-icons:disabled { opacity: 0.3; } .toastui-editor-toolbar-icons.heading { background-position-x: 3px; } .toastui-editor-toolbar-icons.bold { background-position-x: -23px; } .toastui-editor-toolbar-icons.italic { background-position-x: -49px; } .toastui-editor-toolbar-icons.strike { background-position-x: -75px; } .toastui-editor-toolbar-icons.hrline { background-position-x: -101px; } .toastui-editor-toolbar-icons.quote { background-position-x: -127px; } .toastui-editor-toolbar-icons.bullet-list { background-position-x: -153px; } .toastui-editor-toolbar-icons.ordered-list { background-position-x: -179px; } .toastui-editor-toolbar-icons.task-list { background-position-x: -205px; } .toastui-editor-toolbar-icons.indent { background-position-x: -231px; } .toastui-editor-toolbar-icons.outdent { background-position-x: -257px; } .toastui-editor-toolbar-icons.table { background-position-x: -283px; } .toastui-editor-toolbar-icons.image { background-position-x: -309px; } .toastui-editor-toolbar-icons.link { background-position-x: -334px; } .toastui-editor-toolbar-icons.code { background-position-x: -361px; } .toastui-editor-toolbar-icons.codeblock { background-position-x: -388px; } .toastui-editor-toolbar-icons.more { background-position-x: -412px; } .toastui-editor-toolbar-icons:not(:disabled).active { background-position-y: -23px; } @media only screen and (max-width: 480px) { .toastui-editor-popup { max-width: 300px; margin-left: -150px; } .toastui-editor-dropdown-toolbar { max-width: none; } } ================================================ FILE: apps/editor/src/css/md-syntax-highlighting.css ================================================ .toastui-editor-md-heading1 { font-size: 24px; } .toastui-editor-md-heading2 { font-size: 22px; } .toastui-editor-md-heading3 { font-size: 20px; } .toastui-editor-md-heading4 { font-size: 18px; } .toastui-editor-md-heading5 { font-size: 16px; } .toastui-editor-md-heading6 { font-size: 14px; } .toastui-editor-md-heading.toastui-editor-md-delimiter.setext { line-height: 15px; } .toastui-editor-md-strong, .toastui-editor-md-heading, .toastui-editor-md-list-item-style, .toastui-editor-md-list-item .toastui-editor-md-meta { font-weight: bold; } .toastui-editor-md-emph { font-style: italic; } .toastui-editor-md-strike { text-decoration: line-through; } .toastui-editor-md-strike.toastui-editor-md-delimiter { text-decoration: none; } .toastui-editor-md-delimiter, .toastui-editor-md-thematic-break, .toastui-editor-md-link, .toastui-editor-md-table, .toastui-editor-md-block-quote { color: #ccc; } .toastui-editor-md-code.toastui-editor-md-delimiter { color: #aaa; } .toastui-editor-md-meta, .toastui-editor-md-html, .toastui-editor-md-link.toastui-editor-md-link-url.toastui-editor-md-marked-text { color: #999; } .toastui-editor-md-block-quote .toastui-editor-md-marked-text, .toastui-editor-md-list-item .toastui-editor-md-meta { color: #555; } .toastui-editor-md-table .toastui-editor-md-table-cell { color: #222; } .toastui-editor-md-link.toastui-editor-md-link-desc.toastui-editor-md-marked-text, .toastui-editor-md-list-item-style.toastui-editor-md-list-item-odd { color: #4b96e6; } .toastui-editor-md-list-item-style.toastui-editor-md-list-item-even { color: #cb4848; } .toastui-editor-md-code.toastui-editor-md-marked-text { color: #c1798b; } .toastui-editor-md-code { background-color: rgba(243, 229, 233, 0.5); padding: 2px 0; letter-spacing: -0.3px; } .toastui-editor-md-code.toastui-editor-md-start { padding-left: 2px; border-top-left-radius: 2px; border-bottom-left-radius: 2px; } .toastui-editor-md-code.toastui-editor-md-end { padding-right: 2px; border-top-right-radius: 2px; border-bottom-right-radius: 2px; } .toastui-editor-md-code-block-line-background { background-color: #f5f7f8; } .toastui-editor-md-code-block-line-background.start, .toastui-editor-md-custom-block-line-background.start { margin-top: 2px; } .toastui-editor-md-code, .toastui-editor-md-code-block { font-family: Consolas, Courier, 'Lucida Grande', '나눔바른고딕', 'Nanum Barun Gothic', '맑은고딕', 'Malgun Gothic', sans-serif; } .toastui-editor-md-custom-block { color: #452d6b; } .toastui-editor-md-custom-block-line-background { background-color: #f9f7fd; } .toastui-editor-md-custom-block .toastui-editor-md-delimiter { color: #b8b3c0; } .toastui-editor-md-custom-block .toastui-editor-md-meta { color: #5200d0; } ================================================ FILE: apps/editor/src/css/preview-highlighting.css ================================================ .toastui-editor-contents .toastui-editor-md-preview-highlight { position: relative; z-index: 0; } .toastui-editor-contents .toastui-editor-md-preview-highlight::after { content: ''; background-color: rgba(255, 245, 131, 0.5); border-radius: 4px; z-index: -1; position: absolute; top: -4px; right: -4px; left: -4px; bottom: -4px; } .toastui-editor-contents h1.toastui-editor-md-preview-highlight::after, .toastui-editor-contents h2.toastui-editor-md-preview-highlight::after { bottom: 0; } .toastui-editor-contents td.toastui-editor-md-preview-highlight::after, .toastui-editor-contents th.toastui-editor-md-preview-highlight::after { display: none; } .toastui-editor-contents th.toastui-editor-md-preview-highlight, .toastui-editor-contents td.toastui-editor-md-preview-highlight { background-color: rgba(255, 245, 131, 0.5); } .toastui-editor-contents th.toastui-editor-md-preview-highlight { color: #222; } ================================================ FILE: apps/editor/src/css/theme/dark.css ================================================ @charset "utf-8"; .toastui-editor-dark.toastui-editor-defaultUI { border-color: #494c56; color: #eee; } .toastui-editor-dark .toastui-editor-md-container, .toastui-editor-dark .toastui-editor-ww-container { background-color: #121212; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar { background-color: #232428; border-bottom-color: #303238; } .toastui-editor-dark .toastui-editor-toolbar-icons { background-position-y: -49px; border-color: #232428; } .toastui-editor-dark .toastui-editor-toolbar-icons:not(:disabled):hover { background-color: #36383f; border-color: #36383f; } .toastui-editor-dark .toastui-editor-toolbar-divider { background-color: #303238; } .toastui-editor-dark .toastui-editor-tooltip { background-color: #535662; } .toastui-editor-dark .toastui-editor-tooltip .arrow { background-color: #535662; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar .scroll-sync::before { color: #8f939f; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar .scroll-sync.active::before { color: #67ccff; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar .switch { background-color: #2b4455; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar input:checked + .switch { background-color: #2b4455; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar .switch::before { background-color: #8f939f; } .toastui-editor-dark .toastui-editor-defaultUI-toolbar input:checked + .switch::before { background-color: #67ccff; } .toastui-editor-dark .toastui-editor-main .toastui-editor-md-splitter { background-color: #303238; } .toastui-editor-dark .toastui-editor-mode-switch { border-top-color: #393b42; background-color: #121212; } .toastui-editor-dark .toastui-editor-mode-switch .tab-item { border-color: #393b42; background-color: #232428; color: #757a86; } .toastui-editor-dark .toastui-editor-mode-switch .tab-item.active { border-top-color: #121212; background-color: #121212; color: #eee; } .toastui-editor-dark .toastui-editor-popup, .toastui-editor-dark .toastui-editor-context-menu { background-color: #121212; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); border-color: #494c56; } .toastui-editor-dark .toastui-editor-popup-add-heading ul li:hover { background-color: #36383f; } .toastui-editor-dark .toastui-editor-popup-body label { color: #9a9da3; } .toastui-editor-dark .toastui-editor-popup-body input[type='text'] { background-color: transparent; color: #eee; border-color: #303238; } .toastui-editor-dark .toastui-editor-popup-body input[type='text']:focus { outline-color: #67ccff; } .toastui-editor-dark .toastui-editor-popup-body input[type='text'].disabled { color: #969aa5; border-color: #303238; background-color: rgba(48, 50, 56, 0.4); } .toastui-editor-dark .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item { border-bottom-color: #292e37; color: #eee; } .toastui-editor-dark .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item:hover { border-bottom-color: #3c424d; } .toastui-editor-dark .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item.active { color: #67ccff; border-bottom-color: #67ccff; } .toastui-editor-dark .toastui-editor-popup-body .toastui-editor-file-name { border-color: #303238; color: #eee; } .toastui-editor-dark .toastui-editor-popup-body .toastui-editor-file-select-button { border-color: #303238; background-color: #232428; color: #eee; } .toastui-editor-dark .toastui-editor-popup-body .toastui-editor-file-select-button:hover { border-color: #494c56; } .toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-close-button { color: #eee; border-color: #303238; background-color: #232428; } .toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-close-button:hover { border-color: #494c56; } .toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-ok-button { color: #121212; background-color: #67ccff; } .toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-ok-button:hover { color: #121212; background-color: #32baff; } .toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-cell { border-color: #303238; background-color: #121212; } .toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-cell.header { border-color: #303238; background-color: #232428; } .toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-selection-layer { border-color: rgba(103, 204, 255, 0.4); background-color: rgba(103, 204, 255, 0.1); } .toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-description { color: #eee } .toastui-editor-dark .toastui-editor-md-tab-container { background-color: #232428; border-bottom-color: #303238; } .toastui-editor-dark .toastui-editor-md-tab-container .tab-item { border-color: #393b42; background-color: #2d2f34; color: #757a86; } .toastui-editor-dark .toastui-editor-md-tab-container .tab-item.active { border-bottom-color: #121212; background-color: #121212; color: #eee; } .toastui-editor-dark .toastui-editor-context-menu .menu-group { border-bottom-color: #303238; color: #eee; } .toastui-editor-dark .toastui-editor-context-menu .menu-item span::before { background-position-y: -126px; } .toastui-editor-dark .toastui-editor-context-menu li:not(.disabled):hover { background-color: #36383f; } .toastui-editor-dark .toastui-editor-context-menu li.disabled { color: #969aa5; } .toastui-editor-dark .toastui-editor-dropdown-toolbar { border-color: #494c56; background-color: #232428; } .toastui-editor-dark .ProseMirror, .toastui-editor-dark .toastui-editor-contents p, .toastui-editor-dark .toastui-editor-contents h1, .toastui-editor-dark .toastui-editor-contents h2, .toastui-editor-dark .toastui-editor-contents h3, .toastui-editor-dark .toastui-editor-contents h4, .toastui-editor-dark .toastui-editor-contents h5, .toastui-editor-dark .toastui-editor-contents h6 { color: #fff; } .toastui-editor-dark .toastui-editor-contents h1, .toastui-editor-dark .toastui-editor-contents h2 { border-color: #fff; } .toastui-editor-dark .toastui-editor-contents del { color: #777980; } .toastui-editor-dark .toastui-editor-contents blockquote { border-color: #303135; } .toastui-editor-dark .toastui-editor-contents blockquote p, .toastui-editor-dark .toastui-editor-contents blockquote ul, .toastui-editor-dark .toastui-editor-contents blockquote ol { color: #777980; } .toastui-editor-dark .toastui-editor-contents pre { background-color: #232428; } .toastui-editor-dark .toastui-editor-contents pre code { background-color: transparent; color: #fff; } .toastui-editor-dark .toastui-editor-contents code { color: #c1798b; background-color: #35262a; } .toastui-editor-dark .toastui-editor-contents div { color: #fff; } .toastui-editor-dark .toastui-editor-ww-code-block-language { border-color: #303238; background-color: #121212; } .toastui-editor-dark .toastui-editor-ww-code-block-language input { color: #fff; } .toastui-editor-dark .toastui-editor-contents .toastui-editor-ww-code-block:after { background-color: #232428; border: 1px solid #393b42; color: #eee; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg=='); } .toastui-editor-dark .toastui-editor-contents .toastui-editor-custom-block-editor { background: #392d31; color: #fff; border-color: #327491; } .toastui-editor-dark .toastui-editor-custom-block.ProseMirror-selectednode .toastui-editor-custom-block-view { color: #fff; border-color: #327491; } .toastui-editor-dark .toastui-editor-custom-block-view button { background-color: #232428; border-color: #393b42; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg=='); } .toastui-editor-dark .toastui-editor-custom-block-view button:hover { background-color: #232428; border-color: #595c68; } .toastui-editor-dark .toastui-editor-custom-block-view .info { color: #65acca; } .toastui-editor-dark .toastui-editor-contents table { border-color: #303238; } .toastui-editor-dark .toastui-editor-contents table th, .toastui-editor-dark .toastui-editor-contents table td { border-color: #303238; } .toastui-editor-dark .toastui-editor-contents table th { background-color: #3a3c42; } .toastui-editor-dark .toastui-editor-contents table td, .toastui-editor-dark .toastui-editor-contents table td p { color: #fff; } .toastui-editor-dark .toastui-editor-contents td.toastui-editor-cell-selected { background-color: rgba(103, 204, 255, 0.5); } .toastui-editor-dark .toastui-editor-contents th.toastui-editor-cell-selected { background-color: rgba(103, 204, 255, 0.3); } .toastui-editor-dark table.ProseMirror-selectednode { outline-color: #67ccff; } .toastui-editor-dark .html-block.ProseMirror-selectednode { outline-color: #67ccff; } .toastui-editor-dark .toastui-editor-contents ul, .toastui-editor-dark .toastui-editor-contents menu, .toastui-editor-dark .toastui-editor-contents ol, .toastui-editor-dark .toastui-editor-contents dir { color: #55575f; } .toastui-editor-dark .toastui-editor-contents ul > li::before { background-color: #55575f; } .toastui-editor-dark .toastui-editor-contents hr { border-color: #55575f; } .toastui-editor-dark .toastui-editor-contents a { color: #4b96e6; } .toastui-editor-dark .toastui-editor-contents a:hover { color: #1f70de; } .toastui-editor-dark .toastui-editor-contents .image-link:hover::before { border-color: #393b42; background-color: #232428; background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDIwIDIwIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgICAgICA8ZyBzdHJva2U9IiNFRUUiIHN0cm9rZS13aWR0aD0iMS41Ij4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8Zz4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy42NjUgMTUuMDdsLTEuODE5LS4wMDJjLTEuNDg2IDAtMi42OTItMS4yMjgtMi42OTItMi43NDR2LS4xOTJjMC0xLjUxNSAxLjIwNi0yLjc0NCAyLjY5Mi0yLjc0NGgzLjg0NmMxLjQ4NyAwIDIuNjkyIDEuMjI5IDIuNjkyIDIuNzQ0di4xOTIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMDQ1IC0xNzQzKSB0cmFuc2xhdGUoMTA0MCAxNzM4KSB0cmFuc2xhdGUoNSA1KSBzY2FsZSgxIC0xKSByb3RhdGUoNDUgMzcuMjkzIDApIi8+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEyLjMyNiA0LjkzNGwxLjgyMi4wMDJjMS40ODcgMCAyLjY5MyAxLjIyOCAyLjY5MyAyLjc0NHYuMTkyYzAgMS41MTUtMS4yMDYgMi43NDQtMi42OTMgMi43NDRoLTMuODQ1Yy0xLjQ4NyAwLTIuNjkyLTEuMjI5LTIuNjkyLTIuNzQ0VjcuNjgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMDQ1IC0xNzQzKSB0cmFuc2xhdGUoMTA0MCAxNzM4KSB0cmFuc2xhdGUoNSA1KSBzY2FsZSgxIC0xKSByb3RhdGUoNDUgMzAuOTk2IDApIi8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo='); box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); } .toastui-editor-dark .toastui-editor-contents .task-list-item::before { background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgc3Ryb2tlPSIjNTU1NzVGIj4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTAzMCAtMzE2KSB0cmFuc2xhdGUoNzg4IDE5MikgdHJhbnNsYXRlKDI0MiAxMjQpIj4KICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iMTciIGhlaWdodD0iMTciIHg9Ii41IiB5PSIuNSIgcng9IjIiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg=='); background-color: transparent; } .toastui-editor-dark .toastui-editor-contents .task-list-item.checked::before { background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbD0iIzRCOTZFNiI+CiAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2IDBjMS4xMDUgMCAyIC44OTUgMiAydjE0YzAgMS4xMDUtLjg5NSAyLTIgMkgyYy0xLjEwNSAwLTItLjg5NS0yLTJWMkMwIC44OTUuODk1IDAgMiAwaDE0em0tMS43OTMgNS4yOTNjLS4zOS0uMzktMS4wMjQtLjM5LTEuNDE0IDBMNy41IDEwLjU4NSA1LjIwNyA4LjI5M2wtLjA5NC0uMDgzYy0uMzkyLS4zMDUtLjk2LS4yNzgtMS4zMi4wODMtLjM5LjM5LS4zOSAxLjAyNCAwIDEuNDE0bDMgMyAuMDk0LjA4M2MuMzkyLjMwNS45Ni4yNzggMS4zMi0uMDgzbDYtNiAuMDgzLS4wOTRjLjMwNS0uMzkyLjI3OC0uOTYtLjA4My0xLjMyeiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwNTAgLTI5NikgdHJhbnNsYXRlKDc4OCAxOTIpIHRyYW5zbGF0ZSgyNjIgMTA0KSIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K'); } .toastui-editor-dark .toastui-editor-md-delimiter, .toastui-editor-dark .toastui-editor-md-code.toastui-editor-md-delimiter, .toastui-editor-dark .toastui-editor-md-thematic-break, .toastui-editor-dark .toastui-editor-md-link, .toastui-editor-dark .toastui-editor-md-table, .toastui-editor-dark .toastui-editor-md-block-quote { color: #55575f; } .toastui-editor-dark .toastui-editor-md-meta, .toastui-editor-dark .toastui-editor-md-html { color: #55575f; } .toastui-editor-dark .toastui-editor-md-link.toastui-editor-md-link-url.toastui-editor-md-marked-text { color: #777980; } .toastui-editor-dark .toastui-editor-md-block-quote .toastui-editor-md-marked-text, .toastui-editor-dark .toastui-editor-md-list-item .toastui-editor-md-meta { color: #b3b5bc; } .toastui-editor-dark .toastui-editor-md-link.toastui-editor-md-link-desc.toastui-editor-md-marked-text, .toastui-editor-dark .toastui-editor-md-list-item-style.toastui-editor-md-list-item-odd { color: #4b96e6; } .toastui-editor-dark .toastui-editor-md-list-item-style.toastui-editor-md-list-item-even { color: #ef6767; } .toastui-editor-dark .toastui-editor-md-table .toastui-editor-md-table-cell { color: #fff; } .toastui-editor-dark .toastui-editor-md-code.toastui-editor-md-marked-text { color: #c1798b; } .toastui-editor-dark .toastui-editor-md-code { background-color: #35262a; } .toastui-editor-dark .toastui-editor-md-code-block-line-background { background-color: #232428; } .toastui-editor-dark .toastui-editor-md-code-block .toastui-editor-md-meta { color: #aaa; } .toastui-editor-dark .toastui-editor-md-custom-block { color: #fff; } .toastui-editor-dark .toastui-editor-md-custom-block-line-background { background-color: #392d31; } .toastui-editor-dark .toastui-editor-md-custom-block .toastui-editor-md-delimiter { color: #327491; } .toastui-editor-dark .toastui-editor-md-custom-block .toastui-editor-md-meta { color: #65acca; } .toastui-editor-dark .toastui-editor-contents .toastui-editor-md-preview-highlight::after { background-color: rgba(255, 250, 193, 0.5); } .toastui-editor-dark .toastui-editor-contents th.toastui-editor-md-preview-highlight, .toastui-editor-dark .toastui-editor-contents td.toastui-editor-md-preview-highlight { background-color: rgba(255, 250, 193, 0.5); } .toastui-editor-dark .toastui-editor-contents th.toastui-editor-md-preview-highlight { color: #fff; } .toastui-editor-dark .toastui-editor-contents th.toastui-editor-md-preview-highlight, .toastui-editor-dark .toastui-editor-contents td.toastui-editor-md-preview-highlight { background-color: rgba(255, 250, 193, 0.25); } .toastui-editor-dark .toastui-editor-contents .toastui-editor-md-preview-highlight::after { background-color: rgba(255, 250, 193, 0.25); } ================================================ FILE: apps/editor/src/editor.ts ================================================ import { EditorOptions, ViewerOptions } from '@t/editor'; import { DefaultUI, VNode, IndexList, ToolbarItemOptions } from '@t/ui'; import EditorCore from './editorCore'; import Viewer from './viewer'; import html from './ui/vdom/template'; import { Layout } from './ui/components/layout'; import { render } from './ui/vdom/renderer'; /** * ToastUI Editor * @extends ToastUIEditorCore */ class ToastUIEditor extends EditorCore { private defaultUI!: DefaultUI; constructor(options: EditorOptions) { super(options); let layoutComp!: Layout; const destroy = render( this.options.el, html` <${Layout} ref=${(layout: Layout) => (layoutComp = layout)} eventEmitter=${this.eventEmitter} slots=${this.getEditorElements()} hideModeSwitch=${this.options.hideModeSwitch} toolbarItems=${this.options.toolbarItems} previewStyle=${this.options.previewStyle} editorType=${this.options.initialEditType} theme=${this.options.theme} /> ` as VNode ); this.setMinHeight(this.options.minHeight); this.setHeight(this.options.height); this.defaultUI = { insertToolbarItem: layoutComp.insertToolbarItem.bind(layoutComp), removeToolbarItem: layoutComp.removeToolbarItem.bind(layoutComp), destroy, }; this.pluginInfo.toolbarItems?.forEach((toolbarItem) => { const { groupIndex, itemIndex, item } = toolbarItem; this.defaultUI.insertToolbarItem({ groupIndex, itemIndex }, item); }); this.eventEmitter.emit('loadUI', this); } /** * Factory method for Editor * @param {object} options Option for initialize TUIEditor * @returns {object} ToastUIEditor or ToastUIEditorViewer */ static factory(options: (EditorOptions | ViewerOptions) & { viewer?: boolean }) { return options.viewer ? new Viewer(options) : new ToastUIEditor(options as EditorOptions); } /** * add toolbar item * @param {Object} indexInfo group index and item index of the toolbar item * @param {string|Object} item toolbar item */ insertToolbarItem(indexInfo: IndexList, item: string | ToolbarItemOptions) { this.defaultUI.insertToolbarItem(indexInfo, item); } /** * Remove toolbar item * @param {string} itemName toolbar item name */ removeToolbarItem(itemName: string) { this.defaultUI.removeToolbarItem(itemName); } /** * Destroy TUIEditor from document */ destroy() { super.destroy(); this.defaultUI.destroy(); } } export default ToastUIEditor; ================================================ FILE: apps/editor/src/editorCore.ts ================================================ import { DOMParser } from 'prosemirror-model'; import forEachOwnProperties from 'tui-code-snippet/collection/forEachOwnProperties'; import extend from 'tui-code-snippet/object/extend'; import css from 'tui-code-snippet/domUtil/css'; import addClass from 'tui-code-snippet/domUtil/addClass'; import removeClass from 'tui-code-snippet/domUtil/removeClass'; import isString from 'tui-code-snippet/type/isString'; import isNumber from 'tui-code-snippet/type/isNumber'; import { Emitter, Handler } from '@t/event'; import { Base, EditorOptions, EditorPos, EditorType, PreviewStyle, ViewerOptions, WidgetStyle, } from '@t/editor'; import { PluginCommandMap, PluginInfoResult, CommandFn } from '@t/plugin'; import { sendHostName, sanitizeLinkAttribute, deepMergedCopy } from './utils/common'; import MarkdownEditor from './markdown/mdEditor'; import MarkdownPreview from './markdown/mdPreview'; import WysiwygEditor from './wysiwyg/wwEditor'; import EventEmitter from './event/eventEmitter'; import CommandManager from './commands/commandManager'; import Convertor from './convertors/convertor'; import Viewer from './viewer'; import i18n, { I18n } from './i18n/i18n'; import { getPluginInfo } from './helper/plugin'; import { ToastMark } from '@toast-ui/toastmark'; import { WwToDOMAdaptor } from './wysiwyg/adaptor/wwToDOMAdaptor'; import { ScrollSync } from './markdown/scroll/scrollSync'; import { addDefaultImageBlobHook } from './helper/image'; import { setWidgetRules } from './widget/rules'; import { cls, removeProseMirrorHackNodes, replaceBRWithEmptyBlock } from './utils/dom'; import { sanitizeHTML } from './sanitizer/htmlSanitizer'; import { createHTMLSchemaMap } from './wysiwyg/nodes/html'; import { getHTMLRenderConvertors } from './markdown/htmlRenderConvertors'; import { buildQuery } from './queries/queryManager'; import { getEditorToMdPos, getMdToEditorPos } from './markdown/helper/pos'; import { Pos } from '@t/toastmark'; /** * ToastUIEditorCore * @param {Object} options Option object * @param {HTMLElement} options.el - container element * @param {string} [options.height='300px'] - Editor's height style value. Height is applied as border-box ex) '300px', '100%', 'auto' * @param {string} [options.minHeight='200px'] - Editor's min-height style value in pixel ex) '300px' * @param {string} [options.initialValue] - Editor's initial value * @param {string} [options.previewStyle] - Markdown editor's preview style (tab, vertical) * @param {boolean} [options.previewHighlight = true] - Highlight a preview element corresponds to the cursor position in the markdown editor * @param {string} [options.initialEditType] - Initial editor type (markdown, wysiwyg) * @param {Object} [options.events] - Events * @param {function} [options.events.load] - It would be emitted when editor fully load * @param {function} [options.events.change] - It would be emitted when content changed * @param {function} [options.events.caretChange] - It would be emitted when format change by cursor position * @param {function} [options.events.focus] - It would be emitted when editor get focus * @param {function} [options.events.blur] - It would be emitted when editor loose focus * @param {function} [options.events.keydown] - It would be emitted when the key is pressed in editor * @param {function} [options.events.keyup] - It would be emitted when the key is released in editor * @param {function} [options.events.beforePreviewRender] - It would be emitted before rendering the markdown preview with html string * @param {function} [options.events.beforeConvertWysiwygToMarkdown] - It would be emitted before converting wysiwyg to markdown with markdown text * @param {Object} [options.hooks] - Hooks * @param {addImageBlobHook} [options.hooks.addImageBlobHook] - hook for image upload * @param {string} [options.language='en-US'] - language * @param {boolean} [options.useCommandShortcut=true] - whether use keyboard shortcuts to perform commands * @param {boolean} [options.usageStatistics=true] - send hostname to google analytics * @param {Array.} [options.toolbarItems] - toolbar items. * @param {boolean} [options.hideModeSwitch=false] - hide mode switch tab bar * @param {Array.} [options.plugins] - Array of plugins. A plugin can be either a function or an array in the form of [function, options]. * @param {Object} [options.extendedAutolinks] - Using extended Autolinks specified in GFM spec * @param {string} [options.placeholder] - The placeholder text of the editable element. * @param {Object} [options.linkAttributes] - Attributes of anchor element that should be rel, target, hreflang, type * @param {Object} [options.customHTMLRenderer=null] - Object containing custom renderer functions correspond to change markdown node to preview HTML or wysiwyg node * @param {Object} [options.customMarkdownRenderer=null] - Object containing custom renderer functions correspond to change wysiwyg node to markdown text * @param {boolean} [options.referenceDefinition=false] - whether use the specification of link reference definition * @param {function} [options.customHTMLSanitizer=null] - custom HTML sanitizer * @param {boolean} [options.previewHighlight=false] - whether highlight preview area * @param {boolean} [options.frontMatter=false] - whether use the front matter * @param {Array.} [options.widgetRules=[]] - The rules for replacing the text with widget node * @param {string} [options.theme] - The theme to style the editor with. The default is included in toastui-editor.css. * @param {autofocus} [options.autofocus=true] - automatically focus the editor on creation. */ class ToastUIEditorCore { private initialHTML: string; private toastMark: ToastMark; private mdEditor: MarkdownEditor; private wwEditor: WysiwygEditor; private preview: MarkdownPreview; private convertor: Convertor; private commandManager: CommandManager; private height!: string; private minHeight!: string; private mode!: EditorType; private mdPreviewStyle: PreviewStyle; private i18n: I18n; private scrollSync: ScrollSync; private placeholder?: string; eventEmitter: Emitter; protected options: Required; protected pluginInfo: PluginInfoResult; constructor(options: EditorOptions) { this.initialHTML = options.el.innerHTML; options.el.innerHTML = ''; this.options = extend( { previewStyle: 'tab', previewHighlight: true, initialEditType: 'markdown', height: '300px', minHeight: '200px', language: 'en-US', useCommandShortcut: true, usageStatistics: true, toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], ['table', 'image', 'link'], ['code', 'codeblock'], ['scrollSync'], ], hideModeSwitch: false, linkAttributes: null, extendedAutolinks: false, customHTMLRenderer: null, customMarkdownRenderer: null, referenceDefinition: false, customHTMLSanitizer: null, frontMatter: false, widgetRules: [], theme: 'light', autofocus: true, }, options ); const { customHTMLRenderer, extendedAutolinks, referenceDefinition, frontMatter, customMarkdownRenderer, useCommandShortcut, initialEditType, widgetRules, customHTMLSanitizer, } = this.options; this.mode = initialEditType || 'markdown'; this.mdPreviewStyle = this.options.previewStyle; this.i18n = i18n; this.i18n.setCode(this.options.language); this.eventEmitter = new EventEmitter(); setWidgetRules(widgetRules); const linkAttributes = sanitizeLinkAttribute(this.options.linkAttributes); this.pluginInfo = getPluginInfo({ plugins: this.options.plugins, eventEmitter: this.eventEmitter, usageStatistics: this.options.usageStatistics, instance: this, }); const { toHTMLRenderers, toMarkdownRenderers, mdPlugins, wwPlugins, wwNodeViews, mdCommands, wwCommands, markdownParsers, } = this.pluginInfo; const rendererOptions = { linkAttributes, customHTMLRenderer: deepMergedCopy(toHTMLRenderers, customHTMLRenderer), extendedAutolinks, referenceDefinition, frontMatter, sanitizer: customHTMLSanitizer || sanitizeHTML, }; const wwToDOMAdaptor = new WwToDOMAdaptor(linkAttributes, rendererOptions.customHTMLRenderer); const htmlSchemaMap = createHTMLSchemaMap( rendererOptions.customHTMLRenderer, rendererOptions.sanitizer, wwToDOMAdaptor ); this.toastMark = new ToastMark('', { disallowedHtmlBlockTags: ['br', 'img'], extendedAutolinks, referenceDefinition, disallowDeepHeading: true, frontMatter, customParser: markdownParsers, }); this.mdEditor = new MarkdownEditor(this.eventEmitter, { toastMark: this.toastMark, useCommandShortcut, mdPlugins, }); this.preview = new MarkdownPreview(this.eventEmitter, { ...rendererOptions, isViewer: false, highlight: this.options.previewHighlight, }); this.wwEditor = new WysiwygEditor(this.eventEmitter, { toDOMAdaptor: wwToDOMAdaptor, useCommandShortcut, htmlSchemaMap, linkAttributes, wwPlugins, wwNodeViews, }); this.convertor = new Convertor( this.wwEditor.getSchema(), { ...toMarkdownRenderers, ...customMarkdownRenderer }, getHTMLRenderConvertors(linkAttributes, rendererOptions.customHTMLRenderer), this.eventEmitter ); this.setMinHeight(this.options.minHeight); this.setHeight(this.options.height); this.setMarkdown(this.options.initialValue, false); if (this.options.placeholder) { this.setPlaceholder(this.options.placeholder); } if (!this.options.initialValue) { this.setHTML(this.initialHTML, false); } this.commandManager = new CommandManager( this.eventEmitter, this.mdEditor.commands, this.wwEditor.commands, () => this.mode ); if (this.options.usageStatistics) { sendHostName(); } this.scrollSync = new ScrollSync(this.mdEditor, this.preview, this.eventEmitter); this.addInitEvent(); this.addInitCommand(mdCommands, wwCommands); buildQuery(this); if (this.options.hooks) { forEachOwnProperties(this.options.hooks, (fn, key) => this.addHook(key, fn)); } if (this.options.events) { forEachOwnProperties(this.options.events, (fn, key) => this.on(key, fn)); } this.eventEmitter.emit('load', this); this.moveCursorToStart(this.options.autofocus); } private addInitEvent() { this.on('needChangeMode', this.changeMode.bind(this)); this.on('loadUI', () => { if (this.height !== 'auto') { // 75px equals default editor ui height - the editing area height const minHeight = `${Math.min( parseInt(this.minHeight, 10), parseInt(this.height, 10) - 75 )}px`; this.setMinHeight(minHeight); } }); addDefaultImageBlobHook(this.eventEmitter); } private addInitCommand(mdCommands: PluginCommandMap, wwCommands: PluginCommandMap) { const addPluginCommands = (type: EditorType, commandMap: PluginCommandMap) => { Object.keys(commandMap).forEach((name) => { this.addCommand(type, name, commandMap[name]); }); }; this.addCommand('markdown', 'toggleScrollSync', (payload) => { this.eventEmitter.emit('toggleScrollSync', payload!.active); return true; }); addPluginCommands('markdown', mdCommands); addPluginCommands('wysiwyg', wwCommands); } private getCurrentModeEditor() { return (this.isMarkdownMode() ? this.mdEditor : this.wwEditor) as Base; } /** * Factory method for Editor * @param {object} options Option for initialize TUIEditor * @returns {object} ToastUIEditorCore or ToastUIEditorViewer */ static factory(options: (EditorOptions | ViewerOptions) & { viewer?: boolean }) { return options.viewer ? new Viewer(options) : new ToastUIEditorCore(options as EditorOptions); } /** * Set language * @param {string|string[]} code - code for I18N language * @param {object} data - language set */ static setLanguage(code: string | string[], data: Record) { i18n.setLanguage(code, data); } /** * change preview style * @param {string} style - 'tab'|'vertical' */ changePreviewStyle(style: PreviewStyle) { if (this.mdPreviewStyle !== style) { this.mdPreviewStyle = style; this.eventEmitter.emit('changePreviewStyle', style); } } /** * execute editor command * @param {string} name - command name * @param {object} [payload] - payload for command */ exec(name: string, payload?: Record) { this.commandManager.exec(name, payload); } /** * @param {string} type - editor type * @param {string} name - command name * @param {function} command - command handler */ addCommand(type: EditorType, name: string, command: CommandFn) { const commandHoc = (paylaod: Record = {}) => { const { view } = type === 'markdown' ? this.mdEditor : this.wwEditor; command(paylaod, view.state, view.dispatch, view); }; this.commandManager.addCommand(type, name, commandHoc); } /** * Bind eventHandler to event type * @param {string} type Event type * @param {function} handler Event handler */ on(type: string, handler: Handler) { this.eventEmitter.listen(type, handler); } /** * Unbind eventHandler from event type * @param {string} type Event type */ off(type: string) { this.eventEmitter.removeEventHandler(type); } /** * Add hook to TUIEditor event * @param {string} type Event type * @param {function} handler Event handler */ addHook(type: string, handler: Handler) { this.eventEmitter.removeEventHandler(type); this.eventEmitter.listen(type, handler); } /** * Remove hook from TUIEditor event * @param {string} type Event type */ removeHook(type: string) { this.eventEmitter.removeEventHandler(type); } /** * Set focus to current Editor */ focus() { this.getCurrentModeEditor().focus(); } /** * Remove focus of current Editor */ blur() { this.getCurrentModeEditor().blur(); } /** * Set cursor position to end * @param {boolean} [focus] - automatically focus the editor */ moveCursorToEnd(focus = true) { this.getCurrentModeEditor().moveCursorToEnd(focus); } /** * Set cursor position to start * @param {boolean} [focus] - automatically focus the editor */ moveCursorToStart(focus = true) { this.getCurrentModeEditor().moveCursorToStart(focus); } /** * Set markdown syntax text. * @param {string} markdown - markdown syntax text. * @param {boolean} [cursorToEnd=true] - move cursor to contents end */ setMarkdown(markdown = '', cursorToEnd = true) { this.mdEditor.setMarkdown(markdown, cursorToEnd); if (this.isWysiwygMode()) { const mdNode = this.toastMark.getRootNode(); const wwNode = this.convertor.toWysiwygModel(mdNode); this.wwEditor.setModel(wwNode!, cursorToEnd); } } /** * Set html value. * @param {string} html - html syntax text * @param {boolean} [cursorToEnd=true] - move cursor to contents end */ setHTML(html = '', cursorToEnd = true) { const container = document.createElement('div'); // the `br` tag should be replaced with empty block to separate between blocks container.innerHTML = replaceBRWithEmptyBlock(html); const wwNode = DOMParser.fromSchema(this.wwEditor.schema).parse(container); if (this.isMarkdownMode()) { this.mdEditor.setMarkdown(this.convertor.toMarkdownText(wwNode), cursorToEnd); } else { this.wwEditor.setModel(wwNode, cursorToEnd); } } /** * Get content to markdown * @returns {string} markdown text */ getMarkdown() { if (this.isMarkdownMode()) { return this.mdEditor.getMarkdown(); } return this.convertor.toMarkdownText(this.wwEditor.getModel()); } /** * Get content to html * @returns {string} html string */ getHTML() { this.eventEmitter.holdEventInvoke(() => { if (this.isMarkdownMode()) { const mdNode = this.toastMark.getRootNode(); const wwNode = this.convertor.toWysiwygModel(mdNode); this.wwEditor.setModel(wwNode!); } }); const html = removeProseMirrorHackNodes(this.wwEditor.view.dom.innerHTML); if (this.placeholder) { const rePlaceholder = new RegExp( `} [pos] - position */ addWidget(node: Node, style: WidgetStyle, pos?: EditorPos) { this.getCurrentModeEditor().addWidget(node, style, pos); } /** * Replace node with widget to range * @param {number|Array.} start - start position * @param {number|Array.} end - end position * @param {string} text - widget text content */ replaceWithWidget(start: EditorPos, end: EditorPos, text: string) { this.getCurrentModeEditor().replaceWithWidget(start, end, text); } /** * Set editor height * @param {string} height - editor height in pixel */ setHeight(height: string) { const { el } = this.options; if (isString(height)) { if (height === 'auto') { addClass(el, 'auto-height'); } else { removeClass(el, 'auto-height'); } this.setMinHeight(this.getMinHeight()); } css(el, { height }); this.height = height; } /** * Get editor height * @returns {string} editor height in pixel */ getHeight() { return this.height; } /** * Set minimum height to editor content * @param {string} minHeight - min content height in pixel */ setMinHeight(minHeight: string) { if (minHeight !== this.minHeight) { const height = this.height || this.options.height; if (height !== 'auto' && this.options.el.querySelector(`.${cls('main')}`)) { // 75px equals default editor ui height - the editing area height minHeight = `${Math.min(parseInt(minHeight, 10), parseInt(height, 10) - 75)}px`; } const minHeightNum = parseInt(minHeight, 10); this.minHeight = minHeight; this.wwEditor.setMinHeight(minHeightNum); this.mdEditor.setMinHeight(minHeightNum); this.preview.setMinHeight(minHeightNum); } } /** * Get minimum height of editor content * @returns {string} min height in pixel */ getMinHeight() { return this.minHeight; } /** * Return true if current editor mode is Markdown * @returns {boolean} */ isMarkdownMode() { return this.mode === 'markdown'; } /** * Return true if current editor mode is WYSIWYG * @returns {boolean} */ isWysiwygMode() { return this.mode === 'wysiwyg'; } /** * Return false * @returns {boolean} */ isViewer() { return false; } /** * Get current Markdown editor's preview style * @returns {string} */ getCurrentPreviewStyle() { return this.mdPreviewStyle; } /** * Change editor's mode to given mode string * @param {string} mode - Editor mode name of want to change * @param {boolean} [withoutFocus] - Change mode without focus */ changeMode(mode: EditorType, withoutFocus?: boolean) { if (this.mode === mode) { return; } this.mode = mode; if (this.isWysiwygMode()) { const mdNode = this.toastMark.getRootNode(); const wwNode = this.convertor.toWysiwygModel(mdNode); this.wwEditor.setModel(wwNode!); } else { const wwNode = this.wwEditor.getModel(); this.mdEditor.setMarkdown(this.convertor.toMarkdownText(wwNode), !withoutFocus); } this.eventEmitter.emit('removePopupWidget'); this.eventEmitter.emit('changeMode', mode); if (!withoutFocus) { const pos = this.convertor.getMappedPos(); this.focus(); if (this.isWysiwygMode() && isNumber(pos)) { this.wwEditor.setSelection(pos); } else if (Array.isArray(pos)) { this.mdEditor.setSelection(pos); } } } /** * Destroy TUIEditor from document */ destroy() { this.wwEditor.destroy(); this.mdEditor.destroy(); this.preview.destroy(); this.scrollSync.destroy(); this.eventEmitter.emit('destroy'); this.eventEmitter.getEvents().forEach((_, type: string) => this.off(type)); } /** * Hide TUIEditor */ hide() { this.eventEmitter.emit('hide'); } /** * Show TUIEditor */ show() { this.eventEmitter.emit('show'); } /** * Move on scroll position of the editor container * @param {number} value scrollTop value of editor container */ setScrollTop(value: number) { this.getCurrentModeEditor().setScrollTop(value); } /** * Get scroll position value of editor container * @returns {number} scrollTop value of editor container */ getScrollTop() { return this.getCurrentModeEditor().getScrollTop(); } /** * Reset TUIEditor */ reset() { this.wwEditor.setModel([]); this.mdEditor.setMarkdown(''); } /** * Get current selection range * @returns {Array.|Array.} Returns the range of the selection depending on the editor mode * @example * // Markdown mode * const mdSelection = editor.getSelection(); * * console.log(mdSelection); // [[startLineOffset, startCurorOffset], [endLineOffset, endCurorOffset]] * * // WYSIWYG mode * const wwSelection = editor.getSelection(); * * console.log(wwSelection); // [startCursorOffset, endCursorOffset] */ getSelection() { return this.getCurrentModeEditor().getSelection(); } /** * Set the placeholder on all editors * @param {string} placeholder - placeholder to set */ setPlaceholder(placeholder: string) { this.placeholder = placeholder; this.mdEditor.setPlaceholder(placeholder); this.wwEditor.setPlaceholder(placeholder); } /** * Get markdown editor, preview, wysiwyg editor DOM elements */ getEditorElements() { return { mdEditor: this.mdEditor.getElement(), mdPreview: this.preview.getElement(), wwEditor: this.wwEditor.getElement(), }; } /** * Convert position to match editor mode * @param {number|Array.} start - start position * @param {number|Array.} end - end position * @param {string} mode - Editor mode name of want to match converted position to */ convertPosToMatchEditorMode(start: EditorPos, end = start, mode = this.mode) { const { doc } = this.mdEditor.view.state; const isFromArray = Array.isArray(start); const isToArray = Array.isArray(end); let convertedFrom = start; let convertedTo = end; if (isFromArray !== isToArray) { throw new Error('Types of arguments must be same'); } if (mode === 'markdown' && !isFromArray && !isToArray) { [convertedFrom, convertedTo] = getEditorToMdPos(doc, start as number, end as number); } else if (mode === 'wysiwyg' && isFromArray && isToArray) { [convertedFrom, convertedTo] = getMdToEditorPos(doc, start as Pos, end as Pos); } return [convertedFrom, convertedTo]; } } // // (Not an official API) // // Create a function converting markdown to HTML using the internal parser and renderer. // ToastUIEditor._createMarkdownToHTML = createMarkdownToHTML; export default ToastUIEditorCore; ================================================ FILE: apps/editor/src/esm/index.ts ================================================ import EditorCore from '@/editorCore'; import Editor from '@/editor'; import '@/i18n/en-us'; export default Editor; export { Editor, EditorCore }; ================================================ FILE: apps/editor/src/esm/indexViewer.ts ================================================ import Viewer from '@/viewer'; export default Viewer; ================================================ FILE: apps/editor/src/event/eventEmitter.ts ================================================ import isUndefined from 'tui-code-snippet/type/isUndefined'; import isFalsy from 'tui-code-snippet/type/isFalsy'; import { Emitter, EventTypes, Handler } from '@t/event'; import Map from '@/utils/map'; const eventTypeList: EventTypes[] = [ 'afterPreviewRender', 'updatePreview', 'changeMode', 'needChangeMode', 'command', 'changePreviewStyle', 'changePreviewTabPreview', 'changePreviewTabWrite', 'scroll', 'contextmenu', 'show', 'hide', 'changeLanguage', 'changeToolbarState', 'toggleScrollSync', 'mixinTableOffsetMapPrototype', 'setFocusedNode', 'removePopupWidget', 'query', // provide event for user 'openPopup', 'closePopup', 'addImageBlobHook', 'beforePreviewRender', 'beforeConvertWysiwygToMarkdown', 'load', 'loadUI', 'change', 'caretChange', 'destroy', 'focus', 'blur', 'keydown', 'keyup', ]; /** * Class EventEmitter * @ignore */ class EventEmitter implements Emitter { private events: Map; private eventTypes: Record; private hold: boolean; constructor() { this.events = new Map(); this.eventTypes = eventTypeList.reduce((types, type) => { return { ...types, type }; }, {}); this.hold = false; eventTypeList.forEach((eventType) => { this.addEventType(eventType); }); } /** * Listen event and bind event handler * @param {string} type Event type string * @param {function} handler Event handler */ listen(type: string, handler: Handler) { const typeInfo = this.getTypeInfo(type); const eventHandlers = this.events.get(typeInfo.type) || []; if (!this.hasEventType(typeInfo.type)) { throw new Error(`There is no event type ${typeInfo.type}`); } if (typeInfo.namespace) { handler.namespace = typeInfo.namespace; } eventHandlers.push(handler); this.events.set(typeInfo.type, eventHandlers); } /** * Emit event * @param {string} eventName Event name to emit * @returns {Array} */ emit(type: string, ...args: any[]) { const typeInfo = this.getTypeInfo(type); const eventHandlers = this.events.get(typeInfo.type); const results: any[] = []; if (!this.hold && eventHandlers) { eventHandlers.forEach((handler) => { const result = handler(...args); if (!isUndefined(result)) { results.push(result); } }); } return results; } /** * Emit given event and return result * @param {string} eventName Event name to emit * @param {any} source Source to change * @returns {string} */ emitReduce(type: string, source: any, ...args: any[]) { const eventHandlers = this.events.get(type); if (!this.hold && eventHandlers) { eventHandlers.forEach((handler) => { const result = handler(source, ...args); if (!isFalsy(result)) { source = result; } }); } return source; } /** * Get event type and namespace * @param {string} type Event type name * @returns {{type: string, namespace: string}} * @private */ private getTypeInfo(type: string) { const splited = type.split('.'); return { type: splited[0], namespace: splited[1], }; } /** * Check whether event type exists or not * @param {string} type Event type name * @returns {boolean} * @private */ private hasEventType(type: string) { return !isUndefined(this.eventTypes[this.getTypeInfo(type).type]); } /** * Add event type when given event not exists * @param {string} type Event type name */ addEventType(type: string) { if (this.hasEventType(type)) { throw new Error(`There is already have event type ${type}`); } this.eventTypes[type] = type; } /** * Remove event handler from given event type * @param {string} eventType Event type name * @param {function} [handler] - registered event handler */ removeEventHandler(eventType: string, handler?: Handler) { const { type, namespace } = this.getTypeInfo(eventType); if (type && handler) { this.removeEventHandlerWithHandler(type, handler); } else if (type && !namespace) { this.events.delete(type); } else if (!type && namespace) { this.events.forEach((_, evtType) => { this.removeEventHandlerWithTypeInfo(evtType, namespace); }); } else if (type && namespace) { this.removeEventHandlerWithTypeInfo(type, namespace); } } /** * Remove event handler with event handler * @param {string} type - event type name * @param {function} handler - event handler * @private */ private removeEventHandlerWithHandler(type: string, handler: Handler) { const eventHandlers = this.events.get(type); if (eventHandlers) { const handlerIndex = eventHandlers.indexOf(handler); if (eventHandlers.indexOf(handler) >= 0) { eventHandlers.splice(handlerIndex, 1); } } } /** * Remove event handler with event type information * @param {string} type Event type name * @param {string} namespace Event namespace * @private */ private removeEventHandlerWithTypeInfo(type: string, namespace: string) { const handlersToSurvive: Handler[] = []; const eventHandlers = this.events.get(type); if (!eventHandlers) { return; } eventHandlers.map((handler: Handler) => { if (handler.namespace !== namespace) { handlersToSurvive.push(handler); } return null; }); this.events.set(type, handlersToSurvive); } getEvents() { return this.events; } holdEventInvoke(fn: Function) { this.hold = true; fn(); this.hold = false; } } export default EventEmitter; ================================================ FILE: apps/editor/src/helper/image.ts ================================================ import toArray from 'tui-code-snippet/collection/toArray'; import { HookCallback } from '@t/editor'; import { Emitter } from '@t/event'; export function addDefaultImageBlobHook(eventEmitter: Emitter) { eventEmitter.listen('addImageBlobHook', (blob: File, callback: HookCallback) => { const reader = new FileReader(); reader.onload = ({ target }) => callback(target!.result as string); reader.readAsDataURL(blob); }); } export function emitImageBlobHook(eventEmitter: Emitter, blob: File, type: string) { const hook: HookCallback = (imageUrl, altText) => { eventEmitter.emit('command', 'addImage', { imageUrl, altText: altText || blob.name || 'image', }); }; eventEmitter.emit('addImageBlobHook', blob, hook, type); } export function pasteImageOnly(items: DataTransferItemList) { const images = toArray(items).filter(({ type }) => type.indexOf('image') !== -1); if (images.length === 1) { const [item] = images; if (item) { return item.getAsFile(); } } return null; } ================================================ FILE: apps/editor/src/helper/manipulation.ts ================================================ import { TextSelection, Transaction, EditorState } from 'prosemirror-state'; import { ProsemirrorNode, Schema, Mark, ResolvedPos, Fragment } from 'prosemirror-model'; import isString from 'tui-code-snippet/type/isString'; interface ReplacePayload { state: EditorState; from: number; startIndex: number; endIndex: number; createText: (textContent: string) => string; } export function createParagraph(schema: Schema, content?: string | ProsemirrorNode[]) { const { paragraph } = schema.nodes; if (!content) { return paragraph.createAndFill()!; } return paragraph.create(null, isString(content) ? schema.text(content) : content); } export function createTextNode(schema: Schema, text: string, marks?: Mark[]) { return schema.text(text, marks); } export function createTextSelection(tr: Transaction, from: number, to = from) { const contentSize = tr.doc.content.size; const size = contentSize > 0 ? contentSize - 1 : 1; return TextSelection.create(tr.doc, Math.min(from, size), Math.min(to, size)); } export function addParagraph(tr: Transaction, { pos }: ResolvedPos, schema: Schema) { tr.replaceWith(pos, pos, createParagraph(schema)); return tr.setSelection(createTextSelection(tr, pos + 1)); } export function replaceTextNode({ state, from, startIndex, endIndex, createText }: ReplacePayload) { const { tr, doc, schema } = state; for (let i = startIndex; i <= endIndex; i += 1) { const { nodeSize, textContent, content } = doc.child(i); const text = createText(textContent); const node = text ? createTextNode(schema, text) : Fragment.empty; const mappedFrom = tr.mapping.map(from); const mappedTo = mappedFrom + content.size; tr.replaceWith(mappedFrom, mappedTo, node); from += nodeSize; } return tr; } export function splitAndExtendBlock( tr: Transaction, pos: number, text: string, node: ProsemirrorNode ) { const textLen = text.length; (tr.split(pos) as Transaction) .delete(pos - textLen, pos) .insert(tr.mapping.map(pos), node) .setSelection(createTextSelection(tr, tr.mapping.map(pos) - textLen)); } ================================================ FILE: apps/editor/src/helper/plugin.ts ================================================ import isArray from 'tui-code-snippet/type/isArray'; import { Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state'; import { inputRules, InputRule, undoInputRule } from 'prosemirror-inputrules'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { keymap } from 'prosemirror-keymap'; import { Fragment } from 'prosemirror-model'; import i18n from '@/i18n/i18n'; import { deepMergedCopy } from '@/utils/common'; import { EditorPluginInfo, EditorPluginsInfo } from '@t/editor'; import { PluginInfoResult } from '@t/plugin'; import { mixinTableOffsetMapPrototype } from '@/wysiwyg/helper/tableOffsetMap'; function execPlugin(pluginInfo: EditorPluginInfo) { const { plugin, eventEmitter, usageStatistics, instance } = pluginInfo; const pmState = { Plugin, PluginKey, Selection, TextSelection }; const pmView = { Decoration, DecorationSet }; const pmModel = { Fragment }; const pmRules = { InputRule, inputRules, undoInputRule }; const pmKeymap = { keymap }; const context = { eventEmitter, usageStatistics, instance, pmState, pmView, pmModel, pmRules, pmKeymap, i18n, }; if (isArray(plugin)) { const [pluginFn, options = {}] = plugin; return pluginFn(context, options); } return plugin(context); } export function getPluginInfo(pluginsInfo: EditorPluginsInfo) { const { plugins, eventEmitter, usageStatistics, instance } = pluginsInfo; eventEmitter.listen('mixinTableOffsetMapPrototype', mixinTableOffsetMapPrototype); return (plugins ?? []).reduce( (acc, plugin) => { const pluginInfoResult = execPlugin({ plugin, eventEmitter, usageStatistics, instance, }); if (!pluginInfoResult) { throw new Error('The return value of the executed plugin is empty.'); } const { markdownParsers, toHTMLRenderers, toMarkdownRenderers, markdownPlugins, wysiwygPlugins, wysiwygNodeViews, markdownCommands, wysiwygCommands, toolbarItems, } = pluginInfoResult; if (toHTMLRenderers) { acc.toHTMLRenderers = deepMergedCopy(acc.toHTMLRenderers, toHTMLRenderers); } if (toMarkdownRenderers) { acc.toMarkdownRenderers = deepMergedCopy(acc.toMarkdownRenderers, toMarkdownRenderers); } if (markdownPlugins) { acc.mdPlugins = acc.mdPlugins!.concat(markdownPlugins); } if (wysiwygPlugins) { acc.wwPlugins = acc.wwPlugins!.concat(wysiwygPlugins); } if (wysiwygNodeViews) { acc.wwNodeViews = { ...acc.wwNodeViews, ...wysiwygNodeViews }; } if (markdownCommands) { acc.mdCommands = { ...acc.mdCommands, ...markdownCommands }; } if (wysiwygCommands) { acc.wwCommands = { ...acc.wwCommands, ...wysiwygCommands }; } if (toolbarItems) { acc.toolbarItems = acc.toolbarItems!.concat(toolbarItems); } if (markdownParsers) { acc.markdownParsers = { ...acc.markdownParsers, ...markdownParsers }; } return acc; }, { toHTMLRenderers: {}, toMarkdownRenderers: {}, mdPlugins: [], wwPlugins: [], wwNodeViews: {}, mdCommands: {}, wwCommands: {}, toolbarItems: [], markdownParsers: {}, } ); } ================================================ FILE: apps/editor/src/i18n/ar.ts ================================================ /** * @fileoverview I18N for Arabic * @author Amira Salah */ import Editor from '../editorCore'; Editor.setLanguage('ar', { Markdown: 'لغة ترميز', WYSIWYG: 'ما تراه هو ما تحصل عليه', Write: 'يكتب', Preview: 'عرض مسبق', Headings: 'العناوين', Paragraph: 'فقرة', Bold: 'خط عريض', Italic: 'خط مائل', Strike: 'إضراب', Code: 'رمز', Line: 'خط', Blockquote: 'فقرة مقتبسة', 'Unordered list': 'قائمة غير مرتبة', 'Ordered list': 'قائمة مرتبة', Task: 'مهمة', Indent: 'المسافة البادئة', Outdent: 'المسافة الخارجة', 'Insert link': 'أدخل الرابط', 'Insert CodeBlock': 'أدخل الكود', 'Insert table': 'أدخل جدول', 'Insert image': 'أدخل صورة', Heading: 'عنوان', 'Image URL': 'رابط الصورة', 'Select image file': 'حدد ملف الصورة', 'Choose a file': 'اختيار الملف', 'No file': 'لا ملف', Description: 'وصف', OK: 'موافقة', More: 'أكثر', Cancel: 'إلغاء', File: 'ملف', URL: 'رابط', 'Link text': 'نص الرابط', 'Add row to up': 'أضف صفًا لأعلى', 'Add row to down': 'أضف صفًا إلى أسفل', 'Add column to left': 'أضف العمود على اليسار', 'Add column to right': 'أضف عمودًا إلى اليمين', 'Remove row': 'حذف سطر', 'Remove column': 'حذف عمود', 'Align column to left': 'محاذاة اليسار', 'Align column to center': 'محاذاة الوسط', 'Align column to right': 'محاذاة اليمين', 'Remove table': 'حذف الجدول', 'Would you like to paste as table?': 'هل تريد اللصق كجدول', 'Text color': 'لون النص', 'Auto scroll enabled': 'التحريك التلقائي ممكّن', 'Auto scroll disabled': 'التحريك التلقائي معطّل', 'Choose language': 'اختر اللغة', }); ================================================ FILE: apps/editor/src/i18n/cs-cz.ts ================================================ /** * @fileoverview I18N for Czech * @author Dmitrij Tkačenko */ import Editor from '../editorCore'; Editor.setLanguage(['cs', 'cs-CZ'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Napsat', Preview: 'Náhled', Headings: 'Nadpisy', Paragraph: 'Odstavec', Bold: 'Tučné', Italic: 'Kurzíva', Strike: 'Přeškrtnuté', Code: 'Kód', Line: 'Vodorovná čára', Blockquote: 'Citace', 'Unordered list': 'Seznam s odrážkami', 'Ordered list': 'Číslovaný seznam', Task: 'Úkol', Indent: 'Zvětšit odsazení', Outdent: 'Zmenšit odsazení', 'Insert link': 'Vložit odkaz', 'Insert CodeBlock': 'Vložit blok kódu', 'Insert table': 'Vložit tabulku', 'Insert image': 'Vložit obrázek', Heading: 'Nadpis', 'Image URL': 'URL obrázku', 'Select image file': 'Vybrat obrázek', 'Choose a file': 'Vyberte soubor', 'No file': 'Žádný soubor', Description: 'Popis', OK: 'OK', More: 'Více', Cancel: 'Zrušit', File: 'Soubor', URL: 'URL', 'Link text': 'Text odkazu', 'Add row to up': 'Přidejte řádek nahoru', 'Add row to down': 'Přidejte řádek dolů', 'Add column to left': 'Přidat sloupec vlevo', 'Add column to right': 'Přidat sloupec doprava', 'Remove row': 'Odebrat řádek', 'Remove column': 'Odebrat sloupec', 'Align column to left': 'Zarovnat vlevo', 'Align column to center': 'Zarovnat na střed', 'Align column to right': 'Zarovnat vpravo', 'Remove table': 'Odstranit tabulku', 'Would you like to paste as table?': 'Chcete vložit jako tabulku?', 'Text color': 'Barva textu', 'Auto scroll enabled': 'Automatické rolování zapnuto', 'Auto scroll disabled': 'Automatické rolování vypnuto', 'Choose language': 'Vybrat jazyk', }); ================================================ FILE: apps/editor/src/i18n/de-de.ts ================================================ /** * @fileoverview I18N for German * @author Jann-Niklas Kiepert */ import Editor from '../editorCore'; Editor.setLanguage(['de', 'de-DE'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Verfassen', Preview: 'Vorschau', Headings: 'Überschriften', Paragraph: 'Text', Bold: 'Fett', Italic: 'Kursiv', Strike: 'Durchgestrichen', Code: 'Code', Line: 'Trennlinie', Blockquote: 'Blocktext', 'Unordered list': 'Aufzählung', 'Ordered list': 'Nummerierte Aufzählung', Task: 'Aufgabe', Indent: 'Einrücken', Outdent: 'Ausrücken', 'Insert link': 'Link einfügen', 'Insert CodeBlock': 'Codeblock einfügen', 'Insert table': 'Tabelle einfügen', 'Insert image': 'Grafik einfügen', Heading: 'Titel', 'Image URL': 'Bild URL', 'Select image file': 'Grafik auswählen', 'Choose a file': 'Wähle eine Datei', 'No file': 'Keine Datei', Description: 'Beschreibung', OK: 'OK', More: 'Mehr', Cancel: 'Abbrechen', File: 'Datei', URL: 'URL', 'Link text': 'Anzuzeigender Text', 'Add row to up': 'Zeile nach oben hinzufügen', 'Add row to down': 'Zeile nach unten hinzufügen', 'Add column to left': 'Spalte links hinzufügen', 'Add column to right': 'Spalte rechts hinzufügen', 'Remove row': 'Zeile entfernen', 'Remove column': 'Spalte entfernen', 'Align column to left': 'Links ausrichten', 'Align column to center': 'Zentrieren', 'Align column to right': 'Rechts ausrichten', 'Remove table': 'Tabelle entfernen', 'Would you like to paste as table?': 'Möchten Sie eine Tabelle einfügen?', 'Text color': 'Textfarbe', 'Auto scroll enabled': 'Autoscrollen aktiviert', 'Auto scroll disabled': 'Autoscrollen deaktiviert', 'Choose language': 'Sprache auswählen', }); ================================================ FILE: apps/editor/src/i18n/en-us.ts ================================================ /** * @fileoverview I18N for English * @author NHN Cloud FE Development Lab */ import Editor from '../editorCore'; Editor.setLanguage(['en', 'en-US'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Write', Preview: 'Preview', Headings: 'Headings', Paragraph: 'Paragraph', Bold: 'Bold', Italic: 'Italic', Strike: 'Strike', Code: 'Inline code', Line: 'Line', Blockquote: 'Blockquote', 'Unordered list': 'Unordered list', 'Ordered list': 'Ordered list', Task: 'Task', Indent: 'Indent', Outdent: 'Outdent', 'Insert link': 'Insert link', 'Insert CodeBlock': 'Insert codeBlock', 'Insert table': 'Insert table', 'Insert image': 'Insert image', Heading: 'Heading', 'Image URL': 'Image URL', 'Select image file': 'Select image file', 'Choose a file': 'Choose a file', 'No file': 'No file', Description: 'Description', OK: 'OK', More: 'More', Cancel: 'Cancel', File: 'File', URL: 'URL', 'Link text': 'Link text', 'Add row to up': 'Add row to up', 'Add row to down': 'Add row to down', 'Add column to left': 'Add column to left', 'Add column to right': 'Add column to right', 'Remove row': 'Remove row', 'Remove column': 'Remove column', 'Align column to left': 'Align column to left', 'Align column to center': 'Align column to center', 'Align column to right': 'Align column to right', 'Remove table': 'Remove table', 'Would you like to paste as table?': 'Would you like to paste as table?', 'Text color': 'Text color', 'Auto scroll enabled': 'Auto scroll enabled', 'Auto scroll disabled': 'Auto scroll disabled', 'Choose language': 'Choose language', }); ================================================ FILE: apps/editor/src/i18n/es-es.ts ================================================ /** * @fileoverview I18N for Spanish * @author Enrico Lamperti */ import Editor from '../editorCore'; Editor.setLanguage(['es', 'es-ES'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Escribir', Preview: 'Vista previa', Headings: 'Encabezados', Paragraph: 'Párrafo', Bold: 'Negrita', Italic: 'Itálica', Strike: 'Tachado', Code: 'Código', Line: 'Línea', Blockquote: 'Cita', 'Unordered list': 'Lista desordenada', 'Ordered list': 'Lista ordenada', Task: 'Tarea', Indent: 'Sangría', Outdent: 'Saliendo', 'Insert link': 'Insertar enlace', 'Insert CodeBlock': 'Insertar bloque de código', 'Insert table': 'Insertar tabla', 'Insert image': 'Insertar imagen', Heading: 'Encabezado', 'Image URL': 'URL de la imagen', 'Select image file': 'Seleccionar archivo de imagen', 'Choose a file': 'Escoge un archivo', 'No file': 'Ningún archivo', Description: 'Descripción', OK: 'Aceptar', More: 'Más', Cancel: 'Cancelar', File: 'Archivo', URL: 'URL', 'Link text': 'Texto del enlace', 'Add row to up': 'Agregar fila para subir', 'Add row to down': 'Agregar fila hacia abajo', 'Add column to left': 'Agregar columna a la izquierda', 'Add column to right': 'Agregar columna a la derecha', 'Remove row': 'Eliminar fila', 'Remove column': 'Eliminar columna', 'Align column to left': 'Alinear a la izquierda', 'Align column to center': 'Centrar', 'Align column to right': 'Alinear a la derecha', 'Remove table': 'Eliminar tabla', 'Would you like to paste as table?': '¿Desea pegar como tabla?', 'Text color': 'Color del texto', 'Auto scroll enabled': 'Desplazamiento automático habilitado', 'Auto scroll disabled': 'Desplazamiento automático deshabilitado', 'Choose language': 'Elegir idioma', }); ================================================ FILE: apps/editor/src/i18n/fi-fi.ts ================================================ /** * @fileoverview I18N for Finnish * @author Tomi Mynttinen */ import Editor from '../editorCore'; Editor.setLanguage(['fi', 'fi-FI'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Kirjoita', Preview: 'Esikatselu', Headings: 'Otsikot', Paragraph: 'Kappale', Bold: 'Lihavointi', Italic: 'Kursivointi', Strike: 'Yliviivaus', Code: 'Koodi', Line: 'Vaakaviiva', Blockquote: 'Lainaus', 'Unordered list': 'Luettelo', 'Ordered list': 'Numeroitu luettelo', Task: 'Tehtävä', Indent: 'Suurenna sisennystä', Outdent: 'Pienennä sisennystä', 'Insert link': 'Lisää linkki', 'Insert CodeBlock': 'Lisää koodia', 'Insert table': 'Lisää taulukko', 'Insert image': 'Lisää kuva', Heading: 'Otsikko', 'Image URL': 'Kuvan URL', 'Select image file': 'Valitse kuvatiedosto', 'Choose a file': 'Valitse tiedosto', 'No file': 'Ei tiedosto', Description: 'Kuvaus', OK: 'OK', More: 'Lisää', Cancel: 'Peruuta', File: 'Tiedosto', URL: 'URL', 'Link text': 'Linkkiteksti', 'Add row to up': 'Lisää rivi ylöspäin', 'Add row to down': 'Lisää rivi alaspäin', 'Add column to left': 'Lisää sarake vasemmalla', 'Add column to right': 'Lisää sarake oikealle', 'Remove row': 'Poista rivi', 'Remove column': 'Poista sarake', 'Align column to left': 'Tasaus vasemmalle', 'Align column to center': 'Keskitä', 'Align column to right': 'Tasaus oikealle', 'Remove table': 'Poista taulukko', 'Would you like to paste as table?': 'Haluatko liittää taulukkomuodossa?', 'Text color': 'Tekstin väri', 'Auto scroll enabled': 'Automaattinen skrollaus käytössä', 'Auto scroll disabled': 'Automaattinen skrollaus pois käytöstä', 'Choose language': 'Valitse kieli', }); ================================================ FILE: apps/editor/src/i18n/fr-fr.ts ================================================ /** * @fileoverview I18N for French * @author Stanislas Michalak */ import Editor from '../editorCore'; Editor.setLanguage(['fr', 'fr-FR'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Écrire', Preview: 'Aperçu', Headings: 'En-têtes', Paragraph: 'Paragraphe', Bold: 'Gras', Italic: 'Italique', Strike: 'Barré', Code: 'Code en ligne', Line: 'Ligne', Blockquote: 'Citation', 'Unordered list': 'Liste non-ordonnée', 'Ordered list': 'Liste ordonnée', Task: 'Tâche', Indent: 'Retrait', Outdent: 'Sortir', 'Insert link': 'Insérer un lien', 'Insert CodeBlock': 'Insérer un bloc de code', 'Insert table': 'Insérer un tableau', 'Insert image': 'Insérer une image', Heading: 'En-tête', 'Image URL': "URL de l'image", 'Select image file': 'Sélectionnez un fichier image', 'Choose a file': 'Choisissez un fichier', 'No file': 'Pas de fichier', Description: 'Description', OK: 'OK', More: 'de plus', Cancel: 'Annuler', File: 'Fichier', URL: 'URL', 'Link text': 'Texte du lien', 'Add row to up': 'Ajouter une ligne vers le haut', 'Add row to down': 'Ajouter une ligne vers le bas', 'Add column to left': 'Ajouter une colonne à gauche', 'Add column to right': 'Ajouter une colonne à droite', 'Remove row': 'Supprimer une ligne', 'Remove column': 'Supprimer une colonne', 'Align column to left': 'Aligner à gauche', 'Align column to center': 'Aligner au centre', 'Align column to right': 'Aligner à droite', 'Remove table': 'Supprimer le tableau', 'Would you like to paste as table?': 'Voulez-vous coller ce contenu en tant que tableau ?', 'Text color': 'Couleur du texte', 'Auto scroll enabled': 'Défilement automatique activé', 'Auto scroll disabled': 'Défilement automatique désactivé', 'Choose language': 'Choix de la langue', }); ================================================ FILE: apps/editor/src/i18n/gl-es.ts ================================================ /** * @fileoverview I18N for Spanish * @author Aida Vidal */ import Editor from '../editorCore'; Editor.setLanguage(['gl', 'gl-ES'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Escribir', Preview: 'Vista previa', Headings: 'Encabezados', Paragraph: 'Parágrafo', Bold: 'Negriña', Italic: 'Cursiva', Strike: 'Riscado', Code: 'Código', Line: 'Liña', Blockquote: 'Cita', 'Unordered list': 'Lista desordenada', 'Ordered list': 'Lista ordenada', Task: 'Tarefa', Indent: 'Sangría', Outdent: 'Anular sangría', 'Insert link': 'Inserir enlace', 'Insert CodeBlock': 'Inserir bloque de código', 'Insert table': 'Inserir táboa', 'Insert image': 'Inserir imaxe', Heading: 'Encabezado', 'Image URL': 'URL da imaxe', 'Select image file': 'Seleccionar arquivo da imaxe', 'Choose a file': 'Escoge un archivo', 'No file': 'Ningún archivo', Description: 'Descrición', OK: 'Aceptar', More: 'Máis', Cancel: 'Cancelar', File: 'Arquivo', URL: 'URL', 'Link text': 'Texto do enlace', 'Add row to up': 'Engade fila para arriba', 'Add row to down': 'Engade fila para abaixo', 'Add column to left': 'Engade columna á esquerda', 'Add column to right': 'Engade columna á dereita', 'Remove row': 'Eliminar fila', 'Remove column': 'Eliminar columna', 'Align column to left': 'Aliñar á esquerda', 'Align column to center': 'Centrar', 'Align column to right': 'Aliñar á dereita', 'Remove table': 'Eliminar táboa', 'Would you like to paste as table?': 'Desexa pegar como táboa?', 'Text color': 'Cor do texto', 'Auto scroll enabled': 'Desprazamento automático habilitado', 'Auto scroll disabled': 'Desprazamento automático deshabilitado', 'Choose language': 'Elixir idioma', }); ================================================ FILE: apps/editor/src/i18n/hr-hr.ts ================================================ /** * @fileoverview I18N for Croatian * @author Hrvoje A. */ import Editor from '../editorCore'; Editor.setLanguage(['hr', 'hr-HR'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Piši', Preview: 'Pregled', Headings: 'Naslovi', Paragraph: 'Paragraf', Bold: 'podebljano', Italic: 'kurziv', Strike: 'prcrtano', Code: 'Uklopljeni kôd', Line: 'Linija', Blockquote: 'Blok citat', 'Unordered list': 'Neporedana lista', 'Ordered list': 'Poredana lista', Task: 'Task', Indent: 'Povećaj uvlaku', Outdent: 'Smanji uvlaku', 'Insert link': 'Umetni link', 'Insert CodeBlock': 'Umetni blok kôda', 'Insert table': 'Umetni tablicu', 'Insert image': 'Umetni sliku', Heading: 'Naslov', 'Image URL': 'URL slike', 'Select image file': 'Odaberi slikovnu datoteku', 'Choose a file': 'Odaberite datoteka', 'No file': 'Nema datoteka', Description: 'Opis', OK: 'OK', More: 'Više', Cancel: 'Odustani', File: 'Datoteka', URL: 'URL', 'Link text': 'Tekst linka', 'Add row to up': 'Dodaj redak prema gore', 'Add row to down': 'Dodaj redak prema dolje', 'Add column to left': 'Dodaj stupac s lijeve strane', 'Add column to right': 'Dodajte stupac s desne strane', 'Remove row': 'Ukloni redak', 'Remove column': 'Remove stupac', 'Align column to left': 'Poravnaj lijevo', 'Align column to center': 'Poravnaj centrirano', 'Align column to right': 'Poravnaj desno', 'Remove table': 'Ukloni tablicu', 'Would you like to paste as table?': 'Zalite li zalijepiti kao tablicu?', 'Text color': 'Boja teksta', 'Auto scroll enabled': 'Omogući auto klizanje', 'Auto scroll disabled': 'Onemogući auto klizanje', 'Choose language': 'Odabir jezika', }); ================================================ FILE: apps/editor/src/i18n/i18n.ts ================================================ /** * @fileoverview Implements i18n * @author NHN Cloud FE Development Lab */ import extend from 'tui-code-snippet/object/extend'; import Map from '../utils/map'; const DEFAULT_CODE = 'en-US'; /** * Class I18n * @ignore */ class I18n { private code: string; private langs: Map>; constructor() { this.code = DEFAULT_CODE; this.langs = new Map(); } setCode(code?: string) { this.code = code || DEFAULT_CODE; } /** * Set language set * @param {string|string[]} codes locale code * @param {object} data language set */ setLanguage(codes: string | string[], data: Record) { codes = ([] as string[]).concat(codes); codes.forEach((code) => { if (!this.langs.has(code)) { this.langs.set(code, data); } else { const langData = this.langs.get(code)!; this.langs.set(code, extend(langData, data)); } }); } get(key: string, code?: string) { if (!code) { code = this.code; } let langSet = this.langs.get(code); if (!langSet) { langSet = this.langs.get(DEFAULT_CODE)!; } const text = langSet[key]; if (!text) { throw new Error(`There is no text key "${key}" in ${code}`); } return text; } } export { I18n }; export default new I18n(); ================================================ FILE: apps/editor/src/i18n/it-it.ts ================================================ /** * @fileoverview I18N for Italian * @author Massimo Redaelli */ import Editor from '../editorCore'; Editor.setLanguage(['it', 'it-IT'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Scrivere', Preview: 'Anteprima', Headings: 'Intestazioni', Paragraph: 'Paragrafo', Bold: 'Grassetto', Italic: 'Corsivo', Strike: 'Barrato', Code: 'Codice', Line: 'Linea', Blockquote: 'Blocco citazione', 'Unordered list': 'Lista puntata', 'Ordered list': 'Lista numerata', Task: 'Attività', Indent: 'Aggiungi indentazione', Outdent: 'Rimuovi indentazione', 'Insert link': 'Inserisci link', 'Insert CodeBlock': 'Inserisci blocco di codice', 'Insert table': 'Inserisci tabella', 'Insert image': 'Inserisci immagine', Heading: 'Intestazione', 'Image URL': 'URL immagine', 'Select image file': 'Seleziona file immagine', 'Choose a file': 'Scegli un file', 'No file': 'Nessun file', Description: 'Descrizione', OK: 'OK', More: 'Più', Cancel: 'Cancella', File: 'File', URL: 'URL', 'Link text': 'Testo del collegamento', 'Add row to up': 'Aggiungi riga in alto', 'Add row to down': 'Aggiungi riga in basso', 'Add column to left': 'Aggiungi colonna a sinistra', 'Add column to right': 'Aggiungi colonna a destra', 'Remove row': 'Rimuovi riga', 'Remove column': 'Rimuovi colonna', 'Align column to left': 'Allinea a sinistra', 'Align column to center': 'Allinea al centro', 'Align column to right': 'Allinea a destra', 'Remove table': 'Rimuovi tabella', 'Would you like to paste as table?': 'Desideri incollare sotto forma di tabella?', 'Text color': 'Colore del testo', 'Auto scroll enabled': 'Scrolling automatico abilitato', 'Auto scroll disabled': 'Scrolling automatico disabilitato', 'Choose language': 'Scegli la lingua', }); ================================================ FILE: apps/editor/src/i18n/ja-jp.ts ================================================ /** * @fileoverview I18N for Japanese * @author NHN Cloud FE Development Lab */ import Editor from '../editorCore'; Editor.setLanguage(['ja', 'ja-JP'], { Markdown: 'マークダウン', WYSIWYG: 'WYSIWYG', Write: '編集する', Preview: 'プレビュー', Headings: '見出し', Paragraph: '本文', Bold: '太字', Italic: 'イタリック', Strike: 'ストライク', Code: 'インラインコード', Line: 'ライン', Blockquote: '引用', 'Unordered list': '番号なしリスト', 'Ordered list': '順序付きリスト', Task: 'タスク', Indent: 'インデント', Outdent: 'アウトデント', 'Insert link': 'リンク挿入', 'Insert CodeBlock': 'コードブロック挿入', 'Insert table': 'テーブル挿入', 'Insert image': '画像挿入', Heading: '見出し', 'Image URL': 'イメージURL', 'Select image file': '画像ファイル選択', 'Choose a file': 'ファイルの選択', 'No file': 'ファイルがない', Description: 'ディスクリプション ', OK: 'はい', More: 'もっと', Cancel: 'キャンセル', File: 'ファイル', URL: 'URL', 'Link text': 'リンクテキスト', 'Add row to up': '行を上に追加', 'Add row to down': '下に行を追加', 'Add column to left': '左側に列を追加', 'Add column to right': '右側に列を追加', 'Remove row': '行削除', 'Remove column': '列削除', 'Align column to left': '左揃え', 'Align column to center': '中央揃え', 'Align column to right': '右揃え', 'Remove table': 'テーブル削除', 'Would you like to paste as table?': 'テーブルを貼り付けますか?', 'Text color': '文字色相', 'Auto scroll enabled': '自動スクロールが有効', 'Auto scroll disabled': '自動スクロールを無効に', 'Choose language': '言語選択', }); ================================================ FILE: apps/editor/src/i18n/ko-kr.ts ================================================ /** * @fileoverview I18N for Korean * @author NHN Cloud FE Development Lab */ import Editor from '../editorCore'; Editor.setLanguage(['ko', 'ko-KR'], { Markdown: '마크다운', WYSIWYG: '위지윅', Write: '편집하기', Preview: '미리보기', Headings: '제목크기', Paragraph: '본문', Bold: '굵게', Italic: '기울임꼴', Strike: '취소선', Code: '인라인 코드', Line: '문단나눔', Blockquote: '인용구', 'Unordered list': '글머리 기호', 'Ordered list': '번호 매기기', Task: '체크박스', Indent: '들여쓰기', Outdent: '내어쓰기', 'Insert link': '링크 삽입', 'Insert CodeBlock': '코드블럭 삽입', 'Insert table': '표 삽입', 'Insert image': '이미지 삽입', Heading: '제목', 'Image URL': '이미지 주소', 'Select image file': '이미지 파일을 선택하세요.', 'Choose a file': '파일 선택', 'No file': '선택된 파일 없음', Description: '설명', OK: '확인', More: '더 보기', Cancel: '취소', File: '파일', URL: '주소', 'Link text': '링크 텍스트', 'Add row to up': '위에 행 추가', 'Add row to down': '아래에 행 추가', 'Add column to left': '왼쪽에 열 추가', 'Add column to right': '오른쪽에 열 추가', 'Remove row': '행 삭제', 'Remove column': '열 삭제', 'Align column to left': '열 왼쪽 정렬', 'Align column to center': '열 가운데 정렬', 'Align column to right': '열 오른쪽 정렬', 'Remove table': '표 삭제', 'Would you like to paste as table?': '표형태로 붙여 넣겠습니까?', 'Text color': '글자 색상', 'Auto scroll enabled': '자동 스크롤 켜짐', 'Auto scroll disabled': '자동 스크롤 꺼짐', 'Choose language': '언어 선택', }); ================================================ FILE: apps/editor/src/i18n/nb-no.ts ================================================ /** * @fileoverview I18N for Norwegian * @author Anton Reytarovskiy */ import Editor from '../editorCore'; Editor.setLanguage(['nb', 'nb-NO'], { Markdown: 'Funksjonaliteter', WYSIWYG: 'WYSIWYG', Write: 'Skriv', Preview: 'Forhåndsvisning', Headings: 'Overskrift', Paragraph: 'Paragraf', Bold: 'Fet skrift', Italic: 'Italic', Strike: 'Strike', Code: 'Kode', Line: 'Linje', Blockquote: 'Blokksitat', 'Unordered list': 'Usortert liste', 'Ordered list': 'Sortert liste', Task: 'Task', Indent: 'Indent', Outdent: 'Outdent', 'Insert link': 'Sett inn lenke', 'Insert CodeBlock': 'Sett inn CodeStreng', 'Insert table': 'Sett inn diagram', 'Insert image': 'Sett inn bilde', Heading: 'Overskrift', 'Image URL': 'BildeURL', 'Select image file': 'Velg bildefil', 'Choose a file': 'Velg en fil', 'No file': 'Ingen fil', Description: 'Beskrivelse', OK: 'OK', More: 'Mer', Cancel: 'Angre', File: 'Fil', URL: 'URL', 'Link text': 'Lenketekst', 'Add row to up': 'Legg rad til opp', 'Add row to down': 'Legg rad til ned', 'Add column to left': 'Legg til kolonne til venstre', 'Add column to right': 'Legg til kolonne til høyre', 'Remove row': 'Fjern rad', 'Remove column': 'Fjern kolonne', 'Align column to left': 'Venstreorienter', 'Align column to center': 'Senterorienter', 'Align column to right': 'Høyreorienter', 'Remove table': 'Fjern diagram', 'Would you like to paste as table?': 'Ønsker du å lime inn som et diagram?', 'Text color': 'Tekstfarge', 'Auto scroll enabled': 'Auto-scroll aktivert', 'Auto scroll disabled': 'Auto-scroll deaktivert', 'Choose language': 'Velg språl', }); ================================================ FILE: apps/editor/src/i18n/nl-nl.ts ================================================ /** * @fileoverview I18N for Dutch * @author NHN Cloud FE Development Lab */ import Editor from '../editorCore'; Editor.setLanguage(['nl', 'nl-NL'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Opslaan', Preview: 'Voorbeeld', Headings: 'Koppen', Paragraph: 'Alinea', Bold: 'Vet', Italic: 'Cursief', Strike: 'Doorhalen', Code: 'Inline code', Line: 'Regel', Blockquote: 'Citaatblok', 'Unordered list': 'Opsomming', 'Ordered list': 'Genummerde opsomming', Task: 'Taak', Indent: 'Niveau verhogen', Outdent: 'Niveau verlagen', 'Insert link': 'Link invoegen', 'Insert CodeBlock': 'Codeblok toevoegen', 'Insert table': 'Tabel invoegen', 'Insert image': 'Afbeelding invoegen', Heading: 'Kop', 'Image URL': 'Afbeelding URL', 'Select image file': 'Selecteer een afbeelding', 'Choose a file': 'Kies een bestand', 'No file': 'Geen bestand', Description: 'Omschrijving', OK: 'OK', More: 'Meer', Cancel: 'Annuleren', File: 'Bestand', URL: 'URL', 'Link text': 'Link tekst', 'Add row to up': 'Voeg rij toe aan omhoog', 'Add row to down': 'Rij naar beneden toevoegen', 'Add column to left': 'Voeg kolom aan de linkerkant toe', 'Add column to right': 'Voeg een kolom aan de rechterkant toe', 'Remove row': 'Rij verwijderen', 'Remove column': 'Kolom verwijderen', 'Align column to left': 'Links uitlijnen', 'Align column to center': 'Centreren', 'Align column to right': 'Rechts uitlijnen', 'Remove table': 'Verwijder tabel', 'Would you like to paste as table?': 'Wil je dit als tabel plakken?', 'Text color': 'Tekstkleur', 'Auto scroll enabled': 'Autoscroll ingeschakeld', 'Auto scroll disabled': 'Autoscroll uitgeschakeld', 'Choose language': 'Kies een taal', }); ================================================ FILE: apps/editor/src/i18n/pl-pl.ts ================================================ /** * @fileoverview I18N for Polish * @author Marcin Mikołajczak */ import Editor from '../editorCore'; Editor.setLanguage(['pl', 'pl-PL'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Napisz', Preview: 'Podgląd', Headings: 'Nagłówki', Paragraph: 'Akapit', Bold: 'Pogrubienie', Italic: 'Kursywa', Strike: 'Przekreślenie', Code: 'Fragment kodu', Line: 'Linia', Blockquote: 'Cytat', 'Unordered list': 'Lista nieuporządkowana', 'Ordered list': 'Lista uporządkowana', Task: 'Zadanie', Indent: 'Utwórz wcięcie', Outdent: 'Usuń wcięcie', 'Insert link': 'Umieść odnośnik', 'Insert CodeBlock': 'Umieść blok kodu', 'Insert table': 'Umieść tabelę', 'Insert image': 'Umieść obraz', Heading: 'Nagłówek', 'Image URL': 'Adres URL obrazu', 'Select image file': 'Wybierz plik obrazu', 'Choose a file': 'Wybierz plik', 'No file': 'Brak plik', Description: 'Opis', OK: 'OK', More: 'Więcej', Cancel: 'Anuluj', File: 'Plik', URL: 'URL', 'Link text': 'Tekst odnośnika', 'Add row to up': 'Dodaj wiersz do góry', 'Add row to down': 'Dodaj wiersz w dół', 'Add column to left': 'Dodaj kolumnę po lewej stronie', 'Add column to right': 'Dodaj kolumnę po prawej stronie', 'Remove row': 'Usuń rząd', 'Remove column': 'Usuń kolumnę', 'Align column to left': 'Wyrównaj do lewej', 'Align column to center': 'Wyśrodkuj', 'Align column to right': 'Wyrównaj do prawej', 'Remove table': 'Usuń tabelę', 'Would you like to paste as table?': 'Czy chcesz wkleić tekst jako tabelę?', 'Text color': 'Kolor tekstu', 'Auto scroll enabled': 'Włączono automatyczne przewijanie', 'Auto scroll disabled': 'Wyłączono automatyczne przewijanie', 'Choose language': 'Wybierz język', }); ================================================ FILE: apps/editor/src/i18n/pt-br.ts ================================================ /** * @fileoverview I18N for Português * @author Nícolas Huber */ import Editor from '../editorCore'; Editor.setLanguage(['pt', 'pt-BR'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Escrever', Preview: 'Pré-visualizar', Headings: 'Cabeçalhos', Paragraph: 'Parágrafo', Bold: 'Negrito', Italic: 'Itálico', Strike: 'Traçado', Code: 'Código', Line: 'Linha', Blockquote: 'Bloco de citação', 'Unordered list': 'Lista não ordenada', 'Ordered list': 'Lista ordenada', Task: 'Tarefa', Indent: 'Recuo à esquerda', Outdent: 'Recuo à direita', 'Insert link': 'Inserir link', 'Insert CodeBlock': 'Inserir bloco de código', 'Insert table': 'Inserir tabela', 'Insert image': 'Inserir imagem', Heading: 'Título', 'Image URL': 'URL da imagem', 'Select image file': 'Selecione um arquivo de imagem', 'Choose a file': 'Escolha um arquivo', 'No file': 'Nenhum arquivo', Description: 'Descrição', OK: 'OK', More: 'Mais', Cancel: 'Cancelar', File: 'Arquivo', URL: 'URL', 'Link text': 'Link de texto', 'Add row to up': 'Adicionar linha para cima', 'Add row to down': 'Adicionar linha para baixo', 'Add column to left': 'Adicionar coluna à esquerda', 'Add column to right': 'Adicionar coluna à direita', 'Remove row': 'Remover linha', 'Remove column': 'Remover coluna', 'Align column to left': 'Alinhar à esquerda', 'Align column to center': 'Alinhar ao centro', 'Align column to right': 'Alinhar à direita', 'Remove table': 'Remover tabela', 'Would you like to paste as table?': 'Você gostaria de colar como mesa?', 'Text color': 'Cor do texto', 'Auto scroll enabled': 'Rolagem automática habilitada', 'Auto scroll disabled': 'Rolagem automática desabilitada', 'Choose language': 'Escolher linguagem', }); ================================================ FILE: apps/editor/src/i18n/ru-ru.ts ================================================ /** * @fileoverview I18N for Russian * @author Stepan Samko * @author Veaceslav Grimalschi */ import Editor from '../editorCore'; Editor.setLanguage(['ru', 'ru-RU'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Редактор', Preview: 'Просмотр', Headings: 'Заголовки', Paragraph: 'Абзац', Bold: 'Жирный', Italic: 'Курсив', Strike: 'Зачеркнутый', Code: 'Код', Line: 'Линия', Blockquote: 'Цитата', 'Unordered list': 'Неупорядоченный список', 'Ordered list': 'Упорядоченный список', Task: 'Галочка', Indent: 'Увеличить отступ', Outdent: 'Уменьшить отступ', 'Insert link': 'Вставить ссылку', 'Insert CodeBlock': 'Вставить блок кода', 'Insert table': 'Вставить таблицу', 'Insert image': 'Вставить изображение', Heading: 'Заголовок', 'Image URL': 'URL изображения', 'Select image file': 'Выбрать файл изображения', 'Choose a file': 'Выбрать', 'No file': 'Нет файла', Description: 'Описание', OK: 'Хорошо', More: 'Еще', Cancel: 'Отмена', File: 'Файл', URL: 'URL', 'Link text': 'Текст ссылки', 'Add row to up': 'Добавить строку вверх', 'Add row to down': 'Добавить строку вниз', 'Add column to left': 'Добавить столбец слева', 'Add column to right': 'Добавить столбец справа', 'Remove row': 'Удалить ряд', 'Remove column': 'Удалить столбец', 'Align column to left': 'Выровнять по левому краю', 'Align column to center': 'Выровнять по центру', 'Align column to right': 'Выровнять по правому краю', 'Remove table': 'Удалить таблицу', 'Would you like to paste as table?': 'Вы хотите вставить в виде таблицы?', 'Text color': 'Цвет текста', 'Auto scroll enabled': 'Автопрокрутка включена', 'Auto scroll disabled': 'Автопрокрутка отключена', 'Choose language': 'Выбрать язык', }); ================================================ FILE: apps/editor/src/i18n/sv-se.ts ================================================ /** * @fileoverview I18N for Swedish * @author Magnus Aspling */ import Editor from '../editorCore'; Editor.setLanguage(['sv', 'sv-SE'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Skriv', Preview: 'Förhandsgranska', Headings: 'Överskrifter', Paragraph: 'Paragraf', Bold: 'Fet', Italic: 'Kursiv', Strike: 'Genomstruken', Code: 'Kodrad', Line: 'Linje', Blockquote: 'Citatblock', 'Unordered list': 'Punktlista', 'Ordered list': 'Numrerad lista', Task: 'Att göra', Indent: 'Öka indrag', Outdent: 'Minska indrag', 'Insert link': 'Infoga länk', 'Insert CodeBlock': 'Infoga kodblock', 'Insert table': 'Infoga tabell', 'Insert image': 'Infoga bild', Heading: 'Överskrift', 'Image URL': 'Bildadress', 'Select image file': 'Välj en bildfil', 'Choose a file': 'Välj en fil', 'No file': 'Ingen fil', Description: 'Beskrivning', OK: 'OK', More: 'Mer', Cancel: 'Avbryt', File: 'Fil', URL: 'Adress', 'Link text': 'Länktext', 'Add row to up': 'Lägg till rad till upp', 'Add row to down': 'Lägg till rad till ner', 'Add column to left': 'Lägg till kolumn till vänster', 'Add column to right': 'Lägg till kolumn till höger', 'Remove row': 'Radera rad', 'Remove column': 'Radera kolumn', 'Align column to left': 'Vänsterjustera', 'Align column to center': 'Centrera', 'Align column to right': 'Högerjustera', 'Remove table': 'Radera tabell', 'Would you like to paste as table?': 'Vill du klistra in som en tabell?', 'Text color': 'Textfärg', 'Auto scroll enabled': 'Automatisk scroll aktiverad', 'Auto scroll disabled': 'Automatisk scroll inaktiverad', 'Choose language': 'Välj språk', }); ================================================ FILE: apps/editor/src/i18n/tr-tr.ts ================================================ /** * @fileoverview I18N for Turkish * @author Mesut Gölcük */ import Editor from '../editorCore'; Editor.setLanguage(['tr', 'tr-TR'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Düzenle', Preview: 'Ön izleme', Headings: 'Başlıklar', Paragraph: 'Paragraf', Bold: 'Kalın', Italic: 'İtalik', Strike: 'Altı çizgili', Code: 'Satır içi kod', Line: 'Çizgi', Blockquote: 'Alıntı', 'Unordered list': 'Sıralanmamış liste', 'Ordered list': 'Sıralı liste', Task: 'Görev kutusu', Indent: 'Girintiyi arttır', Outdent: 'Girintiyi azalt', 'Insert link': 'Bağlantı ekle', 'Insert CodeBlock': 'Kod bloku ekle', 'Insert table': 'Tablo ekle', 'Insert image': 'İmaj ekle', Heading: 'Başlık', 'Image URL': 'İmaj URL', 'Select image file': 'İmaj dosyası seç', 'Choose a file': 'Bir dosya seçin', 'No file': 'Dosya yok', Description: 'Açıklama', OK: 'Onay', More: 'Daha Fazla', Cancel: 'İptal', File: 'Dosya', URL: 'URL', 'Link text': 'Bağlantı yazısı', 'Add row to up': 'Yukarı satır ekle', 'Add row to down': 'Aşağı satır ekle', 'Add column to left': 'Sola sütun ekleyin', 'Add column to right': 'Sağa sütun ekle', 'Remove row': 'Satır sil', 'Remove column': 'Sütun sil', 'Align column to left': 'Sola hizala', 'Align column to center': 'Merkeze hizala', 'Align column to right': 'Sağa hizala', 'Remove table': 'Tabloyu kaldır', 'Would you like to paste as table?': 'Tablo olarak yapıştırmak ister misiniz?', 'Text color': 'Metin rengi', 'Auto scroll enabled': 'Otomatik kaydırma açık', 'Auto scroll disabled': 'Otomatik kaydırma kapalı', 'Choose language': 'Dil seçiniz', }); ================================================ FILE: apps/editor/src/i18n/uk-ua.ts ================================================ /** * @fileoverview I18N for Ukrainian * @author Nikolya */ import Editor from '../editorCore'; Editor.setLanguage(['uk', 'uk-UA'], { Markdown: 'Markdown', WYSIWYG: 'WYSIWYG', Write: 'Написати', Preview: 'Попередній перегляд', Headings: 'Заголовки', Paragraph: 'Абзац', Bold: 'Жирний', Italic: 'Курсив', Strike: 'Закреслений', Code: 'Вбудований код', Line: 'Лінія', Blockquote: 'Блок цитування', 'Unordered list': 'Невпорядкований список', 'Ordered list': 'Упорядкований список', Task: 'Завдання', Indent: 'відступ', Outdent: 'застарілий', 'Insert link': 'Вставити посилання', 'Insert CodeBlock': 'Вставити код', 'Insert table': 'Вставити таблицю', 'Insert image': 'Вставити зображення', Heading: 'Заголовок', 'Image URL': 'URL зображення', 'Select image file': 'Вибрати файл зображення', 'Choose a file': 'Виберіть файл', 'No file': 'Немає файлу', Description: 'Опис', OK: 'OK', More: 'ще', Cancel: 'Скасувати', File: 'Файл', URL: 'URL', 'Link text': 'Текст посилання', 'Add row to up': 'Додати рядок вгору', 'Add row to down': 'Додати рядок вниз', 'Add column to left': 'Додайте стовпець зліва', 'Add column to right': 'Додайте стовпець праворуч', 'Remove row': 'Видалити ряд', 'Remove column': 'Видалити стовпчик', 'Align column to left': 'Вирівняти по лівому краю', 'Align column to center': 'Вирівняти по центру', 'Align column to right': 'Вирівняти по правому краю', 'Remove table': 'Видалити таблицю', 'Would you like to paste as table?': 'Ви хочете вставити у вигляді таблиці?', 'Text color': 'Колір тексту', 'Auto scroll enabled': 'Автоматична прокрутка включена', 'Auto scroll disabled': 'Автоматична прокрутка відключена', 'Choose language': 'Вибрати мову', }); ================================================ FILE: apps/editor/src/i18n/zh-cn.ts ================================================ /** * @fileoverview I18N for Chinese * @author NHN Cloud FE Development Lab */ import Editor from '../editorCore'; Editor.setLanguage('zh-CN', { Markdown: 'Markdown', WYSIWYG: '所见即所得', Write: '编辑', Preview: '预览', Headings: '标题', Paragraph: '文本', Bold: '加粗', Italic: '斜体字', Strike: '删除线', Code: '内嵌代码', Line: '水平线', Blockquote: '引用块', 'Unordered list': '无序列表', 'Ordered list': '有序列表', Task: '任务', Indent: '缩进', Outdent: '减少缩进', 'Insert link': '插入链接', 'Insert CodeBlock': '插入代码块', 'Insert table': '插入表格', 'Insert image': '插入图片', Heading: '标题', 'Image URL': '图片网址', 'Select image file': '选择图片文件', 'Choose a file': '选择一个文件', 'No file': '没有文件', Description: '说明', OK: '确认', More: '更多', Cancel: '取消', File: '文件', URL: 'URL', 'Link text': '链接文本', 'Add row to up': '向上添加行', 'Add row to down': '在下方添加行', 'Add column to left': '在左侧添加列', 'Add column to right': '在右侧添加列', 'Remove row': '删除行', 'Remove column': '删除列', 'Align column to left': '左对齐', 'Align column to center': '居中对齐', 'Align column to right': '右对齐', 'Remove table': '删除表格', 'Would you like to paste as table?': '需要粘贴为表格吗?', 'Text color': '文字颜色', 'Auto scroll enabled': '自动滚动已启用', 'Auto scroll disabled': '自动滚动已禁用', 'Choose language': '选择语言', }); ================================================ FILE: apps/editor/src/i18n/zh-tw.ts ================================================ /** * @fileoverview I18N for Traditional Chinese * @author Tzu-Ray Su */ import Editor from '../editorCore'; Editor.setLanguage('zh-TW', { Markdown: 'Markdown', WYSIWYG: '所見即所得', Write: '編輯', Preview: '預覽', Headings: '標題', Paragraph: '內文', Bold: '粗體', Italic: '斜體', Strike: '刪除線', Code: '內嵌程式碼', Line: '分隔線', Blockquote: '引言', 'Unordered list': '項目符號清單', 'Ordered list': '編號清單', Task: '核取方塊清單', Indent: '增加縮排', Outdent: '減少縮排', 'Insert link': '插入超連結', 'Insert CodeBlock': '插入程式碼區塊', 'Insert table': '插入表格', 'Insert image': '插入圖片', Heading: '標題', 'Image URL': '圖片網址', 'Select image file': '選擇圖片檔案', 'Choose a file': '選擇一個文件', 'No file': '沒有文件', Description: '描述', OK: '確認', More: '更多', Cancel: '取消', File: '檔案', URL: 'URL', 'Link text': '超連結文字', 'Add row to up': '向上添加行', 'Add row to down': '在下方添加行', 'Add column to left': '在左側添加列', 'Add column to right': '在右側添加列', 'Remove row': '刪除行', 'Remove column': '刪除列', 'Align column to left': '靠左對齊', 'Align column to center': '置中', 'Align column to right': '靠右對齊', 'Remove table': '刪除表格', 'Would you like to paste as table?': '您要以表格貼上嗎?', 'Text color': '文字顏色', 'Auto scroll enabled': '已啟用自動滾動', 'Auto scroll disabled': '已停用自動滾動', 'Choose language': '選擇語言', }); ================================================ FILE: apps/editor/src/index.ts ================================================ import EditorCore from './editorCore'; import Editor from './editor'; import 'prosemirror-view/style/prosemirror.css'; import '@/css/editor.css'; import '@/css/contents.css'; import '@/css/preview-highlighting.css'; import '@/css/md-syntax-highlighting.css'; import './i18n/en-us'; export default Editor; export { Editor, EditorCore }; ================================================ FILE: apps/editor/src/indexEditorOnlyStyle.ts ================================================ import '@/css/editor.css'; import '@/css/preview-highlighting.css'; import '@/css/md-syntax-highlighting.css'; ================================================ FILE: apps/editor/src/indexViewer.ts ================================================ import Viewer from './viewer'; import '@/css/contents.css'; export default Viewer; ================================================ FILE: apps/editor/src/markdown/helper/list.ts ================================================ import { ProsemirrorNode, Schema } from 'prosemirror-model'; import { ListItemMdNode, MdNode, ToastMark } from '@toast-ui/toastmark'; import { findClosestNode, isListNode, isOrderedListNode } from '@/utils/markdown'; import { createTextNode } from '@/helper/manipulation'; import { getTextByMdLine } from './query'; export interface ToListContext { mdNode: T; line: number; toastMark: ToastMark; doc: ProsemirrorNode; startLine: number; } export type ExtendListContext = Omit; export interface ChangedListInfo { line: number; text: string; } interface ToListResult { changedResults: ChangedListInfo[]; firstIndex?: number; lastIndex?: number; } type ExtendedResult = { listSyntax: string; changedResults?: ChangedListInfo[]; lastIndex?: number; }; type ListType = 'bullet' | 'ordered'; type ListToListFn = (context: ToListContext) => ToListResult; type NodeToListFn = (context: ToListContext) => ToListResult; type ExtendListFn = (context: ExtendListContext) => ExtendedResult; interface ItemInfo { line: number; depth: number; mdNode: ListItemMdNode; } interface ListToList { bullet: ListToListFn; ordered: ListToListFn; task: ListToListFn; } interface NodeToList { bullet: NodeToListFn; ordered: NodeToListFn; task: NodeToListFn; } interface ExtendList { bullet: ExtendListFn; ordered: ExtendListFn; } export const reList = /(^\s*)([-*+] |[\d]+\. )/; export const reOrderedList = /(^\s*)([\d])+\.( \[[ xX]])? /; export const reOrderedListGroup = /^(\s*)((\d+)([.)]\s(?:\[(?:x|\s)\]\s)?))(.*)/; export const reCanBeTaskList = /(^\s*)([-*+]|[\d]+\.)( \[[ xX]])? /; const reBulletListGroup = /^(\s*)([-*+]+(\s(?:\[(?:x|\s)\]\s)?))(.*)/; const reTaskList = /(^\s*)([-*+] |[\d]+\. )(\[[ xX]] )/; const reBulletTaskList = /(^\s*)([-*+])( \[[ xX]]) /; export function getListType(text: string): ListType { return reOrderedList.test(text) ? 'ordered' : 'bullet'; } function getListDepth(mdNode: MdNode) { let depth = 0; while (mdNode && mdNode.type !== 'document') { if (mdNode.type === 'list') { depth += 1; } mdNode = mdNode.parent!; } return depth; } function findSameDepthList( toastMark: ToastMark, currentLine: number, depth: number, backward: boolean ): ItemInfo[] { const lineTexts = toastMark.getLineTexts(); const lineLen = lineTexts.length; const result = []; let line = currentLine; while (backward ? line < lineLen : line > 1) { line = backward ? line + 1 : line - 1; const mdNode = toastMark.findFirstNodeAtLine(line) as ListItemMdNode; const currentListDepth = getListDepth(mdNode); if (currentListDepth === depth) { result.push({ line, depth, mdNode }); } else if (currentListDepth < depth) { break; } } return result; } function getSameDepthItems({ toastMark, mdNode, line }: ToListContext) { const depth = getListDepth(mdNode); const forwardList = findSameDepthList(toastMark, line, depth, false).reverse(); const backwardList = findSameDepthList(toastMark, line, depth, true); return forwardList.concat([{ line, depth, mdNode }]).concat(backwardList); } function textToBullet(text: string) { if (!reList.test(text)) { return `* ${text}`; } const type = getListType(text); if (type === 'bullet' && reCanBeTaskList.test(text)) { text = text.replace(reBulletTaskList, '$1$2 '); } else if (type === 'ordered') { text = text.replace(reOrderedList, '$1* '); } return text; } function textToOrdered(text: string, ordinalNum: number) { if (!reList.test(text)) { return `${ordinalNum}. ${text}`; } const type = getListType(text); if (type === 'bullet' || (type === 'ordered' && reCanBeTaskList.test(text))) { text = text.replace(reCanBeTaskList, `$1${ordinalNum}. `); } else if (type === 'ordered') { // eslint-disable-next-line prefer-destructuring const start = reOrderedListGroup.exec(text)![3]; if (Number(start) !== ordinalNum) { text = text.replace(reOrderedList, `$1${ordinalNum}. `); } } return text; } function getChangedInfo( doc: ProsemirrorNode, sameDepthItems: ItemInfo[], type: ListType, start = 0 ): ToListResult { let firstIndex = Number.MAX_VALUE; let lastIndex = 0; const changedResults = sameDepthItems.map(({ line }, index) => { firstIndex = Math.min(line - 1, firstIndex); lastIndex = Math.max(line - 1, lastIndex); let text = getTextByMdLine(doc, line); text = type === 'bullet' ? textToBullet(text) : textToOrdered(text, index + 1 + start); return { text, line }; }); return { changedResults, firstIndex, lastIndex }; } function getBulletOrOrdered(type: ListType, context: ToListContext) { const sameDepthListInfo = getSameDepthItems(context); return getChangedInfo(context.doc, sameDepthListInfo, type); } export const otherListToList: ListToList = { bullet(context) { return getBulletOrOrdered('bullet', context); }, ordered(context) { return getBulletOrOrdered('ordered', context); }, task({ mdNode, doc, line }) { let text = getTextByMdLine(doc, line); if (mdNode.listData.task) { text = text.replace(reTaskList, '$1$2'); } else if (isListNode(mdNode)) { text = text.replace(reList, '$1$2[ ] '); } return { changedResults: [{ text, line }] }; }, }; export const otherNodeToList: NodeToList = { bullet({ doc, line }) { const lineText = getTextByMdLine(doc, line); const changedResults = [{ text: `* ${lineText}`, line }]; return { changedResults }; }, ordered({ toastMark, doc, line, startLine }) { const lineText = getTextByMdLine(doc, line); let firstOrderedListNum = 1; let firstOrderedListLine = startLine; let skipped = 0; for (let i = startLine - 1; i > 0; i -= 1) { const mdNode = toastMark.findFirstNodeAtLine(i)!; const text = getTextByMdLine(doc, i); const canBeListNode = text && !!findClosestNode(mdNode, (targetNode) => isListNode(targetNode)); const searchResult = reOrderedListGroup.exec(getTextByMdLine(doc, i)); if (!searchResult && !canBeListNode) { break; } if (!searchResult && canBeListNode) { skipped += 1; continue; } const [, indent, , start] = searchResult!; // basis on one depth list if (!indent) { firstOrderedListNum = Number(start); firstOrderedListLine = i; break; } } const ordinalNum = firstOrderedListNum + line - firstOrderedListLine - skipped; const changedResults = [{ text: `${ordinalNum}. ${lineText}`, line }]; return { changedResults }; }, task({ doc, line }) { const lineText = getTextByMdLine(doc, line); const changedResults = [{ text: `* [ ] ${lineText}`, line }]; return { changedResults }; }, }; export const extendList: ExtendList = { bullet({ line, doc }: ExtendListContext) { const lineText = getTextByMdLine(doc, line); const [, indent, delimiter] = reBulletListGroup.exec(lineText)!; return { listSyntax: `${indent}${delimiter}` }; }, ordered({ toastMark, line, mdNode, doc }: ExtendListContext) { const depth = getListDepth(mdNode); const lineText = getTextByMdLine(doc, line); const [, indent, , start, delimiter] = reOrderedListGroup.exec(lineText)!; const ordinalNum = Number(start) + 1; const listSyntax = `${indent}${ordinalNum}${delimiter}`; const backwardList = findSameDepthList(toastMark, line, depth, true); const filteredList = backwardList.filter((info) => { const searchResult = reOrderedListGroup.exec(getTextByMdLine(doc, info.line)); return ( searchResult && searchResult[1].length === indent.length && !!findClosestNode(info.mdNode, (targetNode) => isOrderedListNode(targetNode)) ); }); return { listSyntax, ...getChangedInfo(doc, filteredList, 'ordered', ordinalNum) }; }, }; export function getReorderedListInfo( doc: ProsemirrorNode, schema: Schema, line: number, ordinalNum: number, prevIndentLength: number ) { let nodes: ProsemirrorNode[] = []; let lineText = getTextByMdLine(doc, line); let searchResult = reOrderedListGroup.exec(lineText); while (searchResult) { const [, indent, , , delimiter, text] = searchResult; const indentLength = indent.length; if (indentLength === prevIndentLength) { nodes.push(createTextNode(schema, `${indent}${ordinalNum}${delimiter}${text}`)); ordinalNum += 1; line += 1; } else if (indentLength > prevIndentLength) { const nestedListInfo = getReorderedListInfo(doc, schema, line, 1, indentLength); line = nestedListInfo.line; nodes = nodes.concat(nestedListInfo.nodes); } if (indentLength < prevIndentLength || line > doc.childCount) { break; } lineText = getTextByMdLine(doc, line); searchResult = reOrderedListGroup.exec(lineText); } return { nodes, line }; } ================================================ FILE: apps/editor/src/markdown/helper/mdCommand.ts ================================================ import isFunction from 'tui-code-snippet/type/isFunction'; import { EditorCommand } from '@t/spec'; import { createTextSelection } from '@/helper/manipulation'; import { resolveSelectionPos } from './pos'; type ConditionFn = (text: string) => boolean; type Condition = RegExp | ConditionFn; export function toggleMark(condition: Condition, syntax: string): EditorCommand { return () => ({ tr, selection }, dispatch) => { const conditionFn: ConditionFn = !isFunction(condition) ? (text) => condition.test(text) : condition; const syntaxLen = syntax.length; const { doc } = tr; const [from, to] = resolveSelectionPos(selection); const prevPos = Math.max(from - syntaxLen, 1); const nextPos = Math.min(to + syntaxLen, doc.content.size - 1); const slice = selection.content(); let textContent = slice.content.textBetween(0, slice.content.size, '\n'); const prevText = doc.textBetween(prevPos, from, '\n'); const nextText = doc.textBetween(to, nextPos, '\n'); textContent = `${prevText}${textContent}${nextText}`; if (prevText && nextText && conditionFn(textContent)) { tr.delete(nextPos - syntaxLen, nextPos).delete(prevPos, prevPos + syntaxLen); } else { tr.insertText(syntax, to).insertText(syntax, from); const newSelection = selection.empty ? createTextSelection(tr, from + syntaxLen) : createTextSelection(tr, from + syntaxLen, to + syntaxLen); tr.setSelection(newSelection); } dispatch!(tr); return true; }; } ================================================ FILE: apps/editor/src/markdown/helper/pos.ts ================================================ import { AllSelection, Selection } from 'prosemirror-state'; import { ProsemirrorNode, ResolvedPos } from 'prosemirror-model'; import { Sourcepos, MdPos } from '@toast-ui/toastmark'; import { isWidgetNode } from '@/widget/widgetNode'; export function resolveSelectionPos(selection: Selection) { const { from, to } = selection; if (selection instanceof AllSelection) { return [from + 1, to - 1]; } return [from, to]; } function getMdLine(resolvedPos: ResolvedPos) { return resolvedPos.index(0) + 1; } export function getWidgetNodePos(node: ProsemirrorNode, chPos: number, direction: 1 | -1 = 1) { let additionalPos = 0; node.forEach((child, pos) => { // add or subtract widget node tag if (isWidgetNode(child) && pos + 2 < chPos) { additionalPos += 2 * direction; } }); return additionalPos; } export function getEditorToMdPos(doc: ProsemirrorNode, from: number, to = from): Sourcepos { const collapsed = from === to; const startResolvedPos = doc.resolve(from); const startLine = getMdLine(startResolvedPos); let endLine = startLine; const startOffset = startResolvedPos.start(1); let endOffset = startOffset; if (!collapsed) { // prevent the end offset from pointing to the root document position const endResolvedPos = doc.resolve(to === doc.content.size ? to - 1 : to); endOffset = endResolvedPos.start(1); endLine = getMdLine(endResolvedPos); // To resolve the end offset excluding document tag size if (endResolvedPos.pos === doc.content.size) { to = doc.content.size - 2; } } const startCh = Math.max(from - startOffset + 1, 1); const endCh = Math.max(to - endOffset + 1, 1); return [ [startLine, startCh + getWidgetNodePos(doc.child(startLine - 1), startCh, -1)], [endLine, endCh + getWidgetNodePos(doc.child(endLine - 1), endCh, -1)], ]; } export function getStartPosListPerLine(doc: ProsemirrorNode, endIndex: number) { const startPosListPerLine: number[] = []; for (let i = 0, pos = 0; i < endIndex; i += 1) { const child = doc.child(i); startPosListPerLine[i] = pos; pos += child.nodeSize; } return startPosListPerLine; } export function getMdToEditorPos(doc: ProsemirrorNode, startPos: MdPos, endPos: MdPos) { const startPosListPerLine = getStartPosListPerLine(doc, endPos[0]); const startIndex = startPos[0] - 1; const endIndex = endPos[0] - 1; const startNode = doc.child(startIndex); const endNode = doc.child(endIndex); // calculate the position corresponding to the line let from = startPosListPerLine[startIndex]; let to = startPosListPerLine[endIndex]; // calculate the position corresponding to the character offset of the line from += startPos[1] + getWidgetNodePos(startNode, startPos[1] - 1); to += endPos[1] + getWidgetNodePos(endNode, endPos[1] - 1); return [from, Math.min(to, doc.content.size)]; } export function getRangeInfo(selection: Selection) { let { $from, $to } = selection; const { from, to } = selection; const { doc } = $from; if (selection instanceof AllSelection) { $from = doc.resolve(from + 1); $to = doc.resolve(to - 1); } if ($from.depth === 0) { $from = doc.resolve(from - 1); $to = $from; } return { startFromOffset: $from.start(1), endFromOffset: $to.start(1), startToOffset: $from.end(1), endToOffset: $to.end(1), startIndex: $from.index(0), endIndex: $to.index(0), from: $from.pos, to: $to.pos, }; } export function getNodeContentOffsetRange(doc: ProsemirrorNode, targetIndex: number) { let startOffset = 1; let endOffset = 1; for (let i = 0, offset = 0; i < doc.childCount; i += 1) { const { nodeSize } = doc.child(i); // calculate content start, end offset(not node offset) startOffset = offset + 1; endOffset = offset + nodeSize - 1; if (i === targetIndex) { break; } offset += nodeSize; } return { startOffset, endOffset }; } ================================================ FILE: apps/editor/src/markdown/helper/query.ts ================================================ import { ProsemirrorNode } from 'prosemirror-model'; export function getTextByMdLine(doc: ProsemirrorNode, mdLine: number) { return getTextContent(doc, mdLine - 1); } export function getTextContent(doc: ProsemirrorNode, index: number) { return doc.child(index).textContent; } ================================================ FILE: apps/editor/src/markdown/htmlRenderConvertors.ts ================================================ import isFunction from 'tui-code-snippet/type/isFunction'; import { HTMLConvertorMap, MdNode, ListItemMdNode, CodeMdNode, CodeBlockMdNode, CustomInlineMdNode, OpenTagToken, Context, HTMLConvertor, } from '@t/toastmark'; import { LinkAttributes, CustomHTMLRenderer } from '@t/editor'; import { HTMLMdNode } from '@t/markdown'; import { getWidgetContent, widgetToDOM } from '@/widget/rules'; import { getChildrenHTML, getHTMLAttrsByHTMLString } from '@/wysiwyg/nodes/html'; import { includes } from '@/utils/common'; import { reHTMLTag } from '@/utils/constants'; type TokenAttrs = Record; const reCloseTag = /^\s*<\s*\//; const baseConvertors: HTMLConvertorMap = { paragraph(_, { entering, origin, options }: Context) { if (options.nodeId) { return { type: entering ? 'openTag' : 'closeTag', outerNewLine: true, tagName: 'p', }; } return origin!(); }, softbreak(node: MdNode) { const isPrevNodeHTML = node.prev && node.prev.type === 'htmlInline'; const isPrevBR = isPrevNodeHTML && /
                      /.test(node.prev!.literal!); const content = isPrevBR ? '\n' : '
                      \n'; return { type: 'html', content }; }, item(node: MdNode, { entering }: Context) { if (entering) { const attributes: TokenAttrs = {}; const classNames = []; if ((node as ListItemMdNode).listData.task) { attributes['data-task'] = ''; classNames.push('task-list-item'); if ((node as ListItemMdNode).listData.checked) { classNames.push('checked'); attributes['data-task-checked'] = ''; } } return { type: 'openTag', tagName: 'li', classNames, attributes, outerNewLine: true, }; } return { type: 'closeTag', tagName: 'li', outerNewLine: true, }; }, code(node: MdNode) { const attributes = { 'data-backticks': String((node as CodeMdNode).tickCount) }; return [ { type: 'openTag', tagName: 'code', attributes }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'code' }, ]; }, codeBlock(node: MdNode) { const { fenceLength, info } = node as CodeBlockMdNode; const infoWords = info ? info.split(/\s+/) : []; const preClasses = []; const codeAttrs: TokenAttrs = {}; if (fenceLength > 3) { codeAttrs['data-backticks'] = fenceLength; } if (infoWords.length > 0 && infoWords[0].length > 0) { const [lang] = infoWords; preClasses.push(`lang-${lang}`); codeAttrs['data-language'] = lang; } return [ { type: 'openTag', tagName: 'pre', classNames: preClasses }, { type: 'openTag', tagName: 'code', attributes: codeAttrs }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'code' }, { type: 'closeTag', tagName: 'pre' }, ]; }, customInline(node: MdNode, { origin, entering, skipChildren }: Context) { const { info } = node as CustomInlineMdNode; if (info.indexOf('widget') !== -1 && entering) { skipChildren(); const content = getWidgetContent(node as CustomInlineMdNode); const htmlInline = widgetToDOM(info, content).outerHTML; return [ { type: 'openTag', tagName: 'span', classNames: ['tui-widget'] }, { type: 'html', content: htmlInline }, { type: 'closeTag', tagName: 'span' }, ]; } return origin!(); }, }; export function getHTMLRenderConvertors( linkAttributes: LinkAttributes | null, customConvertors: CustomHTMLRenderer ) { const convertors = { ...baseConvertors }; if (linkAttributes) { convertors.link = (_, { entering, origin }: Context) => { const result = origin!(); if (entering) { (result as OpenTagToken).attributes = { ...(result as OpenTagToken).attributes, ...linkAttributes, } as TokenAttrs; } return result; }; } if (customConvertors) { Object.keys(customConvertors).forEach((nodeType: string) => { const orgConvertor = convertors[nodeType]; const customConvertor = customConvertors[nodeType]!; if (orgConvertor && isFunction(customConvertor)) { convertors[nodeType] = (node, context) => { const newContext = { ...context }; newContext.origin = () => orgConvertor(node, context); return customConvertor(node, newContext); }; } else if (includes(['htmlBlock', 'htmlInline'], nodeType) && !isFunction(customConvertor)) { convertors[nodeType] = (node, context) => { const matched = node.literal!.match(reHTMLTag); if (matched) { const [rootHTML, openTagName, , closeTagName] = matched; const typeName = (openTagName || closeTagName).toLowerCase(); const htmlConvertor = customConvertor[typeName]; const childrenHTML = getChildrenHTML(node, typeName); if (htmlConvertor) { // copy for preventing to overwrite the originial property const newNode: HTMLMdNode = { ...node }; newNode.attrs = getHTMLAttrsByHTMLString(rootHTML); newNode.childrenHTML = childrenHTML; newNode.type = typeName; context.entering = !reCloseTag.test(node.literal!); return htmlConvertor(newNode, context); } } return context.origin!(); }; } else { convertors[nodeType] = customConvertor as HTMLConvertor; } }); } return convertors; } ================================================ FILE: apps/editor/src/markdown/marks/blockQuote.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { Command } from 'prosemirror-commands'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { createTextNode, createTextSelection, replaceTextNode, splitAndExtendBlock, } from '@/helper/manipulation'; import { getRangeInfo } from '../helper/pos'; import { getTextContent } from '../helper/query'; export const reBlockQuote = /^\s*> ?/; export class BlockQuote extends Mark { get name() { return 'blockQuote'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('block-quote') }, 0]; }, }; } private createBlockQuoteText(text: string, isBlockQuote?: boolean) { return isBlockQuote ? text.replace(reBlockQuote, '').trim() : `> ${text.trim()}`; } private extendBlockQuote(): Command { return ({ selection, doc, tr, schema }, dispatch) => { const { endFromOffset, endToOffset, endIndex, to } = getRangeInfo(selection); const textContent = getTextContent(doc, endIndex); const isBlockQuote = reBlockQuote.test(textContent); if (isBlockQuote && to > endFromOffset && selection.empty) { const isEmpty = !textContent.replace(reBlockQuote, '').trim(); if (isEmpty) { tr.deleteRange(endFromOffset, endToOffset).split(tr.mapping.map(endToOffset)); } else { const slicedText = textContent.slice(to - endFromOffset).trim(); const node = createTextNode(schema, this.createBlockQuoteText(slicedText)); splitAndExtendBlock(tr, endToOffset, slicedText, node); } dispatch!(tr); return true; } return false; }; } commands(): EditorCommand { return () => (state, dispatch) => { const { selection, doc } = state; const { startFromOffset, endToOffset, startIndex, endIndex } = getRangeInfo(selection); const isBlockQuote = reBlockQuote.test(getTextContent(doc, startIndex)); const tr = replaceTextNode({ state, startIndex, endIndex, from: startFromOffset, createText: (textContent) => this.createBlockQuoteText(textContent, isBlockQuote), }); dispatch!(tr.setSelection(createTextSelection(tr, tr.mapping.map(endToOffset)))); return true; }; } keymaps() { const blockQuoteCommand = this.commands()(); return { 'alt-q': blockQuoteCommand, 'alt-Q': blockQuoteCommand, Enter: this.extendBlockQuote(), }; } } ================================================ FILE: apps/editor/src/markdown/marks/code.ts ================================================ import { DOMOutputSpec, Mark as ProsemirrorMark } from 'prosemirror-model'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { toggleMark } from '../helper/mdCommand'; const reCode = /^(`).*([\s\S]*)\1$/m; const codeSyntax = '`'; export class Code extends Mark { get name() { return 'code'; } get schema() { return { attrs: { start: { default: false }, end: { default: false }, marked: { default: false }, }, toDOM(mark: ProsemirrorMark): DOMOutputSpec { const { start, end, marked } = mark.attrs; let classNames = 'code'; if (start) { classNames += '|delimiter|start'; } if (end) { classNames += '|delimiter|end'; } if (marked) { classNames += '|marked-text'; } return ['span', { class: clsWithMdPrefix(...classNames.split('|')) }, 0]; }, }; } commands(): EditorCommand { return toggleMark(reCode, codeSyntax); } keymaps() { const codeCommand = this.commands()(); return { 'Shift-Mod-c': codeCommand, 'Shift-Mod-C': codeCommand }; } } ================================================ FILE: apps/editor/src/markdown/marks/codeBlock.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { Command } from 'prosemirror-commands'; import { EditorCommand, MdSpecContext } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { createTextNode, createTextSelection, splitAndExtendBlock } from '@/helper/manipulation'; import { isCodeBlockNode } from '@/utils/markdown'; import { getRangeInfo } from '../helper/pos'; import { getTextContent } from '../helper/query'; const fencedCodeBlockSyntax = '```'; export class CodeBlock extends Mark { context!: MdSpecContext; get name() { return 'codeBlock'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('code-block') }, 0]; }, }; } commands(): EditorCommand { return () => (state, dispatch) => { const { selection, schema, tr } = state; const { startFromOffset, endToOffset } = getRangeInfo(selection); const fencedNode = createTextNode(schema, fencedCodeBlockSyntax); // add fenced start block tr.insert(startFromOffset, fencedNode).split(startFromOffset + fencedCodeBlockSyntax.length); // add fenced end block tr.split(tr.mapping.map(endToOffset)).insert(tr.mapping.map(endToOffset), fencedNode); dispatch!( tr.setSelection( // subtract fenced syntax length and open, close tag(2) createTextSelection(tr, tr.mapping.map(endToOffset) - (fencedCodeBlockSyntax.length + 2)) ) ); return true; }; } private keepIndentation(): Command { return ({ selection, tr, doc, schema }, dispatch) => { const { toastMark } = this.context; const { startFromOffset, endToOffset, endIndex, from, to } = getRangeInfo(selection); const textContent = getTextContent(doc, endIndex); if (from === to && textContent.trim()) { const matched = textContent.match(/^\s+/); const mdNode = toastMark.findFirstNodeAtLine(endIndex + 1)!; if (isCodeBlockNode(mdNode) && matched) { const [spaces] = matched; const slicedText = textContent.slice(to - startFromOffset); const node = createTextNode(schema, spaces + slicedText); splitAndExtendBlock(tr, endToOffset, slicedText, node); dispatch!(tr); return true; } } return false; }; } keymaps() { const codeBlockCommand = this.commands()(); return { 'Shift-Mod-p': codeBlockCommand, 'Shift-Mod-P': codeBlockCommand, Enter: this.keepIndentation(), }; } } ================================================ FILE: apps/editor/src/markdown/marks/customBlock.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { EditorCommand } from '@t/spec'; import { getRangeInfo } from '../helper/pos'; import { createTextNode, createTextSelection } from '@/helper/manipulation'; const customBlockSyntax = '$$'; export class CustomBlock extends Mark { get name() { return 'customBlock'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('custom-block') }, 0]; }, }; } commands(): EditorCommand { return (payload) => (state, dispatch) => { const { selection, schema, tr } = state; const { startFromOffset, endToOffset } = getRangeInfo(selection); if (!payload?.info) { return false; } const customBlock = `${customBlockSyntax}${payload.info}`; const startNode = createTextNode(schema, customBlock); const endNode = createTextNode(schema, customBlockSyntax); tr.insert(startFromOffset, startNode).split(startFromOffset + customBlock.length); tr.split(tr.mapping.map(endToOffset)).insert(tr.mapping.map(endToOffset), endNode); dispatch!( tr.setSelection( createTextSelection(tr, tr.mapping.map(endToOffset) - (customBlockSyntax.length + 2)) ) ); return true; }; } } ================================================ FILE: apps/editor/src/markdown/marks/emph.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { toggleMark } from '../helper/mdCommand'; const reEmph = /^(\*|_).*([\s\S]*)\1$/m; const emphSyntax = '*'; export class Emph extends Mark { get name() { return 'emph'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('emph') }, 0]; }, }; } private italic(): EditorCommand { return toggleMark(reEmph, emphSyntax); } commands() { return { italic: this.italic() }; } keymaps() { const italicCommand = this.italic()(); return { 'Mod-i': italicCommand, 'Mod-I': italicCommand }; } } ================================================ FILE: apps/editor/src/markdown/marks/heading.ts ================================================ import { DOMOutputSpec, Mark as ProsemirrorMark } from 'prosemirror-model'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { createTextSelection, replaceTextNode } from '@/helper/manipulation'; import { getRangeInfo } from '../helper/pos'; const reHeading = /^#{1,6}\s/; interface Payload { level: number; } export class Heading extends Mark { get name() { return 'heading'; } get schema() { return { attrs: { level: { default: 1 }, seText: { default: false }, }, toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { const { level, seText } = attrs; let classNames = `heading|heading${level}`; if (seText) { classNames += '|delimiter|setext'; } return ['span', { class: clsWithMdPrefix(...classNames.split('|')) }, 0]; }, }; } private createHeadingText(level: number, text: string, curHeadingSyntax: string) { const textContent = text.replace(curHeadingSyntax, '').trim(); let headingText = ''; while (level > 0) { headingText += '#'; level -= 1; } return `${headingText} ${textContent}`; } commands(): EditorCommand { return (payload) => (state, dispatch) => { const { level } = payload!; const { startFromOffset, endToOffset, startIndex, endIndex } = getRangeInfo(state.selection); const tr = replaceTextNode({ state, from: startFromOffset, startIndex, endIndex, createText: (textContent) => { const matchedHeading = textContent.match(reHeading); const curHeadingSyntax = matchedHeading ? matchedHeading[0] : ''; return this.createHeadingText(level, textContent, curHeadingSyntax); }, }); dispatch!(tr.setSelection(createTextSelection(tr, tr.mapping.map(endToOffset)))); return true; }; } } ================================================ FILE: apps/editor/src/markdown/marks/html.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; export class Html extends Mark { get name() { return 'html'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('html') }, 0]; }, }; } } ================================================ FILE: apps/editor/src/markdown/marks/link.ts ================================================ import { DOMOutputSpec, Mark as ProsemirrorMark } from 'prosemirror-model'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import { escapeTextForLink } from '@/utils/common'; import Mark from '@/spec/mark'; import { createTextNode } from '@/helper/manipulation'; import { resolveSelectionPos } from '../helper/pos'; type CommandType = 'image' | 'link'; interface Payload { linkText: string; altText: string; linkUrl: string; imageUrl: string; } export class Link extends Mark { get name() { return 'link'; } get schema() { return { attrs: { url: { default: false }, desc: { default: false }, }, toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { const { url, desc } = attrs; let classNames = 'link'; if (url) { classNames += '|link-url|marked-text'; } if (desc) { classNames += '|link-desc|marked-text'; } return ['span', { class: clsWithMdPrefix(...classNames.split('|')) }, 0]; }, }; } private addLinkOrImage(commandType: CommandType): EditorCommand { return (payload) => ({ selection, tr, schema }, dispatch) => { const [from, to] = resolveSelectionPos(selection); const { linkText, altText, linkUrl, imageUrl } = payload!; let text = linkText; let url = linkUrl; let syntax = ''; if (commandType === 'image') { text = altText; url = imageUrl; syntax = '!'; } text = escapeTextForLink(text); syntax += `[${text}](${url})`; dispatch!(tr.replaceWith(from, to, createTextNode(schema, syntax))); return true; }; } commands() { return { addImage: this.addLinkOrImage('image'), addLink: this.addLinkOrImage('link'), }; } } ================================================ FILE: apps/editor/src/markdown/marks/listItem.ts ================================================ import { DOMOutputSpec, Mark as ProsemirrorMark } from 'prosemirror-model'; import { Transaction } from 'prosemirror-state'; import { Command } from 'prosemirror-commands'; import { ListItemMdNode, MdNode } from '@toast-ui/toastmark'; import { EditorCommand, MdSpecContext } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { isListNode } from '@/utils/markdown'; import { createTextNode, createTextSelection, splitAndExtendBlock } from '@/helper/manipulation'; import { last } from '@/utils/common'; import { ChangedListInfo, extendList, ExtendListContext, getListType, otherListToList, otherNodeToList, reCanBeTaskList, reList, ToListContext, } from '../helper/list'; import { getRangeInfo, getNodeContentOffsetRange } from '../helper/pos'; import { getTextContent } from '../helper/query'; type CommandType = 'bullet' | 'ordered' | 'task'; function cannotBeListNode({ type, sourcepos }: MdNode, line: number) { // eslint-disable-next-line prefer-destructuring const startLine = sourcepos![0][0]; return line <= startLine && (type === 'codeBlock' || type === 'heading' || type.match('table')); } interface RangeInfo { from: number; startLine: number; endLine: number; indexDiff?: number; } export class ListItem extends Mark { context!: MdSpecContext; get name() { return 'listItem'; } get schema() { return { attrs: { odd: { default: false }, even: { default: false }, listStyle: { default: false }, }, toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { const { odd, even, listStyle } = attrs; let classNames = 'list-item'; if (listStyle) { classNames += '|list-item-style'; } if (odd) { classNames += '|list-item-odd'; } if (even) { classNames += '|list-item-even'; } return ['span', { class: clsWithMdPrefix(...classNames.split('|')) }, 0]; }, }; } private extendList(): Command { return ({ selection, doc, schema, tr }, dispatch) => { const { toastMark } = this.context; const { to, startFromOffset, endFromOffset, endIndex, endToOffset } = getRangeInfo(selection); const textContent = getTextContent(doc, endIndex); const isList = reList.test(textContent); if (!isList || selection.from === startFromOffset || !selection.empty) { return false; } const isEmpty = !textContent.replace(reCanBeTaskList, '').trim(); if (isEmpty) { tr.deleteRange(endFromOffset, endToOffset).split(tr.mapping.map(endToOffset)); } else { const commandType = getListType(textContent); // should add `1` to line for the markdown parser // because markdown parser has `1`(not zero) as the start number const mdNode = toastMark.findFirstNodeAtLine(endIndex + 1) as ListItemMdNode; const slicedText = textContent.slice(to - endFromOffset); const context: ExtendListContext = { toastMark, mdNode, doc, line: endIndex + 1 }; const { listSyntax, changedResults } = extendList[commandType](context); // change ordinal number of backward ordered list if (changedResults?.length) { // split the block tr.split(to); // set first ordered list info changedResults.unshift({ text: listSyntax + slicedText, line: endIndex + 1 }); this.changeToListPerLine(tr, changedResults, { from: to, // don't subtract 1 because the line has increased through 'split' command. startLine: changedResults[0].line, endLine: last(changedResults).line, }); const pos = tr.mapping.map(endToOffset) - slicedText.length; tr.setSelection(createTextSelection(tr, pos)); } else { const node = createTextNode(schema, listSyntax + slicedText); splitAndExtendBlock(tr, endToOffset, slicedText, node); } } dispatch!(tr); return true; }; } private toList(commandType: CommandType): EditorCommand { return () => ({ doc, tr, selection }, dispatch) => { const { toastMark } = this.context; const rangeInfo = getRangeInfo(selection); // should add `1` to line for the markdown parser // because markdown parser has `1`(not zero) as the start number const startLine = rangeInfo.startIndex + 1; const endLine = rangeInfo.endIndex + 1; let { endToOffset } = rangeInfo; let skipLines: number[] = []; for (let line = startLine; line <= endLine; line += 1) { const mdNode: MdNode = toastMark.findFirstNodeAtLine(line)!; if (mdNode && cannotBeListNode(mdNode, line)) { break; } // to skip unnecessary processing if (skipLines.indexOf(line) !== -1) { continue; } const context: ToListContext = { toastMark, mdNode, doc, line, startLine }; const { changedResults } = isListNode(mdNode) ? otherListToList[commandType](context as ToListContext) : otherNodeToList[commandType](context); const endOffset = this.changeToListPerLine(tr, changedResults, { from: getNodeContentOffsetRange(doc, changedResults[0].line - 1).startOffset, startLine: changedResults[0].line, endLine: last(changedResults).line, indexDiff: 1, }); endToOffset = Math.max(endOffset, endToOffset); if (changedResults) { skipLines = skipLines.concat(changedResults.map((info) => info.line)); } } dispatch!(tr.setSelection(createTextSelection(tr, tr.mapping.map(endToOffset)))); return true; }; } private changeToListPerLine( tr: Transaction, changedResults: ChangedListInfo[], { from, startLine, endLine, indexDiff = 0 }: RangeInfo ) { let maxEndOffset = 0; for (let i = startLine - indexDiff; i <= endLine - indexDiff; i += 1) { const { nodeSize, content } = tr.doc.child(i); const mappedFrom = tr.mapping.map(from); const mappedTo = mappedFrom + content.size; const [changedResult] = changedResults.filter((result) => result.line - indexDiff === i); if (changedResult) { tr.replaceWith( mappedFrom, mappedTo, createTextNode(this.context.schema, changedResult.text) ); maxEndOffset = Math.max(maxEndOffset, from + content.size); } from += nodeSize; } return maxEndOffset; } private toggleTask(): Command { return ({ selection, tr, doc, schema }, dispatch) => { const { toastMark } = this.context; const { startIndex, endIndex } = getRangeInfo(selection); let newTr: Transaction | null = null; for (let i = startIndex; i <= endIndex; i += 1) { const mdNode = toastMark.findFirstNodeAtLine(i + 1)!; if (isListNode(mdNode) && mdNode.listData.task) { const { checked, padding } = mdNode.listData; const stateChar = checked ? ' ' : 'x'; const [mdPos] = mdNode.sourcepos!; let { startOffset } = getNodeContentOffsetRange(doc, mdPos[0] - 1); startOffset += mdPos[1] + padding; newTr = tr.replaceWith(startOffset, startOffset + 1, schema.text(stateChar)); } } if (newTr) { dispatch!(newTr); return true; } return false; }; } commands() { return { bulletList: this.toList('bullet'), orderedList: this.toList('ordered'), taskList: this.toList('task'), }; } keymaps() { const bulletCommand = this.toList('bullet')(); const orderedCommand = this.toList('ordered')(); const taskCommand = this.toList('task')(); const togleTaskCommand = this.toggleTask(); return { 'Mod-u': bulletCommand, 'Mod-U': bulletCommand, 'Mod-o': orderedCommand, 'Mod-O': orderedCommand, 'alt-t': taskCommand, 'alt-T': taskCommand, 'Shift-Ctrl-x': togleTaskCommand, 'Shift-Ctrl-X': togleTaskCommand, Enter: this.extendList(), }; } } ================================================ FILE: apps/editor/src/markdown/marks/simpleMark.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; export class TaskDelimiter extends Mark { get name() { return 'taskDelimiter'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('delimiter', 'list-item') }, 0]; }, }; } } export class Delimiter extends Mark { get name() { return 'delimiter'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('delimiter') }, 0]; }, }; } } export class Meta extends Mark { get name() { return 'meta'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('meta') }, 0]; }, }; } } export class MarkedText extends Mark { get name() { return 'markedText'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('marked-text') }, 0]; }, }; } } export class TableCell extends Mark { get name() { return 'tableCell'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('table-cell') }, 0]; }, }; } } ================================================ FILE: apps/editor/src/markdown/marks/strike.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { toggleMark } from '../helper/mdCommand'; const reStrike = /^(~{2}).*([\s\S]*)\1$/m; const strikeSyntax = '~~'; export class Strike extends Mark { get name() { return 'strike'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('strike') }, 0]; }, }; } commands(): EditorCommand { return toggleMark(reStrike, strikeSyntax); } keymaps() { const strikeCommand = this.commands()(); return { 'Mod-s': strikeCommand, 'Mod-S': strikeCommand }; } } ================================================ FILE: apps/editor/src/markdown/marks/strong.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { toggleMark } from '../helper/mdCommand'; export const reStrong = /^(\*{2}|_{2}).*([\s\S]*)\1$/m; const strongSyntax = '**'; export class Strong extends Mark { get name() { return 'strong'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('strong') }, 0]; }, }; } private bold(): EditorCommand { return toggleMark(reStrong, strongSyntax); } commands() { return { bold: this.bold() }; } keymaps() { const boldCommand = this.bold()(); return { 'Mod-b': boldCommand, 'Mod-B': boldCommand }; } } ================================================ FILE: apps/editor/src/markdown/marks/table.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { Command } from 'prosemirror-commands'; import type { Transaction } from 'prosemirror-state'; import { TableCellMdNode, MdNode, MdPos } from '@toast-ui/toastmark'; import { EditorCommand, MdSpecContext } from '@t/spec'; import { TableRowMdNode } from '@t/markdown'; import { clsWithMdPrefix } from '@/utils/dom'; import { findClosestNode, getMdEndCh, isTableCellNode } from '@/utils/markdown'; import Mark from '@/spec/mark'; import { getRangeInfo } from '../helper/pos'; import { createTextNode, createTextSelection } from '@/helper/manipulation'; import { getTextContent } from '../helper/query'; interface Payload { columnCount: number; rowCount: number; } interface MovingTypeInfo { type: 'next' | 'prev'; parentType: 'tableHead' | 'tableBody'; childType: 'firstChild' | 'lastChild'; } const reEmptyTable = /\||\s/g; function createTableHeader(columnCount: number) { return [createTableRow(columnCount), createTableRow(columnCount, true)]; } function createTableBody(columnCount: number, rowCount: number) { const bodyRows = []; for (let i = 0; i < rowCount; i += 1) { bodyRows.push(createTableRow(columnCount)); } return bodyRows; } function createTableRow(columnCount: number, delim?: boolean) { let row = '|'; for (let i = 0; i < columnCount; i += 1) { row += delim ? ' --- |' : ' |'; } return row; } function createTargetTypes(moveNext: boolean): MovingTypeInfo { return moveNext ? { type: 'next', parentType: 'tableHead', childType: 'firstChild' } : { type: 'prev', parentType: 'tableBody', childType: 'lastChild' }; } export class Table extends Mark { context!: MdSpecContext; get name() { return 'table'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('table') }, 0]; }, }; } private extendTable(): Command { return ({ selection, doc, tr, schema }, dispatch) => { if (!selection.empty) { return false; } const { endFromOffset, endToOffset, endIndex, to } = getRangeInfo(selection); const textContent = getTextContent(doc, endIndex); // should add `1` to line for the markdown parser // because markdown parser has `1`(not zero) as the start number const mdPos: MdPos = [endIndex + 1, to - endFromOffset + 1]; const mdNode: MdNode = this.context.toastMark.findNodeAtPosition(mdPos)!; const cellNode = findClosestNode( mdNode, (node) => isTableCellNode(node) && (node.parent!.type === 'tableDelimRow' || node.parent!.parent!.type === 'tableBody') ) as TableCellMdNode; if (cellNode) { const isEmpty = !textContent.replace(reEmptyTable, '').trim(); const parent = cellNode.parent as TableRowMdNode; const columnCount = parent.parent.parent.columns.length; const row = createTableRow(columnCount); if (isEmpty) { tr.deleteRange(endFromOffset, endToOffset).split(tr.mapping.map(endToOffset)); } else { (tr .split(endToOffset) .insert(tr.mapping.map(endToOffset), createTextNode(schema, row)) as Transaction) // should subtract `2` to selection end position considering ` |` text .setSelection(createTextSelection(tr, tr.mapping.map(endToOffset) - 2)); } dispatch!(tr); return true; } return false; }; } private moveTableCell(moveNext: boolean): Command { return ({ selection, tr }, dispatch) => { const { endFromOffset, endIndex, to } = getRangeInfo(selection); const mdPos: MdPos = [endIndex + 1, to - endFromOffset]; const mdNode: MdNode = this.context.toastMark.findNodeAtPosition(mdPos)!; const cellNode = findClosestNode(mdNode, (node) => isTableCellNode(node)) as TableCellMdNode; if (cellNode) { const parent = cellNode.parent as TableRowMdNode; const { type, parentType, childType } = createTargetTypes(moveNext); let chOffset = getMdEndCh(cellNode); if (cellNode[type]) { chOffset = getMdEndCh(cellNode[type]!) - 1; } else { const row = !parent[type] && parent.parent.type === parentType ? parent.parent[type]![childType] : parent[type]; if (type === 'next') { // if there is next row, the base offset would be end position of the next row's first child. // Otherwise, the base offset is zero. const baseOffset = row ? getMdEndCh(row[childType]!) : 0; // calculate tag(open, close) position('2') for selection chOffset += baseOffset + 2; } else if (type === 'prev') { // if there is prev row, the target position would be '-4' for calculating ' |' characters and tag(open, close) // Otherwise, the target position is zero. chOffset = row ? -4 : 0; } } dispatch!(tr.setSelection(createTextSelection(tr, endFromOffset + chOffset))); return true; } return false; }; } private addTable(): EditorCommand { return (payload) => ({ selection, tr, schema }, dispatch) => { const { columnCount, rowCount } = payload!; const { endToOffset } = getRangeInfo(selection); const headerRows = createTableHeader(columnCount); const bodyRows = createTableBody(columnCount, rowCount - 1); const rows = [...headerRows, ...bodyRows]; rows.forEach((row) => { tr.split(tr.mapping.map(endToOffset)).insert( tr.mapping.map(endToOffset), createTextNode(schema, row) ); }); // should add `4` to selection position considering `| ` text and start block tag length dispatch!(tr.setSelection(createTextSelection(tr, endToOffset + 4))); return true; }; } commands() { return { addTable: this.addTable() }; } keymaps() { return { Enter: this.extendTable(), Tab: this.moveTableCell(true), 'Shift-Tab': this.moveTableCell(false), }; } } ================================================ FILE: apps/editor/src/markdown/marks/thematicBreak.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import type { Transaction } from 'prosemirror-state'; import { EditorCommand } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Mark from '@/spec/mark'; import { createTextNode, createTextSelection } from '@/helper/manipulation'; import { getRangeInfo } from '../helper/pos'; const thematicBreakSyntax = '***'; export class ThematicBreak extends Mark { get name() { return 'thematicBreak'; } get schema() { return { toDOM(): DOMOutputSpec { return ['span', { class: clsWithMdPrefix('thematic-break') }, 0]; }, }; } private hr(): EditorCommand { return () => (state, dispatch) => { const { selection, schema, tr } = state; const { from, to, endToOffset } = getRangeInfo(selection); const node = createTextNode(schema, thematicBreakSyntax); (tr .split(from) .replaceWith(tr.mapping.map(from), tr.mapping.map(to), node) .split(tr.mapping.map(to)) as Transaction).setSelection( createTextSelection(tr, tr.mapping.map(endToOffset)) ); dispatch!(tr); return true; }; } commands() { return { hr: this.hr() }; } keymaps() { const lineCommand = this.hr()(); return { 'Mod-l': lineCommand, 'Mod-L': lineCommand }; } } ================================================ FILE: apps/editor/src/markdown/mdEditor.ts ================================================ import { Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { Fragment, Slice } from 'prosemirror-model'; import { ReplaceAroundStep } from 'prosemirror-transform'; import { MdPos, ToastMark } from '@toast-ui/toastmark'; import toArray from 'tui-code-snippet/collection/toArray'; import { MdContext } from '@t/spec'; import { Emitter } from '@t/event'; import { WidgetStyle } from '@t/editor'; import EditorBase from '@/base'; import SpecManager from '@/spec/specManager'; import { cls, toggleClass } from '@/utils/dom'; import { emitImageBlobHook, pasteImageOnly } from '@/helper/image'; import { createParagraph, createTextSelection } from '@/helper/manipulation'; import { syntaxHighlight } from './plugins/syntaxHighlight'; import { previewHighlight } from './plugins/previewHighlight'; import { Doc } from './nodes/doc'; import { Paragraph } from './nodes/paragraph'; import { Text } from './nodes/text'; import { Heading } from './marks/heading'; import { BlockQuote } from './marks/blockQuote'; import { CodeBlock } from './marks/codeBlock'; import { Table } from './marks/table'; import { ThematicBreak } from './marks/thematicBreak'; import { ListItem } from './marks/listItem'; import { Strong } from './marks/strong'; import { Strike } from './marks/strike'; import { Emph } from './marks/emph'; import { Code } from './marks/code'; import { Link } from './marks/link'; import { Delimiter, TaskDelimiter, MarkedText, Meta, TableCell } from './marks/simpleMark'; import { Html } from './marks/html'; import { CustomBlock } from './marks/customBlock'; import { getEditorToMdPos, getMdToEditorPos } from './helper/pos'; import { smartTask } from './plugins/smartTask'; import { createNodesWithWidget, unwrapWidgetSyntax } from '@/widget/rules'; import { Widget, widgetNodeView } from '@/widget/widgetNode'; import { PluginProp } from '@t/plugin'; interface WindowWithClipboard extends Window { clipboardData?: DataTransfer | null; } interface MarkdownOptions { toastMark: ToastMark; useCommandShortcut?: boolean; mdPlugins?: PluginProp[]; } const EVENT_TYPE = 'cut'; const reLineEnding = /\r\n|\n|\r/; export default class MdEditor extends EditorBase { private toastMark: ToastMark; private clipboard!: HTMLTextAreaElement; context!: MdContext; constructor(eventEmitter: Emitter, options: MarkdownOptions) { super(eventEmitter); const { toastMark, useCommandShortcut = true, mdPlugins = [] } = options; this.editorType = 'markdown'; this.el.classList.add('md-mode'); this.toastMark = toastMark; this.extraPlugins = mdPlugins; this.specs = this.createSpecs(); this.schema = this.createSchema(); this.context = this.createContext(); this.keymaps = this.createKeymaps(useCommandShortcut); this.view = this.createView(); this.commands = this.createCommands(); this.specs.setContext({ ...this.context, view: this.view }); this.createClipboard(); // To prevent unnecessary focus setting during initial rendering this.eventEmitter.listen('changePreviewTabWrite', (isMarkdownTabMounted?: boolean) => this.toggleActive(true, isMarkdownTabMounted) ); this.eventEmitter.listen('changePreviewTabPreview', () => this.toggleActive(false)); this.initEvent(); } private toggleActive(active: boolean, isMarkdownTabMounted?: boolean) { toggleClass(this.el!, 'active', active); if (active) { if (!isMarkdownTabMounted) { this.focus(); } } else { this.blur(); } } private createClipboard() { this.clipboard = document.createElement('textarea'); this.clipboard.className = cls('pseudo-clipboard'); this.clipboard.addEventListener('paste', (ev: ClipboardEvent) => { const clipboardData = (ev as ClipboardEvent).clipboardData || (window as WindowWithClipboard).clipboardData; const items = clipboardData && clipboardData.items; if (items) { const containRtfItem = toArray(items).some( (item) => item.kind === 'string' && item.type === 'text/rtf' ); // if it contains rtf, it's most likely copy paste from office -> no image if (!containRtfItem) { const imageBlob = pasteImageOnly(items); if (imageBlob) { ev.preventDefault(); emitImageBlobHook(this.eventEmitter, imageBlob, ev.type); } } } }); // process the pasted data in input event for IE11 this.clipboard.addEventListener('input', (ev) => { const text = (ev.target as HTMLTextAreaElement).value; this.replaceSelection(text); ev.preventDefault(); (ev.target as HTMLTextAreaElement).value = ''; }); this.el.insertBefore(this.clipboard, this.view.dom); } createContext() { return { toastMark: this.toastMark, schema: this.schema, eventEmitter: this.eventEmitter, }; } createSpecs() { return new SpecManager([ new Doc(), new Paragraph(), new Widget(), new Text(), new Heading(), new BlockQuote(), new CodeBlock(), new CustomBlock(), new Table(), new TableCell(), new ThematicBreak(), new ListItem(), new Strong(), new Strike(), new Emph(), new Code(), new Link(), new Delimiter(), new TaskDelimiter(), new MarkedText(), new Meta(), new Html(), ]); } createPlugins() { return [ syntaxHighlight(this.context), previewHighlight(this.context), smartTask(this.context), ...this.createPluginProps(), ].concat(this.defaultPlugins); } createView() { return new EditorView(this.el, { state: this.createState(), dispatchTransaction: (tr) => { this.updateMarkdown(tr); const { state } = this.view.state.applyTransaction(tr); this.view.updateState(state); this.emitChangeEvent(tr); }, handleKeyDown: (_, ev) => { if ((ev.metaKey || ev.ctrlKey) && ev.key.toUpperCase() === 'V') { this.clipboard.focus(); } this.eventEmitter.emit('keydown', this.editorType, ev); return false; }, handleDOMEvents: { copy: (_, ev) => this.captureCopy(ev), cut: (_, ev) => this.captureCopy(ev, EVENT_TYPE), scroll: () => { this.eventEmitter.emit('scroll', 'editor'); return true; }, keyup: (_, ev: KeyboardEvent) => { this.eventEmitter.emit('keyup', this.editorType, ev); return false; }, }, nodeViews: { widget: widgetNodeView, }, }); } createCommands() { return this.specs.commands(this.view); } private captureCopy(ev: ClipboardEvent, type?: string) { ev.preventDefault(); const { selection, tr } = this.view.state; if (selection.empty) { return true; } const text = this.getChanged(selection.content()); if (ev.clipboardData) { ev.clipboardData.setData('text/plain', text); } else { (window as WindowWithClipboard).clipboardData!.setData('Text', text); } if (type === EVENT_TYPE) { this.view.dispatch(tr.deleteSelection().scrollIntoView().setMeta('uiEvent', EVENT_TYPE)); } return true; } private updateMarkdown(tr: Transaction) { if (tr.docChanged) { tr.steps.forEach((step, index) => { if (step.slice && !(step instanceof ReplaceAroundStep)) { const doc = tr.docs[index]; const [from, to] = [step.from, step.to]; const [startPos, endPos] = getEditorToMdPos(doc, from, to); let changed = this.getChanged(step.slice); if (startPos[0] === endPos[0] && startPos[1] === endPos[1] && changed === '') { changed = '\n'; } const editResult = this.toastMark.editMarkdown(startPos, endPos, changed); this.eventEmitter.emit('updatePreview', editResult); tr.setMeta('editResult', editResult).scrollIntoView(); } }); } } private getChanged(slice: Slice) { let changed = ''; const from = 0; const to = slice.content.size; slice.content.nodesBetween(from, to, (node, pos) => { if (node.isText) { changed += node.text!.slice(Math.max(from, pos) - pos, to - pos); } else if (node.isBlock && pos > 0) { changed += '\n'; } }); return changed; } setSelection(start: MdPos, end = start) { const { tr } = this.view.state; const [from, to] = getMdToEditorPos(tr.doc, start, end); this.view.dispatch(tr.setSelection(createTextSelection(tr, from, to)).scrollIntoView()); } replaceSelection(text: string, start?: MdPos, end?: MdPos) { let newTr; const { tr, schema, doc } = this.view.state; const lineTexts = text.split(reLineEnding); const nodes = lineTexts.map((lineText) => createParagraph(schema, createNodesWithWidget(lineText, schema)) ); const slice = new Slice(Fragment.from(nodes), 1, 1); this.focus(); if (start && end) { const [from, to] = getMdToEditorPos(doc, start, end); newTr = tr.replaceRange(from, to, slice); } else { newTr = tr.replaceSelection(slice); } this.view.dispatch(newTr.scrollIntoView()); } deleteSelection(start?: MdPos, end?: MdPos) { let newTr; const { tr, doc } = this.view.state; if (start && end) { const [from, to] = getMdToEditorPos(doc, start, end); newTr = tr.deleteRange(from, to); } else { newTr = tr.deleteSelection(); } this.view.dispatch(newTr.scrollIntoView()); } getSelectedText(start?: MdPos, end?: MdPos) { const { doc, selection } = this.view.state; let { from, to } = selection; if (start && end) { const pos = getMdToEditorPos(doc, start, end); from = pos[0]; to = pos[1]; } return doc.textBetween(from, to, '\n'); } getSelection() { const { from, to } = this.view.state.selection; return getEditorToMdPos(this.view.state.tr.doc, from, to); } setMarkdown(markdown: string, cursorToEnd = true) { const lineTexts = markdown.split(reLineEnding); const { tr, doc, schema } = this.view.state; const nodes = lineTexts.map((lineText) => createParagraph(schema, createNodesWithWidget(lineText, schema)) ); this.view.dispatch(tr.replaceWith(0, doc.content.size, nodes)); if (cursorToEnd) { this.moveCursorToEnd(true); } } addWidget(node: Node, style: WidgetStyle, mdPos?: MdPos) { const { tr, doc, selection } = this.view.state; const pos = mdPos ? getMdToEditorPos(doc, mdPos, mdPos)[0] : selection.to; this.view.dispatch(tr.setMeta('widget', { pos, node, style })); } replaceWithWidget(start: MdPos, end: MdPos, text: string) { const { tr, schema, doc } = this.view.state; const pos = getMdToEditorPos(doc, start, end); const nodes = createNodesWithWidget(text, schema); this.view.dispatch(tr.replaceWith(pos[0], pos[1], nodes)); } getRangeInfoOfNode(pos?: MdPos) { const { doc, selection } = this.view.state; const mdPos = pos || getEditorToMdPos(doc, selection.from)[0]; let mdNode = this.toastMark.findNodeAtPosition(mdPos)!; if (mdNode.type === 'text' && mdNode.parent!.type !== 'paragraph') { mdNode = mdNode.parent!; } // add 1 sync for prosemirror position mdNode.sourcepos![1][1] += 1; return { range: mdNode.sourcepos!, type: mdNode.type }; } getMarkdown() { return this.toastMark .getLineTexts() .map((lineText: string) => unwrapWidgetSyntax(lineText)) .join('\n'); } getToastMark() { return this.toastMark; } } ================================================ FILE: apps/editor/src/markdown/mdPreview.ts ================================================ import off from 'tui-code-snippet/domEvent/off'; import addClass from 'tui-code-snippet/domUtil/addClass'; import removeClass from 'tui-code-snippet/domUtil/removeClass'; import on from 'tui-code-snippet/domEvent/on'; import css from 'tui-code-snippet/domUtil/css'; import { EditResult, MdNode, MdPos, Renderer } from '@toast-ui/toastmark'; import { Emitter } from '@t/event'; import { CustomHTMLRenderer, LinkAttributes } from '@t/editor'; import { cls, createElementWith, removeNode, removeProseMirrorHackNodes, toggleClass, } from '@/utils/dom'; import { getHTMLRenderConvertors } from '@/markdown/htmlRenderConvertors'; import { isInlineNode, findClosestNode, getMdStartCh } from '@/utils/markdown'; import { findAdjacentElementToScrollTop } from './scroll/dom'; import { removeOffsetInfoByNode } from './scroll/offset'; export const CLASS_HIGHLIGHT = cls('md-preview-highlight'); function findTableCell(tableRow: MdNode, chOffset: number) { let cell = tableRow.firstChild; while (cell && cell.next) { if (getMdStartCh(cell.next) > chOffset + 1) { break; } cell = cell.next; } return cell; } type Sanitizer = (html: string) => string; interface Options { linkAttributes: LinkAttributes | null; customHTMLRenderer: CustomHTMLRenderer; isViewer: boolean; highlight?: boolean; sanitizer: Sanitizer; } /** * Class Markdown Preview * @param {HTMLElement} el - base element * @param {eventEmitter} eventEmitter - event manager * @param {object} options * @param {boolean} options.isViewer - true for view-only mode * @param {boolean} options.highlight - true for using live-highlight feature * @param {object} opitons.linkAttributes - attributes for link element * @param {object} opitons.customHTMLRenderer - map of custom HTML render functions * * @ignore */ class MarkdownPreview { el: HTMLElement | null; previewContent!: HTMLElement; private eventEmitter: Emitter; private isViewer: boolean; private cursorNodeId!: number | null; private renderer: Renderer; private sanitizer: Sanitizer; constructor(eventEmitter: Emitter, options: Options) { const el = document.createElement('div'); this.el = el; this.eventEmitter = eventEmitter; this.isViewer = !!options.isViewer; this.el.className = cls('md-preview'); const { linkAttributes, customHTMLRenderer, sanitizer, highlight = false } = options; this.renderer = new Renderer({ gfm: true, nodeId: true, convertors: getHTMLRenderConvertors(linkAttributes, customHTMLRenderer), }); this.cursorNodeId = null; this.sanitizer = sanitizer; this.initEvent(highlight); this.initContentSection(); // To prevent overflowing contents in the viewer if (this.isViewer) { this.previewContent.style.overflowWrap = 'break-word'; } } private initContentSection() { this.previewContent = createElementWith( `
                      ` ) as HTMLElement; if (!this.isViewer) { this.el!.appendChild(this.previewContent); } } private toggleActive(active: boolean) { toggleClass(this.el!, 'active', active); } private initEvent(highlight: boolean) { this.eventEmitter.listen('updatePreview', this.update.bind(this)); if (this.isViewer) { return; } if (highlight) { this.eventEmitter.listen('changeToolbarState', ({ mdNode, cursorPos }) => { this.updateCursorNode(mdNode, cursorPos); }); this.eventEmitter.listen('blur', () => { this.removeHighlight(); }); } on(this.el!, 'scroll', (event) => { this.eventEmitter.emit( 'scroll', 'preview', findAdjacentElementToScrollTop(event.target.scrollTop, this.previewContent) ); }); this.eventEmitter.listen('changePreviewTabPreview', () => this.toggleActive(true)); this.eventEmitter.listen('changePreviewTabWrite', () => this.toggleActive(false)); } private removeHighlight() { if (this.cursorNodeId) { const currentEl = this.getElementByNodeId(this.cursorNodeId); if (currentEl) { removeClass(currentEl, CLASS_HIGHLIGHT); } } } private updateCursorNode(cursorNode: MdNode | null, cursorPos: MdPos) { if (cursorNode) { cursorNode = findClosestNode(cursorNode, (mdNode) => !isInlineNode(mdNode))!; if (cursorNode.type === 'tableRow') { cursorNode = findTableCell(cursorNode, cursorPos[1])!; } else if (cursorNode.type === 'tableBody') { // empty line next to table cursorNode = null; } } const cursorNodeId = cursorNode ? cursorNode.id : null; if (this.cursorNodeId === cursorNodeId) { return; } const oldEL = this.getElementByNodeId(this.cursorNodeId); const newEL = this.getElementByNodeId(cursorNodeId); if (oldEL) { removeClass(oldEL, CLASS_HIGHLIGHT); } if (newEL) { addClass(newEL, CLASS_HIGHLIGHT); } this.cursorNodeId = cursorNodeId; } private getElementByNodeId(nodeId: number | null) { return nodeId ? this.previewContent.querySelector(`[data-nodeid="${nodeId}"]`) : null; } update(changed: EditResult[]) { changed.forEach((editResult) => this.replaceRangeNodes(editResult)); this.eventEmitter.emit('afterPreviewRender', this); } replaceRangeNodes(editResult: EditResult) { const { nodes, removedNodeRange } = editResult; const contentEl = this.previewContent; const newHtml = this.eventEmitter.emitReduce( 'beforePreviewRender', this.sanitizer(nodes.map((node) => this.renderer.render(node)).join('')) ); if (!removedNodeRange) { contentEl.insertAdjacentHTML('afterbegin', newHtml); } else { const [startNodeId, endNodeId] = removedNodeRange.id; const startEl = this.getElementByNodeId(startNodeId); const endEl = this.getElementByNodeId(endNodeId); if (startEl) { startEl.insertAdjacentHTML('beforebegin', newHtml); let el = startEl; while (el && el !== endEl) { const nextEl = el.nextElementSibling as HTMLElement; removeNode(el); removeOffsetInfoByNode(el); el = nextEl; } if (el?.parentNode) { removeNode(el); removeOffsetInfoByNode(el); } } } } getRenderer() { return this.renderer; } destroy() { off(this.el!, 'scroll'); this.el = null; } getElement() { return this.el!; } getHTML() { return removeProseMirrorHackNodes(this.previewContent.innerHTML); } setHTML(html: string) { this.previewContent.innerHTML = html; } setHeight(height: number) { css(this.el!, { height: `${height}px` }); } setMinHeight(minHeight: number) { css(this.el!, { minHeight: `${minHeight}px` }); } } export default MarkdownPreview; ================================================ FILE: apps/editor/src/markdown/nodes/doc.ts ================================================ import Node from '@/spec/node'; export class Doc extends Node { get name() { return 'doc'; } get schema() { return { content: 'block+', }; } } ================================================ FILE: apps/editor/src/markdown/nodes/paragraph.ts ================================================ import { DOMOutputSpec, ProsemirrorNode, Schema } from 'prosemirror-model'; import { Transaction, Selection } from 'prosemirror-state'; import { chainCommands, joinForward, Command } from 'prosemirror-commands'; import { EditorCommand, MdSpecContext } from '@t/spec'; import { clsWithMdPrefix } from '@/utils/dom'; import Node from '@/spec/node'; import { isBulletListNode, isOrderedListNode } from '@/utils/markdown'; import { createTextNode, createTextSelection, replaceTextNode } from '@/helper/manipulation'; import { reBlockQuote } from '../marks/blockQuote'; import { getRangeInfo, getNodeContentOffsetRange } from '../helper/pos'; import { getReorderedListInfo, reList, reOrderedListGroup } from '../helper/list'; import { getTextByMdLine, getTextContent } from '../helper/query'; interface SelectionInfo { from: number; to: number; } interface IndentSelectionInfo extends SelectionInfo { type: 'indent'; lineLen: number; } interface OutdentSelectionInfo extends SelectionInfo { type: 'outdent'; spaceLenList: number[]; } const reStartSpace = /(^\s{1,4})(.*)/; function isBlockUnit(from: number, to: number, text: string) { return from < to || reList.test(text) || reBlockQuote.test(text); } function isInTableCellNode(doc: ProsemirrorNode, schema: Schema, selection: Selection) { let $pos = selection.$from; if ($pos.depth === 0) { $pos = doc.resolve($pos.pos - 1); } const node = $pos.node(1); const startOffset = $pos.start(1); const contentSize = node.content.size; return ( node.rangeHasMark(0, contentSize, schema.marks.table) && $pos.pos - startOffset !== contentSize && $pos.pos !== startOffset ); } function createSelection(tr: Transaction, posInfo: IndentSelectionInfo | OutdentSelectionInfo) { let { from, to } = posInfo; if (posInfo.type === 'indent') { const softTabLen = 4; from += softTabLen; to += (posInfo.lineLen + 1) * softTabLen; } else { const { spaceLenList } = posInfo; from -= spaceLenList[0]; for (let i = 0; i < spaceLenList.length; i += 1) { to -= spaceLenList[i]; } } return createTextSelection(tr, from, to); } export class Paragraph extends Node { context!: MdSpecContext; get name() { return 'paragraph'; } get schema() { return { content: 'inline*', attrs: { className: { default: null }, codeStart: { default: null }, codeEnd: { default: null }, }, selectable: false, group: 'block', parseDOM: [{ tag: 'div' }], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return attrs.className ? ['div', { class: clsWithMdPrefix(attrs.className) }, 0] : ['div', 0]; }, }; } private reorderList(startLine: number, endLine: number) { const { view, toastMark, schema } = this.context; const { tr, selection, doc } = view.state; let mdNode = toastMark.findFirstNodeAtLine(startLine); let topListNode = mdNode; while (mdNode && !isBulletListNode(mdNode!) && mdNode.parent!.type !== 'document') { mdNode = mdNode.parent!; if (isOrderedListNode(mdNode!)) { topListNode = mdNode; break; } } if (topListNode) { startLine = topListNode.sourcepos![0][0]; } const [, indent, , start] = reOrderedListGroup.exec(getTextByMdLine(doc, startLine))!; const indentLen = indent.length; const { line, nodes } = getReorderedListInfo(doc, schema, startLine, Number(start), indentLen); endLine = Math.max(endLine, line - 1); let { startOffset } = getNodeContentOffsetRange(doc, startLine - 1); for (let i = startLine - 1; i <= endLine - 1; i += 1) { const { nodeSize, content } = doc.child(i); const mappedFrom = tr.mapping.map(startOffset); const mappedTo = mappedFrom + content.size; tr.replaceWith(mappedFrom, mappedTo, nodes[i - startLine + 1]); startOffset += nodeSize; } const newSelection = createTextSelection(tr, selection.from, selection.to); view.dispatch!(tr.setSelection(newSelection)); } private indent(tabKey = false): EditorCommand { return () => (state, dispatch) => { const { schema, selection, doc } = state; const { from, to, startFromOffset, startIndex, endIndex } = getRangeInfo(selection); if (tabKey && isInTableCellNode(doc, schema, selection)) { return false; } const startLineText = getTextContent(doc, startIndex); if ( (tabKey && isBlockUnit(from, to, startLineText)) || (!tabKey && reList.test(startLineText)) ) { const tr = replaceTextNode({ state, from: startFromOffset, startIndex, endIndex, createText: (textContent) => ` ${textContent}`, }); const posInfo: IndentSelectionInfo = { type: 'indent', from, to, lineLen: endIndex - startIndex, }; dispatch!(tr.setSelection(createSelection(tr, posInfo))); if (reOrderedListGroup.test(startLineText)) { this.reorderList(startIndex + 1, endIndex + 1); } } else if (tabKey) { dispatch!(state.tr.insert(to, createTextNode(schema, ' '))); } return true; }; } private outdent(tabKey = false): EditorCommand { return () => (state, dispatch) => { const { selection, doc, schema } = state; const { from, to, startFromOffset, startIndex, endIndex } = getRangeInfo(selection); if (tabKey && isInTableCellNode(doc, schema, selection)) { return false; } const startLineText = getTextContent(doc, startIndex); if ( (tabKey && isBlockUnit(from, to, startLineText)) || (!tabKey && reList.test(startLineText)) ) { const spaceLenList: number[] = []; const tr = replaceTextNode({ state, from: startFromOffset, startIndex, endIndex, createText: (textContent) => { const searchResult = reStartSpace.exec(textContent); spaceLenList.push(searchResult ? searchResult[1].length : 0); return textContent.replace(reStartSpace, '$2'); }, }); const posInfo: OutdentSelectionInfo = { type: 'outdent', from, to, spaceLenList }; dispatch!(tr.setSelection(createSelection(tr, posInfo))); if (reOrderedListGroup.test(startLineText)) { this.reorderList(startIndex + 1, endIndex + 1); } } else if (tabKey) { const startText = startLineText.slice(0, to - startFromOffset); const startTextWithoutSpace = startText.replace(/\s{1,4}$/, ''); const deletStart = to - (startText.length - startTextWithoutSpace.length); dispatch!(state.tr.delete(deletStart, to)); } return true; }; } private deleteLines(): Command { return (state, dispatch) => { const { view } = this.context; const { startFromOffset, endToOffset } = getRangeInfo(state.selection); const deleteRange: Command = () => { dispatch!(state.tr.deleteRange(startFromOffset, endToOffset)); return true; }; return chainCommands(deleteRange, joinForward)(state, dispatch, view); }; } private moveDown(): Command { return (state, dispatch) => { const { doc, tr, selection, schema } = state; const { startFromOffset, endToOffset, endIndex } = getRangeInfo(selection); if (endIndex < doc.content.childCount - 1) { const { nodeSize, textContent } = doc.child(endIndex + 1); tr.delete(endToOffset, endToOffset + nodeSize) .split(startFromOffset) // subtract 2(start, end tag length) to insert prev line .insert(tr.mapping.map(startFromOffset) - 2, createTextNode(schema, textContent)); dispatch!(tr); return true; } return false; }; } private moveUp(): Command { return (state, dispatch) => { const { tr, doc, selection, schema } = state; const { startFromOffset, endToOffset, startIndex } = getRangeInfo(selection); if (startIndex > 0) { const { nodeSize, textContent } = doc.child(startIndex - 1); tr.delete(startFromOffset - nodeSize, startFromOffset) .split(tr.mapping.map(endToOffset)) .insert(tr.mapping.map(endToOffset), createTextNode(schema, textContent)); dispatch!(tr); return true; } return false; }; } commands() { return { indent: this.indent(), outdent: this.outdent(), }; } keymaps() { return { Tab: this.indent(true)(), 'Shift-Tab': this.outdent(true)(), 'Mod-d': this.deleteLines(), 'Mod-D': this.deleteLines(), 'Alt-ArrowUp': this.moveUp(), 'Alt-ArrowDown': this.moveDown(), }; } } ================================================ FILE: apps/editor/src/markdown/nodes/text.ts ================================================ import Node from '@/spec/node'; export class Text extends Node { get name() { return 'text'; } get schema() { return { group: 'inline', }; } } ================================================ FILE: apps/editor/src/markdown/plugins/helper/markInfo.ts ================================================ import { MdNodeType, MdPos, HeadingMdNode, LinkMdNode, CodeMdNode, MdNode, CodeBlockMdNode, CustomBlockMdNode, ListItemMdNode, } from '@toast-ui/toastmark'; import isFunction from 'tui-code-snippet/type/isFunction'; import { getMdStartLine, getMdStartCh, getMdEndLine, getMdEndCh, addOffsetPos, setOffsetPos, } from '@/utils/markdown'; const HEADING = 'heading'; const BLOCK_QUOTE = 'blockQuote'; const LIST_ITEM = 'listItem'; const TABLE = 'table'; const TABLE_CELL = 'tableCell'; const CODE_BLOCK = 'codeBlock'; const THEMATIC_BREAK = 'thematicBreak'; const LINK = 'link'; const CODE = 'code'; const META = 'meta'; const DELIM = 'delimiter'; const TASK_DELIM = 'taskDelimiter'; const TEXT = 'markedText'; const HTML = 'html'; const CUSTOM_BLOCK = 'customBlock'; const delimSize = { strong: 2, emph: 1, strike: 2, }; type MarkType = | MdNodeType | typeof LIST_ITEM | typeof DELIM | typeof TASK_DELIM | typeof TEXT | typeof HTML | typeof META; export interface MarkInfo { start: MdPos; end: MdPos; spec?: { type?: MarkType; attrs?: Record }; lineBackground?: boolean; } function markInfo(start: MdPos, end: MdPos, type: MarkType, attrs?: Record): MarkInfo { return { start, end, spec: { type, attrs } }; } function heading({ level, headingType }: HeadingMdNode, start: MdPos, end: MdPos) { const marks = [markInfo(start, end, HEADING, { level })]; if (headingType === 'atx') { marks.push(markInfo(start, addOffsetPos(start, level), DELIM)); } else { marks.push(markInfo(setOffsetPos(end, 0), end, HEADING, { seText: true })); } return marks; } function emphasisAndStrikethrough( { type }: { type: keyof typeof delimSize }, start: MdPos, end: MdPos ) { const startDelimPos = addOffsetPos(start, delimSize[type]); const endDelimPos = addOffsetPos(end, -delimSize[type]); return [ markInfo(startDelimPos, endDelimPos, type), markInfo(start, startDelimPos, DELIM), markInfo(endDelimPos, end, DELIM), ]; } function markLink(start: MdPos, end: MdPos, linkTextStart: MdPos, lastChildCh: number) { return [ markInfo(start, end, LINK), markInfo(setOffsetPos(start, linkTextStart[1] + 1), setOffsetPos(end, lastChildCh), LINK, { desc: true, }), markInfo(setOffsetPos(end, lastChildCh + 2), addOffsetPos(end, -1), LINK, { url: true }), ]; } function image({ lastChild }: LinkMdNode, start: MdPos, end: MdPos) { const lastChildCh = lastChild ? getMdEndCh(lastChild) + 1 : 3; // 3: length of '![]' const linkTextEnd = addOffsetPos(start, 1); return [markInfo(start, linkTextEnd, META), ...markLink(start, end, linkTextEnd, lastChildCh)]; } function link({ lastChild, extendedAutolink }: LinkMdNode, start: MdPos, end: MdPos) { const lastChildCh = lastChild ? getMdEndCh(lastChild) + 1 : 2; // 2: length of '[]' return extendedAutolink ? [markInfo(start, end, LINK, { desc: true })] : markLink(start, end, start, lastChildCh); } function code({ tickCount }: CodeMdNode, start: MdPos, end: MdPos) { const openDelimEnd = addOffsetPos(start, tickCount); const closeDelimStart = addOffsetPos(end, -tickCount); return [ markInfo(start, end, CODE), markInfo(start, openDelimEnd, CODE, { start: true }), markInfo(openDelimEnd, closeDelimStart, CODE, { marked: true }), markInfo(closeDelimStart, end, CODE, { end: true }), ]; } function lineBackground(parent: MdNode, start: MdPos, end: MdPos, prefix: string) { const defaultBackground = { start, end, spec: { attrs: { className: `${prefix}-line-background`, codeStart: start[0], codeEnd: end[0] }, }, lineBackground: true, }; return parent!.type !== 'item' && parent!.type !== 'blockQuote' ? [ { ...defaultBackground, end: start, spec: { attrs: { className: `${prefix}-line-background start` } }, }, { ...defaultBackground, start: [Math.min(start[0] + 1, end[0]), start[1]] as MdPos, }, ] : null; } function codeBlock(node: CodeBlockMdNode, start: MdPos, end: MdPos, endLine: string) { const { fenceOffset, fenceLength, fenceChar, info, infoPadding, parent } = node; const fenceEnd = fenceOffset + fenceLength; const marks = [markInfo(setOffsetPos(start, 1), end, CODE_BLOCK)]; if (fenceChar) { marks.push(markInfo(start, addOffsetPos(start, fenceEnd), DELIM)); } if (info) { marks.push( markInfo( addOffsetPos(start, fenceLength), addOffsetPos(start, fenceLength + infoPadding + info.length), META ) ); } const codeBlockEnd = `^(\\s{0,4})(${fenceChar}{${fenceLength},})`; const reCodeBlockEnd = new RegExp(codeBlockEnd); if (reCodeBlockEnd.test(endLine)) { marks.push(markInfo(setOffsetPos(end, 1), end, DELIM)); } const lineBackgroundMarkInfo = lineBackground(parent!, start, end, 'code-block'); return lineBackgroundMarkInfo ? marks.concat(lineBackgroundMarkInfo) : marks; } function customBlock(node: MdNode, start: MdPos, end: MdPos) { const { offset, syntaxLength, info, parent } = node as CustomBlockMdNode; const syntaxEnd = offset + syntaxLength; const marks = [markInfo(setOffsetPos(start, 1), end, CUSTOM_BLOCK)]; marks.push(markInfo(start, addOffsetPos(start, syntaxEnd), DELIM)); if (info) { marks.push( markInfo( addOffsetPos(start, syntaxEnd), addOffsetPos(start, syntaxLength + info.length), META ) ); } marks.push(markInfo(setOffsetPos(end, 1), end, DELIM)); const lineBackgroundMarkInfo = lineBackground(parent!, start, end, 'custom-block'); return lineBackgroundMarkInfo ? marks.concat(lineBackgroundMarkInfo) : marks; } function markListItemChildren(node: MdNode, markType: MarkType) { const marks: MarkInfo[] = []; while (node) { const { type } = node; if (type === 'paragraph' || type === 'codeBlock') { marks.push( markInfo( [getMdStartLine(node), getMdStartCh(node) - 1], [getMdEndLine(node), getMdEndCh(node) + 1], markType ) ); } node = node.next!; } return marks; } function markParagraphInBlockQuote(node: MdNode) { const marks = []; while (node) { marks.push( markInfo( [getMdStartLine(node), getMdStartCh(node)], [getMdEndLine(node), getMdEndCh(node) + 1], TEXT ) ); node = node.next!; } return marks; } function blockQuote(node: MdNode, start: MdPos, end: MdPos) { let marks = node.parent && node.parent.type !== 'blockQuote' ? [markInfo(start, end, BLOCK_QUOTE)] : []; if (node.firstChild) { let childMarks: MarkInfo[] = []; if (node.firstChild.type === 'paragraph') { childMarks = markParagraphInBlockQuote(node.firstChild.firstChild!); } else if (node.firstChild.type === 'list') { childMarks = markListItemChildren(node.firstChild, TEXT); } marks = [...marks, ...childMarks]; } return marks; } function getSpecOfListItemStyle(node: MdNode): [MarkType, Record] { let depth = 0; while (node.parent!.parent && node.parent!.parent.type === 'item') { node = node.parent!.parent; depth += 1; } const attrs = [{ odd: true }, { even: true }][depth % 2]; return [LIST_ITEM, { ...attrs, listStyle: true }]; } function item(node: ListItemMdNode, start: MdPos) { const { padding, task } = node.listData; const spec = getSpecOfListItemStyle(node); const marks = [markInfo(start, addOffsetPos(start, padding), ...spec)]; if (task) { marks.push( markInfo(addOffsetPos(start, padding), addOffsetPos(start, padding + 3), TASK_DELIM) ); marks.push(markInfo(addOffsetPos(start, padding + 1), addOffsetPos(start, padding + 2), META)); } return marks.concat(markListItemChildren(node.firstChild!, TEXT)); } const markNodeFuncMap = { heading, strong: emphasisAndStrikethrough, emph: emphasisAndStrikethrough, strike: emphasisAndStrikethrough, link, image, code, codeBlock, blockQuote, item, customBlock, }; const simpleMarkClassNameMap = { thematicBreak: THEMATIC_BREAK, table: TABLE, tableCell: TABLE_CELL, htmlInline: HTML, } as const; type MarkNodeFuncMapKey = keyof typeof markNodeFuncMap; type SimpleNodeFuncMapKey = keyof typeof simpleMarkClassNameMap; export function getMarkInfo(node: MdNode, start: MdPos, end: MdPos, endLine: string) { const { type } = node; if (isFunction(markNodeFuncMap[type as MarkNodeFuncMapKey])) { // @ts-ignore return markNodeFuncMap[type as MarkNodeFuncMapKey](node, start, end, endLine); } if (simpleMarkClassNameMap[type as SimpleNodeFuncMapKey]) { return [markInfo(start, end, simpleMarkClassNameMap[type as SimpleNodeFuncMapKey])]; } return null; } ================================================ FILE: apps/editor/src/markdown/plugins/previewHighlight.ts ================================================ import { MdNode, MdPos } from '@toast-ui/toastmark'; import { Plugin } from 'prosemirror-state'; import { MdContext } from '@t/spec'; import { ToolbarStateMap, ToolbarStateKeys } from '@t/ui'; import { traverseParentNodes, isListNode } from '@/utils/markdown'; import { includes } from '@/utils/common'; const defaultToolbarStateKeys: ToolbarStateKeys[] = [ 'taskList', 'orderedList', 'bulletList', 'table', 'strong', 'emph', 'strike', 'heading', 'thematicBreak', 'blockQuote', 'code', 'codeBlock', 'indent', 'outdent', ]; function getToolbarStateType(mdNode: MdNode) { const { type } = mdNode; if (isListNode(mdNode)) { if (mdNode.listData.task) { return 'taskList'; } return mdNode.listData.type === 'ordered' ? 'orderedList' : 'bulletList'; } if (type.indexOf('table') !== -1) { return 'table'; } if (!includes(defaultToolbarStateKeys, type)) { return null; } return type as ToolbarStateKeys; } function getToolbarState(targetNode: MdNode) { const toolbarState = { indent: { active: false, disabled: true }, outdent: { active: false, disabled: true }, } as ToolbarStateMap; let listEnabled = true; traverseParentNodes(targetNode, (mdNode) => { const type = getToolbarStateType(mdNode); if (!type) { return; } if (type === 'bulletList' || type === 'orderedList') { // to apply the nearlist list state in the nested list if (listEnabled) { toolbarState[type] = { active: true }; toolbarState.indent.disabled = false; toolbarState.outdent.disabled = false; listEnabled = false; } } else { toolbarState[type as ToolbarStateKeys] = { active: true }; } }); return toolbarState; } export function previewHighlight({ toastMark, eventEmitter }: MdContext) { return new Plugin({ view() { return { update(view, prevState) { const { state } = view; const { doc, selection } = state; if (prevState && prevState.doc.eq(doc) && prevState.selection.eq(selection)) { return; } const { from } = selection; const startChOffset = state.doc.resolve(from).start(); const line = state.doc.content.findIndex(from).index + 1; let ch = from - startChOffset; if (from === startChOffset) { ch += 1; } const cursorPos: MdPos = [line, ch]; const mdNode = toastMark.findNodeAtPosition(cursorPos)!; const toolbarState = getToolbarState(mdNode); eventEmitter.emit('changeToolbarState', { cursorPos, mdNode, toolbarState, }); eventEmitter.emit('setFocusedNode', mdNode); }, }; }, }); } ================================================ FILE: apps/editor/src/markdown/plugins/smartTask.ts ================================================ import { Plugin } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { MdPos } from '@toast-ui/toastmark'; import { MdContext } from '@t/spec'; import { findClosestNode } from '@/utils/markdown'; import { getRangeInfo, getNodeContentOffsetRange } from '../helper/pos'; const reTaskMarkerKey = /x|backspace/i; const reTaskMarker = /^\[(\s*)(x?)(\s*)\](?:\s+)/i; export function smartTask({ schema, toastMark }: MdContext) { return new Plugin({ props: { handleDOMEvents: { keyup: (view: EditorView, ev: KeyboardEvent) => { const { doc, tr, selection } = view.state; if (selection.empty && reTaskMarkerKey.test(ev.key)) { const { startIndex, startFromOffset, from } = getRangeInfo(selection); // should add `1` to line for the markdown parser // because markdown parser has `1`(not zero) as the start number const mdPos: MdPos = [startIndex + 1, from - startFromOffset + 1]; const mdNode = toastMark.findNodeAtPosition(mdPos)!; const paraNode = findClosestNode( mdNode, (node) => node!.type === 'paragraph' && node.parent?.type === 'item' ); if (paraNode?.firstChild?.literal) { const { firstChild } = paraNode; const matched = firstChild.literal!.match(reTaskMarker); if (matched) { const [startMdPos] = firstChild.sourcepos!; const [, startSpaces, stateChar, lastSpaces] = matched; const spaces = startSpaces.length + lastSpaces.length; const { startOffset } = getNodeContentOffsetRange(doc, startMdPos[0] - 1); const startPos = startMdPos[1] + startOffset; if (stateChar) { const addedPos = spaces ? spaces + 1 : 0; tr.replaceWith(startPos, addedPos + startPos, schema.text(stateChar)); view.dispatch(tr); } else if (!spaces) { tr.insertText(' ', startPos); view.dispatch(tr); } } } } return false; }, }, }, }); } ================================================ FILE: apps/editor/src/markdown/plugins/syntaxHighlight.ts ================================================ import { MdNode, MdPos, EditResult, ToastMark } from '@toast-ui/toastmark'; import { Plugin, Transaction } from 'prosemirror-state'; import { NodeType, ProsemirrorNode, Schema } from 'prosemirror-model'; import { MdContext } from '@t/spec'; import { getMdStartLine, getMdEndLine, getMdStartCh, getMdEndCh } from '@/utils/markdown'; import { includes, last } from '@/utils/common'; import { getStartPosListPerLine, getWidgetNodePos } from '@/markdown/helper/pos'; import { getMarkInfo, MarkInfo } from './helper/markInfo'; interface CodeBlockPos { codeStart: number; codeEnd: number; } interface BlockPosInfo { from: number; to: number; startIndex: number; endIndex: number; } let removingBackgroundIndexMap: Record = {}; export function syntaxHighlight({ schema, toastMark }: MdContext) { return new Plugin({ appendTransaction(transactions, _, newState) { const [tr] = transactions; const newTr = newState.tr; if (tr.docChanged) { let markInfo: MarkInfo[] = []; const editResult: EditResult[] = tr.getMeta('editResult'); editResult.forEach((result) => { const { nodes, removedNodeRange } = result; if (nodes.length) { markInfo = markInfo.concat(getMarkForRemoving(newTr, nodes)); for (const parent of nodes) { const walker = parent.walker(); let event = walker.next(); while (event) { const { node, entering } = event; if (entering) { markInfo = markInfo.concat(getMarkForAdding(node, toastMark)); } event = walker.next(); } } } else if (removedNodeRange) { const maxIndex = newTr.doc.childCount - 1; const [startLine, endLine] = removedNodeRange.line; const startIndex = Math.min(startLine, maxIndex); const endIndex = Math.min(endLine, maxIndex); // cache the index to remove code block, custom block background when there are no adding nodes for (let i = startIndex; i <= endIndex; i += 1) { removingBackgroundIndexMap[i] = true; } } }); appendMarkTr(newTr, schema, markInfo); } return newTr.setMeta('widget', tr.getMeta('widget')); }, }); } function isDifferentBlock(doc: ProsemirrorNode, index: number, attrs: Record) { return Object.keys(attrs).some((name) => attrs[name] !== doc.child(index).attrs[name]); } function addLineBackground( tr: Transaction, doc: ProsemirrorNode, paragraph: NodeType, blockPosInfo: BlockPosInfo, attrs: Record = {} ) { const { startIndex, endIndex, from, to } = blockPosInfo; let shouldChangeBlockType = false; for (let i = startIndex; i <= endIndex; i += 1) { // prevent to remove background of the node that need to have background delete removingBackgroundIndexMap[i]; shouldChangeBlockType = isDifferentBlock(doc, i, attrs); } if (shouldChangeBlockType) { tr.setBlockType(from, to, paragraph, attrs); } } function appendMarkTr(tr: Transaction, schema: Schema, marks: MarkInfo[]) { const { doc } = tr; const { paragraph } = schema.nodes; // get start position per line for lazy calculation const startPosListPerLine = getStartPosListPerLine(doc, doc.childCount); marks.forEach(({ start, end, spec, lineBackground }) => { const startIndex = Math.min(start[0], doc.childCount) - 1; const endIndex = Math.min(end[0], doc.childCount) - 1; const startNode = doc.child(startIndex); const endNode = doc.child(endIndex); // calculate the position corresponding to the line let from = startPosListPerLine[startIndex]; let to = startPosListPerLine[endIndex]; // calculate the position corresponding to the character offset of the line from += start[1] + getWidgetNodePos(startNode, start[1] - 1); to += end[1] + getWidgetNodePos(endNode, end[1] - 1); if (spec) { if (lineBackground) { const posInfo = { from, to, startIndex, endIndex }; addLineBackground(tr, doc, paragraph, posInfo, spec.attrs); } else { tr.addMark(from, to, schema.mark(spec.type!, spec.attrs)); } } else { tr.removeMark(from, to); } }); removeBlockBackground(tr, startPosListPerLine, paragraph); } function removeBlockBackground( tr: Transaction, startPosListPerLine: number[], paragraph: NodeType ) { Object.keys(removingBackgroundIndexMap).forEach((index) => { const startIndex = Number(index); // get the end position of the current line with the next node start position. const endIndex = Math.min(Number(index) + 1, tr.doc.childCount - 1); const from = startPosListPerLine[startIndex]; // subtract '1' for getting end position of the line let to = startPosListPerLine[endIndex] - 1; if (startIndex === endIndex) { to += 2; } tr.setBlockType(from, to, paragraph); }); } function cacheIndexToRemoveBackground(doc: ProsemirrorNode, start: MdPos, end: MdPos) { const skipLines: number[] = []; removingBackgroundIndexMap = {}; for (let i = start[0] - 1; i < end[0]; i += 1) { const node = doc.child(i); let { codeEnd } = node.attrs as CodeBlockPos; const { codeStart } = node.attrs as CodeBlockPos; if (codeStart && codeEnd && !includes(skipLines, codeStart)) { skipLines.push(codeStart); codeEnd = Math.min(codeEnd, doc.childCount); // should subtract '1' to markdown line position // because markdown parser has '1'(not zero) as the start number const startIndex = codeStart - 1; const [endIndex] = end; for (let index = startIndex; index < endIndex; index += 1) { removingBackgroundIndexMap[index] = true; } } } } function getMarkForRemoving({ doc }: Transaction, nodes: MdNode[]) { const [start] = nodes[0].sourcepos!; const [, end] = last(nodes).sourcepos!; const startPos: MdPos = [start[0], start[1]]; const endPos: MdPos = [end[0], end[1] + 1]; const marks: MarkInfo[] = []; cacheIndexToRemoveBackground(doc, start, end); marks.push({ start: startPos, end: endPos }); return marks; } function getMarkForAdding(node: MdNode, toastMark: ToastMark) { const lineTexts = toastMark.getLineTexts(); const startPos: MdPos = [getMdStartLine(node), getMdStartCh(node)]; const endPos: MdPos = [getMdEndLine(node), getMdEndCh(node) + 1]; const markInfo = getMarkInfo(node, startPos, endPos, lineTexts[endPos[0] - 1]); return markInfo ?? []; } ================================================ FILE: apps/editor/src/markdown/scroll/animation.ts ================================================ import { SyncCallbacks } from './scrollSync'; // @TODO: apply bezier and raq type WinSetTimeout = typeof window.setTimeout; const ANIMATION_TIME = 100; const SCROLL_BLOCKING_RESET_DELAY = 15; let currentTimeoutId: number | null = null; let releaseTimer: number | null = null; function run(deltaScrollTop: number, { syncScrollTop, releaseEventBlock }: SyncCallbacks) { if (releaseTimer) { clearTimeout(releaseTimer); } syncScrollTop(deltaScrollTop); releaseTimer = (setTimeout as WinSetTimeout)(() => { releaseEventBlock(); }, SCROLL_BLOCKING_RESET_DELAY); } export function animate( curScrollTop: number, targetScrollTop: number, syncCallbacks: SyncCallbacks ) { const diff = targetScrollTop - curScrollTop; const startTime = Date.now(); const step = () => { const stepTime = Date.now(); const progress = (stepTime - startTime) / ANIMATION_TIME; let deltaValue; if (currentTimeoutId) { clearTimeout(currentTimeoutId); } if (progress < 1) { deltaValue = curScrollTop + diff * Math.cos(((1 - progress) * Math.PI) / 2); run(Math.ceil(deltaValue), syncCallbacks); currentTimeoutId = (setTimeout as WinSetTimeout)(step, 1); } else { run(targetScrollTop, syncCallbacks); currentTimeoutId = null; } }; step(); } ================================================ FILE: apps/editor/src/markdown/scroll/dom.ts ================================================ import { ProsemirrorNode } from 'prosemirror-model'; import { MdNode } from '@toast-ui/toastmark'; import { includes } from '@/utils/common'; import { isStyledInlineNode, getMdEndLine, getMdStartLine } from '@/utils/markdown'; type El = HTMLElement | null; const nestableTypes = ['list', 'item', 'blockQuote']; const nestableTagNames = ['UL', 'OL', 'BLOCKQUOTE']; function isBlankLine(doc: ProsemirrorNode, index: number) { const pmNode = doc.child(index); return !pmNode.childCount || (pmNode.childCount === 1 && !pmNode.firstChild!.text?.trim()); } export function getEditorRangeHeightInfo( doc: ProsemirrorNode, mdNode: MdNode, children: HTMLCollection ) { const start = getMdStartLine(mdNode) - 1; const end = getMdEndLine(mdNode) - 1; const rect = (children[start] as HTMLElement).getBoundingClientRect(); const height = (children[end] as HTMLElement).offsetTop - (children[start] as HTMLElement).offsetTop + children[end].clientHeight; return { height: height <= 0 ? children[start].clientHeight : height + getBlankLinesHeight(doc, children, Math.min(end + 1, doc.childCount - 1)), rect, }; } function getBlankLinesHeight(doc: ProsemirrorNode, children: HTMLCollection, start: number) { const end = doc.childCount - 1; let height = 0; while (start <= end && isBlankLine(doc, start)) { height += children[start].clientHeight; start += 1; } return height; } export function findAncestorHavingId(el: HTMLElement, root: HTMLElement) { while (!el.getAttribute('data-nodeid') && el.parentElement !== root) { el = el.parentElement!; } return el; } export function getTotalOffsetTop(el: El, root: HTMLElement) { let offsetTop = 0; while (el && el !== root) { if (!includes(nestableTagNames, el.tagName)) { offsetTop += el.offsetTop; } if (el.offsetParent === root.offsetParent) { break; } el = el.parentElement; } return offsetTop; } export function findAdjacentElementToScrollTop(scrollTop: number, root: HTMLElement) { let el: El = root; let prev = null; while (el) { const { firstElementChild } = el; if (!firstElementChild) { break; } const lastSibling = findLastSiblingElementToScrollTop( firstElementChild as El, scrollTop, getTotalOffsetTop(el, root) ); prev = el; el = lastSibling; } const adjacentEl = el || prev; return adjacentEl === root ? null : adjacentEl; } function findLastSiblingElementToScrollTop(el: El, scrollTop: number, offsetTop: number): El { if (el && scrollTop > offsetTop + el.offsetTop) { return ( findLastSiblingElementToScrollTop(el.nextElementSibling as El, scrollTop, offsetTop) || el ); } return null; } export function getAdditionalPos( scrollTop: number, offsetTop: number, height: number, targetNodeHeight: number ) { const ratio = Math.min((scrollTop - offsetTop) / height, 1); return ratio * targetNodeHeight; } export function getParentNodeObj(previewContent: HTMLElement, mdNode: MdNode) { let el = previewContent.querySelector(`[data-nodeid="${mdNode.id}"]`); while (!el || isStyledInlineNode(mdNode)) { mdNode = mdNode.parent!; el = previewContent.querySelector(`[data-nodeid="${mdNode.id}"]`); } return getNonNestableNodeObj({ mdNode, el }); } function getNonNestableNodeObj({ mdNode, el }: { mdNode: MdNode; el: HTMLElement }) { while ((includes(nestableTypes, mdNode.type) || mdNode.type === 'table') && mdNode.firstChild) { mdNode = mdNode.firstChild; el = el.firstElementChild as HTMLElement; } return { mdNode, el }; } ================================================ FILE: apps/editor/src/markdown/scroll/offset.ts ================================================ import toArray from 'tui-code-snippet/collection/toArray'; import { getTotalOffsetTop } from './dom'; const offsetInfoMap: { [key: number]: { height: number; offsetTop: number } } = {}; export function setHeight(id: number, height: number) { offsetInfoMap[id] = offsetInfoMap[id] || {}; offsetInfoMap[id].height = height; } export function setOffsetTop(id: number, offsetTop: number) { offsetInfoMap[id] = offsetInfoMap[id] || {}; offsetInfoMap[id].offsetTop = offsetTop; } export function getHeight(id: number) { return offsetInfoMap[id] && offsetInfoMap[id].height; } export function getOffsetTop(id: number) { return offsetInfoMap[id] && offsetInfoMap[id].offsetTop; } export function removeOffsetInfoByNode(node: HTMLElement) { if (node) { delete offsetInfoMap[Number(node.getAttribute('data-nodeid'))]; toArray(node.children).forEach((child) => { removeOffsetInfoByNode(child as HTMLElement); }); } } export function getAndSaveOffsetInfo(node: HTMLElement, root: HTMLElement, mdNodeId: number) { const cachedHeight = getHeight(mdNodeId); const cachedTop = getOffsetTop(mdNodeId); const nodeHeight = cachedHeight || node.clientHeight; const offsetTop = cachedTop || getTotalOffsetTop(node, root) || node.offsetTop; if (!cachedHeight) { setHeight(mdNodeId, nodeHeight); } if (!cachedTop) { setOffsetTop(mdNodeId, offsetTop); } return { nodeHeight, offsetTop }; } ================================================ FILE: apps/editor/src/markdown/scroll/scrollSync.ts ================================================ import { ProsemirrorNode } from 'prosemirror-model'; import { EditorView } from 'prosemirror-view'; import { ToastMark } from '@toast-ui/toastmark'; import { Emitter } from '@t/event'; import { isHTMLNode, getMdStartLine } from '@/utils/markdown'; import MarkdownPreview from '../mdPreview'; import MdEditor from '../mdEditor'; import { animate } from './animation'; import { getAndSaveOffsetInfo } from './offset'; import { getAdditionalPos, findAncestorHavingId, getEditorRangeHeightInfo, getParentNodeObj, getTotalOffsetTop, } from './dom'; const EDITOR_BOTTOM_PADDING = 18; export interface SyncCallbacks { syncScrollTop: (scrollTop: number) => void; releaseEventBlock: () => void; } interface PosInfo { pos: number; inside: number; } type ScrollFrom = 'editor' | 'preview'; export class ScrollSync { private previewRoot: HTMLElement; private previewEl: HTMLElement; private editorView: EditorView; private toastMark: ToastMark; private eventEmitter: Emitter; private latestEditorScrollTop: number | null = null; private latestPreviewScrollTop: number | null = null; private blockedScroll: ScrollFrom | null = null; private active = true; private mdEditor: MdEditor; private timer: NodeJS.Timeout | null = null; constructor(mdEditor: MdEditor, preview: MarkdownPreview, eventEmitter: Emitter) { const { previewContent: previewRoot, el: previewEl } = preview; this.previewRoot = previewRoot; this.previewEl = previewEl!; this.mdEditor = mdEditor; this.editorView = mdEditor.view; this.toastMark = mdEditor.getToastMark(); this.eventEmitter = eventEmitter; this.addScrollSyncEvent(); } private addScrollSyncEvent() { this.eventEmitter.listen('afterPreviewRender', () => { this.clearTimer(); // Immediately after the 'afterPreviewRender' event has occurred, // browser rendering is not yet complete. // So the size of elements can not be accurately measured. this.timer = setTimeout(() => { this.syncPreviewScrollTop(true); }, 200); }); this.eventEmitter.listen('scroll', (type, data) => { if (this.active) { if (type === 'editor' && this.blockedScroll !== 'editor') { this.syncPreviewScrollTop(); } else if (type === 'preview' && this.blockedScroll !== 'preview') { this.syncEditorScrollTop(data); } } }); this.eventEmitter.listen('toggleScrollSync', (active: boolean) => { this.active = active; }); } private getMdNodeAtPos(doc: ProsemirrorNode, posInfo: PosInfo) { const indexInfo = doc.content.findIndex(posInfo.pos); const line = indexInfo.index; return this.toastMark.findFirstNodeAtLine(line + 1); } private getScrollTopByCaretPos() { const pos = this.mdEditor.getSelection(); const firstMdNode = this.toastMark.findFirstNodeAtLine(pos[0][0])!; const previewHeight = this.previewEl.clientHeight; const { el } = getParentNodeObj(this.previewRoot, firstMdNode); const totalOffsetTop = getTotalOffsetTop(el, this.previewRoot) || el.offsetTop; const nodeHeight = el.clientHeight; // multiply 0.5 for calculating the position in the middle of preview area const targetScrollTop = totalOffsetTop + nodeHeight - previewHeight * 0.5; this.latestEditorScrollTop = null; const diff = el.getBoundingClientRect().top - this.previewEl.getBoundingClientRect().top; return diff < previewHeight ? null : targetScrollTop; } private syncPreviewScrollTop(editing = false) { const { editorView, previewEl, previewRoot } = this; const { left, top } = editorView.dom.getBoundingClientRect(); const posInfo = editorView.posAtCoords({ left, top })!; const { doc } = editorView.state; const firstMdNode = this.getMdNodeAtPos(doc, posInfo); if (!firstMdNode || isHTMLNode(firstMdNode)) { return; } const curScrollTop = previewEl.scrollTop; const { scrollTop, scrollHeight, clientHeight, children } = editorView.dom; const isBottomPos = scrollHeight - scrollTop <= clientHeight + EDITOR_BOTTOM_PADDING; let targetScrollTop = isBottomPos ? previewEl.scrollHeight : 0; if (scrollTop && !isBottomPos) { if (editing) { const scrollTopByEditing = this.getScrollTopByCaretPos(); if (!scrollTopByEditing) { return; } targetScrollTop = scrollTopByEditing; } else { const { el, mdNode } = getParentNodeObj(this.previewRoot, firstMdNode); const { height, rect } = getEditorRangeHeightInfo(doc, mdNode, children); const totalOffsetTop = getTotalOffsetTop(el, previewRoot) || el.offsetTop; const nodeHeight = el.clientHeight; const ratio = top > rect.top ? Math.min((top - rect.top) / height, 1) : 0; targetScrollTop = totalOffsetTop + nodeHeight * ratio; } targetScrollTop = this.getResolvedScrollTop( 'editor', scrollTop, targetScrollTop, curScrollTop ); this.latestEditorScrollTop = scrollTop; } if (targetScrollTop !== curScrollTop) { this.run('editor', targetScrollTop, curScrollTop); } } syncEditorScrollTop(targetNode: HTMLElement) { const { toastMark, editorView, previewRoot, previewEl } = this; const { dom, state } = editorView; const { scrollTop, clientHeight, scrollHeight } = previewEl; const isBottomPos = scrollHeight - scrollTop <= clientHeight; const curScrollTop = dom.scrollTop; let targetScrollTop = isBottomPos ? dom.scrollHeight : 0; if (scrollTop && targetNode && !isBottomPos) { targetNode = findAncestorHavingId(targetNode, previewRoot); if (!targetNode.getAttribute('data-nodeid')) { return; } const { children } = dom; const mdNodeId = Number(targetNode.getAttribute('data-nodeid')); const { mdNode, el } = getParentNodeObj(this.previewRoot, toastMark.findNodeById(mdNodeId)!); const mdNodeStartLine = getMdStartLine(mdNode); targetScrollTop = (children[mdNodeStartLine - 1] as HTMLElement).offsetTop; const { height } = getEditorRangeHeightInfo(state.doc, mdNode, children); const { nodeHeight, offsetTop } = getAndSaveOffsetInfo(el, previewRoot, mdNodeId); targetScrollTop += getAdditionalPos(scrollTop, offsetTop, nodeHeight, height); targetScrollTop = this.getResolvedScrollTop( 'preview', scrollTop, targetScrollTop, curScrollTop ); this.latestPreviewScrollTop = scrollTop; } if (targetScrollTop !== curScrollTop) { this.run('preview', targetScrollTop, curScrollTop); } } private getResolvedScrollTop( from: ScrollFrom, scrollTop: number, targetScrollTop: number, curScrollTop: number ) { const latestScrollTop = from === 'editor' ? this.latestEditorScrollTop : this.latestPreviewScrollTop; if (latestScrollTop === null) { return targetScrollTop; } return latestScrollTop < scrollTop ? Math.max(targetScrollTop, curScrollTop) : Math.min(targetScrollTop, curScrollTop); } private run(from: ScrollFrom, targetScrollTop: number, curScrollTop: number) { let scrollTarget: Element; if (from === 'editor') { scrollTarget = this.previewEl; this.blockedScroll = 'preview'; } else { scrollTarget = this.editorView.dom; this.blockedScroll = 'editor'; } const syncCallbacks: SyncCallbacks = { syncScrollTop: (scrollTop) => (scrollTarget.scrollTop = scrollTop), releaseEventBlock: () => (this.blockedScroll = null), }; animate(curScrollTop, targetScrollTop, syncCallbacks); } clearTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } destroy() { this.clearTimer(); this.eventEmitter.removeEventHandler('scroll'); this.eventEmitter.removeEventHandler('afterPreviewRender'); } } ================================================ FILE: apps/editor/src/plugins/dropImage.ts ================================================ import { Plugin } from 'prosemirror-state'; import forEachArray from 'tui-code-snippet/collection/forEachArray'; import { Context } from '@t/spec'; import { emitImageBlobHook } from '@/helper/image'; export function dropImage({ eventEmitter }: Context) { return new Plugin({ props: { handleDOMEvents: { drop: (_, ev) => { const items = (ev as DragEvent).dataTransfer?.files; if (items) { forEachArray(items, (item) => { if (item.type.indexOf('image') !== -1) { ev.preventDefault(); ev.stopPropagation(); emitImageBlobHook(eventEmitter, item, ev.type); return false; } return true; }); } return true; }, }, }, }); } ================================================ FILE: apps/editor/src/plugins/placeholder.ts ================================================ import { Plugin } from 'prosemirror-state'; import { DecorationSet, Decoration } from 'prosemirror-view'; import addClass from 'tui-code-snippet/domUtil/addClass'; interface Options { text?: string; className?: string; } export function placeholder(options: Options) { return new Plugin({ props: { decorations(state) { const { doc } = state; if ( options.text && doc.childCount === 1 && doc.firstChild!.isTextblock && doc.firstChild!.content.size === 0 ) { const placeHolder = document.createElement('span'); addClass(placeHolder, 'placeholder'); if (options.className) { addClass(placeHolder, options.className); } placeHolder.textContent = options.text; return DecorationSet.create(doc, [Decoration.widget(1, placeHolder)]); } return null; }, }, }); } ================================================ FILE: apps/editor/src/plugins/popupWidget.ts ================================================ import { EditorView } from 'prosemirror-view'; import { Plugin, PluginKey } from 'prosemirror-state'; import css from 'tui-code-snippet/domUtil/css'; import { closest, cls } from '@/utils/dom'; import { WidgetStyle } from '@t/editor'; import { Emitter } from '@t/event'; interface Widget { node: HTMLElement; style: WidgetStyle; pos: number; } const pluginKey = new PluginKey('widget'); const MARGIN = 5; class PopupWidget { private popup: HTMLElement | null = null; private eventEmitter: Emitter; private rootEl!: HTMLElement; constructor(view: EditorView, eventEmitter: Emitter) { this.rootEl = view.dom.parentElement!; this.eventEmitter = eventEmitter; this.eventEmitter.listen('blur', this.removeWidget); this.eventEmitter.listen('loadUI', () => { this.rootEl = closest(view.dom.parentElement!, `.${cls('defaultUI')}`) as HTMLElement; }); this.eventEmitter.listen('removePopupWidget', this.removeWidget); } private removeWidget = () => { if (this.popup) { this.rootEl.removeChild(this.popup); this.popup = null; } }; update(view: EditorView) { const widget: Widget | null = pluginKey.getState(view.state); this.removeWidget(); if (widget) { const { node, style } = widget; const { top, left, bottom } = view.coordsAtPos(widget.pos); const height = bottom - top; const rect = this.rootEl.getBoundingClientRect(); const relTopPos = top - rect.top; css(node, { opacity: '0' }); this.rootEl.appendChild(node); css(node, { position: 'absolute', left: `${left - rect.left + MARGIN}px`, top: `${style === 'bottom' ? relTopPos + height - MARGIN : relTopPos - height}px`, opacity: '1', }); this.popup = node; view.focus(); } } destroy() { this.eventEmitter.removeEventHandler('blur', this.removeWidget); } } export function addWidget(eventEmitter: Emitter) { return new Plugin({ key: pluginKey, state: { init() { return null; }, apply(tr) { return tr.getMeta('widget'); }, }, view(editorView) { return new PopupWidget(editorView, eventEmitter); }, }); } ================================================ FILE: apps/editor/src/queries/queryManager.ts ================================================ import type { EditorCore as Editor } from '@t/editor'; type QueryFn = (editor: Editor, payload?: Record) => any; const queryMap: Record = { getPopupInitialValues(editor, payload) { const { popupName } = payload!; return popupName === 'link' ? { linkText: editor.getSelectedText() } : {}; }, }; export function buildQuery(editor: Editor) { editor.eventEmitter.listen('query', (query: string, payload?: Record) => queryMap[query](editor, payload) ); } ================================================ FILE: apps/editor/src/sanitizer/htmlSanitizer.ts ================================================ import DOMPurify from 'dompurify'; import { includes } from '@/utils/common'; const CAN_BE_WHITE_TAG_LIST = ['iframe', 'embed']; const whiteTagList: string[] = []; export function registerTagWhitelistIfPossible(tagName: string) { if (includes(CAN_BE_WHITE_TAG_LIST, tagName)) { whiteTagList.push(tagName.toLowerCase()); } } export function sanitizeHTML( html: string | Node, options?: DOMPurify.Config ) { return DOMPurify.sanitize(html, { ADD_TAGS: whiteTagList, ADD_ATTR: ['rel', 'target', 'hreflang', 'type'], FORBID_TAGS: [ 'input', 'script', 'textarea', 'form', 'button', 'select', 'meta', 'style', 'link', 'title', 'object', 'base', ], ...options, }) as T; } ================================================ FILE: apps/editor/src/spec/mark.ts ================================================ import { Keymap } from 'prosemirror-commands'; import { MarkSpec } from 'prosemirror-model'; import { SpecContext, EditorCommand, EditorCommandMap } from '@t/spec'; export default abstract class Mark { context!: SpecContext; get type() { return 'mark'; } setContext(context: SpecContext) { this.context = context; } abstract get name(): string; abstract get schema(): MarkSpec; commands?(): EditorCommand | EditorCommandMap; keymaps?(): Keymap; } ================================================ FILE: apps/editor/src/spec/node.ts ================================================ import { Keymap } from 'prosemirror-commands'; import { NodeSpec } from 'prosemirror-model'; import { SpecContext, EditorCommand, EditorCommandMap } from '@t/spec'; export default abstract class Node { context!: SpecContext; get type() { return 'node'; } setContext(context: SpecContext) { this.context = context; } abstract get name(): string; abstract get schema(): NodeSpec; commands?(): EditorCommand | EditorCommandMap; keymaps?(): Keymap; } ================================================ FILE: apps/editor/src/spec/specManager.ts ================================================ import { EditorView } from 'prosemirror-view'; import { keymap } from 'prosemirror-keymap'; import { EditorAllCommandMap, SpecContext, EditorCommand } from '@t/spec'; import isFunction from 'tui-code-snippet/type/isFunction'; import { getDefaultCommands } from '@/commands/defaultCommands'; import { includes } from '@/utils/common'; import Mark from '@/spec/mark'; import Node from '@/spec/node'; type Spec = Node | Mark; const defaultCommandShortcuts = [ 'Enter', 'Shift-Enter', 'Mod-Enter', 'Tab', 'Shift-Tab', 'Delete', 'Backspace', 'Mod-Delete', 'Mod-Backspace', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Mod-d', 'Mod-D', 'Alt-ArrowUp', 'Alt-ArrowDown', ]; export function execCommand( view: EditorView, command: EditorCommand, payload?: Record ) { view.focus(); return command(payload)(view.state, view.dispatch, view); } export default class SpecManager { private specs: Spec[]; constructor(specs: Spec[]) { this.specs = specs; } get nodes() { return this.specs .filter((spec) => spec.type === 'node') .reduce((nodes, { name, schema }) => { return { ...nodes, [name]: schema, }; }, {}); } get marks() { return this.specs .filter((spec) => spec.type === 'mark') .reduce((marks, { name, schema }) => { return { ...marks, [name]: schema, }; }, {}); } commands(view: EditorView, addedCommands?: Record) { const specCommands: EditorAllCommandMap = this.specs .filter(({ commands }) => commands) .reduce((allCommands, spec) => { const commands: EditorAllCommandMap = {}; const specCommand = spec.commands!(); if (isFunction(specCommand)) { commands[spec.name] = (payload) => execCommand(view, specCommand, payload); } else { Object.keys(specCommand).forEach((name) => { commands[name] = (payload) => execCommand(view, specCommand[name], payload); }); } return { ...allCommands, ...commands, }; }, {}); const defaultCommands = getDefaultCommands(); Object.keys(defaultCommands).forEach((name) => { specCommands[name] = (payload) => execCommand(view, defaultCommands[name], payload); }); if (addedCommands) { Object.keys(addedCommands).forEach((name) => { specCommands[name] = (payload) => execCommand(view, addedCommands[name], payload); }); } return specCommands; } keymaps(useCommandShortcut: boolean) { const specKeymaps = this.specs.filter((spec) => spec.keymaps).map((spec) => spec.keymaps!()); return specKeymaps.map((keys) => { if (!useCommandShortcut) { Object.keys(keys).forEach((key) => { if (!includes(defaultCommandShortcuts, key)) { delete keys[key]; } }); } return keymap(keys); }); } setContext(context: SpecContext) { this.specs.forEach((spec) => { spec.setContext(context); }); } } ================================================ FILE: apps/editor/src/ui/components/contextMenu.ts ================================================ import { ContextMenuItem, ExecCommand, Pos, VNode } from '@t/ui'; import { Emitter } from '@t/event'; import { closest, cls } from '@/utils/dom'; import html from '../vdom/template'; import { Component } from '../vdom/component'; interface State { pos: Pos | null; menuGroups: ContextMenuItem[][]; } interface Props { eventEmitter: Emitter; execCommand: ExecCommand; } export class ContextMenu extends Component { constructor(props: Props) { super(props); this.state = { pos: null, menuGroups: [], }; this.addEvent(); } addEvent() { this.props.eventEmitter.listen('contextmenu', ({ pos, menuGroups }) => { this.setState({ pos, menuGroups }); }); } mounted() { document.addEventListener('click', this.handleClickDocument); } beforeDestroy() { document.removeEventListener('click', this.handleClickDocument); } private handleClickDocument = (ev: MouseEvent) => { if (!closest(ev.target as HTMLElement, `.${cls('context-menu')}`)) { this.setState({ pos: null }); } }; private getMenuGroupElements() { const { pos, menuGroups } = this.state; return pos ? menuGroups.reduce((acc, group) => { const menuItem: VNode[] = []; group.forEach(({ label, className = false, disabled, onClick }) => { const handleClick = () => { if (!disabled) { onClick!(); this.setState({ pos: null }); } }; menuItem.push( html` ` ); }); acc.push( html`` ); return acc; }, [] as VNode[]) : []; } render() { const style = { display: this.state.pos ? 'block' : 'none', ...this.state.pos }; return html`
                      ${this.getMenuGroupElements()}
                      `; } } ================================================ FILE: apps/editor/src/ui/components/layout.ts ================================================ import { EditorType, PreviewStyle } from '@t/editor'; import { Emitter } from '@t/event'; import { IndexList, ToolbarItem, ToolbarItemOptions } from '@t/ui'; import { cls } from '@/utils/dom'; import html from '../vdom/template'; import { Component } from '../vdom/component'; import { Switch } from './switch'; import { Toolbar } from './toolbar/toolbar'; import { ContextMenu } from './contextMenu'; interface Props { eventEmitter: Emitter; hideModeSwitch: boolean; slots: { mdEditor: HTMLElement; mdPreview: HTMLElement; wwEditor: HTMLElement; }; previewStyle: PreviewStyle; editorType: EditorType; toolbarItems: ToolbarItem[]; theme: string; } interface State { editorType: EditorType; previewStyle: PreviewStyle; hide: boolean; } export class Layout extends Component { private toolbar!: Toolbar; constructor(props: Props) { super(props); const { editorType, previewStyle } = props; this.state = { editorType, previewStyle, hide: false, }; this.addEvent(); } mounted() { const { wwEditor, mdEditor, mdPreview } = this.props.slots; this.refs.wwContainer.appendChild(wwEditor); this.refs.mdContainer.insertAdjacentElement('afterbegin', mdEditor); this.refs.mdContainer.appendChild(mdPreview); } insertToolbarItem(indexList: IndexList, item: string | ToolbarItemOptions) { this.toolbar.insertToolbarItem(indexList, item); } removeToolbarItem(name: string) { this.toolbar.removeToolbarItem(name); } render() { const { eventEmitter, hideModeSwitch, toolbarItems, theme } = this.props; const { hide, previewStyle, editorType } = this.state; const displayClassName = hide ? ' hidden' : ''; const editorTypeClassName = cls(editorType === 'markdown' ? 'md-mode' : 'ww-mode'); const previewClassName = `${cls('md')}-${previewStyle}-style`; const themeClassName = cls([theme !== 'light', `${theme} `]); return html`
                      (this.refs.el = el)} > <${Toolbar} ref=${(toolbar: Toolbar) => (this.toolbar = toolbar)} eventEmitter=${eventEmitter} previewStyle=${previewStyle} toolbarItems=${toolbarItems} editorType=${editorType} />
                      (this.refs.editorSection = el)} >
                      (this.refs.mdContainer = el)} >
                      (this.refs.wwContainer = el)} />
                      ${!hideModeSwitch && html`<${Switch} eventEmitter=${eventEmitter} editorType=${editorType} />`} <${ContextMenu} eventEmitter=${eventEmitter} />
                      `; } addEvent() { const { eventEmitter } = this.props; eventEmitter.listen('hide', this.hide); eventEmitter.listen('show', this.show); eventEmitter.listen('changeMode', this.changeMode); eventEmitter.listen('changePreviewStyle', this.changePreviewStyle); } private changeMode = (editorType: EditorType) => { if (editorType !== this.state.editorType) { this.setState({ editorType }); } }; private changePreviewStyle = (previewStyle: PreviewStyle) => { if (previewStyle !== this.state.previewStyle) { this.setState({ previewStyle }); } }; private hide = () => { this.setState({ hide: true }); }; private show = () => { this.setState({ hide: false }); }; } ================================================ FILE: apps/editor/src/ui/components/popup.ts ================================================ import { ExecCommand, HidePopup, PopupInfo, Pos } from '@t/ui'; import { Emitter } from '@t/event'; import { closest, cls } from '@/utils/dom'; import { shallowEqual } from '@/utils/common'; import html from '../vdom/template'; import { Component } from '../vdom/component'; type PopupStyle = { display: 'none' | 'block'; } & Partial; interface Props { show: boolean; info: PopupInfo; eventEmitter: Emitter; hidePopup: HidePopup; execCommand: ExecCommand; } interface State { popupPos: Pos | null; } const MARGIN_FROM_RIGHT_SIDE = 20; export class Popup extends Component { private handleMousedown = (ev: MouseEvent) => { if ( !closest(ev.target as HTMLElement, `.${cls('popup')}`) && !closest(ev.target as HTMLElement, this.props.info.fromEl) ) { this.props.hidePopup(); } }; mounted() { document.addEventListener('mousedown', this.handleMousedown); this.props.eventEmitter.listen('closePopup', this.props.hidePopup); } beforeDestroy() { document.removeEventListener('mousedown', this.handleMousedown); } updated(prevProps: Props) { const { show, info } = this.props; if (show && info.pos && prevProps.show !== show) { const popupPos = { ...info.pos }; const { offsetWidth } = this.refs.el; const toolbarEl = closest(this.refs.el, `.${cls('toolbar')}`) as HTMLElement; const { offsetWidth: toolbarOffsetWidth } = toolbarEl; if (popupPos.left + offsetWidth >= toolbarOffsetWidth) { popupPos.left = toolbarOffsetWidth - offsetWidth - MARGIN_FROM_RIGHT_SIDE; } if (!shallowEqual(this.state.popupPos, popupPos)) { this.setState({ popupPos }); } } } render() { const { info, show, hidePopup, eventEmitter, execCommand } = this.props; const { className = '', style, render, initialValues = {} } = info || {}; const popupStyle: PopupStyle = { display: show ? 'block' : 'none', ...style, ...this.state.popupPos, }; return html`
                      (this.refs.el = el)} aria-role="dialog" >
                      ${render && render({ eventEmitter, show, hidePopup, execCommand, initialValues })}
                      `; } } ================================================ FILE: apps/editor/src/ui/components/switch.ts ================================================ import { Emitter } from '@t/event'; import { EditorType } from '@t/editor'; import i18n from '@/i18n/i18n'; import { cls } from '@/utils/dom'; import html from '../vdom/template'; import { Component } from '../vdom/component'; interface Props { editorType: EditorType; eventEmitter: Emitter; } interface State { hide: boolean; } export class Switch extends Component { constructor(props: Props) { super(props); this.state = { hide: false, }; } show() { this.setState({ hide: false }); } hide() { this.setState({ hide: true }); } render() { const { editorType, eventEmitter } = this.props; return html`
                      { eventEmitter.emit('needChangeMode', 'markdown'); }} > ${i18n.get('Markdown')}
                      { eventEmitter.emit('needChangeMode', 'wysiwyg'); }} > ${i18n.get('WYSIWYG')}
                      `; } } ================================================ FILE: apps/editor/src/ui/components/tabs.ts ================================================ import { TabInfo } from '@t/ui'; import i18n from '@/i18n/i18n'; import { cls } from '@/utils/dom'; import html from '../vdom/template'; import { Component } from '../vdom/component'; interface Props { tabs: TabInfo[]; activeTab: string; onClick: (ev: MouseEvent, activeTab: string) => void; } export class Tabs extends Component { private toggleTab(ev: MouseEvent, activeTab: string) { this.props.onClick(ev, activeTab); } render() { return html`
                      ${this.props.tabs.map(({ name, text }) => { const isActive = this.props.activeTab === name; return html`
                      this.toggleTab(ev, name)} aria-role="tab" aria-label="${i18n.get(text)}" aria-selected="${isActive ? 'true' : 'false'}" tabindex="${isActive ? '0' : '-1'}" > ${i18n.get(text)}
                      `; })}
                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/buttonHoc.ts ================================================ import css from 'tui-code-snippet/domUtil/css'; import { ExecCommand, SetPopupInfo, ToolbarItemInfo, SetItemWidth, ComponentClass, ToolbarButtonInfo, ToolbarStateMap, } from '@t/ui'; import { Emitter } from '@t/event'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import { closest, cls, getTotalOffset } from '@/utils/dom'; interface Props { tooltipRef: { current: HTMLElement }; disabled: boolean; eventEmitter: Emitter; item: ToolbarItemInfo; execCommand: ExecCommand; setPopupInfo: SetPopupInfo; setItemWidth?: SetItemWidth; } interface Payload { toolbarState: ToolbarStateMap; } interface State { active: boolean; disabled: boolean; } const TOOLTIP_INDENT = 6; export function connectHOC(WrappedComponent: ComponentClass) { return class ButtonHOC extends Component { constructor(props: Props) { super(props); this.state = { active: false, disabled: props.disabled }; this.addEvent(); } private addEvent() { const { item, eventEmitter } = this.props; if (item.state) { eventEmitter.listen('changeToolbarState', ({ toolbarState }: Payload) => { const { active, disabled } = toolbarState[item.state!] ?? {}; this.setState({ active: !!active, disabled: disabled ?? this.props.disabled }); }); } } private getBound(el: HTMLElement) { const { offsetLeft, offsetTop } = getTotalOffset( el, closest(el, `.${cls('toolbar')}`) as HTMLElement ); return { left: offsetLeft, top: el.offsetHeight + offsetTop }; } private showTooltip = (el: HTMLElement) => { const { tooltip } = this.props.item as ToolbarButtonInfo; if (!this.props.disabled && tooltip) { const bound = this.getBound(el); const left = `${bound.left + TOOLTIP_INDENT}px`; const top = `${bound.top + TOOLTIP_INDENT}px`; css(this.props.tooltipRef.current, { display: 'block', left, top }); this.props.tooltipRef.current.querySelector('.text')!.textContent = tooltip; } }; private hideTooltip = () => { css(this.props.tooltipRef.current, 'display', 'none'); }; render() { return html` <${WrappedComponent} ...${this.props} active=${this.state.active} showTooltip=${this.showTooltip} hideTooltip=${this.hideTooltip} getBound=${this.getBound} disabled=${this.state.disabled || this.props.disabled} /> `; } }; } ================================================ FILE: apps/editor/src/ui/components/toolbar/customPopupBody.ts ================================================ import { ExecCommand, HidePopup } from '@t/ui'; import { Emitter } from '@t/event'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; interface Props { body: HTMLElement; show: boolean; eventEmitter: Emitter; execCommand: ExecCommand; hidePopup: HidePopup; } export class CustomPopupBody extends Component { mounted() { // append the custom popup body element this.refs.el.appendChild(this.props.body); } updated(prevProps: Props) { // update custom popup element this.refs.el.replaceChild(this.props.body, prevProps.body); } render() { return html`
                      (this.refs.el = el)}>
                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/customToolbarItem.ts ================================================ import { ExecCommand, SetPopupInfo, SetItemWidth, GetBound, HideTooltip, ShowTooltip, ToolbarCustomOptions, } from '@t/ui'; import { Emitter } from '@t/event'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import { cls, getOuterWidth } from '@/utils/dom'; import { createPopupInfo } from '@/ui/toolbarItemFactory'; import { connectHOC } from './buttonHoc'; interface Props { disabled: boolean; eventEmitter: Emitter; item: ToolbarCustomOptions; active: boolean; execCommand: ExecCommand; setPopupInfo: SetPopupInfo; showTooltip: ShowTooltip; hideTooltip: HideTooltip; getBound: GetBound; setItemWidth?: SetItemWidth; } class CustomToolbarItemComp extends Component { mounted() { const { setItemWidth, item } = this.props; // append the custom html element this.refs.el.appendChild(item.el!); // set width only if it is not a dropdown toolbar if (setItemWidth) { setItemWidth(item.name, getOuterWidth(this.refs.el)); } if (item.onMounted) { item.onMounted(this.props.execCommand); } } updated(prevProps: Props) { const { item, active, disabled } = this.props; if (prevProps.active !== active || prevProps.disabled !== disabled) { item.onUpdated?.({ active, disabled }); } } private showTooltip = () => { this.props.showTooltip(this.refs.el); }; private showPopup = () => { const info = createPopupInfo('customPopupBody', { el: this.refs.el, pos: this.props.getBound(this.refs.el), popup: this.props.item.popup!, }); if (info) { this.props.setPopupInfo(info); } }; render() { const { disabled, item } = this.props; const style = { display: item.hidden ? 'none' : 'inline-block' }; const getListener = (listener: Function) => (disabled ? null : listener); return html`
                      (this.refs.el = el)} style=${style} class=${cls('toolbar-item-wrapper')} onClick=${getListener(this.showPopup)} onMouseover=${getListener(this.showTooltip)} onMouseout=${getListener(this.props.hideTooltip)} >
                      `; } } export const CustomToolbarItem = connectHOC(CustomToolbarItemComp); ================================================ FILE: apps/editor/src/ui/components/toolbar/dropdownToolbarButton.ts ================================================ import { ExecCommand, SetPopupInfo, ToolbarItemInfo, GetBound, HideTooltip, ShowTooltip, ToolbarButtonInfo, } from '@t/ui'; import { Emitter } from '@t/event'; import { closest, cls } from '@/utils/dom'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import { ToolbarGroup } from './toolbarGroup'; import { connectHOC } from './buttonHoc'; interface Props { disabled: boolean; eventEmitter: Emitter; item: ToolbarButtonInfo; items: ToolbarItemInfo[]; execCommand: ExecCommand; setPopupInfo: SetPopupInfo; showTooltip: ShowTooltip; hideTooltip: HideTooltip; getBound: GetBound; } interface State { dropdownPos: { right: number; top: number } | null; showDropdown: boolean; } const POPUP_INDENT = 4; class DropdownToolbarButtonComp extends Component { constructor(props: Props) { super(props); this.state = { showDropdown: false, dropdownPos: null }; } private getBound() { const rect = this.props.getBound(this.refs.el); rect.top += POPUP_INDENT; return { ...rect, left: null, right: 10 }; } private handleClickDocument = ({ target }: MouseEvent) => { if ( !closest(target as HTMLElement, `.${cls('dropdown-toolbar')}`) && !closest(target as HTMLElement, '.more') ) { this.setState({ showDropdown: false, dropdownPos: null }); } }; mounted() { document.addEventListener('click', this.handleClickDocument); } updated() { if (this.state.showDropdown && !this.state.dropdownPos) { this.setState({ dropdownPos: this.getBound() }); } } beforeDestroy() { document.removeEventListener('click', this.handleClickDocument); } private showTooltip = () => { this.props.showTooltip(this.refs.el); }; render() { const { showDropdown, dropdownPos } = this.state; const { disabled, item, items, hideTooltip } = this.props; const visibleItems = items.filter((dropdownItem) => !dropdownItem.hidden); const groupStyle = visibleItems.length ? null : { display: 'none' }; const dropdownStyle = showDropdown ? null : { display: 'none' }; return html`
                      (this.refs.dropdownEl = el)} > ${visibleItems.length ? visibleItems.map( (group, index) => html` <${ToolbarGroup} group=${group} hiddenDivider=${index === visibleItems.length - 1 || (visibleItems as ToolbarButtonInfo[])[index + 1]?.hidden} ...${this.props} /> ` ) : null}
                      `; } } export const DropdownToolbarButton = connectHOC(DropdownToolbarButtonComp); ================================================ FILE: apps/editor/src/ui/components/toolbar/headingPopupBody.ts ================================================ import { Emitter } from '@t/event'; import { ExecCommand } from '@t/ui'; import { closest } from '@/utils/dom'; import i18n from '@/i18n/i18n'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; interface Props { eventEmitter: Emitter; execCommand: ExecCommand; } export class HeadingPopupBody extends Component { execCommand(ev: MouseEvent) { const el = closest(ev.target as HTMLElement, 'li')! as HTMLElement; this.props.execCommand('heading', { level: Number(el.getAttribute('data-level')), }); } render() { return html`
                        this.execCommand(ev)} aria-role="menu" aria-label="${i18n.get('Headings')}" > ${[1, 2, 3, 4, 5, 6].map( (level) => html`
                      • <${`h${level}`}>${i18n.get('Heading')} ${level}
                      • ` )}
                      • ${i18n.get('Paragraph')}
                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/imagePopupBody.ts ================================================ import removeClass from 'tui-code-snippet/domUtil/removeClass'; import addClass from 'tui-code-snippet/domUtil/addClass'; import { HookCallback } from '@t/editor'; import { Emitter } from '@t/event'; import { ExecCommand, HidePopup, TabInfo } from '@t/ui'; import i18n from '@/i18n/i18n'; import { cls } from '@/utils/dom'; import { Component } from '@/ui/vdom/component'; import html from '@/ui/vdom/template'; import { Tabs } from '../tabs'; const TYPE_UI = 'ui'; type TabType = 'url' | 'file'; interface Props { show: boolean; eventEmitter: Emitter; execCommand: ExecCommand; hidePopup: HidePopup; } interface State { activeTab: TabType; file: File | null; fileNameElClassName: string; } export class ImagePopupBody extends Component { private tabs: TabInfo[]; constructor(props: Props) { super(props); this.state = { activeTab: 'file', file: null, fileNameElClassName: '' }; this.tabs = [ { name: 'file', text: 'File' }, { name: 'url', text: 'URL' }, ]; } private initialize = (activeTab: TabType = 'file') => { const urlEl = this.refs.url as HTMLInputElement; urlEl.value = ''; (this.refs.altText as HTMLInputElement).value = ''; (this.refs.file as HTMLInputElement).value = ''; removeClass(urlEl, 'wrong'); this.setState({ activeTab, file: null, fileNameElClassName: '' }); }; private emitAddImageBlob() { const { files } = this.refs.file as HTMLInputElement; const altTextEl = this.refs.altText as HTMLInputElement; let fileNameElClassName = ' wrong'; if (files?.length) { fileNameElClassName = ''; const imageFile = files.item(0)!; const hookCallback: HookCallback = (url, text) => this.props.execCommand('addImage', { imageUrl: url, altText: text || altTextEl.value }); this.props.eventEmitter.emit('addImageBlobHook', imageFile, hookCallback, TYPE_UI); } this.setState({ fileNameElClassName }); } private emitAddImage() { const imageUrlEl = this.refs.url as HTMLInputElement; const altTextEl = this.refs.altText as HTMLInputElement; const imageUrl = imageUrlEl.value; const altText = altTextEl.value || 'image'; removeClass(imageUrlEl, 'wrong'); if (!imageUrl.length) { addClass(imageUrlEl, 'wrong'); return; } if (imageUrl) { this.props.execCommand('addImage', { imageUrl, altText }); } } private execCommand = () => { if (this.state.activeTab === 'file') { this.emitAddImageBlob(); } else { this.emitAddImage(); } }; private toggleTab = (_: MouseEvent, activeTab: TabType) => { if (activeTab !== this.state.activeTab) { this.initialize(activeTab); } }; private showFileSelectBox = () => { this.refs.file.click(); }; private changeFile = (ev: Event) => { const { files } = ev.target as HTMLInputElement; if (files?.length) { this.setState({ file: files[0] }); } }; private preventSelectStart(ev: Event) { ev.preventDefault(); } updated() { if (!this.props.show) { this.initialize(); } } render() { const { activeTab, file, fileNameElClassName } = this.state; return html`
                      <${Tabs} tabs=${this.tabs} activeTab=${activeTab} onClick=${this.toggleTab} />
                      (this.refs.url = el)} />
                      ${file ? file.name : i18n.get('No file')} (this.refs.file = el)} />
                      (this.refs.altText = el)} />
                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/linkPopupBody.ts ================================================ import addClass from 'tui-code-snippet/domUtil/addClass'; import removeClass from 'tui-code-snippet/domUtil/removeClass'; import isUndefined from 'tui-code-snippet/type/isUndefined'; import { Emitter } from '@t/event'; import { ExecCommand, HidePopup, PopupInitialValues } from '@t/ui'; import i18n from '@/i18n/i18n'; import { cls } from '@/utils/dom'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; interface Props { eventEmitter: Emitter; execCommand: ExecCommand; hidePopup: HidePopup; show: boolean; initialValues: PopupInitialValues; } export class LinkPopupBody extends Component { private initialize() { const { linkUrl, linkText } = this.props.initialValues; const linkUrlEl = this.refs.url as HTMLInputElement; const linkTextEl = this.refs.text as HTMLInputElement; removeClass(linkUrlEl, 'wrong'); removeClass(linkTextEl, 'wrong', 'disabled'); linkTextEl.removeAttribute('disabled'); if (linkUrl) { addClass(linkTextEl, 'disabled'); linkTextEl.setAttribute('disabled', 'disabled'); } linkUrlEl.value = linkUrl || ''; linkTextEl.value = linkText || ''; } private execCommand = () => { const linkUrlEl = this.refs.url as HTMLInputElement; const linkTextEl = this.refs.text as HTMLInputElement; removeClass(linkUrlEl, 'wrong'); removeClass(linkTextEl, 'wrong'); if (linkUrlEl.value.length < 1) { addClass(linkUrlEl, 'wrong'); return; } const checkLinkText = isUndefined(this.props.initialValues.linkUrl); if (checkLinkText && linkTextEl.value.length < 1) { addClass(linkTextEl, 'wrong'); return; } this.props.execCommand('addLink', { linkUrl: linkUrlEl.value, linkText: linkTextEl.value, }); }; mounted() { this.initialize(); } updated(prevProps: Props) { if (!prevProps.show && this.props.show) { this.initialize(); } } render() { return html`
                      (this.refs.url = el)} /> (this.refs.text = el)} />
                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/tablePopupBody.ts ================================================ import { Emitter } from '@t/event'; import { ExecCommand, Pos } from '@t/ui'; import { cls } from '@/utils/dom'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import i18n from '@/i18n/i18n'; interface Range { rowIdx: number; colIdx: number; } interface Props { eventEmitter: Emitter; execCommand: ExecCommand; show: boolean; } type State = Range; const CELL_WIDTH = 20; const CELL_HEIGHT = 20; const MIN_ROW_INDEX = 5; const MAX_ROW_INDEX = 14; const MIN_COL_INDEX = 5; const MAX_COL_INDEX = 9; const MIN_ROW_SELECTION_INDEX = 1; const MIN_COL_SELECTION_INDEX = 1; const BORDER_WIDTH = 1; export class TablePopupBody extends Component { private offsetRect!: Pos; constructor(props: Props) { super(props); this.state = { rowIdx: -1, colIdx: -1, }; } private extendSelectionRange = ({ pageX, pageY }: MouseEvent) => { const x = pageX - this.offsetRect.left; const y = pageY - this.offsetRect.top; const range = this.getSelectionRangeByOffset(x, y); this.setState({ ...range }); }; private execCommand = () => { this.props.execCommand('addTable', { rowCount: this.state.rowIdx + 1, columnCount: this.state.colIdx + 1, }); }; private getDescription() { return this.state.colIdx === -1 ? '' : `${this.state.colIdx + 1} x ${this.state.rowIdx + 1}`; } private getBoundByRange(colIdx: number, rowIdx: number) { return { width: (colIdx + 1) * CELL_WIDTH, height: (rowIdx + 1) * CELL_HEIGHT, }; } private getRangeByOffset(x: number, y: number) { return { colIdx: Math.floor(x / CELL_WIDTH), rowIdx: Math.floor(y / CELL_HEIGHT), }; } private getTableRange() { const { colIdx: orgColIdx, rowIdx: orgRowIdx } = this.state; let colIdx = Math.max(orgColIdx, MIN_COL_INDEX); let rowIdx = Math.max(orgRowIdx, MIN_ROW_INDEX); if (orgColIdx >= MIN_COL_INDEX && colIdx < MAX_COL_INDEX) { colIdx += 1; } if (orgRowIdx >= MIN_ROW_INDEX && rowIdx < MAX_ROW_INDEX) { rowIdx += 1; } return { colIdx: colIdx + 1, rowIdx: rowIdx + 1 }; } private getSelectionAreaBound() { const { width, height } = this.getBoundByRange(this.state.colIdx, this.state.rowIdx); if (!width && !height) { return { display: 'none' }; } return { width: width - BORDER_WIDTH, height: height - BORDER_WIDTH, display: 'block' }; } private getSelectionRangeByOffset(x: number, y: number) { const range = this.getRangeByOffset(x, y); range.rowIdx = Math.min(Math.max(range.rowIdx, MIN_ROW_SELECTION_INDEX), MAX_ROW_INDEX); range.colIdx = Math.min(Math.max(range.colIdx, MIN_COL_SELECTION_INDEX), MAX_COL_INDEX); return range; } updated() { if (!this.props.show) { this.setState({ colIdx: -1, rowIdx: -1 }); } else if (this.state.colIdx === -1 && this.state.rowIdx === -1) { const { left, top } = this.refs.tableEl.getBoundingClientRect(); this.offsetRect = { left: window.pageXOffset + left, top: window.pageYOffset + top, }; } } private createTableArea(tableRange: Range) { const { colIdx, rowIdx } = tableRange; const rows = []; for (let i = 0; i < rowIdx; i += 1) { const cells = []; for (let j = 0; j < colIdx; j += 1) { const cellClassNames = `${cls('table-cell')}${i > 0 ? '' : ' header'}`; cells.push(html`
                      `); } rows.push(html`
                      ${cells}
                      `); } return html`
                      ${rows}
                      `; } render() { const tableRange = this.getTableRange(); const selectionAreaBound = this.getSelectionAreaBound(); return html`
                      (this.refs.tableEl = el)} onMousemove=${this.extendSelectionRange} onClick=${this.execCommand} > ${this.createTableArea(tableRange)}

                      ${this.getDescription()}

                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/toolbar.ts ================================================ import throttle from 'tui-code-snippet/tricks/throttle'; import forEachArray from 'tui-code-snippet/collection/forEachArray'; import ResizeObserver from 'resize-observer-polyfill'; import { EditorType, PreviewStyle } from '@t/editor'; import { Emitter } from '@t/event'; import { IndexList, PopupInfo, TabInfo, ToolbarGroupInfo, ToolbarItem, ToolbarItemOptions, } from '@t/ui'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import { createElementWith, getOuterWidth, closest, getTotalOffset, cls, removeNode, } from '@/utils/dom'; import { last } from '@/utils/common'; import { createToolbarItemInfo, toggleScrollSync, groupToolbarItems, setGroupState, createPopupInfo, } from '@/ui/toolbarItemFactory'; import { Popup } from '../popup'; import { Tabs } from '../tabs'; import { ToolbarGroup } from './toolbarGroup'; import { DropdownToolbarButton } from './dropdownToolbarButton'; type TabType = 'write' | 'preview'; interface Props { eventEmitter: Emitter; previewStyle: PreviewStyle; toolbarItems: ToolbarItem[]; editorType: EditorType; } interface State { showPopup: boolean; popupInfo: PopupInfo; activeTab: TabType; items: ToolbarGroupInfo[]; dropdownItems: ToolbarGroupInfo[]; } interface ItemWidthMap { [key: string]: number; } const INLINE_PADDING = 50; export class Toolbar extends Component { private tabs: TabInfo[]; private itemWidthMap: ItemWidthMap; private tooltipRef!: { current: HTMLElement | null }; private initialItems: ToolbarGroupInfo[]; private resizeObserver!: ResizeObserver; private handleResize!: () => void; constructor(props: Props) { super(props); this.tabs = [ { name: 'write', text: 'Write' }, { name: 'preview', text: 'Preview' }, ]; this.itemWidthMap = {}; this.initialItems = groupToolbarItems(props.toolbarItems || [], this.hiddenScrollSync()); this.state = { items: this.initialItems, dropdownItems: [], showPopup: false, popupInfo: {} as PopupInfo, activeTab: 'write', }; this.tooltipRef = { current: null }; this.resizeObserver = new ResizeObserver(() => this.handleResize()); this.addEvent(); } insertToolbarItem(indexList: IndexList, item: string | ToolbarItemOptions) { const { groupIndex, itemIndex } = indexList; const group = this.initialItems[groupIndex]; item = createToolbarItemInfo(item); if (group) { group.splice(itemIndex, 0, item); } else { this.initialItems.push([item]); } this.setState(this.classifyToolbarItems()); } removeToolbarItem(name: string) { forEachArray(this.initialItems, (group) => { let found = false; forEachArray(group, (item, index) => { if (item.name === name) { found = true; group.splice(index, 1); this.setState(this.classifyToolbarItems()); return false; } return true; }); return !found; }); } addEvent() { const { eventEmitter } = this.props; this.handleResize = throttle(() => { // reset toolbar items to re-layout toolbar items with each clientWidth this.setState({ items: this.initialItems, dropdownItems: [] }); this.setState(this.classifyToolbarItems()); }, 200); eventEmitter.listen('openPopup', this.openPopup); } private appendTooltipToRoot() { const tooltip = ``; this.tooltipRef.current = createElementWith(tooltip, this.refs.el) as HTMLElement; } private hiddenScrollSync() { return this.props.editorType === 'wysiwyg' || this.props.previewStyle === 'tab'; } private toggleTab = (_: MouseEvent, activeTab: TabType) => { const { eventEmitter } = this.props; if (this.state.activeTab !== activeTab) { const event = activeTab === 'write' ? 'changePreviewTabWrite' : 'changePreviewTabPreview'; eventEmitter.emit(event); this.setState({ activeTab }); } }; private setItemWidth = (name: string, width: number) => { this.itemWidthMap[name] = width; }; private setPopupInfo = (popupInfo: PopupInfo) => { this.setState({ showPopup: true, popupInfo }); }; private openPopup = (popupName: string, initialValues = {}) => { const el = this.refs.el.querySelector(`.${cls('toolbar-group')} .${popupName}`)!; if (el) { const { offsetLeft, offsetTop } = getTotalOffset( el, closest(el, `.${cls('toolbar')}`) as HTMLElement ); const info = createPopupInfo(popupName, { el, pos: { left: offsetLeft, top: el.offsetHeight + offsetTop }, initialValues, }); if (info) { this.setPopupInfo(info); } } }; private hidePopup = () => { if (this.state.showPopup) { this.setState({ showPopup: false }); } }; private execCommand = (command: string, payload?: Record) => { const { eventEmitter } = this.props; eventEmitter.emit('command', command, payload); this.hidePopup(); }; private movePrevItemToDropdownToolbar( itemIndex: number, items: ToolbarGroupInfo[], group: ToolbarGroupInfo, dropdownGroup: ToolbarGroupInfo ) { const moveItem = (targetGroup: ToolbarGroupInfo) => { const item = targetGroup.pop(); if (item) { dropdownGroup.push(item); } }; if (itemIndex > 1) { moveItem(group); } else { const prevGroup = last(items); if (prevGroup) { moveItem(prevGroup); } } } private classifyToolbarItems() { let totalWidth = 0; const { clientWidth } = this.refs.el; const divider = this.refs.el.querySelector(`.${cls('toolbar-divider')}`); const dividerWidth = divider ? getOuterWidth(divider) : 0; const items: ToolbarGroupInfo[] = []; const dropdownItems: ToolbarGroupInfo[] = []; let moved = false; this.initialItems.forEach((initialGroup, groupIndex) => { const group: ToolbarGroupInfo = []; const dropdownGroup: ToolbarGroupInfo = []; initialGroup.forEach((item, itemIndex) => { if (!item.hidden) { totalWidth += this.itemWidthMap[item.name]; if (totalWidth > clientWidth - INLINE_PADDING) { // should move the prev item to dropdown toolbar for placing the more button if (!moved) { this.movePrevItemToDropdownToolbar(itemIndex, items, group, dropdownGroup); moved = true; } dropdownGroup.push(item); } else { group.push(item); } } }); if (group.length) { setGroupState(group); items.push(group); } if (dropdownGroup.length) { setGroupState(dropdownGroup); dropdownItems.push(dropdownGroup); } // add divider width if (groupIndex < this.state.items.length - 1) { totalWidth += dividerWidth; } }); return { items, dropdownItems }; } mounted() { if (this.props.previewStyle === 'tab') { this.props.eventEmitter.emit('changePreviewTabWrite', true); } // classify toolbar and dropdown toolbar after DOM has been rendered this.setState(this.classifyToolbarItems()); this.appendTooltipToRoot(); this.resizeObserver.observe(this.refs.el); } updated(prevProps: Props) { const { editorType, previewStyle, eventEmitter } = this.props; const changedStyle = previewStyle !== prevProps.previewStyle; const changedType = editorType !== prevProps.editorType; if (changedStyle || changedType) { // show or hide scrollSync button toggleScrollSync(this.initialItems, this.hiddenScrollSync()); const newState = this.classifyToolbarItems() as State; if (changedStyle || (previewStyle === 'tab' && editorType === 'markdown')) { eventEmitter.emit('changePreviewTabWrite'); newState.activeTab = 'write'; } this.setState(newState); } } beforeDestroy() { window.removeEventListener('resize', this.handleResize); this.resizeObserver.disconnect(); removeNode(this.tooltipRef.current!); } render() { const { previewStyle, eventEmitter, editorType } = this.props; const { popupInfo, showPopup, activeTab, items, dropdownItems } = this.state; const props = { eventEmitter, tooltipRef: this.tooltipRef, disabled: editorType === 'markdown' && previewStyle === 'tab' && activeTab === 'preview', execCommand: this.execCommand, setPopupInfo: this.setPopupInfo, }; const toolbarStyle = previewStyle === 'tab' ? { borderTopLeftRadius: 0 } : null; return html`
                      <${Tabs} tabs=${this.tabs} activeTab=${activeTab} onClick=${this.toggleTab} />
                      (this.refs.el = el)} style=${toolbarStyle} > ${items.map( (group, index) => html` <${ToolbarGroup} group=${group} hiddenDivider=${index === items.length - 1 || items[index + 1]?.hidden} setItemWidth=${this.setItemWidth} ...${props} /> ` )} <${DropdownToolbarButton} item=${createToolbarItemInfo('more')} items=${dropdownItems} ...${props} />
                      <${Popup} info=${popupInfo} show=${showPopup} eventEmitter=${eventEmitter} hidePopup=${this.hidePopup} execCommand=${this.execCommand} />
                      `; } } ================================================ FILE: apps/editor/src/ui/components/toolbar/toolbarButton.ts ================================================ import { ExecCommand, SetPopupInfo, SetItemWidth, GetBound, HideTooltip, ShowTooltip, ToolbarButtonInfo, } from '@t/ui'; import { Emitter } from '@t/event'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import { createPopupInfo } from '@/ui/toolbarItemFactory'; import { getOuterWidth } from '@/utils/dom'; import { connectHOC } from './buttonHoc'; interface Props { disabled: boolean; eventEmitter: Emitter; item: ToolbarButtonInfo; active: boolean; execCommand: ExecCommand; setPopupInfo: SetPopupInfo; showTooltip: ShowTooltip; hideTooltip: HideTooltip; getBound: GetBound; setItemWidth?: SetItemWidth; } const DEFAULT_WIDTH = 80; export class ToolbarButtonComp extends Component { mounted() { this.setItemWidth(); } updated(prevProps: Props) { if (prevProps.item.name !== this.props.item.name) { this.setItemWidth(); } } private setItemWidth() { const { setItemWidth, item } = this.props; // set width only if it is not a dropdown toolbar if (setItemWidth) { setItemWidth(item.name, getOuterWidth(this.refs.el) + (item.hidden ? DEFAULT_WIDTH : 0)); } } private showTooltip = () => { this.props.showTooltip(this.refs.el); }; private execCommand = () => { const { item, execCommand, setPopupInfo, getBound, eventEmitter } = this.props; const { command, name, popup } = item; if (command) { execCommand(command); } else { const popupName = popup ? 'customPopupBody' : name; const [initialValues] = eventEmitter.emit('query', 'getPopupInitialValues', { popupName }); const info = createPopupInfo(popupName, { el: this.refs.el, pos: getBound(this.refs.el), popup, initialValues, }); if (info) { setPopupInfo(info); } } }; render() { const { hideTooltip, disabled, item, active } = this.props; const style = { display: item.hidden ? 'none' : null, ...item.style }; const classNames = `${item.className || ''}${active ? ' active' : ''}`; return html` `; } } export const ToolbarButton = connectHOC(ToolbarButtonComp); ================================================ FILE: apps/editor/src/ui/components/toolbar/toolbarGroup.ts ================================================ import { ExecCommand, SetPopupInfo, ToolbarGroupInfo, SetItemWidth, GetBound, HideTooltip, ShowTooltip, ToolbarCustomOptions, } from '@t/ui'; import { Emitter } from '@t/event'; import { cls } from '@/utils/dom'; import html from '@/ui/vdom/template'; import { Component } from '@/ui/vdom/component'; import { ToolbarButton } from './toolbarButton'; import { CustomToolbarItem } from './customToolbarItem'; interface Props { tooltipEl: HTMLElement; disabled: boolean; group: ToolbarGroupInfo; hidden: boolean; hiddenDivider: boolean; eventEmitter: Emitter; execCommand: ExecCommand; setPopupInfo: SetPopupInfo; showTooltip: ShowTooltip; hideTooltip: HideTooltip; getBound: GetBound; setItemWidth?: SetItemWidth; } export class ToolbarGroup extends Component { render() { const { group, hiddenDivider } = this.props; const groupStyle = group.hidden ? { display: 'none' } : null; const dividerStyle = hiddenDivider ? { display: 'none' } : null; return html`
                      ${group.map((item: ToolbarCustomOptions) => { const Comp = item.el ? CustomToolbarItem : ToolbarButton; return html`<${Comp} key=${item.name} ...${this.props} item=${item} />`; })}
                      `; } } ================================================ FILE: apps/editor/src/ui/toolbarItemFactory.ts ================================================ import isString from 'tui-code-snippet/type/isString'; import addClass from 'tui-code-snippet/domUtil/addClass'; import removeClass from 'tui-code-snippet/domUtil/removeClass'; import { PopupInfo, PopupOptions, Pos, ToolbarButtonInfo, ToolbarGroupInfo, ToolbarItem, ToolbarItemInfo, ToolbarItemOptions, PopupInitialValues, ToolbarCustomOptions, ExecCommand, } from '@t/ui'; import i18n from '@/i18n/i18n'; import { cls } from '@/utils/dom'; import html from './vdom/template'; import { HeadingPopupBody } from './components/toolbar/headingPopupBody'; import { ImagePopupBody } from './components/toolbar/imagePopupBody'; import { LinkPopupBody } from './components/toolbar/linkPopupBody'; import { TablePopupBody } from './components/toolbar/tablePopupBody'; import { CustomPopupBody } from './components/toolbar/customPopupBody'; export function createToolbarItemInfo(type: string | ToolbarItemOptions): ToolbarItemInfo { return isString(type) ? createDefaultToolbarItemInfo(type) : type; } function createScrollSyncToolbarItem(): ToolbarItemInfo { const label = document.createElement('label'); const checkbox = document.createElement('input'); const toggleSwitch = document.createElement('span'); label.className = 'scroll-sync active'; checkbox.type = 'checkbox'; checkbox.checked = true; toggleSwitch.className = 'switch'; const onMounted = (execCommand: ExecCommand) => checkbox.addEventListener('change', (ev: Event) => { const { checked } = ev.target as HTMLInputElement; if (checked) { addClass(label, 'active'); } else { removeClass(label, 'active'); } execCommand('toggleScrollSync', { active: checked }); }); label.appendChild(checkbox); label.appendChild(toggleSwitch); return { name: 'scrollSync', el: label, onMounted, }; } function createDefaultToolbarItemInfo(type: string) { let info!: ToolbarButtonInfo | ToolbarCustomOptions; switch (type) { case 'heading': info = { name: 'heading', className: 'heading', tooltip: i18n.get('Headings'), state: 'heading', }; break; case 'bold': info = { name: 'bold', className: 'bold', command: 'bold', tooltip: i18n.get('Bold'), state: 'strong', }; break; case 'italic': info = { name: 'italic', className: 'italic', command: 'italic', tooltip: i18n.get('Italic'), state: 'emph', }; break; case 'strike': info = { name: 'strike', className: 'strike', command: 'strike', tooltip: i18n.get('Strike'), state: 'strike', }; break; case 'hr': info = { name: 'hr', className: 'hrline', command: 'hr', tooltip: i18n.get('Line'), state: 'thematicBreak', }; break; case 'quote': info = { name: 'quote', className: 'quote', command: 'blockQuote', tooltip: i18n.get('Blockquote'), state: 'blockQuote', }; break; case 'ul': info = { name: 'ul', className: 'bullet-list', command: 'bulletList', tooltip: i18n.get('Unordered list'), state: 'bulletList', }; break; case 'ol': info = { name: 'ol', className: 'ordered-list', command: 'orderedList', tooltip: i18n.get('Ordered list'), state: 'orderedList', }; break; case 'task': info = { name: 'task', className: 'task-list', command: 'taskList', tooltip: i18n.get('Task'), state: 'taskList', }; break; case 'table': info = { name: 'table', className: 'table', tooltip: i18n.get('Insert table'), state: 'table', }; break; case 'image': info = { name: 'image', className: 'image', tooltip: i18n.get('Insert image'), }; break; case 'link': info = { name: 'link', className: 'link', tooltip: i18n.get('Insert link'), }; break; case 'code': info = { name: 'code', className: 'code', command: 'code', tooltip: i18n.get('Code'), state: 'code', }; break; case 'codeblock': info = { name: 'codeblock', className: 'codeblock', command: 'codeBlock', tooltip: i18n.get('Insert CodeBlock'), state: 'codeBlock', }; break; case 'indent': info = { name: 'indent', className: 'indent', command: 'indent', tooltip: i18n.get('Indent'), state: 'indent', }; break; case 'outdent': info = { name: 'outdent', className: 'outdent', command: 'outdent', tooltip: i18n.get('Outdent'), state: 'outdent', }; break; case 'scrollSync': info = createScrollSyncToolbarItem(); break; case 'more': info = { name: 'more', className: 'more', tooltip: i18n.get('More'), }; break; default: // do nothing } if (info.name !== 'scrollSync') { (info as ToolbarButtonInfo).className += ` ${cls('toolbar-icons')}`; } return info; } interface Payload { el: HTMLElement; pos: Pos; popup?: PopupOptions; initialValues?: PopupInitialValues; } export function createPopupInfo(type: string, payload: Payload): PopupInfo | null { const { el, pos, popup, initialValues } = payload; switch (type) { case 'heading': return { render: (props) => html`<${HeadingPopupBody} ...${props} />`, className: cls('popup-add-heading'), fromEl: el, pos, }; case 'link': return { render: (props) => html`<${LinkPopupBody} ...${props} />`, className: cls('popup-add-link'), fromEl: el, pos, initialValues, }; case 'image': return { render: (props) => html`<${ImagePopupBody} ...${props} />`, className: cls('popup-add-image'), fromEl: el, pos, }; case 'table': return { render: (props) => html`<${TablePopupBody} ...${props} />`, className: cls('popup-add-table'), fromEl: el, pos, }; case 'customPopupBody': if (!popup) { return null; } return { render: (props) => html`<${CustomPopupBody} ...${props} body=${popup!.body} />`, fromEl: el, pos, ...popup!, }; default: return null; } } export function setGroupState(group: ToolbarGroupInfo) { group.hidden = group.length === group.filter((info: ToolbarButtonInfo) => info.hidden).length; } export function groupToolbarItems(toolbarItems: ToolbarItem[], hiddenScrollSync: boolean) { const toggleScrollSyncState = (item: ToolbarButtonInfo) => { item.hidden = item.name === 'scrollSync' && hiddenScrollSync; return item; }; return toolbarItems.reduce((acc: ToolbarGroupInfo[], item) => { acc.push(item.map((type) => toggleScrollSyncState(createToolbarItemInfo(type)))); const group = acc[(acc.length || 1) - 1]; if (group) { setGroupState(group); } return acc; }, []); } export function toggleScrollSync(toolbarItems: ToolbarGroupInfo[], hiddenScrollSync: boolean) { toolbarItems.forEach((group) => { group.forEach( (item: ToolbarButtonInfo) => (item.hidden = item.name === 'scrollSync' && hiddenScrollSync) ); setGroupState(group); }); } ================================================ FILE: apps/editor/src/ui/vdom/commit.ts ================================================ import isFunction from 'tui-code-snippet/type/isFunction'; import { innerDiff, removeNode } from './dom'; import { VNode } from './vnode'; export function commit(vnode?: VNode) { VNode.removalNodes.forEach((removalNode) => diff(removalNode)); if (vnode) { let next; const walker = vnode.walker(); while ((next = walker.walk())) { vnode = next.vnode!; if (next.entering) { diff(vnode); } else if (isFunction(vnode.type)) { const comp = vnode.component!; // lifecycle method if (!vnode.old && comp.mounted) { comp.mounted(); } if (vnode.old && comp.updated) { const prevProps = comp.prevProps || {}; comp.updated(prevProps); } } } } } function getParentNode(vnode: VNode) { let { parent } = vnode; while (!parent!.node) { parent = parent!.parent!; } return parent!.node; } function diff(vnode: VNode | null) { if (!vnode || !vnode.parent) { return; } if (vnode.node) { const parentNode = getParentNode(vnode); if (vnode.effect === 'A') { parentNode.appendChild(vnode.node); } else if (vnode.effect === 'U') { innerDiff(vnode.node!, vnode.old!.props, vnode.props); } } if (vnode.effect === 'D') { let next; const walker = vnode.walker(); while ((next = walker.walk())) { vnode = next.vnode!; if (!next.entering) { if (isFunction(vnode.type)) { const comp = vnode.component!; // lifecycle method if (comp.beforeDestroy) { comp.beforeDestroy(); } } else { const parentNode = getParentNode(vnode); removeNode(vnode, parentNode); } } } } // apply ref if (vnode.ref) { if (vnode.component) { vnode.ref(vnode.component); } else if (vnode.node) { vnode.ref(vnode.node); } } } ================================================ FILE: apps/editor/src/ui/vdom/component.ts ================================================ import { Component as IComponent, VNode } from '@t/ui'; import { shallowEqual } from '@/utils/common'; import { rerender } from './renderer'; export abstract class Component implements IComponent { props: T; state: R; refs: Record; vnode!: VNode; constructor(props: T) { this.props = props; this.state = {} as R; this.refs = {}; } setState(state: Partial) { const newState = { ...this.state, ...state }; if (!shallowEqual(this.state, newState)) { this.state = newState; rerender(this); } } abstract render(): VNode; } ================================================ FILE: apps/editor/src/ui/vdom/dom.ts ================================================ import isObject from 'tui-code-snippet/type/isObject'; import isNumber from 'tui-code-snippet/type/isNumber'; import { shallowEqual } from '@/utils/common'; import { isTextNode } from '@/utils/dom'; import { VNode } from './vnode'; type ConditionFn = (propName: string) => boolean; type Props = Record; // @TODO: clearfy the type definition for CSSDeclaration export function createNode(vnode: VNode) { let node: Node; if (vnode.type === 'TEXT_NODE') { node = document.createTextNode(vnode.props.nodeValue); } else { node = document.createElement(vnode.type as string); setProps(node, {}, vnode.props); } return node; } export function removeNode(vnode: VNode, parentNode: Node) { if (vnode.node) { parentNode.removeChild(vnode.node); } else { removeNode(vnode.firstChild!, parentNode); } } export function innerDiff(node: Node, prevProps: Props, nextProps: Props) { Object.keys(prevProps).forEach((propName) => { if (/^on/.test(propName)) { if (!nextProps[propName] || prevProps[propName] !== nextProps[propName]) { const eventName = propName.slice(2).toLowerCase(); node.removeEventListener(eventName, prevProps[propName]); } } else if (propName !== 'children' && !nextProps[propName] && !isTextNode(node)) { (node as Element).removeAttribute(propName); } }); setProps( node, prevProps, nextProps, (propName) => !shallowEqual(prevProps[propName], nextProps[propName]) ); } const reNonDimension = /acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i; function setProps(node: Node, prevProps: Props, props: Props, condition?: ConditionFn) { Object.keys(props).forEach((propName) => { if (!condition || condition(propName)) { if (/^on/.test(propName)) { const eventName = propName.slice(2).toLowerCase(); node.addEventListener(eventName, props[propName]); } else if (propName === 'nodeValue') { node[propName] = props[propName]; } else if (propName === 'style' && isObject(props[propName])) { setStyleProps(node as HTMLElement, prevProps[propName], props[propName]); } else if (propName !== 'children') { if (props[propName] === false) { (node as HTMLElement).removeAttribute(propName); } else { (node as HTMLElement).setAttribute(propName, props[propName]); } } } }); } function setStyleProps(node: HTMLElement, prevStyleProps: Props | null, styleProps: Props) { if (prevStyleProps) { Object.keys(prevStyleProps).forEach((styleProp) => { // @ts-ignore node.style[styleProp] = ''; }); } Object.keys(styleProps).forEach((styleProp) => { const value = styleProps[styleProp]; // @ts-ignore node.style[styleProp] = isNumber(value) && !reNonDimension.test(styleProp) ? `${value}px` : value; }); } ================================================ FILE: apps/editor/src/ui/vdom/htm.js ================================================ import { assign } from '@/utils/common'; // @TODO: change syntax with our convention /* eslint-disable */ export default function (n) { for ( var l, e, s = arguments, t = 1, r = '', u = '', a = [0], c = function (n) { t === 1 && (n || (r = r.replace(/^\s*\n\s*|\s*\n\s*$/g, ''))) ? a.push(n ? s[n] : r) : t === 3 && (n || r) ? ((a[1] = n ? s[n] : r), (t = 2)) : t === 2 && r === '...' && n ? (a[2] = assign(a[2] || {}, s[n])) : t === 2 && r && !n ? ((a[2] = a[2] || {})[r] = !0) : t >= 5 && (t === 5 ? (((a[2] = a[2] || {})[e] = n ? (r ? r + s[n] : s[n]) : r), (t = 6)) : (n || r) && (a[2][e] += n ? r + s[n] : r)), (r = ''); }, h = 0; h < n.length; h++ ) { h && (t === 1 && c(), c(h)); for (let i = 0; i < n[h].length; i++) (l = n[h][i]), t === 1 ? l === '<' ? (c(), (a = [a, '', null]), (t = 3)) : (r += l) : t === 4 ? r === '--' && l === '>' ? ((t = 1), (r = '')) : (r = l + r[0]) : u ? l === u ? (u = '') : (r += l) : l === '"' || l === "'" ? (u = l) : l === '>' ? (c(), (t = 1)) : t && (l === '=' ? ((t = 5), (e = r), (r = '')) : l === '/' && (t < 5 || n[h][i + 1] === '>') ? (c(), t === 3 && (a = a[0]), (t = a), (a = a[0]).push(this.apply(null, t.slice(1))), (t = 0)) : l === ' ' || l === '\t' || l === '\n' || l === '\r' ? (c(), (t = 2)) : (r += l)), t === 3 && r === '!--' && ((t = 4), (a = a[0])); } return c(), a.length > 2 ? a.slice(1) : a[1]; } ================================================ FILE: apps/editor/src/ui/vdom/render.ts ================================================ import isFunction from 'tui-code-snippet/type/isFunction'; import { ComponentClass } from '@t/ui'; import { VNode } from './vnode'; import { createNode } from './dom'; import { last } from '@/utils/common'; export function createComponent(Comp: ComponentClass, vnode: VNode) { const { props, component } = vnode; if (component) { component.prevProps = component.props; component.props = vnode.props; return component; } return new Comp(props); } export function buildVNode(vnode: VNode | null) { const root = vnode; while (vnode && !vnode.skip) { if (isFunction(vnode.type)) { const instance = createComponent(vnode.type, vnode); instance.vnode = vnode; vnode.component = instance; vnode.props.children = vnode.children = [instance.render()]; buildChildrenVNode(vnode); } else { if (!vnode.node) { vnode.node = createNode(vnode); } buildChildrenVNode(vnode); } if (vnode.firstChild) { vnode = vnode.firstChild; } else { while (vnode && vnode.parent && !vnode.next) { vnode = vnode.parent!; if (vnode === root) { break; } } vnode = vnode.next; } } } function isSameType(old: VNode | null, vnode: VNode) { return old && vnode && vnode.type === old.type && (!vnode.key || vnode.key === old.key); } // @TODO: add key diff algorithm function buildChildrenVNode(parent: VNode) { const { children } = parent; let old = parent.old ? parent.old.firstChild : null; let prev: VNode | null = null; children.forEach((vnode, index) => { const sameType = isSameType(old, vnode); if (sameType) { vnode.old = old!; vnode.parent = parent; vnode.node = old!.node; vnode.component = old!.component; vnode.effect = 'U'; } if (vnode && !sameType) { vnode.old = null; vnode.parent = parent; vnode.node = null; vnode.effect = 'A'; } if (old && !sameType) { VNode.removalNodes.push(old); old.effect = 'D'; } if (old) { old = old.next; } if (index === 0) { parent.firstChild = vnode; } else if (vnode) { prev!.next = vnode; } prev = vnode; }); const lastChild = last(children); if (!children.length) { while (old) { VNode.removalNodes.push(old); old.effect = 'D'; old = old.next; } } while (old && lastChild) { if (old && lastChild.old !== old) { VNode.removalNodes.push(old); old.effect = 'D'; old = old.next; } } } ================================================ FILE: apps/editor/src/ui/vdom/renderer.ts ================================================ import { Component } from '@t/ui'; import { commit } from './commit'; import { buildVNode } from './render'; import { VNode } from './vnode'; function destroy(vnode: VNode) { vnode.effect = 'D'; VNode.removalNodes = [vnode]; commit(); VNode.removalNodes = []; } export function rerender(comp: Component) { const root = comp.vnode; root.effect = 'U'; root.old = root; // skip for unnecessary reconciliation if (root.next) { root.next.skip = true; } VNode.removalNodes = []; buildVNode(root); commit(root); if (root.next) { root.next.skip = false; } } export function render(container: HTMLElement, vnode: VNode) { const root = new VNode(container.tagName.toLowerCase(), {}, [vnode]); root.node = container; VNode.removalNodes = []; buildVNode(root); commit(root); return () => destroy(root.firstChild!); } ================================================ FILE: apps/editor/src/ui/vdom/template.ts ================================================ import html from './htm'; import isBoolean from 'tui-code-snippet/type/isBoolean'; import isString from 'tui-code-snippet/type/isString'; import isNumber from 'tui-code-snippet/type/isNumber'; import { ComponentClass } from '@t/ui'; import { VNode } from './vnode'; function createTextNode(text: string) { return new VNode('TEXT_NODE', { nodeValue: text }, []); } function excludeUnnecessaryChild(child: VNode, flatted: VNode[]) { let vnode: VNode | null = child; // eslint-disable-next-line no-eq-null,eqeqeq if (isBoolean(child) || child == null) { vnode = null; } else if (isString(child) || isNumber(child)) { vnode = createTextNode(String(child)); } if (vnode) { flatted.push(vnode); } } function h(type: string | ComponentClass, props: Record, ...children: VNode[]) { const flatted: VNode[] = []; children.forEach((child) => { if (Array.isArray(child)) { child.forEach((vnode) => { excludeUnnecessaryChild(vnode, flatted); }); } else { excludeUnnecessaryChild(child, flatted); } }); return new VNode(type, props || {}, flatted); } // @ts-ignore export default html.bind(h) as (strings: TemplateStringsArray, ...values: any[]) => VNode; ================================================ FILE: apps/editor/src/ui/vdom/vnode.ts ================================================ import { Component, ComponentClass } from '@t/ui'; class VNodeWalker { current: VNode | null; root: VNode | null; entering: boolean; constructor(current: VNode | null) { this.current = current; this.root = current; this.entering = true; } walk() { const { entering, current: cur } = this; if (!cur) { return null; } if (entering) { if (cur.firstChild) { this.current = cur.firstChild; this.entering = true; } else { this.entering = false; } } else if (cur === this.root) { this.current = null; } else if (cur.next) { this.current = cur.next; this.entering = true; } else { this.current = cur.parent; this.entering = false; } return { vnode: cur, entering }; } } export class VNode { static removalNodes: VNode[] = []; type: string | ComponentClass; props: Record; children: VNode[]; parent: VNode | null = null; old: VNode | null = null; firstChild: VNode | null = null; next: VNode | null = null; ref?: (node: Node | Component) => void | Node | Component; node!: Node | null; // A: append, U: update, D: delete effect!: 'A' | 'U' | 'D'; component?: Component; key?: string; skip = false; constructor(type: string | ComponentClass, props: Record, children: VNode[]) { this.type = type; this.props = props; this.children = children; this.props.children = children; if (props.ref) { this.ref = props.ref; delete props.ref; } if (props.key) { this.key = props.key; delete props.key; } } walker() { return new VNodeWalker(this); } } ================================================ FILE: apps/editor/src/utils/common.ts ================================================ import isUndefined from 'tui-code-snippet/type/isUndefined'; import isNull from 'tui-code-snippet/type/isNull'; import sendHostname from 'tui-code-snippet/request/sendHostname'; import forEachOwnProperties from 'tui-code-snippet/collection/forEachOwnProperties'; import { LinkAttributeNames, LinkAttributes } from '@t/editor'; export const isMac = /Mac/.test(navigator.platform); const reSpaceMoreThanOne = /[\u0020]+/g; const reEscapeChars = /[>(){}[\]+-.!#|]/g; const reEscapeHTML = /<([a-zA-Z_][a-zA-Z0-9\-._]*)(\s|[^\\>])*\/?>|<(\/)([a-zA-Z_][a-zA-Z0-9\-._]*)\s*\/?>||<([a-zA-Z_][a-zA-Z0-9\-.:/]*)>/g; const reEscapeBackSlash = /\\[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~\\]/g; const reEscapePairedChars = /[*_~`]/g; const reMdImageSyntax = /!\[.*\]\(.*\)/g; const reEscapedCharInLinkSyntax = /[[\]]/g; const reEscapeBackSlashInSentence = /(?:^|[^\\])\\(?!\\)/g; const XMLSPECIAL = '[&<>"]'; const reXmlSpecial = new RegExp(XMLSPECIAL, 'g'); function replaceUnsafeChar(char: string) { switch (char) { case '&': return '&'; case '<': return '<'; case '>': return '>'; case '"': return '"'; default: return char; } } export function escapeXml(text: string) { if (reXmlSpecial.test(text)) { return text.replace(reXmlSpecial, replaceUnsafeChar); } return text; } export function sendHostName() { sendHostname('editor', 'UA-129966929-1'); } export function includes(arr: T[], targetItem: T) { return arr.indexOf(targetItem) !== -1; } const availableLinkAttributes: LinkAttributeNames[] = ['rel', 'target', 'hreflang', 'type']; const reMarkdownTextToEscapeMap = { codeblock: /(^ {4}[^\n]+\n*)+/, thematicBreak: /^ *((\* *){3,}|(- *){3,} *|(_ *){3,}) */, atxHeading: /^(#{1,6}) +[\s\S]+/, seTextheading: /^([^\n]+)\n *(=|-){2,} */, blockquote: /^( *>[^\n]+.*)+/, list: /^ *(\*+|-+|\d+\.) [\s\S]+/, def: /^ *\[([^\]]+)\]: *]+)>?(?: +["(]([^\n]+)[")])? */, link: /!?\[.*\]\(.*\)/, reflink: /!?\[.*\]\s*\[([^\]]*)\]/, verticalBar: /\u007C/, fencedCodeblock: /^((`|~){3,})/, }; export function sanitizeLinkAttribute(attribute: LinkAttributes) { if (!attribute) { return null; } const linkAttributes: LinkAttributes = {}; availableLinkAttributes.forEach((key) => { if (!isUndefined(attribute[key])) { linkAttributes[key] = attribute[key]; } }); return linkAttributes; } export function repeat(text: string, count: number) { let result = ''; for (let i = 0; i < count; i += 1) { result += text; } return result; } function isNeedEscapeText(text: string) { let needEscape = false; forEachOwnProperties(reMarkdownTextToEscapeMap, (reMarkdownTextToEscape) => { if (reMarkdownTextToEscape.test(text)) { needEscape = true; } return !needEscape; }); return needEscape; } export function escapeTextForLink(text: string) { const imageSyntaxRanges: [number, number][] = []; let result = reMdImageSyntax.exec(text); while (result) { imageSyntaxRanges.push([result.index, result.index + result[0].length]); result = reMdImageSyntax.exec(text); } return text.replace(reEscapedCharInLinkSyntax, (matched, offset) => { const isDelimiter = imageSyntaxRanges.some((range) => offset > range[0] && offset < range[1]); return isDelimiter ? matched : `\\${matched}`; }); } export function escape(text: string) { const aheadReplacer = (matched: string) => `\\${matched}`; const behindReplacer = (matched: string) => `${matched}\\`; let escapedText = text.replace(reSpaceMoreThanOne, ' '); if (reEscapeBackSlash.test(escapedText)) { escapedText = escapedText.replace(reEscapeBackSlash, aheadReplacer); } if (reEscapeBackSlashInSentence.test(escapedText)) { escapedText = escapedText.replace(reEscapeBackSlashInSentence, behindReplacer); } escapedText = escapedText.replace(reEscapePairedChars, aheadReplacer); if (reEscapeHTML.test(escapedText)) { escapedText = escapedText.replace(reEscapeHTML, aheadReplacer); } if (isNeedEscapeText(escapedText)) { escapedText = escapedText.replace(reEscapeChars, aheadReplacer); } return escapedText; } export function quote(text: string) { let result; if (text.indexOf('"') === -1) { result = '""'; } else { result = text.indexOf("'") === -1 ? "''" : '()'; } return result[0] + text + result[1]; } export function isNil(value: unknown): value is null | undefined { return isNull(value) || isUndefined(value); } export function shallowEqual(o1: Record | null, o2: Record | null) { if (o1 === null && o1 === o2) { return true; } if (typeof o1 !== 'object' || typeof o2 !== 'object' || isNil(o1) || isNil(o2)) { return o1 === o2; } for (const key in o1) { if (o1[key] !== o2[key]) { return false; } } for (const key in o2) { if (!(key in o1)) { return false; } } return true; } export function last(arr: T[]) { return arr[arr.length - 1]; } export function between(value: number, min: number, max: number) { return value >= min && value <= max; } function isObject(obj: unknown): obj is object { return typeof obj === 'object' && obj !== null; } export function deepMergedCopy, T2 extends Record>( targetObj: T1, obj: T2 ) { const resultObj = { ...targetObj } as T1 & T2; if (targetObj && obj) { Object.keys(obj).forEach((prop: keyof T2) => { if (isObject(resultObj[prop])) { if (Array.isArray(obj[prop])) { resultObj[prop as keyof T1 & T2] = deepCopyArray(obj[prop]); } else if (resultObj.hasOwnProperty(prop)) { resultObj[prop] = deepMergedCopy(resultObj[prop], obj[prop]); } else { resultObj[prop as keyof T1 & T2] = deepCopy(obj[prop]); } } else { resultObj[prop as keyof T1 & T2] = obj[prop]; } }); } return resultObj; } export function deepCopyArray>(items: T): T { return items.map((item) => { if (isObject(item)) { return Array.isArray(item) ? deepCopyArray(item) : deepCopy(item); } return item; }) as T; } export function deepCopy>(obj: T) { const keys = Object.keys(obj); if (!keys.length) { return obj; } return keys.reduce((acc, prop: keyof T) => { if (isObject(obj[prop])) { acc[prop] = Array.isArray(obj[prop]) ? deepCopyArray(obj[prop]) : deepCopy(obj[prop]); } else { acc[prop] = obj[prop]; } return acc; }, {} as T); } export function assign(targetObj: Record, obj: Record = {}) { Object.keys(obj).forEach((prop) => { if (targetObj.hasOwnProperty(prop) && typeof targetObj[prop] === 'object') { if (Array.isArray(obj[prop])) { targetObj[prop] = obj[prop]; } else { assign(targetObj[prop], obj[prop]); } } else { targetObj[prop] = obj[prop]; } }); return targetObj; } export function getSortedNumPair(valueA: number, valueB: number) { return valueA > valueB ? [valueB, valueA] : [valueA, valueB]; } ================================================ FILE: apps/editor/src/utils/constants.ts ================================================ const TAG_NAME = '[A-Za-z][A-Za-z0-9-]*'; const ATTRIBUTE_NAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*'; const UNQUOTED_VALUE = '[^"\'=<>`\\x00-\\x20]+'; const SINGLE_QUOTED_VALUE = "'[^']*'"; const DOUBLE_QUOTED_VALUE = '"[^"]*"'; const ATTRIBUTE_VALUE = `(?:${UNQUOTED_VALUE}|${SINGLE_QUOTED_VALUE}|${DOUBLE_QUOTED_VALUE})`; const ATTRIBUTE_VALUE_SPEC = `${'(?:\\s*=\\s*'}${ATTRIBUTE_VALUE})`; export const ATTRIBUTE = `${'(?:\\s+'}${ATTRIBUTE_NAME}${ATTRIBUTE_VALUE_SPEC}?)`; export const OPEN_TAG = `<(${TAG_NAME})(${ATTRIBUTE})*\\s*/?>`; export const CLOSE_TAG = `]`; export const HTML_TAG = `(?:${OPEN_TAG}|${CLOSE_TAG})`; export const reHTMLTag = new RegExp(`^${HTML_TAG}`, 'i'); export const reBR = //i; export const reHTMLComment = /|/; export const ALTERNATIVE_TAG_FOR_BR = '

                      '; ================================================ FILE: apps/editor/src/utils/dom.ts ================================================ import toArray from 'tui-code-snippet/collection/toArray'; import isArray from 'tui-code-snippet/type/isArray'; import isString from 'tui-code-snippet/type/isString'; import isUndefined from 'tui-code-snippet/type/isUndefined'; import hasClass from 'tui-code-snippet/domUtil/hasClass'; import addClass from 'tui-code-snippet/domUtil/addClass'; import removeClass from 'tui-code-snippet/domUtil/removeClass'; import matches from 'tui-code-snippet/domUtil/matches'; import { ALTERNATIVE_TAG_FOR_BR, HTML_TAG, OPEN_TAG, reBR } from './constants'; import { isNil } from './common'; export function isPositionInBox(style: CSSStyleDeclaration, offsetX: number, offsetY: number) { const left = parseInt(style.left, 10); const top = parseInt(style.top, 10); const width = parseInt(style.width, 10) + parseInt(style.paddingLeft, 10) + parseInt(style.paddingRight, 10); const height = parseInt(style.height, 10) + parseInt(style.paddingTop, 10) + parseInt(style.paddingBottom, 10); return offsetX >= left && offsetX <= left + width && offsetY >= top && offsetY <= top + height; } const CLS_PREFIX = 'toastui-editor-'; export function cls(...names: (string | [boolean, string])[]) { const result = []; for (const name of names) { let className: string | null; if (Array.isArray(name)) { className = name[0] ? name[1] : null; } else { className = name; } if (className) { result.push(`${CLS_PREFIX}${className}`); } } return result.join(' '); } export function clsWithMdPrefix(...names: string[]) { return names.map((className) => `${CLS_PREFIX}md-${className}`).join(' '); } export function isTextNode(node: Node) { return node?.nodeType === Node.TEXT_NODE; } export function isElemNode(node: Node) { return node && node.nodeType === Node.ELEMENT_NODE; } export function findNodes(element: Element, selector: string) { const nodeList = toArray(element.querySelectorAll(selector)); if (nodeList.length) { return nodeList; } return []; } export function appendNodes(node: Node, nodesToAppend: Node | Node[]) { nodesToAppend = isArray(nodesToAppend) ? toArray(nodesToAppend) : [nodesToAppend]; nodesToAppend.forEach((nodeToAppend) => { node.appendChild(nodeToAppend); }); } export function insertBeforeNode(insertedNode: Node, node: Node) { if (node.parentNode) { node.parentNode.insertBefore(insertedNode, node); } } export function removeNode(node: Node) { if (node.parentNode) { node.parentNode.removeChild(node); } } export function unwrapNode(node: Node) { const result = []; while (node.firstChild) { result.push(node.firstChild); if (node.parentNode) { node.parentNode.insertBefore(node.firstChild, node); } } removeNode(node); return result; } export function toggleClass(element: Element, className: string, state?: boolean) { if (isUndefined(state)) { state = !hasClass(element, className); } const toggleFn = state ? addClass : removeClass; toggleFn(element, className); } export function createElementWith(contents: string | HTMLElement, target?: HTMLElement) { const container = document.createElement('div'); if (isString(contents)) { container.innerHTML = contents; } else { container.appendChild(contents); } const { firstChild } = container; if (target) { target.appendChild(firstChild!); } return firstChild; } export function getOuterWidth(el: HTMLElement) { const computed = window.getComputedStyle(el); return ( ['margin-left', 'margin-right'].reduce( (acc, type) => acc + parseInt(computed.getPropertyValue(type), 10), 0 ) + el.offsetWidth ); } export function closest(node: Node, found: string | Node) { let condition; if (isString(found)) { condition = (target: Node) => matches(target as Element, found); } else { condition = (target: Node) => target === found; } while (node && node !== document) { if (isElemNode(node) && condition(node)) { return node; } node = node.parentNode!; } return null; } export function getTotalOffset(el: HTMLElement, root: HTMLElement) { let offsetTop = 0; let offsetLeft = 0; while (el && el !== root) { const { offsetTop: top, offsetLeft: left, offsetParent } = el; offsetTop += top; offsetLeft += left; if (offsetParent === root.offsetParent) { break; } el = el.offsetParent as HTMLElement; } return { offsetTop, offsetLeft }; } export function finalizeHtml(html: Element, needHtmlText: boolean) { let result; if (needHtmlText) { result = html.innerHTML; } else { const frag = document.createDocumentFragment(); const childNodes = toArray(html.childNodes); const { length } = childNodes; for (let i = 0; i < length; i += 1) { frag.appendChild(childNodes[i]); } result = frag; } return result; } export function empty(node: Node) { while (node.firstChild) { node.removeChild(node.firstChild); } } export function appendNode(node: Element, appended: string | ArrayLike | Element) { if (isString(appended)) { node.insertAdjacentHTML('beforeend', appended); } else { const nodes: Element[] = (appended as ArrayLike).length ? toArray(appended as ArrayLike) : [appended as Element]; for (let i = 0, len = nodes.length; i < len; i += 1) { node.appendChild(nodes[i]); } } } export function prependNode(node: Element, appended: string | ArrayLike | Element) { if (isString(appended)) { node.insertAdjacentHTML('afterbegin', appended); } else { const nodes: Element[] = (appended as ArrayLike).length ? toArray(appended as ArrayLike) : [appended as Element]; for (let i = nodes.length - 1, len = 0; i >= len; i -= 1) { node.insertBefore(nodes[i], node.firstChild); } } } export function setAttributes(attributes: Record, element: HTMLElement) { Object.keys(attributes).forEach((attrName) => { if (isNil(attributes[attrName])) { element.removeAttribute(attrName); } else { element.setAttribute(attrName, attributes[attrName]); } }); } export function replaceBRWithEmptyBlock(html: string) { // remove br in paragraph to compatible with markdown let replacedHTML = html.replace(/

                      <\/p>/gi, '

                      '); const reHTMLTag = new RegExp(HTML_TAG, 'ig'); const htmlTagMatched = replacedHTML.match(reHTMLTag); htmlTagMatched?.forEach((htmlTag, index) => { if (reBR.test(htmlTag)) { let alternativeTag = ALTERNATIVE_TAG_FOR_BR; if (index) { const prevTag = htmlTagMatched[index - 1]; const openTagMatched = prevTag.match(OPEN_TAG); if (openTagMatched && !/br/i.test(openTagMatched[1])) { const [, tagName] = openTagMatched; alternativeTag = `<${tagName}>`; } } replacedHTML = replacedHTML.replace(reBR, alternativeTag); } }); return replacedHTML; } export function removeProseMirrorHackNodes(html: string) { const reProseMirrorImage = //g; const reProseMirrorTrailingBreak = / class="ProseMirror-trailingBreak"/g; let resultHTML = html; resultHTML = resultHTML.replace(reProseMirrorImage, ''); resultHTML = resultHTML.replace(reProseMirrorTrailingBreak, ''); return resultHTML; } ================================================ FILE: apps/editor/src/utils/map.ts ================================================ import inArray from 'tui-code-snippet/array/inArray'; import { Mapable } from '@t/map'; /** * @class * @ignore * @classdesc ES6 Map */ class Map implements Mapable { private keys: K[]; private values: V[]; constructor() { this.keys = []; this.values = []; } private getKeyIndex(key: K) { return inArray(key, this.keys); } get(key: K): V { return this.values[this.getKeyIndex(key)]; } set(key: K, value: V) { const keyIndex = this.getKeyIndex(key); if (keyIndex > -1) { this.values[keyIndex] = value; } else { this.keys.push(key); this.values.push(value); } return this; } has(key: K) { return this.getKeyIndex(key) > -1; } delete(key: K) { const keyIndex = this.getKeyIndex(key); if (keyIndex > -1) { this.keys.splice(keyIndex, 1); this.values.splice(keyIndex, 1); return true; } return false; } forEach(callback: (value: V, key: K, map: Mapable) => void, thisArg = this) { this.values.forEach((value, index) => { if (value && this.keys[index]) { callback.call(thisArg, value, this.keys[index], this); } }); } clear() { this.keys = []; this.values = []; } } export default Map; ================================================ FILE: apps/editor/src/utils/markdown.ts ================================================ import { CodeBlockMdNode, CustomBlockMdNode, LinkMdNode, ListItemMdNode, MdNode, MdNodeType, TableCellMdNode, MdPos, } from '@toast-ui/toastmark'; import { includes } from './common'; export function hasSpecificTypeAncestor(mdNode: MdNode, ...types: MdNodeType[]) { while (mdNode && mdNode.parent && mdNode.parent.type !== 'document') { if (includes(types, mdNode.parent.type)) { return true; } mdNode = mdNode.parent; } return false; } export function getMdStartLine(mdNode: MdNode) { return mdNode.sourcepos![0][0]; } export function getMdEndLine(mdNode: MdNode) { return mdNode.sourcepos![1][0]; } export function getMdStartCh(mdNode: MdNode) { return mdNode.sourcepos![0][1]; } export function getMdEndCh(mdNode: MdNode) { return mdNode.sourcepos![1][1]; } export function isMultiLineNode(mdNode: MdNode) { const { type } = mdNode; return type === 'codeBlock' || type === 'paragraph'; } export function isHTMLNode(mdNode: MdNode) { const { type } = mdNode; return type === 'htmlBlock' || type === 'htmlInline'; } export function isStyledInlineNode(mdNode: MdNode) { const { type } = mdNode; return ( type === 'strike' || type === 'strong' || type === 'emph' || type === 'code' || type === 'link' || type === 'image' ); } export function isCodeBlockNode(mdNode: MdNode): mdNode is CodeBlockMdNode { return mdNode && mdNode.type === 'codeBlock'; } export function isCustomBlockNode(mdNode: MdNode): mdNode is CustomBlockMdNode { return mdNode && mdNode.type === 'customBlock'; } export function isListNode(mdNode: MdNode): mdNode is ListItemMdNode { return mdNode && (mdNode.type === 'item' || mdNode.type === 'list'); } export function isOrderedListNode(mdNode: MdNode): mdNode is ListItemMdNode { return isListNode(mdNode) && mdNode.listData.type === 'ordered'; } export function isBulletListNode(mdNode: MdNode): mdNode is ListItemMdNode { return isListNode(mdNode) && mdNode.listData.type !== 'ordered'; } export function isTableCellNode(mdNode: MdNode): mdNode is TableCellMdNode { return mdNode && (mdNode.type === 'tableCell' || mdNode.type === 'tableDelimCell'); } export function isInlineNode(mdNode: MdNode) { switch (mdNode.type) { case 'code': case 'text': case 'emph': case 'strong': case 'strike': case 'link': case 'image': case 'htmlInline': case 'linebreak': case 'softbreak': case 'customInline': return true; default: return false; } } export function findClosestNode( mdNode: MdNode, condition: (targetMdNode: MdNode) => boolean, includeSelf = true ) { mdNode = includeSelf ? mdNode : mdNode.parent!; while (mdNode && mdNode.type !== 'document') { if (condition(mdNode)) { return mdNode; } mdNode = mdNode.parent!; } return null; } export function traverseParentNodes( mdNode: MdNode, iteratee: (targetNode: MdNode) => void, includeSelf = true ) { mdNode = includeSelf ? mdNode! : mdNode.parent!; while (mdNode && mdNode.type !== 'document') { iteratee(mdNode); mdNode = mdNode.parent!; } } export function addOffsetPos(originPos: MdPos, offset: number): MdPos { return [originPos[0], originPos[1] + offset]; } export function setOffsetPos(originPos: MdPos, newOffset: number): MdPos { return [originPos[0], newOffset]; } export function getInlineMarkdownText(mdNode: MdNode) { const text = mdNode.firstChild!.literal; switch (mdNode.type) { case 'emph': return `*${text}*`; case 'strong': return `**${text}**`; case 'strike': return `~~${text}~~`; case 'code': return `\`${text}\``; case 'link': case 'image': /* eslint-disable no-case-declarations */ const { destination, title } = mdNode as LinkMdNode; const delim = mdNode.type === 'link' ? '' : '!'; return `${delim}[${text}](${destination}${title ? ` "${title}"` : ''})`; default: return null; } } export function isContainer(node: MdNode) { switch (node.type) { case 'document': case 'blockQuote': case 'list': case 'item': case 'paragraph': case 'heading': case 'emph': case 'strong': case 'strike': case 'link': case 'image': case 'table': case 'tableHead': case 'tableBody': case 'tableRow': case 'tableCell': case 'tableDelimRow': case 'customInline': return true; default: return false; } } export function getChildrenText(node: MdNode) { const buffer: string[] = []; const walker = node.walker(); let event: ReturnType = null; while ((event = walker.next())) { const { node: childNode } = event; if (childNode.type === 'text') { buffer.push(childNode.literal!); } } return buffer.join(''); } ================================================ FILE: apps/editor/src/viewer.ts ================================================ import { ToastMark } from '@toast-ui/toastmark'; import forEachOwnProperties from 'tui-code-snippet/collection/forEachOwnProperties'; import extend from 'tui-code-snippet/object/extend'; import on from 'tui-code-snippet/domEvent/on'; import off from 'tui-code-snippet/domEvent/off'; import { CustomHTMLRenderer, ViewerOptions } from '@t/editor'; import { Emitter, Handler } from '@t/event'; import MarkdownPreview from './markdown/mdPreview'; import { getPluginInfo } from './helper/plugin'; import { last, sanitizeLinkAttribute } from './utils/common'; import EventEmitter from './event/eventEmitter'; import { cls, isPositionInBox, toggleClass } from './utils/dom'; import { registerTagWhitelistIfPossible, sanitizeHTML } from './sanitizer/htmlSanitizer'; const TASK_ATTR_NAME = 'data-task'; const DISABLED_TASK_ATTR_NAME = 'data-task-disabled'; const TASK_CHECKED_CLASS_NAME = 'checked'; function registerHTMLTagToWhitelist(convertorMap: CustomHTMLRenderer) { ['htmlBlock', 'htmlInline'].forEach((htmlType) => { if (convertorMap[htmlType]) { // register tag white list for preventing to remove the html in sanitizer Object.keys(convertorMap[htmlType]!).forEach((type) => registerTagWhitelistIfPossible(type)); } }); } /** * Class ToastUIEditorViewer * @param {object} options Option object * @param {HTMLElement} options.el - container element * @param {string} [options.initialValue] Editor's initial value * @param {Object} [options.events] - Events * @param {function} [options.events.load] - It would be emitted when editor fully load * @param {function} [options.events.change] - It would be emitted when content changed * @param {function} [options.events.caretChange] - It would be emitted when format change by cursor position * @param {function} [options.events.focus] - It would be emitted when editor get focus * @param {function} [options.events.blur] - It would be emitted when editor loose focus * @param {Array.} [options.plugins] - Array of plugins. A plugin can be either a function or an array in the form of [function, options]. * @param {Object} [options.extendedAutolinks] - Using extended Autolinks specified in GFM spec * @param {Object} [options.linkAttributes] - Attributes of anchor element that should be rel, target, hreflang, type * @param {Object} [options.customHTMLRenderer=null] - Object containing custom renderer functions correspond to change markdown node to preview HTML or wysiwyg node * @param {boolean} [options.referenceDefinition=false] - whether use the specification of link reference definition * @param {function} [options.customHTMLSanitizer=null] - custom HTML sanitizer * @param {boolean} [options.frontMatter=false] - whether use the front matter * @param {string} [options.theme] - The theme to style the viewer with. The default is included in toastui-editor.css. */ class ToastUIEditorViewer { private options: Required; private toastMark: ToastMark; private eventEmitter: Emitter; private preview: MarkdownPreview; constructor(options: ViewerOptions) { this.options = extend( { linkAttributes: null, extendedAutolinks: false, customHTMLRenderer: null, referenceDefinition: false, customHTMLSanitizer: null, frontMatter: false, usageStatistics: true, theme: 'light', }, options ); this.eventEmitter = new EventEmitter(); const linkAttributes = sanitizeLinkAttribute(this.options.linkAttributes); const { toHTMLRenderers, markdownParsers } = getPluginInfo({ plugins: this.options.plugins, eventEmitter: this.eventEmitter, usageStatistics: this.options.usageStatistics, instance: this, }) || {}; const { customHTMLRenderer, extendedAutolinks, referenceDefinition, frontMatter, customHTMLSanitizer, } = this.options; const rendererOptions = { linkAttributes, customHTMLRenderer: { ...toHTMLRenderers, ...customHTMLRenderer }, extendedAutolinks, referenceDefinition, frontMatter, sanitizer: customHTMLSanitizer || sanitizeHTML, }; registerHTMLTagToWhitelist(rendererOptions.customHTMLRenderer); if (this.options.events) { forEachOwnProperties(this.options.events, (fn, key) => { this.on(key, fn); }); } const { el, initialValue, theme } = this.options; const existingHTML = el.innerHTML; if (theme !== 'light') { el.classList.add(cls(theme)); } el.innerHTML = ''; this.toastMark = new ToastMark('', { disallowedHtmlBlockTags: ['br', 'img'], extendedAutolinks, referenceDefinition, disallowDeepHeading: true, frontMatter, customParser: markdownParsers, }); this.preview = new MarkdownPreview(this.eventEmitter, { ...rendererOptions, isViewer: true, }); on(this.preview.previewContent!, 'mousedown', this.toggleTask.bind(this)); if (initialValue) { this.setMarkdown(initialValue); } else if (existingHTML) { this.preview.setHTML(existingHTML); } el.appendChild(this.preview.previewContent); this.eventEmitter.emit('load', this); } /** * Toggle task by detecting mousedown event. * @param {MouseEvent} ev - event * @private */ private toggleTask(ev: MouseEvent) { const element = ev.target as HTMLElement; const style = getComputedStyle(element, ':before'); if ( !element.hasAttribute(DISABLED_TASK_ATTR_NAME) && element.hasAttribute(TASK_ATTR_NAME) && isPositionInBox(style, ev.offsetX, ev.offsetY) ) { toggleClass(element, TASK_CHECKED_CLASS_NAME); this.eventEmitter.emit('change', { source: 'viewer', date: ev, }); } } /** * Set content for preview * @param {string} markdown Markdown text */ setMarkdown(markdown: string) { const lineTexts: string[] = this.toastMark.getLineTexts(); const { length } = lineTexts; const lastLine = last(lineTexts); const endSourcepos: [number, number] = [length, lastLine.length + 1]; const editResult = this.toastMark.editMarkdown([1, 1], endSourcepos, markdown || ''); this.eventEmitter.emit('updatePreview', editResult); } /** * Bind eventHandler to event type * @param {string} type Event type * @param {function} handler Event handler */ on(type: string, handler: Handler) { this.eventEmitter.listen(type, handler); } /** * Unbind eventHandler from event type * @param {string} type Event type */ off(type: string) { this.eventEmitter.removeEventHandler(type); } /** * Add hook to TUIEditor event * @param {string} type Event type * @param {function} handler Event handler */ addHook(type: string, handler: Handler) { this.eventEmitter.removeEventHandler(type); this.eventEmitter.listen(type, handler); } /** * Remove Viewer preview from document */ destroy() { off(this.preview.el!, 'mousedown', this.toggleTask.bind(this)); this.preview.destroy(); this.eventEmitter.emit('destroy'); } /** * Return true * @returns {boolean} */ isViewer() { return true; } /** * Return false * @returns {boolean} */ isMarkdownMode() { return false; } /** * Return false * @returns {boolean} */ isWysiwygMode() { return false; } } export default ToastUIEditorViewer; ================================================ FILE: apps/editor/src/widget/rules.ts ================================================ import { Schema, ProsemirrorNode } from 'prosemirror-model'; import { CustomInlineMdNode } from '@toast-ui/toastmark'; import { WidgetRule, WidgetRuleMap } from '@t/editor'; import { getInlineMarkdownText } from '@/utils/markdown'; let widgetRules: WidgetRule[] = []; const widgetRuleMap: WidgetRuleMap = {}; const reWidgetPrefix = /\$\$widget\d+\s/; export function unwrapWidgetSyntax(text: string) { const index = text.search(reWidgetPrefix); if (index !== -1) { const rest = text.substring(index); const replaced = rest.replace(reWidgetPrefix, '').replace('$$', ''); text = text.substring(0, index); text += unwrapWidgetSyntax(replaced); } return text; } export function createWidgetContent(info: string, text: string) { return `$$${info} ${text}$$`; } export function widgetToDOM(info: string, text: string) { const { rule, toDOM } = widgetRuleMap[info]; const matches = unwrapWidgetSyntax(text).match(rule); if (matches) { text = matches[0]; } return toDOM(text); } export function getWidgetRules() { return widgetRules; } export function setWidgetRules(rules: WidgetRule[]) { widgetRules = rules; widgetRules.forEach((rule, index) => { widgetRuleMap[`widget${index}`] = rule; }); } function mergeNodes(nodes: ProsemirrorNode[], text: string, schema: Schema, ruleIndex: number) { return nodes.concat(createNodesWithWidget(text, schema, ruleIndex)); } /** * create nodes with plain text and replace text matched to the widget rules with the widget node * For example, in case the text and widget rules as below * * text: $test plain text #test * widget rules: [{ rule: /$.+/ }, { rule: /#.+/ }] * * The creating node process is recursive and is as follows. * * in first widget rule(/$.+/) * $test -> widget node * plain text -> match with next widget rule * #test -> match with next widget rule * * in second widget rule(/#.+/) * plain text -> text node(no rule for matching) * #test -> widget node */ export function createNodesWithWidget(text: string, schema: Schema, ruleIndex = 0) { let nodes: ProsemirrorNode[] = []; const { rule } = widgetRules[ruleIndex] || {}; const nextRuleIndex = ruleIndex + 1; text = unwrapWidgetSyntax(text); if (rule && rule.test(text)) { let index; while ((index = text.search(rule)) !== -1) { const prev = text.substring(0, index); // get widget node on first splitted text using next widget rule if (prev) { nodes = mergeNodes(nodes, prev, schema, nextRuleIndex); } // build widget node using current widget rule text = text.substring(index); const [literal] = text.match(rule)!; const info = `widget${ruleIndex}`; nodes.push( schema.nodes.widget.create({ info }, schema.text(createWidgetContent(info, literal))) ); text = text.substring(literal.length); } // get widget node on last splitted text using next widget rule if (text) { nodes = mergeNodes(nodes, text, schema, nextRuleIndex); } } else if (text) { nodes = ruleIndex < widgetRules.length - 1 ? mergeNodes(nodes, text, schema, nextRuleIndex) : [schema.text(text)]; } return nodes; } export function getWidgetContent(widgetNode: CustomInlineMdNode) { let event; let text = ''; const walker = widgetNode.walker(); while ((event = walker.next())) { const { node, entering } = event; if (entering) { if (node !== widgetNode && node.type !== 'text') { text += getInlineMarkdownText(node); // skip the children walker.resumeAt(widgetNode, false); walker.next(); } else if (node.type === 'text') { text += node.literal; } } } return text; } ================================================ FILE: apps/editor/src/widget/widgetNode.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import SpecNode from '@/spec/node'; import { widgetToDOM } from './rules'; export function widgetNodeView(pmNode: ProsemirrorNode) { const dom = document.createElement('span'); const node = widgetToDOM(pmNode.attrs.info, pmNode.textContent); dom.className = 'tui-widget'; dom.appendChild(node); return { dom }; } export function isWidgetNode(pmNode: ProsemirrorNode) { return pmNode.type.name === 'widget'; } export class Widget extends SpecNode { get name() { return 'widget'; } get schema() { return { attrs: { info: { default: null }, }, group: 'inline', inline: true, content: 'text*', selectable: false, atom: true, toDOM(): DOMOutputSpec { return ['span', { class: 'tui-widget' }, 0]; }, parseDOM: [ { tag: 'span.tui-widget', getAttrs(dom: Node | string) { const text = (dom as HTMLElement).textContent!; const [, info] = text.match(/\$\$(widget\d+)/)!; return { info }; }, }, ], }; } } ================================================ FILE: apps/editor/src/wysiwyg/adaptor/mdLikeNode.ts ================================================ import { MdNodeType } from '@toast-ui/toastmark'; import { Mark, Node as ProsemirrorNode } from 'prosemirror-model'; import { MdLikeNode } from '@t/markdown'; import { includes } from '@/utils/common'; export function isPmNode(node: ProsemirrorNode | Mark): node is ProsemirrorNode { return node instanceof ProsemirrorNode; } export function isContainer(type: string) { const containerTypes = [ 'document', 'blockQuote', 'bulletList', 'orderedList', 'listItem', 'paragraph', 'heading', 'emph', 'strong', 'strike', 'link', 'image', 'table', 'tableHead', 'tableBody', 'tableRow', 'tableHeadCell', 'tableBodyCell', ]; return includes(containerTypes, type); } export function createMdLikeNode(node: ProsemirrorNode | Mark): MdLikeNode { const { attrs, type } = node; const nodeType = type.name; const mdLikeNode: MdLikeNode = { type: nodeType as MdNodeType, wysiwygNode: true, literal: !isContainer(nodeType) && isPmNode(node) ? node.textContent : null, }; const nodeTypeMap = { heading: { level: attrs.level }, link: { destination: attrs.linkUrl, title: attrs.title }, image: { destination: attrs.imageUrl }, codeBlock: { info: attrs.language }, bulletList: { type: 'list', listData: { type: 'bullet' } }, orderedList: { type: 'list', listData: { type: 'ordered', start: attrs.order } }, listItem: { type: 'item', listData: { task: attrs.task, checked: attrs.checked } }, tableHeadCell: { type: 'tableCell', cellType: 'head', align: attrs.align }, tableBodyCell: { type: 'tableCell', cellType: 'body', align: attrs.align }, customBlock: { info: attrs.info }, } as const; const nodeInfo = nodeTypeMap[nodeType as keyof typeof nodeTypeMap]; const attributes = { ...mdLikeNode, ...nodeInfo }; // html block, inline node const { htmlAttrs, childrenHTML } = node.attrs; if (htmlAttrs) { return { ...attributes, attrs: htmlAttrs, childrenHTML, }; } return attributes; } ================================================ FILE: apps/editor/src/wysiwyg/adaptor/wwToDOMAdaptor.ts ================================================ import { Context, HTMLConvertorMap, HTMLToken, MdNode, MdNodeType, OpenTagToken, RawHTMLToken, Renderer, TextToken, } from '@toast-ui/toastmark'; import { ProsemirrorNode, Mark } from 'prosemirror-model'; import isArray from 'tui-code-snippet/type/isArray'; import { getHTMLRenderConvertors } from '@/markdown/htmlRenderConvertors'; import { ToDOMAdaptor } from '@t/convertor'; import { includes, last } from '@/utils/common'; import { CustomHTMLRenderer, LinkAttributes } from '@t/editor'; import { setAttributes } from '@/utils/dom'; import { createMdLikeNode, isContainer, isPmNode } from './mdLikeNode'; interface TokenToDOM { openTag: (token: HTMLToken, stack: T[]) => void; closeTag: (token: HTMLToken, stack: T[]) => void; html: (token: HTMLToken, stack: T[]) => void; text: (token: HTMLToken, stack: T[]) => void; } const tokenToDOMNode: TokenToDOM = { openTag(token, stack) { const { tagName, classNames, attributes } = token as OpenTagToken; const el = document.createElement(tagName); let attrs: Record = {}; if (classNames) { el.className = classNames.join(' '); } if (attributes) { attrs = { ...attrs, ...attributes }; } setAttributes(attrs, el); stack.push(el); }, closeTag(_, stack) { if (stack.length > 1) { const el = stack.pop(); last(stack).appendChild(el!); } }, html(token, stack) { last(stack).insertAdjacentHTML('beforeend', (token as RawHTMLToken).content); }, text(token, stack) { const textNode = document.createTextNode((token as TextToken).content); last(stack).appendChild(textNode); }, }; export class WwToDOMAdaptor implements ToDOMAdaptor { private customConvertorKeys: string[]; renderer: Renderer; convertors: HTMLConvertorMap; constructor(linkAttributes: LinkAttributes | null, customRenderer: CustomHTMLRenderer) { const convertors = getHTMLRenderConvertors(linkAttributes, customRenderer); const customHTMLConvertor = { ...customRenderer.htmlBlock, ...customRenderer.htmlInline }; // flatten the html block, inline convertor to other custom convertors this.customConvertorKeys = Object.keys(customRenderer).concat(Object.keys(customHTMLConvertor)); this.renderer = new Renderer({ gfm: true, convertors: { ...convertors, ...customHTMLConvertor }, }); this.convertors = this.renderer.getConvertors(); } private generateTokens(node: ProsemirrorNode | Mark) { const mdLikeNode = createMdLikeNode(node); const context: Context = { entering: true, leaf: isPmNode(node) ? node.isLeaf : false, options: this.renderer.getOptions(), getChildrenText: () => (isPmNode(node) ? node.textContent : ''), skipChildren: () => false, }; const convertor = this.convertors[node.type.name as MdNodeType]!; const converted = convertor(mdLikeNode as MdNode, context, this.convertors)!; let tokens: HTMLToken[] = isArray(converted) ? converted : [converted]; if (isContainer(node.type.name) || node.attrs.htmlInline) { context.entering = false; tokens.push({ type: 'text', content: isPmNode(node) ? node.textContent : '' }); tokens = tokens.concat(convertor(mdLikeNode as MdNode, context, this.convertors)!); } return tokens; } private toDOMNode(node: ProsemirrorNode | Mark) { const tokens = this.generateTokens(node); const stack: HTMLElement[] = []; tokens.forEach((token) => tokenToDOMNode[token.type](token, stack)); return stack[0]; } getToDOMNode(name: string) { if (includes(this.customConvertorKeys, name)) { return this.toDOMNode.bind(this); } return null; } } ================================================ FILE: apps/editor/src/wysiwyg/clipboard/paste.ts ================================================ import { Schema, Node, Slice, Fragment, NodeType } from 'prosemirror-model'; import { isFromMso, convertMsoParagraphsToList } from '@/wysiwyg/clipboard/pasteMsoList'; import { getTableContentFromSlice } from '@/wysiwyg/helper/table'; import { ALTERNATIVE_TAG_FOR_BR } from '@/utils/constants'; const START_FRAGMENT_COMMENT = ''; const END_FRAGMENT_COMMENT = ''; function getContentBetweenFragmentComments(html: string) { const startFragmentIndex = html.indexOf(START_FRAGMENT_COMMENT); const endFragmentIndex = html.lastIndexOf(END_FRAGMENT_COMMENT); if (startFragmentIndex > -1 && endFragmentIndex > -1) { html = html.slice(startFragmentIndex + START_FRAGMENT_COMMENT.length, endFragmentIndex); } return html.replace(/]*>/g, ALTERNATIVE_TAG_FOR_BR); } function convertMsoTableToCompletedTable(html: string) { // wrap with if html contains dangling tags // dangling tag is that tag does not have as parent node if (/<\/td>((?!<\/tr>)[\s\S])*$/i.test(html)) { html = `${html}`; } // wrap with if html contains dangling tags // dangling tag is that tag does not have
                      as parent node if (/<\/tr>((?!<\/table>)[\s\S])*$/i.test(html)) { html = `
                      ${html}
                      `; } return html; } export function changePastedHTML(html: string) { html = getContentBetweenFragmentComments(html); html = convertMsoTableToCompletedTable(html); if (isFromMso(html)) { html = convertMsoParagraphsToList(html); } return html; } function getMaxColumnCount(rows: Node[]) { const row = rows.reduce((prevRow, currentRow) => prevRow.childCount > currentRow.childCount ? prevRow : currentRow ); return row.childCount; } function createCells(orgRow: Node, maxColumnCount: number, cell: NodeType) { const cells = []; const cellCount = orgRow.childCount; for (let colIdx = 0; colIdx < cellCount; colIdx += 1) { if (!orgRow.child(colIdx).attrs.extended) { const copiedCell = colIdx < cellCount ? cell.create(orgRow.child(colIdx).attrs, orgRow.child(colIdx).content) : cell.createAndFill()!; cells.push(copiedCell); } } return cells; } export function copyTableHeadRow(orgRow: Node, maxColumnCount: number, schema: Schema) { const { tableRow, tableHeadCell } = schema.nodes; const cells = createCells(orgRow, maxColumnCount, tableHeadCell); return tableRow.create(null, cells); } export function copyTableBodyRow(orgRow: Node, maxColumnCount: number, schema: Schema) { const { tableRow, tableBodyCell } = schema.nodes; const cells = createCells(orgRow, maxColumnCount, tableBodyCell); return tableRow.create(null, cells); } function creatTableBodyDummyRow(columnCount: number, schema: Schema) { const { tableRow, tableBodyCell } = schema.nodes; const cells = []; for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { const dummyCell = tableBodyCell.createAndFill()!; cells.push(dummyCell); } return tableRow.create({ dummyRowForPasting: true }, cells); } export function createRowsFromPastingTable(tableContent: Fragment) { const tableHeadRows: Node[] = []; const tableBodyRows: Node[] = []; if (tableContent.firstChild!.type.name === 'tableHead') { const tableHead = tableContent.firstChild!; tableHead.forEach((row) => tableHeadRows.push(row)); } if (tableContent.lastChild!.type.name === 'tableBody') { const tableBody = tableContent.lastChild!; tableBody.forEach((row) => tableBodyRows.push(row)); } return [...tableHeadRows, ...tableBodyRows]; } function createTableHead(tableHeadRow: Node, maxColumnCount: number, schema: Schema) { const copiedRow = copyTableHeadRow(tableHeadRow, maxColumnCount, schema); return schema.nodes.tableHead.create(null, copiedRow); } function createTableBody(tableBodyRows: Node[], maxColumnCount: number, schema: Schema) { const copiedRows = tableBodyRows.map((tableBodyRow) => copyTableBodyRow(tableBodyRow, maxColumnCount, schema) ); if (!tableBodyRows.length) { const dummyTableRow = creatTableBodyDummyRow(maxColumnCount, schema); copiedRows.push(dummyTableRow); } return schema.nodes.tableBody.create(null, copiedRows); } function createTableFromPastingTable( rows: Node[], schema: Schema, startFromBody: boolean, isInTable: boolean ) { const columnCount = getMaxColumnCount(rows); if (startFromBody && isInTable) { return schema.nodes.table.create(null, [createTableBody(rows, columnCount, schema)]); } const [tableHeadRow] = rows; const tableBodyRows = rows.slice(1); const nodes = [createTableHead(tableHeadRow, columnCount, schema)]; if (tableBodyRows.length) { nodes.push(createTableBody(tableBodyRows, columnCount, schema)); } return schema.nodes.table.create(null, nodes); } export function changePastedSlice(slice: Slice, schema: Schema, isInTable: boolean) { const nodes: Node[] = []; const { content, openStart, openEnd } = slice; content.forEach((node) => { if (node.type.name === 'table') { const tableContent = getTableContentFromSlice(new Slice(Fragment.from(node), 0, 0)); if (tableContent) { const rows = createRowsFromPastingTable(tableContent); const startFromBody = tableContent.firstChild!.type.name === 'tableBody'; const table = createTableFromPastingTable(rows, schema, startFromBody, isInTable); nodes.push(table); } } else { nodes.push(node); } }); return new Slice(Fragment.from(nodes), openStart, openEnd); } ================================================ FILE: apps/editor/src/wysiwyg/clipboard/pasteMsoList.ts ================================================ import { isElemNode, findNodes, removeNode, unwrapNode, insertBeforeNode, appendNodes, } from '@/utils/dom'; const reMSOListClassName = /MsoListParagraph/; const reMSOStylePrefix = /style=(.|\n)*mso-/; const reMSOListStyle = /mso-list:(.*)/; const reMSOTagName = /O:P/; const reMSOListBullet = /^(n|u|l)/; const MSO_CLASS_NAME_LIST_PARA = 'p.MsoListParagraph'; interface ListItemData { id: number; level: number; prev: ListItemData | null; parent: ListItemData | null; children: ListItemData[]; unordered: boolean; contents: string; } export function isFromMso(html: string) { return reMSOStylePrefix.test(html); } function getListItemContents(para: HTMLElement) { const removedNodes = []; const walker = document.createTreeWalker(para, 1, null, false); while (walker.nextNode()) { const node = walker.currentNode; if (isElemNode(node)) { const { outerHTML, textContent } = node as HTMLElement; const msoSpan = reMSOStylePrefix.test(outerHTML); const bulletSpan = reMSOListStyle.test(outerHTML); if (msoSpan && !bulletSpan && textContent) { removedNodes.push([node, true]); } else if (reMSOTagName.test(node.nodeName) || (msoSpan && !textContent) || bulletSpan) { removedNodes.push([node, false]); } } } removedNodes.forEach(([node, isUnwrap]) => { if (isUnwrap) { unwrapNode(node as HTMLElement); } else { removeNode(node as HTMLElement); } }); return para.innerHTML.trim(); } function createListItemDataFromParagraph(para: HTMLElement, index: number) { const styleAttr = para.getAttribute('style'); if (styleAttr) { const [, listItemInfo] = styleAttr.match(reMSOListStyle)!; const [, levelStr] = listItemInfo.trim().split(' '); const level = parseInt(levelStr.replace('level', ''), 10); const unordered = reMSOListBullet.test(para.textContent || ''); return { id: index, level, prev: null, parent: null, children: [], unordered, contents: getListItemContents(para), }; } return null; } function addListItemDetailData(data: ListItemData, prevData: ListItemData) { if (prevData.level < data.level) { prevData.children.push(data); data.parent = prevData; } else { while (prevData) { if (prevData.level === data.level) { break; } prevData = prevData.parent!; } if (prevData) { data.prev = prevData; data.parent = prevData.parent; if (data.parent) { data.parent.children.push(data); } } } } function createListData(paras: HTMLElement[]) { const listData: ListItemData[] = []; paras.forEach((para, index) => { const prevListItemData = listData[index - 1]; const listItemData = createListItemDataFromParagraph(para, index); if (listItemData) { if (prevListItemData) { addListItemDetailData(listItemData, prevListItemData); } listData.push(listItemData); } }); return listData; } function makeList(listData: ListItemData[]) { const listTagName = listData[0].unordered ? 'ul' : 'ol'; const list = document.createElement(listTagName); listData.forEach((data) => { const { children, contents } = data; const listItem = document.createElement('li'); listItem.innerHTML = contents; list.appendChild(listItem); if (children.length) { list.appendChild(makeList(children)); } }); return list; } function makeListFromParagraphs(paras: HTMLElement[]) { const listData = createListData(paras); const rootChildren = listData.filter(({ parent }) => !parent); return makeList(rootChildren); } function isMsoListParagraphEnd(node: HTMLElement) { while (node) { if (isElemNode(node)) { break; } node = node.nextSibling as HTMLElement; } return node ? !reMSOListClassName.test(node.className) : true; } export function convertMsoParagraphsToList(html: string) { const container = document.createElement('div') as HTMLElement; container.innerHTML = html; let paras: HTMLElement[] = []; const foundParas = findNodes(container, MSO_CLASS_NAME_LIST_PARA); foundParas.forEach((para) => { const msoListParaEnd = isMsoListParagraphEnd(para.nextSibling as HTMLElement); paras.push(para as HTMLElement); if (msoListParaEnd) { const list = makeListFromParagraphs(paras); const { nextSibling } = para; if (nextSibling) { insertBeforeNode(list, nextSibling); } else { appendNodes(container, list); } paras = []; } removeNode(para); }); // without `

                      `, the list string was parsed as a paragraph node and added const extraHTML = foundParas.length ? '

                      ' : ''; return `${extraHTML}${container.innerHTML}`; } ================================================ FILE: apps/editor/src/wysiwyg/clipboard/pasteToTable.ts ================================================ import { Schema, Slice, Fragment } from 'prosemirror-model'; import { Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { getResolvedSelection, createDummyCells, createTableBodyRows, getTableContentFromSlice, getRowAndColumnCount, } from '@/wysiwyg/helper/table'; import { createRowsFromPastingTable, copyTableHeadRow, copyTableBodyRow, } from '@/wysiwyg/clipboard/paste'; import CellSelection from '@/wysiwyg/plugins/selection/cellSelection'; import { last } from '@/utils/common'; import { SelectionInfo, TableOffsetMap } from '@/wysiwyg/helper/tableOffsetMap'; interface PastingRangeInfo { addedRowCount: number; addedColumnCount: number; startRowIdx: number; startColIdx: number; endColIdx: number; endRowIdx: number; } interface ReplacedCellsOffsets { rowIdx: number; startColIdx: number; endColIdx: number; dummyOffsets?: [startCellOffset: number, endCellOffset: number]; } const DUMMY_CELL_SIZE = 4; const TR_NODES_SIZE = 2; function getDummyCellSize(dummyCellCount: number) { return dummyCellCount * DUMMY_CELL_SIZE; } function createPastingCells( tableContent: Fragment, curSelectionInfo: SelectionInfo, schema: Schema ) { const pastingRows: Fragment[] = []; const pastingTableRows = createRowsFromPastingTable(tableContent); const columnCount = pastingTableRows[0].childCount; const rowCount = pastingTableRows.length; const startToTableHead = curSelectionInfo.startRowIdx === 0; const slicedRows = pastingTableRows.slice(0, rowCount); if (startToTableHead) { const tableHeadRow = slicedRows.shift(); if (tableHeadRow) { const { content } = copyTableHeadRow(tableHeadRow, columnCount, schema); pastingRows.push(content); } } slicedRows.forEach((tableBodyRow) => { if (!tableBodyRow.attrs.dummyRowForPasting) { const { content } = copyTableBodyRow(tableBodyRow, columnCount, schema); pastingRows.push(content); } }); return pastingRows; } function getPastingRangeInfo( map: TableOffsetMap, { startRowIdx, startColIdx }: SelectionInfo, pastingCells: Fragment[] ): PastingRangeInfo { const pastingRowCount = pastingCells.length; let pastingColumnCount = 0; for (let i = 0; i < pastingRowCount; i += 1) { let columnCount = pastingCells[i].childCount; pastingCells[i].forEach(({ attrs }) => { const { colspan } = attrs; if (colspan > 1) { columnCount += colspan - 1; } }); pastingColumnCount = Math.max(pastingColumnCount, columnCount); } const endRowIdx = startRowIdx + pastingRowCount - 1; const endColIdx = startColIdx + pastingColumnCount - 1; const addedRowCount = Math.max(endRowIdx + 1 - map.totalRowCount, 0); const addedColumnCount = Math.max(endColIdx + 1 - map.totalColumnCount, 0); return { startRowIdx, startColIdx, endRowIdx, endColIdx, addedRowCount, addedColumnCount, }; } function addReplacedOffsets( { startRowIdx, startColIdx, endRowIdx, endColIdx, addedRowCount, addedColumnCount, }: PastingRangeInfo, cellsOffsets: ReplacedCellsOffsets[] ) { for (let rowIdx = startRowIdx; rowIdx <= endRowIdx - addedRowCount; rowIdx += 1) { cellsOffsets.push({ rowIdx, startColIdx, endColIdx: endColIdx - addedColumnCount, }); } } function expandColumns( tr: Transaction, schema: Schema, map: TableOffsetMap, { startRowIdx, startColIdx, endRowIdx, endColIdx, addedRowCount, addedColumnCount, }: PastingRangeInfo, cellsOffsets: ReplacedCellsOffsets[] ) { const { totalRowCount } = map; let index = 0; for (let rowIdx = 0; rowIdx < totalRowCount; rowIdx += 1) { const { offset, nodeSize } = map.getCellInfo(rowIdx, endColIdx - addedColumnCount); const insertOffset = tr.mapping.map(offset + nodeSize); const cells = createDummyCells(addedColumnCount, rowIdx, schema); tr.insert(insertOffset, cells); if (rowIdx >= startRowIdx && rowIdx <= endRowIdx - addedRowCount) { const cellInfo = map.getCellInfo(rowIdx, endColIdx - addedColumnCount); const startCellOffset = tr.mapping.map(cellInfo.offset); const endCellOffset = insertOffset + getDummyCellSize(addedColumnCount); cellsOffsets[index] = { rowIdx, startColIdx, endColIdx, dummyOffsets: [startCellOffset, endCellOffset], }; index += 1; } } } function expandRows( tr: Transaction, schema: Schema, map: TableOffsetMap, { addedRowCount, addedColumnCount, startColIdx, endColIdx }: PastingRangeInfo, cellsOffsets: ReplacedCellsOffsets[] ) { const mapStart = tr.mapping.maps.length; const tableEndPos = map.tableEndOffset - 2; const rows = createTableBodyRows(addedRowCount, map.totalColumnCount + addedColumnCount, schema); let startOffset = tableEndPos; tr.insert(tr.mapping.slice(mapStart).map(startOffset), rows); for (let rowIndex = 0; rowIndex < addedRowCount; rowIndex += 1) { const startCellOffset = startOffset + getDummyCellSize(startColIdx) + 1; const endCellOffset = startOffset + getDummyCellSize(endColIdx + 1) + 1; const nextCellOffset = startOffset + getDummyCellSize(map.totalColumnCount + addedColumnCount) + TR_NODES_SIZE; cellsOffsets.push({ rowIdx: rowIndex + map.totalRowCount, startColIdx, endColIdx, dummyOffsets: [startCellOffset, endCellOffset], }); startOffset = nextCellOffset; } } function replaceCells( tr: Transaction, pastingRows: Fragment[], cellsOffsets: ReplacedCellsOffsets[], map: TableOffsetMap ) { const mapStart = tr.mapping.maps.length; cellsOffsets.forEach((offsets, index) => { const { rowIdx, startColIdx, endColIdx, dummyOffsets } = offsets; const mapping = tr.mapping.slice(mapStart); const cells = new Slice(pastingRows[index], 0, 0); const from = dummyOffsets ? dummyOffsets[0] : map.getCellStartOffset(rowIdx, startColIdx); const to = dummyOffsets ? dummyOffsets[1] : map.getCellEndOffset(rowIdx, endColIdx); tr.replace(mapping.map(from), mapping.map(to), cells); }); } export function pasteToTable(view: EditorView, slice: Slice) { const { selection, schema, tr } = view.state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const tableContent = getTableContentFromSlice(slice); if (!tableContent) { return false; } const map = TableOffsetMap.create(anchor)!; const curSelectionInfo = map.getRectOffsets(anchor, head); const pastingCells = createPastingCells(tableContent, curSelectionInfo, schema); const pastingInfo = getPastingRangeInfo(map, curSelectionInfo, pastingCells); const cellsOffsets: ReplacedCellsOffsets[] = []; // @TODO: unmerge the span and paste the cell if (canMerge(map, pastingInfo)) { addReplacedOffsets(pastingInfo, cellsOffsets); if (pastingInfo.addedColumnCount) { expandColumns(tr, schema, map, pastingInfo, cellsOffsets); } if (pastingInfo.addedRowCount) { expandRows(tr, schema, map, pastingInfo, cellsOffsets); } replaceCells(tr, pastingCells, cellsOffsets, map); view.dispatch!(tr); setSelection(view, cellsOffsets, map.getCellInfo(0, 0).offset); } return true; } return false; } function setSelection(view: EditorView, cellsOffsets: ReplacedCellsOffsets[], pos: number) { const { tr, doc } = view.state; // get changed cell offsets const map = TableOffsetMap.create(doc.resolve(pos))!; // eslint-disable-next-line prefer-destructuring const { rowIdx: startRowIdx, startColIdx } = cellsOffsets[0]; const { rowIdx: endRowIdx, endColIdx } = last(cellsOffsets); const { offset: startOffset } = map.getCellInfo(startRowIdx, startColIdx); const { offset: endOffset } = map.getCellInfo(endRowIdx, endColIdx); view.dispatch!( tr.setSelection(new CellSelection(doc.resolve(startOffset), doc.resolve(endOffset))) ); } function canMerge(map: TableOffsetMap, pastingInfo: PastingRangeInfo) { const ranges = map.getSpannedOffsets(pastingInfo); const { rowCount, columnCount } = getRowAndColumnCount(ranges); const { rowCount: pastingRowCount, columnCount: pastingColumnCount } = getRowAndColumnCount( pastingInfo ); return rowCount === pastingRowCount && columnCount === pastingColumnCount; } ================================================ FILE: apps/editor/src/wysiwyg/command/list.ts ================================================ import { ProsemirrorNode, NodeType, NodeRange, Fragment, Slice } from 'prosemirror-model'; import { ReplaceAroundStep, canSplit, liftTarget } from 'prosemirror-transform'; import { Transaction, Selection, EditorState } from 'prosemirror-state'; import { Command } from 'prosemirror-commands'; import { findListItem, isInListNode } from '@/wysiwyg/helper/node'; interface Attrs { [key: string]: any; } interface WrapperInfo { type: NodeType; attrs?: Attrs; } function findWrappingOutside(range: NodeRange, type: NodeType) { const { parent, startIndex, endIndex } = range; const around = parent.contentMatchAt(startIndex).findWrapping(type); if (around) { const outer = around.length ? around[0] : type; return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null; } return null; } function findWrappingInside(range: NodeRange, type: NodeType) { const { parent, startIndex, endIndex } = range; const inner = parent.child(startIndex); const inside = type.contentMatch.findWrapping(inner.type); if (inside) { const lastType = inside.length ? inside[inside.length - 1] : type; let innerMatch = lastType.contentMatch; for (let i = startIndex; innerMatch && i < endIndex; i += 1) { innerMatch = innerMatch.matchType(parent.child(i).type)!; } if (innerMatch && innerMatch.validEnd) { return inside; } } return null; } function findWrappers(range: NodeRange, innerRange: NodeRange, nodeType: NodeType, attrs?: Attrs) { const around = findWrappingOutside(range, nodeType); const inner = findWrappingInside(innerRange, nodeType); if (around && inner) { const aroundNodes = around.map((type) => { return { type }; }); const innerNodes = inner.map((type) => { return { type, attrs }; }); return aroundNodes.concat({ type: nodeType }).concat(innerNodes); } return null; } function wrapInList( tr: Transaction, { start, end, startIndex, endIndex, parent }: NodeRange, wrappers: WrapperInfo[], joinBefore: boolean, list: NodeType ) { let content = Fragment.empty; for (let i = wrappers.length - 1; i >= 0; i -= 1) { content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)); } tr.step( new ReplaceAroundStep( start - (joinBefore ? 2 : 0), end, start, end, new Slice(content, 0, 0), wrappers.length, true ) ); let foundListIndex = 0; for (let i = 0; i < wrappers.length; i += 1) { if (wrappers[i].type === list) { foundListIndex = i + 1; break; } } const splitDepth = wrappers.length - foundListIndex; let splitPos = start + wrappers.length - (joinBefore ? 2 : 0); for (let i = startIndex, len = endIndex; i < len; i += 1) { const first = i === startIndex; if (!first && canSplit(tr.doc, splitPos, splitDepth)) { tr.split(splitPos, splitDepth); splitPos += splitDepth * 2; } splitPos += parent.child(i).nodeSize; } return tr; } function changeToList(tr: Transaction, range: NodeRange, list: NodeType, attrs?: Attrs) { const { $from, $to, depth } = range; let outerRange = range; let joinBefore = false; if ( depth >= 2 && $from.node(depth - 1).type.compatibleContent(list) && range.startIndex === 0 && $from.index(depth - 1) ) { const start = tr.doc.resolve(range.start - 2); outerRange = new NodeRange(start, start, depth); if (range.endIndex < range.parent.childCount) { range = new NodeRange($from, tr.doc.resolve($to.end(depth)), depth); } joinBefore = true; } const wrappers = findWrappers(outerRange, range, list, attrs); if (wrappers) { return wrapInList(tr, range, wrappers, joinBefore, list); } return tr; } function getBeforeLineListItem(doc: ProsemirrorNode, offset: number) { let endListItemPos = doc.resolve(offset); while (endListItemPos.node().type.name !== 'paragraph') { offset -= 2; // The position value of endListItemPos = doc.resolve(offset); } return findListItem(endListItemPos); } function toggleTaskListItems(tr: Transaction, { $from, $to }: NodeRange) { const startListItem = findListItem($from); let endListItem = findListItem($to); if (startListItem && endListItem) { while (endListItem) { const { offset, node } = endListItem; const attrs = { task: !node.attrs.task, checked: false }; tr.setNodeMarkup(offset, null, attrs); if (offset === startListItem.offset) { break; } endListItem = getBeforeLineListItem(tr.doc, offset); } } return tr; } function changeListType(tr: Transaction, { $from, $to }: NodeRange, list: NodeType) { const startListItem = findListItem($from); let endListItem = findListItem($to); if (startListItem && endListItem) { while (endListItem) { const { offset, node, depth } = endListItem; if (node.attrs.task) { tr.setNodeMarkup(offset, null, { task: false, checked: false }); } const resolvedPos = tr.doc.resolve(offset); if (resolvedPos.parent!.type !== list) { const parentOffset = resolvedPos.before(depth - 1); tr.setNodeMarkup(parentOffset, list); } if (offset === startListItem.offset) { break; } endListItem = getBeforeLineListItem(tr.doc, offset); } } return tr; } export function changeList(list: NodeType): Command { return ({ selection, tr }, dispatch) => { const { $from, $to } = selection; const range = $from.blockRange($to); if (range) { const newTr = isInListNode($from) ? changeListType(tr, range, list) : changeToList(tr, range, list); dispatch!(newTr); return true; } return false; }; } export function toggleTask(): Command { return ({ selection, tr, schema }, dispatch) => { const { $from, $to } = selection; const range = $from.blockRange($to); if (range) { const newTr = isInListNode($from) ? toggleTaskListItems(tr, range) : changeToList(tr, range, schema.nodes.bulletList, { task: true }); dispatch!(newTr); return true; } return false; }; } export function sinkListItem(listItem: NodeType): Command { return ({ tr, selection }: EditorState, dispatch) => { const { $from, $to } = selection; const range = $from.blockRange( $to, ({ childCount, firstChild }) => !!childCount && firstChild!.type === listItem ); if (range && range.startIndex > 0) { const { parent } = range; const nodeBefore = parent.child(range.startIndex - 1); if (nodeBefore.type !== listItem) { return false; } const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; const inner = nestedBefore ? Fragment.from(listItem.create()) : null; const slice = new Slice( Fragment.from(listItem.create(null, Fragment.from(parent.type.create(null, inner!)))), nestedBefore ? 3 : 1, 0 ); const before = range.start; const after = range.end; tr.step( new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, before, after, slice, 1, true) ); dispatch!(tr); return true; } return false; }; } function liftToOuterList(tr: Transaction, range: NodeRange, listItem: NodeType) { const { $from, $to, end, depth, parent } = range; const endOfList = $to.end(depth); if (end < endOfList) { // There are siblings after the lifted items, which must become // children of the last item tr.step( new ReplaceAroundStep( end - 1, endOfList, end, endOfList, new Slice(Fragment.from(listItem.create(null, parent.copy())), 1, 0), 1, true ) ); range = new NodeRange(tr.doc.resolve($from.pos), tr.doc.resolve(endOfList), depth); } tr.lift(range, liftTarget(range)!); return tr; } function liftOutOfList(tr: Transaction, range: NodeRange) { const list = range.parent; let pos = range.end; // Merge the list items into a single big item for (let i = range.endIndex - 1, len = range.startIndex; i > len; i -= 1) { pos -= list.child(i).nodeSize; tr.delete(pos - 1, pos + 1); } const startPos = tr.doc.resolve(range.start); const listItem = startPos.nodeAfter; const atStart = range.startIndex === 0; const atEnd = range.endIndex === list.childCount; const parent = startPos.node(-1); const indexBefore = startPos.index(-1); const canReplaceParent = parent.canReplace( indexBefore + (atStart ? 0 : 1), indexBefore + 1, listItem?.content.append(atEnd ? Fragment.empty : Fragment.from(list)) ); if (listItem && canReplaceParent) { const start = startPos.pos; const end = start + listItem.nodeSize; // Strip off the surrounding list. At the sides where we're not at // the end of the list, the existing list is closed. At sides where // this is the end, it is overwritten to its end. tr.step( new ReplaceAroundStep( start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, new Slice( (atStart ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))).append( atEnd ? Fragment.empty : Fragment.from(list.copy(Fragment.empty)) ), atStart ? 0 : 1, atEnd ? 0 : 1 ), atStart ? 0 : 1 ) ); } return tr; } export function liftListItem(listItem: NodeType): Command { return ({ tr, selection }: EditorState, dispatch) => { const { $from, $to } = selection; const range = $from.blockRange( $to, ({ childCount, firstChild }) => !!childCount && firstChild!.type === listItem ); if (range) { const topListItem = $from.node(range.depth - 1).type === listItem; const newTr = topListItem ? liftToOuterList(tr, range, listItem) : liftOutOfList(tr, range); dispatch!(newTr); return true; } return false; }; } export function splitListItem(listItem: NodeType): Command { return ({ tr, selection }, dispatch) => { const { $from, $to } = selection; if ($from.depth < 2 || !$from.sameParent($to)) { return false; } const grandParent = $from.node(-1); if (grandParent.type !== listItem) { return false; } if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) { // In an empty block. If this is a nested list, the wrapping // list item should be split. Otherwise, bail out and let next // command handle lifting. if ( $from.depth === 2 || $from.node(-3).type !== listItem || $from.index(-2) !== $from.node(-2).childCount - 1 ) { return false; } const keepItem = $from.index(-1) > 0; let wrapper = Fragment.empty; // Build a fragment containing empty versions of the structure // from the outer list item to the parent node of the cursor for (let depth = $from.depth - (keepItem ? 1 : 2); depth >= $from.depth - 3; depth -= 1) { wrapper = Fragment.from($from.node(depth).copy(wrapper)); } // Add a second list item with an empty default start node wrapper = wrapper.append(Fragment.from(listItem.createAndFill()!)); tr.replace( keepItem ? $from.before() : $from.before(-1), $from.after(-3), new Slice(wrapper, keepItem ? 3 : 2, 2) ); tr.setSelection(Selection.near(tr.doc.resolve($from.pos + (keepItem ? 3 : 2)))); dispatch!(tr); return true; } const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null; const types = nextType && [null, { type: nextType }]; tr.delete($from.pos, $to.pos); if (canSplit(tr.doc, $from.pos, 2, types!)) { tr.split($from.pos, 2, types!); dispatch!(tr); return true; } return false; }; } ================================================ FILE: apps/editor/src/wysiwyg/command/table.ts ================================================ import { ProsemirrorNode, ResolvedPos, Schema } from 'prosemirror-model'; import { Selection, Transaction, NodeSelection } from 'prosemirror-state'; import { addParagraph } from '@/helper/manipulation'; import { TableOffsetMap } from '../helper/tableOffsetMap'; import { Direction } from '../nodes/table'; export type CellPosition = [rowIdx: number, colIdx: number]; type CellOffsetFn = ([rowIdx, colIdx]: CellPosition, map: TableOffsetMap) => number | null; type CellOffsetFnMap = { [key in Direction]: CellOffsetFn; }; const cellOffsetFnMap: CellOffsetFnMap = { left: getLeftCellOffset, right: getRightCellOffset, up: getUpCellOffset, down: getDownCellOffset, }; function isInFirstListItem( pos: ResolvedPos, doc: ProsemirrorNode, [paraDepth, listDepth]: number[] ) { const listItemNode = doc.resolve(pos.before(paraDepth - 1)); return listDepth === paraDepth && !listItemNode.nodeBefore; } function isInLastListItem(pos: ResolvedPos) { let { depth } = pos; let parentNode; while (depth) { parentNode = pos.node(depth); if (parentNode.type.name === 'tableBodyCell') { break; } if (parentNode.type.name === 'listItem') { const grandParent = pos.node(depth - 1); const lastListItem = grandParent.lastChild === parentNode; const hasChildren = parentNode.lastChild?.type.name !== 'paragraph'; if (!lastListItem) { return false; } return !hasChildren; } depth -= 1; } return false; } function canMoveToBeforeCell( direction: Direction, [paraDepth, listDepth, curDepth]: number[], from: ResolvedPos, doc: ProsemirrorNode, inList: boolean ) { if (direction === Direction.LEFT || direction === Direction.UP) { if (inList && !isInFirstListItem(from, doc, [paraDepth, listDepth])) { return false; } const endOffset = from.before(curDepth); const { nodeBefore } = doc.resolve(endOffset); if (nodeBefore) { return false; } } return true; } function canMoveToAfterCell( direction: Direction, curDepth: number, from: ResolvedPos, doc: ProsemirrorNode, inList: boolean ) { if (direction === Direction.RIGHT || direction === Direction.DOWN) { if (inList && !isInLastListItem(from)) { return false; } const endOffset = from.after(curDepth); const { nodeAfter } = doc.resolve(endOffset); if (nodeAfter) { return false; } } return true; } export function canMoveBetweenCells( direction: Direction, [cellDepth, paraDepth]: number[], from: ResolvedPos, doc: ProsemirrorNode ) { const listDepth = cellDepth + 3; // 3 is position of
                      • const inList = paraDepth >= listDepth; const curDepth = inList ? cellDepth + 1 : paraDepth; const moveBeforeCell = canMoveToBeforeCell( direction, [paraDepth, listDepth, curDepth], from, doc, inList ); const moveAfterCell = canMoveToAfterCell(direction, curDepth, from, doc, inList); return moveBeforeCell && moveAfterCell; } export function canBeOutOfTable( direction: Direction, map: TableOffsetMap, [rowIdx, colIdx]: CellPosition ) { const rowspanInfo = map.getRowspanStartInfo(rowIdx, colIdx)!; const inFirstRow = direction === Direction.UP && rowIdx === 0; const inLastRow = direction === Direction.DOWN && (rowspanInfo?.count > 1 ? rowIdx + rowspanInfo!.count - 1 : rowIdx) === map.totalRowCount - 1; return inFirstRow || inLastRow; } export function addParagraphBeforeTable(tr: Transaction, map: TableOffsetMap, schema: Schema) { const tableStartPos = tr.doc.resolve(map.tableStartOffset - 1); if (!tableStartPos.nodeBefore) { return addParagraph(tr, tableStartPos, schema); } return tr.setSelection(Selection.near(tableStartPos, -1)); } export function addParagraphAfterTable( tr: Transaction, map: TableOffsetMap, schema: Schema, forcedAddtion = false ) { const tableEndPos = tr.doc.resolve(map.tableEndOffset); if (forcedAddtion || !tableEndPos.nodeAfter) { return addParagraph(tr, tableEndPos, schema); } return tr.setSelection(Selection.near(tableEndPos, 1)); } export function getRightCellOffset([rowIdx, colIdx]: CellPosition, map: TableOffsetMap) { const { totalRowCount, totalColumnCount } = map; const lastCellInRow = colIdx === totalColumnCount - 1; const lastCellInTable = rowIdx === totalRowCount - 1 && lastCellInRow; if (!lastCellInTable) { let nextColIdx = colIdx + 1; const colspanInfo = map.getColspanStartInfo(rowIdx, colIdx)!; if (colspanInfo?.count > 1) { nextColIdx += colspanInfo.count - 1; } if (lastCellInRow || nextColIdx === totalColumnCount) { rowIdx += 1; nextColIdx = 0; } const { offset } = map.getCellInfo(rowIdx, nextColIdx); return offset + 2; } return null; } export function getLeftCellOffset([rowIdx, colIdx]: CellPosition, map: TableOffsetMap) { const { totalColumnCount } = map; const firstCellInRow = colIdx === 0; const firstCellInTable = rowIdx === 0 && firstCellInRow; if (!firstCellInTable) { colIdx -= 1; if (firstCellInRow) { rowIdx -= 1; colIdx = totalColumnCount - 1; } const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); return offset + nodeSize - 2; } return null; } export function getUpCellOffset([rowIdx, colIdx]: CellPosition, map: TableOffsetMap) { if (rowIdx > 0) { const { offset, nodeSize } = map.getCellInfo(rowIdx - 1, colIdx); return offset + nodeSize - 2; } return null; } export function getDownCellOffset([rowIdx, colIdx]: CellPosition, map: TableOffsetMap) { const { totalRowCount } = map; if (rowIdx < totalRowCount - 1) { let nextRowIdx = rowIdx + 1; const rowspanInfo = map.getRowspanStartInfo(rowIdx, colIdx)!; if (rowspanInfo?.count > 1) { nextRowIdx += rowspanInfo.count - 1; } const { offset } = map.getCellInfo(nextRowIdx, colIdx); return offset + 2; } return null; } export function moveToCell( direction: Direction, tr: Transaction, cellIndex: CellPosition, map: TableOffsetMap ) { const cellOffsetFn = cellOffsetFnMap[direction]; const offset = cellOffsetFn(cellIndex, map); if (offset) { const dir = direction === Direction.RIGHT || direction === Direction.DOWN ? 1 : -1; return tr.setSelection(Selection.near(tr.doc.resolve(offset), dir)); } return null; } export function canSelectTableNode( direction: Direction, map: TableOffsetMap, [rowIdx, colIdx]: CellPosition ) { if (direction === Direction.UP || direction === Direction.DOWN) { return false; } const { tableStartOffset, tableEndOffset } = map; const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); const pos = direction === Direction.LEFT ? tableStartOffset : tableEndOffset; const curPos = direction === Direction.LEFT ? offset - 2 : offset + nodeSize + 3; return pos === curPos; } export function selectNode(tr: Transaction, pos: ResolvedPos, depth: number) { const tablePos = tr.doc.resolve(pos.before(depth - 3)); return tr.setSelection(new NodeSelection(tablePos)); } ================================================ FILE: apps/editor/src/wysiwyg/helper/node.ts ================================================ import { ProsemirrorNode, ResolvedPos } from 'prosemirror-model'; import { includes } from '@/utils/common'; type NodeAttrs = Record; interface CustomAttrs { htmlAttrs: { default: any }; classNames: { default: null | string[] }; } export function findNodeBy( pos: ResolvedPos, condition: (node: ProsemirrorNode, depth: number) => boolean ) { let { depth } = pos; while (depth) { const node = pos.node(depth); if (condition(node, depth)) { return { node, depth, offset: depth > 0 ? pos.before(depth) : 0, }; } depth -= 1; } return null; } export function isListNode({ type }: ProsemirrorNode) { return type.name === 'bulletList' || type.name === 'orderedList'; } export function isInListNode(pos: ResolvedPos) { return !!findNodeBy( pos, ({ type }: ProsemirrorNode) => type.name === 'listItem' || type.name === 'bulletList' || type.name === 'orderedList' ); } export function isInTableNode(pos: ResolvedPos) { return !!findNodeBy( pos, ({ type }: ProsemirrorNode) => type.name === 'tableHeadCell' || type.name === 'tableBodyCell' ); } export function findListItem(pos: ResolvedPos) { return findNodeBy(pos, ({ type }: ProsemirrorNode) => type.name === 'listItem'); } export function createDOMInfoParsedRawHTML(tag: string) { return { tag, getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { ...(rawHTML && { rawHTML }), }; }, }; } export function createCellAttrs(attrs: NodeAttrs) { return Object.keys(attrs).reduce((acc, attrName) => { if (attrName !== 'rawHTML' && attrs[attrName]) { attrName = attrName === 'className' ? 'class' : attrName; acc[attrName] = attrs[attrName]; } return acc; }, {}); } export function createParsedCellDOM(tag: string) { return { tag, getAttrs(dom: Node | string) { return ['rawHTML', 'colspan', 'rowspan', 'extended'].reduce((acc, attrName) => { const attrNameInDOM = attrName === 'rawHTML' ? 'data-raw-html' : attrName; const attrValue = (dom as HTMLElement).getAttribute(attrNameInDOM); if (attrValue) { acc[attrName] = includes(['rawHTML', 'extended'], attrName) ? attrValue : Number(attrValue); } return acc; }, {}); }, }; } export function getDefaultCustomAttrs(): CustomAttrs { return { htmlAttrs: { default: null }, classNames: { default: null }, }; } export function getCustomAttrs(attrs: Record) { const { htmlAttrs, classNames } = attrs; return { ...htmlAttrs, class: classNames ? classNames.join(' ') : null }; } ================================================ FILE: apps/editor/src/wysiwyg/helper/table.ts ================================================ import { Node, Schema, ResolvedPos, Slice, ProsemirrorNode } from 'prosemirror-model'; import { Selection, TextSelection } from 'prosemirror-state'; import { findNodeBy } from '@/wysiwyg/helper/node'; import { CellSelection } from '@t/wysiwyg'; import type { SelectionInfo } from './tableOffsetMap'; export function createTableHeadRow(columnCount: number, schema: Schema, data?: string[]) { const { tableRow, tableHeadCell, paragraph } = schema.nodes; const cells = []; for (let index = 0; index < columnCount; index += 1) { const text = data && data[index]; const para = paragraph.create(null, text ? schema.text(text) : []); cells.push(tableHeadCell.create(null, para)); } return [tableRow.create(null, cells)]; } export function createTableBodyRows( rowCount: number, columnCount: number, schema: Schema, data?: string[] ) { const { tableRow, tableBodyCell, paragraph } = schema.nodes; const tableRows = []; for (let rowIdx = 0; rowIdx < rowCount; rowIdx += 1) { const cells = []; for (let colIdx = 0; colIdx < columnCount; colIdx += 1) { const text = data && data[rowIdx * columnCount + colIdx]; const para = paragraph.create(null, text ? schema.text(text) : []); cells.push(tableBodyCell.create(null, para)); } tableRows.push(tableRow.create(null, cells)); } return tableRows; } export function createDummyCells( columnCount: number, rowIdx: number, schema: Schema, attrs: Record | null = null ) { const { tableHeadCell, tableBodyCell, paragraph } = schema.nodes; const cell = rowIdx === 0 ? tableHeadCell : tableBodyCell; const cells = []; for (let index = 0; index < columnCount; index += 1) { cells.push(cell.create(attrs, paragraph.create())); } return cells; } export function findCellElement(node: HTMLElement, root: Element) { while (node && node !== root) { if (node.nodeName === 'TD' || node.nodeName === 'TH') { return node; } node = node.parentNode as HTMLElement; } return null; } export function findCell(pos: ResolvedPos) { return findNodeBy( pos, ({ type }: Node) => type.name === 'tableHeadCell' || type.name === 'tableBodyCell' ); } export function getResolvedSelection(selection: Selection | CellSelection) { if (selection instanceof TextSelection) { const { $anchor } = selection; const foundCell = findCell($anchor); if (foundCell) { const anchor = $anchor.node(0).resolve($anchor.before(foundCell.depth)); return { anchor, head: anchor }; } } const { startCell, endCell } = selection as CellSelection; return { anchor: startCell, head: endCell }; } export function getTableContentFromSlice(slice: Slice) { if (slice.size) { let { content, openStart, openEnd } = slice; if (content.childCount !== 1) { return null; } while ( content.childCount === 1 && ((openStart > 0 && openEnd > 0) || content.firstChild?.type.name === 'table') ) { openStart -= 1; openEnd -= 1; content = content.firstChild!.content; } if ( content.firstChild!.type.name === 'tableHead' || content.firstChild!.type.name === 'tableBody' ) { return content; } } return null; } export function getRowAndColumnCount({ startRowIdx, startColIdx, endRowIdx, endColIdx, }: SelectionInfo) { const rowCount = endRowIdx - startRowIdx + 1; const columnCount = endColIdx - startColIdx + 1; return { rowCount, columnCount }; } export function setAttrs(cell: ProsemirrorNode, attrs: Record) { return { ...cell.attrs, ...attrs }; } ================================================ FILE: apps/editor/src/wysiwyg/helper/tableOffsetMap.ts ================================================ import type { Node, ResolvedPos } from 'prosemirror-model'; import { findNodeBy } from '@/wysiwyg/helper/node'; import { assign, getSortedNumPair } from '@/utils/common'; export interface CellInfo { offset: number; nodeSize: number; extended?: boolean; } export interface SelectionInfo { startRowIdx: number; startColIdx: number; endRowIdx: number; endColIdx: number; } interface SpanMap { [key: number]: { count: number; startSpanIdx: number }; } export interface RowInfo { [key: number]: CellInfo; length: number; rowspanMap: SpanMap; colspanMap: SpanMap; } interface SpanInfo { node: Node; pos: number; count: number; startSpanIdx: number; } interface OffsetMap { rowInfo: RowInfo[]; table: Node; totalRowCount: number; totalColumnCount: number; tableStartOffset: number; tableEndOffset: number; getCellInfo(rowIdx: number, colIdx: number): CellInfo; posAt(rowIdx: number, colIdx: number): number; getNodeAndPos(rowIdx: number, colIdx: number): { node: Node; pos: number }; extendedRowspan(rowIdx: number, colIdx: number): boolean; extendedColspan(rowIdx: number, colIdx: number): boolean; getRowspanCount(rowIdx: number, colIdx: number): number; getColspanCount(rowIdx: number, colIdx: number): number; decreaseColspanCount(rowIdx: number, colIdx: number): number; decreaseRowspanCount(rowIdx: number, colIdx: number): number; getColspanStartInfo(rowIdx: number, colIdx: number): SpanInfo | null; getRowspanStartInfo(rowIdx: number, colIdx: number): SpanInfo | null; getRectOffsets(startCellPos: ResolvedPos, endCellPos?: ResolvedPos): SelectionInfo; getSpannedOffsets(selectionInfo: SelectionInfo): SelectionInfo; } type CreateOffsetMapMixin = ( headOrBody: Node, startOffset: number, startFromBody?: boolean ) => RowInfo[]; const cache = new Map(); /* eslint-disable @typescript-eslint/no-unused-vars */ export class TableOffsetMap { private table: Node; private tableRows: Node[]; private tableStartPos: number; private rowInfo: RowInfo[]; constructor(table: Node, tableRows: Node[], tableStartPos: number, rowInfo: RowInfo[]) { this.table = table; this.tableRows = tableRows; this.tableStartPos = tableStartPos; this.rowInfo = rowInfo; } static create(cellPos: ResolvedPos): TableOffsetMap | null { const table = findNodeBy(cellPos, ({ type }: Node) => type.name === 'table'); if (table) { const { node, depth, offset } = table; const cached = cache.get(node); if (cached?.tableStartPos === offset + 1) { return cached; } const rows: Node[] = []; const tablePos = cellPos.start(depth); const thead = node.child(0); const tbody = node.child(1); const theadCellInfo = createOffsetMap(thead, tablePos); const tbodyCellInfo = createOffsetMap(tbody, tablePos + thead.nodeSize); thead.forEach((row) => rows.push(row)); tbody.forEach((row) => rows.push(row)); const map = new TableOffsetMap(node, rows, tablePos, theadCellInfo.concat(tbodyCellInfo)); cache.set(node, map); return map; } return null; } get totalRowCount() { return this.rowInfo.length; } get totalColumnCount() { return this.rowInfo[0].length; } get tableStartOffset() { return this.tableStartPos; } get tableEndOffset() { return this.tableStartPos + this.table.nodeSize - 1; } getCellInfo(rowIdx: number, colIdx: number) { return this.rowInfo[rowIdx][colIdx]; } posAt(rowIdx: number, colIdx: number): number { for (let i = 0, rowStart = this.tableStartPos; ; i += 1) { const rowEnd = rowStart + this.tableRows[i].nodeSize; if (i === rowIdx) { let index = colIdx; // Skip the cells from previous row(via rowspan) while (index < this.totalColumnCount && this.rowInfo[i][index].offset < rowStart) { index += 1; } return index === this.totalColumnCount ? rowEnd : this.rowInfo[i][index].offset; } rowStart = rowEnd; } } getNodeAndPos(rowIdx: number, colIdx: number) { const cellInfo = this.rowInfo[rowIdx][colIdx]; return { node: this.table.nodeAt(cellInfo.offset - this.tableStartOffset)!, pos: cellInfo.offset, }; } extendedRowspan(rowIdx: number, colIdx: number) { return false; } extendedColspan(rowIdx: number, colIdx: number) { return false; } getRowspanCount(rowIdx: number, colIdx: number) { return 0; } getColspanCount(rowIdx: number, colIdx: number) { return 0; } decreaseColspanCount(rowIdx: number, colIdx: number) { return 0; } decreaseRowspanCount(rowIdx: number, colIdx: number) { return 0; } getColspanStartInfo(rowIdx: number, colIdx: number): SpanInfo | null { return null; } getRowspanStartInfo(rowIdx: number, colIdx: number): SpanInfo | null { return null; } getCellStartOffset(rowIdx: number, colIdx: number) { const { offset } = this.rowInfo[rowIdx][colIdx]; return this.extendedRowspan(rowIdx, colIdx) ? this.posAt(rowIdx, colIdx) : offset; } getCellEndOffset(rowIdx: number, colIdx: number) { const { offset, nodeSize } = this.rowInfo[rowIdx][colIdx]; return this.extendedRowspan(rowIdx, colIdx) ? this.posAt(rowIdx, colIdx) : offset + nodeSize; } getCellIndex(cellPos: ResolvedPos): [rowIdx: number, colIdx: number] { for (let rowIdx = 0; rowIdx < this.totalRowCount; rowIdx += 1) { const rowInfo = this.rowInfo[rowIdx]; for (let colIdx = 0; colIdx < this.totalColumnCount; colIdx += 1) { if (rowInfo[colIdx].offset + 1 > cellPos.pos) { return [rowIdx, colIdx]; } } } return [0, 0]; } getRectOffsets(startCellPos: ResolvedPos, endCellPos = startCellPos) { if (startCellPos.pos > endCellPos.pos) { [startCellPos, endCellPos] = [endCellPos, startCellPos]; } let [startRowIdx, startColIdx] = this.getCellIndex(startCellPos); let [endRowIdx, endColIdx] = this.getCellIndex(endCellPos); [startRowIdx, endRowIdx] = getSortedNumPair(startRowIdx, endRowIdx); [startColIdx, endColIdx] = getSortedNumPair(startColIdx, endColIdx); return this.getSpannedOffsets({ startRowIdx, startColIdx, endRowIdx, endColIdx }); } getSpannedOffsets(selectionInfo: SelectionInfo): SelectionInfo { return selectionInfo; } } /* eslint-enable @typescript-eslint/no-unused-vars */ let createOffsetMap = (headOrBody: Node, startOffset: number) => { const cellInfoMatrix: RowInfo[] = []; headOrBody.forEach((row: Node, rowOffset: number) => { // get row index based on table(not table head or table body) const rowInfo: RowInfo = { rowspanMap: {}, colspanMap: {}, length: 0 }; row.forEach(({ nodeSize }: Node, cellOffset: number) => { let colIdx = 0; while (rowInfo[colIdx]) { colIdx += 1; } rowInfo[colIdx] = { // 2 is the sum of the front and back positions of the tag offset: startOffset + rowOffset + cellOffset + 2, nodeSize, }; rowInfo.length += 1; }); cellInfoMatrix.push(rowInfo); }); return cellInfoMatrix; }; export function mixinTableOffsetMapPrototype( offsetMapMixin: OffsetMap, createOffsetMapMixin: CreateOffsetMapMixin ) { assign(TableOffsetMap.prototype, offsetMapMixin); createOffsetMap = createOffsetMapMixin; return TableOffsetMap; } ================================================ FILE: apps/editor/src/wysiwyg/marks/code.ts ================================================ import { Mark as ProsemirrorMark, DOMOutputSpec } from 'prosemirror-model'; import { toggleMark } from 'prosemirror-commands'; import Mark from '@/spec/mark'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class Code extends Mark { get name() { return 'code'; } get schema() { return { attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [ { tag: 'code', getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { ...(rawHTML && { rawHTML }), }; }, }, ], toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { return [attrs.rawHTML || 'code', getCustomAttrs(attrs)]; }, }; } commands(): EditorCommand { return () => (state, dispatch) => toggleMark(state.schema.marks.code)(state, dispatch); } keymaps() { const codeCommand = this.commands()(); return { 'Shift-Mod-c': codeCommand, 'Shift-Mod-C': codeCommand, }; } } ================================================ FILE: apps/editor/src/wysiwyg/marks/emph.ts ================================================ import { Mark as ProsemirrorMark, DOMOutputSpec } from 'prosemirror-model'; import { toggleMark } from 'prosemirror-commands'; import Mark from '@/spec/mark'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class Emph extends Mark { get name() { return 'emph'; } get schema() { const parseDOM = ['i', 'em'].map((tag) => { return { tag, getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { ...(rawHTML && { rawHTML }), }; }, }; }); return { attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM, toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { return [attrs.rawHTML || 'em', getCustomAttrs(attrs)]; }, }; } private italic(): EditorCommand { return () => (state, dispatch) => toggleMark(state.schema.marks.emph)(state, dispatch); } commands() { return { italic: this.italic() }; } keymaps() { const italicCommand = this.italic()(); return { 'Mod-i': italicCommand, 'Mod-I': italicCommand, }; } } ================================================ FILE: apps/editor/src/wysiwyg/marks/link.ts ================================================ import { Mark as ProsemirrorMark, DOMOutputSpec } from 'prosemirror-model'; import { toggleMark } from 'prosemirror-commands'; import Mark from '@/spec/mark'; import { escapeXml } from '@/utils/common'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import { createTextNode } from '@/helper/manipulation'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; import { LinkAttributes } from '@t/editor'; export class Link extends Mark { private linkAttributes: LinkAttributes; constructor(linkAttributes: LinkAttributes) { super(); this.linkAttributes = linkAttributes; } get name() { return 'link'; } get schema() { return { attrs: { linkUrl: { default: '' }, title: { default: null }, rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, inclusive: false, parseDOM: [ { tag: 'a[href]', getAttrs(dom: Node | string) { const sanitizedDOM = sanitizeHTML(dom, { RETURN_DOM_FRAGMENT: true }) .firstChild as HTMLElement; const href = sanitizedDOM.getAttribute('href') || ''; const title = sanitizedDOM.getAttribute('title') || ''; const rawHTML = sanitizedDOM.getAttribute('data-raw-html'); return { linkUrl: href, title, ...(rawHTML && { rawHTML }), }; }, }, ], toDOM: ({ attrs }: ProsemirrorMark): DOMOutputSpec => [ attrs.rawHTML || 'a', { href: escapeXml(attrs.linkUrl), ...this.linkAttributes, ...getCustomAttrs(attrs), }, ], }; } private addLink(): EditorCommand { return (payload) => (state, dispatch) => { const { linkUrl, linkText = '' } = payload!; const { schema, tr, selection } = state; const { empty, from, to } = selection; if (from && to && linkUrl) { const attrs = { linkUrl }; const mark = schema.mark('link', attrs); if (empty && linkText) { const node = createTextNode(schema, linkText, mark); tr.replaceRangeWith(from, to, node); } else { tr.addMark(from, to, mark); } dispatch!(tr.scrollIntoView()); return true; } return false; }; } private toggleLink(): EditorCommand { return (payload) => (state, dispatch) => toggleMark(state.schema.marks.link, payload)(state, dispatch); } commands() { return { addLink: this.addLink(), toggleLink: this.toggleLink(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/marks/strike.ts ================================================ import { Mark as ProsemirrorMark, DOMOutputSpec } from 'prosemirror-model'; import { toggleMark } from 'prosemirror-commands'; import Mark from '@/spec/mark'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class Strike extends Mark { get name() { return 'strike'; } get schema() { const parseDOM = ['s', 'del'].map((tag) => { return { tag, getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { ...(rawHTML && { rawHTML }), }; }, }; }); return { attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM, toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { return [attrs.rawHTML || 'del', getCustomAttrs(attrs)]; }, }; } commands(): EditorCommand { return () => (state, dispatch) => toggleMark(state.schema.marks.strike)(state, dispatch); } keymaps() { const strikeCommand = this.commands()(); return { 'Mod-s': strikeCommand, 'Mod-S': strikeCommand, }; } } ================================================ FILE: apps/editor/src/wysiwyg/marks/strong.ts ================================================ import { Mark as ProsemirrorMark, DOMOutputSpec } from 'prosemirror-model'; import { toggleMark } from 'prosemirror-commands'; import Mark from '@/spec/mark'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class Strong extends Mark { get name() { return 'strong'; } get schema() { const parseDOM = ['b', 'strong'].map((tag) => { return { tag, getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { ...(rawHTML && { rawHTML }), }; }, }; }); return { attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM, toDOM({ attrs }: ProsemirrorMark): DOMOutputSpec { return [attrs.rawHTML || 'strong', getCustomAttrs(attrs)]; }, }; } private bold(): EditorCommand { return () => (state, dispatch) => toggleMark(state.schema.marks.strong)(state, dispatch); } commands() { return { bold: this.bold() }; } keymaps() { const boldCommand = this.bold()(); return { 'Mod-b': boldCommand, 'Mod-B': boldCommand, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/blockQuote.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import { wrapIn } from 'prosemirror-commands'; import NodeSchema from '@/spec/node'; import { createDOMInfoParsedRawHTML, getCustomAttrs, getDefaultCustomAttrs, } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class BlockQuote extends NodeSchema { get name() { return 'blockQuote'; } get schema() { return { attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, content: 'block+', group: 'block', parseDOM: [createDOMInfoParsedRawHTML('blockquote')], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['blockquote', getCustomAttrs(attrs), 0]; }, }; } commands(): EditorCommand { return () => (state, dispatch) => wrapIn(state.schema.nodes.blockQuote)(state, dispatch); } keymaps() { const blockQutoeCommand = this.commands()(); return { 'Alt-q': blockQutoeCommand, 'Alt-Q': blockQutoeCommand, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/bulletList.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { getWwCommands } from '@/commands/wwCommands'; import { createDOMInfoParsedRawHTML, getCustomAttrs, getDefaultCustomAttrs, } from '@/wysiwyg/helper/node'; import { changeList, toggleTask } from '@/wysiwyg/command/list'; import { Command } from 'prosemirror-commands'; export class BulletList extends NodeSchema { get name() { return 'bulletList'; } get schema() { return { content: 'listItem+', group: 'block', attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [createDOMInfoParsedRawHTML('ul')], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['ul', getCustomAttrs(attrs), 0]; }, }; } private changeList(): Command { return (state, dispatch) => changeList(state.schema.nodes.bulletList)(state, dispatch); } commands() { return { bulletList: this.changeList, taskList: toggleTask, }; } keymaps() { const bulletListCommand = this.changeList(); const { indent, outdent } = getWwCommands(); return { 'Mod-u': bulletListCommand, 'Mod-U': bulletListCommand, Tab: indent(), 'Shift-Tab': outdent(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/codeBlock.ts ================================================ import { ProsemirrorNode, DOMOutputSpec } from 'prosemirror-model'; import { setBlockType, Command } from 'prosemirror-commands'; import { addParagraph } from '@/helper/manipulation'; import { between, last } from '@/utils/common'; import NodeSchema from '@/spec/node'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class CodeBlock extends NodeSchema { get name() { return 'codeBlock'; } get schema() { return { content: 'text*', group: 'block', attrs: { language: { default: null }, rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, code: true, defining: true, marks: '', parseDOM: [ { tag: 'pre', preserveWhitespace: 'full' as const, getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); const child = (dom as HTMLElement).firstElementChild; return { language: child?.getAttribute('data-language') || null, ...(rawHTML && { rawHTML }), }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return [ attrs.rawHTML || 'pre', ['code', { 'data-language': attrs.language, ...getCustomAttrs(attrs) }, 0], ]; }, }; } commands(): EditorCommand { return () => (state, dispatch) => setBlockType(state.schema.nodes.codeBlock)(state, dispatch); } moveCursor(direction: 'up' | 'down'): Command { return (state, dispatch) => { const { tr, doc, schema } = state; const { $from } = state.selection; const { view } = this.context; if (view!.endOfTextblock(direction) && $from.node().type.name === 'codeBlock') { const lines: string[] = $from.parent.textContent.split('\n'); const offset = direction === 'up' ? $from.start() : $from.end(); const range = direction === 'up' ? [offset, lines[0].length + offset] : [offset - last(lines).length, offset]; const pos = doc.resolve(direction === 'up' ? $from.before() : $from.after()); const node = direction === 'up' ? pos.nodeBefore : pos.nodeAfter; if (between($from.pos, range[0], range[1]) && !node) { const newTr = addParagraph(tr, pos, schema); if (newTr) { dispatch!(newTr); return true; } } } return false; }; } keymaps() { const codeCommand = this.commands()(); return { 'Shift-Mod-p': codeCommand, 'Shift-Mod-P': codeCommand, ArrowUp: this.moveCursor('up'), ArrowDown: this.moveCursor('down'), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/customBlock.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import { setBlockType } from 'prosemirror-commands'; import NodeSchema from '@/spec/node'; import { EditorCommand } from '@t/spec'; export class CustomBlock extends NodeSchema { get name() { return 'customBlock'; } get schema() { return { content: 'text*', group: 'block', attrs: { info: { default: null }, }, atom: true, code: true, defining: true, parseDOM: [ { tag: 'div[data-custom-info]', getAttrs(dom: Node | string) { const info = (dom as HTMLElement).getAttribute('data-custom-info'); return { info }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['div', { 'data-custom-info': attrs.info || null }, 0]; }, }; } commands(): EditorCommand { return (payload) => (state, dispatch) => payload?.info ? setBlockType(state.schema.nodes.customBlock, payload)(state, dispatch) : false; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/doc.ts ================================================ import Node from '@/spec/node'; export class Doc extends Node { get name() { return 'doc'; } get schema() { return { content: 'block+', }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/frontMatter.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { exitCode } from 'prosemirror-commands'; import NodeSchema from '@/spec/node'; import { EditorCommand } from '@t/spec'; export class FrontMatter extends NodeSchema { get name() { return 'frontMatter'; } get schema() { return { content: 'text*', group: 'block', code: true, defining: true, parseDOM: [ { preserveWhitespace: 'full' as const, tag: 'div[data-front-matter]', }, ], toDOM(): DOMOutputSpec { return ['div', { 'data-front-matter': 'true' }, 0]; }, }; } commands(): EditorCommand { return () => (state, dispatch, view) => { const { $from } = state.selection; if (view!.endOfTextblock('down') && $from.node().type.name === 'frontMatter') { return exitCode(state, dispatch); } return false; }; } keymaps() { return { Enter: this.commands()(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/heading.ts ================================================ import { ProsemirrorNode, DOMOutputSpec } from 'prosemirror-model'; import { setBlockType } from 'prosemirror-commands'; import NodeSchema from '@/spec/node'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; import { EditorCommand } from '@t/spec'; export class Heading extends NodeSchema { get name() { return 'heading'; } get levels() { return [1, 2, 3, 4, 5, 6]; } get schema() { const parseDOM = this.levels.map((level) => { return { tag: `h${level}`, getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { level, ...(rawHTML && { rawHTML }), }; }, }; }); return { attrs: { level: { default: 1 }, headingType: { default: 'atx' }, rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, content: 'inline*', group: 'block', defining: true, parseDOM, toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return [`h${attrs.level}`, getCustomAttrs(attrs), 0]; }, }; } commands(): EditorCommand { return (payload) => (state, dispatch) => { const nodeType = state.schema.nodes[payload!.level ? 'heading' : 'paragraph']; return setBlockType(nodeType, payload)(state, dispatch); }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/html.ts ================================================ import { ProsemirrorNode, Mark as ProsemirrorMark, DOMOutputSpec, NodeSpec, MarkSpec, } from 'prosemirror-model'; import { MdNode } from '@toast-ui/toastmark'; import toArray from 'tui-code-snippet/collection/toArray'; import { Sanitizer, HTMLSchemaMap, CustomHTMLRenderer } from '@t/editor'; import { ToDOMAdaptor } from '@t/convertor'; import { registerTagWhitelistIfPossible } from '@/sanitizer/htmlSanitizer'; import { reHTMLTag, ATTRIBUTE } from '@/utils/constants'; export function getChildrenHTML(node: MdNode, typeName: string) { return node .literal!.replace(new RegExp(`(<\\s*${typeName}[^>]*>)|(])`, 'ig'), '') .trim(); } export function getHTMLAttrsByHTMLString(html: string) { html = html.match(reHTMLTag)![0]; const attrs = html.match(new RegExp(ATTRIBUTE, 'g')); return attrs ? attrs.reduce>((acc, attr) => { const [name, ...values] = attr.trim().split('='); if (values.length) { acc[name] = values.join('=').replace(/'|"/g, '').trim(); } return acc; }, {}) : {}; } function getHTMLAttrs(dom: HTMLElement) { return toArray(dom.attributes).reduce>((acc, attr) => { acc[attr.nodeName] = attr.nodeValue; return acc; }, {}); } export function sanitizeDOM( node: ProsemirrorNode | ProsemirrorMark, typeName: string, sanitizer: Sanitizer, wwToDOMAdaptor: ToDOMAdaptor ) { let dom = wwToDOMAdaptor.getToDOMNode(typeName)!(node) as HTMLElement; const html = sanitizer(dom.outerHTML); const container = document.createElement('div'); container.innerHTML = html; dom = container.firstChild as HTMLElement; const htmlAttrs = getHTMLAttrs(dom); return { dom, htmlAttrs }; } const schemaFactory = { htmlBlock(typeName: string, sanitizeHTML: Sanitizer, wwToDOMAdaptor: ToDOMAdaptor): NodeSpec { return { atom: true, content: 'block+', group: 'block', attrs: { htmlAttrs: { default: {} }, childrenHTML: { default: '' }, htmlBlock: { default: true }, }, parseDOM: [ { tag: typeName, getAttrs(dom: Node | string) { return { htmlAttrs: getHTMLAttrs(dom as HTMLElement), childrenHTML: (dom as HTMLElement).innerHTML, }; }, }, ], toDOM(node: ProsemirrorNode): DOMOutputSpec { const { dom, htmlAttrs } = sanitizeDOM(node, typeName, sanitizeHTML, wwToDOMAdaptor); htmlAttrs.class = htmlAttrs.class ? `${htmlAttrs.class} html-block` : 'html-block'; return [typeName, htmlAttrs, ...toArray(dom.childNodes)]; }, }; }, htmlInline(typeName: string, sanitizeHTML: Sanitizer, wwToDOMAdaptor: ToDOMAdaptor): MarkSpec { return { attrs: { htmlAttrs: { default: {} }, htmlInline: { default: true }, }, parseDOM: [ { tag: typeName, getAttrs(dom: Node | string) { return { htmlAttrs: getHTMLAttrs(dom as HTMLElement), }; }, }, ], toDOM(node: ProsemirrorMark): DOMOutputSpec { const { htmlAttrs } = sanitizeDOM(node, typeName, sanitizeHTML, wwToDOMAdaptor); return [typeName, htmlAttrs, 0]; }, }; }, }; export function createHTMLSchemaMap( convertorMap: CustomHTMLRenderer, sanitizeHTML: Sanitizer, wwToDOMAdaptor: ToDOMAdaptor ): HTMLSchemaMap { const htmlSchemaMap: HTMLSchemaMap = { nodes: {}, marks: {} }; (['htmlBlock', 'htmlInline'] as const).forEach((htmlType) => { if (convertorMap[htmlType]) { Object.keys(convertorMap[htmlType]!).forEach((type) => { const targetType = htmlType === 'htmlBlock' ? 'nodes' : 'marks'; // register tag white list for preventing to remove the html in sanitizer registerTagWhitelistIfPossible(type); htmlSchemaMap[targetType][type] = schemaFactory[htmlType]( type, sanitizeHTML, wwToDOMAdaptor ); }); } }); return htmlSchemaMap; } ================================================ FILE: apps/editor/src/wysiwyg/nodes/htmlComment.ts ================================================ import { DOMOutputSpec } from 'prosemirror-model'; import { exitCode } from 'prosemirror-commands'; import NodeSchema from '@/spec/node'; import { EditorCommand } from '@t/spec'; export class HTMLComment extends NodeSchema { get name() { return 'htmlComment'; } get schema() { return { content: 'text*', group: 'block', code: true, defining: true, parseDOM: [{ preserveWhitespace: 'full' as const, tag: 'div[data-html-comment]' }], toDOM(): DOMOutputSpec { return ['div', { 'data-html-comment': 'true' }, 0]; }, }; } commands(): EditorCommand { return () => (state, dispatch, view) => { const { $from } = state.selection; if (view!.endOfTextblock('down') && $from.node().type.name === 'htmlComment') { return exitCode(state, dispatch); } return false; }; } keymaps() { return { Enter: this.commands()(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/image.ts ================================================ import { ProsemirrorNode, DOMOutputSpec } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { escapeXml } from '@/utils/common'; import { sanitizeHTML } from '@/sanitizer/htmlSanitizer'; import { EditorCommand } from '@t/spec'; import { getCustomAttrs, getDefaultCustomAttrs } from '../helper/node'; export class Image extends NodeSchema { get name() { return 'image'; } get schema() { return { inline: true, attrs: { imageUrl: { default: '' }, altText: { default: null }, rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, group: 'inline', selectable: false, parseDOM: [ { tag: 'img[src]', getAttrs(dom: Node | string) { const sanitizedDOM = sanitizeHTML(dom, { RETURN_DOM_FRAGMENT: true }) .firstChild as HTMLElement; const imageUrl = sanitizedDOM.getAttribute('src') || ''; const rawHTML = sanitizedDOM.getAttribute('data-raw-html'); const altText = sanitizedDOM.getAttribute('alt'); return { imageUrl, altText, ...(rawHTML && { rawHTML }), }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return [ attrs.rawHTML || 'img', { src: escapeXml(attrs.imageUrl), ...(attrs.altText && { alt: attrs.altText }), ...getCustomAttrs(attrs), }, ]; }, }; } private addImage(): EditorCommand { return (payload) => ({ schema, tr }, dispatch) => { const { imageUrl, altText } = payload!; if (!imageUrl) { return false; } const node = schema.nodes.image.createAndFill({ imageUrl, ...(altText && { altText }), }); dispatch!(tr.replaceSelectionWith(node!).scrollIntoView()); return true; }; } commands() { return { addImage: this.addImage(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/listItem.ts ================================================ import type { Command } from 'prosemirror-commands'; import type { ProsemirrorNode, DOMOutputSpec } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { splitListItem } from '@/wysiwyg/command/list'; export class ListItem extends NodeSchema { get name() { return 'listItem'; } get schema() { return { content: 'paragraph block*', selectable: false, attrs: { task: { default: false }, checked: { default: false }, rawHTML: { default: null }, }, defining: true, parseDOM: [ { tag: 'li', getAttrs(dom: Node | string) { const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { task: (dom as HTMLElement).hasAttribute('data-task'), checked: (dom as HTMLElement).hasAttribute('data-task-checked'), ...(rawHTML && { rawHTML }), }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { const { task, checked } = attrs; if (!task) { return [attrs.rawHTML || 'li', 0]; } const classNames = ['task-list-item']; if (checked) { classNames.push('checked'); } return [ attrs.rawHTML || 'li', { class: classNames.join(' '), 'data-task': task, ...(checked && { 'data-task-checked': checked }), }, 0, ]; }, }; } private liftToPrevListItem(): Command { return (state, dispatch) => { const { selection, tr, schema } = state; const { $from, empty } = selection; const { listItem } = schema.nodes; const { parent } = $from; const listItemParent = $from.node(-1); if (empty && !parent.childCount && listItemParent.type === listItem) { // move to previous sibling list item when the current list item is not top list item if ($from.index(-2) >= 1) { // should subtract '1' for considering tag length(

                      • ) tr.delete($from.start(-1) - 1, $from.end(-1)); dispatch!(tr); return true; } const grandParentListItem = $from.node(-3); // move to parent list item when the current list item is top list item if (grandParentListItem.type === listItem) { // should subtract '1' for considering tag length(
                          ) tr.delete($from.start(-2) - 1, $from.end(-1)); dispatch!(tr); return true; } } return false; }; } keymaps() { const split: Command = (state, dispatch) => splitListItem(state.schema.nodes.listItem)(state, dispatch); return { Backspace: this.liftToPrevListItem(), Enter: split, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/orderedList.ts ================================================ import { ProsemirrorNode, DOMOutputSpec } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { getWwCommands } from '@/commands/wwCommands'; import { changeList } from '@/wysiwyg/command/list'; import { EditorCommand } from '@t/spec'; import { getDefaultCustomAttrs, getCustomAttrs } from '@/wysiwyg/helper/node'; export class OrderedList extends NodeSchema { get name() { return 'orderedList'; } get schema() { return { content: 'listItem+', group: 'block', attrs: { order: { default: 1 }, rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [ { tag: 'ol', getAttrs(dom: Node | string) { const start = (dom as HTMLElement).getAttribute('start'); const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); return { order: (dom as HTMLElement).hasAttribute('start') ? Number(start) : 1, ...(rawHTML && { rawHTML }), }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return [ attrs.rawHTML || 'ol', { start: attrs.order === 1 ? null : attrs.order, ...getCustomAttrs(attrs) }, 0, ]; }, }; } commands(): EditorCommand { return () => (state, dispatch) => changeList(state.schema.nodes.orderedList)(state, dispatch); } keymaps() { const orderedListCommand = this.commands()(); const { indent, outdent } = getWwCommands(); return { 'Mod-o': orderedListCommand, 'Mod-O': orderedListCommand, Tab: indent(), 'Shift-Tab': outdent(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/paragraph.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { getDefaultCustomAttrs, getCustomAttrs } from '@/wysiwyg/helper/node'; export class Paragraph extends NodeSchema { get name() { return 'paragraph'; } get schema() { return { content: 'inline*', group: 'block', attrs: { ...getDefaultCustomAttrs(), }, parseDOM: [{ tag: 'p' }], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['p', getCustomAttrs(attrs), 0]; }, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/table.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import { TextSelection, Transaction } from 'prosemirror-state'; import { Command } from 'prosemirror-commands'; import NodeSchema from '@/spec/node'; import { isInTableNode, findNodeBy, createDOMInfoParsedRawHTML, getCustomAttrs, getDefaultCustomAttrs, } from '@/wysiwyg/helper/node'; import { createTableHeadRow, createTableBodyRows, createDummyCells, getResolvedSelection, getRowAndColumnCount, setAttrs, } from '@/wysiwyg/helper/table'; import { canBeOutOfTable, canMoveBetweenCells, canSelectTableNode, selectNode, addParagraphBeforeTable, addParagraphAfterTable, moveToCell, } from '@/wysiwyg/command/table'; import { createTextSelection } from '@/helper/manipulation'; import { EditorCommand } from '@t/spec'; import { ColumnAlign } from '@t/wysiwyg'; import { SelectionInfo, TableOffsetMap } from '@/wysiwyg/helper/tableOffsetMap'; interface AddTablePayload { rowCount: number; columnCount: number; data: string[]; } interface AlignColumnPayload { align: ColumnAlign; } // eslint-disable-next-line no-shadow export const enum Direction { LEFT = 'left', RIGHT = 'right', UP = 'up', DOWN = 'down', } type ColDirection = Direction.LEFT | Direction.RIGHT; type RowDirection = Direction.UP | Direction.DOWN; function getTargetRowInfo( direction: RowDirection, map: TableOffsetMap, selectionInfo: SelectionInfo ) { let targetRowIdx: number; let insertColIdx: number; let nodeSize: number; if (direction === Direction.UP) { targetRowIdx = selectionInfo.startRowIdx; insertColIdx = 0; nodeSize = -1; } else { targetRowIdx = selectionInfo.endRowIdx; insertColIdx = map.totalColumnCount - 1; nodeSize = map.getCellInfo(targetRowIdx, insertColIdx).nodeSize + 1; } return { targetRowIdx, insertColIdx, nodeSize }; } function getRowRanges(map: TableOffsetMap, rowIdx: number, totalColumnCount: number) { const { offset: startOffset } = map.getCellInfo(rowIdx, 0); const { offset, nodeSize } = map.getCellInfo(rowIdx, totalColumnCount - 1); return { from: startOffset, to: offset + nodeSize }; } export class Table extends NodeSchema { get name() { return 'table'; } get schema() { return { content: 'tableHead{1} tableBody{1}', group: 'block', attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [createDOMInfoParsedRawHTML('table')], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['table', getCustomAttrs(attrs), 0]; }, }; } private addTable(): EditorCommand { return (payload = { rowCount: 2, columnCount: 1, data: [] }) => (state, dispatch) => { const { rowCount, columnCount, data } = payload; const { schema, selection, tr } = state; const { from, to, $from } = selection; const collapsed = from === to; if (collapsed && !isInTableNode($from)) { const { tableHead, tableBody } = schema.nodes; const theadData = data?.slice(0, columnCount); const tbodyData = data?.slice(columnCount, data.length); const tableHeadRow = createTableHeadRow(columnCount, schema, theadData); const tableBodyRows = createTableBodyRows(rowCount - 1, columnCount, schema, tbodyData); const table = schema.nodes.table.create(null, [ tableHead.create(null, tableHeadRow), tableBody.create(null, tableBodyRows), ]); dispatch!(tr.replaceSelectionWith(table)); return true; } return false; }; } private removeTable(): EditorCommand { return () => (state, dispatch) => { const { selection, tr } = state; const map = TableOffsetMap.create(selection.$anchor)!; if (map) { const { tableStartOffset, tableEndOffset } = map; const startOffset = tableStartOffset - 1; const cursorPos = createTextSelection(tr.delete(startOffset, tableEndOffset), startOffset); dispatch!(tr.setSelection(cursorPos)); return true; } return false; }; } private addColumn(direction: ColDirection): EditorCommand { return () => (state, dispatch) => { const { selection, tr, schema } = state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const map = TableOffsetMap.create(anchor)!; const selectionInfo = map.getRectOffsets(anchor, head); const targetColIdx = direction === Direction.LEFT ? selectionInfo.startColIdx : selectionInfo.endColIdx + 1; const { columnCount } = getRowAndColumnCount(selectionInfo); const { totalRowCount } = map; for (let rowIdx = 0; rowIdx < totalRowCount; rowIdx += 1) { const cells = createDummyCells(columnCount, rowIdx, schema); tr.insert(tr.mapping.map(map.posAt(rowIdx, targetColIdx)), cells); } dispatch!(tr); return true; } return false; }; } private removeColumn(): EditorCommand { return () => (state, dispatch) => { const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const map = TableOffsetMap.create(anchor)!; const selectionInfo = map.getRectOffsets(anchor, head); const { totalColumnCount, totalRowCount } = map; const { columnCount } = getRowAndColumnCount(selectionInfo); const selectedAllColumn = columnCount === totalColumnCount; if (selectedAllColumn) { return false; } const { startColIdx, endColIdx } = selectionInfo; const mapStart = tr.mapping.maps.length; for (let rowIdx = 0; rowIdx < totalRowCount; rowIdx += 1) { for (let colIdx = endColIdx; colIdx >= startColIdx; colIdx -= 1) { const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); const from = tr.mapping.slice(mapStart).map(offset); const to = from + nodeSize; tr.delete(from, to); } } dispatch!(tr); return true; } return false; }; } private addRow(direction: Direction.UP | Direction.DOWN): EditorCommand { return () => (state, dispatch) => { const { selection, schema, tr } = state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const map = TableOffsetMap.create(anchor)!; const { totalColumnCount } = map; const selectionInfo = map.getRectOffsets(anchor, head); const { rowCount } = getRowAndColumnCount(selectionInfo); const { targetRowIdx, insertColIdx, nodeSize } = getTargetRowInfo( direction, map, selectionInfo ); const selectedThead = targetRowIdx === 0; if (!selectedThead) { const rows: ProsemirrorNode[] = []; const from = tr.mapping.map(map.posAt(targetRowIdx, insertColIdx)) + nodeSize; let cells: ProsemirrorNode[] = []; for (let colIdx = 0; colIdx < totalColumnCount; colIdx += 1) { cells = cells.concat(createDummyCells(1, targetRowIdx, schema)); } for (let i = 0; i < rowCount; i += 1) { rows.push(schema.nodes.tableRow.create(null, cells)); } dispatch!(tr.insert(from, rows)); return true; } } return false; }; } private removeRow(): EditorCommand { return () => (state, dispatch) => { const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const map = TableOffsetMap.create(anchor)!; const { totalRowCount, totalColumnCount } = map; const selectionInfo = map.getRectOffsets(anchor, head); const { rowCount } = getRowAndColumnCount(selectionInfo); const { startRowIdx, endRowIdx } = selectionInfo; const selectedThead = startRowIdx === 0; const selectedAllTbodyRow = rowCount === totalRowCount - 1; if (selectedAllTbodyRow || selectedThead) { return false; } for (let rowIdx = endRowIdx; rowIdx >= startRowIdx; rowIdx -= 1) { const { from, to } = getRowRanges(map, rowIdx, totalColumnCount); // delete table row tr.delete(from - 1, to + 1); } dispatch!(tr); return true; } return false; }; } private alignColumn(): EditorCommand { return (payload = { align: 'center' }) => (state, dispatch) => { const { align } = payload; const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const map = TableOffsetMap.create(anchor)!; const { totalRowCount } = map; const selectionInfo = map.getRectOffsets(anchor, head); const { startColIdx, endColIdx } = selectionInfo; for (let rowIdx = 0; rowIdx < totalRowCount; rowIdx += 1) { for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { if (!map.extendedRowspan(rowIdx, colIdx) && !map.extendedColspan(rowIdx, colIdx)) { const { node, pos } = map.getNodeAndPos(rowIdx, colIdx); const attrs = setAttrs(node, { align }); tr.setNodeMarkup(pos, null, attrs); } } } dispatch!(tr); return true; } return false; }; } private moveToCell(direction: Direction): Command { return (state, dispatch) => { const { selection, tr, schema } = state; const { anchor, head } = getResolvedSelection(selection); if (anchor && head) { const map = TableOffsetMap.create(anchor)!; const cellIndex = map.getCellIndex(anchor); let newTr: Transaction | null; if (canBeOutOfTable(direction, map, cellIndex)) { // When there is no content before or after the table, // an empty line('paragraph') is created by pressing the arrow keys. newTr = addParagraphAfterTable(tr, map, schema); } else { newTr = moveToCell(direction, tr, cellIndex, map); } if (newTr) { dispatch!(newTr); return true; } } return false; }; } private moveInCell(direction: Direction): Command { return (state, dispatch) => { const { selection, tr, doc, schema } = state; const { $from } = selection; const { view } = this.context; if (!view.endOfTextblock(direction)) { return false; } const cell = findNodeBy( $from, ({ type }) => type.name === 'tableHeadCell' || type.name === 'tableBodyCell' ); if (cell) { const para = findNodeBy($from, ({ type }) => type.name === 'paragraph'); const { depth: cellDepth } = cell; if (para && canMoveBetweenCells(direction, [cellDepth, para.depth], $from, doc)) { const { anchor } = getResolvedSelection(selection); const map = TableOffsetMap.create(anchor)!; const cellIndex = map.getCellIndex(anchor); let newTr; if (canSelectTableNode(direction, map, cellIndex)) { // When the cursor position is at the end of the cell, // the table is selected when the left / right arrow keys are pressed. newTr = selectNode(tr, $from, cellDepth); } else if (canBeOutOfTable(direction, map, cellIndex)) { // When there is no content before or after the table, // an empty line('paragraph') is created by pressing the arrow keys. if (direction === Direction.UP) { newTr = addParagraphBeforeTable(tr, map, schema); } else if (direction === Direction.DOWN) { newTr = addParagraphAfterTable(tr, map, schema); } } else { newTr = moveToCell(direction, tr, cellIndex, map); } if (newTr) { dispatch!(newTr); return true; } } } return false; }; } private deleteCells(): Command { return (state, dispatch) => { const { schema, selection, tr } = state; const { anchor, head } = getResolvedSelection(selection); const textSelection = selection instanceof TextSelection; if (anchor && head && !textSelection) { const map = TableOffsetMap.create(anchor)!; const { startRowIdx, startColIdx, endRowIdx, endColIdx } = map.getRectOffsets(anchor, head); for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { if (!map.extendedRowspan(rowIdx, colIdx) && !map.extendedColspan(rowIdx, colIdx)) { const { node, pos } = map.getNodeAndPos(rowIdx, colIdx); const cells = createDummyCells(1, rowIdx, schema, node.attrs); tr.replaceWith(tr.mapping.map(pos), tr.mapping.map(pos + node.nodeSize), cells); } } } dispatch!(tr); return true; } return false; }; } private exitTable(): Command { return (state, dispatch) => { const { selection, tr, schema } = state; const { $from } = selection; const cell = findNodeBy( $from, ({ type }) => type.name === 'tableHeadCell' || type.name === 'tableBodyCell' ); if (cell) { const para = findNodeBy($from, ({ type }) => type.name === 'paragraph'); if (para) { const { anchor } = getResolvedSelection(selection); const map = TableOffsetMap.create(anchor)!; dispatch!(addParagraphAfterTable(tr, map, schema, true)); return true; } } return false; }; } commands() { return { addTable: this.addTable(), removeTable: this.removeTable(), addColumnToLeft: this.addColumn(Direction.LEFT), addColumnToRight: this.addColumn(Direction.RIGHT), removeColumn: this.removeColumn(), addRowToUp: this.addRow(Direction.UP), addRowToDown: this.addRow(Direction.DOWN), removeRow: this.removeRow(), alignColumn: this.alignColumn(), }; } keymaps() { const deleteCellContent = this.deleteCells(); return { Tab: this.moveToCell(Direction.RIGHT), 'Shift-Tab': this.moveToCell(Direction.LEFT), ArrowUp: this.moveInCell(Direction.UP), ArrowDown: this.moveInCell(Direction.DOWN), ArrowLeft: this.moveInCell(Direction.LEFT), ArrowRight: this.moveInCell(Direction.RIGHT), Backspace: deleteCellContent, 'Mod-Backspace': deleteCellContent, Delete: deleteCellContent, 'Mod-Delete': deleteCellContent, 'Mod-Enter': this.exitTable(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/tableBody.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { getCustomAttrs, getDefaultCustomAttrs } from '@/wysiwyg/helper/node'; export class TableBody extends NodeSchema { get name() { return 'tableBody'; } get schema() { return { content: 'tableRow+', attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [ { tag: 'tbody', getAttrs(dom: Node | string) { const rows = (dom as HTMLElement).querySelectorAll('tr'); const columns = rows[0].children.length; const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); if (!columns) { return false; } return { ...(rawHTML && { rawHTML }) }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['tbody', getCustomAttrs(attrs), 0]; }, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/tableBodyCell.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { createCellAttrs, createParsedCellDOM } from '@/wysiwyg/helper/node'; export class TableBodyCell extends NodeSchema { get name() { return 'tableBodyCell'; } get schema() { return { content: '(paragraph | bulletList | orderedList)+', attrs: { align: { default: null }, className: { default: null }, rawHTML: { default: null }, colspan: { default: null }, rowspan: { default: null }, extended: { default: null }, }, isolating: true, parseDOM: [createParsedCellDOM('td')], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { const cellAttrs = createCellAttrs(attrs); return ['td', cellAttrs, 0]; }, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/tableHead.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { createDOMInfoParsedRawHTML, getCustomAttrs, getDefaultCustomAttrs, } from '@/wysiwyg/helper/node'; export class TableHead extends NodeSchema { get name() { return 'tableHead'; } get schema() { return { content: 'tableRow{1}', attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [createDOMInfoParsedRawHTML('thead')], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['thead', getCustomAttrs(attrs), 0]; }, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/tableHeadCell.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { createCellAttrs, createParsedCellDOM, getCustomAttrs, getDefaultCustomAttrs, } from '@/wysiwyg/helper/node'; export class TableHeadCell extends NodeSchema { get name() { return 'tableHeadCell'; } get schema() { return { content: 'paragraph+', attrs: { align: { default: null }, className: { default: null }, rawHTML: { default: null }, colspan: { default: null }, extended: { default: null }, ...getDefaultCustomAttrs(), }, isolating: true, parseDOM: [createParsedCellDOM('th')], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { const cellAttrs = createCellAttrs(attrs); return ['th', { ...cellAttrs, ...getCustomAttrs(attrs) }, 0]; }, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/tableRow.ts ================================================ import { DOMOutputSpec, ProsemirrorNode } from 'prosemirror-model'; import NodeSchema from '@/spec/node'; import { getDefaultCustomAttrs, getCustomAttrs } from '@/wysiwyg/helper/node'; export class TableRow extends NodeSchema { get name() { return 'tableRow'; } get schema() { return { content: '(tableHeadCell | tableBodyCell)*', attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, parseDOM: [ { tag: 'tr', getAttrs: (dom: Node | string) => { const columns = (dom as HTMLElement).children.length; const rawHTML = (dom as HTMLElement).getAttribute('data-raw-html'); if (!columns) { return false; } return { ...(rawHTML && { rawHTML }) }; }, }, ], toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['tr', getCustomAttrs(attrs), 0]; }, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/text.ts ================================================ import { Command } from 'prosemirror-commands'; import Node from '@/spec/node'; import { isInListNode, isInTableNode } from '../helper/node'; const reSoftTabLen = /\s{1,4}$/; export class Text extends Node { get name() { return 'text'; } get schema() { return { group: 'inline', }; } private addSpaces(): Command { return ({ selection, tr }, dispatch) => { const { $from, $to } = selection; const range = $from.blockRange($to); if (range && !isInListNode($from) && !isInTableNode($from)) { dispatch!(tr.insertText(' ', $from.pos, $to.pos)); return true; } return false; }; } private removeSpaces(): Command { return ({ selection, tr }, dispatch) => { const { $from, $to, from } = selection; const range = $from.blockRange($to); if (range && !isInListNode($from) && !isInTableNode($from)) { const { nodeBefore } = $from; if (nodeBefore && nodeBefore.isText) { const text = nodeBefore.text!; const removedSpaceText = text.replace(reSoftTabLen, ''); const spaces = text.length - removedSpaceText.length; dispatch!(tr.delete(from - spaces, from)); return true; } } return false; }; } keymaps() { return { Tab: this.addSpaces(), 'Shift-Tab': this.removeSpaces(), }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodes/thematicBreak.ts ================================================ import { ProsemirrorNode, DOMOutputSpec } from 'prosemirror-model'; import Node from '@/spec/node'; import { EditorCommand } from '@t/spec'; import { getDefaultCustomAttrs, getCustomAttrs } from '@/wysiwyg/helper/node'; const ROOT_BLOCK_DEPTH = 1; export class ThematicBreak extends Node { get name() { return 'thematicBreak'; } get schema() { return { attrs: { rawHTML: { default: null }, ...getDefaultCustomAttrs(), }, group: 'block', parseDOM: [{ tag: 'hr' }], selectable: false, toDOM({ attrs }: ProsemirrorNode): DOMOutputSpec { return ['div', getCustomAttrs(attrs), [attrs.rawHTML || 'hr']]; }, }; } private hr(): EditorCommand { return () => (state, dispatch) => { const { $from, $to } = state.selection; if ($from === $to) { const { doc } = state; const { thematicBreak, paragraph } = state.schema.nodes; const nodes: ProsemirrorNode[] = [thematicBreak.create()]; const rootBlock = $from.node(ROOT_BLOCK_DEPTH); const lastBlock = doc.child(doc.childCount - 1) === rootBlock; const blockEnd = doc.resolve($from.after(ROOT_BLOCK_DEPTH)); const nextHr = $from.nodeAfter?.type.name === this.name; if (lastBlock || nextHr) { nodes.push(paragraph.create()); } dispatch!(state.tr.insert(blockEnd.pos, nodes).scrollIntoView()); return true; } return false; }; } commands() { return { hr: this.hr() }; } keymaps() { const hrCommand = this.hr()(); return { 'Mod-l': hrCommand, 'Mod-L': hrCommand, }; } } ================================================ FILE: apps/editor/src/wysiwyg/nodeview/codeBlockView.ts ================================================ import { EditorView, NodeView } from 'prosemirror-view'; import { ProsemirrorNode } from 'prosemirror-model'; import isFunction from 'tui-code-snippet/type/isFunction'; import css from 'tui-code-snippet/domUtil/css'; import { removeNode, setAttributes } from '@/utils/dom'; import { getCustomAttrs } from '@/wysiwyg/helper/node'; import { Emitter } from '@t/event'; type GetPos = (() => number) | boolean; type InputPos = { top: number; right: number; }; const WRAPPER_CLASS_NAME = 'toastui-editor-ww-code-block'; const CODE_BLOCK_LANG_CLASS_NAME = 'toastui-editor-ww-code-block-language'; export class CodeBlockView implements NodeView { dom!: HTMLElement; contentDOM: HTMLElement | null = null; private node: ProsemirrorNode; private view: EditorView; private getPos: GetPos; private eventEmitter: Emitter; private input: HTMLElement | null = null; private timer: NodeJS.Timeout | null = null; constructor(node: ProsemirrorNode, view: EditorView, getPos: GetPos, eventEmitter: Emitter) { this.node = node; this.view = view; this.getPos = getPos; this.eventEmitter = eventEmitter; this.createElement(); this.bindDOMEvent(); this.bindEvent(); } private createElement() { const { language } = this.node.attrs; const wrapper = document.createElement('div'); wrapper.setAttribute('data-language', language || 'text'); wrapper.className = WRAPPER_CLASS_NAME; const pre = this.createCodeBlockElement(); const code = pre.firstChild as HTMLElement; wrapper.appendChild(pre); this.dom = wrapper; this.contentDOM = code; } private createCodeBlockElement() { const pre = document.createElement('pre'); const code = document.createElement('code'); const { language } = this.node.attrs; const attrs = getCustomAttrs(this.node.attrs); if (language) { code.setAttribute('data-language', language); } setAttributes(attrs, pre); pre.appendChild(code); return pre; } private createLanguageEditor({ top, right }: InputPos) { const wrapper = document.createElement('span'); wrapper.className = CODE_BLOCK_LANG_CLASS_NAME; const input = document.createElement('input'); input.type = 'text'; input.value = this.node.attrs.language; wrapper.appendChild(input); this.view.dom.parentElement!.appendChild(wrapper); const wrpperWidth = wrapper.clientWidth; css(wrapper, { top: `${top + 10}px`, left: `${right - wrpperWidth - 10}px`, width: `${wrpperWidth}px`, }); this.input = input; this.input.addEventListener('blur', () => this.changeLanguage()); this.input.addEventListener('keydown', this.handleKeydown); this.clearTimer(); this.timer = setTimeout(() => { this.input!.focus(); }); } private bindDOMEvent() { if (this.dom) { this.dom.addEventListener('click', this.handleMousedown); } } private bindEvent() { this.eventEmitter.listen('scroll', () => { if (this.input) { this.reset(); } }); } private handleMousedown = (ev: MouseEvent) => { const target = ev.target as HTMLElement; const style = getComputedStyle(target, ':after'); // judge to click pseudo element with background image for IE11 if (style.backgroundImage !== 'none' && isFunction(this.getPos)) { const { top, right } = this.view.coordsAtPos(this.getPos()); this.createLanguageEditor({ top, right }); } }; private handleKeydown = (ev: KeyboardEvent) => { if (ev.key === 'Enter' && this.input) { ev.preventDefault(); this.changeLanguage(); } }; private changeLanguage() { if (this.input && isFunction(this.getPos)) { const { value } = this.input as HTMLInputElement; this.reset(); const pos = this.getPos(); const { tr } = this.view.state; tr.setNodeMarkup(pos, null, { language: value }); this.view.dispatch(tr); } } private reset() { if (this.input?.parentElement) { const parent = this.input.parentElement; this.input = null; removeNode(parent); } } private clearTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } stopEvent() { return true; } update(node: ProsemirrorNode) { if (!node.sameMarkup(this.node)) { return false; } this.node = node; return true; } destroy() { this.reset(); this.clearTimer(); if (this.dom) { this.dom.removeEventListener('click', this.handleMousedown); } } } ================================================ FILE: apps/editor/src/wysiwyg/nodeview/customBlockView.ts ================================================ import { EditorView, NodeView } from 'prosemirror-view'; import { ProsemirrorNode } from 'prosemirror-model'; import { StepMap } from 'prosemirror-transform'; import { EditorState, TextSelection, Transaction } from 'prosemirror-state'; import { newlineInCode } from 'prosemirror-commands'; import { redo, undo, undoDepth, history } from 'prosemirror-history'; import { keymap } from 'prosemirror-keymap'; import isFunction from 'tui-code-snippet/type/isFunction'; import { ToDOMAdaptor } from '@t/convertor'; import { createTextSelection } from '@/helper/manipulation'; import { cls } from '@/utils/dom'; type GetPos = (() => number) | boolean; export class CustomBlockView implements NodeView { dom: HTMLElement; private node: ProsemirrorNode; private toDOMAdaptor: ToDOMAdaptor; private editorView: EditorView; private innerEditorView: EditorView | null; private wrapper: HTMLElement; private innerViewContainer!: HTMLElement; private getPos: GetPos; private canceled: boolean; constructor(node: ProsemirrorNode, view: EditorView, getPos: GetPos, toDOMAdaptor: ToDOMAdaptor) { this.node = node; this.editorView = view; this.getPos = getPos; this.toDOMAdaptor = toDOMAdaptor; this.innerEditorView = null; this.canceled = false; this.dom = document.createElement('div'); this.dom.className = cls('custom-block'); this.wrapper = document.createElement('div'); this.wrapper.className = cls('custom-block-view'); this.createInnerViewContainer(); this.renderCustomBlock(); this.dom.appendChild(this.innerViewContainer); this.dom.appendChild(this.wrapper); } private renderToolArea() { const tool = document.createElement('div'); const span = document.createElement('span'); const button = document.createElement('button'); tool.className = 'tool'; span.textContent = this.node.attrs.info; span.className = 'info'; button.type = 'button'; button.addEventListener('click', () => this.openEditor()); tool.appendChild(span); tool.appendChild(button); this.wrapper.appendChild(tool); } private renderCustomBlock() { const toDOMNode = this.toDOMAdaptor.getToDOMNode(this.node.attrs.info); if (toDOMNode) { const node = toDOMNode(this.node); while (this.wrapper.hasChildNodes()) { this.wrapper.removeChild(this.wrapper.lastChild!); } if (node) { this.wrapper.appendChild(node); } this.renderToolArea(); } } private createInnerViewContainer() { this.innerViewContainer = document.createElement('div'); this.innerViewContainer.className = cls('custom-block-editor'); this.innerViewContainer.style.display = 'none'; } private openEditor = () => { if (this.innerEditorView) { throw new Error('The editor is already opened.'); } this.dom.draggable = false; this.wrapper.style.display = 'none'; this.innerViewContainer.style.display = 'block'; this.innerEditorView = new EditorView(this.innerViewContainer, { state: EditorState.create({ doc: this.node, plugins: [ keymap({ 'Mod-z': () => undo(this.innerEditorView!.state, this.innerEditorView!.dispatch), 'Shift-Mod-z': () => redo(this.innerEditorView!.state, this.innerEditorView!.dispatch), Tab: (state, dispatch) => { dispatch!(state.tr.insertText('\t')); return true; }, Enter: newlineInCode, Escape: () => { this.cancelEditing(); return true; }, 'Ctrl-Enter': () => { this.saveAndFinishEditing(); return true; }, }), history(), ], }), dispatchTransaction: (tr: Transaction) => this.dispatchInner(tr), handleDOMEvents: { mousedown: () => { if (this.editorView.hasFocus()) { this.innerEditorView!.focus(); } return true; }, blur: () => { this.saveAndFinishEditing(); return true; }, }, }); this.innerEditorView!.focus(); }; private closeEditor() { if (this.innerEditorView) { this.innerEditorView.destroy(); this.innerEditorView = null; this.innerViewContainer.style.display = 'none'; } this.wrapper.style.display = 'block'; } private saveAndFinishEditing() { const { to } = this.editorView.state.selection; const outerState: EditorState = this.editorView.state; this.editorView.dispatch(outerState.tr.setSelection(createTextSelection(outerState.tr, to))); this.editorView.focus(); this.renderCustomBlock(); this.closeEditor(); } private cancelEditing() { let undoableCount = undoDepth(this.innerEditorView!.state); this.canceled = true; // should undo editing result // eslint-disable-next-line no-plusplus while (undoableCount--) { undo(this.innerEditorView!.state, this.innerEditorView!.dispatch); undo(this.editorView.state, this.editorView.dispatch); } this.canceled = false; const { to } = this.editorView.state.selection; const outerState: EditorState = this.editorView.state; this.editorView.dispatch(outerState.tr.setSelection(TextSelection.create(outerState.doc, to))); this.editorView.focus(); this.closeEditor(); } private dispatchInner(tr: Transaction) { const { state, transactions } = this.innerEditorView!.state.applyTransaction(tr); this.innerEditorView!.updateState(state); if (!this.canceled && isFunction(this.getPos)) { const outerTr = this.editorView.state.tr; const offsetMap = StepMap.offset(this.getPos() + 1); for (let i = 0; i < transactions.length; i += 1) { const { steps } = transactions[i]; for (let j = 0; j < steps.length; j += 1) { outerTr.step(steps[j].map(offsetMap)!); } } if (outerTr.docChanged) { this.editorView.dispatch(outerTr); } } } update(node: ProsemirrorNode) { if (!node.sameMarkup(this.node)) { return false; } this.node = node; if (!this.innerEditorView) { this.renderCustomBlock(); } return true; } stopEvent(event: Event): boolean { return ( !!this.innerEditorView && !!event.target && this.innerEditorView.dom.contains(event.target as Node) ); } ignoreMutation() { return true; } destroy() { this.dom.removeEventListener('dblclick', this.openEditor); this.closeEditor(); } } ================================================ FILE: apps/editor/src/wysiwyg/nodeview/imageView.ts ================================================ import { EditorView, NodeView } from 'prosemirror-view'; import { Node as ProsemirrorNode, Mark } from 'prosemirror-model'; import hasClass from 'tui-code-snippet/domUtil/hasClass'; import isFunction from 'tui-code-snippet/type/isFunction'; import { isPositionInBox, setAttributes } from '@/utils/dom'; import { createTextSelection } from '@/helper/manipulation'; import { getCustomAttrs } from '@/wysiwyg/helper/node'; import { Emitter } from '@t/event'; type GetPos = (() => number) | boolean; const IMAGE_LINK_CLASS_NAME = 'image-link'; export class ImageView implements NodeView { dom: HTMLElement; private node: ProsemirrorNode; private view: EditorView; private getPos: GetPos; private eventEmitter: Emitter; private imageLink: Mark | null; constructor(node: ProsemirrorNode, view: EditorView, getPos: GetPos, eventEmitter: Emitter) { this.node = node; this.view = view; this.getPos = getPos; this.eventEmitter = eventEmitter; this.imageLink = node.marks.filter(({ type }) => type.name === 'link')[0] ?? null; this.dom = this.createElement(); this.bindEvent(); } private createElement() { const image = this.createImageElement(this.node); if (this.imageLink) { const wrapper = document.createElement('span'); wrapper.className = IMAGE_LINK_CLASS_NAME; wrapper.appendChild(image); return wrapper; } return image; } private createImageElement(node: ProsemirrorNode) { const image = document.createElement('img'); const { imageUrl, altText } = node.attrs; const attrs = getCustomAttrs(node.attrs); image.src = imageUrl; if (altText) { image.alt = altText; } setAttributes(attrs, image); return image; } private bindEvent() { if (this.imageLink) { this.dom.addEventListener('mousedown', this.handleMousedown); } } private handleMousedown = (ev: MouseEvent) => { ev.preventDefault(); const { target, offsetX, offsetY } = ev; if ( this.imageLink && isFunction(this.getPos) && hasClass(target as HTMLElement, IMAGE_LINK_CLASS_NAME) ) { const style = getComputedStyle(target as HTMLElement, ':before'); ev.stopPropagation(); if (isPositionInBox(style, offsetX, offsetY)) { const { tr } = this.view.state; const pos = this.getPos(); tr.setSelection(createTextSelection(tr, pos, pos + 1)); this.view.dispatch(tr); this.eventEmitter.emit('openPopup', 'link', this.imageLink.attrs); } } }; stopEvent() { return true; } destroy() { if (this.imageLink) { this.dom.removeEventListener('mousedown', this.handleMousedown); } } } ================================================ FILE: apps/editor/src/wysiwyg/plugins/selection/cellSelection.ts ================================================ import { Node, ResolvedPos, Slice, Fragment } from 'prosemirror-model'; import { Selection, SelectionRange, TextSelection } from 'prosemirror-state'; import { Mappable } from 'prosemirror-transform'; import { TableOffsetMap, SelectionInfo } from '@/wysiwyg/helper/tableOffsetMap'; function getSelectionRanges( doc: Node, map: TableOffsetMap, { startRowIdx, startColIdx, endRowIdx, endColIdx }: SelectionInfo ) { const ranges = []; for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); ranges.push(new SelectionRange(doc.resolve(offset + 1), doc.resolve(offset + nodeSize - 1))); } } return ranges; } function createTableFragment(tableHead: Node, tableBody: Node) { const fragment: Node[] = []; if (tableHead.childCount) { fragment.push(tableHead); } if (tableBody.childCount) { fragment.push(tableBody); } return Fragment.from(fragment); } export default class CellSelection extends Selection { private offsetMap: TableOffsetMap; startCell: ResolvedPos; endCell: ResolvedPos; isCellSelection: boolean; constructor(startCellPos: ResolvedPos, endCellPos = startCellPos) { const doc = startCellPos.node(0); const map = TableOffsetMap.create(startCellPos)!; const selectionInfo = map.getRectOffsets(startCellPos, endCellPos); const ranges = getSelectionRanges(doc, map, selectionInfo); super(ranges[0].$from, ranges[0].$to, ranges); this.startCell = startCellPos; this.endCell = endCellPos; this.offsetMap = map; this.isCellSelection = true; // This property is the api of the 'Selection' in prosemirror, // and is used to disable the text selection. this.visible = false; } map(doc: Node, mapping: Mappable) { const startPos = this.startCell.pos; const endPos = this.endCell.pos; const startCell = doc.resolve(mapping.map(startPos)); const endCell = doc.resolve(mapping.map(endPos)); const map = TableOffsetMap.create(startCell)!; // text selection when rows or columns are deleted if ( this.offsetMap.totalColumnCount > map.totalColumnCount || this.offsetMap.totalRowCount > map.totalRowCount ) { const depthMap = { tableBody: 1, tableRow: 2, tableCell: 3, paragraph: 4 }; const depthFromTable = depthMap[endCell.parent.type.name as keyof typeof depthMap]; const tableEndPos = endCell.end(endCell.depth - depthFromTable); // subtract 4( tag length) const from = Math.min(tableEndPos - 4, endCell.pos); return TextSelection.create(doc, from); } return new CellSelection(startCell, endCell); } eq(cell: CellSelection) { return ( cell instanceof CellSelection && cell.startCell.pos === this.startCell.pos && cell.endCell.pos === this.endCell.pos ); } content() { const table = this.startCell.node(-2); const tableOffset = this.startCell.start(-2); const row = table.child(1).firstChild!; const tableHead = table.child(0).type.create()!; const tableBody = table.child(1).type.create()!; const map = TableOffsetMap.create(this.startCell)!; const selectionInfo = map.getRectOffsets(this.startCell, this.endCell); const { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; let isTableHeadCell = false; for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { const cells = []; for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { const { offset } = map.getCellInfo(rowIdx, colIdx); const cell = table.nodeAt(offset - tableOffset); if (cell) { isTableHeadCell = cell.type.name === 'tableHeadCell'; // mark the extended cell for pasting if (map.extendedRowspan(rowIdx, colIdx) || map.extendedColspan(rowIdx, colIdx)) { cells.push(cell.type.create({ extended: true })); } else { cells.push(cell.copy(cell.content)); } } } const copiedRow = row.copy(Fragment.from(cells)); const targetNode = isTableHeadCell ? tableHead : tableBody; // @ts-ignore targetNode.content = targetNode.content.append(Fragment.from(copiedRow)); } return new Slice(createTableFragment(tableHead, tableBody), 1, 1); } toJSON() { return JSON.stringify(this); } } ================================================ FILE: apps/editor/src/wysiwyg/plugins/selection/tableSelection.ts ================================================ import { EditorState, Plugin, SelectionRange } from 'prosemirror-state'; import { EditorView, Decoration, DecorationSet } from 'prosemirror-view'; import isNull from 'tui-code-snippet/type/isNull'; import { cls } from '@/utils/dom'; import CellSelection from './cellSelection'; import TableSelection, { pluginKey } from './tableSelectionView'; const SELECTED_CELL_CLASS_NAME = cls('cell-selected'); function drawCellSelection({ selection, doc }: EditorState) { if (selection instanceof CellSelection) { const cells: Decoration[] = []; const { ranges } = selection; ranges.forEach(({ $from, $to }: SelectionRange) => { cells.push(Decoration.node($from.pos - 1, $to.pos + 1, { class: SELECTED_CELL_CLASS_NAME })); }); return DecorationSet.create(doc, cells); } return null; } export function tableSelection() { return new Plugin({ key: pluginKey, state: { init() { return null; }, apply(tr, value) { const cellOffset = tr.getMeta(pluginKey); if (cellOffset) { return cellOffset === -1 ? null : cellOffset; } if (isNull(value) || !tr.docChanged) { return value; } const { deleted, pos } = tr.mapping.mapResult(value); return deleted ? null : pos; }, }, props: { decorations: drawCellSelection, createSelectionBetween({ state }) { if (!isNull(pluginKey.getState(state))) { return state.selection; } return null; }, }, view(editorView: EditorView) { return new TableSelection(editorView); }, }); } ================================================ FILE: apps/editor/src/wysiwyg/plugins/selection/tableSelectionView.ts ================================================ import { ResolvedPos } from 'prosemirror-model'; import { EditorView } from 'prosemirror-view'; import { PluginKey } from 'prosemirror-state'; import { findCell, findCellElement } from '@/wysiwyg/helper/table'; import CellSelection from './cellSelection'; interface EventHandlers { mousedown: (ev: Event) => void; mousemove: (ev: Event) => void; mouseup: () => void; } export const pluginKey = new PluginKey('cellSelection'); const MOUSE_RIGHT_BUTTON = 2; export default class TableSelection { private view: EditorView; private handlers: EventHandlers; private startCellPos: ResolvedPos | null; constructor(view: EditorView) { this.view = view; this.handlers = { mousedown: this.handleMousedown.bind(this), mousemove: this.handleMousemove.bind(this), mouseup: this.handleMouseup.bind(this), }; this.startCellPos = null; this.init(); } init() { this.view.dom.addEventListener('mousedown', this.handlers.mousedown); } handleMousedown(ev: Event) { const foundCell = findCellElement(ev.target as HTMLElement, this.view.dom); if ((ev as MouseEvent).button === MOUSE_RIGHT_BUTTON) { ev.preventDefault(); return; } if (foundCell) { const startCellPos = this.getCellPos(ev as MouseEvent); if (startCellPos) { this.startCellPos = startCellPos; } this.bindEvent(); } } handleMousemove(ev: Event) { const prevEndCellOffset = pluginKey.getState(this.view.state); const endCellPos = this.getCellPos(ev as MouseEvent); const { startCellPos } = this; let prevEndCellPos; if (prevEndCellOffset) { prevEndCellPos = this.view.state.doc.resolve(prevEndCellOffset); } else if (startCellPos !== endCellPos) { prevEndCellPos = startCellPos; } if (prevEndCellPos && startCellPos && endCellPos) { this.setCellSelection(startCellPos, endCellPos); } } handleMouseup() { this.startCellPos = null; this.unbindEvent(); if (pluginKey.getState(this.view.state) !== null) { this.view.dispatch(this.view.state.tr.setMeta(pluginKey, -1)); } } bindEvent() { const { dom } = this.view; dom.addEventListener('mousemove', this.handlers.mousemove); dom.addEventListener('mouseup', this.handlers.mouseup); } unbindEvent() { const { dom } = this.view; dom.removeEventListener('mousemove', this.handlers.mousemove); dom.removeEventListener('mouseup', this.handlers.mouseup); } getCellPos({ clientX, clientY }: MouseEvent) { const mousePos = this.view.posAtCoords({ left: clientX, top: clientY }); if (mousePos) { const { doc } = this.view.state; const currentPos = doc.resolve(mousePos.pos); const foundCell = findCell(currentPos); if (foundCell) { const cellOffset = currentPos.before(foundCell.depth); return doc.resolve(cellOffset); } } return null; } setCellSelection(startCellPos: ResolvedPos, endCellPos: ResolvedPos) { const { selection, tr } = this.view.state; const starting = pluginKey.getState(this.view.state) === null; const cellSelection = new CellSelection(startCellPos, endCellPos); if (starting || !selection.eq(cellSelection)) { const newTr = tr.setSelection(cellSelection); if (starting) { newTr.setMeta(pluginKey, endCellPos.pos); } this.view.dispatch!(newTr); } } destroy() { this.view.dom.removeEventListener('mousedown', this.handlers.mousedown); } } ================================================ FILE: apps/editor/src/wysiwyg/plugins/tableContextMenu.ts ================================================ import { Plugin } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { findCellElement } from '@/wysiwyg/helper/table'; import i18n from '@/i18n/i18n'; import { Emitter } from '@t/event'; interface ContextMenuInfo { action: string; command: string; payload?: { align: string; }; className: string; disableInThead?: boolean; } const contextMenuGroups: ContextMenuInfo[][] = [ [ { action: 'Add row to up', command: 'addRowToUp', disableInThead: true, className: 'add-row-up', }, { action: 'Add row to down', command: 'addRowToDown', disableInThead: true, className: 'add-row-down', }, { action: 'Remove row', command: 'removeRow', disableInThead: true, className: 'remove-row' }, ], [ { action: 'Add column to left', command: 'addColumnToLeft', className: 'add-column-left' }, { action: 'Add column to right', command: 'addColumnToRight', className: 'add-column-right' }, { action: 'Remove column', command: 'removeColumn', className: 'remove-column' }, ], [ { action: 'Align column to left', command: 'alignColumn', payload: { align: 'left' }, className: 'align-column-left', }, { action: 'Align column to center', command: 'alignColumn', payload: { align: 'center' }, className: 'align-column-center', }, { action: 'Align column to right', command: 'alignColumn', payload: { align: 'right' }, className: 'align-column-right', }, ], [{ action: 'Remove table', command: 'removeTable', className: 'remove-table' }], ]; function getContextMenuGroups(eventEmitter: Emitter, inTableHead: boolean) { return contextMenuGroups .map((contextMenuGroup) => contextMenuGroup.map(({ action, command, payload, disableInThead, className }) => { return { label: i18n.get(action), onClick: () => { eventEmitter.emit('command', command, payload); }, disabled: inTableHead && !!disableInThead, className, }; }) ) .concat(); } export function tableContextMenu(eventEmitter: Emitter) { return new Plugin({ props: { handleDOMEvents: { contextmenu: (view: EditorView, ev: Event) => { const tableCell = findCellElement(ev.target as HTMLElement, view.dom); if (tableCell) { ev.preventDefault(); const { clientX, clientY } = ev as MouseEvent; const { left, top } = (view.dom.parentNode as HTMLElement).getBoundingClientRect(); const inTableHead = tableCell.nodeName === 'TH'; eventEmitter.emit('contextmenu', { pos: { left: `${clientX - left + 10}px`, top: `${clientY - top + 30}px` }, menuGroups: getContextMenuGroups(eventEmitter, inTableHead), tableCell, }); return true; } return false; }, }, }, }); } ================================================ FILE: apps/editor/src/wysiwyg/plugins/task.ts ================================================ import { Plugin } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { isPositionInBox } from '@/utils/dom'; import { findListItem } from '@/wysiwyg/helper/node'; export function task() { return new Plugin({ props: { handleDOMEvents: { mousedown: (view: EditorView, ev: Event) => { const { clientX, clientY } = ev as MouseEvent; const mousePos = view.posAtCoords({ left: clientX, top: clientY }); if (mousePos) { const { doc, tr } = view.state; const currentPos = doc.resolve(mousePos.pos); const listItem = findListItem(currentPos); const target = ev.target as HTMLElement; const style = getComputedStyle(target, ':before'); const { offsetX, offsetY } = ev as MouseEvent; if (!listItem || !isPositionInBox(style, offsetX, offsetY)) { return false; } ev.preventDefault(); const offset = currentPos.before(listItem.depth); const { attrs } = listItem.node; tr.setNodeMarkup(offset, null, { ...attrs, ...{ checked: !attrs.checked } }); view.dispatch!(tr); return true; } return false; }, }, }, }); } ================================================ FILE: apps/editor/src/wysiwyg/plugins/toolbarState.ts ================================================ import { Node, ResolvedPos, Schema } from 'prosemirror-model'; import { Plugin, Selection } from 'prosemirror-state'; import { includes } from '@/utils/common'; import { ToolbarStateMap, ToolbarStateKeys } from '@t/ui'; import { Emitter } from '@t/event'; type ListType = 'bulletList' | 'orderedList' | 'taskList'; const EXCEPT_TYPES = ['image', 'link', 'customBlock', 'frontMatter']; const MARK_TYPES = ['strong', 'strike', 'emph', 'code']; const LIST_TYPES: ListType[] = ['bulletList', 'orderedList', 'taskList']; function getToolbarStateType(node: Node, parentNode: Node) { const type = node.type.name; if (type === 'listItem') { return node.attrs.task ? 'taskList' : parentNode.type.name; } if (type.indexOf('table') !== -1) { return 'table'; } return type; } function setListNodeToolbarState(type: ToolbarStateKeys, nodeTypeState: ToolbarStateMap) { nodeTypeState[type] = { active: true }; LIST_TYPES.filter((listName) => listName !== type).forEach((listType) => { if (nodeTypeState[listType]) { delete nodeTypeState[listType]; } }); } function setMarkTypeStates( from: ResolvedPos, to: ResolvedPos, schema: Schema, toolbarState: ToolbarStateMap ) { MARK_TYPES.forEach((type) => { const mark = schema.marks[type]; const marksAtPos = from.marksAcross(to) || []; const foundMark = !!mark.isInSet(marksAtPos); if (foundMark) { toolbarState[type as ToolbarStateKeys] = { active: true }; } }); } function getToolbarState(selection: Selection, doc: Node, schema: Schema) { const { $from, $to, from, to } = selection; const toolbarState = { indent: { active: false, disabled: true }, outdent: { active: false, disabled: true }, } as ToolbarStateMap; doc.nodesBetween(from, to, (node, _, parentNode) => { const type = getToolbarStateType(node, parentNode!); if (includes(EXCEPT_TYPES, type)) { return; } if (includes(LIST_TYPES, type)) { setListNodeToolbarState(type as ToolbarStateKeys, toolbarState); toolbarState.indent.disabled = false; toolbarState.outdent.disabled = false; } else if (type === 'paragraph' || type === 'text') { setMarkTypeStates($from, $to, schema, toolbarState); } else { toolbarState[type as ToolbarStateKeys] = { active: true }; } }); return toolbarState; } export function toolbarStateHighlight(eventEmitter: Emitter) { return new Plugin({ view() { return { update(view) { const { selection, doc, schema } = view.state; eventEmitter.emit('changeToolbarState', { toolbarState: getToolbarState(selection, doc, schema), }); }, }; }, }); } ================================================ FILE: apps/editor/src/wysiwyg/specCreator.ts ================================================ import SpecManager from '@/spec/specManager'; import { Doc } from './nodes/doc'; import { Paragraph } from './nodes/paragraph'; import { Text } from './nodes/text'; import { Heading } from './nodes/heading'; import { CodeBlock } from './nodes/codeBlock'; import { BulletList } from './nodes/bulletList'; import { OrderedList } from './nodes/orderedList'; import { ListItem } from './nodes/listItem'; import { BlockQuote } from './nodes/blockQuote'; import { Table } from './nodes/table'; import { TableHead } from './nodes/tableHead'; import { TableBody } from './nodes/tableBody'; import { TableRow } from './nodes/tableRow'; import { TableHeadCell } from './nodes/tableHeadCell'; import { TableBodyCell } from './nodes/tableBodyCell'; import { Image } from './nodes/image'; import { ThematicBreak } from './nodes/thematicBreak'; import { Strong } from './marks/strong'; import { Emph } from './marks/emph'; import { Strike } from './marks/strike'; import { Link } from './marks/link'; import { Code } from './marks/code'; import { CustomBlock } from './nodes/customBlock'; import { FrontMatter } from './nodes/frontMatter'; import { LinkAttributes } from '@t/editor'; import { Widget } from '@/widget/widgetNode'; import { HTMLComment } from './nodes/htmlComment'; export function createSpecs(linkAttributes: LinkAttributes) { return new SpecManager([ new Doc(), new Paragraph(), new Text(), new Heading(), new CodeBlock(), new BulletList(), new OrderedList(), new ListItem(), new BlockQuote(), new Table(), new TableHead(), new TableBody(), new TableRow(), new TableHeadCell(), new TableBodyCell(), new Image(), new ThematicBreak(), new Strong(), new Emph(), new Strike(), new Link(linkAttributes), new Code(), new CustomBlock(), new FrontMatter(), new Widget(), new HTMLComment(), ]); } ================================================ FILE: apps/editor/src/wysiwyg/wwEditor.ts ================================================ import { EditorView, NodeView } from 'prosemirror-view'; import { ProsemirrorNode, Slice, Fragment, Mark, Schema } from 'prosemirror-model'; import isNumber from 'tui-code-snippet/type/isNumber'; import toArray from 'tui-code-snippet/collection/toArray'; import EditorBase from '@/base'; import { getWwCommands } from '@/commands/wwCommands'; import { createParagraph, createTextSelection } from '@/helper/manipulation'; import { emitImageBlobHook, pasteImageOnly } from '@/helper/image'; import { tableSelection } from './plugins/selection/tableSelection'; import { tableContextMenu } from './plugins/tableContextMenu'; import { task } from './plugins/task'; import { toolbarStateHighlight } from './plugins/toolbarState'; import { CustomBlockView } from './nodeview/customBlockView'; import { ImageView } from './nodeview/imageView'; import { CodeBlockView } from './nodeview/codeBlockView'; import { changePastedHTML, changePastedSlice } from './clipboard/paste'; import { pasteToTable } from './clipboard/pasteToTable'; import { createSpecs } from './specCreator'; import { Emitter } from '@t/event'; import { ToDOMAdaptor } from '@t/convertor'; import { HTMLSchemaMap, LinkAttributes, WidgetStyle } from '@t/editor'; import { NodeViewPropMap, PluginProp } from '@t/plugin'; import { createNodesWithWidget } from '@/widget/rules'; import { widgetNodeView } from '@/widget/widgetNode'; import { cls, removeProseMirrorHackNodes } from '@/utils/dom'; import { includes } from '@/utils/common'; import { isInTableNode } from '@/wysiwyg/helper/node'; interface WindowWithClipboard extends Window { clipboardData?: DataTransfer | null; } interface WysiwygOptions { toDOMAdaptor: ToDOMAdaptor; useCommandShortcut?: boolean; htmlSchemaMap?: HTMLSchemaMap; linkAttributes?: LinkAttributes | null; wwPlugins?: PluginProp[]; wwNodeViews?: NodeViewPropMap; } type PluginNodeVeiwFn = (node: ProsemirrorNode, view: EditorView, getPos: () => number) => NodeView; interface PluginNodeViews { [k: string]: PluginNodeVeiwFn; } const CONTENTS_CLASS_NAME = cls('contents'); export default class WysiwygEditor extends EditorBase { private toDOMAdaptor: ToDOMAdaptor; private linkAttributes: LinkAttributes; private pluginNodeViews: NodeViewPropMap; constructor(eventEmitter: Emitter, options: WysiwygOptions) { super(eventEmitter); const { toDOMAdaptor, htmlSchemaMap = {} as HTMLSchemaMap, linkAttributes = {}, useCommandShortcut = true, wwPlugins = [], wwNodeViews = {}, } = options; this.editorType = 'wysiwyg'; this.el.classList.add('ww-mode'); this.toDOMAdaptor = toDOMAdaptor; this.linkAttributes = linkAttributes!; this.extraPlugins = wwPlugins; this.pluginNodeViews = wwNodeViews; this.specs = this.createSpecs(); this.schema = this.createSchema(htmlSchemaMap); this.context = this.createContext(); this.keymaps = this.createKeymaps(useCommandShortcut); this.view = this.createView(); this.commands = this.createCommands(); this.specs.setContext({ ...this.context, view: this.view }); this.initEvent(); } createSpecs() { return createSpecs(this.linkAttributes); } createContext() { return { schema: this.schema, eventEmitter: this.eventEmitter, }; } createSchema(htmlSchemaMap?: HTMLSchemaMap) { return new Schema({ nodes: { ...this.specs.nodes, ...htmlSchemaMap!.nodes }, marks: { ...this.specs.marks, ...htmlSchemaMap!.marks }, }); } createPlugins() { return [ tableSelection(), tableContextMenu(this.eventEmitter), task(), toolbarStateHighlight(this.eventEmitter), ...this.createPluginProps(), ].concat(this.defaultPlugins); } createPluginNodeViews() { const { eventEmitter, pluginNodeViews } = this; const pluginNodeViewMap: PluginNodeViews = {}; if (pluginNodeViews) { Object.keys(pluginNodeViews).forEach((key) => { pluginNodeViewMap[key] = (node, view, getPos) => pluginNodeViews[key](node, view, getPos, eventEmitter); }); } return pluginNodeViewMap; } createView() { const { toDOMAdaptor, eventEmitter } = this; return new EditorView(this.el, { state: this.createState(), attributes: { class: CONTENTS_CLASS_NAME, }, nodeViews: { customBlock(node, view, getPos) { return new CustomBlockView(node, view, getPos, toDOMAdaptor); }, image(node, view, getPos) { return new ImageView(node, view, getPos, eventEmitter); }, codeBlock(node, view, getPos) { return new CodeBlockView(node, view, getPos, eventEmitter); }, widget: widgetNodeView, ...this.createPluginNodeViews(), }, dispatchTransaction: (tr) => { const { state } = this.view.state.applyTransaction(tr); this.view.updateState(state); this.emitChangeEvent(tr.scrollIntoView()); this.eventEmitter.emit('setFocusedNode', state.selection.$from.node(1)); }, transformPastedHTML: changePastedHTML, transformPasted: (slice: Slice) => changePastedSlice(slice, this.schema, isInTableNode(this.view.state.selection.$from)), handlePaste: (view: EditorView, _: ClipboardEvent, slice: Slice) => pasteToTable(view, slice), handleKeyDown: (_, ev) => { this.eventEmitter.emit('keydown', this.editorType, ev); return false; }, handleDOMEvents: { paste: (_, ev) => { const clipboardData = (ev as ClipboardEvent).clipboardData || (window as WindowWithClipboard).clipboardData; const items = clipboardData?.items; if (items) { const containRtfItem = toArray(items).some( (item) => item.kind === 'string' && item.type === 'text/rtf' ); // if it contains rtf, it's most likely copy paste from office -> no image if (!containRtfItem) { const imageBlob = pasteImageOnly(items); if (imageBlob) { ev.preventDefault(); emitImageBlobHook(this.eventEmitter, imageBlob, ev.type); } } } return false; }, keyup: (_, ev: KeyboardEvent) => { this.eventEmitter.emit('keyup', this.editorType, ev); return false; }, scroll: () => { this.eventEmitter.emit('scroll', 'editor'); return true; }, }, }); } createCommands() { return this.specs.commands(this.view, getWwCommands()); } getHTML() { return removeProseMirrorHackNodes(this.view.dom.innerHTML); } getModel() { return this.view.state.doc; } getSelection(): [number, number] { const { from, to } = this.view.state.selection; return [from, to]; } getSchema() { return this.view.state.schema; } replaceSelection(text: string, start?: number, end?: number) { const { schema, tr } = this.view.state; const lineTexts = text.split('\n'); const paras = lineTexts.map((lineText) => createParagraph(schema, createNodesWithWidget(lineText, schema)) ); const slice = new Slice(Fragment.from(paras), 1, 1); const newTr = isNumber(start) && isNumber(end) ? tr.replaceRange(start, end, slice) : tr.replaceSelection(slice); this.view.dispatch(newTr); this.focus(); } deleteSelection(start?: number, end?: number) { const { tr } = this.view.state; const newTr = isNumber(start) && isNumber(end) ? tr.deleteRange(start, end) : tr.deleteSelection(); this.view.dispatch(newTr.scrollIntoView()); } getSelectedText(start?: number, end?: number) { const { doc, selection } = this.view.state; let { from, to } = selection; if (isNumber(start) && isNumber(end)) { from = start; to = end; } return doc.textBetween(from, to, '\n'); } setModel(newDoc: ProsemirrorNode | [], cursorToEnd = false) { const { tr, doc } = this.view.state; this.view.dispatch(tr.replaceWith(0, doc.content.size, newDoc)); if (cursorToEnd) { this.moveCursorToEnd(true); } } setSelection(start: number, end = start) { const { tr } = this.view.state; const selection = createTextSelection(tr, start, end); this.view.dispatch(tr.setSelection(selection).scrollIntoView()); } addWidget(node: Node, style: WidgetStyle, pos?: number) { const { dispatch, state } = this.view; dispatch(state.tr.setMeta('widget', { pos: pos ?? state.selection.to, node, style })); } replaceWithWidget(start: number, end: number, text: string) { const { tr, schema } = this.view.state; const nodes = createNodesWithWidget(text, schema); this.view.dispatch(tr.replaceWith(start, end, nodes)); } getRangeInfoOfNode(pos?: number) { const { doc, selection } = this.view.state; const $pos = pos ? doc.resolve(pos) : selection.$from; const marks = $pos.marks(); const node = $pos.node(); let start = $pos.start(); let end = $pos.end(); let type = node.type.name; if (marks.length || type === 'paragraph') { const mark = marks[marks.length - 1]; const maybeHasMark = (nodeMarks: Mark[]) => nodeMarks.length ? includes(nodeMarks, mark) : true; type = mark ? mark.type.name : 'text'; node.forEach((child, offset) => { const { isText, nodeSize, marks: nodeMarks } = child; const startOffset = $pos.pos - start; if ( isText && offset <= startOffset && offset + nodeSize >= startOffset && maybeHasMark(nodeMarks as Mark[]) ) { start = start + offset; end = start + nodeSize; } }); } return { range: [start, end] as [number, number], type }; } } ================================================ FILE: apps/editor/tsBannerGenerator.js ================================================ /*eslint-disable*/ const fs = require('fs'); const path = require('path'); const pkg = require('./package.json'); const rootPkg = require('../../package.json'); const tsVersion = /[0-9.]+/.exec(rootPkg.devDependencies.typescript)[0]; const declareFilePath = path.join(__dirname, './types/index.d.ts'); const TS_BANNER = [ '// Type definitions for TOAST UI Editor v' + pkg.version, '// TypeScript Version: ' + tsVersion, ].join('\n'); let declareRows = []; fs.readFile(declareFilePath, 'utf8', (error, data) => { if (error) { throw error; } declareRows = data.toString().split('\n'); declareRows.splice(0, 2, TS_BANNER); fs.writeFile(declareFilePath, declareRows.join('\n'), 'utf8', (error, data) => { if (error) { throw error; } console.log('Completed Write Banner for Typescript!'); }); }); ================================================ FILE: apps/editor/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "types/**/*", "../../types/**/*"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@t/*": ["types/*"] }, "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: apps/editor/tuidoc.config.json ================================================ { "header": { "logo": { "src": "https://uicdn.toast.com/toastui/img/tui-editor-bi-white.png", "linkUrl": "/" }, "title": { "text": "github", "linkUrl": "https://github.com/nhn/tui.editor" }, "version": true }, "footer": [ { "title": "NHN Cloud", "linkUrl": "https://github.com/nhn" }, { "title": "FE Development Lab", "linkUrl": "https://ui.toast.com" } ], "main": { "filePath": "README.md" }, "api": { "filePath": [ "tmpdoc/editorCore.js", "tmpdoc/editor.js", "tmpdoc/viewer.js" ], "permalink": false }, "examples": { "filePath": "examples", "titles": { "example01-editor-basic": "1. Editor", "example02-editor-with-horizontal-preview": "2. Editor With Horizontal Preview", "example03-editor-with-wysiwyg-mode": "3. Editor With WYSIWYG Mode", "example04-viewer": "4. Viewer", "example05-viewer-using-editor-factory": "5. Viewer Using Editor's Factory", "example06-dark-theme": "6. Editor with Dark Theme", "example07-editor-with-chart-plugin": "7. Editor with Chart Plugin", "example08-editor-with-code-syntax-highlight-plugin": "8. Editor with Code Syntax Highlight Plugin", "example09-editor-with-color-syntax-plugin": "9. Editor with Color Syntax Plugin", "example10-editor-with-table-merged-cell-plugin": "10. Editor with Table Merged Cell Plugin", "example11-editor-with-uml-plugin": "11. Editor with UML Plugin", "example12-editor-with-all-plugins": "12. Editor with All Plugins", "example13-creating-plugin": "13. Creating Plugin", "example14-using-command": "14. Using Command", "example15-customizing-toolbar-buttons": "15. Customizing Toolbar Buttons", "example16-i18n": "16. Internationalization (i18n)", "example17-placeholder": "17. Placeholder" }, "globalErrorLogVariable": true }, "pathPrefix": "tui.editor" } ================================================ FILE: apps/editor/types/convertor.d.ts ================================================ import { NodeType, MarkType, Schema, ProsemirrorNode, Mark } from 'prosemirror-model'; import { MdNode, MdNodeType, RendererOptions, HTMLToken, MdPos } from './toastmark'; import { WwNodeType, WwMarkType } from './wysiwyg'; export type Attrs = { [name: string]: any } | null; export interface StackItem { type: NodeType; attrs: Attrs | null; content: ProsemirrorNode[]; } export interface ToWwConvertorState { schema: Schema; top(): StackItem; push(node: ProsemirrorNode): void; addText(text: string): void; openMark(mark: Mark): void; closeMark(mark: MarkType): void; addNode(type: NodeType, attrs?: Attrs, content?: ProsemirrorNode[]): ProsemirrorNode | null; openNode(type: NodeType, attrs?: Attrs): void; closeNode(): ProsemirrorNode | null; convertNode(mdNode: MdNode, infoForPosSync: InfoForPosSync): ProsemirrorNode | null; convertByDOMParser(root: HTMLElement): void; } type ToWwConvertor = ( state: ToWwConvertorState, node: MdNode, context: { entering: boolean; skipChildren: () => void; leaf: boolean; options: Omit; getChildrenText: (mdNode: MdNode) => string; origin?: () => HTMLToken | HTMLToken[] | null; }, customAttrs?: { htmlAttrs?: Record; classNames?: string[] } ) => void; export type ToWwConvertorMap = Partial>; export type FirstDelimFn = (index: number) => string; export interface ToMdConvertorState { stopNewline: boolean; inTable: boolean; getDelim(): string; setDelim(delim: string): void; flushClose(size?: number): void; wrapBlock(delim: string, firstDelim: string | null, node: ProsemirrorNode, fn: () => void): void; ensureNewLine(): void; write(content?: string): void; closeBlock(node: ProsemirrorNode): void; text(text: string, escaped?: boolean): void; convertBlock(node: ProsemirrorNode, parent: ProsemirrorNode, index: number): void; convertInline(parent: ProsemirrorNode): void; convertList(node: ProsemirrorNode, delim: string, firstDelimFn: FirstDelimFn): void; convertTableCell(node: ProsemirrorNode): void; convertNode(parent: ProsemirrorNode, infoForPosSync?: InfoForPosSync): string; } export interface ToDOMAdaptor { getToDOMNode(type: string): ((node: ProsemirrorNode | Mark) => Node) | null; } type HTMLToWwConvertor = (state: ToWwConvertorState, node: MdNode, openTagName: string) => void; export type HTMLToWwConvertorMap = Partial>; export interface FlattenHTMLToWwConvertorMap { [k: string]: HTMLToWwConvertor; } export interface NodeInfo { node: ProsemirrorNode; parent?: ProsemirrorNode; index?: number; } export interface MarkInfo { node: Mark; parent?: ProsemirrorNode; index?: number; } interface ToMdConvertorReturnValues { delim?: string | string[]; rawHTML?: string | string[] | null; text?: string; attrs?: Attrs; } type ToMdNodeTypeWriter = ( state: ToMdConvertorState, nodeInfo: NodeInfo, params: ToMdConvertorReturnValues ) => void; export type ToMdNodeTypeWriterMap = Partial>; interface ToMdMarkTypeOption { mixable?: boolean; removedEnclosingWhitespace?: boolean; escape?: boolean; } export type ToMdMarkTypeOptions = Partial>; type ToMdNodeTypeConvertor = (state: ToMdConvertorState, nodeInfo: NodeInfo) => void; export type ToMdNodeTypeConvertorMap = Partial>; type ToMdMarkTypeConvertor = ( nodeInfo?: MarkInfo, entering?: boolean ) => ToMdConvertorReturnValues & ToMdMarkTypeOption; export type ToMdMarkTypeConvertorMap = Partial>; interface ToMdConvertorContext { origin?: () => ReturnType; entering?: boolean; inTable?: boolean; } type ToMdConvertor = ( nodeInfo: NodeInfo | MarkInfo, context: ToMdConvertorContext ) => ToMdConvertorReturnValues; export type ToMdConvertorMap = Partial>; export interface ToMdConvertors { nodeTypeConvertors: ToMdNodeTypeConvertorMap; markTypeConvertors: ToMdMarkTypeConvertorMap; } export interface InfoForPosSync { node: MdNode | ProsemirrorNode | null; setMappedPos: (pos: MdPos | number) => void; } ================================================ FILE: apps/editor/types/editor.d.ts ================================================ import { Schema, NodeSpec, MarkSpec, Fragment } from 'prosemirror-model'; import { EditorView, Decoration, DecorationSet } from 'prosemirror-view'; import { EditorState, Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state'; import { undoInputRule, InputRule, inputRules } from 'prosemirror-inputrules'; import { keymap } from 'prosemirror-keymap'; import { Editor } from '@t/index'; import { HTMLConvertor, MdPos, Sourcepos, Context as MdContext, HTMLToken, HTMLConvertorMap, } from './toastmark'; import { Emitter, Handler } from './event'; import { Context, EditorAllCommandMap, EditorCommandFn, SpecManager } from './spec'; import { ToMdConvertorMap } from './convertor'; import { ToolbarItemOptions, IndexList } from './ui'; import { CommandFn, PluginInfo } from './plugin'; import { HTMLMdNode } from './markdown'; export type PreviewStyle = 'tab' | 'vertical'; export type EditorType = 'markdown' | 'wysiwyg'; export type WidgetStyle = 'top' | 'bottom'; export interface WidgetRule { rule: RegExp; toDOM: (text: string) => HTMLElement; } export type WidgetRuleMap = Record; export interface EventMap { load?: (param: Editor) => void; change?: (editorType: EditorType) => void; caretChange?: (editorType: EditorType) => void; focus?: (editorType: EditorType) => void; blur?: (editorType: EditorType) => void; keydown?: (editorType: EditorType, ev: KeyboardEvent) => void; keyup?: (editorType: EditorType, ev: KeyboardEvent) => void; beforePreviewRender?: (html: string) => string; beforeConvertWysiwygToMarkdown?: (markdownText: string) => string; } type HookCallback = (url: string, text?: string) => void; export type HookMap = { addImageBlobHook?: (blob: Blob | File, callback: HookCallback) => void; }; export type AutolinkParser = ( content: string ) => { url: string; text: string; range: [number, number]; }[]; export type ExtendedAutolinks = boolean | AutolinkParser; export type LinkAttributeNames = 'rel' | 'target' | 'hreflang' | 'type'; // @TODO change option and type name from singular to plural export type LinkAttributes = Partial>; export type Sanitizer = (content: string) => string; export type HTMLMdNodeConvertor = ( node: HTMLMdNode, context: MdContext, convertors?: HTMLConvertorMap ) => HTMLToken | HTMLToken[] | null; export type HTMLMdNodeConvertorMap = Record; export type CustomHTMLRenderer = Partial>; export interface ViewerOptions { el: HTMLElement; initialValue?: string; events?: EventMap; plugins?: EditorPlugin[]; extendedAutolinks?: ExtendedAutolinks; linkAttributes?: LinkAttributes; customHTMLRenderer?: CustomHTMLRenderer; referenceDefinition?: boolean; customHTMLSanitizer?: Sanitizer; frontMatter?: boolean; usageStatistics?: boolean; theme?: string; } export class Viewer { static isViewer: boolean; constructor(options: ViewerOptions); setMarkdown(markdown: string): void; on(type: string, handler: Handler): void; off(type: string): void; destroy(): void; isViewer(): boolean; isMarkdownMode(): boolean; isWysiwygMode(): boolean; addHook(type: string, handler: Handler): void; } export interface I18n { setCode(code?: string): void; setLanguage(codes: string | string[], data: Record): void; get(key: string, code?: string): string; } export interface PluginContext { eventEmitter: Emitter; usageStatistics?: boolean; i18n: I18n; instance: Editor | Viewer; pmState: { Plugin: typeof Plugin; PluginKey: typeof PluginKey; Selection: typeof Selection; TextSelection: typeof TextSelection; }; pmView: { Decoration: typeof Decoration; DecorationSet: typeof DecorationSet }; pmModel: { Fragment: typeof Fragment }; pmRules: { inputRules: typeof inputRules; InputRule: typeof InputRule; undoInputRule: typeof undoInputRule; }; pmKeymap: { keymap: typeof keymap; }; } export type PluginFn = (context: PluginContext, options?: any) => PluginInfo | null; export type EditorPlugin = PluginFn | [PluginFn, any]; type ContextInfo = { eventEmitter: Emitter; usageStatistics: boolean; instance: Editor | Viewer; }; export type EditorPluginInfo = ContextInfo & { plugin: EditorPlugin; }; export type EditorPluginsInfo = ContextInfo & { plugins: EditorPlugin[]; }; export interface EditorOptions { el: HTMLElement; height?: string; minHeight?: string; initialValue?: string; previewStyle?: PreviewStyle; initialEditType?: EditorType; events?: EventMap; hooks?: HookMap; language?: string; useCommandShortcut?: boolean; usageStatistics?: boolean; toolbarItems?: (string | ToolbarItemOptions)[][]; hideModeSwitch?: boolean; plugins?: EditorPlugin[]; extendedAutolinks?: ExtendedAutolinks; placeholder?: string; linkAttributes?: LinkAttributes; customHTMLRenderer?: CustomHTMLRenderer; customMarkdownRenderer?: ToMdConvertorMap; referenceDefinition?: boolean; customHTMLSanitizer?: Sanitizer; previewHighlight?: boolean; frontMatter?: boolean; widgetRules?: WidgetRule[]; theme?: string; autofocus?: boolean; viewer?: boolean; } interface Slots { mdEditor: HTMLElement; mdPreview: HTMLElement; wwEditor: HTMLElement; } export class EditorCore { constructor(options: EditorOptions); public eventEmitter: Emitter; public static factory(options: EditorOptions): EditorCore | Viewer; public static setLanguage(code: string, data: Record): void; changePreviewStyle(style: PreviewStyle): void; exec(name: string, payload?: Record): void; addCommand(type: EditorType, name: string, command: CommandFn): void; on(type: string, handler: Handler): void; off(type: string): void; addHook(type: string, handler: Handler): void; removeHook(type: string): void; focus(): void; blur(): void; moveCursorToEnd(focus?: boolean): void; moveCursorToStart(focus?: boolean): void; setMarkdown(markdown: string, cursorToEnd?: boolean): void; setHTML(html: string, cursorToEnd?: boolean): void; getMarkdown(): string; getHTML(): string; insertText(text: string): void; setSelection(start: EditorPos, end?: EditorPos): void; replaceSelection(text: string, start?: EditorPos, end?: EditorPos): void; deleteSelection(start?: EditorPos, end?: EditorPos): void; getSelectedText(start?: EditorPos, end?: EditorPos): string; getRangeInfoOfNode(pos?: EditorPos): NodeRangeInfo; addWidget(node: Node, style: WidgetStyle, pos?: EditorPos): void; replaceWithWidget(start: EditorPos, end: EditorPos, text: string): void; setHeight(height: string): void; getHeight(): string; setMinHeight(minHeight: string): void; getMinHeight(): string; isMarkdownMode(): boolean; isWysiwygMode(): boolean; isViewer(): boolean; getCurrentPreviewStyle(): PreviewStyle; changeMode(mode: EditorType, isWithoutFocus?: boolean): void; destroy(): void; hide(): void; show(): void; setScrollTop(value: number): void; getScrollTop(): number; reset(): void; getSelection(): SelectionPos; setPlaceholder(placeholder: string): void; getEditorElements(): Slots; convertPosToMatchEditorMode(start: EditorPos, end?: EditorPos, mode?: EditorType): EditorPos[]; } export class Editor extends EditorCore { insertToolbarItem({ groupIndex, itemIndex }: IndexList, item: string | ToolbarItemOptions): void; removeToolbarItem(itemName: string): void; } export type SelectionPos = Sourcepos | [from: number, to: number]; export type EditorPos = MdPos | number; export interface NodeRangeInfo { range: SelectionPos; type: string; } export interface Base { el: HTMLElement; editorType: EditorType; eventEmitter: Emitter; context: Context; schema: Schema; keymaps: Plugin[]; view: EditorView; commands: EditorAllCommandMap; specs: SpecManager; placeholder: { text: string }; createSpecs(): SpecManager; createContext(): Context; createState(): EditorState; createView(): EditorView; createSchema(): Schema; createKeymaps(useCommandShortcut: boolean): Plugin[]; createCommands(): Record>>; focus(): void; blur(): void; destroy(): void; moveCursorToStart(focus: boolean): void; moveCursorToEnd(focus: boolean): void; setScrollTop(top: number): void; getScrollTop(): number; setPlaceholder(text: string): void; setHeight(height: number): void; setMinHeight(minHeight: number): void; getElement(): HTMLElement; setSelection(start: EditorPos, end?: EditorPos): void; replaceWithWidget(start: EditorPos, end: EditorPos, text: string): void; addWidget(node: Node, style: WidgetStyle, pos?: EditorPos): void; replaceSelection(text: string, start?: EditorPos, end?: EditorPos): void; deleteSelection(start?: EditorPos, end?: EditorPos): void; getSelectedText(start?: EditorPos, end?: EditorPos): string; getSelection(): SelectionPos; getRangeInfoOfNode(pos?: EditorPos): NodeRangeInfo; } export type SchemaMap = Record; export interface HTMLSchemaMap { nodes: SchemaMap; marks: SchemaMap; } ================================================ FILE: apps/editor/types/event.d.ts ================================================ import { Mapable } from './map'; export interface Handler { (...args: any[]): any; namespace?: string; } export interface Emitter { listen(type: string, handler: Handler): void; emit(type: string, ...args: any[]): any[]; emitReduce(type: string, source: any, ...args: any[]): any; addEventType(type: string): void; removeEventHandler(type: string, handler?: Handler): void; getEvents(): Mapable; holdEventInvoke(fn: Function): void; } export interface EmitterConstructor { new (): Emitter; } export type EventTypes = | 'afterPreviewRender' | 'updatePreview' | 'changeMode' | 'needChangeMode' | 'command' | 'changePreviewStyle' | 'changePreviewTabPreview' | 'changePreviewTabWrite' | 'scroll' | 'contextmenu' | 'show' | 'hide' | 'changeLanguage' | 'changeToolbarState' | 'toggleScrollSync' | 'mixinTableOffsetMapPrototype' | 'setFocusedNode' | 'removePopupWidget' | 'query' // provide event for user | 'openPopup' | 'closePopup' | 'addImageBlobHook' | 'beforePreviewRender' | 'beforeConvertWysiwygToMarkdown' | 'load' | 'loadUI' | 'change' | 'caretChange' | 'destroy' | 'focus' | 'blur' | 'keydown' | 'keyup'; ================================================ FILE: apps/editor/types/index.d.ts ================================================ // Type definitions for TOAST UI Editor v3.2.2 // TypeScript Version: 4.2.3 import { EditorCore, Editor, Viewer, EditorOptions, ViewerOptions, ExtendedAutolinks, LinkAttributes, Sanitizer, EditorType, PreviewStyle, EventMap, HookMap, WidgetStyle, WidgetRuleMap, WidgetRule, PluginContext, I18n, CustomHTMLRenderer, HTMLMdNodeConvertor, HTMLMdNodeConvertorMap, } from './editor'; import './toastui-editor-viewer'; export { MdNode, MdNodeType, ListMdNode, ListItemMdNode, TableMdNode, TableCellMdNode, CodeBlockMdNode, LinkMdNode, ListData, HeadingMdNode, CodeMdNode, HTMLConvertorMap, } from './toastmark'; export { ToMdConvertorMap } from './convertor'; export { Emitter, Handler } from './event'; export { EditorOptions, ViewerOptions, ExtendedAutolinks, LinkAttributes, Sanitizer, EditorType, PreviewStyle, EventMap, HookMap, WidgetStyle, WidgetRuleMap, WidgetRule, PluginContext, I18n, CustomHTMLRenderer, HTMLMdNodeConvertor, HTMLMdNodeConvertorMap, }; export { Dispatch } from './spec'; export { PluginInfo, PluginNodeViews, CommandFn, PluginCommandMap } from './plugin'; export { MdLikeNode, HTMLMdNode } from './markdown'; export { Editor, EditorCore, Viewer }; export default Editor; export declare namespace toastui { export { Editor }; } ================================================ FILE: apps/editor/types/map.d.ts ================================================ export interface Mapable { clear(): void; delete(key: K): boolean; forEach(callbackfn: (value: V, key: K, map: Mapable) => void, thisArg?: any): void; get(key: K): V | undefined; has(key: K): boolean; set(key: K, value: V): this; } ================================================ FILE: apps/editor/types/markdown.d.ts ================================================ import { MdNode, TableMdNode, Sourcepos, NodeWalker } from './toastmark'; export interface TableRowMdNode extends MdNode { parent: TableBodyMdNode | TableHeadMdNode; } export interface TableBodyMdNode extends MdNode { parent: TableMdNode; } export interface TableHeadMdNode extends MdNode { parent: TableMdNode; firstChild: TableRowMdNode; lastChild: TableRowMdNode; next: TableBodyMdNode; } export interface MdLikeNode { type: string; literal: string | null; wysiwygNode?: boolean; level?: number; destination?: string; title?: string; info?: string; cellType?: 'head' | 'body'; align?: 'left' | 'center' | 'right'; listData?: { type?: 'bullet' | 'ordered'; start?: number; task?: boolean; checked?: boolean; }; attrs?: Record; childrenHTML?: string; } export interface HTMLMdNode { type: string; id: number; parent: MdNode | null; prev: MdNode | null; next: MdNode | null; sourcepos?: Sourcepos; firstChild: MdNode | null; lastChild: MdNode | null; literal: string | null; isContainer(): boolean; unlink(): void; replaceWith(node: MdNode): void; insertAfter(node: MdNode): void; insertBefore(node: MdNode): void; appendChild(child: MdNode): void; prependChild(child: MdNode): void; walker(): NodeWalker; attrs?: Record; childrenHTML?: string; } ================================================ FILE: apps/editor/types/plugin.d.ts ================================================ import { Plugin, EditorState } from 'prosemirror-state'; import { EditorView, NodeView } from 'prosemirror-view'; import { Node } from 'prosemirror-model'; import { CustomParserMap } from './toastmark'; import { CustomHTMLRenderer } from './editor'; import { Emitter } from './event'; import { ToMdConvertorMap } from './convertor'; import { Dispatch, Payload, DefaultPayload } from './spec'; import { ToolbarItemOptions } from './ui'; export type PluginProp = (eventEmitter?: Emitter) => Plugin; export type PluginNodeViews = ( node: Node, view: EditorView, getPos: () => number, eventEmitter: Emitter ) => NodeView; type NodeViewPropMap = Record; export type CommandFn = ( payload: Payload, state: EditorState, dispatch: Dispatch, view: EditorView ) => boolean; export type PluginCommandMap = Record; interface PluginToolbarItem { groupIndex: number; itemIndex: number; item: string | ToolbarItemOptions; } export interface PluginInfo { toHTMLRenderers?: CustomHTMLRenderer; toMarkdownRenderers?: ToMdConvertorMap; markdownPlugins?: PluginProp[]; wysiwygPlugins?: PluginProp[]; wysiwygNodeViews?: NodeViewPropMap; markdownCommands?: PluginCommandMap; wysiwygCommands?: PluginCommandMap; toolbarItems?: PluginToolbarItem[]; markdownParsers?: CustomParserMap; } export interface PluginInfoResult { toHTMLRenderers: CustomHTMLRenderer; toMarkdownRenderers: ToMdConvertorMap; mdPlugins: PluginProp[]; wwPlugins: PluginProp[]; wwNodeViews: NodeViewPropMap; mdCommands: PluginCommandMap; wwCommands: PluginCommandMap; toolbarItems: PluginToolbarItem[]; markdownParsers: CustomParserMap; } ================================================ FILE: apps/editor/types/prosemirror-commands.d.ts ================================================ import { EditorState, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { Schema } from 'prosemirror-model'; import 'prosemirror-commands'; declare module 'prosemirror-commands' { export interface Command { (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView): boolean; } export interface Keymap { [key: string]: Command; } } ================================================ FILE: apps/editor/types/prosemirror-model.d.ts ================================================ import * as Model from 'prosemirror-model'; declare module 'prosemirror-model' { export interface Fragment { textBetween(from: number, to: number, blockSeparator?: string, leafText?: string): string; findIndex(pos: number, round?: number): { index: number; offset: number }; findDiffEnd(other: ProsemirrorNode | Fragment): { a: number; b: number } | null | undefined; } export type ProsemirrorNode = Model.Node; export interface NodeType { compatibleContent(node: NodeType): boolean; } } ================================================ FILE: apps/editor/types/prosemirror-transform.d.ts ================================================ import { Slice, Node, Mark, NodeType } from 'prosemirror-model'; import 'prosemirror-transform'; declare module 'prosemirror-transform' { export interface Step { slice: Slice; from: number; to: number; } export interface Transform { setNodeMarkup( pos: number, type: Node | null, attrs?: { [key: string]: any }, marks?: Mark[] ): Transform; split( pos: number, depth?: number | undefined, typesAfter?: | ({ type: NodeType; attrs?: | { [key: string]: any; } | null | undefined; } | null)[] | undefined ): Transform; } } ================================================ FILE: apps/editor/types/spec.d.ts ================================================ import { Schema } from 'prosemirror-model'; import { Transaction, Plugin } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { Command } from 'prosemirror-commands'; import { ToastMark } from './toastmark'; import { Emitter } from './event'; export interface Context { schema: Schema; eventEmitter: Emitter; } export interface MdContext extends Context { toastMark: ToastMark; } export interface SpecContext extends Context { view: EditorView; } export interface MdSpecContext extends SpecContext { toastMark: ToastMark; } export type DefaultPayload = Record; export type Payload = T extends infer P ? P : any; export type Dispatch = (tr: Transaction) => void; export type EditorCommand = (payload?: Payload) => Command; export type EditorCommandMap = Record>; export type EditorCommandFn = (payload?: Payload) => boolean | void; export type EditorAllCommandMap = Record>; export interface SpecManager { commands( view: EditorView, addedCommands?: Record ): EditorAllCommandMap; keymaps(useCommandShortcut: boolean): Plugin[]; setContext(context: SpecContext): void; } ================================================ FILE: apps/editor/types/toastmark.d.ts ================================================ // @TODO replace these definition for Definitely Type export type BlockNodeType = | 'document' | 'list' | 'blockQuote' | 'item' | 'heading' | 'thematicBreak' | 'paragraph' | 'codeBlock' | 'htmlBlock' | 'table' | 'tableHead' | 'tableBody' | 'tableRow' | 'tableCell' | 'tableDelimRow' | 'tableDelimCell' | 'refDef' | 'customBlock' | 'frontMatter'; export type InlineNodeType = | 'code' | 'text' | 'emph' | 'strong' | 'strike' | 'link' | 'image' | 'htmlInline' | 'linebreak' | 'softbreak' | 'customInline'; export type MdNodeType = BlockNodeType | InlineNodeType; export type Pos = [number, number]; export type MdPos = Pos; export type Sourcepos = [Pos, Pos]; export interface NodeWalker { current: MdNode | null; root: MdNode; entering: boolean; next(): { entering: boolean; node: MdNode } | null; resumeAt(node: MdNode, entering: boolean): void; } export interface MdNode { type: MdNodeType; id: number; parent: MdNode | null; prev: MdNode | null; next: MdNode | null; sourcepos?: Sourcepos; firstChild: MdNode | null; lastChild: MdNode | null; literal: string | null; isContainer(): boolean; unlink(): void; replaceWith(node: MdNode): void; insertAfter(node: MdNode): void; insertBefore(node: MdNode): void; appendChild(child: MdNode): void; prependChild(child: MdNode): void; walker(): NodeWalker; } export interface BlockMdNode extends MdNode { type: BlockNodeType; // temporal data (for parsing) open: boolean; lineOffsets: number[] | null; stringContent: string | null; lastLineBlank: boolean; lastLineChecked: boolean; } export interface ListData { type: 'ordered' | 'bullet'; tight: boolean; start: number; bulletChar: string; delimiter: string; markerOffset: number; padding: number; task: boolean; checked: boolean; } export interface ListMdNode extends BlockMdNode { listData: ListData | null; } export interface ListItemMdNode extends BlockMdNode { parent: MdNode; listData: ListData; } export interface HeadingMdNode extends BlockMdNode { level: number; headingType: 'atx' | 'setext'; } export interface CodeBlockMdNode extends BlockMdNode { fenceOffset: number; fenceLength: number; fenceChar: string | null; info: string | null; infoPadding: number; } export interface TableColumn { align: 'left' | 'center' | 'right' | null; } export interface TableMdNode extends BlockMdNode { columns: TableColumn[]; } export interface TableCellMdNode extends BlockMdNode { startIdx: number; endIdx: number; paddingLeft: number; paddingRight: number; ignored: boolean; attrs?: Record; } export interface RefDefMdNode extends BlockMdNode { title: string; dest: string; label: string; } export interface CustomBlockMdNode extends BlockMdNode { syntaxLength: number; offset: number; info: string; } export interface HtmlBlockMdNode extends BlockMdNode { htmlBlockType: number; } export interface LinkMdNode extends MdNode { destination: string | null; title: string | null; extendedAutolink: boolean; lastChild: MdNode; } export interface CodeMdNode extends MdNode { tickCount: number; } export interface CustomInlineMdNode extends MdNode { info: string; } export type AutolinkParser = ( content: string ) => { url: string; text: string; range: [number, number]; }[]; export type CustomParser = ( node: MdNode, context: { entering: boolean; options: ParserOptions } ) => void; export type CustomParserMap = Partial>; type RefDefState = { id: number; destination: string; title: string; unlinked: boolean; sourcepos: Sourcepos; }; export type RefMap = { [k: string]: RefDefState; }; export type RefLinkCandidateMap = { [k: number]: { node: BlockMdNode; refLabel: string; }; }; export type RefDefCandidateMap = { [k: number]: RefDefMdNode; }; export interface ParserOptions { smart: boolean; tagFilter: boolean; extendedAutolinks: boolean | AutolinkParser; disallowedHtmlBlockTags: string[]; referenceDefinition: boolean; disallowDeepHeading: boolean; frontMatter: boolean; customParser: CustomParserMap | null; } export class Parser { constructor(options?: Partial); advanceOffset(count: number, columns?: boolean): void; advanceNextNonspace(): void; findNextNonspace(): void; addLine(): void; addChild(tag: BlockNodeType, offset: number): BlockMdNode; closeUnmatchedBlocks(): void; finalize(block: BlockMdNode, lineNumber: number): void; processInlines(block: BlockMdNode): void; incorporateLine(ln: string): void; // The main parsing function. Returns a parsed document AST. parse(input: string, lineTexts?: string[]): MdNode; partialParseStart(lineNumber: number, lines: string[]): MdNode; partialParseExtends(lines: string[]): void; partialParseFinish(): void; setRefMaps( refMap: RefMap, refLinkCandidateMap: RefLinkCandidateMap, refDefCandidateMap: RefDefCandidateMap ): void; clearRefMaps(): void; } export type HTMLConvertor = ( node: MdNode, context: Context, convertors?: HTMLConvertorMap ) => HTMLToken | HTMLToken[] | null; export type HTMLConvertorMap = Partial>; interface RendererOptions { gfm: boolean; softbreak: string; nodeId: boolean; tagFilter: boolean; convertors?: HTMLConvertorMap; } interface Context { entering: boolean; leaf: boolean; options: Omit; getChildrenText: (node: MdNode) => string; skipChildren: () => void; origin?: () => ReturnType; } interface TagToken { tagName: string; outerNewLine?: boolean; innerNewLine?: boolean; } export interface OpenTagToken extends TagToken { type: 'openTag'; classNames?: string[]; attributes?: Record; selfClose?: boolean; } export interface CloseTagToken extends TagToken { type: 'closeTag'; } export interface TextToken { type: 'text'; content: string; } export interface RawHTMLToken { type: 'html'; content: string; outerNewLine?: boolean; } export type HTMLToken = OpenTagToken | CloseTagToken | TextToken | RawHTMLToken; export class Renderer { constructor(customOptions?: Partial); getConvertors(): HTMLConvertorMap; getOptions(): RendererOptions; render(rootNode: MdNode): string; renderHTMLNode(node: HTMLToken): void; } export interface RemovedNodeRange { id: [number, number]; line: [number, number]; } export interface EditResult { nodes: MdNode[]; removedNodeRange: RemovedNodeRange | null; } type EventName = 'change'; type EventHandlerMap = { [key in EventName]: Function[]; }; export class ToastMark { constructor(contents?: string, options?: Partial); lineTexts: string[]; editMarkdown(startPos: Pos, endPos: Pos, newText: string): EditResult[]; getLineTexts(): string[]; getRootNode(): MdNode; findNodeAtPosition(pos: Pos): MdNode | null; findFirstNodeAtLine(line: number): MdNode | null; on(eventName: EventName, callback: () => void): void; off(eventName: EventName, callback: () => void): void; findNodeById(id: number): MdNode | null; removeAllNode(): void; } ================================================ FILE: apps/editor/types/toastui-editor-viewer.d.ts ================================================ declare module '@toast-ui/editor/dist/toastui-editor-viewer' { import { Viewer, ViewerOptions, ExtendedAutolinks, LinkAttributes, Sanitizer, EventMap, WidgetRuleMap, WidgetRule, PluginContext, I18n, CustomHTMLRenderer, HTMLMdNodeConvertor, HTMLMdNodeConvertorMap, PluginInfo, PluginNodeViews, PluginCommandMap, } from '@toast-ui/editor'; export { ViewerOptions, ExtendedAutolinks, LinkAttributes, Sanitizer, EventMap, WidgetRuleMap, WidgetRule, PluginContext, I18n, CustomHTMLRenderer, HTMLMdNodeConvertor, HTMLMdNodeConvertorMap, PluginInfo, PluginNodeViews, PluginCommandMap, }; export default Viewer; } ================================================ FILE: apps/editor/types/ui.d.ts ================================================ export interface PopupOptions { body: HTMLElement; className?: string; style?: Record; } export interface ToolbarButtonOptions { name: string; tooltip?: string; className?: string; command?: string; text?: string; style?: Record; popup?: PopupOptions; state?: ToolbarStateKeys; } export interface ToolbarCustomOptions { name: string; tooltip?: string; el?: HTMLElement; popup?: PopupOptions; hidden?: boolean; state?: ToolbarStateKeys; onMounted?: (execCommand: ExecCommand) => void; onUpdated?: (toolbarState: ToolbarItemState) => void; } export type ToolbarButtonInfo = { hidden?: boolean; } & ToolbarButtonOptions; export interface Component { props: T; prevProps?: T; state: R; vnode: VNode; refs: Record; render(): VNode; addEvent?(): void; mounted?(): void; updated?(prevProps: T): void; beforeDestroy?(): void; } export interface VNodeWalker { current: VNode | null; root: VNode | null; entering: boolean; walk: () => { vnode: VNode; entering: boolean } | null; } export interface VNode { type: string | ComponentClass; props: Record; children: VNode[]; parent: VNode | null; old: VNode | null; firstChild: VNode | null; next: VNode | null; ref?: (node: Node | Component) => void | Node | Component; node: Node | null; effect: 'A' | 'U' | 'D'; component?: Component; skip: boolean; walker: () => VNodeWalker; } export interface ComponentClass { new (props?: any): Component; } export interface Pos { left: number; top: number; } export type TooltipStyle = { display: 'none' | 'block'; } & Partial; export interface PopupInfo { className?: string; style?: Record; fromEl: HTMLElement; pos: Pos; render: (props: Record) => VNode | VNode[]; initialValues?: PopupInitialValues; } export type PopupInitialValues = Record; export interface TabInfo { name: string; text: string; } interface ToolbarItemState { active: boolean; disabled?: boolean; } interface ToolbarStateMap { taskList: ToolbarItemState; orderedList: ToolbarItemState; bulletList: ToolbarItemState; table: ToolbarItemState; strong: ToolbarItemState; emph: ToolbarItemState; strike: ToolbarItemState; heading: ToolbarItemState; thematicBreak: ToolbarItemState; blockQuote: ToolbarItemState; code: ToolbarItemState; codeBlock: ToolbarItemState; indent: ToolbarItemState; outdent: ToolbarItemState; } export type ToolbarStateKeys = keyof ToolbarStateMap; export type ToolbarItemInfo = ToolbarCustomOptions | ToolbarButtonInfo; export type ToolbarGroupInfo = ToolbarItemInfo[] & { hidden?: boolean }; export type ToolbarItemOptions = ToolbarCustomOptions | ToolbarButtonOptions; export type ToolbarItem = (string | ToolbarItemOptions)[]; export type ExecCommand = (command: string, payload?: Record) => void; export type HidePopup = () => void; export type SetPopupInfo = (info: PopupInfo) => void; export type SetItemWidth = (name: string, width: number) => void; export type ShowTooltip = (el: HTMLElement) => void; export type HideTooltip = () => void; export type GetBound = (el: HTMLElement, active?: boolean) => Pos; export interface ContextMenuItem { label: string; className?: string; disabled?: boolean; onClick?: () => void; } export interface IndexList { groupIndex: number; itemIndex: number; } export interface DefaultUI { destroy: () => void; insertToolbarItem: (indexList: IndexList, item: string | ToolbarItemOptions) => void; removeToolbarItem: (name: string) => void; } ================================================ FILE: apps/editor/types/wysiwyg.d.ts ================================================ import { ResolvedPos } from 'prosemirror-model'; import { Selection } from 'prosemirror-state'; export type WwNodeType = | 'text' | 'paragraph' | 'heading' | 'codeBlock' | 'bulletList' | 'orderedList' | 'listItem' | 'table' | 'tableHead' | 'tableBody' | 'tableRow' | 'tableHeadCell' | 'tableBodyCell' | 'blockQuote' | 'thematicBreak' | 'image' | 'hardBreak' | 'lineBreak' | 'customBlock' | 'frontMatter' | 'widget' | 'html' | 'htmlComment'; export type WwMarkType = 'strong' | 'emph' | 'strike' | 'link' | 'code' | 'html'; export interface CellSelection extends Selection { startCell: ResolvedPos; endCell: ResolvedPos; } export type ColumnAlign = 'left' | 'right' | 'center'; ================================================ FILE: apps/editor/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const pkg = require('./package.json'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const FileManagerPlugin = require('filemanager-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const ENTRY_EDITOR = './src/index.ts'; const ENTRY_ONLY_STYLE = './src/indexEditorOnlyStyle.ts'; const ENTRY_VIEWER = './src/indexViewer.ts'; let isProduction; let minify; function addFileManagerPlugin(config) { // When an entry option's value is set to a CSS file, // empty JavaScript files are created. (e.g. toastui-editor-only.js) // These files are unnecessary, so use the FileManager plugin to delete them. const options = minify ? { delete: ['./dist/cdn/toastui-editor-only.min.js'], } : { delete: ['./dist/toastui-editor-only.js'], copy: [{ source: './dist/*.{js,css}', destination: './dist/cdn' }], }; config.plugins.push(new FileManagerPlugin({ events: { onEnd: options } })); } function addCopyPluginForThemeCss(config) { const options = minify ? { patterns: [{ from: './src/css/theme/*.css', to: './theme/toastui-editor-[name].min.css' }], } : { patterns: [{ from: './src/css/theme/*.css', to: './theme/toastui-editor-[name].css' }], }; config.plugins.push(new CopyPlugin(options)); } function addMinifyPlugin(config) { config.optimization = { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, extractComments: false, }), new CssMinimizerPlugin(), ], }; } function addAnalyzerPlugin(config, type) { config.plugins.push( new BundleAnalyzerPlugin({ analyzerMode: 'static', reportFilename: `../../report/webpack/stats-${pkg.version}-${type}.html`, }) ); } function setDevelopConfig(config) { // check in examples config.entry = { 'editor-all': ENTRY_EDITOR }; config.output.publicPath = '/dist/cdn'; config.plugins.pop(); config.externals = []; config.devtool = 'inline-source-map'; config.devServer = { // https://github.com/webpack/webpack-dev-server/issues/2484 injectClient: false, inline: true, host: '0.0.0.0', port: 8080, disableHostCheck: true, }; } function setProductionConfig(config) { config.entry = { editor: ENTRY_EDITOR, 'editor-only': ENTRY_ONLY_STYLE, 'editor-viewer': ENTRY_VIEWER, }; addFileManagerPlugin(config); addCopyPluginForThemeCss(config); if (minify) { addMinifyPlugin(config); addAnalyzerPlugin(config, 'normal'); } } function setProductionConfigForAll(config) { config.entry = { 'editor-all': ENTRY_EDITOR }; config.output.path = path.resolve(__dirname, 'dist/cdn'); config.externals = []; addCopyPluginForThemeCss(config); if (minify) { addMinifyPlugin(config); addAnalyzerPlugin(config, 'all'); } } module.exports = (env) => { minify = !!env.minify; isProduction = env.WEBPACK_BUILD; const configs = Array(isProduction ? 2 : 1) .fill(0) .map(() => { return { mode: isProduction ? 'production' : 'development', cache: false, output: { environment: { arrowFunction: false, const: false, }, library: { name: ['toastui', 'Editor'], type: 'umd', export: 'default', }, path: path.resolve(__dirname, minify ? 'dist/cdn' : 'dist'), filename: `toastui-[name]${minify ? '.min' : ''}.js`, }, module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, { test: /\.png$/i, type: 'asset/inline', }, ], }, resolve: { extensions: ['.ts', '.js'], alias: { '@': path.resolve('src'), '@t': path.resolve('types'), }, }, plugins: [ new MiniCssExtractPlugin({ filename: ({ chunk }) => `toastui-${chunk.name.replace('-all', '')}${minify ? '.min' : ''}.css`, }), new webpack.BannerPlugin({ banner: [ pkg.name, `@version ${pkg.version} | ${new Date().toDateString()}`, `@author ${pkg.author}`, `@license ${pkg.license}`, ].join('\n'), raw: false, entryOnly: true, }), new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: isProduction, }), ], externals: [ { 'prosemirror-commands': { commonjs: 'prosemirror-commands', commonjs2: 'prosemirror-commands', amd: 'prosemirror-commands', }, 'prosemirror-history': { commonjs: 'prosemirror-history', commonjs2: 'prosemirror-history', amd: 'prosemirror-history', }, 'prosemirror-inputrules': { commonjs: 'prosemirror-inputrules', commonjs2: 'prosemirror-inputrules', amd: 'prosemirror-inputrules', }, 'prosemirror-keymap': { commonjs: 'prosemirror-keymap', commonjs2: 'prosemirror-keymap', amd: 'prosemirror-keymap', }, 'prosemirror-model': { commonjs: 'prosemirror-model', commonjs2: 'prosemirror-model', amd: 'prosemirror-model', }, 'prosemirror-state': { commonjs: 'prosemirror-state', commonjs2: 'prosemirror-state', amd: 'prosemirror-state', }, 'prosemirror-view': { commonjs: 'prosemirror-view', commonjs2: 'prosemirror-view', amd: 'prosemirror-view', }, 'prosemirror-transform': { commonjs: 'prosemirror-transform', commonjs2: 'prosemirror-transform', amd: 'prosemirror-transform', }, }, ], optimization: { minimize: false, }, performance: { hints: false, }, }; }); if (isProduction) { setProductionConfig(configs[0]); setProductionConfigForAll(configs[1]); } else { setDevelopConfig(configs[0]); } return configs; }; ================================================ FILE: apps/react-editor/.eslintrc.js ================================================ module.exports = { plugins: ['react'], extends: ['plugin:react/recommended'], rules: { 'react/prop-types': 0, }, settings: { react: { version: 'detect', }, }, }; ================================================ FILE: apps/react-editor/README.md ================================================ # TOAST UI Editor for React > This is a [React](https://reactjs.org/) component wrapping [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor). [![npm version](https://img.shields.io/npm/v/@toast-ui/react-editor.svg)](https://www.npmjs.com/package/@toast-ui/react-editor) ## 🚩 Table of Contents - [Collect Statistics on the Use of Open Source](#collect-statistics-on-the-use-of-open-source) - [Install](#-install) - [Usage](#-usage) ## Collect Statistics on the Use of Open Source React Wrapper of TOAST UI Editor applies Google Analytics (GA) to collect statistics on the use of open source, in order to identify how widely TOAST UI Editor is used throughout the world. It also serves as important index to determine the future course of projects. location.hostname (e.g. ui.toast.com) is to be collected and the sole purpose is nothing but to measure statistics on the usage. To disable GA, use the `usageStatistics` props like the example below. ```js ``` ## 💾 Install ### Using npm ```sh npm install --save @toast-ui/react-editor ``` ## 📝 Usage ### Import You can use TOAST UI Editor for React as a ECMAScript module or a CommonJS module. As this module does not contain CSS files, you should import `toastui-editor.css` from `@toast-ui/editor` in the script. - ES Modules ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/react-editor'; ``` - CommonJS ```js require('@toast-ui/editor/dist/toastui-editor.css'); const { Editor } = require('@toast-ui/react-editor'); ``` ### Props [All the options of the TOAST UI Editor](https://nhn.github.io/tui.editor/latest/ToastUIEditor) are supported in the form of props. ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/react-editor'; const MyComponent = () => ( ); ``` ### Instance Methods For using [instance methods of TOAST UI Editor](https://nhn.github.io/tui.editor/latest/ToastUIEditor#addHook), first thing to do is creating Refs of wrapper component using [`createRef()`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs). But the wrapper component does not provide a way to call instance methods of TOAST UI Editor directly. Instead, you can call `getInstance()` method of the wrapper component to get the instance, and call the methods on it. ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/react-editor'; class MyComponent extends React.Component { editorRef = React.createRef(); handleClick = () => { this.editorRef.current.getInstance().exec('bold'); }; render() { return ( <> ); } } ``` #### Getting the Root Element An instance of the wrapper component also provides a handy method for getting the root element. If you want to manipulate the root element directly, you can call `getRootElement` to get the element. ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/react-editor'; class MyComponent extends React.Component { editorRef = React.createRef(); handleClickButton = () => { this.editorRef.current.getRootElement().classList.add('my-editor-root'); }; render() { return ( <> ); } } ``` ### Events [All the events of TOAST UI Editor](https://nhn.github.io/tui.editor/latest/ToastUIEditor#focus) are supported in the form of `on[EventName]` props. The first letter of each event name should be capitalized. For example, for using `focus` event you can use `onFocus` prop like the example below. ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/react-editor'; class MyComponent extends React.Component { handleFocus = () => { console.log('focus!!'); }; render() { return ( ); } } ``` ================================================ FILE: apps/react-editor/demo/esm/index.html ================================================ Demo
                          ================================================ FILE: apps/react-editor/demo/esm/index.jsx ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import { Editor } from '/dist/index.js'; import '@toast-ui/editor/dist/toastui-editor.css'; const content = [ '![image](https://uicdn.toast.com/toastui/img/tui-editor-bi.png)', '', '# Awesome Editor!', '', 'It has been _released as opensource in 2018_ and has ~~continually~~ evolved to **receive 10k GitHub ⭐️ Stars**.', '', '## Create Instance', '', 'You can create an instance with the following code and use `getHtml()` and `getMarkdown()` of the [Editor](https://github.com/nhn/tui.editor).', '', '```js', 'const editor = new Editor(options);', '```', '', '> See the table below for default options', '> > More API information can be found in the document', '', '| name | type | description |', '| --- | --- | --- |', '| el | `HTMLElement` | container element |', '', '## Features', '', '* CommonMark + GFM Specifications', ' * Live Preview', ' * Scroll Sync', ' * Auto Indent', ' * Syntax Highlight', ' 1. Markdown', ' 2. Preview', '', '## Support Wrappers', '', '> * Wrappers', '> 1. [x] React', '> 2. [x] Vue', '> 3. [ ] Ember', ].join('\n'); ReactDOM.render( <> , document.getElementById('editor') ); ================================================ FILE: apps/react-editor/index.d.ts ================================================ import { Component } from 'react'; import ToastuiEditor, { EditorOptions, ViewerOptions, EventMap } from '@toast-ui/editor'; import ToastuiEditorViewer from '@toast-ui/editor/dist/toastui-editor-viewer'; export interface EventMapping { onLoad: EventMap['load']; onChange: EventMap['change']; onCaretChange: EventMap['caretChange']; onFocus: EventMap['focus']; onBlur: EventMap['blur']; onKeydown: EventMap['keydown']; onKeyup: EventMap['keyup']; onBeforePreviewRender: EventMap['beforePreviewRender']; onBeforeConvertWysiwygToMarkdown: EventMap['beforeConvertWysiwygToMarkdown']; } export type EventNames = keyof EventMapping; export type EditorProps = Omit & Partial; export type ViewerProps = Omit & Partial; export class Editor extends Component { getInstance(): ToastuiEditor; getRootElement(): HTMLElement; } export class Viewer extends Component { getInstance(): ToastuiEditorViewer; getRootElement(): HTMLElement; } ================================================ FILE: apps/react-editor/package.json ================================================ { "name": "@toast-ui/react-editor", "version": "3.2.3", "description": "TOAST UI Editor for React", "files": [ "dist", "index.d.ts" ], "main": "dist/toastui-react-editor.js", "module": "dist/esm/index.js", "scripts": { "test:types": "tsc", "lint": "eslint .", "serve": "snowpack dev", "build": "webpack build && rollup -c" }, "homepage": "https://ui.toast.com", "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "author": "NHN Cloud FE Development Lab ", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "apps/react-editor" }, "license": "MIT", "browserslist": "last 2 versions, ie 11", "peerDependencies": { "react": "^17.0.1" }, "devDependencies": { "@types/react": "^17.0.3", "react": "^17.0.1", "react-dom": "^17.0.1" }, "dependencies": { "@toast-ui/editor": "^3.2.2" } } ================================================ FILE: apps/react-editor/rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import banner from 'rollup-plugin-banner'; import { version, author, license } from './package.json'; const bannerText = [ 'TOAST UI Editor : React Wrapper', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n'); export default [ { input: 'src/index.ts', output: { dir: 'dist/esm', format: 'es', sourcemap: false, }, plugins: [typescript(), commonjs(), nodeResolve(), banner(bannerText)], external: ['react', '@toast-ui/editor', '@toast-ui/editor/dist/toastui-editor-viewer'], }, ]; ================================================ FILE: apps/react-editor/snowpack.config.js ================================================ /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, devOptions: { port: 8080, }, alias: { '@': './src', '@t': './types', }, }; ================================================ FILE: apps/react-editor/src/editor.tsx ================================================ import React from 'react'; import Editor, { EventMap } from '@toast-ui/editor'; import type { EditorProps, EventNames } from '../index'; export default class extends React.Component { rootEl = React.createRef(); editorInst!: Editor; getRootElement() { return this.rootEl.current; } getInstance() { return this.editorInst; } getBindingEventNames() { return Object.keys(this.props) .filter((key) => /^on[A-Z][a-zA-Z]+/.test(key)) .filter((key) => this.props[key as EventNames]); } bindEventHandlers(props: EditorProps) { this.getBindingEventNames().forEach((key) => { const eventName = key[2].toLowerCase() + key.slice(3); this.editorInst.off(eventName); this.editorInst.on(eventName, props[key as EventNames]!); }); } getInitEvents() { return this.getBindingEventNames().reduce( (acc: Record, key) => { const eventName = (key[2].toLowerCase() + key.slice(3)) as keyof EventMap; acc[eventName] = this.props[key as EventNames]; return acc; }, {} ); } componentDidMount() { this.editorInst = new Editor({ el: this.rootEl.current!, ...this.props, events: this.getInitEvents(), }); } shouldComponentUpdate(nextProps: EditorProps) { const instance = this.getInstance(); const { height, previewStyle } = nextProps; if (height && this.props.height !== height) { instance.setHeight(height); } if (previewStyle && this.props.previewStyle !== previewStyle) { instance.changePreviewStyle(previewStyle); } this.bindEventHandlers(nextProps); return false; } render() { return
                          ; } } ================================================ FILE: apps/react-editor/src/index.ts ================================================ import Editor from './editor'; import Viewer from './viewer'; export { Editor, Viewer }; ================================================ FILE: apps/react-editor/src/viewer.tsx ================================================ import React from 'react'; import Viewer, { EventMap } from '@toast-ui/editor/dist/toastui-editor-viewer'; import { ViewerProps, EventNames } from '../index'; export default class ViewerComponent extends React.Component { rootEl = React.createRef(); viewerInst!: Viewer; getRootElement() { return this.rootEl.current; } getInstance() { return this.viewerInst; } getBindingEventNames() { return Object.keys(this.props) .filter((key) => /^on[A-Z][a-zA-Z]+/.test(key)) .filter((key) => this.props[key as EventNames]); } bindEventHandlers(props: ViewerProps) { this.getBindingEventNames().forEach((key) => { const eventName = key[2].toLowerCase() + key.slice(3); this.viewerInst.off(eventName); this.viewerInst.on(eventName, props[key as EventNames]!); }); } getInitEvents() { return this.getBindingEventNames().reduce( (acc: Record, key) => { const eventName = (key[2].toLowerCase() + key.slice(3)) as keyof EventMap; acc[eventName] = this.props[key as EventNames]; return acc; }, {} ); } componentDidMount() { this.viewerInst = new Viewer({ el: this.rootEl.current!, ...this.props, events: this.getInitEvents(), }); } shouldComponentUpdate(nextProps: ViewerProps) { this.bindEventHandlers(nextProps); return false; } render() { return
                          ; } } ================================================ FILE: apps/react-editor/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.tsx", "index.d.ts"], "exclude": ["node_modules"], "compilerOptions": { "jsx": "react", "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: apps/react-editor/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const { version, author, license } = require('./package.json'); const config = { entry: './src/index.ts', output: { filename: 'toastui-react-editor.js', path: path.resolve(__dirname, 'dist'), library: { type: 'commonjs2', }, }, externals: { '@toast-ui/editor': { commonjs: '@toast-ui/editor', commonjs2: '@toast-ui/editor', }, '@toast-ui/editor/dist/toastui-editor-viewer': { commonjs: '@toast-ui/editor/dist/toastui-editor-viewer', commonjs2: '@toast-ui/editor/dist/toastui-editor-viewer', }, react: { commonjs: 'react', commonjs2: 'react', }, }, module: { rules: [ { test: /\.tsx?$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'], }, plugins: [ new webpack.BannerPlugin({ banner: [ 'TOAST UI Editor : React Wrapper', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n'), }), ], }; module.exports = () => config; ================================================ FILE: apps/vue-editor/.eslintrc.js ================================================ module.exports = { parser: 'vue-eslint-parser', parserOptions: { parser: '@typescript-eslint/parser', }, extends: ['plugin:vue/base'], plugins: ['vue'], }; ================================================ FILE: apps/vue-editor/README.md ================================================ # TOAST UI Editor for Vue > This is [Vue](https://vuejs.org/) component wrapping [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor). [![npm version](https://img.shields.io/npm/v/@toast-ui/vue-editor.svg)](https://www.npmjs.com/package/@toast-ui/vue-editor) ## 🚩 Table of Contents - [Collect Statistics on the Use of Open Source](#collect-statistics-on-the-use-of-open-source) - [Install](#-install) - [Editor Usage](#-editor-usage) - [Viewer Usage](#-viewer-usage) ## Collect Statistics on the Use of Open Source Vue Wrapper of TOAST UI Editor applies Google Analytics (GA) to collect statistics on the use of open source, in order to identify how widely TOAST UI Editor is used throughout the world. It also serves as important index to determine the future course of projects. location.hostname (e.g. ui.toast.com) is to be collected and the sole purpose is nothing but to measure statistics on the usage. To disable GA, use the following `usageStatistics` options when declare Vue Wrapper component. ```js const options = { ... usageStatistics: false } ``` ## 💾 Install ### Using npm ```sh npm install --save @toast-ui/vue-editor ``` ## 📝 Editor Usage ### Import You can use Toast UI Editor for Vue as a ECMAScript module or a CommonJS module. As this module does not contain CSS files, you should import `toastui-editor.css` from `@toast-ui/editor` in the script. - ES Modules ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/vue-editor'; ``` - CommonJS ```js require('@toast-ui/editor/dist/toastui-editor.css'); const { Editor } = require('@toast-ui/vue-editor'); ``` ### Creating Component First implement `` in the template. ```html ``` And then add `Editor` to the `components` in your component or Vue instance like this: ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/vue-editor'; export default { components: { editor: Editor } }; ``` or ```js import '@toast-ui/editor/dist/toastui-editor.css'; import { Editor } from '@toast-ui/vue-editor'; new Vue({ el: '#app', components: { editor: Editor } }); ``` ### Props | Name | Type | Default | Description | | --------------- | ------ | -------------------------- | --------------------------------------------------------- | | initialValue | String | '' | Editor's initial value . | | initialEditType | String | 'markdown' | Initial editor type (markdown, wysiwyg). | | options | Object | following `defaultOptions` | Options of tui.editor. This is for initailize tui.editor. | | height | String | '300px' | This prop can control the height of the editor. | | previewStyle | String | 'vertical' | Markdown editor's preview style (tab, vertical). | ```js const defaultOptions = { minHeight: '200px', language: 'en-US', useCommandShortcut: true, usageStatistics: true, hideModeSwitch: false, toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], ['table', 'image', 'link'], ['code', 'codeblock'], ['scrollSync'], ] }; ``` ```html ``` ### Instance Methods If you want to more manipulate the Editor, you can use `invoke` method to call the method of toastui.editor. For more information of method, see [instance methods of TOAST UI Editor](https://nhn.github.io/tui.editor/latest/ToastUIEditor#addHook). First, you need to assign `ref` attribute of `` and then you can use `invoke` method through `this.$refs` like this: ```html ``` ### Events - load : It would be emitted when editor fully load - change : It would be emitted when content changed - caretChange : It would be emitted when format change by cursor position - focus : It would be emitted when editor get focus - blur : It would be emitted when editor loose focus ```html ``` ## 📃 Viewer Usage ### Import - ES Modules ```js import '@toast-ui/editor/dist/toastui-editor-viewer.css'; import { Viewer } from '@toast-ui/vue-editor'; ``` - CommonJS ```js require('@toast-ui/editor/dist/toastui-editor-viewer.css'); const { Viewer } = require('@toast-ui/vue-editor'); ``` ### Creating Component First implement `` in the template. ```html ``` And then add `Viewer` to the `components` in your component or Vue instance like this: ```js import '@toast-ui/editor/dist/toastui-editor-viewer.css'; import { Viewer } from '@toast-ui/vue-editor'; export default { components: { viewer: Viewer } }; ``` or ```js import '@toast-ui/editor/dist/toastui-editor-viewer.css'; import { Viewer } from '@toast-ui/vue-editor'; new Vue({ el: '#app', components: { viewer: Viewer } }); ``` ### Props | Name | Type | Default | Description | | ------------ | ------ | ------- | ----------------------------------------------- | | initialValue | String | '' | Viewer's initial value | | height | String | '300px' | This prop can control the height of the viewer. | | options | Object | above `defaultOptions` | Options of tui.editor. This is for initailize tui.editor. | ```html ``` ================================================ FILE: apps/vue-editor/demo/esm/index.html ================================================ Demo
                          ================================================ FILE: apps/vue-editor/demo/esm/index.js ================================================ import Vue from 'vue/dist/vue.esm.browser'; import { Editor } from '/dist/index.js'; import '@toast-ui/editor/dist/toastui-editor.css'; Vue.component('editor', Editor); const content = [ '![image](https://uicdn.toast.com/toastui/img/tui-editor-bi.png)', '', '# Awesome Editor!', '', 'It has been _released as opensource in 2018_ and has ~~continually~~ evolved to **receive 10k GitHub ⭐️ Stars**.', '', '## Create Instance', '', 'You can create an instance with the following code and use `getHtml()` and `getMarkdown()` of the [Editor](https://github.com/nhn/tui.editor).', '', '```js', 'const editor = new Editor(options);', '```', '', '> See the table below for default options', '> > More API information can be found in the document', '', '| name | type | description |', '| --- | --- | --- |', '| el | `HTMLElement` | container element |', '', '## Features', '', '* CommonMark + GFM Specifications', ' * Live Preview', ' * Scroll Sync', ' * Auto Indent', ' * Syntax Highlight', ' 1. Markdown', ' 2. Preview', '', '## Support Wrappers', '', '> * Wrappers', '> 1. [x] React', '> 2. [x] Vue', '> 3. [ ] Ember', ].join('\n'); new Vue({ data() { return { initialValue: content, }; }, template: '', }).$mount('#editor'); ================================================ FILE: apps/vue-editor/index.d.ts ================================================ import Vue from 'vue'; import ToastuiEditor from '@toast-ui/editor'; import ToastuiEditorViewer from '@toast-ui/editor/dist/toastui-editor-viewer'; type FunctionKeys = { [K in keyof T]: T[K] extends Function ? K : never; }[keyof T]; type EditorFnKeys = FunctionKeys; type ViewerFnKeys = FunctionKeys; export class Editor extends Vue { invoke( fname: T, ...args: Parameters ): ReturnType; getRootElement(): HTMLElement; } export class Viewer extends Vue { invoke( fname: T, ...args: Parameters ): ReturnType; getRootElement(): HTMLElement; } ================================================ FILE: apps/vue-editor/package.json ================================================ { "name": "@toast-ui/vue-editor", "version": "3.2.3", "description": "TOAST UI Editor for Vue", "main": "dist/toastui-vue-editor.js", "module": "dist/esm/index.js", "files": [ "dist", "index.d.ts" ], "scripts": { "lint": "eslint .", "serve": "snowpack dev", "build": "webpack build && rollup -c" }, "homepage": "https://ui.toast.com", "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "author": "NHN Cloud FE Development Lab ", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "apps/vue-editor" }, "license": "MIT", "devDependencies": { "vue": "^2.5.0", "vue-loader": "^15.9.8", "vue-template-compiler": "^2.6.12", "@morgul/snowpack-plugin-vue2": "^0.4.0" }, "peerDependencies": { "vue": "^2.5.0" }, "dependencies": { "@toast-ui/editor": "^3.2.2" } } ================================================ FILE: apps/vue-editor/rollup.config.js ================================================ import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import vue from 'rollup-plugin-vue'; import banner from 'rollup-plugin-banner'; import * as ts from 'typescript'; import { version, author, license } from './package.json'; function transpile() { return { name: 'transpile', transform(code) { const result = ts.transpileModule(code, { compilerOptions: { target: 'es5', module: 'es6', importHelpers: true, }, }); return result.outputText; }, }; } const bannerText = [ 'TOAST UI Editor : Vue Wrapper', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n'); export default [ { input: 'src/index.js', output: { dir: 'dist/esm', format: 'es', sourcemap: false, }, plugins: [vue({}), commonjs(), nodeResolve(), transpile(), banner(bannerText)], external: ['vue', '@toast-ui/editor', '@toast-ui/editor/dist/toastui-editor-viewer'], }, ]; ================================================ FILE: apps/vue-editor/snowpack.config.js ================================================ /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, devOptions: { port: 8080, }, plugins: ['@morgul/snowpack-plugin-vue2'], }; ================================================ FILE: apps/vue-editor/src/Editor.vue ================================================ ================================================ FILE: apps/vue-editor/src/Viewer.vue ================================================ ================================================ FILE: apps/vue-editor/src/index.js ================================================ import Editor from './Editor.vue'; import Viewer from './Viewer.vue'; export { Editor, Viewer }; ================================================ FILE: apps/vue-editor/src/mixin/option.js ================================================ const editorEvents = [ 'load', 'change', 'caretChange', 'focus', 'blur', 'keydown', 'keyup', 'beforePreviewRender', 'beforeConvertWysiwygToMarkdown', ]; const defaultValueMap = { initialEditType: 'markdown', initialValue: '', height: '300px', previewStyle: 'vertical', }; export const optionsMixin = { data() { const eventOptions = {}; editorEvents.forEach((event) => { eventOptions[event] = (...args) => { this.$emit(event, ...args); }; }); const options = { ...this.options, initialEditType: this.initialEditType, initialValue: this.initialValue, height: this.height, previewStyle: this.previewStyle, events: eventOptions, }; Object.keys(defaultValueMap).forEach((key) => { if (!options[key]) { options[key] = defaultValueMap[key]; } }); return { editor: null, computedOptions: options }; }, methods: { invoke(methodName, ...args) { let result = null; if (this.editor[methodName]) { result = this.editor[methodName](...args); } return result; }, }, destroyed() { editorEvents.forEach((event) => { this.editor.off(event); }); this.editor.destroy(); }, }; ================================================ FILE: apps/vue-editor/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); const webpack = require('webpack'); const { version, author, license } = require('./package.json'); module.exports = { entry: './src/index.js', output: { filename: 'toastui-vue-editor.js', path: path.resolve(__dirname, 'dist'), library: { type: 'commonjs2', }, environment: { arrowFunction: false, const: false, }, }, externals: { '@toast-ui/editor': { commonjs: '@toast-ui/editor', commonjs2: '@toast-ui/editor', }, '@toast-ui/editor/dist/toastui-editor-viewer': { commonjs: '@toast-ui/editor/dist/toastui-editor-viewer', commonjs2: '@toast-ui/editor/dist/toastui-editor-viewer', }, }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', exclude: /node_modules/, }, { test: /\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, ], }, plugins: [ new VueLoaderPlugin(), new webpack.BannerPlugin({ banner: [ 'TOAST UI Editor : Vue Wrapper', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n'), }), ], }; ================================================ FILE: docs/COMMIT_MESSAGE_CONVENTION.md ================================================ # Commit Message Convention ## Commit Message Format ``` : Short description (fix #1234) Longer description here if necessary BREAKING CHANGE: only contain breaking change ``` * Any line of the commit message cannot be longer 100 characters! ## Revert ``` revert: commit This reverts commit More description if needed ``` ## Type Must be one of the following: * **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, missing semi-colons, etc) * **refactor**: A code change that neither fixes a bug nor adds a feature * **perf**: A code change that improves performance * **test**: Adding missing or correcting existing tests * **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation ## Subject * use the imperative, __present__ tense: "change" not "changed" nor "changes" * don't capitalize the first letter * no dot (.) at the end * reference GitHub issues at the end. If the commit doesn’t completely fix the issue, then use `(refs #1234)` instead of `(fixes #1234)`. ## Body * use the imperative, __present__ tense: "change" not "changed" nor "changes". * the motivation for the change and contrast this with previous behavior. ## BREAKING CHANGE * This commit contains breaking change(s). * start with the word BREAKING CHANGE: with a space or two newlines. The rest of the commit message is then used for this. This convention is based on [AngularJS](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) and [ESLint](https://eslint.org/docs/developer-guide/contributing/pull-requests#step2) ================================================ FILE: docs/ISSUE_TEMPLATE.md ================================================ ## Version ## Test Environment ## Current Behavior ```js // Write example code ``` ## Expected Behavior ================================================ FILE: docs/PULL_REQUEST_TEMPLATE.md ================================================ ### Please check if the PR fulfills these requirements - [ ] 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) - [ ] The commit message follows our guidelines - [ ] 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 --- Thank you for your contribution to TOAST UI product. 🎉 😘 ✨ ================================================ FILE: docs/README.md ================================================ # 📄 Documents * [한글 가이드](https://github.com/nhn/tui.editor/blob/master/docs/ko/README.md) ## Tutorials - [🚀 Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) - [👀 Viewer](https://github.com/nhn/tui.editor/blob/master/docs/en/viewer.md) - [🧩 Plugins](https://github.com/nhn/tui.editor/blob/master/docs/en/plugin.md) - [🌏 Internationalization (i18n)](https://github.com/nhn/tui.editor/blob/master/docs/en/i18n.md) - [🎨 Custom HTML Renderer](https://github.com/nhn/tui.editor/blob/master/docs/en/custom-html-renderer.md) - [🔩 Custom Block](https://github.com/nhn/tui.editor/blob/master/docs/en/custom-block.md) - [🔗 Extended Autolinks](https://github.com/nhn/tui.editor/blob/master/docs/en/extended-autolinks.md) - [🛠 Toolbar](https://github.com/nhn/tui.editor/blob/master/docs/en/toolbar.md) - [📱 Widget](https://github.com/nhn/tui.editor/blob/master/docs/en/widget.md) ### Migration Guide - [✈️ v3.0 Migration Guide](https://github.com/nhn/tui.editor/blob/master/docs/v3.0-migration-guide.md) ## Etc - [📌 Commit Message Convention](https://github.com/nhn/tui.editor/blob/master/docs/COMMIT_MESSAGE_CONVENTION.md) - [📌 Contributing](https://github.com/nhn/tui.editor/blob/master/CONTRIBUTING.md) - [📌 Code of conduct](https://github.com/nhn/tui.editor/blob/master/CODE_OF_CONDUCT.md) ================================================ FILE: docs/en/custom-block.md ================================================ # 🔩 Custom Block Node And HTML Node The TOAST UI Editor (henceforth referred to as 'Editor') follows the [CommonMark](https://spec.commonmark.org/0.29/) specification, and also supports the [GFM](https://github.github.com/gfm/) specification. But what if you want to use a specific syntax that is not supported by CommonMark or GFM? For example, you might want to use [LaTeX](https://www.latex-project.org/) syntax or render elements such as charts in Markdown. The editor provides the option to define a **custom block node** for this usability. ## Custom Block Node The editor provides the `customHTMLRenderer` option that can be customized when converting Markdown Abstract Syntax Tree(AST) to HTML text. Using `customHTMLRenderer` option, rendering results of nodes supported by CommonMark or GFM can be customized like `table` and `heading`. Custom block nodes can also be defined using this `customHTMLRenderer` option. The following code defines a custom block node that renders math typesetting using KaTeX, a library that supports LaTex syntax. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { latex(node) { const generator = new latexjs.HtmlGenerator({ hyphenate: false }); const { body } = latexjs.parse(node.literal, { generator }).htmlDocument(); return [ { type: 'openTag', tagName: 'div', outerNewLine: true }, { type: 'html', content: body.innerHTML }, { type: 'closeTag', tagName: 'div', outerNewLine: true } ]; }, } }); ``` The `latex` function property was written in the `customHTMLRenderer` option, which returns HTML to be rendered in token format. It is easy to use because it configures options in almost the same form as when customizing a markdown node. The code above is rendered in the Markdown Editor as follows. ![image](https://user-images.githubusercontent.com/37766175/120983159-65bf2b00-c7b4-11eb-84af-30c38e832585.png) As you can see in the image above, in order to use a custom block node in a markdown editor, text must be entered within a block enclosed by the `$$` symbol. Blocks wrapped with `$$` symbols are parsed from editor to custom block nodes. In addition, to indicate which custom block node it is, the node name defined by the `customHTMLRenderer` option must be written next to the `$$` symbol. ```js // The node name must be written next to the $$ symbol. $$latex \documentclass{article} \begin{document} $ f(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \, d\xi $ \end{document} $$ ``` ### WYSIWYG The custom block node in the WYSIWYG Editor works like the image below. ![image](https://user-images.githubusercontent.com/37766175/120984395-96539480-c7b5-11eb-8e57-2f43082f345f.gif) In WYSIWYG Editor, the custom block node is rendered in the same result as a markdown preview, and can be changed by clicking on the node and using the edit button that appears when selected. Because the custom block node are eventually parsed based on specific text, editing in the WYSIWYG Editor is also based on text. This operation is different from general WYSIWYG editors, but it is more ideal because the **TOAST UI Editor supports WYSIWYG editors based on markdown**. ## HTML Node CommonMark uses `<` and `>` characters to write nodes that are not supported by default in HTML text. ([CommonMark Raw HTML Spec](https://spec.commonmark.org/0.29/#raw-html)) Because Markdown Editor also follows these specifications, HTML text are rendered correctly in the Markdown preview. ![image](https://user-images.githubusercontent.com/37766175/120987131-44f8d480-c7b8-11eb-971f-0b4ecb59e112.png) ### WYSIWYG Unfortunately, WYSIWYG Editor cannot render HTML nodes properly. The editor internally manages nodes supported by the WYSIWYG Editor as abstracted model object. Nodes that are supported by WYSIWYG Editor are nodes that are supported by CommonMark and GFM (such as `heading`, `list`, `strike` and others) and custom block node. ![image](https://user-images.githubusercontent.com/37766175/120989247-4c20e200-c7ba-11eb-8420-7ff5726592cf.gif) The `iframe` node in the example image above is not a node supported by WYSIWYG Editor. Therefore, if you want to use `iframe` node in WYSIWYG Editor, you need to set it up using `customHTMLRenderer` option. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { htmlBlock: { iframe(node) { return [ { type: 'openTag', tagName: 'iframe', outerNewLine: true, attributes: node.attrs }, { type: 'html', content: node.childrenHTML }, { type: 'closeTag', tagName: 'iframe', outerNewLine: true }, ]; }, } }, }); ``` HTML nodes are defined in the `customHTMLRenderer.htmlBlock` property. To distinguish it from the custom block nodes described above, it should be configured within the `htmlBlock` property. If you run the example code, `iframe` node will be rendered correctly in WYSIWYG as shown in the image below. ![image](https://user-images.githubusercontent.com/37766175/120989209-40352000-c7ba-11eb-9112-047a0af4f9d6.gif) If you want to use an inline HTML node, it should be configured in the `customHTMLRenderer.htmlInline` property. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { htmlBlock: { iframe(node) { return [ { type: 'openTag', tagName: 'iframe', outerNewLine: true, attributes: node.attrs }, { type: 'html', content: node.childrenHTML }, { type: 'closeTag', tagName: 'iframe', outerNewLine: true }, ]; }, }, htmlInline: { big(node, { entering }) { return entering ? { type: 'openTag', tagName: 'big', attributes: node.attrs } : { type: 'closeTag', tagName: 'big' }; }, }, }, }); ``` ================================================ FILE: docs/en/custom-html-renderer.md ================================================ # 🎨 Custom HTML Renderer The TOAST UI Editor (henceforth referred to as 'Editor') provides a way to customize the final HTML contents. The Editor uses its own markdown parser called `ToastMark`, which has two steps for converting markdown text to HTML text. The first step is converting markdown text into AST(Abstract Syntax Tree), and the second step is generating HTML text from the AST. Although it's tricky to customize the first step, the second step can be easily customized by providing a set of functions that convert a certain type of node to HTML string. ## Basic Usage The Editor accepts the `customHTMLRenderer` option, which is a key-value object. The keys of the object is types of node of the AST, and the values are convertor functions to be used for converting a node to a list of tokens. The following code is a basic example of using `customHTMLRenderer` option. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { heading(node, context) { return { type: context.entering ? 'openTag' : 'closeTag', tagName: 'div', classNames: [`heading-${node.level}`] }; }, text(node, context) { const strongContent = node.parent.type === 'strong'; return { type: 'text', content: strongContent ? node.literal.toUpperCase() : node.literal }; }, linebreak(node, context) { return { type: 'html', content: '\n
                          \n' }; } } }); ``` If we set the following markdown content, ```markdown ## Heading Hello World ``` The final HTML content will be like below. ```html
                          HEADING

                          Hello

                          World

                          ``` ## Tokens As you can see in the basic example above, each convertor function returns a token object instead of returning HTML string directly. The token objects are converted to HTML string automatically by internal module. The reason we use tokens instead of HTML string is that tokens are much easier to reuse as they contain structural information which can be used by overriding functions. There are four token types available for the token objects, which are `openTag`, `closeTag`, `text`, and `html`. ### openTag The `openTag` type token represents an opening tag string. A `openTag` type token has `tagName`, `attributes`, `classNames` properties to specify the data for generating HTML string. For example, following token object, ```js { type: 'openTag', tagName: 'a', classNames: ['my-class1', 'my-class2'] attributes: { target: '_blank', href: 'http://ui.toast.com' } } ``` is converted to the HTML string below. ```html ``` To specify self-closing tags like `
                          `, and `
                          ` , you can use `selfClose` options like below. ```js { type: 'openTag', tagName: 'br', classNames: ['my-class'], selfClose: true } ``` ```html
                          ``` ### closeTag The `closeTag` type token represents a closing tag string. A `closeTag` type token does not contain additional information other than `tagName`. ```js { type: 'closeTag', tagName: 'a' } ``` ```html ``` ### text The `text` type token represents a plain text string. This token only has a `content` property and HTML characters in the value are escaped in the converted string. ```js { type: 'text', content: '
                          ' } ``` ```html <br /> ``` ### html The `html` type token represents a raw HTML string. Like the `text` type token, this token also has `content` property and the value is used as is without modification. ```js { type: 'html', content: '
                          ' } ``` ```html
                          ``` ## Node The first parameter of a convertor function is a `Node` type object which is the main element of the AST(Abstract Syntax Tree) constructed by the ToastMark. Every node has common properties for constructing a tree, such as `parent`, `firstChild`, `lastChild`, `prev`, and `next`. In addition, each node has its own properties based on its type. For example, a `heading` type node has `level` property to represent the level of heading, and a `link` type node has a `destination` property to represent the URL of the link. The following markdown text and AST tree object will help you understand the structure of AST generated by the ToastMark. ```md ## TOAST UI **Hello** World! ``` ```js { type: 'document', firstChild: { type: 'heading', level: 2, parent: //[document node], firstChild: type: 'text', parent: //[heading node], literal: 'TOAST UI' }, next: { type: 'paragraph', parent: //[document node], firstChild: { type: 'strong', parent: //[paragraph node], firstChild: { type: 'text', parent: //[strong node], literal: 'Hello' }, next: { type: 'text', parent: //[paragraph node], literal: 'World !' } } } } } ``` The type definition of each node can be found in the [source code](https://github.com/nhn/tui.editor/blob/master/libs/toastmark/src/commonmark/node.ts). ## Context When the Editor tries to generate HTML string using an AST, every node in the AST is traversed in pre-order fashion. Whenever a node is visited, a convertor function of which the key is the same as the type of the node is invoked. At this point, a context object is given to the convertor function as a second parameter. ### entering Every node in an AST except leaf nodes is visited twice during a traversal. The fisrt time when the node is visited, and the second time after all the children of the node are visited. We can determine in which pace the convertor is invoked using `entering` property of the context object. The following code is a typical example using `entering` property. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { heading({ level }, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: `h${level}` }; }, text({ literal }) { return { type: 'text', content: node.literal }; } } }); ``` The `heading` convertor function is using `context.entering` to determin the type of returning token object. The type is `openTag` when the value is `true`, otherwise is `closeTag`. The `text` convertor function doens't need to use `entering` property as it is invoked only once for the first visit. Now, if we set the following markdown text to the editor, ```markdown # TOAST UI ``` The AST genereted by ToastMark will be like below. (only essential properties are specified) ```js { type: 'document', firstChild: { type: 'heading', level: 1, firstChild: { type: 'text', literal: 'TOAST UI' } } } ``` After finishing a traversal, tokens returned by convertor functions are stored in an array like below. ```js [ { type: 'openTag', tagName: 'h1' }, { type: 'text', content: 'TOAST UI' }, { type: 'closeTag', tagName: 'h1' } ]; ``` Finally, the array of token is converted to HTML string. ```html

                          TOAST UI

                          ``` ### origin() If we want to use original convertor function inside the overriding function, we can use `origin()` function. For example, if the return value of original convertor function for `link` node is like below, #### entering: true ```js { type: 'openTag', tagName: 'a', attributes: { href: 'http://ui.toast.com', title: 'TOAST UI' } } ``` #### entering: false ```js { type: 'closeTag', tagName: 'a' } ``` The following code will set `target="_blank"` attribute to the result object only when `entering` state is `true`. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { link(node, context) { const { origin, entering } = context; const result = origin(); if (entering) { result.attributes.target = '_blank'; } return result; } }, } ``` #### entering: true ```js { type: 'openTag', tagName: 'a', attributes: { href: 'http://ui.toast.com', target: '_blank', title: 'TOAST UI' } } ``` ## Advanced Usage ### getChildrenText() In a normal situation, a node doesn't need to care about it's children as their content will be handled by their own convertor functions. However, sometimes a node needs to get the children content to set the value of it's attribute. For this use case, a `context` object provides the `getChildrenText()` function. For example, if a heading element wants to set it's `id` based on its children content, we can use the `getChildrenText()` function like the code below. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { heading({ level }, { entering, getChildrenText }) { const tagName = `h${level}`; if (entering) { return { type: 'openTag', tagName, attributes: { id: getChildrenText(node) .trim() .replace(/\s+/g, '-') } }; } return { type: 'closeTag', tagName }; } } }); ``` Now, if we set the markdown text below, ```markdown # Hello _World_ ``` The return value of `getChildrenText()` inside the `heading` convertor function will be `Hello World`. As we are replacing white spaces into `-`, the final HTML string through the custom renderer will be like below. ```html

                          Hello World

                          ``` ### skipChildren() The `skipChildren()` function skips traversal of child nodes. This function is useful when we want to use the content of children only for the attribute of current node, instead of generating child elements. For example, `image` node has children which represents the description of the image. However, if we want to use an `img` element for representing a `image` node, we can't use child elements as an `img` element cannot have children. In this case, we need to invoke `skipChildren()` to prevent child nodes from being converted to additional HTML string. Instead, we can use `getChildrenText()` to get the text content of children, and set it to the `alt` attribute. The following code example is an simplified version of built-in convertor function for an `image` type node. ```js function image(node, context) { const { destination } = node; const { getChildrenText, skipChildren } = context; skipChildren(); return { type: 'openTag', tagName: 'img', selfClose: true, attributes: { src: destination, alt: getChildrenText(node) } }; } ``` ### Using Multiple Tags for a Node A convertor function can also returns an array of token object. This is useful when we want to convert a node to nested elements. The following code example shows how to convert a `codeBlock` node to `
                          ...
                          ` tag string. ```js function codeBlock(node) { return [ { type: 'openTag', tagName: 'pre', classNames: ['code-block'] }, { type: 'openTag', tagName: 'code' }, { type: 'text', content: node.literal }, { type: 'closeTag', tagName: 'code' }, { type: 'closeTag', tagName: 'pre' } ]; } ``` ### Controlling Newlines In a normal situation, we don't need to care about formatting of converted HTML string. However, as the ToastMark support [CommonMark Spec](https://spec.commonmark.org/0.29/), the renderer supports an option to control new-lines to pass the [official test cases](https://spec.commonmark.org/0.29/spec.json). The `outerNewline` and `innerNewline` property can be added to token objects to control white spaces. The following example will help you understand how to use these properties. #### Token Array ```js [ { type: 'text', content: 'Hello' }, { type: 'openTag', tagName: 'p', outerNewLine: true, innerNewLine: true }, { type: 'html', content: 'My' outerNewLine: true, }, { type: 'closeTag', tagName: 'p', innerNewLine: true }, { type: 'text', content: 'World' } ] ``` #### Converted HTML string ```html Hello

                          My

                          World ``` As you can see in the example above, `outerNewLine` of `openTag` adds `\n` before the tag string, whereas one of `closeTag` adds `\n` after the tag string. In contrast, `innerNewLine` of `openTag` adds `\n` after the tag string, whereas one of `closeTag` adds `\n` before the tag string. In addition, consecutive newlines are merged into one newline to prevent duplication. ================================================ FILE: docs/en/extended-autolinks.md ================================================ # 🔗 Extending Autolinks ## What Is Autolinks? ### Autolinks The [Autolinks](https://spec.commonmark.org/0.29/#autolinks) is the CommonMark specification like as below. (If you want to know the detail specification of the Autolinks, refer to examples in the above link.) > Autolinks are absolute URIs and email addresses inside `<` and `>`. They are parsed as `>` links, with the URL or email address as the link label. The functionality is available in TOAST UI Editor (henceforth referred to as 'Editor') without any configuration, because the Editor follows the CommonMark specification. ![image](https://user-images.githubusercontent.com/37766175/120604939-7ad04d00-c488-11eb-82c1-f9f05891039e.png) ### Extended Autolinks The Extended Autolinks is the specification which is supported by [GFM](https://github.github.com/gfm). The specification makes the Autolinks be recognized in a greater number of conditions. For example, if the text has `www.` with a valid domain, it will be recognized as the Autolinks like as below. ![image](https://user-images.githubusercontent.com/37766175/120605112-a5baa100-c488-11eb-9b72-75eaa9324080.png) More examples related with the Extended Autolinks can be found [here](https://github.github.com/gfm/#autolinks-extension-). ## Extended Autolinks Configuration The Extended Autolinks on Editor can be used by configuring the `extendedAutolinks` option. If the `extendedAutolinks` option is not otherwise defined, Editor will automatically configure the `false` value to make the Extended Autolinks be not worked internally. When we set the `extendedAutolinks` value to explicitly declare it `true` value, the nodes which follow the Extended Autolinks specification can be parsed as the link node on Editor. ```js const editor = new toastui.Editor({ // ... extendedAutolinks: true }); ``` ## Customizing the Extended Autolinks Editor enables users to define their own Extended Autolinks by providing the callback function option. This option can be useful when you want to support the specific link format. To customize the Extended Autolinks, `extendedAutolinks` option should be `function`. The following is a simple example snippet for configuring the option. ```js const reToastuiEditorRepo = /tui\.editor/g; const editor = new Editor({ el: document.querySelector('#editor'), extendedAutolinks: (content) => { const matched = content.match(reToastuiEditorRepo); if (matched) { return matched.map(m => ({ text: 'toastui-editor', url: 'https://github.com/nhn/tui.editor', range: [0, 1] }) ); } return null; } }); ``` As the code above demonstrates, the `content` parameter which has the editing content is passed to the `extendedAutolinks` callback function. If the desired link formats are found in the content, the result should be the array, and each element has `text`, `url` and `range` properties for the information of link. * `text`: The link label * `url`: The link destination * `range`: The link range for calculating source position internally Here is the result of the aforementioned example. ![image](https://user-images.githubusercontent.com/37766175/120606618-55444300-c48a-11eb-8376-859fc6ffcf07.gif) ================================================ FILE: docs/en/getting-started.md ================================================ # 🚀 Getting Started ## The Project Setup TOAST UI Editor can be used by using the package manager or downloading the source directly. However, we highly recommend using the package manager. ### Via Package Manager (npm) You can conveniently install it using the commands provided by each package manager. When using npm, be sure to use it in the environment [Node.js](https://nodejs.org/en/) is installed. ```sh $ npm install --save @toast-ui/editor # Latest Version $ npm install --save @toast-ui/editor@ # Specific Version ``` When installed and used with npm, the list of files that can be imported is as follows: ``` - node_modules/ ├─ @toast-ui/editor/ │ ├─ dist/ │ │ ├─ toastui-editor.js │ │ ├─ toastui-editor-viewer.js │ │ ├─ toastui-editor.css │ │ ├─ toastui-editor-viewer.css │ │ └─ toastui-editor-only.css ``` ### Via Contents Delivery Network (CDN) TOAST UI Editor is available over the CDN powered by [NHN Cloud](https://www.toast.com). You can use the CDN as below. ```html ... ... ... ``` If you want to use a specific version, use the tag name instead of `latest` in the url's path. The CDN directory has the following structure: ``` - uicdn.toast.com/ ├─ editor/ │ ├─ latest/ │ │ ├─ toastui-editor-all.js │ │ ├─ toastui-editor-all.min.js │ │ ├─ toastui-editor-viewer.js │ │ ├─ toastui-editor-viewer.min.js │ │ ├─ toastui-editor-editor.js │ │ ├─ toastui-editor-editor.min.js │ │ ├─ toastui-editor-editor.css │ │ ├─ toastui-editor-editor.min.css │ │ ├─ toastui-editor-viewer.css │ │ └─ toastui-editor-viewer.min.css │ ├─ 2.0.0/ │ │ └─ ... ``` ## Create Your First Editor ### Adding the Wrapper Element You need to add the container element where TOAST UI Editor (henceforth referred to as 'Editor') will be created. ```html ...
                          ... ``` ### Importing the Editor's Constructor Function The editor can be used by creating an instance with the constructor function. To get the constructor function, you should import the module using one of the following ways depending on your environment. #### Using Module Format in Node Environment - ES6 Modules ```javascript import Editor from '@toast-ui/editor'; ``` - CommonJS ```javascript const Editor = require('@toast-ui/editor'); ``` #### Using Namespace in Browser Environment ```javascript const Editor = toastui.Editor; ``` ### Adding CSS Files You need to add the CSS files needed for the Editor. Import CSS files in node environment, and add it to html file when using CDN. #### Using in Node Environment - ES6 Modules ```javascript import '@toast-ui/editor/dist/toastui-editor.css'; // Editor's Style ``` - CommonJS ```javascript require('@toast-ui/editor/dist/toastui-editor.css'); ``` #### Using in Browser Environment by CDN ```html ... ... ... ``` ### Creating Instance You can create an instance with options and call various API after creating an instance. ```js const editor = new Editor({ el: document.querySelector('#editor') }); ``` ![getting-started-01](https://user-images.githubusercontent.com/37766175/121855586-7d576000-cd2e-11eb-9196-0c20270d1221.png) ```js const editor = new Editor({ el: document.querySelector('#editor'), height: '600px', initialEditType: 'markdown', previewStyle: 'vertical' }); ``` ![getting-started-02](https://user-images.githubusercontent.com/37766175/121464762-71e2fc80-c9ef-11eb-9a0a-7b06e08d3ccb.png) The basic options available are: - `height`: Height in string or auto ex) `300px` | `auto` - `initialEditType`: Initial type to show `markdown` | `wysiwyg` - `initialValue`: Initial value. Set Markdown string - `previewStyle`: Preview style of Markdown mode `tab` | `vertical` - `usageStatistics`: Let us know the _hostname_. We want to learn from you how you are using the editor. You are free to disable it. `true` | `false` Find out more options [here](https://nhn.github.io/tui.editor/latest/ToastUIEditor). ## Example You can see the example [here](https://nhn.github.io/tui.editor/latest/tutorial-example01-editor-basic). ================================================ FILE: docs/en/i18n.md ================================================ # 🌏 Internationalization (i18n) TOAST UI Editor provides the ability to set the text set in UI in various languages. There are language files provided by default, you can import each language file and set the code ​​for the language you want to use when you create an instance. ## Files Structure ### Source File (For Contributors) If you want to contribute language files in addition to the [languages ​​supported by default](#supported-languages), add them to the path below. See the [Contributing](#contributing) section for a more detailed contributing process. ``` - tui.editor/apps/editor/src/ - i18n/ - en-us.ts - ko-kr.ts - ... ``` ### Build (For Maintainers) ``` - tui.editor/apps/editor/dist/ - i18n/ - ko-kr.js - ... ``` ### Files Distributed on npm ``` - node_modules/@toast-ui/editor/dist/ - i18n/ - ko-kr.js - ... ``` ### Files Distributed on CDN ``` - uicdn.toast.com/editor/latest/ - i18n/ - ko-kr.js - ko-kr.min.js - ... ``` ## Supported Languages Below is a table of valid language codes for i18n files provided by the TOAST UI Editor. This language code is based on the [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag). When you import a language file, the language code is registered and you can set the code as the option. > Note : The default language is English. The production language file(`en-us.js`) for English is not provided and you don't need to import this file. | Language Name | i18n File | Registered Code | | -------------------------- | --------- | --------------- | | Arabic | ar.js | `ar` | | Chinese (S) | zh-cn.js | `zh-CN` | | Chinese (T) | zh-tw.js | `zh-TW` | | Croatian (Croatia) | hr-hr.js | `hr` \| `hr-HR` | | Czech (Czech Republic) | cs-cz.js | `cs` \| `cs-CZ` | | Dutch (Netherlands) | nl-nl.js | `nl` \| `nl-NL` | | English (United States) | en-us.js | `en` \| `en-US` | | Finnish (Finland) | fi-fi.js | `fi` \| `fi-FI` | | French (France) | fr-fr.js | `fr` \| `fr-FR` | | Galician (Spain) | gl-es.js | `gl` \| `gl-ES` | | German (Germany) | de-de.js | `de` \| `de-DE` | | Italian (Italy) | it-it.js | `it` \| `it-IT` | | Japanese (Japan) | ja-jp.js | `ja` \| `ja-JP` | | Korean (Korea) | ko-kr.js | `ko` \| `ko-KR` | | Norwegian Bokmål (Norway) | nb-no.js | `nb` \| `nb-NO` | | Polish (Poland) | pl-pl.js | `pl` \| `pl-PL` | | Portuguese (Brazil) | pt-br.js | `pt` \| `pt-BR` | | Russian (Russia) | ru-ru.js | `ru` \| `ru-RU` | | Spanish (Castilian, Spain) | es-es.js | `es` \| `es-ES` | | Swedish (Sweden) | sv-se.js | `sv` \| `sv-SE` | | Turkish (Turkey) | tr-tr.js | `tr` \| `tr-TR` | | Ukrainian (Ukraine) | uk-ua.js | `uk` \| `uk-UA` | ## Importing Language Files You must register the language by importing the language file you want to use. The `${fileName}` is corresponding to the 'i18n File' column in [Supported Languages](#supported-languages) (Can be used without an extension). ### ES Modules ```js import '@toast-ui/editor/dist/i18n/${fileName}'; ``` ### CommonJS ```js require('@toast-ui/editor/dist/i18n/${fileName}'); ``` ### Usage CDN You must register the language by including the language file you want to use. The `${fileName}` is corresponding to the 'i18n File' column in [Supported Languages](#supported-languages). It also provides a minified version. ```html ``` ## How to Use > Note : The following examples are based on npm usage. ### Use Case 1 : Basic Usage If you want to set a specific language, you can use the `language` option to create an instance. The value of this option corresponds to the 'Registered Code' column in [Supported Languages](#supported-languages). The default value is `en` and `en-US`. ```js import Editor from '@toast-ui/editor'; // Step 1 : Import language file import '@toast-ui/editor/dist/i18n/ko-kr'; // Step 2 : Set language each editor const foo = new Editor({ // Use default language in English // ... }); const bar = new Editor({ // Use other language // ... language: 'ko-KR', }); ``` ### Use Case 2 : Some Value Overrides Use the `setLanguage` static method to override the value for a specific language code. See [here](https://github.com/nhn/tui.editor/tree/master/apps/editor/src/i18n/en-us.ts) for default values. ```js import Editor from '@toast-ui/editor'; // Step 1 : Import language file import '@toast-ui/editor/dist/i18n/ko-kr'; // Step 2 : Override values of language Editor.setLanguage('en-US', { 'Add row': '[Add Row]', // Default value is 'Add row' }); Editor.setLanguage('ko-KR', { 'Add row': '[로우 추가]', // Default value is '행 추가' }); // Step 3 : Set language each editor const foo = new Editor({ // Use default language in English // ... }); const bar = new Editor({ // Use other language // ... language: 'ko-KR', }); ``` ### Use Case 3 : New Language Registration If the language you want to use is not provided by default, you can register it with the `setLanguage` static method. ```js import Editor from '@toast-ui/editor'; // Step 1 : Register new language Editor.setLanguage('en-GB', { Markdown: '...', WYSIWYG: '...', // ... }); // Step 2 : Set language with new registration code const bar = new Editor({ // ... language: 'en-GB', }); ``` ## Contributing If you want to contribute to providing a different language file, follow this process: ### Step 1 Fork the repository and add the language file to the path below. The name of the language file follows the `${languageCode}-${countryCode}.js` convention. `languageCode` and `countryCode` must be written in lowercase. (e.g. `en-gb.ts`) > Reference : [Nominatim/Country Codes](https://wiki.openstreetmap.org/wiki/Nominatim/Country_Codes) ``` - tui.editor/apps/editor/src/ - i18n/ - en-us.ts - ko-kr.ts - ... ``` ### Step 2 Refer to [this file](https://github.com/nhn/tui.editor/tree/master/src/i18n/en-us.ts) and write each parameter value used when calling the `setLanguage` method. The first parameter is a code value that maps to the language file to register. Code values ​​follow the [`${languageCode}-${countryCode}` convention](https://en.wikipedia.org/wiki/IETF_language_tag). `languageCode` must be in lowercase and `countryCode` in uppercase. ```js // th-th.js // ... Editor.setLanguage('th-TH', { Markdown: '...', WYSIWYG: '...', // ... }); ``` If the following conditions are satisfied, the language code except for the country code can be added. > IETF Language Tag's Reference : Optional script and region subtags are preferred to be omitted when they add no distinguishing information to a language tag. For example, es is preferred over es-Latn, as Spanish is fully expected to be written in the Latin script; ja is preferred over ja-JP, as Japanese as used in Japan does not differ markedly from Japanese as used elsewhere. > > Not all linguistic regions can be represented with a valid region subtag: the subnational regional dialects of a primary language are registered as variant subtags. For example, the valencia variant subtag for the Valencian dialect of Catalan is registered in the Language Subtag Registry with the prefix ca. As this dialect is spoken almost exclusively in Spain, the region subtag ES can normally be omitted. ```js // th-th.js // ... Editor.setLanguage(['th', 'th-TH'], { Markdown: '...', WYSIWYG: '...', // ... }); ``` ## Example You can see the example [here](https://nhn.github.io/tui.editor/latest/tutorial-example16-i18n). ================================================ FILE: docs/en/plugin.md ================================================ # 🧩 Plugins ## What Is Plugin? TOAST UI Editor (henceforth referred to as 'Editor') provides a plugin. Plugin is an extension that can be added as needed. There are a total of 5 plugins provided by the Editor. | Plugin Name | Package Name | Description | | --- | --- | --- | | [`chart`](https://github.com/nhn/tui.editor/tree/master/plugins/chart) | [`@toast-ui/editor-plugin-chart`](https://www.npmjs.com/package/@toast-ui/editor-plugin-chart) | Plugin to render chart | | [`code-syntax-highlight`](https://github.com/nhn/tui.editor/tree/master/plugins/code-syntax-highlight) | [`@toast-ui/editor-plugin-code-syntax-highlight`](https://www.npmjs.com/package/@toast-ui/editor-plugin-code-syntax-highlight) | Plugin to highlight code syntax | | [`color-syntax`](https://github.com/nhn/tui.editor/tree/master/plugins/color-syntax) | [`@toast-ui/editor-plugin-color-syntax`](https://www.npmjs.com/package/@toast-ui/editor-plugin-color-syntax) | Plugin to color editing text | | [`table-merged-cell`](https://github.com/nhn/tui.editor/tree/master/plugins/table-merged-cell) | [`@toast-ui/editor-plugin-table-merged-cell`](https://www.npmjs.com/package/@toast-ui/editor-plugin-table-merged-cell) | Plugin to merge table cells | | [`uml`](https://github.com/nhn/tui.editor/tree/master/plugins/uml) | [`@toast-ui/editor-plugin-uml`](https://www.npmjs.com/package/@toast-ui/editor-plugin-uml) | Plugin to render UML | ## How to Use Plugin Each plugin can be installed and used with npm, or it can be used as provided CDN files. ### Via Package Manager (npm) You can install each plugin using the command, and add the name of the plugin you want to install to `${pluginName}` below. For example, if you install the `chart` plugin, install it as`npm install @toast-ui/editor-plugin-chart`. ```sh $ npm install --save @toast-ui/editor-plugin-${pluginName} # Latest Version $ npm install --save @toast-ui/editor-plugin-${pluginName}@ # Specific Version ``` When installed and used with npm, the list of files that can be imported is as follows: ``` - node_modules/ ├─ @toast-ui/editor-plugin-${pluginName} │ ├─ dist/ │ │ ├─ toastui-editor-plugin-${pluginName}.js │ │ ├─ ... ``` Installed plugins can be imported as shown below depending on the environment. - ES Module ```js import pluginFn from '@toast-ui/editor-plugin-${pluginName}'; ``` - CommonJS ```js const pluginFn = require('@toast-ui/editor-plugin-${pluginName}'); ``` For example, `chart` plugin can be imported as follows: ```js import chart from '@toast-ui/editor-plugin-chart'; ``` ### Via Contents Delivery Network (CDN) Each plugin is available over the CDN powered by [NHN Cloud](https://www.toast.com). ```html ... ... ... ``` If you want to use a specific version, use the tag name instead of `latest` in the url's path. The CDN directory has the following structure: ``` - uicdn.toast.com/ ├─ editor-plugin-${pluginName}/ │ ├─ latest/ │ │ ├─ toastui-editor-plugin-${pluginName}.js │ │ └─ ... │ ├─ 3.0.0/ │ │ └─ ... ``` > Note: Each plugin's CDN file contains all dependencies depending on the situation, or provides different types of bundled files. For more information, please check the each plugin repository. When importing the plugin into the namespace, use the plugin's namespace registered under `toastui.Editor.plugin`. ```js const pluginFn = toastui.Editor.plugin[${pluginName}]; ``` For example, the `chart` plugin imports as follows: ```js const { chart } = toastui.Editor.plugin; ``` ### Using the Plugin in Editor To use a plugin imported from the Editor, use an editor's `plugins` option. You can add each plugin function you imported to this option. The type of `plugins` option is `Array.`. ```js const editor = new Editor({ // ... plugins: [plugin] }); ``` For example, if you add the `chart` and `uml` plugin, you can do something like this: - ES Module ```js import Editor from '@toast-ui/editor'; import chart from '@toast-ui/editor-plugin-chart'; import uml from '@toast-ui/editor-plugin-uml'; const editor = new Editor({ // ... plugins: [chart, uml] }); ``` - CDN ```js const { Editor } = toastui; const { chart, uml } = Editor.plugin; const editor = new Editor({ // ... plugins: [chart, uml] }); ``` If you need an option to use in a plugin function, you can add an array value of the `plugins` option as a tuple. ```js const pluginOptions = { // ... }; const editor = new Editor({ // ... plugins: [[plugin, pluginOptions]] }); ``` ## Creating the User Plugin In addition to the plugins provided by default, users can create and use plugin functions themselves. The method is very easy. It defines the plugin function as shown below to return objects having specified properties. ```ts interface PluginInfo { toHTMLRenderers?: HTMLConvertorMap; toMarkdownRenderers?: ToMdConvertorMap; markdownPlugins?: PluginProp[]; wysiwygPlugins?: PluginProp[]; wysiwygNodeViews?: NodeViewPropMap; markdownCommands?: PluginCommandMap; wysiwygCommands?: PluginCommandMap; toolbarItems?: PluginToolbarItem[]; } const pluginResult: PluginInfo = { // ... } function customPlugin() { // ... return pluginResult; } ``` Like other plugins, it can be used by adding plugin functions defined through the `plugins` option. ```js const editor = new Editor({ // ... plugins: [customPlugin] }); ``` ### Plugin Return Object Let's find out the properties of the objects returned by the plugin. There are a total of 8 properties, as shown below, and user can define only the desired properties for customization. ```ts interface PluginInfo { toHTMLRenderers?: HTMLConvertorMap; toMarkdownRenderers?: ToMdConvertorMap; markdownCommands?: PluginCommandMap; wysiwygCommands?: PluginCommandMap; toolbarItems?: PluginToolbarItem[]; markdownPlugins?: PluginProp[]; wysiwygPlugins?: PluginProp[]; wysiwygNodeViews?: NodeViewPropMap; } ``` #### toHTMLRenderers `toHTMLRenderers` object can change the rendering results of elements when rendered in the Markdown Preview or when converted from Markdown Editor to WYSIWYG Editor. It is same as the [customHTMLRenderer](https://github.com/nhn/tui.editor/blob/master/docs/en/custom-html-renderer.md) option in the editor. **toMarkdownRenderers** `toMarkdownRenderers` object can override markdown text that is converted from WYSIWYG editor to Markdown editor. The function defined in `toMarkdownRenderers` object has 2 parameters: `nodeInfo` and `context`. * `nodeInfo`: WYSIWYG node information when converting from WYSIWYG editor to Markdown editor. * `node`: The information about the target node. * `parent`: The parent node information of the target node. * `index`: The index as child. * `context`: The information needed for converting except `nodeInfo`. * `entering`: This can be seen whether it is a first visit to that node or if it is a visit after traversing all of the child nodes. * `origin`: The function that executes the operation of an original converting function. The function defined in `toMarkdownRenderers` returns the token information needed to convert the result to markdown text. ```ts interface ToMdConvertorReturnValues { delim?: string | string[]; rawHTML?: string | string[] | null; text?: string; attrs?: Attrs; } ``` * `delim`: Defines symbols to be used in markdown text. It is used when it can be converted to multiple symbols, such as `*` and `-` on a list of markdown bullet list. * `rawHTML`: This text is required when converting a node to an HTML node (HTML text) in Markdown. * `text`: Text to be shown in Markdown. * `attrs`: Properties to use when converting a node to markdown text. For example, whether the task list is checked or not, or the url of the image node. **Example** ```ts return { toHTMLRenderers: { // ... tableCell(node: MergedTableCellMdNode, { entering, origin }) { const result = origin!(); // ... return result; }, }, toMarkdownRenderers: { // ... tableHead(nodeInfo) { const row = (nodeInfo.node as ProsemirrorNode).firstChild; let delim = ''; if (row) { row.forEach(({ textContent, attrs }) => { const headDelim = createTableHeadDelim(textContent, attrs.align); delim += `| ${headDelim} `; // ... }); } return { delim }; }, }, }; ``` The code above is an example of a merge table cell plugin. The return result of `tableCell` node defined in `toHTMLRenderers` is used for converting to Markdown Preview and WYSIWYG Editor, while result of `tableHead` nodes defined in `toMarkdownRenderers` are used for converting to Markdown Editor. ![image](https://user-images.githubusercontent.com/37766175/121026660-4c80a380-c7e1-11eb-9d36-65425b6944da.gif) #### markdownCommands, wysiwygCommands Plugin allows adding of markdown and WYSIWYG commands using `markdownCommands` and `wysiwygCommands` options. Each command function has 3 parameters: `payload`, `state`, and `dispatch`, which can be used to control the internal operation of the editor based on [Prosemirror](https://prosemirror.net/). * `payload`: This is a `payload` that is needed to execute commands. * `state`: An instance indicating the editor's internal state, which is the same as [prosemirror-state](https://prosemirror.net/docs/ref/#state). * `dispatch`: If you want to change the contents of an editor through command execution, you have to run the `dispatch` function. It is same as [dispatch](https://prosemirror.net/docs/ref/#view.EditorView.dispatch) function of Prosemirror. If a change occurs in the editor's content by executing a command function, it must return `true`. In the opposite case, `false` must be returned. ```js return { markdownCommands: { myCommand: (payload, state, dispatch) => { // ... return true; }, }, wysiwygCommands: { myCommand: (payload, state, dispatch) => { // ... return true; }, }, }; ``` If you define and return a command function in a plugin, as shown in the example code above, that command can be used in the editor. #### toolbarItems You can also register an editor's toolbar item from the plugin. ```js return { // ... toolbarItems: [ { groupIndex: 0, itemIndex: 3, item: toolbarItem, }, ], }; ``` Like the code above, you can set which items to add to the `toolbarItems` array. Each option object has 3 properties: `groupIndex`, `itemIndex`, and item`. * `groupIndex`: Toolbar group index to add toolbar item. * `itemIndex`: Toolbar item index in group. * `item`: Toolbar Item. It is the same form as the object used in [toolbar customization](https://github.com/nhn/tui.editor/blob/master/docs/en/toolbar.md). If the `toolbarItems` option is set, as in the example code, the toolbar item will be added as the fourth index of the first toolbar group. #### markdownPlugins, wysiwygPlugins The editor use Prosemirror internally. Prosemirror provides its own plugin system. These Prosemirror plugins can also be defined in order to control out editor's internal state. In most cases, these options are not necessary, but are often necessary. For example, code syntax highlighting plugin are used to highlight code displayed in the WYSIWYGs editor `codeBlock`. ```js return { wysiwygPlugins: [() => codeSyntaxHighlighting(context, prism)], }; ``` The method of using this option object is the same as the plugin definition method in Prosemirror, so see [here] (https://prosemirror.net/docs/ref/#state.Plugin). #### wysiwygNodeViews Markdown Editor's contents is plain text, but WYSIWYG Editor's content consists of specific nodes. These nodes can be customized to add attribute or class using the `customHTMLRenderer` option. However, there is a limit to `customHTMLRenderer` option if you want to control something by adding event handker or having more complex interactions. In this case, the plugin's `wysiwygNodeViews` option allows customization of nodes that are rendered in the WYSIWYG Editor. This option will also be unnecessary in most cases. Like the `wysiwygPlugins` property, the `wysiwygNodeViews` property is also used in code syntax highlighting plugin. ```js return { wysiwygNodeViews: { codeBlock: createCodeSyntaxHighlightView(registerdlanguages), }, }; ``` The method of using this option object is the same as the `nodeView` definition method in Prosemirror, so see [here] (https://prosemirror.net/docs/ref/#view.NodeView). ### 'context' parameter of plugin function Plugin functions can use some information with `context` parameters to define the various properties described above. The `context` parameter contains the following properties. * `eventEmitter`: It is the same as `eventEmitter` in an editor. It is used to communicate with the editor. * `usageStatistics`: It decides whether to collect the plugin as GA for `@toast-ui/editor`. * `i18n`: It is an instance for adding i18n. * `pmState`: Some modules of [prosmirror-state] (https://prosemirror.net/docs/ref/#state). * `pmView`: Some modules of [prosemirror-view](https://prosemirror.net/docs/ref/#view). * `pmModel`: Some modules of [prosemirror-model](https://prosemirror.net/docs/ref/#model). ## Example Examples can be found [here](https://nhn.github.io/tui.editor/latest/tutorial-example13-creating-plugin) and in the [plugin package](https://github.com/nhn/tui.editor/tree/master/plugins). ================================================ FILE: docs/en/toolbar.md ================================================ # 🛠 Toolbar Typically, the editor can use shortcuts or toolbar to enter specific text or nodes. In particular, in WYSIWYG editors, where no specific textual syntax exists, the role of the toolbar is important because most of the operation are done through the toolbar. The TOAST UI Editor (henceforth referred to as 'Editor') also provides a toolbar as the default UI, as well as options and APIs for customization. ## Toolbar Option The editor provides a total of 16 toolbar items, including bold, italic, and strike. Unless otherwise specified, the default toolbar option is shown below. ```js const options = { // ... toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], ['table', 'image', 'link'], ['code', 'codeblock'], ['scrollSync'], ], } ``` As you can see in the example code, the toolbar option in the editor is defined in 2D array format. First, each toolbar group is defined as an array, and the toolbar items within the group are designated as items of the array. Each item is rendered in a group in the order in which it is defined, and the toolbar group is rendered separated by the `|` symbol. ![image](https://user-images.githubusercontent.com/37766175/120914229-a137f780-c6d7-11eb-8112-b14a48f8374f.png) If you want to change the configuration of the default toolbar, you can change it by setting the `toolbarItems` option. ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ ['heading', 'bold'], ['ul', 'ol', 'task'], ['code', 'codeblock'], ], }); ``` The above example code is executed as shown below. ![image](https://user-images.githubusercontent.com/37766175/120914344-a47fb300-c6d8-11eb-85cd-857047e8e220.png) ## Toolbar Button Customizing The example seen above is actually just a combination of the basic toolbar items. Then what should user do if they want to create and add a toolbar button? In this case, two main types of options can be customized. ### Button Element Customizing First, there is a way to customize the toolbar button UI provided by the editor. This method is that overriding only the icon, tooltip, or popup operation of the button embedded in the editor. This option consists of the following interfaces: | Name | Type | Description | | --- | --- | --- | | `name` | string | A unique name for the toolbar item and must be specified as required. | | `tooltip` | string | Optional value, which defines the tooltip text to show when mouse is over on the toolbar item. | | `text` | string | Optional value, define if the toolbar button element has text to show. | | `className` | string | Optional value, defines the class to be applied to the toolbar item. | | `style` | Object | Optional value, defines the style to be applied to the toolbar item. | | `command` | string | Optional value, defines the command you want to execute when you click the toolbar button. It has an exclusive relationship with `popup` option. | | `popup` | PopupOptions | Optional value, defines if you want to show the popup when you click the toolbar button. This is an exclusive relationship with the `command` option.. | ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', command: 'bold', text: '@', className: 'toastui-editor-toolbar-icons', style: { backgroundImage: 'none', color: 'red' } }] ], // ... }); ``` The toolbar item is rendered with `className` and `style` options. The item has `@` text node and executes `bold` commands when clicked. ![image](https://user-images.githubusercontent.com/37766175/120915118-ea3e7a80-c6dc-11eb-86cc-5229ed36c4e8.gif) ### popup Option When you click the button, you might want to show the popup. In this case, you can use the `popup` option that you saw above. The interface of the `popup` option is shown below. | Name | Type | Description | | --- | --- | --- | | `body` | HTMLElement | Defines the popup DOM node to be rendered. | | `className` | string | Optional value, defines the class to be applied to the popup. | | `style` | Object | Optional value, defines the style to be applied to the popup. | The popup node is automatically diplayed on the screen when clicked on the toolbar, and disappears automatically when clicked on another area. Let's take a look at the color picker plugin code of the editor. ```js const container = document.createElement('div'); // ... const button = createApplyButton(i18n.get('OK')); button.addEventListener('click', () => { // ... eventEmitter.emit('command', 'color', { selectedColor }); eventEmitter.emit('closePopup'); }); container.appendChild(button); const colorPickerToolber = { name: 'color', tooltip: 'Text color', className: 'some class', popup: { className: 'some class', body: container, style: { width: 'auto' }, }, }; ``` In the example code, the popup element is in a variable `container`. This element has a button element, and when clicked, it executes the `color` command and closes the itself. The popup that user defined can be communicated with editor using `eventEmitter`. In order to execute the command, you can trigger `command` event, and if you want to close the popup, you can trigger `closePopup` event. The defined color picker toolbar item works well with popup as shown below. ![image](https://user-images.githubusercontent.com/37766175/120915630-b6b11f80-c6df-11eb-8094-b264ca9312a1.gif) ## Toolbar Item Customizing If you want to create a toolbar item without using the default button UI as described above, you need to configure the `el` option as shown below. ```js const myCustomEl = document.createElement('span'); myCustomEl.textContent = '😎'; myCustomEl.style = 'cursor: pointer; background: red;' myCustomEl.addEventListener('click', () => { editor.exec('bold'); }); const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', el: myCustomEl, }] ], // ... }); ``` The element to be rendered must be specified as an `el` option. In this case, style, event handler, and class must be set, as the option is to create a complete DOM element. If you run the example code above, it will work as follows. ![iamge](https://user-images.githubusercontent.com/37766175/120915883-3e4b5e00-c6e1-11eb-8f44-95e6d31f41e7.gif) ## Change Toolbar Item State In the editor, you can activate which node is based on the current cursor's position by changing the style of the toolbar element. For example, if the cursor is located on a `strong` node that displays bold text, an element of the `bold` toolbar item is activated as follows. ![image](https://user-images.githubusercontent.com/37766175/124843166-49d5c180-dfcc-11eb-9633-ae1e61d612ea.gif) If you want to change the state of a customized toolbar element like the example above, you need to configure the `state` option. ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', command: 'bold', text: '@', className: 'toastui-editor-toolbar-icons', style: { backgroundImage: 'none', color: 'red' }, // If it is located on the `strong` node, the `active` CSS class is added to this toolbar element. state: 'strong', }] ], // ... }); ``` If the toolbar button is activated according to `state`, the `active` CSS class will be added and you can specify the style that you want using this class. ### `state` list The state of the toolbar element can only be changed by using the state value below. * `heading`: Heading * `strong`: Bold * `emph`: Italic * `strike`: Strike * `thematicBreak`: Horizontal Line * `blockQuote`: Quotes * `bulletList`: Bullet List * `orderedList`: Ordered List * `taskList`: Task List * `table`: Table * `code`: Inline Code * `codeBlock`: Code Block ### `onUpdated()` option If a toolbar element is created with the `el` option without using the default button UI, the state can be changed by configuring the `onUpdated` option. Because there is a limit to directly manipulating toolbar elements that are customized, it is going to provide the `onUpdated` callback options. ```js const myCustomEl = document.createElement('span'); myCustomEl.textContent = '😎'; myCustomEl.style = 'cursor: pointer; background: red;' myCustomEl.addEventListener('click', () => { editor.exec('bold'); }); const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', el: myCustomEl, state: 'strong', onUpdated({ active, disabled }) { if (active) { myCustomEl.style.background = 'green'; } else { myCustomEl.style.background = ''; } } }] ], // ... }); ``` The `onUpdated()` function passes the object that represent `active` and `disabled` state as parameter. This parameter allows you to add styling to an element or define the desired operation. ## Example You can see the example [here](https://nhn.github.io/tui.editor/latest/tutorial-example15-customizing-toolbar-buttons) ================================================ FILE: docs/en/viewer.md ================================================ # 👀 Viewer ## What Is Viewer? TOAST UI Editor (henceforth referred to as 'Editor') provides the **viewer** in case you want to show _Markdown_ content without loading the Editor. The Viewer is much **lighter** than the Editor. ## Creating Viewer The method of creating the Viewer is similar to that of the Editor. > Ref. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) ### Adding Wrapper Element You need to add the container element where the Viewer will be created. ```html ...
                          ... ``` ### Importing Viewer's Constructor Function The Viewer can be used by creating an instance with the constructor function. To get the constructor function, you should import the module using one of the following ways depending on your environment. #### Using Module Format in Node Environment - ES6 Modules ```javascript import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer'; ``` - CommonJS ```javascript const Viewer = require('@toast-ui/dist/toastui-editor-viewer'); ``` #### Using Namespace in Browser Environment ```javascript const Viewer = toastui.Editor; ``` Note that the CDN file of the Viewer should use the following: ```html ... ... ... ``` ### Adding CSS Files You need to add the CSS files needed for the Viewer. Import CSS files in node environment, and add it to html file when using CDN. #### Using in Node Environment - ES6 Modules ```javascript import '@toast-ui/editor/dist/toastui-editor-viewer.css'; ``` - CommonJS ```javascript require('@toast-ui/editor/dist/toastui-editor-viewer.css'); ``` #### Using in Browser Environment by CDN ```html ... ... ... ``` ### Creating Instance You can create an instance with options and call various API after creating an instance. ```js const viewer = new Viewer({ el: document.querySelector('#viewer'), height: '600px', initialValue: '# hello' }); ``` ![viewer-01](https://user-images.githubusercontent.com/37766175/121862304-a3ccc980-cd35-11eb-92c8-02b0e6fcf3cf.png) The basic options available are: - `height`: Height in string or auto ex) `300px` | `auto` - `initialValue`: Initial value. Set Markdown string Find out more options [here](https://nhn.github.io/tui.editor/latest/ToastUIEditorViewer). ## Another Way to Create Viewer Be careful not to load both an editor and a viewer at the same time because an editor already contains a viewer function, you can initialize with `Editor.factory()` of an editor and set the `viewer` option to value `true` in order to make the a viewer. ```js import Editor from '@toast-ui/editor'; const viewer = Editor.factory({ el: document.querySelector('#viewer'), viewer: true, height: '500px', initialValue: '# hello' }); ``` ## Example You can see the example [here](https://nhn.github.io/tui.editor/latest/tutorial-example04-viewer). ================================================ FILE: docs/en/widget.md ================================================ # 📱 Widget Node When you type a specific key in the editor, you can display a suggestion popup, or a link node as a specific widget node. The TOAST UI Editor (henceforth referred to as 'Editor') provides options and APIs for this feature. ## Popup Widget When editing content in the editor, you may want to show the suggestion popup at the current cursor position. At this time, the `addWidget` API can be used to float the DOM node that is desired on the editor. This node does not affect other contents being edited, and is **temporarily added**. In other words, if you enter text or change focus, it disappears. The API signature is as follows. ```ts addWidget(node: Node, style: WidgetStyle, pos?: EditorPos) ``` | Parameter | Type | Description | | --- | --- | --- | | `node` | Node | DOM node to be added as widget | | `style` | 'top' \| 'bottom' | Determines whether to add widget above or below the specified position. | | `pos` | EditorPos | Set where the widget will be added. This is optional value, if not set, adds widget to the current cursor position. | ```js const popup = document.createElement('ul'); // ... editor.addWidget(popup, 'top'); ``` When the above code is executed, a `popup` node is added as shown below. ![image](https://user-images.githubusercontent.com/37766175/120617182-d6a0d300-c494-11eb-8fb9-58926c60e8b7.png) If you want to show the widget when typing the specific key, `keyup` event is useful. ```js editor.on('keyup', (editorType, ev) => { if (ev.key === '@') { const popup = document.createElement('ul'); // ... editor.addWidget(popup, 'top'); } }) ``` ### Inline Widget Node We looked at how to temporarily add popup widget node depending on specific case. Then what can you do if you want to add a mention node by clicking on a specific item in the popup widget? Because Markdown Editor is a text-based editor, such mention node cannot be added. In addition, WYSIWYG editor does not support the mention node internally as well, so it cannot be added. The editor provides a `widgetRules` option for users who want to add *inline widget node* such as mention node. If the text conforms to the rules set for the `widgetRules` option, the node is rendered as an inline widget node in the editor. **Inline widget node is inserted into the editor as content unlike popup widget, affecting the position of other nodes**. ```js const reWidgetRule = /\[(@\S+)\]\((\S+)\)/; const editor = new Editor({ el: document.querySelector('#editor'), widgetRules: [ { rule: reWidgetRule, toDOM(text) { const rule = reWidgetRule; const matched = text.match(rule); const span = document.createElement('span'); span.innerHTML = `${matched[1]}`; return span; }, }, ], }); ``` As shown in the example code, `widgetRules` has each rule in an array format, and each rule consists of `rule` and `toDOM` properties. * `rule`: The value should be the regular expression, and the text that matches this regular expression is replaced with a widget node and rendered. * `toDOM`: Defines the DOM node of the widget node to be rendered. When text matches to the rules of `widgetRules` is entered, it is replaced by an inline widget node as shown in the image below. ![image](https://user-images.githubusercontent.com/37766175/120621226-a6f3ca00-c498-11eb-9355-0275fd3bdbdb.gif) ### `insertText()`, `replaceSelection()` API You can replace it with an inline widget node by typing text that matches the widget rules directly, but most of the time you want to insert an inline widget node, such as a mention node, by clicking on a specific item in a popup widget. In such cases, `inserText()` and `replaceSelection()` APIs can be used to insert an inline widget node when an item in a popup widget is clicked. ```js ul.addEventListener('mousedown', (ev) => { const text = ev.target.textContent.replace(/\s/g, '').replace(/😎/g, ''); const [start, end] = editor.getSelection(); editor.replaceSelection(`[@${text}](${text})`, [start[0], start[1] - 1], end); }); ``` In the example code, the position calculated based on the current cursor position through `getSelection()` API was passed to `replaceSelection()` API because `@` character should be replaced with widget node. As a result, you can see the `@` character replaced by an inline widget node when you click an item in the popup widget as shown in the image below. ![image](https://user-images.githubusercontent.com/37766175/120624280-81b48b00-c49b-11eb-9896-432120c27389.gif) ================================================ FILE: docs/ko/README.md ================================================ # 📄 Documents ## 튜토리얼 - [🚀 시작하기](https://github.com/nhn/tui.editor/blob/master/docs/ko/getting-started.md) - [👀 뷰어](https://github.com/nhn/tui.editor/blob/master/docs/ko/viewer.md) - [🧩 Plugins](https://github.com/nhn/tui.editor/blob/master/docs/ko/plugin.md) - [🌏 Internationalization (i18n)](https://github.com/nhn/tui.editor/blob/master/docs/ko/i18n.md) - [🎨 Custom HTML Renderer](https://github.com/nhn/tui.editor/blob/master/docs/ko/custom-html-renderer.md) - [🔩 커스텀 블록](https://github.com/nhn/tui.editor/blob/master/docs/ko/custom-block.md) - [🔗 자동 링크 확장](https://github.com/nhn/tui.editor/blob/master/docs/ko/extended-autolinks.md) - [🛠 툴바](https://github.com/nhn/tui.editor/blob/master/docs/ko/toolbar.md) - [📱 위젯](https://github.com/nhn/tui.editor/blob/master/docs/ko/widget.md) ### 마이그레이션 가이드 - [✈️ v3.0 마이그레이션 가이드](https://github.com/nhn/tui.editor/blob/master/docs/v3.0-migration-guide-ko.md) ## Etc - [📌 Commit Message Convention](https://github.com/nhn/tui.editor/blob/master/docs/COMMIT_MESSAGE_CONVENTION.md) - [📌 Contributing](https://github.com/nhn/tui.editor/blob/master/CONTRIBUTING.md) - [📌 Code of conduct](https://github.com/nhn/tui.editor/blob/master/CODE_OF_CONDUCT.md) ================================================ FILE: docs/ko/custom-block.md ================================================ # 🔩 커스텀 블록 노드와 HTML 노드 TOAST UI Editor(이하 '에디터'라고 명시)는 [CommonMark](https://spec.commonmark.org/0.29/) 스펙을 준수하며, 추가로 [GFM](https://github.github.com/gfm/) 스펙도 지원한다. 하지만 만약 CommonMark나 GFM에서 지원하지 않는 특정 문법을 사용하고 싶다면 어떨까? 예를 들어 마크다운에서 [LaTeX](https://www.latex-project.org/) 문법을 사용하거나 차트 같은 요소를 렌더링하고 싶을 수 있다. 에디터에서는 이러한 사용성을 위해 사용자만의 **커스텀 블록 노드**를 정의할 수 있는 옵션을 제공한다. ## 커스텀 블록 노드 에디터는 마크다운 AST(Abstract Syntax Tree)를 HTML 문자열로 변환할 때 커스터마이징할 수 있는 `customHTMLRenderer` 옵션을 제공한다. `customHTMLRenderer` 옵션을 사용하면 `table`, `heading`처럼 CommonMark나 GFM에서 지원하는 노드의 렌더링 결과를 커스터마이징할 수 있다. 커스텀 블록 노드 역시 이 `customHTMLRenderer` 옵션을 사용하여 정의할 수 있다. 다음 코드는 LaTex 문법을 지원하는 라이브러리인 KaTeX를 사용하여 수식을 렌더링하는 커스텀 블록 노드를 정의한 것이다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { latex(node) { const generator = new latexjs.HtmlGenerator({ hyphenate: false }); const { body } = latexjs.parse(node.literal, { generator }).htmlDocument(); return [ { type: 'openTag', tagName: 'div', outerNewLine: true }, { type: 'html', content: body.innerHTML }, { type: 'closeTag', tagName: 'div', outerNewLine: true } ]; }, } }); ``` `customHTMLRenderer` 옵션에 `latex` 함수 프로퍼티를 작성하였고 이 함수에서는 렌더링 될 HTML을 토큰 형태로 반환한다. 마크다운 노드를 커스터마이징을 할 때와 거의 동일한 형태로 옵션을 지정하기 때문에 쉽게 사용할 수 있다. 위의 코드는 마크다운 에디터에서 다음처럼 렌더링된다. ![image](https://user-images.githubusercontent.com/37766175/120983159-65bf2b00-c7b4-11eb-84af-30c38e832585.png) 위의 이미지에서 볼 수 있듯이 마크다운 에디터에서 커스텀 블록 노드를 사용하기 위해서는 `$$` 기호로 감싸진 블록 내에 텍스트를 입력해야 한다. `$$` 기호로 감싸진 블록은 에디터에서 커스텀 블록 노드로 파싱된다. 또한 어떠한 커스텀 블록 노드인지 나타내기 위해 `$$` 기호 다음에 반드시 `customHTMLRenderer` 옵션에서 정의한 노드 이름을 작성해야 한다. ```js // $$ 기호 뒤에 옵션에서 정의한 노드 이름을 반드시 명시해야 한다. $$latex \documentclass{article} \begin{document} $ f(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \, d\xi $ \end{document} $$ ``` ### 위지윅 정의된 커스텀 블록 노드는 위지윅 에디터에서 아래 이미지처럼 동작한다. ![image](https://user-images.githubusercontent.com/37766175/120984395-96539480-c7b5-11eb-8e57-2f43082f345f.gif) 위지윅 에디터에서 커스텀 블록 노드는 마크다운 프리뷰와 동일한 모습으로 렌더링되며, 노드를 클릭하여 선택했을 때 나오는 편집 버튼을 통해 내용을 변경할 수 있다. 커스텀 블록 노드도 결국 특정 텍스트를 기준으로 파싱되는 것이기 때문에 위지윅 에디터에서의 편집도 텍스트를 기준으로 한다. 이는 일반적인 위지윅 에디터와는 다른 동작이지만 **TOAST UI Editor는 마크다운을 기반으로 위지윅 에디터를 지원**하기 때문에 이러한 동작이 더 이상적이다. ## HTML 노드 CommonMark에서는 `<`과 `>` 문자를 사용하여 기본적으로 지원하지 않는 노드를 HTML 문자열 형태로 작성할 수 있다. ([CommonMark Raw HTML Spec 참조](https://spec.commonmark.org/0.29/#raw-html)) 에디터의 마크다운 에디터에서도 이러한 스펙을 준수하기 때문에 HTML 문자열은 마크다운 프리뷰에서 올바르게 렌더링 된다. ![image](https://user-images.githubusercontent.com/37766175/120987131-44f8d480-c7b8-11eb-971f-0b4ecb59e112.png) ### 위지윅 하지만 안타깝게도 위지윅 에디터에서는 HTML 노드를 제대로 렌더링할 수 없다. 에디터는 내부적으로 위지윅 에디터에서 기본으로 지원하는 노드를 추상화된 모델 객체로 관리하고 있다. 위지윅 에디터에서 지원하는 노드란 CommonMark와 GFM 에서 지원하는 노드(`heading`, `list`, `strike` 등)와 커스텀 블록 노드를 의미한다. ![image](https://user-images.githubusercontent.com/37766175/120989247-4c20e200-c7ba-11eb-8420-7ff5726592cf.gif) 위 예시 이미지의 `iframe` 노드는 위지윅 에디터에서 기본적으로 지원하는 노드가 아니다. 그렇기 때문에 `iframe` 노드를 위지윅 에디터에서도 사용하고 싶다면 `customHTMLRenderer` 옵션을 사용하여 추가 설정을 해야 한다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { htmlBlock: { iframe(node) { return [ { type: 'openTag', tagName: 'iframe', outerNewLine: true, attributes: node.attrs }, { type: 'html', content: node.childrenHTML }, { type: 'closeTag', tagName: 'iframe', outerNewLine: true }, ]; }, } }, }); ``` HTML 노드는 `customHTMLRenderer.htmlBlock` 프로퍼티에 정의한다. 위에서 설명한 커스텀 블록 노드와 구분하기 위해 `htmlBlock` 프로퍼티 내에서 추가할 HTML 노드의 컨버팅 함수를 정의한다. 예제 코드를 실행하면 아래 이미지처럼 위지윅에서도 `iframe` 노드가 올바르게 렌더링된다. ![image](https://user-images.githubusercontent.com/37766175/120989209-40352000-c7ba-11eb-9112-047a0af4f9d6.gif) 만약 인라인 HTML 노드를 사용하고 싶다면, `customHTMLRenderer.htmlInline` 프로퍼티에 정의한다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { htmlBlock: { iframe(node) { return [ { type: 'openTag', tagName: 'iframe', outerNewLine: true, attributes: node.attrs }, { type: 'html', content: node.childrenHTML }, { type: 'closeTag', tagName: 'iframe', outerNewLine: true }, ]; }, }, htmlInline: { big(node, { entering }) { return entering ? { type: 'openTag', tagName: 'big', attributes: node.attrs } : { type: 'closeTag', tagName: 'big' }; }, }, }, }); ``` ================================================ FILE: docs/ko/custom-html-renderer.md ================================================ # 🎨 Custom HTML Renderer TOAST UI Editor(이하 '에디터'라고 명시)는 마크다운 텍스트를 HTML 문자열로 변환하기 위해 `ToastMark`라는 자체 마크 다운 파서를 사용한다. `ToastMark`는 두 단계로 마크다운 텍스트를 변환한다. 1. 마크다운 텍스트를 AST(Abstract Syntax Tree)로 변환한다. 2. 변환된 AST를 순회하며 HTML 문자열을 생성한다. 첫 번째 단계에서 AST를 생성할 때 커스터마이징 옵션이나 API를 제공하는 것은 파싱 과정 자체를 사용자가 이해해야 하므로 어려운 일이 될 것이다. 하지만 완성된 AST를 사용하여 HTML 문자열로 변환할 때에는 HTML 토큰화에 대해서만 이해하면 되기 때문에 사용자가 커스터마이징하기 어렵지 않다. 그렇기 때문에 에디터에서는 두 번째 단계(AST를 사용하여 HTML 문자열로 변환)에서 커스터마이징할 수 있는 옵션을 사용자에게 제공한다. 이 옵션은 마크다운 프리뷰뿐만 아니라 마크다운에서 위지윅 에디터로 컨버팅할 때에도 적용이 된다. 다만 아래처럼 내부적인 컨버팅 로직은 다르게 동작한다. * 마크다운 프리뷰: 커스터마이징 옵션에 정의한 **HTML 토큰은 마크다운 HTML 문자열을 생성할 때 사용된다**. * 마크다운 → 위지윅 컨버팅: 커스터마이징 옵션에 정의한 **HTML 토큰은 위지윅의 노드로 변환될 때 사용된다**. 이 때 위지윅 노드는 DOM 노드가 아닌 에디터 내부적으로 관리하는 추상화된 모델 객체이다. ## 기본 사용 방법 에디터에서는 `customHTMLRenderer` 옵션으로 HTML 문자열 변환 과정을 커스터마이징할 수 있다. 이 옵션은 key-value 형태의 객체이며, 객체의 키는 AST의 노드 타입, 값은 AST 노드를 HTML 토큰으로 변환하여 반환하는 함수이다. 다음 코드는 `customHTMLRenderer` 옵션을 사용하는 기본 예시이다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { heading(node, context) { return { type: context.entering ? 'openTag' : 'closeTag', tagName: 'div', classNames: [`heading-'${node.level}`] } }, text(node, context) { const strongContent = node.parent.type === 'strong'; return { type: 'text', content: strongContent ? node.literal.toUpperCase() : node.literal } }, linebreak(node, context) { return { type: 'html', content: '\n
                          \n' } } } }); ``` 만약 마크다운 텍스트가 아래와 같다면, ```markdown ## Heading Hello World ``` 다음과 같이 변환된다. ```html
                          HEADING

                          Hello

                          World

                          ``` ## HTML 토큰 위의 기본 예시에서 볼 수 있듯이 각 함수는 HTML 문자열을 직접 반환하는 것이 아니라 **토큰 객체**를 반환한다. 토큰 객체는 `ToastMark` 내부 모듈에 의해 HTML 문자열로 자동 변환된다. HTML 텍스트 대신 토큰을 사용하는 이유는 구조적인 정보를 담아 기본 동작을 재정의하고 재사용할 수 있기 때문이다. 토큰 객체에 사용할 수 있는 타입은 `openTag`, `closeTag`, `text`, `html` 4가지가 있다. ### openTag `openTag` 토큰은 열린 태그 문자열을 나타낸다. `openTag` 토큰은 HTML 문자열을 생성하기 위해 `tagName`, `attributes`, `classNames` 프로퍼티를 가지고 있다. 다음 코드처럼 `openTag` 객체 옵션을 지정한다면, ```js { type: 'openTag', tagName: 'a', classNames: ['my-class1', 'my-class2'] attributes: { target: '_blank', href: 'http://ui.toast.com' } } ``` 아래와 같은 HTML 문자열로 변환된다. ```html ``` 만약 `
                          `과 `
                          `처럼 자체적으로 닫기 태그를 지정하고 싶다면, `selfClose` 옵션을 사용하면 된다. ```js { type: 'openTag', tagName: 'br', classNames: ['my-class'], selfClose: true } ``` ```html
                          ``` ### closeTag `closeTag` 토큰은 닫는 태그 문자열을 나타낸다. `closeTag` 토큰에서는 `tagName` 프로퍼티만 지정하면 된다. ```js { type: 'closeTag', tagName: 'a' } ``` ```html
                          ``` ### text `text` 토큰은 일반 텍스트 문자열을 나타낸다. 이 토큰에는 `content` 프로퍼티만 존재하며 이 값은 이스케이프 처리되어 HTML 텍스트로 사용된다. ```js { type: 'text', content: '
                          ' } ``` ```html <br /> ``` ### html `html` 토큰은 HTML 문자열을 의미한다. `text` 토큰과 마찬가지로 `content` 프로퍼티만 가지지만, 이스케이프 처리 없이 그대로 사용된다. DOM의 `innerHTML` API와 거의 동일한 역할을 한다고 이해하면 된다. ```js { type: 'html', content: '
                          ' } ``` ```html
                          ``` ## Node 옵션으로 지정한 컨버팅 함수의 첫 번째 매개변수는 `Node` 객체이다. 이 객체는 `ToastMark`에 의해 생성된 AST(Abstract Syntax Tree)의 주요 구성 요소이다. 모든 노드는 `parent`, `firstChild`, `lastChild`, `prev`, `next` 등 트리를 구성하기 위한 공통의 속성을 가지고 있다. 또한 각 노드는 타입에 따른 고유한 프로퍼티가 있다. 예를 들어 `heading` 노드는 헤딩 요소의 레벨을 나타내는 `level` 프로퍼티가 있고, `link` 노드에는 링크 URL을 나타내는 `destination` 프로퍼티가 있다. 아래 예시를 보면 마크다운 텍스트가 AST로 변환되었을 때 어떠한 구조인지 파악할 수 있다. ```md ## TOAST UI **Hello** World! ``` ```js { type: 'document', firstChild: { type: 'heading', level: 2, parent: //[document node], firstChild: type: 'text', parent: //[heading node], literal: 'TOAST UI' }, next: { type: 'paragraph', parent: //[document node], firstChild: { type: 'strong', parent: //[paragraph node], firstChild: { type: 'text', parent: //[strong node], literal: 'Hello' }, next: { type: 'text', parent: //[paragraph node], literal: 'World !' } } } } } ``` AST를 구성하는 각 노드의 타입은 [이 코드](https://github.com/nhn/tui.editor/blob/master/libs/toastmark/src/commonmark/node.ts)에서 확인할 수 있다. ## Context 에디터가 AST를 사용하여 HTML 문자열을 생성할 때에는 전위순회 방식으로 모든 노드를 탐색한다. 노드를 방문할 때마다 노드의 타입과 동일한 키 값을 가진 컨버팅 함수가 호출되며, `context` 객체는 컨버팅 함수의 두 번째 매개변수로 주어진다. ### entering 에디터에서 [이 함수](https://github.com/nhn/tui.editor/blob/master/libs/toastmark/src/commonmark/node.ts#L38)에 정의된 노드 타입들은 AST의 순회 중 두 번씩 방문한다. 첫 번째는 해당 노드로 순회를 시작할 때 방문하며, 두 번째는 모든 자식 노드들을 순회한 후 방문한다. `context` 객체의 `entering` 프로퍼티를 사용하여 컨버팅 함수가 호출되는 시점을 알 수 있다. 다음 코드는 `entering` 프로퍼티를 사용하는 예시이다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { heading({ level }, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: `h${level}`, } }, text({ literal }) { return { type: 'text', content: node.literal } } } }); ``` `heading` 노드의 컨버팅 함수는 `context.entering` 프로퍼티를 사용하여 반환할 토큰 객체의 타입을 결정한다. 값이 `true`일 때 `openTag`을 반환하여, 그렇지 않으면 `closeTag`를 반환한다. `text` 컨버팅 함수는 리프 노드이기 때문에 한 번만 호출되므로 `entering` 속성을 사용할 필요가 없다. 만약 다음 마크다운 텍스트를 에디터에 입력했을 때, ```markdown # TOAST UI ``` `ToastMark`가 생성한 AST는 아래와 같다. (편의상 필수 프로퍼티만 간략하게 나타내었다.) ```js { type: 'document', firstChild: { type: 'heading', level: 1, firstChild: { type: 'text', literal: 'TOAST UI' } } } ``` AST 순회를 모두 마치면 지정한 컨버팅 함수의 결과로 반환된 토큰들이 아래와 같은 배열 형태로 저장된다. ```js [ { type: 'openTag', tagName: 'h1' }, { type: 'text', content: 'TOAST UI' }, { type: 'closeTag', tagName: 'h1' } ] ``` 최종적으로 에디터 내부에서 토큰 배열을 사용하여 HTML 문자열로 생성한다. ```html

                          TOAST UI

                          ``` ### origin() 만약 `customHTMLRenderer`로 지정한 함수 안에서 원래 기존의 컨버팅 함수를 사용하고 싶다면, `origin()` 함수를 호출하여 사용할 수 있다. 예를 들어 `link` 노드에 대해 아래와 같은 HTML 토큰을 반환하는 기존의 컨버팅 함수가 있다고 가정해보자. #### `entering: true`인 경우 ```js { type: 'openTag', tagName: 'a', attributes: { href: 'http://ui.toast.com', title: 'TOAST UI' } } ``` #### `entering: false`인 경우 ```js { type: 'closeTag', tagName: 'a' } ``` 이 경우 직접 정의한 컨버팅 함수에서 `origin()` 함수를 호출하여 기존에 정의된 컨버팅 함수를 실행할 수 있다. 아래 코드는 `origin()`(기존 컨버팅 함수)을 호출하여 반환된 HTML 토큰에 `target="_blank"` 속성을 추가적으로 설정한 것이다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { link(node, context) { const { origin, entering } = context; const result = origin(); if (entering) { result.attributes.target = '_blank'; } return result; } }, } ``` #### `entering: true`인 경우 ```js { type: 'openTag', tagName: 'a', attributes: { href: 'http://ui.toast.com', target: '_blank', title: 'TOAST UI' } } ``` ## 심화 사용 방법 ### getChildrenText() 대부분의 경우 노드의 컨버팅 함수에서 자식 노드의 텍스트가 필요하진 않을 것이다. 하지만 종종 자식 노드의 텍스트를 가져와 속성을 설정해야 하는 경우가 있다. 이러한 경우 `context` 객체의 `getChildrenText()` 함수를 사용하면 유용하다. 예를 들어 헤딩 요소에 자식 콘텐츠를 기준으로 `id`를 설정하고 싶다면 아래 코드처럼 `getChildrenText()` 함수를 사용할 수 있다. ```js const editor = new Editor({ el: document.querySelector('#editor'), customHTMLRenderer: { heading(node, { entering, getChildrenText }) { const tagName = `h${node.level}`; if (entering) { return { type: 'openTag', tagName, attributes: { id: getChildrenText(node).trim().replace(/\s+/g, '-') } } } return { type: 'closeTag', tagName }; } } }); ``` 다음과 같은 마크다운 텍스트가 있다면, ```markdown # Hello *World* ``` `heading` 컨버팅 함수에서 `getChildrenText()` 함수의 반환 값은 `Hello World` 문자열이 된다. 컨버팅 함수에서는 공백 문자를 `-` 문자로 치환했기 때문에 최종 HTML 문자열은 아래와 같다. ```html

                          Hello World

                          ``` ### skipChildren() `skipChildren()` 함수를 호출하면 자식 노드의 순회를 건너뛴다. 자식 노드의 콘텐츠를 변환하지 않고 현재 노드의 속성만 사용하여 콘텐츠로 사용하고 싶을 때 유용하다. 예를 들어 `image` 노드에는 이미지의 설명을 나타내는 자식 노드가 존재한다. 그러나 `image` 노드를 HTML로 표현하는 `img` 요소는 자식 요소를 가질 수 없다. 그렇기 때문에 `image` 노드의 자식 노드가 불필요한 HTML 문자열로 변환되지 않도록 `skipChildren()` 함수를 호출해야 한다. 만약 자식 노드의 콘텐츠가 필요하다면 앞서 보았던 `getChildrenText()`를 호출하여 사용할 수 있다. 이러한 자식 노드의 콘텐츠는 `img` 요소의 `alt` 속성으로 설정할 수 있다. 다음 코드는 에디터에 내장된 `image` 노드 컨버터 함수의 예시이다. ```js function image(node, context) { const { destination } = node; const { getChildrenText, skipChildren } = context; skipChildren(); return { type: 'openTag', tagName: 'img', selfClose: true, attributes: { src: destination, alt: getChildrenText(), } } } ``` ### 다중 태그 사용 컨버팅 함수에서는 배열 형태의 토큰을 반환할 수 있다. 이것은 노드를 중첩된 HTML 구조로 변환하려는 경우에 유용하다. 다음 코드는 `codeBlock` 노드를 `
                          ...
                          ` 태그 문자열로 변환하는 예시이다. ```js function codeBlock(node) { return [ { type: 'openTag', tagName: 'pre', classNames: ['code-block'] }, { type: 'openTag', tagName: 'code' }, { type: 'text', content: node.literal }, { type: 'closeTag', tagName: 'code' }, { type: 'closeTag', tagName: 'pre' } ]; } ``` ### 개행 추가 일반적인 경우 최종적으로 변환된 HTML 문자열의 포맷에 신경 쓸 필요가 없다. 그러나 `ToastMark`는 [CommonMark Spec](https://spec.commonmark.org/0.29/)을 준수하기 때문에 개행을 제어하는 옵션을 지원해야만 한다.([공식 테스트 데이터](https://spec.commonmark.org/0.29/spec.json)) 컨버팅 함수의 토큰 객체에 `outerNewline`과 `innerNewline` 프로퍼티를 추가하여 개행을 제어할 수 있다. #### 토큰 배열 ```js [ { type: 'text', content: 'Hello' }, { type: 'openTag', tagName: 'p', outerNewLine: true, innerNewLine: true }, { type: 'html', content: 'My', outerNewLine: true, }, { type: 'closeTag', tagName: 'p', innerNewLine: true }, { type: 'text', content: 'World' } ] ``` #### 변환된 HTML 문자열 ```html Hello

                          My

                          World ``` 위의 예시에서 볼 수 있듯이 `openTag`의 `outerNewLine` 프로퍼티는 여는 태그 문자열 시작 전에 `\n` 문자를 추가한다. 만약 `closeTag`에 `outerNewLine` 프로퍼티가 있다면 닫는 태그 문자열 이후에 `\n` 문자를 추가한다. 이와 반대로, `openTag`의 `innerNewLine` 프로퍼티는 여는 태그 문자열 이후에 `\n` 문자를 추가한다. 만약 `closeTag`에 `innerNewLine` 프로퍼티가 있다면 닫는 태그 문자열 시작 전에 `\n` 문자를 추가한다. 연속된 개행이 있는 경우 중복을 막기 위해 하나의 개행으로 병합된다. ================================================ FILE: docs/ko/extended-autolinks.md ================================================ # 🔗 자동 링크 확장 ## 자동 링크란 무엇일까? [자동 링크](https://spec.commonmark.org/0.29/#autolinks)는 CommonMark에 정의된 스펙이다. (자동 링크의 세부 사양을 알고 싶으면 위 링크의 예를 참조바란다.) 자동 링크는 `<`, `>` 사이에 위치한 절대 경로 URI 또는 이메일 주소이다. URL 또는 이메일 주소를 링크 레이블로 하여 구문 분석된다. 이 기능은 TOAST UI Editor(이하 '에디터'라고 명시)가 CommonMark 스펙을 따르기 때문에 에디터에서도 별도의 설정 없이 사용할 수 있다. ![image](https://user-images.githubusercontent.com/37766175/120604939-7ad04d00-c488-11eb-82c1-f9f05891039e.png) ### 자동 링크 확장 자동 링크 확장은 [GFM](https://github.github.com/gfm) 스펙에서 지원하는 기능이다. 이 기능을 사용하면 텍스트를 자동 링크로 구문 분석하는 경우의 수가 더 많아진다. 예를 들어 텍스트에 `www.`가 있는 경우, 유효한 도메인으로 인식되어 아래와 같이 자동 링크로 인식된다. ![image](https://user-images.githubusercontent.com/37766175/120605112-a5baa100-c488-11eb-9b72-75eaa9324080.png) 자동 링크 확장과 관련된 자세한 예시는 [여기](https://github.github.com/gfm/#autolinks-extension-)에서 찾을 수 있다. ## 자동 링크 확장 설정 자동 링크 확장은 `extendedAutolinks` 옵션을 설정하여 사용할 수 있다. `extendedAutolinks` 옵션 값을 설정하지 않는다면, 에디터는 `false` 값을 기본값으로 사용하여, 이 경우 자동 링크 확장 기능은 동작하지 않는다. 만약 `extendedAutolinks` 값을 `true` 값으로 설정한다면, 자동 링크 확장 기능을 사용할 수 있다. ```js const editor = new toastui.Editor({ // ... extendedAutolinks: true }); ``` ## 자동 링크 확장 커스터마이징 에디터에서는 콜백 함수 형태로 옵션을 설정하여 사용자가 자동 링크를 확장할 수 있다. 이 옵션은 특정 링크 형식을 지원하려는 경우에 유용하다. 자동 링크 확장을 커스터마이징하려면 `extendedAutolinks` 옵션을 `function`으로 설정해야 한다. 아래 간단한 예제 코드가 있다. ```js const reToastuiEditorRepo = /tui\.editor/g; const editor = new Editor({ el: document.querySelector('#editor'), extendedAutolinks: (content) => { const matched = content.match(reToastuiEditorRepo); if (matched) { return matched.map(m => ({ text: 'toastui-editor', url: 'https://github.com/nhn/tui.editor', range: [0, 9] }) ); } return null; } }); ``` 편집 중인 콘텐츠는 위의 코드에서 볼 수 있듯이 `content` 매개 변수로 `extendedAutolinks`에 정의된 콜백 함수에 전달된다. 만약 콘텐츠에서 원하는 형태의 텍스트를 찾는다면, 배열 형태로 확장 링크 정보를 반환해야 한다. 배열 내의 각 링크 정보는 `text`, `url`, `range` 속성으로 구성된다. * `text`: 링크 라벨 * `url`: 링크 url * `range`: 내부적인 소스 위치 계산을 위한 링크 범위. 아래 이미지는 예제 코드를 실행한 결과이다. ![image](https://user-images.githubusercontent.com/37766175/120606618-55444300-c48a-11eb-8376-859fc6ffcf07.gif) ================================================ FILE: docs/ko/getting-started.md ================================================ # 🚀 시작하기 ## 설치하기 TOAST UI Editor는 패키지 매니저를 이용하거나, 직접 소스 코드를 다운받아 사용할 수 있다. 하지만 패키지 매니저 사용을 권장한다. ### 패키지 매니서 사용하기 (npm) 각 패키지 매니저가 제공하는 CLI 도구를 사용하면 쉽게 패키지를 설치할 수 있다. npm 사용을 위해선 [Node.js](https://nodejs.org/ko/)를 미리 설치해야 한다. ```sh $ npm install --save @toast-ui/editor # 최신 버전 $ npm install --save @toast-ui/editor@ # 특정 버전 ``` npm을 통해 설치했다면, 아래와 같은 구조로 TOAST UI Editor가 설치된 것을 볼 수 있다. ``` - node_modules/ ├─ @toast-ui/editor/ │ ├─ dist/ │ │ ├─ toastui-editor.js │ │ ├─ toastui-editor-viewer.js │ │ ├─ toastui-editor.css │ │ ├─ toastui-editor-viewer.css │ │ └─ toastui-editor-only.css ``` ### Contents Delivery Network (CDN) 사용하기 TOAST UI Editor는 CDN을 통해 사용할 수 있다. ```html ... ... ... ``` 특정 버전을 사용하려면 url 경로에서 `latest` 대신 버전 태그를 사용해야 한다. CDN은 아래와 같은 디렉토리 구조로 구성된다. ``` - uicdn.toast.com/ ├─ editor/ │ ├─ latest/ │ │ ├─ toastui-editor-all.js │ │ ├─ toastui-editor-all.min.js │ │ ├─ toastui-editor-viewer.js │ │ ├─ toastui-editor-viewer.min.js │ │ ├─ toastui-editor-editor.js │ │ ├─ toastui-editor-editor.min.js │ │ ├─ toastui-editor-editor.css │ │ ├─ toastui-editor-editor.min.css │ │ ├─ toastui-editor-viewer.css │ │ └─ toastui-editor-viewer.min.css │ ├─ 3.0.0/ │ │ └─ ... ``` ## 사용하기 ### 컨테이너 요소 추가 TOAST UI Editor(이하 '에디터'로 명시)가 생성될 컨테이너 요소를 추가한다. ```html ...
                          ... ``` ### 에디터 생성자 함수 불러오기 에디터는 생성자 함수를 통해 인스턴스를 생성할 수 있다. 생성자 함수에 접근하기 위해서는 환경에 따라 접근할 수 있는 세 가지 방법이 존재한다. #### Node.js 환경에서의 모듈 사용 - ES6 모듈 ```javascript import Editor from '@toast-ui/editor'; ``` - CommonJS ```javascript const Editor = require('@toast-ui/editor'); ``` #### 브라우저 환경에서의 namespace 사용 ```javascript const Editor = toastui.Editor; ``` ### CSS 파일 추가 에디터 사용을 위해 CSS파일을 추가해야 한다. Node.js 환경에서는 CSS 파일을 가져와 사용하며, CDN을 사용할 때는 html 파일에 CSS 파일 의존성을 추가하여 사용한다. #### Node.js 환경 - ES6 모듈 ```javascript import '@toast-ui/editor/dist/toastui-editor.css'; // Editor 스타일 ``` - CommonJS ```javascript require('@toast-ui/editor/dist/toastui-editor.css'); ``` #### CDN 환경 ```html ... ... ... ``` ### 인스턴스 생성하기 옵션과 함께 인스턴스를 생성하여 다양한 API를 호출할 수 있다. ```js const editor = new Editor({ el: document.querySelector('#editor') }); ``` ![getting-started-01](https://user-images.githubusercontent.com/37766175/121855586-7d576000-cd2e-11eb-9196-0c20270d1221.png) ```js const editor = new Editor({ el: document.querySelector('#editor'), height: '600px', initialEditType: 'markdown', previewStyle: 'vertical' }); ``` ![getting-started-02](https://user-images.githubusercontent.com/37766175/121464762-71e2fc80-c9ef-11eb-9a0a-7b06e08d3ccb.png) 대표적인 기본 옵션은 아래와 같다. - `height`: 에디터 영역의 높기 값. 문자열 값을 가진다. `300px` | `auto` - `initialEditType`: 최초로 보여줄 에디터 타입. `markdown` | `wysiwyg` - `initialValue`: 콘텐츠 초기값. 반드시 마크다운 문자열 형태여야 한다. - `previewStyle`: 마크다운 프리뷰 스타일. `tab` | `vertical` - `usageStatistics`: 에디터를 사용하는 웹 사이트의 _호스트명_을 전송한다. 어떠한 사용자가 에디터를 사용하고 있는지 수집하기 위합니다. 이 옵션은 불리언 값을 지정하여 비활성화할 수 있다. `true` | `false` 더 많은 옵션은 [여기](https://nhn.github.io/tui.editor/latest/ToastUIEditor)서 볼 수 있다. ## 예제 예제는 [여기](https://nhn.github.io/tui.editor/latest/tutorial-example01-editor-basic)서 확인할 수 있다. ================================================ FILE: docs/ko/i18n.md ================================================ # 🌏 국제화 (i18n) TOASE UI Editor는 다양한 언어로 UI의 텍스트를 설정할 수 있는 기능을 제공한다. 기본적으로 제공되는 언어 파일이 있으며, 인스턴스를 만들 때 이 파일들을 가져와 사용할 언어를 설정할 수 있다. ## 파일 구조 ### 소스 파일 (기여 용) [기본으로 지원하는 언어](#supported-languages) 외에 언어 파일을 추가하려면 아래 경로에 추가해야 한다. 기여 프로세스에 대한 자세한 내용은 [기여](#contributing) 섹션을 참조 바란다. ``` - tui.editor/apps/editor/src/ - i18n/ - en-us.ts - ko-kr.ts - ... ``` ### 빌드 (메인테이너 용) ``` - tui.editor/apps/editor/dist/ - i18n/ - ko-kr.js - ... ``` ### npm에 배포된 파일 구조 ``` - node_modules/@toast-ui/editor/dist/ - i18n/ - ko-kr.js - ... ``` ### CDN에 배포된 파일 구조 ``` - uicdn.toast.com/editor/latest/ - i18n/ - ko-kr.js - ko-kr.min.js - ... ``` ## 지원 언어 아래는 TOAST UI Editor에서 제공하는 i18n 파일의 언어 코드 표다. 이 언어 코드는 [IETF 언어 태그](https://en.wikipedia.org/wiki/IETF_language_tag)를 기반으로 한다. 언어 파일을 가져오면 자동으로 언어 코드가 등록되고 옵션으로 사용할 언어를 설정할 수 있다. > 참고 : 기본 설정 언어는 영어이므로 `en-us.js` 언어 파일을 가져올 필요가 없다. | 언어명 | i18n 파일 | 등록 코드 | | -------------------------- | --------- | --------------- | | Arabic | ar.js | `ar` | | Chinese (S) | zh-cn.js | `zh-CN` | | Chinese (T) | zh-tw.js | `zh-TW` | | Croatian (Croatia) | hr-hr.js | `hr` \| `hr-HR` | | Czech (Czech Republic) | cs-cz.js | `cs` \| `cs-CZ` | | Dutch (Netherlands) | nl-nl.js | `nl` \| `nl-NL` | | English (United States) | en-us.js | `en` \| `en-US` | | Finnish (Finland) | fi-fi.js | `fi` \| `fi-FI` | | French (France) | fr-fr.js | `fr` \| `fr-FR` | | Galician (Spain) | gl-es.js | `gl` \| `gl-ES` | | German (Germany) | de-de.js | `de` \| `de-DE` | | Italian (Italy) | it-it.js | `it` \| `it-IT` | | Japanese (Japan) | ja-jp.js | `ja` \| `ja-JP` | | Korean (Korea) | ko-kr.js | `ko` \| `ko-KR` | | Norwegian Bokmål (Norway) | nb-no.js | `nb` \| `nb-NO` | | Polish (Poland) | pl-pl.js | `pl` \| `pl-PL` | | Portuguese (Brazil) | pt-br.js | `pt` \| `pt-BR` | | Russian (Russia) | ru-ru.js | `ru` \| `ru-RU` | | Spanish (Castilian, Spain) | es-es.js | `es` \| `es-ES` | | Swedish (Sweden) | sv-se.js | `sv` \| `sv-SE` | | Turkish (Turkey) | tr-tr.js | `tr` \| `tr-TR` | | Ukrainian (Ukraine) | uk-ua.js | `uk` \| `uk-UA` | ## 언어 파일 가져오기 사용할 언어 파일을 가져와 언어를 등록해야 한다. `${fileName}`은 [지원 언어](#supported-languages)의 'i18n 파일' 컬럼에 해당한다 (확장자 없이 사용할 수 있음). ### ES 모듈 ```js import '@toast-ui/editor/dist/i18n/${fileName}'; ``` ### CommonJS ```js require('@toast-ui/editor/dist/i18n/${fileName}'); ``` ### CDN CDN에서는 각 언어 파일 별로 최소화 처리한 파일도 제공한다. ```html ``` ## 사용하기 > 참고 : npm 사용을 기반으로 설명한다. ### 사용 사례 1 : 기본 사용 특정 언어를 설정하려면 `language` 옵션을 사용하여 에디터 인스턴스를 만들어야 한다. 이 옵션의 값은 [지원 언어](#supported-languages)의 '등록 코드' 컬럼에 해당한다. 기본값은 `en`과 `en-US`다. ```js import Editor from '@toast-ui/editor'; // 1단계 : 언어 파일을 가져온다. import '@toast-ui/editor/dist/i18n/ko-kr'; // 2딘계 : 각 에디터에 언어를 설정한다. const foo = new Editor({ // 기본 언어 사용(영어) // ... }); const bar = new Editor({ // 다른 언어 사용(한국어) // ... language: 'ko-KR', }); ``` ### 사용 사례 2 : 언어 값 재정의 `setLanguage` 정적 메서드를 사용하여 특정 언어 코드에 대한 값을 재정의할 수 있다. 기본값은 [여기](https://github.com/nhn/tui.editor/tree/master/apps/editor/src/i18n/en-us.ts)를 참조 바란다. ```js import Editor from '@toast-ui/editor'; // 1단계 : 언어 파일을 가져온다. import '@toast-ui/editor/dist/i18n/ko-kr'; // 2단계 : 언어 값을 재정의한다. Editor.setLanguage('en-US', { 'Add row': '[Add Row]', // 기본값은 'Add row'이다. }); Editor.setLanguage('ko-KR', { 'Add row': '[로우 추가]', // 기본값은 '행 추가'이다. }); // 3단계 : 각 에디터에 언어를 설정한다. const foo = new Editor({ // 기본 언어 사용(영어) // ... }); const bar = new Editor({ // 다른 언어 사용(한국어) // ... language: 'ko-KR', }); ``` ### 사용 사례 3 : 새로운 언어 등록 사용할 언어가 기본적으로 제공되지 않는 경우 `setLanguage` 정적 메서드를 사용하여 등록할 수 있다. ```js import Editor from '@toast-ui/editor'; // 1단계 : 새로운 언어를 등록한다. Editor.setLanguage('en-GB', { Markdown: '...', WYSIWYG: '...', // ... }); // 2단계 : 새로 등록한 언어를 설정한다. const bar = new Editor({ // ... language: 'en-GB', }); ``` ## 기여 다른 언어 파일을 제공하는 데 기여하고 싶다면 다음 절차를 따라야 한다. ### 1단계 저장소를 포크한 후 아래 경로에 언어 파일을 추가한다. 언어 파일의 이름은 `${languageCode}-${countryCode}.js` 규칙을 따라야 한다. `languageCode`와 `countryCode`는 반드시 소문자로 표기해야 한다. (예. `en-gb.ts`) > 참조 : [Nominatim/Country Codes](https://wiki.openstreetmap.org/wiki/Nominatim/Country_Codes) ``` - tui.editor/apps/editor/src/ - i18n/ - en-us.ts - ko-kr.ts - ... ``` ### 2단계 [이 파일](https://github.com/nhn/tui.editor/tree/master/apps/editor/src/i18n/en-us.ts)을 참조하여 `setLanguage` 메서드를 호출할 때 사용되는 각 매개 변수 값을 작성한다. 첫 번째 매개 변수는 등록할 언어 파일에 매핑되는 코드 값이다. 코드 값은 [`${languageCode}-${countryCode}` 컨벤션](https://en.wikipedia.org/wiki/IETF_language_tag)을 따른다. `languageCode`는 소문자, `countryCode`는 대문자여야 한다. ```js // th-th.js // ... Editor.setLanguage('th-TH', { Markdown: '...', WYSIWYG: '...', // ... }); ``` 다음 조건이 충족되면 국가 코드를 제외한 언어 코드를 추가할 수 있다. > IETF 언어 태그 참조 : 언어 태그에 구별되는 정보를 추가하지 않을 경우 지역 하위 태그를 생략하는 것이 좋다. 예를 들어, 스페인어는 라틴어일 것이기 때문에 es-latn보다 es가 더 선호되고, 일본에서 사용되는 일본어는 다른 곳에서 사용되는 일본어와 크게 다르지 않기 때문에 ja-JP보다 ja가 더 선호된다. > > 모든 언어 영역이 유효한 지역의 하위 태그로 표현될 수 있는 것은 아니다: 주 언어의 하위 지역 방언은 하위 태그로 등록된다. 예를 들어, 카탈루냐어의 발렌시아 방언에 대한 발렌시아 하위 태그는 접두사 ca로 언어 하위 태그 에 등록된다. 이 방언은 스페인에서 거의 독점적으로 사용되기 때문에, 일반적으로 지역 하위 태그 ES는 생략할 수 있다. ```js // th-th.js // ... Editor.setLanguage(['th', 'th-TH'], { Markdown: '...', WYSIWYG: '...', // ... }); ``` ## 예제 예제는 [여기](https://nhn.github.io/tui.editor/latest/tutorial-example16-i18n)서 확인할 수 있다. ================================================ FILE: docs/ko/plugin.md ================================================ # 🧩 플러그인 TOAST UI Editor(이하 '에디터'라고 명시)는 기본으로 지원하지 않는 기능들을 플러그인으로 제공한다. 에디터에서 제공하는 플러그인은 현재 5개이며, 추후 자주 사용되는 기능은 더 추가될 수 있다. | 플러그인 명 | 패키지 명 | 설명 | | --- | --- | --- | | [`chart`](https://github.com/nhn/tui.editor/tree/master/plugins/chart) | [`@toast-ui/editor-plugin-chart`](https://www.npmjs.com/package/@toast-ui/editor-plugin-chart) | 차트를 렌더링하기 위한 플러그인 | | [`code-syntax-highlight`](https://github.com/nhn/tui.editor/tree/master/plugins/code-syntax-highlight) | [`@toast-ui/editor-plugin-code-syntax-highlight`](https://www.npmjs.com/package/@toast-ui/editor-plugin-code-syntax-highlight) | 코드 하이라이팅을 위한 플러그인 | | [`color-syntax`](https://github.com/nhn/tui.editor/tree/master/plugins/color-syntax) | [`@toast-ui/editor-plugin-color-syntax`](https://www.npmjs.com/package/@toast-ui/editor-plugin-color-syntax) | 컬러피커 사용을 위한 플러그인 | | [`table-merged-cell`](https://github.com/nhn/tui.editor/tree/master/plugins/table-merged-cell) | [`@toast-ui/editor-plugin-table-merged-cell`](https://www.npmjs.com/package/@toast-ui/editor-plugin-table-merged-cell) | 병합 테이블 셀을 사용하기 위한 플러그인 | | [`uml`](https://github.com/nhn/tui.editor/tree/master/plugins/uml) | [`@toast-ui/editor-plugin-uml`](https://www.npmjs.com/package/@toast-ui/editor-plugin-uml) | UML 사용을 위한 플러그인 | ## 플러그인 설치 및 사용 각 플러그인은 npm을 통해 설치하거나 CDN 형태로 사용할 수 있다. ### 패키지 매니저(npm)를 통한 설치 CLI를 사용하여 각 플러그인을 설치할 수 있다. 설치할 플러그인의 이름을 아래의 `${pluginName}`에 작성하여 설치한다. 예를 들어 `chart` 플러그인을 설치할 경우 `npm install @toast-ui/editor-plugin-chart`로 설치한다. ```sh $ npm install --save @toast-ui/editor-plugin-${pluginName} $ npm install --save @toast-ui/editor-plugin-${pluginName}@ ``` npm을 통해 설치할 경우 아래처럼 `node_modules`에 설치된다. ``` - node_modules/ ├─ @toast-ui/editor-plugin-${pluginName} │ ├─ dist/ │ │ ├─ toastui-editor-plugin-${pluginName}.js │ │ ├─ ... ``` 설치한 플러그인은 모듈 포맷에 따라 아래처럼 가져올 수 있다. - ES 모듈 ```js import pluginFn from '@toast-ui/editor-plugin-${pluginName}'; ``` - CommonJS ```js const pluginFn = require('@toast-ui/editor-plugin-${pluginName}'); ``` 예를 들어 `chart` 플러그인은 다음과 가져올 수 있다. ```js import chart from '@toast-ui/editor-plugin-chart'; ``` ### CDN을 통한 설치 각 플러그인은 [NHN Cloud](https://www.toast.com)에서 제공하는 CDN을 통해서도 사용할 수 있다. ```html ... ... ... ``` 특정 버전을 사용하려면 url 경로에서 `latest` 대신 버전을 명시하면 된다. CDN 디렉터리의 구조는 다음과 같다. ``` - uicdn.toast.com/ ├─ editor-plugin-${pluginName}/ │ ├─ latest/ │ │ ├─ toastui-editor-plugin-${pluginName}.js │ │ └─ ... │ ├─ 3.0.0/ │ │ └─ ... ``` > 참조: 각 플러그인의 CDN 파일은 상황에 따라 모든 의존성을 포함하거나 다른 유형의 번들 파일을 제공한다. 자세한 내용은 각 플러그인 저장소를 확인바란다. CDN을 사용해 플러그인을 가져올 때는 `toastui.Editor.plugin`에 등록된 네임스페이스를 사용한다. ```js const pluginFn = toastui.Editor.plugin[${pluginName}]; ``` 예를 들어 `chart` 플러그인은 다음과 같이 가져온다. ```js const { chart } = toastui.Editor.plugin; ``` ### 플러그인 사용 ES 모듈과 CDN에 따라 플러그인을 설치하고 가져오는 방법은 차이가 있지만, 사용하는 방법은 동일하다. 가져온 플러그인을 사용하려면 에디터의 `plugins` 옵션에 플러그인 함수를 추가해야 한다. `plugins` 옵션의 타입은 `Array.`이다. ```js const editor = new Editor({ // ... plugins: [plugin] }); ``` 만약 `chart`와 `uml` 플러그인을 사용한다면, ES 모듈과 CDN 환경에서 각각 아래처럼 사용할 수 있다. - ES 모듈 ```js import Editor from '@toast-ui/editor'; import chart from '@toast-ui/editor-plugin-chart'; import uml from '@toast-ui/editor-plugin-uml'; const editor = new Editor({ // ... plugins: [chart, uml] }); ``` - CDN ```js const { Editor } = toastui; const { chart, uml } = Editor.plugin; const editor = new Editor({ // ... plugins: [chart, uml] }); ``` 플러그인 함수에서 사용할 옵션이 필요한 경우 `plugins` 옵션에 튜플 형태의 데이터를 추가하면 된다. ```js const pluginOptions = { // ... }; const editor = new Editor({ // ... plugins: [[plugin, pluginOptions]] }); ``` ## 플러그인 만들기 기본적으로 제공되는 플러그인 외에도 사용자가 직접 플러그인 함수를 정의하여 사용할 수 있다. 아래처럼 플러그인 함수를 정의하여 정해진 포맷에 맞는 객체를 반환한다. ```ts interface PluginInfo { toHTMLRenderers?: HTMLConvertorMap; toMarkdownRenderers?: ToMdConvertorMap; markdownPlugins?: PluginProp[]; wysiwygPlugins?: PluginProp[]; wysiwygNodeViews?: NodeViewPropMap; markdownCommands?: PluginCommandMap; wysiwygCommands?: PluginCommandMap; toolbarItems?: PluginToolbarItem[]; } const pluginResult: PluginInfo = { // ... } function customPlugin() { // ... return pluginResult; } ``` 다른 플러그인과 마찬가지로 에디터의 `plugins` 옵션을 통해 직접 정의한 플러그인 함수를 추가하여 사용할 수 있다. ```js const editor = new Editor({ // ... plugins: [customPlugin] }); ``` ### 플러그인 반환 객체 플러그인에서 반환하는 객체의 프로퍼티에 대해 알아보겠다. 아래처럼 총 8개의 프로퍼티가 존재하며, 커스터마이징을 원하는 프로퍼티만 정의하여 반환한다. ```ts interface PluginInfo { toHTMLRenderers?: HTMLConvertorMap; toMarkdownRenderers?: ToMdConvertorMap; markdownCommands?: PluginCommandMap; wysiwygCommands?: PluginCommandMap; toolbarItems?: PluginToolbarItem[]; markdownPlugins?: PluginProp[]; wysiwygPlugins?: PluginProp[]; wysiwygNodeViews?: NodeViewPropMap; } ``` #### toHTMLRenderers `toHTMLRenderers` 객체는 에디터의 마크다운 프리뷰에서 렌더링될 때 또는 마크다운 에디터에서 위지윅 에디터로 컨버팅될 때 요소의 렌더링 결과를 변경할 수 있다. 에디터의 [customHTMLRenderer](https://github.com/nhn/tui.editor/blob/master/docs/ko/custom-html-renderer.md) 옵션과 동일하다. **toMarkdownRenderers** `toMarkdownRenderers` 객체는 위지윅 에디터에서 마크다운 에디터로 컨버팅될 때 변환되는 마크다운 텍스트를 재정의할 수 있다. `toMarkdownRenderers` 객체에 정의하는 함수는 `nodeInfo`와 `context` 두 가지 매개변수를 가진다. * `nodeInfo`: 컨버팅 대상이 되는 위지윅 노드의 정보이다. * `node`: 대상 노드에 대한 정보가 담겨 있다. * `parent`: 대상 노드의 부모 노드 정보가 담겨 있다. * `index`: 대상 노드가 몇 번째 자식인지 알 수 있다. * `context`: 노드 정보 외에 컨버팅에 필요한 정보들이 담겨있다. * `entering`: 해당 노드에 최초 방문인지, 자식 노드의 순회를 모두 끝내고 방문하는 것인지 알 수 있다. * `origin`: 기존 컨버팅 함수의 동작을 실행하는 함수이다. `toMarkdownRenderers` 에 정의된 함수는 결과값으로 마크다운 텍스트로 변환할 때 필요한 토큰 정보들을 반환한다. ```ts interface ToMdConvertorReturnValues { delim?: string | string[]; rawHTML?: string | string[] | null; text?: string; attrs?: Attrs; } ``` * `delim`: 마크다운 텍스트에서 사용할 기호를 정의한다. 마크다운 불릿 리스트의 `*`, `-`처럼 여러 기호로 변환될 수 있는 경우 사용한다. * `rawHTML`: 노드를 마크다운의 HTML 노드(HTML 문자열)로 변환할 경우 필요한 문자열이다. * `text`: 마크다운에서 보여줄 텍스트 정보이다. * `attrs`: 노드를 마크다운 텍스트로 변환할 때 사용할 속성 정보이다. 예를 들어 태스크 리스트의 체크 여부나 이미지 노드의 url 정보가 있다. **예시** ```ts return { toHTMLRenderers: { // ... tableCell(node: MergedTableCellMdNode, { entering, origin }) { const result = origin!(); // ... return result; }, }, toMarkdownRenderers: { // ... tableHead(nodeInfo) { const row = (nodeInfo.node as ProsemirrorNode).firstChild; let delim = ''; if (row) { row.forEach(({ textContent, attrs }) => { const headDelim = createTableHeadDelim(textContent, attrs.align); delim += `| ${headDelim} `; // ... }); } return { delim }; }, }, }; ``` 위의 코드는 병합 테이블 플러그인의 예시이다. `toHTMLRenderers`에 정의된 `tableCell` 노드의 반환 결과는 마크다운 프리뷰와 위지윅 에디터로 컨버팅 시 사용되며, `toMarkdownRenderers`에 정의된 `tableHead` 노드의 텍스트 결과는 마크다운 에디터로 컨버팅 시 사용된다. ![image](https://user-images.githubusercontent.com/37766175/121026660-4c80a380-c7e1-11eb-9d36-65425b6944da.gif) #### markdownCommands, wysiwygCommands 플러그인에서는 `markdownCommands`, `wysiwygCommands` 옵션을 사용하여 마크다운, 위지윅 커맨드를 등록할 수 있다. 각각의 커맨드 함수는 `payload`, `state`, `dispatch` 세 개의 매개변수를 가지며, 이를 사용하여 [Prosemirror](https://prosemirror.net/) 기반의 에디터 내부 동작을 제어할 수 있다. * `payload`: 커맨드 실행할 때 필요한 `payload`이다. * `state`: 에디터의 내부 상태를 나타내는 인스턴스로 [prosemirror-state](https://prosemirror.net/docs/ref/#state)와 동일하다. * `dispatch`: 커맨드 실행을 통해 에디터의 콘텐츠를 변경하고 싶은 경우 `dispatch` 함수를 실행해야 한다. Prosemirror의 [dispatch](https://prosemirror.net/docs/ref/#view.EditorView.dispatch) 함수와 동일하다. 만약 커맨드 함수를 실행하여 에디터의 콘텐츠에 변경 사항이 발생한다면 반드시 `true`를 반환해야 한다. 반대의 경우에는 `false`를 반환해야 한다. ```js return { markdownCommands: { myCommand: (payload, state, dispatch) => { // ... return true; }, }, wysiwygCommands: { myCommand: (payload, state, dispatch) => { // ... return true; }, }, }; ``` 위의 예시 코드처럼 플러그인에서 커맨드 함수를 정의하여 반환하면, 해당 커맨드를 에디터에서 사용할 수 있다. #### toolbarItems 플러그인에서 에디터의 툴바 아이템을 등록할 수도 있다. ```js return { // ... toolbarItems: [ { groupIndex: 0, itemIndex: 3, item: toolbarItem, }, ], }; ``` 위의 코드처럼 `toolbarItems` 배열에 어떤 아이템을 추가할지 설정할 수 있다. 각 옵션 객체는 `groupIndex`, `itemIndex`, `item` 세 가지 프로퍼티가 있으며 다음과 같다. * `groupIndex`: 툴바 아이템을 추가할 그룹의 인덱스를 지정한다. * `itemIndex`: 지정한 그룹 내에서 몇 번째로 추가할지 인덱스를 지정한다. * `item`: 추가할 툴바 아이템 요소를 지정한다. [툴바](https://github.com/nhn/tui.editor/blob/master/docs/ko/toolbar.md)의 툴바 커스터마이징에서 사용되는 객체와 동일한 형태이다. 만약 예제 코드처럼 `toolbarItems` 옵션을 설정한다면, 1번째 툴바 그룹의 4번째 인덱스로 툴바 아이템을 등록할 것이다. #### markdownPlugins, wysiwygPlugins 에디터는 내부적으로 Prosemirror를 사용한다. Prosemirror는 내부적으로 자체적인 플러그인 시스템을 제공한다. 에디터의 플러그인에서도 에디터의 내부 동작을 제어하기 위해 이러한 Prosemirror 플러그인을 직접 정의할 수 있다. 대부분의 경우 이러한 옵션은 필요없지만, 종종 필요한 경우가 있다. 예를 들어 코드 하이라이팅 플러그인에서는 위지윅 에디터의 `codeBlock`에 표시되는 코드를 하이라이팅할 때 사용한다. ```js return { wysiwygPlugins: [() => codeSyntaxHighlighting(context, prism)], }; ``` 이 옵션 객체를 사용하는 방법은 Prosemirror의 플러그인 정의 방법과 동일하니, [여기](https://prosemirror.net/docs/ref/#state.Plugin)를 참조 바란다. #### wysiwygNodeViews 마크다운 에디터는 일반 텍스트이지만, 위지윅 에디터의 콘텐츠는 특정한 노드로 구성된다. 이러한 노드들은 `customHTMLRenderer` 옵션을 사용하여 속성이나 클래스를 추가하는 커스터마이징이 가능하다. 하지만 그 외에 이벤트를 등록하여 무언가를 제어하거나, 더 복잡한 상호 작용을 원하는 경우 `customHTMLRenderer` 옵션만으로는 한계가 있다. 이런 경우 플러그인의 `wysiwygNodeViews` 옵션을 사용하여 위지윅 에디터에서 렌더링되는 노드를 원하는 대로 커스터마이징할 수 있다. 이 옵션 역시 대부분의 경우에는 필요가 없을 것이다. `wysiwygPlugins` 프로퍼티와 마찬가지로 `wysiwygNodeViews` 프로퍼티도 코드 하이라이팅 플러그인에서 사용된다. ```js return { wysiwygNodeViews: { codeBlock: createCodeSyntaxHighlightView(registerdlanguages), }, }; ``` 이 옵션 객체를 사용하는 방법은 Prosemirror의 `nodeView` 정의 방법과 동일하니, [여기](https://prosemirror.net/docs/ref/#view.NodeView)를 참조 바란다. ### 플러그인 함수의 `context` 매개변수 플러그인 함수는 위에서 살펴본 다양한 프로퍼티를 정의하기 위해 `context` 매개변수로 필수적인 정보들을 사용할 수 있다. `context` 매개변수는 아래와 같은 정보들을 가지고 있다. * `eventEmitter`: 에디터의 `eventEmitter`와 동일하다. 에디터와의 통신을 위해 사용한다. * `usageStatistics`: 해당 플러그인을 `@toast-ui/editor`의 GA로 수집할지 결정한다. * `i18n`: 다국어 추가를 위한 인스턴스이다. * `pmState`: [prosemirror-state](https://prosemirror.net/docs/ref/#state)의 일부 모듈을 가진 프로퍼티이다. * `pmView`: [prosemirror-view](https://prosemirror.net/docs/ref/#view)의 일부 모듈을 가진 프로퍼티이다. * `pmModel`: [prosemirror-model](https://prosemirror.net/docs/ref/#model)의 일부 모듈을 가진 프로퍼티이다. ================================================ FILE: docs/ko/toolbar.md ================================================ # 🛠 툴바 일반적으로 에디터에서는 단축키나 툴바를 사용하여 특정 텍스트나 노드를 입력할 수 있다. 특히 마크다운처럼 특정한 텍스트 문법이 존재하지 않는 위지윅 에디터에서는 대부분의 동작이 툴바를 통해 이뤄지기 때문에 툴바의 역할이 중요하다. TOAST UI Editor(이하 '에디터'라고 명시) 역시 기본 UI로 툴바를 제공하며 커스터마이징을 위한 옵션과 API도 제공한다. ## 툴바 옵션 에디터는 bold, italic, strike 등 총 16가지의 툴바를 기본으로 제공한다. 별도의 옵션을 지정하지 않았을 경우 기본 툴바 옵션은 아래와 같다. ```js const options = { // ... toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], ['table', 'image', 'link'], ['code', 'codeblock'], ['scrollSync'], ], } ``` 예제 코드에서 볼 수 있듯이 에디터의 툴바 옵션은 2차원 배열 형태로 정의된다. 먼저 각각의 툴바 그룹을 배열 형태로 정의하며 그룹 내의 툴바 요소들을 배열의 원소로 지정한다. 각 요소들은 정의된 순서대로 그룹 내에서 렌더링되며, 툴바 그룹은 `|` 기호로 구분되어 렌더링 된다. ![image](https://user-images.githubusercontent.com/37766175/120914229-a137f780-c6d7-11eb-8112-b14a48f8374f.png) 만약 기본 툴바의 구성을 변경하고 싶다면 에디터의 `toolbarItems` 옵션을 지정하여 변경할 수 있다. ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ ['heading', 'bold'], ['ul', 'ol', 'task'], ['code', 'codeblock'], ], }); ``` 위의 예제 코드를 실행하면 아래처럼 렌더링된다. ![image](https://user-images.githubusercontent.com/37766175/120914344-a47fb300-c6d8-11eb-85cd-857047e8e220.png) ## 툴바 버튼 커스터마이징 위에서 살펴본 예시는 사실 에디터의 기본 툴바 요소를 조합하는 것에 불과하다. 그렇다면 사용자가 직접 툴바 버튼을 만들어 추가하고 싶다면 어떻게 해야 할까? 이런 경우 크게 두 가지 형태의 옵션을 지정하여 커스터마이징할 수 있다. ### 내장 버튼 요소 커스터마이징 먼저 에디터에서 제공하는 툴바 버튼 UI를 그대로 사용하여 커스터마이징하는 방법이 있다. 이 방법은 에디터에 내장된 버튼을 툴바 요소를 렌더링하며, 여기서 버튼의 아이콘이나 툴팁, 팝업 동작만 재정의한다. 해당 옵션은 아래와 같은 인터페이스로 구성된다. | 이름 | 타입 | 설명 | | --- | --- | --- | | `name` | string | 툴바 요소의 고유한 이름이며, 필수로 지정해야 한다. | | `tooltip` | string | 옵셔널 값이며, 툴바 요소에 마우스를 올렸을 때 보여줄 툴팁 문자열을 정의한다. | | `text` | string | 옵셔널 값이며, 툴바 버튼 요소에 보여줄 텍스트가 있는 경우 정의한다. | | `className` | string | 옵셔널 값이며, 툴바 요소에 적용할 class를 정의한다. | | `style` | Object | 옵셔널 값이며, 툴바 요소에 적용할 style을 정의한다. | | `command` | string | 옵셔널 값이며, 툴바 버튼을 클릭했을 때 실행하고 싶은 명령을 지정한다. `popup` 옵션과는 서로 배타적인 관계이다. | | `popup` | PopupOptions | 옵셔널 값이며, 툴바 버튼을 클릭했을 때 팝업을 띄우고 싶은 경우 지정한다. `command` 옵션과는 서로 배타적인 관계이다. | ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', command: 'bold', text: '@', className: 'toastui-editor-toolbar-icons', style: { backgroundImage: 'none', color: 'red' } }] ], // ... }); ``` 위의 예제 코드를 실행하면 옵션으로 설정한 `className`과 `style`이 적용된 툴바 요소가 생성된다. 생성된 요소는 `@` 텍스트 노드를 가지며, 클릭했을 때 `bold` 커맨드를 실행한다. ![image](https://user-images.githubusercontent.com/37766175/120915118-ea3e7a80-c6dc-11eb-86cc-5229ed36c4e8.gif) ### popup 옵션 만약 버튼을 클릭했을 때 커맨드를 실행하는 것이 아니라 직접 정의한 팝업을 띄우고 싶을 수도 있을 것이다. 이런 경우 위에서 살펴본 `popup` 옵션을 사용하면 된다. `popup` 옵션의 인터페이스는 아래와 같다. | 이름 | 타입 | 설명 | | --- | --- | --- | | `body` | HTMLElement | 렌더링 될 팝업 DOM 노드를 정의한다. | | `className` | string | 옵셔널 값이며, 팝업 요소에 적용할 class를 정의한다. | | `style` | Object | 옵셔널 값이며, 팝업 요소에 적용할 style을 정의한다. | 옵션으로 설정한 팝업 노드는 툴바를 클릭하였을 때 자동으로 화면에 나타나며, 다른 영역을 클릭했을 경우 자동으로 사라진다. 에디터의 컬러피커 플러그인 코드를 조금 변형하여 살펴보겠다. ```js const container = document.createElement('div'); // ... const button = createApplyButton(i18n.get('OK')); button.addEventListener('click', () => { // ... eventEmitter.emit('command', 'color', { selectedColor }); eventEmitter.emit('closePopup'); }); container.appendChild(button); const colorPickerToolber = { name: 'color', tooltip: 'Text color', className: 'some class', popup: { className: 'some class', body: container, style: { width: 'auto' }, }, }; ``` 예제 코드에서는 팝업으로 띄울 요소를 `container`란 변수에 담아 지정하였다. 해당 요소는 버튼 요소를 가지며, 이 버튼을 클릭하였을 때 `color` 커맨드를 실행하고 팝업을 닫는다. 직접 정의한 팝업은 `eventEmitter`를 사용하여 에디터와 통신할 수 있다. 커맨드를 실행하기 위해서는 `command` 이벤트를 발생시키면 되고, 팝업을 닫고 싶을 경우 `closePopup` 이벤트를 발생시키면 된다. 정의된 컬러피커 툴바 요소는 아래처럼 팝업과 잘 연동하여 동작하는 것을 볼 수 있다. ![image](https://user-images.githubusercontent.com/37766175/120915630-b6b11f80-c6df-11eb-8094-b264ca9312a1.gif) ## 툴바 요소 커스터마이징 만약 위에서 설명한 것처럼 기본 버튼 UI를 사용하지 않고 툴바 요소를 만들고 싶다면 아래처럼 `el` 옵션을 지정해야 한다. ```js const myCustomEl = document.createElement('span'); myCustomEl.textContent = '😎'; myCustomEl.style = 'cursor: pointer; background: red;' myCustomEl.addEventListener('click', () => { editor.exec('bold'); }); const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', el: myCustomEl, }] ], // ... }); ``` 렌더링할 요소를 `el` 옵션으로 지정해야 한다. 이 경우 완전한 DOM 요소를 만들어 옵션으로 지정하는 것이기 때문에 클릭했을 때의 동작이나 style, class를 모두 직접 설정해야 한다. 위의 예제 코드를 실행하면 아래와 같이 동작한다. ![iamge](https://user-images.githubusercontent.com/37766175/120915883-3e4b5e00-c6e1-11eb-8f44-95e6d31f41e7.gif) ## 툴바 상태 변경 에디터에서는 현재 커서의 위치에 따라 어떤 노드인지 툴바 요소의 스타일로 활성화할 수 있다. 예를 들어, 커서가 굵은 텍스트를 표시하는 `strong` 노드에 위치한다면, 아래와 같이 `bold` 툴바 요소가 활성화된다. ![image](https://user-images.githubusercontent.com/37766175/124843166-49d5c180-dfcc-11eb-9633-ae1e61d612ea.gif) 위의 예시처럼 커스터마이징한 툴바 요소의 상태를 변경하고 싶다면 `state` 옵션을 지정해야 한다. ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', command: 'bold', text: '@', className: 'toastui-editor-toolbar-icons', style: { backgroundImage: 'none', color: 'red' }, // `strong` 노드에 위치할 경우 툴바 요소에 'active' 클래스가 추가된다. state: 'strong', }] ], // ... }); ``` `state`에 따라 툴바 버튼이 활성화된다면 `active` 클래스가 추가되며, 이 클래스를 기준으로 원하는 스타일을 지정하면 된다. ### state 목록 아래의 state 값을 사용해야만 툴바 요소의 활성화 상태를 변경할 수 있다. * `heading`: 헤딩 * `strong`: 볼드 * `emph`: 이탤릭 * `strike`: 스트라이크 * `thematicBreak`: 수평 가로줄 * `blockQuote`: 인용문 * `bulletList`: 순서가 없는 리스트 * `orderedList`: 순서가 있는 리스트 * `taskList`: task 리스트 * `table`: 테이블 * `code`: 인라인 코드 * `codeBlock`: 코드 블럭 ### `onUpdated()` 옵션 기본 버튼 UI를 사용하지 않고 `el` 옵션을 사용하여 툴바 요소를 만든 경우, `onUpdated` 옵션을 지정해야 상태를 변경할 수 있다. 에디터 내부에서 커스터마이징한 툴바 요소를 직접 조작하는 것은 한계가 있기 때문에 `onUpdated` 콜백 옵션을 제공한다. ```js const myCustomEl = document.createElement('span'); myCustomEl.textContent = '😎'; myCustomEl.style = 'cursor: pointer; background: red;' myCustomEl.addEventListener('click', () => { editor.exec('bold'); }); const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ [{ name: 'myItem', tooltip: 'myItem', el: myCustomEl, state: 'strong', onUpdated({ active, disabled }) { if (active) { myCustomEl.style.background = 'green'; } else { myCustomEl.style.background = ''; } } }] ], // ... }); ``` `onUpdated()` 함수는 `active`, `disabled` 상태를 나타내는 객체를 매개변수로 전달한다. 이 매개변수를 사용하여 요소에 스타일링을 추가하거나 원하는 동작을 정의할 수 있다. ## 예제 예제는 [여기](https://nhn.github.io/tui.editor/latest/tutorial-example15-customizing-toolbar-buttons)서 확인할 수 있다. ================================================ FILE: docs/ko/viewer.md ================================================ # 👀 뷰어 ## 뷰어는 무엇일까? TOASE UI Editor(이하 'Editor'라고 명시)는 에디터를 로딩하지 않고 _마크다운_ 콘텐츠를 보여줄 수 있도록 **뷰어**를 제공한다. 뷰어가 에디터보다 훨씬 **더 가볍다**. ## 뷰어 사용하기 뷰어를 사용하는 방법은 에디터와 유사하다. > 참고. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/ko/getting-started.md) ### 컨테이너 요소 추가 뷰어가 생성될 컨테이너 요소를 추가한다. ```html ...
                          ... ``` ### 뷰어 생성자 함수 불러오기 뷰어는 생성자 함수를 통해 인스턴스를 생성할 수 있다. 생성자 함수에 접근하기 위해서는 환경에 따라 접근할 수 있는 세 가지 방법이 존재한다. #### Node.js 환경에서의 모듈 사용 - ES6 모듈 ```javascript import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer'; ``` - CommonJS ```javascript const Viewer = require('@toast-ui/dist/toastui-editor-viewer'); ``` #### 브라우저 환경에서의 namespace 사용 ```javascript const Viewer = toastui.Editor; ``` CDN에서 뷰어는 다음처럼 사용한다. ```html ... ... ... ``` ### CSS 파일 추가 뷰어 사용을 위해 CSS파일을 추가해야 한다. Node.js 환경에서는 CSS 파일을 가져와 사용하며, CDN을 사용할 때는 html 파일에 CSS 파일 의존성을 추가하여 사용한다. #### Using in Node Environment - ES6 모듈 ```javascript import '@toast-ui/editor/dist/toastui-editor-viewer.css'; ``` - CommonJS ```javascript require('@toast-ui/editor/dist/toastui-editor-viewer.css'); ``` #### CDN 환경 ```html ... ... ... ``` ### 인스턴스 생성하기 옵션과 함께 인스턴스를 생성하여 다양한 API를 호출할 수 있다. ```js const viewer = new Viewer({ el: document.querySelector('#viewer'), height: '600px', initialValue: '# hello' }); ``` ![viewer-01](https://user-images.githubusercontent.com/37766175/121862304-a3ccc980-cd35-11eb-92c8-02b0e6fcf3cf.png) 대표적인 기본 옵션은 아래와 같다. - `height`: 에디터 영역의 높기 값. 문자열 값을 가진다. `300px` | `auto` - `initialValue`: 콘텐츠 초기값. 반드시 마크다운 문자열 형태여야 한다. 더 많은 옵션은 [여기](https://nhn.github.io/tui.editor/latest/ToastUIEditorViewer)서 볼 수 있다. ## 뷰어를 사용하는 다른 방법 에디터에 이미 뷰어 기능이 포함되어 있으므로 에디터와 뷰어가 동시에 로드되지 않도록 주의해야 한다. 또한 `Editor.factory()` 정적 메서드를 사용하여 뷰어를 사용할 수 있다. 아래 코드처럼 `viewer` 옵션을 `true`로 설정하면 뷰어가 생성된다. ```js import Editor from '@toast-ui/editor'; const viewer = Editor.factory({ el: document.querySelector('#viewer'), viewer: true, height: '500px', initialValue: '# hello' }); ``` ## 예제 예제는 [여기](https://nhn.github.io/tui.editor/latest/tutorial-example04-viewer)서 확인할 수 있다. ================================================ FILE: docs/ko/widget.md ================================================ # 📱 위젯 노드 에디터 내에서 특정 키를 입력할 때 인명 검색과 같은 팝업 창을 띄우거나, 멘션 형태의 일반 링크 노드를 특정한 위젯 노드로 보여주고 싶을 때가 있을 것이다. TOAST UI Editor(이하 '에디터'라고 명시)에서는 이러한 기능을 위해 옵션과 API를 제공한다. ## 팝업 위젯 에디터에서 콘텐츠를 편집하다 보면, 현재 커서의 위치에 검색 또는 추천 팝업을 띄우고 싶을 때가 있다. 이 때 `addWidget` API를 사용하여 원하는 DOM 노드를 에디터 상에 띄울 수 있다. 이 노드는 편집 중인 콘텐츠에는 영향을 미치지 않으며, **일시적으로 추가**된다. 즉, 텍스트를 입력하거나 포커스를 옮기면 사라진다. API의 시그니처는 아래와 같다. ```ts addWidget(node: Node, style: WidgetStyle, pos?: EditorPos) ``` | 파라미터 | 타입 | 설명 | | --- | --- | --- | | `node` | Node | 위젯으로 추가할 DOM 노드 | | `style` | 'top' \| 'bottom' | 위젯을 지정된 위치의 위에 추가할 지 아래에 추가할 지 결정한다. | | `pos` | EditorPos | 위젯이 추가될 위치를 지정한다. 옵셔널 값이며, 지정하지 않을 경우 현재 커서 위치에 위젯이 추가된다. | ```js const popup = document.createElement('ul'); // ... editor.addWidget(popup, 'top'); ``` 위의 코드가 실행되면 아래처럼 `popup` 노드가 추가된다. ![image](https://user-images.githubusercontent.com/37766175/120617182-d6a0d300-c494-11eb-8fb9-58926c60e8b7.png) 만약 특정 키를 입력했을 때 위젯 노드를 띄우고 싶다면, 에디터의 `keyup` 이벤트와 연동해서 사용할 수 있다. ```js editor.on('keyup', (editorType, ev) => { if (ev.key === '@') { const popup = document.createElement('ul'); // ... editor.addWidget(popup, 'top'); } }) ``` ### 인라인 위젯 노드 일시적으로 특정 상황에 따라 팝업 위젯 노드를 추가하는 방법을 살펴보았다. 그렇다면 만약 팝업 위젯에서 특정 항목을 클릭하여 멘션 형태의 노드를 추가하고 싶다면 어떻게 할 수 있을까? 마크다운 에디터는 텍스트 기반 에디터이기 때문에 이러한 멘션 노드를 추가할 수 없다. 위지윅 에디터에서도 내부적으로 별도의 멘션 노드를 기본으로 지원하지 않기 때문에 추가할 수 없다. 에디터에서 멘션 노드와 같은 *인라인 위젯 노드*를 추가하고 싶은 사용자를 위해 `widgetRules` 옵션을 제공한다. 만약 텍스트가 `widgetRules` 옵션에 설정한 규칙에 맞는다면 해당 노드는 에디터에서 인라인 위젯 노드로 렌더링 된다. **인라인 위젯 노드는 팝업 위젯과는 다르게 콘텐츠로서 에디터에 삽입되며, 다른 노드의 위치에 영향을 준다**. ```js const reWidgetRule = /\[(@\S+)\]\((\S+)\)/; const editor = new Editor({ el: document.querySelector('#editor'), widgetRules: [ { rule: reWidgetRule, toDOM(text) { const rule = reWidgetRule; const matched = text.match(rule); const span = document.createElement('span'); span.innerHTML = `${matched[1]}`; return span; }, }, ], }); ``` 예제 코드에서 볼 수 있듯이 `widgetRules`는 배열 형태로 각각의 규칙을 정의하며, 각 규칙은 `rule`, `toDOM`이라는 프로퍼티로 구성된다. * `rule`: 반드시 정규식 값이 와야하며, 이 정규식에 맞는 텍스트는 위젯 노드로 치환되어 렌더링된다. * `toDOM`: 렌더링될 위젯 노드의 DOM 노드를 정의한다. `widgetRules`의 규칙에 맞는 텍스트가 입력되면, 아래 이미지처럼 인라인 위젯 노드로 치환되어 렌더링된다. ![image](https://user-images.githubusercontent.com/37766175/120621226-a6f3ca00-c498-11eb-9355-0275fd3bdbdb.gif) ### `insertText()`, `replaceSelection()` API 위젯 규칙에 맞는 텍스트를 직접 입력하여 인라인 위젯 노드 형태로 치환할 수도 있지만, 대부분의 경우는 팝업 위젯에서 특정 항목을 클릭하여 멘션 노드와 같은 인라인 위젯 노드를 삽입하고 싶을 것이다. 이러한 경우 `insertText()`, `replaceSelection()` API를 사용하여 팝업 위젯의 항목을 클릭하였을 때 인라인 위젯 노드를 삽입할 수 있다. ```js ul.addEventListener('mousedown', (ev) => { const text = ev.target.textContent.replace(/\s/g, '').replace(/😎/g, ''); const [start, end] = editor.getSelection(); editor.replaceSelection(`[@${text}](${text})`, [start[0], start[1] - 1], end); }); ``` 예제 코드에서는 `@` 문자까지 포함하여 치환해야 하기 때문에 `getSelection()` API로 현재 커서 위치를 기준으로 계산한 후 `replaceSelection()` API를 호출하였다. 결과적으로 아래 이미지처럼 팝업 위젯의 항목을 클릭하였을 때 `@` 문자가 인라인 위젯 노드로 치환되는 것을 볼 수 있다. ![image](https://user-images.githubusercontent.com/37766175/120624280-81b48b00-c49b-11eb-9896-432120c27389.gif) ================================================ FILE: docs/v3.0-migration-guide-ko.md ================================================ ## 개요 해당 문서는 TOAST UI Editor 3.0 버전 업데이트에 대한 마이그레이션 가이드로, 2.x 버전을 사용하는 사용자가 3.0 버전으로 업데이트할 때 필요한 모든 변경 사항을 기술한다. TOAST UI Editor(이하 '에디터'로 표기)는 3.0 버전에서 기존 [CodeMirror](https://codemirror.net/)와 squire, to-mark 등에 대한 의존성을 제거하고 Prosemirror를 이용하여 추상화 모델을 사용하는 에디터로 변경하는 작업을 진행하였다. 코어 모듈과 API, 플러그인 사용 방법 등이 모두 변경되었기 때문에 업데이트 시 마이그레이션 가이드의 내용을 잘 숙지하길 바란다. 목차는 다음과 같으며, 실제 적용 시에는 '변경 사항' 항목의 내용을 순서대로 진행하길 권장한다. ## 목차 - [변경 사항](#변경-사항) 1. [설치 및 사용 방법](#1-설치-및-사용-방법) 2. [툴바 커스터마이징](#2-툴바-커스터마이징) 3. [플러그인 정의](#3-플러그인-정의) 4. [API와 이벤트](#4-API와-이벤트) 5. [지원 브라우저 범위](#5-지원-브라우저-범위) - [제거된 기능](#제거된-기능) 1. [jQuery Wrapper 제거](#1-jQuery-Wrapper-제거) 2. [의존성 제거](#2-의존성-제거) 3. [제거된 API](#3-제거된-API) ## 변경 사항 ### 1. 설치 및 사용 방법 에디터 사용 방식은 기존 v2.x와 동일하게 [스코프드 패키지(Scoped package)](https://docs.npmjs.com/using-npm/scope.html)를 적용하여 다음과 같이 `@toast-ui/editor`로 패키지를 설치하여 사용한다. 아래는 npm 커맨드를 사용한 에디터 설치 예제이다. ```sh $ npm install @toast-ui/editor $ npm install @toast-ui/editor@ ``` #### 사용 방법 ```js const Editor = require('@toast-ui/editor'); /* CommonJS 방식 */ import Editor from '@toast-ui/editor'; /* ES6 모듈 방식 */ ``` 또한, v3.0에서는 에디터의 기본 UI를 사용하지 않고 별도로 UI를 구상하고 싶은 사용자를 위해 `EditorCore`란 모듈을 named export 형태로 제공한다. 이 모듈을 사용하면 마크다운 에디터와 프리뷰, 위지윅 에디터만 생성하며, 이를 `getEditorElements()` 메서드를 사용하여 원하는 UI에 에디터를 추가하여 사용할 수 있다. 툴바나 툴바 팝업, 스위치 탭과 같은 에디터 외부의 UI는 생성하지 않는다. ```js import { EditorCore } from '@toast-ui/editor'; /* ES6 모듈 방식 */ const editorCore = new EditorCore({ el // ... }); const { mdEditor, mdPreview, wwEditor } = editorCore.getEditorElements(); // ... ``` #### 번들 구조 v3.0에서는 기존 v2.x의 번들 구조에 두 가지가 더 추가되었다. 기존의 legacy 지원을 위한 번들과 cdn 번들 외에 ESM 번들이 추가로 제공된다. ESM 번들은 복잡한 모듈 호환 구문이 없기 때문에 더 가벼우며, 정적 분석으로 인한 트리 쉐이킹(Tree shaking)의 이점도 누릴 수 있다. 두 번째로 다크 테마 지원을 위한 `theme/toastui-editor-dark.css` 파일이 추가되었다. 이에 대한 설명은 [다크 테마 추가](#-다크-테마-추가)에서 볼 수 있다. v3.0의 번들 구조는 다음과 같다. ``` - dist/ ├─ cdn/... ├─ i18n/... ├─ esm/ │ ├─ index.js │ └─ index.js.map ├─ theme/ │ └─ toastui-editor-dark.css │ ├─ toastui-editor-only.css ├─ toastui-editor-viewer.css ├─ toastui-editor.css ├─ toastui-editor.js └─ toastui-editor-viewer.js ``` 또한 v3.0에서 ESM 번들이 추가되며, package.json 파일도 이에 맞게 변경되었다. 기존의 UMD 용 번들 파일은 main 필드에 정의되며, ESM 번들 파일은 exports 필드에 정의되었다. ```json { "main": "dist/toastui-editor.js", "module": "dist/esm/", "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/toastui-editor.js" }, "./viewer": { "import": "./dist/esm/indexViewer.js", "require": "./dist/toastui-editor-viewer.js" } } } ``` #### 다크 테마 추가 v3.0에서는 다크 테마가 추가되었다. 다크 테마를 적용하고 싶은 경우 `theme/toastui-editor-dark.css`를 추가한 후, 에디터의 `theme` 옵션을 `dark`로 설정하여 사용한다. 현재 v3.0에서는 다크 테마만 지원하지만, 사용자가 여러 테마를 혼합하여 사용하거나 추후 다른 테마를 지원하기 위해 `theme` 옵션을 추가하였다. ```js import Editor from '@toast-ui/editor'; import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/theme/toastui-editor-dark.css'; const editor = new Editor({ el: document.querySelector('#editor'), previewStyle: 'vertical', height: '500px', initialValue: content, theme: 'dark', }); ``` ![image](https://user-images.githubusercontent.com/37766175/120954138-73ab8680-c789-11eb-8445-87bf15842482.png) #### 의존성 정보 변경 에디터 3.0에서는 v2.x에서 사용하던 의존성 모듈들이 제거되었다. 만약 CDN 환경에서 개발하고 있다면, v2.x에서 사용하던 [CodeMirror](https://codemirror.net/) 의존성 코드는 더 이상 필요없으니 제거해야 한다. 3.0에서는 Prosemirror와 관련된 의존성 모듈들이 추가되었지만, 이는 CDN 번들에 모두 포함되기 때문에 별도로 추가할 필요가 없다. **v2.0** ```html ... ... ... ... ``` **v3.0** ```html ... ... ... ... ``` ### 2. 툴바 커스터마이징 `toolbarItems` 옵션이 기존 v2.x에 비해 더 간결하고 선언적으로 변경되었다. v3.0에서는 각 툴바 아이템과 툴바 그룹을 **2차원 배열** 형태의 옵션으로 정의한다. 이 방식은 그룹을 구분하기 위해 `divider`라는 불필요한 요소를 옵션으로 넘겨 정의하던 기존 방식보다 훨씬 간결하고 명확하다. **v2.0** ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ 'heading', 'bold', 'italic', 'strike', // 그룹을 구분짓기 위해 옵션에 divider 요소를 추가해야 했다. 'divider', 'hr', 'quote', 'divider', // ... ], // ... }); ``` **v3.0** ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], // ... ], // ... }); ``` 위의 예제 코드를 보면, v3.0의 코드가 더 간결하고 그룹이 어떻게 나뉘는지 훨씬 쉽게 구분할 수 있음을 알 수 있다. #### 커스터마이징 툴바 아이템을 커스터마이징하는 방법도 변경되었다. v2.x에서는 툴바 아이템을 클릭하여 팝업을 띄우거나 닫을 때 에디터의 `eventManager`나 다른 UI 인스턴스에 대한 결합도가 상당히 높았다. 이는 사용자 또는 플러그인에서 커스터마이징할 때 에디터 내부 동작 방식에 대한 지식을 강요하기 때문에 사용하기 어렵고, 불필요한 제어 코드들을 생산하였다. v3.0에서는 이러한 결합도를 낮추기 위해 UI 제어를 위한 코드를 모두 내부로 캡슐화하였고, 사용자는 옵션만 설정하여 툴바 아이템을 커스터마이징할 수 있게 변경하였다. **v2.0** ```js const popup = editor.getUI().createPopup({ header: false, title: null, content: colorPickerContainer, className: 'tui-popup-color', target: editor.getUI().getToolbar().el, css: { width: 'auto', position: 'absolute' } }); editor.eventManager.listen('focus', () => { popup.hide(); // ... }); editor.eventManager.listen('colorButtonClicked', () => { // ... }); editor.eventManager.listen('closeAllPopup', () => { // ... }); ``` 위의 코드는 v2.x에서 컬러피커 플러그인의 툴바 커스터마이징 예시이다. 툴바의 팝업 동작을 제어하기 위해 `editor.getUI().createPopup()`, `editor.getUI().getToolbar()`와 같은 에디터 내부 구조에 종속적인 API를 사용해야 한다. 내부 구현에 대한 이러한 의존성은 유연한 커스터마이징을 더 어렵게 만든다. API 뿐만이 아니다. 팝업을 제어하기 위해 `eventManager`에 여러 이벤트를 등록하여 코드를 수정해야 한다. **v3.0** ```js const popup = { name: 'color', tooltip: 'Text color', className: 'toastui-editor-toolbar-icons color', popup: { className: 'toastui-editor-popup-color', body: colorPickerContainer, style: { width: 'auto' }, }, }; ``` 몇 가지 코드가 생략되었지만, 3.0에서는 간단한 옵션 설정으로 팝업 UI를 생성하고 제어할 수 있다. 기존처럼 내부 UI에 모듈에 대해 알 필요없이 `popup` 옵션 객체에 `className`, `style`, `body` 프로퍼티만 정의하면, 툴바 버튼을 눌렀을 때 팝업을 띄울 수 있다. 툴바 커스터마이징에 대한 더 자세한 설명은 [여기](https://github.com/nhn/tui.editor/tree/master/docs/ko/toolbar.md)를 참조 바란다. ![image](https://user-images.githubusercontent.com/37766175/120915630-b6b11f80-c6df-11eb-8094-b264ca9312a1.gif) ### 3. 플러그인 정의 v3.0의 가장 큰 변경점은 플러그인을 정의하는 방식이다. 기존 2.x에서는 플러그인 역시 앞서 살펴본 툴바 커스터마이징처럼 에디터 내부 모듈에 대한 의존성이 굉장히 높았다. 특히 플러그인은 마크다운 에디터, 위지윅 에디터, 컨버터 등 에디터 내부 인스턴스의 동작을 더욱 깊숙이 알아야 한다. 3.0버전에서는 이 문제를 개선하기 위해 명확한 옵션을 주입하여 각각의 기능을 커스터마이징하는 형태로 변경되었다. 이 가이드에서는 옵션의 형태만 간략하게 설명할 것이며, 플러그인을 정의하는 자세한 방법은 [여기](https://github.com/nhn/tui.editor/tree/master/docs/ko/plugin.md)서 볼 수 있다. #### 커맨드 등록 플러그인에서는 `markdownCommands`, `wysiwygCommands` 옵션을 사용하여 마크다운, 위지윅 커맨드를 등록할 수 있다. ```js return { markdownCommands: { myCommand: (payload, state, dispatch) => { // ... }, }, wysiwygCommands: { myCommand: (payload, state, dispatch) => { // ... }, }, }; ``` 각각의 커맨드는 `payload`, `state`, `dispatch` 세가지 인자를 받으며, 이를 사용하여 Prosemirror 기반의 에디터 내부 동작을 제어할 수 있다. 이 방식 역시 Prosemirror의 동작을 알아야 한다는 단점이 있다. 하지만, 앞으로 에디터 자체적으로 여러 가지 기본 커맨드를 제공할 것이기 때문에 직접적으로 이러한 내부 동작을 재정의할 일은 많지 않을 것이다. #### 컨버팅 특정 요소가 마크다운 프리뷰에서 렌더링될 때 또는 마크다운 에디터에서 위지윅 에디터로 컨버팅할 때 요소의 렌더링 결과를 변경할 수 있다. 반대로 위지윅 에디터에서 마크다운 에디터로 컨버팅할 때 변환되는 마크다운 텍스트를 재정의할 수 있다. `toHTMLRenderers`, `toMarkdownRenderers` 옵션을 사용하여 마크다운 => 위지윅, 위지윅 => 마크다운 컨버팅 시 수행할 동작을 추가할 수 있다. ```ts return { toHTMLRenderers: { // ... tableCell(node: MergedTableCellMdNode, { entering, origin }) { const result = origin!(); // ... return result; }, }, toMarkdownRenderers: { // ... tableHead(nodeInfo) { const row = (nodeInfo.node as ProsemirrorNode).firstChild; let delim = ''; if (row) { row.forEach(({ textContent, attrs }) => { const headDelim = createTableHeadDelim(textContent, attrs.align); delim += `| ${headDelim} `; // ... }); } return { delim }; }, }, }; ``` 위의 코드는 병합 테이블 플러그인의 예시이다. `toHTMLRenderers`에 정의된 `tableCell` 노드의 반환 결과는 마크다운 프리뷰와 위지윅 에디터로 컨버팅 시 사용되며, `toMarkdownRenderers`에 정의된 `tableHead` 노드의 텍스트 결과는 마크다운 에디터로 컨버팅 시 사용된다. 각 에디터로 컨버팅 시 수행될 동작을 노드별 옵션으로 명확하게 설정할 수 있다. #### 툴바 아이템 등록 플러그인에서 툴바 아이템을 등록하는 방식 역시 변경되었다. 앞서 설명한 툴바 커스터마이징 옵션과 유사하며, 어느 그룹에 추가될지 인덱스 정보만 추가로 설정하면 된다. ```ts return { // ... toolbarItems: [ { groupIndex: 0, itemIndex: 3, item: toolbarItem, }, ], }; ``` 위의 코드처럼 `toolbarItems` 배열에 어떤 아이템을 추가할지 설정할 수 있다. 각 옵션 객체는 `groupIndex`, `itemIndex`, `item` 세가지 프로퍼티가 있으며 다음과 같은 역할을 한다. * `groupIndex`: 툴바 아이템을 추가할 그룹의 인덱스를 지정한다. * `itemIndex`: 지정한 그룹 내에서 몇 번째로 추가할지 인덱스를 지정한다. * `item`: 추가할 툴바 아이템 요소를 지정한다. 만약 예제 코드처럼 `toolbarItems` 옵션을 설정한다면, 1번째 툴바 그룹의 4번째 인덱스로 툴바 아이템을 등록할 것이다. 이외에도 마크다운, 위지윅 에디터의 Prosemirror 플러그인을 등록하는 방법, `eventEmitter`로 에디터와 플러그인 간의 통신 등 몇 가지 여기서 소개하지 않는 옵션들이 있다. 해당 내용은 [플러그인 활용 가이드](https://github.com/nhn/tui.editor/tree/master/docs/ko/plugin.md)를 자세히 읽어보길 권장한다. ### 4. API와 이벤트 3.0에서 변경된 API의 시그니처와 이벤트 명은 다음과 같다. #### 커맨드 등록하려는 커맨드의 옵션을 이름과 핸들러로 이루어진 객체 형태가 아닌 각각의 인자로 넘겨주는 형태로 변경되었다. 또한 커맨드를 실행하는 메서드의 인자 형태도 변경되었다. **v2.x** | 메서드 시그니처 | 반환 타입 | | ----------------- | ------------ | | `addCommand(type: string, props: { name: string; exec: Command }` | `void` | | `exec(name: string, ...args: any[]`) | `void` | **v3.0** | 메서드 시그니처 | 반환 타입 | | ----------------- | ------------ | | `addCommand(type: string, name: string, command: CommandFn)` | `void` | | `exec(name: string, payload?: Object)` | `void` | #### 텍스트 조작 API 기존에는 `getTextObject()` API를 사용하여 에디터 내에 텍스트를 삽입하거나 교체하였다. 하지만 `getTextObject()` API가 반환하는 인스턴스의 구조를 알아야 한다는 단점이 있었다. 3.0에서는 해당 API를 삭제하고 텍스트 삽입, 교체, 삭제 동작을 수행하는 API를 별도로 추가하였다. **v2.x** `TextObject`의 인터페이스 ```ts interface TextObject { setRange(range): void; setEndBeforeRange(range): void; expandStartOffset(): void; expandEndOffset(): void; getTextContent(): string; replaceContent(content) : void; deleteContent(): void; peekStartBeforeOffset(offset): Range; } ``` **v3.0** | 메서드 시그니처 | 반환 타입 | 비고 | | ----------------- | ------------ | ------------ | | `replaceSelection(text: string, start?: EditorPos, end?: EditorPos) ` | `void` | 특정 범위의 텍스트를 교체한다. 범위를 지정하지 않을 경우 현재 에디터의 셀렉션 범위 내의 텍스트를 수정한다. | | `deleteSelection(start?: EditorPos, end?: EditorPos)` | `void` | 특정 범위의 텍스트를 삭제한다. 범위를 지정하지 않을 경우 현재 에디터의 셀렉션 범위 내의 텍스트를 삭제한다. | | `getSelectedText(start?: EditorPos, end?: EditorPos)` | `string` | 특정 범위의 텍스트를 가져온다. 범위를 지정하지 않을 경우 현재 에디터의 셀렉션 범위 내의 텍스트를 가져온다. | 위의 API들의 위치 정보(`EditorPos`)는 마크다운 에디터, 위지윅 에디터에 따라 다르며, 아래와 같은 형태이다. 이는 마크다운과 위지윅의 위치 계산 정보 방법이 다르기 때문이다. 마크다운은 라인을 기준으로 계산하여, 위지윅은 문서 시작을 기준으로 오프셋을 계산한다. ```ts // 마크다운 위치 정보 type EditorPos = [line: number, charactorOffset: number]; // 위지윅 위치 정보 type EditorPos = number; // 오프셋 ``` #### 인스턴스 생성 옵션 및 메서드 변경 표기가 잘못되거나 기능이 명확하지 않은 옵션과 메서드가 변경되었다. * 인스턴스 생성 옵션 | v2 | v3 | | --- | --- | | `linkAttribute` | `linkAttributes` | * 인스턴스 메서드 | v2 | v3 | | --- | --- | | `setHtml` | `setHTML` | | `getHtml` | `getHTML` | | `minHeight` | `setMinHeight`, `getMinHeight` | | `height` | `setHeight`, `getHeight` | | `getRange` | `getSelection` | | `remove` | `destroy` | #### 이벤트 명 변경 몇몇 이벤트 명도 더 명확한 의미를 전달을 위해 변경되었다. | v2 | v3 | | --- | --- | | `stateChange` | `caretChange` | | `convertorAfterMarkdownToHtmlConverted` | `beforePreviewRender` | | `convertorAfterHtmlToMarkdownConverted` | `beforeConvertWysiwygToMarkdown` | ### 5. 지원 브라우저 범위 v3.0부터 지원 브라우저 범위가 **인터넷 익스플로러 11 이상**으로 변경된다. 이전 버전에서는 인터넷 익스플로러 10 이상을 지원하였으나 낮은 브라우저 점유율 및 Prosemirror 코어 모듈 사용을 위해 지원 범위를 변경하게 되었다. ## 제거된 기능 ### 1. jQuery Wrapper 제거 v3.0부터는 jQuery Wrapper가 제거되었다. jQuery에서 사용을 원하는 경우 직접 `@toast-ui/editor` 패키지를 랩핑하여 사용해야 한다. ### 2. 의존성 제거 기존의 CodeMirror, squire, to-mark 의존성이 모두 제거되었기 때문에 공식적인 API를 통해서든 비공식적 방법이든 해당 모듈에 직접 접근하여 조작하던 코드는 더 이상 동작하지 않을 것이다. 대부분의 필요한 기능은 에디터 인스턴스 API로 추가되었으니 해당 API를 사용하길 권장한다. **v2.x** ```js const editor = new Editor(/* */); console.log(editor.getCodeMirror()); // CodeMirror 인스턴스 console.log(editor.getSquire()); // squire 인스턴스 ``` **v3.0** ```js const editor = new Editor(/* */); console.log(editor.getCodeMirror()); // Uncaught TypeError console.log(editor.getSquire()); // Uncaught TypeError ``` ### 3. 제거된 API 마지막으로, 에디터 v3.0에서 제거된 API를 정리한 목록이다. #### 정적 속성 | 이름 | 타입 | | --------------------- | ------------------ | | `isViewer` | `{boolean}` | | `codeBlockManager` | `{CodeBlockManager}` | | `WwCodeBlockManager` | `{Class.}` | | `WwTableManager` | `{Class.}` | | `WwTableSelectionManager` | `{Class.}` | | `CommandManager` | `{Class.}` | #### 정적 메서드 | 이름 | 타입 | | ----------------- | ------------ | | `getInstances` | `{function}` | #### 인스턴스 생성 옵션 | 이름 | 타입 | | -------------------- | -------------------------- | | `useDefaultHTMLSanitizer` | `boolean` | #### 인스턴스 메서드 | 이름 | 타입 | | ---------- | ------------ | | `setCodeBlockLanguages` | `{function}` | | `afterAddedCommand` | `{function}` | | `getCodeMirror` | `{function}` | | `getSquire` | `{function}` | | `getCurrentModeEditor` | `{function}` | | `getUI` | `{function}` | ================================================ FILE: docs/v3.0-migration-guide.md ================================================ ## Introduction This migration guide includes all information regarding changes users must be aware of when updating from TOAST UI Editor 2.x to TOAST UI Editor 3.0. TOAST UI Editor (hereafter referred to as the 'Editor') has removed the original [CodeMirror](https://codemirror.net/), squire, and to-mark dependencies and has modified the editor to use abstract models through Prosemirror. Since the core module API and plugin usages were changed, it is advised that users consult the migration guide carefully. The table of contents is as follows and refers to the 'Changes' in the order enumerated when updating. ## Table of Contents - [Changes](#changes) 1. [Installation and Usages](#1-installation-and-usages) 2. [Customizing the Toolbar](#2-customizing-the-toolbar) 3. [Defining Plugins](#3-defining-plugins) 4. [APIs and Events](#4-APIs-and-events) 5. [Supported Browsers](#5-supported-browsers) - [Removed Features](#removed-features) 1. [Removed jQuery Wrapper](#1-removed-jquery-wrapper) 2. [Removed Dependencies](#2-removed-dependencies) 3. [Removed APIs](#3-removed-apis) ## Changes ### 1. Installation and Usages To use the Editor, use the [scoped package](https://docs.npmjs.com/using-npm/scope.html) to install the `@toast-ui/editor` package as you did for the previous v2.x. The following is an example of using the npm command to install the Editor. ```sh $ npm install @toast-ui/editor $ npm install @toast-ui/editor@ ``` #### Usages ```js const Editor = require('@toast-ui/editor'); /* CommonJS */ import Editor from '@toast-ui/editor'; /* ES6 Module */ ``` Furthermore, v3.0 provides an `EditorCore` module as named export for those wanting to implement their own unique UI instead of using the default UI. This module will create the markdown editor, markdown preview, and WYSIWYG editor, and the user can use the `getEditorElements()` method to add the editor to desired UI. This module does not create external editor UIs like toolbars, toolbar popups, and switch tabs. ```js import { EditorCore } from '@toast-ui/editor'; /* ES6 Module */ const editorCore = new EditorCore({ el // ... }); const { mdEditor, mdPreview, wwEditor } = editorCore.getEditorElements(); // ... ``` #### Bundle Structure Aside from the original v2.x bundle content, two new items were added to v3.0. In addition to the original legacy support bundle and the cdn bundle, ESM bundle is included. ESM bundle is lightweight due to the fact that there is no complex module compatibility statement, and it also provides the bundle with the added benefit of tree shaking via static analysis. Secondly, the `theme/toastui-editor-dark.css` is added for the dark theme support. The dark theme will be covered more in depth in [Added Dark Theme](#-added-dark-theme). The bundle structure for v3.0 is as follows. ``` - dist/ ├─ cdn/... ├─ i18n/... ├─ esm/ │ ├─ index.js │ └─ index.js.map ├─ theme/ │ └─ toastui-editor-dark.css │ ├─ toastui-editor-only.css ├─ toastui-editor-viewer.css ├─ toastui-editor.css ├─ toastui-editor.js └─ toastui-editor-viewer.js ``` Furthermore, the ESM bundle is included in the v3.0, and the package.json file has been updated accordingly. The original UMD bundle file is defined in the main field, and the ESM bundle file is defined in the exports field. ```json { "main": "dist/toastui-editor.js", "module": "dist/esm/", "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/toastui-editor.js" }, "./viewer": { "import": "./dist/esm/indexViewer.js", "require": "./dist/toastui-editor-viewer.js" } } } ``` #### Added Dark Theme v3.0 ships with dark theme included. To apply the dark theme, add the `theme/toastui-editor-dark.css` and set the editor's `theme` option to be `dark`. Currently, in v3.0, only the dark theme is supported, but the `theme` option was added to support more diverse combinations of themes in the future. ```js import Editor from '@toast-ui/editor'; import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/theme/toastui-editor-dark.css'; const editor = new Editor({ el: document.querySelector('#editor'), previewStyle: 'vertical', height: '500px', initialValue: content, theme: 'dark', }); ``` ![image](https://user-images.githubusercontent.com/37766175/120954138-73ab8680-c789-11eb-8445-87bf15842482.png) #### Changes in Dependencies Editor 3.0 no longer requires some of the dependent modules that were needed for v2.x. If you are using the CDN for development, the [CodeMirror](https://codemirror.net/) dependencies required for v2.x are no longer necessary and should be removed. The v3.0 requires Prosemirror and its related modules, but the change is reflected in the CDN, so there is nothing for the user to add. **v2.0** ```html ... ... ... ... ``` **v3.0** ```html ... ... ... ... ``` ### 2. Customizing the Toolbar The `toolbarItems` option has been reworked to be more concise and declarative compared to the v2.x. In v3.0, each toolbar item and toolbar groups are defined as options in **2D array** format. This method removes the need to define the `divider` for differentiating different groups, making the final code much more intuitive. **v2.0** ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ 'heading', 'bold', 'italic', 'strike', // The divider element had to be added to differentiate different groups. 'divider', 'hr', 'quote', 'divider', // ... ], // ... }); ``` **v3.0** ```js const editor = new Editor({ el: document.querySelector('#editor'), toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], // ... ], // ... }); ``` By looking at the example code above, it is clear that the v3.0 code is more concise and that the group separation is made easier. #### Customization The method of customization has changed. In v2.x, when displaying or hiding a popup on toolbar item click, the coupling between the editor's `eventManager` and other UI instances were intricate. This forced the users to be familiar with the editor's internal implementations when customizing from the user's or from the plugin's perspective. It made customization difficult and created unnecessary control codes. In v3.0, the UI control codes have been capsulated internally in order to decrease the level of coupling, and users can now customize the toolbar items just by configuring the options. **v2.0** ```js const popup = editor.getUI().createPopup({ header: false, title: null, content: colorPickerContainer, className: 'tui-popup-color', target: editor.getUI().getToolbar().el, css: { width: 'auto', position: 'absolute' } }); editor.eventManager.listen('focus', () => { popup.hide(); // ... }); editor.eventManager.listen('colorButtonClicked', () => { // ... }); editor.eventManager.listen('closeAllPopup', () => { // ... }); ``` The code above is an example of customizing the toolbar's color picker in v2.x. In order to customize how the toolbar popup functions, users had to use API that were dependent on the editor's internal implementations like `editor.getUI().createPopup()` and `editor.getUI().getToolbar()`. Such internal dependencies make flexible customization difficult. It is not just the API. In order to manipulate the popup, users had to register multiple events to the `eventManager`. **v3.0** ```js const popup = { name: 'color', tooltip: 'Text color', className: 'toastui-editor-toolbar-icons color', popup: { className: 'toastui-editor-popup-color', body: colorPickerContainer, style: { width: 'auto' }, }, }; ``` Few bits of codes have been intentionally left out, but in v3.0, users can create and control the popup UI through a simple option object. Users no longer need to be familiar with the internal UI modules and just have to define the `popup` option object's `className`, `style`, and `body` properties to trigger a popup on click of a toolbar button. For more information regarding customizing the toolbar, refer to [this link](https://github.com/nhn/tui.editor/tree/master/docs/en/toolbar.md). ![image](https://user-images.githubusercontent.com/37766175/120915630-b6b11f80-c6df-11eb-8094-b264ca9312a1.gif) ### 3. Defining Plugins The biggest change of the v3.0 is in defining plugins. In v2.x, plugins were also incredibly dependent on the editor's internal modules as seen in the previous toolbar section. For plugins, the users had to be especially familiar with the markdown editor, WYSIWYG editor, converter, and editor's other internal instances and how they function. In v3.0, in order to address this issue, defining plugins have been reworked to include clearly defined options for customizing each feature. This guide will briefly discuss the options, and for in depth guide on defining plugins can be found [here](https://github.com/nhn/tui.editor/tree/master/docs/en/plugin.md). #### Registering Commands Users can register markdown and WYSIWYG commands through `markdownCommands` and `wysiwygCommands` options for plugins. ```js return { markdownCommands: { myCommand: (payload, state, dispatch) => { // ... }, }, wysiwygCommands: { myCommand: (payload, state, dispatch) => { // ... }, }, }; ``` Each command takes `payload`, `state`, and `dispatch` as inputs, and these three parameters can be used to control the internal functionalities of Prosemirror based editor. This method also requires that users be familiar with Prosemirror. However, the Editor will continue to provide our own basic commands, which will prevent users having to work with the internal implementations directly. #### Converting Users can now change the result of a render that happens when a certain element is converted from markdown to preview or from markdown editor to WYSIWYG editor. The same is true in reverse. Users can now redefine the text of the element that is converted from WYSIWYG editor to markdown editor. The `toHTMLRenderers` and `toMarkdownRenderers` options can be used to define what happens during the conversion from markdown to WYSIWYG and from WYSIWYG to markdown. ```ts return { toHTMLRenderers: { // ... tableCell(node: MergedTableCellMdNode, { entering, origin }) { const result = origin!(); // ... return result; }, }, toMarkdownRenderers: { // ... tableHead(nodeInfo) { const row = (nodeInfo.node as ProsemirrorNode).firstChild; let delim = ''; if (row) { row.forEach(({ textContent, attrs }) => { const headDelim = createTableHeadDelim(textContent, attrs.align); delim += `| ${headDelim} `; // ... }); } return { delim }; }, }, }; ``` The above code is an example of merged table plugin. The `tableCell`, defined in `toHTMLRenderers`, node's return value is used for the markdown preview and WYSIWYG editor conversion, and the `tableHead`, defined in `toMarkdownRenderers` node's text value is used for markdown editor conversion. Any process during each editor's conversion can be defined per node through options. #### Registering Toolbar Items The method of registering toolbar items in plugins have changed. The options are similar to the previously explained toolbar customization options. The user just needs to configure the index of the to be added group. ```ts return { // ... toolbarItems: [ { groupIndex: 0, itemIndex: 3, item: toolbarItem, }, ], }; ``` As the code above shows, users can configure which item to add to the `toolbarItems` array. Each option object has `groupIndex`, `itemIndex`, and `item` properties and serves the following purpose. * `groupIndex`: Defines the index of the group that the item will be added to. * `itemIndex`: Defines the index of the item to be placed in the determined group. * `item`: Defines the toolbar item to be added. Following the example code, the `toolbarItems` option will make it so that the toolbar item will be added to the first toolbar group's fourth index. Additionally, there are options that this document does not cover including registering markdown and WYSIWYG editor's Prosemirror plugin, using the `eventEmitter` for communication between the editor and the plugin. For more information, it is recommended that users consult the [Guide to Using Plugins](https://github.com/nhn/tui.editor/tree/master/docs/en/plugin.md). ### 4. APIs and Events The following are the API signatures and event names that have changed as of v3.0. #### Commands The options of the commands to be registered are now passed in as individual inputs instead of an object consisting of the name and the handler. Furthermore, the input format of the method that executes the command has changed as well. **v2.x** | Method Signature | Returned Type | | ----------------- | ------------ | | `addCommand(type: string, props: { name: string; exec: Command }` | `void` | | `exec(name: string, ...args: any[]`) | `void` | **v3.0** | Method Signature | Returned Type | | ----------------- | ------------ | | `addCommand(type: string, name: string, command: CommandFn)` | `void` | | `exec(name: string, payload?: Object)` | `void` | #### Text Manipulation API Originally, the `getTextObject()` API was used to insert or change text from the editor. However, in order to use this, users had to be familiar with the structure of the instance returned by the `getTextObject()` API. In v3.0, the `getTextObject()` API has been replaced with individual APIs that gets, replaces, and deletes text. **v2.x** `TextObject`'s Interface ```ts interface TextObject { setRange(range): void; setEndBeforeRange(range): void; expandStartOffset(): void; expandEndOffset(): void; getTextContent(): string; replaceContent(content) : void; deleteContent(): void; peekStartBeforeOffset(offset): Range; } ``` **v3.0** | Method Signature | Returned Type | Notes | | ----------------- | ------------ | ------------ | | `replaceSelection(text: string, start?: EditorPos, end?: EditorPos) ` | `void` | Replaces the text at the given range. If the range is not provided, the text present in current editor's selected range is replaced. | | `deleteSelection(start?: EditorPos, end?: EditorPos)` | `void` | Deletes the text at the given range. If the range is not provided, the text present in current editor's selected range is replaced. | | `getSelectedText(start?: EditorPos, end?: EditorPos)` | `string` | Gets the text at the given range. If the range is not provided, the text present in current editor's selected range is retrieved. | The above APIs' positional information (`EditorPos`) differs from markdown editor to WYSIWYG editor, and has the following format. This is because markdown and WYSIWYG have different ways to calculate position. Markdown calculates position based on the line, and the WYSIWYG calculates the offset from the start of the document. ```ts // Markdown's Position Information type EditorPos = [line: number, charactorOffset: number]; // WYSIWYG's Position Information type EditorPos = number; // 오프셋 // Offset ``` #### Changed Instance Constructing Options and Methods There were changes in options and methods that were not named properly or that did not clearly indicate its feature. * Instance Constructing Option | v2 | v3 | | --- | --- | | `linkAttribute` | `linkAttributes` | * Instance Methods | v2 | v3 | | --- | --- | | `setHtml` | `setHTML` | | `getHtml` | `getHTML` | | `minHeight` | `setMinHeight`, `getMinHeight` | | `height` | `setHeight`, `getHeight` | | `getRange` | `getSelection` | | `remove` | `destroy` | #### Changed Event Names Some events were renamed to represent their meaning more clearly. | v2 | v3 | | --- | --- | | `stateChange` | `caretChange` | | `convertorAfterMarkdownToHtmlConverted` | `beforePreviewRender` | | `convertorAfterHtmlToMarkdownConverted` | `beforeConvertWysiwygToMarkdown` | ### 5. Supported Browsers From v3.0, only the browsers **above Internet Explorer (IE) 11** will be supported. The previous version supported IE 10 and above, but the support range has been changed due to the low browser share and Prosemirror core module support. ## Removed Features ### 1. Removed jQuery Wrapper From v3.0, the jQuery Wrapper has been removed. To use jQuery, the user must wrap the `@toast-ui/editor` package separately. ### 2. Removed Dependencies Because original CodeMirror, squire, and to-mark dependencies were all removed, any code that accesses these modules directly or indirectly will no longer work. Most of the required features were added to the editor instance's API, and it is recommended that users use the according APIs. **v2.x** ```js const editor = new Editor(/* */); console.log(editor.getCodeMirror()); // CodeMirror Instance console.log(editor.getSquire()); // squire Instance ``` **v3.0** ```js const editor = new Editor(/* */); console.log(editor.getCodeMirror()); // Uncaught TypeError console.log(editor.getSquire()); // Uncaught TypeError ``` ### 3. Removed APIs Lastly, the following is a list of APIs removed from the v3.0. #### Static Properties | Name | Type | | --------------------- | ------------------ | | `isViewer` | `{boolean}` | | `codeBlockManager` | `{CodeBlockManager}` | | `WwCodeBlockManager` | `{Class.}` | | `WwTableManager` | `{Class.}` | | `WwTableSelectionManager` | `{Class.}` | | `CommandManager` | `{Class.}` | #### Static Methods | Name | Type | | ----------------- | ------------ | | `getInstances` | `{function}` | #### Instance Constructing Option | Name | Type | | -------------------- | -------------------------- | | `useDefaultHTMLSanitizer` | `boolean` | #### Instance Method | Name | Type | | ---------- | ------------ | | `setCodeBlockLanguages` | `{function}` | | `afterAddedCommand` | `{function}` | | `getCodeMirror` | `{function}` | | `getSquire` | `{function}` | | `getCurrentModeEditor` | `{function}` | | `getUI` | `{function}` | ================================================ FILE: jest-setup.js ================================================ import '@testing-library/jest-dom'; if (global.Range) { global.Range.prototype.getClientRects = jest.fn().mockReturnValue({ length: 0 }); global.Range.prototype.getBoundingClientRect = jest.fn().mockReturnValue({}); } ================================================ FILE: jest.base.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const path = require('path'); const setupFile = path.resolve(__dirname, './jest-setup.js'); const cssMockFile = path.resolve(__dirname, './__mocks__/cssMock.js'); module.exports = { preset: 'ts-jest', testEnvironment: 'node', setupFilesAfterEnv: [setupFile], transform: { '^.+\\.ts$': 'ts-jest', '^.+\\.js$': 'jest-esm-transformer', '^.+\\.css$': cssMockFile, }, transformIgnorePatterns: ['/node_modules/'], snapshotSerializers: ['jest-serializer-html'], testMatch: ['**/__test__/**/*.spec.ts'], moduleFileExtensions: ['ts', 'js', 'json'], }; ================================================ FILE: jest.config.js ================================================ module.exports = { projects: [ '/libs/toastmark/jest.config.js', '/apps/editor/jest.config.js', '/plugins/color-syntax/jest.config.js', '/plugins/code-syntax-highlight/jest.config.js', '/plugins/uml/jest.config.js', '/plugins/chart/jest.config.js', ], }; ================================================ FILE: lerna.json ================================================ { "packages": ["apps/*", "plugins/*", "libs/*"], "version": "3.2.2" } ================================================ FILE: libs/toastmark/.eslintrc.js ================================================ module.exports = { rules: { 'prefer-destructuring': 0, 'padding-line-between-statements': 0, 'lines-between-class-members': 0, 'no-undefined': 0, 'no-useless-escape': 0, 'no-shadow': 0, 'no-plusplus': 0, 'max-depth': 0, '@typescript-eslint/no-empty-function': 0, 'no-lonely-if': 0, 'no-control-regex': 0, 'no-nested-ternary': 0, 'no-empty': 0, 'dot-notation': 0, 'spaced-comment': 0, eqeqeq: 0, }, }; ================================================ FILE: libs/toastmark/LICENSE ================================================ MIT License Copyright (c) 2020 NHN Corp. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- The files inside src/commonmark/ are derived from commonmark.js (except files in gfm and __test__ directory) Copyright (c) 2014, John MacFarlane All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- src/commonmark/from-code-point.js is derived from a polyfill Copyright Mathias Bynens Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- The test cases in src/commonmark/__test__/base-examples.json are copied from commonmark spec. Copyright (C) 2014-15 John MacFarlane Released under the Creative Commons CC-BY-SA 4.0 license: . ================================================ FILE: libs/toastmark/README.md ================================================ # ToastMark ToastMark is a markdown parser extended from [commonmark.js](https://github.com/commonmark/commonmark.js), with more advanced features to be used within TOAST UI Editor. > Currently, ToastMark is for interal usage only as the API's are supposed to be changed frequently. We are planning to register this as a separate npm package when API's are stabilized. ## Differences from commonmark.js ### GitHub Flavored Markdown(GFM) Support commonmark.js is the reference implementation of [CommonMark](https://spec.commonmark.org/0.29/) and doesn't support [GFM](https://github.github.com/gfm/), which is extended markdown syntax based on CommonMark. ToastMark has its own implementation for supporting GFM. ### Source Position Information Although commonmark.js provides source position information related with each node in AST(Abstract Syntax Tree), those are limited to block-level elements. ToastMark extended this feature to provide source position information for inline-level elements also. ### Incremental Parsing As ToastMark is developed for the purpose of improving markdown editing experience, this must be the key feature of ToastMark. Instead of parsing the entire document whenever a user makes a change to a document, ToastMark parses only changed part of the document and update the existing AST. It also returns information about removed and inserted nodes, which can be used to update syntax highlithing or preview contents incrementally. ### Searching and Editing AST ToastMark provides useful methods to search the existing AST, such as `findNodeAtPosition` and `findNodeById`. These methods can be used for synchronizing scroll position of markdown editor and preview contents, updating the style of the toolbar buttons correspond to the cursor position, and so on. We are also planning to add more methods to edit existing AST to support commands like `Bold`, `Italic`, and `OrderedList` which can be triggered by toolbar buttons and keyboard shortcuts. ### TypeScript The entire codebase is converted from JavaScript to TypeScript. ================================================ FILE: libs/toastmark/demo/index.html ================================================ Demo ================================================ FILE: libs/toastmark/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, }; ================================================ FILE: libs/toastmark/package.json ================================================ { "name": "@toast-ui/toastmark", "version": "0.0.1-alpha.1", "description": "ToastMark - Incremental markdown parser extended from CommonMark.js", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "build": "rollup -c && webpack build" }, "types": "types/index.d.ts", "files": [ "dist", "types" ], "keywords": [ "markdown", "parser" ], "author": "NHN", "main": "dist/toastmark.js", "module": "dist/esm/index.js", "license": "MIT", "devDependencies": { "@types/codemirror": "5.60.5", "@types/mdurl": "^1.0.2", "codemirror": "^5.51.0", "entities": "^2.0.0", "mdurl": "^1.0.1" } } ================================================ FILE: libs/toastmark/rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import json from '@rollup/plugin-json'; export default [ { input: 'src/index.ts', output: { dir: 'dist/esm', format: 'es', sourcemap: false, }, plugins: [typescript(), commonjs(), nodeResolve(), json()], }, ]; ================================================ FILE: libs/toastmark/snowpack.config.js ================================================ /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { demo: '/', src: '/dist', }, devOptions: { port: 8000, }, alias: { '@t': './types', }, }; ================================================ FILE: libs/toastmark/src/__sample__/index.css ================================================ .container { display: flex; height: 500px; } .container > div { flex: 1; border: 1px solid #ccc; overflow-x: hidden; overflow-y: auto; } .CodeMirror { height: 500px; } ================================================ FILE: libs/toastmark/src/__sample__/index.ts ================================================ import codemirror from 'codemirror'; import { ToastMark } from '../toastmark'; import { Renderer } from '../html/renderer'; import { last } from '../helper'; import 'codemirror/lib/codemirror.css'; import './index.css'; document.body.innerHTML = `
                          `; const editorEl = document.querySelector('.editor') as HTMLElement; const htmlEl = document.querySelector('.html') as HTMLElement; const previewEl = document.querySelector('.preview') as HTMLElement; const cm = codemirror(editorEl, { lineNumbers: true }); const doc = new ToastMark(); const renderer = new Renderer({ gfm: true, nodeId: true }); const tokenTypes = { heading: 'header', emph: 'em', strong: 'strong', strike: 'strikethrough', item: 'variable-2', image: 'variable-3', blockQuote: 'quote', }; type TokenTypes = typeof tokenTypes; cm.on('change', (editor, changeObj) => { const { from, to, text } = changeObj; const changed = doc.editMarkdown( [from.line + 1, from.ch + 1], [to.line + 1, to.ch + 1], text.join('\n') ); changed.forEach((result) => { const { nodes, removedNodeRange } = result; const html = renderer.render(doc.getRootNode()); htmlEl.innerText = html; if (!removedNodeRange) { previewEl.innerHTML = html; } else { const [startNodeId, endNodeId] = removedNodeRange.id; const startEl = previewEl.querySelector(`[data-nodeid="${startNodeId}"]`); const endEl = previewEl.querySelector(`[data-nodeid="${endNodeId}"]`); const newHtml = nodes.map((node) => renderer.render(node)).join(''); if (startEl) { startEl.insertAdjacentHTML('beforebegin', newHtml); let el: Element = startEl; while (el !== endEl) { const nextEl: Element | null = el.nextElementSibling; el.remove(); el = nextEl!; } el.remove(); } } if (!nodes.length) { return; } const editFromPos = nodes[0].sourcepos![0]; const editToPos = last(nodes).sourcepos![1]; const editFrom = { line: editFromPos[0] - 1, ch: editFromPos[1] - 1 }; const editTo = { line: editToPos[0] - 1, ch: editToPos[1] }; const marks = cm.findMarks(editFrom, editTo); for (const mark of marks) { mark.clear(); } for (const parent of nodes) { const walker = parent.walker(); let event; while ((event = walker.next())) { const { node, entering } = event; if (entering) { const [startLine, startCh] = node.sourcepos![0]; const [endLine, endCh] = node.sourcepos![1]; const start = { line: startLine - 1, ch: startCh - 1 }; const end = { line: endLine - 1, ch: endCh }; const token = tokenTypes[node.type as keyof TokenTypes]; if (token) { cm.markText(start, end, { className: `cm-${token}` }); } } } } }); }); ================================================ FILE: libs/toastmark/src/__test__/toastmark.spec.ts ================================================ import { ParserOptions } from '@t/parser'; import { ToastMark } from '../toastmark'; import { Parser } from '../commonmark/blocks'; import { getChildNodes } from '../nodeHelper'; import { Node } from '../commonmark/node'; function removeIdAttrFromAllNode(root: Node) { const walker = root.walker(); let event; while ((event = walker.next())) { const { entering, node } = event; if (entering) { // @ts-ignore delete node.id; } } } function assertParseResult(doc: ToastMark, lineTexts: string[], options?: Partial) { expect(doc.getLineTexts()).toEqual(lineTexts); const reader = new Parser(options); const root = doc.getRootNode(); const expectedRoot = reader.parse(lineTexts.join('\n')); removeIdAttrFromAllNode(root); removeIdAttrFromAllNode(expectedRoot); expect(root).toEqual(expectedRoot); } function assertResultNodes(doc: ToastMark, nodes: Node[], startIdx = 0) { const root = doc.getRootNode(); const newNodes = getChildNodes(root); for (let i = 0; i < nodes.length; i += 1) { expect(nodes[i]).toBe(newNodes[i + startIdx]); } } describe('findNodeAtPosition()', () => { it('should return a node at the given position', () => { const doc = new ToastMark('# Hello *World*\n\n- Item 1\n- Item **2**'); expect(doc.findNodeAtPosition([1, 1])).toMatchObject({ type: 'heading', }); expect(doc.findNodeAtPosition([1, 3])).toMatchObject({ type: 'text', literal: 'Hello ', }); expect(doc.findNodeAtPosition([1, 9])).toMatchObject({ type: 'emph', firstChild: { type: 'text', literal: 'World', }, }); expect(doc.findNodeAtPosition([1, 10])).toMatchObject({ type: 'text', literal: 'World', }); expect(doc.findNodeAtPosition([3, 1])).toMatchObject({ type: 'item', }); expect(doc.findNodeAtPosition([3, 3])).toMatchObject({ type: 'text', literal: 'Item 1', }); expect(doc.findNodeAtPosition([4, 8])).toMatchObject({ type: 'strong', firstChild: { type: 'text', literal: '2', }, }); expect(doc.findNodeAtPosition([4, 10])).toMatchObject({ type: 'text', literal: '2', }); expect(doc.findNodeAtPosition([5, 1])).toBeNull(); }); it('should return null if matched node does not exist', () => { const doc = new ToastMark('# Hello\n\nWorld'); // position in between two node (blank line) expect(doc.findNodeAtPosition([2, 1])).toBeNull(); // position out of document range expect(doc.findNodeAtPosition([4, 1])).toBeNull(); }); }); describe('findFirstNodeAtLine()', () => { const markdown = [ '# Hello *World*', '', '- Item D1', ' - Item D2', '![Image](URL)', '', 'Paragraph', ].join('\n'); it('should return the first node at the given line', () => { const doc = new ToastMark(markdown); expect(doc.findFirstNodeAtLine(1)).toMatchObject({ type: 'heading' }); expect(doc.findFirstNodeAtLine(3)).toMatchObject({ type: 'list', prev: { type: 'heading' }, }); expect(doc.findFirstNodeAtLine(4)).toMatchObject({ type: 'list', parent: { type: 'item' }, }); expect(doc.findFirstNodeAtLine(5)).toMatchObject({ type: 'image' }); expect(doc.findFirstNodeAtLine(7)).toMatchObject({ type: 'paragraph' }); }); it('if the given line is blank, returns the first node at the previous line', () => { const doc = new ToastMark(markdown); expect(doc.findFirstNodeAtLine(2)).toMatchObject({ type: 'heading' }); expect(doc.findFirstNodeAtLine(6)).toMatchObject({ type: 'image' }); expect(doc.findFirstNodeAtLine(8)).toMatchObject({ type: 'paragraph' }); }); it('should return null if nothing mathces', () => { const doc = new ToastMark('\n\n'); expect(doc.findFirstNodeAtLine(0)).toBeNull(); expect(doc.findFirstNodeAtLine(1)).toBeNull(); expect(doc.findFirstNodeAtLine(2)).toBeNull(); expect(doc.findFirstNodeAtLine(3)).toBeNull(); }); }); describe('editText()', () => { describe('single paragraph', () => { it('should insert character within a line', () => { const doc = new ToastMark('Hello World'); const result = doc.editMarkdown([1, 6], [1, 6], ',')[0]; assertParseResult(doc, ['Hello, World']); assertResultNodes(doc, result.nodes); }); it('should remove entire text', () => { const doc = new ToastMark('Hello World'); const result = doc.editMarkdown([1, 1], [1, 12], '')[0]; assertParseResult(doc, ['']); assertResultNodes(doc, result.nodes); }); it('should remove preceding newline', () => { const doc = new ToastMark('\nHello World'); const result = doc.editMarkdown([1, 1], [2, 1], '')[0]; assertParseResult(doc, ['Hello World']); assertResultNodes(doc, result.nodes); }); it('should remove last newline', () => { const doc = new ToastMark('Hello World\n'); const result = doc.editMarkdown([1, 12], [2, 1], '')[0]; assertParseResult(doc, ['Hello World']); assertResultNodes(doc, result.nodes); }); it('should insert characters and newlines', () => { const doc = new ToastMark('Hello World'); const result = doc.editMarkdown([1, 6], [1, 7], '!\n\nMy ')[0]; assertParseResult(doc, ['Hello!', '', 'My World']); assertResultNodes(doc, result.nodes); }); it('should replace multiline text with characters', () => { const doc = new ToastMark('Hello\nMy\nWorld'); const result = doc.editMarkdown([1, 5], [3, 3], 'ooo Wooo')[0]; assertParseResult(doc, ['Hellooo Wooorld']); assertResultNodes(doc, result.nodes); }); it('should prepend characters', () => { const doc = new ToastMark('Hello World'); const result = doc.editMarkdown([1, 1], [1, 1], 'Hi, ')[0]; assertParseResult(doc, ['Hi, Hello World']); assertResultNodes(doc, result.nodes); }); it('should append character', () => { const doc = new ToastMark('Hello World'); const result = doc.editMarkdown([1, 12], [1, 12], '!!')[0]; assertParseResult(doc, ['Hello World!!']); assertResultNodes(doc, result.nodes); }); it('should prepend newlines', () => { const doc = new ToastMark('Hello World'); const result = doc.editMarkdown([1, 1], [1, 1], '\n\n\n')[0]; assertParseResult(doc, ['', '', '', 'Hello World']); assertResultNodes(doc, result.nodes); }); it('should prepend characters (unmatched position)', () => { const doc = new ToastMark(' Hello World'); const result = doc.editMarkdown([1, 1], [1, 1], 'Hi,')[0]; assertParseResult(doc, ['Hi, Hello World']); assertResultNodes(doc, result.nodes); }); it('should insert newlines into preceding empty line of first paragraph', () => { const doc = new ToastMark('\nHello World'); const result = doc.editMarkdown([1, 1], [1, 1], '\n')[0]; assertParseResult(doc, ['', '', 'Hello World']); assertResultNodes(doc, result.nodes); }); it('should append characters with newline', () => { const doc = new ToastMark('Hello World\n'); const result = doc.editMarkdown([2, 1], [2, 1], '\nHi')[0]; assertParseResult(doc, ['Hello World', '', 'Hi']); assertResultNodes(doc, result.nodes); }); it('should remove lines with top blank line', () => { const doc = new ToastMark('\n\nabove linebreak\n\nHello World'); const result = doc.editMarkdown([1, 1], [5, 12], '')[0]; assertParseResult(doc, ['']); assertResultNodes(doc, result.nodes); }); it('should parse the table with including prev line', () => { const doc = new ToastMark('| a | b\n--| ---\n c'); const result = doc.editMarkdown([3, 1], [3, 3], '|c|')[0]; assertParseResult(doc, ['| a | b', '--| ---', '|c|']); assertResultNodes(doc, result.nodes); }); }); describe('multiple paragraph', () => { it('should insert paragraphs within multiple paragraphs', () => { const doc = new ToastMark('Hello\n\nMy\n\nWorld'); const result = doc.editMarkdown([1, 6], [5, 1], ',\n\nMy ')[0]; assertParseResult(doc, ['Hello,', '', 'My World']); assertResultNodes(doc, result.nodes); }); it('should replace multiple paragraphs with a heading', () => { const doc = new ToastMark('Hello\n\nMy\n\nWorld'); const result = doc.editMarkdown([1, 1], [5, 1], '# Hello ')[0]; assertParseResult(doc, ['# Hello World']); assertResultNodes(doc, result.nodes); }); it('should remove last block with newlines', () => { const doc = new ToastMark('Hello\n\nWorld\n'); const result = doc.editMarkdown([3, 1], [4, 1], '')[0]; assertParseResult(doc, ['Hello', '', '']); assertResultNodes(doc, result.nodes); }); it('should insert a characters in between paragraphs', () => { const doc = new ToastMark('Hello\n\nWorld'); const result = doc.editMarkdown([2, 1], [2, 1], 'My')[0]; assertParseResult(doc, ['Hello', 'My', 'World']); assertResultNodes(doc, result.nodes); }); it('update sourcepos for every next nodes', () => { const doc = new ToastMark('Hello\n\nMy\n\nWorld *!!*'); const result = doc.editMarkdown([1, 1], [1, 1], 'Hey,\n')[0]; assertParseResult(doc, ['Hey,', 'Hello', '', 'My', '', 'World *!!*']); assertResultNodes(doc, result.nodes); }); it('should update to fenced code blocks to the end of the document', () => { const doc = new ToastMark('``\nHello\n\nMy World'); const result = doc.editMarkdown([1, 3], [1, 3], '`')[0]; assertParseResult(doc, ['```', 'Hello', '', 'My World']); assertResultNodes(doc, result.nodes); }); it('should update to custom block to the end of the document', () => { const doc = new ToastMark('$\nHello\n\nMy World'); const result = doc.editMarkdown([1, 2], [1, 2], '$custom')[0]; assertParseResult(doc, ['$$custom', 'Hello', '', 'My World']); assertResultNodes(doc, result.nodes); }); }); describe('list item', () => { it('single empty item - append characters', () => { const doc = new ToastMark('-'); const result = doc.editMarkdown([1, 2], [1, 2], ' Hello')[0]; assertParseResult(doc, ['- Hello']); assertResultNodes(doc, result.nodes); }); it('single item paragraph - append characters', () => { const doc = new ToastMark('- Hello'); const result = doc.editMarkdown([1, 8], [1, 8], ' World')[0]; assertParseResult(doc, ['- Hello World']); assertResultNodes(doc, result.nodes); }); it('single item - append new item', () => { const doc = new ToastMark('- Hello'); const result = doc.editMarkdown([1, 8], [1, 8], '\n- World')[0]; assertParseResult(doc, ['- Hello', '- World']); assertResultNodes(doc, result.nodes); }); it('prepend a new list before an existing list', () => { const doc = new ToastMark('Hello\n\n- World'); const result = doc.editMarkdown([1, 1], [1, 1], '- ')[0]; assertParseResult(doc, ['- Hello', '', '- World']); assertResultNodes(doc, result.nodes); }); it('prepend a new list before a padded paragraph', () => { const doc = new ToastMark('\n\n My\n\n World'); const result = doc.editMarkdown([1, 1], [1, 1], '- Hello')[0]; assertParseResult(doc, ['- Hello', '', ' My', '', ' World']); assertResultNodes(doc, result.nodes); }); it('prepend a new list before a padded codeblock containing list-like text', () => { const doc = new ToastMark('\n\n - World'); const result = doc.editMarkdown([1, 1], [1, 1], '- Hello')[0]; assertParseResult(doc, ['- Hello', '', ' - World']); assertResultNodes(doc, result.nodes); }); it('convert a paragraph preceded by a list to a list ', () => { const doc = new ToastMark('- Hello\n\nWorld'); const result = doc.editMarkdown([3, 1], [3, 1], '- ')[0]; assertParseResult(doc, ['- Hello', '', '- World']); assertResultNodes(doc, result.nodes); }); it('add paddings to a paragraph preceded by a list', () => { const doc = new ToastMark('- Hello\n\nWorld'); const result = doc.editMarkdown([3, 1], [3, 1], ' ')[0]; assertParseResult(doc, ['- Hello', '', ' World']); assertResultNodes(doc, result.nodes); }); }); describe('Reference Def', () => { it('should parse reference link nodes when modifying url of Reference Def node', () => { const doc = new ToastMark('[foo]: /test\n\n[foo]\n\n[foo]', { referenceDefinition: true }); doc.editMarkdown([1, 1], [1, 13], '[foo]: /test2'); assertParseResult(doc, ['[foo]: /test2', '', '[foo]', '', '[foo]'], { referenceDefinition: true, }); }); it('should change reference link nodes to paragraph nodes when modifying label of Reference Def node', () => { const doc = new ToastMark('[foo]: /test\n\n[foo]\n\n[foo]', { referenceDefinition: true }); doc.editMarkdown([1, 1], [1, 13], '[food]: /test2'); assertParseResult(doc, ['[food]: /test2', '', '[foo]', '', '[foo]'], { referenceDefinition: true, }); }); it('should merge the Reference Def node as paragraph', () => { const doc = new ToastMark('test\n\n[foo]: /test', { referenceDefinition: true }); doc.editMarkdown([2, 1], [2, 1], 'test'); assertParseResult(doc, ['test', 'test', '[foo]: /test'], { referenceDefinition: true, }); }); }); }); it('return the node - findNodeById()', () => { const doc = new ToastMark('# Hello *World*\n\n- Item 1\n- Item **2**'); const firstNodeId = doc.findFirstNodeAtLine(1)!.id; expect(doc.findNodeById(firstNodeId)).toMatchObject({ type: 'heading', }); }); it('remove all node in the map - removeAllNode()', () => { const doc = new ToastMark('# Hello *World*\n\n- Item 1\n- Item **2**'); const firstNodeId = doc.findFirstNodeAtLine(1)!.id; doc.removeAllNode(); expect(doc.findNodeById(firstNodeId)).toEqual(null); }); describe('front matter', () => { it('should change normal paragraph to front matter ', () => { const doc = new ToastMark('---\ntitle: front matter\n--', { frontMatter: true }); doc.editMarkdown([3, 3], [3, 3], '-'); assertParseResult(doc, ['---', 'title: front matter', '---'], { frontMatter: true }); }); it('should change front matter to normal paragraph', () => { const doc = new ToastMark('---\ntitle: front matter\n---', { frontMatter: true }); doc.editMarkdown([3, 2], [3, 3], ''); assertParseResult(doc, ['---', 'title: front matter', '--'], { frontMatter: true }); }); it('should change front matter to setext heading', () => { const doc = new ToastMark('---\ntitle: front matter\n---', { frontMatter: true }); doc.editMarkdown([1, 1], [1, 1], 'heading\n'); assertParseResult(doc, ['heading', '---', 'title: front matter', '---'], { frontMatter: true }); }); it('following paragraph should be changed properly', () => { const doc = new ToastMark('---\ntitle: front matter\n---\npara', { frontMatter: true }); doc.editMarkdown([4, 1], [4, 5], 'changed'); assertParseResult(doc, ['---', 'title: front matter', '---', 'changed'], { frontMatter: true }); }); }); ================================================ FILE: libs/toastmark/src/commonmark/__test__/base-examples.json ================================================ [ { "markdown": "\tfoo\tbaz\t\tbim\n", "html": "
                          foo\tbaz\t\tbim\n
                          \n", "example": 1, "start_line": 352, "end_line": 357, "section": "Tabs" }, { "markdown": " \tfoo\tbaz\t\tbim\n", "html": "
                          foo\tbaz\t\tbim\n
                          \n", "example": 2, "start_line": 359, "end_line": 364, "section": "Tabs" }, { "markdown": " a\ta\n ὐ\ta\n", "html": "
                          a\ta\nὐ\ta\n
                          \n", "example": 3, "start_line": 366, "end_line": 373, "section": "Tabs" }, { "markdown": " - foo\n\n\tbar\n", "html": "
                            \n
                          • \n

                            foo

                            \n

                            bar

                            \n
                          • \n
                          \n", "example": 4, "start_line": 379, "end_line": 390, "section": "Tabs" }, { "markdown": "- foo\n\n\t\tbar\n", "html": "
                            \n
                          • \n

                            foo

                            \n
                              bar\n
                            \n
                          • \n
                          \n", "example": 5, "start_line": 392, "end_line": 404, "section": "Tabs" }, { "markdown": ">\t\tfoo\n", "html": "
                          \n
                            foo\n
                          \n
                          \n", "example": 6, "start_line": 415, "end_line": 422, "section": "Tabs" }, { "markdown": "-\t\tfoo\n", "html": "
                            \n
                          • \n
                              foo\n
                            \n
                          • \n
                          \n", "example": 7, "start_line": 424, "end_line": 433, "section": "Tabs" }, { "markdown": " foo\n\tbar\n", "html": "
                          foo\nbar\n
                          \n", "example": 8, "start_line": 436, "end_line": 443, "section": "Tabs" }, { "markdown": " - foo\n - bar\n\t - baz\n", "html": "
                            \n
                          • foo\n
                              \n
                            • bar\n
                                \n
                              • baz
                              • \n
                              \n
                            • \n
                            \n
                          • \n
                          \n", "example": 9, "start_line": 445, "end_line": 461, "section": "Tabs" }, { "markdown": "#\tFoo\n", "html": "

                          Foo

                          \n", "example": 10, "start_line": 463, "end_line": 467, "section": "Tabs" }, { "markdown": "*\t*\t*\t\n", "html": "
                          \n", "example": 11, "start_line": 469, "end_line": 473, "section": "Tabs" }, { "markdown": "- `one\n- two`\n", "html": "
                            \n
                          • `one
                          • \n
                          • two`
                          • \n
                          \n", "example": 12, "start_line": 496, "end_line": 504, "section": "Precedence" }, { "markdown": "***\n---\n___\n", "html": "
                          \n
                          \n
                          \n", "example": 13, "start_line": 535, "end_line": 543, "section": "Thematic breaks" }, { "markdown": "+++\n", "html": "

                          +++

                          \n", "example": 14, "start_line": 548, "end_line": 552, "section": "Thematic breaks" }, { "markdown": "===\n", "html": "

                          ===

                          \n", "example": 15, "start_line": 555, "end_line": 559, "section": "Thematic breaks" }, { "markdown": "--\n**\n__\n", "html": "

                          --\n**\n__

                          \n", "example": 16, "start_line": 564, "end_line": 572, "section": "Thematic breaks" }, { "markdown": " ***\n ***\n ***\n", "html": "
                          \n
                          \n
                          \n", "example": 17, "start_line": 577, "end_line": 585, "section": "Thematic breaks" }, { "markdown": " ***\n", "html": "
                          ***\n
                          \n", "example": 18, "start_line": 590, "end_line": 595, "section": "Thematic breaks" }, { "markdown": "Foo\n ***\n", "html": "

                          Foo\n***

                          \n", "example": 19, "start_line": 598, "end_line": 604, "section": "Thematic breaks" }, { "markdown": "_____________________________________\n", "html": "
                          \n", "example": 20, "start_line": 609, "end_line": 613, "section": "Thematic breaks" }, { "markdown": " - - -\n", "html": "
                          \n", "example": 21, "start_line": 618, "end_line": 622, "section": "Thematic breaks" }, { "markdown": " ** * ** * ** * **\n", "html": "
                          \n", "example": 22, "start_line": 625, "end_line": 629, "section": "Thematic breaks" }, { "markdown": "- - - -\n", "html": "
                          \n", "example": 23, "start_line": 632, "end_line": 636, "section": "Thematic breaks" }, { "markdown": "- - - - \n", "html": "
                          \n", "example": 24, "start_line": 641, "end_line": 645, "section": "Thematic breaks" }, { "markdown": "_ _ _ _ a\n\na------\n\n---a---\n", "html": "

                          _ _ _ _ a

                          \n

                          a------

                          \n

                          ---a---

                          \n", "example": 25, "start_line": 650, "end_line": 660, "section": "Thematic breaks" }, { "markdown": " *-*\n", "html": "

                          -

                          \n", "example": 26, "start_line": 666, "end_line": 670, "section": "Thematic breaks" }, { "markdown": "- foo\n***\n- bar\n", "html": "
                            \n
                          • foo
                          • \n
                          \n
                          \n
                            \n
                          • bar
                          • \n
                          \n", "example": 27, "start_line": 675, "end_line": 687, "section": "Thematic breaks" }, { "markdown": "Foo\n***\nbar\n", "html": "

                          Foo

                          \n
                          \n

                          bar

                          \n", "example": 28, "start_line": 692, "end_line": 700, "section": "Thematic breaks" }, { "markdown": "Foo\n---\nbar\n", "html": "

                          Foo

                          \n

                          bar

                          \n", "example": 29, "start_line": 709, "end_line": 716, "section": "Thematic breaks" }, { "markdown": "* Foo\n* * *\n* Bar\n", "html": "
                            \n
                          • Foo
                          • \n
                          \n
                          \n
                            \n
                          • Bar
                          • \n
                          \n", "example": 30, "start_line": 722, "end_line": 734, "section": "Thematic breaks" }, { "markdown": "- Foo\n- * * *\n", "html": "
                            \n
                          • Foo
                          • \n
                          • \n
                            \n
                          • \n
                          \n", "example": 31, "start_line": 739, "end_line": 749, "section": "Thematic breaks" }, { "markdown": "# foo\n## foo\n### foo\n#### foo\n##### foo\n###### foo\n", "html": "

                          foo

                          \n

                          foo

                          \n

                          foo

                          \n

                          foo

                          \n
                          foo
                          \n
                          foo
                          \n", "example": 32, "start_line": 768, "end_line": 782, "section": "ATX headings" }, { "markdown": "####### foo\n", "html": "

                          ####### foo

                          \n", "example": 33, "start_line": 787, "end_line": 791, "section": "ATX headings" }, { "markdown": "#5 bolt\n\n#hashtag\n", "html": "

                          #5 bolt

                          \n

                          #hashtag

                          \n", "example": 34, "start_line": 802, "end_line": 809, "section": "ATX headings" }, { "markdown": "\\## foo\n", "html": "

                          ## foo

                          \n", "example": 35, "start_line": 814, "end_line": 818, "section": "ATX headings" }, { "markdown": "# foo *bar* \\*baz\\*\n", "html": "

                          foo bar *baz*

                          \n", "example": 36, "start_line": 823, "end_line": 827, "section": "ATX headings" }, { "markdown": "# foo \n", "html": "

                          foo

                          \n", "example": 37, "start_line": 832, "end_line": 836, "section": "ATX headings" }, { "markdown": " ### foo\n ## foo\n # foo\n", "html": "

                          foo

                          \n

                          foo

                          \n

                          foo

                          \n", "example": 38, "start_line": 841, "end_line": 849, "section": "ATX headings" }, { "markdown": " # foo\n", "html": "
                          # foo\n
                          \n", "example": 39, "start_line": 854, "end_line": 859, "section": "ATX headings" }, { "markdown": "foo\n # bar\n", "html": "

                          foo\n# bar

                          \n", "example": 40, "start_line": 862, "end_line": 868, "section": "ATX headings" }, { "markdown": "## foo ##\n ### bar ###\n", "html": "

                          foo

                          \n

                          bar

                          \n", "example": 41, "start_line": 873, "end_line": 879, "section": "ATX headings" }, { "markdown": "# foo ##################################\n##### foo ##\n", "html": "

                          foo

                          \n
                          foo
                          \n", "example": 42, "start_line": 884, "end_line": 890, "section": "ATX headings" }, { "markdown": "### foo ### \n", "html": "

                          foo

                          \n", "example": 43, "start_line": 895, "end_line": 899, "section": "ATX headings" }, { "markdown": "### foo ### b\n", "html": "

                          foo ### b

                          \n", "example": 44, "start_line": 906, "end_line": 910, "section": "ATX headings" }, { "markdown": "# foo#\n", "html": "

                          foo#

                          \n", "example": 45, "start_line": 915, "end_line": 919, "section": "ATX headings" }, { "markdown": "### foo \\###\n## foo #\\##\n# foo \\#\n", "html": "

                          foo ###

                          \n

                          foo ###

                          \n

                          foo #

                          \n", "example": 46, "start_line": 925, "end_line": 933, "section": "ATX headings" }, { "markdown": "****\n## foo\n****\n", "html": "
                          \n

                          foo

                          \n
                          \n", "example": 47, "start_line": 939, "end_line": 947, "section": "ATX headings" }, { "markdown": "Foo bar\n# baz\nBar foo\n", "html": "

                          Foo bar

                          \n

                          baz

                          \n

                          Bar foo

                          \n", "example": 48, "start_line": 950, "end_line": 958, "section": "ATX headings" }, { "markdown": "## \n#\n### ###\n", "html": "

                          \n

                          \n

                          \n", "example": 49, "start_line": 963, "end_line": 971, "section": "ATX headings" }, { "markdown": "Foo *bar*\n=========\n\nFoo *bar*\n---------\n", "html": "

                          Foo bar

                          \n

                          Foo bar

                          \n", "example": 50, "start_line": 1006, "end_line": 1015, "section": "Setext headings" }, { "markdown": "Foo *bar\nbaz*\n====\n", "html": "

                          Foo bar\nbaz

                          \n", "example": 51, "start_line": 1020, "end_line": 1027, "section": "Setext headings" }, { "markdown": " Foo *bar\nbaz*\t\n====\n", "html": "

                          Foo bar\nbaz

                          \n", "example": 52, "start_line": 1034, "end_line": 1041, "section": "Setext headings" }, { "markdown": "Foo\n-------------------------\n\nFoo\n=\n", "html": "

                          Foo

                          \n

                          Foo

                          \n", "example": 53, "start_line": 1046, "end_line": 1055, "section": "Setext headings" }, { "markdown": " Foo\n---\n\n Foo\n-----\n\n Foo\n ===\n", "html": "

                          Foo

                          \n

                          Foo

                          \n

                          Foo

                          \n", "example": 54, "start_line": 1061, "end_line": 1074, "section": "Setext headings" }, { "markdown": " Foo\n ---\n\n Foo\n---\n", "html": "
                          Foo\n---\n\nFoo\n
                          \n
                          \n", "example": 55, "start_line": 1079, "end_line": 1092, "section": "Setext headings" }, { "markdown": "Foo\n ---- \n", "html": "

                          Foo

                          \n", "example": 56, "start_line": 1098, "end_line": 1103, "section": "Setext headings" }, { "markdown": "Foo\n ---\n", "html": "

                          Foo\n---

                          \n", "example": 57, "start_line": 1108, "end_line": 1114, "section": "Setext headings" }, { "markdown": "Foo\n= =\n\nFoo\n--- -\n", "html": "

                          Foo\n= =

                          \n

                          Foo

                          \n
                          \n", "example": 58, "start_line": 1119, "end_line": 1130, "section": "Setext headings" }, { "markdown": "Foo \n-----\n", "html": "

                          Foo

                          \n", "example": 59, "start_line": 1135, "end_line": 1140, "section": "Setext headings" }, { "markdown": "Foo\\\n----\n", "html": "

                          Foo\\

                          \n", "example": 60, "start_line": 1145, "end_line": 1150, "section": "Setext headings" }, { "markdown": "`Foo\n----\n`\n\n\n", "html": "

                          `Foo

                          \n

                          `

                          \n

                          <a title="a lot

                          \n

                          of dashes"/>

                          \n", "example": 61, "start_line": 1156, "end_line": 1169, "section": "Setext headings" }, { "markdown": "> Foo\n---\n", "html": "
                          \n

                          Foo

                          \n
                          \n
                          \n", "example": 62, "start_line": 1175, "end_line": 1183, "section": "Setext headings" }, { "markdown": "> foo\nbar\n===\n", "html": "
                          \n

                          foo\nbar\n===

                          \n
                          \n", "example": 63, "start_line": 1186, "end_line": 1196, "section": "Setext headings" }, { "markdown": "- Foo\n---\n", "html": "
                            \n
                          • Foo
                          • \n
                          \n
                          \n", "example": 64, "start_line": 1199, "end_line": 1207, "section": "Setext headings" }, { "markdown": "Foo\nBar\n---\n", "html": "

                          Foo\nBar

                          \n", "example": 65, "start_line": 1214, "end_line": 1221, "section": "Setext headings" }, { "markdown": "---\nFoo\n---\nBar\n---\nBaz\n", "html": "
                          \n

                          Foo

                          \n

                          Bar

                          \n

                          Baz

                          \n", "example": 66, "start_line": 1227, "end_line": 1239, "section": "Setext headings" }, { "markdown": "\n====\n", "html": "

                          ====

                          \n", "example": 67, "start_line": 1244, "end_line": 1249, "section": "Setext headings" }, { "markdown": "---\n---\n", "html": "
                          \n
                          \n", "example": 68, "start_line": 1256, "end_line": 1262, "section": "Setext headings" }, { "markdown": "- foo\n-----\n", "html": "
                            \n
                          • foo
                          • \n
                          \n
                          \n", "example": 69, "start_line": 1265, "end_line": 1273, "section": "Setext headings" }, { "markdown": " foo\n---\n", "html": "
                          foo\n
                          \n
                          \n", "example": 70, "start_line": 1276, "end_line": 1283, "section": "Setext headings" }, { "markdown": "> foo\n-----\n", "html": "
                          \n

                          foo

                          \n
                          \n
                          \n", "example": 71, "start_line": 1286, "end_line": 1294, "section": "Setext headings" }, { "markdown": "\\> foo\n------\n", "html": "

                          > foo

                          \n", "example": 72, "start_line": 1300, "end_line": 1305, "section": "Setext headings" }, { "markdown": "Foo\n\nbar\n---\nbaz\n", "html": "

                          Foo

                          \n

                          bar

                          \n

                          baz

                          \n", "example": 73, "start_line": 1331, "end_line": 1341, "section": "Setext headings" }, { "markdown": "Foo\nbar\n\n---\n\nbaz\n", "html": "

                          Foo\nbar

                          \n
                          \n

                          baz

                          \n", "example": 74, "start_line": 1347, "end_line": 1359, "section": "Setext headings" }, { "markdown": "Foo\nbar\n* * *\nbaz\n", "html": "

                          Foo\nbar

                          \n
                          \n

                          baz

                          \n", "example": 75, "start_line": 1365, "end_line": 1375, "section": "Setext headings" }, { "markdown": "Foo\nbar\n\\---\nbaz\n", "html": "

                          Foo\nbar\n---\nbaz

                          \n", "example": 76, "start_line": 1380, "end_line": 1390, "section": "Setext headings" }, { "markdown": " a simple\n indented code block\n", "html": "
                          a simple\n  indented code block\n
                          \n", "example": 77, "start_line": 1408, "end_line": 1415, "section": "Indented code blocks" }, { "markdown": " - foo\n\n bar\n", "html": "
                            \n
                          • \n

                            foo

                            \n

                            bar

                            \n
                          • \n
                          \n", "example": 78, "start_line": 1422, "end_line": 1433, "section": "Indented code blocks" }, { "markdown": "1. foo\n\n - bar\n", "html": "
                            \n
                          1. \n

                            foo

                            \n
                              \n
                            • bar
                            • \n
                            \n
                          2. \n
                          \n", "example": 79, "start_line": 1436, "end_line": 1449, "section": "Indented code blocks" }, { "markdown": "
                          \n *hi*\n\n - one\n", "html": "
                          <a/>\n*hi*\n\n- one\n
                          \n", "example": 80, "start_line": 1456, "end_line": 1467, "section": "Indented code blocks" }, { "markdown": " chunk1\n\n chunk2\n \n \n \n chunk3\n", "html": "
                          chunk1\n\nchunk2\n\n\n\nchunk3\n
                          \n", "example": 81, "start_line": 1472, "end_line": 1489, "section": "Indented code blocks" }, { "markdown": " chunk1\n \n chunk2\n", "html": "
                          chunk1\n  \n  chunk2\n
                          \n", "example": 82, "start_line": 1495, "end_line": 1504, "section": "Indented code blocks" }, { "markdown": "Foo\n bar\n\n", "html": "

                          Foo\nbar

                          \n", "example": 83, "start_line": 1510, "end_line": 1517, "section": "Indented code blocks" }, { "markdown": " foo\nbar\n", "html": "
                          foo\n
                          \n

                          bar

                          \n", "example": 84, "start_line": 1524, "end_line": 1531, "section": "Indented code blocks" }, { "markdown": "# Heading\n foo\nHeading\n------\n foo\n----\n", "html": "

                          Heading

                          \n
                          foo\n
                          \n

                          Heading

                          \n
                          foo\n
                          \n
                          \n", "example": 85, "start_line": 1537, "end_line": 1552, "section": "Indented code blocks" }, { "markdown": " foo\n bar\n", "html": "
                              foo\nbar\n
                          \n", "example": 86, "start_line": 1557, "end_line": 1564, "section": "Indented code blocks" }, { "markdown": "\n \n foo\n \n\n", "html": "
                          foo\n
                          \n", "example": 87, "start_line": 1570, "end_line": 1579, "section": "Indented code blocks" }, { "markdown": " foo \n", "html": "
                          foo  \n
                          \n", "example": 88, "start_line": 1584, "end_line": 1589, "section": "Indented code blocks" }, { "markdown": "```\n<\n >\n```\n", "html": "
                          <\n >\n
                          \n", "example": 89, "start_line": 1639, "end_line": 1648, "section": "Fenced code blocks" }, { "markdown": "~~~\n<\n >\n~~~\n", "html": "
                          <\n >\n
                          \n", "example": 90, "start_line": 1653, "end_line": 1662, "section": "Fenced code blocks" }, { "markdown": "``\nfoo\n``\n", "html": "

                          foo

                          \n", "example": 91, "start_line": 1666, "end_line": 1672, "section": "Fenced code blocks" }, { "markdown": "```\naaa\n~~~\n```\n", "html": "
                          aaa\n~~~\n
                          \n", "example": 92, "start_line": 1677, "end_line": 1686, "section": "Fenced code blocks" }, { "markdown": "~~~\naaa\n```\n~~~\n", "html": "
                          aaa\n```\n
                          \n", "example": 93, "start_line": 1689, "end_line": 1698, "section": "Fenced code blocks" }, { "markdown": "````\naaa\n```\n``````\n", "html": "
                          aaa\n```\n
                          \n", "example": 94, "start_line": 1703, "end_line": 1712, "section": "Fenced code blocks" }, { "markdown": "~~~~\naaa\n~~~\n~~~~\n", "html": "
                          aaa\n~~~\n
                          \n", "example": 95, "start_line": 1715, "end_line": 1724, "section": "Fenced code blocks" }, { "markdown": "```\n", "html": "
                          \n", "example": 96, "start_line": 1730, "end_line": 1734, "section": "Fenced code blocks" }, { "markdown": "`````\n\n```\naaa\n", "html": "
                          \n```\naaa\n
                          \n", "example": 97, "start_line": 1737, "end_line": 1747, "section": "Fenced code blocks" }, { "markdown": "> ```\n> aaa\n\nbbb\n", "html": "
                          \n
                          aaa\n
                          \n
                          \n

                          bbb

                          \n", "example": 98, "start_line": 1750, "end_line": 1761, "section": "Fenced code blocks" }, { "markdown": "```\n\n \n```\n", "html": "
                          \n  \n
                          \n", "example": 99, "start_line": 1766, "end_line": 1775, "section": "Fenced code blocks" }, { "markdown": "```\n```\n", "html": "
                          \n", "example": 100, "start_line": 1780, "end_line": 1785, "section": "Fenced code blocks" }, { "markdown": " ```\n aaa\naaa\n```\n", "html": "
                          aaa\naaa\n
                          \n", "example": 101, "start_line": 1792, "end_line": 1801, "section": "Fenced code blocks" }, { "markdown": " ```\naaa\n aaa\naaa\n ```\n", "html": "
                          aaa\naaa\naaa\n
                          \n", "example": 102, "start_line": 1804, "end_line": 1815, "section": "Fenced code blocks" }, { "markdown": " ```\n aaa\n aaa\n aaa\n ```\n", "html": "
                          aaa\n aaa\naaa\n
                          \n", "example": 103, "start_line": 1818, "end_line": 1829, "section": "Fenced code blocks" }, { "markdown": " ```\n aaa\n ```\n", "html": "
                          ```\naaa\n```\n
                          \n", "example": 104, "start_line": 1834, "end_line": 1843, "section": "Fenced code blocks" }, { "markdown": "```\naaa\n ```\n", "html": "
                          aaa\n
                          \n", "example": 105, "start_line": 1849, "end_line": 1856, "section": "Fenced code blocks" }, { "markdown": " ```\naaa\n ```\n", "html": "
                          aaa\n
                          \n", "example": 106, "start_line": 1859, "end_line": 1866, "section": "Fenced code blocks" }, { "markdown": "```\naaa\n ```\n", "html": "
                          aaa\n    ```\n
                          \n", "example": 107, "start_line": 1871, "end_line": 1879, "section": "Fenced code blocks" }, { "markdown": "``` ```\naaa\n", "html": "

                          \naaa

                          \n", "example": 108, "start_line": 1885, "end_line": 1891, "section": "Fenced code blocks" }, { "markdown": "~~~~~~\naaa\n~~~ ~~\n", "html": "
                          aaa\n~~~ ~~\n
                          \n", "example": 109, "start_line": 1894, "end_line": 1902, "section": "Fenced code blocks" }, { "markdown": "foo\n```\nbar\n```\nbaz\n", "html": "

                          foo

                          \n
                          bar\n
                          \n

                          baz

                          \n", "example": 110, "start_line": 1908, "end_line": 1919, "section": "Fenced code blocks" }, { "markdown": "foo\n---\n~~~\nbar\n~~~\n# baz\n", "html": "

                          foo

                          \n
                          bar\n
                          \n

                          baz

                          \n", "example": 111, "start_line": 1925, "end_line": 1937, "section": "Fenced code blocks" }, { "markdown": "```ruby\ndef foo(x)\n return 3\nend\n```\n", "html": "
                          def foo(x)\n  return 3\nend\n
                          \n", "example": 112, "start_line": 1947, "end_line": 1958, "section": "Fenced code blocks" }, { "markdown": "~~~~ ruby startline=3 $%@#$\ndef foo(x)\n return 3\nend\n~~~~~~~\n", "html": "
                          def foo(x)\n  return 3\nend\n
                          \n", "example": 113, "start_line": 1961, "end_line": 1972, "section": "Fenced code blocks" }, { "markdown": "````;\n````\n", "html": "
                          \n", "example": 114, "start_line": 1975, "end_line": 1980, "section": "Fenced code blocks" }, { "markdown": "``` aa ```\nfoo\n", "html": "

                          aa\nfoo

                          \n", "example": 115, "start_line": 1985, "end_line": 1991, "section": "Fenced code blocks" }, { "markdown": "~~~ aa ``` ~~~\nfoo\n~~~\n", "html": "
                          foo\n
                          \n", "example": 116, "start_line": 1996, "end_line": 2003, "section": "Fenced code blocks" }, { "markdown": "```\n``` aaa\n```\n", "html": "
                          ``` aaa\n
                          \n", "example": 117, "start_line": 2008, "end_line": 2015, "section": "Fenced code blocks" }, { "markdown": "
                          \n
                          \n**Hello**,\n\n_world_.\n
                          \n
                          \n", "html": "
                          \n
                          \n**Hello**,\n

                          world.\n

                          \n
                          \n", "example": 118, "start_line": 2087, "end_line": 2102, "section": "HTML blocks" }, { "markdown": "\n \n \n \n
                          \n hi\n
                          \n\nokay.\n", "html": "\n \n \n \n
                          \n hi\n
                          \n

                          okay.

                          \n", "example": 119, "start_line": 2116, "end_line": 2135, "section": "HTML blocks" }, { "markdown": "
                          \n*foo*\n", "example": 121, "start_line": 2151, "end_line": 2157, "section": "HTML blocks" }, { "markdown": "
                          \n\n*Markdown*\n\n
                          \n", "html": "
                          \n

                          Markdown

                          \n
                          \n", "example": 122, "start_line": 2162, "end_line": 2172, "section": "HTML blocks" }, { "markdown": "
                          \n
                          \n", "html": "
                          \n
                          \n", "example": 123, "start_line": 2178, "end_line": 2186, "section": "HTML blocks" }, { "markdown": "
                          \n
                          \n", "html": "
                          \n
                          \n", "example": 124, "start_line": 2189, "end_line": 2197, "section": "HTML blocks" }, { "markdown": "
                          \n*foo*\n\n*bar*\n", "html": "
                          \n*foo*\n

                          bar

                          \n", "example": 125, "start_line": 2201, "end_line": 2210, "section": "HTML blocks" }, { "markdown": "
                          \n", "html": "\n", "example": 129, "start_line": 2250, "end_line": 2254, "section": "HTML blocks" }, { "markdown": "
                          \nfoo\n
                          \n", "html": "
                          \nfoo\n
                          \n", "example": 130, "start_line": 2257, "end_line": 2265, "section": "HTML blocks" }, { "markdown": "
                          \n``` c\nint x = 33;\n```\n", "html": "
                          \n``` c\nint x = 33;\n```\n", "example": 131, "start_line": 2274, "end_line": 2284, "section": "HTML blocks" }, { "markdown": "\n*bar*\n\n", "html": "\n*bar*\n\n", "example": 132, "start_line": 2291, "end_line": 2299, "section": "HTML blocks" }, { "markdown": "\n*bar*\n\n", "html": "\n*bar*\n\n", "example": 133, "start_line": 2304, "end_line": 2312, "section": "HTML blocks" }, { "markdown": "\n*bar*\n\n", "html": "\n*bar*\n\n", "example": 134, "start_line": 2315, "end_line": 2323, "section": "HTML blocks" }, { "markdown": "\n*bar*\n", "html": "\n*bar*\n", "example": 135, "start_line": 2326, "end_line": 2332, "section": "HTML blocks" }, { "markdown": "\n*foo*\n\n", "html": "\n*foo*\n\n", "example": 136, "start_line": 2341, "end_line": 2349, "section": "HTML blocks" }, { "markdown": "\n\n*foo*\n\n\n", "html": "\n

                          foo

                          \n
                          \n", "example": 137, "start_line": 2356, "end_line": 2366, "section": "HTML blocks" }, { "markdown": "*foo*\n", "html": "

                          foo

                          \n", "example": 138, "start_line": 2374, "end_line": 2378, "section": "HTML blocks" }, { "markdown": "
                          \nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags\n
                          \nokay\n", "html": "
                          \nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags\n
                          \n

                          okay

                          \n", "example": 139, "start_line": 2390, "end_line": 2406, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\n

                          okay

                          \n", "example": 140, "start_line": 2411, "end_line": 2425, "section": "HTML blocks" }, { "markdown": "\nh1 {color:red;}\n\np {color:blue;}\n\nokay\n", "html": "\nh1 {color:red;}\n\np {color:blue;}\n\n

                          okay

                          \n", "example": 141, "start_line": 2430, "end_line": 2446, "section": "HTML blocks" }, { "markdown": "\n\nfoo\n", "html": "\n\nfoo\n", "example": 142, "start_line": 2453, "end_line": 2463, "section": "HTML blocks" }, { "markdown": ">
                          \n> foo\n\nbar\n", "html": "
                          \n
                          \nfoo\n
                          \n

                          bar

                          \n", "example": 143, "start_line": 2466, "end_line": 2477, "section": "HTML blocks" }, { "markdown": "-
                          \n- foo\n", "html": "
                            \n
                          • \n
                            \n
                          • \n
                          • foo
                          • \n
                          \n", "example": 144, "start_line": 2480, "end_line": 2490, "section": "HTML blocks" }, { "markdown": "\n*foo*\n", "html": "\n

                          foo

                          \n", "example": 145, "start_line": 2495, "end_line": 2501, "section": "HTML blocks" }, { "markdown": "*bar*\n*baz*\n", "html": "*bar*\n

                          baz

                          \n", "example": 146, "start_line": 2504, "end_line": 2510, "section": "HTML blocks" }, { "markdown": "1. *bar*\n", "html": "1. *bar*\n", "example": 147, "start_line": 2516, "end_line": 2524, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\n

                          okay

                          \n", "example": 148, "start_line": 2529, "end_line": 2541, "section": "HTML blocks" }, { "markdown": "';\n\n?>\nokay\n", "html": "';\n\n?>\n

                          okay

                          \n", "example": 149, "start_line": 2547, "end_line": 2561, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 150, "start_line": 2566, "end_line": 2570, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\n

                          okay

                          \n", "example": 151, "start_line": 2575, "end_line": 2603, "section": "HTML blocks" }, { "markdown": " \n\n \n", "html": " \n
                          <!-- foo -->\n
                          \n", "example": 152, "start_line": 2608, "end_line": 2616, "section": "HTML blocks" }, { "markdown": "
                          \n\n
                          \n", "html": "
                          \n
                          <div>\n
                          \n", "example": 153, "start_line": 2619, "end_line": 2627, "section": "HTML blocks" }, { "markdown": "Foo\n
                          \nbar\n
                          \n", "html": "

                          Foo

                          \n
                          \nbar\n
                          \n", "example": 154, "start_line": 2633, "end_line": 2643, "section": "HTML blocks" }, { "markdown": "
                          \nbar\n
                          \n*foo*\n", "html": "
                          \nbar\n
                          \n*foo*\n", "example": 155, "start_line": 2650, "end_line": 2660, "section": "HTML blocks" }, { "markdown": "Foo\n\nbaz\n", "html": "

                          Foo\n\nbaz

                          \n", "example": 156, "start_line": 2665, "end_line": 2673, "section": "HTML blocks" }, { "markdown": "
                          \n\n*Emphasized* text.\n\n
                          \n", "html": "
                          \n

                          Emphasized text.

                          \n
                          \n", "example": 157, "start_line": 2706, "end_line": 2716, "section": "HTML blocks" }, { "markdown": "
                          \n*Emphasized* text.\n
                          \n", "html": "
                          \n*Emphasized* text.\n
                          \n", "example": 158, "start_line": 2719, "end_line": 2727, "section": "HTML blocks" }, { "markdown": "\n\n\n\n\n\n\n\n
                          \nHi\n
                          \n", "html": "\n\n\n\n
                          \nHi\n
                          \n", "example": 159, "start_line": 2741, "end_line": 2761, "section": "HTML blocks" }, { "markdown": "\n\n \n\n \n\n \n\n
                          \n Hi\n
                          \n", "html": "\n \n
                          <td>\n  Hi\n</td>\n
                          \n \n
                          \n", "example": 160, "start_line": 2768, "end_line": 2789, "section": "HTML blocks" }, { "markdown": "[foo]: /url \"title\"\n\n[foo]\n", "html": "

                          foo

                          \n", "example": 161, "start_line": 2816, "end_line": 2822, "section": "Link reference definitions" }, { "markdown": " [foo]: \n /url \n 'the title' \n\n[foo]\n", "html": "

                          foo

                          \n", "example": 162, "start_line": 2825, "end_line": 2833, "section": "Link reference definitions" }, { "markdown": "[Foo*bar\\]]:my_(url) 'title (with parens)'\n\n[Foo*bar\\]]\n", "html": "

                          Foo*bar]

                          \n", "example": 163, "start_line": 2836, "end_line": 2842, "section": "Link reference definitions" }, { "markdown": "[Foo bar]:\n\n'title'\n\n[Foo bar]\n", "html": "

                          Foo bar

                          \n", "example": 164, "start_line": 2845, "end_line": 2853, "section": "Link reference definitions" }, { "markdown": "[foo]: /url '\ntitle\nline1\nline2\n'\n\n[foo]\n", "html": "

                          foo

                          \n", "example": 165, "start_line": 2858, "end_line": 2872, "section": "Link reference definitions" }, { "markdown": "[foo]: /url 'title\n\nwith blank line'\n\n[foo]\n", "html": "

                          [foo]: /url 'title

                          \n

                          with blank line'

                          \n

                          [foo]

                          \n", "example": 166, "start_line": 2877, "end_line": 2887, "section": "Link reference definitions" }, { "markdown": "[foo]:\n/url\n\n[foo]\n", "html": "

                          foo

                          \n", "example": 167, "start_line": 2892, "end_line": 2899, "section": "Link reference definitions" }, { "markdown": "[foo]:\n\n[foo]\n", "html": "

                          [foo]:

                          \n

                          [foo]

                          \n", "example": 168, "start_line": 2904, "end_line": 2911, "section": "Link reference definitions" }, { "markdown": "[foo]: <>\n\n[foo]\n", "html": "

                          foo

                          \n", "example": 169, "start_line": 2916, "end_line": 2922, "section": "Link reference definitions" }, { "markdown": "[foo]: (baz)\n\n[foo]\n", "html": "

                          [foo]: (baz)

                          \n

                          [foo]

                          \n", "example": 170, "start_line": 2927, "end_line": 2934, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\\bar\\*baz \"foo\\\"bar\\baz\"\n\n[foo]\n", "html": "

                          foo

                          \n", "example": 171, "start_line": 2940, "end_line": 2946, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n[foo]: url\n", "html": "

                          foo

                          \n", "example": 172, "start_line": 2951, "end_line": 2957, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n[foo]: first\n[foo]: second\n", "html": "

                          foo

                          \n", "example": 173, "start_line": 2963, "end_line": 2970, "section": "Link reference definitions" }, { "markdown": "[FOO]: /url\n\n[Foo]\n", "html": "

                          Foo

                          \n", "example": 174, "start_line": 2976, "end_line": 2982, "section": "Link reference definitions" }, { "markdown": "[ΑΓΩ]: /φου\n\n[αγω]\n", "html": "

                          αγω

                          \n", "example": 175, "start_line": 2985, "end_line": 2991, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n", "html": "", "example": 176, "start_line": 2997, "end_line": 3000, "section": "Link reference definitions" }, { "markdown": "[\nfoo\n]: /url\nbar\n", "html": "

                          bar

                          \n", "example": 177, "start_line": 3005, "end_line": 3012, "section": "Link reference definitions" }, { "markdown": "[foo]: /url \"title\" ok\n", "html": "

                          [foo]: /url "title" ok

                          \n", "example": 178, "start_line": 3018, "end_line": 3022, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n\"title\" ok\n", "html": "

                          "title" ok

                          \n", "example": 179, "start_line": 3027, "end_line": 3032, "section": "Link reference definitions" }, { "markdown": " [foo]: /url \"title\"\n\n[foo]\n", "html": "
                          [foo]: /url "title"\n
                          \n

                          [foo]

                          \n", "example": 180, "start_line": 3038, "end_line": 3046, "section": "Link reference definitions" }, { "markdown": "```\n[foo]: /url\n```\n\n[foo]\n", "html": "
                          [foo]: /url\n
                          \n

                          [foo]

                          \n", "example": 181, "start_line": 3052, "end_line": 3062, "section": "Link reference definitions" }, { "markdown": "Foo\n[bar]: /baz\n\n[bar]\n", "html": "

                          Foo\n[bar]: /baz

                          \n

                          [bar]

                          \n", "example": 182, "start_line": 3067, "end_line": 3076, "section": "Link reference definitions" }, { "markdown": "# [Foo]\n[foo]: /url\n> bar\n", "html": "

                          Foo

                          \n
                          \n

                          bar

                          \n
                          \n", "example": 183, "start_line": 3082, "end_line": 3091, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\nbar\n===\n[foo]\n", "html": "

                          bar

                          \n

                          foo

                          \n", "example": 184, "start_line": 3093, "end_line": 3101, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n===\n[foo]\n", "html": "

                          ===\nfoo

                          \n", "example": 185, "start_line": 3103, "end_line": 3110, "section": "Link reference definitions" }, { "markdown": "[foo]: /foo-url \"foo\"\n[bar]: /bar-url\n \"bar\"\n[baz]: /baz-url\n\n[foo],\n[bar],\n[baz]\n", "html": "

                          foo,\nbar,\nbaz

                          \n", "example": 186, "start_line": 3116, "end_line": 3129, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n> [foo]: /url\n", "html": "

                          foo

                          \n
                          \n
                          \n", "example": 187, "start_line": 3137, "end_line": 3145, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n", "html": "", "example": 188, "start_line": 3154, "end_line": 3157, "section": "Link reference definitions" }, { "markdown": "aaa\n\nbbb\n", "html": "

                          aaa

                          \n

                          bbb

                          \n", "example": 189, "start_line": 3171, "end_line": 3178, "section": "Paragraphs" }, { "markdown": "aaa\nbbb\n\nccc\nddd\n", "html": "

                          aaa\nbbb

                          \n

                          ccc\nddd

                          \n", "example": 190, "start_line": 3183, "end_line": 3194, "section": "Paragraphs" }, { "markdown": "aaa\n\n\nbbb\n", "html": "

                          aaa

                          \n

                          bbb

                          \n", "example": 191, "start_line": 3199, "end_line": 3207, "section": "Paragraphs" }, { "markdown": " aaa\n bbb\n", "html": "

                          aaa\nbbb

                          \n", "example": 192, "start_line": 3212, "end_line": 3218, "section": "Paragraphs" }, { "markdown": "aaa\n bbb\n ccc\n", "html": "

                          aaa\nbbb\nccc

                          \n", "example": 193, "start_line": 3224, "end_line": 3232, "section": "Paragraphs" }, { "markdown": " aaa\nbbb\n", "html": "

                          aaa\nbbb

                          \n", "example": 194, "start_line": 3238, "end_line": 3244, "section": "Paragraphs" }, { "markdown": " aaa\nbbb\n", "html": "
                          aaa\n
                          \n

                          bbb

                          \n", "example": 195, "start_line": 3247, "end_line": 3254, "section": "Paragraphs" }, { "markdown": "aaa \nbbb \n", "html": "

                          aaa
                          \nbbb

                          \n", "example": 196, "start_line": 3261, "end_line": 3267, "section": "Paragraphs" }, { "markdown": " \n\naaa\n \n\n# aaa\n\n \n", "html": "

                          aaa

                          \n

                          aaa

                          \n", "example": 197, "start_line": 3278, "end_line": 3290, "section": "Blank lines" }, { "markdown": "> # Foo\n> bar\n> baz\n", "html": "
                          \n

                          Foo

                          \n

                          bar\nbaz

                          \n
                          \n", "example": 198, "start_line": 3344, "end_line": 3354, "section": "Block quotes" }, { "markdown": "># Foo\n>bar\n> baz\n", "html": "
                          \n

                          Foo

                          \n

                          bar\nbaz

                          \n
                          \n", "example": 199, "start_line": 3359, "end_line": 3369, "section": "Block quotes" }, { "markdown": " > # Foo\n > bar\n > baz\n", "html": "
                          \n

                          Foo

                          \n

                          bar\nbaz

                          \n
                          \n", "example": 200, "start_line": 3374, "end_line": 3384, "section": "Block quotes" }, { "markdown": " > # Foo\n > bar\n > baz\n", "html": "
                          > # Foo\n> bar\n> baz\n
                          \n", "example": 201, "start_line": 3389, "end_line": 3398, "section": "Block quotes" }, { "markdown": "> # Foo\n> bar\nbaz\n", "html": "
                          \n

                          Foo

                          \n

                          bar\nbaz

                          \n
                          \n", "example": 202, "start_line": 3404, "end_line": 3414, "section": "Block quotes" }, { "markdown": "> bar\nbaz\n> foo\n", "html": "
                          \n

                          bar\nbaz\nfoo

                          \n
                          \n", "example": 203, "start_line": 3420, "end_line": 3430, "section": "Block quotes" }, { "markdown": "> foo\n---\n", "html": "
                          \n

                          foo

                          \n
                          \n
                          \n", "example": 204, "start_line": 3444, "end_line": 3452, "section": "Block quotes" }, { "markdown": "> - foo\n- bar\n", "html": "
                          \n
                            \n
                          • foo
                          • \n
                          \n
                          \n
                            \n
                          • bar
                          • \n
                          \n", "example": 205, "start_line": 3464, "end_line": 3476, "section": "Block quotes" }, { "markdown": "> foo\n bar\n", "html": "
                          \n
                          foo\n
                          \n
                          \n
                          bar\n
                          \n", "example": 206, "start_line": 3482, "end_line": 3492, "section": "Block quotes" }, { "markdown": "> ```\nfoo\n```\n", "html": "
                          \n
                          \n
                          \n

                          foo

                          \n
                          \n", "example": 207, "start_line": 3495, "end_line": 3505, "section": "Block quotes" }, { "markdown": "> foo\n - bar\n", "html": "
                          \n

                          foo\n- bar

                          \n
                          \n", "example": 208, "start_line": 3511, "end_line": 3519, "section": "Block quotes" }, { "markdown": ">\n", "html": "
                          \n
                          \n", "example": 209, "start_line": 3535, "end_line": 3540, "section": "Block quotes" }, { "markdown": ">\n> \n> \n", "html": "
                          \n
                          \n", "example": 210, "start_line": 3543, "end_line": 3550, "section": "Block quotes" }, { "markdown": ">\n> foo\n> \n", "html": "
                          \n

                          foo

                          \n
                          \n", "example": 211, "start_line": 3555, "end_line": 3563, "section": "Block quotes" }, { "markdown": "> foo\n\n> bar\n", "html": "
                          \n

                          foo

                          \n
                          \n
                          \n

                          bar

                          \n
                          \n", "example": 212, "start_line": 3568, "end_line": 3579, "section": "Block quotes" }, { "markdown": "> foo\n> bar\n", "html": "
                          \n

                          foo\nbar

                          \n
                          \n", "example": 213, "start_line": 3590, "end_line": 3598, "section": "Block quotes" }, { "markdown": "> foo\n>\n> bar\n", "html": "
                          \n

                          foo

                          \n

                          bar

                          \n
                          \n", "example": 214, "start_line": 3603, "end_line": 3612, "section": "Block quotes" }, { "markdown": "foo\n> bar\n", "html": "

                          foo

                          \n
                          \n

                          bar

                          \n
                          \n", "example": 215, "start_line": 3617, "end_line": 3625, "section": "Block quotes" }, { "markdown": "> aaa\n***\n> bbb\n", "html": "
                          \n

                          aaa

                          \n
                          \n
                          \n
                          \n

                          bbb

                          \n
                          \n", "example": 216, "start_line": 3631, "end_line": 3643, "section": "Block quotes" }, { "markdown": "> bar\nbaz\n", "html": "
                          \n

                          bar\nbaz

                          \n
                          \n", "example": 217, "start_line": 3649, "end_line": 3657, "section": "Block quotes" }, { "markdown": "> bar\n\nbaz\n", "html": "
                          \n

                          bar

                          \n
                          \n

                          baz

                          \n", "example": 218, "start_line": 3660, "end_line": 3669, "section": "Block quotes" }, { "markdown": "> bar\n>\nbaz\n", "html": "
                          \n

                          bar

                          \n
                          \n

                          baz

                          \n", "example": 219, "start_line": 3672, "end_line": 3681, "section": "Block quotes" }, { "markdown": "> > > foo\nbar\n", "html": "
                          \n
                          \n
                          \n

                          foo\nbar

                          \n
                          \n
                          \n
                          \n", "example": 220, "start_line": 3688, "end_line": 3700, "section": "Block quotes" }, { "markdown": ">>> foo\n> bar\n>>baz\n", "html": "
                          \n
                          \n
                          \n

                          foo\nbar\nbaz

                          \n
                          \n
                          \n
                          \n", "example": 221, "start_line": 3703, "end_line": 3717, "section": "Block quotes" }, { "markdown": "> code\n\n> not code\n", "html": "
                          \n
                          code\n
                          \n
                          \n
                          \n

                          not code

                          \n
                          \n", "example": 222, "start_line": 3725, "end_line": 3737, "section": "Block quotes" }, { "markdown": "A paragraph\nwith two lines.\n\n indented code\n\n> A block quote.\n", "html": "

                          A paragraph\nwith two lines.

                          \n
                          indented code\n
                          \n
                          \n

                          A block quote.

                          \n
                          \n", "example": 223, "start_line": 3779, "end_line": 3794, "section": "List items" }, { "markdown": "1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "
                            \n
                          1. \n

                            A paragraph\nwith two lines.

                            \n
                            indented code\n
                            \n
                            \n

                            A block quote.

                            \n
                            \n
                          2. \n
                          \n", "example": 224, "start_line": 3801, "end_line": 3820, "section": "List items" }, { "markdown": "- one\n\n two\n", "html": "
                            \n
                          • one
                          • \n
                          \n

                          two

                          \n", "example": 225, "start_line": 3834, "end_line": 3843, "section": "List items" }, { "markdown": "- one\n\n two\n", "html": "
                            \n
                          • \n

                            one

                            \n

                            two

                            \n
                          • \n
                          \n", "example": 226, "start_line": 3846, "end_line": 3857, "section": "List items" }, { "markdown": " - one\n\n two\n", "html": "
                            \n
                          • one
                          • \n
                          \n
                           two\n
                          \n", "example": 227, "start_line": 3860, "end_line": 3870, "section": "List items" }, { "markdown": " - one\n\n two\n", "html": "
                            \n
                          • \n

                            one

                            \n

                            two

                            \n
                          • \n
                          \n", "example": 228, "start_line": 3873, "end_line": 3884, "section": "List items" }, { "markdown": " > > 1. one\n>>\n>> two\n", "html": "
                          \n
                          \n
                            \n
                          1. \n

                            one

                            \n

                            two

                            \n
                          2. \n
                          \n
                          \n
                          \n", "example": 229, "start_line": 3895, "end_line": 3910, "section": "List items" }, { "markdown": ">>- one\n>>\n > > two\n", "html": "
                          \n
                          \n
                            \n
                          • one
                          • \n
                          \n

                          two

                          \n
                          \n
                          \n", "example": 230, "start_line": 3922, "end_line": 3935, "section": "List items" }, { "markdown": "-one\n\n2.two\n", "html": "

                          -one

                          \n

                          2.two

                          \n", "example": 231, "start_line": 3941, "end_line": 3948, "section": "List items" }, { "markdown": "- foo\n\n\n bar\n", "html": "
                            \n
                          • \n

                            foo

                            \n

                            bar

                            \n
                          • \n
                          \n", "example": 232, "start_line": 3954, "end_line": 3966, "section": "List items" }, { "markdown": "1. foo\n\n ```\n bar\n ```\n\n baz\n\n > bam\n", "html": "
                            \n
                          1. \n

                            foo

                            \n
                            bar\n
                            \n

                            baz

                            \n
                            \n

                            bam

                            \n
                            \n
                          2. \n
                          \n", "example": 233, "start_line": 3971, "end_line": 3993, "section": "List items" }, { "markdown": "- Foo\n\n bar\n\n\n baz\n", "html": "
                            \n
                          • \n

                            Foo

                            \n
                            bar\n\n\nbaz\n
                            \n
                          • \n
                          \n", "example": 234, "start_line": 3999, "end_line": 4017, "section": "List items" }, { "markdown": "123456789. ok\n", "html": "
                            \n
                          1. ok
                          2. \n
                          \n", "example": 235, "start_line": 4021, "end_line": 4027, "section": "List items" }, { "markdown": "1234567890. not ok\n", "html": "

                          1234567890. not ok

                          \n", "example": 236, "start_line": 4030, "end_line": 4034, "section": "List items" }, { "markdown": "0. ok\n", "html": "
                            \n
                          1. ok
                          2. \n
                          \n", "example": 237, "start_line": 4039, "end_line": 4045, "section": "List items" }, { "markdown": "003. ok\n", "html": "
                            \n
                          1. ok
                          2. \n
                          \n", "example": 238, "start_line": 4048, "end_line": 4054, "section": "List items" }, { "markdown": "-1. not ok\n", "html": "

                          -1. not ok

                          \n", "example": 239, "start_line": 4059, "end_line": 4063, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "
                            \n
                          • \n

                            foo

                            \n
                            bar\n
                            \n
                          • \n
                          \n", "example": 240, "start_line": 4082, "end_line": 4094, "section": "List items" }, { "markdown": " 10. foo\n\n bar\n", "html": "
                            \n
                          1. \n

                            foo

                            \n
                            bar\n
                            \n
                          2. \n
                          \n", "example": 241, "start_line": 4099, "end_line": 4111, "section": "List items" }, { "markdown": " indented code\n\nparagraph\n\n more code\n", "html": "
                          indented code\n
                          \n

                          paragraph

                          \n
                          more code\n
                          \n", "example": 242, "start_line": 4118, "end_line": 4130, "section": "List items" }, { "markdown": "1. indented code\n\n paragraph\n\n more code\n", "html": "
                            \n
                          1. \n
                            indented code\n
                            \n

                            paragraph

                            \n
                            more code\n
                            \n
                          2. \n
                          \n", "example": 243, "start_line": 4133, "end_line": 4149, "section": "List items" }, { "markdown": "1. indented code\n\n paragraph\n\n more code\n", "html": "
                            \n
                          1. \n
                             indented code\n
                            \n

                            paragraph

                            \n
                            more code\n
                            \n
                          2. \n
                          \n", "example": 244, "start_line": 4155, "end_line": 4171, "section": "List items" }, { "markdown": " foo\n\nbar\n", "html": "

                          foo

                          \n

                          bar

                          \n", "example": 245, "start_line": 4182, "end_line": 4189, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "
                            \n
                          • foo
                          • \n
                          \n

                          bar

                          \n", "example": 246, "start_line": 4192, "end_line": 4201, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "
                            \n
                          • \n

                            foo

                            \n

                            bar

                            \n
                          • \n
                          \n", "example": 247, "start_line": 4209, "end_line": 4220, "section": "List items" }, { "markdown": "-\n foo\n-\n ```\n bar\n ```\n-\n baz\n", "html": "
                            \n
                          • foo
                          • \n
                          • \n
                            bar\n
                            \n
                          • \n
                          • \n
                            baz\n
                            \n
                          • \n
                          \n", "example": 248, "start_line": 4237, "end_line": 4258, "section": "List items" }, { "markdown": "- \n foo\n", "html": "
                            \n
                          • foo
                          • \n
                          \n", "example": 249, "start_line": 4263, "end_line": 4270, "section": "List items" }, { "markdown": "-\n\n foo\n", "html": "
                            \n
                          • \n
                          \n

                          foo

                          \n", "example": 250, "start_line": 4277, "end_line": 4286, "section": "List items" }, { "markdown": "- foo\n-\n- bar\n", "html": "
                            \n
                          • foo
                          • \n
                          • \n
                          • bar
                          • \n
                          \n", "example": 251, "start_line": 4291, "end_line": 4301, "section": "List items" }, { "markdown": "- foo\n- \n- bar\n", "html": "
                            \n
                          • foo
                          • \n
                          • \n
                          • bar
                          • \n
                          \n", "example": 252, "start_line": 4306, "end_line": 4316, "section": "List items" }, { "markdown": "1. foo\n2.\n3. bar\n", "html": "
                            \n
                          1. foo
                          2. \n
                          3. \n
                          4. bar
                          5. \n
                          \n", "example": 253, "start_line": 4321, "end_line": 4331, "section": "List items" }, { "markdown": "*\n", "html": "
                            \n
                          • \n
                          \n", "example": 254, "start_line": 4336, "end_line": 4342, "section": "List items" }, { "markdown": "foo\n*\n\nfoo\n1.\n", "html": "

                          foo\n*

                          \n

                          foo\n1.

                          \n", "example": 255, "start_line": 4346, "end_line": 4357, "section": "List items" }, { "markdown": " 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "
                            \n
                          1. \n

                            A paragraph\nwith two lines.

                            \n
                            indented code\n
                            \n
                            \n

                            A block quote.

                            \n
                            \n
                          2. \n
                          \n", "example": 256, "start_line": 4368, "end_line": 4387, "section": "List items" }, { "markdown": " 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "
                            \n
                          1. \n

                            A paragraph\nwith two lines.

                            \n
                            indented code\n
                            \n
                            \n

                            A block quote.

                            \n
                            \n
                          2. \n
                          \n", "example": 257, "start_line": 4392, "end_line": 4411, "section": "List items" }, { "markdown": " 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "
                            \n
                          1. \n

                            A paragraph\nwith two lines.

                            \n
                            indented code\n
                            \n
                            \n

                            A block quote.

                            \n
                            \n
                          2. \n
                          \n", "example": 258, "start_line": 4416, "end_line": 4435, "section": "List items" }, { "markdown": " 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "
                          1.  A paragraph\n    with two lines.\n\n        indented code\n\n    > A block quote.\n
                          \n", "example": 259, "start_line": 4440, "end_line": 4455, "section": "List items" }, { "markdown": " 1. A paragraph\nwith two lines.\n\n indented code\n\n > A block quote.\n", "html": "
                            \n
                          1. \n

                            A paragraph\nwith two lines.

                            \n
                            indented code\n
                            \n
                            \n

                            A block quote.

                            \n
                            \n
                          2. \n
                          \n", "example": 260, "start_line": 4470, "end_line": 4489, "section": "List items" }, { "markdown": " 1. A paragraph\n with two lines.\n", "html": "
                            \n
                          1. A paragraph\nwith two lines.
                          2. \n
                          \n", "example": 261, "start_line": 4494, "end_line": 4502, "section": "List items" }, { "markdown": "> 1. > Blockquote\ncontinued here.\n", "html": "
                          \n
                            \n
                          1. \n
                            \n

                            Blockquote\ncontinued here.

                            \n
                            \n
                          2. \n
                          \n
                          \n", "example": 262, "start_line": 4507, "end_line": 4521, "section": "List items" }, { "markdown": "> 1. > Blockquote\n> continued here.\n", "html": "
                          \n
                            \n
                          1. \n
                            \n

                            Blockquote\ncontinued here.

                            \n
                            \n
                          2. \n
                          \n
                          \n", "example": 263, "start_line": 4524, "end_line": 4538, "section": "List items" }, { "markdown": "- foo\n - bar\n - baz\n - boo\n", "html": "
                            \n
                          • foo\n
                              \n
                            • bar\n
                                \n
                              • baz\n
                                  \n
                                • boo
                                • \n
                                \n
                              • \n
                              \n
                            • \n
                            \n
                          • \n
                          \n", "example": 264, "start_line": 4552, "end_line": 4573, "section": "List items" }, { "markdown": "- foo\n - bar\n - baz\n - boo\n", "html": "
                            \n
                          • foo
                          • \n
                          • bar
                          • \n
                          • baz
                          • \n
                          • boo
                          • \n
                          \n", "example": 265, "start_line": 4578, "end_line": 4590, "section": "List items" }, { "markdown": "10) foo\n - bar\n", "html": "
                            \n
                          1. foo\n
                              \n
                            • bar
                            • \n
                            \n
                          2. \n
                          \n", "example": 266, "start_line": 4595, "end_line": 4606, "section": "List items" }, { "markdown": "10) foo\n - bar\n", "html": "
                            \n
                          1. foo
                          2. \n
                          \n
                            \n
                          • bar
                          • \n
                          \n", "example": 267, "start_line": 4611, "end_line": 4621, "section": "List items" }, { "markdown": "- - foo\n", "html": "
                            \n
                          • \n
                              \n
                            • foo
                            • \n
                            \n
                          • \n
                          \n", "example": 268, "start_line": 4626, "end_line": 4636, "section": "List items" }, { "markdown": "1. - 2. foo\n", "html": "
                            \n
                          1. \n
                              \n
                            • \n
                                \n
                              1. foo
                              2. \n
                              \n
                            • \n
                            \n
                          2. \n
                          \n", "example": 269, "start_line": 4639, "end_line": 4653, "section": "List items" }, { "markdown": "- # Foo\n- Bar\n ---\n baz\n", "html": "
                            \n
                          • \n

                            Foo

                            \n
                          • \n
                          • \n

                            Bar

                            \nbaz
                          • \n
                          \n", "example": 270, "start_line": 4658, "end_line": 4672, "section": "List items" }, { "markdown": "- foo\n- bar\n+ baz\n", "html": "
                            \n
                          • foo
                          • \n
                          • bar
                          • \n
                          \n
                            \n
                          • baz
                          • \n
                          \n", "example": 271, "start_line": 4894, "end_line": 4906, "section": "Lists" }, { "markdown": "1. foo\n2. bar\n3) baz\n", "html": "
                            \n
                          1. foo
                          2. \n
                          3. bar
                          4. \n
                          \n
                            \n
                          1. baz
                          2. \n
                          \n", "example": 272, "start_line": 4909, "end_line": 4921, "section": "Lists" }, { "markdown": "Foo\n- bar\n- baz\n", "html": "

                          Foo

                          \n
                            \n
                          • bar
                          • \n
                          • baz
                          • \n
                          \n", "example": 273, "start_line": 4928, "end_line": 4938, "section": "Lists" }, { "markdown": "The number of windows in my house is\n14. The number of doors is 6.\n", "html": "

                          The number of windows in my house is\n14. The number of doors is 6.

                          \n", "example": 274, "start_line": 5005, "end_line": 5011, "section": "Lists" }, { "markdown": "The number of windows in my house is\n1. The number of doors is 6.\n", "html": "

                          The number of windows in my house is

                          \n
                            \n
                          1. The number of doors is 6.
                          2. \n
                          \n", "example": 275, "start_line": 5015, "end_line": 5023, "section": "Lists" }, { "markdown": "- foo\n\n- bar\n\n\n- baz\n", "html": "
                            \n
                          • \n

                            foo

                            \n
                          • \n
                          • \n

                            bar

                            \n
                          • \n
                          • \n

                            baz

                            \n
                          • \n
                          \n", "example": 276, "start_line": 5029, "end_line": 5048, "section": "Lists" }, { "markdown": "- foo\n - bar\n - baz\n\n\n bim\n", "html": "
                            \n
                          • foo\n
                              \n
                            • bar\n
                                \n
                              • \n

                                baz

                                \n

                                bim

                                \n
                              • \n
                              \n
                            • \n
                            \n
                          • \n
                          \n", "example": 277, "start_line": 5050, "end_line": 5072, "section": "Lists" }, { "markdown": "- foo\n- bar\n\n\n\n- baz\n- bim\n", "html": "
                            \n
                          • foo
                          • \n
                          • bar
                          • \n
                          \n\n
                            \n
                          • baz
                          • \n
                          • bim
                          • \n
                          \n", "example": 278, "start_line": 5080, "end_line": 5098, "section": "Lists" }, { "markdown": "- foo\n\n notcode\n\n- foo\n\n\n\n code\n", "html": "
                            \n
                          • \n

                            foo

                            \n

                            notcode

                            \n
                          • \n
                          • \n

                            foo

                            \n
                          • \n
                          \n\n
                          code\n
                          \n", "example": 279, "start_line": 5101, "end_line": 5124, "section": "Lists" }, { "markdown": "- a\n - b\n - c\n - d\n - e\n - f\n- g\n", "html": "
                            \n
                          • a
                          • \n
                          • b
                          • \n
                          • c
                          • \n
                          • d
                          • \n
                          • e
                          • \n
                          • f
                          • \n
                          • g
                          • \n
                          \n", "example": 280, "start_line": 5132, "end_line": 5150, "section": "Lists" }, { "markdown": "1. a\n\n 2. b\n\n 3. c\n", "html": "
                            \n
                          1. \n

                            a

                            \n
                          2. \n
                          3. \n

                            b

                            \n
                          4. \n
                          5. \n

                            c

                            \n
                          6. \n
                          \n", "example": 281, "start_line": 5153, "end_line": 5171, "section": "Lists" }, { "markdown": "- a\n - b\n - c\n - d\n - e\n", "html": "
                            \n
                          • a
                          • \n
                          • b
                          • \n
                          • c
                          • \n
                          • d\n- e
                          • \n
                          \n", "example": 282, "start_line": 5177, "end_line": 5191, "section": "Lists" }, { "markdown": "1. a\n\n 2. b\n\n 3. c\n", "html": "
                            \n
                          1. \n

                            a

                            \n
                          2. \n
                          3. \n

                            b

                            \n
                          4. \n
                          \n
                          3. c\n
                          \n", "example": 283, "start_line": 5197, "end_line": 5214, "section": "Lists" }, { "markdown": "- a\n- b\n\n- c\n", "html": "
                            \n
                          • \n

                            a

                            \n
                          • \n
                          • \n

                            b

                            \n
                          • \n
                          • \n

                            c

                            \n
                          • \n
                          \n", "example": 284, "start_line": 5220, "end_line": 5237, "section": "Lists" }, { "markdown": "* a\n*\n\n* c\n", "html": "
                            \n
                          • \n

                            a

                            \n
                          • \n
                          • \n
                          • \n

                            c

                            \n
                          • \n
                          \n", "example": 285, "start_line": 5242, "end_line": 5257, "section": "Lists" }, { "markdown": "- a\n- b\n\n c\n- d\n", "html": "
                            \n
                          • \n

                            a

                            \n
                          • \n
                          • \n

                            b

                            \n

                            c

                            \n
                          • \n
                          • \n

                            d

                            \n
                          • \n
                          \n", "example": 286, "start_line": 5264, "end_line": 5283, "section": "Lists" }, { "markdown": "- a\n- b\n\n [ref]: /url\n- d\n", "html": "
                            \n
                          • \n

                            a

                            \n
                          • \n
                          • \n

                            b

                            \n
                          • \n
                          • \n

                            d

                            \n
                          • \n
                          \n", "example": 287, "start_line": 5286, "end_line": 5304, "section": "Lists" }, { "markdown": "- a\n- ```\n b\n\n\n ```\n- c\n", "html": "
                            \n
                          • a
                          • \n
                          • \n
                            b\n\n\n
                            \n
                          • \n
                          • c
                          • \n
                          \n", "example": 288, "start_line": 5309, "end_line": 5328, "section": "Lists" }, { "markdown": "- a\n - b\n\n c\n- d\n", "html": "
                            \n
                          • a\n
                              \n
                            • \n

                              b

                              \n

                              c

                              \n
                            • \n
                            \n
                          • \n
                          • d
                          • \n
                          \n", "example": 289, "start_line": 5335, "end_line": 5353, "section": "Lists" }, { "markdown": "* a\n > b\n >\n* c\n", "html": "
                            \n
                          • a\n
                            \n

                            b

                            \n
                            \n
                          • \n
                          • c
                          • \n
                          \n", "example": 290, "start_line": 5359, "end_line": 5373, "section": "Lists" }, { "markdown": "- a\n > b\n ```\n c\n ```\n- d\n", "html": "
                            \n
                          • a\n
                            \n

                            b

                            \n
                            \n
                            c\n
                            \n
                          • \n
                          • d
                          • \n
                          \n", "example": 291, "start_line": 5379, "end_line": 5397, "section": "Lists" }, { "markdown": "- a\n", "html": "
                            \n
                          • a
                          • \n
                          \n", "example": 292, "start_line": 5402, "end_line": 5408, "section": "Lists" }, { "markdown": "- a\n - b\n", "html": "
                            \n
                          • a\n
                              \n
                            • b
                            • \n
                            \n
                          • \n
                          \n", "example": 293, "start_line": 5411, "end_line": 5422, "section": "Lists" }, { "markdown": "1. ```\n foo\n ```\n\n bar\n", "html": "
                            \n
                          1. \n
                            foo\n
                            \n

                            bar

                            \n
                          2. \n
                          \n", "example": 294, "start_line": 5428, "end_line": 5442, "section": "Lists" }, { "markdown": "* foo\n * bar\n\n baz\n", "html": "
                            \n
                          • \n

                            foo

                            \n
                              \n
                            • bar
                            • \n
                            \n

                            baz

                            \n
                          • \n
                          \n", "example": 295, "start_line": 5447, "end_line": 5462, "section": "Lists" }, { "markdown": "- a\n - b\n - c\n\n- d\n - e\n - f\n", "html": "
                            \n
                          • \n

                            a

                            \n
                              \n
                            • b
                            • \n
                            • c
                            • \n
                            \n
                          • \n
                          • \n

                            d

                            \n
                              \n
                            • e
                            • \n
                            • f
                            • \n
                            \n
                          • \n
                          \n", "example": 296, "start_line": 5465, "end_line": 5490, "section": "Lists" }, { "markdown": "`hi`lo`\n", "html": "

                          hilo`

                          \n", "example": 297, "start_line": 5499, "end_line": 5503, "section": "Inlines" }, { "markdown": "\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\-\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\\\\\]\\^\\_\\`\\{\\|\\}\\~\n", "html": "

                          !"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~

                          \n", "example": 298, "start_line": 5513, "end_line": 5517, "section": "Backslash escapes" }, { "markdown": "\\\t\\A\\a\\ \\3\\φ\\«\n", "html": "

                          \\\t\\A\\a\\ \\3\\φ\\«

                          \n", "example": 299, "start_line": 5523, "end_line": 5527, "section": "Backslash escapes" }, { "markdown": "\\*not emphasized*\n\\
                          not a tag\n\\[not a link](/foo)\n\\`not code`\n1\\. not a list\n\\* not a list\n\\# not a heading\n\\[foo]: /url \"not a reference\"\n\\ö not a character entity\n", "html": "

                          *not emphasized*\n<br/> not a tag\n[not a link](/foo)\n`not code`\n1. not a list\n* not a list\n# not a heading\n[foo]: /url "not a reference"\n&ouml; not a character entity

                          \n", "example": 300, "start_line": 5533, "end_line": 5553, "section": "Backslash escapes" }, { "markdown": "\\\\*emphasis*\n", "html": "

                          \\emphasis

                          \n", "example": 301, "start_line": 5558, "end_line": 5562, "section": "Backslash escapes" }, { "markdown": "foo\\\nbar\n", "html": "

                          foo
                          \nbar

                          \n", "example": 302, "start_line": 5567, "end_line": 5573, "section": "Backslash escapes" }, { "markdown": "`` \\[\\` ``\n", "html": "

                          \\[\\`

                          \n", "example": 303, "start_line": 5579, "end_line": 5583, "section": "Backslash escapes" }, { "markdown": " \\[\\]\n", "html": "
                          \\[\\]\n
                          \n", "example": 304, "start_line": 5586, "end_line": 5591, "section": "Backslash escapes" }, { "markdown": "~~~\n\\[\\]\n~~~\n", "html": "
                          \\[\\]\n
                          \n", "example": 305, "start_line": 5594, "end_line": 5601, "section": "Backslash escapes" }, { "markdown": "\n", "html": "

                          http://example.com?find=\\*

                          \n", "example": 306, "start_line": 5604, "end_line": 5608, "section": "Backslash escapes" }, { "markdown": "\n", "html": "\n", "example": 307, "start_line": 5611, "end_line": 5615, "section": "Backslash escapes" }, { "markdown": "[foo](/bar\\* \"ti\\*tle\")\n", "html": "

                          foo

                          \n", "example": 308, "start_line": 5621, "end_line": 5625, "section": "Backslash escapes" }, { "markdown": "[foo]\n\n[foo]: /bar\\* \"ti\\*tle\"\n", "html": "

                          foo

                          \n", "example": 309, "start_line": 5628, "end_line": 5634, "section": "Backslash escapes" }, { "markdown": "``` foo\\+bar\nfoo\n```\n", "html": "
                          foo\n
                          \n", "example": 310, "start_line": 5637, "end_line": 5644, "section": "Backslash escapes" }, { "markdown": "  & © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸\n", "html": "

                            & © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸

                          \n", "example": 311, "start_line": 5674, "end_line": 5682, "section": "Entity and numeric character references" }, { "markdown": "# Ӓ Ϡ �\n", "html": "

                          # Ӓ Ϡ �

                          \n", "example": 312, "start_line": 5693, "end_line": 5697, "section": "Entity and numeric character references" }, { "markdown": "" ആ ಫ\n", "html": "

                          " ആ ಫ

                          \n", "example": 313, "start_line": 5706, "end_line": 5710, "section": "Entity and numeric character references" }, { "markdown": "  &x; &#; &#x;\n�\n&#abcdef0;\n&ThisIsNotDefined; &hi?;\n", "html": "

                          &nbsp &x; &#; &#x;\n&#987654321;\n&#abcdef0;\n&ThisIsNotDefined; &hi?;

                          \n", "example": 314, "start_line": 5715, "end_line": 5725, "section": "Entity and numeric character references" }, { "markdown": "©\n", "html": "

                          &copy

                          \n", "example": 315, "start_line": 5732, "end_line": 5736, "section": "Entity and numeric character references" }, { "markdown": "&MadeUpEntity;\n", "html": "

                          &MadeUpEntity;

                          \n", "example": 316, "start_line": 5742, "end_line": 5746, "section": "Entity and numeric character references" }, { "markdown": "\n", "html": "\n", "example": 317, "start_line": 5753, "end_line": 5757, "section": "Entity and numeric character references" }, { "markdown": "[foo](/föö \"föö\")\n", "html": "

                          foo

                          \n", "example": 318, "start_line": 5760, "end_line": 5764, "section": "Entity and numeric character references" }, { "markdown": "[foo]\n\n[foo]: /föö \"föö\"\n", "html": "

                          foo

                          \n", "example": 319, "start_line": 5767, "end_line": 5773, "section": "Entity and numeric character references" }, { "markdown": "``` föö\nfoo\n```\n", "html": "
                          foo\n
                          \n", "example": 320, "start_line": 5776, "end_line": 5783, "section": "Entity and numeric character references" }, { "markdown": "`föö`\n", "html": "

                          f&ouml;&ouml;

                          \n", "example": 321, "start_line": 5789, "end_line": 5793, "section": "Entity and numeric character references" }, { "markdown": " föfö\n", "html": "
                          f&ouml;f&ouml;\n
                          \n", "example": 322, "start_line": 5796, "end_line": 5801, "section": "Entity and numeric character references" }, { "markdown": "*foo*\n*foo*\n", "html": "

                          *foo*\nfoo

                          \n", "example": 323, "start_line": 5808, "end_line": 5814, "section": "Entity and numeric character references" }, { "markdown": "* foo\n\n* foo\n", "html": "

                          * foo

                          \n
                            \n
                          • foo
                          • \n
                          \n", "example": 324, "start_line": 5816, "end_line": 5825, "section": "Entity and numeric character references" }, { "markdown": "foo bar\n", "html": "

                          foo\n\nbar

                          \n", "example": 325, "start_line": 5827, "end_line": 5833, "section": "Entity and numeric character references" }, { "markdown": " foo\n", "html": "

                          \tfoo

                          \n", "example": 326, "start_line": 5835, "end_line": 5839, "section": "Entity and numeric character references" }, { "markdown": "[a](url "tit")\n", "html": "

                          [a](url "tit")

                          \n", "example": 327, "start_line": 5842, "end_line": 5846, "section": "Entity and numeric character references" }, { "markdown": "`foo`\n", "html": "

                          foo

                          \n", "example": 328, "start_line": 5870, "end_line": 5874, "section": "Code spans" }, { "markdown": "`` foo ` bar ``\n", "html": "

                          foo ` bar

                          \n", "example": 329, "start_line": 5881, "end_line": 5885, "section": "Code spans" }, { "markdown": "` `` `\n", "html": "

                          ``

                          \n", "example": 330, "start_line": 5891, "end_line": 5895, "section": "Code spans" }, { "markdown": "` `` `\n", "html": "

                          ``

                          \n", "example": 331, "start_line": 5899, "end_line": 5903, "section": "Code spans" }, { "markdown": "` a`\n", "html": "

                          a

                          \n", "example": 332, "start_line": 5908, "end_line": 5912, "section": "Code spans" }, { "markdown": "` b `\n", "html": "

                           b 

                          \n", "example": 333, "start_line": 5917, "end_line": 5921, "section": "Code spans" }, { "markdown": "` `\n` `\n", "html": "

                           \n

                          \n", "example": 334, "start_line": 5925, "end_line": 5931, "section": "Code spans" }, { "markdown": "``\nfoo\nbar \nbaz\n``\n", "html": "

                          foo bar baz

                          \n", "example": 335, "start_line": 5936, "end_line": 5944, "section": "Code spans" }, { "markdown": "``\nfoo \n``\n", "html": "

                          foo

                          \n", "example": 336, "start_line": 5946, "end_line": 5952, "section": "Code spans" }, { "markdown": "`foo bar \nbaz`\n", "html": "

                          foo bar baz

                          \n", "example": 337, "start_line": 5957, "end_line": 5962, "section": "Code spans" }, { "markdown": "`foo\\`bar`\n", "html": "

                          foo\\bar`

                          \n", "example": 338, "start_line": 5974, "end_line": 5978, "section": "Code spans" }, { "markdown": "``foo`bar``\n", "html": "

                          foo`bar

                          \n", "example": 339, "start_line": 5985, "end_line": 5989, "section": "Code spans" }, { "markdown": "` foo `` bar `\n", "html": "

                          foo `` bar

                          \n", "example": 340, "start_line": 5991, "end_line": 5995, "section": "Code spans" }, { "markdown": "*foo`*`\n", "html": "

                          *foo*

                          \n", "example": 341, "start_line": 6003, "end_line": 6007, "section": "Code spans" }, { "markdown": "[not a `link](/foo`)\n", "html": "

                          [not a link](/foo)

                          \n", "example": 342, "start_line": 6012, "end_line": 6016, "section": "Code spans" }, { "markdown": "``\n", "html": "

                          <a href="">`

                          \n", "example": 343, "start_line": 6022, "end_line": 6026, "section": "Code spans" }, { "markdown": "
                          `\n", "html": "

                          `

                          \n", "example": 344, "start_line": 6031, "end_line": 6035, "section": "Code spans" }, { "markdown": "``\n", "html": "

                          <http://foo.bar.baz>`

                          \n", "example": 345, "start_line": 6040, "end_line": 6044, "section": "Code spans" }, { "markdown": "`\n", "html": "

                          http://foo.bar.`baz`

                          \n", "example": 346, "start_line": 6049, "end_line": 6053, "section": "Code spans" }, { "markdown": "```foo``\n", "html": "

                          ```foo``

                          \n", "example": 347, "start_line": 6059, "end_line": 6063, "section": "Code spans" }, { "markdown": "`foo\n", "html": "

                          `foo

                          \n", "example": 348, "start_line": 6066, "end_line": 6070, "section": "Code spans" }, { "markdown": "`foo``bar``\n", "html": "

                          `foobar

                          \n", "example": 349, "start_line": 6075, "end_line": 6079, "section": "Code spans" }, { "markdown": "*foo bar*\n", "html": "

                          foo bar

                          \n", "example": 350, "start_line": 6292, "end_line": 6296, "section": "Emphasis and strong emphasis" }, { "markdown": "a * foo bar*\n", "html": "

                          a * foo bar*

                          \n", "example": 351, "start_line": 6302, "end_line": 6306, "section": "Emphasis and strong emphasis" }, { "markdown": "a*\"foo\"*\n", "html": "

                          a*"foo"*

                          \n", "example": 352, "start_line": 6313, "end_line": 6317, "section": "Emphasis and strong emphasis" }, { "markdown": "* a *\n", "html": "

                          * a *

                          \n", "example": 353, "start_line": 6322, "end_line": 6326, "section": "Emphasis and strong emphasis" }, { "markdown": "foo*bar*\n", "html": "

                          foobar

                          \n", "example": 354, "start_line": 6331, "end_line": 6335, "section": "Emphasis and strong emphasis" }, { "markdown": "5*6*78\n", "html": "

                          5678

                          \n", "example": 355, "start_line": 6338, "end_line": 6342, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo bar_\n", "html": "

                          foo bar

                          \n", "example": 356, "start_line": 6347, "end_line": 6351, "section": "Emphasis and strong emphasis" }, { "markdown": "_ foo bar_\n", "html": "

                          _ foo bar_

                          \n", "example": 357, "start_line": 6357, "end_line": 6361, "section": "Emphasis and strong emphasis" }, { "markdown": "a_\"foo\"_\n", "html": "

                          a_"foo"_

                          \n", "example": 358, "start_line": 6367, "end_line": 6371, "section": "Emphasis and strong emphasis" }, { "markdown": "foo_bar_\n", "html": "

                          foo_bar_

                          \n", "example": 359, "start_line": 6376, "end_line": 6380, "section": "Emphasis and strong emphasis" }, { "markdown": "5_6_78\n", "html": "

                          5_6_78

                          \n", "example": 360, "start_line": 6383, "end_line": 6387, "section": "Emphasis and strong emphasis" }, { "markdown": "пристаням_стремятся_\n", "html": "

                          пристаням_стремятся_

                          \n", "example": 361, "start_line": 6390, "end_line": 6394, "section": "Emphasis and strong emphasis" }, { "markdown": "aa_\"bb\"_cc\n", "html": "

                          aa_"bb"_cc

                          \n", "example": 362, "start_line": 6400, "end_line": 6404, "section": "Emphasis and strong emphasis" }, { "markdown": "foo-_(bar)_\n", "html": "

                          foo-(bar)

                          \n", "example": 363, "start_line": 6411, "end_line": 6415, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo*\n", "html": "

                          _foo*

                          \n", "example": 364, "start_line": 6423, "end_line": 6427, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo bar *\n", "html": "

                          *foo bar *

                          \n", "example": 365, "start_line": 6433, "end_line": 6437, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo bar\n*\n", "html": "

                          *foo bar\n*

                          \n", "example": 366, "start_line": 6442, "end_line": 6448, "section": "Emphasis and strong emphasis" }, { "markdown": "*(*foo)\n", "html": "

                          *(*foo)

                          \n", "example": 367, "start_line": 6455, "end_line": 6459, "section": "Emphasis and strong emphasis" }, { "markdown": "*(*foo*)*\n", "html": "

                          (foo)

                          \n", "example": 368, "start_line": 6465, "end_line": 6469, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo*bar\n", "html": "

                          foobar

                          \n", "example": 369, "start_line": 6474, "end_line": 6478, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo bar _\n", "html": "

                          _foo bar _

                          \n", "example": 370, "start_line": 6487, "end_line": 6491, "section": "Emphasis and strong emphasis" }, { "markdown": "_(_foo)\n", "html": "

                          _(_foo)

                          \n", "example": 371, "start_line": 6497, "end_line": 6501, "section": "Emphasis and strong emphasis" }, { "markdown": "_(_foo_)_\n", "html": "

                          (foo)

                          \n", "example": 372, "start_line": 6506, "end_line": 6510, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo_bar\n", "html": "

                          _foo_bar

                          \n", "example": 373, "start_line": 6515, "end_line": 6519, "section": "Emphasis and strong emphasis" }, { "markdown": "_пристаням_стремятся\n", "html": "

                          _пристаням_стремятся

                          \n", "example": 374, "start_line": 6522, "end_line": 6526, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo_bar_baz_\n", "html": "

                          foo_bar_baz

                          \n", "example": 375, "start_line": 6529, "end_line": 6533, "section": "Emphasis and strong emphasis" }, { "markdown": "_(bar)_.\n", "html": "

                          (bar).

                          \n", "example": 376, "start_line": 6540, "end_line": 6544, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo bar**\n", "html": "

                          foo bar

                          \n", "example": 377, "start_line": 6549, "end_line": 6553, "section": "Emphasis and strong emphasis" }, { "markdown": "** foo bar**\n", "html": "

                          ** foo bar**

                          \n", "example": 378, "start_line": 6559, "end_line": 6563, "section": "Emphasis and strong emphasis" }, { "markdown": "a**\"foo\"**\n", "html": "

                          a**"foo"**

                          \n", "example": 379, "start_line": 6570, "end_line": 6574, "section": "Emphasis and strong emphasis" }, { "markdown": "foo**bar**\n", "html": "

                          foobar

                          \n", "example": 380, "start_line": 6579, "end_line": 6583, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo bar__\n", "html": "

                          foo bar

                          \n", "example": 381, "start_line": 6588, "end_line": 6592, "section": "Emphasis and strong emphasis" }, { "markdown": "__ foo bar__\n", "html": "

                          __ foo bar__

                          \n", "example": 382, "start_line": 6598, "end_line": 6602, "section": "Emphasis and strong emphasis" }, { "markdown": "__\nfoo bar__\n", "html": "

                          __\nfoo bar__

                          \n", "example": 383, "start_line": 6606, "end_line": 6612, "section": "Emphasis and strong emphasis" }, { "markdown": "a__\"foo\"__\n", "html": "

                          a__"foo"__

                          \n", "example": 384, "start_line": 6618, "end_line": 6622, "section": "Emphasis and strong emphasis" }, { "markdown": "foo__bar__\n", "html": "

                          foo__bar__

                          \n", "example": 385, "start_line": 6627, "end_line": 6631, "section": "Emphasis and strong emphasis" }, { "markdown": "5__6__78\n", "html": "

                          5__6__78

                          \n", "example": 386, "start_line": 6634, "end_line": 6638, "section": "Emphasis and strong emphasis" }, { "markdown": "пристаням__стремятся__\n", "html": "

                          пристаням__стремятся__

                          \n", "example": 387, "start_line": 6641, "end_line": 6645, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo, __bar__, baz__\n", "html": "

                          foo, bar, baz

                          \n", "example": 388, "start_line": 6648, "end_line": 6652, "section": "Emphasis and strong emphasis" }, { "markdown": "foo-__(bar)__\n", "html": "

                          foo-(bar)

                          \n", "example": 389, "start_line": 6659, "end_line": 6663, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo bar **\n", "html": "

                          **foo bar **

                          \n", "example": 390, "start_line": 6672, "end_line": 6676, "section": "Emphasis and strong emphasis" }, { "markdown": "**(**foo)\n", "html": "

                          **(**foo)

                          \n", "example": 391, "start_line": 6685, "end_line": 6689, "section": "Emphasis and strong emphasis" }, { "markdown": "*(**foo**)*\n", "html": "

                          (foo)

                          \n", "example": 392, "start_line": 6695, "end_line": 6699, "section": "Emphasis and strong emphasis" }, { "markdown": "**Gomphocarpus (*Gomphocarpus physocarpus*, syn.\n*Asclepias physocarpa*)**\n", "html": "

                          Gomphocarpus (Gomphocarpus physocarpus, syn.\nAsclepias physocarpa)

                          \n", "example": 393, "start_line": 6702, "end_line": 6708, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo \"*bar*\" foo**\n", "html": "

                          foo "bar" foo

                          \n", "example": 394, "start_line": 6711, "end_line": 6715, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo**bar\n", "html": "

                          foobar

                          \n", "example": 395, "start_line": 6720, "end_line": 6724, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo bar __\n", "html": "

                          __foo bar __

                          \n", "example": 396, "start_line": 6732, "end_line": 6736, "section": "Emphasis and strong emphasis" }, { "markdown": "__(__foo)\n", "html": "

                          __(__foo)

                          \n", "example": 397, "start_line": 6742, "end_line": 6746, "section": "Emphasis and strong emphasis" }, { "markdown": "_(__foo__)_\n", "html": "

                          (foo)

                          \n", "example": 398, "start_line": 6752, "end_line": 6756, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__bar\n", "html": "

                          __foo__bar

                          \n", "example": 399, "start_line": 6761, "end_line": 6765, "section": "Emphasis and strong emphasis" }, { "markdown": "__пристаням__стремятся\n", "html": "

                          __пристаням__стремятся

                          \n", "example": 400, "start_line": 6768, "end_line": 6772, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__bar__baz__\n", "html": "

                          foo__bar__baz

                          \n", "example": 401, "start_line": 6775, "end_line": 6779, "section": "Emphasis and strong emphasis" }, { "markdown": "__(bar)__.\n", "html": "

                          (bar).

                          \n", "example": 402, "start_line": 6786, "end_line": 6790, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo [bar](/url)*\n", "html": "

                          foo bar

                          \n", "example": 403, "start_line": 6798, "end_line": 6802, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo\nbar*\n", "html": "

                          foo\nbar

                          \n", "example": 404, "start_line": 6805, "end_line": 6811, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo __bar__ baz_\n", "html": "

                          foo bar baz

                          \n", "example": 405, "start_line": 6817, "end_line": 6821, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo _bar_ baz_\n", "html": "

                          foo bar baz

                          \n", "example": 406, "start_line": 6824, "end_line": 6828, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo_ bar_\n", "html": "

                          foo bar

                          \n", "example": 407, "start_line": 6831, "end_line": 6835, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo *bar**\n", "html": "

                          foo bar

                          \n", "example": 408, "start_line": 6838, "end_line": 6842, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar** baz*\n", "html": "

                          foo bar baz

                          \n", "example": 409, "start_line": 6845, "end_line": 6849, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar**baz*\n", "html": "

                          foobarbaz

                          \n", "example": 410, "start_line": 6851, "end_line": 6855, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar*\n", "html": "

                          foo**bar

                          \n", "example": 411, "start_line": 6875, "end_line": 6879, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo** bar*\n", "html": "

                          foo bar

                          \n", "example": 412, "start_line": 6888, "end_line": 6892, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar***\n", "html": "

                          foo bar

                          \n", "example": 413, "start_line": 6895, "end_line": 6899, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar***\n", "html": "

                          foobar

                          \n", "example": 414, "start_line": 6902, "end_line": 6906, "section": "Emphasis and strong emphasis" }, { "markdown": "foo***bar***baz\n", "html": "

                          foobarbaz

                          \n", "example": 415, "start_line": 6913, "end_line": 6917, "section": "Emphasis and strong emphasis" }, { "markdown": "foo******bar*********baz\n", "html": "

                          foobar***baz

                          \n", "example": 416, "start_line": 6919, "end_line": 6923, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar *baz* bim** bop*\n", "html": "

                          foo bar baz bim bop

                          \n", "example": 417, "start_line": 6928, "end_line": 6932, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo [*bar*](/url)*\n", "html": "

                          foo bar

                          \n", "example": 418, "start_line": 6935, "end_line": 6939, "section": "Emphasis and strong emphasis" }, { "markdown": "** is not an empty emphasis\n", "html": "

                          ** is not an empty emphasis

                          \n", "example": 419, "start_line": 6944, "end_line": 6948, "section": "Emphasis and strong emphasis" }, { "markdown": "**** is not an empty strong emphasis\n", "html": "

                          **** is not an empty strong emphasis

                          \n", "example": 420, "start_line": 6951, "end_line": 6955, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo [bar](/url)**\n", "html": "

                          foo bar

                          \n", "example": 421, "start_line": 6964, "end_line": 6968, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo\nbar**\n", "html": "

                          foo\nbar

                          \n", "example": 422, "start_line": 6971, "end_line": 6977, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo _bar_ baz__\n", "html": "

                          foo bar baz

                          \n", "example": 423, "start_line": 6983, "end_line": 6987, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo __bar__ baz__\n", "html": "

                          foo bar baz

                          \n", "example": 424, "start_line": 6990, "end_line": 6994, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo__ bar__\n", "html": "

                          foo bar

                          \n", "example": 425, "start_line": 6997, "end_line": 7001, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo **bar****\n", "html": "

                          foo bar

                          \n", "example": 426, "start_line": 7004, "end_line": 7008, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar* baz**\n", "html": "

                          foo bar baz

                          \n", "example": 427, "start_line": 7011, "end_line": 7015, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo*bar*baz**\n", "html": "

                          foobarbaz

                          \n", "example": 428, "start_line": 7018, "end_line": 7022, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo* bar**\n", "html": "

                          foo bar

                          \n", "example": 429, "start_line": 7025, "end_line": 7029, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar***\n", "html": "

                          foo bar

                          \n", "example": 430, "start_line": 7032, "end_line": 7036, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar **baz**\nbim* bop**\n", "html": "

                          foo bar baz\nbim bop

                          \n", "example": 431, "start_line": 7041, "end_line": 7047, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo [*bar*](/url)**\n", "html": "

                          foo bar

                          \n", "example": 432, "start_line": 7050, "end_line": 7054, "section": "Emphasis and strong emphasis" }, { "markdown": "__ is not an empty emphasis\n", "html": "

                          __ is not an empty emphasis

                          \n", "example": 433, "start_line": 7059, "end_line": 7063, "section": "Emphasis and strong emphasis" }, { "markdown": "____ is not an empty strong emphasis\n", "html": "

                          ____ is not an empty strong emphasis

                          \n", "example": 434, "start_line": 7066, "end_line": 7070, "section": "Emphasis and strong emphasis" }, { "markdown": "foo ***\n", "html": "

                          foo ***

                          \n", "example": 435, "start_line": 7076, "end_line": 7080, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *\\**\n", "html": "

                          foo *

                          \n", "example": 436, "start_line": 7083, "end_line": 7087, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *_*\n", "html": "

                          foo _

                          \n", "example": 437, "start_line": 7090, "end_line": 7094, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *****\n", "html": "

                          foo *****

                          \n", "example": 438, "start_line": 7097, "end_line": 7101, "section": "Emphasis and strong emphasis" }, { "markdown": "foo **\\***\n", "html": "

                          foo *

                          \n", "example": 439, "start_line": 7104, "end_line": 7108, "section": "Emphasis and strong emphasis" }, { "markdown": "foo **_**\n", "html": "

                          foo _

                          \n", "example": 440, "start_line": 7111, "end_line": 7115, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo*\n", "html": "

                          *foo

                          \n", "example": 441, "start_line": 7122, "end_line": 7126, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**\n", "html": "

                          foo*

                          \n", "example": 442, "start_line": 7129, "end_line": 7133, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo**\n", "html": "

                          *foo

                          \n", "example": 443, "start_line": 7136, "end_line": 7140, "section": "Emphasis and strong emphasis" }, { "markdown": "****foo*\n", "html": "

                          ***foo

                          \n", "example": 444, "start_line": 7143, "end_line": 7147, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo***\n", "html": "

                          foo*

                          \n", "example": 445, "start_line": 7150, "end_line": 7154, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo****\n", "html": "

                          foo***

                          \n", "example": 446, "start_line": 7157, "end_line": 7161, "section": "Emphasis and strong emphasis" }, { "markdown": "foo ___\n", "html": "

                          foo ___

                          \n", "example": 447, "start_line": 7167, "end_line": 7171, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _\\__\n", "html": "

                          foo _

                          \n", "example": 448, "start_line": 7174, "end_line": 7178, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _*_\n", "html": "

                          foo *

                          \n", "example": 449, "start_line": 7181, "end_line": 7185, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _____\n", "html": "

                          foo _____

                          \n", "example": 450, "start_line": 7188, "end_line": 7192, "section": "Emphasis and strong emphasis" }, { "markdown": "foo __\\___\n", "html": "

                          foo _

                          \n", "example": 451, "start_line": 7195, "end_line": 7199, "section": "Emphasis and strong emphasis" }, { "markdown": "foo __*__\n", "html": "

                          foo *

                          \n", "example": 452, "start_line": 7202, "end_line": 7206, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo_\n", "html": "

                          _foo

                          \n", "example": 453, "start_line": 7209, "end_line": 7213, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo__\n", "html": "

                          foo_

                          \n", "example": 454, "start_line": 7220, "end_line": 7224, "section": "Emphasis and strong emphasis" }, { "markdown": "___foo__\n", "html": "

                          _foo

                          \n", "example": 455, "start_line": 7227, "end_line": 7231, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo_\n", "html": "

                          ___foo

                          \n", "example": 456, "start_line": 7234, "end_line": 7238, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo___\n", "html": "

                          foo_

                          \n", "example": 457, "start_line": 7241, "end_line": 7245, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo____\n", "html": "

                          foo___

                          \n", "example": 458, "start_line": 7248, "end_line": 7252, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo**\n", "html": "

                          foo

                          \n", "example": 459, "start_line": 7258, "end_line": 7262, "section": "Emphasis and strong emphasis" }, { "markdown": "*_foo_*\n", "html": "

                          foo

                          \n", "example": 460, "start_line": 7265, "end_line": 7269, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__\n", "html": "

                          foo

                          \n", "example": 461, "start_line": 7272, "end_line": 7276, "section": "Emphasis and strong emphasis" }, { "markdown": "_*foo*_\n", "html": "

                          foo

                          \n", "example": 462, "start_line": 7279, "end_line": 7283, "section": "Emphasis and strong emphasis" }, { "markdown": "****foo****\n", "html": "

                          foo

                          \n", "example": 463, "start_line": 7289, "end_line": 7293, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo____\n", "html": "

                          foo

                          \n", "example": 464, "start_line": 7296, "end_line": 7300, "section": "Emphasis and strong emphasis" }, { "markdown": "******foo******\n", "html": "

                          foo

                          \n", "example": 465, "start_line": 7307, "end_line": 7311, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo***\n", "html": "

                          foo

                          \n", "example": 466, "start_line": 7316, "end_line": 7320, "section": "Emphasis and strong emphasis" }, { "markdown": "_____foo_____\n", "html": "

                          foo

                          \n", "example": 467, "start_line": 7323, "end_line": 7327, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo _bar* baz_\n", "html": "

                          foo _bar baz_

                          \n", "example": 468, "start_line": 7332, "end_line": 7336, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo __bar *baz bim__ bam*\n", "html": "

                          foo bar *baz bim bam

                          \n", "example": 469, "start_line": 7339, "end_line": 7343, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo **bar baz**\n", "html": "

                          **foo bar baz

                          \n", "example": 470, "start_line": 7348, "end_line": 7352, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo *bar baz*\n", "html": "

                          *foo bar baz

                          \n", "example": 471, "start_line": 7355, "end_line": 7359, "section": "Emphasis and strong emphasis" }, { "markdown": "*[bar*](/url)\n", "html": "

                          *bar*

                          \n", "example": 472, "start_line": 7364, "end_line": 7368, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo [bar_](/url)\n", "html": "

                          _foo bar_

                          \n", "example": 473, "start_line": 7371, "end_line": 7375, "section": "Emphasis and strong emphasis" }, { "markdown": "*\n", "html": "

                          *

                          \n", "example": 474, "start_line": 7378, "end_line": 7382, "section": "Emphasis and strong emphasis" }, { "markdown": "**\n", "html": "

                          **

                          \n", "example": 475, "start_line": 7385, "end_line": 7389, "section": "Emphasis and strong emphasis" }, { "markdown": "__\n", "html": "

                          __

                          \n", "example": 476, "start_line": 7392, "end_line": 7396, "section": "Emphasis and strong emphasis" }, { "markdown": "*a `*`*\n", "html": "

                          a *

                          \n", "example": 477, "start_line": 7399, "end_line": 7403, "section": "Emphasis and strong emphasis" }, { "markdown": "_a `_`_\n", "html": "

                          a _

                          \n", "example": 478, "start_line": 7406, "end_line": 7410, "section": "Emphasis and strong emphasis" }, { "markdown": "**a\n", "html": "

                          **ahttp://foo.bar/?q=**

                          \n", "example": 479, "start_line": 7413, "end_line": 7417, "section": "Emphasis and strong emphasis" }, { "markdown": "__a\n", "html": "

                          __ahttp://foo.bar/?q=__

                          \n", "example": 480, "start_line": 7420, "end_line": 7424, "section": "Emphasis and strong emphasis" }, { "markdown": "[link](/uri \"title\")\n", "html": "

                          link

                          \n", "example": 481, "start_line": 7503, "end_line": 7507, "section": "Links" }, { "markdown": "[link](/uri)\n", "html": "

                          link

                          \n", "example": 482, "start_line": 7512, "end_line": 7516, "section": "Links" }, { "markdown": "[link]()\n", "html": "

                          link

                          \n", "example": 483, "start_line": 7521, "end_line": 7525, "section": "Links" }, { "markdown": "[link](<>)\n", "html": "

                          link

                          \n", "example": 484, "start_line": 7528, "end_line": 7532, "section": "Links" }, { "markdown": "[link](/my uri)\n", "html": "

                          [link](/my uri)

                          \n", "example": 485, "start_line": 7537, "end_line": 7541, "section": "Links" }, { "markdown": "[link](
                          )\n", "html": "

                          link

                          \n", "example": 486, "start_line": 7543, "end_line": 7547, "section": "Links" }, { "markdown": "[link](foo\nbar)\n", "html": "

                          [link](foo\nbar)

                          \n", "example": 487, "start_line": 7552, "end_line": 7558, "section": "Links" }, { "markdown": "[link]()\n", "html": "

                          [link]()

                          \n", "example": 488, "start_line": 7560, "end_line": 7566, "section": "Links" }, { "markdown": "[a]()\n", "html": "

                          a

                          \n", "example": 489, "start_line": 7571, "end_line": 7575, "section": "Links" }, { "markdown": "[link]()\n", "html": "

                          [link](<foo>)

                          \n", "example": 490, "start_line": 7579, "end_line": 7583, "section": "Links" }, { "markdown": "[a](\n[a](c)\n", "html": "

                          [a](<b)c\n[a](<b)c>\n[a](c)

                          \n", "example": 491, "start_line": 7588, "end_line": 7596, "section": "Links" }, { "markdown": "[link](\\(foo\\))\n", "html": "

                          link

                          \n", "example": 492, "start_line": 7600, "end_line": 7604, "section": "Links" }, { "markdown": "[link](foo(and(bar)))\n", "html": "

                          link

                          \n", "example": 493, "start_line": 7609, "end_line": 7613, "section": "Links" }, { "markdown": "[link](foo\\(and\\(bar\\))\n", "html": "

                          link

                          \n", "example": 494, "start_line": 7618, "end_line": 7622, "section": "Links" }, { "markdown": "[link]()\n", "html": "

                          link

                          \n", "example": 495, "start_line": 7625, "end_line": 7629, "section": "Links" }, { "markdown": "[link](foo\\)\\:)\n", "html": "

                          link

                          \n", "example": 496, "start_line": 7635, "end_line": 7639, "section": "Links" }, { "markdown": "[link](#fragment)\n\n[link](http://example.com#fragment)\n\n[link](http://example.com?foo=3#frag)\n", "html": "

                          link

                          \n

                          link

                          \n

                          link

                          \n", "example": 497, "start_line": 7644, "end_line": 7654, "section": "Links" }, { "markdown": "[link](foo\\bar)\n", "html": "

                          link

                          \n", "example": 498, "start_line": 7660, "end_line": 7664, "section": "Links" }, { "markdown": "[link](foo%20bä)\n", "html": "

                          link

                          \n", "example": 499, "start_line": 7676, "end_line": 7680, "section": "Links" }, { "markdown": "[link](\"title\")\n", "html": "

                          link

                          \n", "example": 500, "start_line": 7687, "end_line": 7691, "section": "Links" }, { "markdown": "[link](/url \"title\")\n[link](/url 'title')\n[link](/url (title))\n", "html": "

                          link\nlink\nlink

                          \n", "example": 501, "start_line": 7696, "end_line": 7704, "section": "Links" }, { "markdown": "[link](/url \"title \\\""\")\n", "html": "

                          link

                          \n", "example": 502, "start_line": 7710, "end_line": 7714, "section": "Links" }, { "markdown": "[link](/url \"title\")\n", "html": "

                          link

                          \n", "example": 503, "start_line": 7720, "end_line": 7724, "section": "Links" }, { "markdown": "[link](/url \"title \"and\" title\")\n", "html": "

                          [link](/url "title "and" title")

                          \n", "example": 504, "start_line": 7729, "end_line": 7733, "section": "Links" }, { "markdown": "[link](/url 'title \"and\" title')\n", "html": "

                          link

                          \n", "example": 505, "start_line": 7738, "end_line": 7742, "section": "Links" }, { "markdown": "[link]( /uri\n \"title\" )\n", "html": "

                          link

                          \n", "example": 506, "start_line": 7762, "end_line": 7767, "section": "Links" }, { "markdown": "[link] (/uri)\n", "html": "

                          [link] (/uri)

                          \n", "example": 507, "start_line": 7773, "end_line": 7777, "section": "Links" }, { "markdown": "[link [foo [bar]]](/uri)\n", "html": "

                          link [foo [bar]]

                          \n", "example": 508, "start_line": 7783, "end_line": 7787, "section": "Links" }, { "markdown": "[link] bar](/uri)\n", "html": "

                          [link] bar](/uri)

                          \n", "example": 509, "start_line": 7790, "end_line": 7794, "section": "Links" }, { "markdown": "[link [bar](/uri)\n", "html": "

                          [link bar

                          \n", "example": 510, "start_line": 7797, "end_line": 7801, "section": "Links" }, { "markdown": "[link \\[bar](/uri)\n", "html": "

                          link [bar

                          \n", "example": 511, "start_line": 7804, "end_line": 7808, "section": "Links" }, { "markdown": "[link *foo **bar** `#`*](/uri)\n", "html": "

                          link foo bar #

                          \n", "example": 512, "start_line": 7813, "end_line": 7817, "section": "Links" }, { "markdown": "[![moon](moon.jpg)](/uri)\n", "html": "

                          \"moon\"

                          \n", "example": 513, "start_line": 7820, "end_line": 7824, "section": "Links" }, { "markdown": "[foo [bar](/uri)](/uri)\n", "html": "

                          [foo bar](/uri)

                          \n", "example": 514, "start_line": 7829, "end_line": 7833, "section": "Links" }, { "markdown": "[foo *[bar [baz](/uri)](/uri)*](/uri)\n", "html": "

                          [foo [bar baz](/uri)](/uri)

                          \n", "example": 515, "start_line": 7836, "end_line": 7840, "section": "Links" }, { "markdown": "![[[foo](uri1)](uri2)](uri3)\n", "html": "

                          \"[foo](uri2)\"

                          \n", "example": 516, "start_line": 7843, "end_line": 7847, "section": "Links" }, { "markdown": "*[foo*](/uri)\n", "html": "

                          *foo*

                          \n", "example": 517, "start_line": 7853, "end_line": 7857, "section": "Links" }, { "markdown": "[foo *bar](baz*)\n", "html": "

                          foo *bar

                          \n", "example": 518, "start_line": 7860, "end_line": 7864, "section": "Links" }, { "markdown": "*foo [bar* baz]\n", "html": "

                          foo [bar baz]

                          \n", "example": 519, "start_line": 7870, "end_line": 7874, "section": "Links" }, { "markdown": "[foo \n", "html": "

                          [foo

                          \n", "example": 520, "start_line": 7880, "end_line": 7884, "section": "Links" }, { "markdown": "[foo`](/uri)`\n", "html": "

                          [foo](/uri)

                          \n", "example": 521, "start_line": 7887, "end_line": 7891, "section": "Links" }, { "markdown": "[foo\n", "html": "

                          [foohttp://example.com/?search=](uri)

                          \n", "example": 522, "start_line": 7894, "end_line": 7898, "section": "Links" }, { "markdown": "[foo][bar]\n\n[bar]: /url \"title\"\n", "html": "

                          foo

                          \n", "example": 523, "start_line": 7932, "end_line": 7938, "section": "Links" }, { "markdown": "[link [foo [bar]]][ref]\n\n[ref]: /uri\n", "html": "

                          link [foo [bar]]

                          \n", "example": 524, "start_line": 7947, "end_line": 7953, "section": "Links" }, { "markdown": "[link \\[bar][ref]\n\n[ref]: /uri\n", "html": "

                          link [bar

                          \n", "example": 525, "start_line": 7956, "end_line": 7962, "section": "Links" }, { "markdown": "[link *foo **bar** `#`*][ref]\n\n[ref]: /uri\n", "html": "

                          link foo bar #

                          \n", "example": 526, "start_line": 7967, "end_line": 7973, "section": "Links" }, { "markdown": "[![moon](moon.jpg)][ref]\n\n[ref]: /uri\n", "html": "

                          \"moon\"

                          \n", "example": 527, "start_line": 7976, "end_line": 7982, "section": "Links" }, { "markdown": "[foo [bar](/uri)][ref]\n\n[ref]: /uri\n", "html": "

                          [foo bar]ref

                          \n", "example": 528, "start_line": 7987, "end_line": 7993, "section": "Links" }, { "markdown": "[foo *bar [baz][ref]*][ref]\n\n[ref]: /uri\n", "html": "

                          [foo bar baz]ref

                          \n", "example": 529, "start_line": 7996, "end_line": 8002, "section": "Links" }, { "markdown": "*[foo*][ref]\n\n[ref]: /uri\n", "html": "

                          *foo*

                          \n", "example": 530, "start_line": 8011, "end_line": 8017, "section": "Links" }, { "markdown": "[foo *bar][ref]\n\n[ref]: /uri\n", "html": "

                          foo *bar

                          \n", "example": 531, "start_line": 8020, "end_line": 8026, "section": "Links" }, { "markdown": "[foo \n\n[ref]: /uri\n", "html": "

                          [foo

                          \n", "example": 532, "start_line": 8032, "end_line": 8038, "section": "Links" }, { "markdown": "[foo`][ref]`\n\n[ref]: /uri\n", "html": "

                          [foo][ref]

                          \n", "example": 533, "start_line": 8041, "end_line": 8047, "section": "Links" }, { "markdown": "[foo\n\n[ref]: /uri\n", "html": "

                          [foohttp://example.com/?search=][ref]

                          \n", "example": 534, "start_line": 8050, "end_line": 8056, "section": "Links" }, { "markdown": "[foo][BaR]\n\n[bar]: /url \"title\"\n", "html": "

                          foo

                          \n", "example": 535, "start_line": 8061, "end_line": 8067, "section": "Links" }, { "markdown": "[Толпой][Толпой] is a Russian word.\n\n[ТОЛПОЙ]: /url\n", "html": "

                          Толпой is a Russian word.

                          \n", "example": 536, "start_line": 8072, "end_line": 8078, "section": "Links" }, { "markdown": "[Foo\n bar]: /url\n\n[Baz][Foo bar]\n", "html": "

                          Baz

                          \n", "example": 537, "start_line": 8084, "end_line": 8091, "section": "Links" }, { "markdown": "[foo] [bar]\n\n[bar]: /url \"title\"\n", "html": "

                          [foo] bar

                          \n", "example": 538, "start_line": 8097, "end_line": 8103, "section": "Links" }, { "markdown": "[foo]\n[bar]\n\n[bar]: /url \"title\"\n", "html": "

                          [foo]\nbar

                          \n", "example": 539, "start_line": 8106, "end_line": 8114, "section": "Links" }, { "markdown": "[foo]: /url1\n\n[foo]: /url2\n\n[bar][foo]\n", "html": "

                          bar

                          \n", "example": 540, "start_line": 8147, "end_line": 8155, "section": "Links" }, { "markdown": "[bar][foo\\!]\n\n[foo!]: /url\n", "html": "

                          [bar][foo!]

                          \n", "example": 541, "start_line": 8162, "end_line": 8168, "section": "Links" }, { "markdown": "[foo][ref[]\n\n[ref[]: /uri\n", "html": "

                          [foo][ref[]

                          \n

                          [ref[]: /uri

                          \n", "example": 542, "start_line": 8174, "end_line": 8181, "section": "Links" }, { "markdown": "[foo][ref[bar]]\n\n[ref[bar]]: /uri\n", "html": "

                          [foo][ref[bar]]

                          \n

                          [ref[bar]]: /uri

                          \n", "example": 543, "start_line": 8184, "end_line": 8191, "section": "Links" }, { "markdown": "[[[foo]]]\n\n[[[foo]]]: /url\n", "html": "

                          [[[foo]]]

                          \n

                          [[[foo]]]: /url

                          \n", "example": 544, "start_line": 8194, "end_line": 8201, "section": "Links" }, { "markdown": "[foo][ref\\[]\n\n[ref\\[]: /uri\n", "html": "

                          foo

                          \n", "example": 545, "start_line": 8204, "end_line": 8210, "section": "Links" }, { "markdown": "[bar\\\\]: /uri\n\n[bar\\\\]\n", "html": "

                          bar\\

                          \n", "example": 546, "start_line": 8215, "end_line": 8221, "section": "Links" }, { "markdown": "[]\n\n[]: /uri\n", "html": "

                          []

                          \n

                          []: /uri

                          \n", "example": 547, "start_line": 8226, "end_line": 8233, "section": "Links" }, { "markdown": "[\n ]\n\n[\n ]: /uri\n", "html": "

                          [\n]

                          \n

                          [\n]: /uri

                          \n", "example": 548, "start_line": 8236, "end_line": 8247, "section": "Links" }, { "markdown": "[foo][]\n\n[foo]: /url \"title\"\n", "html": "

                          foo

                          \n", "example": 549, "start_line": 8259, "end_line": 8265, "section": "Links" }, { "markdown": "[*foo* bar][]\n\n[*foo* bar]: /url \"title\"\n", "html": "

                          foo bar

                          \n", "example": 550, "start_line": 8268, "end_line": 8274, "section": "Links" }, { "markdown": "[Foo][]\n\n[foo]: /url \"title\"\n", "html": "

                          Foo

                          \n", "example": 551, "start_line": 8279, "end_line": 8285, "section": "Links" }, { "markdown": "[foo] \n[]\n\n[foo]: /url \"title\"\n", "html": "

                          foo\n[]

                          \n", "example": 552, "start_line": 8292, "end_line": 8300, "section": "Links" }, { "markdown": "[foo]\n\n[foo]: /url \"title\"\n", "html": "

                          foo

                          \n", "example": 553, "start_line": 8312, "end_line": 8318, "section": "Links" }, { "markdown": "[*foo* bar]\n\n[*foo* bar]: /url \"title\"\n", "html": "

                          foo bar

                          \n", "example": 554, "start_line": 8321, "end_line": 8327, "section": "Links" }, { "markdown": "[[*foo* bar]]\n\n[*foo* bar]: /url \"title\"\n", "html": "

                          [foo bar]

                          \n", "example": 555, "start_line": 8330, "end_line": 8336, "section": "Links" }, { "markdown": "[[bar [foo]\n\n[foo]: /url\n", "html": "

                          [[bar foo

                          \n", "example": 556, "start_line": 8339, "end_line": 8345, "section": "Links" }, { "markdown": "[Foo]\n\n[foo]: /url \"title\"\n", "html": "

                          Foo

                          \n", "example": 557, "start_line": 8350, "end_line": 8356, "section": "Links" }, { "markdown": "[foo] bar\n\n[foo]: /url\n", "html": "

                          foo bar

                          \n", "example": 558, "start_line": 8361, "end_line": 8367, "section": "Links" }, { "markdown": "\\[foo]\n\n[foo]: /url \"title\"\n", "html": "

                          [foo]

                          \n", "example": 559, "start_line": 8373, "end_line": 8379, "section": "Links" }, { "markdown": "[foo*]: /url\n\n*[foo*]\n", "html": "

                          *foo*

                          \n", "example": 560, "start_line": 8385, "end_line": 8391, "section": "Links" }, { "markdown": "[foo][bar]\n\n[foo]: /url1\n[bar]: /url2\n", "html": "

                          foo

                          \n", "example": 561, "start_line": 8397, "end_line": 8404, "section": "Links" }, { "markdown": "[foo][]\n\n[foo]: /url1\n", "html": "

                          foo

                          \n", "example": 562, "start_line": 8406, "end_line": 8412, "section": "Links" }, { "markdown": "[foo]()\n\n[foo]: /url1\n", "html": "

                          foo

                          \n", "example": 563, "start_line": 8416, "end_line": 8422, "section": "Links" }, { "markdown": "[foo](not a link)\n\n[foo]: /url1\n", "html": "

                          foo(not a link)

                          \n", "example": 564, "start_line": 8424, "end_line": 8430, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url\n", "html": "

                          [foo]bar

                          \n", "example": 565, "start_line": 8435, "end_line": 8441, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url1\n[bar]: /url2\n", "html": "

                          foobaz

                          \n", "example": 566, "start_line": 8447, "end_line": 8454, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url1\n[foo]: /url2\n", "html": "

                          [foo]bar

                          \n", "example": 567, "start_line": 8460, "end_line": 8467, "section": "Links" }, { "markdown": "![foo](/url \"title\")\n", "html": "

                          \"foo\"

                          \n", "example": 568, "start_line": 8483, "end_line": 8487, "section": "Images" }, { "markdown": "![foo *bar*]\n\n[foo *bar*]: train.jpg \"train & tracks\"\n", "html": "

                          \"foo

                          \n", "example": 569, "start_line": 8490, "end_line": 8496, "section": "Images" }, { "markdown": "![foo ![bar](/url)](/url2)\n", "html": "

                          \"foo

                          \n", "example": 570, "start_line": 8499, "end_line": 8503, "section": "Images" }, { "markdown": "![foo [bar](/url)](/url2)\n", "html": "

                          \"foo

                          \n", "example": 571, "start_line": 8506, "end_line": 8510, "section": "Images" }, { "markdown": "![foo *bar*][]\n\n[foo *bar*]: train.jpg \"train & tracks\"\n", "html": "

                          \"foo

                          \n", "example": 572, "start_line": 8520, "end_line": 8526, "section": "Images" }, { "markdown": "![foo *bar*][foobar]\n\n[FOOBAR]: train.jpg \"train & tracks\"\n", "html": "

                          \"foo

                          \n", "example": 573, "start_line": 8529, "end_line": 8535, "section": "Images" }, { "markdown": "![foo](train.jpg)\n", "html": "

                          \"foo\"

                          \n", "example": 574, "start_line": 8538, "end_line": 8542, "section": "Images" }, { "markdown": "My ![foo bar](/path/to/train.jpg \"title\" )\n", "html": "

                          My \"foo

                          \n", "example": 575, "start_line": 8545, "end_line": 8549, "section": "Images" }, { "markdown": "![foo]()\n", "html": "

                          \"foo\"

                          \n", "example": 576, "start_line": 8552, "end_line": 8556, "section": "Images" }, { "markdown": "![](/url)\n", "html": "

                          \"\"

                          \n", "example": 577, "start_line": 8559, "end_line": 8563, "section": "Images" }, { "markdown": "![foo][bar]\n\n[bar]: /url\n", "html": "

                          \"foo\"

                          \n", "example": 578, "start_line": 8568, "end_line": 8574, "section": "Images" }, { "markdown": "![foo][bar]\n\n[BAR]: /url\n", "html": "

                          \"foo\"

                          \n", "example": 579, "start_line": 8577, "end_line": 8583, "section": "Images" }, { "markdown": "![foo][]\n\n[foo]: /url \"title\"\n", "html": "

                          \"foo\"

                          \n", "example": 580, "start_line": 8588, "end_line": 8594, "section": "Images" }, { "markdown": "![*foo* bar][]\n\n[*foo* bar]: /url \"title\"\n", "html": "

                          \"foo

                          \n", "example": 581, "start_line": 8597, "end_line": 8603, "section": "Images" }, { "markdown": "![Foo][]\n\n[foo]: /url \"title\"\n", "html": "

                          \"Foo\"

                          \n", "example": 582, "start_line": 8608, "end_line": 8614, "section": "Images" }, { "markdown": "![foo] \n[]\n\n[foo]: /url \"title\"\n", "html": "

                          \"foo\"\n[]

                          \n", "example": 583, "start_line": 8620, "end_line": 8628, "section": "Images" }, { "markdown": "![foo]\n\n[foo]: /url \"title\"\n", "html": "

                          \"foo\"

                          \n", "example": 584, "start_line": 8633, "end_line": 8639, "section": "Images" }, { "markdown": "![*foo* bar]\n\n[*foo* bar]: /url \"title\"\n", "html": "

                          \"foo

                          \n", "example": 585, "start_line": 8642, "end_line": 8648, "section": "Images" }, { "markdown": "![[foo]]\n\n[[foo]]: /url \"title\"\n", "html": "

                          ![[foo]]

                          \n

                          [[foo]]: /url "title"

                          \n", "example": 586, "start_line": 8653, "end_line": 8660, "section": "Images" }, { "markdown": "![Foo]\n\n[foo]: /url \"title\"\n", "html": "

                          \"Foo\"

                          \n", "example": 587, "start_line": 8665, "end_line": 8671, "section": "Images" }, { "markdown": "!\\[foo]\n\n[foo]: /url \"title\"\n", "html": "

                          ![foo]

                          \n", "example": 588, "start_line": 8677, "end_line": 8683, "section": "Images" }, { "markdown": "\\![foo]\n\n[foo]: /url \"title\"\n", "html": "

                          !foo

                          \n", "example": 589, "start_line": 8689, "end_line": 8695, "section": "Images" }, { "markdown": "\n", "html": "

                          http://foo.bar.baz

                          \n", "example": 590, "start_line": 8722, "end_line": 8726, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          http://foo.bar.baz/test?q=hello&id=22&boolean

                          \n", "example": 591, "start_line": 8729, "end_line": 8733, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          irc://foo.bar:2233/baz

                          \n", "example": 592, "start_line": 8736, "end_line": 8740, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          MAILTO:FOO@BAR.BAZ

                          \n", "example": 593, "start_line": 8745, "end_line": 8749, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          a+b+c:d

                          \n", "example": 594, "start_line": 8757, "end_line": 8761, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          made-up-scheme://foo,bar

                          \n", "example": 595, "start_line": 8764, "end_line": 8768, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          http://../

                          \n", "example": 596, "start_line": 8771, "end_line": 8775, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          localhost:5001/foo

                          \n", "example": 597, "start_line": 8778, "end_line": 8782, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          <http://foo.bar/baz bim>

                          \n", "example": 598, "start_line": 8787, "end_line": 8791, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          http://example.com/\\[\\

                          \n", "example": 599, "start_line": 8796, "end_line": 8800, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          foo@bar.example.com

                          \n", "example": 600, "start_line": 8818, "end_line": 8822, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          foo+special@Bar.baz-bar0.com

                          \n", "example": 601, "start_line": 8825, "end_line": 8829, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          <foo+@bar.example.com>

                          \n", "example": 602, "start_line": 8834, "end_line": 8838, "section": "Autolinks" }, { "markdown": "<>\n", "html": "

                          <>

                          \n", "example": 603, "start_line": 8843, "end_line": 8847, "section": "Autolinks" }, { "markdown": "< http://foo.bar >\n", "html": "

                          < http://foo.bar >

                          \n", "example": 604, "start_line": 8850, "end_line": 8854, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          <m:abc>

                          \n", "example": 605, "start_line": 8857, "end_line": 8861, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          <foo.bar.baz>

                          \n", "example": 606, "start_line": 8864, "end_line": 8868, "section": "Autolinks" }, { "markdown": "http://example.com\n", "html": "

                          http://example.com

                          \n", "example": 607, "start_line": 8871, "end_line": 8875, "section": "Autolinks" }, { "markdown": "foo@bar.example.com\n", "html": "

                          foo@bar.example.com

                          \n", "example": 608, "start_line": 8878, "end_line": 8882, "section": "Autolinks" }, { "markdown": "\n", "html": "

                          \n", "example": 609, "start_line": 8960, "end_line": 8964, "section": "Raw HTML" }, { "markdown": "\n", "html": "

                          \n", "example": 610, "start_line": 8969, "end_line": 8973, "section": "Raw HTML" }, { "markdown": "\n", "html": "

                          \n", "example": 611, "start_line": 8978, "end_line": 8984, "section": "Raw HTML" }, { "markdown": "\n", "html": "

                          \n", "example": 612, "start_line": 8989, "end_line": 8995, "section": "Raw HTML" }, { "markdown": "Foo \n", "html": "

                          Foo

                          \n", "example": 613, "start_line": 9000, "end_line": 9004, "section": "Raw HTML" }, { "markdown": "<33> <__>\n", "html": "

                          <33> <__>

                          \n", "example": 614, "start_line": 9009, "end_line": 9013, "section": "Raw HTML" }, { "markdown": "
                          \n", "html": "

                          <a h*#ref="hi">

                          \n", "example": 615, "start_line": 9018, "end_line": 9022, "section": "Raw HTML" }, { "markdown": "
                          \n", "html": "

                          <a href="hi'> <a href=hi'>

                          \n", "example": 616, "start_line": 9027, "end_line": 9031, "section": "Raw HTML" }, { "markdown": "< a><\nfoo>\n\n", "html": "

                          < a><\nfoo><bar/ >\n<foo bar=baz\nbim!bop />

                          \n", "example": 617, "start_line": 9036, "end_line": 9046, "section": "Raw HTML" }, { "markdown": "
                          \n", "html": "

                          <a href='bar'title=title>

                          \n", "example": 618, "start_line": 9051, "end_line": 9055, "section": "Raw HTML" }, { "markdown": "
                          \n", "html": "

                          \n", "example": 619, "start_line": 9060, "end_line": 9064, "section": "Raw HTML" }, { "markdown": "\n", "html": "

                          </a href="foo">

                          \n", "example": 620, "start_line": 9069, "end_line": 9073, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "

                          foo

                          \n", "example": 621, "start_line": 9078, "end_line": 9084, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "

                          foo <!-- not a comment -- two hyphens -->

                          \n", "example": 622, "start_line": 9087, "end_line": 9091, "section": "Raw HTML" }, { "markdown": "foo foo -->\n\nfoo \n", "html": "

                          foo <!--> foo -->

                          \n

                          foo <!-- foo--->

                          \n", "example": 623, "start_line": 9096, "end_line": 9103, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "

                          foo

                          \n", "example": 624, "start_line": 9108, "end_line": 9112, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "

                          foo

                          \n", "example": 625, "start_line": 9117, "end_line": 9121, "section": "Raw HTML" }, { "markdown": "foo &<]]>\n", "html": "

                          foo &<]]>

                          \n", "example": 626, "start_line": 9126, "end_line": 9130, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "

                          foo

                          \n", "example": 627, "start_line": 9136, "end_line": 9140, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "

                          foo

                          \n", "example": 628, "start_line": 9145, "end_line": 9149, "section": "Raw HTML" }, { "markdown": "\n", "html": "

                          <a href=""">

                          \n", "example": 629, "start_line": 9152, "end_line": 9156, "section": "Raw HTML" }, { "markdown": "foo \nbaz\n", "html": "

                          foo
                          \nbaz

                          \n", "example": 630, "start_line": 9166, "end_line": 9172, "section": "Hard line breaks" }, { "markdown": "foo\\\nbaz\n", "html": "

                          foo
                          \nbaz

                          \n", "example": 631, "start_line": 9178, "end_line": 9184, "section": "Hard line breaks" }, { "markdown": "foo \nbaz\n", "html": "

                          foo
                          \nbaz

                          \n", "example": 632, "start_line": 9189, "end_line": 9195, "section": "Hard line breaks" }, { "markdown": "foo \n bar\n", "html": "

                          foo
                          \nbar

                          \n", "example": 633, "start_line": 9200, "end_line": 9206, "section": "Hard line breaks" }, { "markdown": "foo\\\n bar\n", "html": "

                          foo
                          \nbar

                          \n", "example": 634, "start_line": 9209, "end_line": 9215, "section": "Hard line breaks" }, { "markdown": "*foo \nbar*\n", "html": "

                          foo
                          \nbar

                          \n", "example": 635, "start_line": 9221, "end_line": 9227, "section": "Hard line breaks" }, { "markdown": "*foo\\\nbar*\n", "html": "

                          foo
                          \nbar

                          \n", "example": 636, "start_line": 9230, "end_line": 9236, "section": "Hard line breaks" }, { "markdown": "`code \nspan`\n", "html": "

                          code span

                          \n", "example": 637, "start_line": 9241, "end_line": 9246, "section": "Hard line breaks" }, { "markdown": "`code\\\nspan`\n", "html": "

                          code\\ span

                          \n", "example": 638, "start_line": 9249, "end_line": 9254, "section": "Hard line breaks" }, { "markdown": "
                          \n", "html": "

                          \n", "example": 639, "start_line": 9259, "end_line": 9265, "section": "Hard line breaks" }, { "markdown": "\n", "html": "

                          \n", "example": 640, "start_line": 9268, "end_line": 9274, "section": "Hard line breaks" }, { "markdown": "foo\\\n", "html": "

                          foo\\

                          \n", "example": 641, "start_line": 9281, "end_line": 9285, "section": "Hard line breaks" }, { "markdown": "foo \n", "html": "

                          foo

                          \n", "example": 642, "start_line": 9288, "end_line": 9292, "section": "Hard line breaks" }, { "markdown": "### foo\\\n", "html": "

                          foo\\

                          \n", "example": 643, "start_line": 9295, "end_line": 9299, "section": "Hard line breaks" }, { "markdown": "### foo \n", "html": "

                          foo

                          \n", "example": 644, "start_line": 9302, "end_line": 9306, "section": "Hard line breaks" }, { "markdown": "foo\nbaz\n", "html": "

                          foo\nbaz

                          \n", "example": 645, "start_line": 9317, "end_line": 9323, "section": "Soft line breaks" }, { "markdown": "foo \n baz\n", "html": "

                          foo\nbaz

                          \n", "example": 646, "start_line": 9329, "end_line": 9335, "section": "Soft line breaks" }, { "markdown": "hello $.;'there\n", "html": "

                          hello $.;'there

                          \n", "example": 647, "start_line": 9349, "end_line": 9353, "section": "Textual content" }, { "markdown": "Foo χρῆν\n", "html": "

                          Foo χρῆν

                          \n", "example": 648, "start_line": 9356, "end_line": 9360, "section": "Textual content" }, { "markdown": "Multiple spaces\n", "html": "

                          Multiple spaces

                          \n", "example": 649, "start_line": 9365, "end_line": 9369, "section": "Textual content" } ] ================================================ FILE: libs/toastmark/src/commonmark/__test__/base-examples.spec.ts ================================================ import { Parser } from '../blocks'; import { Renderer } from '../../html/renderer'; import specs from './base-examples.json'; const reader = new Parser({ referenceDefinition: true }); const renderer = new Renderer(); specs.forEach((spec) => { const { example, section, markdown, html } = spec; it(`Example ${example} (${section})`, () => { const parsed = reader.parse(markdown); const result = renderer.render(parsed); expect(result).toBe(html); }); }); ================================================ FILE: libs/toastmark/src/commonmark/__test__/helper.spec.ts ================================================ import { Node, BlockNode, createNode } from '../node'; export function pos(line1: number, col1: number, line2: number, col2: number) { return [ [line1, col1], [line2, col2], ]; } export function convertToArrayTree(root: BlockNode, attrs: (keyof BlockNode)[]) { function recur(node: Node) { const newNode: any = {}; attrs.forEach((attr) => { const attrVal = node[attr as keyof Node]; if (attrVal !== undefined && attrVal !== null) { newNode[attr] = attrVal; } }); let child = node.firstChild; if (child) { newNode.children = []; } while (child) { newNode.children.push(recur(child)); child = child.next; } return newNode; } return recur(root); } it('convert', () => { const list = createNode('list', [ [1, 1], [2, 10], ]); const listItem1 = createNode('item', [ [1, 1], [1, 10], ]); const listItem2 = createNode('item', [ [2, 1], [2, 10], ]); const para = createNode('paragraph', [ [2, 1], [2, 10], ]); const emph = createNode('emph', [ [2, 1], [2, 5], ]); const text = createNode('text', [ [2, 6], [2, 10], ]); list.appendChild(listItem1); list.appendChild(listItem2); listItem2.appendChild(para); para.appendChild(emph); para.appendChild(text); const attrs: (keyof Node)[] = ['type', 'sourcepos']; expect(convertToArrayTree(list, attrs)).toEqual({ type: 'list', sourcepos: [ [1, 1], [2, 10], ], children: [ { type: 'item', sourcepos: [ [1, 1], [1, 10], ], }, { type: 'item', sourcepos: [ [2, 1], [2, 10], ], children: [ { type: 'paragraph', sourcepos: [ [2, 1], [2, 10], ], children: [ { type: 'emph', sourcepos: [ [2, 1], [2, 5], ], }, { type: 'text', sourcepos: [ [2, 6], [2, 10], ], }, ], }, ], }, ], }); }); ================================================ FILE: libs/toastmark/src/commonmark/__test__/options.spec.ts ================================================ import { CustomParserMap } from '@t/parser'; import { Parser } from '../blocks'; import { pos } from './helper.spec'; import { Node } from '../node'; it('tags in disallowedHtmlBlockTags should not be parsed as a HTML block', () => { const reader = new Parser({ disallowedHtmlBlockTags: ['br', 'span'] }); const root = reader.parse('
                          \nHello\n\n\nWorld'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'paragraph', firstChild: { type: 'htmlInline', literal: '
                          ', next: { type: 'softbreak', next: { type: 'text', literal: 'Hello', }, }, }, next: { type: 'paragraph', firstChild: { type: 'htmlInline', literal: '', next: { type: 'softbreak', next: { type: 'text', literal: 'World', }, }, }, }, }, }); }); describe('disallowDeepHeading: true', () => { it('the nested seTextHeading is disallowed in list', () => { const reader = new Parser({ disallowDeepHeading: true }); const root = reader.parse('- item1\n\t-'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'list', firstChild: { type: 'item', listData: { bulletChar: '-', markerOffset: 0, padding: 2, start: 0, type: 'bullet', }, firstChild: { type: 'paragraph', sourcepos: pos(1, 3, 2, 2), firstChild: { type: 'text', literal: 'item1', next: { type: 'softbreak', next: { type: 'text', literal: '-', sourcepos: pos(2, 2, 2, 2), }, }, }, }, }, }, }); }); it('the nested atxHeading is disallowed in list', () => { const reader = new Parser({ disallowDeepHeading: true }); const root = reader.parse('- # item1'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'list', firstChild: { type: 'item', listData: { bulletChar: '-', markerOffset: 0, padding: 2, start: 0, type: 'bullet', }, firstChild: { type: 'paragraph', sourcepos: pos(1, 3, 1, 9), firstChild: { type: 'text', literal: '# item1', sourcepos: pos(1, 3, 1, 9), }, }, }, }, }); }); it('the nested seTextHeading is disallowed in blockquote', () => { const reader = new Parser({ disallowDeepHeading: true }); const root = reader.parse('> item1\n> -'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'blockQuote', sourcepos: pos(1, 1, 2, 3), firstChild: { type: 'paragraph', sourcepos: pos(1, 3, 2, 3), firstChild: { type: 'text', literal: 'item1', sourcepos: pos(1, 3, 1, 7), next: { type: 'softbreak', next: { type: 'text', literal: '-', sourcepos: pos(2, 3, 2, 3), }, }, }, }, }, }); }); it('the nested atxHeading is disallowed in blockquote', () => { const reader = new Parser({ disallowDeepHeading: true }); const root = reader.parse('> # item1'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'blockQuote', sourcepos: pos(1, 1, 1, 9), firstChild: { type: 'paragraph', sourcepos: pos(1, 3, 1, 9), firstChild: { type: 'text', literal: '# item1', sourcepos: pos(1, 3, 1, 9), }, }, }, }); }); }); it('should apply the custom parser', () => { let inEmph = false; const customParser: CustomParserMap = { emph(node: Node, { entering }) { inEmph = entering; while (node.firstChild) { node.insertBefore(node.firstChild); } node.unlink(); }, text(node: Node) { if (inEmph) { node.literal = node.literal!.toUpperCase(); } }, }; const reader = new Parser({ customParser }); const root = reader.parse('*test*'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'paragraph', sourcepos: pos(1, 1, 1, 6), firstChild: { type: 'text', sourcepos: pos(1, 2, 1, 5), literal: 'TEST', }, }, }); }); ================================================ FILE: libs/toastmark/src/commonmark/__test__/sourcepos.spec.ts ================================================ import { Parser } from '../blocks'; import { Node, CodeNode } from '../node'; let reader = new Parser(); describe('paragraph', () => { it('simple text', () => { const root = reader.parse('Hello World'); const text = root.firstChild!.firstChild!; expect(text.sourcepos).toEqual([ [1, 1], [1, 11], ]); }); it('simple delimiter text', () => { const root = reader.parse(' { const root = reader.parse(' Hello \n World'); const text1 = root.firstChild!.firstChild!; const linebreak = text1.next!; const text2 = linebreak.next!; expect(text1.sourcepos).toEqual([ [1, 3], [1, 7], ]); expect(linebreak.sourcepos).toEqual([ [1, 8], [1, 10], ]); // preceeding whitespaces are not included in text node expect(text2.sourcepos).toEqual([ [2, 3], [2, 7], ]); }); it('text and emphasis', () => { const root = reader.parse('Hello *World*'); const text = root.firstChild!.firstChild!; const emph = text.next as Node; const emphText = emph.firstChild!; expect(text.sourcepos).toEqual([ [1, 1], [1, 6], ]); expect(emph.sourcepos).toEqual([ [1, 7], [1, 13], ]); expect(emphText.sourcepos).toEqual([ [1, 8], [1, 12], ]); }); it('text and strong emphasis', () => { const root = reader.parse('Hello **World**'); const text = root.firstChild!.firstChild!; const strong = text.next!; const strongText = strong.firstChild!; expect(text.sourcepos).toEqual([ [1, 1], [1, 6], ]); expect(strong.sourcepos).toEqual([ [1, 7], [1, 15], ]); expect(strongText.sourcepos).toEqual([ [1, 9], [1, 13], ]); }); it('text and image', () => { const root = reader.parse('Hello ![World](http://nhn.com)'); const text = root.firstChild!.firstChild!; const image = text.next!; const imageText = image.firstChild!; expect(text.sourcepos).toEqual([ [1, 1], [1, 6], ]); expect(image.sourcepos).toEqual([ [1, 7], [1, 30], ]); expect(imageText.sourcepos).toEqual([ [1, 9], [1, 13], ]); }); it('text with ampersand code ', () => { const root = reader.parse('[ Hello ]'); const text = root.firstChild!.firstChild!; expect(text.sourcepos).toEqual([ [1, 1], [1, 17], ]); }); it('text and link', () => { const root = reader.parse('Hello [World](http://nhn.com)'); const text = root.firstChild!.firstChild!; const link = text.next!; const linkText = link.firstChild!; expect(text.sourcepos).toEqual([ [1, 1], [1, 6], ]); expect(link.sourcepos).toEqual([ [1, 7], [1, 29], ]); expect(linkText.sourcepos).toEqual([ [1, 8], [1, 12], ]); }); it('text and codespan', () => { const root = reader.parse('Hello ``World``'); const text = root.firstChild!.firstChild!; const code = text.next as CodeNode; expect(text.sourcepos).toEqual([ [1, 1], [1, 6], ]); expect(code.tickCount).toBe(2); expect(code.sourcepos).toEqual([ [1, 7], [1, 15], ]); }); it('text and raw html', () => { const root = reader.parse('Hello World'); const text1 = root.firstChild!.firstChild!; const html1 = text1.next!; const text2 = html1.next!; const html2 = text2.next!; expect(text1.sourcepos).toEqual([ [1, 1], [1, 6], ]); expect(html1.sourcepos).toEqual([ [1, 7], [1, 14], ]); expect(text2.sourcepos).toEqual([ [1, 15], [1, 19], ]); expect(html2.sourcepos).toEqual([ [1, 20], [1, 28], ]); }); it('autolink', () => { const root = reader.parse('Hello '); const link = root.firstChild!.firstChild!.next!; const linkText = link.firstChild!; expect(link.sourcepos).toEqual([ [1, 7], [1, 22], ]); expect(linkText.sourcepos).toEqual([ [1, 8], [1, 21], ]); }); it('autolink (mailto)', () => { const root = reader.parse('Hello '); const link = root.firstChild!.firstChild!.next!; const linkText = link.firstChild!; expect(link.sourcepos).toEqual([ [1, 7], [1, 21], ]); expect(linkText.sourcepos).toEqual([ [1, 8], [1, 20], ]); }); }); describe('softbreak and linebreak', () => { it('text with softbreak', () => { const root = reader.parse('Hello\nWorld'); const text1 = root.firstChild!.firstChild!; const softbreak = text1.next!; const text2 = softbreak.next!; expect(text1.sourcepos).toEqual([ [1, 1], [1, 5], ]); expect(softbreak.type).toBe('softbreak'); expect(softbreak.sourcepos).toEqual([ [1, 6], [1, 6], ]); expect(text2.sourcepos).toEqual([ [2, 1], [2, 5], ]); }); it('text with linebreak(space)', () => { const root = reader.parse('Hello \nWorld'); const text1 = root.firstChild!.firstChild!; const linebreak = text1.next!; const text2 = linebreak.next!; // trailing spaces are not included in text node expect(text1.sourcepos).toEqual([ [1, 1], [1, 5], ]); // preceeding spaces are included in linebreak node expect(linebreak.sourcepos).toEqual([ [1, 6], [1, 9], ]); expect(text2.sourcepos).toEqual([ [2, 1], [2, 5], ]); }); it('text with linebreak(backslash)', () => { const root = reader.parse('Hello\\\nWorld'); const text1 = root.firstChild!.firstChild!; const linebreak = text1.next!; const text2 = linebreak.next!; expect(text1.sourcepos).toEqual([ [1, 1], [1, 5], ]); // preceeding backslash is included in linebreak node expect(linebreak.sourcepos).toEqual([ [1, 6], [1, 7], ]); expect(text2.sourcepos).toEqual([ [2, 1], [2, 5], ]); }); }); describe('atx header', () => { it('text and emphasis', () => { const root = reader.parse('# Hello *World*'); const text = root.firstChild!.firstChild!; const emph = text.next!; const emphText = emph.firstChild!; expect(text.sourcepos).toEqual([ [1, 3], [1, 8], ]); expect(emph.sourcepos).toEqual([ [1, 9], [1, 15], ]); expect(emphText.sourcepos).toEqual([ [1, 10], [1, 14], ]); }); it('text and emphasis (header level 3)', () => { const root = reader.parse('### Hello *World*'); const text = root.firstChild!.firstChild!; const emph = text.next!; const emphText = emph.firstChild!; expect(text.sourcepos).toEqual([ [1, 5], [1, 10], ]); expect(emph.sourcepos).toEqual([ [1, 11], [1, 17], ]); expect(emphText.sourcepos).toEqual([ [1, 12], [1, 16], ]); }); }); describe('list items', () => { it('with columns', () => { const root = reader.parse(' - Hello\n\n World'); const listItem = root.firstChild!.firstChild!; const para1 = listItem.firstChild!; const para1Text = para1.firstChild!; const para2 = para1.next!; const para2Text = para2.firstChild!; expect(para1Text.sourcepos).toEqual([ [1, 6], [1, 10], ]); expect(para2Text.sourcepos).toEqual([ [3, 6], [3, 10], ]); }); }); describe('block quote', () => { it('nested paragraph', () => { const root = reader.parse('> Hello\n> > World'); const quote1 = root.firstChild!; const para1 = quote1.firstChild!; const quote2 = para1.next!; const para2 = quote2.firstChild!; expect(para1.firstChild!.sourcepos).toEqual([ [1, 3], [1, 7], ]); expect(para2.firstChild!.sourcepos).toEqual([ [2, 5], [2, 9], ]); }); }); describe('code block', () => { it('empty line', () => { const root = reader.parse('```\n\n```\nHello'); const codeblock = root.firstChild!; const para = codeblock.next!; expect(codeblock.sourcepos).toEqual([ [1, 1], [3, 3], ]); expect(para.sourcepos).toEqual([ [4, 1], [4, 5], ]); }); }); describe('inlline code', () => { it('multi line', () => { const root = reader.parse('`a\n b\n c`\n d'); const para = root.firstChild!; const code = para.firstChild!; const linebreak = code.next!; const text = linebreak.next!; expect(code.sourcepos).toEqual([ [1, 1], [3, 5], ]); expect(linebreak.sourcepos).toEqual([ [3, 6], [3, 6], ]); expect(text.sourcepos).toEqual([ [4, 2], [4, 2], ]); }); }); describe('merge text nodes', () => { it('tokens', () => { const root = reader.parse(['\\ Text *', '[ Text !', '![ Text ]'].join('\n')); const text1 = root.firstChild!.firstChild!; const text2 = text1.next!.next!; const text3 = text2.next!.next!; expect(text1.literal).toBe('\\ Text *'); expect(text1.sourcepos).toEqual([ [1, 1], [1, 8], ]); expect(text2.literal).toBe('[ Text !'); expect(text2.sourcepos).toEqual([ [2, 1], [2, 8], ]); expect(text3.literal).toBe('![ Text ]'); expect(text3.sourcepos).toEqual([ [3, 1], [3, 9], ]); }); }); describe('reference link definition', () => { reader = new Parser({ referenceDefinition: true }); afterAll(() => { reader = new Parser(); }); it('single line without title', () => { const root = reader.parse('[foo]: test'); const refDef = root.firstChild!; expect(refDef.sourcepos).toEqual([ [1, 1], [1, 11], ]); }); it('single line with title', () => { const root = reader.parse('[foo]: test "title"'); const refDef = root.firstChild!; expect(refDef.sourcepos).toEqual([ [1, 1], [1, 19], ]); }); it('multi line without title', () => { const root = reader.parse('[foo]:\n test'); const refDef = root.firstChild!; expect(refDef.sourcepos).toEqual([ [1, 1], [2, 4], ]); }); it('multi line with title', () => { const root = reader.parse('[foo]:\n test "title"'); const refDef = root.firstChild!; expect(refDef.sourcepos).toEqual([ [1, 1], [2, 12], ]); }); it('multi line title which has multi line', () => { const root = reader.parse('[foo]:\n test "\n tit \n l \n e"'); const refDef = root.firstChild!; expect(refDef.sourcepos).toEqual([ [1, 1], [5, 2], ]); }); }); ================================================ FILE: libs/toastmark/src/commonmark/__test__/syntax-info.spec.ts ================================================ import { Parser } from '../blocks'; import { HeadingNode, CodeBlockNode } from '../node'; const parser = new Parser(); describe('headingType ', () => { it('atx heading', () => { const root = parser.parse('# Heading'); const heading = root.firstChild as HeadingNode; expect(heading.headingType).toBe('atx'); }); it('setext heading', () => { const root = parser.parse('Heading\n----'); const heading = root.firstChild as HeadingNode; expect(heading.headingType).toBe('setext'); }); }); describe('CodeBlockNode', () => { it('infoPadding is none', () => { const root = parser.parse('```js'); const codeBlock = root.firstChild as CodeBlockNode; expect(codeBlock.infoPadding).toBe(0); }); it('infoPadding is more than zero', () => { const root = parser.parse('``` js'); const codeBlock = root.firstChild as CodeBlockNode; expect(codeBlock.infoPadding).toBe(3); }); it('info string', () => { const root = parser.parse('``` javascript '); const codeBlock = root.firstChild as CodeBlockNode; expect(codeBlock.info).toBe('javascript'); }); }); ================================================ FILE: libs/toastmark/src/commonmark/blockHandlers.ts ================================================ import { Parser } from './blocks'; import { taskListItemFinalize } from './gfm/taskListItem'; import { table, tableHead, tableBody, tableRow, tableCell, tableDelimRow, tableDelimCell, } from './gfm/tableBlockHandler'; import { customBlock } from './custom/customBlockHandler'; import { ListNode, BlockNode, CodeBlockNode, HtmlBlockNode } from './node'; import { peek, isBlank, isSpaceOrTab, endsWithBlankLine, reClosingCodeFence, CODE_INDENT, C_OPEN_BRACKET, C_GREATERTHAN, } from './blockHelper'; import { unescapeString } from './common'; export const enum Process { Go = 0, Stop = 1, Finished = 2, } // 'finalize' is run when the block is closed. // 'continue' is run to check whether the block is continuing // at a certain line and offset (e.g. whether a block quote // contains a `>`. It returns 0 for matched, 1 for not matched, // and 2 for "we've dealt with this line completely, go to next." export interface BlockHandler { continue(parser: Parser, container: BlockNode): Process; finalize(parser: Parser, block: BlockNode): void; canContain(type: string): boolean; acceptsLines: boolean; } const noop: BlockHandler = { continue() { return Process.Stop; }, finalize() {}, canContain() { return false; }, acceptsLines: true, }; const document: BlockHandler = { continue() { return Process.Go; }, finalize() {}, canContain(t) { return t !== 'item'; }, acceptsLines: false, }; const list: BlockHandler = { continue() { return Process.Go; }, finalize(_, block: ListNode) { let item = block.firstChild as BlockNode; while (item) { // check for non-final list item ending with blank line: if (endsWithBlankLine(item) && item.next) { block.listData!.tight = false; break; } // recurse into children of list item, to see if there are // spaces between any of them: let subitem = item.firstChild as BlockNode; while (subitem) { if (endsWithBlankLine(subitem) && (item.next || subitem.next)) { block.listData!.tight = false; break; } subitem = subitem.next as BlockNode; } item = item.next as BlockNode; } }, canContain(t) { return t === 'item'; }, acceptsLines: false, }; const blockQuote: BlockHandler = { continue(parser) { const ln = parser.currentLine; if (!parser.indented && peek(ln, parser.nextNonspace) === C_GREATERTHAN) { parser.advanceNextNonspace(); parser.advanceOffset(1, false); if (isSpaceOrTab(peek(ln, parser.offset))) { parser.advanceOffset(1, true); } } else { return Process.Stop; } return Process.Go; }, finalize() {}, canContain(t) { return t !== 'item'; }, acceptsLines: false, }; const item: BlockHandler = { continue(parser, container: ListNode) { if (parser.blank) { if (container.firstChild === null) { // Blank line after empty list item return Process.Stop; } parser.advanceNextNonspace(); } else if (parser.indent >= container.listData!.markerOffset + container.listData!.padding) { parser.advanceOffset(container.listData!.markerOffset + container.listData!.padding, true); } else { return Process.Stop; } return Process.Go; }, finalize: taskListItemFinalize, canContain(t) { return t !== 'item'; }, acceptsLines: false, }; const heading: BlockHandler = { continue() { // a heading can never container > 1 line, so fail to match: return Process.Stop; }, finalize() {}, canContain() { return false; }, acceptsLines: false, }; const thematicBreak: BlockHandler = { continue() { // a thematic break can never container > 1 line, so fail to match: return Process.Stop; }, finalize() {}, canContain() { return false; }, acceptsLines: false, }; const codeBlock: BlockHandler = { continue(parser, container: CodeBlockNode) { const ln = parser.currentLine; const indent = parser.indent; if (container.isFenced) { // fenced const match = indent <= 3 && ln.charAt(parser.nextNonspace) === container.fenceChar && ln.slice(parser.nextNonspace).match(reClosingCodeFence); if (match && match[0].length >= container.fenceLength) { // closing fence - we're at end of line, so we can return parser.lastLineLength = parser.offset + indent + match[0].length; parser.finalize(container as BlockNode, parser.lineNumber); return Process.Finished; } // skip optional spaces of fence offset let i = container.fenceOffset; while (i > 0 && isSpaceOrTab(peek(ln, parser.offset))) { parser.advanceOffset(1, true); i--; } } else { // indented if (indent >= CODE_INDENT) { parser.advanceOffset(CODE_INDENT, true); } else if (parser.blank) { parser.advanceNextNonspace(); } else { return Process.Stop; } } return Process.Go; }, finalize(_, block: CodeBlockNode) { if (block.stringContent === null) { return; } if (block.isFenced) { // fenced // first line becomes info string const content = block.stringContent; const newlinePos = content.indexOf('\n'); const firstLine = content.slice(0, newlinePos); const rest = content.slice(newlinePos + 1); const infoString = firstLine.match(/^(\s*)(.*)/); block.infoPadding = infoString![1].length; block.info = unescapeString(infoString![2].trim()); block.literal = rest; } else { // indented block.literal = block.stringContent?.replace(/(\n *)+$/, '\n'); } block.stringContent = null; // allow GC }, canContain() { return false; }, acceptsLines: true, }; const htmlBlock: BlockHandler = { continue(parser, container: HtmlBlockNode) { return parser.blank && (container.htmlBlockType === 6 || container.htmlBlockType === 7) ? Process.Stop : Process.Go; }, finalize(_, block) { block.literal = block.stringContent?.replace(/(\n *)+$/, '') || null; block.stringContent = null; // allow GC }, canContain() { return false; }, acceptsLines: true, }; const paragraph: BlockHandler = { continue(parser) { return parser.blank ? Process.Stop : Process.Go; }, finalize(parser, block) { if (block.stringContent === null) { return; } let pos: number; let hasReferenceDefs = false; // try parsing the beginning as link reference definitions: while ( peek(block.stringContent, 0) === C_OPEN_BRACKET && (pos = parser.inlineParser.parseReference(block, parser.refMap)) ) { block.stringContent = block.stringContent.slice(pos); hasReferenceDefs = true; } if (hasReferenceDefs && isBlank(block.stringContent)) { block.unlink(); } }, canContain() { return false; }, acceptsLines: true, }; const refDef = noop; const frontMatter = noop; export const blockHandlers = { document, list, blockQuote, item, heading, thematicBreak, codeBlock, htmlBlock, paragraph, table, tableBody, tableHead, tableRow, tableCell, tableDelimRow, tableDelimCell, refDef, customBlock, frontMatter, }; ================================================ FILE: libs/toastmark/src/commonmark/blockHelper.ts ================================================ import { BlockNode } from './node'; export const CODE_INDENT = 4; export const C_TAB = 9; export const C_NEWLINE = 10; export const C_GREATERTHAN = 62; export const C_LESSTHAN = 60; export const C_SPACE = 32; export const C_OPEN_BRACKET = 91; export const reNonSpace = /[^ \t\f\v\r\n]/; export const reClosingCodeFence = /^(?:`{3,}|~{3,})(?= *$)/; // Returns true if block ends with a blank line, descending if needed // into lists and sublists. export function endsWithBlankLine(block: BlockNode) { let curBlock: BlockNode | null = block; while (curBlock) { if (curBlock.lastLineBlank) { return true; } const t = curBlock.type; if (!curBlock.lastLineChecked && (t === 'list' || t === 'item')) { curBlock.lastLineChecked = true; curBlock = curBlock.lastChild as BlockNode; } else { curBlock.lastLineChecked = true; break; } } return false; } export function peek(ln: string, pos: number) { if (pos < ln.length) { return ln.charCodeAt(pos); } return -1; } // Returns true if string contains only space characters. export function isBlank(s: string) { return !reNonSpace.test(s); } export function isSpaceOrTab(c: number) { return c === C_SPACE || c === C_TAB; } ================================================ FILE: libs/toastmark/src/commonmark/blockStarts.ts ================================================ import { ListData } from '@t/node'; import { ListNode, HtmlBlockNode, HeadingNode, CodeBlockNode, createNode, BlockNode } from './node'; import { OPENTAG, CLOSETAG } from './rawHtml'; import { peek, isSpaceOrTab, reNonSpace, CODE_INDENT, C_OPEN_BRACKET, C_GREATERTHAN, C_LESSTHAN, C_TAB, C_SPACE, } from './blockHelper'; import { Parser } from './blocks'; import { tableHead, tableBody } from './gfm/tableBlockStart'; import { customBlock } from './custom/customBlockStart'; export const enum Matched { None = 0, // No Match Container, // Keep Going Leaf, // No more block starts } export interface BlockStart { (parser: Parser, container: BlockNode): Matched; } const reCodeFence = /^`{3,}(?!.*`)|^~{3,}/; const reHtmlBlockOpen = [ /./, // dummy for 0 /^<(?:script|pre|style)(?:\s|>|$)/i, /^/, /\?>/, />/, /\]\]>/, ]; const reMaybeSpecial = /^[#`~*+_=<>0-9-;$]/; const reLineEnding = /\r\n|\n|\r/; function document() { return createNode('document', [ [1, 1], [0, 0], ]); } const defaultOptions = { smart: false, tagFilter: false, extendedAutolinks: false, disallowedHtmlBlockTags: [], referenceDefinition: false, disallowDeepHeading: false, customParser: null, frontMatter: false, }; export class Parser implements BlockParser { public doc: BlockNode; public tip: BlockNode; public oldtip: BlockNode; public currentLine: string; public lineNumber: number; public offset: number; public column: number; public nextNonspace: number; public nextNonspaceColumn: number; public indent: number; public indented: boolean; public blank: boolean; private partiallyConsumedTab: boolean; private allClosed: boolean; private lastMatchedContainer: Node; public refMap: RefMap; public refLinkCandidateMap: RefLinkCandidateMap; public refDefCandidateMap: RefDefCandidateMap; public lastLineLength: number; public inlineParser: InlineParser; public options: ParserOptions; public lines: string[]; constructor(options?: Partial) { this.options = { ...defaultOptions, ...options }; this.doc = document(); this.tip = this.doc; this.oldtip = this.doc; this.lineNumber = 0; this.offset = 0; this.column = 0; this.nextNonspace = 0; this.nextNonspaceColumn = 0; this.indent = 0; this.currentLine = ''; this.indented = false; this.blank = false; this.partiallyConsumedTab = false; this.allClosed = true; this.lastMatchedContainer = this.doc; this.refMap = {}; this.refLinkCandidateMap = {}; this.refDefCandidateMap = {}; this.lastLineLength = 0; this.lines = []; if (this.options.frontMatter) { blockHandlers.frontMatter = frontMatterHandler; blockStarts.unshift(frontMatterStart); } this.inlineParser = new InlineParser(this.options); } advanceOffset(count: number, columns = false) { const currentLine = this.currentLine; let charsToTab: number, charsToAdvance: number; let c: string; while (count > 0 && (c = currentLine[this.offset])) { if (c === '\t') { charsToTab = 4 - (this.column % 4); if (columns) { this.partiallyConsumedTab = charsToTab > count; charsToAdvance = charsToTab > count ? count : charsToTab; this.column += charsToAdvance; this.offset += this.partiallyConsumedTab ? 0 : 1; count -= charsToAdvance; } else { this.partiallyConsumedTab = false; this.column += charsToTab; this.offset += 1; count -= 1; } } else { this.partiallyConsumedTab = false; this.offset += 1; this.column += 1; // assume ascii; block starts are ascii count -= 1; } } } advanceNextNonspace() { this.offset = this.nextNonspace; this.column = this.nextNonspaceColumn; this.partiallyConsumedTab = false; } findNextNonspace() { const currentLine = this.currentLine; let i = this.offset; let cols = this.column; let c: string; while ((c = currentLine.charAt(i)) !== '') { if (c === ' ') { i++; cols++; } else if (c === '\t') { i++; cols += 4 - (cols % 4); } else { break; } } this.blank = c === '\n' || c === '\r' || c === ''; this.nextNonspace = i; this.nextNonspaceColumn = cols; this.indent = this.nextNonspaceColumn - this.column; this.indented = this.indent >= CODE_INDENT; } // Add a line to the block at the tip. We assume the tip // can accept lines -- that check should be done before calling this. addLine() { if (this.partiallyConsumedTab) { this.offset += 1; // skip over tab // add space characters: const charsToTab = 4 - (this.column % 4); this.tip.stringContent += repeat(' ', charsToTab); } if (this.tip.lineOffsets) { this.tip.lineOffsets.push(this.offset); } else { this.tip.lineOffsets = [this.offset]; } this.tip.stringContent += `${this.currentLine.slice(this.offset)}\n`; } // Add block of type tag as a child of the tip. If the tip can't // accept children, close and finalize it and try its parent, // and so on til we find a block that can accept children. addChild(tag: BlockNodeType, offset: number) { while (!blockHandlers[this.tip.type].canContain(tag)) { this.finalize(this.tip, this.lineNumber - 1); } const columnNumber = offset + 1; // offset 0 = column 1 const newBlock = createNode(tag, [ [this.lineNumber, columnNumber], [0, 0], ]); newBlock.stringContent = ''; this.tip.appendChild(newBlock); this.tip = newBlock; return newBlock; } // Finalize and close any unmatched blocks. closeUnmatchedBlocks() { if (!this.allClosed) { // finalize any blocks not matched while (this.oldtip !== this.lastMatchedContainer) { const parent = this.oldtip.parent as BlockNode; this.finalize(this.oldtip, this.lineNumber - 1); this.oldtip = parent!; } this.allClosed = true; } } // Finalize a block. Close it and do any necessary postprocessing, // e.g. creating stringContent from strings, setting the 'tight' // or 'loose' status of a list, and parsing the beginnings // of paragraphs for reference definitions. Reset the tip to the // parent of the closed block. finalize(block: BlockNode, lineNumber: number) { const above = block.parent as BlockNode; block.open = false; block.sourcepos![1] = [lineNumber, this.lastLineLength]; blockHandlers[block.type].finalize(this, block); this.tip = above; } // Walk through a block & children recursively, parsing string content // into inline content where appropriate. processInlines(block: BlockNode) { let event; const { customParser } = this.options; const walker = block.walker(); this.inlineParser.refMap = this.refMap; this.inlineParser.refLinkCandidateMap = this.refLinkCandidateMap; this.inlineParser.refDefCandidateMap = this.refDefCandidateMap; this.inlineParser.options = this.options; while ((event = walker.next())) { const { node, entering } = event; const t = node.type; if (customParser && customParser[t]) { customParser[t]!(node, { entering, options: this.options }); } if ( !entering && (t === 'paragraph' || t === 'heading' || (t === 'tableCell' && !(node as TableCellNode).ignored)) ) { this.inlineParser.parse(node as BlockNode); } } } // Analyze a line of text and update the document appropriately. // We parse markdown text by calling this on each line of input, // then finalizing the document. incorporateLine(ln: string) { let container = this.doc; this.oldtip = this.tip; this.offset = 0; this.column = 0; this.blank = false; this.partiallyConsumedTab = false; this.lineNumber += 1; // replace NUL characters for security if (ln.indexOf('\u0000') !== -1) { ln = ln.replace(/\0/g, '\uFFFD'); } this.currentLine = ln; // For each containing block, try to parse the associated line start. // Bail out on failure: container will point to the last matching block. // Set allMatched to false if not all containers match. let allMatched = true; let lastChild: BlockNode; while ((lastChild = container.lastChild as BlockNode) && lastChild.open) { container = lastChild; this.findNextNonspace(); switch (blockHandlers[container.type]['continue'](this, container)) { case Process.Go: // we've matched, keep going break; case Process.Stop: // we've failed to match a block allMatched = false; break; case Process.Finished: // we've hit end of line for fenced code close and can return this.lastLineLength = ln.length; return; default: throw new Error('continue returned illegal value, must be 0, 1, or 2'); } if (!allMatched) { container = container.parent as BlockNode; // back up to last matching block break; } } this.allClosed = container === this.oldtip; this.lastMatchedContainer = container; let matchedLeaf = container.type !== 'paragraph' && blockHandlers[container.type].acceptsLines; const blockStartsLen = blockStarts.length; // Unless last matched container is a code block, try new container starts, // adding children to the last matched container: while (!matchedLeaf) { this.findNextNonspace(); // this is a little performance optimization: if ( container.type !== 'table' && container.type !== 'tableBody' && container.type !== 'paragraph' && !this.indented && !reMaybeSpecial.test(ln.slice(this.nextNonspace)) ) { this.advanceNextNonspace(); break; } let i = 0; while (i < blockStartsLen) { const res = blockStarts[i](this, container); if (res === Matched.Container) { container = this.tip; break; } else if (res === Matched.Leaf) { container = this.tip; matchedLeaf = true; break; } else { i++; } } if (i === blockStartsLen) { // nothing matched this.advanceNextNonspace(); break; } } // What remains at the offset is a text line. Add the text to the // appropriate container. // First check for a lazy paragraph continuation: if (!this.allClosed && !this.blank && this.tip.type === 'paragraph') { // lazy paragraph continuation this.addLine(); } else { // not a lazy continuation // finalize any blocks not matched this.closeUnmatchedBlocks(); if (this.blank && container.lastChild) { (container.lastChild as BlockNode).lastLineBlank = true; } const t = container.type; // Block quote lines are never blank as they start with > // and we don't count blanks in fenced code for purposes of tight/loose // lists or breaking out of lists. We also don't set _lastLineBlank // on an empty list item, or if we just closed a fenced block. const lastLineBlank = this.blank && !( t === 'blockQuote' || (isCodeBlock(container) && container.isFenced) || (t === 'item' && !container.firstChild && container.sourcepos![0][0] === this.lineNumber) ); // propagate lastLineBlank up through parents: let cont: BlockNode | null = container; while (cont) { cont.lastLineBlank = lastLineBlank; cont = cont.parent as BlockNode; } if (blockHandlers[t].acceptsLines) { this.addLine(); // if HtmlBlock, check for end condition if ( isHtmlBlock(container) && container.htmlBlockType >= 1 && container.htmlBlockType <= 5 && reHtmlBlockClose[container.htmlBlockType].test(this.currentLine.slice(this.offset)) ) { this.lastLineLength = ln.length; this.finalize(container, this.lineNumber); } } else if (this.offset < ln.length && !this.blank) { // create paragraph container for line container = this.addChild('paragraph', this.offset); this.advanceNextNonspace(); this.addLine(); } } this.lastLineLength = ln.length; } // The main parsing function. Returns a parsed document AST. parse(input: string, lineTexts?: string[]) { this.doc = document(); this.tip = this.doc; this.lineNumber = 0; this.lastLineLength = 0; this.offset = 0; this.column = 0; this.lastMatchedContainer = this.doc; this.currentLine = ''; const lines = input.split(reLineEnding); let len = lines.length; this.lines = lineTexts ? lineTexts : lines; if (this.options.referenceDefinition) { this.clearRefMaps(); } if (input.charCodeAt(input.length - 1) === C_NEWLINE) { // ignore last blank line created by final newline len -= 1; } for (let i = 0; i < len; i++) { this.incorporateLine(lines[i]); } while (this.tip) { this.finalize(this.tip, len); } this.processInlines(this.doc); return this.doc; } partialParseStart(lineNumber: number, lines: string[]) { this.doc = document(); this.tip = this.doc; this.lineNumber = lineNumber - 1; this.lastLineLength = 0; this.offset = 0; this.column = 0; this.lastMatchedContainer = this.doc; this.currentLine = ''; const len = lines.length; for (let i = 0; i < len; i++) { this.incorporateLine(lines[i]); } return this.doc; } partialParseExtends(lines: string[]) { for (let i = 0; i < lines.length; i++) { this.incorporateLine(lines[i]); } } partialParseFinish() { while (this.tip) { this.finalize(this.tip, this.lineNumber); } this.processInlines(this.doc); } setRefMaps( refMap: RefMap, refLinkCandidateMap: RefLinkCandidateMap, refDefCandidateMap: RefDefCandidateMap ) { this.refMap = refMap; this.refLinkCandidateMap = refLinkCandidateMap; this.refDefCandidateMap = refDefCandidateMap; } clearRefMaps() { [this.refMap, this.refLinkCandidateMap, this.refDefCandidateMap].forEach((map) => { clearObj(map); }); } } ================================================ FILE: libs/toastmark/src/commonmark/common.ts ================================================ import encode from 'mdurl/encode'; import { decodeHTML } from 'entities'; export const ENTITY = '&(?:#x[a-f0-9]{1,6}|#[0-9]{1,7}|[a-z][a-z0-9]{1,31});'; const C_BACKSLASH = 92; const reBackslashOrAmp = /[\\&]/; export const ESCAPABLE = '[!"#$%&\'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]'; const reEntityOrEscapedChar = new RegExp(`\\\\${ESCAPABLE}|${ENTITY}`, 'gi'); const XMLSPECIAL = '[&<>"]'; const reXmlSpecial = new RegExp(XMLSPECIAL, 'g'); const unescapeChar = function (s: string) { if (s.charCodeAt(0) === C_BACKSLASH) { return s.charAt(1); } return decodeHTML(s); }; // Replace entities and backslash escapes with literal characters. export function unescapeString(s: string) { if (reBackslashOrAmp.test(s)) { return s.replace(reEntityOrEscapedChar, unescapeChar); } return s; } export function normalizeURI(uri: string) { try { return encode(uri); } catch (err) { return uri; } } function replaceUnsafeChar(s: string) { switch (s) { case '&': return '&'; case '<': return '<'; case '>': return '>'; case '"': return '"'; default: return s; } } export function escapeXml(s: string) { if (reXmlSpecial.test(s)) { return s.replace(reXmlSpecial, replaceUnsafeChar); } return s; } export function repeat(str: string, count: number): string { const arr = []; for (let i = 0; i < count; i++) { arr.push(str); } return arr.join(''); } export function last(arr: T[]) { if (!arr.length) { return null; } return arr[arr.length - 1]; } export function isEmpty(str: string) { if (!str) { return true; } return !/[^ \t]+/.test(str); } ================================================ FILE: libs/toastmark/src/commonmark/custom/__test__/customBlock.spec.ts ================================================ import { HTMLConvertorMap } from '@t/renderer'; import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { source } from 'common-tags'; const convertors: HTMLConvertorMap = { myCustom(node) { return [ { type: 'openTag', tagName: 'div', outerNewLine: true, classNames: ['myCustom-block'] }, { type: 'html', content: node.literal! }, { type: 'closeTag', tagName: 'div', outerNewLine: true }, ]; }, }; const reader = new Parser(); const renderer = new Renderer({ gfm: true, convertors }); describe('customBlock', () => { it('basic', () => { const input = source` $$myCustom my custom block should be parsed $$ `; const output = source`
                          my custom block should be parsed
                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); it('if cannot find the proper custom type renderer, the content would be rendered as text', () => { const input = source` $$custom custom block $$ `; const output = source`
                          custom block
                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); it('should be rendered regardless of the case insensitive', () => { const input = source` $$MYCuSTOM my custom block should be parsed $$ `; const output = source`
                          my custom block should be parsed
                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); it('should be parsed as paragraph without meta information', () => { const input = source` $$ custom block $$ `; const output = source`

                          $$ custom block $$

                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); it('should be rendered regardless of the white space', () => { const input = source` $$ myCustom my custom block should be parsed $$ `; const output = source`
                          my custom block should be parsed
                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); }); ================================================ FILE: libs/toastmark/src/commonmark/custom/__test__/customInline.spec.ts ================================================ import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { CustomInlineNode } from '../../node'; const reader = new Parser(); const renderer = new Renderer(); describe('customInline', () => { it('basic example', () => { const root = reader.parse('Hello $$myInline World$$'); const para = root.firstChild!; const text = para.firstChild!; const customInline = text.next as CustomInlineNode; const inlineText = customInline.firstChild!; expect(text.literal).toBe('Hello '); expect(inlineText.literal).toBe('World'); expect(customInline.info).toBe('myInline'); expect(customInline.sourcepos).toEqual([ [1, 7], [1, 24], ]); expect(inlineText.sourcepos).toEqual([ [1, 17], [1, 22], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello $$myInline World$$

                          \n'); }); it('nested markdown text example', () => { const root = reader.parse('Hello $$myInline *World*$$'); const para = root.firstChild!; const text = para.firstChild!; const customInline = text.next as CustomInlineNode; const emph = customInline.lastChild!; expect(text.literal).toBe('Hello '); expect(customInline.info).toBe('myInline'); expect(customInline.sourcepos).toEqual([ [1, 7], [1, 26], ]); expect(emph.sourcepos).toEqual([ [1, 18], [1, 24], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello $$myInline World$$

                          \n'); }); it('should be parsed as text without meta information', () => { const root = reader.parse('Hello $$ world$$'); const para = root.firstChild!; const text = para.firstChild!; expect(text.literal).toBe('Hello $$ world$$'); expect(text.sourcepos).toEqual([ [1, 1], [1, 16], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello $$ world$$

                          \n'); }); it('should be render properly with meta information only', () => { const root = reader.parse('Hello $$myInline$$'); const para = root.firstChild!; const text = para.firstChild!; const customInline = text.next as CustomInlineNode; expect(text.literal).toBe('Hello '); expect(customInline.info).toBe('myInline'); expect(customInline.sourcepos).toEqual([ [1, 7], [1, 18], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello $$myInline$$

                          \n'); }); }); ================================================ FILE: libs/toastmark/src/commonmark/custom/customBlockHandler.ts ================================================ import { Process, BlockHandler } from '../blockHandlers'; import { isSpaceOrTab, peek } from '../blockHelper'; import { unescapeString } from '../common'; import { CustomBlockNode, BlockNode } from '../node'; const reClosingCustomBlock = /^\$\$$/; export const customBlock: BlockHandler = { continue(parser, container: CustomBlockNode) { const line = parser.currentLine; const match = line.match(reClosingCustomBlock); if (match) { // closing custom block parser.lastLineLength = match[0].length; parser.finalize(container as BlockNode, parser.lineNumber); return Process.Finished; } // skip optional spaces of custom block offset let i = container.offset; while (i > 0 && isSpaceOrTab(peek(line, parser.offset))) { parser.advanceOffset(1, true); i--; } return Process.Go; }, finalize(_, block: CustomBlockNode) { if (block.stringContent === null) { return; } // first line becomes info string const content = block.stringContent; const newlinePos = content.indexOf('\n'); const firstLine = content.slice(0, newlinePos); const rest = content.slice(newlinePos + 1); const infoString = firstLine.match(/^(\s*)(.*)/); block.info = unescapeString(infoString![2].trim()); block.literal = rest; block.stringContent = null; }, canContain() { return false; }, acceptsLines: true, }; ================================================ FILE: libs/toastmark/src/commonmark/custom/customBlockStart.ts ================================================ import { BlockStart, Matched } from '../blockStarts'; import { CustomBlockNode } from '../node'; const reCustomBlock = /^(\$\$)(\s*[a-zA-Z])+/; const reCanBeCustomInline = /^(\$\$)(\s*[a-zA-Z])+.*(\$\$)/; export const customBlock: BlockStart = (parser) => { let match; if ( !parser.indented && !reCanBeCustomInline.test(parser.currentLine) && (match = parser.currentLine.match(reCustomBlock)) ) { const syntaxLength = match[1].length; parser.closeUnmatchedBlocks(); const container = parser.addChild('customBlock', parser.nextNonspace) as CustomBlockNode; container.syntaxLength = syntaxLength; container.offset = parser.indent; parser.advanceNextNonspace(); parser.advanceOffset(syntaxLength, false); return Matched.Leaf; } return Matched.None; }; ================================================ FILE: libs/toastmark/src/commonmark/from-code-point.ts ================================================ // derived from https://github.com/mathiasbynens/String.fromCodePoint /*! http://mths.be/fromcodepoint v0.2.1 by @mathias */ let fromCodePoint: (c: number) => string; if (String.fromCodePoint) { fromCodePoint = function (_) { try { return String.fromCodePoint(_); } catch (e) { if (e instanceof RangeError) { return String.fromCharCode(0xfffd); } throw e; } }; } else { const stringFromCharCode = String.fromCharCode; const floor = Math.floor; fromCodePoint = function (...args) { const MAX_SIZE = 0x4000; const codeUnits = []; let highSurrogate: number; let lowSurrogate: number; let index = -1; const length = args.length; if (!length) { return ''; } let result = ''; while (++index < length) { let codePoint = Number(args[index]); if ( !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` codePoint < 0 || // not a valid Unicode code point codePoint > 0x10ffff || // not a valid Unicode code point floor(codePoint) !== codePoint // not an integer ) { return String.fromCharCode(0xfffd); } if (codePoint <= 0xffff) { // BMP code point codeUnits.push(codePoint); } else { // Astral code point; split in surrogate halves // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae codePoint -= 0x10000; highSurrogate = (codePoint >> 10) + 0xd800; lowSurrogate = (codePoint % 0x400) + 0xdc00; codeUnits.push(highSurrogate, lowSurrogate); } if (index + 1 === length || codeUnits.length > MAX_SIZE) { result += stringFromCharCode(...codeUnits); codeUnits.length = 0; } } return result; }; } export default fromCodePoint; ================================================ FILE: libs/toastmark/src/commonmark/frontMatter/__test__/frontMatter.spec.ts ================================================ import { source, stripIndent } from 'common-tags'; import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; const reader = new Parser({ frontMatter: true }); const renderer = new Renderer(); describe('front matter', () => { it('should be parsed with YAML(`---`)', () => { const frontMatterText = stripIndent` --- title: front matter --- `; const root = reader.parse(frontMatterText); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'frontMatter', literal: frontMatterText, sourcepos: [ [1, 1], [3, 3], ], }, }); }); it('should be parsed with TOML(`+++`)', () => { const frontMatterText = stripIndent` +++ title: front matter +++ `; const root = reader.parse(frontMatterText); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'frontMatter', literal: frontMatterText, sourcepos: [ [1, 1], [3, 3], ], }, }); }); it('should be parsed with JSON(`;;;`)', () => { const frontMatterText = stripIndent` ;;; title: front matter ;;; `; const root = reader.parse(frontMatterText); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'frontMatter', literal: frontMatterText, sourcepos: [ [1, 1], [3, 3], ], }, }); }); it('should be parsed with the empty line', () => { const markdownText = stripIndent` --- title: front matter description: with empty line --- `; const root = reader.parse(markdownText); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'frontMatter', literal: markdownText, sourcepos: [ [1, 1], [7, 3], ], }, }); }); it('should be parsed with following paragraph', () => { const root = reader.parse('---\ntitle: front matter\n---\npara'); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'frontMatter', literal: '---\ntitle: front matter\n---', sourcepos: [ [1, 1], [3, 3], ], }, lastChild: { type: 'paragraph', literal: null, sourcepos: [ [4, 1], [4, 4], ], firstChild: { literal: 'para', sourcepos: [ [4, 1], [4, 4], ], }, }, }); }); it('should be parsed only once from the top.', () => { const frontMatterText = stripIndent` --- title: front matter description: with empty line --- `; const markdownText = `${frontMatterText}\n---`; const root = reader.parse(markdownText); expect(root).toMatchObject({ type: 'document', firstChild: { type: 'frontMatter', literal: frontMatterText, sourcepos: [ [1, 1], [7, 3], ], next: { type: 'thematicBreak', literal: null, sourcepos: [ [8, 1], [8, 3], ], }, }, }); }); }); describe('Exmaple', () => { const examples = [ { no: 1, input: source` --- title: front matter --- `, output: source`
                          --- title: front matter ---
                          `, }, { no: 2, input: source` --- title: front matter description: with empty line --- `, output: source`
                          --- title: front matter description: with empty line ---
                          `, }, { no: 3, input: source` --- title: front matter --- `, output: source`
                          --- title: front matter ---
                          `, }, { no: 4, input: source` --- title: front matter --- para `, output: source`
                          --- title: front matter ---

                          para

                          `, }, { no: 5, input: source` para --- title: front matter --- `, output: source`

                          para

                          title: front matter

                          `, }, ]; examples.forEach(({ no, input, output }) => { it(String(no), () => { const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); }); }); ================================================ FILE: libs/toastmark/src/commonmark/frontMatter/frontMatterHandler.ts ================================================ import { Process, BlockHandler } from '../blockHandlers'; import { BlockNode } from '../node'; import { reFrontMatter } from './frontMatterStart'; export const frontMatter: BlockHandler = { continue(parser, container: BlockNode) { const line = parser.currentLine; const match = line.match(reFrontMatter); if (container.type === 'frontMatter' && match) { container.stringContent += line; parser.lastLineLength = match[0].length; parser.finalize(container as BlockNode, parser.lineNumber); return Process.Finished; } return Process.Go; }, finalize(_, block: BlockNode) { if (block.stringContent === null) { return; } block.literal = block.stringContent; block.stringContent = null; }, canContain() { return false; }, acceptsLines: true, }; ================================================ FILE: libs/toastmark/src/commonmark/frontMatter/frontMatterStart.ts ================================================ import { BlockStart, Matched } from '../blockStarts'; import { BlockNode } from '../node'; // `---` for YAML, `+++` for TOML, `;;;` for JSON export const reFrontMatter = /^(-{3}|\+{3}|;{3})$/; export const frontMatter: BlockStart = (parser, container) => { const { currentLine, lineNumber, indented } = parser; if ( lineNumber === 1 && !indented && container.type === 'document' && reFrontMatter.test(currentLine) ) { parser.closeUnmatchedBlocks(); const frontMatter = parser.addChild('frontMatter', parser.nextNonspace) as BlockNode; frontMatter.stringContent = currentLine; parser.advanceNextNonspace(); parser.advanceOffset(currentLine.length, false); return Matched.Leaf; } return Matched.None; }; ================================================ FILE: libs/toastmark/src/commonmark/gfm/__test__/autolinks.spec.ts ================================================ import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { LinkNode } from '../../node'; import { pos } from '../../__test__/helper.spec'; import { parseUrlLink, parseEmailLink } from '../autoLinks'; describe('parseUrlLink()', () => { // https://github.github.com/gfm/#extended-www-autolink // https://github.github.com/gfm/#extended-url-autolink it('domain not preceeded by www is invalid', () => { expect(parseUrlLink('nhn.com')).toEqual([]); expect(parseUrlLink('ui.toast.com')).toEqual([]); }); it('domain preceeded by www with less than 2 periods(.) is invalid', () => { expect(parseUrlLink('www.nhn')).toEqual([]); }); it('domain preceeded by www is valid', () => { expect(parseUrlLink('www.nhn.com')).toEqual([ { text: 'www.nhn.com', url: `http://www.nhn.com`, range: [0, 10], }, ]); expect(parseUrlLink('Visit www.nhn.com Now!')).toEqual([ { text: 'www.nhn.com', url: `http://www.nhn.com`, range: [6, 16], }, ]); }); it('domain preceeded by http(s):// is valid', () => { expect(parseUrlLink('http://nhn.com')).toEqual([ { text: 'http://nhn.com', url: `http://nhn.com`, range: [0, 13], }, ]); expect(parseUrlLink('https://nhn.com')).toEqual([ { text: 'https://nhn.com', url: `https://nhn.com`, range: [0, 14], }, ]); }); it('zero or more non-space non-< characters may follow', () => { expect(parseUrlLink('www.nhn.com/help { const pairs = [ ['www.nhn.com/?help?', 'www.nhn.com/?help'], ['www.nhn.com/!help!', 'www.nhn.com/!help'], ['www.nhn.com/,help,', 'www.nhn.com/,help'], ['www.nhn.com/.help.', 'www.nhn.com/.help'], ['www.nhn.com/:help:', 'www.nhn.com/:help'], ['www.nhn.com/*help*', 'www.nhn.com/*help'], ['www.nhn.com/~help~', 'www.nhn.com/~help'], ['http://nhn.com/~help~', 'http://nhn.com/~help'], ['https://nhn.com/~help~', 'https://nhn.com/~help'], ]; pairs.forEach(([input, text]) => { expect(parseUrlLink(input)![0].text).toBe(text); }); }); it('trailing closing parens without matching opening parens are excluded', () => { const pairs = [ ['www.nhn.com/(ui)', 'www.nhn.com/(ui)'], ['www.nhn.com/(ui))', 'www.nhn.com/(ui)'], ['(www.nhn.com/(ui))', 'www.nhn.com/(ui)'], ['(www.nhn.com/((ui))', 'www.nhn.com/((ui))'], ['(www.nhn.com/(ui)', 'www.nhn.com/(ui)'], ['(www.nhn.com/)))(ui))', 'www.nhn.com/)))(ui)'], ['(http://nhn.com/)))(ui))', 'http://nhn.com/)))(ui)'], ['(https://nhn.com/)))(ui))', 'https://nhn.com/)))(ui)'], ]; pairs.forEach(([input, text]) => { expect(parseUrlLink(input)![0].text).toBe(text); }); }); it('trailing entity-like pattern (&xxx;) are excluded', () => { const pairs = [ ['www.nhn.com/ui&editor;grid', 'www.nhn.com/ui&editor;grid'], ['www.nhn.com/ui&grid;', 'www.nhn.com/ui'], ['www.nhn.com/ui&?grid;', 'www.nhn.com/ui&?grid;'], ['http://nhn.com/ui&?grid;', 'http://nhn.com/ui&?grid;'], ['https://nhn.com/ui&?grid;', 'https://nhn.com/ui&?grid;'], ]; pairs.forEach(([input, text]) => { expect(parseUrlLink(input)![0].text).toBe(text); }); }); it('should handle multiple occurrences', () => { expect(parseUrlLink('Hello www.nhn.com and http://toast.com')).toEqual([ { text: 'www.nhn.com', url: 'http://www.nhn.com', range: [6, 16], }, { text: 'http://toast.com', url: 'http://toast.com', range: [22, 37], }, ]); }); }); describe('parseEmailLink', () => { it('simple example', () => { expect(parseEmailLink('ui@toast.com')).toEqual([ { text: 'ui@toast.com', url: 'mailto:ui@toast.com', range: [0, 11], }, ]); expect(parseEmailLink('Hello ui@toast.com guys')).toEqual([ { text: 'ui@toast.com', url: 'mailto:ui@toast.com', range: [6, 17], }, ]); }); it('+ can occur before the @, but not after.', () => { expect(parseEmailLink('ui@to+ast.com')).toEqual([]); expect(parseEmailLink('u+i@toast.com')).toEqual([ { text: 'u+i@toast.com', url: 'mailto:u+i@toast.com', range: [0, 12], }, ]); }); it('trailing dash(-) and underscore(_) are invalid, trailing dot(.) is excluded ', () => { const pairs = [ ['a.b-c_d@a.b', 'a.b-c_d@a.b'], ['a.b-c_d@a.b.', 'a.b-c_d@a.b'], ]; const invalids = ['a.b-c_d@a.b-', 'a.b-c_d@a.b_']; pairs.forEach(([input, text]) => { expect(parseEmailLink(input)![0].text).toBe(text); }); invalids.forEach((input) => { expect(parseEmailLink(input)).toEqual([]); }); }); it('should handle multiple occurrences', () => { expect(parseEmailLink('Hello ui@toast.com and file@toast.com')).toEqual([ { text: 'ui@toast.com', url: 'mailto:ui@toast.com', range: [6, 17], }, { text: 'file@toast.com', url: 'mailto:file@toast.com', range: [23, 36], }, ]); }); }); describe('custom autolink parser', () => { const renderer = new Renderer(); const reader = new Parser({ extendedAutolinks: (content) => { const regex = /\d{3}/g; const result = []; let matched; while ((matched = regex.exec(content))) { const { index } = matched; const text = matched[0]; const range: [number, number] = [index, index + text.length - 1]; const url = `num:${text}`; result.push({ text, url, range }); } return result; }, }); it('should parse custom pattern', () => { const root = reader.parse('A 111 B 222'); const para = root.firstChild!; const link1 = para.firstChild!.next!; const link2 = link1.next!.next!; expect(link1).toMatchObject({ destination: 'num:111', extendedAutolink: true, sourcepos: pos(1, 3, 1, 5), firstChild: { literal: '111', }, }); expect(link2).toMatchObject({ destination: 'num:222', extendedAutolink: true, sourcepos: pos(1, 9, 1, 11), firstChild: { literal: '222', }, }); expect(renderer.render(root)).toBe( '

                          A 111 B 222

                          \n' ); }); }); // https://github.github.com/gfm/#example-621 describe('GFM Examples', () => { const reader = new Parser({ extendedAutolinks: true }); const renderer = new Renderer(); it('621', () => { const root = reader.parse('www.commonmark.org'); const link = root.firstChild!.firstChild as LinkNode; const linkText = link.firstChild!; expect(link).toMatchObject({ type: 'link', destination: 'http://www.commonmark.org', extendedAutolink: true, sourcepos: pos(1, 1, 1, 18), }); expect(linkText).toMatchObject({ literal: 'www.commonmark.org', sourcepos: pos(1, 1, 1, 18), }); const html = renderer.render(root); expect(html).toBe('

                          www.commonmark.org

                          \n'); }); it('622', () => { const root = reader.parse('Visit www.commonmark.org/help for more information.'); const text1 = root.firstChild!.firstChild!; const link = text1.next as LinkNode; const linkText = link.firstChild!; const text2 = link.next!; expect(text1.literal).toBe('Visit '); expect(link).toMatchObject({ type: 'link', extendedAutolink: true, destination: 'http://www.commonmark.org/help', sourcepos: pos(1, 7, 1, 29), }); expect(linkText.literal).toBe('www.commonmark.org/help'); expect(linkText.sourcepos).toEqual(pos(1, 7, 1, 29)); expect(text2.literal).toBe(' for more information.'); expect(text2.sourcepos).toEqual(pos(1, 30, 1, 51)); const html = renderer.render(root); expect(html).toBe( '

                          Visit www.commonmark.org/help for more information.

                          \n' ); }); const examples = [ { no: 623, input: ['Visit www.commonmark.org.\n\n', 'Visit www.commonmark.org/a.b.'].join(''), output: [ '

                          Visit www.commonmark.org.

                          \n', '

                          Visit www.commonmark.org/a.b.

                          \n', ].join(''), }, { no: 624, input: [ 'www.google.com/search?q=Markup+(business)\n\n', 'www.google.com/search?q=Markup+(business)))\n\n', '(www.google.com/search?q=Markup+(business))\n\n', '(www.google.com/search?q=Markup+(business)', ].join(''), output: [ '

                          ', 'www.google.com/search?q=Markup+(business)

                          \n', '

                          ', 'www.google.com/search?q=Markup+(business)))

                          \n', '

                          (', 'www.google.com/search?q=Markup+(business))

                          \n', '

                          (', 'www.google.com/search?q=Markup+(business)

                          \n', ].join(''), }, { no: 625, input: 'www.google.com/search?q=(business))+ok', output: [ '

                          ', 'www.google.com/search?q=(business))+ok

                          \n', ].join(''), }, { no: 626, input: [ 'www.google.com/search?q=commonmark&hl=en\n\n', 'www.google.com/search?q=commonmark&hl;', ].join(''), output: [ '

                          ', 'www.google.com/search?q=commonmark&hl=en

                          \n', '

                          ', 'www.google.com/search?q=commonmark&hl;

                          \n', ].join(''), }, { no: 627, input: 'www.commonmark.org/hewww.commonmark.org/he<lp

                          \n', }, { no: 628, input: [ 'http://commonmark.org\n\n', '(Visit https://encrypted.google.com/search?q=Markup+(business))', ].join(''), output: [ '

                          http://commonmark.org

                          \n', '

                          (Visit ', 'https://encrypted.google.com/search?q=Markup+(business))

                          \n', ].join(''), }, { no: 629, input: 'foo@bar.baz', output: '

                          foo@bar.baz

                          \n', }, { no: 630, input: `hello@mail+xyz.example isn't valid, but hello+xyz@mail.example is.`, output: [ `

                          hello@mail+xyz.example isn't valid, but `, `hello+xyz@mail.example is.

                          \n`, ].join(''), }, { no: 631, input: ['a.b-c_d@a.b\n\n', 'a.b-c_d@a.b.\n\n', 'a.b-c_d@a.b-\n\n', 'a.b-c_d@a.b_'].join(''), output: [ '

                          a.b-c_d@a.b

                          \n', '

                          a.b-c_d@a.b.

                          \n', '

                          a.b-c_d@a.b-

                          \n', '

                          a.b-c_d@a.b_

                          \n', ].join(''), }, ]; examples.forEach(({ no, input, output }) => { it(String(no), () => { const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(output); }); }); }); ================================================ FILE: libs/toastmark/src/commonmark/gfm/__test__/strikethrough.spec.ts ================================================ import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { source } from 'common-tags'; const reader = new Parser({ smart: true }); const renderer = new Renderer({ gfm: true }); describe('smart punctuation', () => { it('single quote', () => { const root = reader.parse(`Hello *'World'*`); const html = renderer.render(root); expect(html).toBe('

                          Hello \u2018World\u2019

                          \n'); }); it('double quote', () => { const root = reader.parse(`Hello "*World*"`); const html = renderer.render(root); expect(html).toBe('

                          Hello \u201CWorld\u201D

                          \n'); }); }); describe('strikethrough', () => { // https://github.github.com/gfm/#example-491 it('GFM Example 491', () => { const root = reader.parse('~~Hi~~ Hello, world!'); const html = renderer.render(root); expect(html).toBe('

                          Hi Hello, world!

                          \n'); }); it('GFM Example 492', () => { const input = source` This ~~has a new paragraph~~. `; const output = source`

                          This ~~has a

                          new paragraph~~.

                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toEqual(`${output}\n`); }); it('basic example', () => { const root = reader.parse('Hello ~~World~~'); const para = root.firstChild!; const text = para.firstChild!; const strike = text.next!; const strikeText = strike.firstChild!; expect(text.literal).toBe('Hello '); expect(strikeText.literal).toBe('World'); expect(strike.sourcepos).toEqual([ [1, 7], [1, 15], ]); expect(strikeText.sourcepos).toEqual([ [1, 9], [1, 13], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello World

                          \n'); }); it('complex delimiters', () => { // 6 long delimiter-run after 'Hello' can be both open and close delimiter const root = reader.parse('~~Hello~~~~~~World~~~'); const para = root.firstChild!; const strike1 = para.firstChild!; const text1 = strike1.next!; const strike2 = text1.next!; const text2 = strike2.next!; expect(strike1.firstChild!.literal).toBe('Hello'); expect(text1.literal).toBe('~~'); expect(strike2.firstChild!.literal).toBe('World'); expect(text2.literal).toBe('~'); expect(strike1.sourcepos).toEqual([ [1, 1], [1, 9], ]); expect(text1.sourcepos).toEqual([ [1, 10], [1, 11], ]); expect(strike2.sourcepos).toEqual([ [1, 12], [1, 20], ]); expect(text2.sourcepos).toEqual([ [1, 21], [1, 21], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello~~World~

                          \n'); }); it('nested delimiters (only strikethrough)', () => { const root = reader.parse('Hello~~~~~~World~~~~~'); const para = root.firstChild!; const text1 = para.firstChild!; const strike1 = text1.next!; const strike2 = strike1.firstChild!; // nested const text2 = strike1.next!; expect(text1.literal).toBe('Hello~~'); expect(strike2.firstChild!.literal).toBe('World'); expect(text2.literal).toBe('~'); expect(text1.sourcepos).toEqual([ [1, 1], [1, 7], ]); expect(strike1.sourcepos).toEqual([ [1, 8], [1, 20], ]); expect(strike2.sourcepos).toEqual([ [1, 10], [1, 18], ]); expect(text2.sourcepos).toEqual([ [1, 21], [1, 21], ]); const html = renderer.render(root); expect(html).toBe('

                          Hello~~World~

                          \n'); }); it('nested delimiters (with emphasis)', () => { const root = reader.parse('~~*Hello*~~**~~World~~**'); const para = root.firstChild!; const strike1 = para.firstChild!; const emph = strike1.firstChild!; const strong = strike1.next!; const strike2 = strong.firstChild!; expect(strike1.sourcepos).toEqual([ [1, 1], [1, 11], ]); expect(emph.sourcepos).toEqual([ [1, 3], [1, 9], ]); expect(strong.sourcepos).toEqual([ [1, 12], [1, 24], ]); expect(strike2.sourcepos).toEqual([ [1, 14], [1, 22], ]); const html = renderer.render(root); expect(html).toBe('

                          HelloWorld

                          \n'); }); }); ================================================ FILE: libs/toastmark/src/commonmark/gfm/__test__/table.spec.ts ================================================ import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { convertToArrayTree } from '../../__test__/helper.spec'; import { BlockNode, TableNode } from 'src/commonmark/node'; import { source } from 'common-tags'; const reader = new Parser(); const renderer = new Renderer({ gfm: true }); // Shortcut function to prevent prettier from adding linebreak beetween nested arrays const pos = (a: number, b: number, c: number, d: number) => [ [a, b], [c, d], ]; describe('table', () => { it('basic', () => { const root = reader.parse(' a | b\n --| ---\n| c | |\n e'); const result = convertToArrayTree(root, [ 'type', 'sourcepos', 'stringContent', 'paddingLeft', 'paddingRight', 'literal', ] as (keyof BlockNode)[]); expect(result).toEqual({ type: 'document', sourcepos: pos(1, 1, 4, 2), children: [ { type: 'table', sourcepos: pos(1, 3, 3, 9), children: [ { type: 'tableHead', sourcepos: pos(1, 3, 2, 8), children: [ { type: 'tableRow', sourcepos: pos(1, 3, 1, 8), children: [ { type: 'tableCell', paddingLeft: 0, paddingRight: 1, sourcepos: pos(1, 3, 1, 4), children: [ { type: 'text', literal: 'a', sourcepos: pos(1, 3, 1, 3), }, ], }, { type: 'tableCell', paddingLeft: 2, paddingRight: 0, sourcepos: pos(1, 6, 1, 8), children: [ { type: 'text', literal: 'b', sourcepos: pos(1, 8, 1, 8), }, ], }, ], }, { type: 'tableDelimRow', sourcepos: pos(2, 2, 2, 8), children: [ { type: 'tableDelimCell', paddingLeft: 0, paddingRight: 0, stringContent: '--', sourcepos: pos(2, 2, 2, 3), }, { type: 'tableDelimCell', paddingLeft: 1, paddingRight: 0, stringContent: '---', sourcepos: pos(2, 5, 2, 8), }, ], }, ], }, { type: 'tableBody', sourcepos: pos(3, 1, 3, 9), children: [ { type: 'tableRow', sourcepos: pos(3, 1, 3, 9), children: [ { type: 'tableCell', paddingLeft: 2, paddingRight: 1, sourcepos: pos(3, 2, 3, 5), children: [ { type: 'text', literal: 'c', sourcepos: pos(3, 4, 3, 4), }, ], }, { type: 'tableCell', paddingLeft: 0, paddingRight: 0, sourcepos: pos(3, 7, 3, 8), }, ], }, ], }, ], }, { type: 'paragraph', sourcepos: pos(4, 2, 4, 2), children: [ { type: 'text', sourcepos: pos(4, 2, 4, 2), literal: 'e', }, ], }, ], }); const html = renderer.render(root); const output = source`
                          a b
                          c

                          e

                          `; expect(html).toBe(`${output}\n`); }); it('preceded by non-empty line', () => { const input = source` Hello World | a | b | | - | - | | c | d | `; const root = reader.parse(input); const result = convertToArrayTree(root, ['type', 'sourcepos'] as (keyof BlockNode)[]); expect(result).toMatchObject({ type: 'document', children: [ { type: 'paragraph', sourcepos: pos(1, 1, 2, 5), }, { type: 'table', sourcepos: pos(3, 1, 5, 9), }, ], }); }); it('with aligns', () => { const root = reader.parse('left | center | right\n:--- | :---: | ---:\na | b | c'); const tableNode = root.firstChild as TableNode; expect(tableNode.columns).toEqual([{ align: 'left' }, { align: 'center' }, { align: 'right' }]); }); it('with empty cells', () => { const input = source` | a | | | | - | - | - | | | b | | | | | c | `; const output = source`
                          a
                          b
                          c
                          `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); }); describe('GFM Exmaple', () => { const examples = [ { no: 198, input: source` | foo | bar | | --- | --- | | baz | bim | `, output: source`
                          foo bar
                          baz bim
                          `, }, { no: 199, input: source` | abc | defghi | :-: | -----------: bar | baz `, output: source`
                          abc defghi
                          bar baz
                          `, }, { no: 200, input: source` | f\\|oo | | ------ | | b \`\\|\` az | | b **\\|** im | `, output: source`
                          f|oo
                          b | az
                          b | im
                          `, }, { no: 201, input: source` | abc | def | | --- | --- | | bar | baz | > bar `, output: source`
                          abc def
                          bar baz

                          bar

                          `, }, { no: 202, input: source` | abc | def | | --- | --- | | bar | baz | bar bar `, output: source`
                          abc def
                          bar baz

                          bar

                          bar

                          `, }, // TODO: need to find a way to parse merged-column and re-activate this test case // { // no: 203, // input: source` // | abc | def | // | --- | // | bar | // `, // output: source` //

                          | abc | def | // | --- | // | bar |

                          // ` // }, { no: 204, input: source` | abc | def | | --- | --- | | bar | | bar | baz | boo | `, output: source`
                          abc def
                          bar
                          bar baz
                          `, }, { no: 205, input: source` | abc | def | | --- | --- | `, output: source`
                          abc def
                          `, }, ]; examples.forEach(({ no, input, output }) => { it(String(no), () => { const root = reader.parse(input); const html = renderer.render(root); expect(html).toBe(`${output}\n`); }); }); }); ================================================ FILE: libs/toastmark/src/commonmark/gfm/__test__/tagfilter.spec.ts ================================================ import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { source } from 'common-tags'; const reader = new Parser(); const renderer = new Renderer({ gfm: true, tagFilter: true }); // https://github.github.com/gfm/#example-653 it('GFM Example 653', () => { const input = source` <style> <em> <blockquote> <xmp> is disallowed. <XMP> is also disallowed. </blockquote> `; const output = source` <p><strong> <title> <style> <em></p> <blockquote> <xmp> is disallowed. <XMP> is also disallowed. </blockquote> `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toEqual(`${output}\n`); }); it('Disallowed tags with attributes and closing tags', () => { const input = source` <strong> <TITLE> <style type="text/css"> <em> <blockquote> </xmp> is disallowed. </XMP> is also disallowed. </blockquote> `; const output = source` <p><strong> <TITLE> <style type="text/css"> <em></p> <blockquote> </xmp> is disallowed. </XMP> is also disallowed. </blockquote> `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toEqual(`${output}\n`); }); it('Keep BlockHTML as is, and only escape during rendering phase', () => { const input = source` <iframe> Hello **World** </iframe> `; // Does not convert emphasis inside <iframe>, as it's inside BlockHTML const output = source` <iframe> Hello **World** </iframe> `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toEqual(`${output}\n`); }); ================================================ FILE: libs/toastmark/src/commonmark/gfm/__test__/taskListItem.spec.ts ================================================ import { source } from 'common-tags'; import { Parser } from '../../blocks'; import { Renderer } from '../../../html/renderer'; import { pos } from '../../__test__/helper.spec'; const reader = new Parser(); const renderer = new Renderer({ gfm: true }); describe('Task list item', () => { it('Parse', () => { const root = reader.parse(source` - [ ] Item1 - [x] Item2 - [X] Item3 `); expect(root).toMatchObject({ firstChild: { type: 'list', firstChild: { type: 'item', listData: { task: true, checked: false, }, firstChild: { type: 'paragraph', sourcepos: pos(1, 7, 1, 11), }, next: { type: 'item', listData: { task: true, checked: true, }, firstChild: { type: 'paragraph', sourcepos: pos(2, 8, 2, 12), }, next: { type: 'item', listData: { task: true, checked: true, }, firstChild: { type: 'paragraph', sourcepos: pos(3, 10, 3, 14), }, }, }, }, }, }); }); // https://github.github.com/gfm/#example-279 it('GFM Example 279', () => { const input = source` - [ ] foo - [x] bar `; const output = source` <ul> <li><input disabled="" type="checkbox" /> foo</li> <li><input checked="" disabled="" type="checkbox" /> bar</li> </ul> `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toEqual(`${output}\n`); }); // https://github.github.com/gfm/#example-280 it('GFM Example 280', () => { const input = source` - [x] foo - [ ] bar - [x] baz - [ ] bim `; const output = source` <ul> <li><input checked="" disabled="" type="checkbox" /> foo <ul> <li><input disabled="" type="checkbox" /> bar</li> <li><input checked="" disabled="" type="checkbox" /> baz</li> </ul> </li> <li><input disabled="" type="checkbox" /> bim</li> </ul> `; const root = reader.parse(input); const html = renderer.render(root); expect(html).toEqual(`${output}\n`); }); }); ================================================ FILE: libs/toastmark/src/commonmark/gfm/autoLinks.ts ================================================ import { Sourcepos } from '@t/node'; import { AutolinkParser } from '@t/parser'; import { createNode, text } from '../node'; import NodeWalker from '../nodeWalker'; const DOMAIN = '(?:[w-]+.)*[A-Za-z0-9-]+.[A-Za-z0-9-]+'; const PATH = '[^<\\s]*[^<?!.,:*_?~\\s]'; const EMAIL = '[\\w.+-]+@(?:[\\w-]+\\.)+[\\w-]+'; function trimUnmatchedTrailingParens(source: string) { const trailingParen = /\)+$/.exec(source); if (trailingParen) { let count = 0; for (const ch of source) { if (ch === '(') { if (count < 0) { count = 1; } else { count += 1; } } else if (ch === ')') { count -= 1; } } if (count < 0) { const trimCount = Math.min(-count, trailingParen[0].length); return source.substring(0, source.length - trimCount); } } return source; } function trimTrailingEntity(source: string) { return source.replace(/&[A-Za-z0-9]+;$/, ''); } interface LinkInfo { text: string; url: string; range: [number, number]; } export function parseEmailLink(source: string) { const reEmailLink = new RegExp(EMAIL, 'g'); const result: LinkInfo[] = []; let m; while ((m = reEmailLink.exec(source))) { const text = m[0]; if (!/[_-]+$/.test(text)) { result.push({ text, range: [m.index, m.index + text.length - 1], url: `mailto:${text}`, }); } } return result; } export function parseUrlLink(source: string) { const reWwwAutolink = new RegExp(`(www|https?://)\.${DOMAIN}${PATH}`, 'g'); const result: LinkInfo[] = []; let m; while ((m = reWwwAutolink.exec(source))) { const text = trimTrailingEntity(trimUnmatchedTrailingParens(m[0])); const scheme = m[1] === 'www' ? 'http://' : ''; result.push({ text, range: [m.index, m.index + text.length - 1], url: `${scheme}${text}`, }); } return result; } function baseAutolinkParser(source: string) { return [...parseUrlLink(source), ...parseEmailLink(source)].sort( (a, b) => a.range[0] - b.range[0] ); } export function convertExtAutoLinks(walker: NodeWalker, autolinkParser: boolean | AutolinkParser) { if (typeof autolinkParser === 'boolean') { autolinkParser = baseAutolinkParser; } let event; while ((event = walker.next())) { const { entering, node } = event; if (entering && node.type === 'text' && node.parent!.type !== 'link') { const literal = node.literal!; const linkInfos = autolinkParser(literal); if (!linkInfos || !linkInfos.length) { continue; } let lastIdx = 0; const [lineNum, chPos] = node.sourcepos![0]; const sourcepos = (startIdx: number, endIdx: number): Sourcepos => [ [lineNum, chPos + startIdx], [lineNum, chPos + endIdx], ]; const newNodes = []; for (const { range, url, text: linkText } of linkInfos) { if (range[0] > lastIdx) { newNodes.push( text(literal.substring(lastIdx, range[0]), sourcepos(lastIdx, range[0] - 1)) ); } const linkNode = createNode('link', sourcepos(...range)); linkNode.appendChild(text(linkText, sourcepos(...range))); linkNode.destination = url; linkNode.extendedAutolink = true; newNodes.push(linkNode); lastIdx = range[1] + 1; } if (lastIdx < literal.length) { newNodes.push(text(literal.substring(lastIdx), sourcepos(lastIdx, literal.length - 1))); } for (const newNode of newNodes) { node.insertBefore(newNode); } node.unlink(); } } } ================================================ FILE: libs/toastmark/src/commonmark/gfm/tableBlockHandler.ts ================================================ import { MdNodeType } from '@t/node'; import { Process, BlockHandler } from '../blockHandlers'; export const table: BlockHandler = { continue() { return Process.Go; }, finalize() {}, canContain(t: MdNodeType) { return t === 'tableHead' || t === 'tableBody'; }, acceptsLines: false, }; export const tableBody: BlockHandler = { continue() { return Process.Go; }, finalize() {}, canContain(t: MdNodeType) { return t === 'tableRow'; }, acceptsLines: false, }; export const tableHead: BlockHandler = { continue() { return Process.Stop; }, finalize() {}, canContain(t: MdNodeType) { return t === 'tableRow' || t === 'tableDelimRow'; }, acceptsLines: false, }; export const tableDelimRow: BlockHandler = { continue() { return Process.Stop; }, finalize() {}, canContain(t: MdNodeType) { return t === 'tableDelimCell'; }, acceptsLines: false, }; export const tableDelimCell: BlockHandler = { continue() { return Process.Stop; }, finalize() {}, canContain() { return false; }, acceptsLines: false, }; export const tableRow: BlockHandler = { continue() { return Process.Stop; }, finalize() {}, canContain(t: MdNodeType) { return t === 'tableCell'; }, acceptsLines: false, }; export const tableCell: BlockHandler = { continue() { return Process.Stop; }, finalize() {}, canContain() { return false; }, acceptsLines: false, }; ================================================ FILE: libs/toastmark/src/commonmark/gfm/tableBlockStart.ts ================================================ import { Sourcepos, TableColumn } from '@t/node'; import { isEmpty } from '../common'; import { BlockStart, Matched } from '../blockStarts'; import { createNode, TableNode, TableCellNode } from '../node'; import { last } from '../../helper'; function parseRowContent(content: string): [number, string[]] { let startIdx = 0; let offset = 0; const cells = []; for (let i = 0; i < content.length; i += 1) { if (content[i] === '|' && content[i - 1] !== '\\') { const cell = content.substring(startIdx, i); if (startIdx === 0 && isEmpty(cell)) { offset = i + 1; } else { cells.push(cell); } startIdx = i + 1; } } if (startIdx < content.length) { const cell = content.substring(startIdx, content.length); if (!isEmpty(cell)) { cells.push(cell); } } return [offset, cells]; } function generateTableCells( cellType: 'tableCell' | 'tableDelimCell', contents: string[], lineNum: number, chPos: number ) { const cells = []; for (const content of contents) { const preSpaces = content.match(/^[ \t]+/); let paddingLeft = preSpaces ? preSpaces[0].length : 0; let paddingRight, trimmed; if (paddingLeft === content.length) { paddingLeft = 0; paddingRight = 0; trimmed = ''; } else { const postSpaces = content.match(/[ \t]+$/); paddingRight = postSpaces ? postSpaces[0].length : 0; trimmed = content.slice(paddingLeft, content.length - paddingRight); } const chPosStart = chPos + paddingLeft; const tableCell = createNode(cellType, [ [lineNum, chPos], [lineNum, chPos + content.length - 1], ]) as TableCellNode; tableCell.stringContent = trimmed.replace(/\\\|/g, '|'); // replace esacped pipe(\|) tableCell.startIdx = cells.length; tableCell.endIdx = cells.length; tableCell.lineOffsets = [chPosStart - 1]; tableCell.paddingLeft = paddingLeft; tableCell.paddingRight = paddingRight; cells.push(tableCell); chPos += content.length + 1; } return cells; } function getColumnFromDelimCell(cellNode: TableCellNode) { let align = null; const content = cellNode.stringContent!; const firstCh = content[0]; const lastCh = content[content.length - 1]; if (lastCh === ':') { align = firstCh === ':' ? 'center' : 'right'; } else if (firstCh === ':') { align = 'left'; } return { align } as TableColumn; } export const tableHead: BlockStart = (parser, container) => { const stringContent = container.stringContent!; if (container.type === 'paragraph' && !parser.indented && !parser.blank) { const lastNewLineIdx = stringContent.length - 1; const lastLineStartIdx = stringContent.lastIndexOf('\n', lastNewLineIdx - 1) + 1; const headerContent = stringContent.slice(lastLineStartIdx, lastNewLineIdx); const delimContent = parser.currentLine.slice(parser.nextNonspace); const [headerOffset, headerCells] = parseRowContent(headerContent); const [delimOffset, delimCells] = parseRowContent(delimContent); const reValidDelimCell = /^[ \t]*:?-+:?[ \t]*$/; if ( // not checking if the number of header cells and delimiter cells are the same // to consider the case of merged-column (via plugin) !headerCells.length || !delimCells.length || delimCells.some((cell) => !reValidDelimCell.test(cell)) || // to prevent to regard setTextHeading as tabel delim cell with 'disallowDeepHeading' option (delimCells.length === 1 && delimContent.indexOf('|') !== 0) ) { return Matched.None; } const lineOffsets = container.lineOffsets!; const firstLineNum = parser.lineNumber - 1; const firstLineStart = last(lineOffsets) + 1; const table = createNode('table', [ [firstLineNum, firstLineStart], [parser.lineNumber, parser.offset], ]); // eslint-disable-next-line arrow-body-style table.columns = delimCells.map(() => ({ align: null })); container.insertAfter(table); if (lineOffsets.length === 1) { container.unlink(); } else { container.stringContent = stringContent.slice(0, lastLineStartIdx); const paraLastLineStartIdx = stringContent.lastIndexOf('\n', lastLineStartIdx - 2) + 1; const paraLastLineLen = lastLineStartIdx - paraLastLineStartIdx - 1; parser.lastLineLength = lineOffsets[lineOffsets.length - 2] + paraLastLineLen; parser.finalize(container, firstLineNum - 1); } parser.advanceOffset(parser.currentLine.length - parser.offset, false); const tableHead = createNode('tableHead', [ [firstLineNum, firstLineStart], [parser.lineNumber, parser.offset], ] as Sourcepos); table.appendChild(tableHead); const tableHeadRow = createNode('tableRow', [ [firstLineNum, firstLineStart], [firstLineNum, firstLineStart + headerContent.length - 1], ]); const tableDelimRow = createNode('tableDelimRow', [ [parser.lineNumber, parser.nextNonspace + 1], [parser.lineNumber, parser.offset], ]); tableHead.appendChild(tableHeadRow); tableHead.appendChild(tableDelimRow); generateTableCells( 'tableCell', headerCells, firstLineNum, firstLineStart + headerOffset ).forEach((cellNode) => { tableHeadRow.appendChild(cellNode); }); const delimCellNodes = generateTableCells( 'tableDelimCell', delimCells, parser.lineNumber, parser.nextNonspace + 1 + delimOffset ); delimCellNodes.forEach((cellNode) => { tableDelimRow.appendChild(cellNode); }); table.columns = delimCellNodes.map(getColumnFromDelimCell); parser.tip = table; return Matched.Leaf; } return Matched.None; }; export const tableBody: BlockStart = (parser, container) => { if ( (container.type !== 'table' && container.type !== 'tableBody') || (!parser.blank && parser.currentLine.indexOf('|') === -1) ) { return Matched.None; } parser.advanceOffset(parser.currentLine.length - parser.offset, false); if (parser.blank) { let table = container; if (container.type === 'tableBody') { table = container.parent as TableNode; parser.finalize(container, parser.lineNumber - 1); } parser.finalize(table, parser.lineNumber - 1); return Matched.None; } let tableBody = container; if (container.type === 'table') { tableBody = parser.addChild('tableBody', parser.nextNonspace); tableBody.stringContent = null; } const tableRow = createNode('tableRow', [ [parser.lineNumber, parser.nextNonspace + 1], [parser.lineNumber, parser.currentLine.length], ]); tableBody.appendChild(tableRow); const table = tableBody.parent as TableNode; const content = parser.currentLine.slice(parser.nextNonspace); const [offset, cellContents] = parseRowContent(content); generateTableCells( 'tableCell', cellContents, parser.lineNumber, parser.nextNonspace + 1 + offset ).forEach((cellNode, idx) => { if (idx >= table.columns.length) { cellNode.ignored = true; } tableRow.appendChild(cellNode); }); return Matched.Leaf; }; ================================================ FILE: libs/toastmark/src/commonmark/gfm/taskListItem.ts ================================================ import { Parser } from '../blocks'; import { ListNode, BlockNode } from '../node'; const reTaskListItemMarker = /^\[([ \txX])\][ \t]+/; // finalize for block handler export function taskListItemFinalize(_: Parser, block: ListNode) { if (block.firstChild && block.firstChild.type === 'paragraph') { const p = block.firstChild as BlockNode; const m = p.stringContent!.match(reTaskListItemMarker); if (m) { const mLen = m[0].length; p.stringContent = p.stringContent!.substring(mLen - 1); p.sourcepos![0][1] += mLen; p.lineOffsets![0] += mLen; block.listData!.task = true; block.listData!.checked = /[xX]/.test(m[1]); } } } ================================================ FILE: libs/toastmark/src/commonmark/inlines.ts ================================================ import { InlineNodeType, Sourcepos, CustomBlockMdNode } from '@t/node'; import { RefMap, RefLinkCandidateMap, RefDefCandidateMap, ParserOptions } from '@t/parser'; import { Node, BlockNode, isHeading, LinkNode, createNode, text, CustomInlineNode } from './node'; import { repeat, normalizeURI, unescapeString, ESCAPABLE, ENTITY } from './common'; import { reHtmlTag } from './rawHtml'; import fromCodePoint from './from-code-point'; import { decodeHTML } from 'entities'; import NodeWalker from './nodeWalker'; import { convertExtAutoLinks } from './gfm/autoLinks'; import { last, normalizeReference } from '../helper'; import { createRefDefState } from '../toastmark'; export const C_NEWLINE = 10; const C_ASTERISK = 42; const C_UNDERSCORE = 95; const C_BACKTICK = 96; const C_OPEN_BRACKET = 91; const C_CLOSE_BRACKET = 93; const C_TILDE = 126; const C_LESSTHAN = 60; const C_BANG = 33; const C_BACKSLASH = 92; const C_AMPERSAND = 38; const C_OPEN_PAREN = 40; const C_CLOSE_PAREN = 41; const C_COLON = 58; const C_SINGLEQUOTE = 39; const C_DOUBLEQUOTE = 34; const C_DOLLAR = 36; // Some regexps used in inline parser: const ESCAPED_CHAR = `\\\\${ESCAPABLE}`; const rePunctuation = new RegExp( /[!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/ ); const reLinkTitle = new RegExp( `^(?:"(${ESCAPED_CHAR}|[^"\\x00])*"` + `|` + `'(${ESCAPED_CHAR}|[^'\\x00])*'` + `|` + `\\((${ESCAPED_CHAR}|[^()\\x00])*\\))` ); const reLinkDestinationBraces = /^(?:<(?:[^<>\n\\\x00]|\\.)*>)/; const reEscapable = new RegExp(`^${ESCAPABLE}`); const reEntityHere = new RegExp(`^${ENTITY}`, 'i'); const reTicks = /`+/; const reTicksHere = /^`+/; const reEllipses = /\.\.\./g; const reDash = /--+/g; const reEmailAutolink = /^<([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/; const reAutolink = /^<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>/i; const reSpnl = /^ *(?:\n *)?/; const reWhitespaceChar = /^[ \t\n\x0b\x0c\x0d]/; const reUnicodeWhitespaceChar = /^\s/; const reFinalSpace = / *$/; const reInitialSpace = /^ */; const reSpaceAtEndOfLine = /^ *(?:\n|$)/; const reLinkLabel = /^\[(?:[^\\\[\]]|\\.){0,1000}\]/; // Matches a string of non-special characters. const reMain = /^[^\n`\[\]\\!<&*_'"~$]+/m; type DelimiterCC = | typeof C_ASTERISK | typeof C_UNDERSCORE | typeof C_SINGLEQUOTE | typeof C_DOUBLEQUOTE | typeof C_TILDE | typeof C_DOLLAR; type Delimiter = { cc: DelimiterCC; numdelims: number; origdelims: number; node: Node; previous: Delimiter | null; next: Delimiter | null; canOpen: boolean; canClose: boolean; }; type Bracket = { node: Node; previous: Bracket | null; previousDelimiter: Delimiter | null; index: number; image: boolean; active: boolean; bracketAfter?: boolean; startpos: [number, number]; }; export class InlineParser { // An InlineParser keeps track of a subject (a string to be parsed) // and a position in that subject. private subject = ''; private delimiters: Delimiter | null = null; // used by handleDelim method private brackets: Bracket | null = null; private pos = 0; private lineStartNum = 0; private lineIdx = 0; private lineOffsets: number[] = [0]; private linePosOffset = 0; public refMap: RefMap = {}; public refLinkCandidateMap: RefLinkCandidateMap = {}; public refDefCandidateMap: RefDefCandidateMap = {}; public options: ParserOptions; constructor(options: ParserOptions) { this.options = options; } sourcepos(start: number): [number, number]; sourcepos(start: number, end: number): Sourcepos; sourcepos(start: number, end?: number): [number, number] | Sourcepos { const linePosOffset = this.linePosOffset + this.lineOffsets[this.lineIdx]; const lineNum = this.lineStartNum + this.lineIdx; const startpos = [lineNum, start + linePosOffset]; if (typeof end === 'number') { return [startpos, [lineNum, end + linePosOffset]] as Sourcepos; } return startpos as [number, number]; } nextLine() { this.lineIdx += 1; this.linePosOffset = -this.pos; } // If re matches at current position in the subject, advance // position in subject and return the match; otherwise return null. match(re: RegExp) { const m = re.exec(this.subject.slice(this.pos)); if (m === null) { return null; } this.pos += m.index + m[0].length; return m[0]; } // Returns the code for the character at the current subject position, or -1 // there are no more characters. peek() { if (this.pos < this.subject.length) { return this.subject.charCodeAt(this.pos); } return -1; } // Parse zero or more space characters, including at most one newline spnl() { this.match(reSpnl); return true; } // All of the parsers below try to match something at the current position // in the subject. If they succeed in matching anything, they // return the inline matched, advancing the subject. // Attempt to parse backticks, adding either a backtick code span or a // literal sequence of backticks. parseBackticks(block: BlockNode) { const startpos = this.pos + 1; const ticks = this.match(reTicksHere); if (ticks === null) { return false; } const afterOpenTicks = this.pos; let matched: string | null; while ((matched = this.match(reTicks)) !== null) { if (matched === ticks) { let contents = this.subject.slice(afterOpenTicks, this.pos - ticks.length); const sourcepos = this.sourcepos(startpos, this.pos); const lines = contents.split('\n'); if (lines.length > 1) { const lastLine = last(lines); this.lineIdx += lines.length - 1; this.linePosOffset = -(this.pos - lastLine.length - ticks.length); sourcepos[1] = this.sourcepos(this.pos); contents = lines.join(' '); } const node = createNode('code', sourcepos); if ( contents.length > 0 && contents.match(/[^ ]/) !== null && contents[0] == ' ' && contents[contents.length - 1] == ' ' ) { node.literal = contents.slice(1, contents.length - 1); } else { node.literal = contents; } node.tickCount = ticks.length; block.appendChild(node); return true; } } // If we got here, we didn't match a closing backtick sequence. this.pos = afterOpenTicks; block.appendChild(text(ticks, this.sourcepos(startpos, this.pos - 1))); return true; } // Parse a backslash-escaped special character, adding either the escaped // character, a hard line break (if the backslash is followed by a newline), // or a literal backslash to the block's children. Assumes current character // is a backslash. parseBackslash(block: BlockNode) { const subj = this.subject; let node: Node; this.pos += 1; const startpos = this.pos; if (this.peek() === C_NEWLINE) { this.pos += 1; node = createNode('linebreak', this.sourcepos(this.pos - 1, this.pos)); block.appendChild(node); this.nextLine(); } else if (reEscapable.test(subj.charAt(this.pos))) { block.appendChild(text(subj.charAt(this.pos), this.sourcepos(startpos, this.pos))); this.pos += 1; } else { block.appendChild(text('\\', this.sourcepos(startpos, startpos))); } return true; } // Attempt to parse an autolink (URL or email in pointy brackets). parseAutolink(block: BlockNode) { let m: string | null; let dest: string; let node: LinkNode; const startpos = this.pos + 1; if ((m = this.match(reEmailAutolink))) { dest = m.slice(1, m.length - 1); node = createNode('link', this.sourcepos(startpos, this.pos)); node.destination = normalizeURI(`mailto:${dest}`); node.title = ''; node.appendChild(text(dest, this.sourcepos(startpos + 1, this.pos - 1))); block.appendChild(node); return true; } if ((m = this.match(reAutolink))) { dest = m.slice(1, m.length - 1); node = createNode('link', this.sourcepos(startpos, this.pos)); node.destination = normalizeURI(dest); node.title = ''; node.appendChild(text(dest, this.sourcepos(startpos + 1, this.pos - 1))); block.appendChild(node); return true; } return false; } // Attempt to parse a raw HTML tag. parseHtmlTag(block: BlockNode) { const startpos = this.pos + 1; const m = this.match(reHtmlTag); if (m === null) { return false; } const node = createNode('htmlInline', this.sourcepos(startpos, this.pos)); node.literal = m; block.appendChild(node); return true; } // Scan a sequence of characters with code cc, and return information about // the number of delimiters and whether they are positioned such that // they can open and/or close emphasis or strong emphasis. A utility // function for strong/emph parsing. scanDelims(cc: number) { let numdelims = 0; const startpos = this.pos; if (cc === C_SINGLEQUOTE || cc === C_DOUBLEQUOTE) { numdelims++; this.pos++; } else { while (this.peek() === cc) { numdelims++; this.pos++; } } if (numdelims === 0 || (numdelims < 2 && (cc === C_TILDE || cc === C_DOLLAR))) { this.pos = startpos; return null; } const charBefore = startpos === 0 ? '\n' : this.subject.charAt(startpos - 1); const ccAfter = this.peek(); let charAfter: string; if (ccAfter === -1) { charAfter = '\n'; } else { charAfter = fromCodePoint(ccAfter); } const afterIsWhitespace = reUnicodeWhitespaceChar.test(charAfter); const afterIsPunctuation = rePunctuation.test(charAfter); const beforeIsWhitespace = reUnicodeWhitespaceChar.test(charBefore); const beforeIsPunctuation = rePunctuation.test(charBefore); const leftFlanking = !afterIsWhitespace && (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation); const rightFlanking = !beforeIsWhitespace && (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation); let canOpen: boolean; let canClose: boolean; if (cc === C_UNDERSCORE) { canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation); canClose = rightFlanking && (!leftFlanking || afterIsPunctuation); } else if (cc === C_SINGLEQUOTE || cc === C_DOUBLEQUOTE) { canOpen = leftFlanking && !rightFlanking; canClose = rightFlanking; } else if (cc === C_DOLLAR) { canOpen = !afterIsWhitespace; canClose = !beforeIsWhitespace; } else { canOpen = leftFlanking; canClose = rightFlanking; } this.pos = startpos; return { numdelims, canOpen, canClose }; } // Handle a delimiter marker for emphasis or a quote. handleDelim(cc: DelimiterCC, block: BlockNode) { const res = this.scanDelims(cc); if (!res) { return false; } const numdelims = res.numdelims; const startpos = this.pos + 1; let contents: string; this.pos += numdelims; if (cc === C_SINGLEQUOTE) { contents = '\u2019'; } else if (cc === C_DOUBLEQUOTE) { contents = '\u201C'; } else { contents = this.subject.slice(startpos - 1, this.pos); } const node = text(contents, this.sourcepos(startpos, this.pos)); block.appendChild(node); // Add entry to stack for this opener if ( (res.canOpen || res.canClose) && (this.options.smart || (cc !== C_SINGLEQUOTE && cc !== C_DOUBLEQUOTE)) ) { this.delimiters = { cc, numdelims, origdelims: numdelims, node, previous: this.delimiters, next: null, canOpen: res.canOpen, canClose: res.canClose, }; if (this.delimiters.previous) { this.delimiters.previous.next = this.delimiters; } } return true; } removeDelimiter(delim: Delimiter) { if (delim.previous !== null) { delim.previous.next = delim.next; } if (delim.next === null) { // top of stack this.delimiters = delim.previous; } else { delim.next.previous = delim.previous; } } removeDelimitersBetween(bottom: Delimiter, top: Delimiter) { if (bottom.next !== top) { bottom.next = top; top.previous = bottom; } } /** * Process all delimiters - emphasis, strong emphasis, strikethrough(gfm) * If the smart punctuation options is true, * convert single/double quotes to corresponding unicode characters. **/ processEmphasis(stackBottom: Delimiter | null) { let opener: Delimiter | null; let closer: Delimiter | null; let oldCloser: Delimiter | null; let openerInl: Node, closerInl: Node; let openerFound: boolean; let oddMatch = false; const openersBottom = { [C_UNDERSCORE]: [stackBottom, stackBottom, stackBottom], [C_ASTERISK]: [stackBottom, stackBottom, stackBottom], [C_SINGLEQUOTE]: [stackBottom], [C_DOUBLEQUOTE]: [stackBottom], [C_TILDE]: [stackBottom], [C_DOLLAR]: [stackBottom], }; // find first closer above stackBottom: closer = this.delimiters; while (closer !== null && closer.previous !== stackBottom) { closer = closer.previous; } // move forward, looking for closers, and handling each while (closer !== null) { const closercc = closer.cc; const closerEmph = closercc === C_UNDERSCORE || closercc === C_ASTERISK; if (!closer.canClose) { closer = closer.next; } else { // found emphasis closer. now look back for first matching opener: opener = closer.previous; openerFound = false; while ( opener !== null && opener !== stackBottom && opener !== openersBottom[closercc][closerEmph ? closer.origdelims % 3 : 0] ) { oddMatch = closerEmph && (closer.canOpen || opener.canClose) && closer.origdelims % 3 !== 0 && (opener.origdelims + closer.origdelims) % 3 === 0; if (opener.cc === closer.cc && opener.canOpen && !oddMatch) { openerFound = true; break; } opener = opener.previous; } oldCloser = closer; if (closerEmph || closercc === C_TILDE || closercc === C_DOLLAR) { if (!openerFound) { closer = closer.next; } else if (opener) { // (null opener check for type narrowing) // calculate actual number of delimiters used from closer const useDelims = closer.numdelims >= 2 && opener.numdelims >= 2 ? 2 : 1; const emptyDelims = closerEmph ? 0 : 1; openerInl = opener.node; closerInl = closer.node; // build contents for new emph element let nodeType: InlineNodeType = closerEmph ? useDelims === 1 ? 'emph' : 'strong' : 'strike'; if (closercc === C_DOLLAR) { nodeType = 'customInline'; } const newNode = createNode(nodeType); const openerEndPos = openerInl.sourcepos![1]; const closerStartPos = closerInl.sourcepos![0]; newNode.sourcepos = [ [openerEndPos[0], openerEndPos[1] - useDelims + 1], [closerStartPos[0], closerStartPos[1] + useDelims - 1], ]; openerInl.sourcepos![1][1] -= useDelims; closerInl.sourcepos![0][1] += useDelims; openerInl.literal = openerInl.literal!.slice(useDelims); closerInl.literal = closerInl.literal!.slice(useDelims); opener.numdelims -= useDelims; closer.numdelims -= useDelims; // remove used delimiters from stack elts and inlines let tmp = openerInl.next; let next; while (tmp && tmp !== closerInl) { next = tmp.next; tmp.unlink(); newNode.appendChild(tmp); tmp = next; } // build custom inline node if (closercc === C_DOLLAR) { const textNode = newNode.firstChild!; const literal = textNode.literal || ''; const [info] = literal.split(/\s/)!; (newNode as CustomInlineNode).info = info; if (literal.length <= info.length) { textNode.unlink(); } else { textNode.sourcepos![0][1] += info.length; textNode.literal = literal.replace(`${info} `, ''); } } openerInl.insertAfter(newNode); // remove elts between opener and closer in delimiters stack this.removeDelimitersBetween(opener, closer); // if opener has 0 delims, remove it and the inline // if opener has 1 delims and character is tilde, remove delimiter only if (opener.numdelims <= emptyDelims) { if (opener.numdelims === 0) { openerInl.unlink(); } this.removeDelimiter(opener); } // if closer has 0 delims, remove it and the inline // if closer has 1 delims and character is tilde, remove delimiter only if (closer.numdelims <= emptyDelims) { if (closer.numdelims === 0) { closerInl.unlink(); } const tempstack = closer.next; this.removeDelimiter(closer); closer = tempstack; } } } else if (closercc === C_SINGLEQUOTE) { closer.node.literal = '\u2019'; if (openerFound) { opener!.node.literal = '\u2018'; } closer = closer.next; } else if (closercc === C_DOUBLEQUOTE) { closer.node.literal = '\u201D'; if (openerFound) { opener!.node.literal = '\u201C'; } closer = closer.next; } if (!openerFound) { // Set lower bound for future searches for openers: openersBottom[closercc][closerEmph ? oldCloser.origdelims % 3 : 0] = oldCloser.previous; if (!oldCloser.canOpen) { // We can remove a closer that can't be an opener, // once we've seen there's no matching opener: this.removeDelimiter(oldCloser); } } } } // remove all delimiters while (this.delimiters !== null && this.delimiters !== stackBottom) { this.removeDelimiter(this.delimiters); } } // Attempt to parse link title (sans quotes), returning the string // or null if no match. parseLinkTitle() { const title = this.match(reLinkTitle); if (title === null) { return null; } // chop off quotes from title and unescape: return unescapeString(title.substr(1, title.length - 2)); } // Attempt to parse link destination, returning the string or null if no match. parseLinkDestination() { let res = this.match(reLinkDestinationBraces); if (res === null) { if (this.peek() === C_LESSTHAN) { return null; } // @TODO handrolled parser; res should be null or the string const savepos = this.pos; let openparens = 0; let c: number; while ((c = this.peek()) !== -1) { if (c === C_BACKSLASH && reEscapable.test(this.subject.charAt(this.pos + 1))) { this.pos += 1; if (this.peek() !== -1) { this.pos += 1; } } else if (c === C_OPEN_PAREN) { this.pos += 1; openparens += 1; } else if (c === C_CLOSE_PAREN) { if (openparens < 1) { break; } else { this.pos += 1; openparens -= 1; } } else if (reWhitespaceChar.exec(fromCodePoint(c)) !== null) { break; } else { this.pos += 1; } } if (this.pos === savepos && c !== C_CLOSE_PAREN) { return null; } if (openparens !== 0) { return null; } res = this.subject.substr(savepos, this.pos - savepos); return normalizeURI(unescapeString(res)); } // chop off surrounding <..>: return normalizeURI(unescapeString(res.substr(1, res.length - 2))); } // Attempt to parse a link label, returning number of characters parsed. parseLinkLabel() { const m = this.match(reLinkLabel); if (m === null || m.length > 1001) { return 0; } return m.length; } // Add open bracket to delimiter stack and add a text node to block's children. parseOpenBracket(block: BlockNode) { const startpos = this.pos; this.pos += 1; const node = text('[', this.sourcepos(this.pos, this.pos)); block.appendChild(node); // Add entry to stack for this opener this.addBracket(node, startpos, false); return true; } // IF next character is [, and ! delimiter to delimiter stack and // add a text node to block's children. Otherwise just add a text node. parseBang(block: BlockNode) { const startpos = this.pos; this.pos += 1; if (this.peek() === C_OPEN_BRACKET) { this.pos += 1; const node = text('![', this.sourcepos(this.pos - 1, this.pos)); block.appendChild(node); // Add entry to stack for this opener this.addBracket(node, startpos + 1, true); } else { const node = text('!', this.sourcepos(this.pos, this.pos)); block.appendChild(node); } return true; } // Try to match close bracket against an opening in the delimiter // stack. Add either a link or image, or a plain [ character, // to block's children. If there is a matching delimiter, // remove it from the delimiter stack. parseCloseBracket(block: BlockNode) { let dest: string | null = null; let title: string | null = null; let matched = false; this.pos += 1; const startpos = this.pos; // get last [ or ![ let opener = this.brackets; if (opener === null) { // no matched opener, just return a literal block.appendChild(text(']', this.sourcepos(startpos, startpos))); return true; } if (!opener.active) { // no matched opener, just return a literal block.appendChild(text(']', this.sourcepos(startpos, startpos))); // take opener off brackets stack this.removeBracket(); return true; } // If we got here, open is a potential opener const isImage = opener.image; // Check to see if we have a link/image const savepos = this.pos; // Inline link? if (this.peek() === C_OPEN_PAREN) { this.pos++; if ( this.spnl() && (dest = this.parseLinkDestination()) !== null && this.spnl() && // make sure there's a space before the title: ((reWhitespaceChar.test(this.subject.charAt(this.pos - 1)) && (title = this.parseLinkTitle())) || true) && this.spnl() && this.peek() === C_CLOSE_PAREN ) { this.pos += 1; matched = true; } else { this.pos = savepos; } } let refLabel = ''; if (!matched) { // Next, see if there's a link label const beforelabel = this.pos; const n = this.parseLinkLabel(); if (n > 2) { refLabel = this.subject.slice(beforelabel, beforelabel + n); } else if (!opener.bracketAfter) { // Empty or missing second label means to use the first label as the reference. // The reference must not contain a bracket. If we know there's a bracket, we don't even bother checking it. refLabel = this.subject.slice(opener.index, startpos); } if (n === 0) { // If shortcut reference link, rewind before spaces we skipped. this.pos = savepos; } if (refLabel) { refLabel = normalizeReference(refLabel); // lookup rawlabel in refMap const link = this.refMap[refLabel]; if (link) { dest = link.destination; title = link.title; matched = true; } } } if (matched) { const node = createNode(isImage ? 'image' : 'link'); node.destination = dest; node.title = title || ''; node.sourcepos = [opener.startpos, this.sourcepos(this.pos)]; let tmp = opener.node.next; let next: Node | null; while (tmp) { next = tmp.next; tmp.unlink(); node.appendChild(tmp); tmp = next; } block.appendChild(node); this.processEmphasis(opener.previousDelimiter); this.removeBracket(); opener.node.unlink(); // We remove this bracket and processEmphasis will remove later delimiters. // Now, for a link, we also deactivate earlier link openers. // (no links in links) if (!isImage) { opener = this.brackets; while (opener !== null) { if (!opener.image) { opener.active = false; // deactivate this opener } opener = opener.previous; } } if (this.options.referenceDefinition) { this.refLinkCandidateMap[block.id] = { node: block, refLabel }; } return true; } // no match this.removeBracket(); // remove this opener from stack this.pos = startpos; block.appendChild(text(']', this.sourcepos(startpos, startpos))); if (this.options.referenceDefinition) { this.refLinkCandidateMap[block.id] = { node: block, refLabel }; } return true; } addBracket(node: Node, index: number, image: boolean) { if (this.brackets !== null) { this.brackets.bracketAfter = true; } this.brackets = { node, startpos: this.sourcepos(index + (image ? 0 : 1)), previous: this.brackets, previousDelimiter: this.delimiters, index, image, active: true, }; } removeBracket() { if (this.brackets) { this.brackets = this.brackets.previous; } } // Attempt to parse an entity. parseEntity(block: BlockNode) { let m; const startpos = this.pos + 1; if ((m = this.match(reEntityHere))) { block.appendChild(text(decodeHTML(m), this.sourcepos(startpos, this.pos))); return true; } return false; } // Parse a run of ordinary characters, or a single character with // a special meaning in markdown, as a plain string. parseString(block: BlockNode) { let m; const startpos = this.pos + 1; if ((m = this.match(reMain))) { if (this.options.smart) { const lit = m.replace(reEllipses, '\u2026').replace(reDash, function (chars) { let enCount = 0; let emCount = 0; if (chars.length % 3 === 0) { // If divisible by 3, use all em dashes emCount = chars.length / 3; } else if (chars.length % 2 === 0) { // If divisible by 2, use all en dashes enCount = chars.length / 2; } else if (chars.length % 3 === 2) { // If 2 extra dashes, use en dash for last 2; em dashes for rest enCount = 1; emCount = (chars.length - 2) / 3; } else { // Use en dashes for last 4 hyphens; em dashes for rest enCount = 2; emCount = (chars.length - 4) / 3; } return repeat('\u2014', emCount) + repeat('\u2013', enCount); }); block.appendChild(text(lit, this.sourcepos(startpos, this.pos))); } else { const node = text(m, this.sourcepos(startpos, this.pos)); block.appendChild(node); } return true; } return false; } // Parse a newline. If it was preceded by two spaces, return a hard // line break; otherwise a soft line break. parseNewline(block: BlockNode) { this.pos += 1; // assume we're at a \n // check previous node for trailing spaces const lastc = block.lastChild; if (lastc && lastc.type === 'text' && lastc.literal![lastc.literal!.length - 1] === ' ') { const hardbreak = lastc.literal![lastc.literal!.length - 2] === ' '; const litLen = lastc.literal!.length; lastc.literal = lastc.literal!.replace(reFinalSpace, ''); const finalSpaceLen = litLen - lastc.literal.length; lastc.sourcepos![1][1] -= finalSpaceLen; block.appendChild( createNode( hardbreak ? 'linebreak' : 'softbreak', this.sourcepos(this.pos - finalSpaceLen, this.pos) ) ); } else { block.appendChild(createNode('softbreak', this.sourcepos(this.pos, this.pos))); } this.nextLine(); this.match(reInitialSpace); // gobble leading spaces in next line return true; } // Attempt to parse a link reference, modifying refmap. parseReference(block: BlockNode, refMap: RefMap) { if (!this.options.referenceDefinition) { return 0; } this.subject = block.stringContent!; this.pos = 0; let title = null; const startpos = this.pos; // label: const matchChars = this.parseLinkLabel(); if (matchChars === 0) { return 0; } const rawlabel = this.subject.substr(0, matchChars); // colon: if (this.peek() === C_COLON) { this.pos++; } else { this.pos = startpos; return 0; } // link url this.spnl(); const dest = this.parseLinkDestination(); if (dest === null) { this.pos = startpos; return 0; } const beforetitle = this.pos; this.spnl(); if (this.pos !== beforetitle) { title = this.parseLinkTitle(); } if (title === null) { title = ''; // rewind before spaces this.pos = beforetitle; } // make sure we're at line end: let atLineEnd = true; if (this.match(reSpaceAtEndOfLine) === null) { if (title === '') { atLineEnd = false; } else { // the potential title we found is not at the line end, // but it could still be a legal link reference if we // discard the title title = ''; // rewind before spaces this.pos = beforetitle; // and instead check if the link URL is at the line end atLineEnd = this.match(reSpaceAtEndOfLine) !== null; } } if (!atLineEnd) { this.pos = startpos; return 0; } const normalLabel = normalizeReference(rawlabel); if (normalLabel === '') { // label must contain non-whitespace characters this.pos = startpos; return 0; } const sourcepos = this.getReferenceDefSourcepos(block); block.sourcepos![0][0] = sourcepos[1][0] + 1; const node = createNode('refDef', sourcepos); node.title = title; node.dest = dest; node.label = normalLabel; block.insertBefore(node); if (!refMap[normalLabel]) { refMap[normalLabel] = createRefDefState(node); } else { this.refDefCandidateMap[node.id] = node; } return this.pos - startpos; } mergeTextNodes(walker: NodeWalker) { let event; let textNodes: Node[] = []; while ((event = walker.next())) { const { entering, node } = event; if (entering && node.type === 'text') { textNodes.push(node); } else if (textNodes.length === 1) { textNodes = []; } else if (textNodes.length > 1) { const firstNode = textNodes[0]; const lastNode = textNodes[textNodes.length - 1]; if (firstNode.sourcepos && lastNode.sourcepos) { firstNode.sourcepos![1] = lastNode.sourcepos![1]; } firstNode.next = lastNode.next; if (firstNode.next) { firstNode.next.prev = firstNode; } for (let i = 1; i < textNodes.length; i += 1) { firstNode.literal! += textNodes[i].literal; textNodes[i].unlink(); } textNodes = []; } } } getReferenceDefSourcepos(block: BlockNode): Sourcepos { const lines = block.stringContent!.split(/\n|\r\n/); let passedUrlLine = false; let quotationCount = 0; let lastLineOffset = { line: 0, ch: 0 }; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (reWhitespaceChar.test(line)) { break; } if (/\:/.test(line) && quotationCount === 0) { if (passedUrlLine) { break; } const lineOffset = line.indexOf(':') === line.length - 1 ? i + 1 : i; lastLineOffset = { line: lineOffset, ch: lines[lineOffset].length }; passedUrlLine = true; } // should consider extendable title const matched = line.match(/'|"/g); if (matched) { quotationCount += matched.length; } if (quotationCount === 2) { lastLineOffset = { line: i, ch: line.length }; break; } } return [ [block.sourcepos![0][0], block.sourcepos![0][1]], [block.sourcepos![0][0] + lastLineOffset.line, lastLineOffset.ch], ]; } // Parse the next inline element in subject, advancing subject position. // On success, add the result to block's children and return true. // On failure, return false. parseInline(block: BlockNode) { let res = false; const c = this.peek(); if (c === -1) { return false; } switch (c) { case C_NEWLINE: res = this.parseNewline(block); break; case C_BACKSLASH: res = this.parseBackslash(block); break; case C_BACKTICK: res = this.parseBackticks(block); break; case C_ASTERISK: case C_UNDERSCORE: case C_TILDE: case C_DOLLAR: res = this.handleDelim(c, block); break; case C_SINGLEQUOTE: case C_DOUBLEQUOTE: res = !!this.options?.smart && this.handleDelim(c, block); break; case C_OPEN_BRACKET: res = this.parseOpenBracket(block); break; case C_BANG: res = this.parseBang(block); break; case C_CLOSE_BRACKET: res = this.parseCloseBracket(block); break; case C_LESSTHAN: res = this.parseAutolink(block) || this.parseHtmlTag(block); break; case C_AMPERSAND: if (!(block as CustomBlockMdNode).disabledEntityParse) { res = this.parseEntity(block); } break; default: res = this.parseString(block); break; } if (!res) { this.pos += 1; block.appendChild(text(fromCodePoint(c), this.sourcepos(this.pos, this.pos + 1))); } return true; } // Parse string content in block into inline children, // using refmap to resolve references. parse(block: BlockNode) { this.subject = block.stringContent!.trim(); this.pos = 0; this.delimiters = null; this.brackets = null; this.lineOffsets = block.lineOffsets || [0]; this.lineIdx = 0; this.linePosOffset = 0; this.lineStartNum = block.sourcepos![0][0]; if (isHeading(block)) { this.lineOffsets[0] += block.level + 1; } while (this.parseInline(block)) {} block.stringContent = null; // allow raw string to be garbage collected this.processEmphasis(null); this.mergeTextNodes(block.walker()); const { extendedAutolinks, customParser } = this.options; if (extendedAutolinks) { convertExtAutoLinks(block.walker(), extendedAutolinks); } if (customParser && block.firstChild) { let event; const walker = block.firstChild.walker(); while ((event = walker.next())) { const { node, entering } = event; if (customParser[node.type]) { customParser[node.type]!(node, { entering, options: this.options }); } } } } } ================================================ FILE: libs/toastmark/src/commonmark/node.ts ================================================ import { BlockMdNode, BlockNodeType, CodeBlockMdNode, CodeMdNode, CustomBlockMdNode, CustomInlineMdNode, HeadingMdNode, HtmlBlockMdNode, LinkMdNode, ListData, ListMdNode, MdNode, MdNodeType, RefDefMdNode, Sourcepos, TableCellMdNode, TableColumn, TableMdNode, } from '@t/node'; import NodeWalker from './nodeWalker'; export function isContainer(node: Node) { switch (node.type) { case 'document': case 'blockQuote': case 'list': case 'item': case 'paragraph': case 'heading': case 'emph': case 'strong': case 'strike': case 'link': case 'image': case 'table': case 'tableHead': case 'tableBody': case 'tableRow': case 'tableCell': case 'tableDelimRow': case 'customInline': return true; default: return false; } } let lastNodeId = 1; let nodeMap: { [key: number]: Node } = {}; export function getNodeById(id: number) { return nodeMap[id]; } export function removeNodeById(id: number) { delete nodeMap[id]; } export function removeAllNode() { nodeMap = {}; } export class Node implements MdNode { type: MdNodeType; id: number; parent: Node | null = null; prev: Node | null = null; next: Node | null = null; sourcepos?: Sourcepos; // only for container node firstChild: Node | null = null; lastChild: Node | null = null; // only for leaf node literal: string | null = null; constructor(nodeType: MdNodeType, sourcepos?: Sourcepos) { if (nodeType === 'document') { this.id = -1; } else { this.id = lastNodeId++; } this.type = nodeType; this.sourcepos = sourcepos; nodeMap![this.id] = this; } isContainer() { return isContainer(this); } unlink() { if (this.prev) { this.prev.next = this.next; } else if (this.parent) { this.parent.firstChild = this.next; } if (this.next) { this.next.prev = this.prev; } else if (this.parent) { this.parent.lastChild = this.prev; } this.parent = null; this.next = null; this.prev = null; } replaceWith(node: Node) { this.insertBefore(node); this.unlink(); } insertAfter(sibling: Node) { sibling.unlink(); sibling.next = this.next; if (sibling.next) { sibling.next.prev = sibling; } sibling.prev = this; this.next = sibling; if (this.parent) { sibling.parent = this.parent; if (!sibling.next) { sibling.parent.lastChild = sibling; } } } insertBefore(sibling: Node) { sibling.unlink(); sibling.prev = this.prev; if (sibling.prev) { sibling.prev.next = sibling; } sibling.next = this; this.prev = sibling; sibling.parent = this.parent; if (!sibling.prev) { sibling.parent!.firstChild = sibling; } } appendChild(child: Node) { child.unlink(); child.parent = this; if (this.lastChild) { this.lastChild.next = child; child.prev = this.lastChild; this.lastChild = child; } else { this.firstChild = child; this.lastChild = child; } } prependChild(child: Node) { child.unlink(); child.parent = this; if (this.firstChild) { this.firstChild.prev = child; child.next = this.firstChild; this.firstChild = child; } else { this.firstChild = child; this.lastChild = child; } } walker() { return new NodeWalker(this); } } export class BlockNode extends Node implements BlockMdNode { type: BlockNodeType; // temporal data (for parsing) open = true; lineOffsets: number[] | null = null; stringContent: string | null = null; lastLineBlank = false; lastLineChecked = false; constructor(nodeType: BlockNodeType, sourcepos?: Sourcepos) { super(nodeType, sourcepos); this.type = nodeType; } } export class ListNode extends BlockNode implements ListMdNode { listData: ListData | null = null; } export class HeadingNode extends BlockNode implements HeadingMdNode { level = 0; headingType: 'atx' | 'setext' = 'atx'; } export class CodeBlockNode extends BlockNode implements CodeBlockMdNode { isFenced = false; fenceChar: string | null = null; fenceLength = 0; fenceOffset = -1; info: string | null = null; infoPadding = 0; } export class TableNode extends BlockNode implements TableMdNode { columns: TableColumn[] = []; } export class TableCellNode extends BlockNode implements TableCellMdNode { startIdx = 0; endIdx = 0; paddingLeft = 0; paddingRight = 0; ignored = false; } export class RefDefNode extends BlockNode implements RefDefMdNode { title = ''; dest = ''; label = ''; } export class CustomBlockNode extends BlockNode implements CustomBlockMdNode { syntaxLength = 0; offset = -1; info = ''; } export class HtmlBlockNode extends BlockNode implements HtmlBlockMdNode { htmlBlockType = -1; } export class LinkNode extends Node implements LinkMdNode { destination: string | null = null; title: string | null = null; extendedAutolink = false; lastChild!: Node; } export class CodeNode extends Node implements CodeMdNode { tickCount = 0; } export class CustomInlineNode extends Node implements CustomInlineMdNode { info = ''; } export function createNode(type: 'heading', sourcepos?: Sourcepos): HeadingNode; export function createNode(type: 'list' | 'item', sourcepos?: Sourcepos): ListNode; export function createNode(type: 'codeBlock', sourcepos?: Sourcepos): CodeBlockNode; export function createNode(type: 'htmlBlock', sourcepos?: Sourcepos): HtmlBlockNode; export function createNode(type: 'link' | 'image', sourcepos?: Sourcepos): LinkNode; export function createNode(type: 'code', sourcepos?: Sourcepos): CodeNode; export function createNode(type: 'table', sourcepos?: Sourcepos): TableNode; export function createNode(type: 'tableCell', sourcepos?: Sourcepos): TableNode; export function createNode(type: 'refDef', sourcepos?: Sourcepos): RefDefNode; export function createNode(type: 'customBlock', sourcepos?: Sourcepos): CustomBlockNode; export function createNode(type: BlockNodeType, sourcepos?: Sourcepos): BlockNode; export function createNode(type: MdNodeType, sourcepos?: Sourcepos): Node; export function createNode(type: MdNodeType, sourcepos?: Sourcepos) { switch (type) { case 'heading': return new HeadingNode(type, sourcepos); case 'list': case 'item': return new ListNode(type, sourcepos); case 'link': case 'image': return new LinkNode(type, sourcepos); case 'codeBlock': return new CodeBlockNode(type, sourcepos); case 'htmlBlock': return new HtmlBlockNode(type, sourcepos); case 'table': return new TableNode(type, sourcepos); case 'tableCell': return new TableCellNode(type, sourcepos); case 'document': case 'paragraph': case 'blockQuote': case 'thematicBreak': case 'tableRow': case 'tableBody': case 'tableHead': case 'frontMatter': return new BlockNode(type, sourcepos); case 'code': return new CodeNode(type, sourcepos); case 'refDef': return new RefDefNode(type, sourcepos); case 'customBlock': return new CustomBlockNode(type, sourcepos); case 'customInline': return new CustomInlineNode(type, sourcepos); default: return new Node(type, sourcepos) as Node; } } export function isCodeBlock(node: Node): node is CodeBlockNode { return node.type === 'codeBlock'; } export function isHtmlBlock(node: Node): node is HtmlBlockNode { return node.type === 'htmlBlock'; } export function isHeading(node: Node): node is HeadingNode { return node.type === 'heading'; } export function isList(node: Node): node is ListNode { return node.type === 'list'; } export function isTable(node: Node): node is TableNode { return node.type === 'table'; } export function isRefDef(node: Node): node is RefDefNode { return node.type === 'refDef'; } export function isCustomBlock(node: Node): node is CustomBlockNode { return node.type === 'customBlock'; } export function isCustomInline(node: Node) { return node.type === 'customInline'; } export function text(s: string, sourcepos?: Sourcepos) { const node = createNode('text', sourcepos); node.literal = s; return node; } ================================================ FILE: libs/toastmark/src/commonmark/nodeWalker.ts ================================================ import { NodeWalker as BaseNodeWalker } from '@t/node'; import { Node, isContainer } from './node'; export default class NodeWalker implements BaseNodeWalker { current: Node | null; root: Node; entering: boolean; constructor(root: Node) { this.current = root; this.root = root; this.entering = true; } next() { const cur = this.current; const entering = this.entering; if (cur === null) { return null; } const container = isContainer(cur); if (entering && container) { if (cur.firstChild) { this.current = cur.firstChild; this.entering = true; } else { // stay on node but exit this.entering = false; } } else if (cur === this.root) { this.current = null; } else if (cur.next === null) { this.current = cur.parent; this.entering = false; } else { this.current = cur.next; this.entering = true; } return { entering, node: cur }; } resumeAt(node: Node, entering: boolean) { this.current = node; this.entering = entering === true; } } ================================================ FILE: libs/toastmark/src/commonmark/rawHtml.ts ================================================ const TAGNAME = '[A-Za-z][A-Za-z0-9-]*'; const ATTRIBUTENAME = '[a-zA-Z_:][a-zA-Z0-9:._-]*'; const UNQUOTEDVALUE = '[^"\'=<>`\\x00-\\x20]+'; const SINGLEQUOTEDVALUE = "'[^']*'"; const DOUBLEQUOTEDVALUE = '"[^"]*"'; const ATTRIBUTEVALUE = `(?:${UNQUOTEDVALUE}|${SINGLEQUOTEDVALUE}|${DOUBLEQUOTEDVALUE})`; const ATTRIBUTEVALUESPEC = `${'(?:\\s*=\\s*'}${ATTRIBUTEVALUE})`; const ATTRIBUTE = `${'(?:\\s+'}${ATTRIBUTENAME}${ATTRIBUTEVALUESPEC}?)`; export const OPENTAG = `<${TAGNAME}${ATTRIBUTE}*\\s*/?>`; export const CLOSETAG = `</${TAGNAME}\\s*[>]`; const HTMLCOMMENT = '<!---->|<!--(?:-?[^>-])(?:-?[^-])*-->'; const PROCESSINGINSTRUCTION = '[<][?].*?[?][>]'; const DECLARATION = '<![A-Z]+\\s+[^>]*>'; const CDATA = '<!\\[CDATA\\[[\\s\\S]*?\\]\\]>'; const HTMLTAG = `(?:${OPENTAG}|${CLOSETAG}|${HTMLCOMMENT}|${PROCESSINGINSTRUCTION}|${DECLARATION}|${CDATA})`; export const reHtmlTag = new RegExp(`^${HTMLTAG}`, 'i'); ================================================ FILE: libs/toastmark/src/helper.ts ================================================ export function last<T>(arr: T[]): T; export function last(arr: string): string; export function last<T>(arr: T[] | string) { return arr[arr.length - 1]; } // normalize a reference in reference link (remove []s, trim, // collapse internal space, unicode case fold. // See commonmark/commonmark.js#168. export function normalizeReference(str: string) { return str .slice(1, str.length - 1) .trim() .replace(/[ \t\r\n]+/, ' ') .toLowerCase() .toUpperCase(); } export function iterateObject<T>(obj: T, iteratee: (key: keyof T, value: T[keyof T]) => void) { Object.keys(obj).forEach((key) => { iteratee(key as keyof T, obj[key as keyof T]); }); } export function omit<T extends object>(obj: T, ...propNames: (keyof T)[]) { const resultMap = { ...obj }; propNames.forEach((key) => { delete resultMap[key]; }); return resultMap; } export function isEmptyObj<T extends object>(obj: T) { return !Object.keys(obj).length; } export function clearObj<T extends object>(obj: T) { Object.keys(obj).forEach((key) => { delete obj[key as keyof T]; }); } ================================================ FILE: libs/toastmark/src/html/__test__/render.spec.ts ================================================ import { source } from 'common-tags'; import { OpenTagToken } from '@t/renderer'; import { Parser } from '../../commonmark/blocks'; import { Renderer } from '../renderer'; const parser = new Parser(); describe('softbreak options', () => { it('softbreak option value should be used as a raw HTML string', () => { const renderer = new Renderer({ softbreak: '\n<br />\n', }); const html = renderer.render(parser.parse('Hello\nWorld')); expect(html).toBe('<p>Hello\n<br />\nWorld</p>\n'); }); }); describe('nodeId options', () => { const renderer = new Renderer({ nodeId: true }); it('every html tag corresponds to container node should contain data-nodeid', () => { const root = parser.parse('*Hello* **World**'); const para = root.firstChild!; const emph = para.firstChild!; const strong = emph.next!.next!; expect(renderer.render(root)).toBe( [ `<p data-nodeid="${para.id}">`, `<em data-nodeid="${emph.id}">Hello</em> `, `<strong data-nodeid="${strong.id}">World</strong>`, '</p>\n', ].join('') ); }); it('htmlBlock should be wrapped by div to contain data-nodeid', () => { const root = parser.parse('<li>Hi</li>'); const htmlBlock = root.firstChild!; expect(renderer.render(root)).toBe(`<div data-nodeid="${htmlBlock.id}"><li>Hi</li></div>\n`); }); it('only top-level tag for each node should contain data-nodeid', () => { const root = parser.parse('```\nHello\n```'); const codeBlock = root.firstChild!; expect(renderer.render(root)).toBe( `<pre data-nodeid="${codeBlock.id}"><code>Hello\n</code></pre>\n` ); }); }); describe('convertors options', () => { it('should pass the context object to convertor', () => { const spy = jest.fn(() => null); const options = { gfm: true, softbreak: '<br />\n', nodeId: true, }; const renderer = new Renderer({ ...options, convertors: { paragraph: spy, }, }); const root = parser.parse('Hello World'); renderer.render(root); expect(spy).toHaveBeenCalledTimes(2); const firstCall = spy.mock.calls[0] as any[]; expect(firstCall[0]).toBe(root.firstChild); expect(firstCall[1]).toMatchObject({ entering: true, leaf: false, options, }); const secondCall = spy.mock.calls[1] as any[]; expect(secondCall[0]).toBe(root.firstChild); expect(secondCall[1]).toMatchObject({ entering: false, leaf: false, options, }); }); it('context object has origin convertor', () => { const renderer = new Renderer({ convertors: { paragraph(_, { entering, origin }) { const result = origin!(); if (entering) { (result as OpenTagToken).classNames = ['my-class']; return result; } return result; }, }, }); const html = renderer.render(parser.parse('Hello World')); expect(html).toBe('<p class="my-class">Hello World</p>\n'); }); }); describe('gfm convertors', () => { it('should apply custom renderer without changing node type to lower case', () => { const spy = jest.fn(); const renderer = new Renderer({ gfm: true, convertors: { tableCell(_, { origin }) { spy(); return origin!(); }, }, }); const input = source` | a | | | | - | - | - | | | b | | | | | c | `; renderer.render(parser.parse(input)); expect(spy).toHaveBeenCalled(); }); }); ================================================ FILE: libs/toastmark/src/html/baseConvertors.ts ================================================ import { HTMLConvertorMap } from '@t/renderer'; import { Node, HeadingNode, CodeBlockNode, ListNode, LinkNode, CustomBlockNode, } from '../commonmark/node'; import { escapeXml } from '../commonmark/common'; import { filterDisallowedTags } from './tagFilter'; const CUSTOM_SYNTAX_LENGTH = 4; export const baseConvertors: HTMLConvertorMap = { heading(node, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: `h${(node as HeadingNode).level}`, outerNewLine: true, }; }, text(node) { return { type: 'text', content: node.literal!, }; }, softbreak(_, { options }) { return { type: 'html', content: options.softbreak, }; }, linebreak() { return { type: 'html', content: '<br />\n', }; }, emph(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'em', }; }, strong(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'strong', }; }, paragraph(node, { entering }) { const grandparent = node.parent?.parent; if (grandparent && grandparent.type === 'list') { if ((grandparent as ListNode).listData!.tight) { return null; } } return { type: entering ? 'openTag' : 'closeTag', tagName: 'p', outerNewLine: true, }; }, thematicBreak() { return { type: 'openTag', tagName: 'hr', outerNewLine: true, selfClose: true, }; }, blockQuote(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'blockquote', outerNewLine: true, innerNewLine: true, }; }, list(node, { entering }) { const { type, start } = (node as ListNode).listData!; const tagName = type === 'bullet' ? 'ul' : 'ol'; const attributes: Record<string, string> = {}; if (tagName === 'ol' && start !== null && start !== 1) { attributes.start = start.toString(); } return { type: entering ? 'openTag' : 'closeTag', tagName, attributes, outerNewLine: true, }; }, item(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'li', outerNewLine: true, }; }, htmlInline(node, { options }) { const content = options.tagFilter ? filterDisallowedTags(node.literal!) : node.literal!; return { type: 'html', content }; }, htmlBlock(node, { options }) { const content = options.tagFilter ? filterDisallowedTags(node.literal!) : node.literal!; if (options.nodeId) { return [ { type: 'openTag', tagName: 'div', outerNewLine: true }, { type: 'html', content }, { type: 'closeTag', tagName: 'div', outerNewLine: true }, ]; } return { type: 'html', content, outerNewLine: true }; }, code(node) { return [ { type: 'openTag', tagName: 'code' }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'code' }, ]; }, codeBlock(node) { const infoStr = (node as CodeBlockNode).info; const infoWords = infoStr ? infoStr.split(/\s+/) : []; const codeClassNames = []; if (infoWords.length > 0 && infoWords[0].length > 0) { codeClassNames.push(`language-${escapeXml(infoWords[0])}`); } return [ { type: 'openTag', tagName: 'pre', outerNewLine: true }, { type: 'openTag', tagName: 'code', classNames: codeClassNames }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'code' }, { type: 'closeTag', tagName: 'pre', outerNewLine: true }, ]; }, link(node: Node, { entering }) { if (entering) { const { title, destination } = node as LinkNode; return { type: 'openTag', tagName: 'a', attributes: { href: escapeXml(destination!), ...(title && { title: escapeXml(title) }), }, }; } return { type: 'closeTag', tagName: 'a' }; }, image(node: Node, { getChildrenText, skipChildren }) { const { title, destination } = node as LinkNode; skipChildren(); return { type: 'openTag', tagName: 'img', selfClose: true, attributes: { src: escapeXml(destination!), alt: getChildrenText(node), ...(title && { title: escapeXml(title) }), }, }; }, customBlock(node, context, convertors) { const info = (node as CustomBlockNode).info!.trim().toLowerCase(); const customConvertor = convertors![info]; if (customConvertor) { try { return customConvertor!(node, context); } catch (e) { console.warn( `[@toast-ui/editor] - The error occurred when ${info} block node was parsed in markdown renderer: ${e}` ); } } return [ { type: 'openTag', tagName: 'div', outerNewLine: true }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'div', outerNewLine: true }, ]; }, frontMatter(node) { return [ { type: 'openTag', tagName: 'div', outerNewLine: true, // Because front matter is metadata, it should not be render. attributes: { style: 'white-space: pre; display: none;' }, }, { type: 'text', content: node.literal! }, { type: 'closeTag', tagName: 'div', outerNewLine: true }, ]; }, customInline(node, context, convertors) { const { info, firstChild } = node as CustomBlockNode; const nomalizedInfo = info.trim().toLowerCase(); const customConvertor = convertors![nomalizedInfo]; const { entering } = context; if (customConvertor) { try { return customConvertor!(node, context); } catch (e) { console.warn( `[@toast-ui/editor] - The error occurred when ${nomalizedInfo} inline node was parsed in markdown renderer: ${e}` ); } } return entering ? [ { type: 'openTag', tagName: 'span' }, { type: 'text', content: `$$${info}${firstChild ? ' ' : ''}` }, ] : [ { type: 'text', content: '$$' }, { type: 'closeTag', tagName: 'span' }, ]; }, }; ================================================ FILE: libs/toastmark/src/html/gfmConvertors.ts ================================================ import { HTMLConvertorMap, HTMLToken, OpenTagToken } from '@t/renderer'; import { Node, ListNode, TableNode, TableCellNode } from '../commonmark/node'; export const gfmConvertors: HTMLConvertorMap = { strike(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'del', }; }, item(node: Node, { entering }) { const { checked, task } = (node as ListNode).listData!; if (entering) { const itemTag: OpenTagToken = { type: 'openTag', tagName: 'li', outerNewLine: true, }; if (task) { return [ itemTag, { type: 'openTag', tagName: 'input', selfClose: true, attributes: { ...(checked && { checked: '' }), disabled: '', type: 'checkbox', }, }, { type: 'text', content: ' ', }, ]; } return itemTag; } return { type: 'closeTag', tagName: 'li', outerNewLine: true, }; }, table(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'table', outerNewLine: true, }; }, tableHead(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'thead', outerNewLine: true, }; }, tableBody(_, { entering }) { return { type: entering ? 'openTag' : 'closeTag', tagName: 'tbody', outerNewLine: true, }; }, tableRow(node: Node, { entering }) { if (entering) { return { type: 'openTag', tagName: 'tr', outerNewLine: true, }; } const result: HTMLToken[] = []; if (node.lastChild) { const columnLen = (node.parent!.parent as TableNode).columns.length; const lastColIdx = (node.lastChild as TableCellNode).endIdx; for (let i = lastColIdx + 1; i < columnLen; i += 1) { result.push( { type: 'openTag', tagName: 'td', outerNewLine: true, }, { type: 'closeTag', tagName: 'td', outerNewLine: true, } ); } } result.push({ type: 'closeTag', tagName: 'tr', outerNewLine: true, }); return result; }, tableCell(node: Node, { entering }) { if ((node as TableCellNode).ignored) { return { type: 'text', content: '', }; } const tablePart = node.parent!.parent!; const tagName = tablePart.type === 'tableHead' ? 'th' : 'td'; const table = tablePart.parent as TableNode; const columnInfo = table.columns[(node as TableCellNode).startIdx]; const attributes = columnInfo?.align ? { align: columnInfo.align } : null; if (entering) { return { type: 'openTag', tagName, outerNewLine: true, ...(attributes && { attributes }), }; } return { type: 'closeTag', tagName, outerNewLine: true, }; }, }; ================================================ FILE: libs/toastmark/src/html/renderer.ts ================================================ import { CloseTagToken, Context, HTMLConvertorMap, HTMLRenderer, HTMLToken, OpenTagToken, RawHTMLToken, RendererOptions, TagToken, TextToken, } from '@t/renderer'; import { MdNodeType } from '@t/node'; import { Node, isContainer, isCustomBlock, isCustomInline } from '../commonmark/node'; import { escapeXml } from '../commonmark/common'; import { last } from '../helper'; import { baseConvertors } from './baseConvertors'; import { gfmConvertors } from './gfmConvertors'; const defaultOptions: RendererOptions = { softbreak: '\n', gfm: false, tagFilter: false, nodeId: false, }; function getChildrenText(node: Node) { const buffer: string[] = []; const walker = node.walker(); let event: ReturnType<typeof walker.next> = null; while ((event = walker.next())) { const { node } = event; if (node.type === 'text') { buffer.push(node.literal!); } } return buffer.join(''); } export class Renderer implements HTMLRenderer { private convertors: HTMLConvertorMap; private options: RendererOptions; private buffer: string[] = []; constructor(customOptions?: Partial<RendererOptions>) { this.options = { ...defaultOptions, ...customOptions }; this.convertors = this.createConvertors(); delete this.options.convertors; } private createConvertors() { let convertors: HTMLConvertorMap = { ...baseConvertors }; if (this.options.gfm) { convertors = { ...convertors, ...gfmConvertors }; } if (this.options.convertors) { const customConvertors = this.options.convertors; const nodeTypes = Object.keys(customConvertors) as MdNodeType[]; const defaultConvertors = { ...baseConvertors, ...gfmConvertors }; nodeTypes.forEach((nodeType) => { const orgConvertor = convertors[nodeType]; const convertor = customConvertors[nodeType]!; const convertorType = Object.keys(defaultConvertors).indexOf(nodeType) === -1 ? nodeType.toLowerCase() : nodeType; if (orgConvertor) { convertors[convertorType] = (node, context, convertors) => { context.origin = () => orgConvertor(node, context, convertors); return convertor(node, context); }; } else { convertors[convertorType] = convertor; } }); } return convertors; } getConvertors() { return this.convertors; } getOptions() { return this.options; } render(rootNode: Node): string { this.buffer = []; const walker = rootNode.walker(); let event: ReturnType<typeof walker.next> = null; while ((event = walker.next())) { const { node, entering } = event; const convertor = this.convertors[node.type]; if (!convertor) { continue; } let skipped = false; const context: Context = { entering, leaf: !isContainer(node), options: this.options, getChildrenText, skipChildren: () => { skipped = true; }, }; const converted = isCustomBlock(node) || isCustomInline(node) ? convertor(node, context, this.convertors) : convertor(node, context); if (converted) { const htmlNodes = Array.isArray(converted) ? converted : [converted]; htmlNodes.forEach((htmlNode, index) => { if (htmlNode.type === 'openTag' && this.options.nodeId && index === 0) { if (!htmlNode.attributes) { htmlNode.attributes = {}; } htmlNode.attributes['data-nodeid'] = String(node.id); } this.renderHTMLNode(htmlNode); }); if (skipped) { walker.resumeAt(node, false); walker.next(); } } } this.addNewLine(); return this.buffer.join(''); } renderHTMLNode(node: HTMLToken) { switch (node.type) { case 'openTag': case 'closeTag': this.renderElementNode(node); break; case 'text': this.renderTextNode(node); break; case 'html': this.renderRawHtmlNode(node); break; default: // no-default-case } } private generateOpenTagString(node: OpenTagToken) { const { tagName, classNames, attributes } = node; this.buffer.push(`<${tagName}`); if (classNames && classNames.length > 0) { this.buffer.push(` class="${classNames.join(' ')}"`); } if (attributes) { Object.keys(attributes).forEach((attrName) => { const attrValue = attributes[attrName]; this.buffer.push(` ${attrName}="${attrValue}"`); }); } if (node.selfClose) { this.buffer.push(' /'); } this.buffer.push('>'); } private generateCloseTagString({ tagName }: CloseTagToken) { this.buffer.push(`</${tagName}>`); } private addNewLine() { if (this.buffer.length && last(last(this.buffer)) !== '\n') { this.buffer.push('\n'); } } private addOuterNewLine(node: TagToken | RawHTMLToken) { if (node.outerNewLine) { this.addNewLine(); } } private addInnerNewLine(node: TagToken) { if (node.innerNewLine) { this.addNewLine(); } } private renderTextNode(node: TextToken) { this.buffer.push(escapeXml(node.content)); } private renderRawHtmlNode(node: RawHTMLToken) { this.addOuterNewLine(node); this.buffer.push(node.content); this.addOuterNewLine(node); } private renderElementNode(node: OpenTagToken | CloseTagToken) { if (node.type === 'openTag') { this.addOuterNewLine(node); this.generateOpenTagString(node); if (node.selfClose) { this.addOuterNewLine(node); } else { this.addInnerNewLine(node); } } else { this.addInnerNewLine(node); this.generateCloseTagString(node); this.addOuterNewLine(node); } } } ================================================ FILE: libs/toastmark/src/html/tagFilter.ts ================================================ const disallowedTags = [ 'title', 'textarea', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'script', 'plaintext', ]; const reDisallowedTag = new RegExp(`<(\/?(?:${disallowedTags.join('|')})[^>]*>)`, 'ig'); export function filterDisallowedTags(str: string) { if (reDisallowedTag.test(str)) { return str.replace(reDisallowedTag, (_, group) => `<${group}`); } return str; } ================================================ FILE: libs/toastmark/src/index.ts ================================================ export { ToastMark } from './toastmark'; export { Renderer } from './html/renderer'; export { Parser } from './commonmark/blocks'; ================================================ FILE: libs/toastmark/src/nodeHelper.ts ================================================ import { Pos, Sourcepos } from '@t/node'; import { Node, getNodeById, removeNodeById } from './commonmark/node'; export const enum Compare { LT = 1, EQ = 0, GT = -1, } function comparePos(p1: Pos, p2: Pos) { if (p1[0] < p2[0]) { return Compare.LT; } if (p1[0] > p2[0]) { return Compare.GT; } if (p1[1] < p2[1]) { return Compare.LT; } if (p1[1] > p2[1]) { return Compare.GT; } return Compare.EQ; } function compareRangeAndPos([startPos, endPos]: Sourcepos, pos: Pos) { if (comparePos(endPos, pos) === Compare.LT) { return Compare.LT; } if (comparePos(startPos, pos) === Compare.GT) { return Compare.GT; } return Compare.EQ; } export function getAllParents(node: Node) { const parents = []; while (node.parent) { parents.push(node.parent); node = node.parent; } return parents.reverse(); } export function removeNextUntil(node: Node, last: Node) { if (node.parent !== last.parent || node === last) { return; } let next = node.next; while (next && next !== last) { const temp = next.next; for (const type of ['parent', 'prev', 'next'] as const) { if (next[type]) { removeNodeById(next[type]!.id); next[type] = null; } } next = temp; } node.next = last.next; if (last.next) { last.next.prev = node; } else { node.parent!.lastChild = node; } } export function getChildNodes(parent: Node) { const nodes = []; let curr: Node | null = parent.firstChild!; while (curr) { nodes.push(curr); curr = curr.next; } return nodes; } export function insertNodesBefore(target: Node, nodes: Node[]) { for (const node of nodes) { target.insertBefore(node); } } export function prependChildNodes(parent: Node, nodes: Node[]) { for (let i = nodes.length - 1; i >= 0; i -= 1) { parent.prependChild(nodes[i]); } } export function updateNextLineNumbers(base: Node | null, diff: number) { if (!base || !base.parent || diff === 0) { return; } const walker = base.parent.walker(); walker.resumeAt(base, true); let event; while ((event = walker.next())) { const { node, entering } = event; if (entering) { node.sourcepos![0][0] += diff; node.sourcepos![1][0] += diff; } } } function compareRangeAndLine([startPos, endPos]: Sourcepos, line: number) { if (endPos[0] < line) { return Compare.LT; } if (startPos[0] > line) { return Compare.GT; } return Compare.EQ; } export function findChildNodeAtLine(parent: Node, line: number) { let node = parent.firstChild; while (node) { const comp = compareRangeAndLine(node.sourcepos!, line); if (comp === Compare.EQ) { return node; } if (comp === Compare.GT) { // To consider that top line is blank line return node.prev || node; } node = node.next; } return parent.lastChild; } function lastLeafNode(node: Node) { while (node.lastChild) { node = node.lastChild; } return node; } function sameLineTopAncestor(node: Node) { while ( node.parent && node.parent.type !== 'document' && node.parent.sourcepos![0][0] === node.sourcepos![0][0] ) { node = node.parent; } return node; } export function findFirstNodeAtLine(parent: Node, line: number) { let node = parent.firstChild; let prev: Node | null = null; while (node) { const comp = compareRangeAndLine(node.sourcepos!, line); if (comp === Compare.EQ) { if (node.sourcepos![0][0] === line || !node.firstChild) { return node; } prev = node; node = node.firstChild; } else if (comp === Compare.GT) { break; } else { prev = node; node = node.next; } } if (prev) { return sameLineTopAncestor(lastLeafNode(prev)); } return null; } export function findNodeAtPosition(parent: Node, pos: Pos) { let node: Node | null = parent; let prev: Node | null = null; while (node) { const comp = compareRangeAndPos(node.sourcepos!, pos); if (comp === Compare.EQ) { if (node.firstChild) { prev = node; node = node.firstChild; } else { return node; } } else if (comp === Compare.GT) { return prev; } else if (node.next) { node = node.next; } else { return prev; } } return node; } export function toString(node: Node | null) { if (!node) { return 'null'; } return `type: ${node.type}, sourcepos: ${node.sourcepos}, firstChild: ${ node.firstChild && node.firstChild.type }, lastChild: ${node.lastChild && node.lastChild.type}, prev: ${ node.prev && node.prev.type }, next: ${node.next && node.next.type}`; } export function findNodeById(id: number) { return getNodeById(id) || null; } export function invokeNextUntil(callback: Function, start: Node | null, end: Node | null = null) { if (start) { const walker = start.walker(); while (start && start !== end) { callback(start); const next = walker.next(); if (next) { start = next.node; } else { break; } } } } export function isUnlinked(id: number) { let node = findNodeById(id); if (!node) { return true; } while (node && node.type !== 'document') { // eslint-disable-next-line no-loop-func if (!node.parent && !node.prev && !node.next) { return true; } node = node.parent!; } return false; } ================================================ FILE: libs/toastmark/src/toastmark.ts ================================================ import { EditResult, EventHandlerMap, EventName, RemovedNodeRange, ToastMark as ToastMarkParser, } from '@t/toastMark'; import { ParserOptions, RefDefCandidateMap, RefLinkCandidateMap, RefMap } from '@t/parser'; import { Pos } from '@t/node'; import { Parser } from './commonmark/blocks'; import { BlockNode, isList, removeAllNode, removeNodeById, Node, isRefDef, RefDefNode, isTable, isCodeBlock, isCustomBlock, } from './commonmark/node'; import { removeNextUntil, getChildNodes, insertNodesBefore, prependChildNodes, updateNextLineNumbers, findChildNodeAtLine, findFirstNodeAtLine, findNodeAtPosition, findNodeById, invokeNextUntil, isUnlinked, } from './nodeHelper'; import { reBulletListMarker, reOrderedListMarker } from './commonmark/blockStarts'; import { iterateObject, omit, isEmptyObj } from './helper'; import { isBlank } from './commonmark/blockHelper'; export const reLineEnding = /\r\n|\n|\r/; type ParseResult = EditResult & { nextNode: Node | null }; function canBeContinuedListItem(lineText: string) { const spaceMatch = lineText.match(/^[ \t]+/); if (spaceMatch && (spaceMatch[0].length >= 2 || /\t/.test(spaceMatch[0]))) { return true; } const leftTrimmed = spaceMatch ? lineText.slice(spaceMatch.length) : lineText; return reBulletListMarker.test(leftTrimmed) || reOrderedListMarker.test(leftTrimmed); } function canBeContinuedTableBody(lineText: string) { return !isBlank(lineText) && lineText.indexOf('|') !== -1; } export function createRefDefState(node: RefDefNode) { const { id, title, sourcepos, dest } = node; return { id, title, sourcepos: sourcepos!, unlinked: false, destination: dest, }; } export class ToastMark implements ToastMarkParser { lineTexts: string[]; private parser: Parser; private root: BlockNode; private eventHandlerMap: EventHandlerMap; private refMap: RefMap; private refLinkCandidateMap: RefLinkCandidateMap; private refDefCandidateMap: RefDefCandidateMap; private referenceDefinition: boolean; constructor(contents?: string, options?: Partial<ParserOptions>) { this.refMap = {}; this.refLinkCandidateMap = {}; this.refDefCandidateMap = {}; this.referenceDefinition = !!options?.referenceDefinition; this.parser = new Parser(options); this.parser.setRefMaps(this.refMap, this.refLinkCandidateMap, this.refDefCandidateMap); this.eventHandlerMap = { change: [] }; contents = contents || ''; this.lineTexts = contents.split(reLineEnding); this.root = this.parser.parse(contents, this.lineTexts); } private updateLineTexts(startPos: Pos, endPos: Pos, newText: string) { const [startLine, startCol] = startPos; const [endLine, endCol] = endPos; const newLines = newText.split(reLineEnding); const newLineLen = newLines.length; const startLineText = this.lineTexts[startLine - 1]; const endLineText = this.lineTexts[endLine - 1]; newLines[0] = startLineText.slice(0, startCol - 1) + newLines[0]; newLines[newLineLen - 1] = newLines[newLineLen - 1] + endLineText.slice(endCol - 1); const removedLineLen = endLine - startLine + 1; this.lineTexts.splice(startLine - 1, removedLineLen, ...newLines); return newLineLen - removedLineLen; } private updateRootNodeState() { if (this.lineTexts.length === 1 && this.lineTexts[0] === '') { this.root.lastLineBlank = true; this.root.sourcepos = [ [1, 1], [1, 0], ]; return; } if (this.root.lastChild) { this.root.lastLineBlank = (this.root.lastChild as BlockNode).lastLineBlank; } const { lineTexts } = this; let idx = lineTexts.length - 1; while (lineTexts[idx] === '') { idx -= 1; } if (lineTexts.length - 2 > idx) { idx += 1; } this.root.sourcepos![1] = [idx + 1, lineTexts[idx].length]; } private replaceRangeNodes( startNode: BlockNode | null, endNode: BlockNode | null, newNodes: BlockNode[] ) { if (!startNode) { if (endNode) { insertNodesBefore(endNode, newNodes); removeNodeById(endNode.id); endNode.unlink(); } else { prependChildNodes(this.root, newNodes); } } else { insertNodesBefore(startNode, newNodes); removeNextUntil(startNode, endNode!); [startNode.id, endNode!.id].forEach((id) => removeNodeById(id)); startNode.unlink(); } } private getNodeRange(startPos: Pos, endPos: Pos) { const startNode = findChildNodeAtLine(this.root, startPos[0]); let endNode = findChildNodeAtLine(this.root, endPos[0]); // extend node range to include a following block which doesn't have preceding blank line if (endNode && endNode.next && endPos[0] + 1 === endNode.next.sourcepos![0][0]) { endNode = endNode.next; } return [startNode, endNode] as [BlockNode, BlockNode]; } private trigger(eventName: EventName, param: any) { this.eventHandlerMap[eventName].forEach((handler) => { handler(param); }); } private extendEndLine(line: number) { while (this.lineTexts[line] === '') { line += 1; } return line; } private parseRange( startNode: BlockNode | null, endNode: BlockNode | null, startLine: number, endLine: number ) { // extends starting range if the first node can be a continued list item if ( startNode && startNode.prev && ((isList(startNode.prev) && canBeContinuedListItem(this.lineTexts[startLine - 1])) || (isTable(startNode.prev) && canBeContinuedTableBody(this.lineTexts[startLine - 1]))) ) { startNode = startNode.prev; startLine = startNode.sourcepos![0][0]; } const editedLines = this.lineTexts.slice(startLine - 1, endLine); const root = this.parser.partialParseStart(startLine, editedLines); // extends ending range if the following node can be a fenced code block or a continued list item let nextNode = endNode ? endNode.next : this.root.firstChild; const { lastChild } = root; const isOpenedLastChildCodeBlock = lastChild && isCodeBlock(lastChild) && lastChild.open; const isOpenedLastChildCustomBlock = lastChild && isCustomBlock(lastChild) && lastChild.open; const isLastChildList = lastChild && isList(lastChild); while ( ((isOpenedLastChildCodeBlock || isOpenedLastChildCustomBlock) && nextNode) || (isLastChildList && nextNode && (nextNode.type === 'list' || nextNode.sourcepos![0][1] >= 2)) ) { const newEndLine = this.extendEndLine(nextNode.sourcepos![1][0]); this.parser.partialParseExtends(this.lineTexts.slice(endLine, newEndLine)); if (!startNode) { startNode = endNode; } endNode = nextNode as BlockNode; endLine = newEndLine; nextNode = nextNode.next; } this.parser.partialParseFinish(); const newNodes = getChildNodes(root)! as BlockNode[]; return { newNodes, extStartNode: startNode, extEndNode: endNode }; } private getRemovedNodeRange( extStartNode: BlockNode | null, extEndNode: BlockNode | null ): RemovedNodeRange | null { if ( !extStartNode || (extStartNode && isRefDef(extStartNode)) || (extEndNode && isRefDef(extEndNode)) ) { return null; } return { id: [extStartNode.id, extEndNode!.id], line: [extStartNode.sourcepos![0][0] - 1, extEndNode!.sourcepos![1][0] - 1], }; } private markDeletedRefMap(extStartNode: BlockNode | null, extEndNode: BlockNode | null) { if (!isEmptyObj(this.refMap)) { const markDeleted = (node: BlockNode) => { if (isRefDef(node)) { const refDefState = this.refMap[node.label]; if (refDefState && node.id === refDefState.id) { refDefState.unlinked = true; } } }; if (extStartNode) { invokeNextUntil(markDeleted, extStartNode.parent!, extEndNode); } if (extEndNode) { invokeNextUntil(markDeleted, extEndNode); } } } private replaceWithNewRefDefState(nodes: BlockNode[]) { if (!isEmptyObj(this.refMap)) { const replaceWith = (node: BlockNode) => { if (isRefDef(node)) { const { label } = node; const refDefState = this.refMap[label]; if (!refDefState || refDefState.unlinked) { this.refMap[label] = createRefDefState(node); } } }; nodes.forEach((node) => { invokeNextUntil(replaceWith, node); }); } } private replaceWithRefDefCandidate() { if (!isEmptyObj(this.refDefCandidateMap)) { iterateObject(this.refDefCandidateMap, (_, candidate) => { const { label, sourcepos } = candidate; const refDefState = this.refMap[label]; if ( !refDefState || refDefState.unlinked || refDefState.sourcepos[0][0] > sourcepos![0][0] ) { this.refMap[label] = createRefDefState(candidate); } }); } } private getRangeWithRefDef( startLine: number, endLine: number, startNode: BlockNode, endNode: BlockNode, lineDiff: number ) { if (this.referenceDefinition && !isEmptyObj(this.refMap)) { const prevNode = findChildNodeAtLine(this.root, startLine - 1); const nextNode = findChildNodeAtLine(this.root, endLine + 1); if (prevNode && isRefDef(prevNode) && prevNode !== startNode && prevNode !== endNode) { startNode = prevNode; startLine = startNode.sourcepos![0][0]; } if (nextNode && isRefDef(nextNode) && nextNode !== startNode && nextNode !== endNode) { endNode = nextNode; endLine = this.extendEndLine(endNode.sourcepos![1][0] + lineDiff); } } return [startNode, endNode, startLine, endLine] as const; } private parse(startPos: Pos, endPos: Pos, lineDiff = 0): ParseResult { const range = this.getNodeRange(startPos, endPos); const [startNode, endNode] = range; const startLine = startNode ? Math.min(startNode.sourcepos![0][0], startPos[0]) : startPos[0]; const endLine = this.extendEndLine( (endNode ? Math.max(endNode.sourcepos![1][0], endPos[0]) : endPos[0]) + lineDiff ); const parseResult = this.parseRange( ...this.getRangeWithRefDef(startLine, endLine, startNode, endNode, lineDiff) ); const { newNodes, extStartNode, extEndNode } = parseResult; const removedNodeRange = this.getRemovedNodeRange(extStartNode, extEndNode); const nextNode = extEndNode ? extEndNode.next : this.root.firstChild; if (this.referenceDefinition) { this.markDeletedRefMap(extStartNode, extEndNode); this.replaceRangeNodes(extStartNode, extEndNode, newNodes); this.replaceWithNewRefDefState(newNodes); } else { this.replaceRangeNodes(extStartNode, extEndNode, newNodes); } return { nodes: newNodes, removedNodeRange, nextNode }; } private parseRefLink() { const result: EditResult[] = []; if (!isEmptyObj(this.refMap)) { iterateObject(this.refMap, (label, value) => { if (value.unlinked) { delete this.refMap[label]; } iterateObject(this.refLinkCandidateMap, (_, candidate) => { const { node, refLabel } = candidate; if (refLabel === label) { result.push(this.parse(node.sourcepos![0], node.sourcepos![1])); } }); }); } return result; } private removeUnlinkedCandidate() { if (!isEmptyObj(this.refDefCandidateMap)) { [this.refLinkCandidateMap, this.refDefCandidateMap].forEach((candidateMap) => { iterateObject(candidateMap, (id) => { if (isUnlinked(id)) { delete candidateMap[id]; } }); }); } } editMarkdown(startPos: Pos, endPos: Pos, newText: string) { const lineDiff = this.updateLineTexts(startPos, endPos, newText); const parseResult = this.parse(startPos, endPos, lineDiff); const editResult: EditResult = omit(parseResult, 'nextNode'); updateNextLineNumbers(parseResult.nextNode, lineDiff); this.updateRootNodeState(); let result = [editResult]; if (this.referenceDefinition) { this.removeUnlinkedCandidate(); this.replaceWithRefDefCandidate(); result = result.concat(this.parseRefLink()); } this.trigger('change', result); return result; } getLineTexts() { return this.lineTexts; } getRootNode() { return this.root; } findNodeAtPosition(pos: Pos) { const node = findNodeAtPosition(this.root, pos); if (!node || node === this.root) { return null; } return node; } findFirstNodeAtLine(line: number) { return findFirstNodeAtLine(this.root, line); } on(eventName: EventName, callback: () => void) { this.eventHandlerMap[eventName].push(callback); } off(eventName: EventName, callback: Function) { const handlers = this.eventHandlerMap[eventName]; const idx = handlers.indexOf(callback); handlers.splice(idx, 1); } findNodeById(id: number) { return findNodeById(id); } removeAllNode() { removeAllNode(); } } ================================================ FILE: libs/toastmark/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "types/**/*", "../../types/**/*"], "exclude": ["node_modules"], "compilerOptions": { "resolveJsonModule": true, "baseUrl": ".", "paths": { "@t/*": ["types/*"] }, "lib": ["esnext", "dom"] } } ================================================ FILE: libs/toastmark/types/index.d.ts ================================================ export { BlockNodeType, InlineNodeType, MdNodeType, NodeWalker, MdNode, BlockMdNode, ListData, ListMdNode, ListItemMdNode, HeadingMdNode, CodeBlockMdNode, TableColumn, TableMdNode, TableCellMdNode, CustomBlockMdNode, HtmlBlockMdNode, LinkMdNode, CodeMdNode, CustomInlineMdNode, Pos as MdPos, Sourcepos, } from './node'; export { ToastMark, EditResult } from './toastMark'; export { HTMLConvertor, HTMLConvertorMap, RendererOptions, Context, OpenTagToken, CloseTagToken, TextToken, RawHTMLToken, HTMLToken, HTMLRenderer as Renderer, } from './renderer'; export { ParserOptions, BlockParser as Parser, CustomParserMap } from './parser'; ================================================ FILE: libs/toastmark/types/node.d.ts ================================================ export type BlockNodeType = | 'document' | 'list' | 'blockQuote' | 'item' | 'heading' | 'thematicBreak' | 'paragraph' | 'codeBlock' | 'htmlBlock' | 'table' | 'tableHead' | 'tableBody' | 'tableRow' | 'tableCell' | 'tableDelimRow' | 'tableDelimCell' | 'refDef' | 'customBlock' | 'frontMatter'; export type InlineNodeType = | 'code' | 'text' | 'emph' | 'strong' | 'strike' | 'link' | 'image' | 'htmlInline' | 'linebreak' | 'softbreak' | 'customInline'; export type MdNodeType = BlockNodeType | InlineNodeType; export type Pos = [number, number]; export type Sourcepos = [Pos, Pos]; export interface NodeWalker { current: MdNode | null; root: MdNode; entering: boolean; next(): { entering: boolean; node: MdNode } | null; resumeAt(node: MdNode, entering: boolean): void; } export interface MdNode { type: MdNodeType; id: number; parent: MdNode | null; prev: MdNode | null; next: MdNode | null; sourcepos?: Sourcepos; firstChild: MdNode | null; lastChild: MdNode | null; literal: string | null; isContainer(): boolean; unlink(): void; replaceWith(node: MdNode): void; insertAfter(node: MdNode): void; insertBefore(node: MdNode): void; appendChild(child: MdNode): void; prependChild(child: MdNode): void; walker(): NodeWalker; } export interface BlockMdNode extends MdNode { type: BlockNodeType; // temporal data (for parsing) open: boolean; lineOffsets: number[] | null; stringContent: string | null; lastLineBlank: boolean; lastLineChecked: boolean; } export interface ListData { type: 'ordered' | 'bullet'; tight: boolean; start: number; bulletChar: string; delimiter: string; markerOffset: number; padding: number; task: boolean; checked: boolean; } export interface ListMdNode extends BlockMdNode { listData: ListData | null; } export interface ListItemMdNode extends BlockMdNode { parent: MdNode; listData: ListData; } export interface HeadingMdNode extends BlockMdNode { level: number; headingType: 'atx' | 'setext'; } export interface CodeBlockMdNode extends BlockMdNode { fenceOffset: number; fenceLength: number; fenceChar: string | null; info: string | null; infoPadding: number; } export interface TableColumn { align: 'left' | 'center' | 'right' | null; } export interface TableMdNode extends BlockMdNode { columns: TableColumn[]; } export interface TableCellMdNode extends BlockMdNode { startIdx: number; endIdx: number; paddingLeft: number; paddingRight: number; ignored: boolean; attrs?: Record<string, any>; } export interface CustomBlockMdNode extends BlockMdNode { disabledEntityParse?: boolean; } export interface RefDefMdNode extends BlockMdNode { title: string; dest: string; label: string; } export interface CustomBlockMdNode extends BlockMdNode { syntaxLength: number; offset: number; info: string; } export interface HtmlBlockMdNode extends BlockMdNode { htmlBlockType: number; } export interface LinkMdNode extends MdNode { destination: string | null; title: string | null; extendedAutolink: boolean; lastChild: MdNode; } export interface CodeMdNode extends MdNode { tickCount: number; } export interface CustomInlineMdNode extends MdNode { info: string; } ================================================ FILE: libs/toastmark/types/parser.d.ts ================================================ import { BlockMdNode, BlockNodeType, MdNode, MdNodeType, RefDefMdNode, Sourcepos } from './node'; export type AutolinkParser = ( content: string ) => { url: string; text: string; range: [number, number]; }[]; export type CustomParser = ( node: MdNode, context: { entering: boolean; options: ParserOptions } ) => void; export type CustomParserMap = Partial<Record<MdNodeType, CustomParser>>; type RefDefState = { id: number; destination: string; title: string; unlinked: boolean; sourcepos: Sourcepos; }; export type RefMap = { [k: string]: RefDefState; }; export type RefLinkCandidateMap = { [k: number]: { node: BlockMdNode; refLabel: string; }; }; export type RefDefCandidateMap = { [k: number]: RefDefMdNode; }; export interface ParserOptions { smart: boolean; tagFilter: boolean; extendedAutolinks: boolean | AutolinkParser; disallowedHtmlBlockTags: string[]; referenceDefinition: boolean; disallowDeepHeading: boolean; frontMatter: boolean; customParser: CustomParserMap | null; } export class BlockParser { constructor(options?: Partial<ParserOptions>); advanceOffset(count: number, columns?: boolean): void; advanceNextNonspace(): void; findNextNonspace(): void; addLine(): void; addChild(tag: BlockNodeType, offset: number): BlockMdNode; closeUnmatchedBlocks(): void; finalize(block: BlockMdNode, lineNumber: number): void; processInlines(block: BlockMdNode): void; incorporateLine(ln: string): void; // The main parsing function. Returns a parsed document AST. parse(input: string, lineTexts?: string[]): MdNode; partialParseStart(lineNumber: number, lines: string[]): MdNode; partialParseExtends(lines: string[]): void; partialParseFinish(): void; setRefMaps( refMap: RefMap, refLinkCandidateMap: RefLinkCandidateMap, refDefCandidateMap: RefDefCandidateMap ): void; clearRefMaps(): void; } ================================================ FILE: libs/toastmark/types/renderer.d.ts ================================================ import { MdNode, MdNodeType } from './node'; export type HTMLConvertor = ( node: MdNode, context: Context, convertors?: HTMLConvertorMap ) => HTMLToken | HTMLToken[] | null; export type HTMLConvertorMap = Partial<Record<MdNodeType | string, HTMLConvertor>>; interface RendererOptions { gfm: boolean; softbreak: string; nodeId: boolean; tagFilter: boolean; convertors?: HTMLConvertorMap; } interface Context { entering: boolean; leaf: boolean; options: Omit<RendererOptions, 'convertors'>; getChildrenText: (node: MdNode) => string; skipChildren: () => void; origin?: () => ReturnType<HTMLConvertor>; } interface TagToken { tagName: string; outerNewLine?: boolean; innerNewLine?: boolean; } export interface OpenTagToken extends TagToken { type: 'openTag'; classNames?: string[]; attributes?: Record<string, any>; selfClose?: boolean; } export interface CloseTagToken extends TagToken { type: 'closeTag'; } export interface TextToken { type: 'text'; content: string; } export interface RawHTMLToken { type: 'html'; content: string; outerNewLine?: boolean; } export type HTMLToken = OpenTagToken | CloseTagToken | TextToken | RawHTMLToken; export class HTMLRenderer { constructor(customOptions?: Partial<RendererOptions>); getConvertors(): HTMLConvertorMap; getOptions(): RendererOptions; render(rootNode: MdNode): string; renderHTMLNode(node: HTMLToken): void; } ================================================ FILE: libs/toastmark/types/toastMark.d.ts ================================================ import { MdNode, Pos } from './node'; import { ParserOptions } from './parser'; export interface RemovedNodeRange { id: [number, number]; line: [number, number]; } export interface EditResult { nodes: MdNode[]; removedNodeRange: RemovedNodeRange | null; } type EventName = 'change'; type EventHandlerMap = { [key in EventName]: Function[]; }; export class ToastMark { constructor(contents?: string, options?: Partial<ParserOptions>); lineTexts: string[]; editMarkdown(startPos: Pos, endPos: Pos, newText: string): EditResult[]; getLineTexts(): string[]; getRootNode(): MdNode; findNodeAtPosition(pos: Pos): MdNode | null; findFirstNodeAtLine(line: number): MdNode | null; on(eventName: EventName, callback: () => void): void; off(eventName: EventName, callback: () => void): void; findNodeById(id: number): MdNode | null; removeAllNode(): void; } ================================================ FILE: libs/toastmark/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const { merge } = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const commonConfig = { entry: path.resolve(__dirname, './src/index.ts'), mode: 'production', module: { rules: [ { test: /\.ts$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { environment: { arrowFunction: false, const: false, }, filename: 'toastmark.js', library: { type: 'commonjs', }, publicPath: '/dist', path: path.resolve(__dirname, 'dist'), }, optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, extractComments: false, }), ], }, }; module.exports = (env) => { const isProduction = env.WEBPACK_BUILD; if (isProduction) { return commonConfig; } return merge(commonConfig, { entry: path.resolve(__dirname, './src/__sample__/index.ts'), mode: 'development', devtool: 'inline-source-map', output: { library: { type: 'umd', }, publicPath: '/', path: path.resolve(__dirname, '/'), }, module: { rules: [ { test: /\.css$/, use: [{ loader: 'style-loader' }, { loader: 'css-loader' }], }, ], }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', }), ], devServer: { open: true, inline: true, host: '0.0.0.0', port: 8000, disableHostCheck: true, }, }); }; ================================================ FILE: package.json ================================================ { "name": "root", "private": true, "devDependencies": { "@babel/core": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/preset-env": "^7.8.3", "@rollup/plugin-commonjs": "^19.0.0", "@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", "@types/node": "^17.0.14", "@types/prosemirror-commands": "^1.0.3", "@types/prosemirror-history": "^1.0.1", "@types/prosemirror-inputrules": "^1.0.4", "@types/prosemirror-keymap": "^1.0.3", "@types/prosemirror-model": "^1.7.2", "@types/prosemirror-state": "^1.2.5", "@types/prosemirror-view": "^1.15.1", "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "command-line-args": "^5.1.1", "common-tags": "^1.8.0", "copy-webpack-plugin": "^10.2.4", "css-loader": "^6.6.0", "css-minimizer-webpack-plugin": "^3.4.1", "eslint": "^7.22.0", "eslint-config-prettier": "^8.1.0", "eslint-config-tui": "^4.0.0", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", "eslint-plugin-vue": "^8.4.1", "eslint-webpack-plugin": "^2.5.2", "filemanager-webpack-plugin": "^4.0.0", "html-webpack-plugin": "^5.3.1", "http-proxy": "^1.18.1", "jest": "^26.6.3", "jest-esm-transformer": "^1.0.0", "jest-serializer-html": "^7.0.0", "lerna": "^3.20.2", "mini-css-extract-plugin": "^2.5.3", "node-fetch": "^2.6.1", "prettier": "^2.1.2", "resize-observer-polyfill": "^1.5.1", "rollup": "^2.52.0", "rollup-plugin-banner": "^0.2.1", "rollup-plugin-vue": "^5.1.9", "snowpack": "^3.0.13", "style-loader": "^2.0.0", "terser-webpack-plugin": "^5.1.1", "ts-jest": "^26.5.3", "ts-loader": "^8.0.18", "tslib": "^2.1.0", "tui-code-snippet": "^2.3.2", "typescript": "^4.2.3", "url-loader": "^4.1.1", "vm": "^0.1.0", "vue-eslint-parser": "^8.2.0", "webpack": "^5.26.0", "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.5.0", "webpack-dev-server": "^3.11.2", "webpack-glob-entry": "^2.1.1", "webpack-merge": "^5.7.3" }, "scripts": { "lint:all": "lerna run --stream lint", "test:all": "jest", "test:types:all": "lerna run --stream test:types", "build:all": "lerna run --stream build", "lint": "node ./scripts/pkg-script.js --script lint --type $type", "test": "node ./scripts/pkg-script.js --script test --type $type", "test:ci": "node ./scripts/pkg-script.js --script test:ci --type $type", "test:types": "node ./scripts/pkg-script.js --script test:types --type $type", "serve": "node ./scripts/pkg-script.js --script serve --type $type", "serve:ie": "node ./scripts/pkg-script.js --script serve:ie --type $type", "build": "node ./scripts/pkg-script.js --script build --type $type", "doc:dev": "node ./scripts/pkg-script.js --script doc:dev --type editor", "doc": "node ./scripts/pkg-script.js --script doc --type editor", "publish:cdn": "node ./scripts/publish-cdn.js" }, "workspaces": [ "apps/*", "libs/*", "plugins/*" ] } ================================================ FILE: plugins/chart/README.md ================================================ # TOAST UI Editor : Chart Plugin > This is a plugin of [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor) to render chart. [![npm version](https://img.shields.io/npm/v/@toast-ui/editor-plugin-chart.svg)](https://www.npmjs.com/package/@toast-ui/editor-plugin-chart) ![chart](https://user-images.githubusercontent.com/37766175/121808323-d8d41000-cc92-11eb-9117-b92a435c9b43.png) ## 🚩 Table of Contents - [Bundle File Structure](#-bundle-file-structure) - [Usage npm](#-usage-npm) - [Usage CDN](#-usage-cdn) ## 📁 Bundle File Structure ### Files Distributed on npm ``` - node_modules/ - @toast-ui/ - editor-plugin-chart/ - dist/ - toastui-editor-plugin-chart.js ``` ### Files Distributed on CDN The bundle files include all dependencies of this plugin. ``` - uicdn.toast.com/ - editor-plugin-chart/ - latest/ - toastui-editor-plugin-chart.js - toastui-editor-plugin-chart.min.js ``` ## 📦 Usage npm To use the plugin, [`@toast-ui/editor`](https://github.com/nhn/tui.editor/tree/master/apps/editor) must be installed. > Ref. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) ### Install ```sh $ npm install @toast-ui/editor-plugin-chart ``` ### Import Plugin Along with the plugin, the plugin's dependency style must be imported. The `chart` plugin has [TOAST UI Chart](https://github.com/nhn/tui.chart) as a dependency, and you need to add a CSS file of TOAST UI Chart. #### ES Modules ```js import '@toast-ui/chart/dist/toastui-chart.css'; import chart from '@toast-ui/editor-plugin-chart'; ``` #### CommonJS ```js require('@toast-ui/chart/dist/toastui-chart.css'); const chart = require('@toast-ui/editor-plugin-chart'); ``` ### Create Instance #### Basic ```js // ... import '@toast-ui/chart/dist/toastui-chart.css'; import Editor from '@toast-ui/editor'; import chart from '@toast-ui/editor-plugin-chart'; const editor = new Editor({ // ... plugins: [chart] }); ``` #### With Viewer ```js // ... import '@toast-ui/chart/dist/toastui-chart.css'; import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer'; import chart from '@toast-ui/editor-plugin-chart'; const viewer = new Viewer({ // ... plugins: [chart] }); ``` or ```js // ... import '@toast-ui/chart/dist/toastui-chart.css'; import Editor from '@toast-ui/editor'; import chart from '@toast-ui/editor-plugin-chart'; const viewer = Editor.factory({ // ... plugins: [chart], viewer: true }); ``` ## 🗂 Usage CDN To use the plugin, the CDN files(CSS, Script) of `@toast-ui/editor` must be included. ### Include Files ```html ... <head> ... <link rel="stylesheet" href="https://uicdn.toast.com/chart/latest/toastui-chart.min.css" /> ... </head> <body> ... <!-- Chart --> <script src="https://uicdn.toast.com/chart/latest/toastui-chart.min.js"></script> <!-- Editor --> <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script> <!-- Editor's Plugin --> <script src="https://uicdn.toast.com/editor-plugin-chart/latest/toastui-editor-plugin-chart.min.js"></script> ... </body> ``` ### Create Instance #### Basic ```js const { Editor } = toastui; const { chart } = Editor.plugin; const editor = new Editor({ // ... plugins: [chart] }); ``` #### With Viewer ```js const Viewer = toastui.Editor; const { chart } = Viewer.plugin; const viewer = new Viewer({ // ... plugins: [chart] }); ``` or ```js const { Editor } = toastui; const { chart } = Editor.plugin; const viewer = Editor.factory({ // ... plugins: [chart], viewer: true }); ``` ### [Optional] Use Plugin with Options The `chart` plugin can set options when used. Just add the plugin function and options related to the plugin to the array(`[pluginFn, pluginOptions]`) and push them to the `plugins` option of the editor. The following options are available in the `chart` plugin. These options are used to set the dimensions of the chart drawn in the editor. | Name | Type | Default Value | Description | | ----------- | ---------------- | ------------- | -------------------- | | `width` | `number\|string` | `'auto'` | Default width value | | `height` | `number\|string` | `'auto'` | Default height value | | `minWidth` | `number` | `0` | Minimum width value | | `minHeight` | `number` | `0` | Minimum height value | | `maxWidth` | `number` | `Infinity` | Maximum width value | | `maxHeight` | `number` | `Infinity` | Maximum height value | ```js // ... import '@toast-ui/chart/dist/toastui-chart.css'; import Editor from '@toast-ui/editor'; import chart from '@toast-ui/editor-plugin-chart'; const chartOptions = { minWidth: 100, maxWidth: 600, minHeight: 100, maxHeight: 300 }; const editor = new Editor({ // ... plugins: [[chart, chartOptions]] }); ``` ================================================ FILE: plugins/chart/demo/editor.html ================================================ <!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8" /> <title>Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/chart/demo/esm/index.html ================================================ Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/chart/demo/viewer.html ================================================ Editor
                          ================================================ FILE: plugins/chart/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; ================================================ FILE: plugins/chart/package.json ================================================ { "name": "@toast-ui/editor-plugin-chart", "version": "3.0.1", "description": "TOAST UI Editor : Chart Plugin", "keywords": [ "nhn", "nhn cloud", "toast", "toastui", "toast-ui", "editor", "plugin", "chart" ], "main": "dist/toastui-editor-plugin-chart.js", "types": "types/index.d.ts", "files": [ "dist/*.js", "types/index.d.ts" ], "browserslist": "last 2 versions, not ie <= 10", "author": "NHN Cloud FE Development Lab ", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "plugins/chart" }, "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "homepage": "https://ui.toast.com", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "build:cdn": "webpack build --env cdn & webpack build --env cdn minify", "build": "webpack build && npm run build:cdn" }, "devDependencies": { "buffer": "^6.0.3", "jest-canvas-mock": "^2.3.1", "os-browserify": "^0.3.0", "stream-browserify": "^3.0.0" }, "dependencies": { "@toast-ui/chart": "^4.1.4" }, "publishConfig": { "access": "public" } } ================================================ FILE: plugins/chart/snowpack.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const httpProxy = require('http-proxy'); const proxy = httpProxy.createServer({ target: 'http://localhost:8080' }); /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, devOptions: { port: 8081, }, routes: [ { src: '/img/.*', dest: (req, res) => { proxy.web(req, res); }, }, ], alias: { '@': './src', '@t': './types', }, }; ================================================ FILE: plugins/chart/src/__test__/unit/chartPlugin.spec.ts ================================================ import 'jest-canvas-mock'; import { PluginOptions } from '@t/index'; import { parseToChartOption, parseToChartData, detectDelimiter, setDefaultOptions, ChartOptions, } from '@/index'; describe('parseToChartOption()', () => { it('should parse option code into object', () => { expect( parseToChartOption(` key1.keyA: value1 key1.keyB: value2 `) ).toEqual({ key1: { keyA: 'value1', keyB: 'value2', }, }); }); it('should parse option code into object with reserved keys(type, url)', () => { // type & url -> editor.Chart & editorChart.url expect( parseToChartOption(` type: line url: http://some.url/to/data/file `) ).toEqual({ editorChart: { type: 'line', url: 'http://some.url/to/data/file', }, }); }); it('should parse option code into object with 1 depth keys(without dot)', () => { // keyA & keyB ... -> chart.keyA, chart.keyB ... expect( parseToChartOption(` keyA: value1 keyB: value2 `) ).toEqual({ chart: { keyA: 'value1', keyB: 'value2', }, }); }); it('should parse option code into object with x & y keys', () => { // x & y keys should be translated to xAxis & yAxis expect( parseToChartOption(` x.keyA: value1 y.keyB: value2 `) ).toEqual({ xAxis: { keyA: 'value1', }, yAxis: { keyB: 'value2', }, }); }); it('should parse option code into object with string numeric value', () => { expect( parseToChartOption(` key1.keyA: 1.234 key1.keyB: 12 `) ).toEqual({ key1: { keyA: 1.234, keyB: 12, }, }); }); it('should parse option code into object with string array value', () => { expect( parseToChartOption(` key1.keyA: [1,2] key1.keyB: ["a", "b"] `) ).toEqual({ key1: { keyA: [1, 2], keyB: ['a', 'b'], }, }); }); it('should parse option code into object with string object value', () => { expect( parseToChartOption(` key1.keyA: {"k1": "v1"} key1.keyB: {"k2": "v2"} `) ).toEqual({ key1: { keyA: { k1: 'v1', }, keyB: { k2: 'v2', }, }, }); }); }); describe('parseToChartData()', () => { it('should parse csv to @toast-ui/chart data format', () => { expect( parseToChartData( ` ,series a,series b category 1, 1.234, 2.345 category 2, 3.456, 4.567 `, ',' ) ).toEqual({ categories: ['category 1', 'category 2'], series: [ { name: 'series a', data: [1.234, 3.456], }, { name: 'series b', data: [2.345, 4.567], }, ], }); }); it('should parse tsv to @toast-ui/chart data format', () => { expect( parseToChartData( ` \tseries a\tseries b category 1\t1.234\t2.345 category 2\t3.456\t4.567 `, '\t' ) ).toEqual({ categories: ['category 1', 'category 2'], series: [ { name: 'series a', data: [1.234, 3.456], }, { name: 'series b', data: [2.345, 4.567], }, ], }); }); it('should parse whitespace separated values to @toast-ui/chart data format', () => { expect( parseToChartData( ['\t"series a" "series b"', '"category 1" 1.234 2.345', '"category 2" 3.456 4.567'].join( '\n' ), /\s+/ ) ).toEqual({ categories: ['category 1', 'category 2'], series: [ { name: 'series a', data: [1.234, 3.456], }, { name: 'series b', data: [2.345, 4.567], }, ], }); }); it('should parse data with legends to @toast-ui/chart data format', () => { expect( parseToChartData( ` series a,series b 1.234, 2.345 3.456, 4.567 `, ',' ) ).toEqual({ categories: [], series: [ { name: 'series a', data: [1.234, 3.456], }, { name: 'series b', data: [2.345, 4.567], }, ], }); }); it('should parse data with categories to @toast-ui/chart data format', () => { expect( parseToChartData( ` category 1, 1.234, 2.345 category 2, 3.456, 4.567 `, ',' ) ).toEqual({ categories: ['category 1', 'category 2'], series: [ { data: [1.234, 3.456], }, { data: [2.345, 4.567], }, ], }); }); }); describe('detectDelimiter()', () => { it('should detect csv', () => { expect( detectDelimiter(` ,series a,series b category 1, 1.234, 2.345 category 2, 3.456, 4.567 `) ).toEqual(','); }); it('should detect tsv', () => { expect( detectDelimiter(` \tseries a\tseries b category 1\t1.234\t2.345 category 2\t3.456\t4.567 `) ).toEqual('\t'); }); it('should detect regex', () => { expect( detectDelimiter( ['\t"series a" "series b"', '"category 1"\t1.234 2.345', '"category 2" 3.456 4.567'].join( '\n' ) ) ).toEqual(/\s+/); }); }); describe('setDefaultOptions', () => { let container: HTMLElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); }); it('should respect default min/max width/height', () => { const chartOptions = setDefaultOptions( { chart: { width: -10, height: -10, }, } as ChartOptions, {} as PluginOptions, container ); expect(chartOptions.chart!.width).toBe(0); expect(chartOptions.chart!.height).toBe(0); }); it('should respect default width/height', () => { const chartOptions = setDefaultOptions( {} as ChartOptions, { width: 300, height: 400, } as PluginOptions, container ); expect(chartOptions.chart!.width).toBe(300); expect(chartOptions.chart!.height).toBe(400); }); it('should use width/height from codeblock', () => { const pluginOptions = { minWidth: 300, minHeight: 400, maxWidth: 700, maxHeight: 800, width: 400, height: 500, }; const chartOptions = setDefaultOptions( { chart: { width: 500, height: 600, }, } as ChartOptions, pluginOptions, container ); expect(chartOptions.chart!.width).toBe(500); expect(chartOptions.chart!.height).toBe(600); }); it('should respect min/max width/height', () => { const pluginOptions = { minWidth: 300, minHeight: 400, maxWidth: 700, maxHeight: 800, } as PluginOptions; let chartOptions = setDefaultOptions( { chart: { width: 200, height: 200, }, } as ChartOptions, pluginOptions, container ); expect(chartOptions.chart!.width).toBe(300); expect(chartOptions.chart!.height).toBe(400); chartOptions = setDefaultOptions( { chart: { width: 1000, height: 1000, }, } as ChartOptions, pluginOptions, container ); expect(chartOptions.chart!.width).toBe(700); expect(chartOptions.chart!.height).toBe(800); }); }); ================================================ FILE: plugins/chart/src/csv.js ================================================ /* eslint-disable */ /* CSV-JS - A Comma-Separated Values parser for JS Built to rfc4180 standard, with options for adjusting strictness: - optional carriage returns for non-microsoft sources - automatically type-cast numeric an boolean values - relaxed mode which: ignores blank lines, ignores gargabe following quoted tokens, does not enforce a consistent record length Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. Author Greg Kindel (twitter @gkindel), 2014 */ /** * @modifier NHN Cloud FE Development Lab */ 'use strict'; /** * @name CSV * @namespace * @ignore */ // implemented as a singleton because JS is single threaded var CSV = {}; CSV.RELAXED = false; CSV.IGNORE_RECORD_LENGTH = false; CSV.IGNORE_QUOTES = false; CSV.LINE_FEED_OK = true; CSV.CARRIAGE_RETURN_OK = true; CSV.DETECT_TYPES = true; CSV.IGNORE_QUOTE_WHITESPACE = true; CSV.DEBUG = false; CSV.COLUMN_SEPARATOR = ','; CSV.ERROR_EOF = 'UNEXPECTED_END_OF_FILE'; CSV.ERROR_CHAR = 'UNEXPECTED_CHARACTER'; CSV.ERROR_EOL = 'UNEXPECTED_END_OF_RECORD'; CSV.WARN_SPACE = 'UNEXPECTED_WHITESPACE'; // not per spec, but helps debugging var QUOTE = '"', CR = '\r', LF = '\n', SPACE = ' ', TAB = '\t'; // states var PRE_TOKEN = 0, MID_TOKEN = 1, POST_TOKEN = 2, POST_RECORD = 4; /** * @name CSV.parse * @function * @description rfc4180 standard csv parse * with options for strictness and data type conversion * By default, will automatically type-cast numeric an boolean values. * @param {String} str A CSV string * @return {Array} An array records, each of which is an array of scalar values. * @example * // simple * var rows = CSV.parse("one,two,three\nfour,five,six") * // rows equals [["one","two","three"],["four","five","six"]] * @example * // Though not a jQuery plugin, it is recommended to use with the $.ajax pipe() method: * $.get("csv.txt") * .pipe( CSV.parse ) * .done( function(rows) { * for( var i =0; i < rows.length; i++){ * console.log(rows[i]) * } * }); * @see http://www.ietf.org/rfc/rfc4180.txt */ CSV.parse = function (str) { var result = (CSV.result = []); CSV.COLUMN_SEPARATOR = CSV.COLUMN_SEPARATOR instanceof RegExp ? new RegExp('^' + CSV.COLUMN_SEPARATOR.source) : CSV.COLUMN_SEPARATOR; CSV.offset = 0; CSV.str = str; CSV.record_begin(); CSV.debug('parse()', str); var c; while (1) { // pull char c = str[CSV.offset++]; CSV.debug('c', c); // detect eof if (c == null) { if (CSV.escaped) { CSV.error(CSV.ERROR_EOF); } if (CSV.record) { CSV.token_end(); CSV.record_end(); } CSV.debug('...bail', c, CSV.state, CSV.record); CSV.reset(); break; } if (CSV.record == null) { // if relaxed mode, ignore blank lines if (CSV.RELAXED && (c == LF || (c == CR && str[CSV.offset + 1] == LF))) { continue; } CSV.record_begin(); } // pre-token: look for start of escape sequence if (CSV.state == PRE_TOKEN) { if ((c === SPACE || c === TAB) && CSV.next_nonspace() == QUOTE) { if (CSV.RELAXED || CSV.IGNORE_QUOTE_WHITESPACE) { continue; } else { // not technically an error, but ambiguous and hard to debug otherwise CSV.warn(CSV.WARN_SPACE); } } if (c == QUOTE && !CSV.IGNORE_QUOTES) { CSV.debug('...escaped start', c); CSV.escaped = true; CSV.state = MID_TOKEN; continue; } CSV.state = MID_TOKEN; } // mid-token and escaped, look for sequences and end quote if (CSV.state == MID_TOKEN && CSV.escaped) { if (c == QUOTE) { if (str[CSV.offset] == QUOTE) { CSV.debug('...escaped quote', c); CSV.token += QUOTE; CSV.offset++; } else { CSV.debug('...escaped end', c); CSV.escaped = false; CSV.state = POST_TOKEN; } } else { CSV.token += c; CSV.debug('...escaped add', c, CSV.token); } continue; } // fall-through: mid-token or post-token, not escaped if (c == CR) { if (str[CSV.offset] == LF) CSV.offset++; else if (!CSV.CARRIAGE_RETURN_OK) CSV.error(CSV.ERROR_CHAR); CSV.token_end(); CSV.record_end(); } else if (c == LF) { if (!(CSV.LINE_FEED_OK || CSV.RELAXED)) CSV.error(CSV.ERROR_CHAR); CSV.token_end(); CSV.record_end(); } else if (CSV.test_regex_separator(str) || CSV.COLUMN_SEPARATOR == c) { CSV.token_end(); } else if (CSV.state == MID_TOKEN) { CSV.token += c; CSV.debug('...add', c, CSV.token); } else if (c === SPACE || c === TAB) { if (!CSV.IGNORE_QUOTE_WHITESPACE) CSV.error(CSV.WARN_SPACE); } else if (!CSV.RELAXED) { CSV.error(CSV.ERROR_CHAR); } } return result; }; /** * @name CSV.stream * @function * @description stream a CSV file * @example * node -e "c=require('CSV-JS');require('fs').createReadStream('csv.txt').pipe(c.stream()).pipe(c.stream.json()).pipe(process.stdout)" * @ignore */ CSV.stream = function () { var stream = require('stream'); var s = new stream.Transform({ objectMode: true }); s.EOL = '\n'; s.prior = ''; s.emitter = (function (s) { return function (e) { s.push(CSV.parse(e + s.EOL)); }; })(s); s._transform = function (chunk, encoding, done) { var lines = this.prior == '' ? chunk.toString().split(this.EOL) : (this.prior + chunk.toString()).split(this.EOL); this.prior = lines.pop(); lines.forEach(this.emitter); done(); }; s._flush = function (done) { if (this.prior != '') { this.emitter(this.prior); this.prior = ''; } done(); }; return s; }; CSV.test_regex_separator = function (str) { if (!(CSV.COLUMN_SEPARATOR instanceof RegExp)) { return false; } var match; str = str.slice(CSV.offset - 1); match = CSV.COLUMN_SEPARATOR.exec(str); if (match) { CSV.offset += match[0].length - 1; } return match !== null; }; CSV.stream.json = function () { var os = require('os'); var stream = require('stream'); var s = new streamTransform({ objectMode: true }); s._transform = function (chunk, encoding, done) { s.push(JSON.stringify(chunk.toString()) + os.EOL); done(); }; return s; }; CSV.reset = function () { CSV.state = null; CSV.token = null; CSV.escaped = null; CSV.record = null; CSV.offset = null; CSV.result = null; CSV.str = null; }; CSV.next_nonspace = function () { var i = CSV.offset; var c; while (i < CSV.str.length) { c = CSV.str[i++]; if (!(c == SPACE || c === TAB)) { return c; } } return null; }; CSV.record_begin = function () { CSV.escaped = false; CSV.record = []; CSV.token_begin(); CSV.debug('record_begin'); }; CSV.record_end = function () { CSV.state = POST_RECORD; if ( !(CSV.IGNORE_RECORD_LENGTH || CSV.RELAXED) && CSV.result.length > 0 && CSV.record.length != CSV.result[0].length ) { CSV.error(CSV.ERROR_EOL); } CSV.result.push(CSV.record); CSV.debug('record end', CSV.record); CSV.record = null; }; CSV.resolve_type = function (token) { if (token.match(/^[-+]?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$/)) { token = parseFloat(token); } else if (token.match(/^(true|false)$/i)) { token = Boolean(token.match(/true/i)); } else if (token === 'undefined') { token = undefined; } else if (token === 'null') { token = null; } return token; }; CSV.token_begin = function () { CSV.state = PRE_TOKEN; // considered using array, but http://www.sitepen.com/blog/2008/05/09/string-performance-an-analysis/ CSV.token = ''; }; CSV.token_end = function () { if (CSV.DETECT_TYPES) { CSV.token = CSV.resolve_type(CSV.token); } CSV.record.push(CSV.token); CSV.debug('token end', CSV.token); CSV.token_begin(); }; CSV.debug = function () { if (CSV.DEBUG) console.log(arguments); }; CSV.dump = function (msg) { return [ msg, 'at char', CSV.offset, ':', CSV.str .substr(CSV.offset - 50, 50) .replace(/\r/gm, '\\r') .replace(/\n/gm, '\\n') .replace(/\t/gm, '\\t'), ].join(' '); }; CSV.error = function (err) { var msg = CSV.dump(err); CSV.reset(); throw msg; }; CSV.warn = function (err) { if (!CSV.DEBUG) { return; } var msg = CSV.dump(err); try { console.warn(msg); return; } catch (e) {} try { console.log(msg); } catch (e) {} }; export default CSV; ================================================ FILE: plugins/chart/src/index.ts ================================================ /** * @example * $$chart * \tcat1\tcat2 => tsv, csv format chart data * jan\t21\t23 * feb\t351\t45 * // url: http://url.to/csv => fetch data from the url when not using plain data * => space required as a separator * type: area => tui.chart.areaChart() * width: 700 => chart.width * height: 300 => chart.height * title: Monthly Revenue => chart.title * format: 1000 => chart.format * x.title: Amount => xAxis.title * x.min: 0 => xAxis.min * x.max 9000 => xAxis.max * x.suffix: $ => xAxis.suffix * y.title: Month => yAxis.title * $$ */ import type { PluginInfo, MdNode, PluginContext } from '@toast-ui/editor'; import Chart, { BaseOptions, LineChart, AreaChart, BarChart, PieChart, ColumnChart, } from '@toast-ui/chart'; import isString from 'tui-code-snippet/type/isString'; import isUndefined from 'tui-code-snippet/type/isUndefined'; import inArray from 'tui-code-snippet/array/inArray'; import extend from 'tui-code-snippet/object/extend'; // @ts-ignore import ajax from 'tui-code-snippet/ajax/index.js'; import { PluginOptions } from '@t/index'; import csv from './csv'; import { trimKeepingTabs, isNumeric, clamp } from './util'; // csv configuration csv.IGNORE_QUOTE_WHITESPACE = false; csv.IGNORE_RECORD_LENGTH = true; csv.DETECT_TYPES = false; const reEOL = /[\n\r]/; const reGroupByDelimiter = /([^:]+)?:?(.*)/; const DEFAULT_DELIMITER = /\s+/; const DELIMITERS = [',', '\t']; const MINIMUM_DELIM_CNT = 2; const SUPPORTED_CHART_TYPES = ['bar', 'column', 'line', 'area', 'pie']; const CATEGORY_CHART_TYPES = ['line', 'area']; const DEFAULT_DIMENSION_OPTIONS = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity, height: 'auto', width: 'auto', }; const RESERVED_KEYS = ['type', 'url']; const chart = { bar: Chart.barChart, column: Chart.columnChart, area: Chart.areaChart, line: Chart.lineChart, pie: Chart.pieChart, }; const chartMap: Record = {}; type ChartType = keyof typeof chart; export type ChartOptions = BaseOptions & { editorChart: { type?: ChartType; url?: string } }; type ChartInstance = BarChart | ColumnChart | AreaChart | LineChart | PieChart; type ChartData = { categories: string[]; series: { data: number[]; name?: string }[]; }; type ParserCallback = (parsedInfo?: { data: ChartData; options?: ChartOptions }) => void; type OnSuccess = (res: { data: any }) => void; export function parse(text: string, callback: ParserCallback) { text = trimKeepingTabs(text); const [firstTexts, secondTexts] = text.split(/\n{2,}/); const urlOptions = parseToChartOption(firstTexts); const url = urlOptions?.editorChart?.url; // if first text is `options` and has `url` option, fetch data from url if (isString(url)) { // url option provided // fetch data from url const success: OnSuccess = ({ data }) => { callback({ data: parseToChartData(data), options: parseToChartOption(firstTexts) }); }; const error = () => callback(); ajax.get(url, { success, error }); } else { const data = parseToChartData(firstTexts); const options = parseToChartOption(secondTexts); callback({ data, options }); } } export function detectDelimiter(text: string) { let delimiter: string | RegExp = DEFAULT_DELIMITER; let delimCnt = 0; text = trimKeepingTabs(text); DELIMITERS.forEach((delim) => { const matched = text.match(new RegExp(delim, 'g'))!; if (matched?.length > Math.max(MINIMUM_DELIM_CNT, delimCnt)) { delimiter = delim; delimCnt = matched.length; } }); return delimiter; } export function parseToChartData(text: string, delimiter?: string | RegExp) { // trim all heading/trailing blank lines text = trimKeepingTabs(text); // @ts-ignore csv.COLUMN_SEPARATOR = delimiter || detectDelimiter(text); let dsv: string[][] = csv.parse(text); // trim all values in 2D array dsv = dsv.map((arr) => arr.map((val) => val.trim())); // test a first row for legends. ['anything', '1', '2', '3'] === false, ['anything', 't1', '2', 't3'] === true const hasLegends = dsv[0] .filter((_, i) => i > 0) .reduce((hasNaN, item) => hasNaN || !isNumeric(item), false); const legends = hasLegends ? dsv.shift()! : []; // test a first column for categories const hasCategories = dsv.slice(1).reduce((hasNaN, row) => hasNaN || !isNumeric(row[0]), false); const categories = hasCategories ? dsv.map((arr) => arr.shift()!) : []; if (hasCategories) { legends.shift(); } // transpose dsv, parse number // [['1','2','3'] [[1,4,7] // ['4','5','6'] => [2,5,8] // ['7','8','9']] [3,6,9]] const tdsv = dsv[0].map((_, i) => dsv.map((x) => parseFloat(x[i]))); // make series const series = tdsv.map((data, i) => hasLegends ? { name: legends[i], data, } : { data, } ); return { categories, series }; } function createOptionKeys(keyString: string) { const keys = keyString.trim().split('.'); const [topKey] = keys; if (inArray(topKey, RESERVED_KEYS) >= 0) { // reserved keys for chart plugin option keys.unshift('editorChart'); } else if (keys.length === 1) { // short names for `chart` keys.unshift('chart'); } else if (topKey === 'x' || topKey === 'y') { // short-handed keys keys[0] = `${topKey}Axis`; } return keys; } export function parseToChartOption(text: string) { const options: Record = {}; if (!isUndefined(text)) { const lineTexts = text.split(reEOL); lineTexts.forEach((lineText) => { const matched = lineText.match(reGroupByDelimiter); if (matched) { // keyString can be nested object keys // ex) key1.key2.key3: value // eslint-disable-next-line prefer-const let [, keyString, value] = matched; if (value) { try { value = JSON.parse(value.trim()); } catch (e) { value = value.trim(); } const keys = createOptionKeys(keyString); let refOptions = options; keys.forEach((key, index) => { refOptions[key] = refOptions[key] || (keys.length - 1 === index ? value : {}); // should change the ref option object to assign nested property refOptions = refOptions[key]; }); } } }); } return options as ChartOptions; } function getAdjustedDimension(size: 'auto' | number, containerWidth: number) { return size === 'auto' ? containerWidth : size; } function getChartDimension( chartOptions: ChartOptions, pluginOptions: PluginOptions, chartContainer: HTMLElement ) { const dimensionOptions = extend({ ...DEFAULT_DIMENSION_OPTIONS }, pluginOptions); const { maxWidth, minWidth, maxHeight, minHeight } = dimensionOptions; // if no width or height specified, set width and height to container width const { width: containerWidth } = chartContainer.getBoundingClientRect(); let { width = dimensionOptions.width, height = dimensionOptions.height } = chartOptions.chart!; width = getAdjustedDimension(width, containerWidth); height = getAdjustedDimension(height, containerWidth); return { width: clamp(width, minWidth, maxWidth), height: clamp(height, minHeight, maxHeight), }; } export function setDefaultOptions( chartOptions: ChartOptions, pluginOptions: PluginOptions, chartContainer: HTMLElement ) { chartOptions = extend( { editorChart: {}, chart: {}, exportMenu: {}, }, chartOptions ); const { width, height } = getChartDimension(chartOptions, pluginOptions, chartContainer); chartOptions.chart!.width = width; chartOptions.chart!.height = height; // default chart type chartOptions.editorChart.type = chartOptions.editorChart.type || 'column'; // default visibility of export menu chartOptions.exportMenu!.visible = !!chartOptions.exportMenu!.visible; return chartOptions; } function destroyChart() { Object.keys(chartMap).forEach((id) => { const container = document.querySelector(`[data-chart-id=${id}]`); if (!container) { chartMap[id].destroy(); delete chartMap[id]; } }); } function renderChart( id: string, text: string, usageStatistics: boolean, pluginOptions: PluginOptions ) { // should draw the chart after rendering container element const chartContainer = document.querySelector(`[data-chart-id=${id}]`)!; destroyChart(); if (chartContainer) { try { parse(text, (parsedInfo) => { const { data, options } = parsedInfo || {}; const chartOptions = setDefaultOptions(options!, pluginOptions, chartContainer); const chartType = chartOptions.editorChart.type!; if ( !data || (CATEGORY_CHART_TYPES.indexOf(chartType) > -1 && data.categories.length !== data.series[0].data.length) ) { chartContainer.innerHTML = 'invalid chart data'; } else if (SUPPORTED_CHART_TYPES.indexOf(chartType) < 0) { chartContainer.innerHTML = `invalid chart type. type: bar, column, line, area, pie`; } else { const toastuiChart = chart[chartType]; chartOptions.usageStatistics = usageStatistics; // @ts-ignore chartMap[id] = toastuiChart({ el: chartContainer, data, options: chartOptions }); } }); } catch (e) { chartContainer.innerHTML = 'invalid chart data'; } } } function generateId() { return `chart-${Math.random().toString(36).substr(2, 10)}`; } let timer: NodeJS.Timeout | null = null; function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } } /** * Chart plugin * @param {Object} context - plugin context for communicating with editor * @param {Object} options - chart options * @param {number} [options.minWidth=0] - minimum width * @param {number} [options.minHeight=0] - minimum height * @param {number} [options.maxWidth=Infinity] - maximum width * @param {number} [options.maxHeight=Infinity] - maximum height * @param {number|string} [options.width='auto'] - default width * @param {number|string} [options.height='auto'] - default height */ export default function chartPlugin( { usageStatistics = true }: PluginContext, options: PluginOptions ): PluginInfo { return { toHTMLRenderers: { chart(node: MdNode) { const id = generateId(); clearTimer(); timer = setTimeout(() => { renderChart(id, node.literal!, usageStatistics, options); }); return [ { type: 'openTag', tagName: 'div', outerNewLine: true, attributes: { 'data-chart-id': id }, }, { type: 'closeTag', tagName: 'div', outerNewLine: true }, ]; }, }, }; } ================================================ FILE: plugins/chart/src/util.ts ================================================ export function trimKeepingTabs(text: string) { return text.replace(/(^(\s*[\n\r])+)|([\n\r]+\s*$)/g, ''); } export function isNumeric(text: string) { const mayBeNum = Number(text); return !isNaN(mayBeNum) && isFinite(mayBeNum); } export function clamp(value: number, min: number, max: number) { if (min > max) { [max, min] = [min, max]; } return Math.max(min, Math.min(value, max)); } ================================================ FILE: plugins/chart/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "types/**/*", "../../types/**/*"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "importHelpers": false, "paths": { "@/*": ["src/*"], "@t/*": ["types/*"] }, "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: plugins/chart/types/index.d.ts ================================================ import type { PluginContext, PluginInfo } from '@toast-ui/editor'; export interface PluginOptions { minWidth: number; maxWidth: number; minHeight: number; maxHeight: number; width: number | 'auto'; height: number | 'auto'; } export default function chartPlugin(context: PluginContext, options: PluginOptions): PluginInfo; ================================================ FILE: plugins/chart/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const { name, version, author, license } = require('./package.json'); const TerserPlugin = require('terser-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); function getOutputConfig(isProduction, isCDN, minify) { const filename = `toastui-${name.replace(/@toast-ui\//, '')}`; const defaultConfig = { library: { name: ['toastui', 'Editor', 'plugin', 'chart'], export: 'default', type: 'umd', }, environment: { arrowFunction: false, const: false, }, }; if (!isProduction || isCDN) { const config = { ...defaultConfig, path: path.resolve(__dirname, 'dist/cdn'), filename: `${filename}${minify ? '.min' : ''}.js`, }; if (!isProduction) { config.publicPath = '/dist/cdn'; } return config; } return { ...defaultConfig, path: path.resolve(__dirname, 'dist'), filename: `${filename}.js`, }; } function getExternalsConfig() { return [ { '@toast-ui/chart': { commonjs: '@toast-ui/chart', commonjs2: '@toast-ui/chart', amd: '@toast-ui/chart', root: ['toastui', 'Chart'], }, }, ]; } function getOptimizationConfig(isProduction, minify) { const minimizer = []; if (isProduction && minify) { minimizer.push( new TerserPlugin({ parallel: true, extractComments: false, }) ); } return { minimizer }; } module.exports = (env) => { const isProduction = env.WEBPACK_BUILD; const { minify = false, cdn = false } = env; const config = { mode: isProduction ? 'production' : 'development', entry: './src/index.ts', output: getOutputConfig(isProduction, cdn, minify), externals: getExternalsConfig(isProduction, cdn), resolve: { fallback: { stream: require.resolve('stream-browserify'), buffer: require.resolve('buffer'), os: require.resolve('os-browserify'), }, extensions: ['.ts', '.js'], }, module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], }, ], }, plugins: [ new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: isProduction, }), ], optimization: getOptimizationConfig(isProduction, minify), }; if (isProduction) { config.plugins.push( new webpack.BannerPlugin( [ 'TOAST UI Editor : Chart Plugin', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n') ) ); } else { config.devServer = { // https://github.com/webpack/webpack-dev-server/issues/2484 injectClient: false, inline: true, host: '0.0.0.0', port: 8081, }; config.devtool = 'inline-source-map'; } return config; }; ================================================ FILE: plugins/code-syntax-highlight/README.md ================================================ # TOAST UI Editor : Code Syntax Highlight Plugin > This is a plugin of [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor) to highlight code syntax. [![npm version](https://img.shields.io/npm/v/@toast-ui/editor-plugin-code-syntax-highlight.svg)](https://www.npmjs.com/package/@toast-ui/editor-plugin-code-syntax-highlight) ![code-syntax-highlight](https://user-images.githubusercontent.com/37766175/121834103-de6c3d00-cd08-11eb-870f-6ff943f65f8b.png) ## 🚩 Table of Contents - [Bundle File Structure](#-bundle-file-structure) - [Usage npm](#-usage-npm) - [Usage CDN](#-usage-cdn) ## 📁 Bundle File Structure ### Serve with npm ### Files Distributed on npm ``` - node_modules/ - @toast-ui/ - editor-plugin-code-syntax-highlight/ - dist/ - toastui-editor-plugin-code-syntax-highlight-all.js - toastui-editor-plugin-code-syntax-highlight.js - toastui-editor-plugin-code-syntax-highlight.css ``` ### Files Distributed on CDN ``` - uicdn.toast.com/ - editor-plugin-code-syntax-highlight/ - latest/ - toastui-editor-plugin-code-syntax-highlight.js - toastui-editor-plugin-code-syntax-highlight.min.js - toastui-editor-plugin-code-syntax-highlight-all.js - toastui-editor-plugin-code-syntax-highlight-all.min.js - toastui-editor-plugin-code-syntax-highlight.css - toastui-editor-plugin-code-syntax-highlight.min.css ``` ## 📦 Usage npm To use the plugin, [`@toast-ui/editor`](https://github.com/nhn/tui.editor/tree/master/apps/editor) must be installed. > Ref. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) ### Install ```sh $ npm install @toast-ui/editor-plugin-code-syntax-highlight ``` ### Import Plugin Along with the plugin, the plugin's dependency style must be imported. The `code-syntax-highlight` plugin has [`prismjs`](https://prismjs.com/) as a dependency, and you need to add a CSS file of `prismjs`. #### ES Modules ```js import 'prismjs/themes/prism.css'; import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'; import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight'; ``` #### CommonJS ```js require('prismjs/themes/prism.css'); require('@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'); const codeSyntaxHighlight = require('@toast-ui/editor-plugin-code-syntax-highlight'); ``` ### Create Instance When you set up a plugin function, you must set it with an option. The option has `highlighter`, and you need to import [`prismjs`](https://www.npmjs.com/package/prismjs) before creating an instance and set it to the value of that option. The main bundle file of `prismjs` contains just several language pack it supports. So we provides the bundle file(`toastui-editor-plugin-code-syntax-highlight-all.js`) to import all languages you need in `prismjs`. #### Basic ##### Import All Languages ```js // ... import 'prismjs/themes/prism.css'; import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'; import Editor from '@toast-ui/editor'; import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all.js'; const editor = new Editor({ // ... plugins: [codeSyntaxHighlight] }); ``` ##### Import Only Languages ​​You Need You need to import the language files you want to use in the code block and register them in the `prismjs` object. A list of available language files can be found [here](https://github.com/PrismJS/prism/tree/master/components). ```js // ... import 'prismjs/themes/prism.css'; import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'; // Step 1. Import prismjs import Prism from 'prismjs'; // Step 2. Import language files of prismjs that you need import 'prismjs/components/prism-clojure.js'; import Editor from '@toast-ui/editor'; import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight'; const editor = new Editor({ // ... plugins: [[codeSyntaxHighlight, { highlighter: Prism }]] }); ``` #### With Viewer As with creating an editor instance, you need to import `prismjs` and pass it to the `highlighter` option. ```js // ... import 'prismjs/themes/prism.css'; import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'; // Import prismjs import Prism from 'prismjs'; import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer'; import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight'; const viewer = new Viewer({ // ... plugins: [[codeSyntaxHighlight, { highlighter: Prism }]] }); ``` or ```js // ... import 'prismjs/themes/prism.css'; import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'; // Import prismjs import Prism from 'prismjs'; import Editor from '@toast-ui/editor'; import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight'; const viewer = Editor.factory({ // ... viewer: true, plugins: [[codeSyntaxHighlight, { highlighter: Prism }]] }); ``` ## 🗂 Usage CDN ### Include Files To use the plugin, the CDN files(CSS, Script) of `@toast-ui/editor` must be included. ### Create Instance #### Basic First, include the editor file. And include the plugin file as needed. If you want to include all language files provided by `prismjs`, see the first title(_Include All Languages_). If you want to register and use only the languages ​​you need, see the second title(_Include Only Languages ​​You Need_). ##### Include All Languages By including the **all** version of the plugin, all languages ​​of `prismjs` are available in the code block. ```html ... ... ... ... ... ... ``` ```js const { Editor } = toastui; const { codeSyntaxHighlight } = Editor.plugin; const instance = new Editor({ // ... plugins: [codeSyntaxHighlight] }); ``` ##### Include Only Languages ​​You Need If you include the **normal** version of the plugin, only the languages ​​you need are available. At this time, you should also include the language files of `prismjs`, and if you only include it, the languages ​​available to the plugin are registered. > Note : The CDN provided by `prismjs` contains several language files. If you want to add other language files, you can use [cdnjs](https://cdnjs.com/libraries/prism) to add each language file or upload a file containing only the language you need on [this page](https://prismjs.com/download.html). ```html ... ... ... ... ... ... ``` #### With Viewer The way to include the plugin and the language files of `prismjs` is the same as above. ##### Use Option of Editor ```js const { Editor } = tosatui; const { codeSyntaxHighlight } = Editor.plugin; const editor = Editor.factory({ // ... plugins: [codeSyntaxHighlight], viewer: true }); ``` ##### Use Viewer Include the Viewer file instead of the Editor. ```html ... ... ... ... ... ... ``` ```js const Viewer = toastui.Editor; const { codeSyntaxHighlight } = Viewer.plugin; const viewer = new Viewer({ // ... plugins: [codeSyntaxHighlight] }); ``` ================================================ FILE: plugins/code-syntax-highlight/demo/editor-all-langs.html ================================================ Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/code-syntax-highlight/demo/editor.html ================================================ Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/code-syntax-highlight/demo/esm/index.html ================================================ Test to use plugin in node environment
                          ================================================ FILE: plugins/code-syntax-highlight/demo/viewer.html ================================================ Viewer
                          ================================================ FILE: plugins/code-syntax-highlight/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; ================================================ FILE: plugins/code-syntax-highlight/package.json ================================================ { "name": "@toast-ui/editor-plugin-code-syntax-highlight", "version": "3.1.0", "description": "TOAST UI Editor : Code Syntax Highlight Plugin", "keywords": [ "nhn", "nhn cloud", "toast", "toastui", "toast-ui", "editor", "plugin", "codeblock", "highlight" ], "main": "dist/toastui-editor-plugin-code-syntax-highlight.js", "types": "types/index.d.ts", "files": [ "dist/*.js", "dist/*.css", "types/index.d.ts" ], "author": "NHN Cloud FE Development Lab ", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "plugins/code-syntax-highlight" }, "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "homepage": "https://ui.toast.com", "browserslist": "last 2 versions, not ie <= 10", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "serve:ie:all": "webpack serve --env all", "build:all": "webpack build --env all", "build:cdn": "webpack build --env cdn & webpack build --env cdn minify", "build:cdn-all": "webpack build --env cdn all & webpack build --env cdn all minify", "build": "webpack build && npm run build:cdn && npm run build:cdn-all && npm run build:all" }, "devDependencies": { "@types/prismjs": "^1.16.3", "cross-env": "^6.0.3" }, "dependencies": { "prismjs": "^1.23.0" }, "publishConfig": { "access": "public" } } ================================================ FILE: plugins/code-syntax-highlight/snowpack.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const httpProxy = require('http-proxy'); const proxy = httpProxy.createServer({ target: 'http://localhost:8080' }); /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, devOptions: { port: 8081, }, routes: [ { src: '/img/.*', dest: (req, res) => { proxy.web(req, res); }, }, ], alias: { '@': './src', '@t': './types', }, }; ================================================ FILE: plugins/code-syntax-highlight/src/__test__/integration/__snapshots__/codeHighlightPlugin.spec.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`codeSyntaxHighlightPlugin should render codeblock element with no language info in markdown preview 1`] = `
                            
                              console.log(123);
                            
                          
                          `; exports[`codeSyntaxHighlightPlugin should render highlighted codeblock element in markdown preview 1`] = `
                            
                              
                                martin
                              
                              
                                :
                              
                              
                                name
                              
                              
                                :
                              
                              Martin D'vloper
                              
                                job
                              
                              
                                :
                              
                              Developer
                              
                                skill
                              
                              
                                :
                              
                              Elite
                            
                          
                          `; exports[`codeSyntaxHighlightPlugin should render highlighted codeblock element in wysiwyg 1`] = `
                              
                                
                                  martin
                                
                                
                                  :
                                
                                
                                  name
                                
                                
                                  :
                                
                                Martin D'vloper
                                
                                  job
                                
                                
                                  :
                                
                                Developer
                                
                                  skill
                                
                                
                                  :
                                
                                Elite
                              
                            
                          `; ================================================ FILE: plugins/code-syntax-highlight/src/__test__/integration/__snapshots__/codeHighlightPluginWithAllLangs.spec.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`codeSyntaxHighlightPlugin should render highlighted codeblock element in markdown preview 1`] = `
                            
                              
                                martin
                              
                              
                                :
                              
                              
                                name
                              
                              
                                :
                              
                              Martin D'vloper
                              
                                job
                              
                              
                                :
                              
                              Developer
                              
                                skill
                              
                              
                                :
                              
                              Elite
                            
                          
                          `; exports[`codeSyntaxHighlightPlugin should render highlighted codeblock element in wysiwyg 1`] = `
                              
                                
                                  martin
                                
                                
                                  :
                                
                                
                                  name
                                
                                
                                  :
                                
                                Martin D'vloper
                                
                                  job
                                
                                
                                  :
                                
                                Developer
                                
                                  skill
                                
                                
                                  :
                                
                                Elite
                              
                            
                          `; ================================================ FILE: plugins/code-syntax-highlight/src/__test__/integration/codeHighlightPlugin.spec.ts ================================================ import { source } from 'common-tags'; import Editor from '@toast-ui/editor'; import codeSyntaxHighlightPlugin from '@/index'; import Prism from 'prismjs'; import 'prismjs/components/prism-yaml.js'; describe('codeSyntaxHighlightPlugin', () => { let container: HTMLElement, mdPreview: HTMLElement, wwEditor: HTMLElement, editor: Editor; const initialValue = source` \`\`\`yaml martin: name: Martin D'vloper job: Developer skill: Elite \`\`\` `; function getPreviewHTML() { return mdPreview .querySelector('.toastui-editor-contents')! .innerHTML.replace(/\sdata-nodeid="\d+"|\n/g, '') .trim(); } function getWwEditorHTML() { return wwEditor.firstElementChild!.innerHTML; } beforeEach(() => { container = document.createElement('div'); editor = new Editor({ el: container, previewStyle: 'vertical', initialValue, plugins: [[codeSyntaxHighlightPlugin, { highlighter: Prism }]], }); const elements = editor.getEditorElements(); mdPreview = elements.mdPreview!; wwEditor = elements.wwEditor!; document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); it('should render highlighted codeblock element in markdown preview', () => { const previewHTML = getPreviewHTML(); expect(previewHTML).toMatchSnapshot(); }); it('should render highlighted codeblock element in wysiwyg', () => { editor.changeMode('wysiwyg'); const wwEditorHTML = getWwEditorHTML(); expect(wwEditorHTML).toMatchSnapshot(); }); it('should render codeblock element with no language info in markdown preview', () => { const markdown = source` \`\`\` console.log(123); \`\`\` `; editor.setMarkdown(markdown); const previewHTML = getPreviewHTML(); expect(previewHTML).toMatchSnapshot(); }); }); ================================================ FILE: plugins/code-syntax-highlight/src/__test__/integration/codeHighlightPluginWithAllLangs.spec.ts ================================================ import { source } from 'common-tags'; import Editor from '@toast-ui/editor'; import codeSyntaxHighlightPlugin from '@/indexAll'; describe('codeSyntaxHighlightPlugin', () => { let container: HTMLElement, mdPreview: HTMLElement, wwEditor: HTMLElement, editor: Editor; const initialValue = source` \`\`\`yaml martin: name: Martin D'vloper job: Developer skill: Elite \`\`\` `; function getPreviewHTML() { return mdPreview .querySelector('.toastui-editor-contents')! .innerHTML.replace(/\sdata-nodeid="\d+"|\n/g, '') .trim(); } function getWwEditorHTML() { return wwEditor.firstElementChild!.innerHTML; } beforeEach(() => { container = document.createElement('div'); editor = new Editor({ el: container, previewStyle: 'vertical', initialValue, plugins: [codeSyntaxHighlightPlugin], }); const elements = editor.getEditorElements(); mdPreview = elements.mdPreview!; wwEditor = elements.wwEditor!; document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); it('should render highlighted codeblock element in markdown preview', () => { const previewHTML = getPreviewHTML(); expect(previewHTML).toMatchSnapshot(); }); it('should render highlighted codeblock element in wysiwyg', () => { editor.changeMode('wysiwyg'); const wwEditorHTML = getWwEditorHTML(); expect(wwEditorHTML).toMatchSnapshot(); }); }); ================================================ FILE: plugins/code-syntax-highlight/src/__test__/unit/languageSelectBox.spec.ts ================================================ import { LanguageSelectBox, WRAPPER_CLASS_NAME, INPUT_CLASS_NANE, LIST_CLASS_NAME, } from '@/nodeViews/languageSelectBox'; import { cls } from '@/utils/dom'; import type { Emitter } from '@toast-ui/editor'; Element.prototype.scrollIntoView = jest.fn(); describe('languageSelectBox', () => { let selectBox: LanguageSelectBox, eventEmitter: Emitter, wrapper: HTMLElement, input: HTMLInputElement, list: HTMLElement, wwContainer: HTMLElement; beforeEach(() => { eventEmitter = { emit: jest.fn(), emitReduce: jest.fn(), listen: jest.fn(), removeEventHandler: jest.fn(), addEventType: jest.fn(), getEvents: jest.fn(), holdEventInvoke: jest.fn(), }; wwContainer = document.createElement('div'); wwContainer.className = 'toastui-editor ww-mode'; document.body.appendChild(wwContainer); selectBox = new LanguageSelectBox(document.body, eventEmitter, ['js', 'css', 'ts']); wrapper = document.body.querySelector(`.${cls(WRAPPER_CLASS_NAME)}`)!; input = document.body.querySelector(`.${cls(INPUT_CLASS_NANE)} > input`)!; list = document.body.querySelector(`.${cls(LIST_CLASS_NAME)}`)!; }); afterEach(() => { selectBox.destroy(); document.body.removeChild(wwContainer); }); it('should create language select box element', () => { expect(wrapper).toHaveClass(`${cls(WRAPPER_CLASS_NAME)}`); }); it('show() should show language select box element', () => { selectBox.show(); expect(wrapper).not.toHaveStyle('display: none'); }); it('hide() should hide language select box element', () => { selectBox.show(); selectBox.hide(); expect(wrapper).toHaveStyle('display: none'); }); it('destory() should remove element on body', () => { selectBox.destroy(); expect(wwContainer).toBeEmptyDOMElement(); expect(eventEmitter.removeEventHandler).toHaveBeenCalled(); }); it('setLanguage() should change input value to selected language', () => { selectBox.setLanguage('foo'); expect(input).toHaveValue('foo'); }); describe('wrapper element', () => { it('should change to active state when input is focused', () => { input.focus(); expect(wrapper).toHaveClass('active'); }); it('should change to inactive state when input is focused out', () => { input.focus(); input.blur(); expect(wrapper).not.toHaveClass('active'); }); }); describe('language list element', () => { it('should show when input is focused', () => { input.focus(); expect(list).toHaveStyle('display: block'); }); it('should hide when input is focused out', () => { input.focus(); input.blur(); expect(list).toHaveStyle('display: none'); }); }); }); ================================================ FILE: plugins/code-syntax-highlight/src/css/plugin.css ================================================ /* prevent to create draggable box in IE with prism */ pre[class*="language-"] { overflow: visible; } .toastui-editor-ww-code-block-highlighting { position: relative; } .toastui-editor-ww-code-block-highlighting:after { content: attr(data-language); position: absolute; display: inline-block; top: 10px; right: 10px; height: 24px; padding: 3px 30px 0 10px; font-weight: bold; font-size: 13px; color: #333; background-color: #e5e9ea; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzU1NTU1NTt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg=='); background-repeat: no-repeat; background-position: right; background-size: 30px 30px; border-radius: 2px; cursor: pointer; } .toastui-editor-code-block-language { position: fixed; display: inline-block; right: 35px; z-index: 30; } .toastui-editor-code-block-language-input { position: relative; display: inline-block; padding: 0 22px 0 10px; width: 112px; height: 26px; border: 1px solid #ccc; border-radius: 2px; background-color: #fff; cursor: pointer; } .toastui-editor-code-block-language-input input { margin: 0; padding: 0; height: 100%; width: 100%; background-color: #fff; color: #222; border: none; outline: none; } .toastui-editor-code-block-language-input input::placeholder { color: #ccc; } .toastui-editor-code-block-language-input input::-ms-clear { display: none; } .toastui-editor-code-block-language .toastui-editor-code-block-language-input::after { content: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTIgMTQiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDEyIDE0OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzIyMjIyMjt9Cjwvc3R5bGU+CjxkZXNjPkNyZWF0ZWQgd2l0aCBza2V0Y2h0b29sLjwvZGVzYz4KPGcgaWQ9IlN5bWJvbHMiPgoJPGcgaWQ9ImNvbS10cmFuZ2xlLWQtc2lkZSI+CgkJPHBvbHlnb24gaWQ9IlJlY3RhbmdsZS03IiBjbGFzcz0ic3QwIiBwb2ludHM9IjIsNSAxMCw1IDYsMTAgCQkiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K'); position: absolute; display: inline-block; top: 7px; right: 5px; width: 12px; height: 14px; } .toastui-editor-code-block-language.active .toastui-editor-code-block-language-input::after { content: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTIgMTQiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDEyIDE0OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzIyMjIyMjt9Cjwvc3R5bGU+CjxkZXNjPkNyZWF0ZWQgd2l0aCBza2V0Y2h0b29sLjwvZGVzYz4KPGcgaWQ9IlN5bWJvbHMiPgoJPGcgaWQ9ImNvbS10cmFuZ2xlLXVwLXNpZGUiPgoJCTxwb2x5Z29uIGlkPSJSZWN0YW5nbGUtNyIgY2xhc3M9InN0MCIgcG9pbnRzPSIyLDkgMTAsOSA2LDQgCQkiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K'); } .toastui-editor-code-block-language-list { position: fixed; margin-top: -1px; width: 144px; border: solid 1px #ccc; border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; } .toastui-editor-code-block-language-list .buttons { max-height: 169px; overflow: auto; padding: 0; } .toastui-editor-code-block-language-list button { width: 100%; background-color: #fff; border: none; outline: 0; padding: 0 10px; font-size: 13px; line-height: 24px; text-align: left; color: #222; cursor: pointer; } .toastui-editor-code-block-language-list button.active { color: #4b96e6; font-weight: bold; } .toastui-editor-code-block-language-list button:hover { background-color: #f4f7f8; } .toastui-editor-dark .toastui-editor-code-block-language-input input::placeholder { color: #eee; } .toastui-editor-dark .toastui-editor-ww-code-block-highlighting:after { background-color: #232428; border: 1px solid #393b42; color: #eee; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg=='); } .toastui-editor-dark .toastui-editor-code-block-language span { border: 1px solid #494c56; background-color: #121212; } .toastui-editor-dark .toastui-editor-code-block-language input { background-color: #121212; color: #eee; } .toastui-editor-dark .toastui-editor-code-block-language-list { box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); border: 1px solid #494c56; border-radius: 2px; } .toastui-editor-dark .toastui-editor-code-block-language-list button { color: #eee; background-color: #121212; } .toastui-editor-dark .toastui-editor-code-block-language-list button.active { color: #4b96e6; } .toastui-editor-dark .toastui-editor-code-block-language-list button:hover { background-color: #36383f; } .toastui-editor-dark .toastui-editor-code-block-language .toastui-editor-code-block-language-input::after { content: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTIgMTQiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDEyIDE0OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxkZXNjPkNyZWF0ZWQgd2l0aCBza2V0Y2h0b29sLjwvZGVzYz4KPGcgaWQ9IlN5bWJvbHMiPgoJPGcgaWQ9ImNvbS10cmFuZ2xlLWQtc2lkZSI+CgkJPHBvbHlnb24gaWQ9IlJlY3RhbmdsZS03IiBjbGFzcz0ic3QwIiBwb2ludHM9IjIsNSAxMCw1IDYsMTAgCQkiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K'); } .toastui-editor-dark .toastui-editor-code-block-language.active .toastui-editor-code-block-language-input::after { content: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTIgMTQiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDEyIDE0OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxkZXNjPkNyZWF0ZWQgd2l0aCBza2V0Y2h0b29sLjwvZGVzYz4KPGcgaWQ9IlN5bWJvbHMiPgoJPGcgaWQ9ImNvbS10cmFuZ2xlLXVwLXNpZGUiPgoJCTxwb2x5Z29uIGlkPSJSZWN0YW5nbGUtNyIgY2xhc3M9InN0MCIgcG9pbnRzPSIyLDkgMTAsOSA2LDQgCQkiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K'); } ================================================ FILE: plugins/code-syntax-highlight/src/index.ts ================================================ import { codeSyntaxHighlightPlugin } from '@/plugin'; import '@/css/plugin.css'; // Prevent to highlight all code elements automatically. // @link https://prismjs.com/docs/Prism.html#.manual // eslint-disable-next-line no-undefined if (typeof window !== undefined) { window.Prism = window.Prism || {}; window.Prism.manual = true; } export default codeSyntaxHighlightPlugin; ================================================ FILE: plugins/code-syntax-highlight/src/indexAll.ts ================================================ import Prism from 'prismjs'; import type { PluginContext, PluginInfo } from '@toast-ui/editor'; import { codeSyntaxHighlightPlugin } from '@/plugin'; import { PrismJs } from '@t/index'; import '@/prismjs-langs'; import '@/css/plugin.css'; // Prevent to highlight all code elements automatically. // @link https://prismjs.com/docs/Prism.html#.manual // eslint-disable-next-line no-undefined if (typeof window !== undefined) { window.Prism = window.Prism || {}; window.Prism.manual = true; } export default function plugin(context: PluginContext): PluginInfo { return codeSyntaxHighlightPlugin(context, { highlighter: Prism as PrismJs }); } ================================================ FILE: plugins/code-syntax-highlight/src/nodeViews/codeSyntaxHighlightView.ts ================================================ import type { EditorView, NodeView } from 'prosemirror-view'; import type { Node as ProsemirrorNode } from 'prosemirror-model'; import isFunction from 'tui-code-snippet/type/isFunction'; import addClass from 'tui-code-snippet/domUtil/addClass'; import { cls } from '@/utils/dom'; import { LanguageSelectBox } from '@/nodeViews/languageSelectBox'; import type { Emitter } from '@toast-ui/editor'; type GetPos = (() => number) | boolean; type CodeBlockPos = { top: number; right: number }; const WRAPPER_CLASS_NAME = 'ww-code-block-highlighting'; function getCustomAttrs(attrs: Record) { const { htmlAttrs, classNames } = attrs; return { ...htmlAttrs, class: classNames ? classNames.join(' ') : null }; } class CodeSyntaxHighlightView implements NodeView { dom!: HTMLElement; contentDOM: HTMLElement | null = null; private languageSelectBox: LanguageSelectBox | null = null; private languageEditing: boolean; // eslint-disable-next-line max-params constructor( private node: ProsemirrorNode, private view: EditorView, private getPos: GetPos, private eventEmitter: Emitter, private languages: string[] ) { this.node = node; this.view = view; this.getPos = getPos; this.eventEmitter = eventEmitter; this.languageEditing = false; this.languages = languages; this.createElement(); this.bindDOMEvent(); this.bindEvent(); } private createElement() { const { language } = this.node.attrs; const wrapper = document.createElement('div'); wrapper.setAttribute('data-language', language || 'text'); addClass(wrapper, cls(WRAPPER_CLASS_NAME)); const pre = this.createCodeBlockElement(); const code = pre.firstChild as HTMLElement; if (language) { addClass(pre, `language-${language}`); addClass(code, `language-${language}`); } wrapper.appendChild(pre); this.dom = wrapper; this.contentDOM = code; } private createCodeBlockElement() { const pre = document.createElement('pre'); const code = document.createElement('code'); const { language } = this.node.attrs; const attrs = getCustomAttrs(this.node.attrs); if (language) { code.setAttribute('data-language', language); } Object.keys(attrs).forEach((attrName) => { if (attrs[attrName]) { pre.setAttribute(attrName, attrs[attrName]); } }); pre.appendChild(code); return pre; } private bindDOMEvent() { if (this.dom) { this.dom.addEventListener('click', this.onClickEditingButton); this.view.dom.addEventListener('mousedown', this.finishLanguageEditing); window.addEventListener('resize', this.finishLanguageEditing); } } private bindEvent() { this.eventEmitter.listen('selectLanguage', this.onSelectLanguage); this.eventEmitter.listen('scroll', this.finishLanguageEditing); this.eventEmitter.listen('finishLanguageEditing', this.finishLanguageEditing); } private onSelectLanguage = (language: string) => { if (this.languageEditing) { this.changeLanguage(language); } }; private onClickEditingButton = (ev: MouseEvent) => { const target = ev.target as HTMLElement; const style = getComputedStyle(target, ':after'); // judge to click pseudo element with background image for IE11 if (style.backgroundImage !== 'none' && isFunction(this.getPos)) { const pos = this.view.coordsAtPos(this.getPos()); this.openLanguageSelectBox(pos); } }; private openLanguageSelectBox(pos: CodeBlockPos) { this.languageSelectBox = new LanguageSelectBox( this.view.dom.parentElement!, this.eventEmitter, this.languages ); this.eventEmitter.emit('showCodeBlockLanguages', pos, this.node.attrs.language); this.languageEditing = true; } private changeLanguage(language: string) { if (isFunction(this.getPos)) { this.reset(); const pos = this.getPos(); const { tr } = this.view.state; tr.setNodeMarkup(pos, null, { language }); this.view.dispatch(tr); } } private finishLanguageEditing = () => { if (this.languageEditing) { this.reset(); } }; private reset() { if (this.languageSelectBox) { this.languageSelectBox.destroy(); this.languageSelectBox = null; } this.languageEditing = false; } stopEvent() { return true; } update(node: ProsemirrorNode) { if (!node.sameMarkup(this.node)) { return false; } this.node = node; return true; } destroy() { this.reset(); if (this.dom) { this.dom.removeEventListener('click', this.onClickEditingButton); this.view.dom.removeEventListener('mousedown', this.finishLanguageEditing); window.removeEventListener('resize', this.finishLanguageEditing); } this.eventEmitter.removeEventHandler('selectLanguage', this.onSelectLanguage); this.eventEmitter.removeEventHandler('scroll', this.finishLanguageEditing); this.eventEmitter.removeEventHandler('finishLanguageEditing', this.finishLanguageEditing); } } export function createCodeSyntaxHighlightView(languages: string[]) { return (node: ProsemirrorNode, view: EditorView, getPos: GetPos, emitter: Emitter) => new CodeSyntaxHighlightView(node, view, getPos, emitter, languages); } ================================================ FILE: plugins/code-syntax-highlight/src/nodeViews/languageSelectBox.ts ================================================ import css from 'tui-code-snippet/domUtil/css'; import addClass from 'tui-code-snippet/domUtil/addClass'; import removeClass from 'tui-code-snippet/domUtil/removeClass'; import hasClass from 'tui-code-snippet/domUtil/hasClass'; import toArray from 'tui-code-snippet/collection/toArray'; import inArray from 'tui-code-snippet/array/inArray'; import { isPositionInBox, removeNode, cls } from '@/utils/dom'; import type { Emitter } from '@toast-ui/editor'; export const WRAPPER_CLASS_NAME = 'code-block-language'; export const INPUT_CLASS_NANE = 'code-block-language-input'; export const LIST_CLASS_NAME = 'code-block-language-list'; export const LANG_ATTR = 'data-language'; const CODE_BLOCK_PADDING = 10; function getButtonsHTML(languages: string[]) { return languages .map((language) => ``) .join(''); } export class LanguageSelectBox { private rootEl: HTMLElement; private eventEmitter: Emitter; private languages: string[]; private wrapper!: HTMLElement; private input!: HTMLInputElement; private list!: HTMLElement; private buttons: Element[] = []; private currentButton!: Element; private prevStoredLanguage = ''; constructor(rootEl: HTMLElement, eventEmitter: Emitter, languages: string[]) { this.rootEl = rootEl; this.eventEmitter = eventEmitter; this.languages = languages; this.createElement(); this.bindDOMEvent(); this.bindEvent(); } private createElement() { this.wrapper = document.createElement('div'); addClass(this.wrapper, cls(WRAPPER_CLASS_NAME)); this.createInputElement(); this.createLanguageListElement(); this.rootEl.appendChild(this.wrapper); this.hide(); } private createInputElement() { const wrapper = document.createElement('span'); addClass(wrapper, cls(INPUT_CLASS_NANE)); const input = document.createElement('input'); input.type = 'text'; input.setAttribute('maxlength', '20'); this.input = input; wrapper.appendChild(this.input); this.wrapper.appendChild(wrapper); } private createLanguageListElement() { this.list = document.createElement('div'); addClass(this.list, cls(LIST_CLASS_NAME)); const buttonsContainer = document.createElement('div'); addClass(buttonsContainer, 'buttons'); buttonsContainer.innerHTML = getButtonsHTML(this.languages); this.buttons = toArray(buttonsContainer.children); this.list.appendChild(buttonsContainer); this.wrapper.appendChild(this.list); this.activateButtonByIndex(0); this.hideList(); } private bindDOMEvent() { this.wrapper.addEventListener('mousedown', this.onSelectToggleButton); this.input.addEventListener('keydown', this.handleKeydown); this.input.addEventListener('focus', () => this.activateSelectBox()); this.input.addEventListener('blur', () => this.inactivateSelectBox()); this.list.addEventListener('mousedown', this.onSelectLanguageButtons); } private bindEvent() { this.eventEmitter.listen('showCodeBlockLanguages', this.showLangugaeSelectBox); } private onSelectToggleButton = (ev: MouseEvent) => { const target = ev.target as HTMLElement; const style = getComputedStyle(target, ':after'); const { offsetX, offsetY } = ev; if (isPositionInBox(style, offsetX, offsetY)) { ev.preventDefault(); this.toggleFocus(); } }; private onSelectLanguageButtons = (ev: MouseEvent) => { const target = ev.target as HTMLElement; const language = target.getAttribute(LANG_ATTR); if (language) { this.selectLanguage(language); } }; private handleKeydown = (ev: KeyboardEvent) => { const { key } = ev; if (key === 'ArrowUp') { this.selectPrevLanguage(); ev.preventDefault(); } else if (key === 'ArrowDown') { this.selectNextLanguage(); ev.preventDefault(); } else if (key === 'Enter' || key === 'Tab') { this.storeInputLanguage(); ev.preventDefault(); } else { this.hideList(); } }; private showLangugaeSelectBox = ( { top, right }: { top: number; right: number }, language: string ) => { if (language) { this.setLanguage(language); } this.show(); const { width } = this.input.parentElement!.getBoundingClientRect(); css(this.wrapper!, { top: `${top + CODE_BLOCK_PADDING}px`, left: `${right - width - CODE_BLOCK_PADDING}px`, }); this.toggleFocus(); }; private activateSelectBox() { addClass(this.wrapper, 'active'); css(this.list, { display: 'block' }); } private inactivateSelectBox() { this.input!.value = this.prevStoredLanguage; removeClass(this.wrapper, 'active'); this.hideList(); } private toggleFocus() { if (hasClass(this.wrapper, 'active')) { this.input.blur(); } else { this.input.focus(); } } private storeInputLanguage() { const selectedLanguage = this.input!.value; this.setLanguage(selectedLanguage); this.hideList(); this.eventEmitter.emit('selectLanguage', selectedLanguage); } private activateButtonByIndex(index: number) { if (this.currentButton) { removeClass(this.currentButton, 'active'); } if (this.buttons.length) { this.currentButton = this.buttons[index]; this.input!.value = this.currentButton.getAttribute(LANG_ATTR)!; addClass(this.currentButton, 'active'); this.currentButton.scrollIntoView(); } } private selectLanguage(selectedLanguage: string) { this.input!.value = selectedLanguage; this.storeInputLanguage(); } private selectPrevLanguage() { let index = inArray(this.currentButton, this.buttons) - 1; if (index < 0) { index = this.buttons.length - 1; } this.activateButtonByIndex(index); } private selectNextLanguage() { let index = inArray(this.currentButton, this.buttons) + 1; if (index >= this.buttons.length) { index = 0; } this.activateButtonByIndex(index); } private hideList() { css(this.list, { display: 'none' }); } show() { css(this.wrapper!, { display: 'inline-block' }); } hide() { css(this.wrapper!, { display: 'none' }); } setLanguage(language: string) { this.prevStoredLanguage = language; this.input!.value = language; const item = this.buttons.filter((button) => button.getAttribute(LANG_ATTR) === language); if (item.length) { const index = inArray(item[0], this.buttons); this.activateButtonByIndex(index); } } destroy() { removeNode(this.wrapper); this.eventEmitter.removeEventHandler('showCodeBlockLanguages', this.showLangugaeSelectBox); } } ================================================ FILE: plugins/code-syntax-highlight/src/plugin.ts ================================================ import isFunction from 'tui-code-snippet/type/isFunction'; import { getHTMLRenderers } from '@/renderers/toHTMLRenderers'; import { codeSyntaxHighlighting } from '@/plugins/codeSyntaxHighlighting'; import { createCodeSyntaxHighlightView } from '@/nodeViews/codeSyntaxHighlightView'; import type { PluginContext, PluginInfo } from '@toast-ui/editor'; import { PluginOptions } from '@t/index'; export function codeSyntaxHighlightPlugin( context: PluginContext, options?: PluginOptions ): PluginInfo { if (options) { const { eventEmitter } = context; const { highlighter: prism } = options; eventEmitter.addEventType('showCodeBlockLanguages'); eventEmitter.addEventType('selectLanguage'); eventEmitter.addEventType('finishLanguageEditing'); const { languages } = prism!; const registerdlanguages = Object.keys(languages).filter( (language) => !isFunction(languages[language]) ); return { toHTMLRenderers: getHTMLRenderers(prism!), wysiwygPlugins: [() => codeSyntaxHighlighting(context, prism!)], wysiwygNodeViews: { codeBlock: createCodeSyntaxHighlightView(registerdlanguages), }, }; } return {}; } ================================================ FILE: plugins/code-syntax-highlight/src/plugins/codeSyntaxHighlighting.ts ================================================ import type { Node as ProsemirrorNode } from 'prosemirror-model'; import type { Decoration } from 'prosemirror-view'; import isString from 'tui-code-snippet/type/isString'; import { flatten } from '@/utils/common'; import type { PluginContext } from '@toast-ui/editor'; import { PrismJs } from '@t/index'; interface ChildNodeInfo { node: ProsemirrorNode; pos: number; } interface HighlightedNodeInfo { text: string; classes: string[]; } const NODE_TYPE = 'codeBlock'; function findCodeBlocks(doc: ProsemirrorNode) { const descendants: ChildNodeInfo[] = []; doc.descendants((node, pos) => { if (node.isBlock && node.type.name === NODE_TYPE) { descendants.push({ node, pos }); } }); return descendants; } function parseTokens( tokens: (string | Prism.Token)[], classNames: string[] = [] ): HighlightedNodeInfo[] { if (isString(tokens)) { return [{ text: tokens, classes: classNames }]; } return tokens.map((token) => { const { type, alias } = token as Prism.Token; let typeClassNames: string[] = []; let aliasClassNames: string[] = []; if (type) { typeClassNames = ['token', type]; } if (alias) { aliasClassNames = isString(alias) ? [alias] : alias; } const classes: string[] = [...classNames, ...typeClassNames, ...aliasClassNames]; return isString(token) ? { text: token, classes, } : parseTokens(token.content as Prism.Token[], classes); }) as HighlightedNodeInfo[]; } function getDecorations(doc: ProsemirrorNode, context: PluginContext, prism: PrismJs) { const { pmView } = context; const decorations: Decoration[] = []; const codeBlocks = findCodeBlocks(doc); codeBlocks.forEach(({ pos, node }) => { const { language } = node.attrs; const registeredLang = prism.languages[language]; const prismTokens = registeredLang ? prism.tokenize(node.textContent, registeredLang) : []; const nodeInfos = flatten(parseTokens(prismTokens)); let startPos = pos + 1; nodeInfos.forEach(({ text, classes }) => { const from = startPos; const to = from + text.length; startPos = to; const classNames = classes.join(' '); const decoration = pmView.Decoration.inline(from, to, { class: classNames, }); if (classNames.length) { decorations.push(decoration); } }); }); return pmView.DecorationSet.create(doc, decorations); } export function codeSyntaxHighlighting(context: PluginContext, prism: PrismJs) { return new context.pmState.Plugin({ state: { init(_, { doc }) { return getDecorations(doc, context, prism); }, apply(tr, set) { if (!tr.docChanged) { return set.map(tr.mapping, tr.doc); } return getDecorations(tr.doc, context, prism); }, }, props: { decorations(state) { return this.getState(state); }, }, }); } ================================================ FILE: plugins/code-syntax-highlight/src/prismjs-langs.ts ================================================ import 'prismjs/components/prism-abap.js'; import 'prismjs/components/prism-abnf.js'; import 'prismjs/components/prism-actionscript.js'; import 'prismjs/components/prism-ada.js'; import 'prismjs/components/prism-agda.js'; import 'prismjs/components/prism-al.js'; import 'prismjs/components/prism-antlr4.js'; import 'prismjs/components/prism-apacheconf.js'; import 'prismjs/components/prism-apex.js'; import 'prismjs/components/prism-apl.js'; import 'prismjs/components/prism-applescript.js'; import 'prismjs/components/prism-aql.js'; import 'prismjs/components/prism-arff.js'; import 'prismjs/components/prism-asciidoc.js'; import 'prismjs/components/prism-asm6502.js'; import 'prismjs/components/prism-aspnet.js'; import 'prismjs/components/prism-autohotkey.js'; import 'prismjs/components/prism-autoit.js'; import 'prismjs/components/prism-bash.js'; import 'prismjs/components/prism-basic.js'; import 'prismjs/components/prism-batch.js'; import 'prismjs/components/prism-bbcode.js'; import 'prismjs/components/prism-birb.js'; import 'prismjs/components/prism-bnf.js'; import 'prismjs/components/prism-brainfuck.js'; import 'prismjs/components/prism-brightscript.js'; import 'prismjs/components/prism-bro.js'; import 'prismjs/components/prism-bsl.js'; import 'prismjs/components/prism-c.js'; import 'prismjs/components/prism-bison.js'; import 'prismjs/components/prism-cil.js'; import 'prismjs/components/prism-clojure.js'; import 'prismjs/components/prism-cmake.js'; import 'prismjs/components/prism-coffeescript.js'; import 'prismjs/components/prism-concurnas.js'; import 'prismjs/components/prism-cpp.js'; import 'prismjs/components/prism-arduino.js'; import 'prismjs/components/prism-csharp.js'; import 'prismjs/components/prism-csp.js'; import 'prismjs/components/prism-css-extras.js'; import 'prismjs/components/prism-cypher.js'; import 'prismjs/components/prism-d.js'; import 'prismjs/components/prism-dart.js'; import 'prismjs/components/prism-dataweave.js'; import 'prismjs/components/prism-dax.js'; import 'prismjs/components/prism-dhall.js'; import 'prismjs/components/prism-diff.js'; import 'prismjs/components/prism-markup-templating.js'; import 'prismjs/components/prism-django.js'; import 'prismjs/components/prism-dns-zone-file.js'; import 'prismjs/components/prism-docker.js'; import 'prismjs/components/prism-ebnf.js'; import 'prismjs/components/prism-editorconfig.js'; import 'prismjs/components/prism-eiffel.js'; import 'prismjs/components/prism-ejs.js'; import 'prismjs/components/prism-elixir.js'; import 'prismjs/components/prism-elm.js'; import 'prismjs/components/prism-erb.js'; import 'prismjs/components/prism-erlang.js'; import 'prismjs/components/prism-etlua.js'; import 'prismjs/components/prism-excel-formula.js'; import 'prismjs/components/prism-factor.js'; import 'prismjs/components/prism-firestore-security-rules.js'; import 'prismjs/components/prism-flow.js'; import 'prismjs/components/prism-fortran.js'; import 'prismjs/components/prism-fsharp.js'; import 'prismjs/components/prism-ftl.js'; import 'prismjs/components/prism-gcode.js'; import 'prismjs/components/prism-gdscript.js'; import 'prismjs/components/prism-gedcom.js'; import 'prismjs/components/prism-gherkin.js'; import 'prismjs/components/prism-git.js'; import 'prismjs/components/prism-glsl.js'; import 'prismjs/components/prism-gml.js'; import 'prismjs/components/prism-go.js'; import 'prismjs/components/prism-graphql.js'; import 'prismjs/components/prism-groovy.js'; import 'prismjs/components/prism-haml.js'; import 'prismjs/components/prism-handlebars.js'; import 'prismjs/components/prism-haskell.js'; import 'prismjs/components/prism-haxe.js'; import 'prismjs/components/prism-hcl.js'; import 'prismjs/components/prism-hlsl.js'; import 'prismjs/components/prism-hpkp.js'; import 'prismjs/components/prism-hsts.js'; import 'prismjs/components/prism-http.js'; import 'prismjs/components/prism-ichigojam.js'; import 'prismjs/components/prism-icon.js'; import 'prismjs/components/prism-iecst.js'; import 'prismjs/components/prism-ignore.js'; import 'prismjs/components/prism-inform7.js'; import 'prismjs/components/prism-ini.js'; import 'prismjs/components/prism-io.js'; import 'prismjs/components/prism-j.js'; import 'prismjs/components/prism-java.js'; import 'prismjs/components/prism-javadoclike.js'; import 'prismjs/components/prism-javadoc.js'; import 'prismjs/components/prism-typescript.js'; import 'prismjs/components/prism-javastacktrace.js'; import 'prismjs/components/prism-jolie.js'; import 'prismjs/components/prism-jq.js'; import 'prismjs/components/prism-js-extras.js'; import 'prismjs/components/prism-js-templates.js'; import 'prismjs/components/prism-jsdoc.js'; import 'prismjs/components/prism-json.js'; import 'prismjs/components/prism-json5.js'; import 'prismjs/components/prism-jsonp.js'; import 'prismjs/components/prism-jsstacktrace.js'; import 'prismjs/components/prism-jsx.js'; import 'prismjs/components/prism-julia.js'; import 'prismjs/components/prism-keyman.js'; import 'prismjs/components/prism-kotlin.js'; import 'prismjs/components/prism-latex.js'; import 'prismjs/components/prism-latte.js'; import 'prismjs/components/prism-less.js'; import 'prismjs/components/prism-lilypond.js'; import 'prismjs/components/prism-liquid.js'; import 'prismjs/components/prism-lisp.js'; import 'prismjs/components/prism-livescript.js'; import 'prismjs/components/prism-llvm.js'; import 'prismjs/components/prism-lolcode.js'; import 'prismjs/components/prism-lua.js'; import 'prismjs/components/prism-makefile.js'; import 'prismjs/components/prism-markdown.js'; import 'prismjs/components/prism-matlab.js'; import 'prismjs/components/prism-mel.js'; import 'prismjs/components/prism-mizar.js'; import 'prismjs/components/prism-mongodb.js'; import 'prismjs/components/prism-monkey.js'; import 'prismjs/components/prism-moonscript.js'; import 'prismjs/components/prism-n1ql.js'; import 'prismjs/components/prism-n4js.js'; import 'prismjs/components/prism-nand2tetris-hdl.js'; import 'prismjs/components/prism-naniscript.js'; import 'prismjs/components/prism-nasm.js'; import 'prismjs/components/prism-neon.js'; import 'prismjs/components/prism-nginx.js'; import 'prismjs/components/prism-nim.js'; import 'prismjs/components/prism-nix.js'; import 'prismjs/components/prism-nsis.js'; import 'prismjs/components/prism-objectivec.js'; import 'prismjs/components/prism-ocaml.js'; import 'prismjs/components/prism-opencl.js'; import 'prismjs/components/prism-oz.js'; import 'prismjs/components/prism-parigp.js'; import 'prismjs/components/prism-parser.js'; import 'prismjs/components/prism-pascal.js'; import 'prismjs/components/prism-pascaligo.js'; import 'prismjs/components/prism-pcaxis.js'; import 'prismjs/components/prism-peoplecode.js'; import 'prismjs/components/prism-perl.js'; import 'prismjs/components/prism-php-extras.js'; import 'prismjs/components/prism-php.js'; import 'prismjs/components/prism-phpdoc.js'; import 'prismjs/components/prism-sql.js'; import 'prismjs/components/prism-plsql.js'; import 'prismjs/components/prism-powerquery.js'; import 'prismjs/components/prism-powershell.js'; import 'prismjs/components/prism-processing.js'; import 'prismjs/components/prism-prolog.js'; import 'prismjs/components/prism-promql.js'; import 'prismjs/components/prism-properties.js'; import 'prismjs/components/prism-protobuf.js'; import 'prismjs/components/prism-pug.js'; import 'prismjs/components/prism-puppet.js'; import 'prismjs/components/prism-pure.js'; import 'prismjs/components/prism-purebasic.js'; import 'prismjs/components/prism-purescript.js'; import 'prismjs/components/prism-python.js'; import 'prismjs/components/prism-q.js'; import 'prismjs/components/prism-qml.js'; import 'prismjs/components/prism-qore.js'; import 'prismjs/components/prism-r.js'; import 'prismjs/components/prism-scheme.js'; import 'prismjs/components/prism-racket.js'; import 'prismjs/components/prism-reason.js'; import 'prismjs/components/prism-regex.js'; import 'prismjs/components/prism-renpy.js'; import 'prismjs/components/prism-rest.js'; import 'prismjs/components/prism-rip.js'; import 'prismjs/components/prism-roboconf.js'; import 'prismjs/components/prism-robotframework.js'; import 'prismjs/components/prism-ruby.js'; import 'prismjs/components/prism-crystal.js'; import 'prismjs/components/prism-rust.js'; import 'prismjs/components/prism-sas.js'; import 'prismjs/components/prism-sass.js'; import 'prismjs/components/prism-scala.js'; import 'prismjs/components/prism-scss.js'; import 'prismjs/components/prism-shell-session.js'; import 'prismjs/components/prism-smali.js'; import 'prismjs/components/prism-smalltalk.js'; import 'prismjs/components/prism-smarty.js'; import 'prismjs/components/prism-sml.js'; import 'prismjs/components/prism-solidity.js'; import 'prismjs/components/prism-solution-file.js'; import 'prismjs/components/prism-soy.js'; import 'prismjs/components/prism-turtle.js'; import 'prismjs/components/prism-sparql.js'; import 'prismjs/components/prism-splunk-spl.js'; import 'prismjs/components/prism-sqf.js'; import 'prismjs/components/prism-stan.js'; import 'prismjs/components/prism-stylus.js'; import 'prismjs/components/prism-swift.js'; import 'prismjs/components/prism-t4-templating.js'; import 'prismjs/components/prism-t4-cs.js'; import 'prismjs/components/prism-t4-vb.js'; import 'prismjs/components/prism-tap.js'; import 'prismjs/components/prism-tcl.js'; import 'prismjs/components/prism-textile.js'; import 'prismjs/components/prism-toml.js'; import 'prismjs/components/prism-tsx.js'; import 'prismjs/components/prism-tt2.js'; import 'prismjs/components/prism-twig.js'; import 'prismjs/components/prism-typoscript.js'; import 'prismjs/components/prism-unrealscript.js'; import 'prismjs/components/prism-vala.js'; import 'prismjs/components/prism-vbnet.js'; import 'prismjs/components/prism-velocity.js'; import 'prismjs/components/prism-verilog.js'; import 'prismjs/components/prism-vhdl.js'; import 'prismjs/components/prism-vim.js'; import 'prismjs/components/prism-visual-basic.js'; import 'prismjs/components/prism-warpscript.js'; import 'prismjs/components/prism-wasm.js'; import 'prismjs/components/prism-wiki.js'; import 'prismjs/components/prism-xeora.js'; import 'prismjs/components/prism-xml-doc.js'; import 'prismjs/components/prism-xojo.js'; import 'prismjs/components/prism-xquery.js'; import 'prismjs/components/prism-yaml.js'; import 'prismjs/components/prism-yang.js'; import 'prismjs/components/prism-zig.js'; ================================================ FILE: plugins/code-syntax-highlight/src/renderers/toHTMLRenderers.ts ================================================ import type { MdNode, CodeBlockMdNode } from '@toast-ui/editor'; import type { HTMLToken } from '@toast-ui/toastmark'; import { PrismJs } from '@t/index'; const BACKTICK_COUNT = 3; export function getHTMLRenderers(prism: PrismJs) { return { codeBlock(node: MdNode): HTMLToken[] { const { fenceLength, info } = node as CodeBlockMdNode; const infoWords = info ? info.split(/\s+/) : []; const preClasses = []; const codeAttrs: Record = {}; if (fenceLength > BACKTICK_COUNT) { codeAttrs['data-backticks'] = fenceLength; } let content = node.literal!; if (infoWords.length && infoWords[0].length) { const [lang] = infoWords; preClasses.push(`lang-${lang}`); codeAttrs['data-language'] = lang; const registeredLang = prism.languages[lang]; if (registeredLang) { content = prism.highlight(node.literal!, registeredLang, lang); } } return [ { type: 'openTag', tagName: 'pre', classNames: preClasses }, { type: 'openTag', tagName: 'code', attributes: codeAttrs }, { type: 'html', content }, { type: 'closeTag', tagName: 'code' }, { type: 'closeTag', tagName: 'pre' }, ]; }, }; } ================================================ FILE: plugins/code-syntax-highlight/src/utils/common.ts ================================================ export function flatten(arr: T[]): T[] { return arr.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []); } ================================================ FILE: plugins/code-syntax-highlight/src/utils/dom.ts ================================================ function stringToNumber(value: string) { return parseInt(value, 10); } export function isPositionInBox(style: CSSStyleDeclaration, offsetX: number, offsetY: number) { const left = stringToNumber(style.left); const top = stringToNumber(style.top); const width = stringToNumber(style.width) + stringToNumber(style.paddingLeft) + stringToNumber(style.paddingRight); const height = stringToNumber(style.height) + stringToNumber(style.paddingTop) + stringToNumber(style.paddingBottom); return offsetX >= left && offsetX <= left + width && offsetY >= top && offsetY <= top + height; } export function removeNode(node: Node) { if (node.parentNode) { node.parentNode.removeChild(node); } } const CLS_PREFIX = 'toastui-editor-'; export function cls(...names: string[]) { return names.map((className) => `${CLS_PREFIX}${className}`).join(' '); } ================================================ FILE: plugins/code-syntax-highlight/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "types/**/*", "../../types/**/*"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "importHelpers": false, "paths": { "@/*": ["src/*"], "@t/*": ["types/*"] }, "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: plugins/code-syntax-highlight/types/index.d.ts ================================================ import type { PluginContext, PluginInfo } from '@toast-ui/editor'; import Prism from 'prismjs'; type PrismJs = typeof Prism & { manual: boolean; }; declare global { interface Window { Prism: PrismJs; } } export type PluginOptions = { highlighter?: PrismJs; }; export default function codeSyntaxHighlightPlugin( context: PluginContext, options: PluginOptions ): PluginInfo; ================================================ FILE: plugins/code-syntax-highlight/types/prosemirror-transform.d.ts ================================================ import { Node, Mark } from 'prosemirror-model'; import 'prosemirror-transform'; declare module 'prosemirror-transform' { export interface Transform { setNodeMarkup( pos: number, type: Node | null, attrs?: { [key: string]: any }, marks?: Mark[] ): Transform; } } ================================================ FILE: plugins/code-syntax-highlight/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const { name, version, author, license } = require('./package.json'); const TerserPlugin = require('terser-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); const filename = `toastui-${name.replace(/@toast-ui\//, '')}`; const ENTRY = './src/index.ts'; const ENTRY_ALL_LANG = './src/indexAll.ts'; function getOutputConfig(isProduction, isCDN, isAll, minify) { const defaultConfig = { environment: { arrowFunction: false, const: false, }, }; if (!isProduction || isCDN) { const config = { ...defaultConfig, library: { name: ['toastui', 'Editor', 'plugin', 'codeSyntaxHighlight'], export: 'default', type: 'umd', }, path: path.resolve(__dirname, 'dist/cdn'), filename: `${filename}${isAll ? '-all' : ''}${minify ? '.min' : ''}.js`, }; if (!isProduction) { config.publicPath = '/dist/cdn'; } return config; } return { ...defaultConfig, library: { export: 'default', type: 'commonjs2', }, path: path.resolve(__dirname, 'dist'), filename: `${filename}${isAll ? '-all' : ''}.js`, }; } function getOptimizationConfig(isProduction, minify) { const minimizer = []; if (isProduction && minify) { minimizer.push( new TerserPlugin({ parallel: true, extractComments: false, }) ); minimizer.push(new CssMinimizerPlugin()); } return { minimizer }; } module.exports = (env) => { const isProduction = env.WEBPACK_BUILD; const { minify = false, cdn = false, all = false } = env; const config = { mode: isProduction ? 'production' : 'development', entry: all ? ENTRY_ALL_LANG : ENTRY, output: getOutputConfig(isProduction, cdn, all, minify), externals: ['prosemirror-state', 'prosemirror-view'], module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, ], }, resolve: { extensions: ['.ts', '.js'], alias: { '@': path.resolve('src'), '@t': path.resolve('types'), }, }, plugins: [ new MiniCssExtractPlugin({ filename: () => `${filename}${minify ? '.min' : ''}.css`, }), new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: isProduction, }), ], optimization: getOptimizationConfig(isProduction, minify), }; if (isProduction) { config.plugins.push( new webpack.BannerPlugin( [ 'TOAST UI Editor : Code Syntax Highlight Plugin', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n') ) ); } else { config.devServer = { // https://github.com/webpack/webpack-dev-server/issues/2484 injectClient: false, inline: true, host: '0.0.0.0', port: 8081, }; config.devtool = 'inline-source-map'; } return config; }; ================================================ FILE: plugins/color-syntax/README.md ================================================ # TOAST UI Editor : Color Syntax Plugin > This is a plugin of [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor) to color editing text. [![npm version](https://img.shields.io/npm/v/@toast-ui/editor-plugin-color-syntax.svg)](https://www.npmjs.com/package/@toast-ui/editor-plugin-color-syntax) ![color-syntax](https://user-images.githubusercontent.com/37766175/121813686-28710680-cca8-11eb-87c6-1dc9625369b0.png) ## 🚩 Table of Contents - [Bundle File Structure](#-bundle-file-structure) - [Usage npm](#-usage-npm) - [Usage CDN](#-usage-cdn) ## 📁 Bundle File Structure ### Files Distributed on npm ``` - node_modules/ - @toast-ui/ - editor-plugin-color-syntax/ - dist/ - toastui-editor-plugin-color-syntax.js - toastui-editor-plugin-color-syntax.css ``` ### Files Distributed on CDN The bundle files include all dependencies of this plugin. ``` - uicdn.toast.com/ - editor-plugin-color-syntax/ - latest/ - toastui-editor-plugin-color-syntax.js - toastui-editor-plugin-color-syntax.min.js - toastui-editor-plugin-color-syntax.css - toastui-editor-plugin-color-syntax.min.css ``` ## 📦 Usage npm To use the plugin, [`@toast-ui/editor`](https://github.com/nhn/tui.editor/tree/master/apps/editor) must be installed. > Ref. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) ### Install ```sh $ npm install @toast-ui/editor-plugin-color-syntax ``` ### Import Plugin Along with the plugin, the plugin's dependency style must be imported. The `color-syntax` plugin has [TOAST UI Color Picker](https://github.com/nhn/tui.color-picker) as a dependency, and you need to add a CSS file of TOAST UI Color Picker. #### ES Modules ```js import 'tui-color-picker/dist/tui-color-picker.css'; import '@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css'; import colorSyntax from '@toast-ui/editor-plugin-color-syntax'; ``` #### CommonJS ```js require('tui-color-picker/dist/tui-color-picker.css'); require('@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css'); const colorSyntax = require('@toast-ui/editor-plugin-color-syntax'); ``` ### Create Instance #### Basic ```js // ... import 'tui-color-picker/dist/tui-color-picker.css'; import '@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css'; import Editor from '@toast-ui/editor'; import colorSyntax from '@toast-ui/editor-plugin-color-syntax'; const editor = new Editor({ // ... plugins: [colorSyntax] }); ``` ## 🗂 Usage CDN To use the plugin, the CDN files(CSS, Script) of `@toast-ui/editor` must be included. ### Include Files ```html ... ... ... ... ... ... ``` ### Create Instance #### Basic ```js const { Editor } = toastui; const { colorSyntax } = Editor.plugin; const editor = new Editor({ // ... plugins: [colorSyntax] }); ``` ### [Optional] Use Plugin with Options The `color-syntax` plugin can set options when used. Just add the plugin function and options related to the plugin to the array(`[pluginFn, pluginOptions]`) and push them to the `plugins` option of the editor. The following options are available in the `color-syntax` plugin. | Name | Type | Default Value | Description | | ----------------- | ---------------- | ------------- | -------------------------------- | | `preset` | `Array.` | | Preset for color palette | ```js // ... import 'tui-color-picker/dist/tui-color-picker.css'; import '@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css'; import Editor from '@toast-ui/editor'; import colorSyntax from '@toast-ui/editor-plugin-color-syntax'; const colorSyntaxOptions = { preset: ['#181818', '#292929', '#393939'] }; const editor = new Editor({ // ... plugins: [[colorSyntax, colorSyntaxOptions]] }); ``` ================================================ FILE: plugins/color-syntax/demo/editor.html ================================================ Editor
                          ================================================ FILE: plugins/color-syntax/demo/esm/index.html ================================================ Editor
                          ================================================ FILE: plugins/color-syntax/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; ================================================ FILE: plugins/color-syntax/package.json ================================================ { "name": "@toast-ui/editor-plugin-color-syntax", "version": "3.1.0", "description": "TOAST UI Editor : Color Syntax Plugin", "keywords": [ "nhn", "nhn cloud", "toast", "toastui", "toast-ui", "editor", "plugin", "color-syntax", "color-picker" ], "main": "dist/toastui-editor-plugin-color-syntax.js", "files": [ "dist/*.js", "dist/*.css", "types/index.d.ts" ], "types": "types/index.d.ts", "author": "NHN Cloud FE Development Lab ", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "plugins/color-syntax" }, "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "homepage": "https://ui.toast.com", "browserslist": "last 2 versions, not ie <= 10", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "build:cdn": "webpack build --env cdn & webpack build --env cdn minify", "build": "webpack build && npm run build:cdn" }, "devDependencies": { "cross-env": "^6.0.3" }, "dependencies": { "tui-color-picker": "^2.2.6" }, "publishConfig": { "access": "public" } } ================================================ FILE: plugins/color-syntax/snowpack.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const httpProxy = require('http-proxy'); const proxy = httpProxy.createServer({ target: 'http://localhost:8080' }); /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, alias: { '@t': './types', }, devOptions: { port: 8081, }, routes: [ { src: '/img/.*', dest: (req, res) => { proxy.web(req, res); }, }, ], }; ================================================ FILE: plugins/color-syntax/src/__test__/integration/colorSyntaxPlugin.spec.ts ================================================ import Editor from '@toast-ui/editor'; import colorPicker from 'tui-color-picker'; import { oneLineTrim } from 'common-tags'; import colorSyntaxPlugin from '@/index'; import { removeProseMirrorHackNodes } from '@/utils/dom'; function removeDataAttr(html: string) { return html .replace(/\sdata-nodeid="\d{1,}"/g, '') .replace(/\n/g, '') .trim(); } describe('colorSyntax', () => { let container: HTMLElement, editor: Editor; function assertWwEditorHTML(html: string) { const wwEditorEl = editor.getEditorElements().wwEditor; const wwEditorHTML = removeProseMirrorHackNodes(wwEditorEl.outerHTML); expect(wwEditorHTML).toContain(html); } function assertMdPreviewHTML(html: string) { const mdPreviewEl = editor.getEditorElements().mdPreview; expect(removeDataAttr(mdPreviewEl.innerHTML)).toContain(html); } beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('usageStatistics option', () => { it('when setting false, GA of color picker is disabled', () => { jest.spyOn(colorPicker, 'create'); editor = new Editor({ el: container, previewStyle: 'vertical', plugins: [colorSyntaxPlugin], usageStatistics: false, }); expect(colorPicker.create).toHaveBeenCalledWith( expect.objectContaining({ usageStatistics: false, }) ); }); it('when setting true, GA of color picker is enabled', () => { jest.spyOn(colorPicker, 'create'); editor = new Editor({ el: container, previewStyle: 'vertical', plugins: [colorSyntaxPlugin], }); expect(colorPicker.create).toHaveBeenCalledWith( expect.objectContaining({ usageStatistics: true, }) ); }); }); describe('convertor', () => { beforeEach(() => { editor = new Editor({ el: container, previewStyle: 'vertical', height: '100px', initialEditType: 'markdown', plugins: [colorSyntaxPlugin], }); }); it('should convert markdown to wysiwyg properly', () => { editor.setMarkdown('text'); editor.exec('selectAll'); editor.exec('color', { selectedColor: '#f0f' }); editor.changeMode('wysiwyg'); assertWwEditorHTML('

                          text

                          '); }); it('should convert wysiwyg to markdown properly', () => { editor.setMarkdown('text'); editor.exec('selectAll'); editor.exec('color', { selectedColor: '#f0f' }); editor.changeMode('wysiwyg'); editor.changeMode('markdown'); assertMdPreviewHTML('text'); }); it('should convert markdown to wysiwyg in table cell properly', () => { editor.exec('addTable', { columnCount: 2, rowCount: 2, }); editor.setSelection([1, 5], [1, 5]); editor.insertText('foo'); editor.setSelection([1, 5], [1, 8]); editor.exec('color', { selectedColor: '#f0f' }); editor.changeMode('wysiwyg'); const expected = oneLineTrim`

                          foo




                          `; assertWwEditorHTML(expected); }); it('should convert wysiwyg to markdown in table cell properly', () => { editor.changeMode('wysiwyg'); editor.exec('addTable', { rowCount: 2, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux'], }); editor.setSelection(4, 8); editor.exec('color', { selectedColor: '#f0f' }); editor.changeMode('markdown'); const expected = oneLineTrim`
                          foo bar
                          baz qux
                          `; assertMdPreviewHTML(expected); }); }); describe('commands', () => { beforeEach(() => { editor = new Editor({ el: container, previewStyle: 'vertical', height: '100px', initialEditType: 'markdown', plugins: [colorSyntaxPlugin], }); }); it('add color in markdown', () => { editor.setMarkdown('text'); editor.exec('selectAll'); editor.exec('color', { selectedColor: '#f0f' }); assertMdPreviewHTML('text'); }); it(`don't add color if value isn't truthy in markdown`, () => { editor.setMarkdown('text'); editor.exec('selectAll'); editor.exec('color'); assertMdPreviewHTML('

                          text

                          '); }); it('add color in wysiwyg', () => { editor.setMarkdown('text'); editor.changeMode('wysiwyg'); editor.exec('selectAll'); editor.exec('color', { selectedColor: '#f0f' }); assertWwEditorHTML('

                          text

                          '); }); it(`don't add color if value isn't truthy in wysiwyg`, () => { editor.setMarkdown('text'); editor.changeMode('wysiwyg'); editor.exec('selectAll'); editor.exec('color'); assertWwEditorHTML('

                          text

                          '); }); it('add color in selected table cell in wysiwyg', () => { editor.changeMode('wysiwyg'); editor.exec('addTable', { rowCount: 2, columnCount: 2, data: ['foo', 'bar', 'baz', 'qux'], }); editor.setSelection(4, 8); editor.exec('color', { selectedColor: '#f0f' }); const expected = oneLineTrim`

                          foo

                          bar

                          baz

                          qux

                          `; assertWwEditorHTML(expected); }); }); describe('multi instances', () => { let container2: HTMLElement, editor2: Editor; beforeEach(() => { container2 = document.createElement('div'); document.body.appendChild(container2); }); afterEach(() => { editor2.destroy(); document.body.removeChild(container2); }); it('should focus to correct editor when using color syntax plugin', () => { editor = new Editor({ el: container, previewStyle: 'vertical', height: '100px', initialEditType: 'markdown', plugins: [colorSyntaxPlugin], }); editor2 = new Editor({ el: container2, previewStyle: 'vertical', height: '100px', initialEditType: 'markdown', plugins: [colorSyntaxPlugin], }); editor2.exec('selectAll'); editor2.exec('color', { selectedColor: '#f0f' }); expect(container2).toContainElement(document.activeElement as HTMLElement); }); }); }); ================================================ FILE: plugins/color-syntax/src/css/plugin.css ================================================ .toastui-editor-popup-color { padding: 0; } .toastui-editor-popup-color .tui-colorpicker-container, .toastui-editor-popup-color .tui-colorpicker-palette-container { width: 147px; } .toastui-editor-popup-color .tui-colorpicker-container ul { width: 152px; margin-bottom: 10px; } .toastui-editor-popup-color .tui-colorpicker-container li { padding: 0 3px 3px 0; } .toastui-editor-popup-color .tui-colorpicker-container li .tui-colorpicker-palette-button { border: solid 1px rgba(0, 0, 0, 0.1); border-radius: 50%; box-sizing: border-box; width: 16px; height: 16px; } .toastui-editor-popup-color .tui-popup-body { padding: 10px; } .toastui-editor-popup-color .tui-colorpicker-container .tui-colorpicker-palette-toggle-slider { display: none; } .toastui-editor-popup-color .tui-colorpicker-container .tui-colorpicker-svg-slider { border-radius: 3px; border: solid 1px rgba(0, 0, 0, 0.05); } .toastui-editor-popup-color .tui-colorpicker-palette-hex { float: right; } .toastui-editor-popup-body input[type='text'].tui-colorpicker-palette-hex { font-family: inherit; font-size: 13px; height: 24px; width: 65px; padding: 3px 25px 3px 10px; border: 1px solid #e1e3e9; border-radius: 2px; float: left; } .toastui-editor-popup-color button { height: 32px; width: 40px; color: #555; background: #f7f9fc; border: 1px solid #e1e3e9; top: 68px; position: absolute; right: 15px; } .toastui-editor-popup-color button:hover { border-color: #cbcfdb; } .toastui-editor-popup-color .tui-colorpicker-container div.tui-colorpicker-clearfix { display: inline-block; margin: 5px 0; width: 102px; } .toastui-editor-popup-color .tui-colorpicker-container .tui-colorpicker-palette-preview { margin-top: 8px; margin-left: -22px; width: 16px; height: 16px; border-radius: 50%; border: solid 1px rgba(0, 0, 0, 0.1); box-sizing: border-box; } .toastui-editor-popup-color .tui-colorpicker-slider-container .tui-colorpicker-slider-right { width: 19px; } .toastui-editor-popup-color .tui-colorpicker-slider-container .tui-colorpicker-svg-huebar { border: solid 1px rgba(0, 0, 0, 0.05); border-radius: 3px; overflow: auto; } .toastui-editor-popup-color .tui-colorpicker-slider-container .tui-colorpicker-huebar-handle { display: none; } .toastui-editor-toolbar-icons.color { background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIxMTYiIHZpZXdCb3g9IjAgMCAyNCAxMTYiPgogICAgPGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8Zz4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8Zz4KICAgICAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNjAwIC0xOTIpIHRyYW5zbGF0ZSg2MDAgMTkyKSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wIDBIMjRWMjRIMHoiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBmaWxsPSIjNTU1IiBkPSJNMiA4LjI1TDEwIDguMjUgMTAgOS43NSAyIDkuNzV6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDQuNzUpIi8+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBzdHJva2U9IiM1NTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIxLjUiIGQ9Ik0wIDE0LjVMNiAwIDEyIDE0LjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYgNC43NSkiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iNSIgaGVpZ2h0PSI1IiB4PSIxOCIgeT0iNCIgZmlsbD0iI0ZBMjgyOCIgcng9IjIuNSIvPgogICAgICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKC02MDAgLTE5MikgdHJhbnNsYXRlKDYwMCAxOTIpIHRyYW5zbGF0ZSgwIDUyKSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wIDBIMjRWMjRIMHoiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBmaWxsPSIjRUVFIiBkPSJNMiA4LjI1TDEwIDguMjUgMTAgOS43NSAyIDkuNzV6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDQuNzUpIi8+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBzdHJva2U9IiNFRUUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIxLjUiIGQ9Ik0wIDE0LjVMNiAwIDEyIDE0LjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYgNC43NSkiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iNSIgaGVpZ2h0PSI1IiB4PSIxOCIgeT0iNCIgZmlsbD0iI0ZGNDg0OCIgcng9IjIuNSIvPgogICAgICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKC02MDAgLTE5MikgdHJhbnNsYXRlKDYwMCAxOTIpIHRyYW5zbGF0ZSgwIDI2KSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wIDBIMjRWMjRIMHoiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBmaWxsPSIjMDBBOUZGIiBkPSJNMiA4LjI1TDEwIDguMjUgMTAgOS43NSAyIDkuNzV6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDQuNzUpIi8+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBzdHJva2U9IiMwMEE5RkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIxLjUiIGQ9Ik0wIDE0LjVMNiAwIDEyIDE0LjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYgNC43NSkiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iNSIgaGVpZ2h0PSI1IiB4PSIxOCIgeT0iNCIgZmlsbD0iI0ZBMjgyOCIgcng9IjIuNSIvPgogICAgICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKC02MDAgLTE5MikgdHJhbnNsYXRlKDYwMCAxOTIpIHRyYW5zbGF0ZSgwIDc4KSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wIDBIMjRWMjRIMHoiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBmaWxsPSIjNjdDQ0ZGIiBkPSJNMiA4LjI1TDEwIDguMjUgMTAgOS43NSAyIDkuNzV6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDQuNzUpIi8+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBzdHJva2U9IiM2N0NDRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIxLjUiIGQ9Ik0wIDE0LjVMNiAwIDEyIDE0LjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYgNC43NSkiLz4KICAgICAgICAgICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iNSIgaGVpZ2h0PSI1IiB4PSIxOCIgeT0iNCIgZmlsbD0iI0ZGNDg0OCIgcng9IjIuNSIvPgogICAgICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIGZpbGw9IiNGRkYiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii4yIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNiAuNWMxLjUxOSAwIDIuODk0LjYxNiAzLjg5IDEuNjEuOTk0Ljk5NiAxLjYxIDIuMzcxIDEuNjEgMy44OSAwIDEuNTE5LS42MTYgMi44OTQtMS42MSAzLjg5LS45OTYuOTk0LTIuMzcxIDEuNjEtMy44OSAxLjYxLTEuNTE5IDAtMi44OTQtLjYxNi0zLjg5LTEuNjFDMS4xMTcgOC44OTMuNSA3LjUxOC41IDZjMC0xLjUxOS42MTYtMi44OTQgMS42MS0zLjg5QzMuMTA3IDEuMTE3IDQuNDgyLjUgNiAuNXpNNiAzYy0uODI4IDAtMS41NzguMzM2LTIuMTIxLjg3OUMzLjMzNiA0LjQyMiAzIDUuMTcyIDMgNmMwIC44MjguMzM2IDEuNTc4Ljg3OSAyLjEyMUM0LjQyMiA4LjY2NCA1LjE3MiA5IDYgOWMuODI4IDAgMS41NzgtLjMzNiAyLjEyMS0uODc5QzguNjY0IDcuNTc4IDkgNi44MjggOSA2YzAtLjgyOC0uMzM2LTEuNTc4LS44NzktMi4xMjFDNy41NzggMy4zMzYgNi44MjggMyA2IDN6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNjAwIC0xOTIpIHRyYW5zbGF0ZSg2MDAgMTkyKSB0cmFuc2xhdGUoMCAxMDQpIi8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo='); background-size: 23px 112px; background-position: 3px 3px; } .toastui-editor-dark .toastui-editor-toolbar-icons.color { background-position-y: -47px; } .toastui-editor-dark .toastui-editor-popup-body input[type='text'].tui-colorpicker-palette-hex { border-color: #303238; } .toastui-editor-dark .toastui-editor-popup-color button { color: #eee; border-color: #303238; background-color: #232428; } .toastui-editor-dark .toastui-editor-popup-color button:hover { border-color: #494c56; } .toastui-editor-dark .toastui-editor-popup-color .tui-colorpicker-container li .tui-colorpicker-palette-button { border-color: rgba(255, 255, 255, 0.1); } .toastui-editor-dark .toastui-editor-popup-color .tui-colorpicker-container .tui-colorpicker-svg-slider, .toastui-editor-dark .toastui-editor-popup-color .tui-colorpicker-slider-container .tui-colorpicker-svg-huebar { border-color: rgba(255, 255, 255, 0.05); } ================================================ FILE: plugins/color-syntax/src/i18n/langs.ts ================================================ import type { I18n } from '@toast-ui/editor'; export function addLangs(i18n: I18n) { i18n.setLanguage('ar', { 'Text color': 'لون النص', }); i18n.setLanguage(['cs', 'cs-CZ'], { 'Text color': 'Barva textu', }); i18n.setLanguage(['de', 'de-DE'], { 'Text color': 'Textfarbe', }); i18n.setLanguage(['en', 'en-US'], { 'Text color': 'Text color', }); i18n.setLanguage(['es', 'es-ES'], { 'Text color': 'Color del texto', }); i18n.setLanguage(['fi', 'fi-FI'], { 'Text color': 'Tekstin väri', }); i18n.setLanguage(['fr', 'fr-FR'], { 'Text color': 'Couleur du texte', }); i18n.setLanguage(['gl', 'gl-ES'], { 'Text color': 'Cor do texto', }); i18n.setLanguage(['hr', 'hr-HR'], { 'Text color': 'Boja teksta', }); i18n.setLanguage(['it', 'it-IT'], { 'Text color': 'Colore del testo', }); i18n.setLanguage(['ja', 'ja-JP'], { 'Text color': '文字色相', }); i18n.setLanguage(['ko', 'ko-KR'], { 'Text color': '글자 색상', }); i18n.setLanguage(['nb', 'nb-NO'], { 'Text color': 'Tekstfarge', }); i18n.setLanguage(['nl', 'nl-NL'], { 'Text color': 'Tekstkleur', }); i18n.setLanguage(['pl', 'pl-PL'], { 'Text color': 'Kolor tekstu', }); i18n.setLanguage(['pt', 'pt-BR'], { 'Text color': 'Cor do texto', }); i18n.setLanguage(['ru', 'ru-RU'], { 'Text color': 'Цвет текста', }); i18n.setLanguage(['sv', 'sv-SE'], { 'Text color': 'Textfärg', }); i18n.setLanguage(['tr', 'tr-TR'], { 'Text color': 'Metin rengi', }); i18n.setLanguage(['uk', 'uk-UA'], { 'Text color': 'Колір тексту', }); i18n.setLanguage('zh-CN', { 'Text color': '文字颜色', }); i18n.setLanguage('zh-TW', { 'Text color': '文字顏色', }); } ================================================ FILE: plugins/color-syntax/src/index.ts ================================================ import ColorPicker from 'tui-color-picker'; import type { Context } from '@toast-ui/toastmark'; import type { PluginContext, PluginInfo, HTMLMdNode, I18n } from '@toast-ui/editor'; import type { Transaction, Selection, TextSelection } from 'prosemirror-state'; import { PluginOptions } from '@t/index'; import { addLangs } from './i18n/langs'; import './css/plugin.css'; import { findParentByClassName } from './utils/dom'; const PREFIX = 'toastui-editor-'; function createApplyButton(text: string) { const button = document.createElement('button'); button.setAttribute('type', 'button'); button.textContent = text; return button; } function createToolbarItemOption(colorPickerContainer: HTMLDivElement, i18n: I18n) { return { name: 'color', tooltip: i18n.get('Text color'), className: `${PREFIX}toolbar-icons color`, popup: { className: `${PREFIX}popup-color`, body: colorPickerContainer, style: { width: 'auto' }, }, }; } function createSelection( tr: Transaction, selection: Selection, SelectionClass: typeof TextSelection, openTag: string, closeTag: string ) { const { mapping, doc } = tr; const { from, to, empty } = selection; const mappedFrom = mapping.map(from) + openTag.length; const mappedTo = mapping.map(to) - closeTag.length; return empty ? SelectionClass.create(doc, mappedTo, mappedTo) : SelectionClass.create(doc, mappedFrom, mappedTo); } function getCurrentEditorEl(colorPickerEl: HTMLElement, containerClassName: string) { const editorDefaultEl = findParentByClassName(colorPickerEl, `${PREFIX}defaultUI`)!; return editorDefaultEl.querySelector(`.${containerClassName} .ProseMirror`)!; } interface ColorPickerOption { container: HTMLDivElement; preset?: Array; usageStatistics: boolean; } let containerClassName: string; let currentEditorEl: HTMLElement; // @TODO: add custom syntax for plugin /** * Color syntax plugin * @param {Object} context - plugin context for communicating with editor * @param {Object} options - options for plugin * @param {Array.} [options.preset] - preset for color palette (ex: ['#181818', '#292929']) * @param {boolean} [options.useCustomSyntax=false] - whether use custom syntax or not */ export default function colorSyntaxPlugin( context: PluginContext, options: PluginOptions = {} ): PluginInfo { const { eventEmitter, i18n, usageStatistics = true, pmState } = context; const { preset } = options; const container = document.createElement('div'); const colorPickerOption: ColorPickerOption = { container, usageStatistics }; addLangs(i18n); if (preset) { colorPickerOption.preset = preset; } const colorPicker = ColorPicker.create(colorPickerOption); const button = createApplyButton(i18n.get('OK')); eventEmitter.listen('focus', (editType) => { containerClassName = `${PREFIX}${editType === 'markdown' ? 'md' : 'ww'}-container`; }); container.addEventListener('click', (ev) => { if ((ev.target as HTMLElement).getAttribute('type') === 'button') { const selectedColor = colorPicker.getColor(); currentEditorEl = getCurrentEditorEl(container, containerClassName); eventEmitter.emit('command', 'color', { selectedColor }); eventEmitter.emit('closePopup'); // force the current editor to focus for preventing to lose focus currentEditorEl.focus(); } }); colorPicker.slider.toggle(true); container.appendChild(button); const toolbarItem = createToolbarItemOption(container, i18n); return { markdownCommands: { color: ({ selectedColor }, { tr, selection, schema }, dispatch) => { if (selectedColor) { const slice = selection.content(); const textContent = slice.content.textBetween(0, slice.content.size, '\n'); const openTag = ``; const closeTag = ``; const colored = `${openTag}${textContent}${closeTag}`; tr.replaceSelectionWith(schema.text(colored)).setSelection( createSelection(tr, selection, pmState.TextSelection, openTag, closeTag) ); dispatch!(tr); return true; } return false; }, }, wysiwygCommands: { color: ({ selectedColor }, { tr, selection, schema }, dispatch) => { if (selectedColor) { const { from, to } = selection; const attrs = { htmlAttrs: { style: `color: ${selectedColor}` } }; const mark = schema.marks.span.create(attrs); tr.addMark(from, to, mark); dispatch!(tr); return true; } return false; }, }, toolbarItems: [ { groupIndex: 0, itemIndex: 3, item: toolbarItem, }, ], toHTMLRenderers: { htmlInline: { span(node: HTMLMdNode, { entering }: Context) { return entering ? { type: 'openTag', tagName: 'span', attributes: node.attrs! } : { type: 'closeTag', tagName: 'span' }; }, }, }, }; } ================================================ FILE: plugins/color-syntax/src/utils/dom.ts ================================================ function hasClass(element: HTMLElement, className: string) { return element.classList.contains(className); } export function findParentByClassName(el: HTMLElement, className: string) { let currentEl: HTMLElement | null = el; while (currentEl && !hasClass(currentEl, className)) { currentEl = currentEl.parentElement; } return currentEl; } export function removeProseMirrorHackNodes(html: string) { const reProseMirrorImage = //g; const reProseMirrorTrailingBreak = / class="ProseMirror-trailingBreak"/g; let resultHTML = html; resultHTML = resultHTML.replace(reProseMirrorImage, ''); resultHTML = resultHTML.replace(reProseMirrorTrailingBreak, ''); return resultHTML; } ================================================ FILE: plugins/color-syntax/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "types/**/*"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@t/*": ["types/*"] }, "typeRoots": ["./types", "node_modules/@types", "../../node_modules/@types"], "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: plugins/color-syntax/types/index.d.ts ================================================ import type { PluginContext, PluginInfo } from '@toast-ui/editor'; export interface PluginOptions { preset?: string[]; } export default function colorPlugin(context: PluginContext, options: PluginOptions): PluginInfo; ================================================ FILE: plugins/color-syntax/types/prosemirror-model.d.ts ================================================ declare module 'prosemirror-model' { export interface Fragment { textBetween(from: number, to: number, blockSeparator?: string, leafText?: string): string; } } ================================================ FILE: plugins/color-syntax/types/tui-color-picker.d.ts ================================================ interface ColorPickerOption { container: HTMLElement; preset?: string[]; } declare module 'tui-color-picker' { interface ColorPicker { getColor(): string; slider: { toggle(type: boolean): void; }; } function create(options: ColorPickerOption): ColorPicker; } ================================================ FILE: plugins/color-syntax/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const { name, version, author, license } = require('./package.json'); const TerserPlugin = require('terser-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); const filename = `toastui-${name.replace(/@toast-ui\//, '')}`; function getOutputConfig(isProduction, isCDN, minify) { const defaultConfig = { library: { name: ['toastui', 'Editor', 'plugin', 'uml'], export: 'default', type: 'umd', }, environment: { arrowFunction: false, const: false, }, }; if (!isProduction || isCDN) { const config = { ...defaultConfig, library: { name: ['toastui', 'Editor', 'plugin', 'colorSyntax'], export: 'default', type: 'umd', }, path: path.resolve(__dirname, 'dist/cdn'), filename: `${filename}${minify ? '.min' : ''}.js`, }; if (!isProduction) { config.publicPath = '/dist/cdn'; } return config; } return { ...defaultConfig, path: path.resolve(__dirname, 'dist'), filename: `${filename}.js`, }; } function getExternalsConfig() { return [ { 'tui-color-picker': { commonjs: 'tui-color-picker', commonjs2: 'tui-color-picker', amd: 'tui-color-picker', root: ['tui', 'colorPicker'], }, }, ]; } function getOptimizationConfig(isProduction, minify) { const minimizer = []; if (isProduction && minify) { minimizer.push( new TerserPlugin({ parallel: true, extractComments: false, }) ); minimizer.push(new CssMinimizerPlugin()); } return { minimizer }; } module.exports = (env) => { const isProduction = env.WEBPACK_BUILD; const { minify = false, cdn = false } = env; const config = { mode: isProduction ? 'production' : 'development', entry: './src/index.ts', output: getOutputConfig(isProduction, cdn, minify), externals: getExternalsConfig(), module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, ], }, resolve: { extensions: ['.ts', '.js'], }, plugins: [ new MiniCssExtractPlugin({ filename: () => `${filename}${minify ? '.min' : ''}.css`, }), new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: isProduction, }), ], optimization: getOptimizationConfig(isProduction, minify), }; if (isProduction) { config.plugins.push( new webpack.BannerPlugin( [ 'TOAST UI Editor : Color Syntax Plugin', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n') ) ); } else { config.devServer = { // https://github.com/webpack/webpack-dev-server/issues/2484 injectClient: false, inline: true, host: '0.0.0.0', port: 8081, }; config.devtool = 'inline-source-map'; } return config; }; ================================================ FILE: plugins/table-merged-cell/README.md ================================================ # TOAST UI Editor : Table Merged Cell Plugin > This is a plugin of [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor) to merge table columns. [![npm version](https://img.shields.io/npm/v/@toast-ui/editor-plugin-table-merged-cell.svg)](https://www.npmjs.com/package/@toast-ui/editor-plugin-table-merged-cell) ![table-merged-cell](https://user-images.githubusercontent.com/37766175/121814008-c0232480-cca9-11eb-8611-7ccc0fe8707f.png) ## 🚩 Table of Contents - [Bundle File Structure](#-bundle-file-structure) - [Usage npm](#-usage-npm) - [Usage CDN](#-usage-cdn) ## 📁 Bundle File Structure ### Files Distributed on npm ``` - node_modules/ - @toast-ui/ - editor-plugin-table-merged-cell/ - dist/ - toastui-editor-plugin-table-merged-cell.js - toastui-editor-plugin-table-merged-cell.css ``` ### Files Distributed on CDN The bundle files include all dependencies of this plugin. ``` - uicdn.toast.com/ - editor-plugin-table-merged-cell/ - latest/ - toastui-editor-plugin-table-merged-cell.js - toastui-editor-plugin-table-merged-cell.min.js - toastui-editor-plugin-table-merged-cell.css - toastui-editor-plugin-table-merged-cell.min.css ``` ## 📦 Usage npm To use the plugin, [`@toast-ui/editor`](https://github.com/nhn/tui.editor/tree/master/apps/editor) must be installed. > Ref. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) ### Install ```sh $ npm install @toast-ui/editor-plugin-table-merged-cell ``` ### Import Plugin #### ES Modules ```js import '@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css'; import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell'; ``` #### CommonJS ```js require('@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css'); const tableMergedCell = require('@toast-ui/editor-plugin-table-merged-cell'); ``` ### Create Instance #### Basic ```js import '@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css'; import Editor from '@toast-ui/editor'; import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell'; const editor = new Editor({ // ... plugins: [tableMergedCell] }); ``` #### With Viewer ```js import '@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css'; import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer'; import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell'; const viewer = new Viewer({ // ... plugins: [tableMergedCell] }); ``` or ```js import '@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css'; import Editor from '@toast-ui/editor'; import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell'; const viewer = Editor.factory({ // ... plugins: [tableMergedCell], viewer: true }); ``` ## 🗂 Usage CDN To use the plugin, the CDN files(CSS, Script) of `@toast-ui/editor` must be included. ### Include Files ```html ... ... ... ... ... ... ``` ### Create Instance #### Basic ```js const { Editor } = toastui; const { tableMergedCell } = Editor.plugin; const editor = new Editor({ // ... plugins: [tableMergedCell] }); ``` #### With Viewer ```js const Viewer = toastui.Editor; const { tableMergedCell } = Viewer.plugin; const viewer = new Viewer({ // ... plugins: [tableMergedCell] }); ``` or ```js const { Editor } = toastui; const { tableMergedCell } = Editor.plugin; const viewer = Editor.factory({ // ... plugins: [tableMergedCell], viewer: true }); ``` ================================================ FILE: plugins/table-merged-cell/demo/data.js ================================================ // merge cell example1 const content1 = [ '| @cols=2:mergedHead1 | @cols=3:mergedHead2 |', '| --- | --- | --- | --- | --- |', '| @cols=2:mergedCell1-1 | cell1-2 | @cols=2:@rows=5:mergedCell1-3 |', '| @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 | cell2-4 | cell2-5 | cell2-6 |', '| cell3-1 |', '| cell4-1 | cell4 | cell4-3 |', '| cell5-1 | cell5-2 | cell5-3 | cell5-4 |', '', ].join('\n'); // merge cell example2 const content2 = [ '| @cols=2:merged | @cols=5:merged |', '| --- | --- | --- | --- | --- | --- | --- |', '| @cols=2:merged | table | | | | table2 |', '| @rows=2:merged | @rows=2:table | table2 | | | | asdf |', '| table | | | | table2 |', '| @cols=3:@rows=2:merged | | | | table2 |', '| | | | table |', ].join('\n'); // normal cell example const content3 = [ '| a | b| c | d |', '| --- | --- | --- | --- |', '| table | table2 | table3 | table4 |', '| table5 | table6 | table7 | table8 |', '| table9 | table10 | table11 | table22 |', ].join('\n'); ================================================ FILE: plugins/table-merged-cell/demo/editor.html ================================================ Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/table-merged-cell/demo/esm/index.html ================================================ Test to use plugin in node environment
                          ================================================ FILE: plugins/table-merged-cell/demo/viewer.html ================================================ Viewer
                          ================================================ FILE: plugins/table-merged-cell/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; ================================================ FILE: plugins/table-merged-cell/package.json ================================================ { "name": "@toast-ui/editor-plugin-table-merged-cell", "version": "3.1.0", "description": "TOAST UI Editor : Table Merged Cell Plugin", "keywords": [ "nhn", "nhn cloud", "toast", "toastui", "toast-ui", "editor", "plugin", "table", "merged-cell" ], "main": "dist/toastui-editor-plugin-table-merged-cell.js", "files": [ "dist/*.js", "dist/*.css", "types/index.d.ts" ], "types": "types/index.d.ts", "author": "NHN Cloud FE Development Lab ", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "plugins/table-merged-cell" }, "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "homepage": "https://ui.toast.com", "browserslist": "last 2 versions, not ie <= 10", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "build:cdn": "webpack build --env cdn & webpack build --env cdn minify", "build": "webpack build && npm run build:cdn" }, "publishConfig": { "access": "public" } } ================================================ FILE: plugins/table-merged-cell/snowpack.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const httpProxy = require('http-proxy'); const proxy = httpProxy.createServer({ target: 'http://localhost:8080' }); /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, devOptions: { port: 8081, }, routes: [ { src: '/img/.*', dest: (req, res) => { proxy.web(req, res); }, }, ], alias: { '@': './src', '@t': './types', }, }; ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/convertor.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import mergedTableCellPlugin from '@/index'; describe('convertor with merged table plugin', () => { let container: HTMLElement, editor: Editor; function assertMdEditorText(markdownText: string) { expect(editor.getMarkdown()).toBe(markdownText); } function assertWYSIWYGHTML(html: string) { const wwEditorEl = editor.getEditorElements().wwEditor; expect(wwEditorEl.innerHTML).toContain(html); } beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); editor = new Editor({ el: container, previewStyle: 'vertical', plugins: [mergedTableCellPlugin], }); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); it('should convert to wysiwyg properly', () => { const content = [ '| @cols=2:mergedHead1 | @cols=3:mergedHead2 |', '| --- | --- | --- | --- | --- |', '| @cols=2:mergedCell1-1 | cell1-2 | @cols=2:@rows=5:mergedCell1-3 |', '| @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 |', '| cell3-1 |', '| cell4-1 | cell4 | cell4-3 |', '| cell5-1 | cell5-2 | cell5-3 |', '', ].join('\n'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; editor.setMarkdown(content); editor.changeMode('wysiwyg'); assertWYSIWYGHTML(expected); }); it('should convert to markdown properly', () => { const content = [ '| @cols=2:mergedHead1 | @cols=3:mergedHead2 |', '| ----------- | ----------- | ----------- | ----------- | ----------- |', '| @cols=2:mergedCell1-1 | cell1-2 | @cols=2:@rows=5:mergedCell1-3 |', '| @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 |', '| cell3-1 |', '| cell4-1 | cell4 | cell4-3 |', '| cell5-1 | cell5-2 | cell5-3 |', '', ].join('\n'); editor.setMarkdown(content); editor.changeMode('wysiwyg'); editor.changeMode('markdown'); assertMdEditorText(content); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/markdown/mergedTablePreview.spec.ts ================================================ import { source } from 'common-tags'; import Editor from '@toast-ui/editor'; import mergedTableCellPlugin from '@/index'; describe('markdown merged table plugin', () => { let container: HTMLElement, editor: Editor; function removeDataAttr(html: string) { return html .replace(/\sdata-nodeid="\d{1,}"/g, '') .replace(/\sclass="toastui-editor-md-preview-highlight"/g, '') .trim(); } function assertMdPreviewHTML(html: string) { const mdPreviewContentEl = editor.getEditorElements().mdPreview.firstChild as HTMLElement; expect(removeDataAttr(mdPreviewContentEl.innerHTML)).toContain(html); } beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); editor = new Editor({ el: container, previewStyle: 'vertical', plugins: [mergedTableCellPlugin], }); }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); it('should render basic table properly', () => { const content = source` | head1 | head2 | | --- | --- | | cell1 | cell2 | `; const result = source`
                          head1 head2
                          cell1 cell2
                          `; editor.setMarkdown(content); assertMdPreviewHTML(result); }); it('should render merged cell with colspan header properly', () => { const content = source` | @cols=2:mergedHead1 | @cols=2:mergedHead2 | | --- | --- | --- | --- | | cell1 | cell2 | cell3 | cell4 | `; const result = source`
                          mergedHead1 mergedHead2
                          cell1 cell2 cell3 cell4
                          `; editor.setMarkdown(content); assertMdPreviewHTML(result); }); it('should render merged cell with colspan header, body properly', () => { const content = source` | @cols=2:mergedHead1 | @cols=2:mergedHead2 | | --- | --- | --- | --- | | @cols=2:mergedCell1 | cell2 | cell3 | `; const result = source`
                          mergedHead1 mergedHead2
                          mergedCell1 cell2 cell3
                          `; editor.setMarkdown(content); assertMdPreviewHTML(result); }); it('should render merged cell with rowspan body properly', () => { const content = source` | head1 | head2 | | --- | --- | | cell1-1 | @rows=2:cell1-2 | | cell2-1 | cell2-2 | `; const result = source`
                          head1 head2
                          cell1-1 cell1-2
                          cell2-1
                          `; editor.setMarkdown(content); assertMdPreviewHTML(result); }); describe('should render merged cell with rowspan, colspan properly', () => { const examples = [ { no: 1, content: source` | @cols=2:mergedHead1 | @cols=2:mergedHead2 | | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | cell1-2 | cell1-3 | | @rows=3:mergedCell2-1 | cell2-2 | cell2-3 | cell2-4 | | cell3-1 | cell3-2 | cell3-3 | cell3-4 | | cell4-1 | cell4-2 | cell4-3 | cell4-4 | | cell5-1 | cell5-2 | cell5-3 | cell5-4 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 cell1-2 cell1-3
                          mergedCell2-1 cell2-2 cell2-3 cell2-4
                          cell3-1 cell3-2 cell3-3
                          cell4-1 cell4-2 cell4-3
                          cell5-1 cell5-2 cell5-3 cell5-4
                          `, }, { no: 2, content: source` | @cols=2:mergedHead1 | @cols=2:mergedHead2 | | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | cell1-2 | cell1-3 | | @rows=2:mergedCell2-1 | cell2-2 | cell2-3 | cell2-4 | | cell3-1 | cell3-2 | cell3-3 | cell3-4 | | @cols=3:@rows=2:cell4-1 | cell4-2 | cell4-3 | cell4-4 | | cell5-1 | cell5-2 | cell5-3 | cell5-4 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 cell1-2 cell1-3
                          mergedCell2-1 cell2-2 cell2-3 cell2-4
                          cell3-1 cell3-2 cell3-3
                          cell4-1 cell4-2
                          cell5-1
                          `, }, { no: 3, content: source` | @cols=2:mergedHead1 | @cols=2:mergedHead2 | | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | cell1-2 | cell1-3 | | @rows=2:mergedCell2-1 | cell2-2 | cell2-3 | cell2-4 | | cell3-1 | cell3-2 | cell3-3 | cell3-4 | | @cols=3:@rows=2:cell4-1 | cell4-2 | cell4-3 | cell4-4 | | @rows=2:cell5-1 | cell5-2 | cell5-3 | cell5-4 | | cell6-1 | cell6-2 | cell6-3 | cell6-4 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 cell1-2 cell1-3
                          mergedCell2-1 cell2-2 cell2-3 cell2-4
                          cell3-1 cell3-2 cell3-3
                          cell4-1 cell4-2
                          cell5-1
                          cell6-1 cell6-2 cell6-3
                          `, }, { no: 4, content: source` | @cols=2:mergedHead1 | @cols=5:mergedHead2 | | --- | --- | --- | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | cell1-2 | cell1-3 | cell1-4 | cell1-5 | cell1-6 | | @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 | cell2-4 | cell2-5 | cell2-6 | | cell3-1 | cell3-2 | cell3-3 | cell3-4 | cell3-5 | cell3-6 | | @cols=3:@rows=2:mergedCell4-1 | cell4-2 | cell4-3 | cell4-4 | | @rows=2:mergedCell5-1 | cell5-2 | cell5-3 | cell5-4 | cell5-5 | | cell6-1 | cell6-2 | cell6-3 | cell6-4 | cell6-5 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 cell1-2 cell1-3 cell1-4 cell1-5 cell1-6
                          mergedCell2-1 mergedCell2-2 cell2-3 cell2-4 cell2-5 cell2-6
                          cell3-1 cell3-2 cell3-3 cell3-4 cell3-5
                          mergedCell4-1 cell4-2 cell4-3 cell4-4
                          mergedCell5-1 cell5-2 cell5-3 cell5-4
                          cell6-1 cell6-2 cell6-3 cell6-4 cell6-5
                          `, }, { no: 5, content: source` | @cols=2:mergedHead1 | @cols=5:mergedHead2 | | --- | --- | --- | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | cell1-2 | @cols=2:@rows=5:mergedCell1-3 | cell1-4 | cell1-5 | cell1-6 | | @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 | cell2-4 | cell2-5 | cell2-6 | | cell3-1 | cell3-2 | | @cols=3:@rows=2:mergedCell4-1 | cell4-2 | | cell5-1 | cell5-2 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 cell1-2 mergedCell1-3 cell1-4 cell1-5
                          mergedCell2-1 mergedCell2-2 cell2-3 cell2-4 cell2-5
                          cell3-1 cell3-2
                          mergedCell4-1 cell4-2
                          cell5-1 cell5-2
                          `, }, { no: 6, content: source` | @cols=2:mergedHead1 | @cols=3:mergedHead2 | | --- | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | cell1-2 | @cols=2:@rows=5:mergedCell1-3 | | @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 | cell2-4 | cell2-5 | cell2-6 | | cell3-1 | | cell4-1 | cell4-2 | | cell5-1 | cell5-2 | cell5-3 | cell5-4 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 cell1-2 mergedCell1-3
                          mergedCell2-1 mergedCell2-2 cell2-3
                          cell3-1
                          cell4-1 cell4-2
                          cell5-1 cell5-2 cell5-3
                          `, }, { no: 7, content: source` | @cols=2:mergedHead1 | @cols=3:mergedHead2 | | --- | --- | --- | --- | --- | | @cols=2:mergedCell1-1 | @rows=3:mergedCell1-2 | @cols=2:@rows=5:mergedCell1-3 | | @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | | | cell4-1 | cell4-2 | | | cell5-1 | cell5-2 | cell5-3 | `, result: source`
                          mergedHead1 mergedHead2
                          mergedCell1-1 mergedCell1-2 mergedCell1-3
                          mergedCell2-1 mergedCell2-2
                          cell4-1 cell4-2
                          cell5-1 cell5-2 cell5-3
                          `, }, { no: 8, content: source` | @cols=2:foo"bar" | @cols=2:baz | | --- | --- | --- | --- | | @cols=2:foo"bar" | cell1-2 | cell1-3 | | @rows=2:baz | cell2-2 | cell2-3 | cell2-4 | | cell3-1 | cell3-2 | cell3-3 | cell3-4 | | @cols=3:@rows=2:foo"bar"baz | cell4-2 | cell4-3 | cell4-4 | | cell5-1 | cell5-2 | cell5-3 | cell5-4 | `, result: source`
                          foo"bar" baz
                          foo"bar" cell1-2 cell1-3
                          baz cell2-2 cell2-3 cell2-4
                          cell3-1 cell3-2 cell3-3
                          foo"bar"baz cell4-2
                          cell5-1
                          `, }, ]; examples.forEach(({ no, content, result }) => { it(` - example${no}`, () => { editor.setMarkdown(content); assertMdPreviewHTML(result); }); }); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/addColumn.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import { assertWYSIWYGHTML, createEditor } from './helper/utils'; let container: HTMLElement, editor: Editor; beforeEach(() => { const editorInfo = createEditor(); container = editorInfo.container; editor = editorInfo.editor; }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('addColumnToLeft command', () => { it('should add column to left and extend col-spanning cell', () => { editor.setSelection(102, 102); // select [2, 1] cell(mergedCell2-2 text) editor.exec('addColumnToLeft'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1


                          mergedCell2-2

                          cell2-3


                          cell3-1

                          cell4-1


                          cell4

                          cell4-3

                          cell5-1


                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add column to left', () => { editor.setSelection(85, 85); // select [2, 0] cell(mergedCell2-1 text) editor.exec('addColumnToLeft'); const expected = oneLineTrim`


                          mergedHead1

                          mergedHead2


                          mergedCell1-1

                          cell1-2

                          mergedCell1-3


                          mergedCell2-1

                          mergedCell2-2

                          cell2-3


                          cell3-1


                          cell4-1

                          cell4

                          cell4-3


                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add column to left as many as the col-spanning count', () => { editor.setSelection(38, 38); // select [1, 0] cell(mergedCell1-1 text) editor.exec('addColumnToLeft'); const expected = oneLineTrim`



                          mergedHead1

                          mergedHead2



                          mergedCell1-1

                          cell1-2

                          mergedCell1-3



                          mergedCell2-1

                          mergedCell2-2

                          cell2-3



                          cell3-1



                          cell4-1

                          cell4

                          cell4-3



                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); describe('addColumnToRight command', () => { it('should add column to right and extend col-spanning cell', () => { editor.setSelection(85, 85); // select [2, 0] cell(mergedCell2-1 text) editor.exec('addColumnToRight'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1


                          mergedCell2-2

                          cell2-3


                          cell3-1

                          cell4-1


                          cell4

                          cell4-3

                          cell5-1


                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add column to right', () => { editor.setSelection(102, 102); // select [2, 1] cell(mergedCell2-2 text) editor.exec('addColumnToRight'); const expected = oneLineTrim`

                          mergedHead1


                          mergedHead2

                          mergedCell1-1


                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2


                          cell2-3


                          cell3-1

                          cell4-1

                          cell4


                          cell4-3

                          cell5-1

                          cell5-2


                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add column to right as many as the col-spanning count', () => { editor.setSelection(38, 38); // select [1, 0] cell(mergedCell1-1 text) editor.exec('addColumnToRight'); const expected = oneLineTrim`

                          mergedHead1



                          mergedHead2

                          mergedCell1-1



                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2



                          cell2-3



                          cell3-1

                          cell4-1

                          cell4



                          cell4-3

                          cell5-1

                          cell5-2



                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/addRow.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import { assertWYSIWYGHTML, createEditor } from './helper/utils'; let container: HTMLElement, editor: Editor; beforeEach(() => { const editorInfo = createEditor(); container = editorInfo.container; editor = editorInfo.editor; }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('addRowToUp command', () => { it('should add row to up and not extend row-spanning cell', () => { editor.setSelection(119, 119); // select [2, 2] cell(cell2-3 text) editor.exec('addRowToUp'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3




                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add row to up and extend row-spanning cell', () => { editor.setSelection(132, 132); // select [3, 2] cell(cell3-1 text) editor.exec('addRowToUp'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3


                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add row to up as many as the row-spanning count', () => { editor.setSelection(100, 100); // select [2, 1] cell(mergedCell2-2 text) editor.exec('addRowToUp'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3







                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); describe('addRowToDown command', () => { it('should add row to down and extend row-spanning cell', () => { editor.setSelection(132, 132); // select [3, 2] cell(cell3-1 text) editor.exec('addRowToDown'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1




                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add row to down and not extend row-spanning cell', () => { editor.setSelection(119, 119); // select [2, 2] cell(cell2-3 text) editor.exec('addRowToDown'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3


                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should add row to down as many as the row-spanning count', () => { editor.setSelection(100, 100); // select [2, 1] cell(mergedCell2-2 text) editor.exec('addRowToDown'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1







                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/helper/cellSelection.ts ================================================ import { Node, ResolvedPos, Slice, Fragment } from 'prosemirror-model'; import { Selection, SelectionRange, TextSelection } from 'prosemirror-state'; import { Mappable } from 'prosemirror-transform'; import { SelectionInfo } from '@t/index'; import { TableOffsetMap } from './tableOffsetMap'; function getSelectionRanges( doc: Node, map: TableOffsetMap, { startRowIdx, startColIdx, endRowIdx, endColIdx }: SelectionInfo ) { const ranges = []; for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); ranges.push(new SelectionRange(doc.resolve(offset + 1), doc.resolve(offset + nodeSize - 1))); } } return ranges; } function createTableFragment(tableHead: Node, tableBody: Node) { const fragment: Node[] = []; if (tableHead.childCount) { fragment.push(tableHead); } if (tableBody.childCount) { fragment.push(tableBody); } return Fragment.from(fragment); } export default class CellSelection extends Selection { private offsetMap: TableOffsetMap; startCell: ResolvedPos; endCell: ResolvedPos; isCellSelection: boolean; constructor(startCellPos: ResolvedPos, endCellPos = startCellPos) { const doc = startCellPos.node(0); const map = TableOffsetMap.create(startCellPos)!; const selectionInfo = map.getRectOffsets(startCellPos, endCellPos); const ranges = getSelectionRanges(doc, map, selectionInfo); super(ranges[0].$from, ranges[0].$to, ranges); this.startCell = startCellPos; this.endCell = endCellPos; this.offsetMap = map; this.isCellSelection = true; // This property is the api of the 'Selection' in prosemirror, // and is used to disable the text selection. this.visible = false; } map(doc: Node, mapping: Mappable) { const startPos = this.startCell.pos; const endPos = this.endCell.pos; const startCell = doc.resolve(mapping.map(startPos)); const endCell = doc.resolve(mapping.map(endPos)); const map = TableOffsetMap.create(startCell)!; // text selection when rows or columns are deleted if ( this.offsetMap.totalColumnCount > map.totalColumnCount || this.offsetMap.totalRowCount > map.totalRowCount ) { const depthMap = { tableBody: 1, tableRow: 2, tableCell: 3, paragraph: 4 }; const depthFromTable = depthMap[endCell.parent.type.name as keyof typeof depthMap]; const tableEndPos = endCell.end(endCell.depth - depthFromTable); // subtract 4( tag length) const from = Math.min(tableEndPos - 4, endCell.pos); return TextSelection.create(doc, from); } return new CellSelection(startCell, endCell); } eq(cell: CellSelection) { return ( cell instanceof CellSelection && cell.startCell.pos === this.startCell.pos && cell.endCell.pos === this.endCell.pos ); } content() { const table = this.startCell.node(-2); const tableOffset = this.startCell.start(-2); const row = table.child(1).firstChild!; const tableHead = table.child(0).type.create()!; const tableBody = table.child(1).type.create()!; const map = TableOffsetMap.create(this.startCell)!; const selectionInfo = map.getRectOffsets(this.startCell, this.endCell); const { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; let isTableHeadCell = false; for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { const cells = []; for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { const { offset } = map.getCellInfo(rowIdx, colIdx); const cell = table.nodeAt(offset - tableOffset); if (cell) { isTableHeadCell = cell.type.name === 'tableHeadCell'; // mark the extended cell for pasting if (map.extendedRowspan(rowIdx, colIdx) || map.extendedColspan(rowIdx, colIdx)) { cells.push(cell.type.create({ extended: true })); } else { cells.push(cell.copy(cell.content)); } } } const copiedRow = row.copy(Fragment.from(cells)); const targetNode = isTableHeadCell ? tableHead : tableBody; // @ts-ignore targetNode.content = targetNode.content.append(Fragment.from(copiedRow)); } return new Slice(createTableFragment(tableHead, tableBody), 1, 1); } toJSON() { return JSON.stringify(this); } } ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/helper/tableOffsetMap.ts ================================================ import type { Node, ResolvedPos } from 'prosemirror-model'; import { findNodeBy } from '@/wysiwyg/util'; export interface CellInfo { offset: number; nodeSize: number; extended?: boolean; } export interface SelectionInfo { startRowIdx: number; startColIdx: number; endRowIdx: number; endColIdx: number; } interface SpanMap { [key: number]: { count: number; startSpanIdx: number }; } export interface RowInfo { [key: number]: CellInfo; length: number; rowspanMap: SpanMap; colspanMap: SpanMap; } function getSortedNumPair(valueA: number, valueB: number) { return valueA > valueB ? [valueB, valueA] : [valueA, valueB]; } export class TableOffsetMap { private table: Node; private tableRows: Node[]; private tableStartPos: number; private rowInfo: RowInfo[]; constructor(table: Node, tableRows: Node[], tableStartPos: number, rowInfo: RowInfo[]) { this.table = table; this.tableRows = tableRows; this.tableStartPos = tableStartPos; this.rowInfo = rowInfo; } static create(cellPos: ResolvedPos): TableOffsetMap | null { const table = findNodeBy(cellPos, ({ type }: Node) => type.name === 'table'); if (table) { const { node, depth } = table; const rows: Node[] = []; const tablePos = cellPos.start(depth); const thead = node.child(0); const tbody = node.child(1); const theadCellInfo = createOffsetMap(thead, tablePos); const tbodyCellInfo = createOffsetMap(tbody, tablePos + thead.nodeSize); thead.forEach((row) => rows.push(row)); tbody.forEach((row) => rows.push(row)); const map = new TableOffsetMap(node, rows, tablePos, theadCellInfo.concat(tbodyCellInfo)); return map; } return null; } get totalRowCount() { return this.rowInfo.length; } get totalColumnCount() { return this.rowInfo[0].length; } get tableStartOffset() { return this.tableStartPos; } get tableEndOffset() { return this.tableStartPos + this.table.nodeSize - 1; } getCellInfo(rowIdx: number, colIdx: number) { return this.rowInfo[rowIdx][colIdx]; } posAt(rowIdx: number, colIdx: number): number { for (let i = 0, rowStart = this.tableStartPos; ; i += 1) { const rowEnd = rowStart + this.tableRows[i].nodeSize; if (i === rowIdx) { let index = colIdx; // Skip the cells from previous row(via rowspan) while (index < this.totalColumnCount && this.rowInfo[i][index].offset < rowStart) { index += 1; } return index === this.totalColumnCount ? rowEnd : this.rowInfo[i][index].offset; } rowStart = rowEnd; } } getNodeAndPos(rowIdx: number, colIdx: number) { const cellInfo = this.rowInfo[rowIdx][colIdx]; return { node: this.table.nodeAt(cellInfo.offset - 1)!, pos: cellInfo.offset }; } extendedRowspan(rowIdx: number, colIdx: number) { const rowspanInfo = this.rowInfo[rowIdx].rowspanMap[colIdx]; return !!rowspanInfo && rowspanInfo.startSpanIdx !== rowIdx; } extendedColspan(rowIdx: number, colIdx: number) { const colspanInfo = this.rowInfo[rowIdx].colspanMap[colIdx]; return !!colspanInfo && colspanInfo.startSpanIdx !== colIdx; } getRowspanCount(rowIdx: number, colIdx: number) { const rowspanInfo = this.rowInfo[rowIdx].rowspanMap[colIdx]; return rowspanInfo ? rowspanInfo.count : 0; } getColspanCount(rowIdx: number, colIdx: number) { const colspanInfo = this.rowInfo[rowIdx].colspanMap[colIdx]; return colspanInfo ? colspanInfo.count : 0; } decreaseColspanCount(rowIdx: number, colIdx: number) { const colspanInfo = this.rowInfo[rowIdx].colspanMap[colIdx]; const startColspanInfo = this.rowInfo[rowIdx].colspanMap[colspanInfo.startSpanIdx]; startColspanInfo.count -= 1; return startColspanInfo.count; } decreaseRowspanCount(rowIdx: number, colIdx: number) { const rowspanInfo = this.rowInfo[rowIdx].rowspanMap[colIdx]; const startRowspanInfo = this.rowInfo[rowspanInfo.startSpanIdx].rowspanMap[colIdx]; startRowspanInfo.count -= 1; return startRowspanInfo.count; } getColspanStartInfo(rowIdx: number, colIdx: number) { const { colspanMap } = this.rowInfo[rowIdx]; const colspanInfo = colspanMap[colIdx]; if (colspanInfo) { const { startSpanIdx } = colspanInfo; const cellInfo = this.rowInfo[rowIdx][startSpanIdx]; return { node: this.table.nodeAt(cellInfo.offset - 1)!, pos: cellInfo.offset, startSpanIdx, count: colspanMap[startSpanIdx].count, }; } return null; } getRowspanStartInfo(rowIdx: number, colIdx: number) { const { rowspanMap } = this.rowInfo[rowIdx]; const rowspanInfo = rowspanMap[colIdx]; if (rowspanInfo) { const { startSpanIdx } = rowspanInfo; const cellInfo = this.rowInfo[startSpanIdx][colIdx]; return { node: this.table.nodeAt(cellInfo.offset - 1)!, pos: cellInfo.offset, startSpanIdx, count: this.rowInfo[startSpanIdx].rowspanMap[colIdx].count, }; } return null; } getSpannedOffsets(selectionInfo: SelectionInfo): SelectionInfo { let { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; for (let rowIdx = endRowIdx; rowIdx >= startRowIdx; rowIdx -= 1) { if (this.rowInfo[rowIdx]) { const { rowspanMap, colspanMap } = this.rowInfo[rowIdx]; for (let colIdx = endColIdx; colIdx >= startColIdx; colIdx -= 1) { const rowspanInfo = rowspanMap[colIdx]; const colspanInfo = colspanMap[colIdx]; if (rowspanInfo) { startRowIdx = Math.min(startRowIdx, rowspanInfo.startSpanIdx); } if (colspanInfo) { startColIdx = Math.min(startColIdx, colspanInfo.startSpanIdx); } } } } for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { if (this.rowInfo[rowIdx]) { const { rowspanMap, colspanMap } = this.rowInfo[rowIdx]; for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { const rowspanInfo = rowspanMap[colIdx]; const colspanInfo = colspanMap[colIdx]; if (rowspanInfo) { endRowIdx = Math.max(endRowIdx, rowIdx + rowspanInfo.count - 1); } if (colspanInfo) { endColIdx = Math.max(endColIdx, colIdx + colspanInfo.count - 1); } } } } return { startRowIdx, startColIdx, endRowIdx, endColIdx }; } getCellStartOffset(rowIdx: number, colIdx: number) { const { offset } = this.rowInfo[rowIdx][colIdx]; return this.extendedRowspan(rowIdx, colIdx) ? this.posAt(rowIdx, colIdx) : offset; } getCellEndOffset(rowIdx: number, colIdx: number) { const { offset, nodeSize } = this.rowInfo[rowIdx][colIdx]; return this.extendedRowspan(rowIdx, colIdx) ? this.posAt(rowIdx, colIdx) : offset + nodeSize; } getCellIndex(cellPos: ResolvedPos): [rowIdx: number, colIdx: number] { for (let rowIdx = 0; rowIdx < this.totalRowCount; rowIdx += 1) { const rowInfo = this.rowInfo[rowIdx]; for (let colIdx = 0; colIdx < this.totalColumnCount; colIdx += 1) { if (rowInfo[colIdx].offset + 1 > cellPos.pos) { return [rowIdx, colIdx]; } } } return [0, 0]; } getRectOffsets(startCellPos: ResolvedPos, endCellPos = startCellPos) { if (startCellPos.pos > endCellPos.pos) { [startCellPos, endCellPos] = [endCellPos, startCellPos]; } let [startRowIdx, startColIdx] = this.getCellIndex(startCellPos); let [endRowIdx, endColIdx] = this.getCellIndex(endCellPos); [startRowIdx, endRowIdx] = getSortedNumPair(startRowIdx, endRowIdx); [startColIdx, endColIdx] = getSortedNumPair(startColIdx, endColIdx); return this.getSpannedOffsets({ startRowIdx, startColIdx, endRowIdx, endColIdx }); } } function extendPrevRowspan(prevRowInfo: RowInfo, rowInfo: RowInfo) { const { rowspanMap, colspanMap } = rowInfo; const { rowspanMap: prevRowspanMap, colspanMap: prevColspanMap } = prevRowInfo; Object.keys(prevRowspanMap).forEach((key) => { const colIdx = Number(key); const prevRowspanInfo = prevRowspanMap[colIdx]; if (prevRowspanInfo?.count > 1) { const prevColspanInfo = prevColspanMap[colIdx]; const { count, startSpanIdx } = prevRowspanInfo; rowspanMap[colIdx] = { count: count - 1, startSpanIdx }; colspanMap[colIdx] = prevColspanInfo; rowInfo[colIdx] = { ...prevRowInfo[colIdx], extended: true }; rowInfo.length += 1; } }); } function extendPrevColspan( rowspan: number, colspan: number, rowIdx: number, colIdx: number, rowInfo: RowInfo ) { const { rowspanMap, colspanMap } = rowInfo; for (let i = 1; i < colspan; i += 1) { colspanMap[colIdx + i] = { count: colspan - i, startSpanIdx: colIdx }; if (rowspan > 1) { rowspanMap[colIdx + i] = { count: rowspan, startSpanIdx: rowIdx }; } rowInfo[colIdx + i] = { ...rowInfo[colIdx] }; rowInfo.length += 1; } } function createOffsetMap(headOrBody: Node, startOffset: number, startFromBody = false) { const cellInfoMatrix: RowInfo[] = []; const beInBody = headOrBody.type.name === 'tableBody'; headOrBody.forEach((row: Node, rowOffset: number, rowIdx: number) => { // get row index based on table(not table head or table body) const rowIdxInWholeTable = beInBody && !startFromBody ? rowIdx + 1 : rowIdx; const prevRowInfo = cellInfoMatrix[rowIdx - 1]; const rowInfo: RowInfo = { rowspanMap: {}, colspanMap: {}, length: 0 }; if (prevRowInfo) { extendPrevRowspan(prevRowInfo, rowInfo); } row.forEach(({ nodeSize, attrs }: Node, cellOffset: number) => { const colspan: number = attrs.colspan ?? 1; const rowspan: number = attrs.rowspan ?? 1; let colIdx = 0; while (rowInfo[colIdx]) { colIdx += 1; } rowInfo[colIdx] = { // 2 is the sum of the front and back positions of the tag offset: startOffset + rowOffset + cellOffset + 2, nodeSize, }; rowInfo.length += 1; if (rowspan > 1) { rowInfo.rowspanMap[colIdx] = { count: rowspan, startSpanIdx: rowIdxInWholeTable }; } if (colspan > 1) { rowInfo.colspanMap[colIdx] = { count: colspan, startSpanIdx: colIdx }; extendPrevColspan(rowspan, colspan, rowIdxInWholeTable, colIdx, rowInfo); } }); cellInfoMatrix.push(rowInfo); }); return cellInfoMatrix; } ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/helper/utils.ts ================================================ import Editor from '@toast-ui/editor'; import mergedTableCellPlugin from '@/index'; export function assertWYSIWYGHTML(editor: Editor, html: string) { const wwEditorEl = editor.getEditorElements().wwEditor; const wwEditorHTML = removeProseMirrorHackNodes(wwEditorEl.outerHTML); expect(wwEditorHTML).toContain(html); } export function createEditor() { const content = [ '| @cols=2:mergedHead1 | @cols=3:mergedHead2 |', '| --- | --- | --- | --- | --- |', '| @cols=2:mergedCell1-1 | cell1-2 | @cols=2:@rows=5:mergedCell1-3 |', '| @rows=2:mergedCell2-1 | @rows=2:mergedCell2-2 | cell2-3 |', '| cell3-1 |', '| cell4-1 | cell4 | cell4-3 |', '| cell5-1 | cell5-2 | cell5-3 |', '', ].join('\n'); const container = document.createElement('div'); document.body.appendChild(container); const editor = new Editor({ el: container, initialEditType: 'wysiwyg', initialValue: content, previewStyle: 'vertical', plugins: [mergedTableCellPlugin], }); return { container, editor }; } export function removeProseMirrorHackNodes(html: string) { const reProseMirrorImage = //g; const reProseMirrorTrailingBreak = / class="ProseMirror-trailingBreak"/g; let resultHTML = html; resultHTML = resultHTML.replace(reProseMirrorImage, ''); resultHTML = resultHTML.replace(reProseMirrorTrailingBreak, ''); return resultHTML; } ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/mergeCells.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import { assertWYSIWYGHTML, createEditor } from './helper/utils'; import type { EditorView } from 'prosemirror-view'; import CellSelection from './helper/cellSelection'; let container: HTMLElement, editor: Editor; beforeEach(() => { const editorInfo = createEditor(); container = editorInfo.container; editor = editorInfo.editor; }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); function setCellSelection(startPos: number, endPos: number) { // @ts-ignore const wwView: EditorView = editor.wwEditor.view; const { tr } = wwView.state; wwView.dispatch!( tr.setSelection(new CellSelection(tr.doc.resolve(startPos), tr.doc.resolve(endPos))) ); } describe('mergeCells command', () => { it('should merge cells included spanning cell', () => { setCellSelection(37, 131); // select [1, 0] cell(mergedCell1-1 text) to [3, 2](cell3-1 text) editor.exec('mergeCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          mergedCell1-3

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should merge cells(normal cells)', () => { setCellSelection(54, 131); // select [1, 2] cell(cell1-2 text) to [3, 2](cell3-1 text) editor.exec('mergeCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          cell2-3

                          cell3-1

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should not merge cells in case of selecting all cells', () => { setCellSelection(37, 65); // select all body cells editor.exec('mergeCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should not merge cells in case of selecting head cells', () => { setCellSelection(1, 37); // select [0, 0](mergedHead1 text) cellto [1, 0](mergedCell1-1 text) editor.exec('mergeCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/removeColumn.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import { assertWYSIWYGHTML, createEditor } from './helper/utils'; let container: HTMLElement, editor: Editor; beforeEach(() => { const editorInfo = createEditor(); container = editorInfo.container; editor = editorInfo.editor; }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('removeColumn command', () => { it('should remove column included col-spanning cell(normal single cell)', () => { editor.setSelection(85, 85); // select [2, 0] cell(mergedCell2-1 text) editor.exec('removeColumn'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-2

                          cell2-3

                          cell3-1

                          cell4

                          cell4-3

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should remove column(normal single cell)', () => { editor.setSelection(102, 102); // select [2, 1] cell(mergedCell2-2 text) editor.exec('removeColumn'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          cell2-3

                          cell3-1

                          cell4-1

                          cell4-3

                          cell5-1

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should remove column(selected col-spanning cell)', () => { editor.setSelection(38, 38); // select [1, 0] cell(mergedCell1-1 text) editor.exec('removeColumn'); const expected = oneLineTrim`

                          mergedHead2

                          cell1-2

                          mergedCell1-3

                          cell2-3

                          cell3-1

                          cell4-3

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/removeRow.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import { assertWYSIWYGHTML, createEditor } from './helper/utils'; let container: HTMLElement, editor: Editor; beforeEach(() => { const editorInfo = createEditor(); container = editorInfo.container; editor = editorInfo.editor; }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); describe('removeRow command', () => { it('should remove row included row-spanning cell(normal single cell)', () => { editor.setSelection(119, 119); // select [2, 2] cell(cell2-3 text) editor.exec('removeRow'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should remove row(normal single cell)', () => { editor.setSelection(132, 132); // select [3, 2] cell(cell3-1 text) editor.exec('removeRow'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should remove row(selected row-spanning cell)', () => { editor.setSelection(100, 100); // select [2, 1] cell(mergedCell2-2 text) editor.exec('removeRow'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); ================================================ FILE: plugins/table-merged-cell/src/__test__/integration/wysiwyg/splitCells.spec.ts ================================================ import { oneLineTrim } from 'common-tags'; import Editor from '@toast-ui/editor'; import { assertWYSIWYGHTML, createEditor } from './helper/utils'; import type { EditorView } from 'prosemirror-view'; import CellSelection from './helper/cellSelection'; let container: HTMLElement, editor: Editor; beforeEach(() => { const editorInfo = createEditor(); container = editorInfo.container; editor = editorInfo.editor; }); afterEach(() => { editor.destroy(); document.body.removeChild(container); }); function setCellSelection(startPos: number, endPos: number) { // @ts-ignore const wwView: EditorView = editor.wwEditor.view; const { tr } = wwView.state; wwView.dispatch!( tr.setSelection(new CellSelection(tr.doc.resolve(startPos), tr.doc.resolve(endPos))) ); } describe('splitCells command', () => { it('should split cells included spanning cell', () => { setCellSelection(37, 131); // select [1, 0] cell(mergedCell1-1 text) to [3, 2](cell3-1 text) editor.exec('splitCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1


                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3



                          cell3-1

                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); it('should split cell(single spanning cell)', () => { editor.setSelection(66, 66); // select [1, 3] cell(mergedCell1-3 text) editor.exec('splitCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3


                          mergedCell2-1

                          mergedCell2-2

                          cell2-3



                          cell3-1



                          cell4-1

                          cell4

                          cell4-3



                          cell5-1

                          cell5-2

                          cell5-3



                          `; assertWYSIWYGHTML(editor, expected); }); it('should split cells in case that all cells are spanning on the row', () => { setCellSelection(118, 131); // select [2, 2] cell(cell2-3 text) to [3, 2](cell3-1 text) editor.exec('mergeCells'); editor.exec('splitCells'); const expected = oneLineTrim`

                          mergedHead1

                          mergedHead2

                          mergedCell1-1

                          cell1-2

                          mergedCell1-3

                          mergedCell2-1

                          mergedCell2-2

                          cell2-3

                          cell3-1


                          cell4-1

                          cell4

                          cell4-3

                          cell5-1

                          cell5-2

                          cell5-3

                          `; assertWYSIWYGHTML(editor, expected); }); }); ================================================ FILE: plugins/table-merged-cell/src/css/plugin.css ================================================ .toastui-editor-context-menu .menu-item .merge-cells::before { background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGcgZmlsbD0iIzQzNDM0MyIgc3Ryb2tlPSIjNDM0MzQzIj4NCgk8cGF0aCBkPSJNMjM2LjcsMjUzLjdsLTk3LjYtNzcuMmMtMS45LTEuNi00LjctMC4xLTQuNywyLjNWMjM0SDQ0VjQ0aDEzMS45djgyLjdjMCwxLjQsMS4yLDIuNiwyLjYsMi42aDM4LjhjMS40LDAsMi42LTEuMiwyLjYtMi42DQoJCVYxOC4xYzAtMTAtOC4xLTE4LjEtMTguMS0xOC4xSDE4LjFDOC4xLDAsMCw4LjEsMCwxOC4xdjQ3NS44YzAsMTAsOC4xLDE4LjEsMTguMSwxOC4xaDE4My42YzEwLDAsMTguMS04LjEsMTguMS0xOC4xVjM4NS4zDQoJCWMwLTEuNC0xLjItMi42LTIuNi0yLjZoLTM4LjhjLTEuNCwwLTIuNiwxLjItMi42LDIuNlY0NjhINDRWMjc4aDkwLjV2NTUuMmMwLDIuNSwyLjgsMy45LDQuNywyLjNsOTcuNi03Ny4yDQoJCUMyMzguMywyNTcuMiwyMzguMywyNTQuOCwyMzYuNywyNTMuN3ogTTQ5My45LDBIMzEwLjNjLTEwLDAtMTguMSw4LjEtMTguMSwxOC4xdjEwOC42YzAsMS40LDEuMiwyLjYsMi42LDIuNmgzOC44DQoJCWMxLjQsMCwyLjYtMS4yLDIuNi0yLjZWNDRINDY4VjIzNGgtOTAuNXYtNTUuMmMwLTIuNS0yLjgtMy45LTQuNy0yLjNsLTk3LjYsNzcuMmMtMS41LDEuMi0xLjUsMy40LDAsNC42bDk3LjYsNzcuMw0KCQljMS45LDEuNSw0LjcsMC4xLDQuNy0yLjNWMjc4SDQ2OFY0NjhIMzM2LjJ2LTgyLjdjMC0xLjQtMS4yLTIuNi0yLjYtMi42aC0zOC44Yy0xLjQsMC0yLjYsMS4yLTIuNiwyLjZ2MTA4LjYNCgkJYzAsMTAsOC4xLDE4LjEsMTguMSwxOC4xaDE4My42YzEwLDAsMTguMS04LjEsMTguMS0xOC4xVjE4LjFDNTEyLDguMSw1MDMuOSwwLDQ5My45LDB6Ii8+DQo8L2c+DQo8L3N2Zz4NCg=='); background-position: 5px 2px; background-size: 14px 14px; } .toastui-editor-context-menu .menu-item .split-cells::before { background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGcgZmlsbD0iIzQzNDM0MyIgc3Ryb2tlPSIjNDM0MzQzIj4NCgk8cGF0aCBkPSJNNTEwLjksMjUzLjhsLTkwLjMtNzEuNGMtMS44LTEuNC00LjQtMC4xLTQuNCwyLjJ2NTEuMWgtODYuMVY1OS44aDEyMnY3Ni42YzAsMS4zLDEuMSwyLjQsMi40LDIuNGgzNS45DQoJCWMxLjMsMCwyLjQtMS4xLDIuNC0yLjRWMzUuOWMwLTkuMy03LjUtMTYuNy0xNi43LTE2LjdIMzA2LjJjLTkuMywwLTE2LjcsNy41LTE2LjcsMTYuN3Y0NDAuMmMwLDkuMyw3LjUsMTYuNywxNi43LDE2LjdoMTY5LjkNCgkJYzkuMywwLDE2LjctNy41LDE2LjctMTYuN1YzNzUuNmMwLTEuMy0xLjEtMi40LTIuNC0yLjRoLTM1LjljLTEuMywwLTIuNCwxLjEtMi40LDIuNHY3Ni42aC0xMjJWMjc2LjNoODYuMXY1MS4xDQoJCWMwLDIuMywyLjYsMy42LDQuNCwyLjJsOTAuMy03MS40QzUxMi40LDI1Ny4xLDUxMi40LDI1NC45LDUxMC45LDI1My44eiBNMjA1LjgsMTkuMUgzNS45Yy05LjMsMC0xNi43LDcuNS0xNi43LDE2Ljd2MTAwLjUNCgkJYzAsMS4zLDEuMSwyLjQsMi40LDIuNGgzNS45YzEuMywwLDIuNC0xLjEsMi40LTIuNFY1OS44aDEyMnYxNzUuOEg5NS43di01MS4xYzAtMi4zLTIuNi0zLjYtNC40LTIuMkwxLDI1My44DQoJCWMtMS40LDEuMS0xLjQsMy4yLDAsNC4ybDkwLjMsNzEuNWMxLjcsMS40LDQuNCwwLjEsNC40LTIuMnYtNTEuMWg4Ni4xdjE3NS44aC0xMjJ2LTc2LjZjMC0xLjMtMS4xLTIuNC0yLjQtMi40SDIxLjUNCgkJYy0xLjMsMC0yLjQsMS4xLTIuNCwyLjR2MTAwLjVjMCw5LjMsNy41LDE2LjcsMTYuNywxNi43aDE2OS45YzkuMywwLDE2LjctNy41LDE2LjctMTYuN1YzNS45QzIyMi41LDI2LjYsMjE1LDE5LjEsMjA1LjgsMTkuMXoiLz4NCjwvZz4NCjwvc3ZnPg0K'); background-position: 5px 2px; background-size: 14px 14px; } .toastui-editor-dark .toastui-editor-context-menu .menu-item .merge-cells::before { background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGcgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjNDM0MzQzIj4NCgk8cGF0aCBkPSJNMjM2LjcsMjUzLjdsLTk3LjYtNzcuMmMtMS45LTEuNi00LjctMC4xLTQuNywyLjNWMjM0SDQ0VjQ0aDEzMS45djgyLjdjMCwxLjQsMS4yLDIuNiwyLjYsMi42aDM4LjhjMS40LDAsMi42LTEuMiwyLjYtMi42DQoJCVYxOC4xYzAtMTAtOC4xLTE4LjEtMTguMS0xOC4xSDE4LjFDOC4xLDAsMCw4LjEsMCwxOC4xdjQ3NS44YzAsMTAsOC4xLDE4LjEsMTguMSwxOC4xaDE4My42YzEwLDAsMTguMS04LjEsMTguMS0xOC4xVjM4NS4zDQoJCWMwLTEuNC0xLjItMi42LTIuNi0yLjZoLTM4LjhjLTEuNCwwLTIuNiwxLjItMi42LDIuNlY0NjhINDRWMjc4aDkwLjV2NTUuMmMwLDIuNSwyLjgsMy45LDQuNywyLjNsOTcuNi03Ny4yDQoJCUMyMzguMywyNTcuMiwyMzguMywyNTQuOCwyMzYuNywyNTMuN3ogTTQ5My45LDBIMzEwLjNjLTEwLDAtMTguMSw4LjEtMTguMSwxOC4xdjEwOC42YzAsMS40LDEuMiwyLjYsMi42LDIuNmgzOC44DQoJCWMxLjQsMCwyLjYtMS4yLDIuNi0yLjZWNDRINDY4VjIzNGgtOTAuNXYtNTUuMmMwLTIuNS0yLjgtMy45LTQuNy0yLjNsLTk3LjYsNzcuMmMtMS41LDEuMi0xLjUsMy40LDAsNC42bDk3LjYsNzcuMw0KCQljMS45LDEuNSw0LjcsMC4xLDQuNy0yLjNWMjc4SDQ2OFY0NjhIMzM2LjJ2LTgyLjdjMC0xLjQtMS4yLTIuNi0yLjYtMi42aC0zOC44Yy0xLjQsMC0yLjYsMS4yLTIuNiwyLjZ2MTA4LjYNCgkJYzAsMTAsOC4xLDE4LjEsMTguMSwxOC4xaDE4My42YzEwLDAsMTguMS04LjEsMTguMS0xOC4xVjE4LjFDNTEyLDguMSw1MDMuOSwwLDQ5My45LDB6Ii8+DQo8L2c+DQo8L3N2Zz4NCg=='); background-position: 5px 2px; } .toastui-editor-dark .toastui-editor-context-menu .menu-item .split-cells::before { background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGcgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjNDM0MzQzIj4NCgk8cGF0aCBkPSJNNTEwLjksMjUzLjhsLTkwLjMtNzEuNGMtMS44LTEuNC00LjQtMC4xLTQuNCwyLjJ2NTEuMWgtODYuMVY1OS44aDEyMnY3Ni42YzAsMS4zLDEuMSwyLjQsMi40LDIuNGgzNS45DQoJCWMxLjMsMCwyLjQtMS4xLDIuNC0yLjRWMzUuOWMwLTkuMy03LjUtMTYuNy0xNi43LTE2LjdIMzA2LjJjLTkuMywwLTE2LjcsNy41LTE2LjcsMTYuN3Y0NDAuMmMwLDkuMyw3LjUsMTYuNywxNi43LDE2LjdoMTY5LjkNCgkJYzkuMywwLDE2LjctNy41LDE2LjctMTYuN1YzNzUuNmMwLTEuMy0xLjEtMi40LTIuNC0yLjRoLTM1LjljLTEuMywwLTIuNCwxLjEtMi40LDIuNHY3Ni42aC0xMjJWMjc2LjNoODYuMXY1MS4xDQoJCWMwLDIuMywyLjYsMy42LDQuNCwyLjJsOTAuMy03MS40QzUxMi40LDI1Ny4xLDUxMi40LDI1NC45LDUxMC45LDI1My44eiBNMjA1LjgsMTkuMUgzNS45Yy05LjMsMC0xNi43LDcuNS0xNi43LDE2Ljd2MTAwLjUNCgkJYzAsMS4zLDEuMSwyLjQsMi40LDIuNGgzNS45YzEuMywwLDIuNC0xLjEsMi40LTIuNFY1OS44aDEyMnYxNzUuOEg5NS43di01MS4xYzAtMi4zLTIuNi0zLjYtNC40LTIuMkwxLDI1My44DQoJCWMtMS40LDEuMS0xLjQsMy4yLDAsNC4ybDkwLjMsNzEuNWMxLjcsMS40LDQuNCwwLjEsNC40LTIuMnYtNTEuMWg4Ni4xdjE3NS44aC0xMjJ2LTc2LjZjMC0xLjMtMS4xLTIuNC0yLjQtMi40SDIxLjUNCgkJYy0xLjMsMC0yLjQsMS4xLTIuNCwyLjR2MTAwLjVjMCw5LjMsNy41LDE2LjcsMTYuNywxNi43aDE2OS45YzkuMywwLDE2LjctNy41LDE2LjctMTYuN1YzNS45QzIyMi41LDI2LjYsMjE1LDE5LjEsMjA1LjgsMTkuMXoiLz4NCjwvZz4NCjwvc3ZnPg0K'); background-position: 5px 2px; } ================================================ FILE: plugins/table-merged-cell/src/i18n/langs.ts ================================================ import { I18n } from '@toast-ui/editor'; export function addLangs(i18n: I18n) { i18n.setLanguage(['ko', 'ko-KR'], { 'Merge cells': '셀 병합', 'Split cells': '셀 병합해제', 'Cannot change part of merged cell': '병합된 셀의 일부를 변경할 수 없습니다.', 'Cannot paste row merged cells into the table header': '테이블 헤더에는 행 병합된 셀을 붙여넣을 수 없습니다.', }); i18n.setLanguage(['en', 'en-US'], { 'Merge cells': 'Merge cells', 'Split cells': 'Split cells', 'Cannot change part of merged cell': 'Cannot change part of merged cell.', 'Cannot paste row merged cells into the table header': 'Cannot paste row merged cells into the table header.', }); i18n.setLanguage(['es', 'es-ES'], { 'Merge cells': 'Combinar celdas', 'Split cells': 'Separar celdas', 'Cannot change part of merged cell': 'No se puede cambiar parte de una celda combinada.', 'Cannot paste row merged cells into the table header': 'No se pueden pegar celdas combinadas en el encabezado de tabla.', }); i18n.setLanguage(['ja', 'ja-JP'], { 'Merge cells': 'セルの結合', 'Split cells': 'セルの結合を解除', 'Cannot change part of merged cell': '結合されたセルの一部を変更することはできません。', 'Cannot paste row merged cells into the table header': '行にマージされたセルをヘッダーに貼り付けることはできません。', }); i18n.setLanguage(['nl', 'nl-NL'], { 'Merge cells': 'Cellen samenvoegen', 'Split cells': 'Samengevoegde cellen ongedaan maken', 'Cannot change part of merged cell': 'Kan geen deel uit van een samengevoegde cel veranderen.', 'Cannot paste row merged cells into the table header': 'Kan geen rij met samengevoegde cellen in de koptekst plakken.', }); i18n.setLanguage('zh-CN', { 'Merge cells': '合并单元格', 'Split cells': '取消合并单元格', 'Cannot change part of merged cell': '无法更改合并单元格的一部分。', 'Cannot paste row merged cells into the table header': '无法将行合并单元格粘贴到标题中。', }); i18n.setLanguage(['de', 'de-DE'], { 'Merge cells': 'Zellen zusammenführen', 'Split cells': 'Zusammenführen rückgängig machen', 'Cannot change part of merged cell': 'Der Teil der verbundenen Zelle kann nicht geändert werden.', 'Cannot paste row merged cells into the table header': 'Die Zeile der verbundenen Zellen kann nicht in die Kopfzeile eingefügt werden.', }); i18n.setLanguage(['ru', 'ru-RU'], { 'Merge cells': 'Объединить ячейки', 'Split cells': 'Разъединить ячейки', 'Cannot change part of merged cell': 'Вы не можете изменять часть комбинированной ячейки.', 'Cannot paste row merged cells into the table header': 'Вы не можете вставлять объединенные ячейки в заголовок таблицы.', }); i18n.setLanguage(['fr', 'fr-FR'], { 'Merge cells': 'Fusionner les cellules', 'Split cells': 'Séparer les cellules', 'Cannot change part of merged cell': 'Impossible de modifier une partie de la cellule fusionnée.', 'Cannot paste row merged cells into the table header': "Impossible de coller les cellules fusionnées dans l'en-tête du tableau.", }); i18n.setLanguage(['uk', 'uk-UA'], { 'Merge cells': "Об'єднати комірки", 'Split cells': "Роз'єднати комірки", 'Cannot change part of merged cell': 'Ви не можете змінювати частину комбінованої комірки.', 'Cannot paste row merged cells into the table header': "Ви не можете вставляти об'єднані комірки в заголовок таблиці.", }); i18n.setLanguage(['tr', 'tr-TR'], { 'Merge cells': 'Hücreleri birleştir', 'Split cells': 'Hücreleri ayır', 'Cannot change part of merged cell': 'Birleştirilmiş hücrelerin bir kısmı değiştirelemez.', 'Cannot paste row merged cells into the table header': 'Satırda birleştirilmiş hücreler sütun başlığına yapıştırılamaz', }); i18n.setLanguage(['fi', 'fi-FI'], { 'Merge cells': 'Yhdistä solut', 'Split cells': 'Jaa solut', 'Cannot change part of merged cell': 'Yhdistettyjen solujen osaa ei voi muuttaa', 'Cannot paste row merged cells into the table header': 'Soluja ei voi yhdistää taulukon otsikkoriviin', }); i18n.setLanguage(['cs', 'cs-CZ'], { 'Merge cells': 'Spojit buňky', 'Split cells': 'Rozpojit buňky', 'Cannot change part of merged cell': 'Nelze měnit část spojené buňky', 'Cannot paste row merged cells into the table header': 'Nelze vkládat spojené buňky do záhlaví tabulky', }); i18n.setLanguage('ar', { 'Merge cells': 'دمج الوحدات', 'Split cells': 'إلغاء دمج الوحدات', 'Cannot change part of merged cell': 'لا يمكن تغيير جزء من الخلية المدموجة', 'Cannot paste row merged cells into the table header': 'لا يمكن لصق الخلايا المدموجة من صف واحد في رأس الجدول', }); i18n.setLanguage(['pl', 'pl-PL'], { 'Merge cells': 'Scal komórki', 'Split cells': 'Rozłącz komórki', 'Cannot change part of merged cell': 'Nie można zmienić części scalonej komórki.', 'Cannot paste row merged cells into the table header': 'Nie można wkleić komórek o scalonym rzędzie w nagłówek tabeli.', }); i18n.setLanguage('zh-TW', { 'Merge cells': '合併儲存格', 'Split cells': '取消合併儲存格', 'Cannot change part of merged cell': '無法變更儲存格的一部分。', 'Cannot paste row merged cells into the table header': '無法將合併的儲存格貼上至表格標題中。', }); i18n.setLanguage(['gl', 'gl-ES'], { 'Merge cells': 'Combinar celas', 'Split cells': 'Separar celas', 'Cannot change part of merged cell': 'Non se pode cambiar parte dunha cela combinada', 'Cannot paste row merged cells into the table header': 'Non se poden pegar celas no encabezado da táboa', }); i18n.setLanguage(['sv', 'sv-SE'], { 'Merge cells': 'Sammanfoga celler', 'Split cells': 'Dela celler', 'Cannot change part of merged cell': 'Ej möjligt att ändra en del av en sammanfogad cell', 'Cannot paste row merged cells into the table header': 'Ej möjligt att klistra in rad-sammanfogade celler i tabellens huvud', }); i18n.setLanguage(['it', 'it-IT'], { 'Merge cells': 'Unisci celle', 'Split cells': 'Separa celle', 'Cannot change part of merged cell': 'Non è possibile modificare parte di una cella unita', 'Cannot paste row merged cells into the table header': "Non è possibile incollare celle unite per riga nell'intestazione della tabella", }); i18n.setLanguage(['nb', 'nb-NO'], { 'Merge cells': 'Slå sammen celler', 'Split cells': 'Separer celler', 'Cannot change part of merged cell': 'Kan ikke endre deler av sammenslåtte celler', 'Cannot paste row merged cells into the table header': 'Kan ikke lime inn rad med sammenslåtte celler', }); i18n.setLanguage(['hr', 'hr-HR'], { 'Merge cells': 'Spoji ćelije', 'Split cells': 'Odspoji ćelije', 'Cannot change part of merged cell': 'Ne mogu mijenjati dio spojene ćelije.', 'Cannot paste row merged cells into the table header': 'Ne mogu zaljepiti redak spojenih ćelija u zaglavlje tablice', }); } ================================================ FILE: plugins/table-merged-cell/src/index.ts ================================================ import type { PluginContext, PluginInfo } from '@toast-ui/editor'; import { markdownParsers } from '@/markdown/parser'; import { toHTMLRenderers } from '@/markdown/renderer'; import { toMarkdownRenderers } from '@/wysiwyg/renderer'; import { addLangs } from '@/i18n/langs'; import { offsetMapMixin, createOffsetMapMixin } from '@/wysiwyg/tableOffsetMapMixin'; import { addMergedTableContextMenu } from '@/wysiwyg/contextMenu'; import { createCommands } from '@/wysiwyg/commandFactory'; import './css/plugin.css'; export default function tableMergedCellPlugin(context: PluginContext): PluginInfo { const { i18n, eventEmitter } = context; const TableOffsetMap = eventEmitter.emitReduce( 'mixinTableOffsetMapPrototype', offsetMapMixin, createOffsetMapMixin ); addLangs(i18n); addMergedTableContextMenu(context); return { toHTMLRenderers, markdownParsers, toMarkdownRenderers, wysiwygCommands: createCommands(context, TableOffsetMap), }; } ================================================ FILE: plugins/table-merged-cell/src/markdown/parser.ts ================================================ import type { CustomParserMap } from '@toast-ui/toastmark'; import { MergedTableRowMdNode, MergedTableCellMdNode, SpanType } from '@t/index'; interface Attrs { colspan?: number; rowspan?: number; } type CellSpanInfo = [spanCount: number, content: string]; function getSpanInfo(content: string, type: SpanType, oppositeType: SpanType): CellSpanInfo { const reSpan = new RegExp(`^((?:${oppositeType}=[0-9]+:)?)${type}=([0-9]+):(.*)`); const parsed = reSpan.exec(content); let spanCount = 1; if (parsed) { spanCount = parseInt(parsed[2], 10); content = parsed[1] + parsed[3]; } return [spanCount, content]; } function extendTableCellIndexWithRowspanMap( node: MergedTableCellMdNode, parent: MergedTableRowMdNode, rowspan: number ) { const prevRow = parent.prev; if (prevRow) { const columnLen = parent.parent.parent.columns.length; // increment the index when prev row has the rowspan count. for (let i = node.startIdx; i < columnLen; i += 1) { const prevRowspanCount = prevRow.rowspanMap[i]; if (prevRowspanCount && prevRowspanCount > 1) { parent.rowspanMap[i] = prevRowspanCount - 1; if (i <= node.endIdx) { node.startIdx += 1; node.endIdx += 1; } } } } if (rowspan > 1) { const { startIdx, endIdx } = node; for (let i = startIdx; i <= endIdx; i += 1) { parent.rowspanMap[i] = rowspan; } } } export const markdownParsers: CustomParserMap = { // @ts-expect-error tableRow(node: MergedTableRowMdNode, { entering }) { if (entering) { node.rowspanMap = {}; if (node.prev && !node.firstChild) { const prevRowspanMap = node.prev.rowspanMap; Object.keys(prevRowspanMap).forEach((key) => { if (prevRowspanMap[key] > 1) { node.rowspanMap[key] = prevRowspanMap[key] - 1; } }); } } }, // @ts-expect-error tableCell(node: MergedTableCellMdNode, { entering }) { const { parent, prev, stringContent } = node; if (entering) { const attrs: Attrs = {}; let content = stringContent!; let [colspan, rowspan] = [1, 1]; [colspan, content] = getSpanInfo(content, '@cols', '@rows'); [rowspan, content] = getSpanInfo(content, '@rows', '@cols'); node.stringContent = content; if (prev) { node.startIdx = prev.endIdx + 1; node.endIdx = node.startIdx; } if (colspan > 1) { attrs.colspan = colspan; node.endIdx += colspan - 1; } if (rowspan > 1) { attrs.rowspan = rowspan; } node.attrs = attrs; extendTableCellIndexWithRowspanMap(node, parent, rowspan); const tablePart = parent.parent; if (tablePart.type === 'tableBody' && node.endIdx >= tablePart.parent.columns.length) { node.ignored = true; } } }, }; ================================================ FILE: plugins/table-merged-cell/src/markdown/renderer.ts ================================================ import type { CustomHTMLRenderer } from '@toast-ui/editor'; import type { OpenTagToken } from '@toast-ui/toastmark'; import { MergedTableCellMdNode, MergedTableRowMdNode } from '@t/index'; export const toHTMLRenderers: CustomHTMLRenderer = { // @ts-ignore tableRow(node: MergedTableRowMdNode, { entering, origin }) { if (entering) { return origin!(); } const result = []; if (node.lastChild) { const columnLen = node.parent.parent.columns.length; const lastColIdx = node.lastChild.endIdx; for (let i = lastColIdx + 1; i < columnLen; i += 1) { if (!node.prev || !node.prev.rowspanMap[i] || node.prev.rowspanMap[i] <= 1) { result.push( { type: 'openTag', tagName: 'td', outerNewLine: true, }, { type: 'closeTag', tagName: 'td', outerNewLine: true, } ); } } } result.push({ type: 'closeTag', tagName: 'tr', outerNewLine: true, }); return result; }, // @ts-ignore tableCell(node: MergedTableCellMdNode, { entering, origin }) { const result = origin!(); if (node.ignored) { return result; } if (entering) { const attributes: Record = { ...node.attrs }; (result as OpenTagToken).attributes = { ...(result as OpenTagToken).attributes, ...attributes, }; } return result; }, }; ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/addColumn.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory, TableOffsetMap, CommandFn, SelectionInfo } from '@t/index'; import { createDummyCells, getResolvedSelection, getRowAndColumnCount, setAttrs } from '../util'; import { Direction } from './direction'; type ColDirection = Direction.LEFT | Direction.RIGHT; function getTargetColInfo( direction: ColDirection, map: TableOffsetMap, selectionInfo: SelectionInfo ) { let targetColIdx: number; let judgeToExtendColspan: (rowIdx: number) => boolean; let insertColIdx: number; if (direction === Direction.LEFT) { targetColIdx = selectionInfo.startColIdx; judgeToExtendColspan = (rowIdx: number) => map.extendedColspan(rowIdx, targetColIdx); insertColIdx = targetColIdx; } else { targetColIdx = selectionInfo.endColIdx; judgeToExtendColspan = (rowIdx: number) => map.getColspanCount(rowIdx, targetColIdx) > 1; insertColIdx = targetColIdx + 1; } return { targetColIdx, judgeToExtendColspan, insertColIdx }; } export function createAddColumnCommand( context: PluginContext, OffsetMap: TableOffsetMapFactory, direction: ColDirection ) { const addColumn: CommandFn = (_, state, dispatch) => { const { selection, tr, schema } = state; const { anchor, head } = getResolvedSelection(selection, context); if (!anchor || !head) { return false; } const map = OffsetMap.create(anchor)!; const selectionInfo = map.getRectOffsets(anchor, head); const { targetColIdx, judgeToExtendColspan, insertColIdx } = getTargetColInfo( direction, map, selectionInfo ); const { columnCount } = getRowAndColumnCount(selectionInfo); const { totalRowCount } = map; for (let rowIdx = 0; rowIdx < totalRowCount; rowIdx += 1) { // increase colspan count inside the col-spanning cell if (judgeToExtendColspan(rowIdx)) { const { node, pos } = map.getColspanStartInfo(rowIdx, targetColIdx)!; const attrs = setAttrs(node, { colspan: node.attrs.colspan + columnCount }); tr.setNodeMarkup(tr.mapping.map(pos), null, attrs); } else { const cells = createDummyCells(columnCount, rowIdx, schema); tr.insert(tr.mapping.map(map.posAt(rowIdx, insertColIdx)), cells); } } dispatch!(tr); return true; }; return addColumn; } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/addRow.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory, TableOffsetMap, CommandFn, SelectionInfo } from '@t/index'; import type { Node } from 'prosemirror-model'; import { createDummyCells, getResolvedSelection, getRowAndColumnCount, setAttrs } from '../util'; import { Direction } from './direction'; type RowDirection = Direction.UP | Direction.DOWN; function getTargetRowInfo( direction: RowDirection, map: TableOffsetMap, selectionInfo: SelectionInfo ) { let targetRowIdx: number; let judgeToExtendRowspan: (rowIdx: number) => boolean; let insertColIdx: number; let nodeSize: number; if (direction === Direction.UP) { targetRowIdx = selectionInfo.startRowIdx; judgeToExtendRowspan = (colIdx: number) => map.extendedRowspan(targetRowIdx, colIdx); insertColIdx = 0; nodeSize = -1; } else { targetRowIdx = selectionInfo.endRowIdx; judgeToExtendRowspan = (colIdx: number) => map.getRowspanCount(targetRowIdx, colIdx) > 1; insertColIdx = map.totalColumnCount - 1; nodeSize = !map.extendedRowspan(targetRowIdx, insertColIdx) ? map.getCellInfo(targetRowIdx, insertColIdx).nodeSize + 1 : 2; } return { targetRowIdx, judgeToExtendRowspan, insertColIdx, nodeSize }; } export function createAddRowCommand( context: PluginContext, OffsetMap: TableOffsetMapFactory, direction: RowDirection ) { const addRow: CommandFn = (_, state, dispatch) => { const { selection, schema, tr } = state; const { anchor, head } = getResolvedSelection(selection, context); if (!anchor || !head) { return false; } const map = OffsetMap.create(anchor)!; const { totalColumnCount } = map; const selectionInfo = map.getRectOffsets(anchor, head); const { rowCount } = getRowAndColumnCount(selectionInfo); const { targetRowIdx, judgeToExtendRowspan, insertColIdx, nodeSize } = getTargetRowInfo( direction, map, selectionInfo ); const selectedThead = targetRowIdx === 0; if (selectedThead) { return false; } const rows: Node[] = []; const from = tr.mapping.map(map.posAt(targetRowIdx, insertColIdx)) + nodeSize; let cells: Node[] = []; for (let colIdx = 0; colIdx < totalColumnCount; colIdx += 1) { // increase rowspan count inside the row-spanning cell if (judgeToExtendRowspan(colIdx)) { const { node, pos } = map.getRowspanStartInfo(targetRowIdx, colIdx)!; const attrs = setAttrs(node, { rowspan: node.attrs.rowspan + rowCount }); tr.setNodeMarkup(tr.mapping.map(pos), null, attrs); } else { cells = cells.concat(createDummyCells(1, targetRowIdx, schema)); } } for (let i = 0; i < rowCount; i += 1) { rows.push(schema.nodes.tableRow.create(null, cells)); } dispatch!(tr.insert(from, rows)); return true; }; return addRow; } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/direction.ts ================================================ // eslint-disable-next-line no-shadow export const enum Direction { LEFT = 'left', RIGHT = 'right', UP = 'up', DOWN = 'down', } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/mergeCells.ts ================================================ import type { Transaction } from 'prosemirror-state'; import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory, TableOffsetMap, CommandFn } from '@t/index'; import type { Fragment, Node } from 'prosemirror-model'; import { getCellSelectionClass, getResolvedSelection, getRowAndColumnCount, setAttrs, } from '../util'; interface RangeInfo { startNode: Node; startPos: number; rowCount: number; columnCount: number; } export function createMergeCellsCommand(context: PluginContext, OffsetMap: TableOffsetMapFactory) { const { Fragment: FragmentClass } = context.pmModel; const mergeCells: CommandFn = (_, state, dispatch) => { const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection, context); // @ts-ignore // judge cell selection if (!anchor || !head || !selection.isCellSelection) { return false; } const map = OffsetMap.create(anchor)!; const CellSelection = getCellSelectionClass(selection); const { totalRowCount, totalColumnCount } = map; const selectionInfo = map.getRectOffsets(anchor, head); const { rowCount, columnCount } = getRowAndColumnCount(selectionInfo); const { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; const allSelected = rowCount >= totalRowCount - 1 && columnCount === totalColumnCount; const hasTableHead = startRowIdx === 0 && endRowIdx > startRowIdx; if (allSelected || hasTableHead) { return false; } let fragment = FragmentClass.empty; for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { // set first cell content if (rowIdx === startRowIdx && colIdx === startColIdx) { fragment = appendFragment(rowIdx, colIdx, fragment, map); // set each cell content and delete the cell for spanning } else if (!map.extendedRowspan(rowIdx, colIdx) && !map.extendedColspan(rowIdx, colIdx)) { const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); const from = tr.mapping.map(offset); const to = from + nodeSize; fragment = appendFragment(rowIdx, colIdx, fragment, map); tr.delete(from, to); } } } const { node, pos } = map.getNodeAndPos(startRowIdx, startColIdx); // set rowspan, colspan to first root cell setSpanToRootCell(tr, fragment, { startNode: node, startPos: pos, rowCount, columnCount, }); tr.setSelection(new CellSelection(tr.doc.resolve(pos))); dispatch!(tr); return true; }; return mergeCells; } function setSpanToRootCell(tr: Transaction, fragment: Fragment, rangeInfo: RangeInfo) { const { startNode, startPos, rowCount, columnCount } = rangeInfo; tr.setNodeMarkup( startPos, null, setAttrs(startNode, { colspan: columnCount, rowspan: rowCount }) ); if (fragment.size) { // add 1 for text start offset(not node start offset) tr.replaceWith(startPos + 1, startPos + startNode.content.size, fragment); } } function appendFragment(rowIdx: number, colIdx: number, fragment: Fragment, map: TableOffsetMap) { const targetFragment = map.getNodeAndPos(rowIdx, colIdx).node.content; // prevent to add empty string return targetFragment.size > 2 ? fragment.append(targetFragment) : fragment; } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/removeColumn.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory, CommandFn } from '@t/index'; import { getResolvedSelection, getRowAndColumnCount, setAttrs } from '../util'; export function createRemoveColumnCommand( context: PluginContext, OffsetMap: TableOffsetMapFactory ) { const removeColumn: CommandFn = (_, state, dispatch) => { const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection, context); if (!anchor || !head) { return false; } const map = OffsetMap.create(anchor)!; const selectionInfo = map.getRectOffsets(anchor, head); const { totalColumnCount, totalRowCount } = map; const { columnCount } = getRowAndColumnCount(selectionInfo); const selectedAllColumn = columnCount === totalColumnCount; if (selectedAllColumn) { return false; } const { startColIdx, endColIdx } = selectionInfo; const mapStart = tr.mapping.maps.length; for (let rowIdx = 0; rowIdx < totalRowCount; rowIdx += 1) { for (let colIdx = endColIdx; colIdx >= startColIdx; colIdx -= 1) { const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); const colspanInfo = map.getColspanStartInfo(rowIdx, colIdx)!; if (!map.extendedRowspan(rowIdx, colIdx)) { // decrease colspan count inside the col-spanning cell if (colspanInfo?.count > 1) { const { node, pos } = map.getColspanStartInfo(rowIdx, colIdx)!; const colspan = map.decreaseColspanCount(rowIdx, colIdx); const attrs = setAttrs(node, { colspan: colspan > 1 ? colspan : null }); tr.setNodeMarkup(tr.mapping.slice(mapStart).map(pos), null, attrs); } else { const from = tr.mapping.slice(mapStart).map(offset); const to = from + nodeSize; tr.delete(from, to); } } } } dispatch!(tr); return true; }; return removeColumn; } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/removeRow.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory, TableOffsetMap, CommandFn } from '@t/index'; import { getResolvedSelection, getRowAndColumnCount, setAttrs } from '../util'; function getRowRanges(map: TableOffsetMap, rowIdx: number) { const { totalColumnCount } = map; let from = Number.MAX_VALUE; let to = 0; for (let colIdx = 0; colIdx < totalColumnCount; colIdx += 1) { if (!map.extendedRowspan(rowIdx, colIdx)) { const { offset, nodeSize } = map.getCellInfo(rowIdx, colIdx); from = Math.min(from, offset); to = Math.max(to, offset + nodeSize); } } return { from, to }; } export function createRemoveRowCommand(context: PluginContext, OffsetMap: TableOffsetMapFactory) { const removeRow: CommandFn = (_, state, dispatch) => { const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection, context); if (anchor && head) { let map = OffsetMap.create(anchor)!; const { totalRowCount, totalColumnCount } = map; const selectionInfo = map.getRectOffsets(anchor, head); const { rowCount } = getRowAndColumnCount(selectionInfo); const { startRowIdx, endRowIdx } = selectionInfo; const selectedThead = startRowIdx === 0; const selectedAllTbodyRow = rowCount === totalRowCount - 1; if (selectedAllTbodyRow || selectedThead) { return false; } for (let rowIdx = endRowIdx; rowIdx >= startRowIdx; rowIdx -= 1) { const mapStart = tr.mapping.maps.length; const { from, to } = getRowRanges(map, rowIdx); // delete table row tr.delete(from - 1, to + 1); for (let colIdx = 0; colIdx < totalColumnCount; colIdx += 1) { const rowspanInfo = map.getRowspanStartInfo(rowIdx, colIdx)!; if (rowspanInfo?.count > 1 && !map.extendedColspan(rowIdx, colIdx)) { // decrease rowspan count inside the row-spanning cell // eslint-disable-next-line max-depth if (map.extendedRowspan(rowIdx, colIdx)) { const { node, pos } = map.getRowspanStartInfo(rowIdx, colIdx)!; const rowspan = map.decreaseRowspanCount(rowIdx, colIdx); const attrs = setAttrs(node, { rowspan: rowspan > 1 ? rowspan : null }); tr.setNodeMarkup(tr.mapping.slice(mapStart).map(pos), null, attrs); // the row-spanning cell should be moved down } else if (!map.extendedRowspan(rowIdx, colIdx)) { const { node, count } = map.getRowspanStartInfo(rowIdx, colIdx)!; const attrs = setAttrs(node, { rowspan: count > 2 ? count - 1 : null }); const copiedCell = node.type.create(attrs, node.content); tr.insert(tr.mapping.slice(mapStart).map(map.posAt(rowIdx + 1, colIdx)), copiedCell); } } } map = OffsetMap.create(tr.doc.resolve(map.tableStartOffset))!; } dispatch!(tr); return true; } return false; }; return removeRow; } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/command/splitCells.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory, TableOffsetMap, CommandFn, SelectionInfo } from '@t/index'; import type { EditorView } from 'prosemirror-view'; import type { Selection } from 'prosemirror-state'; import { getCellSelectionClass, getResolvedSelection, setAttrs } from '../util'; function getColspanEndIdx(rowIdx: number, colIdx: number, map: TableOffsetMap) { let endColIdx = colIdx; if (!map.extendedRowspan(rowIdx, colIdx) && map.extendedColspan(rowIdx, colIdx)) { const { startSpanIdx, count } = map.getColspanStartInfo(rowIdx, colIdx)!; endColIdx = startSpanIdx + count; } return endColIdx; } function judgeInsertToNextRow( map: TableOffsetMap, mappedPos: number, rowIdx: number, colIdx: number ) { const { totalColumnCount } = map; return ( map.extendedRowspan(rowIdx, colIdx) && map.extendedRowspan(rowIdx, totalColumnCount - 1) && mappedPos === map.posAt(rowIdx, totalColumnCount - 1) ); } export function createSplitCellsCommand(context: PluginContext, OffsetMap: TableOffsetMapFactory) { const splitCells: CommandFn = (_, state, dispatch, view) => { const { selection, tr } = state; const { anchor, head } = getResolvedSelection(selection, context); if (!anchor || !head) { return false; } const map = OffsetMap.create(anchor)!; const selectionInfo = map.getRectOffsets(anchor, head); const { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; let lastCellPos = -1; for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { if (map.extendedRowspan(rowIdx, colIdx) || map.extendedColspan(rowIdx, colIdx)) { // insert empty cell in spanning cell position const { node } = map.getNodeAndPos(rowIdx, colIdx); const colspanEndIdx = getColspanEndIdx(rowIdx, colIdx, map); const mappedPos = map.posAt(rowIdx, colspanEndIdx); let pos = tr.mapping.map(mappedPos); // add 2(tr end, open tag length) to insert the cell on the next row // in case that all next cells are spanning on the current row if (judgeInsertToNextRow(map, mappedPos, rowIdx, colspanEndIdx)) { pos += 2; } // get the last cell position for cell selection after splitting cells lastCellPos = Math.max(pos, lastCellPos); tr.insert( pos, node.type.createAndFill(setAttrs(node, { colspan: null, rowspan: null }))! ); } else { // remove colspan, rowspan of the root spanning cell const { node, pos } = map.getNodeAndPos(rowIdx, colIdx); // get the last cell position for cell selection after splitting cells lastCellPos = Math.max(tr.mapping.map(pos), lastCellPos); tr.setNodeMarkup( tr.mapping.map(pos), null, setAttrs(node, { colspan: null, rowspan: null }) ); } } } dispatch!(tr); setCellSelection(view, selection, OffsetMap, map.tableStartOffset, selectionInfo); return true; }; return splitCells; } function setCellSelection( view: EditorView, selection: Selection, OffsetMap: TableOffsetMapFactory, tableStartPos: number, selectionInfo: SelectionInfo ) { // @ts-ignore // judge cell selection if (selection.isCellSelection) { const { tr } = view.state; const CellSelection = getCellSelectionClass(selection); const { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; // get changed cell offsets const map = OffsetMap.create(tr.doc.resolve(tableStartPos))!; const { offset: startOffset } = map.getCellInfo(startRowIdx, startColIdx); const { offset: endOffset } = map.getCellInfo(endRowIdx, endColIdx); tr.setSelection(new CellSelection(tr.doc.resolve(startOffset), tr.doc.resolve(endOffset))); view.dispatch(tr); } } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/commandFactory.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import type { TableOffsetMapFactory } from '@t/index'; import { createMergeCellsCommand } from './command/mergeCells'; import { createSplitCellsCommand } from './command/splitCells'; import { createRemoveColumnCommand } from './command/removeColumn'; import { createRemoveRowCommand } from './command/removeRow'; import { createAddRowCommand } from './command/addRow'; import { createAddColumnCommand } from './command/addColumn'; import { Direction } from './command/direction'; export function createCommands(context: PluginContext, OffsetMap: TableOffsetMapFactory) { return { mergeCells: createMergeCellsCommand(context, OffsetMap), splitCells: createSplitCellsCommand(context, OffsetMap), addRowToUp: createAddRowCommand(context, OffsetMap, Direction.UP), addRowToDown: createAddRowCommand(context, OffsetMap, Direction.DOWN), removeRow: createRemoveRowCommand(context, OffsetMap), addColumnToLeft: createAddColumnCommand(context, OffsetMap, Direction.LEFT), addColumnToRight: createAddColumnCommand(context, OffsetMap, Direction.RIGHT), removeColumn: createRemoveColumnCommand(context, OffsetMap), }; } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/contextMenu.ts ================================================ import type { PluginContext } from '@toast-ui/editor'; import toArray from 'tui-code-snippet/collection/toArray'; const TABLE_CELL_SELECT_CLASS = '.toastui-editor-cell-selected'; function hasSpanAttr(tableCell: Element) { return ( Number(tableCell.getAttribute('colspan')) > 1 || Number(tableCell.getAttribute('rowspan')) > 1 ); } function hasSpanningCell(headOrBody: Element) { return toArray(headOrBody.querySelectorAll(TABLE_CELL_SELECT_CLASS)).some(hasSpanAttr); } function isCellSelected(headOrBody: Element) { return !!headOrBody.querySelectorAll(TABLE_CELL_SELECT_CLASS).length; } function createMergedTableContextMenu(context: PluginContext, tableCell: Element) { const { i18n, eventEmitter } = context; const headOrBody = tableCell.parentElement!.parentElement!; const mergedTableContextMenu = []; if (isCellSelected(headOrBody)) { mergedTableContextMenu.push({ label: i18n.get('Merge cells'), onClick: () => eventEmitter.emit('command', 'mergeCells'), className: 'merge-cells', }); } if (hasSpanAttr(tableCell) || hasSpanningCell(headOrBody)) { mergedTableContextMenu.push({ label: i18n.get('Split cells'), onClick: () => eventEmitter.emit('command', 'splitCells'), className: 'split-cells', }); } return mergedTableContextMenu; } export function addMergedTableContextMenu(context: PluginContext) { context.eventEmitter.listen('contextmenu', (...args) => { const [{ menuGroups, tableCell }] = args; const mergedTableContextMenu = createMergedTableContextMenu(context, tableCell); if (mergedTableContextMenu.length) { // add merged table context menu on third group menuGroups.splice(2, 0, mergedTableContextMenu); } }); } ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/renderer.ts ================================================ import type { Node as ProsemirrorNode } from 'prosemirror-model'; import type { ToMdConvertorMap } from '@toast-ui/editor'; type ColumnAlign = 'left' | 'right' | 'center'; const DELIM_LENGH = 3; function repeat(text: string, count: number) { let result = ''; for (let i = 0; i < count; i += 1) { result += text; } return result; } function createTableHeadDelim(textContent: string, columnAlign: ColumnAlign) { let textLen = textContent.length; let leftDelim = ''; let rightDelim = ''; if (columnAlign === 'left') { leftDelim = ':'; textLen -= 1; } else if (columnAlign === 'right') { rightDelim = ':'; textLen -= 1; } else if (columnAlign === 'center') { leftDelim = ':'; rightDelim = ':'; textLen -= 2; } return `${leftDelim}${repeat('-', Math.max(textLen, DELIM_LENGH))}${rightDelim}`; } function createDelim(node: ProsemirrorNode) { const { rowspan, colspan } = node.attrs; let spanInfo = ''; if (rowspan) { spanInfo = `@rows=${rowspan}:`; } if (colspan) { spanInfo = `@cols=${colspan}:${spanInfo}`; } return { delim: `| ${spanInfo}` }; } export const toMarkdownRenderers: ToMdConvertorMap = { tableHead(nodeInfo) { const row = (nodeInfo.node as ProsemirrorNode).firstChild; let delim = ''; if (row) { row.forEach(({ textContent, attrs }) => { const headDelim = createTableHeadDelim(textContent, attrs.align); delim += `| ${headDelim} `; if (attrs.colspan) { for (let i = 0; i < attrs.colspan - 1; i += 1) { delim += `| ${headDelim} `; } } }); } return { delim }; }, tableHeadCell(nodeInfo) { return createDelim(nodeInfo.node as ProsemirrorNode); }, tableBodyCell(nodeInfo) { return createDelim(nodeInfo.node as ProsemirrorNode); }, }; ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/tableOffsetMapMixin.ts ================================================ import type { RowInfo, SelectionInfo, TableOffsetMap } from '@t/index'; import type { Node } from 'prosemirror-model'; export const offsetMapMixin = { extendedRowspan(rowIdx: number, colIdx: number) { const rowspanInfo = this.rowInfo[rowIdx].rowspanMap[colIdx]; return !!rowspanInfo && rowspanInfo.startSpanIdx !== rowIdx; }, extendedColspan(rowIdx: number, colIdx: number) { const colspanInfo = this.rowInfo[rowIdx].colspanMap[colIdx]; return !!colspanInfo && colspanInfo.startSpanIdx !== colIdx; }, getRowspanCount(rowIdx: number, colIdx: number) { const rowspanInfo = this.rowInfo[rowIdx].rowspanMap[colIdx]; return rowspanInfo ? rowspanInfo.count : 0; }, getColspanCount(rowIdx: number, colIdx: number) { const colspanInfo = this.rowInfo[rowIdx].colspanMap[colIdx]; return colspanInfo ? colspanInfo.count : 0; }, decreaseColspanCount(rowIdx: number, colIdx: number) { const colspanInfo = this.rowInfo[rowIdx].colspanMap[colIdx]; const startColspanInfo = this.rowInfo[rowIdx].colspanMap[colspanInfo.startSpanIdx]; startColspanInfo.count -= 1; return startColspanInfo.count; }, decreaseRowspanCount(rowIdx: number, colIdx: number) { const rowspanInfo = this.rowInfo[rowIdx].rowspanMap[colIdx]; const startRowspanInfo = this.rowInfo[rowspanInfo.startSpanIdx].rowspanMap[colIdx]; startRowspanInfo.count -= 1; return startRowspanInfo.count; }, getColspanStartInfo(rowIdx: number, colIdx: number) { const { colspanMap } = this.rowInfo[rowIdx]; const colspanInfo = colspanMap[colIdx]; if (colspanInfo) { const { startSpanIdx } = colspanInfo; const cellInfo = this.rowInfo[rowIdx][startSpanIdx]; return { node: this.table.nodeAt(cellInfo.offset - this.tableStartOffset)!, pos: cellInfo.offset, startSpanIdx, count: colspanMap[startSpanIdx].count, }; } return null; }, getRowspanStartInfo(rowIdx: number, colIdx: number) { const { rowspanMap } = this.rowInfo[rowIdx]; const rowspanInfo = rowspanMap[colIdx]; if (rowspanInfo) { const { startSpanIdx } = rowspanInfo; const cellInfo = this.rowInfo[startSpanIdx][colIdx]; return { node: this.table.nodeAt(cellInfo.offset - this.tableStartOffset)!, pos: cellInfo.offset, startSpanIdx, count: this.rowInfo[startSpanIdx].rowspanMap[colIdx].count, }; } return null; }, getSpannedOffsets(selectionInfo: SelectionInfo): SelectionInfo { let { startRowIdx, startColIdx, endRowIdx, endColIdx } = selectionInfo; for (let rowIdx = endRowIdx; rowIdx >= startRowIdx; rowIdx -= 1) { if (this.rowInfo[rowIdx]) { const { rowspanMap, colspanMap } = this.rowInfo[rowIdx]; for (let colIdx = endColIdx; colIdx >= startColIdx; colIdx -= 1) { const rowspanInfo = rowspanMap[colIdx]; const colspanInfo = colspanMap[colIdx]; if (rowspanInfo) { startRowIdx = Math.min(startRowIdx, rowspanInfo.startSpanIdx); } if (colspanInfo) { startColIdx = Math.min(startColIdx, colspanInfo.startSpanIdx); } } } } for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx += 1) { if (this.rowInfo[rowIdx]) { const { rowspanMap, colspanMap } = this.rowInfo[rowIdx]; for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx += 1) { const rowspanInfo = rowspanMap[colIdx]; const colspanInfo = colspanMap[colIdx]; if (rowspanInfo) { endRowIdx = Math.max(endRowIdx, rowIdx + rowspanInfo.count - 1); } if (colspanInfo) { endColIdx = Math.max(endColIdx, colIdx + colspanInfo.count - 1); } } } } return { startRowIdx, startColIdx, endRowIdx, endColIdx }; }, } as TableOffsetMap; function extendPrevRowspan(prevRowInfo: RowInfo, rowInfo: RowInfo) { const { rowspanMap, colspanMap } = rowInfo; const { rowspanMap: prevRowspanMap, colspanMap: prevColspanMap } = prevRowInfo; Object.keys(prevRowspanMap).forEach((key) => { const colIdx = Number(key); const prevRowspanInfo = prevRowspanMap[colIdx]; if (prevRowspanInfo?.count > 1) { const prevColspanInfo = prevColspanMap[colIdx]; const { count, startSpanIdx } = prevRowspanInfo; rowspanMap[colIdx] = { count: count - 1, startSpanIdx }; colspanMap[colIdx] = prevColspanInfo; rowInfo[colIdx] = { ...prevRowInfo[colIdx], extended: true }; rowInfo.length += 1; } }); } function extendPrevColspan( rowspan: number, colspan: number, rowIdx: number, colIdx: number, rowInfo: RowInfo ) { const { rowspanMap, colspanMap } = rowInfo; for (let i = 1; i < colspan; i += 1) { colspanMap[colIdx + i] = { count: colspan - i, startSpanIdx: colIdx }; if (rowspan > 1) { rowspanMap[colIdx + i] = { count: rowspan, startSpanIdx: rowIdx }; } rowInfo[colIdx + i] = { ...rowInfo[colIdx] }; rowInfo.length += 1; } } export const createOffsetMapMixin = ( headOrBody: Node, startOffset: number, startFromBody = false ) => { const cellInfoMatrix: RowInfo[] = []; const beInBody = headOrBody.type.name === 'tableBody'; headOrBody.forEach((row: Node, rowOffset: number, rowIdx: number) => { // get row index based on table(not table head or table body) const rowIdxInWholeTable = beInBody && !startFromBody ? rowIdx + 1 : rowIdx; const prevRowInfo = cellInfoMatrix[rowIdx - 1]; const rowInfo: RowInfo = { rowspanMap: {}, colspanMap: {}, length: 0 }; if (prevRowInfo) { extendPrevRowspan(prevRowInfo, rowInfo); } row.forEach(({ nodeSize, attrs }: Node, cellOffset: number) => { const colspan: number = attrs.colspan ?? 1; const rowspan: number = attrs.rowspan ?? 1; let colIdx = 0; while (rowInfo[colIdx]) { colIdx += 1; } rowInfo[colIdx] = { // 2 is the sum of the front and back positions of the tag offset: startOffset + rowOffset + cellOffset + 2, nodeSize, }; rowInfo.length += 1; if (rowspan > 1) { rowInfo.rowspanMap[colIdx] = { count: rowspan, startSpanIdx: rowIdxInWholeTable }; } if (colspan > 1) { rowInfo.colspanMap[colIdx] = { count: colspan, startSpanIdx: colIdx }; extendPrevColspan(rowspan, colspan, rowIdxInWholeTable, colIdx, rowInfo); } }); cellInfoMatrix.push(rowInfo); }); return cellInfoMatrix; }; ================================================ FILE: plugins/table-merged-cell/src/wysiwyg/util.ts ================================================ import type { ResolvedPos, Node, Schema } from 'prosemirror-model'; import type { Selection } from 'prosemirror-state'; import type { PluginContext } from '@toast-ui/editor'; import type { CellSelection, SelectionInfo } from '@t/index'; export function findNodeBy(pos: ResolvedPos, condition: (node: Node, depth: number) => boolean) { let { depth } = pos; while (depth >= 0) { const node = pos.node(depth); if (condition(node, depth)) { return { node, depth, offset: depth > 0 ? pos.before(depth) : 0, }; } depth -= 1; } return null; } export function findCell(pos: ResolvedPos) { return findNodeBy( pos, ({ type }: Node) => type.name === 'tableHeadCell' || type.name === 'tableBodyCell' ); } export function getResolvedSelection(selection: Selection, context: PluginContext) { if (selection instanceof context.pmState.TextSelection) { const { $anchor } = selection; const foundCell = findCell($anchor); if (foundCell) { const anchor = $anchor.node(0).resolve($anchor.before(foundCell.depth)); return { anchor, head: anchor }; } } const { startCell, endCell } = selection as CellSelection; return { anchor: startCell, head: endCell }; } export function getRowAndColumnCount({ startRowIdx, startColIdx, endRowIdx, endColIdx, }: SelectionInfo) { return { rowCount: endRowIdx - startRowIdx + 1, columnCount: endColIdx - startColIdx + 1 }; } export function setAttrs(cell: Node, attrs: Record) { return { ...cell.attrs, ...attrs }; } export function getCellSelectionClass(selection: Selection) { const proto = Object.getPrototypeOf(selection); return proto.constructor; } export function createDummyCells( columnCount: number, rowIdx: number, schema: Schema, attrs: Record | null = null ) { const { tableHeadCell, tableBodyCell, paragraph } = schema.nodes; const cell = rowIdx === 0 ? tableHeadCell : tableBodyCell; const cells = []; for (let index = 0; index < columnCount; index += 1) { cells.push(cell.create(attrs, paragraph.create())); } return cells; } ================================================ FILE: plugins/table-merged-cell/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "types/**/*", "../../types/**/*"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "importHelpers": false, "paths": { "@/*": ["src/*"], "@t/*": ["types/*"] }, "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: plugins/table-merged-cell/types/index.d.ts ================================================ import type { PluginCommandMap, TableMdNode, TableCellMdNode, MdNode, PluginContext, PluginInfo, } from '@toast-ui/editor'; import type { Selection } from 'prosemirror-state'; import type { Node, ResolvedPos } from 'prosemirror-model'; interface TableBodyMdNode extends MdNode { parent: TableMdNode; } interface TableHeadMdNode extends MdNode { parent: TableMdNode; firstChild: MergedTableRowMdNode; lastChild: MergedTableRowMdNode; next: TableBodyMdNode; } type SpanType = '@cols' | '@rows'; export interface MergedTableRowMdNode extends MdNode { firstChild: MergedTableCellMdNode | null; lastChild: MergedTableCellMdNode | null; parent: TableBodyMdNode | TableHeadMdNode; prev: MergedTableRowMdNode | null; next: MergedTableRowMdNode | null; rowspanMap: { [key: string]: number }; } export interface MergedTableCellMdNode extends TableCellMdNode { prev: MergedTableCellMdNode | null; next: MergedTableCellMdNode | null; parent: MergedTableRowMdNode; } export interface CellSelection extends Selection { startCell: ResolvedPos; endCell: ResolvedPos; isCellSelection: boolean; } interface CellInfo { offset: number; nodeSize: number; extended?: boolean; } interface SelectionInfo { startRowIdx: number; startColIdx: number; endRowIdx: number; endColIdx: number; } interface SpanMap { [key: number]: { count: number; startSpanIdx: number }; } export interface RowInfo { [key: number]: CellInfo; length: number; rowspanMap: SpanMap; colspanMap: SpanMap; } interface SpanInfo { node: Node; pos: number; count: number; startSpanIdx: number; } export interface TableOffsetMapFactory { create(pos: ResolvedPos): TableOffsetMap; } export interface TableOffsetMap { rowInfo: RowInfo[]; table: Node; totalRowCount: number; totalColumnCount: number; tableStartOffset: number; tableEndOffset: number; getCellInfo(rowIdx: number, colIdx: number): CellInfo; posAt(rowIdx: number, colIdx: number): number; getNodeAndPos(rowIdx: number, colIdx: number): { node: Node; pos: number }; extendedRowspan(rowIdx: number, colIdx: number): boolean; extendedColspan(rowIdx: number, colIdx: number): boolean; getRowspanCount(rowIdx: number, colIdx: number): number; getColspanCount(rowIdx: number, colIdx: number): number; decreaseColspanCount(rowIdx: number, colIdx: number): number; decreaseRowspanCount(rowIdx: number, colIdx: number): number; getColspanStartInfo(rowIdx: number, colIdx: number): SpanInfo | null; getRowspanStartInfo(rowIdx: number, colIdx: number): SpanInfo | null; getRectOffsets(startCellPos: ResolvedPos, endCellPos?: ResolvedPos): SelectionInfo; getSpannedOffsets(selectionInfo: SelectionInfo): SelectionInfo; } export type CommandFn = PluginCommandMap[keyof PluginCommandMap]; export default function tableMergedCellPlugin(context: PluginContext): PluginInfo; ================================================ FILE: plugins/table-merged-cell/types/prosemirror-transform.d.ts ================================================ import { Node, Mark } from 'prosemirror-model'; import 'prosemirror-transform'; declare module 'prosemirror-transform' { export interface Transform { setNodeMarkup( pos: number, type: Node | null, attrs?: { [key: string]: any }, marks?: Mark[] ): Transform; } } ================================================ FILE: plugins/table-merged-cell/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const { name, version, author, license } = require('./package.json'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); const filename = `toastui-${name.replace(/@toast-ui\//, '')}`; function getOutputConfig(isProduction, isCDN, minify) { const defaultConfig = { environment: { arrowFunction: false, const: false, }, }; if (!isProduction || isCDN) { const config = { ...defaultConfig, library: { name: ['toastui', 'Editor', 'plugin', 'tableMergedCell'], export: 'default', type: 'umd', }, path: path.resolve(__dirname, 'dist/cdn'), filename: `${filename}${minify ? '.min' : ''}.js`, }; if (!isProduction) { config.publicPath = '/dist/cdn'; } return config; } return { ...defaultConfig, library: { export: 'default', type: 'commonjs2', }, path: path.resolve(__dirname, 'dist'), filename: `${filename}.js`, }; } function getOptimizationConfig(isProduction, minify) { const minimizer = []; if (isProduction && minify) { minimizer.push( new TerserPlugin({ parallel: true, extractComments: false, }) ); minimizer.push(new CssMinimizerPlugin()); } return { minimizer }; } module.exports = (env) => { const isProduction = env.WEBPACK_BUILD; const { minify = false, cdn = false } = env; const config = { mode: isProduction ? 'production' : 'development', entry: './src/index.ts', output: getOutputConfig(isProduction, cdn, minify), module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, ], }, resolve: { extensions: ['.ts', '.js'], alias: { '@': path.resolve('src'), '@t': path.resolve('types'), }, }, plugins: [ new MiniCssExtractPlugin({ filename: () => `${filename}${minify ? '.min' : ''}.css`, }), new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: isProduction, }), ], optimization: getOptimizationConfig(isProduction, minify), }; if (isProduction) { config.plugins.push( new webpack.BannerPlugin( [ 'TOAST UI Editor : Table Merged Cell Plugin', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n') ) ); } else { config.devServer = { // https://github.com/webpack/webpack-dev-server/issues/2484 injectClient: false, inline: true, host: '0.0.0.0', port: 8081, }; config.devtool = 'inline-source-map'; } return config; }; ================================================ FILE: plugins/uml/README.md ================================================ # TOAST UI Editor : UML Plugin > This is a plugin of [TOAST UI Editor](https://github.com/nhn/tui.editor/tree/master/apps/editor) to render UML. [![npm version](https://img.shields.io/npm/v/@toast-ui/editor-plugin-uml.svg)](https://www.npmjs.com/package/@toast-ui/editor-plugin-uml) ![uml](https://user-images.githubusercontent.com/37766175/121813437-01fe9b80-cca7-11eb-966b-598333c8ec14.png) ## 🚩 Table of Contents - [Bundle File Structure](#-bundle-file-structure) - [Usage npm](#-usage-npm) - [Usage CDN](#-usage-cdn) ## 📁 Bundle File Structure ### Files Distributed on npm ``` - node_modules/ - @toast-ui/ - editor-plugin-uml/ - dist/ - toastui-editor-plugin-uml.js ``` ### Files Distributed on CDN The bundle files include all dependencies of this plugin. ``` - uicdn.toast.com/ - editor-plugin-uml/ - latest/ - toastui-editor-plugin-uml.js - toastui-editor-plugin-uml.min.js ``` ## 📦 Usage npm To use the plugin, [`@toast-ui/editor`](https://github.com/nhn/tui.editor/tree/master/apps/editor) must be installed. > Ref. [Getting Started](https://github.com/nhn/tui.editor/blob/master/docs/en/getting-started.md) ### Install ```sh $ npm install @toast-ui/editor-plugin-uml ``` ### Import Plugin #### ES Modules ```js import uml from '@toast-ui/editor-plugin-uml'; ``` #### CommonJS ```js const uml = require('@toast-ui/editor-plugin-uml'); ``` ### Create Instance #### Basic ```js import Editor from '@toast-ui/editor'; import uml from '@toast-ui/editor-plugin-uml'; const editor = new Editor({ // ... plugins: [uml] }); ``` #### With Viewer ```js import Viewer from '@toast-ui/editor/dist/toustui-editor-viewer'; import uml from '@toast-ui/editor-plugin-uml'; const viewer = new Viewer({ // ... plugins: [uml] }); ``` or ```js import Editor from '@toast-ui/editor'; import uml from '@toast-ui/editor-plugin-uml'; const viewer = Editor.factory({ // ... plugins: [uml], viewer: true }); ``` ## 🗂 Usage CDN To use the plugin, the CDN files(CSS, Script) of `@toast-ui/editor` must be included. ### Include Files ```html ... ... ... ... ``` ### Create Instance #### Basic ```js const { Editor } = toastui; const { uml } = Editor.plugin; const editor = new Editor({ // ... plugins: [uml] }); ``` #### With Viewer ```js const Viewer = toastui.Editor; const { uml } = Viewer.plugin; const viewer = new Viewer({ // ... plugins: [uml] }); ``` or ```js const { Editor } = toastui; const { uml } = Editor.plugin; const viewer = Editor.factory({ // ... plugins: [uml], viewer: true }); ``` ### [Optional] Use Plugin with Options The `uml` plugin can set options when used. Just add the plugin function and options related to the plugin to the array(`[pluginFn, pluginOptions]`) and push them to the `plugins` option of the editor. The following option is available in the `uml` plugin. | Name | Type | Default Value | Description | | ------------- | -------- | ----------------------------------------- | ------------------------- | | `rendererURL` | `string` | `'http://www.plantuml.com/plantuml/png/'` | URL of plant uml renderer | ```js // ... import Editor from '@toast-ui/editor'; import uml from '@toast-ui/editor-plugin-uml'; const umlOptions = { rendererURL: // ... }; const editor = new Editor({ // ... plugins: [[uml, umlOptions]] }); ``` ================================================ FILE: plugins/uml/demo/editor.html ================================================ Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/uml/demo/esm/index.html ================================================ Editor

                          Editor

                          Viewer

                          ================================================ FILE: plugins/uml/demo/viewer.html ================================================ Viewer
                          ================================================ FILE: plugins/uml/index.d.ts ================================================ import type { PluginContext, PluginInfo } from '@toast-ui/editor'; export interface PluginOptions { rendererURL?: string; } export default function umlPlugin(context: PluginContext, options: PluginOptions): PluginInfo; ================================================ FILE: plugins/uml/jest.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const base = require('../../jest.base.config'); module.exports = { ...base, testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; ================================================ FILE: plugins/uml/package.json ================================================ { "name": "@toast-ui/editor-plugin-uml", "version": "3.0.1", "description": "TOAST UI Editor : UML Plugin", "keywords": [ "nhn", "nhn cloud", "toast", "toastui", "toast-ui", "editor", "plugin", "uml" ], "main": "dist/toastui-editor-plugin-uml.js", "types": "index.d.ts", "files": [ "dist/*.js", "index.d.ts" ], "author": "NHN Cloud FE Development Lab ", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nhn/tui.editor.git", "directory": "plugins/uml" }, "bugs": { "url": "https://github.com/nhn/tui.editor/issues" }, "homepage": "https://ui.toast.com", "browserslist": "last 2 versions, not ie <= 10", "scripts": { "lint": "eslint .", "test:types": "tsc", "test": "jest --watch", "test:ci": "jest", "serve": "snowpack dev", "serve:ie": "webpack serve", "build:cdn": "webpack build --env cdn & webpack build --env cdn minify", "build": "webpack build && npm run build:cdn" }, "devDependencies": { "@types/plantuml-encoder": "^1.4.0", "cross-env": "^6.0.3" }, "dependencies": { "plantuml-encoder": "^1.4.0" }, "publishConfig": { "access": "public" } } ================================================ FILE: plugins/uml/snowpack.config.js ================================================ // eslint-disable-next-line @typescript-eslint/no-var-requires const httpProxy = require('http-proxy'); const proxy = httpProxy.createServer({ target: 'http://localhost:8080' }); /** @type {import("snowpack").SnowpackUserConfig } */ module.exports = { mount: { 'demo/esm': '/', src: '/dist', }, devOptions: { port: 8081, }, routes: [ { src: '/img/.*', dest: (req, res) => { proxy.web(req, res); }, }, ], }; ================================================ FILE: plugins/uml/src/__test__/integration/umlPlugin.spec.ts ================================================ /** * @fileoverview Test uml plugin * @author NHN FE Development Lab */ import Editor from '@toast-ui/editor'; import umlPlugin from '@/index'; function removeDataAttr(html: string) { return html .replace(/\sdata-nodeid="\d{1,}"/g, '') .replace(/\n/g, '') .trim(); } describe('uml plugin', () => { let container: HTMLElement, editor: Editor; function assertWwEditorHTML(html: string) { const wwEditorEl = editor.getEditorElements().wwEditor; expect(wwEditorEl).toContainHTML(html); } function assertMdPreviewHTML(html: string) { const mdPreviewEl = editor.getEditorElements().mdPreview; expect(removeDataAttr(mdPreviewEl.innerHTML)).toContain(html); } beforeEach(() => { container = document.createElement('div'); editor = new Editor({ el: container, previewStyle: 'vertical', plugins: [umlPlugin], }); }); afterEach(() => { editor.destroy(); }); it('should render plant uml image in markdown preview', () => { const lang = 'uml'; editor.setMarkdown(`$$${lang}\nAlice -> Bob: Hello\n$$`); assertMdPreviewHTML('src="//www.plantuml.com/plantuml/png'); }); it('should render plant uml image in markdown preview', () => { const lang = 'plantuml'; editor.setMarkdown(`$$${lang}\nAlice -> Bob: Hello\n$$`); assertMdPreviewHTML('src="//www.plantuml.com/plantuml/png'); }); it('should render uml image in wysiwyg', () => { editor.setMarkdown('$$uml\nAlice -> Bob: Hello\n$$'); editor.changeMode('wysiwyg'); assertWwEditorHTML('src="//www.plantuml.com/plantuml/png'); }); }); ================================================ FILE: plugins/uml/src/index.ts ================================================ /** * @fileoverview Implements uml plugin * @author NHN FE Development Lab */ import plantumlEncoder from 'plantuml-encoder'; import { PluginOptions } from '../index'; import type { MdNode, PluginContext, PluginInfo } from '@toast-ui/editor'; import type { HTMLToken } from '@toast-ui/toastmark'; const DEFAULT_RENDERER_URL = '//www.plantuml.com/plantuml/png/'; function createUMLTokens(text: string, rendererURL: string): HTMLToken[] { let renderedHTML; try { if (!plantumlEncoder) { throw new Error('plantuml-encoder dependency required'); } renderedHTML = ``; } catch (err) { renderedHTML = `Error occurred on encoding uml: ${err.message}`; } return [ { type: 'openTag', tagName: 'div', outerNewLine: true }, { type: 'html', content: renderedHTML }, { type: 'closeTag', tagName: 'div', outerNewLine: true }, ]; } /** * UML plugin * @param {Object} context - plugin context for communicating with editor * @param {Object} options - options for plugin * @param {string} [options.rendererURL] - url of plant uml renderer */ export default function umlPlugin(_: PluginContext, options: PluginOptions = {}): PluginInfo { const { rendererURL = DEFAULT_RENDERER_URL } = options; return { toHTMLRenderers: { uml(node: MdNode) { return createUMLTokens(node.literal!, rendererURL); }, plantUml(node: MdNode) { return createUMLTokens(node.literal!, rendererURL); }, }, }; } ================================================ FILE: plugins/uml/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "index.d.ts"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], }, "importHelpers": false, "typeRoots": ["./types", "node_modules/@types", "../../node_modules/@types"], "lib": ["esnext", "dom", "dom.iterable"] } } ================================================ FILE: plugins/uml/webpack.config.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const { name, version, author, license } = require('./package.json'); const TerserPlugin = require('terser-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); function getOutputConfig(isProduction, isCDN, minify) { const filename = `toastui-${name.replace(/@toast-ui\//, '')}`; const defaultConfig = { library: { name: ['toastui', 'Editor', 'plugin', 'uml'], export: 'default', type: 'umd', }, environment: { arrowFunction: false, const: false, }, }; if (!isProduction || isCDN) { const config = { ...defaultConfig, path: path.resolve(__dirname, 'dist/cdn'), filename: `${filename}${minify ? '.min' : ''}.js`, }; if (!isProduction) { config.publicPath = '/dist/cdn'; } return config; } return { ...defaultConfig, path: path.resolve(__dirname, 'dist'), filename: `${filename}.js`, }; } function getExternalsConfig(isProduction, isCDN) { if (isProduction && !isCDN) { return ['plantuml-encoder']; } return []; } function getOptimizationConfig(isProduction, minify) { const minimizer = []; if (isProduction && minify) { minimizer.push( new TerserPlugin({ parallel: true, extractComments: false, }) ); } return { minimizer }; } module.exports = (env) => { const isProduction = env.WEBPACK_BUILD; const { minify = false, cdn = false } = env; const config = { mode: isProduction ? 'production' : 'development', entry: './src/index.ts', output: getOutputConfig(isProduction, cdn, minify), externals: getExternalsConfig(isProduction, cdn), module: { rules: [ { test: /\.ts$|\.js$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, }, }, ], exclude: /node_modules/, }, ], }, resolve: { extensions: ['.ts', '.js'], }, optimization: getOptimizationConfig(isProduction, minify), }; if (isProduction) { config.plugins = [ new webpack.BannerPlugin( [ 'TOAST UI Editor : UML Plugin', `@version ${version} | ${new Date().toDateString()}`, `@author ${author}`, `@license ${license}`, ].join('\n') ), new ESLintPlugin({ extensions: ['js', 'ts'], exclude: ['node_modules', 'dist'], failOnError: isProduction, }), ]; } else { config.devServer = { // https://github.com/webpack/webpack-dev-server/issues/2484 injectClient: false, inline: true, host: '0.0.0.0', port: 8081, }; config.devtool = 'inline-source-map'; } return config; }; ================================================ FILE: scripts/pkg-script.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ const { spawn } = require('child_process'); const { exit } = require('process'); const commandLineArgs = require('command-line-args'); const optionDefinitions = [ { name: 'type', alias: 't', type: String }, { name: 'script', alias: 's', type: String, defaultOption: true }, ]; const options = commandLineArgs(optionDefinitions); const pkgMap = { editor: '@toast-ui/editor', react: '@toast-ui/react-editor', vue: '@toast-ui/vue-editor', toastmark: '@toast-ui/toastmark', chart: '@toast-ui/editor-plugin-chart', color: '@toast-ui/editor-plugin-color-syntax', code: '@toast-ui/editor-plugin-code-syntax-highlight', table: '@toast-ui/editor-plugin-table-merged-cell', uml: '@toast-ui/editor-plugin-uml', }; const pathMap = { editor: 'apps/editor', react: 'apps/react-editor', vue: 'apps/vue-editor', toastmark: 'libs/toastmark', chart: 'plugins/chart', color: 'plugins/color-syntax', code: 'plugins/code-syntax-highlight', table: 'plugins/table-merged-cell', uml: 'plugins/uml', }; let script; let pkg; let path; Object.keys(options).forEach((key) => { const value = options[key]; if (key === 'script') { script = value; } if (key === 'type') { pkg = pkgMap[value]; path = pathMap[value]; } }); if (!script) { throw new Error( `You should choose "lint", "test", "test:types", "serve", "serve:ie", "build" as the type of script` ); } if (!pkg) { throw new Error( `You should choose "editor", "react", "vue", "toastmark", "chart", "color", "code", "uml", "table" as the configuration of type ` ); } if (script === 'test') { spawn('jest', ['--watch', '--projects', path], { stdio: 'inherit', }).on('exit', (code) => { exit(code); }); } else { spawn('lerna', ['run', '--stream', '--scope', pkg, script], { stdio: 'inherit', }).on('exit', (code) => { exit(code); }); } ================================================ FILE: scripts/publish-cdn.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires, no-process-env */ const path = require('path'); const fs = require('fs'); const fetch = require('node-fetch'); const pkg = require('../apps/editor/package.json'); const LOCAL_DIST_PATH = path.join(__dirname, '../apps/editor/dist/cdn'); const STORAGE_API_URL = 'https://api-storage.cloud.toast.com/v1'; const IDENTITY_API_URL = 'https://api-identity.infrastructure.cloud.toast.com/v2.0'; const tenantId = process.env.TOAST_CLOUD_TENENTID; const storageId = process.env.TOAST_CLOUD_STORAGEID; const username = process.env.TOAST_CLOUD_USERNAME; const password = process.env.TOAST_CLOUD_PASSWORD; async function getTOASTCloudContainer(token) { const response = await fetch(`${STORAGE_API_URL}/${storageId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, }, }); const container = await response.text(); return `${container.trim()}/editor`; } async function getTOASTCloudToken() { const data = { auth: { tenantId, passwordCredentials: { username, password, }, }, }; const response = await fetch(`${IDENTITY_API_URL}/tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); const result = await response.json(); return result.access.token.id; } function publishToCdn(token, localPath, cdnPath) { const files = fs.readdirSync(localPath); files.forEach((fileName) => { const objectPath = `${cdnPath}/${fileName}`; if (fileName.match(/(js|css)$/)) { const readStream = fs.createReadStream(`${localPath}/${fileName}`); const contentType = /css$/.test(fileName) ? 'text/css' : 'text/javascript'; fetch(`${STORAGE_API_URL}/${objectPath}`, { method: 'PUT', headers: { 'Content-Type': contentType, 'X-Auth-Token': token, }, body: readStream, }); } else { publishToCdn(token, `${localPath}/${fileName}`, objectPath); } }); } async function publish() { const token = await getTOASTCloudToken(); const container = await getTOASTCloudContainer(token); const cdnPath = `${storageId}/${container}`; [pkg.version, 'latest'].forEach((dir) => { publishToCdn(token, LOCAL_DIST_PATH, `${cdnPath}/${dir}`); }); } publish(); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "skipLibCheck": true, "target": "es5", "module": "es6", "allowJs": true, "strict": true, "moduleResolution": "node", "esModuleInterop": true, "importHelpers": true, "allowSyntheticDefaultImports": true, "sourceMap": true, "baseUrl": ".", "noEmit": true, } } ================================================ FILE: types/tui-code-snippet.d.ts ================================================ declare module 'tui-code-snippet/type/isFunction' { export default function isFunction(value: unknown): value is Function; } declare module 'tui-code-snippet/type/isUndefined' { export default function isUndefined(value: unknown): value is undefined; } declare module 'tui-code-snippet/type/isFalsy' { export default function isFalsy(value: unknown): value is false; } declare module 'tui-code-snippet/type/isString' { export default function isString(value: unknown): value is string; } declare module 'tui-code-snippet/type/isArray' { export default function isArray(value: unknown): value is any[]; } declare module 'tui-code-snippet/type/isExisty' { export default function isExisty(value: unknown): value is NonNullable; } declare module 'tui-code-snippet/type/isNumber' { export default function isNumber(value: unknown): value is number; } declare module 'tui-code-snippet/type/isNull' { export default function isNull(value: unknown): value is null; } declare module 'tui-code-snippet/type/isObject' { export default function isObject(value: unknown): value is object; } declare module 'tui-code-snippet/type/isBoolean' { export default function isBoolean(value: unknown): value is boolean; } declare module 'tui-code-snippet/collection/forEachOwnProperties' { export default function forEachOwnProperties( obj: T, iteratee: (value: NonNullable, key: keyof T, targetObj: T) => boolean | void, context?: object ): void; } declare module 'tui-code-snippet/collection/forEachArray' { export default function forEachArray( arr: Array | ArrayLike, iteratee: (value: T, index: number, targetArr: Array | ArrayLike) => boolean | void, context?: object ): void; } declare module 'tui-code-snippet/collection/toArray' { export default function toArray(value: ArrayLike): T[]; } declare module 'tui-code-snippet/array/inArray' { export default function inArray(value: T, array: T[], startIndex?: number): number; } declare module 'tui-code-snippet/object/extend' { export default function extend(target: T, source: K): T & K; } declare module 'tui-code-snippet/domUtil/css' { export default function css( element: Element, key: string | Record, value?: string ): void; } declare module 'tui-code-snippet/domUtil/addClass' { export default function addClass(element: Element, ...classNames: string[]): void; } declare module 'tui-code-snippet/domUtil/removeClass' { export default function removeClass(element: Element, ...classNames: string[]): void; } declare module 'tui-code-snippet/domUtil/hasClass' { export default function hasClass(element: Element, ...classNames: string[]): boolean; } declare module 'tui-code-snippet/domEvent/on' { export default function on( element: Element, types: string, handler: (...args: any[]) => any ): void; } declare module 'tui-code-snippet/domEvent/off' { export default function off( element: Element, types: string, handler?: (...args: any[]) => any ): void; } declare module 'tui-code-snippet/request/sendHostname' { export default function sendHostname(appName: string, trackingId: string): void; } declare module 'tui-code-snippet/domUtil/matches' { export default function matches(element: Element, selector: string): boolean; } declare module 'tui-code-snippet/tricks/throttle' { export default function throttle(fn: () => void, interval: number): () => void; } declare module 'tui-code-snippet/domUtil/closest' { export default function closest(el: HTMLElement, found: string): HTMLElement | null; }