Skip to content
Snippets Groups Projects
Commit bad8f246 authored by Дмитрий Малюгин's avatar Дмитрий Малюгин :clock4:
Browse files

feat: setting crop-borders in 'Cropper' (move 'Cropper' to v1.1.0 because of...

feat: setting crop-borders in 'Cropper' (move 'Cropper' to v1.1.0 because of problems with saving images)
parent efbdf918
No related branches found
No related tags found
1 merge request!6Finish "UI-library v1.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,
},
};
// 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,
// },
// };
......@@ -4,11 +4,8 @@ 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';
import { calcContainerRect } from '@/postponed/Cropper/helpers';
import CropperSelectedArea from '@/postponed/Cropper/CropperSelectedArea.vue';
const props = withDefaults(defineProps<ICropperProps>(), {
width: 300,
......@@ -18,16 +15,19 @@ const props = withDefaults(defineProps<ICropperProps>(), {
darknessTheme: '500',
});
const emit = defineEmits(['onSave']);
const canvas = ref();
const image = ref();
const layerX = ref(0);
const layerY = ref(0);
const activeSides = ref<[string, string]>(['top', 'left']);
const activeSides = ref<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 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);
......@@ -35,55 +35,109 @@ 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;
const img = new Image();
img.src = props.src ?? URL.createObjectURL(props.file!);
image.value = new Image();
image.value.crossOrigin = 'anonymous';
image.value.src = props.src ?? URL.createObjectURL(props.file!);
img.onload = () => {
image.value.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);
ctx.value?.drawImage(image.value, 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;
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')) {
const newLeft = event.clientX - container.value?.left - layerX.value;
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')) {
const newBottom = height.value - event.clientY + container.value?.top - 40 + layerY.value;
bottom.value = newBottom + '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')) {
const newRight = width.value - event.clientX + container.value?.left - 40 + layerX.value;
right.value = newRight + '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>
......@@ -95,20 +149,18 @@ const onBorderMove = (event: PointerEvent) => {
},
]"
>
<div id="canvas-container" @pointermove="onBorderMove" @pointerup="isMoving = false">
<div id="canvas-container" v-show="src || file">
<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>
<CropperSelectedArea
:backgroundWidth="backgroundWidth"
:backgroundHeight="backgroundHeight"
:url="url"
:selectedWidth="selectedWidth"
:selectedHeight="selectedHeight"
:top="top"
:left="left"
@onPointerDown="onPointerDown"
/>
</div>
<div
v-show="imageSource"
......@@ -120,8 +172,8 @@ const onBorderMove = (event: PointerEvent) => {
},
]"
>
<Button :theme="theme" :darknessTheme="darknessTheme" label="Reset" />
<Button :theme="theme" :darknessTheme="darknessTheme" label="Save">
<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>
......@@ -129,51 +181,25 @@ const onBorderMove = (event: PointerEvent) => {
</template>
<style scoped>
.container {
display: flex;
align-items: center;
width: max-content;
}
#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;
}
.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;
}
......
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment