创建 Javascript 绘图和注释应用程序

注释和绘图有很多用例。在本教程中,让我们看看如何使用 Javascript 制作一个简单的绘图应用程序。我们将使用纯 Javascript构建它,因此它可以让您灵活地在任何需要的地方实现它。

演示#

首先,让我们看看它是如何工作的,我已经向这个页面添加了功能。单击按钮时,您将获得绘图工具,您可以使用这些工具在页面上绘制线条和箭头。还有一个橡皮擦工具,如果你犯了错误,可以通过点击它们来擦除线条。

单击绘图框中的十字按钮可以关闭绘图工具。同样,所有图纸都保留在之后的页面上,允许您对此页面进行注释


        <button class="animated hover-button" id="start-drawing"><span>✍️ Activate Drawing</span></button><a href="https://twitter.com/thisisfjolt" target="_blank" class="link">Follow on Twitter</a>
<a href="https://fjolt.com/article/javascript-frontend-drawing-annotation-application" target="_blank" class="link">Read Article</a>
        <div id="drawing-cover"></div>
        <div id="drawing-layer"></div><div id="drawing-box">
    <div class="tools">
        <button data-tool="freeHand" data-current="true"><span><i class="fal fa-pencil-alt"></i></span></button>
        <button data-tool="arrow" ><span><i class="fal fa-arrow-up"></i></span></button>
        <button data-tool="eraser" ><span><i class="fal fa-eraser"></i></span></button>
    </div>
    <div class="colors">
        <div data-color="white" data-rColor="white" data-current="true"></div>
        <div data-color="black" data-rColor="#544141"></div>
        <div data-color="red" data-rColor="#d83030"></div>
        <div data-color="green" data-rColor="#30d97d"></div>
        <div data-color="orange" data-rColor="#ff9000"></div>
        <div data-color="yellow" data-rColor="#f3f326"></div>
    </div>
    <div class="close">
        <i class="fal fa-times"></i>
    </div>
</div>

        <script src="https://kit.fontawesome.com/48764efa36.js" crossorigin="anonymous"></script>
body { background: rgb(10 13 37); }
    #drawing-box {
        background: linear-gradient(360deg, #ebf3fd, white);
        position: fixed;
        left: 2rem;
        padding: 1rem;
        display: flex;
        box-shadow: 0 2px 20px #000000c4;
        z-index: 9999999;
        transition: all 0.2s ease-out;
        transform: scale(0.5);
        bottom: -4rem;
        border-radius: 100px;
    }

    [data-drawing="true"] #drawing-box {
        bottom: 3rem;
        transform: scale(1);
    }

    #drawing-cover {
        position: fixed;
        top: 0;
        left: 0;
        pointer-events: none;
        transition: all 0.3s ease-out;
        width: 100%;
        height: 100%;
    }
    [data-drawing="true"] #drawing-cover {
        background: rgba(0,0,0,0.5);
        pointer-events: all;
    }

    #drawing-box .tools i, #drawing-box .close i {
        color: black;
        padding: 0;
        border-bottom: 2px solid transparent;
        font-size: 1.25rem;
        padding: 0 0 0.25rem 0;
    }
    #drawing-box .close {
        border-left: 1px solid #00000030;
        padding: 0 0 0 1rem;
        display: flex;
        align-items: center;
        font-size: 1.5rem;
        cursor: pointer;
    }
    #drawing-box .close i {
        padding: 0;
        margin: 0;
        border: none;
    }
    #drawing-box .tools {
        display: flex;
        border-right: 1px solid #00000030;
        margin: 0 1rem 0 0;
    }

    #drawing-box .tools > button {
        width: 32px;
        margin: 0 1rem 0 0;
        padding: 0;
        background: transparent;
        box-shadow: none;
        height: 32px;
    }

    #drawing-box .tools > button span {
        background: transparent;
        filter: none !important;
        padding: 0;
        border-radius: 4px;
    }

    #drawing-box .colors {
        display: flex;
        align-items: center;
    }
    #drawing-box .colors > div {
        width: 24px;
        height: 24px;
        border-radius: 100px;
        transition: all 0.1s ease-out;
        cursor: pointer;
        margin: 0 1rem 0 0;
        transform: scale(1);
        box-shadow: inset 0 0 0 1px rgba(0,0,0,.1);
    }
    #drawing-box .colors > div:hover {
        transform: scale(1.05);
    }
    #drawing-box .colors > div[data-current="true"]:after {
        content: '';
        position: absolute;
        top: -4px;
        left: -4px;
        width: calc(100% + 4px);
        height: calc(100% + 4px);
        border-radius: 100px;
        border: 2px solid #0646ff;
    }
