Feature: Media Picker drag and drop upload directly on property editor (#13393)

* prototype drag and drop upload

* Add upload image endpoint

* Add MediaPickerThreeController.cs

* Revert "Add upload image endpoint"

This reverts commit 4bb5865480737a00b723968e3bbba317b252acf7.

* Update IIOHelper dependency

* show preview when uploading a new media item

* open uploaded media in media entry editor

* map data from uploaded media entry to support cropper

* add crop data to uploaded media item

* remove media library buttons for media entries not created in the media library

* Implement temp images save & add to media picker 3

* Implement ITemporaryImageService

* Remove save logic from MediaPicker3PropertyEditor

* Dont use a TempImageDto

* Add GetByAlias endpoint

* Add additonal xml doc

* Refactor to take array of aliases

* Add FromQuery attribute

* Formatting

* add resource to get media types by alias

* validate file size and file type based on server variables

* Update OpenApi.json
Add media picker three to BackOfficeServerVariables

* rename endpoint to upload media

* Use baseurl Method

* Dont upload in rte folder

* pass params correctly to end point

* queue files before uploading

* handle invalid files

* progress bar design adjustments

* only create data url for images

* disable edit and name buttons when uploading

* fix missing error messages for invalid files

* add temp location to media entry

* Add startNode to TemporaryImageService.cs

* Refactor get by alias

* Rename to GetAllFiltered

* use getAllFiltered method

* remove autoselect option

* fix missing alias when selecting media type

* fix file filter

* don't overwrite invalid entries from dropping new files

* add disallowed files to filter

* remove console.log

* move media uploader logic to reusable function

* fix missing tmp location

* attach media type alias to the mediaEntry

* support readonly mode

* show discard changes when files has been dropped

* add disabled prop to button group

* emit events when upload queue starts and ends

* pass node to media picker property editor

* add service to keep track of all uploads in progress

* add upload in progress to uploadTracker when the queue starts and ends

* disabled buttons when any upload is in progress

* return a subscription to align with eventsService

* Fix up cases where StartNodeId was null

* scope css

* Show filename in dialog for selecting media type

* reuse translation from media library dropzone

* Don't check for only images

* Remove composer

* Add mediaTypeAlias to TemporaryImageService

* Rename ITemporaryImageService to ITemporaryMediaService

* prefix client side only props with $ so we don't send unnecessary data to the server

* use prefixed dataURL in media entry editor

* render icon for non images

* fix auto select media type

Co-authored-by: Zeegaan <nge@umbraco.dk>
Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>
This commit is contained in:
Mads Rasmussen
2022-11-17 11:16:01 +01:00
committed by GitHub
parent dff3d8a739
commit 57ef0917f2
21 changed files with 916 additions and 72 deletions

View File

@@ -326,6 +326,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<ICultureImpactFactory>(provider => new CultureImpactFactory(provider.GetRequiredService<IOptionsMonitor<ContentSettings>>()));
Services.AddUnique<IDictionaryService, DictionaryService>();
Services.AddUnique<ITemporaryMediaService, TemporaryMediaService>();
}
}
}

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface ITemporaryMediaService
{
public IMedia Save(string temporaryLocation, Guid? startNode, string? mediaTypeAlias);
}

View File

@@ -0,0 +1,94 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
public class TemporaryMediaService : ITemporaryMediaService
{
private readonly IShortStringHelper _shortStringHelper;
private readonly MediaFileManager _mediaFileManager;
private readonly IMediaService _mediaService;
private readonly MediaUrlGeneratorCollection _mediaUrlGenerators;
private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider;
private readonly IHostEnvironment _hostingEnvironment;
private readonly ILogger<TemporaryMediaService> _logger;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public TemporaryMediaService(
IShortStringHelper shortStringHelper,
MediaFileManager mediaFileManager,
IMediaService mediaService,
MediaUrlGeneratorCollection mediaUrlGenerators,
IContentTypeBaseServiceProvider contentTypeBaseServiceProvider,
IHostEnvironment hostingEnvironment,
ILogger<TemporaryMediaService> logger,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_shortStringHelper = shortStringHelper;
_mediaFileManager = mediaFileManager;
_mediaService = mediaService;
_mediaUrlGenerators = mediaUrlGenerators;
_contentTypeBaseServiceProvider = contentTypeBaseServiceProvider;
_hostingEnvironment = hostingEnvironment;
_logger = logger;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
public IMedia Save(string temporaryLocation, Guid? startNode, string? mediaTypeAlias)
{
var userId = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? Constants.Security.SuperUserId;
var absoluteTempImagePath = _hostingEnvironment.MapPathContentRoot(temporaryLocation);
var fileName = Path.GetFileName(absoluteTempImagePath);
var safeFileName = fileName.ToSafeFileName(_shortStringHelper);
var mediaItemName = safeFileName.ToFriendlyName();
IMedia mediaFile;
if (startNode is null)
{
mediaFile = _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaTypeAlias ?? Constants.Conventions.MediaTypes.File, userId);
}
else
{
mediaFile = _mediaService.CreateMedia(mediaItemName, startNode.Value, mediaTypeAlias ?? Constants.Conventions.MediaTypes.File, userId);
}
var fileInfo = new FileInfo(absoluteTempImagePath);
FileStream? fileStream = fileInfo.OpenReadWithRetry();
if (fileStream is null)
{
throw new InvalidOperationException("Could not acquire file stream");
}
using (fileStream)
{
mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream);
}
_mediaService.Save(mediaFile, userId);
// Delete temp file now that we have persisted it
var folderName = Path.GetDirectoryName(absoluteTempImagePath);
try
{
if (folderName is not null)
{
Directory.Delete(folderName, true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath);
}
return mediaFile;
}
}

