ImageStylePanel.vue 12 KB

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