diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 77389fe0f19..2458bf0dd12 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.24 + +- Support multiple files preview + ## 2.20 - Enable file replacement via "drag-and-drop" diff --git a/src/Dropzone/assets/dist/controller.d.ts b/src/Dropzone/assets/dist/controller.d.ts index 6e67b85b5cd..199c79396df 100644 --- a/src/Dropzone/assets/dist/controller.d.ts +++ b/src/Dropzone/assets/dist/controller.d.ts @@ -1,19 +1,48 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { readonly inputTarget: HTMLInputElement; - readonly placeholderTarget: HTMLDivElement; - readonly previewTarget: HTMLDivElement; - readonly previewClearButtonTarget: HTMLButtonElement; - readonly previewFilenameTarget: HTMLDivElement; - readonly previewImageTarget: HTMLDivElement; + readonly placeholderTarget: HTMLElement; + readonly previewTargets: HTMLElement[]; + readonly previewContainerTarget: HTMLElement; + readonly previewTemplateTarget: HTMLTemplateElement; + readonly optionsValue: any; + static values: { + options: { + type: ObjectConstructor; + default: { + preview: { + style: string; + can_open_file_picker: boolean; + can_toggle_placeholder: boolean; + }; + }; + }; + }; static targets: string[]; + files: Map; initialize(): void; connect(): void; disconnect(): void; clear(): void; onInputChange(event: any): void; - _populateImagePreview(file: Blob): void; - onDragEnter(): void; onDragLeave(event: any): void; + onDragOver(event: any): void; + onDrop(event: any): void; + onPreviewContainerClick(event: any): void; + onPreviewButtonClick(event: any): void; private dispatchEvent; + private addFiles; + private buildPreview; + private refreshPreview; + private isImage; + private get isMultiple(); + private updateFileInput; + private formatBytes; + private get firstFile(); + private get isLegacy(); + private refreshLegacyPreview; + private showLegacyPreview; + private hideLegacyPreview; + private showLegacyFileInput; + private hideLegacyFileInput; } diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index ebfb380d12a..e0eac910a33 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -1,79 +1,265 @@ import { Controller } from '@hotwired/stimulus'; class default_1 extends Controller { + constructor() { + super(...arguments); + this.files = new Map(); + } initialize() { this.clear = this.clear.bind(this); this.onInputChange = this.onInputChange.bind(this); - this.onDragEnter = this.onDragEnter.bind(this); this.onDragLeave = this.onDragLeave.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDrop = this.onDrop.bind(this); + this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this); + this.onPreviewContainerClick = this.onPreviewContainerClick.bind(this); } connect() { this.clear(); - this.previewClearButtonTarget.addEventListener('click', this.clear); this.inputTarget.addEventListener('change', this.onInputChange); - this.element.addEventListener('dragenter', this.onDragEnter); this.element.addEventListener('dragleave', this.onDragLeave); + this.element.addEventListener('dragover', this.onDragOver); + this.element.addEventListener('drop', this.onDrop); + if (!this.isLegacy && this.optionsValue.preview.can_open_file_picker) { + this.previewContainerTarget.addEventListener('click', this.onPreviewContainerClick); + } this.dispatchEvent('connect'); } disconnect() { - this.previewClearButtonTarget.removeEventListener('click', this.clear); + this.clear(); this.inputTarget.removeEventListener('change', this.onInputChange); - this.element.removeEventListener('dragenter', this.onDragEnter); this.element.removeEventListener('dragleave', this.onDragLeave); + this.element.removeEventListener('dragover', this.onDragOver); + this.element.removeEventListener('drop', this.onDrop); + if (!this.isLegacy && this.optionsValue.preview.can_open_file_picker) { + this.previewContainerTarget.removeEventListener('click', this.onPreviewContainerClick); + } } clear() { - this.inputTarget.value = ''; - this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; - this.previewImageTarget.style.display = 'none'; - this.previewImageTarget.style.backgroundImage = 'none'; - this.previewFilenameTarget.textContent = ''; + this.files.clear(); + this.updateFileInput(); + this.refreshPreview(); + this.element.classList.remove('dropzone-active'); + if (this.isLegacy) { + this.showLegacyFileInput(); + } this.dispatchEvent('clear'); } onInputChange(event) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + const files = Array.from(event.target.files).filter((file) => typeof file !== 'undefined'); + if (files.length === 0) { return; } - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; - this.previewFilenameTarget.textContent = file.name; - this.previewTarget.style.display = 'flex'; - this.previewImageTarget.style.display = 'none'; - if (file.type && file.type.indexOf('image') !== -1) { - this._populateImagePreview(file); + this.files.clear(); + this.addFiles(files); + this.refreshPreview(); + this.dispatchEvent('change', this.isLegacy ? this.firstFile : Array.from(this.files.values())); + } + onDragLeave(event) { + event.preventDefault(); + if (!this.element.contains(event.relatedTarget)) { + this.element.classList.remove('dropzone-active'); + if (this.isLegacy) { + this.hideLegacyFileInput(); + this.showLegacyPreview(); + } + } + } + onDragOver(event) { + event.preventDefault(); + this.element.classList.add('dropzone-active'); + if (this.isLegacy) { + this.hideLegacyPreview(); + this.showLegacyFileInput(); } - this.dispatchEvent('change', file); } - _populateImagePreview(file) { - if (typeof FileReader === 'undefined') { + onDrop(event) { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files).filter((file) => typeof file !== 'undefined'); + if (files.length === 0) { return; } - const reader = new FileReader(); - reader.addEventListener('load', (event) => { - this.previewImageTarget.style.display = 'block'; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; - }); - reader.readAsDataURL(file); + if (!this.isMultiple) { + this.files.clear(); + } + this.addFiles(files); + this.updateFileInput(); + this.refreshPreview(); + this.element.classList.remove('dropzone-active'); + this.dispatchEvent('change', Array.from(this.files.values())); } - onDragEnter() { - this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; + onPreviewContainerClick(event) { + event.stopPropagation(); + this.inputTarget.click(); } - onDragLeave(event) { - event.preventDefault(); - if (!this.element.contains(event.relatedTarget)) { - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; - this.previewTarget.style.display = 'block'; + onPreviewButtonClick(event) { + event.stopPropagation(); + if (this.isLegacy) { + return this.clear(); } + const button = event.currentTarget; + button.removeEventListener('click', this.onPreviewButtonClick); + const preview = button.closest('.dropzone-preview'); + preview.remove(); + if (!button.dataset.filename) { + return; + } + this.files.delete(button.dataset.filename); + this.updateFileInput(); + this.refreshPreview(); } dispatchEvent(name, payload = {}) { this.dispatch(name, { detail: payload, prefix: 'dropzone' }); } + addFiles(files) { + for (const file of files) { + this.files.set(file.name, file); + } + } + buildPreview(file, el) { + if (!el) { + el = this.previewTemplateTarget.content.firstElementChild?.cloneNode(true); + } + const button = el.querySelector('.dropzone-preview-button'); + if (button) { + button.dataset.filename = file.name; + button.addEventListener('click', this.onPreviewButtonClick); + } + const filename = el.querySelector('.dropzone-preview-filename'); + if (filename) { + filename.textContent = file.name; + } + const size = el.querySelector('.dropzone-preview-file-size'); + if (size) { + size.textContent = this.formatBytes(file.size); + } + const image = el.querySelector('.dropzone-preview-image'); + if (image && this.isImage(file) && typeof FileReader !== 'undefined') { + const reader = new FileReader(); + image.classList.add('dropzone-preview-image-hidden'); + reader.addEventListener('load', (event) => { + image.querySelector('.dropzone-preview-image-placeholder')?.remove(); + image.style.backgroundImage = `url('${event.target.result}')`; + image.classList.remove('dropzone-preview-image-hidden'); + }); + reader.readAsDataURL(file); + } + return el; + } + refreshPreview() { + if (this.isLegacy) { + return this.refreshLegacyPreview(); + } + this.element.classList.add('dropzone-preview-container-hidden'); + for (const preview of this.previewTargets) { + preview.querySelector('.dropzone-preview-button')?.removeEventListener('click', this.onPreviewButtonClick); + preview.remove(); + } + for (const file of this.files.values()) { + const preview = this.buildPreview(file); + this.previewContainerTarget.appendChild(preview); + } + if (this.previewTargets.length > 0) { + this.element.classList.remove('dropzone-preview-container-hidden'); + } + const canToggle = this.optionsValue.preview.can_toggle_placeholder; + if (canToggle) { + const hide = this.previewTargets.length > 0 && + (canToggle === true || (canToggle === 'auto' && this.previewTargets.length < 2)); + this.element.classList.toggle('dropzone-placeholder-hidden', hide); + } + } + isImage(file) { + return typeof file.type !== 'undefined' && file.type.indexOf('image') !== -1; + } + get isMultiple() { + return this.inputTarget.multiple; + } + updateFileInput() { + const dataTransfer = new DataTransfer(); + for (const file of this.files.values()) { + dataTransfer.items.add(file); + } + this.inputTarget.files = dataTransfer.files; + } + formatBytes(bytes, decimals = 2) { + if (bytes === 0) + return '0 Bytes'; + const k = 1024; + const dm = decimals || 2; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; + } + get firstFile() { + return this.files.values().next().value; + } + get isLegacy() { + return this.optionsValue.preview.style === 'legacy'; + } + refreshLegacyPreview() { + const preview = this.previewTargets[0]; + const image = preview.querySelector('.dropzone-preview-image'); + const filename = preview.querySelector('.dropzone-preview-filename'); + const file = this.firstFile; + if (!file) { + this.hideLegacyPreview(); + if (filename) { + filename.textContent = ''; + } + if (image) { + image.style.display = 'none'; + image.style.backgroundImage = 'none'; + } + return; + } + this.buildPreview(file, preview); + const fileCount = this.files.size; + if (filename && fileCount > 1) { + filename.textContent += ` +${fileCount - 1}`; + filename.title = Array.from(this.files.values()) + .map((file) => file.name) + .join('\n'); + } + if (image) { + if (this.isImage(file)) { + image.style.display = 'block'; + } + else { + image.style.display = 'none'; + image.style.backgroundImage = 'none'; + } + } + this.showLegacyPreview(); + this.hideLegacyFileInput(); + } + showLegacyPreview() { + this.previewTargets[0].style.display = 'flex'; + } + hideLegacyPreview() { + this.previewTargets[0].style.display = 'none'; + } + showLegacyFileInput() { + this.inputTarget.style.display = 'block'; + this.placeholderTarget.style.display = 'block'; + } + hideLegacyFileInput() { + this.inputTarget.style.display = 'none'; + this.placeholderTarget.style.display = 'none'; + } } -default_1.targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage']; +default_1.values = { + options: { + type: Object, + default: { + preview: { + style: 'legacy', + can_open_file_picker: true, + can_toggle_placeholder: true, + }, + }, + }, +}; +default_1.targets = ['input', 'placeholder', 'preview', 'previewContainer', 'previewTemplate']; export { default_1 as default }; diff --git a/src/Dropzone/assets/dist/style.min.css b/src/Dropzone/assets/dist/style.min.css index 4c1e49daedb..1c4a8260fcd 100644 --- a/src/Dropzone/assets/dist/style.min.css +++ b/src/Dropzone/assets/dist/style.min.css @@ -1 +1 @@ -.dropzone-container{border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{align-items:center;max-width:100%;display:flex}.dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:absolute;top:0;right:0;overflow:visible}.dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}.dropzone-placeholder{text-align:center;color:#999;flex-grow:1} \ No newline at end of file +.dropzone-container:not(.dropzone-block,.dropzone-inline){border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative;& .dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}& .dropzone-preview{align-items:center;max-width:100%;display:flex}& .dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}& .dropzone-preview-filename{word-wrap:anywhere}& .dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;appearance:inherit;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:absolute;top:0;right:0;overflow:visible}& .dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}& .dropzone-placeholder{text-align:center;color:#999;flex-grow:1}}.dropzone-container.dropzone-block,.dropzone-container.dropzone-inline{border:2px dashed #bbb;position:relative;&.dropzone-active{outline:5px auto highlight;outline:5px auto -webkit-focus-ring-color}& .dropzone-placeholder{text-align:center;color:#999;cursor:pointer;z-index:1;align-content:center;min-height:100px;display:block}& .dropzone-placeholder:focus-within{outline:5px auto highlight;outline:5px auto -webkit-focus-ring-color}& .dropzone-preview{border-top:1px solid #eee;align-items:center;padding:15px 15px 15px 20px;display:flex;position:relative}& .dropzone-preview:hover{background-color:#f9f9f9}& .dropzone-preview>div:first-child{flex-grow:1;align-items:center;display:flex}& .dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;min-width:50px;max-width:50px;height:50px;margin-right:10px}& .dropzone-preview-meta{flex-grow:1}& .dropzone-preview-filename{word-wrap:anywhere;color:#101828;margin:0 0 5px;font-size:small;font-weight:700}& .dropzone-preview-file-size{color:#6a7282;margin:0;font-size:small}& .dropzone-preview-button{width:auto;color:#99a1af;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;appearance:inherit;cursor:pointer;background:0 0;border:none;margin:0;padding:5px;line-height:0;overflow:visible}& .dropzone-preview:hover .dropzone-preview-button,& .dropzone-preview-button:focus{color:#101828}}.dropzone-container.dropzone-inline{&.highlighted{outline:5px auto highlight;outline:5px auto -webkit-focus-ring-color}& .dropzone-preview-container{border-top:1px solid #eee;flex-wrap:wrap;padding:10px;display:flex}& .dropzone-preview{border:none;flex-direction:column;flex-shrink:0;padding:20px;position:relative}& .dropzone-preview-image{margin:0 0 5px}& .dropzone-preview-filename{text-align:center;margin:0 0 5px}& .dropzone-preview-file-size{text-align:center}& .dropzone-preview-button{position:absolute;top:0;right:0}& .dropzone-preview:not(:hover) .dropzone-preview-button:not(:focus){clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}}.dropzone-container.dropzone-block,.dropzone-container.dropzone-inline{& .dropzone-input,&.dropzone-placeholder-hidden .dropzone-placeholder{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}& .dropzone-preview-image-hidden,&.dropzone-preview-container-hidden .dropzone-preview-container{display:none}}.dropzone-container.dropzone-placeholder-hidden{&.dropzone-block{& .dropzone-preview:first-of-type{border-style:none}}&.dropzone-inline{& .dropzone-preview-container{border-style:none}}}.dropzone-container.dropzone-preview-can-open-file-picker{& .dropzone-preview-container{cursor:pointer}} \ No newline at end of file diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index b2533329388..0bd85926508 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -11,116 +11,352 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { declare readonly inputTarget: HTMLInputElement; - declare readonly placeholderTarget: HTMLDivElement; - declare readonly previewTarget: HTMLDivElement; - declare readonly previewClearButtonTarget: HTMLButtonElement; - declare readonly previewFilenameTarget: HTMLDivElement; - declare readonly previewImageTarget: HTMLDivElement; + declare readonly placeholderTarget: HTMLElement; + declare readonly previewTargets: HTMLElement[]; + declare readonly previewContainerTarget: HTMLElement; + declare readonly previewTemplateTarget: HTMLTemplateElement; + declare readonly optionsValue: any; - static targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage']; + static values = { + // Default required for `legacy` style since options aren't bound in legacy controller markup + // Remove default when `legacy` style is deprecated, as defaults are set in form options + options: { + type: Object, + default: { + preview: { + style: 'legacy', + can_open_file_picker: true, + can_toggle_placeholder: true, + }, + }, + }, + }; + + static targets = ['input', 'placeholder', 'preview', 'previewContainer', 'previewTemplate']; + + files: Map = new Map(); initialize() { this.clear = this.clear.bind(this); this.onInputChange = this.onInputChange.bind(this); - this.onDragEnter = this.onDragEnter.bind(this); this.onDragLeave = this.onDragLeave.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDrop = this.onDrop.bind(this); + this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this); + this.onPreviewContainerClick = this.onPreviewContainerClick.bind(this); } connect() { // Reset when connecting to work with Turbolinks this.clear(); - // Clear on click on clear button - this.previewClearButtonTarget.addEventListener('click', this.clear); - // Listen on input change and display preview this.inputTarget.addEventListener('change', this.onInputChange); - // Add dragenter event listener - this.element.addEventListener('dragenter', this.onDragEnter); - // Add dragleave event listener this.element.addEventListener('dragleave', this.onDragLeave); + // Add dragover event listener + this.element.addEventListener('dragover', this.onDragOver); + + // Add drop event listener + this.element.addEventListener('drop', this.onDrop); + + // Show file picker when preview container is clicked + if (!this.isLegacy && this.optionsValue.preview.can_open_file_picker) { + this.previewContainerTarget.addEventListener('click', this.onPreviewContainerClick); + } + this.dispatchEvent('connect'); } disconnect() { - this.previewClearButtonTarget.removeEventListener('click', this.clear); + this.clear(); this.inputTarget.removeEventListener('change', this.onInputChange); - this.element.removeEventListener('dragenter', this.onDragEnter); this.element.removeEventListener('dragleave', this.onDragLeave); + this.element.removeEventListener('dragover', this.onDragOver); + this.element.removeEventListener('drop', this.onDrop); + if (!this.isLegacy && this.optionsValue.preview.can_open_file_picker) { + this.previewContainerTarget.removeEventListener('click', this.onPreviewContainerClick); + } } clear() { - this.inputTarget.value = ''; - this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; - this.previewImageTarget.style.display = 'none'; - this.previewImageTarget.style.backgroundImage = 'none'; - this.previewFilenameTarget.textContent = ''; + this.files.clear(); + this.updateFileInput(); + this.refreshPreview(); + this.element.classList.remove('dropzone-active'); + + if (this.isLegacy) { + this.showLegacyFileInput(); + } this.dispatchEvent('clear'); } onInputChange(event: any) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + const files = (Array.from(event.target.files)).filter((file) => typeof file !== 'undefined'); + if (files.length === 0) { return; } - // Hide the input and placeholder - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; + this.files.clear(); + this.addFiles(files); + this.refreshPreview(); + + this.dispatchEvent('change', this.isLegacy ? this.firstFile : Array.from(this.files.values())); + } - // Show the filename in preview - this.previewFilenameTarget.textContent = file.name; - this.previewTarget.style.display = 'flex'; + onDragLeave(event: any) { + event.preventDefault(); + + // Check if we really leave the main drag area + if (!this.element.contains(event.relatedTarget as Node)) { + this.element.classList.remove('dropzone-active'); - // If the file is an image, load it and display it as preview - this.previewImageTarget.style.display = 'none'; - if (file.type && file.type.indexOf('image') !== -1) { - this._populateImagePreview(file); + if (this.isLegacy) { + this.hideLegacyFileInput(); + this.showLegacyPreview(); + } } + } + + onDragOver(event: any) { + event.preventDefault(); + this.element.classList.add('dropzone-active'); - this.dispatchEvent('change', file); + if (this.isLegacy) { + this.hideLegacyPreview(); + this.showLegacyFileInput(); + } } - _populateImagePreview(file: Blob) { - if (typeof FileReader === 'undefined') { - // FileReader API not available, skip + onDrop(event: any) { + event.preventDefault(); + + const files = (Array.from(event.dataTransfer.files)).filter((file) => typeof file !== 'undefined'); + if (files.length === 0) { return; } - const reader = new FileReader(); + if (!this.isMultiple) { + this.files.clear(); + } - reader.addEventListener('load', (event: any) => { - this.previewImageTarget.style.display = 'block'; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; - }); + this.addFiles(files); + this.updateFileInput(); + this.refreshPreview(); + this.element.classList.remove('dropzone-active'); - reader.readAsDataURL(file); + this.dispatchEvent('change', Array.from(this.files.values())); } - onDragEnter() { - this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; + onPreviewContainerClick(event: any) { + event.stopPropagation(); + this.inputTarget.click(); } - onDragLeave(event: any) { - event.preventDefault(); + onPreviewButtonClick(event: any) { + event.stopPropagation(); - // Check if we really leave the main drag area - if (!this.element.contains(event.relatedTarget as Node)) { - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; - this.previewTarget.style.display = 'block'; + if (this.isLegacy) { + return this.clear(); } + + const button = event.currentTarget; + button.removeEventListener('click', this.onPreviewButtonClick); + + const preview = button.closest('.dropzone-preview'); + preview.remove(); + + if (!button.dataset.filename) { + return; + } + this.files.delete(button.dataset.filename); + + this.updateFileInput(); + this.refreshPreview(); } private dispatchEvent(name: string, payload: any = {}) { this.dispatch(name, { detail: payload, prefix: 'dropzone' }); } + + private addFiles(files: File[]) { + for (const file of files) { + this.files.set(file.name, file); + } + } + + private buildPreview(file: File, el?: HTMLElement): HTMLElement { + if (!el) { + el = this.previewTemplateTarget.content.firstElementChild?.cloneNode(true) as HTMLElement; + } + + const button = el.querySelector('.dropzone-preview-button'); + if (button) { + button.dataset.filename = file.name; + button.addEventListener('click', this.onPreviewButtonClick); + } + + const filename = el.querySelector('.dropzone-preview-filename'); + if (filename) { + filename.textContent = file.name; + } + + const size = el.querySelector('.dropzone-preview-file-size'); + if (size) { + size.textContent = this.formatBytes(file.size); + } + + const image = el.querySelector('.dropzone-preview-image'); + + if (image && this.isImage(file) && typeof FileReader !== 'undefined') { + // If the file is an image, load it and display it as preview + const reader = new FileReader(); + + image.classList.add('dropzone-preview-image-hidden'); + reader.addEventListener('load', (event: any) => { + image.querySelector('.dropzone-preview-image-placeholder')?.remove(); + image.style.backgroundImage = `url('${event.target.result}')`; + image.classList.remove('dropzone-preview-image-hidden'); + }); + + reader.readAsDataURL(file as Blob); + } + + return el; + } + + private refreshPreview() { + if (this.isLegacy) { + return this.refreshLegacyPreview(); + } + + this.element.classList.add('dropzone-preview-container-hidden'); + + for (const preview of this.previewTargets) { + preview.querySelector('.dropzone-preview-button')?.removeEventListener('click', this.onPreviewButtonClick); + preview.remove(); + } + + for (const file of this.files.values()) { + const preview = this.buildPreview(file); + this.previewContainerTarget.appendChild(preview); + } + + if (this.previewTargets.length > 0) { + this.element.classList.remove('dropzone-preview-container-hidden'); + } + + const canToggle = this.optionsValue.preview.can_toggle_placeholder; + if (canToggle) { + const hide = + this.previewTargets.length > 0 && + (canToggle === true || (canToggle === 'auto' && this.previewTargets.length < 2)); + this.element.classList.toggle('dropzone-placeholder-hidden', hide); + } + } + + private isImage(file: File): boolean { + return typeof file.type !== 'undefined' && file.type.indexOf('image') !== -1; + } + + private get isMultiple(): boolean { + return this.inputTarget.multiple; + } + + private updateFileInput() { + const dataTransfer = new DataTransfer(); + for (const file of this.files.values()) { + dataTransfer.items.add(file); + } + this.inputTarget.files = dataTransfer.files; + } + + // Credit: [Pawel Zentala](https://github.com/zentala) + // https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c + private formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals || 2; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; + } + + // + // Legacy methods + // + + private get firstFile(): File | undefined { + return this.files.values().next().value; + } + + private get isLegacy(): boolean { + return this.optionsValue.preview.style === 'legacy'; + } + + private refreshLegacyPreview() { + const preview = this.previewTargets[0]; + const image = preview.querySelector('.dropzone-preview-image'); + const filename = preview.querySelector('.dropzone-preview-filename'); + + const file = this.firstFile; + + if (!file) { + this.hideLegacyPreview(); + + if (filename) { + filename.textContent = ''; + } + + if (image) { + image.style.display = 'none'; + image.style.backgroundImage = 'none'; + } + + return; + } + + this.buildPreview(file, preview); + + const fileCount = this.files.size; + if (filename && fileCount > 1) { + filename.textContent += ` +${fileCount - 1}`; + (filename).title = Array.from(this.files.values()) + .map((file: File) => file.name) + .join('\n'); + } + + if (image) { + if (this.isImage(file)) { + image.style.display = 'block'; + } else { + image.style.display = 'none'; + image.style.backgroundImage = 'none'; + } + } + + this.showLegacyPreview(); + this.hideLegacyFileInput(); + } + + private showLegacyPreview() { + this.previewTargets[0].style.display = 'flex'; + } + + private hideLegacyPreview() { + this.previewTargets[0].style.display = 'none'; + } + + private showLegacyFileInput() { + this.inputTarget.style.display = 'block'; + this.placeholderTarget.style.display = 'block'; + } + + private hideLegacyFileInput() { + this.inputTarget.style.display = 'none'; + this.placeholderTarget.style.display = 'none'; + } } diff --git a/src/Dropzone/assets/src/style.css b/src/Dropzone/assets/src/style.css index 4cd21ac6b8e..92b4a5c0f20 100644 --- a/src/Dropzone/assets/src/style.css +++ b/src/Dropzone/assets/src/style.css @@ -1,72 +1,270 @@ -.dropzone-container { +/* Legacy styles */ +.dropzone-container:not(.dropzone-block, .dropzone-inline) { position: relative; display: flex; min-height: 100px; border: 2px dashed #bbb; align-items: center; padding: 20px 10px; -} -.dropzone-input { - position: absolute; - display: block; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0; - cursor: pointer; - z-index: 1; -} + .dropzone-input { + position: absolute; + display: block; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + z-index: 1; + } -.dropzone-preview { - display: flex; - align-items: center; - max-width: 100%; -} + .dropzone-preview { + display: flex; + align-items: center; + max-width: 100%; + } + + .dropzone-preview-image { + flex-basis: 0; + min-width: 50px; + max-width: 50px; + height: 50px; + margin-right: 10px; + background-size: contain; + background-position: 50% 50%; + background-repeat: no-repeat; + } + + .dropzone-preview-filename { + word-wrap: anywhere; + } + + .dropzone-preview-button { + position: absolute; + top: 0; + right: 0; + z-index: 1; + border: none; + margin: 0; + padding: 0; + width: auto; + overflow: visible; + background: transparent; + color: inherit; + font: inherit; + line-height: normal; + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + -webkit-appearance: none; + appearance: inherit; + } + + .dropzone-preview-button::before { + content: '×'; + padding: 3px 7px; + cursor: pointer; + } -.dropzone-preview-image { - flex-basis: 0; - min-width: 50px; - max-width: 50px; - height: 50px; - margin-right: 10px; - background-size: contain; - background-position: 50% 50%; - background-repeat: no-repeat; + .dropzone-placeholder { + flex-grow: 1; + text-align: center; + color: #999; + } } -.dropzone-preview-filename { - word-wrap: anywhere; +/* Block/inline styles */ +.dropzone-container.dropzone-block, +.dropzone-container.dropzone-inline +{ + position: relative; + border: 2px dashed #bbb; + + &.dropzone-active { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; + } + + .dropzone-placeholder { + display: block; + text-align: center; + align-content: center; + color: #999; + min-height: 100px; + cursor: pointer; + z-index:1; + } + + .dropzone-placeholder:focus-within { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; + } + + .dropzone-preview { + position: relative; + display: flex; + align-items: center; + padding: 15px 15px 15px 20px; + border-top: 1px solid #eee; + } + + .dropzone-preview:hover { + background-color: #f9f9f9; + } + + .dropzone-preview>div:first-child { + display: flex; + align-items: center; + flex-grow: 1; + } + + .dropzone-preview-image { + min-width: 50px; + max-width: 50px; + height: 50px; + margin-right: 10px; + background-size: contain; + background-position: 50% 50%; + background-repeat: no-repeat; + } + + .dropzone-preview-meta { + flex-grow: 1; + } + + .dropzone-preview-filename { + word-wrap: anywhere; + font-size: small; + margin: 0 0 5px 0; + color: #101828; + font-weight: bold; + } + + .dropzone-preview-file-size { + font-size: small; + margin: 0; + color: #6a7282; + } + + .dropzone-preview-button { + border: none; + margin: 0; + padding: 5px; + width: auto; + overflow: visible; + background: transparent; + color: inherit; + font: inherit; + line-height: 0; + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + -webkit-appearance: none; + appearance: inherit; + cursor: pointer; + color: #99a1af; + } + + .dropzone-preview:hover .dropzone-preview-button, + .dropzone-preview-button:focus { + color: #101828; + } } -.dropzone-preview-button { - position: absolute; - top: 0; - right: 0; - z-index: 1; - border: none; - margin: 0; - padding: 0; - width: auto; - overflow: visible; - background: transparent; - color: inherit; - font: inherit; - line-height: normal; - -webkit-font-smoothing: inherit; - -moz-osx-font-smoothing: inherit; - -webkit-appearance: none; +.dropzone-container.dropzone-inline +{ + &.highlighted { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; + } + + .dropzone-preview-container { + display:flex; + flex-wrap: wrap; + padding: 10px; + border-top: 1px solid #eee; + } + + .dropzone-preview { + position: relative; + padding: 20px; + border: none; + flex-shrink: 0; + flex-direction: column; + } + + .dropzone-preview-image { + margin:0 0 5px 0; + } + + .dropzone-preview-filename { + margin: 0 0 5px 0; + text-align: center; + } + + .dropzone-preview-file-size { + text-align: center; + } + + .dropzone-preview-button { + position: absolute; + top: 0; + right: 0; + } + + .dropzone-preview:not(:hover) .dropzone-preview-button:not(:focus) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } } -.dropzone-preview-button::before { - content: '×'; - padding: 3px 7px; - cursor: pointer; +.dropzone-container.dropzone-block, +.dropzone-container.dropzone-inline +{ + /* Visible to screen readers only */ + .dropzone-input, + &.dropzone-placeholder-hidden .dropzone-placeholder { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + .dropzone-preview-image-hidden, + &.dropzone-preview-container-hidden .dropzone-preview-container { + display: none; + } + } -.dropzone-placeholder { - flex-grow: 1; - text-align: center; - color: #999; +.dropzone-container.dropzone-placeholder-hidden { + + &.dropzone-block { + .dropzone-preview:first-of-type { + border-style: none; + } + } + + &.dropzone-inline { + .dropzone-preview-container { + border-style: none; + } + } } + +.dropzone-container.dropzone-preview-can-open-file-picker { + .dropzone-preview-container { + cursor:pointer; + } +} \ No newline at end of file diff --git a/src/Dropzone/assets/test/controller.test.ts b/src/Dropzone/assets/test/controller.test.ts index b37dadf4bbb..8495316fd3f 100644 --- a/src/Dropzone/assets/test/controller.test.ts +++ b/src/Dropzone/assets/test/controller.test.ts @@ -11,6 +11,7 @@ import { Application, Controller } from '@hotwired/stimulus'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import { getByTestId, waitFor } from '@testing-library/dom'; import user from '@testing-library/user-event'; +import { vi } from 'vitest'; import DropzoneController from '../src/controller'; // Controller used to check the actual controller was properly booted @@ -32,6 +33,29 @@ describe('DropzoneController', () => { let container: HTMLElement; beforeEach(() => { + // HACK: Mock `DataTransfer` class until supported by jsdom or use `createDataTransfer` + // util function after upgrading `@testing-library/user-event` package + // https://github.com/jsdom/jsdom/issues/1568 + // https://github.com/testing-library/user-event/issues/1245 + global.DataTransfer = vi.fn(function () { + this.input = document.createElement('input'); + this.input.type = 'file'; + this.data = []; + + Object.defineProperty(this, 'files', { + get: () => { + user.upload(this.input, this.data); + return this.input.files; + }, + }); + + this.items = () => { + this.add = (file: File) => { + this.data.push(file); + }; + }; + }); + container = mountDOM(`
{ dispatched = true; }); - // Manually show preview - getByTestId(container, 'input').style.display = 'none'; - getByTestId(container, 'placeholder').style.display = 'none'; - getByTestId(container, 'preview').style.display = 'block'; + // Select the file + const input = getByTestId(container, 'input'); + const file = new File(['hello'], 'hello.png', { type: 'image/png' }); + + user.upload(input, file); + expect(input.files[0]).toStrictEqual(file); // Click the clear button getByTestId(container, 'button').click(); @@ -151,6 +177,6 @@ describe('DropzoneController', () => { // Check that the input and placeholder are hidden, and preview shown await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); - await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'flex' })); }); }); diff --git a/src/Dropzone/src/Form/DropzoneType.php b/src/Dropzone/src/Form/DropzoneType.php index d0e73271c61..1264500caa9 100644 --- a/src/Dropzone/src/Form/DropzoneType.php +++ b/src/Dropzone/src/Form/DropzoneType.php @@ -13,6 +13,8 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -29,6 +31,27 @@ public function configureOptions(OptionsResolver $resolver) 'placeholder' => 'Drag and drop or browse', ], ]); + + $resolver->setDefault('preview', function (OptionsResolver $previewResolver): void { + $previewResolver->setDefaults([ + 'style' => 'legacy', + 'can_open_file_picker' => true, + 'can_toggle_placeholder' => true, + ]) + ->addAllowedTypes('style', 'string') + ->addAllowedTypes('can_open_file_picker', 'bool') + ->addAllowedTypes('can_toggle_placeholder', ['bool', 'string']) + ->setAllowedValues('style', ['legacy', 'block', 'inline']) + ->setAllowedValues('can_toggle_placeholder', ['auto', true, false]); + }); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['preview'] = $options['preview']; + $view->vars['controller_options'] = [ + 'preview' => $options['preview'], + ]; } public function getParent() diff --git a/src/Dropzone/templates/form_theme.html.twig b/src/Dropzone/templates/form_theme.html.twig index 1dbdda0b8f0..fe1a6c51dec 100644 --- a/src/Dropzone/templates/form_theme.html.twig +++ b/src/Dropzone/templates/form_theme.html.twig @@ -2,6 +2,7 @@ {%- set dataController = (attr['data-controller']|default('') ~ ' symfony--ux-dropzone--dropzone')|trim -%} {%- set attr = attr|merge({'data-controller': '', class: (attr.class|default('') ~ ' dropzone-input')|trim}) -%} + {%- if preview.style == 'legacy' -%}
@@ -21,4 +22,47 @@
+ {%- else -%} +
+ +
+ {%- block preview_template -%} + + {%- endblock -%} +
+
+ {%- endif -%} {%- endblock %}