This commit is contained in:
hanshiyang committed 2025-09-23 10:43:28 +08:00
commit 977ba1c283
456 files changed
+52892

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
import { EditorComponent, EDITOR_COMPONENT } from '../../editor'
import './dialog.css'
export interface IDialogData {
type: string
label?: string
name: string
value?: string
options?: { label: string; value: string }[]
placeholder?: string
width?: number
height?: number
required?: boolean
}
export interface IDialogConfirm {
name: string
value: string
}
export interface IDialogOptions {
onClose?: () => void
onCancel?: () => void
onConfirm?: (payload: IDialogConfirm[]) => void
title: string
data: IDialogData[]
}
export class Dialog {
private options: IDialogOptions
private mask: HTMLDivElement | null
private container: HTMLDivElement | null
private inputList: (
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
)[]
constructor(options: IDialogOptions) {
this.options = options
this.mask = null
this.container = null
this.inputList = []
this._render()
}
private _render() {
const { title, data, onClose, onCancel, onConfirm } = this.options
// 渲染遮罩层
const mask = document.createElement('div')
mask.classList.add('dialog-mask')
mask.setAttribute(EDITOR_COMPONENT, EditorComponent.COMPONENT)
document.body.append(mask)
// 渲染容器
const container = document.createElement('div')
container.classList.add('dialog-container')
container.setAttribute(EDITOR_COMPONENT, EditorComponent.COMPONENT)
// 弹窗
const dialogContainer = document.createElement('div')
dialogContainer.classList.add('dialog')
container.append(dialogContainer)
// 标题容器
const titleContainer = document.createElement('div')
titleContainer.classList.add('dialog-title')
// 标题&关闭按钮
const titleSpan = document.createElement('span')
titleSpan.append(document.createTextNode(title))
const titleClose = document.createElement('i')
titleClose.onclick = () => {
if (onClose) {
onClose()
}
this._dispose()
}
titleContainer.append(titleSpan)
titleContainer.append(titleClose)
dialogContainer.append(titleContainer)
// 选项容器
const optionContainer = document.createElement('div')
optionContainer.classList.add('dialog-option')
// 选项
for (let i = 0; i < data.length; i++) {
const option = data[i]
const optionItemContainer = document.createElement('div')
optionItemContainer.classList.add('dialog-option__item')
// 选项名称
if (option.label) {
const optionName = document.createElement('span')
optionName.append(document.createTextNode(option.label))
optionItemContainer.append(optionName)
if (option.required) {
optionName.classList.add('dialog-option__item--require')
}
}
// 选项输入框
let optionInput:
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
if (option.type === 'select') {
optionInput = document.createElement('select')
option.options?.forEach(item => {
const optionItem = document.createElement('option')
optionItem.value = item.value
optionItem.label = item.label
optionInput.append(optionItem)
})
} else if (option.type === 'textarea') {
optionInput = document.createElement('textarea')
} else {
optionInput = document.createElement('input')
optionInput.type = option.type
}
if (option.width) {
optionInput.style.width = `${option.width}px`
}
if (option.height) {
optionInput.style.height = `${option.height}px`
}
optionInput.name = option.name
optionInput.value = option.value || ''
if (!(optionInput instanceof HTMLSelectElement)) {
optionInput.placeholder = option.placeholder || ''
}
optionItemContainer.append(optionInput)
optionContainer.append(optionItemContainer)
this.inputList.push(optionInput)
}
dialogContainer.append(optionContainer)
// 按钮容器
const menuContainer = document.createElement('div')
menuContainer.classList.add('dialog-menu')
// 取消按钮
const cancelBtn = document.createElement('button')
cancelBtn.classList.add('dialog-menu__cancel')
cancelBtn.append(document.createTextNode('取消'))
cancelBtn.type = 'button'
cancelBtn.onclick = () => {
if (onCancel) {
onCancel()
}
this._dispose()
}
menuContainer.append(cancelBtn)
// 确认按钮
const confirmBtn = document.createElement('button')
confirmBtn.append(document.createTextNode('确定'))
confirmBtn.type = 'submit'
confirmBtn.onclick = () => {
if (onConfirm) {
const payload = this.inputList.map<IDialogConfirm>(input => ({
name: input.name,
value: input.value
}))
onConfirm(payload)
}
this._dispose()
}
menuContainer.append(confirmBtn)
dialogContainer.append(menuContainer)
// 渲染
document.body.append(container)
this.container = container
this.mask = mask
}
private _dispose() {
this.mask?.remove()
this.container?.remove()
}
}
+131
View File
@@ -0,0 +1,131 @@
.dialog-mask {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: .5;
background: #000000;
z-index: 99;
}
.dialog-container {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
z-index: 999;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.dialog {
position: absolute;
padding: 0 30px 30px;
background: #ffffff;
box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
border: 1px solid #e2e6ed;
border-radius: 2px;
}
.dialog-title {
position: relative;
border-bottom: 1px solid #e2e6ed;
margin-bottom: 30px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.dialog-title i {
width: 16px;
height: 16px;
cursor: pointer;
display: inline-block;
background: url(../../assets/images/close.svg);
}
.dialog-option__item {
margin-bottom: 18px;
display: flex;
align-items: center;
justify-content: space-between;
}
.dialog-option__item span {
margin-right: 12px;
font-size: 14px;
color: #3d4757;
position: relative;
}
.dialog-option__item input,
.dialog-option__item textarea,
.dialog-option__item select {
width: 276px;
height: 30px;
border-radius: 2px;
border: 1px solid #d3d3d3;
min-height: 30px;
padding: 5px;
box-sizing: border-box;
outline: none;
appearance: none;
user-select: none;
font-family: inherit;
}
.dialog-option__item input:focus,
.dialog-option__item textarea:focus {
border-color: #4991f2;
}
.dialog-option__item--require::before {
content: "*";
color: #f56c6c;
margin-right: 4px;
position: absolute;
left: -8px;
}
.dialog-menu {
display: flex;
align-items: center;
justify-content: flex-end;
}
.dialog-menu button {
position: relative;
display: inline-block;
border: 1px solid #e2e6ed;
border-radius: 2px;
background: #ffffff;
line-height: 22px;
padding: 0 16px;
white-space: nowrap;
cursor: pointer;
}
.dialog-menu button:hover {
background: rgba(25, 55, 88, .04);
}
.dialog-menu__cancel {
margin-right: 16px;
}
.dialog-menu button[type='submit'] {
color: #ffffff;
background: #4991f2;
border-color: #4991f2;
}
.dialog-menu button[type='submit']:hover {
background: #5b9cf3;
border-color: #5b9cf3;
}
+340
View File
@@ -0,0 +1,340 @@
import { EditorComponent, EDITOR_COMPONENT } from '../../editor'
import './signature.css'
export interface ISignatureResult {
value: string
width: number
height: number
}
export interface ISignatureOptions {
width?: number
height?: number
onClose?: () => void
onCancel?: () => void
onConfirm?: (payload: ISignatureResult | null) => void
}
export class Signature {
private readonly MAX_RECORD_COUNT = 1000
private readonly DEFAULT_WIDTH = 390
private readonly DEFAULT_HEIGHT = 180
private undoStack: Array<Function> = []
private x = 0
private y = 0
private isDrawing = false
private isDrawn = false
private linePoints: [number, number][] = []
private canvasWidth: number
private canvasHeight: number
private options: ISignatureOptions
private mask: HTMLDivElement
private container: HTMLDivElement
private trashContainer: HTMLDivElement
private undoContainer: HTMLDivElement
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private preTimeStamp: number
private dpr: number
constructor(options: ISignatureOptions) {
this.options = options
this.preTimeStamp = 0
this.dpr = window.devicePixelRatio
this.canvasWidth = (options.width || this.DEFAULT_WIDTH) * this.dpr
this.canvasHeight = (options.height || this.DEFAULT_HEIGHT) * this.dpr
const { mask, container, trashContainer, undoContainer, canvas } =
this._render()
this.mask = mask
this.container = container
this.trashContainer = trashContainer
this.undoContainer = undoContainer
this.canvas = canvas
this.ctx = <CanvasRenderingContext2D>canvas.getContext('2d')
this.ctx.scale(this.dpr, this.dpr)
this.ctx.lineCap = 'round'
this._bindEvent()
this._clearUndoFn()
// this is necessary so that the screen does not move when moving - it is removed when closing the modal
document.documentElement.classList.add('overflow-hidden')
document.body.classList.add('overflow-hidden')
this.container.classList.add('overflow-hidden')
}
private _render() {
const { onClose, onCancel, onConfirm } = this.options
// 渲染遮罩层
const mask = document.createElement('div')
mask.classList.add('signature-mask')
mask.setAttribute(EDITOR_COMPONENT, EditorComponent.COMPONENT)
document.body.append(mask)
// 渲染容器
const container = document.createElement('div')
container.classList.add('signature-container')
container.setAttribute(EDITOR_COMPONENT, EditorComponent.COMPONENT)
// 弹窗
const signatureContainer = document.createElement('div')
signatureContainer.classList.add('signature')
container.append(signatureContainer)
// 标题容器
const titleContainer = document.createElement('div')
titleContainer.classList.add('signature-title')
// 标题&关闭按钮
const titleSpan = document.createElement('span')
titleSpan.append(document.createTextNode('插入签名'))
const titleClose = document.createElement('i')
titleClose.onclick = () => {
if (onClose) {
onClose()
}
this._dispose()
}
titleContainer.append(titleSpan)
titleContainer.append(titleClose)
signatureContainer.append(titleContainer)
// 操作区
const operationContainer = document.createElement('div')
operationContainer.classList.add('signature-operation')
// 撤销
const undoContainer = document.createElement('div')
undoContainer.classList.add('signature-operation__undo')
const undoIcon = document.createElement('i')
const undoLabel = document.createElement('span')
undoLabel.innerText = '撤销'
undoContainer.append(undoIcon)
undoContainer.append(undoLabel)
operationContainer.append(undoContainer)
// 清空画布
const trashContainer = document.createElement('div')
trashContainer.classList.add('signature-operation__trash')
const trashIcon = document.createElement('i')
const trashLabel = document.createElement('span')
trashLabel.innerText = '清空'
trashContainer.append(trashIcon)
trashContainer.append(trashLabel)
operationContainer.append(trashContainer)
signatureContainer.append(operationContainer)
// 绘图区
const canvasContainer = document.createElement('div')
canvasContainer.classList.add('signature-canvas')
const canvas = document.createElement('canvas')
canvas.width = this.canvasWidth
canvas.height = this.canvasHeight
canvas.style.width = `${this.canvasWidth / this.dpr}px`
canvas.style.height = `${this.canvasHeight / this.dpr}px`
canvasContainer.append(canvas)
signatureContainer.append(canvasContainer)
// 按钮容器
const menuContainer = document.createElement('div')
menuContainer.classList.add('signature-menu')
// 取消按钮
const cancelBtn = document.createElement('button')
cancelBtn.classList.add('signature-menu__cancel')
cancelBtn.append(document.createTextNode('取消'))
cancelBtn.type = 'button'
cancelBtn.onclick = () => {
if (onCancel) {
onCancel()
}
this._dispose()
}
menuContainer.append(cancelBtn)
// 确认按钮
const confirmBtn = document.createElement('button')
confirmBtn.append(document.createTextNode('确定'))
confirmBtn.type = 'submit'
confirmBtn.onclick = () => {
if (onConfirm) {
onConfirm(this._toData())
}
this._dispose()
}
menuContainer.append(confirmBtn)
signatureContainer.append(menuContainer)
// 渲染
document.body.append(container)
this.container = container
this.mask = mask
return {
mask,
canvas,
container,
trashContainer,
undoContainer
}
}
private _bindEvent() {
this.trashContainer.onclick = this._clearCanvas.bind(this)
this.undoContainer.onclick = this._undo.bind(this)
this.canvas.onmousedown = this._startDraw.bind(this)
this.canvas.onmousemove = this._draw.bind(this)
this.container.onmouseup = this._stopDraw.bind(this)
this.container.ontouchmove = this.registerTouchmove.bind(this)
this.container.ontouchstart = this.registerTouchstart.bind(this)
this.container.ontouchend = this.registerTouchend.bind(this)
}
private _undo() {
if (this.undoStack.length > 1) {
this.undoStack.pop()
if (this.undoStack.length) {
this.undoStack[this.undoStack.length - 1]()
}
}
}
private _saveUndoFn(fn: Function) {
this.undoStack.push(fn)
while (this.undoStack.length > this.MAX_RECORD_COUNT) {
this.undoStack.shift()
}
}
private _clearUndoFn() {
const clearFn = () => {
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
}
this.undoStack = [clearFn]
}
private _clearCanvas() {
this._clearUndoFn()
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
}
private _startDraw(evt: MouseEvent) {
this.isDrawing = true
this.x = evt.offsetX
this.y = evt.offsetY
this.ctx.lineWidth = 1
}
private _draw(evt: MouseEvent) {
if (!this.isDrawing) return
// 计算鼠标移动速度
const curTimestamp = performance.now()
const distance = Math.sqrt(evt.movementX ** 2 + evt.movementY ** 2)
const speed = distance / (curTimestamp - this.preTimeStamp)
// 目标线宽:最小速度1,最大速度5,系数3
const SPEED_FACTOR = 3
const targetLineWidth = Math.min(5, Math.max(1, 5 - speed * SPEED_FACTOR))
// 平滑过渡算法(20%的变化比例)调整线条粗细:系数0.2
const SMOOTH_FACTOR = 0.2
this.ctx.lineWidth =
this.ctx.lineWidth * (1 - SMOOTH_FACTOR) + targetLineWidth * SMOOTH_FACTOR
// 绘制
const { offsetX, offsetY } = evt
this.ctx.beginPath()
this.ctx.moveTo(this.x, this.y)
this.ctx.lineTo(offsetX, offsetY)
this.ctx.stroke()
this.x = offsetX
this.y = offsetY
this.linePoints.push([offsetX, offsetY])
this.isDrawn = true
// 缓存之前时间戳
this.preTimeStamp = curTimestamp
}
private _stopDraw() {
this.isDrawing = false
if (this.isDrawn) {
const imageData = this.ctx.getImageData(
0,
0,
this.canvasWidth,
this.canvasHeight
)
const self = this
this._saveUndoFn(function () {
self.ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
self.ctx.putImageData(imageData, 0, 0)
})
this.isDrawn = false
}
}
private _toData(): ISignatureResult | null {
if (!this.linePoints.length) return null
// 查找矩形四角坐标
const startX = this.linePoints[0][0]
const startY = this.linePoints[0][1]
let minX = startX
let minY = startY
let maxX = startX
let maxY = startY
for (let p = 0; p < this.linePoints.length; p++) {
const point = this.linePoints[p]
if (minX > point[0]) {
minX = point[0]
}
if (maxX < point[0]) {
maxX = point[0]
}
if (minY > point[1]) {
minY = point[1]
}
if (maxY < point[1]) {
maxY = point[1]
}
}
// 增加边框宽度
const lineWidth = this.ctx.lineWidth
minX = minX < lineWidth ? 0 : minX - lineWidth
minY = minY < lineWidth ? 0 : minY - lineWidth
maxX = maxX + lineWidth
maxY = maxY + lineWidth
const sw = maxX - minX
const sh = maxY - minY
// 裁剪图像
const imageData = this.ctx.getImageData(
minX * this.dpr,
minY * this.dpr,
sw * this.dpr,
sh * this.dpr
)
const canvas = document.createElement('canvas')
canvas.style.width = `${sw}px`
canvas.style.height = `${sh}px`
canvas.width = sw * this.dpr
canvas.height = sh * this.dpr
const ctx = <CanvasRenderingContext2D>canvas.getContext('2d')!
ctx.putImageData(imageData, 0, 0)
const value = canvas.toDataURL()
return {
value,
width: sw,
height: sh
}
}
private registerTouchmove(evt: TouchEvent) {
this.registerTouchEvent(evt, 'mousemove')
}
private registerTouchstart(evt: TouchEvent) {
this.registerTouchEvent(evt, 'mousedown')
}
private registerTouchend() {
const me = new MouseEvent('mouseup', {})
this.canvas.dispatchEvent(me)
}
private registerTouchEvent(evt: TouchEvent, eventName: string) {
const touch = evt.touches[0]
const me = new MouseEvent(eventName, {
clientX: touch.clientX,
clientY: touch.clientY
})
this.canvas.dispatchEvent(me)
}
private _dispose() {
this.mask.remove()
this.container.remove()
document.documentElement.classList.remove('overflow-hidden')
document.body.classList.remove('overflow-hidden')
}
}
+132
View File
@@ -0,0 +1,132 @@
.signature-mask {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: .5;
background: #000000;
z-index: 99;
}
.signature-container {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
z-index: 999;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.signature {
position: absolute;
padding: 0 30px 30px;
background: #ffffff;
box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
border: 1px solid #e2e6ed;
border-radius: 2px;
}
.signature-title {
position: relative;
border-bottom: 1px solid #e2e6ed;
margin-bottom: 15px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.signature-title i {
width: 16px;
height: 16px;
cursor: pointer;
display: inline-block;
background: url(../../assets/images/close.svg);
}
.signature-operation>div {
cursor: pointer;
display: inline-flex;
align-items: center;
color: #3d4757;
user-select: none;
}
.signature-operation>div:hover {
color: #6e7175;
}
.signature-operation>div i {
width: 24px;
height: 24px;
display: inline-block;
}
.signature-operation__undo {
background: url(../../assets/images/signature-undo.svg) no-repeat;
}
.signature-operation__trash {
background: url(../../assets/images/trash.svg) no-repeat;
}
.signature-operation>div span {
font-size: 12px;
margin: 0 5px;
}
.signature-canvas {
margin: 15px 0;
user-select: none;
}
.signature-canvas canvas {
background: #f3f5f7;
}
.signature-menu {
display: flex;
align-items: center;
justify-content: flex-end;
}
.signature-menu button {
position: relative;
display: inline-block;
border: 1px solid #e2e6ed;
border-radius: 2px;
background: #ffffff;
line-height: 22px;
padding: 0 16px;
white-space: nowrap;
cursor: pointer;
}
.signature-menu button:hover {
background: rgba(25, 55, 88, .04);
}
.signature-menu__cancel {
margin-right: 16px;
}
.signature-menu button[type='submit'] {
color: #ffffff;
background: #4991f2;
border-color: #4991f2;
}
.signature-menu button[type='submit']:hover {
background: #5b9cf3;
border-color: #5b9cf3;
}
.overflow-hidden {
overflow: hidden !important;
}