리치 텍스트 편집기 개발 지침

리치 텍스트 편집기는 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 설정 및 가져오기가 비용이 많이 드는 작업이기 때문입니다. 데이터 바인딩은 사용자가 컴포넌트와 상호 작용할 때마다 이러한 작업을 트리거할 것입니다.

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

<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 Editor 객체: 편집기의 상태를 관리하고 편집 도구 UI에서 실행된 명령으로 비즈니스 로직을 노출합니다.
  • Markdown 직렬화기: Markdown 소스 문자열을 ProseMirror 문서로, 그 반대로 변환합니다.

편집 도구 UI

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

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

노드 뷰

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

명령 발송

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

참고: 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.

이벤트 가이드에서 이러한 이벤트에 대해 자세히 알아보세요.

<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 가이드를 읽어 새로운 확장을 어떻게 구현하는지 알아볼 수 있습니다. 새로운 확장을 구현하기 전에 내장된 nodesmarks 목록을 확인하는 것이 좋습니다.

리치 텍스트 편집기 확장을 ~/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에 등록하세요. 확장 모듈을 import하고 builtInContentEditorExtensions 배열에 추가하세요:

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

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

Markdown 직렬화기

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

역직렬화

역직렬화는 Markdown을 ProseMirror 문서로 변환하는 과정입니다. 우리는 먼저 Markdown을 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 문서를 참조하세요.

직렬화

직렬화는 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에 구현해야 합니다.