「번역」 ProseMirror 한국어 가이드

✍🏼 작성일 2019년 09월 06일    💡 수정일 2021년 11월 15일
❗️ 참고: 이 글이 작성된 지 이미 일이 지났습니다. 시의성에 유의하세요
🖥  설명:본문에서 언급된 문서나 매뉴얼 API 설명은 https://prosemirror.xheldon.com/docs/ref/에서 확인할 수 있습니다.

https://prosemirror.xheldon.com 도메인을 가리키는 내용은 https://prosemirror.net으로 바꾸면 영어 원문을 확인할 수 있습니다.

번역 설명:

  1. 작업에 ProseMirror를 사용해야 하는데, 시중에 완성도 높은 번역 문서를 찾지 못했습니다(일부 번역은 기계 번역처럼 읽힙니다). 그래서 이 기회에 해당 라이브러리의 개념 설명 문서를 번역하게 되었습니다.
  2. 이전 번역 경험으로 볼 때, 혼동을 피하기 위해 일부 专有名词不翻译를 사용하는 것이 최선의 선택입니다.
  3. 원문에 충실하려고 노력했지만, 직접 번역하면 문맥이 어색해지는 부분이 있어 필요한 경우 주어 등을 추가하거나 의역을 했습니다. 이해가 안 되는 부분은 원문을 참고해 주세요.
  4. 이해가 안 되는 부분은 ProseMirror 포럼에서 저자에게 질문했으며, 관련 링크를 첨부했습니다.
  5. 이 가이드만으로는 이해하기 어려울 수 있으므로, 먼저 훑어본 후 이 저장소에서 heading(node 유형)이나 굵게(mark 유형) 같은 기본 기능이 어떻게 구현되는지 확인하고 다시 가이드를 보시면 더 명확하게 이해할 수 있습니다.
  6. 저는 중국어 입력법에서 영어 구두점을 사용하는 것을 선호합니다.
  7. 가이드 내에서 ProseMirror를 Prosemirror로 오타 낸 부분이 있지만, 내용에는 영향이 없습니다.
  8. 간단한 데모를 만들어 기본 예시를 포함시켜 실험해 볼 수 있도록 이 저장소에 올렸습니다. fork/star 환영합니다.
  9. 중영문 사이에 공백을 두고, 쉼표 뒤에 공백을 넣는 것은 일반적인 관행입니다.
  10. 제 기술력과 번역 수준, 인지도가 부족해 부족한 점이 있을 수 있습니다. 지적해 주시면 감사하겠습니다!

역자가 이해한 개념 설명

  1. Document: ProseMirror가 다루는 전체 문서로, 일반적으로 editor.view.state.doc이 이를 참조합니다.
  2. Schema: ProseMirror의 기본 구조 객체로, 문서를 제한하는 다양한 규칙을 정의합니다. 때로는 이 규칙에 맞추기 위해 수동으로 처리해야 하지만, 대부분의 경우 ProseMirror가 자동으로 처리해 줍니다.
  3. State: ProseMirror의 데이터 구조 객체로, React의 state와 유사합니다. view의 state와 plugin의 지역 state로 구분되며, 위의 schema는 state.schema에 정의됩니다.
  4. View: ProseMirror의 뷰 객체로, 뷰를 업데이트하는 메서드가 있으며 state는 그 속성 중 하나입니다(view.state).
  5. Transform: 문서 변경을 담는 컨테이너 객체로, 변경을 수정하는 메서드도 있습니다. transaction은 그 하위 클래스로, 전체 편집기의 state 변경을 다룹니다.
  6. Selection: 선택 영역 객체로, 아무것도 선택하지 않을 때 커서를 나타낼 수 있습니다. 위치 관련 속성과 메서드가 다양합니다.
  7. Range: 여러 노드 객체를 담는 컨테이너로, 일반적으로 선택 영역에 여러 유형의 노드와 Mark가 포함된 경우 처리합니다.
  8. Slice: 선택 영역이 일부만 선택되어 schema 구조와 맞지 않는 문제를 처리하는 주된 객체입니다.
  9. Node: ProseMirror의 기본 요소로, schema를 통해 다양한 유형의 노드를 정의할 수 있습니다. 최소한 doc(루트 노드)와 text(텍스트 노드) 두 가지 유형이 포함됩니다.
  10. NodeType: ProseMirror의 노드 유형으로, 일반적으로 새 노드를 생성할 때 사용하며 특정 유형 노드의 속성을 정의합니다.
  11. XXXSpec: XXX를 정의할 때의 구성 객체로, NodeSpec, MarkSpec 등이 있습니다.
  12. Mark: ProseMirror는 인라인 텍스트를 DOM과 같은 트리 구조가 아닌 평면 구조로 간주하여 계산과 조작을 용이하게 합니다. 따라서 Mark는 font-size, bold 같은 인라인 노드의 속성을 나타내며 사용자 정의가 가능합니다.
  13. MarkType: 노드 유형과 유사하게 Mark의 속성을 정의하며, Mark를 생성하는 메서드가 있습니다.
  14. DOMOutputSpec: schema의 toDOM에서 지정한 반환 값으로, 공식 설명을 참고하세요.
  15. ResolvedPos: ProseMirror가 위치 정보(아래 위치 계산 섹션 참조)를 분석해 반환하는 객체로, 위치 관련 정보가 포함됩니다.
  16. Plugin: 일반적으로 Plugin을 사용해 클릭/붙여넣기/실행 취소 등의 동작을 구현합니다. Plugin은 노드를 직접 정의할 수도 있습니다.
  17. Decoration: 일반적으로 문서 상태와 무관한 뷰를 생성하는 데 사용되며, 문서 구조에 영향을 주지 않고 특수 효과를 적용할 수 있습니다.

중영문 번역 대조(본 가이드를 읽을 때 상호 교환 가능)

ProseMirror 한국어 가이드

이 가이드는 이 라이브러리에서 사용되는 다양한 개념들과 그들 간의 상호 관계를 설명합니다. 시스템 전체에 대한 인상을 얻기 위해, 독자들은 이 문서의 순서대로 읽거나 최소한 (인내심이 없고 대략적으로만 이해하고 싶다면) View 컴포넌트 부분까지는 읽기를 권장합니다.

소개

ProseMirror는 풍부한 텍스트 편집기를 구축하기 위한 도구와 개념의 완전한 세트를 제공합니다. 이것은 所见即所得 개념에서 영감을 받은 사용자 인터페이스를 사용하지만, 스타일 편집의 깊은 함정에 빠지지 않도록 노력합니다.

ProseMirror의 기본 개념은 당신과 당신의 코드가 문서와 문서의 변경을 완전히 통제한다는 것입니다. 여기서 문서는 HTML의 난잡한 코드 덩어리가 아니라, 당신이 명시적으로 허용한 요소들과 그들 사이의 당신이 지정한 관계만을 포함하는 사용자 정의 데이터 구조입니다(즉, 어떤 요소가 나타날 수 있고 요소들 간의 관계는 모두 당신의 통제 하에 있습니다). 모든 문서 업데이트 작업은 하나의 지점에서 출발하므로 업데이트를 처리하기 쉽습니다.

ProseMirror의 핵심 모듈은 즉시 사용할 수 있는 형태로 제공되지 않습니다. 이 라이브러리를 개발할 때, 우리는 모듈성과 사용자 정의 가능성의 우선순위를 단순성보다 높게 유지했습니다. 물론, 우리는 앞으로 누군가가 ProseMirror를 기반으로 한 즉시 사용 가능한 편집기를 개발하기를 바랍니다. 이것은 비유하자면, ProseMirror는 레고 블록과 같아서 수동으로 조립해야 하며, 성냥갑처럼 열면 바로 사용할 수 있는 것이 아닙니다.

ProseMirror에는 네 가지 필수 모듈이 있으며, 모든 작업은 이 네 가지 모듈을 필요로 합니다. 또한 ProseMirror 코어 팀이 유지 관리하는 많은 확장 모듈들이 있으며, 이들(이 확장 모듈들)은 많은 유용한 기능을 제공하는 타사 모듈처럼 동일한 기능을 구현한 다른 모듈로 대체될 수 있습니다.

위에서 언급한 네 가지 필수 모듈은 다음과 같습니다:

  1. prosemirror-model은 편집기의 Document Model을 정의하며, 편집기의 내용을 설명하는 데 사용됩니다.
  2. prosemirror-state은 편집기의 완전한 상태를 설명하는 단일 데이터 구조를 제공하며, 편집기의 선택 작업과 현재 state에서 다음 state로의 전환을 처리하는 transaction 시스템을 포함합니다.
  3. prosemirror-view은 주어진 state를 편집기에 표시할 수 있는 편집 가능한 요소로 변환하고 사용자 상호 작용을 처리합니다.
  4. prosemirror-transform은 문서를 수정할 수 있는 기능을 포함하며, 이 기능은 다시 실행 및 취소가 가능합니다. 이것은 prosemirror-state 라이브러리의 transaction 기능의 기반이 되며, 실행 취소 기록과 협업 편집을 가능하게 합니다.

이 외에도 기본 편집 명령, 단축키 바인딩, 작업 기록 및 롤백, 매크로 명령, 협업 편집, 그리고 간단한 문서 Schema과 같은 모듈들이 있습니다. 더 많은 모듈은 Github의 ProseMirror 조직에서 찾을 수 있습니다.

ProseMirror는 브라우저에서 직접 로드할 수 있는 스크립트가 아닙니다. 이것은 당신이 그것을 사용하기 위해 일부 번들 도구를 사용해야 함을 의미합니다. 번들 도구는 당신의 스크립트에서 선언된 종속성을 자동으로 찾은 다음 브라우저에서 쉽게 로드할 수 있도록 하나의 스크립트 파일로 병합합니다. 당신은 여기와 같은 웹 번들링에 대한 더 많은 것을 직접 볼 수 있습니다.

나의 첫 번째 편집기

아래 코드는 레고 블록처럼 쌓여 가장 간단한 편집기를 생성합니다:

1
2
3
4
5
6
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 });

Prosemirror는 문서가 준수해야 할 Schema를 수동으로 지정해야 합니다(어떤 요소가 포함될 수 있고 포함되지 않아야 하는지, 그리고 요소 간의 관계를 규정하기 위해). 이를 달성하기 위해 위 코드가 가장 먼저 하는 일은 기본 schema를 가져오는 것입니다(일반적으로 schema는 직접 작성하지만, 여기서 저자는 기본 요소를 포함하는 기존 schema를 예시로 사용했습니다—역자 주).

이후, 이 기본 schema는 state를 생성하는 데 사용되며, 이 state는 schema 제약을 따르는 빈 문서와 문서 시작 부분에 기본 선택 영역(빈 상태이므로 커서를 의미합니다)을 생성합니다. 최종적으로 이 state는 view를 생성하여 document.body에 추가합니다. 위 state의 문서는 결국 편집 가능한 DOM 노드(contenteditable 노드—역자 주)와 사용자 입력에 반응하는 state transaction으로 렌더링됩니다.

(안타깝게도) 지금까지 이 편집기는 사용할 수 없습니다. 예를 들어, 방금의 편집기에서 Enter 키를 누르면 아무 일도 일어나지 않는데, 이는 앞서 언급한 네 가지 핵심 모듈이 Enter 입력 후 무엇을 해야 할지 모르기 때문입니다. 우리는 나중에 다양한 입력 동작에 어떻게 반응해야 하는지 알려줄 것입니다.

