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
Generated
+18
-5
@@ -3775,6 +3775,16 @@
|
|||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -3973,8 +3983,8 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT",
|
"devOptional": true,
|
||||||
"optional": true
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
@@ -5545,9 +5555,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.12",
|
"version": "3.4.15",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -9366,11 +9376,13 @@
|
|||||||
"@rhwp/editor": "^0.7.18",
|
"@rhwp/editor": "^0.7.18",
|
||||||
"@vueuse/core": "^11.3.0",
|
"@vueuse/core": "^11.3.0",
|
||||||
"ag-psd": "^31.0.2",
|
"ag-psd": "^31.0.2",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
"draco3d": "^1.5.7",
|
"draco3d": "^1.5.7",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"jscanify": "^1.4.3",
|
"jscanify": "^1.4.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"marked": "^16.4.2",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.0",
|
||||||
"occt-import-js": "^0.0.22",
|
"occt-import-js": "^0.0.22",
|
||||||
"online-3d-viewer": "^0.18.0",
|
"online-3d-viewer": "^0.18.0",
|
||||||
@@ -9391,6 +9403,7 @@
|
|||||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
||||||
"@fontsource/noto-sans-kr": "^5.3.0",
|
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||||
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
|
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/three": "^0.172.0",
|
"@types/three": "^0.172.0",
|
||||||
|
|||||||
@@ -27,11 +27,13 @@
|
|||||||
"@rhwp/editor": "^0.7.18",
|
"@rhwp/editor": "^0.7.18",
|
||||||
"@vueuse/core": "^11.3.0",
|
"@vueuse/core": "^11.3.0",
|
||||||
"ag-psd": "^31.0.2",
|
"ag-psd": "^31.0.2",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
"draco3d": "^1.5.7",
|
"draco3d": "^1.5.7",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"jscanify": "^1.4.3",
|
"jscanify": "^1.4.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"marked": "^16.4.2",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.0",
|
||||||
"occt-import-js": "^0.0.22",
|
"occt-import-js": "^0.0.22",
|
||||||
"online-3d-viewer": "^0.18.0",
|
"online-3d-viewer": "^0.18.0",
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
||||||
"@fontsource/noto-sans-kr": "^5.3.0",
|
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||||
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
|
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/three": "^0.172.0",
|
"@types/three": "^0.172.0",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const viewKey = computed(() => {
|
|||||||
route.name === 'model3d-viewer' ||
|
route.name === 'model3d-viewer' ||
|
||||||
route.name === 'illustrator-viewer' ||
|
route.name === 'illustrator-viewer' ||
|
||||||
route.name === 'photoshop-viewer' ||
|
route.name === 'photoshop-viewer' ||
|
||||||
|
route.name === 'markdown-viewer' ||
|
||||||
route.name === 'tools-scanner' ||
|
route.name === 'tools-scanner' ||
|
||||||
route.name === 'tools-bg-remove' ||
|
route.name === 'tools-bg-remove' ||
|
||||||
route.name === 'tools-image-edit'
|
route.name === 'tools-image-edit'
|
||||||
@@ -49,6 +50,7 @@ const viewKey = computed(() => {
|
|||||||
'Model3DViewerView',
|
'Model3DViewerView',
|
||||||
'IllustratorViewerView',
|
'IllustratorViewerView',
|
||||||
'PhotoshopViewerView',
|
'PhotoshopViewerView',
|
||||||
|
'MarkdownViewerView',
|
||||||
'ToolsScannerView',
|
'ToolsScannerView',
|
||||||
'ToolsBgRemoveView',
|
'ToolsBgRemoveView',
|
||||||
'ToolsImageEditView',
|
'ToolsImageEditView',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const APP_NAME = "sejong-office";
|
|||||||
export const APP_DISPLAY_NAME = "Sejong Office";
|
export const APP_DISPLAY_NAME = "Sejong Office";
|
||||||
|
|
||||||
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
||||||
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop";
|
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop" | "markdown";
|
||||||
export type ToolKind = "scanner" | "imageedit";
|
export type ToolKind = "scanner" | "imageedit";
|
||||||
|
|
||||||
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||||
@@ -69,6 +69,9 @@ const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
|||||||
dwg: "cad",
|
dwg: "cad",
|
||||||
ai: "illustrator",
|
ai: "illustrator",
|
||||||
psd: "photoshop",
|
psd: "photoshop",
|
||||||
|
md: "markdown",
|
||||||
|
markdown: "markdown",
|
||||||
|
mdown: "markdown",
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type FaviconKind =
|
|||||||
| 'scanner'
|
| 'scanner'
|
||||||
| 'bgremove'
|
| 'bgremove'
|
||||||
| 'imgedit'
|
| 'imgedit'
|
||||||
|
| 'markdown'
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'csv'
|
| 'csv'
|
||||||
| 'file';
|
| 'file';
|
||||||
@@ -55,6 +56,9 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
|
|||||||
/** mdi:crop */
|
/** mdi:crop */
|
||||||
imgedit:
|
imgedit:
|
||||||
'M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17H19V7H7V17M17,17H7V7H17V17Z',
|
'M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17H19V7H7V17M17,17H7V7H17V17Z',
|
||||||
|
/** mdi:language-markdown */
|
||||||
|
markdown:
|
||||||
|
'M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M6.81 15.19V11.53L8.73 13.88L10.66 11.53V15.19H12.31V8.81H10.66L8.73 11.16L6.81 8.81H5.16V15.19H6.81M15.19 15.19H19.5V13.84H16.84V12.5H19.5V11.16H16.84V9.81H19.5V8.81H15.19V15.19Z',
|
||||||
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||||
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
||||||
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||||
@@ -96,6 +100,7 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
|
|||||||
scanner: '#00897b',
|
scanner: '#00897b',
|
||||||
bgremove: '#5c6bc0',
|
bgremove: '#5c6bc0',
|
||||||
imgedit: '#c2185b',
|
imgedit: '#c2185b',
|
||||||
|
markdown: '#6f42c1',
|
||||||
text: '#607d8b',
|
text: '#607d8b',
|
||||||
csv: '#00838f',
|
csv: '#00838f',
|
||||||
file: '#546e7a',
|
file: '#546e7a',
|
||||||
@@ -160,6 +165,9 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
bgremove: 'bgremove',
|
bgremove: 'bgremove',
|
||||||
imgedit: 'imgedit',
|
imgedit: 'imgedit',
|
||||||
ico: 'imgedit',
|
ico: 'imgedit',
|
||||||
|
md: 'markdown',
|
||||||
|
markdown: 'markdown',
|
||||||
|
mdown: 'markdown',
|
||||||
txt: 'text',
|
txt: 'text',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node instanceof HTMLAnchorElement) {
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function renderMarkdown(source: string): string {
|
||||||
|
const html = marked.parse(source ?? '', { async: false });
|
||||||
|
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarkdownFile(file: File) {
|
||||||
|
return (
|
||||||
|
file.type === 'text/markdown' ||
|
||||||
|
file.type === 'text/x-markdown' ||
|
||||||
|
/\.(?:md|markdown|mdown)$/i.test(file.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,11 @@ const router = createRouter({
|
|||||||
name: 'photoshop-viewer',
|
name: 'photoshop-viewer',
|
||||||
component: () => import('@/views/PhotoshopViewerView.vue'),
|
component: () => import('@/views/PhotoshopViewerView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/viewer/markdown',
|
||||||
|
name: 'markdown-viewer',
|
||||||
|
component: () => import('@/views/MarkdownViewerView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/tools/scanner',
|
path: '/tools/scanner',
|
||||||
name: 'tools-scanner',
|
name: 'tools-scanner',
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ const viewerTypes = [
|
|||||||
title: '포토샵',
|
title: '포토샵',
|
||||||
subtitle: 'Adobe Photoshop (.psd, RGB 8bit)',
|
subtitle: 'Adobe Photoshop (.psd, RGB 8bit)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: 'markdown-viewer',
|
||||||
|
extension: 'md',
|
||||||
|
title: '마크다운',
|
||||||
|
subtitle: '.md · 입력 미리보기',
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const toolTypes = [
|
const toolTypes = [
|
||||||
@@ -140,7 +146,7 @@ function onOpenTool(route: string) {
|
|||||||
<input
|
<input
|
||||||
class="drop__input"
|
class="drop__input"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.ai,.psd,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp,.ico"
|
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.ai,.psd,.md,.markdown,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp,.ico"
|
||||||
multiple
|
multiple
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@change="onInput"
|
@change="onInput"
|
||||||
@@ -149,7 +155,7 @@ function onOpenTool(route: string) {
|
|||||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="drop__hint">
|
<span class="drop__hint">
|
||||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너) · 아이콘(.ico)
|
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 마크다운(.md) · 이미지(스캐너) · 아이콘(.ico)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -205,7 +211,7 @@ function onOpenTool(route: string) {
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section__title">뷰어</h2>
|
<h2 class="section__title">뷰어</h2>
|
||||||
<p class="section__lead">
|
<p class="section__lead">
|
||||||
서버 업로드 없이 도면·모델·일러스트·포토샵을 조회합니다. 텍스처·MTL·BIN
|
서버 업로드 없이 도면·모델·일러스트·포토샵·마크다운을 조회합니다. 텍스처·MTL·BIN
|
||||||
등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는
|
등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는
|
||||||
RGB·8bit만 안정적으로 열립니다.
|
RGB·8bit만 안정적으로 열립니다.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ const thirdParty = [
|
|||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
url: 'https://github.com/mermaid-js/mermaid',
|
url: 'https://github.com/mermaid-js/mermaid',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'marked',
|
||||||
|
license: 'MIT',
|
||||||
|
url: 'https://github.com/markedjs/marked',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DOMPurify',
|
||||||
|
license: 'MPL-2.0 OR Apache-2.0',
|
||||||
|
url: 'https://github.com/cure53/DOMPurify',
|
||||||
|
},
|
||||||
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
|
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
|
||||||
{
|
{
|
||||||
name: 'Online 3D Viewer',
|
name: 'Online 3D Viewer',
|
||||||
|
|||||||
@@ -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