- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
345 lines
13 KiB
HTML
345 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>그리기 에디터 대화상자 (HTML 텍스트입력)</title>
|
||
<!-- Bootstrap 5.3 CSS + Icons -->
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
|
||
<style>
|
||
body { padding: 1rem; }
|
||
dialog {
|
||
width: 90vw; height: 90vh;
|
||
border: none; border-radius: .5rem;
|
||
padding: 0; overflow: hidden;
|
||
}
|
||
dialog::backdrop { background: rgba(0,0,0,.5); }
|
||
#editorHeader {
|
||
background: #f8f9fa; padding: .5rem 1rem;
|
||
border-bottom: 1px solid #dee2e6;
|
||
}
|
||
#editorHeader h5 { margin: 0; display: inline; }
|
||
#editorHeader .btn-close { float: right; }
|
||
#editorToolbar, #editorToolbar2 {
|
||
background: #fff;
|
||
padding: .5rem; gap: .25rem;
|
||
}
|
||
#editorToolbar { display: flex; align-items: center; flex-wrap: wrap; }
|
||
#editorToolbar2 { display: flex; align-items: center; }
|
||
.toolbar-btn { width: 40px; height: 40px; padding: 0; }
|
||
.toolbar-btn i { font-size: 1.2rem; }
|
||
.toolbar-btn.active { background-color: #0d6efd; color: #fff; }
|
||
.color-btn { width: 24px; height: 24px; padding: 0; border: 2px solid #fff; cursor: pointer; }
|
||
.color-btn.active { border-color: #000; }
|
||
#editorBody { position: relative; width: 100%; height: calc(100% - 128px); }
|
||
#editorBody canvas { width: 100%; height: 100%; cursor: crosshair; }
|
||
.form-range { width: 150px; }
|
||
/* HTML textarea 스타일 */
|
||
.canvas-text-input {
|
||
position: absolute;
|
||
border: 1px dashed #666;
|
||
background: transparent;
|
||
resize: none;
|
||
outline: none;
|
||
padding: 2px;
|
||
font-family: sans-serif;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 그리기 시작 버튼 -->
|
||
<button id="openEditorBtn" class="btn btn-primary mb-3">
|
||
<i class="bi bi-pencil-square"></i> 그리기 시작
|
||
</button>
|
||
|
||
<!-- 대화상자 -->
|
||
<dialog id="editorDialog">
|
||
<div id="editorHeader">
|
||
<h5>이미지 편집기</h5>
|
||
<button type="button" class="btn-close" aria-label="닫기" id="closeEditorBtn"></button>
|
||
</div>
|
||
|
||
<!-- 툴바 -->
|
||
<div id="editorToolbar">
|
||
<button id="polyBtn" class="btn btn-outline-primary toolbar-btn" title="폴리라인"><i class="bi bi-vector-pen"></i></button>
|
||
<button id="freeBtn" class="btn btn-outline-primary toolbar-btn" title="자유선"><i class="bi bi-brush"></i></button>
|
||
<button id="lineBtn" class="btn btn-outline-primary toolbar-btn" title="직선 (L키)"><i class="bi bi-slash-lg"></i></button>
|
||
<button id="textBtn" class="btn btn-outline-primary toolbar-btn" title="문자입력"><i class="bi bi-type"></i></button>
|
||
<button id="eraserBtn" class="btn btn-outline-warning toolbar-btn" title="지우개"><i class="bi bi-eraser-fill"></i></button>
|
||
<button id="selectBtn" class="btn btn-outline-secondary toolbar-btn" title="객체선택"><i class="bi bi-cursor-text"></i></button>
|
||
<div class="form-check form-switch ms-3">
|
||
<input class="form-check-input" type="checkbox" id="rightAngle" checked>
|
||
<label class="form-check-label" for="rightAngle">직각 고정</label>
|
||
</div>
|
||
<button id="clearBtn" class="btn btn-outline-danger ms-auto">전체 지우기</button>
|
||
</div>
|
||
|
||
<!-- 지우개 크기 & 색상 -->
|
||
<div id="editorToolbar2" class="d-flex align-items-center px-3 pb-2">
|
||
<div class="btn-group" role="group" id="colorPicker">
|
||
<button type="button" class="btn color-btn" data-color="#000000" style="background:#000;"></button>
|
||
<button type="button" class="btn color-btn" data-color="#ff0000" style="background:#f00;"></button>
|
||
<button type="button" class="btn color-btn" data-color="#0000ff" style="background:#00f;"></button>
|
||
<button type="button" class="btn color-btn" data-color="#00aa00" style="background:#0a0;"></button>
|
||
<button type="button" class="btn color-btn" data-color="#ff8800" style="background:#f80;"></button>
|
||
<button type="button" class="btn color-btn" data-color="#800080" style="background:#808;"></button>
|
||
<button type="button" class="btn color-btn" data-color="#888888" style="background:#888;"></button>
|
||
</div>
|
||
<label class="mb-0 me-2">지우개 크기</label>
|
||
<input type="range" class="form-range me-2" id="eraserRange" min="5" max="100" step="1" value="20">
|
||
<span id="eraserSizeLabel" class="fw-bold">20</span>px
|
||
</div>
|
||
|
||
<!-- 캔버스 영역 -->
|
||
<div id="editorBody">
|
||
<canvas id="c" width="800" height="600"></canvas>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- 의존 스크립트 -->
|
||
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js"></script>
|
||
<script>
|
||
$(function(){
|
||
const dialog = document.getElementById('editorDialog');
|
||
const editorBody = document.getElementById('editorBody');
|
||
|
||
// “그리기 시작” 버튼
|
||
$('#openEditorBtn').click(() => {
|
||
if (!dialog.open) dialog.showModal();
|
||
});
|
||
// 우측 X 버튼
|
||
$('#closeEditorBtn').click(() => dialog.close());
|
||
// ESC로 닫히는 기본 동작 막기
|
||
dialog.addEventListener('cancel', e => e.preventDefault());
|
||
|
||
// Fabric.js 초기화
|
||
const canvas = new fabric.Canvas('c',{ selection:false });
|
||
let mode='polyline', selectMode=false, isLine=false, lineObj=null, isRight=true;
|
||
let polyPoints=[], previewLine=null, isPreview=true, currentColor='#0000ff';
|
||
|
||
// 브러시 설정
|
||
const freeBrush = canvas.freeDrawingBrush; freeBrush.width=2; freeBrush.color=currentColor;
|
||
const eraserBrush = new fabric.PencilBrush(canvas);
|
||
eraserBrush.width = +$('#eraserRange').val(); eraserBrush.color='#ffffff';
|
||
function updateEraserCursor(r){
|
||
const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="${r}" height="${r}"><circle cx="${r/2}" cy="${r/2}" r="${r/2}" stroke="black" fill="none"/></svg>`;
|
||
canvas.freeDrawingCursor = `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${r/2} ${r/2}, auto`;
|
||
}
|
||
updateEraserCursor(eraserBrush.width);
|
||
|
||
// 배경 이미지
|
||
fabric.Image.fromURL('https://via.placeholder.com/800x600.png', img=>{
|
||
img.selectable = false;
|
||
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas));
|
||
});
|
||
|
||
// path 생성 시 스타일
|
||
canvas.on('path:created', e=>{
|
||
e.path.selectable = selectMode;
|
||
e.path.stroke = (mode==='eraser')? '#ffffff' : currentColor;
|
||
});
|
||
|
||
// 버튼 강조
|
||
function highlight(){
|
||
$('.toolbar-btn').removeClass('active');
|
||
switch(mode){
|
||
case 'polyline': $('#polyBtn').addClass('active'); break;
|
||
case 'free': $('#freeBtn').addClass('active'); break;
|
||
case 'line': $('#lineBtn').addClass('active'); break;
|
||
case 'text': $('#textBtn').addClass('active'); break;
|
||
case 'eraser': $('#eraserBtn').addClass('active'); break;
|
||
case 'select': $('#selectBtn').addClass('active'); break;
|
||
}
|
||
}
|
||
|
||
// 모드 전환
|
||
function setMode(m){
|
||
if(mode==='polyline'&&m!=='polyline'){
|
||
if(previewLine){ canvas.remove(previewLine); previewLine=null; }
|
||
polyPoints=[];
|
||
}
|
||
mode=m; canvas.isDrawingMode=false;
|
||
if(m==='free'){ canvas.isDrawingMode=true; canvas.freeDrawingBrush=freeBrush; }
|
||
if(m==='eraser'){ canvas.isDrawingMode=true; canvas.freeDrawingBrush=eraserBrush; }
|
||
const el=canvas.upperCanvasEl;
|
||
el.style.cursor = (m==='eraser')? canvas.freeDrawingCursor : 'crosshair';
|
||
selectMode = (m==='select');
|
||
canvas.selection = selectMode;
|
||
canvas.forEachObject(o=>{
|
||
if(o===canvas.backgroundImage) return;
|
||
o.selectable=selectMode;
|
||
o.evented =selectMode;
|
||
});
|
||
highlight();
|
||
}
|
||
setMode('polyline');
|
||
|
||
// 툴바 클릭
|
||
$('#polyBtn').click(()=>setMode('polyline'));
|
||
$('#freeBtn').click(()=>setMode('free'));
|
||
$('#lineBtn').click(()=>setMode('line'));
|
||
$('#textBtn').click(()=>setMode('text'));
|
||
$('#eraserBtn').click(()=>setMode('eraser'));
|
||
$('#selectBtn').click(()=> setMode($('#selectBtn').hasClass('active')?'free':'select'));
|
||
$('#clearBtn').click(()=>{
|
||
canvas.clear();
|
||
fabric.Image.fromURL('https://via.placeholder.com/800x600.png',img=>{
|
||
img.selectable=false; canvas.setBackgroundImage(img,canvas.renderAll.bind(canvas));
|
||
});
|
||
polyPoints=[]; if(previewLine)canvas.remove(previewLine); previewLine=null;
|
||
canvas.upperCanvasEl.style.cursor='crosshair';
|
||
setMode('polyline');
|
||
});
|
||
|
||
// 색상 선택
|
||
$('#colorPicker .color-btn').click(function(){
|
||
$('#colorPicker .color-btn').removeClass('active');
|
||
$(this).addClass('active');
|
||
currentColor = $(this).data('color');
|
||
freeBrush.color = currentColor;
|
||
});
|
||
$('#rightAngle').change(function(){ isRight=this.checked; });
|
||
$('#eraserRange').on('input',function(){
|
||
const s=+this.value;
|
||
$('#eraserSizeLabel').text(s);
|
||
eraserBrush.width = s; updateEraserCursor(s);
|
||
if(mode==='eraser') canvas.upperCanvasEl.style.cursor=canvas.freeDrawingCursor;
|
||
});
|
||
|
||
// 전체 keydown: polyline ESC, text ESC, delete, shortcut L
|
||
$(document).keydown(e=>{
|
||
if(e.key.toLowerCase()==='l') setMode('line');
|
||
if(e.key==='Escape'&&mode==='polyline'){
|
||
isPreview=false;
|
||
if(previewLine){ canvas.remove(previewLine); previewLine=null; canvas.requestRenderAll(); }
|
||
}
|
||
if(e.key==='Escape'&&mode==='text'){
|
||
const existing = editorBody.querySelector('.canvas-text-input');
|
||
existing && existing.remove();
|
||
}
|
||
if(e.key==='Delete'&&selectMode){
|
||
const objs=canvas.getActiveObjects();
|
||
objs.forEach(o=>canvas.remove(o));
|
||
canvas.discardActiveObject();
|
||
canvas.requestRenderAll();
|
||
}
|
||
});
|
||
|
||
// 마우스 다운: line/poly/text
|
||
canvas.on('mouse:down',o=>{
|
||
const p=canvas.getPointer(o.e);
|
||
if(mode==='line'){
|
||
isLine=true;
|
||
lineObj=new fabric.Line([p.x,p.y,p.x,p.y],{
|
||
stroke:currentColor, strokeWidth:2,
|
||
originX:'center', originY:'center',
|
||
selectable:selectMode
|
||
});
|
||
canvas.add(lineObj);
|
||
}
|
||
else if(mode==='polyline'){
|
||
isPreview=true;
|
||
let x=p.x,y=p.y;
|
||
if(isRight&&polyPoints.length){
|
||
const L=polyPoints[polyPoints.length-1];
|
||
const dx=Math.abs(x-L.x),dy=Math.abs(y-L.y);
|
||
if(dx>dy) y=L.y; else x=L.x;
|
||
}
|
||
if(polyPoints.length){
|
||
const prev=polyPoints[polyPoints.length-1];
|
||
const seg=new fabric.Line([prev.x,prev.y,x,y],{
|
||
stroke:currentColor, strokeWidth:2,
|
||
originX:'center', originY:'center',
|
||
selectable:selectMode
|
||
});
|
||
canvas.add(seg);
|
||
}
|
||
polyPoints.push({x,y});
|
||
if(previewLine){ canvas.remove(previewLine); previewLine=null; }
|
||
}
|
||
else if (mode === 'text') {
|
||
// 기존에 떠 있는 textarea 제거
|
||
editorBody.querySelectorAll('.canvas-text-input').forEach(el => el.remove());
|
||
|
||
// HTML textarea 오버레이 생성
|
||
const ta = document.createElement('textarea');
|
||
ta.className = 'canvas-text-input';
|
||
ta.style.left = p.x + 'px';
|
||
ta.style.top = p.y + 'px';
|
||
ta.style.fontSize = '14px';
|
||
ta.rows = 1;
|
||
ta.cols = 20;
|
||
editorBody.appendChild(ta);
|
||
|
||
// 바로 포커스
|
||
setTimeout(() => {
|
||
ta.focus();
|
||
// 전체 텍스트를 선택하려면 아래를 추가하세요.
|
||
// ta.select();
|
||
}, 0);
|
||
|
||
// Enter / Esc 처리
|
||
ta.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const txt = ta.value.trim();
|
||
if (txt) {
|
||
const txtObj = new fabric.Text(txt, {
|
||
left: p.x,
|
||
top: p.y,
|
||
fontSize: 14,
|
||
fill: currentColor
|
||
});
|
||
canvas.add(txtObj);
|
||
}
|
||
ta.remove();
|
||
}
|
||
else if (e.key === 'Escape') {
|
||
ta.remove();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 마우스 무브: line/poly preview
|
||
canvas.on('mouse:move',o=>{
|
||
const p=canvas.getPointer(o.e);
|
||
if(mode==='line'&&isLine){
|
||
let x2=p.x,y2=p.y;
|
||
if(isRight){
|
||
const dx=Math.abs(x2-lineObj.x1), dy=Math.abs(y2-lineObj.y1);
|
||
if(dx>dy) y2=lineObj.y1; else x2=lineObj.x1;
|
||
}
|
||
lineObj.set({x2,y2});
|
||
canvas.requestRenderAll();
|
||
}
|
||
else if(mode==='polyline'&&polyPoints.length&&isPreview){
|
||
const L=polyPoints[polyPoints.length-1];
|
||
let x2=p.x,y2=p.y;
|
||
if(isRight){
|
||
const dx=Math.abs(x2-L.x),dy=Math.abs(y2-L.y);
|
||
if(dx>dy) y2=L.y; else x2=L.x;
|
||
}
|
||
if(previewLine){
|
||
previewLine.set({x1:L.x,y1:L.y,x2,y2});
|
||
} else {
|
||
previewLine=new fabric.Line([L.x,L.y,x2,y2],{
|
||
stroke:'gray',strokeWidth:1,strokeDashArray:[5,5],selectable:false
|
||
});
|
||
canvas.add(previewLine);
|
||
}
|
||
canvas.requestRenderAll();
|
||
}
|
||
});
|
||
|
||
canvas.on('mouse:up',()=>{
|
||
if(mode==='line'){ isLine=false; lineObj=null; }
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|