본문으로 바로가기 본문 바로가기
WIKI 기술 지식 베이스

Markdown MD 에디터

원문 보기 위키 갱신

Markdown을 편집하면서 내용을 바로 렌더링까지 확인할 수 있는 에디터 컴포넌트예요.

출처: 문서

본문

기본 사용법

:::demo v-model로 입력 컨테이너 안의 값을 양방향 바인딩해요. 내용이 바뀌면 content-change 이벤트가 발생하고, 현재 내용을 반환해요.

<template>
  <input v-model="content" />
  <d-editor-md v-model="content" :md-rules="mdRules" base-url="https://test-base-url" @content-change="valueChange" @preview-content-change="previewChange"></d-editor-md>
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('# This is the title');
    const mdRules = reactive({
      linkify: {
        fuzzyLink: false,
      },
    });

    const valueChange = (val) => {
      console.log(val);
    };

    const previewChange = (val) => {
      console.log(val);
    };

    return { content, mdRules, valueChange, previewChange };
  },
});
</script>

:::

사용자 지정 렌더링

:::demo md에서 html로 바뀌는 렌더링 규칙을 직접 정의할 수 있고, XSS 필터 규칙도 커스터마이징해서 지정한 태그를 허용할 수 있어요.

<template>
  <d-editor-md v-model="content" :custom-renderer-rules="customRendererRules" :custom-xss-rules="customRenderRules"></d-editor-md>
</template>

<script>
import { defineComponent, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('[link](#test)\n<kbd>kbd 标签渲染</kbd>');
    const customRenderRules = ref([
      {
        key: 'kbd',
        value: [], // 为空表示过滤所有属性,放开属性则添加对应项,如['id', 'style']
      },
      {
        key: 'input',
        value: null, // value值为null,则对应标签不会被渲染
      }
    ])
    const customRendererRules = ref([
      {
        key: 'link_open',
        value: function (tokens, idx, opts, env, self) {
          const defaultRender = function (nTokens, index, options, ev, that) {
            return that.renderToken(nTokens, index, options);
          };
          const token = tokens[idx];
          const attrs = token.attrs;
          const hrefIndex = token.attrIndex('href');
          const href = attrs[hrefIndex][1];
          if (typeof window !== 'undefined') {
            attrs[hrefIndex][1] = location.href + href;
          }
          token.attrPush(['target', '_blank']);
          token.attrPush(['id', href]);
          return defaultRender(tokens, idx, opts, env, self);
        },
      },
    ]);
    return { content, customRendererRules, customRenderRules };
  },
});
</script>

:::

사용자 지정 툴바

:::demo 에디터 툴바를 직접 구성해요.

<template>
  <d-editor-md v-model="content" :toolbar-config="toolbarConfig" :custom-toolbars="customToolbars"></d-editor-md>
</template>

<script>
import { defineComponent, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('');
    const toolbarConfig = ['add',
      ['undo', 'redo'],
      ['h1', 'h2', 'bold', 'italic', 'strike', 'underline', 'color', 'font'],
      ['ul', 'ol', 'checklist', 'code', 'link', 'image', 'table'],
      'fullscreen',
    ];
    const customToolbars = {
      add: {
        id: 'add',
        name: '新增',
        exitName: '新增',
        type: 'button',
        icon: `<span>+</span>`,
        shortKey: 'ALT+K',
        handler: () => {
          console.log('自定义工具点击事件');
        },
      },
      undo: {
        id: 'undo',
        name: '撤销',
        exitName: '撤销',
        type: 'button',
        icon: `<svg width="16px" height="14px" viewBox="0 0 16 14">
        <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
          <g fill="#293040">
            <path d="M11,5 C13.7614237,5 16,7.23857625 16,10 C16,12.7614237 13.7614237,15 11,15 L7,15 L7,14 L11,14 C13.209139,14 15,12.209139 15,10 C15,7.790861 13.209139,6 11,6 L5,6 L5,10 L0,5.5 L5,1 L5,5 L11,5 Z" id="路径"></path>
          </g>
        </g>
        </svg>`,
        shortKey: 'Ctrl+M',
        handler: () => {
          console.log('覆盖原有工具栏功能事件');
        },
      }
    };
    return { content, toolbarConfig, customToolbars };
  },
});
</script>

:::

순수 렌더링 모드

