diff --git a/src/assets/images/signature.svg b/src/assets/images/signature.svg new file mode 100644 index 0000000..57a007f --- /dev/null +++ b/src/assets/images/signature.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/trash.svg b/src/assets/images/trash.svg new file mode 100644 index 0000000..c9852d1 --- /dev/null +++ b/src/assets/images/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/signature/Signature.ts b/src/components/signature/Signature.ts new file mode 100644 index 0000000..ad9d33c --- /dev/null +++ b/src/components/signature/Signature.ts @@ -0,0 +1,186 @@ +import { EditorComponent, EDITOR_COMPONENT } from '../../editor' +import './signature.css' + +export interface ISignatureConfirm { + value: string; + width: number; + height: number; +} + +export interface ISignatureOptions { + width?: number; + height?: number; + onClose?: () => void; + onCancel?: () => void; + onConfirm?: (payload: ISignatureConfirm) => void; +} + +export class Signature { + private x: number + private y: number + private isDrawing: boolean + private canvasWidth: number + private canvasHeight: number + private options: ISignatureOptions + private mask: HTMLDivElement + private container: HTMLDivElement + private trashContainer: HTMLDivElement + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + + constructor(options: ISignatureOptions) { + this.x = 0 + this.y = 0 + this.isDrawing = false + this.options = options + this.canvasWidth = options.width || 390 + this.canvasHeight = options.height || 180 + const { mask, container, trashContainer, canvas } = this._render() + this.mask = mask + this.container = container + this.trashContainer = trashContainer + this.canvas = canvas + this.ctx = canvas.getContext('2d') + this.ctx.lineCap = 'round' + this._bindEvent() + } + + 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 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}px` + canvas.style.height = `${this.canvasHeight}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 = 'default' + cancelBtn.onclick = () => { + if (onCancel) { + onCancel() + } + this._dispose() + } + menuContainer.append(cancelBtn) + // 确认按钮 + const confirmBtn = document.createElement('button') + confirmBtn.append(document.createTextNode('确定')) + confirmBtn.type = 'primary' + confirmBtn.onclick = () => { + if (onConfirm) { + onConfirm({ + width: this.canvasWidth, + height: this.canvasHeight, + value: this._toDataURL() + }) + } + this._dispose() + } + menuContainer.append(confirmBtn) + signatureContainer.append(menuContainer) + // 渲染 + document.body.append(container) + this.container = container + this.mask = mask + return { + mask, + canvas, + container, + trashContainer + } + } + + private _bindEvent() { + this.trashContainer.onclick = this._clearCanvas.bind(this) + this.canvas.onmousedown = this._startDraw.bind(this) + this.canvas.onmousemove = this._draw.bind(this) + this.container.onmouseup = this._stopDraw.bind(this) + } + + private _clearCanvas() { + 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 = 5 + } + + private _draw(evt: MouseEvent) { + if (!this.isDrawing) return + 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 + } + + private _stopDraw() { + this.isDrawing = false + } + + private _toDataURL() { + return this.canvas.toDataURL() + } + + private _dispose() { + this.mask.remove() + this.container.remove() + } + +} \ No newline at end of file diff --git a/src/components/signature/signature.css b/src/components/signature/signature.css new file mode 100644 index 0000000..e7121d8 --- /dev/null +++ b/src/components/signature/signature.css @@ -0,0 +1,122 @@ +.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__trash { + cursor: pointer; + display: inline-flex; + align-items: center; + color: #3d4757; + user-select: none; +} + +.signature-operation__trash:hover { + color: #6e7175; +} + +.signature-operation__trash i { + width: 24px; + height: 24px; + display: inline-block; + background: url(../../assets/images/trash.svg) no-repeat; +} + +.signature-operation__trash span { + font-size: 12px; + margin-left: 5px; +} + +.signature-canvas { + margin: 15px 0; + border: 1px solid #e9e9e9; + user-select: none; +} + +.signature-canvas canvas { + background: #fbfbfb; +} + +.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='primary'] { + color: #ffffff; + background: #4991f2; + border-color: #4991f2; +} + +.signature-menu button[type='primary']:hover { + background: #5b9cf3; + border-color: #5b9cf3; +} \ No newline at end of file diff --git a/src/editor/index.ts b/src/editor/index.ts index 9d22e92..3ccf63e 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -116,7 +116,8 @@ export { EditorComponent, EDITOR_COMPONENT, PageMode, - ImageDisplay + ImageDisplay, + Command } // 对外类型 diff --git a/src/main.ts b/src/main.ts index 0d12c2f..67b249d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import { data, options } from './mock' import './style.css' import prism from 'prismjs' -import Editor, { ControlType, EditorMode, ElementType, IElement, PageMode } from './editor' +import Editor, { Command, ControlType, EditorMode, ElementType, IElement, PageMode } from './editor' import { Dialog } from './components/dialog/Dialog' import { formatPrismToken } from './utils/prism' +import { Signature } from './components/signature/Signature' window.onload = function () { @@ -865,4 +866,29 @@ window.onload = function () { console.log('elementList: ', payload) } + // 9. 右键菜单注册 + instance.register.contextMenuList([ + { + name: '签名', + icon: 'signature', + when: (payload) => { + return !payload.isReadonly && payload.editorTextFocus + }, + callback: (command: Command) => { + new Signature({ + onConfirm(payload) { + const { value, width, height } = payload + if (!value || !width || !height) return + command.executeInsertElementList([{ + value, + width, + height, + type: ElementType.IMAGE + }]) + } + }) + } + } + ]) + } \ No newline at end of file diff --git a/src/style.css b/src/style.css index ac0ed6f..5b83959 100644 --- a/src/style.css +++ b/src/style.css @@ -699,4 +699,8 @@ ul { .footer .paper-size .options { right: 0; left: unset; +} + +.contextmenu-signature { + background-image: url('./assets/images/signature.svg'); } \ No newline at end of file