본문으로 건너뛰기

명령 팔레트와 Tiptap 확장

context.commands.register(id, handler, opts)에 opts.title이나 opts.paletteVisible: true를 넘기면 그 명령이 앱의 명령 팔레트에 나타나고, ${pluginId}.${id}로 이름 공간이 붙습니다(그래서 두 플러그인이 글자 그대로 id라는 명령을 각각 등록해도 충돌하지 않습니다). 팔레트 항목은 opts.title이 있으면 그것을, 없으면 맨 id를 보여 줍니다. 돌려받은 Disposable을 정리하면(또는 플러그인을 내리면) 명령 등록과 함께 팔레트 항목도 사라집니다.

context.commands.register("summarize", () => summarize(), {
title: "AI Summary: Summarize current document",
paletteVisible: true,
});

메뉴, 슬래시, 단축키에서 부르기

섹션 제목: “메뉴, 슬래시, 단축키에서 부르기”

매니페스트의 contributions.commands에 선언한 명령은 세 곳에서 더 부를 수 있습니다. 에디터의 우클릭 메뉴(contributions.menu), 슬래시 메뉴(contributions.slash), 그리고 사용자가 키를 지정하는 설정 > 단축키입니다. 앱이 세 곳 모두 매니페스트에서 그리고 — 플러그인 매니페스트 참조 — 팔레트와 같은 방식으로 명령을 실행하므로, 어느 곳에서 부른 명령이든 창을 열 수 있습니다.

sandboxed 플러그인이 선언한 명령은 호스트가 연결합니다. trusted 플러그인은 명령을 선언하고 같은 id로 등록도 합니다. commands capability 가 필요합니다.

{
"capabilities": ["commands"],
"contributions": {
"commands": [{ "id": "cite", "title": "Insert citation" }],
"slash": [{ "id": "cite", "command": "cite" }]
}
}
context.commands.register("cite", () => insertCitation());

항목은 플러그인이 켜져 있고 명령이 등록돼 있는 동안에만 보입니다. dispose() 한 명령은 우클릭 메뉴와 슬래시 메뉴에서 사라지고, 그 단축키는 아무것도 하지 않습니다. 선언하지 않고 등록만 한 명령은 지금처럼 팔레트에만 나옵니다. trusted 플러그인에 commands capability 가 없으면 — 설치 때 거둔 경우도 — 세 곳 어디에도 나오지 않습니다.

플러그인이 살아 있는 에디터에 ProseMirror 플러그인을 기여할 수 있습니다. 매니페스트에 선언하고, extensions capability 를 요구하십시오.

{
"capabilities": ["extensions"],
"tiptapExtensions": [
{
"type": "plugin",
"name": "highlighter",
"exportName": "Highlighter"
}
]
}

type은 반드시 "plugin"이어야 합니다 — "node"와 "mark"는 validateManifest가 거부합니다. node 나 mark 는 ProseMirror 스키마를 바꾸는데, 스키마는 그것을 쓰는 에디터를 만들 때 고정됩니다. 게다가 닿아야 할 에디터가 하나가 아닙니다 — 공유 에디터 말고도, 큰 문서를 열 때마다 자기 스키마를 가진 keep-alive 에디터가 따로 만들어지고, 그것은 플러그인이 로드된 한참 뒤에도 생깁니다. 지금은 플러그인이 스키마에 더할 수 있는 경로가 없습니다. 데코레이션·키보드 핸들러·입력 규칙·붙여넣기 규칙 — 에디터 플러그인이 원하는 것의 대부분은 ProseMirror Plugin 하나 안에 들어가므로, 실제로는 거의 제약이 되지 않습니다.

그다음 진입점에서 팩토리를 내보냅니다. 팩토리는 컨텍스트 객체를 받아 ProseMirror Plugin을 정확히 하나 돌려줘야 합니다.

// ProseMirror에서 아무것도 import하지 않습니다. 아래 "ctx.pm으로 만드십시오" 참조 —
// 자기 사본을 싣는 것은 번들이 무거워지는 문제가 아니라 크래시입니다.
let host; // `activate`가 받은 컨텍스트 — 설정값을 그때그때 읽으려고 들고 있습니다
// `ctx.key`는 앱이 발급합니다. 그대로 쓰십시오 — 다른 키로 만든 플러그인은 등록이
// 거부됩니다. 언로드가 정확히 이 키만 제거해야 하기 때문입니다.
export const Highlighter = (ctx) =>
new ctx.pm.Plugin({
key: ctx.key,
// `ctx.settings`가 아닙니다 — 그것은 이 팩토리가 만들어질 때의 스냅숏입니다.
props: {
decorations: (state) =>
buildDecorations(state, ctx.pm, host.settings.getAll()),
},
});
export function activate(context) {
host = context;
}

키는 앱이 발급합니다 — 직접 고르는 게 아닙니다. ctx.key가 아닌 다른 키로 플러그인을 만들면 등록이 거부됩니다. 언로드는 정확히 이 플러그인의 키만 제거해야 하는데, 작성자가 직접 키를 고를 수 있다면 두 플러그인이(서로, 또는 앱 자신의 플러그인과) 충돌할 수 있기 때문입니다. 컨텍스트는 ctx.pm(바로 아래 참조)·ctx.editor(더 아래 참조)·ctx.pluginId·ctx.settings도 함께 담고 있습니다.

