ProseMirror 가이드 톹아보기

개발 · 2025. 3. 31.

이전 글에서는 동시 편집을 지원하기 위한 프로토콜인 WebRTCWebSocket에 대해서 차이와 동작 방식을 짧게나마 알아봤다. 이번에는 WYSIWYG 리치 텍스트 에디터를 만들기 위한 도구와 개념들을 제공하는 ProseMirror를 알아보려고 한다.

 

ProseMirror Guide

 

ProseMirror Guide

strong "strong text with " em "emphasis"

prosemirror.net

 

ProseMirror는 현재 사내에서 사용하고 있는 협업 툴인 Jira에서도 텍스트 에디터로 사용되고 있다. 또한 예전부터 관심 있는 동시편집에 있어서 ProseMirror가 매우 좋은 성능을 발휘하고 있다고 알고 있어서  ProseMirror를 이 기회에 분석해야 겠다고 생각해 가이드 문서를 보게 됐다.

 

ProseMirror?

ProseMirror 는 State와 Action으로 관리되는 텍스트 에디터 제작 도구이다. API가 매우 방대하고 자유도 또한 매우 방대하다는 특징이 있다. ProseMirror는 문서를 단순한 HTML Blob이 아니라 개발자가 명시적으로 허용한 요소들로 구성된 커스텀 데이터 구조로 관리한다는 것이 핵심이다.

 

 

핵심 모듈

  • prosemirror-model : 에디터 문서 모델 정의
  • prosemirror-state : 전체 에디터 상태(선택 영역 포함)와 상태 간 전환을 위한 트랜잭션 관리
  • prosemirror-view : 에디터 상태를 DOM에 렌더링하고 사용자 상효작용을 관리
  • prosemirror-transform : 문서 변경 사항을 기록, 재생함으로써 undo/redo 및 협업 편집 지원
import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"

let state = EditorState.create({schema})
let view = new EditorView(document.body, {state})

 

간단한 코드 예제를 통해 기본 스키마를 사용하여 에디터 상태를 만들 수 있고, 이를 DOM에 렌더링 하는 과정이다.

위 코드는 아직 어떠한 동작도 이루어지지 않는다. 엔진에서 동작에 대한 정의를 하지 않았기 때문이다.

사용자 정의를 하기 위해서는 아래와 같은 핵심 동작을 알고 있어야한다.

 

  • Transactions (트랜잭션):
    사용자가 타이핑하거나 상호작용할 때 문서가 직접 변경되는 것이 아니라, 변경 내용을 기술한 트랜잭션이 생성된다. 이 트랜잭션을 통해 새로운 상태를 만들고, 그 상태로 뷰를 업데이트한다.
/*
아래 예시코드는 dispatchTransaction을 통해 트랜잭션이 발생할 때마다 문서 크기의 변화를 출력하는 예제이다.
새로운 상태를 생성하여 뷰를 업데이트한다.
*/

import { schema } from "prosemirror-schema-basic";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";

let state = EditorState.create({ schema });

let view = new EditorView(document.body, {
  state,
  dispatchTransaction(transaction) {
    console.log("문서 크기 변경:", transaction.before.content.size, "->", transaction.doc.content.size);
    
    let newState = view.state.apply(transaction);
    view.updateState(newState);
  }
});

 

  • Plugins (플러그인):
    에디터의 동작을 확장하기 위해 사용한다. 예를 들어, keymap 플러그인을 사용해 키보드 단축키(undo/redo 등)를 바인딩할 수 있으며, history 플러그인을 통해 변경 이력을 관리한다.
/* 
 history() 플러그인을 사용해 변경 이력을 관리한다.
 keymap() 플러그인으로 Ctrl + Z는 Undo , Ctrl + Y는 Redo 기능을 추가한다.
*/
import { history, undo, redo } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { schema } from "prosemirror-schema-basic";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";

