Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
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