ProseMirror에서 겹쳐 있는 두 버그 수정
Source: Dev.to
번역을 진행하려면 번역이 필요한 전체 텍스트를 제공해 주세요. 텍스트를 주시면 요청하신 대로 한국어로 번역해 드리겠습니다.
문제 설명
저는 Milkdown(ProseMirror를 래핑하는) 위에 마크다운 편집기를 만들고 있습니다.
굵은 제목을 입력하고 커서를 그 시작 부분으로 이동한 뒤 Backspace를 누르면, 제목이 위의 단락과 합쳐지지만 굵은 마크가 이전에 스타일이 없던 단락 텍스트에까지 번져 나갑니다.
Source:
Why Marks Survive the Join
Backspace 키를 텍스트 블록의 위치 0에서 누르면 joinTextblockBackward가 트리거됩니다. 내부적으로 joinTextblocksAround는 다음을 호출합니다:
replaceStep(state.doc, beforePos, afterPos, Slice.empty)이 코드는 두 블록 사이의 경계를 삭제합니다. Fitter 알고리즘은 두 번째 블록의 내용을 첫 번째 블록에 인라인 마크를 건드리지 않고 그대로 끼워 넣습니다—텍스트 노드가 그대로 전달되며, 굵게 표시된 부분도 포함됩니다.
clearIncompatible 함수가 있어 대상 노드 타입에서 허용되지 않는 마크를 제거하지만, 단락은 굵은 글씨를 허용하므로 아무 것도 제거되지 않습니다. splitBlockKeepMarks는 반대 작업(Enter)과 storedMarks(다음에 입력될 문자에 대한 커서 동작)를 처리하는데, 이는 다른 문제입니다.
따라서 마크는 조인(join) 후에도 살아남으며, 이는 의도된 동작입니다. 두 개의 단락을 예로 들어 보겠습니다:
She said the results were
**statistically significant** and could not be ignored.두 번째 단락의 시작에서 Backspace를 누르면 두 단락이 합쳐지고, 사용자가 명시적으로 적용한 굵은 글씨는 살아남아야 합니다. ProseMirror가 조인 시 마크를 제거한다면 사용자의 콘텐츠가 파괴될 것입니다.
헤딩 경우가 이상하게 느껴지는 이유는 다릅니다. 마크다운에서 # **Bold Heading**은 텍스트 노드에 명시적인 굵은 마크가 있습니다—이는 원본이 그렇게 쓰여 있기 때문입니다. 우리는 헤딩의 시각적 속성으로 굵게 보이지만, ProseMirror는 이를 헤딩 노드 안의 인라인 마크로 취급합니다. 헤딩이 단락으로 풀릴 때는 노드 타입만 바뀔 뿐, 마크는 그대로 남습니다. 이는 ProseMirror가 해당 마크가 “헤딩 특성”을 나타내는 것이 아니라 의도적인 굵은 스타일이라고 인식하지 못하기 때문입니다.
Conclusion: 이것은 ProseMirror 버그가 아니라, 마크다운 편집기에서 발생하는 모델링 문제입니다.
Solution: joinTextblockBackward 가로채기
이 수정은 joinTextblockBackward 의 디스패치를 가로채어 헤딩 내용 범위를 스냅샷으로 저장한 뒤, 이전 헤딩에 속해 있던 마크들을 제거합니다. 또한 storedMarks 를 비워 커서가 마크를 물려받지 않도록 합니다. 모든 변경은 하나의 트랜잭션에서 이루어지므로, 실행 취소(Ctrl+Z)를 하면 모든 작업이 원자적으로 되돌아갑니다.
Stand‑alone Plugin
import { Plugin, PluginKey } from "prosemirror-state";
import { joinTextblockBackward } from "prosemirror-commands";
const headingBackspacePlugin = new Plugin({
key: new PluginKey("heading-backspace"),
props: {
handleKeyDown(view, event) {
if (event.key !== "Backspace") return false;
const { state, dispatch } = view;
const headingType = state.schema.nodes.heading;
if (!headingType) return false;
const { $from, empty } = state.selection;
if (
!empty ||
$from.parentOffset !== 0 ||
$from.node().type !== headingType
)
return false;
// Snapshot before the join mutates anything.
const headingContentSize = $from.node().content.size;
const prevEnd = $from.before();
const wrappedDispatch = (tr) => {
// Map prevEnd through the join's ReplaceStep to find where
// the heading content now sits inside the merged paragraph.
const from = tr.mapping.map(prevEnd);
const to = from + headingContentSize;
// Strip every mark type from the former‑heading range.
for (const markType of Object.values(state.schema.marks)) {
tr.removeMark(from, to, markType);
}
// Clear storedMarks so the cursor doesn't inherit marks.
tr.setStoredMarks([]);
dispatch(tr);
};
return joinTextblockBackward(state, wrappedDispatch, view);
},
},
});How it works
$from.before()은 헤딩 시작 토큰 바로 앞의 위치를 반환합니다.- 조인(
ReplaceStep)이 블록 경계를 삭제한 뒤,tr.mapping.map(prevEnd)은 헤딩 내용이 병합된 단락 안에서 시작하는 새로운 위치를 제공합니다. headingContentSize는 조인이 헤딩 내용 자체를 변경하지 않기 때문에 그대로 유지됩니다.tr.removeMark는 같은 트랜잭션 내에서 여러 번 호출될 수 있으며, 지정된 범위에서 각 마크 타입을 안전하게 제거합니다.tr.setStoredMarks([])는 커서 마크를 비워 조인 후 타이핑 시 굵게/기울임 등 마크가 물려받는 것을 방지합니다.
전체 작업이 하나의 트랜잭션으로 처리되므로, 실행 취소를 하면 조인과 마크 제거가 동시에 복원됩니다.
통합
Milkdown 또는 Tiptap을 사용하고 있다면, 프레임워크의 플러그인 API를 통해 이 플러그인을 등록하세요. 기본 ProseMirror 로직은 동일합니다.