Compare commits

..

3 Commits

328 changed files with 42140 additions and 27 deletions

View File

@ -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"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

@ -0,0 +1,9 @@
$fontList: '仓耳小丸子', '优设标题黑', '字制区喜脉体', '峰广明锐体', '得意黑', '摄图摩登小方体', '站酷快乐体', '素材集市康康体', '素材集市酷方体', '途牛类圆体', '锐字真言体';
@each $font in $fontList {
@font-face {
font-display: swap;
font-family: $font;
src: url('../fonts/#{$font}.woff2') format('woff2');
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
import type { Icons } from '../plugins/icon'
declare module 'vue' {
export type GlobalComponents = Icons
}
export {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { hfmath, CONFIG as hfmathConfig } from 'hfmath'
hfmathConfig.SUB_SUP_SCALE = 0.5
export { hfmath }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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))
}
// st
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>

View File

@ -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' },
]

View File

@ -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'],
]

View File

@ -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,
}

View File

@ -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: '锐字真言体' },
]

View File

@ -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` },
],
},
]

View File

@ -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`
},
},
}

View File

@ -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' },
],
},
]

View File

@ -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 },
],
},
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
export const LOCALSTORAGE_KEY_DISCARDED_DB = 'PPTIST_DISCARDED_DB'

View File

@ -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: [
'▢', '▣', '▤', '▥', '▦', '▧', '▨', '▩', '▭', '▮', '▯', '▰', '▱', '▲', '▷', '▼', '◁',
'◈', '◉', '◍', '◐', '◑', '◒', '◓', '◔', '◕', '◧', '◨', '◩', '◪', '◫', '◬', '◭', '◮',
],
},
]

View File

@ -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'],
},
]

View File

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

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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(/&nbsp;/g, ' ').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/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,
}
}

View File

@ -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)
})
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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)
})
}

View File

@ -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,
}
}

View File

@ -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,
}
}

Some files were not shown because too many files have changed in this diff Show More