View File

@@ -1,7 +1,9 @@
using System.Runtime.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
@@ -37,7 +39,8 @@ public class MediaPicker3PropertyEditor : DataEditor
IDataValueEditorFactory dataValueEditorFactory,
IIOHelper ioHelper,
EditorType type = EditorType.PropertyValue)
: this(dataValueEditorFactory, ioHelper, StaticServiceProvider.Instance.GetRequiredService<IEditorConfigurationParser>(), type)
: this(dataValueEditorFactory, ioHelper,
StaticServiceProvider.Instance.GetRequiredService<IEditorConfigurationParser>(), type)
{
}
@@ -68,6 +71,8 @@ public class MediaPicker3PropertyEditor : DataEditor
{
private readonly IDataTypeService _dataTypeService;
private readonly IJsonSerializer _jsonSerializer;
private readonly ITemporaryMediaService _temporaryMediaService;
public MediaPicker3PropertyValueEditor(
ILocalizedTextService localizedTextService,
@@ -75,11 +80,13 @@ public class MediaPicker3PropertyEditor : DataEditor
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute,
IDataTypeService dataTypeService)
IDataTypeService dataTypeService,
ITemporaryMediaService temporaryMediaService)
: base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_jsonSerializer = jsonSerializer;
_dataTypeService = dataTypeService;
_temporaryMediaService = temporaryMediaService;
}
/// <remarks>
@@ -118,6 +125,11 @@ public class MediaPicker3PropertyEditor : DataEditor
{
if (editorValue.Value is JArray dtos)
{
if (editorValue.DataTypeConfiguration is MediaPicker3Configuration configuration)
{
dtos = PersistTempMedia(dtos, configuration);
}
// Clean up redundant/default data
foreach (JObject? dto in dtos.Values<JObject>())
{
@@ -150,7 +162,7 @@ public class MediaPicker3PropertyEditor : DataEditor
Key = Guid.NewGuid(),
MediaKey = guidUdi.Guid,
Crops = Enumerable.Empty<ImageCropperValue.ImageCropperCrop>(),
FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = 0.5m, Top = 0.5m },
FocalPoint = new ImageCropperValue.ImageCropperFocalPoint {Left = 0.5m, Top = 0.5m},
};
}
}
@@ -170,6 +182,49 @@ public class MediaPicker3PropertyEditor : DataEditor
}
}
private JArray PersistTempMedia(JArray jArray, MediaPicker3Configuration mediaPicker3Configuration)
{
var result = new JArray();
foreach (JObject? dto in jArray.Values<JObject>())
{
if (dto is null)
{
continue;
}
if (!dto.TryGetValue("tmpLocation", out JToken? temporaryLocation))
{
// If it does not have a temporary path, it can be an already saved image or not-yet uploaded temp-image, check for media-key
if (dto.TryGetValue("mediaKey", out _))
{
result.Add(dto);
}
continue;
}
var temporaryLocationString = temporaryLocation.Value<string>();
if (temporaryLocationString is null)
{
continue;
}
GuidUdi? startNodeGuid = mediaPicker3Configuration.StartNodeId as GuidUdi ?? null;
JToken? mediaTypeAlias = dto.GetValue("mediaTypeAlias");
IMedia mediaFile = _temporaryMediaService.Save(temporaryLocationString, startNodeGuid?.Guid, mediaTypeAlias?.Value<string>());
MediaWithCropsDto? mediaDto = _jsonSerializer.Deserialize<MediaWithCropsDto>(dto.ToString());
if (mediaDto is null)
{
continue;
}
mediaDto.MediaKey = mediaFile.GetUdi().Guid;
result.Add(JObject.Parse(_jsonSerializer.Serialize(mediaDto)));
}
return result;
}
/// <summary>
/// Model/DTO that represents the JSON that the MediaPicker3 stores.
/// </summary>

View File

