리치 텍스트 편집기 개발 가이드라인

리치 텍스트 편집기는 GitLab 애플리케이션에서 GitLab Flavored Markdown을 위한 WYSIWYG 편집 경험을 제공하는 UI 구성 요소입니다. 또한 다른 엔진을 타깃팅하는 Markdown 중심의 편집기를 구현하기 위한 기반으로 사용됩니다.

우리는 리치 텍스트 편집기를 구축하기 위해 Tiptap 2.0ProseMirror를 사용합니다. 이러한 프레임워크는 웹 기술인 contenteditable 위에 추상화 수준을 제공합니다.

사용 가이드

이 지침을 따라 기능에 리치 텍스트 편집기를 포함하세요.

  1. 리치 텍스트 편집기 구성 포함.
  2. Markdown 설정 및 가져오기.
  3. 변경 내용 수신 대기.

리치 텍스트 편집기 구성 포함

ContentEditor Vue 구성 요소를 가져오세요. ContentEditor는 큰 종속성이므로 비동기 이름 지정 가져오기를 권장합니다.

<script>
export default {
  components: {
    ContentEditor: () =>
      import(
        /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
      ),
  },
  // 구성 요소 정의의 나머지 부분
}
</script>

리치 텍스트 편집기에는 두 개의 속성이 필요합니다:

  • renderMarkdownMarkdown API를 호출하여 응답(String)을 반환하는 비동기 함수입니다.
  • uploadsPathmultipart/form-data를 지원하는 GitLab 업로드 서비스를 가리키는 URL입니다.

이 두 속성의 프로덕션 예시는 WikiForm.vue 구성 요소에서 확인할 수 있습니다.

Markdown 설정 및 가져오기

ContentEditor Vue 구성 요소는 Vue 데이터 바인딩 흐름(v-model)을 구현하지 않습니다. 왜냐하면 Markdown 설정 및 가져오기가 비용이 많이 드는 작업이기 때문입니다. 데이터 바인딩은 사용자가 구성 요소와 상호 작용할 때마다 이러한 작업을 트리거할 것입니다.

대신, initialized 이벤트를 수신하여 ContentEditor 클래스의 인스턴스를 얻어야 합니다.

<script>
import { createAlert } from '~/alert';
import { __ } from '~/locale';

export default {
  methods: {
    async loadInitialContent(contentEditor) {
      this.contentEditor = contentEditor;

      try {
        await this.contentEditor.setSerializedContent(this.content);
      } catch (e) {
        createAlert({ message: __('Could not load initial document') });
      }
    },
    submitChanges() {
      const markdown = this.contentEditor.getSerializedContent();
    },
  },
};
</script>
<template>
  <content-editor
    :render-markdown="renderMarkdown"
    :uploads-path="pageInfo.uploadsPath"
    @initialized="loadInitialContent"
  />
</template>

변경 내용 수신 대기

리치 텍스트 편집기의 변경에 여전히 반응할 수 있습니다. 변경에 반응함으로써 문서가 비어 있는지 또는 더티한지를 알 수 있습니다. 이를 위해 @change 이벤트 핸들러를 사용하세요.

<script>
export default {
  data() {
    return {
      empty: false,
    };
  },
  methods: {
    handleContentEditorChange({ empty }) {
      this.empty = empty;
    }
  },
};
</script>
<template>
  <div>
    <content-editor
      :render-markdown="renderMarkdown"
      :uploads-path="pageInfo.uploadsPath"
      @initialized="loadInitialContent"
      @change="handleContentEditorChange"
      />
    <gl-button :disabled="empty" @click="submitChanges">
      {{ __('Submit changes') }}
    </gl-button>
  </div>
</template>

구현 가이드

리치 텍스트 편집기는 세 가지 주요 레이어로 구성됩니다:

  • 편집 도구 UI, 즉 툴바 및 표 구조 편집기와 같은 것들. 편집 도구 UI는 에디터의 상태를 표시하고 명령을 발송하여 상태를 변이시킵니다.
  • Tiptap 에디터 객체는 에디터의 상태를 관리하고 편집 도구 UI에 의해 실행되는 명령으로서의 비즈니스 로직을 노출합니다.
  • Markdown 직렬화기는 Markdown 소스 문자열을 ProseMirror 문서로 변환하고 그 반대로 변환합니다.

편집 도구 UI

편집 도구 UI는 에디터의 상태를 표시하고 commands를 발송하여 상태를 변이시키는 Vue 구성 요소입니다. 이들은 ~/content_editor/components 디렉토리에 위치합니다. 예를 들어, Bold 툴바 버튼은 사용자가 굵은 텍스트를 선택할 때 활성화되어 에디터의 상태를 표시합니다. 또한 이 버튼은 텍스트를 굵게 서식 지정하기 위해 toggleBold 명령을 발송합니다:

sequenceDiagram participant A as Editing tools UI participant B as Tiptap object A->>B: 상태 조회/명령 발송 B--)A: 상태 변경 알림

노드 뷰

우리는 node views를 구현하여 테이블 및 이미지와 같은 일부 콘텐츠 유형에 대한 인라인 편집 도구를 제공합니다. 노드 뷰는 콘텐츠 유형의 표현을 해당 모델로부터 분리하는 기능을 제공합니다. Vue 컴포넌트를 표현 레이어에 사용함으로써 리치 텍스트 편집기에서 고급 편집 경험을 가능케 합니다. 노드 뷰는 ~/content_editor/components/wrappers에 위치해 있습니다.

