리치 텍스트 에디터 개발 지침

리치 텍스트 에디터는 GitLab 애플리케이션에서 GitLab Flavored Markdown을 위한 WYSIWYG 편집 경험을 제공하는 UI 컴포넌트입니다. 또한 다른 엔진(정적 사이트 생성기 등)을 대상으로 하는 Markdown에 중점을 둔 에디터를 구현하는 기초 역할을 합니다.

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

사용 가이드

리치 텍스트 에디터를 기능에 포함시키려면 다음 지침을 따르세요.

  1. 리치 텍스트 에디터 컴포넌트를 포함시킵니다.
  2. 마크다운 설정 및 가져오기.
  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 컴포넌트를 참조하세요.

설정 및 가져오기

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

대신 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: __('초기 문서를 불러올 수 없습니다') });
      }
    },
    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">
      {{ __('변경 사항 제출') }}
    </gl-button>
  </div>
</template>

구현 가이드

리치 텍스트 에디터는 세 가지 주요 레이어로 구성됩니다.

  • 편집 도구 UI: 툴바 및 테이블 구조 편집기와 같은 편집 도구 UI입니다. 이러한 도구들은 에디터의 상태를 표시하고 명령을 전파하여 상태를 변이시킵니다.
  • Tiptap 에디터 오브젝트: 에디터의 상태를 관리하고 편집 도구 UI에 의해 실행되는 명령으로 비즈니스 로직을 노출하는데 사용됩니다.
  • 마크다운 직렬화기: 마크다운 원본 문자열을 ProseMirror 문서로 변환하고 그 반대로 변환하는 역할을 합니다.

편집 도구 UI

편집 도구 UI는 에디터의 상태를 표시하고 명령을 전달하여 그 상태를 변이시키는 Vue 컴포넌트입니다. 이들은 ~/content_editor/components 디렉터리에 있습니다. 예를 들어 굵은 글씨 툴바 버튼은 사용자가 굵은 텍스트를 선택할 때 상태를 표시하고 텍스트를 굵게 형식화하기 위해 toggleBold 명령을 전파합니다.

sequenceDiagram participant A as 편집 도구 UI participant B as Tiptap 객체 A->>B: 상태 쿼리/명령 전파 B--)A: 상태 변경 알림

노드 뷰

우리는 노드 뷰를 구현하여 테이블이나 이미지와 같은 일부 콘텐츠 유형에 대한 인라인 편집 도구를 제공합니다. 노드 뷰는 콘텐츠 유형의 표현을 해당 모델에서 분리하여 제공합니다. 표현 레이어에서 Vue 컴포넌트를 사용하면 리치 텍스트 에디터에서 정교한 편집 경험을 제공할 수 있습니다. 노드 뷰는 ~/content_editor/components/wrappers에 있습니다.

명령 전파하기

Vue 컴포넌트에 Tiptap 에디터 오브젝트를 주입하여 명령을 전파할 수 있습니다.

note
Vue 컴포넌트에서 에디터 상태를 변경하는 로직을 구현하지 마세요. 이러한 로직을 명령으로 캡슐화하고 컴포넌트의 메서드에서 명령을 전파하세요.
<script>
export default {
  inject: ['tiptapEditor'],
  methods: {
    execute() {
      //Incorrect
      const { state, view } = this.tiptapEditor.state;
      const { tr, schema } = state;
      tr.addMark(state.selection.from, state.selection.to, null, null, schema.mark('bold'));
      
      // Correct
      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 엔드포인트를 사용하여 ProseMirror의 HTML 구문 분석 및 직렬화 기능을 활용합니다:

sequenceDiagram participant A as 리치 텍스트 편집기 participant E as Tiptap 객체 participant B as Markdown 직렬화기 participant C as Markdown API participant D as ProseMirror 구문 분석기 A->>B: deserialize(markdown) B->>C: render(markdown) C-->>B: html B->>D: to document(html) D-->>A: document A->>E: setContent(document)

역직렬화기는 확장 기능 모듈에 있습니다. parseHTMLaddAttributes에 대한 Tiptap 문서를 읽어서 해당 기능을 어떻게 구현하는지 배울 수 있습니다. Tiptap API는 ProseMirror의 스키마 스펙 API를 감싼 것입니다.

직렬화

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

sequenceDiagram participant A as 리치 텍스트 편집기 participant B as Markdown 직렬화기 participant C as ProseMirror Markdown A->>B: serialize(document) B->>C: serialize(document, serializers) C-->>A: Markdown 문자열

prosemirror-markdown는 콘텐츠 편집기에서 지원하는 각 콘텐츠 유형에 대한 직렬화기 함수를 구현해야 합니다. 직렬화기는 ~/content_editor/services/markdown_serializer.js에 구현합니다.