Search
🦉

Prosemirror Guide: Schema, State, View

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

Introduction

프로스미러는 리치텍스트 에디터를 위한 툴을 제공한다. WISWIG(what-you-see-is-what-you-get) 에디터로부터 영감을 받은 UI를 사용하면서도 WISWIG의 편집 스타일로 인한 단점들은 보완하려 노력한다.
WISWIG의 편집 스타일로 인한 단점, 예를 들어 뭐가 있을까?
프로스미러의 가장 중요한 원칙은 개발자가 도큐먼트에 대한 완전한 통제권을 얻게하는 것이다. 여기서 도큐먼트란 HTML의 blob 객체가 아니라, 사용자가 정의한 데이터 구조를 말한다. 도큐먼트는 개발자가 정의한 관계 내에서 명시한 엘리먼트만 포함할 수 있다. 이에 대해서는 후에 ‘스키마’를 다루며 더 자세히 얘기가 나온다.
그리고 모든 업데이트는 싱글 포인트를 통과해야하며, 이를 통해 한 곳에서 업데이트를 검사하고 어떻게 처리할 지 관리할 수 있다.
프로스미러의 코어 라이브러리는 편리한 드랍인 컴포넌트가 아니다. 단순함보다 모듈성과 커스터마이징을 우선시한다. 따라서 프로스미러는 자동차 완제품보다는 레고 세트에 더 가깝다.
프로스미러에는 4가지 필수 모듈이 있다. 무엇을 편집하든 이 라이브러리들이 필요하고, 코어팀으로부터 수많은 Extension 모듈들이 관리된다. 이 모듈들은 유용한 기능을 제공하지만 필요없는 기능은 빼버리거나 비슷한 기능을 구현해 대체할 수 있다.
에디터의 도큐먼트 모델에 대해 정의한다. 도큐먼트 모델은 위에서 말했듯이 에디터의 콘텐츠를 구성하는 데이터 구조를 말한다.
에디터의 모든 state를 구성하는 데이터 구조를 제공한다. 여기서 state란 selection(사용자의 커서가 위치하는 부분), 그리고 transaction(일련의 상태 업데이트 작업들) 시스템들을 포함한다.
프로스미러 뷰 모듈은 DOM에 에디터 state를 표시하고, 유저 이벤트를 핸들링하는 역할을 한다.
도큐먼트를 수정하는 기능을 제공한다. 이 변경사항들은 state 모듈의 트랜잭션 시스템을 통해 기록되고 재생될 수 있다. 이 덕분에 프로스미러 에디터는 콜라보레이팅 에디터나 되돌리기(undo history) 기능을 구현할 수 있다.
이 뿐만 아니라 깃헙에서 ProseMirror Organization에는 basic editing commandsbinding keysundo historyinput macroscollaborative editing 등 여러 모듈들이 더 존재한다.

My first editor

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})
JavaScript
복사
첫번째로 할 일은 ‘스키마를 정의하는 것’이다. 도큐먼트가 무엇으로 구성되어야 하는지 알아야하기 때문이다.
그 다음 두번째로는 스키마를 통해 에디터의 State를 생성해야 한다.
프로스미러는 이렇게 생성된 state를 가지고 view 컴포넌트를 생성한다.
그래서 우리는 SchemaStateView 순서대로 프로스미러에 대해 알아갈 것이다.

1. Schema

