Files
sam-manage/public/js/canvas-editor.js
강영보 d03c7ed870 feat: [bending] 절곡품 관리 MNG 화면
- 기초관리: 목록(13컬럼) + 폼(기본정보 + 케이스전용 + 절곡테이블 + 이미지)
- 절곡품: 가이드레일/케이스/하단마감재 타입별 목록 + 폼
- 부품 추가(기초관리 검색 모달) + 삭제 + 수량/품명/재질 편집
- 절곡테이블 inline 편집 + 재질별 폭합 자동계산
- 작업지시서 레거시 포맷 인쇄 모달
- 원본수정 버튼 sam_item_id 직접 링크
- DB 메뉴 등록 (기초관리 + 절곡품 + 케이스 + 하단마감재)
2026-03-19 21:08:57 +09:00

381 lines
15 KiB
JavaScript

/**
* Canvas Editor - Fabric.js 기반 이미지 편집기
* 5130 레거시 imageEditor.js → MNG 이식
*
* 사용법:
* CanvasEditor.open(existingImageUrl)
* .then(dataURL => { ... }) // 적용 시
* .catch(() => { ... }); // 취소 시
*/
const CanvasEditor = (() => {
let canvas = null;
let initialized = false;
// 상태
let mode = 'polyline';
let selectMode = false;
let isLine = false;
let lineObj = null;
let polyPoints = [];
let previewLine = null;
let isPreview = true;
let isRight = true;
let currentColor = '#000000';
let freeBrush, eraserBrush;
// Promise resolve/reject
let _resolve, _reject;
function getEl(id) { return document.getElementById(id); }
// ── 지우개 SVG 커서 ──
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`;
}
// ── 툴바 하이라이트 ──
function highlight() {
document.querySelectorAll('#ce-dialog .ce-tool-btn').forEach(b => b.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-100'));
const map = {
polyline: 'ce-polyBtn', free: 'ce-freeBtn', line: 'ce-lineBtn',
text: 'ce-textBtn', eraser: 'ce-eraserBtn', select: 'ce-selectBtn'
};
const btn = getEl(map[mode]);
if (btn) btn.classList.add('ring-2', 'ring-blue-500', 'bg-blue-100');
}
// ── 모드 전환 ──
function setMode(m) {
if (m === 'polyline') {
polyPoints = [];
isPreview = true;
if (previewLine) { canvas.remove(previewLine); previewLine = null; }
isLine = false; lineObj = null;
} else if (mode === 'polyline') {
if (previewLine) { canvas.remove(previewLine); previewLine = null; }
polyPoints = [];
}
mode = m;
canvas.isDrawingMode = (m === 'free' || m === 'eraser');
if (m === 'free') canvas.freeDrawingBrush = freeBrush;
if (m === 'eraser') canvas.freeDrawingBrush = eraserBrush;
selectMode = (m === 'select');
canvas.selection = selectMode;
canvas.upperCanvasEl.style.cursor = (m === 'eraser' ? canvas.freeDrawingCursor : 'crosshair');
canvas.getObjects().forEach(o => {
if (o !== canvas.backgroundImage) {
o.selectable = selectMode;
o.evented = selectMode;
}
});
highlight();
}
// ── 캔버스 초기화 ──
function initCanvas() {
const canvasEl = getEl('ce-canvas');
canvas = new fabric.Canvas(canvasEl, { selection: false });
fabric.Text.prototype.textBaseline = 'alphabetic';
if (fabric.IText) fabric.IText.prototype.textBaseline = 'alphabetic';
freeBrush = canvas.freeDrawingBrush;
freeBrush.width = 2;
freeBrush.color = currentColor;
eraserBrush = new fabric.PencilBrush(canvas);
eraserBrush.width = 20;
eraserBrush.color = '#ffffff';
updateEraserCursor(20);
// ── 마우스 이벤트 ──
canvas.on('mouse:down', opt => {
const p = canvas.getPointer(opt.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') {
if (polyPoints.length === 0) isPreview = true;
let x = p.x, y = p.y;
if (isRight && polyPoints.length) {
const prev = polyPoints[polyPoints.length - 1];
if (Math.abs(x - prev.x) > Math.abs(y - prev.y)) y = prev.y;
else x = prev.x;
}
if (polyPoints.length) {
const prev = polyPoints[polyPoints.length - 1];
canvas.add(new fabric.Line([prev.x, prev.y, x, y], {
stroke: currentColor, strokeWidth: 2,
originX: 'center', originY: 'center', selectable: selectMode
}));
}
polyPoints.push({ x, y });
if (previewLine) { canvas.remove(previewLine); previewLine = null; }
} else if (mode === 'text') {
document.querySelectorAll('.ce-text-input').forEach(el => el.remove());
const ta = document.createElement('textarea');
ta.className = 'ce-text-input';
Object.assign(ta.style, {
position: 'absolute', left: p.x + 'px', top: p.y + 'px',
fontSize: '14px', zIndex: '100', border: '1px solid #3b82f6',
borderRadius: '2px', padding: '2px 4px', outline: 'none',
background: 'rgba(255,255,255,0.9)', minWidth: '60px'
});
ta.rows = 1;
getEl('ce-body').appendChild(ta);
setTimeout(() => ta.focus(), 0);
ta.addEventListener('keydown', ev => {
if (ev.key === 'Enter') {
ev.preventDefault();
const txt = ta.value.trim();
if (txt) canvas.add(new fabric.Text(txt, { left: p.x, top: p.y, fontSize: 14, fill: currentColor }));
ta.remove();
} else if (ev.key === 'Escape') { ta.remove(); }
});
}
});
canvas.on('mouse:move', opt => {
const p = canvas.getPointer(opt.e);
if (mode === 'line' && isLine) {
let x2 = p.x, y2 = p.y;
if (isRight) {
if (Math.abs(x2 - lineObj.x1) > Math.abs(y2 - lineObj.y1)) y2 = lineObj.y1;
else x2 = lineObj.x1;
}
lineObj.set({ x2, y2 });
canvas.requestRenderAll();
} else if (mode === 'polyline' && polyPoints.length > 0 && isPreview) {
const L = polyPoints[polyPoints.length - 1];
let x2 = p.x, y2 = p.y;
if (isRight) {
if (Math.abs(x2 - L.x) > Math.abs(y2 - L.y)) 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; }
});
canvas.on('path:created', e => {
e.path.selectable = selectMode;
e.path.stroke = (mode === 'eraser') ? '#ffffff' : currentColor;
});
initialized = true;
}
// ── 이벤트 바인딩 ──
function bindEvents() {
const dialog = getEl('ce-dialog');
// 도구 버튼
const modeMap = {
'ce-polyBtn': 'polyline', 'ce-freeBtn': 'free', 'ce-lineBtn': 'line',
'ce-textBtn': 'text', 'ce-eraserBtn': 'eraser', 'ce-selectBtn': 'select'
};
Object.entries(modeMap).forEach(([id, m]) => {
const el = getEl(id);
if (el) el.onclick = () => setMode(m);
});
// 전체 지우기
const clearBtn = getEl('ce-clearBtn');
if (clearBtn) clearBtn.onclick = () => {
canvas.clear();
canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas));
polyPoints = [];
if (previewLine) { canvas.remove(previewLine); previewLine = null; }
isLine = false; lineObj = null; isPreview = true;
setMode('polyline');
};
// 색상
document.querySelectorAll('#ce-colors .ce-color-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#ce-colors .ce-color-btn').forEach(b => b.classList.remove('ring-2', 'ring-offset-1', 'ring-gray-800'));
btn.classList.add('ring-2', 'ring-offset-1', 'ring-gray-800');
currentColor = btn.dataset.color;
freeBrush.color = currentColor;
};
});
// 지우개 크기
const eraserRange = getEl('ce-eraserRange');
if (eraserRange) eraserRange.oninput = e => {
const r = +e.target.value;
getEl('ce-eraserSize').textContent = r;
eraserBrush.width = r;
updateEraserCursor(r);
if (mode === 'eraser') canvas.upperCanvasEl.style.cursor = canvas.freeDrawingCursor;
};
// 직각 고정
const rightAngle = getEl('ce-rightAngle');
if (rightAngle) rightAngle.onchange = () => { isRight = rightAngle.checked; };
// 적용
getEl('ce-applyBtn').onclick = (e) => {
e.preventDefault();
e.stopPropagation();
try {
const dataURL = canvas.toDataURL('image/png');
dialog.close();
if (_resolve) { const r = _resolve; _resolve = null; _reject = null; r(dataURL); }
} catch (err) {
console.error('Canvas toDataURL failed:', err);
dialog.close();
if (_reject) { const r = _reject; _resolve = null; _reject = null; r(err); }
}
};
// 닫기
getEl('ce-closeBtn').onclick = (e) => {
e.preventDefault();
e.stopPropagation();
dialog.close();
if (_reject) { const r = _reject; _resolve = null; _reject = null; r(new Error('User cancelled')); }
};
// ESC 처리
dialog.addEventListener('cancel', e => e.preventDefault());
window.addEventListener('keydown', e => {
if (!dialog.open || e.key !== 'Escape') return;
e.preventDefault();
// 텍스트 입력 중이면 제거
const texts = document.querySelectorAll('.ce-text-input');
if (texts.length) { texts.forEach(el => el.remove()); return; }
// polyline 프리뷰 취소
if (mode === 'polyline') {
if (previewLine) { canvas.remove(previewLine); previewLine = null; }
polyPoints = [];
isPreview = true;
setMode('polyline');
canvas.requestRenderAll();
}
});
// Delete 키로 선택 객체 삭제
window.addEventListener('keydown', e => {
if (!dialog.open) return;
if (e.key === 'Delete' && selectMode) {
const active = canvas.getActiveObject();
if (active) { canvas.remove(active); canvas.discardActiveObject(); canvas.requestRenderAll(); }
}
if (e.key === 'l' || e.key === 'L') {
if (document.activeElement.tagName === 'TEXTAREA') return;
setMode('line');
}
});
// 외부 클릭 방지
dialog.addEventListener('mousedown', e => { if (e.target === dialog) e.preventDefault(); });
}
// ── 공개 API ──
function open(imageSrc) {
return new Promise((resolve, reject) => {
_resolve = resolve;
_reject = reject;
const dialog = getEl('ce-dialog');
if (!dialog) { reject(new Error('ce-dialog not found')); return; }
if (!initialized) {
initCanvas();
bindEvents();
}
// 상태 리셋
mode = 'polyline';
currentColor = '#000000';
polyPoints = [];
previewLine = null;
isPreview = true;
isLine = false;
lineObj = null;
// 색상 초기화
document.querySelectorAll('#ce-colors .ce-color-btn').forEach(b => b.classList.remove('ring-2', 'ring-offset-1', 'ring-gray-800'));
const firstColor = document.querySelector('#ce-colors .ce-color-btn');
if (firstColor) firstColor.classList.add('ring-2', 'ring-offset-1', 'ring-gray-800');
if (freeBrush) freeBrush.color = currentColor;
// 직각 고정 초기화
const rightAngle = getEl('ce-rightAngle');
if (rightAngle) { rightAngle.checked = true; isRight = true; }
// 캔버스 초기화
canvas.clear();
// 최대 허용 크기 (뷰포트 기준)
const vpW = window.innerWidth * 0.85;
const vpH = window.innerHeight * 0.75;
if (imageSrc && !imageSrc.includes('placeholder')) {
// 기존 이미지 배경 로드
fabric.Image.fromURL(imageSrc, img => {
if (!img || !img.width) {
canvas.setWidth(500);
canvas.setHeight(350);
canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas));
} else {
// 이미지를 뷰포트에 맞춤 (축소만, 확대 안 함)
const pad = 40; // 상하좌우 여백
const fitRatio = Math.min(vpW / (img.width + pad * 2), vpH / (img.height + pad * 2), 1);
const sw = Math.round(img.width * fitRatio);
const sh = Math.round(img.height * fitRatio);
// 캔버스 = 이미지 + 여백
const cw = sw + pad * 2;
const ch = sh + pad * 2;
img.selectable = false;
canvas.setWidth(cw);
canvas.setHeight(ch);
// 이미지를 여백만큼 offset해서 중앙 배치
canvas.setBackgroundColor('#ffffff', () => {});
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), {
originX: 'left', originY: 'top', left: pad, top: pad,
scaleX: fitRatio, scaleY: fitRatio
});
}
setMode('polyline');
// dialog 크기 = 캔버스 + 10% 여유
dialog.style.width = Math.round(canvas.width * 1.1) + 'px';
dialog.showModal();
dialog.focus();
});
} else {
// 빈 캔버스 — 적당한 기본 크기
canvas.setWidth(500);
canvas.setHeight(350);
canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas));
setMode('polyline');
dialog.style.width = '600px';
dialog.showModal();
dialog.focus();
}
});
}
return { open };
})();