Merge branch 'remove-easymde-fix-ime' into main

This commit is contained in:
2026-08-26 20:23:13 +09:00
8 changed files with 179 additions and 133 deletions
+5 -30
View File
@@ -954,8 +954,11 @@ label {
font-size: 14px;
}
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
/* 마크다운 에디터 툴바 — 예전엔 EasyMDE(CodeMirror) 위에 얹었던 자체 버튼이었는데, iOS에서
CodeMirror가 한글 조합(IME) 중에 화면을 다시 그리면서 자소분리를 일으키는 문제 때문에
에디터를 순수 <textarea>(.markdown-editor)로 되돌렸다 — app/static/js/journal-editor.js의
JournalEditor가 선택 영역에 마크다운 문법을 직접 넣고 빼는 방식으로 같은 버튼들을 지원한다.
.markdown-editor 자체는 위 공통 textarea 규칙을 그대로 물려받아 별도 스타일이 필요 없다. */
.markdown-toolbar {
display: flex;
flex-wrap: wrap;
@@ -979,34 +982,6 @@ label {
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 {
margin-top: 0;
}
File diff suppressed because one or more lines are too long
+162 -70
View File
@@ -1,59 +1,158 @@
(function () {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다.
function initEditors(root) {
if (typeof EasyMDE === "undefined") return;
var scope = root instanceof Element ? root : document;
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])");
// 예전엔 이 파일이 EasyMDE(CodeMirror 5 기반 마크다운 에디터)를 초기화했는데, iOS Safari에서
// 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME 입력 중에 CodeMirror가 화면을
// 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로 남는 문제(자소분리)가 있었다.
// inputStyle을 contenteditable로 강제해도 아이폰에서까지 재현돼서(CodeMirror5 자체의 CJK
// IME 한계로 보임), 에디터 엔진을 아예 걷어내고 순수 <textarea>(.markdown-editor)로
// 되돌렸다 — 브라우저 네이티브 입력 처리를 그대로 쓰면 IME 조합이 깨질 이유가 없다.
// 아래는 그 대신 굵게/기울임 같은 툴바 버튼이 쓸 수 있게 선택 영역에 마크다운 문법을
// 직접 넣고 빼는 최소 구현이다 (journal_editor_toolbar.html에서 호출).
textareas.forEach(function (textarea) {
textarea.setAttribute("data-easymde-initialized", "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();
function fireInput(textarea) {
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))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
function handleImagePaste(cm, event) {
// 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
function handleImagePaste(textarea, event) {
var items = event.clipboardData && event.clipboardData.items;
if (!items) return;
@@ -70,15 +169,8 @@
var file = imageItem.getAsFile();
if (!file) return;
var doc = cm.getDoc();
var from = doc.getCursor();
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 marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
insertAtCursor(textarea, marker);
var formData = new FormData();
formData.append("file", file, file.name || "pasted-image.png");
@@ -89,20 +181,20 @@
return res.json();
})
.then(function (data) {
var start = startMark.find();
var end = endMark.find();
if (start && end) { doc.replaceRange("![](" + data.url + ")", start, end); }
replaceMarker(textarea, marker, "![](" + data.url + ")");
})
.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 }));
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); });
});
}
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 = [
"/static/css/style.css",
"/static/css/vendor/easymde.min.css",
"/static/js/app.js",
"/static/js/push-register.js",
"/static/js/habit-reorder.js",
@@ -10,7 +9,6 @@ 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",
-2
View File
@@ -18,13 +18,11 @@
<link rel="icon" href="/static/icons/icon-192.png" />
<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/push-register.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/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/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script>
+1 -4
View File
@@ -134,10 +134,7 @@
if (this.content === this.lastAutoFilled) {
this.content = tmpl;
this.lastAutoFilled = tmpl;
this.$nextTick(() => {
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
this.$refs.contentField.dispatchEvent(new Event('input'));
});
this.$nextTick(() => { this.$refs.contentField.dispatchEvent(new Event('input')); });
}
},
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
@@ -1,14 +1,14 @@
<div class="markdown-toolbar">
<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="$el.closest('.field').querySelector('textarea')._easymde?.toggleItalic()"><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="$el.closest('.field').querySelector('textarea')._easymde?.toggleHeadingSmaller()">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="$el.closest('.field').querySelector('textarea')._easymde?.toggleUnorderedList()"></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="$el.closest('.field').querySelector('textarea')._easymde?.toggleCodeBlock()">&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.bold($el.closest('.field').querySelector('textarea'))"><strong>B</strong></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="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><s>S</s></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="JournalEditor.quote($el.closest('.field').querySelector('textarea'))"></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="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">1.</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="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
</span>
<button
type="button"