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
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
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