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