上一篇我做了一个在线白板!给大家介绍了一下矩形的绘制、选中、拖动、旋转、伸缩,以及放大缩小、网格模式、导出图片等功能,本文继续为各位介绍一下箭头的绘制、自由书写、文字的绘制,以及如何按比例缩放文字图片等这些需要固定长宽比例的图形、如何缩放自由书写折线这些由多个点构成的元素。

箭头的绘制

箭头其实就是一根线段,只是一端存在两根成一定角度的小线段,给定两个端点的坐标即可绘制一条线段,关键是如何计算出另外两根小线段的坐标,箭头线段和线段的夹角我们设置为30度,长度设置为30px

let l = 30;
let deg = 30;

如图所示,已知线段的两个端点坐标为:(x,y)(tx,ty),箭头的两根小线段有一个头是和线段(tx,ty)重合的,所以我们只要求出(x1,y1)(x2,y2)即可。

先来看(x1,y1)

首先我们可以使用Math.atan2函数计算出线段和水平线的夹角Aatan2函数可以计算任意一个点(x, y)和原点(0, 0)的连线与X轴正半轴的夹角大小,我们可以把线段的(x,y)当做原点,那么(tx,ty)对应的坐标就是(tx-x, ty-y),那么可以求出夹角A为:

let lineDeg = radToDeg(Math.atan2(ty - y, tx - x));// atan2计算出来为弧度,需要转成角度

那么线段另一侧与X轴的夹角也是A

已知箭头线段和线段的夹角为30度,那么两者相减就可以计算出箭头线段和X轴的夹角B

let plusDeg = deg - lineDeg;

箭头线段作为斜边,可以和X轴形成一个直角三角形,然后使用勾股定理就可以计算出对边L2和邻边L1

let l1 = l * Math.sin(degToRad(plusDeg));// 角度要先转成弧度
let l2 = l * Math.cos(degToRad(plusDeg));

最后,我们将tx减去L2即可得到x1的坐标,ty加上L1即可得到y1的坐标:

let _x = tx - l2
let _y = ty + l1

计算另一侧的(x2,y2)坐标也是类似,我们可以先计算出和Y轴的夹角,然后同样是勾股定理计算出对边和邻边,再使用(tx,ty)坐标相减:

角度B为:

let plusDeg = 90 - lineDeg - deg;

(x2,y2)坐标计算如下:

let _x = tx - l * Math.sin(degToRad(plusDeg));// L1
let _y = ty - l * Math.cos(degToRad(plusDeg));// L2

自由书写

自由书写很简单,监听鼠标移动事件,记录下移动到的每个点,用线段绘制出来即可,线段的宽度我们暂且设置为2px

const lastMousePos = {x: null,y: null
}
const onMouseMove = (e) => {if (lastMousePos.x !== null && lastMousePos.y !== null) {ctx.beginPath();ctx.lineWidth = 2;ctx.lineCap = "round";ctx.lineJoin = "round";ctx.moveTo(lastMousePos.x, lastMousePos.y);ctx.lineTo(e.clientX, e.clientY);ctx.stroke();}lastMousePos.x = e.clientX;lastMousePos.y = e.clientY;
}

这样画出来的线段是粗细都是一样的,和现实情况其实并不相符,写过毛笔字的朋友应该更有体会,速度慢的时候画的线会粗一点,画的速度快线段会细一点,所以我们可以结合速度来动态设置线段的宽度。

先计算出鼠标当前时刻的速度:

