项目简介
这个项目主要是用于给图片打标记用的,并有移动缩放的功能。可以先看下面的动图。
-
项目地址
-
效果预览
项目背景
当初有个需求是在后台系统给一个效果图的任意位置打上标记并添加上介绍。需要把点位的坐标和详细信息存入数据库里,并且能支持移动和缩放,然后在前台供客户查看。
当初写这个功能的时候找了好久没找到特别符合要求的。有的依赖太多、有的没缩放和移动这也是难点所在,最终找到了一个已经归档的项目结构设计的挺好,但不支持缩放和移动所以自己就进行了二次开发重构。刚开始想着挺容易了,但开发过程中还是遇到了很多问题。
实践过程
下面从代码设计和重点细节讲,以下代码均是精简后的。其他的逻辑都是一些功能操作方面的了代码量也挺多,有感兴趣的可以查看源码。
技术选型
- 整个项目原生
js
写的,最近重构成ts
了。 - 打包工具用的
rollup
,支持umd
和esm
两种类型的。 - 样式使用
scss
预处理,编译用的gulp
工具。 - 文档使用
jsdoc2md
生成工具。 - 代码规范工具都用了
editorconfig
、eslint
、stylelint
、prettier
、commitlint
。
结构设计
结构设计上分为了两个类,一个 Taggd
类是图片的逻辑、一个 Tag
类是图片下标记逻辑、样式表、工具类还有声明单独拆出来。
├── classes
│ ├── Tag.ts // 标记的操作、接口等。
│ ├── TagEffect.ts // 标记的移动、拖拽等。
│ ├── Taggd.ts // 图片的操作、接口等。
│ └── TaggdEffect.ts // 图片的移动、拖拽等。
├── index.scss // 样式表
├── index.ts // 主入口
├── types
│ └── index.ts // ts 接口声明
└── utils
├── event-emitter.ts // 发布订阅模式
├── type-error-message.ts // 错误提示函数
└── utilities.ts // 工具类函数
事件设计
在事件设计上使用发布订阅模式,Taggd
和 Tag
类直接继承 EventEmitter
类。通过实例订阅事件,内部进行操作时通知事件。
import EventEmitter from "../utils/event-emitter";
class Taggd extends EventEmitter {
constructor(image: HTMLImageElement, options: Partial<IDefaultOptions>, data: Tag[] = []) {
super();
}
addTag(tag: Tag) {
this.emit("taggd.tag.add", this, tag);
return this;
}
}
// 创建实例并订阅事件
let taggd = new Taggd(image);
taggd.on("taggd.tag.add", function() {});
这里还做了判断,订阅函数通过返回 false
取消事件的执行。
const isCanceled = !this.emit("taggd.tag.add", this, tag);
if (!isCanceled) {
// 这里逻辑不继续执行了
}
拆分和关联
这时考虑 Taggd
、TaggdEffect
、Tag
、TagEffect
怎么建立联系。
类关联 Taggd
需要管理着所有的 Tag
的更新和移动,在 Tag
需要知道自己是那个 Taggd
的获取缩放比例等信息。稍微想了一下,在添加的时候在 Tag
上保存下 Taggd
。
类合并 TaggdEffect
和 TagEffect
因为两个文件单独拆出去了,所以这两个文件写成纯函数不方便,得传好多参数调用,所以自己写成了对象,通过 assign(Taggd.prototype, TaggdEffect)
和 assign(Tag.prototype, TagEffect)
直接通过原型合并到类上,调用 this
也比较方便。(重构成 TS 的时候这里是个大坑,一会一块说重构 TS 的问题。)
初始化图片
到现在还没有开始实现移动和缩放,在实现之前肯定要保存图片的一些初始信息。
- 初始的时候需要一个合适的缩放,让图片最大程度的显示在我们的容器内并且居中
naturalWidth / naturalHeight
。 - 保存图片的原始宽高,缩放后宽高,以及初始的样式销毁时恢复
naturalWidth
、naturalHeight
、style.cssText
。 - 计算初始的缩放比例
width / naturalWidth
。
newImage.onload = () => {
// 宽高比例
const { naturalWidth, naturalHeight } = image;
const aspectRatio = naturalWidth / naturalHeight;
// 初始居中
let width = parentWidth;
let height = parentHeight;
if (parentHeight * aspectRatio > parentWidth) {
height = parentWidth / aspectRatio;
} else {
width = parentHeight * aspectRatio;
}
// 初始样式
imageData.width = width;
imageData.height = height;
imageData.naturalWidth = naturalWidth;
imageData.naturalHeight = naturalHeight;
imageData.naturalStyle = image.style.cssText;
imageData.ratio = width / naturalWidth;
imageData.left = (parentWidth - width) / 2;
imageData.top = (parentHeight - height) / 2;
};
根据图上的位置获取原始标记坐标
当我们在图片上点击的时候,它的相对于原始图片的坐标是多少了?
举个例子:图片宽高各 1000px
,点击图片中间位置的时候坐标应该是 x = 500
、y = 500
,不论这个图片移动了多少,放大了多少,都不会影响。
- 公式:x = (相对于网页的 x 坐标 - 图片的 left 偏移量) * 缩放比例。
- 公式:y = (相对于网页的 y 坐标 - 图片的 top 偏移量) * 缩放比例。
taggdClickHandler(event: MouseEvent) {
const { imageData } = this
const offset = getOffset(this.png)
const position = {
x: (event.pageX - offset.left) * this.imageData.ratio,
y: (event.pageY - offset.top) * this.imageData.ratio,
}
return this
},
根据原始标记坐标获取在图上位置
当把我们已经有的坐标,怎么渲染到现在的图片上?
举个例子:现在坐标是 x = 500
、y = 500
,图片是经过移动、缩放的,那渲染时候应该在什么位置?按照上面公式换算回来就行了。
-
公式:left = 相对于原始图片的 x 坐标 * 缩放比例 + 图片的 left 。
-
公式:top = 相对于原始图片的 y 坐标 * 缩放比例 + 图片的 top 。
setPosition(x = this.position.x, y = this.position.y) {
const isCanceled = !this.emit('taggd.tag.change', this)
if (!isCanceled) {
const { wrapperElement, position, Taggd } = this
const { left, top, ratio } = Taggd.imageData
position.left = ratio * position.x + left
position.top = ratio * position.y + top
}
return this
}
图片的移动和缩放
各种转换处理完成了,该做缩放和移动操作了。
事件
需要注意一点,mousemove
和 mouseup
要绑定在 document
上,不然移动过快会光标丢失。
enableEditorMode() {
const isCanceled = !this.emit('taggd.editor.enable', this)
if (!isCanceled) {
addClass(this.wrapper, 'taggd--pointer')
this.image.addEventListener(this.options.addEvent, ...)
this.image.addEventListener('wheel', ...)
this.image.addEventListener('mousedown', ...)
document.addEventListener('mousemove', ...)
document.addEventListener('mouseup', ...)
}
return this
}
移动
移动这块比较简单,就是监听鼠标按下事件记录初始位置,移动事件更新位置,抬起事件更新最新的位置。
-
公式:left = 现在的 left + (现在的鼠标 x 坐标 - 开始的鼠标 x 坐标)。
-
公式:top =现在的 top + (现在的鼠标 y 坐标 -开始的鼠标 x 坐标)。
taggdMoveHander(event: MouseEvent) {
if (!this.action) {
return
}
event.preventDefault()
const { imageData, pointer } = this
const { endX, endY } = getPointer(event)
imageData.left = pointer.elX + (endX - pointer.startX)
imageData.top = pointer.elY + (endY - pointer.startY)
this.taggdChangeRender()
this.emit('taggd.editor.move', this)
return this
},
缩放
缩放这块就需要监听滚轮事件,并且缩放的时候要以鼠标位置为中心点缩放。重点在于 鼠标在 x, y 分别占图片的多少比,就把变更的宽高分配多少
- 公式: left -= 缩放的宽 * ((相对网页的 x 坐标 - 图片的 left 偏移量) / 图片的宽)。
- 公式: top -= 缩放的高 * ((相对网页的 y 坐标 - 图片的 top 偏移量) / 图片的高)。
taggdZoomHander(event: WheelEvent) {
if (this.wheeling) {
return
}
this.wheeling = true
setTimeout(() => {
this.wheeling = false
}, 50)
event.preventDefault()
const { options, image, imageData } = this
const { width, height, naturalWidth, naturalHeight } = imageData
// 处理比例
let ratio = getWheelRatio(event, options.zoomRatio)
const zoomRatioMin = Math.max(0.01, options.zoomRatioMin)
const zoomRatioMax = Math.min(100, options.zoomRatioMax)
ratio = (width * ratio) / naturalWidth
ratio = Math.min(Math.max(ratio, zoomRatioMin), zoomRatioMax)
// 获取新的宽高
const offset = getOffset(image)
const newWidth = naturalWidth * ratio
const newHeight = naturalHeight * ratio
const offsetWidth = newWidth - width
const offsetHeight = newHeight - height
// 设置新的宽高
imageData.ratio = ratio
imageData.width = newWidth
imageData.height = newHeight
imageData.left -= offsetWidth * ((event.pageX - offset.left) / width)
imageData.top -= offsetHeight * ((event.pageY - offset.top) / height)
this.taggdChangeRender()
this.emit('taggd.editor.zoom', this)
return this
},
标记的移动
标记的移动也是同样的道理,多了一个边界判断,标记的坐标不能超出图片的元素宽高。
position.x = Math.min(Math.max(0, x), naturalWidth);
position.y = Math.min(Math.max(0, y), naturalHeight);
重构成 TS
大概是快过年的时候学的 ts
吧,过年的时候在家重构了一版,因为对 ts
还在深造,重构完没有发布最新版本。基本上各项配置都要支持对 ts
的支持。
-
jsdoc2md
添加对ts
的支持 如何支持 TS -
rollup
使用rollup-plugin-typescript2
解析编译。 -
@typescript-eslint
、prettier/@typescript-eslint
对ts
语法的支持。 -
前面不是说了一个原型合并的问题吗,其实是
this
的指向问题,因为在单独的文件里this
指向错误,找了好久最终使用ThisType
解决。
const TaggdEffect: ThisType<Taggd> = {
taggdClickHandler(event: MouseEvent) {
// 这样 this 就指向了 Taggd
}
};
总结思考
虽然这是一个很小的项目,但这让我对设计模式和基础知识的重要性更加肯定,做的时候翻了不少资料。后来呢还看了两本书做了总结。
- 读书.红宝书之初读笔记系列
- 读书.设计模式与开发实践
还需要升级和完善的地方还有很多。
- 单元测试没做。
- 类型声明不完善。
- ...
本文参与
本文正在参与「掘金 2021 春招闯关活动」, 点击查看活动详情。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!