diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..cee112be702848ebc41a047608c865d7149cdd6f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,25 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/CangerXiaowanzi.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/DeYiHei.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/FangZhengFangSong.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/FangZhengHeiTi.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/FangZhengKaiTi.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/FangZhengShuSong.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/FengguangMingrui.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/LXGWWenKai.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/MiSans.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/RuiziZhenyan.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/ShetuModernSquare.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/SourceHanSans.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/SourceHanSerif.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/SucaiJishiKangkang.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/TuniuRounded.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/WenDingPLKaiTi.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/YousheTitleBlack.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/ZcoolHappy.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/ZhuQueFangSong.woff2 filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/fonts/ZizhiQuXiMai.woff2 filter=lfs diff=lfs merge=lfs -text diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000000000000000000000000000000000000..8eb175f6d1d0aa02d9d68e69f913caed0ad3c4e5 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 b/frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d6c6435fc9f5bc3d0b64a55b92d994d714735eca --- /dev/null +++ b/frontend/src/assets/fonts/AlibabaPuHuiTi.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e03857d7e181a9201baee2edef8dc6dba054dcb42a4b763cf75bb3dbdee2b321 +size 4150196 diff --git a/frontend/src/assets/fonts/CangerXiaowanzi.woff2 b/frontend/src/assets/fonts/CangerXiaowanzi.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5663c257d4aa5be57d49b74030b66c2d103a218f --- /dev/null +++ b/frontend/src/assets/fonts/CangerXiaowanzi.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18e5bccdcfa630d8a953ae610cc066f0a2941690fc84aced856062cd2a27d88d +size 695832 diff --git a/frontend/src/assets/fonts/DeYiHei.woff2 b/frontend/src/assets/fonts/DeYiHei.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..93a5abd70f93d8fc0cf6541314637f4edb053cb1 --- /dev/null +++ b/frontend/src/assets/fonts/DeYiHei.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ecd63faa61348c4f2480b5fddd8c48fd76f12542328eaa530b2a69badff68ab9 +size 1361616 diff --git a/frontend/src/assets/fonts/FangZhengFangSong.woff2 b/frontend/src/assets/fonts/FangZhengFangSong.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0f3964113548fc3e6bcfb8f0b7c2082054f92d95 --- /dev/null +++ b/frontend/src/assets/fonts/FangZhengFangSong.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d50e0c75d1688cb49aa3e50dc95ba9eb7a695f6593d805149f4abb78a3dd133b +size 1538112 diff --git a/frontend/src/assets/fonts/FangZhengHeiTi.woff2 b/frontend/src/assets/fonts/FangZhengHeiTi.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e26e1336b985e8d9be33dee657695640b6552d84 --- /dev/null +++ b/frontend/src/assets/fonts/FangZhengHeiTi.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:481eee4596a4125253ebf81edf01102298ea85cdb12b850d27863503ccb32518 +size 1189808 diff --git a/frontend/src/assets/fonts/FangZhengKaiTi.woff2 b/frontend/src/assets/fonts/FangZhengKaiTi.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8bda5264a0ab43bf39417226eb6b948964848f9b --- /dev/null +++ b/frontend/src/assets/fonts/FangZhengKaiTi.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a43bcc7319f7c570920c1a7302e2d73cb65378897bdb73a8445cf84559655015 +size 1656040 diff --git a/frontend/src/assets/fonts/FangZhengShuSong.woff2 b/frontend/src/assets/fonts/FangZhengShuSong.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..640b2779707d068686f1120fe6f11b1943795565 --- /dev/null +++ b/frontend/src/assets/fonts/FangZhengShuSong.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b143be48bb5699ae271a1f81c41872499634e896b9c66c1fe3b28bcc4708a4e0 +size 1185016 diff --git a/frontend/src/assets/fonts/FengguangMingrui.woff2 b/frontend/src/assets/fonts/FengguangMingrui.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2b057a73e01f4349ff9a5f50ce5cc14ad48494a2 --- /dev/null +++ b/frontend/src/assets/fonts/FengguangMingrui.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:227d498f6b1ae157e079dccb6cd563f9b89b789b99be9dd660888f928c398660 +size 321284 diff --git a/frontend/src/assets/fonts/LXGWWenKai.woff2 b/frontend/src/assets/fonts/LXGWWenKai.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..59acd8e58de1c5be32f4df306c27c01c212ed6b5 --- /dev/null +++ b/frontend/src/assets/fonts/LXGWWenKai.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cf730147cd4923546015110f9085216b023df388e2c5ca270b5db17d8de496b +size 1826736 diff --git a/frontend/src/assets/fonts/MiSans.woff2 b/frontend/src/assets/fonts/MiSans.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..89feb5958b7bc034b896759d56e63da5cccd7c8c --- /dev/null +++ b/frontend/src/assets/fonts/MiSans.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d7a4ba4faf18306e446787c1ab1bd1e90c9f27bfa937cd8eb3469c7504e563f +size 4847960 diff --git a/frontend/src/assets/fonts/RuiziZhenyan.woff2 b/frontend/src/assets/fonts/RuiziZhenyan.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9cd8fa893b18bcee1cc3f33d4ef3b5b689e4babd --- /dev/null +++ b/frontend/src/assets/fonts/RuiziZhenyan.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fac4031d38f8497766ac3019b094c2fc91326c4257307b33a6c99b72cb5a529d +size 616772 diff --git a/frontend/src/assets/fonts/ShetuModernSquare.woff2 b/frontend/src/assets/fonts/ShetuModernSquare.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4b16ac3c4381a6d0ecc783790190325389692476 --- /dev/null +++ b/frontend/src/assets/fonts/ShetuModernSquare.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6a319f07a79112133b61163eeaa87e632113fccb18a3655a9fbe07a912f1097 +size 790024 diff --git a/frontend/src/assets/fonts/SourceHanSans.woff2 b/frontend/src/assets/fonts/SourceHanSans.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9d95ff4b3ce001f967adc0b1365b0cc555aab8ec --- /dev/null +++ b/frontend/src/assets/fonts/SourceHanSans.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baef3d9c86508d957eee369b1289a1a918a6f1f394f10bd1129332c84f91c671 +size 7511656 diff --git a/frontend/src/assets/fonts/SourceHanSerif.woff2 b/frontend/src/assets/fonts/SourceHanSerif.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..91b9f9a83ccca5c084f6048288fa23b887f36845 --- /dev/null +++ b/frontend/src/assets/fonts/SourceHanSerif.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a39a09928cb92aca1e1bf72d841718ecb9c9549c1d1fa95620b8b42f49434db +size 10413080 diff --git a/frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 b/frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..88192a023e9981adb9bc28a62a8cc09d309cdfac --- /dev/null +++ b/frontend/src/assets/fonts/SucaiJishiCoolSquare.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fade334be8d8e9bb68d43d43f7ab811b51433bc92d5201b30be191e026e0c913 +size 218068 diff --git a/frontend/src/assets/fonts/SucaiJishiKangkang.woff2 b/frontend/src/assets/fonts/SucaiJishiKangkang.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..987fb90a7573f5e14eb4b9f8cc64ef4acb799cbd --- /dev/null +++ b/frontend/src/assets/fonts/SucaiJishiKangkang.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b874c03c6a6006b7d4f2dbaf01f3c46b51554def44a0ed1f01d126c7ff58ff2 +size 500448 diff --git a/frontend/src/assets/fonts/TuniuRounded.woff2 b/frontend/src/assets/fonts/TuniuRounded.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4bfde7d557252170883ff8736328e29e4b4122c5 --- /dev/null +++ b/frontend/src/assets/fonts/TuniuRounded.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f07953ca9d512298bc9a770d0236e992211ab3cebd6f2afd90e6b0da9da76da4 +size 108684 diff --git a/frontend/src/assets/fonts/WenDingPLKaiTi.woff2 b/frontend/src/assets/fonts/WenDingPLKaiTi.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f909adfda7065470d07df44100af4d016dcaa926 --- /dev/null +++ b/frontend/src/assets/fonts/WenDingPLKaiTi.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c91fd0d539e35631dd5086451bc37732fa19e9eb9f7202eb881d6616f6d3634 +size 1962288 diff --git a/frontend/src/assets/fonts/YousheTitleBlack.woff2 b/frontend/src/assets/fonts/YousheTitleBlack.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ddf9e8677dcd4cff0969503ad46e7546b832defa --- /dev/null +++ b/frontend/src/assets/fonts/YousheTitleBlack.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58d9eeac1664dbadefc309a26e6496f3d03915ec32d78edc66bce4760db74d77 +size 642944 diff --git a/frontend/src/assets/fonts/ZcoolHappy.woff2 b/frontend/src/assets/fonts/ZcoolHappy.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..86739e94227df9daf80e41daf1326436eb22c023 --- /dev/null +++ b/frontend/src/assets/fonts/ZcoolHappy.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee4391316d9e560dbf96d92c7a9a12a73b0ac39b4f77662b43c2bd4fd16dcde7 +size 937400 diff --git a/frontend/src/assets/fonts/ZhuQueFangSong.woff2 b/frontend/src/assets/fonts/ZhuQueFangSong.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a3aae7e1d54a409968a601cbf389e7bb5d8766ea --- /dev/null +++ b/frontend/src/assets/fonts/ZhuQueFangSong.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81dd160d3c4608a70cc9eb794e4aaf8b1a6b07dec1a5284c48e402f0ffba1459 +size 2598904 diff --git a/frontend/src/assets/fonts/ZizhiQuXiMai.woff2 b/frontend/src/assets/fonts/ZizhiQuXiMai.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e63c15f82fd3ce1da34ebe988616a401ad1a0dc0 --- /dev/null +++ b/frontend/src/assets/fonts/ZizhiQuXiMai.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cce3fff22f295562a2970044c32ba330f32529503ca77c3d8c26649934f65f3 +size 678188 diff --git a/frontend/src/assets/styles/font.scss b/frontend/src/assets/styles/font.scss new file mode 100644 index 0000000000000000000000000000000000000000..e318bbb368b17d8e60bf69da0558a3e572692bf8 --- /dev/null +++ b/frontend/src/assets/styles/font.scss @@ -0,0 +1,9 @@ +$fonts: 'SourceHanSans', 'SourceHanSerif', 'FangZhengHeiTi', 'FangZhengKaiTi', 'FangZhengShuSong', 'FangZhengFangSong', 'AlibabaPuHuiTi', 'ZhuQueFangSong', 'LXGWWenKai', 'WenDingPLKaiTi', 'DeYiHei', 'MiSans', 'CangerXiaowanzi', 'YousheTitleBlack', 'FengguangMingrui', 'ShetuModernSquare', 'ZcoolHappy', 'ZizhiQuXiMai', 'SucaiJishiKangkang', 'SucaiJishiCoolSquare', 'TuniuRounded', 'RuiziZhenyan'; + +@each $font in $fonts { + @font-face { + font-display: swap; + font-family: $font; + src: url('https://asset.pptist.cn/font/#{$font}.woff2') format('woff2'); + } +} \ No newline at end of file diff --git a/frontend/src/assets/styles/global.scss b/frontend/src/assets/styles/global.scss new file mode 100644 index 0000000000000000000000000000000000000000..4734dde31b0ed771bc5231e2a896a35eda5d1eb8 --- /dev/null +++ b/frontend/src/assets/styles/global.scss @@ -0,0 +1,138 @@ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + vertical-align: baseline; + box-sizing: border-box; +} + +*::before, +*::after { + box-sizing: border-box; +} + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + background-color: #fff; + color: $textColor; +} + +body { + 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'; +} + +ol, +ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote::before, +blockquote::after, +q::before, +q::after { + content: ''; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +a { + text-decoration: none; + color: $themeColor; +} + +img { + vertical-align: middle; + border-style: none; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +mark.active { + background-color: #ff9632; +} + +input, +button, +select, +optgroup, +textarea { + color: inherit; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +textarea { + overflow: auto; + resize: vertical; +} + +a, +area, +button, +[role='button'], +input:not([type='range']), +label, +select, +summary, +textarea { + touch-action: manipulation; +} + +::-webkit-scrollbar { + width: 5px; + height: 5px; + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background-color: #e1e1e1; + border-radius: 3px; +} diff --git a/frontend/src/assets/styles/mixin.scss b/frontend/src/assets/styles/mixin.scss new file mode 100644 index 0000000000000000000000000000000000000000..229bc777ba3d038bb9cccae12bba98163412e3bd --- /dev/null +++ b/frontend/src/assets/styles/mixin.scss @@ -0,0 +1,42 @@ +@mixin ellipsis-oneline() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +@mixin ellipsis-multiline($line: 2) { + word-wrap: break-word; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: $line; + -webkit-box-orient: vertical; +} + +@mixin flex-grid-layout() { + display: flex; + flex-wrap: wrap; + align-content: flex-start; +} + +@mixin flex-grid-layout-children($col, $colWidth) { + width: $colWidth; + margin-bottom: calc(#{100 - $col * $colWidth} / #{$col - 1}); + + &:not(:nth-child(#{$col}n)) { + margin-right: calc(#{100 - $col * $colWidth} / #{$col - 1}); + } +} + +@mixin overflow-overlay() { + overflow: auto; + overflow: overlay; +} + +@mixin absolute-0() { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} \ No newline at end of file diff --git a/frontend/src/assets/styles/prosemirror.scss b/frontend/src/assets/styles/prosemirror.scss new file mode 100644 index 0000000000000000000000000000000000000000..7da8e34f486d99c3324f8c694bddb52faef2e536 --- /dev/null +++ b/frontend/src/assets/styles/prosemirror.scss @@ -0,0 +1,102 @@ +.ProseMirror, .ProseMirror-static { + outline: 0; + border: 0; + font-size: 16px; + word-break: break-word; + white-space: normal; + + &:not(.ProseMirror-static) { + user-select: text; + } + + ::selection { + background-color: rgba($themeColor, 0.25); + color: inherit; + } + + p { + margin: 0; + margin-top: var(--paragraphSpace); + } + p:first-child { + margin-top: 0; + } + + ul, ol, li { + margin: 0; + margin-top: var(--paragraphSpace); + } + ul { + list-style-type: disc; + padding-inline-start: 1.25em; + + li { + list-style-type: inherit; + padding: 0.125em 0; + } + } + + ol { + list-style-type: decimal; + padding-inline-start: 1.25em; + + li { + list-style-type: inherit; + padding: 0.125em 0; + } + } + + code { + background-color: #f1f1f1; + padding: 2px 6px; + margin: 0 1px; + border-radius: 4px; + font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; + } + + sup { + vertical-align: super; + font-size: smaller; + } + sub { + vertical-align: sub; + font-size: smaller; + } + + blockquote { + overflow: hidden; + padding: 0 1.2em; + margin: 0.6em 0; + font-style: italic; + border-left: 4px solid #e0e0e0; + } + + [data-indent='1'] { + padding-left: 1em; + } + [data-indent='2'] { + padding-left: 2em; + } + [data-indent='3'] { + padding-left: 3em; + } + [data-indent='4'] { + padding-left: 4em; + } + [data-indent='5'] { + padding-left: 5em; + } + [data-indent='6'] { + padding-left: 6em; + } + [data-indent='7'] { + padding-left: 7em; + } + [data-indent='8'] { + padding-left: 8em; + } +} + +.ProseMirror-selectednode { + outline: none !important; +} \ No newline at end of file diff --git a/frontend/src/assets/styles/variable.scss b/frontend/src/assets/styles/variable.scss new file mode 100644 index 0000000000000000000000000000000000000000..f8b6d8b2b64c772decb5c7393baab802867dbf15 --- /dev/null +++ b/frontend/src/assets/styles/variable.scss @@ -0,0 +1,13 @@ +$themeColor: #d14424; +$themeHoverColor: #de6949; +$textColor: #41464b; +$borderColor: #e5e7eb; +$lightGray: #f9f9f9; + +$boxShadow: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1); + +$transitionDelay: .2s; +$transitionDelayFast: .1s; +$transitionDelaySlow: .3s; + +$borderRadius: 2px; \ No newline at end of file diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe4add540293a9a32aa1f232528223f91fc92d7a --- /dev/null +++ b/frontend/src/components.d.ts @@ -0,0 +1,7 @@ +import type { Icons } from '@/plugins/icon' + +declare module 'vue' { + export type GlobalComponents = Icons +} + +export {} \ No newline at end of file diff --git a/frontend/src/components/Button.vue b/frontend/src/components/Button.vue new file mode 100644 index 0000000000000000000000000000000000000000..499fe7260b2b992480546e2b4fd72b45a6a72d5a --- /dev/null +++ b/frontend/src/components/Button.vue @@ -0,0 +1,116 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ButtonGroup.vue b/frontend/src/components/ButtonGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..575bf2711401d9f45fd438e479a8de5ca112c86e --- /dev/null +++ b/frontend/src/components/ButtonGroup.vue @@ -0,0 +1,86 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Checkbox.vue b/frontend/src/components/Checkbox.vue new file mode 100644 index 0000000000000000000000000000000000000000..96a613ec07e10fea34130cdf32fc4881223f77a8 --- /dev/null +++ b/frontend/src/components/Checkbox.vue @@ -0,0 +1,109 @@ + + handleChange($event)" + > + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/CheckboxButton.vue b/frontend/src/components/CheckboxButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..722667d06b75030595e6d127ce529faf03a8e0a0 --- /dev/null +++ b/frontend/src/components/CheckboxButton.vue @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorButton.vue b/frontend/src/components/ColorButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..3a0e65454fa7ff3e0711ffd7efc979b97e897cb2 --- /dev/null +++ b/frontend/src/components/ColorButton.vue @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorListButton.vue b/frontend/src/components/ColorListButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..1d22db64072bb54ef479bc5da67f6e7739f195a0 --- /dev/null +++ b/frontend/src/components/ColorListButton.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorPicker/Alpha.vue b/frontend/src/components/ColorPicker/Alpha.vue new file mode 100644 index 0000000000000000000000000000000000000000..decb567c55ccc62ba4b5c054ec54635457fb8cec --- /dev/null +++ b/frontend/src/components/ColorPicker/Alpha.vue @@ -0,0 +1,107 @@ + + + + + + + handleMouseDown($event)" + > + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorPicker/Checkboard.vue b/frontend/src/components/ColorPicker/Checkboard.vue new file mode 100644 index 0000000000000000000000000000000000000000..461559e3f933cd1e4d717cf531d6cec2a9e39ea5 --- /dev/null +++ b/frontend/src/components/ColorPicker/Checkboard.vue @@ -0,0 +1,60 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorPicker/EditableInput.vue b/frontend/src/components/ColorPicker/EditableInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..9f33cf0eed177518bf46f2eb2bf2dbc0b85da369 --- /dev/null +++ b/frontend/src/components/ColorPicker/EditableInput.vue @@ -0,0 +1,69 @@ + + + handleInput($event)" + > + + + + + + diff --git a/frontend/src/components/ColorPicker/Hue.vue b/frontend/src/components/ColorPicker/Hue.vue new file mode 100644 index 0000000000000000000000000000000000000000..51f0e26ef362254c4a0364fbfa187fddaa825195 --- /dev/null +++ b/frontend/src/components/ColorPicker/Hue.vue @@ -0,0 +1,117 @@ + + + handleMouseDown($event)" + > + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorPicker/Saturation.vue b/frontend/src/components/ColorPicker/Saturation.vue new file mode 100644 index 0000000000000000000000000000000000000000..155149b6625be55634d89d7b021aa394f78e12f6 --- /dev/null +++ b/frontend/src/components/ColorPicker/Saturation.vue @@ -0,0 +1,108 @@ + + handleMouseDown($event)" + > + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ColorPicker/index.vue b/frontend/src/components/ColorPicker/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..97e0ce879e7e7641dec506c204ceff143104f039 --- /dev/null +++ b/frontend/src/components/ColorPicker/index.vue @@ -0,0 +1,443 @@ + + + + changeColor(value)" /> + + + + + + + + + changeColor(value)" /> + + + changeColor(value)" /> + + + + + + changeColor(value)" /> + + + + + + + + + + + + + + + + + + + + + 最近使用: + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Contextmenu/MenuContent.vue b/frontend/src/components/Contextmenu/MenuContent.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5219bf18f1c67d73e859b07ec9191980c4a1319 --- /dev/null +++ b/frontend/src/components/Contextmenu/MenuContent.vue @@ -0,0 +1,137 @@ + + + + + + {{menu.text}} + {{menu.subText}} + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Contextmenu/index.vue b/frontend/src/components/Contextmenu/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..7a4fa52de72387cdda0231230d732aed66ef32e3 --- /dev/null +++ b/frontend/src/components/Contextmenu/index.vue @@ -0,0 +1,80 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Contextmenu/types.ts b/frontend/src/components/Contextmenu/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7cad39d79a8c40497f5b6abe9f45767101aff20 --- /dev/null +++ b/frontend/src/components/Contextmenu/types.ts @@ -0,0 +1,14 @@ +export interface ContextmenuItem { + text?: string + subText?: string + divider?: boolean + disable?: boolean + hide?: boolean + children?: ContextmenuItem[] + handler?: (el: HTMLElement) => void +} + +export interface Axis { + x: number + y: number +} \ No newline at end of file diff --git a/frontend/src/components/Divider.vue b/frontend/src/components/Divider.vue new file mode 100644 index 0000000000000000000000000000000000000000..5ce1056b58003fcaa3c1dfae18fa5c41df1a6c0f --- /dev/null +++ b/frontend/src/components/Divider.vue @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Drawer.vue b/frontend/src/components/Drawer.vue new file mode 100644 index 0000000000000000000000000000000000000000..9b04f583817a91620e2010a9c7d9a9ce44085f2f --- /dev/null +++ b/frontend/src/components/Drawer.vue @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/FileInput.vue b/frontend/src/components/FileInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..f5df62f6f60b03c2b5319a5cfd895c5aafc3584d --- /dev/null +++ b/frontend/src/components/FileInput.vue @@ -0,0 +1,45 @@ + + + + handleChange($event)" + > + + + + + + \ No newline at end of file diff --git a/frontend/src/components/FullscreenSpin.vue b/frontend/src/components/FullscreenSpin.vue new file mode 100644 index 0000000000000000000000000000000000000000..cad3531ca3e537d019433bc398986180bb392dd3 --- /dev/null +++ b/frontend/src/components/FullscreenSpin.vue @@ -0,0 +1,71 @@ + + + + + {{tip}} + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/GradientBar.vue b/frontend/src/components/GradientBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..3b465c2f9bc1aedcc9ac3e1042f3fcc3e34a0423 --- /dev/null +++ b/frontend/src/components/GradientBar.vue @@ -0,0 +1,149 @@ + + + addPoint($event)"> + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Input.vue b/frontend/src/components/Input.vue new file mode 100644 index 0000000000000000000000000000000000000000..cd7d43b4d74937b8f38ae293ea4c9484222746d8 --- /dev/null +++ b/frontend/src/components/Input.vue @@ -0,0 +1,136 @@ + + + + + + handleInput($event)" + @focus="$event => handleFocus($event)" + @blur="$event => handleBlur($event)" + @change="$event => emit('change', $event)" + @keydown.enter="$event => emit('enter', $event)" + @keydown.backspace="$event => emit('backspace', $event)" + /> + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/LaTeXEditor/FormulaContent.vue b/frontend/src/components/LaTeXEditor/FormulaContent.vue new file mode 100644 index 0000000000000000000000000000000000000000..6b87e1e8670b7b397337dfe47463f90f60cb035a --- /dev/null +++ b/frontend/src/components/LaTeXEditor/FormulaContent.vue @@ -0,0 +1,57 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/LaTeXEditor/SymbolContent.vue b/frontend/src/components/LaTeXEditor/SymbolContent.vue new file mode 100644 index 0000000000000000000000000000000000000000..96e86a21197927edb02b76590c464d2526e7e371 --- /dev/null +++ b/frontend/src/components/LaTeXEditor/SymbolContent.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/components/LaTeXEditor/hfmath.ts b/frontend/src/components/LaTeXEditor/hfmath.ts new file mode 100644 index 0000000000000000000000000000000000000000..57842bbe6d2ade328591fe33a9098523be926f1f --- /dev/null +++ b/frontend/src/components/LaTeXEditor/hfmath.ts @@ -0,0 +1,5 @@ +import { hfmath, CONFIG as hfmathConfig } from 'hfmath' + +hfmathConfig.SUB_SUP_SCALE = 0.5 + +export { hfmath } \ No newline at end of file diff --git a/frontend/src/components/LaTeXEditor/index.vue b/frontend/src/components/LaTeXEditor/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..d16d879dc79681bbc6f3f9e94be2bb31bafa30ef --- /dev/null +++ b/frontend/src/components/LaTeXEditor/index.vue @@ -0,0 +1,265 @@ + + + + + + + + + 公式预览 + + + + + + + + + + + + + + + + + + + {{item.label}} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue new file mode 100644 index 0000000000000000000000000000000000000000..878dacd761ac86f82265cb3e00d0b201ce5875c0 --- /dev/null +++ b/frontend/src/components/Message.vue @@ -0,0 +1,182 @@ + + + + + + + + + + + + {{ title }} + {{ message }} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..22128a414901ba1b66ce2c20cbafa6242f842288 --- /dev/null +++ b/frontend/src/components/Modal.vue @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/MoveablePanel.vue b/frontend/src/components/MoveablePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..8efd89814fffe740332791bc5cab78657d078158 --- /dev/null +++ b/frontend/src/components/MoveablePanel.vue @@ -0,0 +1,220 @@ + + + + startMove($event)"> + {{title}} + + + + + + + + + startMove($event)"> + + + + startResize($event)"> + + + + + + \ No newline at end of file diff --git a/frontend/src/components/NumberInput.vue b/frontend/src/components/NumberInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ee079be1b5c2a46f38223d1645500df55bc24f4 --- /dev/null +++ b/frontend/src/components/NumberInput.vue @@ -0,0 +1,201 @@ + + + + + + + emit('input', $event)" + @focus="$event => handleFocus($event)" + @blur="$event => handleBlur($event)" + @change="$event => emit('change', $event)" + @keydown.enter="$event => handleEnter($event)" + /> + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/OutlineEditor.vue b/frontend/src/components/OutlineEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..eb5b4f2671849cc95203e4c647db5a8eb327f27b --- /dev/null +++ b/frontend/src/components/OutlineEditor.vue @@ -0,0 +1,350 @@ + + + + handleBlur($event, item)" + @enter="$event => handleEnter($event, item)" + @backspace="$event => handleBackspace($event, item)" + /> + {{ item.content }} + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/PPTManager.vue b/frontend/src/components/PPTManager.vue new file mode 100644 index 0000000000000000000000000000000000000000..7bff37d81dfbafb9fe5efb48cb369ea15b75544b --- /dev/null +++ b/frontend/src/components/PPTManager.vue @@ -0,0 +1,165 @@ + + + + 我的演示文稿 + + + 新建PPT + + + 保存当前 + + + 退出登录 + + + + + + loadPPT(ppt.name)" + > + + {{ ppt.title || ppt.name }} + {{ ppt.repoUrl }} + + + handleGenerateShareLinks(0)" class="btn-share"> + 分享 + + deletePPT(ppt.name)" class="btn-delete"> + 删除 + + + + + + + 还没有演示文稿,创建一个开始吧! + + + + 加载中... + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Popover.vue b/frontend/src/components/Popover.vue new file mode 100644 index 0000000000000000000000000000000000000000..07df30689c781fecbe51e13642f4b0af9f07c7a0 --- /dev/null +++ b/frontend/src/components/Popover.vue @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/PopoverMenuItem.vue b/frontend/src/components/PopoverMenuItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..5ac7f2f901a56a1b015f9376c44c47cc437dc12d --- /dev/null +++ b/frontend/src/components/PopoverMenuItem.vue @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/RadioButton.vue b/frontend/src/components/RadioButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d57657a583bc1deefdeb470d8fc7cfa1ade6702 --- /dev/null +++ b/frontend/src/components/RadioButton.vue @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/RadioGroup.vue b/frontend/src/components/RadioGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..1626c3b140726a25bfe6a8634437fb3884d5d7d8 --- /dev/null +++ b/frontend/src/components/RadioGroup.vue @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Select.vue b/frontend/src/components/Select.vue new file mode 100644 index 0000000000000000000000000000000000000000..a02d467e571905be94c6c6e91885669b13c9941d --- /dev/null +++ b/frontend/src/components/Select.vue @@ -0,0 +1,204 @@ + + + + {{ value }} + + + + + + + + + + + + + + + {{ option.label }} + + + + {{ showLabel }} + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/SelectCustom.vue b/frontend/src/components/SelectCustom.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb029752c26c94ed74a57553e1c7c14c88861824 --- /dev/null +++ b/frontend/src/components/SelectCustom.vue @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/SelectGroup.vue b/frontend/src/components/SelectGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..2123bb71274ed36be884331c1b77e04936556d36 --- /dev/null +++ b/frontend/src/components/SelectGroup.vue @@ -0,0 +1,54 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Slider.vue b/frontend/src/components/Slider.vue new file mode 100644 index 0000000000000000000000000000000000000000..d14bf6d7095c765839ac3ed815db1ac1c75bc820 --- /dev/null +++ b/frontend/src/components/Slider.vue @@ -0,0 +1,280 @@ + + handleMousedown($event)"> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Switch.vue b/frontend/src/components/Switch.vue new file mode 100644 index 0000000000000000000000000000000000000000..f086cf048fe56a380612affaaf0e43fa8db837f2 --- /dev/null +++ b/frontend/src/components/Switch.vue @@ -0,0 +1,84 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Tabs.vue b/frontend/src/components/Tabs.vue new file mode 100644 index 0000000000000000000000000000000000000000..9178e084b1a940c0b3b067d67caac064da8bdba0 --- /dev/null +++ b/frontend/src/components/Tabs.vue @@ -0,0 +1,108 @@ + + + {{tab.label}} + + + + + + \ No newline at end of file diff --git a/frontend/src/components/TextArea.vue b/frontend/src/components/TextArea.vue new file mode 100644 index 0000000000000000000000000000000000000000..6577beabff7fce26a734d089416b7fb2e6ecae87 --- /dev/null +++ b/frontend/src/components/TextArea.vue @@ -0,0 +1,94 @@ + + handleInput($event)" + @focus="$event => emit('focus', $event)" + @blur="$event => emit('blur', $event)" + @keydown.enter="$event => emit('enter', $event)" + > + + + + + \ No newline at end of file diff --git a/frontend/src/components/TextColorButton.vue b/frontend/src/components/TextColorButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..4682864fe442d6e0493ede6e04d203f93e93ae88 --- /dev/null +++ b/frontend/src/components/TextColorButton.vue @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/WritingBoard.vue b/frontend/src/components/WritingBoard.vue new file mode 100644 index 0000000000000000000000000000000000000000..af54e6cd1fa28e7ccfed664cd8e0ea01b0585cee --- /dev/null +++ b/frontend/src/components/WritingBoard.vue @@ -0,0 +1,474 @@ + + + + + handleMousedown($event)" + @mousemove="$event => handleMousemove($event)" + @mouseup="handleMouseup()" + @touchstart="$event => handleMousedown($event)" + @touchmove="$event => handleMousemove($event)" + @touchend="handleMouseup(); mouseInCanvas = false" + @mouseleave="handleMouseup(); mouseInCanvas = false" + @mouseenter="mouseInCanvas = true" + > + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/configs/animation.ts b/frontend/src/configs/animation.ts new file mode 100644 index 0000000000000000000000000000000000000000..858a376b56efa6db5fbdf0822a7ec38e809ecba8 --- /dev/null +++ b/frontend/src/configs/animation.ts @@ -0,0 +1,234 @@ +import type { TurningMode } from '@/types/slides' + +export const ANIMATION_DEFAULT_DURATION = 1000 +export const ANIMATION_DEFAULT_TRIGGER = 'click' +export const ANIMATION_CLASS_PREFIX = 'animate__' + +export const ENTER_ANIMATIONS = [ + { + type: 'bounce', + name: '弹跳', + children: [ + { name: '弹入', value: 'bounceIn' }, + { name: '向右弹入', value: 'bounceInLeft' }, + { name: '向左弹入', value: 'bounceInRight' }, + { name: '向上弹入', value: 'bounceInUp' }, + { name: '向下弹入', value: 'bounceInDown' }, + ], + }, + { + type: 'fade', + name: '浮现', + children: [ + { name: '浮入', value: 'fadeIn' }, + { name: '向下浮入', value: 'fadeInDown' }, + { name: '向下长距浮入', value: 'fadeInDownBig' }, + { name: '向右浮入', value: 'fadeInLeft' }, + { name: '向右长距浮入', value: 'fadeInLeftBig' }, + { name: '向左浮入', value: 'fadeInRight' }, + { name: '向左长距浮入', value: 'fadeInRightBig' }, + { name: '向上浮入', value: 'fadeInUp' }, + { name: '向上长距浮入', value: 'fadeInUpBig' }, + { name: '从左上浮入', value: 'fadeInTopLeft' }, + { name: '从右上浮入', value: 'fadeInTopRight' }, + { name: '从左下浮入', value: 'fadeInBottomLeft' }, + { name: '从右下浮入', value: 'fadeInBottomRight' }, + ], + }, + { + type: 'rotate', + name: '旋转', + children: [ + { name: '旋转进入', value: 'rotateIn' }, + { name: '绕左下进入', value: 'rotateInDownLeft' }, + { name: '绕右下进入', value: 'rotateInDownRight' }, + { name: '绕左上进入', value: 'rotateInUpLeft' }, + { name: '绕右上进入', value: 'rotateInUpRight' }, + ], + }, + { + type: 'zoom', + name: '缩放', + children: [ + { name: '放大进入', value: 'zoomIn' }, + { name: '向下放大进入', value: 'zoomInDown' }, + { name: '从左放大进入', value: 'zoomInLeft' }, + { name: '从右放大进入', value: 'zoomInRight' }, + { name: '向上放大进入', value: 'zoomInUp' }, + ], + }, + { + type: 'slide', + name: '滑入', + children: [ + { name: '向下滑入', value: 'slideInDown' }, + { name: '从右滑入', value: 'slideInLeft' }, + { name: '从左滑入', value: 'slideInRight' }, + { name: '向上滑入', value: 'slideInUp' }, + ], + }, + { + type: 'flip', + name: '翻转', + children: [ + { name: 'X轴翻转进入', value: 'flipInX' }, + { name: 'Y轴翻转进入', value: 'flipInY' }, + ], + }, + { + type: 'back', + name: '放大滑入', + children: [ + { name: '向下放大滑入', value: 'backInDown' }, + { name: '从左放大滑入', value: 'backInLeft' }, + { name: '从右放大滑入', value: 'backInRight' }, + { name: '向上放大滑入', value: 'backInUp' }, + ], + }, + { + type: 'lightSpeed', + name: '飞入', + children: [ + { name: '从右飞入', value: 'lightSpeedInRight' }, + { name: '从左飞入', value: 'lightSpeedInLeft' }, + ], + }, +] + +export const EXIT_ANIMATIONS = [ + { + type: 'bounce', + name: '弹跳', + children: [ + { name: '弹出', value: 'bounceOut' }, + { name: '向左弹出', value: 'bounceOutLeft' }, + { name: '向右弹出', value: 'bounceOutRight' }, + { name: '向上弹出', value: 'bounceOutUp' }, + { name: '向下弹出', value: 'bounceOutDown' }, + ], + }, + { + type: 'fade', + name: '浮现', + children: [ + { name: '浮出', value: 'fadeOut' }, + { name: '向下浮出', value: 'fadeOutDown' }, + { name: '向下长距浮出', value: 'fadeOutDownBig' }, + { name: '向左浮出', value: 'fadeOutLeft' }, + { name: '向左长距浮出', value: 'fadeOutLeftBig' }, + { name: '向右浮出', value: 'fadeOutRight' }, + { name: '向右长距浮出', value: 'fadeOutRightBig' }, + { name: '向上浮出', value: 'fadeOutUp' }, + { name: '向上长距浮出', value: 'fadeOutUpBig' }, + { name: '从左上浮出', value: 'fadeOutTopLeft' }, + { name: '从右上浮出', value: 'fadeOutTopRight' }, + { name: '从左下浮出', value: 'fadeOutBottomLeft' }, + { name: '从右下浮出', value: 'fadeOutBottomRight' }, + ], + }, + { + type: 'rotate', + name: '旋转', + children: [ + { name: '旋转退出', value: 'rotateOut' }, + { name: '绕左下退出', value: 'rotateOutDownLeft' }, + { name: '绕右下退出', value: 'rotateOutDownRight' }, + { name: '绕左上退出', value: 'rotateOutUpLeft' }, + { name: '绕右上退出', value: 'rotateOutUpRight' }, + ], + }, + { + type: 'zoom', + name: '缩放', + children: [ + { name: '缩小退出', value: 'zoomOut' }, + { name: '向下缩小退出', value: 'zoomOutDown' }, + { name: '从左缩小退出', value: 'zoomOutLeft' }, + { name: '从右缩小退出', value: 'zoomOutRight' }, + { name: '向上缩小退出', value: 'zoomOutUp' }, + ], + }, + { + type: 'slide', + name: '滑出', + children: [ + { name: '向下滑出', value: 'slideOutDown' }, + { name: '从左滑出', value: 'slideOutLeft' }, + { name: '从右滑出', value: 'slideOutRight' }, + { name: '向上滑出', value: 'slideOutUp' }, + ], + }, + { + type: 'flip', + name: '翻转', + children: [ + { name: 'X轴翻转退出', value: 'flipOutX' }, + { name: 'Y轴翻转退出', value: 'flipOutY' }, + ], + }, + { + type: 'back', + name: '缩小滑出', + children: [ + { name: '向下缩小滑出', value: 'backOutDown' }, + { name: '从左缩小滑出', value: 'backOutLeft' }, + { name: '从右缩小滑出', value: 'backOutRight' }, + { name: '向上缩小滑出', value: 'backOutUp' }, + ], + }, + { + type: 'lightSpeed', + name: '飞出', + children: [ + { name: '从右飞出', value: 'lightSpeedOutRight' }, + { name: '从左飞出', value: 'lightSpeedOutLeft' }, + ], + }, +] + +export const ATTENTION_ANIMATIONS = [ + { + type: 'shake', + name: '晃动', + children: [ + { name: '左右摇晃', value: 'shakeX' }, + { name: '上下摇晃', value: 'shakeY' }, + { name: '摇头', value: 'headShake' }, + { name: '摆动', value: 'swing' }, + { name: '晃动', value: 'wobble' }, + { name: '惊恐', value: 'tada' }, + { name: '果冻', value: 'jello' }, + ], + }, + { + type: 'other', + name: '其他', + children: [ + { name: '弹跳', value: 'bounce' }, + { name: '闪烁', value: 'flash' }, + { name: '脉搏', value: 'pulse' }, + { name: '橡皮筋', value: 'rubberBand' }, + { name: '心跳(快)', value: 'heartBeat' }, + ], + }, +] + +interface SlideAnimation { + label: string + value: TurningMode +} + +export const SLIDE_ANIMATIONS: SlideAnimation[] = [ + { label: '无', value: 'no' }, + { label: '随机', value: 'random' }, + { label: '左右推移', value: 'slideX' }, + { label: '上下推移', value: 'slideY' }, + { label: '左右推移(3D)', value: 'slideX3D' }, + { label: '上下推移(3D)', value: 'slideY3D' }, + { label: '淡入淡出', value: 'fade' }, + { label: '旋转', value: 'rotate' }, + { label: '上下展开', value: 'scaleY' }, + { label: '左右展开', value: 'scaleX' }, + { label: '放大', value: 'scale' }, + { label: '缩小', value: 'scaleReverse' }, +] \ No newline at end of file diff --git a/frontend/src/configs/chart.ts b/frontend/src/configs/chart.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2279a6c4f247aade66612b0b3a6fb031cbea45e --- /dev/null +++ b/frontend/src/configs/chart.ts @@ -0,0 +1,70 @@ +import type { ChartData } from '@/types/slides' + +export const CHART_TYPE_MAP: { [key: string]: string } = { + 'bar': '柱状图', + 'column': '条形图', + 'line': '折线图', + 'area': '面积图', + 'scatter': '散点图', + 'pie': '饼图', + 'ring': '环形图', + 'radar': '雷达图', +} + +export const CHART_DEFAULT_DATA: { [key: string]: ChartData } = { + 'bar': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['系列1', '系列2'], + series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]], + }, + 'column': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['系列1', '系列2'], + series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]], + }, + 'line': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['系列1', '系列2'], + series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]], + }, + 'pie': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['值'], + series: [[12, 19, 5, 2, 18]], + }, + 'ring': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['值'], + series: [[12, 19, 5, 2, 18]], + }, + 'area': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['系列1', '系列2'], + series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]], + }, + 'radar': { + labels: ['类别1', '类别2', '类别3', '类别4', '类别5'], + legends: ['系列1', '系列2'], + series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]], + }, + 'scatter': { + labels: ['坐标1', '坐标2', '坐标3', '坐标4', '坐标5'], + legends: ['X', 'Y'], + series: [[12, 19, 5, 2, 18], [7, 11, 13, 21, 9]], + }, +} + +export const CHART_PRESET_THEMES = [ + ['#d87c7c', '#919e8b', '#d7ab82', '#6e7074', '#61a0a8', '#efa18d'], + ['#dd6b66', '#759aa0', '#e69d87', '#8dc1a9', '#ea7e53', '#eedd78'], + ['#516b91', '#59c4e6', '#edafda', '#93b7e3', '#a5e7f0', '#cbb0e3'], + ['#893448', '#d95850', '#eb8146', '#ffb248', '#f2d643', '#ebdba4'], + ['#4ea397', '#22c3aa', '#7bd9a5', '#d0648a', '#f58db2', '#f2b3c9'], + ['#3fb1e3', '#6be6c1', '#626c91', '#a0a7e6', '#c4ebad', '#96dee8'], + ['#fc97af', '#87f7cf', '#f7f494', '#72ccff', '#f7c5a0', '#d4a4eb'], + ['#c1232b', '#27727b', '#fcce10', '#e87c25', '#b5c334', '#fe8463'], + ['#2ec7c9', '#b6a2de', '#5ab1ef', '#ffb980', '#d87a80', '#8d98b3'], + ['#e01f54', '#001852', '#f5e8c8', '#b8d2c7', '#c6b38e', '#a4d8c2'], + ['#c12e34', '#e6b600', '#0098d9', '#2b821d', '#005eaa', '#339ca8'], + ['#8a7ca8', '#e098c7', '#8fd3e8', '#71669e', '#cc70af', '#7cb4cc'], +] \ No newline at end of file diff --git a/frontend/src/configs/element.ts b/frontend/src/configs/element.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c88a106246d8802300a962bcf98d7cd11a10ab1 --- /dev/null +++ b/frontend/src/configs/element.ts @@ -0,0 +1,22 @@ +export const ELEMENT_TYPE_ZH: { [key: string]: string } = { + text: '文本', + image: '图片', + shape: '形状', + line: '线条', + chart: '图表', + table: '表格', + video: '视频', + audio: '音频', + latex: '公式', +} + +export const MIN_SIZE: { [key: string]: number } = { + text: 40, + image: 20, + shape: 20, + chart: 200, + table: 30, + video: 250, + audio: 20, + latex: 20, +} \ No newline at end of file diff --git a/frontend/src/configs/font.ts b/frontend/src/configs/font.ts new file mode 100644 index 0000000000000000000000000000000000000000..08b9cc9f17f43be017e37ddedcac6ac8687956f2 --- /dev/null +++ b/frontend/src/configs/font.ts @@ -0,0 +1,25 @@ +export const FONTS = [ + { label: '默认字体', value: '' }, + { label: '思源黑体', value: 'SourceHanSans' }, + { label: '思源宋体', value: 'SourceHanSerif' }, + { label: '方正黑体', value: 'FangZhengHeiTi' }, + { label: '方正楷体', value: 'FangZhengKaiTi' }, + { label: '方正宋体', value: 'FangZhengShuSong' }, + { label: '方正仿宋', value: 'FangZhengFangSong' }, + { label: '阿里巴巴普惠体', value: 'AlibabaPuHuiTi' }, + { label: '朱雀仿宋', value: 'ZhuqueFangSong' }, + { label: '霞鹜文楷', value: 'LXGWWenKai' }, + { label: '文鼎PL楷体', value: 'WenDingPLKaiTi' }, + { label: '得意黑', value: 'DeYiHei' }, + { label: 'MiSans', value: 'MiSans' }, + { label: '仓耳小丸子', value: 'CangerXiaowanzi' }, + { label: '优设标题黑', value: 'YousheTitleBlack' }, + { label: '峰广明锐体', value: 'FengguangMingrui' }, + { label: '摄图摩登小方体', value: 'ShetuModernSquare' }, + { label: '站酷快乐体', value: 'ZcoolHappy' }, + { label: '字制区喜脉体', value: 'ZizhiQuXiMai' }, + { label: '素材集市康康体', value: 'SucaiJishiKangkang' }, + { label: '素材集市酷方体', value: 'SucaiJishiCoolSquare' }, + { label: '途牛类圆体', value: 'TuniuRounded' }, + { label: '锐字真言体', value: 'RuiziZhenyan' }, +] \ No newline at end of file diff --git a/frontend/src/configs/hotkey.ts b/frontend/src/configs/hotkey.ts new file mode 100644 index 0000000000000000000000000000000000000000..3494098eb6d2040099517393e43a1ac51d9b447a --- /dev/null +++ b/frontend/src/configs/hotkey.ts @@ -0,0 +1,149 @@ +export const enum KEYS { + C = 'C', + X = 'X', + Z = 'Z', + Y = 'Y', + A = 'A', + G = 'G', + L = 'L', + F = 'F', + D = 'D', + B = 'B', + P = 'P', + O = 'O', + R = 'R', + T = 'T', + MINUS = '-', + EQUAL = '=', + DIGIT_0 = '0', + DELETE = 'DELETE', + UP = 'ARROWUP', + DOWN = 'ARROWDOWN', + LEFT = 'ARROWLEFT', + RIGHT = 'ARROWRIGHT', + ENTER = 'ENTER', + SPACE = ' ', + TAB = 'TAB', + BACKSPACE = 'BACKSPACE', + ESC = 'ESCAPE', + PAGEUP = 'PAGEUP', + PAGEDOWN = 'PAGEDOWN', + F5 = 'F5', +} + +interface HotkeyItem { + type: string + children: { + label: string + value?: string + }[] +} + +export const HOTKEY_DOC: HotkeyItem[] = [ + { + type: '通用', + children: [ + { label: '剪切', value: 'Ctrl + X' }, + { label: '复制', value: 'Ctrl + C' }, + { label: '粘贴', value: 'Ctrl + V' }, + { label: '粘贴为纯文本', value: 'Ctrl + Shift + V' }, + { label: '快速复制粘贴', value: 'Ctrl + D' }, + { label: '全选', value: 'Ctrl + A' }, + { label: '撤销', value: 'Ctrl + Z' }, + { label: '恢复', value: 'Ctrl + Y' }, + { label: '删除', value: 'Delete / Backspace' }, + { label: '多选', value: '按住 Ctrl 或 Shift' }, + { label: '打开搜索替换', value: 'Ctrl + F' }, + { label: '打印', value: 'Ctrl + P' }, + { label: '关闭弹窗', value: 'ESC' }, + ], + }, + { + type: '幻灯片放映', + children: [ + { label: '从头开始放映幻灯片', value: 'F5' }, + { label: '从当前开始放映幻灯片', value: 'Shift + F5' }, + { label: '切换上一页', value: '↑ / ← / PgUp' }, + { label: '切换下一页', value: '↓ / → / PgDown' }, + { label: '切换下一页', value: 'Enter / Space' }, + { label: '退出放映', value: 'ESC' }, + ], + }, + { + type: '幻灯片编辑', + children: [ + { label: '新建幻灯片', value: 'Enter' }, + { label: '移动画布', value: 'Space + 鼠标拖拽' }, + { label: '缩放画布', value: 'Ctrl + 鼠标滚轮' }, + { label: '放大画布', value: 'Ctrl + =' }, + { label: '缩小画布', value: 'Ctrl + -' }, + { label: '使画布适应当前屏幕', value: 'Ctrl + 0' }, + { label: '上一页(未选中元素)', value: '↑' }, + { label: '下一页(未选中元素)', value: '↓' }, + { label: '上一页', value: '鼠标上滚 / PgUp' }, + { label: '下一页', value: '鼠标下滚 / PgDown' }, + { label: '快速创建文本', value: '双击空白处 / T' }, + { label: '快速创建矩形', value: 'R' }, + { label: '快速创建圆形', value: 'O' }, + { label: '快速创建线条', value: 'L' }, + { label: '退出绘制状态', value: '鼠标右键' }, + ], + }, + { + type: '元素操作', + children: [ + { label: '移动', value: '↑ / ← / ↓ / →' }, + { label: '锁定', value: 'Ctrl + L' }, + { label: '组合', value: 'Ctrl + G' }, + { label: '取消组合', value: 'Ctrl + Shift + G' }, + { label: '置顶层', value: 'Alt + F' }, + { label: '置底层', value: 'Alt + B' }, + { label: '锁定宽高比例', value: '按住 Ctrl 或 Shift' }, + { label: '创建水平 / 垂直线条', value: '按住 Ctrl 或 Shift' }, + { label: '切换焦点元素', value: 'Tab' }, + { label: '确认图片裁剪', value: 'Enter' }, + { label: '完成自定义形状绘制', value: 'Enter' }, + ], + }, + { + type: '表格编辑', + children: [ + { label: '聚焦到下一个单元格', value: 'Tab' }, + { label: '移动焦点单元格', value: '↑ / ← / ↓ / →' }, + { label: '在上方插入一行', value: 'Ctrl + ↑' }, + { label: '在下方插入一行', value: 'Ctrl + ↓' }, + { label: '在左侧插入一列', value: 'Ctrl + ←' }, + { label: '在右侧插入一列', value: 'Ctrl + →' }, + ], + }, + { + type: '图表数据编辑', + children: [ + { label: '聚焦到下一行', value: 'Enter' }, + ], + }, + { + type: '文本编辑', + children: [ + { label: '加粗', value: 'Ctrl + B' }, + { label: '斜体', value: 'Ctrl + I' }, + { label: '下划线', value: 'Ctrl + U' }, + { label: '行内代码', value: 'Ctrl + E' }, + { label: '上角标', value: 'Ctrl + ;' }, + { label: '下角标', value: `Ctrl + '` }, + { label: '选中段落', value: `ESC` }, + ], + }, + { + type: '其他快捷操作', + children: [ + { label: '添加图片 - 粘贴来自系统剪贴板的图片' }, + { label: '添加图片 - 将本地图片拖拽到画布中' }, + { label: '添加图片 - 在画布中粘贴SVG代码' }, + { label: '添加图片 - 粘贴来自 pexels 的图片链接' }, + { label: '添加文本 - 粘贴来自系统剪贴板的文字' }, + { label: '添加文本 - 将外部选中文字拖拽到画布中' }, + { label: '文本编辑 - 支持 markdown 语法创建列表和引用' }, + ], + }, +] \ No newline at end of file diff --git a/frontend/src/configs/imageClip.ts b/frontend/src/configs/imageClip.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3f41a46482766dd00c538c0ee01468839e832ca --- /dev/null +++ b/frontend/src/configs/imageClip.ts @@ -0,0 +1,181 @@ +export const enum ClipPathTypes { + RECT = 'rect', + ELLIPSE = 'ellipse', + POLYGON = 'polygon', +} + +export const enum ClipPaths { + RECT = 'rect', + ROUNDRECT = 'roundRect', + ELLIPSE = 'ellipse', + TRIANGLE = 'triangle', + PENTAGON = 'pentagon', + RHOMBUS = 'rhombus', + STAR = 'star', +} + +interface ClipPath { + [key: string]: { + name: string + type: ClipPathTypes + style: string + radius?: string + createPath?: (width: number, height: number) => string + } +} + +export const CLIPPATHS: ClipPath = { + rect: { + name: '矩形', + type: ClipPathTypes.RECT, + radius: '0', + style: '', + }, + rect2: { + name: '矩形2', + type: ClipPathTypes.POLYGON, + style: 'polygon(0% 0%, 80% 0%, 100% 20%, 100% 100%, 0 100%)', + createPath: (width: number, height: number) => { + return `M 0 0 L ${width * 0.8} 0 L ${width} ${height * 0.2} L ${width} ${height} L 0 ${height} Z` + }, + }, + rect3: { + name: '矩形3', + type: ClipPathTypes.POLYGON, + style: 'polygon(0% 0%, 80% 0%, 100% 20%, 100% 100%, 20% 100%, 0% 80%)', + createPath: (width: number, height: number) => { + return `M 0 0 L ${width * 0.8} 0 L ${width} ${height * 0.2} L ${width} ${height} L ${width * 0.2} ${height} L 0 ${height * 0.8} Z` + }, + }, + roundRect: { + name: '圆角矩形', + type: ClipPathTypes.RECT, + radius: '10px', + style: 'inset(0 round 10px)', + }, + ellipse: { + name: '圆形', + type: ClipPathTypes.ELLIPSE, + style: 'ellipse(50% 50% at 50% 50%)', + }, + triangle: { + name: '三角形', + type: ClipPathTypes.POLYGON, + style: 'polygon(50% 0%, 0% 100%, 100% 100%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.5} 0 L 0 ${height} L ${width} ${height} Z` + }, + }, + triangle2: { + name: '三角形2', + type: ClipPathTypes.POLYGON, + style: 'polygon(50% 100%, 0% 0%, 100% 0%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.5} ${height} L 0 0 L ${width} 0 Z` + }, + }, + triangle3: { + name: '三角形3', + type: ClipPathTypes.POLYGON, + style: 'polygon(0% 0%, 0% 100%, 100% 100%)', + createPath: (width: number, height: number) => { + return `M 0 0 L 0 ${height} L ${width} ${height} Z` + }, + }, + rhombus: { + name: '菱形', + type: ClipPathTypes.POLYGON, + style: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.5} 0 L ${width} ${height * 0.5} L ${width * 0.5} ${height} L 0 ${height * 0.5} Z` + }, + }, + pentagon: { + name: '五边形', + type: ClipPathTypes.POLYGON, + style: 'polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.5} 0 L ${width} ${0.38 * height} L ${0.82 * width} ${height} L ${0.18 * width} ${height} L 0 ${0.38 * height} Z` + }, + }, + hexagon: { + name: '六边形', + type: ClipPathTypes.POLYGON, + style: 'polygon(20% 0%, 80% 0%, 100% 50%, 80% 100%, 20% 100%, 0% 50%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.2} 0 L ${width * 0.8} 0 L ${width} ${height * 0.5} L ${width * 0.8} ${height} L ${width * 0.2} ${height} L 0 ${height * 0.5} Z` + }, + }, + heptagon: { + name: '七边形', + type: ClipPathTypes.POLYGON, + style: 'polygon(50% 0%, 90% 20%, 100% 60%, 75% 100%, 25% 100%, 0% 60%, 10% 20%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.5} 0 L ${width * 0.9} ${height * 0.2} L ${width} ${height * 0.6} L ${width * 0.75} ${height} L ${width * 0.25} ${height} L 0 ${height * 0.6} L ${width * 0.1} ${height * 0.2} Z` + }, + }, + octagon: { + name: '八边形', + type: ClipPathTypes.POLYGON, + style: 'polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.3} 0 L ${width * 0.7} 0 L ${width} ${height * 0.3} L ${width} ${height * 0.7} L ${width * 0.7} ${height} L ${width * 0.3} ${height} L 0 ${height * 0.7} L 0 ${height * 0.3} Z` + }, + }, + chevron: { + name: 'V形', + type: ClipPathTypes.POLYGON, + style: 'polygon(75% 0%, 100% 50%, 75% 100%, 0% 100%, 25% 50%, 0% 0%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.75} 0 L ${width} ${height * 0.5} L ${width * 0.75} ${height} L 0 ${height} L ${width * 0.25} ${height * 0.5} L 0 0 Z` + }, + }, + point: { + name: '点', + type: ClipPathTypes.POLYGON, + style: 'polygon(0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%)', + createPath: (width: number, height: number) => { + return `M 0 0 L ${width * 0.75} 0 L ${width} ${height * 0.5} L ${width * 0.75} ${height} L 0 ${height} Z` + }, + }, + arrow: { + name: '箭头', + type: ClipPathTypes.POLYGON, + style: 'polygon(0% 20%, 60% 20%, 60% 0%, 100% 50%, 60% 100%, 60% 80%, 0% 80%)', + createPath: (width: number, height: number) => { + return `M 0 ${height * 0.2} L ${width * 0.6} ${height * 0.2} L ${width * 0.6} 0 L ${width} ${height * 0.5} L ${width * 0.6} ${height} L ${width * 0.6} ${height * 0.8} L 0 ${height * 0.8} Z` + }, + }, + parallelogram: { + name: '平行四边形', + type: ClipPathTypes.POLYGON, + style: 'polygon(30% 0%, 100% 0%, 70% 100%, 0% 100%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.3} 0 L ${width} 0 L ${width * 0.7} ${height} L 0 ${height} Z` + }, + }, + parallelogram2: { + name: '平行四边形2', + type: ClipPathTypes.POLYGON, + style: 'polygon(30% 100%, 100% 100%, 70% 0%, 0% 0%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.3} ${height} L ${width} ${height} L ${width * 0.7} 0 L 0 0 Z` + }, + }, + trapezoid: { + name: '梯形', + type: ClipPathTypes.POLYGON, + style: 'polygon(25% 0%, 75% 0%, 100% 100%, 0% 100%)', + createPath: (width: number, height: number) => { + return `M ${width * 0.25} 0 L ${width * 0.75} 0 L ${width} ${height} L 0 ${height} Z` + }, + }, + trapezoid2: { + name: '梯形2', + type: ClipPathTypes.POLYGON, + style: 'polygon(0% 0%, 100% 0%, 75% 100%, 25% 100%)', + createPath: (width: number, height: number) => { + return `M 0 0 L ${width} 0 L ${width * 0.75} ${height} L ${width * 0.25} ${height} Z` + }, + }, +} \ No newline at end of file diff --git a/frontend/src/configs/latex.ts b/frontend/src/configs/latex.ts new file mode 100644 index 0000000000000000000000000000000000000000..14dc591bc631cfdf07edc89ac2dba7d40236790f --- /dev/null +++ b/frontend/src/configs/latex.ts @@ -0,0 +1,274 @@ +export const FORMULA_LIST = [ + { + label: '高斯公式', + latex: `\\int\\int\\int _ { \\Omega } \\left( \\frac { \\partial {P} } { \\partial {x} } + \\frac { \\partial {Q} } { \\partial {y} } + \\frac { \\partial {R} }{ \\partial {z} } \\right) \\mathrm { d } V = \\oint _ { \\partial \\Omega } ( P \\cos \\alpha + Q \\cos \\beta + R \\cos \\gamma ) \\mathrm{ d} S` + }, + { + label: '傅里叶级数', + latex: `f(x) = \\frac {a_0} 2 + \\sum_{n = 1}^\\infty {({a_n}\\cos {nx} + {b_n}\\sin {nx})}`, + }, + { + label: '泰勒展开式', + latex: `e ^ { x } = 1 + \\frac { x } { 1 ! } + \\frac { x ^ { 2 } } { 2 ! } + \\frac { x ^ { 3 } } { 3 ! } + ... , \\quad - \\infty < x < \\infty`, + }, + { + label: '定积分', + latex: `\\lim_ { n \\rightarrow + \\infty } \\sum _ { i = 1 } ^ { n } f \\left[ a + \\frac { i } { n } ( b - a ) \\right] \\frac { b - a } { n } = \\int _ { a } ^ { b } f ( x ) dx`, + }, + { + label: '三角恒等式1', + latex: `\\sin \\alpha \\pm \\sin \\beta = 2 \\sin \\frac { 1 } { 2 } ( \\alpha \\pm \\beta ) \\cos \\frac { 1 } { 2 } ( \\alpha \\mp \\beta )`, + }, + { + label: '三角恒等式2', + latex: `\\cos \\alpha + \\cos \\beta = 2 \\cos \\frac { 1 } { 2 } ( \\alpha + \\beta ) \\cos \\frac { 1 } { 2 } ( \\alpha - \\beta )`, + }, + { + label: '和的展开式', + latex: `( 1 + x ) ^ { n } = 1 + \\frac { n x } { 1 ! } + \\frac { n ( n - 1 ) x ^ { 2 } } { 2 ! } + ...`, + }, + { + label: '欧拉公式', + latex: ` e^{ix} = \\cos {x} + i\\sin {x}`, + }, + { + label: '贝努利方程', + latex: `\\frac {dy} {dx} + P(x)y = Q(x) y^n ({n} \\not= {0,1})`, + }, + { + label: '全微分方程', + latex: `du(x,y) = P(x,y)dx + Q(x,y)dy = 0`, + }, + { + label: '非齐次方程', + latex: `y = (\\int Q(x) e^{\\int {P(x)dx}}dx + C)e^{-\\int {P(x)dx}}`, + }, + { + label: '柯西中值定理', + latex: `\\frac{{f(b) - f(a)}}{{F(b) - F(a)}} = \\frac{{f'(\\xi )}}{{F'(\\xi )}}`, + }, + { + label: '拉格朗日中值定理', + latex: `f(b) - f(a) = f'(\\xi )(b - a)`, + }, + { + label: '导数公式', + latex: `(\\arcsin x)' = \\frac{1}{{\\sqrt {1 - x^2} }}`, + }, + { + label: '三角函数积分', + latex: `\\int {tgxdx = - \\ln \\left| {\\cos x} \\right| + C}`, + }, + { + label: '二次曲面', + latex: `\\frac{{{x^2}}}{{{a^2}}} + \\frac{{{y^2}}}{{{b^2}}} - \\frac{{{z^2}}}{{{c^2}}} = 1`, + }, + { + label: '二阶微分', + latex: `\\frac {{d^2}y} {dx^2} + P(x) \\frac {dy} {dx} + Q(x)y = f(x)`, + }, + { + label: '方向导数', + latex: `\\frac{{\\partial f}}{{\\partial l}} = \\frac{{\\partial f}}{{\\partial x}}\\cos \\phi + \\frac{{\\partial f}}{{\\partial y}}\\sin \\phi`, + }, +] + +export const SYMBOL_LIST = [ + { + type: 'operators', + label: '数学', + children: [ + { latex: '\\cdot' }, + { latex: '\\pm' }, + { latex: '\\mp' }, + { latex: '+' }, + { latex: '-' }, + { latex: '\\times' }, + { latex: '\\div' }, + { latex: '<' }, + { latex: '>' }, + { latex: '=' }, + { latex: '\\neq\\ne' }, + { latex: '\\leqq' }, + { latex: '\\geqq' }, + { latex: '\\leq' }, + { latex: '\\geq' }, + { latex: '\\propto' }, + { latex: '\\sim' }, + { latex: '\\equiv' }, + { latex: '\\dagger' }, + { latex: '\\ddagger' }, + { latex: '\\ell' }, + { latex: '\\#' }, + { latex: '\\$' }, + { latex: '\\&' }, + { latex: '\\%' }, + { latex: '\\langle\\rangle' }, + { latex: '()' }, + { latex: '[]' }, + { latex: '\\{\\}' }, + { latex: '||' }, + { latex: '\\|' }, + { latex: '\\exists' }, + { latex: '\\in' }, + { latex: '\\subset' }, + { latex: '\\supset' }, + { latex: '\\cup' }, + { latex: '\\cap' }, + { latex: '\\infty' }, + { latex: '\\partial' }, + { latex: '\\nabla' }, + { latex: '\\aleph' }, + { latex: '\\wp' }, + { latex: '\\therefore' }, + { latex: '\\mid' }, + { latex: '\\sum' }, + { latex: '\\prod' }, + { latex: '\\bigoplus' }, + { latex: '\\bigodot' }, + { latex: '\\int' }, + { latex: '\\oint' }, + { latex: '\\oplus' }, + { latex: '\\odot' }, + { latex: '\\perp' }, + { latex: '\\angle' }, + { latex: '\\triangle' }, + { latex: '\\Box' }, + { latex: '\\rightarrow' }, + { latex: '\\to' }, + { latex: '\\leftarrow' }, + { latex: '\\gets' }, + { latex: '\\circ' }, + { latex: '\\bigcirc' }, + { latex: '\\bullet' }, + { latex: '\\star' }, + { latex: '\\diamond' }, + { latex: '\\ast' }, + { latex: ',' }, + { latex: '.' }, + { latex: ';' }, + { latex: '!' }, + ], + }, + { + type: 'group', + label: '组合', + children: [ + { latex: '\\frac{a}{b}' }, + { latex: '\\frac{dx}{dx}' }, + { latex: '\\frac{\\partial a}{\\partial b}' }, + { latex: '\\sqrt{x}' }, + { latex: '\\sqrt[n]{x}' }, + { latex: 'x^{n}' }, + { latex: 'x_{n}' }, + { latex: 'x_a^b' }, + { latex: '\\int_{a}^{b}' }, + { latex: '\\oint_a^b' }, + { latex: '\\lim_{a \\rightarrow b}' }, + { latex: '\\prod_a^b' }, + { latex: '\\sum_a^b' }, + { latex: '\\left(\\begin{array}a \\\\ b\\end{array}\\right)' }, + { latex: '\\begin{bmatrix}a & b \\\\ c & d \\end{bmatrix}' }, + { latex: '\\begin{cases}a & x = 0 \\\\ b & x > 0\\end{cases}' }, + { latex: '\\hat{a}' }, + { latex: '\\breve{a}' }, + { latex: '\\acute{a}' }, + { latex: '\\grave{a}' }, + { latex: '\\tilde{a}' }, + { latex: '\\bar{a}' }, + { latex: '\\vec{a}' }, + { latex: '\\underline{a}' }, + { latex: '\\overline{a}' }, + { latex: '\\widehat{ab}' }, + { latex: '\\overleftarrow{ab}' }, + { latex: '\\overrightarrow{ab}' }, + ], + }, + { + type: 'verbatim', + label: '函数', + children: [ + { latex: '\\log' }, + { latex: '\\ln' }, + { latex: '\\exp' }, + { latex: '\\mod' }, + { latex: '\\lim' }, + { latex: '\\sin' }, + { latex: '\\cos' }, + { latex: '\\tan' }, + { latex: '\\csc' }, + { latex: '\\sec' }, + { latex: '\\cot' }, + { latex: '\\sinh' }, + { latex: '\\cosh' }, + { latex: '\\tanh' }, + { latex: '\\csch' }, + { latex: '\\sech' }, + { latex: '\\coth' }, + { latex: '\\arcsin' }, + { latex: '\\arccos' }, + { latex: '\\arctan' }, + { latex: '\\arccsc' }, + { latex: '\\arcsec' }, + { latex: '\\arccot' }, + ], + }, + { + type: 'greek', + label: '希腊字母', + children: [ + { latex: '\\alpha' }, + { latex: '\\beta' }, + { latex: '\\gamma' }, + { latex: '\\delta' }, + { latex: '\\varepsilon' }, + { latex: '\\zeta' }, + { latex: '\\eta' }, + { latex: '\\vartheta' }, + { latex: '\\iota' }, + { latex: '\\kappa' }, + { latex: '\\lambda' }, + { latex: '\\mu' }, + { latex: '\\nu' }, + { latex: '\\xi' }, + { latex: '\\omicron' }, + { latex: '\\pi' }, + { latex: '\\rho' }, + { latex: '\\sigma' }, + { latex: '\\tau' }, + { latex: '\\upsilon' }, + { latex: '\\varphi' }, + { latex: '\\chi' }, + { latex: '\\psi' }, + { latex: '\\omega' }, + { latex: '\\epsilon' }, + { latex: '\\theta' }, + { latex: '\\phi' }, + { latex: '\\varsigma' }, + { latex: '\\Alpha' }, + { latex: '\\Beta' }, + { latex: '\\Gamma' }, + { latex: '\\Delta' }, + { latex: '\\Epsilon' }, + { latex: '\\Zeta' }, + { latex: '\\Eta' }, + { latex: '\\Theta' }, + { latex: '\\Iota' }, + { latex: '\\Kappa' }, + { latex: '\\Lambda' }, + { latex: '\\Mu' }, + { latex: '\\Nu' }, + { latex: '\\Xi' }, + { latex: '\\Omicron' }, + { latex: '\\Pi' }, + { latex: '\\Rho' }, + { latex: '\\Sigma' }, + { latex: '\\Tau' }, + { latex: '\\Upsilon' }, + { latex: '\\Phi' }, + { latex: '\\Chi' }, + { latex: '\\Psi' }, + { latex: '\\Omega' }, + ], + }, +] \ No newline at end of file diff --git a/frontend/src/configs/lines.ts b/frontend/src/configs/lines.ts new file mode 100644 index 0000000000000000000000000000000000000000..167304e449682f9b9e1021bb7639511da289d91b --- /dev/null +++ b/frontend/src/configs/lines.ts @@ -0,0 +1,39 @@ +import type { LinePoint, LineStyleType } from '@/types/slides' + + +export interface LinePoolItem { + path: string + style: LineStyleType + points: [LinePoint, LinePoint] + isBroken?: boolean + isBroken2?: boolean + isCurve?: boolean + isCubic?: boolean +} + +interface PresetLine { + type: string + children: LinePoolItem[] +} + +export const LINE_LIST: PresetLine[] = [ + { + type: '直线', + children: [ + { path: 'M 0 0 L 20 20', style: 'solid', points: ['', ''] }, + { path: 'M 0 0 L 20 20', style: 'dashed', points: ['', ''] }, + { path: 'M 0 0 L 20 20', style: 'solid', points: ['', 'arrow'] }, + { path: 'M 0 0 L 20 20', style: 'dashed', points: ['', 'arrow'] }, + { path: 'M 0 0 L 20 20', style: 'solid', points: ['', 'dot'] }, + ], + }, + { + type: '折线、曲线', + children: [ + { path: 'M 0 0 L 0 20 L 20 20', style: 'solid', points: ['', 'arrow'], isBroken: true }, + { path: 'M 0 0 L 10 0 L 10 20 L 20 20', style: 'solid', points: ['', 'arrow'], isBroken2: true }, + { path: 'M 0 0 Q 0 20 20 20', style: 'solid', points: ['', 'arrow'], isCurve: true }, + { path: 'M 0 0 C 20 0 0 20 20 20', style: 'solid', points: ['', 'arrow'], isCubic: true }, + ], + }, +] \ No newline at end of file diff --git a/frontend/src/configs/shapes.ts b/frontend/src/configs/shapes.ts new file mode 100644 index 0000000000000000000000000000000000000000..348b455272ae1120907eafc0cb9de72b67500b46 --- /dev/null +++ b/frontend/src/configs/shapes.ts @@ -0,0 +1,1038 @@ +/* eslint-disable max-lines */ + +// 非专业设计人士可以用该应用绘制基本形状:https://github.com/pipipi-pikachu/svgPathCreator + +import { ShapePathFormulasKeys } from '@/types/slides' + +export interface ShapePoolItem { + viewBox: [number, number] + path: string + special?: boolean + pathFormula?: ShapePathFormulasKeys + outlined?: boolean + pptxShapeType?: string + title?: string + withborder?: boolean +} + +interface ShapeListItem { + type: string + children: ShapePoolItem[] +} + +export interface ShapePathFormula { + editable?: boolean + defaultValue?: number[] + range?: [number, number][] + relative?: string[] + getBaseSize?: ((width: number, height: number) => number)[] + formula: (width: number, height: number, values?: number[]) => string +} + +export const SHAPE_PATH_FORMULAS: { + [key: string]: ShapePathFormula +} = { + [ShapePathFormulasKeys.ROUND_RECT]: { + editable: true, + defaultValue: [0.125], + range: [[0, 0.5]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M ${radius} 0 L ${width - radius} 0 Q ${width} 0 ${width} ${radius} L ${width} ${height - radius} Q ${width} ${height} ${width - radius} ${height} L ${radius} ${height} Q 0 ${height} 0 ${height - radius} L 0 ${radius} Q 0 0 ${radius} 0 Z` + } + }, + [ShapePathFormulasKeys.CUT_RECT_DIAGONAL]: { + editable: true, + defaultValue: [0.2], + range: [[0, 0.9]], + relative: ['right'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M 0 ${height - radius} L 0 0 L ${width - radius} 0 L ${width} ${radius} L ${width} ${height} L ${radius} ${height} Z` + } + }, + [ShapePathFormulasKeys.CUT_RECT_SINGLE]: { + editable: true, + defaultValue: [0.2], + range: [[0, 0.9]], + relative: ['right'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M 0 ${height} L 0 0 L ${width - radius} 0 L ${width} ${radius} L ${width} ${height} Z` + } + }, + [ShapePathFormulasKeys.CUT_RECT_SAMESIDE]: { + editable: true, + defaultValue: [0.2], + range: [[0, 0.5]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M 0 ${radius} L ${radius} 0 L ${width - radius} 0 L ${width} ${radius} L ${width} ${height} L 0 ${height} Z` + } + }, + [ShapePathFormulasKeys.ROUND_RECT_DIAGONAL]: { + editable: true, + defaultValue: [0.125], + range: [[0, 1]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M ${radius} 0 L ${width} 0 L ${width} ${height - radius} Q ${width} ${height} ${width - radius} ${height} L 0 ${height} L 0 ${radius} Q 0 0 ${radius} 0 Z` + } + }, + [ShapePathFormulasKeys.ROUND_RECT_SINGLE]: { + editable: true, + defaultValue: [0.125], + range: [[0, 1]], + relative: ['right'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M 0 0 L ${width - radius} 0 Q ${width} 0 ${width} ${radius} L ${width} ${height} L 0 ${height} L 0 0 Z` + } + }, + [ShapePathFormulasKeys.ROUND_RECT_SAMESIDE]: { + editable: true, + defaultValue: [0.125], + range: [[0, 0.5]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M 0 ${radius} Q 0 0 ${radius} 0 L ${width - radius} 0 Q ${width} 0 ${width} ${radius} L ${width} ${height} L 0 ${height} Z` + } + }, + [ShapePathFormulasKeys.CUT_ROUND_RECT]: { + editable: true, + defaultValue: [0.125], + range: [[0, 0.5]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const radius = Math.min(width, height) * values![0] + return `M ${radius} 0 L ${width - radius} 0 L ${width} ${radius} L ${width} ${height} L 0 ${height} L 0 ${radius} Q 0 0 ${radius} 0 Z` + } + }, + [ShapePathFormulasKeys.MESSAGE]: { + editable: true, + range: [[0, 0.8], [0.1, 0.3]], + defaultValue: [0.3, 0.2], + relative: ['left_bottom', 'bottom'], + getBaseSize: [ + width => width, + (width, height) => height, + ], + formula: (width, height, values) => { + const point = width * values![0] + const arrowWidth = width * 0.2 + const arrowheight = height * values![1] + return `M 0 0 L ${width} 0 L ${width} ${height - arrowheight} L ${point + arrowWidth} ${height - arrowheight} L ${point} ${height} L ${point} ${height - arrowheight} L 0 ${height - arrowheight} Z` + } + }, + [ShapePathFormulasKeys.ROUND_MESSAGE]: { + formula: (width, height) => { + const radius = Math.min(width, height) * 0.125 + const arrowWidth = Math.min(width, height) * 0.2 + const arrowheight = Math.min(width, height) * 0.2 + return `M 0 ${radius} Q 0 0 ${radius} 0 L ${width - radius} 0 Q ${width} 0 ${width} ${radius} L ${width} ${height - radius - arrowheight} Q ${width} ${height - arrowheight} ${width - radius} ${height - arrowheight} L ${width / 2} ${height - arrowheight} L ${width / 2 - arrowWidth} ${height} L ${width / 2 - arrowWidth} ${height - arrowheight} L ${radius} ${height - arrowheight} Q 0 ${height - arrowheight} 0 ${height - radius - arrowheight} L 0 ${radius} Z` + } + }, + [ShapePathFormulasKeys.L]: { + editable: true, + defaultValue: [0.25], + range: [[0.1, 0.9]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const lineWidth = Math.min(width, height) * values![0] + return `M 0 0 L 0 ${height} L ${width} ${height} L ${width} ${height - lineWidth} L ${lineWidth} ${height - lineWidth} L ${lineWidth} 0 Z` + } + }, + [ShapePathFormulasKeys.RING_RECT]: { + editable: true, + defaultValue: [0.25], + range: [[0.1, 0.45]], + relative: ['left'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const lineWidth = Math.min(width, height) * values![0] + return `M 0 0 ${width} 0 ${width} ${height} L 0 ${height} L 0 0 Z M ${lineWidth} ${lineWidth} L ${lineWidth} ${height - lineWidth} L ${width - lineWidth} ${height - lineWidth} L ${width - lineWidth} ${lineWidth} Z` + } + }, + [ShapePathFormulasKeys.PLUS]: { + editable: true, + defaultValue: [0.25], + range: [[0.1, 0.9]], + relative: ['center'], + getBaseSize: [(width, height) => Math.min(width, height)], + formula: (width, height, values) => { + const lineWidth = Math.min(width, height) * values![0] + return `M ${width / 2 - lineWidth / 2} 0 L ${width / 2 - lineWidth / 2} ${height / 2 - lineWidth / 2} L 0 ${height / 2 - lineWidth / 2} L 0 ${height / 2 + lineWidth / 2} L ${width / 2 - lineWidth / 2} ${height / 2 + lineWidth / 2} L ${width / 2 - lineWidth / 2} ${height} L ${width / 2 + lineWidth / 2} ${height} L ${width / 2 + lineWidth / 2} ${height / 2 + lineWidth / 2} L ${width} ${height / 2 + lineWidth / 2} L ${width} ${height / 2 - lineWidth / 2} L ${width / 2 + lineWidth / 2} ${height / 2 - lineWidth / 2} L ${width / 2 + lineWidth / 2} 0 Z` + } + }, + [ShapePathFormulasKeys.TRIANGLE]: { + editable: true, + defaultValue: [0.5], + range: [[0, 1]], + relative: ['left'], + getBaseSize: [width => width], + formula: (width, height, values) => { + const vertex = width * values![0] + return `M ${vertex} 0 L 0 ${height} L ${width} ${height} Z` + } + }, + [ShapePathFormulasKeys.PARALLELOGRAM_LEFT]: { + editable: true, + defaultValue: [0.25], + range: [[0, 0.9]], + relative: ['left'], + getBaseSize: [width => width], + formula: (width, height, values) => { + const point = width * values![0] + return `M ${point} 0 L ${width} 0 L ${width - point} ${height} L 0 ${height} Z` + } + }, + [ShapePathFormulasKeys.PARALLELOGRAM_RIGHT]: { + editable: true, + defaultValue: [0.25], + range: [[0, 0.9]], + relative: ['right'], + getBaseSize: [width => width], + formula: (width, height, values) => { + const point = width * values![0] + return `M 0 0 L ${width - point} 0 L ${width} ${height} L ${point} ${height} Z` + } + }, + [ShapePathFormulasKeys.TRAPEZOID]: { + editable: true, + defaultValue: [0.25], + range: [[0, 0.5]], + relative: ['left'], + getBaseSize: [width => width], + formula: (width, height, values) => { + const point = width * values![0] + return `M ${point} 0 L ${width - point} 0 L ${width} ${height} L 0 ${height} Z` + } + }, + [ShapePathFormulasKeys.BULLET]: { + editable: true, + defaultValue: [0.2], + range: [[0, 1]], + relative: ['top'], + getBaseSize: [(width, height) => height], + formula: (width, height, values) => { + const point = height * values![0] + return `M ${width / 2} 0 L 0 ${point} L 0 ${height} L ${width} ${height} L ${width} ${point} Z` + } + }, + [ShapePathFormulasKeys.INDICATOR]: { + editable: true, + defaultValue: [0.2], + range: [[0, 0.9]], + relative: ['right'], + getBaseSize: [width => width], + formula: (width, height, values) => { + const point = width * values![0] + return `M ${width} ${height / 2} L ${width - point} 0 L 0 0 L ${point} ${height / 2} L 0 ${height} L ${width - point} ${height} Z` + } + }, +} + +export const SHAPE_LIST: ShapeListItem[] = [ + { + type: '矩形', + children: [ + { + viewBox: [200, 200], + path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z', + pptxShapeType: 'rect', + }, + { + viewBox: [200, 200], + path: 'M 50 0 L 150 0 Q 200 0 200 50 L 200 150 Q 200 200 150 200 L 50 200 Q 0 200 0 150 L 0 50 Q 0 0 50 0 Z', + pathFormula: ShapePathFormulasKeys.ROUND_RECT, + pptxShapeType: 'roundRect', + }, + { + viewBox: [200, 200], + path: 'M 0 200 L 0 0 L 150 0 L 200 50 L 200 200 Z', + pathFormula: ShapePathFormulasKeys.CUT_RECT_SINGLE, + pptxShapeType: 'snip1Rect', + }, + { + viewBox: [200, 200], + path: 'M 0 50 L 50 0 L 150 0 L 200 50 L 200 200 L 0 200 Z', + pathFormula: ShapePathFormulasKeys.CUT_RECT_SAMESIDE, + pptxShapeType: 'snip2SameRect', + }, + { + viewBox: [200, 200], + path: 'M 0 150 L 0 0 L 150 0 L 200 50 L 200 200 L 50 200 Z', + pathFormula: ShapePathFormulasKeys.CUT_RECT_DIAGONAL, + pptxShapeType: 'snip2DiagRect', + }, + { + viewBox: [200, 200], + path: 'M 50 0 L 150 0 L 200 50 L 200 200 L 0 200 L 0 50 Q 0 0 50 0 Z', + pathFormula: ShapePathFormulasKeys.CUT_ROUND_RECT, + pptxShapeType: 'snipRoundRect', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 150 0 Q 200 0 200 50 L 200 200 L 0 200 L 0 0 Z', + pathFormula: ShapePathFormulasKeys.ROUND_RECT_SINGLE, + pptxShapeType: 'round1Rect', + }, + { + viewBox: [200, 200], + path: 'M 0 50 Q 0 0 50 0 L 150 0 Q 200 0 200 50 L 200 200 L 0 200 Z', + pathFormula: ShapePathFormulasKeys.ROUND_RECT_SAMESIDE, + pptxShapeType: 'round2SameRect', + }, + { + viewBox: [200, 200], + path: 'M 50 0 L 200 0 L 200 150 Q 200 200 150 200 L 0 200 L 0 50 Q 0 0 50 0 Z', + pathFormula: ShapePathFormulasKeys.ROUND_RECT_DIAGONAL, + pptxShapeType: 'round2DiagRect', + }, + ] + }, + + { + type: '常用形状', + children: [ + { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z', + pptxShapeType: 'ellipse', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 0 200 L 200 200 L 100 0 Z', + pathFormula: ShapePathFormulasKeys.TRIANGLE, + pptxShapeType: 'triangle', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 0 200 L 200 200 Z' + }, + { + viewBox: [200, 200], + path: 'M 70 20 L 0 160 Q 0 200 40 200 L 160 200 Q 200 200 200 160 L 130 20 Q 100 -20 70 20 Z' + }, + { + viewBox: [200, 200], + path: 'M 50 0 L 200 0 L 150 200 L 0 200 L 50 0 Z', + pathFormula: ShapePathFormulasKeys.PARALLELOGRAM_LEFT, + pptxShapeType: 'parallelogram', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 150 0 L 200 200 L 50 200 L 0 0 Z', + pathFormula: ShapePathFormulasKeys.PARALLELOGRAM_RIGHT, + }, + { + viewBox: [200, 200], + path: 'M 50 0 L 150 0 L 200 200 L 0 200 L 50 0 Z', + pathFormula: ShapePathFormulasKeys.TRAPEZOID, + pptxShapeType: 'trapezoid', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 0 100 L 100 200 L 200 100 L 100 0 Z', + pptxShapeType: 'diamond', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 0 50 L 0 200 L 200 200 L 200 50 L 100 0 Z', + pathFormula: ShapePathFormulasKeys.BULLET, + }, + { + viewBox: [200, 200], + path: 'M 200 100 L 150 0 L 0 0 L 50 100 L 0 200 L 150 200 L 200 100 Z', + pathFormula: ShapePathFormulasKeys.INDICATOR, + }, + { + viewBox: [200, 200], + path: 'M 0 0 C 80 20 120 20 200 0 C 180 80 180 120 200 200 C 80 180 120 180 0 200 C 20 120 20 80 0 0 Z', + }, + { + viewBox: [200, 200], + path: 'M 10 10 C 60 0 140 0 190 10 C 200 60 200 140 190 190 C 140 200 60 200 10 190 C 0 140 0 60 10 10 Z', + }, + { + viewBox: [200, 200], + path: 'M 0 200 A 50 100 0 1 1 200 200 L 0 200 Z', + }, + { + viewBox: [200, 200], + path: 'M 40 20 A 100 100 0 1 0 200 100 L 100 100 L 40 20 Z' + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 100 100 102 1 0 200 100 L 100 100 L 100 0 Z', + pptxShapeType: 'pie', + }, + { + viewBox: [200, 200], + path: 'M 160 20 A 100 100 0 1 0 200 100 L 100 100 L 160 20 Z', + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 100 100 102 1 0 200 100 L 100 0 Z', + pptxShapeType: 'chord', + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 100 100 102 1 0 200 100 L 200 0 L 100 0 Z', + pptxShapeType: 'teardrop', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 200 0 Q 200 200 0 200 L 0 0 Z' + }, + { + viewBox: [200, 200], + path: `M100,0 L200,76.6 L161.8,200 L38.2,200 L0,76.6 Z`, + pptxShapeType: 'pentagon', + }, + { + viewBox: [200, 200], + path: 'M 40 0 L 160 0 L 200 100 L 160 200 L 40 200 L 0 100 Z', + pptxShapeType: 'hexagon', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 0 60 L 0 140 L 100 200 L 200 140 L 200 60 L 100 0 Z' + }, + { + viewBox: [200, 200], + path: `M100,0 L170.71,29.29 L200,100 L170.71,170.71 L100,200 L29.29,170.71 L0,100 L29.29,29.29 Z`, + }, + { + viewBox: [200, 200], + path: 'M 60 0 L 140 0 L 200 60 L 200 140 L 140 200 L 60 200 L 0 140 L 0 60 L 60 0 Z', + pptxShapeType: 'octagon', + }, + { + viewBox: [200, 200], + path: 'M 75 0 L 125 0 L 175 25 L 200 75 L 200 125 L 175 175 L 125 200 L 75 200 L 25 175 L 0 125 L 0 75 L 25 25 L 75 0 Z', + pptxShapeType: 'dodecagon', + }, + { + viewBox: [200, 200], + path: 'M 150 0 A 50 100 0 1 1 150 200 L 0 200 L 0 0 L 150 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 50 0 A 25 50 0 1 0 50 200 L 150 200 A 25 50 0 1 0 150 0 L 50 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 150 0 A 50 100 0 1 1 150 200 L 0 200 A 50 100 0 0 0 0 0 L 150 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 200 0 L 200 200 L 0 200 L 0 100 L 200 0 Z', + pptxShapeType: 'flowChartManualInput', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 200 100 L 200 200 L 0 200 L 0 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 200 0 L 200 150 C 110 140 110 240 0 180 Z', + pptxShapeType: 'flowChartDocument', + }, + { + viewBox: [200, 200], + path: 'M 200 0 L 100 0 L 0 100 L 0 200 L 200 0 Z', + pptxShapeType: 'diagStripe', + }, + { + viewBox: [200, 200], + path: 'M 50 0 L 150 0 L 150 50 L 200 50 L 200 150 L 150 150 L 150 200 L 50 200 L 50 150 L 0 150 L 0 50 L 50 50 L 50 0 Z', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 0 200 L 200 200 L 200 140 L 60 140 L 60 0 L 0 0 Z', + pathFormula: ShapePathFormulasKeys.L, + pptxShapeType: 'corner', + }, + { + viewBox: [200, 200], + path: 'M0 0 L200 0 L200 200 L0 200 L0 0 Z M50 50 L50 150 L150 150 L150 50 Z', + pathFormula: ShapePathFormulasKeys.RING_RECT, + pptxShapeType: 'frame', + }, + { + viewBox: [200, 200], + path: 'M0 100 A100 100 0 1 1 0 101 Z M150 100 A50 50 0 1 0 150 101 Z', + pptxShapeType: 'donut', + }, + { + viewBox: [200, 200], + path: 'M 70 0 L 70 70 L 0 70 L 0 130 L 70 130 L 70 200 L 130 200 L 130 130 L 200 130 L 200 70 L 130 70 L 130 0 L 70 0 Z', + pathFormula: ShapePathFormulasKeys.PLUS, + pptxShapeType: 'mathPlus', + }, + { + viewBox: [200, 200], + path: 'M 0 70 L 200 70 L 200 130 L 0 130 Z', + pptxShapeType: 'mathMinus', + }, + { + viewBox: [200, 200], + path: 'M 40 0 L 0 40 L 60 100 L 0 160 L 40 200 L 100 140 L 160 200 L 200 160 L 140 100 L 200 40 L 160 0 L 100 60 L 40 0 Z', + pptxShapeType: 'mathMultiply', + }, + { + viewBox: [200, 200], + path: 'M 0 80 L 200 80 L 200 120 L 0 120 Z M 100 0 A 25 25 0 1 1 100 50 A 25 25 0 1 1 100 0 M 100 200 A 25 25 0 1 1 100 150 A 25 25 0 1 1 100 200', + pptxShapeType: 'mathDivide', + }, + { + viewBox: [200, 200], + path: 'M 0 30 L 200 30 L 200 80 L 0 80 Z M 0 120 L 200 120 L 200 170 L 0 170 Z', + pptxShapeType: 'mathEqual', + }, + { + viewBox: [200, 200], + path: 'M 120 0 L 170 0 L 150 40 L 200 40 L 200 80 L 130 80 L 110 120 L 200 120 L 200 160 L 90 160 L 70 200 L 20 200 L 40 160 L 0 160 L 0 120 L 60 120 L 80 80 L 0 80 L 0 40 L 100 40 Z', + pptxShapeType: 'mathNotEqual', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 200 0 L 200 160 L 100 160 L 60 200 L 60 160 L 0 160 Z', + pathFormula: ShapePathFormulasKeys.MESSAGE, + pptxShapeType: 'wedgeRectCallout', + }, + { + viewBox: [200, 200], + path: 'M 0 40 Q 0 0 40 0 L 160 0 Q 200 0 200 40 L 200 120 Q 200 160 160 160 L 100 160 L 60 200 L 60 160 L 40 160 Q 0 160 0 120 L 0 40 Z', + pathFormula: ShapePathFormulasKeys.ROUND_MESSAGE, + pptxShapeType: 'wedgeRoundRectCallout', + }, + { + viewBox: [200, 200], + path: 'M 180 160 A 100 100 0 1 0 100 200 L 200 200 L 200 160 L 180 160 Z', + pptxShapeType: 'flowChartMagneticTape', + }, + { + viewBox: [200, 200], + path: 'M 200 0 L 0 0 L 200 200 L 0 200 L 200 0 Z', + pptxShapeType: 'flowChartCollate', + }, + { + viewBox: [200, 200], + path: 'M 0 20 C 60 60 140 -40 200 20 L 200 180 C 140 140 60 240 0 180 L 0 20 Z', + pptxShapeType: 'wave', + }, + { + viewBox: [200, 200], + path: 'M 0 20 C 40 -40 60 60 100 20 C 140 -40 160 60 200 20 L 200 180 C 140 240 160 140 100 180 C 40 240 60 140 0 180 L 0 20 Z', + pptxShapeType: 'doubleWave', + }, + { + viewBox: [200, 200], + path: 'M 100 0 Q 0 50 0 175 Q 100 225 200 175 Q 200 50 100 0 Z', + }, + { + viewBox: [200, 200], + path: 'M 0 100 A 50 50 0 1 1 200 100 L 100 200 L 0 100 Z', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 120 80 L 200 100 L 120 120 L 100 200 L 80 120 L 0 100 L 80 80 L 100 0 Z', + pptxShapeType: 'star4', + }, + { + viewBox: [1024, 1024], + path: 'M1018.67652554 400.05983681l-382.95318779-5.89158658L512 34.78141155 388.27666225 394.16825023l-382.95318779 5.89158658L311.68602415 629.83174977l-117.83174978 365.27842665 312.25413766-223.88032637 312.25413904 223.88032637-117.83175116-365.27842665 318.14572563-229.77191296z', + pptxShapeType: 'star5', + special: true, + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 60 60 L 0 100 L 60 140 L 100 200 L 140 140 L 200 100 L 140 60 L 100 0 Z', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 140 60 L 200 60 L 160 100 L 200 140 L 140 140 L 100 200 L 60 140 L 0 140 L 40 100 L 0 60 L 60 60 L 100 0 Z', + pptxShapeType: 'star6', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 130 30 L 170 30 L 170 70 L 200 100 L 170 130 L 170 170 L 130 170 L 100 200 L 70 170 L 30 170 L 30 130 L 0 100 L 30 70 L 30 30 L 70 30 L 100 0', + pptxShapeType: 'star8', + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 0 200 120 A 100 100 0 1 1 100 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 120 0 L 100 80 L 200 80 L 80 200 L 100 120 L 0 120 L 120 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 30 50 Q 40 -20 120 10 Q 180 -10 180 40 Q 210 70 190 100 C 210 140 180 170 160 170 Q 140 210 100 180 C 70 210 20 190 30 150 C -10 140 -10 80 30 50', + pptxShapeType: 'cloud', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 0 100 L 100 200 L 200 100 L 100 0 Z M 200 100 L 0 100', + withborder: true, + pptxShapeType: 'flowChartSort', + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z M 170 30 L 30 170', + withborder: true, + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z M 30 30 L 170 170', + withborder: true, + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z M 170 30 L 30 170 M 30 30 L 170 170', + withborder: true, + pptxShapeType: 'flowChartSummingJunction', + }, + { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z M 200 100 L 0 100 M 100 0 L 100 200', + withborder: true, + pptxShapeType: 'flowChartOr', + }, + { + viewBox: [200, 200], + path: 'M 160 0 A 40 100 0 1 1 160 200 L 40 200 A 40 100 0 1 1 40 0 L 160 0 Z M 160 200 A 40 100 0 1 1 160 0', + withborder: true, + pptxShapeType: 'flowChartMagneticDrum', + }, + { + viewBox: [200, 200], + path: 'M 0 40 A 50 20 0 1 1 200 40 L 200 160 A 50 20 0 1 1 0 160 L 0 40 Z M 200 40 A 50 20 0 1 1 0 40', + withborder: true, + pptxShapeType: 'can', + }, + { + viewBox: [200, 200], + path: 'M 200 0 L 50 0 L 0 50 L 0 200 L 150 200 L 200 150 L 200 0 Z M 200 0 L 150 50 M 150 50 L 0 50 M 150 50 L 150 200', + withborder: true, + pptxShapeType: 'cube', + }, + ], + }, + + { + type: '箭头', + children: [ + { + viewBox: [200, 200], + path: 'M 100 0 L 0 100 L 50 100 L 50 200 L 150 200 L 150 100 L 200 100 L 100 0 Z', + pptxShapeType: 'upArrow', + }, + { + viewBox: [200, 200], + path: 'M 100 200 L 200 100 L 150 100 L 150 0 L 50 0 L 50 100 L 0 100 L 100 200 Z', + pptxShapeType: 'downArrow', + }, + { + viewBox: [200, 200], + path: 'M 0 100 L 100 0 L 100 50 L 200 50 L 200 150 L 100 150 L 100 200 L 0 100 Z', + pptxShapeType: 'leftArrow', + }, + { + viewBox: [200, 200], + path: 'M 200 100 L 100 0 L 100 50 L 0 50 L 0 150 L 100 150 L 100 200 L 200 100 Z', + pptxShapeType: 'rightArrow', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 0 60 L 60 60 L 60 140 L 0 140 L 100 200 L 200 140 L 140 140 L 140 60 L 200 60 L 100 0 Z', + pptxShapeType: 'upDownArrow', + }, + { + viewBox: [200, 200], + path: 'M 0 100 L 60 0 L 60 60 L 140 60 L 140 0 L 200 100 L 140 200 L 140 140 L 60 140 L 60 200 L 0 100 Z', + pptxShapeType: 'leftRightArrow', + }, + { + viewBox: [200, 200], + path: 'M 100 0 L 60 40 L 80 40 L 80 80 L 40 80 L 40 60 L 0 100 L 40 140 L 40 120 L 80 120 L 80 160 L 60 160 L 100 200 L 140 160 L 120 160 L 120 120 L 160 120 L 160 140 L 200 100 L 160 60 L 160 80 L 120 80 L 120 40 L 140 40 L 100 0 Z', + pptxShapeType: 'quadArrow', + }, + { + viewBox: [200, 200], + path: 'M 0 100 L 100 0 L 100 50 L 200 50 L 150 100 L 200 150 L 100 150 L 100 200 L 0 100 Z', + }, + { + viewBox: [200, 200], + path: 'M 200 100 L 100 0 L 100 50 L 0 50 L 50 100 L 0 150 L 100 150 L 100 200 L 200 100 Z', + pptxShapeType: 'notchedRightArrow', + }, + { + viewBox: [200, 200], + path: 'M 0 100 L 80 20 L 80 80 L 120 80 L 120 0 L 200 0 L 200 200 L 120 200 L 120 120 L 80 120 L 80 180 L 0 100 Z', + pptxShapeType: 'leftArrowCallout', + }, + { + viewBox: [200, 200], + path: 'M 200 100 L 120 20 L 120 80 L 80 80 L 80 0 L 0 0 L 0 200 L 80 200 L 80 120 L 120 120 L 120 180 L 200 100 Z', + pptxShapeType: 'rightArrowCallout', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 120 0 L 200 100 L 120 200 L 0 200 L 80 100 L 0 0 Z', + pptxShapeType: 'chevron', + }, + { + viewBox: [200, 200], + path: 'M 80 0 L 200 0 L 120 100 L 200 200 L 80 200 L 0 100 L 80 0 Z', + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 140 0 L 200 100 L 140 200 L 0 200 L 0 100 L 0 0 Z', + pptxShapeType: 'homePlate', + }, + { + viewBox: [200, 200], + path: 'M 60 0 L 200 0 L 200 100 L 200 200 L 60 200 L 0 100 L 60 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 200 100 L 0 200 L 60 100 L 0 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 200 0 L 0 100 L 200 200 L 140 100 L 200 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 0 0 L 80 0 L 200 100 L 80 200 L 0 200 L 120 100 L 0 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 200 0 L 120 0 L 0 100 L 120 200 L 200 200 L 80 100 L 200 0 Z' + }, + { + viewBox: [200, 200], + path: 'M 0 200 L 180 200 L 180 40 L 200 40 L 160 0 L 120 40 L 140 40 L 140 160 L 0 160 L 0 200 Z', + pptxShapeType: 'bentUpArrow', + }, + { + viewBox: [200, 200], + path: 'M 0 200 L 0 20 L 160 20 L 160 0 L 200 40 L 160 80 L 160 60 L 40 60 L 40 200 L 0 200 Z' + }, + { + viewBox: [200, 200], + path: 'M 40 180 L 180 180 L 180 40 L 200 40 L 160 0 L 120 40 L 140 40 L 140 140 L 40 140 L 40 120 L 0 160 L 40 200 L 40 180 Z', + pptxShapeType: 'leftUpArrow', + }, + { + viewBox: [1024, 1024], + path: 'M398.208 302.912V64L0 482.112l398.208 418.176V655.36c284.48 0 483.584 95.552 625.792 304.64-56.896-298.688-227.584-597.312-625.792-657.088z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M625.792 302.912V64L1024 482.112l-398.208 418.176V655.36C341.312 655.36 142.208 750.912 0 960c56.896-298.688 227.584-597.312 625.792-657.088z', + special: true, + }, + ], + }, + + { + type: '其他形状', + children: [ + { + viewBox: [1024, 1024], + path: 'M995.336 243.4016c-15.7584-36.5736-38.3376-69.26639999-66.91440001-97.37280001-28.5768-27.98879999-61.73999999-49.8624-98.78399999-65.26799998-38.22-15.876-78.6744-23.8728-120.4224-23.87280001-57.97680001 0-114.5424 15.876-163.69919999 45.864-11.76 7.17360001-22.932 15.05279999-33.51600001 23.63760001-10.584-8.5848-21.75600001-16.46400001-33.51600001-23.63760001-49.1568-29.98799999-105.7224-45.86399999-163.69919999-45.864-41.74799999 0-82.2024 7.9968-120.4224 23.87280001-36.9264 15.28799999-70.2072 37.27919999-98.78399999 65.26799998-28.6944 28.10640001-51.156 60.79919999-66.91440001 97.37280001-16.34639999 37.9848-24.696 78.3216-24.696 119.83439999 0 39.1608 7.9968 79.96800001 23.8728 121.48080001 13.28880001 34.692 32.34000001 70.67760001 56.6832 107.016 38.57279999 57.5064 91.61040001 117.4824 157.4664 178.28160001 109.1328 100.78319999 217.2072 170.4024 221.79359999 173.22479998l27.87120001 17.8752c12.348 7.8792 28.224 7.8792 40.572 0l27.87119999-17.8752c4.58639999-2.94 112.54319999-72.44159999 221.79360001-173.22479998 65.85599999-60.79919999 118.89359999-120.7752 157.4664-178.28160001 24.3432-36.33839999 43.512-72.324 56.68319999-107.016 15.876-41.5128 23.8728-82.32 23.87280001-121.48080001 0.1176-41.5128-8.232-81.8496-24.5784-119.83439999z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M985.20746667 343.50079998l-303.32586667-44.08319999L546.28693333 24.5248c-3.70346666-7.5264-9.79626667-13.6192-17.32266665-17.32266668-18.87573334-9.3184-41.81333333-1.55306667-51.25120001 17.32266668L342.1184 299.41759999l-303.32586667 44.08319999c-8.36266667 1.19466667-16.00853333 5.13706667-21.8624 11.11040001-14.69440001 15.17226667-14.45546667 39.30453334 0.71679999 54.1184l219.46026668 213.9648-51.84853333 302.1312c-1.43359999 8.24320001-0.11946667 16.8448 3.82293333 24.25173333 9.79626667 18.6368 32.9728 25.92426667 51.6096 16.00853334L512 822.44266665l271.3088 142.64320001c7.40693333 3.9424 16.00853333 5.25653333 24.25173333 3.82293333 20.78719999-3.584 34.7648-23.296 31.1808-44.0832l-51.84853333-302.1312 219.46026668-213.9648c5.97333334-5.85386666 9.91573333-13.49973334 11.11039999-21.8624 3.2256-20.90666667-11.34933333-40.26026667-32.256-43.36640001z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M852.65066667 405.84533333C800.54044445 268.40177778 667.76177778 170.66666667 512.22755555 170.66666667S223.91466667 268.288 171.80444445 405.73155555C74.29688889 431.33155555 2.27555555 520.07822222 2.27555555 625.77777778c0 125.72444445 101.83111111 227.55555555 227.44177778 227.55555555h564.56533334C919.89333333 853.33333333 1021.72444445 751.50222222 1021.72444445 625.77777778c0-105.472-71.79377778-194.21866667-169.07377778-219.93244445z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M926.25224691 323.7371485H654.6457886L898.88200917 15.14388241c5.05486373-6.53433603 0.49315743-16.02761669-7.76722963-16.02761668H418.30008701c-3.45210206 0-6.78091476 1.84934039-8.50696579 4.93157436L90.35039154 555.76772251c-3.82197013 6.53433603 0.86302552 14.7947231 8.50696578 14.79472311h215.01664245l-110.22068713 440.88274851c-2.34249783 9.61657002 9.24670194 16.39748478 16.39748477 9.49328065L933.03316167 340.62779071c6.41104668-6.0411786 2.09591911-16.8906422-6.78091476-16.89064221z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M878.47822222 463.30311111c-22.18666667-49.83466667-53.93066667-93.98044445-94.32177777-131.072l-33.10933334-30.37866666c-4.89244445-4.32355555-12.62933333-2.38933333-14.79111111 3.75466666l-14.79111111 42.43911111c-9.216 26.624-26.16888889 53.81688889-50.176 80.55466667-1.59288889 1.70666667-3.41333333 2.16177778-4.66488889 2.27555556-1.25155555 0.11377778-3.18577778-0.11377778-4.89244445-1.70666667-1.59288889-1.36533333-2.38933333-3.41333333-2.27555555-5.46133333 4.20977778-68.49422222-16.27022222-145.74933333-61.09866667-229.83111112C561.26577778 124.01777778 509.72444445 69.51822222 445.32622222 31.51644445l-46.99022222-27.648c-6.144-3.64088889-13.99466667 1.13777778-13.65333333 8.30577777l2.50311111 54.61333333c1.70666667 37.31911111-2.61688889 70.31466667-12.85688889 97.73511112-12.51555555 33.56444445-30.49244445 64.73955555-53.47555556 92.72888888-16.15644445 19.56977778-34.24711111 37.20533333-54.04444444 52.45155556-47.90044445 36.75022222-87.38133333 84.65066667-114.11911111 138.24C125.72444445 502.10133333 111.50222222 562.74488889 111.50222222 623.50222222c0 53.70311111 10.58133333 105.69955555 31.51644445 154.73777778 20.25244445 47.21777778 49.152 89.77066667 85.90222222 126.17955555 36.864 36.40888889 79.64444445 65.08088889 127.31733333 84.992C405.61777778 1010.11911111 457.95555555 1020.58666667 512 1020.58666667s106.38222222-10.46755555 155.76177778-31.06133334c47.67288889-19.91111111 90.56711111-48.46933333 127.31733333-84.992 36.864-36.40888889 65.76355555-78.96177778 85.90222222-126.17955555 20.93511111-49.03822222 31.51644445-101.03466667 31.51644445-154.73777778 0-55.52355555-11.37777778-109.45422222-34.01955556-160.31288889z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M968.20337778 20.11591112H705.44042667c-22.17301333 0-41.92483556 15.16430222-47.14951111 37.33731555C642.36202666 124.73685332 582.08711111 173.03324444 512 173.03324444s-130.36202666-48.29639112-146.29091556-115.58001777c-5.22467555-22.17301333-24.84906667-37.33731556-47.14951111-37.33731555H55.79662222c-30.96576 0-56.06968889 25.10392889-56.06968888 56.06968888v321.12639999c0 30.96576 25.10392889 56.06968889 56.06968888 56.06968889h95.57333334v494.43271112c0 30.96576 25.10392889 56.06968889 56.06968889 56.06968888h609.1207111c30.96576 0 56.06968889-25.10392889 56.06968889-56.06968888V453.38168888h95.57333334c30.96576 0 56.06968889-25.10392889 56.06968888-56.06968889V76.1856c0-30.96576-25.10392889-56.06968889-56.06968888-56.06968888z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.94648889 239.80714666H523.46880001L373.99210666 96.82944c-1.91146667-1.78403556-4.46008889-2.80348444-7.00871111-2.80348445H43.05351111c-22.55530667 0-40.77795555 18.22264888-40.77795555 40.77795557v754.39217776c0 22.55530667 18.22264888 40.77795555 40.77795555 40.77795557h937.89297778c22.55530667 0 40.77795555-18.22264888 40.77795555-40.77795557V280.58510222c0-22.55530667-18.22264888-40.77795555-40.77795555-40.77795556z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M972.60904597 164.57058577L841.30587843 33.39070759c-18.86327195-18.86327195-44.1375906-29.34286748-70.64480282-29.3428675-26.75379095 0-51.90482023 10.47959553-70.76809219 29.3428675L558.60337778 174.68031322c-18.86327195 18.86327195-29.34286748 44.1375906-29.34286749 70.64480283 0 26.75379095 10.47959553 51.90482023 29.34286749 70.76809218l103.31648301 103.31648302c-24.28800376 53.50758189-57.69942011 101.59043198-99.24793416 143.13894603-41.42522469 41.67180341-89.63136414 75.08321976-143.13894603 99.61780223L316.21649759 558.84995649c-18.86327195-18.86327195-44.1375906-29.34286748-70.64480283-29.34286747-26.75379095 0-51.90482023 10.47959553-70.76809217 29.34286747L33.39070759 700.01627278c-18.86327195 18.86327195-29.34286748 44.1375906-29.3428675 70.76809217 0 26.75379095 10.47959553 51.90482023 29.3428675 70.76809219l131.05658883 131.05658883c30.08260365 30.205893 71.63111769 47.34311394 114.28923598 47.34311394 9.00012323 0 17.63037836-0.73973616 26.13734414-2.21920846 166.19405621-27.37023774 331.03192945-115.76870829 464.06114804-248.67463751C901.84095379 636.27567408 990.11613498 471.56109018 1017.85624079 304.87387654c8.38367642-50.91850535-8.50696579-103.31648302-45.24719482-140.30329077z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M910.60451556 640.96028445c-20.38897778-65.49959112-43.83630221-120.54983112-79.89930667-210.64362666C836.31217778 193.67708444 737.93535999 2.27555556 511.36284444 2.27555556 282.24170667 2.27555556 186.03121778 197.50001778 192.14791111 430.31665779c-36.19043555 90.22122667-59.51032888 144.88917333-79.89930667 210.64362666-43.32657778 139.53706668-29.30915556 197.26336001-18.60494222 198.53767111 22.9376 2.80348444 89.32920888-105.00323556 89.32920889-105.00323556 0 62.44124445 32.11264001 143.86972444 101.69002667 202.61546667-33.64181333 10.32192-109.20846222 38.10190221-91.24067556 68.55793777 14.52714667 24.59420444 250.01984 15.67402668 317.94062222 8.02816 67.92078222 7.64586667 303.41347556 16.56604444 317.94062223-8.02816 17.96778667-30.32860444-57.72629333-58.23601779-91.24067555-68.55793777 69.57738667-58.87317334 101.69002667-140.30165333 101.69002667-202.61546667 0 0 66.39160889 107.80672 89.32920888 105.00323556 10.83164445-1.40174222 24.84906667-59.12803556-18.47751111-198.53767111z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M1016.86992592 199.24764445c-37.13706667 16.01991111-77.55093333 27.54939259-119.17842962 32.03982222 42.96248889-25.60758518 75.60912592-66.02145185 91.02222222-114.08118519-39.68568889 23.66577778-84.58998518 41.02068148-131.31472593 50.00154074C819.53374815 126.79395555 765.76995555 101.79318518 706.18074075 101.79318518c-114.688 0-206.92385185 92.96402963-206.92385186 207.04521482 0 16.01991111 1.94180741 32.03982222 5.09724444 47.45291852-171.72859259-8.98085925-324.88865185-91.02222222-426.71217778-216.63288889-17.96171852 30.82619259-28.15620741 66.02145185-28.1562074 104.49351112 0 71.84687408 36.53025185 135.19834075 92.23585185 172.45677036-33.98162963-1.33499259-66.02145185-10.92266667-93.57084445-26.33576296v2.54862222c0 100.6098963 71.1186963 183.98625185 165.90317037 203.1616-17.3549037 4.49042963-35.92343703 7.03905185-54.49197037 7.03905185-13.47128889 0-26.2144-1.33499259-39.07887407-3.15543704C146.69748148 681.90814815 223.03478518 741.49736297 313.93564445 743.43917037c-71.1186963 55.7056-160.19911111 88.4736-256.9253926 88.4736-17.3549037 0-33.37481482-0.60681482-50.00154074-2.54862222C98.75911111 888.22518518 207.62168889 922.20681482 324.85831111 922.20681482 705.45256297 922.20681482 913.71140741 606.90583703 913.71140741 333.23235555c0-8.98085925 0-17.96171852-0.60681482-26.94257777 40.2925037-29.4912 75.60912592-66.02145185 103.76533333-107.04213333z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M917.96720197 1.08889505H106.03279803C53.56084718 1.08889505 9.37393998 45.27580225 9.37393998 97.74775309v5.52336372c0 19.33177108 8.28504494 41.42522469 22.0934536 55.23363205l331.40179753 392.15879462v325.87843379c0 16.57008987 8.28504494 30.37849854 22.09345359 35.90186098l209.88780469 104.94390299 2.76168121 2.76168121c27.61681602 11.04672615 55.23363335-8.28504494 55.23363335-38.66354218V550.66354348l331.40179753-392.15879462c35.90186097-41.42522469 30.37849854-102.18222047-11.04672616-135.32240022-11.04672615-13.80840865-33.14017975-22.0934536-55.23363335-22.09345359z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M491.70164031 97.48884502a25.89076502 25.89076502 0 0 1 40.59671938 0L745.66415762 367.01171317a25.89076502 25.89076502 0 0 0 30.49932208 7.72839349l208.00640948-89.14190458a25.89076502 25.89076502 0 0 1 35.56096592 29.06238339l-115.18801541 554.96855704A103.56306132 103.56306132 0 0 1 803.14165689 952.14301275H220.85834311a103.56306132 103.56306132 0 0 1-101.4011828-82.51387024l-115.18801541-554.96855704a25.89076502 25.89076502 0 0 1 35.54802012-29.06238339l208.01935528 89.14190458a25.89076502 25.89076502 0 0 0 30.49932208-7.72839349l213.36579793-269.52286815z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M643.02466884 387.7801525c19.85376751-88.69205333 33.718272-152.84087467 41.61900049-192.57389433C704.52292267 95.17283515 652.90057916 2.27555515 550.58614084 2.27555515c-92.26012484 0-138.59407685 45.84971417-165.91530666 137.49816969l-0.70087152 2.67605334c-16.40038399 74.13942085-41.47882668 131.61085116-74.6746315 172.73287031a189.06953915 189.06953915 0 0 1-143.04142182 70.44391902l-26.17434983 0.5606965C77.66380049 387.52529067 27.76177817 438.90551468 27.76177817 501.84374084V881.55022182c0 77.4144 62.25009818 140.17422182 139.05282766 140.17422303h492.82707951c101.23127467 0 191.59267516-63.995904 225.93535999-159.98976l102.37815468-286.22301868c26.04691951-72.82688-11.39234134-153.15945284-83.63303784-179.42300483a138.04612267 138.04612267 0 0 0-47.17499733-8.30850884H643.02466884z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 512c140.82958222 0 254.86222222-114.03264 254.86222222-254.86222222S652.82958222 2.27555555 512 2.27555555a254.78940445 254.78940445 0 0 0-254.86222222 254.86222223C257.13777778 397.96736 371.17041778 512 512 512z m0 72.81777778c-170.10232889 0-509.72444445 97.57582222-509.72444445 291.27111111v145.63555556h1019.4488889v-145.63555556c0-193.69528889-339.62211555-291.27111111-509.72444445-291.27111111z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M1019.81297778 564.50161779l-138.89991111-472.51456c-8.66531556-25.99594668-29.43658667-43.45400889-57.21656889-43.45400891s-50.33528889 15.67402668-59.00060446 41.66997334l-92.00526221 274.48661334H351.69166222L259.6864 90.33045333c-8.66531556-25.99594668-31.22062222-41.66997333-59.00060444-41.66997332s-50.33528889 17.33063112-57.2165689 43.45400887L4.69674667 564.50161779c-5.22467555 17.33063112 1.78403556 36.44529778 15.67402667 46.89464887l491.11950221 368.27591113 492.77610666-368.27591113c13.76256-10.32192 20.77127111-29.43658667 15.54659557-46.89464887z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M927.78951111 340.39277037c-12.01493333-47.81700741 12.01493333-124.03294815 89.08041481-150.97552592l-82.40545184-4.36906667s-31.19028148-109.22666667-174.27721483-118.9357037c-143.08693333-9.8304-236.65777778-3.64088889-236.65777777-3.6408889s106.07122963 67.47780741 63.5941926 187.74850371c-31.06891852 63.71555555-79.85682963 116.02299259-132.04290371 175.61220741-1.57771852 1.57771852-3.03407408 3.15543703-4.2477037 4.49042962C278.25493333 624.86755555 7.13007408 934.34311111 7.13007408 934.34311111c298.43152592 78.15774815 498.43768889-7.64586667 616.76657777-110.56165926 24.87940741-0.24272592 43.5693037-0.36408889 56.19105185-0.36408888 164.8109037 0 304.13558518-142.72284445 298.43152593-301.4656-3.88361482-109.1053037-38.71478518-133.74198518-50.72971852-181.5589926z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M997.8886764 504.17210418L537.2729208 43.89182982c-13.97838539-13.97838539-36.56745619-13.97838539-50.5458416 0L26.1113236 504.17210418c-13.41924998 13.41924998-21.02349164 31.64706454-21.02349163 50.65766867 0 39.47496036 32.09437288 71.56933323 71.56933324 71.56933323h48.53295408V954.83524937c0 19.79339373 15.99127289 35.78466661 35.78466663 35.78466662H440.43066677V740.12724968h125.24633315v250.49266631h297.34821416c19.79339373 0 35.78466661-15.99127289 35.78466663-35.78466662V626.39910608h48.53295408c19.01060414 0 37.23841869-7.49241457 50.65766869-21.02349163 27.84494371-27.95677079 27.84494371-73.24673948-0.11182708-101.20351027z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M1009.13013121 349.27572283L674.72427717 14.86986879c-8.82158299-8.82158299-20.35749924-13.16451618-31.89341544-13.16451618s-23.07183245 4.34293316-31.89341547 13.16451618L392.29790453 233.6451272c-16.5574327-1.90003326-33.25058207-2.71433322-49.94373146-2.71433324-99.34459624 0-198.68919249 32.70771543-280.25490606 98.12314628-20.90036589 16.69314938-22.52896582 48.04369819-3.66434987 67.04403081l246.59717401 246.59717401-292.33368895 292.06225564c-3.52863319 3.52863319-5.83581644 8.27871636-6.24296642 13.30023282l-4.61436649 50.48659809c-1.22144996 12.75736619 8.95729967 23.6146991 21.57894918 23.6146991 0.6785833 0 1.35716662 0 2.03574992-0.13571666l50.48659809-4.61436649c5.02151649-0.40714999 9.77159962-2.71433322 13.30023282-6.24296643l292.33368896-292.33368896 246.59717402 246.59717401c8.82158299 8.82158299 20.35749924 13.16451618 31.89341544 13.16451618 13.16451618 0 26.19331567-5.70009979 35.15061536-16.82886604 76.40848044-95.40881307 108.16617924-214.83947521 95.27309638-330.33435417l218.63954175-218.63954173c17.50744934-17.37173267 17.50744934-45.8722316 0-63.51539759z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M976.62005979 160.47737905c-0.39452595-0.39452595-80.35178503 78.64217259-239.47725131 237.50462156l-111.6508437-111.65084369 237.89914752-237.89914752c-125.19623464-75.35445635-286.03131335-56.02268482-390.31767264 48.26367449-81.92988882 81.92988882-112.57140424 200.15616502-83.37648398 310.09739626l2.36715569 8.81107954-372.82702222 372.69551356c-8.15353628 8.15353628-8.15353628 21.56741857 0 29.72095487l185.95323084 185.95323084c8.15353628 8.15353628 21.56741857 8.15353628 29.72095485 0l372.56400493-372.56400493 8.81107953 2.3671557c110.07273989 29.32642892 228.29901608-1.18357785 310.36041356-83.24497533 104.41786795-104.2863593 123.74963948-265.12143802 49.97328693-390.05465535z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m220.16 343.26755556l-239.616 332.23111111c-14.44977778 20.13866667-44.37333333 20.13866667-58.82311111 0L291.84 481.16622222c-4.32355555-6.03022222 0-14.44977778 7.39555555-14.44977777h53.36177778c11.60533333 0 22.64177778 5.57511111 29.46844445 15.13244444l81.00977777 112.41244444 178.85866667-248.03555555c6.82666667-9.44355555 17.74933333-15.13244445 29.46844445-15.13244445H724.76444445c7.39555555 0 11.71911111 8.41955555 7.39555555 14.44977778z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m218.45333333 537.03111112c0 5.00622222-4.096 9.10222222-9.10222222 9.10222222H302.64888889c-5.00622222 0-9.10222222-4.096-9.10222222-9.10222222v-54.61333334c0-5.00622222 4.096-9.10222222 9.10222222-9.10222222h418.70222222c5.00622222 0 9.10222222 4.096 9.10222222 9.10222222v54.61333334z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m188.18844445 703.37422223l-75.09333334-0.34133333L512 570.48177778l-112.98133333 134.71288889-75.20711112 0.34133333c-5.00622222 0-9.10222222-3.98222222-9.10222222-9.10222222 0-2.16177778 0.79644445-4.20977778 2.16177778-5.91644445l148.02488889-176.35555555L316.87111111 337.92c-1.36533333-1.70666667-2.16177778-3.75466667-2.16177778-5.91644445 0-5.00622222 4.096-9.10222222 9.10222222-9.10222222l75.20711112 0.34133334L512 458.06933333l112.98133333-134.71288888 75.09333334-0.34133334c5.00622222 0 9.10222222 3.98222222 9.10222222 9.10222222 0 2.16177778-0.79644445 4.20977778-2.16177778 5.91644445L559.21777778 514.27555555l147.91111111 176.35555556c1.36533333 1.70666667 2.16177778 3.75466667 2.16177778 5.91644444 0 5.00622222-4.096 9.10222222-9.10222222 9.10222223z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m218.45333333 537.03111112c0 5.00622222-4.096 9.10222222-9.10222222 9.10222222H548.40888889v172.94222222c0 5.00622222-4.096 9.10222222-9.10222222 9.10222222h-54.61333334c-5.00622222 0-9.10222222-4.096-9.10222222-9.10222222V548.40888889H302.64888889c-5.00622222 0-9.10222222-4.096-9.10222222-9.10222222v-54.61333334c0-5.00622222 4.096-9.10222222 9.10222222-9.10222222h172.94222222V302.64888889c0-5.00622222 4.096-9.10222222 9.10222222-9.10222222h54.61333334c5.00622222 0 9.10222222 4.096 9.10222222 9.10222222v172.94222222h172.94222222c5.00622222 0 9.10222222 4.096 9.10222222 9.10222222v54.61333334z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m163.95377778 517.57511112L427.46311111 700.64355555c-1.59288889 1.13777778-3.41333333 1.70666667-5.34755556 1.70666667-5.00622222 0-9.10222222-4.096-9.10222222-9.10222222V331.88977778c0-1.93422222 0.56888889-3.75466667 1.70666667-5.34755556 2.95822222-4.096 8.64711111-5.00622222 12.74311111-2.048L675.95377778 505.17333333c0.79644445 0.56888889 1.47911111 1.25155555 2.048 2.048 2.95822222 3.98222222 2.048 9.67111111-2.048 12.62933334z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m200.81777778 666.39644445l-32.54044445 44.37333333c-2.95822222 4.096-8.64711111 4.89244445-12.74311111 1.93422222L479.34577778 577.76355555c-2.38933333-1.70666667-3.75466667-4.43733333-3.75466667-7.39555555V257.13777778c0-5.00622222 4.096-9.10222222 9.10222222-9.10222223h54.72711112c5.00622222 0 9.10222222 4.096 9.10222222 9.10222223v281.6l162.24711111 117.30488889c4.096 2.84444445 5.00622222 8.53333333 2.048 12.62933333z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M981.10577778 314.48177778c-25.6-61.09866667-62.464-115.93955555-109.34044445-163.04355556-46.87644445-46.99022222-101.60355555-83.968-162.70222222-109.568C646.59911111 15.58755555 580.38044445 2.27555555 512 2.27555555h-2.27555555c-68.83555555 0.34133333-135.39555555 13.99466667-198.08711112 40.84622223-60.52977778 25.94133333-114.80177778 62.80533333-161.22311111 109.79555555-46.42133333 46.99022222-82.83022222 101.60355555-108.08888889 162.47466667C16.27022222 378.42488889 3.072 445.44 3.41333333 514.38933333c0.34133333 78.96177778 19.22844445 157.35466667 54.49955556 227.44177778v172.94222222c0 28.89955555 23.43822222 52.33777778 52.224 52.33777778h172.71466666c69.97333333 35.38488889 148.13866667 54.272 226.98666667 54.61333334h2.38933333c68.03911111 0 133.91644445-13.19822222 196.03911112-39.02577778 60.75733333-25.37244445 115.37066667-61.78133333 162.13333333-108.31644445 46.87644445-46.53511111 83.74044445-100.92088889 109.568-161.56444444 26.73777778-62.80533333 40.39111111-129.59288889 40.73244445-198.54222223 0.22755555-69.29066667-13.19822222-136.53333333-39.59466667-199.79377777zM284.89955555 566.61333333c-30.03733333 0-54.49955555-24.46222222-54.49955555-54.61333333s24.46222222-54.61333333 54.49955555-54.61333333 54.49955555 24.46222222 54.49955556 54.61333333-24.34844445 54.61333333-54.49955556 54.61333333z m227.10044445 0c-30.03733333 0-54.49955555-24.46222222-54.49955555-54.61333333s24.46222222-54.61333333 54.49955555-54.61333333 54.49955555 24.46222222 54.49955555 54.61333333-24.46222222 54.61333333-54.49955555 54.61333333z m227.10044445 0c-30.03733333 0-54.49955555-24.46222222-54.49955556-54.61333333s24.46222222-54.61333333 54.49955556-54.61333333 54.49955555 24.46222222 54.49955555 54.61333333-24.46222222 54.61333333-54.49955555 54.61333333z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.2224823 3.06251924H43.7775177c-22.52048353 0-40.71499847 18.19451494-40.71499846 40.71499846v936.4449646c0 22.52048353 18.19451494 40.71499847 40.71499846 40.71499846h936.4449646c22.52048353 0 40.71499847-18.19451494 40.71499846-40.71499846V43.7775177c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499846zM745.4750693 325.8561164l-267.95558363 371.52436096c-16.15876501 22.52048353-49.62140436 22.52048353-65.78016939 0L253.07805667 477.51948567c-4.83490607-6.74342161 0-16.15876501 8.27023406-16.15876499h59.67291961c12.97790576 0 25.31963967 6.23448413 32.95370188 16.92217123l90.59087157 125.70755774 200.01242995-277.37092701c7.63406221-10.56045272 19.84856175-16.92217125 32.95370189-16.92217124H737.20483524c8.27023407 0 13.10514012 9.41534338 8.27023406 16.158765z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.2224823 3.06251924H43.7775177c-22.52048353 0-40.71499847 18.19451494-40.71499846 40.71499846v936.4449646c0 22.52048353 18.19451494 40.71499847 40.71499846 40.71499846h936.4449646c22.52048353 0 40.71499847-18.19451494 40.71499846-40.71499846V43.7775177c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499846zM756.28999077 542.53624885c0 5.59831228-4.58043732 10.17874961-10.17874962 10.17874962H277.88875885c-5.59831228 0-10.17874961-4.58043732-10.17874962-10.17874962v-61.0724977c0-5.59831228 4.58043732-10.17874961 10.17874962-10.17874962h468.2224823c5.59831228 0 10.17874961 4.58043732 10.17874962 10.17874962v61.0724977z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.2224823 3.06251924H43.7775177c-22.52048353 0-40.71499847 18.19451494-40.71499846 40.71499846v936.4449646c0 22.52048353 18.19451494 40.71499847 40.71499846 40.71499846h936.4449646c22.52048353 0 40.71499847-18.19451494 40.71499846-40.71499846V43.7775177c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499846zM720.79160148 697.63494611c5.59831228 6.61618726 0.8906406 16.6677025-7.76129658 16.66770249h-74.94104404c-5.98001539 0-11.70556205-2.67192177-15.64982754-7.25235911L512 575.36271635l-110.43943332 131.68757314c-3.81703111 4.58043732-9.54257777 7.25235911-15.64982754 7.25235911H310.9696951c-8.65193717 0-13.35960887-10.05151525-7.76129658-16.66770249L458.81603326 512 303.20839852 326.36505389c-5.59831228-6.61618726-0.8906406-16.6677025 7.76129658-16.66770249h74.94104404c5.98001539 0 11.70556205 2.67192177 15.64982754 7.25235911L512 448.63728365l110.43943332-131.68757314c3.81703111-4.58043732 9.54257777-7.25235911 15.64982754-7.25235911H713.0303049c8.65193717 0 13.35960887 10.05151525 7.76129658 16.66770249L565.18396674 512l155.60763474 185.63494611z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.2224823 3.06251924H43.7775177c-22.52048353 0-40.71499847 18.19451494-40.71499846 40.71499846v936.4449646c0 22.52048353 18.19451494 40.71499847 40.71499846 40.71499846h936.4449646c22.52048353 0 40.71499847-18.19451494 40.71499846-40.71499846V43.7775177c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499846zM677.02297814 523.19662459L423.31764398 722.70011704c-9.41534338 7.37959347-23.28388974 0.76340622-23.28388975-11.19662459V312.62374191c0-11.9600308 13.86854636-18.70345241 23.28388975-11.19662457l253.70533416 199.37625807c7.25235911 5.72554666 7.25235911 16.6677025 0 22.39324918z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.2224823 3.06251924H43.7775177c-22.52048353 0-40.71499847 18.19451494-40.71499846 40.71499846v936.4449646c0 22.52048353 18.19451494 40.71499847 40.71499846 40.71499846h936.4449646c22.52048353 0 40.71499847-18.19451494 40.71499846-40.71499846V43.7775177c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499846zM756.28999077 542.53624885c0 5.59831228-4.58043732 10.17874961-10.17874962 10.17874962H552.71499847v193.39624268c0 5.59831228-4.58043732 10.17874961-10.17874962 10.17874962h-61.0724977c-5.59831228 0-10.17874961-4.58043732-10.17874962-10.17874962V552.71499847H277.88875885c-5.59831228 0-10.17874961-4.58043732-10.17874962-10.17874962v-61.0724977c0-5.59831228 4.58043732-10.17874961 10.17874962-10.17874962h193.39624268V277.88875885c0-5.59831228 4.58043732-10.17874961 10.17874962-10.17874962h61.0724977c5.59831228 0 10.17874961 4.58043732 10.17874962 10.17874962v193.39624268h193.39624268c5.59831228 0 10.17874961 4.58043732 10.17874962 10.17874962v61.0724977z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M902.67315697 135.41705551L528.62204754 7.94466448C524.10877635 6.40354749 518.05438818 5.63298899 512 5.63298899s-12.10877635 0.7705585-16.62204754 2.31167549L121.32684303 135.41705551c-9.13662215 3.08223399-16.62204754 13.64989334-16.62204753 23.33691443v531.02488283c0 9.68702108 6.27454775 22.45627614 13.87005291 28.51066431L498.0198673 1013.9638196c3.85279247 2.9721542 8.8063828 4.51327118 13.87005291 4.51327118s10.12734022-1.54111698 13.87005291-4.51327118l379.4450189-295.67430252c7.59550517-5.94430839 13.87005291-18.71356345 13.87005291-28.51066431V158.75396994c0.22015956-9.68702108-7.26526581-20.14460066-16.40188796-23.33691443zM712.89560763 323.43332829L478.86598471 645.63685899c-7.04510625 9.68702108-21.57563786 9.68702108-28.6207441 0l-139.14084824-191.5388259c-4.18303182-5.8342286 0-13.9801327 7.15518603-13.9801327h60.76404132c5.61406904 0 11.0079785 2.75199463 14.31037204 7.26526582l71.22162091 97.97100864 166.11039557-228.74579323c3.30239355-4.51327118 8.58622323-7.26526581 14.31037204-7.26526581H705.7404216c7.15518602 0.11007979 11.33821785 8.25598388 7.15518603 14.09021248z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M959.86498307 186.28001231H797.00498922v-101.78749614c0-44.91373267-36.51626425-81.42999692-81.42999691-81.42999693H308.42500769c-44.91373267 0-81.42999692 36.51626425-81.42999691 81.42999693v101.78749614H64.13501693c-22.52048353 0-40.71499847 18.19451494-40.71499846 40.71499847v40.71499845c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874962h76.8495596l31.42688945 665.43575611c2.03574992 43.38692024 37.91584233 77.61296581 81.30276254 77.6129658h577.64404066c43.5141546 0 79.26701262-34.09881122 81.30276254-77.6129658l31.42688945-665.43575611H990.40123192c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874962v-40.71499845c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499847z m-254.46874039 0H318.60375732v-91.60874653h386.79248536v91.60874653z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.2224823 248.62485371H654.50249462V104.85001539c0-22.52048353-18.19451494-40.71499847-40.71499847-40.71499846H94.67126578v-50.89374808c0-5.59831228-4.58043732-10.17874961-10.17874961-10.17874961h-71.25124732c-5.59831228 0-10.17874961 4.58043732-10.17874961 10.17874961v997.5174623c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874961h71.25124732c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874961V674.85999383h315.54123807v143.77483833c0 22.52048353 18.19451494 40.71499847 40.71499846 40.71499846h529.29497999c22.52048353 0 40.71499847-18.19451494 40.71499846-40.71499846V289.33985217c0-22.52048353-18.19451494-40.71499847-40.71499846-40.71499846z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M804.63905145 265.16532183V94.67126578h109.42155836c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874961v-71.25124732c0-5.59831228-4.58043732-10.17874961-10.17874961-10.17874961H109.93939019c-5.59831228 0-10.17874961 4.58043732-10.17874961 10.17874961v71.25124732c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874961h109.42155836v170.49405605c0 103.6960117 53.94737296 194.92305513 135.3773699 246.83467817-81.42999692 51.91162303-135.37736988 143.13866646-135.3773699 246.83467817v170.49405605h-109.42155836c-5.59831228 0-10.17874961 4.58043732-10.17874961 10.17874961v71.25124732c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874961h804.12121962c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874961v-71.25124732c0-5.59831228-4.58043732-10.17874961-10.17874961-10.17874961h-109.42155836V758.83467817c0-103.6960117-53.94737296-194.92305513-135.3773699-246.83467817 81.42999692-51.91162303 135.37736988-143.13866646 135.3773699-246.83467817z', + special: true, + }, + { + viewBox: [1024, 1024], + path: 'M1020.928 448.44373333l-35.36213334-373.4528c-1.79200001-19.3536-17.2032-34.64533332-36.55679999-36.55679999L575.55626667 3.072h-0.47786666c-3.82293334 0-6.8096 1.19466667-9.07946669 3.46453333L6.53653333 565.99893332c-4.65919999 4.65919999-4.65919999 12.1856 0 16.84480001l434.61973334 434.61973334c2.26986667 2.26986667 5.25653333 3.46453333 8.48213333 3.46453333s6.21226667-1.19466667 8.48213333-3.46453333l559.46239999-559.46239999c2.38933332-2.5088 3.584-5.97333334 3.34506668-9.55733335zM735.40266668 362.66666667c-42.17173333 0-76.45866667-34.28693333-76.45866667-76.45866667s34.28693333-76.45866667 76.45866667-76.45866667 76.45866667 34.28693333 76.45866665 76.45866667-34.28693333 76.45866667-76.45866665 76.45866667z', + special: true, + }, + ], + }, + + { + type: '线性', + children: [ + { + viewBox: [1024, 1024], + path: 'M1009.55537674 75.96950982l-61.38012212-61.38012214c-4.48769762-4.48769762-11.870684-4.48769762-16.3583816 0L14.44462326 931.67210859c-4.48769762 4.48769762-4.48769762 11.870684 0 16.35838159l61.38012212 61.38012214c4.48769762 4.48769762 11.870684 4.48769762 16.3583816 0L1009.41061232 92.18312698c4.63246205-4.34293316 4.63246205-11.72591956 0.14476442-16.21361716zM210.88996692 419.35075905c114.94296453 0 208.46079213-93.51782759 208.46079213-208.46079213s-93.51782759-208.46079213-208.46079213-208.4607921-208.46079213 93.51782759-208.4607921 208.4607921 93.51782759 208.46079213 208.4607921 208.46079213z m0-312.69118816c57.47148228 0 104.23039605 46.75891379 104.23039607 104.23039603s-46.75891379 104.23039605-104.23039607 104.23039607-104.23039605-46.75891379-104.23039603-104.23039607 46.75891379-104.23039605 104.23039603-104.23039603zM813.11003308 604.64924095c-114.94296453 0-208.46079213 93.51782759-208.46079213 208.46079213s93.51782759 208.46079213 208.46079213 208.4607921 208.46079213-93.51782759 208.4607921-208.4607921-93.51782759-208.46079213-208.4607921-208.46079213z m0 312.69118816c-57.47148228 0-104.23039605-46.75891379-104.23039607-104.23039603s46.75891379-104.23039605 104.23039607-104.23039607 104.23039605 46.75891379 104.23039603 104.23039607-46.75891379 104.23039605-104.23039603 104.23039603z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M1004.96017383 478.58365209L483.27851088 25.80594621c-4.00443838-3.45210207-9.11354943-5.3852792-14.49882864-5.38527921h-122.20441284c-10.21822208 0-14.91308089 12.70373557-7.18037228 19.33177152l483.57045622 419.77561022H14.8973037c-6.07569962 0-11.04672658 4.97102697-11.04672658 11.04672657v82.85044938c0 6.07569962 4.97102697 11.04672658 11.04672658 11.04672657h807.92996557L339.25681303 984.24756148c-7.7327086 6.76612003-3.0378498 19.33177153 7.18037229 19.33177152h126.34693531c2.62359757 0 5.24719513-0.96658859 7.18037228-2.76168164L1004.96017383 545.41634791c20.2983601-17.67476253 20.2983601-49.1579333 0-66.83269582z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M1011.38217956 558.9924242L545.80649025 22.43713295c-17.81503843-20.62055629-49.79794206-20.62055629-67.75325638 0L12.61782044 558.9924242c-6.31241519 7.29434645-1.12220714 18.51641789 8.41655359 18.51641789h113.62347344c6.45269109 0 12.62483038-2.80551785 16.97338308-7.71517411L458.69516062 215.87758959V1005.77114384c0 6.1721393 5.04993216 11.22207145 11.22207144 11.22207145h84.16553588c6.1721393 0 11.22207145-5.04993216 11.22207144-11.22207145V215.87758959l307.06393007 353.91607839c4.20827679 4.90965626 10.38041608 7.71517413 16.97338308 7.71517411h113.62347344c9.53876074 0 14.72896878-11.22207145 8.41655359-18.51641789z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M1009.1026963 459.52804874H201.17273073l483.57045624-419.77561022c7.7327086-6.76612003 3.0378498-19.33177153-7.18037229-19.33177152h-122.20441283c-5.3852792 0-10.49439025 1.93317715-14.49882866 5.38527921L19.03982617 478.58365209c-20.2983601 17.67476253-20.2983601 49.1579333 0 66.69461175L543.89742302 1000.81765136c2.07126124 1.79509307 4.55677472 2.76168163 7.18037228 2.76168164h126.3469353c10.21822208 0 14.91308089-12.70373557 7.18037228-19.33177152L201.17273073 564.47195126H1009.1026963c6.07569962 0 11.04672658-4.97102697 11.04672658-11.04672657v-82.85044938c0-6.07569962-4.97102697-11.04672658-11.04672658-11.04672657z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M1002.96562597 446.49115791h-113.62347344c-6.45269109 0-12.62483038 2.80551785-16.97338308 7.71517411L565.30483938 808.12241041V18.22885616c0-6.1721393-5.04993216-11.22207145-11.22207144-11.22207145h-84.16553588c-6.1721393 0-11.22207145 5.04993216-11.22207144 11.22207145v789.89355425L151.63123055 454.20633202c-4.20827679-4.90965626-10.38041608-7.71517413-16.97338308-7.71517411h-113.62347344c-9.53876074 0-14.72896878 11.36234735-8.41655359 18.51641789L478.19350975 1001.56286705c17.81503843 20.62055629 49.79794206 20.62055629 67.75325638 0L1011.38217956 465.0075758c6.31241519-7.29434645 1.12220714-18.51641789-8.41655359-18.51641789z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M975.82443246 622.46726585H14.8973037c-6.07569962 0-11.04672658 4.97102697-11.04672658 11.04672658v82.85044937c0 6.07569962 4.97102697 11.04672658 11.04672658 11.04672659h835.6848661L651.32683905 980.10503902c-5.66144737 7.18037229-0.55233633 17.9509307 8.69929718 17.9509307h100.11095967c6.76612003 0 13.11798782-3.0378498 17.39859437-8.42312903l233.08593092-295.63802022c22.78387358-28.99765728 2.20934532-71.52755463-34.79718873-71.52755462zM1009.1026963 296.58883161H173.4178302l199.25533075-252.69387063c5.66144737-7.18037229 0.55233633-17.9509307-8.69929718-17.9509307h-100.11095967c-6.76612003 0-13.11798782 3.0378498-17.39859437 8.42312903L13.37837881 330.00517953c-22.78387358 28.99765728-2.20934532 71.52755463 34.65910466 71.52755462h961.06521283c6.07569962 0 11.04672658-4.97102697 11.04672658-11.04672658v-82.85044937c0-6.07569962-4.97102697-11.04672658-11.04672658-11.04672659z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M1010.75873115 64.13501693H13.24126885c-5.59831228 0-10.17874961 4.58043732-10.17874961 10.17874961v81.42999691c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874964h997.5174623c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874964v-81.42999691c0-5.59831228-4.58043732-10.17874961-10.17874961-10.17874961zM1010.75873115 858.07748691H13.24126885c-5.59831228 0-10.17874961 4.58043732-10.17874961 10.17874964v81.42999691c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874961h997.5174623c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874961v-81.42999691c0-5.59831228-4.58043732-10.17874961-10.17874961-10.17874964zM1010.75873115 461.10625194H13.24126885c-5.59831228 0-10.17874961 4.58043732-10.17874961 10.17874959v81.42999694c0 5.59831228 4.58043732 10.17874961 10.17874961 10.17874959h997.5174623c5.59831228 0 10.17874961-4.58043732 10.17874961-10.17874959v-81.42999694c0-5.59831228-4.58043732-10.17874961-10.17874961-10.17874959z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M591.98717801 512l405.34042913-483.16579151c6.79427767-8.02960089 1.08090782-20.22841761-9.41933951-20.2284176h-123.22349044c-7.25752386 0-14.20621693 3.24272343-18.99309439 8.80167789L511.38233839 415.95362022 177.07299399 17.40746878c-4.63246205-5.55895447-11.58115512-8.80167789-18.99309439-8.80167789H34.85640916c-10.50024731 0-16.21361717 12.19881672-9.41933952 20.2284176L430.77749876 512 25.43706964 995.16579151c-6.79427767 8.02960089-1.08090782 20.22841761 9.41933952 20.2284176h123.22349044c7.25752386 0 14.20621693-3.24272343 18.99309439-8.80167789l334.3093444-398.54615144 334.30934441 398.54615144c4.63246205 5.55895447 11.58115512 8.80167789 18.99309439 8.80167789h123.22349044c10.50024731 0 16.21361717-12.19881672 9.41933951-20.2284176L591.98717801 512z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M953.5488 832.61667556c-24.08448-57.08913778-58.74574221-108.31644445-102.70947556-152.28017777-43.96373333-43.96373333-95.19104-78.49756444-152.28017777-102.70947558-0.50972445-0.25486222-1.01944888-0.38229333-1.52917334-0.63715555C776.41955556 519.64586667 828.02915556 426.23886221 828.02915556 320.85333332c0-174.58062221-141.44853334-316.02915556-316.02915556-316.02915554S195.97084444 146.27271111 195.97084444 320.85333332c0 105.38552889 51.6096 198.79253333 130.99918223 256.26396447-0.50972445 0.25486222-1.01944888 0.38229333-1.52917334 0.63715555-57.08913778 24.08448-108.31644445 58.61831112-152.28017777 102.70947554-43.96373333 43.96373333-78.49756444 95.19104-102.70947556 152.28017779C46.74901333 888.55893332 34.13333334 947.8144 32.85902222 1008.72647111c-0.12743111 5.7344 4.46008889 10.44935111 10.19448889 10.44935111h76.45866667c5.60696888 0 10.06705778-4.46008889 10.19448889-9.93962666 2.54862221-98.37681778 42.05226667-190.50951112 111.88451555-260.34176001 72.25344-72.25344 168.20906666-112.01194667 270.40881778-112.01194667s198.15537778 39.75850667 270.40881778 112.01194667C852.24106667 818.72668444 891.74471111 910.85937779 894.29333333 1009.23619556c0.12743111 5.60696888 4.58752 9.93962667 10.19448889 9.93962666h76.45866667c5.7344 0 10.32192-4.71495112 10.19448889-10.44935111-1.27431111-60.91207112-13.88999112-120.16753779-37.59217778-176.10979555zM512 540.03484444c-58.49088 0-113.54112-22.81016889-154.95623111-64.22527999S292.81848888 379.34421333 292.81848888 320.85333332c0-58.49088 22.81016889-113.54112 64.22528001-154.9562311S453.50912 101.67182221 512 101.67182221s113.54112 22.81016889 154.95623111 64.22528001S731.18151112 262.36245333 731.18151112 320.85333332c0 58.49088-22.81016889 113.54112-64.22528001 154.95623113S570.49088 540.03484444 512 540.03484444z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M985.31555555 111.50222222H38.68444445c-20.13866667 0-36.40888889 16.27022222-36.4088889 36.40888889v728.17777778c0 20.13866667 16.27022222 36.40888889 36.4088889 36.40888889h946.6311111c20.13866667 0 36.40888889-16.27022222 36.4088889-36.40888889V147.91111111c0-20.13866667-16.27022222-36.40888889-36.4088889-36.40888889z m-45.5111111 126.06577778V830.57777778H84.19555555V237.568l-31.40266666-24.46222222 44.71466666-57.45777778 48.6968889 37.888h731.70488888l48.69688889-37.888 44.71466667 57.45777778-31.51644444 24.46222222z M877.90933333 193.42222222L512 477.86666667 146.09066667 193.42222222l-48.69688889-37.888-44.71466667 57.45777778 31.40266667 24.46222222 388.66488889 302.19377778c22.98311111 17.86311111 55.18222222 17.86311111 78.16533333 0L939.80444445 237.568l31.40266666-24.46222222-44.71466666-57.45777778-48.58311112 37.77422222z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M985.31555555 88.74666667H38.68444445c-20.13866667 0-36.40888889 16.27022222-36.4088889 36.40888888v564.33777778c0 20.13866667 16.27022222 36.40888889 36.4088889 36.40888889h432.35555555v127.43111111H275.34222222c-10.01244445 0-18.20444445 8.192-18.20444444 18.20444445v54.61333333c0 5.00622222 4.096 9.10222222 9.10222222 9.10222222h491.52c5.00622222 0 9.10222222-4.096 9.10222222-9.10222222v-54.61333333c0-10.01244445-8.192-18.20444445-18.20444444-18.20444445H552.96V725.90222222h432.35555555c20.13866667 0 36.40888889-16.27022222 36.4088889-36.40888889V125.15555555c0-20.13866667-16.27022222-36.40888889-36.4088889-36.40888888z m-45.5111111 555.23555555H84.19555555V170.66666667h855.6088889v473.31555555z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M512 2.27555555C230.51377778 2.27555555 2.27555555 230.51377778 2.27555555 512s228.23822222 509.72444445 509.72444445 509.72444445 509.72444445-228.23822222 509.72444445-509.72444445S793.48622222 2.27555555 512 2.27555555z m0 932.97777778c-233.69955555 0-423.25333333-189.55377778-423.25333333-423.25333333 0-101.26222222 35.61244445-194.33244445 95.00444444-267.15022222l595.39911111 595.39911111C706.33244445 899.64088889 613.26222222 935.25333333 512 935.25333333z m328.24888889-156.10311111L244.84977778 183.75111111C317.66755555 124.35911111 410.73777778 88.74666667 512 88.74666667c233.69955555 0 423.25333333 189.55377778 423.25333333 423.25333333 0 101.26222222-35.61244445 194.33244445-95.00444444 267.15022222z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M901.80266667 257.82044445L656.95288889 12.97066667c-6.82666667-6.82666667-16.04266667-10.69511111-25.71377778-10.69511112H147.91111111c-20.13866667 0-36.40888889 16.27022222-36.40888889 36.4088889v946.6311111c0 20.13866667 16.27022222 36.40888889 36.40888889 36.4088889h728.17777778c20.13866667 0 36.40888889-16.27022222 36.40888889-36.4088889V283.648c0-9.67111111-3.86844445-19.00088889-10.69511111-25.82755555zM828.52977778 300.37333333H614.4V86.24355555L828.52977778 300.37333333z m2.048 639.43111112H193.42222222V84.19555555h343.60888889v245.76c0 26.39644445 21.39022222 47.78666667 47.78666667 47.78666667h245.76v562.06222223z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M981.07392 55.79662222H42.92608c-31.22062222 0-50.71758221 34.02410666-35.04355556 61.16693334L304.28728889 620.82616888V927.42542221c0 22.55530667 18.09521779 40.77795555 40.52309333 40.77795557h334.37923556c22.42787556 0 40.52309333-18.22264888 40.52309333-40.77795557V620.82616888L1016.24490667 116.96355556c15.54659555-27.14282666-3.95036444-61.16693333-35.17098667-61.16693334zM628.47203556 876.45297779H395.52796444V677.66044445h233.07150222v198.79253334z m12.23338666-301.50200891l-12.10595556 21.15356445h-233.19893332l-12.10595556-21.15356445L130.59868445 147.54702221h762.8026311L640.70542222 574.95096888z', + special: true, + outlined: true, + }, + { + viewBox: [1024, 1024], + path: 'M980.62285431 4.54099753H654.39920987c-4.2719763 0-7.76722963 3.49525333-7.76722962 7.76722964v72.4941432c0 4.2719763 3.49525333 7.76722963 7.76722962 7.76722963h207.64393877L604.04167111 350.57107753c-64.72691358-49.83972347-143.69374815-76.7661195-226.67365136-76.7661195-99.54999309 0-193.27456395 38.83614815-263.5679921 109.25903012S4.54099753 547.08198717 4.54099753 646.63198025s38.83614815 193.27456395 109.25903012 263.5679921C184.09345581 980.62285431 277.81802667 1019.45900247 377.36801975 1019.45900247s193.27456395-38.83614815 263.5679921-109.25903012C711.35889383 839.90654419 750.19504197 746.18197333 750.19504197 646.63198025c0-82.9799032-26.92639605-161.68783013-76.63666567-226.41474372L931.4304 162.34521283V369.60079013c0 4.2719763 3.49525333 7.76722963 7.76722963 7.76722962h72.4941432c4.2719763 0 7.76722963-3.49525333 7.76722964-7.76722962V43.37714569c0-21.35988148-17.47626667-38.83614815-38.83614816-38.83614816zM377.36801975 921.07409383c-151.33152395 0-274.44211358-123.11058963-274.44211358-274.44211358s123.11058963-274.44211358 274.44211358-274.44211358 274.44211358 123.11058963 274.44211358 274.44211358-123.11058963 274.44211358-274.44211358 274.44211358z', + special: true, + outlined: true, + }, + ], + } +] \ No newline at end of file diff --git a/frontend/src/configs/storage.ts b/frontend/src/configs/storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..a849fc335ffb5346953c81ca0adc82b0d527f05b --- /dev/null +++ b/frontend/src/configs/storage.ts @@ -0,0 +1 @@ +export const LOCALSTORAGE_KEY_DISCARDED_DB = 'PPTIST_DISCARDED_DB' \ No newline at end of file diff --git a/frontend/src/configs/symbol.ts b/frontend/src/configs/symbol.ts new file mode 100644 index 0000000000000000000000000000000000000000..11a393484a87be8a50af4159ca9e4b4806d2d745 --- /dev/null +++ b/frontend/src/configs/symbol.ts @@ -0,0 +1,59 @@ +export const SYMBOL_LIST = [ + { + key: 'letter', + label: '字母', + children: [ + 'α', 'β', 'γ', 'δ', 'ϵ', 'ε', 'ζ', 'η', 'θ', 'ϑ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ϖ', 'ρ', 'ϱ', 'σ', 'ς', 'τ', 'υ', 'ϕ', 'φ', 'χ', 'ψ', 'ω', + 'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Υ', 'Φ', 'Ψ', 'Ω', + '𝐀', '𝐁', '𝐂', '𝐃', '𝐄', '𝐅', '𝐆', '𝐇', '𝐈', '𝐉', '𝐊', '𝐋', '𝐌', '𝐍', '𝐎', '𝐏', '𝐐', '𝐑', '𝐒', '𝐓', '𝐔', '𝐕', '𝐖', '𝐗', '𝐘', '𝐙', + '𝐚', '𝐛', '𝐜', '𝐝', '𝐞', '𝐟', '𝐠', '𝐡', '𝐢', '𝐣', '𝐤', '𝐥', '𝐦', '𝐧', '𝐨', '𝐩', '𝐪', '𝐫', '𝐬', '𝐭', '𝐮', '𝐯', '𝐰', '𝐱', '𝐲', '𝐳', + '𝓐', '𝓑', '𝓒', '𝓓', '𝓔', '𝓕', '𝓖', '𝓗', '𝓘', '𝓙', '𝓚', '𝓛', '𝓜', '𝓝', '𝓞', '𝓟', '𝓠', '𝓡', '𝓢', '𝓣', '𝓤', '𝓥', '𝓦', '𝓧', '𝓨', '𝓩', + '𝓪', '𝓫', '𝓬', '𝓭', '𝓮', '𝓯', '𝓰', '𝓱', '𝓲', '𝓳', '𝓴', '𝓵', '𝓶', '𝓷', '𝓸', '𝓹', '𝓺', '𝓻', '𝓼', '𝓽', '𝓾', '𝓿', '𝔀', '𝔁', '𝔂', '𝔃', + ], + }, + { + key: 'number', + label: '序号', + children: [ + '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳', + '⑴', '⑵', '⑶', '⑷', '⑸', '⑹', '⑺', '⑻', '⑼', '⑽', '⑾', '⑿', '⒀', '⒁', '⒂', '⒃', '⒄', '⒅', '⒆', '⒇', + 'º', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹', '₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉', + 'Ⅰ', 'Ⅱ', 'Ⅲ', 'Ⅳ', 'Ⅴ', 'Ⅵ', 'Ⅶ', 'Ⅷ', 'Ⅸ', 'Ⅹ', 'Ⅺ', 'Ⅻ', 'Ⅼ', 'Ⅽ', 'Ⅾ', 'Ⅿ', + 'ⅰ', 'ⅱ', 'ⅲ', 'ⅳ', 'ⅴ', 'ⅵ', 'ⅶ', 'ⅷ', 'ⅸ', 'ⅹ', 'ⅺ', 'ⅻ', 'ⅼ', 'ⅽ', 'ⅾ', 'ⅿ', 'ↀ', 'ↁ', 'ↂ', + '㊀', '㊁', '㊂', '㊃', '㊄', '㊅', '㊆', '㊇', '㊈', '㊉', '㈠', '㈡', '㈢', '㈣', '㈤', '㈥', '㈦', '㈧', '㈨', '㈩', + '𝟘', '𝟙', '𝟚', '𝟛', '𝟜', '𝟝', '𝟞', '𝟟', '𝟠', '𝟡', + ], + }, + { + key: 'math', + label: '数学', + children: [ + '+', '-', '×', '÷', '=', '~', '¬', '±', '%', '°', 'ǃ', '‰', '‱', '½', '⅓', '⅔', '¼', '¾', + '<', '>', 'l', 'o', 'g', 'l', 'g', 'l', 'n', '⨂', '⨁', '⨄', '⨃', '⨅', '⨆', '√', '∛', '∜', '∝', '∞', + '∟', '∠', '∡', '∢', '∧', '∨', '∩', '∪', '∫', '∬', '∭', '∮', '∯', '∰', '∱', '∲', '∳', + '∴', '∵', '∼', '∽', '∾', '∿', '≃', '≄', '≅', '≆', '≇', '≈', '≊', '≋', '≌', '≍', '≎', '≏', '≐', '≑', '≒', '≓', '≔', '≕', + '≤', '≥', '≦', '≧', '≨', '≩', '≪', '≫', '≺', '≻', '≼', '≽', '≾', '≿', '⊀', '⊁', '⊂', '⊃', '⊄', '⊅', '⊆', '⊇', '⊈', '⊉', '⊊', '⊋', '⊏', '⊐', '⊑', '⊒', + '⊓', '⊔', '⊢', '⊣', '⊤', '⊥', '⊦', '⊧', '⊨', '⊩', '⊪', '⊫', '⊬', '⊭', '⊮', '⊯', '⊲', '⊳', '⊴', '⊵', '⋀', '⋁', '⋂', '⋃', '⋉', '⋊', + '⋋', '⋌', '⟨', '⟩', '⟪', '⟫', '⟮', '⟯', '⧼', '⧽', '⦰', + ], + }, + { + key: 'arrow', + label: '箭头', + children: [ + '←', '↑', '→', '↓', '↔', '↕', '↖', '↗', '↘', '↙', '↚', '↛', '↜', '↝', '↞', '↟', '↠', '↡', '↢', '↣', '↤', '↥', '↦', '↧', '↨', + '↫', '↬', '↭', '↮', '↯', '↰', '↱', '↲', '↳', '↴', '↵', '↶', '↷', '↸', '↹', '↺', '↻', '↼', '↽', '↾', '↿', '⇀', '⇁', '⇂', '⇃', + '⇄', '⇅', '⇆', '⇇', '⇈', '⇉', '⇊', '⇋', '⇌', '⇍', '⇎', '⇏', '⇐', '⇑', '⇒', '⇓', '⇔', '⇕', '⇖', '⇗', '⇘', '⇙', '⇚', '⇛', + '⇜', '⇝', '⇞', '⇟', '⇠', '⇡', '⇢', '⇣', '⇤', '⇥', '⇦', '⇧', '⇨', '⇩', '⇪', '⇫', '⇬', '⇭', '⇮', '⇯', '⇰', '⇱', '⇲', '⇳', '⇴', '⇵', + '⇶', '⇷', '⇸', '⇹', '⇺', '⇻', '⇼', '⇽', '⇾', '⇿', + ], + }, + { + key: 'graph', + label: '图形', + children: [ + '▢', '▣', '▤', '▥', '▦', '▧', '▨', '▩', '▭', '▮', '▯', '▰', '▱', '▲', '▷', '▼', '◁', + '◈', '◉', '◍', '◐', '◑', '◒', '◓', '◔', '◕', '◧', '◨', '◩', '◪', '◫', '◬', '◭', '◮', + ], + }, +] \ No newline at end of file diff --git a/frontend/src/configs/theme.ts b/frontend/src/configs/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e34015ab730026396e08119e45fbd478e1f1719 --- /dev/null +++ b/frontend/src/configs/theme.ts @@ -0,0 +1,122 @@ +export interface PresetTheme { + background: string + fontColor: string + borderColor: string + fontname: string + colors: string[] +} + +export const PRESET_THEMES: PresetTheme[] = [ + { + background: '#ffffff', + fontColor: '#333333', + borderColor: '#41719c', + fontname: '', + colors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4', '#70ad47'], + }, + { + background: '#ffffff', + fontColor: '#333333', + borderColor: '#5f6f1c', + fontname: '', + colors: ['#83992a', '#3c9670', '#44709d', '#a23b32', '#d87728', '#deb340'], + }, + { + background: '#ffffff', + fontColor: '#333333', + borderColor: '#a75f0a', + fontname: '', + colors: ['#e48312', '#bd582c', '#865640', '#9b8357', '#c2bc80', '#94a088'], + }, + { + background: '#ffffff', + fontColor: '#333333', + borderColor: '#7c91a8', + fontname: '', + colors: ['#bdc8df', '#003fa9', '#f5ba00', '#ff7567', '#7676d9', '#923ffc'], + }, + { + background: '#ffffff', + fontColor: '#333333', + borderColor: '#688e19', + fontname: '', + colors: ['#90c225', '#54a121', '#e6b91e', '#e86618', '#c42f19', '#918756'], + }, + { + background: '#ffffff', + fontColor: '#333333', + borderColor: '#4495b0', + fontname: '', + colors: ['#1cade4', '#2683c6', '#27ced7', '#42ba97', '#3e8853', '#62a39f'], + }, + { + background: '#e9efd6', + fontColor: '#333333', + borderColor: '#782009', + fontname: '', + colors: ['#a5300f', '#de7e18', '#9f8351', '#728653', '#92aa4c', '#6aac91'], + }, + { + background: '#17444e', + fontColor: '#ffffff', + borderColor: '#800c0b', + fontname: '', + colors: ['#b01513', '#ea6312', '#e6b729', '#6bab90', '#55839a', '#9e5d9d'], + }, + { + background: '#36234d', + fontColor: '#ffffff', + borderColor: '#830949', + fontname: '', + colors: ['#b31166', '#e33d6f', '#e45f3c', '#e9943a', '#9b6bf2', '#d63cd0'], + }, + { + background: '#247fad', + fontColor: '#ffffff', + borderColor: '#032e45', + fontname: '', + colors: ['#052f61', '#a50e82', '#14967c', '#6a9e1f', '#e87d37', '#c62324'], + }, + { + background: '#103f55', + fontColor: '#ffffff', + borderColor: '#2d7f8a', + fontname: '', + colors: ['#40aebd', '#97e8d5', '#a1cf49', '#628f3e', '#f2df3a', '#fcb01c'], + }, + { + background: '#242367', + fontColor: '#ffffff', + borderColor: '#7d2b8d', + fontname: '', + colors: ['#ac3ec1', '#477bd1', '#46b298', '#90ba4c', '#dd9d31', '#e25345'], + }, + { + background: '#e4b75e', + fontColor: '#333333', + borderColor: '#b68317', + fontname: '', + colors: ['#a5644e', '#b58b80', '#c3986d', '#a19574', '#c17529', '#826277'], + }, + { + background: '#333333', + fontColor: '#ffffff', + borderColor: '#7c91a8', + fontname: '', + colors: ['#bdc8df', '#003fa9', '#f5ba00', '#ff7567', '#7676d9', '#923ffc'], + }, + { + background: '#2b2b2d', + fontColor: '#ffffff', + borderColor: '#893011', + fontname: '', + colors: ['#bc451b', '#d3ba68', '#bb8640', '#ad9277', '#a55a43', '#ad9d7b'], + }, + { + background: '#171b1e', + fontColor: '#ffffff', + borderColor: '#505050', + fontname: '', + colors: ['#6f6f6f', '#bfbfa5', '#dbd084', '#e7bf5f', '#e9a039', '#cf7133'], + }, +] \ No newline at end of file diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..98265c07021a3510e7105b987950409c7605de7c --- /dev/null +++ b/frontend/src/global.d.ts @@ -0,0 +1,16 @@ +interface HTMLElement { + webkitRequestFullScreen(options?: FullscreenOptions): Promise + mozRequestFullScreen(options?: FullscreenOptions): Promise + msRequestFullscreen(options?: FullscreenOptions): Promise +} + +interface Document { + webkitFullscreenElement: Element | null + mozFullScreenElement: Element | null + msFullscreenElement: Element | null + webkitCurrentFullScreenElement: Element | null + + mozCancelFullScreen(): Promise + webkitExitFullscreen(): Promise + msExitFullscreen(): Promise +} \ No newline at end of file diff --git a/frontend/src/hooks/useAIPPT.ts b/frontend/src/hooks/useAIPPT.ts new file mode 100644 index 0000000000000000000000000000000000000000..71aafff554b5bc0d6ccad066d0715030c98c65fd --- /dev/null +++ b/frontend/src/hooks/useAIPPT.ts @@ -0,0 +1,524 @@ +import { ref } from 'vue' +import { nanoid } from 'nanoid' +import type { ImageClipDataRange, PPTElement, PPTImageElement, PPTShapeElement, PPTTextElement, Slide, TextType } from '@/types/slides' +import type { AIPPTSlide } from '@/types/AIPPT' +import { useSlidesStore } from '@/store' +import useAddSlidesOrElements from './useAddSlidesOrElements' +import useSlideHandler from './useSlideHandler' + +interface ImgPoolItem { + id: string + src: string + width: number + height: number +} + +export default () => { + const slidesStore = useSlidesStore() + const { addSlidesFromData } = useAddSlidesOrElements() + const { isEmptySlide } = useSlideHandler() + + const imgPool = ref([]) + const transitionIndex = ref(0) + const transitionTemplate = ref(null) + + const checkTextType = (el: PPTElement, type: TextType) => { + return (el.type === 'text' && el.textType === type) || (el.type === 'shape' && el.text && el.text.type === type) + } + + const getUseableTemplates = (templates: Slide[], n: number, type: TextType) => { + if (n === 1) { + const list = templates.filter(slide => { + const items = slide.elements.filter(el => checkTextType(el, type)) + const titles = slide.elements.filter(el => checkTextType(el, 'title')) + const texts = slide.elements.filter(el => checkTextType(el, 'content')) + + return !items.length && titles.length === 1 && texts.length === 1 + }) + + if (list.length) return list + } + + let target: Slide | null = null + + const list = templates.filter(slide => { + const len = slide.elements.filter(el => checkTextType(el, type)).length + return len >= n + }) + if (list.length === 0) { + const sorted = templates.sort((a, b) => { + const aLen = a.elements.filter(el => checkTextType(el, type)).length + const bLen = b.elements.filter(el => checkTextType(el, type)).length + return aLen - bLen + }) + target = sorted[sorted.length - 1] + } + else { + target = list.reduce((closest, current) => { + const currentLen = current.elements.filter(el => checkTextType(el, type)).length + const closestLen = closest.elements.filter(el => checkTextType(el, type)).length + return (currentLen - n) <= (closestLen - n) ? current : closest + }) + } + + return templates.filter(slide => { + const len = slide.elements.filter(el => checkTextType(el, type)).length + const targetLen = target!.elements.filter(el => checkTextType(el, type)).length + return len === targetLen + }) + } + + const getAdaptedFontsize = ({ + text, + fontSize, + fontFamily, + width, + maxLine, + }: { + text: string + fontSize: number + fontFamily: string + width: number + maxLine: number + }) => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d')! + + let newFontSize = fontSize + const minFontSize = 10 + + while (newFontSize >= minFontSize) { + context.font = `${newFontSize}px ${fontFamily}` + const textWidth = context.measureText(text).width + const line = Math.ceil(textWidth / width) + + if (line <= maxLine) return newFontSize + + const step = newFontSize <= 22 ? 1 : 2 + newFontSize = newFontSize - step + } + + return minFontSize + } + + const getFontInfo = (htmlString: string) => { + const fontSizeRegex = /font-size:\s*(\d+(?:\.\d+)?)\s*px/i + const fontFamilyRegex = /font-family:\s*['"]?([^'";]+)['"]?\s*(?=;|>|$)/i + + const defaultInfo = { + fontSize: 16, + fontFamily: 'Microsoft Yahei', + } + + const fontSizeMatch = htmlString.match(fontSizeRegex) + const fontFamilyMatch = htmlString.match(fontFamilyRegex) + + return { + fontSize: fontSizeMatch ? (+fontSizeMatch[1].trim()) : defaultInfo.fontSize, + fontFamily: fontFamilyMatch ? fontFamilyMatch[1].trim() : defaultInfo.fontFamily, + } + } + + const getNewTextElement = ({ + el, + text, + maxLine, + longestText, + digitPadding, + }: { + el: PPTTextElement | PPTShapeElement + text: string + maxLine: number + longestText?: string + digitPadding?: boolean + }): PPTTextElement | PPTShapeElement => { + const padding = 10 + const width = el.width - padding * 2 - 2 + + let content = el.type === 'text' ? el.content : el.text!.content + + const fontInfo = getFontInfo(content) + const size = getAdaptedFontsize({ + text: longestText || text, + fontSize: fontInfo.fontSize, + fontFamily: fontInfo.fontFamily, + width, + maxLine, + }) + + const parser = new DOMParser() + const doc = parser.parseFromString(content, 'text/html') + + const treeWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT) + + const firstTextNode = treeWalker.nextNode() + if (firstTextNode) { + if (digitPadding && firstTextNode.textContent && firstTextNode.textContent.length === 2 && text.length === 1) { + firstTextNode.textContent = '0' + text + } + else firstTextNode.textContent = text + } + + if (doc.body.innerHTML.indexOf('font-size') === -1) { + const p = doc.querySelector('p') + if (p) p.style.fontSize = '16px' + } + + content = doc.body.innerHTML.replace(/font-size:(.+?)px/g, `font-size: ${size}px`) + + return el.type === 'text' ? { ...el, content, lineHeight: size < 15 ? 1.2 : el.lineHeight } : { ...el, text: { ...el.text!, content } } + } + + const getUseableImage = (el: PPTImageElement): ImgPoolItem | null => { + let img: ImgPoolItem | null = null + + let imgs = [] + + if (el.width === el.height) imgs = imgPool.value.filter(img => img.width === img.height) + else if (el.width > el.height) imgs = imgPool.value.filter(img => img.width > img.height) + else imgs = imgPool.value.filter(img => img.width <= img.height) + if (!imgs.length) imgs = imgPool.value + + img = imgs[Math.floor(Math.random() * imgs.length)] + imgPool.value = imgPool.value.filter(item => item.id !== img!.id) + + return img + } + + const getNewImgElement = (el: PPTImageElement): PPTImageElement => { + const img = getUseableImage(el) + if (!img) return el + + let scale = 1 + let w = el.width + let h = el.height + let range: ImageClipDataRange = [[0, 0], [0, 0]] + const radio = el.width / el.height + if (img.width / img.height >= radio) { + scale = img.height / el.height + w = img.width / scale + const diff = (w - el.width) / 2 / w * 100 + range = [[diff, 0], [100 - diff, 100]] + } + else { + scale = img.width / el.width + h = img.height / scale + const diff = (h - el.height) / 2 / h * 100 + range = [[0, diff], [100, 100 - diff]] + } + const clipShape = (el.clip && el.clip.shape) ? el.clip.shape : 'rect' + const clip = { range, shape: clipShape } + const src = img.src + + return { ...el, src, clip } + } + + const getMdContent = (content: string) => { + const regex = /```markdown([^```]*)```/ + const match = content.match(regex) + if (match) return match[1].trim() + return content.replace('```markdown', '').replace('```', '') + } + + const getJSONContent = (content: string) => { + const regex = /```json([^```]*)```/ + const match = content.match(regex) + if (match) return match[1].trim() + return content.replace('```json', '').replace('```', '') + } + + const AIPPT = (templateSlides: Slide[], _AISlides: AIPPTSlide[], imgs?: ImgPoolItem[]) => { + slidesStore.updateSlideIndex(slidesStore.slides.length - 1) + + if (imgs) imgPool.value = imgs + + const AISlides: AIPPTSlide[] = [] + for (const template of _AISlides) { + if (template.type === 'content') { + const items = template.data.items + if (items.length === 5 || items.length === 6) { + const items1 = items.slice(0, 3) + const items2 = items.slice(3) + AISlides.push({ ...template, data: { ...template.data, items: items1 } }) + AISlides.push({ ...template, data: { ...template.data, items: items2 }, offset: 3 }) + } + else if (items.length === 7 || items.length === 8) { + const items1 = items.slice(0, 4) + const items2 = items.slice(4) + AISlides.push({ ...template, data: { ...template.data, items: items1 } }) + AISlides.push({ ...template, data: { ...template.data, items: items2 }, offset: 4 }) + } + else if (items.length === 9 || items.length === 10) { + const items1 = items.slice(0, 3) + const items2 = items.slice(3, 6) + const items3 = items.slice(6) + AISlides.push({ ...template, data: { ...template.data, items: items1 } }) + AISlides.push({ ...template, data: { ...template.data, items: items2 }, offset: 3 }) + AISlides.push({ ...template, data: { ...template.data, items: items3 }, offset: 6 }) + } + else if (items.length > 10) { + const items1 = items.slice(0, 4) + const items2 = items.slice(4, 8) + const items3 = items.slice(8) + AISlides.push({ ...template, data: { ...template.data, items: items1 } }) + AISlides.push({ ...template, data: { ...template.data, items: items2 }, offset: 4 }) + AISlides.push({ ...template, data: { ...template.data, items: items3 }, offset: 8 }) + } + else { + AISlides.push(template) + } + } + else if (template.type === 'contents') { + const items = template.data.items + if (items.length === 11) { + const items1 = items.slice(0, 6) + const items2 = items.slice(6) + AISlides.push({ ...template, data: { ...template.data, items: items1 } }) + AISlides.push({ ...template, data: { ...template.data, items: items2 }, offset: 6 }) + } + else if (items.length > 11) { + const items1 = items.slice(0, 10) + const items2 = items.slice(10) + AISlides.push({ ...template, data: { ...template.data, items: items1 } }) + AISlides.push({ ...template, data: { ...template.data, items: items2 }, offset: 10 }) + } + else { + AISlides.push(template) + } + } + else AISlides.push(template) + } + + const coverTemplates = templateSlides.filter(slide => slide.type === 'cover') + const contentsTemplates = templateSlides.filter(slide => slide.type === 'contents') + const transitionTemplates = templateSlides.filter(slide => slide.type === 'transition') + const contentTemplates = templateSlides.filter(slide => slide.type === 'content') + const endTemplates = templateSlides.filter(slide => slide.type === 'end') + + if (!transitionTemplate.value) { + const _transitionTemplate = transitionTemplates[Math.floor(Math.random() * transitionTemplates.length)] + transitionTemplate.value = _transitionTemplate + } + + const slides = [] + + for (const item of AISlides) { + if (item.type === 'cover') { + const coverTemplate = coverTemplates[Math.floor(Math.random() * coverTemplates.length)] + const elements = coverTemplate.elements.map(el => { + if (el.type === 'image' && el.imageType && imgPool.value.length) return getNewImgElement(el) + if (el.type !== 'text' && el.type !== 'shape') return el + if (checkTextType(el, 'title') && item.data.title) { + return getNewTextElement({ el, text: item.data.title, maxLine: 1 }) + } + if (checkTextType(el, 'content') && item.data.text) { + return getNewTextElement({ el, text: item.data.text, maxLine: 3 }) + } + return el + }) + slides.push({ + ...coverTemplate, + id: nanoid(10), + elements, + }) + } + else if (item.type === 'contents') { + const _contentsTemplates = getUseableTemplates(contentsTemplates, item.data.items.length, 'item') + const contentsTemplate = _contentsTemplates[Math.floor(Math.random() * _contentsTemplates.length)] + + const sortedNumberItems = contentsTemplate.elements.filter(el => checkTextType(el, 'itemNumber')) + const sortedNumberItemIds = sortedNumberItems.sort((a, b) => { + if (sortedNumberItems.length > 6) { + let aContent = '' + let bContent = '' + if (a.type === 'text') aContent = a.content + if (a.type === 'shape') aContent = a.text!.content + if (b.type === 'text') bContent = b.content + if (b.type === 'shape') bContent = b.text!.content + + if (aContent && bContent) { + const aIndex = parseInt(aContent) + const bIndex = parseInt(bContent) + + return aIndex - bIndex + } + } + const aIndex = a.left + a.top * 2 + const bIndex = b.left + b.top * 2 + return aIndex - bIndex + }).map(el => el.id) + + const sortedItems = contentsTemplate.elements.filter(el => checkTextType(el, 'item')) + const sortedItemIds = sortedItems.sort((a, b) => { + if (sortedItems.length > 6) { + const aItemNumber = sortedNumberItems.find(item => item.groupId === a.groupId) + const bItemNumber = sortedNumberItems.find(item => item.groupId === b.groupId) + + if (aItemNumber && bItemNumber) { + let aContent = '' + let bContent = '' + if (aItemNumber.type === 'text') aContent = aItemNumber.content + if (aItemNumber.type === 'shape') aContent = aItemNumber.text!.content + if (bItemNumber.type === 'text') bContent = bItemNumber.content + if (bItemNumber.type === 'shape') bContent = bItemNumber.text!.content + + if (aContent && bContent) { + const aIndex = parseInt(aContent) + const bIndex = parseInt(bContent) + + return aIndex - bIndex + } + } + } + + const aIndex = a.left + a.top * 2 + const bIndex = b.left + b.top * 2 + return aIndex - bIndex + }).map(el => el.id) + + const longestText = item.data.items.reduce((longest, current) => current.length > longest.length ? current : longest, '') + + const unusedElIds: string[] = [] + const unusedGroupIds: string[] = [] + const elements = contentsTemplate.elements.map(el => { + if (el.type === 'image' && el.imageType && imgPool.value.length) return getNewImgElement(el) + if (el.type !== 'text' && el.type !== 'shape') return el + if (checkTextType(el, 'item')) { + const index = sortedItemIds.findIndex(id => id === el.id) + const itemTitle = item.data.items[index] + if (itemTitle) return getNewTextElement({ el, text: itemTitle, maxLine: 1, longestText }) + + unusedElIds.push(el.id) + if (el.groupId) unusedGroupIds.push(el.groupId) + } + if (checkTextType(el, 'itemNumber')) { + const index = sortedNumberItemIds.findIndex(id => id === el.id) + const offset = item.offset || 0 + return getNewTextElement({ el, text: index + offset + 1 + '', maxLine: 1, digitPadding: true }) + } + return el + }).filter(el => !unusedElIds.includes(el.id) && !(el.groupId && unusedGroupIds.includes(el.groupId))) + slides.push({ + ...contentsTemplate, + id: nanoid(10), + elements, + }) + } + else if (item.type === 'transition') { + transitionIndex.value = transitionIndex.value + 1 + const elements = transitionTemplate.value.elements.map(el => { + if (el.type === 'image' && el.imageType && imgPool.value.length) return getNewImgElement(el) + if (el.type !== 'text' && el.type !== 'shape') return el + if (checkTextType(el, 'title') && item.data.title) { + return getNewTextElement({ el, text: item.data.title, maxLine: 1 }) + } + if (checkTextType(el, 'content') && item.data.text) { + return getNewTextElement({ el, text: item.data.text, maxLine: 3 }) + } + if (checkTextType(el, 'partNumber')) { + return getNewTextElement({ el, text: transitionIndex.value + '', maxLine: 1, digitPadding: true }) + } + return el + }) + slides.push({ + ...transitionTemplate.value, + id: nanoid(10), + elements, + }) + } + else if (item.type === 'content') { + const _contentTemplates = getUseableTemplates(contentTemplates, item.data.items.length, 'item') + const contentTemplate = _contentTemplates[Math.floor(Math.random() * _contentTemplates.length)] + + const sortedTitleItemIds = contentTemplate.elements.filter(el => checkTextType(el, 'itemTitle')).sort((a, b) => { + const aIndex = a.left + a.top * 2 + const bIndex = b.left + b.top * 2 + return aIndex - bIndex + }).map(el => el.id) + + const sortedTextItemIds = contentTemplate.elements.filter(el => checkTextType(el, 'item')).sort((a, b) => { + const aIndex = a.left + a.top * 2 + const bIndex = b.left + b.top * 2 + return aIndex - bIndex + }).map(el => el.id) + + const sortedNumberItemIds = contentTemplate.elements.filter(el => checkTextType(el, 'itemNumber')).sort((a, b) => { + const aIndex = a.left + a.top * 2 + const bIndex = b.left + b.top * 2 + return aIndex - bIndex + }).map(el => el.id) + + const itemTitles = [] + const itemTexts = [] + + for (const _item of item.data.items) { + if (_item.title) itemTitles.push(_item.title) + if (_item.text) itemTexts.push(_item.text) + } + const longestTitle = itemTitles.reduce((longest, current) => current.length > longest.length ? current : longest, '') + const longestText = itemTexts.reduce((longest, current) => current.length > longest.length ? current : longest, '') + + const elements = contentTemplate.elements.map(el => { + if (el.type === 'image' && el.imageType && imgPool.value.length) return getNewImgElement(el) + if (el.type !== 'text' && el.type !== 'shape') return el + if (item.data.items.length === 1) { + const contentItem = item.data.items[0] + if (checkTextType(el, 'content') && contentItem.text) { + return getNewTextElement({ el, text: contentItem.text, maxLine: 6 }) + } + } + else { + if (checkTextType(el, 'itemTitle')) { + const index = sortedTitleItemIds.findIndex(id => id === el.id) + const contentItem = item.data.items[index] + if (contentItem && contentItem.title) { + return getNewTextElement({ el, text: contentItem.title, longestText: longestTitle, maxLine: 1 }) + } + } + if (checkTextType(el, 'item')) { + const index = sortedTextItemIds.findIndex(id => id === el.id) + const contentItem = item.data.items[index] + if (contentItem && contentItem.text) { + return getNewTextElement({ el, text: contentItem.text, longestText, maxLine: 4 }) + } + } + if (checkTextType(el, 'itemNumber')) { + const index = sortedNumberItemIds.findIndex(id => id === el.id) + const offset = item.offset || 0 + return getNewTextElement({ el, text: index + offset + 1 + '', maxLine: 1, digitPadding: true }) + } + } + if (checkTextType(el, 'title') && item.data.title) { + return getNewTextElement({ el, text: item.data.title, maxLine: 1 }) + } + return el + }) + slides.push({ + ...contentTemplate, + id: nanoid(10), + elements, + }) + } + else if (item.type === 'end') { + const endTemplate = endTemplates[Math.floor(Math.random() * endTemplates.length)] + const elements = endTemplate.elements.map(el => { + if (el.type === 'image' && el.imageType && imgPool.value.length) return getNewImgElement(el) + return el + }) + slides.push({ + ...endTemplate, + id: nanoid(10), + elements, + }) + } + } + if (isEmptySlide.value) slidesStore.setSlides(slides) + else addSlidesFromData(slides) + } + + return { + AIPPT, + getMdContent, + getJSONContent, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useAddSlidesOrElements.ts b/frontend/src/hooks/useAddSlidesOrElements.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdf9a831ab5e3e489ff4c70e00d36a6861200296 --- /dev/null +++ b/frontend/src/hooks/useAddSlidesOrElements.ts @@ -0,0 +1,106 @@ +import { storeToRefs } from 'pinia' +import { nanoid } from 'nanoid' +import { useSlidesStore, useMainStore } from '@/store' +import type { PPTElement, Slide } from '@/types/slides' +import { createSlideIdMap, createElementIdMap, getElementRange } from '@/utils/element' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + /** + * 添加指定的元素数据(一组) + * @param elements 元素列表数据 + */ + const addElementsFromData = (elements: PPTElement[]) => { + const { groupIdMap, elIdMap } = createElementIdMap(elements) + + const firstElement = elements[0] + let offset = 0 + let lastSameElement: PPTElement | undefined + + do { + lastSameElement = currentSlide.value.elements.find(el => { + if (el.type !== firstElement.type) return false + + const { minX: oMinX, maxX: oMaxX, minY: oMinY, maxY: oMaxY } = getElementRange(el) + const { minX: nMinX, maxX: nMaxX, minY: nMinY, maxY: nMaxY } = getElementRange({ + ...firstElement, + left: firstElement.left + offset, + top: firstElement.top + offset + }) + if ( + oMinX === nMinX && + oMaxX === nMaxX && + oMinY === nMinY && + oMaxY === nMaxY + ) return true + + return false + }) + if (lastSameElement) offset += 10 + + } while (lastSameElement) + + for (const element of elements) { + element.id = elIdMap[element.id] + + element.left = element.left + offset + element.top = element.top + offset + + if (element.groupId) element.groupId = groupIdMap[element.groupId] + } + slidesStore.addElement(elements) + mainStore.setActiveElementIdList(Object.values(elIdMap)) + addHistorySnapshot() + } + + /** + * 添加指定的页面数据 + * @param slide 页面数据 + */ + const addSlidesFromData = (slides: Slide[]) => { + const slideIdMap = createSlideIdMap(slides) + const newSlides = slides.map(slide => { + const { groupIdMap, elIdMap } = createElementIdMap(slide.elements) + + for (const element of slide.elements) { + element.id = elIdMap[element.id] + if (element.groupId) element.groupId = groupIdMap[element.groupId] + + // 若元素绑定了页面跳转链接 + if (element.link && element.link.type === 'slide') { + + // 待添加页面中包含该页面,则替换相关绑定关系 + if (slideIdMap[element.link.target]) { + element.link.target = slideIdMap[element.link.target] + } + // 待添加页面中不包含该页面,则删除该元素绑定的页面跳转 + else delete element.link + } + } + // 动画id替换 + if (slide.animations) { + for (const animation of slide.animations) { + animation.id = nanoid(10) + animation.elId = elIdMap[animation.elId] + } + } + return { + ...slide, + id: slideIdMap[slide.id], + } + }) + slidesStore.addSlide(newSlides) + addHistorySnapshot() + } + + return { + addElementsFromData, + addSlidesFromData, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useAlignActiveElement.ts b/frontend/src/hooks/useAlignActiveElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..929a27ba17d27aa08e99bc22264b358cefdc6708 --- /dev/null +++ b/frontend/src/hooks/useAlignActiveElement.ts @@ -0,0 +1,177 @@ +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import { ElementAlignCommands } from '@/types/edit' +import { getElementListRange, getRectRotatedOffset } from '@/utils/element' +import useHistorySnapshot from './useHistorySnapshot' + +interface RangeMap { + [id: string]: ReturnType +} + +export default () => { + const slidesStore = useSlidesStore() + const { activeElementIdList, activeElementList } = storeToRefs(useMainStore()) + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + /** + * 对齐选中的元素 + * @param command 对齐方向 + */ + const alignActiveElement = (command: ElementAlignCommands) => { + const { minX, maxX, minY, maxY } = getElementListRange(activeElementList.value) + const elementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + + // 如果所选择的元素为组合元素的成员,需要计算该组合的整体范围 + const groupElementRangeMap: RangeMap = {} + for (const activeElement of activeElementList.value) { + if (activeElement.groupId && !groupElementRangeMap[activeElement.groupId]) { + const groupElements = activeElementList.value.filter(item => item.groupId === activeElement.groupId) + groupElementRangeMap[activeElement.groupId] = getElementListRange(groupElements) + } + } + + // 根据不同的命令,计算对齐的位置 + if (command === ElementAlignCommands.LEFT) { + elementList.forEach(element => { + if (activeElementIdList.value.includes(element.id)) { + if (!element.groupId) { + if ('rotate' in element && element.rotate) { + const { offsetX } = getRectRotatedOffset({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + element.left = minX - offsetX + } + else element.left = minX + } + else { + const range = groupElementRangeMap[element.groupId] + const offset = range.minX - minX + element.left = element.left - offset + } + } + }) + } + else if (command === ElementAlignCommands.RIGHT) { + elementList.forEach(element => { + if (activeElementIdList.value.includes(element.id)) { + if (!element.groupId) { + const elWidth = element.type === 'line' ? Math.max(element.start[0], element.end[0]) : element.width + if ('rotate' in element && element.rotate) { + const { offsetX } = getRectRotatedOffset({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + element.left = maxX - elWidth + offsetX + } + else element.left = maxX - elWidth + } + else { + const range = groupElementRangeMap[element.groupId] + const offset = range.maxX - maxX + element.left = element.left - offset + } + } + }) + } + else if (command === ElementAlignCommands.TOP) { + elementList.forEach(element => { + if (activeElementIdList.value.includes(element.id)) { + if (!element.groupId) { + if ('rotate' in element && element.rotate) { + const { offsetY } = getRectRotatedOffset({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + element.top = minY - offsetY + } + else element.top = minY + } + else { + const range = groupElementRangeMap[element.groupId] + const offset = range.minY - minY + element.top = element.top - offset + } + } + }) + } + else if (command === ElementAlignCommands.BOTTOM) { + elementList.forEach(element => { + if (activeElementIdList.value.includes(element.id)) { + if (!element.groupId) { + const elHeight = element.type === 'line' ? Math.max(element.start[1], element.end[1]) : element.height + if ('rotate' in element && element.rotate) { + const { offsetY } = getRectRotatedOffset({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + element.top = maxY - elHeight + offsetY + } + else element.top = maxY - elHeight + } + else { + const range = groupElementRangeMap[element.groupId] + const offset = range.maxY - maxY + element.top = element.top - offset + } + } + }) + } + else if (command === ElementAlignCommands.HORIZONTAL) { + const horizontalCenter = (minX + maxX) / 2 + elementList.forEach(element => { + if (activeElementIdList.value.includes(element.id)) { + if (!element.groupId) { + const elWidth = element.type === 'line' ? Math.max(element.start[0], element.end[0]) : element.width + element.left = horizontalCenter - elWidth / 2 + } + else { + const range = groupElementRangeMap[element.groupId] + const center = (range.maxX + range.minX) / 2 + const offset = center - horizontalCenter + element.left = element.left - offset + } + } + }) + } + else if (command === ElementAlignCommands.VERTICAL) { + const verticalCenter = (minY + maxY) / 2 + elementList.forEach(element => { + if (activeElementIdList.value.includes(element.id)) { + if (!element.groupId) { + const elHeight = element.type === 'line' ? Math.max(element.start[1], element.end[1]) : element.height + element.top = verticalCenter - elHeight / 2 + } + else { + const range = groupElementRangeMap[element.groupId] + const center = (range.maxY + range.minY) / 2 + const offset = center - verticalCenter + element.top = element.top - offset + } + } + }) + } + + slidesStore.updateSlide({ elements: elementList }) + addHistorySnapshot() + } + + return { + alignActiveElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useAlignElementToCanvas.ts b/frontend/src/hooks/useAlignElementToCanvas.ts new file mode 100644 index 0000000000000000000000000000000000000000..d173545995a323ed5c3a3b4f3c62402dd153e4d3 --- /dev/null +++ b/frontend/src/hooks/useAlignElementToCanvas.ts @@ -0,0 +1,80 @@ +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import { ElementAlignCommands } from '@/types/edit' +import { getElementListRange } from '@/utils/element' +import useHistorySnapshot from './useHistorySnapshot' + +export default () => { + const slidesStore = useSlidesStore() + const { activeElementIdList, activeElementList } = storeToRefs(useMainStore()) + const { currentSlide, viewportRatio, viewportSize } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + /** + * 将所有选中的元素对齐到画布 + * @param command 对齐方向 + */ + const alignElementToCanvas = (command: ElementAlignCommands) => { + const viewportWidth = viewportSize.value + const viewportHeight = viewportSize.value * viewportRatio.value + const { minX, maxX, minY, maxY } = getElementListRange(activeElementList.value) + + const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + for (const element of newElementList) { + if (!activeElementIdList.value.includes(element.id)) continue + + // 水平垂直居中 + if (command === ElementAlignCommands.CENTER) { + const offsetY = minY + (maxY - minY) / 2 - viewportHeight / 2 + const offsetX = minX + (maxX - minX) / 2 - viewportWidth / 2 + element.top = element.top - offsetY + element.left = element.left - offsetX + } + + // 顶部对齐 + if (command === ElementAlignCommands.TOP) { + const offsetY = minY - 0 + element.top = element.top - offsetY + } + + // 垂直居中 + else if (command === ElementAlignCommands.VERTICAL) { + const offsetY = minY + (maxY - minY) / 2 - viewportHeight / 2 + element.top = element.top - offsetY + } + + // 底部对齐 + else if (command === ElementAlignCommands.BOTTOM) { + const offsetY = maxY - viewportHeight + element.top = element.top - offsetY + } + + // 左侧对齐 + else if (command === ElementAlignCommands.LEFT) { + const offsetX = minX - 0 + element.left = element.left - offsetX + } + + // 水平居中 + else if (command === ElementAlignCommands.HORIZONTAL) { + const offsetX = minX + (maxX - minX) / 2 - viewportWidth / 2 + element.left = element.left - offsetX + } + + // 右侧对齐 + else if (command === ElementAlignCommands.RIGHT) { + const offsetX = maxX - viewportWidth + element.left = element.left - offsetX + } + } + + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + return { + alignElementToCanvas, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useCombineElement.ts b/frontend/src/hooks/useCombineElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..146b0540045053e2e18990e51d18d90c75e8978e --- /dev/null +++ b/frontend/src/hooks/useCombineElement.ts @@ -0,0 +1,91 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { nanoid } from 'nanoid' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { activeElementIdList, activeElementList, handleElementId } = storeToRefs(mainStore) + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + /** + * 判断当前选中的元素是否可以组合 + */ + const canCombine = computed(() => { + if (activeElementList.value.length < 2) return false + + const firstGroupId = activeElementList.value[0].groupId + if (!firstGroupId) return true + + const inSameGroup = activeElementList.value.every(el => (el.groupId && el.groupId) === firstGroupId) + return !inSameGroup + }) + + /** + * 组合当前选中的元素:给当前选中的元素赋予一个相同的分组ID + */ + const combineElements = () => { + if (!activeElementList.value.length) return + + // 生成一个新元素列表进行后续操作 + let newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + + // 生成分组ID + const groupId = nanoid(10) + + // 收集需要组合的元素列表,并赋上唯一分组ID + const combineElementList: PPTElement[] = [] + for (const element of newElementList) { + if (activeElementIdList.value.includes(element.id)) { + element.groupId = groupId + combineElementList.push(element) + } + } + + // 确保该组合内所有元素成员的层级是连续的,具体操作方法为: + // 先获取到该组合内最上层元素的层级,将本次需要组合的元素从新元素列表中移除, + // 再根据最上层元素的层级位置,将上面收集到的需要组合的元素列表一起插入到新元素列表中合适的位置 + const combineElementMaxLevel = newElementList.findIndex(_element => _element.id === combineElementList[combineElementList.length - 1].id) + const combineElementIdList = combineElementList.map(_element => _element.id) + newElementList = newElementList.filter(_element => !combineElementIdList.includes(_element.id)) + + const insertLevel = combineElementMaxLevel - combineElementList.length + 1 + newElementList.splice(insertLevel, 0, ...combineElementList) + + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + /** + * 取消组合元素:移除选中元素的分组ID + */ + const uncombineElements = () => { + if (!activeElementList.value.length) return + const hasElementInGroup = activeElementList.value.some(item => item.groupId) + if (!hasElementInGroup) return + + const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + for (const element of newElementList) { + if (activeElementIdList.value.includes(element.id) && element.groupId) delete element.groupId + } + slidesStore.updateSlide({ elements: newElementList }) + + // 取消组合后,需要重置激活元素状态 + // 默认重置为当前正在操作的元素,如果不存在则重置为空 + const handleElementIdList = handleElementId.value ? [handleElementId.value] : [] + mainStore.setActiveElementIdList(handleElementIdList) + + addHistorySnapshot() + } + + return { + canCombine, + combineElements, + uncombineElements, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useCopyAndPasteElement.ts b/frontend/src/hooks/useCopyAndPasteElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc9674ee1b3555ae3e9305edc625a6dfe8e378b7 --- /dev/null +++ b/frontend/src/hooks/useCopyAndPasteElement.ts @@ -0,0 +1,55 @@ +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' +import { copyText, readClipboard } from '@/utils/clipboard' +import { encrypt } from '@/utils/crypto' +import message from '@/utils/message' +import usePasteTextClipboardData from '@/hooks/usePasteTextClipboardData' +import useDeleteElement from './useDeleteElement' + +export default () => { + const mainStore = useMainStore() + const { activeElementIdList, activeElementList } = storeToRefs(mainStore) + + const { pasteTextClipboardData } = usePasteTextClipboardData() + const { deleteElement } = useDeleteElement() + + // 将选中元素数据加密后复制到剪贴板 + const copyElement = () => { + if (!activeElementIdList.value.length) return + + const text = encrypt(JSON.stringify({ + type: 'elements', + data: activeElementList.value, + })) + + copyText(text).then(() => { + mainStore.setEditorareaFocus(true) + }) + } + + // 将选中元素复制后删除(剪切) + const cutElement = () => { + copyElement() + deleteElement() + } + + // 尝试将剪贴板元素数据解密后进行粘贴 + const pasteElement = () => { + readClipboard().then(text => { + pasteTextClipboardData(text) + }).catch(err => message.warning(err)) + } + + // 将选中元素复制后立刻粘贴 + const quickCopyElement = () => { + copyElement() + pasteElement() + } + + return { + copyElement, + cutElement, + pasteElement, + quickCopyElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useCreateElement.ts b/frontend/src/hooks/useCreateElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fc8fecdfde62e4d66310688b99baf3a6d5e35d9 --- /dev/null +++ b/frontend/src/hooks/useCreateElement.ts @@ -0,0 +1,325 @@ +import { storeToRefs } from 'pinia' +import { nanoid } from 'nanoid' +import { useMainStore, useSlidesStore } from '@/store' +import { getImageSize } from '@/utils/image' +import type { PPTLineElement, PPTElement, TableCell, TableCellStyle, PPTShapeElement, ChartType } from '@/types/slides' +import { type ShapePoolItem, SHAPE_PATH_FORMULAS } from '@/configs/shapes' +import type { LinePoolItem } from '@/configs/lines' +import { CHART_DEFAULT_DATA } from '@/configs/chart' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +interface CommonElementPosition { + top: number + left: number + width: number + height: number +} + +interface LineElementPosition { + top: number + left: number + start: [number, number] + end: [number, number] +} + +interface CreateTextData { + content?: string + vertical?: boolean +} + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { creatingElement } = storeToRefs(mainStore) + const { theme, viewportRatio, viewportSize } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + // 创建(插入)一个元素并将其设置为被选中元素 + const createElement = (element: PPTElement, callback?: () => void) => { + slidesStore.addElement(element) + mainStore.setActiveElementIdList([element.id]) + + if (creatingElement.value) mainStore.setCreatingElement(null) + + setTimeout(() => { + mainStore.setEditorareaFocus(true) + }, 0) + + if (callback) callback() + + addHistorySnapshot() + } + + /** + * 创建图片元素 + * @param src 图片地址 + */ + const createImageElement = (src: string) => { + getImageSize(src).then(({ width, height }) => { + const scale = height / width + + if (scale < viewportRatio.value && width > viewportSize.value) { + width = viewportSize.value + height = width * scale + } + else if (height > viewportSize.value * viewportRatio.value) { + height = viewportSize.value * viewportRatio.value + width = height / scale + } + + createElement({ + type: 'image', + id: nanoid(10), + src, + width, + height, + left: (viewportSize.value - width) / 2, + top: (viewportSize.value * viewportRatio.value - height) / 2, + fixedRatio: true, + rotate: 0, + }) + }) + } + + /** + * 创建图表元素 + * @param chartType 图表类型 + */ + const createChartElement = (type: ChartType) => { + createElement({ + type: 'chart', + id: nanoid(10), + chartType: type, + left: 300, + top: 81.25, + width: 400, + height: 400, + rotate: 0, + themeColors: theme.value.themeColors, + textColor: theme.value.fontColor, + data: CHART_DEFAULT_DATA[type], + }) + } + + /** + * 创建表格元素 + * @param row 行数 + * @param col 列数 + */ + const createTableElement = (row: number, col: number) => { + const style: TableCellStyle = { + fontname: theme.value.fontName, + color: theme.value.fontColor, + } + const data: TableCell[][] = [] + for (let i = 0; i < row; i++) { + const rowCells: TableCell[] = [] + for (let j = 0; j < col; j++) { + rowCells.push({ id: nanoid(10), colspan: 1, rowspan: 1, text: '', style }) + } + data.push(rowCells) + } + + const DEFAULT_CELL_WIDTH = 100 + const DEFAULT_CELL_HEIGHT = 36 + + const colWidths: number[] = new Array(col).fill(1 / col) + + const width = col * DEFAULT_CELL_WIDTH + const height = row * DEFAULT_CELL_HEIGHT + + createElement({ + type: 'table', + id: nanoid(10), + width, + height, + colWidths, + rotate: 0, + data, + left: (viewportSize.value - width) / 2, + top: (viewportSize.value * viewportRatio.value - height) / 2, + outline: { + width: 2, + style: 'solid', + color: '#eeece1', + }, + theme: { + color: theme.value.themeColors[0], + rowHeader: true, + rowFooter: false, + colHeader: false, + colFooter: false, + }, + cellMinHeight: 36, + }) + } + + /** + * 创建文本元素 + * @param position 位置大小信息 + * @param content 文本内容 + */ + const createTextElement = (position: CommonElementPosition, data?: CreateTextData) => { + const { left, top, width, height } = position + const content = data?.content || '' + const vertical = data?.vertical || false + + const id = nanoid(10) + createElement({ + type: 'text', + id, + left, + top, + width, + height, + content, + rotate: 0, + defaultFontName: theme.value.fontName, + defaultColor: theme.value.fontColor, + vertical, + }, () => { + setTimeout(() => { + const editorRef: HTMLElement | null = document.querySelector(`#editable-element-${id} .ProseMirror`) + if (editorRef) editorRef.focus() + }, 0) + }) + } + + /** + * 创建形状元素 + * @param position 位置大小信息 + * @param data 形状路径信息 + */ + const createShapeElement = (position: CommonElementPosition, data: ShapePoolItem, supplement: Partial = {}) => { + const { left, top, width, height } = position + const newElement: PPTShapeElement = { + type: 'shape', + id: nanoid(10), + left, + top, + width, + height, + viewBox: data.viewBox, + path: data.path, + fill: theme.value.themeColors[0], + fixedRatio: false, + rotate: 0, + ...supplement, + } + if (data.withborder) newElement.outline = theme.value.outline + if (data.special) newElement.special = true + if (data.pathFormula) { + newElement.pathFormula = data.pathFormula + newElement.viewBox = [width, height] + + const pathFormula = SHAPE_PATH_FORMULAS[data.pathFormula] + if ('editable' in pathFormula && pathFormula.editable) { + newElement.path = pathFormula.formula(width, height, pathFormula.defaultValue!) + newElement.keypoints = pathFormula.defaultValue + } + else newElement.path = pathFormula.formula(width, height) + } + createElement(newElement) + } + + /** + * 创建线条元素 + * @param position 位置大小信息 + * @param data 线条的路径和样式 + */ + const createLineElement = (position: LineElementPosition, data: LinePoolItem) => { + const { left, top, start, end } = position + + const newElement: PPTLineElement = { + type: 'line', + id: nanoid(10), + left, + top, + start, + end, + points: data.points, + color: theme.value.themeColors[0], + style: data.style, + width: 2, + } + if (data.isBroken) newElement.broken = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (data.isBroken2) newElement.broken2 = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (data.isCurve) newElement.curve = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (data.isCubic) newElement.cubic = [[(start[0] + end[0]) / 2, (start[1] + end[1]) / 2], [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]] + createElement(newElement) + } + + /** + * 创建LaTeX元素 + * @param svg SVG代码 + */ + const createLatexElement = (data: { path: string; latex: string; w: number; h: number; }) => { + createElement({ + type: 'latex', + id: nanoid(10), + width: data.w, + height: data.h, + rotate: 0, + left: (viewportSize.value - data.w) / 2, + top: (viewportSize.value * viewportRatio.value - data.h) / 2, + path: data.path, + latex: data.latex, + color: theme.value.fontColor, + strokeWidth: 2, + viewBox: [data.w, data.h], + fixedRatio: true, + }) + } + + /** + * 创建视频元素 + * @param src 视频地址 + */ + const createVideoElement = (src: string) => { + createElement({ + type: 'video', + id: nanoid(10), + width: 500, + height: 300, + rotate: 0, + left: (viewportSize.value - 500) / 2, + top: (viewportSize.value * viewportRatio.value - 300) / 2, + src, + autoplay: false, + }) + } + + /** + * 创建音频元素 + * @param src 音频地址 + */ + const createAudioElement = (src: string) => { + createElement({ + type: 'audio', + id: nanoid(10), + width: 50, + height: 50, + rotate: 0, + left: (viewportSize.value - 50) / 2, + top: (viewportSize.value * viewportRatio.value - 50) / 2, + loop: false, + autoplay: false, + fixedRatio: true, + color: theme.value.themeColors[0], + src, + }) + } + + return { + createImageElement, + createChartElement, + createTableElement, + createTextElement, + createShapeElement, + createLineElement, + createLatexElement, + createVideoElement, + createAudioElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useDeleteElement.ts b/frontend/src/hooks/useDeleteElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ab08953fc8a07d49c84911ff6fa6af4db00f3d9 --- /dev/null +++ b/frontend/src/hooks/useDeleteElement.ts @@ -0,0 +1,44 @@ +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { activeElementIdList, activeGroupElementId } = storeToRefs(mainStore) + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + // 删除全部选中元素 + // 组合元素成员中,存在被选中可独立操作的元素时,优先删除该元素。否则默认删除所有被选中的元素 + const deleteElement = () => { + if (!activeElementIdList.value.length) return + + let newElementList: PPTElement[] = [] + if (activeGroupElementId.value) { + newElementList = currentSlide.value.elements.filter(el => el.id !== activeGroupElementId.value) + } + else { + newElementList = currentSlide.value.elements.filter(el => !activeElementIdList.value.includes(el.id)) + } + + mainStore.setActiveElementIdList([]) + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + // 删除内面内全部元素(无论是否选中) + const deleteAllElements = () => { + if (!currentSlide.value.elements.length) return + mainStore.setActiveElementIdList([]) + slidesStore.updateSlide({ elements: [] }) + addHistorySnapshot() + } + + return { + deleteElement, + deleteAllElements, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useExport.ts b/frontend/src/hooks/useExport.ts new file mode 100644 index 0000000000000000000000000000000000000000..5eee827e8e5a2b73182ec681207802e0936ec3ee --- /dev/null +++ b/frontend/src/hooks/useExport.ts @@ -0,0 +1,915 @@ +import { computed, ref } from 'vue' +import { storeToRefs } from 'pinia' +import { trim } from 'lodash' +import { saveAs } from 'file-saver' +import pptxgen from 'pptxgenjs' +import tinycolor from 'tinycolor2' +import { toPng, toJpeg } from 'html-to-image' +import { useSlidesStore } from '@/store' +import type { PPTElementOutline, PPTElementShadow, PPTElementLink, Slide } from '@/types/slides' +import { getElementRange, getLineElementPath, getTableSubThemeColor } from '@/utils/element' +import { type AST, toAST } from '@/utils/htmlParser' +import { type SvgPoints, toPoints } from '@/utils/svgPathParser' +import { encrypt } from '@/utils/crypto' +import { svg2Base64 } from '@/utils/svg2Base64' +import message from '@/utils/message' + +interface ExportImageConfig { + quality: number + width: number + fontEmbedCSS?: string +} + +export default () => { + const slidesStore = useSlidesStore() + const { slides, theme, viewportRatio, title, viewportSize } = storeToRefs(slidesStore) + + const defaultFontSize = 16 + + const ratioPx2Inch = computed(() => { + return 96 * (viewportSize.value / 960) + }) + const ratioPx2Pt = computed(() => { + return 96 / 72 * (viewportSize.value / 960) + }) + + const exporting = ref(false) + + // 导出图片 + const exportImage = (domRef: HTMLElement, format: string, quality: number, ignoreWebfont = true) => { + exporting.value = true + const toImage = format === 'png' ? toPng : toJpeg + + const foreignObjectSpans = domRef.querySelectorAll('foreignObject [xmlns]') + foreignObjectSpans.forEach(spanRef => spanRef.removeAttribute('xmlns')) + + setTimeout(() => { + const config: ExportImageConfig = { + quality, + width: 1600, + } + + if (ignoreWebfont) config.fontEmbedCSS = '' + + toImage(domRef, config).then(dataUrl => { + exporting.value = false + saveAs(dataUrl, `${title.value}.${format}`) + }).catch(() => { + exporting.value = false + message.error('导出图片失败') + }) + }, 200) + } + + // 导出pptist文件(特有 .pptist 后缀文件) + const exportSpecificFile = (_slides: Slide[]) => { + const blob = new Blob([encrypt(JSON.stringify(_slides))], { type: '' }) + saveAs(blob, `${title.value}.pptist`) + } + + // 导出JSON文件 + const exportJSON = () => { + const json = { + title: title.value, + width: viewportSize.value, + height: viewportSize.value * viewportRatio.value, + theme: theme.value, + slides: slides.value, + } + const blob = new Blob([JSON.stringify(json)], { type: '' }) + saveAs(blob, `${title.value}.json`) + } + + // 格式化颜色值为 透明度 + HexString,供pptxgenjs使用 + const formatColor = (_color: string) => { + if (!_color) return { + alpha: 0, + color: '#000000', + } + + const c = tinycolor(_color) + const alpha = c.getAlpha() + const color = alpha === 0 ? '#ffffff' : c.setAlpha(1).toHexString() + return { + alpha, + color, + } + } + + type FormatColor = ReturnType + + // 将HTML字符串格式化为pptxgenjs所需的格式 + // 核心思路:将HTML字符串按样式分片平铺,每个片段需要继承祖先元素的样式信息,遇到块级元素需要换行 + const formatHTML = (html: string) => { + const ast = toAST(html) + let bulletFlag = false + let indent = 0 + + const slices: pptxgen.TextProps[] = [] + const parse = (obj: AST[], baseStyleObj: { [key: string]: string } = {}) => { + + for (const item of obj) { + const isBlockTag = 'tagName' in item && ['div', 'li', 'p'].includes(item.tagName) + + if (isBlockTag && slices.length) { + const lastSlice = slices[slices.length - 1] + if (!lastSlice.options) lastSlice.options = {} + lastSlice.options.breakLine = true + } + + const styleObj = { ...baseStyleObj } + const styleAttr = 'attributes' in item ? item.attributes.find(attr => attr.key === 'style') : null + if (styleAttr && styleAttr.value) { + const styleArr = styleAttr.value.split(';') + for (const styleItem of styleArr) { + const [_key, _value] = styleItem.split(': ') + const [key, value] = [trim(_key), trim(_value)] + if (key && value) styleObj[key] = value + } + } + + if ('tagName' in item) { + if (item.tagName === 'em') { + styleObj['font-style'] = 'italic' + } + if (item.tagName === 'strong') { + styleObj['font-weight'] = 'bold' + } + if (item.tagName === 'sup') { + styleObj['vertical-align'] = 'super' + } + if (item.tagName === 'sub') { + styleObj['vertical-align'] = 'sub' + } + if (item.tagName === 'a') { + const attr = item.attributes.find(attr => attr.key === 'href') + styleObj['href'] = attr?.value || '' + } + if (item.tagName === 'ul') { + styleObj['list-type'] = 'ul' + } + if (item.tagName === 'ol') { + styleObj['list-type'] = 'ol' + } + if (item.tagName === 'li') { + bulletFlag = true + } + if (item.tagName === 'p') { + if ('attributes' in item) { + const dataIndentAttr = item.attributes.find(attr => attr.key === 'data-indent') + if (dataIndentAttr && dataIndentAttr.value) indent = +dataIndentAttr.value + } + } + } + + if ('tagName' in item && item.tagName === 'br') { + slices.push({ text: '', options: { breakLine: true } }) + } + else if ('content' in item) { + const text = item.content.replace(/ /g, ' ').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&').replace(/\n/g, '') + const options: pptxgen.TextPropsOptions = {} + + if (styleObj['font-size']) { + options.fontSize = parseInt(styleObj['font-size']) / ratioPx2Pt.value + } + if (styleObj['color']) { + options.color = formatColor(styleObj['color']).color + } + if (styleObj['background-color']) { + options.highlight = formatColor(styleObj['background-color']).color + } + if (styleObj['text-decoration-line']) { + if (styleObj['text-decoration-line'].indexOf('underline') !== -1) { + options.underline = { + color: options.color || '#000000', + style: 'sng', + } + } + if (styleObj['text-decoration-line'].indexOf('line-through') !== -1) { + options.strike = 'sngStrike' + } + } + if (styleObj['text-decoration']) { + if (styleObj['text-decoration'].indexOf('underline') !== -1) { + options.underline = { + color: options.color || '#000000', + style: 'sng', + } + } + if (styleObj['text-decoration'].indexOf('line-through') !== -1) { + options.strike = 'sngStrike' + } + } + if (styleObj['vertical-align']) { + if (styleObj['vertical-align'] === 'super') options.superscript = true + if (styleObj['vertical-align'] === 'sub') options.subscript = true + } + if (styleObj['text-align']) options.align = styleObj['text-align'] as pptxgen.HAlign + if (styleObj['font-weight']) options.bold = styleObj['font-weight'] === 'bold' + if (styleObj['font-style']) options.italic = styleObj['font-style'] === 'italic' + if (styleObj['font-family']) options.fontFace = styleObj['font-family'] + if (styleObj['href']) options.hyperlink = { url: styleObj['href'] } + + if (bulletFlag && styleObj['list-type'] === 'ol') { + options.bullet = { type: 'number', indent: (options.fontSize || defaultFontSize) * 1.25 } + options.paraSpaceBefore = 0.1 + bulletFlag = false + } + if (bulletFlag && styleObj['list-type'] === 'ul') { + options.bullet = { indent: (options.fontSize || defaultFontSize) * 1.25 } + options.paraSpaceBefore = 0.1 + bulletFlag = false + } + if (indent) { + options.indentLevel = indent + indent = 0 + } + + slices.push({ text, options }) + } + else if ('children' in item) parse(item.children, styleObj) + } + } + parse(ast) + return slices + } + + type Points = Array< + | { x: number; y: number; moveTo?: boolean } + | { x: number; y: number; curve: { type: 'arc'; hR: number; wR: number; stAng: number; swAng: number } } + | { x: number; y: number; curve: { type: 'quadratic'; x1: number; y1: number } } + | { x: number; y: number; curve: { type: 'cubic'; x1: number; y1: number; x2: number; y2: number } } + | { close: true } + > + + // 将SVG路径信息格式化为pptxgenjs所需要的格式 + const formatPoints = (points: SvgPoints, scale = { x: 1, y: 1 }): Points => { + return points.map(point => { + if (point.close !== undefined) { + return { close: true } + } + else if (point.type === 'M') { + return { + x: point.x / ratioPx2Inch.value * scale.x, + y: point.y / ratioPx2Inch.value * scale.y, + moveTo: true, + } + } + else if (point.curve) { + if (point.curve.type === 'cubic') { + return { + x: point.x / ratioPx2Inch.value * scale.x, + y: point.y / ratioPx2Inch.value * scale.y, + curve: { + type: 'cubic', + x1: (point.curve.x1 as number) / ratioPx2Inch.value * scale.x, + y1: (point.curve.y1 as number) / ratioPx2Inch.value * scale.y, + x2: (point.curve.x2 as number) / ratioPx2Inch.value * scale.x, + y2: (point.curve.y2 as number) / ratioPx2Inch.value * scale.y, + }, + } + } + else if (point.curve.type === 'quadratic') { + return { + x: point.x / ratioPx2Inch.value * scale.x, + y: point.y / ratioPx2Inch.value * scale.y, + curve: { + type: 'quadratic', + x1: (point.curve.x1 as number) / ratioPx2Inch.value * scale.x, + y1: (point.curve.y1 as number) / ratioPx2Inch.value * scale.y, + }, + } + } + } + return { + x: point.x / ratioPx2Inch.value * scale.x, + y: point.y / ratioPx2Inch.value * scale.y, + } + }) + } + + // 获取阴影配置 + const getShadowOption = (shadow: PPTElementShadow): pptxgen.ShadowProps => { + const c = formatColor(shadow.color) + const { h, v } = shadow + + let offset = 4 + let angle = 45 + + if (h === 0 && v === 0) { + offset = 4 + angle = 45 + } + else if (h === 0) { + if (v > 0) { + offset = v + angle = 90 + } + else { + offset = -v + angle = 270 + } + } + else if (v === 0) { + if (h > 0) { + offset = h + angle = 1 + } + else { + offset = -h + angle = 180 + } + } + else if (h > 0 && v > 0) { + offset = Math.max(h, v) + angle = 45 + } + else if (h > 0 && v < 0) { + offset = Math.max(h, -v) + angle = 315 + } + else if (h < 0 && v > 0) { + offset = Math.max(-h, v) + angle = 135 + } + else if (h < 0 && v < 0) { + offset = Math.max(-h, -v) + angle = 225 + } + + return { + type: 'outer', + color: c.color.replace('#', ''), + opacity: c.alpha, + blur: shadow.blur / ratioPx2Pt.value, + offset, + angle, + } + } + + const dashTypeMap = { + 'solid': 'solid', + 'dashed': 'dash', + 'dotted': 'sysDot', + } + + // 获取边框配置 + const getOutlineOption = (outline: PPTElementOutline): pptxgen.ShapeLineProps => { + const c = formatColor(outline?.color || '#000000') + + return { + color: c.color, + transparency: (1 - c.alpha) * 100, + width: (outline.width || 1) / ratioPx2Pt.value, + dashType: outline.style ? dashTypeMap[outline.style] as 'solid' | 'dash' | 'sysDot' : 'solid', + } + } + + // 获取超链接配置 + const getLinkOption = (link: PPTElementLink): pptxgen.HyperlinkProps | null => { + const { type, target } = link + if (type === 'web') return { url: target } + if (type === 'slide') { + const index = slides.value.findIndex(slide => slide.id === target) + if (index !== -1) return { slide: index + 1 } + } + + return null + } + + // 判断是否为Base64图片地址 + const isBase64Image = (url: string) => { + const regex = /^data:image\/[^;]+;base64,/ + return url.match(regex) !== null + } + + // 判断是否为SVG图片地址 + const isSVGImage = (url: string) => { + const isSVGBase64 = /^data:image\/svg\+xml;base64,/.test(url) + const isSVGUrl = /\.svg$/.test(url) + return isSVGBase64 || isSVGUrl + } + + // 导出PPTX文件 + const exportPPTX = (_slides: Slide[], masterOverwrite: boolean, ignoreMedia: boolean) => { + exporting.value = true + const pptx = new pptxgen() + + if (viewportRatio.value === 0.625) pptx.layout = 'LAYOUT_16x10' + else if (viewportRatio.value === 0.75) pptx.layout = 'LAYOUT_4x3' + else if (viewportRatio.value === 0.70710678) { + pptx.defineLayout({ name: 'A3', width: 10, height: 7.0710678 }) + pptx.layout = 'A3' + } + else if (viewportRatio.value === 1.41421356) { + pptx.defineLayout({ name: 'A3_V', width: 10, height: 14.1421356 }) + pptx.layout = 'A3_V' + } + else pptx.layout = 'LAYOUT_16x9' + + if (masterOverwrite) { + const { color: bgColor, alpha: bgAlpha } = formatColor(theme.value.backgroundColor) + pptx.defineSlideMaster({ + title: 'PPTIST_MASTER', + background: { color: bgColor, transparency: (1 - bgAlpha) * 100 }, + }) + } + + for (const slide of _slides) { + const pptxSlide = pptx.addSlide() + + if (slide.background) { + const background = slide.background + if (background.type === 'image' && background.image) { + if (isSVGImage(background.image.src)) { + pptxSlide.addImage({ + data: background.image.src, + x: 0, + y: 0, + w: viewportSize.value / ratioPx2Inch.value, + h: viewportSize.value * viewportRatio.value / ratioPx2Inch.value, + }) + } + else if (isBase64Image(background.image.src)) { + pptxSlide.background = { data: background.image.src } + } + else { + pptxSlide.background = { path: background.image.src } + } + } + else if (background.type === 'solid' && background.color) { + const c = formatColor(background.color) + pptxSlide.background = { color: c.color, transparency: (1 - c.alpha) * 100 } + } + else if (background.type === 'gradient' && background.gradient) { + const colors = background.gradient.colors + const color1 = colors[0].color + const color2 = colors[colors.length - 1].color + const color = tinycolor.mix(color1, color2).toHexString() + const c = formatColor(color) + pptxSlide.background = { color: c.color, transparency: (1 - c.alpha) * 100 } + } + } + if (slide.remark) { + const doc = new DOMParser().parseFromString(slide.remark, 'text/html') + const pList = doc.body.querySelectorAll('p') + const text = [] + for (const p of pList) { + const textContent = p.textContent + text.push(textContent || '') + } + pptxSlide.addNotes(text.join('\n')) + } + + if (!slide.elements) continue + + for (const el of slide.elements) { + if (el.type === 'text') { + const textProps = formatHTML(el.content) + + const options: pptxgen.TextPropsOptions = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + fontSize: defaultFontSize / ratioPx2Pt.value, + fontFace: '微软雅黑', + color: '#000000', + valign: 'top', + margin: 10 / ratioPx2Pt.value, + paraSpaceBefore: 5 / ratioPx2Pt.value, + lineSpacingMultiple: 1.5 / 1.25, + autoFit: true, + } + if (el.rotate) options.rotate = el.rotate + if (el.wordSpace) options.charSpacing = el.wordSpace / ratioPx2Pt.value + if (el.lineHeight) options.lineSpacingMultiple = el.lineHeight / 1.25 + if (el.fill) { + const c = formatColor(el.fill) + const opacity = el.opacity === undefined ? 1 : el.opacity + options.fill = { color: c.color, transparency: (1 - c.alpha * opacity) * 100 } + } + if (el.defaultColor) options.color = formatColor(el.defaultColor).color + if (el.defaultFontName) options.fontFace = el.defaultFontName + if (el.shadow) options.shadow = getShadowOption(el.shadow) + if (el.outline?.width) options.line = getOutlineOption(el.outline) + if (el.opacity !== undefined) options.transparency = (1 - el.opacity) * 100 + if (el.paragraphSpace !== undefined) options.paraSpaceBefore = el.paragraphSpace / ratioPx2Pt.value + if (el.vertical) options.vert = 'eaVert' + + pptxSlide.addText(textProps, options) + } + + else if (el.type === 'image') { + const options: pptxgen.ImageProps = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + } + if (isBase64Image(el.src)) options.data = el.src + else options.path = el.src + + if (el.flipH) options.flipH = el.flipH + if (el.flipV) options.flipV = el.flipV + if (el.rotate) options.rotate = el.rotate + if (el.link) { + const linkOption = getLinkOption(el.link) + if (linkOption) options.hyperlink = linkOption + } + if (el.filters?.opacity) options.transparency = 100 - parseInt(el.filters?.opacity) + if (el.clip) { + if (el.clip.shape === 'ellipse') options.rounding = true + + const [start, end] = el.clip.range + const [startX, startY] = start + const [endX, endY] = end + + const originW = el.width / ((endX - startX) / ratioPx2Inch.value) + const originH = el.height / ((endY - startY) / ratioPx2Inch.value) + + options.w = originW / ratioPx2Inch.value + options.h = originH / ratioPx2Inch.value + + options.sizing = { + type: 'crop', + x: startX / ratioPx2Inch.value * originW / ratioPx2Inch.value, + y: startY / ratioPx2Inch.value * originH / ratioPx2Inch.value, + w: (endX - startX) / ratioPx2Inch.value * originW / ratioPx2Inch.value, + h: (endY - startY) / ratioPx2Inch.value * originH / ratioPx2Inch.value, + } + } + + pptxSlide.addImage(options) + } + + else if (el.type === 'shape') { + if (el.special) { + const svgRef = document.querySelector(`.thumbnail-list .base-element-${el.id} svg`) as HTMLElement + if (svgRef.clientWidth < 1 || svgRef.clientHeight < 1) continue // 临时处理(导入PPTX文件带来的异常数据) + const base64SVG = svg2Base64(svgRef) + + const options: pptxgen.ImageProps = { + data: base64SVG, + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + } + if (el.rotate) options.rotate = el.rotate + if (el.flipH) options.flipH = el.flipH + if (el.flipV) options.flipV = el.flipV + if (el.link) { + const linkOption = getLinkOption(el.link) + if (linkOption) options.hyperlink = linkOption + } + + pptxSlide.addImage(options) + } + else { + const scale = { + x: el.width / el.viewBox[0], + y: el.height / el.viewBox[1], + } + const points = formatPoints(toPoints(el.path), scale) + + let fillColor = formatColor(el.fill) + if (el.gradient) { + const colors = el.gradient.colors + const color1 = colors[0].color + const color2 = colors[colors.length - 1].color + const color = tinycolor.mix(color1, color2).toHexString() + fillColor = formatColor(color) + } + if (el.pattern) fillColor = formatColor('#00000000') + const opacity = el.opacity === undefined ? 1 : el.opacity + + const options: pptxgen.ShapeProps = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100 }, + points, + } + if (el.flipH) options.flipH = el.flipH + if (el.flipV) options.flipV = el.flipV + if (el.shadow) options.shadow = getShadowOption(el.shadow) + if (el.outline?.width) options.line = getOutlineOption(el.outline) + if (el.rotate) options.rotate = el.rotate + if (el.link) { + const linkOption = getLinkOption(el.link) + if (linkOption) options.hyperlink = linkOption + } + + pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options) + } + if (el.text) { + const textProps = formatHTML(el.text.content) + + const options: pptxgen.TextPropsOptions = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + fontSize: defaultFontSize / ratioPx2Pt.value, + fontFace: '微软雅黑', + color: '#000000', + paraSpaceBefore: 5 / ratioPx2Pt.value, + valign: el.text.align, + } + if (el.rotate) options.rotate = el.rotate + if (el.text.defaultColor) options.color = formatColor(el.text.defaultColor).color + if (el.text.defaultFontName) options.fontFace = el.text.defaultFontName + + pptxSlide.addText(textProps, options) + } + if (el.pattern) { + const options: pptxgen.ImageProps = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + } + if (isBase64Image(el.pattern)) options.data = el.pattern + else options.path = el.pattern + + if (el.flipH) options.flipH = el.flipH + if (el.flipV) options.flipV = el.flipV + if (el.rotate) options.rotate = el.rotate + if (el.link) { + const linkOption = getLinkOption(el.link) + if (linkOption) options.hyperlink = linkOption + } + + pptxSlide.addImage(options) + } + } + + else if (el.type === 'line') { + const path = getLineElementPath(el) + const points = formatPoints(toPoints(path)) + const { minX, maxX, minY, maxY } = getElementRange(el) + const c = formatColor(el.color) + + const options: pptxgen.ShapeProps = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: (maxX - minX) / ratioPx2Inch.value, + h: (maxY - minY) / ratioPx2Inch.value, + line: { + color: c.color, + transparency: (1 - c.alpha) * 100, + width: el.width / ratioPx2Pt.value, + dashType: dashTypeMap[el.style] as 'solid' | 'dash' | 'sysDot', + beginArrowType: el.points[0] ? 'arrow' : 'none', + endArrowType: el.points[1] ? 'arrow' : 'none', + }, + points, + } + if (el.shadow) options.shadow = getShadowOption(el.shadow) + + pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options) + } + + else if (el.type === 'chart') { + const chartData = [] + for (let i = 0; i < el.data.series.length; i++) { + const item = el.data.series[i] + chartData.push({ + name: `系列${i + 1}`, + labels: el.data.labels, + values: item, + }) + } + + let chartColors: string[] = [] + if (el.themeColors.length === 10) chartColors = el.themeColors.map(color => formatColor(color).color) + else if (el.themeColors.length === 1) chartColors = tinycolor(el.themeColors[0]).analogous(10).map(color => formatColor(color.toHexString()).color) + else { + const len = el.themeColors.length + const supplement = tinycolor(el.themeColors[len - 1]).analogous(10 + 1 - len).map(color => color.toHexString()) + chartColors = [...el.themeColors.slice(0, len - 1), ...supplement].map(color => formatColor(color).color) + } + + const options: pptxgen.IChartOpts = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + chartColors: (el.chartType === 'pie' || el.chartType === 'ring') ? chartColors : chartColors.slice(0, el.data.series.length), + } + + const textColor = formatColor(el.textColor || '#000000').color + options.catAxisLabelColor = textColor + options.valAxisLabelColor = textColor + + const fontSize = 14 / ratioPx2Pt.value + options.catAxisLabelFontSize = fontSize + options.valAxisLabelFontSize = fontSize + + if (el.fill || el.outline) { + const plotArea: pptxgen.IChartPropsFillLine = {} + if (el.fill) { + plotArea.fill = { color: formatColor(el.fill).color } + } + if (el.outline) { + plotArea.border = { + pt: el.outline.width! / ratioPx2Pt.value, + color: formatColor(el.outline.color!).color, + } + } + options.plotArea = plotArea + } + + if ((el.data.series.length > 1 && el.chartType !== 'scatter') || el.chartType === 'pie' || el.chartType === 'ring') { + options.showLegend = true + options.legendPos = 'b' + options.legendColor = textColor + options.legendFontSize = fontSize + } + + let type = pptx.ChartType.bar + if (el.chartType === 'bar') { + type = pptx.ChartType.bar + options.barDir = 'col' + if (el.options?.stack) options.barGrouping = 'stacked' + } + else if (el.chartType === 'column') { + type = pptx.ChartType.bar + options.barDir = 'bar' + if (el.options?.stack) options.barGrouping = 'stacked' + } + else if (el.chartType === 'line') { + type = pptx.ChartType.line + if (el.options?.lineSmooth) options.lineSmooth = true + } + else if (el.chartType === 'area') { + type = pptx.ChartType.area + } + else if (el.chartType === 'radar') { + type = pptx.ChartType.radar + } + else if (el.chartType === 'scatter') { + type = pptx.ChartType.scatter + options.lineSize = 0 + } + else if (el.chartType === 'pie') { + type = pptx.ChartType.pie + } + else if (el.chartType === 'ring') { + type = pptx.ChartType.doughnut + options.holeSize = 60 + } + + pptxSlide.addChart(type, chartData, options) + } + + else if (el.type === 'table') { + const hiddenCells = [] + for (let i = 0; i < el.data.length; i++) { + const rowData = el.data[i] + + for (let j = 0; j < rowData.length; j++) { + const cell = rowData[j] + if (cell.colspan > 1 || cell.rowspan > 1) { + for (let row = i; row < i + cell.rowspan; row++) { + for (let col = row === i ? j + 1 : j; col < j + cell.colspan; col++) hiddenCells.push(`${row}_${col}`) + } + } + } + } + + const tableData = [] + + const theme = el.theme + let themeColor: FormatColor | null = null + let subThemeColors: FormatColor[] = [] + if (theme) { + themeColor = formatColor(theme.color) + subThemeColors = getTableSubThemeColor(theme.color).map(item => formatColor(item)) + } + + for (let i = 0; i < el.data.length; i++) { + const row = el.data[i] + const _row = [] + + for (let j = 0; j < row.length; j++) { + const cell = row[j] + const cellOptions: pptxgen.TableCellProps = { + colspan: cell.colspan, + rowspan: cell.rowspan, + bold: cell.style?.bold || false, + italic: cell.style?.em || false, + underline: { style: cell.style?.underline ? 'sng' : 'none' }, + align: cell.style?.align || 'left', + valign: 'middle', + fontFace: cell.style?.fontname || '微软雅黑', + fontSize: (cell.style?.fontsize ? parseInt(cell.style?.fontsize) : 14) / ratioPx2Pt.value, + } + if (theme && themeColor) { + let c: FormatColor + if (i % 2 === 0) c = subThemeColors[1] + else c = subThemeColors[0] + + if (theme.rowHeader && i === 0) c = themeColor + else if (theme.rowFooter && i === el.data.length - 1) c = themeColor + else if (theme.colHeader && j === 0) c = themeColor + else if (theme.colFooter && j === row.length - 1) c = themeColor + + cellOptions.fill = { color: c.color, transparency: (1 - c.alpha) * 100 } + } + if (cell.style?.backcolor) { + const c = formatColor(cell.style.backcolor) + cellOptions.fill = { color: c.color, transparency: (1 - c.alpha) * 100 } + } + if (cell.style?.color) cellOptions.color = formatColor(cell.style.color).color + + if (!hiddenCells.includes(`${i}_${j}`)) { + _row.push({ + text: cell.text, + options: cellOptions, + }) + } + } + if (_row.length) tableData.push(_row) + } + + const options: pptxgen.TableProps = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + colW: el.colWidths.map(item => el.width * item / ratioPx2Inch.value), + } + if (el.theme) options.fill = { color: '#ffffff' } + if (el.outline.width && el.outline.color) { + options.border = { + type: el.outline.style === 'solid' ? 'solid' : 'dash', + pt: el.outline.width / ratioPx2Pt.value, + color: formatColor(el.outline.color).color, + } + } + + pptxSlide.addTable(tableData, options) + } + + else if (el.type === 'latex') { + const svgRef = document.querySelector(`.thumbnail-list .base-element-${el.id} svg`) as HTMLElement + const base64SVG = svg2Base64(svgRef) + + const options: pptxgen.ImageProps = { + data: base64SVG, + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + } + if (el.link) { + const linkOption = getLinkOption(el.link) + if (linkOption) options.hyperlink = linkOption + } + + pptxSlide.addImage(options) + } + + else if (!ignoreMedia && (el.type === 'video' || el.type === 'audio')) { + const options: pptxgen.MediaProps = { + x: el.left / ratioPx2Inch.value, + y: el.top / ratioPx2Inch.value, + w: el.width / ratioPx2Inch.value, + h: el.height / ratioPx2Inch.value, + path: el.src, + type: el.type, + } + if (el.type === 'video' && el.poster) options.cover = el.poster + + const extMatch = el.src.match(/\.([a-zA-Z0-9]+)(?:[\?#]|$)/) + if (extMatch && extMatch[1]) options.extn = extMatch[1] + else if (el.ext) options.extn = el.ext + + const videoExts = ['avi', 'mp4', 'm4v', 'mov', 'wmv'] + const audioExts = ['mp3', 'm4a', 'mp4', 'wav', 'wma'] + if (options.extn && [...videoExts, ...audioExts].includes(options.extn)) { + pptxSlide.addMedia(options) + } + } + } + } + + setTimeout(() => { + pptx.writeFile({ fileName: `${title.value}.pptx` }).then(() => exporting.value = false).catch(() => { + exporting.value = false + message.error('导出失败') + }) + }, 200) + } + + return { + exporting, + exportImage, + exportJSON, + exportSpecificFile, + exportPPTX, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useGlobalHotkey.ts b/frontend/src/hooks/useGlobalHotkey.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0e21a04b71195c759fae4741332b061e58dee8d --- /dev/null +++ b/frontend/src/hooks/useGlobalHotkey.ts @@ -0,0 +1,320 @@ +import { onMounted, onUnmounted } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store' +import { ElementOrderCommands } from '@/types/edit' +import { KEYS } from '@/configs/hotkey' + +import useSlideHandler from './useSlideHandler' +import useLockElement from './useLockElement' +import useDeleteElement from './useDeleteElement' +import useCombineElement from './useCombineElement' +import useCopyAndPasteElement from './useCopyAndPasteElement' +import useSelectElement from './useSelectElement' +import useMoveElement from './useMoveElement' +import useOrderElement from './useOrderElement' +import useHistorySnapshot from './useHistorySnapshot' +import useScreening from './useScreening' +import useScaleCanvas from './useScaleCanvas' + +export default () => { + const mainStore = useMainStore() + const keyboardStore = useKeyboardStore() + const { + activeElementIdList, + disableHotkeys, + handleElement, + handleElementId, + editorAreaFocus, + thumbnailsFocus, + showSearchPanel, + } = storeToRefs(mainStore) + const { currentSlide } = storeToRefs(useSlidesStore()) + const { ctrlKeyState, shiftKeyState, spaceKeyState } = storeToRefs(keyboardStore) + + const { + updateSlideIndex, + copySlide, + createSlide, + deleteSlide, + cutSlide, + copyAndPasteSlide, + selectAllSlide, + } = useSlideHandler() + + const { combineElements, uncombineElements } = useCombineElement() + const { deleteElement } = useDeleteElement() + const { lockElement } = useLockElement() + const { copyElement, cutElement, quickCopyElement } = useCopyAndPasteElement() + const { selectAllElements } = useSelectElement() + const { moveElement } = useMoveElement() + const { orderElement } = useOrderElement() + const { redo, undo } = useHistorySnapshot() + const { enterScreening, enterScreeningFromStart } = useScreening() + const { scaleCanvas, resetCanvas } = useScaleCanvas() + + const copy = () => { + if (activeElementIdList.value.length) copyElement() + else if (thumbnailsFocus.value) copySlide() + } + + const cut = () => { + if (activeElementIdList.value.length) cutElement() + else if (thumbnailsFocus.value) cutSlide() + } + + const quickCopy = () => { + if (activeElementIdList.value.length) quickCopyElement() + else if (thumbnailsFocus.value) copyAndPasteSlide() + } + + const selectAll = () => { + if (editorAreaFocus.value) selectAllElements() + if (thumbnailsFocus.value) selectAllSlide() + } + + const lock = () => { + if (!editorAreaFocus.value) return + lockElement() + } + const combine = () => { + if (!editorAreaFocus.value) return + combineElements() + } + + const uncombine = () => { + if (!editorAreaFocus.value) return + uncombineElements() + } + + const remove = () => { + if (activeElementIdList.value.length) deleteElement() + else if (thumbnailsFocus.value) deleteSlide() + } + + const move = (key: string) => { + if (activeElementIdList.value.length) moveElement(key) + else if (key === KEYS.UP || key === KEYS.DOWN) updateSlideIndex(key) + } + + const moveSlide = (key: string) => { + if (key === KEYS.PAGEUP) updateSlideIndex(KEYS.UP) + else if (key === KEYS.PAGEDOWN) updateSlideIndex(KEYS.DOWN) + } + + const order = (command: ElementOrderCommands) => { + if (!handleElement.value) return + orderElement(handleElement.value, command) + } + + const create = () => { + if (!thumbnailsFocus.value) return + createSlide() + } + + const tabActiveElement = () => { + if (!currentSlide.value.elements.length) return + if (!handleElementId.value) { + const firstElement = currentSlide.value.elements[0] + mainStore.setActiveElementIdList([firstElement.id]) + return + } + const currentIndex = currentSlide.value.elements.findIndex(el => el.id === handleElementId.value) + const nextIndex = currentIndex >= currentSlide.value.elements.length - 1 ? 0 : currentIndex + 1 + const nextElementId = currentSlide.value.elements[nextIndex].id + + mainStore.setActiveElementIdList([nextElementId]) + } + + const keydownListener = (e: KeyboardEvent) => { + const { ctrlKey, shiftKey, altKey, metaKey } = e + const ctrlOrMetaKeyActive = ctrlKey || metaKey + + const key = e.key.toUpperCase() + + if (ctrlOrMetaKeyActive && !ctrlKeyState.value) keyboardStore.setCtrlKeyState(true) + if (shiftKey && !shiftKeyState.value) keyboardStore.setShiftKeyState(true) + if (!disableHotkeys.value && key === KEYS.SPACE) keyboardStore.setSpaceKeyState(true) + + + if (ctrlOrMetaKeyActive && key === KEYS.P) { + e.preventDefault() + mainStore.setDialogForExport('pdf') + return + } + if (shiftKey && key === KEYS.F5) { + e.preventDefault() + enterScreening() + keyboardStore.setShiftKeyState(false) + return + } + if (key === KEYS.F5) { + e.preventDefault() + enterScreeningFromStart() + return + } + if (ctrlKey && key === KEYS.F) { + e.preventDefault() + mainStore.setSearchPanelState(!showSearchPanel.value) + return + } + if (ctrlKey && key === KEYS.MINUS) { + e.preventDefault() + scaleCanvas('-') + return + } + if (ctrlKey && key === KEYS.EQUAL) { + e.preventDefault() + scaleCanvas('+') + return + } + if (ctrlKey && key === KEYS.DIGIT_0) { + e.preventDefault() + resetCanvas() + return + } + + if (!editorAreaFocus.value && !thumbnailsFocus.value) return + + if (ctrlOrMetaKeyActive && key === KEYS.C) { + if (disableHotkeys.value) return + e.preventDefault() + copy() + } + if (ctrlOrMetaKeyActive && key === KEYS.X) { + if (disableHotkeys.value) return + e.preventDefault() + cut() + } + if (ctrlOrMetaKeyActive && key === KEYS.D) { + if (disableHotkeys.value) return + e.preventDefault() + quickCopy() + } + if (ctrlOrMetaKeyActive && key === KEYS.Z) { + if (disableHotkeys.value) return + e.preventDefault() + undo() + } + if (ctrlOrMetaKeyActive && key === KEYS.Y) { + if (disableHotkeys.value) return + e.preventDefault() + redo() + } + if (ctrlOrMetaKeyActive && key === KEYS.A) { + if (disableHotkeys.value) return + e.preventDefault() + selectAll() + } + if (ctrlOrMetaKeyActive && key === KEYS.L) { + if (disableHotkeys.value) return + e.preventDefault() + lock() + } + if (!shiftKey && ctrlOrMetaKeyActive && key === KEYS.G) { + if (disableHotkeys.value) return + e.preventDefault() + combine() + } + if (shiftKey && ctrlOrMetaKeyActive && key === KEYS.G) { + if (disableHotkeys.value) return + e.preventDefault() + uncombine() + } + if (altKey && key === KEYS.F) { + if (disableHotkeys.value) return + e.preventDefault() + order(ElementOrderCommands.TOP) + } + if (altKey && key === KEYS.B) { + if (disableHotkeys.value) return + e.preventDefault() + order(ElementOrderCommands.BOTTOM) + } + if (key === KEYS.DELETE || key === KEYS.BACKSPACE) { + if (disableHotkeys.value) return + e.preventDefault() + remove() + } + if (key === KEYS.UP) { + if (disableHotkeys.value) return + e.preventDefault() + move(KEYS.UP) + } + if (key === KEYS.DOWN) { + if (disableHotkeys.value) return + e.preventDefault() + move(KEYS.DOWN) + } + if (key === KEYS.LEFT) { + if (disableHotkeys.value) return + e.preventDefault() + move(KEYS.LEFT) + } + if (key === KEYS.RIGHT) { + if (disableHotkeys.value) return + e.preventDefault() + move(KEYS.RIGHT) + } + if (key === KEYS.PAGEUP) { + if (disableHotkeys.value) return + e.preventDefault() + moveSlide(KEYS.PAGEUP) + } + if (key === KEYS.PAGEDOWN) { + if (disableHotkeys.value) return + e.preventDefault() + moveSlide(KEYS.PAGEDOWN) + } + if (key === KEYS.ENTER) { + if (disableHotkeys.value) return + e.preventDefault() + create() + } + if (key === KEYS.TAB) { + if (disableHotkeys.value) return + e.preventDefault() + tabActiveElement() + } + if (editorAreaFocus.value && !shiftKey && !ctrlOrMetaKeyActive && !disableHotkeys.value) { + if (key === KEYS.T) { + mainStore.setCreatingElement({ type: 'text' }) + } + else if (key === KEYS.R) { + mainStore.setCreatingElement({ type: 'shape', data: { + viewBox: [200, 200], + path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z', + }}) + } + else if (key === KEYS.O) { + mainStore.setCreatingElement({ type: 'shape', data: { + viewBox: [200, 200], + path: 'M 100 0 A 50 50 0 1 1 100 200 A 50 50 0 1 1 100 0 Z', + }}) + } + else if (key === KEYS.L) { + mainStore.setCreatingElement({ type: 'line', data: { + path: 'M 0 0 L 20 20', + style: 'solid', + points: ['', ''], + }}) + } + } + } + + const keyupListener = () => { + if (ctrlKeyState.value) keyboardStore.setCtrlKeyState(false) + if (shiftKeyState.value) keyboardStore.setShiftKeyState(false) + if (spaceKeyState.value) keyboardStore.setSpaceKeyState(false) + } + + onMounted(() => { + document.addEventListener('keydown', keydownListener) + document.addEventListener('keyup', keyupListener) + window.addEventListener('blur', keyupListener) + }) + onUnmounted(() => { + document.removeEventListener('keydown', keydownListener) + document.removeEventListener('keyup', keyupListener) + window.removeEventListener('blur', keyupListener) + }) +} diff --git a/frontend/src/hooks/useHideElement.ts b/frontend/src/hooks/useHideElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3e3e4908f125789eacbcce111f0aa352380801e --- /dev/null +++ b/frontend/src/hooks/useHideElement.ts @@ -0,0 +1,35 @@ +import { storeToRefs } from 'pinia' +import { useSlidesStore, useMainStore } from '@/store' + +export default () => { + const slidesStore = useSlidesStore() + const mainStore = useMainStore() + const { currentSlide } = storeToRefs(slidesStore) + const { activeElementIdList, hiddenElementIdList } = storeToRefs(mainStore) + + const toggleHideElement = (id: string) => { + if (hiddenElementIdList.value.includes(id)) { + mainStore.setHiddenElementIdList(hiddenElementIdList.value.filter(item => item !== id)) + } + else mainStore.setHiddenElementIdList([...hiddenElementIdList.value, id]) + + if (activeElementIdList.value.includes(id)) mainStore.setActiveElementIdList([]) + } + + const showAllElements = () => { + const currentSlideElIdList = currentSlide.value.elements.map(item => item.id) + const needHiddenElementIdList = hiddenElementIdList.value.filter(item => !currentSlideElIdList.includes(item)) + mainStore.setHiddenElementIdList(needHiddenElementIdList) + } + const hideAllElements = () => { + const currentSlideElIdList = currentSlide.value.elements.map(item => item.id) + mainStore.setHiddenElementIdList([...hiddenElementIdList.value, ...currentSlideElIdList]) + if (activeElementIdList.value.length) mainStore.setActiveElementIdList([]) + } + + return { + toggleHideElement, + showAllElements, + hideAllElements, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useHistorySnapshot.ts b/frontend/src/hooks/useHistorySnapshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..f31ee1af65c1db8a29e1f3c54f438e2d6a467d06 --- /dev/null +++ b/frontend/src/hooks/useHistorySnapshot.ts @@ -0,0 +1,27 @@ +import { debounce, throttle} from 'lodash' +import { useSnapshotStore } from '@/store' + +export default () => { + const snapshotStore = useSnapshotStore() + + // 添加历史快照(历史记录) + const addHistorySnapshot = debounce(function() { + snapshotStore.addSnapshot() + }, 300, { trailing: true }) + + // 重做 + const redo = throttle(function() { + snapshotStore.reDo() + }, 100, { leading: true, trailing: false }) + + // 撤销 + const undo = throttle(function() { + snapshotStore.unDo() + }, 100, { leading: true, trailing: false }) + + return { + addHistorySnapshot, + redo, + undo, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useImport.ts b/frontend/src/hooks/useImport.ts new file mode 100644 index 0000000000000000000000000000000000000000..215a74a86f07c407f7bb9e6e6a5ae6039a81e7b9 --- /dev/null +++ b/frontend/src/hooks/useImport.ts @@ -0,0 +1,676 @@ +import { ref } from 'vue' +import { storeToRefs } from 'pinia' +import { parse, type Shape, type Element, type ChartItem, type BaseElement } from 'pptxtojson' +import { nanoid } from 'nanoid' +import { useSlidesStore } from '@/store' +import { decrypt } from '@/utils/crypto' +import { type ShapePoolItem, SHAPE_LIST, SHAPE_PATH_FORMULAS } from '@/configs/shapes' +import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements' +import useSlideHandler from '@/hooks/useSlideHandler' +import useHistorySnapshot from './useHistorySnapshot' +import message from '@/utils/message' +import { getSvgPathRange } from '@/utils/svgPathParser' +import type { + Slide, + TableCellStyle, + TableCell, + ChartType, + SlideBackground, + PPTShapeElement, + PPTLineElement, + PPTImageElement, + ShapeTextAlign, + PPTTextElement, + ChartOptions, + Gradient, + PPTElement, +} from '@/types/slides' +import { getElementListRange } from '@/utils/element' + +const convertFontSizePtToPx = (html: string, ratio: number) => { + return html.replace(/font-size:\s*([\d.]+)pt/g, (match, p1) => { + return `font-size: ${(parseFloat(p1) * ratio).toFixed(1)}px` + }) +} + +export default () => { + const slidesStore = useSlidesStore() + const { theme } = storeToRefs(useSlidesStore()) + + const { addHistorySnapshot } = useHistorySnapshot() + const { addSlidesFromData } = useAddSlidesOrElements() + const { isEmptySlide } = useSlideHandler() + + const exporting = ref(false) + + // 导入pptist文件 + const importSpecificFile = (files: FileList, cover = false) => { + const file = files[0] + + const reader = new FileReader() + reader.addEventListener('load', () => { + try { + const slides = JSON.parse(decrypt(reader.result as string)) + if (cover) { + slidesStore.updateSlideIndex(0) + slidesStore.setSlides(slides) + addHistorySnapshot() + } + else if (isEmptySlide.value) { + slidesStore.setSlides(slides) + addHistorySnapshot() + } + else addSlidesFromData(slides) + } + catch { + message.error('无法正确读取 / 解析该文件') + } + }) + reader.readAsText(file) + } + + const parseLineElement = (el: Shape, ratio: number) => { + let start: [number, number] = [0, 0] + let end: [number, number] = [0, 0] + + if (!el.isFlipV && !el.isFlipH) { // 右下 + start = [0, 0] + end = [el.width, el.height] + } + else if (el.isFlipV && el.isFlipH) { // 左上 + start = [el.width, el.height] + end = [0, 0] + } + else if (el.isFlipV && !el.isFlipH) { // 右上 + start = [0, el.height] + end = [el.width, 0] + } + else { // 左下 + start = [el.width, 0] + end = [0, el.height] + } + + const data: PPTLineElement = { + type: 'line', + id: nanoid(10), + width: +((el.borderWidth || 1) * ratio).toFixed(2), + left: el.left, + top: el.top, + start, + end, + style: el.borderType, + color: el.borderColor, + points: ['', /straightConnector/.test(el.shapType) ? 'arrow' : ''] + } + if (/bentConnector/.test(el.shapType)) { + data.broken2 = [ + Math.abs(start[0] - end[0]) / 2, + Math.abs(start[1] - end[1]) / 2, + ] + } + + return data + } + + const flipGroupElements = (elements: BaseElement[], axis: 'x' | 'y') => { + const minX = Math.min(...elements.map(el => el.left)) + const maxX = Math.max(...elements.map(el => el.left + el.width)) + const minY = Math.min(...elements.map(el => el.top)) + const maxY = Math.max(...elements.map(el => el.top + el.height)) + + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + + return elements.map(element => { + const newElement = { ...element } + + if (axis === 'y') newElement.left = 2 * centerX - element.left - element.width + if (axis === 'x') newElement.top = 2 * centerY - element.top - element.height + + return newElement + }) + } + + const calculateRotatedPosition = ( + x: number, + y: number, + w: number, + h: number, + ox: number, + oy: number, + k: number, + ) => { + const radians = k * (Math.PI / 180) + + const containerCenterX = x + w / 2 + const containerCenterY = y + h / 2 + + const relativeX = ox - w / 2 + const relativeY = oy - h / 2 + + const rotatedX = relativeX * Math.cos(radians) + relativeY * Math.sin(radians) + const rotatedY = -relativeX * Math.sin(radians) + relativeY * Math.cos(radians) + + const graphicX = containerCenterX + rotatedX + const graphicY = containerCenterY + rotatedY + + return { x: graphicX, y: graphicY } + } + + // 导入PPTX文件 + const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean }) => { + const defaultOptions = { + cover: false, + fixedViewport: false, + } + const { cover, fixedViewport } = { ...defaultOptions, ...options } + + const file = files[0] + if (!file) return + + exporting.value = true + + const shapeList: ShapePoolItem[] = [] + for (const item of SHAPE_LIST) { + shapeList.push(...item.children) + } + + const reader = new FileReader() + reader.onload = async e => { + let json = null + try { + json = await parse(e.target!.result as ArrayBuffer) + } + catch { + exporting.value = false + message.error('无法正确读取 / 解析该文件') + return + } + + let ratio = 96 / 72 + const width = json.size.width + + if (fixedViewport) ratio = 1000 / width + else slidesStore.setViewportSize(width * ratio) + + slidesStore.setTheme({ themeColors: json.themeColors }) + + const slides: Slide[] = [] + for (const item of json.slides) { + const { type, value } = item.fill + let background: SlideBackground + if (type === 'image') { + background = { + type: 'image', + image: { + src: value.picBase64, + size: 'cover', + }, + } + } + else if (type === 'gradient') { + background = { + type: 'gradient', + gradient: { + type: value.path === 'line' ? 'linear' : 'radial', + colors: value.colors.map(item => ({ + ...item, + pos: parseInt(item.pos), + })), + rotate: value.rot + 90, + }, + } + } + else { + background = { + type: 'solid', + color: value || '#fff', + } + } + + const slide: Slide = { + id: nanoid(10), + elements: [], + background, + remark: item.note || '', + } + + const parseElements = (elements: Element[]) => { + const sortedElements = elements.sort((a, b) => a.order - b.order) + + for (const el of sortedElements) { + const originWidth = el.width || 1 + const originHeight = el.height || 1 + const originLeft = el.left + const originTop = el.top + + el.width = el.width * ratio + el.height = el.height * ratio + el.left = el.left * ratio + el.top = el.top * ratio + + if (el.type === 'text') { + const textEl: PPTTextElement = { + type: 'text', + id: nanoid(10), + width: el.width, + height: el.height, + left: el.left, + top: el.top, + rotate: el.rotate, + defaultFontName: theme.value.fontName, + defaultColor: theme.value.fontColor, + content: convertFontSizePtToPx(el.content, ratio), + lineHeight: 1, + outline: { + color: el.borderColor, + width: +(el.borderWidth * ratio).toFixed(2), + style: el.borderType, + }, + fill: el.fill.type === 'color' ? el.fill.value : '', + vertical: el.isVertical, + } + if (el.shadow) { + textEl.shadow = { + h: el.shadow.h * ratio, + v: el.shadow.v * ratio, + blur: el.shadow.blur * ratio, + color: el.shadow.color, + } + } + slide.elements.push(textEl) + } + else if (el.type === 'image') { + const element: PPTImageElement = { + type: 'image', + id: nanoid(10), + src: el.src, + width: el.width, + height: el.height, + left: el.left, + top: el.top, + fixedRatio: true, + rotate: el.rotate, + flipH: el.isFlipH, + flipV: el.isFlipV, + } + if (el.borderWidth) { + element.outline = { + color: el.borderColor, + width: +(el.borderWidth * ratio).toFixed(2), + style: el.borderType, + } + } + const clipShapeTypes = ['roundRect', 'ellipse', 'triangle', 'rhombus', 'pentagon', 'hexagon', 'heptagon', 'octagon', 'parallelogram', 'trapezoid'] + if (el.rect) { + element.clip = { + shape: (el.geom && clipShapeTypes.includes(el.geom)) ? el.geom : 'rect', + range: [ + [ + el.rect.l || 0, + el.rect.t || 0, + ], + [ + 100 - (el.rect.r || 0), + 100 - (el.rect.b || 0), + ], + ] + } + } + else if (el.geom && clipShapeTypes.includes(el.geom)) { + element.clip = { + shape: el.geom, + range: [[0, 0], [100, 100]] + } + } + slide.elements.push(element) + } + else if (el.type === 'math') { + slide.elements.push({ + type: 'image', + id: nanoid(10), + src: el.picBase64, + width: el.width, + height: el.height, + left: el.left, + top: el.top, + fixedRatio: true, + rotate: 0, + }) + } + else if (el.type === 'audio') { + slide.elements.push({ + type: 'audio', + id: nanoid(10), + src: el.blob, + width: el.width, + height: el.height, + left: el.left, + top: el.top, + rotate: 0, + fixedRatio: false, + color: theme.value.themeColors[0], + loop: false, + autoplay: false, + }) + } + else if (el.type === 'video') { + slide.elements.push({ + type: 'video', + id: nanoid(10), + src: (el.blob || el.src)!, + width: el.width, + height: el.height, + left: el.left, + top: el.top, + rotate: 0, + autoplay: false, + }) + } + else if (el.type === 'shape') { + if (el.shapType === 'line' || /Connector/.test(el.shapType)) { + const lineElement = parseLineElement(el, ratio) + slide.elements.push(lineElement) + } + else { + const shape = shapeList.find(item => item.pptxShapeType === el.shapType) + + const vAlignMap: { [key: string]: ShapeTextAlign } = { + 'mid': 'middle', + 'down': 'bottom', + 'up': 'top', + } + + const gradient: Gradient | undefined = el.fill?.type === 'gradient' ? { + type: el.fill.value.path === 'line' ? 'linear' : 'radial', + colors: el.fill.value.colors.map(item => ({ + ...item, + pos: parseInt(item.pos), + })), + rotate: el.fill.value.rot, + } : undefined + + const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined + + const fill = el.fill?.type === 'color' ? el.fill.value : '' + + const element: PPTShapeElement = { + type: 'shape', + id: nanoid(10), + width: el.width, + height: el.height, + left: el.left, + top: el.top, + viewBox: [200, 200], + path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z', + fill, + gradient, + pattern, + fixedRatio: false, + rotate: el.rotate, + outline: { + color: el.borderColor, + width: +(el.borderWidth * ratio).toFixed(2), + style: el.borderType, + }, + text: { + content: convertFontSizePtToPx(el.content, ratio), + defaultFontName: theme.value.fontName, + defaultColor: theme.value.fontColor, + align: vAlignMap[el.vAlign] || 'middle', + }, + flipH: el.isFlipH, + flipV: el.isFlipV, + } + if (el.shadow) { + element.shadow = { + h: el.shadow.h * ratio, + v: el.shadow.v * ratio, + blur: el.shadow.blur * ratio, + color: el.shadow.color, + } + } + + if (shape) { + element.path = shape.path + element.viewBox = shape.viewBox + + if (shape.pathFormula) { + element.pathFormula = shape.pathFormula + element.viewBox = [el.width, el.height] + + const pathFormula = SHAPE_PATH_FORMULAS[shape.pathFormula] + if ('editable' in pathFormula && pathFormula.editable) { + element.path = pathFormula.formula(el.width, el.height, pathFormula.defaultValue) + element.keypoints = pathFormula.defaultValue + } + else element.path = pathFormula.formula(el.width, el.height) + } + } + if (el.shapType === 'custom') { + if (el.path!.indexOf('NaN') !== -1) element.path = '' + else { + element.special = true + element.path = el.path! + + const { maxX, maxY } = getSvgPathRange(element.path) + element.viewBox = [maxX || originWidth, maxY || originHeight] + } + } + + if (element.path) slide.elements.push(element) + } + } + else if (el.type === 'table') { + const row = el.data.length + const col = el.data[0].length + + const style: TableCellStyle = { + fontname: theme.value.fontName, + color: theme.value.fontColor, + } + const data: TableCell[][] = [] + for (let i = 0; i < row; i++) { + const rowCells: TableCell[] = [] + for (let j = 0; j < col; j++) { + const cellData = el.data[i][j] + + let textDiv: HTMLDivElement | null = document.createElement('div') + textDiv.innerHTML = cellData.text + const p = textDiv.querySelector('p') + const align = p?.style.textAlign || 'left' + + const span = textDiv.querySelector('span') + const fontsize = span?.style.fontSize ? (parseInt(span?.style.fontSize) * ratio).toFixed(1) + 'px' : '' + const fontname = span?.style.fontFamily || '' + const color = span?.style.color || cellData.fontColor + + rowCells.push({ + id: nanoid(10), + colspan: cellData.colSpan || 1, + rowspan: cellData.rowSpan || 1, + text: textDiv.innerText, + style: { + ...style, + align: ['left', 'right', 'center'].includes(align) ? (align as 'left' | 'right' | 'center') : 'left', + fontsize, + fontname, + color, + bold: cellData.fontBold, + backcolor: cellData.fillColor, + }, + }) + textDiv = null + } + data.push(rowCells) + } + + const allWidth = el.colWidths.reduce((a, b) => a + b, 0) + const colWidths: number[] = el.colWidths.map(item => item / allWidth) + + const firstCell = el.data[0][0] + const border = firstCell.borders.top || + firstCell.borders.bottom || + el.borders.top || + el.borders.bottom || + firstCell.borders.left || + firstCell.borders.right || + el.borders.left || + el.borders.right + const borderWidth = border?.borderWidth || 0 + const borderStyle = border?.borderType || 'solid' + const borderColor = border?.borderColor || '#eeece1' + + slide.elements.push({ + type: 'table', + id: nanoid(10), + width: el.width, + height: el.height, + left: el.left, + top: el.top, + colWidths, + rotate: 0, + data, + outline: { + width: +(borderWidth * ratio || 2).toFixed(2), + style: borderStyle, + color: borderColor, + }, + cellMinHeight: el.rowHeights[0] ? el.rowHeights[0] * ratio : 36, + }) + } + else if (el.type === 'chart') { + let labels: string[] + let legends: string[] + let series: number[][] + + if (el.chartType === 'scatterChart' || el.chartType === 'bubbleChart') { + labels = el.data[0].map((item, index) => `坐标${index + 1}`) + legends = ['X', 'Y'] + series = el.data + } + else { + const data = el.data as ChartItem[] + labels = Object.values(data[0].xlabels) + legends = data.map(item => item.key) + series = data.map(item => item.values.map(v => v.y)) + } + + const options: ChartOptions = {} + + let chartType: ChartType = 'bar' + + switch (el.chartType) { + case 'barChart': + case 'bar3DChart': + chartType = 'bar' + if (el.barDir === 'bar') chartType = 'column' + if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true + break + case 'lineChart': + case 'line3DChart': + if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true + chartType = 'line' + break + case 'areaChart': + case 'area3DChart': + if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true + chartType = 'area' + break + case 'scatterChart': + case 'bubbleChart': + chartType = 'scatter' + break + case 'pieChart': + case 'pie3DChart': + chartType = 'pie' + break + case 'radarChart': + chartType = 'radar' + break + case 'doughnutChart': + chartType = 'ring' + break + default: + } + + slide.elements.push({ + type: 'chart', + id: nanoid(10), + chartType: chartType, + width: el.width, + height: el.height, + left: el.left, + top: el.top, + rotate: 0, + themeColors: el.colors.length ? el.colors : theme.value.themeColors, + textColor: theme.value.fontColor, + data: { + labels, + legends, + series, + }, + options, + }) + } + else if (el.type === 'group') { + let elements: BaseElement[] = el.elements.map(_el => { + let left = _el.left + originLeft + let top = _el.top + originTop + + if (el.rotate) { + const { x, y } = calculateRotatedPosition(originLeft, originTop, originWidth, originHeight, _el.left, _el.top, el.rotate) + left = x + top = y + } + + const element = { + ..._el, + left, + top, + } + if (el.isFlipH && 'isFlipH' in element) element.isFlipH = true + if (el.isFlipV && 'isFlipV' in element) element.isFlipV = true + + return element + }) + if (el.isFlipH) elements = flipGroupElements(elements, 'y') + if (el.isFlipV) elements = flipGroupElements(elements, 'x') + parseElements(elements) + } + else if (el.type === 'diagram') { + const elements = el.elements.map(_el => ({ + ..._el, + left: _el.left + originLeft, + top: _el.top + originTop, + })) + parseElements(elements) + } + } + } + parseElements([...item.elements, ...item.layoutElements]) + slides.push(slide) + } + + if (cover) { + slidesStore.updateSlideIndex(0) + slidesStore.setSlides(slides) + addHistorySnapshot() + } + else if (isEmptySlide.value) { + slidesStore.setSlides(slides) + addHistorySnapshot() + } + else addSlidesFromData(slides) + + exporting.value = false + } + reader.readAsArrayBuffer(file) + } + + return { + importSpecificFile, + importPPTXFile, + exporting, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useLink.ts b/frontend/src/hooks/useLink.ts new file mode 100644 index 0000000000000000000000000000000000000000..8850f99d453687a3f76aa95419dab327c745394c --- /dev/null +++ b/frontend/src/hooks/useLink.ts @@ -0,0 +1,37 @@ +import { useSlidesStore } from '@/store' +import type { PPTElement, PPTElementLink } from '@/types/slides' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' +import message from '@/utils/message' + +export default () => { + const slidesStore = useSlidesStore() + + const { addHistorySnapshot } = useHistorySnapshot() + + const setLink = (handleElement: PPTElement, link: PPTElementLink) => { + const linkRegExp = /^(https?):\/\/[\w\-]+(\.[\w\-]+)+([\w\-.,@?^=%&:\/~+#]*[\w\-@?^=%&\/~+#])?$/ + if (link.type === 'web' && !linkRegExp.test(link.target)) { + message.error('不是正确的网页链接地址') + return false + } + if (link.type === 'slide' && !link.target) { + message.error('请先选择链接目标') + return false + } + const props = { link } + slidesStore.updateElement({ id: handleElement.id, props }) + addHistorySnapshot() + + return true + } + + const removeLink = (handleElement: PPTElement) => { + slidesStore.removeElementProps({ id: handleElement.id, propName: 'link' }) + addHistorySnapshot() + } + + return { + setLink, + removeLink, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useLoadSlides.ts b/frontend/src/hooks/useLoadSlides.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f6acfe7ff837fde6f02f3f36019bd823948a343 --- /dev/null +++ b/frontend/src/hooks/useLoadSlides.ts @@ -0,0 +1,30 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import { storeToRefs } from 'pinia' +import { useSlidesStore } from '@/store' + +export default () => { + const { slides } = storeToRefs(useSlidesStore()) + + const timer = ref(null) + const slidesLoadLimit = ref(50) + + const loadSlide = () => { + if (slides.value.length > slidesLoadLimit.value) { + timer.value = setTimeout(() => { + slidesLoadLimit.value = slidesLoadLimit.value + 20 + loadSlide() + }, 600) + } + else slidesLoadLimit.value = 9999 + } + + onMounted(loadSlide) + + onUnmounted(() => { + if (timer.value) clearTimeout(timer.value) + }) + + return { + slidesLoadLimit, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useLockElement.ts b/frontend/src/hooks/useLockElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..22bf18f57f0f61e027b799517781a1e2da9d8d66 --- /dev/null +++ b/frontend/src/hooks/useLockElement.ts @@ -0,0 +1,61 @@ +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { activeElementIdList } = storeToRefs(mainStore) + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + // 锁定选中的元素,并清空选中元素状态 + const lockElement = () => { + const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + + for (const element of newElementList) { + if (activeElementIdList.value.includes(element.id)) element.lock = true + } + slidesStore.updateSlide({ elements: newElementList }) + mainStore.setActiveElementIdList([]) + addHistorySnapshot() + } + + /** + * 解除元素的锁定状态,并将其设置为当前选择元素 + * @param handleElement 需要解锁的元素 + */ + const unlockElement = (handleElement: PPTElement) => { + const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + + if (handleElement.groupId) { + const groupElementIdList = [] + for (const element of newElementList) { + if (element.groupId === handleElement.groupId) { + element.lock = false + groupElementIdList.push(element.id) + } + } + slidesStore.updateSlide({ elements: newElementList }) + mainStore.setActiveElementIdList(groupElementIdList) + } + else { + for (const element of newElementList) { + if (element.id === handleElement.id) { + element.lock = false + break + } + } + slidesStore.updateSlide({ elements: newElementList }) + mainStore.setActiveElementIdList([handleElement.id]) + } + addHistorySnapshot() + } + + return { + lockElement, + unlockElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useMoveElement.ts b/frontend/src/hooks/useMoveElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a2f6e37d38c07fdd0046b0b71fc543e2685076d --- /dev/null +++ b/frontend/src/hooks/useMoveElement.ts @@ -0,0 +1,61 @@ +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import { KEYS } from '@/configs/hotkey' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default () => { + const slidesStore = useSlidesStore() + const { activeElementIdList, activeGroupElementId } = storeToRefs(useMainStore()) + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + /** + * 将元素向指定方向移动指定的距离 + * 组合元素成员中,存在被选中可独立操作的元素时,优先移动该元素。否则默认移动所有被选中的元素 + * @param command 移动方向 + * @param step 移动距离 + */ + const moveElement = (command: string, step = 1) => { + let newElementList: PPTElement[] = [] + + const move = (el: PPTElement) => { + let { left, top } = el + switch (command) { + case KEYS.LEFT: + left = left - step + break + case KEYS.RIGHT: + left = left + step + break + case KEYS.UP: + top = top - step + break + case KEYS.DOWN: + top = top + step + break + default: break + } + return { ...el, left, top } + } + + if (activeGroupElementId.value) { + newElementList = currentSlide.value.elements.map(el => { + return activeGroupElementId.value === el.id ? move(el) : el + }) + } + else { + newElementList = currentSlide.value.elements.map(el => { + return activeElementIdList.value.includes(el.id) ? move(el) : el + }) + } + + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + return { + moveElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useOrderElement.ts b/frontend/src/hooks/useOrderElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..6400d64b4f6acd38f5520e76b53e7f90da51bd94 --- /dev/null +++ b/frontend/src/hooks/useOrderElement.ts @@ -0,0 +1,212 @@ +import { storeToRefs } from 'pinia' +import { useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import { ElementOrderCommands } from '@/types/edit' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default () => { + const slidesStore = useSlidesStore() + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + /** + * 获取组合元素层级范围 + * @param elementList 本页所有元素列表 + * @param combineElementList 组合元素列表 + */ + const getCombineElementLevelRange = (elementList: PPTElement[], combineElementList: PPTElement[]) => { + return { + minLevel: elementList.findIndex(_element => _element.id === combineElementList[0].id), + maxLevel: elementList.findIndex(_element => _element.id === combineElementList[combineElementList.length - 1].id), + } + } + + /** + * 上移一层 + * @param elementList 本页所有元素列表 + * @param element 当前操作的元素 + */ + const moveUpElement = (elementList: PPTElement[], element: PPTElement) => { + const copyOfElementList: PPTElement[] = JSON.parse(JSON.stringify(elementList)) + + // 如果被操作的元素是组合元素成员,需要将该组合全部成员一起进行移动 + if (element.groupId) { + + // 获取到该组合全部成员,以及所有成员的层级范围 + const combineElementList = copyOfElementList.filter(_element => _element.groupId === element.groupId) + const { minLevel, maxLevel } = getCombineElementLevelRange(elementList, combineElementList) + + // 已经处在顶层,无法继续移动 + if (maxLevel === elementList.length - 1) return + + // 通过组合成员范围的最大值,获取到该组合上一层的元素,然后将该组合元素从元素列表中移除(并缓存被移除的元素列表) + // 若上层元素处在另一个组合中,则将上述被移除的组合元素插入到该上层组合上方 + // 若上层元素不处于任何分组中,则将上述被移除的组合元素插入到该上层元素上方 + const nextElement = copyOfElementList[maxLevel + 1] + const movedElementList = copyOfElementList.splice(minLevel, combineElementList.length) + + if (nextElement.groupId) { + const nextCombineElementList = copyOfElementList.filter(_element => _element.groupId === nextElement.groupId) + copyOfElementList.splice(minLevel + nextCombineElementList.length, 0, ...movedElementList) + } + else copyOfElementList.splice(minLevel + 1, 0, ...movedElementList) + } + + // 如果被操作的元素不是组合元素成员 + else { + + // 获取该元素在列表中的层级 + const level = elementList.findIndex(item => item.id === element.id) + + // 已经处在顶层,无法继续移动 + if (level === elementList.length - 1) return + + // 获取到该组合上一层的元素,然后将该组合元素从元素列表中移除(并缓存被移除的元素列表) + const nextElement = copyOfElementList[level + 1] + const movedElement = copyOfElementList.splice(level, 1)[0] + + // 通过组合成员范围的最大值,获取到该组合上一层的元素,然后将该组合元素从元素列表中移除(并缓存被移除的元素列表) + // 若上层元素处在另一个组合中,则将上述被移除的组合元素插入到该上层组合上方 + // 若上层元素不处于任何分组中,则将上述被移除的组合元素插入到该上层元素上方 + if (nextElement.groupId) { + const combineElementList = copyOfElementList.filter(_element => _element.groupId === nextElement.groupId) + copyOfElementList.splice(level + combineElementList.length, 0, movedElement) + } + else copyOfElementList.splice(level + 1, 0, movedElement) + } + + return copyOfElementList + } + + /** + * 下移一层,操作方式同上移 + * @param elementList 本页所有元素列表 + * @param element 当前操作的元素 + */ + const moveDownElement = (elementList: PPTElement[], element: PPTElement) => { + const copyOfElementList: PPTElement[] = JSON.parse(JSON.stringify(elementList)) + + if (element.groupId) { + const combineElementList = copyOfElementList.filter(_element => _element.groupId === element.groupId) + const { minLevel } = getCombineElementLevelRange(elementList, combineElementList) + if (minLevel === 0) return + + const prevElement = copyOfElementList[minLevel - 1] + const movedElementList = copyOfElementList.splice(minLevel, combineElementList.length) + + if (prevElement.groupId) { + const prevCombineElementList = copyOfElementList.filter(_element => _element.groupId === prevElement.groupId) + copyOfElementList.splice(minLevel - prevCombineElementList.length, 0, ...movedElementList) + } + else copyOfElementList.splice(minLevel - 1, 0, ...movedElementList) + } + + else { + const level = elementList.findIndex(item => item.id === element.id) + if (level === 0) return + + const prevElement = copyOfElementList[level - 1] + const movedElement = copyOfElementList.splice(level, 1)[0] + + if (prevElement.groupId) { + const combineElementList = copyOfElementList.filter(_element => _element.groupId === prevElement.groupId) + copyOfElementList.splice(level - combineElementList.length, 0, movedElement) + } + else copyOfElementList.splice(level - 1, 0, movedElement) + } + + return copyOfElementList + } + + /** + * 置顶层 + * @param elementList 本页所有元素列表 + * @param element 当前操作的元素 + */ + const moveTopElement = (elementList: PPTElement[], element: PPTElement) => { + const copyOfElementList: PPTElement[] = JSON.parse(JSON.stringify(elementList)) + + // 如果被操作的元素是组合元素成员,需要将该组合全部成员一起进行移动 + if (element.groupId) { + + // 获取到该组合全部成员,以及所有成员的层级范围 + const combineElementList = copyOfElementList.filter(_element => _element.groupId === element.groupId) + const { minLevel, maxLevel } = getCombineElementLevelRange(elementList, combineElementList) + + // 已经处在顶层,无法继续移动 + if (maxLevel === elementList.length - 1) return null + + // 将该组合元素从元素列表中移除,然后将被移除的元素添加到元素列表顶部 + const movedElementList = copyOfElementList.splice(minLevel, combineElementList.length) + copyOfElementList.push(...movedElementList) + } + + // 如果被操作的元素不是组合元素成员 + else { + + // 获取该元素在列表中的层级 + const level = elementList.findIndex(item => item.id === element.id) + + // 已经处在顶层,无法继续移动 + if (level === elementList.length - 1) return null + + // 将该组合元素从元素列表中移除,然后将被移除的元素添加到元素列表底部 + copyOfElementList.splice(level, 1) + copyOfElementList.push(element) + } + + return copyOfElementList + } + + /** + * 置底层,操作方式同置顶 + * @param elementList 本页所有元素列表 + * @param element 当前操作的元素 + */ + const moveBottomElement = (elementList: PPTElement[], element: PPTElement) => { + const copyOfElementList: PPTElement[] = JSON.parse(JSON.stringify(elementList)) + + if (element.groupId) { + const combineElementList = copyOfElementList.filter(_element => _element.groupId === element.groupId) + const { minLevel } = getCombineElementLevelRange(elementList, combineElementList) + if (minLevel === 0) return + + const movedElementList = copyOfElementList.splice(minLevel, combineElementList.length) + copyOfElementList.unshift(...movedElementList) + } + + else { + const level = elementList.findIndex(item => item.id === element.id) + if (level === 0) return + + copyOfElementList.splice(level, 1) + copyOfElementList.unshift(element) + } + + return copyOfElementList + } + + /** + * 调整元素层级 + * @param element 需要调整层级的元素 + * @param command 调整命令:上移、下移、置顶、置底 + */ + const orderElement = (element: PPTElement, command: ElementOrderCommands) => { + let newElementList + + if (command === ElementOrderCommands.UP) newElementList = moveUpElement(currentSlide.value.elements, element) + else if (command === ElementOrderCommands.DOWN) newElementList = moveDownElement(currentSlide.value.elements, element) + else if (command === ElementOrderCommands.TOP) newElementList = moveTopElement(currentSlide.value.elements, element) + else if (command === ElementOrderCommands.BOTTOM) newElementList = moveBottomElement(currentSlide.value.elements, element) + + if (!newElementList) return + + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + return { + orderElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/usePPTManager.ts b/frontend/src/hooks/usePPTManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d9065f4ee92797f5b46882e96f586e3383b63f2 --- /dev/null +++ b/frontend/src/hooks/usePPTManager.ts @@ -0,0 +1,141 @@ +import { onMounted, ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useAuthStore, useSlidesStore } from '@/store' +import dataSyncService from '@/services/dataSyncService' +import api from '@/services' +import message from '@/utils/message' + +export default () => { + const authStore = useAuthStore() + const slidesStore = useSlidesStore() + const { title } = storeToRefs(slidesStore) + + const pptList = ref([]) + const loading = ref(false) + const showPPTManager = ref(false) + + // 获取PPT列表 + const refreshPPTList = async () => { + if (!authStore.isLoggedIn) return + + loading.value = true + try { + pptList.value = await dataSyncService.getPPTList() + } catch (error) { + message.error('获取PPT列表失败') + } finally { + loading.value = false + } + } + + // 创建新PPT + const createNewPPT = async (title: string = '新建演示文稿') => { + try { + loading.value = true + const pptId = await dataSyncService.createNewPPT(title) + if (pptId) { + message.success('创建成功') + await refreshPPTList() + return pptId + } + } catch (error) { + message.error('创建失败') + throw error + } finally { + loading.value = false + } + } + + // 加载PPT + const loadPPT = async (pptId: string) => { + try { + loading.value = true + await dataSyncService.loadPPT(pptId) + message.success('PPT加载成功') + } catch (error) { + message.error('PPT加载失败') + throw error + } finally { + loading.value = false + } + } + + // 删除PPT + const deletePPT = async (pptId: string) => { + try { + loading.value = true + await dataSyncService.deletePPT(pptId) + message.success('删除成功') + await refreshPPTList() + } catch (error) { + message.error('删除失败') + throw error + } finally { + loading.value = false + } + } + + // 保存当前PPT + const saveCurrentPPT = async () => { + try { + const success = await dataSyncService.manualSave() + if (success) { + message.success('保存成功') + } else { + message.error('保存失败') + } + return success + } catch (error) { + message.error('保存失败') + return false + } + } + + // 生成分享链接 + const generateShareLinks = async (slideIndex = 0) => { + try { + const links = await dataSyncService.generateShareLink(slideIndex) + message.success('分享链接生成成功') + return links + } catch (error) { + message.error('生成分享链接失败') + throw error + } + } + + // 复制PPT + const copyPPT = async (pptId: string, newTitle: string) => { + try { + loading.value = true + const response = await api.copyPPT(pptId, newTitle) + message.success('复制成功') + await refreshPPTList() + return response.pptId + } catch (error) { + message.error('复制失败') + throw error + } finally { + loading.value = false + } + } + + // 组件挂载时初始化 + onMounted(() => { + if (authStore.isLoggedIn) { + refreshPPTList() + } + }) + + return { + pptList, + loading, + showPPTManager, + refreshPPTList, + createNewPPT, + loadPPT, + deletePPT, + saveCurrentPPT, + generateShareLinks, + copyPPT + } +} \ No newline at end of file diff --git a/frontend/src/hooks/usePasteEvent.ts b/frontend/src/hooks/usePasteEvent.ts new file mode 100644 index 0000000000000000000000000000000000000000..21c81fc2b9952afad0052387fa87c90dc2a26ef6 --- /dev/null +++ b/frontend/src/hooks/usePasteEvent.ts @@ -0,0 +1,58 @@ +import { onMounted, onUnmounted } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' +import { getImageDataURL } from '@/utils/image' +import usePasteTextClipboardData from './usePasteTextClipboardData' +import useCreateElement from './useCreateElement' + +export default () => { + const { editorAreaFocus, thumbnailsFocus, disableHotkeys } = storeToRefs(useMainStore()) + + const { pasteTextClipboardData } = usePasteTextClipboardData() + const { createImageElement } = useCreateElement() + + // 粘贴图片到幻灯片元素 + const pasteImageFile = (imageFile: File) => { + getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL)) + } + + /** + * 粘贴事件监听 + * @param e ClipboardEvent + */ + const pasteListener = (e: ClipboardEvent) => { + if (!editorAreaFocus.value && !thumbnailsFocus.value) return + if (disableHotkeys.value) return + + if (!e.clipboardData) return + + const clipboardDataItems = e.clipboardData.items + const clipboardDataFirstItem = clipboardDataItems[0] + + if (!clipboardDataFirstItem) return + + // 如果剪贴板内有图片,优先尝试读取图片 + let isImage = false + for (const item of clipboardDataItems) { + if (item.kind === 'file' && item.type.indexOf('image') !== -1) { + const imageFile = item.getAsFile() + if (imageFile) pasteImageFile(imageFile) + isImage = true + } + } + + if (isImage) return + + // 如果剪贴板内没有图片,但有文字内容,尝试解析文字内容 + if (clipboardDataFirstItem.kind === 'string' && clipboardDataFirstItem.type === 'text/plain') { + clipboardDataFirstItem.getAsString(text => pasteTextClipboardData(text)) + } + } + + onMounted(() => { + document.addEventListener('paste', pasteListener) + }) + onUnmounted(() => { + document.removeEventListener('paste', pasteListener) + }) +} \ No newline at end of file diff --git a/frontend/src/hooks/usePasteTextClipboardData.ts b/frontend/src/hooks/usePasteTextClipboardData.ts new file mode 100644 index 0000000000000000000000000000000000000000..de9d798c6bbcecaaea3a88f664492448bb3246f9 --- /dev/null +++ b/frontend/src/hooks/usePasteTextClipboardData.ts @@ -0,0 +1,98 @@ +import { storeToRefs } from 'pinia' +import { useKeyboardStore } from '@/store' +import { pasteCustomClipboardString } from '@/utils/clipboard' +import { parseText2Paragraphs } from '@/utils/textParser' +import { getImageDataURL, isSVGString, svg2File } from '@/utils/image' +import { isValidURL } from '@/utils/common' +import useCreateElement from '@/hooks/useCreateElement' +import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements' + +interface PasteTextClipboardDataOptions { + onlySlide?: boolean + onlyElements?: boolean +} + +/** + * 判断图片URL字符串 + * + * !!!注意,你需要判断允许哪些来源的图片地址被匹配,然后自行编写正则表达式 + * !!!必须确保图片来源都是合法、可靠、可控、无访问限制的 + */ +const isValidImgURL = (url: string) => { + const pexels = /^https?:\/\/(?:[a-zA-Z0-9-]+\.)*pexels\.com\/[^\s]+\.(?:jpg|jpeg|png|svg|webp)(?:\?.*)?$/i.test(url) + const pptist = /^https?:\/\/(?:[a-zA-Z0-9-]+\.)*pptist\.cn\/[^\s]+\.(?:jpg|jpeg|png|svg|webp)(?:\?.*)?$/i.test(url) + return pexels || pptist +} + +export default () => { + const { shiftKeyState } = storeToRefs(useKeyboardStore()) + + const { createTextElement, createImageElement } = useCreateElement() + const { addElementsFromData, addSlidesFromData } = useAddSlidesOrElements() + + /** + * 粘贴普通文本:创建为新的文本元素 + * @param text 文本 + */ + const createTextElementFromClipboard = (text: string) => { + createTextElement({ + left: 0, + top: 0, + width: 600, + height: 50, + }, { content: text }) + } + + /** + * 解析剪贴板内容,根据解析结果选择合适的粘贴方式 + * @param text 剪贴板内容 + * @param options 配置项:onlySlide -- 仅处理页面粘贴;onlyElements -- 仅处理元素粘贴; + */ + const pasteTextClipboardData = (text: string, options?: PasteTextClipboardDataOptions) => { + const onlySlide = options?.onlySlide || false + const onlyElements = options?.onlyElements || false + + const clipboardData = pasteCustomClipboardString(text) + + // 元素或页面 + if (typeof clipboardData === 'object') { + const { type, data } = clipboardData + + if (type === 'elements' && !onlySlide) addElementsFromData(data) + else if (type === 'slides' && !onlyElements) addSlidesFromData(data) + } + + // 普通文本 + else if (!onlyElements && !onlySlide) { + // 普通文字 + if (shiftKeyState.value) { + const string = parseText2Paragraphs(clipboardData) + createTextElementFromClipboard(string) + } + else { + // 尝试检查是否为图片地址链接 + if (isValidImgURL(clipboardData)) { + createImageElement(clipboardData) + } + // 尝试检查是否为超链接 + else if (isValidURL(clipboardData)) { + createTextElementFromClipboard(`${clipboardData}`) + } + // 尝试检查是否为SVG代码 + else if (isSVGString(clipboardData)) { + const file = svg2File(clipboardData) + getImageDataURL(file).then(dataURL => createImageElement(dataURL)) + } + // 普通文字 + else { + const string = parseText2Paragraphs(clipboardData) + createTextElementFromClipboard(string) + } + } + } + } + + return { + pasteTextClipboardData, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useScaleCanvas.ts b/frontend/src/hooks/useScaleCanvas.ts new file mode 100644 index 0000000000000000000000000000000000000000..28156abe444de94d2c26409bbb2888c980b5b2a9 --- /dev/null +++ b/frontend/src/hooks/useScaleCanvas.ts @@ -0,0 +1,50 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' + +export default () => { + const mainStore = useMainStore() + const { canvasPercentage, canvasScale, canvasDragged } = storeToRefs(mainStore) + + const canvasScalePercentage = computed(() => Math.round(canvasScale.value * 100) + '%') + + /** + * 缩放画布百分比 + * @param command 缩放命令:放大、缩小 + */ + const scaleCanvas = (command: '+' | '-') => { + let percentage = canvasPercentage.value + const step = 5 + const max = 200 + const min = 30 + if (command === '+' && percentage <= max) percentage += step + if (command === '-' && percentage >= min) percentage -= step + + mainStore.setCanvasPercentage(percentage) + } + + /** + * 设置画布缩放比例 + * 但不是直接设置该值,而是通过设置画布可视区域百分比来动态计算 + * @param value 目标画布缩放比例 + */ + const setCanvasScalePercentage = (value: number) => { + const percentage = Math.round(value / canvasScale.value * canvasPercentage.value) / 100 + mainStore.setCanvasPercentage(percentage) + } + + /** + * 重置画布尺寸和位置 + */ + const resetCanvas = () => { + mainStore.setCanvasPercentage(90) + if (canvasDragged) mainStore.setCanvasDragged(false) + } + + return { + canvasScalePercentage, + setCanvasScalePercentage, + scaleCanvas, + resetCanvas, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useScreening.ts b/frontend/src/hooks/useScreening.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b75be6433716d68f616f1adc1c1c856dc2345fd --- /dev/null +++ b/frontend/src/hooks/useScreening.ts @@ -0,0 +1,31 @@ +import { useScreenStore, useSlidesStore } from '@/store' +import { enterFullscreen, exitFullscreen, isFullscreen } from '@/utils/fullscreen' + +export default () => { + const screenStore = useScreenStore() + const slidesStore = useSlidesStore() + + // 进入放映状态(从当前页开始) + const enterScreening = () => { + enterFullscreen() + screenStore.setScreening(true) + } + + // 进入放映状态(从第一页开始) + const enterScreeningFromStart = () => { + slidesStore.updateSlideIndex(0) + enterScreening() + } + + // 退出放映状态 + const exitScreening = () => { + screenStore.setScreening(false) + if (isFullscreen()) exitFullscreen() + } + + return { + enterScreening, + enterScreeningFromStart, + exitScreening, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useSearch.ts b/frontend/src/hooks/useSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..52e3af161ef1d6f9ed5410e0eea34837a10eabcf --- /dev/null +++ b/frontend/src/hooks/useSearch.ts @@ -0,0 +1,444 @@ +import { nextTick, onBeforeUnmount, ref, watch } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTTableElement } from '@/types/slides' +import message from '@/utils/message' + +interface SearchTextResult { + elType: 'text' | 'shape' + slideId: string + elId: string +} +interface SearchTableResult { + elType: 'table' + slideId: string + elId: string + cellIndex: [number, number] +} + +type SearchResult = SearchTextResult | SearchTableResult + +type Modifiers = 'g' | 'gi' + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { handleElement } = storeToRefs(mainStore) + const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore) + + const searchWord = ref('') + const replaceWord = ref('') + const searchResults = ref([]) + const searchIndex = ref(-1) + + const modifiers = ref('g') + + const search = () => { + const textList: SearchResult[] = [] + const matchRegex = new RegExp(searchWord.value, modifiers.value) + const textRegex = /(<([^>]+)>)/g + + for (const slide of slides.value) { + for (const el of slide.elements) { + if (el.type === 'text') { + const text = el.content.replace(textRegex, '') + const rets = text.match(matchRegex) + rets && textList.push(...new Array(rets.length).fill({ + slideId: slide.id, + elId: el.id, + elType: el.type, + })) + } + else if (el.type === 'shape' && el.text && el.text.content) { + const text = el.text.content.replace(textRegex, '') + const rets = text.match(matchRegex) + rets && textList.push(...new Array(rets.length).fill({ + slideId: slide.id, + elId: el.id, + elType: el.type, + })) + } + else if (el.type === 'table') { + for (let i = 0; i < el.data.length; i++) { + const row = el.data[i] + for (let j = 0; j < row.length; j++) { + const cell = row[j] + if (!cell.text) continue + const text = cell.text.replace(textRegex, '') + const rets = text.match(matchRegex) + rets && textList.push(...new Array(rets.length).fill({ + slideId: slide.id, + elId: el.id, + elType: el.type, + cellIndex: [i, j], + })) + } + } + } + } + } + if (textList.length) { + searchResults.value = textList + searchIndex.value = 0 + highlightCurrentSlide() + } + else { + message.warning('未查找到匹配项') + clearMarks() + } + } + + const getTextNodeList = (dom: Node): Text[] => { + const nodeList = [...dom.childNodes] + const textNodes = [] + while (nodeList.length) { + const node = nodeList.shift()! + if (node.nodeType === node.TEXT_NODE) { + (node as Text).wholeText && textNodes.push(node as Text) + } + else { + nodeList.unshift(...node.childNodes) + } + } + return textNodes + } + + const getTextInfoList = (textNodes: Text[]) => { + let length = 0 + const textList = textNodes.map(node => { + const startIdx = length, endIdx = length + node.wholeText.length + length = endIdx + return { + text: node.wholeText, + startIdx, + endIdx + } + }) + return textList + } + + type TextInfoList = ReturnType + + const getMatchList = (content: string, keyword: string) => { + const reg = new RegExp(keyword, modifiers.value) + const matchList = [] + let match = reg.exec(content) + while (match) { + matchList.push(match) + match = reg.exec(content) + } + return matchList + } + + const highlight = (textNodes: Text[], textList: TextInfoList, matchList: RegExpExecArray[], index: number) => { + for (let i = matchList.length - 1; i >= 0; i--) { + const match = matchList[i] + const matchStart = match.index + const matchEnd = matchStart + match[0].length + + for (let textIdx = 0; textIdx < textList.length; textIdx++) { + const { text, startIdx, endIdx } = textList[textIdx] + if (endIdx < matchStart) continue + if (startIdx >= matchEnd) break + + let textNode = textNodes[textIdx] + const nodeMatchStartIdx = Math.max(0, matchStart - startIdx) + const nodeMatchLength = Math.min(endIdx, matchEnd) - startIdx - nodeMatchStartIdx + + if (nodeMatchStartIdx > 0) textNode = textNode.splitText(nodeMatchStartIdx) + if (nodeMatchLength < textNode.wholeText.length) textNode.splitText(nodeMatchLength) + + const mark = document.createElement('mark') + mark.dataset.index = index + i + '' + mark.innerText = text.substring(nodeMatchStartIdx, nodeMatchStartIdx + nodeMatchLength) + textNode.parentNode!.replaceChild(mark, textNode) + } + } + } + + const highlightTableText = (nodes: NodeListOf, index: number) => { + for (const node of nodes) { + node.innerHTML = node.innerHTML.replace(new RegExp(searchWord.value, modifiers.value), () => { + return `${searchWord.value}` + }) + } + } + + const clearMarks = () => { + const markNodes = document.querySelectorAll('.editable-element mark') + for (const mark of markNodes) { + setTimeout(() => { + const parentNode = mark.parentNode! + const text = mark.textContent! + parentNode.replaceChild(document.createTextNode(text), mark) + }, 0) + } + } + + const highlightCurrentSlide = () => { + clearMarks() + + setTimeout(() => { + for (let i = 0; i < searchResults.value.length; i++) { + const lastTarget = searchResults.value[i - 1] + const target = searchResults.value[i] + if (target.slideId !== currentSlide.value.id) continue + if (lastTarget && lastTarget.elId === target.elId) continue + + const node = document.querySelector(`#editable-element-${target.elId}`) + if (node) { + if (target.elType === 'table') { + const cells = node.querySelectorAll('.cell-text') + highlightTableText(cells, i) + } + else { + const textNodes = getTextNodeList(node) + const textList = getTextInfoList(textNodes) + const content = textList.map(({ text }) => text).join('') + const matchList = getMatchList(content, searchWord.value) + highlight(textNodes, textList, matchList, i) + } + } + } + }, 0) + } + + const setActiveMark = () => { + const markNodes = document.querySelectorAll('mark[data-index]') + for (const node of markNodes) { + setTimeout(() => { + const index = (node as HTMLElement).dataset.index + if (index !== undefined && +index === searchIndex.value) { + node.classList.add('active') + } + else node.classList.remove('active') + }, 0) + } + } + + const turnTarget = () => { + if (searchIndex.value === -1) return + + const target = searchResults.value[searchIndex.value] + + if (target.slideId === currentSlide.value.id) setTimeout(setActiveMark, 0) + else { + const index = slides.value.findIndex(slide => slide.id === target.slideId) + if (index !== -1) slidesStore.updateSlideIndex(index) + } + } + + const searchNext = () => { + if (!searchWord.value) return message.warning('请先输入查找内容') + mainStore.setActiveElementIdList([]) + if (searchIndex.value === -1) search() + else if (searchIndex.value < searchResults.value.length - 1) searchIndex.value += 1 + else searchIndex.value = 0 + turnTarget() + } + + const searchPrev = () => { + if (!searchWord.value) return message.warning('请先输入查找内容') + mainStore.setActiveElementIdList([]) + if (searchIndex.value === -1) search() + else if (searchIndex.value > 0) searchIndex.value -= 1 + else searchIndex.value = searchResults.value.length - 1 + turnTarget() + } + + const replace = () => { + if (!searchWord.value) return + if (searchIndex.value === -1) { + searchNext() + return + } + + const target = searchResults.value[searchIndex.value] + let targetElement = null + if (target.elType === 'table') { + const [i, j] = target.cellIndex + targetElement = document.querySelector(`#editable-element-${target.elId} .cell[data-cell-index="${i}_${j}"] .cell-text`) + } + else targetElement = document.querySelector(`#editable-element-${target.elId} .ProseMirror`) + if (!targetElement) return + + const fakeElement = document.createElement('div') + fakeElement.innerHTML = targetElement.innerHTML + + let replaced = false + const marks = fakeElement.querySelectorAll('mark[data-index]') + for (const mark of marks) { + const parentNode = mark.parentNode! + if (mark.classList.contains('active')) { + if (replaced) parentNode.removeChild(mark) + else { + parentNode.replaceChild(document.createTextNode(replaceWord.value), mark) + replaced = true + } + } + else { + const text = mark.textContent! + parentNode.replaceChild(document.createTextNode(text), mark) + } + } + + if (target.elType === 'text') { + const props = { content: fakeElement.innerHTML } + slidesStore.updateElement({ id: target.elId, props }) + } + else if (target.elType === 'shape') { + const el = currentSlide.value.elements.find(item => item.id === target.elId) + if (el && el.type === 'shape' && el.text) { + const props = { text: { ...el.text, content: fakeElement.innerHTML } } + slidesStore.updateElement({ id: target.elId, props }) + } + } + else if (target.elType === 'table') { + const el = currentSlide.value.elements.find(item => item.id === target.elId) + if (el && el.type === 'table') { + const data = el.data.map((row, i) => { + if (i === target.cellIndex[0]) { + return row.map((cell, j) => { + if (j === target.cellIndex[1]) { + return { + ...cell, + text: fakeElement.innerHTML, + } + } + return cell + }) + } + return row + }) + const props = { data } + slidesStore.updateElement({ id: target.elId, props }) + } + } + + searchResults.value.splice(searchIndex.value, 1) + if (searchResults.value.length) { + if (searchIndex.value > searchResults.value.length - 1) { + searchIndex.value = 0 + } + nextTick(() => { + highlightCurrentSlide() + turnTarget() + }) + } + else searchIndex.value = -1 + } + + const replaceAll = () => { + if (!searchWord.value) return + if (searchIndex.value === -1) { + searchNext() + return + } + + for (let i = 0; i < searchResults.value.length; i++) { + const lastTarget = searchResults.value[i - 1] + const target = searchResults.value[i] + if (lastTarget && lastTarget.elId === target.elId) continue + + const targetSlide = slides.value.find(item => item.id === target.slideId) + if (!targetSlide) continue + const targetElement = targetSlide.elements.find(item => item.id === target.elId) + if (!targetElement) continue + + const fakeElement = document.createElement('div') + if (targetElement.type === 'text') fakeElement.innerHTML = targetElement.content + else if (targetElement.type === 'shape') fakeElement.innerHTML = targetElement.text?.content || '' + + if (target.elType === 'table') { + const data = (targetElement as PPTTableElement).data.map(row => { + return row.map(cell => { + if (!cell.text) return cell + return { + ...cell, + text: cell.text.replace(new RegExp(searchWord.value, 'g'), replaceWord.value), + } + }) + }) + const props = { data } + slidesStore.updateElement({ id: target.elId, slideId: target.slideId, props }) + } + else { + const textNodes = getTextNodeList(fakeElement) + const textList = getTextInfoList(textNodes) + const content = textList.map(({ text }) => text).join('') + const matchList = getMatchList(content, searchWord.value) + highlight(textNodes, textList, matchList, i) + + const marks = fakeElement.querySelectorAll('mark[data-index]') + let lastMarkIndex = -1 + for (const mark of marks) { + const markIndex = +(mark as HTMLElement).dataset.index! + const parentNode = mark.parentNode! + if (markIndex === lastMarkIndex) parentNode.removeChild(mark) + else { + parentNode.replaceChild(document.createTextNode(replaceWord.value), mark) + lastMarkIndex = markIndex + } + } + + if (target.elType === 'text') { + const props = { content: fakeElement.innerHTML } + slidesStore.updateElement({ id: target.elId, slideId: target.slideId, props }) + } + else if (target.elType === 'shape') { + const el = currentSlide.value.elements.find(item => item.id === target.elId) + if (el && el.type === 'shape' && el.text) { + const props = { text: { ...el.text, content: fakeElement.innerHTML } } + slidesStore.updateElement({ id: target.elId, slideId: target.slideId, props }) + } + } + } + } + searchResults.value = [] + searchIndex.value = -1 + } + + const reset = () => { + searchIndex.value = -1 + searchResults.value = [] + + if (!searchWord.value) clearMarks() + } + + watch(searchWord, reset) + + watch(slideIndex, () => { + nextTick(() => { + highlightCurrentSlide() + setTimeout(setActiveMark, 0) + }) + }) + + watch(handleElement, () => { + if (handleElement.value) { + searchIndex.value = -1 + searchResults.value = [] + clearMarks() + } + }) + + onBeforeUnmount(clearMarks) + + const toggleModifiers = () => { + modifiers.value = modifiers.value === 'g' ? 'gi' : 'g' + reset() + } + + return { + searchWord, + replaceWord, + searchResults, + searchIndex, + modifiers, + searchNext, + searchPrev, + replace, + replaceAll, + toggleModifiers, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useSectionHandler.ts b/frontend/src/hooks/useSectionHandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..874779f42123ab6c30eaa6366a3446952c3a0021 --- /dev/null +++ b/frontend/src/hooks/useSectionHandler.ts @@ -0,0 +1,92 @@ +import { storeToRefs } from 'pinia' +import { nanoid } from 'nanoid' +import { useSlidesStore } from '@/store' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' +import useSlideHandler from '@/hooks/useSlideHandler' + +export default () => { + const slidesStore = useSlidesStore() + const { slides } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + const { deleteSlide } = useSlideHandler() + + const createSection = () => { + slidesStore.updateSlide({ + sectionTag: { + id: nanoid(6), + }, + }) + addHistorySnapshot() + } + + const removeSection = (sectionId: string) => { + if (!sectionId) return + + const slide = slides.value.find(slide => slide.sectionTag?.id === sectionId)! + slidesStore.removeSlideProps({ + id: slide.id, + propName: 'sectionTag', + }) + addHistorySnapshot() + } + + const removeAllSection = () => { + const _slides = slides.value.map(slide => { + if (slide.sectionTag) delete slide.sectionTag + return slide + }) + slidesStore.setSlides(_slides) + addHistorySnapshot() + } + + const removeSectionSlides = (sectionId: string) => { + let startIndex = 0 + if (sectionId) { + startIndex = slides.value.findIndex(slide => slide.sectionTag?.id === sectionId) + } + const ids: string[] = [] + + for (let i = startIndex; i < slides.value.length; i++) { + const slide = slides.value[i] + if (i !== startIndex && slide.sectionTag) break + + ids.push(slide.id) + } + + deleteSlide(ids) + } + + const updateSectionTitle = (sectionId: string, title: string) => { + if (!title) return + + if (sectionId === 'default') { + slidesStore.updateSlide({ + sectionTag: { + id: nanoid(6), + title, + }, + }, slides.value[0].id) + } + else { + const slide = slides.value.find(slide => slide.sectionTag?.id === sectionId) + if (!slide) return + + slidesStore.updateSlide({ + sectionTag: { + ...slide.sectionTag!, + title, + }, + }, slide.id) + } + addHistorySnapshot() + } + + return { + createSection, + removeSection, + removeAllSection, + removeSectionSlides, + updateSectionTitle, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useSelectElement.ts b/frontend/src/hooks/useSelectElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e543879c6c904c1aa1f53da608868d0bf45c0d0 --- /dev/null +++ b/frontend/src/hooks/useSelectElement.ts @@ -0,0 +1,31 @@ +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' + +export default () => { + const mainStore = useMainStore() + const { currentSlide } = storeToRefs(useSlidesStore()) + const { hiddenElementIdList, handleElementId } = storeToRefs(mainStore) + + // 将当前页面全部元素设置为被选择状态 + const selectAllElements = () => { + const unlockedElements = currentSlide.value.elements.filter(el => !el.lock && !hiddenElementIdList.value.includes(el.id)) + const newActiveElementIdList = unlockedElements.map(el => el.id) + mainStore.setActiveElementIdList(newActiveElementIdList) + } + + // 将指定元素设置为被选择状态 + const selectElement = (id: string) => { + if (handleElementId.value === id) return + if (hiddenElementIdList.value.includes(id)) return + + const lockedElements = currentSlide.value.elements.filter(el => el.lock) + if (lockedElements.some(el => el.id === id)) return + + mainStore.setActiveElementIdList([id]) + } + + return { + selectAllElements, + selectElement, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useShapeFormatPainter.ts b/frontend/src/hooks/useShapeFormatPainter.ts new file mode 100644 index 0000000000000000000000000000000000000000..362e0594fb0f46f378035361fc4fe65da93d816c --- /dev/null +++ b/frontend/src/hooks/useShapeFormatPainter.ts @@ -0,0 +1,28 @@ +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' +import type { PPTShapeElement } from '@/types/slides' + +export default () => { + const mainStore = useMainStore() + const { shapeFormatPainter, handleElement } = storeToRefs(mainStore) + + const toggleShapeFormatPainter = (keep = false) => { + const _handleElement = handleElement.value as PPTShapeElement + + if (shapeFormatPainter.value) mainStore.setShapeFormatPainter(null) + else { + mainStore.setShapeFormatPainter({ + keep, + fill: _handleElement.fill, + gradient: _handleElement.gradient, + outline: _handleElement.outline, + opacity: _handleElement.opacity, + shadow: _handleElement.shadow, + }) + } + } + + return { + toggleShapeFormatPainter, + } +} diff --git a/frontend/src/hooks/useSlideBackgroundStyle.ts b/frontend/src/hooks/useSlideBackgroundStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba12e6a338f70230e4c4e892b832c2046cb8e7a8 --- /dev/null +++ b/frontend/src/hooks/useSlideBackgroundStyle.ts @@ -0,0 +1,53 @@ +import { type Ref, computed } from 'vue' +import type { SlideBackground } from '@/types/slides' + +// 将页面背景数据转换为css样式 +export default (background: Ref) => { + const backgroundStyle = computed(() => { + if (!background.value) return { backgroundColor: '#fff' } + + const { + type, + color, + image, + gradient, + } = background.value + + // 纯色背景 + if (type === 'solid') return { backgroundColor: color } + + // 背景图模式 + // 包括:背景图、背景大小,是否重复 + else if (type === 'image' && image) { + const { src, size } = image + if (!src) return { backgroundColor: '#fff' } + if (size === 'repeat') { + return { + backgroundImage: `url(${src}`, + backgroundRepeat: 'repeat', + backgroundSize: 'contain', + } + } + return { + backgroundImage: `url(${src}`, + backgroundRepeat: 'no-repeat', + backgroundSize: size || 'cover', + } + } + + // 渐变色背景 + else if (type === 'gradient' && gradient) { + const { type, colors, rotate } = gradient + const list = colors.map(item => `${item.color} ${item.pos}%`) + + if (type === 'radial') return { backgroundImage: `radial-gradient(${list.join(',')}` } + return { backgroundImage: `linear-gradient(${rotate}deg, ${list.join(',')}` } + } + + return { backgroundColor: '#fff' } + }) + + return { + backgroundStyle, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useSlideHandler.ts b/frontend/src/hooks/useSlideHandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1d7fd524a1488681099b7a49c86008c4c2d4bb1 --- /dev/null +++ b/frontend/src/hooks/useSlideHandler.ts @@ -0,0 +1,191 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { nanoid } from 'nanoid' +import { useMainStore, useSlidesStore } from '@/store' +import type { Slide } from '@/types/slides' +import { copyText, readClipboard } from '@/utils/clipboard' +import { encrypt } from '@/utils/crypto' +import { createElementIdMap } from '@/utils/element' +import { KEYS } from '@/configs/hotkey' +import message from '@/utils/message' +import usePasteTextClipboardData from '@/hooks/usePasteTextClipboardData' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' +import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements' + +export default () => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { selectedSlidesIndex: _selectedSlidesIndex, activeElementIdList } = storeToRefs(mainStore) + const { currentSlide, slides, theme, slideIndex } = storeToRefs(slidesStore) + + const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value]) + const selectedSlides = computed(() => slides.value.filter((item, index) => selectedSlidesIndex.value.includes(index))) + const selectedSlidesId = computed(() => selectedSlides.value.map(item => item.id)) + + const { pasteTextClipboardData } = usePasteTextClipboardData() + const { addSlidesFromData } = useAddSlidesOrElements() + const { addHistorySnapshot } = useHistorySnapshot() + + // 重置幻灯片 + const resetSlides = () => { + const emptySlide: Slide = { + id: nanoid(10), + elements: [], + background: { + type: 'solid', + color: theme.value.backgroundColor, + }, + } + slidesStore.updateSlideIndex(0) + mainStore.setActiveElementIdList([]) + slidesStore.setSlides([emptySlide]) + } + + /** + * 移动页面焦点 + * @param command 移动页面焦点命令:上移、下移 + */ + const updateSlideIndex = (command: string) => { + if (command === KEYS.UP && slideIndex.value > 0) { + if (activeElementIdList.value.length) mainStore.setActiveElementIdList([]) + slidesStore.updateSlideIndex(slideIndex.value - 1) + } + else if (command === KEYS.DOWN && slideIndex.value < slides.value.length - 1) { + if (activeElementIdList.value.length) mainStore.setActiveElementIdList([]) + slidesStore.updateSlideIndex(slideIndex.value + 1) + } + } + + // 将当前页面数据加密后复制到剪贴板 + const copySlide = () => { + const text = encrypt(JSON.stringify({ + type: 'slides', + data: selectedSlides.value, + })) + + copyText(text).then(() => { + mainStore.setThumbnailsFocus(true) + }) + } + + // 尝试将剪贴板页面数据解密后添加到下一页(粘贴) + const pasteSlide = () => { + readClipboard().then(text => { + pasteTextClipboardData(text, { onlySlide: true }) + }).catch(err => message.warning(err)) + } + + // 创建一页空白页并添加到下一页 + const createSlide = () => { + const emptySlide: Slide = { + id: nanoid(10), + elements: [], + background: { + type: 'solid', + color: theme.value.backgroundColor, + }, + } + mainStore.setActiveElementIdList([]) + slidesStore.addSlide(emptySlide) + addHistorySnapshot() + } + + // 根据模板创建新页面 + const createSlideByTemplate = (slide: Slide) => { + const { groupIdMap, elIdMap } = createElementIdMap(slide.elements) + + for (const element of slide.elements) { + element.id = elIdMap[element.id] + if (element.groupId) element.groupId = groupIdMap[element.groupId] + } + const newSlide = { + ...slide, + id: nanoid(10), + } + mainStore.setActiveElementIdList([]) + slidesStore.addSlide(newSlide) + addHistorySnapshot() + } + + // 将当前页复制一份到下一页 + const copyAndPasteSlide = () => { + const slide = JSON.parse(JSON.stringify(currentSlide.value)) + addSlidesFromData([slide]) + } + + // 删除当前页,若将删除全部页面,则执行重置幻灯片操作 + const deleteSlide = (targetSlidesId = selectedSlidesId.value) => { + if (slides.value.length === targetSlidesId.length) resetSlides() + else slidesStore.deleteSlide(targetSlidesId) + + mainStore.updateSelectedSlidesIndex([]) + + addHistorySnapshot() + } + + // 将当前页复制后删除(剪切) + // 由于复制操作会导致多选状态消失,所以需要提前将需要删除的页面ID进行缓存 + const cutSlide = () => { + const targetSlidesId = [...selectedSlidesId.value] + copySlide() + deleteSlide(targetSlidesId) + } + + // 选中全部幻灯片 + const selectAllSlide = () => { + const newSelectedSlidesIndex = Array.from(Array(slides.value.length), (item, index) => index) + mainStore.setActiveElementIdList([]) + mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex) + } + + // 拖拽调整幻灯片顺序同步数据 + const sortSlides = (newIndex: number, oldIndex: number) => { + if (oldIndex === newIndex) return + + const _slides: Slide[] = JSON.parse(JSON.stringify(slides.value)) + + const movingSlide = _slides[oldIndex] + const movingSlideSection = movingSlide.sectionTag + if (movingSlideSection) { + const movingSlideSectionNext = _slides[oldIndex + 1] + delete movingSlide.sectionTag + if (movingSlideSectionNext && !movingSlideSectionNext.sectionTag) { + movingSlideSectionNext.sectionTag = movingSlideSection + } + } + if (newIndex === 0) { + const firstSection = _slides[0].sectionTag + if (firstSection) { + delete _slides[0].sectionTag + movingSlide.sectionTag = firstSection + } + } + + const _slide = _slides[oldIndex] + _slides.splice(oldIndex, 1) + _slides.splice(newIndex, 0, _slide) + slidesStore.setSlides(_slides) + slidesStore.updateSlideIndex(newIndex) + } + + const isEmptySlide = computed(() => { + if (slides.value.length > 1) return false + if (slides.value[0].elements.length > 0) return false + return true + }) + + return { + resetSlides, + updateSlideIndex, + copySlide, + pasteSlide, + createSlide, + createSlideByTemplate, + copyAndPasteSlide, + deleteSlide, + cutSlide, + selectAllSlide, + sortSlides, + isEmptySlide, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useSlideTheme.ts b/frontend/src/hooks/useSlideTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f3cbb2a944f523b7c3e4ff72aa5614932433537 --- /dev/null +++ b/frontend/src/hooks/useSlideTheme.ts @@ -0,0 +1,416 @@ +import tinycolor from 'tinycolor2' +import { storeToRefs } from 'pinia' +import { useSlidesStore } from '@/store' +import type { Slide } from '@/types/slides' +import type { PresetTheme } from '@/configs/theme' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' +import { getLineElementLength } from '@/utils/element' + +interface ThemeValueWithArea { + area: number + value: string +} + +export default () => { + const slidesStore = useSlidesStore() + const { slides, theme } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + // 获取指定幻灯片内的主要主题样式,并以在当中的占比进行排序 + const getSlidesThemeStyles = (slide: Slide | Slide[]) => { + const slides = Array.isArray(slide) ? slide : [slide] + + const backgroundColorValues: ThemeValueWithArea[] = [] + const themeColorValues: ThemeValueWithArea[] = [] + const fontColorValues: ThemeValueWithArea[] = [] + const fontNameValues: ThemeValueWithArea[] = [] + + for (const slide of slides) { + if (slide.background) { + if (slide.background.type === 'solid' && slide.background.color) { + backgroundColorValues.push({ area: 1, value: slide.background.color }) + } + else if (slide.background.type === 'gradient' && slide.background.gradient) { + const len = slide.background.gradient.colors.length + backgroundColorValues.push(...slide.background.gradient.colors.map(item => ({ + area: 1 / len, + value: item.color, + }))) + } + else backgroundColorValues.push({ area: 1, value: theme.value.backgroundColor }) + } + for (const el of slide.elements) { + const elWidth = el.width + let elHeight = 0 + if (el.type === 'line') { + const [startX, startY] = el.start + const [endX, endY] = el.end + elHeight = Math.sqrt(Math.pow(Math.abs(startX - endX), 2) + Math.pow(Math.abs(startY - endY), 2)) + } + else elHeight = el.height + + const area = elWidth * elHeight + + if (el.type === 'shape' || el.type === 'text') { + if (el.fill) { + themeColorValues.push({ area, value: el.fill }) + } + if (el.type === 'shape' && el.gradient) { + const len = el.gradient.colors.length + themeColorValues.push(...el.gradient.colors.map(item => ({ + area: 1 / len * area, + value: item.color, + }))) + } + + const text = (el.type === 'shape' ? el.text?.content : el.content) || '' + if (!text) continue + + const plainText = text.replace(/<[^>]+>/g, '').replace(/\s*/g, '') + const matchForColor = text.match(/<[^>]+color: .+?<\/.+?>/g) + const matchForFont = text.match(/<[^>]+font-family: .+?<\/.+?>/g) + + let defaultColorPercent = 1 + let defaultFontPercent = 1 + + if (matchForColor) { + for (const item of matchForColor) { + const ret = item.match(/color: (.+?);/) + if (!ret) continue + const text = item.replace(/<[^>]+>/g, '').replace(/\s*/g, '') + const color = ret[1] + const percentage = text.length / plainText.length + defaultColorPercent = defaultColorPercent - percentage + + fontColorValues.push({ + area: area * percentage, + value: color, + }) + } + } + if (matchForFont) { + for (const item of matchForFont) { + const ret = item.match(/font-family: (.+?);/) + if (!ret) continue + const text = item.replace(/<[^>]+>/g, '').replace(/\s*/g, '') + const font = ret[1] + const percentage = text.length / plainText.length + defaultFontPercent = defaultFontPercent - percentage + + fontNameValues.push({ + area: area * percentage, + value: font, + }) + } + } + + if (defaultColorPercent) { + const _defaultColor = el.type === 'shape' ? el.text?.defaultColor : el.defaultColor + const defaultColor = _defaultColor || theme.value.fontColor + fontColorValues.push({ + area: area * defaultColorPercent, + value: defaultColor, + }) + } + if (defaultFontPercent) { + const _defaultFont = el.type === 'shape' ? el.text?.defaultFontName : el.defaultFontName + const defaultFont = _defaultFont || theme.value.fontName + fontNameValues.push({ + area: area * defaultFontPercent, + value: defaultFont, + }) + } + } + else if (el.type === 'table') { + const cellCount = el.data.length * el.data[0].length + let cellWithFillCount = 0 + for (const row of el.data) { + for (const cell of row) { + if (cell.style?.backcolor) { + cellWithFillCount += 1 + themeColorValues.push({ area: area / cellCount, value: cell.style?.backcolor }) + } + if (cell.text) { + const percent = (cell.text.length >= 10) ? 1 : (cell.text.length / 10) + if (cell.style?.color) { + fontColorValues.push({ area: area / cellCount * percent, value: cell.style?.color }) + } + if (cell.style?.fontname) { + fontColorValues.push({ area: area / cellCount * percent, value: cell.style?.fontname }) + } + } + } + } + if (el.theme) { + const percent = 1 - cellWithFillCount / cellCount + themeColorValues.push({ area: area * percent, value: el.theme.color }) + } + } + else if (el.type === 'chart') { + if (el.fill) { + themeColorValues.push({ area: area * 0.5, value: el.fill }) + } + themeColorValues.push({ area: area * 0.5, value: el.themeColors[0] }) + } + else if (el.type === 'line') { + themeColorValues.push({ area, value: el.color }) + } + else if (el.type === 'audio') { + themeColorValues.push({ area, value: el.color }) + } + else if (el.type === 'latex') { + fontColorValues.push({ area, value: el.color }) + } + } + } + + const backgroundColors: { [key: string]: number } = {} + for (const item of backgroundColorValues) { + const color = tinycolor(item.value).toRgbString() + if (color === 'rgba(0, 0, 0, 0)') continue + if (!backgroundColors[color]) backgroundColors[color] = item.area + else backgroundColors[color] += item.area + } + + const themeColors: { [key: string]: number } = {} + for (const item of themeColorValues) { + const color = tinycolor(item.value).toRgbString() + if (color === 'rgba(0, 0, 0, 0)') continue + if (!themeColors[color]) themeColors[color] = item.area + else themeColors[color] += item.area + } + + const fontColors: { [key: string]: number } = {} + for (const item of fontColorValues) { + const color = tinycolor(item.value).toRgbString() + if (color === 'rgba(0, 0, 0, 0)') continue + if (!fontColors[color]) fontColors[color] = item.area + else fontColors[color] += item.area + } + + const fontNames: { [key: string]: number } = {} + for (const item of fontNameValues) { + if (!fontNames[item.value]) fontNames[item.value] = item.area + else fontNames[item.value] += item.area + } + + return { + backgroundColors: Object.keys(backgroundColors).sort((a, b) => backgroundColors[b] - backgroundColors[a]), + themeColors: Object.keys(themeColors).sort((a, b) => themeColors[b] - themeColors[a]), + fontColors: Object.keys(fontColors).sort((a, b) => fontColors[b] - fontColors[a]), + fontNames: Object.keys(fontNames).sort((a, b) => fontNames[b] - fontNames[a]), + } + } + + // 获取指定幻灯片内的主要颜色(忽略透明度),并按颜色面积排序 + const getSlideAllColors = (slide: Slide) => { + const colorMap: { [key: string]: number } = {} + + const record = (color: string, area: number) => { + const _color = tinycolor(color).setAlpha(1).toRgbString() + if (!colorMap[_color]) colorMap[_color] = area + else colorMap[_color] = colorMap[_color] + area + } + + for (const el of slide.elements) { + const width = el.width + const height = el.type === 'line' ? getLineElementLength(el) : el.height + const area = width * height + + if (el.type === 'shape' && tinycolor(el.fill).getAlpha() !== 0) { + record(el.fill, area) + } + if (el.type === 'text' && el.fill && tinycolor(el.fill).getAlpha() !== 0) { + record(el.fill, area) + } + if (el.type === 'image' && el.colorMask && tinycolor(el.colorMask).getAlpha() !== 0) { + record(el.colorMask, area) + } + if (el.type === 'table' && el.theme && tinycolor(el.theme.color).getAlpha() !== 0) { + record(el.theme.color, area) + } + if (el.type === 'chart' && el.themeColors[0] && tinycolor(el.themeColors[0]).getAlpha() !== 0) { + record(el.themeColors[0], area) + } + if (el.type === 'line' && tinycolor(el.color).getAlpha() !== 0) { + record(el.color, area) + } + if (el.type === 'audio' && tinycolor(el.color).getAlpha() !== 0) { + record(el.color, area) + } + } + const colors = Object.keys(colorMap).sort((a, b) => colorMap[b] - colorMap[a]) + return colors + } + + // 创建原颜色与新颜色的对应关系表 + const createSlideThemeColorMap = (slide: Slide, _newColors: string[]): { [key: string]: string } => { + const newColors = [..._newColors] + const oldColors = getSlideAllColors(slide) + const themeColorMap: { [key: string]: string } = {} + + if (oldColors.length > newColors.length) { + const analogous = tinycolor(newColors[0]).analogous(oldColors.length - newColors.length + 10) + const otherColors = analogous.map(item => item.toHexString()).slice(1) + newColors.push(...otherColors) + } + for (let i = 0; i < oldColors.length; i++) { + themeColorMap[oldColors[i]] = newColors[i] + } + + return themeColorMap + } + + // 设置幻灯片主题 + const setSlideTheme = (slide: Slide, theme: PresetTheme) => { + const colorMap = createSlideThemeColorMap(slide, theme.colors) + + const getColor = (color: string) => { + const alpha = tinycolor(color).getAlpha() + const _color = colorMap[tinycolor(color).setAlpha(1).toRgbString()] + return _color ? tinycolor(_color).setAlpha(alpha).toRgbString() : color + } + + if (!slide.background || slide.background.type !== 'image') { + slide.background = { + type: 'solid', + color: theme.background, + } + } + for (const el of slide.elements) { + if (el.type === 'shape') { + if (el.fill) el.fill = getColor(el.fill) + if (el.gradient) delete el.gradient + if (el.text) { + el.text.defaultColor = theme.fontColor + el.text.defaultFontName = theme.fontname + if(el.text.content) el.text.content = el.text.content.replace(/color: .+?;/g, '').replace(/font-family: .+?;/g, '') + } + } + if (el.type === 'text') { + if (el.fill) el.fill = getColor(el.fill) + el.defaultColor = theme.fontColor + el.defaultFontName = theme.fontname + if(el.content) el.content = el.content.replace(/color: .+?;/g, '').replace(/font-family: .+?;/g, '') + } + if (el.type === 'image' && el.colorMask) { + el.colorMask = getColor(el.colorMask) + } + if (el.type === 'table') { + if (el.theme) el.theme.color = getColor(el.theme.color) + for (const rowCells of el.data) { + for (const cell of rowCells) { + if (cell.style) { + cell.style.color = theme.fontColor + cell.style.fontname = theme.fontname + } + } + } + } + if (el.type === 'chart') { + el.themeColors = getColor(el.themeColors[0]) ? [getColor(el.themeColors[0])] : el.themeColors + el.textColor = theme.fontColor + } + if (el.type === 'line') el.color = getColor(el.color) + if (el.type === 'audio') el.color = getColor(el.color) + if (el.type === 'latex') el.color = theme.fontColor + + if ('outline' in el && el.outline) { + el.outline.color = theme.borderColor + } + } + } + + // 应用预置主题 + const applyPresetTheme = (theme: PresetTheme, resetSlides = false) => { + slidesStore.setTheme({ + backgroundColor: theme.background, + themeColors: theme.colors, + fontColor: theme.fontColor, + outline: { + width: 2, + style: 'solid', + color: theme.borderColor, + }, + fontName: theme.fontname, + }) + + if (resetSlides) { + const newSlides: Slide[] = JSON.parse(JSON.stringify(slides.value)) + for (const slide of newSlides) { + setSlideTheme(slide, theme) + } + slidesStore.setSlides(newSlides) + addHistorySnapshot() + } + } + + // 将当前主题配置应用到全部页面 + const applyThemeToAllSlides = (applyAll = false) => { + const newSlides: Slide[] = JSON.parse(JSON.stringify(slides.value)) + const { themeColors, backgroundColor, fontColor, fontName, outline, shadow } = theme.value + + for (const slide of newSlides) { + if (!slide.background || slide.background.type !== 'image') { + slide.background = { + type: 'solid', + color: backgroundColor + } + } + + for (const el of slide.elements) { + if (applyAll) { + if ('outline' in el && el.outline) el.outline = outline + if ('shadow' in el && el.shadow) el.shadow = shadow + } + + if (el.type === 'shape') { + const alpha = tinycolor(el.fill).getAlpha() + if (alpha > 0) el.fill = themeColors[0] + if (el.text) { + el.text.defaultColor = fontColor + el.text.defaultFontName = fontName + if(el.text.content) el.text.content = el.text.content.replace(/color: .+?;/g, '').replace(/font-family: .+?;/g, '') + } + if (el.gradient) delete el.gradient + } + else if (el.type === 'line') el.color = themeColors[0] + else if (el.type === 'text') { + if (el.fill) { + const alpha = tinycolor(el.fill).getAlpha() + if (alpha > 0) el.fill = themeColors[0] + } + el.defaultColor = fontColor + el.defaultFontName = fontName + if(el.content) el.content = el.content.replace(/color: .+?;/g, '').replace(/font-family: .+?;/g, '') + } + else if (el.type === 'table') { + if (el.theme) el.theme.color = themeColors[0] + for (const rowCells of el.data) { + for (const cell of rowCells) { + if (cell.style) { + cell.style.color = fontColor + cell.style.fontname = fontName + } + } + } + } + else if (el.type === 'chart') { + el.themeColors = themeColors + el.textColor = fontColor + } + else if (el.type === 'latex') el.color = fontColor + else if (el.type === 'audio') el.color = themeColors[0] + } + } + slidesStore.setSlides(newSlides) + addHistorySnapshot() + } + + return { + getSlidesThemeStyles, + applyPresetTheme, + applyThemeToAllSlides, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useTextFormatPainter.ts b/frontend/src/hooks/useTextFormatPainter.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eaeb86f732aeaa86c010c5fe45816bf3d99dfd6 --- /dev/null +++ b/frontend/src/hooks/useTextFormatPainter.ts @@ -0,0 +1,29 @@ +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' + +export default () => { + const mainStore = useMainStore() + const { richTextAttrs, textFormatPainter } = storeToRefs(mainStore) + + const toggleTextFormatPainter = (keep = false) => { + if (textFormatPainter.value) mainStore.setTextFormatPainter(null) + else { + mainStore.setTextFormatPainter({ + keep, + bold: richTextAttrs.value.bold, + em: richTextAttrs.value.em, + underline: richTextAttrs.value.underline, + strikethrough: richTextAttrs.value.strikethrough, + color: richTextAttrs.value.color, + backcolor: richTextAttrs.value.backcolor, + fontname: richTextAttrs.value.fontname, + fontsize: richTextAttrs.value.fontsize, + align: richTextAttrs.value.align, + }) + } + } + + return { + toggleTextFormatPainter, + } +} diff --git a/frontend/src/hooks/useUniformDisplayElement.ts b/frontend/src/hooks/useUniformDisplayElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d9de54a9c9b8112daf0e8849144c13354f29f9e --- /dev/null +++ b/frontend/src/hooks/useUniformDisplayElement.ts @@ -0,0 +1,261 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import { getElementRange, getElementListRange, getRectRotatedOffset } from '@/utils/element' +import useHistorySnapshot from './useHistorySnapshot' + +interface ElementItem { + min: number + max: number + el: PPTElement +} + +interface GroupItem { + groupId: string + els: PPTElement[] +} + +interface GroupElementsItem { + min: number + max: number + els: PPTElement[] +} + +type Item = ElementItem | GroupElementsItem + +interface ElementWithPos { + pos: number + el: PPTElement +} + +interface LastPos { + min: number + max: number +} + +export default () => { + const slidesStore = useSlidesStore() + const { activeElementIdList, activeElementList } = storeToRefs(useMainStore()) + const { currentSlide } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + const displayItemCount = computed(() => { + let count = 0 + const groupIdList: string[] = [] + for (const el of activeElementList.value) { + if (!el.groupId) count += 1 + else if (!groupIdList.includes(el.groupId)) { + groupIdList.push(el.groupId) + count += 1 + } + } + return count + }) + // 水平均匀排列 + const uniformHorizontalDisplay = () => { + const { minX, maxX } = getElementListRange(activeElementList.value) + const copyOfActiveElementList: PPTElement[] = JSON.parse(JSON.stringify(activeElementList.value)) + const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + + // 分别获取普通元素和组合元素集合,并记录下每一项的范围 + const singleElemetList: ElementItem[] = [] + let groupList: GroupItem[] = [] + for (const el of copyOfActiveElementList) { + if (!el.groupId) { + const { minX, maxX } = getElementRange(el) + singleElemetList.push({ min: minX, max: maxX, el }) + } + else { + const groupEl = groupList.find(item => item.groupId === el.groupId) + if (!groupEl) groupList.push({ groupId: el.groupId, els: [el] }) + else { + groupList = groupList.map(item => item.groupId === el.groupId ? { ...item, els: [...item.els, el] } : item) + } + } + } + const formatedGroupList: GroupElementsItem[] = [] + for (const groupItem of groupList) { + const { minX, maxX } = getElementListRange(groupItem.els) + formatedGroupList.push({ min: minX, max: maxX, els: groupItem.els }) + } + + // 将普通元素和组合元素集合组合在一起,然后将每一项按位置(从左到右)排序 + const list: Item[] = [...singleElemetList, ...formatedGroupList] + list.sort((itemA, itemB) => itemA.min - itemB.min) + + // 计算元素均匀分布所需要的间隔: + // (所选元素整体范围 - 所有所选元素宽度和) / (所选元素数 - 1) + let totalWidth = 0 + for (const item of list) { + const width = item.max - item.min + totalWidth += width + } + const span = ((maxX - minX) - totalWidth) / (list.length - 1) + + // 按位置顺序依次计算每一个元素的目标位置 + // 第一项中的元素即为起点,无需计算 + // 从第二项开始,每一项的位置应该为:上一项位置 + 上一项宽度 + 间隔 + // 注意此处计算的位置(pos)并非元素最终的left值,而是目标位置范围最小值(元素旋转后的left值 ≠ 范围最小值) + const sortedElementData: ElementWithPos[] = [] + + const firstItem = list[0] + let lastPos: LastPos = { min: firstItem.min, max: firstItem.max } + + if ('el' in firstItem) { + sortedElementData.push({ pos: firstItem.min, el: firstItem.el }) + } + else { + for (const el of firstItem.els) { + const { minX: pos } = getElementRange(el) + sortedElementData.push({ pos, el }) + } + } + + for (let i = 1; i < list.length; i++) { + const item = list[i] + const lastWidth = lastPos.max - lastPos.min + const currentPos = lastPos.min + lastWidth + span + const currentWidth = item.max - item.min + lastPos = { min: currentPos, max: currentPos + currentWidth } + + if ('el' in item) { + sortedElementData.push({ pos: currentPos, el: item.el }) + } + else { + for (const el of item.els) { + const { minX } = getElementRange(el) + const offset = minX - item.min + sortedElementData.push({ pos: currentPos + offset, el }) + } + } + } + + // 根据目标位置计算元素最终目标left值 + // 对于旋转后的元素,需要计算旋转前后left的偏移来做校正 + for (const element of newElementList) { + if (!activeElementIdList.value.includes(element.id)) continue + + for (const sortedItem of sortedElementData) { + if (sortedItem.el.id === element.id) { + if ('rotate' in element && element.rotate) { + const { offsetX } = getRectRotatedOffset({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + element.left = sortedItem.pos - offsetX + } + else element.left = sortedItem.pos + } + } + } + + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + // 垂直均匀排列(逻辑类似水平均匀排列方法) + const uniformVerticalDisplay = () => { + const { minY, maxY } = getElementListRange(activeElementList.value) + const copyOfActiveElementList: PPTElement[] = JSON.parse(JSON.stringify(activeElementList.value)) + const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.value.elements)) + + const singleElemetList: ElementItem[] = [] + let groupList: GroupItem[] = [] + for (const el of copyOfActiveElementList) { + if (!el.groupId) { + const { minY, maxY } = getElementRange(el) + singleElemetList.push({ min: minY, max: maxY, el }) + } + else { + const groupEl = groupList.find(item => item.groupId === el.groupId) + if (!groupEl) groupList.push({ groupId: el.groupId, els: [el] }) + else { + groupList = groupList.map(item => item.groupId === el.groupId ? { ...item, els: [...item.els, el] } : item) + } + } + } + const formatedGroupList: GroupElementsItem[] = [] + for (const groupItem of groupList) { + const { minY, maxY } = getElementListRange(groupItem.els) + formatedGroupList.push({ min: minY, max: maxY, els: groupItem.els }) + } + + const list: Item[] = [...singleElemetList, ...formatedGroupList] + list.sort((itemA, itemB) => itemA.min - itemB.min) + + let totalHeight = 0 + for (const item of list) { + const height = item.max - item.min + totalHeight += height + } + const span = ((maxY - minY) - totalHeight) / (list.length - 1) + + const sortedElementData: ElementWithPos[] = [] + + const firstItem = list[0] + let lastPos: LastPos = { min: firstItem.min, max: firstItem.max } + + if ('el' in firstItem) { + sortedElementData.push({ pos: firstItem.min, el: firstItem.el }) + } + else { + for (const el of firstItem.els) { + const { minY: pos } = getElementRange(el) + sortedElementData.push({ pos, el }) + } + } + + for (let i = 1; i < list.length; i++) { + const item = list[i] + const lastHeight = lastPos.max - lastPos.min + const currentPos = lastPos.min + lastHeight + span + const currentHeight = item.max - item.min + lastPos = { min: currentPos, max: currentPos + currentHeight } + + if ('el' in item) { + sortedElementData.push({ pos: currentPos, el: item.el }) + } + else { + for (const el of item.els) { + const { minY } = getElementRange(el) + const offset = minY - item.min + sortedElementData.push({ pos: currentPos + offset, el }) + } + } + } + + for (const element of newElementList) { + if (!activeElementIdList.value.includes(element.id)) continue + + for (const sortedItem of sortedElementData) { + if (sortedItem.el.id === element.id) { + if ('rotate' in element && element.rotate) { + const { offsetY } = getRectRotatedOffset({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + element.top = sortedItem.pos - offsetY + } + else element.top = sortedItem.pos + } + } + } + + slidesStore.updateSlide({ elements: newElementList }) + addHistorySnapshot() + } + + return { + displayItemCount, + uniformHorizontalDisplay, + uniformVerticalDisplay, + } +} \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..b316dcda08f23ad60a05963b28d084ee68dc9179 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,19 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +import '@icon-park/vue-next/styles/index.css' +import 'prosemirror-view/style/prosemirror.css' +import 'animate.css' +import '@/assets/styles/prosemirror.scss' +import '@/assets/styles/global.scss' +import '@/assets/styles/font.scss' + +import Icon from '@/plugins/icon' +import Directive from '@/plugins/directive' + +const app = createApp(App) +app.use(Icon) +app.use(Directive) +app.use(createPinia()) +app.mount('#app') diff --git a/frontend/src/plugins/directive/clickOutside.ts b/frontend/src/plugins/directive/clickOutside.ts new file mode 100644 index 0000000000000000000000000000000000000000..0037abd966d4f4d0476630e6d5a1eca9c591df44 --- /dev/null +++ b/frontend/src/plugins/directive/clickOutside.ts @@ -0,0 +1,35 @@ +import type { Directive, DirectiveBinding } from 'vue' + +const CTX_CLICK_OUTSIDE_HANDLER = 'CTX_CLICK_OUTSIDE_HANDLER' + +interface CustomHTMLElement extends HTMLElement { + [CTX_CLICK_OUTSIDE_HANDLER]?: (event: MouseEvent) => void +} + +const clickListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => { + const handler = binding.value + + const path = event.composedPath() + const isClickOutside = path ? path.indexOf(el) < 0 : !el.contains(event.target as HTMLElement) + + if (!isClickOutside) return + handler(event) +} + +const ClickOutsideDirective: Directive = { + mounted(el: CustomHTMLElement, binding) { + el[CTX_CLICK_OUTSIDE_HANDLER] = (event: MouseEvent) => clickListener(el, event, binding) + setTimeout(() => { + document.addEventListener('click', el[CTX_CLICK_OUTSIDE_HANDLER]!) + }, 0) + }, + + unmounted(el: CustomHTMLElement) { + if (el[CTX_CLICK_OUTSIDE_HANDLER]) { + document.removeEventListener('click', el[CTX_CLICK_OUTSIDE_HANDLER]) + delete el[CTX_CLICK_OUTSIDE_HANDLER] + } + }, +} + +export default ClickOutsideDirective \ No newline at end of file diff --git a/frontend/src/plugins/directive/contextmenu.ts b/frontend/src/plugins/directive/contextmenu.ts new file mode 100644 index 0000000000000000000000000000000000000000..76dbb11621606f053d90c0d70b8de2c6472e60a8 --- /dev/null +++ b/frontend/src/plugins/directive/contextmenu.ts @@ -0,0 +1,64 @@ +import { type Directive, type DirectiveBinding, createVNode, render } from 'vue' +import ContextmenuComponent from '@/components/Contextmenu/index.vue' + +const CTX_CONTEXTMENU_HANDLER = 'CTX_CONTEXTMENU_HANDLER' + +interface CustomHTMLElement extends HTMLElement { + [CTX_CONTEXTMENU_HANDLER]?: (event: MouseEvent) => void +} + +const contextmenuListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => { + event.stopPropagation() + event.preventDefault() + + const menus = binding.value(el) + if (!menus) return + + let container: HTMLDivElement | null = null + + // 移除右键菜单并取消相关的事件监听 + const removeContextmenu = () => { + if (container) { + document.body.removeChild(container) + container = null + } + el.classList.remove('contextmenu-active') + document.body.removeEventListener('scroll', removeContextmenu) + window.removeEventListener('resize', removeContextmenu) + } + + // 创建自定义菜单 + const options = { + axis: { x: event.x, y: event.y }, + el, + menus, + removeContextmenu, + } + container = document.createElement('div') + const vm = createVNode(ContextmenuComponent, options, null) + render(vm, container) + document.body.appendChild(container) + + // 为目标节点添加菜单激活状态的className + el.classList.add('contextmenu-active') + + // 页面变化时移除菜单 + document.body.addEventListener('scroll', removeContextmenu) + window.addEventListener('resize', removeContextmenu) +} + +const ContextmenuDirective: Directive = { + mounted(el: CustomHTMLElement, binding) { + el[CTX_CONTEXTMENU_HANDLER] = (event: MouseEvent) => contextmenuListener(el, event, binding) + el.addEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER]) + }, + + unmounted(el: CustomHTMLElement) { + if (el && el[CTX_CONTEXTMENU_HANDLER]) { + el.removeEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER]) + delete el[CTX_CONTEXTMENU_HANDLER] + } + }, +} + +export default ContextmenuDirective \ No newline at end of file diff --git a/frontend/src/plugins/directive/index.ts b/frontend/src/plugins/directive/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2ee91c926be06822142aedb37611835646f850a --- /dev/null +++ b/frontend/src/plugins/directive/index.ts @@ -0,0 +1,13 @@ +import type { App } from 'vue' + +import Contextmenu from './contextmenu' +import ClickOutside from './clickOutside' +import Tooltip from './tooltip' + +export default { + install(app: App) { + app.directive('contextmenu', Contextmenu) + app.directive('click-outside', ClickOutside) + app.directive('tooltip', Tooltip) + } +} diff --git a/frontend/src/plugins/directive/tooltip.scss b/frontend/src/plugins/directive/tooltip.scss new file mode 100644 index 0000000000000000000000000000000000000000..4d7a899a65216bb0041882e2e69cd795e1a346fc --- /dev/null +++ b/frontend/src/plugins/directive/tooltip.scss @@ -0,0 +1,67 @@ +.tippy-box[data-theme~='tooltip'] { + background-color: #262626; + color: #fff; + border-radius: $borderRadius; + padding: 8px; + font-size: 12px; + line-height: 1.5; + + .tippy-arrow { + width: 12px; + height: 12px; + color: #262626; + + &::before { + content: ''; + position: absolute; + border-color: transparent; + border-style: solid; + } + } + + &[data-placement^='top'] > .tippy-arrow { + bottom: 0; + + &::before { + bottom: -5px; + left: 0; + border-width: 6px 6px 0; + border-top-color: initial; + transform-origin: center top; + } + } + + &[data-placement^='bottom'] > .tippy-arrow { + top: 0; + + &::before { + top: -5px; + left: 0; + border-width: 0 6px 6px; + border-bottom-color: initial; + transform-origin: center bottom; + } + } + + &[data-placement^='left'] > .tippy-arrow { + right: 0; + + &::before { + border-width: 6px 0 6px 6px; + border-left-color: initial; + right: -5px; + transform-origin: center left; + } + } + + &[data-placement^='right'] > .tippy-arrow { + left: 0; + + &::before { + left: -5px; + border-width: 6px 6px 6px 0; + border-right-color: initial; + transform-origin: center right; + } + } +} \ No newline at end of file diff --git a/frontend/src/plugins/directive/tooltip.ts b/frontend/src/plugins/directive/tooltip.ts new file mode 100644 index 0000000000000000000000000000000000000000..40d6b0ea27abe2d0ab0a9c4e2da77621d06ae4c2 --- /dev/null +++ b/frontend/src/plugins/directive/tooltip.ts @@ -0,0 +1,62 @@ +import type { Directive, DirectiveBinding } from 'vue' +import tippy, { type Instance, type Placement } from 'tippy.js' + +import './tooltip.scss' + +const TOOLTIP_INSTANCE = 'TOOLTIP_INSTANCE' + +interface CustomHTMLElement extends HTMLElement { + [TOOLTIP_INSTANCE]?: Instance +} + +type Delay = number | [number | null, number | null] + +interface BindingValue { + content: string + placement?: Placement + delay?: Delay +} + +const TooltipDirective: Directive = { + mounted(el: CustomHTMLElement, binding: DirectiveBinding) { + let content = '' + let placement: Placement = 'top' + let delay: Delay = [300, 0] + + if (typeof binding.value === 'string') { + content = binding.value + } + else { + content = binding.value.content + if (binding.value.placement !== undefined) placement = binding.value.placement + if (binding.value.delay !== undefined) delay = binding.value.delay + } + + el[TOOLTIP_INSTANCE] = tippy(el, { + content, + theme: 'tooltip', + duration: 100, + animation: 'scale', + allowHTML: true, + placement, + delay, + }) + }, + + updated(el: CustomHTMLElement, binding: DirectiveBinding) { + let content = '' + if (typeof binding.value === 'string') { + content = binding.value + } + else { + content = binding.value.content + } + if (el[TOOLTIP_INSTANCE]) el[TOOLTIP_INSTANCE].setContent(content) + }, + + unmounted(el: CustomHTMLElement) { + if (el[TOOLTIP_INSTANCE]) el[TOOLTIP_INSTANCE].destroy() + }, +} + +export default TooltipDirective \ No newline at end of file diff --git a/frontend/src/plugins/icon.ts b/frontend/src/plugins/icon.ts new file mode 100644 index 0000000000000000000000000000000000000000..91bebf6c66476c07456939f5a8666c4e24e6ea16 --- /dev/null +++ b/frontend/src/plugins/icon.ts @@ -0,0 +1,272 @@ +// https://iconpark.bytedance.com/official + +import type { App } from 'vue' +import { + PlayOne, + FullScreenPlay, + Lock, + Unlock, + Ppt, + Format, + Picture, + FullScreen, + List, + OrderedList, + FlipVertically, + FlipHorizontally, + FontSize, + Code, + TextBold, + TextItalic, + TextUnderline, + Strikethrough, + Edit, + Quote, + BackgroundColor, + Group, + Ungroup, + Back, + Next, + Fullwidth, + AlignTop, + AlignLeft, + AlignRight, + AlignBottom, + AlignVertically, + AlignHorizontally, + BringToFront, + SendToBack, + Send, + AlignTextLeft, + AlignTextRight, + AlignTextCenter, + AlignTextBoth, + RowHeight, + Write, + InsertTable, + AddText, + Fill, + Tailoring, + Effects, + ColorFilter, + Up, + Down, + Plus, + Minus, + Connection, + BringToFrontOne, + SentToBack, + Github, + ChartProportion, + ChartHistogram, + ChartHistogramOne, + ChartLineArea, + ChartRing, + ChartScatter, + ChartLine, + ChartPie, + RadarChart, + Text, + Rotate, + LeftTwo, + RightTwo, + Platte, + Close, + CloseSmall, + Undo, + Transform, + Click, + Theme, + ArrowCircleLeft, + ArrowRight, + GraphicDesign, + Logout, + Erase, + Clear, + AlignTextTopOne, + AlignTextBottomOne, + AlignTextMiddleOne, + Pause, + VolumeMute, + VolumeNotice, + VolumeSmall, + VideoTwo, + Formula, + LinkOne, + FullScreenOne, + OffScreenOne, + Power, + ListView, + Magic, + HighLight, + Download, + IndentLeft, + IndentRight, + VerticalSpacingBetweenItems, + Copy, + Delete, + Square, + Round, + Needle, + TextRotationNone, + TextRotationDown, + FormatBrush, + PreviewOpen, + PreviewClose, + StopwatchStart, + Search, + Left, + Right, + MoveOne, + HamburgerButton, + Attention, + CheckOne, + CloseOne, + Info, + Comment, + User, + Switch, + More, + Share, +} from '@icon-park/vue-next' + +export interface Icons { + [key: string]: typeof PlayOne +} + +export const icons: Icons = { + IconPlayOne: PlayOne, + IconFullScreenPlay: FullScreenPlay, + IconLock: Lock, + IconUnlock: Unlock, + IconPpt: Ppt, + IconFormat: Format, + IconPicture: Picture, + IconFullScreen: FullScreen, + IconList: List, + IconOrderedList: OrderedList, + IconFlipVertically: FlipVertically, + IconFlipHorizontally: FlipHorizontally, + IconFontSize: FontSize, + IconCode: Code, + IconTextBold: TextBold, + IconTextItalic: TextItalic, + IconTextUnderline: TextUnderline, + IconStrikethrough: Strikethrough, + IconEdit: Edit, + IconQuote: Quote, + IconBackgroundColor: BackgroundColor, + IconGroup: Group, + IconUngroup: Ungroup, + IconBack: Back, + IconNext: Next, + IconFullwidth: Fullwidth, + IconAlignTop: AlignTop, + IconAlignLeft: AlignLeft, + IconAlignRight: AlignRight, + IconAlignBottom: AlignBottom, + IconAlignVertically: AlignVertically, + IconAlignHorizontally: AlignHorizontally, + IconBringToFront: BringToFront, + IconSendToBack: SendToBack, + IconSend: Send, + IconAlignTextLeft: AlignTextLeft, + IconAlignTextRight: AlignTextRight, + IconAlignTextCenter: AlignTextCenter, + IconAlignTextBoth: AlignTextBoth, + IconRowHeight: RowHeight, + IconWrite: Write, + IconInsertTable: InsertTable, + IconAddText: AddText, + IconFill: Fill, + IconTailoring: Tailoring, + IconEffects: Effects, + IconColorFilter: ColorFilter, + IconUp: Up, + IconDown: Down, + IconPlus: Plus, + IconMinus: Minus, + IconConnection: Connection, + IconBringToFrontOne: BringToFrontOne, + IconSentToBack: SentToBack, + IconGithub: Github, + IconChartProportion: ChartProportion, + IconChartHistogram: ChartHistogram, + IconChartHistogramOne: ChartHistogramOne, + IconChartLineArea: ChartLineArea, + IconChartRing: ChartRing, + IconChartScatter: ChartScatter, + IconChartLine: ChartLine, + IconChartPie: ChartPie, + IconRadarChart: RadarChart, + IconText: Text, + IconRotate: Rotate, + IconLeftTwo: LeftTwo, + IconRightTwo: RightTwo, + IconPlatte: Platte, + IconClose: Close, + IconCloseSmall: CloseSmall, + IconUndo: Undo, + IconTransform: Transform, + IconClick: Click, + IconTheme: Theme, + IconArrowCircleLeft: ArrowCircleLeft, + IconArrowRight: ArrowRight, + IconGraphicDesign: GraphicDesign, + IconLogout: Logout, + IconErase: Erase, + IconClear: Clear, + IconAlignTextTopOne: AlignTextTopOne, + IconAlignTextBottomOne: AlignTextBottomOne, + IconAlignTextMiddleOne: AlignTextMiddleOne, + IconPause: Pause, + IconVolumeMute: VolumeMute, + IconVolumeNotice: VolumeNotice, + IconVolumeSmall: VolumeSmall, + IconVideoTwo: VideoTwo, + IconFormula: Formula, + IconLinkOne: LinkOne, + IconFullScreenOne: FullScreenOne, + IconOffScreenOne: OffScreenOne, + IconPower: Power, + IconListView: ListView, + IconMagic: Magic, + IconHighLight: HighLight, + IconDownload: Download, + IconIndentLeft: IndentLeft, + IconIndentRight: IndentRight, + IconVerticalSpacingBetweenItems: VerticalSpacingBetweenItems, + IconCopy: Copy, + IconDelete: Delete, + IconSquare: Square, + IconRound: Round, + IconNeedle: Needle, + IconTextRotationNone: TextRotationNone, + IconTextRotationDown: TextRotationDown, + IconFormatBrush: FormatBrush, + IconPreviewOpen: PreviewOpen, + IconPreviewClose: PreviewClose, + IconStopwatchStart: StopwatchStart, + IconSearch: Search, + IconLeft: Left, + IconRight: Right, + IconMoveOne: MoveOne, + IconHamburgerButton: HamburgerButton, + IconAttention: Attention, + IconCheckOne: CheckOne, + IconCloseOne: CloseOne, + IconInfo: Info, + IconComment: Comment, + IconUser: User, + IconSwitch: Switch, + IconMore: More, + IconShare: Share, +} + +export default { + install(app: App) { + for (const key of Object.keys(icons)) { + app.component(key, icons[key]) + } + } +} \ No newline at end of file diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..8eb780ccb41ad6eca7078b1b4338f49b3f32eab1 --- /dev/null +++ b/frontend/src/services/config.ts @@ -0,0 +1,62 @@ +import axios from 'axios' +import message from '@/utils/message' + +const instance = axios.create({ timeout: 1000 * 300 }) + +// 请求拦截器 - 添加JWT token +instance.interceptors.request.use( + config => { + const token = localStorage.getItem('pptist_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => { + return Promise.reject(error) + } +) + +// 响应拦截器 - 处理认证错误和其他错误 +instance.interceptors.response.use( + response => { + if (response.status >= 200 && response.status < 400) { + return Promise.resolve(response.data) + } + message.error('未知的请求错误!') + return Promise.reject(response) + }, + error => { + if (error && error.response) { + const status = error.response.status + + // 处理认证错误 + if (status === 401) { + localStorage.removeItem('pptist_token') + localStorage.removeItem('pptist_user') + message.error('登录已过期,请重新登录') + // 可以在这里触发重定向到登录页面 + window.location.href = '/login' + return Promise.reject('Authentication failed') + } + + if (status >= 400 && status < 500) { + const errorMsg = error.response.data?.error || error.message + message.error(errorMsg) + return Promise.reject(errorMsg) + } + else if (status >= 500) { + const errorMsg = error.response.data?.error || '服务器内部错误' + message.error(errorMsg) + return Promise.reject(errorMsg) + } + + message.error('服务器遇到未知错误!') + return Promise.reject(error.message) + } + message.error('连接到服务器失败 或 服务器响应超时!') + return Promise.reject(error) + } +) + +export default instance \ No newline at end of file diff --git a/frontend/src/services/dataSyncService.ts b/frontend/src/services/dataSyncService.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd1a340c1bced9f6220376299ce8ad4301f18ca7 --- /dev/null +++ b/frontend/src/services/dataSyncService.ts @@ -0,0 +1,255 @@ +import api from '@/services' +import { debounce } from 'lodash' + +class DataSyncService { + private currentPPTId: string | null = null + private saveTimeout: number | null = null + private isOnline = true + private autoSaveDelay = 300000 // 默认5分钟,可配置 + private isInitialized = false + private debouncedSave: any = null + + constructor() { + this.setupNetworkMonitoring() + } + + // 延迟初始化,在 Pinia 可用后调用 + async initialize() { + if (this.isInitialized) return + await this.setupAutoSave() + this.isInitialized = true + } + + // 设置自动保存延迟时间(毫秒) + setAutoSaveDelay(delay: number) { + this.autoSaveDelay = Math.max(500, delay) // 最小500ms + if (this.isInitialized) { + this.setupAutoSave() // 重新设置自动保存 + } + } + + // 获取当前自动保存延迟时间 + getAutoSaveDelay(): number { + return this.autoSaveDelay + } + + // 设置当前PPT ID + setCurrentPPTId(pptId: string) { + this.currentPPTId = pptId + } + + // 自动保存功能 + private async setupAutoSave() { + if (this.debouncedSave) { + this.debouncedSave.cancel() + } + + this.debouncedSave = debounce(async () => { + await this.savePPT() + }, this.autoSaveDelay) // 使用可配置的延迟时间 + + // 监听slides变化 + try { + const { useSlidesStore } = await import('@/store') + const slidesStore = useSlidesStore() + slidesStore.$subscribe(() => { + if (this.isOnline && this.currentPPTId) { + this.debouncedSave() + } + }) + } + catch (error) { + // console.warn('无法设置自动保存,store 未就绪:', error) + } + } + + // 网络状态监控 + private setupNetworkMonitoring() { + window.addEventListener('online', () => { + this.isOnline = true + // console.log('网络已连接,恢复自动保存') + }) + + window.addEventListener('offline', () => { + this.isOnline = false + // console.log('网络已断开,暂停自动保存') + }) + } + + // 保存PPT到后端 + async savePPT(force = false): Promise { + // 动态导入 store,避免初始化时的依赖问题 + const { useAuthStore, useSlidesStore } = await import('@/store') + + try { + const authStore = useAuthStore() + const slidesStore = useSlidesStore() + + if (!authStore.isLoggedIn) { + // console.warn('用户未登录,无法保存') + return false + } + + // 如果没有当前PPT ID且是强制保存,创建新PPT + if (!this.currentPPTId && force) { + try { + const response = await api.createPPT(slidesStore.title || '未命名演示文稿') + this.currentPPTId = response.pptId + + // 更新slides store中的数据以匹配新创建的PPT + if (response.ppt) { + slidesStore.setSlides(response.ppt.slides) + slidesStore.setTitle(response.ppt.title) + slidesStore.setTheme(response.ppt.theme) + } + + // console.log('创建新PPT并保存成功') + return true + } + catch (createError) { + // console.error('创建新PPT失败:', createError) + return false + } + } + + if (!this.currentPPTId && !force) { + // console.warn('没有当前PPT ID') + return false + } + + const pptData = { + pptId: this.currentPPTId, + title: slidesStore.title, + slides: slidesStore.slides, + theme: slidesStore.theme + } + + await api.savePPT(pptData) + // console.log('PPT保存成功') + return true + } + catch (error) { + // console.error('PPT保存失败:', error) + return false + } + } + + // 创建新PPT + async createNewPPT(title: string): Promise { + const { useAuthStore } = await import('@/store') + const authStore = useAuthStore() + + if (!authStore.isLoggedIn) { + throw new Error('用户未登录') + } + + try { + const response = await api.createPPT(title) + this.setCurrentPPTId(response.pptId) + return response.pptId + } + catch (error) { + // console.error('创建PPT失败:', error) + throw error + } + } + + // 加载PPT + async loadPPT(pptId: string): Promise { + const { useAuthStore, useSlidesStore } = await import('@/store') + const authStore = useAuthStore() + const slidesStore = useSlidesStore() + + if (!authStore.isLoggedIn) { + throw new Error('用户未登录') + } + + try { + const pptData = await api.getPPT(pptId) + + slidesStore.setSlides(pptData.slides) + slidesStore.setTitle(pptData.title) + if (pptData.theme) { + slidesStore.setTheme(pptData.theme) + } + + this.setCurrentPPTId(pptId) + // console.log('PPT加载成功') + return true + } + catch (error) { + // console.error('PPT加载失败:', error) + throw error + } + } + + // 获取PPT列表 + async getPPTList() { + const { useAuthStore } = await import('@/store') + const authStore = useAuthStore() + + if (!authStore.isLoggedIn) { + throw new Error('用户未登录') + } + + return await api.getPPTList() + } + + // 删除PPT + async deletePPT(pptId: string): Promise { + const { useAuthStore } = await import('@/store') + const authStore = useAuthStore() + + if (!authStore.isLoggedIn) { + throw new Error('用户未登录') + } + + try { + await api.deletePPT(pptId) + + // 如果删除的是当前PPT,清除当前PPT ID + if (this.currentPPTId === pptId) { + this.currentPPTId = null + } + + // console.log('PPT删除成功') + return true + } + catch (error) { + // console.error('PPT删除失败:', error) + throw error + } + } + + // 生成分享链接 + async generateShareLink(slideIndex = 0) { + const { useAuthStore } = await import('@/store') + const authStore = useAuthStore() + + if (!authStore.isLoggedIn || !this.currentPPTId) { + throw new Error('用户未登录或没有当前PPT') + } + + try { + const response = await api.generateShareLink( + authStore.currentUser!.id, + this.currentPPTId, + slideIndex + ) + return response + } + catch (error) { + // console.error('生成分享链接失败:', error) + throw error + } + } + + // 手动保存 + async manualSave(): Promise { + return await this.savePPT(true) + } +} + +// 创建单例实例 +export const dataSyncService = new DataSyncService() +export default dataSyncService \ No newline at end of file diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..23a4c0b6118c1b65246c5211bd83a27a5a115f74 --- /dev/null +++ b/frontend/src/services/index.ts @@ -0,0 +1,108 @@ +import axios from './config' + +// export const SERVER_URL = 'http://localhost:5000' +export const SERVER_URL = (import.meta.env.MODE === 'development') ? '/api' : '/api' +export const ASSET_URL = 'https://asset.pptist.cn' + +export default { + getMockData(filename: string): Promise { + return axios.get(`./mocks/${filename}.json`) + }, + + getFileData(filename: string): Promise { + return axios.get(`${ASSET_URL}/data/${filename}.json`) + }, + + AIPPT_Outline( + content: string, + language: string, + model: string, + ): Promise { + return fetch(`${SERVER_URL}/tools/aippt_outline`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content, + language, + model, + stream: true, + }), + }) + }, + + AIPPT( + content: string, + language: string, + model: string, + ): Promise { + return fetch(`${SERVER_URL}/tools/aippt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content, + language, + model, + stream: true, + }), + }) + }, + + // 用户认证 + login(username: string, password: string): Promise { + return axios.post(`${SERVER_URL}/auth/login`, { username, password }) + }, + + verifyToken(): Promise { + return axios.get(`${SERVER_URL}/auth/verify`) + }, + + getUserInfo(): Promise { + return axios.get(`${SERVER_URL}/auth/user`) + }, + + // PPT管理 + getPPTList(): Promise { + return axios.get(`${SERVER_URL}/ppt/list`) + }, + + getPPT(pptId: string): Promise { + return axios.get(`${SERVER_URL}/ppt/${pptId}`) + }, + + savePPT(pptData: any): Promise { + return axios.post(`${SERVER_URL}/ppt/save`, pptData) + }, + + createPPT(title: string): Promise { + return axios.post(`${SERVER_URL}/ppt/create`, { title }) + }, + + deletePPT(pptId: string): Promise { + return axios.delete(`${SERVER_URL}/ppt/${pptId}`) + }, + + copyPPT(pptId: string, title: string): Promise { + return axios.post(`${SERVER_URL}/ppt/${pptId}/copy`, { title }) + }, + + // 公共分享 + generateShareLink(userId: string, pptId: string, slideIndex?: number): Promise { + return axios.post(`${SERVER_URL}/public/generate-share-link`, { + userId, + pptId, + slideIndex + }) + }, + + getPublicPPT(userId: string, pptId: string): Promise { + return axios.get(`${SERVER_URL}/public/ppt/${userId}/${pptId}`) + }, + + getPublicSlide(userId: string, pptId: string, slideIndex: number): Promise { + return axios.get(`${SERVER_URL}/public/view/${userId}/${pptId}/${slideIndex}`) + } +} \ No newline at end of file diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f5fe52e046e4a8c880f0fc706c81e33dc085eeb --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,85 @@ +import { defineStore } from 'pinia' +import api from '@/services' + +interface User { + id: string + username: string + role: string +} + +interface AuthState { + user: User | null + token: string | null + isAuthenticated: boolean +} + +export const useAuthStore = defineStore('auth', { + state: (): AuthState => ({ + user: null, + token: localStorage.getItem('pptist_token'), + isAuthenticated: false + }), + + getters: { + currentUser: (state) => state.user, + isLoggedIn: (state) => state.isAuthenticated && !!state.token, + userRole: (state) => state.user?.role || 'guest' + }, + + actions: { + async login(username: string, password: string) { + try { + const response = await api.login(username, password) + this.token = response.token + this.user = response.user + this.isAuthenticated = true + + localStorage.setItem('pptist_token', response.token) + localStorage.setItem('pptist_user', JSON.stringify(response.user)) + + return response + } catch (error) { + this.logout() + throw error + } + }, + + async verifyToken() { + if (!this.token) { + this.logout() + return false + } + + try { + const response = await api.verifyToken() + this.user = response.user + this.isAuthenticated = true + return true + } catch (error) { + this.logout() + return false + } + }, + + logout() { + this.user = null + this.token = null + this.isAuthenticated = false + + localStorage.removeItem('pptist_token') + localStorage.removeItem('pptist_user') + }, + + async initAuth() { + const savedUser = localStorage.getItem('pptist_user') + if (savedUser && this.token) { + try { + this.user = JSON.parse(savedUser) + await this.verifyToken() + } catch (error) { + this.logout() + } + } + } + } +}) \ No newline at end of file diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aba2ec45ff5ae7566d71af14cb67618e7ce77d4e --- /dev/null +++ b/frontend/src/store/index.ts @@ -0,0 +1,15 @@ +import { useMainStore } from './main' +import { useSlidesStore } from './slides' +import { useSnapshotStore } from './snapshot' +import { useKeyboardStore } from './keyboard' +import { useScreenStore } from './screen' +import { useAuthStore } from './auth' + +export { + useMainStore, + useSlidesStore, + useSnapshotStore, + useKeyboardStore, + useScreenStore, + useAuthStore, +} \ No newline at end of file diff --git a/frontend/src/store/keyboard.ts b/frontend/src/store/keyboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..60d1792b45c71af407553b90d22fb13b403a15d8 --- /dev/null +++ b/frontend/src/store/keyboard.ts @@ -0,0 +1,33 @@ +import { defineStore } from 'pinia' + +export interface KeyboardState { + ctrlKeyState: boolean + shiftKeyState: boolean + spaceKeyState: boolean +} + +export const useKeyboardStore = defineStore('keyboard', { + state: (): KeyboardState => ({ + ctrlKeyState: false, // ctrl键按下状态 + shiftKeyState: false, // shift键按下状态 + spaceKeyState: false, // space键按下状态 + }), + + getters: { + ctrlOrShiftKeyActive(state) { + return state.ctrlKeyState || state.shiftKeyState + }, + }, + + actions: { + setCtrlKeyState(active: boolean) { + this.ctrlKeyState = active + }, + setShiftKeyState(active: boolean) { + this.shiftKeyState = active + }, + setSpaceKeyState(active: boolean) { + this.spaceKeyState = active + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/store/main.ts b/frontend/src/store/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..64ae62ab682eb670cd180710ac23fdeedbb04c54 --- /dev/null +++ b/frontend/src/store/main.ts @@ -0,0 +1,210 @@ +import { customAlphabet } from 'nanoid' +import { defineStore } from 'pinia' +import { ToolbarStates } from '@/types/toolbar' +import type { CreatingElement, ShapeFormatPainter, TextFormatPainter } from '@/types/edit' +import type { DialogForExportTypes } from '@/types/export' +import { type TextAttrs, defaultRichTextAttrs } from '@/utils/prosemirror/utils' + +import { useSlidesStore } from './slides' + +export interface MainState { + activeElementIdList: string[] + handleElementId: string + activeGroupElementId: string + hiddenElementIdList: string[] + canvasPercentage: number + canvasScale: number + canvasDragged: boolean + thumbnailsFocus: boolean + editorAreaFocus: boolean + disableHotkeys: boolean + gridLineSize: number + showRuler: boolean + creatingElement: CreatingElement | null + creatingCustomShape: boolean + toolbarState: ToolbarStates + clipingImageElementId: string + isScaling: boolean + richTextAttrs: TextAttrs + selectedTableCells: string[] + selectedSlidesIndex: number[] + dialogForExport: DialogForExportTypes + databaseId: string + textFormatPainter: TextFormatPainter | null + shapeFormatPainter: ShapeFormatPainter | null + showSelectPanel: boolean + showSearchPanel: boolean + showNotesPanel: boolean + showMarkupPanel: boolean + showAIPPTDialog: boolean +} + +const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') +export const databaseId = nanoid(10) + +export const useMainStore = defineStore('main', { + state: (): MainState => ({ + activeElementIdList: [], // 被选中的元素ID集合,包含 handleElementId + handleElementId: '', // 正在操作的元素ID + activeGroupElementId: '', // 组合元素成员中,被选中可独立操作的元素ID + hiddenElementIdList: [], // 被隐藏的元素ID集合 + canvasPercentage: 90, // 画布可视区域百分比 + canvasScale: 1, // 画布缩放比例(基于宽度{{slidesStore.viewportSize}}像素) + canvasDragged: false, // 画布被拖拽移动 + thumbnailsFocus: false, // 左侧导航缩略图区域聚焦 + editorAreaFocus: false, // 编辑区域聚焦 + disableHotkeys: false, // 禁用快捷键 + gridLineSize: 0, // 网格线尺寸(0表示不显示网格线) + showRuler: false, // 显示标尺 + creatingElement: null, // 正在插入的元素信息,需要通过绘制插入的元素(文字、形状、线条) + creatingCustomShape: false, // 正在绘制任意多边形 + toolbarState: ToolbarStates.SLIDE_DESIGN, // 右侧工具栏状态 + clipingImageElementId: '', // 当前正在裁剪的图片ID + richTextAttrs: defaultRichTextAttrs, // 富文本状态 + selectedTableCells: [], // 选中的表格单元格 + isScaling: false, // 正在进行元素缩放 + selectedSlidesIndex: [], // 当前被选中的页面索引集合 + dialogForExport: '', // 导出面板 + databaseId, // 标识当前应用的indexedDB数据库ID + textFormatPainter: null, // 文字格式刷 + shapeFormatPainter: null, // 形状格式刷 + showSelectPanel: false, // 打开选择面板 + showSearchPanel: false, // 打开查找替换面板 + showNotesPanel: false, // 打开批注面板 + showMarkupPanel: false, // 打开类型标注面板 + showAIPPTDialog: false, // 打开AIPPT创建窗口 + }), + + getters: { + activeElementList(state) { + const slidesStore = useSlidesStore() + const currentSlide = slidesStore.currentSlide + if (!currentSlide || !currentSlide.elements) return [] + return currentSlide.elements.filter(element => state.activeElementIdList.includes(element.id)) + }, + + handleElement(state) { + const slidesStore = useSlidesStore() + const currentSlide = slidesStore.currentSlide + if (!currentSlide || !currentSlide.elements) return null + return currentSlide.elements.find(element => state.handleElementId === element.id) || null + }, + }, + + actions: { + setActiveElementIdList(activeElementIdList: string[]) { + if (activeElementIdList.length === 1) this.handleElementId = activeElementIdList[0] + else this.handleElementId = '' + + this.activeElementIdList = activeElementIdList + }, + + setHandleElementId(handleElementId: string) { + this.handleElementId = handleElementId + }, + + setActiveGroupElementId(activeGroupElementId: string) { + this.activeGroupElementId = activeGroupElementId + }, + + setHiddenElementIdList(hiddenElementIdList: string[]) { + this.hiddenElementIdList = hiddenElementIdList + }, + + setCanvasPercentage(percentage: number) { + this.canvasPercentage = percentage + }, + + setCanvasScale(scale: number) { + this.canvasScale = scale + }, + + setCanvasDragged(isDragged: boolean) { + this.canvasDragged = isDragged + }, + + setThumbnailsFocus(isFocus: boolean) { + this.thumbnailsFocus = isFocus + }, + + setEditorareaFocus(isFocus: boolean) { + this.editorAreaFocus = isFocus + }, + + setDisableHotkeysState(disable: boolean) { + this.disableHotkeys = disable + }, + + setGridLineSize(size: number) { + this.gridLineSize = size + }, + + setRulerState(show: boolean) { + this.showRuler = show + }, + + setCreatingElement(element: CreatingElement | null) { + this.creatingElement = element + }, + + setCreatingCustomShapeState(state: boolean) { + this.creatingCustomShape = state + }, + + setToolbarState(toolbarState: ToolbarStates) { + this.toolbarState = toolbarState + }, + + setClipingImageElementId(elId: string) { + this.clipingImageElementId = elId + }, + + setRichtextAttrs(attrs: TextAttrs) { + this.richTextAttrs = attrs + }, + + setSelectedTableCells(cells: string[]) { + this.selectedTableCells = cells + }, + + setScalingState(isScaling: boolean) { + this.isScaling = isScaling + }, + + updateSelectedSlidesIndex(selectedSlidesIndex: number[]) { + this.selectedSlidesIndex = selectedSlidesIndex + }, + + setDialogForExport(type: DialogForExportTypes) { + this.dialogForExport = type + }, + + setTextFormatPainter(textFormatPainter: TextFormatPainter | null) { + this.textFormatPainter = textFormatPainter + }, + + setShapeFormatPainter(shapeFormatPainter: ShapeFormatPainter | null) { + this.shapeFormatPainter = shapeFormatPainter + }, + + setSelectPanelState(show: boolean) { + this.showSelectPanel = show + }, + + setSearchPanelState(show: boolean) { + this.showSearchPanel = show + }, + + setNotesPanelState(show: boolean) { + this.showNotesPanel = show + }, + + setMarkupPanelState(show: boolean) { + this.showMarkupPanel = show + }, + + setAIPPTDialogState(show: boolean) { + this.showAIPPTDialog = show + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/store/screen.ts b/frontend/src/store/screen.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec997458e45bb6bf1ba73326130ec821bf25b6cb --- /dev/null +++ b/frontend/src/store/screen.ts @@ -0,0 +1,17 @@ +import { defineStore } from 'pinia' + +export interface ScreenState { + screening: boolean +} + +export const useScreenStore = defineStore('screen', { + state: (): ScreenState => ({ + screening: false, // 是否进入放映状态 + }), + + actions: { + setScreening(screening: boolean) { + this.screening = screening + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/store/slides.ts b/frontend/src/store/slides.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dc0db510f1dc88cf500799db185343f47f28c49 --- /dev/null +++ b/frontend/src/store/slides.ts @@ -0,0 +1,232 @@ +import { defineStore } from 'pinia' +import { omit } from 'lodash' +import type { Slide, SlideTheme, PPTElement, PPTAnimation, SlideTemplate } from '@/types/slides' + +interface RemovePropData { + id: string + propName: string | string[] +} + +interface UpdateElementData { + id: string | string[] + props: Partial + slideId?: string +} + +interface FormatedAnimation { + animations: PPTAnimation[] + autoNext: boolean +} + +export interface SlidesState { + title: string + theme: SlideTheme + slides: Slide[] + slideIndex: number + viewportSize: number + viewportRatio: number + templates: SlideTemplate[] +} + +export const useSlidesStore = defineStore('slides', { + state: (): SlidesState => ({ + title: '未命名演示文稿', // 幻灯片标题 + theme: { + themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4', '#70ad47'], + fontColor: '#333', + fontName: '', + backgroundColor: '#fff', + shadow: { + h: 3, + v: 3, + blur: 2, + color: '#808080', + }, + outline: { + width: 2, + color: '#525252', + style: 'solid', + }, + }, // 主题样式 + slides: [], // 幻灯片页面数据 + slideIndex: 0, // 当前页面索引 + viewportSize: 1000, // 可视区域宽度基数 + viewportRatio: 0.5625, // 可视区域比例,默认16:9 + templates: [ + { name: '红色通用', id: 'template_1', cover: 'https://asset.pptist.cn/img/template_1.jpg' }, + { name: '蓝色通用', id: 'template_2', cover: 'https://asset.pptist.cn/img/template_2.jpg' }, + { name: '紫色通用', id: 'template_3', cover: 'https://asset.pptist.cn/img/template_3.jpg' }, + { name: '莫兰迪配色', id: 'template_4', cover: 'https://asset.pptist.cn/img/template_4.jpg' }, + ], // 模板 + }), + + getters: { + currentSlide(state) { + return state.slides[state.slideIndex] + }, + + currentSlideAnimations(state) { + const currentSlide = state.slides[state.slideIndex] + if (!currentSlide?.animations) return [] + + const els = currentSlide.elements + const elIds = els.map(el => el.id) + return currentSlide.animations.filter(animation => elIds.includes(animation.elId)) + }, + + // 格式化的当前页动画 + // 将触发条件为“与上一动画同时”的项目向上合并到序列中的同一位置 + // 为触发条件为“上一动画之后”项目的上一项添加自动向下执行标记 + formatedAnimations(state) { + const currentSlide = state.slides[state.slideIndex] + if (!currentSlide?.animations) return [] + + const els = currentSlide.elements + const elIds = els.map(el => el.id) + const animations = currentSlide.animations.filter(animation => elIds.includes(animation.elId)) + + const formatedAnimations: FormatedAnimation[] = [] + for (const animation of animations) { + if (animation.trigger === 'click' || !formatedAnimations.length) { + formatedAnimations.push({ animations: [animation], autoNext: false }) + } + else if (animation.trigger === 'meantime') { + const last = formatedAnimations[formatedAnimations.length - 1] + last.animations = last.animations.filter(item => item.elId !== animation.elId) + last.animations.push(animation) + formatedAnimations[formatedAnimations.length - 1] = last + } + else if (animation.trigger === 'auto') { + const last = formatedAnimations[formatedAnimations.length - 1] + last.autoNext = true + formatedAnimations[formatedAnimations.length - 1] = last + formatedAnimations.push({ animations: [animation], autoNext: false }) + } + } + return formatedAnimations + }, + }, + + actions: { + setTitle(title: string) { + if (!title) this.title = '未命名演示文稿' + else this.title = title + }, + + setTheme(themeProps: Partial) { + this.theme = { ...this.theme, ...themeProps } + }, + + setViewportSize(size: number) { + this.viewportSize = size + }, + + setViewportRatio(viewportRatio: number) { + this.viewportRatio = viewportRatio + }, + + setSlides(slides: Slide[]) { + this.slides = slides + }, + + setTemplates(templates: SlideTemplate[]) { + this.templates = templates + }, + + addSlide(slide: Slide | Slide[]) { + const slides = Array.isArray(slide) ? slide : [slide] + for (const slide of slides) { + if (slide.sectionTag) delete slide.sectionTag + } + + const addIndex = this.slideIndex + 1 + this.slides.splice(addIndex, 0, ...slides) + this.slideIndex = addIndex + }, + + updateSlide(props: Partial, slideId?: string) { + const slideIndex = slideId ? this.slides.findIndex(item => item.id === slideId) : this.slideIndex + this.slides[slideIndex] = { ...this.slides[slideIndex], ...props } + }, + + removeSlideProps(data: RemovePropData) { + const { id, propName } = data + + const slides = this.slides.map(slide => { + return slide.id === id ? omit(slide, propName) : slide + }) as Slide[] + this.slides = slides + }, + + deleteSlide(slideId: string | string[]) { + const slidesId = Array.isArray(slideId) ? slideId : [slideId] + const slides: Slide[] = JSON.parse(JSON.stringify(this.slides)) + + const deleteSlidesIndex = [] + for (const deletedId of slidesId) { + const index = slides.findIndex(item => item.id === deletedId) + deleteSlidesIndex.push(index) + + const deletedSlideSection = slides[index].sectionTag + if (deletedSlideSection) { + const handleSlideNext = slides[index + 1] + if (handleSlideNext && !handleSlideNext.sectionTag) { + delete slides[index].sectionTag + slides[index + 1].sectionTag = deletedSlideSection + } + } + + slides.splice(index, 1) + } + let newIndex = Math.min(...deleteSlidesIndex) + + const maxIndex = slides.length - 1 + if (newIndex > maxIndex) newIndex = maxIndex + + this.slideIndex = newIndex + this.slides = slides + }, + + updateSlideIndex(index: number) { + this.slideIndex = index + }, + + addElement(element: PPTElement | PPTElement[]) { + const elements = Array.isArray(element) ? element : [element] + const currentSlideEls = this.slides[this.slideIndex].elements + const newEls = [...currentSlideEls, ...elements] + this.slides[this.slideIndex].elements = newEls + }, + + deleteElement(elementId: string | string[]) { + const elementIdList = Array.isArray(elementId) ? elementId : [elementId] + const currentSlideEls = this.slides[this.slideIndex].elements + const newEls = currentSlideEls.filter(item => !elementIdList.includes(item.id)) + this.slides[this.slideIndex].elements = newEls + }, + + updateElement(data: UpdateElementData) { + const { id, props, slideId } = data + const elIdList = typeof id === 'string' ? [id] : id + + const slideIndex = slideId ? this.slides.findIndex(item => item.id === slideId) : this.slideIndex + const slide = this.slides[slideIndex] + const elements = slide.elements.map(el => { + return elIdList.includes(el.id) ? { ...el, ...props } : el + }) + this.slides[slideIndex].elements = (elements as PPTElement[]) + }, + + removeElementProps(data: RemovePropData) { + const { id, propName } = data + const propsNames = typeof propName === 'string' ? [propName] : propName + + const slideIndex = this.slideIndex + const slide = this.slides[slideIndex] + const elements = slide.elements.map(el => { + return el.id === id ? omit(el, propsNames) : el + }) + this.slides[slideIndex].elements = (elements as PPTElement[]) + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/store/snapshot.ts b/frontend/src/store/snapshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..76f8ca17c9410020c0dfea7ccf816e20200e3e47 --- /dev/null +++ b/frontend/src/store/snapshot.ts @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia' +import type { IndexableTypeArray } from 'dexie' +import { db, type Snapshot } from '@/utils/database' + +import { useSlidesStore } from './slides' +import { useMainStore } from './main' + +export interface ScreenState { + snapshotCursor: number + snapshotLength: number +} + +export const useSnapshotStore = defineStore('snapshot', { + state: (): ScreenState => ({ + snapshotCursor: -1, // 历史快照指针 + snapshotLength: 0, // 历史快照长度 + }), + + getters: { + canUndo(state) { + return state.snapshotCursor > 0 + }, + canRedo(state) { + return state.snapshotCursor < state.snapshotLength - 1 + }, + }, + + actions: { + setSnapshotCursor(cursor: number) { + this.snapshotCursor = cursor + }, + setSnapshotLength(length: number) { + this.snapshotLength = length + }, + + async initSnapshotDatabase() { + const slidesStore = useSlidesStore() + + const newFirstSnapshot = { + index: slidesStore.slideIndex, + slides: JSON.parse(JSON.stringify(slidesStore.slides)), + } + await db.snapshots.add(newFirstSnapshot) + this.setSnapshotCursor(0) + this.setSnapshotLength(1) + }, + + async addSnapshot() { + const slidesStore = useSlidesStore() + + // 获取当前indexeddb中全部快照的ID + const allKeys = await db.snapshots.orderBy('id').keys() + + let needDeleteKeys: IndexableTypeArray = [] + + // 记录需要删除的快照ID + // 若当前快照指针不处在最后一位,那么再添加快照时,应该将当前指针位置后面的快照全部删除,对应的实际情况是: + // 用户撤回多次后,再进行操作(添加快照),此时原先被撤销的快照都应该被删除 + if (this.snapshotCursor >= 0 && this.snapshotCursor < allKeys.length - 1) { + needDeleteKeys = allKeys.slice(this.snapshotCursor + 1) + } + + // 添加新快照 + const snapshot = { + index: slidesStore.slideIndex, + slides: JSON.parse(JSON.stringify(slidesStore.slides)), + } + await db.snapshots.add(snapshot) + + // 计算当前快照长度,用于设置快照指针的位置(此时指针应该处在最后一位,即:快照长度 - 1) + let snapshotLength = allKeys.length - needDeleteKeys.length + 1 + + // 快照数量超过长度限制时,应该将头部多余的快照删除 + const snapshotLengthLimit = 20 + if (snapshotLength > snapshotLengthLimit) { + needDeleteKeys.push(allKeys[0]) + snapshotLength-- + } + + // 快照数大于1时,需要保证撤回操作后维持页面焦点不变:也就是将倒数第二个快照对应的索引设置为当前页的索引 + // https://github.com/pipipi-pikachu/PPTist/issues/27 + if (snapshotLength >= 2) { + db.snapshots.update(allKeys[snapshotLength - 2] as number, { index: slidesStore.slideIndex }) + } + + await db.snapshots.bulkDelete(needDeleteKeys as number[]) + + this.setSnapshotCursor(snapshotLength - 1) + this.setSnapshotLength(snapshotLength) + }, + + async unDo() { + if (this.snapshotCursor <= 0) return + + const slidesStore = useSlidesStore() + const mainStore = useMainStore() + + const snapshotCursor = this.snapshotCursor - 1 + const snapshots: Snapshot[] = await db.snapshots.orderBy('id').toArray() + const snapshot = snapshots[snapshotCursor] + const { index, slides } = snapshot + + const slideIndex = index > slides.length - 1 ? slides.length - 1 : index + + slidesStore.setSlides(slides) + slidesStore.updateSlideIndex(slideIndex) + this.setSnapshotCursor(snapshotCursor) + mainStore.setActiveElementIdList([]) + }, + + async reDo() { + if (this.snapshotCursor >= this.snapshotLength - 1) return + + const slidesStore = useSlidesStore() + const mainStore = useMainStore() + + const snapshotCursor = this.snapshotCursor + 1 + const snapshots: Snapshot[] = await db.snapshots.orderBy('id').toArray() + const snapshot = snapshots[snapshotCursor] + const { index, slides } = snapshot + + const slideIndex = index > slides.length - 1 ? slides.length - 1 : index + + slidesStore.setSlides(slides) + slidesStore.updateSlideIndex(slideIndex) + this.setSnapshotCursor(snapshotCursor) + mainStore.setActiveElementIdList([]) + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/types/AIPPT.ts b/frontend/src/types/AIPPT.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1f2950dd4c0dd7ca7570bb621151e37cf8f2dee --- /dev/null +++ b/frontend/src/types/AIPPT.ts @@ -0,0 +1,41 @@ +export interface AIPPTCover { + type: 'cover' + data: { + title: string + text: string + } +} + +export interface AIPPTContents { + type: 'contents' + data: { + items: string[] + } + offset?: number +} + +export interface AIPPTTransition { + type: 'transition' + data: { + title: string + text: string + } +} + +export interface AIPPTContent { + type: 'content' + data: { + title: string + items: { + title: string + text: string + }[] + }, + offset?: number +} + +export interface AIPPTEnd { + type: 'end' +} + +export type AIPPTSlide = AIPPTCover | AIPPTContents | AIPPTTransition | AIPPTContent | AIPPTEnd \ No newline at end of file diff --git a/frontend/src/types/edit.ts b/frontend/src/types/edit.ts new file mode 100644 index 0000000000000000000000000000000000000000..744350d0f61032ad6ed98072d7f1d641475833e7 --- /dev/null +++ b/frontend/src/types/edit.ts @@ -0,0 +1,126 @@ +import type { ShapePoolItem } from '@/configs/shapes' +import type { LinePoolItem } from '@/configs/lines' +import type { ImageClipDataRange, PPTElementOutline, PPTElementShadow, Gradient } from './slides' + +export enum ElementOrderCommands { + UP = 'up', + DOWN = 'down', + TOP = 'top', + BOTTOM = 'bottom', +} + +export enum ElementAlignCommands { + TOP = 'top', + BOTTOM = 'bottom', + LEFT = 'left', + RIGHT = 'right', + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal', + CENTER = 'center', +} + +export const enum OperateBorderLines { + T = 'top', + B = 'bottom', + L = 'left', + R = 'right', +} + +export const enum OperateResizeHandlers { + LEFT_TOP = 'left-top', + TOP = 'top', + RIGHT_TOP = 'right-top', + LEFT = 'left', + RIGHT = 'right', + LEFT_BOTTOM = 'left-bottom', + BOTTOM = 'bottom', + RIGHT_BOTTOM = 'right-bottom', +} + +export const enum OperateLineHandlers { + START = 'start', + END = 'end', + C = 'ctrl', + C1 = 'ctrl1', + C2 = 'ctrl2', +} + +export interface AlignmentLineAxis { + x: number + y: number +} + +export interface AlignmentLineProps { + type: 'vertical' | 'horizontal' + axis: AlignmentLineAxis + length: number +} + +export interface MultiSelectRange { + minX: number + maxX: number + minY: number + maxY: number +} + +export interface ImageClipedEmitData { + range: ImageClipDataRange + position: { + left: number + top: number + width: number + height: number + } +} + +export interface CreateElementSelectionData { + start: [number, number] + end: [number, number] +} + +export interface CreateCustomShapeData { + start: [number, number] + end: [number, number] + path: string + viewBox: [number, number] + fill?: string + outline?: PPTElementOutline +} + +export interface CreatingTextElement { + type: 'text' + vertical?: boolean +} +export interface CreatingShapeElement { + type: 'shape' + data: ShapePoolItem +} +export interface CreatingLineElement { + type: 'line' + data: LinePoolItem +} +export type CreatingElement = CreatingTextElement | CreatingShapeElement | CreatingLineElement + +export type TextFormatPainterKeys = 'bold' | 'em' | 'underline' | 'strikethrough' | 'color' | 'backcolor' | 'fontsize' | 'fontname' | 'align' + +export interface TextFormatPainter { + keep: boolean + bold?: boolean + em?: boolean + underline?: boolean + strikethrough?: boolean + color?: string + backcolor?: string + fontsize?: string + fontname?: string + align?: 'left' | 'right' | 'center' +} + +export interface ShapeFormatPainter { + keep: boolean + fill?: string + gradient?: Gradient + outline?: PPTElementOutline + opacity?: number + shadow?: PPTElementShadow +} \ No newline at end of file diff --git a/frontend/src/types/export.ts b/frontend/src/types/export.ts new file mode 100644 index 0000000000000000000000000000000000000000..896f7e39511dd259ecaf8a246b47d25accd043aa --- /dev/null +++ b/frontend/src/types/export.ts @@ -0,0 +1 @@ +export type DialogForExportTypes = 'image' | 'pdf' | 'json' | 'pptx' | 'pptist' | '' \ No newline at end of file diff --git a/frontend/src/types/injectKey.ts b/frontend/src/types/injectKey.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c91dcfae2e250d19f68652d55bcc3afaf192bd6 --- /dev/null +++ b/frontend/src/types/injectKey.ts @@ -0,0 +1,12 @@ +import type { InjectionKey, Ref } from 'vue' + +export type SlideScale = Ref +export type SlideId = Ref +export type RadioGroupValue = { + value: Ref + updateValue: (value: string) => void +} + +export const injectKeySlideScale: InjectionKey = Symbol() +export const injectKeySlideId: InjectionKey = Symbol() +export const injectKeyRadioGroupValue: InjectionKey = Symbol() \ No newline at end of file diff --git a/frontend/src/types/mobile.ts b/frontend/src/types/mobile.ts new file mode 100644 index 0000000000000000000000000000000000000000..86299aef58f24d2cd02551963e3f09a61e124df9 --- /dev/null +++ b/frontend/src/types/mobile.ts @@ -0,0 +1 @@ +export type Mode = 'preview' | 'player' | 'editor' \ No newline at end of file diff --git a/frontend/src/types/slides.ts b/frontend/src/types/slides.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d14f8303dd21d95da07bc81c44c15fed830d597 --- /dev/null +++ b/frontend/src/types/slides.ts @@ -0,0 +1,771 @@ +export const enum ShapePathFormulasKeys { + ROUND_RECT = 'roundRect', + ROUND_RECT_DIAGONAL = 'roundRectDiagonal', + ROUND_RECT_SINGLE = 'roundRectSingle', + ROUND_RECT_SAMESIDE = 'roundRectSameSide', + CUT_RECT_DIAGONAL = 'cutRectDiagonal', + CUT_RECT_SINGLE = 'cutRectSingle', + CUT_RECT_SAMESIDE = 'cutRectSameSide', + CUT_ROUND_RECT = 'cutRoundRect', + MESSAGE = 'message', + ROUND_MESSAGE = 'roundMessage', + L = 'L', + RING_RECT = 'ringRect', + PLUS = 'plus', + TRIANGLE = 'triangle', + PARALLELOGRAM_LEFT = 'parallelogramLeft', + PARALLELOGRAM_RIGHT = 'parallelogramRight', + TRAPEZOID = 'trapezoid', + BULLET = 'bullet', + INDICATOR = 'indicator', +} + +export const enum ElementTypes { + TEXT = 'text', + IMAGE = 'image', + SHAPE = 'shape', + LINE = 'line', + CHART = 'chart', + TABLE = 'table', + LATEX = 'latex', + VIDEO = 'video', + AUDIO = 'audio', +} + +/** + * 渐变 + * + * type: 渐变类型(径向、线性) + * + * colors: 渐变颜色列表(pos: 百分比位置;color: 颜色) + * + * rotate: 渐变角度(线性渐变) + */ +export type GradientType = 'linear' | 'radial' +export type GradientColor = { + pos: number + color: string +} +export interface Gradient { + type: GradientType + colors: GradientColor[] + rotate: number +} + +export type LineStyleType = 'solid' | 'dashed' | 'dotted' + +/** + * 元素阴影 + * + * h: 水平偏移量 + * + * v: 垂直偏移量 + * + * blur: 模糊程度 + * + * color: 阴影颜色 + */ +export interface PPTElementShadow { + h: number + v: number + blur: number + color: string +} + +/** + * 元素边框 + * + * style?: 边框样式(实线或虚线) + * + * width?: 边框宽度 + * + * color?: 边框颜色 + */ +export interface PPTElementOutline { + style?: LineStyleType + width?: number + color?: string +} + +export type ElementLinkType = 'web' | 'slide' + +/** + * 元素超链接 + * + * type: 链接类型(网页、幻灯片页面) + * + * target: 目标地址(网页链接、幻灯片页面ID) + */ +export interface PPTElementLink { + type: ElementLinkType + target: string +} + + +/** + * 元素通用属性 + * + * id: 元素ID + * + * left: 元素水平方向位置(距离画布左侧) + * + * top: 元素垂直方向位置(距离画布顶部) + * + * lock?: 锁定元素 + * + * groupId?: 组合ID(拥有相同组合ID的元素即为同一组合元素成员) + * + * width: 元素宽度 + * + * height: 元素高度 + * + * rotate: 旋转角度 + * + * link?: 超链接 + * + * name?: 元素名 + */ +interface PPTBaseElement { + id: string + left: number + top: number + lock?: boolean + groupId?: string + width: number + height: number + rotate: number + link?: PPTElementLink + name?: string +} + + +export type TextType = 'title' | 'subtitle' | 'content' | 'item' | 'itemTitle' | 'notes' | 'header' | 'footer' | 'partNumber' | 'itemNumber' + +/** + * 文本元素 + * + * type: 元素类型(text) + * + * content: 文本内容(HTML字符串) + * + * defaultFontName: 默认字体(会被文本内容中的HTML内联样式覆盖) + * + * defaultColor: 默认颜色(会被文本内容中的HTML内联样式覆盖) + * + * outline?: 边框 + * + * fill?: 填充色 + * + * lineHeight?: 行高(倍),默认1.5 + * + * wordSpace?: 字间距,默认0 + * + * opacity?: 不透明度,默认1 + * + * shadow?: 阴影 + * + * paragraphSpace?: 段间距,默认 5px + * + * vertical?: 竖向文本 + * + * textType?: 文本类型 + */ +export interface PPTTextElement extends PPTBaseElement { + type: 'text' + content: string + defaultFontName: string + defaultColor: string + outline?: PPTElementOutline + fill?: string + lineHeight?: number + wordSpace?: number + opacity?: number + shadow?: PPTElementShadow + paragraphSpace?: number + vertical?: boolean + textType?: TextType +} + + +/** + * 图片翻转、形状翻转 + * + * flipH?: 水平翻转 + * + * flipV?: 垂直翻转 + */ +export interface ImageOrShapeFlip { + flipH?: boolean + flipV?: boolean +} + +/** + * 图片滤镜 + * + * https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter + * + * 'blur'?: 模糊,默认0(px) + * + * 'brightness'?: 亮度,默认100(%) + * + * 'contrast'?: 对比度,默认100(%) + * + * 'grayscale'?: 灰度,默认0(%) + * + * 'saturate'?: 饱和度,默认100(%) + * + * 'hue-rotate'?: 色相旋转,默认0(deg) + * + * 'opacity'?: 不透明度,默认100(%) + */ +export type ImageElementFilterKeys = 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'saturate' | 'hue-rotate' | 'opacity' | 'sepia' | 'invert' +export interface ImageElementFilters { + 'blur'?: string + 'brightness'?: string + 'contrast'?: string + 'grayscale'?: string + 'saturate'?: string + 'hue-rotate'?: string + 'sepia'?: string + 'invert'?: string + 'opacity'?: string +} + +export type ImageClipDataRange = [[number, number], [number, number]] + +/** + * 图片裁剪 + * + * range: 裁剪范围,例如:[[10, 10], [90, 90]] 表示裁取原图从左上角 10%, 10% 到 90%, 90% 的范围 + * + * shape: 裁剪形状,见 configs/imageClip.ts CLIPPATHS + */ +export interface ImageElementClip { + range: ImageClipDataRange + shape: string +} + +export type ImageType = 'pageFigure' | 'itemFigure' | 'background' + +/** + * 图片元素 + * + * type: 元素类型(image) + * + * fixedRatio: 固定图片宽高比例 + * + * src: 图片地址 + * + * outline?: 边框 + * + * filters?: 图片滤镜 + * + * clip?: 裁剪信息 + * + * flipH?: 水平翻转 + * + * flipV?: 垂直翻转 + * + * shadow?: 阴影 + * + * radius?: 圆角半径 + * + * colorMask?: 颜色蒙版 + * + * imageType?: 图片类型 + */ +export interface PPTImageElement extends PPTBaseElement { + type: 'image' + fixedRatio: boolean + src: string + outline?: PPTElementOutline + filters?: ImageElementFilters + clip?: ImageElementClip + flipH?: boolean + flipV?: boolean + shadow?: PPTElementShadow + radius?: number + colorMask?: string + imageType?: ImageType +} + +export type ShapeTextAlign = 'top' | 'middle' | 'bottom' + +/** + * 形状内文本 + * + * content: 文本内容(HTML字符串) + * + * defaultFontName: 默认字体(会被文本内容中的HTML内联样式覆盖) + * + * defaultColor: 默认颜色(会被文本内容中的HTML内联样式覆盖) + * + * align: 文本对齐方向(垂直方向) + * + * type: 文本类型 + */ +export interface ShapeText { + content: string + defaultFontName: string + defaultColor: string + align: ShapeTextAlign + type?: TextType +} + +/** + * 形状元素 + * + * type: 元素类型(shape) + * + * viewBox: SVG的viewBox属性,例如 [1000, 1000] 表示 '0 0 1000 1000' + * + * path: 形状路径,SVG path 的 d 属性 + * + * fixedRatio: 固定形状宽高比例 + * + * fill: 填充,不存在渐变时生效 + * + * gradient?: 渐变,该属性存在时将优先作为填充 + * + * pattern?: 图案,该属性存在时将优先作为填充 + * + * outline?: 边框 + * + * opacity?: 不透明度 + * + * flipH?: 水平翻转 + * + * flipV?: 垂直翻转 + * + * shadow?: 阴影 + * + * special?: 特殊形状(标记一些难以解析的形状,例如路径使用了 L Q C A 以外的类型,该类形状在导出后将变为图片的形式) + * + * text?: 形状内文本 + * + * pathFormula?: 形状路径计算公式 + * 一般情况下,形状的大小变化时仅由宽高基于 viewBox 的缩放比例来调整形状,而 viewBox 本身和 path 不会变化, + * 但也有一些形状希望能更精确的控制一些关键点的位置,此时就需要提供路径计算公式,通过在缩放时更新 viewBox 并重新计算 path 来重新绘制形状 + * + * keypoints?: 关键点位置百分比 + */ +export interface PPTShapeElement extends PPTBaseElement { + type: 'shape' + viewBox: [number, number] + path: string + fixedRatio: boolean + fill: string + gradient?: Gradient + pattern?: string + outline?: PPTElementOutline + opacity?: number + flipH?: boolean + flipV?: boolean + shadow?: PPTElementShadow + special?: boolean + text?: ShapeText + pathFormula?: ShapePathFormulasKeys + keypoints?: number[] +} + + +export type LinePoint = '' | 'arrow' | 'dot' + +/** + * 线条元素 + * + * type: 元素类型(line) + * + * start: 起点位置([x, y]) + * + * end: 终点位置([x, y]) + * + * style: 线条样式(实线、虚线、点线) + * + * color: 线条颜色 + * + * points: 端点样式([起点样式, 终点样式],可选:无、箭头、圆点) + * + * shadow?: 阴影 + * + * broken?: 折线控制点位置([x, y]) + * + * broken2?: 双折线控制点位置([x, y]) + * + * curve?: 二次曲线控制点位置([x, y]) + * + * cubic?: 三次曲线控制点位置([[x1, y1], [x2, y2]]) + */ +export interface PPTLineElement extends Omit { + type: 'line' + start: [number, number] + end: [number, number] + style: LineStyleType + color: string + points: [LinePoint, LinePoint] + shadow?: PPTElementShadow + broken?: [number, number] + broken2?: [number, number] + curve?: [number, number] + cubic?: [[number, number], [number, number]] +} + + +export type ChartType = 'bar' | 'column' | 'line' | 'pie' | 'ring' | 'area' | 'radar' | 'scatter' + +export interface ChartOptions { + lineSmooth?: boolean + stack?: boolean +} + +export interface ChartData { + labels: string[] + legends: string[] + series: number[][] +} + +/** + * 图表元素 + * + * type: 元素类型(chart) + * + * fill?: 填充色 + * + * chartType: 图表基础类型(bar/line/pie),所有图表类型都是由这三种基本类型衍生而来 + * + * data: 图表数据 + * + * options: 扩展选项 + * + * outline?: 边框 + * + * themeColors: 主题色 + * + * textColor?: 文字颜色 + */ +export interface PPTChartElement extends PPTBaseElement { + type: 'chart' + fill?: string + chartType: ChartType + data: ChartData + options?: ChartOptions + outline?: PPTElementOutline + themeColors: string[] + textColor?: string +} + + +export type TextAlign = 'left' | 'center' | 'right' | 'justify' +/** + * 表格单元格样式 + * + * bold?: 加粗 + * + * em?: 斜体 + * + * underline?: 下划线 + * + * strikethrough?: 删除线 + * + * color?: 字体颜色 + * + * backcolor?: 填充色 + * + * fontsize?: 字体大小 + * + * fontname?: 字体 + * + * align?: 对齐方式 + */ +export interface TableCellStyle { + bold?: boolean + em?: boolean + underline?: boolean + strikethrough?: boolean + color?: string + backcolor?: string + fontsize?: string + fontname?: string + align?: TextAlign +} + + +/** + * 表格单元格 + * + * id: 单元格ID + * + * colspan: 合并列数 + * + * rowspan: 合并行数 + * + * text: 文字内容 + * + * style?: 单元格样式 + */ +export interface TableCell { + id: string + colspan: number + rowspan: number + text: string + style?: TableCellStyle +} + +/** + * 表格主题 + * + * color: 主题色 + * + * rowHeader: 标题行 + * + * rowFooter: 汇总行 + * + * colHeader: 第一列 + * + * colFooter: 最后一列 + */ +export interface TableTheme { + color: string + rowHeader: boolean + rowFooter: boolean + colHeader: boolean + colFooter: boolean +} + +/** + * 表格元素 + * + * type: 元素类型(table) + * + * outline: 边框 + * + * theme?: 主题 + * + * colWidths: 列宽数组,如[30, 50, 20]表示三列宽度分别为30%, 50%, 20% + * + * cellMinHeight: 单元格最小高度 + * + * data: 表格数据 + */ +export interface PPTTableElement extends PPTBaseElement { + type: 'table' + outline: PPTElementOutline + theme?: TableTheme + colWidths: number[] + cellMinHeight: number + data: TableCell[][] +} + + +/** + * LaTeX元素(公式) + * + * type: 元素类型(latex) + * + * latex: latex代码 + * + * path: svg path + * + * color: 颜色 + * + * strokeWidth: 路径宽度 + * + * viewBox: SVG的viewBox属性 + * + * fixedRatio: 固定形状宽高比例 + */ +export interface PPTLatexElement extends PPTBaseElement { + type: 'latex' + latex: string + path: string + color: string + strokeWidth: number + viewBox: [number, number] + fixedRatio: boolean +} + +/** + * 视频元素 + * + * type: 元素类型(video) + * + * src: 视频地址 + * + * autoplay: 自动播放 + * + * poster: 预览封面 + * + * ext: 视频后缀,当资源链接缺少后缀时用该字段确认资源类型 + */ +export interface PPTVideoElement extends PPTBaseElement { + type: 'video' + src: string + autoplay: boolean + poster?: string + ext?: string +} + +/** + * 音频元素 + * + * type: 元素类型(audio) + * + * fixedRatio: 固定图标宽高比例 + * + * color: 图标颜色 + * + * loop: 循环播放 + * + * autoplay: 自动播放 + * + * src: 音频地址 + * + * ext: 音频后缀,当资源链接缺少后缀时用该字段确认资源类型 + */ +export interface PPTAudioElement extends PPTBaseElement { + type: 'audio' + fixedRatio: boolean + color: string + loop: boolean + autoplay: boolean + src: string + ext?: string +} + + +export type PPTElement = PPTTextElement | PPTImageElement | PPTShapeElement | PPTLineElement | PPTChartElement | PPTTableElement | PPTLatexElement | PPTVideoElement | PPTAudioElement + +export type AnimationType = 'in' | 'out' | 'attention' +export type AnimationTrigger = 'click' | 'meantime' | 'auto' + +/** + * 元素动画 + * + * id: 动画id + * + * elId: 元素ID + * + * effect: 动画效果 + * + * type: 动画类型(入场、退场、强调) + * + * duration: 动画持续时间 + * + * trigger: 动画触发方式(click - 单击时、meantime - 与上一动画同时、auto - 上一动画之后) + */ +export interface PPTAnimation { + id: string + elId: string + effect: string + type: AnimationType + duration: number + trigger: AnimationTrigger +} + +export type SlideBackgroundType = 'solid' | 'image' | 'gradient' +export type SlideBackgroundImageSize = 'cover' | 'contain' | 'repeat' +export interface SlideBackgroundImage { + src: string + size: SlideBackgroundImageSize, +} + +/** + * 幻灯片背景 + * + * type: 背景类型(纯色、图片、渐变) + * + * color?: 背景颜色(纯色) + * + * image?: 图片背景 + * + * gradientType?: 渐变背景 + */ +export interface SlideBackground { + type: SlideBackgroundType + color?: string + image?: SlideBackgroundImage + gradient?: Gradient +} + + +export type TurningMode = 'no' | 'fade' | 'slideX' | 'slideY' | 'random' | 'slideX3D' | 'slideY3D' | 'rotate' | 'scaleY' | 'scaleX' | 'scale' | 'scaleReverse' + +export interface NoteReply { + id: string + content: string + time: number + user: string +} + +export interface Note { + id: string + content: string + time: number + user: string + elId?: string + replies?: NoteReply[] +} + +export interface SectionTag { + id: string + title?: string +} + +export type SlideType = 'cover' | 'contents' | 'transition' | 'content' | 'end' + +/** + * 幻灯片页面 + * + * id: 页面ID + * + * elements: 元素集合 + * + * notes?: 批注 + * + * remark?: 备注 + * + * background?: 页面背景 + * + * animations?: 元素动画集合 + * + * turningMode?: 翻页方式 + * + * slideType?: 页面类型 + */ +export interface Slide { + id: string + elements: PPTElement[] + notes?: Note[] + remark?: string + background?: SlideBackground + animations?: PPTAnimation[] + turningMode?: TurningMode + sectionTag?: SectionTag + type?: SlideType +} + +/** + * 幻灯片主题 + * + * backgroundColor: 页面背景颜色 + * + * themeColor: 主题色,用于默认创建的形状颜色等 + * + * fontColor: 字体颜色 + * + * fontName: 字体 + */ +export interface SlideTheme { + backgroundColor: string + themeColors: string[] + fontColor: string + fontName: string + outline: PPTElementOutline + shadow: PPTElementShadow +} + +export interface SlideTemplate { + name: string + id: string + cover: string +} \ No newline at end of file diff --git a/frontend/src/types/toolbar.ts b/frontend/src/types/toolbar.ts new file mode 100644 index 0000000000000000000000000000000000000000..11b8742f7469251084a5fa60f5852861ee4a56a1 --- /dev/null +++ b/frontend/src/types/toolbar.ts @@ -0,0 +1,10 @@ +export const enum ToolbarStates { + SYMBOL = 'symbol', + EL_ANIMATION = 'elAnimation', + EL_STYLE = 'elStyle', + EL_POSITION = 'elPosition', + SLIDE_DESIGN = 'slideDesign', + SLIDE_ANIMATION = 'slideAnimation', + MULTI_STYLE = 'multiStyle', + MULTI_POSITION = 'multiPosition', +} \ No newline at end of file diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb9bd3885799cb48b10d59d151e6bd0c4f326900 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,98 @@ +import Clipboard from 'clipboard' +import { decrypt } from '@/utils/crypto' + +/** + * 复制文本到剪贴板 + * @param text 文本内容 + */ +export const copyText = (text: string) => { + return new Promise((resolve, reject) => { + const fakeElement = document.createElement('button') + const clipboard = new Clipboard(fakeElement, { + text: () => text, + action: () => 'copy', + container: document.body, + }) + clipboard.on('success', e => { + clipboard.destroy() + resolve(e) + }) + clipboard.on('error', e => { + clipboard.destroy() + reject(e) + }) + document.body.appendChild(fakeElement) + fakeElement.click() + document.body.removeChild(fakeElement) + }) +} + +// 读取剪贴板 +export const readClipboard = (): Promise => { + return new Promise((resolve, reject) => { + if (navigator.clipboard?.readText) { + navigator.clipboard.readText().then(text => { + if (!text) reject('剪贴板为空或者不包含文本') + return resolve(text) + }) + } + else reject('浏览器不支持或禁止访问剪贴板,请使用快捷键 Ctrl + V') + }) +} + +// 解析加密后的剪贴板内容 +export const pasteCustomClipboardString = (text: string) => { + let clipboardData + try { + clipboardData = JSON.parse(decrypt(text)) + } + catch { + clipboardData = text + } + + return clipboardData +} + +// 尝试解析剪贴板内容是否为Excel表格(或类似的)数据格式 +export const pasteExcelClipboardString = (text: string): string[][] | null => { + const lines: string[] = text.split('\r\n') + + if (lines[lines.length - 1] === '') lines.pop() + + let colCount = -1 + const data: string[][] = [] + for (const index in lines) { + data[index] = lines[index].split('\t') + + if (data[index].length === 1) return null + if (colCount === -1) colCount = data[index].length + else if (colCount !== data[index].length) return null + } + return data +} + +// 尝试解析剪贴板内容是否为HTML table代码 +export const pasteHTMLTableClipboardString = (text: string): string[][] | null => { + const parser = new DOMParser() + const doc = parser.parseFromString(text, 'text/html') + const table = doc.querySelector('table') + const data: string[][] = [] + + if (!table) return data + + const rows = table.querySelectorAll('tr') + for (const row of rows) { + const rowData = [] + const cells = row.querySelectorAll('td, th') + for (const cell of cells) { + const text = cell.textContent ? cell.textContent.trim() : '' + const colspan = parseInt(cell.getAttribute('colspan') || '1', 10) + for (let i = 0; i < colspan; i++) { + rowData.push(text) + } + } + data.push(rowData) + } + + return data +} \ No newline at end of file diff --git a/frontend/src/utils/common.ts b/frontend/src/utils/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e26f6bb26367d208dd5ee93207e3967f53e5c39 --- /dev/null +++ b/frontend/src/utils/common.ts @@ -0,0 +1,24 @@ +import { padStart } from 'lodash' + +/** + * 补足数字位数 + * @param digit 数字 + * @param len 位数 + */ +export const fillDigit = (digit: number, len: number) => { + return padStart('' + digit, len, '0') +} + +/** + * 判断设备 + */ +export const isPC = () => { + return !navigator.userAgent.match(/(iPhone|iPod|iPad|Android|Mobile|BlackBerry|Symbian|Windows Phone)/i) +} + +/** + * 判断URL字符串 + */ +export const isValidURL = (url: string) => { + return /^(https?:\/\/)([\w-]+\.)+[\w-]{2,}(\/[\w-./?%&=]*)?$/i.test(url) +} \ No newline at end of file diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts new file mode 100644 index 0000000000000000000000000000000000000000..21b78213fb5e2d8e8c2950f4d223bde0209e7026 --- /dev/null +++ b/frontend/src/utils/crypto.ts @@ -0,0 +1,20 @@ +import CryptoJS from 'crypto-js' + +const CRYPTO_KEY = 'pptist' + +/** + * 加密 + * @param msg 待加密字符串 + */ +export const encrypt = (msg: string) => { + return CryptoJS.AES.encrypt(msg, CRYPTO_KEY).toString() +} + +/** + * 解密 + * @param ciphertext 待解密字符串 + */ +export const decrypt = (ciphertext: string) => { + const bytes = CryptoJS.AES.decrypt(ciphertext, CRYPTO_KEY) + return bytes.toString(CryptoJS.enc.Utf8) +} \ No newline at end of file diff --git a/frontend/src/utils/database.ts b/frontend/src/utils/database.ts new file mode 100644 index 0000000000000000000000000000000000000000..48141039c885897054522ae275f4521a429e59f4 --- /dev/null +++ b/frontend/src/utils/database.ts @@ -0,0 +1,55 @@ +import Dexie, { type EntityTable } from 'dexie' +import { databaseId } from '@/store/main' +import type { Slide } from '@/types/slides' +import { LOCALSTORAGE_KEY_DISCARDED_DB } from '@/configs/storage' + +export interface writingBoardImg { + id: string + dataURL: string +} + +export interface Snapshot { + id: number + index: number + slides: Slide[] +} + +const databaseNamePrefix = 'PPTist' + +// 删除失效/过期的数据库 +// 应用关闭时(关闭或刷新浏览器),会将其数据库ID记录在 localStorage 中,表示该ID指向的数据库已失效 +// 当应用初始化时,检查当前所有数据库,将被记录失效的数据库删除 +// 另外,距离初始化时间超过12小时的数据库也将被删除(这是为了防止出现因意外未被正确删除的库) +export const deleteDiscardedDB = async () => { + const now = new Date().getTime() + + const localStorageDiscardedDB = localStorage.getItem(LOCALSTORAGE_KEY_DISCARDED_DB) + const localStorageDiscardedDBList: string[] = localStorageDiscardedDB ? JSON.parse(localStorageDiscardedDB) : [] + + const databaseNames = await Dexie.getDatabaseNames() + const discardedDBNames = databaseNames.filter(name => { + if (name.indexOf(databaseNamePrefix) === -1) return false + + const [prefix, id, time] = name.split('_') + if (prefix !== databaseNamePrefix || !id || !time) return true + if (localStorageDiscardedDBList.includes(id)) return true + if (now - (+time) >= 1000 * 60 * 60 * 12) return true + + return false + }) + + for (const name of discardedDBNames) Dexie.delete(name) + localStorage.removeItem(LOCALSTORAGE_KEY_DISCARDED_DB) +} + +const db = new Dexie(`${databaseNamePrefix}_${databaseId}_${new Date().getTime()}`) as Dexie & { + snapshots: EntityTable, + writingBoardImgs: EntityTable, +} + +db.version(1).stores({ + snapshots: '++id', + writingBoardImgs: 'id', +}) + +export { db } \ No newline at end of file diff --git a/frontend/src/utils/element.ts b/frontend/src/utils/element.ts new file mode 100644 index 0000000000000000000000000000000000000000..8847e1e6b525b8bdd43f9d5f81947172176ae9dd --- /dev/null +++ b/frontend/src/utils/element.ts @@ -0,0 +1,259 @@ +import tinycolor from 'tinycolor2' +import { nanoid } from 'nanoid' +import type { PPTElement, PPTLineElement, Slide } from '@/types/slides' + +interface RotatedElementData { + left: number + top: number + width: number + height: number + rotate: number +} + +interface IdMap { + [id: string]: string +} + +/** + * 计算元素在画布中的矩形范围旋转后的新位置范围 + * @param element 元素的位置大小和旋转角度信息 + */ +export const getRectRotatedRange = (element: RotatedElementData) => { + const { left, top, width, height, rotate = 0 } = element + + const radius = Math.sqrt( Math.pow(width, 2) + Math.pow(height, 2) ) / 2 + const auxiliaryAngle = Math.atan(height / width) * 180 / Math.PI + + const tlbraRadian = (180 - rotate - auxiliaryAngle) * Math.PI / 180 + const trblaRadian = (auxiliaryAngle - rotate) * Math.PI / 180 + + const middleLeft = left + width / 2 + const middleTop = top + height / 2 + + const xAxis = [ + middleLeft + radius * Math.cos(tlbraRadian), + middleLeft + radius * Math.cos(trblaRadian), + middleLeft - radius * Math.cos(tlbraRadian), + middleLeft - radius * Math.cos(trblaRadian), + ] + const yAxis = [ + middleTop - radius * Math.sin(tlbraRadian), + middleTop - radius * Math.sin(trblaRadian), + middleTop + radius * Math.sin(tlbraRadian), + middleTop + radius * Math.sin(trblaRadian), + ] + + return { + xRange: [Math.min(...xAxis), Math.max(...xAxis)], + yRange: [Math.min(...yAxis), Math.max(...yAxis)], + } +} + +/** + * 计算元素在画布中的矩形范围旋转后的新位置与旋转之前位置的偏离距离 + * @param element 元素的位置大小和旋转角度信息 + */ +export const getRectRotatedOffset = (element: RotatedElementData) => { + const { xRange: originXRange, yRange: originYRange } = getRectRotatedRange({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: 0, + }) + const { xRange: rotatedXRange, yRange: rotatedYRange } = getRectRotatedRange({ + left: element.left, + top: element.top, + width: element.width, + height: element.height, + rotate: element.rotate, + }) + return { + offsetX: rotatedXRange[0] - originXRange[0], + offsetY: rotatedYRange[0] - originYRange[0], + } +} + +/** + * 计算元素在画布中的位置范围 + * @param element 元素信息 + */ +export const getElementRange = (element: PPTElement) => { + let minX, maxX, minY, maxY + + if (element.type === 'line') { + minX = element.left + maxX = element.left + Math.max(element.start[0], element.end[0]) + minY = element.top + maxY = element.top + Math.max(element.start[1], element.end[1]) + } + else if ('rotate' in element && element.rotate) { + const { left, top, width, height, rotate } = element + const { xRange, yRange } = getRectRotatedRange({ left, top, width, height, rotate }) + minX = xRange[0] + maxX = xRange[1] + minY = yRange[0] + maxY = yRange[1] + } + else { + minX = element.left + maxX = element.left + element.width + minY = element.top + maxY = element.top + element.height + } + return { minX, maxX, minY, maxY } +} + +/** + * 计算一组元素在画布中的位置范围 + * @param elementList 一组元素信息 + */ +export const getElementListRange = (elementList: PPTElement[]) => { + const leftValues: number[] = [] + const topValues: number[] = [] + const rightValues: number[] = [] + const bottomValues: number[] = [] + + elementList.forEach(element => { + const { minX, maxX, minY, maxY } = getElementRange(element) + leftValues.push(minX) + topValues.push(minY) + rightValues.push(maxX) + bottomValues.push(maxY) + }) + + const minX = Math.min(...leftValues) + const maxX = Math.max(...rightValues) + const minY = Math.min(...topValues) + const maxY = Math.max(...bottomValues) + + return { minX, maxX, minY, maxY } +} + +/** + * 计算线条元素的长度 + * @param element 线条元素 + */ +export const getLineElementLength = (element: PPTLineElement) => { + const deltaX = element.end[0] - element.start[0] + const deltaY = element.end[1] - element.start[1] + const len = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + return len +} + +export interface AlignLine { + value: number + range: [number, number] +} + +/** + * 将一组对齐吸附线进行去重:同位置的的多条对齐吸附线仅留下一条,取该位置所有对齐吸附线的最大值和最小值为新的范围 + * @param lines 一组对齐吸附线信息 + */ +export const uniqAlignLines = (lines: AlignLine[]) => { + const uniqLines: AlignLine[] = [] + lines.forEach(line => { + const index = uniqLines.findIndex(_line => _line.value === line.value) + if (index === -1) uniqLines.push(line) + else { + const uniqLine = uniqLines[index] + const rangeMin = Math.min(uniqLine.range[0], line.range[0]) + const rangeMax = Math.max(uniqLine.range[1], line.range[1]) + const range: [number, number] = [rangeMin, rangeMax] + const _line = { value: line.value, range } + uniqLines[index] = _line + } + }) + return uniqLines +} + +/** + * 以页面列表为基础,为每一个页面生成新的ID,并关联到旧ID形成一个字典 + * 主要用于页面元素时,维持数据中各处页面ID原有的关系 + * @param slides 页面列表 + */ +export const createSlideIdMap = (slides: Slide[]) => { + const slideIdMap: IdMap = {} + for (const slide of slides) { + slideIdMap[slide.id] = nanoid(10) + } + return slideIdMap +} + +/** + * 以元素列表为基础,为每一个元素生成新的ID,并关联到旧ID形成一个字典 + * 主要用于复制元素时,维持数据中各处元素ID原有的关系 + * 例如:原本两个组合的元素拥有相同的groupId,复制后依然会拥有另一个相同的groupId + * @param elements 元素列表数据 + */ +export const createElementIdMap = (elements: PPTElement[]) => { + const groupIdMap: IdMap = {} + const elIdMap: IdMap = {} + for (const element of elements) { + const groupId = element.groupId + if (groupId && !groupIdMap[groupId]) { + groupIdMap[groupId] = nanoid(10) + } + elIdMap[element.id] = nanoid(10) + } + return { + groupIdMap, + elIdMap, + } +} + +/** + * 根据表格的主题色,获取对应用于配色的子颜色 + * @param themeColor 主题色 + */ +export const getTableSubThemeColor = (themeColor: string) => { + const rgba = tinycolor(themeColor) + return [ + rgba.setAlpha(0.3).toRgbString(), + rgba.setAlpha(0.1).toRgbString(), + ] +} + +/** + * 获取线条元素路径字符串 + * @param element 线条元素 + */ +export const getLineElementPath = (element: PPTLineElement) => { + const start = element.start.join(',') + const end = element.end.join(',') + if (element.broken) { + const mid = element.broken.join(',') + return `M${start} L${mid} L${end}` + } + else if (element.broken2) { + const { minX, maxX, minY, maxY } = getElementRange(element) + if (maxX - minX >= maxY - minY) return `M${start} L${element.broken2[0]},${element.start[1]} L${element.broken2[0]},${element.end[1]} ${end}` + return `M${start} L${element.start[0]},${element.broken2[1]} L${element.end[0]},${element.broken2[1]} ${end}` + } + else if (element.curve) { + const mid = element.curve.join(',') + return `M${start} Q${mid} ${end}` + } + else if (element.cubic) { + const [c1, c2] = element.cubic + const p1 = c1.join(',') + const p2 = c2.join(',') + return `M${start} C${p1} ${p2} ${end}` + } + return `M${start} L${end}` +} + +/** + * 判断一个元素是否在可视范围内 + * @param element 元素 + * @param parent 父元素 + */ +export const isElementInViewport = (element: HTMLElement, parent: HTMLElement): boolean => { + const elementRect = element.getBoundingClientRect() + const parentRect = parent.getBoundingClientRect() + + return ( + elementRect.top >= parentRect.top && + elementRect.bottom <= parentRect.bottom + ) +} \ No newline at end of file diff --git a/frontend/src/utils/emitter.ts b/frontend/src/utils/emitter.ts new file mode 100644 index 0000000000000000000000000000000000000000..b858d9ef556f2cc36893fe34e85583b9c54f9518 --- /dev/null +++ b/frontend/src/utils/emitter.ts @@ -0,0 +1,29 @@ +import mitt, { type Emitter } from 'mitt' + +export const enum EmitterEvents { + RICH_TEXT_COMMAND = 'RICH_TEXT_COMMAND', + SYNC_RICH_TEXT_ATTRS_TO_STORE = 'SYNC_RICH_TEXT_ATTRS_TO_STORE', + OPEN_CHART_DATA_EDITOR = 'OPEN_CHART_DATA_EDITOR', + OPEN_LATEX_EDITOR = 'OPEN_LATEX_EDITOR', +} + +export interface RichTextAction { + command: string + value?: string +} + +export interface RichTextCommand { + target?: string + action: RichTextAction | RichTextAction[] +} + +type Events = { + [EmitterEvents.RICH_TEXT_COMMAND]: RichTextCommand + [EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE]: void + [EmitterEvents.OPEN_CHART_DATA_EDITOR]: void + [EmitterEvents.OPEN_LATEX_EDITOR]: void +} + +const emitter: Emitter = mitt() + +export default emitter \ No newline at end of file diff --git a/frontend/src/utils/fullscreen.ts b/frontend/src/utils/fullscreen.ts new file mode 100644 index 0000000000000000000000000000000000000000..98498dcb948f0f7cbcd411cb2a0bb74ef662fd58 --- /dev/null +++ b/frontend/src/utils/fullscreen.ts @@ -0,0 +1,27 @@ +// 进入全屏 +export const enterFullscreen = () => { + const docElm = document.documentElement + if (docElm.requestFullscreen) docElm.requestFullscreen() + else if (docElm.mozRequestFullScreen) docElm.mozRequestFullScreen() + else if (docElm.webkitRequestFullScreen) docElm.webkitRequestFullScreen() + else if (docElm.msRequestFullscreen) docElm.msRequestFullscreen() +} + +// 退出全屏 +export const exitFullscreen = () => { + if (document.exitFullscreen) document.exitFullscreen() + else if (document.mozCancelFullScreen) document.mozCancelFullScreen() + else if (document.webkitExitFullscreen) document.webkitExitFullscreen() + else if (document.msExitFullscreen) document.msExitFullscreen() +} + +// 判断是否全屏 +export const isFullscreen = () => { + const fullscreenElement = + document.fullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + document.msFullscreenElement || + document.webkitCurrentFullScreenElement + return !!fullscreenElement +} \ No newline at end of file diff --git a/frontend/src/utils/htmlParser/format.ts b/frontend/src/utils/htmlParser/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e82fa2cf41ad8e37272a67a8276f562c4936696 --- /dev/null +++ b/frontend/src/utils/htmlParser/format.ts @@ -0,0 +1,47 @@ +import type { HTMLNode, CommentOrTextAST, ElementAST, AST } from './types' + +export const splitHead = (str: string, sep: string) => { + const idx = str.indexOf(sep) + if (idx === -1) return [str] + return [str.slice(0, idx), str.slice(idx + sep.length)] +} + +const unquote = (str: string) => { + const car = str.charAt(0) + const end = str.length - 1 + const isQuoteStart = car === '"' || car === "'" + if (isQuoteStart && car === str.charAt(end)) { + return str.slice(1, end) + } + return str +} + +const formatAttributes = (attributes: string[]) => { + return attributes.map(attribute => { + const parts = splitHead(attribute.trim(), '=') + const key = parts[0] + const value = typeof parts[1] === 'string' ? unquote(parts[1]) : null + return { key, value } + }) +} + +export const format = (nodes: HTMLNode[]): AST[] => { + return nodes.map(node => { + if (node.type === 'element') { + const children = format(node.children) + const item: ElementAST = { + type: 'element', + tagName: node.tagName.toLowerCase(), + attributes: formatAttributes(node.attributes), + children, + } + return item + } + + const item: CommentOrTextAST = { + type: node.type, + content: node.content, + } + return item + }) +} \ No newline at end of file diff --git a/frontend/src/utils/htmlParser/index.ts b/frontend/src/utils/htmlParser/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..27230d15bf07a816bfd8b7dc4c0440d4476a9020 --- /dev/null +++ b/frontend/src/utils/htmlParser/index.ts @@ -0,0 +1,15 @@ +// 参考:https://github.com/andrejewski/himalaya 用TypeScript重写并简化部分功能 + +import { lexer } from './lexer' +import { parser } from './parser' +import { format } from './format' +import { toHTML } from './stringify' +export type { AST } from './types' + +export const toAST = (str: string) => { + const tokens = lexer(str) + const nodes = parser(tokens) + return format(nodes) +} + +export { toHTML } diff --git a/frontend/src/utils/htmlParser/lexer.ts b/frontend/src/utils/htmlParser/lexer.ts new file mode 100644 index 0000000000000000000000000000000000000000..06fbc9aee60e57d6e393503c45b87ca455e80ef7 --- /dev/null +++ b/frontend/src/utils/htmlParser/lexer.ts @@ -0,0 +1,275 @@ +import { startsWith, endsWith } from 'lodash' +import type { Token } from './types' +import { childlessTags } from './tags' + +interface State { + str: string + position: number + tokens: Token[] +} + +const jumpPosition = (state: State, end: number) => { + const len = end - state.position + movePositopn(state, len) +} + +const movePositopn = (state: State, len: number) => { + state.position = state.position + len +} + +const findTextEnd = (str: string, index: number) => { + const isEnd = false + while (!isEnd) { + const textEnd = str.indexOf('<', index) + if (textEnd === -1) { + return textEnd + } + const char = str.charAt(textEnd + 1) + if (char === '/' || char === '!' || /[A-Za-z0-9]/.test(char)) { + return textEnd + } + index = textEnd + 1 + } + return -1 +} + +const lexText = (state: State) => { + const { str } = state + let textEnd = findTextEnd(str, state.position) + if (textEnd === state.position) return + if (textEnd === -1) { + textEnd = str.length + } + + const content = str.slice(state.position, textEnd) + jumpPosition(state, textEnd) + + state.tokens.push({ + type: 'text', + content, + }) +} + +const lexComment = (state: State) => { + const { str } = state + + movePositopn(state, 4) + let contentEnd = str.indexOf('-->', state.position) + let commentEnd = contentEnd + 3 + if (contentEnd === -1) { + contentEnd = commentEnd = str.length + } + + const content = str.slice(state.position, contentEnd) + jumpPosition(state, commentEnd) + + state.tokens.push({ + type: 'comment', + content, + }) +} + +const lexTagName = (state: State) => { + const { str } = state + const len = str.length + let start = state.position + + while (start < len) { + const char = str.charAt(start) + const isTagChar = !(/\s/.test(char) || char === '/' || char === '>') + if (isTagChar) break + start++ + } + + let end = start + 1 + while (end < len) { + const char = str.charAt(end) + const isTagChar = !(/\s/.test(char) || char === '/' || char === '>') + if (!isTagChar) break + end++ + } + + jumpPosition(state, end) + const tagName = str.slice(start, end) + state.tokens.push({ + type: 'tag', + content: tagName + }) + return tagName +} + +const lexTagAttributes = (state: State) => { + const { str, tokens } = state + let cursor = state.position + let quote = null + let wordBegin = cursor + const words = [] + const len = str.length + while (cursor < len) { + const char = str.charAt(cursor) + if (quote) { + const isQuoteEnd = char === quote + if (isQuoteEnd) quote = null + cursor++ + continue + } + + const isTagEnd = char === '/' || char === '>' + if (isTagEnd) { + if (cursor !== wordBegin) words.push(str.slice(wordBegin, cursor)) + break + } + + const isWordEnd = /\s/.test(char) + if (isWordEnd) { + if (cursor !== wordBegin) words.push(str.slice(wordBegin, cursor)) + wordBegin = cursor + 1 + cursor++ + continue + } + + const isQuoteStart = char === '\'' || char === '"' + if (isQuoteStart) { + quote = char + cursor++ + continue + } + + cursor++ + } + jumpPosition(state, cursor) + + const type = 'attribute' + for (let i = 0; i < words.length; i++) { + const word = words[i] + + const isNotPair = word.indexOf('=') === -1 + if (isNotPair) { + const secondWord = words[i + 1] + if (secondWord && startsWith(secondWord, '=')) { + if (secondWord.length > 1) { + const newWord = word + secondWord + tokens.push({ type, content: newWord }) + i += 1 + continue + } + const thirdWord = words[i + 2] + i += 1 + if (thirdWord) { + const newWord = word + '=' + thirdWord + tokens.push({ type, content: newWord }) + i += 1 + continue + } + } + } + if (endsWith(word, '=')) { + const secondWord = words[i + 1] + if (secondWord && secondWord.indexOf('=') === -1) { + const newWord = word + secondWord + tokens.push({ type, content: newWord }) + i += 1 + continue + } + + const newWord = word.slice(0, -1) + tokens.push({ type, content: newWord }) + continue + } + + tokens.push({ type, content: word }) + } +} + +const lexSkipTag = (tagName: string, state: State) => { + const { str, tokens } = state + const safeTagName = tagName.toLowerCase() + const len = str.length + let index = state.position + + while (index < len) { + const nextTag = str.indexOf('', index) + if (nextTag === -1) { + lexText(state) + break + } + + const tagState = { + str, + position: state.position, + tokens: [], + } + jumpPosition(tagState, nextTag) + const name = lexTag(tagState) + if (safeTagName !== name.toLowerCase()) { + index = tagState.position + continue + } + + if (nextTag !== state.position) { + const textStart = state.position + jumpPosition(state, nextTag) + tokens.push({ + type: 'text', + content: str.slice(textStart, nextTag), + }) + } + + tokens.push(...tagState.tokens) + jumpPosition(state, tagState.position) + break + } +} + +const lexTag = (state: State) => { + const { str } = state + const secondChar = str.charAt(state.position + 1) + const tagStartClose = secondChar === '/' + movePositopn(state, tagStartClose ? 2 : 1) + state.tokens.push({ + type: 'tag-start', + close: tagStartClose, + }) + + const tagName = lexTagName(state) + lexTagAttributes(state) + + const firstChar = str.charAt(state.position) + const tagEndClose = firstChar === '/' + movePositopn(state, tagEndClose ? 2 : 1) + state.tokens.push({ + type: 'tag-end', + close: tagEndClose, + }) + return tagName +} + +const lex = (state: State) => { + const str = state.str + const len = str.length + + while (state.position < len) { + const start = state.position + lexText(state) + + if (state.position === start) { + const isComment = startsWith(str, '!--', start + 1) + if (isComment) lexComment(state) + else { + const tagName = lexTag(state) + const safeTag = tagName.toLowerCase() + if (childlessTags.includes(safeTag)) lexSkipTag(tagName, state) + } + } + } +} + +export const lexer = (str: string): Token[] => { + const state = { + str, + position: 0, + tokens: [], + } + lex(state) + return state.tokens +} \ No newline at end of file diff --git a/frontend/src/utils/htmlParser/parser.ts b/frontend/src/utils/htmlParser/parser.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf848797aeebf15ffd66b61c2361de7df9b3a286 --- /dev/null +++ b/frontend/src/utils/htmlParser/parser.ts @@ -0,0 +1,129 @@ +import type { Token, HTMLNode, TagToken, NormalElement, TagEndToken, AttributeToken, TextToken } from './types' +import { closingTags, closingTagAncestorBreakers, voidTags } from './tags' + +interface StackItem { + tagName: string | null + children: HTMLNode[] +} + +interface State { + stack: StackItem[] + cursor: number + tokens: Token[] +} + +export const parser = (tokens: Token[]) => { + const root: StackItem = { tagName: null, children: [] } + const state: State = { tokens, cursor: 0, stack: [root] } + parse(state) + return root.children +} + +export const hasTerminalParent = (tagName: string, stack: StackItem[]) => { + const tagParents = closingTagAncestorBreakers[tagName] + if (tagParents) { + let currentIndex = stack.length - 1 + while (currentIndex >= 0) { + const parentTagName = stack[currentIndex].tagName + if (parentTagName === tagName) break + if (parentTagName && tagParents.includes(parentTagName)) return true + currentIndex-- + } + } + return false +} + +export const rewindStack = (stack: StackItem[], newLength: number) => { + stack.splice(newLength) +} + +export const parse = (state: State) => { + const { stack, tokens } = state + let { cursor } = state + let nodes = stack[stack.length - 1].children + const len = tokens.length + + while (cursor < len) { + const token = tokens[cursor] + if (token.type !== 'tag-start') { + nodes.push(token as TextToken) + cursor++ + continue + } + + const tagToken = tokens[++cursor] as TagToken + cursor++ + const tagName = tagToken.content.toLowerCase() + if (token.close) { + let index = stack.length + let shouldRewind = false + while (--index > -1) { + if (stack[index].tagName === tagName) { + shouldRewind = true + break + } + } + while (cursor < len) { + if (tokens[cursor].type !== 'tag-end') break + cursor++ + } + if (shouldRewind) { + rewindStack(stack, index) + break + } + else continue + } + + const isClosingTag = closingTags.includes(tagName) + let shouldRewindToAutoClose = isClosingTag + if (shouldRewindToAutoClose) { + shouldRewindToAutoClose = !hasTerminalParent(tagName, stack) + } + + if (shouldRewindToAutoClose) { + let currentIndex = stack.length - 1 + while (currentIndex > 0) { + if (tagName === stack[currentIndex].tagName) { + rewindStack(stack, currentIndex) + const previousIndex = currentIndex - 1 + nodes = stack[previousIndex].children + break + } + currentIndex = currentIndex - 1 + } + } + + const attributes = [] + let tagEndToken: TagEndToken | undefined + while (cursor < len) { + const _token = tokens[cursor] + if (_token.type === 'tag-end') { + tagEndToken = _token + break + } + attributes.push((_token as AttributeToken).content) + cursor++ + } + + if (!tagEndToken) break + + cursor++ + const children: HTMLNode[] = [] + const elementNode: NormalElement = { + type: 'element', + tagName: tagToken.content, + attributes, + children, + } + nodes.push(elementNode) + + const hasChildren = !(tagEndToken.close || voidTags.includes(tagName)) + if (hasChildren) { + stack.push({tagName, children}) + const innerState = { tokens, cursor, stack } + parse(innerState) + cursor = innerState.cursor + } + } + state.cursor = cursor +} \ No newline at end of file diff --git a/frontend/src/utils/htmlParser/stringify.ts b/frontend/src/utils/htmlParser/stringify.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b13762718719477e5023df1d25237019c74f578 --- /dev/null +++ b/frontend/src/utils/htmlParser/stringify.ts @@ -0,0 +1,28 @@ +import type { AST, ElementAST, ElementAttribute } from './types' +import { voidTags } from './tags' + +export const formatAttributes = (attributes: ElementAttribute[]) => { + return attributes.reduce((attrs, attribute) => { + const { key, value } = attribute + if (value === null) return `${attrs} ${key}` + if (key === 'style' && !value) return '' + + const quoteEscape = value.indexOf('\'') !== -1 + const quote = quoteEscape ? '"' : '\'' + return `${attrs} ${key}=${quote}${value}${quote}` + }, '') +} + +export const toHTML = (tree: AST[]) => { + const htmlStrings: string[] = tree.map(node => { + if (node.type === 'text') return node.content + if (node.type === 'comment') return `` + + const { tagName, attributes, children } = node as ElementAST + const isSelfClosing = voidTags.includes(tagName.toLowerCase()) + + if (isSelfClosing) return `<${tagName}${formatAttributes(attributes)}>` + return `<${tagName}${formatAttributes(attributes)}>${toHTML(children)}${tagName}>` + }) + return htmlStrings.join('') +} \ No newline at end of file diff --git a/frontend/src/utils/htmlParser/tags.ts b/frontend/src/utils/htmlParser/tags.ts new file mode 100644 index 0000000000000000000000000000000000000000..9320250de109541fbc3913d8bdddb09ed36d34d7 --- /dev/null +++ b/frontend/src/utils/htmlParser/tags.ts @@ -0,0 +1,20 @@ +export const childlessTags = ['style', 'script', 'template'] + +export const closingTags = ['html', 'head', 'body', 'p', 'dt', 'dd', 'li', 'option', 'thead', 'th', 'tbody', 'tr', 'td', 'tfoot', 'colgroup'] + +interface ClosingTagAncestorBreakers { + [key: string]: string[] +} + +export const closingTagAncestorBreakers: ClosingTagAncestorBreakers = { + li: ['ul', 'ol', 'menu'], + dt: ['dl'], + dd: ['dl'], + tbody: ['table'], + thead: ['table'], + tfoot: ['table'], + tr: ['table'], + td: ['table'], +} + +export const voidTags = ['!doctype', 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'] \ No newline at end of file diff --git a/frontend/src/utils/htmlParser/types.ts b/frontend/src/utils/htmlParser/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cc0b86978f1853086a4c7145f66baf5d05df0b1 --- /dev/null +++ b/frontend/src/utils/htmlParser/types.ts @@ -0,0 +1,69 @@ +export interface ElementAttribute { + key: string + value: string | null +} + +export interface CommentElement { + type: 'comment' + content: string +} + +export interface TextElement { + type: 'text' + content: string +} + +export interface NormalElement { + type: 'element' + tagName: string + children: HTMLNode[] + attributes: string[] +} + +export type HTMLNode = CommentElement | TextElement | NormalElement + +export interface ElementAST { + type: 'element' + tagName: string + children: AST[] + attributes: ElementAttribute[] +} + +export interface CommentOrTextAST { + type: 'comment' | 'text' + content: string +} + +export type AST = CommentOrTextAST | ElementAST + +export interface TagStartToken { + type: 'tag-start' + close: boolean +} + +export interface TagEndToken { + type: 'tag-end' + close: boolean +} + +export interface TagToken { + type: 'tag' + content: string +} + +export interface TextToken { + type: 'text' + content: string +} + +export interface CommentToken { + type: 'comment' + content: string +} + +export interface AttributeToken { + type: 'attribute' + content: string +} + +export type Token = TagStartToken | TagEndToken | TagToken | TextToken | CommentToken | AttributeToken diff --git a/frontend/src/utils/image.ts b/frontend/src/utils/image.ts new file mode 100644 index 0000000000000000000000000000000000000000..306ddbc81f2937da4115dd55433f0a075ae9f797 --- /dev/null +++ b/frontend/src/utils/image.ts @@ -0,0 +1,75 @@ +interface ImageSize { + width: number + height: number +} + +/** + * 获取图片的原始宽高 + * @param src 图片地址 + */ +export const getImageSize = (src: string): Promise => { + return new Promise(resolve => { + const img = document.createElement('img') + img.src = src + img.style.opacity = '0' + document.body.appendChild(img) + + img.onload = () => { + const imgWidth = img.clientWidth + const imgHeight = img.clientHeight + + img.onload = null + img.onerror = null + + document.body.removeChild(img) + + resolve({ width: imgWidth, height: imgHeight }) + } + + img.onerror = () => { + img.onload = null + img.onerror = null + } + }) +} + +/** + * 读取图片文件的dataURL + * @param file 图片文件 + */ +export const getImageDataURL = (file: File): Promise => { + return new Promise(resolve => { + const reader = new FileReader() + reader.addEventListener('load', () => { + resolve(reader.result as string) + }) + reader.readAsDataURL(file) + }) +} + +/** + * 判断是否为SVG代码字符串 + * @param text 待验证文本 + */ +export const isSVGString = (text: string): boolean => { + const svgRegex = /[\s\S]*?<\/svg>/i + if (!svgRegex.test(text)) return false + + try { + const parser = new DOMParser() + const doc = parser.parseFromString(text, 'image/svg+xml') + return doc.documentElement.nodeName === 'svg' + } + catch { + return false + } +} + +/** + * SVG代码转文件 + * @param svg SVG代码 + */ +export const svg2File = (svg: string): File => { + const blob = new Blob([svg], { type: 'image/svg+xml' }) + return new File([blob], `${Date.now()}.svg`, { type: 'image/svg+xml' }) +} \ No newline at end of file diff --git a/frontend/src/utils/message.ts b/frontend/src/utils/message.ts new file mode 100644 index 0000000000000000000000000000000000000000..84a4ce3827e1e0d9d24e5426ab48c344cb964761 --- /dev/null +++ b/frontend/src/utils/message.ts @@ -0,0 +1,103 @@ +import { createVNode, render, type AppContext } from 'vue' +import MessageComponent from '@/components/Message.vue' + +export interface MessageOptions { + type?: 'info' | 'success' | 'warning' | 'error' + title?: string + message?: string + duration?: number + closable?: boolean + ctx?: AppContext + onClose?: () => void +} + +export type MessageTypeOptions = Omit +export interface MessageIntance { + id: string + close: () => void +} + +export type MessageFn = (message: string, options?: MessageTypeOptions) => MessageIntance +export interface Message { + (options: MessageOptions): MessageIntance + info: MessageFn + success: MessageFn + error: MessageFn + warning: MessageFn + closeAll: () => void + _context?: AppContext | null +} + +const instances: MessageIntance[] = [] +let wrap: HTMLDivElement | null = null +let seed = 0 +const defaultOptions: MessageOptions = { + duration: 3000, +} + +const message: Message = (options: MessageOptions) => { + const id = 'message-' + seed++ + const props = { + ...defaultOptions, + ...options, + id, + } + + if (!wrap) { + wrap = document.createElement('div') + wrap.className = 'message-wrap' + wrap.style.cssText = ` + width: 100%; + position: fixed; + top: 0; + left: 0; + z-index: 6000; + pointer-events: none; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 15px; + background-color: rgba(255, 255, 255, 0); + transition: all 1s ease-in-out; + align-items: center; + ` + document.body.appendChild(wrap) + } + + const vm = createVNode(MessageComponent, props, null) + const div = document.createElement('div') + + vm.appContext = options.ctx || message._context || null + vm.props!.onClose = options.onClose + vm.props!.onDestroy = () => { + if (wrap && wrap.childNodes.length <= 1) { + wrap.remove() + wrap = null + } + render(null, div) + } + + render(vm, div) + wrap.appendChild(div.firstElementChild!) + + const instance = { + id, + close: () => vm?.component?.exposed?.close(), + } + + instances.push(instance) + return instance +} + +message.success = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'success', message: msg }) +message.info = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'info', message: msg }) +message.warning = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'warning', message: msg }) +message.error = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'error', message: msg }) + +message.closeAll = function() { + for (let i = instances.length - 1; i >= 0; i--) { + instances[i].close() + } +} + +export default message \ No newline at end of file diff --git a/frontend/src/utils/print.ts b/frontend/src/utils/print.ts new file mode 100644 index 0000000000000000000000000000000000000000..f28f3e826a79d76657c67fa6a3fefaad71ef7d1c --- /dev/null +++ b/frontend/src/utils/print.ts @@ -0,0 +1,87 @@ +interface PageSize { + width: number + height: number + margin: number +} + +const createIframe = () => { + const iframe = document.createElement('iframe') + iframe.style.width = '0' + iframe.style.height = '0' + iframe.style.position = 'absolute' + iframe.style.right = '0' + iframe.style.top = '0' + iframe.style.border = '0' + + document.body.appendChild(iframe) + + return iframe +} + +const writeContent = (doc: Document, printNode: HTMLElement, size: PageSize) => { + const docType = '' + + let style = '' + const styleSheets = document.styleSheets + if (styleSheets) { + for (const styleSheet of styleSheets) { + if (!styleSheet.cssRules) continue + + for (const rule of styleSheet.cssRules) { + style += rule.cssText + } + } + } + + const { width, height, margin } = size + const head = ` + + + + ` + const body = '' + printNode.innerHTML + '' + + doc.open() + doc.write(` + ${docType} + + ${head} + ${body} + + `) + doc.close() +} + +export const print = (printNode: HTMLElement, size: PageSize) => { + const iframe = createIframe() + const iframeContentWindow = iframe.contentWindow + + if (!iframe.contentDocument || !iframeContentWindow) return + writeContent(iframe.contentDocument, printNode, size) + + const handleLoadIframe = () => { + iframeContentWindow.focus() + iframeContentWindow.print() + } + + const handleAfterprint = () => { + iframe.removeEventListener('load', handleLoadIframe) + iframeContentWindow.removeEventListener('afterprint', handleAfterprint) + document.body.removeChild(iframe) + } + + iframe.addEventListener('load', handleLoadIframe) + iframeContentWindow.addEventListener('afterprint', handleAfterprint) +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/commands/setListStyle.ts b/frontend/src/utils/prosemirror/commands/setListStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..58d6fdf2c6029d99dbad194afbf58f7500b53f73 --- /dev/null +++ b/frontend/src/utils/prosemirror/commands/setListStyle.ts @@ -0,0 +1,31 @@ +import type { EditorView } from 'prosemirror-view' +import { isList } from '../utils' + +interface Style { + [key: string]: string +} + +export const setListStyle = (view: EditorView, style: Style | Style[]) => { + const { state } = view + const { schema, selection } = state + const tr = state.tr.setSelection(selection) + + const { doc } = tr + if (!doc) return tr + + const { from, to } = selection + doc.nodesBetween(from, to, (node, pos) => { + if (isList(node, schema)) { + if (from - 3 <= pos && to + 3 >= pos + node.nodeSize) { + const styles = Array.isArray(style) ? style : [style] + + for (const style of styles) { + tr.setNodeAttribute(pos, style.key, style.value) + } + } + } + return false + }) + + view.dispatch(tr) +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/commands/setTextAlign.ts b/frontend/src/utils/prosemirror/commands/setTextAlign.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca1139fa5483bee7ec99e20a7f9f3b9019605c7a --- /dev/null +++ b/frontend/src/utils/prosemirror/commands/setTextAlign.ts @@ -0,0 +1,62 @@ +import type { Schema, Node, NodeType } from 'prosemirror-model' +import type { Transaction } from 'prosemirror-state' +import type { EditorView } from 'prosemirror-view' + +export const setTextAlign = (tr: Transaction, schema: Schema, alignment: string) => { + const { selection, doc } = tr + if (!selection || !doc) return tr + + const { from, to } = selection + const { nodes } = schema + + const blockquote = nodes.blockquote + const listItem = nodes.list_item + const paragraph = nodes.paragraph + + interface Task { + node: Node + pos: number + nodeType: NodeType + } + + const tasks: Task[] = [] + alignment = alignment || '' + + const allowedNodeTypes = new Set([blockquote, listItem, paragraph]) + + doc.nodesBetween(from, to, (node, pos) => { + const nodeType = node.type + const align = node.attrs.align || '' + if (align !== alignment && allowedNodeTypes.has(nodeType)) { + tasks.push({ + node, + pos, + nodeType, + }) + } + return true + }) + + if (!tasks.length) return tr + + tasks.forEach(task => { + const { node, pos, nodeType } = task + let { attrs } = node + if (alignment) attrs = { ...attrs, align: alignment } + else attrs = { ...attrs, align: null } + tr = tr.setNodeMarkup(pos, nodeType, attrs, node.marks) + }) + + return tr +} + +export const alignmentCommand = (view: EditorView, alignment: string) => { + const { state } = view + const { schema, selection } = state + const tr = setTextAlign( + state.tr.setSelection(selection), + schema, + alignment, + ) + view.dispatch(tr) +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/commands/setTextIndent.ts b/frontend/src/utils/prosemirror/commands/setTextIndent.ts new file mode 100644 index 0000000000000000000000000000000000000000..f87d0ac93e4d317df7164e8732da5d3cd5fd8dca --- /dev/null +++ b/frontend/src/utils/prosemirror/commands/setTextIndent.ts @@ -0,0 +1,87 @@ +import type { Schema } from 'prosemirror-model' +import { type Transaction, TextSelection, AllSelection } from 'prosemirror-state' +import type { EditorView } from 'prosemirror-view' +import { isList } from '../utils' + +type IndentKey = 'indent' | 'textIndent' + +function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number, indentKey: IndentKey): Transaction { + if (!tr.doc) return tr + + const node = tr.doc.nodeAt(pos) + if (!node) return tr + + const minIndent = 0 + const maxIndent = 8 + + let indent = (node.attrs[indentKey] || 0) + delta + if (indent < minIndent) indent = minIndent + if (indent > maxIndent) indent = maxIndent + + if (indent === node.attrs[indentKey]) return tr + + const nodeAttrs = { + ...node.attrs, + [indentKey]: indent, + } + + return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks) +} + +const setIndent = (tr: Transaction, schema: Schema, delta: number, indentKey: IndentKey): Transaction => { + const { selection, doc } = tr + if (!selection || !doc) return tr + + if (!(selection instanceof TextSelection || selection instanceof AllSelection)) return tr + + const { from, to } = selection + + doc.nodesBetween(from, to, (node, pos) => { + const nodeType = node.type + + if (nodeType.name === 'paragraph' || nodeType.name === 'blockquote') { + tr = setNodeIndentMarkup(tr, pos, delta, indentKey) + return false + } + else if (isList(node, schema)) return false + return true + }) + + return tr +} + +export const indentCommand = (view: EditorView, delta: number) => { + const { state } = view + const { schema, selection } = state + + const tr = setIndent( + state.tr.setSelection(selection), + schema, + delta, + 'indent', + ) + if (tr.docChanged) { + view.dispatch(tr) + return true + } + + return false +} + +export const textIndentCommand = (view: EditorView, delta: number) => { + const { state } = view + const { schema, selection } = state + + const tr = setIndent( + state.tr.setSelection(selection), + schema, + delta, + 'textIndent', + ) + if (tr.docChanged) { + view.dispatch(tr) + return true + } + + return false +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/commands/toggleList.ts b/frontend/src/utils/prosemirror/commands/toggleList.ts new file mode 100644 index 0000000000000000000000000000000000000000..4605e7512f796c80715399f78c8cf75a67db9354 --- /dev/null +++ b/frontend/src/utils/prosemirror/commands/toggleList.ts @@ -0,0 +1,54 @@ +import { wrapInList, liftListItem } from 'prosemirror-schema-list' +import type { Node, NodeType } from 'prosemirror-model' +import type { Transaction, EditorState } from 'prosemirror-state' +import { findParentNode, isList } from '../utils' + +interface Attr { + [key: string]: number | string +} + +interface TextStyleAttr { + color?: string + fontsize?: string +} + +export const toggleList = (listType: NodeType, itemType: NodeType, listStyleType: string, textStyleAttr: TextStyleAttr = {}) => { + return (state: EditorState, dispatch: (tr: Transaction) => void) => { + const { schema, selection } = state + const { $from, $to } = selection + const range = $from.blockRange($to) + + if (!range) return false + + const parentList = findParentNode((node: Node) => isList(node, schema))(selection) + + if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) { + if (parentList.node.type === listType && !listStyleType) { + return liftListItem(itemType)(state, dispatch) + } + + if (isList(parentList.node, schema) && listType.validContent(parentList.node.content)) { + const { tr } = state + + const nodeAttrs: Attr = { + ...parentList.node.attrs, + ...textStyleAttr, + } + if (listStyleType) nodeAttrs.listStyleType = listStyleType + + tr.setNodeMarkup(parentList.pos, listType, nodeAttrs) + + if (dispatch) dispatch(tr) + + return false + } + } + + const nodeAttrs: Attr = { + ...textStyleAttr, + } + if (listStyleType) nodeAttrs.listStyleType = listStyleType + + return wrapInList(listType, nodeAttrs)(state, dispatch) + } +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/index.ts b/frontend/src/utils/prosemirror/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c70b91c4699542e30ae0975c936c615b9d4f5f43 --- /dev/null +++ b/frontend/src/utils/prosemirror/index.ts @@ -0,0 +1,32 @@ +import { EditorState } from 'prosemirror-state' +import { type DirectEditorProps, EditorView } from 'prosemirror-view' +import { Schema, DOMParser } from 'prosemirror-model' +import { buildPlugins, type PluginOptions } from './plugins/index' +import { schemaNodes, schemaMarks } from './schema/index' + +const schema = new Schema({ + nodes: schemaNodes, + marks: schemaMarks, +}) + +export const createDocument = (content: string) => { + const htmlString = `${content}` + const parser = new window.DOMParser() + const element = parser.parseFromString(htmlString, 'text/html').body.firstElementChild + return DOMParser.fromSchema(schema).parse(element as Element) +} + +export const initProsemirrorEditor = ( + dom: Element, + content: string, + props: Omit, + pluginOptions?: PluginOptions, +) => { + return new EditorView(dom, { + state: EditorState.create({ + doc: createDocument(content), + plugins: buildPlugins(schema, pluginOptions), + }), + ...props, + }) +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/plugins/index.ts b/frontend/src/utils/prosemirror/plugins/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aecc78107b8c8d0c7f1931e387c4965f4f5b3b29 --- /dev/null +++ b/frontend/src/utils/prosemirror/plugins/index.ts @@ -0,0 +1,31 @@ +import { keymap } from 'prosemirror-keymap' +import type { Schema } from 'prosemirror-model' +import { history } from 'prosemirror-history' +import { baseKeymap } from 'prosemirror-commands' +import { dropCursor } from 'prosemirror-dropcursor' +import { gapCursor } from 'prosemirror-gapcursor' + +import { buildKeymap } from './keymap' +import { buildInputRules } from './inputrules' +import { placeholderPlugin } from './placeholder' + +export interface PluginOptions { + placeholder?: string +} + +export const buildPlugins = (schema: Schema, options?: PluginOptions) => { + const placeholder = options?.placeholder + + const plugins = [ + buildInputRules(schema), + keymap(buildKeymap(schema)), + keymap(baseKeymap), + dropCursor(), + gapCursor(), + history(), + ] + + if (placeholder) plugins.push(placeholderPlugin(placeholder)) + + return plugins +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/plugins/inputrules.ts b/frontend/src/utils/prosemirror/plugins/inputrules.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ec8c57ae5b6263d09cccdd192c64153b3f7df65 --- /dev/null +++ b/frontend/src/utils/prosemirror/plugins/inputrules.ts @@ -0,0 +1,63 @@ +import type { NodeType, Schema } from 'prosemirror-model' +import { + inputRules, + wrappingInputRule, + smartQuotes, + emDash, + ellipsis, + InputRule, +} from 'prosemirror-inputrules' + +const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType) + +const orderedListRule = (nodeType: NodeType) => ( + wrappingInputRule( + /^(\d+)\.\s$/, + nodeType, + match => ({order: +match[1]}), + (match, node) => node.childCount + node.attrs.order === +match[1], + ) +) + +const bulletListRule = (nodeType: NodeType) => wrappingInputRule(/^\s*([-+*])\s$/, nodeType) + +const codeRule = () => { + const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/ + + return new InputRule(inputRegex, (state, match, start, end) => { + const { schema } = state + + const tr = state.tr.insertText(`${match[2]} `, start, end) + const mark = schema.marks.code.create() + + return tr.addMark(start, start + match[2].length, mark) + }) +} + +const linkRule = () => { + const urlRegEx = /(?:https?:\/\/)?[\w-]+(?:\.[\w-]+)+\.?(?:\d+)?(?:\/\S*)?$/ + + return new InputRule(urlRegEx, (state, match, start, end) => { + const { schema } = state + + const tr = state.tr.insertText(match[0], start, end) + const mark = schema.marks.link.create({ href: match[0], title: match[0] }) + + return tr.addMark(start, start + match[0].length, mark) + }) +} + +export const buildInputRules = (schema: Schema) => { + const rules = [ + ...smartQuotes, + ellipsis, + emDash, + ] + rules.push(blockQuoteRule(schema.nodes.blockquote)) + rules.push(orderedListRule(schema.nodes.ordered_list)) + rules.push(bulletListRule(schema.nodes.bullet_list)) + rules.push(codeRule()) + rules.push(linkRule()) + + return inputRules({ rules }) +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/plugins/keymap.ts b/frontend/src/utils/prosemirror/plugins/keymap.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4370453b55f01fce610508e16e0c46a19c587bf --- /dev/null +++ b/frontend/src/utils/prosemirror/plugins/keymap.ts @@ -0,0 +1,51 @@ +import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list' +import type { Schema } from 'prosemirror-model' +import { undo, redo } from 'prosemirror-history' +import { undoInputRule } from 'prosemirror-inputrules' +import type { Command } from 'prosemirror-state' +import { + toggleMark, + selectParentNode, + joinUp, + joinDown, + chainCommands, + newlineInCode, + createParagraphNear, + liftEmptyBlock, + splitBlockKeepMarks, +} from 'prosemirror-commands' + +interface Keys { + [key: string]: Command +} + +export const buildKeymap = (schema: Schema) => { + const keys: Keys = {} + const bind = (key: string, cmd: Command) => keys[key] = cmd + + bind('Alt-ArrowUp', joinUp) + bind('Alt-ArrowDown', joinDown) + bind('Mod-z', undo) + bind('Mod-y', redo) + bind('Backspace', undoInputRule) + bind('Escape', selectParentNode) + bind('Mod-b', toggleMark(schema.marks.strong)) + bind('Mod-i', toggleMark(schema.marks.em)) + bind('Mod-u', toggleMark(schema.marks.underline)) + bind('Mod-d', toggleMark(schema.marks.strikethrough)) + bind('Mod-e', toggleMark(schema.marks.code)) + bind('Mod-;', toggleMark(schema.marks.superscript)) + bind(`Mod-'`, toggleMark(schema.marks.subscript)) + bind('Enter', chainCommands( + splitListItem(schema.nodes.list_item), + newlineInCode, + createParagraphNear, + liftEmptyBlock, + splitBlockKeepMarks, + )) + bind('Mod-[', liftListItem(schema.nodes.list_item)) + bind('Mod-]', sinkListItem(schema.nodes.list_item)) + bind('Tab', sinkListItem(schema.nodes.list_item)) + + return keys +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/plugins/placeholder.ts b/frontend/src/utils/prosemirror/plugins/placeholder.ts new file mode 100644 index 0000000000000000000000000000000000000000..51b67f06146845a1cfa74a345bc2ebaf59106afc --- /dev/null +++ b/frontend/src/utils/prosemirror/plugins/placeholder.ts @@ -0,0 +1,23 @@ +import { Plugin } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' +import type { Node } from 'prosemirror-model' + +const isEmptyParagraph = (node: Node) => { + return node.type.name === 'paragraph' && node.nodeSize === 2 +} + +export const placeholderPlugin = (placeholder: string) => { + return new Plugin({ + props: { + decorations(state) { + const { $from } = state.selection + if (isEmptyParagraph($from.parent)) { + const decoration = Decoration.node($from.before(), $from.after(), { + 'data-placeholder': placeholder, + }) + return DecorationSet.create(state.doc, [decoration]) + } + }, + }, + }) +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/schema/index.ts b/frontend/src/utils/prosemirror/schema/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..001ecfac8a678a8c50e9737fb974d59ba5ac91b8 --- /dev/null +++ b/frontend/src/utils/prosemirror/schema/index.ts @@ -0,0 +1,5 @@ +import nodes from './nodes' +import marks from './marks' + +export const schemaNodes = nodes +export const schemaMarks = marks diff --git a/frontend/src/utils/prosemirror/schema/marks.ts b/frontend/src/utils/prosemirror/schema/marks.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f434cbe92285bdc39f54e59ff1b1c1ae97d694f --- /dev/null +++ b/frontend/src/utils/prosemirror/schema/marks.ts @@ -0,0 +1,192 @@ +import { marks } from 'prosemirror-schema-basic' +import type { MarkSpec } from 'prosemirror-model' + +const subscript: MarkSpec = { + excludes: 'subscript', + parseDOM: [ + { tag: 'sub' }, + { + style: 'vertical-align', + getAttrs: value => value === 'sub' && null + }, + ], + toDOM: () => ['sub', 0], +} + +const superscript: MarkSpec = { + excludes: 'superscript', + parseDOM: [ + { tag: 'sup' }, + { + style: 'vertical-align', + getAttrs: value => value === 'super' && null + }, + ], + toDOM: () => ['sup', 0], +} + +const strikethrough: MarkSpec = { + parseDOM: [ + { tag: 'strike' }, + { + style: 'text-decoration', + getAttrs: value => value === 'line-through' && null + }, + { + style: 'text-decoration-line', + getAttrs: value => value === 'line-through' && null + }, + ], + toDOM: () => ['span', { style: 'text-decoration-line: line-through;' }, 0], +} + +const underline: MarkSpec = { + parseDOM: [ + { tag: 'u' }, + { + style: 'text-decoration', + getAttrs: value => value === 'underline' && null + }, + { + style: 'text-decoration-line', + getAttrs: value => value === 'underline' && null + }, + ], + toDOM: () => ['span', { style: 'text-decoration: underline;' }, 0], +} + +const forecolor: MarkSpec = { + attrs: { + color: {}, + }, + inline: true, + group: 'inline', + parseDOM: [ + { + style: 'color', + getAttrs: color => color ? { color } : {} + }, + ], + toDOM: mark => { + const { color } = mark.attrs + let style = '' + if (color) style += `color: ${color};` + return ['span', { style }, 0] + }, +} + +const backcolor: MarkSpec = { + attrs: { + backcolor: {}, + }, + inline: true, + group: 'inline', + parseDOM: [ + { + style: 'background-color', + getAttrs: backcolor => backcolor ? { backcolor } : {} + }, + ], + toDOM: mark => { + const { backcolor } = mark.attrs + let style = '' + if (backcolor) style += `background-color: ${backcolor};` + return ['span', { style }, 0] + }, +} + +const fontsize: MarkSpec = { + attrs: { + fontsize: {}, + }, + inline: true, + group: 'inline', + parseDOM: [ + { + style: 'font-size', + getAttrs: fontsize => fontsize ? { fontsize } : {} + }, + ], + toDOM: mark => { + const { fontsize } = mark.attrs + let style = '' + if (fontsize) style += `font-size: ${fontsize};` + return ['span', { style }, 0] + }, +} + +const fontname: MarkSpec = { + attrs: { + fontname: {}, + }, + inline: true, + group: 'inline', + parseDOM: [ + { + style: 'font-family', + getAttrs: fontname => { + return { fontname: fontname && typeof fontname === 'string' ? fontname.replace(/[\"\']/g, '') : '' } + } + }, + ], + toDOM: mark => { + const { fontname } = mark.attrs + let style = '' + if (fontname) style += `font-family: ${fontname};` + return ['span', { style }, 0] + }, +} + +const link: MarkSpec = { + attrs: { + href: {}, + title: { default: null }, + target: { default: '_blank' }, + }, + inclusive: false, + parseDOM: [ + { + tag: 'a[href]', + getAttrs: dom => { + const href = (dom as HTMLElement).getAttribute('href') + const title = (dom as HTMLElement).getAttribute('title') + return { href, title } + } + }, + ], + toDOM: node => ['a', node.attrs, 0], +} + +const mark: MarkSpec = { + attrs: { + index: { default: null }, + }, + parseDOM: [ + { + tag: 'mark', + getAttrs: dom => { + const index = (dom as HTMLElement).dataset.index + return { index } + } + }, + ], + toDOM: node => ['mark', { 'data-index': node.attrs.index }, 0], +} + +const { em, strong, code } = marks + +export default { + em, + strong, + fontsize, + fontname, + code, + forecolor, + backcolor, + subscript, + superscript, + strikethrough, + underline, + link, + mark, +} \ No newline at end of file diff --git a/frontend/src/utils/prosemirror/schema/nodes.ts b/frontend/src/utils/prosemirror/schema/nodes.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e27f0587067e52c82826295faf2e629f307c9bd --- /dev/null +++ b/frontend/src/utils/prosemirror/schema/nodes.ts @@ -0,0 +1,178 @@ +import { nodes } from 'prosemirror-schema-basic' +import type { Node, NodeSpec } from 'prosemirror-model' +import { listItem as _listItem } from 'prosemirror-schema-list' + +interface Attr { + [key: string]: number | string +} + +const orderedList: NodeSpec = { + attrs: { + order: { + default: 1, + }, + listStyleType: { + default: '', + }, + fontsize: { + default: '', + }, + color: { + default: '', + }, + }, + content: 'list_item+', + group: 'block', + parseDOM: [ + { + tag: 'ol', + getAttrs: dom => { + const order = ((dom as HTMLElement).hasAttribute('start') ? (dom as HTMLElement).getAttribute('start') : 1) || 1 + const attr: Attr = { order: +order } + + const { listStyleType, fontSize, color } = (dom as HTMLElement).style + if (listStyleType) attr['listStyleType'] = listStyleType + if (fontSize) attr['fontsize'] = fontSize + if (color) attr['color'] = color + + return attr + } + } + ], + toDOM: (node: Node) => { + const { order, listStyleType, fontsize, color } = node.attrs + let style = '' + if (listStyleType) style += `list-style-type: ${listStyleType};` + if (fontsize) style += `font-size: ${fontsize};` + if (color) style += `color: ${color};` + + const attr: Attr = { style } + if (order !== 1) attr['start'] = order + + + return ['ol', attr, 0] + }, +} + +const bulletList: NodeSpec = { + attrs: { + listStyleType: { + default: '', + }, + fontsize: { + default: '', + }, + color: { + default: '', + }, + }, + content: 'list_item+', + group: 'block', + parseDOM: [ + { + tag: 'ul', + getAttrs: dom => { + const attr: Attr = {} + + const { listStyleType, fontSize, color } = (dom as HTMLElement).style + if (listStyleType) attr['listStyleType'] = listStyleType + if (fontSize) attr['fontsize'] = fontSize + if (color) attr['color'] = color + + return attr + } + } + ], + toDOM: (node: Node) => { + const { listStyleType, fontsize, color } = node.attrs + let style = '' + if (listStyleType) style += `list-style-type: ${listStyleType};` + if (fontsize) style += `font-size: ${fontsize};` + if (color) style += `color: ${color};` + + return ['ul', { style }, 0] + }, +} + +const listItem: NodeSpec = { + ..._listItem, + content: 'paragraph block*', + group: 'block', +} + +const paragraph: NodeSpec = { + attrs: { + align: { + default: '', + }, + indent: { + default: 0, + }, + textIndent: { + default: 0, + }, + }, + content: 'inline*', + group: 'block', + parseDOM: [ + { + tag: 'p', + getAttrs: dom => { + const { textAlign, textIndent } = (dom as HTMLElement).style + + let align = (dom as HTMLElement).getAttribute('align') || textAlign || '' + align = /(left|right|center|justify)/.test(align) ? align : '' + + let textIndentLevel = 0 + if (textIndent) { + if (/em/.test(textIndent)) { + textIndentLevel = parseInt(textIndent) + } + else if (/px/.test(textIndent)) { + textIndentLevel = Math.floor(parseInt(textIndent) / 16) + if (!textIndentLevel) textIndentLevel = 1 + } + } + + const indent = +((dom as HTMLElement).getAttribute('data-indent') || 0) + + return { align, indent, textIndent: textIndentLevel } + } + }, + { + tag: 'img', + ignore: true, + }, + { + tag: 'pre', + skip: true, + }, + ], + toDOM: (node: Node) => { + const { align, indent, textIndent } = node.attrs + let style = '' + if (align && align !== 'left') style += `text-align: ${align};` + if (textIndent) style += `text-indent: ${textIndent}em;` + + const attr: Attr = { style } + if (indent) attr['data-indent'] = indent + + return ['p', attr, 0] + }, +} + +const { + doc, + blockquote, + text, +} = nodes + +export default { + doc, + paragraph, + blockquote, + text, + 'ordered_list': orderedList, + 'bullet_list': bulletList, + 'list_item': listItem, +} diff --git a/frontend/src/utils/prosemirror/utils.ts b/frontend/src/utils/prosemirror/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce1f06dab66d6641caa9e0f6acb04b5940bbfe52 --- /dev/null +++ b/frontend/src/utils/prosemirror/utils.ts @@ -0,0 +1,260 @@ +import type { Node, NodeType, ResolvedPos, Mark, MarkType, Schema } from 'prosemirror-model' +import type { EditorState, Selection } from 'prosemirror-state' +import type { EditorView } from 'prosemirror-view' +import { selectAll } from 'prosemirror-commands' + +export const isList = (node: Node, schema: Schema) => { + return ( + node.type === schema.nodes.bullet_list || + node.type === schema.nodes.ordered_list + ) +} + +export const autoSelectAll = (view: EditorView) => { + const { empty } = view.state.selection + if (empty) selectAll(view.state, view.dispatch) +} + +export const addMark = (editorView: EditorView, mark: Mark, selection?: { from: number; to: number; }) => { + if (selection) { + editorView.dispatch(editorView.state.tr.addMark(selection.from, selection.to, mark)) + } + else { + const { $from, $to } = editorView.state.selection + editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark)) + } +} + +export const findNodesWithSameMark = (doc: Node, from: number, to: number, markType: MarkType) => { + let ii = from + const finder = (mark: Mark) => mark.type === markType + let firstMark = null + let fromNode = null + let toNode = null + + while (ii <= to) { + const node = doc.nodeAt(ii) + if (!node || !node.marks) return null + + const mark = node.marks.find(finder) + if (!mark) return null + + if (firstMark && mark !== firstMark) return null + + fromNode = fromNode || node + firstMark = firstMark || mark + toNode = node + ii++ + } + + let fromPos = from + let toPos = to + + let jj = 0 + ii = from - 1 + while (ii > jj) { + const node = doc.nodeAt(ii) + const mark = node && node.marks.find(finder) + if (!mark || mark !== firstMark) break + fromPos = ii + fromNode = node + ii-- + } + + ii = to + 1 + jj = doc.nodeSize - 2 + while (ii < jj) { + const node = doc.nodeAt(ii) + const mark = node && node.marks.find(finder) + if (!mark || mark !== firstMark) break + toPos = ii + toNode = node + ii++ + } + + return { + mark: firstMark, + from: { + node: fromNode, + pos: fromPos, + }, + to: { + node: toNode, + pos: toPos, + }, + } +} + +const equalNodeType = (nodeType: NodeType, node: Node) => { + return Array.isArray(nodeType) && nodeType.indexOf(node.type) > -1 || node.type === nodeType +} + +const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: Node) => boolean) => { + for (let i = $pos.depth; i > 0; i--) { + const node = $pos.node(i) + if (predicate(node)) { + return { + pos: i > 0 ? $pos.before(i) : 0, + start: $pos.start(i), + depth: i, + node, + } + } + } +} + +export const findParentNode = (predicate: (node: Node) => boolean) => { + return (_ref: Selection) => findParentNodeClosestToPos(_ref.$from, predicate) +} + +export const findParentNodeOfType = (nodeType: NodeType) => { + return (selection: Selection) => { + return findParentNode((node: Node) => { + return equalNodeType(nodeType, node) + })(selection) + } +} + +export const isActiveOfParentNodeType = (nodeType: string, state: EditorState) => { + const node = state.schema.nodes[nodeType] + return !!findParentNodeOfType(node)(state.selection) +} + +export const getLastTextNode = (node: Node | null): Node | null => { + if (!node) return null + if (node.type.name === 'text') return node + if (!node.lastChild) return null + + return getLastTextNode(node.lastChild) +} + +export const getMarkAttrs = (view: EditorView) => { + const { selection, doc } = view.state + const { from } = selection + + let node = doc.nodeAt(from) || doc.nodeAt(from - 1) + node = getLastTextNode(node) + + return node?.marks || [] +} + +export const getAttrValue = (marks: readonly Mark[], markType: string, attr: string): string | null => { + for (const mark of marks) { + if (mark.type.name === markType && mark.attrs[attr]) return mark.attrs[attr] + } + return null +} + +export const isActiveMark = (marks: readonly Mark[], markType: string) => { + for (const mark of marks) { + if (mark.type.name === markType) return true + } + return false +} + +export const markActive = (state: EditorState, type: MarkType) => { + const { from, $from, to, empty } = state.selection + if (empty) return type.isInSet(state.storedMarks || $from.marks()) + return state.doc.rangeHasMark(from, to, type) +} + +export const getAttrValueInSelection = (view: EditorView, attr: string) => { + const { selection, doc } = view.state + const { from, to } = selection + + let keepChecking = true + let value = '' + doc.nodesBetween(from, to, node => { + if (keepChecking && node.attrs[attr]) { + keepChecking = false + value = node.attrs[attr] + } + return keepChecking + }) + return value +} + +type Align = 'left' | 'right' | 'center' + +interface DefaultAttrs { + color: string + backcolor: string + fontsize: string + fontname: string + align: Align +} +const _defaultAttrs: DefaultAttrs = { + color: '#000000', + backcolor: '', + fontsize: '16px', + fontname: '', + align: 'left', +} +export const getTextAttrs = (view: EditorView, attrs: Partial = {}) => { + const defaultAttrs: DefaultAttrs = { ..._defaultAttrs, ...attrs } + + const marks = getMarkAttrs(view) + + const isBold = isActiveMark(marks, 'strong') + const isEm = isActiveMark(marks, 'em') + const isUnderline = isActiveMark(marks, 'underline') + const isStrikethrough = isActiveMark(marks, 'strikethrough') + const isSuperscript = isActiveMark(marks, 'superscript') + const isSubscript = isActiveMark(marks, 'subscript') + const isCode = isActiveMark(marks, 'code') + const color = getAttrValue(marks, 'forecolor', 'color') || defaultAttrs.color + const backcolor = getAttrValue(marks, 'backcolor', 'backcolor') || defaultAttrs.backcolor + const fontsize = getAttrValue(marks, 'fontsize', 'fontsize') || defaultAttrs.fontsize + const fontname = getAttrValue(marks, 'fontname', 'fontname') || defaultAttrs.fontname + const link = getAttrValue(marks, 'link', 'href') || '' + const align = (getAttrValueInSelection(view, 'align') || defaultAttrs.align) as Align + const isBulletList = isActiveOfParentNodeType('bullet_list', view.state) + const isOrderedList = isActiveOfParentNodeType('ordered_list', view.state) + const isBlockquote = isActiveOfParentNodeType('blockquote', view.state) + + return { + bold: isBold, + em: isEm, + underline: isUnderline, + strikethrough: isStrikethrough, + superscript: isSuperscript, + subscript: isSubscript, + code: isCode, + color: color, + backcolor: backcolor, + fontsize: fontsize, + fontname: fontname, + link: link, + align: align, + bulletList: isBulletList, + orderedList: isOrderedList, + blockquote: isBlockquote, + } +} + +export type TextAttrs = ReturnType + +export const getFontsize = (view: EditorView) => { + const marks = getMarkAttrs(view) + const fontsize = getAttrValue(marks, 'fontsize', 'fontsize') || _defaultAttrs.fontsize + return parseInt(fontsize) +} + +export const defaultRichTextAttrs: TextAttrs = { + bold: false, + em: false, + underline: false, + strikethrough: false, + superscript: false, + subscript: false, + code: false, + color: '#000000', + backcolor: '', + fontsize: '16px', + fontname: '', + link: '', + align: 'left', + bulletList: false, + orderedList: false, + blockquote: false, +} \ No newline at end of file diff --git a/frontend/src/utils/selection.ts b/frontend/src/utils/selection.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ab533fcd1b1ee416737a9dec2ae5779ce5e66cd --- /dev/null +++ b/frontend/src/utils/selection.ts @@ -0,0 +1,5 @@ +// 清除文字选区 +export const removeAllRanges = () => { + const selection = window.getSelection() + selection && selection.removeAllRanges() +} \ No newline at end of file diff --git a/frontend/src/utils/svg2Base64.ts b/frontend/src/utils/svg2Base64.ts new file mode 100644 index 0000000000000000000000000000000000000000..b939d54c6abdf831453541779cd9a42f92f9d451 --- /dev/null +++ b/frontend/src/utils/svg2Base64.ts @@ -0,0 +1,55 @@ +// svg转base64图片,参考:https://github.com/scriptex/svg64 + +const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' +const PREFIX = 'data:image/svg+xml;base64,' + +const utf8Encode = (string: string) => { + string = string.replace(/\r\n/g, '\n') + let utftext = '' + + for (let n = 0; n < string.length; n++) { + const c = string.charCodeAt(n) + + if (c < 128) { + utftext += String.fromCharCode(c) + } + else if (c > 127 && c < 2048) { + utftext += String.fromCharCode((c >> 6) | 192) + utftext += String.fromCharCode((c & 63) | 128) + } + else { + utftext += String.fromCharCode((c >> 12) | 224) + utftext += String.fromCharCode(((c >> 6) & 63) | 128) + utftext += String.fromCharCode((c & 63) | 128) + } + } + + return utftext +} + +const encode = (input: string) => { + let output = '' + let chr1, chr2, chr3, enc1, enc2, enc3, enc4 + let i = 0 + input = utf8Encode(input) + while (i < input.length) { + chr1 = input.charCodeAt(i++) + chr2 = input.charCodeAt(i++) + chr3 = input.charCodeAt(i++) + enc1 = chr1 >> 2 + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4) + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6) + enc4 = chr3 & 63 + if (isNaN(chr2)) enc3 = enc4 = 64 + else if (isNaN(chr3)) enc4 = 64 + output = output + characters.charAt(enc1) + characters.charAt(enc2) + characters.charAt(enc3) + characters.charAt(enc4) + } + return output +} + +export const svg2Base64 = (element: Element) => { + const XMLS = new XMLSerializer() + const svg = XMLS.serializeToString(element) + + return PREFIX + encode(svg) +} \ No newline at end of file diff --git a/frontend/src/utils/svgPathParser.ts b/frontend/src/utils/svgPathParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bcca8bb102a2d59488e1dfaecebfa36ff23e7d5 --- /dev/null +++ b/frontend/src/utils/svgPathParser.ts @@ -0,0 +1,146 @@ +import { SVGPathData } from 'svg-pathdata' +import arcToBezier from 'svg-arc-to-cubic-bezier' + +const typeMap = { + 1: 'Z', + 2: 'M', + 4: 'H', + 8: 'V', + 16: 'L', + 32: 'C', + 64: 'S', + 128: 'Q', + 256: 'T', + 512: 'A', +} + +/** + * 简单解析SVG路径 + * @param d SVG path d属性 + */ +export const parseSvgPath = (d: string) => { + const pathData = new SVGPathData(d) + + const ret = pathData.commands.map(item => { + return { ...item, type: typeMap[item.type] } + }) + return ret +} + +export type SvgPath = ReturnType + +/** + * 解析SVG路径,并将圆弧(A)类型的路径转为三次贝塞尔(C)类型的路径 + * @param d SVG path d属性 + */ +export const toPoints = (d: string) => { + const pathData = new SVGPathData(d) + + const points = [] + for (const item of pathData.commands) { + const type = typeMap[item.type] + + if (item.type === 2 || item.type === 16) { + points.push({ + x: item.x, + y: item.y, + relative: item.relative, + type, + }) + } + if (item.type === 32) { + points.push({ + x: item.x, + y: item.y, + curve: { + type: 'cubic', + x1: item.x1, + y1: item.y1, + x2: item.x2, + y2: item.y2, + }, + relative: item.relative, + type, + }) + } + else if (item.type === 128) { + points.push({ + x: item.x, + y: item.y, + curve: { + type: 'quadratic', + x1: item.x1, + y1: item.y1, + }, + relative: item.relative, + type, + }) + } + else if (item.type === 512) { + const lastPoint = points[points.length - 1] + if (!['M', 'L', 'Q', 'C'].includes(lastPoint.type)) continue + + const cubicBezierPoints = arcToBezier({ + px: lastPoint.x as number, + py: lastPoint.y as number, + cx: item.x, + cy: item.y, + rx: item.rX, + ry: item.rY, + xAxisRotation: item.xRot, + largeArcFlag: item.lArcFlag, + sweepFlag: item.sweepFlag, + }) + for (const cbPoint of cubicBezierPoints) { + points.push({ + x: cbPoint.x, + y: cbPoint.y, + curve: { + type: 'cubic', + x1: cbPoint.x1, + y1: cbPoint.y1, + x2: cbPoint.x2, + y2: cbPoint.y2, + }, + relative: false, + type: 'C', + }) + } + } + else if (item.type === 1) { + points.push({ close: true, type }) + } + else continue + } + return points +} + +export const getSvgPathRange = (path: string) => { + try { + const pathData = new SVGPathData(path) + const xList = [] + const yList = [] + for (const item of pathData.commands) { + const x = ('x' in item) ? item.x : 0 + const y = ('y' in item) ? item.y : 0 + xList.push(x) + yList.push(y) + } + return { + minX: Math.min(...xList), + minY: Math.min(...yList), + maxX: Math.max(...xList), + maxY: Math.max(...yList), + } + } + catch { + return { + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + } + } +} + +export type SvgPoints = ReturnType \ No newline at end of file diff --git a/frontend/src/utils/textParser.ts b/frontend/src/utils/textParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcb2ea62833cd18090b9f141b67bc38523cafd0e --- /dev/null +++ b/frontend/src/utils/textParser.ts @@ -0,0 +1,13 @@ +/** + * 将普通文本转为带段落信息的HTML字符串 + * @param text 文本 + */ +export const parseText2Paragraphs = (text: string) => { + const htmlText = text.replace(/[\n\r]+/g, '') + const paragraphs = htmlText.split('') + let string = '' + for (const paragraph of paragraphs) { + if (paragraph) string += `${paragraph}` + } + return string +} \ No newline at end of file diff --git a/frontend/src/views/Editor/AIPPTDialog.vue b/frontend/src/views/Editor/AIPPTDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..f6dab90a6b6824202c8acb6aa50b454669333678 --- /dev/null +++ b/frontend/src/views/Editor/AIPPTDialog.vue @@ -0,0 +1,351 @@ + + + + AIPPT + 从下方挑选合适的模板,开始生成PPT + 确认下方内容大纲(点击编辑内容,右键添加/删除大纲项),开始选择模板 + 在下方输入您的PPT主题,并适当补充信息,如行业、岗位、学科、用途等 + + + + + + {{ keyword.length }} / 50 + {{ language === 'zh' ? '中' : '英' }} + AI 生成 + + + + {{ item }} + + + 选择AI模型: + + + + + {{ outline }} + + + + + 选择模板 + 返回重新生成 + + + + + + + + + + 生成 + 返回大纲 + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/AlignmentLine.vue b/frontend/src/views/Editor/Canvas/AlignmentLine.vue new file mode 100644 index 0000000000000000000000000000000000000000..3404ef685791048e353cbbaffd5de4a2af963569 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/AlignmentLine.vue @@ -0,0 +1,49 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/EditableElement.vue b/frontend/src/views/Editor/Canvas/EditableElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..b191bf0ab6576e35984f20ddabb4ecb3565f42e1 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/EditableElement.vue @@ -0,0 +1,167 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/ElementCreateSelection.vue b/frontend/src/views/Editor/Canvas/ElementCreateSelection.vue new file mode 100644 index 0000000000000000000000000000000000000000..d20a14d1f31cdb9179f0ed0465b8a95e59a4b134 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/ElementCreateSelection.vue @@ -0,0 +1,231 @@ + + createSelection($event)" + @contextmenu.stop.prevent + > + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/GridLines.vue b/frontend/src/views/Editor/Canvas/GridLines.vue new file mode 100644 index 0000000000000000000000000000000000000000..13f968a03c6f66e4f5252ff3a72918d132f7ebde --- /dev/null +++ b/frontend/src/views/Editor/Canvas/GridLines.vue @@ -0,0 +1,61 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/LinkDialog.vue b/frontend/src/views/Editor/Canvas/LinkDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..a2c06ea197805a8f30332360324c8427c61b3042 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/LinkDialog.vue @@ -0,0 +1,130 @@ + + + + + + + + + + 预览: + + + + + 取消 + 确认 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/MouseSelection.vue b/frontend/src/views/Editor/Canvas/MouseSelection.vue new file mode 100644 index 0000000000000000000000000000000000000000..a40b0312d9b2a3348f4e2a037965a09e2c915423 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/MouseSelection.vue @@ -0,0 +1,46 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/BorderLine.vue b/frontend/src/views/Editor/Canvas/Operate/BorderLine.vue new file mode 100644 index 0000000000000000000000000000000000000000..779caa2968f4feb60cd59067d1e30db78a743ebf --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/BorderLine.vue @@ -0,0 +1,72 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/CommonElementOperate.vue b/frontend/src/views/Editor/Canvas/Operate/CommonElementOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7d2f236c33b65dd239d7965da7785537764814b --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/CommonElementOperate.vue @@ -0,0 +1,64 @@ + + + + + scaleElement($event, elementInfo, point.direction)" + /> + rotateElement($event, elementInfo)" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/ImageElementOperate.vue b/frontend/src/views/Editor/Canvas/Operate/ImageElementOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..bf728c24c53e55edc3c8eb70fe71f7fd885c10a6 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/ImageElementOperate.vue @@ -0,0 +1,67 @@ + + + + + scaleElement($event, elementInfo, point.direction)" + /> + rotateElement($event, elementInfo)" + /> + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/LineElementOperate.vue b/frontend/src/views/Editor/Canvas/Operate/LineElementOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..5fa42418e76b87bb2f08497b2fc0d98f150a7407 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/LineElementOperate.vue @@ -0,0 +1,126 @@ + + + + dragLineElement($event, elementInfo, point.handler)" + /> + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/LinkHandler.vue b/frontend/src/views/Editor/Canvas/Operate/LinkHandler.vue new file mode 100644 index 0000000000000000000000000000000000000000..6e60d6c07fae5c60f14359ed28c5570b6da2129d --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/LinkHandler.vue @@ -0,0 +1,74 @@ + + + {{link.target}} + 幻灯片页面 {{link.target}} + + 更换 + + 移除 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/MultiSelectOperate.vue b/frontend/src/views/Editor/Canvas/Operate/MultiSelectOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..6ea8df8718f548906b8d14fffb140a275cf9adf8 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/MultiSelectOperate.vue @@ -0,0 +1,82 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/ResizeHandler.vue b/frontend/src/views/Editor/Canvas/Operate/ResizeHandler.vue new file mode 100644 index 0000000000000000000000000000000000000000..1f6d175a8cfec26cff4a88ab876fa9120623b804 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/ResizeHandler.vue @@ -0,0 +1,85 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/RotateHandler.vue b/frontend/src/views/Editor/Canvas/Operate/RotateHandler.vue new file mode 100644 index 0000000000000000000000000000000000000000..49456706613dd5b91510eb19546264a216795751 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/RotateHandler.vue @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/ShapeElementOperate.vue b/frontend/src/views/Editor/Canvas/Operate/ShapeElementOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..2e6d65a905c3d6f93af13cf915e2f27ea830c658 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/ShapeElementOperate.vue @@ -0,0 +1,109 @@ + + + + + scaleElement($event, elementInfo, point.direction)" + /> + rotateElement($event, elementInfo)" + /> + moveShapeKeypoint($event, elementInfo, index)" + > + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/TableElementOperate.vue b/frontend/src/views/Editor/Canvas/Operate/TableElementOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..b4811dca80c0f967f9e72e764678dc0b61d61dc5 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/TableElementOperate.vue @@ -0,0 +1,62 @@ + + + + + scaleElement($event, elementInfo, point.direction)" + /> + rotateElement($event, elementInfo)" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/TextElementOperate.vue b/frontend/src/views/Editor/Canvas/Operate/TextElementOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..68caf46254b50ce71dc421a4353b95ba93836c7a --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/TextElementOperate.vue @@ -0,0 +1,61 @@ + + + + + scaleElement($event, elementInfo, point.direction)" + /> + rotateElement($event, elementInfo)" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Operate/index.vue b/frontend/src/views/Editor/Canvas/Operate/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..8296d16d3a319d292dd7e904d49004090a46d0f5 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Operate/index.vue @@ -0,0 +1,138 @@ + + + + + + {{index + 1}} + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/Ruler.vue b/frontend/src/views/Editor/Canvas/Ruler.vue new file mode 100644 index 0000000000000000000000000000000000000000..888563bf3138ad52f15e710809be04a9d227f2c8 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/Ruler.vue @@ -0,0 +1,199 @@ + + + + + {{ marker * 100 }} + + + + + + + {{ marker * 100 }} + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/ShapeCreateCanvas.vue b/frontend/src/views/Editor/Canvas/ShapeCreateCanvas.vue new file mode 100644 index 0000000000000000000000000000000000000000..8b2b3cbd182e8f840141419e423acfb1c4d8a9de --- /dev/null +++ b/frontend/src/views/Editor/Canvas/ShapeCreateCanvas.vue @@ -0,0 +1,195 @@ + + addPoint($event)" + @mousemove="$event => updateMousePosition($event)" + @contextmenu.stop.prevent="close()" + > + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/ViewportBackground.vue b/frontend/src/views/Editor/Canvas/ViewportBackground.vue new file mode 100644 index 0000000000000000000000000000000000000000..0d55c1d77d64013fc6b0db77d9b09095d6a2862b --- /dev/null +++ b/frontend/src/views/Editor/Canvas/ViewportBackground.vue @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/frontend/src/views/Editor/Canvas/hooks/useCommonOperate.ts b/frontend/src/views/Editor/Canvas/hooks/useCommonOperate.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f0f701b1fd0d7299472815072dd70853fade901 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useCommonOperate.ts @@ -0,0 +1,49 @@ +import { computed, type Ref } from 'vue' +import { OperateResizeHandlers, OperateBorderLines } from '@/types/edit' + +export default (width: Ref, height: Ref) => { + // 元素缩放点 + const resizeHandlers = computed(() => { + return [ + { direction: OperateResizeHandlers.LEFT_TOP, style: {} }, + { direction: OperateResizeHandlers.TOP, style: {left: width.value / 2 + 'px'} }, + { direction: OperateResizeHandlers.RIGHT_TOP, style: {left: width.value + 'px'} }, + { direction: OperateResizeHandlers.LEFT, style: {top: height.value / 2 + 'px'} }, + { direction: OperateResizeHandlers.RIGHT, style: {left: width.value + 'px', top: height.value / 2 + 'px'} }, + { direction: OperateResizeHandlers.LEFT_BOTTOM, style: {top: height.value + 'px'} }, + { direction: OperateResizeHandlers.BOTTOM, style: {left: width.value / 2 + 'px', top: height.value + 'px'} }, + { direction: OperateResizeHandlers.RIGHT_BOTTOM, style: {left: width.value + 'px', top: height.value + 'px'} }, + ] + }) + + // 文本元素缩放点 + const textElementResizeHandlers = computed(() => { + return [ + { direction: OperateResizeHandlers.LEFT, style: {top: height.value / 2 + 'px'} }, + { direction: OperateResizeHandlers.RIGHT, style: {left: width.value + 'px', top: height.value / 2 + 'px'} }, + ] + }) + const verticalTextElementResizeHandlers = computed(() => { + return [ + { direction: OperateResizeHandlers.TOP, style: {left: width.value / 2 + 'px'} }, + { direction: OperateResizeHandlers.BOTTOM, style: {left: width.value / 2 + 'px', top: height.value + 'px'} }, + ] + }) + + // 元素选中边框线 + const borderLines = computed(() => { + return [ + { type: OperateBorderLines.T, style: {width: width.value + 'px'} }, + { type: OperateBorderLines.B, style: {top: height.value + 'px', width: width.value + 'px'} }, + { type: OperateBorderLines.L, style: {height: height.value + 'px'} }, + { type: OperateBorderLines.R, style: {left: width.value + 'px', height: height.value + 'px'} }, + ] + }) + + return { + resizeHandlers, + textElementResizeHandlers, + verticalTextElementResizeHandlers, + borderLines, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useDragElement.ts b/frontend/src/views/Editor/Canvas/hooks/useDragElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..b47d3573e494218a81c48f94cf6d9249d32d2e08 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useDragElement.ts @@ -0,0 +1,327 @@ +import type { Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import type { AlignmentLineProps } from '@/types/edit' +import { getRectRotatedRange, uniqAlignLines, type AlignLine } from '@/utils/element' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +export default ( + elementList: Ref, + alignmentLines: Ref, + canvasScale: Ref, +) => { + const slidesStore = useSlidesStore() + const { activeElementIdList, activeGroupElementId } = storeToRefs(useMainStore()) + const { shiftKeyState } = storeToRefs(useKeyboardStore()) + const { viewportRatio, viewportSize } = storeToRefs(slidesStore) + + const { addHistorySnapshot } = useHistorySnapshot() + + const dragElement = (e: MouseEvent | TouchEvent, element: PPTElement) => { + const isTouchEvent = !(e instanceof MouseEvent) + if (isTouchEvent && (!e.changedTouches || !e.changedTouches[0])) return + + if (!activeElementIdList.value.includes(element.id)) return + let isMouseDown = true + + const edgeWidth = viewportSize.value + const edgeHeight = viewportSize.value * viewportRatio.value + + const sorptionRange = 5 + + const originElementList: PPTElement[] = JSON.parse(JSON.stringify(elementList.value)) + const originActiveElementList = originElementList.filter(el => activeElementIdList.value.includes(el.id)) + + const elOriginLeft = element.left + const elOriginTop = element.top + const elOriginWidth = element.width + const elOriginHeight = ('height' in element && element.height) ? element.height : 0 + const elOriginRotate = ('rotate' in element && element.rotate) ? element.rotate : 0 + + const startPageX = isTouchEvent ? e.changedTouches[0].pageX : e.pageX + const startPageY = isTouchEvent ? e.changedTouches[0].pageY : e.pageY + + let isMisoperation: boolean | null = null + + const isActiveGroupElement = element.id === activeGroupElementId.value + + // 收集对齐对齐吸附线 + // 包括页面内除目标元素外的其他元素在画布中的各个可吸附对齐位置:上下左右四边,水平中心、垂直中心 + // 其中线条和被旋转过的元素需要重新计算他们在画布中的中心点位置的范围 + let horizontalLines: AlignLine[] = [] + let verticalLines: AlignLine[] = [] + + for (const el of elementList.value) { + if (el.type === 'line') continue + if (isActiveGroupElement && el.id === element.id) continue + if (!isActiveGroupElement && activeElementIdList.value.includes(el.id)) continue + + let left, top, width, height + if ('rotate' in el && el.rotate) { + const { xRange, yRange } = getRectRotatedRange({ + left: el.left, + top: el.top, + width: el.width, + height: el.height, + rotate: el.rotate, + }) + left = xRange[0] + top = yRange[0] + width = xRange[1] - xRange[0] + height = yRange[1] - yRange[0] + } + else { + left = el.left + top = el.top + width = el.width + height = el.height + } + + const right = left + width + const bottom = top + height + const centerX = top + height / 2 + const centerY = left + width / 2 + + const topLine: AlignLine = { value: top, range: [left, right] } + const bottomLine: AlignLine = { value: bottom, range: [left, right] } + const horizontalCenterLine: AlignLine = { value: centerX, range: [left, right] } + const leftLine: AlignLine = { value: left, range: [top, bottom] } + const rightLine: AlignLine = { value: right, range: [top, bottom] } + const verticalCenterLine: AlignLine = { value: centerY, range: [top, bottom] } + + horizontalLines.push(topLine, bottomLine, horizontalCenterLine) + verticalLines.push(leftLine, rightLine, verticalCenterLine) + } + + // 画布可视区域的四个边界、水平中心、垂直中心 + const edgeTopLine: AlignLine = { value: 0, range: [0, edgeWidth] } + const edgeBottomLine: AlignLine = { value: edgeHeight, range: [0, edgeWidth] } + const edgeHorizontalCenterLine: AlignLine = { value: edgeHeight / 2, range: [0, edgeWidth] } + const edgeLeftLine: AlignLine = { value: 0, range: [0, edgeHeight] } + const edgeRightLine: AlignLine = { value: edgeWidth, range: [0, edgeHeight] } + const edgeVerticalCenterLine: AlignLine = { value: edgeWidth / 2, range: [0, edgeHeight] } + + horizontalLines.push(edgeTopLine, edgeBottomLine, edgeHorizontalCenterLine) + verticalLines.push(edgeLeftLine, edgeRightLine, edgeVerticalCenterLine) + + // 对齐吸附线去重 + horizontalLines = uniqAlignLines(horizontalLines) + verticalLines = uniqAlignLines(verticalLines) + + const handleMousemove = (e: MouseEvent | TouchEvent) => { + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + // 如果鼠标滑动距离过小,则将操作判定为误操作: + // 如果误操作标记为null,表示是第一次触发移动,需要计算当前是否是误操作 + // 如果误操作标记为true,表示当前还处在误操作范围内,但仍然需要继续计算检查后续操作是否还处于误操作 + // 如果误操作标记为false,表示已经脱离了误操作范围,不需要再次计算 + if (isMisoperation !== false) { + isMisoperation = Math.abs(startPageX - currentPageX) < sorptionRange && + Math.abs(startPageY - currentPageY) < sorptionRange + } + if (!isMouseDown || isMisoperation) return + + let moveX = (currentPageX - startPageX) / canvasScale.value + let moveY = (currentPageY - startPageY) / canvasScale.value + + if (shiftKeyState.value) { + if (Math.abs(moveX) > Math.abs(moveY)) moveY = 0 + if (Math.abs(moveX) < Math.abs(moveY)) moveX = 0 + } + + // 基础目标位置 + let targetLeft = elOriginLeft + moveX + let targetTop = elOriginTop + moveY + + // 计算目标元素在画布中的位置范围,用于吸附对齐 + // 需要区分单选和多选两种情况,其中多选状态下需要计算多选元素的整体范围;单选状态下需要继续区分线条、普通元素、旋转后的普通元素三种情况 + let targetMinX: number, targetMaxX: number, targetMinY: number, targetMaxY: number + + if (activeElementIdList.value.length === 1 || isActiveGroupElement) { + if (elOriginRotate) { + const { xRange, yRange } = getRectRotatedRange({ + left: targetLeft, + top: targetTop, + width: elOriginWidth, + height: elOriginHeight, + rotate: elOriginRotate, + }) + targetMinX = xRange[0] + targetMaxX = xRange[1] + targetMinY = yRange[0] + targetMaxY = yRange[1] + } + else if (element.type === 'line') { + targetMinX = targetLeft + targetMaxX = targetLeft + Math.max(element.start[0], element.end[0]) + targetMinY = targetTop + targetMaxY = targetTop + Math.max(element.start[1], element.end[1]) + } + else { + targetMinX = targetLeft + targetMaxX = targetLeft + elOriginWidth + targetMinY = targetTop + targetMaxY = targetTop + elOriginHeight + } + } + else { + const leftValues = [] + const topValues = [] + const rightValues = [] + const bottomValues = [] + + for (let i = 0; i < originActiveElementList.length; i++) { + const element = originActiveElementList[i] + const left = element.left + moveX + const top = element.top + moveY + const width = element.width + const height = ('height' in element && element.height) ? element.height : 0 + const rotate = ('rotate' in element && element.rotate) ? element.rotate : 0 + + if ('rotate' in element && element.rotate) { + const { xRange, yRange } = getRectRotatedRange({ left, top, width, height, rotate }) + leftValues.push(xRange[0]) + topValues.push(yRange[0]) + rightValues.push(xRange[1]) + bottomValues.push(yRange[1]) + } + else if (element.type === 'line') { + leftValues.push(left) + topValues.push(top) + rightValues.push(left + Math.max(element.start[0], element.end[0])) + bottomValues.push(top + Math.max(element.start[1], element.end[1])) + } + else { + leftValues.push(left) + topValues.push(top) + rightValues.push(left + width) + bottomValues.push(top + height) + } + } + + targetMinX = Math.min(...leftValues) + targetMaxX = Math.max(...rightValues) + targetMinY = Math.min(...topValues) + targetMaxY = Math.max(...bottomValues) + } + + const targetCenterX = targetMinX + (targetMaxX - targetMinX) / 2 + const targetCenterY = targetMinY + (targetMaxY - targetMinY) / 2 + + // 将收集到的对齐吸附线与计算的目标元素位置范围做对比,二者的差小于设定的值时执行自动对齐校正 + // 水平和垂直两个方向需要分开计算 + const _alignmentLines: AlignmentLineProps[] = [] + let isVerticalAdsorbed = false + let isHorizontalAdsorbed = false + for (let i = 0; i < horizontalLines.length; i++) { + const { value, range } = horizontalLines[i] + const min = Math.min(...range, targetMinX, targetMaxX) + const max = Math.max(...range, targetMinX, targetMaxX) + + if (Math.abs(targetMinY - value) < sorptionRange && !isHorizontalAdsorbed) { + targetTop = targetTop - (targetMinY - value) + isHorizontalAdsorbed = true + _alignmentLines.push({type: 'horizontal', axis: {x: min - 50, y: value}, length: max - min + 100}) + } + if (Math.abs(targetMaxY - value) < sorptionRange && !isHorizontalAdsorbed) { + targetTop = targetTop - (targetMaxY - value) + isHorizontalAdsorbed = true + _alignmentLines.push({type: 'horizontal', axis: {x: min - 50, y: value}, length: max - min + 100}) + } + if (Math.abs(targetCenterY - value) < sorptionRange && !isHorizontalAdsorbed) { + targetTop = targetTop - (targetCenterY - value) + isHorizontalAdsorbed = true + _alignmentLines.push({type: 'horizontal', axis: {x: min - 50, y: value}, length: max - min + 100}) + } + } + for (let i = 0; i < verticalLines.length; i++) { + const { value, range } = verticalLines[i] + const min = Math.min(...range, targetMinY, targetMaxY) + const max = Math.max(...range, targetMinY, targetMaxY) + + if (Math.abs(targetMinX - value) < sorptionRange && !isVerticalAdsorbed) { + targetLeft = targetLeft - (targetMinX - value) + isVerticalAdsorbed = true + _alignmentLines.push({type: 'vertical', axis: {x: value, y: min - 50}, length: max - min + 100}) + } + if (Math.abs(targetMaxX - value) < sorptionRange && !isVerticalAdsorbed) { + targetLeft = targetLeft - (targetMaxX - value) + isVerticalAdsorbed = true + _alignmentLines.push({type: 'vertical', axis: {x: value, y: min - 50}, length: max - min + 100}) + } + if (Math.abs(targetCenterX - value) < sorptionRange && !isVerticalAdsorbed) { + targetLeft = targetLeft - (targetCenterX - value) + isVerticalAdsorbed = true + _alignmentLines.push({type: 'vertical', axis: {x: value, y: min - 50}, length: max - min + 100}) + } + } + alignmentLines.value = _alignmentLines + + // 单选状态下,或者当前选中的多个元素中存在正在操作的元素时,仅修改正在操作的元素的位置 + if (activeElementIdList.value.length === 1 || isActiveGroupElement) { + elementList.value = elementList.value.map(el => { + return el.id === element.id ? { ...el, left: targetLeft, top: targetTop } : el + }) + } + + // 多选状态下,除了修改正在操作的元素的位置,其他被选中的元素也需要修改位置信息 + // 其他被选中的元素的位置信息通过正在操作的元素的移动偏移量来进行计算 + else { + const handleElement = elementList.value.find(el => el.id === element.id) + if (!handleElement) return + + elementList.value = elementList.value.map(el => { + if (activeElementIdList.value.includes(el.id)) { + if (el.id === element.id) { + return { + ...el, + left: targetLeft, + top: targetTop, + } + } + return { + ...el, + left: el.left + (targetLeft - handleElement.left), + top: el.top + (targetTop - handleElement.top), + } + } + return el + }) + } + } + + const handleMouseup = (e: MouseEvent | TouchEvent) => { + isMouseDown = false + + document.ontouchmove = null + document.ontouchend = null + document.onmousemove = null + document.onmouseup = null + + alignmentLines.value = [] + + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + if (startPageX === currentPageX && startPageY === currentPageY) return + + slidesStore.updateSlide({ elements: elementList.value }) + addHistorySnapshot() + } + + if (isTouchEvent) { + document.ontouchmove = handleMousemove + document.ontouchend = handleMouseup + } + else { + document.onmousemove = handleMousemove + document.onmouseup = handleMouseup + } + } + + return { + dragElement, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useDragLineElement.ts b/frontend/src/views/Editor/Canvas/hooks/useDragLineElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..99c213888beb9c42a6fcec382feaa064af6e6aac --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useDragLineElement.ts @@ -0,0 +1,232 @@ +import type { Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useKeyboardStore, useMainStore, useSlidesStore } from '@/store' +import type { PPTElement, PPTLineElement } from '@/types/slides' +import { OperateLineHandlers } from '@/types/edit' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +interface AdsorptionPoint { + x: number + y: number +} + +export default (elementList: Ref) => { + const slidesStore = useSlidesStore() + const { canvasScale } = storeToRefs(useMainStore()) + const { ctrlOrShiftKeyActive } = storeToRefs(useKeyboardStore()) + const { addHistorySnapshot } = useHistorySnapshot() + + // 拖拽线条端点 + const dragLineElement = (e: MouseEvent, element: PPTLineElement, command: OperateLineHandlers) => { + let isMouseDown = true + + const sorptionRange = 8 + + const startPageX = e.pageX + const startPageY = e.pageY + + const adsorptionPoints: AdsorptionPoint[] = [] + + // 获取所有线条以外的未旋转的元素的8个缩放点作为吸附位置 + for (let i = 0; i < elementList.value.length; i++) { + const _element = elementList.value[i] + if (_element.type === 'line' || _element.rotate) continue + + const left = _element.left + const top = _element.top + const width = _element.width + const height = _element.height + + const right = left + width + const bottom = top + height + const centerX = top + height / 2 + const centerY = left + width / 2 + + const topPoint = { x: centerY, y: top } + const bottomPoint = { x: centerY, y: bottom } + const leftPoint = { x: left, y: centerX } + const rightPoint = { x: right, y: centerX } + + const leftTopPoint = { x: left, y: top } + const rightTopPoint = { x: right, y: top } + const leftBottomPoint = { x: left, y: bottom } + const rightBottomPoint = { x: right, y: bottom } + + adsorptionPoints.push( + topPoint, + bottomPoint, + leftPoint, + rightPoint, + leftTopPoint, + rightTopPoint, + leftBottomPoint, + rightBottomPoint, + ) + } + + document.onmousemove = e => { + if (!isMouseDown) return + + const currentPageX = e.pageX + const currentPageY = e.pageY + + const moveX = (currentPageX - startPageX) / canvasScale.value + const moveY = (currentPageY - startPageY) / canvasScale.value + + // 线条起点和终点在编辑区域中的位置 + let startX = element.left + element.start[0] + let startY = element.top + element.start[1] + let endX = element.left + element.end[0] + let endY = element.top + element.end[1] + + const mid = element.broken || element.broken2 || element.curve || [0, 0] + let midX = element.left + mid[0] + let midY = element.top + mid[1] + + const [c1, c2] = element.cubic || [[0, 0], [0, 0]] + let c1X = element.left + c1[0] + let c1Y = element.top + c1[1] + let c2X = element.left + c2[0] + let c2Y = element.top + c2[1] + + // 拖拽起点或终点的位置 + // 水平和垂直方向上有吸附 + if (command === OperateLineHandlers.START) { + startX = startX + moveX + startY = startY + moveY + + if (Math.abs(startX - endX) < sorptionRange) startX = endX + if (Math.abs(startY - endY) < sorptionRange) startY = endY + + for (const adsorptionPoint of adsorptionPoints) { + const { x, y } = adsorptionPoint + if (Math.abs(x - startX) < sorptionRange && Math.abs(y - startY) < sorptionRange) { + startX = x + startY = y + break + } + } + } + else if (command === OperateLineHandlers.END) { + endX = endX + moveX + endY = endY + moveY + + if (Math.abs(startX - endX) < sorptionRange) endX = startX + if (Math.abs(startY - endY) < sorptionRange) endY = startY + + for (const adsorptionPoint of adsorptionPoints) { + const { x, y } = adsorptionPoint + if (Math.abs(x - endX) < sorptionRange && Math.abs(y - endY) < sorptionRange) { + endX = x + endY = y + break + } + } + } + else if (command === OperateLineHandlers.C) { + midX = midX + moveX + midY = midY + moveY + + if (Math.abs(midX - startX) < sorptionRange) midX = startX + if (Math.abs(midY - startY) < sorptionRange) midY = startY + if (Math.abs(midX - endX) < sorptionRange) midX = endX + if (Math.abs(midY - endY) < sorptionRange) midY = endY + if (Math.abs(midX - (startX + endX) / 2) < sorptionRange && Math.abs(midY - (startY + endY) / 2) < sorptionRange) { + midX = (startX + endX) / 2 + midY = (startY + endY) / 2 + } + } + else if (command === OperateLineHandlers.C1) { + c1X = c1X + moveX + c1Y = c1Y + moveY + + if (Math.abs(c1X - startX) < sorptionRange) c1X = startX + if (Math.abs(c1Y - startY) < sorptionRange) c1Y = startY + if (Math.abs(c1X - endX) < sorptionRange) c1X = endX + if (Math.abs(c1Y - endY) < sorptionRange) c1Y = endY + } + else if (command === OperateLineHandlers.C2) { + c2X = c2X + moveX + c2Y = c2Y + moveY + + if (Math.abs(c2X - startX) < sorptionRange) c2X = startX + if (Math.abs(c2Y - startY) < sorptionRange) c2Y = startY + if (Math.abs(c2X - endX) < sorptionRange) c2X = endX + if (Math.abs(c2Y - endY) < sorptionRange) c2Y = endY + } + + // 计算更新起点和终点基于自身元素位置的坐标 + const minX = Math.min(startX, endX) + const minY = Math.min(startY, endY) + const maxX = Math.max(startX, endX) + const maxY = Math.max(startY, endY) + + const start: [number, number] = [0, 0] + const end: [number, number] = [maxX - minX, maxY - minY] + if (startX > endX) { + start[0] = maxX - minX + end[0] = 0 + } + if (startY > endY) { + start[1] = maxY - minY + end[1] = 0 + } + + elementList.value = elementList.value.map(el => { + if (el.id === element.id) { + const newEl: PPTLineElement = { + ...(el as PPTLineElement), + left: minX, + top: minY, + start: start, + end: end, + } + if (command === OperateLineHandlers.START || command === OperateLineHandlers.END) { + if (ctrlOrShiftKeyActive.value) { + if (element.broken) newEl.broken = [midX - minX, midY - minY] + if (element.curve) newEl.curve = [midX - minX, midY - minY] + if (element.cubic) newEl.cubic = [[c1X - minX, c1Y - minY], [c2X - minX, c2Y - minY]] + } + else { + if (element.broken) newEl.broken = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (element.curve) newEl.curve = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (element.cubic) newEl.cubic = [[(start[0] + end[0]) / 2, (start[1] + end[1]) / 2], [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]] + } + if (element.broken2) newEl.broken2 = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + } + else if (command === OperateLineHandlers.C) { + if (element.broken) newEl.broken = [midX - minX, midY - minY] + if (element.curve) newEl.curve = [midX - minX, midY - minY] + if (element.broken2) { + if (maxX - minX >= maxY - minY) newEl.broken2 = [midX - minX, newEl.broken2![1]] + else newEl.broken2 = [newEl.broken2![0], midY - minY] + } + } + else { + if (element.cubic) newEl.cubic = [[c1X - minX, c1Y - minY], [c2X - minX, c2Y - minY]] + } + return newEl + } + return el + }) + } + + document.onmouseup = e => { + isMouseDown = false + document.onmousemove = null + document.onmouseup = null + + const currentPageX = e.pageX + const currentPageY = e.pageY + + if (startPageX === currentPageX && startPageY === currentPageY) return + + slidesStore.updateSlide({ elements: elementList.value }) + addHistorySnapshot() + } + } + + return { + dragLineElement, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useDropImageOrText.ts b/frontend/src/views/Editor/Canvas/hooks/useDropImageOrText.ts new file mode 100644 index 0000000000000000000000000000000000000000..e481fa6f48796acd0385b5c9e569aa8d99f57366 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useDropImageOrText.ts @@ -0,0 +1,64 @@ +import { onMounted, onUnmounted, type Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' +import { getImageDataURL } from '@/utils/image' +import { parseText2Paragraphs } from '@/utils/textParser' +import useCreateElement from '@/hooks/useCreateElement' + +export default (elementRef: Ref) => { + const { disableHotkeys } = storeToRefs(useMainStore()) + + const { createImageElement, createTextElement } = useCreateElement() + + // 拖拽元素到画布中 + const handleDrop = (e: DragEvent) => { + if (!e.dataTransfer || e.dataTransfer.items.length === 0) return + + const dataItems = e.dataTransfer.items + const dataTransferFirstItem = dataItems[0] + + // 检查事件对象中是否存在图片,存在则插入图片,否则继续检查是否存在文字,存在则插入文字 + let isImage = false + for (const item of dataItems) { + if (item.kind === 'file' && item.type.indexOf('image') !== -1) { + const imageFile = item.getAsFile() + if (imageFile) { + getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL)) + } + isImage = true + } + } + + if (isImage) return + + if (dataTransferFirstItem.kind === 'string' && dataTransferFirstItem.type === 'text/plain') { + dataTransferFirstItem.getAsString(text => { + if (disableHotkeys.value) return + const string = parseText2Paragraphs(text) + createTextElement({ + left: 0, + top: 0, + width: 600, + height: 50, + }, { content: string }) + }) + } + } + + onMounted(() => { + elementRef.value && elementRef.value.addEventListener('drop', handleDrop) + + document.ondragleave = e => e.preventDefault() + document.ondrop = e => e.preventDefault() + document.ondragenter = e => e.preventDefault() + document.ondragover = e => e.preventDefault() + }) + onUnmounted(() => { + elementRef.value && elementRef.value.removeEventListener('drop', handleDrop) + + document.ondragleave = null + document.ondrop = null + document.ondragenter = null + document.ondragover = null + }) +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useInsertFromCreateSelection.ts b/frontend/src/views/Editor/Canvas/hooks/useInsertFromCreateSelection.ts new file mode 100644 index 0000000000000000000000000000000000000000..9421fa9eaac8abe0cbef98e58d1310859b5faca5 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useInsertFromCreateSelection.ts @@ -0,0 +1,95 @@ +import type { Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' +import type { CreateElementSelectionData } from '@/types/edit' +import useCreateElement from '@/hooks/useCreateElement' + +export default (viewportRef: Ref) => { + const mainStore = useMainStore() + const { canvasScale, creatingElement } = storeToRefs(mainStore) + + // 通过鼠标框选时的起点和终点,计算选区的位置大小 + const formatCreateSelection = (selectionData: CreateElementSelectionData) => { + const { start, end } = selectionData + + if (!viewportRef.value) return + const viewportRect = viewportRef.value.getBoundingClientRect() + + const [startX, startY] = start + const [endX, endY] = end + const minX = Math.min(startX, endX) + const maxX = Math.max(startX, endX) + const minY = Math.min(startY, endY) + const maxY = Math.max(startY, endY) + + const left = (minX - viewportRect.x) / canvasScale.value + const top = (minY - viewportRect.y) / canvasScale.value + const width = (maxX - minX) / canvasScale.value + const height = (maxY - minY) / canvasScale.value + + return { left, top, width, height } + } + + // 通过鼠标框选时的起点和终点,计算线条在画布中的位置和起点终点 + const formatCreateSelectionForLine = (selectionData: CreateElementSelectionData) => { + const { start, end } = selectionData + + if (!viewportRef.value) return + const viewportRect = viewportRef.value.getBoundingClientRect() + + const [startX, startY] = start + const [endX, endY] = end + const minX = Math.min(startX, endX) + const maxX = Math.max(startX, endX) + const minY = Math.min(startY, endY) + const maxY = Math.max(startY, endY) + + const left = (minX - viewportRect.x) / canvasScale.value + const top = (minY - viewportRect.y) / canvasScale.value + const width = (maxX - minX) / canvasScale.value + const height = (maxY - minY) / canvasScale.value + + const _start: [number, number] = [ + startX === minX ? 0 : width, + startY === minY ? 0 : height, + ] + const _end: [number, number] = [ + endX === minX ? 0 : width, + endY === minY ? 0 : height, + ] + + return { + left, + top, + start: _start, + end: _end, + } + } + + const { createTextElement, createShapeElement, createLineElement } = useCreateElement() + + // 根据鼠标选区的位置大小插入元素 + const insertElementFromCreateSelection = (selectionData: CreateElementSelectionData) => { + if (!creatingElement.value) return + + const type = creatingElement.value.type + if (type === 'text') { + const position = formatCreateSelection(selectionData) + position && createTextElement(position, { vertical: creatingElement.value.vertical }) + } + else if (type === 'shape') { + const position = formatCreateSelection(selectionData) + position && createShapeElement(position, creatingElement.value.data) + } + else if (type === 'line') { + const position = formatCreateSelectionForLine(selectionData) + position && createLineElement(position, creatingElement.value.data) + } + mainStore.setCreatingElement(null) + } + + return { + formatCreateSelection, + insertElementFromCreateSelection, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useMouseSelection.ts b/frontend/src/views/Editor/Canvas/hooks/useMouseSelection.ts new file mode 100644 index 0000000000000000000000000000000000000000..395311b81fd908db290ca5a0f4091e4a35902a8a --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useMouseSelection.ts @@ -0,0 +1,146 @@ +import { type Ref, ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore } from '@/store' +import type { PPTElement } from '@/types/slides' +import { getElementRange } from '@/utils/element' + +export default (elementList: Ref, viewportRef: Ref) => { + const mainStore = useMainStore() + const { canvasScale, hiddenElementIdList } = storeToRefs(mainStore) + + const mouseSelectionVisible = ref(false) + const mouseSelectionQuadrant = ref(1) + const mouseSelection = ref({ + top: 0, + left: 0, + width: 0, + height: 0, + }) + + // 更新鼠标框选范围 + const updateMouseSelection = (e: MouseEvent) => { + if (!viewportRef.value) return + + let isMouseDown = true + const viewportRect = viewportRef.value.getBoundingClientRect() + + const minSelectionRange = 5 + + const startPageX = e.pageX + const startPageY = e.pageY + + const left = (startPageX - viewportRect.x) / canvasScale.value + const top = (startPageY - viewportRect.y) / canvasScale.value + + // 确定框选的起始位置和其他默认值初始化 + mouseSelection.value = { + top: top, + left: left, + width: 0, + height: 0, + } + mouseSelectionVisible.value = false + mouseSelectionQuadrant.value = 4 + + document.onmousemove = e => { + if (!isMouseDown) return + + const currentPageX = e.pageX + const currentPageY = e.pageY + + const offsetWidth = (currentPageX - startPageX) / canvasScale.value + const offsetHeight = (currentPageY - startPageY) / canvasScale.value + + const width = Math.abs(offsetWidth) + const height = Math.abs(offsetHeight) + + if ( width < minSelectionRange || height < minSelectionRange ) return + + // 计算鼠标框选(移动)的方向 + // 按四个象限的位置区分,如右下角为第四象限 + let quadrant = 0 + if ( offsetWidth > 0 && offsetHeight > 0 ) quadrant = 4 + else if ( offsetWidth < 0 && offsetHeight < 0 ) quadrant = 2 + else if ( offsetWidth > 0 && offsetHeight < 0 ) quadrant = 1 + else if ( offsetWidth < 0 && offsetHeight > 0 ) quadrant = 3 + + // 更新框选范围 + mouseSelection.value = { + ...mouseSelection.value, + width: width, + height: height, + } + mouseSelectionVisible.value = true + mouseSelectionQuadrant.value = quadrant + } + + document.onmouseup = () => { + document.onmousemove = null + document.onmouseup = null + isMouseDown = false + + // 计算画布中的元素是否处在鼠标选择范围中,处在范围中的元素设置为被选中状态 + let inRangeElementList: PPTElement[] = [] + for (let i = 0; i < elementList.value.length; i++) { + const element = elementList.value[i] + const mouseSelectionLeft = mouseSelection.value.left + const mouseSelectionTop = mouseSelection.value.top + const mouseSelectionWidth = mouseSelection.value.width + const mouseSelectionHeight = mouseSelection.value.height + + const { minX, maxX, minY, maxY } = getElementRange(element) + + // 计算元素是否处在框选范围内时,四个框选方向的计算方式有差异 + let isInclude = false + if (mouseSelectionQuadrant.value === 4) { + isInclude = minX > mouseSelectionLeft && + maxX < mouseSelectionLeft + mouseSelectionWidth && + minY > mouseSelectionTop && + maxY < mouseSelectionTop + mouseSelectionHeight + } + else if (mouseSelectionQuadrant.value === 2) { + isInclude = minX > (mouseSelectionLeft - mouseSelectionWidth) && + maxX < (mouseSelectionLeft - mouseSelectionWidth) + mouseSelectionWidth && + minY > (mouseSelectionTop - mouseSelectionHeight) && + maxY < (mouseSelectionTop - mouseSelectionHeight) + mouseSelectionHeight + } + else if (mouseSelectionQuadrant.value === 1) { + isInclude = minX > mouseSelectionLeft && + maxX < mouseSelectionLeft + mouseSelectionWidth && + minY > (mouseSelectionTop - mouseSelectionHeight) && + maxY < (mouseSelectionTop - mouseSelectionHeight) + mouseSelectionHeight + } + else if (mouseSelectionQuadrant.value === 3) { + isInclude = minX > (mouseSelectionLeft - mouseSelectionWidth) && + maxX < (mouseSelectionLeft - mouseSelectionWidth) + mouseSelectionWidth && + minY > mouseSelectionTop && + maxY < mouseSelectionTop + mouseSelectionHeight + } + + // 被锁定或被隐藏的元素即使在范围内,也不需要设置为选中状态 + if (isInclude && !element.lock && !hiddenElementIdList.value.includes(element.id)) inRangeElementList.push(element) + } + + // 如果范围内有组合元素的成员,需要该组全部成员都处在范围内,才会被设置为选中状态 + inRangeElementList = inRangeElementList.filter(inRangeElement => { + if (inRangeElement.groupId) { + const inRangeElementIdList = inRangeElementList.map(inRangeElement => inRangeElement.id) + const groupElementList = elementList.value.filter(element => element.groupId === inRangeElement.groupId) + return groupElementList.every(groupElement => inRangeElementIdList.includes(groupElement.id)) + } + return true + }) + const inRangeElementIdList = inRangeElementList.map(inRangeElement => inRangeElement.id) + mainStore.setActiveElementIdList(inRangeElementIdList) + + mouseSelectionVisible.value = false + } + } + + return { + mouseSelection, + mouseSelectionVisible, + mouseSelectionQuadrant, + updateMouseSelection, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useMoveShapeKeypoint.ts b/frontend/src/views/Editor/Canvas/hooks/useMoveShapeKeypoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..120340eb029111bce62258ac127c556f6acfa739 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useMoveShapeKeypoint.ts @@ -0,0 +1,124 @@ +import type { Ref } from 'vue' +import { useSlidesStore } from '@/store' +import type { PPTElement, PPTShapeElement } from '@/types/slides' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' +import { SHAPE_PATH_FORMULAS } from '@/configs/shapes' + +interface ShapePathData { + baseSize: number, + originPos: number, + min: number, + max: number, + relative: string, +} + +export default ( + elementList: Ref, + canvasScale: Ref, +) => { + const slidesStore = useSlidesStore() + + const { addHistorySnapshot } = useHistorySnapshot() + + const moveShapeKeypoint = (e: MouseEvent | TouchEvent, element: PPTShapeElement, index = 0) => { + const isTouchEvent = !(e instanceof MouseEvent) + if (isTouchEvent && (!e.changedTouches || !e.changedTouches[0])) return + + let isMouseDown = true + + const startPageX = isTouchEvent ? e.changedTouches[0].pageX : e.pageX + const startPageY = isTouchEvent ? e.changedTouches[0].pageY : e.pageY + + const originKeypoints = element.keypoints! + + const pathFormula = SHAPE_PATH_FORMULAS[element.pathFormula!] + let shapePathData: ShapePathData | null = null + if ('editable' in pathFormula && pathFormula.editable) { + const getBaseSize = pathFormula.getBaseSize![index] + const range = pathFormula.range![index] + const relative = pathFormula.relative![index] + const keypoint = originKeypoints[index] + + const baseSize = getBaseSize(element.width, element.height) + const originPos = baseSize * keypoint + const [min, max] = range + + shapePathData = { baseSize, originPos, min, max, relative } + } + + const handleMousemove = (e: MouseEvent | TouchEvent) => { + if (!isMouseDown) return + + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + const moveX = (currentPageX - startPageX) / canvasScale.value + const moveY = (currentPageY - startPageY) / canvasScale.value + + elementList.value = elementList.value.map(el => { + if (el.id === element.id && shapePathData) { + const { baseSize, originPos, min, max, relative } = shapePathData + const shapeElement = el as PPTShapeElement + + let keypoint = 0 + + if (relative === 'center') keypoint = (originPos - moveX * 2) / baseSize + else if (relative === 'left') keypoint = (originPos + moveX) / baseSize + else if (relative === 'right') keypoint = (originPos - moveX) / baseSize + else if (relative === 'top') keypoint = (originPos + moveY) / baseSize + else if (relative === 'bottom') keypoint = (originPos - moveY) / baseSize + else if (relative === 'left_bottom') keypoint = (originPos + moveX) / baseSize + else if (relative === 'right_bottom') keypoint = (originPos - moveX) / baseSize + else if (relative === 'top_right') keypoint = (originPos + moveY) / baseSize + else if (relative === 'bottom_right') keypoint = (originPos - moveY) / baseSize + + if (keypoint < min) keypoint = min + if (keypoint > max) keypoint = max + + let keypoints: number[] = [] + if (Array.isArray(originKeypoints)) { + keypoints = [...originKeypoints] + keypoints[index] = keypoint + } + else keypoints = [keypoint] + + return { + ...el, + keypoints, + path: pathFormula.formula(shapeElement.width, shapeElement.height, keypoints), + } + } + return el + }) + } + + const handleMouseup = (e: MouseEvent | TouchEvent) => { + isMouseDown = false + + document.ontouchmove = null + document.ontouchend = null + document.onmousemove = null + document.onmouseup = null + + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + if (startPageX === currentPageX && startPageY === currentPageY) return + + slidesStore.updateSlide({ elements: elementList.value }) + addHistorySnapshot() + } + + if (isTouchEvent) { + document.ontouchmove = handleMousemove + document.ontouchend = handleMouseup + } + else { + document.onmousemove = handleMousemove + document.onmouseup = handleMouseup + } + } + + return { + moveShapeKeypoint, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useRotateElement.ts b/frontend/src/views/Editor/Canvas/hooks/useRotateElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..5813732f045b06c1fc68e2e5ef4cf5df48848854 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useRotateElement.ts @@ -0,0 +1,100 @@ +import type { Ref } from 'vue' +import { useSlidesStore } from '@/store' +import type { PPTElement, PPTLineElement, PPTVideoElement, PPTAudioElement, PPTChartElement } from '@/types/slides' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +/** + * 计算给定坐标到原点连线的弧度 + * @param x 坐标x + * @param y 坐标y + */ +const getAngleFromCoordinate = (x: number, y: number) => { + const radian = Math.atan2(x, y) + const angle = 180 / Math.PI * radian + return angle +} + +export default ( + elementList: Ref, + viewportRef: Ref, + canvasScale: Ref, +) => { + const slidesStore = useSlidesStore() + + const { addHistorySnapshot } = useHistorySnapshot() + + // 旋转元素 + const rotateElement = (e: MouseEvent | TouchEvent, element: Exclude) => { + const isTouchEvent = !(e instanceof MouseEvent) + if (isTouchEvent && (!e.changedTouches || !e.changedTouches[0])) return + + let isMouseDown = true + let angle = 0 + const elOriginRotate = element.rotate || 0 + + const elLeft = element.left + const elTop = element.top + const elWidth = element.width + const elHeight = element.height + + // 元素中心点(旋转中心点) + const centerX = elLeft + elWidth / 2 + const centerY = elTop + elHeight / 2 + + if (!viewportRef.value) return + const viewportRect = viewportRef.value.getBoundingClientRect() + + const handleMousemove = (e: MouseEvent | TouchEvent) => { + if (!isMouseDown) return + + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + // 计算当前鼠标位置相对元素中心点连线的角度(弧度) + const mouseX = (currentPageX - viewportRect.left) / canvasScale.value + const mouseY = (currentPageY - viewportRect.top) / canvasScale.value + const x = mouseX - centerX + const y = centerY - mouseY + + angle = getAngleFromCoordinate(x, y) + + // 靠近45倍数的角度时有吸附效果 + const sorptionRange = 5 + if ( Math.abs(angle) <= sorptionRange ) angle = 0 + else if ( angle > 0 && Math.abs(angle - 45) <= sorptionRange ) angle -= (angle - 45) + else if ( angle < 0 && Math.abs(angle + 45) <= sorptionRange ) angle -= (angle + 45) + else if ( angle > 0 && Math.abs(angle - 90) <= sorptionRange ) angle -= (angle - 90) + else if ( angle < 0 && Math.abs(angle + 90) <= sorptionRange ) angle -= (angle + 90) + else if ( angle > 0 && Math.abs(angle - 135) <= sorptionRange ) angle -= (angle - 135) + else if ( angle < 0 && Math.abs(angle + 135) <= sorptionRange ) angle -= (angle + 135) + else if ( angle > 0 && Math.abs(angle - 180) <= sorptionRange ) angle -= (angle - 180) + else if ( angle < 0 && Math.abs(angle + 180) <= sorptionRange ) angle -= (angle + 180) + + elementList.value = elementList.value.map(el => element.id === el.id ? { ...el, rotate: angle } : el) + } + + const handleMouseup = () => { + isMouseDown = false + document.onmousemove = null + document.onmouseup = null + + if (elOriginRotate === angle) return + + slidesStore.updateSlide({ elements: elementList.value }) + addHistorySnapshot() + } + + if (isTouchEvent) { + document.ontouchmove = handleMousemove + document.ontouchend = handleMouseup + } + else { + document.onmousemove = handleMousemove + document.onmouseup = handleMouseup + } + } + + return { + rotateElement, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useScaleElement.ts b/frontend/src/views/Editor/Canvas/hooks/useScaleElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb28e688d6f90ca33dcc078d20bdb6d1c55ce065 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useScaleElement.ts @@ -0,0 +1,579 @@ +import type { Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store' +import type { PPTElement, PPTImageElement, PPTLineElement, PPTShapeElement } from '@/types/slides' +import { OperateResizeHandlers, type AlignmentLineProps, type MultiSelectRange } from '@/types/edit' +import { MIN_SIZE } from '@/configs/element' +import { SHAPE_PATH_FORMULAS } from '@/configs/shapes' +import { type AlignLine, uniqAlignLines } from '@/utils/element' +import useHistorySnapshot from '@/hooks/useHistorySnapshot' + +interface RotateElementData { + left: number + top: number + width: number + height: number +} + +/** + * 计算旋转后的元素八个缩放点的位置 + * @param element 元素原始位置大小信息 + * @param angle 旋转角度 + */ +const getRotateElementPoints = (element: RotateElementData, angle: number) => { + const { left, top, width, height } = element + + const radius = Math.sqrt( Math.pow(width, 2) + Math.pow(height, 2) ) / 2 + const auxiliaryAngle = Math.atan(height / width) * 180 / Math.PI + + const tlbraRadian = (180 - angle - auxiliaryAngle) * Math.PI / 180 + const trblaRadian = (auxiliaryAngle - angle) * Math.PI / 180 + const taRadian = (90 - angle) * Math.PI / 180 + const raRadian = angle * Math.PI / 180 + + const halfWidth = width / 2 + const halfHeight = height / 2 + + const middleLeft = left + halfWidth + const middleTop = top + halfHeight + + const leftTopPoint = { + left: middleLeft + radius * Math.cos(tlbraRadian), + top: middleTop - radius * Math.sin(tlbraRadian), + } + const topPoint = { + left: middleLeft + halfHeight * Math.cos(taRadian), + top: middleTop - halfHeight * Math.sin(taRadian), + } + const rightTopPoint = { + left: middleLeft + radius * Math.cos(trblaRadian), + top: middleTop - radius * Math.sin(trblaRadian), + } + const rightPoint = { + left: middleLeft + halfWidth * Math.cos(raRadian), + top: middleTop + halfWidth * Math.sin(raRadian), + } + const rightBottomPoint = { + left: middleLeft - radius * Math.cos(tlbraRadian), + top: middleTop + radius * Math.sin(tlbraRadian), + } + const bottomPoint = { + left: middleLeft - halfHeight * Math.sin(raRadian), + top: middleTop + halfHeight * Math.cos(raRadian), + } + const leftBottomPoint = { + left: middleLeft - radius * Math.cos(trblaRadian), + top: middleTop + radius * Math.sin(trblaRadian), + } + const leftPoint = { + left: middleLeft - halfWidth * Math.cos(raRadian), + top: middleTop - halfWidth * Math.sin(raRadian), + } + + return { leftTopPoint, topPoint, rightTopPoint, rightPoint, rightBottomPoint, bottomPoint, leftBottomPoint, leftPoint } +} + +/** + * 获取元素某缩放点相对的另一个点的位置,如:【上】对应【下】、【左上】对应【右下】 + * @param direction 当前操作的缩放点 + * @param points 旋转后的元素八个缩放点的位置 + */ +const getOppositePoint = (direction: OperateResizeHandlers, points: ReturnType): { left: number; top: number } => { + const oppositeMap = { + [OperateResizeHandlers.RIGHT_BOTTOM]: points.leftTopPoint, + [OperateResizeHandlers.LEFT_BOTTOM]: points.rightTopPoint, + [OperateResizeHandlers.LEFT_TOP]: points.rightBottomPoint, + [OperateResizeHandlers.RIGHT_TOP]: points.leftBottomPoint, + [OperateResizeHandlers.TOP]: points.bottomPoint, + [OperateResizeHandlers.BOTTOM]: points.topPoint, + [OperateResizeHandlers.LEFT]: points.rightPoint, + [OperateResizeHandlers.RIGHT]: points.leftPoint, + } + return oppositeMap[direction] +} + +export default ( + elementList: Ref, + alignmentLines: Ref, + canvasScale: Ref, +) => { + const mainStore = useMainStore() + const slidesStore = useSlidesStore() + const { activeElementIdList, activeGroupElementId } = storeToRefs(mainStore) + const { viewportRatio, viewportSize } = storeToRefs(slidesStore) + const { ctrlOrShiftKeyActive } = storeToRefs(useKeyboardStore()) + + const { addHistorySnapshot } = useHistorySnapshot() + + // 缩放元素 + const scaleElement = (e: MouseEvent | TouchEvent, element: Exclude, command: OperateResizeHandlers) => { + const isTouchEvent = !(e instanceof MouseEvent) + if (isTouchEvent && (!e.changedTouches || !e.changedTouches[0])) return + + let isMouseDown = true + mainStore.setScalingState(true) + + const elOriginLeft = element.left + const elOriginTop = element.top + const elOriginWidth = element.width + const elOriginHeight = element.height + + const originTableCellMinHeight = element.type === 'table' ? element.cellMinHeight : 0 + + const elRotate = ('rotate' in element && element.rotate) ? element.rotate : 0 + const rotateRadian = Math.PI * elRotate / 180 + + const fixedRatio = ctrlOrShiftKeyActive.value || ('fixedRatio' in element && element.fixedRatio) + const aspectRatio = elOriginWidth / elOriginHeight + + const startPageX = isTouchEvent ? e.changedTouches[0].pageX : e.pageX + const startPageY = isTouchEvent ? e.changedTouches[0].pageY : e.pageY + + // 元素最小缩放限制 + const minSize = MIN_SIZE[element.type] || 20 + const getSizeWithinRange = (size: number, type: 'width' | 'height') => { + if (!fixedRatio) return size < minSize ? minSize : size + + let minWidth = minSize + let minHeight = minSize + const ratio = element.width / element.height + if (ratio < 1) minHeight = minSize / ratio + if (ratio > 1) minWidth = minSize * ratio + + if (type === 'width') return size < minWidth ? minWidth : size + return size < minHeight ? minHeight : size + } + + let points: ReturnType + let baseLeft = 0 + let baseTop = 0 + let horizontalLines: AlignLine[] = [] + let verticalLines: AlignLine[] = [] + + // 旋转后的元素进行缩放时,引入基点的概念,以当前操作的缩放点相对的点为基点 + // 例如拖动右下角缩放时,左上角为基点,需要保持左上角不变然后修改其他的点的位置来达到所放的效果 + if ('rotate' in element && element.rotate) { + const { left, top, width, height } = element + points = getRotateElementPoints({ left, top, width, height }, elRotate) + const oppositePoint = getOppositePoint(command, points) + + baseLeft = oppositePoint.left + baseTop = oppositePoint.top + } + + // 未旋转的元素具有缩放时的对齐吸附功能,在此处收集对齐对齐吸附线 + // 包括页面内除目标元素外的其他元素在画布中的各个可吸附对齐位置:上下左右四边 + // 其中线条和被旋转过的元素不参与吸附对齐 + else { + const edgeWidth = viewportSize.value + const edgeHeight = viewportSize.value * viewportRatio.value + const isActiveGroupElement = element.id === activeGroupElementId.value + + for (const el of elementList.value) { + if ('rotate' in el && el.rotate) continue + if (el.type === 'line') continue + if (isActiveGroupElement && el.id === element.id) continue + if (!isActiveGroupElement && activeElementIdList.value.includes(el.id)) continue + + const left = el.left + const top = el.top + const width = el.width + const height = el.height + const right = left + width + const bottom = top + height + + const topLine: AlignLine = { value: top, range: [left, right] } + const bottomLine: AlignLine = { value: bottom, range: [left, right] } + const leftLine: AlignLine = { value: left, range: [top, bottom] } + const rightLine: AlignLine = { value: right, range: [top, bottom] } + + horizontalLines.push(topLine, bottomLine) + verticalLines.push(leftLine, rightLine) + } + + // 画布可视区域的四个边界、水平中心、垂直中心 + const edgeTopLine: AlignLine = { value: 0, range: [0, edgeWidth] } + const edgeBottomLine: AlignLine = { value: edgeHeight, range: [0, edgeWidth] } + const edgeHorizontalCenterLine: AlignLine = { value: edgeHeight / 2, range: [0, edgeWidth] } + const edgeLeftLine: AlignLine = { value: 0, range: [0, edgeHeight] } + const edgeRightLine: AlignLine = { value: edgeWidth, range: [0, edgeHeight] } + const edgeVerticalCenterLine: AlignLine = { value: edgeWidth / 2, range: [0, edgeHeight] } + + horizontalLines.push(edgeTopLine, edgeBottomLine, edgeHorizontalCenterLine) + verticalLines.push(edgeLeftLine, edgeRightLine, edgeVerticalCenterLine) + + horizontalLines = uniqAlignLines(horizontalLines) + verticalLines = uniqAlignLines(verticalLines) + } + + // 对齐吸附方法 + // 将收集到的对齐吸附线与计算的目标元素当前的位置大小相关数据做对比,差值小于设定的值时执行自动缩放校正 + // 水平和垂直两个方向需要分开计算 + const alignedAdsorption = (currentX: number | null, currentY: number | null) => { + const sorptionRange = 5 + + const _alignmentLines: AlignmentLineProps[] = [] + let isVerticalAdsorbed = false + let isHorizontalAdsorbed = false + const correctionVal = { offsetX: 0, offsetY: 0 } + + if (currentY || currentY === 0) { + for (let i = 0; i < horizontalLines.length; i++) { + const { value, range } = horizontalLines[i] + const min = Math.min(...range, currentX || 0) + const max = Math.max(...range, currentX || 0) + + if (Math.abs(currentY - value) < sorptionRange && !isHorizontalAdsorbed) { + correctionVal.offsetY = currentY - value + isHorizontalAdsorbed = true + _alignmentLines.push({ type: 'horizontal', axis: {x: min - 50, y: value}, length: max - min + 100 }) + } + } + } + if (currentX || currentX === 0) { + for (let i = 0; i < verticalLines.length; i++) { + const { value, range } = verticalLines[i] + const min = Math.min(...range, (currentY || 0)) + const max = Math.max(...range, (currentY || 0)) + + if (Math.abs(currentX - value) < sorptionRange && !isVerticalAdsorbed) { + correctionVal.offsetX = currentX - value + isVerticalAdsorbed = true + _alignmentLines.push({ type: 'vertical', axis: {x: value, y: min - 50}, length: max - min + 100 }) + } + } + } + alignmentLines.value = _alignmentLines + return correctionVal + } + + const handleMousemove = (e: MouseEvent | TouchEvent) => { + if (!isMouseDown) return + + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + const x = currentPageX - startPageX + const y = currentPageY - startPageY + + let width = elOriginWidth + let height = elOriginHeight + let left = elOriginLeft + let top = elOriginTop + + // 元素被旋转的情况下,需要根据元素旋转的角度,重新计算需要缩放的距离(鼠标按下后移动的距离) + if (elRotate) { + const revisedX = (Math.cos(rotateRadian) * x + Math.sin(rotateRadian) * y) / canvasScale.value + let revisedY = (Math.cos(rotateRadian) * y - Math.sin(rotateRadian) * x) / canvasScale.value + + // 锁定宽高比例(仅四个角可能触发,四条边不会触发) + // 以水平方向上缩放的距离为基础,计算垂直方向上的缩放距离,保持二者具有相同的缩放比例 + if (fixedRatio) { + if (command === OperateResizeHandlers.RIGHT_BOTTOM || command === OperateResizeHandlers.LEFT_TOP) revisedY = revisedX / aspectRatio + if (command === OperateResizeHandlers.LEFT_BOTTOM || command === OperateResizeHandlers.RIGHT_TOP) revisedY = -revisedX / aspectRatio + } + + // 根据不同的操作点分别计算元素缩放后的大小和位置 + // 需要注意: + // 此处计算的位置需要在后面重新进行校正,因为旋转后再缩放事实上会改变元素基点的位置(虽然视觉上基点保持不动,但这是【旋转】+【移动】共同作用的结果) + // 但此处计算的大小不需要重新校正,因为前面已经重新计算需要缩放的距离,相当于大小已经经过了校正 + if (command === OperateResizeHandlers.RIGHT_BOTTOM) { + width = getSizeWithinRange(elOriginWidth + revisedX, 'width') + height = getSizeWithinRange(elOriginHeight + revisedY, 'height') + } + else if (command === OperateResizeHandlers.LEFT_BOTTOM) { + width = getSizeWithinRange(elOriginWidth - revisedX, 'width') + height = getSizeWithinRange(elOriginHeight + revisedY, 'height') + left = elOriginLeft - (width - elOriginWidth) + } + else if (command === OperateResizeHandlers.LEFT_TOP) { + width = getSizeWithinRange(elOriginWidth - revisedX, 'width') + height = getSizeWithinRange(elOriginHeight - revisedY, 'height') + left = elOriginLeft - (width - elOriginWidth) + top = elOriginTop - (height - elOriginHeight) + } + else if (command === OperateResizeHandlers.RIGHT_TOP) { + width = getSizeWithinRange(elOriginWidth + revisedX, 'width') + height = getSizeWithinRange(elOriginHeight - revisedY, 'height') + top = elOriginTop - (height - elOriginHeight) + } + else if (command === OperateResizeHandlers.TOP) { + height = getSizeWithinRange(elOriginHeight - revisedY, 'height') + top = elOriginTop - (height - elOriginHeight) + } + else if (command === OperateResizeHandlers.BOTTOM) { + height = getSizeWithinRange(elOriginHeight + revisedY, 'height') + } + else if (command === OperateResizeHandlers.LEFT) { + width = getSizeWithinRange(elOriginWidth - revisedX, 'width') + left = elOriginLeft - (width - elOriginWidth) + } + else if (command === OperateResizeHandlers.RIGHT) { + width = getSizeWithinRange(elOriginWidth + revisedX, 'width') + } + + // 获取当前元素的基点坐标,与初始状态时的基点坐标进行对比,并计算差值进行元素位置的校正 + const currentPoints = getRotateElementPoints({ width, height, left, top }, elRotate) + const currentOppositePoint = getOppositePoint(command, currentPoints) + const currentBaseLeft = currentOppositePoint.left + const currentBaseTop = currentOppositePoint.top + + const offsetX = currentBaseLeft - baseLeft + const offsetY = currentBaseTop - baseTop + + left = left - offsetX + top = top - offsetY + } + + // 元素未被旋转的情况下,正常计算新的位置大小即可,无需复杂的校正等工作 + // 额外需要处理对齐吸附相关的操作 + // 锁定宽高比例相关的操作同上,不再赘述 + else { + let moveX = x / canvasScale.value + let moveY = y / canvasScale.value + + if (fixedRatio) { + if (command === OperateResizeHandlers.RIGHT_BOTTOM || command === OperateResizeHandlers.LEFT_TOP) moveY = moveX / aspectRatio + if (command === OperateResizeHandlers.LEFT_BOTTOM || command === OperateResizeHandlers.RIGHT_TOP) moveY = -moveX / aspectRatio + } + + if (command === OperateResizeHandlers.RIGHT_BOTTOM) { + const { offsetX, offsetY } = alignedAdsorption(elOriginLeft + elOriginWidth + moveX, elOriginTop + elOriginHeight + moveY) + moveX = moveX - offsetX + moveY = moveY - offsetY + if (fixedRatio) { + if (offsetY) moveX = moveY * aspectRatio + else moveY = moveX / aspectRatio + } + width = getSizeWithinRange(elOriginWidth + moveX, 'width') + height = getSizeWithinRange(elOriginHeight + moveY, 'height') + } + else if (command === OperateResizeHandlers.LEFT_BOTTOM) { + const { offsetX, offsetY } = alignedAdsorption(elOriginLeft + moveX, elOriginTop + elOriginHeight + moveY) + moveX = moveX - offsetX + moveY = moveY - offsetY + if (fixedRatio) { + if (offsetY) moveX = -moveY * aspectRatio + else moveY = -moveX / aspectRatio + } + width = getSizeWithinRange(elOriginWidth - moveX, 'width') + height = getSizeWithinRange(elOriginHeight + moveY, 'height') + left = elOriginLeft - (width - elOriginWidth) + } + else if (command === OperateResizeHandlers.LEFT_TOP) { + const { offsetX, offsetY } = alignedAdsorption(elOriginLeft + moveX, elOriginTop + moveY) + moveX = moveX - offsetX + moveY = moveY - offsetY + if (fixedRatio) { + if (offsetY) moveX = moveY * aspectRatio + else moveY = moveX / aspectRatio + } + width = getSizeWithinRange(elOriginWidth - moveX, 'width') + height = getSizeWithinRange(elOriginHeight - moveY, 'height') + left = elOriginLeft - (width - elOriginWidth) + top = elOriginTop - (height - elOriginHeight) + } + else if (command === OperateResizeHandlers.RIGHT_TOP) { + const { offsetX, offsetY } = alignedAdsorption(elOriginLeft + elOriginWidth + moveX, elOriginTop + moveY) + moveX = moveX - offsetX + moveY = moveY - offsetY + if (fixedRatio) { + if (offsetY) moveX = -moveY * aspectRatio + else moveY = -moveX / aspectRatio + } + width = getSizeWithinRange(elOriginWidth + moveX, 'width') + height = getSizeWithinRange(elOriginHeight - moveY, 'height') + top = elOriginTop - (height - elOriginHeight) + } + else if (command === OperateResizeHandlers.LEFT) { + const { offsetX } = alignedAdsorption(elOriginLeft + moveX, null) + moveX = moveX - offsetX + width = getSizeWithinRange(elOriginWidth - moveX, 'width') + left = elOriginLeft - (width - elOriginWidth) + } + else if (command === OperateResizeHandlers.RIGHT) { + const { offsetX } = alignedAdsorption(elOriginLeft + elOriginWidth + moveX, null) + moveX = moveX - offsetX + width = getSizeWithinRange(elOriginWidth + moveX, 'width') + } + else if (command === OperateResizeHandlers.TOP) { + const { offsetY } = alignedAdsorption(null, elOriginTop + moveY) + moveY = moveY - offsetY + height = getSizeWithinRange(elOriginHeight - moveY, 'height') + top = elOriginTop - (height - elOriginHeight) + } + else if (command === OperateResizeHandlers.BOTTOM) { + const { offsetY } = alignedAdsorption(null, elOriginTop + elOriginHeight + moveY) + moveY = moveY - offsetY + height = getSizeWithinRange(elOriginHeight + moveY, 'height') + } + } + + elementList.value = elementList.value.map(el => { + if (element.id !== el.id) return el + if (el.type === 'shape' && 'pathFormula' in el && el.pathFormula) { + const pathFormula = SHAPE_PATH_FORMULAS[el.pathFormula] + + let path = '' + if ('editable' in pathFormula) path = pathFormula.formula(width, height, el.keypoints!) + else path = pathFormula.formula(width, height) + + return { + ...el, left, top, width, height, + viewBox: [width, height], + path, + } + } + if (el.type === 'table') { + let cellMinHeight = originTableCellMinHeight + (height - elOriginHeight) / el.data.length + cellMinHeight = cellMinHeight < 36 ? 36 : cellMinHeight + + if (cellMinHeight === originTableCellMinHeight) return { ...el, left, width } + return { + ...el, left, top, width, height, + cellMinHeight: cellMinHeight < 36 ? 36 : cellMinHeight, + } + } + return { ...el, left, top, width, height } + }) + } + + const handleMouseup = (e: MouseEvent | TouchEvent) => { + isMouseDown = false + + document.ontouchmove = null + document.ontouchend = null + document.onmousemove = null + document.onmouseup = null + + alignmentLines.value = [] + + const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + if (startPageX === currentPageX && startPageY === currentPageY) return + + slidesStore.updateSlide({ elements: elementList.value }) + mainStore.setScalingState(false) + + addHistorySnapshot() + } + + if (isTouchEvent) { + document.ontouchmove = handleMousemove + document.ontouchend = handleMouseup + } + else { + document.onmousemove = handleMousemove + document.onmouseup = handleMouseup + } + } + + // 多选元素缩放 + const scaleMultiElement = (e: MouseEvent, range: MultiSelectRange, command: OperateResizeHandlers) => { + let isMouseDown = true + + const { minX, maxX, minY, maxY } = range + const operateWidth = maxX - minX + const operateHeight = maxY - minY + const aspectRatio = operateWidth / operateHeight + + const startPageX = e.pageX + const startPageY = e.pageY + + const originElementList: PPTElement[] = JSON.parse(JSON.stringify(elementList.value)) + + document.onmousemove = e => { + if (!isMouseDown) return + + const currentPageX = e.pageX + const currentPageY = e.pageY + + const x = (currentPageX - startPageX) / canvasScale.value + let y = (currentPageY - startPageY) / canvasScale.value + + // 锁定宽高比例,逻辑同上 + if (ctrlOrShiftKeyActive.value) { + if (command === OperateResizeHandlers.RIGHT_BOTTOM || command === OperateResizeHandlers.LEFT_TOP) y = x / aspectRatio + if (command === OperateResizeHandlers.LEFT_BOTTOM || command === OperateResizeHandlers.RIGHT_TOP) y = -x / aspectRatio + } + + // 所有选中元素的整体范围 + let currentMinX = minX + let currentMaxX = maxX + let currentMinY = minY + let currentMaxY = maxY + + if (command === OperateResizeHandlers.RIGHT_BOTTOM) { + currentMaxX = maxX + x + currentMaxY = maxY + y + } + else if (command === OperateResizeHandlers.LEFT_BOTTOM) { + currentMinX = minX + x + currentMaxY = maxY + y + } + else if (command === OperateResizeHandlers.LEFT_TOP) { + currentMinX = minX + x + currentMinY = minY + y + } + else if (command === OperateResizeHandlers.RIGHT_TOP) { + currentMaxX = maxX + x + currentMinY = minY + y + } + else if (command === OperateResizeHandlers.TOP) { + currentMinY = minY + y + } + else if (command === OperateResizeHandlers.BOTTOM) { + currentMaxY = maxY + y + } + else if (command === OperateResizeHandlers.LEFT) { + currentMinX = minX + x + } + else if (command === OperateResizeHandlers.RIGHT) { + currentMaxX = maxX + x + } + + // 所有选中元素的整体宽高 + const currentOppositeWidth = currentMaxX - currentMinX + const currentOppositeHeight = currentMaxY - currentMinY + + // 当前正在操作元素宽高占所有选中元素的整体宽高的比例 + let widthScale = currentOppositeWidth / operateWidth + let heightScale = currentOppositeHeight / operateHeight + + if (widthScale <= 0) widthScale = 0 + if (heightScale <= 0) heightScale = 0 + + // 根据前面计算的比例,计算并修改所有选中元素的位置大小 + elementList.value = elementList.value.map(el => { + if ((el.type === 'image' || el.type === 'shape') && activeElementIdList.value.includes(el.id)) { + const originElement = originElementList.find(originEl => originEl.id === el.id) as PPTImageElement | PPTShapeElement + return { + ...el, + width: originElement.width * widthScale, + height: originElement.height * heightScale, + left: currentMinX + (originElement.left - minX) * widthScale, + top: currentMinY + (originElement.top - minY) * heightScale, + } + } + return el + }) + } + + document.onmouseup = e => { + isMouseDown = false + document.onmousemove = null + document.onmouseup = null + + if (startPageX === e.pageX && startPageY === e.pageY) return + + slidesStore.updateSlide({ elements: elementList.value }) + addHistorySnapshot() + } + } + + return { + scaleElement, + scaleMultiElement, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/hooks/useSelectElement.ts b/frontend/src/views/Editor/Canvas/hooks/useSelectElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9d8471146dc6d731ec97a6770f3ef0dc5ff0874 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useSelectElement.ts @@ -0,0 +1,92 @@ +import type { Ref } from 'vue' +import { uniq } from 'lodash' +import { storeToRefs } from 'pinia' +import { useMainStore, useKeyboardStore } from '@/store' +import type { PPTElement } from '@/types/slides' + +export default ( + elementList: Ref, + moveElement: (e: MouseEvent | TouchEvent, element: PPTElement) => void, +) => { + const mainStore = useMainStore() + const { activeElementIdList, activeGroupElementId, handleElementId, editorAreaFocus } = storeToRefs(mainStore) + const { ctrlOrShiftKeyActive } = storeToRefs(useKeyboardStore()) + + // 选中元素 + // startMove 表示是否需要再选中操作后进入到开始移动的状态 + const selectElement = (e: MouseEvent | TouchEvent, element: PPTElement, startMove = true) => { + if (!editorAreaFocus.value) mainStore.setEditorareaFocus(true) + + // 如果目标元素当前未被选中,则将他设为选中状态 + // 此时如果按下Ctrl键或Shift键,则进入多选状态,将当前已选中的元素和目标元素一起设置为选中状态,否则仅将目标元素设置为选中状态 + // 如果目标元素是分组成员,需要将该组合的其他元素一起设置为选中状态 + if (!activeElementIdList.value.includes(element.id)) { + let newActiveIdList: string[] = [] + + if (ctrlOrShiftKeyActive.value) { + newActiveIdList = [...activeElementIdList.value, element.id] + } + else newActiveIdList = [element.id] + + if (element.groupId) { + const groupMembersId: string[] = [] + elementList.value.forEach((el: PPTElement) => { + if (el.groupId === element.groupId) groupMembersId.push(el.id) + }) + newActiveIdList = [...newActiveIdList, ...groupMembersId] + } + + mainStore.setActiveElementIdList(uniq(newActiveIdList)) + mainStore.setHandleElementId(element.id) + } + + // 如果目标元素已被选中,且按下了Ctrl键或Shift键,则取消其被选中状态 + // 除非目标元素是最后的一个被选中元素,或者目标元素所在的组合是最后一组选中组合 + // 如果目标元素是分组成员,需要将该组合的其他元素一起取消选中状态 + else if (ctrlOrShiftKeyActive.value) { + let newActiveIdList: string[] = [] + + if (element.groupId) { + const groupMembersId: string[] = [] + elementList.value.forEach((el: PPTElement) => { + if (el.groupId === element.groupId) groupMembersId.push(el.id) + }) + newActiveIdList = activeElementIdList.value.filter(id => !groupMembersId.includes(id)) + } + else { + newActiveIdList = activeElementIdList.value.filter(id => id !== element.id) + } + + if (newActiveIdList.length > 0) { + mainStore.setActiveElementIdList(newActiveIdList) + } + } + + // 如果目标元素已被选中,同时目标元素不是当前操作元素,则将其设置为当前操作元素 + else if (handleElementId.value !== element.id) { + mainStore.setHandleElementId(element.id) + } + + // 如果目标元素已被选中,同时也是当前操作元素,那么当目标元素在该状态下再次被点击时,将被设置为多选元素中的激活成员 + else if (activeGroupElementId.value !== element.id) { + const startPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX + const startPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY + + ;(e.target as HTMLElement).onmouseup = (e: MouseEvent) => { + const currentPageX = e.pageX + const currentPageY = e.pageY + + if (startPageX === currentPageX && startPageY === currentPageY) { + mainStore.setActiveGroupElementId(element.id) + ;(e.target as HTMLElement).onmouseup = null + } + } + } + + if (startMove) moveElement(e, element) + } + + return { + selectElement, + } +} diff --git a/frontend/src/views/Editor/Canvas/hooks/useViewportSize.ts b/frontend/src/views/Editor/Canvas/hooks/useViewportSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d215bc03fdd15678ccb9ce57caa1305ce05060f --- /dev/null +++ b/frontend/src/views/Editor/Canvas/hooks/useViewportSize.ts @@ -0,0 +1,124 @@ +import { ref, computed, onMounted, onUnmounted, watch, type Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useMainStore, useSlidesStore } from '@/store' + +export default (canvasRef: Ref) => { + const viewportLeft = ref(0) + const viewportTop = ref(0) + + const mainStore = useMainStore() + const { canvasPercentage, canvasDragged } = storeToRefs(mainStore) + const { viewportRatio, viewportSize } = storeToRefs(useSlidesStore()) + + // 初始化画布可视区域的位置 + const initViewportPosition = () => { + if (!canvasRef.value) return + const canvasWidth = canvasRef.value.clientWidth + const canvasHeight = canvasRef.value.clientHeight + + if (canvasHeight / canvasWidth > viewportRatio.value) { + const viewportActualWidth = canvasWidth * (canvasPercentage.value / 100) + mainStore.setCanvasScale(viewportActualWidth / viewportSize.value) + viewportLeft.value = (canvasWidth - viewportActualWidth) / 2 + viewportTop.value = (canvasHeight - viewportActualWidth * viewportRatio.value) / 2 + } + else { + const viewportActualHeight = canvasHeight * (canvasPercentage.value / 100) + mainStore.setCanvasScale(viewportActualHeight / (viewportSize.value * viewportRatio.value)) + viewportLeft.value = (canvasWidth - viewportActualHeight / viewportRatio.value) / 2 + viewportTop.value = (canvasHeight - viewportActualHeight) / 2 + } + } + + // 更新画布可视区域的位置 + const setViewportPosition = (newValue: number, oldValue: number) => { + if (!canvasRef.value) return + const canvasWidth = canvasRef.value.clientWidth + const canvasHeight = canvasRef.value.clientHeight + + if (canvasHeight / canvasWidth > viewportRatio.value) { + const newViewportActualWidth = canvasWidth * (newValue / 100) + const oldViewportActualWidth = canvasWidth * (oldValue / 100) + const newViewportActualHeight = newViewportActualWidth * viewportRatio.value + const oldViewportActualHeight = oldViewportActualWidth * viewportRatio.value + + mainStore.setCanvasScale(newViewportActualWidth / viewportSize.value) + + viewportLeft.value = viewportLeft.value - (newViewportActualWidth - oldViewportActualWidth) / 2 + viewportTop.value = viewportTop.value - (newViewportActualHeight - oldViewportActualHeight) / 2 + } + else { + const newViewportActualHeight = canvasHeight * (newValue / 100) + const oldViewportActualHeight = canvasHeight * (oldValue / 100) + const newViewportActualWidth = newViewportActualHeight / viewportRatio.value + const oldViewportActualWidth = oldViewportActualHeight / viewportRatio.value + + mainStore.setCanvasScale(newViewportActualHeight / (viewportSize.value * viewportRatio.value)) + + viewportLeft.value = viewportLeft.value - (newViewportActualWidth - oldViewportActualWidth) / 2 + viewportTop.value = viewportTop.value - (newViewportActualHeight - oldViewportActualHeight) / 2 + } + } + + // 可视区域缩放或比例变化时,重置/更新可视区域的位置 + watch(canvasPercentage, setViewportPosition) + watch(viewportRatio, initViewportPosition) + watch(viewportSize, initViewportPosition) + + // 画布拖拽状态改变(复原)时,重置可视区域的位置 + watch(canvasDragged, () => { + if (!canvasDragged.value) initViewportPosition() + }) + + // 画布可视区域位置和大小的样式 + const viewportStyles = computed(() => ({ + width: viewportSize.value, + height: viewportSize.value * viewportRatio.value, + left: viewportLeft.value, + top: viewportTop.value, + })) + + // 监听画布尺寸发生变化时,重置可视区域的位置 + const resizeObserver = new ResizeObserver(initViewportPosition) + + onMounted(() => { + if (canvasRef.value) resizeObserver.observe(canvasRef.value) + }) + onUnmounted(() => { + if (canvasRef.value) resizeObserver.unobserve(canvasRef.value) + }) + + // 拖拽画布 + const dragViewport = (e: MouseEvent) => { + let isMouseDown = true + + const startPageX = e.pageX + const startPageY = e.pageY + + const originLeft = viewportLeft.value + const originTop = viewportTop.value + + document.onmousemove = e => { + if (!isMouseDown) return + + const currentPageX = e.pageX + const currentPageY = e.pageY + + viewportLeft.value = originLeft + (currentPageX - startPageX) + viewportTop.value = originTop + (currentPageY - startPageY) + } + + document.onmouseup = () => { + isMouseDown = false + document.onmousemove = null + document.onmouseup = null + + mainStore.setCanvasDragged(true) + } + } + + return { + viewportStyles, + dragViewport, + } +} \ No newline at end of file diff --git a/frontend/src/views/Editor/Canvas/index.vue b/frontend/src/views/Editor/Canvas/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..05b06faa473ee440cc10da2535ffe3f7e8a7d0d8 --- /dev/null +++ b/frontend/src/views/Editor/Canvas/index.vue @@ -0,0 +1,371 @@ + + handleMousewheelCanvas($event)" + @mousedown="$event => handleClickBlankArea($event)" + @dblclick="$event => handleDblClick($event)" + v-contextmenu="contextmenus" + v-click-outside="removeEditorAreaFocus" + > + insertElementFromCreateSelection(data)" + /> + insertCustomShape(data)" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/CanvasTool/ChartPool.vue b/frontend/src/views/Editor/CanvasTool/ChartPool.vue new file mode 100644 index 0000000000000000000000000000000000000000..5ff76d3ea73427244f65f6a76f0f0f5a0e535d3e --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/ChartPool.vue @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + {{ CHART_TYPE_MAP[chart] }} + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/CanvasTool/LinePool.vue b/frontend/src/views/Editor/CanvasTool/LinePool.vue new file mode 100644 index 0000000000000000000000000000000000000000..5928730dbde3905e4c13e92ffb642c21145e9531 --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/LinePool.vue @@ -0,0 +1,112 @@ + + + + {{item.type}} + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/CanvasTool/MediaInput.vue b/frontend/src/views/Editor/CanvasTool/MediaInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..854d614deac00ac752619dd79a279cd6e7cf3f8a --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/MediaInput.vue @@ -0,0 +1,75 @@ + + + + + + + + 取消 + 确认 + + + + + + + 取消 + 确认 + + + + + + + + diff --git a/frontend/src/views/Editor/CanvasTool/ShapeItemThumbnail.vue b/frontend/src/views/Editor/CanvasTool/ShapeItemThumbnail.vue new file mode 100644 index 0000000000000000000000000000000000000000..0efd43cad2de58b311ed54c6eaf65ae41c52940b --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/ShapeItemThumbnail.vue @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/CanvasTool/ShapePool.vue b/frontend/src/views/Editor/CanvasTool/ShapePool.vue new file mode 100644 index 0000000000000000000000000000000000000000..acd0d9d3d7e9f2b17e13666a26554d47a9baf01d --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/ShapePool.vue @@ -0,0 +1,63 @@ + + + + {{item.type}} + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/CanvasTool/TableGenerator.vue b/frontend/src/views/Editor/CanvasTool/TableGenerator.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a6efa3d48cca0043d759f1b853bf92d5be62547 --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/TableGenerator.vue @@ -0,0 +1,162 @@ + + + + 表格 {{endCell.length ? `${endCell[0]} x ${endCell[1]}` : ''}} + {{ isCustom ? '返回' : '自定义'}} + + + + + + + + + + + + + + 行数: + + + + 列数: + + + + 取消 + 确认 + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/CanvasTool/index.vue b/frontend/src/views/Editor/CanvasTool/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..f0caadfc6a23d5e3da664c8e783b7fab5cbb4b71 --- /dev/null +++ b/frontend/src/views/Editor/CanvasTool/index.vue @@ -0,0 +1,361 @@ + + + + + + + + + + 批注面板 + 选择窗格 + 查找替换 + + + + + + + + + + + + + + + + { drawText(); textTypeSelectVisible = false }"> 横向文本框 + { drawText(true); textTypeSelectVisible = false }"> 竖向文本框 + + + + + + + + drawShape(shape)" /> + + + + + + + { drawCustomShape(); shapeMenuVisible = false }">自由绘制 + + + + + insertImageElement(files)"> + + + + + drawLine(line)" /> + + + + + + { createChartElement(chart); chartPoolVisible = false }" /> + + + + + + { createTableElement(row, col); tableGeneratorVisible = false }" + /> + + + + + + + { createVideoElement(src); mediaInputVisible = false }" + @insertAudio="src => { createAudioElement(src); mediaInputVisible = false }" + /> + + + + + + + + + + {{item}}% + 适应屏幕 + + {{canvasScalePercentage}} + + + + + + + { createLatexElement(data); latexEditorVisible = false }" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/EditorHeader/HotkeyDoc.vue b/frontend/src/views/Editor/EditorHeader/HotkeyDoc.vue new file mode 100644 index 0000000000000000000000000000000000000000..e2e0cf75fa7a9fc54713272254c764aaec3a1cc0 --- /dev/null +++ b/frontend/src/views/Editor/EditorHeader/HotkeyDoc.vue @@ -0,0 +1,49 @@ + + + + {{item.type}} + + + {{hotkey.label}} + {{hotkey.value}} + + {{hotkey.label}} + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/EditorHeader/index.vue b/frontend/src/views/Editor/EditorHeader/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..78427b4a272e30cf17a5c8f091b26d52475c70a4 --- /dev/null +++ b/frontend/src/views/Editor/EditorHeader/index.vue @@ -0,0 +1,331 @@ + + + + + + 新建演示文稿 + 保存当前PPT + 我的PPT + 导入 pptist 文件 + 导入 pptx 文件 + 重置幻灯片 + 进入全屏 + 快捷键 + 退出登录 + AI 生成 PPT + { + importPPTXFile(files) + mainMenuVisible = false + }"> + 导入 pptx 文件(测试版) + + { + importSpecificFile(files) + mainMenuVisible = false + }"> + 导入 pptist 文件 + + 导出文件 + 重置幻灯片 + 幻灯片类型标注 + 意见反馈 + 常见问题 + 快捷操作 + + + + + + + + {{ title }} + + + + + + + + + + + + + + + + 从头开始 + 从当前页开始 + + + + + + + AI + + + + + + + + + + + + 快捷操作 + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/ExportDialog/ExportImage.vue b/frontend/src/views/Editor/ExportDialog/ExportImage.vue new file mode 100644 index 0000000000000000000000000000000000000000..4547e14113f9e16201b424b672238afa3bcc0bcc --- /dev/null +++ b/frontend/src/views/Editor/ExportDialog/ExportImage.vue @@ -0,0 +1,185 @@ + + + + + + + + + + 导出格式: + + JPEG + PNG + + + + 导出范围: + + 全部 + 当前页 + 自定义 + + + + 自定义范围: + + + + + 图片质量: + + + + + 忽略在线字体: + + + + + + + + 导出图片 + 关闭 + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/ExportDialog/ExportJSON.vue b/frontend/src/views/Editor/ExportDialog/ExportJSON.vue new file mode 100644 index 0000000000000000000000000000000000000000..bdda8921ce934cafdaa45b2d9b8b4b8c4fbb34f1 --- /dev/null +++ b/frontend/src/views/Editor/ExportDialog/ExportJSON.vue @@ -0,0 +1,83 @@ + + + + {{ json }} + + + + 导出 JSON + 关闭 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/ExportDialog/ExportPDF.vue b/frontend/src/views/Editor/ExportDialog/ExportPDF.vue new file mode 100644 index 0000000000000000000000000000000000000000..57e9fa3efdbc5d27e29c3252500bbb8bcacd1ee5 --- /dev/null +++ b/frontend/src/views/Editor/ExportDialog/ExportPDF.vue @@ -0,0 +1,168 @@ + + + + + + + + + + + + + 导出范围: + + 全部 + 当前页 + + + + 每页数量: + + + + 边缘留白: + + + + + + 提示:若打印预览与实际样式不一致,请在弹出的打印窗口中勾选【背景图形】选项。 + + + + + 打印 / 导出 PDF + 关闭 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/ExportDialog/ExportPPTX.vue b/frontend/src/views/Editor/ExportDialog/ExportPPTX.vue new file mode 100644 index 0000000000000000000000000000000000000000..301317774e5f96b4c0885a8e5c58fce3956b26bb --- /dev/null +++ b/frontend/src/views/Editor/ExportDialog/ExportPPTX.vue @@ -0,0 +1,149 @@ + + + + + 导出范围: + + 全部 + 当前页 + 自定义 + + + + 自定义范围: + + + + 忽略音频/视频: + + + + + + 覆盖默认母版: + + + + + + + 提示:1. 支持导出格式:avi、mp4、mov、wmv、mp3、wav;2. 跨域资源无法导出。 + + + + 导出 PPTX + 关闭 + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/ExportDialog/ExportSpecificFile.vue b/frontend/src/views/Editor/ExportDialog/ExportSpecificFile.vue new file mode 100644 index 0000000000000000000000000000000000000000..7cd5cecc0eeb12385f1e9932eeb13d931dc72e5c --- /dev/null +++ b/frontend/src/views/Editor/ExportDialog/ExportSpecificFile.vue @@ -0,0 +1,130 @@ + + + + + 导出范围: + + 全部 + 当前页 + 自定义 + + + + 自定义范围: + + + + 提示:.pptist 是本应用的特有文件后缀,支持将该类型的文件导入回应用中。 + + + + 导出 .pptist 文件 + 关闭 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/ExportDialog/index.vue b/frontend/src/views/Editor/ExportDialog/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..0a82016523e1e195fdf485536fbbcb82005a206b --- /dev/null +++ b/frontend/src/views/Editor/ExportDialog/index.vue @@ -0,0 +1,70 @@ + + + setDialogForExport(key as DialogForExportTypes)" + /> + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/MarkupPanel.vue b/frontend/src/views/Editor/MarkupPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..167dcd3d680f2cc0aaebe98f9478b2b4f11b1cae --- /dev/null +++ b/frontend/src/views/Editor/MarkupPanel.vue @@ -0,0 +1,190 @@ + + + + + 当前页面类型: + updateSlide(value as SlideType | '')" + :options="slideTypeOptions" + /> + + + 当前文本类型: + updateElement(value as TextType | '')" + :options="textTypeOptions" + /> + + + 当前图片类型: + updateElement(value as ImageType | '')" + :options="imageTypeOptions" + /> + + 选中图片、文字、带文字的形状,标记类型 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/NotesPanel.vue b/frontend/src/views/Editor/NotesPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..a151061dfd84641bec43594b15590d049b6decd9 --- /dev/null +++ b/frontend/src/views/Editor/NotesPanel.vue @@ -0,0 +1,351 @@ + + + + + + + + + + {{ note.user }} + {{ new Date(note.time).toLocaleString() }} + + + + 回复 + 删除 + + + {{ note.content }} + + + + + + + {{ reply.user }} + {{ new Date(reply.time).toLocaleString() }} + + + + 删除 + + + {{ reply.content }} + + + + + + 取消 + 回复 + + + + 本页暂无批注 + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Remark/Editor.vue b/frontend/src/views/Editor/Remark/Editor.vue new file mode 100644 index 0000000000000000000000000000000000000000..a2b3208c6ef782a1b71f5e856170a08e3245d996 --- /dev/null +++ b/frontend/src/views/Editor/Remark/Editor.vue @@ -0,0 +1,250 @@ + + + + + + + + + + + + execCommand('color', value)" /> + + + + + + execCommand('backcolor', value)" /> + + + + + + + + + + + + + diff --git a/frontend/src/views/Editor/Remark/index.vue b/frontend/src/views/Editor/Remark/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..a9321b8e89213ebfcf42ec4b8ad2531b14ccb03a --- /dev/null +++ b/frontend/src/views/Editor/Remark/index.vue @@ -0,0 +1,89 @@ + + + resize($event)" + > + handleInput(value)" + /> + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/SearchPanel.vue b/frontend/src/views/Editor/SearchPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..22b0fbd5e374919e3b053a5f6eaf097861f097a0 --- /dev/null +++ b/frontend/src/views/Editor/SearchPanel.vue @@ -0,0 +1,151 @@ + + + + + + + + + {{searchIndex + 1}}/{{searchResults.length}} + + Aa + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/SelectPanel.vue b/frontend/src/views/Editor/SelectPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..b897825502fcc017b7b8f7f6a4fc70c5dbe26d81 --- /dev/null +++ b/frontend/src/views/Editor/SelectPanel.vue @@ -0,0 +1,251 @@ + + + + + 全部显示 + 全部隐藏 + + + + + + + + + + 组合 + + saveElementName($event, groupItem.id)" + @keydown.enter="$event => saveElementName($event, groupItem.id)" + > + {{groupItem.name || ELEMENT_TYPE_ZH[groupItem.type]}} + + + + + + + + saveElementName($event, item.id)" + @keydown.enter="$event => saveElementName($event, item.id)" + > + {{item.name || ELEMENT_TYPE_ZH[item.type]}} + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Thumbnails/Templates.vue b/frontend/src/views/Editor/Thumbnails/Templates.vue new file mode 100644 index 0000000000000000000000000000000000000000..d78c1f715da5e4a33280197dfac102aee8cdca45 --- /dev/null +++ b/frontend/src/views/Editor/Thumbnails/Templates.vue @@ -0,0 +1,218 @@ + + + + {{ item.name }} + + + + + {{ item.label }} + + 插入全部 + + + + + + + + 插入模板 + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Thumbnails/index.vue b/frontend/src/views/Editor/Thumbnails/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..e79dc7f2876c7bf3cdad49d63e39e91db4b120e6 --- /dev/null +++ b/frontend/src/views/Editor/Thumbnails/index.vue @@ -0,0 +1,811 @@ + + setThumbnailsFocus(true)" + v-click-outside="() => setThumbnailsFocus(false)" + v-contextmenu="contextmenusThumbnails" + > + + 添加幻灯片 + + + { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }" + @selectAll="slides => { insertAllTemplates(slides); presetLayoutPopoverVisible = false }" + /> + + + + + + + + + + saveSection($event)" + @keydown.enter.stop="$event => saveSection($event)" + v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')" + > + + {{ element?.sectionTag ? (element?.sectionTag?.title || '无标题节') : '默认节' }} + + + handleClickSlideThumbnail($event, index)" + @dblclick="enterScreening()" + v-contextmenu="contextmenusThumbnailItem" + > + {{ fillDigit(index + 1, 2) }} + + + + + + + + {{ element.notes.length }} + + + + + + 幻灯片 {{slideIndex + 1}} / {{slides.length}} + + + + + + 第 {{ shareSlideIndex + 1 }} 页幻灯片 + + + + + + + + 单页查看链接: + + + 复制 + + + + + 完整PPT链接: + + + 复制 + + + + + 前端查看链接: + + + 复制 + + + + + + 说明: + + 单页查看链接:只显示当前选中的幻灯片页面 + 完整PPT链接:可以查看整个PPT演示文稿 + 前端查看链接:在前端界面中查看幻灯片 + + 💡 这些链接是固定的,可以直接分享给他人访问 + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementAnimationPanel.vue b/frontend/src/views/Editor/Toolbar/ElementAnimationPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..a89eab052d2bfebe652cdd2b717ff8516d9a5e40 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementAnimationPanel.vue @@ -0,0 +1,498 @@ + + + + handlePopoverVisibleChange(visible)" + style="width: 100%;" + > + + + + + + {{effect.name}}: + + + {{item.name}} + + + + + + + + + 添加动画 + + + + + 选中画布中的元素添加动画 + + + + + + + + {{element.index}} + 【{{element.elType}}】{{element.animationEffect}} + + + + + + + + + + + 持续时长: + updateElementAnimationDuration(element.id, value)" + style="width: 65%;" + /> + + + 触发方式: + updateElementAnimationTrigger(element.id, value as AnimationTrigger)" + style="width: 65%;" + :options="[ + { label: '主动触发', value: 'click' }, + { label: '与上一动画同时', value: 'meantime' }, + { label: '上一动画之后', value: 'auto' }, + ]" + /> + + + 更换动画 + + + + + + + + + + {{ animateIn ? '停止预览' : '预览全部'}} + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementPositionPanel.vue b/frontend/src/views/Editor/Toolbar/ElementPositionPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..d2b83a2673fe679e17dc6f26e201d30cfe1be16f --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementPositionPanel.vue @@ -0,0 +1,316 @@ + + + 层级: + + 置顶 + 置底 + + + 上移 + 下移 + + + + + 对齐: + + + + + + + + + + + + + + + updateLeft(value)" + style="width: 45%;" + > + + 水平: + + + + updateTop(value)" + style="width: 45%;" + > + + 垂直: + + + + + + + updateWidth(value)" + style="width: 45%;" + > + + 宽度: + + + + + + + + updateHeight(value)" + style="width: 45%;" + > + + 高度: + + + + + + + + + + updateRotate(value)" + style="width: 45%;" + > + + 旋转: + + + + -45° + +45° + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/AudioStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/AudioStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..ac127d246deb88c7bdf308d88fac58639af252e8 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/AudioStylePanel.vue @@ -0,0 +1,77 @@ + + + + 图标颜色: + + + updateAudio({ color: value })" + /> + + + + + + + 自动播放: + + updateAudio({ autoplay: value })" + /> + + + + + 循环播放: + + updateAudio({ loop: value })" + /> + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ChartDataEditor.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ChartDataEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..50eca68bad5033166e6d31b6bec07f313bfe0868 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ChartDataEditor.vue @@ -0,0 +1,565 @@ + + + + + + + {{ alphabet[colIndex - 1] }} + + + + + {{ rowIndex }} + + + + + + + + + + + + + + + + + + handlePaste($event, rowIndex - 1, colIndex - 1)" + > + + + + + + + + + 图表类型:{{ CHART_TYPE_MAP[chartType] }} + + + {{CHART_TYPE_MAP[item]}} + + 点击更换 + + + + 取消 + 清空数据 + 确认 + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ThemeColorsSetting.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ThemeColorsSetting.vue new file mode 100644 index 0000000000000000000000000000000000000000..41e97e8c81d6becb38ab21c0276cc54863e3bd0e --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/ThemeColorsSetting.vue @@ -0,0 +1,108 @@ + + + 图表主题配色 + + + + 主题配色{{ index + 1 }}: + + + themeColors[index] = value" + /> + + + + + + + + + 添加主题色 + + + + 确认 + + + + + + diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/index.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..de5258a5f0a9ff1dbe13affbda5fe7bcf75c0d8e --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ChartStylePanel/index.vue @@ -0,0 +1,265 @@ + + + + 编辑图表 + + + + + + + updateOptions({ stack: value })" + :value="stack" + style="flex: 2;" + >堆叠样式 + updateOptions({ lineSmooth: value })" + :value="lineSmooth" + style="flex: 3;" + >使用平滑曲线 + + + + + + + 背景填充: + + + updateFill(value)" + /> + + + + + + 文字颜色: + + + updateTextColor(value)" + /> + + + + + + + 主题配色: + + + + 预置图表主题: + + + + + + 幻灯片主题: + + + + + + + 自定义配色 + + + + + + + + + + + + updateData(value)" + /> + + + + setThemeColors(colors)" /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/ImageStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ImageStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..39d61b77e35528f09b469717fe95df41fcb25241 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ImageStylePanel.vue @@ -0,0 +1,357 @@ + + + + + + + + 裁剪图片 + + + + 按形状: + + + + + + + + 按{{typeItem.label}}: + + {{item.key}} + + + + + + + + + + 圆角半径: + updateImage({ radius: value })" + style="width: 60%;" + /> + + + + + + + + + + + + + replaceImage(files)"> + 替换图片 + + 重置样式 + 设为背景 + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/LatexStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/LatexStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..640d10042aececc604787cc2fa3df4f880cf1299 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/LatexStylePanel.vue @@ -0,0 +1,102 @@ + + + + 编辑 LaTeX + + + + + + 颜色: + + + updateLatex({ color: value })" + /> + + + + + + 粗细: + updateLatex({ strokeWidth: value })" + style="width: 60%;" + /> + + + + { updateLatexData(data); latexEditorVisible = false }" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/LineStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/LineStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..73a24700475ea8adf72be4690df1e925a874bb7a --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/LineStylePanel.vue @@ -0,0 +1,157 @@ + + + + 线条样式: + + + + + + + + + + + + + 线条颜色: + + + updateLine({ color: value })" + /> + + + + + + 线条宽度: + updateLine({ width: value })" + style="width: 60%;" + /> + + + + 起点样式: + + + + + + + + + + + + + 终点样式: + + + + + + + + + + + + + + + + 交换方向 + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/ShapeStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ShapeStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..f4056a64aef9985099d59134f2b3ad0164879c94 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/ShapeStylePanel.vue @@ -0,0 +1,373 @@ + + + + 点击替换形状 + + + + + + + + + + + + updateFillType(value as 'fill' | 'gradient' | 'pattern')" + :options="[ + { label: '纯色填充', value: 'fill' }, + { label: '渐变填充', value: 'gradient' }, + { label: '图片填充', value: 'pattern' }, + ]" + /> + + + + updateFill(value)" + /> + + + + updateGradient({ type: value as GradientType })" + v-else-if="fillType === 'gradient'" + :options="[ + { label: '线性渐变', value: 'linear' }, + { label: '径向渐变', value: 'radial' }, + ]" + /> + + + + + updateGradient({ colors: value })" + @update:index="index => currentGradientIndex = index" + /> + + + 当前色块: + + + updateGradientColors(value)" + /> + + + + + + 渐变角度: + updateGradient({ rotate: value as number })" + /> + + + + + + uploadPattern(files)"> + + + + + + + + + + + + + + + + + + updateTextAlign(value as 'top' | 'middle' | 'bottom')" + > + + + + + + + + + + + + + + + + + 形状格式刷 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/TableStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/TableStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..5a89b08cb8ea7cccf47b70e1afddf3aa1c263ece --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/TableStylePanel.vue @@ -0,0 +1,419 @@ + + + + updateTextAttrs({ fontname: value as string })" + :options="FONTS" + > + + + + + updateTextAttrs({ fontsize: value as string })" + :options="fontSizeOptions.map(item => ({ + label: item, value: item + }))" + > + + + + + + + + + + updateTextAttrs({ color: value })" + /> + + + + + + + + updateTextAttrs({ backcolor: value })" + /> + + + + + + + + + + + + + + + updateTextAttrs({ align: value as TextAlign })" + > + + + + + + + + + + + + + + 行数: + + + {{rowCount}} + + + + + 列数: + + + {{colCount}} + + + + + + + + 启用主题表格: + + toggleTheme(value)" + /> + + + + + + updateTheme({ rowHeader: value })" + :value="theme.rowHeader" + style="flex: 1;" + >标题行 + updateTheme({ rowFooter: value })" + :value="theme.rowFooter" + style="flex: 1;" + >汇总行 + + + updateTheme({ colHeader: value })" + :value="theme.colHeader" + style="flex: 1;" + >第一列 + updateTheme({ colFooter: value })" + :value="theme.colFooter" + style="flex: 1;" + >最后一列 + + + 主题颜色: + + + updateTheme({ color: value })" + /> + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/TextStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/TextStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..34c72da41fb5d05376d7e5f54ab017f620800b5d --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/TextStylePanel.vue @@ -0,0 +1,270 @@ + + + + {{item.label}} + + + + + + + + 行间距: + updateLineHeight(value as number)" + :options="lineHeightOptions.map(item => ({ + label: item + '倍', value: item + }))" + > + + + + + + + 段间距: + updateParagraphSpace(value as number)" + :options="paragraphSpaceOptions.map(item => ({ + label: item + 'px', value: item + }))" + > + + + + + + + 字间距: + updateWordSpace(value as number)" + :options="wordSpaceOptions.map(item => ({ + label: item + 'px', value: item + }))" + > + + + + + + + 文本框填充: + + + updateFill(value)" + /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/VideoStylePanel.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/VideoStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..e4df79a099a4e5cc8ead8eae2fba5dc203f860f0 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/VideoStylePanel.vue @@ -0,0 +1,106 @@ + + + 视频预览封面 + + setVideoPoster(files)"> + + + + + + + + + 重置封面 + + + + 自动播放: + + updateVideo({ autoplay: value })" + /> + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/ElementStylePanel/index.vue b/frontend/src/views/Editor/Toolbar/ElementStylePanel/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..01eba5d98681805b2a0ca1fb777c02c7fcda699a --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/ElementStylePanel/index.vue @@ -0,0 +1,40 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/MultiPositionPanel.vue b/frontend/src/views/Editor/Toolbar/MultiPositionPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..a1d98a1c19588fec287c8d8eb34efe96918dd2ec --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/MultiPositionPanel.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + 水平均匀分布 + 垂直均匀分布 + + + + + + 组合 + 取消组合 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/MultiStylePanel.vue b/frontend/src/views/Editor/Toolbar/MultiStylePanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..0bd983832305c88dd7a767dc90fcb4d2a5360858 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/MultiStylePanel.vue @@ -0,0 +1,275 @@ + + + + 填充颜色: + + + updateFill(value)" + /> + + + + + + + + + 边框样式: + + + + + + + + + + + + + 边框颜色: + + + updateOutline({ color: value })" + /> + + + + + + 边框粗细: + updateOutline({ width: value })" + style="width: 60%;" + /> + + + + + + updateFontStyle('fontname', value as string)" + :options="FONTS" + > + + + + + updateFontStyle('fontsize', value as string)" + :options="fontSizeOptions.map(item => ({ + label: item, value: item + }))" + > + + + + + + + + + updateFontStyle('color', value)" + /> + + + + + + + + updateFontStyle('backcolor', value)" + /> + + + + + + + + - + + updateFontStyle('align', value)" + > + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/SlideAnimationPanel.vue b/frontend/src/views/Editor/Toolbar/SlideAnimationPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..f8c20dbb18f313fd7368570311be3dfdec9ab002 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/SlideAnimationPanel.vue @@ -0,0 +1,250 @@ + + + + + + {{item.label}} + + + 应用到全部 + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/SlideDesignPanel/ThemeColorsSetting.vue b/frontend/src/views/Editor/Toolbar/SlideDesignPanel/ThemeColorsSetting.vue new file mode 100644 index 0000000000000000000000000000000000000000..e6d4c865787a0ff24c85864198aae03a9d36be55 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/SlideDesignPanel/ThemeColorsSetting.vue @@ -0,0 +1,84 @@ + + + 编辑主题色 + + + + 幻灯片主题色{{ index + 1 }}: + + + themeColors[index] = value" + /> + + + + + + + 确认 + + + + + + diff --git a/frontend/src/views/Editor/Toolbar/SlideDesignPanel/ThemeStylesExtract.vue b/frontend/src/views/Editor/Toolbar/SlideDesignPanel/ThemeStylesExtract.vue new file mode 100644 index 0000000000000000000000000000000000000000..643f4d1b3465b46f7c1c2d88ee04bf79f6847219 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/SlideDesignPanel/ThemeStylesExtract.vue @@ -0,0 +1,299 @@ + + + + + + 字体: + + + {{ fontMap[item] || item }} + + √ + 选择 + 应用到主题 + + + + + + 文字颜色: + + + {{ getHexColor(item) }} + + √ + 选择 + 应用到主题 + + + + + + 背景颜色: + + + {{ getHexColor(item) }} + + √ + 选择 + 应用到主题 + + + + + + 主题色:(点击色块排除不要的颜色) + + + + + + + + + + 将选中配置保存为主题 + + + + + + + diff --git a/frontend/src/views/Editor/Toolbar/SlideDesignPanel/index.vue b/frontend/src/views/Editor/Toolbar/SlideDesignPanel/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..4c75ceb4a38830707c4f13597467030c1df5b856 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/SlideDesignPanel/index.vue @@ -0,0 +1,595 @@ + + + 背景填充 + + updateBackgroundType(value as 'gradient' | 'image' | 'solid')" + :options="[ + { label: '纯色填充', value: 'solid' }, + { label: '图片填充', value: 'image' }, + { label: '渐变填充', value: 'gradient' }, + ]" + /> + + + + + updateBackground({ color })" + /> + + + + + updateImageBackground({ size: value as SlideBackgroundImageSize })" + v-else-if="background.type === 'image'" + :options="[ + { label: '缩放', value: 'contain' }, + { label: '拼贴', value: 'repeat' }, + { label: '缩放铺满', value: 'cover' }, + ]" + /> + + updateGradientBackground({ type: value as GradientType })" + v-else + :options="[ + { label: '线性渐变', value: 'linear' }, + { label: '径向渐变', value: 'radial' }, + ]" + /> + + + + uploadBackgroundImage(files)"> + + + + + + + + + + + updateGradientBackground({ colors: value })" + @update:index="index => currentGradientIndex = index" + /> + + + 当前色块: + + + updateGradientBackgroundColors(value)" + /> + + + + + + 渐变角度: + updateGradientBackground({ rotate: value as number })" + style="width: 60%;" + /> + + + + + 应用背景到全部 + + + + + + updateViewportRatio(value as number)" + :options="[ + { label: '宽屏 16 : 9', value: 0.5625 }, + { label: '宽屏 16 : 10', value: 0.625 }, + { label: '标准 4 : 3', value: 0.75 }, + { label: '纸张 A3 / A4', value: 0.70710678 }, + { label: '竖向 A3 / A4', value: 1.41421356 }, + ]" + /> + + + + 画布尺寸:{{ viewportSize }} × {{ toFixed(viewportSize * viewportRatio) }} + + + + + + 全局主题 + + 更多 + + + + + + 字体: + updateTheme({ fontName: value as string })" + :options="FONTS" + /> + + + 字体颜色: + + + updateTheme({ fontColor: value })" + /> + + + + + + 背景颜色: + + + updateTheme({ backgroundColor: value })" + /> + + + + + + 主题色: + + + + + + 边框样式: + + + + + + + + + + + + + 边框颜色: + + + updateTheme({ outline: { ...theme.outline, color: value } })" + /> + + + + + + 边框粗细: + updateTheme({ outline: { ...theme.outline, width: value } })" + style="width: 60%;" + /> + + + 水平阴影: + updateTheme({ shadow: { ...theme.shadow, h: value as number } })" + /> + + + 垂直阴影: + updateTheme({ shadow: { ...theme.shadow, v: value as number } })" + /> + + + 模糊距离: + updateTheme({ shadow: { ...theme.shadow, blur: value as number } })" + /> + + + 阴影颜色: + + + updateTheme({ shadow: { ...theme.shadow, color: value } })" + /> + + + + + + + + 应用主题到全部 + + + + 从幻灯片提取主题 + + + + + 预置主题 + + + + 文字 Aa + + + + + + 设置 + 设置并应用 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/SymbolPanel.vue b/frontend/src/views/Editor/Toolbar/SymbolPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..ca03eeb2415990fb4ffad871f038f8880f707e67 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/SymbolPanel.vue @@ -0,0 +1,77 @@ + + + + + + {{item}} + + + + + + + + diff --git a/frontend/src/views/Editor/Toolbar/common/ElementColorMask.vue b/frontend/src/views/Editor/Toolbar/common/ElementColorMask.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d1655ea316b0b628590e42c41ceffec8e04673a --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/ElementColorMask.vue @@ -0,0 +1,91 @@ + + + + 着色(蒙版): + + toggleColorMask(value)" + /> + + + + + 蒙版颜色: + + + updateColorMask(value)" + /> + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/ElementFilter.vue b/frontend/src/views/Editor/Toolbar/common/ElementFilter.vue new file mode 100644 index 0000000000000000000000000000000000000000..594274f30a54bda962ec0881ace078f9d97b74a2 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/ElementFilter.vue @@ -0,0 +1,196 @@ + + + + 启用滤镜: + + toggleFilters(value)" + /> + + + + + + + {{ item.label }} + + + + + {{filter.label}} + updateFilter(filter, value as number)" + /> + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/ElementFlip.vue b/frontend/src/views/Editor/Toolbar/common/ElementFlip.vue new file mode 100644 index 0000000000000000000000000000000000000000..191730dbf56cf61361696f4276976243b0ba5742 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/ElementFlip.vue @@ -0,0 +1,57 @@ + + + + 垂直翻转 + 水平翻转 + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/ElementOpacity.vue b/frontend/src/views/Editor/Toolbar/common/ElementOpacity.vue new file mode 100644 index 0000000000000000000000000000000000000000..219784e1498dfe003603d757263fc89124e33efd --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/ElementOpacity.vue @@ -0,0 +1,51 @@ + + + + 不透明度: + updateOpacity(value as number)" + style="width: 60%;" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/ElementOutline.vue b/frontend/src/views/Editor/Toolbar/common/ElementOutline.vue new file mode 100644 index 0000000000000000000000000000000000000000..ec8b337902fc54669c3eb4b889ee67b12a0c50e0 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/ElementOutline.vue @@ -0,0 +1,133 @@ + + + + 启用边框: + + toggleOutline(value)" + /> + + + + + 边框样式: + + + + + + + + + + + + + 边框颜色: + + + updateOutline({ color: value })" + /> + + + + + + 边框粗细: + updateOutline({ width: value })" + style="width: 60%;" + /> + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/ElementShadow.vue b/frontend/src/views/Editor/Toolbar/common/ElementShadow.vue new file mode 100644 index 0000000000000000000000000000000000000000..b8e2e7482c7ce1c2e0f6422a5979155c5d19ce4b --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/ElementShadow.vue @@ -0,0 +1,118 @@ + + + + 启用阴影: + + toggleShadow(value)" /> + + + + + 水平阴影: + updateShadow({ h: value as number })" + /> + + + 垂直阴影: + updateShadow({ v: value as number })" + /> + + + 模糊距离: + updateShadow({ blur: value as number })" + /> + + + 阴影颜色: + + + updateShadow({ color: value })" + /> + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/RichTextBase.vue b/frontend/src/views/Editor/Toolbar/common/RichTextBase.vue new file mode 100644 index 0000000000000000000000000000000000000000..97231bc24283d1d21690b6e5ba88eb8f8818c576 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/RichTextBase.vue @@ -0,0 +1,386 @@ + + + + emitRichTextCommand('fontname', value as string)" + :options="FONTS" + > + + + + + emitRichTextCommand('fontsize', value as string)" + :options="fontSizeOptions.map(item => ({ + label: item, value: item + }))" + > + + + + + + + + + + emitRichTextCommand('color', value)" + /> + + + + + + + + emitRichTextCommand('backcolor', value)" + /> + + + + + + + + - + + + + + + + + + + + A² + A₂ + + + + + + + + + + + + + 移除 + 确认 + + + + + + + + + emitRichTextCommand('align', value)" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 减小首行缩进 + + + + + + + + + + 增大首行缩进 + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/common/SVGLine.vue b/frontend/src/views/Editor/Toolbar/common/SVGLine.vue new file mode 100644 index 0000000000000000000000000000000000000000..0cec468fb2ee79c69cb2fb402dcc3ab1e7a784ed --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/common/SVGLine.vue @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/Toolbar/index.vue b/frontend/src/views/Editor/Toolbar/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..2a6eb74ae0970f73226f24ba5f9372d87070a293 --- /dev/null +++ b/frontend/src/views/Editor/Toolbar/index.vue @@ -0,0 +1,115 @@ + + + setToolbarState(key as ToolbarStates)" + /> + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Editor/index.vue b/frontend/src/views/Editor/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..91d37875fd0ea3163b7d75ae40e4c1b9d9b1a8bf --- /dev/null +++ b/frontend/src/views/Editor/index.vue @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000000000000000000000000000000000000..63eaf26696f058eb83c75ce309d558a6a913ad2a --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,147 @@ + + + + PPTist 登录 + + + 用户名 + + + + 密码 + + + + {{ loading ? '登录中...' : '登录' }} + + + + 测试账号: + 管理员: PS01 / admin_cybercity2025 + 用户: PS02 / cybercity2025 + 用户: PS03 / cybercity2025 + 用户: PS04 / cybercity2025 + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobileEditor/ElementToolbar.vue b/frontend/src/views/Mobile/MobileEditor/ElementToolbar.vue new file mode 100644 index 0000000000000000000000000000000000000000..bb71def24f51ee3a2519b87f1dc210facab18dd1 --- /dev/null +++ b/frontend/src/views/Mobile/MobileEditor/ElementToolbar.vue @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + - + + + + + emitRichTextCommand('align', value)" + > + + + + + + + + + 文字颜色: + + + + + + + + 填充色: + + + + + + + + + + + 复制 + 删除 + + + + + + 置顶 + 置底 + 上移 + 下移 + + + + + + 左对齐 + 水平居中 + 右对齐 + + + 上对齐 + 垂直居中 + 下对齐 + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobileEditor/Header.vue b/frontend/src/views/Mobile/MobileEditor/Header.vue new file mode 100644 index 0000000000000000000000000000000000000000..d1c45a3c50f80e81b2b56a869fa36dc05ac2048f --- /dev/null +++ b/frontend/src/views/Mobile/MobileEditor/Header.vue @@ -0,0 +1,50 @@ + + + + 撤销 + 重做 + + 退出编辑 + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobileEditor/MobileEditableElement.vue b/frontend/src/views/Mobile/MobileEditor/MobileEditableElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..9abc126a183c0f7d6b8bb289e0b9089933cabaf2 --- /dev/null +++ b/frontend/src/views/Mobile/MobileEditor/MobileEditableElement.vue @@ -0,0 +1,51 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobileEditor/MobileOperate.vue b/frontend/src/views/Mobile/MobileEditor/MobileOperate.vue new file mode 100644 index 0000000000000000000000000000000000000000..f2433ee603f2432838dd03d10906e492059550d1 --- /dev/null +++ b/frontend/src/views/Mobile/MobileEditor/MobileOperate.vue @@ -0,0 +1,79 @@ + + + + + scaleElement($event, elementInfo, point.direction)" + /> + rotateElement($event, elementInfo as CanRotatePPTElement)" + /> + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobileEditor/SlideToolbar.vue b/frontend/src/views/Mobile/MobileEditor/SlideToolbar.vue new file mode 100644 index 0000000000000000000000000000000000000000..9c06c5a1776e2a3552a96236b41c1599c2db81a1 --- /dev/null +++ b/frontend/src/views/Mobile/MobileEditor/SlideToolbar.vue @@ -0,0 +1,143 @@ + + + + handleInputMark($event)" + > + + + + 新幻灯片 + 复制 + 删除 + + + 文字 + + insertImageElement(files)"> + 图片 + + + 矩形 + 圆形 + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobileEditor/index.vue b/frontend/src/views/Mobile/MobileEditor/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..994198956064da2ad4ea1b33f4c2523097e6fda0 --- /dev/null +++ b/frontend/src/views/Mobile/MobileEditor/index.vue @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobilePlayer.vue b/frontend/src/views/Mobile/MobilePlayer.vue new file mode 100644 index 0000000000000000000000000000000000000000..7445d9d22c1d602f5b0bca696f435cd06d10c280 --- /dev/null +++ b/frontend/src/views/Mobile/MobilePlayer.vue @@ -0,0 +1,256 @@ + + + touchStartListener($event)" + @touchend="$event => touchEndListener($event)" + > + + + + + + + + + + 退出播放 + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/MobilePreview.vue b/frontend/src/views/Mobile/MobilePreview.vue new file mode 100644 index 0000000000000000000000000000000000000000..179f41287f77209fd2712fc10b9ee8cacad9f740 --- /dev/null +++ b/frontend/src/views/Mobile/MobilePreview.vue @@ -0,0 +1,89 @@ + + + + + + + + + 编辑 + + 播放 + + + + + + + diff --git a/frontend/src/views/Mobile/MobileThumbnails.vue b/frontend/src/views/Mobile/MobileThumbnails.vue new file mode 100644 index 0000000000000000000000000000000000000000..c99a91cc119e104c20182a6e326babcb100f2b71 --- /dev/null +++ b/frontend/src/views/Mobile/MobileThumbnails.vue @@ -0,0 +1,101 @@ + + + + + + {{ index + 1 }} + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Mobile/index.vue b/frontend/src/views/Mobile/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..a5138d4772da9121d71d962a8a08217d2840fde5 --- /dev/null +++ b/frontend/src/views/Mobile/index.vue @@ -0,0 +1,36 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/PublicViewer.vue b/frontend/src/views/PublicViewer.vue new file mode 100644 index 0000000000000000000000000000000000000000..b92dc2f97e533b4e26d228c6c362303da70eacee --- /dev/null +++ b/frontend/src/views/PublicViewer.vue @@ -0,0 +1,497 @@ + + + + + + + {{ error }} + 请检查分享链接是否正确或联系分享者 + + + + + + + {{ presentationTitle }} + + + {{ isFullscreen ? '退出全屏' : '全屏查看' }} + + + 开始演示 + + + + + + + + {{ index + 1 }} + + + + + + + + + + + 上一页 + + + {{ currentSlideIndex + 1 }} / {{ slides.length }} + + + 下一页 + + + + + + + + + + + + + + + + 退出演示 + + ← + {{ currentSlideIndex + 1 }} / {{ slides.length }} + → + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/BaseView.vue b/frontend/src/views/Screen/BaseView.vue new file mode 100644 index 0000000000000000000000000000000000000000..d3af48d421b9d716386ecc330cd2e9692855b0c9 --- /dev/null +++ b/frontend/src/views/Screen/BaseView.vue @@ -0,0 +1,285 @@ + + + mousewheelListener($event)" + @touchstart="$event => touchStartListener($event)" + @touchend="$event => touchEndListener($event)" + v-contextmenu="contextmenus" + /> + + + + + + + + + + + + + + + 幻灯片 {{slideIndex + 1}} / {{slides.length}} + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/BottomThumbnails.vue b/frontend/src/views/Screen/BottomThumbnails.vue new file mode 100644 index 0000000000000000000000000000000000000000..8b0f786341a771627f50e9fa1c466e1e4afa3dd0 --- /dev/null +++ b/frontend/src/views/Screen/BottomThumbnails.vue @@ -0,0 +1,109 @@ + + + handleMousewheelThumbnails($event)" + > + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/CountdownTimer.vue b/frontend/src/views/Screen/CountdownTimer.vue new file mode 100644 index 0000000000000000000000000000000000000000..5aa94ac06dbcaaab6d0486261e60c6ca3f5496f0 --- /dev/null +++ b/frontend/src/views/Screen/CountdownTimer.vue @@ -0,0 +1,204 @@ + + + + {{ inTiming ? '暂停' : '开始'}} + 重置 + 倒计时 + + + + changeTime($event, 'minute')" + @keydown.stop + @keydown.enter.stop="$event => changeTime($event, 'minute')" + > + + : + + changeTime($event, 'second')" + @keydown.stop + @keydown.enter.stop="$event => changeTime($event, 'second')" + > + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/PresenterView.vue b/frontend/src/views/Screen/PresenterView.vue new file mode 100644 index 0000000000000000000000000000000000000000..69c2928fdbff1177bb12a0167a0f0e6d96d10ade --- /dev/null +++ b/frontend/src/views/Screen/PresenterView.vue @@ -0,0 +1,340 @@ + + + + 普通视图 + 画笔 + 激光笔 + 计时器 + fullscreenState ? manualExitFullscreen() : enterFullscreen()"> + + + {{ fullscreenState ? '退出全屏' : '全屏' }} + + + 结束放映 + + + + + mousewheelListener($event)" + @touchstart="$event => touchStartListener($event)" + @touchend="$event => touchEndListener($event)" + v-contextmenu="contextmenus" + /> + + + + + handleMousewheelThumbnails($event)" + > + + + + + + + + + 演讲者备注 + P {{slideIndex + 1}} / {{slides.length}} + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/ScreenElement.vue b/frontend/src/views/Screen/ScreenElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..0047caef85580374201f22baeb9d4de1ea72b6f1 --- /dev/null +++ b/frontend/src/views/Screen/ScreenElement.vue @@ -0,0 +1,109 @@ + + openLink($event)" + > + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/ScreenSlide.vue b/frontend/src/views/Screen/ScreenSlide.vue new file mode 100644 index 0000000000000000000000000000000000000000..f377ac8479c496bce83297f4b7bea82d63c961e0 --- /dev/null +++ b/frontend/src/views/Screen/ScreenSlide.vue @@ -0,0 +1,64 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/ScreenSlideList.vue b/frontend/src/views/Screen/ScreenSlideList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b071d532f2d75fba66fcd86b811cfbd8af0338e6 --- /dev/null +++ b/frontend/src/views/Screen/ScreenSlideList.vue @@ -0,0 +1,202 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/SlideThumbnails.vue b/frontend/src/views/Screen/SlideThumbnails.vue new file mode 100644 index 0000000000000000000000000000000000000000..fa7c8cc2b4dd62025f9a0580767e2640293b5317 --- /dev/null +++ b/frontend/src/views/Screen/SlideThumbnails.vue @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/WritingBoardTool.vue b/frontend/src/views/Screen/WritingBoardTool.vue new file mode 100644 index 0000000000000000000000000000000000000000..b13c9e9997076773deb9a26087afeade1518f82b --- /dev/null +++ b/frontend/src/views/Screen/WritingBoardTool.vue @@ -0,0 +1,299 @@ + + + + + + + + + + + + + 墨迹粗细: + + + + + + + + + + + + + + + + + 墨迹粗细: + + + + + + + + + + + 墨迹粗细: + + + + + + + + + + + 橡皮大小: + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Screen/hooks/useExecPlay.ts b/frontend/src/views/Screen/hooks/useExecPlay.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed7c28c86f26418512225bdc5ce32c58a764d9cd --- /dev/null +++ b/frontend/src/views/Screen/hooks/useExecPlay.ts @@ -0,0 +1,263 @@ +import { onMounted, onUnmounted, ref } from 'vue' +import { throttle } from 'lodash' +import { storeToRefs } from 'pinia' +import { useSlidesStore } from '@/store' +import { KEYS } from '@/configs/hotkey' +import { ANIMATION_CLASS_PREFIX } from '@/configs/animation' +import message from '@/utils/message' + +export default () => { + const slidesStore = useSlidesStore() + const { slides, slideIndex, formatedAnimations } = storeToRefs(slidesStore) + + // 当前页的元素动画执行到的位置 + const animationIndex = ref(0) + + // 动画执行状态 + const inAnimation = ref(false) + + // 最小已播放页面索引 + const playedSlidesMinIndex = ref(slideIndex.value) + + // 执行元素动画 + const runAnimation = () => { + // 正在执行动画时,禁止其他新的动画开始 + if (inAnimation.value) return + + const { animations, autoNext } = formatedAnimations.value[animationIndex.value] + animationIndex.value += 1 + + // 标记开始执行动画 + inAnimation.value = true + + let endAnimationCount = 0 + + // 依次执行该位置中的全部动画 + for (const animation of animations) { + const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`) + if (!elRef) { + endAnimationCount += 1 + continue + } + + const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}` + + // 执行动画前先清除原有的动画状态(如果有) + elRef.style.removeProperty('--animate-duration') + for (const classname of elRef.classList) { + if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`) + } + + // 执行动画 + elRef.style.setProperty('--animate-duration', `${animation.duration}ms`) + elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`) + + // 执行动画结束,将“退场”以外的动画状态清除 + const handleAnimationEnd = () => { + if (animation.type !== 'out') { + elRef.style.removeProperty('--animate-duration') + elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`) + } + + // 判断该位置上的全部动画都已经结束后,标记动画执行完成,并尝试继续向下执行(如果有需要) + endAnimationCount += 1 + if (endAnimationCount === animations.length) { + inAnimation.value = false + if (autoNext) runAnimation() + } + } + elRef.addEventListener('animationend', handleAnimationEnd, { once: true }) + } + } + + onMounted(() => { + const firstAnimations = formatedAnimations.value[0] + if (firstAnimations && firstAnimations.animations.length) { + const autoExecFirstAnimations = firstAnimations.animations.every(item => item.trigger === 'auto' || item.trigger === 'meantime') + if (autoExecFirstAnimations) runAnimation() + } + }) + + // 撤销元素动画,除了将索引前移外,还需要清除动画状态 + const revokeAnimation = () => { + animationIndex.value -= 1 + const { animations } = formatedAnimations.value[animationIndex.value] + + for (const animation of animations) { + const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`) + if (!elRef) continue + + elRef.style.removeProperty('--animate-duration') + for (const classname of elRef.classList) { + if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`) + } + } + + // 如果撤销时该位置有且仅有强调动画,则继续执行一次撤销 + if (animations.every(item => item.type === 'attention')) execPrev() + } + + // 关闭自动播放 + const autoPlayTimer = ref(0) + const closeAutoPlay = () => { + if (autoPlayTimer.value) { + clearInterval(autoPlayTimer.value) + autoPlayTimer.value = 0 + } + } + onUnmounted(closeAutoPlay) + + // 循环放映 + const loopPlay = ref(false) + const setLoopPlay = (loop: boolean) => { + loopPlay.value = loop + } + + const throttleMassage = throttle(function(msg) { + message.success(msg) + }, 1000, { leading: true, trailing: false }) + + // 向上/向下播放 + // 遇到元素动画时,优先执行动画播放,无动画则执行翻页 + // 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画 + // 撤回到上一页时,若该页从未播放过(意味着不存在动画状态),需要将动画索引置为最小值(初始状态),否则置为最大值(最终状态) + const execPrev = () => { + if (formatedAnimations.value.length && animationIndex.value > 0) { + revokeAnimation() + } + else if (slideIndex.value > 0) { + slidesStore.updateSlideIndex(slideIndex.value - 1) + if (slideIndex.value < playedSlidesMinIndex.value) { + animationIndex.value = 0 + playedSlidesMinIndex.value = slideIndex.value + } + else animationIndex.value = formatedAnimations.value.length + } + else { + if (loopPlay.value) turnSlideToIndex(slides.value.length - 1) + else throttleMassage('已经是第一页了') + } + inAnimation.value = false + } + const execNext = () => { + if (formatedAnimations.value.length && animationIndex.value < formatedAnimations.value.length) { + runAnimation() + } + else if (slideIndex.value < slides.value.length - 1) { + slidesStore.updateSlideIndex(slideIndex.value + 1) + animationIndex.value = 0 + inAnimation.value = false + } + else { + if (loopPlay.value) turnSlideToIndex(0) + else { + throttleMassage('已经是最后一页了') + closeAutoPlay() + } + inAnimation.value = false + } + } + + // 自动播放 + const autoPlayInterval = ref(2500) + const autoPlay = () => { + closeAutoPlay() + message.success('开始自动放映') + autoPlayTimer.value = setInterval(execNext, autoPlayInterval.value) + } + + const setAutoPlayInterval = (interval: number) => { + closeAutoPlay() + autoPlayInterval.value = interval + autoPlay() + } + + // 鼠标滚动翻页 + const mousewheelListener = throttle(function(e: WheelEvent) { + if (e.deltaY < 0) execPrev() + else if (e.deltaY > 0) execNext() + }, 500, { leading: true, trailing: false }) + + // 触摸屏上下滑动翻页 + const touchInfo = ref<{ x: number; y: number; } | null>(null) + + const touchStartListener = (e: TouchEvent) => { + touchInfo.value = { + x: e.changedTouches[0].pageX, + y: e.changedTouches[0].pageY, + } + } + const touchEndListener = (e: TouchEvent) => { + if (!touchInfo.value) return + + const offsetX = Math.abs(touchInfo.value.x - e.changedTouches[0].pageX) + const offsetY = e.changedTouches[0].pageY - touchInfo.value.y + + if ( Math.abs(offsetY) > offsetX && Math.abs(offsetY) > 50 ) { + touchInfo.value = null + + if (offsetY > 0) execPrev() + else execNext() + } + } + + // 快捷键翻页 + const keydownListener = (e: KeyboardEvent) => { + const key = e.key.toUpperCase() + + if (key === KEYS.UP || key === KEYS.LEFT || key === KEYS.PAGEUP) execPrev() + else if ( + key === KEYS.DOWN || + key === KEYS.RIGHT || + key === KEYS.SPACE || + key === KEYS.ENTER || + key === KEYS.PAGEDOWN + ) execNext() + } + + onMounted(() => document.addEventListener('keydown', keydownListener)) + onUnmounted(() => document.removeEventListener('keydown', keydownListener)) + + // 切换到上一张/上一张幻灯片(无视元素的入场动画) + const turnPrevSlide = () => { + slidesStore.updateSlideIndex(slideIndex.value - 1) + animationIndex.value = 0 + } + const turnNextSlide = () => { + slidesStore.updateSlideIndex(slideIndex.value + 1) + animationIndex.value = 0 + } + + // 切换幻灯片到指定的页面 + const turnSlideToIndex = (index: number) => { + slidesStore.updateSlideIndex(index) + animationIndex.value = 0 + } + const turnSlideToId = (id: string) => { + const index = slides.value.findIndex(slide => slide.id === id) + if (index !== -1) { + slidesStore.updateSlideIndex(index) + animationIndex.value = 0 + } + } + + return { + autoPlayTimer, + autoPlayInterval, + setAutoPlayInterval, + autoPlay, + closeAutoPlay, + loopPlay, + setLoopPlay, + mousewheelListener, + touchStartListener, + touchEndListener, + turnPrevSlide, + turnNextSlide, + turnSlideToIndex, + turnSlideToId, + execPrev, + execNext, + animationIndex, + } +} diff --git a/frontend/src/views/Screen/hooks/useFullscreen.ts b/frontend/src/views/Screen/hooks/useFullscreen.ts new file mode 100644 index 0000000000000000000000000000000000000000..038fd95f06da8bcf4edb3c53b948e6db4c64c203 --- /dev/null +++ b/frontend/src/views/Screen/hooks/useFullscreen.ts @@ -0,0 +1,38 @@ +import { onMounted, onUnmounted, ref } from 'vue' +import { isFullscreen, exitFullscreen } from '@/utils/fullscreen' +import useScreening from '@/hooks/useScreening' + +export default () => { + const fullscreenState = ref(true) + const escExit = ref(true) + + const { exitScreening } = useScreening() + + const handleFullscreenChange = () => { + fullscreenState.value = isFullscreen() + if (!fullscreenState.value && escExit.value) exitScreening() + + escExit.value = true + } + + onMounted(() => { + fullscreenState.value = isFullscreen() + document.addEventListener('fullscreenchange', handleFullscreenChange) + document.addEventListener('webkitfullscreenchange', handleFullscreenChange) // Safari 兼容 + }) + onUnmounted(() => { + document.removeEventListener('fullscreenchange', handleFullscreenChange) + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange) + }) + + const manualExitFullscreen = () => { + if (!fullscreenState.value) return + escExit.value = false + exitFullscreen() + } + + return { + fullscreenState, + manualExitFullscreen, + } +} \ No newline at end of file diff --git a/frontend/src/views/Screen/hooks/useSlideSize.ts b/frontend/src/views/Screen/hooks/useSlideSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..caef344e51806a859e87718513dd9937bf2ccbf9 --- /dev/null +++ b/frontend/src/views/Screen/hooks/useSlideSize.ts @@ -0,0 +1,47 @@ +import { onMounted, onUnmounted, ref, type Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useSlidesStore } from '@/store' + +export default (wrapRef?: Ref) => { + const slidesStore = useSlidesStore() + const { viewportRatio } = storeToRefs(slidesStore) + + const slideWidth = ref(0) + const slideHeight = ref(0) + + // 计算和更新幻灯片内容的尺寸(按比例自适应屏幕) + const setSlideContentSize = () => { + const slideWrapRef = wrapRef?.value || document.body + const winWidth = slideWrapRef.clientWidth + const winHeight = slideWrapRef.clientHeight + let width, height + + if (winHeight / winWidth === viewportRatio.value) { + width = winWidth + height = winHeight + } + else if (winHeight / winWidth > viewportRatio.value) { + width = winWidth + height = winWidth * viewportRatio.value + } + else { + width = winHeight / viewportRatio.value + height = winHeight + } + slideWidth.value = width + slideHeight.value = height + } + + onMounted(() => { + setSlideContentSize() + window.addEventListener('resize', setSlideContentSize) + }) + onUnmounted(() => { + window.removeEventListener('resize', setSlideContentSize) + }) + + return { + slideWidth, + slideHeight, + } +} \ No newline at end of file diff --git a/frontend/src/views/Screen/hooks/useSlidesWithTurningMode.ts b/frontend/src/views/Screen/hooks/useSlidesWithTurningMode.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e8aa698defc7fe8f78f6b3f1c044f83481aea10 --- /dev/null +++ b/frontend/src/views/Screen/hooks/useSlidesWithTurningMode.ts @@ -0,0 +1,27 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useSlidesStore } from '@/store' +import { SLIDE_ANIMATIONS } from '@/configs/animation' + +export default () => { + const { slides } = storeToRefs(useSlidesStore()) + + const slidesWithTurningMode = computed(() => { + return slides.value.map(slide => { + let turningMode = slide.turningMode + if (!turningMode) turningMode = 'slideY' + if (turningMode === 'random') { + const turningModeKeys = SLIDE_ANIMATIONS.filter(item => !['random', 'no'].includes(item.value)).map(item => item.value) + turningMode = turningModeKeys[Math.floor(Math.random() * turningModeKeys.length)] + } + return { + ...slide, + turningMode, + } + }) + }) + + return { + slidesWithTurningMode, + } +} \ No newline at end of file diff --git a/frontend/src/views/Screen/index.vue b/frontend/src/views/Screen/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..b965d43853ec507d2257fe35de3a408c548070b8 --- /dev/null +++ b/frontend/src/views/Screen/index.vue @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/ThumbnailSlide/ThumbnailElement.vue b/frontend/src/views/components/ThumbnailSlide/ThumbnailElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..c939b5ee8987d704a01996d059c910a5c7c88725 --- /dev/null +++ b/frontend/src/views/components/ThumbnailSlide/ThumbnailElement.vue @@ -0,0 +1,50 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/ThumbnailSlide/index.vue b/frontend/src/views/components/ThumbnailSlide/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..78294c6fc28bafdfacba38b6ccc3d5ed6eac96d6 --- /dev/null +++ b/frontend/src/views/components/ThumbnailSlide/index.vue @@ -0,0 +1,78 @@ + + + + + + + 加载中 ... + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/AudioElement/AudioPlayer.vue b/frontend/src/views/components/element/AudioElement/AudioPlayer.vue new file mode 100644 index 0000000000000000000000000000000000000000..c64a16bf133de30d82714450ebcee833ac7f878c --- /dev/null +++ b/frontend/src/views/components/element/AudioElement/AudioPlayer.vue @@ -0,0 +1,503 @@ + + + + + + + + + + + + + + + + + + + + + handleClickVolumeBar($event)" + > + + + + + + + + + + + {{ptime}} / {{dtime}} + + + handleMousemovePlayBar($event)" + @mouseenter="playBarTimeVisible = true" + @mouseleave="playBarTimeVisible = false" + > + {{playBarTime}} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/AudioElement/BaseAudioElement.vue b/frontend/src/views/components/element/AudioElement/BaseAudioElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..d3c785235506f0bf2a475e71b51da10f92598beb --- /dev/null +++ b/frontend/src/views/components/element/AudioElement/BaseAudioElement.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/AudioElement/ScreenAudioElement.vue b/frontend/src/views/components/element/AudioElement/ScreenAudioElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..ee1c9522caf0c77cb1585422556640db6e193a3d --- /dev/null +++ b/frontend/src/views/components/element/AudioElement/ScreenAudioElement.vue @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/AudioElement/index.vue b/frontend/src/views/components/element/AudioElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..0969579098f5e9e1fb2bf9d3167468d4183b2d8d --- /dev/null +++ b/frontend/src/views/components/element/AudioElement/index.vue @@ -0,0 +1,120 @@ + + + + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + > + + + + + + + + + + diff --git a/frontend/src/views/components/element/ChartElement/BaseChartElement.vue b/frontend/src/views/components/element/ChartElement/BaseChartElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..a63760c4d0f898213eabfd684cde84375a98a68d --- /dev/null +++ b/frontend/src/views/components/element/ChartElement/BaseChartElement.vue @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ChartElement/Chart.vue b/frontend/src/views/components/element/ChartElement/Chart.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5557120ab3b7642ac7f93864cf2a00e8a98c1b8 --- /dev/null +++ b/frontend/src/views/components/element/ChartElement/Chart.vue @@ -0,0 +1,83 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ChartElement/chartOption.ts b/frontend/src/views/components/element/ChartElement/chartOption.ts new file mode 100644 index 0000000000000000000000000000000000000000..53d3c3e24b0a1feecb4d3c6743172b4f5fac6a89 --- /dev/null +++ b/frontend/src/views/components/element/ChartElement/chartOption.ts @@ -0,0 +1,293 @@ +import type { ComposeOption } from 'echarts/core' +import type { + BarSeriesOption, + LineSeriesOption, + PieSeriesOption, + ScatterSeriesOption, + RadarSeriesOption, +} from 'echarts/charts' +import type { ChartData, ChartType } from '@/types/slides' + +type EChartOption = ComposeOption + +export interface ChartOptionPayload { + type: ChartType + data: ChartData + themeColors: string[] + textColor?: string + lineSmooth?: boolean + stack?: boolean +} + +export const getChartOption = ({ + type, + data, + themeColors, + textColor, + lineSmooth, + stack, +}: ChartOptionPayload): EChartOption | null => { + if (type === 'bar') { + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: data.series.length > 1 ? { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + } : undefined, + xAxis: { + type: 'category', + data: data.labels, + }, + yAxis: { + type: 'value', + }, + series: data.series.map((item, index) => { + const seriesItem: BarSeriesOption = { + data: item, + name: data.legends[index], + type: 'bar', + label: { + show: true, + }, + } + if (stack) seriesItem.stack = 'A' + return seriesItem + }), + } + } + if (type === 'column') { + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: data.series.length > 1 ? { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + } : undefined, + yAxis: { + type: 'category', + data: data.labels, + }, + xAxis: { + type: 'value', + }, + series: data.series.map((item, index) => { + const seriesItem: BarSeriesOption = { + data: item, + name: data.legends[index], + type: 'bar', + label: { + show: true, + }, + } + if (stack) seriesItem.stack = 'A' + return seriesItem + }), + } + } + if (type === 'line') { + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: data.series.length > 1 ? { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + } : undefined, + xAxis: { + type: 'category', + data: data.labels, + }, + yAxis: { + type: 'value', + }, + series: data.series.map((item, index) => { + const seriesItem: LineSeriesOption = { + data: item, + name: data.legends[index], + type: 'line', + smooth: lineSmooth, + label: { + show: true, + }, + } + if (stack) seriesItem.stack = 'A' + return seriesItem + }), + } + } + if (type === 'pie') { + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + }, + series: [ + { + data: data.series[0].map((item, index) => ({ value: item, name: data.labels[index] })), + label: textColor ? { + color: textColor, + } : {}, + type: 'pie', + radius: '70%', + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + label: { + show: true, + fontSize: 14, + fontWeight: 'bold' + }, + }, + } + ], + } + } + if (type === 'ring') { + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + }, + series: [ + { + data: data.series[0].map((item, index) => ({ value: item, name: data.labels[index] })), + label: textColor ? { + color: textColor, + } : {}, + type: 'pie', + radius: ['40%', '70%'], + padAngle: 1, + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 4, + }, + emphasis: { + label: { + show: true, + fontSize: 14, + fontWeight: 'bold' + }, + }, + } + ], + } + } + if (type === 'area') { + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: data.series.length > 1 ? { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + } : undefined, + xAxis: { + type: 'category', + boundaryGap: false, + data: data.labels, + }, + yAxis: { + type: 'value', + }, + series: data.series.map((item, index) => { + const seriesItem: LineSeriesOption = { + data: item, + name: data.legends[index], + type: 'line', + areaStyle: {}, + label: { + show: true, + }, + } + if (stack) seriesItem.stack = 'A' + return seriesItem + }), + } + } + if (type === 'radar') { + // indicator 中不设置max时显示异常,设置max后控制台警告,无解,等EChart官方修复此bug + // const values: number[] = [] + // for (const item of data.series) { + // values.push(...item) + // } + // const max = Math.max(...values) + + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + legend: data.series.length > 1 ? { + top: 'bottom', + textStyle: textColor ? { + color: textColor, + } : {}, + } : undefined, + radar: { + indicator: data.labels.map(item => ({ name: item })), + }, + series: [ + { + data: data.series.map((item, index) => ({ value: item, name: data.legends[index] })), + type: 'radar', + }, + ], + } + } + if (type === 'scatter') { + const formatedData = [] + for (let i = 0; i < data.series[0].length; i++) { + const x = data.series[0][i] + const y = data.series[1] ? data.series[1][i] : x + formatedData.push([x, y]) + } + + return { + color: themeColors, + textStyle: textColor ? { + color: textColor, + } : {}, + xAxis: {}, + yAxis: {}, + series: [ + { + symbolSize: 12, + data: formatedData, + type: 'scatter', + } + ], + } + } + + return null +} \ No newline at end of file diff --git a/frontend/src/views/components/element/ChartElement/index.vue b/frontend/src/views/components/element/ChartElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a05c279c0abc73e7e798ab71e0133d4119b8c49 --- /dev/null +++ b/frontend/src/views/components/element/ChartElement/index.vue @@ -0,0 +1,88 @@ + + + + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + @dblclick="openDataEditor()" + > + + + + + + + + + + diff --git a/frontend/src/views/components/element/ElementOutline.vue b/frontend/src/views/components/element/ElementOutline.vue new file mode 100644 index 0000000000000000000000000000000000000000..569bc0d312c0a015ed4c9707ec23805b0fe08ebe --- /dev/null +++ b/frontend/src/views/components/element/ElementOutline.vue @@ -0,0 +1,48 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/BaseImageElement.vue b/frontend/src/views/components/element/ImageElement/BaseImageElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ffb35e55c7cf1e965b2419f3f39afc6e1078a88 --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/BaseImageElement.vue @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/ImageElement/ImageClipHandler.vue b/frontend/src/views/components/element/ImageElement/ImageClipHandler.vue new file mode 100644 index 0000000000000000000000000000000000000000..d8674221d270dbbd47df47989c9070311edb650d --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/ImageClipHandler.vue @@ -0,0 +1,647 @@ + + + + + + + + + moveClipRange($event)" + > + scaleClipRange($event, point)" + > + + + + + scaleClipRange($event, point)" + > + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/ImageOutline/ImageEllipseOutline.vue b/frontend/src/views/components/element/ImageElement/ImageOutline/ImageEllipseOutline.vue new file mode 100644 index 0000000000000000000000000000000000000000..db432676e1d7a9444a0da36cf270f867277b18ec --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/ImageOutline/ImageEllipseOutline.vue @@ -0,0 +1,51 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/ImageOutline/ImagePolygonOutline.vue b/frontend/src/views/components/element/ImageElement/ImageOutline/ImagePolygonOutline.vue new file mode 100644 index 0000000000000000000000000000000000000000..c071b2823d925952d1d0f1eca60abc18b342bb07 --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/ImageOutline/ImagePolygonOutline.vue @@ -0,0 +1,49 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/ImageOutline/ImageRectOutline.vue b/frontend/src/views/components/element/ImageElement/ImageOutline/ImageRectOutline.vue new file mode 100644 index 0000000000000000000000000000000000000000..e29b6baf91bab60e38d033b95481f4048943465d --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/ImageOutline/ImageRectOutline.vue @@ -0,0 +1,54 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/ImageOutline/index.vue b/frontend/src/views/components/element/ImageElement/ImageOutline/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..7c41e8d501e33777516b82f67aae710d9525a0ec --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/ImageOutline/index.vue @@ -0,0 +1,41 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/index.vue b/frontend/src/views/components/element/ImageElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..a43551589e697998d7ec17a3b0be4c02b6ba3983 --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/index.vue @@ -0,0 +1,193 @@ + + + + handleClip(range)" + /> + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + > + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/ImageElement/useClipImage.ts b/frontend/src/views/components/element/ImageElement/useClipImage.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1136788775ea3852ca7a44f598f6c30a87eac28 --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/useClipImage.ts @@ -0,0 +1,53 @@ +import { computed, type Ref } from 'vue' +import { CLIPPATHS, ClipPathTypes } from '@/configs/imageClip' +import type { PPTImageElement } from '@/types/slides' + +export default (element: Ref) => { + const clipShape = computed(() => { + let _clipShape = CLIPPATHS.rect + + if (element.value.clip) { + const shape = element.value.clip.shape || ClipPathTypes.RECT + _clipShape = CLIPPATHS[shape] + } + if (_clipShape.radius !== undefined && element.value.radius) { + _clipShape = { + ..._clipShape, + radius: `${element.value.radius}px`, + style: `inset(0 round ${element.value.radius}px)`, + } + } + + return _clipShape + }) + + const imgPosition = computed(() => { + if (!element.value.clip) { + return { + top: '0', + left: '0', + width: '100%', + height: '100%', + } + } + + const [start, end] = element.value.clip.range + + const widthScale = (end[0] - start[0]) / 100 + const heightScale = (end[1] - start[1]) / 100 + const left = start[0] / widthScale + const top = start[1] / heightScale + + return { + left: -left + '%', + top: -top + '%', + width: 100 / widthScale + '%', + height: 100 / heightScale + '%', + } + }) + + return { + clipShape, + imgPosition, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/ImageElement/useFilter.ts b/frontend/src/views/components/element/ImageElement/useFilter.ts new file mode 100644 index 0000000000000000000000000000000000000000..026c6ed24d5cbcebd117a4802f5f2cac055bea04 --- /dev/null +++ b/frontend/src/views/components/element/ImageElement/useFilter.ts @@ -0,0 +1,18 @@ +import { computed, type Ref } from 'vue' +import type { ImageElementFilters, ImageElementFilterKeys } from '@/types/slides' + +export default (filters: Ref) => { + const filter = computed(() => { + if (!filters.value) return '' + let filter = '' + const keys = Object.keys(filters.value) as ImageElementFilterKeys[] + for (const key of keys) { + filter += `${key}(${filters.value[key]}) ` + } + return filter + }) + + return { + filter, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/LatexElement/BaseLatexElement.vue b/frontend/src/views/components/element/LatexElement/BaseLatexElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..aff60f4861fb94271ce9baeaadd09e0ae59d8728 --- /dev/null +++ b/frontend/src/views/components/element/LatexElement/BaseLatexElement.vue @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/LatexElement/index.vue b/frontend/src/views/components/element/LatexElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..282701f9ced4259b7501066d89103fc4c7c9163c --- /dev/null +++ b/frontend/src/views/components/element/LatexElement/index.vue @@ -0,0 +1,90 @@ + + + + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + @dblclick="openLatexEditor()" + > + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/LineElement/BaseLineElement.vue b/frontend/src/views/components/element/LineElement/BaseLineElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..18e4bc4e9f0547516a5410a9cf2040ab8ee263a8 --- /dev/null +++ b/frontend/src/views/components/element/LineElement/BaseLineElement.vue @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/LineElement/LinePointMarker.vue b/frontend/src/views/components/element/LineElement/LinePointMarker.vue new file mode 100644 index 0000000000000000000000000000000000000000..7e4acc20bd6d0b8232a907ee776361339b03ca99 --- /dev/null +++ b/frontend/src/views/components/element/LineElement/LinePointMarker.vue @@ -0,0 +1,45 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/LineElement/index.vue b/frontend/src/views/components/element/LineElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..5372bd8c14250fcb0cb7c074801afb90cabe2001 --- /dev/null +++ b/frontend/src/views/components/element/LineElement/index.vue @@ -0,0 +1,134 @@ + + + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + > + + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/ProsemirrorEditor.vue b/frontend/src/views/components/element/ProsemirrorEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..d34d6ad9cf4fb4f83486c5c4e1c1edc251fc6eea --- /dev/null +++ b/frontend/src/views/components/element/ProsemirrorEditor.vue @@ -0,0 +1,323 @@ + + emit('mousedown', $event)" + > + + + + + diff --git a/frontend/src/views/components/element/ShapeElement/BaseShapeElement.vue b/frontend/src/views/components/element/ShapeElement/BaseShapeElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..f0c0b2a4b7215fad0ef08c976a0bb097a7313e46 --- /dev/null +++ b/frontend/src/views/components/element/ShapeElement/BaseShapeElement.vue @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/ShapeElement/GradientDefs.vue b/frontend/src/views/components/element/ShapeElement/GradientDefs.vue new file mode 100644 index 0000000000000000000000000000000000000000..212d1fab53b996e49675d9f1cb4b78f8f216d8dc --- /dev/null +++ b/frontend/src/views/components/element/ShapeElement/GradientDefs.vue @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ShapeElement/PatternDefs.vue b/frontend/src/views/components/element/ShapeElement/PatternDefs.vue new file mode 100644 index 0000000000000000000000000000000000000000..85a6426babde7bb0a19cf934bf22461adad41860 --- /dev/null +++ b/frontend/src/views/components/element/ShapeElement/PatternDefs.vue @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/ShapeElement/index.vue b/frontend/src/views/components/element/ShapeElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..e45edba4f16f2e26d63fe518f6d1194f162bc42c --- /dev/null +++ b/frontend/src/views/components/element/ShapeElement/index.vue @@ -0,0 +1,258 @@ + + + + handleSelectElement($event)" + @mouseup="execFormatPainter()" + @touchstart="$event => handleSelectElement($event)" + @dblclick="startEdit()" + > + + + + + + + + + + + + updateText(value, ignore)" + @blur="checkEmptyText()" + @mousedown="$event => handleSelectElement($event, false)" + /> + + + + + + + + + diff --git a/frontend/src/views/components/element/TableElement/BaseTableElement.vue b/frontend/src/views/components/element/TableElement/BaseTableElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..8bd620093ba65b96758bdf4b8f418fbb166ae7ad --- /dev/null +++ b/frontend/src/views/components/element/TableElement/BaseTableElement.vue @@ -0,0 +1,51 @@ + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/TableElement/CustomTextarea.vue b/frontend/src/views/components/element/TableElement/CustomTextarea.vue new file mode 100644 index 0000000000000000000000000000000000000000..dd804bbf37428b428d72fb939475c80a15b6c94e --- /dev/null +++ b/frontend/src/views/components/element/TableElement/CustomTextarea.vue @@ -0,0 +1,104 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/TableElement/EditableTable.vue b/frontend/src/views/components/element/TableElement/EditableTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..0cb30c3542fbb334debab9136e3483a9f011db08 --- /dev/null +++ b/frontend/src/views/components/element/TableElement/EditableTable.vue @@ -0,0 +1,840 @@ + + + + handleMousedownColHandler($event, index)" + > + + + + + + + + handleCellMousedown($event, rowIndex, colIndex)" + @mouseenter="handleCellMouseenter(rowIndex, colIndex)" + v-contextmenu="(el: HTMLElement) => contextmenus(el)" + > + handleInput(value, rowIndex, colIndex)" + @insertExcelData="value => insertExcelData(value, rowIndex, colIndex)" + /> + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/TableElement/StaticTable.vue b/frontend/src/views/components/element/TableElement/StaticTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..ec8655a02b4779cc54e4972b5b236315fcbe52bd --- /dev/null +++ b/frontend/src/views/components/element/TableElement/StaticTable.vue @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/TableElement/index.vue b/frontend/src/views/components/element/TableElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..794dd1d6faccc20039355fd4f9da6658b07ace36 --- /dev/null +++ b/frontend/src/views/components/element/TableElement/index.vue @@ -0,0 +1,204 @@ + + + + + updateTableCells(data)" + @changeColWidths="widths => updateColWidths(widths)" + @changeSelectedCells="cells => updateSelectedCells(cells)" + /> + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + > + 双击编辑 + + + + + + + + + diff --git a/frontend/src/views/components/element/TableElement/useHideCells.ts b/frontend/src/views/components/element/TableElement/useHideCells.ts new file mode 100644 index 0000000000000000000000000000000000000000..85a59c5f633c994ecda54bffd078a8ad05a69954 --- /dev/null +++ b/frontend/src/views/components/element/TableElement/useHideCells.ts @@ -0,0 +1,31 @@ +import { computed, type Ref } from 'vue' +import type { TableCell } from '@/types/slides' + +// 计算无效的单元格位置(被合并的单元格位置)集合 + +export default (cells: Ref) => { + const hideCells = computed(() => { + const hideCells = [] + + for (let i = 0; i < cells.value.length; i++) { + const rowCells = cells.value[i] + + for (let j = 0; j < rowCells.length; j++) { + const cell = rowCells[j] + + if (cell.colspan > 1 || cell.rowspan > 1) { + for (let row = i; row < i + cell.rowspan; row++) { + for (let col = row === i ? j + 1 : j; col < j + cell.colspan; col++) { + hideCells.push(`${row}_${col}`) + } + } + } + } + } + return hideCells + }) + + return { + hideCells, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/TableElement/useSubThemeColor.ts b/frontend/src/views/components/element/TableElement/useSubThemeColor.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc83008c40168240f7f9b1eb2e33379eece67964 --- /dev/null +++ b/frontend/src/views/components/element/TableElement/useSubThemeColor.ts @@ -0,0 +1,18 @@ +import { ref, watch, type Ref } from 'vue' +import type { TableTheme } from '@/types/slides' +import { getTableSubThemeColor } from '@/utils/element' + +// 通过表格的主题色计算辅助颜色 + +export default (theme: Ref) => { + const subThemeColor = ref(['', '']) + watch(() => theme.value, () => { + if (theme.value) { + subThemeColor.value = getTableSubThemeColor(theme.value.color) + } + }, { immediate: true }) + + return { + subThemeColor, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/TableElement/utils.ts b/frontend/src/views/components/element/TableElement/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1eb98ed1861d329c39b2e38d538092723b4a4ac --- /dev/null +++ b/frontend/src/views/components/element/TableElement/utils.ts @@ -0,0 +1,39 @@ +import type { CSSProperties } from 'vue' +import type { TableCellStyle } from '@/types/slides' + +/** + * 计算单元格文本样式 + * @param style 单元格文本样式原数据 + */ +export const getTextStyle = (style?: TableCellStyle): CSSProperties => { + if (!style) return {} + const { + bold, + em, + underline, + strikethrough, + color, + backcolor, + fontsize, + fontname, + align, + } = style + + let textDecoration = `${underline ? 'underline' : ''} ${strikethrough ? 'line-through' : ''}` + if (textDecoration === ' ') textDecoration = 'none' + + return { + fontWeight: bold ? 'bold' : 'normal', + fontStyle: em ? 'italic' : 'normal', + textDecoration, + color: color || '#000', + backgroundColor: backcolor || '', + fontSize: fontsize || '14px', + fontFamily: fontname || '', + textAlign: align || 'left', + } +} + +export const formatText = (text: string) => { + return text.replace(/\n/g, '').replace(/ /g, ' ') +} \ No newline at end of file diff --git a/frontend/src/views/components/element/TextElement/BaseTextElement.vue b/frontend/src/views/components/element/TextElement/BaseTextElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..fa2e74102cdb9187da0b4ce3d089e593bb31115e --- /dev/null +++ b/frontend/src/views/components/element/TextElement/BaseTextElement.vue @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/TextElement/index.vue b/frontend/src/views/components/element/TextElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..7e2fd889d25b4fb6ddf98fc1310e65dfdce38cdb --- /dev/null +++ b/frontend/src/views/components/element/TextElement/index.vue @@ -0,0 +1,220 @@ + + + + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + > + + updateContent(value, ignore)" + @mousedown="$event => handleSelectElement($event, false)" + /> + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/VideoElement/BaseVideoElement.vue b/frontend/src/views/components/element/VideoElement/BaseVideoElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..7cc87099cb479b482ef971330bd7b58ace6a5c7a --- /dev/null +++ b/frontend/src/views/components/element/VideoElement/BaseVideoElement.vue @@ -0,0 +1,52 @@ + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/VideoElement/ScreenVideoElement.vue b/frontend/src/views/components/element/VideoElement/ScreenVideoElement.vue new file mode 100644 index 0000000000000000000000000000000000000000..c1f4ed0e5fb158be7f2c9a9a16d221e45809c850 --- /dev/null +++ b/frontend/src/views/components/element/VideoElement/ScreenVideoElement.vue @@ -0,0 +1,62 @@ + + + + + + + + + + + + + diff --git a/frontend/src/views/components/element/VideoElement/VideoPlayer/index.vue b/frontend/src/views/components/element/VideoElement/VideoPlayer/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..205dca621c1370126c5ac42b79e31f08cdf5af99 --- /dev/null +++ b/frontend/src/views/components/element/VideoElement/VideoPlayer/index.vue @@ -0,0 +1,693 @@ + + + + 视频加载失败 + + + + + + + + + + + + + + + + + + + + + + + + + + + + handleClickVolumeBar($event)" + > + + + + + + + + + {{ptime}} / {{dtime}} + + + + + + + {{playbackRate === 1 ? '倍速' : (playbackRate + 'x')}} + + {{item.label}} + + + + + + 循环{{loop ? '开' : '关'}} + + + + + handleMousemovePlayBar($event)" + @mouseenter="playBarTimeVisible = true" + @mouseleave="playBarTimeVisible = false" + > + {{playBarTime}} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/element/VideoElement/VideoPlayer/useMSE.ts b/frontend/src/views/components/element/VideoElement/VideoPlayer/useMSE.ts new file mode 100644 index 0000000000000000000000000000000000000000..f49cbbeda36b2e9e331a5b1389ea3145413ed780 --- /dev/null +++ b/frontend/src/views/components/element/VideoElement/VideoPlayer/useMSE.ts @@ -0,0 +1,41 @@ +import { onMounted, type Ref } from 'vue' + +export default ( + src: string, + videoRef: Ref, +) => { + onMounted(() => { + if (!videoRef.value) return + + let type = 'normal' + if (/m3u8(#|\?|$)/i.exec(src)) type = 'hls' + else if (/.flv(#|\?|$)/i.exec(src)) type = 'flv' + + if (videoRef.value && type === 'hls' && (videoRef.value.canPlayType('application/x-mpegURL') || videoRef.value.canPlayType('application/vnd.apple.mpegURL'))) { + type = 'normal' + } + + if (type === 'hls') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Hls = (window as any).Hls + + if (Hls && Hls.isSupported()) { + const hls = new Hls() + hls.loadSource(src) + hls.attachMedia(videoRef.value) + } + } + else if (type === 'flv') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const flvjs = (window as any).flvjs + if (flvjs && flvjs.isSupported()) { + const flvPlayer = flvjs.createPlayer({ + type: 'flv', + url: src, + }) + flvPlayer.attachMediaElement(videoRef.value) + flvPlayer.load() + } + } + }) +} \ No newline at end of file diff --git a/frontend/src/views/components/element/VideoElement/index.vue b/frontend/src/views/components/element/VideoElement/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5cd06051b7e31d627820c69be76fa64f196b031 --- /dev/null +++ b/frontend/src/views/components/element/VideoElement/index.vue @@ -0,0 +1,110 @@ + + + + handleSelectElement($event, false)" + @touchstart="$event => handleSelectElement($event, false)" + > + + handleSelectElement($event)" + @touchstart="$event => handleSelectElement($event)" + > + + + + + + + + diff --git a/frontend/src/views/components/element/hooks/useElementFill.ts b/frontend/src/views/components/element/hooks/useElementFill.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a752095e4aa0a490a6d9c6f9484ef56afe9657a --- /dev/null +++ b/frontend/src/views/components/element/hooks/useElementFill.ts @@ -0,0 +1,15 @@ +import type { PPTShapeElement } from '@/types/slides' +import { computed, type Ref } from 'vue' + +// 计算元素的填充样式 +export default (element: Ref, source: string) => { + const fill = computed(() => { + if (element.value.pattern) return `url(#${source}-pattern-${element.value.id})` + if (element.value.gradient) return `url(#${source}-gradient-${element.value.id})` + return element.value.fill || 'none' + }) + + return { + fill, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/hooks/useElementFlip.ts b/frontend/src/views/components/element/hooks/useElementFlip.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aec3351b12ac93a505b2506f29804ed4e45b815 --- /dev/null +++ b/frontend/src/views/components/element/hooks/useElementFlip.ts @@ -0,0 +1,18 @@ +import { computed, type Ref } from 'vue' + +// 计算元素的翻转样式 +export default (flipH: Ref, flipV: Ref) => { + const flipStyle = computed(() => { + let style = '' + + if (flipH.value && flipV.value) style = 'rotateX(180deg) rotateY(180deg)' + else if (flipV.value) style = 'rotateX(180deg)' + else if (flipH.value) style = 'rotateY(180deg)' + + return style + }) + + return { + flipStyle, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/hooks/useElementOutline.ts b/frontend/src/views/components/element/hooks/useElementOutline.ts new file mode 100644 index 0000000000000000000000000000000000000000..1af87fbea61b590b26d29060b86d1b4cb91cd62b --- /dev/null +++ b/frontend/src/views/components/element/hooks/useElementOutline.ts @@ -0,0 +1,23 @@ +import { computed, type Ref } from 'vue' +import type { PPTElementOutline } from '@/types/slides' + +// 计算边框相关属性值,主要是对默认值的处理 +export default (outline: Ref) => { + const outlineWidth = computed(() => outline.value?.width ?? 0) + const outlineStyle = computed(() => outline.value?.style || 'solid') + const outlineColor = computed(() => outline.value?.color || '#d14424') + + const strokeDashArray = computed(() => { + const size = outlineWidth.value + if (outlineStyle.value === 'dashed') return size <= 6 ? `${size * 4.5} ${size * 2}` : `${size * 4} ${size * 1.5}` + if (outlineStyle.value === 'dotted') return size <= 6 ? `${size * 1.8} ${size * 1.6}` : `${size * 1.5} ${size * 1.2}` + return '0 0' + }) + + return { + outlineWidth, + outlineStyle, + outlineColor, + strokeDashArray, + } +} \ No newline at end of file diff --git a/frontend/src/views/components/element/hooks/useElementShadow.ts b/frontend/src/views/components/element/hooks/useElementShadow.ts new file mode 100644 index 0000000000000000000000000000000000000000..d30718ed530cba55ab33be3c6463657eb4275e16 --- /dev/null +++ b/frontend/src/views/components/element/hooks/useElementShadow.ts @@ -0,0 +1,17 @@ +import { computed, type Ref } from 'vue' +import type { PPTElementShadow } from '@/types/slides' + +// 计算元素的阴影样式 +export default (shadow: Ref) => { + const shadowStyle = computed(() => { + if (shadow.value) { + const { h, v, blur, color } = shadow.value + return `${h}px ${v}px ${blur}px ${color}` + } + return '' + }) + + return { + shadowStyle, + } +} \ No newline at end of file
还没有演示文稿,创建一个开始吧!
加载中...
{{ outline }}
{{ json }}
说明:
💡 这些链接是固定的,可以直接分享给他人访问
管理员: PS01 / admin_cybercity2025
用户: PS02 / cybercity2025
用户: PS03 / cybercity2025
用户: PS04 / cybercity2025
请检查分享链接是否正确或联系分享者