프로스미러의 각 도큐먼트는 각자의 ‘스키마’를 갖고 있다. 스키마는 도큐먼트에서 어떤 종류의 노드들이 포함되고, 또 그 노드들이 어떻게 중첩될 수 있는지 설명하는 데이터이다. 예를 들어, 최상위 노드는 하나 이상의 블록을 포함할 수 있고 paragraph 노드는 mark가 적용된 여러 개의 인라인 노드를 포함할 수 있음을 표시할 수 있다.
import { Schema } from 'prosemirror-model' const nodes = { doc: { content: 'block+', // 하나 이상의 블록 노드를 포함 }, paragraph: { content: 'inline*', // 0개 이상의 인라인 노드(text 등) group: 'block', // paragraph는 block 그룹 parseDOM: [{ tag: 'p' }], toDOM() { return ['p', 0] }, }, text: { group: 'inline', // text는 inline 그룹 }, } const marks = { bold: { parseDOM: [{ tag: 'strong' }, { style: 'font-weight=bold' }], toDOM() { return ['strong', 0] }, }, italic: { parseDOM: [{ tag: 'em' }, { style: 'font-style=italic' }], toDOM() { return ['em', 0] }, }, } export const schema = new Schema({ nodes, marks })
JavaScript
복사

Node Types

도큐먼트의 모든 노드는 type을 가진다. type은 노드가 가진 속성이나 의미를 표현하며 에디터에 렌더링된다. 스키마를 정의할 때 노드 타입을 통해 스키마가 어떤 노드들이 어떤 규칙으로 구성되는지 설명할 수 있다.
그리고 모든 스키마는 최소한 하나의 최상위 노드 타입을 가져야 하고, 기본적으로 최상위 노드 타입의 이름은 "doc" 이다. 물론 이 또한 설정으로 바꿀 수 있긴 하다. 또한, 인라인 노드들로 간주되는 노드 타입들은 inline 속성으로 선언해야 한다. text 타입의 경우 내부적으로 인라인 노드기 때문에 따로 명시해줄 필요는 없다.

Content Expressions

스키마의 content 필드는 content expressions(콘텐츠 표현식)으로 표현된다. 이는 자식노드의 순서를 제어하고 에디터가 유효한 노드 타입으로 구성되었는지 판단하는 기준이 된다.
예를 들어 paragraph는 “한 단락”을 의미하며, paragraph+는 “한 개 이상의 단락”을 표현한다. 마찬가지로 paragraph*는 “0개 이상의 단락”을 의미하며, caption?는 “0개 또는 1개의 캡션 노드”를 의미한다.
노드 이름 뒤에 정규 표현식 유사 범위 연산자를 사용할 수 있는데, 예를 들어 {2}는 “정확히 두 개”, {1, 5}는 “1개에서 5개까지”, {2,}는 “두 개 이상”을 의미한다.
이러한 표현식을 결합하여 시퀀스를 만들 수 있다. 예를 들어 “heading paragraph+”는 “먼저 제목, 그 다음 하나 이상의 단락”을 의미한다. 두 표현식 사이의 선택을 표시하려면 파이프 | 연산자를 사용할 수 있다. 예를 들어 “(paragraph | blockquote)+”는 ‘한 개 이상의 단락 또는 인용문’을 의미한다.

group

group은 스키마에서 여러 번 나타날 수 있다. 예를 들어 ‘블록’ 노드라는 개념이 최상위 수준에 나타날 수 있지만 blockquote 내부에 중첩되어 있을 수도 있다. 이럴 땐, 노드 사양에 group 속성을 지정하여 노드 그룹을 생성할 수 있으며, 표현식에서 해당 그룹의 이름을 참조하여 사용할 수 있다.
const groupSchema = new Schema({ nodes: { doc: {content: "block+"}, paragraph: {group: "block", content: "text*"}, blockquote: {group: "block", content: "block+"}, text: {} } })
JavaScript
복사
이 예시에서 block+(paragraph | blockquote)+ 과 같은 의미이다. paragraph와 blcokquote에 모두 group 속성을 block 으로 지정했기 때문이다.
블록 콘텐츠를 포함하는 노드(예: 위 예시에서의 docblockquote)에서는 항상 최소 한 개의 자식 노드를 포함하도록 권장된다. 브라우저는 해당 노드가 빈 상태일 경우 노드를 완전히 접기(collapse) 때문에 편집이 상당히 어려워지기 때문이다. 여기서 노드를 collapse한다는 것은 화면상에서 아예 안 보이게 만들거나, 커서조차 놓을 수 없는 상태로 만들어버린다는 뜻이다.

콘텐츠 표현식에서 순서가 중요한 이유

또한, 노드가 OR 표현식 (|)에서 나타나는 순서도 중요하다. 예를 들어, replace 단계(도큐먼트의 일부를 새로운 콘텐츠로 대체하는 작업) 이후에도 도큐먼트가 스키마를 계속 지키도록 해야하는데, 이를 위해 선택되지 않은 노드의 기본 인스턴스를 생성할 때, 표현식의 첫번째 타입이 기본으로 사용된다. 만약에 노드가 아닌 그룹인 경우는 어떨까? 그룹에서 paragraphblockquote 의 순서를 바꿔 blockquote가 먼저 오게 된다면 에디터는 블록 노드를 생성하려고 시도할 때마다 스택오버플로우가 발생하게 된다. 왜 그럴까? blockquote 노드는 block 타입의 노드를 최소 하나 이상 필요로 하기 때문에 콘텐츠로 또 다른 block 을 생성하려 하고, block 그룹의 기본은 blockquote 이므로 다시 blockquote 노드를 생성하려고 시도하고, 이 과정이 반복되기 때문이다 ㄷㄷ.

유효성 검사

ProseMirror 라이브러리에는 노드를 조작하는 다양한 함수들이 있지만, 그 모든 함수들이 입력값의 유효성을 다 검사해주는 건 아니다. transform 같은 고수준(high-level) API는 입력이 유효한지 자동으로 검사해주지만, NodeType.create 같은 저수준(primitive) 노드 생성 메서드들은 입력값이 유효한지 확인하지 않고, 그 책임을 사용하는 개발자에게 맡긴다. 예를 들어, NodeType.create를 이용하면 잘못된 구조의 노드도 만들 수 있다.
그리고 slice의 경계(edge)가 열린 상태일 때(예: 복붙을 위한 문서 조각(slice) 등)는 일시적으로 잘못된 구조의 노드를 만드는 것이 합리적일 수도 있다. 하지만 정상적인 전체 노드를 생성하거나 사용할 때는, createChecked()라는 유효성 검사를 포함한 생성 메서드나 이미 만든 노드에 대해 check()를 호출해서 그 노드의 구조가 유효한지 검증할 수 있다.

Marks

Marks는 인라인 콘텐츠에 추가적인 정보나 스타일을 더할 때 사용한다. 스키마는 반드시 스키마에서 허용하는 마크 타입을 모두 선언해야 한다. Mark types는 노드 타입과 비슷한 형태의 객체이며 마크 객체를 식별하고 그에 대한 추가 정보를 제공한다.
기본적으로 인라인 콘텐츠를 포함하는 노드는 스키마에 정의된 모든 마크를 자식 노드에 적용할 수 있다. 하지만 이 동작은 각 노드의 marks 속성을 통해 커스터마이징할 수 있다.
예시
paragraph 노드의 텍스트에 strongemphasis 마크들을 지원하는 간단한 스키마다. 하지만 heading에는 마크가 적용되지 않는다.
const markSchema = new Schema({ nodes: { doc: {content: "block+"}, paragraph: {group: "block", content: "text*", marks: "_"}, heading: {group: "block", content: "text*", marks: ""}, text: {inline: true} }, marks: { strong: {}, em: {} } })
JavaScript
복사
마크의 집합(marks 속성)은 마크 이름이나 마크 그룹을 공백으로 구분한 문자열로 해석된다. "_"(언더스코어)는 와일드카드로 동작하며, 스키마에 정의된 모든 마크를 허용한다는 의미다. "" (빈 문자열)은 마크를 전혀 허용하지 않는다는 의미다.
위 예시에서 paragraph 의 mark는 _ 이고 heading 의 mark는 빈 문자열이므로 paragraph는 스키마 내의 모든 마크를 허용하고, heading은 아무것도 허용하지 않는다. 만약 일부만 허용하고 싶다면 marks: “strong em” 과 같이 쓰면 된다. strong, em의 mark만 허용한다는 뜻이다.

Attributes

문서 스키마는 각 노드나 마크가 어떤 속성(attributes)을 가질 수 있는지도 함께 정의한다. 예를 들어, heading 노드처럼 레벨(level) 같은 추가적인 정보를 저장해야 하는 경우, 이러한 데이터는 attribute로 처리하는 것이 가장 적절하다. 각 노드나 마크의 attribute는 JSON으로 직렬화 가능한, 평범한 객체 형태로 표현된다.
어떤 속성들을 허용할지 정의하려면, 해당 노드나 마크의 spec에서 attrs 필드를 사용하면 된다. 이 필드는 선택사항이지만, attribute가 필요한 경우 반드시 명시적으로 정의해야 한다.
heading: { content: "text*", attrs: {level: {default: 1}} }
JavaScript
복사
이 스키마에서 heading 노드를 생성하면, 모든 인스턴스는 .attrs.level 속성에 level이라는 attribute를 가지게 된다. 이 속성이 명시되지 않은 채로 노드가 생성되면, 기본값으로 1이 자동 할당된다. 만약 attribute에 대해 기본값을 지정하지 않았는데, 노드를 생성할 때 해당 attribute를 생략하면 에러가 발생하게 된다.
또한 기본값이 없는 attribute는 ProseMirror 라이브러리가 해당 노드를 자동 생성하는 것을 방해하게 된다. 예를 들어, 트랜스폼이나 createAndFill을 사용할 때, 스키마 제약을 만족시키기 위해 비어 있는 노드를 자동으로 채워 넣어야 할 때, 기본값을 알 수 없어서 해당 노드를 만들 수 없게 된다.
이런 이유 때문에, 기본값 없는 attribute를 요구하는 노드는 스키마 상에서 반드시 존재해야 하는 위치(required position)에 둘 수 없다. 편집기가 스키마 제약을 강제하려면, 언제든지 기본값으로 채운 빈 노드를 만들어 넣을 수 있어야 하기 때문이다.
조금 더 설명을 부연하자면, 아래와 같은 스키마가 있다고 하자.
doc: { content: "heading paragraph+" }
JavaScript
복사
이런 스키마가 있을 때, 문서를 새로 만들면 ProseMirror는 자동으로 다음과 같이 채우려고 한다.
doc( heading(...?) // 필수니까 뭔가 넣어줘야 함 paragraph() // 최소 하나 필요 )
JavaScript
복사
그런데 헤딩 노드가 다음과 같이 정의되어있다면 ProseMirror가 heading을 자동으로 생성하려고 할 때, level 값이 필수인데 기본값도 없고 ProseMirror는 자동으로 level 값을 추측할 수 없으므로 에러가 난다.
heading: { attrs: { level: {} // 기본값이 없음 }, content: "text*" }
JavaScript
복사
이런 노드는 위에서 말한 것처럼 기본 값을 지정해줘야 하고, 그렇지 않으면 heading 같은 노드를 반드시 필요한 위치(예: doc의 첫 자식 노드) 에 둘 수 없다.
하지만 prosemirror-model 모듈에서 Attrs 에 대한 타입 정의를 보면 이러한 default에 대한 강제는 되어있지 않다.
type Attrs = { readonly [attr: string]: any; };
TypeScript
복사
때문에 tiptap과 같이 default 키를 포함한 타입을 강제하거나 tiptap의 Attribute 타입을 사용하는 것이 좋다.
export declare type Attribute = { default: any; rendered?: boolean; renderHTML?: ((attributes: Record<string, any>) => Record<string, any> | null) | null; parseHTML?: ((element: HTMLElement) => any | null) | null; keepOnSplit: boolean; isRequired?: boolean; };
TypeScript
복사

Serialization and Parsing

브라우저에서 문서를 편집할 수 있으려면, 문서의 각 노드가 브라우저의 DOM 구조로 표현 가능해야 한다.
이를 가장 간단하게 구현하는 방법은, 각 노드의 DOM 표현 방식을 스키마 안에 있는 toDOM 필드에
명시하는 것이다.
toDOM 필드는 함수 형태로 정의되며, 이 함수는 노드를 인자로 받아, 그 노드를 어떻게 DOM으로 나타낼지를 설명하는 결과를 반환해야 한다. 이 반환값은 실제 DOM 노드일 수도 있고, DOMOutputSpec 형태의 배열 형식으로 DOM 구조를 묘사하는 값일 수도 있다.
예시
const schema = new Schema({ nodes: { doc: {content: "paragraph+"}, paragraph: { content: "text*", toDOM(node) { return ["p", 0] } }, text: {} } })
JavaScript
복사
["p", 0]라는 표현은 해당 노드를 HTML의 <p> 태그로 렌더링하겠다는 의미다. 여기서 0은 노드의 콘텐츠가 삽입될 자리(hole)를 나타낸다. 만약 HTML 속성을 추가하고 싶다면, 태그 이름 뒤에 객체 형태로 넣을 수 있다. 예를 들어 ["div", { class: "c" }, 0]<div class="c">내용</div>을 나타낸다. Leaf 노드(자식이 없는 노드)는 콘텐츠가 없기 때문에, DOM 구조 안에 별도의 ‘hole’을 지정할 필요가 없다.
마크(Mark) 스펙에도 비슷하게 toDOM 메서드를 정의할 수 있는데, 마크는 항상 콘텐츠를 하나의 태그로 감싸는 형태로 렌더링해야 하므로, 반환되는 노드 안에 콘텐츠가 바로 들어가며, 별도로 0으로 hole을 지정할 필요는 없다.
또한 사용자가 무언가를 붙여넣거나 드래그해서 에디터에 넣을 경우, DOM으로부터 문서를 파싱해야 하는 상황이 자주 발생한다. 이를 위해 ProseMirror의 model 모듈에는 DOM 파싱 기능도 함께 포함되어 있으며, 스키마 안에 parseDOM 속성을 사용해 DOM에서 문서를 어떻게 읽어올지 정의하는 것이 권장된다.
parseDOM 필드는 해당 노드나 마크가 어떤 DOM 구조로부터 파싱될 수 있는지를 설명하는 파싱 규칙(parse rules)의 배열을 정의할 수 있다. 예를 들어, 기본 스키마에서는 emphasis 마크를 아래와 같이 정의한다.
parseDOM: [ {tag: "em"}, // Match <em> nodes {tag: "i"}, // and <i> nodes {style: "font-style=italic"} // and inline 'font-style: italic' ]
JavaScript
복사
이처럼 tag 값에는 CSS 선택자(CSS selector) 를 쓸 수 있기 때문에, 예를 들어 "div.myclass" 같은 특정 클래스를 가진 요소를 지정하는 것도 가능하다. 또한 style을 사용하면 인라인 CSS 스타일 값을 기준으로도 마크를 인식시킬 수 있다.
스키마에 parseDOM 정보가 포함되어 있다면, DOMParser.fromSchema 메서드를 통해 해당 스키마에 맞는 DOMParser 객체를 생성할 수 있다.
ProseMirror 에디터는 이 기능을 활용해 기본 클립보드 파서를 만든다. 물론 필요하다면 clipboardParser 프로퍼티를 통해 이 파서를 직접 오버라이드할 수도 있다.
ProseMirror 문서는 또한 내장된 JSON 직렬화 포맷을 지원한다. 문서 노드에 .toJSON()을 호출하면, JSON.stringify로 안전하게 문자열화할 수 있는 객체를 얻을 수 있다. 반대로 스키마 객체의 nodeFromJSON() 메서드를 사용하면, 이 JSON 표현을 다시 ProseMirror 문서 객체로 되돌릴 수 있다.

Extending a schema

Schema 생성자에 전달되는 nodesmarks 옵션은 일반적인 자바스크립트 객체뿐 아니라 OrderedMap 객체도 사용할 수 있다. 한 번 스키마가 생성되면, schema.spec.nodesschema.spec.marks는 항상 OrderedMap 형태로 제공되며, 이를 기반으로 다른 스키마를 확장하거나 수정하는 데 활용할 수 있다.
OrderedMap은 노드 세트를 유연하게 수정할 수 있는 다양한 메서드를 제공한다. 예를 들어, schema.spec.nodes.remove("blockquote")처럼 작성하면, 기존 스키마에서 blockquote 노드를 제거한 새로운 노드 세트를 만들 수 있다. 이렇게 수정한 결과는 다시 새로운 스키마를 만들 때 nodes 옵션으로 넘겨줄 수 있다.
추가로, schema-list 모듈은 리스트 관련 노드들을 기존 노드셋에 쉽게 추가할 수 있도록 도와주는 addListNodes 메서드를 함께 제공한다. 이런 방식으로 ProseMirror 스키마는 점진적 확장과 커스터마이징이 가능하다.
즉, ProseMirror에서 기본 스키마가 있고, 여기에 "특정 노드를 빼거나 추가해서" 새로운 스키마를 만들고 싶을 때, 예를 들어…
blockquote 노드는 빼고 싶고
bullet_list, ordered_list 같은 리스트 관련 노드는 새로 추가하고 싶다
이럴 때 기존 스키마의 nodes를 그냥 객체처럼 다루는 게 아니라, OrderedMap이라는 특수한 자료구조를 이용해서 유연하게 수정할 수 있다.
예시
import { Schema } from 'prosemirror-model' import { schema as basicSchema } from 'prosemirror-schema-basic' import { addListNodes } from 'prosemirror-schema-list' // 1. 기본 스키마의 nodes는 OrderedMap 형태로 되어 있음 const originalNodes = basicSchema.spec.nodes // 2. blockquote 노드를 제거 const nodesWithoutBlockquote = originalNodes.remove('blockquote') // 3. 리스트 노드를 추가 (bullet_list, ordered_list 등) const extendedNodes = addListNodes( nodesWithoutBlockquote, 'paragraph block*', 'block' ) // 4. 새로운 스키마 생성 const mySchema = new Schema({ nodes: extendedNodes, marks: basicSchema.spec.marks, // 마크는 그대로 사용 })
TypeScript
복사

2. Editor State

에디터의 상태(state)는 어떤 요소들로 구성될까? 가장 기본이 되는 것은 당연히 문서(document) 이다. 그리고 현재 사용자가 커서를 어디에 두고 있는지를 나타내는 선택(selection) 정보도 필요하다.
또 하나 중요한 것은, 예를 들어 사용자가 아직 입력을 시작하진 않았지만 bold 같은 마크를 활성화하거나 비활성화한 경우처럼, 현재 적용할 예정인 마크 상태(storedMarks) 도 저장해야 한다.
ProseMirror의 에디터 상태는 이 세 가지를 기본 구성 요소로 갖는다. 이들은 각각 다음과 같은 프로퍼티로 접근할 수 있다.
doc: 현재 문서
selection: 현재 커서 또는 선택 영역
storedMarks: 아직 입력은 안 했지만 적용할 마크 상태
예를 들어 다음과 같은 코드로 상태를 생성할 수 있다.
import {schema} from "prosemirror-schema-basic" import {EditorState} from "prosemirror-state" let state = EditorState.create({schema}) console.log(state.doc.toString()) // An empty paragraph console.log(state.selection.from) // 1, the start of the paragraph
JavaScript
복사
이 예시에서 에디터의 현재 상태는 아직 비어있는 paragraph의 상태이고, selection을 조회하면 현재 커서의 위치는 문단의 맨 첫번째이므로 1이 출력된다.
하지만 에디터의 상태는 이 기본 요소들만으로 끝나지 않는다.예를 들어 undo/redo 기능은 변경 이력을 저장해야 하는데, 이런 기능들은 플러그인 형태로 추가적인 상태 데이터를 저장할 수 있어야 한다. 그래서 ProseMirror는 현재 활성화된 플러그인 집합도 state에 포함되며, 각 플러그인은 자신만의 상태 슬롯을 정의하여 필요한 데이터를 유지할 수 있다.

Selection

ProseMirror는 여러 종류의 선택(selection) 을 지원하며, 외부 라이브러리나 사용자 코드에서 커스텀 selection 타입을 정의하는 것도 가능하다. 선택은 Selection 클래스(또는 그 서브클래스)의 인스턴스로 표현된다. 문서나 기타 상태 관련 값들과 마찬가지로, selection 객체도 불변(immutable) 이다.
즉, 선택을 변경하려면 기존 객체를 수정하는 것이 아니라, 새로운 selection 객체와 이를 담을 새로운 state를 만들어야 한다. 모든 selection은 기본적으로 다음 정보를 가진다.
from: 선택의 시작 위치
to: 선택의 끝 위치
이 두 값은 현재 문서 안의 구체적인 위치(position) 를 나타낸다. 또한 많은 selection 타입에서는 anchorhead라는 개념도 존재한다.
anchor: 커서가 시작된 곳
head: 현재 커서가 도착한 지점
예를 들어, Hello, world! 문서에서 "Hello"H는 1번 위치, !은 13번 위치라고 가정한다. 사용자가 마우스로 "Hello"의 H에서 시작해서 o까지 드래그하면 anchor는 H 위치 (1번)가 되고, head: o 위치 (6번)가 된다.
TextSelection의 fromto는 항상 인라인 콘텐츠를 허용하는 노드 내부를 가리켜야 한다. ProseMirror 코어 라이브러리는 NodeSelection도 지원한다. 이 타입은 문서에서 하나의 노드 전체가 선택될 때 사용된다.
예를 들어 사용자가 ctrl 또는 cmd 키를 누른 채 노드를 클릭했을 때 발생하는 경우가 이에 해당한다(Mac에서는 ctrl). NodeSelection은 선택된 노드의 직전 위치부터 직후 위치까지를 범위로 삼는다. 즉, 특정 노드 자체가 통째로 선택된 상태가 된다.
반대로, 이번엔 사용자가 "Hello"의 o에서 시작해서 H까지 드래그하면 셀렉션 값은 아래와 같다.
selection = { anchor: 6, head: 1, from: 1, to: 6 }
TypeScript
복사
from, to는 항상 작은 값부터 큰 값까지 정렬되지만, anchor, head는 사용자의 실제 드래그 방향을 보존한다.
따라서 모든 selection 객체에는 from, to뿐만 아니라 anchor, head도 반드시 존재해야 한다. 가장 일반적으로 사용되는 selection 타입은 TextSelection이다. 이 타입은 다음 두 가지 경우에 사용된다.
커서가 깜빡이는 위치 (즉, anchorhead가 같을 때)
텍스트 일부가 드래그로 선택되었을 때

Transactions

일반적인 편집 상황에서는 ProseMirror의 상태(state)는 이전 상태를 기반으로 점진적으로 갱신된다. 특별한 경우(예: 새로운 문서를 불러올 때)를 제외하면, 기존 상태에서 새로운 상태를 파생시키는 방식이 기본 흐름이다.
상태 업데이트는 transaction을 기존 상태에 apply하여 이루어진다. 이 과정을 통해 새로운 상태가 생성되며, 개념적으로는 한 번에 "쓱" 일어나는 것으로 간주된다.
즉, 이전 상태와 트랜잭션이 주어지면, 상태를 구성하는 각 요소(doc, selection, storedMarks 등)가 새롭게 계산되고, 이 값들을 모아 새로운 상태 객체가 만들어진다.
let tr = state.tr console.log(tr.doc.content.size) // 25 tr.insertText("hello") // Replaces selection with 'hello' let newState = state.apply(tr) console.log(tr.doc.content.size) // 30
JavaScript
복사
TransactionTransform의 서브클래스로, 여러 개의 step을 문서에 차례로 적용해 새 문서를 구성하는 기능을 상속받는다. 여기에 더해, 트랜잭션은 현재 선택(selection) 상태 등 상태 관련 정보를 함께 추적하며, replaceSelection 같은 selection 조작에 특화된 편의 메서드도 함께 제공한다.
트랜잭션을 만드는 가장 쉬운 방법은 EditorState 객체에서 제공하는 tr getter를 사용하는 것이다. 이렇게 하면 현재 상태를 기반으로 하는 비어 있는 새 트랜잭션이 만들어지고, 여기에 step이나 기타 업데이트를 추가해나갈 수 있다. 기본적으로 트랜잭션이 수행되면, 기존 선택 영역은 각 step을 거치며 자동으로 map되어 새로운 문서 구조에 맞게 위치가 조정된다.
하지만 필요하다면 setSelection을 통해 선택 영역을 명시적으로 설정할 수도 있다.
let tr = state.tr console.log(tr.selection.from) // → 10 tr.delete(6, 8) console.log(tr.selection.from) // → 8 (moved back) tr.setSelection(TextSelection.create(tr.doc, 3)) console.log(tr.selection.from) // → 3
JavaScript
복사
비슷하게, 현재 활성화된 마크 집합(storedMarks)도 문서나 선택 영역이 바뀌면 자동으로 초기화된다. 필요할 경우 setStoredMarksensureMarks를 사용해 적용할 마크를 명시적으로 설정할 수 있다.
그리고 사용자 인터랙션 이후에는 대부분의 경우scrollIntoView를 호출해 선택 영역이 스크롤뷰에 나타나도록 강제하는 것이 좋다.
마지막으로, Transform 계열의 메서드들과 마찬가지로, 대부분의 Transaction 메서드는 자기 자신(this) 을 반환하므로, .insertText(...).setSelection(...).scrollIntoView() 와 같이 메서드 체이닝이 가능하다.

Plugins

새로운 EditorState를 생성할 때, 사용할 플러그인 배열을 함께 지정할 수 있다. 이렇게 지정된 플러그인들은 해당 상태 객체와, 그로부터 파생되는 모든 상태에서 공유되며, 트랜잭션 처리 방식이나 에디터 동작 전반에 영향을 줄 수 있다.
플러그인은 Plugin 클래스의 인스턴스이며, 간단한 이벤트 응답부터 복잡한 상태 관리까지 다양한 기능을 모델링할 수 있다.
가장 단순한 플러그인은 EditorView에 특정 props를 추가하는 용도로 사용되는데, 예를 들어 키보드 입력 이벤트에 반응하는 플러그인은 다음처럼 만들 수 있다.
let myPlugin = new Plugin({ props: { handleKeyDown(view, event) { console.log("A key was pressed!") return false // We did not handle this } } }) let state = EditorState.create({schema, plugins: [myPlugin]})
JavaScript
복사
플러그인이 자신만의 상태 값을 저장하고 싶다면, state 속성을 사용해 초기값과 트랜잭션 처리 방식을 정의할 수 있다. 이 플러그인은 단순히 지금까지 적용된 트랜잭션의 수를 카운팅하는 기능을 가진다. 현재 카운트를 가져오고 싶을 때는 getState() 메서드를 사용하면 된다.
let transactionCounter = new Plugin({ state: { init() { return 0 }, // 초기값 0 apply(tr, value) { return value + 1 } // 트랜잭션 발생 시 카운터 증가 } }) function getTransactionCount(state) { return transactionCounter.getState(state) }
JavaScript
복사
ProseMirror의 상태 객체는 불변(immutable) 이기 때문에, 플러그인 상태도 역시 불변 객체여야 한다. 즉, 상태를 변경할 필요가 있다면 기존 값을 수정하는 것이 아니라 새 값을 반환해야 하며, 다른 코드가 이 값을 직접 변경해서는 안 된다.

메타데이터와 트랜잭션 마킹

플러그인을 통해 트랜잭션에 추가 정보(metadata)를 붙이는 것도 매우 유용하다. 예를 들어 undo 기능을 구현한 플러그인의 경우, 일반적인 변경 내용은 undo 스택에 추가하지만, undo가 실제 실행되는 순간에는 트랜잭션에 표시를 남겨서 undo 스택에서 제거하고, redo 스택에 넣도록 특별 처리해야 한다.
이런 처리를 위해 트랜잭션에는 setMetagetMeta 메서드가 존재한다. 아래는 특정 트랜잭션에 카운트 제외 표시를 붙이고, 그 표시가 있는 경우에는 카운터 증가를 생략하는 예시다
let transactionCounter = new Plugin({ state: { init() { return 0 }, apply(tr, value) { if (tr.getMeta(transactionCounter)) return value else return value + 1 } } }) function markAsUncounted(tr) { tr.setMeta(transactionCounter, true) }
JavaScript
복사
여기서 getMeta()setMeta()에 넘겨지는 키는 문자열도 가능하지만, 이름 충돌을 피하기 위해 플러그인 객체 자체를 키로 사용하는 것이 권장된다. ProseMirror 내부에서도 의미 있는 문자열 키가 몇 가지 존재하는데, 예를 들면,
"addToHistory": false undo 히스토리에 포함되지 않도록 지정
"paste": true 붙여넣기 이벤트로 생성된 트랜잭션임을 명시
tr.setMeta('addToHistory', false); tr.setMeta('paste', true);
TypeScript
복사
이처럼 트랜잭션에 부가 정보를 부착하면, 플러그인이 보다 정교하게 트랜잭션을 제어할 수 있게 된다.

3. View Component

ProseMirror의 EditorView는 에디터 상태(editor state)를 사용자에게 보여주고, 해당 상태에 대해 사용자가 편집 작업을 수행할 수 있도록 하는 UI 컴포넌트이다. 다만 ProseMirror 코어 뷰 컴포넌트가 다루는 “편집 작업(editing actions)”의 범위는 꽤 제한적이다. 여기서 말하는 편집 작업이란 다음과 같은 직접적인 상호작용만을 의미한다.
키보드 입력
마우스 클릭
복사/붙여넣기
드래그
즉, 메뉴를 띄우는 UI나, 모든 키보드 단축키를 제공하는 기능 등은 코어 뷰 컴포넌트의 책임에 포함되지 않으며, 이런 기능들은 플러그인(plugin) 을 통해 따로 구현해야 한다.

Editable DOM

브라우저는 HTML 요소에 contentEditable 속성을 지정함으로써 해당 요소를 편집 가능하게 만들 수 있는 기능을 제공한다. 이 속성이 활성화되면, 해당 요소를 포커스할 수 있고, 선택(selection)할 수 있으며, 사용자가 키보드로 텍스트를 입력할 수 있다.
ProseMirror의 EditorView는 스키마에 정의된 toDOM 메서드를 활용해 문서(document)의 DOM 표현을 생성하고, 해당 DOM 요소를 편집 가능 상태로 만든다. 그리고 사용자가 해당 요소에 포커스를 주면, 브라우저의 DOM selection 상태와 에디터 내부 selection 상태가 일치하도록 동기화해준다.
또한 ProseMirror는 여러 종류의 DOM 이벤트에 대한 이벤트 핸들러를 등록하여, 발생한 이벤트를 적절한 transaction으로 변환한다. 예를 들어 사용자가 붙여넣기를 수행하면, 붙여넣은 콘텐츠는 clipboardParser를 통해 ProseMirror 문서 조각(slice)으로 파싱되고, 해당 위치에 삽입된다.
한편 많은 이벤트는 처음부터 ProseMirror가 직접 처리하지 않고, 일단 브라우저가 기본 동작을 수행한 뒤, 그 결과를 ProseMirror가 다시 확인하여 내부 모델로 해석하는 방식으로 동작한다. 예를 들어, 커서 이동이나 선택(selection) 처리는 브라우저가 잘 처리한다. 특히 양방향 텍스트(bidirectional text, 왼쪽에서 오른쪽으로 읽는 언어(LTR) 와 오른쪽에서 왼쪽으로 읽는 언어(RTL) 가 같은 문장이나 문서 안에 섞여 있는 경우) 같은 복잡한 상황까지 고려해야 하므로, 이런 커서 관련 키 입력이나 마우스 동작은 브라우저에게 맡기고, 브라우저가 만든 DOM selection이 에디터 상태와 다르다고 판단될 경우에만 선택(selection)을 갱신하는 트랜잭션을 디스패치(dispatch)한다.
심지어 텍스트 입력조차도 브라우저에게 위임하는 경우가 많다. 왜냐하면 텍스트 입력 과정을 직접 가로채면
자동 완성
자동 대문자 변환
모바일 인터페이스의 네이티브 기능들(예: iOS의 자동 수정)
같은 브라우저 고유의 편의 기능들이 제대로 작동하지 않을 수 있기 때문이다.
대신 브라우저가 DOM을 업데이트하면, ProseMirror는 그 변경 사항을 감지하고, 변경된 부분을 다시 파싱한 뒤, 문서의 차이점을 기반으로 새로운 트랜잭션을 생성해 문서를 갱신한다.

Data flow

ProseMirror에서 EditorView는 특정한 EditorState를 화면에 렌더링한다. 그리고 어떤 일이 발생했을 때(예: 입력, 클릭 등), 이를 기반으로 트랜잭션(transaction) 을 생성하고 브로드캐스트(전파) 한다.
이 트랜잭션은 일반적으로 새로운 상태(state) 를 만드는 데 사용되고, 그 새로운 상태는 EditorViewupdateState 메서드를 통해 뷰에 다시 전달된다.
이 흐름은 다음과 같은 직관적이고 순환적인 데이터 흐름(cyclic data flow)을 구성한다.
출처: 프로스미러 가이드 공식문서
이 방식은 JavaScript에서 흔히 쓰이는 명령형 이벤트 핸들러 체계보다 훨씬 단순하고 예측 가능하다. 전통적인 방식은 다양한 이벤트 핸들러가 곳곳에서 상태를 바꾸기 때문에, 데이터 흐름이 매우 복잡한 거미줄처럼 얽히게 되기 쉽다. 반면 ProseMirror는 이 순환 구조 덕분에 상태 흐름을 깔끔하게 유지할 수 있다.

애플리케이션 전체와 연결하고 싶을 땐?

ProseMirror의 트랜잭션을 더 큰 앱 구조에 통합하고 싶다면, 트랜잭션이 dispatch 될 때dispatchTransaction이라는 prop을 이용해 가로채는(intercept) 것이 가능하다. 이 방법을 쓰면, ProseMirror의 내부 상태 갱신 흐름을 Redux와 같은 아키텍처의 전체 상태 흐름에 통합할 수 있다.
즉, ProseMirror의 상태를 애플리케이션의 전역 스토어(store) 에서 관리할 수 있게 된다. 이렇게 하면 ProseMirror도 다른 UI 상태와 동일한 방식으로 관리할 수 있어, 복잡한 에디터 로직도 일관성 있게 통제할 수 있다.
// The app's state let appState = { editor: EditorState.create({schema}), score: 0 } let view = new EditorView(document.body, { state: appState.editor, dispatchTransaction(transaction) { update({type: "EDITOR_TRANSACTION", transaction}) } }) // A crude app state update function, which takes an update object, // updates the `appState`, and then refreshes the UI. function update(event) { if (event.type == "EDITOR_TRANSACTION") appState.editor = appState.editor.apply(event.transaction) else if (event.type == "SCORE_POINT") appState.score++ draw() } // An even cruder drawing function function draw() { document.querySelector("#score").textContent = appState.score view.updateState(appState.editor) }
JavaScript
복사
appState는 에디터 상태(editor)와 점수(score)라는 두 가지 상태를 가진 전역 상태 객체이다. 즉, ProseMirror의 상태도 이 객체 안에서 같이 관리되고 있다.
에디터는 appState.editor를 기반으로 생성되는데, ProseMirror 내부에서 어떤 트랜잭션(transaction) 이 발생하면 dispatchTransaction 함수가 호출되고 이 트랜잭션을 update()라는 앱 레벨 상태 갱신 함수로 전달한다. 즉, ProseMirror의 상태 변경 흐름이 appState를 통해 중앙 집중식으로 처리된다.
update 함수는 마치 Redux의 reducer처럼 event.type에 따라 다른 동작하는데, "EDITOR_TRANSACTION"이면 → 트랜잭션을 적용해 새로운 에디터 상태를 만들고 "SCORE_POINT"이면 점수를 1 올힌다. 그리고 매번 상태가 바뀔 때마다 draw()를 호출해 UI를 갱신한다.
마지막으로 draw 함수에서는 #score에 현재 점수를 반영하고, view.updateState()를 통해 에디터에 새로운 상태를 적용한다.
핵심 흐름을 요약하면 이렇게 된다.
사용자 편집 → transaction 발생 → dispatchTransaction으로 app-level update() 호출 → appState.editor에 트랜잭션 적용 → draw() 통해 UI 업데이트 (DOM + EditorView)
View → Transaction → AppState → View 로 이어지는 순환적 데이터 흐름(cyclic data flow) 을 구성한다.

Efficient updating

updateState를 구현하는 가장 단순한 방법은, 에디터 상태가 바뀔 때마다 전체 문서를 다시 그리는 것이다. 하지만 문서가 클 경우, 이런 방식은 성능에 큰 부담이 되기 때문에 현실적이지 않다. 다행히 EditorView는 상태를 업데이트할 때, 이전 문서와 새 문서를 모두 가지고 있기 때문에, 두 문서를 비교해서 변경되지 않은 부분은 DOM을 그대로 두고, 바뀐 부분만 갱신하는 방식으로 최적화할 수 있다. ProseMirror는 이런 방식으로 작동하며, 덕분에 일반적인 편집 상황에서는 DOM에 대해 최소한의 작업만 수행해도 된다.
예시: 타이핑처럼 브라우저가 먼저 DOM을 수정한 경우
사용자가 텍스트를 입력하면, 브라우저는 이미 DOM에 해당 텍스트를 추가한 상태다. 이럴 경우 ProseMirror는 DOM과 내부 상태를 맞추기만 하면 되므로, 추가적인 DOM 수정 없이 상태만 동기화하면 된다. 하지만 만약 해당 트랜잭션이 나중에 취소되거나 변경된다면, ProseMirror는 DOM을 되돌려서 다시 상태와 일치하도록 만든다. 상태(state) 와 화면(DOM) 의 불일치를 방지하기 위해서다.
한편, EditorView는 사용자의 선택(selection)이 실제로 상태와 어긋났을 때만 DOM selection을 갱신한다. 그 이유는, 브라우저가 selection과 관련된 숨겨진 상태들을 함께 관리하고 있기 때문이다. 예를 들어, 사용자가 아래 방향키로 짧은 줄을 통과해 다음 긴 줄로 이동할 때, 이전 줄에서의 수평 위치 정보를 기억해두고, 다음 줄에서도 같은 가로 위치로 커서를 자동 이동시키는 브라우저 기능이 있다. 만약 ProseMirror가 매번 DOM selection을 덮어써버리면, 이런 미묘한 브라우저의 동작이 깨질 수 있기 때문에, 정말 어긋났을 때만 조심스럽게 갱신한다.

Props

프로스미러의 Props라는 용어는 React의 그 Props에서 가져온 개념이 맞다. Props는 UI 컴포넌트에 전달되는 파라미터(속성)와 같은 역할을 하며, 이상적으로는 컴포넌트가 어떤 props를 받느냐에 따른 동작이 완전히 정의되어야 한다.
let view = new EditorView({ state: myState, editable() { return false }, // Enables read-only behavior handleDoubleClick() { console.log("Double click!") } })
JavaScript
복사
이처럼 state 역시 하나의 prop이며, 이 외의 다른 prop 값들도 시간이 지나면서 변경될 수 있지만, 이들은 컴포넌트 외부에서만 변경되며, 컴포넌트 자신이 직접 바꾸지 않기 때문에 state로 간주되진 않는다. updateState() 메서드는 결국 state prop을 업데이트하는 단축 메서드일 뿐이다.

플러그인도 props를 선언할 수 있다

플러그인 역시 props 속성을 통해 자신만의 props를 뷰에 전달할 수 있다. 단, statedispatchTransaction처럼 뷰에 직접 지정해야 하는 필수 props는 제외다. 예를 들어, 문서 크기에 따라 에디터를 자동으로 읽기 전용으로 만드는 플러그인은 다음과 같이 정의할 수 있다
function maxSizePlugin(max) { return new Plugin({ props: { editable(state) { return state.doc.content.size < max } } }) }
JavaScript
복사

여러 곳에서 동일한 prop을 선언하면?

특정 prop이 직접 지정된 경우와 플러그인 양쪽에서 중복 선언될 수도 있는데, 이런 경우 어떻게 동작할지는 prop의 종류에 따라 다르다. 대부분의 경우, 직접 제공된 prop이 우선권을 가진다. 그다음으로는 등록된 플러그인들이 선언한 props가 순서대로 적용된다. props 별로 중복 선언된 prop을 처리하는 방식이 다른데, 예를 들면 다음과 같다.
prop 별 처리 방식 예시
prop 종류
병합 방식
domParser
가장 먼저 발견된 값 하나만 사용하고, 나머지는 무시
handleKeyDown, handleClick 등 핸들러 함수
처음으로 true를 반환한 핸들러만 이벤트를 처리
attributes, decorations
모든 값들을 합쳐서(union) 적용

Decorations

Decorations(데코레이션) 은 ProseMirror 에디터 뷰가 문서를 그릴 때 스타일이나 시각적 요소를 덧붙일 수 있는 메커니즘이다. 데코레이션은 decorations prop을 통해 제공되며, 총 세 가지 종류가 있다.
Node (Decoration.node): 특정 노드의 DOM 표현에 스타일이나 속성을 추가함
Widget (Decoration.widget): 문서에 실제 존재하지 않는 DOM 노드를 특정 위치에 삽입함 (예: 커서 위에 버튼 표시)
Inline (Decoration.inline): 특정 범위 안의 인라인 콘텐츠에 스타일이나 속성을 적용함
데코레이션을 효율적으로 렌더링하고 비교하기 위해, 단일 객체가 아닌 DecorationSet 형태로 제공되어야 한다. DecorationSet은 실제 문서 트리 구조를 흉내 낸 자료구조로, create 정적 메서드를 통해 생성할 수 있다.
let purplePlugin = new Plugin({ props: { decorations(state) { return DecorationSet.create(state.doc, [ Decoration.inline(0, state.doc.content.size, {style: "color: purple"}) ]) } } })
JavaScript
복사
위 플러그인은 전체 문서 텍스트에 보라색 인라인 스타일을 적용하는 데코레이션을 생성한다.

비효율적인 방식: 매번 데코레이션 새로 생성

문서가 크거나 데코레이션이 많을 때는, 상태가 바뀔 때마다 DecorationSet.create를 다시 호출하는 건 비효율적이다. 이럴 땐 플러그인 내부 상태에 DecorationSet을 저장해두고, 문서가 변경될 때는 기존 데코레이션을 map()으로 매핑해서 변화된 문서에 맞게 조정하는 방식이 좋다.
let specklePlugin = new Plugin({ state: { init(_, {doc}) { let speckles = [] for (let pos = 1; pos < doc.content.size; pos += 4) speckles.push(Decoration.inline(pos - 1, pos, {style: "background: yellow"})) return DecorationSet.create(doc, speckles) }, apply(tr, set) { return set.map(tr.mapping, tr.doc) } }, props: { decorations(state) { return specklePlugin.getState(state) } } })
JavaScript
복사
이 플러그인은 문서의 4번째 글자마다 노란색 배경을 주는 데코레이션을 생성한다. 실용적이진 않지만, 예를 들어 검색어 하이라이팅이나 주석 표시 같은 기능의 구조와 유사하다.

.map()의 역할

트랜잭션이 발생할 때마다 apply() 메서드에서 DecorationSet.map()을 호출해 기존 데코레이션을 새 문서 구조에 맞게 자동 재배치한다. map()은 내부적으로 문서 구조를 따라가는 트리 구조 덕분에, 변경된 부분만 다시 계산하기 때문에 성능이 매우 뛰어나다.

실제 사용에서는?

실제 플러그인에서는 단순히 map()만 하지 않고, apply() 안에서 트랜잭션 정보를 보고
새 데코레이션을 add() 하거나
기존 데코레이션을 remove() 하면서
데코레이션 상태를 동적으로 갱신한다. 이 때 트랜잭션의 메타데이터나 변경 범위를 활용하면 더욱 정교하게 제어할 수 있다.

Node views

ProseMirror에서 에디터 뷰가 문서를 그릴 때에 영향을 줄 수 있는 또 하나의 방법이 있다. 바로 NodeView를 활용하는 것이다. NodeView를 사용하면 문서의 특정 노드에 대해 작은 UI 컴포넌트처럼 동작하는 렌더링 로직을 정의할 수 있다. 이를 통해 각 노드의 DOM을 직접 렌더링하고, 업데이트 방식이나 이벤트 응답 등을 커스터마이징할 수 있다.
let view = new EditorView({ state, nodeViews: { image(node) { return new ImageView(node) } } }) class ImageView { constructor(node) { // The editor will use this as the node's DOM representation this.dom = document.createElement("img") this.dom.src = node.attrs.src this.dom.addEventListener("click", e => { console.log("You clicked me!") e.preventDefault() }) } stopEvent() { return true } }
JavaScript
복사
위 예시에서 image 노드에 대해 ImageView라는 NodeView를 지정했으며, 해당 클래스에서는 img 엘리먼트를 직접 만들어 사용자의 클릭 이벤트에 반응하도록 설정했다. 또한 stopEvent()를 정의함으로써, 이 DOM에서 발생한 이벤트는 ProseMirror 기본 처리에서 제외된다.

문서 상태를 바꾸려면 노드의 위치(getPos)가 필요하다

NodeView를 통해 사용자와 상호작용할 수는 있지만, 실제로 문서 내부의 노드를 바꾸려면 해당 노드가 문서 내에서 어디 위치하는지를 알아야 한다. 이를 위해 ProseMirror는 NodeView 생성자에 getPos() 함수를 함께 전달해준다. 아래는 사용자가 이미지를 클릭했을 때 alt 속성을 바꾸도록 수정한 예시다.
let view = new EditorView({ state, nodeViews: { image(node, view, getPos) { return new ImageView(node, view, getPos) } } }) class ImageView { constructor(node, view, getPos) { this.dom = document.createElement("img") this.dom.src = node.attrs.src this.dom.alt = node.attrs.alt this.dom.addEventListener("click", e => { e.preventDefault() let alt = prompt("New alt text:", "") if (alt) view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { src: node.attrs.src, alt })) }) } stopEvent() { return true } }
JavaScript
복사
여기서 사용하는 setNodeMarkup 메서드는 특정 위치의 노드에 대해 노드 타입이나 속성(attribute)을 변경할 수 있는 트랜잭션을 만든다.

콘텐츠가 있는 노드라면? (contentDOM 사용)

앞서 본 이미지 노드는 자식 콘텐츠가 없기 때문에 contentDOM 같은 걸 신경 쓰지 않아도 되지만, 단락(paragraph)처럼 콘텐츠를 포함하는 노드는 상황이 달라진다.
let view = new EditorView({ state, nodeViews: { paragraph(node) { return new ParagraphView(node) } } }) class ParagraphView { constructor(node) { this.dom = this.contentDOM = document.createElement("p") if (node.content.size == 0) this.dom.classList.add("empty") } update(node) { if (node.content.size > 0) this.dom.classList.remove("empty") else this.dom.classList.add("empty") return true } }
JavaScript
복사
이 예시에서 ParagraphView는 단락에 텍스트가 없을 경우 "empty" 클래스를 추가하고, 내용이 생기면 해당 클래스를 제거한다. 여기서 핵심은 contentDOM이라는 속성이다. ProseMirror는 이 속성이 정의되어 있으면, 노드의 콘텐츠를 자동으로 contentDOM 안에 렌더링하고, 내용 변경도 자동으로 처리해준다. 만약 contentDOM을 지정하지 않으면, 해당 노드의 자식 콘텐츠는 ProseMirror가 전혀 건드리지 않으며, 완전히 개발자가 직접 렌더링 및 업데이트를 책임져야 한다.

update(node) 메서드란?

NodeView의 update() 메서드는 에디터 내부에서 해당 노드에 변경이 발생했을 때 호출되며, 기존 NodeView가 새 노드를 계속 사용해도 괜찮은지 판단하는 역할을 한다. true를 반환하면 기존 NodeView 인스턴스와 외부 DOM을 그대로 유지하고 그 내부에 있는 콘텐츠(contentDOM)만 갱신한다.
반대로 false를 반환하면 ProseMirror는 이 NodeView를 새로 만들어야 한다고 판단하고, 기존 DOM과 인스턴스를 완전히 폐기하고, 새로운 NodeView를 생성해서 새로운 DOM을 렌더링한다.
때문에, 업데이트가 성공적으로 이루어졌다면 true를 반환하고, 그렇지 않다면 false를 반환해 새로운 DOM을 렌더링해야한다.
또는 노드의 타입이 바뀌었거나, 이 NodeView가 다룰 수 없는 구조라면 false를 반환해야 하고
그렇지 않고 DOM을 그대로 두고 스타일만 바꾼다든지, 내부 콘텐츠만 바꾸면 되는 경우 true를 반환해야한다.
위 예시에서는 update()를 활용해 단락이 비었는지 여부에 따라 "empty" 클래스를 동적으로 관리한다. 기존 DOM 엘리먼트를 그대로 유지한 채, class만 바꾸면 되므로 true를 반환한 것이다.

References