Compare commits

...
8 Commits
Author SHA1 Message Date
shinalok 74bfe34d3f Merge branch 'ios-only-plain-textarea-fallback' into main
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m38s
2026-08-27 08:16:10 +09:00
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
shinalok f524157326 Merge branch 'remove-easymde-fix-ime' into main 2026-08-26 20:23:13 +09:00
shinalokandClaude Sonnet 5 ac98cf4f99 replace EasyMDE journal editor with plain textarea to fix iOS Korean IME jamo split
Forcing inputStyle to contenteditable (previous commit) didn't fix it on
iPhone, confirming this is CodeMirror 5's own long-standing weakness with
CJK IME composition on iOS WebKit, not just the iPad desktop-UA detection
issue. There's no reliable fix short of dropping CodeMirror for the journal
content field, so EasyMDE is removed entirely and the textarea goes back to
a native, uncontrolled <textarea> — nothing intercepts/re-renders it during
composition, so iOS's IME just works.

The markdown toolbar buttons (bold/italic/heading/quote/lists/code/link)
now manipulate the textarea's selection directly via a small JournalEditor
JS API instead of calling EasyMDE/CodeMirror commands, and image paste
tracks its upload placeholder by a unique text marker instead of a
CodeMirror bookmark. Also drops the vendored easymde.min.js/css (no longer
referenced) and bumps the service worker CACHE_NAME so stale cached copies
of the old vendor files get evicted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:23:09 +09:00
shinalok 291813820b Merge branch 'fix-ios-ime-jamo-split' into main
CI/CD / test (push) Successful in 48s
CI/CD / deploy (push) Successful in 1m33s
2026-08-26 12:43:58 +09:00
shinalokandClaude Sonnet 5 779bbb58aa force EasyMDE contenteditable input mode to fix Korean IME jamo splitting on iPad
iPadOS 13+ sends a desktop Safari UA by default, which fools CodeMirror's mobile detection into using the more fragile textarea input mode. That, combined with CodeMirror repainting the line mid-composition, breaks Korean IME composition and leaves jamo unmerged. inputStyle can't be changed after the editor is created, so it must be set in the EasyMDE constructor options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:43:45 +09:00
shinalok 46c412f8f3 Merge pull request 'enlarge journal calendar cells and pair day/title on one line' (#1) from journal-calendar-ui-tweaks into main
CI/CD / test (push) Successful in 30s
CI/CD / deploy (push) Successful in 2m15s
Reviewed-on: #1
2026-08-19 17:18:22 +09:00
shinalokandClaude Sonnet 5 8a49757882 enlarge journal calendar cells and pair day/title on one line
CI/CD / test (pull_request) Successful in 51s
CI/CD / deploy (pull_request) Skipped
- calendar-cell-journal: bump min-height 44px -> 76px, scale up date/dot/title font sizes
- journal calendar titles were centered; left-align them
- rework JournalCalendarDay: replace separate category_colors/titles lists
  (mismatched lengths, no way to pair a dot with its title) with a single
  entries list of (color, title) pairs so the dot and its title render on
  the same non-wrapping row per entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:13:38 +09:00
8 changed files with 331 additions and 92 deletions
+6 -1
View File
@@ -116,10 +116,15 @@ class JournalAttachmentOut(BaseModel):
has_thumbnail: bool has_thumbnail: bool
class JournalCalendarEntry(BaseModel):
color: str # 그 엔트리가 속한 카테고리 색상 (점 표시용)
title: str # 제목(없으면 내용 일부)
class JournalCalendarDay(BaseModel): class JournalCalendarDay(BaseModel):
entry_date: date entry_date: date
total_count: int total_count: int
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거) entries: list[JournalCalendarEntry] # 엔트리별 (색상, 제목) 쌍 — 점과 제목이 한 줄에 붙어 나오도록 1:1로 매칭
class JournalDayDetailItem(BaseModel): class JournalDayDetailItem(BaseModel):
+17 -8
View File
@@ -23,6 +23,7 @@ from app.models.journal import (
from app.schemas.journal import ( from app.schemas.journal import (
JournalAttachmentOut, JournalAttachmentOut,
JournalCalendarDay, JournalCalendarDay,
JournalCalendarEntry,
JournalCategoryCreate, JournalCategoryCreate,
JournalDayDetailItem, JournalDayDetailItem,
JournalEntryCreate, JournalEntryCreate,
@@ -363,25 +364,33 @@ def get_monthly_journal_summary(
last_day = date(year, month, days_in_month) last_day = date(year, month, days_in_month)
stmt = ( stmt = (
select(JournalEntry.entry_date, JournalCategory.color) select(JournalEntry.entry_date, JournalCategory.color, JournalEntry.title, JournalEntry.content)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id) .join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day)) .where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
.order_by(JournalEntry.entry_date, JournalEntry.created_at)
) )
if category_id is not None: if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id) stmt = stmt.where(JournalEntry.category_id == category_id)
rows = db.execute(stmt).all() rows = db.execute(stmt).all()
counts: dict[date, int] = {} counts: dict[date, int] = {}
colors_by_date: dict[date, list[str]] = {} entries_by_date: dict[date, list[JournalCalendarEntry]] = {}
for entry_date, color in rows: for entry_date, color, title, content in rows:
counts[entry_date] = counts.get(entry_date, 0) + 1 counts[entry_date] = counts.get(entry_date, 0) + 1
colors = colors_by_date.setdefault(entry_date, []) entries = entries_by_date.setdefault(entry_date, [])
color = color or "var(--color-accent)" entries.append(
if color not in colors: JournalCalendarEntry(
colors.append(color) color=color or "var(--color-accent)",
title=title or (content[:12] + ("" if len(content) > 12 else "")),
)
)
return { return {
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d]) d: JournalCalendarDay(
entry_date=d,
total_count=counts[d],
entries=entries_by_date[d],
)
for d in counts for d in counts
} }
+41 -5
View File
@@ -872,15 +872,48 @@ label {
} }
/* 저널링 */ /* 저널링 */
.journal-day-dots { .calendar-cell-journal {
aspect-ratio: auto;
min-height: 76px;
padding: 6px 5px;
overflow: hidden;
}
.calendar-cell-journal .calendar-date {
font-size: 13px;
}
.journal-day-entries {
display: flex; display: flex;
gap: 2px; flex-direction: column;
align-items: flex-start;
width: 100%;
}
.journal-day-entry {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
} }
.journal-day-dot { .journal-day-dot {
width: 5px; width: 6px;
height: 5px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
}
.journal-day-title {
min-width: 0;
font-size: 10px;
line-height: 1.3;
color: var(--color-text-muted);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.journal-prompt-card, .journal-prompt-card,
@@ -946,7 +979,10 @@ label {
background: var(--color-bg); background: var(--color-bg);
} }
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다 */ /* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다. iOS에서는
app/static/js/journal-editor.js가 EasyMDE 대신 순수 <textarea>로 폴백하므로(한글 IME
자소분리 회피) 이 규칙은 그 경우 그냥 매칭되지 않는다 — .markdown-editor 자체는 위 공통
textarea 규칙을 그대로 물려받아 별도 스타일 없이도 정상적으로 보인다. */
.markdown-editor + .EasyMDEContainer .CodeMirror { .markdown-editor + .EasyMDEContainer .CodeMirror {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-control); border-radius: var(--radius-control);
+248 -59
View File
@@ -1,92 +1,230 @@
(function () { (function () {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로 // 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서 //
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다. // EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
function initEditors(root) { // 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
if (typeof EasyMDE === "undefined") return; // 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
var scope = root instanceof Element ? root : document; // 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])"); // 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
// <textarea>로 폴백한다(브라우저 네이티브 입력 처리를 그대로 쓰면 IME가 깨질 이유가
// 없다). PC/안드로이드 등 iOS가 아닌 환경은 지금까지처럼 EasyMDE를 그대로 쓴다.
//
// 굵게/기울임 같은 툴바 버튼과 카테고리 템플릿 자동 채우기는 window.JournalEditor를
// 통해 호출되는데, 이 객체가 각 textarea에 EasyMDE가 붙어있는지(t._easymde) 보고
// EasyMDE 명령 또는 아래의 직접 선택 영역 조작 중 알맞은 쪽으로 위임한다 — 호출하는
// 템플릿 쪽(journal_editor_toolbar.html, journal.html)은 어느 엔진이 쓰이는지 몰라도 된다.
textareas.forEach(function (textarea) { function isIOS() {
textarea.setAttribute("data-easymde-initialized", "true"); 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;
}
var easymde = new EasyMDE({ function fireInput(textarea) {
element: textarea, textarea.dispatchEvent(new Event("input", { bubbles: true }));
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()를 명시적으로 // ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
easymde.codemirror.on("paste", function (cm, event) { function insertAtCursor(textarea, text) {
handleImagePaste(cm, event); 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);
}
textarea._easymde = easymde; 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) {
// 그 자리에 마크다운 이미지 문법(![](url))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
function handleImagePaste(cm, event) {
var items = event.clipboardData && event.clipboardData.items; var items = event.clipboardData && event.clipboardData.items;
if (!items) return; if (!items) return null;
var imageItem = null;
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image/") === 0) { if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
imageItem = items[i];
break;
}
} }
if (!imageItem) return; return null;
}
event.preventDefault(); // 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
var file = imageItem.getAsFile(); // 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
// 계속 타이핑해도 자리를 잃지 않는다.
function handlePlainImagePaste(textarea, event) {
var file = extractImageFile(event);
if (!file) return; 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 doc = cm.getDoc();
var from = doc.getCursor(); var from = doc.getCursor();
var placeholder = "![업로드 중...]()"; var placeholder = "![업로드 중...]()";
doc.replaceRange(placeholder, from); doc.replaceRange(placeholder, from);
var to = { line: from.line, ch: from.ch + placeholder.length }; var to = { line: from.line, ch: from.ch + placeholder.length };
// 업로드가 끝나기 전에 사용자가 다른 곳에서 계속 타이핑해도(줄 추가 등) 자리를 잃지
// 않도록 고정 좌표 대신 CodeMirror 북마크로 추적한다.
var startMark = doc.setBookmark(from); var startMark = doc.setBookmark(from);
var endMark = doc.setBookmark(to); var endMark = doc.setBookmark(to);
var formData = new FormData(); uploadPastedImage(file)
formData.append("file", file, file.name || "pasted-image.png");
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();
})
.then(function (data) { .then(function (data) {
var start = startMark.find(); var start = startMark.find();
var end = endMark.find(); var end = endMark.find();
if (start && end) { doc.replaceRange("![](" + data.url + ")", start, end); } if (start && end) doc.replaceRange("![](" + data.url + ")", start, end);
}) })
.catch(function (err) { .catch(function (err) {
var start = startMark.find(); var start = startMark.find();
var end = endMark.find(); var end = endMark.find();
if (start && end) { doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end); } if (start && end) doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end);
}) })
.finally(function () { .finally(function () {
startMark.clear(); startMark.clear();
@@ -96,6 +234,57 @@
}); });
} }
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 () { document.addEventListener("DOMContentLoaded", function () {
initEditors(document); initEditors(document);
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "habit-tracker-v6"; const CACHE_NAME = "habit-tracker-v8";
const APP_SHELL = [ const APP_SHELL = [
"/static/css/style.css", "/static/css/style.css",
"/static/css/vendor/easymde.min.css", "/static/css/vendor/easymde.min.css",
+8 -8
View File
@@ -134,10 +134,7 @@
if (this.content === this.lastAutoFilled) { if (this.content === this.lastAutoFilled) {
this.content = tmpl; this.content = tmpl;
this.lastAutoFilled = tmpl; this.lastAutoFilled = tmpl;
this.$nextTick(() => { this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
this.$refs.contentField.dispatchEvent(new Event('input'));
});
} }
}, },
init() { this.onCategoryChange(this.$refs.categorySelect.value); }, init() { this.onCategoryChange(this.$refs.categorySelect.value); },
@@ -252,16 +249,19 @@
{% for d in week %} {% for d in week %}
{% set day_summary = summary_map.get(d) %} {% set day_summary = summary_map.get(d) %}
<div <div
class="calendar-cell clickable{{ '' if d.month == month else ' muted' }}" class="calendar-cell calendar-cell-journal clickable{{ '' if d.month == month else ' muted' }}"
hx-get="/journal/day/{{ d.isoformat() }}" hx-get="/journal/day/{{ d.isoformat() }}"
hx-target="#day-detail" hx-target="#day-detail"
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<span class="calendar-date">{{ d.day }}</span> <span class="calendar-date">{{ d.day }}</span>
{% if day_summary %} {% if day_summary %}
<span class="journal-day-dots"> <span class="journal-day-entries">
{% for color in day_summary.category_colors %} {% for entry in day_summary.entries %}
<span class="journal-day-dot" style="background: {{ color }};"></span> <span class="journal-day-entry">
<span class="journal-day-dot" style="background: {{ entry.color }};"></span>
<span class="journal-day-title">{{ entry.title }}</span>
</span>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
@@ -1,14 +1,14 @@
<div class="markdown-toolbar"> <div class="markdown-toolbar">
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;"> <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="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><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="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><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="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><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="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">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="JournalEditor.quote($el.closest('.field').querySelector('textarea'))"></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="JournalEditor.ul($el.closest('.field').querySelector('textarea'))"></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="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">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="JournalEditor.code($el.closest('.field').querySelector('textarea'))">&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.link($el.closest('.field').querySelector('textarea'))">🔗</button>
</span> </span>
<button <button
type="button" type="button"
+1 -1
View File
@@ -285,7 +285,7 @@ def test_get_monthly_journal_summary_counts_entries_per_day(db_session, test_use
summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month) summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month)
assert summary[today].total_count == 2 assert summary[today].total_count == 2
assert summary[today].category_colors == ["#ff0000"] assert [e.color for e in summary[today].entries] == ["#ff0000", "#ff0000"]
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user): def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):