Transactions

사용자가 입력할 때, 또는 더 넓게 말해 사용자가 페이지의 view와 상호작용할 때, prosemirror는 'state transactions’을 생성합니다. 이는 사용자가 입력할 때마다 prosemirror가 단순히 문서 내용을 수정하는 것뿐만 아니라, 뒤에서 state도 업데이트한다는 의미입니다. 즉, 모든 변경 사항에는 transaction이 생성되며, 이는 state에 적용된 변경 사항을 설명합니다. 이러한 변경 사항은 새로운 state를 생성하는 데 사용될 수 있으며, 이 새로운 state는 view를 업데이트하는 데 사용됩니다.

기본적으로 이러한 변경 사항은 프레임워크에 의해 처리되며, 사용자는 신경 쓸 필요가 없습니다. 그러나 plugin을 작성하거나 view를 커스터마이징하는 방식으로 이 변경 과정에 일부 hook을 추가할 수 있습니다. 예를 들어, 다음 코드는 각 transaction이 생성될 때 호출되는 dispatchTransaction prop을 추가합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 忽略 import 部分
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);
},
});

매번 state 업데이트는 최종적으로 updateState 메서드를 실행해야 하며, 일반적으로 transaction을 dispatch할 때마다 편집 상태 업데이트가 트리거됩니다.

Plugins

Plugins은 다양한 방식으로 편집 동작과 편집 상태를 확장하는 데 사용됩니다. 일부 plugin은 비교적 간단합니다. 예를 들어 keymap plugin은 키보드 입력 actions을 바인딩하는 데 사용됩니다. 또 다른 plugin은 상대적으로 복잡할 수 있습니다. 예를 들어 history plugin은 transactions을 모니터링하고 사용자가 transaction을 취소하려고 할 때 반대 순서로 저장하여 undo/redo 기능을 구현합니다.

먼저 undo/redo 기능을 얻기 위해 다음 두 plugin을 추가해 보겠습니다:

1
2
3
4
5
6
7
8
9
// 忽略重复的导入
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 })],
});
let view = new EditorView(document.body, { state });

Plugins은 state 생성 시 등록됩니다(왜냐하면 state의 transactions에 접근 권한이 필요하기 때문입니다). 이 undo/redo 가능한 state에 대해 view를 생성한 후, Ctrl+Z(또는 Mac에서는 Cmd+Z)를 눌러 이전 작업을 취소할 수 있게 될 것입니다.

Commands

위 예제에서 관련 키보드 키에 바인딩된 특수 함수를 commands라고 합니다. 대부분의 편집 동작은 commands 형태로 작성되므로 특정 키에 바인딩되거나 편집 메뉴에서 호출되거나 사용자 조작을 위해 노출될 수 있습니다.

prosemirror-commands 패키지는 enter 및 delete 키 동작을 편집기에서 예상대로 매핑하는 것을 포함하여 많은 기본 편집 commands를 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 忽略重复的导入
import { baseKeymap } from 'prosemirror-commands';

let state = EditorState.create({
schema,
plugins: [
history(),
keymap({ 'Mod-z': undo, 'Mod-y': redo }),
keymap(baseKeymap),
],
});
let view = new EditorView(document.body, { state });

이제 기본적으로 작동하는 편집기를 갖게 되었습니다.

편집 작업을 용이하게 하는 메뉴를 추가하거나 schema가 허용하는 키 바인딩을 추가하는 등과 같은 작업을 원한다면, prosemirror-example-setup 패키지를 확인하고 싶을 수 있습니다. 이 패키지는 기본 편집기를 구현하기 위한 일련의 사전 설정된 plugin을 제공하지만, 패키지 이름이 시사하는 바와 같이 이는 일부 API 사용법을 예시하기 위한 것이지 프로덕션 환경에서 사용할 수 있는 패키지는 아닙니다. 실제 개발 환경에서는 원하는 효과를 정확히 구현하기 위해 일부 내용을 자체 코드로 대체하고 싶을 수 있습니다.

Content

state의 document 객체는 doc 속성에 저장되며, 이는 읽기 전용 자료 구조로, 일련의 계층적 노드들로 표현됩니다. 이러한 노드 계층 구조는 브라우저의 DOM 노드와 유사합니다. 간단한 document는 “doc” 노드 하나를 가질 수 있으며, 이 노드는 두 개의 “paragraph” 노드를 포함하고, 각 “paragraph” 노드는 다시 하나의 “text” 노드를 포함할 수 있습니다. document 자료 구조에 대한 더 많은 정보는 guide에서 읽을 수 있습니다.

state를 초기화할 때 초기 document를 전달할 수 있습니다. 이 경우 schema 필드는 선택 사항입니다. 왜냐하면 schema는 document에서 추출할 수 있기 때문입니다.

아래 예제에서는 DOM 포맷팅 메커니즘을 사용하여 DOM 내 “content” ID를 가진 요소를 포맷팅하여 state를 초기화합니다. 이 state가 사용하는 schema 정보는 DOM 노드가 포맷팅된 후 해당 요소에 매핑되어 얻어집니다(즉, DOM 노드가 포함하는 요소들이 포맷팅되어 schema 형태로 변환되므로 schema 정보를 수동으로 지정할 필요 없이 포맷팅된 DOM 정보에서 얻을 수 있다는 의미입니다).

1
2
3
4
5
6
7
8
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),
});

Documents

Prosemirror는 document 내용을 표현하기 위해 자체적인 자료 구조를 정의합니다. document는 편집기를 구성하는 핵심 요소이기 때문에 document가 어떻게 작동하는지 이해하는 것이 중요합니다.

구조

Prosemirror의 document는 node 유형으로, fragment 객체를 포함하며, fragment 객체는 0개 이상의 자식 node를 포함합니다.

이는 브라우저의 DOM 구조와 매우 유사해 보이는데, Prosemirror도 DOM처럼 재귀적인 트리 구조를 가지기 때문입니다. 그러나 Prosemirror는 인라인 요소를 저장하는 방식에서 DOM과 약간 다릅니다.

HTML에서는 paragraph와 그 안에 포함된 마크업이 트리 형태로 표현됩니다. 예를 들어 다음과 같은 HTML 구조가 있습니다:

1
2
3
4
5
6
<p>
This is{' '}
<strong>
strong text with <em>emphasis</em>
</strong>
</p>

dom structure

반면 Prosemirror에서는 인라인 요소가 평평한 모델로 표현되며, 노드 마크업은 해당 node에 메타데이터로 첨부됩니다:

prosemirror-document-structure

이러한 자료 구조는 우리가 생각하는 이 유형의 텍스트에 더 부합합니다. 이는 문단 내 위치를 트리 노드 경로 대신 문자 오프셋으로 표현할 수 있게 하며, 콘텐츠를 분할하거나 스타일을 변경하는 작업을 어색한 트리 조작 없이 쉽게 수행할 수 있게 합니다.

이는 또한 각 document가 단 하나의 자료 구조 표현만을 가짐을 의미합니다. 텍스트 노드에서 인접하고 동일한 marks는 병합되며, 빈 텍스트 노드는 허용되지 않습니다. marks의 순서는 schema에 지정됩니다.

따라서 Prosemirror document는 block node들의 트리이며, 대부분의 leaf node는 textblock 유형입니다. 이는 텍스트를 포함하는 block node입니다. 또한 hr 요소나 video 요소처럼 내용이 없는 간단한 leaf node도 가질 수 있습니다.

Node 객체는 문서 내에서의 역할을 나타내는 여러 속성을 가집니다:

  • isBlockisInline은 이 node가 block 유형의 node(div와 유사)인지 inline 유형의 node(span과 유사)인지를 알려줍니다.
  • inlineContent가 true이면 이 node는 content로 inline 요소만 허용합니다(이 노드를 판단하여 다음 단계에서 inline node를 추가할지 여부를 결정할 수 있습니다).
  • isTextBlock이 true이면 이 node는 inline content를 포함하는 block node입니다.
  • isLeaf이 true이면 이 node는 어떠한 content도 허용하지 않습니다.

따라서, 일반적인 “paragraph” 노드는 textblock 유형의 노드이며, blockquote(인용 요소)는 다른 block 요소로 내용이 구성될 수 있는 block 요소입니다. 텍스트 노드, 줄바꿈, 인라인 이미지는 모두 inline leaf nodes이고, 수평 구분선(hr 요소) 노드는 전형적인 block leaf nodes입니다(leaf nodes는 더 이상 하위 노드를 포함할 수 없는 노드를 의미하며, 앞서 언급한 대로 inline일 수도 있고 block일 수도 있습니다—역자 주).

Schema는 "어떤 요소가 어디에 허용되는지"와 같은 제약 조건을 더 지정할 수 있게 합니다. 예를 들어, 노드가 block content를 허용한다고 해서 모든 block nodes가 content로 허용되는 것은 아닙니다(schema를 통해 수동으로 예외를 지정할 수 있습니다—역자 주).

정체성과 지속성

DOM 트리와 ProseMirror 문서의 또 다른 차이점은 nodes 객체의 표현 방식입니다. DOM에서 nodes는 identity를 가진 mutable 객체입니다(mutable 객체가 무엇인지 모르는 경우 검색해 보세요). 이는 노드가 부모 노드 아래에서만 나타날 수 있음을 의미합니다(다른 곳에 나타나면 원래 위치에서는 사라집니다. identity가 있으므로 유일합니다—역자 주). 노드가 업데이트되면 mutated됩니다(노드 업데이트는 기존 노드에서 이루어지며, 이를 mutated 즉 돌연변이라고 합니다. 기존 객체를 수정하는 것을 의미하며, 수정 전후로 동일한 객체입니다—역자 주).

반면 ProseMirror에서는 nodes가 단순히 values입니다(DOM의 mutable과 달리 values는 unmutable입니다). 노드를 표현하는 것은 숫자 3을 표현하는 것과 같습니다. 3은 서로 다른 데이터 구조에 동시에 나타날 수 있으며, 현재 데이터 구조에 바인딩되지 않습니다. 여기에 1을 더하면 새로운 value인 4를 얻으며, 원래의 3은 어떤 수정도 이루어지지 않습니다.

이것이 ProseMirror 문서의 메커니즘입니다. 그 값은 변경되지 않으며, 새로운 문서를 계산하기 위한 원시 값으로 사용될 수 있습니다. 이러한 문서의 nodes들은 자신이 속한 데이터 구조를 알지 못합니다. 여러 구조에 존재할 수 있고, 심지어 하나의 구조 내에서 여러 번 반복될 수 있기 때문입니다. 그들은 values이며, 상태를 가진 객체가 아닙니다.

이는 문서를 업데이트할 때마다 새로운 문서를 얻는다는 것을 의미합니다. 새로운 문서는 이번 업데이트에서 변경되지 않은 모든 하위 nodes의 value를 공유하므로, 새 문서를 생성하는 것은 저렴한 작업이 됩니다.

이 메커니즘에는 여러 장점이 있습니다. state가 업데이트될 때 편집기를 항상 사용할 수 있게 합니다. 새로운 state가 새로운 문서를 나타내기 때문입니다(업데이트가 완료되지 않으면 state가 나타나지 않으므로 문서도 없으며, 편집기는 여전히 이전 state + 문서 상태입니다—역자 주). 새 상태와 이전 상태는 즉시 전환될 수 있습니다(중간 상태 없이). 이러한 상태 전환은 간단한 수학적 추론 방식으로 수행될 수 있습니다—반면 값이 뒤에서 계속 변경되는 경우(DOM 노드처럼 돌연변이되는 경우—역자 주) 이러한 추론은 매우 어려워집니다. ProseMirror의 이 메커니즘은 협업 편집을 가능하게 하며, 이전에 화면에 그려진 문서와 현재 문서를 비교하는 알고리즘을 통해 매우 효율적으로 update DOM을 할 수 있게 합니다.

