自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

圖形編輯器:基于 Canvas 的所見即所得文本編輯

開發(fā) 前端
文本編輯,可以看作是對一個個矩形塊進行編排,我們計算好每個字形 glyph 的包圍盒,編排成一行或多行的文字。

大家好,我是前端西瓜哥。

前段時間給我的 suika 圖形編輯器重寫了文本編輯功能,基本支持了所見即所地編輯文本了,這篇文章總結一下實現(xiàn)這個功能需要做的一些工作。

suika 圖形編輯器 github 地址:

https://github.com/F-star/suika

線上體驗:

https://blog.fstars.wang/app/suika/

簡單演示,使用的字體是 “得意黑”。

作為一款圖形編輯器,自然是少不了文本的輸入和編輯功能。

為了提高性能,圖形編輯器通常使用 canvas 實現(xiàn),但文本編輯如果要用 canvas 實現(xiàn)是不小的工作量。

對此,一種簡單的方式是進入編輯狀態(tài)時隱藏原來的文本圖形,然后在其正上方通過絕對定位放上一個 input 或 texture,如果還想支持富文本,也可以找一個富文本編輯器掛載在 div 容器元素上,再把這個 div 做絕對定位。

這種借助 html 元素的方式,在簡單場景倒是沒什么問題。

但它有如下缺陷:

  • 無法保持文本圖形原來所在的層級,只能提升到頂層。這樣就不能所見即所得的看文本圖形和它上方圖形效果疊加的效果,比如沒法實時觀察并調(diào)整毛玻璃濾鏡下文字渲染效果。
  • canvas 和 html 渲染效果不一致。圖形編輯器的文本渲染可能做了一些加強,比如右上小標、文字使用漸變填充、圖片填充、虛線描邊等各種增強功能。這是基于 html 的文本編輯器是無法模擬的,且不同瀏覽器的 html 渲染也有微妙不同。

出于精益求精的精神,我們嘗試在圖形編輯器下,做一個基于 canvas 2d 的簡單文本編輯器。

文本圖形

文本圖形實體。

class TextGraphis {
  attrs: {
    content: string
  }
}

這里我們就不這么復雜,用純文本,提供一個 content 屬性,保存字符串形式的文本內(nèi)容。

字形 box

然后我們需要計算 content 中每個字形(glyph)的寬高,之后需要用它們來定位文字游標的位置。

interface IGlyph {
  position: IPoint;
  width: number;
  height: number;
  // box 頂部到基線的距離
  fontBoundingBoxAscent: number;
}

注意這里說的不是每個字符(char),這是因為數(shù)據(jù)上的多個字符的表達,在渲染時可能會合并為一個。

JavaScript 支持 Unicode,一個 Unicode 字符可能會占用 2 個或更多碼點 的空間,比如 "??"。

"??".length 的返回值是 2,雖然看起來只有一個字符。?? 其實等價于 \uD842\uDFB7。

一個 Unicode 可以簡單和一個 glyph 劃等號(暫不考慮連字 ligature)。

emoji 也是 Unicode,對于 canvas 2d,如果字體的字符集中有對應的 emoji,會將這個 emoji 渲染出來,否則用操作系統(tǒng)提供的 emoji 進行渲染。

我們沒法用字符串的 length 屬性來判斷 glyph 的數(shù)量。

我們可以用 for...of 來拿到每個 Unicode 字符,然后用 ctx.measureText() 方法拿到每個 glyph 的 box 信息。

const glyphs = [];

for (const c of content) {
  const textMetrics = ctx.measureText(c);
  glyphs.push({
    position: { ...position },
    width: textMetrics.width,
    height:
      textMetrics.fontBoundingBoxAscent + textMetrics.fontBoundingBoxDescent,
    fontBoundingBoxAscent: textMetrics.fontBoundingBoxAscent,
  });
  position.x += textMetrics.width;
}

fontBoundingBoxAscent 為 box 頂部距離文本基線(baseline)的距離,fontBoundingBoxDescent 為 box 底部距離文本基線的距離,二者相加即為 box 的高度。

fontBoundingBoxAscent 屬性我們也保存下來,canvas 2d 渲染是基于基線的,我們需要這個值做垂直位移。

position 記錄了 glyph 的左上角到文本起點位置的距離。

