canvas封装Text2D对象

前言

学习目标

  • 创建二维文字对象

知识点

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

1-文字的样式对象

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

canvas封装Text2D对象

最底层的是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

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

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

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

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

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

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

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

3-绘制文字

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

  • /src/examples/Text2D.vue

效果如下:

canvas封装Text2D对象

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

4-变换文字

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

更新控制框实际上就是更新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>

效果如下:

canvas封装Text2D对象

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

总结

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

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

原文链接:https://juejin.cn/post/7246706891429396537 作者:李伟_Li慢慢

(0)
上一篇 2023年6月21日 上午10:57
下一篇 2023年6月21日 上午11:07

相关推荐

发表回复

登录后才能评论