From 1c25554c384f67f1470cb7139e11db37291c73fc Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 30 Jun 2025 11:35:38 +0000 Subject: [PATCH] update --- ts_web/elements/index.ts | 1 + .../dees-input-profilepicture.demo.ts | 208 +++++++++ .../dees-input-profilepicture.ts | 361 ++++++++++++++++ ts_web/elements/profilepicture/index.ts | 3 + .../profilepicture/profilepicture.cropper.ts | 400 ++++++++++++++++++ .../profilepicture/profilepicture.modal.ts | 342 +++++++++++++++ ts_web/pages/input-showcase.ts | 25 ++ 7 files changed, 1340 insertions(+) create mode 100644 ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts create mode 100644 ts_web/elements/profilepicture/dees-input-profilepicture.ts create mode 100644 ts_web/elements/profilepicture/index.ts create mode 100644 ts_web/elements/profilepicture/profilepicture.cropper.ts create mode 100644 ts_web/elements/profilepicture/profilepicture.modal.ts diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 6357b96..fe083e6 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -32,6 +32,7 @@ export * from './dees-input-datepicker.js'; export * from './dees-input-dropdown.js'; export * from './dees-input-fileupload.js'; export * from './dees-input-iban.js'; +export * from './profilepicture/dees-input-profilepicture.js'; export * from './dees-input-typelist.js'; export * from './dees-input-phone.js'; export * from './dees-input-wysiwyg.js'; diff --git a/ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts b/ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts new file mode 100644 index 0000000..52fae8e --- /dev/null +++ b/ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts @@ -0,0 +1,208 @@ +import { html, css } from '@design.estate/dees-element'; +import '@design.estate/dees-wcctools/demotools'; +import '../dees-panel.js'; +import './dees-input-profilepicture.js'; +import type { DeesInputProfilePicture } from './dees-input-profilepicture.js'; + +export const demoFunc = () => html` + + +
+ { + // Basic demo with round profile picture + const roundProfile = elementArg.querySelector('dees-input-profilepicture[shape="round"]'); + + if (roundProfile) { + roundProfile.addEventListener('change', (event: CustomEvent) => { + const target = event.target as DeesInputProfilePicture; + console.log('Round profile picture changed:', target.value?.substring(0, 50) + '...'); + }); + } + }}> + +
+ + + +
+
+
+ + { + // Different sizes demo + const profiles = elementArg.querySelectorAll('dees-input-profilepicture'); + profiles.forEach((profile) => { + profile.addEventListener('change', (event: CustomEvent) => { + const target = event.target as DeesInputProfilePicture; + console.log(`Profile (size ${target.size}) changed`); + }); + }); + }}> + +
+ + + + + +
+
+
+ + { + // Pre-filled profile with placeholder + const sampleImageUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNjY3ZWVhIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM3NjRiYTIiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0idXJsKCNncmFkaWVudCkiIC8+CiAgPHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSI4MCIgZmlsbD0id2hpdGUiPkpEPC90ZXh0Pgo8L3N2Zz4='; + + const prefilledProfile = elementArg.querySelector('#prefilled-profile') as DeesInputProfilePicture; + if (prefilledProfile) { + prefilledProfile.value = sampleImageUrl; + + prefilledProfile.addEventListener('change', (event: CustomEvent) => { + const target = event.target as DeesInputProfilePicture; + const output = elementArg.querySelector('#prefilled-output'); + if (output) { + output.textContent = target.value ? + `Image data: ${target.value.substring(0, 80)}...` : + 'No image selected'; + } + }); + } + }}> + + + +
+ Image data will appear here when changed +
+
+
+ + { + // Disabled state demo + const disabledProfile = elementArg.querySelector('#disabled-profile') as DeesInputProfilePicture; + if (disabledProfile) { + disabledProfile.value = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2NjY2NjYyIgLz4KICA8dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjYwIiBmaWxsPSJ3aGl0ZSI+TkE8L3RleHQ+Cjwvc3ZnPg=='; + } + }}> + +
+ + + + + +
+
+
+ + + +
    +
  • Image Upload: Click to upload or drag & drop images
  • +
  • Image Cropping: Interactive crop tool with resize handles
  • +
  • Shape Support: Round or square profile pictures
  • +
  • Size Customization: Adjustable dimensions
  • +
  • Preview & Edit: Hover overlay with edit and delete options
  • +
  • File Validation: Format and size restrictions
  • +
  • Responsive Design: Works on desktop and mobile devices
  • +
  • Form Integration: Standard form value binding and validation
  • +
  • Accessibility: Keyboard navigation and screen reader support
  • +
  • Z-Index Management: Proper modal stacking with registry
  • +
+ +
+ Supported Formats: JPEG, PNG, WebP
+ Max File Size: 5MB (configurable)
+ Output Format: Base64 encoded JPEG +
+
+
+
+`; \ No newline at end of file diff --git a/ts_web/elements/profilepicture/dees-input-profilepicture.ts b/ts_web/elements/profilepicture/dees-input-profilepicture.ts new file mode 100644 index 0000000..707afd7 --- /dev/null +++ b/ts_web/elements/profilepicture/dees-input-profilepicture.ts @@ -0,0 +1,361 @@ +import { + customElement, + html, + property, + css, + cssManager, + state, + type TemplateResult, +} from '@design.estate/dees-element'; +import { DeesInputBase } from '../dees-input-base.js'; +import '../dees-icon.js'; +import '../dees-label.js'; +import { ProfilePictureModal } from './profilepicture.modal.js'; +import { demoFunc } from './dees-input-profilepicture.demo.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-input-profilepicture': DeesInputProfilePicture; + } +} + +export type ProfileShape = 'square' | 'round'; + +@customElement('dees-input-profilepicture') +export class DeesInputProfilePicture extends DeesInputBase { + public static demo = demoFunc; + + @property({ type: String }) + public value: string = ''; // Base64 encoded image or URL + + @property({ type: String }) + public shape: ProfileShape = 'round'; + + @property({ type: Number }) + public size: number = 120; + + @property({ type: String }) + public placeholder: string = ''; + + @property({ type: Boolean }) + public allowUpload: boolean = true; + + @property({ type: Boolean }) + public allowDelete: boolean = true; + + @property({ type: Number }) + public maxFileSize: number = 5 * 1024 * 1024; // 5MB + + @property({ type: Array }) + public acceptedFormats: string[] = ['image/jpeg', 'image/png', 'image/webp']; + + @state() + private isHovered: boolean = false; + + @state() + private isDragging: boolean = false; + + private modalInstance: ProfilePictureModal | null = null; + + public static styles = [ + ...DeesInputBase.baseStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + position: relative; + } + + .input-wrapper { + display: flex; + flex-direction: column; + gap: 16px; + } + + .profile-container { + position: relative; + display: inline-block; + cursor: pointer; + transition: all 0.3s ease; + } + + .profile-container:hover { + transform: scale(1.02); + } + + .profile-picture { + width: var(--size, 120px); + height: var(--size, 120px); + background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; + border: 3px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + transition: all 0.3s ease; + } + + .profile-picture.round { + border-radius: 50%; + } + + .profile-picture.square { + border-radius: 12px; + } + + .profile-picture.dragging { + border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; + box-shadow: 0 0 0 4px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')}; + } + + .profile-picture:hover { + border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; + } + + .profile-picture:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + .profile-image { + width: 100%; + height: 100%; + object-fit: cover; + } + + .placeholder-icon { + color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; + } + + .overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + } + + .profile-container:hover .overlay { + opacity: 1; + } + + .overlay-content { + display: flex; + gap: 12px; + } + + .overlay-button { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + pointer-events: auto; + } + + .overlay-button:hover { + background: white; + transform: scale(1.1); + } + + .overlay-button.delete { + background: rgba(239, 68, 68, 0.9); + color: white; + } + + .overlay-button.delete:hover { + background: rgb(239, 68, 68); + } + + .drop-zone-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: white; + font-weight: 500; + pointer-events: none; + } + + .hidden-input { + display: none; + } + `, + ]; + + render(): TemplateResult { + return html` +
+ + +
+
+ ${this.value ? html` + Profile picture + ` : html` + + `} + + ${this.isDragging ? html` +
+
+ Drop image here +
+
+ ` : ''} + + ${this.value && !this.disabled ? html` +
+
+ ${this.allowUpload ? html` + + ` : ''} + ${this.allowDelete ? html` + + ` : ''} +
+
+ ` : ''} +
+
+ + +
+ `; + } + + private handleClick(): void { + if (this.disabled || !this.allowUpload) return; + + if (!this.value) { + // If no image, open file picker + const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement; + input.click(); + } + } + + private handleFileSelect(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + + if (file) { + this.processFile(file); + } + + // Reset input to allow selecting the same file again + input.value = ''; + } + + private handleDragOver(event: DragEvent): void { + event.preventDefault(); + if (!this.disabled && this.allowUpload) { + this.isDragging = true; + } + } + + private handleDragLeave(): void { + this.isDragging = false; + } + + private handleDrop(event: DragEvent): void { + event.preventDefault(); + this.isDragging = false; + + if (this.disabled || !this.allowUpload) return; + + const file = event.dataTransfer?.files[0]; + if (file) { + this.processFile(file); + } + } + + private async processFile(file: File): Promise { + // Validate file type + if (!this.acceptedFormats.includes(file.type)) { + console.error('Invalid file type:', file.type); + return; + } + + // Validate file size + if (file.size > this.maxFileSize) { + console.error('File too large:', file.size); + return; + } + + // Read file as base64 + const reader = new FileReader(); + reader.onload = async (e) => { + const base64 = e.target?.result as string; + + // Open modal for cropping + await this.openModal(base64); + }; + reader.readAsDataURL(file); + } + + private async openModal(initialImage?: string): Promise { + const imageToEdit = initialImage || this.value; + + if (!imageToEdit) { + // If no image provided, open file picker + const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement; + input.click(); + return; + } + + // Create and show modal + this.modalInstance = new ProfilePictureModal(); + this.modalInstance.shape = this.shape; + this.modalInstance.initialImage = imageToEdit; + + this.modalInstance.addEventListener('save', (event: CustomEvent) => { + this.value = event.detail.croppedImage; + this.changeSubject.next(this); + }); + + document.body.appendChild(this.modalInstance); + } + + private deletePicture(): void { + this.value = ''; + this.changeSubject.next(this); + } + + public getValue(): string { + return this.value; + } + + public setValue(value: string): void { + this.value = value; + } +} \ No newline at end of file diff --git a/ts_web/elements/profilepicture/index.ts b/ts_web/elements/profilepicture/index.ts new file mode 100644 index 0000000..4c8c89b --- /dev/null +++ b/ts_web/elements/profilepicture/index.ts @@ -0,0 +1,3 @@ +export * from './dees-input-profilepicture.js'; +export * from './profilepicture.modal.js'; +export * from './profilepicture.cropper.js'; \ No newline at end of file diff --git a/ts_web/elements/profilepicture/profilepicture.cropper.ts b/ts_web/elements/profilepicture/profilepicture.cropper.ts new file mode 100644 index 0000000..ae1da7a --- /dev/null +++ b/ts_web/elements/profilepicture/profilepicture.cropper.ts @@ -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 { + // Load image + await this.loadImage(); + + // Setup canvases + this.setupCanvases(); + + // Setup event listeners + this.setupEventListeners(); + + // Initial render + this.render(); + } + + private async loadImage(): Promise { + 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 = { + '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 { + 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(); + } +} \ No newline at end of file diff --git a/ts_web/elements/profilepicture/profilepicture.modal.ts b/ts_web/elements/profilepicture/profilepicture.modal.ts new file mode 100644 index 0000000..64928fd --- /dev/null +++ b/ts_web/elements/profilepicture/profilepicture.modal.ts @@ -0,0 +1,342 @@ +import { + DeesElement, + customElement, + html, + property, + css, + cssManager, + state, + type TemplateResult, +} from '@design.estate/dees-element'; +import '../dees-icon.js'; +import '../dees-button.js'; +import '../dees-windowlayer.js'; +import { ImageCropper } from './profilepicture.cropper.js'; +import { zIndexRegistry } from '../00zindex.js'; +import type { ProfileShape } from './dees-input-profilepicture.js'; + +@customElement('dees-profilepicture-modal') +export class ProfilePictureModal extends DeesElement { + @property({ type: String }) + public initialImage: string = ''; + + @property({ type: String }) + public shape: ProfileShape = 'round'; + + @state() + private currentStep: 'crop' | 'preview' = 'crop'; + + @state() + private croppedImage: string = ''; + + @state() + private isProcessing: boolean = false; + + private cropper: ImageCropper | null = null; + private windowLayer: any; + private zIndex: number = 0; + + public static styles = [ + cssManager.defaultStyles, + css` + :host { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-index); + } + + .modal-container { + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; + border-radius: 16px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + width: 90%; + max-width: 600px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalSlideIn 0.3s ease-out; + } + + @keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .modal-header { + padding: 24px; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + display: flex; + align-items: center; + justify-content: space-between; + } + + .modal-title { + font-size: 20px; + font-weight: 600; + color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; + } + + .close-button { + width: 32px; + height: 32px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; + transition: all 0.2s ease; + } + + .close-button:hover { + background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; + color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; + } + + .modal-body { + flex: 1; + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + } + + .cropper-container { + width: 100%; + max-width: 400px; + aspect-ratio: 1; + position: relative; + } + + .preview-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + } + + .preview-image { + width: 200px; + height: 200px; + object-fit: cover; + border: 3px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + } + + .preview-image.round { + border-radius: 50%; + } + + .preview-image.square { + border-radius: 12px; + } + + .success-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: ${cssManager.bdTheme('hsl(142 69% 45% / 0.1)', 'hsl(142 69% 55% / 0.1)')}; + color: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')}; + border-radius: 8px; + font-weight: 500; + } + + .modal-footer { + padding: 24px; + border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + display: flex; + gap: 12px; + justify-content: flex-end; + } + + .instructions { + text-align: center; + color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; + font-size: 14px; + } + + .loading-spinner { + width: 48px; + height: 48px; + border: 3px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + border-top-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + + // Get z-index from registry + this.zIndex = zIndexRegistry.getNextZIndex(); + this.style.setProperty('--z-index', this.zIndex.toString()); + + // Add window layer + this.windowLayer = document.createElement('dees-windowlayer'); + this.windowLayer.addEventListener('click', () => this.close()); + document.body.appendChild(this.windowLayer); + + // Register with z-index registry + zIndexRegistry.register(this, this.zIndex); + } + + async disconnectedCallback() { + super.disconnectedCallback(); + + // Cleanup + if (this.cropper) { + this.cropper.destroy(); + } + + if (this.windowLayer) { + this.windowLayer.remove(); + } + + // Unregister from z-index registry + zIndexRegistry.unregister(this); + } + + render(): TemplateResult { + return html` + + `; + } + + async firstUpdated() { + if (this.currentStep === 'crop') { + await this.initializeCropper(); + } + } + + private async initializeCropper(): Promise { + await this.updateComplete; + + const container = this.shadowRoot!.getElementById('cropperContainer'); + if (!container) return; + + this.cropper = new ImageCropper({ + container, + image: this.initialImage, + shape: this.shape, + aspectRatio: 1, + }); + + await this.cropper.initialize(); + } + + private async handleCrop(): Promise { + if (!this.cropper) return; + + try { + this.isProcessing = true; + this.currentStep = 'preview'; + await this.updateComplete; + + // Get cropped image + const croppedData = await this.cropper.getCroppedImage(); + this.croppedImage = croppedData; + + // Simulate processing time for better UX + await new Promise(resolve => setTimeout(resolve, 800)); + + this.isProcessing = false; + + // Emit save event + this.dispatchEvent(new CustomEvent('save', { + detail: { croppedImage: this.croppedImage }, + bubbles: true, + composed: true + })); + + // Auto close after showing success + setTimeout(() => { + this.close(); + }, 1500); + + } catch (error) { + console.error('Error cropping image:', error); + this.isProcessing = false; + } + } + + private close(): void { + this.remove(); + } +} \ No newline at end of file diff --git a/ts_web/pages/input-showcase.ts b/ts_web/pages/input-showcase.ts index 85f0561..afc8298 100644 --- a/ts_web/pages/input-showcase.ts +++ b/ts_web/pages/input-showcase.ts @@ -500,6 +500,31 @@ export const inputShowcase = () => html` .accept=${'image/*'} > + + +
+ + + + + +
+