diff --git a/src/components/Cropper/Cropper.stories.ts b/src/components/Cropper/Cropper.stories.ts deleted file mode 100644 index 30c424faed71603a2ecbaecb0cc6020f98ed6de2..0000000000000000000000000000000000000000 --- a/src/components/Cropper/Cropper.stories.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3'; - -import Cropper from './Cropper.vue'; - -const meta: Meta = { - title: 'Components/Cropper', - component: Cropper, - tags: ['pick'], - parameters: { - docs: { - description: { - component: 'A component to pick color. Can be with button.', - }, - }, - }, - argTypes: { - menuPosition: { control: 'select', options: ['top', 'right', 'bottom', 'left'] }, - src: { control: 'text' }, - width: { control: 'number' }, - height: { control: 'number' }, - disabled: { control: 'boolean' }, - }, -} satisfies Meta<typeof Cropper>; - -export default meta; - -type Story = StoryObj<typeof meta>; - -export const Simple: Story = { - args: { - src: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQoFRQjM-wM_nXMA03AGDXgJK3VeX7vtD3ctA&s', - }, -}; - -export const Full: Story = { - args: { - buttonProps: { - label: 'Pick color!', - theme: 'red', - textStyle: 'bold', - }, - - size: 'large', - sameButtonColor: true, - }, -}; diff --git a/src/components/Cropper/Cropper.vue b/src/components/Cropper/Cropper.vue deleted file mode 100644 index 95fecf432405132d51cb6ddb5437585dda7b936a..0000000000000000000000000000000000000000 --- a/src/components/Cropper/Cropper.vue +++ /dev/null @@ -1,183 +0,0 @@ -<script setup lang="ts"> -import type { ICropperProps } from '@interfaces/componentsProps'; -import { computed, ref, watch } from 'vue'; -import Button from '@components/Button/Button.vue'; -import { convertThemeToTextColor } from '@helpers/common'; -import SaveIcon from '@icons/Mono/SaveIcon.vue'; -import CornerLeftTopIcon from '@icons/Mono/CornerLeftTopIcon.vue'; -import CornerRightTopIcon from '@icons/Mono/CornerRightTopIcon.vue'; -import CornerLeftBottomIcon from '@icons/Mono/CornerLeftBottomIcon.vue'; -import CornerRightBottomIcon from '@icons/Mono/CornerRightBottomIcon.vue'; -import { calcContainerRect, onBorderMove } from '@components/Cropper/helpers'; - -const props = withDefaults(defineProps<ICropperProps>(), { - width: 300, - height: 300, - menuPosition: 'top', - theme: 'black', - darknessTheme: '500', -}); - -const canvas = ref(); -const layerX = ref(0); -const layerY = ref(0); -const activeSides = ref<[string, string]>(['top', 'left']); - -const isMoving = ref<boolean>(false); -const top = ref('0'); -const left = ref('0'); -const right = ref('0'); -const bottom = ref('0'); - -const ctx = computed(() => canvas.value && canvas.value.getContext('2d')); -const imageSource = computed(() => props.src ?? props.file); -const width = computed(() => props.width); -const height = computed(() => props.height); -const color = computed(() => convertThemeToTextColor(props.theme, props.darknessTheme)); -const container = computed(() => calcContainerRect()); - -watch( - [imageSource, ctx, width, height], - () => { - if (!imageSource.value) return; - - const img = new Image(); - img.src = props.src ?? URL.createObjectURL(props.file!); - - img.onload = () => { - canvas.value.width = width.value ?? 0; - canvas.value.height = height.value ?? 0; - ctx.value?.drawImage(img, 0, 0, width.value ?? 0, height.value ?? 0); - }; - }, - { immediate: true }, -); - -const onPointerDown = (event: PointerEvent, newSides: [string, string]) => { - activeSides.value = newSides; - layerX.value = event.layerX; - layerY.value = event.layerY; - isMoving.value = true; -}; - -// TODO почему то в Ñамом начале переноÑа Ñлемент ÑмещаетÑÑ Ð½Ð° 1-2 пикÑÐµÐ»Ñ Ð²Ð½Ð¸Ð·. ПофикÑить -const onBorderMove = (event: PointerEvent) => { - if (!isMoving.value) return; - if (event.clientY + 39 - layerY.value > container.value?.top + height.value) { - console.log('out?'); - isMoving.value = false; - } - if (activeSides.value.includes('top')) { - const newTop = event.clientY - container.value?.top - layerY.value; - top.value = newTop + 'px'; - } - if (activeSides.value.includes('left')) { - const newLeft = event.clientX - container.value?.left - layerX.value; - left.value = newLeft + 'px'; - } - if (activeSides.value.includes('bottom')) { - const newBottom = height.value - event.clientY + container.value?.top - 40 + layerY.value; - bottom.value = newBottom + 'px'; - } - if (activeSides.value.includes('right')) { - const newRight = width.value - event.clientX + container.value?.left - 40 + layerX.value; - right.value = newRight + 'px'; - } -}; -</script> - -<template> - <section - :class="[ - 'container', - { - flexVertical: menuPosition === 'top' || menuPosition === 'bottom', - }, - ]" - > - <div id="canvas-container" @pointermove="onBorderMove" @pointerup="isMoving = false"> - <canvas ref="canvas" id="cropper-canvas"></canvas> - <button @pointerdown="onPointerDown($event, ['left', 'top'])" class="crop-border left top"> - <CornerLeftTopIcon color="white" /> - </button> - <button @pointerdown="onPointerDown($event, ['right', 'top'])" class="crop-border right top"> - <CornerRightTopIcon color="white" /> - </button> - <button @pointerdown="onPointerDown($event, ['right', 'bottom'])" class="crop-border right bottom"> - <CornerRightBottomIcon color="white" /> - </button> - <button @pointerdown="onPointerDown($event, ['left', 'bottom'])" class="crop-border left bottom"> - <CornerLeftBottomIcon color="white" /> - </button> - </div> - <div - v-show="imageSource" - :class="[ - 'buttons', - { - order1: menuPosition === 'top' || menuPosition === 'left', - flexVertical: menuPosition === 'right' || menuPosition === 'left', - }, - ]" - > - <Button :theme="theme" :darknessTheme="darknessTheme" label="Reset" /> - <Button :theme="theme" :darknessTheme="darknessTheme" label="Save"> - <SaveIcon :color="color" size="16" /> - </Button> - </div> - </section> -</template> - -<style scoped> -.container { - display: flex; - align-items: center; - width: max-content; -} -#canvas-container { - position: relative; - line-height: 0; -} -#cropper-canvas { - border: 1px solid black; -} -.buttons { - display: flex; - align-items: center; - gap: 20px; -} -.crop-border { - position: absolute; - z-index: 50; - width: 40px; - height: 40px; - border: 2px dashed white; - opacity: 0.6; - transition: opacity 0.1s ease; - cursor: pointer; -} -.crop-border:hover { - opacity: 0.8; -} -.crop-border:active { - opacity: 1; -} -.left { - left: v-bind(left); -} -.top { - top: v-bind(top); -} -.right { - right: v-bind(right); -} -.bottom { - bottom: v-bind(bottom); -} -.flexVertical { - flex-direction: column; -} -.order1 { - order: -1; -} -</style> diff --git a/src/postponed/Cropper/Cropper.stories.ts b/src/postponed/Cropper/Cropper.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7ab1380ad9ac9a6763d519513e336ac65435093 --- /dev/null +++ b/src/postponed/Cropper/Cropper.stories.ts @@ -0,0 +1,45 @@ +// import type { Meta, StoryObj } from '@storybook/vue3'; +// +// import Cropper from './Cropper.vue'; +// +// const meta: Meta = { +// title: 'Components/Cropper', +// component: Cropper, +// tags: ['pick'], +// parameters: { +// docs: { +// description: { +// component: 'A component to pick color. Can be with button.', +// }, +// }, +// }, +// argTypes: { +// menuPosition: { control: 'select', options: ['top', 'right', 'bottom', 'left'] }, +// src: { control: 'text' }, +// width: { control: 'number' }, +// height: { control: 'number' }, +// }, +// } satisfies Meta<typeof Cropper>; +// +// export default meta; +// +// type Story = StoryObj<typeof meta>; +// +// export const Simple: Story = { +// args: { +// src: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQoFRQjM-wM_nXMA03AGDXgJK3VeX7vtD3ctA&s', +// }, +// }; +// +// export const Full: Story = { +// args: { +// buttonProps: { +// label: 'Pick color!', +// theme: 'red', +// textStyle: 'bold', +// }, +// +// size: 'large', +// sameButtonColor: true, +// }, +// }; diff --git a/src/postponed/Cropper/Cropper.vue b/src/postponed/Cropper/Cropper.vue new file mode 100644 index 0000000000000000000000000000000000000000..568f1ee47d51ad0f85dbdf2508b54c6d44ccbb1f --- /dev/null +++ b/src/postponed/Cropper/Cropper.vue @@ -0,0 +1,209 @@ +<script setup lang="ts"> +import type { ICropperProps } from '@interfaces/componentsProps'; +import { computed, ref, watch } from 'vue'; +import Button from '@components/Button/Button.vue'; +import { convertThemeToTextColor } from '@helpers/common'; +import SaveIcon from '@icons/Mono/SaveIcon.vue'; +import { calcContainerRect } from '@/postponed/Cropper/helpers'; +import CropperSelectedArea from '@/postponed/Cropper/CropperSelectedArea.vue'; + +const props = withDefaults(defineProps<ICropperProps>(), { + width: 300, + height: 300, + menuPosition: 'top', + theme: 'black', + darknessTheme: '500', +}); + +const emit = defineEmits(['onSave']); + +const canvas = ref(); +const image = ref(); +const layerX = ref(0); +const layerY = ref(0); +const activeSides = ref<string[]>(['top', 'left']); + +const isMoving = ref<boolean>(false); +const top = ref('0px'); +const left = ref('0px'); +const selectedWidth = ref(props.width + 'px'); +const selectedHeight = ref(props.height + 'px'); + +const ctx = computed(() => canvas.value && canvas.value.getContext('2d')); +const imageSource = computed(() => props.src ?? props.file); +const width = computed(() => props.width); +const height = computed(() => props.height); +const color = computed(() => convertThemeToTextColor(props.theme, props.darknessTheme)); +const container = computed(() => calcContainerRect()); +const url = computed(() => `url(${props.src})`); +const backgroundWidth = computed(() => props.width + 'px'); +const backgroundHeight = computed(() => props.height + 'px'); + +watch( + [imageSource, ctx, width, height], + () => { + if (!imageSource.value) return; + + image.value = new Image(); + image.value.crossOrigin = 'anonymous'; + image.value.src = props.src ?? URL.createObjectURL(props.file!); + + image.value.onload = () => { + canvas.value.width = width.value ?? 0; + canvas.value.height = height.value ?? 0; + ctx.value?.drawImage(image.value, 0, 0, width.value ?? 0, height.value ?? 0); + }; + }, + { immediate: true }, +); + +const onBorderMove = (event: PointerEvent) => { + if (!isMoving.value) return; + + if ( + activeSides.value.includes('top') && + !(event.clientY - layerY.value < (container.value?.top ?? 0)) && + !(event.clientY + 38 - layerY.value > (container.value?.top ?? 0) + height.value) + ) { + const newTop = event.clientY - (container.value?.top ?? 0) - layerY.value; + selectedHeight.value = +selectedHeight.value.slice(0, -2) - (newTop - +top.value.slice(0, -2)) + 'px'; + top.value = newTop + 'px'; + } + if ( + activeSides.value.includes('left') && + !(event.clientX - layerX.value < (container.value?.left ?? 0)) && + !(event.clientX + 38 - layerX.value > (container.value?.left ?? 0) + width.value) + ) { + const newLeft = event.clientX - (container.value?.left ?? 0) - layerX.value; + selectedWidth.value = +selectedWidth.value.slice(0, -2) - (newLeft - +left.value.slice(0, -2)) + 'px'; + left.value = newLeft + 'px'; + } + if ( + activeSides.value.includes('bottom') && + !(event.clientY - layerY.value < (container.value?.top ?? 0)) && + !(event.clientY + 38 - layerY.value > (container.value?.top ?? 0) + height.value) + ) { + const newHeight = -+top.value.slice(0, -2) + event.clientY - (container.value?.top ?? 0) + 38 - layerY.value; + selectedHeight.value = newHeight + 'px'; + } + if ( + activeSides.value.includes('right') && + !(event.clientX - layerX.value < (container.value?.left ?? 0)) && + !(event.clientX + 38 - layerX.value > (container.value?.left ?? 0) + width.value) + ) { + const newWidth = -+left.value.slice(0, -2) + event.clientX - (container.value?.left ?? 0) + 38 - layerX.value; + selectedWidth.value = newWidth + 'px'; + } +}; + +const onPointerUp = () => { + isMoving.value = false; + document.removeEventListener('pointermove', onBorderMove); + document.removeEventListener('pointerup', onPointerUp); +}; + +const onPointerDown = (event: PointerEvent, newSides: string[]) => { + document.addEventListener('pointermove', onBorderMove); + document.addEventListener('pointerup', onPointerUp); + activeSides.value = newSides; + layerX.value = event.layerX; + layerY.value = event.layerY; + isMoving.value = true; +}; + +const resetSelected = () => { + top.value = '0px'; + left.value = '0px'; + selectedWidth.value = width.value + 'px'; + selectedHeight.value = height.value + 'px'; +}; + +// TODO не получаетÑÑ ÐºÐ¾Ñ€Ñ€ÐµÐºÑ‚Ð½Ð¾ получить ÑÑылку на изображение. ТеÑтировалоÑÑŒ в Playground - вÑегда получал +// TODO ...либо полноÑтью чёрное, либо полноÑтью белое изображение +const saveImage = () => { + ctx.value.fillStyle = 'rgb(200,0,0)'; + ctx.value.fillRect(40, 60, 20, 20); + ctx.value.clearRect(0, 0, width.value, height.value); + ctx.value.drawImage(image.value, left.value, top.value, selectedWidth.value, selectedHeight.value); + const canvasEl = document.querySelector('#cropper-canvas')!; + canvas.value.toBlob((blob: Blob) => { + console.log('canvasEl: ', canvasEl); + console.log('URL.createObjectURL(blob): ', URL.createObjectURL(blob)); + console.log('blob: ', blob); + const file = new File([blob], 'fileName.png'); + console.log('file: ', file); + console.log('url: ', URL.createObjectURL(blob)); + console.log('toDataURL: ', canvas.value.toDataURL()); + emit('onSave', canvas.value.toDataURL()); + // emit('onSave', canvas.value.toDataURL('image/jpeg')); + }, 'image/jpeg'); +}; +</script> + +<template> + <section + :class="[ + 'container', + { + flexVertical: menuPosition === 'top' || menuPosition === 'bottom', + }, + ]" + > + <div id="canvas-container" v-show="src || file"> + <canvas ref="canvas" id="cropper-canvas"></canvas> + <CropperSelectedArea + :backgroundWidth="backgroundWidth" + :backgroundHeight="backgroundHeight" + :url="url" + :selectedWidth="selectedWidth" + :selectedHeight="selectedHeight" + :top="top" + :left="left" + @onPointerDown="onPointerDown" + /> + </div> + <div + v-show="imageSource" + :class="[ + 'buttons', + { + order1: menuPosition === 'top' || menuPosition === 'left', + flexVertical: menuPosition === 'right' || menuPosition === 'left', + }, + ]" + > + <Button @click="resetSelected" :theme="theme" :darknessTheme="darknessTheme" label="Reset" /> + <Button @click="saveImage" :theme="theme" :darknessTheme="darknessTheme" label="Save"> + <SaveIcon :color="color" size="16" /> + </Button> + </div> + </section> +</template> + +<style scoped> +#canvas-container { + position: relative; + line-height: 0; + filter: brightness(70%); +} +#cropper-canvas { + border: 1px solid black; +} +.container { + display: flex; + align-items: center; + width: max-content; + padding: 1px; +} +.buttons { + display: flex; + align-items: center; + gap: 20px; +} +.flexVertical { + flex-direction: column; +} +.order1 { + order: -1; +} +</style> diff --git a/src/postponed/Cropper/CropperSelectedArea.vue b/src/postponed/Cropper/CropperSelectedArea.vue new file mode 100644 index 0000000000000000000000000000000000000000..881b0fcdf4fada847fe50e30145b28db8f2635c0 --- /dev/null +++ b/src/postponed/Cropper/CropperSelectedArea.vue @@ -0,0 +1,144 @@ +<script setup lang="ts"> +import CornerRightBottomIcon from '@icons/Mono/CornerRightBottomIcon.vue'; +import CornerRightTopIcon from '@icons/Mono/CornerRightTopIcon.vue'; +import CornerLeftTopIcon from '@icons/Mono/CornerLeftTopIcon.vue'; +import CornerLeftBottomIcon from '@icons/Mono/CornerLeftBottomIcon.vue'; +import { computed } from 'vue'; + +interface IProps { + backgroundWidth: string; + backgroundHeight: string; + url: string; + top: string; + left: string; + selectedWidth: string; + selectedHeight: string; +} +const props = defineProps<IProps>(); +const emit = defineEmits(['onPointerDown']); + +const backgroundTop = computed(() => 1 - +props.top.slice(0, -2) + 'px'); +const backgroundLeft = computed(() => 1 - +props.left.slice(0, -2) + 'px'); +</script> + +<template> + <div class="selected-area" :style="`width: ${selectedWidth}; height: ${selectedHeight}`"> + <div class="selected-background"></div> + <button + @pointerdown="emit('onPointerDown', $event, ['left', 'top'])" + class="crop-border left top" + style="cursor: se-resize" + > + <CornerLeftTopIcon color="white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['top'])" + class="crop-border top center" + :style="`left: ${+selectedWidth.slice(0, -2) / 2 - 20 + 'px'}; cursor: s-resize`" + > + <hr style="width: 36px; height: 3px; background-color: white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['right', 'top'])" + class="crop-border right top" + style="cursor: sw-resize" + > + <CornerRightTopIcon color="white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['right'])" + class="crop-border right center" + :style="`top: ${+selectedHeight.slice(0, -2) / 2 - 20 + 'px'}; cursor: w-resize`" + > + <div style="width: 3px; height: 36px; margin-left: auto; background-color: white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['right', 'bottom'])" + class="crop-border right bottom" + style="cursor: nw-resize" + > + <CornerRightBottomIcon color="white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['bottom'])" + class="crop-border bottom center" + :style="`left: ${+selectedWidth.slice(0, -2) / 2 - 20 + 'px'}; cursor: n-resize`" + > + <hr style="width: 36px; height: 3px; background-color: white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['left', 'bottom'])" + class="crop-border left bottom" + style="cursor: ne-resize" + > + <CornerLeftBottomIcon color="white" /> + </button> + <button + @pointerdown="emit('onPointerDown', $event, ['left'])" + class="crop-border left center" + :style="`top: ${+selectedHeight.slice(0, -2) / 2 - 20 + 'px'}; cursor: w-resize`" + > + <div style="width: 3px; height: 36px; background-color: white" /> + </button> + </div> +</template> + +<style scoped> +.selected-area { + position: absolute; + top: v-bind(top); + left: v-bind(left); + z-index: 5; + filter: brightness(145%); + background-repeat: no-repeat; + background-size: contain; + overflow: hidden; +} +.selected-background { + position: absolute; + top: v-bind(backgroundTop); + left: v-bind(backgroundLeft); + width: v-bind(backgroundWidth); + height: v-bind(backgroundHeight); + background-image: v-bind(url); + background-repeat: no-repeat; + background-size: v-bind(backgroundWidth) v-bind(backgroundHeight); + overflow: hidden; +} +.crop-border { + position: absolute; + z-index: 50; + width: 40px; + height: 40px; + border: 1px dashed white; + opacity: 0.6; + transition: opacity 0.1s ease; +} +.crop-border:hover { + opacity: 0.8; +} +.crop-border:active { + opacity: 1; +} +.left { + left: 0; +} +.top { + top: 0; +} +.right { + right: 0; +} +.bottom { + bottom: 0; +} +.center { + display: flex; +} +.crop-border.top.center { + align-items: start; +} +.crop-border.bottom.center { + align-items: end; +} +</style>