iPadOS 13+ sends a desktop Safari UA by default, which fools CodeMirror's mobile detection into using the more fragile textarea input mode. That, combined with CodeMirror repainting the line mid-composition, breaks Korean IME composition and leaves jamo unmerged. inputStyle can't be changed after the editor is created, so it must be set in the EasyMDE constructor options. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
5.4 KiB
JavaScript
116 lines
5.4 KiB
JavaScript
(function () {
|
|
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로
|
|
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서
|
|
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다.
|
|
function initEditors(root) {
|
|
if (typeof EasyMDE === "undefined") return;
|
|
var scope = root instanceof Element ? root : document;
|
|
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])");
|
|
|
|
textareas.forEach(function (textarea) {
|
|
textarea.setAttribute("data-easymde-initialized", "true");
|
|
|
|
var easymde = new EasyMDE({
|
|
element: textarea,
|
|
toolbar: false,
|
|
spellChecker: false,
|
|
autoDownloadFontAwesome: false,
|
|
status: false,
|
|
placeholder: textarea.getAttribute("placeholder") || "",
|
|
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
|
|
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
|
|
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
|
|
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
|
|
unorderedListStyle: "-",
|
|
// CodeMirror(EasyMDE 내부 엔진)는 User-Agent로 모바일 여부를 판단해 입력 모드를
|
|
// textarea/contenteditable 중 하나로 자동으로 고른다. iPadOS 13+는 기본 설정에서
|
|
// 데스크톱 Safari인 척하는 UA를 보내기 때문에(Mac으로 오인) 이 자동 감지가 실패해서
|
|
// 더 취약한 textarea 모드로 떨어지고, 한글처럼 여러 keystroke를 조합해 한 글자를
|
|
// 완성하는 IME 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려
|
|
// 자소가 분리된 채로 남는 현상(자소분리)이 생긴다. contenteditable 모드를 생성 시점에
|
|
// 명시적으로 강제해 이 오감지를 우회한다 — inputStyle은 CodeMirror 생성 이후에는
|
|
// 바꿀 수 없는 옵션이라(런타임에 setOption을 호출하면 에러) 반드시 여기서 지정해야
|
|
// 한다. iPhone은 원래도 정상 감지되지만 동일하게 명시해서 일관되게 둔다.
|
|
inputStyle: "contenteditable",
|
|
});
|
|
|
|
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
|
|
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
|
|
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
|
|
easymde.codemirror.on("change", function () {
|
|
easymde.codemirror.save();
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
|
|
easymde.codemirror.on("paste", function (cm, event) {
|
|
handleImagePaste(cm, event);
|
|
});
|
|
|
|
textarea._easymde = easymde;
|
|
});
|
|
}
|
|
|
|
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
|
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
|
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
|
|
function handleImagePaste(cm, event) {
|
|
var items = event.clipboardData && event.clipboardData.items;
|
|
if (!items) return;
|
|
|
|
var imageItem = null;
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].type.indexOf("image/") === 0) {
|
|
imageItem = items[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!imageItem) return;
|
|
|
|
event.preventDefault();
|
|
var file = imageItem.getAsFile();
|
|
if (!file) return;
|
|
|
|
var doc = cm.getDoc();
|
|
var from = doc.getCursor();
|
|
var placeholder = "![업로드 중...]()";
|
|
doc.replaceRange(placeholder, from);
|
|
var to = { line: from.line, ch: from.ch + placeholder.length };
|
|
// 업로드가 끝나기 전에 사용자가 다른 곳에서 계속 타이핑해도(줄 추가 등) 자리를 잃지
|
|
// 않도록 고정 좌표 대신 CodeMirror 북마크로 추적한다.
|
|
var startMark = doc.setBookmark(from);
|
|
var endMark = doc.setBookmark(to);
|
|
|
|
var formData = new FormData();
|
|
formData.append("file", file, file.name || "pasted-image.png");
|
|
|
|
fetch("/api/journal/paste-image", { method: "POST", body: formData })
|
|
.then(function (res) {
|
|
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
|
|
return res.json();
|
|
})
|
|
.then(function (data) {
|
|
var start = startMark.find();
|
|
var end = endMark.find();
|
|
if (start && end) { doc.replaceRange("", start, end); }
|
|
})
|
|
.catch(function (err) {
|
|
var start = startMark.find();
|
|
var end = endMark.find();
|
|
if (start && end) { doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end); }
|
|
})
|
|
.finally(function () {
|
|
startMark.clear();
|
|
endMark.clear();
|
|
cm.save();
|
|
cm.getTextArea().dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initEditors(document);
|
|
});
|
|
document.body.addEventListener("htmx:afterSwap", function (evt) {
|
|
initEditors(evt.target);
|
|
});
|
|
})();
|