:::demo MDRender로 내용만 따로 렌더링해요.

<template>
  <d-md-render :content="content" :md-rules="mdRules" base-url="https://test-base-url"></d-md-render>
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('# h1 \n [link](#test)');
    const mdRules = reactive({
      linkify: {
        fuzzyLink: false,
      },
    });

    return { content, mdRules };
  },
});
</script>

:::

단일 컬럼 렌더링 모드

:::demo mode로 서로 다른 표시 모드를 전환해요.

<template>
  <d-button variant="solid" color="primary" @click="handleModeChange" style="margin-bottom:8px">切换编辑/只读模式</d-button>
  <d-editor-md
    v-model="content"
    :mode="mode"
    :md-rules="mdRules"
    base-url="https://test-base-url"
    @content-change="valueChange"
  ></d-editor-md>
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('# This is the title');
    const mdRules = reactive({
      linkify: {
        fuzzyLink: false,
      },
    });

    const valueChange = (val) => {
      console.log(val);
    };

    const mode = ref('editonly');

    const handleModeChange = () => {
      if (mode.value === 'editonly') {
        mode.value = 'readonly';
      } else {
        mode.value = 'editonly';
      }
    };

    return { content, mode, mdRules, valueChange, handleModeChange };
  },
});
</script>

:::

이미지 파일 업로드 설정

:::demo imageUploadToServer를 설정하면 에디터가 붙여넣기 동작도 함께 감시해요. 이미지가 있으면 imageUpload 이벤트도 발생해요.

<template>
  <d-editor-md v-model="content" :image-upload-to-server="true" @content-change="valueChange" @image-upload="imageUpload"></d-editor-md>
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('# This is the title');

    const valueChange = (val) => {
      console.log(val);
    };

    const imageUpload = ({ file, callback }) => {
      let message;
      const rFilter = /^(image\/bmp|image\/gif|image\/jpge|image\/jpeg|image\/jpg|image\/png|image\/tiff)$/i;
      if (!rFilter.test(file.type)) {
        console.log(rFilter, file.type);
        message = 'Please choose bmp/jpg/jpge/png/gif/tiff type picture to upload';
      } else if (file.size / (1024 * 1024) > 1) {
        message = 'Please choose a picture smaller than 1M to upload';
      }

      if (message) {
        // throw the error message by yourself
        return false;
      } else {
        new Promise((resolve) => {
          const xhr = new XMLHttpRequest();
          xhr.open('POST', 'https://xxx.xxx.com/v1/xxx');
          xhr.setRequestHeader('yourKey', 'yourValue');

          xhr.addEventListener(
            'load',
            (evt) => {
              const result = JSON.parse(xhr.responseText);
              resolve(result);
            },
            false
          );

          const fd = new FormData();
          fd.append('file', file);
          xhr.send(fd);
        }).then((res) => {
          if (res.status === 'success') {
            callback({ name: file.name, imgUrl: res['imgUrl'], title: res['imgTitle'] });
          } else {
            // throw your error message
          }
        });
      }
    };

    return { content, valueChange, imageUpload };
  },
});
</script>

:::

checkbox 렌더링

:::demo md-plugins에 checkbox 플러그인을 설정하면 checkbox를 렌더링하고 checked 변경에 반응해요.

editor-md/checkbox

:::

수학 공식 렌더링

:::demo md-plugins에 katex 플러그인을 설정하면 수학 공식을 렌더링해요.

<template>
  <d-editor-md
    v-model="content"
  >
  </d-editor-md>
  <!-- <d-editor-md
    v-model="content"
    :md-plugins="plugins"
  >
  </d-editor-md> -->
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';
// import mk from '@iktakahiro/markdown-it-katex';  // 请首先安装 @iktakahiro/markdown-it-katex 依赖

export default defineComponent({
  setup() {
    const content = ref(`$E = mc^2$
$\\sqrt{3x-1}+(1+x)^2$  // DEMO无法进行import,使用时请放开代码中注释
    `);

    const mdPlugins = [{
      // plugin: mk
    }];

    return { content, mdPlugins };
  },
});
</script>

<style>
@import 'katex/dist/katex.min.css';  /* 请首先安装 katex 依赖 */
</style>

:::

PlantUML 렌더링

:::demo md-plugins에 plantuml 플러그인을 설정하면 plantuml 다이어그램을 렌더링해요.

