feat: 元素快捷悬浮菜单功能初步完成

This commit is contained in:
pipipi-pikachu
2026-05-15 20:01:31 +08:00
parent 013a380961
commit 3081c8f94f
14 changed files with 1193 additions and 42 deletions
@@ -0,0 +1,153 @@
<template>
<Popover
trigger="click"
:placement="submenuPlacement === 'top' ? 'top' : 'bottom'"
:contentStyle="{ width: '240px' }"
>
<template #content>
<div class="border-popover">
<div class="row">
<div class="label">边框样式</div>
<SelectCustom class="control">
<template #options>
<div
class="option"
v-for="item in lineStyleOptions"
:key="item"
@click="updateOutline({ style: item })"
>
<SVGLine :type="item" />
</div>
</template>
<template #label>
<SVGLine :type="(outline?.style || 'solid') as LineStyleType" />
</template>
</SelectCustom>
</div>
<div class="row">
<div class="label">边框颜色</div>
<Popover trigger="click" class="control">
<template #content>
<ColorPicker :modelValue="outline?.color || '#000'" @update:modelValue="value => updateOutline({ color: value })" />
</template>
<ColorButton :color="outline?.color || '#000'" />
</Popover>
</div>
<div class="row">
<div class="label">边框粗细</div>
<NumberInput
class="control"
:value="outline?.width || 0"
@update:value="value => updateOutline({ width: value })"
/>
</div>
</div>
</template>
<div class="toolbar-btn">
<i-icon-park-outline:selected class="icon" />
<span>边框</span>
</div>
</Popover>
</template>
<script lang="ts" setup>
import { computed, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { LineStyleType, PPTElementOutline, PPTShapeElement, PPTTableElement } from '@/types/slides'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import SVGLine from '@/views/Editor/Toolbar/common/SVGLine.vue'
import Popover from '@/components/Popover.vue'
import ColorButton from '@/components/ColorButton.vue'
import ColorPicker from '@/components/ColorPicker/index.vue'
import NumberInput from '@/components/NumberInput.vue'
import SelectCustom from '@/components/SelectCustom.vue'
defineProps<{
submenuPlacement: 'top' | 'bottom'
}>()
const slidesStore = useSlidesStore()
const { theme } = storeToRefs(slidesStore)
const { handleElement } = storeToRefs(useMainStore())
const handleOutlinedElement = handleElement as Ref<PPTShapeElement | PPTTableElement>
const outline = computed<PPTElementOutline | undefined>(() => {
const el = handleOutlinedElement.value
if (!el) return undefined
return el.outline
})
const lineStyleOptions: LineStyleType[] = ['solid', 'dashed', 'dotted']
const { addHistorySnapshot } = useHistorySnapshot()
const updateOutline = (outlineProps: Partial<PPTElementOutline>) => {
if (!handleElement.value) return
const baseOutline = outline.value || theme.value.outline
const newOutline: PPTElementOutline = { ...baseOutline, ...outlineProps }
slidesStore.updateElement({ id: handleElement.value.id, props: { outline: newOutline } })
addHistorySnapshot()
}
</script>
<style lang="scss" scoped>
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
.border-popover {
user-select: none;
}
.row {
display: flex;
align-items: center;
& + .row {
margin-top: 10px;
}
}
.label {
width: 40%;
font-size: 13px;
}
.control {
width: 60%;
}
.option {
height: 32px;
padding: 0 5px;
border-radius: $borderRadius;
&:hover {
background-color: rgba($color: $themeColor, $alpha: .05);
cursor: pointer;
}
}
</style>
@@ -0,0 +1,103 @@
<template>
<div class="toolbar-content">
<button class="toolbar-btn" @click="openDataEditor()">
<i-icon-park-outline:edit class="icon" />
<span>编辑数据</span>
</button>
<Popover trigger="click" :placement="submenuPlacement === 'top' ? 'top' : 'bottom'">
<template #content>
<div class="chart-type-list">
<PopoverMenuItem
center
v-for="item in chartList"
:key="item"
@click="changeChartType(item)"
>{{ CHART_TYPE_MAP[item] }}</PopoverMenuItem>
</div>
</template>
<button class="toolbar-btn">
<i-icon-park-outline:chart-histogram class="icon" />
<span>类型</span>
</button>
</Popover>
</div>
</template>
<script lang="ts" setup>
import { type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSlidesStore, useMainStore } from '@/store'
import type { ChartType, PPTChartElement } from '@/types/slides'
import { CHART_TYPE_MAP } from '@/configs/chart'
import emitter, { EmitterEvents } from '@/utils/emitter'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import Popover from '@/components/Popover.vue'
import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
defineProps<{
elementInfo: PPTChartElement
submenuPlacement: 'top' | 'bottom'
}>()
const slidesStore = useSlidesStore()
const { handleElement, handleElementId } = storeToRefs(useMainStore())
const handleChartElement = handleElement as Ref<PPTChartElement>
const chartList: ChartType[] = ['bar', 'column', 'line', 'area', 'scatter', 'pie', 'ring', 'radar']
const { addHistorySnapshot } = useHistorySnapshot()
const openDataEditor = () => {
emitter.emit(EmitterEvents.OPEN_CHART_DATA_EDITOR)
}
const changeChartType = (type: ChartType) => {
if (!handleChartElement.value || handleChartElement.value.chartType === type) return
slidesStore.updateElement({ id: handleElementId.value, props: { chartType: type } })
addHistorySnapshot()
}
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
.chart-type-list {
width: 120px;
}
</style>
@@ -0,0 +1,111 @@
<template>
<div class="toolbar-content">
<button class="toolbar-btn" @click="clipImage()">
<i-icon-park-outline:tailoring class="icon" />
<span>裁剪</span>
</button>
<FileInput @change="files => replaceImage(files)">
<button class="toolbar-btn">
<i-icon-park-outline:transform class="icon" />
<span>替换</span>
</button>
</FileInput>
</div>
</template>
<script lang="ts" setup>
import { type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTImageElement } from '@/types/slides'
import { getImageDataURL, getImageSize } from '@/utils/image'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import FileInput from '@/components/FileInput.vue'
defineProps<{
elementInfo: PPTImageElement
submenuPlacement: 'top' | 'bottom'
}>()
const mainStore = useMainStore()
const slidesStore = useSlidesStore()
const { handleElement, handleElementId } = storeToRefs(mainStore)
const handleImageElement = handleElement as Ref<PPTImageElement>
const { addHistorySnapshot } = useHistorySnapshot()
const clipImage = () => {
mainStore.setClipingImageElementId(handleElementId.value)
}
const replaceImage = (files: FileList) => {
const imageFile = files[0]
if (!imageFile) return
getImageDataURL(imageFile).then(dataURL => {
const originWidth = handleImageElement.value.width
const originHeight = handleImageElement.value.height
const originLeft = handleImageElement.value.left
const originTop = handleImageElement.value.top
const centerX = originLeft + originWidth / 2
const centerY = originTop + originHeight / 2
getImageSize(dataURL).then(({ width, height }) => {
const h = originHeight
const w = width * (originHeight / height)
const l = centerX - w / 2
const t = centerY - h / 2
slidesStore.removeElementProps({
id: handleElementId.value,
propName: 'clip',
})
slidesStore.updateElement({
id: handleElementId.value,
props: { src: dataURL, width: w, height: h, left: l, top: t },
})
addHistorySnapshot()
})
})
}
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
</style>
@@ -0,0 +1,90 @@
<template>
<div class="toolbar-content">
<button class="toolbar-btn" @click="openLatexEditor()">
<i-icon-park-outline:edit class="icon" />
<span>编辑 LaTeX</span>
</button>
<Popover trigger="click" :placement="submenuPlacement === 'top' ? 'top' : 'bottom'">
<template #content>
<ColorPicker :modelValue="handleLatexElement.color" @update:modelValue="value => updateLatex({ color: value })" />
</template>
<button class="toolbar-btn">
<i-icon-park-outline:platte class="icon" />
<span>颜色</span>
</button>
</Popover>
</div>
</template>
<script lang="ts" setup>
import { type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTLatexElement } from '@/types/slides'
import emitter, { EmitterEvents } from '@/utils/emitter'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import Popover from '@/components/Popover.vue'
import ColorPicker from '@/components/ColorPicker/index.vue'
defineProps<{
elementInfo: PPTLatexElement
submenuPlacement: 'top' | 'bottom'
}>()
const slidesStore = useSlidesStore()
const { handleElement } = storeToRefs(useMainStore())
const handleLatexElement = handleElement as Ref<PPTLatexElement>
const { addHistorySnapshot } = useHistorySnapshot()
const updateLatex = (props: Partial<PPTLatexElement>) => {
if (!handleElement.value) return
slidesStore.updateElement({ id: handleElement.value.id, props })
addHistorySnapshot()
}
const openLatexEditor = () => {
emitter.emit(EmitterEvents.OPEN_LATEX_EDITOR)
}
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
</style>
@@ -0,0 +1,142 @@
<template>
<div class="toolbar-content">
<Popover
trigger="click"
:placement="submenuPlacement === 'top' ? 'top' : 'bottom'"
:contentStyle="{ width: '120px' }"
>
<template #content>
<div class="line-style-list">
<div
class="line-style-item"
:class="{ active: handleLineElement.style === item }"
v-for="item in lineStyleOptions"
:key="item"
@click="updateLine({ style: item })"
>
<SVGLine :type="item" />
</div>
</div>
</template>
<button class="toolbar-btn">
<i-icon-park-outline:connection class="icon" />
<span>样式</span>
</button>
</Popover>
<Popover trigger="click" :placement="submenuPlacement === 'top' ? 'top' : 'bottom'">
<template #content>
<ColorPicker :modelValue="handleLineElement.color" @update:modelValue="value => updateLine({ color: value })" />
</template>
<button class="toolbar-btn">
<i-icon-park-outline:platte class="icon" />
<span>颜色</span>
</button>
</Popover>
<div class="width-slider">
<Slider
:min="1"
:max="12"
:step="1"
:value="handleLineElement.width"
@update:value="value => updateLine({ width: value as number })"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { LineStyleType, PPTLineElement } from '@/types/slides'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import SVGLine from '@/views/Editor/Toolbar/common/SVGLine.vue'
import Popover from '@/components/Popover.vue'
import ColorPicker from '@/components/ColorPicker/index.vue'
import Slider from '@/components/Slider.vue'
defineProps<{
elementInfo: PPTLineElement
submenuPlacement: 'top' | 'bottom'
}>()
const slidesStore = useSlidesStore()
const { handleElement } = storeToRefs(useMainStore())
const handleLineElement = handleElement as Ref<PPTLineElement>
const lineStyleOptions: LineStyleType[] = ['solid', 'dashed', 'dotted']
const { addHistorySnapshot } = useHistorySnapshot()
const updateLine = (props: Partial<PPTLineElement>) => {
if (!handleElement.value) return
slidesStore.updateElement({ id: handleElement.value.id, props })
addHistorySnapshot()
}
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
.line-style-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.line-style-item {
height: 32px;
padding: 0 10px;
display: flex;
align-items: center;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: rgba($color: $themeColor, $alpha: .05);
}
&.active {
color: $themeColor;
}
}
.width-slider {
width: 120px;
padding: 0 8px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,102 @@
<template>
<div class="toolbar-content">
<Popover trigger="click" :placement="submenuPlacement === 'top' ? 'top' : 'bottom'">
<template #content>
<ColorPicker :modelValue="fill" @update:modelValue="value => updateFill(value)" />
</template>
<button class="toolbar-btn">
<i-icon-park-outline:fill class="icon" />
<span>填充</span>
</button>
</Popover>
<BorderPanel :submenuPlacement="submenuPlacement" />
<template v-if="handleShapeElement.text?.content">
<div class="divider"></div>
<TextStyleControls :submenuPlacement="submenuPlacement" />
</template>
</div>
</template>
<script lang="ts" setup>
import { type Ref, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTShapeElement } from '@/types/slides'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import BorderPanel from './BorderPanel.vue'
import TextStyleControls from './TextStyleControls.vue'
import Popover from '@/components/Popover.vue'
import ColorPicker from '@/components/ColorPicker/index.vue'
defineProps<{
elementInfo: PPTShapeElement
submenuPlacement: 'top' | 'bottom'
}>()
const slidesStore = useSlidesStore()
const { handleElement, handleElementId } = storeToRefs(useMainStore())
const handleShapeElement = handleElement as Ref<PPTShapeElement>
const fill = ref('#fff')
watch(handleShapeElement, () => {
if (!handleShapeElement.value || handleShapeElement.value.type !== 'shape') return
fill.value = handleShapeElement.value.fill || '#fff'
}, { deep: true, immediate: true })
const { addHistorySnapshot } = useHistorySnapshot()
const updateFill = (value: string) => {
slidesStore.removeElementProps({ id: handleElementId.value, propName: ['gradient', 'pattern'] })
slidesStore.updateElement({ id: handleElementId.value, props: { fill: value } })
addHistorySnapshot()
}
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
.divider {
width: 1px;
height: 18px;
background-color: $borderColor;
margin: 0 4px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,109 @@
<template>
<div class="toolbar-content">
<Popover trigger="click" :placement="submenuPlacement === 'top' ? 'top' : 'bottom'">
<template #content>
<ColorPicker :modelValue="cellBackcolor" @update:modelValue="value => updateCellBackcolor(value)" />
</template>
<button class="toolbar-btn">
<i-icon-park-outline:fill class="icon" />
<span>填充</span>
</button>
</Popover>
<BorderPanel :submenuPlacement="submenuPlacement" />
</div>
</template>
<script lang="ts" setup>
import { computed, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTTableElement, TableCell, TableCellStyle } from '@/types/slides'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import BorderPanel from './BorderPanel.vue'
import Popover from '@/components/Popover.vue'
import ColorPicker from '@/components/ColorPicker/index.vue'
defineProps<{
elementInfo: PPTTableElement
submenuPlacement: 'top' | 'bottom'
}>()
const slidesStore = useSlidesStore()
const { handleElement, handleElementId, selectedTableCells } = storeToRefs(useMainStore())
const handleTableElement = handleElement as Ref<PPTTableElement>
const cellBackcolor = computed(() => {
const el = handleTableElement.value
if (!el || el.type !== 'table') return ''
let rowIndex = 0
let colIndex = 0
if (selectedTableCells.value.length) {
const selected = selectedTableCells.value[0]
rowIndex = +selected.split('_')[0]
colIndex = +selected.split('_')[1]
}
return el.data[rowIndex]?.[colIndex]?.style?.backcolor || ''
})
const { addHistorySnapshot } = useHistorySnapshot()
const updateCellBackcolor = (backcolor: string) => {
const el = handleTableElement.value
if (!el || el.type !== 'table') return
const data: TableCell[][] = JSON.parse(JSON.stringify(el.data))
const selected = selectedTableCells.value
for (let i = 0; i < data.length; i++) {
for (let j = 0; j < data[i].length; j++) {
if (!selected.length || selected.includes(`${i}_${j}`)) {
const style: TableCellStyle = data[i][j].style || {}
data[i][j].style = { ...style, backcolor }
}
}
}
slidesStore.updateElement({ id: handleElementId.value, props: { data } })
addHistorySnapshot()
}
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
.icon {
flex-shrink: 0;
font-size: 16px;
}
span {
flex-shrink: 0;
font-size: 12px;
margin-left: 5px;
}
}
</style>
@@ -0,0 +1,154 @@
<template>
<Select
class="font-select"
:value="richTextAttrs.fontname"
search
searchLabel="搜索字体"
@update:value="value => emitRichTextCommand('fontname', value as string)"
:options="FONTS"
/>
<Select
class="fontsize-select"
:value="richTextAttrs.fontsize"
search
searchLabel="搜索字号"
@update:value="value => emitRichTextCommand('fontsize', value as string)"
:options="fontSizeOptions.map(item => ({ label: item, value: item }))"
/>
<div class="divider"></div>
<Popover trigger="click" :placement="submenuPlacement === 'top' ? 'top' : 'bottom'">
<template #content>
<ColorPicker :modelValue="richTextAttrs.color" @update:modelValue="value => emitRichTextCommand('color', value)" />
</template>
<button class="toolbar-btn">
<i-icon-park-outline:platte />
</button>
</Popover>
<button
class="toolbar-btn"
:class="{ active: richTextAttrs.bold }"
@click="emitRichTextCommand('bold')"
><i-icon-park-outline:text-bold /></button>
<button
class="toolbar-btn"
:class="{ active: richTextAttrs.em }"
@click="emitRichTextCommand('em')"
><i-icon-park-outline:text-italic /></button>
<button
class="toolbar-btn"
:class="{ active: richTextAttrs.underline }"
@click="emitRichTextCommand('underline')"
><i-icon-park-outline:text-underline /></button>
<div class="divider"></div>
<button
class="toolbar-btn"
:class="{ active: richTextAttrs.align === 'left' }"
@click="emitRichTextCommand('align', 'left')"
><i-icon-park-outline:align-text-left /></button>
<button
class="toolbar-btn"
:class="{ active: richTextAttrs.align === 'center' }"
@click="emitRichTextCommand('align', 'center')"
><i-icon-park-outline:align-text-center /></button>
<button
class="toolbar-btn"
:class="{ active: richTextAttrs.align === 'right' }"
@click="emitRichTextCommand('align', 'right')"
><i-icon-park-outline:align-text-right /></button>
<button
class="toolbar-btn"
:class="{ active: (richTextAttrs.align as string) === 'justify' }"
@click="emitRichTextCommand('align', 'justify')"
><i-icon-park-outline:align-text-both /></button>
<div class="divider"></div>
<button class="toolbar-btn" @click="emitRichTextCommand('clear')">
<i-icon-park-outline:format />
</button>
</template>
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import { useMainStore } from '@/store'
import { FONTS } from '@/configs/font'
import emitter, { EmitterEvents } from '@/utils/emitter'
import Select from '@/components/Select.vue'
import Popover from '@/components/Popover.vue'
import ColorPicker from '@/components/ColorPicker/index.vue'
defineProps<{
submenuPlacement: 'top' | 'bottom'
}>()
const { richTextAttrs } = storeToRefs(useMainStore())
const fontSizeOptions = [
'12px', '14px', '16px', '18px', '20px', '22px', '24px', '28px', '32px',
'36px', '40px', '44px', '48px', '54px', '60px', '66px', '72px', '76px',
'80px', '88px', '96px', '104px', '112px', '120px',
]
const emitRichTextCommand = (command: string, value?: string) => {
emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, { action: { command, value } })
}
</script>
<style lang="scss" scoped>
.toolbar-btn {
min-width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0 5px;
border: 0;
font-size: 16px;
color: $textColor;
background-color: transparent;
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: $lightGray;
}
&.active {
background-color: rgba($color: $themeColor, $alpha: .12);
color: $themeColor;
}
}
.font-select {
width: 110px;
margin-right: 4px;
}
.fontsize-select {
width: 80px;
}
.divider {
width: 1px;
height: 18px;
background-color: $borderColor;
margin: 0 4px;
flex-shrink: 0;
}
::v-deep(.select) {
height: 28px;
.selector {
height: 26px;
line-height: 26px;
}
.icon {
height: 26px;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<div class="toolbar-content">
<TextStyleControls :submenuPlacement="submenuPlacement" />
</div>
</template>
<script lang="ts" setup>
import type { PPTTextElement } from '@/types/slides'
import TextStyleControls from './TextStyleControls.vue'
defineProps<{
elementInfo: PPTTextElement
submenuPlacement: 'top' | 'bottom'
}>()
</script>
<style lang="scss" scoped>
.toolbar-content {
width: max-content;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
}
</style>
@@ -0,0 +1,71 @@
<template>
<div ref="toolbarRef" class="floating-toolbar" :style="toolbarStyle" @mousedown.stop>
<component
:is="currentToolbarComponent"
:elementInfo="elementInfo"
:submenuPlacement="submenuPlacement"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, nextTick, type Component } from 'vue'
import {
ElementTypes,
type PPTElement,
} from '@/types/slides'
import TextToolbar from './TextToolbar.vue'
import ImageToolbar from './ImageToolbar.vue'
import ShapeToolbar from './ShapeToolbar.vue'
import TableToolbar from './TableToolbar.vue'
import LineToolbar from './LineToolbar.vue'
import LatexToolbar from './LatexToolbar.vue'
import ChartToolbar from './ChartToolbar.vue'
const props = defineProps<{
elementInfo: PPTElement
toolbarStyle: Record<string, string>
submenuPlacement: 'top' | 'bottom'
}>()
const emit = defineEmits<{
(e: 'measure', width: number): void
}>()
const toolbarRef = ref<HTMLElement | null>(null)
const currentToolbarComponent = computed<unknown>(() => {
const toolbarComponentMap: Partial<Record<ElementTypes, Component>> = {
[ElementTypes.TEXT]: TextToolbar,
[ElementTypes.IMAGE]: ImageToolbar,
[ElementTypes.SHAPE]: ShapeToolbar,
[ElementTypes.TABLE]: TableToolbar,
[ElementTypes.LINE]: LineToolbar,
[ElementTypes.CHART]: ChartToolbar,
[ElementTypes.LATEX]: LatexToolbar,
}
return toolbarComponentMap[props.elementInfo.type]
})
onMounted(() => {
nextTick(() => {
if (toolbarRef.value) emit('measure', toolbarRef.value.clientWidth)
})
})
</script>
<style lang="scss" scoped>
.floating-toolbar {
position: absolute;
width: max-content;
height: 40px;
padding: 0 5px;
background-color: #fff;
border: 1px solid rgba($color: #d9d9d9, $alpha: .2);
box-shadow: $boxShadow;
border-radius: $borderRadius;
display: flex;
align-items: center;
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="link-handler" :style="linkHandlerStyle">
<div class="link-handler" :style="handlerStyle">
<a class="link" v-if="elementInfo.link?.type === 'web'" :href="elementInfo.link.target" target="_blank">{{elementInfo.link.target}}</a>
<a class="link" v-else-if="elementInfo.link" @click="turnTarget(elementInfo.link.target)">幻灯片页面 {{elementInfo.link.target}}</a>
<div class="btns">
@@ -11,34 +11,23 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTElement } from '@/types/slides'
import type { getElementRange } from '@/utils/element'
import useLink from '@/hooks/useLink'
import Divider from '@/components/Divider.vue'
const props = defineProps<{
defineProps<{
elementInfo: PPTElement
range: ReturnType<typeof getElementRange>
handlerStyle: Record<string, string>
openLinkDialog: () => void
}>()
const mainStore = useMainStore()
const slidesStore = useSlidesStore()
const { canvasScale } = storeToRefs(mainStore)
const { slides } = storeToRefs(slidesStore)
const { removeLink } = useLink()
const linkHandlerStyle = computed(() => {
const { minX, maxY } = props.range
return {
left: minX * canvasScale.value + 'px',
top: maxY * canvasScale.value + 10 + 'px',
}
})
const turnTarget = (slideId: string) => {
const targetIndex = slides.value.findIndex(item => item.id === slideId)
if (targetIndex !== -1) {
@@ -59,6 +48,7 @@ const turnTarget = (slideId: string) => {
display: flex;
align-items: center;
color: $themeColor;
border-radius: $borderRadius;
}
.link {
max-width: 300px;
@@ -1,41 +1,75 @@
<template>
<div class="element-float-layer">
<template v-for="item in elementFloatItems" :key="item.element.id">
<AnimationIndex
v-if="item.animationIndexList.length"
:elementInfo="item.element"
:range="item.range"
:indexList="item.animationIndexList"
/>
<AnimationIndex
v-for="item in animationIndexItems"
:key="item.element.id"
:elementInfo="item.element"
:range="item.range"
:indexList="item.animationIndexList"
/>
<LinkHandler
v-if="handleElementId === item.element.id && item.element.link"
:elementInfo="item.element"
:range="item.range"
:openLinkDialog="openLinkDialog"
@mousedown.stop
/>
</template>
<FloatingToolbar
v-if="floatingToolbar"
:key="floatingToolbar.element.id"
:elementInfo="floatingToolbar.element"
:toolbarStyle="floatingToolbar.toolbarStyle"
:submenuPlacement="floatingToolbar.submenuPlacement"
@measure="value => floatingToolbarWidth = value"
/>
<LinkHandler
v-if="linkHandler"
:elementInfo="linkHandler.element"
:handlerStyle="linkHandler.handlerStyle"
:openLinkDialog="openLinkDialog"
@mousedown.stop
/>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTElement } from '@/types/slides'
import { ElementTypes, type PPTElement } from '@/types/slides'
import type useViewportSize from '../hooks/useViewportSize'
import { getElementRange } from '@/utils/element'
import AnimationIndex from './AnimationIndex.vue'
import LinkHandler from './LinkHandler.vue'
import FloatingToolbar from './FloatingToolbar/index.vue'
const props = defineProps<{
elementList: PPTElement[]
canvasRef: HTMLElement | null
viewportStyles: ReturnType<typeof useViewportSize>['viewportStyles']['value']
openLinkDialog: () => void
}>()
const { handleElementId, hiddenElementIdList, toolbarState } = storeToRefs(useMainStore())
const FLOAT_LAYER_GAP = 10
const FLOATING_TOOLBAR_HEIGHT = 40
const LINK_HANDLER_HEIGHT = 30
const ROTATE_HANDLER_RESERVED_GAP = 40
const TOOLBAR_ELEMENT_TYPES: string[] = [
ElementTypes.TEXT,
ElementTypes.IMAGE,
ElementTypes.SHAPE,
ElementTypes.TABLE,
ElementTypes.LINE,
ElementTypes.CHART,
ElementTypes.LATEX,
]
const ROTATE_HANDLER_ELEMENT_TYPES: string[] = [
ElementTypes.TEXT,
ElementTypes.IMAGE,
ElementTypes.SHAPE,
ElementTypes.TABLE,
ElementTypes.LATEX,
]
const { activeElementIdList, activeGroupElementId, canvasScale, handleElementId, hiddenElementIdList, toolbarState } = storeToRefs(useMainStore())
const { formatedAnimations } = storeToRefs(useSlidesStore())
const floatingToolbarWidth = ref(100)
const getAnimationIndexList = (element: PPTElement) => {
const indexList = []
@@ -46,25 +80,95 @@ const getAnimationIndexList = (element: PPTElement) => {
return indexList
}
const elementFloatItems = computed(() => {
const animationIndexItems = computed(() => {
const items = []
for (const element of props.elementList) {
if (hiddenElementIdList.value.includes(element.id)) continue
const animationIndexList = toolbarState.value === 'elAnimation' ? getAnimationIndexList(element) : []
const showLinkHandler = handleElementId.value === element.id && !!element.link
if (!animationIndexList.length && !showLinkHandler) continue
if (!animationIndexList.length) continue
const range = getElementRange(element)
items.push({
element,
range: getElementRange(element),
range,
animationIndexList,
})
}
return items
})
const floatingToolbarTarget = computed(() => {
const targetId = activeGroupElementId.value || (activeElementIdList.value.length === 1 ? activeElementIdList.value[0] : '')
if (!targetId || hiddenElementIdList.value.includes(targetId)) return null
const element = props.elementList.find(element => element.id === targetId) || null
if (!element || !TOOLBAR_ELEMENT_TYPES.includes(element.type)) return null
return element
})
const floatingToolbar = computed(() => {
const element = floatingToolbarTarget.value
if (!element) return null
const range = getElementRange(element)
const showLinkHandler = handleElementId.value === element.id && !!element.link
const canvasWidth = props.canvasRef?.clientWidth || 0
const canvasHeight = props.canvasRef?.clientHeight || 0
const availableTop = -props.viewportStyles.top
const availableBottom = canvasHeight - props.viewportStyles.top
const availableLeft = -props.viewportStyles.left
const availableRight = canvasWidth - props.viewportStyles.left
const minLeft = availableLeft + FLOAT_LAYER_GAP
const maxLeft = availableRight - floatingToolbarWidth.value - FLOAT_LAYER_GAP
const bottomTop = range.maxY * canvasScale.value + FLOAT_LAYER_GAP
const bottomHeight = FLOATING_TOOLBAR_HEIGHT + (showLinkHandler ? FLOAT_LAYER_GAP + LINK_HANDLER_HEIGHT : 0)
const placement: 'top' | 'bottom' = canvasHeight && bottomTop + bottomHeight > availableBottom ? 'top' : 'bottom'
const rotateHandlerGap = ROTATE_HANDLER_ELEMENT_TYPES.includes(element.type) ? ROTATE_HANDLER_RESERVED_GAP : FLOAT_LAYER_GAP
const left = range.minX * canvasScale.value
const toolbarLeft = canvasWidth ? (maxLeft < minLeft ? minLeft : Math.min(Math.max(left, minLeft), maxLeft)) : left
const top = placement === 'bottom' ? bottomTop : range.minY * canvasScale.value - rotateHandlerGap - FLOATING_TOOLBAR_HEIGHT
const toolbarTop = Math.max(availableTop + FLOAT_LAYER_GAP, top)
const submenuPlacement: 'top' | 'bottom' = canvasHeight && toolbarTop + FLOATING_TOOLBAR_HEIGHT / 2 > (availableTop + availableBottom) / 2 ? 'top' : 'bottom'
return {
element,
range,
placement,
toolbarStyle: {
left: toolbarLeft + 'px',
top: toolbarTop + 'px',
},
submenuPlacement,
}
})
const linkHandler = computed(() => {
const element = handleElementId.value ? props.elementList.find(item => item.id === handleElementId.value) || null : null
if (!element || !element.link || hiddenElementIdList.value.includes(element.id)) return null
const range = getElementRange(element)
const canvasWidth = props.canvasRef?.clientWidth || 0
const availableLeft = -props.viewportStyles.left
const availableRight = canvasWidth - props.viewportStyles.left
const minLeft = availableLeft + FLOAT_LAYER_GAP
const maxLeft = availableRight - floatingToolbarWidth.value - FLOAT_LAYER_GAP
const left = range.minX * canvasScale.value
const toolbarLeft = canvasWidth ? (maxLeft < minLeft ? minLeft : Math.min(Math.max(left, minLeft), maxLeft)) : left
const toolbarBottom = floatingToolbar.value && floatingToolbar.value.element.id === element.id && floatingToolbar.value.placement === 'bottom'
const top = range.maxY * canvasScale.value + FLOAT_LAYER_GAP + (toolbarBottom ? FLOATING_TOOLBAR_HEIGHT + FLOAT_LAYER_GAP : 0)
return {
element,
handlerStyle: {
left: toolbarLeft + 'px',
top: top + 'px',
},
}
})
</script>
<style lang="scss" scoped>
+2
View File
@@ -56,6 +56,8 @@
/>
<ElementFloatLayer
:elementList="elementList"
:canvasRef="canvasRef"
:viewportStyles="viewportStyles"
:openLinkDialog="openLinkDialog"
/>
<ViewportBackground />