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:
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
src/Umbraco.Core/Services/ITemporaryMediaService.cs
Normal file
8
src/Umbraco.Core/Services/ITemporaryMediaService.cs
Normal 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);
|
||||
}
|
||||
94
src/Umbraco.Core/Services/TemporaryMediaService.cs
Normal file
94
src/Umbraco.Core/Services/TemporaryMediaService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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!))
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<umb-media-picker3-property-editor
|
||||
model="model"
|
||||
node="node"
|
||||
ng-attr-readonly="{{ readonly || undefined }}">
|
||||
</umb-media-picker3-property-editor>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user