journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for markdown authoring: syntax highlighting, smart list continuation, and a custom text-based toolbar (built-in EasyMDE toolbar icons require Font Awesome from a CDN, which this app doesn't use). unorderedListStyle is set to "-" to match the app's own template convention. - Add a preview/edit toggle button that swaps the editor for the exact same server-rendered markdown (via /journal/preview) shown after saving, instead of always showing both. - Fix create/edit entry routes to verify the submitted category_id actually belongs to the current user before inserting -- every other write path in this app already checked ownership; this one didn't (found while manually testing the new editor with a typo'd category id that happened to belong to someone else's category, which surfaced as an IntegrityError 500 instead of a clean 404-equivalent). - Fix the default "일상" category template: bare "-" bullet lines don't parse as list items in the markdown renderer (they need a trailing space), and the content_template validator was silently stripping that trailing space off on every save. Backfill migration updates any category still holding the old, broken template text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import bleach
|
||||
import markdown
|
||||
from markupsafe import Markup
|
||||
|
||||
# nl2br: 빈 줄 없이 그냥 엔터만 쳐도 줄바꿈되게 한다 — 지금까지 백엔드가 순수 텍스트를
|
||||
# white-space: pre-wrap으로 보여주던 것과 체감이 최대한 비슷하도록.
|
||||
_MARKDOWN_EXTENSIONS = ["nl2br", "sane_lists"]
|
||||
|
||||
_ALLOWED_TAGS = [
|
||||
"p", "br", "strong", "em", "del",
|
||||
"h1", "h2", "h3", "h4",
|
||||
"ul", "ol", "li",
|
||||
"blockquote", "code", "pre", "hr", "a",
|
||||
]
|
||||
_ALLOWED_ATTRS = {"a": ["href", "title"]}
|
||||
|
||||
|
||||
def render_markdown(text: str) -> Markup:
|
||||
"""저널 기록 내용을 마크다운 HTML로 렌더링한다. markdown 라이브러리는 기본적으로 원본 HTML을
|
||||
그대로 통과시키므로(<script> 등 포함) bleach로 허용 태그만 남기고 나머지는 전부 지운다 —
|
||||
이 함수가 반환하는 Markup만 템플릿에서 이스케이프 없이(그대로 안전하게) 렌더링해야 한다."""
|
||||
html = markdown.markdown(text, extensions=_MARKDOWN_EXTENSIONS)
|
||||
return Markup(bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS, strip=True))
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.markdown_utils import render_markdown
|
||||
from app.models.journal import JournalMood
|
||||
from app.routers.pages import _current_user_or_redirect, templates
|
||||
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
|
||||
@@ -33,6 +34,7 @@ templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in
|
||||
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
|
||||
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
|
||||
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
|
||||
templates.env.filters["markdown"] = render_markdown
|
||||
|
||||
|
||||
def _parse_moods(raw: str) -> list[JournalMood]:
|
||||
@@ -133,6 +135,17 @@ def journal_day_detail(request: Request, entry_date: date, db: Session = Depends
|
||||
return _render_day_detail(request, db, current.id, entry_date)
|
||||
|
||||
|
||||
@router.post("/journal/preview")
|
||||
def preview_entry_content(request: Request, content: str = Form(""), db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
if not content.strip():
|
||||
return HTMLResponse('<span class="empty-state">미리보기가 여기에 표시돼요</span>')
|
||||
return HTMLResponse(render_markdown(content))
|
||||
|
||||
|
||||
@router.post("/journal/new")
|
||||
def create_entry_page(
|
||||
request: Request,
|
||||
@@ -149,6 +162,9 @@ def create_entry_page(
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
if journal_service.get_category(db, category_id, current.id) is None:
|
||||
return HTMLResponse("카테고리를 찾을 수 없어요")
|
||||
|
||||
try:
|
||||
parsed_date = date.fromisoformat(entry_date)
|
||||
data = JournalEntryCreate(
|
||||
@@ -202,6 +218,16 @@ def edit_entry_page(
|
||||
|
||||
original_date = entry.entry_date
|
||||
|
||||
if journal_service.get_category(db, category_id, current.id) is None:
|
||||
return _render_day_detail(
|
||||
request,
|
||||
db,
|
||||
current.id,
|
||||
original_date,
|
||||
edit_error="카테고리를 찾을 수 없어요",
|
||||
editing_entry_id=entry_id,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed_date = date.fromisoformat(entry_date)
|
||||
data = JournalEntryUpdate(
|
||||
|
||||
@@ -29,10 +29,13 @@ class JournalCategoryBase(BaseModel):
|
||||
@field_validator("content_template")
|
||||
@classmethod
|
||||
def blank_template_to_none(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
# color/name과 달리 여기선 .strip()으로 값 자체를 바꾸지 않는다 — 템플릿 맨 끝의
|
||||
# "- "(대시+공백)처럼 의미 있는 trailing whitespace가 있을 수 있고, 그걸 지우면
|
||||
# markdown이 그 줄을 목록으로 인식하지 못하게 된다(빈 값인지 판단만 strip으로 하고,
|
||||
# 실제로 저장하는 값은 원본을 그대로 쓴다).
|
||||
if v is None or not v.strip():
|
||||
return None
|
||||
v = v.strip()
|
||||
return v or None
|
||||
return v
|
||||
|
||||
|
||||
class JournalCategoryCreate(JournalCategoryBase):
|
||||
|
||||
@@ -34,27 +34,27 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
THUMBNAIL_WIDTH = 400
|
||||
DEFAULT_CATEGORY_NAME = "일상"
|
||||
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
|
||||
|
||||
-
|
||||
|
||||
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
|
||||
|
||||
-
|
||||
|
||||
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
|
||||
|
||||
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
|
||||
|
||||
- """
|
||||
# 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
|
||||
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
|
||||
# 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
|
||||
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
|
||||
_BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
|
||||
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
|
||||
DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
|
||||
[
|
||||
"**1. Story : 오늘 무슨 일이 있었나요?**",
|
||||
_BULLET,
|
||||
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
|
||||
_BULLET,
|
||||
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
|
||||
_BULLET,
|
||||
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
|
||||
_BULLET,
|
||||
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
|
||||
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
|
||||
_BULLET,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- 카테고리 ----
|
||||
|
||||
+122
-1
@@ -902,8 +902,129 @@ label {
|
||||
}
|
||||
|
||||
.journal-entry-content {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.journal-preview {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg);
|
||||
min-height: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
|
||||
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
|
||||
.markdown-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.md-tool-btn {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-tool-btn:active {
|
||||
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;
|
||||
}
|
||||
|
||||
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
|
||||
border-left-color: var(--color-text);
|
||||
}
|
||||
|
||||
.markdown-editor + .EasyMDEContainer .editor-statusbar {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.journal-entry-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.journal-entry-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.journal-entry-content p,
|
||||
.journal-entry-content ul,
|
||||
.journal-entry-content ol,
|
||||
.journal-entry-content blockquote,
|
||||
.journal-entry-content pre {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.journal-entry-content h1,
|
||||
.journal-entry-content h2,
|
||||
.journal-entry-content h3,
|
||||
.journal-entry-content h4 {
|
||||
margin: 12px 0 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.journal-entry-content h1 { font-size: 19px; }
|
||||
.journal-entry-content h2 { font-size: 17px; }
|
||||
.journal-entry-content h3,
|
||||
.journal-entry-content h4 { font-size: 15px; }
|
||||
|
||||
.journal-entry-content ul,
|
||||
.journal-entry-content ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.journal-entry-content blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.journal-entry-content code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.journal-entry-content pre {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.journal-entry-content pre code {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.journal-entry-content a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.journal-entry-tags {
|
||||
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
(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])");
|
||||
|
||||
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는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
|
||||
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
|
||||
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
|
||||
easymde.codemirror.on("change", function () {
|
||||
easymde.codemirror.save();
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
textarea._easymde = easymde;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initEditors(document);
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSwap", function (evt) {
|
||||
initEditors(evt.target);
|
||||
});
|
||||
})();
|
||||
Vendored
+7
File diff suppressed because one or more lines are too long
@@ -1,13 +1,16 @@
|
||||
const CACHE_NAME = "habit-tracker-v5";
|
||||
const CACHE_NAME = "habit-tracker-v6";
|
||||
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",
|
||||
"/static/js/journal-category-reorder.js",
|
||||
"/static/js/journal-editor.js",
|
||||
"/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",
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
<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>
|
||||
</head>
|
||||
|
||||
@@ -124,11 +124,20 @@
|
||||
categoryTemplates: {{ category_templates|tojson|forceescape }},
|
||||
content: '',
|
||||
lastAutoFilled: '',
|
||||
previewMode: false,
|
||||
togglePreview() {
|
||||
this.previewMode = !this.previewMode;
|
||||
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
|
||||
},
|
||||
onCategoryChange(categoryId) {
|
||||
const tmpl = this.categoryTemplates[categoryId] || '';
|
||||
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'));
|
||||
});
|
||||
}
|
||||
},
|
||||
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||
@@ -173,7 +182,25 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-content">내용</label>
|
||||
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea>
|
||||
{% include "partials/journal_editor_toolbar.html" %}
|
||||
<div x-show="!previewMode">
|
||||
<textarea
|
||||
id="new-entry-content"
|
||||
name="content"
|
||||
rows="8"
|
||||
required
|
||||
placeholder="오늘 하루는 어땠나요?"
|
||||
class="markdown-editor"
|
||||
x-model="content"
|
||||
x-ref="contentField"
|
||||
hx-post="/journal/preview"
|
||||
hx-trigger="input changed delay:400ms, load"
|
||||
hx-target="#new-entry-preview"
|
||||
hx-swap="innerHTML"
|
||||
hx-params="content"
|
||||
></textarea>
|
||||
</div>
|
||||
<div id="new-entry-preview" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
|
||||
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}],
|
||||
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
|
||||
previewMode: false,
|
||||
togglePreview() {
|
||||
this.previewMode = !this.previewMode;
|
||||
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div x-show="!editing">
|
||||
@@ -24,7 +29,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
|
||||
<div class="journal-entry-content">{{ item.content }}</div>
|
||||
<div class="journal-entry-content">{{ item.content|markdown }}</div>
|
||||
{% if item.tags %}
|
||||
<div class="journal-entry-tags">
|
||||
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
|
||||
@@ -94,7 +99,22 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>내용</label>
|
||||
<textarea name="content" rows="4" required>{{ item.content }}</textarea>
|
||||
{% include "partials/journal_editor_toolbar.html" %}
|
||||
<div x-show="!previewMode">
|
||||
<textarea
|
||||
name="content"
|
||||
rows="6"
|
||||
required
|
||||
class="markdown-editor"
|
||||
x-ref="contentField"
|
||||
hx-post="/journal/preview"
|
||||
hx-trigger="input changed delay:400ms, load"
|
||||
hx-target="#journal-edit-preview-{{ item.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-params="content"
|
||||
>{{ item.content }}</textarea>
|
||||
</div>
|
||||
<div id="journal-edit-preview-{{ item.id }}" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>기분 (여러 개 선택 가능)</label>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="md-tool-btn md-preview-toggle"
|
||||
style="margin-left:auto;"
|
||||
@click="togglePreview()"
|
||||
x-text="previewMode ? '✏️ 편집' : '👁 미리보기'"
|
||||
></button>
|
||||
</div>
|
||||
Reference in New Issue
Block a user