@@ -529,6 +529,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
"propertyTypeApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl<PropertyTypeController>(
controller => controller.HasValues(string.Empty))
},
{
"mediaPickerThreeBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl<MediaPickerThreeController>(
controller => controller.UploadMedia(null!))
},
}
},
{

View File

@@ -0,0 +1,113 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Dictionary;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Web.Common.ActionsResults;
using Umbraco.Cms.Web.Common.Attributes;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.BackOffice.Controllers;
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
[Authorize(Policy = AuthorizationPolicies.SectionAccessMedia)]
public class MediaPickerThreeController : ContentControllerBase
{
private readonly IHostingEnvironment _hostingEnvironment;
private readonly ContentSettings _contentSettings;
private readonly IImageUrlGenerator _imageUrlGenerator;
private readonly IIOHelper _ioHelper;
public MediaPickerThreeController(
ICultureDictionary cultureDictionary,
ILoggerFactory loggerFactory,
IShortStringHelper shortStringHelper,
IEventMessagesFactory eventMessages,
ILocalizedTextService localizedTextService,
IJsonSerializer serializer,
IHostingEnvironment hostingEnvironment,
IOptionsSnapshot<ContentSettings> contentSettings,
IImageUrlGenerator imageUrlGenerator,
IIOHelper ioHelper)
: base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer)
{
_hostingEnvironment = hostingEnvironment;
_contentSettings = contentSettings.Value;
_imageUrlGenerator = imageUrlGenerator;
_ioHelper = ioHelper;
}
[HttpPost]
public async Task<IActionResult> UploadMedia(List<IFormFile> file)
{
// Create an unique folder path to help with concurrent users to avoid filename clash
var imageTempPath =
_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads + "/" + Guid.NewGuid());
// Ensure image temp path exists
if (Directory.Exists(imageTempPath) == false)
{
Directory.CreateDirectory(imageTempPath);
}
// Must have a file
if (file.Count == 0)
{
return NotFound();
}
// Should only have one file
if (file.Count > 1)
{
return new UmbracoProblemResult("Only one file can be uploaded at a time", HttpStatusCode.BadRequest);
}
// Really we should only have one file per request to this endpoint
IFormFile formFile = file.First();
var fileName = formFile.FileName.Trim(new[] { '\"' }).TrimEnd();
var safeFileName = fileName.ToSafeFileName(ShortStringHelper);
var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLowerInvariant();
if (_contentSettings.IsFileAllowedForUpload(ext) == false)
{
// Throw some error - to say can't upload this IMG type
return new UmbracoProblemResult("This is not an image filetype extension that is approved", HttpStatusCode.BadRequest);
}
var newFilePath = imageTempPath + Path.DirectorySeparatorChar + safeFileName;
var relativeNewFilePath = GetRelativePath(newFilePath);
await using (FileStream stream = System.IO.File.Create(newFilePath))
{
await formFile.CopyToAsync(stream);
}
return Ok(new { tmpLocation = relativeNewFilePath });
}
// Use private method istead of _ioHelper.GetRelativePath as that is relative for the webroot and not the content root.
private string GetRelativePath(string path)
{
if (path.IsFullPath())
{
var rootDirectory = _hostingEnvironment.MapPathContentRoot("~");
var relativePath = _ioHelper.PathStartsWith(path, rootDirectory) ? path[rootDirectory.Length..] : path;
path = relativePath;
}
return PathUtility.EnsurePathIsApplicationRootPrefixed(path);
}
}

View File

@@ -132,6 +132,35 @@ public class MediaTypeController : ContentTypeControllerBase<IMediaType>
return dto;
}
/// <summary>
/// Returns a media type by alias
/// </summary>
/// /// <param name="alias">Alias of the media type</param>
/// <returns></returns>
public IEnumerable<MediaTypeDisplay> GetAllFiltered([FromQuery] string[] aliases)
{
if (aliases.Length < 1)
{
return _mediaTypeService.GetAll().Select(_umbracoMapper.Map<IMediaType, MediaTypeDisplay>).WhereNotNull();
}
var mediaTypeDisplays = new List<MediaTypeDisplay>();
foreach (var alias in aliases)
{
IMediaType? mediaType = _mediaTypeService.Get(alias);
MediaTypeDisplay? mediaTypeDisplay = _umbracoMapper.Map<IMediaType, MediaTypeDisplay>(mediaType);
if (mediaTypeDisplay is not null)
{
mediaTypeDisplays.Add(mediaTypeDisplay);
}
}
return mediaTypeDisplays;
}
/// <summary>
/// Deletes a media type with a given ID
/// </summary>

View File

@@ -129,7 +129,8 @@ Use this directive to render a button with a dropdown of alternative actions.
size: "@?",
icon: "@?",
label: "@?",
labelKey: "@?"
labelKey: "@?",
disabled: "<?"
},
link: link
};

View File

