Add a markdown viewer for .md files and live typed preview.
office.sejong.dev/pipeline/head This commit looks good
office.sejong.dev/pipeline/head This commit looks good
Render GitHub-flavored markdown in the browser with marked and sanitize HTML with DOMPurify. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
545a2802e2
commit
c4ae7495dd
10 files changed
+445
-9
No files matched your search
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import {
|
||||
isMarkdownFile,
|
||||
renderMarkdown,
|
||||
} from '@/integrations/markdown/render';
|
||||
import { downloadText, readTextFile } from '@/utils/downloadBlob';
|
||||
|
||||
defineOptions({ name: 'MarkdownViewerView' });
|
||||
|
||||
const DEFAULT_SOURCE = `# 마크다운
|
||||
|
||||
왼쪽 칸에 입력하거나 \`.md\` 파일을 열면 미리보기가 표시됩니다.
|
||||
|
||||
- **굵게** · *기울임* · \`코드\`
|
||||
- [링크](https://git.sejong.dev)
|
||||
|
||||
\`\`\`ts
|
||||
console.log('hello');
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const filename = ref('미리보기.md');
|
||||
const source = ref(DEFAULT_SOURCE);
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
usePageTitle(filename, { faviconKind: 'markdown' });
|
||||
|
||||
const html = computed(() => renderMarkdown(source.value));
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
downloadText(source.value, filename.value, 'text/markdown;charset=utf-8');
|
||||
}
|
||||
|
||||
async function loadFile(file: File) {
|
||||
if (!isMarkdownFile(file)) {
|
||||
error.value = '.md 또는 .markdown 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
source.value = await readTextFile(file);
|
||||
filename.value = file.name.replace(/\.markdown$/i, '.md');
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '파일을 열지 못했습니다.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file) void loadFile(file);
|
||||
}
|
||||
|
||||
function consumePending() {
|
||||
const [file] = consumeViewerFiles('markdown');
|
||||
if (file) void loadFile(file);
|
||||
}
|
||||
|
||||
onMounted(consumePending);
|
||||
onActivated(consumePending);
|
||||
|
||||
const {
|
||||
dropActive,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop: onZoneDrop,
|
||||
} = useFileDropZone((files) => {
|
||||
const file = files[0];
|
||||
if (file) void loadFile(file);
|
||||
});
|
||||
|
||||
watch(source, () => {
|
||||
if (error.value && source.value) error.value = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div
|
||||
class="md-page"
|
||||
:class="{ 'is-drop-active': dropActive }"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onZoneDrop"
|
||||
>
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:busy="busy"
|
||||
save-label="다운로드"
|
||||
@open="onOpen"
|
||||
@save="onSave"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".md,.markdown,.mdown,text/markdown,text/plain"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="md-layout">
|
||||
<section class="source-pane" aria-label="마크다운 입력">
|
||||
<textarea
|
||||
v-model="source"
|
||||
class="source"
|
||||
spellcheck="false"
|
||||
:disabled="busy"
|
||||
aria-label="마크다운 원문"
|
||||
placeholder="# 제목 여기에 마크다운을 입력하세요."
|
||||
/>
|
||||
</section>
|
||||
<section class="preview-pane" aria-label="마크다운 미리보기">
|
||||
<article class="preview" v-html="html" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||
마크다운 파일을 놓아 열기
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.md-page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.md-layout {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 1fr) minmax(0, 1.15fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.source-pane,
|
||||
.preview-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.source {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 1rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: 0.9rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
overflow: auto;
|
||||
padding: 1.25rem 1.4rem 2rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-width: 46rem;
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview :deep(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preview :deep(h1),
|
||||
.preview :deep(h2),
|
||||
.preview :deep(h3),
|
||||
.preview :deep(h4) {
|
||||
margin: 1.35em 0 0.5em;
|
||||
line-height: 1.3;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preview :deep(h1) {
|
||||
font-size: 1.7rem;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview :deep(h2) {
|
||||
font-size: 1.3rem;
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.preview :deep(p),
|
||||
.preview :deep(ul),
|
||||
.preview :deep(ol),
|
||||
.preview :deep(blockquote),
|
||||
.preview :deep(pre),
|
||||
.preview :deep(table) {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.preview :deep(ul),
|
||||
.preview :deep(ol) {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.preview :deep(a) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview :deep(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
padding: 0.12em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.preview :deep(pre) {
|
||||
overflow: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.preview :deep(pre code) {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.preview :deep(blockquote) {
|
||||
padding: 0 0.85rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview :deep(hr) {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.4em 0;
|
||||
}
|
||||
|
||||
.preview :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview :deep(table) {
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.preview :deep(th),
|
||||
.preview :deep(td) {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview :deep(th) {
|
||||
background: var(--canvas);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 2.75rem 0.5rem 0.5rem;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: #b42318;
|
||||
background: color-mix(in srgb, #b42318 10%, var(--surface));
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.md-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(12rem, 0.85fr) minmax(14rem, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user