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:
@@ -954,11 +954,8 @@ label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 마크다운 에디터 툴바 — 예전엔 EasyMDE(CodeMirror) 위에 얹었던 자체 버튼이었는데, iOS에서
|
||||
CodeMirror가 한글 조합(IME) 중에 화면을 다시 그리면서 자소분리를 일으키는 문제 때문에
|
||||
에디터를 순수 <textarea>(.markdown-editor)로 되돌렸다 — app/static/js/journal-editor.js의
|
||||
JournalEditor가 선택 영역에 마크다운 문법을 직접 넣고 빼는 방식으로 같은 버튼들을 지원한다.
|
||||
.markdown-editor 자체는 위 공통 textarea 규칙을 그대로 물려받아 별도 스타일이 필요 없다. */
|
||||
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
|
||||
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
|
||||
.markdown-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -982,6 +979,37 @@ label {
|
||||
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 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
+7
File diff suppressed because one or more lines are too long
+139
-52
@@ -1,17 +1,34 @@
|
||||
(function () {
|
||||
// 예전엔 이 파일이 EasyMDE(CodeMirror 5 기반 마크다운 에디터)를 초기화했는데, iOS Safari에서
|
||||
// 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME 입력 중에 CodeMirror가 화면을
|
||||
// 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로 남는 문제(자소분리)가 있었다.
|
||||
// inputStyle을 contenteditable로 강제해도 아이폰에서까지 재현돼서(CodeMirror5 자체의 CJK
|
||||
// IME 한계로 보임), 에디터 엔진을 아예 걷어내고 순수 <textarea>(.markdown-editor)로
|
||||
// 되돌렸다 — 브라우저 네이티브 입력 처리를 그대로 쓰면 IME 조합이 깨질 이유가 없다.
|
||||
// 아래는 그 대신 굵게/기울임 같은 툴바 버튼이 쓸 수 있게 선택 영역에 마크다운 문법을
|
||||
// 직접 넣고 빼는 최소 구현이다 (journal_editor_toolbar.html에서 호출).
|
||||
// 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
|
||||
//
|
||||
// EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
|
||||
// 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
|
||||
// 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
|
||||
// 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
|
||||
// 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
|
||||
// <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) {
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
// ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
|
||||
|
||||
function insertAtCursor(textarea, text) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
@@ -22,10 +39,6 @@
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
// 클립보드 이미지 붙여넣기용: 업로드 중 표식을 값에서 찾아 최종 마크다운 링크로 바꾼다.
|
||||
// 고정된 커서 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가
|
||||
// 다른 곳을 계속 타이핑해도 자리를 잃지 않는다. 표식이 이미 지워졌으면(사용자가 직접
|
||||
// 지운 경우) 조용히 넘어간다.
|
||||
function replaceMarker(textarea, marker, replacement) {
|
||||
var idx = textarea.value.indexOf(marker);
|
||||
if (idx === -1) return;
|
||||
@@ -50,8 +63,6 @@
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
// 커서(또는 선택 영역)가 걸쳐 있는 줄들의 범위를 구한다 — 글머리 기호/인용 등 줄 단위
|
||||
// 토글에 쓰인다.
|
||||
function currentLineRange(textarea) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
@@ -82,7 +93,7 @@
|
||||
replaceBlock(textarea, range, newLines.join("\n"));
|
||||
}
|
||||
|
||||
function toggleOrderedList(textarea) {
|
||||
function toggleOrderedListPlain(textarea) {
|
||||
var range = currentLineRange(textarea);
|
||||
var lines = range.block.split("\n");
|
||||
var re = /^\d+\.\s/;
|
||||
@@ -93,14 +104,14 @@
|
||||
replaceBlock(textarea, range, newLines.join("\n"));
|
||||
}
|
||||
|
||||
function toggleHeading(textarea) {
|
||||
function toggleHeadingPlain(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) {
|
||||
function toggleCodePlain(textarea) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.slice(start, end);
|
||||
@@ -116,7 +127,7 @@
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
function insertLink(textarea) {
|
||||
function insertLinkPlain(textarea) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.slice(start, end) || "링크 텍스트";
|
||||
@@ -131,70 +142,146 @@
|
||||
}
|
||||
|
||||
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
|
||||
// 호출하는 공개 API.
|
||||
// 호출하는 공개 API — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
|
||||
// 판단해서 알맞은 쪽으로 위임한다.
|
||||
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); },
|
||||
bold: function (t) { if (t) (t._easymde ? t._easymde.toggleBold() : wrapSelection(t, "**")); },
|
||||
italic: function (t) { if (t) (t._easymde ? t._easymde.toggleItalic() : wrapSelection(t, "*")); },
|
||||
strike: function (t) { if (t) (t._easymde ? t._easymde.toggleStrikethrough() : wrapSelection(t, "~~")); },
|
||||
heading: function (t) { if (t) (t._easymde ? t._easymde.toggleHeadingSmaller() : toggleHeadingPlain(t)); },
|
||||
quote: function (t) { if (t) (t._easymde ? t._easymde.toggleBlockquote() : toggleLinePrefix(t, "> ")); },
|
||||
ul: function (t) { if (t) (t._easymde ? t._easymde.toggleUnorderedList() : toggleLinePrefix(t, "- ")); },
|
||||
ol: function (t) { if (t) (t._easymde ? t._easymde.toggleOrderedList() : toggleOrderedListPlain(t)); },
|
||||
code: function (t) { if (t) (t._easymde ? t._easymde.toggleCodeBlock() : toggleCodePlain(t)); },
|
||||
link: function (t) { if (t) (t._easymde ? t._easymde.drawLink() : insertLinkPlain(t)); },
|
||||
setValue: function (t, value) {
|
||||
if (!t) return;
|
||||
t.value = value;
|
||||
if (t._easymde) t._easymde.value(value);
|
||||
fireInput(t);
|
||||
},
|
||||
};
|
||||
|
||||
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
||||
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
||||
// 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
|
||||
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;
|
||||
if (!items) return;
|
||||
|
||||
var imageItem = null;
|
||||
if (!items) return null;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image/") === 0) {
|
||||
imageItem = items[i];
|
||||
break;
|
||||
}
|
||||
if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
|
||||
}
|
||||
if (!imageItem) return;
|
||||
return null;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
var file = imageItem.getAsFile();
|
||||
// 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
|
||||
// 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
|
||||
// 계속 타이핑해도 자리를 잃지 않는다.
|
||||
function handlePlainImagePaste(textarea, event) {
|
||||
var file = extractImageFile(event);
|
||||
if (!file) return;
|
||||
event.preventDefault();
|
||||
|
||||
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");
|
||||
uploadPastedImage(file)
|
||||
.then(function (data) { replaceMarker(textarea, marker, ""); })
|
||||
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
|
||||
}
|
||||
|
||||
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();
|
||||
})
|
||||
// EasyMDE 모드: CodeMirror 북마크로 위치를 추적한다(원본 로직 그대로).
|
||||
function handleCodeMirrorImagePaste(cm, event) {
|
||||
var file = extractImageFile(event);
|
||||
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) {
|
||||
replaceMarker(textarea, marker, "");
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) doc.replaceRange("", start, end);
|
||||
})
|
||||
.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) {
|
||||
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); });
|
||||
if (typeof EasyMDE !== "undefined" && !isIOS()) {
|
||||
initEasyMDE(textarea);
|
||||
} else {
|
||||
initPlainTextarea(textarea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+7
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
const CACHE_NAME = "habit-tracker-v7";
|
||||
const CACHE_NAME = "habit-tracker-v8";
|
||||
const APP_SHELL = [
|
||||
"/static/css/style.css",
|
||||
"/static/css/vendor/easymde.min.css",
|
||||
"/static/js/app.js",
|
||||
"/static/js/push-register.js",
|
||||
"/static/js/habit-reorder.js",
|
||||
@@ -9,6 +10,7 @@ const APP_SHELL = [
|
||||
"/static/js/vendor/htmx.min.js",
|
||||
"/static/js/vendor/alpine.min.js",
|
||||
"/static/js/vendor/sortable.min.js",
|
||||
"/static/js/vendor/easymde.min.js",
|
||||
"/static/icons/icon-192.png",
|
||||
"/static/icons/icon-512.png",
|
||||
"/static/icons/icon-apple-180.png",
|
||||
|
||||
Reference in New Issue
Block a user