.ai, .psd support

This commit is contained in:
ysj7705 committed 2026-07-27 11:30:58 +09:00
1 parent f5d880caf5
commit 2967548ff3
11 files changed
+1136 -6

No files matched your search

+1
View File
@@ -26,6 +26,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@rhwp/editor": "^0.7.18",
"@vueuse/core": "^11.3.0",
"ag-psd": "^31.0.2",
"draco3d": "^1.5.7",
"element-plus": "^2.14.3",
"fflate": "^0.8.2",
+4
View File
@@ -25,6 +25,8 @@ const viewKey = computed(() => {
route.name === 'drawio-editor' ||
route.name === 'cad-viewer' ||
route.name === 'model3d-viewer' ||
route.name === 'illustrator-viewer' ||
route.name === 'photoshop-viewer' ||
route.name === 'tools-scanner' ||
route.name === 'tools-bg-remove'
) {
@@ -44,6 +46,8 @@ const viewKey = computed(() => {
'DrawioEditorView',
'CadViewerView',
'Model3DViewerView',
'IllustratorViewerView',
'PhotoshopViewerView',
'ToolsScannerView',
'ToolsBgRemoveView',
]"
+3 -1
View File
@@ -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";
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop";
export type ToolKind = "scanner";
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
@@ -66,6 +66,8 @@ export const MODEL3D_EXTENSIONS = [
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
dxf: "cad",
dwg: "cad",
ai: "illustrator",
psd: "photoshop",
...Object.fromEntries(
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
),
+12
View File
@@ -18,6 +18,8 @@ export type FaviconKind =
| 'diagram'
| 'cad'
| 'model3d'
| 'illustrator'
| 'photoshop'
| 'scanner'
| 'bgremove'
| 'text'
@@ -40,6 +42,12 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
/** mdi:vector-polyline — Illustrator-style mark */
illustrator:
'M2,9V15H6.5L9.5,19H14.5L17.5,15H22V9H17.5L14.5,5H9.5L6.5,9H2M9.5,7.5L12,11L14.5,7.5H9.5M6.5,10.5L8.5,13.5H5V10.5H6.5M18.5,13.5L16.5,10.5H19V13.5H18.5M12,12.5L9.5,16.5H14.5L12,12.5Z',
/** mdi:image — Photoshop-style mark */
photoshop:
'M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z',
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
bgremove:
'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z',
@@ -79,6 +87,8 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
diagram: '#2e7d8a',
cad: '#5c6bc0',
model3d: '#6d4c41',
illustrator: '#f57c00',
photoshop: '#31a8ff',
scanner: '#00897b',
bgremove: '#5c6bc0',
text: '#607d8b',
@@ -111,6 +121,8 @@ const EXT_KIND: Record<string, FaviconKind> = {
xml: 'diagram',
dxf: 'cad',
dwg: 'cad',
ai: 'illustrator',
psd: 'photoshop',
'3dm': 'model3d',
'3ds': 'model3d',
'3mf': 'model3d',
@@ -0,0 +1,117 @@
import {
GlobalWorkerOptions,
getDocument,
RenderingCancelledException,
type PDFDocumentProxy,
type RenderTask,
} from 'pdfjs-dist';
const WORKER_SRC = '/viewer-assets/scanner/pdfjs/pdf.worker.min.mjs';
let workerConfigured = false;
let activeRender: RenderTask | null = null;
/** Serialize canvas renders so pdf.js never sees overlapping render() calls. */
let renderGate: Promise<void> = Promise.resolve();
function ensureWorker() {
if (workerConfigured) return;
GlobalWorkerOptions.workerSrc = WORKER_SRC;
workerConfigured = true;
}
/** PDF-compatible .ai embeds a PDF stream (often starts with %PDF). */
export function looksPdfCompatibleAi(data: ArrayBuffer): boolean {
const n = Math.min(data.byteLength, 8192);
if (n < 5) return false;
const text = new TextDecoder('latin1').decode(data.slice(0, n));
return text.includes('%PDF');
}
export const AI_INCOMPATIBLE_MESSAGE =
'PDF 호환으로 저장된 .ai만 열 수 있습니다. Illustrator에서 「PDF 호환 파일 만들기」로 저장한 뒤 다시 열어 주세요.';
export function isAiRenderCancelled(reason: unknown): boolean {
return (
reason instanceof RenderingCancelledException ||
(typeof reason === 'object' &&
reason !== null &&
'name' in reason &&
(reason as { name: string }).name === 'RenderingCancelledException')
);
}
export async function cancelAiRender(): Promise<void> {
const task = activeRender;
if (!task) return;
activeRender = null;
task.cancel();
try {
await task.promise;
} catch {
/* cancelled or failed after cancel */
}
}
export async function openAiAsPdf(data: ArrayBuffer): Promise<PDFDocumentProxy> {
ensureWorker();
if (!looksPdfCompatibleAi(data)) {
throw new Error(AI_INCOMPATIBLE_MESSAGE);
}
try {
return await getDocument({ data: data.slice(0) }).promise;
} catch {
throw new Error(AI_INCOMPATIBLE_MESSAGE);
}
}
export async function renderAiPage(
pdf: PDFDocumentProxy,
pageNumber: number,
canvas: HTMLCanvasElement,
scale: number,
): Promise<{ width: number; height: number }> {
// Interrupt in-flight work so the gate holder can exit quickly.
void cancelAiRender();
let release!: () => void;
const previous = renderGate;
renderGate = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
await cancelAiRender();
const page = await pdf.getPage(pageNumber);
try {
const viewport = page.getViewport({ scale });
canvas.width = Math.max(1, Math.floor(viewport.width));
canvas.height = Math.max(1, Math.floor(viewport.height));
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('캔버스를 초기화하지 못했습니다.');
}
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
const task = page.render({ canvasContext: context, viewport, canvas });
activeRender = task;
try {
await task.promise;
} finally {
if (activeRender === task) activeRender = null;
}
return { width: canvas.width, height: canvas.height };
} finally {
page.cleanup();
}
} finally {
release();
}
}
export function isAiFile(file: File): boolean {
return /\.ai$/i.test(file.name) || file.type === 'application/postscript';
}
@@ -0,0 +1,57 @@
import { readPsd, type Psd } from 'ag-psd';
export const PSD_INCOMPATIBLE_MESSAGE =
'이 브라우저에서는 RGB·Grayscale·Bitmap / 8bit .psd만 안정적으로 볼 수 있습니다. CMYK·16bit·PSB는 지원하지 않습니다.';
export type OpenedPsd = {
width: number;
height: number;
/** Composite image produced by ag-psd */
canvas: HTMLCanvasElement;
};
export function isPsdFile(file: File): boolean {
return (
/\.psd$/i.test(file.name) || file.type === 'image/vnd.adobe.photoshop'
);
}
export function openPsd(data: ArrayBuffer): OpenedPsd {
let psd: Psd;
try {
psd = readPsd(data);
} catch {
throw new Error(PSD_INCOMPATIBLE_MESSAGE);
}
const source = psd.canvas;
if (!source || source.width < 1 || source.height < 1) {
throw new Error(PSD_INCOMPATIBLE_MESSAGE);
}
return {
width: psd.width || source.width,
height: psd.height || source.height,
canvas: source as HTMLCanvasElement,
};
}
export function drawPsdToCanvas(
source: HTMLCanvasElement,
target: HTMLCanvasElement,
scale: number,
): { width: number; height: number } {
const width = Math.max(1, Math.floor(source.width * scale));
const height = Math.max(1, Math.floor(source.height * scale));
target.width = width;
target.height = height;
const context = target.getContext('2d', { alpha: true });
if (!context) {
throw new Error('캔버스를 초기화하지 못했습니다.');
}
context.clearRect(0, 0, width, height);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(source, 0, 0, width, height);
return { width, height };
}
+10
View File
@@ -44,6 +44,16 @@ const router = createRouter({
name: 'model3d-viewer',
component: () => import('@/views/Model3DViewerView.vue'),
},
{
path: '/viewer/illustrator',
name: 'illustrator-viewer',
component: () => import('@/views/IllustratorViewerView.vue'),
},
{
path: '/viewer/photoshop',
name: 'photoshop-viewer',
component: () => import('@/views/PhotoshopViewerView.vue'),
},
{
path: '/tools/scanner',
name: 'tools-scanner',
+18 -5
View File
@@ -60,6 +60,18 @@ const viewerTypes = [
title: '3D 모델',
subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등',
},
{
route: 'illustrator-viewer',
extension: 'ai',
title: '일러스트',
subtitle: 'Adobe Illustrator (.ai, PDF 호환)',
},
{
route: 'photoshop-viewer',
extension: 'psd',
title: '포토샵',
subtitle: 'Adobe Photoshop (.psd, RGB 8bit)',
},
] as const;
const toolTypes = [
@@ -122,7 +134,7 @@ function onOpenTool(route: string) {
<input
class="drop__input"
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,.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"
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"
multiple
:disabled="busy"
@change="onInput"
@@ -131,7 +143,7 @@ function onOpenTool(route: string) {
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span>
<span class="drop__hint">
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 이미지(스캐너)
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너)
</span>
</label>
@@ -185,10 +197,11 @@ function onOpenTool(route: string) {
</section>
<section class="section">
<h2 class="section__title">CAD·3D 모델</h2>
<h2 class="section__title">뷰어</h2>
<p class="section__lead">
서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조
파일이나 ZIP은 원본과 함께 선택하세요.
서버 업로드 없이 도면·모델·일러스트·포토샵을 조회합니다. 텍스처·MTL·BIN
등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는
RGB·8bit만 안정적으로 열립니다.
</p>
<div class="grid">
@@ -0,0 +1,476 @@
<script setup lang="ts">
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
import type { PDFDocumentProxy } from 'pdfjs-dist';
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 {
AI_INCOMPATIBLE_MESSAGE,
cancelAiRender,
isAiFile,
isAiRenderCancelled,
openAiAsPdf,
renderAiPage,
} from '@/integrations/illustrator/loadAi';
defineOptions({ name: 'IllustratorViewerView' });
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const stageRef = ref<HTMLElement | null>(null);
const filename = ref('일러스트');
const error = ref('');
const notice = ref('');
const busy = ref(false);
const page = ref(1);
const pageCount = ref(0);
const zoom = ref(1);
const fitZoom = ref(1);
let pdfDoc: PDFDocumentProxy | null = null;
let renderSeq = 0;
usePageTitle(filename, { faviconKind: 'illustrator' });
const pageLabel = computed(() =>
pageCount.value > 0 ? `${page.value} / ${pageCount.value}` : '—',
);
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
async function disposePdf() {
renderSeq += 1;
await cancelAiRender();
if (pdfDoc) {
try {
await pdfDoc.cleanup();
} catch {
/* ignore */
}
pdfDoc = null;
}
pageCount.value = 0;
page.value = 1;
}
async function paint() {
const canvas = canvasRef.value;
if (!pdfDoc || !canvas || pageCount.value < 1) return;
const seq = ++renderSeq;
// Coalesce rapid page/zoom updates onto the latest request.
await Promise.resolve();
if (seq !== renderSeq) return;
busy.value = true;
try {
await renderAiPage(pdfDoc, page.value, canvas, zoom.value);
if (seq !== renderSeq) return;
error.value = '';
} catch (reason) {
if (seq !== renderSeq || isAiRenderCancelled(reason)) return;
error.value =
reason instanceof Error ? reason.message : '페이지를 그리지 못했습니다.';
} finally {
if (seq === renderSeq) busy.value = false;
}
}
async function updateFitZoom() {
if (!pdfDoc || !stageRef.value) return;
try {
const p = await pdfDoc.getPage(page.value);
const viewport = p.getViewport({ scale: 1 });
p.cleanup();
const pad = 32;
const availW = Math.max(120, stageRef.value.clientWidth - pad);
const availH = Math.max(120, stageRef.value.clientHeight - pad);
const sx = availW / viewport.width;
const sy = availH / viewport.height;
fitZoom.value = Math.max(0.1, Math.min(sx, sy, 4));
} catch {
fitZoom.value = 1;
}
}
async function loadFile(file: File) {
if (!isAiFile(file)) {
error.value = '.ai 파일을 선택해 주세요.';
return;
}
busy.value = true;
error.value = '';
notice.value = '';
filename.value = file.name;
await disposePdf();
try {
const data = await file.arrayBuffer();
pdfDoc = await openAiAsPdf(data);
pageCount.value = pdfDoc.numPages;
page.value = 1;
await nextTick();
await updateFitZoom();
zoom.value = fitZoom.value;
await paint();
if (pdfDoc.numPages > 1) {
notice.value = `아트보드(페이지) ${pdfDoc.numPages}개`;
}
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : AI_INCOMPATIBLE_MESSAGE;
await disposePdf();
} finally {
busy.value = false;
}
}
function applyFiles(files: File[]) {
const file = files.find((item) => isAiFile(item));
if (!file) {
if (files.length) error.value = '.ai 파일을 선택해 주세요.';
return;
}
void loadFile(file);
}
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useFileDropZone(applyFiles);
onMounted(() => {
applyFiles(consumeViewerFiles('illustrator'));
});
onActivated(() => {
const files = consumeViewerFiles('illustrator');
if (files.length) applyFiles(files);
});
onBeforeUnmount(() => {
void disposePdf();
});
watch([page, zoom], () => {
if (!pdfDoc || pageCount.value < 1) return;
void paint();
});
function onOpen() {
fileInput.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (files.length) applyFiles(files);
}
function onClose() {
void router.push({ name: 'home' });
}
function zoomBy(factor: number) {
zoom.value = Math.min(4, Math.max(0.1, Math.round(zoom.value * factor * 100) / 100));
}
async function zoomFit() {
await updateFitZoom();
zoom.value = fitZoom.value;
}
function zoomReset() {
zoom.value = 1;
}
function prevPage() {
if (page.value > 1) page.value -= 1;
}
function nextPage() {
if (page.value < pageCount.value) page.value += 1;
}
</script>
<template>
<AppShell :editor="true">
<div
class="ai-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<EditorToolbar
:filename="filename"
:show-save="false"
:busy="busy"
@open="onOpen"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".ai,application/postscript"
@change="onFileSelected"
/>
<div v-if="pageCount > 0" class="ai-controls">
<div class="ai-controls__group">
<button
type="button"
class="ai-btn"
:disabled="page <= 1 || busy"
@click="prevPage"
>
이전
</button>
<span class="ai-controls__label">{{ pageLabel }}</span>
<button
type="button"
class="ai-btn"
:disabled="page >= pageCount || busy"
@click="nextPage"
>
다음
</button>
</div>
<div class="ai-controls__group">
<button type="button" class="ai-btn" :disabled="busy" @click="zoomBy(1 / 1.25)">
−
</button>
<span class="ai-controls__label">{{ zoomLabel }}</span>
<button type="button" class="ai-btn" :disabled="busy" @click="zoomBy(1.25)">
+
</button>
<button type="button" class="ai-btn" :disabled="busy" @click="zoomFit">
맞춤
</button>
<button type="button" class="ai-btn" :disabled="busy" @click="zoomReset">
100%
</button>
</div>
</div>
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
{{ error }}
</p>
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
<div ref="stageRef" class="ai-stage">
<canvas
v-show="pageCount > 0 && !error"
ref="canvasRef"
class="ai-canvas"
/>
<div v-if="!pageCount && !error && !busy" class="empty-state">
<strong>Illustrator (.ai) 파일을 선택해 주세요.</strong>
<span>
PDF 호환으로 저장된 .ai만 브라우저에서 볼 수 있습니다. 파일을 열거나
이 영역으로 끌어다 놓으세요.
</span>
<button type="button" @click="onOpen">파일 열기</button>
</div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
.ai 파일을 놓으세요
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.ai-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
position: relative;
}
.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;
}
.ai-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.ai-controls__group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.ai-controls__label {
min-width: 3.5rem;
text-align: center;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
color: var(--ink-soft);
}
.ai-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.28rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink);
cursor: pointer;
}
.ai-btn:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
background: var(--card-hover);
}
.ai-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.85rem;
font-size: 0.82rem;
color: var(--ink-soft);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.viewer-message--error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
}
.ai-stage {
flex: 1;
min-height: 0;
position: relative;
overflow: auto;
display: grid;
place-items: center;
padding: 1rem;
background:
linear-gradient(45deg, #ececec 25%, transparent 25%),
linear-gradient(-45deg, #ececec 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ececec 75%),
linear-gradient(-45deg, transparent 75%, #ececec 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-color: #f7f7f7;
}
@media (prefers-color-scheme: dark) {
.ai-stage {
background-color: #1a1a1a;
background-image:
linear-gradient(45deg, #242424 25%, transparent 25%),
linear-gradient(-45deg, #242424 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #242424 75%),
linear-gradient(-45deg, transparent 75%, #242424 75%);
}
}
.ai-canvas {
display: block;
max-width: none;
box-shadow: 0 4px 24px rgb(0 0 0 / 18%);
background: #fff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.55rem;
max-width: 28rem;
padding: 1.25rem 1.35rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
}
.empty-state strong {
font-size: 1rem;
}
.empty-state span {
font-size: 0.88rem;
color: var(--muted);
line-height: 1.45;
}
.empty-state button {
font: inherit;
font-size: 0.86rem;
font-weight: 600;
margin-top: 0.25rem;
padding: 0.4rem 0.85rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.drop-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--ink);
font-weight: 650;
font-size: 1.05rem;
pointer-events: none;
z-index: 2;
}
.ai-page.is-drop-active .ai-stage {
outline: 2px dashed var(--accent);
outline-offset: -8px;
}
</style>
@@ -0,0 +1,421 @@
<script setup lang="ts">
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
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 {
drawPsdToCanvas,
isPsdFile,
openPsd,
PSD_INCOMPATIBLE_MESSAGE,
type OpenedPsd,
} from '@/integrations/photoshop/loadPsd';
defineOptions({ name: 'PhotoshopViewerView' });
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const stageRef = ref<HTMLElement | null>(null);
const filename = ref('포토샵');
const error = ref('');
const notice = ref('');
const busy = ref(false);
const hasImage = ref(false);
const zoom = ref(1);
const fitZoom = ref(1);
let opened: OpenedPsd | null = null;
let renderSeq = 0;
usePageTitle(filename, { faviconKind: 'photoshop' });
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
function disposePsd() {
renderSeq += 1;
opened = null;
hasImage.value = false;
}
function paint() {
const canvas = canvasRef.value;
if (!opened || !canvas) return;
const seq = ++renderSeq;
try {
drawPsdToCanvas(opened.canvas, canvas, zoom.value);
if (seq !== renderSeq) return;
error.value = '';
} catch (reason) {
if (seq !== renderSeq) return;
error.value =
reason instanceof Error ? reason.message : '이미지를 그리지 못했습니다.';
}
}
function updateFitZoom() {
if (!opened || !stageRef.value) {
fitZoom.value = 1;
return;
}
const pad = 32;
const availW = Math.max(120, stageRef.value.clientWidth - pad);
const availH = Math.max(120, stageRef.value.clientHeight - pad);
const sx = availW / opened.width;
const sy = availH / opened.height;
fitZoom.value = Math.max(0.1, Math.min(sx, sy, 4));
}
async function loadFile(file: File) {
if (!isPsdFile(file)) {
error.value = '.psd 파일을 선택해 주세요.';
return;
}
busy.value = true;
error.value = '';
notice.value = '';
filename.value = file.name;
disposePsd();
try {
const data = await file.arrayBuffer();
opened = openPsd(data);
hasImage.value = true;
await nextTick();
updateFitZoom();
zoom.value = fitZoom.value;
paint();
notice.value = `${opened.width} × ${opened.height}px`;
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : PSD_INCOMPATIBLE_MESSAGE;
disposePsd();
} finally {
busy.value = false;
}
}
function applyFiles(files: File[]) {
const file = files.find((item) => isPsdFile(item));
if (!file) {
if (files.length) error.value = '.psd 파일을 선택해 주세요.';
return;
}
void loadFile(file);
}
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useFileDropZone(applyFiles);
onMounted(() => {
applyFiles(consumeViewerFiles('photoshop'));
});
onActivated(() => {
const files = consumeViewerFiles('photoshop');
if (files.length) applyFiles(files);
});
onBeforeUnmount(() => {
disposePsd();
});
watch(zoom, () => {
if (!opened) return;
paint();
});
function onOpen() {
fileInput.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (files.length) applyFiles(files);
}
function onClose() {
void router.push({ name: 'home' });
}
function zoomBy(factor: number) {
zoom.value = Math.min(
4,
Math.max(0.1, Math.round(zoom.value * factor * 100) / 100),
);
}
function zoomFit() {
updateFitZoom();
zoom.value = fitZoom.value;
}
function zoomReset() {
zoom.value = 1;
}
</script>
<template>
<AppShell :editor="true">
<div
class="psd-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<EditorToolbar
:filename="filename"
:show-save="false"
:busy="busy"
@open="onOpen"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".psd,image/vnd.adobe.photoshop"
@change="onFileSelected"
/>
<div v-if="hasImage" class="psd-controls">
<div class="psd-controls__group">
<button type="button" class="psd-btn" :disabled="busy" @click="zoomBy(1 / 1.25)">
−
</button>
<span class="psd-controls__label">{{ zoomLabel }}</span>
<button type="button" class="psd-btn" :disabled="busy" @click="zoomBy(1.25)">
+
</button>
<button type="button" class="psd-btn" :disabled="busy" @click="zoomFit">
맞춤
</button>
<button type="button" class="psd-btn" :disabled="busy" @click="zoomReset">
100%
</button>
</div>
</div>
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
{{ error }}
</p>
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
<div ref="stageRef" class="psd-stage">
<canvas
v-show="hasImage && !error"
ref="canvasRef"
class="psd-canvas"
/>
<div v-if="!hasImage && !error && !busy" class="empty-state">
<strong>Photoshop (.psd) 파일을 선택해 주세요.</strong>
<span>
RGB·Grayscale·Bitmap / 8bit .psd만 브라우저에서 볼 수 있습니다.
파일을 열거나 이 영역으로 끌어다 놓으세요.
</span>
<button type="button" @click="onOpen">파일 열기</button>
</div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
.psd 파일을 놓으세요
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.psd-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
position: relative;
}
.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;
}
.psd-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.psd-controls__group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.psd-controls__label {
min-width: 3.5rem;
text-align: center;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
color: var(--ink-soft);
}
.psd-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.28rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink);
cursor: pointer;
}
.psd-btn:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
background: var(--card-hover);
}
.psd-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.85rem;
font-size: 0.82rem;
color: var(--ink-soft);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.viewer-message--error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
}
.psd-stage {
flex: 1;
min-height: 0;
position: relative;
overflow: auto;
display: grid;
place-items: center;
padding: 1rem;
background:
linear-gradient(45deg, #ececec 25%, transparent 25%),
linear-gradient(-45deg, #ececec 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ececec 75%),
linear-gradient(-45deg, transparent 75%, #ececec 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-color: #f7f7f7;
}
@media (prefers-color-scheme: dark) {
.psd-stage {
background-color: #1a1a1a;
background-image:
linear-gradient(45deg, #242424 25%, transparent 25%),
linear-gradient(-45deg, #242424 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #242424 75%),
linear-gradient(-45deg, transparent 75%, #242424 75%);
}
}
.psd-canvas {
display: block;
max-width: none;
box-shadow: 0 4px 24px rgb(0 0 0 / 18%);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.55rem;
max-width: 28rem;
padding: 1.25rem 1.35rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
}
.empty-state strong {
font-size: 1rem;
}
.empty-state span {
font-size: 0.88rem;
color: var(--muted);
line-height: 1.45;
}
.empty-state button {
font: inherit;
font-size: 0.86rem;
font-weight: 600;
margin-top: 0.25rem;
padding: 0.4rem 0.85rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.drop-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--ink);
font-weight: 650;
font-size: 1.05rem;
pointer-events: none;
z-index: 2;
}
.psd-page.is-drop-active .psd-stage {
outline: 2px dashed var(--accent);
outline-offset: -8px;
}
</style>