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