Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/image.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0">
7 <link rel="manifest" href="/manifest.json">
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
9 <meta name="apple-mobile-web-app-title" content="비나래 라운지">
10 <meta name="apple-mobile-web-app-capable" content="yes">
11 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12 <meta property="og:image" content="/image/lounge1.png" />
13 <meta property="og:description" content="이미지를 불러와 원하는 위치에 텍스트를 추가하고 저장할 수 있어요." />
14 <meta property="og:url" content="hinana.moe/hinana/image" />
15 <meta property="og:title" content="이미지 편집기 — 비나래 라운지" />
16 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
17 <title>이미지 편집기 — 비나래 라운지</title>
18
19 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
20 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
21 <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&family=Montserrat:wght@300;400;700&display=swap" rel="stylesheet">
22 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
23 <script src="/js/popup.js"></script>
24 <style>
25 @font-face {
26 font-family: 'SeoulHangangM';
27 src: url('/css/SeoulHangangM.woff') format('woff');
28 }
29 @font-face {
30 font-family: 'SeoulNamsanM';
31 src: url('/css/SeoulNamsanM_1.woff') format('woff');
32 }
33 </style>
34
35 <style>
36 :root {
37 --font-family: 'Noto Sans KR', sans-serif;
38 --bg-main: #f8f7f5; --bg-secondary: #ffffff; --bg-tertiary: #1a2238;
39 --text-primary: #1a2238; --text-secondary: #5e6676;
40 --accent-color: #c5a059; --border-color: #e5e1da;
41 --shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
42 }
43 body.dark-mode {
44 --bg-main: #111827; --bg-secondary: #1f2937; --bg-tertiary: #111827;
45 --text-primary: #f3f4f6; --text-secondary: #9ca3af;
46 --border-color: #374151; --accent-color: #f59e0b;
47 }
48
49 *, *::before, *::after { box-sizing: border-box; }
50 html, body { margin: 0; padding: 0; font-family: var(--font-family); background: var(--bg-main); color: var(--text-primary); min-height: 100vh; }
51 a { text-decoration: none; color: inherit; }
52 .footer-sign { mix-blend-mode: multiply; }
53 body.dark-mode .footer-sign { mix-blend-mode: screen; }
54
55 /* Header */
56 .site-header {
57 background: var(--bg-tertiary); color: #fff;
58 height: 60px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
59 }
60 .site-header a { color: #fff; opacity: 0.85; font-size: 0.9rem; }
61 .site-header a:hover { opacity: 1; }
62 .site-header .brand { font-weight: 700; font-size: 1rem; opacity: 1; display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
63 .site-header .brand > span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
64 .header-logo { height: 26px; width: auto; max-width: 170px; object-fit: contain; }
65
66 /* Layout */
67 .page-body { display: flex; gap: 0; min-height: calc(100vh - 53px); }
68
69 /* Left panel */
70 .panel {
71 width: 280px; min-width: 280px; background: var(--bg-secondary);
72 border-right: 1px solid var(--border-color);
73 display: flex; flex-direction: column; gap: 0; overflow-y: auto;
74 }
75 .panel-section { padding: 16px; border-bottom: 1px solid var(--border-color); }
76 .panel-section:last-child { border-bottom: none; }
77 .panel-title {
78 font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
79 letter-spacing: 0.08em; color: var(--text-secondary); margin-bottom: 10px;
80 }
81
82 .form-label-sm { font-size: 0.78rem; color: var(--text-secondary); margin-bottom: 4px; display: block; }
83 .form-control-sm, .form-select-sm {
84 font-size: 0.85rem; padding: 6px 10px;
85 border: 1px solid var(--border-color); border-radius: 6px;
86 background: var(--bg-main); color: var(--text-primary); width: 100%;
87 }
88 .form-control-sm:focus, .form-select-sm:focus { outline: none; border-color: var(--accent-color); }
89 body.dark-mode .form-control-sm, body.dark-mode .form-select-sm { background: #374151; border-color: #4b5563; }
90
91 .color-row { display: flex; align-items: center; gap: 8px; }
92 .color-input { width: 36px; height: 32px; border: 1px solid var(--border-color); border-radius: 6px; padding: 2px; cursor: pointer; background: none; }
93
94 .btn-accent {
95 background: var(--accent-color); color: #fff; border: none;
96 padding: 8px 14px; border-radius: 8px; font-size: 0.85rem; font-weight: 600;
97 cursor: pointer; width: 100%; transition: opacity 0.2s;
98 }
99 .btn-accent:hover { opacity: 0.88; }
100 .btn-outline-sm {
101 background: transparent; border: 1px solid var(--border-color);
102 color: var(--text-primary); padding: 6px 12px; border-radius: 6px;
103 font-size: 0.82rem; cursor: pointer; transition: border-color 0.15s;
104 }
105 .btn-outline-sm:hover { border-color: var(--accent-color); color: var(--accent-color); }
106 .btn-danger-sm {
107 background: transparent; border: 1px solid #dc2626;
108 color: #dc2626; padding: 6px 12px; border-radius: 6px;
109 font-size: 0.82rem; cursor: pointer;
110 }
111 .btn-danger-sm:hover { background: #dc2626; color: #fff; }
112
113 /* Text item list */
114 .text-item {
115 display: flex; align-items: center; justify-content: space-between;
116 padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border-color);
117 margin-bottom: 6px; cursor: pointer; font-size: 0.8rem;
118 background: var(--bg-main); transition: border-color 0.15s;
119 }
120 .text-item:hover { border-color: var(--accent-color); }
121 .text-item.selected { border-color: var(--accent-color); background: rgba(197,160,89,0.08); }
122 .text-item-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; margin-right: 6px; }
123 .text-item-del { color: #dc2626; border: none; background: none; font-size: 0.9rem; cursor: pointer; padding: 0 2px; flex-shrink: 0; }
124
125 /* Canvas area */
126 .canvas-area {
127 flex: 1; display: flex; align-items: flex-start; justify-content: center;
128 padding: 32px; overflow: auto; background: var(--bg-main);
129 }
130 .canvas-wrap {
131 position: relative; display: inline-block;
132 box-shadow: 0 4px 24px rgba(0,0,0,0.15); border-radius: 4px; overflow: hidden;
133 cursor: crosshair;
134 }
135 #mainCanvas { display: block; }
136 .canvas-placeholder {
137 width: 600px; height: 400px;
138 background: var(--bg-secondary); border: 2px dashed var(--border-color);
139 border-radius: 8px; display: flex; flex-direction: column;
140 align-items: center; justify-content: center; gap: 12px;
141 color: var(--text-secondary); font-size: 0.9rem; cursor: pointer;
142 }
143 .canvas-placeholder i { font-size: 2.5rem; opacity: 0.4; }
144
145 /* Upload zone */
146 .upload-zone {
147 border: 2px dashed var(--border-color); border-radius: 8px;
148 padding: 18px; text-align: center; cursor: pointer;
149 color: var(--text-secondary); font-size: 0.82rem;
150 transition: border-color 0.2s;
151 }
152 .upload-zone:hover, .upload-zone.dragover { border-color: var(--accent-color); color: var(--text-primary); }
153 .upload-zone i { display: block; font-size: 1.5rem; margin-bottom: 6px; opacity: 0.5; }
154
155 /* Instruction */
156 .hint { font-size: 0.75rem; color: var(--text-secondary); margin-top: 6px; line-height: 1.5; }
157
158 /* Mobile */
159 @media (max-width: 768px) {
160 .page-body { flex-direction: column; }
161 .panel { width: 100%; min-width: unset; border-right: none; border-bottom: 1px solid var(--border-color); }
162 .canvas-area { padding: 16px; }
163 .site-header { padding: 0 12px; gap: 8px; }
164 .site-header .brand { gap: 8px; font-size: 0.82rem; }
165 .header-logo { max-width: 132px; height: 24px; }
166 .site-header .header-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
167 .site-header .header-actions a { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; font-size: 0; }
168 .site-header .header-actions a i { font-size: 1rem; }
169 }
170 </style>
171 </head>
172 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
173
174 <header class="site-header">
175 <div class="brand">
176 <a href="/hinana/lounge">
177 <img src="/image/lounge1.png" alt="비나래 라운지" class="header-logo">
178 </a>
179 <span style="opacity:0.4; font-weight:300;">|</span>
180 <span>이미지 편집기</span>
181 </div>
182 <div class="header-actions" style="display:flex; gap:16px; align-items:center;">
183 <a href="/hinana/lounge"><i class="bi bi-arrow-left"></i> 라운지로</a>
184 <form action="/toggle-theme" method="POST" style="margin:0;">
185 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
186 <button type="submit" style="background:none;border:none;color:#fff;font-size:1rem;cursor:pointer;opacity:0.8;" title="테마 변경">
187 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
188 </button>
189 </form>
190 </div>
191 </header>
192
193 <div class="page-body">
194
195 <!-- Left Panel -->
196 <div class="panel" id="panel">
197
198 <!-- Image upload -->
199 <div class="panel-section">
200 <div class="panel-title"><i class="bi bi-image me-1"></i>이미지</div>
201 <div class="upload-zone" id="uploadZone">
202 <i class="bi bi-upload"></i>
203 클릭하거나 파일을 드래그하세요
204 </div>
205 <input type="file" id="fileInput" accept="image/*" style="display:none;">
206 <p class="hint mt-2">JPG, PNG, GIF, WebP 지원</p>
207 </div>
208
209 <!-- Text properties -->
210 <div class="panel-section">
211 <div class="panel-title"><i class="bi bi-type me-1"></i>텍스트 추가</div>
212
213 <label class="form-label-sm">내용</label>
214 <textarea id="inputText" class="form-control-sm" rows="2" placeholder="입력할 텍스트..." style="resize:vertical;"></textarea>
215
216 <div class="mt-2">
217 <label class="form-label-sm">폰트</label>
218 <select id="inputFont" class="form-select-sm">
219 <option value="SeoulHangangM">서울 한강체</option>
220 <option value="SeoulNamsanM">서울 남산체</option>
221 <option value="Noto Sans KR">Noto Sans KR</option>
222 <option value="Arial">Arial</option>
223 <option value="Georgia">Georgia</option>
224 <option value="Montserrat">Montserrat</option>
225 <option value="Courier New">Courier New</option>
226 </select>
227 </div>
228
229 <div class="mt-2" style="display:flex; gap:8px;">
230 <div style="flex:1;">
231 <label class="form-label-sm">크기</label>
232 <input type="number" id="inputSize" class="form-control-sm" value="32" min="8" max="200">
233 </div>
234 <div style="flex:1;">
235 <label class="form-label-sm">굵기</label>
236 <select id="inputWeight" class="form-select-sm">
237 <option value="300">가늘게</option>
238 <option value="normal" selected>보통</option>
239 <option value="bold">굵게</option>
240 </select>
241 </div>
242 </div>
243
244 <div class="mt-2">
245 <label class="form-label-sm">색상</label>
246 <div class="color-row">
247 <input type="color" id="inputColor" class="color-input" value="#000000">
248 <input type="text" id="inputColorHex" class="form-control-sm" value="#000000" style="flex:1;" placeholder="#000000">
249 </div>
250 </div>
251
252 <div class="mt-2">
253 <label class="form-label-sm">불투명도</label>
254 <input type="range" id="inputOpacity" min="0" max="100" value="100" style="width:100%;">
255 <span id="opacityVal" class="hint">100%</span>
256 </div>
257
258 <div class="mt-2">
259 <label class="form-label-sm">테두리 (px, 0=없음)</label>
260 <div style="display:flex; gap:8px; align-items:center;">
261 <input type="number" id="inputStroke" class="form-control-sm" value="0" min="0" max="20" style="width:70px;">
262 <input type="color" id="inputStrokeColor" class="color-input" value="#000000">
263 </div>
264 </div>
265
266 <div class="mt-2">
267 <label class="form-label-sm" style="display:flex; align-items:center; gap:6px;">
268 <input type="checkbox" id="inputBgBox"> 배경 상자
269 </label>
270 <div id="bgBoxOptions" style="display:none; margin-top:6px;">
271 <div class="color-row">
272 <input type="color" id="inputBgColor" class="color-input" value="#000000">
273 <input type="text" id="inputBgColorHex" class="form-control-sm" value="#000000" style="flex:1;" placeholder="#000000">
274 </div>
275 <div style="margin-top:6px;">
276 <label class="form-label-sm">배경 불투명도</label>
277 <input type="range" id="inputBgOpacity" min="0" max="100" value="60" style="width:100%;">
278 <span id="bgOpacityVal" class="hint">60%</span>
279 </div>
280 <div style="margin-top:6px;">
281 <label class="form-label-sm">여백 (px)</label>
282 <input type="number" id="inputBgPadding" class="form-control-sm" value="8" min="0" max="60">
283 </div>
284 </div>
285 </div>
286
287 <button class="btn-accent mt-3" id="btnAddText">
288 <i class="bi bi-plus-lg me-1"></i>캔버스에 클릭해서 추가
289 </button>
290 <p class="hint">버튼 클릭 후 캔버스 원하는 위치를 클릭하세요.</p>
291 </div>
292
293 <!-- Text list -->
294 <div class="panel-section" id="textListSection" style="display:none;">
295 <div class="panel-title"><i class="bi bi-list-ul me-1"></i>텍스트 목록</div>
296 <div id="textList"></div>
297 <p class="hint">항목 클릭 → 선택 / 드래그로 이동</p>
298 </div>
299
300
301 <!-- Download -->
302 <div class="panel-section">
303 <div class="panel-title"><i class="bi bi-download me-1"></i>저장</div>
304 <div style="display:flex; gap:8px;">
305 <button class="btn-outline-sm" id="btnDownloadPng" style="flex:1;">PNG</button>
306 <button class="btn-outline-sm" id="btnDownloadJpg" style="flex:1;">JPG</button>
307 </div>
308 </div>
309
310 </div>
311
312 <!-- Canvas area -->
313 <div class="canvas-area" id="canvasArea">
314 <div class="canvas-placeholder" id="canvasPlaceholder">
315 <i class="bi bi-image"></i>
316 <span>이미지를 불러오면 여기에 표시됩니다</span>
317 <button class="btn-accent" style="width:auto; padding:8px 20px;" onclick="document.getElementById('fileInput').click()">
318 <i class="bi bi-folder2-open me-1"></i>이미지 열기
319 </button>
320 </div>
321 <div class="canvas-wrap" id="canvasWrap" style="display:none;">
322 <canvas id="mainCanvas"></canvas>
323 </div>
324 </div>
325
326 </div>
327
328 <script>
329 const fileInput = document.getElementById('fileInput');
330 const uploadZone = document.getElementById('uploadZone');
331 const canvas = document.getElementById('mainCanvas');
332 const ctx = canvas.getContext('2d');
333 const placeholder = document.getElementById('canvasPlaceholder');
334 const canvasWrap = document.getElementById('canvasWrap');
335 const textList = document.getElementById('textList');
336 const textListSection = document.getElementById('textListSection');
337
338 let baseImage = null; // HTMLImageElement
339 let texts = []; // [{ text, font, size, weight, color, opacity, stroke, strokeColor, x, y, id }]
340 let selectedId = null;
341 let pendingPlace = false; // 클릭해서 위치 지정 모드
342 let dragging = null; // { id, offsetX, offsetY }
343
344 // ── 이미지 로드 ──────────────────────────────────────────
345 uploadZone.addEventListener('click', () => fileInput.click());
346 uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
347 uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
348 uploadZone.addEventListener('drop', e => {
349 e.preventDefault();
350 uploadZone.classList.remove('dragover');
351 const file = e.dataTransfer.files[0];
352 if (file && file.type.startsWith('image/')) loadImageFile(file);
353 });
354 fileInput.addEventListener('change', () => {
355 if (fileInput.files[0]) loadImageFile(fileInput.files[0]);
356 });
357
358 let logicalW = 0, logicalH = 0;
359 let origW = 0, origH = 0;
360 const dpr = window.devicePixelRatio || 1;
361
362 function setCanvasSize(w, h) {
363 logicalW = w;
364 logicalH = h;
365 canvas.width = Math.round(w * dpr);
366 canvas.height = Math.round(h * dpr);
367 canvas.style.width = w + 'px';
368 canvas.style.height = h + 'px';
369 }
370
371 function loadImageFile(file) {
372 const reader = new FileReader();
373 reader.onload = e => {
374 const img = new Image();
375 img.onload = () => {
376 baseImage = img;
377 origW = img.naturalWidth;
378 origH = img.naturalHeight;
379
380 // 너비 기준으로 맞추고 세로 스크롤 허용 (원본보다 크게 표시하지 않음)
381 const areaW = document.getElementById('canvasArea').clientWidth - 32;
382 const scale = Math.min(areaW / origW, 1);
383 setCanvasSize(Math.round(origW * scale), Math.round(origH * scale));
384
385 placeholder.style.display = 'none';
386 canvasWrap.style.display = 'inline-block';
387 redraw();
388 };
389 img.src = e.target.result;
390 };
391 reader.readAsDataURL(file);
392 }
393
394 // ── 폰트 로드 보장 ───────────────────────────────────────
395 const loadedFonts = new Set();
396 async function ensureFont(weight, size, family) {
397 const key = `${weight}_${family}`;
398 if (loadedFonts.has(key)) return;
399 try {
400 await document.fonts.load(`${weight} ${size}px "${family}"`);
401 loadedFonts.add(key);
402 } catch(e) {}
403 }
404
405 // ── 렌더링 ──────────────────────────────────────────────
406 async function redraw() {
407 if (!baseImage) return;
408 // 모든 텍스트 폰트 미리 로드
409 await Promise.all(texts.map(t => ensureFont(t.weight, t.size, t.font)));
410 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
411 ctx.clearRect(0, 0, logicalW, logicalH);
412 ctx.drawImage(baseImage, 0, 0, logicalW, logicalH);
413 texts.forEach(t => drawText(t));
414 if (selectedId) drawSelection(texts.find(t => t.id === selectedId));
415 }
416
417 function drawTextOn(targetCtx, t) {
418 targetCtx.save();
419 targetCtx.font = `${t.weight} ${t.size}px "${t.font}"`;
420 targetCtx.textBaseline = 'top';
421
422 const lines = t.text.split('\n');
423 const lineHeight = t.size * 1.25;
424 const pad = t.bgPadding || 0;
425
426 if (t.bgBox) {
427 const maxW = Math.max(...lines.map(l => targetCtx.measureText(l).width));
428 const totalH = lines.length * lineHeight;
429 targetCtx.globalAlpha = (t.bgOpacity !== undefined ? t.bgOpacity : 60) / 100;
430 targetCtx.fillStyle = t.bgColor || '#000000';
431 targetCtx.fillRect(t.x - pad, t.y - pad, maxW + pad * 2, totalH + pad * 2);
432 }
433
434 targetCtx.globalAlpha = t.opacity / 100;
435 targetCtx.fillStyle = t.color;
436 lines.forEach((line, i) => {
437 const y = t.y + i * lineHeight;
438 if (t.stroke > 0) {
439 targetCtx.lineWidth = t.stroke * 2;
440 targetCtx.strokeStyle = t.strokeColor;
441 targetCtx.strokeText(line, t.x, y);
442 }
443 targetCtx.fillText(line, t.x, y);
444 });
445 targetCtx.restore();
446 }
447
448 function drawText(t) { drawTextOn(ctx, t); }
449
450 function drawSelection(t) {
451 if (!t) return;
452 const bbox = getTextBBox(t);
453 ctx.save();
454 ctx.strokeStyle = '#3b82f6';
455 ctx.lineWidth = 1.5;
456 ctx.setLineDash([5, 3]);
457 ctx.strokeRect(bbox.x - 4, bbox.y - 4, bbox.w + 8, bbox.h + 8);
458 ctx.restore();
459 }
460
461 function getTextBBox(t) {
462 ctx.font = `${t.weight} ${t.size}px "${t.font}"`;
463 const lines = t.text.split('\n');
464 const lineHeight = t.size * 1.25;
465 const maxW = Math.max(...lines.map(l => ctx.measureText(l).width));
466 return { x: t.x, y: t.y, w: maxW, h: lines.length * lineHeight };
467 }
468
469 // ── 텍스트 추가 (클릭 위치 지정) ────────────────────────
470 document.getElementById('btnAddText').addEventListener('click', () => {
471 if (!baseImage) {
472 showAlert('먼저 이미지를 불러오세요.');
473 return;
474 }
475 const txt = document.getElementById('inputText').value.trim();
476 if (!txt) {
477 showAlert('텍스트 내용을 입력하세요.');
478 return;
479 }
480 pendingPlace = true;
481 canvas.style.cursor = 'crosshair';
482 document.getElementById('btnAddText').textContent = '캔버스를 클릭하세요...';
483 });
484
485 canvas.addEventListener('click', async e => {
486 if (!pendingPlace) return;
487 const rect = canvas.getBoundingClientRect();
488 const x = e.clientX - rect.left;
489 const y = e.clientY - rect.top;
490
491 const font = document.getElementById('inputFont').value;
492 const weight = document.getElementById('inputWeight').value;
493 const size = parseInt(document.getElementById('inputSize').value) || 32;
494
495 // 폰트 로드 후 텍스트 추가
496 await ensureFont(weight, size, font);
497
498 const t = {
499 id: Date.now(),
500 text: document.getElementById('inputText').value,
501 font, size, weight,
502 color: document.getElementById('inputColor').value,
503 opacity: parseInt(document.getElementById('inputOpacity').value),
504 stroke: parseInt(document.getElementById('inputStroke').value) || 0,
505 strokeColor: document.getElementById('inputStrokeColor').value,
506 bgBox: document.getElementById('inputBgBox').checked,
507 bgColor: document.getElementById('inputBgColor').value,
508 bgOpacity: parseInt(document.getElementById('inputBgOpacity').value),
509 bgPadding: parseInt(document.getElementById('inputBgPadding').value) || 0,
510 x, y
511 };
512 texts.push(t);
513 selectedId = t.id;
514 pendingPlace = false;
515 canvas.style.cursor = 'default';
516 document.getElementById('btnAddText').innerHTML = '<i class="bi bi-plus-lg me-1"></i>캔버스에 클릭해서 추가';
517 updateTextList();
518 redraw();
519 });
520
521 // ── 드래그로 이동 ────────────────────────────────────────
522 canvas.addEventListener('mousedown', e => {
523 if (pendingPlace) return;
524 const rect = canvas.getBoundingClientRect();
525 const mx = e.clientX - rect.left;
526 const my = e.clientY - rect.top;
527
528 // 역순으로 탐색(위에 그려진 것 우선)
529 for (let i = texts.length - 1; i >= 0; i--) {
530 const bbox = getTextBBox(texts[i]);
531 if (mx >= bbox.x - 6 && mx <= bbox.x + bbox.w + 6 &&
532 my >= bbox.y - 6 && my <= bbox.y + bbox.h + 6) {
533 selectedId = texts[i].id;
534 dragging = { id: texts[i].id, offsetX: mx - texts[i].x, offsetY: my - texts[i].y };
535 canvas.style.cursor = 'grabbing';
536 redraw();
537 return;
538 }
539 }
540 selectedId = null;
541 redraw();
542 });
543
544 canvas.addEventListener('mousemove', e => {
545 if (!dragging) return;
546 const rect = canvas.getBoundingClientRect();
547 const mx = e.clientX - rect.left;
548 const my = e.clientY - rect.top;
549 const t = texts.find(t => t.id === dragging.id);
550 if (t) { t.x = mx - dragging.offsetX; t.y = my - dragging.offsetY; }
551 redraw();
552 });
553
554 canvas.addEventListener('mouseup', () => {
555 dragging = null;
556 canvas.style.cursor = pendingPlace ? 'crosshair' : 'default';
557 });
558
559 // 터치 드래그 (모바일)
560 canvas.addEventListener('touchstart', e => {
561 if (pendingPlace) return;
562 e.preventDefault();
563 const touch = e.touches[0];
564 const rect = canvas.getBoundingClientRect();
565 const mx = touch.clientX - rect.left;
566 const my = touch.clientY - rect.top;
567
568 for (let i = texts.length - 1; i >= 0; i--) {
569 const bbox = getTextBBox(texts[i]);
570 if (mx >= bbox.x - 10 && mx <= bbox.x + bbox.w + 10 &&
571 my >= bbox.y - 10 && my <= bbox.y + bbox.h + 10) {
572 selectedId = texts[i].id;
573 dragging = { id: texts[i].id, offsetX: mx - texts[i].x, offsetY: my - texts[i].y };
574 redraw();
575 return;
576 }
577 }
578 selectedId = null;
579 redraw();
580 }, { passive: false });
581
582 canvas.addEventListener('touchmove', e => {
583 if (!dragging) return;
584 e.preventDefault();
585 const touch = e.touches[0];
586 const rect = canvas.getBoundingClientRect();
587 const mx = touch.clientX - rect.left;
588 const my = touch.clientY - rect.top;
589 const t = texts.find(t => t.id === dragging.id);
590 if (t) { t.x = mx - dragging.offsetX; t.y = my - dragging.offsetY; }
591 redraw();
592 }, { passive: false });
593
594 canvas.addEventListener('touchend', () => { dragging = null; });
595
596 // 터치로 텍스트 위치 지정
597 canvas.addEventListener('touchend', e => {
598 if (!pendingPlace) return;
599 e.preventDefault();
600 const touch = e.changedTouches[0];
601 const rect = canvas.getBoundingClientRect();
602 canvas.dispatchEvent(new MouseEvent('click', {
603 clientX: touch.clientX,
604 clientY: touch.clientY
605 }));
606 }, { passive: false });
607
608 // ── 색상 동기화 ──────────────────────────────────────────
609 document.getElementById('inputColor').addEventListener('input', function () {
610 document.getElementById('inputColorHex').value = this.value;
611 });
612 document.getElementById('inputColorHex').addEventListener('input', function () {
613 if (/^#[0-9a-fA-F]{6}$/.test(this.value))
614 document.getElementById('inputColor').value = this.value;
615 });
616 document.getElementById('inputOpacity').addEventListener('input', function () {
617 document.getElementById('opacityVal').textContent = this.value + '%';
618 });
619 document.getElementById('inputBgBox').addEventListener('change', function () {
620 document.getElementById('bgBoxOptions').style.display = this.checked ? '' : 'none';
621 });
622 document.getElementById('inputBgColor').addEventListener('input', function () {
623 document.getElementById('inputBgColorHex').value = this.value;
624 });
625 document.getElementById('inputBgColorHex').addEventListener('input', function () {
626 if (/^#[0-9a-fA-F]{6}$/.test(this.value))
627 document.getElementById('inputBgColor').value = this.value;
628 });
629 document.getElementById('inputBgOpacity').addEventListener('input', function () {
630 document.getElementById('bgOpacityVal').textContent = this.value + '%';
631 });
632
633 // ── 텍스트 목록 ──────────────────────────────────────────
634 function updateTextList() {
635 textListSection.style.display = texts.length ? '' : 'none';
636 textList.innerHTML = '';
637 texts.forEach((t, i) => {
638 const row = document.createElement('div');
639 row.className = 'text-item' + (t.id === selectedId ? ' selected' : '');
640 row.innerHTML =
641 '<span class="text-item-label">' + (i + 1) + '. ' + escHtml(t.text.replace(/\n/g, ' ').substring(0, 24)) + '</span>' +
642 '<button class="text-item-del" title="삭제"><i class="bi bi-x"></i></button>';
643 row.querySelector('.text-item-del').addEventListener('click', ev => {
644 ev.stopPropagation();
645 texts = texts.filter(x => x.id !== t.id);
646 if (selectedId === t.id) selectedId = null;
647 updateTextList();
648 redraw();
649 });
650 row.addEventListener('click', () => {
651 selectedId = t.id;
652 updateTextList();
653 redraw();
654 });
655 textList.appendChild(row);
656 });
657 }
658
659 function escHtml(s) {
660 return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
661 }
662
663 // ── 키보드 Delete ──────────────────────────────────────
664 document.addEventListener('keydown', e => {
665 if ((e.key === 'Delete' || e.key === 'Backspace') &&
666 selectedId && document.activeElement.tagName !== 'INPUT' &&
667 document.activeElement.tagName !== 'TEXTAREA') {
668 texts = texts.filter(t => t.id !== selectedId);
669 selectedId = null;
670 updateTextList();
671 redraw();
672 }
673 });
674
675 // ── 다운로드 (원본 해상도로 렌더링) ─────────────────────
676 async function download(type) {
677 if (!baseImage) { showAlert('이미지를 먼저 불러오세요.'); return; }
678
679 // 원본 해상도 오프스크린 캔버스
680 const exportCanvas = document.createElement('canvas');
681 exportCanvas.width = origW;
682 exportCanvas.height = origH;
683 const exportCtx = exportCanvas.getContext('2d');
684
685 // 원본 이미지 그리기
686 exportCtx.drawImage(baseImage, 0, 0, origW, origH);
687
688 // 텍스트: 표시 좌표 → 원본 좌표로 역산
689 const sf = origW / logicalW;
690 await Promise.all(texts.map(t =>
691 document.fonts.load(`${t.weight} ${Math.round(t.size * sf)}px "${t.font}"`)
692 ));
693 const prev = selectedId;
694 selectedId = null;
695 texts.forEach(t => drawTextOn(exportCtx, {
696 ...t,
697 x: t.x * sf,
698 y: t.y * sf,
699 size: t.size * sf,
700 stroke: t.stroke * sf,
701 }));
702 selectedId = prev;
703
704 const mime = type === 'jpg' ? 'image/jpeg' : 'image/png';
705 const quality = type === 'jpg' ? 0.92 : undefined;
706 const url = exportCanvas.toDataURL(mime, quality);
707 const a = document.createElement('a');
708 a.href = url;
709 a.download = 'image_edit.' + type;
710 a.click();
711 }
712
713 document.getElementById('btnDownloadPng').addEventListener('click', () => download('png'));
714 document.getElementById('btnDownloadJpg').addEventListener('click', () => download('jpg'));
715
716 </script>
717
718 <footer style="text-align:center; padding: 40px 20px; border-top: 1px solid var(--border-color); background: var(--bg-secondary); margin-top: auto;">
719 <img src="/image/sign.png" class="footer-sign" style="width: 160px; opacity: 0.7;">
720 <div style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8; margin-top: 16px;">
721 <strong>비나래 라운지</strong><br>
722 X - @NoctchillHinana<br>
723 ⓒ 2024~2026. 비나래 | hinana.moe
724 </div>
725 </footer>
726 </body>
727 </html>
728