canvas封装Text2D对象

932 阅读7分钟

前言

源码

github.com/buglas/canv…

学习目标

  • 创建二维文字对象

知识点

  • ctx.fillText()
  • ctx.strokeText()

1-文字的样式对象

首先咱们先看一下样式对象的架构思路。

image-20230620164128644

最底层的是BasicStyle,再上面的StandStyle和TextStyle依次成继承关系。

  • BasicStyle 具备投影、透明度、合成、裁剪相关的属性,图案对象便是使用的此样式。
  • StandStyle 具备描边相关的样式,适用于路径对象。
  • TextStyle 具备文字相关的样式,适用于文字对象。

1-1-BasicStyle

BasicStyle我们之前写过,其整体代码如下。

  • /src/lmm/style/BasicStyle.ts
/* 参数类型 */
export type BasicStyleType = {
    // 投影相关
    shadowColor?: string | undefined
    shadowBlur?: number
    shadowOffsetX?: number
    shadowOffsetY?: number

    // 全局透明度
    globalAlpha?: number | undefined

    //合成相关
    globalCompositeOperation?: GlobalCompositeOperation | undefined

    // 裁剪
    clip?: boolean
}

class BasicStyle {
    // 投影相关
    shadowColor: string | undefined
    shadowBlur = 0
    shadowOffsetX = 0
    shadowOffsetY = 0

    // 全局透明度
    globalAlpha: number | undefined

    //合成相关
    globalCompositeOperation: GlobalCompositeOperation | undefined

    // 裁剪
    clip = false

    constructor(attr: BasicStyleType = {}) {
        this.setOption(attr)
    }

    /* 设置样式 */
    setOption(attr: BasicStyleType = {}) {
        Object.assign(this, attr)
    }

    /* 应用样式 */
    apply(ctx: CanvasRenderingContext2D) {
        const {
            globalAlpha,
            globalCompositeOperation,
            shadowColor,
            shadowBlur,
            shadowOffsetX,
            shadowOffsetY,
            clip,
        } = this

        /* 投影 */
        if (shadowColor) {
            ctx.shadowColor = shadowColor
            ctx.shadowBlur = shadowBlur
            ctx.shadowOffsetX = shadowOffsetX
            ctx.shadowOffsetY = shadowOffsetY
        }

        /* 全局合成 */
        globalCompositeOperation &&
            (ctx.globalCompositeOperation = globalCompositeOperation)

        /*透明度合成*/
        globalAlpha !== undefined && (ctx.globalAlpha = globalAlpha)

        /* 裁剪 */
        clip && ctx.clip()
    }
}
export { BasicStyle }

1-2-StandStyle

StandStyle 的整体代码如下。

  • /src/lmm/style/StandStyle.ts
import { BasicStyle, BasicStyleType } from './BasicStyle'

/* 绘图顺序 */
type OrderType = 0 | 1

/* 绘图方法顺序 */
type MethodsType = ['fill', 'stroke'] | ['stroke', 'fill']

export type StandStyleType = {
    strokeStyle?: string | CanvasGradient | CanvasPattern | undefined
    fillStyle?: string | CanvasGradient | CanvasPattern | undefined
    lineWidth?: number
    lineDash?: number[] | undefined
    lineDashOffset?: number
    lineCap?: CanvasLineCap
    lineJoin?: CanvasLineJoin
    miterLimit?: number
    order?: OrderType
} & BasicStyleType

class StandStyle extends BasicStyle {
    strokeStyle: string | CanvasGradient | CanvasPattern | undefined
    fillStyle: string | CanvasGradient | CanvasPattern | undefined
    lineWidth: number = 1
    lineDash: number[] | undefined
    lineDashOffset: number = 0
    lineCap: CanvasLineCap = 'butt'
    lineJoin: CanvasLineJoin = 'miter'
    miterLimit: number = 10

    // 填充和描边的顺序, 默认0,即先填充再描边
    order: OrderType = 0

    constructor(attr: StandStyleType = {}) {
        super()
        this.setOption(attr)
    }

