ImageStylePanel.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <template>
  2. <div class="image-style-panel">
  3. <div
  4. class="origin-image"
  5. :style="{ backgroundImage: `url(${handleElement.src})` }"
  6. ></div>
  7. <ButtonGroup class="row">
  8. <Button style="flex: 5;" @click="clipImage()"><IconTailoring class="btn-icon" /> 裁剪图片</Button>
  9. <Popover trigger="click" v-model:visible="clipPanelVisible">
  10. <template #content>
  11. <div class="clip">
  12. <div class="title">按形状裁剪:</div>
  13. <div class="shape-clip">
  14. <div
  15. class="shape-clip-item"
  16. v-for="(item, index) in shapeClipPathOptions"
  17. :key="index"
  18. @click="presetImageClip(index)"
  19. >
  20. <div class="shape" :style="{ backgroundImage: `url(${handleElement.src})`, clipPath: item.style }"></div>
  21. </div>
  22. </div>
  23. <template v-for="type in ratioClipOptions" :key="type.label">
  24. <div class="title" v-if="type.label">{{type.label}}:</div>
  25. <ButtonGroup class="row">
  26. <Button
  27. style="flex: 1;"
  28. v-for="item in type.children"
  29. :key="item.key"
  30. @click="presetImageClip('rect', item.ratio)"
  31. >{{item.key}}</Button>
  32. </ButtonGroup>
  33. </template>
  34. </div>
  35. </template>
  36. <Button class="no-padding" style="flex: 1;"><IconDown /></Button>
  37. </Popover>
  38. </ButtonGroup>
  39. <Popover trigger="click">
  40. <template #content>
  41. <div class="filter">
  42. <div class="filter-item" v-for="filter in filterOptions" :key="filter.key">
  43. <div class="name">{{filter.label}}</div>
  44. <Slider
  45. class="filter-slider"
  46. :max="filter.max"
  47. :min="filter.min"
  48. :step="filter.step"
  49. :value="filter.value"
  50. @change="value => updateFilter(filter, value)"
  51. />
  52. <div class="value">{{`${filter.value}${filter.unit}`}}</div>
  53. </div>
  54. </div>
  55. </template>
  56. <Button class="full-width-btn"><IconColorFilter class="btn-icon" /> 设置滤镜</Button>
  57. </Popover>
  58. <ElementFlip />
  59. <Divider />
  60. <ElementOutline />
  61. <Divider />
  62. <ElementShadow />
  63. <Divider />
  64. <FileInput @change="files => replaceImage(files)">
  65. <Button class="full-width-btn"><IconTransform class="btn-icon" /> 替换图片</Button>
  66. </FileInput>
  67. <Button class="full-width-btn" @click="resetImage()"><IconUndo class="btn-icon" /> 重置样式</Button>
  68. <Button class="full-width-btn" @click="setBackgroundImage()"><IconTheme class="btn-icon" /> 设为背景</Button>
  69. </div>
  70. </template>
  71. <script lang="ts">
  72. import { computed, defineComponent, ref, watch } from 'vue'
  73. import { MutationTypes, useStore } from '@/store'
  74. import { PPTImageElement, Slide } from '@/types/slides'
  75. import { CLIPPATHS } from '@/configs/imageClip'
  76. import { getImageDataURL } from '@/utils/image'
  77. import useHistorySnapshot from '@/hooks/useHistorySnapshot'
  78. import ElementOutline from '../common/ElementOutline.vue'
  79. import ElementShadow from '../common/ElementShadow.vue'
  80. import ElementFlip from '../common/ElementFlip.vue'
  81. interface FilterOption {
  82. label: string;
  83. key: string;
  84. default: number;
  85. value: number;
  86. unit: string;
  87. max: number;
  88. step: number;
  89. }
  90. const defaultFilters: FilterOption[] = [
  91. { label: '模糊', key: 'blur', default: 0, value: 0, unit: 'px', max: 10, step: 1 },
  92. { label: '亮度', key: 'brightness', default: 100, value: 100, unit: '%', max: 200, step: 5 },
  93. { label: '对比度', key: 'contrast', default: 100, value: 100, unit: '%', max: 200, step: 5 },
  94. { label: '灰度', key: 'grayscale', default: 0, value: 0, unit: '%', max: 100, step: 5 },
  95. { label: '饱和度', key: 'saturate', default: 100, value: 100, unit: '%', max: 200, step: 5 },
  96. { label: '色相', key: 'hue-rotate', default: 0, value: 0, unit: 'deg', max: 360, step: 10 },
  97. { label: '不透明度', key: 'opacity', default: 100, value: 100, unit: '%', max: 100, step: 5 },
  98. ]
  99. const shapeClipPathOptions = CLIPPATHS
  100. const ratioClipOptions = [
  101. {
  102. label: '纵横比(方形)',
  103. children: [
  104. { key: '1:1', ratio: 1 / 1 },
  105. ],
  106. },
  107. {
  108. label: '纵横比(纵向)',
  109. children: [
  110. { key: '2:3', ratio: 3 / 2 },
  111. { key: '3:4', ratio: 4 / 3 },
  112. { key: '3:5', ratio: 5 / 3 },
  113. { key: '4:5', ratio: 5 / 4 },
  114. ],
  115. },
  116. {
  117. label: '纵横比(横向)',
  118. children: [
  119. { key: '3:2', ratio: 2 / 3 },
  120. { key: '4:3', ratio: 3 / 4 },
  121. { key: '5:3', ratio: 3 / 5 },
  122. { key: '5:4', ratio: 4 / 5 },
  123. ],
  124. },
  125. {
  126. children: [
  127. { key: '16:9', ratio: 9 / 16 },
  128. { key: '16:10', ratio: 10 / 16 },
  129. ],
  130. },
  131. ]
  132. export default defineComponent({
  133. name: 'image-style-panel',
  134. components: {
  135. ElementOutline,
  136. ElementShadow,
  137. ElementFlip,
  138. },
  139. setup() {
  140. const store = useStore()
  141. const handleElement = computed<PPTImageElement>(() => store.getters.handleElement)
  142. const currentSlide = computed<Slide>(() => store.getters.currentSlide)
  143. const clipPanelVisible = ref(false)
  144. const filterOptions = ref<FilterOption[]>(JSON.parse(JSON.stringify(defaultFilters)))
  145. watch(handleElement, () => {
  146. if (!handleElement.value || handleElement.value.type !== 'image') return
  147. const filters = handleElement.value.filters
  148. if (filters) {
  149. filterOptions.value = defaultFilters.map(item => {
  150. if (filters[item.key] !== undefined) return { ...item, value: parseInt(filters[item.key]) }
  151. return item
  152. })
  153. }
  154. else filterOptions.value = JSON.parse(JSON.stringify(defaultFilters))
  155. }, { deep: true, immediate: true })
  156. const { addHistorySnapshot } = useHistorySnapshot()
  157. // 设置滤镜
  158. const updateFilter = (filter: FilterOption, value: number) => {
  159. const originFilters = handleElement.value.filters || {}
  160. const filters = { ...originFilters, [filter.key]: `${value}${filter.unit}` }
  161. const props = { filters }
  162. store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
  163. addHistorySnapshot()
  164. }
  165. // 打开自由裁剪
  166. const clipImage = () => {
  167. store.commit(MutationTypes.SET_CLIPING_IMAGE_ELEMENT_ID, handleElement.value.id)
  168. clipPanelVisible.value = false
  169. }
  170. // 获取原始图片的位置大小
  171. const getImageElementDataBeforeClip = () => {
  172. // 图片当前的位置大小和裁剪范围
  173. const imgWidth = handleElement.value.width
  174. const imgHeight = handleElement.value.height
  175. const imgLeft = handleElement.value.left
  176. const imgTop = handleElement.value.top
  177. const originClipRange = handleElement.value.clip ? handleElement.value.clip.range : [[0, 0], [100, 100]]
  178. const originWidth = imgWidth / ((originClipRange[1][0] - originClipRange[0][0]) / 100)
  179. const originHeight = imgHeight / ((originClipRange[1][1] - originClipRange[0][1]) / 100)
  180. const originLeft = imgLeft - originWidth * (originClipRange[0][0] / 100)
  181. const originTop = imgTop - originHeight * (originClipRange[0][1] / 100)
  182. return {
  183. originClipRange,
  184. originWidth,
  185. originHeight,
  186. originLeft,
  187. originTop,
  188. }
  189. }
  190. // 预设裁剪
  191. const presetImageClip = (shape: string, ratio = 0) => {
  192. const {
  193. originClipRange,
  194. originWidth,
  195. originHeight,
  196. originLeft,
  197. originTop,
  198. } = getImageElementDataBeforeClip()
  199. // 纵横比裁剪(形状固定为矩形)
  200. if (ratio) {
  201. const imageRatio = originHeight / originWidth
  202. const min = 0
  203. const max = 100
  204. let range
  205. if (imageRatio > ratio) {
  206. const distance = ((1 - ratio / imageRatio) / 2) * 100
  207. range = [[min, distance], [max, max - distance]]
  208. }
  209. else {
  210. const distance = ((1 - imageRatio / ratio) / 2) * 100
  211. range = [[distance, min], [max - distance, max]]
  212. }
  213. store.commit(MutationTypes.UPDATE_ELEMENT, {
  214. id: handleElement.value.id,
  215. props: {
  216. clip: { ...handleElement.value.clip, shape, range },
  217. left: originLeft + originWidth * (range[0][0] / 100),
  218. top: originTop + originHeight * (range[0][1] / 100),
  219. width: originWidth * (range[1][0] - range[0][0]) / 100,
  220. height: originHeight * (range[1][1] - range[0][1]) / 100,
  221. },
  222. })
  223. }
  224. // 形状裁剪(保持当前裁剪范围)
  225. else {
  226. store.commit(MutationTypes.UPDATE_ELEMENT, {
  227. id: handleElement.value.id,
  228. props: {
  229. clip: { ...handleElement.value.clip, shape, range: originClipRange }
  230. },
  231. })
  232. }
  233. clipImage()
  234. addHistorySnapshot()
  235. }
  236. // 替换图片(保持当前的样式)
  237. const replaceImage = (files: File[]) => {
  238. const imageFile = files[0]
  239. if (!imageFile) return
  240. getImageDataURL(imageFile).then(dataURL => {
  241. const props = { src: dataURL }
  242. store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
  243. })
  244. addHistorySnapshot()
  245. }
  246. // 重置图片:清除全部样式
  247. const resetImage = () => {
  248. if (handleElement.value.clip) {
  249. const {
  250. originWidth,
  251. originHeight,
  252. originLeft,
  253. originTop,
  254. } = getImageElementDataBeforeClip()
  255. store.commit(MutationTypes.UPDATE_ELEMENT, {
  256. id: handleElement.value.id,
  257. props: {
  258. left: originLeft,
  259. top: originTop,
  260. width: originWidth,
  261. height: originHeight,
  262. },
  263. })
  264. }
  265. store.commit(MutationTypes.REMOVE_ELEMENT_PROPS, {
  266. id: handleElement.value.id,
  267. propName: ['clip', 'outline', 'flip', 'shadow', 'filters'],
  268. })
  269. addHistorySnapshot()
  270. }
  271. // 将图片设置为背景
  272. const setBackgroundImage = () => {
  273. const background = {
  274. ...currentSlide.value.background,
  275. type: 'image',
  276. image: handleElement.value.src,
  277. imageSize: 'cover',
  278. }
  279. store.commit(MutationTypes.UPDATE_SLIDE, { background })
  280. addHistorySnapshot()
  281. }
  282. return {
  283. clipPanelVisible,
  284. shapeClipPathOptions,
  285. ratioClipOptions,
  286. filterOptions,
  287. handleElement,
  288. updateFilter,
  289. clipImage,
  290. presetImageClip,
  291. replaceImage,
  292. resetImage,
  293. setBackgroundImage,
  294. }
  295. },
  296. })
  297. </script>
  298. <style lang="scss" scoped>
  299. .row {
  300. width: 100%;
  301. display: flex;
  302. align-items: center;
  303. margin-bottom: 10px;
  304. }
  305. .switch-wrapper {
  306. text-align: right;
  307. }
  308. .origin-image {
  309. height: 100px;
  310. background-size: contain;
  311. background-repeat: no-repeat;
  312. background-position: center;
  313. background-color: $lightGray;
  314. margin-bottom: 10px;
  315. }
  316. .full-width-btn {
  317. width: 100%;
  318. margin-bottom: 10px;
  319. }
  320. .btn-icon {
  321. margin-right: 3px;
  322. }
  323. .filter {
  324. width: 280px;
  325. font-size: 12px;
  326. }
  327. .filter-item {
  328. padding: 8px 5px;
  329. display: flex;
  330. justify-content: center;
  331. align-items: center;
  332. .name {
  333. width: 60px;
  334. }
  335. .filter-slider {
  336. flex: 1;
  337. margin: 0 6px;
  338. }
  339. .value {
  340. width: 40px;
  341. text-align: right;
  342. }
  343. }
  344. .clip {
  345. width: 260px;
  346. font-size: 12px;
  347. .title {
  348. margin-bottom: 5px;
  349. }
  350. }
  351. .shape-clip {
  352. margin-bottom: 10px;
  353. @include flex-grid-layout();
  354. }
  355. .shape-clip-item {
  356. display: flex;
  357. justify-content: center;
  358. align-items: center;
  359. cursor: pointer;
  360. @include flex-grid-layout-children(5, 16%);
  361. .shape {
  362. width: 40px;
  363. height: 40px;
  364. background-position: center;
  365. background-repeat: no-repeat;
  366. background-size: cover;
  367. }
  368. }
  369. </style>