update
This commit is contained in:
@ -32,6 +32,7 @@ export * from './dees-input-datepicker.js';
|
|||||||
export * from './dees-input-dropdown.js';
|
export * from './dees-input-dropdown.js';
|
||||||
export * from './dees-input-fileupload.js';
|
export * from './dees-input-fileupload.js';
|
||||||
export * from './dees-input-iban.js';
|
export * from './dees-input-iban.js';
|
||||||
|
export * from './profilepicture/dees-input-profilepicture.js';
|
||||||
export * from './dees-input-typelist.js';
|
export * from './dees-input-typelist.js';
|
||||||
export * from './dees-input-phone.js';
|
export * from './dees-input-phone.js';
|
||||||
export * from './dees-input-wysiwyg.js';
|
export * from './dees-input-wysiwyg.js';
|
||||||
|
208
ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts
Normal file
208
ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts
Normal file
@ -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`
|
||||||
|
<style>
|
||||||
|
${css`
|
||||||
|
.demo-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-panel {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 48px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-output {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 105, 242, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="demo-container">
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
// 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) + '...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<dees-panel .title=${'Profile Picture Input'} .subtitle=${'Basic usage with round and square shapes'}>
|
||||||
|
<div class="demo-row">
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Profile Picture (Round)"
|
||||||
|
description="Click to upload or drag & drop an image"
|
||||||
|
shape="round"
|
||||||
|
size="120"
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Profile Picture (Square)"
|
||||||
|
description="Supports JPEG, PNG, and WebP formats"
|
||||||
|
shape="square"
|
||||||
|
size="120"
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
// 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<dees-panel .title=${'Size Variations'} .subtitle=${'Profile pictures in different sizes'}>
|
||||||
|
<div class="demo-row">
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Small (80px)"
|
||||||
|
shape="round"
|
||||||
|
size="80"
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Medium (120px)"
|
||||||
|
shape="round"
|
||||||
|
size="120"
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Large (160px)"
|
||||||
|
shape="round"
|
||||||
|
size="160"
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<dees-panel .title=${'Pre-filled and Value Binding'} .subtitle=${'Profile picture with initial value and change tracking'}>
|
||||||
|
<dees-input-profilepicture
|
||||||
|
id="prefilled-profile"
|
||||||
|
label="Edit Existing Profile"
|
||||||
|
description="Click the edit button to change or delete to remove"
|
||||||
|
shape="round"
|
||||||
|
size="150"
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<div id="prefilled-output" class="demo-output">
|
||||||
|
Image data will appear here when changed
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
// Disabled state demo
|
||||||
|
const disabledProfile = elementArg.querySelector('#disabled-profile') as DeesInputProfilePicture;
|
||||||
|
if (disabledProfile) {
|
||||||
|
disabledProfile.value = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2NjY2NjYyIgLz4KICA8dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjYwIiBmaWxsPSJ3aGl0ZSI+TkE8L3RleHQ+Cjwvc3ZnPg==';
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<dees-panel .title=${'Form States'} .subtitle=${'Different states and configurations'}>
|
||||||
|
<div class="demo-row">
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Required Field"
|
||||||
|
description="This field is required"
|
||||||
|
shape="round"
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
id="disabled-profile"
|
||||||
|
label="Disabled State"
|
||||||
|
description="Cannot be edited"
|
||||||
|
shape="square"
|
||||||
|
.disabled=${true}
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
label="Upload Only"
|
||||||
|
description="Delete not allowed"
|
||||||
|
shape="round"
|
||||||
|
.allowDelete=${false}
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
|
||||||
|
<dees-demowrapper>
|
||||||
|
<dees-panel .title=${'Features'} .subtitle=${'Complete feature set of the profile picture input'}>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li><strong>Image Upload:</strong> Click to upload or drag & drop images</li>
|
||||||
|
<li><strong>Image Cropping:</strong> Interactive crop tool with resize handles</li>
|
||||||
|
<li><strong>Shape Support:</strong> Round or square profile pictures</li>
|
||||||
|
<li><strong>Size Customization:</strong> Adjustable dimensions</li>
|
||||||
|
<li><strong>Preview & Edit:</strong> Hover overlay with edit and delete options</li>
|
||||||
|
<li><strong>File Validation:</strong> Format and size restrictions</li>
|
||||||
|
<li><strong>Responsive Design:</strong> Works on desktop and mobile devices</li>
|
||||||
|
<li><strong>Form Integration:</strong> Standard form value binding and validation</li>
|
||||||
|
<li><strong>Accessibility:</strong> Keyboard navigation and screen reader support</li>
|
||||||
|
<li><strong>Z-Index Management:</strong> Proper modal stacking with registry</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px;">
|
||||||
|
<strong>Supported Formats:</strong> JPEG, PNG, WebP<br>
|
||||||
|
<strong>Max File Size:</strong> 5MB (configurable)<br>
|
||||||
|
<strong>Output Format:</strong> Base64 encoded JPEG
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
</div>
|
||||||
|
`;
|
361
ts_web/elements/profilepicture/dees-input-profilepicture.ts
Normal file
361
ts_web/elements/profilepicture/dees-input-profilepicture.ts
Normal file
@ -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<DeesInputProfilePicture> {
|
||||||
|
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`
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="profile-container"
|
||||||
|
@click=${this.handleClick}
|
||||||
|
@dragover=${this.handleDragOver}
|
||||||
|
@dragleave=${this.handleDragLeave}
|
||||||
|
@drop=${this.handleDrop}
|
||||||
|
style="--size: ${this.size}px"
|
||||||
|
>
|
||||||
|
<div class="profile-picture ${this.shape} ${this.isDragging ? 'dragging' : ''}">
|
||||||
|
${this.value ? html`
|
||||||
|
<img class="profile-image" src="${this.value}" alt="Profile picture" />
|
||||||
|
` : html`
|
||||||
|
<dees-icon class="placeholder-icon" icon="lucide:user" iconSize="${this.size * 0.5}"></dees-icon>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${this.isDragging ? html`
|
||||||
|
<div class="overlay" style="opacity: 1">
|
||||||
|
<div class="drop-zone-text">
|
||||||
|
Drop image here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.value && !this.disabled ? html`
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="overlay-content">
|
||||||
|
${this.allowUpload ? html`
|
||||||
|
<button class="overlay-button" @click=${(e: Event) => { e.stopPropagation(); this.openModal(); }} title="Change picture">
|
||||||
|
<dees-icon icon="lucide:pencil" iconSize="20"></dees-icon>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${this.allowDelete ? html`
|
||||||
|
<button class="overlay-button delete" @click=${(e: Event) => { e.stopPropagation(); this.deletePicture(); }} title="Delete picture">
|
||||||
|
<dees-icon icon="lucide:trash2" iconSize="20"></dees-icon>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden-input"
|
||||||
|
accept="${this.acceptedFormats.join(',')}"
|
||||||
|
@change=${this.handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
3
ts_web/elements/profilepicture/index.ts
Normal file
3
ts_web/elements/profilepicture/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './dees-input-profilepicture.js';
|
||||||
|
export * from './profilepicture.modal.js';
|
||||||
|
export * from './profilepicture.cropper.js';
|
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();
|
||||||
|
}
|
||||||
|
}
|
342
ts_web/elements/profilepicture/profilepicture.modal.ts
Normal file
342
ts_web/elements/profilepicture/profilepicture.modal.ts
Normal file
@ -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`
|
||||||
|
<div class="modal-container" @click=${(e: Event) => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">
|
||||||
|
${this.currentStep === 'crop' ? 'Crop Profile Picture' : 'Preview'}
|
||||||
|
</h3>
|
||||||
|
<button class="close-button" @click=${this.close}>
|
||||||
|
<dees-icon icon="lucide:x" iconSize="20"></dees-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
${this.currentStep === 'crop' ? html`
|
||||||
|
<div class="instructions">
|
||||||
|
Drag and resize the selection area to crop your profile picture
|
||||||
|
</div>
|
||||||
|
<div class="cropper-container" id="cropperContainer"></div>
|
||||||
|
` : html`
|
||||||
|
<div class="preview-container">
|
||||||
|
${this.isProcessing ? html`
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="instructions">Processing image...</div>
|
||||||
|
` : html`
|
||||||
|
<img
|
||||||
|
class="preview-image ${this.shape}"
|
||||||
|
src="${this.croppedImage}"
|
||||||
|
alt="Cropped preview"
|
||||||
|
/>
|
||||||
|
<div class="success-message">
|
||||||
|
<dees-icon icon="lucide:checkCircle" iconSize="20"></dees-icon>
|
||||||
|
<span>Profile picture updated successfully!</span>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
${this.currentStep === 'crop' ? html`
|
||||||
|
<dees-button type="secondary" @click=${this.close}>
|
||||||
|
Cancel
|
||||||
|
</dees-button>
|
||||||
|
<dees-button type="primary" @click=${this.handleCrop}>
|
||||||
|
Crop & Save
|
||||||
|
</dees-button>
|
||||||
|
` : html`
|
||||||
|
${!this.isProcessing ? html`
|
||||||
|
<dees-button type="primary" @click=${this.close}>
|
||||||
|
Done
|
||||||
|
</dees-button>
|
||||||
|
` : ''}
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstUpdated() {
|
||||||
|
if (this.currentStep === 'crop') {
|
||||||
|
await this.initializeCropper();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeCropper(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@ -500,6 +500,31 @@ export const inputShowcase = () => html`
|
|||||||
.accept=${'image/*'}
|
.accept=${'image/*'}
|
||||||
></dees-input-fileupload>
|
></dees-input-fileupload>
|
||||||
</dees-panel>
|
</dees-panel>
|
||||||
|
|
||||||
|
<dees-panel .title=${'Profile Picture Input'} .subtitle=${'Image upload with cropping'}>
|
||||||
|
<div class="demo-grid">
|
||||||
|
<dees-input-profilepicture
|
||||||
|
.label=${'User Avatar'}
|
||||||
|
.description=${'Round profile picture'}
|
||||||
|
.shape=${'round'}
|
||||||
|
.size=${120}
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
.label=${'Company Logo'}
|
||||||
|
.description=${'Square format'}
|
||||||
|
.shape=${'square'}
|
||||||
|
.size=${120}
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
|
||||||
|
<dees-input-profilepicture
|
||||||
|
.label=${'Team Member'}
|
||||||
|
.description=${'Larger profile image'}
|
||||||
|
.shape=${'round'}
|
||||||
|
.size=${150}
|
||||||
|
></dees-input-profilepicture>
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Rich Editors Section -->
|
<!-- Rich Editors Section -->
|
||||||
|
Reference in New Issue
Block a user