nodes는 일반적인 JavaScript 객체로 표현되며, 속성을 명시적으로 freezing하는 것(mutate 방지)은 성능에 큰 영향을 미칩니다. 따라서 ProseMirror 문서는 비돌연변이 메커니즘으로 실행되지만, 실제로는 수동으로 수정할 수 있습니다. 다만 ProseMirror는 이를 지원하지 않으며, 이러한 데이터 구조를 강제로 mutate하면 편집기가 충돌할 수 있습니다. 이러한 데이터 구조는 항상 여러 곳에서 공유되어 사용되기 때문입니다(한 곳을 수정하면 다른 알 수 없는 곳에 영향을 미칩니다—역자 주). 따라서 반드시 주의하세요!!! 또한 이 원리는 노드 객체에 저장된 배열과 객체에도 적용됩니다. 예를 들어 node attributes 객체나 fragments에 있는 하위 nodes들도 마찬가지입니다.

데이터 구조

문서의 데이터 구조는 다음과 같이 보입니다:

prosemirror-data-structure

각 node는 Node 클래스의 인스턴스입니다. 이들은 type 속성으로 분류되며, type 속성을 통해 node의 이름, 사용 가능한 attributes 등의 정보를 알 수 있습니다. Node types(및 mark types)는 각 schema에 의해 한 번만 생성되며, 자신이 속한 schema를 알고 있습니다.

node의 content는 Fragment 인스턴스를 가리키는 필드에 저장되며, 그 내용은 nodes 배열입니다. content가 없거나 content를 허용하지 않는 nodes도 마찬가지로, 이러한 nodes는 공유되는 empty fragment로 대체됩니다.

일부 node 유형은 attributes를 가질 수 있으며, 이는 각 node에 (content와는 별도로) 추가 값으로 저장됩니다. 예를 들어, image node는 attributes를 사용해 alt 텍스트와 URL 정보를 저장할 수 있습니다.

또한 inline nodes는 활성화된 marks를 포함합니다. marks는 emphasis나 link 같은 것을 의미하며, Mark 인스턴스로 표현됩니다.

전체 document도 하나의 node입니다. document의 content는 최상위 node의 자식 nodes로 구성됩니다. 일반적으로 이 최상위 node의 자식 nodes는 일련의 block nodes이며, 이 중 일부는 textblocks를 포함할 수 있고, 이 textblocks는 inline content를 포함합니다. 그러나 최상위 node가 단일 textblock일 수도 있으며, 이 경우 전체 document는 inline content만 포함하게 됩니다.

어떤 node가 어떤 위치에 나타날 수 있는지는 document의 schema에 의해 결정됩니다. 프로그래밍 방식으로(즉, 편집기에 직접 내용을 입력하는 방식이 아닌) nodes를 생성하려면 schema를 통해야 합니다. 예를 들어 아래의 nodetext 메서드를 사용하는 것처럼 말이죠.

1
2
3
4
5
6
7
8
import { schema } from 'prosemirror-schema-basic';

// null 参数的位置是用来在必要的情况下指定属性的
let doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('One.')]),
schema.node('horizontal_rule'),
schema.node('paragraph', null, [schema.text('Two!')]),
]);

인덱싱

Prosemirror nodes는 두 가지 유형의 인덱싱을 지원합니다. 이들은 오프셋을 사용해 각 nodes를 구분하는 트리 유형으로 취급될 수도 있고, 일련의 토큰을 가진 평평한 구조(토큰은 계수 단위로 이해할 수 있음)로 취급될 수도 있습니다.

첫 번째 인덱스는 DOM에서와 같이 개별 nodes와 상호 작용할 수 있게 해주며, child methodchildCount를 사용해 자식 nodes에 직접 접근하거나, descendantsnodesBetween를 사용해 document를 순회하는 재귀 함수를 작성할 수 있습니다(모든 nodes를 순회하려는 경우).

두 번째 인덱스는 문서에서 특정 위치를 지정할 때 더 유용합니다. 이는 문서의 임의의 위치를 정수로 나타낼 수 있으며, 이 정수는 토큰의 순서입니다. 이러한 토큰 객체는 실제로 메모리에 존재하지 않지만(단순히 계수를 위한 것입니다), document의 트리 구조와 각 node가 자신의 크기를 알고 있기 때문에 위치별로 접근하는 것이 저렴합니다.

  • Document의 시작 위치, 즉 모든 content의 시작 부분은 위치 0입니다.
  • leaf node가 아닌 node(즉, content를 포함할 수 있는 node)에 들어가거나 나오는 것은 1개의 토큰으로 계산됩니다. 따라서 document가 paragraph(태그는 p)로 시작하는 경우, paragraph 시작 부분의 위치는 1입니다(즉 <p> 이후의 위치).
  • Text nodes의 각 문자는 1개의 토큰으로 계산됩니다. 따라서 document 시작 부분의 paragraph가 "hi"라는 단어를 포함하는 경우, 위치 2는 “h” 이후, 위치 3은 “i” 이후, 위치 4는 전체 paragraph 이후입니다(즉 </p> 이후).
  • content를 허용하지 않는 leaf nodes(예: 이미지 node)는 1개의 토큰으로 계산됩니다.

따라서 다음과 같은 HTML로 표현되는 document가 있다면:

1
2
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>

토큰 순서와 위치는 다음과 같이 보일 것입니다:

prosemirror-indexing

각 노드에는 전체 노드의 크기를 나타내는 nodeSize 속성이 있으며, .content.size를 통해 노드 콘텐츠의 크기도 확인할 수 있습니다. 주의할 점은 document의 최상위 노드(즉, DOM에서 contenteditable 속성이 있는 노드로 전체 document의 루트 노드임)의 경우, 시작 및 종료 토큰은 document의 일부로 간주되지 않습니다(커서를 document 외부에 위치시킬 수 없기 때문). 따라서 document의 크기는 doc.content.size이며, doc.nodeSize가 아닙니다(비록 document의 시작/종료 태그가 document의 일부로 간주되지 않더라도 여전히 카운트됩니다. 후자는 항상 전자보다 2가 큽니다).

이러한 위치를 수동으로 계산하는 것은 상당한 계산 작업이 필요합니다. (따라서) Node.resolve를 호출하여 특정 position에 대한 더 자세한 데이터 구조 정보를 얻을 수 있습니다. 이 데이터 구조는 해당 position의 부모 노드가 무엇인지, 부모 노드 내에서의 오프셋은 얼마인지, 부모 노드의 조상 노드들은 무엇인지 등의 정보를 제공합니다.

자식 노드의 index(예: 각 childCount), document 범위의 position, 그리고 노드 오프셋(때로는 재귀 함수에서 현재 처리 중인 노드의 위치를 나타내는 데 사용되며, 이 경우 노드 오프셋이 관련됨) 사이의 차이를 반드시 구분해야 합니다.

슬라이스(Slices)

사용자의 복사-붙여넣기나 드래그 앤 드롭과 같은 작업에는 문서 조각(slice of document)이라는 개념이 관련됩니다. 예를 들어 두 position 사이의 콘텐츠가 하나의 슬라이스가 됩니다. 이러한 슬라이스는 완전한 노드나 프래그먼트와 달리 “열려 있을”(open) 수 있습니다(즉, 슬라이스에 포함된 태그가 닫히지 않았을 수 있음. 예를 들어 <p>123</p><p>456</p>에서 슬라이스가 23</p><p>45일 수 있음).

예를 들어, 한 단락의 중간부터 다른 단락의 중간까지 커서로 선택하면, 선택된 슬라이스는 두 개의 단락을 포함하게 되며 첫 번째는 시작 부분에서 열려 있고 두 번째는 끝 부분에서 열려 있습니다. 반면 인터페이스를 통해(뷰와의 상호작용이 아닌) 단락 노드를 선택하면 닫힌 노드를 선택한 것입니다. 슬라이스를 일반적인 노드 콘텐츠처럼 다루면, 필요한 일부 노드들(예: 슬라이스 콘텐츠를 완전한 노드로 만드는 태그, 앞의 예에서 시작 부분의 <p>와 끝 부분의 </p>)이 슬라이스 외부에 위치하기 때문에 해당 콘텐츠가 스키마 제약을 충족하지 못할 수 있습니다.

Slice 데이터 구조는 이러한 데이터를 표현하는 데 사용됩니다. 이는 양쪽의 open depth (루트 노드에 대한 상대적인 계층 깊이를 의미) 정보를 포함하는 fragment를 저장합니다. 노드에서 slice method를 사용하여 document에서 "슬라이스"를 “잘라낼” 수 있습니다.

1
2
3
4
5
6
7
//假设文档有两个 p 标签, 第一个 p 标签包含 a, 另一个 p 标签包含 b, 即:
// <p>a</p><p>b</p>
let slice1 = doc.slice(0, 3); // The first paragraph
console.log(slice1.openStart, slice1.openEnd); // → 0 0
let slice2 = doc.slice(1, 5); // From start of first paragraph
// to end of second
console.log(slice2.openStart, slice2.openEnd); // → 1 1

변경(Changing)

노드와 프래그먼트는 불변 데이터 구조이므로 절대로 직접 수정해서는 안 됩니다. document를 조작해야 하는 경우, 항상 불변성을 유지해야 합니다(조작 후 새로운 document가 생성되고 기존 document는 변경되지 않음).

대부분의 경우 노드를 직접 수정하지 않고 transformations을 사용하여 document를 업데이트해야 합니다. 이는 변경 기록을 남기는 데도 유용하며, 이러한 기록은 편집기 상태의 일부인 document에 필수적입니다.

만약 수동으로 document를 업데이트해야 한다면, Prosemirror는 NodeFragment에 document의 새 버전을 생성하는 데 유용한 헬퍼 함수들을 제공합니다. 자주 사용하게 될 Node.replace 메서드는 지정된 document의 range 내 콘텐츠를 새로운 content를 포함한 slice로 대체합니다. node를 얕게 업데이트하려면 copy 메서드를 사용할 수 있는데, 이는 동일한 node를 새로 생성하되 새 node에 대해 다른 content를 지정할 수 있게 해줍니다. Fragment도 replaceChildappend 같은 document 업데이트 메서드를 제공합니다.

스키마

모든 Prosemirror document는 관련된 schema를 가지고 있습니다. 이 스키마는 document에 존재할 수 있는 node들의 유형과 node들의 중첩 관계를 설명합니다. 예를 들어, 스키마는 최상위 node가 하나 이상의 block을 포함할 수 있고, paragraph node는 임의의 수의 inline node를 포함할 수 있으며, 이 inline node들은 임의의 수의 marks를 가질 수 있다고 규정할 수 있습니다.

스키마 사용법의 예시로 basic schema 패키지를 참고할 수 있지만, Prosemirror의 장점 중 하나는 사용자 정의 스키마를 정의할 수 있다는 점입니다.

Node 유형

document 내의 각 node는 type을 가지며, 이는 node의 의미론적 의미와 편집기에서의 렌더링 방식 등을 포함한 node의 속성을 나타냅니다.

