/** * 이미지 합성 및 편집 기능을 위한 자바스크립트 파일 */ // 요소 가져오기 const backgroundInput = document.getElementById('background-input'); const overlayInput = document.getElementById('overlay-input'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const scaleSlider = document.getElementById('scale-slider'); const scaleValue = document.getElementById('scale-value'); const rotationSlider = document.getElementById('rotation-slider'); const rotationValue = document.getElementById('rotation-value'); // 필터 관련 요소 const temperatureSlider = document.getElementById('temperature-slider'); const temperatureValue = document.getElementById('temperature-value'); const brightnessSlider = document.getElementById('brightness-slider'); const brightnessValue = document.getElementById('brightness-value'); const contrastSlider = document.getElementById('contrast-slider'); const contrastValue = document.getElementById('contrast-value'); const saturationSlider = document.getElementById('saturation-slider'); const saturationValue = document.getElementById('saturation-value'); const resetFilterBtn = document.getElementById('reset-filter-btn'); const generateBtn = document.getElementById('generate-btn'); const resetAllBtn = document.getElementById('reset-all-btn'); const downloadBtn = document.getElementById('download-btn'); const deleteLayerBtn = document.getElementById('delete-layer-btn'); // 추가 const previewContainer = document.getElementById('preview-container'); const previewImg = document.getElementById('preview-img'); const layersList = document.getElementById('layers-list'); // 캔버스 기본 크기 const CANVAS_WIDTH = 800; const CANVAS_HEIGHT = 600; canvas.width = CANVAS_WIDTH; canvas.height = CANVAS_HEIGHT; // 전역 변수 let backgroundImage = null; let originalFormat = 'png'; // 기본 포맷을 PNG로 설정 let canvasAdjustedHeight = CANVAS_HEIGHT; // 이미지 비율에 맞게 조정된 캔버스 높이 // 배경 이미지 그리기 속성 let bgDrawProps = { x: 0, y: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT }; // 여러 개의 오버레이 이미지와 그 속성을 저장할 배열 let overlays = []; // 현재 조작 중인 오버레이 이미지의 인덱스 (-1이면 선택되지 않음) let activeOverlayIndex = -1; // 마우스/터치 조작 관련 상태 let isDragging = false; let isRotating = false; let isResizing = false; let activeHandle = null; // 이전 마우스/터치 좌표 let lastX = 0; let lastY = 0; // 리사이즈 시작 시 핸들까지의 거리 (비례 스케일링에 사용) let resizeStartDistance = 0; // 핸들 설정 const handleSize = 15; const rotateHandleDistance = 30; // 클릭 감지 시 여유 공간 const clickPadding = 5; // 캔버스 초기화 (흰색 배경) function initCanvas() { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_WIDTH, canvasAdjustedHeight); updateLayersList(); } function cropTransparentPixels(imageElement, alphaThreshold = 0) { const tempCanvas = document.createElement('canvas'); tempCanvas.width = imageElement.naturalWidth || imageElement.width; tempCanvas.height = imageElement.naturalHeight || imageElement.height; const tempCtx = tempCanvas.getContext('2d'); tempCtx.drawImage(imageElement, 0, 0); let imageData; try { imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); } catch (e) { console.error("Error getting ImageData (possibly due to CORS):", e); // CORS 오류 발생 시, 보안 제약 없이 이미지 전체를 사용하는 대체 로직 (배경 제거 효과는 없을 수 있음) const fallbackCanvas = document.createElement('canvas'); fallbackCanvas.width = tempCanvas.width; fallbackCanvas.height = tempCanvas.height; const fallbackCtx = fallbackCanvas.getContext('2d'); fallbackCtx.drawImage(imageElement, 0, 0); return fallbackCanvas; } const data = imageData.data; let minX = tempCanvas.width, minY = tempCanvas.height, maxX = 0, maxY = 0; let foundContent = false; for (let y = 0; y < tempCanvas.height; y++) { for (let x = 0; x < tempCanvas.width; x++) { const alpha = data[(y * tempCanvas.width + x) * 4 + 3]; if (alpha > alphaThreshold) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); foundContent = true; } } } if (!foundContent) { // 내용이 없는 경우 (완전히 투명하거나 오류) const emptyCanvas = document.createElement('canvas'); emptyCanvas.width = imageElement.naturalWidth || 1; emptyCanvas.height = imageElement.naturalHeight || 1; return emptyCanvas; } const cropWidth = maxX - minX + 1; const cropHeight = maxY - minY + 1; const croppedCanvas = document.createElement('canvas'); croppedCanvas.width = cropWidth; croppedCanvas.height = cropHeight; const croppedCtx = croppedCanvas.getContext('2d'); // 잘라낸 영역만 새 캔버스에 그립니다. croppedCtx.drawImage(tempCanvas, minX, minY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight); return croppedCanvas; } // 파일 또는 Blob 객체를 받아 이미지를 로드하고 캔버스에 추가하는 함수 function loadImage(fileOrBlob, type) { if (!fileOrBlob) return; let fileExtension = 'png'; // 기본값 let isBlob = fileOrBlob instanceof Blob; let fileName = 'unknown'; if (!isBlob && fileOrBlob.name) { fileExtension = fileOrBlob.name.split('.').pop().toLowerCase(); fileName = fileOrBlob.name; } else if (isBlob && fileOrBlob.type) { fileExtension = fileOrBlob.type.split('/')[1] ? fileOrBlob.type.split('/')[1].toLowerCase() : 'png'; fileName = 'processed_image.' + fileExtension; // Blob의 경우 파일 이름 임시 지정 } if (type === 'background' && !isBlob && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension)) { originalFormat = fileExtension; } const reader = new FileReader(); reader.onload = function(event) { const img = new Image(); img.onload = function() { if (type === 'background') { backgroundImage = img; const aspectRatio = img.width / img.height; bgDrawProps.width = CANVAS_WIDTH; bgDrawProps.height = CANVAS_WIDTH / aspectRatio; bgDrawProps.x = 0; bgDrawProps.y = 0; canvasAdjustedHeight = bgDrawProps.height; canvas.height = canvasAdjustedHeight; canvas.style.height = 'auto'; // 캔버스 wrapper가 아닌 canvas 자체의 style } else if (type === 'overlay') { let imageToUse = img; // PNG 또는 투명도가 있는 이미지 형식의 경우에만 투명 픽셀 자르기 시도 // 예시 이미지는 배경 제거가 완료된 이미지라고 가정하거나, // 여기에서 cropTransparentPixels를 적용합니다. // 실제 복잡한 배경 제거 로직은 이 부분이 아닌 별도의 함수에서 구현되어야 합니다. if (fileExtension === 'png' || fileExtension === 'webp' || fileExtension === 'gif' || (isBlob && fileOrBlob.type === 'image/png')) { try { // cropTransparentPixels는 투명 영역을 자르는 기능입니다. // 만약 예시 이미지가 이미 배경 제거된 이미지라면 이 함수를 사용할 수 있습니다. // 일반 이미지의 배경을 제거하려면 다른 알고리즘이 필요합니다. const croppedImageCanvas = cropTransparentPixels(img); if (croppedImageCanvas.width > 0 && croppedImageCanvas.height > 0) { imageToUse = croppedImageCanvas; } else { // 잘라낼 영역이 없으면 원본 사용 또는 빈 이미지 처리 console.warn("No non-transparent pixels found, using original or empty."); // imageToUse = img; // 원본 이미지 사용 const emptyCanvas = document.createElement('canvas'); emptyCanvas.width = img.naturalWidth || 1; emptyCanvas.height = img.naturalHeight || 1; imageToUse = emptyCanvas; // 또는 빈 캔버스 사용 } } catch (e) { console.error("Error during image cropping/processing, using original:", e); imageToUse = img; // 오류 발생 시 원본 사용 } } const newOverlay = { image: imageToUse, // 캔버스 중앙에 배치 x: CANVAS_WIDTH / 2, y: canvasAdjustedHeight / 2, scale: 1, rotation: 0, filters: { ...ImageFilters.DEFAULT_FILTERS } }; // 이미지가 캔버스보다 크면 초기 스케일 조정 const initialScaleRatio = Math.min(CANVAS_WIDTH / newOverlay.image.width, canvasAdjustedHeight / newOverlay.image.height); if (initialScaleRatio < 1) { newOverlay.scale = initialScaleRatio * 0.9; // 약간 더 작게 시작 } overlays.push(newOverlay); activeOverlayIndex = overlays.length - 1; // 새로 추가된 레이어를 활성화 updateControlPanel(); updateLayersList(); } drawCanvas(); }; img.onerror = function() { console.error("Error loading image from reader result for:", fileName); alert("이미지를 로드하는 데 실패했습니다. 파일이 유효한지 확인해주세요."); }; img.src = event.target.result; }; reader.readAsDataURL(fileOrBlob); // File 또는 Blob에서 Data URL 읽기 } function updateControlPanel() { const isActive = activeOverlayIndex >= 0; const activeOverlay = isActive ? overlays[activeOverlayIndex] : null; // 오버레이가 선택되지 않았을 때 컨트롤 비활성화 scaleSlider.disabled = !isActive; rotationSlider.disabled = !isActive; temperatureSlider.disabled = !isActive; brightnessSlider.disabled = !isActive; contrastSlider.disabled = !isActive; saturationSlider.disabled = !isActive; resetFilterBtn.disabled = !isActive; deleteLayerBtn.disabled = !isActive; // 삭제 버튼도 비활성화 if (isActive) { scaleSlider.value = Math.round(activeOverlay.scale * 100); scaleValue.textContent = scaleSlider.value + '%'; rotationSlider.value = Math.round(activeOverlay.rotation); rotationValue.textContent = rotationSlider.value + '°'; temperatureSlider.value = activeOverlay.filters.temperature; temperatureValue.textContent = activeOverlay.filters.temperature; brightnessSlider.value = activeOverlay.filters.brightness; brightnessValue.textContent = activeOverlay.filters.brightness + '%'; contrastSlider.value = activeOverlay.filters.contrast; contrastValue.textContent = activeOverlay.filters.contrast + '%'; saturationSlider.value = activeOverlay.filters.saturation; saturationValue.textContent = activeOverlay.filters.saturation + '%'; } else { // 오버레이가 선택되지 않으면 기본값으로 표시 scaleSlider.value = 100; scaleValue.textContent = '100%'; rotationSlider.value = 0; rotationValue.textContent = '0°'; temperatureSlider.value = 0; temperatureValue.textContent = '0'; brightnessSlider.value = 100; brightnessValue.textContent = '100%'; contrastSlider.value = 100; contrastValue.textContent = '100%'; saturationSlider.value = 100; saturationValue.textContent = '100%'; } // 오버레이가 하나라도 있을 때만 생성/다운로드 버튼 활성화 generateBtn.disabled = overlays.length === 0; downloadBtn.disabled = overlays.length === 0; // 오버레이가 없으면 미리보기 영역 숨김 if (overlays.length === 0) { previewContainer.style.display = 'none'; } } function updateLayersList() { if (!layersList) return; // layersList 요소가 없으면 함수 종료 layersList.innerHTML = ''; // 목록 초기화 // 삭제 버튼 활성화/비활성화 설정 if (deleteLayerBtn) { deleteLayerBtn.disabled = activeOverlayIndex < 0; } if (overlays.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.style.padding = '8px'; emptyMsg.style.textAlign = 'center'; emptyMsg.style.color = '#777'; emptyMsg.textContent = '레이어가 없습니다'; layersList.appendChild(emptyMsg); return; } // 레이어 목록을 역순으로 표시 (가장 위에 있는 레이어가 목록의 아래에 오도록) for (let i = overlays.length - 1; i >= 0; i--) { const layerItem = document.createElement('div'); layerItem.className = 'layer-item'; if (i === activeOverlayIndex) { layerItem.classList.add('active'); // 활성 레이어 표시 } const layerName = document.createElement('span'); layerName.className = 'layer-name'; layerName.textContent = `레이어 ${i + 1}`; // 레이어 번호 표시 (1부터 시작) const layerControls = document.createElement('div'); layerControls.className = 'layer-controls'; // 위로 이동 버튼 (맨 위 레이어 제외) if (i < overlays.length - 1) { const upButton = document.createElement('button'); upButton.className = 'layer-button'; upButton.textContent = '↑'; upButton.title = '위로 이동'; upButton.addEventListener('click', (e) => { e.stopPropagation(); moveLayerUp(i); }); // 이벤트 버블링 방지 layerControls.appendChild(upButton); } // 아래로 이동 버튼 (맨 아래 레이어 제외) if (i > 0) { const downButton = document.createElement('button'); downButton.className = 'layer-button'; downButton.textContent = '↓'; downButton.title = '아래로 이동'; downButton.addEventListener('click', (e) => { e.stopPropagation(); moveLayerDown(i); }); // 이벤트 버블링 방지 layerControls.appendChild(downButton); } // 레이어 삭제 버튼 추가 const deleteButton = document.createElement('button'); deleteButton.className = 'delete-button'; deleteButton.textContent = '×'; deleteButton.title = '레이어 삭제 (단축키: Del)'; deleteButton.addEventListener('click', (e) => { e.stopPropagation(); // 이벤트 버블링 방지 // 현재 레이어 인덱스를 설정하고 삭제 activeOverlayIndex = i; deleteSelectedLayer(); }); layerControls.appendChild(deleteButton); // 레이어 항목 클릭 시 해당 레이어 활성화 layerItem.addEventListener('click', function() { activeOverlayIndex = i; updateControlPanel(); // 컨트롤 패널 업데이트 updateLayersList(); // 레이어 목록 시각적 업데이트 drawCanvas(); // 캔버스 다시 그리기 (활성 레이어 핸들 표시) }); layerItem.appendChild(layerName); layerItem.appendChild(layerControls); layersList.appendChild(layerItem); } } // 레이어 위로 이동 function moveLayerUp(index) { if (index < overlays.length - 1) { // 현재 레이어와 바로 위 레이어의 위치를 바꿉니다. const temp = overlays[index]; overlays[index] = overlays[index + 1]; overlays[index + 1] = temp; // 활성 레이어의 인덱스도 조정합니다. if (activeOverlayIndex === index) activeOverlayIndex = index + 1; else if (activeOverlayIndex === index + 1) activeOverlayIndex = index; updateLayersList(); // 목록 업데이트 drawCanvas(); // 캔버스 다시 그리기 } } // 레이어 아래로 이동 function moveLayerDown(index) { if (index > 0) { // 현재 레이어와 바로 아래 레이어의 위치를 바꿉니다. const temp = overlays[index]; overlays[index] = overlays[index - 1]; overlays[index - 1] = temp; // 활성 레이어의 인덱스도 조정합니다. if (activeOverlayIndex === index) activeOverlayIndex = index - 1; else if (activeOverlayIndex === index - 1) activeOverlayIndex = index; updateLayersList(); // 목록 업데이트 drawCanvas(); // 캔버스 다시 그리기 } } function drawCanvas() { // 캔버스 전체 지우기 ctx.clearRect(0, 0, CANVAS_WIDTH, canvasAdjustedHeight); // 배경 이미지 그리기 (있다면) if (backgroundImage) { ctx.drawImage(backgroundImage, 0, 0, backgroundImage.width, backgroundImage.height, bgDrawProps.x, bgDrawProps.y, bgDrawProps.width, bgDrawProps.height); } else { // 배경 이미지가 없으면 흰색 배경 채우기 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_WIDTH, canvasAdjustedHeight); } // 오버레이 이미지들 그리기 (레이어 순서대로) overlays.forEach((overlay, index) => { // 이미지가 유효하지 않으면 건너뜁니다. if (!overlay.image || overlay.image.width === 0 || overlay.image.height === 0) return; // 필터 적용된 이미지 캔버스 생성 (filter.js의 함수 사용) const filteredCanvas = ImageFilters.createFilteredCanvas(overlay.image, overlay.filters); ctx.save(); // 현재 캔버스 상태 저장 // 오버레이 이미지의 중심을 기준으로 변환 적용 ctx.translate(overlay.x, overlay.y); // 위치 이동 ctx.rotate(overlay.rotation * Math.PI / 180); // 회전 (라디안으로 변환) ctx.scale(overlay.scale, overlay.scale); // 크기 조정 // 이미지를 중심 기준으로 그립니다. (translate에서 이동했으므로 -width/2, -height/2) ctx.drawImage(filteredCanvas, -overlay.image.width / 2, -overlay.image.height / 2); ctx.restore(); // 이전 캔버스 상태 복원 (변환 상태 초기화) // 현재 활성 레이어에만 변환 핸들 그리기 if (index === activeOverlayIndex) { drawTransformHandles(overlay); } }); } // 변환 핸들 그리기 function drawTransformHandles(activeOverlay) { // 활성 오버레이나 이미지가 유효하지 않으면 그리지 않습니다. if (!activeOverlay || !activeOverlay.image || activeOverlay.image.width === 0 || activeOverlay.image.height === 0) return; const currentWidth = activeOverlay.image.width * activeOverlay.scale; const currentHeight = activeOverlay.image.height * activeOverlay.scale; const halfWidth = currentWidth / 2; const halfHeight = currentHeight / 2; const angle = activeOverlay.rotation * Math.PI / 180; // 현재 회전 각도 (라디안) // 코너 핸들 위치 (회전되지 않은 상태 기준) const handles = { 'tl': { x: -halfWidth, y: -halfHeight, cursor: 'nwse-resize' }, // Top-Left 'tr': { x: halfWidth, y: -halfHeight, cursor: 'nesw-resize' }, // Top-Right 'br': { x: halfWidth, y: halfHeight, cursor: 'nwse-resize' }, // Bottom-Right 'bl': { x: -halfWidth, y: halfHeight, cursor: 'nesw-resize' } // Bottom-Left }; // 회전 핸들 위치 (이미지 상단 중앙에서 회전 거리만큼 떨어진 위치) const rotateHandlePos = { x: 0, y: -halfHeight - rotateHandleDistance }; ctx.save(); // 현재 캔버스 상태 저장 // 오버레이 이미지의 중심 위치로 이동 후 회전 ctx.translate(activeOverlay.x, activeOverlay.y); ctx.rotate(angle); // 코너 핸들 그리기 const cornerHandleColor = '#e74c3c'; // 빨간색 for (const [, handle] of Object.entries(handles)) { ctx.beginPath(); // 핸들 위치에 사각형 그리기 (핸들 크기의 절반만큼 빼서 중앙에 위치) ctx.rect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize); ctx.fillStyle = cornerHandleColor; ctx.fill(); } // 회전 핸들 연결 선 그리기 ctx.beginPath(); ctx.moveTo(0, -halfHeight); // 이미지 상단 중앙 ctx.lineTo(rotateHandlePos.x, rotateHandlePos.y); // 회전 핸들 위치 ctx.strokeStyle = '#3498db'; // 파란색 ctx.lineWidth = 2; ctx.stroke(); // 회전 핸들 원 그리기 ctx.beginPath(); ctx.arc(rotateHandlePos.x, rotateHandlePos.y, handleSize / 2, 0, Math.PI * 2); // 회전 핸들 위치에 원 그리기 ctx.fillStyle = '#3498db'; // 파란색 ctx.fill(); ctx.restore(); // 이전 캔버스 상태 복원 } // 주어진 캔버스 좌표에서 어떤 핸들이 있는지 확인 function getHandleAtPosition(canvasX, canvasY, activeOverlay) { // 활성 오버레이나 이미지가 유효하지 않으면 핸들 없음 if (!activeOverlay || !activeOverlay.image || activeOverlay.image.width === 0 || activeOverlay.image.height === 0) return null; const currentWidth = activeOverlay.image.width * activeOverlay.scale; const currentHeight = activeOverlay.image.height * activeOverlay.scale; const halfWidth = currentWidth / 2; const halfHeight = currentHeight / 2; // 마우스/터치 좌표를 오버레이 이미지의 중심을 기준으로 하고 회전을 되돌립니다. const dx = canvasX - activeOverlay.x; const dy = canvasY - activeOverlay.y; const angle = -activeOverlay.rotation * Math.PI / 180; // 회전을 되돌리는 각도 const cos = Math.cos(angle); const sin = Math.sin(angle); // 회전된 좌표 const rotatedX = dx * cos - dy * sin; const rotatedY = dx * sin + dy * cos; // 코너 핸들 위치 (회전되지 않은 상태 기준) const handles = { 'tl': { x: -halfWidth, y: -halfHeight, cursor: 'nwse-resize' }, 'tr': { x: halfWidth, y: -halfHeight, cursor: 'nesw-resize' }, 'br': { x: halfWidth, y: halfHeight, cursor: 'nwse-resize' }, 'bl': { x: -halfWidth, y: halfHeight, cursor: 'nesw-resize' } }; // 각 코너 핸들 근처에 클릭/터치했는지 확인 for (const [key, handle] of Object.entries(handles)) { const dist = Math.sqrt(Math.pow(rotatedX - handle.x, 2) + Math.pow(rotatedY - handle.y, 2)); // 클릭 패딩을 더하여 클릭/터치 영역을 약간 넓게 잡습니다. if (dist <= handleSize / 2 + clickPadding) { return { id: key, cursor: handle.cursor }; // 해당 핸들의 ID와 커서 모양 반환 } } // 회전 핸들 위치 (회전되지 않은 상태 기준) const rotateHandlePos = { x: 0, y: -halfHeight - rotateHandleDistance }; const rotateDist = Math.sqrt(Math.pow(rotatedX - rotateHandlePos.x, 2) + Math.pow(rotatedY - rotateHandlePos.y, 2)); // 회전 핸들 근처에 클릭/터치했는지 확인 if (rotateDist <= handleSize / 2 + clickPadding) { return { id: 'rotate', cursor: 'grab' }; // 회전 핸들 ID와 커서 모양 반환 } return null; // 어떤 핸들도 클릭/터치되지 않음 } // 주어진 캔버스 좌표가 특정 오버레이 이미지 영역 위에 있는지 확인 function isOverOverlayImage(canvasX, canvasY, overlay) { // 오버레이나 이미지가 유효하지 않으면 false 반환 if (!overlay || !overlay.image || overlay.image.width === 0 || overlay.image.height === 0) return false; const currentWidth = overlay.image.width * overlay.scale; const currentHeight = overlay.image.height * overlay.scale; const halfWidth = currentWidth / 2; const halfHeight = currentHeight / 2; // 마우스/터치 좌표를 오버레이 이미지의 중심을 기준으로 하고 회전을 되돌립니다. const dx = canvasX - overlay.x; const dy = canvasY - overlay.y; const angle = -overlay.rotation * Math.PI / 180; // 회전을 되돌리는 각도 const cos = Math.cos(angle); const sin = Math.sin(angle); // 회전된 좌표 const rotatedX = dx * cos - dy * sin; const rotatedY = dx * sin + dy * cos; // 회전된 좌표가 이미지 경계 내에 있는지 확인 (클릭 패딩 적용) return (rotatedX >= -halfWidth - clickPadding && rotatedX <= halfWidth + clickPadding && rotatedY >= -halfHeight - clickPadding && rotatedY <= halfHeight + clickPadding); } // 마우스 커서 모양 업데이트 function updateCursor(canvasX, canvasY) { // 드래그, 회전, 리사이즈 중에는 커서 모양을 변경하지 않습니다. if (isDragging || isRotating || isResizing) return; let cursor = 'default'; // 기본 커서 모양 let handled = false; // 핸들이나 이미지 위에 있는지 여부 // 활성 레이어가 있다면 핸들 위에 있는지 먼저 확인 if (activeOverlayIndex >= 0) { const activeOverlay = overlays[activeOverlayIndex]; const handle = getHandleAtPosition(canvasX, canvasY, activeOverlay); if (handle) { cursor = handle.cursor; // 핸들에 맞는 커서 모양 설정 handled = true; } } // 핸들 위에 있지 않다면, 오버레이 이미지 위에 있는지 확인 (가장 위에 있는 레이어부터) if (!handled) { for (let i = overlays.length - 1; i >= 0; i--) { if (isOverOverlayImage(canvasX, canvasY, overlays[i])) { cursor = 'move'; // 이동 커서 설정 handled = true; break; // 하나라도 발견하면 중지 } } } canvas.style.cursor = cursor; // 캔버스 커서 모양 적용 } // 합성된 최종 이미지 생성 function generateMergedImage() { // 오버레이 이미지가 없으면 생성할 수 없습니다. if (overlays.length === 0) { alert('합성할 오버레이 이미지가 없습니다.'); return null; } // 결과를 그릴 새 캔버스 생성 const resultCanvas = document.createElement('canvas'); resultCanvas.width = CANVAS_WIDTH; resultCanvas.height = canvasAdjustedHeight; const resultCtx = resultCanvas.getContext('2d'); // 캔버스 초기화 (배경 채우기) resultCtx.clearRect(0, 0, resultCanvas.width, resultCanvas.height); if (backgroundImage) { resultCtx.drawImage(backgroundImage, 0, 0, backgroundImage.width, backgroundImage.height, bgDrawProps.x, bgDrawProps.y, bgDrawProps.width, bgDrawProps.height); } else { resultCtx.fillStyle = '#ffffff'; resultCtx.fillRect(0, 0, CANVAS_WIDTH, canvasAdjustedHeight); } // 오버레이 이미지들을 순서대로 그립니다. overlays.forEach(overlay => { if (!overlay.image || overlay.image.width === 0 || overlay.image.height === 0) return; // 필터 적용된 이미지 캔버스 생성 const filteredCanvas = ImageFilters.createFilteredCanvas(overlay.image, overlay.filters); resultCtx.save(); // 현재 상태 저장 // 오버레이 위치, 회전, 스케일 적용 resultCtx.translate(overlay.x, overlay.y); resultCtx.rotate(overlay.rotation * Math.PI / 180); resultCtx.scale(overlay.scale, overlay.scale); // 이미지를 중심 기준으로 그립니다. resultCtx.drawImage(filteredCanvas, -overlay.image.width / 2, -overlay.image.height / 2); resultCtx.restore(); // 상태 복원 }); // 미리보기에 결과 이미지 표시 previewImg.src = resultCanvas.toDataURL(`image/${originalFormat === 'jpg' ? 'jpeg' : originalFormat}`); previewContainer.style.display = 'flex'; // 미리보기 컨테이너 보이게 함 return resultCanvas; // 결과 캔버스 반환 } // 선택된 레이어 삭제 function deleteSelectedLayer() { if (activeOverlayIndex >= 0) { // 삭제 확인 (선택 사항) if (confirm(`레이어 ${activeOverlayIndex + 1}을(를) 삭제하시겠습니까?`)) { // 해당 인덱스의 레이어 삭제 overlays.splice(activeOverlayIndex, 1); activeOverlayIndex = -1; // 활성 레이어 없음으로 설정 updateControlPanel(); // 컨트롤 패널 업데이트 updateLayersList(); // 레이어 목록 업데이트 drawCanvas(); // 캔버스 다시 그리기 // 모든 오버레이가 삭제되면 미리보기 숨김 if (overlays.length === 0) { previewContainer.style.display = 'none'; } } } } // 마우스/터치 이벤트의 클라이언트 좌표를 캔버스 좌표로 변환 function getCanvasCoordinates(clientX, clientY) { const rect = canvas.getBoundingClientRect(); // 캔버스의 화면상 위치와 크기 const scaleX = canvas.width / rect.width; // 가로 스케일 비율 const scaleY = canvas.height / rect.height; // 세로 스케일 비율 // 클라이언트 좌표를 캔버스 내부 좌표로 변환 const x = (clientX - rect.left) * scaleX; const y = (clientY - rect.top) * scaleY; return { x, y }; } // 현재 필터 값을 활성 레이어에 적용하고 캔버스 다시 그리기 function applyCurrentFilters() { if (activeOverlayIndex < 0) return; // 활성 레이어가 없으면 적용하지 않음 const filterValues = { temperature: parseInt(temperatureSlider.value), brightness: parseInt(brightnessSlider.value), contrast: parseInt(contrastSlider.value), saturation: parseInt(saturationSlider.value) }; // filter.js의 applyFilterToLayer 함수 호출 ImageFilters.applyFilterToLayer(overlays, activeOverlayIndex, filterValues); drawCanvas(); // 변경 사항을 반영하여 캔버스 다시 그리기 } // --- 이벤트 리스너 --- // DOMContentLoaded 이벤트: 문서 로드 후 초기화 및 이벤트 리스너 설정 document.addEventListener('DOMContentLoaded', () => { // 배경 이미지 업로드 이벤트 리스너 backgroundInput.addEventListener('change', function(e) { if (e.target.files.length > 0) { loadImage(e.target.files[0], 'background'); } }); // 오버레이 이미지 업로드 이벤트 리스너 overlayInput.addEventListener('change', function(e) { if (e.target.files.length > 0) { loadImage(e.target.files[0], 'overlay'); this.value = ''; // 파일 선택 후 input 값 초기화 (같은 파일 다시 선택 가능하게) } }); // 예시 이미지 클릭 이벤트 리스너 설정 const exampleImagesContainer = document.getElementById('example-images-container'); // 예시 이미지 컨테이너 ID if (exampleImagesContainer) { exampleImagesContainer.querySelectorAll('.example-img').forEach(img => { img.addEventListener('click', function() { const imageUrl = this.src; const fileName = this.getAttribute('data-filename') || 'example_image.png'; // 예시 이미지 로드 const exampleImgElement = new Image(); exampleImgElement.crossOrigin = 'anonymous'; // CORS 문제 방지를 위해 필요할 수 있습니다. exampleImgElement.onload = function() { // TODO: 여기에서 배경 제거 로직을 실행합니다. // 제공된 cropTransparentPixels 함수는 투명 영역을 자르는 데 사용될 수 있습니다. // 만약 예시 이미지가 이미 배경 제거된 이미지라면 이 함수를 사용해도 됩니다. // 일반 이미지의 복잡한 배경을 제거하려면 별도의 고급 이미지 처리 로직이나 라이브러리가 필요합니다. let processedImageSource = exampleImgElement; // 기본적으로는 원본 이미지 사용 try { // 투명 픽셀 자르기 시도 (배경 제거된 이미지에 유용) const croppedCanvas = cropTransparentPixels(exampleImgElement); if (croppedCanvas.width > 0 && croppedCanvas.height > 0) { processedImageSource = croppedCanvas; } else { console.warn("cropTransparentPixels resulted in empty canvas, using original image."); processedImageSource = exampleImgElement; } } catch (e) { console.error("Error during cropTransparentPixels, using original image:", e); processedImageSource = exampleImgElement; } // 처리된 이미지를 Blob으로 변환 // cropTransparentPixels가 캔버스를 반환하므로 toBlob 사용 const canvasToProcess = processedImageSource instanceof HTMLCanvasElement ? processedImageSource : null; if (canvasToProcess) { canvasToProcess.toBlob(function(blob) { if (blob) { // Blob을 사용하여 loadImage 함수 호출 (loadImage 함수가 Blob을 처리하도록 수정됨) loadImage(blob, 'overlay'); } else { console.error("Failed to create blob from processed canvas."); alert("이미지 처리에 실패했습니다."); } }, 'image/png'); // 배경 제거 시 투명도를 위해 PNG 포맷 권장 } else if (processedImageSource instanceof HTMLImageElement) { // Canvas가 아닌 Image 요소인 경우 (e.g., cropTransparentPixels 오류 시) // Data URL로 변환하여 Blob 생성 후 loadImage 호출 const tempCanvas = document.createElement('canvas'); tempCanvas.width = processedImageSource.width; tempCanvas.height = processedImageSource.height; const tempCtx = tempCanvas.getContext('2d'); tempCtx.drawImage(processedImageSource, 0, 0); tempCanvas.toBlob(function(blob) { if (blob) { loadImage(blob, 'overlay'); } else { console.error("Failed to create blob from fallback image."); alert("이미지 처리에 실패했습니다."); } }, 'image/png'); } else { console.error("Processed image source is neither Canvas nor Image."); alert("이미지 처리에 실패했습니다."); } }; exampleImgElement.onerror = function() { console.error("Error loading example image:", imageUrl); alert("예시 이미지를 로드하는 데 실패했습니다."); }; exampleImgElement.src = imageUrl; // 예시 이미지 로드 시작 }); }); } // Delete 키 눌렀을 때 선택된 레이어 삭제 document.addEventListener('keydown', function(e) { // Ctrl 또는 Cmd 키와 함께 눌렸을 때만 동작하도록 조건 추가 (실수 방지) if (e.key === 'Delete' && activeOverlayIndex >= 0) { e.preventDefault(); // 기본 동작(뒤로가기 등) 방지 deleteSelectedLayer(); } }); // 레이어 삭제 버튼 이벤트 deleteLayerBtn.addEventListener('click', function() { if (activeOverlayIndex >= 0) { deleteSelectedLayer(); } }); // 크기 조절 슬라이더 이벤트 scaleSlider.addEventListener('input', function() { if (activeOverlayIndex >= 0) { overlays[activeOverlayIndex].scale = parseInt(this.value) / 100; scaleValue.textContent = this.value + '%'; drawCanvas(); // 캔버스 다시 그리기 } }); // 회전 슬라이더 이벤트 rotationSlider.addEventListener('input', function() { if (activeOverlayIndex >= 0) { overlays[activeOverlayIndex].rotation = parseInt(this.value); rotationValue.textContent = overlays[activeOverlayIndex].rotation + '°'; drawCanvas(); // 캔버스 다시 그리기 } }); // 필터 슬라이더 이벤트 temperatureSlider.addEventListener('input', function() { temperatureValue.textContent = this.value; applyCurrentFilters(); }); brightnessSlider.addEventListener('input', function() { brightnessValue.textContent = this.value + '%'; applyCurrentFilters(); }); contrastSlider.addEventListener('input', function() { contrastValue.textContent = this.value + '%'; applyCurrentFilters(); }); saturationSlider.addEventListener('input', function() { saturationValue.textContent = this.value + '%'; applyCurrentFilters(); }); // 필터 초기화 버튼 이벤트 resetFilterBtn.addEventListener('click', function() { // filter.js의 resetFilters 함수 호출 const defaultFilters = ImageFilters.resetFilters(overlays, activeOverlayIndex); // 슬라이더 값과 표시 업데이트 temperatureSlider.value = defaultFilters.temperature; temperatureValue.textContent = defaultFilters.temperature; brightnessSlider.value = defaultFilters.brightness; brightnessValue.textContent = defaultFilters.brightness + '%'; contrastSlider.value = defaultFilters.contrast; contrastValue.textContent = defaultFilters.contrast + '%'; saturationSlider.value = defaultFilters.saturation; saturationValue.textContent = defaultFilters.saturation + '%'; drawCanvas(); // 변경 사항 반영 }); // 캔버스 마우스/터치 이벤트 리스너 canvas.addEventListener('mousedown', function(e) { // 마우스 왼쪽 버튼만 처리 if (e.button !== 0) return; const { x, y } = getCanvasCoordinates(e.clientX, e.clientY); const isAltPressed = e.altKey; // Alt 키 눌림 여부 확인 let clickedOverlayIndex = -1; // 클릭된 오버레이 인덱스 let handle = null; // 클릭된 핸들 // 활성 레이어가 있다면 먼저 해당 레이어의 핸들 클릭 여부 확인 if (activeOverlayIndex >= 0) { handle = getHandleAtPosition(x, y, overlays[activeOverlayIndex]); if (handle) { clickedOverlayIndex = activeOverlayIndex; // 핸들이 클릭되면 해당 레이어 선택 } } // 핸들이 클릭되지 않았다면 오버레이 이미지 자체 클릭 여부 확인 if (!handle) { let overlaysAtPosition = []; // 클릭된 위치에 있는 모든 오버레이 목록 // 모든 오버레이를 순회하며 클릭된 위치에 해당하는 오버레이 찾기 for (let i = 0; i < overlays.length; i++) { if (isOverOverlayImage(x, y, overlays[i])) { overlaysAtPosition.push(i); } } if (overlaysAtPosition.length > 0) { // 여러 개의 오버레이가 겹쳐 있다면 Alt 키 눌림 여부에 따라 처리 if (isAltPressed && overlaysAtPosition.length > 1) { // Alt 키가 눌렸고 여러 개가 겹쳐 있다면, 현재 선택된 레이어 다음 순서의 레이어를 선택 if (activeOverlayIndex >= 0) { const currentIndexInAtPosition = overlaysAtPosition.indexOf(activeOverlayIndex); if (currentIndexInAtPosition !== -1) { const nextIndexInAtPosition = (currentIndexInAtPosition + 1) % overlaysAtPosition.length; clickedOverlayIndex = overlaysAtPosition[nextIndexInAtPosition]; } else { // 현재 선택된 레이어가 클릭된 위치에 없으면 가장 위에 있는 레이어 선택 (기본 동작) clickedOverlayIndex = overlaysAtPosition[overlaysAtPosition.length - 1]; } } else { // Alt 키가 눌렸지만 현재 선택된 레이어가 없으면 가장 위에 있는 레이어 선택 clickedOverlayIndex = overlaysAtPosition[overlaysAtPosition.length - 1]; } } else { // Alt 키가 눌리지 않았거나 하나만 겹쳐 있다면, 가장 위에 있는 레이어 선택 clickedOverlayIndex = overlaysAtPosition[overlaysAtPosition.length - 1]; } } } // 클릭된 오버레이가 있다면 해당 오버레이를 활성 레이어로 설정하고 상태 업데이트 if (clickedOverlayIndex >= 0) { const activeOverlay = overlays[clickedOverlayIndex]; // 이미 활성 레이어인 경우는 제외 if (activeOverlayIndex !== clickedOverlayIndex) { activeOverlayIndex = clickedOverlayIndex; updateControlPanel(); // 컨트롤 패널 업데이트 updateLayersList(); // 레이어 목록 업데이트 } // 클릭된 것이 핸들이면 해당 작업 시작 if (handle) { if (handle.id === 'rotate') { isRotating = true; canvas.style.cursor = 'grabbing'; } else { isResizing = true; activeHandle = handle.id; // 활성 핸들 ID 설정 // 리사이즈 시작 시 마우스 위치와 오버레이 중심 간의 거리 계산 (비례 스케일링에 사용) const dxMouse = x - activeOverlay.x; const dyMouse = y - activeOverlay.y; resizeStartDistance = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); if (resizeStartDistance < 10) resizeStartDistance = 10; // 너무 가까우면 최소값 설정 } } else { // 핸들이 아닌 오버레이 이미지 자체를 클릭한 경우 드래그 시작 isDragging = true; canvas.style.cursor = 'grabbing'; // 드래그 커서 모양 } } else { // 오버레이 이미지가 아닌 빈 공간을 클릭한 경우 활성 레이어 해제 activeOverlayIndex = -1; updateControlPanel(); // 컨트롤 패널 초기화 updateLayersList(); // 레이어 목록 업데이트 (활성 표시 제거) } // 마지막 마우스/터치 좌표 저장 lastX = x; lastY = y; drawCanvas(); // 캔버스 다시 그리기 (활성 레이어 핸들 표시 업데이트) }); canvas.addEventListener('mousemove', function(e) { const { x, y } = getCanvasCoordinates(e.clientX, e.clientY); // 드래그, 회전, 리사이즈 중이 아니면 커서 모양 업데이트 if (!isDragging && !isRotating && !isResizing) { updateCursor(x, y); } else if (activeOverlayIndex >= 0) { // 드래그, 회전, 리사이즈 중이고 활성 레이어가 있다면 const activeOverlay = overlays[activeOverlayIndex]; canvas.style.cursor = 'grabbing'; // 작업 중에는 잡는 커서 유지 if (isRotating) { // 마우스 위치를 기준으로 회전 각도 계산 const dx = x - activeOverlay.x; const dy = y - activeOverlay.y; let angle = Math.atan2(dy, dx) * 180 / Math.PI + 90; // atan2 결과는 -180~180, +90하여 0~360으로 변환 angle = (angle % 360 + 360) % 360; // 각도를 0~360 범위로 유지 activeOverlay.rotation = angle; // 회전 슬라이더 및 값 업데이트 rotationSlider.value = Math.round(activeOverlay.rotation); rotationValue.textContent = Math.round(activeOverlay.rotation) + '°'; } else if (isResizing && activeOverlay.image) { // 리사이즈 중이고 이미지가 있다면 const dxMouse = x - activeOverlay.x; const dyMouse = y - activeOverlay.y; const currentDistance = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); if (resizeStartDistance > 0 && currentDistance > 0) { // 마우스 이동 거리에 비례하여 스케일 조정 let newScale = activeOverlay.scale * (currentDistance / resizeStartDistance); activeOverlay.scale = Math.max(0.01, newScale); // 스케일 최소값 제한 resizeStartDistance = currentDistance; // 다음 이동 계산을 위해 거리 업데이트 } // 스케일 슬라이더 및 값 업데이트 const scalePercent = Math.round(activeOverlay.scale * 100); scaleSlider.value = scalePercent; scaleValue.textContent = scalePercent + '%'; } else if (isDragging && activeOverlay.image) { // 드래그 중이고 이미지가 있다면 const dx = x - lastX; // x축 이동 거리 const dy = y - lastY; // y축 이동 거리 activeOverlay.x += dx; // 오버레이 위치 업데이트 activeOverlay.y += dy; } // 작업 중에는 캔버스 계속 다시 그리기 if (isRotating || isResizing || isDragging) { drawCanvas(); } } // 마지막 마우스/터치 좌표 업데이트 lastX = x; lastY = y; }); // 마우스 버튼 떼거나 캔버스 밖으로 벗어났을 때 작업 종료 window.addEventListener('mouseup', function(e) { isDragging = false; isRotating = false; isResizing = false; activeHandle = null; // 활성 핸들 없음 // 마우스 좌표가 유효하면 커서 모양 업데이트 if (e.clientX !== undefined && e.clientY !== undefined) { const { x, y } = getCanvasCoordinates(e.clientX, e.clientY); updateCursor(x, y); } else { // 좌표가 유효하지 않으면 기본 커서로 설정 canvas.style.cursor = 'default'; } }); // 터치 이벤트 (마우스 이벤트를 에뮬레이션하여 처리) canvas.addEventListener('touchstart', function(e) { e.preventDefault(); // 기본 터치 동작 방지 (스크롤 등) if (e.touches.length > 0) { const touch = e.touches[0]; // 첫 번째 터치 정보를 사용하여 mousedown 이벤트 생성 및 발생 const mouseEvent = new MouseEvent('mousedown', { clientX: touch.clientX, clientY: touch.clientY, buttons: 1 }); canvas.dispatchEvent(mouseEvent); } }, { passive: false }); // passive: false로 설정하여 preventDefault()가 동작하도록 함 canvas.addEventListener('touchmove', function(e) { e.preventDefault(); // 기본 터치 동작 방지 if (e.touches.length > 0) { const touch = e.touches[0]; // 첫 번째 터치 정보를 사용하여 mousemove 이벤트 생성 및 발생 const mouseEvent = new MouseEvent('mousemove', { clientX: touch.clientX, clientY: touch.clientY, buttons: 1 }); canvas.dispatchEvent(mouseEvent); } }, { passive: false }); canvas.addEventListener('touchend', function(e) { e.preventDefault(); // 기본 터치 동작 방지 // mouseup 이벤트 생성 및 발생 (좌표 정보는 필요 없음) const mouseEvent = new MouseEvent('mouseup', {}); window.dispatchEvent(mouseEvent); // window에 이벤트를 발생시켜 mouseup 리스너가 동작하도록 함 }, { passive: false }); // 합성 버튼 이벤트 generateBtn.addEventListener('click', function() { generateMergedImage(); // 합성 이미지 생성 및 미리보기에 표시 // 이 버튼 클릭 시 UI 레이아웃을 변경하는 코드는 여기에 없습니다. // 오직 generateMergedImage() 함수만 호출합니다. }); // 전체 초기화 버튼 이벤트 resetAllBtn.addEventListener('click', function() { backgroundImage = null; // 배경 이미지 초기화 overlays = []; // 오버레이 배열 초기화 activeOverlayIndex = -1; // 활성 레이어 해제 backgroundInput.value = ''; // 파일 input 값 초기화 overlayInput.value = ''; updateControlPanel(); // 컨트롤 패널 초기화 updateLayersList(); // 레이어 목록 초기화 // 필터 컨트롤 초기값으로 설정 const defaultFilters = ImageFilters.resetFilters(overlays, activeOverlayIndex); // (사실상 overlays가 비어 있으므로 기본값 반환) temperatureSlider.value = defaultFilters.temperature; temperatureValue.textContent = defaultFilters.temperature; brightnessSlider.value = defaultFilters.brightness; brightnessValue.textContent = defaultFilters.brightness + '%'; contrastSlider.value = defaultFilters.contrast; contrastValue.textContent = defaultFilters.contrast + '%'; saturationSlider.value = defaultFilters.saturation; saturationValue.textContent = defaultFilters.saturation + '%'; // 캔버스 크기 및 초기 상태로 재설정 canvasAdjustedHeight = CANVAS_HEIGHT; canvas.height = canvasAdjustedHeight; initCanvas(); // 캔버스 흰색 배경으로 초기화 previewContainer.style.display = 'none'; // 미리보기 영역 숨김 }); // 다운로드 버튼 이벤트 downloadBtn.addEventListener('click', function() { const resultCanvas = generateMergedImage(); // 합성 이미지 생성 if (!resultCanvas) return; // 이미지가 없으면 다운로드하지 않음 const link = document.createElement('a'); // 다운로드를 위한 임시 링크 요소 생성 // 파일 이름 생성 (현재 시간 기반) const timestamp = new Date().toISOString().replace(/[-:.]/g, '').substring(0, 14); // 이미지 포맷 및 확장자 결정 (원본 배경 이미지 포맷 따름) const mimeType = originalFormat === 'jpg' ? 'image/jpeg' : `image/${originalFormat}`; const fileExt = originalFormat; link.download = `merged_image_${timestamp}.${fileExt}`; // 다운로드될 파일 이름 설정 // 캔버스 이미지를 Data URL로 변환하여 링크의 href로 설정 link.href = resultCanvas.toDataURL(mimeType, 1.0); // 1.0은 화질 (PNG는 영향 없음, JPG는 0~1) link.click(); // 링크 클릭 이벤트를 발생시켜 다운로드 실행 }); // 페이지 로드 시 초기화 함수 호출 initCanvas(); // 캔버스 초기 상태로 설정 updateControlPanel(); // 컨트롤 패널 초기 상태로 설정 (버튼 비활성화 등) }); // DOMContentLoaded 끝