    /* 设置样式 */
    setOption(attr: StandStyleType = {}) {
        Object.assign(this, attr)
    }

    /* 获取有顺序的绘图方法 */
    get drawOrder(): MethodsType {
        return this.order ? ['fill', 'stroke'] : ['stroke', 'fill']
    }

    /* 应用样式 */
    apply(ctx: CanvasRenderingContext2D) {
        super.apply(ctx)
        const {
            fillStyle,
            strokeStyle,
            lineWidth,
            lineCap,
            lineJoin,
            miterLimit,
            lineDash,
            lineDashOffset,
        } = this

        if (strokeStyle) {
            ctx.strokeStyle = strokeStyle
            ctx.lineWidth = lineWidth
            ctx.lineCap = lineCap
            ctx.lineJoin = lineJoin
            ctx.miterLimit = miterLimit
            if (lineDash) {
                ctx.setLineDash(lineDash)
                ctx.lineDashOffset = lineDashOffset
            }
        }
        fillStyle && (ctx.fillStyle = fillStyle)
    }
}
export { StandStyle }

其中的描边色、填充色以及描边样式相关的属性都是与原生canvas的api相对应的。

order 定义了填充和描边的顺序。

drawOrder 可以基于order获取由填充方法和描边方法构成的数组,此方法可以便于相应图形的绘图。

apply() 是应用样式的方法,这都是基础,不再多说。

1-3-TextStyle

TextStyle 的整体代码如下。

  • /src/lmm/style/TextStyle.ts
import { StandStyle, StandStyleType } from './StandStyle'

type FontStyle = '' | 'italic'
type FontWeight = '' | 'bold'

export type TextStyleType = {
    fontStyle?: FontStyle
    fontWeight?: FontWeight
    fontSize?: number
    fontFamily?: string
    textAlign?: CanvasTextAlign
    textBaseline?: CanvasTextBaseline
} & StandStyleType

class TextStyle extends StandStyle {
    fontStyle: FontStyle = ''
    fontWeight: FontWeight = ''
    fontSize: number = 12
    fontFamily: string = 'arial'
    textAlign: CanvasTextAlign = 'start'
    textBaseline: CanvasTextBaseline = 'alphabetic'

    constructor(attr: TextStyleType = {}) {
        super()
        this.setOption(attr)
    }

    /* 设置样式 */
    setOption(attr: TextStyleType = {}) {
        Object.assign(this, attr)
    }

    /* 应用样式 */
    apply(ctx: CanvasRenderingContext2D) {
        super.apply(ctx)
        this.setFont(ctx)
        ctx.textAlign = this.textAlign
        ctx.textBaseline = this.textBaseline
    }

    /* font 相关样式 */
    setFont(ctx: CanvasRenderingContext2D) {
        ctx.font = `${this.fontStyle} ${this.fontWeight} ${this.fontSize}px  ${this.fontFamily}`
    }
}
export { TextStyle }

其中的font开头的属性对应的是ctx.font,此属性通过setFont() 方法进行设置。之所以将其封装为一个独立方法,是因为在文字对象里获取文字宽度的时候需要设置ctx.font。

apply(ctx) 是应用文字样式的方法,很简单,不再赘述。

2-建立文字对象

在objects文件夹中建立一个文字对象。

  • /src/lmm/objects/Text2D.ts
import { Vector2 } from '../math/Vector2'
import { TextStyle, TextStyleType } from '../style/TextStyle'
import { Object2D, Object2DType } from './Object2D'
import { crtPathByMatrix } from './ObjectUtils'

/* 构造参数的类型 */
type TextType = Object2DType & {
    text?: string
    maxWidth?: number | undefined
    style?: TextStyleType
}

/* 虚拟上下文对象 */
const virtuallyCtx = document
    .createElement('canvas')
    .getContext('2d') as CanvasRenderingContext2D

