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>
|