#drawing-box i { 
      display: block;
}
    #drawing-box [data-tool][data-current="true"] i {
        color: #0646ff;
        cursor: pointer;
        border-bottom: 2px solid #0646ff !important;
    }

    #drawing-box [data-tool]:not([data-current="true"]):hover i {
        color: rgba(0,0,0,0.5);
    }

    #drawing-layer svg {
        display: block !important;
        fill: transparent;
        clip-path: inset(-9999px -9999px -99999px -99999px);
        overflow: visible;
        z-index: 999999999;
    }
    #drawing-layer {
        overflow: visible;
        pointer-events: none;
    }

    [data-drawing="true"] #drawing-layer {
        pointer-events: all;
    }
    #drawing-layer .free-hand, #drawing-layer .arrow {
        overflow: visible;
        position: absolute;
    }

    #drawing-layer .free-hand.static, #drawing-layer .arrow.static {
        opacity: 0;
    }

    #drawing-layer svg path {
        stroke-linecap: round;
    }

    #drawing-layer svg path, #drawing-layer svg line {
        cursor: pointer;
	    pointer-events: visiblepainted;
        position: absolute;
    }
    #drawing-box .colors [data-color="black"] { background: #544141; }
    #drawing-box .colors [data-color="red"] { background: #d83030; }
    #drawing-box .colors [data-color="green"] { background: #30d97d; }
    #drawing-box .colors [data-color="orange"] { background: #ff9000; }
    #drawing-box .colors [data-color="yellow"] { background: #f3f326; }

    @media screen and (max-width: 700px) {
        body[data-drawing="true"] {
            overflow: hidden;
            position: fixed;
            width: 100%;
            height: 100%;
            top: 0;
            left: 0;
        }
        #drawing-box .colors [data-color="green"], 
        #drawing-box .colors [data-color="orange"],
        #drawing-box .colors [data-color="yellow"],
        #drawing-box .colors [data-color="white"] {
            display: none;
        }
    }


    button {
        background: linear-gradient(180deg, #ff7147, #e0417f);
        font-size: 1.5rem;
        color: white;
        padding: 1rem 2rem;
        line-height: 2rem;
      cursor: pointer;
        will-change: transform, filter;
        float: none;
        transition: all 0.15s ease-out;
        height: auto;
        border-radius: 100px;
      border: none;
        overflow: hidden;
        display: block;
      margin: 2rem;
        display: block;
        transform: rotateX(0deg) rotateY(0deg) scale(1);
        filter: drop-shadow(0 15px 15px rgba(0,0,0,0.3));
        font-weight: 500;
        perspective-origin: 0 0;
        letter-spacing: 0;
    }

.link {
  display: block;
  color: rgba(255,255,255,0.7);
  font-size: 1.25rem;
  letter-spacing: 0.5px;
  line-height: 2rem;
  margin: 0 0 0 3rem;
}

.link:hover {
  color: rgba(255,255,255,1);
}

    // Ensure drawing layer is at root
    document.body.appendChild(document.getElementById('drawing-layer'));

    // Manage Main UI
    // Add a pointerdown event for each color and tool.
    // When a user clicks a color or tool, then we set it to our current config.color or config.tool respectively, and highlight it on the UI
    [ 'data-rColor', 'data-tool' ].forEach(function(i) {
        document.querySelectorAll(`[${i}]`).forEach(function(item) {
            item.addEventListener('pointerdown', function(e) {
                document.querySelectorAll(`[${i}]`).forEach(function(i) {
                    i.setAttribute('data-current', false);
                });
                item.setAttribute('data-current', true);
                if(i == 'data-rColor') {
                    config.color = item.getAttribute(i);
                } else if(i == 'data-tool') {
                    config.tool = item.getAttribute(i);
                }
            });
        });
    });

    let config = {
        drawing: false,         // Set to true if we are drawing, false if we aren't
        tool: 'freeHand',       // The currently selected tool
		color : 'white',        // The currently selected colour
        strokeWidth: 4,         // The width of the lines we draw
        configNormalisation: 12,// The average normalisation for pencil drawing
    }

    let arrow = {
        // topX, Y, and bottomX, Y store information on the arrows top and bottom ends
		topX: 0,
		topY: 0,
		bottomX: 0,      
		bottomY: 0,          
		activeDirection: 'se',                    // This is the current direction of the arrow, i.e. south-east
	    arrowClasses: [ 'nw', 'ne', 'sw', 'se' ], // These are possible arrow directions
		lineAngle: 0,                             // This is the angle the arrow point at about the starting point
	}
    let freeHand = {
		currentPathText: 'M0 0 ',      // This is the current path of the pencil line, in text
		topX: 0,                       // The starting X coordinate
		topY: 0,                       // The starting Y coordinate
        lastMousePoints: [ [0, 0] ],   // This is the current path of the pencil line, in array
    }

    let svgEl = {
		arrowPath: (start, dimensions, path, dummy, direction, end, angle, hyp, id) => 
		`<div class="arrow drawing-el static current-item" data-id="${id}" data-direction="${direction}" 
			style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;">
			<div class="arrow-point arrow-point-one"></div>
			<div class="arrow-point arrow-point-two" style="
				transform-origin: 0 0; left: ${hyp[1]}px; top: ${hyp[2]}px; transform: rotateZ(${angle}deg) translateY(-${hyp[0]}px) translateX(-15px);
			"></div>
			<svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}">
				<defs>
					<marker id="arrow-head-${id}" class="arrow-resizer" markerWidth="10" markerHeight="10" refX="0" refY="3" 
					orient="auto" markerUnits="strokeWidth" viewBox="0 0 20 20">
						<path d="M0 0 L0 6 L9 3 z" fill="${config.color}" />
					</marker>
				</defs>
				<path marker-start="url(#bottom-marker)" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" marker-end="url(#arrow-head-${id})" class="arrow-line" d="${path}"></path>
			</svg>
		</div>`,
		drawPath: (start, dimensions, path, id) => 
		`<div class="free-hand drawing-el static current-item" data-id="${id}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;">
			<svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}">			
				<path d="${path}" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}"></path>
			</svg>
		</div>`
	}

    // Set the body attribute 'data-drawing' to true or false, based on if the user clicks the 'Start Drawing' button
    // Also sets config.drawing to true or false.
    document.getElementById('start-drawing').addEventListener('click', function(e) {
        if(config.drawing === true) {
            config.drawing = false;
            document.body.setAttribute('data-drawing', false)
        } else {   
            let drawingCover = document.getElementById('drawing-cover');
            document.body.setAttribute('data-drawing', true)
            config.drawing = true;
        }
    });

    // Closes the drawing box and sets 'data-drawing' on the body element to false
    // Along with cofig.drawing to false.
    document.querySelector('#drawing-box .close').addEventListener('click', function(e) {
        document.body.setAttribute('data-drawing', false);
        config.drawing = false;
    })

    document.body.addEventListener('pointerdown', function(e) {
        
        // Generate id for each element
        let id = helper.generateId();
    
        if(config.tool == 'arrow' && config.drawing == true) {
            // Set arrow start point
            arrow.topX = e.clientX;
            arrow.topY = e.clientY;

            // Add element to drawing layer
			document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + 
            svgEl.arrowPath(  [ arrow.topX + window.scrollX, arrow.topY + window.scrollY ], [  e.clientX, e.clientX ], `M0 0 L0 0`, 'arrow-item', arrow.arrowClasses[3], [ 0, 0 ], 0, [ 0, 0, 0 ], id );
        }
        else if(config.tool == 'freeHand' && config.drawing == true) {

            // Set the drawing starting point
            freeHand.topX = e.clientX;
            freeHand.topY = e.clientY;

            // Set the current path and most recent mouse points to whereever we are scrolled on the page
            freeHand.currentPathText = `M${window.scrollX} ${window.scrollY} `;
            freeHand.lastMousePoints = [[ window.scrollX, window.scrollY ]];
            
            // Add element to the drawing layer
            document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + 
            svgEl.drawPath( [ e.clientX, e.clientY ], [ e.clientX, e.clientY ], ``, id);
        } 
        else if(config.tool == 'eraser' && config.drawing == true) {
            // Check if user has clicked on an svg
            if(helper.parent(e.target, '.drawing-el', 1) !== null && helper.parent(e.target, '.drawing-el', 1).matches('.drawing-el')) {
                // If they have, delete it
                helper.parent(e.target, '.drawing-el', 1).remove();
            }
        }
    })

    document.body.addEventListener('pointermove', function(e) {

        // Assuming there is a current item to in the drawing layer
        if(document.querySelector('#drawing-layer .current-item') !== null) {
            // If we are using the arrow tool
            if(config.drawing == true && config.tool == 'arrow') {
                // Then get the original start position
                let startX = arrow.topX;
                let startY = arrow.topY;
                // Set a default angle of 90
                let angleStart = 90;
                
                // And a default direction of 'south east'
                let arrowClass = arrow.arrowClasses[3];
                // Calculate how far the user has moved their mouse from the original position
                let endX = e.pageX - startX - window.scrollX;
                let endY = e.pageY - startY - window.scrollY;

                // And using that info, calculate the arrow's angle
                helper.calculateArrowLineAngle(endX, endY);
                // Then update the config to this new end position
                arrow.bottomX = endX;
                arrow.bottomY = endY;
                
                // And update the HTML to show the new arrow to the user
                document.querySelector('#drawing-layer .arrow.current-item').classList.remove('static');
                document.querySelector('#drawing-layer .arrow.current-item').setAttribute('data-direction', arrow.activeDirection);
                document.querySelector('#drawing-layer .arrow.current-item svg').setAttribute('viewbox', `0 ${endX} 0 ${endY}`);
                document.querySelector('#drawing-layer .arrow.current-item path.arrow-line').setAttribute('d', `M0 0 L${endX} ${endY}`);
            }
            
            else if(config.drawing == true && config.tool == 'freeHand') {
                // Similar to arrows, calculate the user's end position
                let endX = e.pageX - freeHand.topX;
                let endY = e.pageY - freeHand.topY;
                
                // And push these new coordinates to our config
                let newCoordinates = [ endX, endY ];
                freeHand.lastMousePoints.push([endX, endY]);
                if(freeHand.lastMousePoints.length >= config.configNormalisation) {
                    freeHand.lastMousePoints.shift();
                }
                
                // Then calculate the average points to display a line to the user
                let avgPoint = helper.getAveragePoint(0);
                if (avgPoint) {
                    freeHand.currentPathText += " L" + avgPoint.x + " " + avgPoint.y;

                    let tmpPath = '';
                    for (let offset = 2; offset < freeHand.lastMousePoints.length; offset += 2) {
                        avgPoint = helper.getAveragePoint(offset);
                        tmpPath += " L" + avgPoint.x + " " + avgPoint.y;
                    }

                    // Set the complete current path coordinates
                    document.querySelector('#drawing-layer .free-hand.current-item').classList.remove('static');
                    document.querySelector('#drawing-layer .free-hand.current-item svg path').setAttribute('d', freeHand.currentPathText + tmpPath);
                }

            }
        }
			
    });

    // Whenever the user leaves the page with their mouse or lifts up their cursor
    [ 'mouseleave', 'pointerup' ].forEach(function(item) {
        document.body.addEventListener(item, function(e) {
            // Remove current-item class from all elements, and give all SVG elements pointer-events
            document.querySelectorAll('#drawing-layer > div').forEach(function(item) {
                item.style.pointerEvent = 'all';
                item.classList.remove('current-item');
                // Delete any 'static' elements
                if(item.classList.contains('static')) {
                    item.remove();
                }
            });
            // Reset freeHand variables where needed
            freeHand.currentPathText = 'M0 0 ';
            freeHand.lastMousePoints = [ [0, 0] ];
        });
    });


    let helper = {
        // This averages out a certain number of mouse movements for free hand drawing
        // To give our lines a smoother effect
        getAveragePoint: function(offset) {
            let len = freeHand.lastMousePoints.length;
            if (len % 2 === 1 || len >= 8) {
                let totalX = 0;
                let totalY = 0;
                let pt, i;
                let count = 0;
                for (i = offset; i < len; i++) {
                    count++;
                    pt = freeHand.lastMousePoints[i];
                    totalX += pt[0];
                    totalY += pt[1];
                }

                return {
                    x: totalX / count,
                    y: totalY / count
                }
            }
            return null;
        },
        // This calculates the angle and direction of a moving arrow
        calculateArrowLineAngle: function(lineEndX, lineEndY) {
			
			var calcLineEndX = lineEndX;
			var calcLineEndY = lineEndY;
			var angleStart = 90;
			var angle = 0;
			var a = calcLineEndX;
			var b = calcLineEndY;
			var c = Math.sqrt(Math.pow(lineEndX, 2) + Math.pow(lineEndY, 2));
			
			if(calcLineEndX <= 0 && calcLineEndY >= 0) {
				// quadrant 3
				angleStart = 180;
				angle = Math.asin(a/c) * -1 * (180/Math.PI);
				arrow.activeDirection = arrow.arrowClasses[2];
			} else if(calcLineEndY <= 0 && calcLineEndX >= 0) {
				// quadrant 1
				angleStart = 0;
				angle = Math.asin(a/c) * (180/Math.PI);
				arrow.activeDirection = arrow.arrowClasses[1];
			} else if(calcLineEndY <= 0 && calcLineEndX <= 0) {
				// quadrant 4
				angleStart = 270;
				angle = Math.asin(b/c) * -1 * (180/Math.PI);
				arrow.activeDirection = arrow.arrowClasses[0];
			}
			else {
				// quadrant 2
				angleStart = 90;
				angle = Math.asin(b/c) * (180/Math.PI);
				arrow.activeDirection = arrow.arrowClasses[3];
			}
			
			arrow.lineAngle = angle + angleStart;
		},
        // This generates a UUID for our drawn elements
        generateId: function() {
            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
                var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);
            });
        },
        // This function matches parent elements allowing us to select a parent element
        parent: function(el, match, last) {
            var result = [];
            for (var p = el && el.parentElement; p; p = p.parentElement) {
                result.push(p);
                if(p.matches(match)) {
                    break;
                }
            }
            if(last == 1) {
                return result[result.length - 1];
            } else {
                return result;
            }
        }
    }