명령 전송

Tiptap 편집기 객체를 Vue 컴포넌트에 주입하여 명령을 전송할 수 있습니다.

참고: Vue 컴포넌트에서 에디터의 상태를 변경하는 로직을 구현하지 마십시오. 이러한 로직을 명령에 캡슐화하고 컴포넌트의 메서드에서 명령을 전송하세요.

<script>
export default {
  inject: ['tiptapEditor'],
  methods: {
    execute() {
      // 잘못된 예
      const { state, view } = this.tiptapEditor.state;
      const { tr, schema } = state;
      tr.addMark(state.selection.from, state.selection.to, null, null, schema.mark('bold'));

      // 올바른 예
      this.tiptapEditor.chain().toggleBold().focus().run();
    },
  }
};
</script>
<template>

에디터 상태 쿼리

EditorStateObserver 렌더리스 컴포넌트를 사용하여 에디터 상태의 변경에 반응할 수 있습니다. 문서나 선택이 변경될 때와 같은 상황에서 다음 이벤트를 수신할 수 있습니다:

  • docUpdate
  • selectionUpdate
  • transaction
  • focus
  • blur
  • error.

더 많은 정보는 Tiptap 이벤트 가이드에서 확인하세요.

<script>
// 효율성을 위해 일부 코드가 숨겨져 있습니다
import EditorStateObserver from './editor_state_observer.vue';

export default {
  components: {
    EditorStateObserver,
  },
  data() {
    return {
      error: null,
    };
  },
  methods: {
    displayError({ message }) {
      this.error = message;
    },
    dismissError() {
      this.error = null;
    },
  },
};
</script>
<template>
  <editor-state-observer @error="displayError">
    <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError">
      {{ error }}
    </gl-alert>
  </editor-state-observer>
</template>

Tiptap 에디터 객체

Tiptap Editor 클래스는 에디터의 상태를 관리하고 리치 텍스트 편집기의 비즈니스 로직을 캡슐화합니다. 리치 텍스트 편집기는 이 클래스의 새 인스턴스를 구성하고 GitLab Flavored Markdown을 지원하기 위한 모든 필요한 확장 기능을 제공합니다.

새로운 확장 기능 구현

확장 기능은 리치 텍스트 편집기의 구성요소입니다. 새로운 확장 기능을 구현하는 방법은 Tiptap 가이드를 읽어보세요. 새로운 확장 기능을 구현하기 전에 내장 노드마크 목록을 확인하는 것이 좋습니다.

리치 텍스트 편집기 확장 기능을 ~/content_editor/extensions 디렉터리에 저장하세요. Tiptap 내장 확장 기능을 사용할 때는 해당 디렉터리 내부에 ES6 모듈로 감싸세요:

export { Bold as default } from '@tiptap/extension-bold';

확장 기능의 동작을 사용자 정의하려면 extend 메서드를 사용하세요:

import { HardBreak } from '@tiptap/extension-hard-break';

export default HardBreak.extend({
  addKeyboardShortcuts() {
    return {
      'Shift-Enter': () => this.editor.commands.setHardBreak(),
    };
  },
});

확장 기능 등록

새로운 확장 기능을 ~/content_editor/services/create_content_editor.js에 등록하세요. 확장 기능 모듈을 가져와 builtInContentEditorExtensions 배열에 추가하세요:

import Emoji from '../extensions/emoji';

const builtInContentEditorExtensions = [
  Code,
  CodeBlockHighlight,
  Document,
  Dropcursor,
  Emoji,
  // 다른 확장 기능
]

Markdown 직렬화기

Markdown 직렬화기는 Markdown 문자열을 ProseMirror 문서로, 그리고 그 반대로 변환합니다.

역직렬화

역직렬화는 Markdown을 ProseMirror 문서로 변환하는 과정입니다. 우리는 먼저 Markdown을 HTML로 렌더링하고 Markdown API 엔드포인트를 사용하여 이를 HTML로 변환할 수 있는 ProseMirror의 HTML 파싱 및 직렬화 능력을 활용합니다:

sequenceDiagram participant A as rich text editor participant E as Tiptap object participant B as Markdown serializer participant C as Markdown API participant D as ProseMirror parser A->>B: deserialize(markdown) B->>C: render(markdown) C-->>B: html B->>D: to document(html) D-->>A: document A->>E: setContent(document)

역직렬화기는 확장 모듈에 존재합니다. 이를 구현하는 방법에 대해 알아보려면 Tiptap 문서에서 parseHTMLaddAttributes를 읽어보세요. Tiptap API는 ProseMirror의 스키마 스펙 API를 감싼 것입니다.

직렬화

직렬화는 ProseMirror 문서를 Markdown으로 변환하는 과정입니다. 콘텐츠 편집기는 prosemirror-markdown를 사용하여 문서를 직렬화합니다. 직렬화기를 구현하기 전에 MarkdownSerializerMarkdownSerializerState 클래스 설명서를 읽는 것을 권장합니다:

sequenceDiagram participant A as rich text editor participant B as Markdown serializer participant C as ProseMirror Markdown A->>B: serialize(document) B->>C: serialize(document, serializers) C-->>A: Markdown string

prosemirror-markdown은 리치 텍스트 편집기에서 지원하는 각 콘텐츠 유형마다 직렬화기 함수를 구현해야 합니다. 우리는 ~/content_editor/services/markdown_serializer.js에서 직렬화기를 구현합니다.