diff --git a/src/Umbraco.Core/IO/SystemDirectories.cs b/src/Umbraco.Core/IO/SystemDirectories.cs index a0fa732c8c..46c5bf851c 100644 --- a/src/Umbraco.Core/IO/SystemDirectories.cs +++ b/src/Umbraco.Core/IO/SystemDirectories.cs @@ -36,6 +36,8 @@ namespace Umbraco.Core.IO public static string Scripts => IOHelper.ReturnPath("umbracoScriptsPath", "~/scripts"); + public static string StyleSheets => IOHelper.ReturnPath("umbracoStylesheetsPath", "~/css"); + public static string Umbraco => IOHelper.ReturnPath("umbracoPath", "~/umbraco"); //TODO: Consider removing this diff --git a/src/Umbraco.Core/Models/Stylesheet.cs b/src/Umbraco.Core/Models/Stylesheet.cs index a228b70105..87632fac27 100644 --- a/src/Umbraco.Core/Models/Stylesheet.cs +++ b/src/Umbraco.Core/Models/Stylesheet.cs @@ -21,7 +21,7 @@ namespace Umbraco.Core.Models { } internal Stylesheet(string path, Func getFileContent) - : base(path.EnsureEndsWith(".css"), getFileContent) + : base(string.IsNullOrEmpty(path) ? path : path.EnsureEndsWith(".css"), getFileContent) { InitializeProperties(); } diff --git a/src/Umbraco.Core/Strings/Css/StylesheetRule.cs b/src/Umbraco.Core/Strings/Css/StylesheetRule.cs index e97e6cff1e..6f91906250 100644 --- a/src/Umbraco.Core/Strings/Css/StylesheetRule.cs +++ b/src/Umbraco.Core/Strings/Css/StylesheetRule.cs @@ -20,8 +20,19 @@ namespace Umbraco.Core.Strings.Css sb.Append("*/"); sb.Append(Environment.NewLine); sb.Append(Selector); - sb.Append("{"); - sb.Append(Styles.IsNullOrWhiteSpace() ? "" : Styles.Trim()); + sb.Append(" {"); + sb.Append(Environment.NewLine); + // append nicely formatted style rules + // - using tabs because the back office code editor uses tabs + if (Styles.IsNullOrWhiteSpace() == false) + { + // since we already have a string builder in play here, we'll append to it the "hard" way + // instead of using string interpolation (for increased performance) + foreach (var style in Styles.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append("\t").Append(style.StripNewLines().Trim()).Append(";").Append(Environment.NewLine); + } + } sb.Append("}"); return sb.ToString(); diff --git a/src/Umbraco.Tests/Strings/StylesheetHelperTests.cs b/src/Umbraco.Tests/Strings/StylesheetHelperTests.cs index c2e17c7755..5ae4c0511f 100644 --- a/src/Umbraco.Tests/Strings/StylesheetHelperTests.cs +++ b/src/Umbraco.Tests/Strings/StylesheetHelperTests.cs @@ -131,5 +131,84 @@ world */p{font-size: 1em;}")] // Assert Assert.IsTrue(results.Any() == false); } + + [Test] + public void AppendRules_IsFormatted() + { + // base CSS + var css = Tabbed( +@"body { +#font-family:Arial; +}"); + // add a couple of rules + var result = StylesheetHelper.AppendRule(css, new StylesheetRule + { + Name = "Test", + Selector = ".test", + Styles = "font-color: red;margin: 1rem;" + }); + result = StylesheetHelper.AppendRule(result, new StylesheetRule + { + Name = "Test2", + Selector = ".test2", + Styles = "font-color: green;" + }); + + // verify the CSS formatting including the indents + Assert.AreEqual(Tabbed( +@"body { +#font-family:Arial; +} + +/**umb_name:Test*/ +.test { +#font-color: red; +#margin: 1rem; +} + +/**umb_name:Test2*/ +.test2 { +#font-color: green; +}"), result + ); + } + + [Test] + public void ParseFormattedRules_CanParse() + { + // base CSS + var css = Tabbed( +@"body { +#font-family:Arial; +} + +/**umb_name:Test*/ +.test { +#font-color: red; +#margin: 1rem; +} + +/**umb_name:Test2*/ +.test2 { +#font-color: green; +}"); + var rules = StylesheetHelper.ParseRules(css); + Assert.AreEqual(2, rules.Count()); + + Assert.AreEqual("Test", rules.First().Name); + Assert.AreEqual(".test", rules.First().Selector); + Assert.AreEqual( +@"font-color: red; +margin: 1rem;", rules.First().Styles); + + Assert.AreEqual("Test2", rules.Last().Name); + Assert.AreEqual(".test2", rules.Last().Selector); + Assert.AreEqual("font-color: green;", rules.Last().Styles); + } + + // can't put tabs in verbatim strings, so this will replace # with \t to test the CSS indents + // - and it's tabs because the editor uses tabs, not spaces... + private static string Tabbed(string input) => input.Replace("#", "\t"); + } } diff --git a/src/Umbraco.Web.UI.Client/gulpfile.js b/src/Umbraco.Web.UI.Client/gulpfile.js index d1bbfe65db..b773025e7c 100644 --- a/src/Umbraco.Web.UI.Client/gulpfile.js +++ b/src/Umbraco.Web.UI.Client/gulpfile.js @@ -159,10 +159,13 @@ gulp.task('dependencies', function () { "./node_modules/ace-builds/src-min-noconflict/ext-settings_menu.js", "./node_modules/ace-builds/src-min-noconflict/snippets/text.js", "./node_modules/ace-builds/src-min-noconflict/snippets/javascript.js", + "./node_modules/ace-builds/src-min-noconflict/snippets/css.js", "./node_modules/ace-builds/src-min-noconflict/theme-chrome.js", "./node_modules/ace-builds/src-min-noconflict/mode-razor.js", "./node_modules/ace-builds/src-min-noconflict/mode-javascript.js", - "./node_modules/ace-builds/src-min-noconflict/worker-javascript.js" + "./node_modules/ace-builds/src-min-noconflict/mode-css.js", + "./node_modules/ace-builds/src-min-noconflict/worker-javascript.js", + "./node_modules/ace-builds/src-min-noconflict/worker-css.js" ], "base": "./node_modules/ace-builds" }, diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index b21255a85f..c1d42c249e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -149,6 +149,7 @@ @import "components/umb-box.less"; @import "components/umb-number-badge.less"; @import "components/umb-progress-circle.less"; +@import "components/umb-stylesheet.less"; @import "components/buttons/umb-button.less"; @import "components/buttons/umb-button-group.less"; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-stylesheet.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-stylesheet.less new file mode 100644 index 0000000000..31824590e8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-stylesheet.less @@ -0,0 +1,46 @@ +.umb-stylesheet-rules { + max-width: 600px; +} + +.umb-stylesheet-rules__listitem { + display: flex; + padding: 6px; + margin: 10px 0 !important; + background: @gray-10; + cursor: pointer; +} + +.umb-stylesheet-rules__listitem i { + display: flex; + align-items: center; + margin-right: 5px; + cursor: move; +} + +.umb-stylesheet-rules__listitem a { + margin-left: auto; +} + +.umb-stylesheet-rules__listitem input { + width: 295px; +} + +.umb-stylesheet-rules__left { + display: flex; + flex: 1 1 auto; + overflow: hidden; +} + +.umb-stylesheet-rules__right { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.umb-stylesheet-rule-overlay { + textarea { + width: 300px; + height: 120px; + resize: none; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/create.controller.js b/src/Umbraco.Web.UI.Client/src/views/stylesheets/create.controller.js new file mode 100644 index 0000000000..229587cc63 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/create.controller.js @@ -0,0 +1,18 @@ +(function () { + "use strict"; + + function StyleSheetsCreateController($scope, $location, navigationService) { + + var vm = this; + var node = $scope.dialogOptions.currentNode; + + vm.createFile = createFile; + + function createFile() { + $location.path("/settings/stylesheets/edit/" + node.id).search("create", "true"); + navigationService.hideMenu(); + } + } + + angular.module("umbraco").controller("Umbraco.Editors.StyleSheets.CreateController", StyleSheetsCreateController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/create.html b/src/Umbraco.Web.UI.Client/src/views/stylesheets/create.html new file mode 100644 index 0000000000..82854635f9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/create.html @@ -0,0 +1,23 @@ + + + diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/delete.controller.js b/src/Umbraco.Web.UI.Client/src/views/stylesheets/delete.controller.js new file mode 100644 index 0000000000..cdfe92d105 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/delete.controller.js @@ -0,0 +1,29 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.StyleSheets.DeleteController + * @function + * + * @description + * The controller for deleting stylesheets + */ +function StyleSheetsDeleteController($scope, codefileResource, treeService, navigationService) { + + $scope.performDelete = function() { + + //mark it for deletion (used in the UI) + $scope.currentNode.loading = true; + + codefileResource.deleteByPath('stylesheets', $scope.currentNode.id) + .then(function() { + $scope.currentNode.loading = false; + treeService.removeNode($scope.currentNode); + navigationService.hideMenu(); + }); + }; + + $scope.cancel = function() { + navigationService.hideDialog(); + }; +} + +angular.module("umbraco").controller("Umbraco.Editors.StyleSheets.DeleteController", StyleSheetsDeleteController); diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/delete.html b/src/Umbraco.Web.UI.Client/src/views/stylesheets/delete.html new file mode 100644 index 0000000000..fd6f6edd78 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/delete.html @@ -0,0 +1,12 @@ +
+
+ +