/* 文字对齐方式引起的偏移量 */
const alignRatio = {
    start: 0,
    left: 0,
    center: -0.5,
    end: -1,
    right: -1,
}
const baselineRatio = {
    top: 0,
    middle: -0.5,
    bottom: -1,
    hanging: -0.05,
    alphabetic: -0.78,
    ideographic: -1,
}

class Text2D extends Object2D {
    text = ''
    maxWidth: number | undefined
    style: TextStyle = new TextStyle()

    // 类型
    readonly isText = true

    constructor(attr: TextType = {}) {
        super()
        this.setOption(attr)
    }

    /* 属性设置 */
    setOption(attr: TextType) {
        for (let [key, val] of Object.entries(attr)) {
            if (key === 'style') {
                this.style.setOption(val)
            } else {
                this[key] = val
            }
        }
    }

    /* 文本尺寸 */
    get size(): Vector2 {
        const { style, text, maxWidth } = this
        style.setFont(virtuallyCtx)
        const { width } = virtuallyCtx.measureText(text)
        let w = maxWidth === undefined ? width : Math.min(width, maxWidth)
        return new Vector2(w, style.fontSize)
    }

    /* 绘制图像边界 */
    crtPath(ctx: CanvasRenderingContext2D, matrix = this.pvmMatrix) {
        this.computeBoundingBox()
        const {
            boundingBox: {
                min: { x: x0, y: y0 },
                max: { x: x1, y: y1 },
            },
        } = this
        crtPathByMatrix(ctx, [x0, y0, x1, y0, x1, y1, x0, y1], matrix)
    }

    /* 计算边界盒子 */
    computeBoundingBox() {
        const {
            boundingBox: { min, max },
            size,
            offset,
            style: { textAlign, textBaseline },
        } = this

        min.set(
            offset.x + size.x * alignRatio[textAlign],
            offset.y + size.y * baselineRatio[textBaseline]
        )
        max.addVectors(min, size)
    }

    /* 绘图 */
    drawShape(ctx: CanvasRenderingContext2D) {
        const {
            text,
            offset: { x, y },
            maxWidth,
            style,
        } = this

        //样式
        style.apply(ctx)

        // 绘图
        for (let method of style.drawOrder) {
            style[`${method}Style`] && ctx[`${method}Text`](text, x, y, maxWidth)
        }
    }
}

export { Text2D }

文字对象是用ctx.fillText(text, x, y, maxWidth)和ctx.strokeText(text, x, y, maxWidth)绘制的,这两种方法的参数都是一样的。Text2D对象的text、offset和maxWidth便是对应了这些属性。

文字对象的边界盒子会受offset和对齐方式的影响。

offset会让图形发生偏移,这个我们在图案对象里说过。

文字的对齐方式也会让文字发生偏移,这个并不难理解,其偏移量是根据文字的尺寸,按照特定的比例来算的。

我这里的比例是自己测量的,不保证其严谨性。

const alignRatio = {
    start: 0,
    left: 0,
    center: -0.5,
    end: -1,
    right: -1,
}
const baselineRatio = {
    top: 0,
    middle: -0.5,
    bottom: -1,
    hanging: -0.05,
    alphabetic: -0.78,
    ideographic: -1,
}

有了上面的对齐比例,再结合offset和文字的尺寸,便可以算出其边界盒子。

computeBoundingBox() {
    const {
        boundingBox: { min, max },
        size,
        offset,
        style: { textAlign, textBaseline },
    } = this

    min.set(
        offset.x + size.x * alignRatio[textAlign],
        offset.y + size.y * baselineRatio[textBaseline]
    )
    max.addVectors(min, size)
}

对于文字的尺寸的获取及路径的绘制,这都是基础,我不再赘述。

3-绘制文字

我们建立一个Text2D.vue页,测试一下文字。

  • /src/examples/Text2D.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { OrbitControler } from '../lmm/controler/OrbitControler'
import { Scene } from '../lmm/core/Scene'
import { Text2D } from '../lmm/objects/Text2D'

// 获取父级属性
defineProps({
    size: { type: Object, default: { width: 0, height: 0 } },
})

// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>()

/* 场景 */
const scene = new Scene()