@@ -5,7 +5,7 @@
appState, contentResource, entityResource, navigationService, notificationsService, contentAppHelper,
serverValidationManager, contentEditingHelper, localizationService, formHelper, umbRequestHelper,
editorState, $http, eventsService, overlayService, $location, localStorageService, treeService,
$exceptionHandler) {
$exceptionHandler, uploadTracker) {
var evts = [];
var infiniteMode = $scope.infiniteModel && $scope.infiniteModel.infiniteMode;
@@ -183,6 +183,10 @@
}
}));
evts.push(eventsService.on("uploadTracker.uploadsInProgressChanged", function (name, args) {
$scope.page.uploadsInProgress = args.uploadsInProgress.filter(x => x.entityKey === $scope.content.key).length > 0;
}));
evts.push(eventsService.on("rte.file.uploading", function () {
$scope.page.saveButtonState = "busy";
$scope.page.buttonGroupState = "busy";

View File

@@ -121,6 +121,22 @@ function mediaTypeResource($q, $http, umbRequestHelper, umbDataFormatter, locali
'Failed to retrieve all content types');
},
getAllFiltered: function (aliases) {
var aliasesQuery = "";
if (aliases && aliases.length > 0) {
aliases.forEach(alias => aliasesQuery += `aliases=${alias}&`);
}
return umbRequestHelper.resourcePromise(
$http.get(
umbRequestHelper.getApiUrl(
"mediaTypeApiBaseUrl",
"getAllFiltered",
aliasesQuery)),
'Failed to retrieve media types');
},
getScaffold: function (parentId) {
return umbRequestHelper.resourcePromise(

View File

@@ -0,0 +1,54 @@
/**
* @ngdoc service
* @name umbraco.services.uploadTracker
* @description a helper to keep track of uploads in progress
**/
function uploadTracker(eventsService) {
const uploadsInProgress = [];
const events = {};
/**
* @ngdoc function
* @name umbraco.services.uploadTracker#uploadStarted
* @methodOf umbraco.services.uploadTracker
* @function
*
* @description
* Called when an upload is started to inform listeners that an upload is in progress. This will raise the uploadTracker.uploadsInProgressChanged event.
*
* @param {string} entityKey The key of the entity where the upload is taking place
*/
function uploadStarted (entityKey) {
const uploadDetails = {
entityKey
};
uploadsInProgress.push(uploadDetails);
eventsService.emit('uploadTracker.uploadsInProgressChanged', { uploadsInProgress });
}
/**
* @ngdoc function
* @name umbraco.services.uploadTracker#uploadEnded
* @methodOf umbraco.services.uploadTracker
* @function
*
* @description
* Called when an upload is ended to inform listeners that an upload has stopped. This will raise the uploadTracker.uploadsInProgressChanged event.
*
* @param {string} entityKey The key of the entity where the upload has stopped.
*/
function uploadEnded (entityKey) {
const index = uploadsInProgress.findIndex(upload => upload.entityKey === entityKey);
uploadsInProgress.splice(index, 1);
eventsService.emit('uploadTracker.uploadsInProgressChanged', { uploadsInProgress });
}
return {
uploadStarted,
uploadEnded
};
}
angular.module('umbraco.services').factory('uploadTracker', uploadTracker);

View File

@@ -116,6 +116,254 @@
return obj;
};
const MediaUploader = function (Upload, mediaHelper, mediaTypeHelper, localizationService, overlayService, mediaTypeResource) {
const umbracoSettings = Umbraco.Sys.ServerVariables.umbracoSettings;
const allowedUploadFiles = mediaHelper.formatFileTypes(umbracoSettings.allowedUploadFiles);
const allowedImageFileTypes = mediaHelper.formatFileTypes(umbracoSettings.imageFileTypes);
const allowedFileTypes = `${allowedUploadFiles},${allowedImageFileTypes}`;
const disallowedFileTypes = mediaHelper.formatFileTypes(umbracoSettings.disallowedUploadFiles);
const maxFileSize = umbracoSettings.maxFileSize !== '' ? `${umbracoSettings.maxFileSize} KB` : '';
const events = {};
const translations = {};
let initialized = false;
let uploadURL = '';
let allowedMediaTypes = [];
let queue = [];
let invalidEntries = [];
function init (options) {
uploadURL = options.uploadURL;
return new Promise((resolve, reject) => {
const promises = [
mediaTypeResource.getAllFiltered(options.allowedMediaTypeAliases),
localizationService.localizeMany(["media_disallowedFileType", "media_maxFileSize", "defaultdialogs_selectMediaType"])
];
Promise.all(promises).then(values => {
const mediaTypes = values[0];
const translationValues = values[1];
allowedMediaTypes = mediaTypes;
translations.disallowedFileType = translationValues[0];
translations.maxFileSize = translationValues[1] + " " + maxFileSize;
translations.selectMediaTypeDialogTitle = translationValues[2];
initialized = true;
resolve();
}, (reason) => {
reject(reason);
});
});
}
function requestUpload (files) {
if (!initialized) {
throw 'MediaUploader is not initialized';
}
const validBatch = [];
const uploadItems = createUploadItemsFromFiles(files);
// Validate based on server allowed file types
uploadItems.forEach(item => {
const isAllowedFileType = Upload.validatePattern(item.file, allowedFileTypes);
const isDisallowedFileType = Upload.validatePattern(item.file, disallowedFileTypes);
const underMaxFileSize = maxFileSize ? validateMaxFileSize(item.file, maxFileSize) : true;
if (isAllowedFileType && !isDisallowedFileType && underMaxFileSize) {
_acceptMediaEntry(item.mediaEntry);
validBatch.push(item);
return;
}
if (!isAllowedFileType || isDisallowedFileType) {
_rejectMediaEntry(item.mediaEntry, { type: 'pattern', message: translations.disallowedFileType });
return;
}
if (!underMaxFileSize) {
_rejectMediaEntry(item.mediaEntry, { type: 'maxSize', message: translations.maxFileSize });
return;
}
});
_addItemsToQueue(validBatch);
_emit('queueStarted');
_processQueue();
}
function _acceptMediaEntry (mediaEntry) {
_emit('mediaEntryAccepted', { mediaEntry });
}
function _rejectMediaEntry (mediaEntry, reason) {
mediaEntry.error = true;
mediaEntry.errorType = {};
mediaEntry.errorType[reason.type] = true;
mediaEntry.errorText = reason.message;
invalidEntries.push(mediaEntry);
_emit('mediaEntryRejected', { mediaEntry });
}
function createUploadItemsFromFiles (files) {
// angular complains about "Illegal invocation" if the file is part of the model.value
// so we have to keep them separate
return files.map(file => {
const mediaEntry = {
key: String.CreateGuid(),
name: file.name,
$uploadProgress: 0,
$dataURL: ''
};
if (file.type.includes('image')) {
Upload.base64DataUrl(file).then(function(url) {
mediaEntry.$dataURL = url;
});
} else {
mediaEntry.$extension = mediaHelper.getFileExtension(file.name);
}
return {
mediaEntry,
file
};
});
};
function validateMaxFileSize (file, val) {
return file.size - 0.1 <= Upload.translateScalars(val);
}
function _upload(queueItem) {
const mediaEntry = queueItem.mediaEntry;
_emit('uploadStarted', { mediaEntry });
Upload.upload({
url: uploadURL,
file: queueItem.file
})
.progress(function(evt) {
var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10);
mediaEntry.$uploadProgress = progressPercentage;
})
.success(function (data) {
_emit('uploadSuccess', { mediaEntry, ...data });
_processQueue();
})
.error(function(error) {
_emit('uploadError', { mediaEntry });
_rejectMediaEntry(mediaEntry, { type: 'server', message: error.Message });
_processQueue();
});
}
function _addItemsToQueue (queueItems) {
queue = [...queue, ...queueItems];
}
function _processQueue () {
const nextItem = queue.shift();
// queue is empty
if (!nextItem) {
_emit('queueCompleted');
return;
}
_getMatchedMediaType(nextItem.file).then(mediaType => {
nextItem.mediaEntry.mediaTypeAlias = mediaType.alias;
nextItem.mediaEntry.$icon = mediaType.icon;
_upload(nextItem);
}, () => {
_rejectMediaEntry(nextItem.mediaEntry, { type: 'pattern', message: translations.disallowedFileType });
_processQueue();
});
}
function _getMatchedMediaType(file) {
return new Promise((resolve, reject) => {
const uploadFileExtension = mediaHelper.getFileExtension(file.name);
const matchedMediaTypes = mediaTypeHelper.getTypeAcceptingFileExtensions(allowedMediaTypes, [uploadFileExtension]);
if (matchedMediaTypes.length === 0) {
reject();
return;
};
if (matchedMediaTypes.length === 1) {
resolve(matchedMediaTypes[0]);
return;
};
// when we get all media types, the "File" media type will always show up because it accepts all file extensions.
// If we don't remove it from the list we will always show the picker.
const matchedMediaTypesNoFile = matchedMediaTypes.filter(mediaType => mediaType.alias !== "File");
if (matchedMediaTypesNoFile.length === 1) {
resolve(matchedMediaTypesNoFile[0]);
return;
};
if (matchedMediaTypes.length > 1) {
_chooseMediaTypeDialog(matchedMediaTypes, file)
.then((selectedMediaType) => {
resolve(selectedMediaType);
}, () => {
reject();
});
};
});
}
function _chooseMediaTypeDialog(mediaTypes, file) {
return new Promise((resolve, reject) => {
const dialog = {
view: "itempicker",
filter: mediaTypes.length > 8,
availableItems: mediaTypes,
submit: function (model) {
resolve(model.selectedItem);
overlayService.close();
},
close: function () {
reject();
overlayService.close();
}
};
dialog.title = translations.selectMediaTypeDialogTitle;
dialog.subtitle = file.name;
overlayService.open(dialog);
});
}
function _emit(name, data) {
if (!events[name]) return;
events[name].forEach(callback => callback({name}, data));
}
function on(name, callback) {
if (typeof callback !== 'function') return;
const unsubscribe = function () {
events[name] = events[name].filter(cb => cb !== callback);
}
events[name] = events[name] || [];
events[name].push(callback);
return unsubscribe;
}
return {
init,
requestUpload,
on
}
}
let _utilities = {
noop: noop,
copy: copy,
@@ -130,7 +378,8 @@
isObject: isObject,
fromJson: fromJson,
toJson: toJson,
forEach: forEach
forEach: forEach,
MediaUploader: MediaUploader
};
if (typeof (window.Utilities) === 'undefined') {

View File

@@ -44,6 +44,20 @@ angular.module("umbraco")
function updateMedia() {
if (!vm.mediaEntry.mediaKey) {
vm.imageSrc = vm.mediaEntry.$dataURL;
vm.fileSrc = vm.mediaEntry.$dataURL;
vm.loading = false;
vm.hasDimensions = false;
vm.isCroppable = false;
vm.fileExtension = 'JPG';
localizationService.localize("mediaPicker_editMediaEntryLabel", [vm.mediaEntry.name, vm.model.documentName]).then(data => {
vm.title = data;
});
return;
}
vm.loading = true;
entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) {

View File

@@ -93,13 +93,13 @@
</umb-media-preview>
<div class="umb-media-entry-editor__imageholder-actions">
<button class="btn btn-link" ng-click="vm.repickMedia()">
<button ng-if="vm.mediaEntry.mediaKey" class="btn btn-link" ng-click="vm.repickMedia()">
<umb-icon icon="icon-wrong"></umb-icon>
<localize key="mediaPicker_changeMedia"
>Replace media</localize
>
</button>
<button class="btn btn-link" ng-click="vm.openMedia()">
<button ng-if="vm.mediaEntry.mediaKey" class="btn btn-link" ng-click="vm.openMedia()">
<umb-icon icon="icon-out"></umb-icon>
<localize key="mediaPicker_openMedia">Open media</localize>
</button>

View File

@@ -1,5 +1,4 @@
<div class="btn-group umb-button-group" ng-class="{ 'dropup': direction === 'up', '-with-button-group-toggle': subButtons.length > 0 }">
<umb-button
ng-if="defaultButton"
alias="{{defaultButton.alias ? defaultButton.alias : 'groupPrimary' }}"
@@ -15,7 +14,8 @@
shortcut-when-hidden="{{defaultButton.hotKeyWhenHidden}}"
size="{{size}}"
icon="{{icon}}"
add-ellipsis={{defaultButton.addEllipsis}}>
add-ellipsis={{defaultButton.addEllipsis}}
disabled="disabled">
</umb-button>
<button
@@ -25,7 +25,8 @@
ng-if="subButtons.length > 0"
ng-click="toggleDropdown()"
aria-haspopup="true"
aria-expanded="{{dropdown.isOpen}}">
aria-expanded="{{dropdown.isOpen}}"
ng-disabled="disabled">
<span class="caret">
<span class="sr-only">
<localize key="{{labelKey}}">{{label}}</localize>
@@ -45,7 +46,8 @@
data-element="{{subButton.alias ? 'button-' + subButton.alias : 'button-group-secondary-' + $index }}"
ng-click="executeMenuItem(subButton)"
hotkey="{{subButton.hotKey}}"
hotkey-when-hidden="{{subButton.hotKeyWhenHidden}}">
hotkey-when-hidden="{{subButton.hotKeyWhenHidden}}"
ng-disabled="disabled">
<localize key="{{subButton.labelKey}}">{{subButton.labelKey}}</localize>
<span ng-if="subButton.addEllipsis === 'true'">...</span>
</button>

View File

@@ -39,7 +39,8 @@
button-style="link"
label-key="general_close"
shortcut="esc"
type="button">
type="button"
disabled="page.uploadsInProgress">
</umb-button>
<umb-button
@@ -48,7 +49,8 @@
type="button"
button-style="link"
action="preview(content)"
label-key="buttons_saveAndPreview">
label-key="buttons_saveAndPreview"
disabled="page.uploadsInProgress">
</umb-button>
<umb-button
@@ -60,7 +62,8 @@
action="save(content)"
label-key="buttons_save"
shortcut="ctrl+s"
add-ellipsis="{{page.saveButtonEllipsis}}">
add-ellipsis="{{page.saveButtonEllipsis}}"
disabled="page.uploadsInProgress">
</umb-button>
<umb-button-group
@@ -72,7 +75,8 @@
direction="up"
float="right"
label-key="buttons_morePublishingOptions"
label="More publishing options">
label="More publishing options"
disabled="page.uploadsInProgress">
</umb-button-group>
<umb-button
@@ -81,7 +85,8 @@
button-style="primary"
state="saveAndCloseButtonState"
label-key="buttons_saveAndClose"
type="button">
type="button"
disabled="page.uploadsInProgress">
</umb-button>
<umb-button
@@ -90,7 +95,8 @@
button-style="primary"
state="publishAndCloseButtonState"
label-key="buttons_publishAndClose"
type="button">
type="button"
disabled="page.uploadsInProgress">
</umb-button>
</umb-editor-footer-content-right>

View File

@@ -3,9 +3,7 @@ umb-media-card {
position: relative;
display: inline-block;
width: 100%;
//background-color: white;
border-radius: @baseBorderRadius;
//box-shadow: 0 1px 2px rgba(0,0,0,.2);
overflow: hidden;
transition: box-shadow 120ms;
cursor: pointer;
@@ -194,3 +192,32 @@ umb-media-card {
}
}
}
.umb-media-card {
.__upload-progress-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
background-color: rgba(255,255,255,.7);
display: flex;
align-items: center;
justify-content: center;
.__upload-progress {
width: 66%;
margin-top: 13px;
}
.__upload-progress-label {
font-size: 13px;
margin-top: 3px;
}
}
}

View File

@@ -1,4 +1,5 @@
<umb-media-picker3-property-editor
model="model"
node="node"
ng-attr-readonly="{{ readonly || undefined }}">
</umb-media-picker3-property-editor>

View File

@@ -2,55 +2,121 @@
<umb-load-indicator ng-if="vm.loading"></umb-load-indicator>
<div class="umb-media-card-grid" ng-class="{'--singleMode':(vm.validationLimit.max === 1 && vm.model.value.length <= 1)}">
<div
class="dropzone"
ngf-drop
ngf-change="vm.handleFiles($files, $invalidFiles)"
ngf-drag-over-class="'drag-over'"
ngf-multiple="{{vm.model.config.multiple}}"
ngf-allow-dir="{{ vm.allowDir }}"
ng-disabled="vm.readonly">
<div style="display:contents;" ui-sortable="vm.sortableOptions" ng-model="vm.model.value" ng-if="vm.loading !== true">
<div ng-repeat="media in vm.model.value track by media.key" class="umb-media-card-grid__cell">
<button type="button"
ng-if="vm.loading !== true && vm.allowAdd && ((vm.singleMode === true && vm.model.value.length === 0) || vm.singleMode === false)"
class="btn-reset umb-media-card-grid--inline-create-button"
ng-click="vm.addMediaAt($index, $event)"
ng-controller="Umbraco.PropertyEditors.MediaPicker3PropertyEditor.CreateButtonController as inlineCreateButtonCtrl"
ng-mousemove="inlineCreateButtonCtrl.onMouseMove($event)"
ng-disabled="!vm.allowAddMedia">
<div class="__plus" ng-style="{'top':inlineCreateButtonCtrl.plusPosY}">
<umb-icon icon="icon-add" class="icon"></umb-icon>
</div>
</button>
<umb-media-card
media-key="media.mediaKey"
on-name-clicked="vm.editMedia(media, $index, $event)"
ng-class="{'--active': vm.activeMediaEntry === media}"
has-error="vm.propertyForm.maxCount.$valid === false && $index >= vm.validationLimit.max"
allowed-types="vm.allowedTypes">
<div class="__actions">
<button ng-if="vm.supportCopy" type="button" class="btn-reset __action umb-outline" localize="title" title="actions_copy" ng-click="vm.copyMedia(media); $event.stopPropagation();">
<umb-icon icon="icon-documents" class="icon"></umb-icon>
</button>
<button ng-if="vm.allowRemoveMedia" type="button" class="btn-reset __action umb-outline" localize="title" title="general_remove" ng-click="vm.removeMedia(media); $event.stopPropagation();">
<umb-icon icon="icon-trash" class="icon"></umb-icon>
</button>
</div>
</umb-media-card>
</div>
<div class="drop-overlay">
<localize key="media_dragAndDropYourFilesIntoTheArea"></localize>
</div>
<button ng-if="vm.loading !== true && ((vm.singleMode === true && vm.model.value.length === 0) || vm.singleMode !== true)"
id="{{vm.model.alias}}"
type="button"
class="btn-reset umb-media-card-grid__create-button umb-outline"
ng-disabled="!vm.allowAddMedia"
ng-click="vm.addMediaAt(vm.model.value.length, $event)">
<div>
<umb-icon icon="icon-add" class="icon large"></umb-icon>
<localize key="general_add">Add</localize>
</div>
</button>
<div class="umb-media-card-grid" ng-class="{'--singleMode':(vm.validationLimit.max === 1 && vm.model.value.length <= 1)}">
<div style="display:contents;" ui-sortable="vm.sortableOptions" ng-model="vm.model.value" ng-if="vm.loading !== true">
<div ng-repeat="media in vm.model.value track by media.key" class="umb-media-card-grid__cell">
<button type="button"
ng-if="vm.loading !== true && vm.allowAdd && ((vm.singleMode === true && vm.model.value.length === 0) || vm.singleMode === false)"
class="btn-reset umb-media-card-grid--inline-create-button"
ng-click="vm.addMediaAt($index, $event)"
ng-controller="Umbraco.PropertyEditors.MediaPicker3PropertyEditor.CreateButtonController as inlineCreateButtonCtrl"
ng-mousemove="inlineCreateButtonCtrl.onMouseMove($event)"
ng-disabled="!vm.allowAddMedia">
<div class="__plus" ng-style="{'top':inlineCreateButtonCtrl.plusPosY}">
<umb-icon icon="icon-add" class="icon"></umb-icon>
</div>
</button>
<div ng-if="!media.mediaKey" class="umb-media-card">
<div class="__showcase">
<div ng-if="media.$uploadProgress < 100" class="__upload-progress-container">
<div class="__upload-progress">
<umb-progress-bar class="__upload-progress-bar" percentage="{{media.$uploadProgress}}"></umb-progress-bar>
<div class="__upload-progress-label" ng-if="media.$uploadProgress === 0">Waiting...</div>
<div class="__upload-progress-label" ng-if="media.$uploadProgress > 0">{{media.$uploadProgress}}%</div>
</div>
</div>
<img
ng-if="media.$dataURL"
ng-src="{{media.$dataURL}}"
title="{{media.name}}"
alt="{{media.name}}" />
<umb-file-icon ng-if="!media.$dataURL && media.$icon"
icon="{{media.$icon}}"
size="s"
text="{{media.name}}"
extension="{{media.$extension}}">
</umb-file-icon>
<button
type="button"
class="btn-reset __info"
ng-click="vm.editMedia(media, $index, $event)"
ng-disabled="media.$uploadProgress < 100">
<div class="__name" ng-bind="media.name"></div>
</button>
<div class="__actions">
<button
ng-if="vm.allowRemoveMedia"
type="button" class="btn-reset __action umb-outline"
localize="title"
title="general_remove"
ng-click="vm.removeMedia(media); $event.stopPropagation();"
ng-disabled="media.$uploadProgress < 100">
<umb-icon icon="icon-trash" class="icon"></umb-icon>
</button>
</div>
</div>
</div>
<umb-media-card
ng-if="media.mediaKey"
media-key="media.mediaKey"
on-name-clicked="vm.editMedia(media, $index, $event)"
ng-class="{'--active': vm.activeMediaEntry === media}"
has-error="vm.propertyForm.maxCount.$valid === false && $index >= vm.validationLimit.max"
allowed-types="vm.allowedTypes">
<div class="__actions">
<button ng-if="vm.supportCopy" type="button" class="btn-reset __action umb-outline" localize="title" title="actions_copy" ng-click="vm.copyMedia(media); $event.stopPropagation();">
<umb-icon icon="icon-documents" class="icon"></umb-icon>
</button>
<button ng-if="vm.allowRemoveMedia" type="button" class="btn-reset __action umb-outline" localize="title" title="general_remove" ng-click="vm.removeMedia(media); $event.stopPropagation();">
<umb-icon icon="icon-trash" class="icon"></umb-icon>
</button>
</div>
</umb-media-card>
</div>
</div>
<button ng-if="vm.loading !== true && ((vm.singleMode === true && vm.model.value.length === 0) || vm.singleMode !== true)"
id="{{vm.model.alias}}"
type="button"
class="btn-reset umb-media-card-grid__create-button umb-outline"
ng-disabled="!vm.allowAddMedia"
ng-click="vm.addMediaAt(vm.model.value.length, $event)">
<div>
<umb-icon icon="icon-add" class="icon large"></umb-icon>
<localize key="general_add">Add</localize>
</div>
</button>
</div>
</div>
<div class="help" ng-repeat="invalidEntry in vm.invalidEntries track by invalidEntry.key">
<span>{{ invalidEntry.name }}</span>
<span ng-if="invalidEntry.error" class="text-error">{{ invalidEntry.errorText }}</span>
</div>
<input type="hidden" name="modelValue" ng-model="vm.model.value" val-server="value" />

View File

@@ -18,4 +18,41 @@ umb-media-picker3-property-editor[readonly] {
border-color: @inputReadonlyBorderColor;
}
}
}
}
.umb-mediapicker3 {
.dropzone {
position: relative;
.drop-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 12px;
background-color: rgba(27,38,79,.2);
z-index: 1;
backdrop-filter: blur(0px);
opacity: 0;
pointer-events: none;
transition: backdrop-filter 200ms ease-in-out, opacity 200ms ease-in-out;
margin: -1px;
}
&.drag-over {
.drop-overlay {
backdrop-filter: blur(2px);
opacity: 1;
pointer-events: all;
border: 2px solid @blueExtraDark;
}
}
};
}

