diff --git a/package-lock.json b/package-lock.json index 543c8b5..817d106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3775,6 +3775,16 @@ "@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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3973,8 +3983,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", @@ -5545,9 +5555,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -9366,11 +9376,13 @@ "@rhwp/editor": "^0.7.18", "@vueuse/core": "^11.3.0", "ag-psd": "^31.0.2", + "dompurify": "^3.4.15", "draco3d": "^1.5.7", "element-plus": "^2.14.3", "fflate": "^0.8.2", "jscanify": "^1.4.3", "lodash-es": "^4.17.21", + "marked": "^16.4.2", "mermaid": "^11.16.0", "occt-import-js": "^0.0.22", "online-3d-viewer": "^0.18.0", @@ -9391,6 +9403,7 @@ "@fontsource-variable/noto-sans-kr": "^5.3.0", "@fontsource/noto-sans-kr": "^5.3.0", "@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0", + "@types/dompurify": "^3.0.5", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/three": "^0.172.0", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d35b829..a5674f8 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -27,11 +27,13 @@ "@rhwp/editor": "^0.7.18", "@vueuse/core": "^11.3.0", "ag-psd": "^31.0.2", + "dompurify": "^3.4.15", "draco3d": "^1.5.7", "element-plus": "^2.14.3", "fflate": "^0.8.2", "jscanify": "^1.4.3", "lodash-es": "^4.17.21", + "marked": "^16.4.2", "mermaid": "^11.16.0", "occt-import-js": "^0.0.22", "online-3d-viewer": "^0.18.0", @@ -52,6 +54,7 @@ "@fontsource-variable/noto-sans-kr": "^5.3.0", "@fontsource/noto-sans-kr": "^5.3.0", "@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0", + "@types/dompurify": "^3.0.5", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/three": "^0.172.0", diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 1362dbd..a5ba072 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -27,6 +27,7 @@ const viewKey = computed(() => { route.name === 'model3d-viewer' || route.name === 'illustrator-viewer' || route.name === 'photoshop-viewer' || + route.name === 'markdown-viewer' || route.name === 'tools-scanner' || route.name === 'tools-bg-remove' || route.name === 'tools-image-edit' @@ -49,6 +50,7 @@ const viewKey = computed(() => { 'Model3DViewerView', 'IllustratorViewerView', 'PhotoshopViewerView', + 'MarkdownViewerView', 'ToolsScannerView', 'ToolsBgRemoveView', 'ToolsImageEditView', diff --git a/packages/frontend/src/config/app.ts b/packages/frontend/src/config/app.ts index ed766eb..4136e69 100644 --- a/packages/frontend/src/config/app.ts +++ b/packages/frontend/src/config/app.ts @@ -6,7 +6,7 @@ export const APP_NAME = "sejong-office"; export const APP_DISPLAY_NAME = "Sejong Office"; 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"; const DIAGRAM_EXTENSION_KIND: Record = { @@ -69,6 +69,9 @@ const VIEWER_EXTENSION_KIND: Record = { dwg: "cad", ai: "illustrator", psd: "photoshop", + md: "markdown", + markdown: "markdown", + mdown: "markdown", ...Object.fromEntries( MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]), ), diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index 7f164a6..92545dd 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -23,6 +23,7 @@ export type FaviconKind = | 'scanner' | 'bgremove' | 'imgedit' + | 'markdown' | 'text' | 'csv' | 'file'; @@ -55,6 +56,9 @@ export const ICON_PATHS: Record = { /** mdi:crop */ imgedit: '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', 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', @@ -96,6 +100,7 @@ export const ICON_COLORS: Record = { scanner: '#00897b', bgremove: '#5c6bc0', imgedit: '#c2185b', + markdown: '#6f42c1', text: '#607d8b', csv: '#00838f', file: '#546e7a', @@ -160,6 +165,9 @@ const EXT_KIND: Record = { bgremove: 'bgremove', imgedit: 'imgedit', ico: 'imgedit', + md: 'markdown', + markdown: 'markdown', + mdown: 'markdown', txt: 'text', }; diff --git a/packages/frontend/src/integrations/markdown/render.ts b/packages/frontend/src/integrations/markdown/render.ts new file mode 100644 index 0000000..0577107 --- /dev/null +++ b/packages/frontend/src/integrations/markdown/render.ts @@ -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) + ); +} diff --git a/packages/frontend/src/router/index.ts b/packages/frontend/src/router/index.ts index 4a69b7f..2d66d23 100644 --- a/packages/frontend/src/router/index.ts +++ b/packages/frontend/src/router/index.ts @@ -54,6 +54,11 @@ const router = createRouter({ name: 'photoshop-viewer', component: () => import('@/views/PhotoshopViewerView.vue'), }, + { + path: '/viewer/markdown', + name: 'markdown-viewer', + component: () => import('@/views/MarkdownViewerView.vue'), + }, { path: '/tools/scanner', name: 'tools-scanner', diff --git a/packages/frontend/src/views/HomeView.vue b/packages/frontend/src/views/HomeView.vue index cca4dbd..5db5d16 100644 --- a/packages/frontend/src/views/HomeView.vue +++ b/packages/frontend/src/views/HomeView.vue @@ -72,6 +72,12 @@ const viewerTypes = [ title: '포토샵', subtitle: 'Adobe Photoshop (.psd, RGB 8bit)', }, + { + route: 'markdown-viewer', + extension: 'md', + title: '마크다운', + subtitle: '.md · 입력 미리보기', + }, ] as const; const toolTypes = [ @@ -140,7 +146,7 @@ function onOpenTool(route: string) { - 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너) · 아이콘(.ico) + 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 마크다운(.md) · 이미지(스캐너) · 아이콘(.ico) @@ -205,7 +211,7 @@ function onOpenTool(route: string) {

뷰어

- 서버 업로드 없이 도면·모델·일러스트·포토샵을 조회합니다. 텍스처·MTL·BIN + 서버 업로드 없이 도면·모델·일러스트·포토샵·마크다운을 조회합니다. 텍스처·MTL·BIN 등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는 RGB·8bit만 안정적으로 열립니다.

diff --git a/packages/frontend/src/views/LicensesView.vue b/packages/frontend/src/views/LicensesView.vue index f18f0df..727fc07 100644 --- a/packages/frontend/src/views/LicensesView.vue +++ b/packages/frontend/src/views/LicensesView.vue @@ -20,6 +20,16 @@ const thirdParty = [ license: 'MIT', 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: 'Online 3D Viewer', diff --git a/packages/frontend/src/views/MarkdownViewerView.vue b/packages/frontend/src/views/MarkdownViewerView.vue new file mode 100644 index 0000000..a8fbb49 --- /dev/null +++ b/packages/frontend/src/views/MarkdownViewerView.vue @@ -0,0 +1,357 @@ + + +