Search
🦉

Prosemirror Guide2: Documents, Transformation, Commands

subtitle
공식 문서 번역 및 내용 추가
Tags
자바스크립트
front-end
Created
2025/08/04
2 more properties

My first editor

저번 글에서는 Prosemirror 에디터를 생성할 때 기본적으로 알아야 하는 세 가지(schema, state, view)를 살펴봤다. 이로써 프로스미러 에디터를 만드는 데 필요한 개념을 익혔다.
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})
TypeScript
복사
위 예시는 가장 간단한 프로스미러 에디터 예시이다. ProseMirror를 사용할 때는 문서가 따라야 할 스키마(schema) 를 명시해야 한다. 그래서 가장 먼저 해야 할 일은, 기본 스키마를 포함한 모듈을 import 하는 것이다. 그 다음, 이 스키마를 기반으로 에디터 상태(state) 를 생성한다. state는 스키마에 맞는 빈 문서(document) 를 만들고, 문서의 시작 부분에 기본 선택 영역(selection) 을 설정한다. 마지막으로 이 상태를 기반으로 한 에디터 뷰(view) 를 생성하고, 이를 document.body에 붙인다.
이로써 document는 편집 가능한 DOM 노드로 렌더링되며, 사용자가 입력할 때마다 상태를 변경하는 트랜잭션(transaction) 이 발생하게 된다.
잠깐, 트랜잭션? 이건 또 뭘까? 이 간단한 에디터를 커스터마이징하고 확장하기 위해 Transaction, Plugins, Commands, Content 개념에 대해 소개한다.

Transaction

사용자가 입력하거나 에디터 뷰와 상호작용할 때, ProseMirror는 이를 상태 트랜잭션(state transaction)으로 처리한다. 이 말은, 단순히 문서를 직접 수정하고 그걸로 상태가 자동으로 바뀌는 식이 아니라는 뜻이다.
대신, 어떤 변경이 발생하면 ProseMirror는 트랜잭션(transaction) 을 생성한다. 이 트랜잭션은 무엇이 어떻게 바뀌었는지를 설명하는 객체로, 이를 기존 상태에 적용하면 새로운 상태(state) 를 만들 수 있다. 이 새로운 상태는 다시 뷰를 업데이트하는 데 사용된다. 이전 글에서는 view component를 다룰 때 view → (DOM event) -> (transaction) → state → view 이라는 데이터의 순환으로 설명했었다.
이러한 흐름은 기본적으로 내부에서 자동으로 처리되지만, 플러그인을 작성하거나 뷰를 설정하여 직접 개입할 수도 있다. 예를 들어, 아래 코드는 dispatchTransaction이라는 prop을 지정해서 트랜잭션이 발생할 때마다 호출되는 로직을 직접 정의하고 있다.
let state = EditorState.create({ schema }) let view = new EditorView(document.body, { state, dispatchTransaction(transaction) { console.log("Document size went from", transaction.before.content.size, "to", transaction.doc.content.size) let newState = view.state.apply(transaction) view.updateState(newState) } })
JavaScript
복사
여기서 dispatchTransaction은 다음과 같은 역할을 한다.
1.
트랜잭션이 발생하면, 변경 전과 후의 문서 크기를 콘솔에 출력하고
2.
트랜잭션을 적용해 새 상태를 만들고
3.
view.updateState()를 호출해 에디터를 새 상태로 갱신한다
ProseMirror에서는 모든 상태 갱신이 반드시 updateState()를 통해 이루어져야 하며, 일반적인 편집 작업은 모두 트랜잭션을 디스패치(dispatch) 하는 방식으로 처리된다. 즉, ProseMirror는 사용자 조작 → 트랜잭션 생성 → 상태 적용 → 뷰 갱신이라는 명확한 흐름을 가지고 있으며, 필요하다면 그 흐름의 중간을 직접 제어할 수 있는 구조를 갖추고 있다.

Plugins

