ElementPositionPanel.vue 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. <template>
  2. <div class="element-positopn-panel">
  3. <div class="title">层级:</div>
  4. <ButtonGroup class="row">
  5. <Button style="flex: 1" @click="orderElement(handleElement!, ElementOrderCommands.TOP)"><IconSendToBack class="btn-icon" /> 置顶</Button>
  6. <Button style="flex: 1" @click="orderElement(handleElement!, ElementOrderCommands.BOTTOM)"
  7. ><IconBringToFrontOne class="btn-icon" /> 置底</Button
  8. >
  9. </ButtonGroup>
  10. <ButtonGroup class="row">
  11. <Button style="flex: 1" @click="orderElement(handleElement!, ElementOrderCommands.UP)"><IconBringToFront class="btn-icon" /> 上移</Button>
  12. <Button style="flex: 1" @click="orderElement(handleElement!, ElementOrderCommands.DOWN)"><IconSentToBack class="btn-icon" /> 下移</Button>
  13. </ButtonGroup>
  14. <Divider />
  15. <div class="title">对齐:</div>
  16. <ButtonGroup class="row">
  17. <Button style="flex: 1" v-tooltip="'左对齐'" @click="alignElementToCanvas(ElementAlignCommands.LEFT)"><IconAlignLeft /></Button>
  18. <Button style="flex: 1" v-tooltip="'水平居中'" @click="alignElementToCanvas(ElementAlignCommands.HORIZONTAL)"><IconAlignVertically /></Button>
  19. <Button style="flex: 1" v-tooltip="'右对齐'" @click="alignElementToCanvas(ElementAlignCommands.RIGHT)"><IconAlignRight /></Button>
  20. </ButtonGroup>
  21. <ButtonGroup class="row">
  22. <Button style="flex: 1" v-tooltip="'上对齐'" @click="alignElementToCanvas(ElementAlignCommands.TOP)"><IconAlignTop /></Button>
  23. <Button style="flex: 1" v-tooltip="'垂直居中'" @click="alignElementToCanvas(ElementAlignCommands.VERTICAL)"><IconAlignHorizontally /></Button>
  24. <Button style="flex: 1" v-tooltip="'下对齐'" @click="alignElementToCanvas(ElementAlignCommands.BOTTOM)"><IconAlignBottom /></Button>
  25. </ButtonGroup>
  26. <Divider />
  27. <div class="row">
  28. <NumberInput :step="5" :value="left" @update:value="value => updateLeft(value)" style="width: 45%">
  29. <template #prefix> 水平: </template>
  30. </NumberInput>
  31. <div style="width: 10%"></div>
  32. <NumberInput :step="5" :value="top" @update:value="value => updateTop(value)" style="width: 45%">
  33. <template #prefix> 垂直: </template>
  34. </NumberInput>
  35. </div>
  36. <template v-if="handleElement!.type !== 'line' && !(handleElement!.type === 'elf' && handleElement!.subtype === 'elf-enjoy')">
  37. <div class="row">
  38. <NumberInput
  39. :min="minSize"
  40. :max="1920"
  41. :step="5"
  42. :disabled="isVerticalText"
  43. :value="width"
  44. @update:value="value => updateWidth(value)"
  45. style="width: 45%"
  46. >
  47. <template #prefix> 宽度: </template>
  48. </NumberInput>
  49. <template v-if="['image', 'shape'].includes(handleElement!.type) || ['elf-audio'].includes(handleElement!.subtype)">
  50. <IconLock style="width: 10%" class="icon-btn active" v-tooltip="'解除宽高比锁定'" @click="updateFixedRatio(false)" v-if="fixedRatio" />
  51. <IconUnlock style="width: 10%" class="icon-btn" v-tooltip="'宽高比锁定'" @click="updateFixedRatio(true)" v-else />
  52. </template>
  53. <div style="width: 10%" v-else></div>
  54. <NumberInput
  55. :min="minSize"
  56. :max="1080"
  57. :step="5"
  58. :disabled="isHorizontalText || handleElement!.type === 'table'"
  59. :value="height"
  60. @update:value="value => updateHeight(value)"
  61. style="width: 45%"
  62. >
  63. <template #prefix> 高度: </template>
  64. </NumberInput>
  65. </div>
  66. </template>
  67. <template v-if="!['line', 'elf'].includes(handleElement!.type)">
  68. <Divider />
  69. <div class="row">
  70. <NumberInput :min="-180" :max="180" :step="5" :value="rotate" @update:value="value => updateRotate(value)" style="width: 45%">
  71. <template #prefix> 旋转: </template>
  72. </NumberInput>
  73. <div style="width: 7%"></div>
  74. <div class="text-btn" @click="updateRotate45('-')" style="width: 24%"><IconRotate /> -45°</div>
  75. <div class="text-btn" @click="updateRotate45('+')" style="width: 24%"><IconRotate :style="{ transform: 'rotateY(180deg)' }" /> +45°</div>
  76. </div>
  77. </template>
  78. </div>
  79. </template>
  80. <script lang="ts" setup>
  81. import { computed, ref, watch } from "vue"
  82. import { round } from "lodash"
  83. import { storeToRefs } from "pinia"
  84. import { useMainStore, useSlidesStore } from "@/store"
  85. import type { PPTElement } from "@/types/slides"
  86. import { ElementAlignCommands, ElementOrderCommands } from "@/types/edit"
  87. import { MIN_SIZE } from "@/configs/element"
  88. import { SHAPE_PATH_FORMULAS } from "@/configs/shapes"
  89. import useOrderElement from "@/hooks/useOrderElement"
  90. import useAlignElementToCanvas from "@/hooks/useAlignElementToCanvas"
  91. import useHistorySnapshot from "@/hooks/useHistorySnapshot"
  92. import Divider from "@/components/Divider.vue"
  93. import Button from "@/components/Button.vue"
  94. import ButtonGroup from "@/components/ButtonGroup.vue"
  95. import NumberInput from "@/components/NumberInput.vue"
  96. const slidesStore = useSlidesStore()
  97. const { handleElement, handleElementId } = storeToRefs(useMainStore())
  98. const left = ref(0)
  99. const top = ref(0)
  100. const width = ref(0)
  101. const height = ref(0)
  102. const rotate = ref(0)
  103. const fixedRatio = ref(false)
  104. const minSize = computed(() => {
  105. if (!handleElement.value) return 20
  106. return MIN_SIZE[handleElement.value.type] || 20
  107. })
  108. const isHorizontalText = computed(() => {
  109. return handleElement.value?.type === "text" && !handleElement.value.vertical
  110. })
  111. const isVerticalText = computed(() => {
  112. return handleElement.value?.type === "text" && handleElement.value.vertical
  113. })
  114. watch(
  115. handleElement,
  116. () => {
  117. if (!handleElement.value) return
  118. left.value = round(handleElement.value.left, 1)
  119. top.value = round(handleElement.value.top, 1)
  120. fixedRatio.value = "fixedRatio" in handleElement.value && !!handleElement.value.fixedRatio
  121. if (handleElement.value.type !== "line") {
  122. width.value = round(handleElement.value.width, 1)
  123. height.value = round(handleElement.value.height, 1)
  124. rotate.value = "rotate" in handleElement.value && handleElement.value.rotate !== undefined ? round(handleElement.value.rotate, 1) : 0
  125. }
  126. },
  127. { deep: true, immediate: true }
  128. )
  129. const { orderElement } = useOrderElement()
  130. const { alignElementToCanvas } = useAlignElementToCanvas()
  131. const { addHistorySnapshot } = useHistorySnapshot()
  132. // 设置元素位置
  133. const updateLeft = (value: number) => {
  134. const props = { left: value }
  135. slidesStore.updateElement({ id: handleElementId.value, props })
  136. addHistorySnapshot()
  137. }
  138. const updateTop = (value: number) => {
  139. const props = { top: value }
  140. slidesStore.updateElement({ id: handleElementId.value, props })
  141. addHistorySnapshot()
  142. }
  143. // 设置元素宽度、高度、旋转角度
  144. // 对形状设置宽高时,需要检查是否需要更新形状路径
  145. const updateShapePathData = (width: number, height: number) => {
  146. if (handleElement.value && handleElement.value.type === "shape" && "pathFormula" in handleElement.value && handleElement.value.pathFormula) {
  147. const pathFormula = SHAPE_PATH_FORMULAS[handleElement.value.pathFormula]
  148. let path = ""
  149. if ("editable" in pathFormula && pathFormula.editable) path = pathFormula.formula(width, height, handleElement.value.keypoints!)
  150. else path = pathFormula.formula(width, height)
  151. return {
  152. viewBox: [width, height],
  153. path
  154. }
  155. }
  156. return null
  157. }
  158. const updateWidth = (value: number) => {
  159. if (!handleElement.value) return
  160. if (handleElement.value.type === "line" || isVerticalText.value) return
  161. let h = height.value
  162. if (fixedRatio.value) {
  163. const ratio = width.value / height.value
  164. h = value / ratio < minSize.value ? minSize.value : value / ratio
  165. }
  166. let props: Partial<PPTElement> = { width: value, height: h }
  167. const shapePathData = updateShapePathData(value, h)
  168. if (shapePathData) {
  169. props = {
  170. width: value,
  171. height: h,
  172. ...shapePathData
  173. }
  174. }
  175. slidesStore.updateElement({ id: handleElementId.value, props })
  176. addHistorySnapshot()
  177. }
  178. const updateHeight = (value: number) => {
  179. if (!handleElement.value) return
  180. if (handleElement.value.type === "line" || handleElement.value.type === "table" || isHorizontalText.value) return
  181. let w = width.value
  182. if (fixedRatio.value) {
  183. const ratio = width.value / height.value
  184. w = value * ratio < minSize.value ? minSize.value : value * ratio
  185. }
  186. let props: Partial<PPTElement> = { width: w, height: value }
  187. const shapePathData = updateShapePathData(w, value)
  188. if (shapePathData) {
  189. props = {
  190. width: w,
  191. height: value,
  192. ...shapePathData
  193. }
  194. }
  195. slidesStore.updateElement({ id: handleElementId.value, props })
  196. addHistorySnapshot()
  197. }
  198. const updateRotate = (value: number) => {
  199. const props = { rotate: value }
  200. slidesStore.updateElement({ id: handleElementId.value, props })
  201. addHistorySnapshot()
  202. }
  203. // 固定元素的宽高比
  204. const updateFixedRatio = (value: boolean) => {
  205. const props = { fixedRatio: value }
  206. slidesStore.updateElement({ id: handleElementId.value, props })
  207. addHistorySnapshot()
  208. }
  209. // 将元素旋转45度(顺时针或逆时针)
  210. const updateRotate45 = (command: "+" | "-") => {
  211. let _rotate = Math.floor(rotate.value / 45) * 45
  212. if (command === "+") _rotate = _rotate + 45
  213. else if (command === "-") _rotate = _rotate - 45
  214. if (_rotate < -180) _rotate = -180
  215. if (_rotate > 180) _rotate = 180
  216. const props = { rotate: _rotate }
  217. slidesStore.updateElement({ id: handleElementId.value, props })
  218. addHistorySnapshot()
  219. }
  220. </script>
  221. <style lang="scss" scoped>
  222. .row {
  223. width: 100%;
  224. display: flex;
  225. align-items: center;
  226. margin-bottom: 10px;
  227. }
  228. .title {
  229. margin-bottom: 10px;
  230. }
  231. .label {
  232. text-align: center;
  233. }
  234. .btn-icon {
  235. margin-right: 3px;
  236. }
  237. .icon-btn {
  238. cursor: pointer;
  239. &.active {
  240. color: $themeColor;
  241. }
  242. }
  243. .text-btn {
  244. height: 30px;
  245. line-height: 30px;
  246. text-align: center;
  247. cursor: pointer;
  248. &:hover {
  249. background-color: #efefef;
  250. border-radius: $borderRadius;
  251. }
  252. }
  253. </style>