Merge branch 'remove-easymde-fix-ime' into main
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
-7
File diff suppressed because one or more lines are too long
+162
-70
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
||||
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
||||
// 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("", start, end); }
|
||||
replaceMarker(textarea, marker, "");
|
||||
})
|
||||
.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); });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
-7
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()"></></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'))"></></button>
|
||||
<button type="button" class="md-tool-btn" title="링크" @click="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user