스키마를 정의할 때는 사용되는 각 node type을 spec object로 설명하여 나열해야 합니다:

1
2
3
4
5
6
7
8
const trivialSchema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: { content: 'text*' },
text: { inline: true },
/* ... and so on */
},
});

위 코드는 document가 하나 이상의 paragraph를 포함할 수 있고, 각 paragraph는 임의의 수의 text를 포함할 수 있는 스키마를 정의합니다.

각 스키마는 최소한 최상위 node의 type(기본 이름은 "doc"이지만 구성할 수 있음)과 text content를 위한 “text” type을 정의해야 합니다.

inline 유형으로 인덱스 등을 계산하는 node는 반드시 inline 속성을 선언해야 합니다(text 유형은 inline으로 정의된다는 점을 떠올려보세요—이를 놓쳤을 수도 있습니다).

콘텐츠 표현식

위 스키마 예제에서 content 필드의 문자열 값을 'content expressions’라고 합니다. 이는 현재 type의 node에 대해 어떤 child node 유형이 허용되는지 제어합니다.

예를 들어, "paragraph"는 “하나의 paragraph”, "paragraph+"는 "하나 이상의 paragraph"를 의미합니다. 유사하게, "paragraph*"는 “0개 이상의 paragraph”, "caption?"은 "0개 또는 1개의 caption node"를 의미합니다. node 이름 뒤에 정규 표현식과 유사한 범위 표기법을 사용할 수도 있습니다: {2}(정확히 2개), {1, 5}(1개에서 5개), {2, }(2개 이상).

이러한 표현식은 조합되어 시퀀스를 생성할 수 있습니다. 예를 들어 "heading paragraph+"는 "heading 하나로 시작하고 그 뒤에 하나 이상의 paragraph"를 의미합니다. 파이프 기호 “|” 연산자를 사용하여 두 표현식 중 하나를 선택할 수도 있습니다: “(paragraph | blockquote)+”.

스키마에서 여러 번 나타날 수 있는 element type 그룹이 있을 수 있습니다. 예를 들어 “block” 개념의 node들이 최상위 요소 아래에 나타나거나 blockquote 유형의 node 내에 중첩될 수 있습니다. 스키마의 group 속성을 지정하여 node 그룹을 생성한 후 다른 표현식에서 그룹 이름을 사용할 수 있습니다:

1
2
3
4
5
6
7
8
const groupSchema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'text*' },
blockquote: { group: 'block', content: 'block+' },
text: {},
},
});

위 예제에서 "block+"는 "(paragraph | blockquote)+"와 동등합니다.

block content를 허용하는 nodes(예제에서는 doc과 blockquote)에는 최소한 하나의 child node가 있도록 설정하는 것을 권장합니다. 노드가 비어 있을 경우 브라우저가 이를 축소하여 편집할 수 없게 만들기 때문입니다(이 말은, 위의 doc이나 blockquote의 content를 block+ 대신 block*로 설정하면 child nodes가 없는 경우를 허용한다는 의미입니다. 여기서 *는 0개 이상, +는 1개 이상을 나타내는 일반적인 정규 표현식 표기법을 따릅니다. 이 경우 편집 시 브라우저에 입력되는 것은 text node, 즉 inline 노드이므로 입력이 불가능해집니다. 독자분들은 직접 시도해보시기 바랍니다—역자 주).

schema에서 nodes의 작성 순서는 중요합니다. 필수 node에 대해 기본 인스턴스를 새로 생성할 때, 예를 들어 replace step를 적용한 후 현재 문서가 여전히 schema의 제약을 충족하도록 하기 위해, schema 제약을 만족하는 첫 번째 node의 expression이 사용됩니다. node의 expression이 group인 경우, 이 group의 첫 번째 node type(schema 내에서 해당 group 멤버 node의 출현 순서에 따라 결정됨)이 사용됩니다. 만약 위의 schema 예제에서 "paragraph"와 "blockquote"의 순서를 바꾸면, 편집기가 block node를 새로 생성하려 할 때 stack overflow 오류가 발생할 것입니다—편집기가 먼저 “blockquote” node를 생성하려 시도하지만, 이 node는 최소한 하나의 block node를 필요로 하기 때문에 내용으로 또 다른 “blockquote” node를 먼저 생성해야 하고, 이 과정이 무한 반복되기 때문입니다.

모든 Prosemirror 라이브러리의 node 조작 함수가 현재 처리 중인 content의 유효성을 확인하는 것은 아닙니다—transforms와 같은 고급 개념은 확인하지만, 저수준의 node 생성 메서드는 일반적으로 유효성 검사를 호출자에게 맡깁니다. 이러한 저수준 메서드는 (현재 조작 중인 content가 유효하지 않더라도) 여전히 사용될 수 있습니다. 예를 들어, NodeType.create은 유효하지 않은 content를 포함하는 노드를 생성할 수 있습니다. slices의 “open” 측면에 있는 node의 경우 이는 오히려 타당한 경우도 있습니다(slice는 유효한 노드가 아니지만 slice를 직접 조작해야 하는 상황—사용자가 수동으로 보완하도록 할 수는 없지 않나요?—역자 주). createChecked 메서드는 주어진 content가 schema를 준수하는지 확인할 수 있으며, check 메서드는 주어진 content가 유효한지 assert할 수 있습니다.

Marks

Marks는 일반적으로 inline content에 추가적인 스타일과 기타 정보를 제공하는 데 사용됩니다. schema는 현재 문서에서 허용되는 모든 schema를 선언해야 합니다(nodes를 선언하는 것과 유사합니다—역자 주). Mark types는 node types와 유사한 객체로, 다양한 mark를 분류하고 추가 정보를 제공하는 데 사용됩니다.

기본적으로, inline content를 허용하는 nodes는 schema에 정의된 모든 marks가 해당 child nodes에 적용되는 것을 허용합니다. node spec의 marks 필드에서 이를 구성할 수 있습니다.

다음은 paragraphs에서는 strong과 emphasis marks를 설정할 수 있지만 heading에서는 이 두 marks를 허용하지 않는 간단한 schema 예제입니다.

1
2
3
4
5
6
7
8
9
10
11
12
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: {},
},
});

marks 필드의 값은 쉼표로 구분된 marks 이름이나 mark groups—""로 작성할 수 있으며, ""는 모든 marks를 허용하는 와일드카드입니다. 빈 문자열은 어떤 marks도 허용하지 않음을 의미합니다.

Attributes

Document의 schema는 또한 node와 mark가 가질 수 있는 attributes를 정의합니다. node type에 추가적인 node 전용 정보(예: heading node의 level 정보(H1, H2 등—역자 주))가 필요한 경우 attribute를 사용하는 것이 적합합니다.

Attribute는 일반적인 평범한 객체로, 각 node 또는 mark에 대해 미리 정의된 속성을 가지며, JSON으로 직렬화 가능한 값을 가리킵니다. 허용되는 attributes를 지정하기 위해 node spec과 mark spec에서 선택적 attr 속성을 사용할 수 있습니다:

1
2
3
4
heading: {
content: "text*",
attrs: {level: {default: 1}}
}

위 스키마에서 각 heading node 인스턴스는 .attrs.level을 통해 접근할 수 있는 level 속성을 가집니다. ](https://prosemirror.xheldon.com/docs/ref/#model.NodeType.create) heading을 생성할 때 명시적으로 지정하지 않으면 level은 기본값으로 1이 됩니다.

node를 정의할 때 attribute의 기본값을 제공하지 않으면, 해당 node를 생성할 때 명시적으로 attribute를 전달하지 않으면 오류가 발생합니다. 이는 Prosemirror가 createAndFill과 같은 인터페이스를 호출하여 스키마 제약 조건을 충족하는 node를 생성하는 것을 불가능하게 만들기도 합니다.

직렬화와 파싱

브라우저에서 요소를 편집하려면 document node가 DOM 형태로 표시되어야 합니다. 가장 간단한 방법은 스키마에서 각 node에 대해 DOM에서 어떻게 표시할지를 지정하는 것입니다. 이는 스키마의 각 node spec에 toDOM 필드를 추가하여 구현할 수 있습니다.

이 필드는 현재 node를 인자로 받아 node의 DOM 구조 설명을 반환하는 함수를 가리켜야 합니다. 이는 직접 DOM node가 될 수도 있고, ](https://prosemirror.xheldon.com/docs/ref/#model.DOMOutputSpec)와 같은 배열로 설명될 수도 있습니다. 예를 들어:

1
2
3
4
5
6
7
8
9
10
11
12
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'text*',
toDOM(node) {
return ['p', 0];
},
},
text: {},
},
});

위 예시에서 [“p”, 0]은 paragraph node가 HTML에서

태그로 렌더링됨을 의미합니다. 0은 "hole"을 나타내며, 해당 node의 내용이 렌더링되어야 할 위치를 표시합니다(즉, node에 내용이 있을 것으로 예상된다면 배열 끝에 0을 추가해야 합니다). 태그 뒤에 HTML 속성을 나타내는 객체를 추가할 수도 있습니다. 예: [“div”, {class: “c”}, 0]. leaf node는 내용이 없으므로 DOM에 "hole"이 필요하지 않습니다.

Mark의 specs는 node와 유사한 toDOM 메서드를 가지지만, content를 직접 감싸는 별도의 태그로 렌더링해야 하므로 반환된 node에 content가 직접 포함됩니다. 따라서 위에서 언급한 "hole"을 별도로 지정할 필요가 없습니다.

HTML DOM 내용을 Prosemirror가 인식하는 document로 변환하는 경우도 많습니다. 예를 들어 사용자가 편집기에 내용을 붙여넣거나 드래그할 때가 그렇습니다. Prosemirror-model 모듈에는 이를 처리하는 함수가 있지만, 스키마의 parseDOM 속성에 직접 파싱 방법을 포함시킬 수도 있습니다.