跟踪用户的鼠标活动#

如前所述,此演示使用 vanilla JS,因此为了使其正常工作,我们专注于使用一些事件侦听器来跟踪用户正在做什么。

为了跟踪用户活动,我创建了 3 个对象– 一个是通用配置项,另外两个分别与铅笔和箭头绘图工具相关。下面显示了这些配置以及评论中每个项目的解释。


let config = {
    drawing: false,         // Set to true if we are drawing, false if we aren't
    tool: 'freeHand',       // The currently selected tool
	color : 'white',        // The currently selected colour
    strokeWidth: 4,         // The width of the lines we draw
    configNormalisation: 12,// The average normalisation for pencil drawing
}
let arrow = {
    // topX, Y, and bottomX, Y store information on the arrows top and bottom ends
	topX: 0,
	topY: 0,
	bottomX: 0,      
	bottomY: 0,          
	activeDirection: 'se',                    // This is the current direction of the arrow, i.e. south-east
    arrowClasses: [ 'nw', 'ne', 'sw', 'se' ], // These are possible arrow directions
	lineAngle: 0,                             // This is the angle the arrow point at about the starting point
}
let freeHand = {
	currentPathText: 'M0 0 ',      // This is the current path of the pencil line, in text
	topX: 0,                       // The starting X coordinate
	topY: 0,                       // The starting Y coordinate
    lastMousePoints: [ [0, 0] ],   // This is the current path of the pencil line, in array
}