let state = EditorState.create({
  schema,
  plugins: [
    history(), // 변경 이력 관리
    keymap({ "Mod-z": undo, "Mod-y": redo }) // Ctrl+Z(Undo), Ctrl+Y(Redo)
  ]
});

let view = new EditorView(document.body, { state });
  • Commands (명령어):
    편집 동작을 함수 형태로 제공하며, 키 바인딩이나 메뉴 등에 연결할 수 있다. 기본적인 명령어들(undo, redo 등)은 prosemirror-commands 패키지에서 제공된다.
/*
 undo(view.state, view.dispatch) 및 redo(view.state, view.dispatch)를 직접 실행하여 
 되돌리기/다시 실행 기능을 추가한다
 
 baseKeymap을 통해 기본적인 편집 명령(Enter, Delete 등) 활성화한다
*/

import { undo, redo } from "prosemirror-history";
import { schema } from "prosemirror-schema-basic";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { baseKeymap } from "prosemirror-commands";
import { keymap } from "prosemirror-keymap";

let state = EditorState.create({
  schema,
  plugins: [
    keymap({ "Mod-z": undo, "Mod-y": redo }),
    keymap(baseKeymap) // 기본적인 편집 명령 추가 (Enter, Delete 등)
  ]
});

let view = new EditorView(document.body, { state });

// 버튼을 클릭하면 명령 실행
document.getElementById("undoBtn").addEventListener("click", () => {
  undo(view.state, view.dispatch);
});

document.getElementById("redoBtn").addEventListener("click", () => {
  redo(view.state, view.dispatch);
});
  • Content (내용):
    에디터 상태의 문서는 읽기 전용 데이터 구조로 관리되며, 초기 문서를 DOM 파서 등을 통해 불러올 수 있다.
/*
 <div id="content"> 내부의 HTML 내용을 가져와 ProseMirror 문서로 변환한다.
 이를 통해 기존 HTML 콘텐츠를 편집 가능하게 로드 가능하다.
*/

import { DOMParser } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";

let content = document.getElementById("content");

let state = EditorState.create({
  doc: DOMParser.fromSchema(schema).parse(content) // HTML을 문서로 변환
});

let view = new EditorView(document.body, { state });

ProseMirror-Document

전체 개요
ProseMirror의 문서는 DOM과 유사한 트리 구조를 갖지만, 인라인 컨텐츠는 평면 시퀀스로 저장되어 문자 오프셋을 기준으로 다루기 쉽다.

 

세부 내용

  • 구조 (Structure):
    문서는 하나의 최상위 노드로 시작하며, 이 노드는 자식 노드들을 포함하는 프래그먼트(fragment)를 보유한다. 인라인 컨텐츠는 트리 구조 대신 평면 시퀀스로 관리되어 위치 계산을 단순화한다.
  • 불변성과 영속성 (Identity and Persistence):
    문서의 노드들은 불변(immutable) 값이다. 업데이트 시 기존 노드를 변경하는 대신, 변경된 부분만 새로운 값으로 생성하며, 변경되지 않은 부분은 공유된다. 이 방식은 에디터가 중간에 유효하지 않은 상태가 되는 것을 방지한다.
  • 데이터 구조 (Data Structures):
    각 노드는 Node 클래스의 인스턴스로, 타입, 속성(attributes), 마크(mark) 등을 포함합니다. 문서 전체는 노드들의 트리로 표현된다.
  • 인덱싱 (Indexing):
    문서 내의 위치는 트리의 각 노드의 시작, 종료, 텍스트의 각 문자 등을 토큰(token) 시퀀스로 표현하여, 숫자 오프셋으로 쉽게 접근할 수 있게 한다.
  • 슬라이스 (Slices):
    복사/붙여 넣기나 드래그 앤 드롭을 지원하기 위해 문서의 일부분(슬라이스)을 다루며, 이때 시작 또는 끝의 노드가 ‘열린(open)’ 상태일 수 있다.
  • 변경 (Changing):
    노드와 프래그먼트는 직접 수정하지 않고, 트랜스폼(transformations) 메서드를 사용해 업데이트한다. 예를 들어, Node.replacecopy 메서드를 활용하여 문서를 갱신할 수 있다.

