.ai, .psd support
This commit is contained in:
1 parent
f5d880caf5
commit
2967548ff3
11 files changed
+1136
-6
No files matched your search
Generated
+17
@@ -4309,6 +4309,22 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ag-psd": {
|
||||||
|
"version": "31.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-psd/-/ag-psd-31.0.2.tgz",
|
||||||
|
"integrity": "sha512-5s5PvqbomPIIJ9YjL6robz55rT4RYPiQkww4brisyZEb5jzlHB1EKh47IJg7gIFj0RUrD0cMgdntM907qaZjXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "1.5.1",
|
||||||
|
"pako": "2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ag-psd/node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/ai": {
|
"node_modules/ai": {
|
||||||
"version": "5.0.217",
|
"version": "5.0.217",
|
||||||
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.217.tgz",
|
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.217.tgz",
|
||||||
@@ -9349,6 +9365,7 @@
|
|||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@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",
|
||||||
"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",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@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",
|
||||||
"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",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const viewKey = computed(() => {
|
|||||||
route.name === 'drawio-editor' ||
|
route.name === 'drawio-editor' ||
|
||||||
route.name === 'cad-viewer' ||
|
route.name === 'cad-viewer' ||
|
||||||
route.name === 'model3d-viewer' ||
|
route.name === 'model3d-viewer' ||
|
||||||
|
route.name === 'illustrator-viewer' ||
|
||||||
|
route.name === 'photoshop-viewer' ||
|
||||||
route.name === 'tools-scanner' ||
|
route.name === 'tools-scanner' ||
|
||||||
route.name === 'tools-bg-remove'
|
route.name === 'tools-bg-remove'
|
||||||
) {
|
) {
|
||||||
@@ -44,6 +46,8 @@ const viewKey = computed(() => {
|
|||||||
'DrawioEditorView',
|
'DrawioEditorView',
|
||||||
'CadViewerView',
|
'CadViewerView',
|
||||||
'Model3DViewerView',
|
'Model3DViewerView',
|
||||||
|
'IllustratorViewerView',
|
||||||
|
'PhotoshopViewerView',
|
||||||
'ToolsScannerView',
|
'ToolsScannerView',
|
||||||
'ToolsBgRemoveView',
|
'ToolsBgRemoveView',
|
||||||
]"
|
]"
|
||||||
|
|||||||
@@ -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";
|
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop";
|
||||||
export type ToolKind = "scanner";
|
export type ToolKind = "scanner";
|
||||||
|
|
||||||
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||||
@@ -66,6 +66,8 @@ export const MODEL3D_EXTENSIONS = [
|
|||||||
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
||||||
dxf: "cad",
|
dxf: "cad",
|
||||||
dwg: "cad",
|
dwg: "cad",
|
||||||
|
ai: "illustrator",
|
||||||
|
psd: "photoshop",
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export type FaviconKind =
|
|||||||
| 'diagram'
|
| 'diagram'
|
||||||
| 'cad'
|
| 'cad'
|
||||||
| 'model3d'
|
| 'model3d'
|
||||||
|
| 'illustrator'
|
||||||
|
| 'photoshop'
|
||||||
| 'scanner'
|
| 'scanner'
|
||||||
| 'bgremove'
|
| 'bgremove'
|
||||||
| 'text'
|
| 'text'
|
||||||
@@ -40,6 +42,12 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
|
|||||||
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
||||||
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
|
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',
|
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',
|
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
|
||||||
bgremove:
|
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',
|
'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',
|
diagram: '#2e7d8a',
|
||||||
cad: '#5c6bc0',
|
cad: '#5c6bc0',
|
||||||
model3d: '#6d4c41',
|
model3d: '#6d4c41',
|
||||||
|
illustrator: '#f57c00',
|
||||||
|
photoshop: '#31a8ff',
|
||||||
scanner: '#00897b',
|
scanner: '#00897b',
|
||||||
bgremove: '#5c6bc0',
|
bgremove: '#5c6bc0',
|
||||||
text: '#607d8b',
|
text: '#607d8b',
|
||||||
@@ -111,6 +121,8 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
xml: 'diagram',
|
xml: 'diagram',
|
||||||
dxf: 'cad',
|
dxf: 'cad',
|
||||||
dwg: 'cad',
|
dwg: 'cad',
|
||||||
|
ai: 'illustrator',
|
||||||
|
psd: 'photoshop',
|
||||||
'3dm': 'model3d',
|
'3dm': 'model3d',
|
||||||
'3ds': 'model3d',
|
'3ds': 'model3d',
|
||||||
'3mf': '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 };
|
||||||
|
}
|
||||||
@@ -44,6 +44,16 @@ const router = createRouter({
|
|||||||
name: 'model3d-viewer',
|
name: 'model3d-viewer',
|
||||||
component: () => import('@/views/Model3DViewerView.vue'),
|
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',
|
path: '/tools/scanner',
|
||||||
name: 'tools-scanner',
|
name: 'tools-scanner',
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ const viewerTypes = [
|
|||||||
title: '3D 모델',
|
title: '3D 모델',
|
||||||
subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등',
|
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;
|
] as const;
|
||||||
|
|
||||||
const toolTypes = [
|
const toolTypes = [
|
||||||
@@ -122,7 +134,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,.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
|
multiple
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@change="onInput"
|
@change="onInput"
|
||||||
@@ -131,7 +143,7 @@ function onOpenTool(route: string) {
|
|||||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="drop__hint">
|
<span class="drop__hint">
|
||||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 이미지(스캐너)
|
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -185,10 +197,11 @@ function onOpenTool(route: string) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section__title">CAD·3D 모델</h2>
|
<h2 class="section__title">뷰어</h2>
|
||||||
<p class="section__lead">
|
<p class="section__lead">
|
||||||
서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조
|
서버 업로드 없이 도면·모델·일러스트·포토샵을 조회합니다. 텍스처·MTL·BIN
|
||||||
파일이나 ZIP은 원본과 함께 선택하세요.
|
등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는
|
||||||
|
RGB·8bit만 안정적으로 열립니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid">
|
<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>
|
||||||
Reference in new issue
Block a user