比較遺憾的是,canvas 2d 拿不到字體的 kerning 字距表。

我也有想到一個辦法,比較曲折,就是分別單獨計算兩個字符的各自的寬度,然后再計算兩個字符拼接后的寬度。

求出這兩個寬度的差值,便是這兩個字符的字距了。

這里可以優(yōu)化一下,相同的 glyph 沒有必要重新計算,可以用一個 map 緩存起來。

Range

我們拿到了字符串中每個 glyph 的幾何信息,就能正確的位置渲染 cursor 光標了。

首先我們定義一個 RangeManager 類,來 維護文本中的光標線和選中信息。

class RangeManager {
  private range = { start: 0, end: 0 };
  
  setRange(range) {
    this.range = {
      start: range.start,
      end: range.end,
    };
  }

  getRange() {
    return { ...this.range };
  }
}

成員屬性 range 的 start 表示被 編輯文本上選區(qū)的起始索引值,end 表示選區(qū)的結束位置。

當 start 和 end 值相等時,在最上層會顯示一個閃爍的豎直光標,位置為對應 glyph 的左側。

閃爍動畫可能會導致渲染不斷被觸發(fā),需要做一些優(yōu)化,目前 suika 圖形編輯器上的文字光標目前并不會閃爍。

另外我們還有一個方案,就是像 canvas editor 一樣,用一個帶動畫的 div 模擬,反正它都是要放在最頂部的。

如果 start 和 end 的值不同,則是將這個區(qū)間內(nèi)繪制一個半透明的矩形,同樣是放到最頂層。

start 的值并不要求一定小于 end ,是可以大于 end 的。后面我們用光標選中字符時需要用到這個特性。

但有時候我們希望拿到基于左右位置拿到兩個索引值,用于正確切割出 range 左右兩側的子字符串。

所以我們要加個 getSortedRange 方法。

class RangeManager {
  // ...

  getSortedRange() {
    const rangeLeft = Math.min(this.range.start, this.range.end);
    const rangeRight = Math.max(this.range.start, this.range.end);
    return { rangeLeft, rangeRight };
  }
}

光標位置計算

如果 range.start 和 range.end 相等,我們會渲染一條光標線,為此我們需要計算這條線的 top 和 bottom 位置,見下圖。

做法是拿到正在被編輯的文本圖形實體的字形信息,即前面提到的字形 box 數(shù)組。

const glyphInfos = textGraphics.getGlyphs();

根據(jù) range.start 的索引值找到匹配的 glyph 項,對應的 position 是相對文本實體的本地坐標,我們需要應用文本的矩陣得到場景坐標。

又因為我們需要把光標渲染在最頂層,也就是視口坐標系上,所以我們又要再做一個場景坐標到視口坐標的轉換。自此 top 計算出來了。

const startGlyphInfo = glyphInfos[range.start]
// 文本實體上的光標位置
const cursorPosInText = startGlyphInfo.position;
const textMatrix = textGraphics.getWorldTransform();
// 場景坐標
const top = applyMatrix(textMatrix, cursorPosInText);
// 畫布坐標
const topInViewport = this.editor.toViewportPt(top.x, top.y);

bottom 位置同理,加上高度再進行同樣的矩陣變換。

const bottom = applyMatrix(textMatrix, {
  x: cursorPosInText.x,
  y: cursorPosInText.y + contentHeight,
});
const bottomInViewport = this.editor.toViewportPt(bottom.x, bottom.y);

如果 range.start 和 range.end 不相等,則渲染為一個半透明的矩形,當然因為矩陣變換的緣故,也可能會變成一個平行四邊形。

我們要計算這個平行四邊形的 4 個點,前面我們已經(jīng)算出 top 和 bottom 這兩個點了,我們再計算一個 right,見下圖。

計算過程也大同小異,right 對應 range.end 索引位置的 glyph。

let rightInViewport = null;

if (range.end !== range.start) {
  const endGlyphInfo = glyphInfos[range.end]
  const endPosInText = endGlyphInfo.position;
  const right = applyMatrix(textMatrix, endPosInText);
  rightInViewport = this.editor.toViewportPt(right.x, right.y);
}

top、bottom、right 這三個點,再基于平行四邊形(矩形做了矩陣變換)的特征,可以算出最后一個點,然后就可以進行渲染了。

