Upload 339 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +22 -0
- frontend/src/App.vue +137 -0
- frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 +3 -0
- frontend/src/assets/fonts/CangerXiaowanzi.woff2 +3 -0
- frontend/src/assets/fonts/DeYiHei.woff2 +3 -0
- frontend/src/assets/fonts/FangZhengFangSong.woff2 +3 -0
- frontend/src/assets/fonts/FangZhengHeiTi.woff2 +3 -0
- frontend/src/assets/fonts/FangZhengKaiTi.woff2 +3 -0
- frontend/src/assets/fonts/FangZhengShuSong.woff2 +3 -0
- frontend/src/assets/fonts/FengguangMingrui.woff2 +3 -0
- frontend/src/assets/fonts/LXGWWenKai.woff2 +3 -0
- frontend/src/assets/fonts/MiSans.woff2 +3 -0
- frontend/src/assets/fonts/RuiziZhenyan.woff2 +3 -0
- frontend/src/assets/fonts/ShetuModernSquare.woff2 +3 -0
- frontend/src/assets/fonts/SourceHanSans.woff2 +3 -0
- frontend/src/assets/fonts/SourceHanSerif.woff2 +3 -0
- frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 +3 -0
- frontend/src/assets/fonts/SucaiJishiKangkang.woff2 +3 -0
- frontend/src/assets/fonts/TuniuRounded.woff2 +3 -0
- frontend/src/assets/fonts/WenDingPLKaiTi.woff2 +3 -0
- frontend/src/assets/fonts/YousheTitleBlack.woff2 +3 -0
- frontend/src/assets/fonts/ZcoolHappy.woff2 +3 -0
- frontend/src/assets/fonts/ZhuQueFangSong.woff2 +3 -0
- frontend/src/assets/fonts/ZizhiQuXiMai.woff2 +3 -0
- frontend/src/assets/styles/font.scss +9 -0
- frontend/src/assets/styles/global.scss +138 -0
- frontend/src/assets/styles/mixin.scss +42 -0
- frontend/src/assets/styles/prosemirror.scss +102 -0
- frontend/src/assets/styles/variable.scss +13 -0
- frontend/src/components.d.ts +7 -0
- frontend/src/components/Button.vue +116 -0
- frontend/src/components/ButtonGroup.vue +86 -0
- frontend/src/components/Checkbox.vue +109 -0
- frontend/src/components/CheckboxButton.vue +21 -0
- frontend/src/components/ColorButton.vue +42 -0
- frontend/src/components/ColorListButton.vue +58 -0
- frontend/src/components/ColorPicker/Alpha.vue +107 -0
- frontend/src/components/ColorPicker/Checkboard.vue +60 -0
- frontend/src/components/ColorPicker/EditableInput.vue +69 -0
- frontend/src/components/ColorPicker/Hue.vue +117 -0
- frontend/src/components/ColorPicker/Saturation.vue +108 -0
- frontend/src/components/ColorPicker/index.vue +443 -0
- frontend/src/components/Contextmenu/MenuContent.vue +137 -0
- frontend/src/components/Contextmenu/index.vue +80 -0
- frontend/src/components/Contextmenu/types.ts +14 -0
- frontend/src/components/Divider.vue +34 -0
- frontend/src/components/Drawer.vue +126 -0
- frontend/src/components/FileInput.vue +45 -0
- frontend/src/components/FullscreenSpin.vue +71 -0
- frontend/src/components/GradientBar.vue +149 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,25 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
frontend/src/assets/fonts/CangerXiaowanzi.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
frontend/src/assets/fonts/DeYiHei.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
frontend/src/assets/fonts/FangZhengFangSong.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
frontend/src/assets/fonts/FangZhengHeiTi.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
frontend/src/assets/fonts/FangZhengKaiTi.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
frontend/src/assets/fonts/FangZhengShuSong.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
frontend/src/assets/fonts/FengguangMingrui.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
frontend/src/assets/fonts/LXGWWenKai.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
frontend/src/assets/fonts/MiSans.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
frontend/src/assets/fonts/RuiziZhenyan.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
frontend/src/assets/fonts/ShetuModernSquare.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
frontend/src/assets/fonts/SourceHanSans.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
frontend/src/assets/fonts/SourceHanSerif.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
frontend/src/assets/fonts/SucaiJishiKangkang.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
frontend/src/assets/fonts/TuniuRounded.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
frontend/src/assets/fonts/WenDingPLKaiTi.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
frontend/src/assets/fonts/YousheTitleBlack.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
frontend/src/assets/fonts/ZcoolHappy.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
frontend/src/assets/fonts/ZhuQueFangSong.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
frontend/src/assets/fonts/ZizhiQuXiMai.woff2 filter=lfs diff=lfs merge=lfs -text
|
frontend/src/App.vue
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div id="app">
|
| 3 |
+
<!-- 未登录状态显示登录页面 -->
|
| 4 |
+
<Login v-if="!authStore.isLoggedIn" />
|
| 5 |
+
|
| 6 |
+
<!-- 已登录但数据加载中 -->
|
| 7 |
+
<FullscreenSpin v-else-if="!dataLoaded" tip="数据加载中,请稍等..." loading :mask="false" />
|
| 8 |
+
|
| 9 |
+
<!-- 已登录且数据已加载 -->
|
| 10 |
+
<template v-else>
|
| 11 |
+
<Screen v-if="screening" />
|
| 12 |
+
<Editor v-else-if="_isPC" />
|
| 13 |
+
<Mobile v-else />
|
| 14 |
+
</template>
|
| 15 |
+
</div>
|
| 16 |
+
</template>
|
| 17 |
+
|
| 18 |
+
<script lang="ts" setup>
|
| 19 |
+
import { onMounted, ref } from 'vue'
|
| 20 |
+
import { storeToRefs } from 'pinia'
|
| 21 |
+
import { useScreenStore, useMainStore, useSnapshotStore, useSlidesStore, useAuthStore } from '@/store'
|
| 22 |
+
import { LOCALSTORAGE_KEY_DISCARDED_DB } from '@/configs/storage'
|
| 23 |
+
import { deleteDiscardedDB } from '@/utils/database'
|
| 24 |
+
import { isPC } from '@/utils/common'
|
| 25 |
+
import api from '@/services'
|
| 26 |
+
import dataSyncService from '@/services/dataSyncService'
|
| 27 |
+
|
| 28 |
+
import Editor from './views/Editor/index.vue'
|
| 29 |
+
import Screen from './views/Screen/index.vue'
|
| 30 |
+
import Mobile from './views/Mobile/index.vue'
|
| 31 |
+
import Login from './views/Login.vue'
|
| 32 |
+
import FullscreenSpin from '@/components/FullscreenSpin.vue'
|
| 33 |
+
|
| 34 |
+
const _isPC = isPC()
|
| 35 |
+
const dataLoaded = ref(false)
|
| 36 |
+
|
| 37 |
+
const mainStore = useMainStore()
|
| 38 |
+
const slidesStore = useSlidesStore()
|
| 39 |
+
const snapshotStore = useSnapshotStore()
|
| 40 |
+
const authStore = useAuthStore()
|
| 41 |
+
const { databaseId } = storeToRefs(mainStore)
|
| 42 |
+
const { screening } = storeToRefs(useScreenStore())
|
| 43 |
+
|
| 44 |
+
if (import.meta.env.MODE !== 'development') {
|
| 45 |
+
window.onbeforeunload = () => false
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// 初始化应用数据
|
| 49 |
+
const initializeApp = async () => {
|
| 50 |
+
try {
|
| 51 |
+
// 初始化 DataSyncService(在 Pinia 可用后)
|
| 52 |
+
await dataSyncService.initialize()
|
| 53 |
+
|
| 54 |
+
// 如果用户已登录,加载用户的PPT数据
|
| 55 |
+
if (authStore.isLoggedIn) {
|
| 56 |
+
const pptList = await api.getPPTList()
|
| 57 |
+
|
| 58 |
+
// 如果用户有PPT,加载第一个PPT;否则创建默认PPT
|
| 59 |
+
if (pptList.length > 0) {
|
| 60 |
+
const firstPPT = await api.getPPT(pptList[0].name)
|
| 61 |
+
slidesStore.setSlides(firstPPT.slides)
|
| 62 |
+
slidesStore.setTitle(firstPPT.title)
|
| 63 |
+
if (firstPPT.theme) {
|
| 64 |
+
slidesStore.setTheme(firstPPT.theme)
|
| 65 |
+
}
|
| 66 |
+
// 设置当前PPT ID以便自动保存
|
| 67 |
+
dataSyncService.setCurrentPPTId(pptList[0].name)
|
| 68 |
+
}
|
| 69 |
+
else {
|
| 70 |
+
// 创建默认演示文稿
|
| 71 |
+
const defaultPPT = await api.createPPT('我的第一个演示文稿')
|
| 72 |
+
slidesStore.setSlides(defaultPPT.ppt.slides)
|
| 73 |
+
slidesStore.setTitle(defaultPPT.ppt.title)
|
| 74 |
+
slidesStore.setTheme(defaultPPT.ppt.theme)
|
| 75 |
+
dataSyncService.setCurrentPPTId(defaultPPT.pptId)
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
else {
|
| 79 |
+
// 未登录状态,加载默认示例数据
|
| 80 |
+
const slides = await api.getFileData('slides')
|
| 81 |
+
slidesStore.setSlides(slides)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
await deleteDiscardedDB()
|
| 85 |
+
snapshotStore.initSnapshotDatabase()
|
| 86 |
+
dataLoaded.value = true
|
| 87 |
+
}
|
| 88 |
+
catch (error) {
|
| 89 |
+
// 如果是认证错误,清除登录状态
|
| 90 |
+
if (error === 'Authentication failed') {
|
| 91 |
+
authStore.logout()
|
| 92 |
+
}
|
| 93 |
+
else {
|
| 94 |
+
// 其他错误,加载默认数据
|
| 95 |
+
try {
|
| 96 |
+
const slides = await api.getFileData('slides')
|
| 97 |
+
slidesStore.setSlides(slides)
|
| 98 |
+
dataLoaded.value = true
|
| 99 |
+
}
|
| 100 |
+
catch (fallbackError) {
|
| 101 |
+
// 创建一个空的默认幻灯片
|
| 102 |
+
slidesStore.setSlides([{
|
| 103 |
+
id: 'default-slide',
|
| 104 |
+
elements: [],
|
| 105 |
+
background: { type: 'solid', color: '#ffffff' }
|
| 106 |
+
}])
|
| 107 |
+
dataLoaded.value = true
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
onMounted(async () => {
|
| 114 |
+
// 初始化认证状态
|
| 115 |
+
await authStore.initAuth()
|
| 116 |
+
|
| 117 |
+
// 初始化应用数据
|
| 118 |
+
await initializeApp()
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
// 应用注销时向 localStorage 中记录下本次 indexedDB 的数据库ID,用于之后清除数据库
|
| 122 |
+
window.addEventListener('unload', () => {
|
| 123 |
+
const discardedDB = localStorage.getItem(LOCALSTORAGE_KEY_DISCARDED_DB)
|
| 124 |
+
const discardedDBList: string[] = discardedDB ? JSON.parse(discardedDB) : []
|
| 125 |
+
|
| 126 |
+
discardedDBList.push(databaseId.value)
|
| 127 |
+
|
| 128 |
+
const newDiscardedDB = JSON.stringify(discardedDBList)
|
| 129 |
+
localStorage.setItem(LOCALSTORAGE_KEY_DISCARDED_DB, newDiscardedDB)
|
| 130 |
+
})
|
| 131 |
+
</script>
|
| 132 |
+
|
| 133 |
+
<style lang="scss">
|
| 134 |
+
#app {
|
| 135 |
+
height: 100%;
|
| 136 |
+
}
|
| 137 |
+
</style>
|
frontend/src/assets/fonts/AlibabaPuHuiTi.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e03857d7e181a9201baee2edef8dc6dba054dcb42a4b763cf75bb3dbdee2b321
|
| 3 |
+
size 4150196
|
frontend/src/assets/fonts/CangerXiaowanzi.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:18e5bccdcfa630d8a953ae610cc066f0a2941690fc84aced856062cd2a27d88d
|
| 3 |
+
size 695832
|
frontend/src/assets/fonts/DeYiHei.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ecd63faa61348c4f2480b5fddd8c48fd76f12542328eaa530b2a69badff68ab9
|
| 3 |
+
size 1361616
|
frontend/src/assets/fonts/FangZhengFangSong.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d50e0c75d1688cb49aa3e50dc95ba9eb7a695f6593d805149f4abb78a3dd133b
|
| 3 |
+
size 1538112
|
frontend/src/assets/fonts/FangZhengHeiTi.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:481eee4596a4125253ebf81edf01102298ea85cdb12b850d27863503ccb32518
|
| 3 |
+
size 1189808
|
frontend/src/assets/fonts/FangZhengKaiTi.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a43bcc7319f7c570920c1a7302e2d73cb65378897bdb73a8445cf84559655015
|
| 3 |
+
size 1656040
|
frontend/src/assets/fonts/FangZhengShuSong.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b143be48bb5699ae271a1f81c41872499634e896b9c66c1fe3b28bcc4708a4e0
|
| 3 |
+
size 1185016
|
frontend/src/assets/fonts/FengguangMingrui.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:227d498f6b1ae157e079dccb6cd563f9b89b789b99be9dd660888f928c398660
|
| 3 |
+
size 321284
|
frontend/src/assets/fonts/LXGWWenKai.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5cf730147cd4923546015110f9085216b023df388e2c5ca270b5db17d8de496b
|
| 3 |
+
size 1826736
|
frontend/src/assets/fonts/MiSans.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7d7a4ba4faf18306e446787c1ab1bd1e90c9f27bfa937cd8eb3469c7504e563f
|
| 3 |
+
size 4847960
|
frontend/src/assets/fonts/RuiziZhenyan.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fac4031d38f8497766ac3019b094c2fc91326c4257307b33a6c99b72cb5a529d
|
| 3 |
+
size 616772
|
frontend/src/assets/fonts/ShetuModernSquare.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d6a319f07a79112133b61163eeaa87e632113fccb18a3655a9fbe07a912f1097
|
| 3 |
+
size 790024
|
frontend/src/assets/fonts/SourceHanSans.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:baef3d9c86508d957eee369b1289a1a918a6f1f394f10bd1129332c84f91c671
|
| 3 |
+
size 7511656
|
frontend/src/assets/fonts/SourceHanSerif.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a39a09928cb92aca1e1bf72d841718ecb9c9549c1d1fa95620b8b42f49434db
|
| 3 |
+
size 10413080
|
frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fade334be8d8e9bb68d43d43f7ab811b51433bc92d5201b30be191e026e0c913
|
| 3 |
+
size 218068
|
frontend/src/assets/fonts/SucaiJishiKangkang.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8b874c03c6a6006b7d4f2dbaf01f3c46b51554def44a0ed1f01d126c7ff58ff2
|
| 3 |
+
size 500448
|
frontend/src/assets/fonts/TuniuRounded.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f07953ca9d512298bc9a770d0236e992211ab3cebd6f2afd90e6b0da9da76da4
|
| 3 |
+
size 108684
|
frontend/src/assets/fonts/WenDingPLKaiTi.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5c91fd0d539e35631dd5086451bc37732fa19e9eb9f7202eb881d6616f6d3634
|
| 3 |
+
size 1962288
|
frontend/src/assets/fonts/YousheTitleBlack.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:58d9eeac1664dbadefc309a26e6496f3d03915ec32d78edc66bce4760db74d77
|
| 3 |
+
size 642944
|
frontend/src/assets/fonts/ZcoolHappy.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ee4391316d9e560dbf96d92c7a9a12a73b0ac39b4f77662b43c2bd4fd16dcde7
|
| 3 |
+
size 937400
|
frontend/src/assets/fonts/ZhuQueFangSong.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:81dd160d3c4608a70cc9eb794e4aaf8b1a6b07dec1a5284c48e402f0ffba1459
|
| 3 |
+
size 2598904
|
frontend/src/assets/fonts/ZizhiQuXiMai.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2cce3fff22f295562a2970044c32ba330f32529503ca77c3d8c26649934f65f3
|
| 3 |
+
size 678188
|
frontend/src/assets/styles/font.scss
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
$fonts: 'SourceHanSans', 'SourceHanSerif', 'FangZhengHeiTi', 'FangZhengKaiTi', 'FangZhengShuSong', 'FangZhengFangSong', 'AlibabaPuHuiTi', 'ZhuQueFangSong', 'LXGWWenKai', 'WenDingPLKaiTi', 'DeYiHei', 'MiSans', 'CangerXiaowanzi', 'YousheTitleBlack', 'FengguangMingrui', 'ShetuModernSquare', 'ZcoolHappy', 'ZizhiQuXiMai', 'SucaiJishiKangkang', 'SucaiJishiCoolSquare', 'TuniuRounded', 'RuiziZhenyan';
|
| 2 |
+
|
| 3 |
+
@each $font in $fonts {
|
| 4 |
+
@font-face {
|
| 5 |
+
font-display: swap;
|
| 6 |
+
font-family: $font;
|
| 7 |
+
src: url('https://asset.pptist.cn/font/#{$font}.woff2') format('woff2');
|
| 8 |
+
}
|
| 9 |
+
}
|
frontend/src/assets/styles/global.scss
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
html, body, div, span, applet, object, iframe,
|
| 2 |
+
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
| 3 |
+
a, abbr, acronym, address, big, cite, code,
|
| 4 |
+
del, dfn, em, img, ins, kbd, q, s, samp,
|
| 5 |
+
small, strike, strong, sub, sup, tt, var,
|
| 6 |
+
b, u, i, center,
|
| 7 |
+
dl, dt, dd, ol, ul, li,
|
| 8 |
+
fieldset, form, label, legend,
|
| 9 |
+
table, caption, tbody, tfoot, thead, tr, th, td,
|
| 10 |
+
article, aside, canvas, details, embed,
|
| 11 |
+
figure, figcaption, footer, header, hgroup,
|
| 12 |
+
menu, nav, output, ruby, section, summary,
|
| 13 |
+
time, mark, audio, video {
|
| 14 |
+
margin: 0;
|
| 15 |
+
padding: 0;
|
| 16 |
+
border: 0;
|
| 17 |
+
font-size: 100%;
|
| 18 |
+
vertical-align: baseline;
|
| 19 |
+
box-sizing: border-box;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
*::before,
|
| 23 |
+
*::after {
|
| 24 |
+
box-sizing: border-box;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
article,
|
| 28 |
+
aside,
|
| 29 |
+
details,
|
| 30 |
+
figcaption,
|
| 31 |
+
figure,
|
| 32 |
+
footer,
|
| 33 |
+
header,
|
| 34 |
+
hgroup,
|
| 35 |
+
menu,
|
| 36 |
+
nav,
|
| 37 |
+
section {
|
| 38 |
+
display: block;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
html,
|
| 42 |
+
body {
|
| 43 |
+
width: 100%;
|
| 44 |
+
height: 100%;
|
| 45 |
+
overflow: hidden;
|
| 46 |
+
background-color: #fff;
|
| 47 |
+
color: $textColor;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
body {
|
| 51 |
+
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
ol,
|
| 55 |
+
ul {
|
| 56 |
+
list-style: none;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
blockquote, q {
|
| 60 |
+
quotes: none;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
blockquote::before,
|
| 64 |
+
blockquote::after,
|
| 65 |
+
q::before,
|
| 66 |
+
q::after {
|
| 67 |
+
content: '';
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
table {
|
| 71 |
+
border-collapse: collapse;
|
| 72 |
+
border-spacing: 0;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
a {
|
| 76 |
+
text-decoration: none;
|
| 77 |
+
color: $themeColor;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
img {
|
| 81 |
+
vertical-align: middle;
|
| 82 |
+
border-style: none;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
hr {
|
| 86 |
+
box-sizing: content-box;
|
| 87 |
+
height: 0;
|
| 88 |
+
overflow: visible;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
mark.active {
|
| 92 |
+
background-color: #ff9632;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
input,
|
| 96 |
+
button,
|
| 97 |
+
select,
|
| 98 |
+
optgroup,
|
| 99 |
+
textarea {
|
| 100 |
+
color: inherit;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
button,
|
| 104 |
+
input {
|
| 105 |
+
overflow: visible;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
button,
|
| 109 |
+
select {
|
| 110 |
+
text-transform: none;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
textarea {
|
| 114 |
+
overflow: auto;
|
| 115 |
+
resize: vertical;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
a,
|
| 119 |
+
area,
|
| 120 |
+
button,
|
| 121 |
+
[role='button'],
|
| 122 |
+
input:not([type='range']),
|
| 123 |
+
label,
|
| 124 |
+
select,
|
| 125 |
+
summary,
|
| 126 |
+
textarea {
|
| 127 |
+
touch-action: manipulation;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
::-webkit-scrollbar {
|
| 131 |
+
width: 5px;
|
| 132 |
+
height: 5px;
|
| 133 |
+
background-color: transparent;
|
| 134 |
+
}
|
| 135 |
+
::-webkit-scrollbar-thumb {
|
| 136 |
+
background-color: #e1e1e1;
|
| 137 |
+
border-radius: 3px;
|
| 138 |
+
}
|
frontend/src/assets/styles/mixin.scss
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@mixin ellipsis-oneline() {
|
| 2 |
+
overflow: hidden;
|
| 3 |
+
white-space: nowrap;
|
| 4 |
+
text-overflow: ellipsis;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
@mixin ellipsis-multiline($line: 2) {
|
| 8 |
+
word-wrap: break-word;
|
| 9 |
+
overflow: hidden;
|
| 10 |
+
text-overflow: ellipsis;
|
| 11 |
+
display: -webkit-box;
|
| 12 |
+
-webkit-line-clamp: $line;
|
| 13 |
+
-webkit-box-orient: vertical;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
@mixin flex-grid-layout() {
|
| 17 |
+
display: flex;
|
| 18 |
+
flex-wrap: wrap;
|
| 19 |
+
align-content: flex-start;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@mixin flex-grid-layout-children($col, $colWidth) {
|
| 23 |
+
width: $colWidth;
|
| 24 |
+
margin-bottom: calc(#{100 - $col * $colWidth} / #{$col - 1});
|
| 25 |
+
|
| 26 |
+
&:not(:nth-child(#{$col}n)) {
|
| 27 |
+
margin-right: calc(#{100 - $col * $colWidth} / #{$col - 1});
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
@mixin overflow-overlay() {
|
| 32 |
+
overflow: auto;
|
| 33 |
+
overflow: overlay;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
@mixin absolute-0() {
|
| 37 |
+
position: absolute;
|
| 38 |
+
top: 0;
|
| 39 |
+
right: 0;
|
| 40 |
+
bottom: 0;
|
| 41 |
+
left: 0;
|
| 42 |
+
}
|
frontend/src/assets/styles/prosemirror.scss
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.ProseMirror, .ProseMirror-static {
|
| 2 |
+
outline: 0;
|
| 3 |
+
border: 0;
|
| 4 |
+
font-size: 16px;
|
| 5 |
+
word-break: break-word;
|
| 6 |
+
white-space: normal;
|
| 7 |
+
|
| 8 |
+
&:not(.ProseMirror-static) {
|
| 9 |
+
user-select: text;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
::selection {
|
| 13 |
+
background-color: rgba($themeColor, 0.25);
|
| 14 |
+
color: inherit;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
p {
|
| 18 |
+
margin: 0;
|
| 19 |
+
margin-top: var(--paragraphSpace);
|
| 20 |
+
}
|
| 21 |
+
p:first-child {
|
| 22 |
+
margin-top: 0;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
ul, ol, li {
|
| 26 |
+
margin: 0;
|
| 27 |
+
margin-top: var(--paragraphSpace);
|
| 28 |
+
}
|
| 29 |
+
ul {
|
| 30 |
+
list-style-type: disc;
|
| 31 |
+
padding-inline-start: 1.25em;
|
| 32 |
+
|
| 33 |
+
li {
|
| 34 |
+
list-style-type: inherit;
|
| 35 |
+
padding: 0.125em 0;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
ol {
|
| 40 |
+
list-style-type: decimal;
|
| 41 |
+
padding-inline-start: 1.25em;
|
| 42 |
+
|
| 43 |
+
li {
|
| 44 |
+
list-style-type: inherit;
|
| 45 |
+
padding: 0.125em 0;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
code {
|
| 50 |
+
background-color: #f1f1f1;
|
| 51 |
+
padding: 2px 6px;
|
| 52 |
+
margin: 0 1px;
|
| 53 |
+
border-radius: 4px;
|
| 54 |
+
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
sup {
|
| 58 |
+
vertical-align: super;
|
| 59 |
+
font-size: smaller;
|
| 60 |
+
}
|
| 61 |
+
sub {
|
| 62 |
+
vertical-align: sub;
|
| 63 |
+
font-size: smaller;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
blockquote {
|
| 67 |
+
overflow: hidden;
|
| 68 |
+
padding: 0 1.2em;
|
| 69 |
+
margin: 0.6em 0;
|
| 70 |
+
font-style: italic;
|
| 71 |
+
border-left: 4px solid #e0e0e0;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
[data-indent='1'] {
|
| 75 |
+
padding-left: 1em;
|
| 76 |
+
}
|
| 77 |
+
[data-indent='2'] {
|
| 78 |
+
padding-left: 2em;
|
| 79 |
+
}
|
| 80 |
+
[data-indent='3'] {
|
| 81 |
+
padding-left: 3em;
|
| 82 |
+
}
|
| 83 |
+
[data-indent='4'] {
|
| 84 |
+
padding-left: 4em;
|
| 85 |
+
}
|
| 86 |
+
[data-indent='5'] {
|
| 87 |
+
padding-left: 5em;
|
| 88 |
+
}
|
| 89 |
+
[data-indent='6'] {
|
| 90 |
+
padding-left: 6em;
|
| 91 |
+
}
|
| 92 |
+
[data-indent='7'] {
|
| 93 |
+
padding-left: 7em;
|
| 94 |
+
}
|
| 95 |
+
[data-indent='8'] {
|
| 96 |
+
padding-left: 8em;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.ProseMirror-selectednode {
|
| 101 |
+
outline: none !important;
|
| 102 |
+
}
|
frontend/src/assets/styles/variable.scss
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
$themeColor: #d14424;
|
| 2 |
+
$themeHoverColor: #de6949;
|
| 3 |
+
$textColor: #41464b;
|
| 4 |
+
$borderColor: #e5e7eb;
|
| 5 |
+
$lightGray: #f9f9f9;
|
| 6 |
+
|
| 7 |
+
$boxShadow: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);
|
| 8 |
+
|
| 9 |
+
$transitionDelay: .2s;
|
| 10 |
+
$transitionDelayFast: .1s;
|
| 11 |
+
$transitionDelaySlow: .3s;
|
| 12 |
+
|
| 13 |
+
$borderRadius: 2px;
|
frontend/src/components.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Icons } from '@/plugins/icon'
|
| 2 |
+
|
| 3 |
+
declare module 'vue' {
|
| 4 |
+
export type GlobalComponents = Icons
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export {}
|
frontend/src/components/Button.vue
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<button
|
| 3 |
+
class="button"
|
| 4 |
+
:class="{
|
| 5 |
+
'disabled': disabled,
|
| 6 |
+
'checked': !disabled && checked,
|
| 7 |
+
'default': !disabled && type === 'default',
|
| 8 |
+
'primary': !disabled && type === 'primary',
|
| 9 |
+
'checkbox': !disabled && type === 'checkbox',
|
| 10 |
+
'radio': !disabled && type === 'radio',
|
| 11 |
+
'small': size === 'small',
|
| 12 |
+
'first': first,
|
| 13 |
+
'last': last,
|
| 14 |
+
}"
|
| 15 |
+
@click="handleClick()"
|
| 16 |
+
>
|
| 17 |
+
<slot></slot>
|
| 18 |
+
</button>
|
| 19 |
+
</template>
|
| 20 |
+
|
| 21 |
+
<script lang="ts" setup>
|
| 22 |
+
const props = withDefaults(defineProps<{
|
| 23 |
+
checked?: boolean
|
| 24 |
+
disabled?: boolean
|
| 25 |
+
type?: 'default' | 'primary' | 'checkbox' | 'radio'
|
| 26 |
+
size?: 'small' | 'normal'
|
| 27 |
+
first?: boolean
|
| 28 |
+
last?: boolean
|
| 29 |
+
}>(), {
|
| 30 |
+
checked: false,
|
| 31 |
+
disabled: false,
|
| 32 |
+
type: 'default',
|
| 33 |
+
size: 'normal',
|
| 34 |
+
first: false,
|
| 35 |
+
last: false,
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
const emit = defineEmits<{
|
| 39 |
+
(event: 'click'): void
|
| 40 |
+
}>()
|
| 41 |
+
|
| 42 |
+
const handleClick = () => {
|
| 43 |
+
if (props.disabled) return
|
| 44 |
+
emit('click')
|
| 45 |
+
}
|
| 46 |
+
</script>
|
| 47 |
+
|
| 48 |
+
<style lang="scss" scoped>
|
| 49 |
+
.button {
|
| 50 |
+
height: 32px;
|
| 51 |
+
line-height: 32px;
|
| 52 |
+
outline: 0;
|
| 53 |
+
font-size: 13px;
|
| 54 |
+
padding: 0 15px;
|
| 55 |
+
text-align: center;
|
| 56 |
+
color: $textColor;
|
| 57 |
+
border-radius: $borderRadius;
|
| 58 |
+
user-select: none;
|
| 59 |
+
letter-spacing: 1px;
|
| 60 |
+
cursor: pointer;
|
| 61 |
+
|
| 62 |
+
&.small {
|
| 63 |
+
height: 24px;
|
| 64 |
+
line-height: 24px;
|
| 65 |
+
padding: 0 7px;
|
| 66 |
+
letter-spacing: 0;
|
| 67 |
+
font-size: 12px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
&.default {
|
| 71 |
+
background-color: #fff;
|
| 72 |
+
border: 1px solid #d9d9d9;
|
| 73 |
+
color: $textColor;
|
| 74 |
+
|
| 75 |
+
&:hover {
|
| 76 |
+
color: $themeColor;
|
| 77 |
+
border-color: $themeColor;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
&.primary {
|
| 81 |
+
background-color: $themeColor;
|
| 82 |
+
border: 1px solid $themeColor;
|
| 83 |
+
color: #fff;
|
| 84 |
+
|
| 85 |
+
&:hover {
|
| 86 |
+
background-color: $themeHoverColor;
|
| 87 |
+
border-color: $themeHoverColor;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
&.checkbox, &.radio {
|
| 91 |
+
background-color: #fff;
|
| 92 |
+
border: 1px solid #d9d9d9;
|
| 93 |
+
color: $textColor;
|
| 94 |
+
|
| 95 |
+
&:not(.checked):hover {
|
| 96 |
+
color: $themeColor;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
&.checked {
|
| 100 |
+
color: #fff;
|
| 101 |
+
background-color: $themeColor;
|
| 102 |
+
border-color: $themeColor;
|
| 103 |
+
|
| 104 |
+
&:hover {
|
| 105 |
+
background-color: $themeHoverColor;
|
| 106 |
+
border-color: $themeHoverColor;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
&.disabled {
|
| 110 |
+
background-color: #f5f5f5;
|
| 111 |
+
border: 1px solid #d9d9d9;
|
| 112 |
+
color: #b7b7b7;
|
| 113 |
+
cursor: default;
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
</style>
|
frontend/src/components/ButtonGroup.vue
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="button-group" :class="{ 'passive': passive }" ref="groupRef">
|
| 3 |
+
<slot></slot>
|
| 4 |
+
</div>
|
| 5 |
+
</template>
|
| 6 |
+
|
| 7 |
+
<script lang="ts" setup>
|
| 8 |
+
withDefaults(defineProps<{
|
| 9 |
+
passive?: boolean
|
| 10 |
+
}>(), {
|
| 11 |
+
passive: false,
|
| 12 |
+
})
|
| 13 |
+
</script>
|
| 14 |
+
|
| 15 |
+
<style lang="scss" scoped>
|
| 16 |
+
.button-group {
|
| 17 |
+
display: flex;
|
| 18 |
+
align-items: center;
|
| 19 |
+
|
| 20 |
+
::v-deep(button.button) {
|
| 21 |
+
border-radius: 0;
|
| 22 |
+
border-left-width: 1px;
|
| 23 |
+
border-right-width: 0;
|
| 24 |
+
display: inline-block;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
&:not(.passive) {
|
| 28 |
+
::v-deep(button.button) {
|
| 29 |
+
&:not(:last-child, .radio, .checkbox):hover {
|
| 30 |
+
position: relative;
|
| 31 |
+
|
| 32 |
+
&::after {
|
| 33 |
+
content: '';
|
| 34 |
+
width: 1px;
|
| 35 |
+
height: calc(100% + 2px);
|
| 36 |
+
background-color: $themeColor;
|
| 37 |
+
position: absolute;
|
| 38 |
+
top: -1px;
|
| 39 |
+
right: -1px;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
&:first-child {
|
| 44 |
+
border-top-left-radius: $borderRadius;
|
| 45 |
+
border-bottom-left-radius: $borderRadius;
|
| 46 |
+
border-left-width: 1px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
&:last-child {
|
| 50 |
+
border-top-right-radius: $borderRadius;
|
| 51 |
+
border-bottom-right-radius: $borderRadius;
|
| 52 |
+
border-right-width: 1px;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
&.passive {
|
| 57 |
+
::v-deep(button.button) {
|
| 58 |
+
&:not(.last, .radio, .checkbox):hover {
|
| 59 |
+
position: relative;
|
| 60 |
+
|
| 61 |
+
&::after {
|
| 62 |
+
content: '';
|
| 63 |
+
width: 1px;
|
| 64 |
+
height: calc(100% + 2px);
|
| 65 |
+
background-color: $themeColor;
|
| 66 |
+
position: absolute;
|
| 67 |
+
top: -1px;
|
| 68 |
+
right: -1px;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
&.first {
|
| 73 |
+
border-top-left-radius: $borderRadius;
|
| 74 |
+
border-bottom-left-radius: $borderRadius;
|
| 75 |
+
border-left-width: 1px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
&.last {
|
| 79 |
+
border-top-right-radius: $borderRadius;
|
| 80 |
+
border-bottom-right-radius: $borderRadius;
|
| 81 |
+
border-right-width: 1px;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
</style>
|
frontend/src/components/Checkbox.vue
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<label
|
| 3 |
+
class="checkbox"
|
| 4 |
+
:class="{
|
| 5 |
+
'checked': value,
|
| 6 |
+
'disabled': disabled,
|
| 7 |
+
}"
|
| 8 |
+
@change="$event => handleChange($event)"
|
| 9 |
+
>
|
| 10 |
+
<span class="checkbox-input"></span>
|
| 11 |
+
<input class="checkbox-original" type="checkbox" :checked="value">
|
| 12 |
+
<span class="checkbox-label">
|
| 13 |
+
<slot></slot>
|
| 14 |
+
</span>
|
| 15 |
+
</label>
|
| 16 |
+
</template>
|
| 17 |
+
|
| 18 |
+
<script lang="ts" setup>
|
| 19 |
+
const props = withDefaults(defineProps<{
|
| 20 |
+
value: boolean
|
| 21 |
+
disabled?: boolean
|
| 22 |
+
}>(), {
|
| 23 |
+
disabled: false,
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
const emit = defineEmits<{
|
| 27 |
+
(event: 'update:value', payload: boolean): void
|
| 28 |
+
}>()
|
| 29 |
+
|
| 30 |
+
const handleChange = (e: Event) => {
|
| 31 |
+
if (props.disabled) return
|
| 32 |
+
emit('update:value', (e.target as HTMLInputElement).checked)
|
| 33 |
+
}
|
| 34 |
+
</script>
|
| 35 |
+
|
| 36 |
+
<style lang="scss" scoped>
|
| 37 |
+
.checkbox {
|
| 38 |
+
height: 20px;
|
| 39 |
+
display: flex;
|
| 40 |
+
align-items: center;
|
| 41 |
+
cursor: pointer;
|
| 42 |
+
|
| 43 |
+
&:not(.disabled).checked {
|
| 44 |
+
.checkbox-input {
|
| 45 |
+
background-color: $themeColor;
|
| 46 |
+
border-color: $themeColor;
|
| 47 |
+
}
|
| 48 |
+
.checkbox-input::after {
|
| 49 |
+
transform: rotate(45deg) scaleY(1);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.checkbox-label {
|
| 53 |
+
color: $themeColor;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
&.disabled {
|
| 58 |
+
color: #b7b7b7;
|
| 59 |
+
cursor: default;
|
| 60 |
+
|
| 61 |
+
.checkbox-input {
|
| 62 |
+
background-color: #f5f5f5;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.checkbox-input {
|
| 68 |
+
display: inline-block;
|
| 69 |
+
position: relative;
|
| 70 |
+
border: 1px solid #d9d9d9;
|
| 71 |
+
border-radius: $borderRadius;
|
| 72 |
+
width: 16px;
|
| 73 |
+
height: 16px;
|
| 74 |
+
background-color: #fff;
|
| 75 |
+
vertical-align: middle;
|
| 76 |
+
transition: border-color .15s cubic-bezier(.71, -.46, .29, 1.46), background-color .15s cubic-bezier(.71, -.46, .29, 1.46);
|
| 77 |
+
z-index: 1;
|
| 78 |
+
|
| 79 |
+
&::after {
|
| 80 |
+
content: '';
|
| 81 |
+
border: 2px solid #fff;
|
| 82 |
+
border-left: 0;
|
| 83 |
+
border-top: 0;
|
| 84 |
+
height: 9px;
|
| 85 |
+
left: 4px;
|
| 86 |
+
position: absolute;
|
| 87 |
+
top: 1px;
|
| 88 |
+
transform: rotate(45deg) scaleY(0);
|
| 89 |
+
width: 6px;
|
| 90 |
+
transition: transform .15s ease-in .05s;
|
| 91 |
+
transform-origin: center;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
.checkbox-original {
|
| 95 |
+
opacity: 0;
|
| 96 |
+
outline: 0;
|
| 97 |
+
position: absolute;
|
| 98 |
+
margin: 0;
|
| 99 |
+
width: 0;
|
| 100 |
+
height: 0;
|
| 101 |
+
z-index: -1;
|
| 102 |
+
}
|
| 103 |
+
.checkbox-label {
|
| 104 |
+
margin-left: 5px;
|
| 105 |
+
line-height: 20px;
|
| 106 |
+
font-size: 13px;
|
| 107 |
+
user-select: none;
|
| 108 |
+
}
|
| 109 |
+
</style>
|
frontend/src/components/CheckboxButton.vue
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Button
|
| 3 |
+
:checked="checked"
|
| 4 |
+
:disabled="disabled"
|
| 5 |
+
type="checkbox"
|
| 6 |
+
>
|
| 7 |
+
<slot></slot>
|
| 8 |
+
</Button>
|
| 9 |
+
</template>
|
| 10 |
+
|
| 11 |
+
<script lang="ts" setup>
|
| 12 |
+
import Button from './Button.vue'
|
| 13 |
+
|
| 14 |
+
withDefaults(defineProps<{
|
| 15 |
+
checked?: boolean
|
| 16 |
+
disabled?: boolean
|
| 17 |
+
}>(), {
|
| 18 |
+
checked: false,
|
| 19 |
+
disabled: false,
|
| 20 |
+
})
|
| 21 |
+
</script>
|
frontend/src/components/ColorButton.vue
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Button class="color-btn">
|
| 3 |
+
<div class="color-block">
|
| 4 |
+
<div class="content" :style="{ backgroundColor: color }"></div>
|
| 5 |
+
</div>
|
| 6 |
+
<IconPlatte class="color-btn-icon" />
|
| 7 |
+
</Button>
|
| 8 |
+
</template>
|
| 9 |
+
|
| 10 |
+
<script lang="ts" setup>
|
| 11 |
+
import Button from './Button.vue'
|
| 12 |
+
|
| 13 |
+
defineProps<{
|
| 14 |
+
color: string
|
| 15 |
+
}>()
|
| 16 |
+
</script>
|
| 17 |
+
|
| 18 |
+
<style lang="scss" scoped>
|
| 19 |
+
.color-btn {
|
| 20 |
+
width: 100%;
|
| 21 |
+
display: flex !important;
|
| 22 |
+
align-items: center;
|
| 23 |
+
justify-content: center;
|
| 24 |
+
padding: 0 !important;
|
| 25 |
+
}
|
| 26 |
+
.color-block {
|
| 27 |
+
height: 20px;
|
| 28 |
+
margin-left: 8px;
|
| 29 |
+
flex: 1;
|
| 30 |
+
outline: 1px dashed rgba($color: #666, $alpha: .12);
|
| 31 |
+
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAAEBJREFUOE9jfPbs2X8GIoCkpCQRqhgYGEcNxBlOo2GIM2iGQLL5//8/UTnl+fPnxOWUUQNxhtNoGOLOKYM+2QAAh2Nq10DwkukAAAAASUVORK5CYII=);
|
| 32 |
+
}
|
| 33 |
+
.content {
|
| 34 |
+
width: 100%;
|
| 35 |
+
height: 100%;
|
| 36 |
+
}
|
| 37 |
+
.color-btn-icon {
|
| 38 |
+
width: 32px;
|
| 39 |
+
font-size: 13px;
|
| 40 |
+
color: #bfbfbf;
|
| 41 |
+
}
|
| 42 |
+
</style>
|
frontend/src/components/ColorListButton.vue
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Button class="color-btn">
|
| 3 |
+
<div class="blocks">
|
| 4 |
+
<div class="color-block" v-for="(color, index) in colors" :key="index">
|
| 5 |
+
<div class="content" :style="{ backgroundColor: color }"></div>
|
| 6 |
+
</div>
|
| 7 |
+
</div>
|
| 8 |
+
<IconPlatte class="color-btn-icon" />
|
| 9 |
+
</Button>
|
| 10 |
+
</template>
|
| 11 |
+
|
| 12 |
+
<script lang="ts" setup>
|
| 13 |
+
import { computed } from 'vue'
|
| 14 |
+
import Button from './Button.vue'
|
| 15 |
+
|
| 16 |
+
const props = defineProps<{
|
| 17 |
+
colors: string[]
|
| 18 |
+
}>()
|
| 19 |
+
|
| 20 |
+
const colors = computed(() => {
|
| 21 |
+
if (props.colors.length > 12) return props.colors.slice(0, 12)
|
| 22 |
+
return props.colors
|
| 23 |
+
})
|
| 24 |
+
</script>
|
| 25 |
+
|
| 26 |
+
<style lang="scss" scoped>
|
| 27 |
+
.color-btn {
|
| 28 |
+
width: 100%;
|
| 29 |
+
display: flex !important;
|
| 30 |
+
align-items: center;
|
| 31 |
+
justify-content: center;
|
| 32 |
+
padding: 0 !important;
|
| 33 |
+
}
|
| 34 |
+
.blocks {
|
| 35 |
+
display: flex;
|
| 36 |
+
flex: 1;
|
| 37 |
+
margin-left: 8px;
|
| 38 |
+
outline: 1px dashed rgba($color: #666, $alpha: .12);
|
| 39 |
+
}
|
| 40 |
+
.color-block {
|
| 41 |
+
height: 20px;
|
| 42 |
+
flex: 1;
|
| 43 |
+
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAAEBJREFUOE9jfPbs2X8GIoCkpCQRqhgYGEcNxBlOo2GIM2iGQLL5//8/UTnl+fPnxOWUUQNxhtNoGOLOKYM+2QAAh2Nq10DwkukAAAAASUVORK5CYII=);
|
| 44 |
+
|
| 45 |
+
& + & {
|
| 46 |
+
margin-left: 2px;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
.content {
|
| 50 |
+
width: 100%;
|
| 51 |
+
height: 100%;
|
| 52 |
+
}
|
| 53 |
+
.color-btn-icon {
|
| 54 |
+
width: 32px;
|
| 55 |
+
font-size: 13px;
|
| 56 |
+
color: #bfbfbf;
|
| 57 |
+
}
|
| 58 |
+
</style>
|
frontend/src/components/ColorPicker/Alpha.vue
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="alpha">
|
| 3 |
+
<div class="alpha-checkboard-wrap">
|
| 4 |
+
<Checkboard />
|
| 5 |
+
</div>
|
| 6 |
+
<div class="alpha-gradient" :style="{ background: gradientColor }"></div>
|
| 7 |
+
<div
|
| 8 |
+
class="alpha-container"
|
| 9 |
+
ref="alphaRef"
|
| 10 |
+
@mousedown="$event => handleMouseDown($event)"
|
| 11 |
+
>
|
| 12 |
+
<div class="alpha-pointer" :style="{ left: color.a * 100 + '%' }">
|
| 13 |
+
<div class="alpha-picker"></div>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
</div>
|
| 17 |
+
</template>
|
| 18 |
+
|
| 19 |
+
<script lang="ts" setup>
|
| 20 |
+
import { computed, onUnmounted, ref } from 'vue'
|
| 21 |
+
|
| 22 |
+
import Checkboard from './Checkboard.vue'
|
| 23 |
+
import type { ColorFormats } from 'tinycolor2'
|
| 24 |
+
|
| 25 |
+
const props = defineProps<{
|
| 26 |
+
value: ColorFormats.RGBA
|
| 27 |
+
}>()
|
| 28 |
+
|
| 29 |
+
const emit = defineEmits<{
|
| 30 |
+
(event: 'colorChange', payload: ColorFormats.RGBA): void
|
| 31 |
+
}>()
|
| 32 |
+
|
| 33 |
+
const color = computed(() => props.value)
|
| 34 |
+
|
| 35 |
+
const gradientColor = computed(() => {
|
| 36 |
+
const rgbaStr = [color.value.r, color.value.g, color.value.b].join(',')
|
| 37 |
+
return `linear-gradient(to right, rgba(${rgbaStr}, 0) 0%, rgba(${rgbaStr}, 1) 100%)`
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
const alphaRef = ref<HTMLElement>()
|
| 41 |
+
const handleChange = (e: MouseEvent) => {
|
| 42 |
+
e.preventDefault()
|
| 43 |
+
if (!alphaRef.value) return
|
| 44 |
+
const containerWidth = alphaRef.value.clientWidth
|
| 45 |
+
const xOffset = alphaRef.value.getBoundingClientRect().left + window.pageXOffset
|
| 46 |
+
const left = e.pageX - xOffset
|
| 47 |
+
let a
|
| 48 |
+
|
| 49 |
+
if (left < 0) a = 0
|
| 50 |
+
else if (left > containerWidth) a = 1
|
| 51 |
+
else a = Math.round(left * 100 / containerWidth) / 100
|
| 52 |
+
|
| 53 |
+
if (color.value.a !== a) {
|
| 54 |
+
emit('colorChange', {
|
| 55 |
+
r: color.value.r,
|
| 56 |
+
g: color.value.g,
|
| 57 |
+
b: color.value.b,
|
| 58 |
+
a: a,
|
| 59 |
+
})
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const unbindEventListeners = () => {
|
| 64 |
+
window.removeEventListener('mousemove', handleChange)
|
| 65 |
+
window.removeEventListener('mouseup', unbindEventListeners)
|
| 66 |
+
}
|
| 67 |
+
const handleMouseDown = (e: MouseEvent) => {
|
| 68 |
+
handleChange(e)
|
| 69 |
+
window.addEventListener('mousemove', handleChange)
|
| 70 |
+
window.addEventListener('mouseup', unbindEventListeners)
|
| 71 |
+
}
|
| 72 |
+
onUnmounted(unbindEventListeners)
|
| 73 |
+
</script>
|
| 74 |
+
|
| 75 |
+
<style lang="scss" scoped>
|
| 76 |
+
.alpha {
|
| 77 |
+
@include absolute-0();
|
| 78 |
+
}
|
| 79 |
+
.alpha-checkboard-wrap {
|
| 80 |
+
overflow: hidden;
|
| 81 |
+
|
| 82 |
+
@include absolute-0();
|
| 83 |
+
}
|
| 84 |
+
.alpha-gradient {
|
| 85 |
+
@include absolute-0();
|
| 86 |
+
}
|
| 87 |
+
.alpha-container {
|
| 88 |
+
cursor: pointer;
|
| 89 |
+
position: relative;
|
| 90 |
+
z-index: 2;
|
| 91 |
+
height: 100%;
|
| 92 |
+
margin: 0 3px;
|
| 93 |
+
}
|
| 94 |
+
.alpha-pointer {
|
| 95 |
+
z-index: 2;
|
| 96 |
+
position: absolute;
|
| 97 |
+
}
|
| 98 |
+
.alpha-picker {
|
| 99 |
+
cursor: pointer;
|
| 100 |
+
width: 4px;
|
| 101 |
+
height: 8px;
|
| 102 |
+
box-shadow: 0 0 2px rgba(0, 0, 0, .6);
|
| 103 |
+
background: #fff;
|
| 104 |
+
margin-top: 1px;
|
| 105 |
+
transform: translateX(-2px);
|
| 106 |
+
}
|
| 107 |
+
</style>
|
frontend/src/components/ColorPicker/Checkboard.vue
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="checkerboard" :style="bgStyle"></div>
|
| 3 |
+
</template>
|
| 4 |
+
|
| 5 |
+
<script lang="ts" setup>
|
| 6 |
+
import { computed } from 'vue'
|
| 7 |
+
|
| 8 |
+
const props = withDefaults(defineProps<{
|
| 9 |
+
size?: number
|
| 10 |
+
white?: string
|
| 11 |
+
grey?: string
|
| 12 |
+
}>(), {
|
| 13 |
+
size: 8,
|
| 14 |
+
white: '#fff',
|
| 15 |
+
grey: '#e6e6e6',
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
interface CheckboardCache {
|
| 19 |
+
[key: string]: string | null
|
| 20 |
+
}
|
| 21 |
+
const checkboardCache: CheckboardCache = {}
|
| 22 |
+
|
| 23 |
+
const renderCheckboard = (white: string, grey: string, size: number) => {
|
| 24 |
+
const canvas = document.createElement('canvas')
|
| 25 |
+
canvas.width = canvas.height = size * 2
|
| 26 |
+
const ctx = canvas.getContext('2d')
|
| 27 |
+
|
| 28 |
+
if (!ctx) return null
|
| 29 |
+
|
| 30 |
+
ctx.fillStyle = white
|
| 31 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
| 32 |
+
ctx.fillStyle = grey
|
| 33 |
+
ctx.fillRect(0, 0, size, size)
|
| 34 |
+
ctx.translate(size, size)
|
| 35 |
+
ctx.fillRect(0, 0, size, size)
|
| 36 |
+
return canvas.toDataURL()
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const getCheckboard = (white: string, grey: string, size: number) => {
|
| 40 |
+
const key = white + ',' + grey + ',' + size
|
| 41 |
+
if (checkboardCache[key]) return checkboardCache[key]
|
| 42 |
+
|
| 43 |
+
const checkboard = renderCheckboard(white, grey, size)
|
| 44 |
+
checkboardCache[key] = checkboard
|
| 45 |
+
return checkboard
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const bgStyle = computed(() => {
|
| 49 |
+
const checkboard = getCheckboard(props.white, props.grey, props.size)
|
| 50 |
+
return { backgroundImage: `url(${checkboard})` }
|
| 51 |
+
})
|
| 52 |
+
</script>
|
| 53 |
+
|
| 54 |
+
<style lang="scss" scoped>
|
| 55 |
+
.checkerboard {
|
| 56 |
+
background-size: contain;
|
| 57 |
+
|
| 58 |
+
@include absolute-0();
|
| 59 |
+
}
|
| 60 |
+
</style>
|
frontend/src/components/ColorPicker/EditableInput.vue
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="editable-input">
|
| 3 |
+
<input
|
| 4 |
+
class="input-content"
|
| 5 |
+
:value="val"
|
| 6 |
+
@input="$event => handleInput($event)"
|
| 7 |
+
>
|
| 8 |
+
</div>
|
| 9 |
+
</template>
|
| 10 |
+
|
| 11 |
+
<script lang="ts" setup>
|
| 12 |
+
import { computed } from 'vue'
|
| 13 |
+
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
| 14 |
+
|
| 15 |
+
const props = defineProps<{
|
| 16 |
+
value: ColorFormats.RGBA
|
| 17 |
+
}>()
|
| 18 |
+
|
| 19 |
+
const emit = defineEmits<{
|
| 20 |
+
(event: 'colorChange', payload: ColorFormats.RGBA): void
|
| 21 |
+
}>()
|
| 22 |
+
|
| 23 |
+
const val = computed(() => {
|
| 24 |
+
let _hex = ''
|
| 25 |
+
if (props.value.a < 1) _hex = tinycolor(props.value).toHex8String().toUpperCase()
|
| 26 |
+
else _hex = tinycolor(props.value).toHexString().toUpperCase()
|
| 27 |
+
return _hex.replace('#', '')
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
const handleInput = (e: Event) => {
|
| 31 |
+
const value = (e.target as HTMLInputElement).value
|
| 32 |
+
if (value.length >= 6) {
|
| 33 |
+
const color = tinycolor(value)
|
| 34 |
+
if (color.isValid()) {
|
| 35 |
+
emit('colorChange', color.toRgb())
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
</script>
|
| 40 |
+
|
| 41 |
+
<style lang="scss" scoped>
|
| 42 |
+
.editable-input {
|
| 43 |
+
width: 100%;
|
| 44 |
+
position: relative;
|
| 45 |
+
overflow: hidden;
|
| 46 |
+
text-align: center;
|
| 47 |
+
font-size: 14px;
|
| 48 |
+
|
| 49 |
+
&::after {
|
| 50 |
+
content: '#';
|
| 51 |
+
position: absolute;
|
| 52 |
+
left: 0;
|
| 53 |
+
top: 50%;
|
| 54 |
+
transform: translateY(-50%);
|
| 55 |
+
color: #999;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
.input-content {
|
| 59 |
+
width: 100%;
|
| 60 |
+
padding: 3px;
|
| 61 |
+
border: 0;
|
| 62 |
+
border-bottom: 1px solid #ddd;
|
| 63 |
+
outline: none;
|
| 64 |
+
text-align: center;
|
| 65 |
+
}
|
| 66 |
+
.input-label {
|
| 67 |
+
text-transform: capitalize;
|
| 68 |
+
}
|
| 69 |
+
</style>
|
frontend/src/components/ColorPicker/Hue.vue
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="hue">
|
| 3 |
+
<div
|
| 4 |
+
class="hue-container"
|
| 5 |
+
ref="hueRef"
|
| 6 |
+
@mousedown="$event => handleMouseDown($event)"
|
| 7 |
+
>
|
| 8 |
+
<div
|
| 9 |
+
class="hue-pointer"
|
| 10 |
+
:style="{ left: pointerLeft }"
|
| 11 |
+
>
|
| 12 |
+
<div class="hue-picker"></div>
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
</template>
|
| 17 |
+
|
| 18 |
+
<script lang="ts" setup>
|
| 19 |
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
| 20 |
+
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
| 21 |
+
|
| 22 |
+
const props = defineProps<{
|
| 23 |
+
value: ColorFormats.RGBA
|
| 24 |
+
hue: number
|
| 25 |
+
}>()
|
| 26 |
+
|
| 27 |
+
const emit = defineEmits<{
|
| 28 |
+
(event: 'colorChange', payload: ColorFormats.HSLA): void
|
| 29 |
+
}>()
|
| 30 |
+
|
| 31 |
+
const oldHue = ref(0)
|
| 32 |
+
const pullDirection = ref('')
|
| 33 |
+
|
| 34 |
+
const color = computed(() => {
|
| 35 |
+
const hsla = tinycolor(props.value).toHsl()
|
| 36 |
+
if (props.hue !== -1) hsla.h = props.hue
|
| 37 |
+
return hsla
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
const pointerLeft = computed(() => {
|
| 41 |
+
if (color.value.h === 0 && pullDirection.value === 'right') return '100%'
|
| 42 |
+
return color.value.h * 100 / 360 + '%'
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
watch(() => props.value, () => {
|
| 46 |
+
const hsla = tinycolor(props.value).toHsl()
|
| 47 |
+
const h = hsla.s === 0 ? props.hue : hsla.h
|
| 48 |
+
if (h !== 0 && h - oldHue.value > 0) pullDirection.value = 'right'
|
| 49 |
+
if (h !== 0 && h - oldHue.value < 0) pullDirection.value = 'left'
|
| 50 |
+
oldHue.value = h
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
const hueRef = ref<HTMLElement>()
|
| 54 |
+
const handleChange = (e: MouseEvent) => {
|
| 55 |
+
e.preventDefault()
|
| 56 |
+
if (!hueRef.value) return
|
| 57 |
+
|
| 58 |
+
const containerWidth = hueRef.value.clientWidth
|
| 59 |
+
const xOffset = hueRef.value.getBoundingClientRect().left + window.pageXOffset
|
| 60 |
+
const left = e.pageX - xOffset
|
| 61 |
+
let h, percent
|
| 62 |
+
|
| 63 |
+
if (left < 0) h = 0
|
| 64 |
+
else if (left > containerWidth) h = 360
|
| 65 |
+
else {
|
| 66 |
+
percent = left * 100 / containerWidth
|
| 67 |
+
h = 360 * percent / 100
|
| 68 |
+
}
|
| 69 |
+
if (props.hue === -1 || color.value.h !== h) {
|
| 70 |
+
emit('colorChange', {
|
| 71 |
+
h,
|
| 72 |
+
l: color.value.l,
|
| 73 |
+
s: color.value.s,
|
| 74 |
+
a: color.value.a,
|
| 75 |
+
})
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const unbindEventListeners = () => {
|
| 80 |
+
window.removeEventListener('mousemove', handleChange)
|
| 81 |
+
window.removeEventListener('mouseup', unbindEventListeners)
|
| 82 |
+
}
|
| 83 |
+
const handleMouseDown = (e: MouseEvent) => {
|
| 84 |
+
handleChange(e)
|
| 85 |
+
window.addEventListener('mousemove', handleChange)
|
| 86 |
+
window.addEventListener('mouseup', unbindEventListeners)
|
| 87 |
+
}
|
| 88 |
+
onUnmounted(unbindEventListeners)
|
| 89 |
+
</script>
|
| 90 |
+
|
| 91 |
+
<style lang="scss" scoped>
|
| 92 |
+
.hue {
|
| 93 |
+
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
|
| 94 |
+
|
| 95 |
+
@include absolute-0();
|
| 96 |
+
}
|
| 97 |
+
.hue-container {
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
margin: 0 2px;
|
| 100 |
+
position: relative;
|
| 101 |
+
height: 100%;
|
| 102 |
+
}
|
| 103 |
+
.hue-pointer {
|
| 104 |
+
z-index: 2;
|
| 105 |
+
position: absolute;
|
| 106 |
+
top: 0;
|
| 107 |
+
}
|
| 108 |
+
.hue-picker {
|
| 109 |
+
cursor: pointer;
|
| 110 |
+
margin-top: 1px;
|
| 111 |
+
width: 4px;
|
| 112 |
+
height: 8px;
|
| 113 |
+
box-shadow: 0 0 2px rgba(0, 0, 0, .6);
|
| 114 |
+
background: #fff;
|
| 115 |
+
transform: translateX(-2px);
|
| 116 |
+
}
|
| 117 |
+
</style>
|
frontend/src/components/ColorPicker/Saturation.vue
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div
|
| 3 |
+
class="saturation"
|
| 4 |
+
ref="saturationRef"
|
| 5 |
+
:style="{ background: bgColor }"
|
| 6 |
+
@mousedown="$event => handleMouseDown($event)"
|
| 7 |
+
>
|
| 8 |
+
<div class="saturation-white"></div>
|
| 9 |
+
<div class="saturation-black"></div>
|
| 10 |
+
<div class="saturation-pointer"
|
| 11 |
+
:style="{
|
| 12 |
+
top: pointerTop,
|
| 13 |
+
left: pointerLeft,
|
| 14 |
+
}"
|
| 15 |
+
>
|
| 16 |
+
<div class="saturation-circle"></div>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
</template>
|
| 20 |
+
|
| 21 |
+
<script lang="ts" setup>
|
| 22 |
+
import { computed, onUnmounted, ref } from 'vue'
|
| 23 |
+
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
| 24 |
+
import { throttle, clamp } from 'lodash'
|
| 25 |
+
|
| 26 |
+
const props = defineProps<{
|
| 27 |
+
value: ColorFormats.RGBA
|
| 28 |
+
hue: number
|
| 29 |
+
}>()
|
| 30 |
+
|
| 31 |
+
const emit = defineEmits<{
|
| 32 |
+
(event: 'colorChange', payload: ColorFormats.HSVA): void
|
| 33 |
+
}>()
|
| 34 |
+
|
| 35 |
+
const color = computed(() => {
|
| 36 |
+
const hsva = tinycolor(props.value).toHsv()
|
| 37 |
+
if (props.hue !== -1) hsva.h = props.hue
|
| 38 |
+
return hsva
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
const bgColor = computed(() => `hsl(${color.value.h}, 100%, 50%)`)
|
| 42 |
+
const pointerTop = computed(() => (-(color.value.v * 100) + 1) + 100 + '%')
|
| 43 |
+
const pointerLeft = computed(() => color.value.s * 100 + '%')
|
| 44 |
+
|
| 45 |
+
const emitChangeEvent = throttle(function(param: ColorFormats.HSVA) {
|
| 46 |
+
emit('colorChange', param)
|
| 47 |
+
}, 20, { leading: true, trailing: false })
|
| 48 |
+
|
| 49 |
+
const saturationRef = ref<HTMLElement>()
|
| 50 |
+
const handleChange = (e: MouseEvent) => {
|
| 51 |
+
e.preventDefault()
|
| 52 |
+
if (!saturationRef.value) return
|
| 53 |
+
|
| 54 |
+
const containerWidth = saturationRef.value.clientWidth
|
| 55 |
+
const containerHeight = saturationRef.value.clientHeight
|
| 56 |
+
const xOffset = saturationRef.value.getBoundingClientRect().left + window.pageXOffset
|
| 57 |
+
const yOffset = saturationRef.value.getBoundingClientRect().top + window.pageYOffset
|
| 58 |
+
const left = clamp(e.pageX - xOffset, 0, containerWidth)
|
| 59 |
+
const top = clamp(e.pageY - yOffset, 0, containerHeight)
|
| 60 |
+
const saturation = left / containerWidth
|
| 61 |
+
const bright = clamp(-(top / containerHeight) + 1, 0, 1)
|
| 62 |
+
|
| 63 |
+
emitChangeEvent({
|
| 64 |
+
h: color.value.h,
|
| 65 |
+
s: saturation,
|
| 66 |
+
v: bright,
|
| 67 |
+
a: color.value.a,
|
| 68 |
+
})
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const unbindEventListeners = () => {
|
| 72 |
+
window.removeEventListener('mousemove', handleChange)
|
| 73 |
+
window.removeEventListener('mouseup', unbindEventListeners)
|
| 74 |
+
}
|
| 75 |
+
const handleMouseDown = (e: MouseEvent) => {
|
| 76 |
+
handleChange(e)
|
| 77 |
+
window.addEventListener('mousemove', handleChange)
|
| 78 |
+
window.addEventListener('mouseup', unbindEventListeners)
|
| 79 |
+
}
|
| 80 |
+
onUnmounted(unbindEventListeners)
|
| 81 |
+
</script>
|
| 82 |
+
|
| 83 |
+
<style lang="scss" scoped>
|
| 84 |
+
.saturation,
|
| 85 |
+
.saturation-white,
|
| 86 |
+
.saturation-black {
|
| 87 |
+
@include absolute-0();
|
| 88 |
+
|
| 89 |
+
cursor: pointer;
|
| 90 |
+
}
|
| 91 |
+
.saturation-white {
|
| 92 |
+
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
|
| 93 |
+
}
|
| 94 |
+
.saturation-black {
|
| 95 |
+
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
|
| 96 |
+
}
|
| 97 |
+
.saturation-pointer {
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
position: absolute;
|
| 100 |
+
}
|
| 101 |
+
.saturation-circle {
|
| 102 |
+
width: 4px;
|
| 103 |
+
height: 4px;
|
| 104 |
+
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0, 0, 0, .3), 0 0 1px 2px rgba(0, 0, 0, .4);
|
| 105 |
+
border-radius: 50%;
|
| 106 |
+
transform: translate(-2px, -2px);
|
| 107 |
+
}
|
| 108 |
+
</style>
|
frontend/src/components/ColorPicker/index.vue
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="color-picker">
|
| 3 |
+
<div class="picker-saturation-wrap">
|
| 4 |
+
<Saturation :value="color" :hue="hue" @colorChange="value => changeColor(value)" />
|
| 5 |
+
</div>
|
| 6 |
+
<div class="picker-controls">
|
| 7 |
+
<div class="picker-color-wrap">
|
| 8 |
+
<div class="picker-current-color" :style="{ background: currentColor }"></div>
|
| 9 |
+
<Checkboard />
|
| 10 |
+
</div>
|
| 11 |
+
<div class="picker-sliders">
|
| 12 |
+
<div class="picker-hue-wrap">
|
| 13 |
+
<Hue :value="color" :hue="hue" @colorChange="value => changeColor(value)" />
|
| 14 |
+
</div>
|
| 15 |
+
<div class="picker-alpha-wrap">
|
| 16 |
+
<Alpha :value="color" @colorChange="value => changeColor(value)" />
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="picker-field">
|
| 22 |
+
<EditableInput class="input" :value="color" @colorChange="value => changeColor(value)" />
|
| 23 |
+
<div class="straw" @click="openEyeDropper()"><IconNeedle /></div>
|
| 24 |
+
<div class="transparent" @click="selectPresetColor('#00000000')">
|
| 25 |
+
<Checkboard />
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div class="picker-presets">
|
| 30 |
+
<div
|
| 31 |
+
class="picker-presets-color"
|
| 32 |
+
v-for="c in themeColors"
|
| 33 |
+
:key="c"
|
| 34 |
+
:style="{ background: c }"
|
| 35 |
+
@click="selectPresetColor(c)"
|
| 36 |
+
></div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div class="picker-gradient-presets">
|
| 40 |
+
<div
|
| 41 |
+
class="picker-gradient-col"
|
| 42 |
+
v-for="(col, index) in presetColors"
|
| 43 |
+
:key="index"
|
| 44 |
+
>
|
| 45 |
+
<div class="picker-gradient-color"
|
| 46 |
+
v-for="c in col"
|
| 47 |
+
:key="c"
|
| 48 |
+
:style="{ background: c }"
|
| 49 |
+
@click="selectPresetColor(c)"
|
| 50 |
+
></div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div class="picker-presets">
|
| 55 |
+
<div
|
| 56 |
+
v-for="c in standardColors"
|
| 57 |
+
:key="c"
|
| 58 |
+
class="picker-presets-color"
|
| 59 |
+
:style="{ background: c }"
|
| 60 |
+
@click="selectPresetColor(c)"
|
| 61 |
+
></div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="recent-colors-title" v-if="recentColors.length">最近使用:</div>
|
| 65 |
+
<div class="picker-presets">
|
| 66 |
+
<div
|
| 67 |
+
v-for="c in recentColors"
|
| 68 |
+
:key="c"
|
| 69 |
+
class="picker-presets-color alpha"
|
| 70 |
+
@click="selectPresetColor(c)"
|
| 71 |
+
>
|
| 72 |
+
<div class="picker-presets-color-content" :style="{ background: c }"></div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</template>
|
| 77 |
+
|
| 78 |
+
<script lang="ts" setup>
|
| 79 |
+
import { computed, onMounted, ref, watch } from 'vue'
|
| 80 |
+
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
| 81 |
+
import { debounce } from 'lodash'
|
| 82 |
+
import { toCanvas } from 'html-to-image'
|
| 83 |
+
import message from '@/utils/message'
|
| 84 |
+
|
| 85 |
+
import Alpha from './Alpha.vue'
|
| 86 |
+
import Checkboard from './Checkboard.vue'
|
| 87 |
+
import Hue from './Hue.vue'
|
| 88 |
+
import Saturation from './Saturation.vue'
|
| 89 |
+
import EditableInput from './EditableInput.vue'
|
| 90 |
+
|
| 91 |
+
const props = withDefaults(defineProps<{
|
| 92 |
+
modelValue?: string
|
| 93 |
+
}>(), {
|
| 94 |
+
modelValue: '#e86b99',
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
const emit = defineEmits<{
|
| 98 |
+
(event: 'update:modelValue', payload: string): void
|
| 99 |
+
}>()
|
| 100 |
+
|
| 101 |
+
const RECENT_COLORS = 'RECENT_COLORS'
|
| 102 |
+
|
| 103 |
+
const presetColorConfig = [
|
| 104 |
+
['#7f7f7f', '#f2f2f2'],
|
| 105 |
+
['#0d0d0d', '#808080'],
|
| 106 |
+
['#1c1a10', '#ddd8c3'],
|
| 107 |
+
['#0e243d', '#c6d9f0'],
|
| 108 |
+
['#233f5e', '#dae5f0'],
|
| 109 |
+
['#632623', '#f2dbdb'],
|
| 110 |
+
['#4d602c', '#eaf1de'],
|
| 111 |
+
['#3f3150', '#e6e0ec'],
|
| 112 |
+
['#1e5867', '#d9eef3'],
|
| 113 |
+
['#99490f', '#fee9da'],
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
const gradient = (startColor: string, endColor: string, step: number) => {
|
| 117 |
+
const _startColor = tinycolor(startColor).toRgb()
|
| 118 |
+
const _endColor = tinycolor(endColor).toRgb()
|
| 119 |
+
|
| 120 |
+
const rStep = (_endColor.r - _startColor.r) / step
|
| 121 |
+
const gStep = (_endColor.g - _startColor.g) / step
|
| 122 |
+
const bStep = (_endColor.b - _startColor.b) / step
|
| 123 |
+
const gradientColorArr = []
|
| 124 |
+
|
| 125 |
+
for (let i = 0; i < step; i++) {
|
| 126 |
+
const gradientColor = tinycolor({
|
| 127 |
+
r: _startColor.r + rStep * i,
|
| 128 |
+
g: _startColor.g + gStep * i,
|
| 129 |
+
b: _startColor.b + bStep * i,
|
| 130 |
+
}).toRgbString()
|
| 131 |
+
gradientColorArr.push(gradientColor)
|
| 132 |
+
}
|
| 133 |
+
return gradientColorArr
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const getPresetColors = () => {
|
| 137 |
+
const presetColors = []
|
| 138 |
+
for (const color of presetColorConfig) {
|
| 139 |
+
presetColors.push(gradient(color[1], color[0], 5))
|
| 140 |
+
}
|
| 141 |
+
return presetColors
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const themeColors = ['#000000', '#ffffff', '#eeece1', '#1e497b', '#4e81bb', '#e2534d', '#9aba60', '#8165a0', '#47acc5', '#f9974c']
|
| 145 |
+
const standardColors = ['#c21401', '#ff1e02', '#ffc12a', '#ffff3a', '#90cf5b', '#00af57', '#00afee', '#0071be', '#00215f', '#72349d']
|
| 146 |
+
|
| 147 |
+
const hue = ref(-1)
|
| 148 |
+
const recentColors = ref<string[]>([])
|
| 149 |
+
|
| 150 |
+
const color = computed({
|
| 151 |
+
get() {
|
| 152 |
+
return tinycolor(props.modelValue).toRgb()
|
| 153 |
+
},
|
| 154 |
+
set(rgba: ColorFormats.RGBA) {
|
| 155 |
+
const rgbaString = `rgba(${[rgba.r, rgba.g, rgba.b, rgba.a].join(',')})`
|
| 156 |
+
emit('update:modelValue', rgbaString)
|
| 157 |
+
},
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
const presetColors = getPresetColors()
|
| 161 |
+
|
| 162 |
+
const currentColor = computed(() => {
|
| 163 |
+
return `rgba(${[color.value.r, color.value.g, color.value.b, color.value.a].join(',')})`
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
const selectPresetColor = (colorString: string) => {
|
| 167 |
+
hue.value = tinycolor(colorString).toHsl().h
|
| 168 |
+
emit('update:modelValue', colorString)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// 每次选择非预设颜色时,需要将该颜色加入到最近使用列表中
|
| 172 |
+
const updateRecentColorsCache = debounce(function() {
|
| 173 |
+
const _color = tinycolor(color.value).toRgbString()
|
| 174 |
+
if (!recentColors.value.includes(_color)) {
|
| 175 |
+
recentColors.value = [_color, ...recentColors.value]
|
| 176 |
+
|
| 177 |
+
const maxLength = 10
|
| 178 |
+
if (recentColors.value.length > maxLength) {
|
| 179 |
+
recentColors.value = recentColors.value.slice(0, maxLength)
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
}, 300, { trailing: true })
|
| 183 |
+
|
| 184 |
+
onMounted(() => {
|
| 185 |
+
const recentColorsCache = localStorage.getItem(RECENT_COLORS)
|
| 186 |
+
if (recentColorsCache) recentColors.value = JSON.parse(recentColorsCache)
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
watch(recentColors, () => {
|
| 190 |
+
const recentColorsCache = JSON.stringify(recentColors.value)
|
| 191 |
+
localStorage.setItem(RECENT_COLORS, recentColorsCache)
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
const changeColor = (value: ColorFormats.RGBA | ColorFormats.HSLA | ColorFormats.HSVA) => {
|
| 195 |
+
if ('h' in value) {
|
| 196 |
+
hue.value = value.h
|
| 197 |
+
color.value = tinycolor(value).toRgb()
|
| 198 |
+
}
|
| 199 |
+
else {
|
| 200 |
+
hue.value = tinycolor(value).toHsl().h
|
| 201 |
+
color.value = value
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
updateRecentColorsCache()
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// 打开取色吸管
|
| 208 |
+
// 检查环境是否支持原生取色吸管,支持则使用原生吸管,否则使用自定义吸管
|
| 209 |
+
const openEyeDropper = () => {
|
| 210 |
+
const isSupportedEyeDropper = 'EyeDropper' in window
|
| 211 |
+
|
| 212 |
+
if (isSupportedEyeDropper) browserEyeDropper()
|
| 213 |
+
else customEyeDropper()
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// 原生取色吸管
|
| 217 |
+
const browserEyeDropper = () => {
|
| 218 |
+
message.success('按 ESC 键关闭取色吸管', { duration: 0 })
|
| 219 |
+
|
| 220 |
+
// eslint-disable-next-line
|
| 221 |
+
const eyeDropper = new (window as any).EyeDropper()
|
| 222 |
+
eyeDropper.open().then((result: { sRGBHex: string }) => {
|
| 223 |
+
const tColor = tinycolor(result.sRGBHex)
|
| 224 |
+
hue.value = tColor.toHsl().h
|
| 225 |
+
color.value = tColor.toRgb()
|
| 226 |
+
|
| 227 |
+
message.closeAll()
|
| 228 |
+
updateRecentColorsCache()
|
| 229 |
+
}).catch(() => {
|
| 230 |
+
message.closeAll()
|
| 231 |
+
})
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// 基于 Canvas 的自定义取色吸管
|
| 235 |
+
const customEyeDropper = () => {
|
| 236 |
+
const targetRef: HTMLElement | null = document.querySelector('.canvas')
|
| 237 |
+
if (!targetRef) return
|
| 238 |
+
|
| 239 |
+
const maskRef = document.createElement('div')
|
| 240 |
+
maskRef.style.cssText = 'position: fixed; top: 0; left: 0; bottom: 0; right: 0; z-index: 9999; cursor: wait;'
|
| 241 |
+
document.body.appendChild(maskRef)
|
| 242 |
+
|
| 243 |
+
const colorBlockRef = document.createElement('div')
|
| 244 |
+
colorBlockRef.style.cssText = 'position: absolute; top: -100px; left: -100px; width: 16px; height: 16px; border: 1px solid #000; z-index: 999'
|
| 245 |
+
maskRef.appendChild(colorBlockRef)
|
| 246 |
+
|
| 247 |
+
const { left, top, width, height } = targetRef.getBoundingClientRect()
|
| 248 |
+
|
| 249 |
+
const filter = (node: HTMLElement) => {
|
| 250 |
+
if (node.tagName && node.tagName.toUpperCase() === 'FOREIGNOBJECT') return false
|
| 251 |
+
if (node.classList && node.classList.contains('operate')) return false
|
| 252 |
+
return true
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
toCanvas(targetRef, { filter, fontEmbedCSS: '', width, height, canvasWidth: width, canvasHeight: height, pixelRatio: 1 }).then(canvasRef => {
|
| 256 |
+
canvasRef.style.cssText = `position: absolute; top: ${top}px; left: ${left}px; cursor: crosshair;`
|
| 257 |
+
maskRef.style.cursor = 'default'
|
| 258 |
+
maskRef.appendChild(canvasRef)
|
| 259 |
+
|
| 260 |
+
const ctx = canvasRef.getContext('2d')
|
| 261 |
+
if (!ctx) return
|
| 262 |
+
|
| 263 |
+
let currentColor = ''
|
| 264 |
+
const handleMousemove = (e: MouseEvent) => {
|
| 265 |
+
const x = e.x
|
| 266 |
+
const y = e.y
|
| 267 |
+
|
| 268 |
+
const mouseX = x - left
|
| 269 |
+
const mouseY = y - top
|
| 270 |
+
|
| 271 |
+
const [r, g, b, a] = ctx.getImageData(mouseX, mouseY, 1, 1).data
|
| 272 |
+
currentColor = `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(2)})`
|
| 273 |
+
|
| 274 |
+
colorBlockRef.style.left = x + 10 + 'px'
|
| 275 |
+
colorBlockRef.style.top = y + 10 + 'px'
|
| 276 |
+
colorBlockRef.style.backgroundColor = currentColor
|
| 277 |
+
}
|
| 278 |
+
const handleMouseleave = () => {
|
| 279 |
+
currentColor = ''
|
| 280 |
+
colorBlockRef.style.left = '-100px'
|
| 281 |
+
colorBlockRef.style.top = '-100px'
|
| 282 |
+
colorBlockRef.style.backgroundColor = ''
|
| 283 |
+
}
|
| 284 |
+
const handleMousedown = (e: MouseEvent) => {
|
| 285 |
+
if (currentColor && e.button === 0) {
|
| 286 |
+
const tColor = tinycolor(currentColor)
|
| 287 |
+
hue.value = tColor.toHsl().h
|
| 288 |
+
color.value = tColor.toRgb()
|
| 289 |
+
|
| 290 |
+
updateRecentColorsCache()
|
| 291 |
+
}
|
| 292 |
+
document.body.removeChild(maskRef)
|
| 293 |
+
|
| 294 |
+
canvasRef.removeEventListener('mousemove', handleMousemove)
|
| 295 |
+
canvasRef.removeEventListener('mouseleave', handleMouseleave)
|
| 296 |
+
window.removeEventListener('mousedown', handleMousedown)
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
canvasRef.addEventListener('mousemove', handleMousemove)
|
| 300 |
+
canvasRef.addEventListener('mouseleave', handleMouseleave)
|
| 301 |
+
window.addEventListener('mousedown', handleMousedown)
|
| 302 |
+
}).catch(() => {
|
| 303 |
+
message.error('取色吸管初始化失败')
|
| 304 |
+
document.body.removeChild(maskRef)
|
| 305 |
+
})
|
| 306 |
+
}
|
| 307 |
+
</script>
|
| 308 |
+
|
| 309 |
+
<style lang="scss" scoped>
|
| 310 |
+
.color-picker {
|
| 311 |
+
position: relative;
|
| 312 |
+
width: 240px;
|
| 313 |
+
background: #fff;
|
| 314 |
+
user-select: none;
|
| 315 |
+
margin-bottom: -10px;
|
| 316 |
+
}
|
| 317 |
+
.picker-saturation-wrap {
|
| 318 |
+
width: 100%;
|
| 319 |
+
padding-bottom: 50%;
|
| 320 |
+
position: relative;
|
| 321 |
+
overflow: hidden;
|
| 322 |
+
}
|
| 323 |
+
.picker-controls {
|
| 324 |
+
display: flex;
|
| 325 |
+
}
|
| 326 |
+
.picker-sliders {
|
| 327 |
+
padding: 4px 0;
|
| 328 |
+
flex: 1;
|
| 329 |
+
}
|
| 330 |
+
.picker-hue-wrap {
|
| 331 |
+
position: relative;
|
| 332 |
+
height: 10px;
|
| 333 |
+
}
|
| 334 |
+
.picker-alpha-wrap {
|
| 335 |
+
position: relative;
|
| 336 |
+
height: 10px;
|
| 337 |
+
margin-top: 4px;
|
| 338 |
+
overflow: hidden;
|
| 339 |
+
}
|
| 340 |
+
.picker-color-wrap {
|
| 341 |
+
width: 24px;
|
| 342 |
+
height: 24px;
|
| 343 |
+
position: relative;
|
| 344 |
+
margin-top: 4px;
|
| 345 |
+
margin-right: 4px;
|
| 346 |
+
outline: 1px dashed rgba($color: #666, $alpha: .12);
|
| 347 |
+
|
| 348 |
+
.checkerboard {
|
| 349 |
+
background-size: auto;
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
.picker-current-color {
|
| 353 |
+
@include absolute-0();
|
| 354 |
+
|
| 355 |
+
z-index: 2;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.picker-field {
|
| 359 |
+
display: flex;
|
| 360 |
+
margin-bottom: 8px;
|
| 361 |
+
|
| 362 |
+
.transparent {
|
| 363 |
+
width: 24px;
|
| 364 |
+
height: 24px;
|
| 365 |
+
margin-top: 4px;
|
| 366 |
+
margin-left: 8px;
|
| 367 |
+
position: relative;
|
| 368 |
+
cursor: pointer;
|
| 369 |
+
|
| 370 |
+
&::after {
|
| 371 |
+
content: '';
|
| 372 |
+
width: 26px;
|
| 373 |
+
height: 2px;
|
| 374 |
+
position: absolute;
|
| 375 |
+
top: 11px;
|
| 376 |
+
left: -1px;
|
| 377 |
+
transform: rotate(-45deg);
|
| 378 |
+
background-color: #f00;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.checkerboard {
|
| 382 |
+
background-size: auto;
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.straw {
|
| 387 |
+
width: 24px;
|
| 388 |
+
height: 24px;
|
| 389 |
+
margin-top: 4px;
|
| 390 |
+
margin-left: 8px;
|
| 391 |
+
display: flex;
|
| 392 |
+
justify-content: center;
|
| 393 |
+
align-items: center;
|
| 394 |
+
font-size: 20px;
|
| 395 |
+
background-color: #f5f5f5;
|
| 396 |
+
outline: 1px solid #f1f1f1;
|
| 397 |
+
cursor: pointer;
|
| 398 |
+
}
|
| 399 |
+
.input {
|
| 400 |
+
flex: 1;
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.picker-presets {
|
| 405 |
+
@include flex-grid-layout();
|
| 406 |
+
}
|
| 407 |
+
.picker-presets-color {
|
| 408 |
+
@include flex-grid-layout-children(10, 7%);
|
| 409 |
+
|
| 410 |
+
height: 0;
|
| 411 |
+
padding-bottom: 7%;
|
| 412 |
+
flex-shrink: 0;
|
| 413 |
+
position: relative;
|
| 414 |
+
cursor: pointer;
|
| 415 |
+
|
| 416 |
+
&.alpha {
|
| 417 |
+
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAADBJREFUOE9jfPbs2X8GPEBSUhKfNAPjqAHDIgz+//+PNx08f/4cfzoYNYCBceiHAQC5flV5JzgrxQAAAABJRU5ErkJggg==);
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
.picker-presets-color-content {
|
| 421 |
+
@include absolute-0();
|
| 422 |
+
}
|
| 423 |
+
.picker-gradient-presets {
|
| 424 |
+
@include flex-grid-layout();
|
| 425 |
+
}
|
| 426 |
+
.picker-gradient-col {
|
| 427 |
+
@include flex-grid-layout-children(10, 7%);
|
| 428 |
+
|
| 429 |
+
display: flex;
|
| 430 |
+
flex-direction: column;
|
| 431 |
+
}
|
| 432 |
+
.picker-gradient-color {
|
| 433 |
+
width: 100%;
|
| 434 |
+
height: 16px;
|
| 435 |
+
position: relative;
|
| 436 |
+
cursor: pointer;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.recent-colors-title {
|
| 440 |
+
font-size: 12px;
|
| 441 |
+
margin-bottom: 4px;
|
| 442 |
+
}
|
| 443 |
+
</style>
|
frontend/src/components/Contextmenu/MenuContent.vue
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<ul class="menu-content">
|
| 3 |
+
<template v-for="(menu, index) in menus" :key="menu.text || index">
|
| 4 |
+
<li
|
| 5 |
+
v-if="!menu.hide"
|
| 6 |
+
class="menu-item"
|
| 7 |
+
@click.stop="handleClickMenuItem(menu)"
|
| 8 |
+
:class="{'divider': menu.divider, 'disable': menu.disable}"
|
| 9 |
+
>
|
| 10 |
+
<div
|
| 11 |
+
class="menu-item-content"
|
| 12 |
+
:class="{
|
| 13 |
+
'has-children': menu.children,
|
| 14 |
+
'has-handler': menu.handler,
|
| 15 |
+
}"
|
| 16 |
+
v-if="!menu.divider"
|
| 17 |
+
>
|
| 18 |
+
<span class="text">{{menu.text}}</span>
|
| 19 |
+
<span class="sub-text" v-if="menu.subText && !menu.children">{{menu.subText}}</span>
|
| 20 |
+
|
| 21 |
+
<menu-content
|
| 22 |
+
class="sub-menu"
|
| 23 |
+
:menus="menu.children"
|
| 24 |
+
v-if="menu.children && menu.children.length"
|
| 25 |
+
:handleClickMenuItem="handleClickMenuItem"
|
| 26 |
+
/>
|
| 27 |
+
</div>
|
| 28 |
+
</li>
|
| 29 |
+
</template>
|
| 30 |
+
</ul>
|
| 31 |
+
</template>
|
| 32 |
+
|
| 33 |
+
<script lang="ts" setup>
|
| 34 |
+
import type { ContextmenuItem } from './types'
|
| 35 |
+
|
| 36 |
+
defineProps<{
|
| 37 |
+
menus: ContextmenuItem[]
|
| 38 |
+
handleClickMenuItem: (item: ContextmenuItem) => void
|
| 39 |
+
}>()
|
| 40 |
+
</script>
|
| 41 |
+
|
| 42 |
+
<style lang="scss" scoped>
|
| 43 |
+
$menuWidth: 180px;
|
| 44 |
+
$menuHeight: 30px;
|
| 45 |
+
$subMenuWidth: 120px;
|
| 46 |
+
|
| 47 |
+
.menu-content {
|
| 48 |
+
width: $menuWidth;
|
| 49 |
+
padding: 5px 0;
|
| 50 |
+
background: #fff;
|
| 51 |
+
border: 1px solid $borderColor;
|
| 52 |
+
box-shadow: $boxShadow;
|
| 53 |
+
border-radius: $borderRadius;
|
| 54 |
+
list-style: none;
|
| 55 |
+
margin: 0;
|
| 56 |
+
}
|
| 57 |
+
.menu-item {
|
| 58 |
+
padding: 0 20px;
|
| 59 |
+
color: #555;
|
| 60 |
+
font-size: 12px;
|
| 61 |
+
transition: all $transitionDelayFast;
|
| 62 |
+
white-space: nowrap;
|
| 63 |
+
height: $menuHeight;
|
| 64 |
+
line-height: $menuHeight;
|
| 65 |
+
background-color: #fff;
|
| 66 |
+
cursor: pointer;
|
| 67 |
+
|
| 68 |
+
&:not(.disable):hover > .menu-item-content > .sub-menu {
|
| 69 |
+
display: block;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
&:not(.disable):hover > .has-children.has-handler::after {
|
| 73 |
+
transform: scale(1);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
&:hover:not(.disable) {
|
| 77 |
+
background-color: rgba($color: $themeColor, $alpha: .2);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
&.divider {
|
| 81 |
+
height: 1px;
|
| 82 |
+
overflow: hidden;
|
| 83 |
+
margin: 5px;
|
| 84 |
+
background-color: #e5e5e5;
|
| 85 |
+
line-height: 0;
|
| 86 |
+
padding: 0;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
&.disable {
|
| 90 |
+
color: #b1b1b1;
|
| 91 |
+
cursor: no-drop;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
.menu-item-content {
|
| 95 |
+
display: flex;
|
| 96 |
+
align-items: center;
|
| 97 |
+
justify-content: space-between;
|
| 98 |
+
position: relative;
|
| 99 |
+
|
| 100 |
+
&.has-children::before {
|
| 101 |
+
content: '';
|
| 102 |
+
display: inline-block;
|
| 103 |
+
width: 8px;
|
| 104 |
+
height: 8px;
|
| 105 |
+
border-width: 1px;
|
| 106 |
+
border-style: solid;
|
| 107 |
+
border-color: #666 #666 transparent transparent;
|
| 108 |
+
position: absolute;
|
| 109 |
+
right: 0;
|
| 110 |
+
top: 50%;
|
| 111 |
+
transform: translateY(-50%) rotate(45deg);
|
| 112 |
+
}
|
| 113 |
+
&.has-children.has-handler::after {
|
| 114 |
+
content: '';
|
| 115 |
+
display: inline-block;
|
| 116 |
+
width: 1px;
|
| 117 |
+
height: 24px;
|
| 118 |
+
background-color: rgba($color: #fff, $alpha: .3);
|
| 119 |
+
position: absolute;
|
| 120 |
+
right: 18px;
|
| 121 |
+
top: 3px;
|
| 122 |
+
transform: scale(0);
|
| 123 |
+
transition: transform $transitionDelay;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.sub-text {
|
| 127 |
+
opacity: 0.6;
|
| 128 |
+
}
|
| 129 |
+
.sub-menu {
|
| 130 |
+
width: $subMenuWidth;
|
| 131 |
+
position: absolute;
|
| 132 |
+
display: none;
|
| 133 |
+
left: 112%;
|
| 134 |
+
top: -6px;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
</style>
|
frontend/src/components/Contextmenu/index.vue
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div
|
| 3 |
+
class="mask"
|
| 4 |
+
@contextmenu.prevent="removeContextmenu()"
|
| 5 |
+
@mousedown.left="removeContextmenu()"
|
| 6 |
+
></div>
|
| 7 |
+
|
| 8 |
+
<div
|
| 9 |
+
class="contextmenu"
|
| 10 |
+
:style="{
|
| 11 |
+
left: style.left + 'px',
|
| 12 |
+
top: style.top + 'px',
|
| 13 |
+
}"
|
| 14 |
+
@contextmenu.prevent
|
| 15 |
+
>
|
| 16 |
+
<MenuContent
|
| 17 |
+
:menus="menus"
|
| 18 |
+
:handleClickMenuItem="handleClickMenuItem"
|
| 19 |
+
/>
|
| 20 |
+
</div>
|
| 21 |
+
</template>
|
| 22 |
+
|
| 23 |
+
<script lang="ts" setup>
|
| 24 |
+
import { computed } from 'vue'
|
| 25 |
+
import type { ContextmenuItem, Axis } from './types'
|
| 26 |
+
|
| 27 |
+
import MenuContent from './MenuContent.vue'
|
| 28 |
+
|
| 29 |
+
const props = defineProps<{
|
| 30 |
+
axis: Axis
|
| 31 |
+
el: HTMLElement
|
| 32 |
+
menus: ContextmenuItem[]
|
| 33 |
+
removeContextmenu: () => void
|
| 34 |
+
}>()
|
| 35 |
+
|
| 36 |
+
const style = computed(() => {
|
| 37 |
+
const MENU_WIDTH = 180
|
| 38 |
+
const MENU_HEIGHT = 30
|
| 39 |
+
const DIVIDER_HEIGHT = 11
|
| 40 |
+
const PADDING = 5
|
| 41 |
+
|
| 42 |
+
const { x, y } = props.axis
|
| 43 |
+
const menuCount = props.menus.filter(menu => !(menu.divider || menu.hide)).length
|
| 44 |
+
const dividerCount = props.menus.filter(menu => menu.divider).length
|
| 45 |
+
|
| 46 |
+
const menuWidth = MENU_WIDTH
|
| 47 |
+
const menuHeight = menuCount * MENU_HEIGHT + dividerCount * DIVIDER_HEIGHT + PADDING * 2
|
| 48 |
+
|
| 49 |
+
const screenWidth = document.body.clientWidth
|
| 50 |
+
const screenHeight = document.body.clientHeight
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
left: screenWidth <= x + menuWidth ? x - menuWidth : x,
|
| 54 |
+
top: screenHeight <= y + menuHeight ? y - menuHeight : y,
|
| 55 |
+
}
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
const handleClickMenuItem = (item: ContextmenuItem) => {
|
| 59 |
+
if (item.disable) return
|
| 60 |
+
if (item.children && !item.handler) return
|
| 61 |
+
if (item.handler) item.handler(props.el)
|
| 62 |
+
props.removeContextmenu()
|
| 63 |
+
}
|
| 64 |
+
</script>
|
| 65 |
+
|
| 66 |
+
<style lang="scss">
|
| 67 |
+
.mask {
|
| 68 |
+
position: fixed;
|
| 69 |
+
left: 0;
|
| 70 |
+
top: 0;
|
| 71 |
+
width: 100vw;
|
| 72 |
+
height: 100vh;
|
| 73 |
+
z-index: 9998;
|
| 74 |
+
}
|
| 75 |
+
.contextmenu {
|
| 76 |
+
position: fixed;
|
| 77 |
+
z-index: 9999;
|
| 78 |
+
user-select: none;
|
| 79 |
+
}
|
| 80 |
+
</style>
|
frontend/src/components/Contextmenu/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface ContextmenuItem {
|
| 2 |
+
text?: string
|
| 3 |
+
subText?: string
|
| 4 |
+
divider?: boolean
|
| 5 |
+
disable?: boolean
|
| 6 |
+
hide?: boolean
|
| 7 |
+
children?: ContextmenuItem[]
|
| 8 |
+
handler?: (el: HTMLElement) => void
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface Axis {
|
| 12 |
+
x: number
|
| 13 |
+
y: number
|
| 14 |
+
}
|
frontend/src/components/Divider.vue
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div :class="['divider', type]"
|
| 3 |
+
:style="{
|
| 4 |
+
margin: type === 'horizontal' ? `${margin >= 0 ? margin : 24}px 0` : `0 ${margin >= 0 ? margin : 8}px`
|
| 5 |
+
}"
|
| 6 |
+
></div>
|
| 7 |
+
</template>
|
| 8 |
+
|
| 9 |
+
<script lang="ts" setup>
|
| 10 |
+
withDefaults(defineProps<{
|
| 11 |
+
type?: 'horizontal' | 'vertical'
|
| 12 |
+
margin?: number
|
| 13 |
+
}>(), {
|
| 14 |
+
type: 'horizontal',
|
| 15 |
+
margin: -1,
|
| 16 |
+
})
|
| 17 |
+
</script>
|
| 18 |
+
|
| 19 |
+
<style lang="scss" scoped>
|
| 20 |
+
.divider {
|
| 21 |
+
&.horizontal {
|
| 22 |
+
width: 100%;
|
| 23 |
+
margin: 24px 0;
|
| 24 |
+
border-block-start: 1px solid rgba(5, 5, 5, .06);
|
| 25 |
+
}
|
| 26 |
+
&.vertical {
|
| 27 |
+
position: relative;
|
| 28 |
+
height: 1em;
|
| 29 |
+
display: inline-block;
|
| 30 |
+
margin: 0 8px;
|
| 31 |
+
border-inline-start: 1px solid rgba(5, 5, 5, .06);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
</style>
|
frontend/src/components/Drawer.vue
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Teleport to="body">
|
| 3 |
+
<Transition :name="`drawer-slide-${placement}`"
|
| 4 |
+
@afterLeave="contentVisible = false"
|
| 5 |
+
@before-enter="contentVisible = true"
|
| 6 |
+
>
|
| 7 |
+
<div :class="['drawer', placement]" v-show="visible" :style="{ width: props.width + 'px' }">
|
| 8 |
+
<div class="header">
|
| 9 |
+
<slot name="title"></slot>
|
| 10 |
+
<span class="close-btn" @click="emit('update:visible', false)"><IconClose /></span>
|
| 11 |
+
</div>
|
| 12 |
+
<div class="content" v-if="contentVisible" :style="contentStyle">
|
| 13 |
+
<slot></slot>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
</Transition>
|
| 17 |
+
</Teleport>
|
| 18 |
+
</template>
|
| 19 |
+
|
| 20 |
+
<script lang="ts" setup>
|
| 21 |
+
import { computed, ref, type CSSProperties } from 'vue'
|
| 22 |
+
|
| 23 |
+
const props = withDefaults(defineProps<{
|
| 24 |
+
visible: boolean
|
| 25 |
+
width?: number
|
| 26 |
+
contentStyle?: CSSProperties
|
| 27 |
+
placement?: 'left' | 'right'
|
| 28 |
+
}>(), {
|
| 29 |
+
width: 320,
|
| 30 |
+
placement: 'right',
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
const emit = defineEmits<{
|
| 34 |
+
(event: 'update:visible', payload: boolean): void
|
| 35 |
+
}>()
|
| 36 |
+
|
| 37 |
+
const contentVisible = ref(false)
|
| 38 |
+
|
| 39 |
+
const contentStyle = computed(() => {
|
| 40 |
+
return {
|
| 41 |
+
width: props.width + 'px',
|
| 42 |
+
...(props.contentStyle || {})
|
| 43 |
+
}
|
| 44 |
+
})
|
| 45 |
+
</script>
|
| 46 |
+
|
| 47 |
+
<style lang="scss" scoped>
|
| 48 |
+
.drawer {
|
| 49 |
+
height: 100%;
|
| 50 |
+
position: fixed;
|
| 51 |
+
top: 0;
|
| 52 |
+
bottom: 0;
|
| 53 |
+
z-index: 5000;
|
| 54 |
+
background: #fff;
|
| 55 |
+
display: flex;
|
| 56 |
+
flex-direction: column;
|
| 57 |
+
|
| 58 |
+
&.left {
|
| 59 |
+
left: 0;
|
| 60 |
+
box-shadow: 3px 0 6px -4px rgba(0, 0, 0, 0.12), 9px 0 28px 8px rgba(0, 0, 0, 0.05);
|
| 61 |
+
}
|
| 62 |
+
&.right {
|
| 63 |
+
right: 0;
|
| 64 |
+
box-shadow: -3px 0 6px -4px rgba(0, 0, 0, 0.12), -9px 0 28px 8px rgba(0, 0, 0, 0.05);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.header {
|
| 69 |
+
height: 50px;
|
| 70 |
+
padding: 0 15px;
|
| 71 |
+
position: relative;
|
| 72 |
+
display: flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
|
| 75 |
+
.close-btn {
|
| 76 |
+
width: 20px;
|
| 77 |
+
height: 20px;
|
| 78 |
+
display: flex;
|
| 79 |
+
justify-content: center;
|
| 80 |
+
align-items: center;
|
| 81 |
+
position: absolute;
|
| 82 |
+
top: 15px;
|
| 83 |
+
right: 15px;
|
| 84 |
+
cursor: pointer;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
.content {
|
| 88 |
+
padding: 0 15px;
|
| 89 |
+
overflow: auto;
|
| 90 |
+
flex: 1;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.drawer-slide-right-enter-active {
|
| 94 |
+
animation: drawer-slide-right-enter .25s both ease;
|
| 95 |
+
}
|
| 96 |
+
.drawer-slide-right-leave-active {
|
| 97 |
+
animation: drawer-slide-right-leave .25s both ease;
|
| 98 |
+
}
|
| 99 |
+
.drawer-slide-left-enter-active {
|
| 100 |
+
animation: drawer-slide-left-enter .25s both ease;
|
| 101 |
+
}
|
| 102 |
+
.drawer-slide-left-leave-active {
|
| 103 |
+
animation: drawer-slide-left-leave .25s both ease;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
@keyframes drawer-slide-right-enter {
|
| 107 |
+
from {
|
| 108 |
+
transform: translateX(100%);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
@keyframes drawer-slide-right-leave {
|
| 112 |
+
to {
|
| 113 |
+
transform: translateX(100%);
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
@keyframes drawer-slide-left-enter {
|
| 117 |
+
from {
|
| 118 |
+
transform: translateX(-100%);
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
@keyframes drawer-slide-left-leave {
|
| 122 |
+
to {
|
| 123 |
+
transform: translateX(-100%);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
</style>
|
frontend/src/components/FileInput.vue
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="file-input" @click="handleClick()">
|
| 3 |
+
<slot></slot>
|
| 4 |
+
<input
|
| 5 |
+
class="input"
|
| 6 |
+
type="file"
|
| 7 |
+
name="upload"
|
| 8 |
+
ref="inputRef"
|
| 9 |
+
:accept="accept"
|
| 10 |
+
@change="$event => handleChange($event)"
|
| 11 |
+
>
|
| 12 |
+
</div>
|
| 13 |
+
</template>
|
| 14 |
+
|
| 15 |
+
<script lang="ts" setup>
|
| 16 |
+
import { ref } from 'vue'
|
| 17 |
+
|
| 18 |
+
withDefaults(defineProps<{
|
| 19 |
+
accept?: string
|
| 20 |
+
}>(), {
|
| 21 |
+
accept: 'image/*',
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
const emit = defineEmits<{
|
| 25 |
+
(event: 'change', payload: FileList): void
|
| 26 |
+
}>()
|
| 27 |
+
|
| 28 |
+
const inputRef = ref<HTMLInputElement>()
|
| 29 |
+
|
| 30 |
+
const handleClick = () => {
|
| 31 |
+
if (!inputRef.value) return
|
| 32 |
+
inputRef.value.value = ''
|
| 33 |
+
inputRef.value.click()
|
| 34 |
+
}
|
| 35 |
+
const handleChange = (e: Event) => {
|
| 36 |
+
const files = (e.target as HTMLInputElement).files
|
| 37 |
+
if (files) emit('change', files)
|
| 38 |
+
}
|
| 39 |
+
</script>
|
| 40 |
+
|
| 41 |
+
<style lang="scss" scoped>
|
| 42 |
+
.input {
|
| 43 |
+
display: none;
|
| 44 |
+
}
|
| 45 |
+
</style>
|
frontend/src/components/FullscreenSpin.vue
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="fullscreen-spin" :class="{ 'mask': mask }" v-if="loading">
|
| 3 |
+
<div class="spin">
|
| 4 |
+
<div class="spinner"></div>
|
| 5 |
+
<div class="text">{{tip}}</div>
|
| 6 |
+
</div>
|
| 7 |
+
</div>
|
| 8 |
+
</template>
|
| 9 |
+
|
| 10 |
+
<script lang="ts" setup>
|
| 11 |
+
withDefaults(defineProps<{
|
| 12 |
+
loading?: boolean
|
| 13 |
+
mask?: boolean
|
| 14 |
+
tip?: string
|
| 15 |
+
}>(), {
|
| 16 |
+
loading: false,
|
| 17 |
+
mask: true,
|
| 18 |
+
tip: '',
|
| 19 |
+
})
|
| 20 |
+
</script>
|
| 21 |
+
|
| 22 |
+
<style lang="scss" scoped>
|
| 23 |
+
.fullscreen-spin {
|
| 24 |
+
position: fixed;
|
| 25 |
+
top: 0;
|
| 26 |
+
bottom: 0;
|
| 27 |
+
left: 0;
|
| 28 |
+
right: 0;
|
| 29 |
+
z-index: 100;
|
| 30 |
+
display: flex;
|
| 31 |
+
justify-content: center;
|
| 32 |
+
align-items: center;
|
| 33 |
+
|
| 34 |
+
&.mask {
|
| 35 |
+
background-color: rgba($color: #f1f1f1, $alpha: .7);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
.spin {
|
| 39 |
+
width: 200px;
|
| 40 |
+
height: 200px;
|
| 41 |
+
position: fixed;
|
| 42 |
+
top: 50%;
|
| 43 |
+
left: 50%;
|
| 44 |
+
margin-top: -100px;
|
| 45 |
+
margin-left: -100px;
|
| 46 |
+
display: flex;
|
| 47 |
+
flex-direction: column;
|
| 48 |
+
justify-content: center;
|
| 49 |
+
align-items: center;
|
| 50 |
+
}
|
| 51 |
+
.spinner {
|
| 52 |
+
width: 36px;
|
| 53 |
+
height: 36px;
|
| 54 |
+
border: 3px solid $themeColor;
|
| 55 |
+
border-top-color: transparent;
|
| 56 |
+
border-radius: 50%;
|
| 57 |
+
animation: spinner .8s linear infinite;
|
| 58 |
+
}
|
| 59 |
+
.text {
|
| 60 |
+
margin-top: 20px;
|
| 61 |
+
color: $themeColor;
|
| 62 |
+
}
|
| 63 |
+
@keyframes spinner {
|
| 64 |
+
0% {
|
| 65 |
+
transform: rotate(0deg);
|
| 66 |
+
}
|
| 67 |
+
100% {
|
| 68 |
+
transform: rotate(360deg);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
</style>
|
frontend/src/components/GradientBar.vue
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="gradient-bar">
|
| 3 |
+
<div class="bar" ref="barRef" :style="{ backgroundImage: gradientStyle }" @click="$event => addPoint($event)"></div>
|
| 4 |
+
<div class="point"
|
| 5 |
+
:class="{ 'active': index === i }"
|
| 6 |
+
v-for="(item, i) in points"
|
| 7 |
+
:key="item.pos + '-' + i"
|
| 8 |
+
:style="{
|
| 9 |
+
backgroundColor: item.color,
|
| 10 |
+
left: `calc(${item.pos}% - 5px)`,
|
| 11 |
+
}"
|
| 12 |
+
@mousedown.left="movePoint(i)"
|
| 13 |
+
@click.right="removePoint(i)"
|
| 14 |
+
></div>
|
| 15 |
+
</div>
|
| 16 |
+
</template>
|
| 17 |
+
|
| 18 |
+
<script lang="ts" setup>
|
| 19 |
+
import type { GradientColor } from '@/types/slides'
|
| 20 |
+
import { ref, computed, watchEffect } from 'vue'
|
| 21 |
+
|
| 22 |
+
const props = defineProps<{
|
| 23 |
+
value: GradientColor[]
|
| 24 |
+
index: number
|
| 25 |
+
}>()
|
| 26 |
+
|
| 27 |
+
const emit = defineEmits<{
|
| 28 |
+
(event: 'update:value', payload: GradientColor[]): void
|
| 29 |
+
(event: 'update:index', payload: number): void
|
| 30 |
+
}>()
|
| 31 |
+
|
| 32 |
+
const points = ref<GradientColor[]>([])
|
| 33 |
+
|
| 34 |
+
const barRef = ref<HTMLElement>()
|
| 35 |
+
|
| 36 |
+
watchEffect(() => {
|
| 37 |
+
points.value = props.value
|
| 38 |
+
if (props.index > props.value.length - 1) emit('update:index', 0)
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
const gradientStyle = computed(() => {
|
| 42 |
+
const list = points.value.map(item => `${item.color} ${item.pos}%`)
|
| 43 |
+
return `linear-gradient(to right, ${list.join(',')})`
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
const removePoint = (index: number) => {
|
| 47 |
+
if (props.value.length <= 2) return
|
| 48 |
+
|
| 49 |
+
let targetIndex = 0
|
| 50 |
+
|
| 51 |
+
if (index === props.index) {
|
| 52 |
+
targetIndex = (index - 1 < 0) ? 0 : index - 1
|
| 53 |
+
}
|
| 54 |
+
else if (props.index === props.value.length - 1) {
|
| 55 |
+
targetIndex = props.value.length - 2
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const values = props.value.filter((item, _index) => _index !== index)
|
| 59 |
+
emit('update:index', targetIndex)
|
| 60 |
+
emit('update:value', values)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const movePoint = (index: number) => {
|
| 64 |
+
let isMouseDown = true
|
| 65 |
+
|
| 66 |
+
document.onmousemove = e => {
|
| 67 |
+
if (!isMouseDown) return
|
| 68 |
+
if (!barRef.value) return
|
| 69 |
+
|
| 70 |
+
let pos = Math.round((e.clientX - barRef.value.getBoundingClientRect().left) / barRef.value.clientWidth * 100)
|
| 71 |
+
if (pos > 100) pos = 100
|
| 72 |
+
if (pos < 0) pos = 0
|
| 73 |
+
|
| 74 |
+
points.value = points.value.map((item, _index) => {
|
| 75 |
+
if (_index === index) return { ...item, pos }
|
| 76 |
+
return item
|
| 77 |
+
})
|
| 78 |
+
}
|
| 79 |
+
document.onmouseup = () => {
|
| 80 |
+
isMouseDown = false
|
| 81 |
+
|
| 82 |
+
const point = points.value[index]
|
| 83 |
+
const _points = [...points.value]
|
| 84 |
+
_points.splice(index, 1)
|
| 85 |
+
|
| 86 |
+
let targetIndex = 0
|
| 87 |
+
for (let i = 0; i < _points.length; i++) {
|
| 88 |
+
if (point.pos > _points[i].pos) targetIndex = i + 1
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
_points.splice(targetIndex, 0, point)
|
| 92 |
+
|
| 93 |
+
emit('update:index', targetIndex)
|
| 94 |
+
emit('update:value', _points)
|
| 95 |
+
|
| 96 |
+
document.onmousemove = null
|
| 97 |
+
document.onmouseup = null
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const addPoint = (e: MouseEvent) => {
|
| 102 |
+
if (props.value.length >= 6) return
|
| 103 |
+
if (!barRef.value) return
|
| 104 |
+
const pos = Math.round((e.clientX - barRef.value.getBoundingClientRect().left) / barRef.value.clientWidth * 100)
|
| 105 |
+
|
| 106 |
+
let targetIndex = 0
|
| 107 |
+
for (let i = 0; i < props.value.length; i++) {
|
| 108 |
+
if (pos > props.value[i].pos) targetIndex = i + 1
|
| 109 |
+
}
|
| 110 |
+
const color = props.value[targetIndex - 1] ? props.value[targetIndex - 1].color : props.value[targetIndex].color
|
| 111 |
+
const values = [...props.value]
|
| 112 |
+
values.splice(targetIndex, 0, { pos, color })
|
| 113 |
+
emit('update:index', targetIndex)
|
| 114 |
+
emit('update:value', values)
|
| 115 |
+
}
|
| 116 |
+
</script>
|
| 117 |
+
|
| 118 |
+
<style lang="scss" scoped>
|
| 119 |
+
.gradient-bar {
|
| 120 |
+
width: calc(100% - 10px);
|
| 121 |
+
height: 18px;
|
| 122 |
+
padding: 1px 0;
|
| 123 |
+
margin: 3px 0;
|
| 124 |
+
position: relative;
|
| 125 |
+
left: 5px;
|
| 126 |
+
|
| 127 |
+
.bar {
|
| 128 |
+
height: 16px;
|
| 129 |
+
border: 1px solid #d9d9d9;
|
| 130 |
+
}
|
| 131 |
+
.point {
|
| 132 |
+
width: 10px;
|
| 133 |
+
height: 18px;
|
| 134 |
+
background-color: #fff;
|
| 135 |
+
position: absolute;
|
| 136 |
+
top: 0;
|
| 137 |
+
border: 2px solid #fff;
|
| 138 |
+
outline: 1px solid #d9d9d9;
|
| 139 |
+
box-shadow: 0 0 2px 2px #d9d9d9;
|
| 140 |
+
border-radius: 1px;
|
| 141 |
+
cursor: pointer;
|
| 142 |
+
|
| 143 |
+
&.active {
|
| 144 |
+
outline: 1px solid $themeColor;
|
| 145 |
+
box-shadow: 0 0 2px 2px $themeColor;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
</style>
|