여기에는 DOM을 node나 mark로 매핑하는 방법을 설명하는 ](https://prosemirror.xheldon.com/docs/ref/#model.ParseRule) 규칙 목록이 포함됩니다. 예를 들어, 기본 스키마는 emphasis mark를 다음과 같이 정의합니다:

1
2
3
4
5
parseDOM: [
{ tag: 'em' }, // Match <em> nodes
{ tag: 'i' }, // and <i> nodes
{ style: 'font-style=italic' }, // and inline 'font-style: italic'
];

parse rule의 tag 필드는 CSS 선택자일 수도 있으므로 "div.myclass"와 같은 문자열을 전달할 수 있습니다. 마찬가지로 style 필드는 인라인 CSS 스타일과 일치합니다.

스키마에 parseDOM 필드가 포함되어 있으면 DOMParser.fromSchema를 사용하여 DOMParser 객체를 생성할 수 있습니다. 편집기는 기본 클립보드 내용 파서를 생성할 때 이 방법을 사용하지만, ](https://prosemirror.xheldon.com/docs/ref/#view.EditorProps.clipboardParser) 이를 재정의할 수도 있습니다.

Document에는 내장된 JSON 직렬화 방식도 있습니다. node에서 toJSON을 호출하면 JSON.stringify 함수에 안전하게 전달할 수 있는 객체를 생성할 수 있습니다(디버깅을 용이하게 하기 위한 목적인 것으로 보입니다). 또한 스키마 객체에는 toJSON 결과를 원래 node로 다시 변환하는 nodeFromJSON 메서드가 있습니다.

스키마 확장

Schema 생성자에 전달되는 nodes 및 marks 옵션은 OrderedMap 타입의 객체이거나 일반 JavaScript 객체일 수 있습니다. 생성된 스키마의 .spec.nodes 및 .spec.marks 속성은 항상 OrderedMaps이며, 다른 스키마의 기초로 사용될 수 있습니다.

OrderedMaps는 새로운 스키마를 쉽게 생성할 수 있는 다양한 메서드를 지원하는 맵입니다. 예를 들어, schema.markSpec.remove("blockquote")를 호출한 후 그 결과를 Schema 생성자의 nodes 필드에 전달하면 blockquote 노드가 없는 스키마를 생성할 수 있습니다.

문서 변환(Document transformations)

Transform은 Prosemirror의 핵심 작동 방식입니다. 이는 transactions의 기반이 되며, 편집 기록 추적과 협업 편집을 가능하게 합니다.

왜?

왜 우리는 문서를 직접 수정(mutate)할 수 없을까요? 아니면 적어도 문서의 완전히 새로운 버전을 생성하여 편집기에 반영할 수는 없을까요?

여러 이유가 있습니다. 그 중 하나는 코드의 명확성입니다. 불변(Immutable) 데이터 구조는 실제로 간단한 코드를 만듭니다. 또한 transform 시스템이 주로 수행하는 작업은 문서 업데이트의 흔적을 보존하는 것입니다. transform의 일련의 값들은 이전 문서에서 새로운 문서로의 각 단계(step) 기록을 나타냅니다.

Undo History는 이러한 단계들을 저장하고 필요할 때 이를 반대로 적용할 수 있습니다(Prosemirror는 단순히 이전 상태로 되돌리는 것보다 더 복잡한 선택적 undo를 구현합니다).

Collaborative editing(협업 편집) 시스템은 이러한 단계들을 전송하고 필요할 때 기록하여 각 문서 편집자가 동일한 문서를 가질 수 있도록 합니다.

대부분의 경우, (자신 또는 협업 편집으로부터) 각 문서 변경에 반응하는 것은 에디터 플러그인에게 유용하며, 이는 플러그인이 항상 에디터의 상태와 동일한 상태를 유지할 수 있게 합니다.

단계(Steps)

문서 업데이트는 steps라는 작은 단위로 분해되며, 이는 업데이트를 설명합니다. 일반적으로 이를 직접 다룰 필요는 없지만, 그 작동 방식을 이해하는 것은 중요합니다.

Steps의 예로는 문서의 일부를 교체하는 ReplaceStep이나 특정 범위에 Mark를 적용하는 AddMarkStep이 있습니다.

Step은 문서에 applied되어 새로운 문서를 생성할 수 있습니다.

1
2
3
4
5
console.log(myDoc.toString()); // → p("hello")
// 删除了 position 在 3-5 的 setp
let step = new ReplaceStep(3, 5, Slice.empty);
let result = step.apply(myDoc);
console.log(result.doc.toString()); // → p("heo")

Step을 적용하는 것은 비교적 간단한 과정입니다. 이는 스키마 제약을 유지하기 위해 노드를 삽입하거나 스키마에 맞게 슬라이스를 변환하는 등의 작업을 수행하지 않습니다. 이는 Step 적용이 실패할 수 있음을 의미합니다. 예를 들어, 노드의 토큰(즉, 노드의 여는 태그 또는 닫는 태그) 중 하나를 삭제하려고 하면 해당 노드의 다른 토큰이 제대로 닫히지 않을 수 있으며, 이는 의미가 없습니다. 이것이 apply 메서드가 result object를 반환하는 이유입니다. (Step 적용이 성공하면) 새로운 문서에 대한 참조를 유지하거나 (실패할 경우) 오류 메시지를 포함합니다.

일반적으로 helper function을 사용하여 Step을 생성하는 것이 좋으며, 이를 통해 세부 사항을 걱정할 필요가 없습니다.

변환(Transforms)

편집 작업은 하나 이상의 Step을 생성할 수 있습니다. 일련의 Step을 처리하는 가장 편리한 방법은 Transform object를 생성하는 것입니다(또는 편집기의 전체 상태를 처리하는 경우 Transaction을 사용할 수 있으며, 이는 Transform의 하위 클래스입니다).

1
2
3
4
5
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

대부분의 transform 메서드는 transform 자체를 반환하므로 체이닝이 용이합니다(예: tr.delete(5, 7).split(5)).

Transform에는 deleteing, replaceing, adding, removeing marks과 같은 메서드뿐만 아니라 트리 데이터 구조를 조작하는 splitting, joining, lifting, wrapping 등의 메서드가 있습니다.

문서를 변경할 때, 해당 문서를 가리키는 일부 위치(position)가 더 이상 유효하지 않거나 원래 의미를 잃을 수 있습니다. 예를 들어, 문자 하나를 삽입하면 그 뒤에 오는 모든 문자의 위치가 1씩 증가하여 새로운 위치를 가리키게 됩니다. 마찬가지로 문서의 모든 내용을 삭제하면, 이전에 내용을 가리키던 위치들은 더 이상 사용할 수 없게 됩니다.

문서가 변경되는 과정에서 위치(position)를 유지해야 할 필요가 종종 있습니다. 예를 들어 선택 영역 경계(selection boundaries)의 경우가 그러합니다. 이 문제를 해결하기 위해 step은 map을 제공할 수 있으며, 이를 통해 step 적용 전후의 문서 위치 정보를 변환할 수 있습니다.

1
2
3
4
let step = new ReplaceStep(4, 6, Slice.empty); // Delete 4-5
let map = step.getMap();
console.log(map.map(8)); // → 6
console.log(map.map(2)); // → 2 (document 变化的地方之前的 position 未变化)

Transform 객체는 자동으로 일련의 step에서 생성된 map을 accumulate(누적 계산)합니다. 이는 Mapping이라는 추상화를 사용하여 구현되며, 여러 step의 map을 수집하고 한 번에 모두 매핑할 수 있도록 합니다.

1
2
3
4
5
6
let tr = new Transaction(myDoc);
tr.split(10); // split a node, +2 tokens at 10
tr.delete(2, 5); // -3 tokens at 2
console.log(tr.mapping.map(15)); // → 14
console.log(tr.mapping.map(6)); // → 3
console.log(tr.mapping.map(10)); // → 9

그러나 주어진 position이 어디로 매핑되어야 하는지에 대한 문제가 있습니다. 위 예제의 마지막 줄을 보면, 위치 10이 정확히 node가 분할된 지점에 위치하며, 이 위치에 두 개의 token이 삽입되었습니다. 이 경우 해당 위치가 삽입된 내용의 앞으로 매핑되어야 할까요, 아니면 뒤로 매핑되어야 할까요? 이 예제에서는 분명히 삽입된 내용의 뒤로 매핑되었습니다.

하지만 때로는 다른 매핑 동작을 원할 수도 있습니다. 이것이 map 메서드가 step map과 mapping 시 두 번째 매개변수인 bias를 받는 이유입니다. bias를 -1로 설정하면 삽입된 position이 삽입된 내용의 앞으로 매핑됩니다.

1
console.log(tr.mapping.map(10, -1)); // → 7

각각의 개별 step을 작고 직관적으로 만드는 이유는 이러한 매핑이 가능하도록 하기 위함이며, 무손실 방식으로 step을 inverting하고 서로의 position map에 step을 매핑하기 위함입니다.

리베이징(Rebasing)

step과 map에 대해 더 복잡한 작업을 수행할 때, 예를 들어 사용자 정의 변경 추적을 구현하거나 협업 편집 기능을 통합할 때, step을 리베이스(rebase)해야 할 필요가 생깁니다.

이 부분은 실제로 필요하다고 확신할 때까지 배우는 것을 꺼릴 수 있습니다.

리베이싱은 간단한 예로, 동일한 문서가 두 step에 의해 수정될 때 하나의 step을 변환하여 다른 step이 수정한 문서에 적용할 수 있도록 하는 것을 말합니다. 의사 코드는 다음과 같습니다:

1
2
3
4
5
stepA(doc) = docA
stepB(doc) = docB
stepB(docA) = MISMATCH!
rebase(stepB, mapA) = stepB'
stepB'(docA) = docAB

Step에는 map 메서드가 있으며, 이는 mapping을 제공하여 전체 step을 매핑합니다. 이 매핑 과정은 실패할 수 있는데, 일부 step이 매핑될 때 더 이상 의미가 없을 수 있기 때문입니다(예: 적용하려는 내용이 이미 삭제된 경우). 그러나 매핑이 성공하면 새로운 문서, 즉 매핑된 새로운 문서를 가리키는 step을 얻게 됩니다. 따라서 위 의사 코드 예제에서 rebase(stepB, mapA)는 간단히 stepB.map(mapA)로 호출할 수 있습니다.

한 chain의 steps를 다른 chain의 steps에 리베이스하려는 경우:

1
2
3
stepA2(stepA1(doc)) = docA
stepB2(stepB1(doc)) = docB
???(docA) = docAB

stepB1을 stepA1, stepA2를 거쳐 stepB1’로 매핑할 수 있습니다. 그러나 stepB2의 경우 stepB1(doc)에서 생성된 문서에서 시작하며, 후자의 매핑된 버전은 stepB1’(docA)에서 생성된 문서에 적용되어야 하므로 상황이 더 복잡해집니다. 다음과 같은 chain의 maps를 통해 매핑되어야 합니다:

1
rebase(stepB2, [invert(mapB1), mapA1, mapA2, mapB1'])

예를 들어, 먼저 stepB1의 map을 역전시켜 document를 시작 document로 되돌린 다음(stepB1), stepA1과 stepA2에 의해 생성된 map 스트림(체인 호출)을 적용하고, 마지막으로 setpB1에 의해 생성된 map을 적용하여 document를 docA로 변환합니다.

여기에 setpB3이 있다면, 이전의 map 스트림을 통해 stepB3의 map 스트림을 얻을 수 있으며, 이 스트림 앞에 invert(mapB2)를 추가하고 mapB2’를 스트림 끝에 배치하는 방식으로 진행할 수 있습니다.

그러나 stepB1이 일부 내용을 삽입한 후 stepB2가 해당 내용에 대해 작업을 수행할 때, invert(mapB1) 매핑을 통해 stepB2는 null을 반환할 것입니다. 이는 stepB1의 역전이 적용될 내용을 삭제하기 때문입니다. 그러나 이 내용은 나중에 mapB1에 의해 스트림에 다시 도입될 것입니다. 매핑이라는 추상 객체는 이러한 스트림을 추적하는 방법을 제공하며, 파이프라인 내에서 관련 maps를 역전시키는 방법을 포함합니다. 위에서 설명한 시나리오를 해결하기 위해 mapping 객체를 통해 step을 매핑할 수 있습니다.

리베이스된 step이 있다 하더라도, 현재 document에 적용할 때 여전히 사용 가능하다는 보장은 없습니다. 예를 들어, step이 일부 mark를 추가했지만, 다른 step이 mark를 추가하려는 내용의 부모 노드를 수정하여 해당 부모 노드가 이전 step의 mark 추가를 허용하지 않는 노드로 변경된 경우, step을 적용하려고 시도하면 실패합니다. 이러한 경우에는 해당 step을 삭제하는 것이 더 적절한 처리 방법입니다.

에디터 상태

에디터의 상태는 무엇으로 구성될까요? 물론, 이를 구성하는 document가 이미 있습니다. 그러나 selection도 상태를 구성합니다. 또한 marks 설정 변경을 저장하는 방법도 필요합니다. 예를 들어, 아직 편집을 시작하지 않았을 때 mark를 활성화하거나 비활성화하는 경우입니다.(즉, 일반적인 요구 사항을 충족하기 위해: 먼저 mark(예: bold/font-size 등)를 클릭한 다음 편집을 시작하는 경우)

Prosemirror의 상태는 주로 세 가지 구성 요소로 이루어져 있으며, 이들은 state 객체에 존재합니다: doc, selectionstoreMarks.

1
2
3
4
5
6
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

그러나 플러그인도 상태를 저장해야 할 수 있습니다. 예를 들어, 실행 취소 기록 플러그인은 변경 기록을 저장해야 합니다. 이것이 활성화된 플러그인의 설정도 state에 저장되는 이유이며, 이러한 플러그인은 자신의 상태를 저장하기 위한 자체 슬롯을 정의할 수도 있습니다.

선택(Selection)

Prosemirror는 다양한 유형의 선택을 지원하며(그리고 제3자 코드가 새로운 선택 유형을 정의할 수 있도록 허용), 이러한 다양한 유형의 Selections는 Selection 서브클래스 형태로 나타납니다. document 및 기타 일부 state 관련 값과 마찬가지로, 이들은 모두 불변(immutable)입니다. 즉, 선택을 변경하려면 새로운 선택 객체와 이를 보유할 새로운 state를 생성해야 합니다.

선택에는 최소한 현재 document 내의 시작(.form)과 끝(.to) 위치가 있습니다. 많은 선택 유형은 anchor(선택 영역의 고정된 측면)와 head(선택 영역의 이동 가능한 측면)을 구분하므로, 이러한 속성은 모든 선택 객체에 존재합니다.

가장 일반적으로 사용되는 선택 유형은 text selection으로, 일반적인 커서(anchor와 head가 동일한 경우) 또는 텍스트 선택을 나타내는 데 사용됩니다. text selection의 양쪽 끝은 inline 위치에 있어야 합니다. 즉, inline content를 허용하는 nodes 내에 있어야 합니다.

Prosemirror의 핵심 라이브러리는 node selection도 지원하며, 이 선택은 단일 node가 선택되었을 때를 나타냅니다. 예를 들어, node에서 ctrl/cmd + 클릭을 할 때입니다. 이 유형의 선택 범위는 해당 node 앞에서 node 뒤의 위치까지입니다.

트랜잭션(Transactions)

일반적인 편집 과정에서 새로운 state는 기존 state로부터 파생됩니다. 다만 문서를 로드할 때와 같이 완전히 새로운 state를 생성해야 하는 경우는 예외입니다(즉, 기존 state에서 파생되지 않음).

state는 기존 state에 applingtransaction을 적용하여 업데이트되고 새로운 state가 생성됩니다. 개념적으로 이 과정은 한 번만 발생합니다: 기존 state와 변경 transaction이 주어지면, state의 각 구성 요소에 대한 새로운 값이 계산되어 새로운 state의 값으로 조합됩니다.

1
2
3
4
5
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

TransactionTransform의 하위 클래스로, 이전 문서에 steps를 적용하여 문서를 업데이트하는 메서드를 상속받습니다. 또한 transaction은 selection 및 기타 state 관련 구성 요소를 추적하며, replaceSelection과 같은 selection 관련 편의 메서드를 제공합니다.

transaction을 생성하는 가장 간단한 방법은 편집기의 state 객체에서 tr getter(즉, view.state.tr)를 호출하는 것입니다. 이는 현재 state를 기반으로 빈 tr을 생성하므로, 여기에 steps 및 기타 업데이트를 추가할 수 있습니다.

기본적으로 기존 selection은 각 step을 통해 mapped되어 새로운 selection이 생성되지만, setSelection을 사용하여 정확히 새로운 selection을 설정할 수도 있습니다.

1
2
3
4
5
6
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

유사하게, 활성 marks 집합(즉, storeMarks)은 문서나 selection이 변경될 때 자동으로 지워지며, setStoredMarksensureMarks을 통해 다시 설정할 수 있습니다.

마지막으로, scrollInteView 메서드는 다음 state가 (브라우저에 의해) 현재 뷰에 렌더링되도록 보장합니다. 대부분의 사용자 작업 후에 이 메서드를 호출하는 것이 좋습니다.

Transform의 메서드와 마찬가지로, 대부분의 Transaction 메서드는 편의를 위해 transaction 자체를 반환하여 체이닝을 지원합니다.

플러그인

새로운 state를 creating할 때 플러그인 배열을 제공할 수 있습니다. 이 플러그인은 모든 state에 존재하며, transaction 적용 및 state 동작 방식에 영향을 미칩니다.

플러그인은 Plugin 클래스의 인스턴스로, 다양한 기능을 구현할 수 있습니다. 가장 간단한 경우는 이벤트에 응답하여 editor view에 몇 가지 props를 추가하는 것이며, 더 복잡한 경우에는 editor에 새로운 state를 추가하고 transaction을 기반으로 이를 업데이트하는 것입니다.

플러그인을 생성할 때는 객체를 전달하여 동작을 지정해야 합니다:

1
2
3
4
5
6
7
8
9
10
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] });

