.ai, .psd support
This commit is contained in:
1 parent
f5d880caf5
commit
2967548ff3
11 files changed
+1136
-6
No files matched your search
@@ -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',
|
||||
]"
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user