源码
学习目标
- 创建T恤图案编辑器
知识点
- TransformControler 的应用
- canvas 全局合成
- canvas 图层控制
- DOM和图形组件的数据传递
1-搭建前端静态
1-1-页面结构
T恤图案编辑器的页面结构如下图所示。
- 图案库:存储图案素材;向T恤图案编辑器添加图案。
- T恤图案编辑器:管理和变换编辑器中的图案。
- 效果图:在实际产品中实时显示图案效果。
- 图层:管理图案,比如选择图案、删除图案、图案排序等。
1-2-创建项目
按照之前说过的vue3+vitest+ts初始化图形项目的方法,创建一个tshirt-editor项目,将之前的lmm文件导入其中。具体流程我不再多说,大家可以看源码。
在App.vue页中把基本的页面结构搭出来。
- /src/App.vue
<script setup lang="ts"></script>
<template>
<div id="left">
<div class="tit">图案库</div>
<div id="patternLib">
<div class="patternWrapper">
<img
class="pattern"
src="https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/1.png"
alt=""
/>
</div>
</div>
</div>
<div id="center" ref="editorDomRef"></div>
<div id="right">
<div id="effect" ref="effectDomRef"></div>
<ul id="layers">
<li class="layer">
<img
class="thumbnail"
src="https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/1.png"
alt=""
/>
01
</li>
</ul>
</div>
</template>
<style scoped>
* {
box-sizing: border-box;
}
#left {
display: flex;
flex-direction: column;
border-right: 1px solid #ddd;
}
.tit {
font-weight: bold;
font-size: 18px;
padding: 15px 15px 6px 15px;
border-bottom: 1px solid #ddd;
}
#patternLib {
padding: 15px;
width: 200px;
height: 100%;
overflow-y: scroll;
}
.patternWrapper {
flex-wrap: wrap;
margin: 15px 0px;
}
.pattern {
width: 100%;
cursor: pointer;
transition: transform 100ms;
}
.pattern:hover {
transform: scale(1.1);
}
#center {
flex: 1;
background-image: url('https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/alpha_back.png');
position: relative;
}
.tips {
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 15px;
line-height: 28px;
position: absolute;
bottom: 0;
}
#right {
display: flex;
flex-direction: column;
width: 350px;
border-left: 1px solid #ddd;
}
#effect {
background-color: antiquewhite;
height: 350px;
}
#layers {
flex: 1;
padding: 0;
margin: 0;
overflow-y: scroll;
}
.layer {
display: flex;
align-items: center;
padding: 3px;
list-style: none;
cursor: pointer;
transition: all 100ms;
border-bottom: 1px solid #ddd;
line-height: 0;
}
.layerActive {
background-color: #f5f5f5;
box-shadow: inset #ccc 0 1px 2px;
}
.thumbnail {
width: 50px;
background-image: url('https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/alpha_back.png');
background-size: 8px;
margin-right: 12px;
}
/* 滚动条样式 */
ul::-webkit-scrollbar,
div::-webkit-scrollbar {
width: 8px;
}
ul::-webkit-scrollbar-thumb,
div::-webkit-scrollbar-thumb {
-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
ul::-webkit-scrollbar-track,
div::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
</style>
效果如下:
现在基本页面结构已经有了,接下来我们需要先用假数据模拟数据和DOM的动态交互。
2-图案库
1.模拟fetch 请求后端图案库数据
const patternData = new Promise<string[]>((resolve) => {
const arr: string[] = []
for (let i = 1; i <= 8; i++) {
arr.push(
`https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/${i}.png`
)
}
resolve(arr)
})
2.将请求到的图案库绑定到相应的DOM上。
<script setup lang="ts">
import { onMounted, ref } from 'vue'
// 模拟fetch 请求后端图案库数据
const patternData = new Promise<string[]>((resolve) => {
const arr: string[] = []
for (let i = 1; i <= 8; i++) {
arr.push(
`https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/${i}.png`
)
}
resolve(arr)
})
/* 图案库 */
const patternsRef = ref<string[]>([])
/* 图案库 */
patternData.then((data) => {
patternsRef.value = data
})
/* 当点击图像库中的图案时,将图案添加到图案编辑器和图层中 */
function addPattern({ target }: MouseEvent) {}
</script>
<template>
<div id="left">
<div class="tit">图案库</div>
<div id="patternLib">
<div class="patternWrapper" v-for="item in patternsRef">
<img class="pattern" :src="item" alt="" @click="addPattern" />
</div>
</div>
</div>
……
</template>
效果如下:
当点击图像库中的图案时,会将图案添加到图案编辑器和图层中。
所以我们接下来先去建立一个图案编辑器对象。
3-Editor图案编辑器
1.Editor对象会具备之前ImgControler.vue 的功能。
- /src/component/Editor.ts
import { ref } from 'vue'
import { TransformControler } from '../lmm/controler/TransformControler'
import { OrbitControler } from '../lmm/controler/OrbitControler'
import { Scene } from '../lmm/core/Scene'
import { Group } from '../lmm/objects/Group'
import { Img2D } from '../lmm/objects/Img2D'
import { Vector2 } from '../lmm/math/Vector2'
import { EventDispatcher } from '../lmm/core/EventDispatcher'
import { SelectObj } from '../lmm/objects/ObjectUtils'
import { Object2D } from '../lmm/objects/Object2D'
class Editor extends EventDispatcher {
/* 编辑器场景 */
editorScene = new Scene()
/* 编辑器中的图案 */
group = new Group()
/* 图案控制器 */
transformControler = new TransformControler()
/* 相机轨道控制器 */
orbitControler = new OrbitControler(this.editorScene.camera)
/* 鼠标划入的图形 */
objHover: Object2D | null = null
/* 鼠标状态 */
cursor = ref('default')
constructor() {
super()
const { editorScene, orbitControler, group, transformControler } = this
/* 编辑器场景*/
editorScene.add(group, transformControler)
/* 渲染编辑器和虚拟场景 */
transformControler.addEventListener('change', () => {
this.render()
})
orbitControler.addEventListener('change', () => {
this.render()
})
}
onMounted(editorDom: HTMLDivElement) {
const {
editorScene: { canvas },
} = this
/* 编辑器 */
editorDom.append(canvas)
const { clientWidth: dx, clientHeight: dy } = editorDom
canvas.width = dx
canvas.height = dy
/* 编辑器事件监听 */
canvas.addEventListener('pointerdown', this.pointerdown.bind(this))
canvas.addEventListener('pointermove', this.pointermove.bind(this))
window.addEventListener('pointerup', this.pointerup.bind(this))
window.addEventListener('keydown', this.keydown.bind(this))
window.addEventListener('keyup', this.keyup.bind(this))
canvas.addEventListener('wheel', this.wheel.bind(this))
canvas.addEventListener('contextmenu', this.contextmenu.bind(this))
}
onUnmounted() {
const {
editorScene: { canvas },
} = this
/* 删除canvas,避免onMounted时重复添加 */
canvas.remove()
/* 取消事件监听 */
canvas.removeEventListener('pointerdown', this.pointerdown)
canvas.removeEventListener('pointermove', this.pointermove)
window.removeEventListener('pointerup', this.pointerup)
window.removeEventListener('keydown', this.keydown)
window.removeEventListener('keyup', this.keyup)
canvas.removeEventListener('wheel', this.wheel)
canvas.removeEventListener('contextmenu', this.contextmenu)
}
/* 鼠标按下 */
pointerdown(event: PointerEvent) {
const { editorScene, transformControler, group, orbitControler } = this
const { button, clientX, clientY } = event
const mp = editorScene.clientToClip(clientX, clientY)
switch (button) {
case 0:
this.objHover = SelectObj(editorScene)(group.children, mp)
transformControler.pointerdown(this.objHover, mp)
this.updateMouseCursor()
break
case 1:
orbitControler.pointerdown(clientX, clientY)
break
}
}
/* 鼠标移动 */
pointermove(event: PointerEvent) {
const { editorScene, transformControler, group, orbitControler } = this
const { clientX, clientY } = event
const mp = editorScene.clientToClip(clientX, clientY)
orbitControler.pointermove(clientX, clientY)
transformControler.pointermove(mp)
this.objHover = SelectObj(editorScene)(group.children, mp)
this.updateMouseCursor()
}
/* 鼠标抬起 */
pointerup({ button }: PointerEvent) {
switch (button) {
case 0:
this.transformControler.pointerup()
break
case 1:
this.orbitControler.pointerup()
break
}
}
/* 键盘按下 */
keydown({ key, altKey, shiftKey }: KeyboardEvent) {
this.transformControler.keydown(key, altKey, shiftKey)
this.updateMouseCursor()
}
/* 键盘抬起 */
keyup({ altKey, shiftKey }: KeyboardEvent) {
this.transformControler.keyup(altKey, shiftKey)
}
/* 滑动滚轮 */
wheel({ deltaY }: WheelEvent) {
this.orbitControler.doScale(deltaY)
}
/* 取消右键的默认功能 */
contextmenu(event: MouseEvent) {
event.preventDefault()
}
/* 更新鼠标样式 */
updateMouseCursor() {
const { transformControler, cursor, objHover } = this
if (transformControler.mouseState) {
cursor.value = 'none'
} else if (objHover) {
cursor.value = 'pointer'
} else {
cursor.value = 'default'
}
}
/* 设计图和效果图的渲染 */
render() {
this.editorScene.render()
}
}
export { Editor }
上面的方法都是从ImgControler.vue 中拷贝过来的。
2.在App.vue的onMounted 生命周期中调用Editor的onMounted 方法
- /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Editor } from '../component/Editor'
……
/* 编辑器的DOMRef */
const editorDomRef = ref<HTMLDivElement>()
/* 图案编辑器 */
const editor = new Editor()
onMounted(() => {
if (!editorDomRef.value) {
return
}
/* 图案编辑器 */
editor.onMounted(editorDomRef.value)
})
</script>
3.定义一个designSize 设计尺寸,用于规范所有图案的在编辑器中的尺寸。
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
/* 设计尺寸 */
designSize = 0
onMounted(editorDom: HTMLDivElement) {
const {
editorScene: { canvas },
} = this
/* 编辑器 */
editorDom.append(canvas)
const { clientWidth: dx, clientHeight: dy } = editorDom
canvas.width = dx
canvas.height = dy
/* 设计尺寸 */
const designSize = Math.min(dx, dy) * 0.5
this.designSize = designSize
……
}
……
}
designSize在onMounted() 中会取canvas宽高最小值的一半。
4.在Editor中,建立将图案添加到图案编辑器中的方法。
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
/* 添加图案 */
addImg(image: HTMLImageElement) {
const {
group: { children },
} = this
/* 图案序号,基于最大序号递增 */
const maxNum = Math.max(...children.map((obj) => obj.layerNum))
const layerNum = (children.length ? maxNum : 0) + 1
/* 建立Img2D对象 */
const img2D = new Img2D({
image,
layerNum,
name: '图层' + layerNum,
})
/* 基于设计尺寸设置图案尺寸 */
this.setImg2DSize(img2D, 0.5)
/* 添加图案 */
this.group.add(img2D)
/* 选择图案 */
this.transformControler.obj = img2D
return img2D
}
/* 设置图案尺寸 */
setImg2DSize(img2D: Img2D,ratio:number) {
const { designSize } = this
const { width, height } = img2D.image as HTMLImageElement
const w = designSize*ratio
const h = (w * width) / height
img2D.size.set(w, h)
img2D.offset.set(-w / 2, -h / 2)
}
}
export { Editor }
5.在App.vue中点击图案库中的图案时,调用Editor的addImg()方法,同时绑定鼠标。
- /src/App.vue
<script setup lang="ts">
import { Editor } from '../component/Editor'
……
/* 图案编辑器 */
const editor = new Editor()
const { cursor } = editor
/* 当点击图像库中的图案时,将图案添加到图案编辑器和图层中 */
function addPattern({ target }: MouseEvent) {
if (!(target instanceof Image)) {
return
}
/* 将图片添加到编辑器中 */
editor.addImg(target)
}
</script>
<template>
<div id="left">
<div class="tit">图案库</div>
<div id="patternLib">
<div class="patternWrapper" v-for="item in patternsRef">
<img class="pattern" :src="item" alt="" @click="addPattern" />
</div>
</div>
</div>
<div id="center" ref="editorDomRef" :style="{ cursor }"></div>
……
</template>
现在点击图像库中的图案,便可以将图案添加到图案编辑区中,并且可以对其进行变换。
6.在图案编辑器中添加一张设计图,用于作为图案变换的参考。
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
/* 设计图 */
designImg = new Img2D({
index: 1000,
})
constructor() {
super()
const { editorScene, orbitControler, group, transformControler, designImg } = this
/* 编辑器场景*/
editorScene.add(group, transformControler, designImg)
……
}
……
/* 配置设计图 */
setDesignImg(src: string) {
const { designImg, designSize } = this
/* 图案尺寸随设计尺寸而定,位置居中 */
designImg.setOption({
src,
size: new Vector2(designSize),
offset: new Vector2(-designSize / 2),
})
/* 渲染 */
;(designImg.image as HTMLImageElement).onload = () => {
this.editorScene.render()
}
}
}
export { Editor }
在App.vue 中模拟一个请求设计图的方法。
- /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Editor } from '../component/Editor'
/* 图案类型 */
type ImgType = {
src?: string
globalCompositeOperation: GlobalCompositeOperation
}
/* 模拟fetch请求后端T恤数据 */
const TShirtData = new Promise<{
designImgSrc: string
effectImgData: ImgType[]
}>((resolve) => {
const path = 'https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/'
resolve({
/* 设计图 */
designImgSrc: path + 'design.png',
/* 效果图素材 */
effectImgData: [
{
src: '',
globalCompositeOperation: 'source-over',
},
{
src: path + 'shirt-shadow.jpg',
globalCompositeOperation: 'multiply',
},
{
src: path + 'shirt-mask.png',
globalCompositeOperation: 'destination-in',
},
{
src: '',
globalCompositeOperation: 'destination-in',
},
{
src: path + 'shirt-origin.jpg',
globalCompositeOperation: 'destination-over',
},
],
})
})
/* 图案编辑器 */
const editor = new Editor()
const { cursor } = editor
onMounted(() => {
……
/* T恤数据 */
TShirtData.then(({ designImgSrc }) => {
editor.setDesignImg(designImgSrc)
})
})
</script>
效果如下:
之前请求数据时的effectImgData是用于显示效果图的,接下来咱们来说其实现过程。
4-Effector效果图
效果图可用于在编辑图案的同时,实时显示实际效果。
效果图的实现,可以用二维图像合成,也可以通过三维模型实现。
我们这里先用二维图像合成,三维的之后我会放到three.js 课程里说。
二维图像的合成,我是用canvas 2d中内置的合成属性globalCompositeOperation 来做的,若大家不熟悉此属性,需补一下《canvas从入门到放飞自我-基础篇》里的全局合成。
4-1-T恤图案合成原理
效果图的合成需要图案和几张T恤相关的图片:
- T恤原图
- T恤投影图,用于为图案添加阴影,使其更真实。
- T恤的图案遮罩图,对图案进行裁剪,使图案只能出现在它应该出现的地方。
效果图的合成方式如下图所示:
在我当前的canvas 组件中代码逻辑如下:
/* 设计尺寸 */
const size = new Vector2(400)
/* T恤偏移 */
const offset = new Vector2(-size.width / 2)
/* 文件路径 */
const path = 'https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/'
/* 图案 */
const pattern = new Img2D({
src: path + '1.png',
offset: new Vector2(-200, -165),
size: new Vector2(400, 330),
})
/* TShirt 投影图 */
const shirtShadow = new Img2D({
src: path + 'shirt-shadow.jpg',
offset,
size,
style: {
globalCompositeOperation: 'multiply',
},
})
/* TShirt 遮罩图 */
const shirtMask = new Img2D({
src: path + 'shirt-mask.png',
offset,
size,
style: {
globalCompositeOperation: 'destination-in',
},
})
/* 用作裁剪的图案 */
const patternMask = new Img2D({
image: pattern.image,
offset: pattern.offset,
size: pattern.size,
style: {
globalCompositeOperation: 'destination-in',
},
})
/* TShirt 原图 */
const shirtOrigin = new Img2D({
src: path + 'shirt-origin.jpg',
offset,
size,
style: {
globalCompositeOperation: 'destination-over',
},
})
scene.add(pattern, shirtShadow, shirtMask, patternMask, shirtOrigin)
效果如下:
4-2-项目里的效果图
效果图的合成需要图案编辑器里的图案,因此需要在Editor里生成一张虚拟canvas,这个canvas的尺寸要根据效果图的Dom尺寸而定。
1.在Editor中添加一个虚拟场景,使其中的图案随编辑器场景而变。
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
/* 虚拟场景 */
resultScene = new Scene()
/* 虚拟场景里的图案集合 */
resultGroup = new Group()
constructor() {
super()
const {
editorScene,
orbitControler,
group,
transformControler,
designImg,
resultScene,
resultGroup,
} = this
……
/* 虚拟场景 */
resultScene.add(resultGroup)
/* 虚拟场景的图案同步编辑器场景的图案 */
// 添加图案
group.addEventListener('add', ({ obj }) => {
if (obj instanceof Img2D) {
const { image, position, rotate, scale, offset, size, uuid } = obj
resultGroup.add(
new Img2D({
image,
position,
rotate,
scale,
offset,
size,
uuid,
})
)
}
})
// 变换图案
transformControler.addEventListener('transformed', ({ obj }) => {
const { position, rotate, scale, offset } = obj
const resultImg = resultGroup.children[group.children.indexOf(obj)]
if (resultImg instanceof Img2D) {
resultImg.setOption({
position,
rotate,
scale,
offset,
})
}
})
// 删除图案
group.addEventListener('remove', ({ obj }) => {
resultGroup.getObjectByProperty('uuid', obj.uuid)?.remove()
})
}
/* 设计图和效果图的渲染 */
render() {
this.editorScene.render()
this.resultScene.render()
this.dispatchEvent({ type: 'render' })
}
……
}
export { Editor }
2.虚拟场景的尺寸需要随效果图的DOM尺寸而定义。因此需要在App.vue 中将效果图的DOM尺寸传入Editor。
- /src/App.vue
<script setup lang="ts">
……
/* 效果图的DOMRef */
const effectDomRef = ref<HTMLDivElement>()
……
onMounted(() => {
if (!editorDomRef.value||!effectDomRef.value) {
return
}
/* 图案编辑器 */
editor.onMounted(editorDomRef.value,effectDomRef.value)
……
})
</script>
<template>
……
<div id="right">
<div id="effect" ref="effectDomRef"></div>
……
</div>
</template>
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
onMounted(editorDom: HTMLDivElement, effectDom: HTMLDivElement) {
const {
editorScene: { canvas },
resultScene: { canvas: resultCanvas },
resultGroup,
} = this
……
/* 虚拟场景 */
const { clientWidth: fx, clientHeight: fy } = effectDom
resultCanvas.width = fx
resultCanvas.height = fy
resultGroup.setOption({
scale: new Vector2(fx / designSize),
position: new Vector2(0,fx * 0.12),
})
}
……
}
export { Editor }
3.建立一个Effector对象,用于渲染效果图。
- /src/component/Effector.ts
import { Scene } from '../lmm/core/Scene'
import { Img2D } from '../lmm/objects/Img2D'
import { Vector2 } from '../lmm/math/Vector2'
import { ImagePromise } from '../lmm/objects/ObjectUtils'
/* 图案数据类型 */
type Img2DType = {
src?: string
globalCompositeOperation: GlobalCompositeOperation
}
class Effector {
scene = new Scene()
onMounted(effectDom: HTMLDivElement) {
const {
scene: { canvas },
} = this
const { clientWidth: fx, clientHeight: fy } = effectDom
effectDom.append(canvas)
canvas.width = fx
canvas.height = fy
}
onUnmounted() {
this.scene.canvas.remove()
}
/* 添加图案 */
addImgs(effectImg2DData: Img2DType[], resultCanvas: HTMLCanvasElement) {
const {
scene: {
canvas: { width },
},
scene,
} = this
const pros: Promise<HTMLImageElement>[] = []
effectImg2DData.forEach(({ src, globalCompositeOperation }, index) => {
let image: HTMLImageElement | HTMLCanvasElement = new Image()
if (src) {
image.src = src
pros.push(ImagePromise(image))
} else {
image = resultCanvas
}
scene.add(
new Img2D({
size: new Vector2(width),
offset: new Vector2(-width / 2),
index,
image,
style: { globalCompositeOperation },
})
)
})
/* 渲染 */
Promise.all(pros).then(() => {
scene.render()
})
}
/* 渲染 */
render() {
this.scene.render()
}
}
export { Effector }
4.在App.vue 中实例化Effector对象,将效果图相关的图案数据传入Effector。
- /src/App.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Editor } from './component/Editor'
import { Effector } from './component/Effector'
……
/* 图案类型 */
type ImgType = {
src?: string
globalCompositeOperation: GlobalCompositeOperation
}
/* 模拟fetch请求后端T恤数据 */
const TShirtData = new Promise<{
designImgSrc: string
effectImgData: ImgType[]
}>((resolve) => {
const path = 'https://yxyy-pandora.oss-cn-beijing.aliyuncs.com/stamp-images/'
resolve({
/* 设计图 */
designImgSrc: path + 'design.png',
/* 效果图素材 */
effectImgData: [
{
src: '',
globalCompositeOperation: 'source-over',
},
{
src: path + 'shirt-shadow.jpg',
globalCompositeOperation: 'multiply',
},
{
src: path + 'shirt-mask.png',
globalCompositeOperation: 'destination-in',
},
{
src: '',
globalCompositeOperation: 'destination-in',
},
{
src: path + 'shirt-origin.jpg',
globalCompositeOperation: 'destination-over',
},
],
})
})
/* 图案库 */
const patternsRef = ref<string[]>([])
/* 编辑器的DOMRef */
const editorDomRef = ref<HTMLDivElement>()
/* 效果图的DOMRef */
const effectDomRef = ref<HTMLDivElement>()
/* 图案编辑器 */
const editor = new Editor()
const { cursor } = editor
/* 效果图 */
const effector = new Effector()
/* 渲染效果图 */
editor.addEventListener('render', () => {
effector.render()
})
onMounted(() => {
……
if (!editorDomRef.value || !effectDomRef.value) {
return
}
……
/* T恤数据 */
TShirtData.then(({ designImgSrc, effectImgData }) => {
editor.setDesignImg(designImgSrc)
effector.addImgs(effectImgData, editor.resultScene.canvas)
})
/* 效果图 */
effector.onMounted(effectDomRef.value)
})
onUnmounted(() => {
editor.onUnmounted()
effector.onUnmounted()
})
……
</script>
最终效果如下:
现在图案的编辑和效果展示就已经实现了。
接下来,我们基于编辑器里的图案显示相应图层。
5-图层
图层可用于图案的选择、排序、隐藏等管理操作。
1.在添加图案时,显示相应图层。
- /src/App.vue
<script setup lang="ts">
……
/* 图层数据类型 */
interface Layer {
src: string
name: string
uuid: string
active: boolean
}
/* 图层集合 */
const layersRef = ref<Layer[]>([])
……
/* 当点击图案库中的图案时,将图案添加到图案编辑器和图层中 */
function addPattern({ target }: MouseEvent) {
if (!(target instanceof Image)) {
return
}
/* 将图案添加到编辑器中 */
const { uuid, name } = editor.addImg(target)
/* 取消当前图层的选择 */
for (let layer of layersRef.value) {
if (layer.active) {
layer.active = false
break
}
}
/* 添加图案到layersRef中 */
layersRef.value.unshift({
src: target.src,
active: true,
uuid,
name,
})
}
</script>
<template>
……
<div id="right">
<div id="effect" ref="effectDomRef"></div>
<ul id="layers">
<template v-for="(item, index) in layersRef" :key="item.uuid">
<li
class="layer"
:class="{ layerActive: item.active }"
:data-uuid="item.uuid"
:index="index"
>
<img class="thumbnail" :src="item.src" alt="" />
{{ item.name }}
</li>
</template>
</ul>
</div>
</template>
效果如下:
2.点击图层选择图案。
在Editor 中添加一个基于uuid 选择图案的方法。
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
/* 基于uuid 选择图案 */
selectImgByUUID(uuid: string) {
const { group, transformControler } = this
const obj = group.getObjectByProperty('uuid', uuid)
if (obj instanceof Img2D) {
transformControler.obj = obj
}
}
}
export { Editor }
在图层上添加选择事件,选择图层所对应的图案。
- /src/App.vue
<script setup lang="ts">
……
/* 选择图层 */
function selectLayer(index: number) {
const { value } = layersRef
// 激活图层
activateLayer(index)
// 更新图案的控制状态
editor.selectImgByUUID(value[index].uuid)
}
/* 激活图层 */
function activateLayer(index: number) {
const { value } = layersRef
for (let i = 0, len = value.length; i < len; i++) {
value[i].active = i === index
}
}
</script>
<template>
……
<div id="right">
<div id="effect" ref="effectDomRef"></div>
<ul id="layers">
<template v-for="(item, index) in layersRef" :key="item.uuid">
<li
class="layer"
:class="{ layerActive: item.active }"
:data-uuid="item.uuid"
:index="index"
@click="selectLayer(index)"
>
<img class="thumbnail" :src="item.src" alt="" />
{{ item.name }}
</li>
</template>
</ul>
</div>
</template>
效果如下:
3.通过拖拽图层控制图案的排序。
在Editor 中添加一个基于图案索引置换图案的方法。
- /src/component/Editor.ts
class Editor extends EventDispatcher {
……
/* 基于图案索引置换图案 */
replaceImg(a: number, b: number) {
const { group, resultGroup } = this
for (let { children } of [group, resultGroup]) {
;[children[a], children[b]] = [children[b], children[a]]
}
this.render()
}
}
export { Editor }
在图层上添加拖拽事件,置换图层和图案。
- /src/App.vue
<script setup lang="ts">
……
/* 开始拖拽 */
function dragstart({ dataTransfer }: DragEvent, index: number) {
dataTransfer && dataTransfer.setData('index', index.toString())
}
/* 置入 */
function drop({ dataTransfer }: DragEvent, index: number) {
if (!dataTransfer) {
return
}
/* 激活图层 */
const dragIndex = parseInt(dataTransfer.getData('index'))
activateLayer(dragIndex)
const { value } = layersRef
/* 选择图案 */
editor.selectImgByUUID(value[dragIndex].uuid)
/* 置换图层 */
;[value[dragIndex], value[index]] = [value[index], value[dragIndex]]
/* 置换图案 */
const len = value.length - 1
editor.replaceImg(len - dragIndex, len - index)
}
</script>
<template>
……
<div id="right">
<div id="effect" ref="effectDomRef"></div>
<ul id="layers">
<template v-for="(item, index) in layersRef" :key="item.uuid">
<li
class="layer"
:class="{ layerActive: item.active }"
:data-uuid="item.uuid"
:index="index"
@click="selectLayer(index)"
draggable="true"
@dragover.prevent
@dragstart="dragstart($event, index)"
@drop="drop($event, index)"
>
<img class="thumbnail" :src="item.src" alt="" />
{{ item.name }}
</li>
</template>
</ul>
</div>
</template>
4.点击图案,选择图层。
/* 图案编辑器 */
const editor = new Editor()
const {
cursor,
transformControler,
group,
group: { children }
} = editor
transformControler.addEventListener('selected', ({ obj }) => {
// 更新图层选择状态
activateLayer(children.length - 1 - children.indexOf(obj))
})
5.删除图案时,删除图层。
group.addEventListener('remove', ({ obj: { uuid } }) => {
// 删除图层
removeLayer(uuid)
})
/* 删除图层 */
function removeLayer(uuid: string) {
const { value } = layersRef
for (let i = 0, len = value.length; i < len; i++) {
if (value[i].uuid === uuid) {
value.splice(i, 1)
break
}
}
}
好啦,到目前为止,整个图案编辑器的项目就搞定了。
6.作业:在图层右侧添加一只眼睛,控制图层可见性。
总结
现在我们已经完成了《canvas进阶课程-矩阵变换》。
整个课程主要告诉了大家两点知识:
-
以面向对象的思路架构canvas。
-
矩阵变换的应用:
- 本地模型矩阵
- 世界模型矩阵
- 视图投影矩阵
- 裁剪坐标系
- canvas画布的坐标系
- client坐标系
- 基点变换
矩阵变换是图形学的基础,若大家看了这个课程,再去理解三维世界里的矩阵变换,会更加简单。
比如,三维物体的位移、旋转和缩放也是可以存储于矩阵中,位移和缩放数据可以用三维向量表示,旋转数据可以用四元数或欧拉表示。
之后的canvas 进阶课我有以下打算:
- 基于滴滴的三个面试题,展开一篇canvas 进阶课。我因为在去滴滴的过程中,遇到了几个很经典的面试题,所以想着分享给大家,提高大家的面试通关率。
- 引入高中物理,比如弹力、阻力、摩擦力等,为canvas动画做好铺垫。