플러그인이 자체 state slot(Vue 용어로는 스코프 슬롯)이 필요한 경우, state 속성을 정의할 수 있습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let transactionCounter = new Plugin({
state: {
init() {
return 0;
},
apply(tr, value) {
return value + 1;
},
},
});

function getTransactionCount(state) {
return transactionCounter.getState(state);
}

위 예제에서 이 플러그인은 단순히 state에 적용된 transaction의 수를 계산합니다. 이 헬퍼 함수는 editor의 state 객체에서 플러그인의 state를 가져올 수 있는 플러그인의 getState 메서드를 사용합니다.

editor의 state는 불변(immutable)한 지속성 객체이며, 플러그인 state는 그 일부이므로 플러그인 state 값도 반드시 immutable해야 합니다. 예를 들어, 플러그인 state를 변경해야 하는 경우 apply 메서드는 기존 값을 변경하는 대신 새로운 값을 반환해야 하며, 다른 코드는 이를 변경해서는 안 됩니다.

플러그인에게는 트랜잭션에 추가 정보를 첨부하는 것이 종종 유용합니다. 예를 들어 실행 취소 기록(undo history)에서, 실행 취소 작업을 수행할 때 결과 트랜잭션에 표시를 추가합니다. 플러그인이 이 표시를 감지하면 해당 트랜잭션을 특별히 처리하며, 일반적인 문서 변경 대신 실행 취소 스택의 상단 항목을 제거하고 이 트랜잭션을 다시 실행 스택에 추가합니다.

이러한 목적(트랜잭션에 추가 정보 첨부)을 위해, 트랜잭션은 메타데이터를 첨부할 수 있도록 허용합니다. 위 예제에서처럼 트랜잭션 카운트 플러그인을 업데이트하여 표시된 트랜잭션은 계산하지 않도록 할 수 있습니다. 아래와 같이 말이죠:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}

메타데이터의 키(key)는 문자열일 수 있지만, 이름 충돌을 피하기 위해 플러그인 객체(즉 PluginKey 객체, Symbol과 유사한 원리)를 사용하는 것이 강력히 권장됩니다. 일부 키는 이미 Prosemirror에서 사용 중입니다. 예를 들어 "addToHistory"는 false로 설정될 수 있으며, 이는 해당 트랜잭션이 실행 취소되지 않도록 합니다. 붙여넣기 이벤트를 처리할 때 편집기는 트랜잭션의 paste 속성을 true로 설정합니다.

뷰 컴포넌트

Prosemirror의 편집기 뷰는 사용자 인터페이스 컴포넌트로, 편집기 상태를 사용자에게 표시하고 사용자가 편집 작업을 수행할 수 있도록 합니다.

위에서 언급한 "편집 작업"의 정의는 코어 뷰 컴포넌트에 대해 더 좁게 정의됩니다. 뷰 컴포넌트는 클릭, 입력, 복사, 붙여넣기, 드래그와 같은 편집 인터페이스 상호작용을 직접 처리합니다. 그 외의 많은 작업은 처리하지 않습니다. 이는 메뉴 표시, 키보드 바인딩 제공, 또는 코어 뷰 컴포넌트 외부에서 뷰 컴포넌트에 반응하는 것과 같은 작업이 플러그인을 통해 구현되어야 함을 의미합니다.

편집 가능한 DOM

편집기는 DOM의 일부를 편집 가능으로 지정할 수 있도록 하며, 이 속성은 해당 DOM 부분이 포커스 및 선택을 허용하여 내용 입력이 가능하게 합니다. 뷰 컴포넌트는 문서의 DOM 표현을 생성하고(기본적으로 스키마의 toDOM 메서드 사용), 이를 편집 가능하게 만듭니다. 편집 가능한 요소가 포커스되면 Prosemirror는 DOM 선택이 편집기 상태의 선택과 일치하도록 보장합니다.

대부분의 DOM 이벤트에 대해 등록된 이벤트 핸들러가 있으며, 이들은 이벤트를 적절한 트랜잭션으로 변환합니다. 예를 들어 붙여넣기 시, 붙여넣은 내용이 Prosemirror 문서의 슬라이스로 형식화되어 문서에 삽입됩니다.

대부분의 이벤트는 Prosemirror가 한 번 감싸지 않고 사용자가 직접 처리한 후 Prosemirror의 데이터 모델로 재해석될 수 있습니다. 예를 들어 브라우저는 커서와 선택 위치 처리(특히 양방향 텍스트 처리 시)에 매우 능숙하므로, 대부분의 커서 이동 관련 키 및 마우스 이벤트는 브라우저에 맡겨집니다. 처리 후 Prosemirror는 현재 DOM 선택이 어떤 유형의 텍스트 선택에 해당하는지 확인합니다. 실제 선택이 Prosemirror의 현재 선택과 일치하지 않는 것으로 감지되면 선택을 업데이트하는 트랜잭션이 전달됩니다.

입력 이벤트도 일반적으로 브라우저에 맡겨집니다. 입력 이벤트를 간섭하면 모바일의 맞춤법 검사, 자동 대문자 변환 및 기타 기본 기능이 작동하지 않을 수 있기 때문입니다. 브라우저가 DOM을 업데이트하면 편집기는 이를 감지하고 문서의 변경된 부분을 재형식화한 후 이러한 변경 사항을 트랜잭션으로 변환합니다.

데이터 흐름

따라서, editor view는 주어진 editor state를 표시하고, 특정 이벤트가 발생할 때 새로운 transaction을 생성하여 이를 방출합니다(이렇게 생성된 transaction은 다른 plugin이나 이벤트에서 사용될 수 있음). 그런 다음 이 transaction은 일반적으로 새로운 state를 생성하는 데 사용되며, 이 새로운 state는 view의 updateState 메서드에 의해 적용됩니다:

prosemirror-data-flow

이와 같이, Prosemirror는 JavaScript 생태계에서 일반적으로 볼 수 있는 명령형 이벤트 처리 방식(더 복잡한 데이터 흐름 네트워크를 생성하는)과는 완전히 다른 단순한 순환 데이터 흐름을 생성합니다.

