mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2026-09-21 05:51:11 +08:00
feat: 元素快捷悬浮菜单功能初步完成
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
/>
|
||||
<ElementFloatLayer
|
||||
:elementList="elementList"
|
||||
:canvasRef="canvasRef"
|
||||
:viewportStyles="viewportStyles"
|
||||
:openLinkDialog="openLinkDialog"
|
||||
/>
|
||||
<ViewportBackground />
|
||||
|
||||
Reference in New Issue
Block a user