具體怎么渲染就不展開了,不同渲染庫寫法不一樣。

輸入法定位問題

下面我們看看,怎么通過鍵盤輸入文本。

既然都做所見即所得了,看起來我們不需要用 input、textarea 這些 dom 元素了,直接監(jiān)聽 keydown 事件應該就好了。

但實際上它是有局限性的,它只能用在不需要輸入法的場景,比如只輸入英文。如果你用輸入法輸入中文,因為沒有 focus 一個輸入框中,所以不會有輸入法的浮窗出現(xiàn)。

所以我們還是要 提供一個文本輸入元素并讓它保持 focus 狀態(tài)。

這里我選擇用 input 元素,因為我的文本編輯首先還是比較簡單的。

input 元素雖然必須要在,但讓它看起來不在就行了。

我們把它的不透明度設置為 0,然后 z-index 設置為 -1,寬度也改成 1px(保證 input 下的光標保持在 input 框中的起始位置)。

const defaultInputStyle = {
  opacity: 0,
  zIndex: '-1',
  width: '1px',

  margin: 0,
  padding: 0,
  border: 0,
  outline: 0,

  position: 'fixed',
}

fixed 定位

為了讓輸入法彈窗定位到正確的位置,我們需要 給 input 設置 fixed 定位。

我們確保 input 的左下角對齊前面計算的那個 bottomInViewport 即可。

具體計算為:left 為前面計算的那個 bottomInViewport 的 x,再加上 canvas 相對頁面左測的偏移值;top 為 bottomInViewport 的 y 值減去文本的字體大小,再加上 canvas 相對頁面頂部的偏移值。

const styles = {
  left: bottomInViewport.x + canvasOffsetX + 'px',
  top: bottomInViewport.y - inputDomHeight + canvasOffsetY + 'px',
  height: `${inputDomHeight}px`,
  fontSize: `${inputDomHeight}px`,
}
Object.assign(inputDom.style, styles);

這時候有的同學可能會問了,問我怎么不用 absolute 定位,相對 canvas 的容器元素。說實話我是用過的,然后發(fā)現(xiàn)一個 input 元素的特性。

就是如果一個 input 元素在一個 div 下,但是呢,它跑到 div 的顯示區(qū)域外,看不到它。

當這個 input 是 focus 狀態(tài)時,那瀏覽器會強行修改 div 的 offset 讓 input 可以被看到,結果是突然 div 上出現(xiàn)了一大塊空白區(qū)域,主體內(nèi)容被擠不見了。

換成 fixed 就不會有這個問題,輸入法彈窗會移動頁面外,但不會影響頁面的布局。

輸入文本

當文本編輯被激活時,這個 input 會設置為 focus 狀態(tài)。

此時我們監(jiān)聽 input 元素的 input 事件,將用戶輸入的內(nèi)容更新到文本實體 textGraphics 上,并修正 range。

我們可以通過 input 事件對象的 isComposing 是否為 true 判斷用戶是否在使用輸入法。

簡單輸入

首先是比較簡單的場景,不輸入中文的情況。

