replace EasyMDE journal editor with plain textarea to fix iOS Korean IME jamo split

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>
This commit is contained in:
2026-08-26 20:23:09 +09:00
co-authored by Claude Sonnet 5
parent 291813820b
commit ac98cf4f99
8 changed files with 179 additions and 133 deletions
+5 -30
View File
@@ -954,8 +954,11 @@ label {
font-size: 14px; font-size: 14px;
} }
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 — /* 마크다운 에디터 툴바 — 예전엔 EasyMDE(CodeMirror) 위에 얹었던 자체 버튼이었는데, iOS에서
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */ CodeMirror가 한글 조합(IME) 중에 화면을 다시 그리면서 자소분리를 일으키는 문제 때문에
에디터를 순수 <textarea>(.markdown-editor)로 되돌렸다 — app/static/js/journal-editor.js의
JournalEditor가 선택 영역에 마크다운 문법을 직접 넣고 빼는 방식으로 같은 버튼들을 지원한다.
.markdown-editor 자체는 위 공통 textarea 규칙을 그대로 물려받아 별도 스타일이 필요 없다. */
.markdown-toolbar { .markdown-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -979,34 +982,6 @@ label {
background: var(--color-bg); background: var(--color-bg);
} }
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다 */
.markdown-editor + .EasyMDEContainer .CodeMirror {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 15px;
padding: 6px 8px;
overflow: hidden; /* 안에서 뭐가 카드 폭보다 커지려 해도 밖으로 안 새어나가게 */
}
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
border-left-color: var(--color-text);
}
.markdown-editor + .EasyMDEContainer .editor-statusbar {
color: var(--color-text-muted);
}
/* 원본 해상도가 큰 이미지를 붙여넣었을 때 에디터/미리보기 폭을 넘어가지 않게 캡핑.
.journal-entry-content img가 미리보기(.journal-preview)는 이미 커버하지만, 에디터 쪽
(CodeMirror가 마크다운 이미지를 인라인 위젯으로 그리는 경우)도 같은 규칙을 강제로 적용. */
.markdown-editor + .EasyMDEContainer .CodeMirror img {
max-width: 100% !important;
height: auto !important;
}
.journal-entry-content > *:first-child { .journal-entry-content > *:first-child {
margin-top: 0; margin-top: 0;
} }
File diff suppressed because one or more lines are too long
+163 -71
View File
@@ -1,59 +1,158 @@
(function () { (function () {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로 // 예전엔 이 파일이 EasyMDE(CodeMirror 5 기반 마크다운 에디터)를 초기화했는데, iOS Safari에서
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서 // 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME 입력 중에 CodeMirror가 화면을
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다. // 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로 남는 문제(자소분리)가 있었다.
function initEditors(root) { // inputStyle을 contenteditable로 강제해도 아이폰에서까지 재현돼서(CodeMirror5 자체의 CJK
if (typeof EasyMDE === "undefined") return; // IME 한계로 보임), 에디터 엔진을 아예 걷어내고 순수 <textarea>(.markdown-editor)로
var scope = root instanceof Element ? root : document; // 되돌렸다 — 브라우저 네이티브 입력 처리를 그대로 쓰면 IME 조합이 깨질 이유가 없다.
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])"); // 아래는 그 대신 굵게/기울임 같은 툴바 버튼이 쓸 수 있게 선택 영역에 마크다운 문법을
// 직접 넣고 빼는 최소 구현이다 (journal_editor_toolbar.html에서 호출).
textareas.forEach(function (textarea) { function fireInput(textarea) {
textarea.setAttribute("data-easymde-initialized", "true"); textarea.dispatchEvent(new Event("input", { bubbles: 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;
});
} }
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);
},
};
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고 // 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
// 그 자리에 마크다운 이미지 문법(![](url))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서 // 그 자리에 마크다운 이미지 문법(![](url))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다. // 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
function handleImagePaste(cm, event) { function handleImagePaste(textarea, event) {
var items = event.clipboardData && event.clipboardData.items; var items = event.clipboardData && event.clipboardData.items;
if (!items) return; if (!items) return;
@@ -70,15 +169,8 @@
var file = imageItem.getAsFile(); var file = imageItem.getAsFile();
if (!file) return; if (!file) return;
var doc = cm.getDoc(); var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
var from = doc.getCursor(); insertAtCursor(textarea, marker);
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(); var formData = new FormData();
formData.append("file", file, file.name || "pasted-image.png"); formData.append("file", file, file.name || "pasted-image.png");
@@ -89,23 +181,23 @@
return res.json(); return res.json();
}) })
.then(function (data) { .then(function (data) {
var start = startMark.find(); replaceMarker(textarea, marker, "![](" + data.url + ")");
var end = endMark.find();
if (start && end) { doc.replaceRange("![](" + data.url + ")", start, end); }
}) })
.catch(function (err) { .catch(function (err) {
var start = startMark.find(); replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")");
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 }));
}); });
} }
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 () { document.addEventListener("DOMContentLoaded", function () {
initEditors(document); initEditors(document);
}); });
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -1,7 +1,6 @@
const CACHE_NAME = "habit-tracker-v6"; const CACHE_NAME = "habit-tracker-v7";
const APP_SHELL = [ const APP_SHELL = [
"/static/css/style.css", "/static/css/style.css",
"/static/css/vendor/easymde.min.css",
"/static/js/app.js", "/static/js/app.js",
"/static/js/push-register.js", "/static/js/push-register.js",
"/static/js/habit-reorder.js", "/static/js/habit-reorder.js",
@@ -10,7 +9,6 @@ const APP_SHELL = [
"/static/js/vendor/htmx.min.js", "/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js", "/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js", "/static/js/vendor/sortable.min.js",
"/static/js/vendor/easymde.min.js",
"/static/icons/icon-192.png", "/static/icons/icon-192.png",
"/static/icons/icon-512.png", "/static/icons/icon-512.png",
"/static/icons/icon-apple-180.png", "/static/icons/icon-apple-180.png",
-2
View File
@@ -18,13 +18,11 @@
<link rel="icon" href="/static/icons/icon-192.png" /> <link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" /> <link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/vendor/easymde.min.css" />
<script src="/static/js/vendor/htmx.min.js" defer></script> <script src="/static/js/vendor/htmx.min.js" defer></script>
<script src="/static/js/push-register.js" defer></script> <script src="/static/js/push-register.js" defer></script>
<script src="/static/js/vendor/sortable.min.js" defer></script> <script src="/static/js/vendor/sortable.min.js" defer></script>
<script src="/static/js/habit-reorder.js" defer></script> <script src="/static/js/habit-reorder.js" defer></script>
<script src="/static/js/journal-category-reorder.js" defer></script> <script src="/static/js/journal-category-reorder.js" defer></script>
<script src="/static/js/vendor/easymde.min.js" defer></script>
<script src="/static/js/journal-editor.js" defer></script> <script src="/static/js/journal-editor.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script> <script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
+1 -4
View File
@@ -134,10 +134,7 @@
if (this.content === this.lastAutoFilled) { if (this.content === this.lastAutoFilled) {
this.content = tmpl; this.content = tmpl;
this.lastAutoFilled = tmpl; this.lastAutoFilled = tmpl;
this.$nextTick(() => { this.$nextTick(() => { this.$refs.contentField.dispatchEvent(new Event('input')); });
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
this.$refs.contentField.dispatchEvent(new Event('input'));
});
} }
}, },
init() { this.onCategoryChange(this.$refs.categorySelect.value); }, init() { this.onCategoryChange(this.$refs.categorySelect.value); },
@@ -1,14 +1,14 @@
<div class="markdown-toolbar"> <div class="markdown-toolbar">
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;"> <span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
<button type="button" class="md-tool-btn" title="굵게" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBold()"><strong>B</strong></button> <button type="button" class="md-tool-btn" title="굵게" @click="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><strong>B</strong></button>
<button type="button" class="md-tool-btn" title="기울임" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleItalic()"><em>I</em></button> <button type="button" class="md-tool-btn" title="기울임" @click="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><em>I</em></button>
<button type="button" class="md-tool-btn" title="취소선" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleStrikethrough()"><s>S</s></button> <button type="button" class="md-tool-btn" title="취소선" @click="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><s>S</s></button>
<button type="button" class="md-tool-btn" title="제목" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleHeadingSmaller()">H</button> <button type="button" class="md-tool-btn" title="제목" @click="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">H</button>
<button type="button" class="md-tool-btn" title="인용" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBlockquote()"></button> <button type="button" class="md-tool-btn" title="인용" @click="JournalEditor.quote($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="글머리 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleUnorderedList()"></button> <button type="button" class="md-tool-btn" title="글머리 목록" @click="JournalEditor.ul($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="번호 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleOrderedList()">1.</button> <button type="button" class="md-tool-btn" title="번호 목록" @click="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">1.</button>
<button type="button" class="md-tool-btn" title="코드" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleCodeBlock()">&lt;/&gt;</button> <button type="button" class="md-tool-btn" title="코드" @click="JournalEditor.code($el.closest('.field').querySelector('textarea'))">&lt;/&gt;</button>
<button type="button" class="md-tool-btn" title="링크" @click="$el.closest('.field').querySelector('textarea')._easymde?.drawLink()">🔗</button> <button type="button" class="md-tool-btn" title="링크" @click="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
</span> </span>
<button <button
type="button" type="button"