672 lines
18 KiB
JavaScript
672 lines
18 KiB
JavaScript
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,
|
||
}
|
||
}
|