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

리치 텍스트 에디터는 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를 호출한 결과(문자열)를 반환하는 비동기 함수입니다.
  • uploadsPathmultipart/form-data 지원이 있는 GitLab 업로드 서비스를 가리키는 URL입니다.

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

Markdown 설정 및 가져오기

ContentEditor Vue 구성 요소는 Markdown 설정 및 가져오기가 비용이 많이 드는 작업이기 때문에 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는 툴바와 테이블 구조 편집기와 같은 요소로, 편집기의 상태를 표시하고 명령을 전송하여 상태를 변경합니다.
  • Tiptap Editor 객체는 편집기의 상태를 관리하고, 편집 도구 UI에서 실행되는 명령으로 비즈니스 로직을 노출합니다.
  • Markdown 직렬 변환기는 Markdown 소스 문자열을 ProseMirror 문서로 변환하고 그 반대도 수행합니다.

편집 도구 UI

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

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

노드 뷰

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

명령 전송

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

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,
  // Other extensions
]

Markdown 직렬 변환기

Markdown Serializer는 Markdown 문자열을 ProseMirror 문서로 변환하고 그 반대도 수행합니다.

역직렬화

역직렬화는 Markdown을 ProseMirror 문서로 변환하는 과정입니다. 우리는 Markdown을 Markdown API endpoint를 사용하여 HTML로 렌더링함으로써 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)

역직렬 변환기는 확장 모듈 내에 존재합니다. Tiptap 문서에서 parseHTMLaddAttributes에 대해 읽어보며 구현 방법을 학습하세요. Tiptap API는 ProseMirror의 schema spec API를 감싸고 있습니다.

직렬화

직렬화는 ProseMirror 문서를 Markdown으로 변환하는 과정입니다. Content Editor는 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 string

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