Compare commits
3 Commits
2f3a2b7aae
...
6e3efe7843
Author | SHA1 | Date |
---|---|---|
朱浩 | 6e3efe7843 | |
朱浩 | 432dbcc6fa | |
zhangxuelin | cae55eac0b |
64
package.json
|
@ -21,11 +21,6 @@
|
||||||
"build:linux": "npm run build && electron-builder --linux"
|
"build:linux": "npm run build && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
|
||||||
"@electron-toolkit/utils": "^3.0.0",
|
|
||||||
"@electron/remote": "^2.1.2",
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
|
||||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
|
||||||
"@antv/x6": "^2.18.1",
|
"@antv/x6": "^2.18.1",
|
||||||
"@antv/x6-plugin-clipboard": "^2.1.6",
|
"@antv/x6-plugin-clipboard": "^2.1.6",
|
||||||
"@antv/x6-plugin-dnd": "^2.1.1",
|
"@antv/x6-plugin-dnd": "^2.1.1",
|
||||||
|
@ -34,6 +29,11 @@
|
||||||
"@antv/x6-plugin-selection": "^2.2.2",
|
"@antv/x6-plugin-selection": "^2.2.2",
|
||||||
"@antv/x6-plugin-snapline": "^2.1.7",
|
"@antv/x6-plugin-snapline": "^2.1.7",
|
||||||
"@antv/x6-plugin-transform": "^2.1.8",
|
"@antv/x6-plugin-transform": "^2.1.8",
|
||||||
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
|
"@electron/remote": "^2.1.2",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||||
"@vue-office/docx": "^1.6.2",
|
"@vue-office/docx": "^1.6.2",
|
||||||
"@vue-office/excel": "^1.7.11",
|
"@vue-office/excel": "^1.7.11",
|
||||||
"@vue-office/pdf": "^2.0.2",
|
"@vue-office/pdf": "^2.0.2",
|
||||||
|
@ -53,6 +53,8 @@
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
"jsondiffpatch": "0.6.0",
|
"jsondiffpatch": "0.6.0",
|
||||||
|
"less": "^4.2.0",
|
||||||
|
"less-loader": "^7.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"node-addon-api": "^8.1.0",
|
"node-addon-api": "^8.1.0",
|
||||||
"pdfjs-dist": "4.4.168",
|
"pdfjs-dist": "4.4.168",
|
||||||
|
@ -62,11 +64,39 @@
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vue-qr": "^4.0.9",
|
"vue-qr": "^4.0.9",
|
||||||
"vue-router": "^4.4.0",
|
"vue-router": "^4.4.0",
|
||||||
|
"whiteboard_lyc": "^0.1.3",
|
||||||
"xgplayer": "^3.0.19",
|
"xgplayer": "^3.0.19",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"less": "^4.2.0",
|
|
||||||
"less-loader": "^7.3.0",
|
"@icon-park/vue-next": "^1.4.2",
|
||||||
"whiteboard_lyc": "^0.1.3"
|
"animate.css": "^4.1.1",
|
||||||
|
"clipboard": "^2.0.11",
|
||||||
|
"dexie": "3.0.3",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"hfmath": "^0.0.2",
|
||||||
|
"html-to-image": "^1.11.11",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"number-precision": "^1.6.0",
|
||||||
|
"pptxgenjs": "^3.12.0",
|
||||||
|
"pptxtojson": "^1.0.3",
|
||||||
|
"prosemirror-commands": "^1.6.0",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.3.2",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-model": "^1.22.2",
|
||||||
|
"prosemirror-schema-basic": "^1.2.3",
|
||||||
|
"prosemirror-schema-list": "^1.4.1",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-view": "^1.33.9",
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.2.0",
|
||||||
|
"svg-pathdata": "^7.1.0",
|
||||||
|
"tinycolor2": "^1.6.0",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
|
"vue": "^3.4.34",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/eslint-config": "^1.0.2",
|
"@electron-toolkit/eslint-config": "^1.0.2",
|
||||||
|
@ -84,6 +114,22 @@
|
||||||
"vite": "^5.3.1",
|
"vite": "^5.3.1",
|
||||||
"vite-plugin-windicss": "^1.9.3",
|
"vite-plugin-windicss": "^1.9.3",
|
||||||
"vue": "^3.4.30",
|
"vue": "^3.4.30",
|
||||||
"windicss": "^3.5.6"
|
"windicss": "^3.5.6",
|
||||||
|
|
||||||
|
"@commitlint/cli": "^18.4.3",
|
||||||
|
"@commitlint/config-conventional": "^18.4.3",
|
||||||
|
"@tsconfig/node18": "^18.2.2",
|
||||||
|
"@types/crypto-js": "^4.2.1",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
|
"@types/lodash": "^4.14.202",
|
||||||
|
"@types/node": "^18.19.3",
|
||||||
|
"@types/svg-arc-to-cubic-bezier": "^3.2.2",
|
||||||
|
"@types/tinycolor2": "^1.4.6",
|
||||||
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
|
"@vue/tsconfig": "^0.5.0",
|
||||||
|
"husky": "^8.0.3",
|
||||||
|
"npm-run-all2": "^6.1.1",
|
||||||
|
"typescript": "~5.3.0",
|
||||||
|
"vue-tsc": "^1.8.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 9.8 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 9.8 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 565 B |
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<Screen v-if="screening" />
|
||||||
|
<Editor v-else-if="_isPC" />
|
||||||
|
<Mobile v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useScreenStore, useMainStore, useSnapshotStore } from './store'
|
||||||
|
import { LOCALSTORAGE_KEY_DISCARDED_DB } from './configs/storage'
|
||||||
|
import { deleteDiscardedDB } from './utils/database'
|
||||||
|
import { isPC } from './utils/common'
|
||||||
|
import Editor from './views/Editor/index.vue'
|
||||||
|
import Screen from './views/Screen/index.vue'
|
||||||
|
import Mobile from './views/Mobile/index.vue'
|
||||||
|
|
||||||
|
const _isPC = isPC()
|
||||||
|
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
const snapshotStore = useSnapshotStore()
|
||||||
|
const { databaseId } = storeToRefs(mainStore)
|
||||||
|
const { screening } = storeToRefs(useScreenStore())
|
||||||
|
|
||||||
|
if (import.meta.env.MODE !== 'development') {
|
||||||
|
window.onbeforeunload = () => false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await deleteDiscardedDB()
|
||||||
|
snapshotStore.initSnapshotDatabase()
|
||||||
|
mainStore.setAvailableFonts()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 应用注销时向 localStorage 中记录下本次 indexedDB 的数据库ID,用于之后清除数据库
|
||||||
|
window.addEventListener('unload', () => {
|
||||||
|
const discardedDB = localStorage.getItem(LOCALSTORAGE_KEY_DISCARDED_DB)
|
||||||
|
const discardedDBList: string[] = discardedDB ? JSON.parse(discardedDB) : []
|
||||||
|
|
||||||
|
discardedDBList.push(databaseId.value)
|
||||||
|
|
||||||
|
const newDiscardedDB = JSON.stringify(discardedDBList)
|
||||||
|
localStorage.setItem(LOCALSTORAGE_KEY_DISCARDED_DB, newDiscardedDB)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,9 @@
|
||||||
|
$fontList: '仓耳小丸子', '优设标题黑', '字制区喜脉体', '峰广明锐体', '得意黑', '摄图摩登小方体', '站酷快乐体', '素材集市康康体', '素材集市酷方体', '途牛类圆体', '锐字真言体';
|
||||||
|
|
||||||
|
@each $font in $fontList {
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: $font;
|
||||||
|
src: url('../fonts/#{$font}.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
@import "variable";
|
||||||
|
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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
@import "variable";
|
||||||
|
.ProseMirror, .ProseMirror-static {
|
||||||
|
outline: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
|
||||||
|
&:not(.ProseMirror-static) {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: rgba($themeColor, 0.25);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: var(--paragraphSpace);
|
||||||
|
}
|
||||||
|
p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-inline-start: 1.25em;
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style-type: inherit;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-inline-start: 1.25em;
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style-type: inherit;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: $borderColor;
|
||||||
|
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-right: 1.2em;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
font-style: italic;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-indent='1'] {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
[data-indent='2'] {
|
||||||
|
padding-left: 40px;
|
||||||
|
}
|
||||||
|
[data-indent='3'] {
|
||||||
|
padding-left: 60px;
|
||||||
|
}
|
||||||
|
[data-indent='4'] {
|
||||||
|
padding-left: 80px;
|
||||||
|
}
|
||||||
|
[data-indent='5'] {
|
||||||
|
padding-left: 100px;
|
||||||
|
}
|
||||||
|
[data-indent='6'] {
|
||||||
|
padding-left: 120px;
|
||||||
|
}
|
||||||
|
[data-indent='7'] {
|
||||||
|
padding-left: 140px;
|
||||||
|
}
|
||||||
|
[data-indent='8'] {
|
||||||
|
padding-left: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-selectednode {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
$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;
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--zhuhao-theme-color: #e5e7eb;
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { Icons } from '../plugins/icon'
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
export type GlobalComponents = Icons
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
|
@ -0,0 +1,118 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:class="{
|
||||||
|
'disabled': disabled,
|
||||||
|
'checked': !disabled && checked,
|
||||||
|
'default': !disabled && type === 'default',
|
||||||
|
'primary': !disabled && type === 'primary',
|
||||||
|
'checkbox': !disabled && type === 'checkbox',
|
||||||
|
'radio': !disabled && type === 'radio',
|
||||||
|
'small': size === 'small',
|
||||||
|
'first': first,
|
||||||
|
'last': last,
|
||||||
|
}"
|
||||||
|
@click="handleClick()"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
checked?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
type?: 'default' | 'primary' | 'checkbox' | 'radio'
|
||||||
|
size?: 'small' | 'normal'
|
||||||
|
first?: boolean
|
||||||
|
last?: boolean
|
||||||
|
}>(), {
|
||||||
|
checked: false,
|
||||||
|
disabled: false,
|
||||||
|
type: 'default',
|
||||||
|
size: 'normal',
|
||||||
|
first: false,
|
||||||
|
last: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'click'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('click')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.button {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
outline: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 15px;
|
||||||
|
text-align: center;
|
||||||
|
color: $textColor;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
padding: 0 7px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.default {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: $textColor;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $themeColor;
|
||||||
|
border-color: $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.primary {
|
||||||
|
background-color: $themeColor;
|
||||||
|
border: 1px solid $themeColor;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $themeHoverColor;
|
||||||
|
border-color: $themeHoverColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.checkbox, &.radio {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: $textColor;
|
||||||
|
|
||||||
|
&:not(.checked):hover {
|
||||||
|
color: $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.checked {
|
||||||
|
color: #fff;
|
||||||
|
background-color: $themeColor;
|
||||||
|
border-color: $themeColor;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $themeHoverColor;
|
||||||
|
border-color: $themeHoverColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: #b7b7b7;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<div class="button-group" :class="{ 'passive': passive }" ref="groupRef">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
passive?: boolean
|
||||||
|
}>(), {
|
||||||
|
passive: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
::v-deep(button.button) {
|
||||||
|
border-radius: 0;
|
||||||
|
border-left-width: 1px;
|
||||||
|
border-right-width: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.passive) {
|
||||||
|
::v-deep(button.button) {
|
||||||
|
&:not(:last-child, .radio, .checkbox):hover {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 1px;
|
||||||
|
height: calc(100% + 2px);
|
||||||
|
background-color: $themeColor;
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: $borderRadius;
|
||||||
|
border-bottom-left-radius: $borderRadius;
|
||||||
|
border-left-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-top-right-radius: $borderRadius;
|
||||||
|
border-bottom-right-radius: $borderRadius;
|
||||||
|
border-right-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.passive {
|
||||||
|
::v-deep(button.button) {
|
||||||
|
&:not(.last, .radio, .checkbox):hover {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 1px;
|
||||||
|
height: calc(100% + 2px);
|
||||||
|
background-color: $themeColor;
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.first {
|
||||||
|
border-top-left-radius: $borderRadius;
|
||||||
|
border-bottom-left-radius: $borderRadius;
|
||||||
|
border-left-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.last {
|
||||||
|
border-top-right-radius: $borderRadius;
|
||||||
|
border-bottom-right-radius: $borderRadius;
|
||||||
|
border-right-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,111 @@
|
||||||
|
<template>
|
||||||
|
<label
|
||||||
|
class="checkbox"
|
||||||
|
:class="{
|
||||||
|
'checked': value,
|
||||||
|
'disabled': disabled,
|
||||||
|
}"
|
||||||
|
@change="$event => handleChange($event)"
|
||||||
|
>
|
||||||
|
<span class="checkbox-input"></span>
|
||||||
|
<input class="checkbox-original" type="checkbox" :checked="value">
|
||||||
|
<span class="checkbox-label">
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleChange = (e: Event) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('update:value', (e.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.checkbox {
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:not(.disabled).checked {
|
||||||
|
.checkbox-input {
|
||||||
|
background-color: $themeColor;
|
||||||
|
border-color: $themeColor;
|
||||||
|
}
|
||||||
|
.checkbox-input::after {
|
||||||
|
transform: rotate(45deg) scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
color: $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: #b7b7b7;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.checkbox-input {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #fff;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: border-color .15s cubic-bezier(.71, -.46, .29, 1.46), background-color .15s cubic-bezier(.71, -.46, .29, 1.46);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
height: 9px;
|
||||||
|
left: 4px;
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
transform: rotate(45deg) scaleY(0);
|
||||||
|
width: 6px;
|
||||||
|
transition: transform .15s ease-in .05s;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.checkbox-original {
|
||||||
|
opacity: 0;
|
||||||
|
outline: 0;
|
||||||
|
position: absolute;
|
||||||
|
margin: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.checkbox-label {
|
||||||
|
margin-left: 5px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,21 @@
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:checked="checked"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Button from './Button.vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
checked?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
checked: false,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,44 @@
|
||||||
|
<template>
|
||||||
|
<Button class="color-btn">
|
||||||
|
<div class="color-block">
|
||||||
|
<div class="content" :style="{ backgroundColor: color }"></div>
|
||||||
|
</div>
|
||||||
|
<IconPlatte class="color-btn-icon" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Button from './Button.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
color: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.color-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.color-block {
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
flex: 1;
|
||||||
|
outline: 1px dashed rgba($color: #666, $alpha: .12);
|
||||||
|
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAAEBJREFUOE9jfPbs2X8GIoCkpCQRqhgYGEcNxBlOo2GIM2iGQLL5//8/UTnl+fPnxOWUUQNxhtNoGOLOKYM+2QAAh2Nq10DwkukAAAAASUVORK5CYII=);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.color-btn-icon {
|
||||||
|
width: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,109 @@
|
||||||
|
<template>
|
||||||
|
<div class="alpha">
|
||||||
|
<div class="alpha-checkboard-wrap">
|
||||||
|
<Checkboard />
|
||||||
|
</div>
|
||||||
|
<div class="alpha-gradient" :style="{ background: gradientColor }"></div>
|
||||||
|
<div
|
||||||
|
class="alpha-container"
|
||||||
|
ref="alphaRef"
|
||||||
|
@mousedown="$event => handleMouseDown($event)"
|
||||||
|
>
|
||||||
|
<div class="alpha-pointer" :style="{ left: color.a * 100 + '%' }">
|
||||||
|
<div class="alpha-picker"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import Checkboard from './Checkboard.vue'
|
||||||
|
import type { ColorFormats } from 'tinycolor2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: ColorFormats.RGBA
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'colorChange', payload: ColorFormats.RGBA): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const color = computed(() => props.value)
|
||||||
|
|
||||||
|
const gradientColor = computed(() => {
|
||||||
|
const rgbaStr = [color.value.r, color.value.g, color.value.b].join(',')
|
||||||
|
return `linear-gradient(to right, rgba(${rgbaStr}, 0) 0%, rgba(${rgbaStr}, 1) 100%)`
|
||||||
|
})
|
||||||
|
|
||||||
|
const alphaRef = ref<HTMLElement>()
|
||||||
|
const handleChange = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!alphaRef.value) return
|
||||||
|
const containerWidth = alphaRef.value.clientWidth
|
||||||
|
const xOffset = alphaRef.value.getBoundingClientRect().left + window.pageXOffset
|
||||||
|
const left = e.pageX - xOffset
|
||||||
|
let a
|
||||||
|
|
||||||
|
if (left < 0) a = 0
|
||||||
|
else if (left > containerWidth) a = 1
|
||||||
|
else a = Math.round(left * 100 / containerWidth) / 100
|
||||||
|
|
||||||
|
if (color.value.a !== a) {
|
||||||
|
emit('colorChange', {
|
||||||
|
r: color.value.r,
|
||||||
|
g: color.value.g,
|
||||||
|
b: color.value.b,
|
||||||
|
a: a,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbindEventListeners = () => {
|
||||||
|
window.removeEventListener('mousemove', handleChange)
|
||||||
|
window.removeEventListener('mouseup', unbindEventListeners)
|
||||||
|
}
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
handleChange(e)
|
||||||
|
window.addEventListener('mousemove', handleChange)
|
||||||
|
window.addEventListener('mouseup', unbindEventListeners)
|
||||||
|
}
|
||||||
|
onUnmounted(unbindEventListeners)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.alpha {
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
.alpha-checkboard-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
.alpha-gradient {
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
.alpha-container {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
.alpha-pointer {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.alpha-picker {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
box-shadow: 0 0 2px rgba(0, 0, 0, .6);
|
||||||
|
background: #fff;
|
||||||
|
margin-top: 1px;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,62 @@
|
||||||
|
<template>
|
||||||
|
<div class="checkerboard" :style="bgStyle"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
size?: number
|
||||||
|
white?: string
|
||||||
|
grey?: string
|
||||||
|
}>(), {
|
||||||
|
size: 8,
|
||||||
|
white: '#fff',
|
||||||
|
grey: '#e6e6e6',
|
||||||
|
})
|
||||||
|
|
||||||
|
interface CheckboardCache {
|
||||||
|
[key: string]: string | null
|
||||||
|
}
|
||||||
|
const checkboardCache: CheckboardCache = {}
|
||||||
|
|
||||||
|
const renderCheckboard = (white: string, grey: string, size: number) => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = canvas.height = size * 2
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!ctx) return null
|
||||||
|
|
||||||
|
ctx.fillStyle = white
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.fillStyle = grey
|
||||||
|
ctx.fillRect(0, 0, size, size)
|
||||||
|
ctx.translate(size, size)
|
||||||
|
ctx.fillRect(0, 0, size, size)
|
||||||
|
return canvas.toDataURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCheckboard = (white: string, grey: string, size: number) => {
|
||||||
|
const key = white + ',' + grey + ',' + size
|
||||||
|
if (checkboardCache[key]) return checkboardCache[key]
|
||||||
|
|
||||||
|
const checkboard = renderCheckboard(white, grey, size)
|
||||||
|
checkboardCache[key] = checkboard
|
||||||
|
return checkboard
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgStyle = computed(() => {
|
||||||
|
const checkboard = getCheckboard(props.white, props.grey, props.size)
|
||||||
|
return { backgroundImage: `url(${checkboard})` }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.checkerboard {
|
||||||
|
background-size: contain;
|
||||||
|
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,71 @@
|
||||||
|
<template>
|
||||||
|
<div class="editable-input">
|
||||||
|
<input
|
||||||
|
class="input-content"
|
||||||
|
:value="val"
|
||||||
|
@input="$event => handleInput($event)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: ColorFormats.RGBA
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'colorChange', payload: ColorFormats.RGBA): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const val = computed(() => {
|
||||||
|
let _hex = ''
|
||||||
|
if (props.value.a < 1) _hex = tinycolor(props.value).toHex8String().toUpperCase()
|
||||||
|
else _hex = tinycolor(props.value).toHexString().toUpperCase()
|
||||||
|
return _hex.replace('#', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleInput = (e: Event) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value
|
||||||
|
if (value.length >= 6) {
|
||||||
|
const color = tinycolor(value)
|
||||||
|
if (color.isValid()) {
|
||||||
|
emit('colorChange', color.toRgb())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.editable-input {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '#';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.input-content {
|
||||||
|
width: 100%;
|
||||||
|
padding: 3px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.input-label {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,119 @@
|
||||||
|
<template>
|
||||||
|
<div class="hue">
|
||||||
|
<div
|
||||||
|
class="hue-container"
|
||||||
|
ref="hueRef"
|
||||||
|
@mousedown="$event => handleMouseDown($event)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="hue-pointer"
|
||||||
|
:style="{ left: pointerLeft }"
|
||||||
|
>
|
||||||
|
<div class="hue-picker"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: ColorFormats.RGBA
|
||||||
|
hue: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'colorChange', payload: ColorFormats.HSLA): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const oldHue = ref(0)
|
||||||
|
const pullDirection = ref('')
|
||||||
|
|
||||||
|
const color = computed(() => {
|
||||||
|
const hsla = tinycolor(props.value).toHsl()
|
||||||
|
if (props.hue !== -1) hsla.h = props.hue
|
||||||
|
return hsla
|
||||||
|
})
|
||||||
|
|
||||||
|
const pointerLeft = computed(() => {
|
||||||
|
if (color.value.h === 0 && pullDirection.value === 'right') return '100%'
|
||||||
|
return color.value.h * 100 / 360 + '%'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.value, () => {
|
||||||
|
const hsla = tinycolor(props.value).toHsl()
|
||||||
|
const h = hsla.s === 0 ? props.hue : hsla.h
|
||||||
|
if (h !== 0 && h - oldHue.value > 0) pullDirection.value = 'right'
|
||||||
|
if (h !== 0 && h - oldHue.value < 0) pullDirection.value = 'left'
|
||||||
|
oldHue.value = h
|
||||||
|
})
|
||||||
|
|
||||||
|
const hueRef = ref<HTMLElement>()
|
||||||
|
const handleChange = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!hueRef.value) return
|
||||||
|
|
||||||
|
const containerWidth = hueRef.value.clientWidth
|
||||||
|
const xOffset = hueRef.value.getBoundingClientRect().left + window.pageXOffset
|
||||||
|
const left = e.pageX - xOffset
|
||||||
|
let h, percent
|
||||||
|
|
||||||
|
if (left < 0) h = 0
|
||||||
|
else if (left > containerWidth) h = 360
|
||||||
|
else {
|
||||||
|
percent = left * 100 / containerWidth
|
||||||
|
h = 360 * percent / 100
|
||||||
|
}
|
||||||
|
if (props.hue === -1 || color.value.h !== h) {
|
||||||
|
emit('colorChange', {
|
||||||
|
h,
|
||||||
|
l: color.value.l,
|
||||||
|
s: color.value.s,
|
||||||
|
a: color.value.a,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbindEventListeners = () => {
|
||||||
|
window.removeEventListener('mousemove', handleChange)
|
||||||
|
window.removeEventListener('mouseup', unbindEventListeners)
|
||||||
|
}
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
handleChange(e)
|
||||||
|
window.addEventListener('mousemove', handleChange)
|
||||||
|
window.addEventListener('mouseup', unbindEventListeners)
|
||||||
|
}
|
||||||
|
onUnmounted(unbindEventListeners)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.hue {
|
||||||
|
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
|
||||||
|
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
.hue-container {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 2px;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.hue-pointer {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.hue-picker {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 1px;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
box-shadow: 0 0 2px rgba(0, 0, 0, .6);
|
||||||
|
background: #fff;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,110 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="saturation"
|
||||||
|
ref="saturationRef"
|
||||||
|
:style="{ background: bgColor }"
|
||||||
|
@mousedown="$event => handleMouseDown($event)"
|
||||||
|
>
|
||||||
|
<div class="saturation-white"></div>
|
||||||
|
<div class="saturation-black"></div>
|
||||||
|
<div class="saturation-pointer"
|
||||||
|
:style="{
|
||||||
|
top: pointerTop,
|
||||||
|
left: pointerLeft,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="saturation-circle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
||||||
|
import { throttle, clamp } from 'lodash'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: ColorFormats.RGBA
|
||||||
|
hue: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'colorChange', payload: ColorFormats.HSVA): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const color = computed(() => {
|
||||||
|
const hsva = tinycolor(props.value).toHsv()
|
||||||
|
if (props.hue !== -1) hsva.h = props.hue
|
||||||
|
return hsva
|
||||||
|
})
|
||||||
|
|
||||||
|
const bgColor = computed(() => `hsl(${color.value.h}, 100%, 50%)`)
|
||||||
|
const pointerTop = computed(() => (-(color.value.v * 100) + 1) + 100 + '%')
|
||||||
|
const pointerLeft = computed(() => color.value.s * 100 + '%')
|
||||||
|
|
||||||
|
const emitChangeEvent = throttle(function(param: ColorFormats.HSVA) {
|
||||||
|
emit('colorChange', param)
|
||||||
|
}, 20, { leading: true, trailing: false })
|
||||||
|
|
||||||
|
const saturationRef = ref<HTMLElement>()
|
||||||
|
const handleChange = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!saturationRef.value) return
|
||||||
|
|
||||||
|
const containerWidth = saturationRef.value.clientWidth
|
||||||
|
const containerHeight = saturationRef.value.clientHeight
|
||||||
|
const xOffset = saturationRef.value.getBoundingClientRect().left + window.pageXOffset
|
||||||
|
const yOffset = saturationRef.value.getBoundingClientRect().top + window.pageYOffset
|
||||||
|
const left = clamp(e.pageX - xOffset, 0, containerWidth)
|
||||||
|
const top = clamp(e.pageY - yOffset, 0, containerHeight)
|
||||||
|
const saturation = left / containerWidth
|
||||||
|
const bright = clamp(-(top / containerHeight) + 1, 0, 1)
|
||||||
|
|
||||||
|
emitChangeEvent({
|
||||||
|
h: color.value.h,
|
||||||
|
s: saturation,
|
||||||
|
v: bright,
|
||||||
|
a: color.value.a,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbindEventListeners = () => {
|
||||||
|
window.removeEventListener('mousemove', handleChange)
|
||||||
|
window.removeEventListener('mouseup', unbindEventListeners)
|
||||||
|
}
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
handleChange(e)
|
||||||
|
window.addEventListener('mousemove', handleChange)
|
||||||
|
window.addEventListener('mouseup', unbindEventListeners)
|
||||||
|
}
|
||||||
|
onUnmounted(unbindEventListeners)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.saturation,
|
||||||
|
.saturation-white,
|
||||||
|
.saturation-black {
|
||||||
|
@include absolute-0();
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.saturation-white {
|
||||||
|
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
|
||||||
|
}
|
||||||
|
.saturation-black {
|
||||||
|
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
.saturation-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.saturation-circle {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0, 0, 0, .3), 0 0 1px 2px rgba(0, 0, 0, .4);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,445 @@
|
||||||
|
<template>
|
||||||
|
<div class="color-picker">
|
||||||
|
<div class="picker-saturation-wrap">
|
||||||
|
<Saturation :value="color" :hue="hue" @colorChange="value => changeColor(value)" />
|
||||||
|
</div>
|
||||||
|
<div class="picker-controls">
|
||||||
|
<div class="picker-color-wrap">
|
||||||
|
<div class="picker-current-color" :style="{ background: currentColor }"></div>
|
||||||
|
<Checkboard />
|
||||||
|
</div>
|
||||||
|
<div class="picker-sliders">
|
||||||
|
<div class="picker-hue-wrap">
|
||||||
|
<Hue :value="color" :hue="hue" @colorChange="value => changeColor(value)" />
|
||||||
|
</div>
|
||||||
|
<div class="picker-alpha-wrap">
|
||||||
|
<Alpha :value="color" @colorChange="value => changeColor(value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="picker-field">
|
||||||
|
<EditableInput class="input" :value="color" @colorChange="value => changeColor(value)" />
|
||||||
|
<div class="straw" @click="openEyeDropper()"><IconNeedle /></div>
|
||||||
|
<div class="transparent" @click="selectPresetColor('#00000000')">
|
||||||
|
<Checkboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="picker-presets">
|
||||||
|
<div
|
||||||
|
class="picker-presets-color"
|
||||||
|
v-for="c in themeColors"
|
||||||
|
:key="c"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@click="selectPresetColor(c)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="picker-gradient-presets">
|
||||||
|
<div
|
||||||
|
class="picker-gradient-col"
|
||||||
|
v-for="(col, index) in presetColors"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<div class="picker-gradient-color"
|
||||||
|
v-for="c in col"
|
||||||
|
:key="c"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@click="selectPresetColor(c)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="picker-presets">
|
||||||
|
<div
|
||||||
|
v-for="c in standardColors"
|
||||||
|
:key="c"
|
||||||
|
class="picker-presets-color"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@click="selectPresetColor(c)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recent-colors-title" v-if="recentColors.length">最近使用:</div>
|
||||||
|
<div class="picker-presets">
|
||||||
|
<div
|
||||||
|
v-for="c in recentColors"
|
||||||
|
:key="c"
|
||||||
|
class="picker-presets-color alpha"
|
||||||
|
@click="selectPresetColor(c)"
|
||||||
|
>
|
||||||
|
<div class="picker-presets-color-content" :style="{ background: c }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import tinycolor, { type ColorFormats } from 'tinycolor2'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
|
import { toCanvas } from 'html-to-image'
|
||||||
|
import message from '../../utils/message'
|
||||||
|
|
||||||
|
import Alpha from './Alpha.vue'
|
||||||
|
import Checkboard from './Checkboard.vue'
|
||||||
|
import Hue from './Hue.vue'
|
||||||
|
import Saturation from './Saturation.vue'
|
||||||
|
import EditableInput from './EditableInput.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
}>(), {
|
||||||
|
modelValue: '#e86b99',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', payload: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const RECENT_COLORS = 'RECENT_COLORS'
|
||||||
|
|
||||||
|
const presetColorConfig = [
|
||||||
|
['#7f7f7f', '#f2f2f2'],
|
||||||
|
['#0d0d0d', '#808080'],
|
||||||
|
['#1c1a10', '#ddd8c3'],
|
||||||
|
['#0e243d', '#c6d9f0'],
|
||||||
|
['#233f5e', '#dae5f0'],
|
||||||
|
['#632623', '#f2dbdb'],
|
||||||
|
['#4d602c', '#eaf1de'],
|
||||||
|
['#3f3150', '#e6e0ec'],
|
||||||
|
['#1e5867', '#d9eef3'],
|
||||||
|
['#99490f', '#fee9da'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const gradient = (startColor: string, endColor: string, step: number) => {
|
||||||
|
const _startColor = tinycolor(startColor).toRgb()
|
||||||
|
const _endColor = tinycolor(endColor).toRgb()
|
||||||
|
|
||||||
|
const rStep = (_endColor.r - _startColor.r) / step
|
||||||
|
const gStep = (_endColor.g - _startColor.g) / step
|
||||||
|
const bStep = (_endColor.b - _startColor.b) / step
|
||||||
|
const gradientColorArr = []
|
||||||
|
|
||||||
|
for (let i = 0; i < step; i++) {
|
||||||
|
const gradientColor = tinycolor({
|
||||||
|
r: _startColor.r + rStep * i,
|
||||||
|
g: _startColor.g + gStep * i,
|
||||||
|
b: _startColor.b + bStep * i,
|
||||||
|
}).toRgbString()
|
||||||
|
gradientColorArr.push(gradientColor)
|
||||||
|
}
|
||||||
|
return gradientColorArr
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPresetColors = () => {
|
||||||
|
const presetColors = []
|
||||||
|
for (const color of presetColorConfig) {
|
||||||
|
presetColors.push(gradient(color[1], color[0], 5))
|
||||||
|
}
|
||||||
|
return presetColors
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeColors = ['#000000', '#ffffff', '#eeece1', '#1e497b', '#4e81bb', '#e2534d', '#9aba60', '#8165a0', '#47acc5', '#f9974c']
|
||||||
|
const standardColors = ['#c21401', '#ff1e02', '#ffc12a', '#ffff3a', '#90cf5b', '#00af57', '#00afee', '#0071be', '#00215f', '#72349d']
|
||||||
|
|
||||||
|
const hue = ref(-1)
|
||||||
|
const recentColors = ref<string[]>([])
|
||||||
|
|
||||||
|
const color = computed({
|
||||||
|
get() {
|
||||||
|
return tinycolor(props.modelValue).toRgb()
|
||||||
|
},
|
||||||
|
set(rgba: ColorFormats.RGBA) {
|
||||||
|
const rgbaString = `rgba(${[rgba.r, rgba.g, rgba.b, rgba.a].join(',')})`
|
||||||
|
emit('update:modelValue', rgbaString)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const presetColors = getPresetColors()
|
||||||
|
|
||||||
|
const currentColor = computed(() => {
|
||||||
|
return `rgba(${[color.value.r, color.value.g, color.value.b, color.value.a].join(',')})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectPresetColor = (colorString: string) => {
|
||||||
|
hue.value = tinycolor(colorString).toHsl().h
|
||||||
|
emit('update:modelValue', colorString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次选择非预设颜色时,需要将该颜色加入到最近使用列表中
|
||||||
|
const updateRecentColorsCache = debounce(function() {
|
||||||
|
const _color = tinycolor(color.value).toRgbString()
|
||||||
|
if (!recentColors.value.includes(_color)) {
|
||||||
|
recentColors.value = [_color, ...recentColors.value]
|
||||||
|
|
||||||
|
const maxLength = 10
|
||||||
|
if (recentColors.value.length > maxLength) {
|
||||||
|
recentColors.value = recentColors.value.slice(0, maxLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300, { trailing: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const recentColorsCache = localStorage.getItem(RECENT_COLORS)
|
||||||
|
if (recentColorsCache) recentColors.value = JSON.parse(recentColorsCache)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(recentColors, () => {
|
||||||
|
const recentColorsCache = JSON.stringify(recentColors.value)
|
||||||
|
localStorage.setItem(RECENT_COLORS, recentColorsCache)
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeColor = (value: ColorFormats.RGBA | ColorFormats.HSLA | ColorFormats.HSVA) => {
|
||||||
|
if ('h' in value) {
|
||||||
|
hue.value = value.h
|
||||||
|
color.value = tinycolor(value).toRgb()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
hue.value = tinycolor(value).toHsl().h
|
||||||
|
color.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRecentColorsCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开取色吸管
|
||||||
|
// 检查环境是否支持原生取色吸管,支持则使用原生吸管,否则使用自定义吸管
|
||||||
|
const openEyeDropper = () => {
|
||||||
|
const isSupportedEyeDropper = 'EyeDropper' in window
|
||||||
|
|
||||||
|
if (isSupportedEyeDropper) browserEyeDropper()
|
||||||
|
else customEyeDropper()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原生取色吸管
|
||||||
|
const browserEyeDropper = () => {
|
||||||
|
message.success('按 ESC 键关闭取色吸管', { duration: 0 })
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const eyeDropper = new (window as any).EyeDropper()
|
||||||
|
eyeDropper.open().then((result: { sRGBHex: string }) => {
|
||||||
|
const tColor = tinycolor(result.sRGBHex)
|
||||||
|
hue.value = tColor.toHsl().h
|
||||||
|
color.value = tColor.toRgb()
|
||||||
|
|
||||||
|
message.closeAll()
|
||||||
|
updateRecentColorsCache()
|
||||||
|
}).catch(() => {
|
||||||
|
message.closeAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于 Canvas 的自定义取色吸管
|
||||||
|
const customEyeDropper = () => {
|
||||||
|
const targetRef: HTMLElement | null = document.querySelector('.canvas')
|
||||||
|
if (!targetRef) return
|
||||||
|
|
||||||
|
const maskRef = document.createElement('div')
|
||||||
|
maskRef.style.cssText = 'position: fixed; top: 0; left: 0; bottom: 0; right: 0; z-index: 9999; cursor: wait;'
|
||||||
|
document.body.appendChild(maskRef)
|
||||||
|
|
||||||
|
const colorBlockRef = document.createElement('div')
|
||||||
|
colorBlockRef.style.cssText = 'position: absolute; top: -100px; left: -100px; width: 16px; height: 16px; border: 1px solid #000; z-index: 999'
|
||||||
|
maskRef.appendChild(colorBlockRef)
|
||||||
|
|
||||||
|
const { left, top, width, height } = targetRef.getBoundingClientRect()
|
||||||
|
|
||||||
|
const filter = (node: HTMLElement) => {
|
||||||
|
if (node.tagName && node.tagName.toUpperCase() === 'FOREIGNOBJECT') return false
|
||||||
|
if (node.classList && node.classList.contains('operate')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
toCanvas(targetRef, { filter, fontEmbedCSS: '', width, height, canvasWidth: width, canvasHeight: height, pixelRatio: 1 }).then(canvasRef => {
|
||||||
|
canvasRef.style.cssText = `position: absolute; top: ${top}px; left: ${left}px; cursor: crosshair;`
|
||||||
|
maskRef.style.cursor = 'default'
|
||||||
|
maskRef.appendChild(canvasRef)
|
||||||
|
|
||||||
|
const ctx = canvasRef.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
let currentColor = ''
|
||||||
|
const handleMousemove = (e: MouseEvent) => {
|
||||||
|
const x = e.x
|
||||||
|
const y = e.y
|
||||||
|
|
||||||
|
const mouseX = x - left
|
||||||
|
const mouseY = y - top
|
||||||
|
|
||||||
|
const [r, g, b, a] = ctx.getImageData(mouseX, mouseY, 1, 1).data
|
||||||
|
currentColor = `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(2)})`
|
||||||
|
|
||||||
|
colorBlockRef.style.left = x + 10 + 'px'
|
||||||
|
colorBlockRef.style.top = y + 10 + 'px'
|
||||||
|
colorBlockRef.style.backgroundColor = currentColor
|
||||||
|
}
|
||||||
|
const handleMouseleave = () => {
|
||||||
|
currentColor = ''
|
||||||
|
colorBlockRef.style.left = '-100px'
|
||||||
|
colorBlockRef.style.top = '-100px'
|
||||||
|
colorBlockRef.style.backgroundColor = ''
|
||||||
|
}
|
||||||
|
const handleMousedown = (e: MouseEvent) => {
|
||||||
|
if (currentColor && e.button === 0) {
|
||||||
|
const tColor = tinycolor(currentColor)
|
||||||
|
hue.value = tColor.toHsl().h
|
||||||
|
color.value = tColor.toRgb()
|
||||||
|
|
||||||
|
updateRecentColorsCache()
|
||||||
|
}
|
||||||
|
document.body.removeChild(maskRef)
|
||||||
|
|
||||||
|
canvasRef.removeEventListener('mousemove', handleMousemove)
|
||||||
|
canvasRef.removeEventListener('mouseleave', handleMouseleave)
|
||||||
|
window.removeEventListener('mousedown', handleMousedown)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.addEventListener('mousemove', handleMousemove)
|
||||||
|
canvasRef.addEventListener('mouseleave', handleMouseleave)
|
||||||
|
window.addEventListener('mousedown', handleMousedown)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('取色吸管初始化失败')
|
||||||
|
document.body.removeChild(maskRef)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.color-picker {
|
||||||
|
position: relative;
|
||||||
|
width: 240px;
|
||||||
|
background: #fff;
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: -10px;
|
||||||
|
}
|
||||||
|
.picker-saturation-wrap {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 50%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.picker-controls {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.picker-sliders {
|
||||||
|
padding: 4px 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.picker-hue-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.picker-alpha-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.picker-color-wrap {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
outline: 1px dashed rgba($color: #666, $alpha: .12);
|
||||||
|
|
||||||
|
.checkerboard {
|
||||||
|
background-size: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.picker-current-color {
|
||||||
|
@include absolute-0();
|
||||||
|
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-field {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.transparent {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 26px;
|
||||||
|
height: 2px;
|
||||||
|
position: absolute;
|
||||||
|
top: 11px;
|
||||||
|
left: -1px;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
background-color: #f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkerboard {
|
||||||
|
background-size: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.straw {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
outline: 1px solid #f1f1f1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-presets {
|
||||||
|
@include flex-grid-layout();
|
||||||
|
}
|
||||||
|
.picker-presets-color {
|
||||||
|
@include flex-grid-layout-children(10, 7%);
|
||||||
|
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 7%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.alpha {
|
||||||
|
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAADBJREFUOE9jfPbs2X8GPEBSUhKfNAPjqAHDIgz+//+PNx08f/4cfzoYNYCBceiHAQC5flV5JzgrxQAAAABJRU5ErkJggg==);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.picker-presets-color-content {
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
.picker-gradient-presets {
|
||||||
|
@include flex-grid-layout();
|
||||||
|
}
|
||||||
|
.picker-gradient-col {
|
||||||
|
@include flex-grid-layout-children(10, 7%);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.picker-gradient-color {
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-colors-title {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,139 @@
|
||||||
|
<template>
|
||||||
|
<ul class="menu-content">
|
||||||
|
<template v-for="(menu, index) in menus" :key="menu.text || index">
|
||||||
|
<li
|
||||||
|
v-if="!menu.hide"
|
||||||
|
class="menu-item"
|
||||||
|
@click.stop="handleClickMenuItem(menu)"
|
||||||
|
:class="{'divider': menu.divider, 'disable': menu.disable}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="menu-item-content"
|
||||||
|
:class="{
|
||||||
|
'has-children': menu.children,
|
||||||
|
'has-handler': menu.handler,
|
||||||
|
}"
|
||||||
|
v-if="!menu.divider"
|
||||||
|
>
|
||||||
|
<span class="text">{{menu.text}}</span>
|
||||||
|
<span class="sub-text" v-if="menu.subText && !menu.children">{{menu.subText}}</span>
|
||||||
|
|
||||||
|
<menu-content
|
||||||
|
class="sub-menu"
|
||||||
|
:menus="menu.children"
|
||||||
|
v-if="menu.children && menu.children.length"
|
||||||
|
:handleClickMenuItem="handleClickMenuItem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ContextmenuItem } from './types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
menus: ContextmenuItem[]
|
||||||
|
handleClickMenuItem: (item: ContextmenuItem) => void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
$menuWidth: 180px;
|
||||||
|
$menuHeight: 30px;
|
||||||
|
$subMenuWidth: 120px;
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
width: $menuWidth;
|
||||||
|
padding: 5px 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid $borderColor;
|
||||||
|
box-shadow: $boxShadow;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
padding: 0 20px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all $transitionDelayFast;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: $menuHeight;
|
||||||
|
line-height: $menuHeight;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:not(.disable):hover > .menu-item-content > .sub-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disable):hover > .has-children.has-handler::after {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.disable) {
|
||||||
|
background-color: rgba($color: $themeColor, $alpha: .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.divider {
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 5px;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
line-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disable {
|
||||||
|
color: #b1b1b1;
|
||||||
|
cursor: no-drop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.menu-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.has-children::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #666 #666 transparent transparent;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
&.has-children.has-handler::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: rgba($color: #fff, $alpha: .3);
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
top: 3px;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform $transitionDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-text {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.sub-menu {
|
||||||
|
width: $subMenuWidth;
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
left: 112%;
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,80 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="mask"
|
||||||
|
@contextmenu.prevent="removeContextmenu()"
|
||||||
|
@mousedown.left="removeContextmenu()"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="contextmenu"
|
||||||
|
:style="{
|
||||||
|
left: style.left + 'px',
|
||||||
|
top: style.top + 'px',
|
||||||
|
}"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<MenuContent
|
||||||
|
:menus="menus"
|
||||||
|
:handleClickMenuItem="handleClickMenuItem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ContextmenuItem, Axis } from './types'
|
||||||
|
|
||||||
|
import MenuContent from './MenuContent.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
axis: Axis
|
||||||
|
el: HTMLElement
|
||||||
|
menus: ContextmenuItem[]
|
||||||
|
removeContextmenu: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
const MENU_WIDTH = 180
|
||||||
|
const MENU_HEIGHT = 30
|
||||||
|
const DIVIDER_HEIGHT = 11
|
||||||
|
const PADDING = 5
|
||||||
|
|
||||||
|
const { x, y } = props.axis
|
||||||
|
const menuCount = props.menus.filter(menu => !(menu.divider || menu.hide)).length
|
||||||
|
const dividerCount = props.menus.filter(menu => menu.divider).length
|
||||||
|
|
||||||
|
const menuWidth = MENU_WIDTH
|
||||||
|
const menuHeight = menuCount * MENU_HEIGHT + dividerCount * DIVIDER_HEIGHT + PADDING * 2
|
||||||
|
|
||||||
|
const screenWidth = document.body.clientWidth
|
||||||
|
const screenHeight = document.body.clientHeight
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: screenWidth <= x + menuWidth ? x - menuWidth : x,
|
||||||
|
top: screenHeight <= y + menuHeight ? y - menuHeight : y,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClickMenuItem = (item: ContextmenuItem) => {
|
||||||
|
if (item.disable) return
|
||||||
|
if (item.children && !item.handler) return
|
||||||
|
if (item.handler) item.handler(props.el)
|
||||||
|
props.removeContextmenu()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
.contextmenu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
<template>
|
||||||
|
<div :class="['divider', type]"
|
||||||
|
:style="{
|
||||||
|
margin: type === 'horizontal' ? `${margin >= 0 ? margin : 24}px 0` : `0 ${margin >= 0 ? margin : 8}px`
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
type?: 'horizontal' | 'vertical'
|
||||||
|
margin?: number
|
||||||
|
}>(), {
|
||||||
|
type: 'horizontal',
|
||||||
|
margin: -1,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.divider {
|
||||||
|
&.horizontal {
|
||||||
|
width: 100%;
|
||||||
|
margin: 24px 0;
|
||||||
|
border-block-start: 1px solid rgba(5, 5, 5, .06);
|
||||||
|
}
|
||||||
|
&.vertical {
|
||||||
|
position: relative;
|
||||||
|
height: 1em;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 8px;
|
||||||
|
border-inline-start: 1px solid rgba(5, 5, 5, .06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,128 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition :name="`drawer-slide-${placement}`"
|
||||||
|
@afterLeave="contentVisible = false"
|
||||||
|
@before-enter="contentVisible = true"
|
||||||
|
>
|
||||||
|
<div :class="['drawer', placement]" v-show="visible" :style="{ width: props.width + 'px' }">
|
||||||
|
<div class="header">
|
||||||
|
<slot name="title"></slot>
|
||||||
|
<span class="close-btn" @click="emit('update:visible', false)"><IconClose /></span>
|
||||||
|
</div>
|
||||||
|
<div class="content" v-if="contentVisible" :style="contentStyle">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, type CSSProperties } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
width?: number
|
||||||
|
contentStyle?: CSSProperties
|
||||||
|
placement?: 'left' | 'right'
|
||||||
|
}>(), {
|
||||||
|
width: 320,
|
||||||
|
placement: 'right',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:visible', payload: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const contentVisible = ref(false)
|
||||||
|
|
||||||
|
const contentStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
width: props.width + 'px',
|
||||||
|
...(props.contentStyle || {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.drawer {
|
||||||
|
height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 0;
|
||||||
|
box-shadow: 3px 0 6px -4px rgba(0, 0, 0, 0.12), 9px 0 28px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
&.right {
|
||||||
|
right: 0;
|
||||||
|
box-shadow: -3px 0 6px -4px rgba(0, 0, 0, 0.12), -9px 0 28px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 15px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 0 15px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-slide-right-enter-active {
|
||||||
|
animation: drawer-slide-right-enter .25s both ease;
|
||||||
|
}
|
||||||
|
.drawer-slide-right-leave-active {
|
||||||
|
animation: drawer-slide-right-leave .25s both ease;
|
||||||
|
}
|
||||||
|
.drawer-slide-left-enter-active {
|
||||||
|
animation: drawer-slide-left-enter .25s both ease;
|
||||||
|
}
|
||||||
|
.drawer-slide-left-leave-active {
|
||||||
|
animation: drawer-slide-left-leave .25s both ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawer-slide-right-enter {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes drawer-slide-right-leave {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes drawer-slide-left-enter {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes drawer-slide-left-leave {
|
||||||
|
to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<div class="file-input" @click="handleClick()">
|
||||||
|
<slot></slot>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="file"
|
||||||
|
name="upload"
|
||||||
|
ref="inputRef"
|
||||||
|
:accept="accept"
|
||||||
|
@change="$event => handleChange($event)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
accept?: string
|
||||||
|
}>(), {
|
||||||
|
accept: 'image/*',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'change', payload: FileList): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!inputRef.value) return
|
||||||
|
inputRef.value.value = ''
|
||||||
|
inputRef.value.click()
|
||||||
|
}
|
||||||
|
const handleChange = (e: Event) => {
|
||||||
|
const files = (e.target as HTMLInputElement).files
|
||||||
|
if (files) emit('change', files)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,68 @@
|
||||||
|
<template>
|
||||||
|
<div class="fullscreen-spin" v-if="loading">
|
||||||
|
<div class="spin">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="text">{{tip}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
loading?: boolean
|
||||||
|
tip?: string
|
||||||
|
}>(), {
|
||||||
|
loading: false,
|
||||||
|
tip: '',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.fullscreen-spin {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba($color: #f1f1f1, $alpha: .7);
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -100px;
|
||||||
|
margin-left: -100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid $themeColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spinner .8s linear infinite;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: $themeColor;
|
||||||
|
}
|
||||||
|
@keyframes spinner {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,152 @@
|
||||||
|
<template>
|
||||||
|
<div class="gradient-bar">
|
||||||
|
<div class="bar" ref="barRef" :style="{ backgroundImage: gradientStyle }" @click="$event => addPoint($event)"></div>
|
||||||
|
<div class="point"
|
||||||
|
:class="{ 'active': activeIndex === index }"
|
||||||
|
v-for="(item, index) in points"
|
||||||
|
:key="item.pos + '-' + index"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
left: `calc(${item.pos}% - 5px)`,
|
||||||
|
}"
|
||||||
|
@mousedown.left="movePoint(index)"
|
||||||
|
@click.right="removePoint(index)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { GradientColor } from '../types/slides'
|
||||||
|
import { ref, computed, watchEffect, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: GradientColor[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: GradientColor[]): void
|
||||||
|
(event: 'update:index', payload: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const points = ref<GradientColor[]>([])
|
||||||
|
|
||||||
|
const barRef = ref<HTMLElement>()
|
||||||
|
const activeIndex = ref(0)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
points.value = props.value
|
||||||
|
if (activeIndex.value > props.value.length - 1) activeIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeIndex, () => {
|
||||||
|
emit('update:index', activeIndex.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gradientStyle = computed(() => {
|
||||||
|
const list = points.value.map(item => `${item.color} ${item.pos}%`)
|
||||||
|
return `linear-gradient(to right, ${list.join(',')})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const removePoint = (index: number) => {
|
||||||
|
if (props.value.length <= 2) return
|
||||||
|
|
||||||
|
if (index === activeIndex.value) {
|
||||||
|
activeIndex.value = (index - 1 < 0) ? 0 : index - 1
|
||||||
|
}
|
||||||
|
else if (activeIndex.value === props.value.length - 1) {
|
||||||
|
activeIndex.value = props.value.length - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = props.value.filter((item, _index) => _index !== index)
|
||||||
|
emit('update:value', values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const movePoint = (index: number) => {
|
||||||
|
let isMouseDown = true
|
||||||
|
|
||||||
|
document.onmousemove = e => {
|
||||||
|
if (!isMouseDown) return
|
||||||
|
if (!barRef.value) return
|
||||||
|
|
||||||
|
let pos = Math.round((e.clientX - barRef.value.getBoundingClientRect().left) / barRef.value.clientWidth * 100)
|
||||||
|
if (pos > 100) pos = 100
|
||||||
|
if (pos < 0) pos = 0
|
||||||
|
|
||||||
|
points.value = points.value.map((item, _index) => {
|
||||||
|
if (_index === index) return { ...item, pos }
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
document.onmouseup = () => {
|
||||||
|
isMouseDown = false
|
||||||
|
|
||||||
|
const point = points.value[index]
|
||||||
|
const _points = [...points.value]
|
||||||
|
_points.splice(index, 1)
|
||||||
|
|
||||||
|
let targetIndex = 0
|
||||||
|
for (let i = 0; i < _points.length; i++) {
|
||||||
|
if (point.pos > _points[i].pos) targetIndex = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
activeIndex.value = targetIndex
|
||||||
|
_points.splice(targetIndex, 0, point)
|
||||||
|
|
||||||
|
emit('update:value', _points)
|
||||||
|
|
||||||
|
document.onmousemove = null
|
||||||
|
document.onmouseup = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPoint = (e: MouseEvent) => {
|
||||||
|
if (props.value.length >= 6) return
|
||||||
|
if (!barRef.value) return
|
||||||
|
const pos = Math.round((e.clientX - barRef.value.getBoundingClientRect().left) / barRef.value.clientWidth * 100)
|
||||||
|
|
||||||
|
let targetIndex = 0
|
||||||
|
for (let i = 0; i < props.value.length; i++) {
|
||||||
|
if (pos > props.value[i].pos) targetIndex = i + 1
|
||||||
|
}
|
||||||
|
const color = props.value[targetIndex - 1] ? props.value[targetIndex - 1].color : props.value[targetIndex].color
|
||||||
|
const values = [...props.value]
|
||||||
|
values.splice(targetIndex, 0, { pos, color })
|
||||||
|
activeIndex.value = targetIndex
|
||||||
|
emit('update:value', values)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.gradient-bar {
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
height: 18px;
|
||||||
|
padding: 1px 0;
|
||||||
|
margin: 3px 0;
|
||||||
|
position: relative;
|
||||||
|
left: 5px;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
.point {
|
||||||
|
width: 10px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
outline: 1px solid #d9d9d9;
|
||||||
|
box-shadow: 0 0 2px 2px #d9d9d9;
|
||||||
|
border-radius: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
outline: 1px solid $themeColor;
|
||||||
|
box-shadow: 0 0 2px 2px $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,134 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="input"
|
||||||
|
:class="{
|
||||||
|
'disabled': disabled,
|
||||||
|
'focused': focused,
|
||||||
|
'simple': simple,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="prefix">
|
||||||
|
<slot name="prefix"></slot>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref="inputRef"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="value"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@input="$event => handleInput($event)"
|
||||||
|
@focus="$event => handleFocus($event)"
|
||||||
|
@blur="$event => handleBlur($event)"
|
||||||
|
@change="$event => emit('change', $event)"
|
||||||
|
@keydown.enter="$event => emit('enter', $event)"
|
||||||
|
/>
|
||||||
|
<span class="suffix">
|
||||||
|
<slot name="suffix"></slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
simple?: boolean
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
placeholder: '',
|
||||||
|
simple: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: string): void
|
||||||
|
(event: 'input', payload: Event): void
|
||||||
|
(event: 'change', payload: Event): void
|
||||||
|
(event: 'blur', payload: Event): void
|
||||||
|
(event: 'focus', payload: Event): void
|
||||||
|
(event: 'enter', payload: Event): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const focused = ref(false)
|
||||||
|
|
||||||
|
const handleInput = (e: Event) => {
|
||||||
|
emit('update:value', (e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
const handleBlur = (e: Event) => {
|
||||||
|
focused.value = false
|
||||||
|
emit('blur', e)
|
||||||
|
}
|
||||||
|
const handleFocus = (e: Event) => {
|
||||||
|
focused.value = true
|
||||||
|
emit('focus', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement>()
|
||||||
|
const focus = () => {
|
||||||
|
if (inputRef.value) inputRef.value.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.input {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
transition: border-color .25s;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
outline: 0;
|
||||||
|
border: 0;
|
||||||
|
line-height: 30px;
|
||||||
|
vertical-align: top;
|
||||||
|
color: $textColor;
|
||||||
|
padding: 0 5px;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji';
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disabled):hover, &.focused {
|
||||||
|
border-color: $themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-color: #dcdcdc;
|
||||||
|
color: #b7b7b7;
|
||||||
|
|
||||||
|
input {
|
||||||
|
color: #b7b7b7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.simple {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix, .suffix {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 30px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="formula-content"
|
||||||
|
overflow="visible"
|
||||||
|
:width="box.w + 32"
|
||||||
|
:height="box.h + 32"
|
||||||
|
stroke="#000"
|
||||||
|
stroke-width="1"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
:transform="`scale(${scale}, ${scale}) translate(0,0) matrix(1,0,0,1,0,0)`"
|
||||||
|
transform-origin="0 50%"
|
||||||
|
>
|
||||||
|
<path :d="pathd"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { hfmath } from './hfmath'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
latex: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const box = ref({ x: 0, y: 0, w: 0, h: 0 })
|
||||||
|
const pathd = ref('')
|
||||||
|
|
||||||
|
watch(() => props.latex, () => {
|
||||||
|
const eq = new hfmath(props.latex)
|
||||||
|
pathd.value = eq.pathd({})
|
||||||
|
box.value = eq.box({})
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const scale = computed(() => {
|
||||||
|
const boxW = box.value.w + 32
|
||||||
|
const boxH = box.value.h + 32
|
||||||
|
|
||||||
|
if (boxW > props.width || boxH > props.height) {
|
||||||
|
if (boxW / boxH > props.width / props.height) return props.width / boxW
|
||||||
|
return props.height / boxH
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
svg {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template>
|
||||||
|
<div class="symbol-content" v-html="svg"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { hfmath } from './hfmath'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
latex: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const svg = computed(() => {
|
||||||
|
const eq = new hfmath(props.latex)
|
||||||
|
return eq.svg({
|
||||||
|
SCALE_X: 10,
|
||||||
|
SCALE_Y: 10,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { hfmath, CONFIG as hfmathConfig } from 'hfmath'
|
||||||
|
|
||||||
|
hfmathConfig.SUB_SUP_SCALE = 0.5
|
||||||
|
|
||||||
|
export { hfmath }
|
|
@ -0,0 +1,267 @@
|
||||||
|
<template>
|
||||||
|
<div class="latex-editor">
|
||||||
|
<div class="container">
|
||||||
|
<div class="left">
|
||||||
|
<div class="input-area">
|
||||||
|
<TextArea v-model:value="latex" placeholder="输入 LaTeX 公式" ref="textAreaRef" />
|
||||||
|
</div>
|
||||||
|
<div class="preview">
|
||||||
|
<div class="placeholder" v-if="!latex">公式预览</div>
|
||||||
|
<div class="preview-content" v-else>
|
||||||
|
<FormulaContent
|
||||||
|
:width="518"
|
||||||
|
:height="138"
|
||||||
|
:latex="latex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<Tabs
|
||||||
|
:tabs="tabs"
|
||||||
|
v-model:value="toolbarState"
|
||||||
|
card
|
||||||
|
/>
|
||||||
|
<div class="content">
|
||||||
|
<div class="symbol" v-if="toolbarState === 'symbol'">
|
||||||
|
<Tabs
|
||||||
|
:tabs="symbolTabs"
|
||||||
|
v-model:value="selectedSymbolKey"
|
||||||
|
spaceBetween
|
||||||
|
:tabsStyle="{ margin: '10px 10px 0' }"
|
||||||
|
/>
|
||||||
|
<div class="symbol-pool">
|
||||||
|
<div class="symbol-item" v-for="item in symbolPool" :key="item.latex" @click="insertSymbol(item.latex)">
|
||||||
|
<SymbolContent :latex="item.latex" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="formula" v-else>
|
||||||
|
<div class="formula-item" v-for="item in formulaList" :key="item.label">
|
||||||
|
<div class="formula-title">{{item.label}}</div>
|
||||||
|
<div class="formula-item-content" @click="latex = item.latex">
|
||||||
|
<FormulaContent
|
||||||
|
:width="236"
|
||||||
|
:height="60"
|
||||||
|
:latex="item.latex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<Button class="btn" @click="emit('close')">取消</Button>
|
||||||
|
<Button class="btn" type="primary" @click="update()">确定</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { hfmath } from './hfmath'
|
||||||
|
import { FORMULA_LIST, SYMBOL_LIST } from '../../configs/latex'
|
||||||
|
import message from '../../utils/message'
|
||||||
|
|
||||||
|
import FormulaContent from './FormulaContent.vue'
|
||||||
|
import SymbolContent from './SymbolContent.vue'
|
||||||
|
import Button from '../Button.vue'
|
||||||
|
import TextArea from '../TextArea.vue'
|
||||||
|
import Tabs from '../Tabs.vue'
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
key: 'symbol' | 'formula'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: TabItem[] = [
|
||||||
|
{ label: '常用符号', key: 'symbol' },
|
||||||
|
{ label: '预置公式', key: 'formula' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface LatexResult {
|
||||||
|
latex: string
|
||||||
|
path: string
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value?: string
|
||||||
|
}>(), {
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update', payload: LatexResult): void
|
||||||
|
(event: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formulaList = FORMULA_LIST
|
||||||
|
|
||||||
|
const symbolTabs = SYMBOL_LIST.map(item => ({
|
||||||
|
label: item.label,
|
||||||
|
key: item.type,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const latex = ref('')
|
||||||
|
const toolbarState = ref<'symbol' | 'formula'>('symbol')
|
||||||
|
const textAreaRef = ref<InstanceType<typeof TextArea>>()
|
||||||
|
|
||||||
|
const selectedSymbolKey = ref(SYMBOL_LIST[0].type)
|
||||||
|
const symbolPool = computed(() => {
|
||||||
|
const selectedSymbol = SYMBOL_LIST.find(item => item.type === selectedSymbolKey.value)
|
||||||
|
return selectedSymbol?.children || []
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.value) latex.value = props.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (!latex.value) return message.error('公式不能为空')
|
||||||
|
|
||||||
|
const eq = new hfmath(latex.value)
|
||||||
|
const pathd = eq.pathd({})
|
||||||
|
const box = eq.box({})
|
||||||
|
|
||||||
|
emit('update', {
|
||||||
|
latex: latex.value,
|
||||||
|
path: pathd,
|
||||||
|
w: box.w + 32,
|
||||||
|
h: box.h + 32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSymbol = (latex: string) => {
|
||||||
|
if (!textAreaRef.value) return
|
||||||
|
textAreaRef.value.focus()
|
||||||
|
document.execCommand('insertText', false, latex)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../assets/styles/variable.scss";
|
||||||
|
@import "../../assets/styles/mixin.scss";
|
||||||
|
.latex-editor {
|
||||||
|
height: 560px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
height: calc(100% - 50px);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
width: 540px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.input-area {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: 100% !important;
|
||||||
|
border-color: $borderColor !important;
|
||||||
|
padding: 10px !important;
|
||||||
|
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
height: 160px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid $borderColor;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.preview-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
width: 280px;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 20px;
|
||||||
|
border: solid 1px $borderColor;
|
||||||
|
background-color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
height: calc(100% - 40px);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.formula {
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
@include overflow-overlay();
|
||||||
|
}
|
||||||
|
.formula-item {
|
||||||
|
& + .formula-item {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-title {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.formula-item-content {
|
||||||
|
height: 60px;
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: $lightGray;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.symbol {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.symbol-pool {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
@include overflow-overlay();
|
||||||
|
}
|
||||||
|
.symbol-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $lightGray;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,184 @@
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
name="message-fade"
|
||||||
|
appear
|
||||||
|
mode="in-out"
|
||||||
|
@beforeLeave="emit('close')"
|
||||||
|
@afterLeave="emit('destroy')"
|
||||||
|
>
|
||||||
|
<div class="message" :id="id" v-if="visible">
|
||||||
|
<div class="message-container"
|
||||||
|
@mouseenter="clearTimer()"
|
||||||
|
@mouseleave="startTimer()"
|
||||||
|
>
|
||||||
|
<div class="icons">
|
||||||
|
<IconAttention theme="filled" size="18" fill="#faad14" v-if="type === 'warning'" />
|
||||||
|
<IconCheckOne theme="filled" size="18" fill="#52c41a" v-if="type === 'success'" />
|
||||||
|
<IconCloseOne theme="filled" size="18" fill="#ff4d4f" v-if="type === 'error'" />
|
||||||
|
<IconInfo theme="filled" size="18" fill="#1677ff" v-if="type === 'info'" />
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="title" v-if="title">{{ title }}</div>
|
||||||
|
<div class="description">{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="control" v-if="closable">
|
||||||
|
<span
|
||||||
|
class="close-btn"
|
||||||
|
@click="close()"
|
||||||
|
>
|
||||||
|
<IconCloseSmall />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref, onBeforeMount } from 'vue'
|
||||||
|
import { icons } from '../plugins/icon'
|
||||||
|
|
||||||
|
const {
|
||||||
|
IconAttention,
|
||||||
|
IconCheckOne,
|
||||||
|
IconCloseOne,
|
||||||
|
IconInfo,
|
||||||
|
IconCloseSmall,
|
||||||
|
} = icons
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
type?: string
|
||||||
|
title?: string
|
||||||
|
duration?: number
|
||||||
|
closable?: boolean
|
||||||
|
}>(), {
|
||||||
|
type: 'success',
|
||||||
|
title: '',
|
||||||
|
duration: 3000,
|
||||||
|
closable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'destroy'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(true)
|
||||||
|
const timer = ref<number | null>(null)
|
||||||
|
|
||||||
|
const startTimer = () => {
|
||||||
|
if (props.duration <= 0) return
|
||||||
|
timer.value = setTimeout(close, props.duration)
|
||||||
|
}
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer.value) clearTimeout(timer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => visible.value = false
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
clearTimer()
|
||||||
|
})
|
||||||
|
onMounted(() => {
|
||||||
|
startTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
close,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.message {
|
||||||
|
max-width: 600px;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.message-container {
|
||||||
|
min-width: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
box-shadow: 0 1px 8px rgba(0, 0, 0, .15);
|
||||||
|
background: #fff;
|
||||||
|
pointer-events: all;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.icons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
line-height: 1.5;
|
||||||
|
color: $textColor;
|
||||||
|
}
|
||||||
|
.title + .description {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.control {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-fade-enter-active {
|
||||||
|
animation: message-fade-in-down .3s;
|
||||||
|
}
|
||||||
|
.message-fade-leave-active {
|
||||||
|
animation: message-fade-out .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes message-fade-in-down {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes message-fade-out {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: -45px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,156 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div class="modal" ref="modalRef" v-show="visible" tabindex="-1" @keyup.esc="onEsc()">
|
||||||
|
<div class="mask" @click="onClickMask()"></div>
|
||||||
|
<Transition name="modal-zoom"
|
||||||
|
@afterLeave="contentVisible = false"
|
||||||
|
@before-enter="contentVisible = true"
|
||||||
|
>
|
||||||
|
<div class="modal-content" v-show="visible" :style="contentStyle">
|
||||||
|
<span class="close-btn" v-if="closeButton" @click="close()"><IconClose /></span>
|
||||||
|
<slot v-if="contentVisible"></slot>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, ref, watch, type CSSProperties } from 'vue'
|
||||||
|
import { icons } from '../plugins/icon'
|
||||||
|
|
||||||
|
const { IconClose } = icons
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
width?: number
|
||||||
|
closeButton?: boolean
|
||||||
|
closeOnClickMask?: boolean
|
||||||
|
closeOnEsc?: boolean
|
||||||
|
contentStyle?: CSSProperties
|
||||||
|
}>(), {
|
||||||
|
width: 480,
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClickMask: true,
|
||||||
|
closeOnEsc: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalRef = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:visible', payload: boolean): void
|
||||||
|
(event: 'closed'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const contentVisible = ref(false)
|
||||||
|
|
||||||
|
const contentStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
width: props.width + 'px',
|
||||||
|
...(props.contentStyle || {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.visible, () => {
|
||||||
|
if (props.visible) {
|
||||||
|
nextTick(() => modalRef.value!.focus())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('update:visible', false)
|
||||||
|
emit('closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEsc = () => {
|
||||||
|
if (props.visible && props.closeOnEsc) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickMask = () => {
|
||||||
|
if (props.closeOnClickMask) close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.modal, .mask {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
outline: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(0, 0, 0, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
z-index: 5001;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-fade-enter-active {
|
||||||
|
animation: modal-fade-enter .25s both ease-in;
|
||||||
|
}
|
||||||
|
.modal-fade-leave-active {
|
||||||
|
animation: modal-fade-leave .25s both ease-out;
|
||||||
|
}
|
||||||
|
.modal-zoom-enter-active {
|
||||||
|
animation: modal-zoom-enter .25s both cubic-bezier(.4, 0, 0, 1.5);
|
||||||
|
}
|
||||||
|
.modal-zoom-leave-active {
|
||||||
|
animation: modal-zoom-leave .25s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-fade-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes modal-fade-leave {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes modal-zoom-enter {
|
||||||
|
from {
|
||||||
|
transform: scale3d(.3, .3, .3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes modal-zoom-leave {
|
||||||
|
to {
|
||||||
|
transform: scale3d(.3, .3, .3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,222 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="moveable-panel"
|
||||||
|
ref="moveablePanelRef"
|
||||||
|
:style="{
|
||||||
|
width: w + 'px',
|
||||||
|
height: h ? h + 'px' : 'auto',
|
||||||
|
left: x + 'px',
|
||||||
|
top: y + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-if="title">
|
||||||
|
<div class="header" @mousedown="$event => startMove($event)">
|
||||||
|
<div class="title">{{title}}</div>
|
||||||
|
<div class="close-btn" @click="emit('close')"><IconClose /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="content" @mousedown="$event => startMove($event)">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="resizer" v-if="resizeable" @mousedown="$event => startResize($event)"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
minWidth?: number
|
||||||
|
minHeight?: number
|
||||||
|
maxWidth?: number
|
||||||
|
maxHeight?: number
|
||||||
|
left?: number
|
||||||
|
top?: number
|
||||||
|
title?: string
|
||||||
|
moveable?: boolean
|
||||||
|
resizeable?: boolean
|
||||||
|
}>(), {
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
maxWidth: 500,
|
||||||
|
maxHeight: 500,
|
||||||
|
left: 10,
|
||||||
|
top: 10,
|
||||||
|
title: '',
|
||||||
|
moveable: true,
|
||||||
|
resizeable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const x = ref(0)
|
||||||
|
const y = ref(0)
|
||||||
|
const w = ref(0)
|
||||||
|
const h = ref(0)
|
||||||
|
const moveablePanelRef = ref<HTMLElement>()
|
||||||
|
const realHeight = computed(() => {
|
||||||
|
if (!h.value) {
|
||||||
|
return moveablePanelRef.value?.clientHeight || 0
|
||||||
|
}
|
||||||
|
return h.value
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.left >= 0) x.value = props.left
|
||||||
|
else x.value = document.body.clientWidth + props.left - props.width
|
||||||
|
|
||||||
|
if (props.top >= 0) y.value = props.top
|
||||||
|
else y.value = document.body.clientHeight + props.top - realHeight.value
|
||||||
|
|
||||||
|
w.value = props.width
|
||||||
|
h.value = props.height
|
||||||
|
})
|
||||||
|
|
||||||
|
const startMove = (e: MouseEvent) => {
|
||||||
|
if (!props.moveable) return
|
||||||
|
|
||||||
|
let isMouseDown = true
|
||||||
|
|
||||||
|
const windowWidth = document.body.clientWidth
|
||||||
|
const clientHeight = document.body.clientHeight
|
||||||
|
|
||||||
|
const startPageX = e.pageX
|
||||||
|
const startPageY = e.pageY
|
||||||
|
|
||||||
|
const originLeft = x.value
|
||||||
|
const originTop = y.value
|
||||||
|
|
||||||
|
document.onmousemove = e => {
|
||||||
|
if (!isMouseDown) return
|
||||||
|
|
||||||
|
const moveX = e.pageX - startPageX
|
||||||
|
const moveY = e.pageY - startPageY
|
||||||
|
|
||||||
|
let left = originLeft + moveX
|
||||||
|
let top = originTop + moveY
|
||||||
|
|
||||||
|
if (left < 0) left = 0
|
||||||
|
if (top < 0) top = 0
|
||||||
|
if (left + w.value > windowWidth) left = windowWidth - w.value
|
||||||
|
if (top + realHeight.value > clientHeight) top = clientHeight - realHeight.value
|
||||||
|
|
||||||
|
x.value = left
|
||||||
|
y.value = top
|
||||||
|
}
|
||||||
|
document.onmouseup = () => {
|
||||||
|
isMouseDown = false
|
||||||
|
|
||||||
|
document.onmousemove = null
|
||||||
|
document.onmouseup = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResize = (e: MouseEvent) => {
|
||||||
|
if (!props.resizeable) return
|
||||||
|
|
||||||
|
let isMouseDown = true
|
||||||
|
|
||||||
|
const startPageX = e.pageX
|
||||||
|
const startPageY = e.pageY
|
||||||
|
|
||||||
|
const originWidth = w.value
|
||||||
|
const originHeight = h.value
|
||||||
|
|
||||||
|
document.onmousemove = e => {
|
||||||
|
if (!isMouseDown) return
|
||||||
|
|
||||||
|
const moveX = e.pageX - startPageX
|
||||||
|
const moveY = e.pageY - startPageY
|
||||||
|
|
||||||
|
let width = originWidth + moveX
|
||||||
|
let height = originHeight + moveY
|
||||||
|
|
||||||
|
if (width < props.minWidth) width = props.minWidth
|
||||||
|
if (height < props.minHeight) height = props.minHeight
|
||||||
|
if (width > props.maxWidth) width = props.maxWidth
|
||||||
|
if (height > props.maxHeight) height = props.maxHeight
|
||||||
|
|
||||||
|
w.value = width
|
||||||
|
h.value = height
|
||||||
|
}
|
||||||
|
document.onmouseup = () => {
|
||||||
|
isMouseDown = false
|
||||||
|
|
||||||
|
document.onmousemove = null
|
||||||
|
document.onmouseup = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.moveable-panel {
|
||||||
|
position: fixed;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: $boxShadow;
|
||||||
|
border: 1px solid $borderColor;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.resizer {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: se-resize;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
right: -4px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
transform-origin: center;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-left-color: #e1e1e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,203 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="number-input"
|
||||||
|
:class="{
|
||||||
|
'disabled': disabled,
|
||||||
|
'focused': focused,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="prefix">
|
||||||
|
<slot name="prefix"></slot>
|
||||||
|
</span>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:disabled="disabled"
|
||||||
|
v-model="number"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@input="$event => emit('input', $event)"
|
||||||
|
@focus="$event => handleFocus($event)"
|
||||||
|
@blur="$event => handleBlur($event)"
|
||||||
|
@change="$event => emit('change', $event)"
|
||||||
|
@keydown.enter="$event => handleEnter($event)"
|
||||||
|
/>
|
||||||
|
<div class="handlers">
|
||||||
|
<span class="handler" @click="number += step">
|
||||||
|
<svg fill="currentColor" width="1em" height="1em" viewBox="64 64 896 896"><path d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"></path></svg>
|
||||||
|
</span>
|
||||||
|
<span class="handler" @click="number -= step">
|
||||||
|
<svg fill="currentColor" width="1em" height="1em" viewBox="64 64 896 896"><path d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"></path></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="suffix">
|
||||||
|
<slot name="suffix"></slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: number
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
placeholder: '',
|
||||||
|
min: 0,
|
||||||
|
max: Infinity,
|
||||||
|
step: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: number): void
|
||||||
|
(event: 'input', payload: Event): void
|
||||||
|
(event: 'change', payload: Event): void
|
||||||
|
(event: 'blur', payload: Event): void
|
||||||
|
(event: 'focus', payload: Event): void
|
||||||
|
(event: 'enter', payload: Event): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const number = ref(0)
|
||||||
|
const focused = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.value, () => {
|
||||||
|
if (props.value !== number.value) {
|
||||||
|
number.value = props.value
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(number, () => {
|
||||||
|
const value = +number.value
|
||||||
|
if (isNaN(value)) return
|
||||||
|
else if (value > props.max) return
|
||||||
|
else if (value < props.min) return
|
||||||
|
|
||||||
|
number.value = value
|
||||||
|
emit('update:value', number.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkAndEmitValue = () => {
|
||||||
|
let value = +number.value
|
||||||
|
if (isNaN(value)) value = props.min
|
||||||
|
else if (value > props.max) value = props.max
|
||||||
|
else if (value < props.min) value = props.min
|
||||||
|
|
||||||
|
number.value = value
|
||||||
|
emit('update:value', number.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnter = (e: Event) => {
|
||||||
|
checkAndEmitValue()
|
||||||
|
emit('enter', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = (e: Event) => {
|
||||||
|
checkAndEmitValue()
|
||||||
|
focused.value = false
|
||||||
|
emit('blur', e)
|
||||||
|
}
|
||||||
|
const handleFocus = (e: Event) => {
|
||||||
|
focused.value = true
|
||||||
|
emit('focus', e)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.number-input {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
padding: 0 0 0 5px;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
transition: border-color .25s;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
color: $textColor;
|
||||||
|
padding: 0 0 0 5px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
&:not(.disabled) .input-wrap:hover .handlers {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.handlers {
|
||||||
|
width: 20px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 6px;
|
||||||
|
color: #999;
|
||||||
|
opacity: 0;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity .25s;
|
||||||
|
|
||||||
|
.handler {
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-left: 1px solid #d9d9d9;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& + .handler {
|
||||||
|
border-top: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
outline: 0;
|
||||||
|
border: 0;
|
||||||
|
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji';
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disabled):hover, &.focused {
|
||||||
|
border-color: $themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-color: #dcdcdc;
|
||||||
|
color: #b7b7b7;
|
||||||
|
|
||||||
|
input {
|
||||||
|
color: #b7b7b7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix, .suffix {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 30px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,105 @@
|
||||||
|
<template>
|
||||||
|
<div class="popover" :class="{ 'center': center }" ref="triggerRef">
|
||||||
|
<div class="popover-content" :style="contentStyle" ref="contentRef">
|
||||||
|
<slot name="content" v-if="contentVisible"></slot>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type CSSProperties, onMounted, onUnmounted, ref, watch, computed } from 'vue'
|
||||||
|
import tippy, { type Instance, type Placement } from 'tippy.js'
|
||||||
|
|
||||||
|
import 'tippy.js/animations/scale.css'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value?: boolean
|
||||||
|
trigger?: 'click' | 'mouseenter' | 'manual'
|
||||||
|
placement?: Placement
|
||||||
|
appendTo?: HTMLElement | 'parent'
|
||||||
|
contentStyle?: CSSProperties
|
||||||
|
center?: boolean
|
||||||
|
offset?: number
|
||||||
|
}>(), {
|
||||||
|
value: false,
|
||||||
|
trigger: 'click',
|
||||||
|
placement: 'bottom',
|
||||||
|
center: false,
|
||||||
|
offset: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const instance = ref<Instance>()
|
||||||
|
const triggerRef = ref<HTMLElement>()
|
||||||
|
const contentRef = ref<HTMLElement>()
|
||||||
|
const contentVisible = ref(false)
|
||||||
|
|
||||||
|
const contentStyle = computed(() => {
|
||||||
|
return props.contentStyle || {}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.value, () => {
|
||||||
|
if (!instance.value) return
|
||||||
|
if (props.value) instance.value.show()
|
||||||
|
else instance.value.hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (instance.value) instance.value.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
instance.value = tippy(triggerRef.value!, {
|
||||||
|
content: contentRef.value!,
|
||||||
|
allowHTML: true,
|
||||||
|
trigger: props.trigger,
|
||||||
|
placement: props.placement,
|
||||||
|
interactive: true,
|
||||||
|
appendTo: props.appendTo || document.body,
|
||||||
|
maxWidth: 'none',
|
||||||
|
offset: [0, props.offset],
|
||||||
|
duration: 200,
|
||||||
|
animation: 'scale',
|
||||||
|
theme: 'popover',
|
||||||
|
onShow() {
|
||||||
|
contentVisible.value = true
|
||||||
|
},
|
||||||
|
onShown() {
|
||||||
|
if (!props.value) emit('update:value', true)
|
||||||
|
},
|
||||||
|
onHidden() {
|
||||||
|
if (props.value) emit('update:value', false)
|
||||||
|
contentVisible.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.popover.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.popover-content {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid $borderColor;
|
||||||
|
box-shadow: $boxShadow;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.tippy-box[data-theme~='popover'] {
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<div class="popover-menu-item" :class="{ 'center': center }" @click="emit('click')">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
center?: boolean
|
||||||
|
}>(), {
|
||||||
|
center: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'click'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.popover-menu-item {
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
}
|
||||||
|
& + .popover-menu-item {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,26 @@
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:checked="!disabled && _value === value"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="radio"
|
||||||
|
@click="!disabled && updateValue(value)"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { injectKeyRadioGroupValue, type RadioGroupValue } from '../types/injectKey'
|
||||||
|
|
||||||
|
import Button from './Button.vue'
|
||||||
|
|
||||||
|
const { value: _value, updateValue } = inject(injectKeyRadioGroupValue) as RadioGroupValue
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<ButtonGroup class="radio-group">
|
||||||
|
<slot></slot>
|
||||||
|
</ButtonGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, provide } from 'vue'
|
||||||
|
import { injectKeyRadioGroupValue } from '../types/injectKey'
|
||||||
|
|
||||||
|
import ButtonGroup from './ButtonGroup.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const updateValue = (value: string) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('update:value', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = computed(() => props.value)
|
||||||
|
|
||||||
|
provide(injectKeyRadioGroupValue, {
|
||||||
|
value,
|
||||||
|
updateValue,
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,206 @@
|
||||||
|
<template>
|
||||||
|
<div class="select-wrap" v-if="disabled">
|
||||||
|
<div class="select disabled" ref="selectRef">
|
||||||
|
<div class="selector">{{ value }}</div>
|
||||||
|
<div class="icon">
|
||||||
|
<slot name="icon">
|
||||||
|
<IconDown :size="14" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Popover
|
||||||
|
class="select-wrap"
|
||||||
|
trigger="click"
|
||||||
|
v-model:value="popoverVisible"
|
||||||
|
placement="bottom"
|
||||||
|
:contentStyle="{
|
||||||
|
padding: 0,
|
||||||
|
boxShadow: '0 6px 16px 0 rgba(0, 0, 0, 0.08)',
|
||||||
|
}"
|
||||||
|
v-else
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<template v-if="search">
|
||||||
|
<Input ref="searchInputRef" simple :placeholder="searchLabel" v-model:value="searchKey" :style="{ width: width + 2 + 'px' }" />
|
||||||
|
<Divider :margin="0" />
|
||||||
|
</template>
|
||||||
|
<div class="options" :style="{ width: width + 2 + 'px' }">
|
||||||
|
<div class="option"
|
||||||
|
:class="{
|
||||||
|
'disabled': option.disabled,
|
||||||
|
'selected': option.value === value,
|
||||||
|
}"
|
||||||
|
v-for="option in showOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="handleSelect(option)"
|
||||||
|
>{{ option.label }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="select" ref="selectRef">
|
||||||
|
<div class="selector">{{ showLabel }}</div>
|
||||||
|
<div class="icon">
|
||||||
|
<slot name="icon">
|
||||||
|
<IconDown :size="14" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
|
import Popover from './Popover.vue'
|
||||||
|
import Input from './Input.vue'
|
||||||
|
import Divider from './Divider.vue'
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: string | number
|
||||||
|
options: SelectOption[]
|
||||||
|
disabled?: boolean
|
||||||
|
search?: boolean
|
||||||
|
searchLabel?: string
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
search: false,
|
||||||
|
searchLabel: '搜索',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const popoverVisible = ref(false)
|
||||||
|
const selectRef = ref<HTMLElement>()
|
||||||
|
const searchInputRef = ref<InstanceType<typeof Input>>()
|
||||||
|
const width = ref(0)
|
||||||
|
const searchKey = ref('')
|
||||||
|
|
||||||
|
const showLabel = computed(() => {
|
||||||
|
return props.options.find(item => item.value === props.value)?.label || props.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const showOptions = computed(() => {
|
||||||
|
if (!props.search) return props.options
|
||||||
|
if (!searchKey.value.trim()) return props.options
|
||||||
|
const opts = props.options.filter(item => {
|
||||||
|
return item.label.toLowerCase().indexOf(searchKey.value.toLowerCase()) !== -1
|
||||||
|
})
|
||||||
|
return opts.length ? opts : props.options
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(popoverVisible, () => {
|
||||||
|
if (popoverVisible.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
if (searchInputRef.value) searchInputRef.value.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else searchKey.value = ''
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
searchKey.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
if (!selectRef.value) return
|
||||||
|
width.value = selectRef.value.clientWidth
|
||||||
|
}
|
||||||
|
const resizeObserver = new ResizeObserver(updateWidth)
|
||||||
|
onMounted(() => {
|
||||||
|
if (!selectRef.value) return
|
||||||
|
resizeObserver.observe(selectRef.value)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (!selectRef.value) return
|
||||||
|
resizeObserver.unobserve(selectRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelect = (option: SelectOption) => {
|
||||||
|
if (option.disabled) return
|
||||||
|
|
||||||
|
emit('update:value', option.value)
|
||||||
|
popoverVisible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
padding-right: 32px;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
transition: border-color .25s;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:not(.disabled):hover {
|
||||||
|
border-color: $themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-color: #dcdcdc;
|
||||||
|
color: #b7b7b7;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
min-width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
padding-left: 10px;
|
||||||
|
@include ellipsis-oneline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.options {
|
||||||
|
max-height: 260px;
|
||||||
|
padding: 5px;
|
||||||
|
overflow: auto;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.option {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
@include ellipsis-oneline();
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: #b7b7b7;
|
||||||
|
}
|
||||||
|
&:not(.disabled, .selected):hover {
|
||||||
|
background-color: rgba($color: $themeColor, $alpha: .05);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
color: $themeColor;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 30px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,56 @@
|
||||||
|
<template>
|
||||||
|
<div class="select-group">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.select-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
::v-deep(.select-wrap) {
|
||||||
|
.select {
|
||||||
|
border-radius: 0;
|
||||||
|
border-left-width: 0;
|
||||||
|
border-right-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + .select-wrap {
|
||||||
|
.select {
|
||||||
|
border-left-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
& + .select-wrap {
|
||||||
|
.select {
|
||||||
|
border-left-color: $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
.select {
|
||||||
|
border-top-left-radius: $borderRadius;
|
||||||
|
border-bottom-left-radius: $borderRadius;
|
||||||
|
border-left-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
.select {
|
||||||
|
border-top-right-radius: $borderRadius;
|
||||||
|
border-bottom-right-radius: $borderRadius;
|
||||||
|
border-right-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,283 @@
|
||||||
|
<template>
|
||||||
|
<div class="slider" :class="{ 'disabled': disabled }" ref="sliderRef" @mousedown="$event => handleMousedown($event)">
|
||||||
|
<div class="bar">
|
||||||
|
<template v-if="!range">
|
||||||
|
<div class="track" :style="{ width: `${percentage}%` }"></div>
|
||||||
|
<div class="thumb" :style="{ left: `${percentage}%` }" :data-tooltip="tooltipValue"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="track" :style="{ width: `${end - start}%`, left: `${start}%` }"></div>
|
||||||
|
<div class="thumb" :style="{ left: `${start}%` }" :data-tooltip="tooltipRangeStartValue"></div>
|
||||||
|
<div class="thumb" :style="{ left: `${end}%` }" :data-tooltip="tooltipRangeEndValue"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import NP from 'number-precision'
|
||||||
|
|
||||||
|
const getBoundingClientRectViewLeft = (element: HTMLElement) => {
|
||||||
|
return element.getBoundingClientRect().left
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: number | [number, number]
|
||||||
|
disabled?: boolean
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
range?: boolean
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
range: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: number | [number, number]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sliderRef = ref<HTMLElement>()
|
||||||
|
const percentage = ref(0)
|
||||||
|
const start = ref(0)
|
||||||
|
const end = ref(0)
|
||||||
|
const handler = ref<'start' | 'end'>('end')
|
||||||
|
|
||||||
|
const getNewValue = (percentage: number) => {
|
||||||
|
let diff = percentage / 100 * (props.max - props.min)
|
||||||
|
if (props.step >= 1) diff = Math.fround(diff)
|
||||||
|
else {
|
||||||
|
const str = props.step.toString()
|
||||||
|
const match = str.match(/^[0.]*([1-9])/)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const targetNumber = match[1]
|
||||||
|
const position = str.indexOf(targetNumber) - 1
|
||||||
|
if (position > 0) {
|
||||||
|
const accuracy = Math.pow(10, position)
|
||||||
|
diff = Math.fround(diff * accuracy) / accuracy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NP.plus(diff, props.min)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipValue = computed(() => {
|
||||||
|
return getNewValue(percentage.value)
|
||||||
|
})
|
||||||
|
const tooltipRangeStartValue = computed(() => {
|
||||||
|
return getNewValue(start.value)
|
||||||
|
})
|
||||||
|
const tooltipRangeEndValue = computed(() => {
|
||||||
|
return getNewValue(end.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.value, () => {
|
||||||
|
if (props.max === props.min) return
|
||||||
|
if (typeof props.value === 'number') {
|
||||||
|
percentage.value = (props.value - props.min) / (props.max - props.min) * 100
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
start.value = (props.value[0] - props.min) / (props.max - props.min) * 100
|
||||||
|
end.value = (props.value[1] - props.min) / (props.max - props.min) * 100
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPercentage = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!sliderRef.value) return 0
|
||||||
|
const clientX = 'clientX' in e ? e.clientX : e.changedTouches[0].clientX
|
||||||
|
let progress = (clientX - getBoundingClientRectViewLeft(sliderRef.value)) / sliderRef.value.clientWidth
|
||||||
|
progress = Math.max(progress, 0)
|
||||||
|
progress = Math.min(progress, 1)
|
||||||
|
|
||||||
|
let _percentage = progress * 100
|
||||||
|
const step = props.step / (props.max - props.min) * 100
|
||||||
|
const remainder = _percentage % step
|
||||||
|
|
||||||
|
if (remainder > 0) {
|
||||||
|
if (remainder <= step / 2) _percentage = _percentage - remainder
|
||||||
|
else _percentage = _percentage - remainder + step
|
||||||
|
}
|
||||||
|
return _percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双滑块(范围)模式
|
||||||
|
const updateRange = (e: MouseEvent | TouchEvent) => {
|
||||||
|
const value = getPercentage(e)
|
||||||
|
|
||||||
|
if (handler.value === 'start') start.value = value
|
||||||
|
else end.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRangeEnd = (e: MouseEvent | TouchEvent) => {
|
||||||
|
updatePercentage(e)
|
||||||
|
const newValue = getNewValue(percentage.value)
|
||||||
|
const oldValueArr = props.value as [number, number]
|
||||||
|
const newValueArr: [number, number] = handler.value === 'start' ? [newValue, oldValueArr[1]] : [oldValueArr[0], newValue]
|
||||||
|
if (newValueArr[0] > newValueArr[1]) {
|
||||||
|
[newValueArr[0], newValueArr[1]] = [newValueArr[1], newValueArr[0]]
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:value', newValueArr)
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', updateRange)
|
||||||
|
document.removeEventListener('touchmove', updateRange)
|
||||||
|
document.removeEventListener('mouseup', updateRangeEnd)
|
||||||
|
document.removeEventListener('touchend', updateRangeEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单滑块模式
|
||||||
|
const updatePercentage = (e: MouseEvent | TouchEvent) => {
|
||||||
|
percentage.value = getPercentage(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePercentageEnd = (e: MouseEvent | TouchEvent) => {
|
||||||
|
updatePercentage(e)
|
||||||
|
const newValue = getNewValue(percentage.value)
|
||||||
|
|
||||||
|
emit('update:value', newValue)
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', updatePercentage)
|
||||||
|
document.removeEventListener('touchmove', updatePercentage)
|
||||||
|
document.removeEventListener('mouseup', updatePercentageEnd)
|
||||||
|
document.removeEventListener('touchend', updatePercentageEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMousedown = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
|
||||||
|
if (props.range) {
|
||||||
|
const _percentage = getPercentage(e)
|
||||||
|
|
||||||
|
if (Math.abs(_percentage - start.value) < Math.abs(_percentage - end.value)) {
|
||||||
|
handler.value = 'start'
|
||||||
|
}
|
||||||
|
else handler.value = 'end'
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', updateRange)
|
||||||
|
document.addEventListener('touchmove', updateRange)
|
||||||
|
document.addEventListener('mouseup', updateRangeEnd)
|
||||||
|
document.addEventListener('touchend', updateRangeEnd)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
document.addEventListener('mousemove', updatePercentage)
|
||||||
|
document.addEventListener('touchmove', updatePercentage)
|
||||||
|
document.addEventListener('mouseup', updatePercentageEnd)
|
||||||
|
document.addEventListener('touchend', updatePercentageEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
.track {
|
||||||
|
background-color: #b4b4b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
outline: 2px solid #b4b4b4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.slider:not(.disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
&:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
&:hover {
|
||||||
|
background-color: $themeHoverColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
&:hover, &:active {
|
||||||
|
outline: 4px solid $themeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
margin-left: 5px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: $themeColor;
|
||||||
|
transition: background-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
outline: 2px solid $themeColor;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
&::before, &::after {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
min-width: 28px;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 24px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: #262626;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
padding: 6px 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 15px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: #262626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,86 @@
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="switch"
|
||||||
|
:class="{
|
||||||
|
'active': value,
|
||||||
|
'disabled': disabled,
|
||||||
|
}"
|
||||||
|
@click="handleChange()"
|
||||||
|
>
|
||||||
|
<span class="switch-core"></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('update:value', !props.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.switch {
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:not(.disabled).active {
|
||||||
|
.switch-core {
|
||||||
|
border-color: $themeColor;
|
||||||
|
background-color: $themeColor;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: 100%;
|
||||||
|
margin-left: -17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.switch-core::after {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.switch-core {
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #d9d9d9;
|
||||||
|
transition: border-color .3s, background-color .3s;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
border-radius: 100%;
|
||||||
|
transition: all .3s;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,110 @@
|
||||||
|
<template>
|
||||||
|
<div class="tabs"
|
||||||
|
:class="{
|
||||||
|
'card': card,
|
||||||
|
'space-around': spaceAround,
|
||||||
|
'space-between': spaceBetween,
|
||||||
|
}"
|
||||||
|
:style="tabsStyle || {}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'active': tab.key === value }"
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:style="{
|
||||||
|
...(tabStyle || {}),
|
||||||
|
'--color': tab.color,
|
||||||
|
}"
|
||||||
|
@click="emit('update:value', tab.key)"
|
||||||
|
>{{tab.label}}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type CSSProperties } from 'vue'
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
value: string
|
||||||
|
tabs: TabItem[]
|
||||||
|
card?: boolean
|
||||||
|
tabsStyle?: CSSProperties
|
||||||
|
tabStyle?: CSSProperties
|
||||||
|
spaceAround?: boolean
|
||||||
|
spaceBetween?: boolean
|
||||||
|
}>(), {
|
||||||
|
card: false,
|
||||||
|
spaceAround: false,
|
||||||
|
spaceBetween: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: string): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:not(.card) {
|
||||||
|
font-size: 13px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-bottom: 1px solid $borderColor;
|
||||||
|
|
||||||
|
&.space-around {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
&.space-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-bottom: 2px solid var(--color, $themeColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.card {
|
||||||
|
height: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: $lightGray;
|
||||||
|
border-bottom: 1px solid $borderColor;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + .tab {
|
||||||
|
border-left: 1px solid $borderColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,94 @@
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
:class="{
|
||||||
|
'disabled': disabled,
|
||||||
|
'resizable': resizable,
|
||||||
|
}"
|
||||||
|
ref="textareaRef"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="value"
|
||||||
|
:rows="rows"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:style="{
|
||||||
|
padding: padding ? `${padding}px` : '10px',
|
||||||
|
}"
|
||||||
|
@input="$event => handleInput($event)"
|
||||||
|
@focus="$event => emit('focus', $event)"
|
||||||
|
@blur="$event => emit('blur', $event)"
|
||||||
|
></textarea>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
value: string
|
||||||
|
rows?: number
|
||||||
|
padding?: number
|
||||||
|
disabled?: boolean
|
||||||
|
resizable?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}>(), {
|
||||||
|
rows: 4,
|
||||||
|
disabled: false,
|
||||||
|
resizable: false,
|
||||||
|
placeholder: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:value', payload: string): void
|
||||||
|
(event: 'focus', payload: FocusEvent): void
|
||||||
|
(event: 'blur', payload: FocusEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleInput = (e: Event) => {
|
||||||
|
emit('update:value', (e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
|
const focus = () => {
|
||||||
|
if (textareaRef.value) textareaRef.value.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.textarea {
|
||||||
|
outline: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
padding: 10px;
|
||||||
|
transition: border-color .25s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.675;
|
||||||
|
resize: none;
|
||||||
|
font-family: -apple-system,BlinkMacSystemFont, 'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji';
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $themeColor;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.resizable {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-color: #dcdcdc;
|
||||||
|
color: #b7b7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<Button class="text-color-btn">
|
||||||
|
<slot></slot>
|
||||||
|
<div class="text-color-block">
|
||||||
|
<div class="text-color-block-content" :style="{ backgroundColor: color }"></div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Button from './Button.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
color: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.text-color-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.text-color-block {
|
||||||
|
width: 17px;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 1px;
|
||||||
|
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAACdJREFUGFdjfPbs2X8GBgYGSUlJEMXAiCHw//9/sIrnz59DVKALAADNxxVfaiODNQAAAABJRU5ErkJggg==);
|
||||||
|
|
||||||
|
.text-color-block-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,361 @@
|
||||||
|
<template>
|
||||||
|
<div class="writing-board" ref="writingBoardRef">
|
||||||
|
<div class="blackboard" v-if="blackboard"></div>
|
||||||
|
|
||||||
|
<canvas class="canvas" ref="canvasRef"
|
||||||
|
:style="{
|
||||||
|
width: canvasWidth + 'px',
|
||||||
|
height: canvasHeight + 'px',
|
||||||
|
}"
|
||||||
|
@mousedown="$event => 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"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
<template v-if="mouseInCanvas">
|
||||||
|
<div
|
||||||
|
class="eraser"
|
||||||
|
:style="{
|
||||||
|
left: mouse.x - rubberSize / 2 + 'px',
|
||||||
|
top: mouse.y - rubberSize / 2 + 'px',
|
||||||
|
width: rubberSize + 'px',
|
||||||
|
height: rubberSize + 'px',
|
||||||
|
}"
|
||||||
|
v-if="model === 'eraser'"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="pen"
|
||||||
|
:style="{
|
||||||
|
left: mouse.x - penSize / 2 + 'px',
|
||||||
|
top: mouse.y - penSize * 6 + penSize / 2 + 'px',
|
||||||
|
color: color,
|
||||||
|
}"
|
||||||
|
v-if="model === 'pen'"
|
||||||
|
>
|
||||||
|
<IconWrite class="icon" :size="penSize * 6" v-if="model === 'pen'" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pen"
|
||||||
|
:style="{
|
||||||
|
left: mouse.x - markSize / 2 + 'px',
|
||||||
|
top: mouse.y + 'px',
|
||||||
|
color: color,
|
||||||
|
}"
|
||||||
|
v-if="model === 'mark'"
|
||||||
|
>
|
||||||
|
<IconHighLight class="icon" :size="markSize * 1.5" v-if="model === 'mark'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
color?: string
|
||||||
|
model?: 'pen' | 'eraser' | 'mark'
|
||||||
|
blackboard?: boolean
|
||||||
|
penSize?: number
|
||||||
|
markSize?: number
|
||||||
|
rubberSize?: number
|
||||||
|
}>(), {
|
||||||
|
color: '#ffcc00',
|
||||||
|
model: 'pen',
|
||||||
|
blackboard: false,
|
||||||
|
penSize: 6,
|
||||||
|
markSize: 24,
|
||||||
|
rubberSize: 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'end'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null
|
||||||
|
const writingBoardRef = ref<HTMLElement>()
|
||||||
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
|
|
||||||
|
let lastPos = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}
|
||||||
|
let isMouseDown = false
|
||||||
|
let lastTime = 0
|
||||||
|
let lastLineWidth = -1
|
||||||
|
|
||||||
|
// 鼠标位置坐标:用于画笔或橡皮位置跟随
|
||||||
|
const mouse = ref({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 鼠标是否处在画布范围内:处在范围内才会显示画笔或橡皮
|
||||||
|
const mouseInCanvas = ref(false)
|
||||||
|
|
||||||
|
// 监听更新canvas尺寸
|
||||||
|
const canvasWidth = ref(0)
|
||||||
|
const canvasHeight = ref(0)
|
||||||
|
|
||||||
|
const widthScale = computed(() => canvasRef.value ? canvasWidth.value / canvasRef.value.width : 1)
|
||||||
|
const heightScale = computed(() => canvasRef.value ? canvasHeight.value / canvasRef.value.height : 1)
|
||||||
|
|
||||||
|
const updateCanvasSize = () => {
|
||||||
|
if (!writingBoardRef.value) return
|
||||||
|
canvasWidth.value = writingBoardRef.value.clientWidth
|
||||||
|
canvasHeight.value = writingBoardRef.value.clientHeight
|
||||||
|
}
|
||||||
|
const resizeObserver = new ResizeObserver(updateCanvasSize)
|
||||||
|
onMounted(() => {
|
||||||
|
if (writingBoardRef.value) resizeObserver.observe(writingBoardRef.value)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (writingBoardRef.value) resizeObserver.unobserve(writingBoardRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化画布
|
||||||
|
const initCanvas = () => {
|
||||||
|
if (!canvasRef.value || !writingBoardRef.value) return
|
||||||
|
|
||||||
|
ctx = canvasRef.value.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
canvasRef.value.width = writingBoardRef.value.clientWidth
|
||||||
|
canvasRef.value.height = writingBoardRef.value.clientHeight
|
||||||
|
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
}
|
||||||
|
onMounted(initCanvas)
|
||||||
|
|
||||||
|
// 切换画笔模式时,更新 canvas ctx 配置
|
||||||
|
const updateCtx = () => {
|
||||||
|
if (!ctx) return
|
||||||
|
if (props.model === 'mark') {
|
||||||
|
ctx.globalCompositeOperation = 'xor'
|
||||||
|
ctx.globalAlpha = 0.5
|
||||||
|
}
|
||||||
|
else if (props.model === 'pen') {
|
||||||
|
ctx.globalCompositeOperation = 'source-over'
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.model, updateCtx)
|
||||||
|
|
||||||
|
// 绘制画笔墨迹方法
|
||||||
|
const draw = (posX: number, posY: number, lineWidth: number) => {
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const lastPosX = lastPos.x
|
||||||
|
const lastPosY = lastPos.y
|
||||||
|
|
||||||
|
ctx.lineWidth = lineWidth
|
||||||
|
ctx.strokeStyle = props.color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(lastPosX, lastPosY)
|
||||||
|
ctx.lineTo(posX, posY)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.closePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 擦除墨迹方法
|
||||||
|
const erase = (posX: number, posY: number) => {
|
||||||
|
if (!ctx || !canvasRef.value) return
|
||||||
|
const lastPosX = lastPos.x
|
||||||
|
const lastPosY = lastPos.y
|
||||||
|
|
||||||
|
const radius = props.rubberSize / 2
|
||||||
|
|
||||||
|
const sinRadius = radius * Math.sin(Math.atan((posY - lastPosY) / (posX - lastPosX)))
|
||||||
|
const cosRadius = radius * Math.cos(Math.atan((posY - lastPosY) / (posX - lastPosX)))
|
||||||
|
const rectPoint1: [number, number] = [lastPosX + sinRadius, lastPosY - cosRadius]
|
||||||
|
const rectPoint2: [number, number] = [lastPosX - sinRadius, lastPosY + cosRadius]
|
||||||
|
const rectPoint3: [number, number] = [posX + sinRadius, posY - cosRadius]
|
||||||
|
const rectPoint4: [number, number] = [posX - sinRadius, posY + cosRadius]
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(posX, posY, radius, 0, Math.PI * 2)
|
||||||
|
ctx.clip()
|
||||||
|
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(...rectPoint1)
|
||||||
|
ctx.lineTo(...rectPoint3)
|
||||||
|
ctx.lineTo(...rectPoint4)
|
||||||
|
ctx.lineTo(...rectPoint2)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.clip()
|
||||||
|
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算鼠标两次移动之间的距离
|
||||||
|
const getDistance = (posX: number, posY: number) => {
|
||||||
|
const lastPosX = lastPos.x
|
||||||
|
const lastPosY = lastPos.y
|
||||||
|
return Math.sqrt((posX - lastPosX) * (posX - lastPosX) + (posY - lastPosY) * (posY - lastPosY))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据鼠标两次移动之间的距离s和时间t计算绘制速度,速度越快,墨迹越细
|
||||||
|
const getLineWidth = (s: number, t: number) => {
|
||||||
|
const maxV = 10
|
||||||
|
const minV = 0.1
|
||||||
|
const maxWidth = props.penSize
|
||||||
|
const minWidth = 3
|
||||||
|
const v = s / t
|
||||||
|
let lineWidth
|
||||||
|
|
||||||
|
if (v <= minV) lineWidth = maxWidth
|
||||||
|
else if (v >= maxV) lineWidth = minWidth
|
||||||
|
else lineWidth = maxWidth - v / maxV * maxWidth
|
||||||
|
|
||||||
|
if (lastLineWidth === -1) return lineWidth
|
||||||
|
return lineWidth * 1 / 3 + lastLineWidth * 2 / 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径操作
|
||||||
|
const handleMove = (x: number, y: number) => {
|
||||||
|
const time = new Date().getTime()
|
||||||
|
|
||||||
|
if (props.model === 'pen') {
|
||||||
|
const s = getDistance(x, y)
|
||||||
|
const t = time - lastTime
|
||||||
|
const lineWidth = getLineWidth(s, t)
|
||||||
|
|
||||||
|
draw(x, y, lineWidth)
|
||||||
|
lastLineWidth = lineWidth
|
||||||
|
}
|
||||||
|
else if (props.model === 'mark') draw(x, y, props.markSize)
|
||||||
|
else erase(x, y)
|
||||||
|
|
||||||
|
lastPos = { x, y }
|
||||||
|
lastTime = new Date().getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取鼠标在canvas中的相对位置
|
||||||
|
const getMouseOffsetPosition = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!canvasRef.value) return [0, 0]
|
||||||
|
const event = e instanceof MouseEvent ? e : e.changedTouches[0]
|
||||||
|
const canvasRect = canvasRef.value.getBoundingClientRect()
|
||||||
|
const x = event.pageX - canvasRect.x
|
||||||
|
const y = event.pageY - canvasRect.y
|
||||||
|
return [x, y]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理鼠标(触摸)事件
|
||||||
|
// 准备开始绘制/擦除墨迹(落笔)
|
||||||
|
const handleMousedown = (e: MouseEvent | TouchEvent) => {
|
||||||
|
const [mouseX, mouseY] = getMouseOffsetPosition(e)
|
||||||
|
const x = mouseX / widthScale.value
|
||||||
|
const y = mouseY / heightScale.value
|
||||||
|
|
||||||
|
isMouseDown = true
|
||||||
|
lastPos = { x, y }
|
||||||
|
lastTime = new Date().getTime()
|
||||||
|
|
||||||
|
if (!(e instanceof MouseEvent)) {
|
||||||
|
mouse.value = { x: mouseX, y: mouseY }
|
||||||
|
mouseInCanvas.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始绘制/擦除墨迹(移动)
|
||||||
|
const handleMousemove = (e: MouseEvent | TouchEvent) => {
|
||||||
|
const [mouseX, mouseY] = getMouseOffsetPosition(e)
|
||||||
|
const x = mouseX / widthScale.value
|
||||||
|
const y = mouseY / heightScale.value
|
||||||
|
|
||||||
|
mouse.value = { x: mouseX, y: mouseY }
|
||||||
|
|
||||||
|
if (isMouseDown) handleMove(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束绘制/擦除墨迹(停笔)
|
||||||
|
const handleMouseup = () => {
|
||||||
|
if (!isMouseDown) return
|
||||||
|
isMouseDown = false
|
||||||
|
emit('end')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空画布
|
||||||
|
const clearCanvas = () => {
|
||||||
|
if (!ctx || !canvasRef.value) return
|
||||||
|
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||||
|
emit('end')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 DataURL
|
||||||
|
const getImageDataURL = () => {
|
||||||
|
return canvasRef.value?.toDataURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 DataURL(绘制图片到 canvas)
|
||||||
|
const setImageDataURL = (imageDataURL: string) => {
|
||||||
|
if (!ctx || !canvasRef.value) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||||
|
|
||||||
|
if (imageDataURL) {
|
||||||
|
ctx.globalCompositeOperation = 'source-over'
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
img.src = imageDataURL
|
||||||
|
img.onload = () => {
|
||||||
|
ctx!.drawImage(img, 0, 0)
|
||||||
|
updateCtx()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
clearCanvas,
|
||||||
|
getImageDataURL,
|
||||||
|
setImageDataURL,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/styles/variable.scss";
|
||||||
|
@import "../assets/styles/mixin.scss";
|
||||||
|
.writing-board {
|
||||||
|
z-index: 8;
|
||||||
|
cursor: none;
|
||||||
|
@include absolute-0();
|
||||||
|
}
|
||||||
|
.blackboard {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #0f392b;
|
||||||
|
}
|
||||||
|
.canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.eraser, .pen {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
filter: drop-shadow(2px 2px 2px #555);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraser {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid rgba($color: #555, $alpha: .15);
|
||||||
|
color: rgba($color: #555, $alpha: .75);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -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' },
|
||||||
|
]
|
|
@ -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'],
|
||||||
|
]
|
|
@ -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: 20,
|
||||||
|
image: 20,
|
||||||
|
shape: 20,
|
||||||
|
chart: 200,
|
||||||
|
table: 20,
|
||||||
|
video: 250,
|
||||||
|
audio: 20,
|
||||||
|
latex: 20,
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
export const SYS_FONTS = [
|
||||||
|
{ label: 'Arial', value: 'Arial' },
|
||||||
|
{ label: '微软雅黑', value: 'Microsoft Yahei' },
|
||||||
|
{ label: '宋体', value: 'SimSun' },
|
||||||
|
{ label: '黑体', value: 'SimHei' },
|
||||||
|
{ label: '楷体', value: 'KaiTi' },
|
||||||
|
{ label: '新宋体', value: 'NSimSun' },
|
||||||
|
{ label: '仿宋', value: 'FangSong' },
|
||||||
|
{ label: '苹方', value: 'PingFang SC' },
|
||||||
|
{ label: '华文黑体', value: 'STHeiti' },
|
||||||
|
{ label: '华文楷体', value: 'STKaiti' },
|
||||||
|
{ label: '华文宋体', value: 'STSong' },
|
||||||
|
{ label: '华文仿宋', value: 'STFangSong' },
|
||||||
|
{ label: '华文中宋', value: 'STZhongSong' },
|
||||||
|
{ label: '华文琥珀', value: 'STHupo' },
|
||||||
|
{ label: '华文新魏', value: 'STXinwei' },
|
||||||
|
{ label: '华文隶书', value: 'STLiti' },
|
||||||
|
{ label: '华文行楷', value: 'STXingkai' },
|
||||||
|
{ label: '冬青黑体', value: 'Hiragino Sans GB' },
|
||||||
|
{ label: '兰亭黑', value: 'Lantinghei SC' },
|
||||||
|
{ label: '偏偏体', value: 'Hanzipen SC' },
|
||||||
|
{ label: '手札体', value: 'Hannotate SC' },
|
||||||
|
{ label: '宋体', value: 'Songti SC' },
|
||||||
|
{ label: '娃娃体', value: 'Wawati SC' },
|
||||||
|
{ label: '行楷', value: 'Xingkai SC' },
|
||||||
|
{ label: '圆体', value: 'Yuanti SC' },
|
||||||
|
{ label: '华文细黑', value: 'STXihei' },
|
||||||
|
{ label: '幼圆', value: 'YouYuan' },
|
||||||
|
{ label: '隶书', value: 'LiSu' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const WEB_FONTS = [
|
||||||
|
{ label: '得意黑', value: '得意黑' },
|
||||||
|
{ label: '仓耳小丸子', value: '仓耳小丸子' },
|
||||||
|
{ label: '优设标题黑', value: '优设标题黑' },
|
||||||
|
{ label: '峰广明锐体', value: '峰广明锐体' },
|
||||||
|
{ label: '摄图摩登小方体', value: '摄图摩登小方体' },
|
||||||
|
{ label: '站酷快乐体', value: '站酷快乐体' },
|
||||||
|
{ label: '字制区喜脉体', value: '字制区喜脉体' },
|
||||||
|
{ label: '素材集市康康体', value: '素材集市康康体' },
|
||||||
|
{ label: '素材集市酷方体', value: '素材集市酷方体' },
|
||||||
|
{ label: '途牛类圆体', value: '途牛类圆体' },
|
||||||
|
{ label: '锐字真言体', value: '锐字真言体' },
|
||||||
|
]
|
|
@ -0,0 +1,129 @@
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOTKEY_DOC = [
|
||||||
|
{
|
||||||
|
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` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
|
@ -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`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
|
@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { LinePoint } from '../types/slides'
|
||||||
|
|
||||||
|
|
||||||
|
export interface LinePoolItem {
|
||||||
|
path: string
|
||||||
|
style: 'solid' | 'dashed'
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
|
@ -0,0 +1 @@
|
||||||
|
export const LOCALSTORAGE_KEY_DISCARDED_DB = 'PPTIST_DISCARDED_DB'
|
|
@ -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: [
|
||||||
|
'▢', '▣', '▤', '▥', '▦', '▧', '▨', '▩', '▭', '▮', '▯', '▰', '▱', '▲', '▷', '▼', '◁',
|
||||||
|
'◈', '◉', '◍', '◐', '◑', '◒', '◓', '◔', '◕', '◧', '◨', '◩', '◪', '◫', '◬', '◭', '◮',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
|
@ -0,0 +1,93 @@
|
||||||
|
export interface PresetTheme {
|
||||||
|
background: string
|
||||||
|
fontColor: string
|
||||||
|
fontname: string
|
||||||
|
colors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESET_THEMES: PresetTheme[] = [
|
||||||
|
{
|
||||||
|
background: '#ffffff',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4', '#70ad47'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#ffffff',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#83992a', '#3c9670', '#44709d', '#a23b32', '#d87728', '#deb340'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#ffffff',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#e48312', '#bd582c', '#865640', '#9b8357', '#c2bc80', '#94a088'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#ffffff',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#bdc8df', '#003fa9', '#f5ba00', '#ff7567', '#7676d9', '#923ffc'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#ffffff',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#90c225', '#54a121', '#e6b91e', '#e86618', '#c42f19', '#918756'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#ffffff',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#1cade4', '#2683c6', '#27ced7', '#42ba97', '#3e8853', '#62a39f'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#e9efd6',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#a5300f', '#de7e18', '#9f8351', '#728653', '#92aa4c', '#6aac91'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#17444e',
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#b01513', '#ea6312', '#e6b729', '#6bab90', '#55839a', '#9e5d9d'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#36234d',
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#b31166', '#e33d6f', '#e45f3c', '#e9943a', '#9b6bf2', '#d63cd0'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#247fad',
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#052f61', '#a50e82', '#14967c', '#6a9e1f', '#e87d37', '#c62324'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#103f55',
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#40aebd', '#97e8d5', '#a1cf49', '#628f3e', '#f2df3a', '#fcb01c'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#242367',
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#ac3ec1', '#477bd1', '#46b298', '#90ba4c', '#dd9d31', '#e25345'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#e4b75e',
|
||||||
|
fontColor: '#333333',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#f0a22e', '#a5644e', '#b58b80', '#c3986d', '#a19574', '#c17529'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
background: '#333333',
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontname: 'Microsoft Yahei',
|
||||||
|
colors: ['#bdc8df', '#003fa9', '#f5ba00', '#ff7567', '#7676d9', '#923ffc'],
|
||||||
|
},
|
||||||
|
]
|
|
@ -0,0 +1,16 @@
|
||||||
|
interface HTMLElement {
|
||||||
|
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>
|
||||||
|
mozRequestFullScreen(options?: FullscreenOptions): Promise<void>
|
||||||
|
msRequestFullscreen(options?: FullscreenOptions): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
webkitFullscreenElement: Element | null
|
||||||
|
mozFullScreenElement: Element | null
|
||||||
|
msFullscreenElement: Element | null
|
||||||
|
webkitCurrentFullScreenElement: Element | null
|
||||||
|
|
||||||
|
mozCancelFullScreen(): Promise<void>
|
||||||
|
webkitExitFullscreen(): Promise<void>
|
||||||
|
msExitFullscreen(): Promise<void>
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<typeof getElementListRange>
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.themeColor],
|
||||||
|
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.themeColor,
|
||||||
|
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<PPTShapeElement> = {}) => {
|
||||||
|
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.themeColor,
|
||||||
|
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.themeColor,
|
||||||
|
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.themeColor,
|
||||||
|
src,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createImageElement,
|
||||||
|
createChartElement,
|
||||||
|
createTableElement,
|
||||||
|
createTextElement,
|
||||||
|
createShapeElement,
|
||||||
|
createLineElement,
|
||||||
|
createLatexElement,
|
||||||
|
createVideoElement,
|
||||||
|
createAudioElement,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,855 @@
|
||||||
|
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 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,
|
||||||
|
slides: slides.value,
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(json)], { type: '' })
|
||||||
|
saveAs(blob, `${title.value}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化颜色值为 透明度 + HexString,供pptxgenjs使用
|
||||||
|
const formatColor = (_color: string) => {
|
||||||
|
const c = tinycolor(_color)
|
||||||
|
const alpha = c.getAlpha()
|
||||||
|
const color = alpha === 0 ? '#ffffff' : c.setAlpha(1).toHexString()
|
||||||
|
return {
|
||||||
|
alpha,
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormatColor = ReturnType<typeof formatColor>
|
||||||
|
|
||||||
|
// 将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 || 20) * 1.25 }
|
||||||
|
options.paraSpaceBefore = 0.1
|
||||||
|
bulletFlag = false
|
||||||
|
}
|
||||||
|
if (bulletFlag && styleObj['list-type'] === 'ul') {
|
||||||
|
options.bullet = { indent: (options.fontSize || 20) * 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出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 (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) pptxSlide.addNotes(slide.remark)
|
||||||
|
|
||||||
|
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: 20 / 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.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)
|
||||||
|
}
|
||||||
|
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: 20 / 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 (!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.MINUS) {
|
||||||
|
if (disableHotkeys.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
scaleCanvas('-')
|
||||||
|
}
|
||||||
|
if (key === KEYS.EQUAL) {
|
||||||
|
if (disableHotkeys.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
scaleCanvas('+')
|
||||||
|
}
|
||||||
|
if (key === KEYS.DIGIT_0) {
|
||||||
|
if (disableHotkeys.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
resetCanvas()
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,494 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { parse, type Shape, type Element, type ChartItem } 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 message from '../utils/message'
|
||||||
|
import { getSvgPathRange } from '../utils/svgPathParser'
|
||||||
|
import type {
|
||||||
|
Slide,
|
||||||
|
TableCellStyle,
|
||||||
|
TableCell,
|
||||||
|
ChartType,
|
||||||
|
SlideBackground,
|
||||||
|
PPTShapeElement,
|
||||||
|
PPTLineElement,
|
||||||
|
ShapeTextAlign,
|
||||||
|
PPTTextElement,
|
||||||
|
ChartOptions,
|
||||||
|
} from '../types/slides'
|
||||||
|
|
||||||
|
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 { 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)
|
||||||
|
}
|
||||||
|
else if (isEmptySlide.value) slidesStore.setSlides(slides)
|
||||||
|
else addSlidesFromData(slides)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
message.error('无法正确读取 / 解析该文件')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseLineElement = (el: Shape) => {
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入PPTX文件
|
||||||
|
const importPPTXFile = (files: FileList) => {
|
||||||
|
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 => {
|
||||||
|
const json = await parse(e.target!.result as ArrayBuffer)
|
||||||
|
|
||||||
|
const ratio = 96 / 72
|
||||||
|
const width = json.size.width
|
||||||
|
|
||||||
|
slidesStore.setViewportSize(width * ratio)
|
||||||
|
|
||||||
|
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: 'linear',
|
||||||
|
colors: value.colors.map(item => ({
|
||||||
|
...item,
|
||||||
|
pos: parseInt(item.pos),
|
||||||
|
})),
|
||||||
|
rotate: value.rot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
background = {
|
||||||
|
type: 'solid',
|
||||||
|
color: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slide: Slide = {
|
||||||
|
id: nanoid(10),
|
||||||
|
elements: [],
|
||||||
|
background,
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseElements = (elements: Element[]) => {
|
||||||
|
for (const el of elements) {
|
||||||
|
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,
|
||||||
|
style: el.borderType,
|
||||||
|
},
|
||||||
|
fill: el.fillColor,
|
||||||
|
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') {
|
||||||
|
slide.elements.push({
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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.themeColor,
|
||||||
|
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)
|
||||||
|
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 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: el.fillColor || 'none',
|
||||||
|
fixedRatio: false,
|
||||||
|
rotate: el.rotate,
|
||||||
|
outline: {
|
||||||
|
color: el.borderColor,
|
||||||
|
width: el.borderWidth,
|
||||||
|
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 colWidths: number[] = new Array(col).fill(1 / col)
|
||||||
|
|
||||||
|
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: el.borderWidth || 2,
|
||||||
|
style: el.borderType,
|
||||||
|
color: el.borderColor || '#eeece1',
|
||||||
|
},
|
||||||
|
cellMinHeight: 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: [theme.value.themeColor],
|
||||||
|
textColor: theme.value.fontColor,
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
legends,
|
||||||
|
series,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (el.type === 'group' || el.type === 'diagram') {
|
||||||
|
const elements = el.elements.map(_el => ({
|
||||||
|
..._el,
|
||||||
|
left: _el.left + originLeft,
|
||||||
|
top: _el.top + originTop,
|
||||||
|
}))
|
||||||
|
parseElements(elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseElements(item.elements)
|
||||||
|
slides.push(slide)
|
||||||
|
}
|
||||||
|
slidesStore.updateSlideIndex(0)
|
||||||
|
slidesStore.setSlides(slides)
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
importSpecificFile,
|
||||||
|
importPPTXFile,
|
||||||
|
exporting,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<number | null>(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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
// 如果剪贴板内有图片,优先尝试读取图片
|
||||||
|
for (const item of clipboardDataItems) {
|
||||||
|
if (item.kind === 'file' && item.type.indexOf('image') !== -1) {
|
||||||
|
const imageFile = item.getAsFile()
|
||||||
|
if (imageFile) pasteImageFile(imageFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果剪贴板内没有图片,但有文字内容,尝试解析文字内容
|
||||||
|
if (clipboardDataFirstItem.kind === 'string' && clipboardDataFirstItem.type === 'text/plain') {
|
||||||
|
clipboardDataFirstItem.getAsString(text => pasteTextClipboardData(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('paste', pasteListener)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('paste', pasteListener)
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,96 @@
|
||||||
|
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) => {
|
||||||
|
return /^https:\/\/pptist.cn(\/[\w-./?%&=]*)?\.(jpg|jpeg|png|svg|webp)(\?.*)?$/i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<a href="${clipboardData}" title="${clipboardData}" target="_blank">${clipboardData}</a>`)
|
||||||
|
}
|
||||||
|
// 尝试检查是否为SVG代码
|
||||||
|
else if (isSVGString(clipboardData)) {
|
||||||
|
const file = svg2File(clipboardData)
|
||||||
|
getImageDataURL(file).then(dataURL => createImageElement(dataURL))
|
||||||
|
}
|
||||||
|
// 普通文字
|
||||||
|
else {
|
||||||
|
const string = parseText2Paragraphs(clipboardData)
|
||||||
|
createTextElementFromClipboard(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pasteTextClipboardData,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|