Merge pull request #65 from JeremyYu-cn/feat-upgrade-vue3

Feat: Convert right-click-menu component and save-download component to vue3
This commit is contained in:
Jeremy Yu 2024-03-04 21:36:03 +00:00 committed by GitHub
commit 65b724ac0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 282 additions and 343 deletions

View File

@ -113,11 +113,14 @@ export default class DragHelper {
private moveFlutter(x: number, y: number, d = 0, lazy = 0) {
const { width, height, finallySize } = this.initial as TInitial
let scale: string | null = null
if (!d) {
if (d) {
if (width > finallySize) {
scale = width - d >= finallySize ? `transform: scale(${(width - d) / width});` : null
} else scale = width + d <= finallySize ? `transform: scale(${(width + d) / width})` : null
} else {
scale = width + d <= finallySize ? `transform: scale(${(width + d) / width})` : null
}
}
const options = [`left: ${x}px`, `top: ${y}px`, `width: ${width}px`, `height: ${height}px`]
scale && options.push(scale)
options.push(`transition: all ${lazy}s`)
@ -137,9 +140,7 @@ export default class DragHelper {
this.dragging = false
store.commit('setDraging', false)
store.commit('selectItem', {})
if (!this.cloneEl) {
return
}
if (!done) {
const { pageX, offsetX, pageY, offsetY } = this.initial as TInitial
this.changeStyle([`left: ${pageX - offsetX}px`, `top: ${pageY - offsetY}px`, 'transform: scale(1)', 'transition: all 0.3s'])

View File

@ -0,0 +1,13 @@
import { ComputedRef, computed } from 'vue'
import { useStore } from 'vuex'
export function useSetupMapGetters<T extends string>(strList: T[]) {
const mapData: Partial<{[x in T]: ComputedRef}> = {}
const getters = useStore().getters
strList.forEach(val => {
mapData[val] = computed(() => getters[val])
})
return mapData as {[x in T]: ComputedRef}
}

View File

@ -15,7 +15,7 @@ interface Options {
}
export default {
upload: async (file: File, options: Options, cb?: IQiniuSubscribeCb) => {
upload: async (file: File | Blob, options: Options, cb?: IQiniuSubscribeCb) => {
const win = window
let name = ''
const suffix = file.type.split('/')[1] || 'png' // 文件后缀

View File

@ -8,7 +8,7 @@
// TODO: Group类型比较特殊所以需要全量循环并判断是否为group
const arr = ['w-text', 'w-image', 'w-svg', 'w-group', 'w-qrcode']
export function getTarget(currentTarget: HTMLElement) {
export function getTarget(currentTarget: HTMLElement): Promise<HTMLElement | null> {
let collector: string[] = []
let groupTarger: HTMLElement | null = null
let saveTarger: HTMLElement | null = null

View File

@ -1,107 +0,0 @@
<!--
* Old Component File
* @Author: ShawnPhang
* @Date: 2023-10-08 14:15:17
* @Description: 手动抠图 - 修补擦除
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-09 01:28:11
-->
<!-- <template>
<div>
<el-dialog v-model="show" align-center width="90%" @close="showMatting = false">
<template #header>
<div class="tool-wrap">
<el-button type="primary" plain @click="done">确认应用</el-button>
<el-radio-group v-model="isErasing" style="margin-left: 35px">
<el-radio :label="false" size="large"> <b>修补画笔</b> <i class="icon sd-xiubu" /></el-radio>
<el-radio :label="true" size="large"> <b>擦除画笔</b> <i class="icon sd-cachu" /></el-radio>
</el-radio-group>
<number-slider v-model="radius" class="slider-wrap" label="画笔尺寸" :showInput="false" labelWidth="90px" :maxValue="constants.RADIUS_SLIDER_MAX" :minValue="constants.RADIUS_SLIDER_MIN" :step="constants.RADIUS_SLIDER_STEP" />
<number-slider v-model="hardness" class="slider-wrap" label="画笔硬度" :showInput="false" labelWidth="90px" :maxValue="constants.HARDNESS_SLIDER_MAX" :minValue="constants.HARDNESS_SLIDER_MIN" :step="constants.HARDNESS_SLIDER_STEP" />
</div>
</template>
<matting v-if="showMatting" :hasHeader="false" @register="mattingStart" />
</el-dialog>
</div>
</template> -->
<script lang="ts">
// import { defineComponent, reactive, toRefs, nextTick } from 'vue'
// import matting, { MattingType } from '@palxp/image-extraction'
// import { ElRadioGroup, ElRadio } from 'element-plus'
// import numberSlider from '@/components/modules/settings/numberSlider.vue'
// export default defineComponent({
// components: { matting, ElRadioGroup, ElRadio, numberSlider },
// setup() {
// const state: any = reactive({
// show: false,
// showMatting: false,
// isErasing: false,
// radius: 0, //
// brushSize: '', //
// hardness: 0, //
// hardnessText: '', //
// constants: {},
// })
// const params: any = { raw: '', result: '' }
// let matting: MattingType | any = {}
// let callback: any = null //
// const mattingStart: any = (mattingOptions: MattingType) => {
// mattingOptions.initLoadImages(params.raw, params.result)
// state.isErasing = mattingOptions.isErasing
// state.radius = mattingOptions.radius
// state.hardness = mattingOptions.hardness
// state.constants = mattingOptions.constants
// matting = mattingOptions
// }
// const open = async (raw: any, result: any, cb: any) => {
// state.show = true
// params.raw = raw
// params.result = result
// await nextTick()
// setTimeout(() => {
// state.showMatting = true
// }, 300)
// callback = cb
// }
// const done = () => {
// state.show = false
// callback(matting.getResult())
// }
// return {
// ...toRefs(state),
// open,
// done,
// mattingStart,
// }
// },
// })
</script>
<style lang="less" scoped>
:deep(.el-dialog__body) {
padding: 0 !important;
}
:deep(.el-dialog__header) {
padding: 10px 35px;
// var(--el-dialog-padding-primary)
}
.tool-wrap {
display: flex;
align-items: center;
}
// .tool-left {
// display: inline-flex;
// flex: 1;
// }
.slider-wrap {
margin-left: 35px;
width: 240px;
}
</style>

View File

@ -2,8 +2,8 @@
* @Author: ShawnPhang
* @Date: 2022-10-08 10:07:19
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-05 00:04:51
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-04 18:10:00
-->
<template>
<el-dialog v-model="state.dialogVisible" title="选择图片" @close="close">

View File

@ -3,7 +3,7 @@
* @Date: 2022-03-16 09:15:52
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-04 09:50:00
* @Date: 2024-03-04 18:50:00
-->
<template>
<div ref="qrCodeDom" class="qrcode__wrap"></div>
@ -11,10 +11,11 @@
<script lang="ts" setup>
import { onMounted, ref, watch, nextTick, defineProps } from 'vue'
import QRCodeStyling, { DrawType, TypeNumber, Mode, ErrorCorrectionLevel, DotType, CornerSquareType, CornerDotType, } from 'qr-code-styling'
import QRCodeStyling, {DotType, Options } from 'qr-code-styling'
import { debounce } from 'throttle-debounce'
import { generateOption } from './method'
type TProps = {
export type TQrcodeProps = {
width?: number
height?: number
image?: string
@ -25,7 +26,7 @@ type TProps = {
}
}
const props = withDefaults(defineProps<TProps>(), {
const props = withDefaults(defineProps<TQrcodeProps>(), {
width: 300,
height: 300,
dotsOptions: () => ({
@ -34,7 +35,7 @@ const props = withDefaults(defineProps<TProps>(), {
})
})
let options = {}
let options: Options = {}
watch(
() => [props.width, props.height, props.dotsOptions],
() => {
@ -43,56 +44,11 @@ watch(
)
const render = debounce(300, false, async () => {
options = {
width: props.width,
height: props.height,
type: 'canvas' as DrawType, // canvas svg
data: props.value,
image: props.image, // /favicon.svg
margin: 0,
qrOptions: {
typeNumber: 3 as TypeNumber,
mode: 'Byte' as Mode,
errorCorrectionLevel: 'M' as ErrorCorrectionLevel,
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.4,
margin: 6,
crossOrigin: 'anonymous',
},
backgroundOptions: {
color: '#ffffff',
},
dotsOptions: {
// color: '#41b583',
// type: 'rounded' as DotType,
...props.dotsOptions,
},
cornersSquareOptions: {
color: props.dotsOptions.color,
type: '',
// type: 'extra-rounded' as CornerSquareType,
// gradient: {
// type: 'linear', // 'radial'
// rotation: 180,
// colorStops: [{ offset: 0, color: '#25456e' }, { offset: 1, color: '#4267b2' }]
// },
},
cornersDotOptions: {
color: props.dotsOptions.color,
type: 'square' as CornerDotType,
// gradient: {
// type: 'linear', // 'radial'
// rotation: 180,
// colorStops: [{ offset: 0, color: '#00266e' }, { offset: 1, color: '#4060b3' }]
// },
},
}
options = generateOption(props)
if (props.value) {
qrCode.update(options)
options && qrCode.update(options)
await nextTick()
if (!qrCodeDom.value || !qrCodeDom.value.firstChild) return
if (!qrCodeDom?.value?.firstChild) return
(qrCodeDom.value.firstChild as HTMLElement).setAttribute('style', "width: 100%;") //
}
})

View File

@ -0,0 +1,60 @@
/*
* @Author: Jeremy Yu
* @Date: 2024-03-04 18:10:00
* @Description:
* @LastEditors: Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-04 18:10:00
*/
import { CornerDotType, Options } from "qr-code-styling"
import { TQrcodeProps } from "./index.vue"
/** 生成二维码数据 */
export function generateOption(props: TQrcodeProps): Options {
return {
width: props.width,
height: props.height,
type: 'canvas', // canvas svg
data: props.value,
image: props.image, // /favicon.svg
margin: 0,
qrOptions: {
typeNumber: 3,
mode: 'Byte',
errorCorrectionLevel: 'M',
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.4,
margin: 6,
crossOrigin: 'anonymous',
},
backgroundOptions: {
color: '#ffffff',
},
dotsOptions: {
// color: '#41b583',
// type: 'rounded' as DotType,
...props.dotsOptions,
},
cornersSquareOptions: {
color: props.dotsOptions.color,
// type: '',
// type: 'extra-rounded' as CornerSquareType,
// gradient: {
// type: 'linear', // 'radial'
// rotation: 180,
// colorStops: [{ offset: 0, color: '#25456e' }, { offset: 1, color: '#4267b2' }]
// },
},
cornersDotOptions: {
color: props.dotsOptions.color,
type: 'square' as CornerDotType,
// gradient: {
// type: 'linear', // 'radial'
// rotation: 180,
// colorStops: [{ offset: 0, color: '#00266e' }, { offset: 1, color: '#4060b3' }]
// },
},
}
}

View File

@ -1,136 +1,142 @@
<template>
<div v-show="showMenuBg" id="menu-bg" class="menu-bg" @click="closeMenu">
<ul ref="menuList" class="menu-list" :style="styleObj">
<li v-for="(item, index) in menuList.list" :key="index" :class="{ 'menu-item': true, 'disable-menu': dCopyElement.length === 0 && item.type === 'paste' }" @click.stop="selectMenu(item.type)">
<li v-for="(item, index) in menuListData.list" :key="index" :class="{ 'menu-item': true, 'disable-menu': dCopyElement.length === 0 && item.type === 'paste' }" @click.stop="selectMenu(item.type)">
{{ item.text }}
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { mapGetters, mapActions } from 'vuex'
import { widgetMenu, pageMenu, menuList } from './rcMenuData'
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useStore } from 'vuex'
import {
widgetMenu as widget,
pageMenu as page,
menuList as menu,
TMenuItemData, TWidgetItemData,
} from './rcMenuData'
import { getTarget } from '@/common/methods/target'
import { useSetupMapGetters } from '@/common/hooks/mapGetters';
export default defineComponent({
setup() {},
data() {
return {
menuList,
showMenuBg: false,
widgetMenu,
pageMenu,
}
},
computed: {
...mapGetters(['dActiveElement', 'dAltDown', 'dWidgets', 'dCopyElement']),
styleObj() {
return {
left: this.menuList.left + 'px',
top: this.menuList.top + 'px',
const store = useStore()
const menuListData = ref<TMenuItemData>({...menu})
const showMenuBg = ref<boolean>(false)
const widgetMenu = ref<TWidgetItemData[]>({...widget})
const pageMenu = ref<TWidgetItemData[]>({...page})
const {dActiveElement, dAltDown, dWidgets, dCopyElement} = useSetupMapGetters(['dActiveElement', 'dAltDown', 'dWidgets', 'dCopyElement'])
const styleObj = computed(() => {
return {
left: menuListData.value.left + 'px',
top: menuListData.value.top + 'px',
}
})
onMounted(() => {
document.oncontextmenu = mouseRightClick
})
async function mouseRightClick(e: MouseEvent) {
e.stopPropagation()
e.preventDefault()
if (showMenuBg.value) {
showMenuBg.value = false
return
}
if (!e.target) return
let target = await getTarget(e.target as HTMLElement)
if (!target) return
let type = target.getAttribute('data-type')
if (type) {
let uuid = target.getAttribute('data-uuid') //
if (uuid !== '-1' && !dAltDown) {
let widget = dWidgets.value.find((item: any) => item.uuid === uuid)
if (
widget.parent !== '-1' &&
widget.parent !== dActiveElement.value.uuid &&
widget.parent !== dActiveElement.value.parent
) {
uuid = widget.parent
}
},
},
mounted() {
document.oncontextmenu = this.mouseRightClick
},
methods: {
...mapActions(['selectWidget', 'copyWidget', 'pasteWidget', 'updateLayerIndex', 'deleteWidget', 'ungroup']),
async mouseRightClick(e: any) {
e.stopPropagation()
e.preventDefault()
if (this.showMenuBg) {
this.showMenuBg = false
}
store.dispatch('selectWidget', {
uuid: uuid ?? '-1',
})
showMenu(e)
}
}
function showMenu(e: MouseEvent) {
let isPage = dActiveElement.value.uuid === '-1'
menuListData.value.list = isPage ? pageMenu.value : widgetMenu.value
if (dActiveElement.value.isContainer) {
let ungroup: TWidgetItemData[] = [
{
type: 'ungroup',
text: '取消组合',
},
]
menuListData.value.list = ungroup.concat(menuListData.value.list)
}
showMenuBg.value = true
// document.getElementById('menu-bg').addEventListener('click', this.closeMenu, false)
let mx = e.pageX
let my = e.pageY
let listWidth = 120
if (mx + listWidth > window.innerWidth) {
mx -= listWidth
}
let listHeight = (14 + 10) * menuListData.value.list.length + 10
if (my + listHeight > window.innerHeight) {
my -= listHeight
}
menuListData.value.left = mx
menuListData.value.top = my
}
function closeMenu() {
showMenuBg.value = false
}
/** 点击菜单触发事件 */
function selectMenu(type: TWidgetItemData['type']) {
switch (type) {
case 'copy':
store.dispatch('copyWidget')
break
case 'paste':
if (dCopyElement.value.length === 0) {
return
}
// let target = e.target
let target = await getTarget(e.target)
store.dispatch('pasteWidget')
break
case 'index-up':
store.dispatch('updateLayerIndex', {
uuid: dActiveElement.value.uuid,
value: 1,
isGroup: dActiveElement.value.isContainer,
})
break
case 'index-down':
store.dispatch('updateLayerIndex', {
uuid: dActiveElement.value.uuid,
value: -1,
isGroup: dActiveElement.value.isContainer,
})
break
case 'del':
store.dispatch('deleteWidget')
break
case 'ungroup':
store.dispatch('ungroup', dActiveElement.value.uuid)
break
}
closeMenu()
}
let type = target.getAttribute('data-type')
if (type) {
let uuid = target.getAttribute('data-uuid') //
if (uuid !== '-1' && !this.dAltDown) {
let widget = this.dWidgets.find((item: any) => item.uuid === uuid)
if (widget.parent !== '-1' && widget.parent !== this.dActiveElement.uuid && widget.parent !== this.dActiveElement.parent) {
uuid = widget.parent
}
}
this.selectWidget({
uuid: uuid || '-1',
})
this.showMenu(e)
}
},
showMenu(e: any) {
let isPage = this.dActiveElement.uuid === '-1'
this.menuList.list = isPage ? this.pageMenu : this.widgetMenu
if (this.dActiveElement.isContainer) {
let ungroup = [
{
type: 'ungroup',
text: '取消组合',
},
]
this.menuList.list = ungroup.concat(this.menuList.list)
}
this.showMenuBg = true
// document.getElementById('menu-bg').addEventListener('click', this.closeMenu, false)
let mx = e.pageX
let my = e.pageY
let listWidth = 120
if (mx + listWidth > window.innerWidth) {
mx -= listWidth
}
let listHeight = (14 + 10) * this.menuList.list.length + 10
if (my + listHeight > window.innerHeight) {
my -= listHeight
}
this.menuList.left = mx
this.menuList.top = my
},
closeMenu() {
this.showMenuBg = false
// document.getElementById('menu-bg').removeEventListener('click', this.closeMenu, false)
},
selectMenu(type) {
switch (type) {
case 'copy':
this.copyWidget()
break
case 'paste':
if (this.dCopyElement.length === 0) {
return
}
this.pasteWidget()
break
case 'index-up':
this.updateLayerIndex({
uuid: this.dActiveElement.uuid,
value: 1,
isGroup: this.dActiveElement.isContainer,
})
break
case 'index-down':
this.updateLayerIndex({
uuid: this.dActiveElement.uuid,
value: -1,
isGroup: this.dActiveElement.isContainer,
})
break
case 'del':
this.deleteWidget()
break
case 'ungroup':
this.ungroup(this.dActiveElement.uuid)
break
}
this.closeMenu()
},
},
})
</script>
<style lang="less" scoped>

View File

@ -2,15 +2,28 @@
* @Author: ShawnPhang
* @Date: 2021-07-30 17:38:50
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2021-07-30 18:15:22
* @LastEditors: ShawnPhang, Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-04 18:50:00
*/
export const menuList: any = {
export type TMenuItemData = {
left: number
top: number
list: TWidgetItemData[]
}
export const menuList: TMenuItemData = {
left: 0,
top: 0,
list: [],
}
export const widgetMenu = [
export type TWidgetItemData = {
type: 'copy' | 'paste' | 'index-up' | 'index-down' | 'del' | 'ungroup'
text: string
}
export const widgetMenu: TWidgetItemData[] = [
{
type: 'copy',
text: '复制',
@ -33,7 +46,7 @@ export const widgetMenu = [
},
]
export const pageMenu = [
export const pageMenu: TWidgetItemData[] = [
{
type: 'paste',
text: '粘贴',

View File

@ -2,66 +2,63 @@
* @Author: ShawnPhang
* @Date: 2021-08-01 11:12:17
* @Description: 前端出图 - 用于封面
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-09-13 17:36:36
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-04 18:50:00
-->
<template>
<div id="cover-wrap"></div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, watch, getCurrentInstance, ComponentInternalInstance } from 'vue'
import { mapGetters, mapActions } from 'vuex'
<script lang="ts" setup>
import { useStore } from 'vuex'
import html2canvas from 'html2canvas'
import api from '@/api'
import Qiniu from '@/common/methods/QiNiu'
import { useSetupMapGetters } from '@/common/hooks/mapGetters'
export default defineComponent({
props: ['modelValue'],
emits: ['update:modelValue'],
setup(props, context) {
const { proxy }: any = getCurrentInstance() as ComponentInternalInstance
const store = useStore();
async function createCover(cb: any) {
const nowZoom = proxy?.dZoom
//
proxy?.selectWidget({
uuid: '-1',
})
proxy?.updateZoom(100)
const opts = {
useCORS: true, //
scale: 0.2,
}
setTimeout(async () => {
const clonePage: HTMLElement = document.getElementById('page-design-canvas').cloneNode(true)
clonePage.setAttribute('id', 'clone-page')
document.body.appendChild(clonePage)
html2canvas(document.getElementById('clone-page'), opts).then((canvas: any) => {
canvas.toBlob(
async (blobObj: Blob) => {
const result: any = await Qiniu.upload(blobObj, { bucket: 'xp-design', prePath: 'cover/user' })
cb(result)
},
'image/jpeg',
0.15,
)
proxy?.updateZoom(nowZoom)
clonePage.remove()
})
}, 10)
}
const { dZoom } = useSetupMapGetters(['dZoom'])
return {
createCover,
}
},
computed: {
...mapGetters(['dZoom']),
},
methods: {
...mapActions(['selectWidget', 'updateZoom']),
},
// props: ['modelValue'],
// emits: ['update:modelValue'],
async function createCover(cb: any) {
const nowZoom = dZoom.value
//
store.dispatch('selectWidget', {
uuid: '-1',
})
store.dispatch('updateZoom', 100)
const opts = {
useCORS: true, //
scale: 0.2,
}
setTimeout(async () => {
const clonePage = document.getElementById('page-design-canvas')?.cloneNode(true) as HTMLElement
if (!clonePage) return
clonePage.setAttribute('id', 'clone-page')
document.body.appendChild(clonePage)
html2canvas(clonePage, opts).then((canvas) => {
canvas.toBlob(
async (blobObj) => {
if (blobObj) {
const result = await Qiniu.upload(blobObj, { bucket: 'xp-design', prePath: 'cover/user' })
cb(result)
}
},
'image/jpeg',
0.15,
)
store.dispatch('updateZoom', nowZoom)
clonePage.remove()
})
}, 10)
}
defineExpose({
createCover
})
</script>

View File

@ -40,7 +40,7 @@ interface IQiniuSubscribeCb {
interface Window {
qiniu: {
upload: (
file: File,
file: File | Blob,
name: string,
token: string,
exObj: Record<string, any>,