CatPtain commited on
Commit
89ce340
·
verified ·
1 Parent(s): 38717b4

Upload 339 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +22 -0
  2. frontend/src/App.vue +137 -0
  3. frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 +3 -0
  4. frontend/src/assets/fonts/CangerXiaowanzi.woff2 +3 -0
  5. frontend/src/assets/fonts/DeYiHei.woff2 +3 -0
  6. frontend/src/assets/fonts/FangZhengFangSong.woff2 +3 -0
  7. frontend/src/assets/fonts/FangZhengHeiTi.woff2 +3 -0
  8. frontend/src/assets/fonts/FangZhengKaiTi.woff2 +3 -0
  9. frontend/src/assets/fonts/FangZhengShuSong.woff2 +3 -0
  10. frontend/src/assets/fonts/FengguangMingrui.woff2 +3 -0
  11. frontend/src/assets/fonts/LXGWWenKai.woff2 +3 -0
  12. frontend/src/assets/fonts/MiSans.woff2 +3 -0
  13. frontend/src/assets/fonts/RuiziZhenyan.woff2 +3 -0
  14. frontend/src/assets/fonts/ShetuModernSquare.woff2 +3 -0
  15. frontend/src/assets/fonts/SourceHanSans.woff2 +3 -0
  16. frontend/src/assets/fonts/SourceHanSerif.woff2 +3 -0
  17. frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 +3 -0
  18. frontend/src/assets/fonts/SucaiJishiKangkang.woff2 +3 -0
  19. frontend/src/assets/fonts/TuniuRounded.woff2 +3 -0
  20. frontend/src/assets/fonts/WenDingPLKaiTi.woff2 +3 -0
  21. frontend/src/assets/fonts/YousheTitleBlack.woff2 +3 -0
  22. frontend/src/assets/fonts/ZcoolHappy.woff2 +3 -0
  23. frontend/src/assets/fonts/ZhuQueFangSong.woff2 +3 -0
  24. frontend/src/assets/fonts/ZizhiQuXiMai.woff2 +3 -0
  25. frontend/src/assets/styles/font.scss +9 -0
  26. frontend/src/assets/styles/global.scss +138 -0
  27. frontend/src/assets/styles/mixin.scss +42 -0
  28. frontend/src/assets/styles/prosemirror.scss +102 -0
  29. frontend/src/assets/styles/variable.scss +13 -0
  30. frontend/src/components.d.ts +7 -0
  31. frontend/src/components/Button.vue +116 -0
  32. frontend/src/components/ButtonGroup.vue +86 -0
  33. frontend/src/components/Checkbox.vue +109 -0
  34. frontend/src/components/CheckboxButton.vue +21 -0
  35. frontend/src/components/ColorButton.vue +42 -0
  36. frontend/src/components/ColorListButton.vue +58 -0
  37. frontend/src/components/ColorPicker/Alpha.vue +107 -0
  38. frontend/src/components/ColorPicker/Checkboard.vue +60 -0
  39. frontend/src/components/ColorPicker/EditableInput.vue +69 -0
  40. frontend/src/components/ColorPicker/Hue.vue +117 -0
  41. frontend/src/components/ColorPicker/Saturation.vue +108 -0
  42. frontend/src/components/ColorPicker/index.vue +443 -0
  43. frontend/src/components/Contextmenu/MenuContent.vue +137 -0
  44. frontend/src/components/Contextmenu/index.vue +80 -0
  45. frontend/src/components/Contextmenu/types.ts +14 -0
  46. frontend/src/components/Divider.vue +34 -0
  47. frontend/src/components/Drawer.vue +126 -0
  48. frontend/src/components/FileInput.vue +45 -0
  49. frontend/src/components/FullscreenSpin.vue +71 -0
  50. 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>