- 기초관리: 목록(13컬럼) + 폼(기본정보 + 케이스전용 + 절곡테이블 + 이미지) - 절곡품: 가이드레일/케이스/하단마감재 타입별 목록 + 폼 - 부품 추가(기초관리 검색 모달) + 삭제 + 수량/품명/재질 편집 - 절곡테이블 inline 편집 + 재질별 폭합 자동계산 - 작업지시서 레거시 포맷 인쇄 모달 - 원본수정 버튼 sam_item_id 직접 링크 - DB 메뉴 등록 (기초관리 + 절곡품 + 케이스 + 하단마감재)
381 lines
15 KiB
JavaScript
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 };
|
|
})();
|