- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
296 lines
11 KiB
HTML
296 lines
11 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Fabric.js 이미지 편집기 (폴리라인 기본)</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>
|
||
#c { border:1px solid #dee2e6; cursor: crosshair; }
|
||
.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; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-light">
|
||
|
||
<div class="container py-3">
|
||
<!-- 툴바 -->
|
||
<div class="d-flex align-items-center mb-3 gap-2">
|
||
<!-- 순서: 폴리라인 → 자유선 → 직선 → 문자 → 지우개 → 객체선택 -->
|
||
<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="colorPicker" class="btn-group mb-3" role="group">
|
||
<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>
|
||
|
||
<!-- 지우개 크기 -->
|
||
<div class="row mb-3 align-items-center gx-2">
|
||
<div class="col-auto">
|
||
<label for="eraserRange" class="form-label mb-0">지우개 크기</label>
|
||
</div>
|
||
<div class="col">
|
||
<input type="range" class="form-range" id="eraserRange" min="5" max="100" step="1" value="20">
|
||
</div>
|
||
<div class="col-auto">
|
||
<span id="eraserSizeLabel" class="fw-bold">20</span>px
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 캔버스 -->
|
||
<div class="border bg-white">
|
||
<canvas id="c" width="800" height="600" class="w-100"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- jQuery, Bootstrap, Fabric.js -->
|
||
<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 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 created
|
||
canvas.on('path:created', e=>{
|
||
e.path.selectable=selectMode;
|
||
e.path.stroke = (mode==='eraser')? '#ffffff' : currentColor;
|
||
});
|
||
|
||
// 툴 활성화 표시
|
||
function highlightButton(){
|
||
$('.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 / 객체선택 가능
|
||
selectMode=(m==='select');
|
||
canvas.selection=selectMode;
|
||
canvas.forEachObject(o=>{
|
||
if(o===canvas.backgroundImage) return;
|
||
o.selectable=selectMode;
|
||
o.evented=selectMode;
|
||
});
|
||
|
||
highlightButton();
|
||
}
|
||
|
||
// 초기: 폴리라인
|
||
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;
|
||
});
|
||
|
||
// 키 이벤트
|
||
$(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 act=canvas.getActiveObject();
|
||
if(act&&act.isEditing) canvas.remove(act);
|
||
}
|
||
if(e.key==='Delete'&&selectMode){
|
||
const objs=canvas.getActiveObjects();
|
||
objs.forEach(o=>canvas.remove(o));
|
||
canvas.discardActiveObject();
|
||
canvas.requestRenderAll();
|
||
}
|
||
});
|
||
|
||
// 마우스 다운
|
||
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'){
|
||
const it=new fabric.IText('',{ left:p.x, top:p.y, fontSize:14, fill:currentColor, selectable:false });
|
||
canvas.add(it).setActiveObject(it);
|
||
it.enterEditing();
|
||
it.hiddenTextarea.focus();
|
||
it.on('editing:exited',()=>{
|
||
if(!it.text) canvas.remove(it);
|
||
else it.selectable=selectMode;
|
||
canvas.requestRenderAll();
|
||
});
|
||
}
|
||
});
|
||
|
||
// 마우스 무브
|
||
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>
|