ShapeStylePanel.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <template>
  2. <div class="shape-style-panel">
  3. <div class="title">
  4. <span>{{ lang.ssClickReplaceShape }}</span>
  5. <IconDown />
  6. </div>
  7. <div class="shape-pool">
  8. <div class="category" v-for="item in SHAPE_LIST" :key="item.type">
  9. <div class="shape-list">
  10. <ShapeItemThumbnail
  11. class="shape-item"
  12. v-for="(shape, index) in item.children"
  13. :key="index"
  14. :shape="shape"
  15. @click="changeShape(shape)"
  16. />
  17. </div>
  18. </div>
  19. </div>
  20. <div class="row">
  21. <Select
  22. style="flex: 1;"
  23. :value="fillType"
  24. @update:value="value => updateFillType(value as 'fill' | 'gradient' | 'pattern')"
  25. :options="[
  26. { label: lang.ssSolidFill, value: 'fill' },
  27. { label: lang.ssGradFill, value: 'gradient' },
  28. { label: lang.ssImgFill, value: 'pattern' },
  29. ]"
  30. />
  31. <div style="width: 10px;" v-if="fillType !== 'pattern'"></div>
  32. <Popover trigger="click" v-if="fillType === 'fill'" style="flex: 1;">
  33. <template #content>
  34. <ColorPicker
  35. :modelValue="fill"
  36. @update:modelValue="value => updateFill(value)"
  37. />
  38. </template>
  39. <ColorButton :color="fill" />
  40. </Popover>
  41. <Select
  42. style="flex: 1;"
  43. :value="gradient.type"
  44. @update:value="value => updateGradient({ type: value as GradientType })"
  45. v-else-if="fillType === 'gradient'"
  46. :options="[
  47. { label: lang.ssLinearGrad, value: 'linear' },
  48. { label: lang.ssRadialGrad, value: 'radial' },
  49. ]"
  50. />
  51. </div>
  52. <template v-if="fillType === 'gradient'">
  53. <div class="row">
  54. <GradientBar
  55. :value="gradient.colors"
  56. :index="currentGradientIndex"
  57. @update:value="value => updateGradient({ colors: value })"
  58. @update:index="index => currentGradientIndex = index"
  59. />
  60. </div>
  61. <div class="row">
  62. <div style="width: 40%;">{{ lang.ssCurColorBlock }}</div>
  63. <Popover trigger="click" style="width: 60%;">
  64. <template #content>
  65. <ColorPicker
  66. :modelValue="gradient.colors[currentGradientIndex].color"
  67. @update:modelValue="value => updateGradientColors(value)"
  68. />
  69. </template>
  70. <ColorButton :color="gradient.colors[currentGradientIndex].color" />
  71. </Popover>
  72. </div>
  73. <div class="row" v-if="gradient.type === 'linear'">
  74. <div style="width: 40%;">{{ lang.ssGradAngle }}</div>
  75. <Slider
  76. style="width: 60%;"
  77. :min="0"
  78. :max="360"
  79. :step="15"
  80. :value="gradient.rotate"
  81. @update:value="value => updateGradient({ rotate: value as number })"
  82. />
  83. </div>
  84. </template>
  85. <template v-if="fillType === 'pattern'">
  86. <div class="pattern-image-wrapper">
  87. <FileInput @change="files => uploadPattern(files)">
  88. <div class="pattern-image">
  89. <div class="content" :style="{ backgroundImage: `url(${pattern})` }">
  90. <IconPlus />
  91. </div>
  92. </div>
  93. </FileInput>
  94. </div>
  95. </template>
  96. <ElementFlip />
  97. <Divider />
  98. <template v-if="handleShapeElement.text?.content">
  99. <RichTextBase />
  100. <Divider />
  101. <RadioGroup
  102. class="row"
  103. button-style="solid"
  104. :value="textAlign"
  105. @update:value="value => updateTextAlign(value as 'top' | 'middle' | 'bottom')"
  106. >
  107. <RadioButton value="top" v-tooltip="lang.ssAlignTop" style="flex: 1;"><IconAlignTextTopOne /></RadioButton>
  108. <RadioButton value="middle" v-tooltip="lang.ssAlignMiddle" style="flex: 1;"><IconAlignTextMiddleOne /></RadioButton>
  109. <RadioButton value="bottom" v-tooltip="lang.ssAlignBottom" style="flex: 1;"><IconAlignTextBottomOne /></RadioButton>
  110. </RadioGroup>
  111. <Divider />
  112. </template>
  113. <ElementOutline />
  114. <Divider />
  115. <ElementShadow />
  116. <Divider />
  117. <ElementOpacity />
  118. <Divider />
  119. <div class="row">
  120. <CheckboxButton
  121. v-tooltip="lang.ssFormatPainter"
  122. style="flex: 1;"
  123. :checked="!!shapeFormatPainter"
  124. @click="toggleShapeFormatPainter()"
  125. @dblclick="toggleShapeFormatPainter(true)"
  126. ><IconFormatBrush /> {{ lang.ssShapePainter }}</CheckboxButton>
  127. </div>
  128. </div>
  129. </template>
  130. <script lang="ts" setup>
  131. import { type Ref, ref, watch } from 'vue'
  132. import { storeToRefs } from 'pinia'
  133. import { useMainStore, useSlidesStore } from '@/store'
  134. import type { GradientType, PPTShapeElement, Gradient, ShapeText } from '@/types/slides'
  135. import { type ShapePoolItem, SHAPE_LIST, SHAPE_PATH_FORMULAS } from '@/configs/shapes'
  136. import { getImageDataURL } from '@/utils/image'
  137. import emitter, { EmitterEvents } from '@/utils/emitter'
  138. import useHistorySnapshot from '@/hooks/useHistorySnapshot'
  139. import useShapeFormatPainter from '@/hooks/useShapeFormatPainter'
  140. import { lang } from '@/main'
  141. import ElementOpacity from '../common/ElementOpacity.vue'
  142. import ElementOutline from '../common/ElementOutline.vue'
  143. import ElementShadow from '../common/ElementShadow.vue'
  144. import ElementFlip from '../common/ElementFlip.vue'
  145. import RichTextBase from '../common/RichTextBase.vue'
  146. import ShapeItemThumbnail from '@/views/Editor/CanvasTool/ShapeItemThumbnail.vue'
  147. import ColorButton from '@/components/ColorButton.vue'
  148. import CheckboxButton from '@/components/CheckboxButton.vue'
  149. import ColorPicker from '@/components/ColorPicker/index.vue'
  150. import Divider from '@/components/Divider.vue'
  151. import Slider from '@/components/Slider.vue'
  152. import RadioButton from '@/components/RadioButton.vue'
  153. import RadioGroup from '@/components/RadioGroup.vue'
  154. import Select from '@/components/Select.vue'
  155. import Popover from '@/components/Popover.vue'
  156. import GradientBar from '@/components/GradientBar.vue'
  157. import FileInput from '@/components/FileInput.vue'
  158. const mainStore = useMainStore()
  159. const slidesStore = useSlidesStore()
  160. const { handleElement, handleElementId, shapeFormatPainter } = storeToRefs(mainStore)
  161. const handleShapeElement = handleElement as Ref<PPTShapeElement>
  162. const fill = ref<string>('#000')
  163. const pattern = ref<string>('')
  164. const gradient = ref<Gradient>({
  165. type: 'linear',
  166. rotate: 0,
  167. colors: [
  168. { pos: 0, color: '#fff' },
  169. { pos: 100, color: '#fff' },
  170. ],
  171. })
  172. const fillType = ref('fill')
  173. const textAlign = ref('middle')
  174. const currentGradientIndex = ref(0)
  175. watch(handleElement, () => {
  176. if (!handleElement.value || handleElement.value.type !== 'shape') return
  177. fill.value = handleElement.value.fill || '#fff'
  178. const defaultGradientColor = [
  179. { pos: 0, color: fill.value },
  180. { pos: 100, color: '#fff' },
  181. ]
  182. gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, colors: defaultGradientColor }
  183. pattern.value = handleElement.value.pattern || ''
  184. fillType.value = (handleElement.value.pattern !== undefined) ? 'pattern' : (handleElement.value.gradient ? 'gradient' : 'fill')
  185. textAlign.value = handleElement.value?.text?.align || 'middle'
  186. if (handleElement.value.text?.content) {
  187. emitter.emit(EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE)
  188. }
  189. }, { deep: true, immediate: true })
  190. watch(handleElementId, () => {
  191. currentGradientIndex.value = 0
  192. })
  193. const { addHistorySnapshot } = useHistorySnapshot()
  194. const { toggleShapeFormatPainter } = useShapeFormatPainter()
  195. const updateElement = (props: Partial<PPTShapeElement>) => {
  196. slidesStore.updateElement({ id: handleElementId.value, props })
  197. addHistorySnapshot()
  198. }
  199. // 设置填充类型:渐变、纯色
  200. const updateFillType = (type: 'gradient' | 'fill' | 'pattern') => {
  201. if (type === 'fill') {
  202. slidesStore.removeElementProps({ id: handleElementId.value, propName: ['gradient', 'pattern'] })
  203. addHistorySnapshot()
  204. }
  205. else if (type === 'gradient') {
  206. currentGradientIndex.value = 0
  207. slidesStore.removeElementProps({ id: handleElementId.value, propName: 'pattern' })
  208. updateElement({ gradient: gradient.value })
  209. }
  210. else if (type === 'pattern') {
  211. slidesStore.removeElementProps({ id: handleElementId.value, propName: 'gradient' })
  212. updateElement({ pattern: '' })
  213. }
  214. }
  215. // 设置渐变填充
  216. const updateGradient = (gradientProps: Partial<Gradient>) => {
  217. if (!gradient.value) return
  218. const _gradient = { ...gradient.value, ...gradientProps }
  219. updateElement({ gradient: _gradient })
  220. }
  221. const updateGradientColors = (color: string) => {
  222. const colors = gradient.value.colors.map((item, index) => {
  223. if (index === currentGradientIndex.value) return { ...item, color }
  224. return item
  225. })
  226. updateGradient({ colors })
  227. }
  228. // 上传填充图片
  229. const uploadPattern = (files: FileList) => {
  230. const imageFile = files[0]
  231. if (!imageFile) return
  232. getImageDataURL(imageFile).then(dataURL => {
  233. pattern.value = dataURL
  234. updateElement({ pattern: dataURL })
  235. })
  236. }
  237. // 设置填充色
  238. const updateFill = (value: string) => {
  239. updateElement({ fill: value })
  240. }
  241. // 修改形状
  242. const changeShape = (shape: ShapePoolItem) => {
  243. const { width, height } = handleElement.value as PPTShapeElement
  244. const props: Partial<PPTShapeElement> = {
  245. viewBox: shape.viewBox,
  246. path: shape.path,
  247. special: shape.special,
  248. }
  249. if (shape.pathFormula) {
  250. props.pathFormula = shape.pathFormula
  251. props.viewBox = [width, height]
  252. const pathFormula = SHAPE_PATH_FORMULAS[shape.pathFormula]
  253. if ('editable' in pathFormula) {
  254. props.path = pathFormula.formula(width, height, pathFormula.defaultValue)
  255. props.keypoints = pathFormula.defaultValue
  256. }
  257. else props.path = pathFormula.formula(width, height)
  258. }
  259. else {
  260. props.pathFormula = undefined
  261. props.keypoints = undefined
  262. }
  263. updateElement(props)
  264. }
  265. const updateTextAlign = (align: 'top' | 'middle' | 'bottom') => {
  266. const _handleElement = handleElement.value as PPTShapeElement
  267. const defaultText: ShapeText = {
  268. content: '',
  269. defaultFontName: '',
  270. defaultColor: '#000',
  271. align: 'middle',
  272. }
  273. const _text = _handleElement.text || defaultText
  274. updateElement({ text: { ..._text, align } })
  275. }
  276. </script>
  277. <style lang="scss" scoped>
  278. .shape-style-panel {
  279. user-select: none;
  280. }
  281. .row {
  282. width: 100%;
  283. display: flex;
  284. align-items: center;
  285. margin-bottom: 10px;
  286. }
  287. .font-size-btn {
  288. padding: 0;
  289. }
  290. .title {
  291. display: flex;
  292. justify-content: space-between;
  293. align-items: center;
  294. margin-bottom: 10px;
  295. }
  296. .shape-pool {
  297. width: 235px;
  298. height: 150px;
  299. overflow: auto;
  300. padding: 5px;
  301. padding-right: 10px;
  302. border: 1px solid $borderColor;
  303. margin-bottom: 20px;
  304. }
  305. .shape-list {
  306. @include flex-grid-layout();
  307. }
  308. .shape-item {
  309. @include flex-grid-layout-children(6, 14%);
  310. height: 0;
  311. padding-bottom: 14%;
  312. flex-shrink: 0;
  313. }
  314. .pattern-image-wrapper {
  315. margin-bottom: 10px;
  316. }
  317. .pattern-image {
  318. height: 0;
  319. padding-bottom: 56.25%;
  320. border: 1px dashed $borderColor;
  321. border-radius: $borderRadius;
  322. position: relative;
  323. transition: all $transitionDelay;
  324. &:hover {
  325. border-color: $themeColor;
  326. color: $themeColor;
  327. }
  328. .content {
  329. @include absolute-0();
  330. display: flex;
  331. justify-content: center;
  332. align-items: center;
  333. background-position: center;
  334. background-size: contain;
  335. background-repeat: no-repeat;
  336. cursor: pointer;
  337. }
  338. }
  339. </style>