let lastMouseTime = null;
const onMouseMove = (e) => {if (lastMousePos.x !== null && lastMousePos.y !== null) {// 使用两点距离公式计算出鼠标这一次和上一次的移动距离let mouseDistance = getTowPointDistance(e.clientX,e.clientY,lastMousePos.x,lastMousePos.y);// 计算时间let curTime = Date.now();let mouseDuration = curTime - lastMouseTime;// 计算速度let mouseSpeed = mouseDistance / mouseDuration;// 更新时间lastMouseTime = curTime;}// ...
}

看一下计算出来的速度:

我们取10作为最大的速度,0.5作为最小的速度,同样线段的宽度也设定一个最大和最小宽度,太大和太小实际观感其实都不太好,那么当速度大于最大的速度,宽度就设为最小宽度;小于最小的速度,宽度就设为最大的宽度,处于中间的速度,宽度我们就按比例进行计算:

// 动态计算线宽
const computedLineWidthBySpeed = (speed) => {let lineWidth = 0;let minLineWidth = 2;let maxLineWidth = 4;let maxSpeed = 10;let minSpeed = 0.5;// 速度超快,那么直接使用最小的笔画if (speed >= maxSpeed) {lineWidth = minLineWidth;} else if (speed <= minSpeed) {// 速度超慢,那么直接使用最大的笔画lineWidth = maxLineWidth;} else {// 中间速度,那么根据速度的比例来计算lineWidth = maxLineWidth -((speed - minSpeed) / (maxSpeed - minSpeed)) * maxLineWidth;}return lineWidth;
};

中间速度的比例计算也很简单,计算当前速度相对于最大速度的比值,乘以最大宽度,因为速度和宽度是成反比的,所以用最大宽度相减计算出该速度对应的宽度。

可以看到速度慢的时候确实是宽的,速度快的时候确实也是细的,但是这个宽度变化是跳跃的,很突兀,也无法体现出是一个渐变的过程,解决方法很简单,因为是相对于上一次的线条来说差距过大,所以我们可以把这一次计算出来的宽度和上一次的宽度进行中和,比如各区一半作为本次的宽度:

const computedLineWidthBySpeed = (speed, lastLineWidth = -1) => {// ...if (lastLineWidth === -1) {lastLineWidth = maxLineWidth;}// 最终的粗细为计算出来的一半加上上一次粗细的一半,防止两次粗细相差过大,出现明显突变return lineWidth * (1 / 2) + lastLineWidth * (1 / 2);
}

虽然仔细看还是能看出来突变,但相比之前还是好了很多。

文字的绘制

文字的输入是通过input标签实现的。

当绘制新文字时,创建一个无边框无背景的input元素,通过固定定位显示在鼠标所点击的位置,然后自动获取焦点,监听输入事件,实时计算输入的文字大小动态更新文本框的宽高,达到可以一直输入的效果,当失去焦点时隐藏文本框,将输入的文本通过canvas绘制出来即可。

点击某个文字进行编辑时,需要获取到该文字、及对应的样式,如字号、字体、行高、颜色等,然后在canvas画布上隐藏该文字,将文本框定位到该位置,设置文字内容,并且也设置对应的样式,尽量看起来像是原地编辑,而不是另外创建了一个输入框来进行编辑:

// 显示文本编辑框
showTextEdit() {if (!this.editable) {// 输入框不存在,创建一个this.crateTextInputEl();} else {// 已创建则让它显示this.editable.style.display = "block";}// 更新文本框样式this.updateTextInputStyle();// 聚焦this.editable.focus();
}// 创建文本输入框元素
crateTextInputEl() {this.editable = document.createElement("textarea");// 设置样式,让我们看不见Object.assign(this.editable.style, {position: "fixed",display: "block",minHeight: "1em",backfaceVisibility: "hidden",margin: 0,padding: 0,border: 0,outline: 0,resize: "none",background: "transparent",overflow: "hidden",whiteSpace: "pre",});// 监听事件this.editable.addEventListener("input", this.onTextInput);this.editable.addEventListener("blur", this.onTextBlur);// 插入到页面document.body.appendChild(this.editable);
}

通过input事件来监听输入,获取到输入的文本,计算文本的宽高,文本是可以换行的,所以整体的宽度为最长那行文字的宽度,宽度的计算通过创建一个div元素将文本塞进去,设置样式,然后使用getBoundingClientRect获取div的宽度,也就是文字的宽度:

// 文本输入事件
onTextInput() {// 当前新建或编辑的文本元素let activeElement = this.app.elements.activeElement;// 实时更新文本activeElement.text = this.editable.value;// 计算文本的宽高let { width, height } = getTextElementSize(activeElement);// 更新文本元素的宽高activeElement.width = width;activeElement.height = height;// 根据当前文本元素更新输入框的样式this.updateTextInputStyle();
}

实时更新文本元素的信息,用于后续通过canvas进行渲染,接下来看一下getTextElementSize的实现:

// 计算一个文本元素的宽高
export const getTextElementSize = (element) => {let { text, style } = element;// 取出文字和样式数据let width = getWrapTextActWidth(element);// 获取文本的最大宽度const lines = Math.max(splitTextLines(text).length, 1);// 文本的行数let lineHeight = style.fontSize * style.lineHeightRatio;// 计算出行高let height = lines * lineHeight;// 行数乘行高计算出文本整体高度return {width,height,};
};

文本的宽和高分成了两部分进行计算,高度直接是行数和行高相乘得到,看一下计算宽度的逻辑:

// 计算换行文本的实际宽度
export const getWrapTextActWidth = (element) => {let { text } = element;let textArr = splitTextLines(text);// 将文字切割成行数组let maxWidth = -Infinity;// 遍历每行计算宽度textArr.forEach((textRow) => {// 计算某行文字的宽度let width = getTextActWidth(textRow, element.style);if (width > maxWidth) {maxWidth = width;}});return maxWidth;
};// 文本切割成行
export const splitTextLines = (text) => {return text.replace(/\r\n?/g, "\n").split("\n");
};// 计算文本的实际渲染宽度
let textCheckEl = null;
export const getTextActWidth = (text, style) => {if (!textCheckEl) {// 创建一个div元素textCheckEl = document.createElement("div");textCheckEl.style.position = "fixed";textCheckEl.style.left = "-99999px";document.body.appendChild(textCheckEl);}let { fontSize, fontFamily } = style;// 设置文本内容、字号、字体textCheckEl.innerText = text;textCheckEl.style.fontSize = fontSize + "px";textCheckEl.style.fontFamily = fontFamily;// 通过getBoundingClientRect获取div的宽度let { width } = textCheckEl.getBoundingClientRect();return width;
};

文字的宽高也计算出来了,最后我们来看一下更新文本框的方法:

// 根据当前文字元素的样式更新文本输入框的样式
updateTextInputStyle() {let activeElement = this.app.elements.activeElement;let { x, y, width, height, style, text, rotate } = activeElement;// 设置文本内容this.editable.value = text;let styles = {font: getFontString(fontSize, style.fontFamily),// 设置字号及字体lineHeight: `${fontSize * style.lineHeightRatio}px`,// 设置行高left: `${x}px`,// 定位top: `${y}px`,color: style.fillStyle,// 设置颜色width: Math.max(width, 100) + "px",// 设置为文本的宽高height: height * state.scale + "px",transform: `rotate(${rotate}deg)`,// 文本元素旋转了,输入框也需要旋转opacity: style.globalAlpha,// 设置透明度};Object.assign(this.editable.style, styles);
}// 拼接文字字体字号字符串
export const getFontString = (fontSize, fontFamily) => {return `${fontSize}px ${fontFamily}`;
};

伸缩图片和文字

图片和文字都属于是宽高比例固定的元素,那么伸缩时就需要保持原比例,上一篇文章里介绍的伸缩方法是不能保持比例的,所以需要进行一定修改,距离上一篇已经过了这么久的时间,大家肯定都忘了伸缩的逻辑,可以先复习一下:2.第二步,修理它(往下滚动到【伸缩矩形】小小节)。

总结来说就是一个矩形的绘制需要x,y,width,height,rotate五个属性,伸缩不会影响旋转,所以计算伸缩后的矩形也就是计算新的x,y,width,height值,这里也简单列一下步骤:

1.根据矩形的中心点计算鼠标拖动的角的对角点坐标,比如我们拖动的是矩形的右下角,那么对角点就是左上角;

2.根据鼠标拖动到的实时位置结合对角点坐标,计算出新矩形的中心点坐标;

3.获取鼠标实时坐标经新的中心点反向旋转原始矩形的旋转角度后的坐标;

4.知道了未旋转时的右下角坐标,以及新的中心点坐标,那么新矩形的左上角坐标、宽、高都可以轻松计算出来;

接下来看一下如何按比例伸缩。

黑色的为原始矩形,绿色的为鼠标按住右下角实时拖动后的矩形,这个是没有保持原宽高比的,拖动到这个位置如果要保持宽高比应该为红色所示的矩形。

根据之前的逻辑,我们是可以计算出绿色矩形未旋转前的位置和宽高的,那么新的比例也可以计算出来,再根据原始矩形的宽高比例,我们可以计算出红色矩形未旋转前的位置和宽高:

如图所示,我们先计算出实时拖动后的绿色矩形未旋转时的位置和宽高newRect,假设原始矩形的宽高比为2,新矩形的宽高比为1,新的小于旧的,那么如果要比例相同,需要调整新矩形的高度,反之调整新矩形的宽度,计算的等式为:

newRect.width / newRect.height = originRect.width / originRect.height

那么我们就可以计算出红色矩形的右下角坐标:

let originRatio = originRect.width / originRect.height;// 原始矩形的宽高比
let newRatio = newRect.width / newRect.height;// 新矩形的宽高比
let x1, y1
if (newRatio < originRatio) {// 新矩形的比例小于原始矩形的比例,宽度不变,调整新矩形的高度x1 = newRect.x + newRect.width;y1 = newRect.y + newRect.width / originRatio;
} else if (newRatio > originRatio) {// 新矩形的比例大于原始矩形的比例,高度不变,调整新矩形的宽度x1 = newRect.x + originRatio * newRect.height;y1 = newRect.y + newRect.height;
}

红色矩形未旋转时的右下角坐标计算出来了,那么我们要把它以新中心点旋转原始矩形的角度:

到这一步,你是不是会发现好像似曾相识,没错,忽略绿色的矩形,想象成我们鼠标是拖动到了红色矩形右下角的位置,那么只要再从头进行一下最开始提到的4个步骤就可以计算出红色矩形未旋转前的位置和宽高,也就是按比例伸缩后的矩形的位置和宽高。详细代码请参考:https://github.com/wanglin2/tiny_whiteboard/blob/main/tiny-whiteboard/src/elements/DragElement.js#L280。

对于图片的话上面的步骤就足够了,因为图片的大小就是宽和高,但是对于文字来说,它的大小是字号,所以我们还得把计算出的宽高转换成字号,笔者的做法是:

新字号 = 新高度 / 行数 / 行高比例

代码如下:

let fontSize = Math.floor(height / splitTextLines(text).length / style.lineHeightRatio
);
this.style.fontSize = fontSize;

比如一段文字有2行,行高为1.5,计算出的新高度为60,那么不考虑行高计算出的字号就是30,考虑行高,显然字号会小于30x * 1.5 = 30,所以还需要再除以行高比。

缩放多边形或折线

我们的伸缩操作计算出的是一个新矩形的位置和宽高,对于由多个点构成的元素(比如多边形、折线、手绘线)来说这个矩形就是它们的最小包围框:

所以我们只要能根据新的宽高缩放元素的每个点就可以了:

// 更新元素包围框
updateRect(x, y, width, height) {let { startWidth, startHeight, startPointArr } = this;// 元素初始的包围框宽高、点位数组// 计算新宽高相对于原始宽高的缩放比例let scaleX = width / startWidth;let scaleY = height / startHeight;// 元素的所有点位都进行同步缩放this.pointArr = startPointArr.map((point) => {let nx = point[0] * scaleX;let ny = point[1] * scaleY;return [nx, ny];});// 更新元素包围框this.updatePos(x, y);this.updateSize(width, height);return this;
}

可以看到元素飞走了,其实缩放的大小是正确的,我们把框拖过去进行一下对比:

所以只是发生了位移,这个位移是怎么发生的呢,其实很明显,比如一个线段的两个点的坐标为(1,1)(1,3),放大2倍后变成了(2,2)(2,6),很明显线段被是放大拉长了,但是同样也很明显位置变了:

解决方法是我们可以计算出元素新的包围框,然后计算出和原来包围框的距离,最后缩放后的所有点位都往回偏移这个距离即可:

// 更新元素包围框
updateRect(x, y, width, height) {// ...// 缩放后会发生偏移,所以计算一下元素的新包围框和原来包围框的差距,然后整体往回偏移let rect = getBoundingRect(this.pointArr);let offsetX = rect.x - x;let offsetY = rect.y - y;this.pointArr = this.pointArr.map((point) => {return [point[0] - offsetX, point[1] - offsetY, ...point.slice(2)];});this.updatePos(x, y);this.updateSize(width, height);return this;
}

总结

到这里这个小项目的一些核心实现也就都介绍完了,当然这个项目还是存在很大的不足,比如:

1.元素的点击检测完全是依赖于点到点的距离或点到直线的距离,这就导致不支持像贝塞尔曲线或是椭圆这样的元素,因为无法知道曲线上每个点的坐标,自然也无法比较;

2.多个元素同时旋转目前也没有很好的解决;

3.不支持镜像伸缩;

项目地址:https://github.com/wanglin2/tiny_whiteboard,欢迎给个star~

我做了一个在线白板(二)相关推荐

  1. Java程序员用下班时间给学弟做了一个在线考试系统

    文章目录 前言 一.框架介绍 1.1.Spring 1.2.SpringMVC 1.3.MyBatis 1.4.SSM整合 二.开发环境 三.系统功能 3.1 考试界面登陆 3.2 选择试题 3.3 ...

  2. Asp.Net Core在线生成二维码

    前言: 原先用zxing Code写过基于Winfrom的批量生成二维码工具,以及单个生成二维码工具:批量生成二维码Gihub源代码 今天尝试用QRCoder 加 Asp.Net Core 写了一个在 ...

  3. 调用第三方api在线生成二维码

    我写过一篇文章是java代码后端自己传入链接由本地代码生成二维码图片并保存在本地,今天我们实现调用第三方在线生成二维码 首先我们找到一个在线生成二维码的api接口,因为这样的api接口有许多,这里我提 ...

  4. 一个在线显示doc文本的实例

    <span style="font-family: Arial, Helvetica, sans-serif; background-color: rgb(255, 255, 255) ...

  5. servlet+ajax在线生成二维码

    前几天博主写了一篇在线生成二维码的文章,因为是在文件上传案例中的基础上写的Demo,所以使用的是Spring+springMVC框架写的.有小朋友说搭建框架太麻烦,所以博主特意把代码摘出来,使用最原始 ...

  6. 【教程】什么是在线报修二维码?如何快速创建?

    报修就是把设施.设备等损坏情况报告有关部门,要求修理,而本平台的报修码就是由本系统生成一个二维码,客户通过手机扫码(无需安装特定APP),简单填写基本信息,后台会即时发出短信或微信通知到您手机上,您再 ...

  7. 怎么将图文、视频生成一个二维码?多内容在线生成二维码的方法

    现在很多幼儿园在招生.宣传时经常会使用二维码的方式来让家长快速了解幼儿园的信息,那么大多采用的方式也是通过文字.图片.视频等类型的内容来做宣传推广,那么如何将这些类型的内容同时放到一个二维码中呢?怎么 ...

  8. 用python制作二维码_用python做一个可视化生成二维码的工具

    用python做一个可视化生成二维码的工具 环境 pip install gooey pip install MyQR 源代码 from gooey import GooeyParser,Gooey ...

  9. 百度前端技术学院--零基础--第二天 给自己做一个在线简历吧

    百度前端技术学院–零基础–第二天 给自己做一个在线简历吧 课程目标 通过简单的实践,更加清楚地了解HTML是什么,HTML5是什么.学习基本的HTML标签,理解HTML语义化概念 任务描述 用code ...

最新文章

  1. 从BloomFilter到Counter BloomFilter
  2. 对象是空的吗? [重复]
  3. 【Netty】使用 Netty 开发 HTTP 服务器 ( HTTP 请求过滤 )
  4. Java中常用的6种排序算法详细分解
  5. 几个RTP工具的使用 rtptools_1.18【原创】
  6. nagios监控mysql服务_nagios监控mysql服务
  7. Android开发笔记(二十九)使用SharedPreferences存取数据
  8. 计算机科学常见工具书清单、项目开发清单
  9. CodeMix使用的语言和框架(六):HTML5
  10. Eclipse中jsp文件ISO-8859-1编码转换为UTF-8或者GBK方法
  11. mysql修改校对集_mysql数据库的基本操作(增删改查、字符集、校对集)
  12. wordwrap() 函数
  13. 开源的 CMD 配色工具:ColorTool
  14. Excel无法打开文件xxx.xlsx,因为文件格式或文件扩展名无效。请确定文件未损坏,并且文件扩展名与文件的格式匹配
  15. Mac 清除dns缓存
  16. WhatsAPP营销详细攻略,带你一镜到底的了解WhatsAPP营销
  17. Tensorflow变量作用域及变量初始化
  18. java判断日期前后_Java丨时间判断谁前谁后
  19. SQL数据库——分组查询GROUP BY
  20. 如何交换数据:10 分钟为 MQL5 创建 DLL

热门文章

  1. java计算机毕业设计的工资管理系统源码+mysql数据库+系统+lw文档+部署
  2. QML基础以及Qt Quick应用
  3. [ProblemSolving]关于utorrent未响应的解决方法
  4. 云转码系统搭配服务器,服务器做云转码
  5. SmartNest切割套料编程软件
  6. 推广创意的关键,优化主图、优化标题、优化定价、优化DSR评分,以及如何选词
  7. php 压缩gif 不动,调整GIF动画文件的大小而不会破坏动画
  8. 51nod 1533 CF538F
  9. [CF538F]A Heap of Heaps(主席树)
  10. 我为什么把think in java 读了10遍