乐趣区

关于前端:我做了一个在线白板二

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

箭头的绘制

箭头其实就是一根线段,只是一端存在两根成肯定角度的小线段,给定两个端点的坐标即可绘制一条线段,要害是如何计算出另外两根小线段的坐标,箭头线段和线段的夹角咱们设置为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~

退出移动版