https://github.com/zhcxk1998/School-Partners
采用的是canvas绘制画笔,由 css3 的transform属性来进行平移与缩放,因为呢考虑到如果用 canvas 的drawImage或者scale等属性进行变化,生成出来的图片也会有影响,想着直接 css3 变化,canvas 用来做画笔等功能。大佬们有何妙招,在评论区指点指点!
举一下横坐标的例子,通过公式来将鼠标按下的坐标转换为画布中的相对坐标,这一点尤为重要
(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
transformOrigin
: transform变化的基点(通过这个属性来控制元素以哪里进行变化)downX
: 鼠标按下的坐标(注意,用的时候需要减去容器左偏移距离,因为我们要的是相对于容器的坐标)scale
: 缩放倍数,默认为1translateX
: 平移的距离这个公式的话,其实就比较通用,可以用在别的利用到transform
属性的场景,至于怎么推导的话,我是用的笨办法
先做出两个相同的元素,然后标记上坐标,并且设置容器属性overflow:hidden
来隐藏溢出内容
现在就有两个一样的矩阵啦,我们为他标记上一些红点,然后我们对左边的进行 css3 的样式变化transform
矩形的宽高是360px * 360px
的,我们定义一下他的变化属性,变化基点选择正中心,放大 3 倍
transform-origin: 180px 180px;
transform: scale(3, 3);
得到如下结果
现在对比一下上面的结果,就会发现,放大 3 倍的时候,恰好是中间黑色方块占据了全部宽度。接下来我们就可以对这些点与原先没有进行变化(右边)的矩形进行对比就可以得到他们坐标的关系啦
开始对两个坐标进行对比,然后推出公式
现在举一个简单的例子吧,例如我们算一下左上角的坐标(现在已经标记为黄色了)
(这里左边计算坐标的值是我们鼠标按下的坐标)
360px
,所以分成 3 等份,每份宽度是120px
x:120 y:0
,右边的黄色标记为x:160 y:120
(这个其实肉眼看应该就能看出来了,实在不行可以用纸笔算一算)这个坐标可能有点特殊,我们再换几个来计算计算(根据特殊推一般)
x:120 y:120
,右边:x: 160 y:160
x: 240 y:240
,右边:x: 200: y:200
好了,我们差不多已经可以拿到坐标之间的关系了,我们可以列一个表
还觉得不放心?我们可以换一下,缩放倍数与容器宽高等进行计算
不知道大家有没有感觉呢,然后我们就可以慢慢根据坐标推出通用的公式啦
(transformOrigin - downX) / scale * (scale-1) + down - translateX = point
当然,我们或许还有这个translateX
没有尝试,这个就比较简单一点了,脑内模拟一下,就知道我们可以减去位移的距离就 ok 啦。我们测试一下
我们先修改一下样式,新增一下位移的距离
transform-origin: 180px 180px;
transform: scale(3, 3) translate(-40px,-40px);
还是我们上面的状态,ok,我们现在蓝色跟绿色的标记还是一一对应的,那我们看看现在的坐标情况
x:0 y:0
,右边:x:160 y:160
x:120 y:120
,右边:x:200 y:200
我们分别运用公式算一下出来的坐标是怎么样的 (以下为经过坐标换算)
x:120 y:120
,右边:x:160 y:160
x:160 y:160
,右边:x:200 y:200
不难发现,我们其实就相差了与位移距离translateX/translateY
的差值,所以,我们只需要减去位移的距离就可以完美的进行坐标转换啦
根据上面的公式,我们可以简单测试一下!这个公式到底能不能生效!!!
我们直接沿用上面的 demo,测试一下如果元素进行了变化,我们鼠标点下的地方生成一个标记,位置是否显示正确
const wrap = document.getElementById('wrap')
wrap.onmousedown = function (e) {
const downX = e.pageX - wrap.offsetLeft
const downY = e.pageY - wrap.offsetTop
const scale = 3
const translateX = -40
const translateY = -40
const transformOriginX = 180
const transformOriginY = 180
const dot = document.getElementById('dot')
dot.style.left = (transformOriginX - downX) / scale * (scale - 1) + downX - translateX + 'px'
dot.style.top = (transformOriginY - downY) / scale * (scale - 1) + downY - translateY + 'px'
}
可能有人会问,为什么要减去这个offsetLeft
跟offsetTop
呢,因为我们上面反复强调,我们计算的是鼠标点击的坐标,而这个坐标还是相对于我们展示容器的坐标,所以我们要减去容器本身的偏移量才行。
既然 demo 啥的都已经测试了 ok 了,我们接下来就逐一分析一下这个组件应该咋设计好呢(目前仍为低配版,之后再进行优化完善)
我们先简单分析一下这个构成吧,其实主要就是一个画布的容器,右边一个工具栏,仅此而已
大体就这样子啦!
<div class ref={wrapRef}>
<canvas
ref={canvasRef}
class>
<p>很可惜,这个东东与您的电脑不搭!</p>
</canvas>
<div class />
</div>
我们唯一需要的一点就是,容器需要设置属性overflow: hidden
用来隐藏内部 canvas 画布溢出的内容,也就是说,我们要控制我们可视的区域。同时我们需要动态获取容器宽高来为 canvas 设置尺寸
我们可以弄个方法来初始化并且填充画布,以下截取主要部分,其实就是为 canvas 画布设置尺寸与填充我们的图片
const fillImage = async () => {
// 此处省略...
const img: HTMLImageElement = new Image()
img.src = await getURLBase64(fillImageSrc)
img.onload = () => {
canvas.width = img.width
canvas.height = img.height
context.drawImage(img, 0, 0)
// 设置变化基点,为画布容器中央
canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
// 清除上一次变化的效果
canvas.style.transform = ''
}
}
这个控制移动的话,我们首先可以弄一个方法来监听画布鼠标的各种事件,可以区分不同的模式来进行不同的事件处理
const handleCanvas = () => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!context || !wrap) return
// 清除上一次设置的监听,以防获取参数错误
wrap.onmousedown = null
wrap.onmousedown = function (event: MouseEvent) {
const downX: number = event.pageX
const downY: number = event.pageY
// 区分我们现在选择的鼠标模式:移动、画笔、橡皮擦
switch (mouseMode) {
case MOVE_MODE:
handleMoveMode(downX, downY)
break
case LINE_MODE:
handleLineMode(downX, downY)
break
case ERASER_MODE:
handleEraserMode(downX, downY)
break
default:
break
}
}
这个就比较好办啦,我们只需要利用鼠标按下的坐标,和我们拖动的距离就可以实现画布的移动啦,因为涉及到每次移动都需要计算最新的位移距离,我们可以定义几个变量来进行计算。
这里监听的是容器的鼠标事件,而不是 canvas 画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作
简单的总结一下:
// 定义一些变量,来保存当前/最新的移动状态
// 当前位移的距离
const translatePointXRef: MutableRefObject<number> = useRef(0)
const translatePointYRef: MutableRefObject<number> = useRef(0)
// 上一次位移结束的位移距离
const fillStartPointXRef: MutableRefObject<number> = useRef(0)
const fillStartPointYRef: MutableRefObject<number> = useRef(0)
// 移动时候的监听函数
const handleMoveMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const { current: fillStartPointX } = fillStartPointXRef
const { current: fillStartPointY } = fillStartPointYRef
if (!canvas || !wrap || mouseMode !== 0) return
// 为容器添加移动事件,可以在空白处移动图片
wrap.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX
const moveY: number = event.pageY
// 更新现在的位移距离,值为:上一次位移结束的坐标+移动的距离
translatePointXRef.current = fillStartPointX + (moveX - downX)
translatePointYRef.current = fillStartPointY + (moveY - downY)
// 更新画布的css变化
canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
}
wrap.onmouseup = (event: MouseEvent) => {
const upX: number = event.pageX
const upY: number = event.pageY
// 取消事件监听
wrap.onmousemove = null
wrap.onmouseup = null;
// 鼠标抬起时候,更新“上一次唯一结束的坐标”
fillStartPointXRef.current = fillStartPointX + (upX - downX)
fillStartPointYRef.current = fillStartPointY + (upY - downY)
}
}
画布缩放我主要通过右侧的滑动条以及鼠标滚轮来实现,首先我们再监听画布鼠标事件的函数中加一下监听滚轮的事件
总结一下:
// 监听鼠标滚轮,更新画布缩放倍数
const handleCanvas = () => {
const { current: wrap } = wrapRef
// 省略一万字...
wrap.onwheel = null
wrap.onwheel = (e: MouseWheelEvent) => {
const { deltaY } = e
// 这里要注意一下,我是0.1来递增递减,但是因为JS使用IEEE 754,来计算,所以精度有问题,我们自己处理一下
const newScale: number = deltaY > 0
? (canvasScale * 10 - 0.1 * 10) / 10
: (canvasScale * 10 + 0.1 * 10) / 10
if (newScale < 0.1 || newScale > 2) return
setCanvasScale(newScale)
}
}
// 监听滑动条来控制缩放
<Slider
min={0.1}
max={2.01}
step={0.1}
value={canvasScale}
tipFormatter={(value) => `${(value).toFixed(2)}x`}
onChange={handleScaleChange} />
const handleScaleChange = (value: number) => {
setCanvasScale(value)
}
接着我们使用 hooks 的副作用函数,依赖于画布缩放倍数来进行样式的更新
//监听缩放画布
useEffect(() => {
const { current: canvas } = canvasRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
}, [canvasScale])
这个就需要用到我们之前推导出来的公式啦!因为呢,仔细想一下,如果我们缩放位移之后,我们鼠标按下的位置,他的坐标可能就相对于画布来说会有变化,所以我们需要转换一下才能进行鼠标按下的位置与画布的位置一一对应的效果
稍微总结一下:
// 利用公式转换一下坐标
const generateLinePoint = (x: number, y: number) => {
const { current: wrap } = wrapRef
const { current: translatePointX } = translatePointXRef
const { current: translatePointY } = translatePointYRef
const wrapWidth: number = wrap?.offsetWidth || 0
const wrapHeight: number = wrap?.offsetHeight || 0
// 缩放位移坐标变化规律
// (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY
return {
pointX,
pointY
}
}
// 监听鼠标画笔事件
const handleLineMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
// 减去画布偏移的距离(以画布为基准进行计算坐标)
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.globalCompositeOperation = "source-over"
context.beginPath()
// 设置画笔起点
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
// 开始绘制画笔线条~
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
橡皮擦目前还有点问题,现在的话是通过将canvas
画布的背景图片 + globalCompositeOperation
这个属性来模拟橡皮擦的实现,不过,这时候图片生成出来之后,橡皮擦的痕迹会变成白色,而不是透明
此步骤与画笔实现差不多,只有一点点小变动
context.globalCompositeOperation = "destination-out"
// 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色
const handleEraserMode = (downX: number, downY: number) => {
const { current: canvas } = canvasRef
const { current: wrap } = wrapRef
const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
if (!canvas || !wrap || !context) return
const offsetLeft: number = canvas.offsetLeft
const offsetTop: number = canvas.offsetTop
downX = downX - offsetLeft
downY = downY - offsetTop
const { pointX, pointY } = generateLinePoint(downX, downY)
context.beginPath()
context.moveTo(pointX, pointY)
canvas.onmousemove = null
canvas.onmousemove = (event: MouseEvent) => {
const moveX: number = event.pageX - offsetLeft
const moveY: number = event.pageY - offsetTop
const { pointX, pointY } = generateLinePoint(moveX, moveY)
context.globalCompositeOperation = "destination-out"
context.lineWidth = lineWidth
context.lineTo(pointX, pointY)
context.stroke()
}
canvas.onmouseup = () => {
context.closePath()
canvas.onmousemove = null
canvas.onmouseup = null
}
}
这个的话,我们首先需要了解常见的撤销与恢复的功能的逻辑 分几种情况吧
所以我们需要设置一些变量来存,状态列表,与当前画笔的状态下标
// 定义参数存东东
const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)
我们还需要在初始化 canvas 的时候,我们就添加入当前的状态存入列表中,作为最先开始的空画布状态
const fillImage = async () => {
// 省略一万字...
img.src = await getURLBase64(fillImageSrc)
img.onload = () => {
const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
canvasHistroyListRef.current = []
canvasHistroyListRef.current.push(imageData)
setCanvasCurrentHistory(1)
}
}
然后我们就实现一下,画笔更新时候,我们也需要将当前的状态添加入画笔状态列表,并且更新当前状态对应的下标,还需要处理一下一些细节
总结一下: