keep EasyMDE on desktop/Android, fall back to plain textarea only on iOS

Dropping EasyMDE entirely (previous commit) fixed the Korean IME jamo split
but also removed the live markdown syntax highlighting everywhere, even on
platforms that never had the bug. Only iOS WebKit breaks CJK IME composition
under CodeMirror, so journal-editor.js now detects iOS (including the
desktop-UA-masquerading iPad, via MacIntel + multi-touch) and only skips
EasyMDE there, falling back to a native <textarea>. Everywhere else keeps
the full EasyMDE/CodeMirror editing experience as before.

window.JournalEditor now dispatches to the right engine per textarea
(checking t._easymde) so the toolbar buttons and the category-template
autofill in journal.html work unchanged regardless of which engine backs a
given textarea. Restores the easymde vendor files, base.html includes, and
service worker precache entries removed in the previous commit (cache
bumped to v8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 08:16:06 +09:00
co-authored by Claude Sonnet 5
parent f524157326
commit 6000de166f
7 changed files with 192 additions and 59 deletions
+33 -5
View File
@@ -954,11 +954,8 @@ label {
font-size: 14px; font-size: 14px;
} }
/* 마크다운 에디터 툴바 — 예전엔 EasyMDE(CodeMirror) 위에 얹었던 자체 버튼이었는데, iOS에서 /* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
CodeMirror가 한글 조합(IME) 중에 화면을 다시 그리면서 자소분리를 일으키는 문제 때문에 EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
에디터를 순수 <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;
@@ -982,6 +979,37 @@ label {
background: var(--color-bg); background: var(--color-bg);
} }
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다. iOS에서는
app/static/js/journal-editor.js가 EasyMDE 대신 순수 <textarea>로 폴백하므로(한글 IME
자소분리 회피) 이 규칙은 그 경우 그냥 매칭되지 않는다 — .markdown-editor 자체는 위 공통
textarea 규칙을 그대로 물려받아 별도 스타일 없이도 정상적으로 보인다. */
.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
+139 -52
View File
@@ -1,17 +1,34 @@
(function () { (function () {
// 예전엔 이 파일이 EasyMDE(CodeMirror 5 기반 마크다운 에디터)를 초기화했는데, iOS Safari에서 // 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
// 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME 입력 중에 CodeMirror가 화면을 //
// 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로 남는 문제(자소분리)가 있었다. // EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
// inputStyle을 contenteditable로 강제해도 아이폰에서까지 재현돼서(CodeMirror5 자체의 CJK // 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
// IME 한계로 보임), 에디터 엔진을 아예 걷어내고 순수 <textarea>(.markdown-editor) // 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채
// 되돌렸다 — 브라우저 네이티브 입력 처리를 그대로 쓰면 IME 조합이 깨질 이유가 없다. // 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
// 아래는 그 대신 굵게/기울임 같은 툴바 버튼이 쓸 수 있게 선택 영역에 마크다운 문법을 // 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
// 직접 넣고 빼는 최소 구현이다 (journal_editor_toolbar.html에서 호출). // <textarea>로 폴백한다(브라우저 네이티브 입력 처리를 그대로 쓰면 IME가 깨질 이유가
// 없다). PC/안드로이드 등 iOS가 아닌 환경은 지금까지처럼 EasyMDE를 그대로 쓴다.
//
// 굵게/기울임 같은 툴바 버튼과 카테고리 템플릿 자동 채우기는 window.JournalEditor를
// 통해 호출되는데, 이 객체가 각 textarea에 EasyMDE가 붙어있는지(t._easymde) 보고
// EasyMDE 명령 또는 아래의 직접 선택 영역 조작 중 알맞은 쪽으로 위임한다 — 호출하는
// 템플릿 쪽(journal_editor_toolbar.html, journal.html)은 어느 엔진이 쓰이는지 몰라도 된다.
function isIOS() {
var ua = navigator.userAgent || "";
if (/iPad|iPhone|iPod/.test(ua)) return true;
// iPadOS 13+는 기본 설정에서 데스크톱 Safari인 척하는 UA를 보낸다("Macintosh"로 위장,
// "Request Mobile Website"를 켜지 않는 한). 이런 위장 아이패드를 잡아내는 표준적인
// 방법은 "Mac인데 멀티터치가 된다"는 조합을 보는 것이다(실제 맥은 터치스크린이 없음).
return navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
}
function fireInput(textarea) { function fireInput(textarea) {
textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.dispatchEvent(new Event("input", { bubbles: true }));
} }
// ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
function insertAtCursor(textarea, text) { function insertAtCursor(textarea, text) {
var start = textarea.selectionStart; var start = textarea.selectionStart;
var end = textarea.selectionEnd; var end = textarea.selectionEnd;
@@ -22,10 +39,6 @@
fireInput(textarea); fireInput(textarea);
} }
// 클립보드 이미지 붙여넣기용: 업로드 중 표식을 값에서 찾아 최종 마크다운 링크로 바꾼다.
// 고정된 커서 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가
// 다른 곳을 계속 타이핑해도 자리를 잃지 않는다. 표식이 이미 지워졌으면(사용자가 직접
// 지운 경우) 조용히 넘어간다.
function replaceMarker(textarea, marker, replacement) { function replaceMarker(textarea, marker, replacement) {
var idx = textarea.value.indexOf(marker); var idx = textarea.value.indexOf(marker);
if (idx === -1) return; if (idx === -1) return;
@@ -50,8 +63,6 @@
fireInput(textarea); fireInput(textarea);
} }
// 커서(또는 선택 영역)가 걸쳐 있는 줄들의 범위를 구한다 — 글머리 기호/인용 등 줄 단위
// 토글에 쓰인다.
function currentLineRange(textarea) { function currentLineRange(textarea) {
var start = textarea.selectionStart; var start = textarea.selectionStart;
var end = textarea.selectionEnd; var end = textarea.selectionEnd;
@@ -82,7 +93,7 @@
replaceBlock(textarea, range, newLines.join("\n")); replaceBlock(textarea, range, newLines.join("\n"));
} }
function toggleOrderedList(textarea) { function toggleOrderedListPlain(textarea) {
var range = currentLineRange(textarea); var range = currentLineRange(textarea);
var lines = range.block.split("\n"); var lines = range.block.split("\n");
var re = /^\d+\.\s/; var re = /^\d+\.\s/;
@@ -93,14 +104,14 @@
replaceBlock(textarea, range, newLines.join("\n")); replaceBlock(textarea, range, newLines.join("\n"));
} }
function toggleHeading(textarea) { function toggleHeadingPlain(textarea) {
var range = currentLineRange(textarea); var range = currentLineRange(textarea);
var re = /^#{1,6}\s/; var re = /^#{1,6}\s/;
var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block; var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block;
replaceBlock(textarea, range, newBlock); replaceBlock(textarea, range, newBlock);
} }
function toggleCode(textarea) { function toggleCodePlain(textarea) {
var start = textarea.selectionStart; var start = textarea.selectionStart;
var end = textarea.selectionEnd; var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end); var selected = textarea.value.slice(start, end);
@@ -116,7 +127,7 @@
fireInput(textarea); fireInput(textarea);
} }
function insertLink(textarea) { function insertLinkPlain(textarea) {
var start = textarea.selectionStart; var start = textarea.selectionStart;
var end = textarea.selectionEnd; var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end) || "링크 텍스트"; var selected = textarea.value.slice(start, end) || "링크 텍스트";
@@ -131,70 +142,146 @@
} }
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가 // journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
// 호출하는 공개 API. // 호출하는 공개 API — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
// 판단해서 알맞은 쪽으로 위임한다.
window.JournalEditor = { window.JournalEditor = {
bold: function (t) { if (t) wrapSelection(t, "**"); }, bold: function (t) { if (t) (t._easymde ? t._easymde.toggleBold() : wrapSelection(t, "**")); },
italic: function (t) { if (t) wrapSelection(t, "*"); }, italic: function (t) { if (t) (t._easymde ? t._easymde.toggleItalic() : wrapSelection(t, "*")); },
strike: function (t) { if (t) wrapSelection(t, "~~"); }, strike: function (t) { if (t) (t._easymde ? t._easymde.toggleStrikethrough() : wrapSelection(t, "~~")); },
heading: function (t) { if (t) toggleHeading(t); }, heading: function (t) { if (t) (t._easymde ? t._easymde.toggleHeadingSmaller() : toggleHeadingPlain(t)); },
quote: function (t) { if (t) toggleLinePrefix(t, "> "); }, quote: function (t) { if (t) (t._easymde ? t._easymde.toggleBlockquote() : toggleLinePrefix(t, "> ")); },
ul: function (t) { if (t) toggleLinePrefix(t, "- "); }, ul: function (t) { if (t) (t._easymde ? t._easymde.toggleUnorderedList() : toggleLinePrefix(t, "- ")); },
ol: function (t) { if (t) toggleOrderedList(t); }, ol: function (t) { if (t) (t._easymde ? t._easymde.toggleOrderedList() : toggleOrderedListPlain(t)); },
code: function (t) { if (t) toggleCode(t); }, code: function (t) { if (t) (t._easymde ? t._easymde.toggleCodeBlock() : toggleCodePlain(t)); },
link: function (t) { if (t) insertLink(t); }, link: function (t) { if (t) (t._easymde ? t._easymde.drawLink() : insertLinkPlain(t)); },
setValue: function (t, value) { setValue: function (t, value) {
if (!t) return; if (!t) return;
t.value = value; t.value = value;
if (t._easymde) t._easymde.value(value);
fireInput(t); fireInput(t);
}, },
}; };
// 클립보드 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고 // ---- 클립보드 이미지 붙여넣기 ----
// 그 자리에 마크다운 이미지 문법(![](url))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서 // 스크린샷/사진을 그대로 붙여넣기 대신 서버에 업로드하고 그 자리에 마크다운 이미지
// 기본 텍스트 붙여넣기가 그대로 동작하게 둔다. // 문법(![](url))을 끼워넣는다. 두 엔진 다 업로드 로직은 같고, "지금 커서 위치를 어떻게
function handleImagePaste(textarea, event) { // 표시해뒀다가 나중에 찾아서 바꿔치기하는지"만 다르다.
function uploadPastedImage(file) {
var formData = new FormData();
formData.append("file", file, file.name || "pasted-image.png");
return 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();
});
}
function extractImageFile(event) {
var items = event.clipboardData && event.clipboardData.items; var items = event.clipboardData && event.clipboardData.items;
if (!items) return; if (!items) return null;
var imageItem = null;
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image/") === 0) { if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
imageItem = items[i];
break;
}
} }
if (!imageItem) return; return null;
}
event.preventDefault(); // 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
var file = imageItem.getAsFile(); // 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
// 계속 타이핑해도 자리를 잃지 않는다.
function handlePlainImagePaste(textarea, event) {
var file = extractImageFile(event);
if (!file) return; if (!file) return;
event.preventDefault();
var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()"; var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
insertAtCursor(textarea, marker); insertAtCursor(textarea, marker);
var formData = new FormData(); uploadPastedImage(file)
formData.append("file", file, file.name || "pasted-image.png"); .then(function (data) { replaceMarker(textarea, marker, "![](" + data.url + ")"); })
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
}
fetch("/api/journal/paste-image", { method: "POST", body: formData }) // EasyMDE 모드: CodeMirror 북마크로 위치를 추적한다(원본 로직 그대로).
.then(function (res) { function handleCodeMirrorImagePaste(cm, event) {
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); }); var file = extractImageFile(event);
return res.json(); if (!file) return;
}) event.preventDefault();
var doc = cm.getDoc();
var from = doc.getCursor();
var placeholder = "![업로드 중...]()";
doc.replaceRange(placeholder, from);
var to = { line: from.line, ch: from.ch + placeholder.length };
var startMark = doc.setBookmark(from);
var endMark = doc.setBookmark(to);
uploadPastedImage(file)
.then(function (data) { .then(function (data) {
replaceMarker(textarea, marker, "![](" + data.url + ")"); var start = startMark.find();
var end = endMark.find();
if (start && end) doc.replaceRange("![](" + data.url + ")", start, end);
}) })
.catch(function (err) { .catch(function (err) {
replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); 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 }));
}); });
} }
function initEasyMDE(textarea) {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을
// 전제로 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal_editor_toolbar.html의
// 자체 버튼이 위 JournalEditor를 통해 EasyMDE 인스턴스 메서드를 호출한다.
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는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
fireInput(textarea);
});
easymde.codemirror.on("paste", function (cm, event) {
handleCodeMirrorImagePaste(cm, event);
});
textarea._easymde = easymde;
}
function initPlainTextarea(textarea) {
textarea.addEventListener("paste", function (event) { handlePlainImagePaste(textarea, event); });
}
function initEditors(root) { function initEditors(root) {
var scope = root instanceof Element ? root : document; var scope = root instanceof Element ? root : document;
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])"); var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])");
textareas.forEach(function (textarea) { textareas.forEach(function (textarea) {
textarea.setAttribute("data-journal-editor-initialized", "true"); textarea.setAttribute("data-journal-editor-initialized", "true");
textarea.addEventListener("paste", function (event) { handleImagePaste(textarea, event); }); if (typeof EasyMDE !== "undefined" && !isIOS()) {
initEasyMDE(textarea);
} else {
initPlainTextarea(textarea);
}
}); });
} }
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,6 +1,7 @@
const CACHE_NAME = "habit-tracker-v7"; const CACHE_NAME = "habit-tracker-v8";
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",
@@ -9,6 +10,7 @@ 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,11 +18,13 @@
<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 -1
View File
@@ -134,7 +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.$refs.contentField.dispatchEvent(new Event('input')); }); this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
} }
}, },
init() { this.onCategoryChange(this.$refs.categorySelect.value); }, init() { this.onCategoryChange(this.$refs.categorySelect.value); },