·PNU-Modu

마크다운 에디터와 미리보기를 양방향으로 잇기 - data-source-line과 postMessage


PNU-Modu의 목표는 하나였다. 비전공 교직원이 마크다운 문법을 배우지 않고도 브로슈어를 쓰게 하는 것. 그러려면 편집 화면이 두 가지를 만족해야 했다. 왼쪽에 글을 쓰면 오른쪽에 실제 결과가 바로 보일 것. 그리고 오른쪽 결과를 보고 그 자리에서 고칠 수 있을 것. 흔히 말하는 위지윅(WYSIWYG)이다.

앞의 절반(왼쪽 → 오른쪽)은 어렵지 않았다. 진짜 문제는 뒤의 절반, 오른쪽 렌더링 화면을 보고 고치면 그게 왼쪽 원본 마크다운에 정확히 반영되는 부분이었다. 이 양방향을 만드는 데 대부분의 시간이 들어갔다.

먼저, 왜 그냥 렌더가 안 되나

이 사이트는 Docusaurus로 빌드된다. 브로슈어 콘텐츠는 MDX로 쓰는데, MDX는 결국 JS로 컴파일되는 포맷이라 빌드 과정을 거쳐야 화면이 된다. 브라우저 안 편집기에서 키를 칠 때마다 MDX를 컴파일할 수는 없다. 그러면 저장 전에 결과를 볼 방법이 없다.

그래서 미리보기를 별도 Docusaurus 라우트(/preview)로 만들고, 관리자 편집기에서 그 페이지를 iframe으로 띄웠다. 편집기와 iframe은 postMessage로만 대화한다. iframe 안에서는 MDX 컴파일 대신 react-markdown + rehype-raw + 내가 만든 ::: 디렉티브 플러그인으로 마크다운을 즉시 렌더한다. 중요한 건, 이 미리보기가 실제 사이트와 같은 React 컴포넌트 트리를 쓴다는 점이다. 미리보기용으로 대충 흉내 낸 게 아니라, 배포될 때 쓰는 그 컴포넌트를 그대로 불러 쓴다. 그래서 미리보기가 곧 결과물이다.

여기까지가 왼쪽 → 오른쪽이다. 편집기에서 글이 바뀌면 MDX_UPDATE 메시지로 내용을 던지고 iframe이 다시 그린다. 이건 반나절이면 된다.

오른쪽에서 왼쪽으로 - 어떤 DOM이 몇 번째 줄인가

문제는 반대 방향이었다. 사용자가 오른쪽 미리보기의 어떤 문단을 클릭했을 때, 그게 왼쪽 원본 마크다운의 몇 번째 줄인지를 알아야 한다. 렌더된 결과에는 그런 정보가 없다. <p>안녕하세요</p>만 봐서는 이게 원본 12번째 줄인지 40번째 줄인지 알 수가 없다.

그래서 렌더 파이프라인에 커스텀 rehype 플러그인을 하나 끼웠다. AST를 만들 때 각 노드는 원본에서의 위치(position)를 갖고 있는데, 이걸 DOM 속성으로 심어 내려보내는 것이다.

// rehypeInjectLines - 렌더된 요소마다 원본 줄 번호를 새긴다
const start = node.position?.start?.line;
node.properties['data-source-line'] = start;

이제 렌더된 모든 문단·제목·목록에 data-source-line이 붙는다. 클릭이 들어오면 클릭된 요소에서 가장 가까운 [data-source-line]을 찾아 그 줄 번호를 JUMP_TO_LINE 메시지로 편집기에 보낸다. 편집기는 그 줄로 커서를 옮기고 화면 정중앙으로 스크롤한다. 반대로 편집기에서 커서를 옮기면 HIGHLIGHT_LINE을 보내, 미리보기의 해당 블록을 파란 링으로 글로우시킨다. 왼쪽과 오른쪽이 서로를 가리키기 시작한 순간이었다.