/* 相机轨道控制器 */
const orbitControler = new OrbitControler(scene.camera)

/* 文字测试 */
const text2D = new Text2D({
    text: 'Sphinx',
    style: {
        fontSize: 100,
        fillStyle: '#00acec',
        textAlign: 'center',
        textBaseline: 'middle',
    },
})
scene.add(text2D)

/* 按需渲染 */
orbitControler.addEventListener('change', () => {
    scene.render()
    drawRect()
})

/* 滑动滚轮缩放 */
function wheel({ deltaY }: WheelEvent) {
    orbitControler.doScale(deltaY)
}

/* 按住滚轮平移 */
function pointerdown(event: PointerEvent) {
    if (event.button == 1) {
        orbitControler.pointerdown(event.clientX, event.clientY)
    }
}
function pointermove(event: PointerEvent) {
    orbitControler.pointermove(event.clientX, event.clientY)
}
function pointerup(event: PointerEvent) {
    if (event.button == 1) {
        orbitControler.pointerup()
    }
}

/* 绘制文字边界 */
function drawRect() {
    const {
        ctx,
        canvas: { width, height },
    } = scene
    ctx.save()
    ctx.strokeStyle = 'maroon'
    ctx.translate(width / 2, height / 2)
    ctx.beginPath()
    text2D.crtPath(ctx, text2D.pvmMatrix)
    ctx.closePath()
    ctx.stroke()
    ctx.restore()
}

onMounted(() => {
    const canvas = canvasRef.value
    if (canvas) {
        scene.setOption({ canvas })
        scene.render()
        drawRect()
    }
})
</script>

<template>
    <canvas
        ref="canvasRef"
        :width="size.width"
        :height="size.height"
        @wheel="wheel"
        @pointerdown="pointerdown"
        @pointermove="pointermove"
        @pointerup="pointerup"
    ></canvas>
</template>

<style scoped></style>

效果如下:

image-20230620203309840

接下来,我们用TransformControler 对象对其进行变换测试。

4-变换文字

考虑到了文字的内容可能会在变换的过程中发生改变,所以我们再给TransformControler对象添加一个更新控制框的方法-updateFrame()。

class TransformControler extends Object2D {
    ……
    updateFrame() {
        this.obj?.computeBoundingBox()
    }
}

更新控制框实际上就是更新TransformControler所控制的图形的边界,因为控制框就是根据图形的边界画的。

接下来在之前的TransformControler.vue页里实例化一个Text2D对象。

整体代码如下:

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { TransformControler } from '../lmm/controler/TransformControler'
import { OrbitControler } from '../lmm/controler/OrbitControler'
import { Scene } from '../lmm/core/Scene'
import { Vector2 } from '../lmm/math/Vector2'
import { Group } from '../lmm/objects/Group'
import { ImagePromises, SelectObj } from '../lmm/objects/ObjectUtils'
import { Object2D } from '../lmm/objects/Object2D'
import { Img2D } from '../lmm/objects/Img2D'
import { Text2D } from '../lmm/objects/Text2D'

// 获取父级属性
defineProps({
    size: { type: Object, default: { width: 0, height: 0 } },
})

// 鼠标样式
const cursor = ref('default')

// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>()

/* 场景 */
const scene = new Scene()

/* 相机轨道控制器 */
const orbitControler = new OrbitControler(scene.camera)

/* 图案控制器 */
const transformControler = new TransformControler()
scene.add(transformControler)

const images: HTMLImageElement[] = []
for (let i = 1; i < 3; i++) {
    const image = new Image()
    image.src = `https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/${i}.png`
    images.push(image)
}
const imagePromises = ImagePromises(images)

/* 鼠标滑上的图案 */
let imgHover: Object2D | null

/* 选择图案的方法 */
const selectObj = SelectObj(scene)

/* 图形集合 */
const group = new Group()
scene.add(group)

/* 文字内容 */
const message = ref('Sphinx')