控制用户界面#

下一个主要步骤是添加 UI。我创建了一个简单的绘图 UI,当用户单击“开始绘图”时会弹出该 UI。在 HTML 中,它看起来像这样:

<button class="animated hover-button" id="start-drawing"><span>✍️ Activate Drawing</span></button>
<div id="drawing-cover"></div>
<div id="drawing-layer"></div>
<div id="drawing-box">
    <div class="tools">
        <button data-tool="freeHand" data-current="true"><span>Pen</span></button>
        <button data-tool="arrow"><span>Arrow</span></button>
        <button data-tool="eraser"><span>Eraser</span></button>
    </div>
    <div class="colors">
        <div data-color="white" data-rColor="white" data-current="true"></div>
        <div data-color="black" data-rColor="#544141"></div>
        <div data-color="red" data-rColor="#d83030"></div>
        <div data-color="green" data-rColor="#30d97d"></div>
        <div data-color="orange" data-rColor="#ff9000"></div>
        <div data-color="yellow" data-rColor="f3f326"></div>
    </div>
    <div class="close">
        Close
    </div>
</div>

注意:我添加了另外两个位于 UI 之外的元素 – 一个是drawing-cover,它在激活绘图时简单地覆盖屏幕,另一个是drawing-layer,它包含用户绘制的所有绘图元素。