<template>
  <d-editor-md
    v-model="content"
  >
  </d-editor-md>
  <!-- <d-editor-md
    v-model="content"
    :md-plugins="plugins"
  >
  </d-editor-md> -->
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';
// import PlantUml from 'markdown-it-plantuml';  // 请首先安装 markdown-it-plantuml 依赖

export default defineComponent({
  setup() {
    const content = ref(`// DEMO无法进行import,使用时请放开代码中注释
@startuml
Alice -> "Bob()" : Hello
"Bob()" -> "This is very long" as Long
' You can also declare:
' "Bob()" -> Long as "This is very long"
Long --> "Bob()" : ok
@enduml`);

    const mdPlugins = [{
      // plugin: PlantUml,
      // opts: {server: 'https://www/plantuml.com/plantuml'} // 自定义server可参考plantuml官方文档进行搭建
    }];

    return { content, mdPlugins };
  },
});
</script>

:::

emoji 렌더링

md-plugins에 emoji 플러그인을 설정하면 emoji 이모티콘을 렌더링해요. 자세한 사용 방법은 예제 코드를 참고하세요.

:::demo

<template>
  <d-editor-md v-model="content" :md-plugins="plugins"></d-editor-md>
</template>

<script>
import { defineComponent,ref } from 'vue';
// import { full as emoji } from 'markdown-it-emoji'; // 请首先安装 markdown-it-emoji 依赖

export default defineComponent({
  setup(){
    const content = ref(':joy:');
    const plugins = [];
    /* const plugins = [{
      plugin: emoji,
    }]; */

    return {content,plugins}
  }
})

</script>

:::

빠른 힌트 설정

:::demo hintConfig를 설정하면 @로 사용자를 고르는 것 같은 시나리오를 지원할 수 있어요.

<template>
  <d-editor-md
    v-model="content"
    :placeholder="'You can enter @ associate member, enter # to associate an order number...'"
    :hint-config="hintConfig"
    :fullscreen-z-index="1000"
    @content-change="valueChange"
  >
    <template #hintTemplate>
      <ul class="list-menu" v-if="hintList && hintList.length">
        <li class="menu-item" v-for="(item, index) of hintList" @click="hintItemClick(item)">{{ `${item.itemText}`}}</li>
      </ul>
    </template>
  </d-editor-md>
</template>

<script>
import { defineComponent, reactive, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref('');

    const valueChange = (val) => {
      console.log(val);
    };

    const hintList = ref([]);

    const hintItemClick = (item) => {
      hintCallback.value && hintCallback.value(item.insertText || item.itemText);
    };

    const hintCallback = ref();

    const hintConfig = {
      '#': (e) => {
          const { callback, cursorHint, prefix } = e;
          const numberList = [
            {
              itemText: '00001',
              insertText: '[00001](#00001)'
            },
            {
              itemText: '00002',
              insertText: '[00002](#00002)'
            },
            {
              itemText: '00003',
              insertText: '[00003](#00003)'
            },
            {
              itemText: '00004',
              insertText: '[00004](#00004)'
            }
          ];
          hintList.value = numberList.filter((item) => item.itemText.indexOf(cursorHint) !== -1);
          hintCallback.value = callback;
      },
      '@': {
        handler: (e) => {
          const { callback, cursorHint, prefix } = e;
          const userList = [
            {
              itemText: 'User1'
            },
            {
              itemText: 'User2'
            },
            {
              itemText: 'User3'
            },
            {
              itemText: 'User4'
            }
          ];
          hintList.value = userList.filter((item) => item.itemText.indexOf(cursorHint) !== -1);
          hintCallback.value = callback;
        },
      },
      throttleTime: 200,
    };

    return { content, valueChange, hintConfig, hintList, hintCallback, hintItemClick };
  },
});
</script>
<style>
.list-menu {
  padding: 8px;
  margin: 0;
  width: 100px;
}
.menu-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding: 4px;
  cursor: pointer;
}
.menu-item:hover {
  background-color: var(--devui-list-item-hover-bg, #f2f5fc);
  color: var(--devui-list-item-hover-text, #526ecc);
}
</style>

:::

TOC 목차 렌더링

:::demo TOC 목차 생성을 지원해요.

<template>
  <d-editor-md v-model="content" :base-url="baseUrl"></d-editor-md>
</template>

<script>
import { defineComponent, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref(`[TOC]
# Directory

## Level-1 Directory
This is a Level-1 directory.

### Level-2 Directory
This is a Level-1 directory.

## Level-1 Directory
This is a Level-1 directory.
`);
    const baseUrl = location.href;

    return { content, baseUrl };
  },
});
</script>

:::

mermaid 렌더링

:::demo mermaid 플로우차트, 간트 차트, 시퀀스 다이어그램 같은 차트 렌더링을 지원해요.

<template>
  <d-editor-md v-model="content" :fullscreen-z-index="1000"></d-editor-md>
</template>

<script>
import { defineComponent, ref } from 'vue';

export default defineComponent({
  setup() {
    const content = ref(`# Flow Chart
\`\`\`mermaid
flowchart LR
A[Hard] -->|Text| B(Round)
B --> C{Decision}
C -->|One| D[Result 1]
C -->|Two| E[Result 2]
\`\`\`

# Gantt
\`\`\`mermaid
gantt
    section Section
    Completed :done,    des1, 2014-01-06,2014-01-08
    Active        :active,  des2, 2014-01-07, 3d
    Parallel 1   :         des3, after des1, 1d
    Parallel 2   :         des4, after des1, 1d
    Parallel 3   :         des5, after des3, 1d
    Parallel 4   :         des6, after des4, 1d
\`\`\`

# Class Diagram
\`\`\`mermaid
classDiagram
Class01 <|-- AveryLongClass : Cool
<<Interface>> Class01
Class09 --> C2 : Where am I?
Class09 --* C3
Class09 --|> Class07
Class07 : equals()
Class07 : Object[] elementData
Class01 : size()
Class01 : int chimp
Class01 : int gorilla
class Class10 {
  <<service>>
  int id
  size()
}
\`\`\`

# State Diagram
\`\`\`mermaid
stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]
\`\`\`

# Pie
\`\`\`mermaid
pie
"Dogs" : 386
"Cats" : 85
"Rats" : 15
\`\`\`

# Sequence Diagram
\`\`\`mermaid
sequenceDiagram
Alice->>John: Hello John, how are you?
loop Healthcheck
    John->>John: Fight against hypochondria
end
Note right of John: Rational thoughts!
John-->>Alice: Great!
John->>Bob: How about you?
Bob-->>John: Jolly good!
\`\`\`
`);

    return { content };
  },
});
</script>

:::

EditorMd 파라미터

파라미터 타입 기본값 설명
v-model string '' 에디터 내용을 양방향 바인딩해요
options object {} 에디터 초기화 시 적용할 커스텀 설정이에요. CodeMirror Options를 참고하세요
base-url string -- html로 렌더링할 때 상대 url 앞에 붙일 baseUrl을 설정해요
custom-parse (html: string) => string -- 렌더링된 html을 직접 처리해요. 렌더링된 html을 받아서 커스텀 처리 후의 html을 반환해야 해요
md-rules object {} markdown이 문자열을 처리하는 방식을 설정해요. markdown-it을 참고하세요
md-plugins MdPlugin[] -- markdown-it 플러그인을 설정해요
hintConfig MdHintConfig[] -- 빠른 힌트 설정을 구성해요
mode 'editonly' | 'readonly' | 'normal' 'normal' 쓰기 전용/읽기 전용/두 컬럼 표시 모드를 고를 수 있어요. 기본값인 'normal'은 두 컬럼 모드로 표시해요
custom-renderer-rules ICustomRenderRule[] [] markdown이 노드를 렌더링하는 방식을 직접 정의해요. 각 규칙은 대응하는 노드 key를 지정하고 렌더링 함수를 직접 작성해야 해요
custom-xss-rules ICustomXssRule[] [] xss가 특정 tag를 필터링하는 방식을 직접 정의해요. 각 규칙은 tag를 지정하고 화이트리스트에 넣을 속성 배열을 제공해야 해요
placeholder string '' 에디터에 내용이 없을 때 표시되는 안내 문구예요
fullscreen-z-index number 10 에디터가 전체 화면 상태일 때의 z-index예요
image-upload-to-server boolean false 이미지 커스텀 업로드 스위치를 켤지 여부예요 (켜면 이미지 복사를 감시하고, 툴바의 이미지 기능으로 업로드할 때 이벤트 콜백을 내보내요)
editor-container-height number -- 선택, 에디터 내용 영역의 높이예요
toolbar-config Array(string) [['undo', 'redo'],['h1', 'h2', 'bold', 'italic', 'strike', 'underline', 'color', 'font'],['ul', 'ol', 'checklist', 'code', 'link', 'image', 'table'],'fullscreen'] 툴바에 표시할 버튼이에요. []로 감싸면 같은 그룹이라는 뜻이고, 그룹이 다르면 선으로 구분돼요. 커스텀도 가능하며, 이때는 custom-toolbars 파라미터를 함께 설정해야 해요
custom-toolbars {IToolbarItemConfig} -- toolbar-config에 있는 각 버튼의 세부 설정을 구성해요 사용자 지정 툴바

