Forcing inputStyle to contenteditable (previous commit) didn't fix it on iPhone, confirming this is CodeMirror 5's own long-standing weakness with CJK IME composition on iOS WebKit, not just the iPad desktop-UA detection issue. There's no reliable fix short of dropping CodeMirror for the journal content field, so EasyMDE is removed entirely and the textarea goes back to a native, uncontrolled <textarea> — nothing intercepts/re-renders it during composition, so iOS's IME just works. The markdown toolbar buttons (bold/italic/heading/quote/lists/code/link) now manipulate the textarea's selection directly via a small JournalEditor JS API instead of calling EasyMDE/CodeMirror commands, and image paste tracks its upload placeholder by a unique text marker instead of a CodeMirror bookmark. Also drops the vendored easymde.min.js/css (no longer referenced) and bumps the service worker CACHE_NAME so stale cached copies of the old vendor files get evicted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
208 lines
8.6 KiB
JavaScript
208 lines
8.6 KiB
JavaScript
(function () {
|
|
// 예전엔 이 파일이 EasyMDE(CodeMirror 5 기반 마크다운 에디터)를 초기화했는데, iOS Safari에서
|
|
// 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME 입력 중에 CodeMirror가 화면을
|
|
// 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로 남는 문제(자소분리)가 있었다.
|
|
// inputStyle을 contenteditable로 강제해도 아이폰에서까지 재현돼서(CodeMirror5 자체의 CJK
|
|
// IME 한계로 보임), 에디터 엔진을 아예 걷어내고 순수 <textarea>(.markdown-editor)로
|
|
// 되돌렸다 — 브라우저 네이티브 입력 처리를 그대로 쓰면 IME 조합이 깨질 이유가 없다.
|
|
// 아래는 그 대신 굵게/기울임 같은 툴바 버튼이 쓸 수 있게 선택 영역에 마크다운 문법을
|
|
// 직접 넣고 빼는 최소 구현이다 (journal_editor_toolbar.html에서 호출).
|
|
|
|
function fireInput(textarea) {
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
function insertAtCursor(textarea, text) {
|
|
var start = textarea.selectionStart;
|
|
var end = textarea.selectionEnd;
|
|
var value = textarea.value;
|
|
textarea.value = value.slice(0, start) + text + value.slice(end);
|
|
var pos = start + text.length;
|
|
textarea.selectionStart = textarea.selectionEnd = pos;
|
|
fireInput(textarea);
|
|
}
|
|
|
|
// 클립보드 이미지 붙여넣기용: 업로드 중 표식을 값에서 찾아 최종 마크다운 링크로 바꾼다.
|
|
// 고정된 커서 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가
|
|
// 다른 곳을 계속 타이핑해도 자리를 잃지 않는다. 표식이 이미 지워졌으면(사용자가 직접
|
|
// 지운 경우) 조용히 넘어간다.
|
|
function replaceMarker(textarea, marker, replacement) {
|
|
var idx = textarea.value.indexOf(marker);
|
|
if (idx === -1) return;
|
|
var before = textarea.value.slice(0, idx);
|
|
var after = textarea.value.slice(idx + marker.length);
|
|
textarea.value = before + replacement + after;
|
|
var pos = before.length + replacement.length;
|
|
textarea.selectionStart = textarea.selectionEnd = pos;
|
|
fireInput(textarea);
|
|
}
|
|
|
|
function wrapSelection(textarea, prefix, suffix) {
|
|
if (suffix === undefined) suffix = prefix;
|
|
var start = textarea.selectionStart;
|
|
var end = textarea.selectionEnd;
|
|
var value = textarea.value;
|
|
var selected = value.slice(start, end);
|
|
textarea.value = value.slice(0, start) + prefix + selected + suffix + value.slice(end);
|
|
textarea.selectionStart = start + prefix.length;
|
|
textarea.selectionEnd = start + prefix.length + selected.length;
|
|
textarea.focus();
|
|
fireInput(textarea);
|
|
}
|
|
|
|
// 커서(또는 선택 영역)가 걸쳐 있는 줄들의 범위를 구한다 — 글머리 기호/인용 등 줄 단위
|
|
// 토글에 쓰인다.
|
|
function currentLineRange(textarea) {
|
|
var start = textarea.selectionStart;
|
|
var end = textarea.selectionEnd;
|
|
var value = textarea.value;
|
|
var lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
|
var lineEnd = value.indexOf("\n", end);
|
|
if (lineEnd === -1) lineEnd = value.length;
|
|
return { lineStart: lineStart, lineEnd: lineEnd, block: value.slice(lineStart, lineEnd) };
|
|
}
|
|
|
|
function replaceBlock(textarea, range, newBlock) {
|
|
var value = textarea.value;
|
|
textarea.value = value.slice(0, range.lineStart) + newBlock + value.slice(range.lineEnd);
|
|
textarea.selectionStart = range.lineStart;
|
|
textarea.selectionEnd = range.lineStart + newBlock.length;
|
|
textarea.focus();
|
|
fireInput(textarea);
|
|
}
|
|
|
|
function toggleLinePrefix(textarea, prefix) {
|
|
var range = currentLineRange(textarea);
|
|
var lines = range.block.split("\n");
|
|
var allPrefixed = lines.every(function (line) { return line.indexOf(prefix) === 0; });
|
|
var newLines = lines.map(function (line) {
|
|
if (allPrefixed) return line.slice(prefix.length);
|
|
return line.indexOf(prefix) === 0 ? line : prefix + line;
|
|
});
|
|
replaceBlock(textarea, range, newLines.join("\n"));
|
|
}
|
|
|
|
function toggleOrderedList(textarea) {
|
|
var range = currentLineRange(textarea);
|
|
var lines = range.block.split("\n");
|
|
var re = /^\d+\.\s/;
|
|
var allNumbered = lines.every(function (line) { return re.test(line); });
|
|
var newLines = lines.map(function (line, i) {
|
|
return allNumbered ? line.replace(re, "") : (i + 1) + ". " + line.replace(re, "");
|
|
});
|
|
replaceBlock(textarea, range, newLines.join("\n"));
|
|
}
|
|
|
|
function toggleHeading(textarea) {
|
|
var range = currentLineRange(textarea);
|
|
var re = /^#{1,6}\s/;
|
|
var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block;
|
|
replaceBlock(textarea, range, newBlock);
|
|
}
|
|
|
|
function toggleCode(textarea) {
|
|
var start = textarea.selectionStart;
|
|
var end = textarea.selectionEnd;
|
|
var selected = textarea.value.slice(start, end);
|
|
if (selected.indexOf("\n") === -1) {
|
|
wrapSelection(textarea, "`");
|
|
return;
|
|
}
|
|
var value = textarea.value;
|
|
textarea.value = value.slice(0, start) + "```\n" + selected + "\n```" + value.slice(end);
|
|
textarea.selectionStart = start + 4;
|
|
textarea.selectionEnd = start + 4 + selected.length;
|
|
textarea.focus();
|
|
fireInput(textarea);
|
|
}
|
|
|
|
function insertLink(textarea) {
|
|
var start = textarea.selectionStart;
|
|
var end = textarea.selectionEnd;
|
|
var selected = textarea.value.slice(start, end) || "링크 텍스트";
|
|
var url = window.prompt("링크 주소를 입력하세요", "https://");
|
|
if (!url) return;
|
|
var value = textarea.value;
|
|
var markdown = "[" + selected + "](" + url + ")";
|
|
textarea.value = value.slice(0, start) + markdown + value.slice(end);
|
|
textarea.selectionStart = textarea.selectionEnd = start + markdown.length;
|
|
textarea.focus();
|
|
fireInput(textarea);
|
|
}
|
|
|
|
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
|
|
// 호출하는 공개 API.
|
|
window.JournalEditor = {
|
|
bold: function (t) { if (t) wrapSelection(t, "**"); },
|
|
italic: function (t) { if (t) wrapSelection(t, "*"); },
|
|
strike: function (t) { if (t) wrapSelection(t, "~~"); },
|
|
heading: function (t) { if (t) toggleHeading(t); },
|
|
quote: function (t) { if (t) toggleLinePrefix(t, "> "); },
|
|
ul: function (t) { if (t) toggleLinePrefix(t, "- "); },
|
|
ol: function (t) { if (t) toggleOrderedList(t); },
|
|
code: function (t) { if (t) toggleCode(t); },
|
|
link: function (t) { if (t) insertLink(t); },
|
|
setValue: function (t, value) {
|
|
if (!t) return;
|
|
t.value = value;
|
|
fireInput(t);
|
|
},
|
|
};
|
|
|
|
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
|
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
|
// 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
|
|
function handleImagePaste(textarea, 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 marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
|
|
insertAtCursor(textarea, marker);
|
|
|
|
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) {
|
|
replaceMarker(textarea, marker, "");
|
|
})
|
|
.catch(function (err) {
|
|
replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")");
|
|
});
|
|
}
|
|
|
|
function initEditors(root) {
|
|
var scope = root instanceof Element ? root : document;
|
|
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])");
|
|
|
|
textareas.forEach(function (textarea) {
|
|
textarea.setAttribute("data-journal-editor-initialized", "true");
|
|
textarea.addEventListener("paste", function (event) { handleImagePaste(textarea, event); });
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initEditors(document);
|
|
});
|
|
document.body.addEventListener("htmx:afterSwap", function (evt) {
|
|
initEditors(evt.target);
|
|
});
|
|
})();
|