inputDom.addEventListener('input', (e) => {
    
  // ...
    
  // Not IME input, directly add to textGraphics
  if (!e.isComposing && e.data) {
    const { rangeLeft, rangeRight } = rangeManager.getSortedRange();

    const content = textGraphics.attrs.content;
    const newContent =
      sliceContent(content, 0, rangeLeft) +
      e.data +
      sliceContent(content, rangeRight);

    // 更新文本實體的 content 和 size
    TextEditor.updateTextContentAndResize(textGraphics, newContent);
    const dataLength = getContentLength(e.data);
    // 更新 range 的狀態(tài),往右邊移動 e.date 的長度
    this.rangeManager.setRange({
      start: rangeLeft + dataLength,
      end: rangeLeft + dataLength,
    });
  }
}

e.isComposing 為 false 表示沒有在使用輸入法,然后 e.data 保存的是用戶輸入的內(nèi)容。

需要注意,e.data 可能存在為 null 的情況,比如 backspace 刪除字符,粘貼空內(nèi)容,這種情況需要過濾掉。

我們在 content 字符串 range 區(qū)域的字符串丟棄,然后將 e.data 的字符串拼接進去,得到 newContent,并對文本實體 textGraphics 進行更新,最后更新 range 的狀態(tài),往右邊移動 e.date 的長度。

因為 unicode 的存在,我們不能用字符串的 length 屬性了,那都是騙人的,要改用 for...of 去實現(xiàn)一些字符串方法。

// 獲取字符串的長度
const getContentLength = (content) => {
  let count = 0;
  for (const _ of content) {
    count++;
  }
  return count;
};

// 字符串截斷
const sliceContent = (content, start, end) => {
  let res = '';
  let i = 0;
  for (const char of content) {
    if (end !== undefined && i >= end) {
      break;
    }
    if (i >= start) {
      res += char;
    }
    i++;
  }
  return res;
};

通過輸入法輸入

如果使用了輸入法,情況會復雜一點。

這種場景下,e.isComposing 為 true,e.data 則是用戶正在輸入的內(nèi)容。

比如我想輸入 “你好”,通過拼音輸入法進行完整的拼音輸入,最后按下空格。這個過程中 input 事件會多次觸發(fā),e.data 依次為:

n
ni
ni h
ni ha
ni hao
你好

所以我們不能將每次 input 事件的 e.data 直接拼接到 content 上。

我們需要在  e.isComposing 第一次為 true 時,保存好 range 兩邊的字符串內(nèi)容,以及 e.data 的內(nèi)容。

之后就將開始時兩邊的字符串和  e.data 拼接即可。

inputDom.addEventListener('input', (e) => {
  let composingText = '';
  let leftContentWhenComposing = '';
  let rightContentWhenComposing = '';
    
  if (e.isComposing) {
    if (!composingText) {
      // 輸入法第一次輸入內(nèi)容,保存好 range 兩邊的內(nèi)容
      const { rangeLeft, rangeRight } = rangeManager.getSortedRange();
      const content = textGraphics.attrs.content;
      leftContentWhenComposing = sliceContent(content, 0, rangeLeft);
      rightContentWhenComposing = sliceContent(content, rangeRight);
    }
    composingText = e.data ?? '';
  } else {
    // 重置
    composingText = '';
    leftContentWhenComposing = '';
    rightContentWhenComposing = '';
  }
  
  // ...
  
  if (e.isComposing) {
    const newContent =
      leftContentWhenComposing + composingText + rightContentWhenComposing;
    
    TextEditor.updateTextContentAndResize(textGraphics, newContent);
   // 更新 range
    const newRangeStart =
      getContentLength(leftContentWhenComposing) +
      getContentLength(composingText);
    rangeManager.setRange({
      start: newRangeStart,
      end: newRangeStart,
    });
  }
})

各種快捷鍵行為

然后是監(jiān)聽 input 的 keydown 事件,實現(xiàn)各種編輯操作。

inputDom.addEventListener('keydown', (e) => {
  // ...
})

Esc,退出文本編輯模式。

if (e.key === 'Escape') {
  this.inactive();
}

左方向鍵,如果光標狀態(tài),range 左移動一位;如果選擇狀態(tài),range 置為 rangeLeft。

如果還按住 Shift 鍵,只對 range.end 減 1。注意 range 的索引值不要越界。

if (e.key === 'ArrowLeft') {
  if (e.shiftKey) {
    this.rangeManager.moveRangeEnd(-1);
  } else {
    this.rangeManager.moveLeft();
  }
}

右方向鍵,同理。

Backspace,如果是光標狀態(tài),往左刪掉一個字符,range 左移一位;如果是選中多個 字符狀態(tài),刪掉這些字符,range 設置為 rangeLeft 。

Delete,類似 Backspace,但是是往右側刪除。

if (e.key === 'Backspace' || e.key === 'Delete') {
  let { rangeLeft, rangeRight } = this.rangeManager.getSortedRange();
  const isSelected = rangeLeft !== rangeRight;

  if (!isSelected) {
    rangeLeft = e.key === 'Backspace' ? rangeLeft - 1 : rangeLeft;
    rangeRight = e.key === 'Backspace' ? rangeRight : rangeRight + 1;
  }

  const content = textGraphics.attrs.content;
  const leftContent = sliceContent(content, 0, rangeLeft);
  const rightContent = sliceContent(content, rangeRight);
  const newContent = leftContent + rightContent;
  TextEditor.updateTextContentAndResize(textGraphics, newContent);

  if (isSelected) {
    rangeManager.setRange({
      start: rangeLeft,
      end: rangeLeft,
    });
  } else if (e.key === 'Backspace') {
    rangeManager.moveLeft();
  }
}

Command / Ctrl + A,全選,將 range 區(qū)間設置為 content 的完全的區(qū)間。

this.rangeManager.setRange({
  start: 0,
  end: this.textGraphics.getContentLength(),
});

Command / Ctrl + C,復制。將 range 區(qū)間的文本寫入到剪貼板

Command / Ctrl + X,剪切。將 range 區(qū)間的文本寫入到剪貼板,然后將 range 的內(nèi)容丟棄。

鼠標選中

下面看看怎么通過鼠標來進行文本的選擇。

我們需要綁定 canvas 元素的鼠標事件,這個我原本就封裝好了,其實也就是 canvas 上的鼠標事件對象拿到視口坐標,通過矩陣轉換成場景坐標。

點擊鼠標時,我們拿到這個場景坐標,然后我們給這個場景坐標做文本實體矩陣的 逆矩陣運算,得到在文本實體的本地坐標。

然后就是 glyph 數(shù)組的 x 和鼠標位置的位置,找到被點中的 glyph。

class TextGraphics {
  // ...

  getCursorIndex(point) {
    // 逆矩陣得到本地坐標
    point = applyInverseMatrix(this.attrs.transform, point);
    const glyphs = this.getGlyphs();

    // binary search, find the nearest but not greater than point.x glyph index
    let left = 0;
    let right = glyphs.length - 1;
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      const glyph = glyphs[mid];
      if (point.x < glyph.position.x) {
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    }
    if (left === 0) return 0;
    if (left >= glyphs.length) return glyphs.length - 1;

    if (
      glyphs[left].position.x - point.x >
      point.x - glyphs[right].position.x
    ) {
      return right;
    }
    return left;
  }
}

這里用了二分查找,效率很高。

找到 glyph 后,我們還要看一下鼠標位置靠近 glyph 的左半部分還是右半部分,設置為更靠近的一邊的索引值。

然后將這個索引值設置為 range 即可。

const cursorIndex = textGraphics.getCursorIndex(mousePt);
this.rangeManager.setRange({
  start: cursorIndex,
  end: cursorIndex,
});

然后此時拖拽鼠標,我們使用同樣的方式,計算出索引值,設置給 range.end。

結尾

文本編輯,可以看作是對一個個矩形塊進行編排,我們計算好每個字形 glyph 的包圍盒,編排成一行或多行的文字。

然后引入 range  的概念,用來表達目前光標在哪里,或哪些矩形塊被選中。

最后再通過監(jiān)聽鍵盤事件和 mouse 事件更新 range,并通過 input事件獲取用戶輸入內(nèi)容,直接更新到文本圖形上。

這個文本編輯器還是比較簡單,但基本的核心已經(jīng)具備,希望對你有幫助。

責任編輯:姜華 來源: 前端西瓜哥
相關推薦

2021-02-27 21:20:31

工具編輯器CKEditor

2022-06-13 08:24:45

Typora編輯器

2010-02-04 11:13:49

WEB編輯器

2023-04-17 11:03:52

富文本編輯器MTE

2013-10-23 11:06:54

HTML5開發(fā)框架

2016-09-23 20:30:54

Javascriptuiwebview富文本編輯器

2010-03-24 09:20:07

CentOS vi編輯

2018-09-07 17:45:19

華為云

2020-12-23 22:25:11

Vi文本編輯器Unix

2021-01-07 11:00:59

Sed文本編輯器Linux

2022-05-13 15:32:11

GNOME文本編輯器

2020-12-13 12:14:45

H5開發(fā)H5-Dooring

2011-11-16 17:34:57

編輯器

2020-08-20 15:16:27

微軟開源Windows

2009-12-09 10:27:03

VS 2005文本編輯

2012-09-29 11:38:27

編程工具文本編輯器編程

2022-01-18 09:35:36

GNOME編輯器Linux

2013-11-18 10:08:56

工具免費編程工具

2011-05-11 10:27:42

文本編輯器

2009-02-25 10:55:29

FCKeditor控件JSP
點贊
收藏

51CTO技術棧公眾號