diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 77389fe0f19..bca3bcc83ac 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.24 + +- Preview works with muliple files + ## 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..6a76aaaf20b 100644 --- a/src/Dropzone/assets/dist/controller.d.ts +++ b/src/Dropzone/assets/dist/controller.d.ts @@ -12,7 +12,7 @@ export default class extends Controller { disconnect(): void; clear(): void; onInputChange(event: any): void; - _populateImagePreview(file: Blob): void; + _populateImagePreview(file: Blob, imagePreviewElement: HTMLElement): void; onDragEnter(): void; onDragLeave(event: any): void; private dispatchEvent; diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index ebfb380d12a..8e78e9bd303 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -25,35 +25,52 @@ class default_1 extends Controller { this.inputTarget.value = ''; this.inputTarget.style.display = 'block'; this.placeholderTarget.style.display = 'block'; + this.previewTarget.innerHTML = ''; this.previewTarget.style.display = 'none'; - this.previewImageTarget.style.display = 'none'; - this.previewImageTarget.style.backgroundImage = 'none'; - this.previewFilenameTarget.textContent = ''; + this.element.classList.remove('dropzone-on-drag-enter'); this.dispatchEvent('clear'); } onInputChange(event) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + const files = event.target.files; + if (files.length === 0) { + this.previewClearButtonTarget.style.display = 'none'; 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.previewTarget.innerHTML = ''; + for (const file of files) { + const filePreviewContainer = document.createElement('div'); + filePreviewContainer.classList.add('dropzone-preview-file'); + const fileNameElement = document.createElement('span'); + fileNameElement.textContent = file.name; + filePreviewContainer.appendChild(fileNameElement); + if (file.type) { + const imagePreviewElement = document.createElement('div'); + if (file.type.indexOf('image') !== -1) { + imagePreviewElement.classList.add('dropzone-preview-image'); + this._populateImagePreview(file, imagePreviewElement); + } + else { + const noPreviewSvg = ''; + imagePreviewElement.innerHTML = noPreviewSvg; + imagePreviewElement.classList.add('dropzone-no-preview'); + } + filePreviewContainer.appendChild(imagePreviewElement); + } + this.previewTarget.appendChild(filePreviewContainer); + this.dispatchEvent('change', file); } - this.dispatchEvent('change', file); + this.previewTarget.style.display = 'grid'; } - _populateImagePreview(file) { + _populateImagePreview(file, imagePreviewElement) { if (typeof FileReader === 'undefined') { return; } const reader = new FileReader(); reader.addEventListener('load', (event) => { - this.previewImageTarget.style.display = 'block'; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; + imagePreviewElement.style.backgroundImage = `url("${event.target.result}")`; + imagePreviewElement.style.display = 'block'; }); reader.readAsDataURL(file); } @@ -61,6 +78,8 @@ class default_1 extends Controller { this.inputTarget.style.display = 'block'; this.placeholderTarget.style.display = 'block'; this.previewTarget.style.display = 'none'; + this.element.classList.add('dropzone-on-drag-enter'); + this.element.classList.remove('dropzone-on-drag-leave'); } onDragLeave(event) { event.preventDefault(); @@ -68,6 +87,8 @@ class default_1 extends Controller { this.inputTarget.style.display = 'none'; this.placeholderTarget.style.display = 'none'; this.previewTarget.style.display = 'block'; + this.element.classList.remove('dropzone-on-drag-enter'); + this.element.classList.add('dropzone-on-drag-leave'); } } dispatchEvent(name, payload = {}) { diff --git a/src/Dropzone/assets/dist/style.min.css b/src/Dropzone/assets/dist/style.min.css index 4c1e49daedb..f804d25a9f4 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 +:root{--dropzone-background:white;--dropzone-background-hover:#ddd;--dropzone-border-color:#aaa;--dropzone-border-color-hover:#666;--dropzone-text-color:##333;--dropzone-spacing:8px;--dropzone-radius:8px;--dropzone-width:cacl(100% - 2*var(--dropzone-spacing));--dropzone-height:120px;--dropzone-image-size:100px}.dropzone-container{min-height:var(--dropzone-height);width:var(--dropzone-width);border:2px dashed var(--dropzone-border-color);padding:var(--dropzone-spacing);border-radius:var(--dropzone-radius);color:var(--dropzone-text-color);background-color:var(--dropzone-background);flex-wrap:wrap;align-items:center;display:flex;position:relative}.dropzone-container:has(.dropzone-preview:empty) .dropzone-preview-button{display:none}.dropzone-container:hover,.dropzone-on-drag-enter{background-color:var(--dropzone-background-hover);transition:all .3s}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{gap:var(--dropzone-spacing);grid-template-columns:repeat(auto-fill,var(--dropzone-image-size));grid-template-rows:auto;place-items:stretch stretch;width:100%;height:100%;display:grid}.dropzone-preview-file{word-wrap:anywhere;flex-direction:column-reverse;justify-content:start;align-items:center;display:flex}.dropzone-preview-file:hover{filter:brightness(110%)}.dropzone-preview-image{margin-bottom:var(--dropzone-spacing);width:var(--dropzone-image-size);aspect-ratio:1;border-radius:var(--dropzone-radius);box-shadow:0 0 8px var(--dropzone-background-hover);background-position:50%;background-repeat:no-repeat;background-size:cover}.dropzone-no-preview{margin-bottom:var(--dropzone-spacing);height:var(--dropzone-image-size)}.dropzone-no-preview svg{width:100%}.dropzone-preview-file span{font-size:.9em;font-weight:300}.dropzone-preview-button{top:var(--dropzone-spacing);right:var(--dropzone-spacing);z-index:1;cursor:pointer;background:0 0;border:none;place-items:center;margin:0;display:grid;position:absolute}.dropzone-placeholder{text-align:center;flex-grow:1}.dropzone-on-drag-leave{background-color:var(--dropzone-background);transition:all .3s} \ No newline at end of file diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index b2533329388..fdd7cf85387 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -56,17 +56,17 @@ export default class extends Controller { this.inputTarget.value = ''; this.inputTarget.style.display = 'block'; this.placeholderTarget.style.display = 'block'; + this.previewTarget.innerHTML = ''; this.previewTarget.style.display = 'none'; - this.previewImageTarget.style.display = 'none'; - this.previewImageTarget.style.backgroundImage = 'none'; - this.previewFilenameTarget.textContent = ''; + this.element.classList.remove('dropzone-on-drag-enter'); this.dispatchEvent('clear'); } onInputChange(event: any) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + const files = event.target.files; + if (files.length === 0) { + this.previewClearButtonTarget.style.display = 'none'; return; } @@ -74,30 +74,57 @@ export default class extends Controller { this.inputTarget.style.display = 'none'; this.placeholderTarget.style.display = 'none'; - // Show the filename in preview - this.previewFilenameTarget.textContent = file.name; - this.previewTarget.style.display = 'flex'; + // Clear previous previews + this.previewTarget.innerHTML = ''; - // 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); + for (const file of files) { + // Create a container for each file preview + const filePreviewContainer = document.createElement('div'); + filePreviewContainer.classList.add('dropzone-preview-file'); + + // Create a filename preview element + const fileNameElement = document.createElement('span'); + fileNameElement.textContent = file.name; + filePreviewContainer.appendChild(fileNameElement); + + // Create an image preview element if the file is an image, else a default svg file icon + if (file.type) { + const imagePreviewElement = document.createElement('div'); + + if (file.type.indexOf('image') !== -1) { + imagePreviewElement.classList.add('dropzone-preview-image'); + this._populateImagePreview(file, imagePreviewElement); + } else { + const noPreviewSvg = + ''; + imagePreviewElement.innerHTML = noPreviewSvg; + + imagePreviewElement.classList.add('dropzone-no-preview'); + } + + filePreviewContainer.appendChild(imagePreviewElement); + } + + // Append the file preview container to the main preview target + this.previewTarget.appendChild(filePreviewContainer); + + this.dispatchEvent('change', file); } - this.dispatchEvent('change', file); + // Show the preview container + this.previewTarget.style.display = 'grid'; } - _populateImagePreview(file: Blob) { + _populateImagePreview(file: Blob, imagePreviewElement: HTMLElement) { if (typeof FileReader === 'undefined') { // FileReader API not available, skip return; } const reader = new FileReader(); - reader.addEventListener('load', (event: any) => { - this.previewImageTarget.style.display = 'block'; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; + imagePreviewElement.style.backgroundImage = `url("${event.target.result}")`; + imagePreviewElement.style.display = 'block'; }); reader.readAsDataURL(file); @@ -107,6 +134,8 @@ export default class extends Controller { this.inputTarget.style.display = 'block'; this.placeholderTarget.style.display = 'block'; this.previewTarget.style.display = 'none'; + this.element.classList.add('dropzone-on-drag-enter'); + this.element.classList.remove('dropzone-on-drag-leave'); } onDragLeave(event: any) { @@ -117,6 +146,8 @@ export default class extends Controller { this.inputTarget.style.display = 'none'; this.placeholderTarget.style.display = 'none'; this.previewTarget.style.display = 'block'; + this.element.classList.remove('dropzone-on-drag-enter'); + this.element.classList.add('dropzone-on-drag-leave'); } } diff --git a/src/Dropzone/assets/src/style.css b/src/Dropzone/assets/src/style.css index 4cd21ac6b8e..f7083968ae7 100644 --- a/src/Dropzone/assets/src/style.css +++ b/src/Dropzone/assets/src/style.css @@ -1,72 +1,121 @@ +:root { + --dropzone-background: white; + --dropzone-background-hover: #dddddd; + --dropzone-border-color: #aaaaaa; + --dropzone-border-color-hover: #666666; + --dropzone-text-color: ##333333; + + --dropzone-spacing: 8px; + --dropzone-radius: 8px; + + --dropzone-width: cacl(100% - 2 * var(--dropzone-spacing)); + --dropzone-height: 120px; + --dropzone-image-size: 100px; +} + .dropzone-container { - position: relative; - display: flex; - min-height: 100px; - border: 2px dashed #bbb; - align-items: center; - padding: 20px 10px; + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + min-height: var(--dropzone-height); + width: var(--dropzone-width); + border: 2px dashed var(--dropzone-border-color); + padding: var(--dropzone-spacing); + border-radius: var(--dropzone-radius); + color: var(--dropzone-text-color); + background-color: var(--dropzone-background); +} + +.dropzone-container:has(.dropzone-preview:empty) .dropzone-preview-button { + display: none; +} + +.dropzone-container:hover, +.dropzone-on-drag-enter { + background-color: var(--dropzone-background-hover); + transition: 0.3s; } .dropzone-input { - position: absolute; - display: block; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0; - cursor: pointer; - z-index: 1; + 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%; + display: grid; + gap: var(--dropzone-spacing); + grid-template-columns: repeat(auto-fill, var(--dropzone-image-size)); + grid-template-rows: auto; + place-items: stretch; + width: 100%; + height: 100%; +} + +.dropzone-preview-file { + display: flex; + flex-direction: column-reverse; + align-items: center; + justify-content: start; + word-wrap: anywhere; +} + +.dropzone-preview-file:hover { + filter: brightness(110%); } .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; + margin-bottom: var(--dropzone-spacing); + width: var(--dropzone-image-size); + aspect-ratio: 1; + background-size: cover; + background-position: 50% 50%; + background-repeat: no-repeat; + border-radius: var(--dropzone-radius); + box-shadow: 0 0 8px var(--dropzone-background-hover); } -.dropzone-preview-filename { - word-wrap: anywhere; +.dropzone-no-preview { + margin-bottom: var(--dropzone-spacing); + height: var(--dropzone-image-size); } -.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-no-preview svg { + width: 100%; } -.dropzone-preview-button::before { - content: '×'; - padding: 3px 7px; - cursor: pointer; + +.dropzone-preview-file span { + font-weight: 300; + font-size: 0.9em; +} + +.dropzone-preview-button { + position: absolute; + top: var(--dropzone-spacing); + right: var(--dropzone-spacing); + z-index: 1; + border: none; + margin: 0; + background: none; + display: grid; + place-items: center; + cursor: pointer; } .dropzone-placeholder { - flex-grow: 1; - text-align: center; - color: #999; + flex-grow: 1; + text-align: center; +} + +.dropzone-on-drag-leave { + background-color: var(--dropzone-background); + transition: 0.3s; } diff --git a/src/Dropzone/assets/test/controller.test.ts b/src/Dropzone/assets/test/controller.test.ts index b37dadf4bbb..d4da3c050c6 100644 --- a/src/Dropzone/assets/test/controller.test.ts +++ b/src/Dropzone/assets/test/controller.test.ts @@ -45,24 +45,16 @@ describe('DropzoneController', () => { Placeholder + +
`); @@ -111,6 +103,7 @@ describe('DropzoneController', () => { // Attach a listener to ensure the event is dispatched let dispatched = null; + getByTestId(container, 'container').addEventListener('dropzone:change', (event) => { dispatched = event; }); diff --git a/src/Dropzone/templates/form_theme.html.twig b/src/Dropzone/templates/form_theme.html.twig index 1dbdda0b8f0..6cc903bb36f 100644 --- a/src/Dropzone/templates/form_theme.html.twig +++ b/src/Dropzone/templates/form_theme.html.twig @@ -1,24 +1,17 @@ {% block dropzone_widget -%} - {%- set dataController = (attr['data-controller']|default('') ~ ' symfony--ux-dropzone--dropzone')|trim -%} - {%- set attr = attr|merge({'data-controller': '', class: (attr.class|default('') ~ ' dropzone-input')|trim}) -%} +{%- set dataController = (attr['data-controller']|default('') ~ ' symfony--ux-dropzone--dropzone')|trim -%} +{%- set attr = attr|merge({'data-controller': '', class: (attr.class|default('') ~ ' dropzone-input')|trim}) -%} -