前言
源码
学习目标
- 创建二维文字对象
知识点
- ctx.fillText()
- ctx.strokeText()
1-文字的样式对象
首先咱们先看一下样式对象的架构思路。
最底层的是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>
效果如下:
接下来,我们用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>
效果如下:
文字可以自由变换,且随文字内容的改变,控制框也会发生相应改变。
总结
这个文字对象主要就是在理解了canvas 底层API的基础上,找一种合理的架构方式,对其进行封装。
下一章我们用过一个实战案例《T恤图案编辑器》检验一下我们建立的图形变换组件。