/* 文字 */
const text2D = new Text2D({
    text: message.value,
    position: new Vector2(300, 100),
    maxWidth: 400,
    style: {
        fontSize: 100,
        fillStyle: '#00acec',
        textAlign: 'right',
        textBaseline: 'top',
    },
})
group.add(text2D)

function textChange() {
    text2D.text = message.value
    transformControler.updateFrame()
    scene.render()
}

/* 所以图片加载完成 */
function onAllImageLoaded() {
    /* 添加图像 */
    group.add(
        ...images.map((image, i) => {
            const size = new Vector2(image.width, image.height).multiplyScalar(0.3)
            return new Img2D({
                image,
                position: new Vector2(0, i * 150 - 250),
                offset: new Vector2(-size.x / 2, -size.y / 2),
                rotate: 0.3,
                size,
                name: 'img-' + i,
                style: {
                    shadowColor: 'rgba(0,0,0,0.5)',
                    shadowBlur: 5,
                    shadowOffsetY: 20,
                },
            })
        })
    )

    /* 渲染 */
    scene.render()
}

/* 按需渲染 */
orbitControler.addEventListener('change', () => {
    scene.render()
})
transformControler.addEventListener('change', () => {
    scene.render()
})

/* 鼠标按下*/
function pointerdown(event: PointerEvent) {
    const { button, clientX, clientY } = event
    const mp = scene.clientToClip(clientX, clientY)
    switch (button) {
        case 0:
            imgHover = selectObj(group.children, mp)
            transformControler.pointerdown(imgHover, mp)
            updateMouseCursor()
            break
        case 1:
            orbitControler.pointerdown(clientX, clientY)
            break
    }
}
/* 鼠标移动 */
function pointermove(event: PointerEvent) {
    const { clientX, clientY } = event
    const mp = scene.clientToClip(clientX, clientY)
    orbitControler.pointermove(clientX, clientY)
    transformControler.pointermove(mp)
    imgHover = selectObj(group.children, mp)
    updateMouseCursor()
}

/* 滑动滚轮缩放 */
function wheel({ deltaY }: WheelEvent) {
    orbitControler.doScale(deltaY)
}

/* 鼠标抬起 */
window.addEventListener('pointerup', (event: PointerEvent) => {
    switch (event.button) {
        case 0:
            transformControler.pointerup()
            break
        case 1:
            orbitControler.pointerup()
            break
    }
})

/* 键盘按下 */
window.addEventListener(
    'keydown',
    ({ key, altKey, shiftKey }: KeyboardEvent) => {
        transformControler.keydown(key, altKey, shiftKey)
        updateMouseCursor()
    }
)

/* 键盘抬起 */
window.addEventListener('keyup', ({ altKey, shiftKey }: KeyboardEvent) => {
    transformControler.keyup(altKey, shiftKey)
})

/* 更新鼠标样式 */
function updateMouseCursor() {
    if (transformControler.mouseState) {
        cursor.value = 'none'
    } else if (imgHover) {
        cursor.value = 'pointer'
    } else {
        cursor.value = 'default'
    }
}

onMounted(() => {
    const canvas = canvasRef.value
    if (canvas) {
        scene.setOption({ canvas })
        Promise.all(imagePromises).then(onAllImageLoaded)
    }
})
</script>

<template>
    <canvas
        ref="canvasRef"
        :style="{ cursor }"
        :width="size.width"
        :height="size.height"
        @pointerdown="pointerdown"
        @pointermove="pointermove"
        @wheel="wheel"
    ></canvas>
    <div id="text">
        <label>文字内容:</label>
        <input type="text" v-model="message" @input="textChange" />
    </div>
</template>

<style scoped>
#text {
    position: absolute;
    left: 15px;
    top: 15px;
}
</style>

效果如下:

image-20230620210624016

文字可以自由变换,且随文字内容的改变,控制框也会发生相应改变。

总结

这个文字对象主要就是在理解了canvas 底层API的基础上,找一种合理的架构方式,对其进行封装。

下一章我们用过一个实战案例《T恤图案编辑器》检验一下我们建立的图形变换组件。