这是我的项目记录系列文章第四篇,上一篇 主要介绍了 Dock 弹框等的实现,同时提到了此次主角 drawing 画板。
画板是目前实现的功能里较为典型的 Hooks 用例,本篇就来详细介绍下,画板最终的效果如图题所示,同时你可以在我的项目 代码(欢迎 watch 和 star)体验。
Canvas 实现画布(译文)
实现画布部分基本参考 React Component to draw on a page using Hooks and Typescript 该文提供完整代码及介绍,十分详细,如果你的英文不错,你可以直接看这篇文章跳过本节译文。
创建组件
我们需要做的第一件事是创建一个 Canvas 组件。 画布需要占用一些空间,我们希望任何父组件都能够覆盖这些空间,所以我们将添加宽度和高度属性。
同时我们将 window.innerWidth 和 window.innerHeight 分别设置为 Canvas 的宽度和高度 defaultProps。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import React from 'react';
interface CanvasProps { width: number; height: number; }
const Canvas = ({ width, height }: CanvasProps) => { return <canvas height={height} width={width} />; };
Canvas.defaultProps = { width: window.innerWidth, height: window.innerHeight, };
export default Canvas;
|
让我们画画吧
因为我们需要修改 canvas 元素,所以我们需要为它添加一个 ref。 我们可以通过使用 useRef 钩子修改我们的 canvas 来实现这一点:
1 2
| const canvasRef = useRef<HTMLCanvasElement>(null); return <canvas ref={canvasRef} height={height} width={width} />;
|
设置状态
我们需要跟踪一些变量:
我们可以通过添加 useState 钩子来做到这一点。Coordinate 是鼠标位置坐标的类型。
1 2 3 4 5 6 7 8 9
| type Coordinate = { x: number; y: number; };
const Canvas = ({ width, height }: CanvasProps) => { const [isPainting, setIsPainting] = useState(false); const [mousePosition, setMousePosition] = useState<Coordinate | undefined>(undefined); // ... other stuff here
|
当鼠标按下时开始绘图
我们将在 useEffect 钩子中添加事件侦听器。 如果我们有一个对画布的有效引用,那么我们将向 mouseDown 事件添加一个事件侦听器。 在 unmount 时,我们需要删除该事件侦听器。
1 2 3 4 5 6 7 8 9 10
| useEffect(() => { if (!canvasRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; canvas.addEventListener('mousedown', startPaint); return () => { canvas.removeEventListener('mousedown', startPaint); }; }, [startPaint]);
|
Startpaint 需要获取鼠标的当前坐标并将 isPainting 设置为 true。 我们还将把它包装在一个 useCallback 钩子中,这样我们就可以在 useCallback 钩子中使用它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const startPaint = useCallback((event: MouseEvent) => { const coordinates = getCoordinates(event); if (coordinates) { setIsPainting(true); setMousePosition(coordinates); } }, []);
// ...other stuff here
const getCoordinates = (event: MouseEvent): Coordinate | undefined => { if (!canvasRef.current) { return; }
const canvas: HTMLCanvasElement = canvasRef.current; return {event.pageX - canvas.offsetLeft, event.pageY - canvas.offsetTop}; };
|
随鼠标移动画线
与 mouseDown 事件侦听器类似,我们将使用 useEffect hook 来添加 mousemove 事件。
1 2 3 4 5 6 7 8 9 10
| useEffect(() => { if (!canvasRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; canvas.addEventListener('mousemove', paint); return () => { canvas.removeEventListener('mousemove', paint); }; }, [paint]);
|
paint 需要:
- 检查一下我们是否在 paint
- 获取新鼠标坐标
- 通过从画布获取呈现上下文,将新旧坐标连线
- 更新旧坐标
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| const paint = useCallback( (event: MouseEvent) => { if (isPainting) { const newMousePosition = getCoordinates(event); if (mousePosition && newMousePosition) { drawLine(mousePosition, newMousePosition); setMousePosition(newMousePosition); } } }, [isPainting, mousePosition] );
// ...other stuff here
const drawLine = (originalMousePosition: Coordinate, newMousePosition: Coordinate) => { if (!canvasRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; const context = canvas.getContext('2d'); if (context) { context.strokeStyle = 'red'; context.lineJoin = 'round'; context.lineWidth = 5;
context.beginPath(); context.moveTo(originalMousePosition.x, originalMousePosition.y); context.lineTo(newMousePosition.x, newMousePosition.y); context.closePath();
context.stroke(); } };
|
鼠标放开则停止绘制
当用户释放鼠标或者将鼠标移出画布区域时,我们希望停止绘制:
1 2 3 4 5 6 7 8 9 10 11 12
| useEffect(() => { if (!canvasRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; canvas.addEventListener('mouseup', exitPaint); canvas.addEventListener('mouseleave', exitPaint); return () => { canvas.removeEventListener('mouseup', exitPaint); canvas.removeEventListener('mouseleave', exitPaint); }; }, [exitPaint]);
|
在 exitPaint 中,我们只是将 isPainting 设置为 false
1 2 3
| const exitPaint = useCallback(() => { setIsPainting(false); }, []);
|
译文完
优化画板:添加功能
画板已经可以画画了,但是作为一个独立工具,只是能够画画是远远不够的,接下来我们为其添加功能面板:
封装 Iconfont 组件
功能面板用到了很多图标,后续项目也会用到,因此我封装了一个 Iconfont 组件
,图标来源是 iconfont,每次我们修改或增加图标等,只需要修改 scriptElem.src 即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| // src/components/iconfont/index.tsx import React, { CSSProperties, RefObject } from "react"; import "./index.scss";
const scriptElem = document.createElement("script"); scriptElem.src = "//at.alicdn.com/t/font_1848517_ds8sk573mfk.js"; document.body.appendChild(scriptElem);
interface PropsTypes { className?: string; type: string; style?: object; svgRef?: RefObject<SVGSVGElement>; clickEvent?: (T: any) => void; }
export const Iconfont = ({ className, type, style, svgRef, clickEvent, }: PropsTypes) => { return ( <svg ref={svgRef} className={className ? "icon-font " + className : "icon-font"} aria-hidden="true" style={style as CSSProperties} onClick={clickEvent} > <use xlinkHref={`#${type}`} /> </svg> ); };
// ./index.scss .icon-font { width: 1em; height: 1em; vertical-align: -0.15em; fill: currentColor; overflow: hidden; }
|
功能面板结构
功能面板结构如下,对应本节开头图片:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import { Iconfont } from "../iconfont"; import { CSSTransition } from "react-transition-group";
return ( <React.Fragment> <canvas id="canvas" ref={canvasRef} height={height} width={width} /> <div id="toolbox-open" style={ { borderRadius: isToolboxOpen ? null : 5, } as CSSProperties } > <Iconfont type={isToolboxOpen ? "icon-upward_flat" : "icon-downward_flat"} style={{ width: "100%", fontSize: 32, }} clickEvent={toolboxOpenClick} /> </div> <CSSTransition in={isToolboxOpen} //用于判断是否出现的状态 timeout={300} //动画持续时间 classNames="toolbox" //className值,防止重复 unmountOnExit > <div id="toolbox"> <span>Options</span> <div className="options"> ... </div> <span>Toolbox</span> <div className="tools"> ... </div> <div className="sizes"> ... </div> <ol className="colors"> ... </ol> </div> </CSSTransition> </React.Fragment> )
|
通过 isToolboxOpen 设定功能面板是否收缩,引入 CSSTransition 添加展开收缩动画。
1 2 3 4 5 6 7
| const [isToolboxOpen, setToolboxOpen] = useState(true); const toolboxOpenClick = useCallback( (e) => { setToolboxOpen(!isToolboxOpen); }, [isToolboxOpen] );
|
下面我们依次介绍各个功能模块:
主要用来选择是画笔还是橡皮擦:
1 2
| const toolsMap = ["canvas_paint", "canvas_eraser"]; const [eraserEnabled, setEraserEnabled] = useState(false);
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <div className="tools"> {toolsMap.map((tool, index) => { return ( <Iconfont key={index + tool} className={ tool === "canvas_eraser" ? eraserEnabled ? "active" : "" : !eraserEnabled ? "active" : "" } type={"icon-" + tool} style={{ fontSize: 50 }} clickEvent={(e) => onToolsClick([e, tool])} /> ); })} </div>
|
1 2 3 4 5 6 7 8 9 10 11 12
| const onToolsClick = useCallback(([e, toolName]) => { const el = e.currentTarget; if (el.classList[1]) return; toolName === "canvas_eraser" ? setEraserEnabled(true) : setEraserEnabled(false); el.classList.add("active"); el.parentNode.childNodes.forEach((item: HTMLLIElement) => { if (!item.matches("svg") || item === el) return; item.classList.remove("active"); }); }, []);
|
修改 paint 函数,通过 eraserEnabled 判断是 clearRect 还是 drawLine:
1 2 3 4 5 6 7 8 9 10 11 12 13
| if (mousePosition && newMousePosition) { if (eraserEnabled) { clearRect({ x: newMousePosition.x - lineWidth / 2, y: newMousePosition.y - lineWidth / 2, width: lineWidth, height: lineWidth, }); } else { drawLine(mousePosition, newMousePosition); setMousePosition(newMousePosition); } }
|
sizes/colors 面板:
colors 面板列出了几种常用颜色,增加了原生颜色选择器,可改变画笔颜色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <ol className="colors"> {colorMap.map((color, index) => { return ( <li className={color === strokeStyle ? color + " active" : color} key={index + color} onClick={(e) => onColorsClick([e, "li", color])} ></li> ); })} <input type="color" value={strokeStyle} onChange={onColorsChange} id="currentColor" /> </ol>
|
1 2 3 4 5 6 7 8 9 10 11 12
| const [strokeStyle, setStrokeStyle] = useState("black");
const onColorsClick = useCallback(([e, selector, color]) => { const el = e.target; if (el.className.includes("active")) return; setStrokeStyle(color); el.classList.add("active"); el.parentNode.childNodes.forEach((item: HTMLLIElement) => { if (!item.matches(selector) || item === el) return; item.classList.remove("active"); }); }, []);
|
sizes 主要用来修改画笔或橡皮檫粗细:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <div className="sizes"> <input style={ { backgroundColor: eraserEnabled ? "#ebeff4" : strokeStyle, } as CSSProperties } type="range" id="range" name="range" min="1" max="20" value={lineWidth} onChange={onSizesChange} /> </div>
|
1 2 3 4 5
| const [lineWidth, setLineWidth] = useState(5);
const onSizesChange = useCallback((e) => { setLineWidth(e.target.value); }, []);
|
options 面板:
主要有保存、清空、回退及前进功能:
1 2 3 4 5 6
| const optionsMap = [ "canvas_save", "canvas_clear", "turn_left_flat", "turn_right_flat", ];
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <div className="options"> {optionsMap.map((option, index) => { return ( <Iconfont svgRef={ option === "turn_right_flat" ? goRef : option === "turn_left_flat" ? backRef : undefined } key={index + option} className={option} type={"icon-" + option} style={{ fontSize: 50 }} clickEvent={(e) => onOptionsClick([e, option])} /> ); })} </div>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const onOptionsClick = useCallback( ([e, toolName]) => { switch (toolName) { case "canvas_clear": setClearDialogOpen(true); break; case "canvas_save": saveCanvas(); break; case "turn_left_flat": changeCanvas("back"); break; case "turn_right_flat": changeCanvas("go"); break; } }, [saveCanvas, changeCanvas] );
|
首先我们介绍 回退及前进:
1 2 3 4
| const backRef = useRef<SVGSVGElement>(null); const goRef = useRef<SVGSVGElement>(null); const [step, setStep] = useState(-1); const [canvasHistory, setCanvasHistory] = useState<string[]>([]);
|
我们在每次画笔或橡皮 mouseup 时,记录下 canvas 片段(saveFragment),值得注意的是,这里我们的 mouseleave 还应该是上文原来的 exitPaint(无 saveFragment):
1 2 3 4 5
| const exitPaint = useCallback(() => { setIsPainting(false); setMousePosition(undefined); saveFragment(); }, [saveFragment]);
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const saveFragment = useCallback(() => { setStep(step + 1); if (!canvasRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; canvasHistory.push(canvas.toDataURL()); setCanvasHistory(canvasHistory);
if (!backRef.current || !goRef.current) { return; } const back: SVGSVGElement = backRef.current; const go: SVGSVGElement = goRef.current; back.classList.add("active"); go.classList.remove("active"); }, [step, canvasHistory]);
|
当我们点击这两个按钮就会触发 changeCanvas,获取 step 从而得到对应 canvasHistory 内 url,根据它我们能生成一个片段图片画到画布上下文内。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const changeCanvas = useCallback( (type) => { if (!canvasRef.current || !backRef.current || !goRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; const context = canvas.getContext("2d"); const back: SVGSVGElement = backRef.current; const go: SVGSVGElement = goRef.current; if (context) { let currentStep = -1; if (type === "back" && step >= 0) { currentStep = step - 1; go.classList.add("active"); if (currentStep < 0) { back.classList.remove("active"); } } else if (type === "go" && step < canvasHistory.length - 1) { currentStep = step + 1; back.classList.add("active"); if (currentStep === canvasHistory.length - 1) { go.classList.remove("active"); } } else { return; } context.clearRect(0, 0, width, height); const canvasPic = new Image(); canvasPic.src = canvasHistory[currentStep]; canvasPic.addEventListener("load", () => { context.drawImage(canvasPic, 0, 0); }); setStep(currentStep); } }, [canvasHistory, step, width, height] );
|
接着我们来看下 保存按钮的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const saveCanvas = useCallback(() => { if (!canvasRef.current) { return; } const canvas: HTMLCanvasElement = canvasRef.current; const context = canvas.getContext("2d"); if (context) { // 用于记录当前 context.globalCompositeOperation ——(合成或混合模式) const compositeOperation = context.globalCompositeOperation;、 // 设置为 “在现有的画布内容后面绘制新的图形” context.globalCompositeOperation = "destination-over"; context.fillStyle = "#fff"; context.fillRect(0, 0, width, height); const imageData = canvas.toDataURL("image/png"); // 将数据从已有的 ImageData 对象绘制到位图 context.putImageData(context.getImageData(0, 0, width, height), 0, 0); // 复原 context.globalCompositeOperation context.globalCompositeOperation = compositeOperation; // 下载操作 const a = document.createElement("a"); document.body.appendChild(a); a.href = imageData; a.download = "myPaint"; a.target = "_blank"; a.click(); } }, [width, height]);
|
最后我们来讲讲清空按钮:
清空按钮的实现其实十分简单,但是点击直接删除的话交互太不友好,我们需要给他来个确认弹框,
根据第三篇讲到的 UseModel 组件我们可以快速写出一个弹框:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| import React, { useMemo, useState, CSSProperties } from "react"; import { Dialog, Button } from "react-desktop/macOs"; /// <reference path="react-desktop.d.ts" />
interface DialogProps { width: number; height: number; id: string; title?: string; message?: string; imgSrc?: string; onCheck: (T: any) => void; onClose: (T: any) => void; }
export const useDialog = () => { const [isVisible, setIsVisible] = useState(false); const openDialog = () => setIsVisible(true); const closeDialog = () => setIsVisible(false); const RenderDialog = ({ width, height, id, title, message, imgSrc, onCheck, onClose, }: DialogProps) => { const styles = useMemo( () => ({ width: width, height: height, left: `calc(50vw - ${width / 2}px)`, top: `calc(50vh - ${height}px)`, borderRadius: 4, }), [width, height] );
const renderIcon = () => { if (!imgSrc) return; return ( <img src={require("../footer/image/" + imgSrc)} width="52" height="52" alt="tip" /> ); }; return ( <React.Fragment> {isVisible && ( <div id={id} style={styles as CSSProperties}> <Dialog title={title} message={message} icon={renderIcon()} buttons={[ <Button onClick={onClose}>取消</Button>, <Button color="blue" onClick={onCheck}> 确认 </Button>, ]} /> </div> )} </React.Fragment> ); };
return { openDialog, closeDialog, RenderDialog, }; };
|
使用也是一样的十分简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import { useDialog } from "../dialog/index";
...
const { openDialog, closeDialog, RenderDialog } = useDialog(); const [isClearDialogOpen, setClearDialogOpen] = useState(false); useEffect(isClearDialogOpen ? openDialog : closeDialog, [isClearDialogOpen]);
return ( <React.Fragment> ... <RenderDialog width={300} height={120} id="clear-dialog" title="您确定要清空该画布吗?" message="一旦清空将无法撤回。" imgSrc={"Drawing.png"} onCheck={checkClearDialog} onClose={closeClearDialog} ></RenderDialog> </React.Fragment> );
|
效果如下图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| // 确认清空 const checkClearDialog = useCallback( (e) => { clearRect({ x: 0, y: 0, width, height, }); setCanvasHistory([]); setStep(-1); closeClearDialog(e); if (!backRef.current || !goRef.current) { return; } const back: SVGSVGElement = backRef.current; const go: SVGSVGElement = goRef.current; back.classList.remove("active"); go.classList.remove("active"); }, [closeClearDialog, clearRect, width, height] ); // 取消 const closeClearDialog = useCallback( (e) => { setClearDialogOpen(false); }, [setClearDialogOpen] );
|
功能面板完。
至此,一个简约而不简单的画板就完成了。
小结
本篇文章梳理了仿 MacOS 桌面中画图工具的实现过程,代码及功能并不复杂但有很多值得注意的细节,希望通过该文章你能够掌握 React Hooks 基本用法及对 Canvas 有一定了解。
如果你喜欢这篇文章,不要忘了给我点赞(收藏永远比点赞多,可以像 B 站一样三连啊哈哈)。🍮