들어가며
이전 글에서 Prosemirror 가이드를 보고 공부한 것을 익힐 겸 아주 간단한 에디터를 만들었다. 하지만 NodeView를 만들 때 DOM을 직접 컨트롤 해야해서 여간 불편한 게 아니었다. Prosemirror의 노드 뷰에 React 컴포넌트를 쓸 수 있으면 좋겠다고 생각했다. 다행히 이런 역할을 해주는 Tiptap이라는 오픈소스가 있다.
Tiptap은 ProseMirror를 기반으로 한 리치 텍스트 에디터 프레임워크다. ProseMirror는 문서 모델, 트랜잭션, 플러그인 시스템을 제공하지만 뷰는 DOM API를 직접 다루는 구조라 React와 결합하기 쉽지 않다.
Tiptap은 이 간극을 메우기 위해 NodeView → ReactNodeViewRenderer → ReactRenderer → React Portal 구조를 설계했다. 이를 통해 ProseMirror가 DOM을 요구하는 시점에 React 컴포넌트를 자연스럽게 마운트하고, 양쪽의 생명주기를 맞춰 동작하게 한다.
브릿지 구조의 핵심
ProseMirror는 특정 노드를 렌더링할 때 NodeView라는 개념을 사용한다. NodeView는 해당 노드의 루트 DOM과, 필요하면 편집 가능한 하위 DOM(contentDOM)을 반환하며, update, selection, destroy 같은 생명주기 메서드를 제공한다.
Tiptap은 ReactNodeViewRenderer 함수를 제공해 이 NodeView를 React 컴포넌트로 구현할 수 있게 한다. 내부적으로 ReactNodeView 클래스가 ProseMirror NodeView를 구현하고, 그 안에서 ReactRenderer를 사용해 DOM 컨테이너를 생성한다. 그리고 이 컨테이너에 React 컴포넌트를 포털(createPortal)로 연결해 준다.
그 결과 ProseMirror는 자신이 관리하는 DOM 트리에 이 컨테이너를 붙여 쓰고, React는 별도의 React 트리에서 해당 컨테이너를 대상으로 렌더링을 수행한다. 이렇게 양쪽은 서로의 렌더링 사이클을 방해하지 않으면서 한 화면에 공존하게 된다.
코드로 보는 구현
1. ReactNodeView – NodeView 구현체
ProseMirror가 NodeView를 생성할 때 호출되는 클래스다. 여기서 ReactRenderer를 만들고, 이를 통해 DOM 컨테이너를 준비한다.
// packages/react/src/ReactNodeView.tsx
mount() {
const props = {
editor: this.editor,
node: this.node,
decorations: this.decorations,
view: this.view,
selected: false,
updateAttributes: (attrs = {}) => this.updateAttributes(attrs),
deleteNode: () => this.deleteNode(),
ref: createRef<T>(),
}
const Component = this.component
const ReactNodeViewProvider = memo(componentProps => {
return (
<ReactNodeViewContext.Provider value={context}>
{createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
})
this.renderer = new ReactRenderer(ReactNodeViewProvider, {
editor: this.editor,
props,
as: this.node.isInline ? 'span' : 'div',
className: `node-${this.node.type.name}`.trim(),
})
}
get dom() {
return this.renderer.element as HTMLElement
}
get contentDOM() {
return this.node.isLeaf ? null : this.contentDOMElement
}
TypeScript
복사
2. ReactRenderer – DOM 컨테이너와 포털 등록
이 클래스는 DOM 컨테이너를 만들고, EditorContent에 등록해서 React Portal을 통해 마운트되도록 한다.
// packages/react/src/ReactRenderer.tsx
constructor(component, { editor, props = {}, as = 'div', className = '' }) {
this.element = document.createElement(as)
this.element.classList.add('react-renderer')
if (className) this.element.classList.add(...className.split(' '))
if (editor.isInitialized) {
flushSync(() => this.render())
} else {
this.render()
}
}
render() {
const Component = this.component
this.reactElement = <Component {...this.props} />
this.editor.contentComponent?.setRenderer(this.id, this)
}
TypeScript
복사
3. EditorContent – Portal 마운트
EditorContent는 등록된 모든 ReactRenderer 인스턴스를 순회하며, 각각의 DOM 컨테이너에 React Portal을 생성한다.
// packages/react/src/EditorContent.tsx
{Object.entries(renderers).map(([key, renderer]) =>
createPortal(renderer.reactElement, renderer.element, key)
)}
TypeScript
복사
왜 renderToString이 아니라 createPortal인가?
만약 React 컴포넌트를 renderToString 같은 메서드로 HTML 문자열로 변환해 컨테이너에 붙인다면, 그 시점 이후에는 React의 상태 관리와 이벤트 시스템이 단절된다. HTML은 단순한 정적 마크업일 뿐, React의 가상 DOM과 연결되지 않기 때문이다.
반면 createPortal은 React의 렌더 트리를 유지한 채 DOM의 임의 위치로 컴포넌트를 렌더할 수 있게 해준다. 이렇게 하면 ProseMirror가 관리하는 DOM 구조 안에 React 컴포넌트를 배치하면서도, React의 상태·이벤트·라이프사이클이 그대로 유지된다. 즉, NodeView 내부 UI를 React로 구현하고, 편집 중에도 정상적으로 상호작용할 수 있게 된다.
생명주기 타임라인
에디터가 문서를 렌더링하면 ProseMirror는 각 노드 타입에 맞는 NodeView 생성을 요청한다. 이때 ReactNodeView가 인스턴스화되고, 내부에서 ReactRenderer를 통해 컨테이너 DOM이 만들어진다. 컨테이너는 EditorContent에 등록되고, React Portal이 이 위치에 컴포넌트를 마운트한다. React 입장에서는 이 시점이 곧 componentDidMount에 해당한다.
편집 중에 노드 속성이나 데코레이션이 변경되면 ProseMirror는 NodeView의 update 메서드를 호출한다. Tiptap은 renderer.updateProps()로 새로운 props를 React 컴포넌트에 전달하고, React는 일반적인 재렌더링 과정을 거쳐 변경 사항을 반영한다. 이 과정에서 ProseMirror는 동일한 DOM 컨테이너를 유지하기 때문에 커서나 selection 상태가 초기화되지 않는다.
selection 상태 변화 역시 동일하다. ProseMirror에서 selection이 바뀌면 selectNode 또는 deselectNode가 호출되고, Tiptap은 props의 selected 값을 갱신하여 React UI를 변경한다.
마지막으로 노드가 제거되면 ProseMirror는 destroy를 호출하고, Tiptap은 renderer.destroy()로 EditorContent에서 해당 포털을 제거한다. 그 순간 React의 componentWillUnmount가 실행되면서 정리 작업이 마무리된다. 이렇게 ProseMirror와 React의 수명 주기는 생성 → 업데이트 → 파괴의 흐름에서 정확히 맞물린다.
실제 코드
1. 생성(Mount) – NodeView 인스턴스화
ProseMirror가 문서를 렌더링하면서 NodeView를 생성하면, ReactNodeView.mount()가 호출된다.
이 메서드에서 ReactRenderer를 생성하고, DOM 컨테이너를 만들고, EditorContent에 등록한다.
// packages/react/src/ReactNodeView.tsx
mount() {
const props = {
editor: this.editor,
node: this.node,
decorations: this.decorations as DecorationWithType[],
innerDecorations: this.innerDecorations,
view: this.view,
selected: false,
extension: this.extension,
HTMLAttributes: this.HTMLAttributes,
getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(),
ref: createRef<T>(),
}
const Component = this.component
const ReactNodeViewProvider = memo(componentProps => {
return (
<ReactNodeViewContext.Provider value={context}>
{createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
})
this.renderer = new ReactRenderer(ReactNodeViewProvider, {
editor: this.editor,
props,
as: this.node.isInline ? 'span' : 'div',
className: `node-${this.node.type.name}`.trim(),
})
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
this.updateElementAttributes()
}
TypeScript
복사
이 시점이 React 컴포넌트 입장에서는 componentDidMount에 해당한다. 컨테이너는 EditorContent의 createPortal로 마운트된다.
2. 업데이트(Update) – props 변경
편집 중 노드 속성이나 데코레이션이 바뀌면 ProseMirror가 NodeView의 update()를 호출한다.
Tiptap은 여기서 React 컴포넌트의 props를 갱신하여 리렌더링을 유도한다.
// packages/react/src/ReactNodeView.tsx
update(node: Node, decorations: readonly Decoration[], innerDecorations: DecorationSource): boolean {
const rerenderComponent = (props?: Record<string, any>) => {
this.renderer.updateProps(props)
if (typeof this.options.attrs === 'function') {
this.updateElementAttributes()
}
}
if (node.type !== this.node.type) {
return false
}
this.node = node
this.decorations = decorations
this.innerDecorations = innerDecorations
rerenderComponent({ node, decorations, innerDecorations })
return true
}
TypeScript
복사
이 로직 덕분에 DOM 컨테이너는 그대로 유지되면서 React만 상태를 갱신한다. 따라서 커서나 selection이 초기화되지 않는다.
3. 선택/해제(Select/Deselect) – UI 상태 반영
ProseMirror의 selection이 변하면 handleSelectionUpdate()가 호출되어, NodeView가 선택된 상태인지 판별하고 UI에 반영한다.
// packages/react/src/ReactNodeView.tsx
selectNode() {
this.renderer.updateProps({
selected: true,
})
this.renderer.element.classList.add('ProseMirror-selectednode')
}
deselectNode() {
this.renderer.updateProps({
selected: false,
})
this.renderer.element.classList.remove('ProseMirror-selectednode')
}
TypeScript
복사
이렇게 하면 React 컴포넌트는 selected prop을 받아 시각적인 상태를 즉시 변경할 수 있다.
4. Destroy – 포털 제거
노드가 문서에서 제거되면 ProseMirror가 NodeView의 destroy()를 호출하고, Tiptap은 React Portal을 제거한다.
// packages/react/src/ReactNodeView.tsx
destroy() {
this.renderer.destroy()
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
this.contentDOMElement = null
}
TypeScript
복사
renderer.destroy()는 EditorContent에서 해당 포털을 제거하고, React의 componentWillUnmount가 실행되어 리소스를 정리한다.