EditorMd 이벤트

이벤트명 콜백 파라미터 설명 Demo 링크
after-editor-init Function(instance: object) 에디터 초기화 이벤트예요. 에디터 객체를 반환해요
content-change Function(content: string) 에디터 내용 변경 이벤트예요. 현재 내용을 반환해요
preview-content-change Function(string) 미리보기 내용이 바뀔 때 발생해요. 대응하는 html 필드를 반환해요
image-upload Function({file, callback}) 이미지 업로드 스위치를 켠 뒤 발생하는 이미지 업로드 이벤트 콜백이에요. 파일 내용과 callback 함수를 반환해요
checked-change Function(content: string) plugins에 checkbox를 추가한 뒤, 미리보기의 checkbox checked 상태가 바뀔 때의 콜백이에요

MdRender 파라미터

파라미터 타입 기본값 설명 Demo 링크
base-url string -- html로 렌더링할 때 상대 url 앞에 붙일 baseUrl을 설정해요
custom-parse (html: string) => string -- 렌더링된 html을 직접 처리해요. 렌더링된 html을 받아서 커스텀 처리 후의 html을 반환해야 해요
md-rules object {} markdown이 문자열을 처리하는 방식을 설정해요. markdown-it을 참고하세요
custom-renderer-rules ICustomRenderRule[] [] markdown이 노드를 렌더링하는 방식을 직접 정의해요. 각 규칙은 대응하는 노드 key를 지정하고 렌더링 함수를 직접 작성해야 해요
custom-xss-rules ICustomXssRule[] [] xss가 특정 tag를 필터링하는 방식을 직접 정의해요. 각 규칙은 tag를 지정하고 화이트리스트에 넣을 속성 배열을 제공해야 해요

MdRender 이벤트

이벤트명 콜백 파라미터 설명 Demo 링크
md-render-change Function(string) 내용이 바뀔 때 발생해요. 대응하는 html 렌더링 결과 필드를 반환해요
checked-change Function(content: string) plugins에 checkbox를 추가한 뒤, 미리보기의 checkbox checked 상태가 바뀔 때의 콜백이에요

ICustomRenderRule

interface ICustomRenderRule {
  key: string;
  value: Function;
}

ICustomXssRule

interface ICustomXssRule {
  key: string;
  value: string[] | null;
}

MdPlugin

export interface MdPlugin {
  plugin: any;
  opts?: Object;
}

HintConfig

export interface HintConfigItem {
  handler: (obj: { callback: (replaceText: string) => void; cursorHint: string; prefix: string }) => void;
}
export interface HintConfig {
  throttleTime: number;  // 触发提示事件debounceTime(ms),默认300
  [key: string]: HintConfigItem;  // key为触发提示前缀配置
}

IToolbarItemConfig

export interface IToolbarItemConfig {
  id: string;
  name?: string;
  exitName?: string;
  type?: 'button' | 'dropDown';
  icon?: string;
  exitIcon?: string;
  template?: any;
  component?: any;
  shortKey?: string;
  params?: { [key: string]: any };
  handler?(editor?: any, params?: any): void;
}
const toolbars = Record<string, IToolbarItemConfig>

더 알아보기 (Learn more)

d-editor-md는 Markdown 편집부터 katex·mermaid 같은 플러그인 확장, 이미지 업로드까지 폭넓게 지원해요. 먼저 기본 사용법으로 v-model 바인딩에 익숙해진 뒤 필요한 기능을 하나씩 붙여 보세요.