*Schemas*

전체 개요
각 ProseMirror 문서는 스키마와 연관되어 있으며, 스키마는 문서 내에서 허용되는 노드와 그들의 중첩 규칙을 정의한다. 이를 통해 에디터의 내용이 항상 유효한 상태를 유지할 수 있도록 한다.

 

세부 내용

  • 노드 타입 (Node Types):
    모든 노드는 고유한 타입을 가지며, 이 타입은 스키마 내에서 노드의 이름, 속성, 렌더링 방식 등을 정의한다. 예를 들어, 기본 스키마에서는 "doc", "paragraph", "text" 등의 타입이 사용된다.
  • 컨텐츠 표현식 (Content Expressions):
    스키마의 각 노드 스펙에서 "paragraph+", "paragraph*" 등과 같이 문자열 패턴을 사용해 해당 노드가 허용하는 자식 노드의 시퀀스를 정의한다.
    • "paragraph": 단일 단락
    • "paragraph+": 하나 이상의 단락
    • "paragraph*": 0개 이상의 단락
    • "caption?": 0개 또는 1개의 캡션 노드
      또한 파이프(|) 연산자를 사용해 여러 타입 중 선택할 수 있도록 표현할 수 있다.

Prosemirror에서는 제일 중요한 부분이 스키마라고 생각한다. 스키마는 문서 구조와 동작을 결정하는 핵심 요소이기 때문이다. 스키마를 직접 정의함으로써 개발자는 에디터의 동작을 세밀하게 제어할 수 있을 것이다. 예를 들어 특정 노드 유형을 허용하거나 제한함으로써 문서의 유효성을 보장할 수 있을 것이다.

 

 

ProseMirror !

동시 편집을 지원하기 위한 WYZYWIG 에디터 엔진을 알아보는 가운데 알게 된 ProseMirror를 알아보았다.

ProseMirror는 가이드 문서에서 에디터 제작 도구라고 소개를 하고 있는데 이 정도면 그냥 프레임워크가 아닐까..?

그래도 스키마의 정의만 올바르게 내려주면 UX를 효과적으로 표현할 수 있을 것 같아서 매력적임에 틀림없다.

기회가 되면 ProseMirror를 통해 웹 에디터를 구현할 수 있는 기회가 왔으면 좋겠다.

 

끝으로 ProseMirror의 핵심 개념을 정리한다.

 

contenteditable을 활용하지만 직접 조작하지 않음

  • HTML의 contenteditable 속성을 활용하긴 하지만, ProseMirror 자체의 트랜잭션 기반 문서 모델을 우선적으로 관리함
  • 즉, 사용자가 입력하면 ProseMirror의 상태 관리 시스템이 이를 해석하여 문서 모델을 변경하고, 그 결과를 다시 contenteditable에 반영하는 구조.

DOM이 아니라 JSON 기반 문서 모델을 사용

  • ProseMirror는 내부적으로 JSON 형태의 트리 구조 문서 모델을 관리함.
  • contenteditable을 직접 조작하는 것이 아니라, 문서의 변화를 트랜잭션으로 관리하고 이를 반영하는 방식.
  • 따라서 innerHTML을 직접 다루는 일반적인 WYSIWYG 에디터(예: Quill, TinyMCE)와는 다름.

플러그인 시스템을 통해 기능 확장 가능

  • prosemirror-commands, prosemirror-schema-basic, prosemirror-state 등의 모듈을 사용하여 기본 편집 기능을 커스텀 가능함.
  • 따라서 기본 contenteditable 기능이 아니라 보다 정교한 커스텀 편집 동작을 구현할 수 있음.