对于每种工具和颜色,我都附加了一些数据属性:

  • 对于工具– 每个项目都有一个数据工具属性。
  • 对于颜色– 每个项目都有一个data-colordata-rColor属性,分别指颜色的文本名称和它的十六进制值。

我们需要数据属性,因为当用户单击项目时,我们将在代码中引用它们。下面是用于控制 UI 的代码 – 实际上,我们在这里所做的只是config在用户单击工具或颜色时更改我们的主要对象:

// Add a pointerdown event for each color and tool.
// When a user clicks a color or tool, then we set it to our current config.color or config.tool respectively, and highlight it on the UI
[ 'data-rColor', 'data-tool' ].forEach(function(i) {
    document.querySelectorAll(`[${i}]`).forEach(function(item) {
        item.addEventListener('pointerdown', function(e) {
            document.querySelectorAll(`[${i}]`).forEach(function(i) {
                i.setAttribute('data-current', false);
            });
            item.setAttribute('data-current', true);
            if(i == 'data-rColor') {
                config.color = item.getAttribute(i);
            } else if(i == 'data-tool') {
                config.tool = item.getAttribute(i);
            }
        });
    });
});

// Set the body attribute 'data-drawing' to true or false, based on if the user clicks the 'Start Drawing' button
// Also sets config.drawing to true or false.
document.getElementById('start-drawing').addEventListener('click', function(e) {
    if(config.drawing === true) {
        config.drawing = false;
        document.body.setAttribute('data-drawing', false)
    } else {   
        let drawingCover = document.getElementById('drawing-cover');
        document.body.setAttribute('data-drawing', true)
        config.drawing = true;
    }
});