ctx.pm으로 만드십시오 — 직접 import하지 마십시오

섹션 제목: “ctx.pm으로 만드십시오 — 직접 import하지 마십시오”

ctx.pm은 앱 자신의 Decoration·DecorationSet·Plugin·PluginKey를 담고 있고 얼려져 있습니다. ProseMirror에 관한 것은 전부 여기서 꺼내 쓰고, 에디터에 기여하는 플러그인에서는 @tiptap/pm/*을 import하지 마십시오.

플러그인은 자기 번들로 배포되므로 안에서 @tiptap/pm/view를 import하면 prosemirror-view의 두 번째 사본이 됩니다. 그 사본은 앱의 것과 상호운용되지 않고, 그 실패가 하필 가장 먼저 시험해 보는 자리에서 조용하기 때문에 정확히 적어 둡니다.

데코레이션 소스 자기 사본으로 만든 DecorationSet
자기 것 하나뿐 등록·렌더·트랜잭션 전부 정상 — 멀쩡해 보입니다
자기 것 + 다른 것 Cannot read properties of undefined (reading 'localsInner')

에디터 자신의 확장들이 언제나 무언가를 그리고 있으므로 사용자가 만나는 것은 두 번째 줄입니다. 작은 재현 예제는 첫 번째 줄에 걸리기 쉽고, 그러면 import가 무해하다고 믿게 됩니다.

논거는 ctx.key와 같습니다 — 정체성이 하나여야 하고, 그것을 보장할 수 있는 쪽은 앱뿐입니다.

번들러를 쓴다면 @tiptap/pm을 의존성으로 둘 필요조차 없습니다 — examples/plugins/bullet-threading은 ProseMirror가 한 글자도 없는 번들로 빌드되고, 테스트가 그것을 단언합니다.

ctx.settings는 로드 시점의 스냅숏이지, 살아 있는 값이 아닙니다. 플러그인이 로드될 때 반영돼 있던 설정값을 담고 있습니다. 설정 폼에서 값을 바꿔도 플러그인이 다시 로드되지는 않으므로 팩토리도 다시 실행되지 않습니다 — ctx.settings를 읽는 prop 은 플러그인이 시작할 때의 값을 계속 내놓습니다. 현재 값이 필요하면 그 시점에 context.settings.getAll()을 부르십시오 (activate가 받은 그 context이며, settings capability 가 필요합니다). 스냅숏은 플러그인을 다시 불러올 때 갱신됩니다.

설정이 바뀌어도 플러그인을 다시 부르는 것은 없습니다. 설정 이벤트가 없고 (PluginEventName은 editor:ready·file:open·file:save뿐), SettingsAPI에는 getAll()밖에 없습니다. 그래서 상태 변경마다 도는 prop은 살아 있습니다 — 매번 현재 값을 읽을 수 있으니까요. 반면 activate에서 한 번만 하는 일, 예컨대 스타일시트 주입은 그렇지 않고, 다시 로드될 때까지 시작 시점의 값에 머뭅니다. 설정이 즉시 반영돼야 한다면 그것이 제어하는 것을 스타일시트가 아니라 데코레이션에 실으십시오.

기여한 플러그인은 props.editable도 선언할 수 없습니다 — 그 호출 역시 거부됩니다. 편집 가능 여부는 에디터의 코어 Editable 확장과 vim 의 몫입니다(§298 §12-⑪). 그것을 거부할 수 있는 제3의 주인이 생기면 그 계약이 깨집니다.

재시작이 필요 없습니다. Tiptap 플러그인은 여러분의 플러그인이 활성화되거나 언로드되는 순간 — 스키마를 다시 만드는 것이 아니라 평범한 editor.registerPlugin / editor.unregisterPlugin API를 통해 — 에디터에 설치되거나 제거됩니다. 개발자 구역에서 플러그인을 다시 불러오면(아래 참조) 바뀐 기여분이 즉시 반영됩니다.

에디터의 DOM을 직접 건드리지 마십시오. ProseMirror의 DOMObserver는 view.dom의 서브트리 전체를 속성 변경까지 포함해 관찰하고, contentDOM을 가진 노드에서는 그것을 무시하지 않습니다 — 그 구간을 문서 변경처럼 다시 읽고 다시 그립니다. 플러그인이 에디터 밖에서 쓴 속성은 즉시 지워지고, 같은 구간의 위젯 데코레이션은 재생성되며(눈에 보이는 깜박임), readDOMChange가 그 “변경”을 실제 문서 트랜잭션으로 만들 수도 있습니다. 에디터에 무언가를 그려야 한다면 "plugin" 기여를 만들고 데코레이션을 쓰십시오 — 이 API가 그러라고 있는 것입니다.

에디터는 하나가 아닙니다. 마크다운 표면은 공유 에디터와 — 큰 문서라면 — keep-alive 에디터를 형제로 함께 마운트하고, 활성이 아닌 쪽에 display: none을 겁니다. 그래서 document.querySelector(".tiptap")이 사용자가 보고 있는 에디터가 아니라 숨은 쪽을 집을 수 있습니다. 기여한 플러그인의 팩토리는 자신이 설치된 표면의 ctx.editor를 그대로 받으므로, 이 문제를 겪지 않습니다.