왜 알아야 할까?
ProseMirror는 완성품이 아니라 레고 박스에 가깝다. ProseMirror가 아닌 Quill.js나 Tiptap을 사용하면 되는 것 아닌가하는데 Quill은 ‘완성품’에 가깝기 때문에 커스터마이징이 어렵다. 한편, Tiptap은 ProseMirror 기반이지만, tiptap에서도 커스터마이징이 필요한 순간에는 ProseMirror에 대한 지식이 반드시 수반된다.
하지만 ProseMirror는 직접 다뤄보면 개념이 많고, 구조도 복잡하다. 이 글은 ProseMirror의 공식 문서를 직접 읽고 정리한 내용이고, 핵심 구조와 개념을 빠르게 파악할 수 있도록 돕는다.
•
ProseMirror가 내부적으로 문서를 어떻게 표현하고 관리하는지 이해할 수 있다.
•
트랜잭션, Transform, Plugin 같은 중요한 개념을 사례와 함께 파악할 수 있다.
•
position, indexing 등을 통해 원하는 위치의 콘텐츠를 마음대로 컨트롤할 수 있게 된다.
ProseMirror의 문서 구조: doc, fragment, node
ProseMirror에서 에디터 콘텐츠는 document → fragment → node 구조로 표현된다.
•
document: 에디터 전체 콘텐츠를 나타내는 최상위 객체
•
fragment: 여러 노드를 묶어 놓은 것
•
node: 실제 콘텐츠 단위 (텍스트, 이미지 등)
특히 leaf node는 콘텐츠를 가질 수 없는 노드로, 예를 들어 이미지, 가로줄, 동영상 등이 여기에 해당한다.
왜 ProseMirror의 노드 구조는 플랫할까?
ProseMirror는 주로 텍스트 편집에 특화되어 있다. 그래서 DOM처럼 중첩된 트리 구조가 아닌, 플랫한 구조를 사용한다. 이렇게 하면 특정 위치를 offset 기반으로 바로 찾을 수 있고, 마크(mark)를 통한 스타일 조작도 훨씬 단순해진다.
또한 인접한 노드의 마크는 자동으로 결합되기 때문에, 스타일 변경이 더 직관적이다.
Position, Token, Slice란?
•
position: 문서에서 노드가 차지하는 위치 (숫자 인덱스로 관리)
•
token: 텍스트 콘텐츠를 분해한 최소 단위 (예: hello → h, e, l, l, o)
•
slice: 복사/붙여넣기, 드래그앤드롭 시 사용되는 콘텐츠 덩어리. openStart, openEnd를 통해 어느 정도 깊이에서 잘린 것인지 알 수 있다.
문서 변경은 Transaction과 Transform을 통해 일어난다
ProseMirror는 상태 불변성을 유지하기 위해 직접 문서를 수정하지 않는다. 대신 transaction을 통해 변경을 누적하고, 이 안에서 여러 step들이 문서를 점진적으로 바꾼다.
let tr = state.tr
tr.insertText("hello") // selection 자리에 hello 삽입
let newState = state.apply(tr)
JavaScript
복사
Transform은 실제 DOM 변경이 아닌, 변경 내역(step)을 쌓는 구조다. 이를 통해 협업 편집과 undo/redo 와 같은 기능을 구현할 수 있다.
Step과 Mapping: 포지션을 잃지 않고 따라가기
문서가 바뀌면 기존에 알고 있던 position이 무의미해질 수 있다. 이걸 해결하는 게 step.getMap() 이다. 삽입/삭제 등의 변화가 있어도, 이전 포지션을 새로운 포지션으로 변환할 수 있게 해준다.
let map = step.getMap()
map.map(10) // 문서 변경 전 position 10 → 변경 후 몇 번으로 이동했는지 찾는다.
JavaScript
복사
이 기능 덕분에 selection도 정확하게 추적 가능하다.
에디터 상태(state)의 구성
ProseMirror의 EditorState는 다음 세 가지로 구성된다:
•
doc: 문서 자체
•
selection: 현재 선택 범위
•
storedMarks: 다음 입력될 텍스트에 적용할 마크 정보
추가로 undo 히스토리나 플러그인 상태도 함께 저장된다. 이 모든 것은 불변 상태로 유지된다.
Plugin: 에디터 기능의 핵심 확장 포인트
Plugin을 통해 에디터 기능을 확장할 수 있다. 키 이벤트 처리, 커스텀 상태 관리, 데코레이션 등 다양한 방식으로 사용된다.
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
console.log("key pressed")
return false
}
}
})
JavaScript
복사
상태를 가지는 플러그인도 만들 수 있다:
state: {
init() { return 0 },
apply(tr, value) { return value + 1 }
}
JavaScript
복사
Decorations: 문서 외관을 꾸미는 법
문서 내용을 바꾸지 않고 하이라이팅, 스타일링, 위젯 삽입 등을 하고 싶을 때는 Decoration을 사용한다.
Decoration.inline(0, 5, {style: "background: yellow"})
JavaScript
복사
많은 데코레이션이 있을 경우, 상태로 저장해서 tr.mapping으로 업데이트하는 것이 성능상 유리하다.
NodeView: 노드마다 독립적인 UI 컴포넌트
특정 노드에 대해 완전히 독립적인 UI를 만들고 싶다면 NodeView를 활용할 수 있다. 이미지, 비디오, 코드블록 등에 자주 사용된다.
class ImageView {
constructor(node, view, getPos) {
this.dom = document.createElement("img")
this.dom.src = node.attrs.src
this.dom.addEventListener("click", () => {
const alt = prompt("새 alt 입력:")
if (alt) view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, {
src: node.attrs.src,
alt
}))
})
}
}
JavaScript
복사
Commands: 명령어 인터페이스로 편집 동작 정의
Command는 사용자의 편집 행동(삭제, 삽입 등)을 코드로 정의한 함수다. 상태와 dispatch를 받아 실행 가능 여부를 판단하고, 실행한다.
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
if (dispatch) dispatch(state.tr.deleteSelection())
return true
}
JavaScript
복사
여러 command를 묶어 순차적으로 실행할 수도 있다:
chainCommands(deleteSelection, joinBackward, selectNodeBackward)
JavaScript
복사
협업 편집
ProseMirror는 step 기반의 트랜잭션을 통해 충돌 없는 협업 편집을 구현할 수 있다. collab 플러그인을 사용하면 로컬 변경을 step으로 관리하고, 중앙 서버(authority)와 주고받는다.
결론
Tiptap과 같이 훌륭한 에디터가 있다고 해도, 까다로운 요구사항들이 있을 경우 커스터마이징이 필요하다. 그런 경우에는 ProseMirror에 대한 기본 지식이 꼭 필요하다. 그래야 올바른 방식으로 에디터를 컨트롤 할 수 있다. 이처럼 ProseMirror는 학습 곡선이 있는 편이지만, 내부 구조를 이해하면 아주 강력한 에디터를 만들 수 있다.