View File

@@ -17,7 +17,8 @@
controller: MediaPicker3Controller,
controllerAs: "vm",
bindings: {
model: "="
model: "=",
node: "="
},
require: {
propertyForm: "^form",
@@ -28,7 +29,10 @@
}
});
function MediaPicker3Controller($scope, editorService, clipboardService, localizationService, overlayService, userService, entityResource, $attrs) {
function MediaPicker3Controller($scope, editorService, clipboardService, localizationService, overlayService, userService, entityResource, $attrs, umbRequestHelper, $injector, uploadTracker) {
const mediaUploader = $injector.instantiate(Utilities.MediaUploader);
let uploadInProgress = false;
var unsubscribe = [];
@@ -47,10 +51,15 @@
vm.allowRemoveMedia = true;
vm.allowEditMedia = true;
vm.handleFiles = handleFiles;
vm.invalidEntries = [];
vm.addMediaAt = addMediaAt;
vm.editMedia = editMedia;
vm.removeMedia = removeMedia;
vm.copyMedia = copyMedia;
vm.allowDir = true;
vm.labels = {};
@@ -70,7 +79,6 @@
});
vm.$onInit = function() {
vm.validationLimit = vm.model.config.validationLimit || {};
// If single-mode we only allow 1 item as the maximum:
if(vm.model.config.multiple === false) {
@@ -80,6 +88,17 @@
vm.singleMode = vm.validationLimit.max === 1;
vm.allowedTypes = vm.model.config.filter ? vm.model.config.filter.split(",") : null;
const uploaderOptions = {
uploadURL: umbRequestHelper.getApiUrl("mediaPickerThreeBaseUrl", "uploadMedia"),
allowedMediaTypeAliases: vm.allowedTypes
};
unsubscribe.push(mediaUploader.on('mediaEntryAccepted', _handleMediaEntryAccepted));
unsubscribe.push(mediaUploader.on('mediaEntryRejected', _handleMediaEntryRejected));
unsubscribe.push(mediaUploader.on('queueStarted', _handleMediaQueueStarted));
unsubscribe.push(mediaUploader.on('uploadSuccess', _handleMediaUploadSuccess));
unsubscribe.push(mediaUploader.on('queueCompleted', _handleMediaQueueCompleted));
copyAllMediasAction = {
labelKey: "clipboard_labelForCopyAllEntries",
labelTokens: [vm.model.label],
@@ -135,11 +154,51 @@
vm.allowEdit = hasAccessToMedia;
vm.allowAdd = hasAccessToMedia;
vm.loading = false;
mediaUploader.init(uploaderOptions).then(() => {
vm.loading = false;
});
});
};
function handleFiles (files, invalidFiles) {
if (vm.readonly) return;
const allFiles = [...files, ...invalidFiles];
mediaUploader.requestUpload(allFiles);
};
function _handleMediaEntryAccepted (event, data) {
vm.model.value.push(data.mediaEntry);
setDirty();
}
function _handleMediaEntryRejected (event, data) {
// we need to make sure the media entry hasn't been accepted earlier in process
const index = vm.model.value.findIndex(mediaEntry => mediaEntry.key === data.mediaEntry.key);
if (index !== -1) {
vm.model.value.splice(index, 1);
}
vm.invalidEntries.push(data.mediaEntry);
setDirty();
}
function _handleMediaUploadSuccess (event, data) {
const mediaEntry = vm.model.value.find(mediaEntry => mediaEntry.key === data.mediaEntry.key);
if (!mediaEntry) return;
mediaEntry.tmpLocation = data.tmpLocation;
updateMediaEntryData(mediaEntry);
}
function _handleMediaQueueStarted () {
uploadInProgress = true;
uploadTracker.uploadStarted(vm.node.key);
}
function _handleMediaQueueCompleted () {
uploadInProgress = false;
uploadTracker.uploadEnded(vm.node.key);
}
function onServerValueChanged(newVal, oldVal) {
if(newVal === null || !Array.isArray(newVal)) {
newVal = [];
@@ -430,7 +489,7 @@
vm.sortableOptions = {
cursor: "grabbing",
handle: "umb-media-card",
handle: "umb-media-card, .umb-media-card",
cancel: "input,textarea,select,option",
classes: ".umb-media-card--dragging",
distance: 5,
@@ -469,6 +528,10 @@
for (const subscription of unsubscribe) {
subscription();
}
if (uploadInProgress) {
uploadTracker.uploadEnded(vm.node.key);
}
});
}