전체 대화는 다섯 종류의 메시지로 정리됐다.

편집기 → 미리보기 : MDX_UPDATE(내용), HIGHLIGHT_LINE(커서 줄)
미리보기 → 편집기 : PREVIEW_READY(준비됨), JUMP_TO_LINE(클릭한 줄), INLINE_TEXT_UPDATE(고친 텍스트)

PREVIEW_READY를 따로 둔 건, iframe이 아직 안 뜬 상태에서 편집기가 메시지를 쏘면 그냥 허공에 사라지기 때문이다. iframe이 마운트되면 먼저 준비됐다고 알리고, 그때부터 다리를 연다.

더블클릭해서 그 자리에서 고치기

가리키는 것 다음은 고치는 것이었다. 미리보기의 문단을 더블클릭하면 그 요소에 contenteditable을 붙여 바로 편집되게 했다. 여기서 잔손질이 꽤 들어갔다. 한글은 조합 중(IME)에 Enter가 들어오면 오작동하기 쉬워 Enter는 저장, Esc는 원복으로 잡고, 붙여넣기는 서식을 버리고 평문만 넣도록 했다. 편집이 끝나면(blur) 바뀐 텍스트를 INLINE_TEXT_UPDATE로 원본 줄 번호와 함께 편집기로 돌려보낸다.

if (keyEvent.key === 'Enter' && !keyEvent.shiftKey) { keyEvent.preventDefault(); target.blur(); }
else if (keyEvent.key === 'Escape') { target.innerText = originalText; target.blur(); }

문제는 그다음이었다.

진짜 지옥 - 껍데기를 벗기지 않고 알맹이만 갈아끼우기

미리보기에서 돌아오는 건 렌더된 순수 텍스트다. 원본이 ## 진정한 AI·SW 가치확산이었어도, 사용자가 편집한 뒤 돌아오는 건 ##가 벗겨진 “진정한 AI·SW 가치확산”뿐이다. 원본이 <div style="text-align:center">![img](...)</div> 같은 HTML 래퍼 안에 있었거나, 줄 끝에 <br/>가 붙어 있었어도 마찬가지다. 알맹이만 돌아온다.

그래서 원본 줄을 통째로 갈아치우면 안 됐다. ##도, <div>도, <br/>도 다 날아간다. 알맹이만 바꾸고 마크다운·HTML 껍데기는 그대로 둬야 했다. 이걸 여러 단계로 풀었다.

먼저 가장 안전한 방법 - 원본 줄 안에서 옛 텍스트를 찾아 새 텍스트로 부분 치환한다. 이러면 앞뒤 문법이 손상될 일이 없다. 이게 실패하면 공백을 정리해서 다시, 그래도 안 되면 대시 문자(- vs -, 붙여넣기에서 자주 뒤바뀐다)까지 맞춰 본다.

