Files
habit-tracker/app/static/js/journal-editor.js
T
shinalokandClaude Sonnet 5 6000de166f 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>
2026-08-27 08:16:06 +09:00

295 lines
13 KiB
JavaScript

(function () {
// 저널 본문 입력창(.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;
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 toggleOrderedListPlain(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 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 toggleCodePlain(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 insertLinkPlain(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 — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
// 판단해서 알맞은 쪽으로 위임한다.
window.JournalEditor = {
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);
},
};
// ---- 클립보드 이미지 붙여넣기 ----
// 스크린샷/사진을 그대로 붙여넣기 대신 서버에 업로드하고 그 자리에 마크다운 이미지
// 문법(![](url))을 끼워넣는다. 두 엔진 다 업로드 로직은 같고, "지금 커서 위치를 어떻게
// 표시해뒀다가 나중에 찾아서 바꿔치기하는지"만 다르다.
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 null;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
}
return null;
}
// 순수 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);
uploadPastedImage(file)
.then(function (data) { replaceMarker(textarea, marker, "![](" + data.url + ")"); })
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
}
// 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) {
var start = startMark.find();
var end = endMark.find();
if (start && end) doc.replaceRange("![](" + data.url + ")", 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 }));
});
}
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");
if (typeof EasyMDE !== "undefined" && !isIOS()) {
initEasyMDE(textarea);
} else {
initPlainTextarea(textarea);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
initEditors(document);
});
document.body.addEventListener("htmx:afterSwap", function (evt) {
initEditors(evt.target);
});
})();