플러그인(Plugins)은 에디터와 에디터 state의 동작을 확장하는 데 사용된다. 어떤 플러그인은 단순하게 키보드 입력에 동작을 연결하는 keymap 플러그인처럼 비교적 간단한 기능을 하기도 하고, history 플러그인처럼 더 복잡한 기능을 하기도 한다. 예를 들어 history 플러그인은 트랜잭션을 관찰해서 되돌리기(undo)를 위한 반대 작업을 저장해둔다.
아래 코드는 이 두 가지 플러그인을 에디터에 추가해, 되돌리기/다시 실행(undo/redo) 기능을 활성화하는 예시이다.
import { undo, redo, history } from "prosemirror-history" import { keymap } from "prosemirror-keymap" let state = EditorState.create({ schema, plugins: [ history(), keymap({ "Mod-z": undo, "Mod-y": redo }) // Ctrl/Cmd+Z → undo, Ctrl/Cmd+Y → redo ] }) let view = new EditorView(document.body, { state })
JavaScript
복사
여기서 중요한 포인트는 다음과 같다. 플러그인은 에디터 state를 생성할 때 함께 등록되어야 한다. 왜냐하면 플러그인은 상태 트랜잭션에 접근해야 하기 때문이다. 이렇게 history 기능이 활성화된 상태로 에디터 뷰를 만들면, 이제 사용자가 Ctrl+Z (또는 Mac에서는 Cmd+Z) 를 눌렀을 때 마지막 변경을 되돌릴 수 있다.
즉, 이 예시처럼 프로스미러는 플러그인을 통해 에디터의 기능을 유연하게 확장할 수 있고, 되돌리기/다시 실행 같은 편집기 기본 기능도 플러그인으로 직접 제어할 수 있는, 커스터마이징에 용이한 구조라는 것을 알 수 있다.

Commands

이전 예시에서 키에 바인딩했던 undoredo 는 commands(명령어) 라 불리는 특별한 종류의 함수다. 대부분의 편집 작업(예: 줄바꿈, 삭제, 되돌리기 등)은 이 command 형태로 작성되어 있고, 이들은 키보드 단축키에 바인딩하거나 메뉴에 연결하거나, 기타 사용자 인터페이스에 노출할 수 있다.
prosemirror-commands 패키지는 이런 command 들 중에서 기본적인 편집 명령어들을 다수 제공하며, baseKeymap이라는 최소한의 키맵도 함께 제공한다. 이 키맵을 활성화하면 Enter나 Delete 키를 눌렀을 때 기대하는 동작이 수행되도록 해준다.
예를 들어 아래 코드는 editor에 기본적인 키 동작을 포함한 명령어들을 등록하는 방식이다.
import { baseKeymap } from "prosemirror-commands" let state = EditorState.create({ schema, plugins: [ history(), keymap({ "Mod-z": undo, "Mod-y": redo }), // 되돌리기/다시 실행 단축키 keymap(baseKeymap) // Enter, Delete 등 기본 명령어 바인딩 ] }) let view = new EditorView(document.body, { state })
JavaScript
복사
여기까지 설정하면, 기본적인 편집 기능이 작동하는 실제 사용 가능한 에디터가 만들어진다.
만약 메뉴, 커스텀 단축키, 스키마에 특화된 동작들을 추가하고 싶다면 prosemirror-example-setup 패키지를 살펴볼 수 있다. 이 모듈은 에디터를 빠르게 구성할 수 있도록 도와주는 플러그인 모음을 제공한다. 단, 이름에서 알 수 있듯이 이는 실제 서비스용보다는 학습용/예제용 구성을 목적으로 만들어졌기 때문에, 실제로 배포할 서비스에서는 이 패키지를 직접 쓰기보다는 원하는 대로 설정한 커스텀 코드로 대체하는 것이 일반적이다.

Content

에디터 상태(EditorState)의 문서는 doc 속성 아래에 존재한다. 이 doc은 읽기 전용 데이터 구조이며, 노드들의 계층 구조로 문서를 표현한다. 이 구조는 브라우저의 DOM과 비슷하다.
예를 들어, 간단한 문서는 doc 노드 안에 두 개의 paragraph(문단) 노드가 있고, 각 문단 안에 하나의 text 노드가 들어 있는 형태일 수 있다. 에디터 상태를 초기화할 때, 초기 문서(doc)를 직접 제공할 수도 있다. 이 경우에는 schema 필드를 생략할 수도 있다. 왜냐하면, 문서 객체(doc) 안에도 어떤 스키마로 구성되어 있는지가 포함되어 있기 때문이다.
예를 들어, 아래 코드는 id="content"인 DOM 요소 안의 내용을 파싱해서 그걸 초기 문서로 사용하여 상태를 생성하는 예제이다. 이때 사용되는 DOMParser.fromSchema(schema)는 스키마에 정의된 규칙을 바탕으로 DOM을 ProseMirror의 문서 구조로 변환해준다.
import { DOMParser } from "prosemirror-model" import { EditorState } from "prosemirror-state" import { schema } from "prosemirror-schema-basic" let content = document.getElementById("content") let state = EditorState.create({ doc: DOMParser.fromSchema(schema).parse(content) })
JavaScript
복사
즉, HTML 콘텐츠를 기반으로 ProseMirror 문서를 만들고, 이를 상태로 사용할 수 있도록 해주는 방식이다.
여기까지 배웠다면 기본적인 커스터마이징이 가능하다. 하지만 프로스미러의 기능들을 더 잘 활용하려면 Documents, Document Transformation, Commands 개념에 대해 더 깊게 알아야 한다.

Documents

ProseMirror는 문서를 표현하기 위한 자체적인 데이터 구조를 정의한다. 도큐먼트는 에디터의 핵심 요소이며, 이 구조를 이해하면 ProseMirror의 작동 방식을 파악하는 데 큰 도움이 된다.

Structure

ProseMirror에서 도큐먼트는 하나의 Node 객체로 표현되며, 이 노드는 0개 이상의 자식 노드를 포함하는 Fragment를 가진다. 이 구조는 재귀적이며 트리 형태를 갖는다는 점에서 브라우저의 DOM(Document Object Model)과 유사하다. 하지만 ProseMirror는 인라인 콘텐츠를 저장하는 방식에서 DOM과는 차이를 보인다.
예를 들어, 다음과 같은 HTML 문단이 있다고 해보자.
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
HTML
복사
브라우저 DOM에서는 이를 다음과 같이 중첩된 트리 구조로 표현한다.
출처: 프로스미러 가이드 공식문서
하지만 ProseMirror는 인라인 콘텐츠를 납작한(flat) 시퀀스 형태로 모델링하며, 스타일(markup)은 각 텍스트 노드에 메타데이터(mark)로 붙인다.
출처: 프로스미러 가이드 공식문서
이러한 모델은 우리가 실제로 텍스트를 생각하고 다루는 방식에 더 가까운 구조다.
예를 들어
특정 문단 내에서 커서 위치를 파악할 때 문자 단위 오프셋(offset)으로 처리 가능
스타일 변경이나 텍스트 분할과 같은 작업을 할 때 복잡한 트리 조작 없이 처리 가능하다.
이 구조 덕분에 문서는 항상 하나의 유효한 형태만 가질 수 있다.
동일한 mark를 가진 인접한 텍스트 노드는 자동으로 병합된다.
빈 텍스트 노드는 허용되지 않는다.
mark의 적용 순서는 schema에서 정의된 순서를 따른다.
결과적으로 ProseMirror 문서는 다음과 같은 형태를 갖는다.
트리 형태의 블록 노드(block nodes) 구조
대부분의 말단 노드는 textblock(텍스트를 포함하는 블록 노드)
말단이지만 텍스트를 포함하지 않는 블록 노드도 존재함 → 예: hr, video
각 노드 객체(Node object)는 자신의 역할을 나타내는 여러 속성을 가진다.
isBlock: 블록 노드인지 여부
isInline: 인라인 노드인지 여부
inlineContent: 인라인 콘텐츠를 자식으로 허용하는지 여부
isTextblock: 텍스트를 포함하는 블록 노드인지 여부
isLeaf: 자식 노드를 가질 수 없는 말단 노드인지 여부
예시로
"paragraph"는 일반적으로 isTextblock 속성을 갖는다.
"blockquote"는 다른 블록 노드를 자식으로 갖는 블록 노드이다.
text, hard_break, inline_image는 인라인 말단 노드
horizontal_rule는 블록 말단 노드
마지막으로, 스키마는 "이 노드가 어떤 자식 노드를 가질 수 있는지"에 대한 세부 규칙을 정의할 수 있다. 즉, 어떤 노드가 블록 콘텐츠를 허용한다고 해서 모든 종류의 블록 노드를 자식으로 가질 수 있는 것은 아니다.
이러한 구조 덕분에 프로스미러는 성능, 커서 위치 추적, 마크 병합 처리 등에서 정교하면서도 효율적인 에디터를 구현할 수 있게 된다.

Identity and persistence

DOM 트리와 프로스미러 문서 사이의 또 다른 중요한 차이는 노드를 표현하는 객체가 어떻게 동작하는가에 있다.
브라우저의 DOM에서는 노드가 상태를 가진(mutable) 객체이며, 고유한 정체성(identity)을 가진다. 이 말은 즉, 어떤 DOM 노드는 오직 하나의 부모 노드에만 속할 수 있으며, 업데이트가 일어날 때 기존 노드 객체가 변경(mutate)된다는 의미다.
하지만 프로스미러에서는 다르다. 프로스미러의 노드는 단지 값(value)일 뿐이다. 이는 우리가 숫자 3을 다룰 때와 비슷한 방식으로 접근해야 한다. 숫자 3은 여러 데이터 구조에서 동시에 사용될 수 있고, 어떤 특정 구조에 포함되었다는 링크나 상태를 가지지 않으며, 거기에 1을 더하면 원래 3을 바꾸지 않고 새로운 값인 4가 생긴다.
프로스미러 도큐먼트의 조각(pieces)도 마찬가지다. 도큐먼트 조각은 변경되지 않으며, 새로운 도큐먼트 조각을 만들기 위한 기준점으로만 사용된다. 그들은 자신이 어떤 데이터 구조에 포함되어 있는지 알지 못하며, 여러 구조에 동시에 포함되거나, 하나의 구조 내에 여러 번 등장할 수도 있다. 즉, 상태를 가진 객체가 아닌, 값으로 동작한다.
이러한 특징 때문에, 도큐먼트를 업데이트할 때마다 항상 새로운 도큐먼트 객체가 생성된다. 하지만 바뀌지 않은 하위 노드들은 이전 도큐먼트와 공유되기 때문에, 새로운 도큐먼트를 만드는 작업은 비교적 비용이 저렴하다 (메모리/성능 측면에서).
예시
이런 도큐먼트가 있다고 가정할 때, ‘Hello’를 ‘Hi’로 바꾸고 싶다고 가정해보자.
const doc1 = { type: "doc", content: [ { type: "paragraph", content: [{ type: "text", text: "Hello" }] }, { type: "paragraph", content: [{ type: "text", text: "World" }] }, ] }
JavaScript
복사
// 새로 만든 노드 const newParagraph = { type: "paragraph", content: [{ type: "text", text: "Hi" }] } // 새로운 도큐먼트: 나머지는 doc1에서 재사용 const doc2 = { type: "doc", content: [ newParagraph, // 새로 만든 노드 doc1.content[1] // 기존 노드를 그대로 참조 ] }
JavaScript
복사
즉, doc2는 완전히 새로운 도큐먼트처럼 보이지만, 전체 복사한 게 아니라, 바뀐 부분만 새로 만들고 나머지는 기존 문서의 노드 참조를 재사용한 것이다.
이런 설계는 여러 가지 장점을 가진다.
업데이트 도중 에디터가 잘못된 중간 상태에 빠지는 일이 없어진다.
새로운 상태는 완전히 새롭게 만들어지며, 기존 상태를 바로 교체하는 방식으로 적용되기 때문이다.
문서를 수학적으로 다루는 것처럼 예측 가능하게 사고할 수 있게 해준다.
(값이 내부에서 자꾸 변하면 reasoning이 어렵지만, ProseMirror에서는 그렇지 않다.)
이는 협업 편집 기능 구현을 가능하게 하고,
ProseMirror가 매우 효율적인 DOM 업데이트 알고리즘을 실행할 수 있게 해준다.
(화면에 마지막으로 렌더링한 문서와 현재 문서를 비교하는 방식)
단, 이러한 노드들은 일반적인 자바스크립트 객체로 표현되기 때문에, Object.freeze 같은 방법으로 강제로 불변성을 부여하지 않는다. 왜냐하면 그렇게 하면 성능이 떨어지기 때문이다.
프로스미러에서 Object.freeze로 노드를 동결하면 왜 성능이 떨어지게 될까?
그래서 실제로는 값을 바꾸는 것이 ‘가능’하긴 하지만, 그렇게 하면 안 된다. 노드들은 여러 데이터 구조에서 공유되고 있기 때문에, 하나를 변경하면 여러 군데가 동시에 망가질 수 있다. 이는 노드 객체 내부에 들어있는 속성 객체(attrs)나 자식 노드 배열(fragments) 같은 부분도 마찬가지다. 이들도 마찬가지로 절대 변경하면 안 된다.
ProseMirror 문서의 노드는 숫자와 같이 다루어야 하는 불변 값이다.
직접 변경하지 말고, 항상 새로운 문서를 만들어 교체해야 한다.

Data structures

이제 ProseMirror의 도큐먼트 구조가 어떻게 생겼는지 알아보자.
ProseMirror에서 도큐먼트는 결국 Node 객체 하나다. 이 객체는 다음과 같은 구조를 갖고 있다.
출처: 프로스미러 가이드 공식문서
1.
Node 인스턴스: 각 노드는 Node 클래스의 인스턴스로 만들어진다. 이 노드는 type이라는 정보를 갖고 있고, 이건 해당 노드가 어떤 노드인지 알려주는 역할을 한다. (예: paragraph, heading 등).
2.
type (NodeType): 노드의 이름, 가능한 속성(attribute), 어떤 내용을 포함할 수 있는지 등의 정보를 담고 있다. 같은 schema에 속하는 여러 노드들이 타입별로 정의돼 있다.
3.
content (Fragment): 노드 안에 들어갈 수 있는 자식 노드들을 Fragment라는 객체로 묶어서 보관한다. 비어 있는 노드(예: 수평선)도 실제로는 empty fragment라는 기본값을 가진다.
4.
attrs (Attributes): 노드의 추가 데이터들. 예: 이미지 노드의 alt, src 속성 등.
5.
marks: 인라인 노드에만 존재한다. 예: “strong”(굵게), “em”(기울임), “link” 같은 스타일 정보들이 배열로 저장됨.
결국 전체 도큐먼트도 하나의 Node(“doc” 타입의 루트 노드)일 뿐이다. 이 루트 노드의 자식으로 여러 블록 노드들이 들어가고, 블록 노드 안에는 인라인 콘텐츠가 들어갈 수도 있다. 심지어 문서 전체가 인라인 콘텐츠 하나만 있어도 된다. (루트가 textblock이 될 수도 있다.)
아래 코드는 ProseMirror 기본 schema를 사용해서 도큐먼트를 수동으로 생성하는 예시이다. One.+ 수평선 + Two!이 3개의 노드가 한 도큐먼트를 구성하고 있다.
import {schema} from "prosemirror-schema-basic" // (The null arguments are where you can specify attributes, if necessary.) let doc = schema.node("doc", null, [ schema.node("paragraph", null, [schema.text("One.")]), schema.node("horizontal_rule"), schema.node("paragraph", null, [schema.text("Two!")]) ])
JavaScript
복사

Indexing

ProseMirror의 노드는 두 가지 방식으로 인덱싱할 수 있다. 첫 번째는 DOM처럼 트리 구조로 접근하는 방식이고, 두 번째는 평면적인(flat한) 토큰 시퀀스로 도큐먼트를 취급하는 방식이다.

1. 트리 기반 인덱싱

이 방식은 DOM에서 하는 것처럼 개별 노드에 접근하거나, 자식 노드를 바로 조회하고 싶은 경우에 유용하다. 예를 들어 child 메서드나 childCount를 사용하여 특정 노드의 자식에 접근할 수 있으며, 전체 문서를 순회하고 싶을 때는 descendantsnodesBetween 같은 메서드를 사용한다. 재귀 함수를 통해 문서를 트리처럼 탐색하는 것도 가능하다.
예를 들어서 descendants 메서드로 트리를 순회하면서 highlight라는 타입 이름으로 하이라이트 노드를 찾아서 추가적인 작업을 해줄 수 있다.
editor.state.doc.descendants((node) => { if (node.type.name === 'highlight') { // hightlight 노드에 대한 추가적인 작업... } });
TypeScript
복사

2. 토큰 시퀀스 기반 인덱싱

이 방식은 도큐먼트의 특정 위치를 다룰 때 훨씬 유용하다. 도큐먼트의 모든 위치를 하나의 정수로 표현할 수 있도록 하는데, 이 정수는 실제 객체가 아닌 토큰 순서에 따른 인덱스일 뿐이다. 실제로 메모리에 토큰 객체가 있는 건 아니고, 도큐먼트의 트리 구조와 각 노드가 자기 크기(nodeSize)를 알고 있다는 점을 활용해 정수 기반 위치 접근을 빠르게 처리한다.

위치 규칙

문서의 시작(첫 콘텐츠 바로 앞)은 position 0이다.
콘텐츠를 허용하는 노드에 들어가거나 나오는 지점은 토큰 1개로 센다.
예: 문서가 <p>로 시작한다면, 그 문단의 시작은 position 1이다.
텍스트 노드의 문자 하나마다 토큰 1개로 센다.
예: “hi”라면, “h” 다음이 position 2, “i” 다음이 position 3이다.
콘텐츠를 허용하지 않는 리프 노드(예: 이미지)는 토큰 1개로 간주한다.
예시
HTML로 표현된 문서가 다음과 같다고 하자.
<p>One</p> <blockquote><p>Two<img src="..."></p></blockquote>
HTML
복사
이 문서를 토큰 시퀀스로 표현하면 다음과 같다. 즉, 각 노드는 시작 태그, 문자, 닫는 태그 등으로 하나의 시퀀스처럼 계산된다.
0 1 2 3 4 5  <p> O n e </p> 5 6 7 8 9 10 11 12 13 <blockquote> <p> T w o <img> </p> </blockquote>
Plain Text
복사
노드 크기 정보
각 노드는 .nodeSize 프로퍼티를 가지며, 자신 전체의 크기를 나타낸다.
.content.size는 노드의 자식 콘텐츠의 크기를 나타낸다.
문서 루트 노드의 경우에는 <doc> 자체의 시작/종료 토큰은 포함되지 않으므로 doc.nodeSize가 아니라 doc.content.size가 실제 문서의 크기다. (문서 바깥에는 커서를 둘 수 없기 때문)

위치 해석을 쉽게 하는 방법

문서 내 특정 위치를 사람이 직접 계산하려면 생각보다 복잡하다. 이럴 때는 Node.resolve(position)을 호출하면 ResolvedPos라는 구조체를 얻을 수 있다. 이 구조체를 통해 다음과 같은 정보를 알 수 있다.
현재 위치의 부모 노드
그 부모 내에서의 offset
부모의 조상 노드들
그 외 몇 가지 정보
또는 Prosemirror Debugger Tool이라는 크롬 익스텐션을 활용하면 디버깅하기 편하다.
주의점
childCount로 접근하는 자식 인덱스
문서 전체에서의 위치를 나타내는 position
현재 처리 중인 노드 내부에서의 위치인 node-local offset
이 세 가지는 모두 다르므로 주의해서 구분해야 한다.
요약하자면, ProseMirror는 트리 기반 탐색과 토큰 시퀀스 기반 탐색 모두 지원하며, 이 덕분에 유연한 위치 제어와 빠른 업데이트가 가능하다.

Slices

복붙이나 드래그앤드롭 같은 기능을 처리하려면, 도큐먼트에서 두 위치 사이의 콘텐츠, 즉 도큐먼트의 일부분(slice) 을 다룰 수 있어야 한다. 슬라이스는 전체 노드(node)나 프래그먼트(fragment)와는 다르다. 왜냐하면 슬라이스의 시작 또는 끝에 있는 노드들이 ‘열려 있을 수(open)’ 있기 때문이다.
예를 들어, 한 문단의 중간부터 다음 문단의 중간까지 텍스트를 선택하면, 슬라이스에는 두 개의 문단이 포함되지만, 첫 번째 문단은 시작이 열린(open at the start) 상태이고, 두 번째 문단은 끝이 열린(open at the end) 상태다. 반면, 전체 문단을 선택한 경우는 닫힌(closed) 노드를 선택한 것이다.
열려 있는 노드는 슬라이스로 다룰 때 스키마 제약 조건을 위반할 수 있다. 왜냐하면 슬라이스가 문단 전체가 아닌 일부만 포함하고 있어서, 원래 스키마에서 요구하는 필수 노드들이 슬라이스 밖에 있을 수 있기 때문이다.
이런 불완전한 문서 조각(slices)을 표현하기 위해 ProseMirror에서는 Slice라는 데이터 구조를 사용한다.
Slice는 다음을 포함한다.
하나의 Fragment (자식 노드의 집합)
양쪽 끝에 대한 open depth 정보 (openStart, openEnd)
이 값은 해당 슬라이스가 어느 깊이까지 열려 있는지를 나타낸다
슬라이스를 만들기 위해서는 Node.slice 메서드를 사용하면 된다.
예시
// doc holds two paragraphs, containing text "a" and "b" let slice1 = doc.slice(0, 3) // The first paragraph console.log(slice1.openStart, slice1.openEnd) // → 0 0
JavaScript
복사
openStartopenEnd가 0이므로, 이 슬라이스는 완전히 닫힌(closed) 상태의 노드이다.
let slice2 = doc.slice(1, 5) // From start of first paragraph // to end of second console.log(slice2.openStart, slice2.openEnd) // → 1 1
JavaScript
복사
openStartopenEnd가 1이므로, 양쪽 모두 하위 노드가 열려 있는(open) 상태이다.
즉, 이 슬라이스는 두 문단의 중간부터 중간까지 잘라낸 부분이다.
ProseMirror에서 슬라이스는 완전한 노드가 아니라 열려 있을 수도 있는 부분적인 문서 조각을 표현하며, 복사-붙여넣기 같은 기능을 구현할 때 매우 중요하다.

Changing

ProseMirror의 NodeFragment영속적(persistent) 자료구조이다. 즉, 절대로 직접 수정(mutate)해서는 안 된다. 한 번 얻은 문서, 노드, 프래그먼트 객체는 항상 동일한 상태로 유지되며, 변경되지 않는다.
대부분의 경우, 도큐먼트를 갱신할 때는 직접 노드를 건드리지 않고,
transform 기능을 이용해 변환(transformation) 을 수행하게 된다. 이 방식은 도큐먼트를 안전하게 업데이트할 수 있을 뿐 아니라, 무엇이 어떻게 바뀌었는지 기록도 남기게 되어, 해당 도큐먼트가 editor state의 일부일 때 필수적이다.
하지만 transform을 사용하지 않고 직접 새로운 도큐먼트 버전을 수동으로 만들고 싶을 때도 있다. 이럴 경우, NodeFragment에는 다음과 같은 헬퍼 메서드들이 준비되어 있다.
1.
문서 전체를 갱신하고 싶을 때
Node.replace: 문서의 특정 범위를 새로운 Slice교체한다. 전체 문서를 업데이트할 때 가장 일반적으로 사용하는 방식이다.
2.
특정 노드만 얕게(shallow) 수정하고 싶을 때
Node.copy: 기존 노드를 기반으로, 새로운 콘텐츠만 넣어서 유사한 노드를 생성한다.
3.
프래그먼트를 다룰 때
Fragment.replaceChild: 자식 노드를 다른 것으로 교체
Fragment.append: 새로운 노드를 뒤에 추가
ProseMirror에서는 문서 구조를 직접 수정하지 않고, 항상 새로운 구조를 생성하여 대체해야 한다. 이를 통해 불변성(immutability)을 유지하면서도, 문서 상태의 추적과 협업(콜라보 편집 기능)이 가능해진다.

Document transformations

Documents의 changing에서 말했던 것처럼 transform을 사용하면 도큐먼트를 안전하게 업데이트하고 변경 기록도 남길 수 있다. 때문에 모든 transaction의 기반이 되며, 되돌리기(undo) 기능이나 협업 편집(collaborative editing) 기능이 가능하게 만드는 역할을 한다.

왜 transform이 필요할까?

“그냥 문서를 직접 바꾸면 안 되는 걸까?”, “아니면 새 문서를 만들어서 에디터에 넣기만 하면 되는 거 아냐?” 라는 질문이 있을 수 있다. 그렇게 하지 않는 데는 몇 가지 이유가 있다.
코드 명확성(Code Clarity): 불변(immutable) 데이터 구조는 코드의 흐름을 더 단순하게 만들고, 버그를 줄인다.
업데이트 히스토리 기록: Transform 시스템은 단순히 문서를 바꾸는 것이 아니라, 이전 문서에서 새 문서로 바뀌기까지의 변화 이력(steps) 을 저장한다. 어떤 편집이 일어났는지를 추적할 수 있게 된다.
이러한 변화를 저장하는 것은 다음과 같은 기능에 매우 중요하다.
각 step의 역연산(inverse) 을 적용하면 이전 상태로 되돌릴 수 있다. ProseMirror는 단순히 이전 상태로 롤백하는 것보다 복잡한 선택적 되돌리기(selective undo) 를 지원한다.
각 사용자의 step을 다른 에디터에게 전달하고, 필요하다면 이를 재정렬해 모든 사용자가 동일한 문서 상태를 유지할 수 있게 한다.
플러그인 대응
에디터 플러그인들은 들어오는 각 변화에 대해 실시간으로 분석하고 반응할 수 있다. 이를 통해 자체적인 상태를 에디터 전체와 동기화된 상태로 유지할 수 있다.
문서를 직접 바꾸는 방식은 단순해 보일 수 있지만, 변화 과정을 추적할 수 없고, 되돌리기나 협업이 어렵고, 플러그인 확장성도 낮아진다. 그래서 ProseMirror는 Transform을 통해 기록 가능한 변화 방식을 채택함으로써 보다 강력하고 확장성 있는 에디터 생태계를 만든다.

Steps

ProseMirror에서 문서 업데이트는 Step이라는 단위로 쪼개어 표현된다. Step은 특정 편집 작업(예: 삭제, 교체, 마크 추가 등)을 정확히 기술하는 객체이다. 대부분의 경우 개발자가 직접 Step을 다룰 필요는 없지만, 어떻게 동작하는지는 알아두면 유용하다.
Step의 예시
ReplaceStep: 문서의 일부분을 다른 내용으로 교체한다.
AddMarkStep: 특정 구간에 mark(예: bold, italic 등) 를 추가한다.
각 Step은 apply 메서드를 통해 문서에 적용할 수 있으며, 적용 결과로 새로운 문서 객체가 반환된다.
console.log(myDoc.toString()) // → p("hello") // A step that deletes the content between positions 3 and 5 let step = new ReplaceStep(3, 5, Slice.empty) let result = step.apply(myDoc) console.log(result.doc.toString()) // → p("heo")
JavaScript
복사

중요한 특징

apply()단순한 적용만 한다.
문서 구조(schema)를 맞춰주거나, slice를 자동 보정하지 않는다.
즉, 규칙에 어긋나는 Step은 실패할 수 있다.
예를 들어, 노드의 시작 토큰만 삭제하려 하면 닫는 토큰이 남아 도큐먼트가 불균형해지고 무효 상태가 되어 실패하게 된다. 그래서 apply()는 에러가 발생할 수 있는 결과 객체(StepResult) 를 반환한다. 이 객체는 성공 시 새로운 도큐먼트, 실패 시 에러 메시지를 담는다.

실무에서는?

직접 Step을 만들기보다는, Transform.replace 같은 헬퍼 함수를 사용해 Step을 자동으로 생성하고 적용하는 방식이 일반적이다. 이는 스키마를 만족하도록, 내부적으로 안전한 Step만 만들어준다.
요약하자면,
Step은 문서 변경의 최소 단위
apply()는 새로운 도큐먼트를 반환하거나 에러를 반환함
직접 다루기보단 Transform의 helper를 사용하는 것이 좋다.

Transforms

편집 동작(editing action)은 하나 이상의 Step을 만들어낼 수 있다. 여러 개의 step을 연속적으로 다루기 가장 편리한 방법은 Transform 객체를 사용하는 것이다. (또는 전체 에디터 상태와 함께 작업하는 경우라면, Transform을 상속한 Transaction을 사용한다.)
let tr = new Transform(myDoc) tr.delete(5, 7) // Delete between position 5 and 7 tr.split(5) // Split the parent node at position 5 console.log(tr.doc.toString()) // The modified document console.log(tr.steps.length) // → 2
JavaScript
복사
Transform의 대부분의 메서드는 자기 자신(this) 을 반환하므로 tr.delete(5, 7).split(5) 같이 체이닝 방식으로 연달아 호출할 수 있다.

다양한 Transform 메서드들

delete / replace: 삭제, 교체
addMark / removeMark: 마크 추가/제거
split: 노드 분리
join: 인접 노드 병합
lift: 특정 노드를 상위로 올림
wrap: 특정 노드를 새로운 래퍼로 감쌈
등 다양한 문서 구조 변형이 가능하다.

Mapping

도큐먼트에 변경이 발생하면, 해당 도큐먼트의 특정 위치를 가리키던 좌표(position)들이 무효화되거나 의미가 바뀔 수 있다.
예를 들어,
도큐먼트에 글자를 삽입하면, 그 이후의 모든 위치들은 한 글자 앞을 가리키게 됨
도큐먼트 전체가 삭제되면, 해당 위치들을 가리키던 포지션은 더 이상 유효하지 않음
하지만 실제 에디터에서는 선택 영역(selection boundaries) 같은 좌표 정보를 변경 이후에도 유지해야 할 필요가 있다. 이를 위해, StepStepMap이라는 객체를 통해 변경 전/후 포지션 간의 변환을 제공한다.
let step = new ReplaceStep(4, 6, Slice.empty) // Delete 4-5 let map = step.getMap() console.log(map.map(8)) // → 6 (2토큰 빠졌으니 8 → 6) console.log(map.map(2)) // → 2 (삭제 이전 위치라 변화 없음)
JavaScript
복사
Transform 객체는 여러 Step이 연속될 수 있으므로, 각 Step의 map을 자동으로 누적해서 Mapping이라는 객체로 관리한다.
let tr = new Transform(myDoc) tr.split(10) // 10에서 노드 분리 → 토큰 2개 추가됨 tr.delete(2, 5) // 2~4 삭제 → 토큰 3개 삭제됨 console.log(tr.mapping.map(15)) // → 14 console.log(tr.mapping.map(6)) // → 3 console.log(tr.mapping.map(10)) // → 9
JavaScript
복사

경계에서의 위치 매핑은 모호할 수 있다

예를 들어, split(10)은 포지션 10에서 토큰 2개를 삽입하는데, 이때 10 포지션을 매핑하면 삽입된 뒤로 이동할 수도 있고, 기존 위치 그대로 유지할 수도 있다. 기본은 삽입 뒤로 이동하는 방식이다. 아래 예시에서는 split을 하며 토큰 2개가 추가되었지만 delete로 인해 토큰 3개가 삭제되어 10 포지션이 -1 이동하여 9로 매핑된 것이다.
console.log(tr.mapping.map(10)) // → 9 (삽입 뒤로 이동)
JavaScript
복사
.map(pos, -1)처럼 bias 값을 -1로 주면, 기존 위치 유지 쪽으로 작동한다. 즉, split으로 인한 토큰 삽입 전으로 위치를 간주하여 기존 위치에서 delete token 3한 값, 10 - 3을 하여 7 포지션이 된 것이다.
console.log(tr.mapping.map(10, -1)) // → 7
JavaScript
복사
왜 이런 구조로 만들었는가?
각 Step을 작고 명확하게 정의하면, 포지션 매핑(position mapping), Step 되돌리기 (undo), 서로 다른 Step 간의 위치 보정 같은 기능을 정확하고 손실 없이 구현할 수 있기 때문이다.

Rebasing

Rebase란, 두 개의 변경(step)이 동일한 도큐먼트에서 시작했지만, 한 쪽이 먼저 적용된 도큐먼트 위에 나머지 변경을 다시 적용할 수 있게 만드는 작업이다. 협업 편집이나 변경 추적(change tracking) 같은 기능을 직접 구현하려 할 때 꼭 필요한 개념이다.

단일 Step

이 상태에서
stepA(doc) = docA stepB(doc) = docB
JavaScript
복사
같은 도큐먼트에 A, B 라는 스텝을 적용했다고 해보자.
stepB(docA) =// 에러. docA는 stepB의 대상과 다름
JavaScript
복사
docA에 대해 stepB를 적용하면 docA는 stepB의 대상 도큐먼트와 상태가 다르기 때문에 에러가 난다.
이때 stepBdocA 기준으로 “재정렬”해야 한다.
rebase(stepB, mapA) = stepB' stepB'(docA) = docAB
JavaScript
복사
여기서 mapAstepA.getMap() 같은 거고, stepB.map(mapA)를 통해 새로운 stepB'를 얻을 수 있다.

여러 개의 중첩된 Step을 Rebase할 때

stepA2(stepA1(doc)) = docA stepB2(stepB1(doc)) = docB
JavaScript
복사
이제 우리가 원하는 건
???(docA) = docAB
JavaScript
복사
즉, stepA1 + stepA2가 적용된 문서(docA)에 stepB1 + stepB2의 효과까지 적용해서 docAB를 얻고 싶은 것.
Step들을 어떻게 Rebase할까?
stepB1stepA1, stepA2의 map을 통해 쉽게 변환 가능 → stepB1'
하지만 stepB2stepB1'이 아닌 stepB1 이후 상태를 기준으로 하기 때문에 더 복잡해진다.
그래서 stepB2를 Rebase하려면?
이 경우, stepB2는 아래의 연결된 맵 체인을 따라야 한다.
rebase(stepB2, [ invert(mapB1), // stepB1이 삽입한 위치 기준 → 원래 문서 기준으로 되돌리기 mapA1, mapA2, // stepA 들의 변화 적용 mapB1' // 재배치된 stepB1의 map ])
JavaScript
복사
먼저 stepB2가 참조하던 위치를 원래 위치로 복원하고
그 원래 위치를 stepA1 + stepA2를 거쳐 변화된 문서로 변환
마지막으로, stepB1'에 의해 다시 삽입된 위치를 반영
조금 복잡할 수 있는데 예시를 들어 좀 더 쉽게 이해해보자.
doc에서 두 사용자가 동시에 다른 작업을 했다.
A 사용자는 글 머리에서 제목을 붙였다.
B 사용자는 중간에 있는 문장 하나를 수정했다.
// 원본 문서 doc = "오늘 날씨는 좋다. 산책을 가고 싶다."
JavaScript
복사
사용자 A의 변경 (stepA1, stepA2)
// stepA1: 앞에 "제목: "을 삽입"제목: 오늘 날씨는 좋다. 산책을 가고 싶다." // stepA2: 마지막 문장을 "등산을 가고 싶다."로 변경"제목: 오늘 날씨는 좋다. 등산을 가고 싶다."
JavaScript
복사
사용자 B의 변경 (stepB1, stepB2)
// stepB1: "날씨는 좋다"를 "날씨가 맑다"로 변경"오늘 날씨가 맑다. 산책을 가고 싶다." // stepB2: "산책"이라는 단어에 밑줄(mark)을 추가"오늘 날씨가 맑다. _산책_을 가고 싶다."
JavaScript
복사
여기서 문제는, docA = stepA2(stepA1(doc))이 적용된 상태인데, stepB1, stepB2는 원본 문서 기준으로 만들어진 것이고 때문에docA에는 적용이 안된다. 그래서 rebase가 필요한 것이다.
rebase(stepB2, [ invert(mapB1), // stepB1이 삽입했던 위치를 "되돌림" — 원래 위치로 감 mapA1, mapA2, // A 사용자의 변경 사항들을 적용해서 위치 다시 이동 mapB1' // stepB1을 docA 기준으로 다시 적용한 버전의 map ])
JavaScript
복사
1.
invert(mapB1): stepB2가 기준으로 삼던 stepB1 적용 후 문서를 다시 원래 문서 기준의 위치로 되돌림
2.
mapA1, mapA2: 원래 문서 기준 위치를 A 사용자의 변경 사항을 반영한 새로운 문서 docA 위치로 이동시킴
3.
mapB1’: 이제 이 위치에서 stepB1이 다시 삽입된 위치를 반영해줘야 stepB2가 정확한 대상에 마크를 다시 적용할 수 있음
즉, 정리하면 위의 코드는 stepB2를 적용하기 위해, stepB1의 위치를 되돌려서 원본 doc의 위치를 알아내고, 여기에 stepA1, stepA2의 변경사항을 적용해서 docA의 위치를 알아낸 후, docA를 기준으로 stepB1 적용한 버전의 map에 stepB2를 적용한다.
stepB2는 "산책"이라는 단어에 밑줄을 긋는 동작
근데 "산책"의 위치는 A의 변경, B의 삽입에 따라 계속 이동
그 이동 흐름을 "되돌렸다 → A의 변경 적용 → B의 삽입 반영" 순서로 따라가, 현재 도큐먼트에서 "산책"이 어디 있는지를 정확히 파악한 후 변경사항을 적용하는 것이다.

복잡한 상황: 삽입된 콘텐츠를 다시 참조할 때

예를 들어, stepB1이 문자열을 삽입하고 stepB2가 그 삽입된 문자열에 마크를 추가했다고 해보자.
이런 경우,invert(mapB1)을 통해 위치를 되돌리면, 그 문자열은 아직 문서에 없으므로 stepB2.map(...) === null 이다. 하지만 이 콘텐츠는 mapB1'에 의해 다시 삽입되기 때문에, Mapping을 통해 그 관계를 추적해야 한다. (ProseMirror의 Mapping 객체는 이런 복잡한 연결 구조를 추적할 수 있게 해준다.)

재배치했다고 해서 무조건 적용 가능한 건 아니다

예를 들어, 어떤 step이 특정 위치에 mark를 추가하려 했는데, 그 부모 노드가 이제 mark를 허용하지 않게 됐다면 step.apply()는 실패하고 예외가 발생한다. 보통 이런 경우는 그냥 해당 step을 버리는 것이 적절하다.

용어 정리

용어
설명
Step
문서에 적용되는 단일 변경
StepMap
step이 문서에서 위치를 어떻게 변경했는지 기록한 객체
Mapping
여러 StepMap을 연결해 포지션을 한꺼번에 변환할 수 있는 도구
invert()
step의 반대 동작 (삭제 삽입 등)
rebase()
한 step을, 다른 step 이후에도 적용할 수 있도록 “다시 매핑”하는 과정

Commands

ProseMirror에서 명령어(command)란 사용자가 키보드 단축키나 메뉴 인터랙션을 통해 실행할 수 있는 편집 동작을 정의한 함수를 의미한다. 예를 들어, 텍스트를 삭제하거나, 블록을 분할하거나, 포맷을 적용하는 등의 모든 행위는 명령어를 통해 처리할 수 있다.

Command 함수의 기본 구조

ProseMirror command는 다음과 같은 형식의 함수로 작성한다.
function myCommand(state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView): boolean
TypeScript
복사
state: 현재 에디터의 상태 (필수)
dispatch: 트랜잭션을 적용하기 위한 함수 (선택)
view: DOM에 접근해야 하는 명령어에서만 사용 (선택)
예시: 선택된 텍스트를 삭제하는 명령어
function deleteSelection(state, dispatch) { if (state.selection.empty) return false if (dispatch) dispatch(state.tr.deleteSelection()) return true }
TypeScript
복사
선택 영역이 비어 있으면 아무 동작도 하지 않고 false를 반환한다.
선택 영역이 있다면, 트랜잭션을 생성해 dispatch하고 true를 반환한다.

dispatch 인자를 생략하는 이유

command는 실제로 실행되기도 하지만, 실행 가능 여부만 판단하기 위해 호출될 수도 있다. 이럴 경우 dispatch 인자를 생략하면 명령어는 아무 일도 하지 않고 true/false만 반환한다.
예시
// 실행 가능한지만 확인 deleteSelection(state) // 실제로 실행 deleteSelection(state, dispatch)
TypeScript
복사
이러한 구조 덕분에 메뉴바나 툴바는 명령어를 통해 버튼을 활성/비활성화할 수 있다.

명령어에서 view를 사용하는 경우

대부분의 명령어는 statedispatch만으로 충분하지만, 일부는 DOM 위치나 포커스를 확인해야 하므로 view를 필요로 한다.
예시
function blinkView(_state, dispatch, view) { if (dispatch) { view.dom.style.background = "yellow" setTimeout(() => (view.dom.style.background = ""), 1000) } return true }
TypeScript
복사
이 명령어는 실행 시 에디터 DOM의 배경을 1초간 노란색으로 바꿨다가 복원한다. 실무에서는 이와 비슷한 방식으로 다이얼로그를 띄우거나 포커스를 이동하는 등의 작업을 수행한다.

여러 명령어를 체이닝하기: chainCommands

여러 명령어를 순서대로 실행 시도하고, 가장 먼저 성공한 명령어만 실행하는 방식이다.
import { chainCommands, deleteSelection, joinBackward, selectNodeBackward } from 'prosemirror-commands' const backspaceCommand = chainCommands( deleteSelection, joinBackward, selectNodeBackward )
TypeScript
복사
이 예시에서 deleteSelection이 먼저 실행되며, 그게 실패하면 joinBackward, 그것도 실패하면 selectNodeBackward가 시도된다. 세 가지 모두 실패하면 브라우저의 기본 동작(Backspace)이 실행된다.

자주 쓰는 명령어들

ProseMirror는 다음과 같은 다양한 명령어 생성기를 제공한다:
toggleMark(markType): 마크 온/오프 (ex. Bold, Italic)
setBlockType(nodeType): 블록 노드 유형 변경 (ex. Heading → Paragraph)
wrapIn(nodeType): 특정 노드로 감싸기
undo, redo: 히스토리 트랜잭션 되돌리기/재실행
baseKeymap: 위 명령어들을 키보드 단축키에 연결한 기본 키맵

커스텀 Commands를 만들어야 하는 경우

ProseMirror는 schema에 따라 동작이 달라지기 때문에, 사용자 정의 노드를 편집하거나, 사용자 맞춤 행동이 필요할 경우 직접 command를 만들어야 한다. 커스텀 command도 위에서 소개한 함수 시그니처를 그대로 따르면 된다.
ProseMirror의 명령어 시스템은 직관적이지는 않지만, 일단 익숙해지면 커스터마이징의 자유도가 매우 높다는 장점이 있다. 실제로 커맨드를 구현하면서 조금씩 감을 익혀가는 것이 가장 빠른 학습법이다.
지금까지 프로스미러 공식문서의 가이드를 모두 살펴보았다. 다음은 Prosemirror로 에디터 만들면서 지금까지 배운 개념 적용해보겠다.

References