qcast-front/src/hooks/useCanvas.js
2026-05-12 10:31:50 +09:00

672 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { fabric } from 'fabric'
import { actionHandler, anchorWrapper, polygonPositionHandler } from '@/util/canvas-util'
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
import { canvasSizeState, canvasState, canvasZoomState, fontSizeState } from '@/store/canvasAtom'
import { QLine } from '@/components/fabric/QLine'
import { QPolygon } from '@/components/fabric/QPolygon'
import { defineQLine } from '@/util/qline-utils'
import { defineQPolygon } from '@/util/qpolygon-utils'
import { writeImage } from '@/lib/canvas'
import { useCanvasEvent } from '@/hooks/useCanvasEvent'
import { useFont } from '@/hooks/common/useFont'
import { OBJECT_PROTOTYPE, SAVE_KEY } from '@/common/common'
import { imageDisplaySelector } from '@/store/settingAtom'
export function useCanvas(id) {
const [canvas, setCanvas] = useRecoilState(canvasState)
const [isLocked, setIsLocked] = useState(false)
const [history, setHistory] = useState([])
const [backImg, setBackImg] = useState()
const [canvasSize] = useRecoilState(canvasSizeState)
const [fontSize] = useRecoilState(fontSizeState)
const { setCanvasForEvent, attachDefaultEventOnCanvas } = useCanvasEvent()
const isImageDisplay = useRecoilValue(imageDisplaySelector)
const {} = useFont()
const resetCanvasZoom = useResetRecoilState(canvasZoomState)
const zoom = useRecoilValue(canvasZoomState)
/**
* 처음 셋팅
*/
useEffect(() => {
const c = new fabric.Canvas(id, {
height: canvasSize.vertical,
width: canvasSize.horizontal,
backgroundColor: 'white',
preserveObjectStacking: true,
selection: false,
})
setCanvas(c)
setCanvasForEvent(c)
attachDefaultEventOnCanvas()
resetCanvasZoom()
return () => {
// c.dispose()
c.clear()
}
}, [])
useEffect(() => {
// zoom 상태가 변경될 때 tempGrid 라인들의 크기를 캔버스에 맞게 조정
if (canvas) {
adjustTempGridLines()
}
}, [zoom])
const adjustTempGridLines = () => {
if (!canvas) return
const canvasWidth = canvas.getWidth()
const canvasHeight = canvas.getHeight()
const currentZoom = canvas.getZoom()
const viewportTransform = canvas.viewportTransform
// 실제 보이는 캔버스 영역 계산 (zoom과 pan 고려)
const visibleLeft = -viewportTransform[4] / currentZoom
const visibleTop = -viewportTransform[5] / currentZoom
const visibleRight = visibleLeft + canvasWidth / currentZoom
const visibleBottom = visibleTop + canvasHeight / currentZoom
// tempGrid 라인들을 찾아서 크기 조정
const tempGridLines = canvas.getObjects().filter((obj) => ['tempGrid', 'lineGrid', 'mouseLine'].includes(obj.name))
tempGridLines.forEach((line) => {
if (line.direction === 'vertical') {
// 세로 라인: y축을 캔버스 전체 높이로 설정
line.set({
x1: line.x1,
y1: visibleTop - 100, // 여유 공간 추가
x2: line.x1,
y2: visibleBottom + 100, // 여유 공간 추가
})
} else if (line.direction === 'horizontal') {
// 가로 라인: x축을 캔버스 전체 너비로 설정
line.set({
x1: visibleLeft - 100, // 여유 공간 추가
y1: line.y1,
x2: visibleRight + 100, // 여유 공간 추가
y2: line.y1,
})
}
line.setCoords()
})
canvas.renderAll()
}
useEffect(() => {
canvas
?.getObjects()
?.filter((obj) => obj.type === 'textbox' || obj.type === 'text' || obj.type === 'i-text')
.forEach((obj) => {
obj.set({ fontSize: fontSize })
})
canvas
?.getObjects()
?.filter((obj) => obj.type === 'QLine' || obj.type === 'QPolygon' || obj.type === 'QRect')
.forEach((obj) => {
obj.setFontSize(fontSize)
})
canvas?.getObjects().length > 0 && canvas?.renderAll()
}, [fontSize])
/**
* 캔버스 초기화
*/
useEffect(() => {
if (canvas) {
initialize()
attachDefaultEventOnCanvas()
}
}, [canvas])
/**
* 마우스 포인터의 가이드라인을 제거합니다.
*/
const removeMouseLines = () => {
if (canvas?._objects.length > 0) {
const mouseLines = canvas?._objects.filter((obj) => obj.name === 'mouseLine')
mouseLines.forEach((item) => canvas?.remove(item))
}
canvas?.renderAll()
}
const initialize = () => {
canvas.getObjects().length > 0 && canvas?.clear()
// [BG-CACHE-DIAG 2026-05-08] drawCacheOnCanvas 0-dim cache 차단 + 식별
// 운영 S203X460260507001 등 손상된 plan 로드 시 InvalidStateError 방지.
// 객체 width/height 가 정상이어도 zoom/scale/bbox 조합으로 _cacheCanvas 가
// 0×0 이 될 수 있어 객체 단위 가드로는 부족. drawCacheOnCanvas 직전에
// 캐시 캔버스 dim 검사 + 로그 + 캐시 비활성으로 차단.
if (!fabric.Object.prototype.__bgCacheDiagPatched) {
const origDrawCache = fabric.Object.prototype.drawCacheOnCanvas
fabric.Object.prototype.drawCacheOnCanvas = function (ctx) {
const c = this._cacheCanvas
if (!c || !c.width || !c.height) {
console.warn('[BG-CACHE-DIAG] skip 0-dim cache:', {
type: this.type, name: this.name, id: this.id,
w: this.width, h: this.height,
sx: this.scaleX, sy: this.scaleY,
visible: this.visible, opacity: this.opacity,
cacheW: c?.width, cacheH: c?.height,
})
this.objectCaching = false
return
}
return origDrawCache.call(this, ctx)
}
fabric.Object.prototype.__bgCacheDiagPatched = true
}
// settings for all canvas in the app
fabric.Object.prototype.transparentCorners = false
fabric.Object.prototype.selectable = true
fabric.Object.prototype.lockMovementX = true
fabric.Object.prototype.lockMovementY = true
fabric.Object.prototype.lockRotation = true
fabric.Object.prototype.lockScalingX = true
fabric.Object.prototype.lockScalingY = true
fabric.Object.prototype.cornerColor = '#2BEBC8'
fabric.Object.prototype.cornerStyle = 'rect'
fabric.Object.prototype.cornerStrokeColor = '#2BEBC8'
fabric.Object.prototype.cornerSize = 6
// 해당 오브젝트 타입의 경우 저장한 값 그대로 불러와야함
OBJECT_PROTOTYPE.forEach((type) => {
const originalToObject = type.toObject
type.toObject = function (propertiesToInclude) {
let source = {}
for (let key in this) {
if (typeof this[key] !== 'function' && SAVE_KEY.includes(key)) {
source[key] = this[key]
}
}
//QLine에 커스텀 어트리뷰트 넣기
if (this.type === 'QLine') {
if (this.attributes) {
this.attributes.type = this.attributes.type || 'default'
source.attributes = {
...this.attributes,
type: this.attributes.type,
}
}
}
// 원본 toObject 를 호출하여 Group 의 objects 등 고유 직렬화 로직을 유지
const base = originalToObject ? originalToObject.call(this, propertiesToInclude) : this.callSuper('toObject', propertiesToInclude)
return fabric.util.object.extend(base, source)
}
})
fabric.QLine = QLine
fabric.QPolygon = QPolygon
QPolygon.prototype.canvas = canvas
QLine.prototype.canvas = canvas
defineQLine()
defineQPolygon()
}
/**
* 캔버스에 도형을 추가한다. 도형은 사용하는 페이지에서 만들어서 파라미터로 넘겨주어야 한다.
*/
const addShape = (shape) => {
canvas?.add(shape)
canvas?.setActiveObject(shape)
canvas?.requestRenderAll()
}
const onChange = (e) => {
const target = e.target
if (target) {
// settleDown(target)
}
if (!isLocked) {
setHistory([])
}
setIsLocked(false)
}
/**
* 눈금 모양에 맞게 움직이도록 한다.
*/
const settleDown = (shape) => {
const left = Math.round(shape?.left / 10) * 10
const top = Math.round(shape?.top / 10) * 10
shape?.set({ left: left, top: top })
}
/**
* redo, undo가 필요한 곳에서 사용한다.
*/
const handleUndo = () => {
if (canvas) {
if (canvas?._objects.length > 0) {
const poppedObject = canvas?._objects.pop()
const group = []
group.push(poppedObject)
if (poppedObject.parent || poppedObject.parentId) {
canvas
?.getObjects()
.filter((obj) => obj.parent === poppedObject.parent || obj.parentId === poppedObject.parentId || obj === poppedObject.parent)
.forEach((obj) => {
group.push(obj)
canvas?.remove(obj)
})
}
setHistory((prev) => {
if (prev === undefined) {
return poppedObject ? [group] : []
}
return poppedObject ? [...prev, group] : prev
})
canvas?.renderAll()
}
}
}
const handleRedo = () => {
if (canvas && history) {
if (history.length > 0) {
setIsLocked(true)
if (Array.isArray(history[history.length - 1])) {
history[history.length - 1].forEach((obj) => {
canvas?.add(obj)
})
} else {
canvas?.add(history[history.length - 1])
}
const newHistory = history.slice(0, -1)
setHistory(newHistory)
}
}
}
/**
* 선택한 도형을 복사한다.
*/
const handleCopy = () => {
const activeObjects = canvas?.getActiveObjects()
if (activeObjects?.length === 0) {
return
}
activeObjects?.forEach((obj) => {
obj.clone((cloned) => {
cloned.set({ left: obj.left + 10, top: obj.top + 10 })
addShape(cloned)
})
})
}
/**
* 페이지 내 캔버스 저장
* todo : 현재는 localStorage에 그냥 저장하지만 나중에 변경해야함
*/
const handleSave = () => {
const objects = canvas?.getObjects()
if (objects?.length === 0) {
alert('저장할 대상이 없습니다.')
return
}
const jsonStr = JSON.stringify(canvas)
localStorage.setItem('canvas', jsonStr)
}
/**
* 페이지 내 캔버스에 저장한 내용 불러오기
* todo : 현재는 localStorage에 그냥 저장하지만 나중에 변경해야함
*/
const handlePaste = () => {
const jsonStr = localStorage.getItem('canvas')
if (!jsonStr) {
alert('붙여넣기 할 대상이 없습니다.')
return
}
canvas?.loadFromJSON(JSON.parse(jsonStr), () => {
localStorage.removeItem('canvas')
console.log('paste done')
})
}
const moveDown = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let top = targetObj.top + 10
if (top > canvasSize.vertical) {
top = canvasSize.vertical
}
targetObj.set({ top: top })
canvas?.renderAll()
}
const moveUp = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let top = targetObj.top - 10
if (top < 0) {
top = 0
}
targetObj.set({ top: top })
canvas?.renderAll()
}
const moveLeft = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let left = targetObj.left - 10
if (left < 0) {
left = 0
}
targetObj.set({ left: left })
canvas?.renderAll()
}
const moveRight = () => {
const targetObj = canvas?.getActiveObject()
if (!targetObj) {
return
}
let left = targetObj.left + 10
if (left > canvasSize.horizontal) {
left = canvasSize.horizontal
}
targetObj.set({ left: left })
canvas?.renderAll()
}
const handleRotate = (degree = 45) => {
const target = canvas?.getActiveObject()
if (!target) {
return
}
const currentAngle = target.angle
target.set({ angle: currentAngle + degree })
canvas?.renderAll()
}
/**
* Polygon 타입만 가능
* 생성한 polygon을 넘기면 해당 polygon은 꼭지점으로 컨트롤 가능한 polygon이 됨
*/
const attachCustomControlOnPolygon = (poly) => {
const lastControl = poly.points?.length - 1
poly.cornerStyle = 'rect'
poly.cornerColor = 'rgba(0,0,255,0.5)'
poly.objectCaching = false
poly.controls = poly.points.reduce(function (acc, point, index) {
acc['p' + index] = new fabric.Control({
positionHandler: polygonPositionHandler,
actionHandler: anchorWrapper(index > 0 ? index - 1 : lastControl, actionHandler),
actionName: 'modifyPolygon',
pointIndex: index,
})
return acc
}, {})
poly.hasBorders = !poly.edit
canvas?.requestRenderAll()
}
/**
* 이미지로 저장하는 함수
* @param {string} title - 저장할 이미지 이름
* @param userId
* @param setThumbnails
*/
const saveImage = async (title = 'canvas', userId, setThumbnails) => {
removeMouseLines()
await writeImage(title, canvas?.toDataURL('image/png').replace('data:image/png;base64,', ''))
.then((res) => {
console.log('success', res)
})
.catch((err) => {
console.log('err', err)
})
// const canvasStatus = addCanvas()
// const patternData = {
// userId: userId,
// imageName: title,
// objectNo: 'test123240822001',
// canvasStatus: JSON.stringify(canvasStatus).replace(/"/g, '##'),
// }
// await post({ url: '/api/canvas-management/canvas-statuses', data: patternData })
// setThumbnails((prev) => [...prev, { imageName: `/canvasState/${title}.png`, userId, canvasStatus: JSON.stringify(canvasStatus) }])
}
const handleFlip = () => {
const target = canvas?.getActiveObject()
if (!target) {
return
}
// 현재 scaleX 및 scaleY 값을 가져옵니다.
const scaleX = target.scaleX
// const scaleY = target.scaleY;
// 도형을 반전시킵니다.
target.set({
scaleX: scaleX * -1,
// scaleY: scaleY * -1
})
// 캔버스를 다시 그립니다.
canvas?.renderAll()
}
function fillCanvasWithDots(canvas, gap) {
const width = canvas.getWidth()
const height = canvas.getHeight()
for (let x = 0; x < width; x += gap) {
for (let y = 0; y < height; y += gap) {
const circle = new fabric.Circle({
radius: 1,
fill: 'black',
left: x,
top: y,
selectable: false,
})
canvas.add(circle)
}
}
canvas?.renderAll()
}
const setCanvasBackgroundWithDots = (canvas, gap) => {
// Create a new canvas and fill it with dots
const tempCanvas = new fabric.StaticCanvas()
tempCanvas.setDimensions({
width: canvas.getWidth(),
height: canvas.getHeight(),
})
fillCanvasWithDots(tempCanvas, gap)
// Convert the dotted canvas to an image
const dataUrl = tempCanvas.toDataURL({ format: 'png' })
// Set the image as the background of the original canvas
fabric.Image.fromURL(dataUrl, function (img) {
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), {
scaleX: canvas.width / img.width,
scaleY: canvas.height / img.height,
})
})
}
const addCanvas = () => {
// const canvasState = canvas
const objs = canvas?.toJSON([
'selectable',
'name',
'parentId',
'id',
'length',
'idx',
'direction',
'lines',
'points',
'lockMovementX',
'lockMovementY',
'lockRotation',
'lockScalingX',
'lockScalingY',
'opacity',
'cells',
'maxX',
'maxY',
'minX',
'minY',
'x',
'y',
'stickeyPoint',
])
const str = JSON.stringify(objs)
canvas?.clear()
return str
// setTimeout(() => {
// // 역직렬화하여 캔버스에 객체를 다시 추가합니다.
// canvas?.loadFromJSON(JSON.parse(str), function () {
// // 모든 객체가 로드되고 캔버스에 추가된 후 호출됩니다.
// console.log(canvas?.getObjects().filter((obj) => obj.name === 'roof'))
// canvas?.renderAll() // 캔버스를 다시 그립니다.
// })
// }, 1000)
}
/**
* cad 파일 사용시 이미지 로딩 함수
*/
const handleBackImageLoadToCanvas = (url) => {
canvas
.getObjects()
.filter((obj) => obj.name === 'backGroundImage')
.forEach((img) => {
canvas.remove(img)
canvas?.renderAll()
})
fabric.Image.fromURL(`${url}?${new Date().getTime()}`, function (img) {
console.log(img)
img.set({
left: 0,
top: 0,
width: img.width,
height: img.height,
name: 'backGroundImage',
selectable: false,
hasRotatingPoint: false, // 회전 핸들 활성화
lockMovementX: false,
lockMovementY: false,
lockRotation: false,
lockScalingX: false,
lockScalingY: false,
visible: isImageDisplay,
})
// image = img
canvas?.add(img)
canvas?.sendToBack(img)
canvas?.renderAll()
setBackImg(img)
})
}
const handleCadImageInit = () => {
canvas.clear()
}
const getCurrentCanvas = () => {
return canvas.toJSON([
'selectable',
'name',
'parentId',
'id',
'length',
'idx',
'direction',
'lines',
'points',
'lockMovementX',
'lockMovementY',
'lockRotation',
'lockScalingX',
'lockScalingY',
'opacity',
'cells',
'maxX',
'maxY',
'minX',
'minY',
'x',
'y',
'stickeyPoint',
])
}
return {
canvas,
addShape,
handleUndo,
handleRedo,
handleCopy,
handleSave,
handlePaste,
handleRotate,
attachCustomControlOnPolygon,
saveImage,
handleFlip,
setCanvasBackgroundWithDots,
addCanvas,
removeMouseLines,
handleBackImageLoadToCanvas,
handleCadImageInit,
backImg,
setBackImg,
}
}