update
This commit is contained in:
400
ts_web/elements/profilepicture/profilepicture.cropper.ts
Normal file
400
ts_web/elements/profilepicture/profilepicture.cropper.ts
Normal file
@ -0,0 +1,400 @@
|
||||
import type { ProfileShape } from './dees-input-profilepicture.js';
|
||||
|
||||
export interface CropperOptions {
|
||||
container: HTMLElement;
|
||||
image: string;
|
||||
shape: ProfileShape;
|
||||
aspectRatio: number;
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
export class ImageCropper {
|
||||
private options: CropperOptions;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private img: HTMLImageElement;
|
||||
private overlayCanvas: HTMLCanvasElement;
|
||||
private overlayCtx: CanvasRenderingContext2D;
|
||||
|
||||
// Crop area properties
|
||||
private cropX: number = 0;
|
||||
private cropY: number = 0;
|
||||
private cropSize: number = 200;
|
||||
private minCropSize: number = 50;
|
||||
|
||||
// Interaction state
|
||||
private isDragging: boolean = false;
|
||||
private isResizing: boolean = false;
|
||||
private dragStartX: number = 0;
|
||||
private dragStartY: number = 0;
|
||||
private resizeHandle: string = '';
|
||||
|
||||
// Image properties
|
||||
private imageScale: number = 1;
|
||||
private imageOffsetX: number = 0;
|
||||
private imageOffsetY: number = 0;
|
||||
|
||||
constructor(options: CropperOptions) {
|
||||
this.options = {
|
||||
minSize: 50,
|
||||
...options
|
||||
};
|
||||
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
|
||||
this.overlayCanvas = document.createElement('canvas');
|
||||
this.overlayCtx = this.overlayCanvas.getContext('2d')!;
|
||||
|
||||
this.img = new Image();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Load image
|
||||
await this.loadImage();
|
||||
|
||||
// Setup canvases
|
||||
this.setupCanvases();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadImage(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.img.onload = () => resolve();
|
||||
this.img.onerror = reject;
|
||||
this.img.src = this.options.image;
|
||||
});
|
||||
}
|
||||
|
||||
private setupCanvases(): void {
|
||||
const container = this.options.container;
|
||||
const containerSize = Math.min(container.clientWidth, container.clientHeight);
|
||||
|
||||
// Set canvas sizes
|
||||
this.canvas.width = containerSize;
|
||||
this.canvas.height = containerSize;
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.style.position = 'absolute';
|
||||
this.canvas.style.top = '0';
|
||||
this.canvas.style.left = '0';
|
||||
|
||||
this.overlayCanvas.width = containerSize;
|
||||
this.overlayCanvas.height = containerSize;
|
||||
this.overlayCanvas.style.width = '100%';
|
||||
this.overlayCanvas.style.height = '100%';
|
||||
this.overlayCanvas.style.position = 'absolute';
|
||||
this.overlayCanvas.style.top = '0';
|
||||
this.overlayCanvas.style.left = '0';
|
||||
this.overlayCanvas.style.cursor = 'move';
|
||||
|
||||
container.appendChild(this.canvas);
|
||||
container.appendChild(this.overlayCanvas);
|
||||
|
||||
// Calculate image scale and position
|
||||
const scale = Math.max(
|
||||
containerSize / this.img.width,
|
||||
containerSize / this.img.height
|
||||
);
|
||||
|
||||
this.imageScale = scale;
|
||||
this.imageOffsetX = (containerSize - this.img.width * scale) / 2;
|
||||
this.imageOffsetY = (containerSize - this.img.height * scale) / 2;
|
||||
|
||||
// Initialize crop area
|
||||
this.cropSize = containerSize * 0.8;
|
||||
this.cropX = (containerSize - this.cropSize) / 2;
|
||||
this.cropY = (containerSize - this.cropSize) / 2;
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.overlayCanvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.overlayCanvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.overlayCanvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
this.overlayCanvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
|
||||
|
||||
// Touch events
|
||||
this.overlayCanvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
||||
this.overlayCanvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
|
||||
this.overlayCanvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||
}
|
||||
|
||||
private handleMouseDown(e: MouseEvent): void {
|
||||
const rect = this.overlayCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
||||
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
||||
|
||||
const handle = this.getResizeHandle(x, y);
|
||||
|
||||
if (handle) {
|
||||
this.isResizing = true;
|
||||
this.resizeHandle = handle;
|
||||
} else if (this.isInsideCropArea(x, y)) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
}
|
||||
|
||||
private handleMouseMove(e: MouseEvent): void {
|
||||
const rect = this.overlayCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
||||
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
||||
|
||||
// Update cursor
|
||||
const handle = this.getResizeHandle(x, y);
|
||||
if (handle) {
|
||||
this.overlayCanvas.style.cursor = this.getResizeCursor(handle);
|
||||
} else if (this.isInsideCropArea(x, y)) {
|
||||
this.overlayCanvas.style.cursor = 'move';
|
||||
} else {
|
||||
this.overlayCanvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
// Handle dragging
|
||||
if (this.isDragging) {
|
||||
const dx = x - this.dragStartX;
|
||||
const dy = y - this.dragStartY;
|
||||
|
||||
this.cropX = Math.max(0, Math.min(this.canvas.width - this.cropSize, this.cropX + dx));
|
||||
this.cropY = Math.max(0, Math.min(this.canvas.height - this.cropSize, this.cropY + dy));
|
||||
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Handle resizing
|
||||
if (this.isResizing) {
|
||||
this.handleResize(x, y);
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.resizeHandle = '';
|
||||
}
|
||||
|
||||
private handleTouchStart(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
this.handleMouseDown(mouseEvent);
|
||||
}
|
||||
|
||||
private handleTouchMove(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
this.handleMouseMove(mouseEvent);
|
||||
}
|
||||
|
||||
private handleTouchEnd(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
this.handleMouseUp();
|
||||
}
|
||||
|
||||
private getResizeHandle(x: number, y: number): string {
|
||||
const handleSize = 20;
|
||||
const handles = {
|
||||
'nw': { x: this.cropX, y: this.cropY },
|
||||
'ne': { x: this.cropX + this.cropSize, y: this.cropY },
|
||||
'sw': { x: this.cropX, y: this.cropY + this.cropSize },
|
||||
'se': { x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
||||
};
|
||||
|
||||
for (const [key, pos] of Object.entries(handles)) {
|
||||
if (Math.abs(x - pos.x) < handleSize && Math.abs(y - pos.y) < handleSize) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private getResizeCursor(handle: string): string {
|
||||
const cursors: Record<string, string> = {
|
||||
'nw': 'nw-resize',
|
||||
'ne': 'ne-resize',
|
||||
'sw': 'sw-resize',
|
||||
'se': 'se-resize'
|
||||
};
|
||||
return cursors[handle] || 'default';
|
||||
}
|
||||
|
||||
private isInsideCropArea(x: number, y: number): boolean {
|
||||
return x >= this.cropX && x <= this.cropX + this.cropSize &&
|
||||
y >= this.cropY && y <= this.cropY + this.cropSize;
|
||||
}
|
||||
|
||||
private handleResize(x: number, y: number): void {
|
||||
const dx = x - this.dragStartX;
|
||||
const dy = y - this.dragStartY;
|
||||
|
||||
switch (this.resizeHandle) {
|
||||
case 'se':
|
||||
this.cropSize = Math.max(this.minCropSize, Math.min(
|
||||
this.cropSize + Math.max(dx, dy),
|
||||
Math.min(
|
||||
this.canvas.width - this.cropX,
|
||||
this.canvas.height - this.cropY
|
||||
)
|
||||
));
|
||||
break;
|
||||
case 'nw':
|
||||
const newSize = Math.max(this.minCropSize, this.cropSize - Math.max(dx, dy));
|
||||
const sizeDiff = this.cropSize - newSize;
|
||||
this.cropX += sizeDiff;
|
||||
this.cropY += sizeDiff;
|
||||
this.cropSize = newSize;
|
||||
break;
|
||||
case 'ne':
|
||||
const neSizeDx = Math.max(dx, -dy);
|
||||
const neNewSize = Math.max(this.minCropSize, this.cropSize + neSizeDx);
|
||||
const neSizeDiff = neNewSize - this.cropSize;
|
||||
this.cropY -= neSizeDiff;
|
||||
this.cropSize = neNewSize;
|
||||
break;
|
||||
case 'sw':
|
||||
const swSizeDx = Math.max(-dx, dy);
|
||||
const swNewSize = Math.max(this.minCropSize, this.cropSize + swSizeDx);
|
||||
const swSizeDiff = swNewSize - this.cropSize;
|
||||
this.cropX -= swSizeDiff;
|
||||
this.cropSize = swNewSize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
// Clear canvases
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
|
||||
// Draw image
|
||||
this.ctx.drawImage(
|
||||
this.img,
|
||||
this.imageOffsetX,
|
||||
this.imageOffsetY,
|
||||
this.img.width * this.imageScale,
|
||||
this.img.height * this.imageScale
|
||||
);
|
||||
|
||||
// Draw overlay
|
||||
this.overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
this.overlayCtx.fillRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
|
||||
// Clear crop area
|
||||
this.overlayCtx.save();
|
||||
|
||||
if (this.options.shape === 'round') {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(
|
||||
this.cropX + this.cropSize / 2,
|
||||
this.cropY + this.cropSize / 2,
|
||||
this.cropSize / 2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.overlayCtx.clip();
|
||||
} else {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.rect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
||||
this.overlayCtx.clip();
|
||||
}
|
||||
|
||||
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
this.overlayCtx.restore();
|
||||
|
||||
// Draw crop border
|
||||
this.overlayCtx.strokeStyle = 'white';
|
||||
this.overlayCtx.lineWidth = 2;
|
||||
|
||||
if (this.options.shape === 'round') {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(
|
||||
this.cropX + this.cropSize / 2,
|
||||
this.cropY + this.cropSize / 2,
|
||||
this.cropSize / 2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.overlayCtx.stroke();
|
||||
} else {
|
||||
this.overlayCtx.strokeRect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
||||
}
|
||||
|
||||
// Draw resize handles
|
||||
this.drawResizeHandles();
|
||||
}
|
||||
|
||||
private drawResizeHandles(): void {
|
||||
const handleSize = 8;
|
||||
const handles = [
|
||||
{ x: this.cropX, y: this.cropY },
|
||||
{ x: this.cropX + this.cropSize, y: this.cropY },
|
||||
{ x: this.cropX, y: this.cropY + this.cropSize },
|
||||
{ x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
||||
];
|
||||
|
||||
this.overlayCtx.fillStyle = 'white';
|
||||
|
||||
handles.forEach(handle => {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(handle.x, handle.y, handleSize, 0, Math.PI * 2);
|
||||
this.overlayCtx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
async getCroppedImage(): Promise<string> {
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
const cropCtx = cropCanvas.getContext('2d')!;
|
||||
|
||||
// Set output size
|
||||
const outputSize = 400;
|
||||
cropCanvas.width = outputSize;
|
||||
cropCanvas.height = outputSize;
|
||||
|
||||
// Calculate source coordinates
|
||||
const scale = 1 / this.imageScale;
|
||||
const sx = (this.cropX - this.imageOffsetX) * scale;
|
||||
const sy = (this.cropY - this.imageOffsetY) * scale;
|
||||
const sSize = this.cropSize * scale;
|
||||
|
||||
// Apply shape mask if round
|
||||
if (this.options.shape === 'round') {
|
||||
cropCtx.beginPath();
|
||||
cropCtx.arc(outputSize / 2, outputSize / 2, outputSize / 2, 0, Math.PI * 2);
|
||||
cropCtx.clip();
|
||||
}
|
||||
|
||||
// Draw cropped image
|
||||
cropCtx.drawImage(
|
||||
this.img,
|
||||
sx, sy, sSize, sSize,
|
||||
0, 0, outputSize, outputSize
|
||||
);
|
||||
|
||||
return cropCanvas.toDataURL('image/jpeg', 0.9);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.canvas.remove();
|
||||
this.overlayCanvas.remove();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user