transaction을 “가로채는” 것은 가능한데, 이는 dispatchTransaction 속성을 통해 dispatched되기 때문입니다. 이를 통해 Prosemirror의 데이터 흐름을 더 큰 데이터 루프에 통합할 수 있습니다. 예를 들어 React/Vue와 같은 뷰 프레임워크의 데이터 흐름이나 Redux와 유사한 아키텍처를 전체 앱에서 사용하는 경우, Prosemirror의 transaction을 주요 이벤트 dispatch 루프에 통합하고 Prosemirror의 state를 앱의 'store’에 포함시킬 수 있습니다(여기서는 Redux의 store 개념을 차용함).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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);
}

효율적인 업데이트

updateState 기능을 구현하는 한 가지 방법은 호출될 때마다 전체 document를 다시 렌더링하는 것입니다. 그러나 큰 document의 경우 이는 매우 느릴 수 있습니다.

따라서 view를 업데이트할 때, view는 기존 document와 새로운 document를 비교하여 DOM에서 변경되지 않은 부분은 그대로 유지합니다(새로운 부분은 교체됨). Prosemirror는 이 작업을 대신 처리하여 각 업데이트가 최소한의 작업만 수행하도록 합니다.

텍스트 입력 업데이트와 같은 경우, 브라우저 자체의 편집 작업으로 DOM에 이미 반영된 텍스트(즉, 브라우저가 DOM을 수정한 후 Prosemirror가 DOM 변경 이벤트를 감지하여 transaction을 트리거하고 DOM의 입력 변경 사항을 동기화하므로 DOM을 다시 수정할 필요가 없음)는 Prosemirror와 DOM 간의 일관성을 유지하기 위해 추가적인 DOM 업데이트가 필요하지 않습니다.(이러한 DOM 상태를 Prosemirror로 동기화하는 transaction이 취소되면, view는 DOM을 undo하여 state와의 동기화를 유지합니다)

마찬가지로, DOM 선택 영역은 state의 선택 영역과 일치하지 않을 때만 동기화되어 브라우저 선택 영역의 다양한 내부 상태(예: 짧은 줄에서 위/아래 화살표 키를 누를 때 커서가 이전/다음 줄의 긴 줄 끝으로 이동하는 기능)를 손상시키지 않도록 합니다.

Props

'Props’는 매우 유용한 개념으로, 정확히 말하면 React에서 차용한 것입니다. Props는 UI 컴포넌트에 대한 매개변수와 같습니다. 이상적으로, 컴포넌트가 받는 props는 그 동작을 완전히 정의합니다.

1
2
3
4
5
6
7
8
9
let view = new EditorView({
state: myState,
editable() {
return false;
}, // Enables read-only behavior
handleDoubleClick() {
console.log('Double click!');
},
});

위에서 언급한 것처럼, 현재 state는 하나의 prop입니다. 컴포넌트를 제어하는 코드(즉, 컴포넌트에 props를 전달하는 코드)는 다른 시간에 다른 props를 updates할 수 있지만, state는 제외됩니다. 왜냐하면 컴포넌트 자체는 state를 제외한 다른 props를 변경하지 않기 때문입니다(이러한 업데이트는 컴포넌트를 제어하는 코드에서 처리해야 함). updateState는 단순히 state prop을 업데이트하는 편의 메서드일 뿐입니다.

Plugin도 props를 declare할 수 있지만, statedispatchTransaction은 포함되지 않습니다. 이 두 가지는 view를 정의할 때 직접 제공해야 합니다(Plugin은 state 필드를 정의할 수 있지만, 여기서 말하는 state는 editor의 state를 의미함).

1
2
3
4
5
6
7
8
9
function maxSizePlugin(max) {
return new Plugin({
props: {
editable(state) {
return state.doc.content.size < max;
},
},
});
}

여러 플러그인 등에 의해 prop이 여러 번 선언될 때, 이 prop들이 어떻게 처리되는지는 각 prop 자체에 달려 있습니다. 일반적으로 (editor view)에서 직접 제공되는 props가 우선순위를 가지며, 이후 각 플러그인이 선언된 순서대로 처리됩니다. 일부 props의 경우, 예를 들어 domParser는 처음 선언된 값이 사용되고 이후 선언된 값은 무시됩니다. (props의) 처리 함수의 경우 boolean 값을 반환하여 해당 이벤트를 처리했는지 여부를 나타내며, 첫 번째로 true를 반환한 함수가 이벤트를 처리합니다(그 후 동일 유형의 이벤트 처리 함수는 무시됩니다). 마지막으로, attributes(편집 가능한 DOM에 속성을 설정할 수 있음) 및 decorations(다음 섹션에서 설명)과 같은 다른 props의 경우 병합된 값이 사용됩니다.

Decorations

Decorations는 문서 뷰를 그리는 데 있어 일부 능력을 제공합니다. 이들은 decorations 속성의 반환값을 통해 생성되며, 세 가지 유형이 있습니다:

  • Node decorations: 단일 노드의 DOM에 스타일이나 기타 DOM 속성을 추가합니다.
  • Widget decorations: 실제 문서의 일부가 아닌 DOM 노드를 지정된 위치에 삽입합니다.
  • Inline decorations: 지정된 범위 내의 인라인 요소에 스타일이나 속성을 추가하며, node decoration과 유사하지만 인라인 요소에만 적용됩니다.

효율적인 decoration 그리기 및 비교를 위해 위의 decoration들은 decoration set(실제 문서 구조와 유사한 트리 형태의 데이터 구조) 형태로 제공되어야 합니다. 정적 메서드 create를 사용하여 새로 생성할 수 있으며, 이 함수에는 현재 문서와 decoration 배열 객체를 인자로 전달합니다:

1
2
3
4
5
6
7
8
9
10
11
let purplePlugin = new Plugin({
props: {
decorations(state) {
return DecorationSet.create(state.doc, [
Decoration.inline(0, state.doc.content.size, {
style: 'color: purple',
}),
]);
},
},
});

많은 decoration이 있는 경우 매번 리렌더링할 때마다 메모리에서 decoration set을 생성하는 것은 비용이 많이 듭니다. 따라서 이러한 경우에는 decoration을 플러그인의 state에서 유지하고, 문서가 수정될 때 이를 새로운 문서 상태에 매핑한 후 필요한 경우에만 업데이트하는 것이 권장됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
},
},
});

예제의 플러그인은 state를 decoration set으로 초기화하며, 이 decoration은 매 4번째 위치에 노란색 인라인 배경 decoration을 추가합니다. 이것은 그다지 유용하지 않을 수 있지만, 이러한 방식은 검색 결과 강조 표시나 코멘트 영역 추가와 같은 기능을 구현하는 데 사용될 수 있습니다.

transaction이 state에 적용될 때, 플러그인 state의 apply 메서드는 decoration set을 앞으로 매핑하여 decoration set(생성된 요소)이 새로운 문서 구조에 "적응"하도록 합니다. 매핑 메서드(주로 로컬 변경에 사용됨)는 decoration set의 트리 구조 덕분에 효율적으로 업데이트됩니다—변경의 영향을 받는 노드만 업데이트됩니다.

(프로덕션 환경에서 플러그인의 apply 메서드는 새로운 이벤트가 발생하여 decoration을 추가하거나 제거할 때도 호출될 수 있으며, 이때는 transaction이 전달하는 정보를 확인하거나 플러그인의 transaction에 첨부된 메타 정보를 검사하여 이를 감지할 수 있습니다.)

마지막으로, decorations 속성은 단순히 플러그인의 state를 반환하며, 이는 뷰에 decoration이 표시되도록 합니다.

Node views

문서 뷰가 어떻게 그려지는지 영향을 미치는 또 다른 방법이 있습니다. Node views는 문서 내에서 작고 독립적인 노드의 UI 컴포넌트를 정의함으로써 구현됩니다. 이들(사용자가 정의한 node views)은 DOM을 어떻게 렌더링할지, 업데이트할지 정의하고, 이벤트에 대한 사용자 정의 코드를 작성할 수 있게 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
}
}

예제의 image node view 객체는 image에 대한 사용자 정의 DOM 노드를 생성하고, 이벤트 처리 함수를 추가하며, Prosemirror가 해당 DOM 노드에서 발생한 이벤트를 무시해야 함을 나타내는 stopEvent 메서드를 포함합니다.

노드와 상호작용하여 문서 내의 실제 노드에 영향을 주고 싶을 때가 많습니다. 하지만 노드를 변경하기 위한 트랜잭션을 생성하려면 먼저 해당 노드의 위치를 알아야 합니다. 이를 가능하게 하기 위해, 노드 뷰는 현재 문서에서의 위치를 조회할 수 있는 getter 함수를 제공합니다. 앞의 예제를 수정하여, 이 노드를 클릭할 때 이미지 노드의 alt 정보를 입력할 수 있도록 해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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;
}
}

setNodeMarkup은 주어진 위치의 노드 유형이나 속성을 변경하는 데 사용할 수 있는 메서드입니다. 위 예제에서는 getPos 메서드를 사용하여 이미지 노드의 현재 위치를 찾은 후, 이 노드에 새로운 속성과 alt 정보를 부여합니다.

노드가 업데이트될 때 기본 동작은 외부 DOM 구조를 유지하면서 해당 노드의 자식 요소만 새로운 자식 집합과 비교하여 필요에 따라 업데이트하거나 교체하는 것입니다. 노드 뷰는 이 기본 동작을 재정의할 수 있으며, 이를 통해 단락의 CSS 클래스명 업데이트와 같은 노드 내용 기반 작업을 수행할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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.type.name != 'paragraph') return false;
if (node.content.size > 0) this.dom.classList.remove('empty');
else this.dom.classList.add('empty');
return true;
}
}

이미지는 내용을 갖지 않으므로, 앞선 예제에서는 내용 렌더링 방식을 걱정할 필요가 없었습니다. 하지만 단락은 내용을 가집니다. 노드 뷰는 내용을 조작하는 두 가지 방법을 지원합니다: ProseMirror가 내용을 관리하도록 하거나, 완전히 수동으로 관리하는 것입니다. contentDOM 속성을 제공하면 ProseMirror는 노드 내용을 해당 속성 노드 내에 렌더링하고 내용 업데이트를 처리합니다. 이 속성을 제공하지 않으면 노드 내용은 편집기에게 블랙박스가 되며, 내용 표시 방식과 사용자 상호작용은 전적으로 사용자에게 달려 있습니다.

이 경우, 단락 내용이 일반적인 편집 가능한 텍스트처럼 동작하길 원하므로, contentDOM 속성은 dom 속성과 동일하게 정의됩니다. 내용이 외부 컨테이너에 직접 렌더링되어야 하기 때문입니다.

마법은 update 메서드에서 일어납니다. 먼저, 이 메서드는 노드 뷰가 변경된 노드를 표시하기 위해 어떻게 업데이트될지 완전히 결정합니다. 편집기의 업데이트 알고리즘에 의해 생성된 새로운 노드는 무엇이든 될 수 있으므로, 현재 노드 뷰가 처리할 수 있는지 확인해야 합니다.

예제의 update 메서드는 먼저 새로운 노드가 단락인지 확인하고, 그렇지 않으면 즉시 중단합니다. 그런 다음 새 노드의 내용을 기반으로 empty 클래스명이 노드에 존재해야 하는지 확인하며, true를 반환하면 업데이트가 성공했음을 나타냅니다(이때 노드 내용이 업데이트됩니다).

명령어

ProseMirror 용어에서, 명령어 함수는 사용자가 특정 키 조합(예: cmd + a로 전체 선택)을 누르거나 메뉴 상호작용을 통해 작업을 실행할 수 있게 합니다.

실용적인 이유로 명령어는 약간 복잡합니다. 일부 간단한 명령어는 editor state와 dispatch(EditorView.dispatch 또는 트랜잭션 관련 함수)를 매개변수로 받아 boolean 값을 반환하는 함수입니다. 다음은 매우 간단한 예시입니다:

1
2
3
4
5
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false;
dispatch(state.tr.deleteSelection());
return true;
}

명령어를 사용할 수 없는 경우 false를 반환하고 아무 작업도 수행하지 않아야 합니다. 사용 가능한 경우 트랜잭션을 dispatch하고 true를 반환해야 합니다. keymap 플러그인은 이 메커니즘을 사용하여 이미 명령어 중 하나에 의해 처리된 키 입력이 다른 명령어에 의해 처리되지 않도록 방지합니다.

명령어를 실제로 실행하지 않고 주어진 state에 적용 가능한지 확인하기 위해, dispatch 매개변수는 선택적입니다. 명령어 함수는 dispatch 없이 호출될 때 사용 가능한 경우 true만 반환하고 다른 작업을 수행하지 않습니다. 다음 예시가 이를 보여줍니다:

1
2
3
4
5
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false;
if (dispatch) dispatch(state.tr.deleteSelection());
return true;
}

현재 선택 영역을 삭제할 수 있는지 확인하려면 deleteSelection(view.state, null)을 호출하고, 실제로 선택 영역을 삭제하려면 deleteSelection(view.state, view.dispatch)를 호출합니다. 메뉴 바는 이 메커니즘을 사용하여 메뉴 버튼을 비활성화(회색으로 표시)할지 결정할 수 있습니다.

위에서 언급한 메뉴바에서 commands를 사용할 때, 실제 editor view에 접근하지 않습니다. 사실 대부분의 경우 command는 접근할 필요가 없으며, view가 없을 때도 설정을 통해 메뉴 명령을 적용하고 테스트할 수 있습니다. 하지만 일부 commands는 실제로 DOM과 상호작용해야 할 수 있습니다. 예를 들어, 특정 position이 textblock의 끝에 있는지 query하거나, view를 기준으로 위치를 잡은 대화상자를 띄우고 싶을 수 있습니다. 따라서 대부분의 commands를 호출하는 plugin은 세 번째 인자로 현재 view를 전달할 것입니다.

1
2
3
4
5
6
7
function blinkView(_state, dispatch, view) {
if (dispatch) {
view.dom.style.background = 'yellow';
setTimeout(() => (view.dom.style.background = ''), 1000);
}
return true;
}

이 예제(비록 쓸모없지만)는 commands가 transaction을 dispatch할 필요가 없음을 보여줍니다. 일반적으로 commands는 부수 효과(transaction dispatch)를 적용하기 위해 호출되지만, 대화상자를 띄우는 용도로도 호출될 수 있습니다(dispatch 없이).

prosemirror-commands 모듈은 다양한 편집 commands를 제공합니다. 간단한 deleteSelection 변형 command부터, textblock의 시작 부분에서 backspace를 눌렀을 때 발생하는 block-joining 동작을 구현하는 joinBackward과 같은 더 복잡한 command까지 포함합니다. 이 모듈에는 또한 basic keymap (기본 키 바인딩)이 포함되어 있으며, 다양한 플랫폼 독립적인(즉, Win/Mac 또는 Safari/Chrome 등을 구분하지 않는) commands를 해당 키에 바인딩합니다.

일부 경우에는, 일반적으로 단일 키에 바인딩되는 다른 동작들이 별도의 commands로 분리됩니다(즉, 하나의 키가 다른 상황에서 다른 command에 의해 처리될 수 있음). 유틸리티 함수 chainCommands는 여러 commands를 조합하는 데 사용할 수 있습니다. 이 함수는 하나씩 시도하다가 true를 반환하는 command가 나올 때까지 계속합니다.

예를 들어, 기본 키 매핑은 backspace 키를 command chain deleteSelection (선택 영역이 비어 있지 않을 때 작동), joinBackward (커서가 textblock의 시작 부분에 있을 때 작동), 그리고 selectNodeBackward (schema가 일반적인 노드 결합을 금지하는 경우 selection 이전의 노드를 선택)에 바인딩합니다. 이 중 어느 것도 적용되지 않으면 브라우저는 기본 동작을 수행하며, 이는 textblock 내에서 backspace를 누르는 경우에 적합합니다(이렇게 해야 기본 맞춤법 검사 등이 정상적으로 작동합니다).

commands 모듈은 또한 toggleMark와 같은 command 생성자를 내보냅니다. 이 생성자는 mark 유형과 선택적 속성 집합을 받아 현재 선택 영역의 mark를 토글하는 command 함수를 반환합니다.

다른 모듈들도 command 함수를 내보낼 수 있습니다. 예를 들어 history 모듈의 undoredo 함수가 있습니다. 자신만의 편집기를 맞춤화하거나 사용자가 사용자 정의 document node와 상호작용할 수 있도록 하려면, 자신만의 command 함수를 작성해야 할 수도 있습니다.

협업 편집

실시간 협업 편집은 여러 사용자가 동시에 같은 document를 편집할 수 있게 합니다. 사용자가 문서를 수정하면 해당 변경 사항이 로컬 document에 즉시 적용되고, 이 변경 사항을 다른 사용자에게 전송합니다. 서로 다른 사용자의 서로 다른 변경 사항이 자동으로 병합되며(수동으로 충돌을 해결할 필요 없음), 편집이 중단되지 않고 문서는 항상 일관된 상태를 유지합니다.

이 가이드는 Prosemirror의 협업 편집 기능을 시작하는 방법을 설명합니다.

알고리즘

Prosemirror의 협업 편집 시스템은 central authority(중앙 권한) 모델을 사용합니다. 이 모델은 각 사용자의 변경 사항이 document에 어떤 순서로 적용될지 결정합니다. 두 편집기가 동시에 변경 사항을 만들면, 이 변경 사항은 authority에 제출됩니다. authority는 그 중 하나의 변경 사항을 수락한 다음 모든 편집기에 이 변경 사항을 브로드캐스트합니다. 다른 변경 사항은 수락되지 않으며, 편집기가 서버에서 새로운 변경 사항을 받으면 로컬 변경 사항을 다른 편집기의 최신 변경 사항 버전으로 rebase한 다음 로컬 변경 사항을 다시 제출하려고 시도합니다(여기서 rebase는 git의 rebase와 유사합니다. 로컬 변경 사항은 유지된 채(서버에 의해 거부되었으므로) 이전 편집기 문서를 최신 상태로 업데이트한 다음 자신의 로컬 변경 사항을 다시 제출합니다. 이후 서버가 이를 수락하는지 다시 확인합니다).

중앙 권한 기관의 역할은 실제로 매우 간단합니다. 그것은 반드시:

  • 현재 문서의 버전을 추적해야 합니다.
  • 편집자로부터 변경 사항을 수신하고, 해당 변경 사항이 적용될 때 이를 자체 변경 목록에 추가해야 합니다.
  • 편집자에게 특정 버전을 수신할 수 있는 방법을 제공해야 합니다.

이제 JavaScript 환경에서 실행되는 매우 간단한 중앙 권한 기관을 구현해 보겠습니다. 이 기관은 편집기와 동일한 환경에서 작동합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Authority {
constructor(doc) {
this.doc = doc;
this.steps = [];
this.stepClientIDs = [];
this.onNewSteps = [];
}
receiveSteps(version, steps, clientID) {
if (version != this.steps.length) return;
// Apply and accumulate new steps
steps.forEach((step) => {
this.doc = step.apply(this.doc).doc;
this.steps.push(step);
this.stepClientIDs.push(clientID);
});
// Signal listeners
this.onNewSteps.forEach(function (f) {
f();
});
}
stepsSince(version) {
return {
steps: this.steps.slice(version),
clientIDs: this.stepClientIDs.slice(version),
};
}
}

편집기가 변경 사항을 권한 기관에 제출하려고 할 때, 그들은 권한 기관의 receiveSteps 메서드를 호출합니다. 여기에는 마지막으로 수신한 버전 번호, 해당 버전에 추가된 새로운 변경 사항, 그리고 클라이언트 ID(자신의 변경 사항을 식별하기 위한)가 전달됩니다.

위의 제출이 권한 기관에 의해 수락되면, 클라이언트는 서버로부터 새로운 변경 사항이 사용 가능하다는 알림을 받게 되며, 각각의 변경 단계에 대한 지침을 받습니다. 실제 구현에서는 receiveSteps가 상태를 반환하고, 최적화를 위해 즉시 변경 단계를 확인하도록 할 수도 있습니다(서버 알림을 기다리지 않고). 그러나 위의 메커니즘(서버 알림 대기)은 불안정한 네트워크 상황에서의 안전망으로 사용되므로, 항상 서버로부터의 변경 사항 수신을 대체 솔루션으로 고려해야 합니다.

이 예제에서 권한 기관의 구현은 무한히 증가하는 단계 배열을 가지며, 그 길이는 현재 버전을 나타냅니다.

collab 모듈

collab 모듈은 collab 함수를 내보내며, 이 함수는 로컬 변경 사항을 추적하고 원격 변경 사항을 수신하며, 어떤 변경 사항을 권한 기관에 보내야 하는지 알려주는 플러그인을 반환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from 'prosemirror-schema-basic';
import collab from 'prosemirror-collab';

function collabEditor(authority, place) {
let view = new EditorView(place, {
state: EditorState.create({
doc: authority.doc,
plugins: [collab.collab({ version: authority.steps.length })],
}),
dispatchTransaction(transaction) {
let newState = view.state.apply(transaction);
view.updateState(newState);
let sendable = collab.sendableSteps(newState);
if (sendable)
authority.receiveSteps(
sendable.version,
sendable.steps,
sendable.clientID
);
},
});

authority.onNewSteps.push(function () {
let newData = authority.stepsSince(collab.getVersion(view.state));
view.dispatch(
collab.receiveTransaction(view.state, newData.steps, newData.clientIDs)
);
});

return view;
}

collabEditor 함수는 collab 플러그인이 로드된 새로운 editor view를 생성합니다. state가 업데이트될 때마다, 권한 기관에 보내야 할 것이 있는지 확인하고, 필요한 경우 이를 전송합니다.

또한 새로운 변경 단계가 사용 가능해질 때 권한 기관이 호출할 함수를 등록합니다. 이 함수는 권한 기관의 지시에 따라 로컬 편집기를 업데이트하는 트랜잭션을 생성합니다.

변경 단계 집합이 권한 기관에 의해 거부되면, 변경 단계는 확인되지 않은 상태로 유지됩니다. 그러나 권한 기관으로부터 새로운 변경 단계를 수신하면, onNewSteps 콜백이 dispatch를 호출하여 dispatchTransaction 함수를 트리거하게 되고, 이는 변경 사항을 다시 제출하려는 시도로 이어집니다.

이것이 전부입니다. 물론, colab demo에서의 장기 폴링이나 웹 소켓과 같은 비동기 데이터 흐름의 경우, 더 복잡한 통신 및 동기화 코드가 필요할 수 있습니다. 또한 메모리 사용량을 줄이기 위해 권한 기관이 일부 단계를 삭제하도록 할 수도 있습니다. 그러나 전반적으로 이 작은 예제는 권한 기관이 어떻게 구현되어야 하는지를 완전히 설명합니다.

- EOF -
이 글의 최초 게시: 「번역」 ProseMirror 한국어 가이드 - Xheldon Blog