Merge branch 'ios-only-plain-textarea-fallback' into main
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
+138
-51
@@ -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();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!imageItem) return;
|
||||
|
||||
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",
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
<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,7 +134,7 @@
|
||||
if (this.content === this.lastAutoFilled) {
|
||||
this.content = 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); },
|
||||
|
||||
Reference in New Issue
Block a user