Commit bad8f246 authored by Дмитрий Малюгин's avatar Дмитрий Малюгин 🕓
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
Loading
Loading
Loading
Loading
+0 −46
Original line number Diff line number Diff line
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,
  },
};
+45 −0
Original line number Diff line number Diff line
// 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,
//   },
// };
+209 −0
Original line number Diff line number Diff line
@@ -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('right')) {
    const newRight = width.value - event.clientX + container.value?.left - 40 + layerX.value;
    right.value = newRight + '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>
@@ -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;
}
+144 −0
Original line number Diff line number Diff line
<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>