字玩FontPlayer开发笔记14 Vue3实现多边形工具
目录
- 字玩FontPlayer开发笔记14 Vue3实现多边形工具
- 笔记
- 整体流程
- 临时变量
- 多边形组件数据结构
- 初始化多边形工具
- mousedown事件
- mousemove事件
- 监听mouseup事件
- 渲染控件
- 将多边形转换为平滑的钢笔路径
字玩FontPlayer开发笔记14 Vue3实现多边形工具
字玩FontPlayer是笔者开源的一款字体设计工具,使用Vue3 + ElementUI开发,源代码:github | gitee
笔记
多变形工具允许用户创建自定义多边形形状,实现效果:
整体流程
- 使用points临时变量记录创建时多边形的顶点
- 监听mousedown事件,第一次点击时在points数组中添加首个顶点
- 监听mousemove事件,每次鼠标按下后第一次移动时在points数组中添加顶点,非第一次移动则改变points中最后一个顶点的位置,使其移动到鼠标当前位置
- 监听mouseup事件,如果路径闭合,则创建多边形组件,并重置临时变量
- 使用renderPolygonEditor渲染控件,每次变量更新时重新渲染控件
- 字玩支持将多边形路径转换为平滑的钢笔路径,使用paper.js实现
临时变量
-
points
记录创建时多边形的顶点数组 -
editing
记录当前是否在创建顶点过程中 -
mousedown
记录当前鼠标是否按下 -
mousemove
记录当前鼠标是否移动
多边形组件数据结构
每个组件最外层数据结构如下:
// 字符组件数据结构,包含变换等基础信息,与包含图形信息的IComponentValue不同
// component data struct, contains transform info, etc, different with IComponentValue
export interface IComponent {
uuid: string;
type: string;
name: string;
lock: boolean;
visible: boolean;
value: IComponentValue;
x: number;
y: number;
w: number;
h: number;
rotation: number;
flipX: boolean;
flipY: boolean;
usedInCharacter: boolean;
opacity?: number;
}
对于每个不同的组件,记录相应数据在IComponent的value字段中,IComponentValue枚举定义如下:
// 字符图形组件信息枚举
// enum of basic element info for component
export enum IComponentValue {
IPenComponent,
IPolygonComponent,
IRectangleComponent,
IEllipseComponent,
IPictureComponent,
ICustomGlyph,
}
对于多边形组件,IPolygonComponent数据格式如下:
// 多边形组件
// polygon component
export interface IPolygonComponent {
points: any;
strokeColor: string;
fillColor: string;
closePath: boolean;
contour?: Array<ILine | IQuadraticBezierCurve | ICubicBezierCurve>;
preview?: Array<ILine | IQuadraticBezierCurve | ICubicBezierCurve>;
}
生成多边形组件代码:
// 生成多边形组件
// generate polygon component
const genPolygonComponent = (points: Array<IPoint>, closePath: boolean) => {
const { x, y, w, h } = getBound(points.reduce((arr: Array<{x: number, y: number }>, point: IPoint) => {
arr.push({
x: point.x,
y: point.y,
})
return arr
}, []))
const rotation = 0
const flipX = false
const flipY = false
let options = {
unitsPerEm: 1000,
descender: -200,
advanceWidth: 1000,
}
if (editStatus.value === Status.Edit) {
options.unitsPerEm = selectedFile.value.fontSettings.unitsPerEm
options.descender = selectedFile.value.fontSettings.descender
options.advanceWidth = selectedFile.value.fontSettings.unitsPerEm
}
let transformed_points = transformPoints(points, {
x, y, w, h, rotation, flipX, flipY,
})
const contour_points = formatPoints(transformed_points, options, 1)
const contour = genPolygonContour(contour_points)
const scale = 100 / (options.unitsPerEm as number)
const preview_points = transformed_points.map((point) => {
return Object.assign({}, point, {
x: point.x * scale,
y: point.y * scale,
})
})
const preview_contour = genPolygonContour(preview_points)
return {
uuid: genUUID(),
type: 'polygon',
name: 'polygon',
lock: false,
visible: true,
value: {
points: points,
fillColor: '',
strokeColor: '#000',
closePath,
preview: preview_contour,
contour: contour,
} as unknown as IComponentValue,
x,
y,
w,
h,
rotation: 0,
flipX: false,
flipY: false,
usedInCharacter: true,
}
}
初始化多边形工具
每次切换至多边形工具时,首先进行工具的初始化,包括添加事件监听器,并定义关闭工具回调方法等。
// 多边形工具初始化方法
// initializer for polygon tool
const initPolygon = (canvas: HTMLCanvasElement, glyph: boolean = false) => {
mousedown.value = false
mousemove.value = false
const nearD = 5
let closePath = false
const onMouseDown = (e: MouseEvent) => {
//...
}
const onMouseMove = (e: MouseEvent) => {
//...
}
const onMouseUp = (e: MouseEvent) => {
//...
}
const onEnter = (e: KeyboardEvent) => {
//...
}
const onKeyDown = (e: KeyboardEvent) => {
//...
}
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('keydown', onKeyDown)
const closePolygon = () => {
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('mousemove', onMouseMove)
setEditing(false)
setPoints([])
closePath = false
}
return closePolygon
}
mousedown事件
监听mousedown事件,第一次点击时在points数组中添加首个顶点
const onMouseDown = (e: MouseEvent) => {
if (!points.value.length) {
// 保存状态
saveState('创建多边形组件', [
StoreType.Polygon,
glyph ? StoreType.EditGlyph : StoreType.EditCharacter
],
OpType.Undo
)
}
setEditing(true)
mousedown.value = true
if (!points.value.length) {
const _point: IPoint = {
uuid: genUUID(),
x: getCoord(e.offsetX),
y: getCoord(e.offsetY),
}
const _points = R.clone(points.value)
_points.push(_point)
setPoints(_points)
}
}
mousemove事件
监听mousemove事件,每次鼠标按下后第一次移动时在points数组中添加顶点,非第一次移动则改变points中最后一个顶点的位置,使其移动到鼠标当前位置
const onMouseMove = (e: MouseEvent) => {
if (!points.value.length || !editing) return
const _points = R.clone(points.value)
if (!mousedown.value) {
if (!mousemove.value) {
// 保存状态
saveState('创建多边形组件', [
StoreType.Polygon,
glyph ? StoreType.EditGlyph : StoreType.EditCharacter
],
OpType.Undo
)
// 第一次移动鼠标
const _point = {
uuid: genUUID(),
x: getCoord(e.offsetX),
y: getCoord(e.offsetY),
}
_points.push(_point)
setPoints(_points)
mousemove.value = true
} else {
// 移动鼠标
const _point = _points[_points.length - 1]
_point.x = getCoord(e.offsetX)
_point.y = getCoord(e.offsetY)
closePath = false
if (isNearPoint(getCoord(e.offsetX), getCoord(e.offsetY), points.value[0].x, points.value[0].y, nearD)) {
_point.x = points.value[0].x
_point.y = points.value[0].y
closePath = true
}
setPoints(_points)
mousemove.value = true
}
}
}
监听mouseup事件
监听mouseup事件,如果路径闭合,则创建多边形组件,并重置临时变量
const onMouseUp = (e: MouseEvent) => {
if (!points.value.length || !editing) return
mousedown.value = false
mousemove.value = false
if (closePath) {
setEditing(false)
if (!glyph) {
addComponentForCurrentCharacterFile(genPolygonComponent(R.clone(points.value), true))
} else {
addComponentForCurrentGlyph(genPolygonComponent(R.clone(points.value), true))
}
setPoints([])
closePath = false
}
}
渲染控件
// 渲染多边形编辑工具
// render polygon editor
const renderPolygonEditor = (points: IPoints, canvas: HTMLCanvasElement) => {
const ctx: CanvasRenderingContext2D = (canvas as HTMLCanvasElement).getContext('2d') as CanvasRenderingContext2D
const _points = points.value.map((point: IPoint) => {
return mapCanvasCoords({
x: point.x,
y: point.y,
})
})
if (!_points.length) return
const w = 10
ctx.strokeStyle = '#000'
ctx.fillStyle = '#000'
ctx.beginPath()
ctx.moveTo(_points[0].x, _points[0].y)
for (let i = 1; i < _points.length; i ++) {
ctx.lineTo(_points[i].x, _points[i].y)
}
ctx.stroke()
ctx.closePath()
for (let i = 0; i < _points.length; i++) {
ctx.fillRect(_points[i].x - w / 2, _points[i].y - w / 2, w, w)
}
}
将多边形转换为平滑的钢笔路径
实现效果:
const transformToPath = () => {
savePolygonEditState()
const polygonComponent = selectedComponent.value.value
const { x, y, w, h, rotation, flipX, flipY } = selectedComponent.value
const points: Array<{
x: number,
y: number,
}> = transformPoints(polygonComponent.points.map((point: IPoint) => {
return {
x: point.x,
y: point.y,
}
}), {
x, y, w, h, rotation, flipX, flipY,
})
let penPoints: Array<IPenPoint> = []
// 创建一个闭合多边形
const segments = []
for(let i = 0; i < points.length - 1; i++) {
segments.push([points[i].x, points[i].y])
}
// 如果收尾节点和起始节点重合,则不添加
if (points[points.length - 1].x !== points[0].x || points[points.length - 1].y !== points[0].y) {
segments.push([points[points.length - 1].x, points[points.length - 1].y])
}
let path = new paper.Path({
segments,
closed: true,
})
path.smooth()
let uuid1 = genUUID()
for (let i = 0; i < path.curves.length; i++) {
const curve = path.curves[i]
const uuid2 = genUUID()
const uuid3 = genUUID()
penPoints.push({
uuid: uuid1,
x: curve.points[0].x,
y: curve.points[0].y,
type: 'anchor',
origin: null,
isShow: true,
})
penPoints.push({
uuid: uuid2,
x: curve.points[1].x,
y: curve.points[1].y,
type: 'control',
origin: uuid1,
isShow: false,
})
uuid1 = genUUID()
penPoints.push({
uuid: uuid3,
x: curve.points[2].x,
y: curve.points[2].y,
type: 'control',
origin: uuid1,
isShow: false,
})
if (i >= path.curves.length - 1) {
penPoints.push({
uuid: uuid1,
x: curve.points[3].x,
y: curve.points[3].y,
type: 'anchor',
origin: null,
isShow: true,
})
}
}
const { x: penX, y: penY, w: penW, h: penH } = getBound(penPoints)
if (editStatus.value === Status.Edit) {
modifyComponentForCurrentCharacterFile(selectedComponentUUID.value, {
value: {
points: penPoints,
editMode: false,
},
type: 'pen',
x: penX,
y: penY,
w: penW,
h: penH,
rotation: 0,
})
} else if (editStatus.value === Status.Glyph) {
modifyComponentForCurrentGlyph(selectedComponentUUID_Glyph.value, {
value: {
points: penPoints,
editMode: false,
},
type: 'pen',
x: penX,
y: penY,
w: penW,
h: penH,
rotation: 0,
})
}
}