이 셋이 다 빗나가면 마지막으로 줄을 분해해서 다시 조립한다. 정규식으로 줄 앞쪽의 마크다운 접두사(#, *, -, 번호, 그리고 여는 HTML 태그)와 줄 끝의 닫는 태그를 떼어내 보관하고, 가운데 알맹이만 새 텍스트로 바꾼 뒤 접두사와 꼬리를 다시 붙인다.

// 껍데기(앞의 마크다운/여는 태그, 뒤의 닫는 태그)를 보존하고 알맹이만 교체
const leading  = originalLine.match(/^(\s*(?:#+|\*|-|\d+\.)?\s*(?:<[^>]+>)*\s*)/)?.[1] ?? '';
const trailing = originalLine.match(/((?:\s*<\/[^>]+>)*\s*)$/)?.[1] ?? '';
lines[idx] = leading + restoredText + trailing;

<br/>가 있던 줄이면 새 텍스트의 개행을 다시 <br/>로 되돌려 넣는 것까지 처리했다. 비전공자가 문법을 안 보게 하는 게 목표였는데, 그걸 위해 나는 문법을 한 글자도 잃지 않게 만들어야 했다. 역설적인 작업이었다.

위지윅을 구워삶기

되게 만드는 것과 자연스럽게 만드는 것은 달랐다. 굴러가게 한 뒤에도 며칠을 더 붙어 있었다.

에디터가 얼어붙었다. 편집기 상태를 미리보기와 동기화하려고 MutationObserver를 달았는데, 관찰이 변경을 부르고 그 변경이 다시 관찰을 부르는 순환에 빠졌다. 편집기가 통째로 멈췄다. 순환을 만드는 옵저버를 걷어내고 나서야 풀렸다.

커서 하이라이트가 한 박자 늦었다. Toast UI 에디터는 내부적으로 ProseMirror로 돌아가는데, 커서 위치를 읽는 시점이 ProseMirror의 렌더 루프보다 빨라서 엉뚱한 줄을 잡았다. 커서 추적을 50ms 뒤로 미뤄 렌더가 끝난 뒤 읽게 하니 맞아떨어졌다.

본문을 갱신할 때마다 화면이 튀었다. 편집기 내용을 코드로 바꾸는 setMarkdown이 스크롤과 포커스를 초기화해서, 인라인 편집 한 번에 화면이 맨 위로 튕겼다. 그래서 갱신 직전에 선택 영역과 스크롤 위치를 저장했다가 직후에 복원하는 래퍼로 감쌌다.

제목이 미리보기를 죽였다. Docusaurus의 커스텀 heading 컴포넌트가 미리보기 안에서 useTOCHighlight의 재귀 호출 크래시를 일으켰다. 미리보기에서는 heading을 순수 h1-h6으로 되돌려 프레임워크의 목차 기계를 아예 끄는 것으로 막았다.

이 네 가지에는 이렇다 할 묘수가 없었다. 하나씩 재현하고, 원인을 좁히고, 가장 작은 수정으로 걷어내는 반복이었다. 순환을 끊고, 타이밍을 한 박자 늦추고, 상태를 저장했다 복원하고, 프레임워크 기능 하나를 끄고. 그렇게 하나씩 들어내고 나서야 편집이 겨우 손에 붙는 느낌이 됐다.

지금

지금은 왼쪽 텍스트와 오른쪽 렌더링 화면이 어느 정도 서로를 안다. 왼쪽에서 커서를 옮기면 오른쪽 블록이 글로우하고, 오른쪽을 클릭하면 왼쪽이 그 줄로 스크롤되고, 오른쪽을 더블클릭하면 그 자리에서 고쳐지고 그게 왼쪽 원본에 문법을 지킨 채 반영된다. 적어도 일반적인 문단·제목·목록에서는 사용자가 마크다운을 의식할 일이 거의 없다.

물론 완성됐다고 말하긴 어렵다. 여러 겹으로 중첩된 HTML 안의 텍스트나, 한 줄에 같은 문구가 두 번 나오는 경우엔 분해조립이 엉뚱한 곳을 고칠 수 있다. 그런 줄은 아직 왼쪽 편집기에서 직접 고치는 편이 안전하고, 이건 앞으로 더 다듬어야 할 부분이다.

그래도 이 기능을 붙들면서 하나는 분명해졌다. 양방향 편집의 어려움은 결국 “표현(렌더된 화면)과 원본(마크다운) 사이의 대응을 양쪽으로 유지하는 것”에 있었다. 왼쪽에서 오른쪽으로 갈 때는 문법이 화면이 되며 사라지고, 오른쪽에서 왼쪽으로 돌아올 때는 그 사라진 문법을 되살려야 한다. 사용자가 편하려면 그 사라짐과 복원을 시스템이 대신 떠안아야 했다. 아직 다 떠안지는 못했지만, 적어도 이 기능이 풀어야 할 문제가 무엇인지는 알게 됐다.