// Closes the drawing box and sets 'data-drawing' on the body element to false
// Along with cofig.drawing to false.
document.querySelector('#drawing-box .close').addEventListener('click', function(e) {
    document.body.setAttribute('data-drawing', false);
    config.drawing = false;
})

我们绘图的 HTML 元素#

要绘制,我们必须在用户屏幕上附加一些东西。为了使事情变得简单和模块化,我使用了 SVG。根据您的用例,如果需要,这也可以用画布重写 – 但是 SVG 也可以正常工作。SVG 在我们需要时为我们提供了对 DOM 元素的大量控制。我创建了两个函数,它们为箭头和线条图返回 HTML。这些如下所示。

由于我们需要将一些数字传递给这些 HTML 元素,因此我们为这些函数提供了变量(即start、dimensions、path等。这允许我们为 SVG 标签提供更新的定位,我们可以将其渲染到页面上。

let svgEl = {
	arrowPath: (start, dimensions, path, dummy, direction, end, angle, hyp, id) => 
	`<div class="arrow drawing-el static current-item" data-id="${id}" data-direction="${direction}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;">
		<div class="arrow-point arrow-point-one"></div>
		<div class="arrow-point arrow-point-two" style="
			transform-origin: 0 0; left: ${hyp[1]}px; top: ${hyp[2]}px; transform: rotateZ(${angle}deg) translateY(-${hyp[0]}px) translateX(-15px);
		"></div>
		<svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}">
			<defs>
				<marker id="arrow-head-${id}" class="arrow-resizer" markerWidth="10" markerHeight="10" refX="0" refY="3" orient="auto" markerUnits="strokeWidth" viewBox="0 0 20 20">
					<path d="M0 0 L0 6 L9 3 z" fill="${config.color}" />
				</marker>
			</defs>
			<path marker-start="url(#bottom-marker)" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" marker-end="url(#arrow-head-${id})" class="arrow-line" d="${path}" />
		</svg>
	</div>`,
	drawPath: (start, dimensions, path, id) => 
	`<div class="free-hand drawing-el static current-item" data-id="${id}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;">
		<svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}">			
			<path d="${path}" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" />
		</svg>
	</div>`
}

这两个函数为我们提供了正确的 SVG 用于箭头手绘

用户交互#

最后一步是添加用户交互。最终,这归结为三个主要功能:

  • mousedown,告诉我们用户何时开始绘图,假设他们点击了“开始绘图”按钮。
  • mousemove,跟踪用户的鼠标移动。
  • mouseup,当用户完成绘图时。

第 1 步:鼠标按下

第一阶段是mousedown。这将在用户点击我们的网页时触发。因此,我们要确保用户正在绘制(即为config.drawingtrue 。我们可以通过检查我们的config对象是否config.drawing设置为true来做到这一点。如果我们正在绘图,那么我们将用户单击的初始点存储在freeHandconfigarrow对象中。

最后,我们将 HTML 元素附加到页面。如果我们使用橡皮擦,我们会检查用户点击的点是否为 SVG,如果点击则将其删除。为此,我们使用辅助函数,可以在Github Repo或我们的“>Codepen示例中找到。


document.body.addEventListener('pointerdown', function(e) {
    
    // Generate id for each element
    let id = helper.generateId();

    if(config.tool == 'arrow' && config.drawing == true) {
        // Set arrow start point
        arrow.topX = e.clientX;
        arrow.topY = e.clientY;
        // Add element to drawing layer
		document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + 
        svgEl.arrowPath(  [ arrow.topX + window.scrollX, arrow.topY + window.scrollY ], [  e.clientX, e.clientX ], `M0 0 L0 0`, 'arrow-item', arrow.arrowClasses[3], [ 0, 0 ], 0, [ 0, 0, 0 ], id );
    }
    else if(config.tool == 'freeHand' && config.drawing == true) {
        // Set the drawing starting point
        freeHand.topX = e.clientX;
        freeHand.topY = e.clientY;
        // Set the current path and most recent mouse points to whereever we are scrolled on the page
        freeHand.currentPathText = `M${window.scrollX} ${window.scrollY} `;
        freeHand.lastMousePoints = [[ window.scrollX, window.scrollY ]];
        
        // Add element to the drawing layer
        document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + 
        svgEl.drawPath( [ e.clientX, e.clientY ], [ e.clientX, e.clientY ], ``, id);
    } 
    else if(config.tool == 'eraser' && config.drawing == true) {
        // Check if user has clicked on an svg
        if(helper.parent(e.target, '.drawing-el', 1) !== null && helper.parent(e.target, '.drawing-el', 1).matches('.drawing-el')) {
            // If they have, delete it
            helper.parent(e.target, '.drawing-el', 1).remove();
        }
    }
})

第 2 步:鼠标移动

接下来让我们研究一下用户点击然后移动鼠标时会发生什么。在这种情况下,我们想徒手延长线,或移动箭头的箭头。当前绘制的元素有一个名为current-item的类,因此我们可以使用它来更新我们的 HTML 元素。从根本上说,我们只是想根据用户鼠标的位置向 SVG 元素添加更多点。由于我们存储了用户点击的原始位置,我们可以使用它作为参考点来计算用户从那里移动config了多少像素。为此,我们还使用了两个计算辅助函数,这两个函数都可以在Github Repo或我们的codepen示例中找到:

  • 对于箭头,我们calculateArrowLineAngle用来计算箭头的角度和方向。
  • 对于徒手,我们使用getAveragePoint计算最后几次鼠标移动的平均值,以创建一条平滑线。

static移动后,我们还会从绘制的元素中删除该类。这让我们知道用户想要保留这个绘制的元素。如果他们没有移动,我们稍后会在他们将手指从鼠标上移开时将其移除,并且static类让我们确定这一点。

document.body.addEventListener('pointermove', function(e) {
    // Assuming there is a current item to in the drawing layer
    if(document.querySelector('#drawing-layer .current-item') !== null) {
        // If we are using the arrow tool
        if(config.drawing == true && config.tool == 'arrow') {
            // Then get the original start position
            let startX = arrow.topX;
            let startY = arrow.topY;
            // Set a default angle of 90
            let angleStart = 90;
            
            // And a default direction of 'south east'
            let arrowClass = arrow.arrowClasses[3];
            // Calculate how far the user has moved their mouse from the original position
            let endX = e.pageX - startX - window.scrollX;
            let endY = e.pageY - startY - window.scrollY;
            // And using that info, calculate the arrow's angle
            helper.calculateArrowLineAngle(endX, endY);
            // Then update the config to this new end position
            arrow.bottomX = endX;
            arrow.bottomY = endY;
            
            // And update the HTML to show the new arrow to the user
            document.querySelector('#drawing-layer .arrow.current-item').classList.remove('static');
            document.querySelector('#drawing-layer .arrow.current-item').setAttribute('data-direction', arrow.activeDirection);
            document.querySelector('#drawing-layer .arrow.current-item svg').setAttribute('viewbox', `0 ${endX} 0 ${endY}`);
            document.querySelector('#drawing-layer .arrow.current-item path.arrow-line').setAttribute('d', `M0 0 L${endX} ${endY}`);
        }
        
        else if(config.drawing == true && config.tool == 'freeHand') {
            // Similar to arrows, calculate the user's end position
            let endX = e.pageX - freeHand.topX;
            let endY = e.pageY - freeHand.topY;
            
            // And push these new coordinates to our config
            let newCoordinates = [ endX, endY ];
            freeHand.lastMousePoints.push([endX, endY]);
            if(freeHand.lastMousePoints.length >= config.configNormalisation) {
                freeHand.lastMousePoints.shift();
            }
            
            // Then calculate the average points to display a line to the user
            let avgPoint = helper.getAveragePoint(0);
            if (avgPoint) {
                freeHand.currentPathText += " L" + avgPoint.x + " " + avgPoint.y;
                let tmpPath = '';
                for (let offset = 2; offset < freeHand.lastMousePoints.length; offset += 2) {
                    avgPoint = helper.getAveragePoint(offset);
                    tmpPath += " L" + avgPoint.x + " " + avgPoint.y;
                }
                // Set the complete current path coordinates
                document.querySelector('#drawing-layer .free-hand.current-item').classList.remove('static');
                document.querySelector('#drawing-layer .free-hand.current-item svg path').setAttribute('d', freeHand.currentPathText + tmpPath);
            }
        }
    }
		
});

第 3 步:鼠标上移

鼠标向上的目的是a)重置绘图配置freeHandb)删除用户未移动鼠标的任何元素arrow。如果我们不做b),那么当用户点击页面时会出现随机箭头。

与其他功能相比,这相对简单,如下所示:

// Whenever the user leaves the page with their mouse or lifts up their cursor
[ 'mouseleave', 'pointerup' ].forEach(function(item) {
    document.body.addEventListener(item, function(e) {
        // Remove current-item class from all elements, and give all SVG elements pointer-events
        document.querySelectorAll('#drawing-layer > div').forEach(function(item) {
            item.style.pointerEvent = 'all';
            item.classList.remove('current-item');
            // Delete any 'static' elements
            if(item.classList.contains('static')) {
                item.remove();
            }
        });
        // Reset freeHand variables where needed
        freeHand.currentPathText = 'M0 0 ';
        freeHand.lastMousePoints = [ [0, 0] ];
    });
});

结论#

我们完成了。由于我们使用了 pointerdownpointermovepointerup,这个演示应该也可以在移动设备上运行。下面,我附上了一些有用的链接,包括 Github 和 Codepen 上的源代码。如果您有任何问题,可以通过Twitter联系我们。

javascript-frontend-drawing-annotation-application