+ Are you sure you want to delete {{currentNode.name}} ? +

+ + + + +
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/stylesheets/edit.controller.js new file mode 100644 index 0000000000..2705294d54 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/edit.controller.js @@ -0,0 +1,303 @@ +(function () { + "use strict"; + + function StyleSheetsEditController($scope, $routeParams, $timeout, $http, appState, editorState, navigationService, assetsService, codefileResource, contentEditingHelper, notificationsService, localizationService, templateHelper, angularHelper, umbRequestHelper) { + + var vm = this; + + vm.page = {}; + vm.page.loading = true; + vm.page.menu = {}; + vm.page.menu.currentSection = appState.getSectionState("currentSection"); + vm.page.menu.currentNode = null; + vm.page.saveButtonState = "init"; + + localizationService.localizeMany(["stylesheet_tabCode", "stylesheet_tabRules"]).then(function (data) { + vm.page.navigation = [ + { + "name": data[0], + "alias": "code", + "icon": "icon-brackets", + "view": "views/stylesheets/views/code/code.html", + "active": true + }, + { + "name": data[1], + "alias": "rules", + "icon": "icon-font", + "view": "views/stylesheets/views/rules/rules.html" + } + ]; + }); + + //Used to toggle the keyboard shortcut modal + //From a custom keybinding in ace editor - that conflicts with our own to show the dialog + vm.showKeyboardShortcut = false; + + //Keyboard shortcuts for help dialog + vm.page.keyboardShortcutsOverview = []; + + templateHelper.getGeneralShortcuts().then(function(shortcuts){ + vm.page.keyboardShortcutsOverview.push(shortcuts); + }); + + templateHelper.getEditorShortcuts().then(function(shortcuts){ + vm.page.keyboardShortcutsOverview.push(shortcuts); + }); + + vm.stylesheet = {}; + + // bind functions to view model + vm.save = interpolateAndSave; + + /* Function bound to view model */ + + function interpolateAndSave() { + vm.page.saveButtonState = "busy"; + + var activeApp = _.find(vm.page.navigation, function(item) { + return item.active; + }); + + if (activeApp.alias === "rules") { + // we're on the rules tab: interpolate the rules into the editor value and save the output as stylesheet content + interpolateRules().then( + function (content) { + vm.stylesheet.content = content; + save(activeApp); + }, + function(err) { + } + ); + } else { + // we're on the code tab: just save the editor value as stylesheet content + vm.stylesheet.content = vm.editor.getValue(); + save(activeApp); + } + } + + /* Local functions */ + + function save(activeApp) { + contentEditingHelper.contentEditorPerformSave({ + saveMethod: codefileResource.save, + scope: $scope, + content: vm.stylesheet, + // We do not redirect on failure for style sheets - this is because it is not possible to actually save the style sheet + // when server side validation fails - as opposed to content where we are capable of saving the content + // item if server side validation fails + redirectOnFailure: false, + rebindCallback: function (orignal, saved) {} + }).then(function (saved) { + + localizationService.localizeMany(["speechBubbles_fileSavedHeader", "speechBubbles_fileSavedText"]).then(function(data){ + var header = data[0]; + var message = data[1]; + notificationsService.success(header, message); + }); + + //check if the name changed, if so we need to redirect + if (vm.stylesheet.id !== saved.id) { + contentEditingHelper.redirectToRenamedContent(saved.id); + } + else { + vm.page.saveButtonState = "success"; + vm.stylesheet = saved; + + //sync state + editorState.set(vm.stylesheet); + + // sync tree + navigationService.syncTree({ tree: "stylesheets", path: vm.stylesheet.path, forceReload: true }).then(function (syncArgs) { + vm.page.menu.currentNode = syncArgs.node; + }); + + if (activeApp.alias === "rules") { + $scope.selectApp(activeApp); + } + } + + }, function (err) { + + vm.page.saveButtonState = "error"; + + localizationService.localizeMany(["speechBubbles_validationFailedHeader", "speechBubbles_validationFailedMessage"]).then(function(data){ + var header = data[0]; + var message = data[1]; + notificationsService.error(header, message); + }); + + }); + + + } + + function init() { + + //we need to load this somewhere, for now its here. + assetsService.loadCss("lib/ace-razor-mode/theme/razor_chrome.css", $scope); + + if ($routeParams.create) { + codefileResource.getScaffold("stylesheets", $routeParams.id).then(function (stylesheet) { + ready(stylesheet, false); + }); + } else { + codefileResource.getByPath('stylesheets', $routeParams.id).then(function (stylesheet) { + ready(stylesheet, true); + }); + } + + } + + function ready(stylesheet, syncTree) { + + vm.page.loading = false; + + vm.stylesheet = stylesheet; + + vm.setDirty = function () { + setFormState("dirty"); + } + + //sync state + editorState.set(vm.stylesheet); + + if (syncTree) { + navigationService.syncTree({ tree: "stylesheets", path: vm.stylesheet.path, forceReload: true }).then(function (syncArgs) { + vm.page.menu.currentNode = syncArgs.node; + }); + } + + vm.aceOption = { + mode: "css", + theme: "chrome", + showPrintMargin: false, + advanced: { + fontSize: '14px', + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: false + }, + onLoad: function(_editor) { + + vm.editor = _editor; + + //Update the auto-complete method to use ctrl+alt+space + _editor.commands.bindKey("ctrl-alt-space", "startAutocomplete"); + + //Unassigns the keybinding (That was previously auto-complete) + //As conflicts with our own tree search shortcut + _editor.commands.bindKey("ctrl-space", null); + + //TODO: Move all these keybinding config out into some helper/service + _editor.commands.addCommands([ + //Disable (alt+shift+K) + //Conflicts with our own show shortcuts dialog - this overrides it + { + name: 'unSelectOrFindPrevious', + bindKey: 'Alt-Shift-K', + exec: function() { + //Toggle the show keyboard shortcuts overlay + $scope.$apply(function(){ + vm.showKeyboardShortcut = !vm.showKeyboardShortcut; + }); + }, + readOnly: true + } + ]); + + // initial cursor placement + // Keep cursor in name field if we are create a new style sheet + // else set the cursor at the bottom of the code editor + if(!$routeParams.create) { + $timeout(function(){ + vm.editor.navigateFileEnd(); + vm.editor.focus(); + }); + } + + vm.editor.on("change", changeAceEditor); + + } + } + + function changeAceEditor() { + setFormState("dirty"); + } + + function setFormState(state) { + + // get the current form + var currentForm = angularHelper.getCurrentForm($scope); + + // set state + if(state === "dirty") { + currentForm.$setDirty(); + } else if(state === "pristine") { + currentForm.$setPristine(); + } + } + } + + function interpolateRules() { + var payload = { + content: vm.stylesheet.content, + rules: vm.stylesheet.rules + }; + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "codeFileApiBaseUrl", + "PostInterpolateStylesheetRules"), + payload), + "Failed to interpolate sheet rules"); + } + + function extractRules() { + var payload = { + content: vm.stylesheet.content, + rules: null + }; + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "codeFileApiBaseUrl", + "PostExtractStylesheetRules"), + payload), + "Failed to extract style sheet rules"); + } + + $scope.selectApp = function (app) { + vm.page.loading = true; + + // are we going to the code tab? + if (app.alias === "code") { + // yes - interpolate the rules into the current editor value before displaying the editor + interpolateRules().then( + function(content) { + vm.stylesheet.content = content; + vm.page.loading = false; + }, + function(err) { + } + ); + } + else { + // no - extract the rules from the current editor value before displaying the rules tab + extractRules().then( + function(rules) { + vm.stylesheet.rules = rules; + vm.page.loading = false; + }, + function(err) { + } + ); + } + } + + init(); + + } + + angular.module("umbraco").controller("Umbraco.Editors.StyleSheets.EditController", StyleSheetsEditController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/edit.html b/src/Umbraco.Web.UI.Client/src/views/stylesheets/edit.html new file mode 100644 index 0000000000..7daac56b38 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/edit.html @@ -0,0 +1,56 @@ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/code/code.html b/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/code/code.html new file mode 100644 index 0000000000..ce8b2c8b2f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/code/code.html @@ -0,0 +1,9 @@ + + +
+
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/rules/rules.controller.js b/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/rules/rules.controller.js new file mode 100644 index 0000000000..28d1724b64 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/rules/rules.controller.js @@ -0,0 +1,70 @@ +angular.module("umbraco").controller("Umbraco.Editors.StyleSheets.RulesController", + function ($scope, localizationService) { + $scope.sortableOptions = { + axis: 'y', + containment: 'parent', + cursor: 'move', + items: 'div.umb-stylesheet-rules__listitem', + handle: '.handle', + tolerance: 'pointer', + update: function (e, ui) { + setDirty(); + } + }; + + $scope.add = function (evt) { + evt.preventDefault(); + + openOverlay({}, $scope.labels.addRule, (newRule) => { + $scope.model.stylesheet.rules.push(newRule); + setDirty(); + }); + } + + $scope.edit = function(rule, evt) { + evt.preventDefault(); + + openOverlay(rule, $scope.labels.editRule, (newRule) => { + rule.name = newRule.name; + rule.selector = newRule.selector; + rule.styles = newRule.styles; + setDirty(); + }); + } + + $scope.remove = function (rule, evt) { + evt.preventDefault(); + + $scope.model.stylesheet.rules = _.without($scope.model.stylesheet.rules, rule); + setDirty(); + } + + function openOverlay(rule, title, onSubmit) { + $scope.model.overlay = { + title: title, + submit: function (model) { + onSubmit(model.rule); + $scope.model.overlay = null; + }, + close: function (oldModel) { + $scope.model.overlay = null; + }, + rule: _.clone(rule) + }; + } + + function setDirty() { + $scope.model.setDirty(); + } + + function init() { + localizationService.localizeMany(["stylesheet_overlayTitleAddRule", "stylesheet_overlayTitleEditRule"]).then(function (data) { + $scope.labels = { + addRule: data[0], + editRule: data[1] + } + }); + } + + init(); + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/rules/rules.html b/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/rules/rules.html new file mode 100644 index 0000000000..17b52f064e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/stylesheets/views/rules/rules.html @@ -0,0 +1,41 @@ + + +
+
+
Rich text editor styles
+ Define the styles that should be available in the rich text editor for this stylesheet +
+
+
+
+
+ +
+ {{rule.name}} +
+
+ Remove +
+
+
+
+ +
+
+
+
+ + + + + + + + + + + + + +
+
diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml index e624e22b75..7646851cd7 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml @@ -281,6 +281,7 @@ Ny partial view fra snippet Ny partial view makro fra snippet Ny partial view makro (uden makro) + Ny stylesheet-fil Til dit website @@ -1037,12 +1038,19 @@ Mange hilsner fra Umbraco robotten %0% er nu låst op - Bruger CSS-syntaks f.eks. h1, .redheader, .blueTex + Teksteditor-styles + Definér de styles, der skal være tilgængelige i teksteditoren for dette stylesheet + Selector + Bruger CSS-syntaks, f.eks. "h1" eller ".redheader" Rediger stylesheet Rediger CSS-egenskab - Navn der identificerer CSS-egenskaben i tekstredigeringsværktøjet - Forhåndsvisning + Tilføj style + Redigér style + Det navn der vises i teksteditoren Styles + Den CSS der skal anvendes i teksteditoren, f.eks. "color:red;" + Kode + Editor Rediger skabelon @@ -1427,4 +1435,4 @@ Mange hilsner fra Umbraco robotten Der findes ikke nogen "Genopret" relation for dette dokument/medie. Brug "Flyt" muligheden fra menuen for at flytte det manuelt. Det dokument/medie du ønsker at genoprette under ('%0%') er i skraldespanden. Brug "Flyt" muligheden fra menuen for at flytte det manuelt. - \ No newline at end of file + diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml index 4abfdff0fb..11a32a9703 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml @@ -295,6 +295,7 @@ New partial view from snippet New partial view macro from snippet New partial view macro (without macro) + New style sheet file Browse your website @@ -1317,12 +1318,19 @@ To manage your website, simply open the Umbraco back office and start adding con An error occurred while exporting the document type - Uses CSS syntax ex: h1, .redHeader, .blueTex + Rich text editor styles + Define the styles that should be available in the rich text editor for this stylesheet Edit stylesheet Edit stylesheet property - Name to identify the style property in the rich text editor - Preview + The name displayed in the editor style selector + Add style + Edit style + Selector + Uses CSS syntax, e.g. "h1" or ".redHeader" Styles + The CSS that should be applied in the rich text editor, e.g. "color:red;" + Code + Editor Edit template diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml index 91d09b2380..6a29fc0c0c 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml @@ -315,6 +315,7 @@ New partial view from snippet New partial view macro from snippet New partial view macro (without macro) + New style sheet file Browse your website @@ -1340,12 +1341,19 @@ To manage your website, simply open the Umbraco back office and start adding con An error occurred while exporting the document type - Uses CSS syntax ex: h1, .redHeader, .blueTex + Rich text editor styles + Define the styles that should be available in the rich text editor for this stylesheet Edit stylesheet Edit stylesheet property - Name to identify the style property in the rich text editor - Preview + The name displayed in the editor style selector + Add style + Edit style + Selector + Uses CSS syntax, e.g. "h1" or ".redHeader" Styles + The CSS that should be applied in the rich text editor, e.g. "color:red;" + Code + Editor Edit template diff --git a/src/Umbraco.Web/Editors/CodeFileController.cs b/src/Umbraco.Web/Editors/CodeFileController.cs index edcd71b2e8..7dc797f9de 100644 --- a/src/Umbraco.Web/Editors/CodeFileController.cs +++ b/src/Umbraco.Web/Editors/CodeFileController.cs @@ -11,12 +11,15 @@ using Umbraco.Core; using Umbraco.Core.IO; using Umbraco.Core.Models; using Umbraco.Core.Services; +using Umbraco.Core.Strings.Css; using Umbraco.Web.Composing; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; using Umbraco.Web.Trees; +using Stylesheet = Umbraco.Core.Models.Stylesheet; +using StylesheetRule = Umbraco.Web.Models.ContentEditing.StylesheetRule; namespace Umbraco.Web.Editors { @@ -121,7 +124,7 @@ namespace Umbraco.Web.Editors /// /// Used to get a specific file from disk via the FileService /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' /// The filename or urlencoded path of the file to open /// The file and its contents from the virtualPath public CodeFileDisplay GetByPath(string type, string virtualPath) @@ -168,6 +171,18 @@ namespace Umbraco.Web.Editors return display; } throw new HttpResponseException(HttpStatusCode.NotFound); + + case Core.Constants.Trees.Stylesheets: + var stylesheet = Services.FileService.GetStylesheetByName(virtualPath); + if (stylesheet != null) + { + var display = Mapper.Map(stylesheet); + display.FileType = Core.Constants.Trees.Stylesheets; + display.Path = Url.GetTreePathFromFilePath(stylesheet.Path); + display.Id = System.Web.HttpUtility.UrlEncode(stylesheet.Path); + return display; + } + throw new HttpResponseException(HttpStatusCode.NotFound); } throw new HttpResponseException(HttpStatusCode.NotFound); @@ -204,9 +219,9 @@ namespace Umbraco.Web.Editors } /// - /// Used to scaffold the json object for the editors for 'scripts', 'partialViews', 'partialViewMacros' + /// Used to scaffold the json object for the editors for 'scripts', 'partialViews', 'partialViewMacros' and 'stylesheets' /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' /// /// /// @@ -235,6 +250,10 @@ namespace Umbraco.Web.Editors codeFileDisplay = Mapper.Map(new Script(string.Empty)); codeFileDisplay.VirtualPath = SystemDirectories.Scripts; break; + case Core.Constants.Trees.Stylesheets: + codeFileDisplay = Mapper.Map(new Stylesheet(string.Empty)); + codeFileDisplay.VirtualPath = SystemDirectories.StyleSheets; + break; default: throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, "Unsupported editortype")); } @@ -257,7 +276,7 @@ namespace Umbraco.Web.Editors /// /// Used to delete a specific file from disk via the FileService /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' /// The filename or urlencoded path of the file to delete /// Will return a simple 200 if file deletion succeeds [HttpDelete] @@ -308,6 +327,14 @@ namespace Umbraco.Web.Editors } return Request.CreateErrorResponse(HttpStatusCode.NotFound, "No Script or folder found with the specified path"); + case Core.Constants.Trees.Stylesheets: + if (Services.FileService.GetStylesheetByName(virtualPath) != null) + { + Services.FileService.DeleteStylesheet(virtualPath, Security.CurrentUser.Id); + return Request.CreateResponse(HttpStatusCode.OK); + } + return Request.CreateErrorResponse(HttpStatusCode.NotFound, "No Stylesheet found with the specified path"); + default: return Request.CreateResponse(HttpStatusCode.NotFound); } @@ -316,7 +343,7 @@ namespace Umbraco.Web.Editors } /// - /// Used to create or update a 'partialview', 'partialviewmacro' or 'script' file + /// Used to create or update a 'partialview', 'partialviewmacro', 'script' or 'stylesheets' file /// /// /// The updated CodeFileDisplay model @@ -369,13 +396,17 @@ namespace Umbraco.Web.Editors display.Id = System.Web.HttpUtility.UrlEncode(scriptResult.Path); return display; - //display.AddErrorNotification( - // Services.TextService.Localize("speechBubbles/partialViewErrorHeader"), - // Services.TextService.Localize("speechBubbles/partialViewErrorText")); - - + //display.AddErrorNotification( + // Services.TextService.Localize("speechBubbles/partialViewErrorHeader"), + // Services.TextService.Localize("speechBubbles/partialViewErrorText")); + case Core.Constants.Trees.Stylesheets: + var stylesheetResult = CreateOrUpdateStylesheet(display); + display = Mapper.Map(stylesheetResult, display); + display.Path = Url.GetTreePathFromFilePath(stylesheetResult.Path); + display.Id = System.Web.HttpUtility.UrlEncode(stylesheetResult.Path); + return display; default: throw new HttpResponseException(HttpStatusCode.NotFound); @@ -384,6 +415,66 @@ namespace Umbraco.Web.Editors return display; } + /// + /// Extracts "umbraco style rules" from a style sheet + /// + /// The style sheet data + /// The style rules + public StylesheetRule[] PostExtractStylesheetRules(StylesheetData data) + { + if (data.Content.IsNullOrWhiteSpace()) + { + return new StylesheetRule[0]; + } + + return StylesheetHelper.ParseRules(data.Content)?.Select(rule => new StylesheetRule + { + Name = rule.Name, + Selector = rule.Selector, + Styles = rule.Styles + }).ToArray(); + } + + /// + /// Creates a style sheet from CSS and style rules + /// + /// The style sheet data + /// The style sheet combined from the CSS and the rules + /// + /// Any "umbraco style rules" in the CSS will be removed and replaced with the rules passed in + /// + public string PostInterpolateStylesheetRules(StylesheetData data) + { + // first remove all existing rules + var existingRules = data.Content.IsNullOrWhiteSpace() + ? new Core.Strings.Css.StylesheetRule[0] + : StylesheetHelper.ParseRules(data.Content).ToArray(); + foreach (var rule in existingRules) + { + data.Content = StylesheetHelper.ReplaceRule(data.Content, rule.Name, null); + } + + data.Content = data.Content.TrimEnd('\n', '\r'); + + // now add all the posted rules + if (data.Rules != null && data.Rules.Any()) + { + foreach (var rule in data.Rules) + { + data.Content = StylesheetHelper.AppendRule(data.Content, new Core.Strings.Css.StylesheetRule + { + Name = rule.Name, + Selector = rule.Selector, + Styles = rule.Styles + }); + } + + data.Content += Environment.NewLine; + } + + return data.Content; + } + /// /// Create or Update a Script /// @@ -394,15 +485,33 @@ namespace Umbraco.Web.Editors /// use a normal file system because they must exist on a real file system for ASP.NET to work. /// private Script CreateOrUpdateScript(CodeFileDisplay display) + { + return CreateOrUpdateFile(display, ".js", Current.FileSystems.ScriptsFileSystem, + name => Services.FileService.GetScriptByName(name), + (script, userId) => Services.FileService.SaveScript(script, userId), + name => new Script(name)); + } + + private Stylesheet CreateOrUpdateStylesheet(CodeFileDisplay display) + { + return CreateOrUpdateFile(display, ".css", Current.FileSystems.StylesheetsFileSystem, + name => Services.FileService.GetStylesheetByName(name), + (stylesheet, userId) => Services.FileService.SaveStylesheet(stylesheet, userId), + name => new Stylesheet(name) + ); + } + + private T CreateOrUpdateFile(CodeFileDisplay display, string extension, IFileSystem fileSystem, + Func getFileByName, Action saveFile, Func createFile) where T : Core.Models.File { //must always end with the correct extension - display.Name = EnsureCorrectFileExtension(display.Name, ".js"); + display.Name = EnsureCorrectFileExtension(display.Name, extension); var virtualPath = display.VirtualPath ?? string.Empty; // this is all weird, should be using relative paths everywhere! - var relPath = Current.FileSystems.ScriptsFileSystem.GetRelativePath(virtualPath); + var relPath = fileSystem.GetRelativePath(virtualPath); - if (relPath.EndsWith(".js") == false) + if (relPath.EndsWith(extension) == false) { //this would typically mean it's new relPath = relPath.IsNullOrWhiteSpace() @@ -410,25 +519,25 @@ namespace Umbraco.Web.Editors : relPath.EnsureEndsWith('/') + display.Name; } - var script = Services.FileService.GetScriptByName(relPath); - if (script != null) + var file = getFileByName(relPath); + if (file != null) { // might need to find the path - var orgPath = script.OriginalPath.Substring(0, script.OriginalPath.IndexOf(script.Name)); - script.Path = orgPath + display.Name; + var orgPath = file.OriginalPath.Substring(0, file.OriginalPath.IndexOf(file.Name)); + file.Path = orgPath + display.Name; - script.Content = display.Content; + file.Content = display.Content; //try/catch? since this doesn't return an Attempt? - Services.FileService.SaveScript(script, Security.CurrentUser.Id); + saveFile(file, Security.CurrentUser.Id); } else { - script = new Script(relPath); - script.Content = display.Content; - Services.FileService.SaveScript(script, Security.CurrentUser.Id); + file = createFile(relPath); + file.Content = display.Content; + saveFile(file, Security.CurrentUser.Id); } - return script; + return file; } private Attempt CreateOrUpdatePartialView(CodeFileDisplay display) @@ -511,5 +620,13 @@ namespace Umbraco.Web.Editors var dirInfo = new DirectoryInfo(path); return dirInfo.Attributes == FileAttributes.Directory; } + + // this is an internal class for passing stylesheet data from the client to the controller while editing + public class StylesheetData + { + public string Content { get; set; } + + public StylesheetRule[] Rules { get; set; } + } } } diff --git a/src/Umbraco.Web/Editors/StylesheetController.cs b/src/Umbraco.Web/Editors/StylesheetController.cs index 99ab0add34..9785644d3f 100644 --- a/src/Umbraco.Web/Editors/StylesheetController.cs +++ b/src/Umbraco.Web/Editors/StylesheetController.cs @@ -32,4 +32,4 @@ namespace Umbraco.Web.Editors } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Web/Models/ContentEditing/StylesheetRule.cs b/src/Umbraco.Web/Models/ContentEditing/StylesheetRule.cs index 9219cce4f5..b3212445ae 100644 --- a/src/Umbraco.Web/Models/ContentEditing/StylesheetRule.cs +++ b/src/Umbraco.Web/Models/ContentEditing/StylesheetRule.cs @@ -16,5 +16,7 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "selector")] public string Selector { get; set; } + [DataMember(Name = "styles")] + public string Styles { get; set; } } } diff --git a/src/Umbraco.Web/Models/Mapping/CodeFileMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/CodeFileMapperProfile.cs index 082abfdace..94c43f8f11 100644 --- a/src/Umbraco.Web/Models/Mapping/CodeFileMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/CodeFileMapperProfile.cs @@ -1,6 +1,7 @@ using AutoMapper; using Umbraco.Core.Models; using Umbraco.Web.Models.ContentEditing; +using Stylesheet = Umbraco.Core.Models.Stylesheet; namespace Umbraco.Web.Models.Mapping { @@ -20,6 +21,12 @@ namespace Umbraco.Web.Models.Mapping .ForMember(dest => dest.Path, opt => opt.Ignore()) .ForMember(dest => dest.Snippet, opt => opt.Ignore()); + CreateMap() + .ForMember(dest => dest.FileType, opt => opt.Ignore()) + .ForMember(dest => dest.Notifications, opt => opt.Ignore()) + .ForMember(dest => dest.Path, opt => opt.Ignore()) + .ForMember(dest => dest.Snippet, opt => opt.Ignore()); + CreateMap() .IgnoreEntityCommonProperties() .ForMember(dest => dest.Id, opt => opt.Ignore())