我做了一個線上白板(二)

語言: CN / TW / HK

上一篇我做了一個線上白板!給大家介紹了一下矩形的繪製、選中、拖動、旋轉、伸縮,以及放大縮小、網格模式、匯出圖片等功能,本文繼續為各位介紹一下箭頭的繪製、自由書寫、文字的繪製,以及如何按比例縮放文字圖片等這些需要固定長寬比例的圖形、如何縮放自由書寫折線這些由多個點構成的元素。

箭頭的繪製

箭頭其實就是一根線段,只是一端存在兩根成一定角度的小線段,給定兩個端點的座標即可繪製一條線段,關鍵是如何計算出另外兩根小線段的座標,箭頭線段和線段的夾角我們設定為 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~