From 3da9a33b955342872a683f12a0b4da017df855b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8D=A3=E9=A1=B6?= Date: Sat, 4 May 2024 00:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E7=BB=91=E5=AE=9A=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/player/CanvasPlayer.vue | 85 ++++++++-- src/components/player/customFabricControl.ts | 4 +- src/components/player/operate.ts | 160 +++++++++++++++++++ src/components/right-panel/RightPanel.vue | 37 +++-- src/utils/eventBus.ts | 6 +- 5 files changed, 256 insertions(+), 36 deletions(-) create mode 100644 src/components/player/operate.ts diff --git a/src/components/player/CanvasPlayer.vue b/src/components/player/CanvasPlayer.vue index b3edc30..da8f6f4 100644 --- a/src/components/player/CanvasPlayer.vue +++ b/src/components/player/CanvasPlayer.vue @@ -8,7 +8,7 @@ import Logo from '~/assets/icons/icon-github.svg' import { usePlayerStore, type ElementItem } from '~/stores/player' import emitter, { BusEvent } from '~/utils/eventBus' import { initFabricControlCustomStyle } from './customFabricControl' -import { nanoid } from 'nanoid' +import { registerHotkey } from './operate' defineProps<{ msg: string @@ -17,7 +17,7 @@ const container = ref(null) let videoRef: HTMLVideoElement let canvasRef: HTMLCanvasElement const playerStore = usePlayerStore() -const { togglePlay, addElement, removeElement } = playerStore +const { togglePlay, addElement, removeElement, setFocusElements } = playerStore const { playStatus, currentTime, duration, elementList } = storeToRefs(playerStore) const menuShow = ref(false) const contextMenuPosition = ref({ x: 0, y: 0 }) @@ -47,6 +47,7 @@ emitter.on(BusEvent.ElementDelete, onDelete) emitter.on(BusEvent.ElementAdd, onAdd) emitter.on(BusEvent.ElementAlign, setElementAlign) emitter.on(BusEvent.ElementLayer, setElementLayer) +emitter.on(BusEvent.ElementSelectAll, selectAll) emitter.on(BusEvent.CanvasFullScreen, toggleCanvasFullScreen) emitter.on(BusEvent.CanvasExportCurrentFrame, onExportCurrentFrame) emitter.on(BusEvent.VideoSkip, (time: number) => (videoRef.currentTime += time)) @@ -76,17 +77,17 @@ function initCanvas() { // 初始化 Canvas 事件 function initCanvasEvent() { + // 画布鼠标按下事件 canvas.on('mouse:down', canvasOnMouseDown) + // 选中元素时 + canvas.on('selection:created', onElementSelected) + canvas.on('selection:updated', onElementSelected) + // 取消选中元素时 + canvas.on('selection:cleared', onElementDeselected) // 目标移动中 - canvas.on('object:moving', (e) => { - if (!e.target) return - e.target.opacity = 0.5 - }) + canvas.on('object:moving', onElementMoving) // 目标修改后 - canvas.on('object:modified', (e) => { - if (!e.target) return - e.target.opacity = 1 - }) + canvas.on('object:modified', onElementModified) } // 绘制元素 @@ -100,14 +101,16 @@ async function drawElements() { } // 删除元素 -function onDelete(obj: ElementItem) { - const { element, id } = toRaw(obj) - const activeObject = element instanceof fabric.Object ? element : null +function onDelete(obj: ElementItem | void) { + const targetElement: ElementItem | null = obj ? toRaw(obj) : canvas.getActiveObject() + if (!targetElement) return + const { elementId } = targetElement + const activeObject = targetElement instanceof fabric.Object ? targetElement : null if (!activeObject) return canvas.remove(activeObject) canvas.requestRenderAll() if (menuShow.value) menuShow.value = false - removeElement(id) + elementId && removeElement(elementId) } // 添加元素 @@ -126,6 +129,9 @@ function onAdd({ type, value }: { type: string; value: string }) { case 'text': addText(value) break + case 'rect': + addRect() + break default: break } @@ -148,6 +154,19 @@ function addSVG(url: string) { }) } +// 绘制矩形 +function addRect() { + const rectElement = new fabric.Rect({ + left: canvas.width! / 2, + top: canvas.height! / 2, + fill: 'red', + width: 100, + height: 100, + }) + canvas.add(rectElement) + addElement('svg', rectElement) +} + // 添加文字 function addText(val: string, options?: fabric.ITextboxOptions) { const textElement = new fabric.Textbox(val, { @@ -201,8 +220,8 @@ async function drawVideo(url: string) { canvas.add(videoElement) continuouslyRepaint() duration.value = videoRef.duration - canvas.setActiveObject(videoElement) addElement('video', videoElement) + canvas.setActiveObject(videoElement) videoRef.addEventListener('timeupdate', () => { currentTime.value = videoRef.currentTime @@ -227,8 +246,8 @@ function addImage(url: string) { angle: 0, }) canvas.add(imgElement) - canvas.setActiveObject(imgElement) addElement('image', imgElement) + canvas.setActiveObject(imgElement) }) } @@ -405,6 +424,7 @@ function setElementLayer(type: 'up' | 'down' | 'top' | 'bottom') { menuShow.value = false } +// 切换画布全屏 function toggleCanvasFullScreen(fullscreen?: boolean) { if (fullscreen === undefined) { if (document.fullscreenElement) { @@ -552,9 +572,42 @@ function canvasOnMouseDown(e: fabric.IEvent) { } } +// 全选 +function selectAll() { + canvas.discardActiveObject() + const sel = new fabric.ActiveSelection(canvas.getObjects(), { + canvas: canvas, + }) + canvas.setActiveObject(sel) + canvas.requestRenderAll() +} + +// 元素选中时 +function onElementSelected(e: fabric.IEvent) { + setFocusElements(e.selected, undefined) +} + +// 元素取消选中时 +function onElementDeselected(e: fabric.IEvent) { + setFocusElements(undefined, e.deselected) +} + +// 元素移动时 +function onElementMoving(e: fabric.IEvent) { + if (!e.target) return + e.target.opacity = 0.5 +} + +// 元素修改后 +function onElementModified(e: fabric.IEvent) { + if (!e.target) return + e.target.opacity = 1 +} onMounted((): void => { // 初始化画布 initCanvas() + // 注册快捷键 + registerHotkey() // oncopy事件禁用复制; document.oncopy = onCopy diff --git a/src/components/player/customFabricControl.ts b/src/components/player/customFabricControl.ts index 543bbb2..c30063d 100644 --- a/src/components/player/customFabricControl.ts +++ b/src/components/player/customFabricControl.ts @@ -7,8 +7,8 @@ export function initFabricControlCustomStyle() { borderColor: '#fff', // 控制边框颜色 cornerColor: '#fff', // 控制控件颜色 cornerStrokeColor: '#fff', // 控制控件边框颜色 - cornerSize: 10, // 控制控件大小 + cornerSize: 14, // 控制控件大小 cornerStyle: 'circle', // 控制控件形状 - transparentCorners: false // 控制控件是否透明 + transparentCorners: false, // 控制控件是否透明 }) } diff --git a/src/components/player/operate.ts b/src/components/player/operate.ts new file mode 100644 index 0000000..0cb787a --- /dev/null +++ b/src/components/player/operate.ts @@ -0,0 +1,160 @@ +import emitter, { BusEvent } from '~/utils/eventBus' + +const keyCodeMap: Record = { + '8': 'Backspace', + '27': 'Esc', + '37': 'left', + '38': 'top', + '39': 'right', + '40': 'bottom', + '46': 'Delete', + + '48': '0', + '49': '1', + '50': '2', + '51': '3', + '52': '4', + '53': '5', + '54': '6', + '55': '7', + '56': '8', + '57': '9', + + '65': 'A', + '66': 'B', + '67': 'C', + '68': 'D', + '69': 'E', + '70': 'F', + '71': 'G', + '72': 'H', + '73': 'I', + '74': 'J', + '75': 'K', + '76': 'L', + '77': 'M', + '78': 'N', + '79': 'O', + '80': 'P', + '81': 'Q', + '82': 'R', + '83': 'S', + '84': 'T', + '85': 'U', + '86': 'V', + '87': 'W', + '88': 'X', + '89': 'Y', + '90': 'Z', + + '187': '+', + '188': ',', + '189': '-', + '190': '.', + '191': '/', + '219': '[', + '221': ']', +} +const keyEventMap: Record< + string, + (event: KeyboardEvent & { readonly target: HTMLElement }) => void +> = { + // 全选 + 'cmd&A': (event) => { + event.preventDefault() + emitter.emit(BusEvent.ElementSelectAll) + }, + // 剪切 + 'cmd&X': (event) => { + event.preventDefault() + }, + // 复制 + 'cmd&C': (event) => { + event.preventDefault() + emitter.emit(BusEvent.ElementCopy) + }, + // 粘贴 + 'cmd&V': (event) => { + event.preventDefault() + emitter.emit(BusEvent.ElementPaste) + }, + // 撤销 undo + 'cmd&Z': (event) => { + event.preventDefault() + }, + // 重做 redo + 'cmd&shift&Z': (event) => { + event.preventDefault() + }, + // 重做 redo + 'cmd&Y': (event) => { + event.preventDefault() + }, + // 保存 + 'cmd&S': (event) => { + event.preventDefault() + }, + // 锁定/取消锁定 + 'cmd&L': (event) => { + if (event.shiftKey) return + event.preventDefault() + }, + // 上移一个图层 + 'cmd&]': (event) => {}, + // 上移到顶层 + 'cmd&alt&]': (event) => {}, + // 下移一个图层 + 'cmd&[': (event) => {}, + // 下移到底层 + 'cmd&alt&[': (event) => {}, + // 退格键删除 + Backspace: (event) => { + event.preventDefault() + emitter.emit(BusEvent.ElementDelete) + }, + // 删除键删除 + Delete: (event) => { + event.preventDefault() + emitter.emit(BusEvent.ElementDelete) + }, + // 元素恢复实际大小 + 'shift&0': (event) => {}, + // 元素左右翻转 + 'shift&H': (event) => {}, + // 元素上下翻转 + 'shift&V': (event) => {}, + // 元素向左移动 + left: (event) => {}, + // 元素向右移动 + right: (event) => {}, + // 元素向上移动 + top: (event) => {}, + // 元素向下移动 + bottom: (event) => {}, +} +// 找到对应的热键方法并执行 +const findHotKeyFun = (event: KeyboardEvent & { optionKey?: boolean }) => { + const keys: string[] = [] + if (event.metaKey || event.ctrlKey) { + keys.push('cmd') + } + if (event.shiftKey) { + keys.push('shift') + } + if (event.altKey || event.optionKey) { + keys.push('alt') + } + keys.push(keyCodeMap[event.keyCode]) + const handler = keyEventMap[keys.join('&')] + handler?.(event as KeyboardEvent & { target: HTMLElement }) +} + +// 快捷键绑定 +export function registerHotkey() { + window.addEventListener('keydown', findHotKeyFun, false) +} + +// 快捷键解绑 +export function unregisterHotkey() { + window.removeEventListener('keydown', findHotKeyFun, false) +} diff --git a/src/components/right-panel/RightPanel.vue b/src/components/right-panel/RightPanel.vue index a0f6532..96a20d3 100644 --- a/src/components/right-panel/RightPanel.vue +++ b/src/components/right-panel/RightPanel.vue @@ -12,20 +12,24 @@ import { IconLayerUp, IconLayerDown, } from '~/assets/icons/index' +import { computed } from 'vue' import { usePlayerStoreWithRefs, type ElementItem } from '~/stores/player' import emitter, { BusEvent } from '~/utils/eventBus' -const { elementList } = usePlayerStoreWithRefs() +const { elementList, focusElements } = usePlayerStoreWithRefs() +const focusElementIds = computed(() => { + return focusElements.value.map((item) => item.elementId) +}) -function onDelete(item: ElementItem) { +function deleteElement(item: ElementItem) { emitter.emit(BusEvent.ElementDelete, item) } -function onAlign(align: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') { +function setAlign(align: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') { emitter.emit(BusEvent.ElementAlign, align) } -function onLayer(align: 'up' | 'down' | 'top' | 'bottom') { +function setLayer(align: 'up' | 'down' | 'top' | 'bottom') { emitter.emit(BusEvent.ElementLayer, align) } @@ -34,41 +38,41 @@ function onLayer(align: 'up' | 'down' | 'top' | 'bottom') {

元素层级:

-
+
上移一层
-
+
下移一层
-
+
置于顶层
-
+
置于底层

对齐方式:

-
+
-
+
-
+
-
+
-
+
-
+
@@ -80,11 +84,12 @@ function onLayer(align: 'up' | 'down' | 'top' | 'bottom') { >
-

{{ item.type }} - {{ index + 1 }}

-
diff --git a/src/utils/eventBus.ts b/src/utils/eventBus.ts index 9f94555..f862d30 100644 --- a/src/utils/eventBus.ts +++ b/src/utils/eventBus.ts @@ -1,4 +1,3 @@ -import type { fabric } from 'fabric' import mitt from 'mitt' import type { Emitter } from 'mitt' import type { ElementItem } from '~/stores/player' @@ -8,6 +7,7 @@ export enum BusEvent { ElementPaste = 'element:paste', ElementDelete = 'element:delete', ElementAdd = 'element:add', + ElementSelectAll = 'element:select-all', ElementAlign = 'element:align', ElementFlip = 'element:flip', ElementLayer = 'element:layer', @@ -23,7 +23,7 @@ type BusEvents = { // 粘贴元素 [BusEvent.ElementPaste]: void // 删除元素 - [BusEvent.ElementDelete]: ElementItem + [BusEvent.ElementDelete]: ElementItem | void // 添加元素 [BusEvent.ElementAdd]: { // 元素类型 image | text | svg | gif | music @@ -37,6 +37,8 @@ type BusEvents = { [BusEvent.ElementFlip]: 'x' | 'y' // 元素层级 [BusEvent.ElementLayer]: 'up' | 'down' | 'top' | 'bottom' + // 全选 + [BusEvent.ElementSelectAll]: void // ============================= video ============================= // 视频跳转 [BusEvent.VideoSkip]: number